Merge pull request #4402 from sbwalker/dev

search modifications
This commit is contained in:
Shaun Walker 2024-07-12 10:33:34 -04:00 committed by GitHub
commit 7abc2289de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 144 additions and 306 deletions

View File

@ -4,7 +4,6 @@ using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Oqtane.Documentation;
using Oqtane.Enums; using Oqtane.Enums;
using Oqtane.Infrastructure; using Oqtane.Infrastructure;
using Oqtane.Services; using Oqtane.Services;
@ -28,7 +27,7 @@ namespace Oqtane.Controllers
{ {
try try
{ {
return await _searchService.SearchAsync(searchQuery); return await _searchService.GetSearchResultsAsync(searchQuery);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@ -58,6 +58,8 @@ namespace Oqtane.Infrastructure
var currentTime = DateTime.UtcNow; var currentTime = DateTime.UtcNow;
var lastIndexedOn = Convert.ToDateTime(siteSettings.GetValue(SearchLastIndexedOnSetting, DateTime.MinValue.ToString())); var lastIndexedOn = Convert.ToDateTime(siteSettings.GetValue(SearchLastIndexedOnSetting, DateTime.MinValue.ToString()));
log += $"Index Date: {lastIndexedOn}<br />";
var ignorePaths = siteSettings.GetValue(SearchIgnorePathsSetting, "").Split(','); var ignorePaths = siteSettings.GetValue(SearchIgnorePathsSetting, "").Split(',');
var ignoreEntities = siteSettings.GetValue(SearchIgnoreEntitiesSetting, "").Split(','); var ignoreEntities = siteSettings.GetValue(SearchIgnoreEntitiesSetting, "").Split(',');
@ -68,7 +70,7 @@ namespace Oqtane.Infrastructure
// index pages // index pages
foreach (var page in pages) foreach (var page in pages)
{ {
if (Constants.InternalPagePaths.Contains(page.Path) || ignorePaths.Contains(page.Path)) if (!string.IsNullOrEmpty(page.Path) && (Constants.InternalPagePaths.Contains(page.Path) || ignorePaths.Contains(page.Path)))
{ {
continue; continue;
} }
@ -169,9 +171,8 @@ namespace Oqtane.Infrastructure
} }
} }
// save search content // save search contents
await searchService.SaveSearchContentAsync(searchContents, siteSettings); log += await searchService.SaveSearchContentsAsync(searchContents, siteSettings);
log += $"Index Date: {lastIndexedOn}<br />";
log += $"Items Indexed: {searchContents.Count}<br />"; log += $"Items Indexed: {searchContents.Count}<br />";
// update last indexed on // update last indexed on

View File

@ -71,7 +71,7 @@ namespace Oqtane.SiteTemplates
Content = "<p>Copyright (c) 2018-2024 .NET Foundation</p>" + Content = "<p>Copyright (c) 2018-2024 .NET Foundation</p>" +
"<p>Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:</p>" + "<p>Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:</p>" +
"<p>The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.</p>" + "<p>The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.</p>" +
"<p>THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.</p>" "<p>THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.</p>"
}, },
new PageTemplateModule { ModuleDefinitionName = "Oqtane.Modules.HtmlText, Oqtane.Client", Title = "Secure Content", Pane = PaneNames.Default, new PageTemplateModule { ModuleDefinitionName = "Oqtane.Modules.HtmlText, Oqtane.Client", Title = "Secure Content", Pane = PaneNames.Default,
PermissionList = new List<Permission> { PermissionList = new List<Permission> {

View File

@ -1,60 +0,0 @@
using System;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Oqtane.Models;
using Oqtane.Repository;
using Oqtane.Security;
using Oqtane.Services;
using Oqtane.Shared;
namespace Oqtane.Managers.Search
{
public class ModuleSearchResultManager : ISearchResultManager
{
public string Name => EntityNames.Module;
private readonly IServiceProvider _serviceProvider;
public ModuleSearchResultManager(
IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public string GetUrl(SearchResult searchResult, SearchQuery searchQuery)
{
var pageRepository = _serviceProvider.GetRequiredService<IPageRepository>();
var pageIdValue = searchResult.SearchContentProperties?.FirstOrDefault(i => i.Name == Constants.SearchPageIdPropertyName)?.Value ?? string.Empty;
if(!string.IsNullOrEmpty(pageIdValue) && int.TryParse(pageIdValue, out int pageId))
{
var page = pageRepository.GetPage(pageId);
if (page != null)
{
return $"{searchQuery.Alias.Protocol}{searchQuery.Alias.Name}{(!string.IsNullOrEmpty(page.Path) && !page.Path.StartsWith("/") ? "/" : "")}{page.Path}";
}
}
return string.Empty;
}
public bool Visible(SearchContent searchResult, SearchQuery searchQuery)
{
var pageIdValue = searchResult.SearchContentProperties?.FirstOrDefault(i => i.Name == Constants.SearchPageIdPropertyName)?.Value ?? string.Empty;
if (!string.IsNullOrEmpty(pageIdValue) && int.TryParse(pageIdValue, out int pageId))
{
return CanViewPage(pageId, searchQuery.User);
}
return false;
}
private bool CanViewPage(int pageId, User user)
{
var pageRepository = _serviceProvider.GetRequiredService<IPageRepository>();
var page = pageRepository.GetPage(pageId);
return page != null && !page.IsDeleted && UserSecurity.IsAuthorized(user, PermissionNames.View, page.PermissionList)
&& (Utilities.IsEffectiveOrExpired(page.EffectiveDate, page.ExpiryDate) || UserSecurity.IsAuthorized(user, PermissionNames.Edit, page.PermissionList));
}
}
}

View File

@ -28,105 +28,10 @@ namespace Oqtane.Providers
_searchContentRepository = searchContentRepository; _searchContentRepository = searchContentRepository;
} }
public void Commit() public async Task<List<SearchResult>> GetSearchResultsAsync(SearchQuery searchQuery)
{ {
} var searchContents = await _searchContentRepository.GetSearchContentsAsync(searchQuery);
return searchContents.Select(item => ConvertToSearchResult(item, searchQuery)).ToList();
public void DeleteSearchContent(string id)
{
_searchContentRepository.DeleteSearchContent(id);
}
public bool Optimize()
{
return true;
}
public void ResetIndex()
{
_searchContentRepository.DeleteAllSearchContent();
}
public void SaveSearchContent(SearchContent searchContent, Dictionary<string, string> siteSettings, bool autoCommit = false)
{
// remove existing search content
_searchContentRepository.DeleteSearchContent(searchContent.EntityName, searchContent.EntityId);
if (!searchContent.IsDeleted)
{
// clean the search content to remove html tags
CleanSearchContent(searchContent);
_searchContentRepository.AddSearchContent(searchContent);
// save the index words
AnalyzeSearchContent(searchContent, siteSettings);
}
}
public async Task<SearchResults> SearchAsync(SearchQuery searchQuery, Func<SearchContent, SearchQuery, bool> validateFunc)
{
var totalResults = 0;
var searchContentList = await _searchContentRepository.GetSearchContentsAsync(searchQuery);
// convert the search content to search results
var results = searchContentList
.Where(i => validateFunc(i, searchQuery))
.Select(i => ConvertToSearchResult(i, searchQuery));
if (searchQuery.SortDirection == SearchSortDirections.Descending)
{
switch (searchQuery.SortField)
{
case SearchSortFields.Relevance:
results = results.OrderByDescending(i => i.Score).ThenByDescending(i => i.ContentModifiedOn);
break;
case SearchSortFields.Title:
results = results.OrderByDescending(i => i.Title).ThenByDescending(i => i.ContentModifiedOn);
break;
default:
results = results.OrderByDescending(i => i.ContentModifiedOn);
break;
}
}
else
{
switch (searchQuery.SortField)
{
case SearchSortFields.Relevance:
results = results.OrderBy(i => i.Score).ThenByDescending(i => i.ContentModifiedOn);
break;
case SearchSortFields.Title:
results = results.OrderBy(i => i.Title).ThenByDescending(i => i.ContentModifiedOn);
break;
default:
results = results.OrderBy(i => i.ContentModifiedOn);
break;
}
}
// remove duplicated results based on page id for Page and Module types
results = results.DistinctBy(i =>
{
if (i.EntityName == EntityNames.Page || i.EntityName == EntityNames.Module)
{
var pageId = i.SearchContentProperties.FirstOrDefault(p => p.Name == Constants.SearchPageIdPropertyName)?.Value ?? string.Empty;
return !string.IsNullOrEmpty(pageId) ? pageId : i.UniqueKey;
}
else
{
return i.UniqueKey;
}
});
totalResults = results.Count();
return new SearchResults
{
Results = results.Skip(searchQuery.PageIndex * searchQuery.PageSize).Take(searchQuery.PageSize).ToList(),
TotalResults = totalResults
};
} }
private SearchResult ConvertToSearchResult(SearchContent searchContent, SearchQuery searchQuery) private SearchResult ConvertToSearchResult(SearchContent searchContent, SearchQuery searchQuery)
@ -152,17 +57,6 @@ namespace Oqtane.Providers
return searchResult; return searchResult;
} }
private float CalculateScore(SearchContent searchContent, SearchQuery searchQuery)
{
var score = 0f;
foreach (var keyword in SearchUtils.GetKeywords(searchQuery.Keywords))
{
score += searchContent.SearchContentWords.Where(i => i.SearchWord.Word.StartsWith(keyword)).Sum(i => i.Count);
}
return score / 100;
}
private string BuildSnippet(SearchContent searchContent, SearchQuery searchQuery) private string BuildSnippet(SearchContent searchContent, SearchQuery searchQuery)
{ {
var content = $"{searchContent.Title} {searchContent.Description} {searchContent.Body}"; var content = $"{searchContent.Title} {searchContent.Description} {searchContent.Body}";
@ -189,8 +83,6 @@ namespace Oqtane.Providers
snippet = $"{prefix}{content.Substring(start, length)}{suffix}"; snippet = $"{prefix}{content.Substring(start, length)}{suffix}";
break; break;
} }
} }
@ -207,6 +99,36 @@ namespace Oqtane.Providers
return snippet; return snippet;
} }
private float CalculateScore(SearchContent searchContent, SearchQuery searchQuery)
{
var score = 0f;
foreach (var keyword in SearchUtils.GetKeywords(searchQuery.Keywords))
{
score += searchContent.SearchContentWords.Where(i => i.SearchWord.Word.StartsWith(keyword)).Sum(i => i.Count);
}
return score / 100;
}
public Task SaveSearchContent(SearchContent searchContent, Dictionary<string, string> siteSettings)
{
// remove existing search content
_searchContentRepository.DeleteSearchContent(searchContent.EntityName, searchContent.EntityId);
if (!searchContent.IsDeleted)
{
// clean the search content to remove html tags
CleanSearchContent(searchContent);
_searchContentRepository.AddSearchContent(searchContent);
// save the index words
AnalyzeSearchContent(searchContent, siteSettings);
}
return Task.CompletedTask;
}
private void AnalyzeSearchContent(SearchContent searchContent, Dictionary<string, string> siteSettings) private void AnalyzeSearchContent(SearchContent searchContent, Dictionary<string, string> siteSettings)
{ {
var ignoreWords = IgnoreWords.Split(','); var ignoreWords = IgnoreWords.Split(',');
@ -324,5 +246,11 @@ namespace Oqtane.Providers
return string.Join(" ", phrases); return string.Join(" ", phrases);
} }
public Task ResetIndex()
{
_searchContentRepository.DeleteAllSearchContent();
return Task.CompletedTask;
}
} }
} }

View File

@ -2,10 +2,8 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Oqtane.Infrastructure;
using Oqtane.Models; using Oqtane.Models;
using Oqtane.Repository; using Oqtane.Repository;
using Oqtane.Security; using Oqtane.Security;
@ -16,134 +14,89 @@ namespace Oqtane.Services
public class SearchService : ISearchService public class SearchService : ISearchService
{ {
private const string SearchProviderSettingName = "SearchProvider"; private const string SearchProviderSettingName = "SearchProvider";
private const string SearchEnabledSettingName = "SearchEnabled";
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly ITenantManager _tenantManager;
private readonly IAliasRepository _aliasRepository;
private readonly ISettingRepository _settingRepository; private readonly ISettingRepository _settingRepository;
private readonly IPermissionRepository _permissionRepository; private readonly IPermissionRepository _permissionRepository;
private readonly ILogger<SearchService> _logger; private readonly ILogger<SearchService> _logger;
private readonly IMemoryCache _cache;
public SearchService( public SearchService(
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
ITenantManager tenantManager,
IAliasRepository aliasRepository,
ISettingRepository settingRepository, ISettingRepository settingRepository,
IPermissionRepository permissionRepository, IPermissionRepository permissionRepository,
ILogger<SearchService> logger, ILogger<SearchService> logger)
IMemoryCache cache)
{ {
_tenantManager = tenantManager;
_aliasRepository = aliasRepository;
_settingRepository = settingRepository; _settingRepository = settingRepository;
_permissionRepository = permissionRepository; _permissionRepository = permissionRepository;
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_logger = logger; _logger = logger;
_cache = cache;
} }
public async Task<SearchResults> SearchAsync(SearchQuery searchQuery) public async Task<SearchResults> GetSearchResultsAsync(SearchQuery searchQuery)
{ {
var searchProvider = GetSearchProvider(searchQuery.SiteId); var searchProvider = GetSearchProvider(searchQuery.SiteId);
var searchResults = await searchProvider.SearchAsync(searchQuery, Visible); var searchResults = await searchProvider.GetSearchResultsAsync(searchQuery);
//generate the document url if it's not set. var totalResults = 0;
foreach (var result in searchResults.Results)
// trim results based on permissions
var results = searchResults.Where(i => IsVisible(i, searchQuery));
if (searchQuery.SortDirection == SearchSortDirections.Descending)
{ {
if(string.IsNullOrEmpty(result.Url)) switch (searchQuery.SortField)
{ {
result.Url = GetDocumentUrl(result, searchQuery); case SearchSortFields.Relevance:
results = results.OrderByDescending(i => i.Score).ThenByDescending(i => i.ContentModifiedOn);
break;
case SearchSortFields.Title:
results = results.OrderByDescending(i => i.Title).ThenByDescending(i => i.ContentModifiedOn);
break;
default:
results = results.OrderByDescending(i => i.ContentModifiedOn);
break;
}
}
else
{
switch (searchQuery.SortField)
{
case SearchSortFields.Relevance:
results = results.OrderBy(i => i.Score).ThenByDescending(i => i.ContentModifiedOn);
break;
case SearchSortFields.Title:
results = results.OrderBy(i => i.Title).ThenByDescending(i => i.ContentModifiedOn);
break;
default:
results = results.OrderBy(i => i.ContentModifiedOn);
break;
} }
} }
return searchResults; // remove duplicated results based on page id for Page and Module types
} results = results.DistinctBy(i =>
private ISearchProvider GetSearchProvider(int siteId)
{
var providerName = GetSearchProviderSetting(siteId);
var searchProviders = _serviceProvider.GetServices<ISearchProvider>();
var provider = searchProviders.FirstOrDefault(i => i.Name == providerName);
if(provider == null)
{ {
provider = searchProviders.FirstOrDefault(i => i.Name == Constants.DefaultSearchProviderName); if (i.EntityName == EntityNames.Page || i.EntityName == EntityNames.Module)
}
return provider;
}
private string GetSearchProviderSetting(int siteId)
{
var setting = _settingRepository.GetSetting(EntityNames.Site, siteId, SearchProviderSettingName);
if(!string.IsNullOrEmpty(setting?.SettingValue))
{
return setting.SettingValue;
}
return Constants.DefaultSearchProviderName;
}
private bool SearchEnabled(int siteId)
{
var setting = _settingRepository.GetSetting(EntityNames.Site, siteId, SearchEnabledSettingName);
if (!string.IsNullOrEmpty(setting?.SettingValue))
{
return bool.TryParse(setting.SettingValue, out bool enabled) && enabled;
}
return true;
}
private void SetTenant(int siteId)
{
var alias = _aliasRepository.GetAliases().OrderBy(i => i.SiteId).ThenByDescending(i => i.IsDefault).FirstOrDefault(i => i.SiteId == siteId);
_tenantManager.SetAlias(alias);
}
private List<ISearchResultManager> GetSearchResultManagers()
{
var managers = new List<ISearchResultManager>();
var managerTypes = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(s => s.GetTypes())
.Where(p => typeof(ISearchResultManager).IsAssignableFrom(p) && !p.IsInterface && !p.IsAbstract);
foreach (var type in managerTypes)
{
var manager = (ISearchResultManager)ActivatorUtilities.CreateInstance(_serviceProvider, type);
managers.Add(manager);
}
return managers.ToList();
}
public async Task SaveSearchContentAsync(List<SearchContent> searchContents, Dictionary<string, string> siteSettings)
{
if(searchContents.Any())
{
var searchProvider = GetSearchProvider(searchContents.First().SiteId);
foreach (var searchContent in searchContents)
{ {
try var pageId = i.SearchContentProperties.FirstOrDefault(p => p.Name == Constants.SearchPageIdPropertyName)?.Value ?? string.Empty;
{ return !string.IsNullOrEmpty(pageId) ? pageId : i.UniqueKey;
searchProvider.SaveSearchContent(searchContent, siteSettings);
}
catch(Exception ex)
{
_logger.LogError(ex, $"Search: Save search content {searchContent.UniqueKey} failed.");
}
} }
else
{
return i.UniqueKey;
}
});
await Task.CompletedTask; totalResults = results.Count();
//commit the index changes return new SearchResults
searchProvider.Commit(); {
} Results = results.Skip(searchQuery.PageIndex * searchQuery.PageSize).Take(searchQuery.PageSize).ToList(),
TotalResults = totalResults
};
} }
private bool Visible(SearchContent searchContent, SearchQuery searchQuery) private bool IsVisible(SearchContent searchContent, SearchQuery searchQuery)
{ {
var visible = true; var visible = true;
foreach (var permission in searchContent.Permissions.Split(',')) foreach (var permission in searchContent.Permissions.Split(','))
@ -165,15 +118,52 @@ namespace Oqtane.Services
return UserSecurity.IsAuthorized(user, PermissionNames.View, permissions); return UserSecurity.IsAuthorized(user, PermissionNames.View, permissions);
} }
private string GetDocumentUrl(SearchResult result, SearchQuery searchQuery) public async Task<string> SaveSearchContentsAsync(List<SearchContent> searchContents, Dictionary<string, string> siteSettings)
{ {
var searchResultManager = GetSearchResultManagers().FirstOrDefault(i => i.Name == result.EntityName); var result = "";
if(searchResultManager != null)
if (searchContents.Any())
{ {
return searchResultManager.GetUrl(result, searchQuery); var searchProvider = GetSearchProvider(searchContents.First().SiteId);
foreach (var searchContent in searchContents)
{
try
{
await searchProvider.SaveSearchContent(searchContent, siteSettings);
}
catch (Exception ex)
{
result += $"Error Saving Search Content With UniqueKey {searchContent.UniqueKey} - {ex.Message}<br />";
}
}
} }
return string.Empty; return result;
}
private ISearchProvider GetSearchProvider(int siteId)
{
var providerName = GetSearchProviderSetting(siteId);
var searchProviders = _serviceProvider.GetServices<ISearchProvider>();
var provider = searchProviders.FirstOrDefault(i => i.Name == providerName);
if (provider == null)
{
provider = searchProviders.FirstOrDefault(i => i.Name == Constants.DefaultSearchProviderName);
}
return provider;
}
private string GetSearchProviderSetting(int siteId)
{
var setting = _settingRepository.GetSetting(EntityNames.Site, siteId, SearchProviderSettingName);
if (!string.IsNullOrEmpty(setting?.SettingValue))
{
return setting.SettingValue;
}
return Constants.DefaultSearchProviderName;
} }
} }
} }

View File

@ -11,16 +11,10 @@ namespace Oqtane.Services
{ {
string Name { get; } string Name { get; }
void SaveSearchContent(SearchContent searchContent, Dictionary<string, string> siteSettings, bool autoCommit = false); Task<List<SearchResult>> GetSearchResultsAsync(SearchQuery searchQuery);
void DeleteSearchContent(string id); Task SaveSearchContent(SearchContent searchContent, Dictionary<string, string> siteSettings);
Task<SearchResults> SearchAsync(SearchQuery searchQuery, Func<SearchContent, SearchQuery, bool> validateFunc);
bool Optimize(); Task ResetIndex();
void Commit();
void ResetIndex();
} }
} }

View File

@ -1,14 +0,0 @@
using System;
using Oqtane.Models;
namespace Oqtane.Services
{
public interface ISearchResultManager
{
string Name { get; }
bool Visible(SearchContent searchResult, SearchQuery searchQuery);
string GetUrl(SearchResult searchResult, SearchQuery searchQuery);
}
}

View File

@ -6,8 +6,8 @@ namespace Oqtane.Services
{ {
public interface ISearchService public interface ISearchService
{ {
Task SaveSearchContentAsync(List<SearchContent> searchContents, Dictionary<string, string> siteSettings); Task<SearchResults> GetSearchResultsAsync(SearchQuery searchQuery);
Task<SearchResults> SearchAsync(SearchQuery searchQuery); Task<string> SaveSearchContentsAsync(List<SearchContent> searchContents, Dictionary<string, string> siteSettings);
} }
} }