Browse Source

Merge branch 'master' of https://github.com/Squidex/squidex

# Conflicts:
#	backend/src/Squidex/wwwroot/scripts/editor-sdk.d.ts
#	backend/src/Squidex/wwwroot/scripts/editor-sdk.js
pull/1039/head
Sebastian 3 years ago
parent
commit
f1c0ef68a3
  1. 2
      .github/workflows/check-updates.yml
  2. 10
      .github/workflows/dev.yml
  3. 10
      .github/workflows/release.yml
  4. 20
      CHANGELOG.md
  5. 90
      backend/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaActionHandler.cs
  6. 67
      backend/extensions/Squidex.Extensions/Actions/Comment/CommentActionHandler.cs
  7. 3
      backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentActionHandler.cs
  8. 29
      backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectAction.cs
  9. 197
      backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectActionHandler.cs
  10. 32
      backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectPlugin.cs
  11. 1
      backend/extensions/Squidex.Extensions/Actions/Email/EmailActionHandler.cs
  12. 86
      backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs
  13. 4
      backend/extensions/Squidex.Extensions/Actions/Prerender/PrerenderActionHandler.cs
  14. 25
      backend/i18n/frontend_en.json
  15. 33
      backend/i18n/frontend_fr.json
  16. 25
      backend/i18n/frontend_it.json
  17. 25
      backend/i18n/frontend_nl.json
  18. 21
      backend/i18n/frontend_pt.json
  19. 25
      backend/i18n/frontend_zh.json
  20. 25
      backend/i18n/source/frontend_en.json
  21. 16
      backend/i18n/source/frontend_fr.json
  22. 5
      backend/i18n/source/frontend_it.json
  23. 5
      backend/i18n/source/frontend_nl.json
  24. 5
      backend/i18n/source/frontend_pt.json
  25. 5
      backend/i18n/source/frontend_zh.json
  26. 10
      backend/src/Migrations/MigrationPath.cs
  27. 66
      backend/src/Migrations/Migrations/MongoDb/CopyRuleStatistics.cs
  28. 4
      backend/src/Migrations/OldEvents/CommentDeleted.cs
  29. 4
      backend/src/Migrations/OldEvents/CommentUpdated.cs
  30. 25
      backend/src/Migrations/RebuilderExtensions.cs
  31. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs
  32. 9
      backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs
  33. 3
      backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx
  34. 6
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayCalculatedDefaultValue.cs
  35. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayFieldProperties.cs
  36. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/ComponentsFieldProperties.cs
  37. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs
  38. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs
  39. 20
      backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/DefaultValueFactory.cs
  40. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs
  41. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
  42. 20
      backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/SubscriptionPublisher.cs
  43. 27
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Extensions.cs
  44. 20
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemasHash.cs
  45. 2
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj
  46. 4
      backend/src/Squidex.Domain.Apps.Entities/Apps/AppEventDeleter.cs
  47. 10
      backend/src/Squidex.Domain.Apps.Entities/Apps/AppPermanentDeleter.cs
  48. 10
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetPermanentDeleter.cs
  49. 20
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs
  50. 20
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs
  51. 4
      backend/src/Squidex.Domain.Apps.Entities/Assets/RebuildFiles.cs
  52. 10
      backend/src/Squidex.Domain.Apps.Entities/Assets/RecursiveDeleter.cs
  53. 10
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.cs
  54. 12
      backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.Rules.cs
  55. 2
      backend/src/Squidex.Domain.Apps.Entities/Billing/UsageNotifierWorker.cs
  56. 266
      backend/src/Squidex.Domain.Apps.Entities/Collaboration/CommentCollaborationHandler.cs
  57. 3
      backend/src/Squidex.Domain.Apps.Entities/Collaboration/CommentTriggerHandler.cs
  58. 2
      backend/src/Squidex.Domain.Apps.Entities/Collaboration/EmailUserNotificationOptions.cs
  59. 2
      backend/src/Squidex.Domain.Apps.Entities/Collaboration/EmailUserNotifications.cs
  60. 23
      backend/src/Squidex.Domain.Apps.Entities/Collaboration/ICollaborationService.cs
  61. 2
      backend/src/Squidex.Domain.Apps.Entities/Collaboration/IUserNotifications.cs
  62. 2
      backend/src/Squidex.Domain.Apps.Entities/Collaboration/NoopUserNotifications.cs
  63. 15
      backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentTextCommand.cs
  64. 22
      backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CreateComment.cs
  65. 48
      backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/_CommentsCommand.cs
  66. 32
      backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsLoader.cs
  67. 95
      backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsResult.cs
  68. 76
      backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsCommandMiddleware.cs
  69. 167
      backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsStream.cs
  70. 87
      backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/Guards/GuardComments.cs
  71. 16
      backend/src/Squidex.Domain.Apps.Entities/Comments/ICommentsLoader.cs
  72. 16
      backend/src/Squidex.Domain.Apps.Entities/Comments/IWatchingService.cs
  73. 61
      backend/src/Squidex.Domain.Apps.Entities/Comments/WatchingService.cs
  74. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs
  75. 11
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentHeaders.cs
  76. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Cache/CachingBatchLoader.cs
  77. 5
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs
  78. 55
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ApplicationQueries.cs
  79. 30
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs
  80. 3
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ConvertData.cs
  81. 20
      backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs
  82. 20
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs
  83. 12
      backend/src/Squidex.Domain.Apps.Entities/Invitation/InvitationEventConsumer.cs
  84. 5
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs
  85. 4
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerProcessor.cs
  86. 7
      backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj
  87. 7
      backend/src/Squidex.Domain.Apps.Events/Comments/CommentCreated.cs
  88. 17
      backend/src/Squidex.Domain.Apps.Events/Comments/CommentsEvent.cs
  89. 2
      backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj
  90. 12
      backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/EventStoreProjectionClient.cs
  91. 51
      backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs
  92. 4
      backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStoreSubscription.cs
  93. 17
      backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/Utils.cs
  94. 45
      backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/FilterExtensions.cs
  95. 10
      backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStoreSubscription.cs
  96. 90
      backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs
  97. 14
      backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs
  98. 4
      backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj
  99. 4
      backend/src/Squidex.Infrastructure/Commands/Rebuilder.cs
  100. 6
      backend/src/Squidex.Infrastructure/DomainId.cs

2
.github/workflows/check-updates.yml

@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3.6.0
- uses: actions/checkout@v4.1.1
with:
token: ${{ secrets.WORKFLOW_SECRET }}

10
.github/workflows/dev.yml

@ -16,19 +16,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Prepare - Checkout
uses: actions/checkout@v3.6.0
uses: actions/checkout@v4.1.1
- name: Prepare - Inject short Variables
uses: rlespinasse/github-slug-action@v4.4.1
- name: Prepare - Set up QEMU
uses: docker/setup-qemu-action@v2.2.0
uses: docker/setup-qemu-action@v3.0.0
- name: Prepare - Set up Docker Buildx
uses: docker/setup-buildx-action@v2.9.1
uses: docker/setup-buildx-action@v3.0.0
- name: Build - BUILD
uses: docker/build-push-action@v4.1.1
uses: docker/build-push-action@v5.0.0
with:
load: true
build-args: "SQUIDEX__RUNTIME__VERSION=7.0.0-dev-${{ env.BUILD_NUMBER }}"
@ -103,7 +103,7 @@ jobs:
- name: Publish - Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2.2.0
uses: docker/login-action@v3.0.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

10
.github/workflows/release.yml

@ -11,19 +11,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Prepare - Checkout
uses: actions/checkout@v3.6.0
uses: actions/checkout@v4.1.1
- name: Prepare - Inject short Variables
uses: rlespinasse/github-slug-action@v4.4.1
- name: Prepare - Set up QEMU
uses: docker/setup-qemu-action@v2.2.0
uses: docker/setup-qemu-action@v3.0.0
- name: Prepare - Set up Docker Buildx
uses: docker/setup-buildx-action@v2.9.1
uses: docker/setup-buildx-action@v3.0.0
- name: Build - BUILD
uses: docker/build-push-action@v4.1.1
uses: docker/build-push-action@v5.0.0
with:
load: true
build-args: "SQUIDEX__BUILD__VERSION=${{ env.GITHUB_REF_SLUG }},SQUIDEX__RUNTIME__VERSION=${{ env.GITHUB_REF_SLUG }}"
@ -104,7 +104,7 @@ jobs:
fi
- name: Publish - Login to Docker Hub
uses: docker/login-action@v2.2.0
uses: docker/login-action@v3.0.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

20
CHANGELOG.md

@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [7.8.2] - 2023-09-19
### Fixed
* **Assets**: Fixed S3 configuration.
* **Contents**: Fixed a query that was causing exceptions when using pagination.
* **UI**: Generate unique IDs for radio groups to fix a problem when multiple groups exist per page.
### Changed
* **UI**: Migration to angular 16.
* **UI**: Better chat dialog.
### Added
* **Contents**: Default values for array fields.
* **Contents**: Default values for components fields.
* **Rules**: Support for deep detect to annotate images with AI models.
* **UI**: Widget plugins for teams and app dashboard.
## [7.8.1] - 2023-08-04
### Fixed

90
backend/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaActionHandler.cs

@ -40,65 +40,65 @@ public sealed class AlgoliaActionHandler : RuleActionHandler<AlgoliaAction, Algo
protected override async Task<(string Description, AlgoliaJob Data)> CreateJobAsync(EnrichedEvent @event, AlgoliaAction action)
{
if (@event is IEnrichedEntityEvent entityEvent)
if (@event is not IEnrichedEntityEvent entityEvent)
{
var delete = @event.ShouldDelete(scriptEngine, action.Delete);
return ("Ignore", new AlgoliaJob());
}
var ruleDescription = string.Empty;
var contentId = entityEvent.Id.ToString();
var content = (AlgoliaContent?)null;
var delete = @event.ShouldDelete(scriptEngine, action.Delete);
if (delete)
{
ruleDescription = $"Delete entry from Algolia index: {action.IndexName}";
}
else
{
ruleDescription = $"Add entry to Algolia index: {action.IndexName}";
var ruleDescription = string.Empty;
var contentId = entityEvent.Id.ToString();
var content = (AlgoliaContent?)null;
try
{
string? jsonString;
if (delete)
{
ruleDescription = $"Delete entry from Algolia index: {action.IndexName}";
}
else
{
ruleDescription = $"Add entry to Algolia index: {action.IndexName}";
if (!string.IsNullOrEmpty(action.Document))
{
jsonString = await FormatAsync(action.Document, @event);
jsonString = jsonString?.Trim();
}
else
{
jsonString = ToJson(@event);
}
try
{
string? jsonString;
content = serializer.Deserialize<AlgoliaContent>(jsonString!);
if (!string.IsNullOrEmpty(action.Document))
{
jsonString = await FormatAsync(action.Document, @event);
jsonString = jsonString?.Trim();
}
catch (Exception ex)
else
{
content = new AlgoliaContent
{
More = new Dictionary<string, object>
{
["error"] = $"Invalid JSON: {ex.Message}"
}
};
jsonString = ToJson(@event);
}
content.ObjectID = contentId;
content = serializer.Deserialize<AlgoliaContent>(jsonString!);
}
var ruleJob = new AlgoliaJob
catch (Exception ex)
{
AppId = action.AppId,
ApiKey = action.ApiKey,
Content = serializer.Serialize(content, true),
ContentId = contentId,
IndexName = (await FormatAsync(action.IndexName, @event))!
};
return (ruleDescription, ruleJob);
content = new AlgoliaContent
{
More = new Dictionary<string, object>
{
["error"] = $"Invalid JSON: {ex.Message}"
}
};
}
content.ObjectID = contentId;
}
return ("Ignore", new AlgoliaJob());
var ruleJob = new AlgoliaJob
{
AppId = action.AppId,
ApiKey = action.ApiKey,
Content = serializer.Serialize(content, true),
ContentId = contentId,
IndexName = (await FormatAsync(action.IndexName, @event))!
};
return (ruleDescription, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(AlgoliaJob job,

67
backend/extensions/Squidex.Extensions/Actions/Comment/CommentActionHandler.cs

@ -7,65 +7,68 @@
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Domain.Apps.Entities.Collaboration;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
namespace Squidex.Extensions.Actions.Comment;
public sealed class CommentActionHandler : RuleActionHandler<CommentAction, CreateComment>
public sealed class CommentActionHandler : RuleActionHandler<CommentAction, CommentCreated>
{
private const string Description = "Send a Comment";
private readonly ICommandBus commandBus;
private readonly ICollaborationService collaboration;
public CommentActionHandler(RuleEventFormatter formatter, ICommandBus commandBus)
public CommentActionHandler(RuleEventFormatter formatter, ICollaborationService collaboration)
: base(formatter)
{
this.commandBus = commandBus;
this.collaboration = collaboration;
}
protected override async Task<(string Description, CreateComment Data)> CreateJobAsync(EnrichedEvent @event, CommentAction action)
protected override async Task<(string Description, CommentCreated Data)> CreateJobAsync(EnrichedEvent @event, CommentAction action)
{
if (@event is EnrichedContentEvent contentEvent)
if (@event is not EnrichedContentEvent contentEvent)
{
var ruleJob = new CreateComment
{
AppId = contentEvent.AppId
};
return ("Ignore", new CommentCreated());
}
var ruleJob = new CommentCreated
{
AppId = contentEvent.AppId
};
ruleJob.Text = (await FormatAsync(action.Text, @event))!;
var text = await FormatAsync(action.Text, @event);
if (!string.IsNullOrEmpty(action.Client))
{
ruleJob.Actor = RefToken.Client(action.Client);
}
else
{
ruleJob.Actor = contentEvent.Actor;
}
if (string.IsNullOrWhiteSpace(text))
{
return ("NoText", new CommentCreated());
}
ruleJob.CommentsId = contentEvent.Id;
ruleJob.Text = text;
return (Description, ruleJob);
if (!string.IsNullOrEmpty(action.Client))
{
ruleJob.Actor = RefToken.Client(action.Client);
}
else
{
ruleJob.Actor = contentEvent.Actor;
}
ruleJob.CommentsId = contentEvent.Id;
return ("Ignore", new CreateComment());
return (Description, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(CreateComment job,
protected override async Task<Result> ExecuteJobAsync(CommentCreated job,
CancellationToken ct = default)
{
var command = job;
if (command.CommentsId == default)
if (job.CommentsId == default)
{
return Result.Ignored();
}
command.FromRule = true;
await commandBus.PublishAsync(command, ct);
await collaboration.CommentAsync(job.AppId, job.CommentsId, job.Text, job.Actor, job.Url, true, ct);
return Result.Success($"Commented: {command.Text}");
return Result.Success($"Commented: {job.Text}");
}
}

3
backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentActionHandler.cs

@ -47,6 +47,7 @@ public sealed class CreateContentActionHandler : RuleActionHandler<CreateContent
}
ruleJob.SchemaId = schema.NamedId();
ruleJob.FromRule = true;
var json = await FormatAsync(action.Data, @event);
@ -74,8 +75,6 @@ public sealed class CreateContentActionHandler : RuleActionHandler<CreateContent
{
var command = job;
command.FromRule = true;
await commandBus.PublishAsync(command, ct);
return Result.Success($"Created to: {command.SchemaId.Name}");

29
backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectAction.cs

@ -0,0 +1,29 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
namespace Squidex.Extensions.Actions.DeepDetect;
[RuleAction(
Title = "DeepDetect",
IconImage = "<svg viewBox='0 0 28 28' xmlns='http://www.w3.org/2000/svg'><g style='stroke-width:1.24962' fill='none'><path fill='#ff5252' d='M13 21.92H0v-8.032h9.386V10.92h3.57v11zm-9.386-4.889v1.702H9.43v-1.702z' style='stroke-width:1.24962' transform='matrix(.78667 0 0 .81405 2.529 2.668)'/><path fill='#fff' d='M29.164 21.92h-13V14.028H25.7V5.92h3.464zm-9.536-4.804v1.673H25.7v-1.673z' style='stroke-width:1.24962' transform='matrix(.78667 0 0 .81405 2.529 2.668)'/></g></svg>",
IconColor = "#526a75",
Display = "Annotate image",
Description = "Annotate an image using deep detect.")]
public sealed record DeepDetectAction : RuleAction
{
[Display(Name = "Min Probability", Description = "The minimum probability for objects to be recognized (0 - 100).")]
[Editor(RuleFieldEditor.Number)]
public long MinimumProbability { get; set; }
[Display(Name = "Max Tags", Description = "The maximum number of tags to use.")]
[Editor(RuleFieldEditor.Number)]
public long MaximumTags { get; set; }
}

197
backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectActionHandler.cs

@ -0,0 +1,197 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Net.Http.Json;
using System.Text.RegularExpressions;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Json;
using Squidex.Text;
namespace Squidex.Extensions.Actions.DeepDetect;
#pragma warning disable MA0048 // File name must match type name
internal partial class DeepDetectActionHandler : RuleActionHandler<DeepDetectAction, DeepDetectJob>
{
private const string Description = "Analyze Image";
private readonly IHttpClientFactory httpClientFactory;
private readonly IJsonSerializer jsonSerializer;
private readonly IAppProvider appProvider;
private readonly IAssetQueryService assetQuery;
private readonly ICommandBus commandBus;
private readonly IUrlGenerator urlGenerator;
public DeepDetectActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory,
IJsonSerializer jsonSerializer,
IAppProvider appProvider,
IAssetQueryService assetQuery,
ICommandBus commandBus,
IUrlGenerator urlGenerator)
: base(formatter)
{
this.httpClientFactory = httpClientFactory;
this.jsonSerializer = jsonSerializer;
this.appProvider = appProvider;
this.assetQuery = assetQuery;
this.commandBus = commandBus;
this.urlGenerator = urlGenerator;
}
protected override Task<(string Description, DeepDetectJob Data)> CreateJobAsync(EnrichedEvent @event, DeepDetectAction action)
{
if (@event is not EnrichedAssetEvent assetEvent)
{
return Task.FromResult(("Ignore", new DeepDetectJob()));
}
if (assetEvent.AssetType != AssetType.Image)
{
return Task.FromResult(("Ignore", new DeepDetectJob()));
}
var ruleJob = new DeepDetectJob
{
Actor = assetEvent.Actor,
AppId = assetEvent.AppId.Id,
AssetId = assetEvent.Id,
MaximumTags = action.MaximumTags,
MinimumPropability = action.MinimumProbability,
Url = urlGenerator.AssetContent(assetEvent.AppId, assetEvent.Id.ToString(), assetEvent.FileVersion)
};
return Task.FromResult((Description, ruleJob));
}
protected override async Task<Result> ExecuteJobAsync(DeepDetectJob job,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(job.Url))
{
return Result.Ignored();
}
var httpClient = httpClientFactory.CreateClient("DeepDetect");
var response = await httpClient.PostAsJsonAsync("predict", new
{
service = "squidexdetector",
output = new
{
best = job.MaximumTags,
confidence_threshold = job.MinimumPropability / 100d,
},
data = new[]
{
job.Url,
}
}, ct);
var body = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
return Result.Failed(new InvalidOperationException($"Failed with status code {response.StatusCode}\n\n{body}"));
}
var responseJson = jsonSerializer.Deserialize<DetectResponse>(body);
var tags = responseJson!.Body.Predictions.SelectMany(x => x.Classes);
if (!tags.Any())
{
return Result.Success(body);
}
var app = await appProvider.GetAppAsync(job.AppId, true, ct);
if (app == null)
{
return Result.Failed(new InvalidOperationException("App not found."));
}
var context = Context.Admin(app);
var asset = await assetQuery.FindAsync(context, job.AssetId, ct: ct);
if (asset == null)
{
return Result.Failed(new InvalidOperationException("Asset not found."));
}
var command = new AnnotateAsset
{
Tags = asset.TagNames,
AssetId = asset.AssetId,
AppId = asset.AppId,
Actor = job.Actor,
FromRule = true
};
foreach (var tag in tags)
{
var tagParts = tag.Cat.Split(',')[0].Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (IdRegex().IsMatch(tagParts[0]))
{
tagParts = tagParts.Skip(1).ToArray();
}
var tagName = string.Join('_', tagParts.Select(x => x.Slugify()));
command.Tags.Add($"ai/{tagName}");
}
await commandBus.PublishAsync(command, ct);
return Result.Success(body);
}
private sealed class DetectResponse
{
public DetectBody Body { get; set; }
}
private sealed class DetectBody
{
public DetectPredications[] Predictions { get; set; }
}
private sealed class DetectPredications
{
public DetectClass[] Classes { get; set; }
}
private sealed class DetectClass
{
public double Prob { get; set; }
public string Cat { get; set; }
}
[GeneratedRegex("^n[0-9]+$")]
private static partial Regex IdRegex();
}
public sealed class DeepDetectJob
{
public DomainId AppId { get; set; }
public DomainId AssetId { get; set; }
public RefToken Actor { get; set; }
public long MaximumTags { get; set; }
public long MinimumPropability { get; set; }
public string? Url { get; set; }
}

32
backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectPlugin.cs

@ -0,0 +1,32 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.DeepDetect;
internal class DeepDetectPlugin : IPlugin
{
public void ConfigureServices(IServiceCollection services, IConfiguration config)
{
var url = config.GetValue<string>("deepdetect:url");
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
return;
}
services.AddHttpClient("DeepDetect", client =>
{
client.BaseAddress = uri;
});
services.AddRuleAction<DeepDetectAction, DeepDetectActionHandler>();
}
}

1
backend/extensions/Squidex.Extensions/Actions/Email/EmailActionHandler.cs

@ -47,7 +47,6 @@ public sealed class EmailActionHandler : RuleActionHandler<EmailAction, EmailJob
using (var smtpClient = new SmtpClient())
{
await smtpClient.ConnectAsync(job.ServerHost, job.ServerPort, cancellationToken: ct);
await smtpClient.AuthenticateAsync(job.ServerUsername, job.ServerPassword, ct);
var smtpMessage = new MimeMessage();

86
backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs

@ -7,83 +7,79 @@
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Domain.Apps.Entities.Collaboration;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Shared.Users;
namespace Squidex.Extensions.Actions.Notification;
public sealed class NotificationActionHandler : RuleActionHandler<NotificationAction, CreateComment>
public sealed class NotificationActionHandler : RuleActionHandler<NotificationAction, CommentCreated>
{
private const string Description = "Send a Notification";
private readonly ICommandBus commandBus;
private readonly ICollaborationService collaboration;
private readonly IUserResolver userResolver;
public NotificationActionHandler(RuleEventFormatter formatter, ICommandBus commandBus, IUserResolver userResolver)
public NotificationActionHandler(RuleEventFormatter formatter, ICollaborationService collaboration, IUserResolver userResolver)
: base(formatter)
{
this.commandBus = commandBus;
this.collaboration = collaboration;
this.userResolver = userResolver;
}
protected override async Task<(string Description, CreateComment Data)> CreateJobAsync(EnrichedEvent @event, NotificationAction action)
protected override async Task<(string Description, CommentCreated Data)> CreateJobAsync(EnrichedEvent @event, NotificationAction action)
{
if (@event is EnrichedUserEventBase userEvent)
if (@event is not EnrichedUserEventBase userEvent)
{
var user = await userResolver.FindByIdOrEmailAsync(action.User);
return ("Ignore", new CommentCreated());
}
if (user == null)
{
throw new InvalidOperationException($"Cannot find user by '{action.User}'");
}
var user = await userResolver.FindByIdOrEmailAsync(action.User);
var actor = userEvent.Actor;
if (user == null)
{
throw new InvalidOperationException($"Cannot find user by '{action.User}'");
}
if (!string.IsNullOrEmpty(action.Client))
{
actor = RefToken.Client(action.Client);
}
var actor = userEvent.Actor;
var ruleJob = new CreateComment
{
AppId = CommentsCommand.NoApp,
Actor = actor,
CommentId = DomainId.NewGuid(),
CommentsId = DomainId.Create(user.Id),
FromRule = true,
Text = (await FormatAsync(action.Text, @event))!
};
if (!string.IsNullOrWhiteSpace(action.Url))
{
var url = await FormatAsync(action.Url, @event);
if (!string.IsNullOrEmpty(action.Client))
{
actor = RefToken.Client(action.Client);
}
if (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri))
{
ruleJob.Url = uri;
}
}
var ruleJob = new CommentCreated
{
Actor = actor,
CommentId = DomainId.NewGuid(),
CommentsId = DomainId.Create(user.Id),
FromRule = true,
Text = (await FormatAsync(action.Text, @event))!
};
if (!string.IsNullOrWhiteSpace(action.Url))
{
var url = await FormatAsync(action.Url, @event);
return (Description, ruleJob);
if (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri))
{
ruleJob.Url = uri;
}
}
return ("Ignore", new CreateComment());
return (Description, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(CreateComment job,
protected override async Task<Result> ExecuteJobAsync(CommentCreated job,
CancellationToken ct = default)
{
var command = job;
if (command.CommentsId == default)
if (job.CommentsId == default)
{
return Result.Ignored();
}
await commandBus.PublishAsync(command, ct);
await collaboration.NotifyAsync(job.CommentsId.ToString(), job.Text, job.Actor, job.Url, true, ct);
return Result.Success($"Notified: {command.Text}");
return Result.Success($"Notified: {job.Text}");
}
}

4
backend/extensions/Squidex.Extensions/Actions/Prerender/PrerenderActionHandler.cs

@ -27,8 +27,8 @@ public sealed class PrerenderActionHandler : RuleActionHandler<PrerenderAction,
{
var url = await FormatAsync(action.Url, @event);
var request = new { prerenderToken = action.Token, url };
var requestBody = ToJson(request);
var requestObject = new { prerenderToken = action.Token, url };
var requestBody = ToJson(requestObject);
return ($"Recache {url}", new PrerenderJob { RequestBody = requestBody });
}

25
backend/i18n/frontend_en.json

@ -166,10 +166,12 @@
"backups.started": "Backup started, it can take several minutes to complete.",
"backups.startedLabel": "Started",
"backups.startFailed": "Failed to start backup.",
"chat.answer": "Here is my answer:",
"chat.answers": "Answers",
"chat.answersEmpty": "The ChatBot does not provide an answer or has not been configured yet.",
"chat.ask": "Ask",
"chat.description": "Use the ChatBot (usually OpenAI) to generate content. Just write a prompt and describe, what you need.\n\n\nAlso add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.",
"chat.describeFormat": "Also add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.",
"chat.description": "Use the ChatBot (usually OpenAI) to generate content. Just write a prompt and describe the content you want to generate.",
"chat.prompt": "Describe the content you want to generate",
"chat.title": "Chat Bot",
"chat.use": "Use",
@ -194,16 +196,6 @@
"clients.connectWizard.cliStep3": "Add your app name the CLI config",
"clients.connectWizard.cliStep3Hint": "You can manage configuration to multiple apps in the CLI and switch to an app.",
"clients.connectWizard.cliStep4": "Switch to your app in the CLI",
"clients.connectWizard.dotnetSdk": "Use the .NET SDK",
"clients.connectWizard.dotnetSdkDocumentation": "Documentations for the .NET SDK is available: ",
"clients.connectWizard.dotnetSdkHint": "Install the .NET SDK and establish a connection to this app.",
"clients.connectWizard.dotnetSdkStep1": "Install the .NET SDK",
"clients.connectWizard.dotnetSdkStep1Download": "The SDK is available on [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary/)",
"clients.connectWizard.dotnetSdkStep2": "Version 14 and before: Create a client manager",
"clients.connectWizard.dotnetSdkStep2_15": "Version 15 and later: Create a client",
"clients.connectWizard.dotnetSdkStep3": "Optionally: Install the Service Extensions for the SDK",
"clients.connectWizard.dotnetSdkStep3Download": "The SDK Extension is available on [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary.ServiceExtensions/)",
"clients.connectWizard.dotnetSdkStep4": "Optionally: Register the client manager and all clients",
"clients.connectWizard.javascriptSdk": "Use the JavaScript SDK",
"clients.connectWizard.javascriptSdkDocumentation": "Documentations for the JavaScript SDK is available: ",
"clients.connectWizard.javascriptSdkHint": "Install the SDK and establish a connection to this app.",
@ -217,8 +209,10 @@
"clients.connectWizard.manuallyStep3": "Add the token as HTTP header to all requests",
"clients.connectWizard.manuallyTokenHint": "Tokens usally expire after 30days, but you can request multiple tokens.",
"clients.connectWizard.postManDocs": "Start with the Postman tutorial in the [Documentation](https://docs.squidex.io/02-documentation/developer-guides/api-overview/postman).",
"clients.connectWizard.sdk": "Use the official SDK",
"clients.connectWizard.sdkHelp": "You need another SDK?",
"clients.connectWizard.sdkHelpLink": "Contact us in the Support Forum",
"clients.connectWizard.sdkHint": "Install the SDK and establish a connection to this app.",
"clients.connectWizard.step0Title": "Setup client",
"clients.connectWizard.step1Title": "Choose connection method",
"clients.connectWizard.step2Title": "Connect",
@ -387,6 +381,7 @@
"common.remember": "Don't ask again",
"common.rename": "Rename",
"common.renameTag": "Rename Tag",
"common.repository": "Repository",
"common.requiredHint": "required",
"common.reset": "Reset",
"common.restore": "Restore",
@ -848,8 +843,8 @@
"schemas.field.hide": "Hide in API",
"schemas.field.hintsHint": "Describe this field for documentation and the UI.",
"schemas.field.inlineEditable": "Inline Editable",
"schemas.field.isEmbeddable": "Is embedding contents and assets",
"schemas.field.isEmbeddableHint": "With this option a custom format is returned in GraphQL, where the linked assets or contents can be fetched.",
"schemas.field.isEmbeddable": "Embed Contents and Assets",
"schemas.field.isEmbeddableHint": "With this option a custom output is used in GraphQL, and embedded assets and contents can be included.",
"schemas.field.labelHint": "Display name for documentation and the UI.",
"schemas.field.localizable": "Localizable",
"schemas.field.localizableHint": "You can mark the field as localizable. It means that is dependent on the language, for example a city name.",
@ -919,11 +914,15 @@
"schemas.fieldTypes.references.countMin": "Min Items",
"schemas.fieldTypes.references.description": "Links to other content items.",
"schemas.fieldTypes.references.mustBePublished": "Only take published references into account when validating.",
"schemas.fieldTypes.references.query": "Initial Query",
"schemas.fieldTypes.references.queryHint": "The initial query that is used in the UI to narrow down the results for the user. In Odata notation.",
"schemas.fieldTypes.references.resolve": "Resolve references",
"schemas.fieldTypes.references.resolveHint": "Show the name of the referenced item in content list when MaxItems is set to 1.",
"schemas.fieldTypes.string.characters": "Characters",
"schemas.fieldTypes.string.charactersMax": "Max Characters",
"schemas.fieldTypes.string.charactersMin": "Min Characters",
"schemas.fieldTypes.string.classNames": "Class Names",
"schemas.fieldTypes.string.classNamesHint": "The allowed CSS classes that the content creator can choose from.",
"schemas.fieldTypes.string.contentType": "Content Type",
"schemas.fieldTypes.string.description": "Titles, names, paragraphs.",
"schemas.fieldTypes.string.folderId": "Asset folder",

33
backend/i18n/frontend_fr.json

@ -166,10 +166,12 @@
"backups.started": "La sauvegarde a commencé, cela peut prendre plusieurs minutes.",
"backups.startedLabel": "Commencé",
"backups.startFailed": "Échec du démarrage de la sauvegarde.",
"chat.answer": "Here is my answer:",
"chat.answers": "Answers",
"chat.answersEmpty": "The ChatBot does not provide an answer or has not been configured yet.",
"chat.ask": "Ask",
"chat.description": "Use the ChatBot (usually OpenAI) to generate content. Just write a prompt and describe, what you need.\n\n\nAlso add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.",
"chat.describeFormat": "Also add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.",
"chat.description": "Use the ChatBot (usually OpenAI) to generate content. Just write a prompt and describe the content you want to generate.",
"chat.prompt": "Describe the content you want to generate",
"chat.title": "Chat Bot",
"chat.use": "Use",
@ -194,22 +196,12 @@
"clients.connectWizard.cliStep3": "Ajoutez le nom de votre application à la configuration CLI",
"clients.connectWizard.cliStep3Hint": "Vous pouvez gérer la configuration de plusieurs applications dans l'interface de ligne de commande et basculer vers une application.",
"clients.connectWizard.cliStep4": "Basculez vers votre application dans la CLI",
"clients.connectWizard.dotnetSdk": "Utiliser le SDK .NET",
"clients.connectWizard.dotnetSdkDocumentation": "Les documentations pour le SDK .NET sont disponibles\u00A0:",
"clients.connectWizard.dotnetSdkHint": "Installez le SDK .NET et établissez une connexion à cette application.",
"clients.connectWizard.dotnetSdkStep1": "Installer le SDK .NET",
"clients.connectWizard.dotnetSdkStep1Download": "Le SDK est disponible sur [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary/)",
"clients.connectWizard.dotnetSdkStep2": "Version 14 et antérieure : Créer un gestionnaire de clientèle",
"clients.connectWizard.dotnetSdkStep2_15": "Version 15 et supérieures : Créer un client",
"clients.connectWizard.dotnetSdkStep3": "Facultatif\u00A0: installez les extensions de service pour le SDK",
"clients.connectWizard.dotnetSdkStep3Download": "L'extension SDK est disponible sur [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary.ServiceExtensions/)",
"clients.connectWizard.dotnetSdkStep4": "Facultativement\u00A0: Enregistrez le gestionnaire de clientèle et tous les clients",
"clients.connectWizard.javascriptSdk": "Utiliser le SDK JavaScript",
"clients.connectWizard.javascriptSdkDocumentation": "Les documentations pour le SDK JavaScript sont disponibles\u00A0:",
"clients.connectWizard.javascriptSdkHint": "Installez le SDK et établissez une connexion à cette application.",
"clients.connectWizard.javascriptSdkStep1": "Installer le SDK Javascript",
"clients.connectWizard.javascriptSdkStep1Download": "Le SDK est disponible sur [npm](https://www.npmjs.com/package/@squidex/squidex",
"clients.connectWizard.javascriptSdkStep2": "Créer un client",
"clients.connectWizard.javascriptSdk": "Use the JavaScript SDK",
"clients.connectWizard.javascriptSdkDocumentation": "Documentations for the JavaScript SDK is available: ",
"clients.connectWizard.javascriptSdkHint": "Install the SDK and establish a connection to this app.",
"clients.connectWizard.javascriptSdkStep1": "Install the Javascript SDK",
"clients.connectWizard.javascriptSdkStep1Download": "The SDK is available on [npm](https://www.npmjs.com/package/@squidex/squidex)",
"clients.connectWizard.javascriptSdkStep2": "Create a client",
"clients.connectWizard.manually": "Connectez-vous manuellement",
"clients.connectWizard.manuallyHint": "Obtenez des instructions pour établir une connexion avec Postman ou curl.",
"clients.connectWizard.manuallyStep1": "Obtenir un jeton en utilisant curl",
@ -217,8 +209,10 @@
"clients.connectWizard.manuallyStep3": "Ajouter le jeton en tant qu'en-tête HTTP à toutes les requêtes",
"clients.connectWizard.manuallyTokenHint": "Les jetons expirent généralement après 30 jours, mais vous pouvez demander plusieurs jetons.",
"clients.connectWizard.postManDocs": "Commencez par le tutoriel Postman dans la [Documentation](https://docs.squidex.io/02-documentation/developer-guides/api-overview/postman).",
"clients.connectWizard.sdk": "Use the official SDK",
"clients.connectWizard.sdkHelp": "Vous avez besoin d'un autre SDK\u00A0?",
"clients.connectWizard.sdkHelpLink": "Contactez-nous sur le forum d'assistance",
"clients.connectWizard.sdkHint": "Install the SDK and establish a connection to this app.",
"clients.connectWizard.step0Title": "Configurer le client",
"clients.connectWizard.step1Title": "Choisissez la méthode de connexion",
"clients.connectWizard.step2Title": "Connecter",
@ -387,6 +381,7 @@
"common.remember": "Ne demande plus",
"common.rename": "Renommer",
"common.renameTag": "Renommer la balise",
"common.repository": "Repository",
"common.requiredHint": "requis",
"common.reset": "Réinitialiser",
"common.restore": "Restaurer",
@ -919,11 +914,15 @@
"schemas.fieldTypes.references.countMin": "Articles minimum",
"schemas.fieldTypes.references.description": "Liens vers d'autres éléments de contenu.",
"schemas.fieldTypes.references.mustBePublished": "Ne tenez compte que des références publiées lors de la validation.",
"schemas.fieldTypes.references.query": "Initial Query",
"schemas.fieldTypes.references.queryHint": "The initial query that is used in the UI to narrow down the results for the user. In Odata notation.",
"schemas.fieldTypes.references.resolve": "Résoudre les références",
"schemas.fieldTypes.references.resolveHint": "Afficher le nom de l'élément référencé dans la liste de contenu lorsque MaxItems est défini sur 1.",
"schemas.fieldTypes.string.characters": "Personnages",
"schemas.fieldTypes.string.charactersMax": "Caractères maximum",
"schemas.fieldTypes.string.charactersMin": "Caractères minimum",
"schemas.fieldTypes.string.classNames": "Class Names",
"schemas.fieldTypes.string.classNamesHint": "The allowed CSS classes that the content creator can choose from.",
"schemas.fieldTypes.string.contentType": "Type de contenu",
"schemas.fieldTypes.string.description": "Titres, noms, paragraphes.",
"schemas.fieldTypes.string.folderId": "Dossier d'actifs",

25
backend/i18n/frontend_it.json

@ -166,10 +166,12 @@
"backups.started": "Backup avviato, il suo completamento potrebbe richiedere alcuni minuti.",
"backups.startedLabel": "Avviato",
"backups.startFailed": "Non è stato possibile avviare il backup.",
"chat.answer": "Here is my answer:",
"chat.answers": "Answers",
"chat.answersEmpty": "The ChatBot does not provide an answer or has not been configured yet.",
"chat.ask": "Ask",
"chat.description": "Use the ChatBot (usually OpenAI) to generate content. Just write a prompt and describe, what you need.\n\n\nAlso add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.",
"chat.describeFormat": "Also add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.",
"chat.description": "Use the ChatBot (usually OpenAI) to generate content. Just write a prompt and describe the content you want to generate.",
"chat.prompt": "Describe the content you want to generate",
"chat.title": "Chat Bot",
"chat.use": "Use",
@ -194,16 +196,6 @@
"clients.connectWizard.cliStep3": "Inserisci il nome della tua app per la configurazione della CLI",
"clients.connectWizard.cliStep3Hint": "È possibile gestire le configurazione per le diverse appi all'interno della CLI e passare ad un'app.",
"clients.connectWizard.cliStep4": "Passa alla tua app usando CLI",
"clients.connectWizard.dotnetSdk": "Connetti la tua APP utilizzando SDK",
"clients.connectWizard.dotnetSdkDocumentation": "Documentations for the .NET SDK is available: ",
"clients.connectWizard.dotnetSdkHint": "Scarica l'SDK e connetti quest'app.",
"clients.connectWizard.dotnetSdkStep1": "Installa .NET SDK",
"clients.connectWizard.dotnetSdkStep1Download": "L'SDK è disponibile su [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary/)",
"clients.connectWizard.dotnetSdkStep2": "Crea un client manager",
"clients.connectWizard.dotnetSdkStep2_15": "Version 15 and later: Create a client",
"clients.connectWizard.dotnetSdkStep3": "Optionally: Install the Service Extensions for the SDK",
"clients.connectWizard.dotnetSdkStep3Download": "The SDK Extension is available on [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary.ServiceExtensions/)",
"clients.connectWizard.dotnetSdkStep4": "Optionally: Register the client manager and all clients",
"clients.connectWizard.javascriptSdk": "Use the JavaScript SDK",
"clients.connectWizard.javascriptSdkDocumentation": "Documentations for the JavaScript SDK is available: ",
"clients.connectWizard.javascriptSdkHint": "Install the SDK and establish a connection to this app.",
@ -217,8 +209,10 @@
"clients.connectWizard.manuallyStep3": "Aggiungi il token come header HTTP header a tutte le richieste",
"clients.connectWizard.manuallyTokenHint": "Solitamente i Token scadono dopo 30 giorni, ma puoi richiedere token multipli.",
"clients.connectWizard.postManDocs": "Per il tutorial Postman inizia da questo link [Documentazione](https://docs.squidex.io/02-documentation/developer-guides/api-overview/postman).",
"clients.connectWizard.sdk": "Use the official SDK",
"clients.connectWizard.sdkHelp": "Hai bisogno di un altro SDK?",
"clients.connectWizard.sdkHelpLink": "Contattaci nel Forum di assistenza",
"clients.connectWizard.sdkHint": "Install the SDK and establish a connection to this app.",
"clients.connectWizard.step0Title": "Setup client",
"clients.connectWizard.step1Title": "Scegli la tipologia di connessione",
"clients.connectWizard.step2Title": "Collega",
@ -387,6 +381,7 @@
"common.remember": "Ricorda la mia decisione",
"common.rename": "Rinomina",
"common.renameTag": "Rename Tag",
"common.repository": "Repository",
"common.requiredHint": "obbligatorio",
"common.reset": "Reimposta",
"common.restore": "Ripristina",
@ -848,8 +843,8 @@
"schemas.field.hide": "Nascondi nelle API",
"schemas.field.hintsHint": "Descrivi questo schema per la documentazione e le interfacce utente.",
"schemas.field.inlineEditable": "Modificabile sulla stessa linea",
"schemas.field.isEmbeddable": "Is embedding contents and assets",
"schemas.field.isEmbeddableHint": "With this option a custom format is returned in GraphQL, where the linked assets or contents can be fetched.",
"schemas.field.isEmbeddable": "Embed Contents and Assets",
"schemas.field.isEmbeddableHint": "With this option a custom output is used in GraphQL, and embedded assets and contents can be included.",
"schemas.field.labelHint": "Nome da visualizzare per la documentazione e le interfacce utente.",
"schemas.field.localizable": "Consente la localizzazione",
"schemas.field.localizableHint": "Puoi impostare il campo per consentire la localizzazione, ossia che dipende dalla lingua che utilizzi come ad esempio i nomi delle città.",
@ -919,11 +914,15 @@
"schemas.fieldTypes.references.countMin": "Numero Min Elementi",
"schemas.fieldTypes.references.description": "Link ad altri elementi del contenuto.",
"schemas.fieldTypes.references.mustBePublished": "I contenuti collegati devono essere pubblicati",
"schemas.fieldTypes.references.query": "Initial Query",
"schemas.fieldTypes.references.queryHint": "The initial query that is used in the UI to narrow down the results for the user. In Odata notation.",
"schemas.fieldTypes.references.resolve": "Resolve references",
"schemas.fieldTypes.references.resolveHint": "Mostra il nome dell'elemento collegato (riferimento) nella lista dei contenuti quando il numero massimo di elementi è impostato a 1.",
"schemas.fieldTypes.string.characters": "Caratteri",
"schemas.fieldTypes.string.charactersMax": "Max numero di Caratteri",
"schemas.fieldTypes.string.charactersMin": "Min numero di Caratteri",
"schemas.fieldTypes.string.classNames": "Class Names",
"schemas.fieldTypes.string.classNamesHint": "The allowed CSS classes that the content creator can choose from.",
"schemas.fieldTypes.string.contentType": "Content Type",
"schemas.fieldTypes.string.description": "Titoli, nomi, paragrafi.",
"schemas.fieldTypes.string.folderId": "Asset folder",

25
backend/i18n/frontend_nl.json

@ -166,10 +166,12 @@
"backups.started": "Back-up gestart, het kan enkele minuten duren om te voltooien.",
"backups.startedLabel": "Gestart",
"backups.startFailed": "Starten van back-up is mislukt.",
"chat.answer": "Here is my answer:",
"chat.answers": "Answers",
"chat.answersEmpty": "The ChatBot does not provide an answer or has not been configured yet.",
"chat.ask": "Ask",
"chat.description": "Use the ChatBot (usually OpenAI) to generate content. Just write a prompt and describe, what you need.\n\n\nAlso add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.",
"chat.describeFormat": "Also add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.",
"chat.description": "Use the ChatBot (usually OpenAI) to generate content. Just write a prompt and describe the content you want to generate.",
"chat.prompt": "Describe the content you want to generate",
"chat.title": "Chat Bot",
"chat.use": "Use",
@ -194,16 +196,6 @@
"clients.connectWizard.cliStep3": "Voeg uw app-naam toe aan de CLI-configuratie",
"clients.connectWizard.cliStep3Hint": "Je kunt de configuratie voor meerdere apps in de CLI beheren en overschakelen naar een app.",
"clients.connectWizard.cliStep4": "Schakel over naar uw app in de CLI",
"clients.connectWizard.dotnetSdk": "Maak verbinding met uw app met SDK",
"clients.connectWizard.dotnetSdkDocumentation": "Documentations for the .NET SDK is available: ",
"clients.connectWizard.dotnetSdkHint": "Download een SDK en maak verbinding met deze app.",
"clients.connectWizard.dotnetSdkStep1": "Installeer de .NET SDK",
"clients.connectWizard.dotnetSdkStep1Download": "De SDK is beschikbaar op [nuget] (https://www.nuget.org/packages/Squidex.ClientLibrary/)",
"clients.connectWizard.dotnetSdkStep2": "Maak een klantenbeheerder",
"clients.connectWizard.dotnetSdkStep2_15": "Version 15 and later: Create a client",
"clients.connectWizard.dotnetSdkStep3": "Optionally: Install the Service Extensions for the SDK",
"clients.connectWizard.dotnetSdkStep3Download": "The SDK Extension is available on [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary.ServiceExtensions/)",
"clients.connectWizard.dotnetSdkStep4": "Optionally: Register the client manager and all clients",
"clients.connectWizard.javascriptSdk": "Use the JavaScript SDK",
"clients.connectWizard.javascriptSdkDocumentation": "Documentations for the JavaScript SDK is available: ",
"clients.connectWizard.javascriptSdkHint": "Install the SDK and establish a connection to this app.",
@ -217,8 +209,10 @@
"clients.connectWizard.manuallyStep3": "Voeg het token toe als HTTP-header aan alle verzoeken",
"clients.connectWizard.manuallyTokenHint": "Tokens vervallen gewoonlijk na 30 dagen, maar je kunt meerdere tokens aanvragen.",
"clients.connectWizard.postManDocs": "Begin met de Postman-tutorial in de [Documentatie] (https://docs.squidex.io/02-documentation/developer-guides/api-overview/postman).",
"clients.connectWizard.sdk": "Use the official SDK",
"clients.connectWizard.sdkHelp": "Heb je een andere SDK nodig?",
"clients.connectWizard.sdkHelpLink": "Neem contact met ons op in het ondersteuningsforum",
"clients.connectWizard.sdkHint": "Install the SDK and establish a connection to this app.",
"clients.connectWizard.step0Title": "Client instellen",
"clients.connectWizard.step1Title": "Kies verbindingsmethode",
"clients.connectWizard.step2Title": "Verbinden",
@ -387,6 +381,7 @@
"common.remember": "Onthoud mijn keuze",
"common.rename": "Hernoemen",
"common.renameTag": "Hernoem Tag",
"common.repository": "Repository",
"common.requiredHint": "verplicht",
"common.reset": "Reset",
"common.restore": "Herstellen",
@ -848,8 +843,8 @@
"schemas.field.hide": "Verbergen in API",
"schemas.field.hintsHint": "Beschrijf dit schema voor documentatie en gebruikersinterfaces.",
"schemas.field.inlineEditable": "Inline bewerkbaar",
"schemas.field.isEmbeddable": "Is embedding contents and assets",
"schemas.field.isEmbeddableHint": "With this option a custom format is returned in GraphQL, where the linked assets or contents can be fetched.",
"schemas.field.isEmbeddable": "Embed Contents and Assets",
"schemas.field.isEmbeddableHint": "With this option a custom output is used in GraphQL, and embedded assets and contents can be included.",
"schemas.field.labelHint": "Weergavenaam voor documentatie en gebruikersinterfaces.",
"schemas.field.localizable": "Localizable",
"schemas.field.localizableHint": "Je kunt het veld markeren als lokaliseerbaar. Dit betekent dat het afhankelijk is van de taal, bijvoorbeeld de naam van een stad.",
@ -919,11 +914,15 @@
"schemas.fieldTypes.references.countMin": "Min. items",
"schemas.fieldTypes.references.description": "Links naar andere inhoudsitems.",
"schemas.fieldTypes.references.mustBePublished": "Referenties moeten worden gepubliceerd",
"schemas.fieldTypes.references.query": "Initial Query",
"schemas.fieldTypes.references.queryHint": "The initial query that is used in the UI to narrow down the results for the user. In Odata notation.",
"schemas.fieldTypes.references.resolve": "Referenties tonen",
"schemas.fieldTypes.references.resolveHint": "Toon de naam van het item waarnaar wordt verwezen in de inhoudslijst wanneer MaxItems is ingesteld op 1.",
"schemas.fieldTypes.string.characters": "Karakters",
"schemas.fieldTypes.string.charactersMax": "Max. karakters",
"schemas.fieldTypes.string.charactersMin": "Min. karakters",
"schemas.fieldTypes.string.classNames": "Class Names",
"schemas.fieldTypes.string.classNamesHint": "The allowed CSS classes that the content creator can choose from.",
"schemas.fieldTypes.string.contentType": "Inhoudstype",
"schemas.fieldTypes.string.description": "Titels, namen, alinea's.",
"schemas.fieldTypes.string.folderId": "Documenten map",

21
backend/i18n/frontend_pt.json

@ -166,10 +166,12 @@
"backups.started": "O reforço começou, pode levar vários minutos para ser concluído.",
"backups.startedLabel": "Começou",
"backups.startFailed": "Falhou em começar o backup.",
"chat.answer": "Here is my answer:",
"chat.answers": "Answers",
"chat.answersEmpty": "The ChatBot does not provide an answer or has not been configured yet.",
"chat.ask": "Ask",
"chat.description": "Use the ChatBot (usually OpenAI) to generate content. Just write a prompt and describe, what you need.\n\n\nAlso add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.",
"chat.describeFormat": "Also add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.",
"chat.description": "Use the ChatBot (usually OpenAI) to generate content. Just write a prompt and describe the content you want to generate.",
"chat.prompt": "Describe the content you want to generate",
"chat.title": "Chat Bot",
"chat.use": "Use",
@ -194,16 +196,6 @@
"clients.connectWizard.cliStep3": "Adicione o nome da sua aplicação ao CLI config",
"clients.connectWizard.cliStep3Hint": "Pode gerir a configuração de várias aplicações no CLI e mudar para uma aplicação.",
"clients.connectWizard.cliStep4": "Mude para a sua aplicação no CLI",
"clients.connectWizard.dotnetSdk": "Conecte-se à sua App com a SDK",
"clients.connectWizard.dotnetSdkDocumentation": "Documentations for the .NET SDK is available: ",
"clients.connectWizard.dotnetSdkHint": "Descarregue um SDK e estabeleça uma ligação a esta aplicação.",
"clients.connectWizard.dotnetSdkStep1": "Instale o .NET SDK",
"clients.connectWizard.dotnetSdkStep1Download": "O SDK está disponível em [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary/)",
"clients.connectWizard.dotnetSdkStep2": "Criar um gestor de clientes",
"clients.connectWizard.dotnetSdkStep2_15": "Version 15 and later: Create a client",
"clients.connectWizard.dotnetSdkStep3": "Optionally: Install the Service Extensions for the SDK",
"clients.connectWizard.dotnetSdkStep3Download": "The SDK Extension is available on [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary.ServiceExtensions/)",
"clients.connectWizard.dotnetSdkStep4": "Optionally: Register the client manager and all clients",
"clients.connectWizard.javascriptSdk": "Use the JavaScript SDK",
"clients.connectWizard.javascriptSdkDocumentation": "Documentations for the JavaScript SDK is available: ",
"clients.connectWizard.javascriptSdkHint": "Install the SDK and establish a connection to this app.",
@ -217,8 +209,10 @@
"clients.connectWizard.manuallyStep3": "Adicione o token como cabeçalho HTTP a todos os pedidos",
"clients.connectWizard.manuallyTokenHint": "Tokens normalmente expiram após 30 dias, mas você pode solicitar várias tokens.",
"clients.connectWizard.postManDocs": "Comece com o tutorial do Carteiro na [Documentação](https://docs.squidex.io/02-documentation/developer-guides/api-overview/postman).",
"clients.connectWizard.sdk": "Use the official SDK",
"clients.connectWizard.sdkHelp": "Precisa de outro SDK?",
"clients.connectWizard.sdkHelpLink": "Contacte-nos no Fórum de Apoio",
"clients.connectWizard.sdkHint": "Install the SDK and establish a connection to this app.",
"clients.connectWizard.step0Title": "Cliente de configuração",
"clients.connectWizard.step1Title": "Escolha o método de ligação",
"clients.connectWizard.step2Title": "Ligar",
@ -387,6 +381,7 @@
"common.remember": "Não pergunte de novo.",
"common.rename": "Renomear",
"common.renameTag": "Renomear Etiqueta",
"common.repository": "Repository",
"common.requiredHint": "Necessário",
"common.reset": "Reset",
"common.restore": "Restaurar",
@ -919,11 +914,15 @@
"schemas.fieldTypes.references.countMin": "Min Itens",
"schemas.fieldTypes.references.description": "Links para outros itens de conteúdo.",
"schemas.fieldTypes.references.mustBePublished": "Só ter em conta as referências publicadas ao validar.",
"schemas.fieldTypes.references.query": "Initial Query",
"schemas.fieldTypes.references.queryHint": "The initial query that is used in the UI to narrow down the results for the user. In Odata notation.",
"schemas.fieldTypes.references.resolve": "Resolver referências",
"schemas.fieldTypes.references.resolveHint": "Mostre o nome do item referenciado na lista de conteúdos quando maxItems estiver definido para 1.",
"schemas.fieldTypes.string.characters": "Personagens",
"schemas.fieldTypes.string.charactersMax": "Personagens Max",
"schemas.fieldTypes.string.charactersMin": "Personagens de Min",
"schemas.fieldTypes.string.classNames": "Class Names",
"schemas.fieldTypes.string.classNamesHint": "The allowed CSS classes that the content creator can choose from.",
"schemas.fieldTypes.string.contentType": "Tipo de Conteúdo",
"schemas.fieldTypes.string.description": "Títulos, nomes, parágrafos.",
"schemas.fieldTypes.string.folderId": "Pasta de ativos",

25
backend/i18n/frontend_zh.json

@ -166,10 +166,12 @@
"backups.started": "备份已开始,可能需要几分钟才能完成。",
"backups.startedLabel": "开始",
"backups.startFailed": "启动备份失败。",
"chat.answer": "Here is my answer:",
"chat.answers": "Answers",
"chat.answersEmpty": "The ChatBot does not provide an answer or has not been configured yet.",
"chat.ask": "Ask",
"chat.description": "Use the ChatBot (usually OpenAI) to generate content. Just write a prompt and describe, what you need.\n\n\nAlso add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.",
"chat.describeFormat": "Also add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.",
"chat.description": "Use the ChatBot (usually OpenAI) to generate content. Just write a prompt and describe the content you want to generate.",
"chat.prompt": "Describe the content you want to generate",
"chat.title": "Chat Bot",
"chat.use": "Use",
@ -194,16 +196,6 @@
"clients.connectWizard.cliStep3": "在 CLI 配置中添加你的应用名称",
"clients.connectWizard.cliStep3Hint": "您可以在 CLI 中管理多个应用程序的配置并切换到一个应用程序。",
"clients.connectWizard.cliStep4": "在 CLI 中切换到您的应用程序",
"clients.connectWizard.dotnetSdk": "使用 SDK 连接到您的应用程序",
"clients.connectWizard.dotnetSdkDocumentation": "Documentations for the .NET SDK is available: ",
"clients.connectWizard.dotnetSdkHint": "下载 SDK 并建立与此应用程序的连接。",
"clients.connectWizard.dotnetSdkStep1": "安装.NET SDK",
"clients.connectWizard.dotnetSdkStep1Download": "SDK 可在 [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary/)",
"clients.connectWizard.dotnetSdkStep2": "创建客户端管理器",
"clients.connectWizard.dotnetSdkStep2_15": "Version 15 and later: Create a client",
"clients.connectWizard.dotnetSdkStep3": "Optionally: Install the Service Extensions for the SDK",
"clients.connectWizard.dotnetSdkStep3Download": "The SDK Extension is available on [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary.ServiceExtensions/)",
"clients.connectWizard.dotnetSdkStep4": "Optionally: Register the client manager and all clients",
"clients.connectWizard.javascriptSdk": "Use the JavaScript SDK",
"clients.connectWizard.javascriptSdkDocumentation": "Documentations for the JavaScript SDK is available: ",
"clients.connectWizard.javascriptSdkHint": "Install the SDK and establish a connection to this app.",
@ -217,8 +209,10 @@
"clients.connectWizard.manuallyStep3": "将令牌作为 HTTP 标头添加到所有请求中",
"clients.connectWizard.manuallyTokenHint": "令牌通常会在 30 天后过期,但您可以请求多个令牌。",
"clients.connectWizard.postManDocs": "从 [文档](https://docs.squidex.io/02-documentation/developer-guides/api-overview/postman) 中的 Postman 教程开始。",
"clients.connectWizard.sdk": "Use the official SDK",
"clients.connectWizard.sdkHelp": "你需要另一个 SDK?",
"clients.connectWizard.sdkHelpLink": "在支持论坛联系我们",
"clients.connectWizard.sdkHint": "Install the SDK and establish a connection to this app.",
"clients.connectWizard.step0Title": "设置客户端",
"clients.connectWizard.step1Title": "选择连接方式",
"clients.connectWizard.step2Title": "连接",
@ -387,6 +381,7 @@
"common.remember": "不要再问了",
"common.rename": "重命名",
"common.renameTag": "Rename Tag",
"common.repository": "Repository",
"common.requiredHint": "必需的",
"common.reset": "重置",
"common.restore": "恢复",
@ -848,8 +843,8 @@
"schemas.field.hide": "隐藏在 API",
"schemas.field.hintsHint": "为文档和 UI 描述这个字段。",
"schemas.field.inlineEditable": "内联可编辑",
"schemas.field.isEmbeddable": "Is embedding contents and assets",
"schemas.field.isEmbeddableHint": "With this option a custom format is returned in GraphQL, where the linked assets or contents can be fetched.",
"schemas.field.isEmbeddable": "Embed Contents and Assets",
"schemas.field.isEmbeddableHint": "With this option a custom output is used in GraphQL, and embedded assets and contents can be included.",
"schemas.field.labelHint": "文档和 UI 的显示名称。",
"schemas.field.localizable": "Localizable",
"schemas.field.localizableHint": "您可以将字段标记为可本地化。这意味着这取决于语言,例如城市名称。",
@ -919,11 +914,15 @@
"schemas.fieldTypes.references.countMin": "最小项目",
"schemas.fieldTypes.references.description": "链接到其他内容项。",
"schemas.fieldTypes.references.mustBePublished": "必须发布参考文献",
"schemas.fieldTypes.references.query": "Initial Query",
"schemas.fieldTypes.references.queryHint": "The initial query that is used in the UI to narrow down the results for the user. In Odata notation.",
"schemas.fieldTypes.references.resolve": "Resolve references",
"schemas.fieldTypes.references.resolveHint": "当 MaxItems 设置为 1 时,在内容列表中显示引用项的名称。",
"schemas.fieldTypes.string.characters": "字符",
"schemas.fieldTypes.string.charactersMax": "最大字符数",
"schemas.fieldTypes.string.charactersMin": "最小字符数",
"schemas.fieldTypes.string.classNames": "Class Names",
"schemas.fieldTypes.string.classNamesHint": "The allowed CSS classes that the content creator can choose from.",
"schemas.fieldTypes.string.contentType": "内容类型",
"schemas.fieldTypes.string.description": "标题、名称、段落。",
"schemas.fieldTypes.string.folderId": "资源文件夹",

25
backend/i18n/source/frontend_en.json

@ -166,10 +166,12 @@
"backups.started": "Backup started, it can take several minutes to complete.",
"backups.startedLabel": "Started",
"backups.startFailed": "Failed to start backup.",
"chat.answer": "Here is my answer:",
"chat.answers": "Answers",
"chat.answersEmpty": "The ChatBot does not provide an answer or has not been configured yet.",
"chat.ask": "Ask",
"chat.description": "Use the ChatBot (usually OpenAI) to generate content. Just write a prompt and describe, what you need.\n\n\nAlso add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.",
"chat.describeFormat": "Also add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.",
"chat.description": "Use the ChatBot (usually OpenAI) to generate content. Just write a prompt and describe the content you want to generate.",
"chat.prompt": "Describe the content you want to generate",
"chat.title": "Chat Bot",
"chat.use": "Use",
@ -194,16 +196,6 @@
"clients.connectWizard.cliStep3": "Add your app name the CLI config",
"clients.connectWizard.cliStep3Hint": "You can manage configuration to multiple apps in the CLI and switch to an app.",
"clients.connectWizard.cliStep4": "Switch to your app in the CLI",
"clients.connectWizard.dotnetSdk": "Use the .NET SDK",
"clients.connectWizard.dotnetSdkDocumentation": "Documentations for the .NET SDK is available: ",
"clients.connectWizard.dotnetSdkHint": "Install the .NET SDK and establish a connection to this app.",
"clients.connectWizard.dotnetSdkStep1": "Install the .NET SDK",
"clients.connectWizard.dotnetSdkStep1Download": "The SDK is available on [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary/)",
"clients.connectWizard.dotnetSdkStep2": "Version 14 and before: Create a client manager",
"clients.connectWizard.dotnetSdkStep2_15": "Version 15 and later: Create a client",
"clients.connectWizard.dotnetSdkStep3": "Optionally: Install the Service Extensions for the SDK",
"clients.connectWizard.dotnetSdkStep3Download": "The SDK Extension is available on [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary.ServiceExtensions/)",
"clients.connectWizard.dotnetSdkStep4": "Optionally: Register the client manager and all clients",
"clients.connectWizard.javascriptSdk": "Use the JavaScript SDK",
"clients.connectWizard.javascriptSdkDocumentation": "Documentations for the JavaScript SDK is available: ",
"clients.connectWizard.javascriptSdkHint": "Install the SDK and establish a connection to this app.",
@ -217,8 +209,10 @@
"clients.connectWizard.manuallyStep3": "Add the token as HTTP header to all requests",
"clients.connectWizard.manuallyTokenHint": "Tokens usally expire after 30days, but you can request multiple tokens.",
"clients.connectWizard.postManDocs": "Start with the Postman tutorial in the [Documentation](https://docs.squidex.io/02-documentation/developer-guides/api-overview/postman).",
"clients.connectWizard.sdk": "Use the official SDK",
"clients.connectWizard.sdkHelp": "You need another SDK?",
"clients.connectWizard.sdkHelpLink": "Contact us in the Support Forum",
"clients.connectWizard.sdkHint": "Install the SDK and establish a connection to this app.",
"clients.connectWizard.step0Title": "Setup client",
"clients.connectWizard.step1Title": "Choose connection method",
"clients.connectWizard.step2Title": "Connect",
@ -387,6 +381,7 @@
"common.remember": "Don't ask again",
"common.rename": "Rename",
"common.renameTag": "Rename Tag",
"common.repository": "Repository",
"common.requiredHint": "required",
"common.reset": "Reset",
"common.restore": "Restore",
@ -848,8 +843,8 @@
"schemas.field.hide": "Hide in API",
"schemas.field.hintsHint": "Describe this field for documentation and the UI.",
"schemas.field.inlineEditable": "Inline Editable",
"schemas.field.isEmbeddable": "Is embedding contents and assets",
"schemas.field.isEmbeddableHint": "With this option a custom format is returned in GraphQL, where the linked assets or contents can be fetched.",
"schemas.field.isEmbeddable": "Embed Contents and Assets",
"schemas.field.isEmbeddableHint": "With this option a custom output is used in GraphQL, and embedded assets and contents can be included.",
"schemas.field.labelHint": "Display name for documentation and the UI.",
"schemas.field.localizable": "Localizable",
"schemas.field.localizableHint": "You can mark the field as localizable. It means that is dependent on the language, for example a city name.",
@ -919,11 +914,15 @@
"schemas.fieldTypes.references.countMin": "Min Items",
"schemas.fieldTypes.references.description": "Links to other content items.",
"schemas.fieldTypes.references.mustBePublished": "Only take published references into account when validating.",
"schemas.fieldTypes.references.query": "Initial Query",
"schemas.fieldTypes.references.queryHint": "The initial query that is used in the UI to narrow down the results for the user. In Odata notation.",
"schemas.fieldTypes.references.resolve": "Resolve references",
"schemas.fieldTypes.references.resolveHint": "Show the name of the referenced item in content list when MaxItems is set to 1.",
"schemas.fieldTypes.string.characters": "Characters",
"schemas.fieldTypes.string.charactersMax": "Max Characters",
"schemas.fieldTypes.string.charactersMin": "Min Characters",
"schemas.fieldTypes.string.classNames": "Class Names",
"schemas.fieldTypes.string.classNamesHint": "The allowed CSS classes that the content creator can choose from.",
"schemas.fieldTypes.string.contentType": "Content Type",
"schemas.fieldTypes.string.description": "Titles, names, paragraphs.",
"schemas.fieldTypes.string.folderId": "Asset folder",

16
backend/i18n/source/frontend_fr.json

@ -183,22 +183,6 @@
"clients.connectWizard.cliStep3": "Ajoutez le nom de votre application à la configuration CLI",
"clients.connectWizard.cliStep3Hint": "Vous pouvez gérer la configuration de plusieurs applications dans l'interface de ligne de commande et basculer vers une application.",
"clients.connectWizard.cliStep4": "Basculez vers votre application dans la CLI",
"clients.connectWizard.dotnetSdk": "Utiliser le SDK .NET",
"clients.connectWizard.dotnetSdkDocumentation": "Les documentations pour le SDK .NET sont disponibles\u00A0:",
"clients.connectWizard.dotnetSdkHint": "Installez le SDK .NET et établissez une connexion à cette application.",
"clients.connectWizard.dotnetSdkStep1": "Installer le SDK .NET",
"clients.connectWizard.dotnetSdkStep1Download": "Le SDK est disponible sur [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary/)",
"clients.connectWizard.dotnetSdkStep2": "Version 14 et antérieure : Créer un gestionnaire de clientèle",
"clients.connectWizard.dotnetSdkStep2_15": "Version 15 et supérieures : Créer un client",
"clients.connectWizard.dotnetSdkStep3": "Facultatif\u00A0: installez les extensions de service pour le SDK",
"clients.connectWizard.dotnetSdkStep3Download": "L'extension SDK est disponible sur [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary.ServiceExtensions/)",
"clients.connectWizard.dotnetSdkStep4": "Facultativement\u00A0: Enregistrez le gestionnaire de clientèle et tous les clients",
"clients.connectWizard.javascriptSdk": "Utiliser le SDK JavaScript",
"clients.connectWizard.javascriptSdkDocumentation": "Les documentations pour le SDK JavaScript sont disponibles\u00A0:",
"clients.connectWizard.javascriptSdkHint": "Installez le SDK et établissez une connexion à cette application.",
"clients.connectWizard.javascriptSdkStep1": "Installer le SDK Javascript",
"clients.connectWizard.javascriptSdkStep1Download": "Le SDK est disponible sur [npm](https://www.npmjs.com/package/@squidex/squidex",
"clients.connectWizard.javascriptSdkStep2": "Créer un client",
"clients.connectWizard.manually": "Connectez-vous manuellement",
"clients.connectWizard.manuallyHint": "Obtenez des instructions pour établir une connexion avec Postman ou curl.",
"clients.connectWizard.manuallyStep1": "Obtenir un jeton en utilisant curl",

5
backend/i18n/source/frontend_it.json

@ -147,11 +147,6 @@
"clients.connectWizard.cliStep3": "Inserisci il nome della tua app per la configurazione della CLI",
"clients.connectWizard.cliStep3Hint": "È possibile gestire le configurazione per le diverse appi all'interno della CLI e passare ad un'app.",
"clients.connectWizard.cliStep4": "Passa alla tua app usando CLI",
"clients.connectWizard.dotnetSdk": "Connetti la tua APP utilizzando SDK",
"clients.connectWizard.dotnetSdkHint": "Scarica l'SDK e connetti quest'app.",
"clients.connectWizard.dotnetSdkStep1": "Installa .NET SDK",
"clients.connectWizard.dotnetSdkStep1Download": "L'SDK è disponibile su [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary/)",
"clients.connectWizard.dotnetSdkStep2": "Crea un client manager",
"clients.connectWizard.manually": "Connetti manualmente",
"clients.connectWizard.manuallyHint": "Leggi le istruzioni su come stabilire una connessione utilizzando Postman o curl.",
"clients.connectWizard.manuallyStep1": "Ottenere un token usando curl",

5
backend/i18n/source/frontend_nl.json

@ -169,11 +169,6 @@
"clients.connectWizard.cliStep3": "Voeg uw app-naam toe aan de CLI-configuratie",
"clients.connectWizard.cliStep3Hint": "Je kunt de configuratie voor meerdere apps in de CLI beheren en overschakelen naar een app.",
"clients.connectWizard.cliStep4": "Schakel over naar uw app in de CLI",
"clients.connectWizard.dotnetSdk": "Maak verbinding met uw app met SDK",
"clients.connectWizard.dotnetSdkHint": "Download een SDK en maak verbinding met deze app.",
"clients.connectWizard.dotnetSdkStep1": "Installeer de .NET SDK",
"clients.connectWizard.dotnetSdkStep1Download": "De SDK is beschikbaar op [nuget] (https://www.nuget.org/packages/Squidex.ClientLibrary/)",
"clients.connectWizard.dotnetSdkStep2": "Maak een klantenbeheerder",
"clients.connectWizard.manually": "Handmatig verbinden",
"clients.connectWizard.manuallyHint": "Krijg instructies om een ​​verbinding tot stand te brengen met Postman of curl.",
"clients.connectWizard.manuallyStep1": "Verkrijg een token met curl",

5
backend/i18n/source/frontend_pt.json

@ -180,11 +180,6 @@
"clients.connectWizard.cliStep3": "Adicione o nome da sua aplicação ao CLI config",
"clients.connectWizard.cliStep3Hint": "Pode gerir a configuração de várias aplicações no CLI e mudar para uma aplicação.",
"clients.connectWizard.cliStep4": "Mude para a sua aplicação no CLI",
"clients.connectWizard.dotnetSdk": "Conecte-se à sua App com a SDK",
"clients.connectWizard.dotnetSdkHint": "Descarregue um SDK e estabeleça uma ligação a esta aplicação.",
"clients.connectWizard.dotnetSdkStep1": "Instale o .NET SDK",
"clients.connectWizard.dotnetSdkStep1Download": "O SDK está disponível em [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary/)",
"clients.connectWizard.dotnetSdkStep2": "Criar um gestor de clientes",
"clients.connectWizard.manually": "Conecte-se manualmente",
"clients.connectWizard.manuallyHint": "Obtenha instruções sobre como estabelecer uma ligação com o Carteiro ou o caracol.",
"clients.connectWizard.manuallyStep1": "Obter um símbolo usando caracóis",

5
backend/i18n/source/frontend_zh.json

@ -157,11 +157,6 @@
"clients.connectWizard.cliStep3": "在 CLI 配置中添加你的应用名称",
"clients.connectWizard.cliStep3Hint": "您可以在 CLI 中管理多个应用程序的配置并切换到一个应用程序。",
"clients.connectWizard.cliStep4": "在 CLI 中切换到您的应用程序",
"clients.connectWizard.dotnetSdk": "使用 SDK 连接到您的应用程序",
"clients.connectWizard.dotnetSdkHint": "下载 SDK 并建立与此应用程序的连接。",
"clients.connectWizard.dotnetSdkStep1": "安装.NET SDK",
"clients.connectWizard.dotnetSdkStep1Download": "SDK 可在 [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary/)",
"clients.connectWizard.dotnetSdkStep2": "创建客户端管理器",
"clients.connectWizard.manually": "手动连接",
"clients.connectWizard.manuallyHint": "获取如何与 Postman 或 curl 建立连接的说明。",
"clients.connectWizard.manuallyStep1": "使用 curl 获取令牌",

10
backend/src/Migrations/MigrationPath.cs

@ -15,7 +15,7 @@ namespace Migrations;
public sealed class MigrationPath : IMigrationPath
{
private const int CurrentVersion = 25;
private const int CurrentVersion = 26;
private readonly IServiceProvider serviceProvider;
public MigrationPath(IServiceProvider serviceProvider)
@ -114,10 +114,16 @@ public sealed class MigrationPath : IMigrationPath
}
}
// Version 13: Json refactoring
// Version 13: Json refactoring.
if (version < 13)
{
yield return serviceProvider.GetRequiredService<ConvertRuleEventsJson>();
}
// Version 27: New rule statistics using normal usage collection.
if (version < 26)
{
yield return serviceProvider.GetRequiredService<CopyRuleStatistics>();
}
}
}

66
backend/src/Migrations/Migrations/MongoDb/CopyRuleStatistics.cs

@ -0,0 +1,66 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Migrations;
using Squidex.Infrastructure.MongoDb;
namespace Migrations.Migrations.MongoDb;
public sealed class CopyRuleStatistics : IMigration
{
private readonly IMongoDatabase database;
private readonly IRuleUsageTracker ruleUsageTracker;
[BsonIgnoreExtraElements]
public class Document
{
public DomainId AppId { get; private set; }
public DomainId RuleId { get; private set; }
public int NumFailed { get; private set; }
public int NumSucceeded { get; private set; }
}
public CopyRuleStatistics(IMongoDatabase database, IRuleUsageTracker ruleUsageTracker)
{
this.database = database;
this.ruleUsageTracker = ruleUsageTracker;
}
public async Task UpdateAsync(
CancellationToken ct)
{
var collectionName = "RuleStatistics";
// Do not create the collection if not needed.
if (!await database.CollectionExistsAsync(collectionName, ct))
{
return;
}
var collection = database.GetCollection<Document>(collectionName);
await foreach (var document in collection.Find(new BsonDocument()).ToAsyncEnumerable(ct))
{
await ruleUsageTracker.TrackAsync(
document.AppId,
document.RuleId,
default,
0,
document.NumSucceeded,
document.NumFailed,
ct);
}
}
}

4
backend/src/Squidex.Domain.Apps.Events/Comments/CommentDeleted.cs → backend/src/Migrations/OldEvents/CommentDeleted.cs

@ -7,9 +7,9 @@
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Comments;
namespace Migrations.OldEvents;
[EventType(nameof(CommentDeleted))]
public sealed class CommentDeleted : CommentsEvent
public sealed class CommentDeleted : IEvent
{
}

4
backend/src/Squidex.Domain.Apps.Events/Comments/CommentUpdated.cs → backend/src/Migrations/OldEvents/CommentUpdated.cs

@ -7,10 +7,10 @@
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Comments;
namespace Migrations.OldEvents;
[EventType(nameof(CommentUpdated))]
public sealed class CommentUpdated : CommentsEvent
public sealed class CommentUpdated : IEvent
{
public string Text { get; set; }
}

25
backend/src/Migrations/RebuilderExtensions.cs

@ -11,6 +11,7 @@ using Squidex.Domain.Apps.Entities.Contents.DomainObject;
using Squidex.Domain.Apps.Entities.Rules.DomainObject;
using Squidex.Domain.Apps.Entities.Schemas.DomainObject;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.EventSourcing;
namespace Migrations;
@ -21,36 +22,48 @@ public static class RebuilderExtensions
public static Task RebuildAppsAsync(this Rebuilder rebuilder, int batchSize,
CancellationToken ct = default)
{
return rebuilder.RebuildAsync<AppDomainObject, AppDomainObject.State>("^app\\-", batchSize, AllowedErrorRate, ct);
var streamFilter = StreamFilter.Prefix("app-");
return rebuilder.RebuildAsync<AppDomainObject, AppDomainObject.State>(streamFilter, batchSize, AllowedErrorRate, ct);
}
public static Task RebuildSchemasAsync(this Rebuilder rebuilder, int batchSize,
CancellationToken ct = default)
{
return rebuilder.RebuildAsync<SchemaDomainObject, SchemaDomainObject.State>("^schema\\-", batchSize, AllowedErrorRate, ct);
var streamFilter = StreamFilter.Prefix("schema-");
return rebuilder.RebuildAsync<SchemaDomainObject, SchemaDomainObject.State>(streamFilter, batchSize, AllowedErrorRate, ct);
}
public static Task RebuildRulesAsync(this Rebuilder rebuilder, int batchSize,
CancellationToken ct = default)
{
return rebuilder.RebuildAsync<RuleDomainObject, RuleDomainObject.State>("^rule\\-", batchSize, AllowedErrorRate, ct);
var streamFilter = StreamFilter.Prefix("rule-");
return rebuilder.RebuildAsync<RuleDomainObject, RuleDomainObject.State>(streamFilter, batchSize, AllowedErrorRate, ct);
}
public static Task RebuildAssetsAsync(this Rebuilder rebuilder, int batchSize,
CancellationToken ct = default)
{
return rebuilder.RebuildAsync<AssetDomainObject, AssetDomainObject.State>("^asset\\-", batchSize, AllowedErrorRate, ct);
var streamFilter = StreamFilter.Prefix("asset-");
return rebuilder.RebuildAsync<AssetDomainObject, AssetDomainObject.State>(streamFilter, batchSize, AllowedErrorRate, ct);
}
public static Task RebuildAssetFoldersAsync(this Rebuilder rebuilder, int batchSize,
CancellationToken ct = default)
{
return rebuilder.RebuildAsync<AssetFolderDomainObject, AssetFolderDomainObject.State>("^assetFolder\\-", batchSize, AllowedErrorRate, ct);
var streamFilter = StreamFilter.Prefix("assetFolder-");
return rebuilder.RebuildAsync<AssetFolderDomainObject, AssetFolderDomainObject.State>(streamFilter, batchSize, AllowedErrorRate, ct);
}
public static Task RebuildContentAsync(this Rebuilder rebuilder, int batchSize,
CancellationToken ct = default)
{
return rebuilder.RebuildAsync<ContentDomainObject, ContentDomainObject.State>("^content\\-", batchSize, AllowedErrorRate, ct);
var streamFilter = StreamFilter.Prefix("content-");
return rebuilder.RebuildAsync<ContentDomainObject, ContentDomainObject.State>(streamFilter, batchSize, AllowedErrorRate, ct);
}
}

2
backend/src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs

@ -12,7 +12,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.Comments;
public sealed record Comment(DomainId Id, Instant Time, RefToken User, string Text, Uri? Url = null)
public sealed record Comment(Instant Time, RefToken User, string Text, Uri? Url = null, bool SkipHandlers = false)
{
public RefToken User { get; } = Guard.NotNull(User);

9
backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs

@ -762,6 +762,15 @@ namespace Squidex.Domain.Apps.Core {
}
}
/// <summary>
/// Looks up a localized string similar to The graphql request..
/// </summary>
public static string GraphqlRequest {
get {
return ResourceManager.GetString("GraphqlRequest", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The current item, if the field is part of an array..
/// </summary>

3
backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx

@ -351,6 +351,9 @@
<data name="EventType" xml:space="preserve">
<value>The type of the event.</value>
</data>
<data name="GraphqlRequest" xml:space="preserve">
<value>The graphql request.</value>
</data>
<data name="ItemData" xml:space="preserve">
<value>The current item, if the field is part of an array.</value>
</data>

6
backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/DeleteComment.cs → backend/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayCalculatedDefaultValue.cs

@ -5,8 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Comments.Commands;
namespace Squidex.Domain.Apps.Core.Schemas;
public sealed class DeleteComment : CommentCommand
public enum ArrayCalculatedDefaultValue
{
EmptyArray,
Null
}

2
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayFieldProperties.cs

@ -15,6 +15,8 @@ public sealed record ArrayFieldProperties : FieldProperties
public int? MaxItems { get; init; }
public ArrayCalculatedDefaultValue CalculatedDefaultValue { get; init; }
public ReadonlyList<string>? UniqueFields { get; init; }
public override T Accept<T, TArgs>(IFieldPropertiesVisitor<T, TArgs> visitor, TArgs args)

2
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/ComponentsFieldProperties.cs

@ -18,6 +18,8 @@ public sealed record ComponentsFieldProperties : FieldProperties
public ReadonlyList<string>? UniqueFields { get; init; }
public ArrayCalculatedDefaultValue CalculatedDefaultValue { get; init; }
public DomainId SchemaId
{
init

2
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs

@ -26,6 +26,8 @@ public sealed record ReferencesFieldProperties : FieldProperties
public bool MustBePublished { get; init; }
public string? Query { get; init; }
public ReferencesFieldEditor Editor { get; init; }
public DomainId SchemaId

2
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs

@ -14,6 +14,8 @@ public sealed record StringFieldProperties : FieldProperties
{
public ReadonlyList<string>? AllowedValues { get; init; }
public ReadonlyList<string>? ClassNames { get; set; }
public LocalizedValue<string?> DefaultValues { get; init; }
public string? DefaultValue { get; init; }

20
backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/DefaultValueFactory.cs

@ -33,6 +33,21 @@ public sealed class DefaultValueFactory : IFieldPropertiesVisitor<JsonValue, Def
public JsonValue Visit(ArrayFieldProperties properties, Args args)
{
if (properties.CalculatedDefaultValue == ArrayCalculatedDefaultValue.Null)
{
return JsonValue.Null;
}
return new JsonArray();
}
public JsonValue Visit(ComponentsFieldProperties properties, Args args)
{
if (properties.CalculatedDefaultValue == ArrayCalculatedDefaultValue.Null)
{
return JsonValue.Null;
}
return new JsonArray();
}
@ -55,11 +70,6 @@ public sealed class DefaultValueFactory : IFieldPropertiesVisitor<JsonValue, Def
return JsonValue.Null;
}
public JsonValue Visit(ComponentsFieldProperties properties, Args args)
{
return new JsonArray();
}
public JsonValue Visit(GeolocationFieldProperties properties, Args args)
{
return JsonValue.Null;

2
backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs

@ -22,6 +22,8 @@ public interface IUrlGenerator
string AssetContent(NamedId<DomainId> appId, string idOrSlug);
string AssetContent(NamedId<DomainId> appId, string idOrSlug, long version);
string AssetContentBase();
string AssetContentBase(string appName);

2
backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj

@ -28,7 +28,7 @@
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
<PackageReference Include="NJsonSchema" Version="10.9.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="5.13.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="5.19.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="7.0.0" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />

20
backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/SubscriptionPublisher.cs

@ -16,25 +16,13 @@ public sealed class SubscriptionPublisher : IEventConsumer
private readonly ISubscriptionService subscriptionService;
private readonly IEnumerable<ISubscriptionEventCreator> subscriptionEventCreators;
public string Name
{
get => "Subscriptions";
}
public string Name => "Subscriptions";
public string EventsFilter
{
get => "^(content-|asset-)";
}
public StreamFilter EventsFilter { get; } = StreamFilter.Prefix("content-", "asset-");
public bool StartLatest
{
get => true;
}
public bool StartLatest => true;
public bool CanClear
{
get => false;
}
public bool CanClear => false;
public SubscriptionPublisher(ISubscriptionService subscriptionService, IEnumerable<ISubscriptionEventCreator> subscriptionEventCreators)
{

27
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Extensions.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
@ -18,6 +19,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations;
public static class Extensions
{
private static readonly BsonDocument LookupLet =
new BsonDocument()
.Add("id", "$_id");
private static readonly BsonDocument LookupMatch =
new BsonDocument()
.Add("$expr", new BsonDocument()
.Add("$eq", new BsonArray { "$_id", "$$id" }));
private static Dictionary<string, string> propertyMap;
public static IReadOnlyDictionary<string, string> PropertyMap
@ -113,8 +123,15 @@ public static class Extensions
.QuerySort(query)
.QuerySkip(query)
.QueryLimit(query)
.Lookup<IdOnly, MongoContentEntity, IdOnly>(collection, x => x.Id, x => x.DocumentId, x => x.Joined)
.SelectFields(q.Fields)
.Lookup<IdOnly, MongoContentEntity, MongoContentEntity, MongoContentEntity[], IdOnly>(collection,
LookupLet,
PipelineDefinitionBuilder.For<MongoContentEntity>()
.Match(LookupMatch)
.Project(
BuildProjection2<MongoContentEntity>(q.Fields)),
x => x.Joined)
.Project<IdOnly>(
Builders<IdOnly>.Projection.Include(x => x.Joined))
.ToListAsync(ct);
return joined.Select(x => x.Joined[0]).ToList();
@ -147,15 +164,15 @@ public static class Extensions
public static IFindFluent<T, T> SelectFields<T>(this IFindFluent<T, T> find, IEnumerable<string>? fields)
{
return find.Project<T>(BuildProjection<T>(fields));
return find.Project<T>(BuildProjection2<T>(fields));
}
public static IAggregateFluent<T> SelectFields<T>(this IAggregateFluent<T> find, IEnumerable<string>? fields)
{
return find.Project<T>(BuildProjection<T>(fields));
return find.Project<T>(BuildProjection2<T>(fields));
}
private static ProjectionDefinition<T> BuildProjection<T>(IEnumerable<string>? fields)
private static ProjectionDefinition<T, T> BuildProjection2<T>(IEnumerable<string>? fields)
{
var projector = Builders<T>.Projection;
var projections = new List<ProjectionDefinition<T>>();

20
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemasHash.cs

@ -19,25 +19,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Schemas;
public sealed class MongoSchemasHash : MongoRepositoryBase<MongoSchemasHashEntity>, ISchemasHash, IEventConsumer, IDeleter
{
public int BatchSize
{
get => 1000;
}
public int BatchSize => 1000;
public int BatchDelay
{
get => 500;
}
public int BatchDelay => 500;
public string Name
{
get => GetType().Name;
}
public string EventsFilter
{
get => "^schema-";
}
public StreamFilter EventsFilter { get; } = StreamFilter.Prefix("schema-");
public MongoSchemasHash(IMongoDatabase database)
: base(database)

2
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj

@ -23,7 +23,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MongoDB.Driver" Version="2.20.0" />
<PackageReference Include="MongoDB.Driver" Version="2.22.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

4
backend/src/Squidex.Domain.Apps.Entities/Apps/AppEventDeleter.cs

@ -23,6 +23,8 @@ public sealed class AppEventDeleter : IDeleter
public Task DeleteAppAsync(IAppEntity app,
CancellationToken ct)
{
return eventStore.DeleteAsync($"^([a-zA-Z0-9]+)\\-{app.Id}", ct);
var streamFilter = StreamFilter.Prefix($"([a-zA-Z0-9]+)-{app.Id}");
return eventStore.DeleteAsync(streamFilter, ct);
}
}

10
backend/src/Squidex.Domain.Apps.Entities/Apps/AppPermanentDeleter.cs

@ -20,15 +20,7 @@ public sealed class AppPermanentDeleter : IEventConsumer
private readonly IDomainObjectFactory factory;
private readonly HashSet<string> consumingTypes;
public string Name
{
get => GetType().Name;
}
public string EventsFilter
{
get => "^app-";
}
public StreamFilter EventsFilter { get; } = StreamFilter.Prefix("app-");
public AppPermanentDeleter(IEnumerable<IDeleter> deleters, IDomainObjectFactory factory, TypeRegistry typeRegistry)
{

10
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetPermanentDeleter.cs

@ -17,15 +17,7 @@ public sealed class AssetPermanentDeleter : IEventConsumer
private readonly IAssetFileStore assetFileStore;
private readonly HashSet<string> consumingTypes;
public string Name
{
get => GetType().Name;
}
public string EventsFilter
{
get => "^asset-";
}
public StreamFilter EventsFilter { get; } = StreamFilter.Prefix("asset-");
public AssetPermanentDeleter(IAssetFileStore assetFileStore, TypeRegistry typeRegistry)
{

20
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs

@ -21,25 +21,11 @@ public partial class AssetUsageTracker : IEventConsumer
{
private IMemoryCache memoryCache;
public int BatchSize
{
get => 1000;
}
public int BatchSize => 1000;
public int BatchDelay
{
get => 1000;
}
public int BatchDelay => 1000;
public string Name
{
get => GetType().Name;
}
public string EventsFilter
{
get => "^asset-";
}
public StreamFilter EventsFilter { get; } = StreamFilter.Prefix("asset-");
private void ClearCache()
{

20
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs

@ -39,19 +39,21 @@ public sealed class AssetsFluidExtension : IFluidExtension
private async ValueTask<Completion> ResolveAsset(ValueTuple<Expression, Expression> arguments, TextWriter writer, TextEncoder encoder, TemplateContext context)
{
if (context.GetValue("event")?.ToObjectValue() is EnrichedEvent enrichedEvent)
if (context.GetValue("event")?.ToObjectValue() is not EnrichedEvent enrichedEvent)
{
var (nameArg, idArg) = arguments;
return Completion.Normal;
}
var assetId = await idArg.EvaluateAsync(context);
var asset = await ResolveAssetAsync(serviceProvider, enrichedEvent.AppId.Id, assetId);
var (nameArg, idArg) = arguments;
if (asset != null)
{
var name = (await nameArg.EvaluateAsync(context)).ToStringValue();
var assetId = await idArg.EvaluateAsync(context);
var asset = await ResolveAssetAsync(serviceProvider, enrichedEvent.AppId.Id, assetId);
context.SetValue(name, asset);
}
if (asset != null)
{
var name = (await nameArg.EvaluateAsync(context)).ToStringValue();
context.SetValue(name, asset);
}
return Completion.Normal;

4
backend/src/Squidex.Domain.Apps.Entities/Assets/RebuildFiles.cs

@ -33,7 +33,9 @@ public sealed class RebuildFiles
public async Task RepairAsync(
CancellationToken ct = default)
{
await foreach (var storedEvent in eventStore.QueryAllAsync("^asset\\-", ct: ct))
var streamFilter = StreamFilter.Prefix("asset-");
await foreach (var storedEvent in eventStore.QueryAllAsync(streamFilter, ct: ct))
{
var @event = eventFormatter.ParseIfKnown(storedEvent);

10
backend/src/Squidex.Domain.Apps.Entities/Assets/RecursiveDeleter.cs

@ -23,15 +23,7 @@ public sealed class RecursiveDeleter : IEventConsumer
private readonly ILogger<RecursiveDeleter> log;
private readonly HashSet<string> consumingTypes;
public string Name
{
get => GetType().Name;
}
public string EventsFilter
{
get => "^assetFolder-";
}
public StreamFilter EventsFilter { get; } = StreamFilter.Prefix("assetFolder-");
public RecursiveDeleter(
ICommandBus commandBus,

10
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using NodaTime;
using Squidex.Domain.Apps.Entities.Backup.State;
@ -147,7 +146,9 @@ public sealed partial class BackupProcessor
var backupUsers = new UserMapping(run.Actor);
var backupContext = new BackupContext(appId, backupUsers, writer);
await foreach (var storedEvent in eventStore.QueryAllAsync(GetFilter(), ct: ct))
var streamFilter = StreamFilter.Prefix($"[^\\-]*-{appId}");
await foreach (var storedEvent in eventStore.QueryAllAsync(streamFilter, ct: ct))
{
var @event = eventFormatter.Parse(storedEvent);
@ -200,11 +201,6 @@ public sealed partial class BackupProcessor
}
}
private string GetFilter()
{
return $"^[^\\-]*-{Regex.Escape(appId.ToString())}";
}
public Task DeleteAsync(DomainId id)
{
return scheduler.ScheduleAsync(async _ =>

12
backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.Rules.cs

@ -103,18 +103,26 @@ public sealed partial class UsageGate : IRuleUsageTracker
var tasks = new List<Task>
{
usageTracker.TrackAsync(date, appKey, ruleId.ToString(), counters, ct),
usageTracker.TrackAsync(SummaryDate, appKey, ruleId.ToString(), counters, ct)
};
if (date != default)
{
tasks.Add(usageTracker.TrackAsync(date, appKey, ruleId.ToString(), counters, ct));
}
var (_, _, teamId) = await GetPlanForAppAsync(appId, true, ct);
if (teamId != null)
{
var teamKey = TeamRulesKey(teamId.Value);
tasks.Add(usageTracker.TrackAsync(date, teamKey, appId.ToString(), counters, ct));
tasks.Add(usageTracker.TrackAsync(SummaryDate, teamKey, appId.ToString(), counters, ct));
if (date != default)
{
tasks.Add(usageTracker.TrackAsync(date, teamKey, appId.ToString(), counters, ct));
}
}
await Task.WhenAll(tasks);

2
backend/src/Squidex.Domain.Apps.Entities/Billing/UsageNotifierWorker.cs

@ -6,7 +6,7 @@
// ==========================================================================
using NodaTime;
using Squidex.Domain.Apps.Entities.Notifications;
using Squidex.Domain.Apps.Entities.Collaboration;
using Squidex.Hosting;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;

266
backend/src/Squidex.Domain.Apps.Entities/Collaboration/CommentCollaborationHandler.cs

@ -0,0 +1,266 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using NodaTime;
using Squidex.Domain.Apps.Core.Comments;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Reflection;
using Squidex.Shared.Users;
using YDotNet.Document.Cells;
using YDotNet.Document.Types.Events;
using YDotNet.Extensions;
using YDotNet.Server;
namespace Squidex.Domain.Apps.Entities.Collaboration;
public sealed partial class CommentCollaborationHandler : IDocumentCallback, ICollaborationService
{
private static readonly Regex MentionRegex = BuildMentionRegex();
private readonly IJsonSerializer jsonSerializer;
private readonly IEventStore eventStore;
private readonly IEventFormatter eventFormatter;
private readonly IUserResolver userResolver;
private readonly IClock clock;
private readonly ILogger<CommentCollaborationHandler> log;
private IDocumentManager? currentManager;
public Task LastTask { get; private set; }
public CommentCollaborationHandler(
IJsonSerializer jsonSerializer,
IEventStore eventStore,
IEventFormatter eventFormatter,
IUserResolver userResolver,
IClock clock,
ILogger<CommentCollaborationHandler> log)
{
this.jsonSerializer = jsonSerializer;
this.eventStore = eventStore;
this.eventFormatter = eventFormatter;
this.userResolver = userResolver;
this.clock = clock;
this.log = log;
}
public ValueTask OnInitializedAsync(IDocumentManager manager)
{
currentManager = manager;
return default;
}
public Task NotifyAsync(string userId, string text, RefToken actor, Uri? url, bool skipHandlers,
CancellationToken ct = default)
{
return CommentAsync(UserDocument(userId), text, actor, url, skipHandlers, ct);
}
public Task CommentAsync(NamedId<DomainId> appId, DomainId resourceId, string text, RefToken actor, Uri? url, bool skipHandlers,
CancellationToken ct = default)
{
return CommentAsync(ResourceDocument(appId, resourceId), text, actor, url, skipHandlers, ct);
}
private async Task CommentAsync(string documentName, string text, RefToken actor, Uri? url, bool skipHandlers,
CancellationToken ct)
{
if (currentManager == null)
{
return;
}
var notificationsContext = new DocumentContext(documentName, 0);
// Use the update method to ensure that only one thread has access to the doc.
await currentManager.UpdateDocAsync(notificationsContext, doc =>
{
var stream = doc.Array("stream");
using (var transaction = doc.WriteTransaction())
{
var commentValue = new Comment(clock.GetCurrentInstant(), actor, text, url, skipHandlers);
var commentJson = jsonSerializer.Serialize(commentValue);
stream.InsertRange(transaction, stream.Length, InputFactory.FromJson(commentJson));
}
}, ct);
}
public ValueTask OnDocumentLoadedAsync(DocumentLoadEvent @event)
{
if (!IsResourceDocument(@event.Context.DocumentName, out var appId, out var resourceId))
{
return default;
}
var stream = @event.Document.Array("stream");
stream.ObserveDeep(changes =>
{
var newComments =
changes
.Where(x => x.Tag == EventBranchTag.Array)
.Select(x => x.ArrayEvent)
.SelectMany(x => x.Delta).Where(x => x.Tag == EventChangeTag.Add)
.SelectMany(x => x.Values).Where(x => x.Tag == OutputTag.JsonObject)
.ToArray();
if (newComments.Length == 0)
{
// Just store the last task for tests.
LastTask = Task.CompletedTask;
return;
}
LastTask = Task.Run(async () =>
{
try
{
// Run in an extra task to prevent deadlocks with the outer transaction.
await HandleAsync(@event, appId, resourceId, newComments);
}
catch (Exception ex)
{
// We are in an extra task, so the exception would be probably swallowed.
log.LogError(ex, "Failed to handle yjs event.");
throw;
}
});
});
return default;
}
private async Task HandleAsync(DocumentLoadEvent @event, NamedId<DomainId> appId, DomainId resourceId, Output[] newComments)
{
var comments = new List<Comment>();
// Use the update method to ensure that only one thread has access to the doc.
await @event.Source.UpdateDocAsync(@event.Context, (doc) =>
{
using (var transaction = @event.Document.ReadTransaction())
{
foreach (var output in newComments)
{
// Just use the json string for debuggability.
var json = output.ToJson(transaction);
var comment = jsonSerializer.Deserialize<Comment>(json);
if (!comment.SkipHandlers)
{
comments.Add(comment);
}
}
}
});
var streamName = $"comments-{DomainId.Combine(appId, resourceId)}";
foreach (var comment in comments)
{
var commentEvent = await CreateEventAsync(comment, appId, resourceId);
var eventBody = Envelope.Create<IEvent>(commentEvent);
var eventData = eventFormatter.ToEventData(eventBody, Guid.NewGuid());
await eventStore.AppendAsync(Guid.NewGuid(), streamName, EtagVersion.Any, new List<EventData> { eventData });
foreach (var mentionedUser in commentEvent.Mentions.OrEmpty())
{
await NotifyAsync(mentionedUser, comment.Text, RefToken.User(mentionedUser), comment.Url, true);
}
}
}
private async Task<CommentCreated> CreateEventAsync(Comment comment, NamedId<DomainId> appId, DomainId commentsId)
{
var @event = new CommentCreated
{
Actor = comment.User,
CommentId = DomainId.NewGuid(),
CommentsId = commentsId,
AppId = appId,
};
SimpleMapper.Map(comment, @event);
await MentionUsersAsync(@event);
return @event;
}
private async Task MentionUsersAsync(CommentCreated comment)
{
if (string.IsNullOrWhiteSpace(comment.Text))
{
return;
}
var emails = MentionRegex.Matches(comment.Text).Select(x => x.Value[1..]).ToArray();
if (emails.Length == 0)
{
return;
}
var mentions = new List<string>();
foreach (var email in emails)
{
var user = await userResolver.FindByIdOrEmailAsync(email);
if (user != null)
{
mentions.Add(user.Id);
}
}
if (mentions.Count > 0)
{
comment.Mentions = mentions.ToArray();
}
}
public string UserDocument(string userId)
{
return $"users/{userId}";
}
public string ResourceDocument(NamedId<DomainId> appId, DomainId resourceId)
{
return $"apps/{appId}/{resourceId}";
}
private static bool IsResourceDocument(string name, out NamedId<DomainId> appId, out DomainId resourceId)
{
resourceId = default;
if (!name.StartsWith("apps", StringComparison.Ordinal))
{
appId = default!;
return false;
}
var parts = name.Split('/');
if (parts.Length < 3 || !NamedId<DomainId>.TryParse(parts[1], DomainId.TryParse, out appId!))
{
appId = default!;
return false;
}
resourceId = DomainId.Create(string.Join('/', parts.Skip(2)));
return true;
}
[GeneratedRegex(@"@(?=.{1,64}@)[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*", RegexOptions.Compiled | RegexOptions.ExplicitCapture, matchTimeoutMilliseconds: 100)]
private static partial Regex BuildMentionRegex();
}

3
backend/src/Squidex.Domain.Apps.Entities/Comments/CommentTriggerHandler.cs → backend/src/Squidex.Domain.Apps.Entities/Collaboration/CommentTriggerHandler.cs

@ -17,7 +17,7 @@ using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Comments;
namespace Squidex.Domain.Apps.Entities.Collaboration;
public sealed class CommentTriggerHandler : IRuleTriggerHandler
{
@ -29,7 +29,6 @@ public sealed class CommentTriggerHandler : IRuleTriggerHandler
public CommentTriggerHandler(IScriptEngine scriptEngine, IUserResolver userResolver)
{
this.scriptEngine = scriptEngine;
this.userResolver = userResolver;
}

2
backend/src/Squidex.Domain.Apps.Entities/Notifications/EmailUserNotificationOptions.cs → backend/src/Squidex.Domain.Apps.Entities/Collaboration/EmailUserNotificationOptions.cs

@ -5,7 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Notifications;
namespace Squidex.Domain.Apps.Entities.Collaboration;
public sealed class EmailUserNotificationOptions
{

2
backend/src/Squidex.Domain.Apps.Entities/Notifications/EmailUserNotifications.cs → backend/src/Squidex.Domain.Apps.Entities/Collaboration/EmailUserNotifications.cs

@ -16,7 +16,7 @@ using Squidex.Infrastructure.Email;
using Squidex.Shared.Identity;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Notifications;
namespace Squidex.Domain.Apps.Entities.Collaboration;
public sealed class EmailUserNotifications : IUserNotifications
{

23
backend/src/Squidex.Domain.Apps.Entities/Collaboration/ICollaborationService.cs

@ -0,0 +1,23 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Collaboration;
public interface ICollaborationService
{
Task NotifyAsync(string userId, string text, RefToken actor, Uri? url, bool skipHandlers,
CancellationToken ct = default);
Task CommentAsync(NamedId<DomainId> appId, DomainId resourceId, string text, RefToken actor, Uri? url, bool skipHandlers,
CancellationToken ct = default);
string UserDocument(string userId);
string ResourceDocument(NamedId<DomainId> appId, DomainId resourceId);
}

2
backend/src/Squidex.Domain.Apps.Entities/Notifications/IUserNotifications.cs → backend/src/Squidex.Domain.Apps.Entities/Collaboration/IUserNotifications.cs

@ -9,7 +9,7 @@ using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Notifications;
namespace Squidex.Domain.Apps.Entities.Collaboration;
public interface IUserNotifications
{

2
backend/src/Squidex.Domain.Apps.Entities/Notifications/NoopUserNotifications.cs → backend/src/Squidex.Domain.Apps.Entities/Collaboration/NoopUserNotifications.cs

@ -9,7 +9,7 @@ using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Notifications;
namespace Squidex.Domain.Apps.Entities.Collaboration;
public sealed class NoopUserNotifications : IUserNotifications
{

15
backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentTextCommand.cs

@ -1,15 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Comments.Commands;
public abstract class CommentTextCommand : CommentCommand
{
public string Text { get; set; }
public string[]? Mentions { get; set; }
}

22
backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CreateComment.cs

@ -1,22 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Comments.Commands;
public sealed class CreateComment : CommentTextCommand
{
public bool IsMention { get; set; }
public Uri? Url { get; set; }
public CreateComment()
{
CommentId = DomainId.NewGuid();
}
}

48
backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/_CommentsCommand.cs

@ -1,48 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Domain.Apps.Entities.Comments.Commands;
public abstract class CommentCommand : CommentsCommand
{
public DomainId CommentId { get; set; }
}
public abstract class CommentsCommand : CommentsCommandBase
{
public static readonly NamedId<DomainId> NoApp = NamedId.Of(DomainId.Empty, "none");
public DomainId CommentsId { get; set; }
public override DomainId AggregateId
{
get
{
if (AppId.Id == default)
{
return CommentsId;
}
else
{
return DomainId.Combine(AppId, CommentsId);
}
}
}
}
// This command is needed as marker for middlewares.
public abstract class CommentsCommandBase : SquidexCommand, IAppCommand, IAggregateCommand
{
public NamedId<DomainId> AppId { get; set; }
public abstract DomainId AggregateId { get; }
}

32
backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsLoader.cs

@ -1,32 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Comments.DomainObject;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Comments;
public sealed class CommentsLoader : ICommentsLoader
{
private readonly IDomainObjectFactory domainObjectFactory;
public CommentsLoader(IDomainObjectFactory domainObjectFactory)
{
this.domainObjectFactory = domainObjectFactory;
}
public async Task<CommentsResult> GetCommentsAsync(DomainId id, long version = EtagVersion.Any,
CancellationToken ct = default)
{
var stream = domainObjectFactory.Create<CommentsStream>(id);
await stream.LoadAsync(ct);
return stream.GetComments(version);
}
}

95
backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsResult.cs

@ -1,95 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Comments;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Entities.Comments;
public sealed class CommentsResult
{
public List<Comment> CreatedComments { get; set; } = new List<Comment>();
public List<Comment> UpdatedComments { get; set; } = new List<Comment>();
public List<DomainId> DeletedComments { get; set; } = new List<DomainId>();
public long Version { get; set; }
public static CommentsResult FromEvents(IEnumerable<Envelope<CommentsEvent>> events, long currentVersion, int lastVersion)
{
var result = new CommentsResult { Version = currentVersion };
foreach (var @event in events.Skip(lastVersion < 0 ? 0 : lastVersion + 1))
{
switch (@event.Payload)
{
case CommentDeleted deleted:
{
var id = deleted.CommentId;
if (result.CreatedComments.Exists(x => x.Id == id))
{
result.CreatedComments.RemoveAll(x => x.Id == id);
}
else if (result.UpdatedComments.Exists(x => x.Id == id))
{
result.UpdatedComments.RemoveAll(x => x.Id == id);
result.DeletedComments.Add(id);
}
else
{
result.DeletedComments.Add(id);
}
break;
}
case CommentCreated created:
{
var comment = new Comment(
created.CommentId,
@event.Headers.Timestamp(),
@event.Payload.Actor,
created.Text,
created.Url);
result.CreatedComments.Add(comment);
break;
}
case CommentUpdated updated:
{
var id = updated.CommentId;
var comment = new Comment(
id,
@event.Headers.Timestamp(),
@event.Payload.Actor,
updated.Text,
null);
if (result.CreatedComments.Exists(x => x.Id == id))
{
result.CreatedComments.RemoveAll(x => x.Id == id);
result.CreatedComments.Add(comment);
}
else
{
result.UpdatedComments.Add(comment);
}
break;
}
}
}
return result;
}
}

76
backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsCommandMiddleware.cs

@ -1,76 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text.RegularExpressions;
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Infrastructure.Commands;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject;
public sealed class CommentsCommandMiddleware : AggregateCommandMiddleware<CommentsCommandBase, CommentsStream>
{
private static readonly Regex MentionRegex = new Regex(@"@(?=.{1,64}@)[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*", RegexOptions.Compiled | RegexOptions.ExplicitCapture, TimeSpan.FromMilliseconds(100));
private readonly IUserResolver userResolver;
public CommentsCommandMiddleware(IDomainObjectFactory domainObjectFactory, IUserResolver userResolver)
: base(domainObjectFactory)
{
this.userResolver = userResolver;
}
public override async Task HandleAsync(CommandContext context, NextDelegate next,
CancellationToken ct)
{
if (context.Command is CommentsCommand commentsCommand)
{
if (commentsCommand is CreateComment createComment && !IsMention(createComment))
{
await MentionUsersAsync(createComment);
}
}
await base.HandleAsync(context, next, ct);
}
private static bool IsMention(CreateComment createComment)
{
return createComment.IsMention;
}
private async Task MentionUsersAsync(CommentTextCommand command)
{
if (string.IsNullOrWhiteSpace(command.Text))
{
return;
}
var emails = MentionRegex.Matches(command.Text).Select(x => x.Value[1..]).ToArray();
if (emails.Length == 0)
{
return;
}
var mentions = new List<string>();
foreach (var email in emails)
{
var user = await userResolver.FindByIdOrEmailAsync(email);
if (user != null)
{
mentions.Add(user.Id);
}
}
if (mentions.Count > 0)
{
command.Mentions = mentions.ToArray();
}
}
}

167
backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsStream.cs

@ -1,167 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Domain.Apps.Entities.Comments.DomainObject.Guards;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject;
public class CommentsStream : IAggregate
{
private readonly List<Envelope<CommentsEvent>> uncommittedEvents = new List<Envelope<CommentsEvent>>();
private readonly List<Envelope<CommentsEvent>> events = new List<Envelope<CommentsEvent>>();
private readonly DomainId key;
private readonly IEventFormatter eventFormatter;
private readonly IEventStore eventStore;
private readonly string streamName;
private long version = EtagVersion.Empty;
private long Version => version;
public CommentsStream(
DomainId key,
IEventFormatter eventFormatter,
IEventStore eventStore)
{
this.key = key;
this.eventFormatter = eventFormatter;
this.eventStore = eventStore;
streamName = $"comments-{key}";
}
public virtual async Task LoadAsync(
CancellationToken ct)
{
var storedEvents = await eventStore.QueryReverseAsync(streamName, 100, ct);
foreach (var @event in storedEvents)
{
var parsedEvent = eventFormatter.Parse(@event);
version = @event.EventStreamNumber;
events.Add(parsedEvent.To<CommentsEvent>());
}
}
public virtual async Task<CommandResult> ExecuteAsync(IAggregateCommand command,
CancellationToken ct)
{
await LoadAsync(ct);
switch (command)
{
case CreateComment createComment:
return await Upsert(createComment, c =>
{
GuardComments.CanCreate(c);
Create(c);
}, ct);
case UpdateComment updateComment:
return await Upsert(updateComment, c =>
{
GuardComments.CanUpdate(c, key.ToString(), events);
Update(c);
}, ct);
case DeleteComment deleteComment:
return await Upsert(deleteComment, c =>
{
GuardComments.CanDelete(c, key.ToString(), events);
Delete(c);
}, ct);
default:
ThrowHelper.NotSupportedException();
return null!;
}
}
private async Task<CommandResult> Upsert<TCommand>(TCommand command, Action<TCommand> handler,
CancellationToken ct) where TCommand : CommentsCommand
{
Guard.NotNull(command);
Guard.NotNull(handler);
if (command.ExpectedVersion > EtagVersion.Any && command.ExpectedVersion != Version)
{
throw new DomainObjectVersionException(key.ToString(), Version, command.ExpectedVersion);
}
var previousVersion = version;
try
{
handler(command);
if (uncommittedEvents.Count > 0)
{
var commitId = Guid.NewGuid();
var eventData = uncommittedEvents.Select(x => eventFormatter.ToEventData(x, commitId)).ToList();
await eventStore.AppendAsync(commitId, streamName, previousVersion, eventData, ct);
}
events.AddRange(uncommittedEvents);
return CommandResult.Empty(key, Version, previousVersion);
}
catch
{
version = previousVersion;
throw;
}
finally
{
uncommittedEvents.Clear();
}
}
public void Create(CreateComment command)
{
RaiseEvent(SimpleMapper.Map(command, new CommentCreated()));
}
public void Update(UpdateComment command)
{
RaiseEvent(SimpleMapper.Map(command, new CommentUpdated()));
}
public void Delete(DeleteComment command)
{
RaiseEvent(SimpleMapper.Map(command, new CommentDeleted()));
}
private void RaiseEvent(CommentsEvent @event)
{
uncommittedEvents.Add(Envelope.Create(@event));
version++;
}
public virtual List<Envelope<CommentsEvent>> GetUncommittedEvents()
{
return uncommittedEvents;
}
public virtual CommentsResult GetComments(long sinceVersion = EtagVersion.Any)
{
return CommentsResult.FromEvents(events, Version, (int)sinceVersion);
}
}

87
backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/Guards/GuardComments.cs

@ -1,87 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject.Guards;
public static class GuardComments
{
public static void CanCreate(CreateComment command)
{
Guard.NotNull(command);
Validate.It(e =>
{
if (string.IsNullOrWhiteSpace(command.Text))
{
e(Not.Defined(nameof(command.Text)), nameof(command.Text));
}
});
}
public static void CanUpdate(UpdateComment command, string commentsId, List<Envelope<CommentsEvent>> events)
{
Guard.NotNull(command);
var comment = FindComment(events, command.CommentId);
if (!string.Equals(commentsId, command.Actor.Identifier, StringComparison.Ordinal) && !comment.Payload.Actor.Equals(command.Actor))
{
throw new DomainException(T.Get("comments.notUserComment"));
}
Validate.It(e =>
{
if (string.IsNullOrWhiteSpace(command.Text))
{
e(Not.Defined(nameof(command.Text)), nameof(command.Text));
}
});
}
public static void CanDelete(DeleteComment command, string commentsId, List<Envelope<CommentsEvent>> events)
{
Guard.NotNull(command);
var comment = FindComment(events, command.CommentId);
if (!string.Equals(commentsId, command.Actor.Identifier, StringComparison.Ordinal) && !comment.Payload.Actor.Equals(command.Actor))
{
throw new DomainException(T.Get("comments.notUserComment"));
}
}
private static Envelope<CommentCreated> FindComment(List<Envelope<CommentsEvent>> events, DomainId commentId)
{
Envelope<CommentCreated>? result = null;
foreach (var @event in events)
{
if (@event.Payload is CommentCreated created && created.CommentId == commentId)
{
result = @event.To<CommentCreated>();
}
else if (@event.Payload is CommentDeleted deleted && deleted.CommentId == commentId)
{
result = null;
}
}
if (result == null)
{
throw new DomainObjectNotFoundException(commentId.ToString());
}
return result;
}
}

16
backend/src/Squidex.Domain.Apps.Entities/Comments/ICommentsLoader.cs

@ -1,16 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Comments;
public interface ICommentsLoader
{
Task<CommentsResult> GetCommentsAsync(DomainId id, long version = EtagVersion.Any,
CancellationToken ct = default);
}

16
backend/src/Squidex.Domain.Apps.Entities/Comments/IWatchingService.cs

@ -1,16 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Comments;
public interface IWatchingService
{
Task<string[]> GetWatchingUsersAsync(DomainId appId, string? resource, string userId,
CancellationToken ct = default);
}

61
backend/src/Squidex.Domain.Apps.Entities/Comments/WatchingService.cs

@ -1,61 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using NodaTime;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Comments;
public sealed class WatchingService : IWatchingService
{
private readonly IPersistenceFactory<State> persistenceFactory;
[CollectionName("Watches")]
public sealed class State
{
private static readonly Duration Timeout = Duration.FromMinutes(1);
public Dictionary<string, Instant> Users { get; set; } = new Dictionary<string, Instant>();
public (bool, string[]) Add(string watcherId, IClock clock)
{
var now = clock.GetCurrentInstant();
foreach (var (userId, lastSeen) in Users.ToList())
{
var timeSinceLastSeen = now - lastSeen;
if (timeSinceLastSeen > Timeout)
{
Users.Remove(userId);
}
}
Users[watcherId] = now;
return (true, Users.Keys.ToArray());
}
}
public IClock Clock { get; set; } = SystemClock.Instance;
public WatchingService(IPersistenceFactory<State> persistenceFactory)
{
this.persistenceFactory = persistenceFactory;
}
public async Task<string[]> GetWatchingUsersAsync(DomainId appId, string? resource, string userId,
CancellationToken ct = default)
{
var state = new SimpleState<State>(persistenceFactory, GetType(), $"{appId}_{resource}");
await state.LoadAsync(ct);
return await state.UpdateAsync(x => x.Add(userId, Clock), ct: ct);
}
}

4
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs

@ -97,8 +97,10 @@ public sealed class ContentChangedTriggerHandler : IRuleTriggerHandler, ISubscri
yield break;
}
var allTriggers = context.Rules.Select(x => x.Value.Trigger).OfType<ContentChangedTriggerV2>();
// This method is only called once per event, therefore we check all rules.
if (!context.Rules.Select(x => x.Value.Trigger).OfType<ContentChangedTriggerV2>().Any(t => MatchesAnySchema(t.ReferencedSchemas, enrichedEvent)))
if (!allTriggers.Any(t => MatchesAnySchema(t.ReferencedSchemas, enrichedEvent)))
{
yield break;
}

11
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentHeaders.cs

@ -23,6 +23,7 @@ public static class ContentHeaders
public const string KeyNoResolveLanguages = "X-NoResolveLanguages";
public const string KeyResolveFlow = "X-ResolveFlow";
public const string KeyResolveUrls = "X-ResolveUrls";
public const string KeyResolveSchemaNames = "X-ResolveSchemaName";
public const string KeyUnpublished = "X-Unpublished";
public static void AddCacheHeaders(this Context context, IRequestCache cache)
@ -98,6 +99,16 @@ public static class ContentHeaders
return builder.WithBoolean(KeyResolveFlow, value);
}
public static bool ResolveSchemaNames(this Context context)
{
return context.AsBoolean(KeyResolveSchemaNames);
}
public static ICloneBuilder WithResolveSchemaNames(this ICloneBuilder builder, bool value = true)
{
return builder.WithBoolean(KeyResolveSchemaNames, value);
}
public static bool NoResolveLanguages(this Context context)
{
return context.AsBoolean(KeyNoResolveLanguages);

4
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Cache/CachingBatchLoader.cs

@ -21,11 +21,11 @@ internal class CachingBatchDataLoader<TKey, T> : DataLoaderBase<CacheableId<TKey
private readonly IQueryCache<TKey, T> queryCache;
private readonly Func<IEnumerable<TKey>, CancellationToken, Task<IDictionary<TKey, T>>> queryDelegate;
public CachingBatchDataLoader(IQueryCache<TKey, T> queryStore,
public CachingBatchDataLoader(IQueryCache<TKey, T> queryCache,
Func<IEnumerable<TKey>, CancellationToken, Task<IDictionary<TKey, T>>> queryDelegate, bool canCache = true, int maxBatchSize = int.MaxValue)
: base(canCache, maxBatchSize)
{
this.queryCache = queryStore;
this.queryCache = queryCache;
this.queryDelegate = queryDelegate;
}

5
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs

@ -41,6 +41,7 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext
this.dataLoaders = dataLoaders;
Context = context.Clone(b => b
.WithResolveSchemaNames()
.WithNoCleanup()
.WithNoEnrichment()
.WithNoAssetEnrichment());
@ -76,7 +77,9 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext
public IDataLoaderResult<IEnrichedContentEntity?> GetContent(DomainId schemaId, DomainId id, long version)
{
return dataLoaders.Context!.GetOrAddLoader(nameof(GetContent), ct =>
var cacheKey = $"{nameof(GetContent)}_{schemaId}_{id}_{version}";
return dataLoaders.Context!.GetOrAddLoader(cacheKey, ct =>
{
return FindContentAsync(schemaId.ToString(), id, version, ct);
}).LoadAsync();

55
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ApplicationQueries.cs

@ -6,7 +6,9 @@
// ==========================================================================
using GraphQL.Types;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents;
using static Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents.ContentActions;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types;
@ -28,34 +30,62 @@ internal sealed class ApplicationQueries : ObjectGraphType
continue;
}
AddContentFind(schemaInfo, contentType);
AddContentQueries(builder, schemaInfo, contentType);
if (schemaInfo.Schema.SchemaDef.Type == SchemaType.Singleton)
{
// Mark the normal queries as deprecated to motivate using the new endpoint.
var deprecation = $"Use 'find{schemaInfo.TypeName}Singleton' instead.";
AddContentFind(schemaInfo, contentType, deprecation);
AddContentFindSingleton(schemaInfo, contentType);
AddContentQueries(builder, schemaInfo, contentType, deprecation);
}
else
{
AddContentFind(schemaInfo, contentType, null);
AddContentQueries(builder, schemaInfo, contentType, null);
}
}
Description = "The app queries.";
}
private void AddContentFind(SchemaInfo schemaInfo, IGraphType contentType)
private void AddContentFind(SchemaInfo schemaInfo, IGraphType contentType, string? deprecatedReason)
{
AddField(new FieldTypeWithSchemaId
{
Name = $"find{schemaInfo.TypeName}Content",
Arguments = ContentActions.Find.Arguments,
Arguments = Find.Arguments,
ResolvedType = contentType,
Resolver = ContentActions.Find.Resolver,
Resolver = Find.Resolver,
DeprecationReason = deprecatedReason,
Description = $"Find an {schemaInfo.DisplayName} content by id.",
SchemaId = schemaInfo.Schema.Id
});
}
private void AddContentQueries(Builder builder, SchemaInfo schemaInfo, IGraphType contentType)
private void AddContentFindSingleton(SchemaInfo schemaInfo, IGraphType contentType)
{
AddField(new FieldTypeWithSchemaId
{
Name = $"find{schemaInfo.TypeName}Singleton",
Arguments = FindSingleton.Arguments,
ResolvedType = contentType,
Resolver = FindSingleton.Resolver,
DeprecationReason = null,
Description = $"Find an {schemaInfo.DisplayName} singleton.",
SchemaId = schemaInfo.Schema.Id
});
}
private void AddContentQueries(Builder builder, SchemaInfo schemaInfo, IGraphType contentType, string? deprecatedReason)
{
AddField(new FieldTypeWithSchemaId
{
Name = $"query{schemaInfo.TypeName}Contents",
Arguments = ContentActions.QueryOrReferencing.Arguments,
Arguments = QueryOrReferencing.Arguments,
ResolvedType = new ListGraphType(new NonNullGraphType(contentType)),
Resolver = ContentActions.QueryOrReferencing.Query,
Resolver = QueryOrReferencing.Query,
DeprecationReason = deprecatedReason,
Description = $"Query {schemaInfo.DisplayName} content items.",
SchemaId = schemaInfo.Schema.Id
});
@ -70,9 +100,10 @@ internal sealed class ApplicationQueries : ObjectGraphType
AddField(new FieldTypeWithSchemaId
{
Name = $"query{schemaInfo.TypeName}ContentsWithTotal",
Arguments = ContentActions.QueryOrReferencing.Arguments,
Arguments = QueryOrReferencing.Arguments,
ResolvedType = contentResultTyp,
Resolver = ContentActions.QueryOrReferencing.QueryWithTotal,
Resolver = QueryOrReferencing.QueryWithTotal,
DeprecationReason = deprecatedReason,
Description = $"Query {schemaInfo.DisplayName} content items with total count.",
SchemaId = schemaInfo.Schema.Id
});
@ -90,9 +121,9 @@ internal sealed class ApplicationQueries : ObjectGraphType
AddField(new FieldType
{
Name = "queryContentsByIds",
Arguments = ContentActions.QueryByIds.Arguments,
Arguments = QueryByIds.Arguments,
ResolvedType = new NonNullGraphType(new ListGraphType(new NonNullGraphType(unionType))),
Resolver = ContentActions.QueryByIds.Resolver,
Resolver = QueryByIds.Resolver,
Description = "Query content items by IDs across schemeas."
});
}

30
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs

@ -96,6 +96,36 @@ internal static class ContentActions
});
}
public static class FindSingleton
{
public static readonly QueryArguments Arguments = new QueryArguments
{
new QueryArgument(Scalars.Int)
{
Name = "version",
Description = FieldDescriptions.QueryVersion,
DefaultValue = null
}
};
public static readonly IFieldResolver Resolver = Resolvers.Sync<object, object?>((_, fieldContext, context) =>
{
var contentSchemaId = fieldContext.FieldDefinition.SchemaId();
var contentVersion = fieldContext.GetArgument<int?>("version");
if (contentVersion >= 0)
{
return context.GetContent(contentSchemaId, contentSchemaId, contentVersion.Value);
}
else
{
return context.GetContent(contentSchemaId, contentSchemaId,
fieldContext.FieldNames(),
fieldContext.CacheDuration());
}
});
}
public static class QueryByIds
{
public static readonly QueryArguments Arguments = new QueryArguments

3
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ConvertData.cs

@ -149,7 +149,10 @@ public sealed class ConvertData : IContentEnricherStep
{
converter.Add(new ResolveAssetUrls(context.App.NamedId(), urlGenerator, assetUrls));
}
}
if (!context.IsFrontendClient || context.ResolveSchemaNames())
{
converter.Add(new AddSchemaNames(components));
}

20
backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs

@ -39,19 +39,21 @@ public sealed class ReferencesFluidExtension : IFluidExtension
private async ValueTask<Completion> ResolveReference(ValueTuple<Expression, Expression> arguments, TextWriter writer, TextEncoder encoder, TemplateContext context)
{
if (context.GetValue("event")?.ToObjectValue() is EnrichedEvent enrichedEvent)
if (context.GetValue("event")?.ToObjectValue() is not EnrichedEvent enrichedEvent)
{
var (nameArg, idArg) = arguments;
return Completion.Normal;
}
var contentId = await idArg.EvaluateAsync(context);
var content = await ResolveContentAsync(serviceProvider, enrichedEvent.AppId.Id, contentId);
var (nameArg, idArg) = arguments;
if (content != null)
{
var name = (await nameArg.EvaluateAsync(context)).ToStringValue();
var contentId = await idArg.EvaluateAsync(context);
var content = await ResolveContentAsync(serviceProvider, enrichedEvent.AppId.Id, contentId);
context.SetValue(name, content);
}
if (content != null)
{
var name = (await nameArg.EvaluateAsync(context)).ToStringValue();
context.SetValue(name, content);
}
return Completion.Normal;

20
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs

@ -21,25 +21,13 @@ public sealed class TextIndexingProcess : IEventConsumer
private readonly ITextIndex textIndex;
private readonly ITextIndexerState textIndexerState;
public int BatchSize
{
get => 1000;
}
public int BatchSize => 1000;
public int BatchDelay
{
get => 1000;
}
public int BatchDelay => 1000;
public string Name
{
get => "TextIndexer5";
}
public string Name => "TextIndexer5";
public string EventsFilter
{
get => "^content-";
}
public StreamFilter EventsFilter { get; } = StreamFilter.Prefix("content-");
public ITextIndex TextIndex
{

12
backend/src/Squidex.Domain.Apps.Entities/Invitation/InvitationEventConsumer.cs

@ -7,7 +7,7 @@
using Microsoft.Extensions.Logging;
using NodaTime;
using Squidex.Domain.Apps.Entities.Notifications;
using Squidex.Domain.Apps.Entities.Collaboration;
using Squidex.Domain.Apps.Events.Apps;
using Squidex.Domain.Apps.Events.Teams;
using Squidex.Infrastructure;
@ -24,15 +24,9 @@ public sealed class InvitationEventConsumer : IEventConsumer
private readonly IAppProvider appProvider;
private readonly ILogger<InvitationEventConsumer> log;
public string Name
{
get => "NotificationEmailSender";
}
public string Name => "NotificationEmailSender";
public string EventsFilter
{
get { return "^app-|^app-"; }
}
public StreamFilter EventsFilter { get; } = StreamFilter.Prefix("app-");
public InvitationEventConsumer(
IAppProvider appProvider,

5
backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs

@ -65,9 +65,10 @@ public sealed class DefaultRuleRunnerService : IRuleRunnerService
var simulatedEvents = new List<SimulatedRuleEvent>(MaxSimulatedEvents);
var fromNow = SystemClock.Instance.GetCurrentInstant().Minus(Duration.FromDays(7));
var streamStart = SystemClock.Instance.GetCurrentInstant().Minus(Duration.FromDays(7));
var streamFilter = StreamFilter.Prefix($"([a-zA-Z0-9]+)-{appId.Id}");
await foreach (var storedEvent in eventStore.QueryAllReverseAsync($"^([a-zA-Z0-9]+)\\-{appId.Id}", fromNow, MaxSimulatedEvents, ct))
await foreach (var storedEvent in eventStore.QueryAllReverseAsync(streamFilter, streamStart, MaxSimulatedEvents, ct))
{
var @event = eventFormatter.ParseIfKnown(storedEvent);

4
backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerProcessor.cs

@ -254,9 +254,9 @@ public sealed class RuleRunnerProcessor
await using var batch = new RuleQueueWriter(ruleEventRepository, ruleUsageTracker, null);
// Use a prefix query so that the storage can use an index for the query.
var filter = $"^([a-z]+)\\-{appId}";
var streamFilter = StreamFilter.Prefix($"([a-zA-Z0-9]+)\\-{appId}");
await foreach (var storedEvent in eventStore.QueryAllAsync(filter, run.Job.Position, ct: ct))
await foreach (var storedEvent in eventStore.QueryAllAsync(streamFilter, run.Job.Position, ct: ct))
{
var @event = eventFormatter.ParseIfKnown(storedEvent);

7
backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj

@ -29,7 +29,9 @@
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="7.0.0" />
<PackageReference Include="Notifo.SDK" Version="1.7.4" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.CLI.Core" Version="11.1.0" />
<PackageReference Include="Squidex.CLI.Core" Version="11.2.0" />
<PackageReference Include="Squidex.YDotNet.Extensions" Version="0.2.9" />
<PackageReference Include="Squidex.YDotNet.Server" Version="0.2.9" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="7.0.0" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
@ -52,4 +54,7 @@
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<Folder Include="Notifications\" />
</ItemGroup>
</Project>

7
backend/src/Squidex.Domain.Apps.Events/Comments/CommentCreated.cs

@ -5,13 +5,18 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Comments;
[EventType(nameof(CommentCreated))]
public sealed class CommentCreated : CommentsEvent
public sealed class CommentCreated : AppEvent
{
public DomainId CommentsId { get; set; }
public DomainId CommentId { get; set; }
public string Text { get; set; }
public string[]? Mentions { get; set; }

17
backend/src/Squidex.Domain.Apps.Events/Comments/CommentsEvent.cs

@ -1,17 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Events.Comments;
public abstract class CommentsEvent : AppEvent
{
public DomainId CommentsId { get; set; }
public DomainId CommentId { get; set; }
}

2
backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj

@ -24,7 +24,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="MongoDB.Driver" Version="2.20.0" />
<PackageReference Include="MongoDB.Driver" Version="2.22.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Security.Principal.Windows" Version="5.0.0" />

12
backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/EventStoreProjectionClient.cs

@ -29,22 +29,22 @@ public sealed class EventStoreProjectionClient
return $"by-{projectionPrefix.Slugify()}-{filter.Slugify()}";
}
public async Task<string> CreateProjectionAsync(string? streamFilter = null)
public async Task<string> CreateProjectionAsync(StreamFilter filter)
{
if (!string.IsNullOrWhiteSpace(streamFilter) && streamFilter[0] != '^')
if (filter.Kind == StreamFilterKind.MatchFull && filter.Prefixes?.Count == 1)
{
return $"{projectionPrefix}-{streamFilter}";
return $"{projectionPrefix}-{filter.Prefixes[0]}";
}
streamFilter ??= ".*";
var regex = filter.ToRegex();
var name = CreateFilterProjectionName(streamFilter);
var name = CreateFilterProjectionName(regex);
var query =
$@"fromAll()
.when({{
$any: function (s, e) {{
if (e.streamId.indexOf('{projectionPrefix}') === 0 && /{streamFilter}/.test(e.streamId.substring({projectionPrefix.Length + 1}))) {{
if (e.streamId.indexOf('{projectionPrefix}') === 0 && /{regex}/.test(e.streamId.substring({projectionPrefix.Length + 1}))) {{
linkTo('{name}', e);
}}
}}

51
backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs

@ -46,14 +46,14 @@ public sealed class GetEventStore : IEventStore, IInitializable
}
}
public IEventSubscription CreateSubscription(IEventSubscriber<StoredEvent> subscriber, string? streamFilter = null, string? position = null)
public IEventSubscription CreateSubscription(IEventSubscriber<StoredEvent> subscriber, StreamFilter filter, string? position = null)
{
Guard.NotNull(streamFilter);
Guard.NotNull(filter);
return new GetEventStoreSubscription(subscriber, client, projectionClient, serializer, position, StreamPrefix, streamFilter);
return new GetEventStoreSubscription(subscriber, client, projectionClient, serializer, position, StreamPrefix, filter);
}
public async IAsyncEnumerable<StoredEvent> QueryAllAsync(string? streamFilter = null, string? position = null, int take = int.MaxValue,
public async IAsyncEnumerable<StoredEvent> QueryAllAsync(StreamFilter filter, string? position = null, int take = int.MaxValue,
[EnumeratorCancellation] CancellationToken ct = default)
{
if (take <= 0)
@ -61,7 +61,7 @@ public sealed class GetEventStore : IEventStore, IInitializable
yield break;
}
var streamName = await projectionClient.CreateProjectionAsync(streamFilter);
var streamName = await projectionClient.CreateProjectionAsync(filter);
var stream = QueryAsync(streamName, position.ToPosition(false), take, ct);
@ -71,7 +71,7 @@ public sealed class GetEventStore : IEventStore, IInitializable
}
}
public async IAsyncEnumerable<StoredEvent> QueryAllReverseAsync(string? streamFilter = null, Instant timestamp = default, int take = int.MaxValue,
public async IAsyncEnumerable<StoredEvent> QueryAllReverseAsync(StreamFilter filter, Instant timestamp = default, int take = int.MaxValue,
[EnumeratorCancellation] CancellationToken ct = default)
{
if (take <= 0)
@ -79,7 +79,7 @@ public sealed class GetEventStore : IEventStore, IInitializable
yield break;
}
var streamName = await projectionClient.CreateProjectionAsync(streamFilter);
var streamName = await projectionClient.CreateProjectionAsync(filter);
var stream = QueryReverseAsync(streamName, StreamPosition.End, take, ct);
@ -89,41 +89,14 @@ public sealed class GetEventStore : IEventStore, IInitializable
}
}
public async Task<IReadOnlyList<StoredEvent>> QueryReverseAsync(string streamName, int count = int.MaxValue,
public async Task<IReadOnlyList<StoredEvent>> QueryStreamAsync(string streamName, long afterStreamPosition = EtagVersion.Empty,
CancellationToken ct = default)
{
Guard.NotNullOrEmpty(streamName);
if (count <= 0)
{
return EmptyEvents;
}
using (Telemetry.Activities.StartActivity("GetEventStore/GetEventStore"))
{
var result = new List<StoredEvent>();
var stream = QueryReverseAsync(GetStreamName(streamName), StreamPosition.End, count, ct);
await foreach (var storedEvent in stream.IgnoreNotFound(ct))
{
result.Add(storedEvent);
}
return result.ToList();
}
}
public async Task<IReadOnlyList<StoredEvent>> QueryAsync(string streamName, long afterStreamPosition = EtagVersion.Empty,
CancellationToken ct = default)
{
Guard.NotNullOrEmpty(streamName);
using (Telemetry.Activities.StartActivity("GetEventStore/QueryAsync"))
{
var result = new List<StoredEvent>();
var stream = QueryAsync(GetStreamName(streamName), afterStreamPosition.ToPositionBefore(), int.MaxValue, ct);
var stream = QueryAsync(streamName, afterStreamPosition.ToPositionBefore(), int.MaxValue, ct);
await foreach (var storedEvent in stream.IgnoreNotFound(ct))
{
@ -156,7 +129,7 @@ public sealed class GetEventStore : IEventStore, IInitializable
streamName,
start,
count,
resolveLinkTos: true,
true,
cancellationToken: ct);
return result.Select(x => Formatter.Read(x, StreamPrefix, serializer));
@ -210,10 +183,10 @@ public sealed class GetEventStore : IEventStore, IInitializable
}
}
public async Task DeleteAsync(string streamFilter,
public async Task DeleteAsync(StreamFilter filter,
CancellationToken ct = default)
{
var streamName = await projectionClient.CreateProjectionAsync(streamFilter);
var streamName = await projectionClient.CreateProjectionAsync(filter);
var events = client.ReadStreamAsync(Direction.Forwards, streamName, StreamPosition.Start, resolveLinkTos: true, cancellationToken: ct);

4
backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStoreSubscription.cs

@ -23,14 +23,14 @@ internal sealed class GetEventStoreSubscription : IEventSubscription
IJsonSerializer serializer,
string? position,
string? prefix,
string? streamFilter)
StreamFilter filter)
{
#pragma warning disable MA0134 // Observe result of async calls
Task.Run(async () =>
{
var ct = cts.Token;
var streamName = await projectionClient.CreateProjectionAsync(streamFilter);
var streamName = await projectionClient.CreateProjectionAsync(filter);
async Task OnEvent(StreamSubscription subscription, ResolvedEvent @event,
CancellationToken ct)

17
backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/Utils.cs

@ -77,4 +77,21 @@ public static class Utils
yield return enumerator.Current;
}
}
public static string ToRegex(this StreamFilter filter)
{
if (filter.Prefixes == null)
{
return ".*";
}
if (filter.Kind == StreamFilterKind.MatchStart)
{
return $"^{string.Join('|', filter.Prefixes.Select(p => $"({p})"))}";
}
else
{
return $"^{string.Join('|', filter.Prefixes.Select(p => $"({p})"))}$";
}
}
}

45
backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/FilterExtensions.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text.RegularExpressions;
using MongoDB.Driver;
namespace Squidex.Infrastructure.EventSourcing;
@ -13,53 +14,57 @@ internal static class FilterExtensions
{
public static FilterDefinition<MongoEventCommit> ByOffset(long streamPosition)
{
return Builders<MongoEventCommit>.Filter.Gte(x => x.EventStreamOffset, streamPosition);
var builder = Builders<MongoEventCommit>.Filter;
return builder.Gte(x => x.EventStreamOffset, streamPosition);
}
public static FilterDefinition<MongoEventCommit> ByPosition(StreamPosition streamPosition)
{
var builder = Builders<MongoEventCommit>.Filter;
if (streamPosition.IsEndOfCommit)
{
return Builders<MongoEventCommit>.Filter.Gt(x => x.Timestamp, streamPosition.Timestamp);
return builder.Gt(x => x.Timestamp, streamPosition.Timestamp);
}
else
{
return Builders<MongoEventCommit>.Filter.Gte(x => x.Timestamp, streamPosition.Timestamp);
return builder.Gte(x => x.Timestamp, streamPosition.Timestamp);
}
}
public static FilterDefinition<MongoEventCommit>? ByStream(string? streamFilter)
public static FilterDefinition<MongoEventCommit> ByStream(StreamFilter filter)
{
if (StreamFilter.IsAll(streamFilter))
{
return Builders<MongoEventCommit>.Filter.Exists(x => x.EventStream, true);
}
var builder = Builders<MongoEventCommit>.Filter;
if (streamFilter.Contains('^', StringComparison.Ordinal))
if (filter.Prefixes == null)
{
return Builders<MongoEventCommit>.Filter.Regex(x => x.EventStream, streamFilter);
return builder.Exists(x => x.EventStream, true);
}
else
if (filter.Kind == StreamFilterKind.MatchStart)
{
return Builders<MongoEventCommit>.Filter.Eq(x => x.EventStream, streamFilter);
return builder.Or(filter.Prefixes.Select(p => builder.Regex(x => x.EventStream, $"^{p}")));
}
return builder.In(x => x.EventStream, filter.Prefixes);
}
public static FilterDefinition<ChangeStreamDocument<MongoEventCommit>>? ByChangeInStream(string? streamFilter)
public static FilterDefinition<ChangeStreamDocument<MongoEventCommit>>? ByChangeInStream(StreamFilter filter)
{
if (StreamFilter.IsAll(streamFilter))
var builder = Builders<ChangeStreamDocument<MongoEventCommit>>.Filter;
if (filter.Prefixes == null)
{
return null;
}
if (streamFilter.Contains('^', StringComparison.Ordinal))
{
return Builders<ChangeStreamDocument<MongoEventCommit>>.Filter.Regex(x => x.FullDocument.EventStream, streamFilter);
}
else
if (filter.Kind == StreamFilterKind.MatchStart)
{
return Builders<ChangeStreamDocument<MongoEventCommit>>.Filter.Eq(x => x.FullDocument.EventStream, streamFilter);
return builder.Or(filter.Prefixes.Select(p => builder.Regex(x => x.FullDocument.EventStream, $"^{Regex.Escape(p)}")));
}
return builder.In(x => x.FullDocument.EventStream, filter.Prefixes);
}
public static IEnumerable<StoredEvent> Filtered(this MongoEventCommit commit, StreamPosition position)

10
backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStoreSubscription.cs

@ -18,7 +18,7 @@ public sealed class MongoEventStoreSubscription : IEventSubscription
private readonly IEventSubscriber<StoredEvent> eventSubscriber;
private readonly CancellationTokenSource stopToken = new CancellationTokenSource();
public MongoEventStoreSubscription(MongoEventStore eventStore, IEventSubscriber<StoredEvent> eventSubscriber, string? streamFilter, string? position)
public MongoEventStoreSubscription(MongoEventStore eventStore, IEventSubscriber<StoredEvent> eventSubscriber, StreamFilter streamFilter, string? position)
{
this.eventStore = eventStore;
this.eventSubscriber = eventSubscriber;
@ -26,7 +26,7 @@ public sealed class MongoEventStoreSubscription : IEventSubscription
QueryAsync(streamFilter, position).Forget();
}
private async Task QueryAsync(string? streamFilter, string? position)
private async Task QueryAsync(StreamFilter streamFilter, string? position)
{
try
{
@ -51,7 +51,7 @@ public sealed class MongoEventStoreSubscription : IEventSubscription
}
}
private async Task QueryCurrentAsync(string? streamFilter, StreamPosition lastPosition)
private async Task QueryCurrentAsync(StreamFilter streamFilter, StreamPosition lastPosition)
{
BsonDocument? resumeToken = null;
@ -103,7 +103,7 @@ public sealed class MongoEventStoreSubscription : IEventSubscription
}
}
private async Task<string?> QueryOldAsync(string? streamFilter, string? position)
private async Task<string?> QueryOldAsync(StreamFilter streamFilter, string? position)
{
string? lastRawPosition = null;
@ -134,7 +134,7 @@ public sealed class MongoEventStoreSubscription : IEventSubscription
return lastRawPosition;
}
private static PipelineDefinition<ChangeStreamDocument<MongoEventCommit>, ChangeStreamDocument<MongoEventCommit>>? Match(string? streamFilter)
private static PipelineDefinition<ChangeStreamDocument<MongoEventCommit>, ChangeStreamDocument<MongoEventCommit>>? Match(StreamFilter streamFilter)
{
var result = new EmptyPipelineDefinition<ChangeStreamDocument<MongoEventCommit>>();

90
backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs

@ -20,71 +20,40 @@ public partial class MongoEventStore : MongoRepositoryBase<MongoEventCommit>, IE
{
private static readonly List<StoredEvent> EmptyEvents = new List<StoredEvent>();
public IEventSubscription CreateSubscription(IEventSubscriber<StoredEvent> subscriber, string? streamFilter = null, string? position = null)
public IEventSubscription CreateSubscription(IEventSubscriber<StoredEvent> subscriber, StreamFilter filter, string? position = null)
{
Guard.NotNull(subscriber);
if (CanUseChangeStreams)
{
return new MongoEventStoreSubscription(this, subscriber, streamFilter, position);
return new MongoEventStoreSubscription(this, subscriber, filter, position);
}
else
{
return new PollingSubscription(this, subscriber, streamFilter, position);
return new PollingSubscription(this, subscriber, filter, position);
}
}
public async Task<IReadOnlyList<StoredEvent>> QueryReverseAsync(string streamName, int count = int.MaxValue,
public async Task<IReadOnlyList<StoredEvent>> QueryStreamAsync(string streamName, long afterStreamPosition = EtagVersion.Empty,
CancellationToken ct = default)
{
Guard.NotNullOrEmpty(streamName);
if (count <= 0)
{
return EmptyEvents;
}
using (Telemetry.Activities.StartActivity("MongoEventStore/QueryLatestAsync"))
{
var filter = Filter.Eq(x => x.EventStream, streamName);
var commits =
await Collection.Find(filter).Sort(Sort.Descending(x => x.Timestamp)).Limit(count)
.ToListAsync(ct);
var result = commits.Select(x => x.Filtered()).Reverse().SelectMany(x => x).TakeLast(count).ToList();
return result;
}
}
public async Task<IReadOnlyList<StoredEvent>> QueryAsync(string streamName, long afterStreamPosition = EtagVersion.Empty,
CancellationToken ct = default)
{
Guard.NotNullOrEmpty(streamName);
using (Telemetry.Activities.StartActivity("MongoEventStore/QueryAsync"))
{
var filter =
Filter.And(
Filter.Eq(x => x.EventStream, streamName),
Filter.Gte(x => x.EventStreamOffset, afterStreamPosition));
var commits =
await Collection.Find(filter)
await Collection.Find(CreateFilter(StreamFilter.Name(streamName), afterStreamPosition))
.ToListAsync(ct);
var result = Convert(commits, afterStreamPosition);
if ((commits.Count == 0 || commits[0].EventStreamOffset != afterStreamPosition) && afterStreamPosition > EtagVersion.Empty)
{
filter =
var filterBefore =
Filter.And(
Filter.Eq(x => x.EventStream, streamName),
FilterExtensions.ByStream(StreamFilter.Name(streamName)),
Filter.Lt(x => x.EventStreamOffset, afterStreamPosition));
commits =
await Collection.Find(filter).SortByDescending(x => x.EventStreamOffset).Limit(1)
await Collection.Find(filterBefore).SortByDescending(x => x.EventStreamOffset).Limit(1)
.ToListAsync(ct);
result = Convert(commits, afterStreamPosition).Concat(result).ToList();
@ -94,26 +63,7 @@ public partial class MongoEventStore : MongoRepositoryBase<MongoEventCommit>, IE
}
}
public async Task<IReadOnlyDictionary<string, IReadOnlyList<StoredEvent>>> QueryManyAsync(IEnumerable<string> streamNames,
CancellationToken ct = default)
{
Guard.NotNull(streamNames);
using (Telemetry.Activities.StartActivity("MongoEventStore/QueryManyAsync"))
{
var filter = Filter.In(x => x.EventStream, streamNames);
var commits =
await Collection.Find(filter)
.ToListAsync(ct);
var result = commits.GroupBy(x => x.EventStream).ToDictionary(x => x.Key, Convert);
return result;
}
}
public async IAsyncEnumerable<StoredEvent> QueryAllReverseAsync(string? streamFilter = null, Instant timestamp = default, int take = int.MaxValue,
public async IAsyncEnumerable<StoredEvent> QueryAllReverseAsync(StreamFilter filter, Instant timestamp = default, int take = int.MaxValue,
[EnumeratorCancellation] CancellationToken ct = default)
{
if (take <= 0)
@ -123,10 +73,8 @@ public partial class MongoEventStore : MongoRepositoryBase<MongoEventCommit>, IE
StreamPosition lastPosition = timestamp;
var filterDefinition = CreateFilter(streamFilter, lastPosition);
var find =
Collection.Find(filterDefinition, Batching.Options)
Collection.Find(CreateFilter(filter, lastPosition), Batching.Options)
.Limit(take).Sort(Sort.Descending(x => x.Timestamp).Ascending(x => x.EventStream));
var taken = 0;
@ -158,12 +106,12 @@ public partial class MongoEventStore : MongoRepositoryBase<MongoEventCommit>, IE
}
}
public async IAsyncEnumerable<StoredEvent> QueryAllAsync(string? streamFilter = null, string? position = null, int take = int.MaxValue,
public async IAsyncEnumerable<StoredEvent> QueryAllAsync(StreamFilter filter, string? position = null, int take = int.MaxValue,
[EnumeratorCancellation] CancellationToken ct = default)
{
StreamPosition lastPosition = position;
var filterDefinition = CreateFilter(streamFilter, lastPosition);
var filterDefinition = CreateFilter(filter, lastPosition);
var find =
Collection.Find(filterDefinition).SortBy(x => x.Timestamp).ThenByDescending(x => x.EventStream)
@ -197,15 +145,13 @@ public partial class MongoEventStore : MongoRepositoryBase<MongoEventCommit>, IE
return commits.OrderBy(x => x.EventStreamOffset).ThenBy(x => x.Timestamp).SelectMany(x => x.Filtered(streamPosition)).ToList();
}
private static FilterDefinition<MongoEventCommit> CreateFilter(string? streamFilter, StreamPosition streamPosition)
private static FilterDefinition<MongoEventCommit> CreateFilter(StreamFilter filter, StreamPosition streamPosition)
{
var filter = FilterExtensions.ByPosition(streamPosition);
if (streamFilter != null)
{
return Filter.And(filter, FilterExtensions.ByStream(streamFilter));
}
return Filter.And(FilterExtensions.ByPosition(streamPosition), FilterExtensions.ByStream(filter));
}
return filter;
private static FilterDefinition<MongoEventCommit> CreateFilter(StreamFilter filter, long streamPosition)
{
return Filter.And(FilterExtensions.ByStream(filter), FilterExtensions.ByOffset(streamPosition));
}
}

14
backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs

@ -17,20 +17,12 @@ public partial class MongoEventStore
private const int MaxWriteAttempts = 20;
private static readonly BsonTimestamp EmptyTimestamp = new BsonTimestamp(0);
public Task DeleteStreamAsync(string streamName,
public Task DeleteAsync(StreamFilter filter,
CancellationToken ct = default)
{
Guard.NotNullOrEmpty(streamName);
return Collection.DeleteManyAsync(x => x.EventStream == streamName, ct);
}
public Task DeleteAsync(string streamFilter,
CancellationToken ct = default)
{
Guard.NotNullOrEmpty(streamFilter);
Guard.NotDefault(filter);
return Collection.DeleteManyAsync(FilterExtensions.ByStream(streamFilter), ct);
return Collection.DeleteManyAsync(FilterExtensions.ByStream(filter), ct);
}
public async Task AppendAsync(Guid commitId, string streamName, long expectedVersion, ICollection<EventData> events,

4
backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj

@ -18,8 +18,8 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MongoDB.Driver" Version="2.20.0" />
<PackageReference Include="MongoDB.Driver.GridFS" Version="2.20.0" />
<PackageReference Include="MongoDB.Driver" Version="2.22.0" />
<PackageReference Include="MongoDB.Driver.GridFS" Version="2.22.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

4
backend/src/Squidex.Infrastructure/Commands/Rebuilder.cs

@ -49,14 +49,14 @@ public class Rebuilder
return domainObject;
}
public virtual Task RebuildAsync<T, TState>(string filter, int batchSize,
public virtual Task RebuildAsync<T, TState>(StreamFilter filter, int batchSize,
CancellationToken ct = default)
where T : DomainObject<TState> where TState : class, IDomainState<TState>, new()
{
return RebuildAsync<T, TState>(filter, batchSize, 0, ct);
}
public virtual async Task RebuildAsync<T, TState>(string filter, int batchSize, double errorThreshold,
public virtual async Task RebuildAsync<T, TState>(StreamFilter filter, int batchSize, double errorThreshold,
CancellationToken ct = default)
where T : DomainObject<TState> where TState : class, IDomainState<TState>, new()
{

6
backend/src/Squidex.Infrastructure/DomainId.cs

@ -102,4 +102,10 @@ public readonly struct DomainId : IEquatable<DomainId>, IComparable<DomainId>
{
return new DomainId($"{id1}{IdSeparator}{id2}");
}
public static bool TryParse(ReadOnlySpan<char> input, out DomainId result)
{
result = new DomainId(input.ToString());
return true;
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save