Merge pull request #4304 from zyhfish/task/add-search-function

#4303: add search function.
This commit is contained in:
Shaun Walker 2024-06-04 16:52:10 -04:00 committed by GitHub
commit d449396ad5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 2572 additions and 4 deletions

View File

@ -0,0 +1,135 @@
@using Microsoft.AspNetCore.Http
@using Oqtane.Services
@using System.Net
@namespace Oqtane.Modules.Admin.SearchResults
@inherits ModuleBase
@inject ISearchResultsService SearchResultsService
@inject IStringLocalizer<Index> Localizer
@inject IHttpContextAccessor HttpContext
<div class="search-result-container">
<div class="row">
<div class="col">
<form method="post" @formname="SearchInputForm" @onsubmit="@(async () => await Search())" data-enhance>
<div class="input-group mb-3">
<span class="input-group-text">@Localizer["SearchPrefix"]</span>
<input type="hidden" name="@Constants.RequestVerificationToken" value="@SiteState.AntiForgeryToken" />
<input type="text" name="keywords" class="form-control shadow-none" maxlength="50"
aria-label="Keywords"
placeholder="@Localizer["SearchPlaceholder"]"
@bind-value="_keywords">
<button class="btn btn-primary shadow-none" type="submit">@Localizer["Search"]</button>
</div>
</form>
</div>
</div>
<div class="row">
<div class="col-md-12 mb-3">
@if (_loading)
{
<div class="app-progress-indicator"></div>
}
else
{
@if (_searchResults != null && _searchResults.Results != null)
{
if (_searchResults.Results.Any())
{
<Pager Items="@_searchResults?.Results"
Format="Grid"
PageSize="@_pageSize.ToString()"
DisplayPages="@_displayPages.ToString()"
CurrentPage="@_currentPage.ToString()"
Columns="1"
Toolbar="Bottom"
Parameters="@($"q={_keywords}")">
<Row>
<div class="search-item">
<h4 class="mb-1"><a href="@context.Url">@context.Title</a></h4>
<p class="mb-0 text-muted">@((MarkupString)context.Snippet)</p>
</div>
</Row>
</Pager>
}
else
{
<div class="alert alert-info show mt-3" role="alert">
@Localizer["NoResult"]
</div>
}
}
<div class="clearfix"></div>
}
</div>
</div>
</div>
@code {
public override string RenderMode => RenderModes.Static;
private const int SearchDefaultPageSize = 10;
private SearchSortDirections _searchSortDirection = SearchSortDirections.Descending; //default sort by
private SearchSortFields _searchSortField = SearchSortFields.Relevance;
private string _keywords;
private bool _loading;
private SearchResults _searchResults;
private int _currentPage = 0;
private int _pageSize = SearchDefaultPageSize;
private int _displayPages = 7;
protected override async Task OnInitializedAsync()
{
if (ModuleState.Settings.ContainsKey("PageSize"))
{
_pageSize = int.Parse(ModuleState.Settings["PageSize"]);
}
if (PageState.QueryString.ContainsKey("q"))
{
_keywords = WebUtility.UrlDecode(PageState.QueryString["q"]);
}
if (!string.IsNullOrEmpty(_keywords))
{
await PerformSearch();
}
}
private async Task Search()
{
_keywords = HttpContext.HttpContext.Request.Form["keywords"];
if (string.IsNullOrEmpty(_keywords))
{
AddModuleMessage(Localizer["MissingKeywords"], MessageType.Warning);
}
else
{
ClearModuleMessage();
_currentPage = 0;
await PerformSearch();
}
}
private async Task PerformSearch()
{
_loading = true;
StateHasChanged();
var searchQuery = new SearchQuery
{
SiteId = PageState.Site.SiteId,
Alias = PageState.Alias,
User = PageState.User,
Keywords = _keywords,
SortDirection = _searchSortDirection,
SortField = _searchSortField,
PageIndex = 0,
PageSize = int.MaxValue
};
_searchResults = await SearchResultsService.SearchAsync(ModuleState.ModuleId, searchQuery);
_loading = false;
StateHasChanged();
}
}

View File

@ -0,0 +1,24 @@
using System.Collections.Generic;
using Oqtane.Documentation;
using Oqtane.Models;
using Oqtane.Shared;
namespace Oqtane.Modules.Admin.SearchResults
{
[PrivateApi("Mark this as private, since it's not very useful in the public docs")]
public class ModuleInfo : IModule
{
public ModuleDefinition ModuleDefinition => new ModuleDefinition
{
Name = "Search Results",
Description = "Display Search Results",
Version = Constants.Version,
ServerManagerType = "",
SettingsType = "Oqtane.Modules.Admin.SearchResults.Settings, Oqtane.Client",
Resources = new List<Resource>()
{
new Resource { ResourceType = ResourceType.Stylesheet, Url = "~/Module.css" }
}
};
}
}

View File

@ -0,0 +1,48 @@
@namespace Oqtane.Modules.Admin.SearchResults
@inherits ModuleBase
@implements Oqtane.Interfaces.ISettingsControl
@inject ISettingService SettingService
@inject IStringLocalizer<Settings> Localizer
@inject IStringLocalizer<SharedResources> SharedLocalizer
<div class="container">
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="pageSize" ResourceKey="PageSize" ResourceType="@resourceType">Page Size:</Label>
<div class="col-sm-9">
<input name="pageSize" id="pageSize" class="form-control" type="number" @bind="_pageSize" />
</div>
</div>
</div>
@code {
private const string SearchDefaultPageSize = "10";
private string resourceType = "Oqtane.Modules.Admin.SearchResults.Settings, Oqtane.Client"; // for localization
private string _pageSize;
protected override void OnInitialized()
{
try
{
_pageSize = SettingService.GetSetting(ModuleState.Settings, "PageSize", SearchDefaultPageSize);
}
catch (Exception ex)
{
AddModuleMessage(ex.Message, MessageType.Error);
}
}
public async Task UpdateSettings()
{
try
{
var settings = await SettingService.GetModuleSettingsAsync(ModuleState.ModuleId);
settings = SettingService.SetSetting(settings, "PageSize", _pageSize);
await SettingService.UpdateModuleSettingsAsync(settings, ModuleState.ModuleId);
}
catch (Exception ex)
{
AddModuleMessage(ex.Message, MessageType.Error);
}
}
}

View File

@ -0,0 +1,156 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Ascending" xml:space="preserve">
<value>Ascending</value>
</data>
<data name="Descending" xml:space="preserve">
<value>Descending</value>
</data>
<data name="MissingKeywords" xml:space="preserve">
<value>Please provide the search keywords.</value>
</data>
<data name="ModificationTime" xml:space="preserve">
<value>Modification Time</value>
</data>
<data name="NoResult" xml:space="preserve">
<value>No results found.</value>
</data>
<data name="Relevance" xml:space="preserve">
<value>Relevance</value>
</data>
<data name="Search" xml:space="preserve">
<value>Search</value>
</data>
<data name="SearchPlaceholder" xml:space="preserve">
<value>Search</value>
</data>
<data name="SearchPrefix" xml:space="preserve">
<value>Search:</value>
</data>
<data name="SortDirection" xml:space="preserve">
<value>Sort Direction</value>
</data>
<data name="SortField" xml:space="preserve">
<value>Sort By</value>
</data>
<data name="Title" xml:space="preserve">
<value>Title</value>
</data>
</root>

View File

@ -0,0 +1,123 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="PageSize" xml:space="preserve">
<value>Page Size</value>
</data>
</root>

View File

@ -0,0 +1,126 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Search" xml:space="preserve">
<value>Search</value>
</data>
<data name="SearchPlaceHolder" xml:space="preserve">
<value>Search</value>
</data>
</root>

View File

@ -0,0 +1,13 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Oqtane.Documentation;
using Oqtane.Models;
namespace Oqtane.Services
{
[PrivateApi("Mark SearchResults classes as private, since it's not very useful in the public docs")]
public interface ISearchResultsService
{
Task<SearchResults> SearchAsync(int moduleId, SearchQuery searchQuery);
}
}

View File

@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using Oqtane.Documentation;
using Oqtane.Models;
using Oqtane.Modules;
using Oqtane.Shared;
namespace Oqtane.Services
{
[PrivateApi("Don't show in the documentation, as everything should use the Interface")]
public class SearchResultsService : ServiceBase, ISearchResultsService, IClientService
{
public SearchResultsService(HttpClient http, SiteState siteState) : base(http, siteState) { }
private string ApiUrl => CreateApiUrl("SearchResults");
public async Task<SearchResults> SearchAsync(int moduleId, SearchQuery searchQuery)
{
return await PostJsonAsync<SearchQuery, SearchResults>(CreateAuthorizationPolicyUrl(ApiUrl, EntityNames.Module, moduleId), searchQuery);
}
}
}

View File

@ -9,7 +9,9 @@
<div class="row flex-xl-nowrap gx-0">
<div class="sidebar">
<nav class="navbar">
<Logo /><Menu Orientation="Vertical" />
<Logo />
<Menu Orientation="Vertical" />
<Search CssClass="px-3" />
</nav>
</div>

View File

@ -0,0 +1,61 @@
@namespace Oqtane.Themes.Controls
@using System.Net
@using Microsoft.AspNetCore.Http
@inherits ThemeControlBase
@inject IStringLocalizer<Search> Localizer
@inject NavigationManager NavigationManager
@inject IHttpContextAccessor HttpContext
@if (_searchResultsPage != null)
{
<span class="app-search @CssClass">
<form method="post" class="app-form-inline" @formname="SearchForm" @onsubmit="@PerformSearch" data-enhance>
<input type="hidden" name="@Constants.RequestVerificationToken" value="@SiteState.AntiForgeryToken" />
<input type="text" name="keywords" maxlength="50"
class="form-control d-inline-block pe-5 shadow-none"
@bind-value="_keywords"
placeholder="@Localizer["SearchPlaceHolder"]"
aria-label="Search" />
<button type="submit" class="btn btn-search">
<span class="oi oi-magnifying-glass align-middle"></span>
</button>
</form>
</span>
}
@code {
private Page _searchResultsPage;
private string _keywords = "";
[Parameter]
public string CssClass { get; set; }
[Parameter]
public string SearchResultPagePath { get; set; } = "search";
protected override void OnInitialized()
{
if(!string.IsNullOrEmpty(SearchResultPagePath))
{
_searchResultsPage = PageState.Pages.FirstOrDefault(i => i.Path == SearchResultPagePath);
}
}
protected override void OnParametersSet()
{
}
private void PerformSearch()
{
var keywords = HttpContext.HttpContext.Request.Form["keywords"];
if (!string.IsNullOrEmpty(keywords) && _searchResultsPage != null)
{
var url = NavigateUrl(_searchResultsPage.Path, $"q={keywords}");
NavigationManager.NavigateTo(url);
}
}
}

View File

@ -6,7 +6,12 @@
<nav class="navbar navbar-dark bg-primary fixed-top">
<Logo /><Menu Orientation="Horizontal" />
<div class="controls ms-auto">
<div class="controls-group"><UserProfile ShowRegister="@_register" /> <Login ShowLogin="@_login" /> <ControlPanel LanguageDropdownAlignment="right" /></div>
<div class="controls-group">
<UserProfile ShowRegister="@_register" />
<Login ShowLogin="@_login" />
<Search CssClass="me-3" />
<ControlPanel LanguageDropdownAlignment="right" />
</div>
</div>
</nav>
<div class="content">

View File

@ -0,0 +1,41 @@
using System;
using System.Net;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Oqtane.Documentation;
using Oqtane.Enums;
using Oqtane.Infrastructure;
using Oqtane.Services;
using Oqtane.Shared;
namespace Oqtane.Controllers
{
[Route(ControllerRoutes.ApiRoute)]
public class SearchResultsController : ModuleControllerBase
{
private readonly ISearchService _searchService;
public SearchResultsController(ISearchService searchService, ILogManager logger, IHttpContextAccessor accessor) : base(logger, accessor)
{
_searchService = searchService;
}
[HttpPost]
[Authorize(Policy = PolicyNames.ViewModule)]
public async Task<Models.SearchResults> Post([FromBody] Models.SearchQuery searchQuery)
{
try
{
return await _searchService.SearchAsync(searchQuery);
}
catch (Exception ex)
{
_logger.Log(LogLevel.Error, this, LogFunction.Other, ex, "Fetch search results failed.", searchQuery);
HttpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest;
return null;
}
}
}
}

View File

@ -21,6 +21,7 @@ using Oqtane.Infrastructure;
using Oqtane.Infrastructure.Interfaces;
using Oqtane.Managers;
using Oqtane.Modules;
using Oqtane.Providers;
using Oqtane.Repository;
using Oqtane.Security;
using Oqtane.Services;
@ -97,6 +98,9 @@ namespace Microsoft.Extensions.DependencyInjection
services.AddScoped<IUrlMappingService, UrlMappingService>();
services.AddScoped<IVisitorService, VisitorService>();
services.AddScoped<ISyncService, SyncService>();
services.AddScoped<ISearchResultsService, SearchResultsService>();
services.AddScoped<ISearchService, SearchService>();
services.AddScoped<ISearchProvider, DatabaseSearchProvider>();
return services;
}
@ -131,6 +135,7 @@ namespace Microsoft.Extensions.DependencyInjection
services.AddTransient<ILanguageRepository, LanguageRepository>();
services.AddTransient<IVisitorRepository, VisitorRepository>();
services.AddTransient<IUrlMappingRepository, UrlMappingRepository>();
services.AddTransient<ISearchContentRepository, SearchContentRepository>();
// managers
services.AddTransient<IDBContextDependencies, DBContextDependencies>();

View File

@ -0,0 +1,90 @@
using System;
using System.Linq;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Oqtane.Models;
using Oqtane.Repository;
using Oqtane.Services;
using Oqtane.Shared;
namespace Oqtane.Infrastructure
{
public class SearchIndexJob : HostedServiceBase
{
private const string SearchIndexStartTimeSettingName = "SearchIndex_StartTime";
public SearchIndexJob(IServiceScopeFactory serviceScopeFactory) : base(serviceScopeFactory)
{
Name = "Search Index Job";
Frequency = "m"; // run every minute.
Interval = 1;
IsEnabled = true;
}
public override string ExecuteJob(IServiceProvider provider)
{
// get services
var siteRepository = provider.GetRequiredService<ISiteRepository>();
var settingRepository = provider.GetRequiredService<ISettingRepository>();
var logRepository = provider.GetRequiredService<ILogRepository>();
var searchService = provider.GetRequiredService<ISearchService>();
var sites = siteRepository.GetSites().ToList();
var logs = new StringBuilder();
foreach (var site in sites)
{
var startTime = GetSearchStartTime(site.SiteId, settingRepository);
logs.AppendLine($"Search: Begin index site: {site.Name}<br />");
var currentTime = DateTime.UtcNow;
searchService.IndexContent(site.SiteId, startTime, logNote =>
{
logs.AppendLine(logNote);
}, handleError =>
{
logs.AppendLine(handleError);
});
UpdateSearchStartTime(site.SiteId, currentTime, settingRepository);
logs.AppendLine($"Search: End index site: {site.Name}<br />");
}
return logs.ToString();
}
private DateTime? GetSearchStartTime(int siteId, ISettingRepository settingRepository)
{
var setting = settingRepository.GetSetting(EntityNames.Site, siteId, SearchIndexStartTimeSettingName);
if(setting == null)
{
return null;
}
return Convert.ToDateTime(setting.SettingValue);
}
private void UpdateSearchStartTime(int siteId, DateTime startTime, ISettingRepository settingRepository)
{
var setting = settingRepository.GetSetting(EntityNames.Site, siteId, SearchIndexStartTimeSettingName);
if (setting == null)
{
setting = new Setting
{
EntityName = EntityNames.Site,
EntityId = siteId,
SettingName = SearchIndexStartTimeSettingName,
SettingValue = Convert.ToString(startTime),
};
settingRepository.AddSetting(setting);
}
else
{
setting.SettingValue = Convert.ToString(startTime);
settingRepository.UpdateSetting(setting);
}
}
}
}

View File

@ -133,6 +133,30 @@ namespace Oqtane.SiteTemplates
}
}
});
_pageTemplates.Add(new PageTemplate
{
Name = "Search Results",
Parent = "",
Order = 7,
Path = "search",
Icon = "oi oi-magnifying-glass",
IsNavigation = false,
IsPersonalizable = false,
PermissionList = new List<Permission> {
new Permission(PermissionNames.View, RoleNames.Everyone, true),
new Permission(PermissionNames.View, RoleNames.Admin, true),
new Permission(PermissionNames.Edit, RoleNames.Admin, true)
},
PageTemplateModules = new List<PageTemplateModule> {
new PageTemplateModule { ModuleDefinitionName = "Oqtane.Modules.Admin.SearchResults, Oqtane.Client", Title = "Search Results", Pane = PaneNames.Default,
PermissionList = new List<Permission> {
new Permission(PermissionNames.View, RoleNames.Everyone, true),
new Permission(PermissionNames.View, RoleNames.Admin, true),
new Permission(PermissionNames.Edit, RoleNames.Admin, true)
}
}
}
});
if (System.IO.File.Exists(Path.Combine(_environment.WebRootPath, "images", "logo-white.png")))
{

View File

@ -66,6 +66,9 @@ namespace Oqtane.Infrastructure
case "5.1.0":
Upgrade_5_1_0(tenant, scope);
break;
case "5.2.0":
Upgrade_5_2_0(tenant, scope);
break;
}
}
}
@ -385,5 +388,48 @@ namespace Oqtane.Infrastructure
}
}
private void Upgrade_5_2_0(Tenant tenant, IServiceScope scope)
{
CreateSearchResultsPages(tenant, scope);
}
private void CreateSearchResultsPages(Tenant tenant, IServiceScope scope)
{
var pageTemplates = new List<PageTemplate>();
pageTemplates.Add(new PageTemplate
{
Name = "Search Results",
Parent = "",
Path = "search",
Icon = "oi oi-magnifying-glass",
IsNavigation = false,
IsPersonalizable = false,
PermissionList = new List<Permission> {
new Permission(PermissionNames.View, RoleNames.Everyone, true),
new Permission(PermissionNames.View, RoleNames.Admin, true),
new Permission(PermissionNames.Edit, RoleNames.Admin, true)
},
PageTemplateModules = new List<PageTemplateModule> {
new PageTemplateModule { ModuleDefinitionName = "Oqtane.Modules.Admin.SearchResults, Oqtane.Client", Title = "Search Results", Pane = PaneNames.Default,
PermissionList = new List<Permission> {
new Permission(PermissionNames.View, RoleNames.Everyone, true),
new Permission(PermissionNames.View, RoleNames.Admin, true),
new Permission(PermissionNames.Edit, RoleNames.Admin, true)
}
}
}
});
var pages = scope.ServiceProvider.GetRequiredService<IPageRepository>();
var sites = scope.ServiceProvider.GetRequiredService<ISiteRepository>();
foreach (var site in sites.GetSites().ToList())
{
if (!pages.GetPages(site.SiteId).ToList().Where(item => item.Path == "search").Any())
{
sites.CreatePages(site, pageTemplates, null);
}
}
}
}
}

View File

@ -0,0 +1,146 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Oqtane.Interfaces;
using Oqtane.Models;
using Oqtane.Repository;
using Oqtane.Shared;
namespace Oqtane.Managers.Search
{
public class ModuleSearchIndexManager : SearchIndexManagerBase
{
public const int ModuleSearchIndexManagerPriority = 200;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<ModuleSearchIndexManager> _logger;
private readonly IPageModuleRepository _pageModuleRepostory;
private readonly IPageRepository _pageRepository;
public ModuleSearchIndexManager(
IServiceProvider serviceProvider,
IPageModuleRepository pageModuleRepostory,
ILogger<ModuleSearchIndexManager> logger,
IPageRepository pageRepository)
: base(serviceProvider)
{
_serviceProvider = serviceProvider;
_logger = logger;
_pageModuleRepostory = pageModuleRepostory;
_pageRepository = pageRepository;
}
public override string Name => EntityNames.Module;
public override int Priority => ModuleSearchIndexManagerPriority;
public override int IndexContent(int siteId, DateTime? startTime, Action<IList<SearchContent>> processSearchContent, Action<string> handleError)
{
var pageModules = _pageModuleRepostory.GetPageModules(siteId).DistinctBy(i => i.ModuleId);
var searchContentList = new List<SearchContent>();
foreach(var pageModule in pageModules)
{
var page = _pageRepository.GetPage(pageModule.PageId);
if(page == null || SearchUtils.IsSystemPage(page))
{
continue;
}
var module = pageModule.Module;
if (module.ModuleDefinition.ServerManagerType != "")
{
_logger.LogDebug($"Search: Begin index module {module.ModuleId}.");
var type = Type.GetType(module.ModuleDefinition.ServerManagerType);
if (type?.GetInterface(nameof(ISearchable)) != null)
{
try
{
var moduleSearch = (ISearchable)ActivatorUtilities.CreateInstance(_serviceProvider, type);
var contentList = moduleSearch.GetSearchContentList(module, startTime.GetValueOrDefault(DateTime.MinValue));
if(contentList != null)
{
foreach(var searchContent in contentList)
{
SaveModuleMetaData(searchContent, pageModule);
searchContentList.Add(searchContent);
}
}
}
catch(Exception ex)
{
_logger.LogError(ex, $"Search: Index module {module.ModuleId} failed.");
handleError($"Search: Index module {module.ModuleId} failed: {ex.Message}");
}
}
_logger.LogDebug($"Search: End index module {module.ModuleId}.");
}
}
processSearchContent(searchContentList);
return searchContentList.Count;
}
private void SaveModuleMetaData(SearchContent searchContent, PageModule pageModule)
{
searchContent.SiteId = pageModule.Module.SiteId;
if(string.IsNullOrEmpty(searchContent.EntityName))
{
searchContent.EntityName = EntityNames.Module;
}
if(searchContent.EntityId == 0)
{
searchContent.EntityId = pageModule.ModuleId;
}
if (searchContent.IsActive)
{
searchContent.IsActive = !pageModule.Module.IsDeleted;
}
if (searchContent.ModifiedTime == DateTime.MinValue)
{
searchContent.ModifiedTime = pageModule.ModifiedOn;
}
if (string.IsNullOrEmpty(searchContent.AdditionalContent))
{
searchContent.AdditionalContent = string.Empty;
}
var page = _pageRepository.GetPage(pageModule.PageId);
if (string.IsNullOrEmpty(searchContent.Url) && page != null)
{
searchContent.Url = $"{(!string.IsNullOrEmpty(page.Path) && !page.Path.StartsWith("/") ? "/" : "")}{page.Path}";
}
if (string.IsNullOrEmpty(searchContent.Title) && page != null)
{
searchContent.Title = !string.IsNullOrEmpty(page.Title) ? page.Title : page.Name;
}
if (searchContent.Properties == null)
{
searchContent.Properties = new List<SearchContentProperty>();
}
if(!searchContent.Properties.Any(i => i.Name == Constants.SearchPageIdPropertyName))
{
searchContent.Properties.Add(new SearchContentProperty { Name = Constants.SearchPageIdPropertyName, Value = pageModule.PageId.ToString() });
}
if (!searchContent.Properties.Any(i => i.Name == Constants.SearchModuleIdPropertyName))
{
searchContent.Properties.Add(new SearchContentProperty { Name = Constants.SearchModuleIdPropertyName, Value = pageModule.ModuleId.ToString() });
}
}
}
}

View File

@ -0,0 +1,60 @@
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.Properties?.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.Properties?.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.IsPageModuleVisible(page.EffectiveDate, page.ExpiryDate) || UserSecurity.IsAuthorized(user, PermissionNames.Edit, page.PermissionList));
}
}
}

View File

@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.Reflection.Metadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Oqtane.Interfaces;
using Oqtane.Models;
using Oqtane.Repository;
using Oqtane.Shared;
namespace Oqtane.Managers.Search
{
public class PageSearchIndexManager : SearchIndexManagerBase
{
private const int PageSearchIndexManagerPriority = 100;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<ModuleSearchIndexManager> _logger;
private readonly IPageRepository _pageRepository;
public PageSearchIndexManager(
IServiceProvider serviceProvider,
ILogger<ModuleSearchIndexManager> logger,
IPageRepository pageRepository)
: base(serviceProvider)
{
_serviceProvider = serviceProvider;
_logger = logger;
_pageRepository = pageRepository;
}
public override string Name => EntityNames.Page;
public override int Priority => PageSearchIndexManagerPriority;
public override int IndexContent(int siteId, DateTime? startTime, Action<IList<SearchContent>> processSearchContent, Action<string> handleError)
{
var startTimeValue = startTime.GetValueOrDefault(DateTime.MinValue);
var pages = _pageRepository.GetPages(siteId).Where(i => i.ModifiedOn >= startTimeValue);
var searchContentList = new List<SearchContent>();
foreach(var page in pages)
{
try
{
if(SearchUtils.IsSystemPage(page))
{
continue;
}
var searchContent = new SearchContent
{
EntityName = EntityNames.Page,
EntityId = page.PageId,
SiteId = page.SiteId,
ModifiedTime = page.ModifiedOn,
AdditionalContent = string.Empty,
Url = $"{(!string.IsNullOrEmpty(page.Path) && !page.Path.StartsWith("/") ? "/" : "")}{page.Path}",
Title = !string.IsNullOrEmpty(page.Title) ? page.Title : page.Name,
Description = string.Empty,
Body = $"{page.Name} {page.Title}",
IsActive = !page.IsDeleted && Utilities.IsPageModuleVisible(page.EffectiveDate, page.ExpiryDate)
};
if (searchContent.Properties == null)
{
searchContent.Properties = new List<SearchContentProperty>();
}
if (!searchContent.Properties.Any(i => i.Name == Constants.SearchPageIdPropertyName))
{
searchContent.Properties.Add(new SearchContentProperty { Name = Constants.SearchPageIdPropertyName, Value = page.PageId.ToString() });
}
searchContentList.Add(searchContent);
}
catch(Exception ex)
{
_logger.LogError(ex, $"Search: Index page {page.PageId} failed.");
handleError($"Search: Index page {page.PageId} failed: {ex.Message}");
}
}
processSearchContent(searchContentList);
return searchContentList.Count;
}
}
}

View File

@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.DependencyInjection;
using Oqtane.Models;
using Oqtane.Repository;
using Oqtane.Services;
using Oqtane.Shared;
namespace Oqtane.Managers.Search
{
public abstract class SearchIndexManagerBase : ISearchIndexManager
{
private const string SearchIndexManagerEnabledSettingFormat = "SearchIndexManager_{0}_Enabled";
private readonly IServiceProvider _serviceProvider;
public SearchIndexManagerBase(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public abstract int Priority { get; }
public abstract string Name { get; }
public abstract int IndexContent(int siteId, DateTime? startDate, Action<IList<SearchContent>> processSearchContent, Action<string> handleError);
public virtual bool IsIndexEnabled(int siteId)
{
var settingName = string.Format(SearchIndexManagerEnabledSettingFormat, Name);
var settingRepository = _serviceProvider.GetRequiredService<ISettingRepository>();
var setting = settingRepository.GetSetting(EntityNames.Site, siteId, settingName);
return setting == null || setting.SettingValue == "true";
}
}
}

View File

@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations.Operations;
using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders;
using Oqtane.Databases.Interfaces;
using Oqtane.Models;
namespace Oqtane.Migrations.EntityBuilders
{
public class SearchContentEntityBuilder : AuditableBaseEntityBuilder<SearchContentEntityBuilder>
{
private const string _entityTableName = "SearchContent";
private readonly PrimaryKey<SearchContentEntityBuilder> _primaryKey = new("PK_SearchContent", x => x.SearchContentId);
public SearchContentEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database)
{
EntityTableName = _entityTableName;
PrimaryKey = _primaryKey;
}
protected override SearchContentEntityBuilder BuildTable(ColumnsBuilder table)
{
SearchContentId = AddAutoIncrementColumn(table, "SearchContentId");
EntityName = AddStringColumn(table, "EntityName", 50);
EntityId = AddIntegerColumn(table, "EntityId");
SiteId = AddIntegerColumn(table, "SiteId");
Title = AddStringColumn(table, "Title", 255);
Description = AddMaxStringColumn(table, "Description");
Body = AddMaxStringColumn(table, "Body");
Url = AddStringColumn(table, "Url", 255);
ModifiedTime = AddDateTimeColumn(table, "ModifiedTime");
IsActive = AddBooleanColumn(table, "IsActive");
AdditionalContent = AddMaxStringColumn(table, "AdditionalContent");
AddAuditableColumns(table);
return this;
}
public OperationBuilder<AddColumnOperation> SearchContentId { get; private set; }
public OperationBuilder<AddColumnOperation> EntityName { get; private set; }
public OperationBuilder<AddColumnOperation> EntityId { get; private set; }
public OperationBuilder<AddColumnOperation> SiteId { get; private set; }
public OperationBuilder<AddColumnOperation> Title { get; private set; }
public OperationBuilder<AddColumnOperation> Description { get; private set; }
public OperationBuilder<AddColumnOperation> Body { get; private set; }
public OperationBuilder<AddColumnOperation> Url { get; private set; }
public OperationBuilder<AddColumnOperation> ModifiedTime { get; private set; }
public OperationBuilder<AddColumnOperation> IsActive { get; private set; }
public OperationBuilder<AddColumnOperation> AdditionalContent { get; private set; }
}
}

View File

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations.Operations;
using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders;
using Oqtane.Databases.Interfaces;
namespace Oqtane.Migrations.EntityBuilders
{
public class SearchContentPropertyEntityBuilder : BaseEntityBuilder<SearchContentPropertyEntityBuilder>
{
private const string _entityTableName = "SearchContentProperty";
private readonly PrimaryKey<SearchContentPropertyEntityBuilder> _primaryKey = new("PK_SearchContentProperty", x => x.PropertyId);
private readonly ForeignKey<SearchContentPropertyEntityBuilder> _searchContentForeignKey = new("FK_SearchContentProperty_SearchContent", x => x.SearchContentId, "SearchContent", "SearchContentId", ReferentialAction.Cascade);
public SearchContentPropertyEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database)
{
EntityTableName = _entityTableName;
PrimaryKey = _primaryKey;
ForeignKeys.Add(_searchContentForeignKey);
}
protected override SearchContentPropertyEntityBuilder BuildTable(ColumnsBuilder table)
{
PropertyId = AddAutoIncrementColumn(table, "PropertyId");
SearchContentId = AddIntegerColumn(table, "SearchContentId");
Name = AddStringColumn(table, "Name", 50);
Value = AddStringColumn(table, "Value", 50);
return this;
}
public OperationBuilder<AddColumnOperation> PropertyId { get; private set; }
public OperationBuilder<AddColumnOperation> SearchContentId { get; private set; }
public OperationBuilder<AddColumnOperation> Name { get; private set; }
public OperationBuilder<AddColumnOperation> Value { get; private set; }
}
}

View File

@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations.Operations;
using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders;
using Oqtane.Databases.Interfaces;
namespace Oqtane.Migrations.EntityBuilders
{
public class SearchContentWordsEntityBuilder : BaseEntityBuilder<SearchContentWordsEntityBuilder>
{
private const string _entityTableName = "SearchContentWords";
private readonly PrimaryKey<SearchContentWordsEntityBuilder> _primaryKey = new("PK_SearchContentWords", x => x.WordId);
private readonly ForeignKey<SearchContentWordsEntityBuilder> _searchContentForeignKey = new("FK_SearchContentWords_SearchContent", x => x.SearchContentId, "SearchContent", "SearchContentId", ReferentialAction.Cascade);
private readonly ForeignKey<SearchContentWordsEntityBuilder> _wordSourceForeignKey = new("FK_SearchContentWords_WordSource", x => x.WordSourceId, "SearchContentWordSource", "WordSourceId", ReferentialAction.Cascade);
public SearchContentWordsEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database)
{
EntityTableName = _entityTableName;
PrimaryKey = _primaryKey;
ForeignKeys.Add(_searchContentForeignKey);
ForeignKeys.Add(_wordSourceForeignKey);
}
protected override SearchContentWordsEntityBuilder BuildTable(ColumnsBuilder table)
{
WordId = AddAutoIncrementColumn(table, "WordId");
SearchContentId = AddIntegerColumn(table, "SearchContentId");
WordSourceId = AddIntegerColumn(table, "WordSourceId");
Count = AddIntegerColumn(table, "Count");
return this;
}
public OperationBuilder<AddColumnOperation> WordId { get; private set; }
public OperationBuilder<AddColumnOperation> SearchContentId { get; private set; }
public OperationBuilder<AddColumnOperation> WordSourceId { get; private set; }
public OperationBuilder<AddColumnOperation> Count { get; private set; }
}
}

View File

@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations.Operations;
using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders;
using Oqtane.Databases.Interfaces;
namespace Oqtane.Migrations.EntityBuilders
{
public class SearchContentWordSourceEntityBuilder : BaseEntityBuilder<SearchContentWordSourceEntityBuilder>
{
private const string _entityTableName = "SearchContentWordSource";
private readonly PrimaryKey<SearchContentWordSourceEntityBuilder> _primaryKey = new("PK_SearchContentWordSource", x => x.WordSourceId);
public SearchContentWordSourceEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database)
{
EntityTableName = _entityTableName;
PrimaryKey = _primaryKey;
}
protected override SearchContentWordSourceEntityBuilder BuildTable(ColumnsBuilder table)
{
WordSourceId = AddAutoIncrementColumn(table, "WordSourceId");
Word = AddStringColumn(table, "Word", 255);
return this;
}
public OperationBuilder<AddColumnOperation> WordSourceId { get; private set; }
public OperationBuilder<AddColumnOperation> Word { get; private set; }
}
}

View File

@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Oqtane.Databases.Interfaces;
using Oqtane.Migrations.EntityBuilders;
using Oqtane.Repository;
namespace Oqtane.Migrations.Tenant
{
[DbContext(typeof(TenantDBContext))]
[Migration("Tenant.05.02.00.01")]
public class AddSearchTables : MultiDatabaseMigration
{
public AddSearchTables(IDatabase database) : base(database)
{
}
protected override void Up(MigrationBuilder migrationBuilder)
{
var searchContentEntityBuilder = new SearchContentEntityBuilder(migrationBuilder, ActiveDatabase);
searchContentEntityBuilder.Create();
var searchContentPropertyEntityBuilder = new SearchContentPropertyEntityBuilder(migrationBuilder, ActiveDatabase);
searchContentPropertyEntityBuilder.Create();
var searchContentWordSourceEntityBuilder = new SearchContentWordSourceEntityBuilder(migrationBuilder, ActiveDatabase);
searchContentWordSourceEntityBuilder.Create();
searchContentWordSourceEntityBuilder.AddIndex("IX_SearchContentWordSource", "Word", true);
var searchContentWordsEntityBuilder = new SearchContentWordsEntityBuilder(migrationBuilder, ActiveDatabase);
searchContentWordsEntityBuilder.Create();
}
protected override void Down(MigrationBuilder migrationBuilder)
{
var searchContentWordsEntityBuilder = new SearchContentWordsEntityBuilder(migrationBuilder, ActiveDatabase);
searchContentWordsEntityBuilder.Drop();
var searchContentWordSourceEntityBuilder = new SearchContentWordSourceEntityBuilder(migrationBuilder, ActiveDatabase);
searchContentWordSourceEntityBuilder.DropIndex("IX_SearchContentWordSource");
searchContentWordSourceEntityBuilder.Drop();
var searchContentPropertyEntityBuilder = new SearchContentPropertyEntityBuilder(migrationBuilder, ActiveDatabase);
searchContentPropertyEntityBuilder.Drop();
var searchContentEntityBuilder = new SearchContentEntityBuilder(migrationBuilder, ActiveDatabase);
searchContentEntityBuilder.Drop();
}
}
}

View File

@ -8,20 +8,29 @@ using Oqtane.Shared;
using Oqtane.Migrations.Framework;
using Oqtane.Documentation;
using System.Linq;
using Oqtane.Interfaces;
using System.Collections.Generic;
using System;
// ReSharper disable ConvertToUsingDeclaration
namespace Oqtane.Modules.HtmlText.Manager
{
[PrivateApi("Mark HtmlText classes as private, since it's not very useful in the public docs")]
public class HtmlTextManager : MigratableModuleBase, IInstallable, IPortable
public class HtmlTextManager : MigratableModuleBase, IInstallable, IPortable, ISearchable
{
private readonly IServiceProvider _serviceProvider;
private readonly IHtmlTextRepository _htmlText;
private readonly IDBContextDependencies _DBContextDependencies;
private readonly ISqlRepository _sqlRepository;
public HtmlTextManager(IHtmlTextRepository htmlText, IDBContextDependencies DBContextDependencies, ISqlRepository sqlRepository)
public HtmlTextManager(
IServiceProvider serviceProvider,
IHtmlTextRepository htmlText,
IDBContextDependencies DBContextDependencies,
ISqlRepository sqlRepository)
{
_serviceProvider = serviceProvider;
_htmlText = htmlText;
_DBContextDependencies = DBContextDependencies;
_sqlRepository = sqlRepository;
@ -39,6 +48,27 @@ namespace Oqtane.Modules.HtmlText.Manager
return content;
}
public IList<SearchContent> GetSearchContentList(Module module, DateTime startDate)
{
var searchContentList = new List<SearchContent>();
var htmltexts = _htmlText.GetHtmlTexts(module.ModuleId);
if (htmltexts != null && htmltexts.Any(i => i.CreatedOn >= startDate))
{
var htmltext = htmltexts.OrderByDescending(item => item.CreatedOn).First();
searchContentList.Add(new SearchContent
{
Title = module.Title,
Description = string.Empty,
Body = htmltext.Content,
ModifiedTime = htmltext.ModifiedOn
});
}
return searchContentList;
}
public void ImportModule(Module module, string content, string version)
{
content = WebUtility.HtmlDecode(content);

View File

@ -33,6 +33,7 @@
<EmbeddedResource Include="Scripts\MigrateTenant.sql" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.11.61" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.5" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.0" />

View File

@ -0,0 +1,308 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Xml;
using HtmlAgilityPack;
using Oqtane.Models;
using Oqtane.Repository;
using Oqtane.Services;
using Oqtane.Shared;
using static Microsoft.Extensions.Logging.EventSource.LoggingEventSource;
namespace Oqtane.Providers
{
public class DatabaseSearchProvider : ISearchProvider
{
private readonly ISearchContentRepository _searchContentRepository;
private const string IgnoreWords = "the,be,to,of,and,a,i,in,that,have,it,for,not,on,with,he,as,you,do,at,this,but,his,by,from,they,we,say,her,she,or,an,will,my,one,all,would,there,their,what,so,up,out,if,about,who,get,which,go,me,when,make,can,like,time,no,just,him,know,take,people,into,year,your,good,some,could,them,see,other,than,then,now,look,only,come,its,over,think,also,back,after,use,two,how,our,work,first,well,way,even,new,want,because,any,these,give,day,most,us";
private const int WordMinLength = 3;
public string Name => Constants.DefaultSearchProviderName;
public DatabaseSearchProvider(ISearchContentRepository searchContentRepository)
{
_searchContentRepository = searchContentRepository;
}
public void Commit()
{
}
public void DeleteSearchContent(string id)
{
_searchContentRepository.DeleteSearchContent(id);
}
public bool Optimize()
{
return true;
}
public void ResetIndex()
{
_searchContentRepository.DeleteAllSearchContent();
}
public void SaveSearchContent(SearchContent searchContent, bool autoCommit = false)
{
//remove exist document
_searchContentRepository.DeleteSearchContent(searchContent.EntityName, searchContent.EntityId);
//clean the search content to remove html tags
CleanSearchContent(searchContent);
_searchContentRepository.AddSearchContent(searchContent);
//save the index words
AnalyzeSearchContent(searchContent);
}
public async Task<SearchResults> SearchAsync(SearchQuery searchQuery, Func<SearchContent, SearchQuery, bool> validateFunc)
{
var totalResults = 0;
var searchContentList = await _searchContentRepository.GetSearchContentListAsync(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.ModifiedTime);
break;
case SearchSortFields.Title:
results = results.OrderByDescending(i => i.Title).ThenByDescending(i => i.ModifiedTime);
break;
default:
results = results.OrderByDescending(i => i.ModifiedTime);
break;
}
}
else
{
switch (searchQuery.SortField)
{
case SearchSortFields.Relevance:
results = results.OrderBy(i => i.Score).ThenByDescending(i => i.ModifiedTime);
break;
case SearchSortFields.Title:
results = results.OrderBy(i => i.Title).ThenByDescending(i => i.ModifiedTime);
break;
default:
results = results.OrderBy(i => i.ModifiedTime);
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.Properties.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)
{
var searchResult = new SearchResult()
{
SearchContentId = searchContent.SearchContentId,
SiteId = searchContent.SiteId,
EntityName = searchContent.EntityName,
EntityId = searchContent.EntityId,
Title = searchContent.Title,
Description = searchContent.Description,
Body = searchContent.Body,
Url = searchContent.Url,
ModifiedTime = searchContent.ModifiedTime,
Properties = searchContent.Properties,
Snippet = BuildSnippet(searchContent, searchQuery),
Score = CalculateScore(searchContent, searchQuery)
};
return searchResult;
}
private float CalculateScore(SearchContent searchContent, SearchQuery searchQuery)
{
var score = 0f;
foreach (var keyword in SearchUtils.GetKeywordsList(searchQuery.Keywords))
{
score += searchContent.Words.Where(i => i.WordSource.Word.StartsWith(keyword)).Sum(i => i.Count);
}
return score / 100;
}
private string BuildSnippet(SearchContent searchContent, SearchQuery searchQuery)
{
var content = $"{searchContent.Title} {searchContent.Description} {searchContent.Body}";
var snippet = string.Empty;
foreach (var keyword in SearchUtils.GetKeywordsList(searchQuery.Keywords))
{
if (!string.IsNullOrWhiteSpace(keyword) && content.Contains(keyword, StringComparison.OrdinalIgnoreCase))
{
var start = content.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) - 20;
var prefix = "...";
var suffix = "...";
if (start <= 0)
{
start = 0;
prefix = string.Empty;
}
var length = searchQuery.BodySnippetLength;
if (start + length >= content.Length)
{
length = content.Length - start;
suffix = string.Empty;
}
snippet = $"{prefix}{content.Substring(start, length)}{suffix}";
break;
}
}
if (string.IsNullOrEmpty(snippet))
{
snippet = content.Substring(0, searchQuery.BodySnippetLength);
}
foreach (var keyword in SearchUtils.GetKeywordsList(searchQuery.Keywords))
{
snippet = Regex.Replace(snippet, $"({keyword})", $"<b>$1</b>", RegexOptions.IgnoreCase);
}
return snippet;
}
private void AnalyzeSearchContent(SearchContent searchContent)
{
//analyze the search content and save the index words
var indexContent = $"{searchContent.Title} {searchContent.Description} {searchContent.Body} {searchContent.AdditionalContent}";
var words = GetWords(indexContent, WordMinLength);
var existWords = _searchContentRepository.GetWords(searchContent.SearchContentId);
foreach (var kvp in words)
{
var word = existWords.FirstOrDefault(i => i.WordSource.Word == kvp.Key);
if (word != null)
{
word.Count = kvp.Value;
_searchContentRepository.UpdateSearchContentWords(word);
}
else
{
var wordSource = _searchContentRepository.GetSearchContentWordSource(kvp.Key);
if (wordSource == null)
{
wordSource = _searchContentRepository.AddSearchContentWordSource(new SearchContentWordSource { Word = kvp.Key });
}
word = new SearchContentWords
{
SearchContentId = searchContent.SearchContentId,
WordSourceId = wordSource.WordSourceId,
Count = kvp.Value
};
_searchContentRepository.AddSearchContentWords(word);
}
}
}
private static Dictionary<string, int> GetWords(string content, int minLength)
{
content = FormatText(content);
var words = new Dictionary<string, int>();
var ignoreWords = IgnoreWords.Split(',');
if (!string.IsNullOrEmpty(content))
{
foreach (var word in content.Split(' '))
{
if (word.Length >= minLength && !ignoreWords.Contains(word))
{
if (!words.ContainsKey(word))
{
words.Add(word, 1);
}
else
{
words[word] += 1;
}
}
}
}
return words;
}
private static string FormatText(string text)
{
text = HtmlEntity.DeEntitize(text);
foreach (var punctuation in ".?!,;:-_()[]{}'\"/\\".ToCharArray())
{
text = text.Replace(punctuation, ' ');
}
text = text.Replace(" ", " ").ToLower().Trim();
return text;
}
private void CleanSearchContent(SearchContent searchContent)
{
searchContent.Title = GetCleanContent(searchContent.Title);
searchContent.Description = GetCleanContent(searchContent.Description);
searchContent.Body = GetCleanContent(searchContent.Body);
searchContent.AdditionalContent = GetCleanContent(searchContent.AdditionalContent);
}
private string GetCleanContent(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return string.Empty;
}
content = WebUtility.HtmlDecode(content);
var page = new HtmlDocument();
page.LoadHtml(content);
var phrases = page.DocumentNode.Descendants().Where(i =>
i.NodeType == HtmlNodeType.Text &&
i.ParentNode.Name != "script" &&
i.ParentNode.Name != "style" &&
!string.IsNullOrEmpty(i.InnerText.Trim())
).Select(i => i.InnerText);
return string.Join(" ", phrases);
}
}
}

View File

@ -29,5 +29,9 @@ namespace Oqtane.Repository
public virtual DbSet<Language> Language { get; set; }
public virtual DbSet<Visitor> Visitor { get; set; }
public virtual DbSet<UrlMapping> UrlMapping { get; set; }
public virtual DbSet<SearchContent> SearchContent { get; set; }
public virtual DbSet<SearchContentProperty> SearchContentProperty { get; set; }
public virtual DbSet<SearchContentWords> SearchContentWords { get; set; }
public virtual DbSet<SearchContentWordSource> SearchContentWordSource { get; set; }
}
}

View File

@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Oqtane.Models;
namespace Oqtane.Repository
{
public interface ISearchContentRepository
{
Task<IEnumerable<SearchContent>> GetSearchContentListAsync(SearchQuery searchQuery);
SearchContent AddSearchContent(SearchContent searchContent);
void DeleteSearchContent(int searchContentId);
void DeleteSearchContent(string entityName, int entryId);
void DeleteSearchContent(string uniqueKey);
void DeleteAllSearchContent();
SearchContentWordSource GetSearchContentWordSource(string word);
SearchContentWordSource AddSearchContentWordSource(SearchContentWordSource wordSource);
IEnumerable<SearchContentWords> GetWords(int searchContentId);
SearchContentWords AddSearchContentWords(SearchContentWords word);
SearchContentWords UpdateSearchContentWords(SearchContentWords word);
}
}

View File

@ -0,0 +1,169 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Oqtane.Models;
using Oqtane.Shared;
namespace Oqtane.Repository
{
public class SearchContentRepository : ISearchContentRepository
{
private readonly IDbContextFactory<TenantDBContext> _dbContextFactory;
public SearchContentRepository(IDbContextFactory<TenantDBContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<IEnumerable<SearchContent>> GetSearchContentListAsync(SearchQuery searchQuery)
{
using var db = _dbContextFactory.CreateDbContext();
var searchContentList = db.SearchContent.AsNoTracking()
.Include(i => i.Properties)
.Include(i => i.Words)
.ThenInclude(w => w.WordSource)
.Where(i => i.SiteId == searchQuery.SiteId && i.IsActive);
if (searchQuery.EntityNames != null && searchQuery.EntityNames.Any())
{
searchContentList = searchContentList.Where(i => searchQuery.EntityNames.Contains(i.EntityName));
}
if (searchQuery.BeginModifiedTimeUtc != DateTime.MinValue)
{
searchContentList = searchContentList.Where(i => i.ModifiedTime >= searchQuery.BeginModifiedTimeUtc);
}
if (searchQuery.EndModifiedTimeUtc != DateTime.MinValue)
{
searchContentList = searchContentList.Where(i => i.ModifiedTime <= searchQuery.EndModifiedTimeUtc);
}
if (searchQuery.Properties != null && searchQuery.Properties.Any())
{
foreach (var property in searchQuery.Properties)
{
searchContentList = searchContentList.Where(i => i.Properties.Any(p => p.Name == property.Key && p.Value == property.Value));
}
}
var filteredContentList = new List<SearchContent>();
if (!string.IsNullOrEmpty(searchQuery.Keywords))
{
foreach (var keyword in SearchUtils.GetKeywordsList(searchQuery.Keywords))
{
filteredContentList.AddRange(await searchContentList.Where(i => i.Words.Any(w => w.WordSource.Word.StartsWith(keyword))).ToListAsync());
}
}
return filteredContentList.DistinctBy(i => i.UniqueKey);
}
public SearchContent AddSearchContent(SearchContent searchContent)
{
using var context = _dbContextFactory.CreateDbContext();
context.SearchContent.Add(searchContent);
if(searchContent.Properties != null && searchContent.Properties.Any())
{
foreach(var property in searchContent.Properties)
{
property.SearchContentId = searchContent.SearchContentId;
context.SearchContentProperty.Add(property);
}
}
context.SaveChanges();
return searchContent;
}
public void DeleteSearchContent(int searchContentId)
{
using var db = _dbContextFactory.CreateDbContext();
var searchContent = db.SearchContent.Find(searchContentId);
db.SearchContent.Remove(searchContent);
db.SaveChanges();
}
public void DeleteSearchContent(string entityName, int entryId)
{
using var db = _dbContextFactory.CreateDbContext();
var searchContent = db.SearchContent.FirstOrDefault(i => i.EntityName == entityName && i.EntityId == entryId);
if(searchContent != null)
{
db.SearchContent.Remove(searchContent);
db.SaveChanges();
}
}
public void DeleteSearchContent(string uniqueKey)
{
using var db = _dbContextFactory.CreateDbContext();
var searchContent = db.SearchContent.FirstOrDefault(i => (i.EntityName + ":" + i.EntityId) == uniqueKey);
if (searchContent != null)
{
db.SearchContent.Remove(searchContent);
db.SaveChanges();
}
}
public void DeleteAllSearchContent()
{
using var db = _dbContextFactory.CreateDbContext();
db.SearchContent.RemoveRange(db.SearchContent);
db.SaveChanges();
}
public SearchContentWordSource GetSearchContentWordSource(string word)
{
if(string.IsNullOrEmpty(word))
{
return null;
}
using var db = _dbContextFactory.CreateDbContext();
return db.SearchContentWordSource.FirstOrDefault(i => i.Word == word);
}
public SearchContentWordSource AddSearchContentWordSource(SearchContentWordSource wordSource)
{
using var db = _dbContextFactory.CreateDbContext();
db.SearchContentWordSource.Add(wordSource);
db.SaveChanges();
return wordSource;
}
public IEnumerable<SearchContentWords> GetWords(int searchContentId)
{
using var db = _dbContextFactory.CreateDbContext();
return db.SearchContentWords
.Include(i => i.WordSource)
.Where(i => i.SearchContentId == searchContentId).ToList();
}
public SearchContentWords AddSearchContentWords(SearchContentWords word)
{
using var db = _dbContextFactory.CreateDbContext();
db.SearchContentWords.Add(word);
db.SaveChanges();
return word;
}
public SearchContentWords UpdateSearchContentWords(SearchContentWords word)
{
using var db = _dbContextFactory.CreateDbContext();
db.Entry(word).State = EntityState.Modified;
db.SaveChanges();
return word;
}
}
}

View File

@ -0,0 +1,234 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using HtmlAgilityPack;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Oqtane.Infrastructure;
using Oqtane.Models;
using Oqtane.Repository;
using Oqtane.Security;
using Oqtane.Shared;
namespace Oqtane.Services
{
public class SearchService : ISearchService
{
private const string SearchProviderSettingName = "SearchProvider";
private const string SearchEnabledSettingName = "SearchEnabled";
private readonly IServiceProvider _serviceProvider;
private readonly ITenantManager _tenantManager;
private readonly IAliasRepository _aliasRepository;
private readonly ISettingRepository _settingRepository;
private readonly IPermissionRepository _permissionRepository;
private readonly ILogger<SearchService> _logger;
private readonly IMemoryCache _cache;
public SearchService(
IServiceProvider serviceProvider,
ITenantManager tenantManager,
IAliasRepository aliasRepository,
ISettingRepository settingRepository,
IPermissionRepository permissionRepository,
ILogger<SearchService> logger,
IMemoryCache cache)
{
_tenantManager = tenantManager;
_aliasRepository = aliasRepository;
_settingRepository = settingRepository;
_permissionRepository = permissionRepository;
_serviceProvider = serviceProvider;
_logger = logger;
_cache = cache;
}
public void IndexContent(int siteId, DateTime? startTime, Action<string> logNote, Action<string> handleError)
{
var searchEnabled = SearchEnabled(siteId);
if(!searchEnabled)
{
logNote($"Search: Search is disabled on site {siteId}.<br />");
return;
}
_logger.LogDebug($"Search: Start Index Content of {siteId}, Start Time: {startTime.GetValueOrDefault(DateTime.MinValue)}");
var searchProvider = GetSearchProvider(siteId);
SetTenant(siteId);
if (startTime == null)
{
searchProvider.ResetIndex();
}
var searchIndexManagers = GetSearchIndexManagers(m => { });
foreach (var searchIndexManager in searchIndexManagers)
{
if (!searchIndexManager.IsIndexEnabled(siteId))
{
logNote($"Search: Ignore indexer {searchIndexManager.Name} because it's disabled.<br />");
}
else
{
_logger.LogDebug($"Search: Begin Index {searchIndexManager.Name}");
var count = searchIndexManager.IndexContent(siteId, startTime, SaveSearchContent, handleError);
logNote($"Search: Indexer {searchIndexManager.Name} processed {count} search content.<br />");
_logger.LogDebug($"Search: End Index {searchIndexManager.Name}");
}
}
}
public async Task<SearchResults> SearchAsync(SearchQuery searchQuery)
{
var searchProvider = GetSearchProvider(searchQuery.SiteId);
var searchResults = await searchProvider.SearchAsync(searchQuery, Visible);
//generate the document url if it's not set.
foreach (var result in searchResults.Results)
{
if(string.IsNullOrEmpty(result.Url))
{
result.Url = GetDocumentUrl(result, searchQuery);
}
}
return searchResults;
}
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;
}
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 IList<ISearchIndexManager> GetSearchIndexManagers(Action<ISearchIndexManager> initManager)
{
var managers = new List<ISearchIndexManager>();
var managerTypes = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(s => s.GetTypes())
.Where(p => typeof(ISearchIndexManager).IsAssignableFrom(p) && !p.IsInterface && !p.IsAbstract);
foreach (var type in managerTypes)
{
var manager = (ISearchIndexManager)ActivatorUtilities.CreateInstance(_serviceProvider, type);
initManager(manager);
managers.Add(manager);
}
return managers.OrderBy(i => i.Priority).ToList();
}
private IList<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();
}
private void SaveSearchContent(IList<SearchContent> searchContentList)
{
if(searchContentList.Any())
{
var searchProvider = GetSearchProvider(searchContentList.First().SiteId);
foreach (var searchContent in searchContentList)
{
try
{
searchProvider.SaveSearchContent(searchContent);
}
catch(Exception ex)
{
_logger.LogError(ex, $"Search: Save search content {searchContent.UniqueKey} failed.");
}
}
//commit the index changes
searchProvider.Commit();
}
}
private bool Visible(SearchContent searchContent, SearchQuery searchQuery)
{
if(!HasViewPermission(searchQuery.SiteId, searchQuery.User, searchContent.EntityName, searchContent.EntityId))
{
return false;
}
var searchResultManager = GetSearchResultManagers().FirstOrDefault(i => i.Name == searchContent.EntityName);
if (searchResultManager != null)
{
return searchResultManager.Visible(searchContent, searchQuery);
}
return true;
}
private bool HasViewPermission(int siteId, User user, string entityName, int entityId)
{
var permissions = _permissionRepository.GetPermissions(siteId, entityName, entityId).ToList();
return UserSecurity.IsAuthorized(user, PermissionNames.View, permissions);
}
private string GetDocumentUrl(SearchResult result, SearchQuery searchQuery)
{
var searchResultManager = GetSearchResultManagers().FirstOrDefault(i => i.Name == result.EntityName);
if(searchResultManager != null)
{
return searchResultManager.GetUrl(result, searchQuery);
}
return string.Empty;
}
}
}

View File

@ -0,0 +1,3 @@
.search-result-container ul.pagination li label, .search-result-container ul.dropdown-menu li label {
cursor: pointer;
}

View File

@ -79,6 +79,10 @@ body {
top: -2px;
}
.app-search input{
width: auto;
}
.navbar-toggler {
background-color: rgba(255, 255, 255, 0.1);
margin: .5rem;

View File

@ -235,3 +235,17 @@ app {
.app-form-inline {
display: inline-block;
}
.app-search{
display: inline-block;
position: relative;
}
.app-search input + button{
background: none;
border: none;
position: absolute;
right: 0;
top: 0;
}
.app-search input + button .oi{
top: 0;
}

View File

@ -0,0 +1,10 @@
using System;
namespace Oqtane.Shared
{
public enum SearchSortDirections
{
Ascending,
Descending
}
}

View File

@ -0,0 +1,11 @@
using System;
namespace Oqtane.Shared
{
public enum SearchSortFields
{
Relevance,
Title,
LastModified
}
}

View File

@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Web;
using Oqtane.Models;
namespace Oqtane.Services
{
public interface ISearchIndexManager
{
int Priority { get; }
string Name { get; }
bool IsIndexEnabled(int siteId);
int IndexContent(int siteId, DateTime? startTime, Action<IList<SearchContent>> processSearchContent, Action<string> handleError);
}
}

View File

@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Oqtane.Models;
namespace Oqtane.Services
{
public interface ISearchProvider
{
string Name { get; }
void SaveSearchContent(SearchContent searchContent, bool autoCommit = false);
void DeleteSearchContent(string id);
Task<SearchResults> SearchAsync(SearchQuery searchQuery, Func<SearchContent, SearchQuery, bool> validateFunc);
bool Optimize();
void Commit();
void ResetIndex();
}
}

View File

@ -0,0 +1,14 @@
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

@ -0,0 +1,15 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;
using Oqtane.Models;
namespace Oqtane.Services
{
public interface ISearchService
{
void IndexContent(int siteId, DateTime? startTime, Action<string> logNote, Action<string> handleError);
Task<SearchResults> SearchAsync(SearchQuery searchQuery);
}
}

View File

@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Oqtane.Models;
namespace Oqtane.Interfaces
{
public interface ISearchable
{
public IList<SearchContent> GetSearchContentList(Module module, DateTime startTime);
}
}

View File

@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
namespace Oqtane.Models
{
public class SearchContent : ModelBase
{
public int SearchContentId { get; set; }
[NotMapped]
public string UniqueKey => $"{EntityName}:{EntityId}";
public string EntityName { get; set; }
public int EntityId { get; set; }
public int SiteId { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public string Body { get; set; }
public string Url { get; set; }
public DateTime ModifiedTime { get; set; }
public bool IsActive { get; set; } = true;
public string AdditionalContent { get; set; }
public IList<SearchContentProperty> Properties { get; set; }
public IList<SearchContentWords> Words { get; set; }
public override string ToString()
{
return JsonSerializer.Serialize(this);
}
}
}

View File

@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
namespace Oqtane.Models
{
public class SearchContentProperty
{
[Key]
public int PropertyId { get; set; }
public int SearchContentId { get; set; }
public string Name { get; set; }
public string Value { get; set; }
}
}

View File

@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace Oqtane.Models
{
public class SearchContentWordSource
{
[Key]
public int WordSourceId { get; set; }
public string Word { get; set; }
}
}

View File

@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Oqtane.Models
{
public class SearchContentWords
{
[Key]
public int WordId { get; set; }
public int SearchContentId { get; set; }
public int WordSourceId { get; set; }
public int Count { get; set; }
public SearchContentWordSource WordSource { get; set; }
}
}

View File

@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using Oqtane.Shared;
namespace Oqtane.Models
{
public class SearchQuery
{
public int SiteId { get; set; }
public Alias Alias { get; set; }
public User User { get; set; }
public string Keywords { get; set; }
public IList<string> EntityNames { get; set; } = new List<string>();
public DateTime BeginModifiedTimeUtc { get; set; }
public DateTime EndModifiedTimeUtc { get; set; }
public IDictionary<string, string> Properties { get; set; } = new Dictionary<string, string>();
public int PageIndex { get; set; }
public int PageSize { get; set; }
public SearchSortFields SortField { get; set; }
public SearchSortDirections SortDirection { get; set; }
public int BodySnippetLength { get; set;} = 255;
}
}

View File

@ -0,0 +1,11 @@
namespace Oqtane.Models
{
public class SearchResult : SearchContent
{
public float Score { get; set; }
public string DisplayScore { get; set; }
public string Snippet { get; set; }
}
}

View File

@ -0,0 +1,11 @@
using System.Collections.Generic;
namespace Oqtane.Models
{
public class SearchResults
{
public IList<SearchResult> Results { get; set; }
public int TotalResults { get; set; }
}
}

View File

@ -77,6 +77,10 @@ namespace Oqtane.Shared
public static readonly string VisitorCookiePrefix = "APP_VISITOR_";
public const string DefaultSearchProviderName = "Database";
public const string SearchPageIdPropertyName = "PageId";
public const string SearchModuleIdPropertyName = "ModuleId";
// Obsolete constants
const string RoleObsoleteMessage = "Use the corresponding member from Oqtane.Shared.RoleNames";

View File

@ -0,0 +1,36 @@
using System.Collections.Generic;
namespace Oqtane.Shared
{
public sealed class SearchUtils
{
private static readonly IList<string> _systemPages;
static SearchUtils()
{
_systemPages = new List<string> { "login", "register", "profile", "404", "search" };
}
public static IList<string> GetKeywordsList(string keywords)
{
var keywordsList = new List<string>();
if(!string.IsNullOrEmpty(keywords))
{
foreach (var keyword in keywords.Split(' '))
{
if (!string.IsNullOrWhiteSpace(keyword.Trim()))
{
keywordsList.Add(keyword.Trim().ToLower());
}
}
}
return keywordsList;
}
public static bool IsSystemPage(Models.Page page)
{
return page.Path.Contains("admin") || _systemPages.Contains(page.Path);
}
}
}