diff --git a/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj b/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj index 167f3dbac..e5c45f381 100644 --- a/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj +++ b/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj @@ -9,6 +9,7 @@ + diff --git a/backend/extensions/Squidex.Extensions/Text/Azure/AzureIndexDefinition.cs b/backend/extensions/Squidex.Extensions/Text/Azure/AzureIndexDefinition.cs new file mode 100644 index 000000000..e8348badf --- /dev/null +++ b/backend/extensions/Squidex.Extensions/Text/Azure/AzureIndexDefinition.cs @@ -0,0 +1,130 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Azure.Search.Documents.Indexes.Models; + +namespace Squidex.Extensions.Text.Azure +{ + public static class AzureIndexDefinition + { + private static readonly Dictionary AllowedLanguages = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["iv"] = ("text_iv", LexicalAnalyzerName.StandardLucene.ToString()), + ["zh"] = ("text_zh", LexicalAnalyzerName.ZhHansLucene.ToString()) + }; + + static AzureIndexDefinition() + { + var analyzers = + typeof(LexicalAnalyzerName) + .GetProperties(BindingFlags.Public | BindingFlags.Static) + .Select(x => x.GetValue(null)) + .Select(x => x.ToString()) + .OrderBy(x => x) + .ToList(); + + var addedLanguage = new HashSet(); + + foreach (var analyzer in analyzers) + { + var indexOfDot = analyzer.IndexOf('.', StringComparison.Ordinal); + + if (indexOfDot > 0) + { + var language = analyzer[0..indexOfDot]; + + var isValidLanguage = + language.Length == 2 || + language.StartsWith("zh-", StringComparison.Ordinal); + + if (isValidLanguage && addedLanguage.Add(language)) + { + var fieldName = $"text_{language.Replace('-', '_')}"; + + AllowedLanguages[language] = (fieldName, analyzer); + } + } + } + } + + public static string GetTextField(string key) + { + if (AllowedLanguages.TryGetValue(key, out var field)) + { + return field.Field; + } + + if (key.Length > 2 && AllowedLanguages.TryGetValue(key[2..], out field)) + { + return field.Field; + } + + return AllowedLanguages["iv"].Field; + } + + public static SearchIndex Create(string indexName) + { + var fields = new List + { + new SimpleField("docId", SearchFieldDataType.String) + { + IsKey = true, + }, + new SimpleField("appId", SearchFieldDataType.String) + { + IsFilterable = true, + }, + new SimpleField("appName", SearchFieldDataType.String) + { + IsFilterable = false + }, + new SimpleField("contentId", SearchFieldDataType.String) + { + IsFilterable = false + }, + new SearchableField("schemaId") + { + IsFilterable = true + }, + new SimpleField("schemaName", SearchFieldDataType.String) + { + IsFilterable = false + }, + new SimpleField("serveAll", SearchFieldDataType.Boolean) + { + IsFilterable = true + }, + new SimpleField("servePublished", SearchFieldDataType.Boolean) + { + IsFilterable = true + } + }; + + foreach (var (field, analyzer) in AllowedLanguages.Values) + { + fields.Add( + new SearchableField(field) + { + IsFilterable = false, + IsFacetable = false, + AnalyzerName = analyzer + }); + } + + var index = new SearchIndex(indexName) + { + Fields = fields + }; + + return index; + } + } +} diff --git a/backend/extensions/Squidex.Extensions/Text/Azure/AzureTextIndex.cs b/backend/extensions/Squidex.Extensions/Text/Azure/AzureTextIndex.cs new file mode 100644 index 000000000..5f3df056e --- /dev/null +++ b/backend/extensions/Squidex.Extensions/Text/Azure/AzureTextIndex.cs @@ -0,0 +1,147 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Azure; +using Azure.Search.Documents; +using Azure.Search.Documents.Indexes; +using Azure.Search.Documents.Models; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Contents; +using Squidex.Domain.Apps.Entities.Contents.Text; +using Squidex.Hosting; +using Squidex.Infrastructure; + +namespace Squidex.Extensions.Text.Azure +{ + public sealed class AzureTextIndex : IInitializable, ITextIndex + { + private readonly SearchIndexClient indexClient; + private readonly SearchClient searchClient; + + public AzureTextIndex( + string serviceEndpoint, + string serviceApiKey, + string indexName) + { + indexClient = new SearchIndexClient(new Uri(serviceEndpoint), new AzureKeyCredential(serviceApiKey)); + + searchClient = indexClient.GetSearchClient(indexName); + } + + public async Task InitializeAsync( + CancellationToken ct) + { + await CreateIndexAsync(ct); + } + + public async Task ClearAsync( + CancellationToken ct = default) + { + await indexClient.DeleteIndexAsync(searchClient.IndexName, ct); + + await CreateIndexAsync(ct); + } + + private async Task CreateIndexAsync( + CancellationToken ct) + { + var index = AzureIndexDefinition.Create(searchClient.IndexName); + + await indexClient.CreateOrUpdateIndexAsync(index, true, true, ct); + } + + public async Task ExecuteAsync(IndexCommand[] commands, + CancellationToken ct = default) + { + var batch = IndexDocumentsBatch.Create(); + + commands.Foreach(x => CommandFactory.CreateCommands(x, batch.Actions)); + + await searchClient.IndexDocumentsAsync(batch, cancellationToken: ct); + } + + public Task> SearchAsync(IAppEntity app, GeoQuery query, SearchScope scope, + CancellationToken ct = default) + { + Guard.NotNull(app, nameof(app)); + Guard.NotNull(query, nameof(query)); + + return Task.FromResult>(null); + } + + public async Task> SearchAsync(IAppEntity app, TextQuery query, SearchScope scope, + CancellationToken ct = default) + { + Guard.NotNull(app, nameof(app)); + Guard.NotNull(query, nameof(query)); + + if (string.IsNullOrWhiteSpace(query.Text)) + { + return null; + } + + var searchOptions = new SearchOptions + { + Filter = BuildFilter(app, query, scope) + }; + + searchOptions.Select.Add("contentId"); + searchOptions.Size = 2000; + + var results = await searchClient.SearchAsync("*", searchOptions, ct); + + var ids = new List(); + + await foreach (var item in results.Value.GetResultsAsync().WithCancellation(ct)) + { + if (item != null) + { + ids.Add(DomainId.Create(item.Document["contentId"].ToString())); + } + } + + return ids; + } + + private static string BuildFilter(IAppEntity app, TextQuery query, SearchScope scope) + { + var sb = new StringBuilder(); + + sb.Append($"appId eq '{app.Id}' and {GetServeField(scope)} eq true"); + + if (query.RequiredSchemaIds?.Count > 0) + { + var schemaIds = string.Join(" or ", query.RequiredSchemaIds.Select(x => $"schemaId eq '{x}'")); + + sb.Append($" and ({schemaIds}) and search.ismatchscoring('{query.Text}')"); + } + else if (query.PreferredSchemaId.HasValue) + { + sb.Append($" and ((search.ismatchscoring('{query.Text}') and search.ismatchscoring('{query.PreferredSchemaId}', 'schemaId')) or search.ismatchscoring('{query.Text}'))"); + } + else + { + sb.Append($" and search.ismatchscoring('{query.Text}')"); + } + + return sb.ToString(); + } + + private static string GetServeField(SearchScope scope) + { + return scope == SearchScope.Published ? + "servePublished" : + "serveAll"; + } + } +} diff --git a/backend/extensions/Squidex.Extensions/Text/Azure/AzureTextPlugin.cs b/backend/extensions/Squidex.Extensions/Text/Azure/AzureTextPlugin.cs new file mode 100644 index 000000000..f8d60bd33 --- /dev/null +++ b/backend/extensions/Squidex.Extensions/Text/Azure/AzureTextPlugin.cs @@ -0,0 +1,62 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Squidex.Domain.Apps.Entities.Contents.Text; +using Squidex.Hosting; +using Squidex.Hosting.Configuration; +using Squidex.Infrastructure.Plugins; + +namespace Squidex.Extensions.Text.Azure +{ + public sealed class AzureTextPlugin : IPlugin + { + public void ConfigureServices(IServiceCollection services, IConfiguration config) + { + var fullTextType = config.GetValue("fullText:type"); + + if (string.Equals(fullTextType, "Azure", StringComparison.OrdinalIgnoreCase)) + { + var serviceEndpoint = config.GetValue("fullText:azure:serviceEndpoint"); + + if (string.IsNullOrWhiteSpace(serviceEndpoint)) + { + var error = new ConfigurationError("Value is required.", "fullText:azure:serviceEndpoint"); + + throw new ConfigurationException(error); + } + + var serviceApiKey = config.GetValue("fullText:azure:apiKey"); + + if (string.IsNullOrWhiteSpace(serviceApiKey)) + { + var error = new ConfigurationError("Value is required.", "fullText:azure:apiKey"); + + throw new ConfigurationException(error); + } + + var indexName = config.GetValue("fullText:azure:indexName"); + + if (string.IsNullOrWhiteSpace(indexName)) + { + indexName = "squidex-index"; + } + + services.AddSingleton( + c => new AzureTextIndex(serviceEndpoint, serviceApiKey, indexName)); + + services.AddSingleton( + c => c.GetRequiredService()); + + services.AddSingleton( + c => c.GetRequiredService()); + } + } + } +} diff --git a/backend/extensions/Squidex.Extensions/Text/Azure/CommandFactory.cs b/backend/extensions/Squidex.Extensions/Text/Azure/CommandFactory.cs new file mode 100644 index 000000000..c833772f5 --- /dev/null +++ b/backend/extensions/Squidex.Extensions/Text/Azure/CommandFactory.cs @@ -0,0 +1,81 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Text; +using Azure.Search.Documents.Models; +using Squidex.Domain.Apps.Entities.Contents.Text; + +namespace Squidex.Extensions.Text.Azure +{ + public static class CommandFactory + { + public static void CreateCommands(IndexCommand command, IList> batch) + { + switch (command) + { + case UpsertIndexEntry upsert: + batch.Add(UpsertEntry(upsert)); + break; + case UpdateIndexEntry update: + batch.Add(UpdateEntry(update)); + break; + case DeleteIndexEntry delete: + batch.Add(DeleteEntry(delete)); + break; + } + } + + private static IndexDocumentsAction UpsertEntry(UpsertIndexEntry upsert) + { + var searchDocument = new SearchDocument + { + ["docId"] = upsert.DocId.ToBase64(), + ["appId"] = upsert.AppId.Id.ToString(), + ["appName"] = upsert.AppId.Name, + ["contentId"] = upsert.ContentId.ToString(), + ["schemaId"] = upsert.SchemaId.Id.ToString(), + ["schemaName"] = upsert.SchemaId.Name, + ["serveAll"] = upsert.ServeAll, + ["servePublished"] = upsert.ServePublished + }; + + if (upsert.Texts != null) + { + foreach (var (key, value) in upsert.Texts) + { + searchDocument[AzureIndexDefinition.GetTextField(key)] = value; + } + } + + return IndexDocumentsAction.MergeOrUpload(searchDocument); + } + + private static IndexDocumentsAction UpdateEntry(UpdateIndexEntry update) + { + var searchDocument = new SearchDocument + { + ["docId"] = update.DocId.ToBase64(), + ["serveAll"] = update.ServeAll, + ["servePublished"] = update.ServePublished, + }; + + return IndexDocumentsAction.MergeOrUpload(searchDocument); + } + + private static IndexDocumentsAction DeleteEntry(DeleteIndexEntry delete) + { + return IndexDocumentsAction.Delete("docId", delete.DocId.ToBase64()); + } + + private static string ToBase64(this string value) + { + return Convert.ToBase64String(Encoding.Default.GetBytes(value)); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/CommandFactory.cs b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/CommandFactory.cs similarity index 96% rename from backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/CommandFactory.cs rename to backend/extensions/Squidex.Extensions/Text/ElasticSearch/CommandFactory.cs index f4982575e..1a1b2fedc 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/CommandFactory.cs +++ b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/CommandFactory.cs @@ -6,8 +6,9 @@ // ========================================================================== using System.Collections.Generic; +using Squidex.Domain.Apps.Entities.Contents.Text; -namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic +namespace Squidex.Extensions.Text.ElasticSearch { public static class CommandFactory { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/ElasticSearchMapping.cs b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchMapping.cs similarity index 99% rename from backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/ElasticSearchMapping.cs rename to backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchMapping.cs index 626ff6bc1..84eccb230 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/ElasticSearchMapping.cs +++ b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchMapping.cs @@ -11,7 +11,7 @@ using System.Threading; using System.Threading.Tasks; using Elasticsearch.Net; -namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic +namespace Squidex.Extensions.Text.ElasticSearch { public static class ElasticSearchMapping { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/ElasticSearchTextIndex.cs b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextIndex.cs similarity index 85% rename from backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/ElasticSearchTextIndex.cs rename to backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextIndex.cs index 2e6efc7ed..afd819a27 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/ElasticSearchTextIndex.cs +++ b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextIndex.cs @@ -7,18 +7,18 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using System.Threading.Tasks; using Elasticsearch.Net; using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Contents; +using Squidex.Domain.Apps.Entities.Contents.Text; using Squidex.Hosting; using Squidex.Infrastructure; -namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic +namespace Squidex.Extensions.Text.ElasticSearch { - [ExcludeFromCodeCoverage] public sealed class ElasticSearchTextIndex : ITextIndex, IInitializable { private readonly ElasticLowLevelClient client; @@ -74,13 +74,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic } } - public Task?> SearchAsync(IAppEntity app, GeoQuery query, SearchScope scope, + public Task> SearchAsync(IAppEntity app, GeoQuery query, SearchScope scope, CancellationToken ct = default) { - return Task.FromResult?>(null); + return Task.FromResult>(null); } - public async Task?> SearchAsync(IAppEntity app, TextQuery query, SearchScope scope, + public async Task> SearchAsync(IAppEntity app, TextQuery query, SearchScope scope, CancellationToken ct = default) { Guard.NotNull(app, nameof(app)); @@ -161,24 +161,29 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic size = 2000 }; - if (query.Filter?.SchemaIds?.Length > 0) + if (query.RequiredSchemaIds?.Count > 0) { var bySchema = new { terms = new Dictionary { - ["schemaId.keyword"] = query.Filter.SchemaIds.Select(x => x.ToString()).ToArray() + ["schemaId.keyword"] = query.RequiredSchemaIds.Select(x => x.ToString()).ToArray() } }; - if (query.Filter.Must) - { - elasticQuery.query.@bool.must.Add(bySchema); - } - else + elasticQuery.query.@bool.must.Add(bySchema); + } + else if (query.PreferredSchemaId.HasValue) + { + var bySchema = new { - elasticQuery.query.@bool.should.Add(bySchema); - } + terms = new Dictionary + { + ["schemaId.keyword"] = query.PreferredSchemaId.ToString() + } + }; + + elasticQuery.query.@bool.should.Add(bySchema); } var result = await client.SearchAsync(indexName, CreatePost(elasticQuery), ctx: ct); diff --git a/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextPlugin.cs b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextPlugin.cs new file mode 100644 index 000000000..9510c9273 --- /dev/null +++ b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextPlugin.cs @@ -0,0 +1,53 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Squidex.Domain.Apps.Entities.Contents.Text; +using Squidex.Hosting; +using Squidex.Hosting.Configuration; +using Squidex.Infrastructure.Plugins; + +namespace Squidex.Extensions.Text.ElasticSearch +{ + public sealed class ElasticSearchTextPlugin : IPlugin + { + public void ConfigureServices(IServiceCollection services, IConfiguration config) + { + var fullTextType = config.GetValue("fullText:type"); + + if (string.Equals(fullTextType, "elastic", StringComparison.OrdinalIgnoreCase)) + { + var elasticConfiguration = config.GetValue("fullText:elastic:configuration"); + + if (string.IsNullOrWhiteSpace(elasticConfiguration)) + { + var error = new ConfigurationError("Value is required.", "fullText:elastic:configuration"); + + throw new ConfigurationException(error); + } + + var indexName = config.GetValue("fullText:elastic:indexName"); + + if (string.IsNullOrWhiteSpace(indexName)) + { + indexName = "squidex-index"; + } + + services.AddSingleton( + c => new ElasticSearchTextIndex(elasticConfiguration, indexName)); + + services.AddSingleton( + c => c.GetRequiredService()); + + services.AddSingleton( + c => c.GetRequiredService()); + } + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndex.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndex.cs index ac7ac868e..439805af1 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndex.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndex.cs @@ -9,25 +9,44 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; using MongoDB.Driver; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents.Text; using Squidex.Infrastructure; using Squidex.Infrastructure.MongoDb; -using Squidex.Infrastructure.Tasks; namespace Squidex.Domain.Apps.Entities.MongoDb.FullText { public sealed class MongoTextIndex : MongoRepositoryBase, ITextIndex, IDeleter { - private const int Limit = 2000; - private const int LimitHalf = 1000; - private static readonly List EmptyResults = new List(); + private readonly ProjectionDefinition searchTextProjection; + private readonly ProjectionDefinition searchGeoProjection; + + private sealed class MongoTextResult + { + [BsonId] + [BsonElement] + [BsonRepresentation(BsonType.String)] + public string Id { get; set; } + + [BsonRequired] + [BsonElement("_ci")] + [BsonRepresentation(BsonType.String)] + public DomainId ContentId { get; set; } + + [BsonIgnoreIfDefault] + [BsonElement("score")] + public double Score { get; set; } + } public MongoTextIndex(IMongoDatabase database, bool setup = false) : base(database, setup) { + searchGeoProjection = Projection.Include(x => x.ContentId); + searchTextProjection = Projection.Include(x => x.ContentId).MetaTextScore("score"); } protected override Task SetupCollectionAsync(IMongoCollection collection, @@ -97,78 +116,80 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.FullText Filter.GeoWithinCenterSphere(x => x.GeoObject, query.Longitude, query.Latitude, query.Radius / 6378100)); var byGeo = - await GetCollection(scope).Find(findFilter).Limit(Limit).Only(x => x.ContentId) + await GetCollection(scope).Find(findFilter).Limit(query.Take).Project(searchGeoProjection) .ToListAsync(ct); - var field = Field.Of(x => nameof(x.ContentId)); - - return byGeo.Select(x => DomainId.Create(x[field].AsString)).Distinct().ToList(); + return byGeo.Select(x => x.ContentId).ToList(); } public async Task?> SearchAsync(IAppEntity app, TextQuery query, SearchScope scope, CancellationToken ct = default) { - var (queryText, filter) = query; - - if (string.IsNullOrWhiteSpace(queryText)) + if (string.IsNullOrWhiteSpace(query.Text)) { - return EmptyResults; + return null; } - if (filter == null) + List documents; + + if (query.RequiredSchemaIds?.Count > 0) { - return await SearchByAppAsync(queryText, app, scope, Limit, ct); + documents = await SearchBySchemaAsync(query.Text, app, query.RequiredSchemaIds, scope, query.Take, ct); } - else if (filter.Must) + else if (query.PreferredSchemaId == null) { - return await SearchBySchemaAsync(queryText, app, filter, scope, Limit, ct); + documents = await SearchByAppAsync(query.Text, app, scope, query.Take, ct); } else { - var (bySchema, byApp) = await AsyncHelper.WhenAll( - SearchBySchemaAsync(queryText, app, filter, scope, LimitHalf, ct), - SearchByAppAsync(queryText, app, scope, LimitHalf, ct)); + var halfBucket = query.Take / 2; + + var schemaIds = Enumerable.Repeat(query.PreferredSchemaId.Value, 1); - return bySchema.Union(byApp).Distinct().ToList(); + documents = new List(); + documents.AddRange(await SearchBySchemaAsync(query.Text, app, schemaIds, scope, halfBucket, ct)); + documents.AddRange(await SearchByAppAsync(query.Text, app, scope, halfBucket, ct)); } + + return documents.OrderByDescending(x => x.Score).Select(x => x.ContentId).Distinct().ToList(); } - private async Task> SearchBySchemaAsync(string queryText, IAppEntity app, TextFilter filter, SearchScope scope, int limit, + private Task> SearchBySchemaAsync(string queryText, IAppEntity app, IEnumerable schemaIds, SearchScope scope, int limit, CancellationToken ct = default) { - var findFilter = + var filter = Filter.And( Filter.Eq(x => x.AppId, app.Id), - Filter.In(x => x.SchemaId, filter.SchemaIds), + Filter.In(x => x.SchemaId, schemaIds), Filter_ByScope(scope), Filter.Text(queryText, "none")); - var bySchema = - await GetCollection(scope).Find(findFilter).Limit(limit).Only(x => x.ContentId) - .ToListAsync(ct); - - var field = Field.Of(x => nameof(x.ContentId)); - - return bySchema.Select(x => DomainId.Create(x[field].AsString)).Distinct().ToList(); + return SearchAsync(filter, scope, limit, ct); } - private async Task> SearchByAppAsync(string queryText, IAppEntity app, SearchScope scope, int limit, + private Task> SearchByAppAsync(string queryText, IAppEntity app, SearchScope scope, int limit, CancellationToken ct = default) { - var findFilter = + var filter = Filter.And( Filter.Eq(x => x.AppId, app.Id), Filter.Exists(x => x.SchemaId), Filter_ByScope(scope), Filter.Text(queryText, "none")); - var bySchema = - await GetCollection(scope).Find(findFilter).Limit(limit).Only(x => x.ContentId) - .ToListAsync(ct); + return SearchAsync(filter, scope, limit, ct); + } + + private Task> SearchAsync(FilterDefinition filter, SearchScope scope, int limit, + CancellationToken ct = default) + { + var collection = GetCollection(scope); - var field = Field.Of(x => nameof(x.ContentId)); + var find = + collection.Find(filter).Limit(limit) + .Project(searchTextProjection).Sort(Sort.MetaTextScore("score")); - return bySchema.Select(x => DomainId.Create(x[field].AsString)).Distinct().ToList(); + return find.ToListAsync(ct); } private static FilterDefinition Filter_ByScope(SearchScope scope) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs index 2129c5197..b01380e89 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs @@ -46,6 +46,8 @@ namespace Squidex.Domain.Apps.Entities.Contents public bool IsSingleton { get; set; } + public bool IsDeleted { get; set; } + public string SchemaDisplayName { get; set; } public string StatusColor { get; set; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsSearchSource.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsSearchSource.cs index acb7c1ba6..76d8bbb7b 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsSearchSource.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsSearchSource.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Collections.Generic; +using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -46,14 +47,17 @@ namespace Squidex.Domain.Apps.Entities.Contents { var result = new SearchResults(); - var searchFilter = await CreateSearchFilterAsync(context, ct); + var schemaIds = await GetSchemaIdsAsync(context, ct); - if (searchFilter == null) + if (schemaIds.Count == 0) { return result; } - var textQuery = new TextQuery($"{query}~", searchFilter); + var textQuery = new TextQuery($"{query}~", 10) + { + RequiredSchemaIds = schemaIds + }; var ids = await contentTextIndexer.SearchAsync(context.App, textQuery, context.Scope(), ct); @@ -78,27 +82,12 @@ namespace Squidex.Domain.Apps.Entities.Contents return result; } - private async Task CreateSearchFilterAsync(Context context, + private async Task> GetSchemaIdsAsync(Context context, CancellationToken ct) { - var allowedSchemas = new List(); - var schemas = await appProvider.GetSchemasAsync(context.App.Id, ct); - foreach (var schema in schemas) - { - if (HasPermission(context, schema.SchemaDef.Name)) - { - allowedSchemas.Add(schema.Id); - } - } - - if (allowedSchemas.Count == 0) - { - return null; - } - - return TextFilter.MustHaveSchemas(allowedSchemas.ToArray()); + return schemas.Where(x => HasPermission(context, x.SchemaDef.Name)).Select(x => x.Id).ToList(); } private static bool HasPermission(Context context, string schemaName) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs index 9b612f323..998bda1be 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs @@ -27,5 +27,7 @@ namespace Squidex.Domain.Apps.Entities.Contents ContentData Data { get; } ScheduleJob? ScheduleJob { get; } + + bool IsDeleted { get; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs index 1340638fe..4b3461d4a 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs @@ -92,7 +92,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries throw new InvalidOperationException(); } - var textQuery = new TextQuery(query.FullText, TextFilter.ShouldHaveSchemas(schema.Id)); + var textQuery = new TextQuery(query.FullText, 1000) + { + PreferredSchemaId = schema.Id + }; var fullTextIds = await textIndex.SearchAsync(context.App, textQuery, context.Scope()); var fullTextFilter = ClrFilter.Eq("id", "__notfound__"); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/GeoQueryTransformer.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/GeoQueryTransformer.cs index 5650fbc05..064ed5cef 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/GeoQueryTransformer.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/GeoQueryTransformer.cs @@ -38,7 +38,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { var field = string.Join(".", nodeIn.Path.Skip(1)); - var searchQuery = new GeoQuery(args.Schema.Id, field, sphere.Latitude, sphere.Longitude, sphere.Radius); + var searchQuery = new GeoQuery(args.Schema.Id, field, sphere.Latitude, sphere.Longitude, sphere.Radius, 1000); var searchScope = args.Context.Scope(); var ids = await args.TextIndex.SearchAsync(args.Context.App, searchQuery, searchScope); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/GeoQuery.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/GeoQuery.cs index f6f155640..e7d2a3849 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/GeoQuery.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/GeoQuery.cs @@ -11,7 +11,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.Text { - public sealed record GeoQuery(DomainId SchemaId, string Field, double Latitude, double Longitude, double Radius) + public sealed record GeoQuery(DomainId SchemaId, string Field, double Latitude, double Longitude, double Radius, int Take) { } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextSearch.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextSearch.cs index fea13f4f1..eb59b78fd 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextSearch.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextSearch.cs @@ -5,26 +5,17 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using Squidex.Infrastructure; #pragma warning disable SA1313 // Parameter names should begin with lower-case letter namespace Squidex.Domain.Apps.Entities.Contents.Text { - public sealed record TextQuery(string? Text, TextFilter? Filter) + public sealed record TextQuery(string Text, int Take) { - } - - public sealed record TextFilter(DomainId[]? SchemaIds, bool Must) - { - public static TextFilter MustHaveSchemas(params DomainId[] schemaIds) - { - return new TextFilter(schemaIds, true); - } + public IReadOnlyList? RequiredSchemaIds { get; init; } - public static TextFilter ShouldHaveSchemas(params DomainId[] schemaIds) - { - return new TextFilter(schemaIds, false); - } + public DomainId? PreferredSchemaId { get; init; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj b/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj index b49f957fc..54b7a6852 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj +++ b/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj @@ -18,7 +18,6 @@ - all diff --git a/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs b/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs index aead3f879..07baf6e09 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs +++ b/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Threading; using System.Threading.Tasks; using MongoDB.Bson; diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs index cbf20eda0..bc7ea86fd 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs @@ -103,6 +103,11 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models /// public FieldDto[]? ReferenceFields { get; set; } + /// + /// Indicates whether the content is deleted. + /// + public bool IsDeleted { get; set; } + /// /// The version of the content. /// @@ -140,6 +145,11 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models SimpleMapper.Map(content.ScheduleJob, response.ScheduleJob); } + if (response.IsDeleted) + { + return response; + } + return response.CreateLinksAsync(content, resources, content.SchemaId.Name); } diff --git a/backend/src/Squidex/Config/Domain/ContentsServices.cs b/backend/src/Squidex/Config/Domain/ContentsServices.cs index 8c31b4ffd..64afad17f 100644 --- a/backend/src/Squidex/Config/Domain/ContentsServices.cs +++ b/backend/src/Squidex/Config/Domain/ContentsServices.cs @@ -16,7 +16,6 @@ using Squidex.Domain.Apps.Entities.Contents.DomainObject; using Squidex.Domain.Apps.Entities.Contents.Queries; using Squidex.Domain.Apps.Entities.Contents.Queries.Steps; using Squidex.Domain.Apps.Entities.Contents.Text; -using Squidex.Domain.Apps.Entities.Contents.Text.Elastic; using Squidex.Domain.Apps.Entities.Contents.Validation; using Squidex.Domain.Apps.Entities.History; using Squidex.Domain.Apps.Entities.Search; @@ -97,19 +96,6 @@ namespace Squidex.Config.Domain services.AddSingletonAs>() .AsSelf(); - - config.ConfigureByOption("fullText:type", new Alternatives - { - ["Elastic"] = () => - { - var elasticConfiguration = config.GetRequiredValue("fullText:elastic:configuration"); - var elasticIndexName = config.GetRequiredValue("fullText:elastic:indexName"); - - services.AddSingletonAs(c => new ElasticSearchTextIndex(elasticConfiguration, elasticIndexName)) - .As(); - }, - ["Default"] = () => { } - }); } } } diff --git a/backend/src/Squidex/Config/Domain/EventSourcingServices.cs b/backend/src/Squidex/Config/Domain/EventSourcingServices.cs index 7a30e9e4f..0242978b2 100644 --- a/backend/src/Squidex/Config/Domain/EventSourcingServices.cs +++ b/backend/src/Squidex/Config/Domain/EventSourcingServices.cs @@ -7,7 +7,6 @@ using System.Linq; using EventStore.Client; -using EventStore.ClientAPI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using MongoDB.Driver; @@ -16,7 +15,6 @@ using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Diagnostics; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing.Grains; -using Squidex.Infrastructure.Json; using Squidex.Infrastructure.States; namespace Squidex.Config.Domain diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentsSearchSourceTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentsSearchSourceTests.cs index 2ac6a6a6c..95f03f617 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentsSearchSourceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentsSearchSourceTests.cs @@ -192,7 +192,7 @@ namespace Squidex.Domain.Apps.Entities.Contents var ids = new List { content.Id }; - A.CallTo(() => contentIndex.SearchAsync(ctx.App, A.That.Matches(x => x.Text == "query~" && x.Filter != null), ctx.Scope(), ct)) + A.CallTo(() => contentIndex.SearchAsync(ctx.App, A.That.Matches(x => x.Text == "query~"), ctx.Scope(), ct)) .Returns(ids); A.CallTo(() => contentQuery.QueryAsync(ctx, A.That.HasIds(ids), ct)) diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryParserTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryParserTests.cs index 08ebb3c08..81e0b133c 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryParserTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryParserTests.cs @@ -194,7 +194,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries [Fact] public async Task Should_convert_geo_query_to_filter() { - A.CallTo(() => textIndex.SearchAsync(requestContext.App, new GeoQuery(schemaId.Id, "geo.iv", 10, 20, 30), requestContext.Scope(), default)) + A.CallTo(() => textIndex.SearchAsync(requestContext.App, new GeoQuery(schemaId.Id, "geo.iv", 10, 20, 30, 1000), requestContext.Scope(), default)) .Returns(new List { DomainId.Create("1"), DomainId.Create("2") }); var query = Q.Empty.WithODataQuery("$filter=geo.distance(data/geo/iv, geography'POINT(20 10)') lt 30.0"); @@ -207,7 +207,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries [Fact] public async Task Should_convert_geo_query_to_filter_if_single_id_found() { - A.CallTo(() => textIndex.SearchAsync(requestContext.App, new GeoQuery(schemaId.Id, "geo.iv", 10, 20, 30), requestContext.Scope(), default)) + A.CallTo(() => textIndex.SearchAsync(requestContext.App, new GeoQuery(schemaId.Id, "geo.iv", 10, 20, 30, 1000), requestContext.Scope(), default)) .Returns(new List { DomainId.Create("1") }); var query = Q.Empty.WithODataQuery("$filter=geo.distance(data/geo/iv, geography'POINT(20 10)') lt 30.0"); @@ -220,7 +220,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries [Fact] public async Task Should_convert_geo_query_to_filter_if_index_returns_null() { - A.CallTo(() => textIndex.SearchAsync(requestContext.App, new GeoQuery(schemaId.Id, "geo.iv", 10, 20, 30), requestContext.Scope(), default)) + A.CallTo(() => textIndex.SearchAsync(requestContext.App, new GeoQuery(schemaId.Id, "geo.iv", 10, 20, 30, 1000), requestContext.Scope(), default)) .Returns(Task.FromResult?>(null)); var query = Q.Empty.WithODataQuery("$filter=geo.distance(data/geo/iv, geography'POINT(20 10)') lt 30.0"); @@ -233,7 +233,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries [Fact] public async Task Should_convert_geo_query_to_filter_if_index_returns_empty() { - A.CallTo(() => textIndex.SearchAsync(requestContext.App, new GeoQuery(schemaId.Id, "geo.iv", 10, 20, 30), requestContext.Scope(), default)) + A.CallTo(() => textIndex.SearchAsync(requestContext.App, new GeoQuery(schemaId.Id, "geo.iv", 10, 20, 30, 1000), requestContext.Scope(), default)) .Returns(new List()); var query = Q.Empty.WithODataQuery("$filter=geo.distance(data/geo/iv, geography'POINT(20 10)') lt 30.0"); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTestsBase.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTestsBase.cs index af90a5ee3..8ebf01a28 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTestsBase.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTestsBase.cs @@ -392,7 +392,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text { return async p => { - var query = new GeoQuery(schemaId.Id, field, latitude, longitude, 1000); + var query = new GeoQuery(schemaId.Id, field, latitude, longitude, 1000, 1000); var result = await p.TextIndex.SearchAsync(app, query, target); @@ -411,7 +411,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text { return async p => { - var query = new TextQuery(text, TextFilter.ShouldHaveSchemas(schemaId.Id)); + var query = new TextQuery(text, 1000) + { + RequiredSchemaIds = new List { schemaId.Id } + }; var result = await p.TextIndex.SearchAsync(app, query, target); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests_Elastic.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests_Elastic.cs index 150a5119c..0c5411085 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests_Elastic.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests_Elastic.cs @@ -6,7 +6,7 @@ // ========================================================================== using System.Threading.Tasks; -using Squidex.Domain.Apps.Entities.Contents.Text.Elastic; +using Squidex.Extensions.Text.ElasticSearch; using Squidex.Infrastructure; using Xunit; diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj index efe90fb27..53c45dd95 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj @@ -8,6 +8,7 @@ enable + diff --git a/frontend/app/framework/angular/forms/editors/autocomplete.component.html b/frontend/app/framework/angular/forms/editors/autocomplete.component.html index c06a52bc8..0b914bfce 100644 --- a/frontend/app/framework/angular/forms/editors/autocomplete.component.html +++ b/frontend/app/framework/angular/forms/editors/autocomplete.component.html @@ -15,8 +15,8 @@ - -
+ +
boolean) | undefined; + private condition: ((rect: DOMRect) => boolean) | undefined; private conditionValue = false; @Input('sqxResizeMinWidth') @@ -25,7 +25,7 @@ export class ResizedDirective extends ResourceOwner implements OnDestroy, OnChan public resizeCondition = new EventEmitter(); @Output('sqxResized') - public resize = new EventEmitter(); + public resize = new EventEmitter(); constructor(resizeService: ResizeService, element: ElementRef, private readonly zone: NgZone, @@ -50,7 +50,7 @@ export class ResizedDirective extends ResourceOwner implements OnDestroy, OnChan } } - public onResize(rect: ClientRect) { + public onResize(rect: DOMRect) { if (this.condition) { const value = this.condition(rect); diff --git a/frontend/app/framework/angular/sync-width.directive.ts b/frontend/app/framework/angular/sync-width.directive.ts index 1a7ceaba8..01f4cc175 100644 --- a/frontend/app/framework/angular/sync-width.directive.ts +++ b/frontend/app/framework/angular/sync-width.directive.ts @@ -29,7 +29,7 @@ export class SyncWidthDirective extends ResourceOwner implements AfterViewInit, this.onReposition(); } - public onResize(size: ClientRect) { + public onResize(size: DOMRect) { this.resize(size.width); } diff --git a/frontend/app/framework/utils/modal-positioner.spec.ts b/frontend/app/framework/utils/modal-positioner.spec.ts index 79bb4cc0c..46eb7799e 100644 --- a/frontend/app/framework/utils/modal-positioner.spec.ts +++ b/frontend/app/framework/utils/modal-positioner.spec.ts @@ -8,7 +8,7 @@ import { positionModal } from './modal-positioner'; describe('position', () => { - function buildRect(x: number, y: number, w: number, h: number): ClientRect { + function buildRect(x: number, y: number, w: number, h: number): any { return { top: y, left: x, diff --git a/frontend/app/framework/utils/modal-positioner.ts b/frontend/app/framework/utils/modal-positioner.ts index 762a20c31..1d8581d2f 100644 --- a/frontend/app/framework/utils/modal-positioner.ts +++ b/frontend/app/framework/utils/modal-positioner.ts @@ -18,7 +18,7 @@ const POSITION_RIGHT_CENTER = 'right'; const POSITION_RIGHT_TOP = 'right-top'; const POSITION_RIGHT_BOTTOM = 'right-bottom'; -export function positionModal(targetRect: ClientRect, modalRect: ClientRect, relativePosition: string, offset: number, fix: boolean, viewportWidth: number, viewportHeight: number): { x: number; y: number } { +export function positionModal(targetRect: DOMRect, modalRect: DOMRect, relativePosition: string, offset: number, fix: boolean, viewportWidth: number, viewportHeight: number): { x: number; y: number } { let y = 0; let x = 0; diff --git a/frontend/app/shell/pages/internal/search-menu.component.html b/frontend/app/shell/pages/internal/search-menu.component.html index 80ca53a36..2ae553470 100644 --- a/frontend/app/shell/pages/internal/search-menu.component.html +++ b/frontend/app/shell/pages/internal/search-menu.component.html @@ -2,6 +2,7 @@