#4303: add search function.
This commit is contained in:
parent
d75e3acdf3
commit
9d85ca07f4
147
Oqtane.Client/Modules/SearchResults/Index.razor
Normal file
147
Oqtane.Client/Modules/SearchResults/Index.razor
Normal file
|
@ -0,0 +1,147 @@
|
|||
@using Oqtane.Modules.SearchResults.Services
|
||||
@namespace Oqtane.Modules.SearchResults
|
||||
@inherits ModuleBase
|
||||
@inject ISearchResultsService SearchResultsService
|
||||
@inject IStringLocalizer<Index> Localizer
|
||||
|
||||
<div class="search-result-container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">@Localizer["SearchPrefix"]</span>
|
||||
<input type="text" class="form-control shadow-none" maxlength="50"
|
||||
aria-label="Keywords"
|
||||
placeholder="@Localizer["SearchPlaceholder"]"
|
||||
@bind="_keywords"
|
||||
@bind:event="oninput"
|
||||
@onkeypress="KeywordsChanged">
|
||||
<button class="btn btn-primary shadow-none" type="button" @onclick="@(async () => await Search())">@Localizer["Search"]</button>
|
||||
</div>
|
||||
</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">
|
||||
<Row>
|
||||
<div class="search-item">
|
||||
<h4 class="mb-1"><a href="@context.Url">@context.Title</a></h4>
|
||||
<div class="font-13 text-success mb-3">@context.Url</div>
|
||||
<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 {
|
||||
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 = Constants.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("s"))
|
||||
{
|
||||
_keywords = PageState.QueryString["s"];
|
||||
}
|
||||
|
||||
if (PageState.QueryString.ContainsKey("p"))
|
||||
{
|
||||
_currentPage = Convert.ToInt32(PageState.QueryString["p"]);
|
||||
if (_currentPage < 1)
|
||||
{
|
||||
_currentPage = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_keywords))
|
||||
{
|
||||
await PerformSearch();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task KeywordsChanged(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Code == "Enter" || e.Code == "NumpadEnter")
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_keywords))
|
||||
{
|
||||
await Search();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Search()
|
||||
{
|
||||
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(PageState.ModuleId, searchQuery);
|
||||
|
||||
_loading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
25
Oqtane.Client/Modules/SearchResults/ModuleInfo.cs
Normal file
25
Oqtane.Client/Modules/SearchResults/ModuleInfo.cs
Normal file
|
@ -0,0 +1,25 @@
|
|||
using System.Collections.Generic;
|
||||
using Oqtane.Documentation;
|
||||
using Oqtane.Models;
|
||||
using Oqtane.Shared;
|
||||
|
||||
namespace Oqtane.Modules.SearchResults
|
||||
{
|
||||
[PrivateApi("Mark SearchResults classes 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 = "1.0.0",
|
||||
ServerManagerType = "",
|
||||
ReleaseVersions = "1.0.0",
|
||||
SettingsType = "Oqtane.Modules.SearchResults.Settings, Oqtane.Client",
|
||||
Resources = new List<Resource>()
|
||||
{
|
||||
new Resource { ResourceType = ResourceType.Stylesheet, Url = "~/Module.css" }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Oqtane.Documentation;
|
||||
using Oqtane.Models;
|
||||
|
||||
namespace Oqtane.Modules.SearchResults.Services
|
||||
{
|
||||
[PrivateApi("Mark SearchResults classes as private, since it's not very useful in the public docs")]
|
||||
public interface ISearchResultsService
|
||||
{
|
||||
Task<Models.SearchResults> SearchAsync(int moduleId, SearchQuery searchQuery);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Oqtane.Documentation;
|
||||
using Oqtane.Models;
|
||||
using Oqtane.Services;
|
||||
using Oqtane.Shared;
|
||||
|
||||
namespace Oqtane.Modules.SearchResults.Services
|
||||
{
|
||||
[PrivateApi("Mark SearchResults classes as private, since it's not very useful in the public docs")]
|
||||
public class SearchResultsService : ServiceBase, ISearchResultsService, IClientService
|
||||
{
|
||||
public SearchResultsService(HttpClient http, SiteState siteState) : base(http, siteState) {}
|
||||
|
||||
private string ApiUrl => CreateApiUrl("SearchResults");
|
||||
|
||||
public async Task<Models.SearchResults> SearchAsync(int moduleId, SearchQuery searchQuery)
|
||||
{
|
||||
return await PostJsonAsync<SearchQuery, Models.SearchResults>(CreateAuthorizationPolicyUrl(ApiUrl, EntityNames.Module, moduleId), searchQuery);
|
||||
}
|
||||
}
|
||||
}
|
46
Oqtane.Client/Modules/SearchResults/Settings.razor
Normal file
46
Oqtane.Client/Modules/SearchResults/Settings.razor
Normal file
|
@ -0,0 +1,46 @@
|
|||
@namespace Oqtane.Modules.SearchResults
|
||||
@inherits ModuleBase
|
||||
@inject ISettingService SettingService
|
||||
@implements Oqtane.Interfaces.ISettingsControl
|
||||
@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 string resourceType = "Oqtane.Modules.SearchResults.Settings, Oqtane.Client"; // for localization
|
||||
private string _pageSize;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
try
|
||||
{
|
||||
_pageSize = SettingService.GetSetting(ModuleState.Settings, "PageSize", Constants.SearchDefaultPageSize.ToString());
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
156
Oqtane.Client/Resources/Modules/SearchResults/Index.resx
Normal file
156
Oqtane.Client/Resources/Modules/SearchResults/Index.resx
Normal 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>
|
123
Oqtane.Client/Resources/Modules/SearchResults/Settings.resx
Normal file
123
Oqtane.Client/Resources/Modules/SearchResults/Settings.resx
Normal 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>
|
126
Oqtane.Client/Resources/Themes/Controls/Search.resx
Normal file
126
Oqtane.Client/Resources/Themes/Controls/Search.resx
Normal 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>
|
|
@ -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>
|
||||
|
||||
|
|
57
Oqtane.Client/Themes/Controls/Theme/Search.razor
Normal file
57
Oqtane.Client/Themes/Controls/Theme/Search.razor
Normal file
|
@ -0,0 +1,57 @@
|
|||
@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" @onclick="PerformSearch">
|
||||
<span class="oi oi-magnifying-glass align-middle"></span>
|
||||
</button>
|
||||
</form>
|
||||
</span>
|
||||
}
|
||||
|
||||
|
||||
|
||||
@code {
|
||||
private const string SearchResultPagePath = "search-results";
|
||||
|
||||
private Page _searchResultsPage;
|
||||
private string _keywords = "";
|
||||
|
||||
[Parameter]
|
||||
public string CssClass { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_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, $"s={keywords}");
|
||||
NavigationManager.NavigateTo(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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">
|
||||
|
|
|
@ -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,8 @@ namespace Microsoft.Extensions.DependencyInjection
|
|||
services.AddScoped<IUrlMappingService, UrlMappingService>();
|
||||
services.AddScoped<IVisitorService, VisitorService>();
|
||||
services.AddScoped<ISyncService, SyncService>();
|
||||
services.AddScoped<ISearchService, SearchService>();
|
||||
services.AddScoped<ISearchProvider, DatabaseSearchProvider>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
@ -131,6 +134,7 @@ namespace Microsoft.Extensions.DependencyInjection
|
|||
services.AddTransient<ILanguageRepository, LanguageRepository>();
|
||||
services.AddTransient<IVisitorRepository, VisitorRepository>();
|
||||
services.AddTransient<IUrlMappingRepository, UrlMappingRepository>();
|
||||
services.AddTransient<ISearchDocumentRepository, SearchDocumentRepository>();
|
||||
|
||||
// managers
|
||||
services.AddTransient<IDBContextDependencies, DBContextDependencies>();
|
||||
|
|
88
Oqtane.Server/Infrastructure/Jobs/SearchIndexJob.cs
Normal file
88
Oqtane.Server/Infrastructure/Jobs/SearchIndexJob.cs
Normal file
|
@ -0,0 +1,88 @@
|
|||
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
|
||||
{
|
||||
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, Constants.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, Constants.SearchIndexStartTimeSettingName);
|
||||
if (setting == null)
|
||||
{
|
||||
setting = new Setting
|
||||
{
|
||||
EntityName = EntityNames.Site,
|
||||
EntityId = siteId,
|
||||
SettingName = Constants.SearchIndexStartTimeSettingName,
|
||||
SettingValue = Convert.ToString(startTime),
|
||||
};
|
||||
|
||||
settingRepository.AddSetting(setting);
|
||||
}
|
||||
else
|
||||
{
|
||||
setting.SettingValue = Convert.ToString(startTime);
|
||||
settingRepository.UpdateSetting(setting);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -133,6 +133,30 @@ namespace Oqtane.SiteTemplates
|
|||
}
|
||||
}
|
||||
});
|
||||
_pageTemplates.Add(new PageTemplate
|
||||
{
|
||||
Name = "Search Results",
|
||||
Parent = "",
|
||||
Order = 7,
|
||||
Path = "search-results",
|
||||
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.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")))
|
||||
{
|
||||
|
|
127
Oqtane.Server/Managers/Search/ModuleSearchIndexManager.cs
Normal file
127
Oqtane.Server/Managers/Search/ModuleSearchIndexManager.cs
Normal file
|
@ -0,0 +1,127 @@
|
|||
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
|
||||
{
|
||||
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 => Constants.ModuleSearchIndexManagerName;
|
||||
|
||||
public override int Priority => Constants.ModuleSearchIndexManagerPriority;
|
||||
|
||||
public override int IndexDocuments(int siteId, DateTime? startTime, Action<IList<SearchDocument>> processSearchDocuments, Action<string> handleError)
|
||||
{
|
||||
var pageModules = _pageModuleRepostory.GetPageModules(siteId).DistinctBy(i => i.ModuleId);
|
||||
var searchDocuments = new List<SearchDocument>();
|
||||
|
||||
foreach(var pageModule in pageModules)
|
||||
{
|
||||
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("IModuleSearch") != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var moduleSearch = (IModuleSearch)ActivatorUtilities.CreateInstance(_serviceProvider, type);
|
||||
var documents = moduleSearch.GetSearchDocuments(module, startTime.GetValueOrDefault(DateTime.MinValue));
|
||||
if(documents != null)
|
||||
{
|
||||
foreach(var document in documents)
|
||||
{
|
||||
SaveModuleMetaData(document, pageModule);
|
||||
|
||||
searchDocuments.Add(document);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
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}.");
|
||||
}
|
||||
}
|
||||
|
||||
processSearchDocuments(searchDocuments);
|
||||
|
||||
return searchDocuments.Count;
|
||||
}
|
||||
|
||||
private void SaveModuleMetaData(SearchDocument document, PageModule pageModule)
|
||||
{
|
||||
|
||||
document.EntryId = pageModule.ModuleId;
|
||||
document.IndexerName = Name;
|
||||
document.SiteId = pageModule.Module.SiteId;
|
||||
document.LanguageCode = string.Empty;
|
||||
|
||||
if(document.ModifiedTime == DateTime.MinValue)
|
||||
{
|
||||
document.ModifiedTime = pageModule.ModifiedOn;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(document.AdditionalContent))
|
||||
{
|
||||
document.AdditionalContent = string.Empty;
|
||||
}
|
||||
|
||||
var page = _pageRepository.GetPage(pageModule.PageId);
|
||||
|
||||
if (string.IsNullOrEmpty(document.Url) && page != null)
|
||||
{
|
||||
document.Url = page.Url;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(document.Title) && page != null)
|
||||
{
|
||||
document.Title = !string.IsNullOrEmpty(page.Title) ? page.Title : page.Name;
|
||||
}
|
||||
|
||||
if (document.Properties == null)
|
||||
{
|
||||
document.Properties = new List<SearchDocumentProperty>();
|
||||
}
|
||||
|
||||
if(!document.Properties.Any(i => i.Name == Constants.SearchPageIdPropertyName))
|
||||
{
|
||||
document.Properties.Add(new SearchDocumentProperty { Name = Constants.SearchPageIdPropertyName, Value = pageModule.PageId.ToString() });
|
||||
}
|
||||
|
||||
if (!document.Properties.Any(i => i.Name == Constants.SearchModuleIdPropertyName))
|
||||
{
|
||||
document.Properties.Add(new SearchDocumentProperty { Name = Constants.SearchModuleIdPropertyName, Value = pageModule.ModuleId.ToString() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
69
Oqtane.Server/Managers/Search/ModuleSearchResultManager.cs
Normal file
69
Oqtane.Server/Managers/Search/ModuleSearchResultManager.cs
Normal file
|
@ -0,0 +1,69 @@
|
|||
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 => Constants.ModuleSearchIndexManagerName;
|
||||
|
||||
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(SearchDocument searchResult, SearchQuery searchQuery)
|
||||
{
|
||||
var pageIdValue = searchResult.Properties?.FirstOrDefault(i => i.Name == Constants.SearchPageIdPropertyName)?.Value ?? string.Empty;
|
||||
var moduleIdValue = searchResult.Properties?.FirstOrDefault(i => i.Name == Constants.SearchModuleIdPropertyName)?.Value ?? string.Empty;
|
||||
if (!string.IsNullOrEmpty(pageIdValue) && int.TryParse(pageIdValue, out int pageId)
|
||||
&& !string.IsNullOrEmpty(moduleIdValue) && int.TryParse(moduleIdValue, out int moduleId))
|
||||
{
|
||||
return CanViewPage(pageId, searchQuery.User) && CanViewModule(moduleId, searchQuery.User);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool CanViewModule(int moduleId, User user)
|
||||
{
|
||||
var moduleRepository = _serviceProvider.GetRequiredService<IModuleRepository>();
|
||||
var module = moduleRepository.GetModule(moduleId);
|
||||
return module != null && !module.IsDeleted && UserSecurity.IsAuthorized(user, PermissionNames.View, module.PermissionList);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
94
Oqtane.Server/Managers/Search/PageSearchIndexManager.cs
Normal file
94
Oqtane.Server/Managers/Search/PageSearchIndexManager.cs
Normal file
|
@ -0,0 +1,94 @@
|
|||
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 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 => Constants.PageSearchIndexManagerName;
|
||||
|
||||
public override int Priority => Constants.PageSearchIndexManagerPriority;
|
||||
|
||||
public override int IndexDocuments(int siteId, DateTime? startTime, Action<IList<SearchDocument>> processSearchDocuments, Action<string> handleError)
|
||||
{
|
||||
var startTimeValue = startTime.GetValueOrDefault(DateTime.MinValue);
|
||||
var pages = _pageRepository.GetPages(siteId).Where(i => i.ModifiedOn >= startTimeValue);
|
||||
var searchDocuments = new List<SearchDocument>();
|
||||
|
||||
foreach(var page in pages)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(IsSystemPage(page))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var document = new SearchDocument
|
||||
{
|
||||
EntryId = page.PageId,
|
||||
IndexerName = Name,
|
||||
SiteId = page.SiteId,
|
||||
LanguageCode = string.Empty,
|
||||
ModifiedTime = page.ModifiedOn,
|
||||
AdditionalContent = string.Empty,
|
||||
Url = page.Url ?? string.Empty,
|
||||
Title = !string.IsNullOrEmpty(page.Title) ? page.Title : page.Name,
|
||||
Description = string.Empty,
|
||||
Body = $"{page.Name} {page.Title}"
|
||||
};
|
||||
|
||||
if (document.Properties == null)
|
||||
{
|
||||
document.Properties = new List<SearchDocumentProperty>();
|
||||
}
|
||||
|
||||
if (!document.Properties.Any(i => i.Name == Constants.SearchPageIdPropertyName))
|
||||
{
|
||||
document.Properties.Add(new SearchDocumentProperty { Name = Constants.SearchPageIdPropertyName, Value = page.PageId.ToString() });
|
||||
}
|
||||
|
||||
searchDocuments.Add(document);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Search: Index page {page.PageId} failed.");
|
||||
handleError($"Search: Index page {page.PageId} failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
processSearchDocuments(searchDocuments);
|
||||
|
||||
return searchDocuments.Count;
|
||||
}
|
||||
|
||||
private bool IsSystemPage(Models.Page page)
|
||||
{
|
||||
return page.Path.Contains("admin") || page.Path == "login" || page.Path == "register" || page.Path == "profile";
|
||||
}
|
||||
}
|
||||
}
|
46
Oqtane.Server/Managers/Search/PageSearchResultManager.cs
Normal file
46
Oqtane.Server/Managers/Search/PageSearchResultManager.cs
Normal file
|
@ -0,0 +1,46 @@
|
|||
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 PageSearchResultManager : ISearchResultManager
|
||||
{
|
||||
public string Name => Constants.PageSearchIndexManagerName;
|
||||
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
public PageSearchResultManager(
|
||||
IServiceProvider serviceProvider)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public string GetUrl(SearchResult searchResult, SearchQuery searchQuery)
|
||||
{
|
||||
var pageRepository = _serviceProvider.GetRequiredService<IPageRepository>();
|
||||
var page = pageRepository.GetPage(searchResult.EntryId);
|
||||
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(SearchDocument searchResult, SearchQuery searchQuery)
|
||||
{
|
||||
var pageRepository = _serviceProvider.GetRequiredService<IPageRepository>();
|
||||
var page = pageRepository.GetPage(searchResult.EntryId);
|
||||
|
||||
return page != null && !page.IsDeleted
|
||||
&& UserSecurity.IsAuthorized(searchQuery.User, PermissionNames.View, page.PermissionList)
|
||||
&& (Utilities.IsPageModuleVisible(page.EffectiveDate, page.ExpiryDate) || UserSecurity.IsAuthorized(searchQuery.User, PermissionNames.Edit, page.PermissionList));
|
||||
}
|
||||
}
|
||||
}
|
34
Oqtane.Server/Managers/Search/SearchIndexManagerBase.cs
Normal file
34
Oqtane.Server/Managers/Search/SearchIndexManagerBase.cs
Normal file
|
@ -0,0 +1,34 @@
|
|||
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 readonly IServiceProvider _serviceProvider;
|
||||
|
||||
public SearchIndexManagerBase(IServiceProvider serviceProvider)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public abstract int Priority { get; }
|
||||
|
||||
public abstract string Name { get; }
|
||||
|
||||
public abstract int IndexDocuments(int siteId, DateTime? startDate, Action<IList<SearchDocument>> processSearchDocuments, Action<string> handleError);
|
||||
|
||||
public virtual bool IsIndexEnabled(int siteId)
|
||||
{
|
||||
var settingName = string.Format(Constants.SearchIndexManagerEnabledSettingFormat, Name);
|
||||
var settingRepository = _serviceProvider.GetRequiredService<ISettingRepository>();
|
||||
var setting = settingRepository.GetSetting(EntityNames.Site, siteId, settingName);
|
||||
return setting == null || setting.SettingValue == "true";
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
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 SearchDocumentEntityBuilder : AuditableBaseEntityBuilder<SearchDocumentEntityBuilder>
|
||||
{
|
||||
private const string _entityTableName = "SearchDocument";
|
||||
private readonly PrimaryKey<SearchDocumentEntityBuilder> _primaryKey = new("PK_SearchDocument", x => x.SearchDocumentId);
|
||||
|
||||
public SearchDocumentEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database)
|
||||
{
|
||||
EntityTableName = _entityTableName;
|
||||
PrimaryKey = _primaryKey;
|
||||
}
|
||||
|
||||
protected override SearchDocumentEntityBuilder BuildTable(ColumnsBuilder table)
|
||||
{
|
||||
SearchDocumentId = AddAutoIncrementColumn(table, "SearchDocumentId");
|
||||
EntryId = AddIntegerColumn(table, "EntryId");
|
||||
IndexerName = AddStringColumn(table, "IndexerName", 50);
|
||||
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");
|
||||
LanguageCode = AddStringColumn(table, "LanguageCode", 20);
|
||||
|
||||
AddAuditableColumns(table);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public OperationBuilder<AddColumnOperation> SearchDocumentId { get; private set; }
|
||||
|
||||
public OperationBuilder<AddColumnOperation> EntryId { get; private set; }
|
||||
|
||||
public OperationBuilder<AddColumnOperation> IndexerName { 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; }
|
||||
|
||||
public OperationBuilder<AddColumnOperation> LanguageCode { get; private set; }
|
||||
|
||||
}
|
||||
}
|
|
@ -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 SearchDocumentPropertyEntityBuilder : BaseEntityBuilder<SearchDocumentPropertyEntityBuilder>
|
||||
{
|
||||
private const string _entityTableName = "SearchDocumentProperty";
|
||||
private readonly PrimaryKey<SearchDocumentPropertyEntityBuilder> _primaryKey = new("PK_SearchDocumentProperty", x => x.PropertyId);
|
||||
private readonly ForeignKey<SearchDocumentPropertyEntityBuilder> _searchDocumentForeignKey = new("FK_SearchDocumentProperty_SearchDocument", x => x.SearchDocumentId, "SearchDocument", "SearchDocumentId", ReferentialAction.Cascade);
|
||||
|
||||
public SearchDocumentPropertyEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database)
|
||||
{
|
||||
EntityTableName = _entityTableName;
|
||||
PrimaryKey = _primaryKey;
|
||||
|
||||
ForeignKeys.Add(_searchDocumentForeignKey);
|
||||
}
|
||||
|
||||
protected override SearchDocumentPropertyEntityBuilder BuildTable(ColumnsBuilder table)
|
||||
{
|
||||
PropertyId = AddAutoIncrementColumn(table, "PropertyId");
|
||||
SearchDocumentId = AddIntegerColumn(table, "SearchDocumentId");
|
||||
Name = AddStringColumn(table, "Name", 50);
|
||||
Value = AddStringColumn(table, "Value", 50);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public OperationBuilder<AddColumnOperation> PropertyId { get; private set; }
|
||||
|
||||
public OperationBuilder<AddColumnOperation> SearchDocumentId { get; private set; }
|
||||
|
||||
public OperationBuilder<AddColumnOperation> Name { get; private set; }
|
||||
|
||||
public OperationBuilder<AddColumnOperation> Value { get; private set; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
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 SearchDocumentTagEntityBuilder : BaseEntityBuilder<SearchDocumentTagEntityBuilder>
|
||||
{
|
||||
private const string _entityTableName = "SearchDocumentTag";
|
||||
private readonly PrimaryKey<SearchDocumentTagEntityBuilder> _primaryKey = new("PK_SearchDocumentTag", x => x.TagId);
|
||||
private readonly ForeignKey<SearchDocumentTagEntityBuilder> _searchDocumentForeignKey = new("FK_SearchDocumentTag_SearchDocument", x => x.SearchDocumentId, "SearchDocument", "SearchDocumentId", ReferentialAction.Cascade);
|
||||
|
||||
public SearchDocumentTagEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database)
|
||||
{
|
||||
EntityTableName = _entityTableName;
|
||||
PrimaryKey = _primaryKey;
|
||||
|
||||
ForeignKeys.Add(_searchDocumentForeignKey);
|
||||
}
|
||||
|
||||
protected override SearchDocumentTagEntityBuilder BuildTable(ColumnsBuilder table)
|
||||
{
|
||||
TagId = AddAutoIncrementColumn(table, "TagId");
|
||||
SearchDocumentId = AddIntegerColumn(table, "SearchDocumentId");
|
||||
Tag = AddStringColumn(table, "Tag", 50);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public OperationBuilder<AddColumnOperation> TagId { get; private set; }
|
||||
|
||||
public OperationBuilder<AddColumnOperation> SearchDocumentId { get; private set; }
|
||||
|
||||
public OperationBuilder<AddColumnOperation> Tag { get; private set; }
|
||||
}
|
||||
}
|
42
Oqtane.Server/Migrations/Tenant/05020001_AddSearchTables.cs
Normal file
42
Oqtane.Server/Migrations/Tenant/05020001_AddSearchTables.cs
Normal file
|
@ -0,0 +1,42 @@
|
|||
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 searchDocumentEntityBuilder = new SearchDocumentEntityBuilder(migrationBuilder, ActiveDatabase);
|
||||
searchDocumentEntityBuilder.Create();
|
||||
|
||||
var searchDocumentPropertyEntityBuilder = new SearchDocumentPropertyEntityBuilder(migrationBuilder, ActiveDatabase);
|
||||
searchDocumentPropertyEntityBuilder.Create();
|
||||
|
||||
var searchDocumentTagEntityBuilder = new SearchDocumentTagEntityBuilder(migrationBuilder, ActiveDatabase);
|
||||
searchDocumentTagEntityBuilder.Create();
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
var searchDocumentPropertyEntityBuilder = new SearchDocumentPropertyEntityBuilder(migrationBuilder, ActiveDatabase);
|
||||
searchDocumentPropertyEntityBuilder.Drop();
|
||||
|
||||
var searchDocumentTagEntityBuilder = new SearchDocumentTagEntityBuilder(migrationBuilder, ActiveDatabase);
|
||||
searchDocumentTagEntityBuilder.Drop();
|
||||
|
||||
var searchDocumentEntityBuilder = new SearchDocumentEntityBuilder(migrationBuilder, ActiveDatabase);
|
||||
searchDocumentEntityBuilder.Drop();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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, IModuleSearch
|
||||
{
|
||||
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<SearchDocument> GetSearchDocuments(Module module, DateTime startDate)
|
||||
{
|
||||
var searchDocuments = new List<SearchDocument>();
|
||||
|
||||
var htmltexts = _htmlText.GetHtmlTexts(module.ModuleId);
|
||||
if (htmltexts != null && htmltexts.Any(i => i.CreatedOn >= startDate))
|
||||
{
|
||||
var htmltext = htmltexts.OrderByDescending(item => item.CreatedOn).First();
|
||||
|
||||
searchDocuments.Add(new SearchDocument
|
||||
{
|
||||
Title = module.Title,
|
||||
Description = string.Empty,
|
||||
Body = SearchUtils.Clean(htmltext.Content, true),
|
||||
ModifiedTime = htmltext.ModifiedOn
|
||||
});
|
||||
}
|
||||
|
||||
return searchDocuments;
|
||||
}
|
||||
|
||||
public void ImportModule(Module module, string content, string version)
|
||||
{
|
||||
content = WebUtility.HtmlDecode(content);
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
using System;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Oqtane.Controllers;
|
||||
using Oqtane.Documentation;
|
||||
using Oqtane.Enums;
|
||||
using Oqtane.Infrastructure;
|
||||
using Oqtane.Modules.SearchResults.Services;
|
||||
using Oqtane.Shared;
|
||||
|
||||
namespace Oqtane.Modules.SearchResults.Controllers
|
||||
{
|
||||
[Route(ControllerRoutes.ApiRoute)]
|
||||
[PrivateApi("Mark SearchResults classes as private, since it's not very useful in the public docs")]
|
||||
public class SearchResultsController : ModuleControllerBase
|
||||
{
|
||||
private readonly ISearchResultsService _searchResultsService;
|
||||
|
||||
public SearchResultsController(ISearchResultsService searchResultsService, ILogManager logger, IHttpContextAccessor accessor) : base(logger, accessor)
|
||||
{
|
||||
_searchResultsService = searchResultsService;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = PolicyNames.ViewModule)]
|
||||
public async Task<Models.SearchResults> Post([FromBody] Models.SearchQuery searchQuery)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _searchResultsService.SearchAsync(AuthEntityId(EntityNames.Module), 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Oqtane.Documentation;
|
||||
using Oqtane.Infrastructure;
|
||||
using Oqtane.Models;
|
||||
using Oqtane.Services;
|
||||
|
||||
namespace Oqtane.Modules.SearchResults.Services
|
||||
{
|
||||
[PrivateApi("Mark SearchResults classes as private, since it's not very useful in the public docs")]
|
||||
public class ServerSearchResultsService : ISearchResultsService, ITransientService
|
||||
{
|
||||
private readonly ILogManager _logger;
|
||||
private readonly IHttpContextAccessor _accessor;
|
||||
private readonly Alias _alias;
|
||||
private readonly ISearchService _searchService;
|
||||
|
||||
public ServerSearchResultsService(
|
||||
ITenantManager tenantManager,
|
||||
ILogManager logger,
|
||||
IHttpContextAccessor accessor,
|
||||
ISearchService searchService)
|
||||
{
|
||||
_logger = logger;
|
||||
_accessor = accessor;
|
||||
_alias = tenantManager.GetAlias();
|
||||
_searchService = searchService;
|
||||
}
|
||||
|
||||
public async Task<Models.SearchResults> SearchAsync(int moduleId, SearchQuery searchQuery)
|
||||
{
|
||||
var results = await _searchService.SearchAsync(searchQuery);
|
||||
return results;
|
||||
}
|
||||
}
|
||||
}
|
24
Oqtane.Server/Modules/SearchResults/Startup/ServerStartup.cs
Normal file
24
Oqtane.Server/Modules/SearchResults/Startup/ServerStartup.cs
Normal file
|
@ -0,0 +1,24 @@
|
|||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Oqtane.Infrastructure;
|
||||
using Oqtane.Modules.SearchResults.Services;
|
||||
|
||||
namespace Oqtane.Modules.SearchResults.Startup
|
||||
{
|
||||
public class ServerStartup : IServerStartup
|
||||
{
|
||||
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
|
||||
{
|
||||
}
|
||||
|
||||
public void ConfigureMvc(IMvcBuilder mvcBuilder)
|
||||
{
|
||||
}
|
||||
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
services.AddTransient<ISearchResultsService, ServerSearchResultsService>();
|
||||
}
|
||||
}
|
||||
}
|
202
Oqtane.Server/Providers/DatabaseSearchProvider.cs
Normal file
202
Oqtane.Server/Providers/DatabaseSearchProvider.cs
Normal file
|
@ -0,0 +1,202 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
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 ISearchDocumentRepository _searchDocumentRepository;
|
||||
|
||||
private const float TitleBoost = 100f;
|
||||
private const float DescriptionBoost = 10f;
|
||||
private const float BodyBoost = 10f;
|
||||
private const float AdditionalContentBoost = 5f;
|
||||
|
||||
public string Name => Constants.DefaultSearchProviderName;
|
||||
|
||||
public DatabaseSearchProvider(ISearchDocumentRepository searchDocumentRepository)
|
||||
{
|
||||
_searchDocumentRepository = searchDocumentRepository;
|
||||
}
|
||||
|
||||
public void Commit()
|
||||
{
|
||||
}
|
||||
|
||||
public void DeleteDocument(string id)
|
||||
{
|
||||
_searchDocumentRepository.DeleteSearchDocument(id);
|
||||
}
|
||||
|
||||
public bool Optimize()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public void ResetIndex()
|
||||
{
|
||||
_searchDocumentRepository.DeleteAllSearchDocuments();
|
||||
}
|
||||
|
||||
public void SaveDocument(SearchDocument document, bool autoCommit = false)
|
||||
{
|
||||
//remove exist document
|
||||
_searchDocumentRepository.DeleteSearchDocument(document.IndexerName, document.EntryId);
|
||||
|
||||
_searchDocumentRepository.AddSearchDocument(document);
|
||||
}
|
||||
|
||||
public async Task<SearchResults> SearchAsync(SearchQuery searchQuery, Func<SearchDocument, SearchQuery, bool> validateFunc)
|
||||
{
|
||||
var totalResults = 0;
|
||||
|
||||
var documents = await _searchDocumentRepository.GetSearchDocumentsAsync(searchQuery);
|
||||
|
||||
//convert the search documents to search results.
|
||||
var results = documents
|
||||
.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.IndexerName == Constants.PageSearchIndexManagerName || i.IndexerName == Constants.ModuleSearchIndexManagerName)
|
||||
{
|
||||
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(SearchDocument searchDocument, SearchQuery searchQuery)
|
||||
{
|
||||
var searchResult = new SearchResult()
|
||||
{
|
||||
SearchDocumentId = searchDocument.SearchDocumentId,
|
||||
SiteId = searchDocument.SiteId,
|
||||
IndexerName = searchDocument.IndexerName,
|
||||
EntryId = searchDocument.EntryId,
|
||||
Title = searchDocument.Title,
|
||||
Description = searchDocument.Description,
|
||||
Body = searchDocument.Body,
|
||||
Url = searchDocument.Url,
|
||||
ModifiedTime = searchDocument.ModifiedTime,
|
||||
Tags = searchDocument.Tags,
|
||||
Properties = searchDocument.Properties,
|
||||
Snippet = BuildSnippet(searchDocument, searchQuery),
|
||||
Score = CalculateScore(searchDocument, searchQuery)
|
||||
};
|
||||
|
||||
return searchResult;
|
||||
}
|
||||
|
||||
private float CalculateScore(SearchDocument searchDocument, SearchQuery searchQuery)
|
||||
{
|
||||
var score = 0f;
|
||||
foreach (var keyword in SearchUtils.GetKeywordsList(searchQuery.Keywords))
|
||||
{
|
||||
score += Regex.Matches(searchDocument.Title, keyword, RegexOptions.IgnoreCase).Count * TitleBoost;
|
||||
score += Regex.Matches(searchDocument.Description, keyword, RegexOptions.IgnoreCase).Count * DescriptionBoost;
|
||||
score += Regex.Matches(searchDocument.Body, keyword, RegexOptions.IgnoreCase).Count * BodyBoost;
|
||||
score += Regex.Matches(searchDocument.AdditionalContent, keyword, RegexOptions.IgnoreCase).Count * AdditionalContentBoost;
|
||||
}
|
||||
|
||||
return score / 100;
|
||||
}
|
||||
|
||||
private string BuildSnippet(SearchDocument searchDocument, SearchQuery searchQuery)
|
||||
{
|
||||
var content = $"{searchDocument.Title} {searchDocument.Description} {searchDocument.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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -29,5 +29,8 @@ 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<SearchDocument> SearchDocument { get; set; }
|
||||
public virtual DbSet<SearchDocumentProperty> SearchDocumentProperty { get; set; }
|
||||
public virtual DbSet<SearchDocumentTag> SearchDocumentTag { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Oqtane.Models;
|
||||
|
||||
namespace Oqtane.Repository
|
||||
{
|
||||
public interface ISearchDocumentRepository
|
||||
{
|
||||
Task<IEnumerable<SearchDocument>> GetSearchDocumentsAsync(SearchQuery searchQuery);
|
||||
SearchDocument AddSearchDocument(SearchDocument searchDocument);
|
||||
void DeleteSearchDocument(int searchDocumentId);
|
||||
void DeleteSearchDocument(string indexerName, int entryId);
|
||||
void DeleteSearchDocument(string uniqueKey);
|
||||
void DeleteAllSearchDocuments();
|
||||
}
|
||||
}
|
136
Oqtane.Server/Repository/SearchDocumentRepository.cs
Normal file
136
Oqtane.Server/Repository/SearchDocumentRepository.cs
Normal file
|
@ -0,0 +1,136 @@
|
|||
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 SearchDocumentRepository : ISearchDocumentRepository
|
||||
{
|
||||
private readonly IDbContextFactory<TenantDBContext> _dbContextFactory;
|
||||
|
||||
public SearchDocumentRepository(IDbContextFactory<TenantDBContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SearchDocument>> GetSearchDocumentsAsync(SearchQuery searchQuery)
|
||||
{
|
||||
using var db = _dbContextFactory.CreateDbContext();
|
||||
var documents = db.SearchDocument.AsNoTracking()
|
||||
.Include(i => i.Properties)
|
||||
.Include(i => i.Tags)
|
||||
.Where(i => i.SiteId == searchQuery.SiteId);
|
||||
|
||||
if (searchQuery.Sources != null && searchQuery.Sources.Any())
|
||||
{
|
||||
documents = documents.Where(i => searchQuery.Sources.Contains(i.IndexerName));
|
||||
}
|
||||
|
||||
if (searchQuery.BeginModifiedTimeUtc != DateTime.MinValue)
|
||||
{
|
||||
documents = documents.Where(i => i.ModifiedTime >= searchQuery.BeginModifiedTimeUtc);
|
||||
}
|
||||
|
||||
if (searchQuery.EndModifiedTimeUtc != DateTime.MinValue)
|
||||
{
|
||||
documents = documents.Where(i => i.ModifiedTime <= searchQuery.EndModifiedTimeUtc);
|
||||
}
|
||||
|
||||
if (searchQuery.Tags != null && searchQuery.Tags.Any())
|
||||
{
|
||||
foreach (var tag in searchQuery.Tags)
|
||||
{
|
||||
documents = documents.Where(i => i.Tags.Any(t => t.Tag == tag));
|
||||
}
|
||||
}
|
||||
|
||||
if (searchQuery.Properties != null && searchQuery.Properties.Any())
|
||||
{
|
||||
foreach (var property in searchQuery.Properties)
|
||||
{
|
||||
documents = documents.Where(i => i.Properties.Any(p => p.Name == property.Key && p.Value == property.Value));
|
||||
}
|
||||
}
|
||||
|
||||
var filteredDocuments = new List<SearchDocument>();
|
||||
if (!string.IsNullOrEmpty(searchQuery.Keywords))
|
||||
{
|
||||
foreach (var keyword in SearchUtils.GetKeywordsList(searchQuery.Keywords))
|
||||
{
|
||||
filteredDocuments.AddRange(await documents.Where(i => i.Title.Contains(keyword) || i.Description.Contains(keyword) || i.Body.Contains(keyword)).ToListAsync());
|
||||
}
|
||||
}
|
||||
|
||||
return filteredDocuments.DistinctBy(i => i.UniqueKey);
|
||||
}
|
||||
|
||||
public SearchDocument AddSearchDocument(SearchDocument searchDocument)
|
||||
{
|
||||
using var context = _dbContextFactory.CreateDbContext();
|
||||
context.SearchDocument.Add(searchDocument);
|
||||
|
||||
if(searchDocument.Properties != null && searchDocument.Properties.Any())
|
||||
{
|
||||
foreach(var property in searchDocument.Properties)
|
||||
{
|
||||
property.SearchDocumentId = searchDocument.SearchDocumentId;
|
||||
context.SearchDocumentProperty.Add(property);
|
||||
}
|
||||
}
|
||||
|
||||
if (searchDocument.Tags != null && searchDocument.Tags.Any())
|
||||
{
|
||||
foreach (var tag in searchDocument.Tags)
|
||||
{
|
||||
tag.SearchDocumentId = searchDocument.SearchDocumentId;
|
||||
context.SearchDocumentTag.Add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
context.SaveChanges();
|
||||
|
||||
return searchDocument;
|
||||
}
|
||||
|
||||
public void DeleteSearchDocument(int searchDocumentId)
|
||||
{
|
||||
using var db = _dbContextFactory.CreateDbContext();
|
||||
var searchDocument = db.SearchDocument.Find(searchDocumentId);
|
||||
db.SearchDocument.Remove(searchDocument);
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
public void DeleteSearchDocument(string indexerName, int entryId)
|
||||
{
|
||||
using var db = _dbContextFactory.CreateDbContext();
|
||||
var searchDocument = db.SearchDocument.FirstOrDefault(i => i.IndexerName == indexerName && i.EntryId == entryId);
|
||||
if(searchDocument != null)
|
||||
{
|
||||
db.SearchDocument.Remove(searchDocument);
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteSearchDocument(string uniqueKey)
|
||||
{
|
||||
using var db = _dbContextFactory.CreateDbContext();
|
||||
var searchDocument = db.SearchDocument.FirstOrDefault(i => (i.IndexerName + ":" + i.EntryId) == uniqueKey);
|
||||
if (searchDocument != null)
|
||||
{
|
||||
db.SearchDocument.Remove(searchDocument);
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteAllSearchDocuments()
|
||||
{
|
||||
using var db = _dbContextFactory.CreateDbContext();
|
||||
db.SearchDocument.RemoveRange(db.SearchDocument);
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
}
|
214
Oqtane.Server/Services/SearchService.cs
Normal file
214
Oqtane.Server/Services/SearchService.cs
Normal file
|
@ -0,0 +1,214 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Oqtane.Infrastructure;
|
||||
using Oqtane.Models;
|
||||
using Oqtane.Repository;
|
||||
using Oqtane.Shared;
|
||||
|
||||
namespace Oqtane.Services
|
||||
{
|
||||
public class SearchService : ISearchService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ITenantManager _tenantManager;
|
||||
private readonly IAliasRepository _aliasRepository;
|
||||
private readonly ISettingRepository _settingRepository;
|
||||
private readonly ILogger<SearchService> _logger;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public SearchService(
|
||||
IServiceProvider serviceProvider,
|
||||
ITenantManager tenantManager,
|
||||
IAliasRepository aliasRepository,
|
||||
ISettingRepository settingRepository,
|
||||
ILogger<SearchService> logger,
|
||||
IMemoryCache cache)
|
||||
{
|
||||
_tenantManager = tenantManager;
|
||||
_aliasRepository = aliasRepository;
|
||||
_settingRepository = settingRepository;
|
||||
_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.IndexDocuments(siteId, startTime, SaveIndexDocuments, handleError);
|
||||
logNote($"Search: Indexer {searchIndexManager.Name} processed {count} documents.<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, HasViewPermission);
|
||||
|
||||
//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, Constants.SearchProviderSettingName);
|
||||
if(!string.IsNullOrEmpty(setting?.SettingValue))
|
||||
{
|
||||
return setting.SettingValue;
|
||||
}
|
||||
|
||||
return Constants.DefaultSearchProviderName;
|
||||
}
|
||||
|
||||
private bool SearchEnabled(int siteId)
|
||||
{
|
||||
var setting = _settingRepository.GetSetting(EntityNames.Site, siteId, Constants.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 SaveIndexDocuments(IList<SearchDocument> searchDocuments)
|
||||
{
|
||||
if(searchDocuments.Any())
|
||||
{
|
||||
var searchProvider = GetSearchProvider(searchDocuments.First().SiteId);
|
||||
|
||||
foreach (var searchDocument in searchDocuments)
|
||||
{
|
||||
try
|
||||
{
|
||||
searchProvider.SaveDocument(searchDocument);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Search: Save search document {searchDocument.UniqueKey} failed.");
|
||||
}
|
||||
}
|
||||
|
||||
//commit the index changes
|
||||
searchProvider.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasViewPermission(SearchDocument searchDocument, SearchQuery searchQuery)
|
||||
{
|
||||
var searchResultManager = GetSearchResultManagers().FirstOrDefault(i => i.Name == searchDocument.IndexerName);
|
||||
if (searchResultManager != null)
|
||||
{
|
||||
return searchResultManager.Visible(searchDocument, searchQuery);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private string GetDocumentUrl(SearchResult result, SearchQuery searchQuery)
|
||||
{
|
||||
var searchResultManager = GetSearchResultManagers().FirstOrDefault(i => i.Name == result.IndexerName);
|
||||
if(searchResultManager != null)
|
||||
{
|
||||
return searchResultManager.GetUrl(result, searchQuery);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
.search-result-container ul.pagination li label, .search-result-container ul.dropdown-menu li label {
|
||||
cursor: pointer;
|
||||
}
|
|
@ -79,6 +79,10 @@ body {
|
|||
top: -2px;
|
||||
}
|
||||
|
||||
.app-search input{
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.navbar-toggler {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
margin: .5rem;
|
||||
|
|
|
@ -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;
|
||||
}
|
10
Oqtane.Shared/Enums/SearchSortDirections.cs
Normal file
10
Oqtane.Shared/Enums/SearchSortDirections.cs
Normal file
|
@ -0,0 +1,10 @@
|
|||
using System;
|
||||
|
||||
namespace Oqtane.Shared
|
||||
{
|
||||
public enum SearchSortDirections
|
||||
{
|
||||
Ascending,
|
||||
Descending
|
||||
}
|
||||
}
|
11
Oqtane.Shared/Enums/SearchSortFields.cs
Normal file
11
Oqtane.Shared/Enums/SearchSortFields.cs
Normal file
|
@ -0,0 +1,11 @@
|
|||
using System;
|
||||
|
||||
namespace Oqtane.Shared
|
||||
{
|
||||
public enum SearchSortFields
|
||||
{
|
||||
Relevance,
|
||||
Title,
|
||||
LastModified
|
||||
}
|
||||
}
|
14
Oqtane.Shared/Interfaces/IModuleSearch.cs
Normal file
14
Oqtane.Shared/Interfaces/IModuleSearch.cs
Normal 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 IModuleSearch
|
||||
{
|
||||
public IList<SearchDocument> GetSearchDocuments(Module module, DateTime startTime);
|
||||
}
|
||||
}
|
20
Oqtane.Shared/Interfaces/ISearchIndexManager.cs
Normal file
20
Oqtane.Shared/Interfaces/ISearchIndexManager.cs
Normal 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 IndexDocuments(int siteId, DateTime? startTime, Action<IList<SearchDocument>> processSearchDocuments, Action<string> handleError);
|
||||
}
|
||||
}
|
26
Oqtane.Shared/Interfaces/ISearchProvider.cs
Normal file
26
Oqtane.Shared/Interfaces/ISearchProvider.cs
Normal 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 SaveDocument(SearchDocument document, bool autoCommit = false);
|
||||
|
||||
void DeleteDocument(string id);
|
||||
|
||||
Task<SearchResults> SearchAsync(SearchQuery searchQuery, Func<SearchDocument, SearchQuery, bool> validateFunc);
|
||||
|
||||
bool Optimize();
|
||||
|
||||
void Commit();
|
||||
|
||||
void ResetIndex();
|
||||
}
|
||||
}
|
14
Oqtane.Shared/Interfaces/ISearchResultManager.cs
Normal file
14
Oqtane.Shared/Interfaces/ISearchResultManager.cs
Normal file
|
@ -0,0 +1,14 @@
|
|||
using System;
|
||||
using Oqtane.Models;
|
||||
|
||||
namespace Oqtane.Services
|
||||
{
|
||||
public interface ISearchResultManager
|
||||
{
|
||||
string Name { get; }
|
||||
|
||||
bool Visible(SearchDocument searchResult, SearchQuery searchQuery);
|
||||
|
||||
string GetUrl(SearchResult searchResult, SearchQuery searchQuery);
|
||||
}
|
||||
}
|
15
Oqtane.Shared/Interfaces/ISearchService.cs
Normal file
15
Oqtane.Shared/Interfaces/ISearchService.cs
Normal 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);
|
||||
}
|
||||
}
|
45
Oqtane.Shared/Models/SearchDocument.cs
Normal file
45
Oqtane.Shared/Models/SearchDocument.cs
Normal file
|
@ -0,0 +1,45 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json;
|
||||
namespace Oqtane.Models
|
||||
{
|
||||
public class SearchDocument : ModelBase
|
||||
{
|
||||
public int SearchDocumentId { get; set; }
|
||||
|
||||
[NotMapped]
|
||||
public string UniqueKey => $"{IndexerName}:{EntryId}";
|
||||
|
||||
public int EntryId { get; set; }
|
||||
|
||||
public string IndexerName { 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; }
|
||||
|
||||
public string AdditionalContent { get; set; }
|
||||
|
||||
public string LanguageCode { get; set; }
|
||||
|
||||
public IList<SearchDocumentTag> Tags { get; set; }
|
||||
|
||||
public IList<SearchDocumentProperty> Properties { get; set; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return JsonSerializer.Serialize(this);
|
||||
}
|
||||
}
|
||||
}
|
17
Oqtane.Shared/Models/SearchDocumentProperty.cs
Normal file
17
Oqtane.Shared/Models/SearchDocumentProperty.cs
Normal file
|
@ -0,0 +1,17 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Oqtane.Models
|
||||
{
|
||||
public class SearchDocumentProperty
|
||||
{
|
||||
[Key]
|
||||
public int PropertyId { get; set; }
|
||||
|
||||
public int SearchDocumentId { get; set; }
|
||||
|
||||
public string Name { get; set; }
|
||||
|
||||
public string Value { get; set; }
|
||||
}
|
||||
}
|
14
Oqtane.Shared/Models/SearchDocumentTag.cs
Normal file
14
Oqtane.Shared/Models/SearchDocumentTag.cs
Normal file
|
@ -0,0 +1,14 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Oqtane.Models
|
||||
{
|
||||
public class SearchDocumentTag
|
||||
{
|
||||
[Key]
|
||||
public int TagId { get; set; }
|
||||
|
||||
public int SearchDocumentId { get; set; }
|
||||
|
||||
public string Tag { get; set; }
|
||||
}
|
||||
}
|
37
Oqtane.Shared/Models/SearchQuery.cs
Normal file
37
Oqtane.Shared/Models/SearchQuery.cs
Normal file
|
@ -0,0 +1,37 @@
|
|||
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> Sources { get; set; } = new List<string>();
|
||||
|
||||
public DateTime BeginModifiedTimeUtc { get; set; }
|
||||
|
||||
public DateTime EndModifiedTimeUtc { get; set; }
|
||||
|
||||
public IList<string> Tags { get; set; } = new List<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
11
Oqtane.Shared/Models/SearchResult.cs
Normal file
11
Oqtane.Shared/Models/SearchResult.cs
Normal file
|
@ -0,0 +1,11 @@
|
|||
namespace Oqtane.Models
|
||||
{
|
||||
public class SearchResult : SearchDocument
|
||||
{
|
||||
public float Score { get; set; }
|
||||
|
||||
public string DisplayScore { get; set; }
|
||||
|
||||
public string Snippet { get; set; }
|
||||
}
|
||||
}
|
11
Oqtane.Shared/Models/SearchResults.cs
Normal file
11
Oqtane.Shared/Models/SearchResults.cs
Normal 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; }
|
||||
}
|
||||
}
|
|
@ -77,6 +77,22 @@ namespace Oqtane.Shared
|
|||
|
||||
public static readonly string VisitorCookiePrefix = "APP_VISITOR_";
|
||||
|
||||
public const string SearchIndexManagerEnabledSettingFormat = "SearchIndexManager_{0}_Enabled";
|
||||
public const string SearchIndexStartTimeSettingName = "SearchIndex_StartTime";
|
||||
public const string SearchResultManagersCacheName = "SearchResultManagers";
|
||||
public const int SearchDefaultPageSize = 10;
|
||||
public const string SearchPageIdPropertyName = "PageId";
|
||||
public const string SearchModuleIdPropertyName = "ModuleId";
|
||||
public const string DefaultSearchProviderName = "Database";
|
||||
public const string SearchProviderSettingName = "SearchProvider";
|
||||
public const string SearchEnabledSettingName = "SearchEnabled";
|
||||
|
||||
public const string ModuleSearchIndexManagerName = "Module";
|
||||
public const string PageSearchIndexManagerName = "Page";
|
||||
|
||||
public const int PageSearchIndexManagerPriority = 100;
|
||||
public const int ModuleSearchIndexManagerPriority = 200;
|
||||
|
||||
// Obsolete constants
|
||||
|
||||
const string RoleObsoleteMessage = "Use the corresponding member from Oqtane.Shared.RoleNames";
|
||||
|
|
95
Oqtane.Shared/Shared/SearchUtils.cs
Normal file
95
Oqtane.Shared/Shared/SearchUtils.cs
Normal file
|
@ -0,0 +1,95 @@
|
|||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Oqtane.Shared
|
||||
{
|
||||
public sealed class SearchUtils
|
||||
{
|
||||
private const string PunctuationMatch = "[~!#\\$%\\^&*\\(\\)-+=\\{\\[\\}\\]\\|;:\\x22'<,>\\.\\?\\\\\\t\\r\\v\\f\\n]";
|
||||
private static readonly Regex _stripWhiteSpaceRegex = new Regex("\\s+", RegexOptions.Compiled);
|
||||
private static readonly Regex _stripTagsRegex = new Regex("<[^<>]*>", RegexOptions.Compiled);
|
||||
private static readonly Regex _afterRegEx = new Regex(PunctuationMatch + "\\s", RegexOptions.Compiled);
|
||||
private static readonly Regex _beforeRegEx = new Regex("\\s" + PunctuationMatch, RegexOptions.Compiled);
|
||||
|
||||
public static string Clean(string html, bool removePunctuation)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (html.Contains("<"))
|
||||
{
|
||||
html = WebUtility.HtmlDecode(html);
|
||||
}
|
||||
|
||||
html = StripTags(html, true);
|
||||
html = WebUtility.HtmlDecode(html);
|
||||
|
||||
if (removePunctuation)
|
||||
{
|
||||
html = StripPunctuation(html, true);
|
||||
html = StripWhiteSpace(html, true);
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keywordsList;
|
||||
}
|
||||
|
||||
private static string StripTags(string html, bool retainSpace)
|
||||
{
|
||||
return _stripTagsRegex.Replace(html, retainSpace ? " " : string.Empty);
|
||||
}
|
||||
|
||||
private static string StripPunctuation(string html, bool retainSpace)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
string retHTML = html + " ";
|
||||
|
||||
var repString = retainSpace ? " " : string.Empty;
|
||||
while (_beforeRegEx.IsMatch(retHTML))
|
||||
{
|
||||
retHTML = _beforeRegEx.Replace(retHTML, repString);
|
||||
}
|
||||
|
||||
while (_afterRegEx.IsMatch(retHTML))
|
||||
{
|
||||
retHTML = _afterRegEx.Replace(retHTML, repString);
|
||||
}
|
||||
|
||||
return retHTML.Trim('"');
|
||||
}
|
||||
|
||||
private static string StripWhiteSpace(string html, bool retainSpace)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return _stripWhiteSpaceRegex.Replace(html, retainSpace ? " " : string.Empty);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user