#4303: add search function.

This commit is contained in:
Ben 2024-06-03 21:19:42 +08:00
parent d75e3acdf3
commit 9d85ca07f4
50 changed files with 2478 additions and 4 deletions

View 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();
}
}

View 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" }
}
};
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using Oqtane.Documentation;
using Oqtane.Models;
using Oqtane.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);
}
}
}

View 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);
}
}
}

View File

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

View File

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

View File

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

View File

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

View 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);
}
}
}

View File

@ -6,7 +6,12 @@
<nav class="navbar navbar-dark bg-primary fixed-top"> <nav class="navbar navbar-dark bg-primary fixed-top">
<Logo /><Menu Orientation="Horizontal" /> <Logo /><Menu Orientation="Horizontal" />
<div class="controls ms-auto"> <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> </div>
</nav> </nav>
<div class="content"> <div class="content">

View File

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

View 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);
}
}
}
}

View File

@ -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"))) if (System.IO.File.Exists(Path.Combine(_environment.WebRootPath, "images", "logo-white.png")))
{ {

View 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() });
}
}
}
}

View 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));
}
}
}

View 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";
}
}
}

View 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));
}
}
}

View 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";
}
}
}

View File

@ -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; }
}
}

View File

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations.Operations;
using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders;
using Oqtane.Databases.Interfaces;
namespace Oqtane.Migrations.EntityBuilders
{
public class 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; }
}
}

View File

@ -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; }
}
}

View 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();
}
}
}

View File

@ -8,20 +8,29 @@ using Oqtane.Shared;
using Oqtane.Migrations.Framework; using Oqtane.Migrations.Framework;
using Oqtane.Documentation; using Oqtane.Documentation;
using System.Linq; using System.Linq;
using Oqtane.Interfaces;
using System.Collections.Generic;
using System;
// ReSharper disable ConvertToUsingDeclaration // ReSharper disable ConvertToUsingDeclaration
namespace Oqtane.Modules.HtmlText.Manager namespace Oqtane.Modules.HtmlText.Manager
{ {
[PrivateApi("Mark HtmlText classes as private, since it's not very useful in the public docs")] [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 IHtmlTextRepository _htmlText;
private readonly IDBContextDependencies _DBContextDependencies; private readonly IDBContextDependencies _DBContextDependencies;
private readonly ISqlRepository _sqlRepository; 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; _htmlText = htmlText;
_DBContextDependencies = DBContextDependencies; _DBContextDependencies = DBContextDependencies;
_sqlRepository = sqlRepository; _sqlRepository = sqlRepository;
@ -39,6 +48,27 @@ namespace Oqtane.Modules.HtmlText.Manager
return content; 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) public void ImportModule(Module module, string content, string version)
{ {
content = WebUtility.HtmlDecode(content); content = WebUtility.HtmlDecode(content);

View File

@ -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;
}
}
}
}

View File

@ -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;
}
}
}

View 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>();
}
}
}

View 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;
}
}
}

View File

@ -29,5 +29,8 @@ namespace Oqtane.Repository
public virtual DbSet<Language> Language { get; set; } public virtual DbSet<Language> Language { get; set; }
public virtual DbSet<Visitor> Visitor { get; set; } public virtual DbSet<Visitor> Visitor { get; set; }
public virtual DbSet<UrlMapping> UrlMapping { 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; }
} }
} }

View File

@ -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();
}
}

View 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();
}
}
}

View 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;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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);
}
}

View File

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

View File

@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Oqtane.Models;
namespace Oqtane.Services
{
public interface ISearchProvider
{
string Name { get; }
void 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();
}
}

View 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);
}
}

View File

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

View File

@ -0,0 +1,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);
}
}
}

View 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; }
}
}

View 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; }
}
}

View 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;
}
}

View 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; }
}
}

View File

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

View File

@ -77,6 +77,22 @@ namespace Oqtane.Shared
public static readonly string VisitorCookiePrefix = "APP_VISITOR_"; 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 // Obsolete constants
const string RoleObsoleteMessage = "Use the corresponding member from Oqtane.Shared.RoleNames"; const string RoleObsoleteMessage = "Use the corresponding member from Oqtane.Shared.RoleNames";

View 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("&lt;"))
{
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);
}
}
}