mirror of https://github.com/Squidex/squidex.git
Browse Source
* Temporary * More progress. * Updates to text idnexer. * Some progress. * Test * Upgrade to NG9 * Build fixed. * Tsconfig udpated * Progress * Small build optimization. * Improvements * Fixes * Elastic search indexer. * Text indexer improvement. * Text indexer improvements. * Impróvements for singleton content items. * Found * Started with tests. * More tests. * More tests. * Better text index. * Handle asset search. * Remove unused status filter. * Tests fixed. * More tests. * Small task fix. * Loading animations. * Extracted scripting. * Fix formatting. * Performance improvement and migration fix.pull/492/head
committed by
GitHub
355 changed files with 7878 additions and 3774 deletions
@ -0,0 +1,135 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Core.Schemas; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Json.Objects; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.ConvertContent |
|||
{ |
|||
public sealed class StringFormatter : IFieldVisitor<string> |
|||
{ |
|||
private readonly IJsonValue value; |
|||
|
|||
private StringFormatter(IJsonValue value) |
|||
{ |
|||
this.value = value; |
|||
} |
|||
|
|||
public static string Format(IJsonValue? value, IField field) |
|||
{ |
|||
Guard.NotNull(field); |
|||
|
|||
if (value == null || value is JsonNull) |
|||
{ |
|||
return string.Empty; |
|||
} |
|||
|
|||
return field.Accept(new StringFormatter(value)); |
|||
} |
|||
|
|||
public string Visit(IArrayField field) |
|||
{ |
|||
return FormatArray("Item", "Items"); |
|||
} |
|||
|
|||
public string Visit(IField<AssetsFieldProperties> field) |
|||
{ |
|||
return FormatArray("Asset", "Assets"); |
|||
} |
|||
|
|||
public string Visit(IField<BooleanFieldProperties> field) |
|||
{ |
|||
if (value is JsonBoolean boolean && boolean.Value) |
|||
{ |
|||
return "Yes"; |
|||
} |
|||
else |
|||
{ |
|||
return "No"; |
|||
} |
|||
} |
|||
|
|||
public string Visit(IField<DateTimeFieldProperties> field) |
|||
{ |
|||
return value.ToString(); |
|||
} |
|||
|
|||
public string Visit(IField<GeolocationFieldProperties> field) |
|||
{ |
|||
if (value is JsonObject obj && obj.TryGetValue("latitude", out var lat) && obj.TryGetValue("longitude", out var lon)) |
|||
{ |
|||
return $"{lat}, {lon}"; |
|||
} |
|||
else |
|||
{ |
|||
return string.Empty; |
|||
} |
|||
} |
|||
|
|||
public string Visit(IField<JsonFieldProperties> field) |
|||
{ |
|||
return "<Json />"; |
|||
} |
|||
|
|||
public string Visit(IField<NumberFieldProperties> field) |
|||
{ |
|||
return value.ToString(); |
|||
} |
|||
|
|||
public string Visit(IField<ReferencesFieldProperties> field) |
|||
{ |
|||
return FormatArray("Reference", "References"); |
|||
} |
|||
|
|||
public string Visit(IField<StringFieldProperties> field) |
|||
{ |
|||
if (field.Properties.Editor == StringFieldEditor.StockPhoto) |
|||
{ |
|||
return "[Photo]"; |
|||
} |
|||
else |
|||
{ |
|||
return value.ToString(); |
|||
} |
|||
} |
|||
|
|||
public string Visit(IField<TagsFieldProperties> field) |
|||
{ |
|||
if (value is JsonArray array) |
|||
{ |
|||
return string.Join(", ", array); |
|||
} |
|||
else |
|||
{ |
|||
return string.Empty; |
|||
} |
|||
} |
|||
|
|||
public string Visit(IField<UIFieldProperties> field) |
|||
{ |
|||
return string.Empty; |
|||
} |
|||
|
|||
private string FormatArray(string singularName, string pluralName) |
|||
{ |
|||
if (value is JsonArray array) |
|||
{ |
|||
if (array.Count > 1) |
|||
{ |
|||
return $"{array.Count} {pluralName}"; |
|||
} |
|||
else if (array.Count == 1) |
|||
{ |
|||
return $"1 {singularName}"; |
|||
} |
|||
} |
|||
|
|||
return $"0 {pluralName}"; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,51 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Core |
|||
{ |
|||
public interface IUrlGenerator |
|||
{ |
|||
string AppSettingsUI(NamedId<Guid> appId); |
|||
|
|||
string AssetsUI(NamedId<Guid> appId); |
|||
|
|||
string AssetsUI(NamedId<Guid> appId, string? query = null); |
|||
|
|||
string BackupsUI(NamedId<Guid> appId); |
|||
|
|||
string ClientsUI(NamedId<Guid> appId); |
|||
|
|||
string ContentsUI(NamedId<Guid> appId); |
|||
|
|||
string ContentsUI(NamedId<Guid> appId, NamedId<Guid> schemaId); |
|||
|
|||
string ContentUI(NamedId<Guid> appId, NamedId<Guid> schemaId, Guid contentId); |
|||
|
|||
string ContributorsUI(NamedId<Guid> appId); |
|||
|
|||
string DashboardUI(NamedId<Guid> appId); |
|||
|
|||
string LanguagesUI(NamedId<Guid> appId); |
|||
|
|||
string PatternsUI(NamedId<Guid> appId); |
|||
|
|||
string PlansUI(NamedId<Guid> appId); |
|||
|
|||
string RolesUI(NamedId<Guid> appId); |
|||
|
|||
string RulesUI(NamedId<Guid> appId); |
|||
|
|||
string SchemasUI(NamedId<Guid> appId); |
|||
|
|||
string SchemaUI(NamedId<Guid> appId, NamedId<Guid> schemaId); |
|||
|
|||
string WorkflowsUI(NamedId<Guid> appId); |
|||
} |
|||
} |
|||
@ -0,0 +1,144 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using MongoDB.Driver; |
|||
using NodaTime; |
|||
using Squidex.Domain.Apps.Entities.Apps; |
|||
using Squidex.Domain.Apps.Entities.Contents; |
|||
using Squidex.Domain.Apps.Entities.Contents.Text; |
|||
using Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations; |
|||
using Squidex.Domain.Apps.Entities.Schemas; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Json; |
|||
using Squidex.Infrastructure.Log; |
|||
using Squidex.Infrastructure.MongoDb; |
|||
using Squidex.Infrastructure.Queries; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents |
|||
{ |
|||
public sealed class MongoContentCollectionAll : MongoRepositoryBase<MongoContentEntity> |
|||
{ |
|||
private readonly QueryContent queryContentAsync; |
|||
private readonly QueryContentsByIds queryContentsById; |
|||
private readonly QueryContentsByQuery queryContentsByQuery; |
|||
private readonly QueryIdsAsync queryIdsAsync; |
|||
private readonly QueryScheduledContents queryScheduledItems; |
|||
|
|||
public MongoContentCollectionAll(IMongoDatabase database, IAppProvider appProvider, IContentTextIndex indexer, IJsonSerializer serializer) |
|||
: base(database) |
|||
{ |
|||
queryContentAsync = new QueryContent(serializer); |
|||
queryContentsById = new QueryContentsByIds(serializer, appProvider); |
|||
queryContentsByQuery = new QueryContentsByQuery(serializer, indexer); |
|||
queryIdsAsync = new QueryIdsAsync(appProvider); |
|||
queryScheduledItems = new QueryScheduledContents(); |
|||
} |
|||
|
|||
public IMongoCollection<MongoContentEntity> GetInternalCollection() |
|||
{ |
|||
return Collection; |
|||
} |
|||
|
|||
protected override string CollectionName() |
|||
{ |
|||
return "State_Contents_All"; |
|||
} |
|||
|
|||
protected override async Task SetupCollectionAsync(IMongoCollection<MongoContentEntity> collection, CancellationToken ct = default) |
|||
{ |
|||
await queryContentAsync.PrepareAsync(collection, ct); |
|||
await queryContentsById.PrepareAsync(collection, ct); |
|||
await queryContentsByQuery.PrepareAsync(collection, ct); |
|||
await queryIdsAsync.PrepareAsync(collection, ct); |
|||
await queryScheduledItems.PrepareAsync(collection, ct); |
|||
} |
|||
|
|||
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query) |
|||
{ |
|||
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByQuery")) |
|||
{ |
|||
return await queryContentsByQuery.DoAsync(app, schema, query, SearchScope.All); |
|||
} |
|||
} |
|||
|
|||
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, HashSet<Guid> ids) |
|||
{ |
|||
Guard.NotNull(app); |
|||
|
|||
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIds")) |
|||
{ |
|||
var result = await queryContentsById.DoAsync(app.Id, schema, ids); |
|||
|
|||
return ResultList.Create(result.Count, result.Select(x => x.Content)); |
|||
} |
|||
} |
|||
|
|||
public async Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryAsync(IAppEntity app, HashSet<Guid> ids) |
|||
{ |
|||
Guard.NotNull(app); |
|||
|
|||
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIdsWithoutSchema")) |
|||
{ |
|||
var result = await queryContentsById.DoAsync(app.Id, null, ids); |
|||
|
|||
return result; |
|||
} |
|||
} |
|||
|
|||
public async Task<IContentEntity?> FindContentAsync(ISchemaEntity schema, Guid id) |
|||
{ |
|||
using (Profiler.TraceMethod<MongoContentRepository>()) |
|||
{ |
|||
return await queryContentAsync.DoAsync(schema, id); |
|||
} |
|||
} |
|||
|
|||
public async Task QueryScheduledWithoutDataAsync(Instant now, Func<IContentEntity, Task> callback) |
|||
{ |
|||
using (Profiler.TraceMethod<MongoContentRepository>()) |
|||
{ |
|||
await queryScheduledItems.DoAsync(now, callback); |
|||
} |
|||
} |
|||
|
|||
public async Task<IReadOnlyList<(Guid SchemaId, Guid Id)>> QueryIdsAsync(Guid appId, HashSet<Guid> ids) |
|||
{ |
|||
using (Profiler.TraceMethod<MongoContentRepository>()) |
|||
{ |
|||
return await queryIdsAsync.DoAsync(appId, ids); |
|||
} |
|||
} |
|||
|
|||
public async Task<IReadOnlyList<(Guid SchemaId, Guid Id)>> QueryIdsAsync(Guid appId, Guid schemaId, FilterNode<ClrValue> filterNode) |
|||
{ |
|||
using (Profiler.TraceMethod<MongoContentRepository>()) |
|||
{ |
|||
return await queryIdsAsync.DoAsync(appId, schemaId, filterNode); |
|||
} |
|||
} |
|||
|
|||
public Task<MongoContentEntity> FindAsync(Guid id) |
|||
{ |
|||
return Collection.Find(x => x.Id == id).FirstOrDefaultAsync(); |
|||
} |
|||
|
|||
public Task UpsertVersionedAsync(Guid id, long oldVersion, MongoContentEntity entity) |
|||
{ |
|||
return Collection.UpsertVersionedAsync(id, oldVersion, entity); |
|||
} |
|||
|
|||
public Task RemoveAsync(Guid id) |
|||
{ |
|||
return Collection.DeleteOneAsync(x => x.Id == id); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,119 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using MongoDB.Driver; |
|||
using Squidex.Domain.Apps.Entities.Apps; |
|||
using Squidex.Domain.Apps.Entities.Contents; |
|||
using Squidex.Domain.Apps.Entities.Contents.Text; |
|||
using Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations; |
|||
using Squidex.Domain.Apps.Entities.Schemas; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Json; |
|||
using Squidex.Infrastructure.Log; |
|||
using Squidex.Infrastructure.MongoDb; |
|||
using Squidex.Infrastructure.Queries; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents |
|||
{ |
|||
public sealed class MongoContentCollectionPublished : MongoRepositoryBase<MongoContentEntity> |
|||
{ |
|||
private readonly QueryContent queryContentAsync; |
|||
private readonly QueryContentsByIds queryContentsById; |
|||
private readonly QueryContentsByQuery queryContentsByQuery; |
|||
private readonly QueryIdsAsync queryIdsAsync; |
|||
|
|||
public MongoContentCollectionPublished(IMongoDatabase database, IAppProvider appProvider, IContentTextIndex indexer, IJsonSerializer serializer) |
|||
: base(database) |
|||
{ |
|||
queryContentAsync = new QueryContent(serializer); |
|||
queryContentsById = new QueryContentsByIds(serializer, appProvider); |
|||
queryContentsByQuery = new QueryContentsByQuery(serializer, indexer); |
|||
queryIdsAsync = new QueryIdsAsync(appProvider); |
|||
} |
|||
|
|||
public IMongoCollection<MongoContentEntity> GetInternalCollection() |
|||
{ |
|||
return Collection; |
|||
} |
|||
|
|||
protected override string CollectionName() |
|||
{ |
|||
return "State_Contents_Published"; |
|||
} |
|||
|
|||
protected override async Task SetupCollectionAsync(IMongoCollection<MongoContentEntity> collection, CancellationToken ct = default) |
|||
{ |
|||
await queryContentAsync.PrepareAsync(collection, ct); |
|||
await queryContentsById.PrepareAsync(collection, ct); |
|||
await queryContentsByQuery.PrepareAsync(collection, ct); |
|||
await queryIdsAsync.PrepareAsync(collection, ct); |
|||
} |
|||
|
|||
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, ClrQuery query) |
|||
{ |
|||
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByQuery")) |
|||
{ |
|||
return await queryContentsByQuery.DoAsync(app, schema, query, SearchScope.Published); |
|||
} |
|||
} |
|||
|
|||
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, HashSet<Guid> ids) |
|||
{ |
|||
Guard.NotNull(app); |
|||
|
|||
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIds")) |
|||
{ |
|||
var result = await queryContentsById.DoAsync(app.Id, schema, ids); |
|||
|
|||
return ResultList.Create(result.Count, result.Select(x => x.Content)); |
|||
} |
|||
} |
|||
|
|||
public async Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryAsync(IAppEntity app, HashSet<Guid> ids) |
|||
{ |
|||
Guard.NotNull(app); |
|||
|
|||
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIdsWithoutSchema")) |
|||
{ |
|||
var result = await queryContentsById.DoAsync(app.Id, null, ids); |
|||
|
|||
return result; |
|||
} |
|||
} |
|||
|
|||
public async Task<IContentEntity?> FindContentAsync(ISchemaEntity schema, Guid id) |
|||
{ |
|||
using (Profiler.TraceMethod<MongoContentRepository>()) |
|||
{ |
|||
return await queryContentAsync.DoAsync(schema, id); |
|||
} |
|||
} |
|||
|
|||
public async Task<IReadOnlyList<(Guid SchemaId, Guid Id)>> QueryIdsAsync(Guid appId, HashSet<Guid> ids) |
|||
{ |
|||
using (Profiler.TraceMethod<MongoContentRepository>()) |
|||
{ |
|||
return await queryIdsAsync.DoAsync(appId, ids); |
|||
} |
|||
} |
|||
|
|||
public Task UpsertVersionedAsync(Guid id, long oldVersion, MongoContentEntity entity) |
|||
{ |
|||
return Collection.UpsertVersionedAsync(id, oldVersion, entity); |
|||
} |
|||
|
|||
public Task RemoveAsync(Guid id) |
|||
{ |
|||
return Collection.DeleteOneAsync(x => x.Id == id); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,61 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using MongoDB.Bson.Serialization; |
|||
using MongoDB.Driver; |
|||
using Squidex.Domain.Apps.Entities.Contents.Text.State; |
|||
using Squidex.Infrastructure.MongoDb; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.MongoDb.FullText |
|||
{ |
|||
public sealed class MongoTextIndexerState : MongoRepositoryBase<TextContentState>, ITextIndexerState |
|||
{ |
|||
static MongoTextIndexerState() |
|||
{ |
|||
BsonClassMap.RegisterClassMap<TextContentState>(cm => |
|||
{ |
|||
cm.MapIdField(x => x.ContentId); |
|||
|
|||
cm.MapProperty(x => x.DocIdCurrent) |
|||
.SetElementName("c"); |
|||
|
|||
cm.MapProperty(x => x.DocIdNew) |
|||
.SetElementName("n").SetIgnoreIfNull(true); |
|||
|
|||
cm.MapProperty(x => x.DocIdForPublished) |
|||
.SetElementName("p").SetIgnoreIfNull(true); |
|||
}); |
|||
} |
|||
|
|||
public MongoTextIndexerState(IMongoDatabase database, bool setup = false) |
|||
: base(database, setup) |
|||
{ |
|||
} |
|||
|
|||
protected override string CollectionName() |
|||
{ |
|||
return "TextIndexerState"; |
|||
} |
|||
|
|||
public Task<TextContentState?> GetAsync(Guid contentId) |
|||
{ |
|||
return Collection.Find(x => x.ContentId == contentId).FirstOrDefaultAsync()!; |
|||
} |
|||
|
|||
public Task RemoveAsync(Guid contentId) |
|||
{ |
|||
return Collection.DeleteOneAsync(x => x.ContentId == contentId); |
|||
} |
|||
|
|||
public Task SetAsync(TextContentState state) |
|||
{ |
|||
return Collection.ReplaceOneAsync(x => x.ContentId == state.ContentId, state, UpsertReplace); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,92 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using Squidex.Domain.Apps.Core; |
|||
using Squidex.Domain.Apps.Entities.Search; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Shared; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Apps |
|||
{ |
|||
public sealed class AppSettingsSearchSource : ISearchSource |
|||
{ |
|||
private const int MaxItems = 3; |
|||
private readonly IUrlGenerator urlGenerator; |
|||
|
|||
public AppSettingsSearchSource(IUrlGenerator urlGenerator) |
|||
{ |
|||
Guard.NotNull(urlGenerator); |
|||
|
|||
this.urlGenerator = urlGenerator; |
|||
} |
|||
|
|||
public Task<SearchResults> SearchAsync(string query, Context context) |
|||
{ |
|||
var result = new SearchResults(); |
|||
|
|||
var appId = context.App.NamedId(); |
|||
|
|||
void Search(string term, string permissionId, Func<NamedId<Guid>, string> generate, SearchResultType type) |
|||
{ |
|||
if (result.Count < MaxItems && term.Contains(query, StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
var permission = Permissions.ForApp(permissionId, appId.Name); |
|||
|
|||
if (context.Permissions.Allows(permission)) |
|||
{ |
|||
var url = generate(appId); |
|||
|
|||
result.Add(term, type, url); |
|||
} |
|||
} |
|||
} |
|||
|
|||
Search("Assets", Permissions.AppAssetsRead, |
|||
urlGenerator.AssetsUI, SearchResultType.Asset); |
|||
|
|||
Search("Backups", Permissions.AppBackupsRead, |
|||
urlGenerator.BackupsUI, SearchResultType.Setting); |
|||
|
|||
Search("Clients", Permissions.AppClientsRead, |
|||
urlGenerator.ClientsUI, SearchResultType.Setting); |
|||
|
|||
Search("Contents", Permissions.AppCommon, |
|||
urlGenerator.ContentsUI, SearchResultType.Content); |
|||
|
|||
Search("Contributors", Permissions.AppContributorsRead, |
|||
urlGenerator.ContributorsUI, SearchResultType.Setting); |
|||
|
|||
Search("Dashboard", Permissions.AppCommon, |
|||
urlGenerator.DashboardUI, SearchResultType.Dashboard); |
|||
|
|||
Search("Languages", Permissions.AppCommon, |
|||
urlGenerator.LanguagesUI, SearchResultType.Setting); |
|||
|
|||
Search("Patterns", Permissions.AppCommon, |
|||
urlGenerator.PatternsUI, SearchResultType.Setting); |
|||
|
|||
Search("Roles", Permissions.AppRolesRead, |
|||
urlGenerator.RolesUI, SearchResultType.Setting); |
|||
|
|||
Search("Rules", Permissions.AppRulesRead, |
|||
urlGenerator.RulesUI, SearchResultType.Rule); |
|||
|
|||
Search("Schemas", Permissions.AppCommon, |
|||
urlGenerator.SchemasUI, SearchResultType.Schema); |
|||
|
|||
Search("Subscription", Permissions.AppPlansRead, |
|||
urlGenerator.PlansUI, SearchResultType.Setting); |
|||
|
|||
Search("Workflows", Permissions.AppWorkflowsRead, |
|||
urlGenerator.WorkflowsUI, SearchResultType.Setting); |
|||
|
|||
return Task.FromResult(result); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,60 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Threading.Tasks; |
|||
using Squidex.Domain.Apps.Core; |
|||
using Squidex.Domain.Apps.Entities.Search; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Queries; |
|||
using Squidex.Shared; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Assets |
|||
{ |
|||
public sealed class AssetsSearchSource : ISearchSource |
|||
{ |
|||
private readonly IAssetQueryService assetQuery; |
|||
private readonly IUrlGenerator urlGenerator; |
|||
|
|||
public AssetsSearchSource(IAssetQueryService assetQuery, IUrlGenerator urlGenerator) |
|||
{ |
|||
Guard.NotNull(assetQuery); |
|||
Guard.NotNull(urlGenerator); |
|||
|
|||
this.assetQuery = assetQuery; |
|||
|
|||
this.urlGenerator = urlGenerator; |
|||
} |
|||
|
|||
public async Task<SearchResults> SearchAsync(string query, Context context) |
|||
{ |
|||
var result = new SearchResults(); |
|||
|
|||
var permission = Permissions.ForApp(Permissions.AppAssetsRead, context.App.Name); |
|||
|
|||
if (context.Permissions.Allows(permission)) |
|||
{ |
|||
var filter = ClrFilter.Contains("fileName", query); |
|||
|
|||
var clrQuery = new ClrQuery { Filter = filter, Take = 5 }; |
|||
|
|||
var assets = await assetQuery.QueryAsync(context, null, Q.Empty.WithQuery(clrQuery)); |
|||
|
|||
if (assets.Count > 0) |
|||
{ |
|||
var url = urlGenerator.AssetsUI(context.App.NamedId(), query); |
|||
|
|||
foreach (var asset in assets) |
|||
{ |
|||
result.Add(asset.FileName, SearchResultType.Asset, url); |
|||
} |
|||
} |
|||
} |
|||
|
|||
return result; |
|||
} |
|||
} |
|||
} |
|||
@ -1,13 +1,13 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Commands |
|||
{ |
|||
public sealed class DiscardChanges : ContentCommand |
|||
public sealed class CreateContentDraft : ContentCommand |
|||
{ |
|||
} |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Commands |
|||
{ |
|||
public sealed class DeleteContentDraft : ContentCommand |
|||
{ |
|||
} |
|||
} |
|||
@ -0,0 +1,163 @@ |
|||
// ==========================================================================
|
|||
// 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 System.Threading.Tasks; |
|||
using Squidex.Domain.Apps.Core; |
|||
using Squidex.Domain.Apps.Core.Contents; |
|||
using Squidex.Domain.Apps.Core.ConvertContent; |
|||
using Squidex.Domain.Apps.Core.Schemas; |
|||
using Squidex.Domain.Apps.Entities.Contents.Text; |
|||
using Squidex.Domain.Apps.Entities.Search; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Json.Objects; |
|||
using Squidex.Shared; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents |
|||
{ |
|||
public sealed class ContentsSearchSource : ISearchSource |
|||
{ |
|||
private readonly IAppProvider appProvider; |
|||
private readonly IContentQueryService contentQuery; |
|||
private readonly IContentTextIndex contentTextIndexer; |
|||
private readonly IUrlGenerator urlGenerator; |
|||
|
|||
public ContentsSearchSource( |
|||
IAppProvider appProvider, |
|||
IContentQueryService contentQuery, |
|||
IContentTextIndex contentTextIndexer, |
|||
IUrlGenerator urlGenerator) |
|||
{ |
|||
Guard.NotNull(appProvider); |
|||
Guard.NotNull(contentQuery); |
|||
Guard.NotNull(contentTextIndexer); |
|||
Guard.NotNull(urlGenerator); |
|||
|
|||
this.appProvider = appProvider; |
|||
this.contentQuery = contentQuery; |
|||
this.contentTextIndexer = contentTextIndexer; |
|||
this.urlGenerator = urlGenerator; |
|||
} |
|||
|
|||
public async Task<SearchResults> SearchAsync(string query, Context context) |
|||
{ |
|||
var result = new SearchResults(); |
|||
|
|||
var searchFilter = await CreateSearchFilterAsync(context); |
|||
|
|||
if (searchFilter == null) |
|||
{ |
|||
return result; |
|||
} |
|||
|
|||
var ids = await contentTextIndexer.SearchAsync($"{query}~", context.App, searchFilter, context.Scope()); |
|||
|
|||
if (ids == null || ids.Count == 0) |
|||
{ |
|||
return result; |
|||
} |
|||
|
|||
var appId = context.App.NamedId(); |
|||
|
|||
var contents = await contentQuery.QueryAsync(context, ids); |
|||
|
|||
foreach (var content in contents) |
|||
{ |
|||
var url = urlGenerator.ContentUI(appId, content.SchemaId, content.Id); |
|||
|
|||
var name = FormatName(content, context.App.LanguagesConfig.Master); |
|||
|
|||
result.Add(name, SearchResultType.Content, url, content.SchemaDisplayName); |
|||
} |
|||
|
|||
return result; |
|||
} |
|||
|
|||
private async Task<SearchFilter?> CreateSearchFilterAsync(Context context) |
|||
{ |
|||
var allowedSchemas = new List<Guid>(); |
|||
|
|||
var schemas = await appProvider.GetSchemasAsync(context.App.Id); |
|||
|
|||
foreach (var schema in schemas) |
|||
{ |
|||
if (HasPermission(context, schema.SchemaDef.Name)) |
|||
{ |
|||
allowedSchemas.Add(schema.Id); |
|||
} |
|||
} |
|||
|
|||
if (allowedSchemas.Count == 0) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
return SearchFilter.MustHaveSchemas(allowedSchemas.ToArray()); |
|||
} |
|||
|
|||
private static bool HasPermission(Context context, string schemaName) |
|||
{ |
|||
var permission = Permissions.ForApp(Permissions.AppContentsRead, context.App.Name, schemaName); |
|||
|
|||
return context.Permissions.Allows(permission); |
|||
} |
|||
|
|||
private string FormatName(IEnrichedContentEntity content, string masterLanguage) |
|||
{ |
|||
var sb = new StringBuilder(); |
|||
|
|||
IJsonValue? GetValue(NamedContentData? data, RootField field) |
|||
{ |
|||
if (data != null && data.TryGetValue(field.Name, out var fieldValue) && fieldValue != null) |
|||
{ |
|||
var isInvariant = field.Partitioning.Equals(Partitioning.Invariant); |
|||
|
|||
if (isInvariant && fieldValue.TryGetValue("iv", out var value)) |
|||
{ |
|||
return value; |
|||
} |
|||
|
|||
if (!isInvariant && fieldValue.TryGetValue(masterLanguage, out value)) |
|||
{ |
|||
return value; |
|||
} |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
if (content.ReferenceFields != null) |
|||
{ |
|||
foreach (var field in content.ReferenceFields) |
|||
{ |
|||
var value = GetValue(content.ReferenceData, field) ?? GetValue(content.Data, field); |
|||
|
|||
var formatted = StringFormatter.Format(value, field); |
|||
|
|||
if (!string.IsNullOrWhiteSpace(formatted)) |
|||
{ |
|||
if (sb.Length > 0) |
|||
{ |
|||
sb.Append(", "); |
|||
} |
|||
|
|||
sb.Append(formatted); |
|||
} |
|||
} |
|||
} |
|||
|
|||
if (sb.Length == 0) |
|||
{ |
|||
return "Content"; |
|||
} |
|||
|
|||
return sb.ToString(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,60 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using Squidex.Domain.Apps.Core.Scripting; |
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps |
|||
{ |
|||
public sealed class ScriptContent : IContentEnricherStep |
|||
{ |
|||
private readonly IScriptEngine scriptEngine; |
|||
|
|||
public ScriptContent(IScriptEngine scriptEngine) |
|||
{ |
|||
Guard.NotNull(scriptEngine, nameof(scriptEngine)); |
|||
|
|||
this.scriptEngine = scriptEngine; |
|||
} |
|||
|
|||
public async Task EnrichAsync(Context context, IEnumerable<ContentEntity> contents, ProvideSchema schemas) |
|||
{ |
|||
if (ShouldEnrich(context)) |
|||
{ |
|||
foreach (var group in contents.GroupBy(x => x.SchemaId.Id)) |
|||
{ |
|||
var schema = await schemas(group.Key); |
|||
|
|||
var script = schema.SchemaDef.Scripts.Query; |
|||
|
|||
if (!string.IsNullOrWhiteSpace(script)) |
|||
{ |
|||
var results = new List<IEnrichedContentEntity>(); |
|||
|
|||
var scriptContext = new ScriptContext { User = context.User }; |
|||
|
|||
foreach (var content in group) |
|||
{ |
|||
scriptContext.Data = content.Data; |
|||
scriptContext.ContentId = content.Id; |
|||
|
|||
content.Data = scriptEngine.Transform(scriptContext, script); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private static bool ShouldEnrich(Context context) |
|||
{ |
|||
return !context.IsFrontendClient; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,15 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents |
|||
{ |
|||
public enum SearchScope |
|||
{ |
|||
All, |
|||
Published |
|||
} |
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Core.Contents; |
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.State |
|||
{ |
|||
public sealed class ContentVersion |
|||
{ |
|||
public Status Status { get; } |
|||
|
|||
public NamedContentData Data { get; } |
|||
|
|||
public ContentVersion(Status status, NamedContentData data) |
|||
{ |
|||
Guard.NotNull(data); |
|||
|
|||
Status = status; |
|||
|
|||
Data = data; |
|||
} |
|||
|
|||
public ContentVersion WithStatus(Status status) |
|||
{ |
|||
return new ContentVersion(status, Data); |
|||
} |
|||
|
|||
public ContentVersion WithData(NamedContentData data) |
|||
{ |
|||
return new ContentVersion(Status, data); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,198 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Diagnostics.CodeAnalysis; |
|||
using System.Threading.Tasks; |
|||
using Elasticsearch.Net; |
|||
using Squidex.Domain.Apps.Entities.Apps; |
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic |
|||
{ |
|||
[ExcludeFromCodeCoverage] |
|||
public sealed class ElasticSearchTextIndex : IContentTextIndex |
|||
{ |
|||
private const string IndexName = "contents"; |
|||
private readonly ElasticLowLevelClient client; |
|||
|
|||
public ElasticSearchTextIndex() |
|||
{ |
|||
var config = new ConnectionConfiguration(new Uri("http://localhost:9200")); |
|||
|
|||
client = new ElasticLowLevelClient(config); |
|||
} |
|||
|
|||
public async Task ExecuteAsync(NamedId<Guid> appId, NamedId<Guid> schemaId, params IndexCommand[] commands) |
|||
{ |
|||
foreach (var command in commands) |
|||
{ |
|||
switch (command) |
|||
{ |
|||
case UpsertIndexEntry upsert: |
|||
await UpsertAsync(appId, schemaId, upsert); |
|||
break; |
|||
case UpdateIndexEntry update: |
|||
await UpdateAsync(update); |
|||
break; |
|||
case DeleteIndexEntry delete: |
|||
await DeleteAsync(delete); |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
private async Task UpsertAsync(NamedId<Guid> appId, NamedId<Guid> schemaId, UpsertIndexEntry upsert) |
|||
{ |
|||
upsert.Texts["en"] = "Foo"; |
|||
|
|||
var data = new |
|||
{ |
|||
appId = appId.Id, |
|||
appName = appId.Name, |
|||
contentId = upsert.ContentId, |
|||
schemaId = schemaId.Id, |
|||
schemaName = schemaId.Name, |
|||
serveAll = upsert.ServeAll, |
|||
servePublished = upsert.ServePublished, |
|||
texts = upsert.Texts, |
|||
}; |
|||
|
|||
var result = await client.IndexAsync<StringResponse>(IndexName, upsert.DocId, CreatePost(data)); |
|||
|
|||
if (!result.Success) |
|||
{ |
|||
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException); |
|||
} |
|||
} |
|||
|
|||
private async Task UpdateAsync(UpdateIndexEntry update) |
|||
{ |
|||
var data = new |
|||
{ |
|||
doc = new |
|||
{ |
|||
update.ServeAll, |
|||
update.ServePublished |
|||
} |
|||
}; |
|||
|
|||
var result = await client.UpdateAsync<StringResponse>(IndexName, update.DocId, CreatePost(data)); |
|||
|
|||
if (!result.Success) |
|||
{ |
|||
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException); |
|||
} |
|||
} |
|||
|
|||
private Task DeleteAsync(DeleteIndexEntry delete) |
|||
{ |
|||
return client.DeleteAsync<StringResponse>(IndexName, delete.DocId); |
|||
} |
|||
|
|||
private static PostData CreatePost<T>(T data) |
|||
{ |
|||
return new SerializableData<T>(data); |
|||
} |
|||
|
|||
public async Task<List<Guid>?> SearchAsync(string? queryText, IAppEntity app, SearchFilter? filter, SearchScope scope) |
|||
{ |
|||
var serveField = GetServeField(scope); |
|||
|
|||
var query = new |
|||
{ |
|||
query = new |
|||
{ |
|||
@bool = new |
|||
{ |
|||
must = new List<object> |
|||
{ |
|||
new |
|||
{ |
|||
term = new Dictionary<string, object> |
|||
{ |
|||
["appId.keyword"] = app.Id |
|||
} |
|||
}, |
|||
new |
|||
{ |
|||
term = new Dictionary<string, string> |
|||
{ |
|||
[serveField] = "true" |
|||
} |
|||
}, |
|||
new |
|||
{ |
|||
multi_match = new |
|||
{ |
|||
fields = new[] |
|||
{ |
|||
"texts.*" |
|||
}, |
|||
query = queryText |
|||
} |
|||
} |
|||
}, |
|||
should = new List<object>() |
|||
} |
|||
}, |
|||
_source = new[] |
|||
{ |
|||
"contentId" |
|||
}, |
|||
size = 2000 |
|||
}; |
|||
|
|||
if (filter?.SchemaIds.Count > 0) |
|||
{ |
|||
var bySchema = new |
|||
{ |
|||
term = new Dictionary<string, object> |
|||
{ |
|||
["schemaId.keyword"] = filter.SchemaIds |
|||
} |
|||
}; |
|||
|
|||
if (filter.Must) |
|||
{ |
|||
query.query.@bool.must.Add(bySchema); |
|||
} |
|||
else |
|||
{ |
|||
query.query.@bool.should.Add(bySchema); |
|||
} |
|||
} |
|||
|
|||
var result = await client.SearchAsync<DynamicResponse>(IndexName, CreatePost(query)); |
|||
|
|||
if (!result.Success) |
|||
{ |
|||
throw result.OriginalException; |
|||
} |
|||
|
|||
var ids = new List<Guid>(); |
|||
|
|||
foreach (var item in result.Body.hits.hits) |
|||
{ |
|||
if (item != null) |
|||
{ |
|||
ids.Add(Guid.Parse(item["_source"]["contentId"])); |
|||
} |
|||
} |
|||
|
|||
return ids; |
|||
} |
|||
|
|||
private static string GetServeField(SearchScope scope) |
|||
{ |
|||
return scope == SearchScope.Published ? |
|||
"servePublished" : |
|||
"serveAll"; |
|||
} |
|||
} |
|||
} |
|||
@ -1,117 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using Orleans; |
|||
using Squidex.Domain.Apps.Core.Contents; |
|||
using Squidex.Domain.Apps.Entities.Apps; |
|||
using Squidex.Domain.Apps.Events.Contents; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
using Squidex.Infrastructure.Log; |
|||
using Squidex.Infrastructure.Tasks; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
public sealed class GrainTextIndexer : ITextIndexer, IEventConsumer |
|||
{ |
|||
private readonly IGrainFactory grainFactory; |
|||
|
|||
public string Name |
|||
{ |
|||
get { return "TextIndexer2"; } |
|||
} |
|||
|
|||
public string EventsFilter |
|||
{ |
|||
get { return "^content-"; } |
|||
} |
|||
|
|||
public GrainTextIndexer(IGrainFactory grainFactory) |
|||
{ |
|||
Guard.NotNull(grainFactory); |
|||
|
|||
this.grainFactory = grainFactory; |
|||
} |
|||
|
|||
public bool Handles(StoredEvent @event) |
|||
{ |
|||
return true; |
|||
} |
|||
|
|||
public Task ClearAsync() |
|||
{ |
|||
return TaskHelper.Done; |
|||
} |
|||
|
|||
public async Task On(Envelope<IEvent> @event) |
|||
{ |
|||
if (@event.Payload is ContentEvent contentEvent) |
|||
{ |
|||
var index = grainFactory.GetGrain<ITextIndexerGrain>(contentEvent.SchemaId.Id); |
|||
|
|||
var id = contentEvent.ContentId; |
|||
|
|||
switch (@event.Payload) |
|||
{ |
|||
case ContentDeleted _: |
|||
await index.DeleteAsync(id); |
|||
break; |
|||
case ContentCreated contentCreated: |
|||
await index.IndexAsync(Data(id, contentCreated.Data, true)); |
|||
break; |
|||
case ContentUpdateProposed contentUpdateProposed: |
|||
await index.IndexAsync(Data(id, contentUpdateProposed.Data, true)); |
|||
break; |
|||
case ContentUpdated contentUpdated: |
|||
await index.IndexAsync(Data(id, contentUpdated.Data, false)); |
|||
break; |
|||
case ContentChangesDiscarded _: |
|||
await index.CopyAsync(id, false); |
|||
break; |
|||
case ContentChangesPublished _: |
|||
await index.CopyAsync(id, true); |
|||
break; |
|||
case ContentStatusChanged contentStatusChanged when contentStatusChanged.Status == Status.Published: |
|||
await index.CopyAsync(id, true); |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
private static Update Data(Guid contentId, NamedContentData data, bool onlyDraft) |
|||
{ |
|||
return new Update { Id = contentId, Text = data.ToTexts(), OnlyDraft = onlyDraft }; |
|||
} |
|||
|
|||
public async Task<List<Guid>?> SearchAsync(string? queryText, IAppEntity app, Guid schemaId, Scope scope = Scope.Published) |
|||
{ |
|||
if (string.IsNullOrWhiteSpace(queryText)) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var index = grainFactory.GetGrain<ITextIndexerGrain>(schemaId); |
|||
|
|||
using (Profiler.TraceMethod<GrainTextIndexer>()) |
|||
{ |
|||
var context = CreateContext(app, scope); |
|||
|
|||
return await index.SearchAsync(queryText, context); |
|||
} |
|||
} |
|||
|
|||
private static SearchContext CreateContext(IAppEntity app, Scope scope) |
|||
{ |
|||
var languages = new HashSet<string>(app.LanguagesConfig.AllKeys); |
|||
|
|||
return new SearchContext { Languages = languages, Scope = scope }; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
public abstract class IndexCommand |
|||
{ |
|||
public string DocId { get; set; } |
|||
} |
|||
} |
|||
@ -1,114 +0,0 @@ |
|||
// ==========================================================================
|
|||
// 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 Lucene.Net.Documents; |
|||
using Lucene.Net.Index; |
|||
using Lucene.Net.Search; |
|||
using Lucene.Net.Util; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
internal sealed class IndexState |
|||
{ |
|||
private const string MetaFor = "_fd"; |
|||
private readonly Dictionary<(Guid, Scope), (bool, bool)> lastChanges = new Dictionary<(Guid, Scope), (bool, bool)>(); |
|||
private readonly BytesRef bytesRef = new BytesRef(2); |
|||
private readonly IIndex index; |
|||
|
|||
public IndexState(IIndex index) |
|||
{ |
|||
this.index = index; |
|||
} |
|||
|
|||
public void Index(Guid id, Scope scope, Document document, bool forDraft, bool forPublished) |
|||
{ |
|||
var value = GetValue(forDraft, forPublished); |
|||
|
|||
document.SetBinaryDocValue(MetaFor, value); |
|||
|
|||
lastChanges[(id, scope)] = (forDraft, forPublished); |
|||
} |
|||
|
|||
public void Index(Guid id, Scope scope, Term term, bool forDraft, bool forPublished) |
|||
{ |
|||
var value = GetValue(forDraft, forPublished); |
|||
|
|||
index.Writer.UpdateBinaryDocValue(term, MetaFor, value); |
|||
|
|||
lastChanges[(id, scope)] = (forDraft, forPublished); |
|||
} |
|||
|
|||
public bool HasBeenAdded(Guid id, Scope scope, Term term, out int docId) |
|||
{ |
|||
docId = -1; |
|||
|
|||
if (lastChanges.ContainsKey((id, scope))) |
|||
{ |
|||
return true; |
|||
} |
|||
|
|||
if (index.Searcher == null) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
var docs = index.Searcher.Search(new TermQuery(term), 1); |
|||
|
|||
var found = docs.ScoreDocs.FirstOrDefault(); |
|||
|
|||
if (found != null) |
|||
{ |
|||
docId = found.Doc; |
|||
|
|||
return true; |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
public void Get(Guid id, Scope scope, int docId, out bool forDraft, out bool forPublished) |
|||
{ |
|||
if (lastChanges.TryGetValue((id, scope), out var forValue)) |
|||
{ |
|||
(forDraft, forPublished) = forValue; |
|||
} |
|||
else |
|||
{ |
|||
Get(docId, out forDraft, out forPublished); |
|||
} |
|||
} |
|||
|
|||
public void Get(int docId, out bool forDraft, out bool forPublished) |
|||
{ |
|||
var forValue = GetForValues(docId); |
|||
|
|||
(forDraft, forPublished) = ToFlags(forValue); |
|||
} |
|||
|
|||
private BytesRef GetForValues(int docId) |
|||
{ |
|||
return index.Reader.GetBinaryValue(MetaFor, docId, bytesRef); |
|||
} |
|||
|
|||
private static BytesRef GetValue(bool forDraft, bool forPublished) |
|||
{ |
|||
return new BytesRef(new[] |
|||
{ |
|||
(byte)(forDraft ? 1 : 0), |
|||
(byte)(forPublished ? 1 : 0) |
|||
}); |
|||
} |
|||
|
|||
private static (bool, bool) ToFlags(BytesRef bytes) |
|||
{ |
|||
return (bytes.Bytes[0] == 1, bytes.Bytes[1] == 1); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,53 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using Lucene.Net.Index; |
|||
using Lucene.Net.Util; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene |
|||
{ |
|||
public static class LuceneExtensions |
|||
{ |
|||
public static BytesRef GetBinaryValue(this IndexReader? reader, string field, int docId, BytesRef? result = null) |
|||
{ |
|||
if (result != null) |
|||
{ |
|||
Array.Clear(result.Bytes, 0, result.Bytes.Length); |
|||
} |
|||
else |
|||
{ |
|||
result = new BytesRef(); |
|||
} |
|||
|
|||
if (reader == null || docId < 0) |
|||
{ |
|||
return result; |
|||
} |
|||
|
|||
var leaves = reader.Leaves; |
|||
|
|||
if (leaves.Count == 1) |
|||
{ |
|||
var docValues = leaves[0].AtomicReader.GetBinaryDocValues(field); |
|||
|
|||
docValues.Get(docId, result); |
|||
} |
|||
else if (leaves.Count > 1) |
|||
{ |
|||
var subIndex = ReaderUtil.SubIndex(docId, leaves); |
|||
|
|||
var subLeave = leaves[subIndex]; |
|||
var subValues = subLeave.AtomicReader.GetBinaryDocValues(field); |
|||
|
|||
subValues.Get(docId - subLeave.DocBase, result); |
|||
} |
|||
|
|||
return result; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,61 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using Orleans; |
|||
using Orleans.Concurrency; |
|||
using Squidex.Domain.Apps.Entities.Apps; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Log; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene |
|||
{ |
|||
public sealed class LuceneTextIndex : IContentTextIndex |
|||
{ |
|||
private readonly IGrainFactory grainFactory; |
|||
|
|||
public LuceneTextIndex(IGrainFactory grainFactory) |
|||
{ |
|||
Guard.NotNull(grainFactory); |
|||
|
|||
this.grainFactory = grainFactory; |
|||
} |
|||
|
|||
public async Task<List<Guid>?> SearchAsync(string? queryText, IAppEntity app, SearchFilter? filter, SearchScope scope) |
|||
{ |
|||
if (string.IsNullOrWhiteSpace(queryText)) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var index = grainFactory.GetGrain<ILuceneTextIndexGrain>(app.Id); |
|||
|
|||
using (Profiler.TraceMethod<LuceneTextIndex>()) |
|||
{ |
|||
var context = CreateContext(app, scope); |
|||
|
|||
return await index.SearchAsync(queryText, filter, context); |
|||
} |
|||
} |
|||
|
|||
private static SearchContext CreateContext(IAppEntity app, SearchScope scope) |
|||
{ |
|||
var languages = new HashSet<string>(app.LanguagesConfig.AllKeys); |
|||
|
|||
return new SearchContext { Languages = languages, Scope = scope }; |
|||
} |
|||
|
|||
public Task ExecuteAsync(NamedId<Guid> appId, NamedId<Guid> schemaId, params IndexCommand[] commands) |
|||
{ |
|||
var index = grainFactory.GetGrain<ILuceneTextIndexGrain>(appId.Id); |
|||
|
|||
return index.IndexAsync(schemaId, commands.AsImmutable()); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,253 @@ |
|||
// ==========================================================================
|
|||
// 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.Threading.Tasks; |
|||
using Lucene.Net.Documents; |
|||
using Lucene.Net.Index; |
|||
using Lucene.Net.QueryParsers.Classic; |
|||
using Lucene.Net.Search; |
|||
using Lucene.Net.Util; |
|||
using Orleans.Concurrency; |
|||
using Squidex.Domain.Apps.Core; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Orleans; |
|||
using Squidex.Infrastructure.Validation; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text.Lucene |
|||
{ |
|||
public sealed class LuceneTextIndexGrain : GrainOfGuid, ILuceneTextIndexGrain |
|||
{ |
|||
private const LuceneVersion Version = LuceneVersion.LUCENE_48; |
|||
private const int MaxResults = 2000; |
|||
private const int MaxUpdates = 400; |
|||
private const string MetaId = "_id"; |
|||
private const string MetaFor = "_fd"; |
|||
private const string MetaContentId = "_cid"; |
|||
private const string MetaSchemaId = "_si"; |
|||
private const string MetaSchemaName = "_sn"; |
|||
private static readonly TimeSpan CommitDelay = TimeSpan.FromSeconds(10); |
|||
private static readonly string[] Invariant = { InvariantPartitioning.Key }; |
|||
private readonly IndexManager indexManager; |
|||
private IDisposable? timer; |
|||
private IIndex index; |
|||
private QueryParser? queryParser; |
|||
private HashSet<string>? currentLanguages; |
|||
private int updates; |
|||
|
|||
public LuceneTextIndexGrain(IndexManager indexManager) |
|||
{ |
|||
Guard.NotNull(indexManager); |
|||
|
|||
this.indexManager = indexManager; |
|||
} |
|||
|
|||
public override async Task OnDeactivateAsync() |
|||
{ |
|||
if (index != null) |
|||
{ |
|||
await CommitAsync(); |
|||
|
|||
await indexManager.ReleaseAsync(index); |
|||
} |
|||
} |
|||
|
|||
protected override async Task OnActivateAsync(Guid key) |
|||
{ |
|||
index = await indexManager.AcquireAsync(key); |
|||
} |
|||
|
|||
public Task<List<Guid>> SearchAsync(string queryText, SearchFilter? filter, SearchContext context) |
|||
{ |
|||
var result = new List<Guid>(); |
|||
|
|||
if (!string.IsNullOrWhiteSpace(queryText)) |
|||
{ |
|||
index.EnsureReader(); |
|||
|
|||
if (index.Searcher != null) |
|||
{ |
|||
var query = BuildQuery(queryText, filter, context); |
|||
|
|||
var hits = index.Searcher.Search(query, MaxResults).ScoreDocs; |
|||
|
|||
if (hits.Length > 0) |
|||
{ |
|||
var buffer = new BytesRef(2); |
|||
|
|||
var found = new HashSet<Guid>(); |
|||
|
|||
foreach (var hit in hits) |
|||
{ |
|||
var forValue = index.Reader.GetBinaryValue(MetaFor, hit.Doc, buffer); |
|||
|
|||
if (context.Scope == SearchScope.All && forValue.Bytes[0] != 1) |
|||
{ |
|||
continue; |
|||
} |
|||
|
|||
if (context.Scope == SearchScope.Published && forValue.Bytes[1] != 1) |
|||
{ |
|||
continue; |
|||
} |
|||
|
|||
var document = index.Searcher.Doc(hit.Doc); |
|||
|
|||
if (document != null) |
|||
{ |
|||
var idString = document.Get(MetaContentId); |
|||
|
|||
if (Guid.TryParse(idString, out var id)) |
|||
{ |
|||
if (found.Add(id)) |
|||
{ |
|||
result.Add(id); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
return Task.FromResult(result); |
|||
} |
|||
|
|||
private Query BuildQuery(string query, SearchFilter? filter, SearchContext context) |
|||
{ |
|||
if (queryParser == null || currentLanguages == null || !currentLanguages.SetEquals(context.Languages)) |
|||
{ |
|||
var fields = context.Languages.Union(Invariant).ToArray(); |
|||
|
|||
queryParser = new MultiFieldQueryParser(Version, fields, index.Analyzer); |
|||
|
|||
currentLanguages = context.Languages; |
|||
} |
|||
|
|||
try |
|||
{ |
|||
var byQuery = queryParser.Parse(query); |
|||
|
|||
if (filter?.SchemaIds.Count > 0) |
|||
{ |
|||
var bySchemas = new BooleanQuery |
|||
{ |
|||
Boost = 2f |
|||
}; |
|||
|
|||
foreach (var schemaId in filter.SchemaIds) |
|||
{ |
|||
var term = new Term(MetaSchemaId, schemaId.ToString()); |
|||
|
|||
bySchemas.Add(new TermQuery(term), Occur.SHOULD); |
|||
} |
|||
|
|||
var occur = filter.Must ? Occur.MUST : Occur.SHOULD; |
|||
|
|||
return new BooleanQuery |
|||
{ |
|||
{ byQuery, Occur.MUST }, |
|||
{ bySchemas, occur } |
|||
}; |
|||
} |
|||
|
|||
return byQuery; |
|||
} |
|||
catch (ParseException ex) |
|||
{ |
|||
throw new ValidationException(ex.Message); |
|||
} |
|||
} |
|||
|
|||
private async Task<bool> TryCommitAsync() |
|||
{ |
|||
timer?.Dispose(); |
|||
|
|||
updates++; |
|||
|
|||
if (updates >= MaxUpdates) |
|||
{ |
|||
await CommitAsync(); |
|||
|
|||
return true; |
|||
} |
|||
else |
|||
{ |
|||
index.MarkStale(); |
|||
|
|||
try |
|||
{ |
|||
timer = RegisterTimer(_ => CommitAsync(), null, CommitDelay, CommitDelay); |
|||
} |
|||
catch (InvalidOperationException) |
|||
{ |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
public async Task CommitAsync() |
|||
{ |
|||
if (updates > 0) |
|||
{ |
|||
await indexManager.CommitAsync(index); |
|||
|
|||
updates = 0; |
|||
} |
|||
} |
|||
|
|||
public Task IndexAsync(NamedId<Guid> schemaId, Immutable<IndexCommand[]> updates) |
|||
{ |
|||
foreach (var command in updates.Value) |
|||
{ |
|||
switch (command) |
|||
{ |
|||
case DeleteIndexEntry delete: |
|||
index.Writer.DeleteDocuments(new Term(MetaId, delete.DocId)); |
|||
break; |
|||
case UpdateIndexEntry update: |
|||
index.Writer.UpdateBinaryDocValue(new Term(MetaId, update.DocId), MetaFor, GetValue(update.ServeAll, update.ServePublished)); |
|||
break; |
|||
case UpsertIndexEntry upsert: |
|||
{ |
|||
var document = new Document(); |
|||
|
|||
document.AddStringField(MetaId, upsert.DocId, Field.Store.YES); |
|||
document.AddStringField(MetaContentId, upsert.ContentId.ToString(), Field.Store.YES); |
|||
document.AddStringField(MetaSchemaId, schemaId.Id.ToString(), Field.Store.YES); |
|||
document.AddStringField(MetaSchemaName, schemaId.Name, Field.Store.YES); |
|||
document.AddBinaryDocValuesField(MetaFor, GetValue(upsert.ServeAll, upsert.ServePublished)); |
|||
|
|||
foreach (var (key, value) in upsert.Texts) |
|||
{ |
|||
document.AddTextField(key, value, Field.Store.NO); |
|||
} |
|||
|
|||
index.Writer.UpdateDocument(new Term(MetaId, upsert.DocId), document); |
|||
|
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
return TryCommitAsync(); |
|||
} |
|||
|
|||
private static BytesRef GetValue(bool forDraft, bool forPublished) |
|||
{ |
|||
return new BytesRef(new[] |
|||
{ |
|||
(byte)(forDraft ? 1 : 0), |
|||
(byte)(forPublished ? 1 : 0) |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,46 @@ |
|||
// ==========================================================================
|
|||
// 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 Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
[Equals(DoNotAddEqualityOperators = true)] |
|||
public sealed class SearchFilter |
|||
{ |
|||
public IReadOnlyList<Guid> SchemaIds { get; } |
|||
|
|||
public bool Must { get; } |
|||
|
|||
public SearchFilter(IReadOnlyList<Guid> schemaIds, bool must) |
|||
{ |
|||
Guard.NotNull(schemaIds); |
|||
|
|||
SchemaIds = schemaIds; |
|||
|
|||
Must = must; |
|||
} |
|||
|
|||
public static SearchFilter MustHaveSchemas(List<Guid> schemaIds) |
|||
{ |
|||
return new SearchFilter(schemaIds, true); |
|||
} |
|||
|
|||
public static SearchFilter MustHaveSchemas(params Guid[] schemaIds) |
|||
{ |
|||
return new SearchFilter(schemaIds?.ToList()!, true); |
|||
} |
|||
|
|||
public static SearchFilter ShouldHaveSchemas(params Guid[] schemaIds) |
|||
{ |
|||
return new SearchFilter(schemaIds?.ToList()!, false); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,64 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Caching; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text.State |
|||
{ |
|||
public sealed class CachingTextIndexerState : ITextIndexerState |
|||
{ |
|||
private readonly ITextIndexerState inner; |
|||
private LRUCache<Guid, Tuple<TextContentState?>> cache = new LRUCache<Guid, Tuple<TextContentState?>>(1000); |
|||
|
|||
public CachingTextIndexerState(ITextIndexerState inner) |
|||
{ |
|||
Guard.NotNull(inner); |
|||
|
|||
this.inner = inner; |
|||
} |
|||
|
|||
public async Task ClearAsync() |
|||
{ |
|||
await inner.ClearAsync(); |
|||
|
|||
cache = new LRUCache<Guid, Tuple<TextContentState?>>(1000); |
|||
} |
|||
|
|||
public async Task<TextContentState?> GetAsync(Guid contentId) |
|||
{ |
|||
if (cache.TryGetValue(contentId, out var value)) |
|||
{ |
|||
return value.Item1; |
|||
} |
|||
|
|||
var result = await inner.GetAsync(contentId); |
|||
|
|||
cache.Set(contentId, Tuple.Create(result)); |
|||
|
|||
return result; |
|||
} |
|||
|
|||
public Task SetAsync(TextContentState state) |
|||
{ |
|||
Guard.NotNull(state); |
|||
|
|||
cache.Set(state.ContentId, Tuple.Create<TextContentState?>(state)); |
|||
|
|||
return inner.SetAsync(state); |
|||
} |
|||
|
|||
public Task RemoveAsync(Guid contentId) |
|||
{ |
|||
cache.Set(contentId, Tuple.Create<TextContentState?>(null)); |
|||
|
|||
return inner.RemoveAsync(contentId); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text.State |
|||
{ |
|||
public interface ITextIndexerState |
|||
{ |
|||
Task<TextContentState?> GetAsync(Guid contentId); |
|||
|
|||
Task SetAsync(TextContentState state); |
|||
|
|||
Task RemoveAsync(Guid contentId); |
|||
|
|||
Task ClearAsync(); |
|||
} |
|||
} |
|||
@ -0,0 +1,50 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using Squidex.Infrastructure.Tasks; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text.State |
|||
{ |
|||
public sealed class InMemoryTextIndexerState : ITextIndexerState |
|||
{ |
|||
private readonly Dictionary<Guid, TextContentState> states = new Dictionary<Guid, TextContentState>(); |
|||
|
|||
public Task ClearAsync() |
|||
{ |
|||
states.Clear(); |
|||
|
|||
return TaskHelper.Done; |
|||
} |
|||
|
|||
public Task<TextContentState?> GetAsync(Guid contentId) |
|||
{ |
|||
if (states.TryGetValue(contentId, out var result)) |
|||
{ |
|||
return Task.FromResult<TextContentState?>(result); |
|||
} |
|||
|
|||
return Task.FromResult<TextContentState?>(null); |
|||
} |
|||
|
|||
public Task RemoveAsync(Guid contentId) |
|||
{ |
|||
states.Remove(contentId); |
|||
|
|||
return TaskHelper.Done; |
|||
} |
|||
|
|||
public Task SetAsync(TextContentState state) |
|||
{ |
|||
states[state.ContentId] = state; |
|||
|
|||
return TaskHelper.Done; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,46 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text.State |
|||
{ |
|||
public sealed class TextContentState |
|||
{ |
|||
public Guid ContentId { get; set; } |
|||
|
|||
public string DocIdCurrent { get; set; } |
|||
|
|||
public string? DocIdNew { get; set; } |
|||
|
|||
public string? DocIdForPublished { get; set; } |
|||
|
|||
public void GenerateDocIdNew() |
|||
{ |
|||
if (DocIdCurrent?.EndsWith("_2") != false) |
|||
{ |
|||
DocIdNew = $"{ContentId}_1"; |
|||
} |
|||
else |
|||
{ |
|||
DocIdNew = $"{ContentId}_2"; |
|||
} |
|||
} |
|||
|
|||
public void GenerateDocIdCurrent() |
|||
{ |
|||
if (DocIdNew?.EndsWith("_2") != false) |
|||
{ |
|||
DocIdCurrent = $"{ContentId}_1"; |
|||
} |
|||
else |
|||
{ |
|||
DocIdCurrent = $"{ContentId}_2"; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -1,172 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using Lucene.Net.Documents; |
|||
using Lucene.Net.Index; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
internal sealed class TextIndexContent |
|||
{ |
|||
private const string MetaId = "_id"; |
|||
private const string MetaKey = "_key"; |
|||
private readonly IIndex index; |
|||
private readonly IndexState indexState; |
|||
private readonly Guid id; |
|||
|
|||
public TextIndexContent(IIndex index, IndexState indexState, Guid id) |
|||
{ |
|||
this.index = index; |
|||
this.indexState = indexState; |
|||
|
|||
this.id = id; |
|||
} |
|||
|
|||
public void Delete() |
|||
{ |
|||
index.Writer.DeleteDocuments(new Term(MetaId, id.ToString())); |
|||
} |
|||
|
|||
public static bool TryGetId(int docId, Scope scope, IIndex index, IndexState indexState, out Guid result) |
|||
{ |
|||
result = Guid.Empty; |
|||
|
|||
indexState.Get(docId, out var draft, out var published); |
|||
|
|||
if (scope == Scope.Draft && !draft) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
if (scope == Scope.Published && !published) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
var document = index.Searcher?.Doc(docId); |
|||
|
|||
if (document != null) |
|||
{ |
|||
var idString = document.Get(MetaId); |
|||
|
|||
if (!Guid.TryParse(idString, out result)) |
|||
{ |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
|
|||
public void Index(Dictionary<string, string> text, bool onlyDraft) |
|||
{ |
|||
var converted = CreateDocument(text); |
|||
|
|||
Upsert(converted, Scope.Draft, |
|||
forDraft: true, |
|||
forPublished: false); |
|||
|
|||
var isPublishDocumentAdded = IsAdded(Scope.Published, out var docId); |
|||
var isPublishForPublished = IsForPublished(Scope.Published, docId); |
|||
|
|||
if (!onlyDraft && isPublishDocumentAdded && isPublishForPublished) |
|||
{ |
|||
Upsert(converted, Scope.Published, |
|||
forDraft: false, |
|||
forPublished: true); |
|||
} |
|||
else if (!onlyDraft || !isPublishDocumentAdded) |
|||
{ |
|||
Upsert(converted, Scope.Published, |
|||
forDraft: false, |
|||
forPublished: false); |
|||
} |
|||
else |
|||
{ |
|||
UpdateFor(Scope.Published, |
|||
forDraft: false, |
|||
forPublished: isPublishForPublished); |
|||
} |
|||
} |
|||
|
|||
public void Copy(bool fromDraft) |
|||
{ |
|||
if (fromDraft) |
|||
{ |
|||
UpdateFor(Scope.Draft, |
|||
forDraft: true, |
|||
forPublished: false); |
|||
|
|||
UpdateFor(Scope.Published, |
|||
forDraft: false, |
|||
forPublished: true); |
|||
} |
|||
else |
|||
{ |
|||
UpdateFor(Scope.Draft, |
|||
forDraft: false, |
|||
forPublished: false); |
|||
|
|||
UpdateFor(Scope.Published, |
|||
forDraft: true, |
|||
forPublished: true); |
|||
} |
|||
} |
|||
|
|||
private static Document CreateDocument(Dictionary<string, string> text) |
|||
{ |
|||
var document = new Document(); |
|||
|
|||
foreach (var (key, value) in text) |
|||
{ |
|||
document.AddTextField(key, value, Field.Store.NO); |
|||
} |
|||
|
|||
return document; |
|||
} |
|||
|
|||
private void UpdateFor(Scope scope, bool forDraft, bool forPublished) |
|||
{ |
|||
var term = new Term(MetaKey, BuildKey(scope)); |
|||
|
|||
indexState.Index(id, scope, term, forDraft, forPublished); |
|||
} |
|||
|
|||
private void Upsert(Document document, Scope draft, bool forDraft, bool forPublished) |
|||
{ |
|||
var contentKey = BuildKey(draft); |
|||
|
|||
document.SetField(MetaId, id.ToString()); |
|||
document.SetField(MetaKey, contentKey); |
|||
|
|||
indexState.Index(id, draft, document, forDraft, forPublished); |
|||
|
|||
index.Writer.UpdateDocument(new Term(MetaKey, contentKey), document); |
|||
} |
|||
|
|||
private bool IsAdded(Scope scope, out int docId) |
|||
{ |
|||
var term = new Term(MetaKey, BuildKey(scope)); |
|||
|
|||
return indexState.HasBeenAdded(id, scope, term, out docId); |
|||
} |
|||
|
|||
private bool IsForPublished(Scope scope, int docId) |
|||
{ |
|||
indexState.Get(id, scope, docId, out _, out var forPublished); |
|||
|
|||
return forPublished; |
|||
} |
|||
|
|||
private string BuildKey(Scope scope) |
|||
{ |
|||
return $"{id}_{(scope == Scope.Draft ? 1 : 0)}"; |
|||
} |
|||
} |
|||
} |
|||
@ -1,181 +0,0 @@ |
|||
// ==========================================================================
|
|||
// 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.Threading.Tasks; |
|||
using Lucene.Net.QueryParsers.Classic; |
|||
using Lucene.Net.Search; |
|||
using Lucene.Net.Util; |
|||
using Squidex.Domain.Apps.Core; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Orleans; |
|||
using Squidex.Infrastructure.Validation; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
public sealed class TextIndexerGrain : GrainOfGuid, ITextIndexerGrain |
|||
{ |
|||
private const LuceneVersion Version = LuceneVersion.LUCENE_48; |
|||
private const int MaxResults = 2000; |
|||
private const int MaxUpdates = 400; |
|||
private static readonly TimeSpan CommitDelay = TimeSpan.FromSeconds(10); |
|||
private static readonly string[] Invariant = { InvariantPartitioning.Key }; |
|||
private readonly IndexManager indexManager; |
|||
private IDisposable? timer; |
|||
private IIndex index; |
|||
private IndexState indexState; |
|||
private QueryParser? queryParser; |
|||
private HashSet<string>? currentLanguages; |
|||
private int updates; |
|||
|
|||
public TextIndexerGrain(IndexManager indexManager) |
|||
{ |
|||
Guard.NotNull(indexManager); |
|||
|
|||
this.indexManager = indexManager; |
|||
} |
|||
|
|||
public override async Task OnDeactivateAsync() |
|||
{ |
|||
if (index != null) |
|||
{ |
|||
await indexManager.ReleaseAsync(index); |
|||
} |
|||
} |
|||
|
|||
protected override async Task OnActivateAsync(Guid key) |
|||
{ |
|||
index = await indexManager.AcquireAsync(key); |
|||
|
|||
indexState = new IndexState(index); |
|||
} |
|||
|
|||
public Task<bool> IndexAsync(Update update) |
|||
{ |
|||
var content = new TextIndexContent(index, indexState, update.Id); |
|||
|
|||
content.Index(update.Text, update.OnlyDraft); |
|||
|
|||
return TryCommitAsync(); |
|||
} |
|||
|
|||
public Task<bool> CopyAsync(Guid id, bool fromDraft) |
|||
{ |
|||
var content = new TextIndexContent(index, indexState, id); |
|||
|
|||
content.Copy(fromDraft); |
|||
|
|||
return TryCommitAsync(); |
|||
} |
|||
|
|||
public Task<bool> DeleteAsync(Guid id) |
|||
{ |
|||
var content = new TextIndexContent(index, indexState, id); |
|||
|
|||
content.Delete(); |
|||
|
|||
return TryCommitAsync(); |
|||
} |
|||
|
|||
public Task<List<Guid>> SearchAsync(string queryText, SearchContext context) |
|||
{ |
|||
var result = new List<Guid>(); |
|||
|
|||
if (!string.IsNullOrWhiteSpace(queryText)) |
|||
{ |
|||
index.EnsureReader(); |
|||
|
|||
if (index.Searcher != null) |
|||
{ |
|||
var query = BuildQuery(queryText, context); |
|||
|
|||
var hits = index.Searcher.Search(query, MaxResults).ScoreDocs; |
|||
|
|||
if (hits.Length > 0) |
|||
{ |
|||
var found = new HashSet<Guid>(); |
|||
|
|||
foreach (var hit in hits) |
|||
{ |
|||
if (TextIndexContent.TryGetId(hit.Doc, context.Scope, index, indexState, out var id)) |
|||
{ |
|||
if (found.Add(id)) |
|||
{ |
|||
result.Add(id); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
return Task.FromResult(result); |
|||
} |
|||
|
|||
private Query BuildQuery(string query, SearchContext context) |
|||
{ |
|||
if (queryParser == null || currentLanguages == null || !currentLanguages.SetEquals(context.Languages)) |
|||
{ |
|||
var fields = context.Languages.Union(Invariant).ToArray(); |
|||
|
|||
queryParser = new MultiFieldQueryParser(Version, fields, index.Analyzer); |
|||
|
|||
currentLanguages = context.Languages; |
|||
} |
|||
|
|||
try |
|||
{ |
|||
return queryParser.Parse(query); |
|||
} |
|||
catch (ParseException ex) |
|||
{ |
|||
throw new ValidationException(ex.Message); |
|||
} |
|||
} |
|||
|
|||
private async Task<bool> TryCommitAsync() |
|||
{ |
|||
timer?.Dispose(); |
|||
|
|||
updates++; |
|||
|
|||
if (updates >= MaxUpdates) |
|||
{ |
|||
await CommitAsync(); |
|||
|
|||
return true; |
|||
} |
|||
else |
|||
{ |
|||
index.MarkStale(); |
|||
|
|||
try |
|||
{ |
|||
timer = RegisterTimer(_ => CommitAsync(), null, CommitDelay, CommitDelay); |
|||
} |
|||
catch (InvalidOperationException) |
|||
{ |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
public async Task CommitAsync() |
|||
{ |
|||
if (updates > 0) |
|||
{ |
|||
await indexManager.CommitAsync(index); |
|||
|
|||
updates = 0; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,277 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Threading.Tasks; |
|||
using Squidex.Domain.Apps.Core.Contents; |
|||
using Squidex.Domain.Apps.Entities.Contents.Text.State; |
|||
using Squidex.Domain.Apps.Events.Contents; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
public sealed class TextIndexingProcess : IEventConsumer |
|||
{ |
|||
private const string NotFound = "<404>"; |
|||
private readonly IContentTextIndex textIndexer; |
|||
private readonly ITextIndexerState textIndexerState; |
|||
|
|||
public string Name |
|||
{ |
|||
get { return "TextIndexer2"; } |
|||
} |
|||
|
|||
public string EventsFilter |
|||
{ |
|||
get { return "^content-"; } |
|||
} |
|||
|
|||
public IContentTextIndex TextIndexer |
|||
{ |
|||
get { return textIndexer; } |
|||
} |
|||
|
|||
public TextIndexingProcess(IContentTextIndex textIndexer, ITextIndexerState textIndexerState) |
|||
{ |
|||
Guard.NotNull(textIndexer); |
|||
Guard.NotNull(textIndexerState); |
|||
|
|||
this.textIndexer = textIndexer; |
|||
this.textIndexerState = textIndexerState; |
|||
} |
|||
|
|||
public bool Handles(StoredEvent @event) |
|||
{ |
|||
return true; |
|||
} |
|||
|
|||
public Task ClearAsync() |
|||
{ |
|||
return textIndexerState.ClearAsync(); |
|||
} |
|||
|
|||
public async Task On(Envelope<IEvent> @event) |
|||
{ |
|||
switch (@event.Payload) |
|||
{ |
|||
case ContentCreated created: |
|||
await CreateAsync(created); |
|||
break; |
|||
case ContentUpdated updated: |
|||
await UpdateAsync(updated); |
|||
break; |
|||
case ContentStatusChanged statusChanged when statusChanged.Status == Status.Published: |
|||
await PublishAsync(statusChanged); |
|||
break; |
|||
case ContentStatusChanged statusChanged: |
|||
await UnpublishAsync(statusChanged); |
|||
break; |
|||
case ContentDraftCreated draftCreated: |
|||
await CreateDraftAsync(draftCreated); |
|||
break; |
|||
case ContentDraftDeleted draftDelted: |
|||
await DeleteDraftAsync(draftDelted); |
|||
break; |
|||
case ContentDeleted deleted: |
|||
await DeleteAsync(deleted); |
|||
break; |
|||
} |
|||
} |
|||
|
|||
private async Task CreateAsync(ContentCreated @event) |
|||
{ |
|||
var state = new TextContentState |
|||
{ |
|||
ContentId = @event.ContentId |
|||
}; |
|||
|
|||
state.GenerateDocIdCurrent(); |
|||
|
|||
await IndexAsync(@event, |
|||
new UpsertIndexEntry |
|||
{ |
|||
ContentId = @event.ContentId, |
|||
DocId = state.DocIdCurrent, |
|||
ServeAll = true, |
|||
ServePublished = false, |
|||
Texts = @event.Data.ToTexts(), |
|||
}); |
|||
|
|||
await textIndexerState.SetAsync(state); |
|||
} |
|||
|
|||
private async Task CreateDraftAsync(ContentDraftCreated @event) |
|||
{ |
|||
var state = await textIndexerState.GetAsync(@event.ContentId); |
|||
|
|||
if (state != null) |
|||
{ |
|||
state.GenerateDocIdNew(); |
|||
|
|||
await textIndexerState.SetAsync(state); |
|||
} |
|||
} |
|||
|
|||
private async Task UpdateAsync(ContentUpdated @event) |
|||
{ |
|||
var state = await textIndexerState.GetAsync(@event.ContentId); |
|||
|
|||
if (state != null) |
|||
{ |
|||
if (state.DocIdNew != null) |
|||
{ |
|||
await IndexAsync(@event, |
|||
new UpsertIndexEntry |
|||
{ |
|||
ContentId = @event.ContentId, |
|||
DocId = state.DocIdNew, |
|||
ServeAll = true, |
|||
ServePublished = false, |
|||
Texts = @event.Data.ToTexts() |
|||
}, |
|||
new UpdateIndexEntry |
|||
{ |
|||
DocId = state.DocIdCurrent, |
|||
ServeAll = false, |
|||
ServePublished = true |
|||
}); |
|||
|
|||
state.DocIdForPublished = state.DocIdCurrent; |
|||
} |
|||
else |
|||
{ |
|||
var isPublished = state.DocIdCurrent == state.DocIdForPublished; |
|||
|
|||
await IndexAsync(@event, |
|||
new UpsertIndexEntry |
|||
{ |
|||
ContentId = @event.ContentId, |
|||
DocId = state.DocIdCurrent, |
|||
ServeAll = true, |
|||
ServePublished = isPublished, |
|||
Texts = @event.Data.ToTexts() |
|||
}); |
|||
|
|||
state.DocIdForPublished = state.DocIdNew; |
|||
} |
|||
|
|||
await textIndexerState.SetAsync(state); |
|||
} |
|||
} |
|||
|
|||
private async Task UnpublishAsync(ContentStatusChanged @event) |
|||
{ |
|||
var state = await textIndexerState.GetAsync(@event.ContentId); |
|||
|
|||
if (state != null && state.DocIdForPublished != null) |
|||
{ |
|||
await IndexAsync(@event, |
|||
new UpdateIndexEntry |
|||
{ |
|||
DocId = state.DocIdForPublished, |
|||
ServeAll = true, |
|||
ServePublished = false |
|||
}); |
|||
|
|||
state.DocIdForPublished = null; |
|||
|
|||
await textIndexerState.SetAsync(state); |
|||
} |
|||
} |
|||
|
|||
private async Task PublishAsync(ContentStatusChanged @event) |
|||
{ |
|||
var state = await textIndexerState.GetAsync(@event.ContentId); |
|||
|
|||
if (state != null) |
|||
{ |
|||
if (state.DocIdNew != null) |
|||
{ |
|||
await IndexAsync(@event, |
|||
new UpdateIndexEntry |
|||
{ |
|||
DocId = state.DocIdNew, |
|||
ServeAll = true, |
|||
ServePublished = true |
|||
}, |
|||
new DeleteIndexEntry |
|||
{ |
|||
DocId = state.DocIdCurrent |
|||
}); |
|||
|
|||
state.DocIdForPublished = state.DocIdNew; |
|||
state.DocIdCurrent = state.DocIdNew; |
|||
} |
|||
else |
|||
{ |
|||
await IndexAsync(@event, |
|||
new UpdateIndexEntry |
|||
{ |
|||
DocId = state.DocIdCurrent, |
|||
ServeAll = true, |
|||
ServePublished = true |
|||
}); |
|||
|
|||
state.DocIdForPublished = state.DocIdCurrent; |
|||
} |
|||
|
|||
state.DocIdNew = null; |
|||
|
|||
await textIndexerState.SetAsync(state); |
|||
} |
|||
} |
|||
|
|||
private async Task DeleteDraftAsync(ContentDraftDeleted @event) |
|||
{ |
|||
var state = await textIndexerState.GetAsync(@event.ContentId); |
|||
|
|||
if (state != null && state.DocIdNew != null) |
|||
{ |
|||
await IndexAsync(@event, |
|||
new UpdateIndexEntry |
|||
{ |
|||
DocId = state.DocIdCurrent, |
|||
ServeAll = true, |
|||
ServePublished = true |
|||
}, |
|||
new DeleteIndexEntry |
|||
{ |
|||
DocId = state.DocIdNew, |
|||
}); |
|||
|
|||
state.DocIdNew = null; |
|||
|
|||
await textIndexerState.SetAsync(state); |
|||
} |
|||
} |
|||
|
|||
private async Task DeleteAsync(ContentDeleted @event) |
|||
{ |
|||
var state = await textIndexerState.GetAsync(@event.ContentId); |
|||
|
|||
if (state != null) |
|||
{ |
|||
await IndexAsync(@event, |
|||
new DeleteIndexEntry |
|||
{ |
|||
DocId = state.DocIdCurrent |
|||
}, |
|||
new DeleteIndexEntry |
|||
{ |
|||
DocId = state.DocIdNew ?? NotFound, |
|||
}); |
|||
|
|||
await textIndexerState.RemoveAsync(state.ContentId); |
|||
} |
|||
} |
|||
|
|||
private Task IndexAsync(ContentEvent @event, params IndexCommand[] commands) |
|||
{ |
|||
return textIndexer.ExecuteAsync(@event.AppId, @event.SchemaId, commands); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
public sealed class UpdateIndexEntry : IndexCommand |
|||
{ |
|||
public bool ServeAll { get; set; } |
|||
|
|||
public bool ServePublished { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,3 @@ |
|||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd"> |
|||
<Equals /> |
|||
</Weavers> |
|||
@ -0,0 +1,26 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> |
|||
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. --> |
|||
<xs:element name="Weavers"> |
|||
<xs:complexType> |
|||
<xs:all> |
|||
<xs:element name="Equals" minOccurs="0" maxOccurs="1" type="xs:anyType" /> |
|||
</xs:all> |
|||
<xs:attribute name="VerifyAssembly" type="xs:boolean"> |
|||
<xs:annotation> |
|||
<xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation> |
|||
</xs:annotation> |
|||
</xs:attribute> |
|||
<xs:attribute name="VerifyIgnoreCodes" type="xs:string"> |
|||
<xs:annotation> |
|||
<xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation> |
|||
</xs:annotation> |
|||
</xs:attribute> |
|||
<xs:attribute name="GenerateXsd" type="xs:boolean"> |
|||
<xs:annotation> |
|||
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation> |
|||
</xs:annotation> |
|||
</xs:attribute> |
|||
</xs:complexType> |
|||
</xs:element> |
|||
</xs:schema> |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue