Browse Source

File scoped namespace. (#938)

pull/940/head
Sebastian Stehle 4 years ago
committed by GitHub
parent
commit
c652b50ecc
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      .github/workflows/dev.yml
  2. 6
      .github/workflows/release.yml
  3. 41
      backend/extensions/Squidex.Extensions/APM/ApplicationInsights/ApplicationInsightsPlugin.cs
  4. 45
      backend/extensions/Squidex.Extensions/APM/Otlp/OtlpPlugin.cs
  5. 75
      backend/extensions/Squidex.Extensions/APM/Stackdriver/StackdriverExceptionHandler.cs
  6. 69
      backend/extensions/Squidex.Extensions/APM/Stackdriver/StackdriverPlugin.cs
  7. 49
      backend/extensions/Squidex.Extensions/APM/Stackdriver/StackdriverSeverityLogAppender.cs
  8. 65
      backend/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaAction.cs
  9. 201
      backend/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaActionHandler.cs
  10. 11
      backend/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaPlugin.cs
  11. 57
      backend/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueAction.cs
  12. 95
      backend/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueActionHandler.cs
  13. 11
      backend/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueuePlugin.cs
  14. 43
      backend/extensions/Squidex.Extensions/Actions/ClientPool.cs
  15. 35
      backend/extensions/Squidex.Extensions/Actions/Comment/CommentAction.cs
  16. 83
      backend/extensions/Squidex.Extensions/Actions/Comment/CommentActionHandler.cs
  17. 11
      backend/extensions/Squidex.Extensions/Actions/Comment/CommentPlugin.cs
  18. 49
      backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentAction.cs
  19. 97
      backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentActionHandler.cs
  20. 11
      backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentPlugin.cs
  21. 89
      backend/extensions/Squidex.Extensions/Actions/Discourse/DiscourseAction.cs
  22. 123
      backend/extensions/Squidex.Extensions/Actions/Discourse/DiscourseActionHandler.cs
  23. 11
      backend/extensions/Squidex.Extensions/Actions/Discourse/DiscoursePlugin.cs
  24. 77
      backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchAction.cs
  25. 219
      backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchActionHandler.cs
  26. 11
      backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchPlugin.cs
  27. 95
      backend/extensions/Squidex.Extensions/Actions/Email/EmailAction.cs
  28. 107
      backend/extensions/Squidex.Extensions/Actions/Email/EmailActionHandler.cs
  29. 11
      backend/extensions/Squidex.Extensions/Actions/Email/EmailPlugin.cs
  30. 37
      backend/extensions/Squidex.Extensions/Actions/Fastly/FastlyAction.cs
  31. 79
      backend/extensions/Squidex.Extensions/Actions/Fastly/FastlyActionHandler.cs
  32. 11
      backend/extensions/Squidex.Extensions/Actions/Fastly/FastlyPlugin.cs
  33. 87
      backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaAction.cs
  34. 157
      backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaActionHandler.cs
  35. 21
      backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaPlugin.cs
  36. 395
      backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaProducer.cs
  37. 25
      backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaProducerOptions.cs
  38. 89
      backend/extensions/Squidex.Extensions/Actions/Medium/MediumAction.cs
  39. 193
      backend/extensions/Squidex.Extensions/Actions/Medium/MediumActionHandler.cs
  40. 11
      backend/extensions/Squidex.Extensions/Actions/Medium/MediumPlugin.cs
  41. 51
      backend/extensions/Squidex.Extensions/Actions/Notification/NotificationAction.cs
  42. 109
      backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs
  43. 11
      backend/extensions/Squidex.Extensions/Actions/Notification/NotificationPlugin.cs
  44. 77
      backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchAction.cs
  45. 219
      backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchActionHandler.cs
  46. 11
      backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchPlugin.cs
  47. 39
      backend/extensions/Squidex.Extensions/Actions/Prerender/PrerenderAction.cs
  48. 57
      backend/extensions/Squidex.Extensions/Actions/Prerender/PrerenderActionHandler.cs
  49. 11
      backend/extensions/Squidex.Extensions/Actions/Prerender/PrerenderPlugin.cs
  50. 83
      backend/extensions/Squidex.Extensions/Actions/RuleHelper.cs
  51. 29
      backend/extensions/Squidex.Extensions/Actions/Script/ScriptAction.cs
  52. 57
      backend/extensions/Squidex.Extensions/Actions/Script/ScriptActionHandler.cs
  53. 11
      backend/extensions/Squidex.Extensions/Actions/Script/ScriptPlugin.cs
  54. 97
      backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRAction.cs
  55. 145
      backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRActionHandler.cs
  56. 11
      backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRPlugin.cs
  57. 41
      backend/extensions/Squidex.Extensions/Actions/Slack/SlackAction.cs
  58. 69
      backend/extensions/Squidex.Extensions/Actions/Slack/SlackActionHandler.cs
  59. 11
      backend/extensions/Squidex.Extensions/Actions/Slack/SlackPlugin.cs
  60. 47
      backend/extensions/Squidex.Extensions/Actions/Twitter/TweetAction.cs
  61. 79
      backend/extensions/Squidex.Extensions/Actions/Twitter/TweetActionHandler.cs
  62. 11
      backend/extensions/Squidex.Extensions/Actions/Twitter/TwitterOptions.cs
  63. 15
      backend/extensions/Squidex.Extensions/Actions/Twitter/TwitterPlugin.cs
  64. 63
      backend/extensions/Squidex.Extensions/Actions/Typesense/TypesenseAction.cs
  65. 199
      backend/extensions/Squidex.Extensions/Actions/Typesense/TypesenseActionHandler.cs
  66. 11
      backend/extensions/Squidex.Extensions/Actions/Typesense/TypesensePlugin.cs
  67. 91
      backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs
  68. 205
      backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs
  69. 11
      backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookPlugin.cs
  70. 109
      backend/extensions/Squidex.Extensions/Assets/Azure/AzureMetadataSource.cs
  71. 21
      backend/extensions/Squidex.Extensions/Assets/Azure/AzureMetadataSourceOptions.cs
  72. 19
      backend/extensions/Squidex.Extensions/Assets/Azure/AzureMetadataSourcePlugin.cs
  73. 57
      backend/extensions/Squidex.Extensions/Samples/AssetStore/MemoryAssetStorePlugin.cs
  74. 21
      backend/extensions/Squidex.Extensions/Samples/Controllers/PluginController.cs
  75. 145
      backend/extensions/Squidex.Extensions/Samples/Middleware/DoubleLinkedContentMiddleware.cs
  76. 205
      backend/extensions/Squidex.Extensions/Text/Azure/AzureIndexDefinition.cs
  77. 241
      backend/extensions/Squidex.Extensions/Text/Azure/AzureTextIndex.cs
  78. 59
      backend/extensions/Squidex.Extensions/Text/Azure/AzureTextPlugin.cs
  79. 149
      backend/extensions/Squidex.Extensions/Text/Azure/CommandFactory.cs
  80. 173
      backend/extensions/Squidex.Extensions/Text/ElasticSearch/CommandFactory.cs
  81. 95
      backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchClient.cs
  82. 173
      backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchIndexDefinition.cs
  83. 313
      backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextIndex.cs
  84. 69
      backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextPlugin.cs
  85. 19
      backend/extensions/Squidex.Extensions/Text/ElasticSearch/IElasticSearchClient.cs
  86. 95
      backend/extensions/Squidex.Extensions/Text/ElasticSearch/OpenSearchClient.cs
  87. 137
      backend/extensions/Squidex.Extensions/Validation/CompositeUniqueValidator.cs
  88. 43
      backend/extensions/Squidex.Extensions/Validation/CompositeUniqueValidatorFactory.cs
  89. 11
      backend/extensions/Squidex.Extensions/Validation/CompositeUniqueValidatorPlugin.cs
  90. 241
      backend/i18n/translator/Squidex.Translator/Commands.cs
  91. 19
      backend/i18n/translator/Squidex.Translator/Extensions.cs
  92. 63
      backend/i18n/translator/Squidex.Translator/Processes/Backend.cs
  93. 63
      backend/i18n/translator/Squidex.Translator/Processes/CheckBackend.cs
  94. 125
      backend/i18n/translator/Squidex.Translator/Processes/CheckFrontend.cs
  95. 45
      backend/i18n/translator/Squidex.Translator/Processes/Frontend.cs
  96. 71
      backend/i18n/translator/Squidex.Translator/Processes/GenerateBackendResources.cs
  97. 45
      backend/i18n/translator/Squidex.Translator/Processes/GenerateFrontendResources.cs
  98. 51
      backend/i18n/translator/Squidex.Translator/Processes/GenerateKeys.cs
  99. 217
      backend/i18n/translator/Squidex.Translator/Processes/Helper.cs
  100. 65
      backend/i18n/translator/Squidex.Translator/Processes/TranslateBackend.cs

6
.github/workflows/dev.yml

@ -110,7 +110,7 @@ jobs:
working-directory: backend/tests working-directory: backend/tests
- name: RUN TEST - name: RUN TEST
uses: kohlerdominik/docker-run-action@v1.0.2 uses: kohlerdominik/docker-run-action@v1.1.0
with: with:
image: squidex/build image: squidex/build
environment: | environment: |
@ -124,7 +124,7 @@ jobs:
run: dotnet test /src/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated run: dotnet test /src/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated
- name: RUN TEST on path - name: RUN TEST on path
uses: kohlerdominik/docker-run-action@v1.0.2 uses: kohlerdominik/docker-run-action@v1.1.0
with: with:
image: squidex/build image: squidex/build
environment: | environment: |
@ -138,7 +138,7 @@ jobs:
run: dotnet test /src/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated run: dotnet test /src/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated
- name: RUN TEST with dedicated collections - name: RUN TEST with dedicated collections
uses: kohlerdominik/docker-run-action@v1.0.2 uses: kohlerdominik/docker-run-action@v1.1.0
with: with:
image: squidex/build image: squidex/build
environment: | environment: |

6
.github/workflows/release.yml

@ -93,7 +93,7 @@ jobs:
working-directory: backend/tests working-directory: backend/tests
- name: RUN TEST - name: RUN TEST
uses: kohlerdominik/docker-run-action@v1.0.2 uses: kohlerdominik/docker-run-action@v1.1.0
with: with:
image: squidex/build image: squidex/build
environment: | environment: |
@ -107,7 +107,7 @@ jobs:
run: dotnet test /src/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated run: dotnet test /src/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated
- name: RUN TEST on path - name: RUN TEST on path
uses: kohlerdominik/docker-run-action@v1.0.2 uses: kohlerdominik/docker-run-action@v1.1.0
with: with:
image: squidex/build image: squidex/build
environment: | environment: |
@ -121,7 +121,7 @@ jobs:
run: dotnet test /src/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated run: dotnet test /src/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated
- name: RUN TEST with dedicated collections - name: RUN TEST with dedicated collections
uses: kohlerdominik/docker-run-action@v1.0.2 uses: kohlerdominik/docker-run-action@v1.1.0
with: with:
image: squidex/build image: squidex/build
environment: | environment: |

41
backend/extensions/Squidex.Extensions/APM/ApplicationInsights/ApplicationInsightsPlugin.cs

@ -12,35 +12,34 @@ using OpenTelemetry.Trace;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.APM.ApplicationInsights namespace Squidex.Extensions.APM.ApplicationInsights;
public sealed class ApplicationInsightsPlugin : IPlugin
{ {
public sealed class ApplicationInsightsPlugin : IPlugin private sealed class Configurator : ITelemetryConfigurator
{ {
private sealed class Configurator : ITelemetryConfigurator private readonly IConfiguration config;
{
private readonly IConfiguration config;
public Configurator(IConfiguration config) public Configurator(IConfiguration config)
{ {
this.config = config; this.config = config;
} }
public void Configure(TracerProviderBuilder builder) public void Configure(TracerProviderBuilder builder)
{
builder.AddAzureMonitorTraceExporter(options =>
{ {
builder.AddAzureMonitorTraceExporter(options => config.GetSection("logging:applicationInsights").Bind(options);
{ });
config.GetSection("logging:applicationInsights").Bind(options);
});
}
} }
}
public void ConfigureServices(IServiceCollection services, IConfiguration config) public void ConfigureServices(IServiceCollection services, IConfiguration config)
{
if (config.GetValue<bool>("logging:applicationInsights:enabled"))
{ {
if (config.GetValue<bool>("logging:applicationInsights:enabled")) services.AddSingleton<ITelemetryConfigurator,
{ Configurator>();
services.AddSingleton<ITelemetryConfigurator,
Configurator>();
}
} }
} }
} }

45
backend/extensions/Squidex.Extensions/APM/Otlp/OtlpPlugin.cs

@ -11,38 +11,37 @@ using OpenTelemetry.Trace;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.APM.Otlp namespace Squidex.Extensions.APM.Otlp;
public sealed class OtlpPlugin : IPlugin
{ {
public sealed class OtlpPlugin : IPlugin private sealed class Configurator : ITelemetryConfigurator
{ {
private sealed class Configurator : ITelemetryConfigurator private readonly IConfiguration config;
public Configurator(IConfiguration config)
{ {
private readonly IConfiguration config; this.config = config;
}
public Configurator(IConfiguration config) public void Configure(TracerProviderBuilder builder)
{ {
this.config = config; // See: https://docs.microsoft.com/aspnet/core/grpc/troubleshoot#call-insecure-grpc-services-with-net-core-client
} AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
public void Configure(TracerProviderBuilder builder) builder.AddOtlpExporter(options =>
{ {
// See: https://docs.microsoft.com/aspnet/core/grpc/troubleshoot#call-insecure-grpc-services-with-net-core-client config.GetSection("logging:otlp").Bind(options);
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); });
builder.AddOtlpExporter(options =>
{
config.GetSection("logging:otlp").Bind(options);
});
}
} }
}
public void ConfigureServices(IServiceCollection services, IConfiguration config) public void ConfigureServices(IServiceCollection services, IConfiguration config)
{
if (config.GetValue<bool>("logging:otlp:enabled"))
{ {
if (config.GetValue<bool>("logging:otlp:enabled")) services.AddSingleton<ITelemetryConfigurator,
{ Configurator>();
services.AddSingleton<ITelemetryConfigurator,
Configurator>();
}
} }
} }
} }

75
backend/extensions/Squidex.Extensions/APM/Stackdriver/StackdriverExceptionHandler.cs

@ -11,58 +11,57 @@ using Microsoft.AspNetCore.Http.Extensions;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Log; using Squidex.Log;
namespace Squidex.Extensions.APM.Stackdriver namespace Squidex.Extensions.APM.Stackdriver;
internal sealed class StackdriverExceptionHandler : ILogAppender
{ {
internal sealed class StackdriverExceptionHandler : ILogAppender private readonly IContextExceptionLogger logger;
private readonly HttpContextWrapper httpContextWrapper;
public sealed class HttpContextWrapper : IContextWrapper
{ {
private readonly IContextExceptionLogger logger; private readonly IHttpContextAccessor httpContextAccessor;
private readonly HttpContextWrapper httpContextWrapper;
public sealed class HttpContextWrapper : IContextWrapper internal HttpContextWrapper(IHttpContextAccessor httpContextAccessor)
{ {
private readonly IHttpContextAccessor httpContextAccessor; this.httpContextAccessor = httpContextAccessor;
}
internal HttpContextWrapper(IHttpContextAccessor httpContextAccessor)
{
this.httpContextAccessor = httpContextAccessor;
}
public string GetHttpMethod()
{
return httpContextAccessor.HttpContext?.Request?.Method ?? string.Empty;
}
public string GetUri()
{
return httpContextAccessor.HttpContext?.Request?.GetDisplayUrl() ?? string.Empty;
}
public string GetUserAgent() public string GetHttpMethod()
{ {
return httpContextAccessor.HttpContext?.Request?.Headers["User-Agent"].ToString() ?? string.Empty; return httpContextAccessor.HttpContext?.Request?.Method ?? string.Empty;
}
} }
public StackdriverExceptionHandler(IContextExceptionLogger logger, IHttpContextAccessor httpContextAccessor) public string GetUri()
{ {
this.logger = logger; return httpContextAccessor.HttpContext?.Request?.GetDisplayUrl() ?? string.Empty;
}
httpContextWrapper = new HttpContextWrapper(httpContextAccessor); public string GetUserAgent()
{
return httpContextAccessor.HttpContext?.Request?.Headers["User-Agent"].ToString() ?? string.Empty;
} }
}
public void Append(IObjectWriter writer, SemanticLogLevel logLevel, Exception exception) public StackdriverExceptionHandler(IContextExceptionLogger logger, IHttpContextAccessor httpContextAccessor)
{
this.logger = logger;
httpContextWrapper = new HttpContextWrapper(httpContextAccessor);
}
public void Append(IObjectWriter writer, SemanticLogLevel logLevel, Exception exception)
{
try
{ {
try if (exception != null && exception is not DomainException && exception is not OperationCanceledException)
{
if (exception != null && exception is not DomainException && exception is not OperationCanceledException)
{
logger.Log(exception, httpContextWrapper);
}
}
catch
{ {
return; logger.Log(exception, httpContextWrapper);
} }
} }
catch
{
return;
}
} }
} }

69
backend/extensions/Squidex.Extensions/APM/Stackdriver/StackdriverPlugin.cs

@ -14,54 +14,53 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
using Squidex.Log; using Squidex.Log;
namespace Squidex.Extensions.APM.Stackdriver namespace Squidex.Extensions.APM.Stackdriver;
public sealed class StackdriverPlugin : IPlugin
{ {
public sealed class StackdriverPlugin : IPlugin private sealed class Configurator : ITelemetryConfigurator
{ {
private sealed class Configurator : ITelemetryConfigurator private readonly string projectId;
{
private readonly string projectId;
public Configurator(string projectId) public Configurator(string projectId)
{ {
this.projectId = projectId; this.projectId = projectId;
}
public void Configure(TracerProviderBuilder builder)
{
builder.UseStackdriverExporter(projectId);
}
} }
public void ConfigureServices(IServiceCollection services, IConfiguration config) public void Configure(TracerProviderBuilder builder)
{ {
var isEnabled = config.GetValue<bool>("logging:stackdriver:enabled"); builder.UseStackdriverExporter(projectId);
}
}
if (!isEnabled) public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
return; var isEnabled = config.GetValue<bool>("logging:stackdriver:enabled");
}
var projectId = config.GetValue<string>("logging:stackdriver:projectId"); if (!isEnabled)
{
return;
}
var projectId = config.GetValue<string>("logging:stackdriver:projectId");
if (string.IsNullOrWhiteSpace(projectId)) if (string.IsNullOrWhiteSpace(projectId))
{ {
return; return;
} }
services.AddSingleton<ITelemetryConfigurator>( services.AddSingleton<ITelemetryConfigurator>(
new Configurator(projectId)); new Configurator(projectId));
services.AddSingleton<ILogAppender, services.AddSingleton<ILogAppender,
StackdriverSeverityLogAppender>(); StackdriverSeverityLogAppender>();
services.AddSingleton<ILogAppender, services.AddSingleton<ILogAppender,
StackdriverExceptionHandler>(); StackdriverExceptionHandler>();
var serviceName = config.GetValue<string>("logging:name") ?? "Squidex"; var serviceName = config.GetValue<string>("logging:name") ?? "Squidex";
var serviceVersion = Assembly.GetEntryAssembly()?.GetName().Version?.ToString(); var serviceVersion = Assembly.GetEntryAssembly()?.GetName().Version?.ToString();
services.AddSingleton(c => ContextExceptionLogger.Create(projectId, serviceVersion, serviceVersion, null)); services.AddSingleton(c => ContextExceptionLogger.Create(projectId, serviceVersion, serviceVersion, null));
}
} }
} }

49
backend/extensions/Squidex.Extensions/APM/Stackdriver/StackdriverSeverityLogAppender.cs

@ -7,36 +7,35 @@
using Squidex.Log; using Squidex.Log;
namespace Squidex.Extensions.APM.Stackdriver namespace Squidex.Extensions.APM.Stackdriver;
public sealed class StackdriverSeverityLogAppender : ILogAppender
{ {
public sealed class StackdriverSeverityLogAppender : ILogAppender public void Append(IObjectWriter writer, SemanticLogLevel logLevel, Exception exception)
{ {
public void Append(IObjectWriter writer, SemanticLogLevel logLevel, Exception exception) var severity = GetSeverity(logLevel);
{
var severity = GetSeverity(logLevel);
writer.WriteProperty(nameof(severity), severity); writer.WriteProperty(nameof(severity), severity);
} }
private static string GetSeverity(SemanticLogLevel logLevel) private static string GetSeverity(SemanticLogLevel logLevel)
{
switch (logLevel)
{ {
switch (logLevel) case SemanticLogLevel.Trace:
{ return "DEBUG";
case SemanticLogLevel.Trace: case SemanticLogLevel.Debug:
return "DEBUG"; return "DEBUG";
case SemanticLogLevel.Debug: case SemanticLogLevel.Information:
return "DEBUG"; return "INFO";
case SemanticLogLevel.Information: case SemanticLogLevel.Warning:
return "INFO"; return "WARNING";
case SemanticLogLevel.Warning: case SemanticLogLevel.Error:
return "WARNING"; return "ERROR";
case SemanticLogLevel.Error: case SemanticLogLevel.Fatal:
return "ERROR"; return "CRITICAL";
case SemanticLogLevel.Fatal: default:
return "CRITICAL"; return "DEFAULT";
default:
return "DEFAULT";
}
} }
} }
} }

65
backend/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaAction.cs

@ -10,42 +10,41 @@ using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Algolia namespace Squidex.Extensions.Actions.Algolia;
[RuleAction(
Title = "Algolia",
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M16 .842C7.633.842.842 7.625.842 16S7.625 31.158 16 31.158c8.374 0 15.158-6.791 15.158-15.166S24.375.842 16 .842zm0 25.83c-5.898 0-10.68-4.781-10.68-10.68S10.101 5.313 16 5.313s10.68 4.781 10.68 10.679-4.781 10.68-10.68 10.68zm0-19.156v7.956c0 .233.249.388.458.279l7.055-3.663a.312.312 0 0 0 .124-.434 8.807 8.807 0 0 0-7.319-4.447z'/></svg>",
IconColor = "#0d9bf9",
Display = "Populate Algolia index",
Description = "Populate a full text search index in Algolia.",
ReadMore = "https://www.algolia.com/")]
public sealed record AlgoliaAction : RuleAction
{ {
[RuleAction( [LocalizedRequired]
Title = "Algolia", [Display(Name = "Application Id", Description = "The application ID.")]
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M16 .842C7.633.842.842 7.625.842 16S7.625 31.158 16 31.158c8.374 0 15.158-6.791 15.158-15.166S24.375.842 16 .842zm0 25.83c-5.898 0-10.68-4.781-10.68-10.68S10.101 5.313 16 5.313s10.68 4.781 10.68 10.679-4.781 10.68-10.68 10.68zm0-19.156v7.956c0 .233.249.388.458.279l7.055-3.663a.312.312 0 0 0 .124-.434 8.807 8.807 0 0 0-7.319-4.447z'/></svg>", [Editor(RuleFieldEditor.Text)]
IconColor = "#0d9bf9", [Formattable]
Display = "Populate Algolia index", public string AppId { get; set; }
Description = "Populate a full text search index in Algolia.",
ReadMore = "https://www.algolia.com/")]
public sealed record AlgoliaAction : RuleAction
{
[LocalizedRequired]
[Display(Name = "Application Id", Description = "The application ID.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string AppId { get; set; }
[LocalizedRequired] [LocalizedRequired]
[Display(Name = "Api Key", Description = "The API key to grant access to Squidex.")] [Display(Name = "Api Key", Description = "The API key to grant access to Squidex.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
[Formattable] [Formattable]
public string ApiKey { get; set; } public string ApiKey { get; set; }
[LocalizedRequired] [LocalizedRequired]
[Display(Name = "Index Name", Description = "The name of the index.")] [Display(Name = "Index Name", Description = "The name of the index.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
[Formattable] [Formattable]
public string IndexName { get; set; } public string IndexName { get; set; }
[Display(Name = "Document", Description = "The optional custom document.")] [Display(Name = "Document", Description = "The optional custom document.")]
[Editor(RuleFieldEditor.TextArea)] [Editor(RuleFieldEditor.TextArea)]
[Formattable] [Formattable]
public string Document { get; set; } public string Document { get; set; }
[Display(Name = "Deletion", Description = "The condition when to delete the entry.")] [Display(Name = "Deletion", Description = "The condition when to delete the entry.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
public string Delete { get; set; } public string Delete { get; set; }
}
} }

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

@ -16,147 +16,146 @@ using Squidex.Infrastructure.Json;
#pragma warning disable IDE0059 // Value assigned to symbol is never used #pragma warning disable IDE0059 // Value assigned to symbol is never used
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Algolia namespace Squidex.Extensions.Actions.Algolia;
public sealed class AlgoliaActionHandler : RuleActionHandler<AlgoliaAction, AlgoliaJob>
{ {
public sealed class AlgoliaActionHandler : RuleActionHandler<AlgoliaAction, AlgoliaJob> private readonly ClientPool<(string AppId, string ApiKey, string IndexName), ISearchIndex> clients;
{ private readonly IScriptEngine scriptEngine;
private readonly ClientPool<(string AppId, string ApiKey, string IndexName), ISearchIndex> clients; private readonly IJsonSerializer serializer;
private readonly IScriptEngine scriptEngine;
private readonly IJsonSerializer serializer;
public AlgoliaActionHandler(RuleEventFormatter formatter, IScriptEngine scriptEngine, IJsonSerializer serializer) public AlgoliaActionHandler(RuleEventFormatter formatter, IScriptEngine scriptEngine, IJsonSerializer serializer)
: base(formatter) : base(formatter)
{
clients = new ClientPool<(string AppId, string ApiKey, string IndexName), ISearchIndex>(key =>
{ {
clients = new ClientPool<(string AppId, string ApiKey, string IndexName), ISearchIndex>(key => var client = new SearchClient(key.AppId, key.ApiKey);
{
var client = new SearchClient(key.AppId, key.ApiKey);
return client.InitIndex(key.IndexName); return client.InitIndex(key.IndexName);
}); });
this.scriptEngine = scriptEngine; this.scriptEngine = scriptEngine;
this.serializer = serializer; this.serializer = serializer;
} }
protected override async Task<(string Description, AlgoliaJob Data)> CreateJobAsync(EnrichedEvent @event, AlgoliaAction action) protected override async Task<(string Description, AlgoliaJob Data)> CreateJobAsync(EnrichedEvent @event, AlgoliaAction action)
{
if (@event is IEnrichedEntityEvent entityEvent)
{ {
if (@event is IEnrichedEntityEvent entityEvent) var delete = @event.ShouldDelete(scriptEngine, action.Delete);
{
var delete = @event.ShouldDelete(scriptEngine, action.Delete);
var ruleDescription = string.Empty; var ruleDescription = string.Empty;
var contentId = entityEvent.Id.ToString(); var contentId = entityEvent.Id.ToString();
var content = (AlgoliaContent)null; var content = (AlgoliaContent)null;
if (delete) if (delete)
{ {
ruleDescription = $"Delete entry from Algolia index: {action.IndexName}"; ruleDescription = $"Delete entry from Algolia index: {action.IndexName}";
} }
else else
{
ruleDescription = $"Add entry to Algolia index: {action.IndexName}";
try
{ {
ruleDescription = $"Add entry to Algolia index: {action.IndexName}"; string jsonString;
try if (!string.IsNullOrEmpty(action.Document))
{ {
string jsonString; jsonString = await FormatAsync(action.Document, @event);
jsonString = jsonString?.Trim();
if (!string.IsNullOrEmpty(action.Document))
{
jsonString = await FormatAsync(action.Document, @event);
jsonString = jsonString?.Trim();
}
else
{
jsonString = ToJson(@event);
}
content = serializer.Deserialize<AlgoliaContent>(jsonString);
} }
catch (Exception ex) else
{ {
content = new AlgoliaContent jsonString = ToJson(@event);
{
More = new Dictionary<string, object>
{
["error"] = $"Invalid JSON: {ex.Message}"
}
};
} }
content.ObjectID = contentId; content = serializer.Deserialize<AlgoliaContent>(jsonString);
} }
catch (Exception ex)
var ruleJob = new AlgoliaJob
{ {
AppId = action.AppId, content = new AlgoliaContent
ApiKey = action.ApiKey, {
Content = serializer.Serialize(content, true), More = new Dictionary<string, object>
ContentId = contentId, {
IndexName = await FormatAsync(action.IndexName, @event) ["error"] = $"Invalid JSON: {ex.Message}"
}; }
};
}
return (ruleDescription, ruleJob); 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, return ("Ignore", new AlgoliaJob());
CancellationToken ct = default) }
protected override async Task<Result> ExecuteJobAsync(AlgoliaJob job,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(job.AppId))
{ {
if (string.IsNullOrWhiteSpace(job.AppId)) return Result.Ignored();
{ }
return Result.Ignored();
}
var index = await clients.GetClientAsync((job.AppId, job.ApiKey, job.IndexName)); var index = await clients.GetClientAsync((job.AppId, job.ApiKey, job.IndexName));
try try
{
if (job.Content != null)
{ {
if (job.Content != null) var raw = new[]
{ {
var raw = new[] new JRaw(job.Content)
{ };
new JRaw(job.Content)
};
var response = await index.SaveObjectsAsync(raw, null, ct, true); var response = await index.SaveObjectsAsync(raw, null, ct, true);
return Result.Success(serializer.Serialize(response, true)); return Result.Success(serializer.Serialize(response, true));
}
else
{
var response = await index.DeleteObjectAsync(job.ContentId, null, ct);
return Result.Success(serializer.Serialize(response, true));
}
} }
catch (Exception ex) else
{ {
return Result.Failed(ex); var response = await index.DeleteObjectAsync(job.ContentId, null, ct);
return Result.Success(serializer.Serialize(response, true));
} }
} }
catch (Exception ex)
{
return Result.Failed(ex);
}
} }
}
public sealed class AlgoliaContent public sealed class AlgoliaContent
{ {
[JsonPropertyName("objectID")] [JsonPropertyName("objectID")]
public string ObjectID { get; set; } public string ObjectID { get; set; }
[JsonExtensionData] [JsonExtensionData]
public Dictionary<string, object> More { get; set; } = new Dictionary<string, object>(); public Dictionary<string, object> More { get; set; } = new Dictionary<string, object>();
} }
public sealed class AlgoliaJob public sealed class AlgoliaJob
{ {
public string AppId { get; set; } public string AppId { get; set; }
public string ApiKey { get; set; } public string ApiKey { get; set; }
public string ContentId { get; set; } public string ContentId { get; set; }
public string IndexName { get; set; } public string IndexName { get; set; }
public string Content { get; set; } public string Content { get; set; }
}
} }

11
backend/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaPlugin.cs

@ -9,13 +9,12 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.Algolia namespace Squidex.Extensions.Actions.Algolia;
public sealed class AlgoliaPlugin : IPlugin
{ {
public sealed class AlgoliaPlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) services.AddRuleAction<AlgoliaAction, AlgoliaActionHandler>();
{
services.AddRuleAction<AlgoliaAction, AlgoliaActionHandler>();
}
} }
} }

57
backend/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueAction.cs

@ -11,40 +11,39 @@ using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.AzureQueue namespace Squidex.Extensions.Actions.AzureQueue;
[RuleAction(
Title = "Azure Queue",
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M.011 16L0 6.248l12-1.63V16zM14 4.328L29.996 2v14H14zM30 18l-.004 14L14 29.75V18zM12 29.495L.01 27.851.009 18H12z'/></svg>",
IconColor = "#0d9bf9",
Display = "Send to Azure Queue",
Description = "Send an event to azure queue storage.",
ReadMore = "https://azure.microsoft.com/en-us/services/storage/queues/")]
public sealed record AzureQueueAction : RuleAction
{ {
[RuleAction( [LocalizedRequired]
Title = "Azure Queue", [Display(Name = "Connection", Description = "The connection string to the storage account.")]
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M.011 16L0 6.248l12-1.63V16zM14 4.328L29.996 2v14H14zM30 18l-.004 14L14 29.75V18zM12 29.495L.01 27.851.009 18H12z'/></svg>", [Editor(RuleFieldEditor.Text)]
IconColor = "#0d9bf9", [Formattable]
Display = "Send to Azure Queue", public string ConnectionString { get; set; }
Description = "Send an event to azure queue storage.",
ReadMore = "https://azure.microsoft.com/en-us/services/storage/queues/")]
public sealed record AzureQueueAction : RuleAction
{
[LocalizedRequired]
[Display(Name = "Connection", Description = "The connection string to the storage account.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string ConnectionString { get; set; }
[LocalizedRequired] [LocalizedRequired]
[Display(Name = "Queue", Description = "The name of the queue.")] [Display(Name = "Queue", Description = "The name of the queue.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
[Formattable] [Formattable]
public string Queue { get; set; } public string Queue { get; set; }
[Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")] [Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")]
[Editor(RuleFieldEditor.TextArea)] [Editor(RuleFieldEditor.TextArea)]
[Formattable] [Formattable]
public string Payload { get; set; } public string Payload { get; set; }
protected override IEnumerable<ValidationError> CustomValidate() protected override IEnumerable<ValidationError> CustomValidate()
{
if (!string.IsNullOrWhiteSpace(Queue) && !Regex.IsMatch(Queue, "^[a-z][a-z0-9]{2,}(\\-[a-z0-9]+)*$"))
{ {
if (!string.IsNullOrWhiteSpace(Queue) && !Regex.IsMatch(Queue, "^[a-z][a-z0-9]{2,}(\\-[a-z0-9]+)*$")) yield return new ValidationError("Queue must be valid azure queue name.", nameof(Queue));
{
yield return new ValidationError("Queue must be valid azure queue name.", nameof(Queue));
}
} }
} }
} }

95
backend/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueActionHandler.cs

@ -12,69 +12,68 @@ using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.AzureQueue namespace Squidex.Extensions.Actions.AzureQueue;
public sealed class AzureQueueActionHandler : RuleActionHandler<AzureQueueAction, AzureQueueJob>
{ {
public sealed class AzureQueueActionHandler : RuleActionHandler<AzureQueueAction, AzureQueueJob> private readonly ClientPool<(string ConnectionString, string QueueName), CloudQueue> clients;
{
private readonly ClientPool<(string ConnectionString, string QueueName), CloudQueue> clients;
public AzureQueueActionHandler(RuleEventFormatter formatter) public AzureQueueActionHandler(RuleEventFormatter formatter)
: base(formatter) : base(formatter)
{
clients = new ClientPool<(string ConnectionString, string QueueName), CloudQueue>(key =>
{ {
clients = new ClientPool<(string ConnectionString, string QueueName), CloudQueue>(key => var storageAccount = CloudStorageAccount.Parse(key.ConnectionString);
{
var storageAccount = CloudStorageAccount.Parse(key.ConnectionString);
var queueClient = storageAccount.CreateCloudQueueClient(); var queueClient = storageAccount.CreateCloudQueueClient();
var queueRef = queueClient.GetQueueReference(key.QueueName); var queueRef = queueClient.GetQueueReference(key.QueueName);
return queueRef; return queueRef;
}); });
} }
protected override async Task<(string Description, AzureQueueJob Data)> CreateJobAsync(EnrichedEvent @event, AzureQueueAction action) protected override async Task<(string Description, AzureQueueJob Data)> CreateJobAsync(EnrichedEvent @event, AzureQueueAction action)
{
var queueName = await FormatAsync(action.Queue, @event);
string requestBody;
if (!string.IsNullOrEmpty(action.Payload))
{ {
var queueName = await FormatAsync(action.Queue, @event); requestBody = await FormatAsync(action.Payload, @event);
string requestBody;
if (!string.IsNullOrEmpty(action.Payload))
{
requestBody = await FormatAsync(action.Payload, @event);
}
else
{
requestBody = ToEnvelopeJson(@event);
}
var ruleDescription = $"Send AzureQueueJob to azure queue '{queueName}'";
var ruleJob = new AzureQueueJob
{
QueueConnectionString = action.ConnectionString,
QueueName = queueName,
MessageBodyV2 = requestBody
};
return (ruleDescription, ruleJob);
} }
else
protected override async Task<Result> ExecuteJobAsync(AzureQueueJob job,
CancellationToken ct = default)
{ {
var queue = await clients.GetClientAsync((job.QueueConnectionString, job.QueueName)); requestBody = ToEnvelopeJson(@event);
}
await queue.AddMessageAsync(new CloudQueueMessage(job.MessageBodyV2), null, null, null, null, ct); var ruleDescription = $"Send AzureQueueJob to azure queue '{queueName}'";
var ruleJob = new AzureQueueJob
{
QueueConnectionString = action.ConnectionString,
QueueName = queueName,
MessageBodyV2 = requestBody
};
return Result.Complete(); return (ruleDescription, ruleJob);
}
} }
public sealed class AzureQueueJob protected override async Task<Result> ExecuteJobAsync(AzureQueueJob job,
CancellationToken ct = default)
{ {
public string QueueConnectionString { get; set; } var queue = await clients.GetClientAsync((job.QueueConnectionString, job.QueueName));
public string QueueName { get; set; } await queue.AddMessageAsync(new CloudQueueMessage(job.MessageBodyV2), null, null, null, null, ct);
public string MessageBodyV2 { get; set; } return Result.Complete();
} }
} }
public sealed class AzureQueueJob
{
public string QueueConnectionString { get; set; }
public string QueueName { get; set; }
public string MessageBodyV2 { get; set; }
}

11
backend/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueuePlugin.cs

@ -9,13 +9,12 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.AzureQueue namespace Squidex.Extensions.Actions.AzureQueue;
public sealed class AzureQueuePlugin : IPlugin
{ {
public sealed class AzureQueuePlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) services.AddRuleAction<AzureQueueAction, AzureQueueActionHandler>();
{
services.AddRuleAction<AzureQueueAction, AzureQueueActionHandler>();
}
} }
} }

43
backend/extensions/Squidex.Extensions/Actions/ClientPool.cs

@ -10,34 +10,33 @@ using Microsoft.Extensions.Options;
#pragma warning disable RECS0108 // Warns about static fields in generic types #pragma warning disable RECS0108 // Warns about static fields in generic types
namespace Squidex.Extensions.Actions namespace Squidex.Extensions.Actions;
internal sealed class ClientPool<TKey, TClient>
{ {
internal sealed class ClientPool<TKey, TClient> private static readonly TimeSpan TimeToLive = TimeSpan.FromMinutes(30);
{ private readonly MemoryCache memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private static readonly TimeSpan TimeToLive = TimeSpan.FromMinutes(30); private readonly Func<TKey, Task<TClient>> factory;
private readonly MemoryCache memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private readonly Func<TKey, Task<TClient>> factory;
public ClientPool(Func<TKey, TClient> factory) public ClientPool(Func<TKey, TClient> factory)
{ {
this.factory = x => Task.FromResult(factory(x)); this.factory = x => Task.FromResult(factory(x));
} }
public ClientPool(Func<TKey, Task<TClient>> factory) public ClientPool(Func<TKey, Task<TClient>> factory)
{ {
this.factory = factory; this.factory = factory;
} }
public async Task<TClient> GetClientAsync(TKey key) public async Task<TClient> GetClientAsync(TKey key)
{
if (!memoryCache.TryGetValue<TClient>(key, out var client))
{ {
if (!memoryCache.TryGetValue<TClient>(key, out var client)) client = await factory(key);
{
client = await factory(key);
memoryCache.Set(key, client, TimeToLive);
}
return client; memoryCache.Set(key, client, TimeToLive);
} }
return client;
} }
} }

35
backend/extensions/Squidex.Extensions/Actions/Comment/CommentAction.cs

@ -10,24 +10,23 @@ using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Comment namespace Squidex.Extensions.Actions.Comment;
[RuleAction(
Title = "Comment",
IconImage = "<svg version='1.1' xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><path d='M20.016 15.984v-12h-16.031v14.016l2.016-2.016h14.016zM20.016 2.016c1.078 0 1.969 0.891 1.969 1.969v12c0 1.078-0.891 2.016-1.969 2.016h-14.016l-3.984 3.984v-18c0-1.078 0.891-1.969 1.969-1.969h16.031z'></path></svg>",
IconColor = "#3389ff",
Display = "Create comment",
Description = "Create a comment for a content event.")]
public sealed record CommentAction : RuleAction
{ {
[RuleAction( [LocalizedRequired]
Title = "Comment", [Display(Name = "Text", Description = "The comment text.")]
IconImage = "<svg version='1.1' xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><path d='M20.016 15.984v-12h-16.031v14.016l2.016-2.016h14.016zM20.016 2.016c1.078 0 1.969 0.891 1.969 1.969v12c0 1.078-0.891 2.016-1.969 2.016h-14.016l-3.984 3.984v-18c0-1.078 0.891-1.969 1.969-1.969h16.031z'></path></svg>", [Editor(RuleFieldEditor.TextArea)]
IconColor = "#3389ff", [Formattable]
Display = "Create comment", public string Text { get; set; }
Description = "Create a comment for a content event.")]
public sealed record CommentAction : RuleAction
{
[LocalizedRequired]
[Display(Name = "Text", Description = "The comment text.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Text { get; set; }
[Display(Name = "Client", Description = "An optional client name.")] [Display(Name = "Client", Description = "An optional client name.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
public string Client { get; set; } public string Client { get; set; }
}
} }

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

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

11
backend/extensions/Squidex.Extensions/Actions/Comment/CommentPlugin.cs

@ -9,13 +9,12 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.Comment namespace Squidex.Extensions.Actions.Comment;
public sealed class CommentPlugin : IPlugin
{ {
public sealed class CommentPlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) services.AddRuleAction<CommentAction, CommentActionHandler>();
{
services.AddRuleAction<CommentAction, CommentActionHandler>();
}
} }
} }

49
backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentAction.cs

@ -10,33 +10,32 @@ using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.CreateContent namespace Squidex.Extensions.Actions.CreateContent;
[RuleAction(
Title = "CreateContent",
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 28 28'><path d='M21.875 28H6.125A6.087 6.087 0 010 21.875V6.125A6.087 6.087 0 016.125 0h15.75A6.087 6.087 0 0128 6.125v15.75A6.088 6.088 0 0121.875 28zM6.125 1.75A4.333 4.333 0 001.75 6.125v15.75a4.333 4.333 0 004.375 4.375h15.75a4.333 4.333 0 004.375-4.375V6.125a4.333 4.333 0 00-4.375-4.375H6.125z'/><path d='M13.125 12.25H7.35c-1.575 0-2.888-1.313-2.888-2.888V7.349c0-1.575 1.313-2.888 2.888-2.888h5.775c1.575 0 2.887 1.313 2.887 2.888v2.013c0 1.575-1.312 2.888-2.887 2.888zM7.35 6.212c-.613 0-1.138.525-1.138 1.138v2.012A1.16 1.16 0 007.35 10.5h5.775a1.16 1.16 0 001.138-1.138V7.349a1.16 1.16 0 00-1.138-1.138H7.35zM22.662 16.713H5.337c-.525 0-.875-.35-.875-.875s.35-.875.875-.875h17.237c.525 0 .875.35.875.875s-.35.875-.787.875zM15.138 21.262h-9.8c-.525 0-.875-.35-.875-.875s.35-.875.875-.875h9.713c.525 0 .875.35.875.875s-.35.875-.787.875z'/></svg>",
IconColor = "#3389ff",
Display = "Create content",
Description = "Create a a new content item for any schema.")]
public sealed record CreateContentAction : RuleAction
{ {
[RuleAction( [LocalizedRequired]
Title = "CreateContent", [Display(Name = "Data", Description = "The content data.")]
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 28 28'><path d='M21.875 28H6.125A6.087 6.087 0 010 21.875V6.125A6.087 6.087 0 016.125 0h15.75A6.087 6.087 0 0128 6.125v15.75A6.088 6.088 0 0121.875 28zM6.125 1.75A4.333 4.333 0 001.75 6.125v15.75a4.333 4.333 0 004.375 4.375h15.75a4.333 4.333 0 004.375-4.375V6.125a4.333 4.333 0 00-4.375-4.375H6.125z'/><path d='M13.125 12.25H7.35c-1.575 0-2.888-1.313-2.888-2.888V7.349c0-1.575 1.313-2.888 2.888-2.888h5.775c1.575 0 2.887 1.313 2.887 2.888v2.013c0 1.575-1.312 2.888-2.887 2.888zM7.35 6.212c-.613 0-1.138.525-1.138 1.138v2.012A1.16 1.16 0 007.35 10.5h5.775a1.16 1.16 0 001.138-1.138V7.349a1.16 1.16 0 00-1.138-1.138H7.35zM22.662 16.713H5.337c-.525 0-.875-.35-.875-.875s.35-.875.875-.875h17.237c.525 0 .875.35.875.875s-.35.875-.787.875zM15.138 21.262h-9.8c-.525 0-.875-.35-.875-.875s.35-.875.875-.875h9.713c.525 0 .875.35.875.875s-.35.875-.787.875z'/></svg>", [Editor(RuleFieldEditor.TextArea)]
IconColor = "#3389ff", [Formattable]
Display = "Create content", public string Data { get; set; }
Description = "Create a a new content item for any schema.")]
public sealed record CreateContentAction : RuleAction
{
[LocalizedRequired]
[Display(Name = "Data", Description = "The content data.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Data { get; set; }
[LocalizedRequired] [LocalizedRequired]
[Display(Name = "Schema", Description = "The name of the schema.")] [Display(Name = "Schema", Description = "The name of the schema.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
public string Schema { get; set; } public string Schema { get; set; }
[Display(Name = "Client", Description = "An optional client name.")] [Display(Name = "Client", Description = "An optional client name.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
public string Client { get; set; } public string Client { get; set; }
[Display(Name = "Publish", Description = "Publish the content.")] [Display(Name = "Publish", Description = "Publish the content.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
public bool Publish { get; set; } public bool Publish { get; set; }
}
} }

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

@ -15,70 +15,69 @@ using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Json;
using Command = Squidex.Domain.Apps.Entities.Contents.Commands.CreateContent; using Command = Squidex.Domain.Apps.Entities.Contents.Commands.CreateContent;
namespace Squidex.Extensions.Actions.CreateContent namespace Squidex.Extensions.Actions.CreateContent;
public sealed class CreateContentActionHandler : RuleActionHandler<CreateContentAction, Command>
{ {
public sealed class CreateContentActionHandler : RuleActionHandler<CreateContentAction, Command> private const string Description = "Create a content";
{ private readonly ICommandBus commandBus;
private const string Description = "Create a content"; private readonly IAppProvider appProvider;
private readonly ICommandBus commandBus; private readonly IJsonSerializer jsonSerializer;
private readonly IAppProvider appProvider;
private readonly IJsonSerializer jsonSerializer;
public CreateContentActionHandler(RuleEventFormatter formatter, IAppProvider appProvider, ICommandBus commandBus, IJsonSerializer jsonSerializer) public CreateContentActionHandler(RuleEventFormatter formatter, IAppProvider appProvider, ICommandBus commandBus, IJsonSerializer jsonSerializer)
: base(formatter) : base(formatter)
{ {
this.appProvider = appProvider; this.appProvider = appProvider;
this.commandBus = commandBus; this.commandBus = commandBus;
this.jsonSerializer = jsonSerializer; this.jsonSerializer = jsonSerializer;
} }
protected override async Task<(string Description, Command Data)> CreateJobAsync(EnrichedEvent @event, CreateContentAction action) protected override async Task<(string Description, Command Data)> CreateJobAsync(EnrichedEvent @event, CreateContentAction action)
{
var ruleJob = new Command
{ {
var ruleJob = new Command AppId = @event.AppId
{ };
AppId = @event.AppId
};
var schema = await appProvider.GetSchemaAsync(@event.AppId.Id, action.Schema, true);
if (schema == null) var schema = await appProvider.GetSchemaAsync(@event.AppId.Id, action.Schema, true);
{
throw new InvalidOperationException($"Cannot find schema '{action.Schema}'");
}
ruleJob.SchemaId = schema.NamedId(); if (schema == null)
{
var json = await FormatAsync(action.Data, @event); throw new InvalidOperationException($"Cannot find schema '{action.Schema}'");
}
ruleJob.Data = jsonSerializer.Deserialize<ContentData>(json); ruleJob.SchemaId = schema.NamedId();
if (!string.IsNullOrEmpty(action.Client)) var json = await FormatAsync(action.Data, @event);
{
ruleJob.Actor = RefToken.Client(action.Client);
}
else if (@event is EnrichedUserEventBase userEvent)
{
ruleJob.Actor = userEvent.Actor;
}
if (action.Publish) ruleJob.Data = jsonSerializer.Deserialize<ContentData>(json);
{
ruleJob.Status = Status.Published;
}
return (Description, ruleJob); if (!string.IsNullOrEmpty(action.Client))
{
ruleJob.Actor = RefToken.Client(action.Client);
}
else if (@event is EnrichedUserEventBase userEvent)
{
ruleJob.Actor = userEvent.Actor;
} }
protected override async Task<Result> ExecuteJobAsync(Command job, if (action.Publish)
CancellationToken ct = default)
{ {
var command = job; ruleJob.Status = Status.Published;
}
command.FromRule = true; return (Description, ruleJob);
}
await commandBus.PublishAsync(command, ct); protected override async Task<Result> ExecuteJobAsync(Command job,
CancellationToken ct = default)
{
var command = job;
return Result.Success($"Created to: {command.SchemaId.Name}"); command.FromRule = true;
}
await commandBus.PublishAsync(command, ct);
return Result.Success($"Created to: {command.SchemaId.Name}");
} }
} }

11
backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentPlugin.cs

@ -9,13 +9,12 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.CreateContent namespace Squidex.Extensions.Actions.CreateContent;
public sealed class CreateContentPlugin : IPlugin
{ {
public sealed class CreateContentPlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) services.AddRuleAction<CreateContentAction, CreateContentActionHandler>();
{
services.AddRuleAction<CreateContentAction, CreateContentActionHandler>();
}
} }
} }

89
backend/extensions/Squidex.Extensions/Actions/Discourse/DiscourseAction.cs

@ -10,50 +10,49 @@ using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Discourse namespace Squidex.Extensions.Actions.Discourse;
[RuleAction(
Title = "Discourse",
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M16.137 0C7.376 0 0 7.037 0 15.721V32l16.134-.016C24.895 31.984 32 24.676 32 15.995S24.888 0 16.137 0zm.336 6.062a9.862 9.862 0 0 1 5.119 1.555l-.038-.023a.747.747 0 0 1 .05.033l-.033-.021c.288.183.529.353.762.534l-.022-.016c.058.044.094.073.131.103l-.018-.014c.218.174.411.34.597.514l-.005-.005a9.48 9.48 0 0 1 .639.655l.009.01c.073.082.154.176.233.272l.014.018c.053.06.116.133.177.206l.013.017-.052-.047-.008-.007c.104.126.218.273.328.423l.02.028.001.001-.001-.001c-.01-.018.005.005.019.028l.024.042c.145.206.301.451.445.704l.025.048c.131.226.273.51.402.801l.025.063a9.504 9.504 0 0 1 .802 3.853c0 5.38-4.401 9.741-9.831 9.741a9.866 9.866 0 0 1-4.106-.888l.061.025-6.39 1.43 1.78-5.672a7.888 7.888 0 0 1-.293-.584l-.025-.061a8.226 8.226 0 0 1-.254-.617l-.022-.068A1.043 1.043 0 0 1 7 19.017l-.022-.067a8.428 8.428 0 0 1-.246-.829l-.014-.067a9.402 9.402 0 0 1-.265-2.248c0-5.381 4.403-9.744 9.834-9.744l.194.002h-.01z'/></svg>",
IconColor = "#eB6121",
Display = "Post to discourse",
Description = "Create a post or topic at discourse.",
ReadMore = "https://www.discourse.org/")]
public sealed record DiscourseAction : RuleAction
{ {
[RuleAction( [AbsoluteUrl]
Title = "Discourse", [LocalizedRequired]
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M16.137 0C7.376 0 0 7.037 0 15.721V32l16.134-.016C24.895 31.984 32 24.676 32 15.995S24.888 0 16.137 0zm.336 6.062a9.862 9.862 0 0 1 5.119 1.555l-.038-.023a.747.747 0 0 1 .05.033l-.033-.021c.288.183.529.353.762.534l-.022-.016c.058.044.094.073.131.103l-.018-.014c.218.174.411.34.597.514l-.005-.005a9.48 9.48 0 0 1 .639.655l.009.01c.073.082.154.176.233.272l.014.018c.053.06.116.133.177.206l.013.017-.052-.047-.008-.007c.104.126.218.273.328.423l.02.028.001.001-.001-.001c-.01-.018.005.005.019.028l.024.042c.145.206.301.451.445.704l.025.048c.131.226.273.51.402.801l.025.063a9.504 9.504 0 0 1 .802 3.853c0 5.38-4.401 9.741-9.831 9.741a9.866 9.866 0 0 1-4.106-.888l.061.025-6.39 1.43 1.78-5.672a7.888 7.888 0 0 1-.293-.584l-.025-.061a8.226 8.226 0 0 1-.254-.617l-.022-.068A1.043 1.043 0 0 1 7 19.017l-.022-.067a8.428 8.428 0 0 1-.246-.829l-.014-.067a9.402 9.402 0 0 1-.265-2.248c0-5.381 4.403-9.744 9.834-9.744l.194.002h-.01z'/></svg>", [Display(Name = "Server Url", Description = "The url to the discourse server.")]
IconColor = "#eB6121", [Editor(RuleFieldEditor.Url)]
Display = "Post to discourse", public Uri Url { get; set; }
Description = "Create a post or topic at discourse.",
ReadMore = "https://www.discourse.org/")] [LocalizedRequired]
public sealed record DiscourseAction : RuleAction [Display(Name = "Api Key", Description = "The api key to authenticate to your discourse server.")]
{ [Editor(RuleFieldEditor.Text)]
[AbsoluteUrl] public string ApiKey { get; set; }
[LocalizedRequired]
[Display(Name = "Server Url", Description = "The url to the discourse server.")] [LocalizedRequired]
[Editor(RuleFieldEditor.Url)] [Display(Name = "Api User", Description = "The api username to authenticate to your discourse server.")]
public Uri Url { get; set; } [Editor(RuleFieldEditor.Text)]
public string ApiUsername { get; set; }
[LocalizedRequired]
[Display(Name = "Api Key", Description = "The api key to authenticate to your discourse server.")] [LocalizedRequired]
[Editor(RuleFieldEditor.Text)] [Display(Name = "Text", Description = "The text as markdown.")]
public string ApiKey { get; set; } [Editor(RuleFieldEditor.TextArea)]
[Formattable]
[LocalizedRequired] public string Text { get; set; }
[Display(Name = "Api User", Description = "The api username to authenticate to your discourse server.")]
[Editor(RuleFieldEditor.Text)] [Display(Name = "Title", Description = "The optional title when creating new topics.")]
public string ApiUsername { get; set; } [Editor(RuleFieldEditor.Text)]
[Formattable]
[LocalizedRequired] public string Title { get; set; }
[Display(Name = "Text", Description = "The text as markdown.")]
[Editor(RuleFieldEditor.TextArea)] [Display(Name = "Topic", Description = "The optional topic id.")]
[Formattable] [Editor(RuleFieldEditor.Text)]
public string Text { get; set; } public int? Topic { get; set; }
[Display(Name = "Title", Description = "The optional title when creating new topics.")] [Display(Name = "Category", Description = "The optional category id.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
[Formattable] public int? Category { get; set; }
public string Title { get; set; }
[Display(Name = "Topic", Description = "The optional topic id.")]
[Editor(RuleFieldEditor.Text)]
public int? Topic { get; set; }
[Display(Name = "Category", Description = "The optional category id.")]
[Editor(RuleFieldEditor.Text)]
public int? Category { get; set; }
}
} }

123
backend/extensions/Squidex.Extensions/Actions/Discourse/DiscourseActionHandler.cs

@ -11,87 +11,86 @@ using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Discourse namespace Squidex.Extensions.Actions.Discourse;
public sealed class DiscourseActionHandler : RuleActionHandler<DiscourseAction, DiscourseJob>
{ {
public sealed class DiscourseActionHandler : RuleActionHandler<DiscourseAction, DiscourseJob> private const string DescriptionCreatePost = "Create discourse Post";
private const string DescriptionCreateTopic = "Create discourse Topic";
private readonly IHttpClientFactory httpClientFactory;
public DiscourseActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory)
: base(formatter)
{ {
private const string DescriptionCreatePost = "Create discourse Post"; this.httpClientFactory = httpClientFactory;
private const string DescriptionCreateTopic = "Create discourse Topic"; }
private readonly IHttpClientFactory httpClientFactory; protected override async Task<(string Description, DiscourseJob Data)> CreateJobAsync(EnrichedEvent @event, DiscourseAction action)
{
var url = $"{action.Url.ToString().TrimEnd('/')}/posts.json?api_key={action.ApiKey}&api_username={action.ApiUsername}";
public DiscourseActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory) var json = new Dictionary<string, object>
: base(formatter)
{ {
this.httpClientFactory = httpClientFactory; ["title"] = await FormatAsync(action.Title, @event)
} };
protected override async Task<(string Description, DiscourseJob Data)> CreateJobAsync(EnrichedEvent @event, DiscourseAction action) if (action.Topic != null)
{ {
var url = $"{action.Url.ToString().TrimEnd('/')}/posts.json?api_key={action.ApiKey}&api_username={action.ApiUsername}"; json.Add("topic_id", action.Topic.Value);
}
var json = new Dictionary<string, object>
{
["title"] = await FormatAsync(action.Title, @event)
};
if (action.Topic != null)
{
json.Add("topic_id", action.Topic.Value);
}
if (action.Category != null) if (action.Category != null)
{ {
json.Add("category", action.Category.Value); json.Add("category", action.Category.Value);
} }
json["raw"] = await FormatAsync(action.Text, @event); json["raw"] = await FormatAsync(action.Text, @event);
var requestBody = ToJson(json); var requestBody = ToJson(json);
var ruleJob = new DiscourseJob var ruleJob = new DiscourseJob
{ {
ApiKey = action.ApiKey, ApiKey = action.ApiKey,
ApiUserName = action.ApiUsername, ApiUserName = action.ApiUsername,
RequestUrl = url, RequestUrl = url,
RequestBody = requestBody RequestBody = requestBody
}; };
var description = var description =
action.Topic != null ? action.Topic != null ?
DescriptionCreateTopic : DescriptionCreateTopic :
DescriptionCreatePost; DescriptionCreatePost;
return (description, ruleJob); return (description, ruleJob);
} }
protected override async Task<Result> ExecuteJobAsync(DiscourseJob job, protected override async Task<Result> ExecuteJobAsync(DiscourseJob job,
CancellationToken ct = default) CancellationToken ct = default)
{
using (var httpClient = httpClientFactory.CreateClient())
{ {
using (var httpClient = httpClientFactory.CreateClient()) using (var request = new HttpRequestMessage(HttpMethod.Post, job.RequestUrl)
{ {
using (var request = new HttpRequestMessage(HttpMethod.Post, job.RequestUrl) Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json")
{ })
Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json") {
}) request.Headers.TryAddWithoutValidation("Api-Key", job.ApiKey);
{ request.Headers.TryAddWithoutValidation("Api-Username", job.ApiUserName);
request.Headers.TryAddWithoutValidation("Api-Key", job.ApiKey);
request.Headers.TryAddWithoutValidation("Api-Username", job.ApiUserName); return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct);
return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct);
}
} }
} }
} }
}
public sealed class DiscourseJob public sealed class DiscourseJob
{ {
public string ApiKey { get; set; } public string ApiKey { get; set; }
public string ApiUserName { get; set; } public string ApiUserName { get; set; }
public string RequestUrl { get; set; } public string RequestUrl { get; set; }
public string RequestBody { get; set; } public string RequestBody { get; set; }
}
} }

11
backend/extensions/Squidex.Extensions/Actions/Discourse/DiscoursePlugin.cs

@ -9,13 +9,12 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.Discourse namespace Squidex.Extensions.Actions.Discourse;
public sealed class DiscoursePlugin : IPlugin
{ {
public sealed class DiscoursePlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) services.AddRuleAction<DiscourseAction, DiscourseActionHandler>();
{
services.AddRuleAction<DiscourseAction, DiscourseActionHandler>();
}
} }
} }

77
backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchAction.cs

@ -10,44 +10,43 @@ using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.ElasticSearch namespace Squidex.Extensions.Actions.ElasticSearch;
[RuleAction(
Title = "ElasticSearch",
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 29 28'><path d='M13.427 17.436H4.163C3.827 16.354 3.636 15.2 3.636 14s.182-2.355.527-3.436h15.245c1.891 0 3.418 1.545 3.418 3.445a3.421 3.421 0 0 1-3.418 3.427h-5.982zm-.436 1.146H4.6a11.508 11.508 0 0 0 4.2 4.982 11.443 11.443 0 0 0 15.827-3.209 5.793 5.793 0 0 0-4.173-1.773H12.99zm7.464-9.164a5.794 5.794 0 0 0 4.173-1.773 11.45 11.45 0 0 0-9.536-5.1c-2.327 0-4.491.7-6.3 1.891a11.554 11.554 0 0 0-4.2 4.982h15.864z'/></svg>",
IconColor = "#1e5470",
Display = "Populate ElasticSearch index",
Description = "Populate a full text search index in ElasticSearch.",
ReadMore = "https://www.elastic.co/")]
public sealed record ElasticSearchAction : RuleAction
{ {
[RuleAction( [AbsoluteUrl]
Title = "ElasticSearch", [LocalizedRequired]
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 29 28'><path d='M13.427 17.436H4.163C3.827 16.354 3.636 15.2 3.636 14s.182-2.355.527-3.436h15.245c1.891 0 3.418 1.545 3.418 3.445a3.421 3.421 0 0 1-3.418 3.427h-5.982zm-.436 1.146H4.6a11.508 11.508 0 0 0 4.2 4.982 11.443 11.443 0 0 0 15.827-3.209 5.793 5.793 0 0 0-4.173-1.773H12.99zm7.464-9.164a5.794 5.794 0 0 0 4.173-1.773 11.45 11.45 0 0 0-9.536-5.1c-2.327 0-4.491.7-6.3 1.891a11.554 11.554 0 0 0-4.2 4.982h15.864z'/></svg>", [Display(Name = "Server Url", Description = "The url to the instance or cluster.")]
IconColor = "#1e5470", [Editor(RuleFieldEditor.Url)]
Display = "Populate ElasticSearch index", public Uri Host { get; set; }
Description = "Populate a full text search index in ElasticSearch.",
ReadMore = "https://www.elastic.co/")] [LocalizedRequired]
public sealed record ElasticSearchAction : RuleAction [Display(Name = "Index Name", Description = "The name of the index.")]
{ [Editor(RuleFieldEditor.Text)]
[AbsoluteUrl] [Formattable]
[LocalizedRequired] public string IndexName { get; set; }
[Display(Name = "Server Url", Description = "The url to the instance or cluster.")]
[Editor(RuleFieldEditor.Url)] [Display(Name = "Username", Description = "The optional username.")]
public Uri Host { get; set; } [Editor(RuleFieldEditor.Text)]
public string Username { get; set; }
[LocalizedRequired]
[Display(Name = "Index Name", Description = "The name of the index.")] [Display(Name = "Password", Description = "The optional password.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
[Formattable] public string Password { get; set; }
public string IndexName { get; set; }
[Display(Name = "Document", Description = "The optional custom document.")]
[Display(Name = "Username", Description = "The optional username.")] [Editor(RuleFieldEditor.TextArea)]
[Editor(RuleFieldEditor.Text)] [Formattable]
public string Username { get; set; } public string Document { get; set; }
[Display(Name = "Password", Description = "The optional password.")] [Display(Name = "Deletion", Description = "The condition when to delete the document.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
public string Password { get; set; } public string Delete { get; set; }
[Display(Name = "Document", Description = "The optional custom document.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Document { get; set; }
[Display(Name = "Deletion", Description = "The condition when to delete the document.")]
[Editor(RuleFieldEditor.Text)]
public string Delete { get; set; }
}
} }

219
backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchActionHandler.cs

@ -16,154 +16,153 @@ using Squidex.Infrastructure.Json;
#pragma warning disable IDE0059 // Value assigned to symbol is never used #pragma warning disable IDE0059 // Value assigned to symbol is never used
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.ElasticSearch namespace Squidex.Extensions.Actions.ElasticSearch;
public sealed class ElasticSearchActionHandler : RuleActionHandler<ElasticSearchAction, ElasticSearchJob>
{ {
public sealed class ElasticSearchActionHandler : RuleActionHandler<ElasticSearchAction, ElasticSearchJob> private readonly ClientPool<(Uri Host, string Username, string Password), ElasticLowLevelClient> clients;
{ private readonly IScriptEngine scriptEngine;
private readonly ClientPool<(Uri Host, string Username, string Password), ElasticLowLevelClient> clients; private readonly IJsonSerializer serializer;
private readonly IScriptEngine scriptEngine;
private readonly IJsonSerializer serializer;
public ElasticSearchActionHandler(RuleEventFormatter formatter, IScriptEngine scriptEngine, IJsonSerializer serializer) public ElasticSearchActionHandler(RuleEventFormatter formatter, IScriptEngine scriptEngine, IJsonSerializer serializer)
: base(formatter) : base(formatter)
{
clients = new ClientPool<(Uri Host, string Username, string Password), ElasticLowLevelClient>(key =>
{ {
clients = new ClientPool<(Uri Host, string Username, string Password), ElasticLowLevelClient>(key => var config = new ConnectionConfiguration(key.Host);
if (!string.IsNullOrEmpty(key.Username) && !string.IsNullOrWhiteSpace(key.Password))
{ {
var config = new ConnectionConfiguration(key.Host); config = config.BasicAuthentication(key.Username, key.Password);
}
if (!string.IsNullOrEmpty(key.Username) && !string.IsNullOrWhiteSpace(key.Password)) return new ElasticLowLevelClient(config);
{ });
config = config.BasicAuthentication(key.Username, key.Password);
}
return new ElasticLowLevelClient(config); this.scriptEngine = scriptEngine;
}); this.serializer = serializer;
}
this.scriptEngine = scriptEngine; protected override async Task<(string Description, ElasticSearchJob Data)> CreateJobAsync(EnrichedEvent @event, ElasticSearchAction action)
this.serializer = serializer; {
} var delete = @event.ShouldDelete(scriptEngine, action.Delete);
protected override async Task<(string Description, ElasticSearchJob Data)> CreateJobAsync(EnrichedEvent @event, ElasticSearchAction action) string contentId;
{
var delete = @event.ShouldDelete(scriptEngine, action.Delete);
string contentId; if (@event is IEnrichedEntityEvent enrichedEntityEvent)
{
contentId = enrichedEntityEvent.Id.ToString();
}
else
{
contentId = DomainId.NewGuid().ToString();
}
if (@event is IEnrichedEntityEvent enrichedEntityEvent) var ruleDescription = string.Empty;
{ var ruleJob = new ElasticSearchJob
contentId = enrichedEntityEvent.Id.ToString(); {
} IndexName = await FormatAsync(action.IndexName, @event),
else ServerHost = action.Host.ToString(),
{ ServerUser = action.Username,
contentId = DomainId.NewGuid().ToString(); ServerPassword = action.Password,
} ContentId = contentId
};
if (delete)
{
ruleDescription = $"Delete entry index: {action.IndexName}";
}
else
{
ruleDescription = $"Upsert to index: {action.IndexName}";
var ruleDescription = string.Empty; ElasticSearchContent content;
var ruleJob = new ElasticSearchJob try
{
IndexName = await FormatAsync(action.IndexName, @event),
ServerHost = action.Host.ToString(),
ServerUser = action.Username,
ServerPassword = action.Password,
ContentId = contentId
};
if (delete)
{
ruleDescription = $"Delete entry index: {action.IndexName}";
}
else
{ {
ruleDescription = $"Upsert to index: {action.IndexName}"; string jsonString;
ElasticSearchContent content; if (!string.IsNullOrEmpty(action.Document))
try
{ {
string jsonString; jsonString = await FormatAsync(action.Document, @event);
jsonString = jsonString?.Trim();
if (!string.IsNullOrEmpty(action.Document))
{
jsonString = await FormatAsync(action.Document, @event);
jsonString = jsonString?.Trim();
}
else
{
jsonString = ToJson(@event);
}
content = serializer.Deserialize<ElasticSearchContent>(jsonString);
} }
catch (Exception ex) else
{ {
content = new ElasticSearchContent jsonString = ToJson(@event);
{
More = new Dictionary<string, object>
{
["error"] = $"Invalid JSON: {ex.Message}"
}
};
} }
content.ContentId = contentId; content = serializer.Deserialize<ElasticSearchContent>(jsonString);
ruleJob.Content = serializer.Serialize(content, true);
} }
catch (Exception ex)
{
content = new ElasticSearchContent
{
More = new Dictionary<string, object>
{
["error"] = $"Invalid JSON: {ex.Message}"
}
};
}
content.ContentId = contentId;
return (ruleDescription, ruleJob); ruleJob.Content = serializer.Serialize(content, true);
} }
protected override async Task<Result> ExecuteJobAsync(ElasticSearchJob job, return (ruleDescription, ruleJob);
CancellationToken ct = default) }
protected override async Task<Result> ExecuteJobAsync(ElasticSearchJob job,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(job.ServerHost))
{ {
if (string.IsNullOrWhiteSpace(job.ServerHost)) return Result.Ignored();
{ }
return Result.Ignored();
}
var client = await clients.GetClientAsync((new Uri(job.ServerHost, UriKind.Absolute), job.ServerUser, job.ServerPassword)); var client = await clients.GetClientAsync((new Uri(job.ServerHost, UriKind.Absolute), job.ServerUser, job.ServerPassword));
try try
{
if (job.Content != null)
{ {
if (job.Content != null) var response = await client.IndexAsync<StringResponse>(job.IndexName, job.ContentId, job.Content, ctx: ct);
{
var response = await client.IndexAsync<StringResponse>(job.IndexName, job.ContentId, job.Content, ctx: ct);
return Result.SuccessOrFailed(response.OriginalException, response.Body); return Result.SuccessOrFailed(response.OriginalException, response.Body);
}
else
{
var response = await client.DeleteAsync<StringResponse>(job.IndexName, job.ContentId, ctx: ct);
return Result.SuccessOrFailed(response.OriginalException, response.Body);
}
} }
catch (ElasticsearchClientException ex) else
{ {
return Result.Failed(ex); var response = await client.DeleteAsync<StringResponse>(job.IndexName, job.ContentId, ctx: ct);
return Result.SuccessOrFailed(response.OriginalException, response.Body);
} }
} }
catch (ElasticsearchClientException ex)
{
return Result.Failed(ex);
}
} }
}
public sealed class ElasticSearchContent public sealed class ElasticSearchContent
{ {
public string ContentId { get; set; } public string ContentId { get; set; }
[JsonExtensionData] [JsonExtensionData]
public Dictionary<string, object> More { get; set; } = new Dictionary<string, object>(); public Dictionary<string, object> More { get; set; } = new Dictionary<string, object>();
} }
public sealed class ElasticSearchJob public sealed class ElasticSearchJob
{ {
public string ServerHost { get; set; } public string ServerHost { get; set; }
public string ServerUser { get; set; } public string ServerUser { get; set; }
public string ServerPassword { get; set; } public string ServerPassword { get; set; }
public string ContentId { get; set; } public string ContentId { get; set; }
public string Content { get; set; } public string Content { get; set; }
public string IndexName { get; set; } public string IndexName { get; set; }
}
} }

11
backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchPlugin.cs

@ -9,13 +9,12 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.ElasticSearch namespace Squidex.Extensions.Actions.ElasticSearch;
public sealed class ElasticSearchPlugin : IPlugin
{ {
public sealed class ElasticSearchPlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) services.AddRuleAction<ElasticSearchAction, ElasticSearchActionHandler>();
{
services.AddRuleAction<ElasticSearchAction, ElasticSearchActionHandler>();
}
} }
} }

95
backend/extensions/Squidex.Extensions/Actions/Email/EmailAction.cs

@ -10,60 +10,59 @@ using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Email namespace Squidex.Extensions.Actions.Email;
[RuleAction(
Title = "Email",
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M28 5h-24c-2.209 0-4 1.792-4 4v13c0 2.209 1.791 4 4 4h24c2.209 0 4-1.791 4-4v-13c0-2.208-1.791-4-4-4zM2 10.25l6.999 5.25-6.999 5.25v-10.5zM30 22c0 1.104-0.898 2-2 2h-24c-1.103 0-2-0.896-2-2l7.832-5.875 4.368 3.277c0.533 0.398 1.166 0.6 1.8 0.6 0.633 0 1.266-0.201 1.799-0.6l4.369-3.277 7.832 5.875zM30 20.75l-7-5.25 7-5.25v10.5zM17.199 18.602c-0.349 0.262-0.763 0.4-1.199 0.4s-0.851-0.139-1.2-0.4l-12.8-9.602c0-1.103 0.897-2 2-2h24c1.102 0 2 0.897 2 2l-12.801 9.602z'/></svg>",
IconColor = "#333300",
Display = "Send an email",
Description = "Send an email with a custom SMTP server.",
ReadMore = "https://en.wikipedia.org/wiki/Email")]
public sealed record EmailAction : RuleAction
{ {
[RuleAction( [LocalizedRequired]
Title = "Email", [Display(Name = "Server Host", Description = "The IP address or host to the SMTP server.")]
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M28 5h-24c-2.209 0-4 1.792-4 4v13c0 2.209 1.791 4 4 4h24c2.209 0 4-1.791 4-4v-13c0-2.208-1.791-4-4-4zM2 10.25l6.999 5.25-6.999 5.25v-10.5zM30 22c0 1.104-0.898 2-2 2h-24c-1.103 0-2-0.896-2-2l7.832-5.875 4.368 3.277c0.533 0.398 1.166 0.6 1.8 0.6 0.633 0 1.266-0.201 1.799-0.6l4.369-3.277 7.832 5.875zM30 20.75l-7-5.25 7-5.25v10.5zM17.199 18.602c-0.349 0.262-0.763 0.4-1.199 0.4s-0.851-0.139-1.2-0.4l-12.8-9.602c0-1.103 0.897-2 2-2h24c1.102 0 2 0.897 2 2l-12.801 9.602z'/></svg>", [Editor(RuleFieldEditor.Text)]
IconColor = "#333300", public string ServerHost { get; set; }
Display = "Send an email",
Description = "Send an email with a custom SMTP server.",
ReadMore = "https://en.wikipedia.org/wiki/Email")]
public sealed record EmailAction : RuleAction
{
[LocalizedRequired]
[Display(Name = "Server Host", Description = "The IP address or host to the SMTP server.")]
[Editor(RuleFieldEditor.Text)]
public string ServerHost { get; set; }
[LocalizedRequired] [LocalizedRequired]
[Display(Name = "Server Port", Description = "The port to the SMTP server.")] [Display(Name = "Server Port", Description = "The port to the SMTP server.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
public int ServerPort { get; set; } public int ServerPort { get; set; }
[LocalizedRequired] [LocalizedRequired]
[Display(Name = "Username", Description = "The username for the SMTP server.")] [Display(Name = "Username", Description = "The username for the SMTP server.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
[Formattable] [Formattable]
public string ServerUsername { get; set; } public string ServerUsername { get; set; }
[LocalizedRequired] [LocalizedRequired]
[Display(Name = "Password", Description = "The password for the SMTP server.")] [Display(Name = "Password", Description = "The password for the SMTP server.")]
[Editor(RuleFieldEditor.Password)] [Editor(RuleFieldEditor.Password)]
public string ServerPassword { get; set; } public string ServerPassword { get; set; }
[LocalizedRequired] [LocalizedRequired]
[Display(Name = "From Address", Description = "The email sending address.")] [Display(Name = "From Address", Description = "The email sending address.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
[Formattable] [Formattable]
public string MessageFrom { get; set; } public string MessageFrom { get; set; }
[LocalizedRequired] [LocalizedRequired]
[Display(Name = "To Address", Description = "The email message will be sent to.")] [Display(Name = "To Address", Description = "The email message will be sent to.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
[Formattable] [Formattable]
public string MessageTo { get; set; } public string MessageTo { get; set; }
[LocalizedRequired] [LocalizedRequired]
[Display(Name = "Subject", Description = "The subject line for this email message.")] [Display(Name = "Subject", Description = "The subject line for this email message.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
[Formattable] [Formattable]
public string MessageSubject { get; set; } public string MessageSubject { get; set; }
[LocalizedRequired] [LocalizedRequired]
[Display(Name = "Body", Description = "The message body.")] [Display(Name = "Body", Description = "The message body.")]
[Editor(RuleFieldEditor.TextArea)] [Editor(RuleFieldEditor.TextArea)]
[Formattable] [Formattable]
public string MessageBody { get; set; } public string MessageBody { get; set; }
}
} }

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

@ -13,81 +13,80 @@ using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Email namespace Squidex.Extensions.Actions.Email;
public sealed class EmailActionHandler : RuleActionHandler<EmailAction, EmailJob>
{ {
public sealed class EmailActionHandler : RuleActionHandler<EmailAction, EmailJob> public EmailActionHandler(RuleEventFormatter formatter)
: base(formatter)
{ {
public EmailActionHandler(RuleEventFormatter formatter) }
: base(formatter)
{
}
protected override async Task<(string Description, EmailJob Data)> CreateJobAsync(EnrichedEvent @event, EmailAction action) protected override async Task<(string Description, EmailJob Data)> CreateJobAsync(EnrichedEvent @event, EmailAction action)
{
var ruleJob = new EmailJob
{ {
var ruleJob = new EmailJob ServerHost = action.ServerHost,
{ ServerPassword = action.ServerPassword,
ServerHost = action.ServerHost, ServerPort = action.ServerPort,
ServerPassword = action.ServerPassword, ServerUsername = await FormatAsync(action.ServerUsername, @event),
ServerPort = action.ServerPort, MessageFrom = await FormatAsync(action.MessageFrom, @event),
ServerUsername = await FormatAsync(action.ServerUsername, @event), MessageTo = await FormatAsync(action.MessageTo, @event),
MessageFrom = await FormatAsync(action.MessageFrom, @event), MessageSubject = await FormatAsync(action.MessageSubject, @event),
MessageTo = await FormatAsync(action.MessageTo, @event), MessageBody = await FormatAsync(action.MessageBody, @event)
MessageSubject = await FormatAsync(action.MessageSubject, @event), };
MessageBody = await FormatAsync(action.MessageBody, @event)
}; var description = $"Send an email to {action.MessageTo}";
var description = $"Send an email to {action.MessageTo}"; return (description, ruleJob);
}
return (description, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(EmailJob job, protected override async Task<Result> ExecuteJobAsync(EmailJob job,
CancellationToken ct = default) CancellationToken ct = default)
{
using (var smtpClient = new SmtpClient())
{ {
using (var smtpClient = new SmtpClient()) await smtpClient.ConnectAsync(job.ServerHost, job.ServerPort, cancellationToken: ct);
{
await smtpClient.ConnectAsync(job.ServerHost, job.ServerPort, cancellationToken: ct);
await smtpClient.AuthenticateAsync(job.ServerUsername, job.ServerPassword, ct);
var smtpMessage = new MimeMessage(); await smtpClient.AuthenticateAsync(job.ServerUsername, job.ServerPassword, ct);
smtpMessage.From.Add(MailboxAddress.Parse( var smtpMessage = new MimeMessage();
job.MessageFrom));
smtpMessage.To.Add(MailboxAddress.Parse( smtpMessage.From.Add(MailboxAddress.Parse(
job.MessageTo)); job.MessageFrom));
smtpMessage.Body = new TextPart(TextFormat.Html) smtpMessage.To.Add(MailboxAddress.Parse(
{ job.MessageTo));
Text = job.MessageBody
};
smtpMessage.Subject = job.MessageSubject; smtpMessage.Body = new TextPart(TextFormat.Html)
{
Text = job.MessageBody
};
await smtpClient.SendAsync(smtpMessage, ct); smtpMessage.Subject = job.MessageSubject;
}
return Result.Complete(); await smtpClient.SendAsync(smtpMessage, ct);
} }
return Result.Complete();
} }
}
public sealed class EmailJob public sealed class EmailJob
{ {
public int ServerPort { get; set; } public int ServerPort { get; set; }
public string ServerHost { get; set; } public string ServerHost { get; set; }
public string ServerUsername { get; set; } public string ServerUsername { get; set; }
public string ServerPassword { get; set; } public string ServerPassword { get; set; }
public string MessageFrom { get; set; } public string MessageFrom { get; set; }
public string MessageTo { get; set; } public string MessageTo { get; set; }
public string MessageSubject { get; set; } public string MessageSubject { get; set; }
public string MessageBody { get; set; } public string MessageBody { get; set; }
}
} }

11
backend/extensions/Squidex.Extensions/Actions/Email/EmailPlugin.cs

@ -9,13 +9,12 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.Email namespace Squidex.Extensions.Actions.Email;
public sealed class EmailPlugin : IPlugin
{ {
public sealed class EmailPlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) services.AddRuleAction<EmailAction, EmailActionHandler>();
{
services.AddRuleAction<EmailAction, EmailActionHandler>();
}
} }
} }

37
backend/extensions/Squidex.Extensions/Actions/Fastly/FastlyAction.cs

@ -10,25 +10,24 @@ using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Fastly namespace Squidex.Extensions.Actions.Fastly;
[RuleAction(
Title = "Fastly",
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 28 32'><path d='M10.68.948v1.736h.806v2.6A12.992 12.992 0 0 0 .951 18.051c0 7.178 5.775 12.996 12.9 12.996 7.124 0 12.9-5.819 12.9-12.996-.004-6.332-4.502-11.605-10.455-12.755l-.081-.013V2.684h.807V.948H10.68zm3.53 10.605c3.218.173 5.81 2.713 6.09 5.922v.211h-.734v.737h.734v.201c-.279 3.21-2.871 5.752-6.09 5.925v-.723h-.733v.721c-3.281-.192-5.905-2.845-6.077-6.152h.728v-.737h-.724c.195-3.284 2.808-5.911 6.073-6.103v.725h.733v-.727zm2.513 3.051l-2.462 2.282a1.13 1.13 0 0 0-.41-.078c-.633 0-1.147.517-1.147 1.155a1.15 1.15 0 0 0 1.147 1.155c.633 0 1.147-.517 1.147-1.155 0-.117-.018-.23-.05-.337l.002.008 2.223-2.505-.449-.526z'/></svg>",
IconColor = "#e23335",
Display = "Purge fastly cache",
Description = "Remove entries from the fastly CDN cache.",
ReadMore = "https://www.fastly.com/")]
public sealed record FastlyAction : RuleAction
{ {
[RuleAction( [LocalizedRequired]
Title = "Fastly", [Display(Name = "Api Key", Description = "The API key to grant access to Squidex.")]
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 28 32'><path d='M10.68.948v1.736h.806v2.6A12.992 12.992 0 0 0 .951 18.051c0 7.178 5.775 12.996 12.9 12.996 7.124 0 12.9-5.819 12.9-12.996-.004-6.332-4.502-11.605-10.455-12.755l-.081-.013V2.684h.807V.948H10.68zm3.53 10.605c3.218.173 5.81 2.713 6.09 5.922v.211h-.734v.737h.734v.201c-.279 3.21-2.871 5.752-6.09 5.925v-.723h-.733v.721c-3.281-.192-5.905-2.845-6.077-6.152h.728v-.737h-.724c.195-3.284 2.808-5.911 6.073-6.103v.725h.733v-.727zm2.513 3.051l-2.462 2.282a1.13 1.13 0 0 0-.41-.078c-.633 0-1.147.517-1.147 1.155a1.15 1.15 0 0 0 1.147 1.155c.633 0 1.147-.517 1.147-1.155 0-.117-.018-.23-.05-.337l.002.008 2.223-2.505-.449-.526z'/></svg>", [Editor(RuleFieldEditor.Text)]
IconColor = "#e23335", public string ApiKey { get; set; }
Display = "Purge fastly cache",
Description = "Remove entries from the fastly CDN cache.",
ReadMore = "https://www.fastly.com/")]
public sealed record FastlyAction : RuleAction
{
[LocalizedRequired]
[Display(Name = "Api Key", Description = "The API key to grant access to Squidex.")]
[Editor(RuleFieldEditor.Text)]
public string ApiKey { get; set; }
[LocalizedRequired] [LocalizedRequired]
[Display(Name = "Service Id", Description = "The ID of the fastly service.")] [Display(Name = "Service Id", Description = "The ID of the fastly service.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
public string ServiceId { get; set; } public string ServiceId { get; set; }
}
} }

79
backend/extensions/Squidex.Extensions/Actions/Fastly/FastlyActionHandler.cs

@ -11,64 +11,63 @@ using Squidex.Infrastructure;
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Fastly namespace Squidex.Extensions.Actions.Fastly;
public sealed class FastlyActionHandler : RuleActionHandler<FastlyAction, FastlyJob>
{ {
public sealed class FastlyActionHandler : RuleActionHandler<FastlyAction, FastlyJob> private const string Description = "Purge key in fastly";
private readonly IHttpClientFactory httpClientFactory;
public FastlyActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory)
: base(formatter)
{ {
private const string Description = "Purge key in fastly"; this.httpClientFactory = httpClientFactory;
}
private readonly IHttpClientFactory httpClientFactory; protected override (string Description, FastlyJob Data) CreateJob(EnrichedEvent @event, FastlyAction action)
{
var id = string.Empty;
public FastlyActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory) if (@event is IEnrichedEntityEvent entityEvent)
: base(formatter)
{ {
this.httpClientFactory = httpClientFactory; id = DomainId.Combine(@event.AppId.Id, entityEvent.Id).ToString();
} }
protected override (string Description, FastlyJob Data) CreateJob(EnrichedEvent @event, FastlyAction action) var ruleJob = new FastlyJob
{ {
var id = string.Empty; Key = id,
FastlyApiKey = action.ApiKey,
if (@event is IEnrichedEntityEvent entityEvent) FastlyServiceID = action.ServiceId
{ };
id = DomainId.Combine(@event.AppId.Id, entityEvent.Id).ToString();
}
var ruleJob = new FastlyJob
{
Key = id,
FastlyApiKey = action.ApiKey,
FastlyServiceID = action.ServiceId
};
return (Description, ruleJob); return (Description, ruleJob);
} }
protected override async Task<Result> ExecuteJobAsync(FastlyJob job, protected override async Task<Result> ExecuteJobAsync(FastlyJob job,
CancellationToken ct = default) CancellationToken ct = default)
{
using (var httpClient = httpClientFactory.CreateClient())
{ {
using (var httpClient = httpClientFactory.CreateClient()) httpClient.Timeout = TimeSpan.FromSeconds(2);
{
httpClient.Timeout = TimeSpan.FromSeconds(2);
var requestUrl = $"https://api.fastly.com/service/{job.FastlyServiceID}/purge/{job.Key}"; var requestUrl = $"https://api.fastly.com/service/{job.FastlyServiceID}/purge/{job.Key}";
using (var request = new HttpRequestMessage(HttpMethod.Post, requestUrl)) using (var request = new HttpRequestMessage(HttpMethod.Post, requestUrl))
{ {
request.Headers.Add("Fastly-Key", job.FastlyApiKey); request.Headers.Add("Fastly-Key", job.FastlyApiKey);
return await httpClient.OneWayRequestAsync(request, ct: ct); return await httpClient.OneWayRequestAsync(request, ct: ct);
}
} }
} }
} }
}
public sealed class FastlyJob public sealed class FastlyJob
{ {
public string FastlyApiKey { get; set; } public string FastlyApiKey { get; set; }
public string FastlyServiceID { get; set; } public string FastlyServiceID { get; set; }
public string Key { get; set; } public string Key { get; set; }
}
} }

11
backend/extensions/Squidex.Extensions/Actions/Fastly/FastlyPlugin.cs

@ -9,13 +9,12 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.Fastly namespace Squidex.Extensions.Actions.Fastly;
public sealed class FastlyPlugin : IPlugin
{ {
public sealed class FastlyPlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) services.AddRuleAction<FastlyAction, FastlyActionHandler>();
{
services.AddRuleAction<FastlyAction, FastlyActionHandler>();
}
} }
} }

87
backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaAction.cs

@ -10,49 +10,48 @@ using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Kafka namespace Squidex.Extensions.Actions.Kafka;
[RuleAction(
Title = "Kafka",
IconImage = "<svg version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 1000 1000' enable-background='new 0 0 1000 1000' xml:space='preserve'><g><path d = 'M674.2,552.7c-38.2,0-72.4,17-95.9,43.6l-60.1-42.5c6.5-17.4,10.1-36.4,10.1-56.1c0-19.5-3.6-38-9.6-55.2l59.9-42c23.5,26.4,57.5,43.4,95.7,43.4c70.4,0,127.7-57.2,127.7-127.7c0-70.4-57.2-127.7-127.7-127.7c-70.4,0-127.7,57.2-127.7,127.7c0,12.5,2,24.8,5.4,36.2l-60.1,42c-25-31.1-61.3-52.8-102.2-59.5v-72.2c57.9-12.3,101.5-63.7,101.5-125C491.1,67.2,433.8,10,363.4,10S235.7,67.2,235.7,137.7c0,60.6,42.5,111.3,99.3,124.5v73.1c-77.8,13.4-136.8,80.9-136.8,162.3c0,81.6,59.7,149.4,137.5,162.5v77.4c-57.2,12.5-100.4,63.7-100.4,124.8c0,70.4,57.2,127.7,127.7,127.7c70.4,0,128.1-57.2,128.1-127.9c0-61-43.2-112.2-100.4-124.8V660c40.2-6.7,75.6-27.9,100.4-58.4l60.4,42.7c-3.4,11.4-5.1,23.5-5.1,36c0,70.4,57.2,127.7,127.7,127.7c70.4,0,127.7-57.2,127.7-127.7C801.6,609.9,744.6,552.7,674.2,552.7L674.2,552.7z M674.2,253.9c34.2,0,61.9,27.7,61.9,61.9c0,34.2-27.7,61.9-61.9,61.9c-34.2,0-62.2-27.7-62.2-61.9C612,281.7,640,253.9,674.2,253.9L674.2,253.9z M301.2,137.7c0-34.2,27.7-61.9,61.9-61.9c34.2,0,61.9,27.7,61.9,61.9s-27.7,61.9-61.9,61.9C329,199.6,301.2,171.7,301.2,137.7L301.2,137.7z M425.1,862.1c0,34.2-27.7,61.9-61.9,61.9c-34.2,0-61.9-27.7-61.9-61.9c0-34.2,27.7-61.9,61.9-61.9C397.4,800.2,425.1,828.1,425.1,862.1L425.1,862.1z M363.2,584c-47.6,0-86.3-38.7-86.3-86.3c0-47.6,38.7-86.3,86.3-86.3c47.6,0,86.3,38.7,86.3,86.3C449.7,545.3,410.8,584,363.2,584L363.2,584z M674.2,742.5c-34.2,0-61.9-27.7-61.9-61.9c0-34.2,27.7-61.9,61.9-61.9c34.2,0,61.9,27.7,61.9,61.9C736.1,714.8,708.2,742.5,674.2,742.5L674.2,742.5z'/></g></svg>",
IconColor = "#404244",
Display = "Push to kafka",
Description = "Connect to Kafka stream and push data to that stream.",
ReadMore = "https://kafka.apache.org/quickstart")]
public sealed record KafkaAction : RuleAction
{ {
[RuleAction( [LocalizedRequired]
Title = "Kafka", [Display(Name = "Topic Name", Description = "The name of the topic.")]
IconImage = "<svg version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 1000 1000' enable-background='new 0 0 1000 1000' xml:space='preserve'><g><path d = 'M674.2,552.7c-38.2,0-72.4,17-95.9,43.6l-60.1-42.5c6.5-17.4,10.1-36.4,10.1-56.1c0-19.5-3.6-38-9.6-55.2l59.9-42c23.5,26.4,57.5,43.4,95.7,43.4c70.4,0,127.7-57.2,127.7-127.7c0-70.4-57.2-127.7-127.7-127.7c-70.4,0-127.7,57.2-127.7,127.7c0,12.5,2,24.8,5.4,36.2l-60.1,42c-25-31.1-61.3-52.8-102.2-59.5v-72.2c57.9-12.3,101.5-63.7,101.5-125C491.1,67.2,433.8,10,363.4,10S235.7,67.2,235.7,137.7c0,60.6,42.5,111.3,99.3,124.5v73.1c-77.8,13.4-136.8,80.9-136.8,162.3c0,81.6,59.7,149.4,137.5,162.5v77.4c-57.2,12.5-100.4,63.7-100.4,124.8c0,70.4,57.2,127.7,127.7,127.7c70.4,0,128.1-57.2,128.1-127.9c0-61-43.2-112.2-100.4-124.8V660c40.2-6.7,75.6-27.9,100.4-58.4l60.4,42.7c-3.4,11.4-5.1,23.5-5.1,36c0,70.4,57.2,127.7,127.7,127.7c70.4,0,127.7-57.2,127.7-127.7C801.6,609.9,744.6,552.7,674.2,552.7L674.2,552.7z M674.2,253.9c34.2,0,61.9,27.7,61.9,61.9c0,34.2-27.7,61.9-61.9,61.9c-34.2,0-62.2-27.7-62.2-61.9C612,281.7,640,253.9,674.2,253.9L674.2,253.9z M301.2,137.7c0-34.2,27.7-61.9,61.9-61.9c34.2,0,61.9,27.7,61.9,61.9s-27.7,61.9-61.9,61.9C329,199.6,301.2,171.7,301.2,137.7L301.2,137.7z M425.1,862.1c0,34.2-27.7,61.9-61.9,61.9c-34.2,0-61.9-27.7-61.9-61.9c0-34.2,27.7-61.9,61.9-61.9C397.4,800.2,425.1,828.1,425.1,862.1L425.1,862.1z M363.2,584c-47.6,0-86.3-38.7-86.3-86.3c0-47.6,38.7-86.3,86.3-86.3c47.6,0,86.3,38.7,86.3,86.3C449.7,545.3,410.8,584,363.2,584L363.2,584z M674.2,742.5c-34.2,0-61.9-27.7-61.9-61.9c0-34.2,27.7-61.9,61.9-61.9c34.2,0,61.9,27.7,61.9,61.9C736.1,714.8,708.2,742.5,674.2,742.5L674.2,742.5z'/></g></svg>", [Editor(RuleFieldEditor.Text)]
IconColor = "#404244", [Formattable]
Display = "Push to kafka", public string TopicName { get; set; }
Description = "Connect to Kafka stream and push data to that stream.",
ReadMore = "https://kafka.apache.org/quickstart")] [Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")]
public sealed record KafkaAction : RuleAction [Editor(RuleFieldEditor.TextArea)]
{ [Formattable]
[LocalizedRequired] public string Payload { get; set; }
[Display(Name = "Topic Name", Description = "The name of the topic.")]
[Editor(RuleFieldEditor.Text)] [Display(Name = "Key", Description = "The message key, commonly used for partitioning.")]
[Formattable] [Editor(RuleFieldEditor.Text)]
public string TopicName { get; set; } [Formattable]
public string Key { get; set; }
[Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")]
[Editor(RuleFieldEditor.TextArea)] [Display(Name = "Partition Key", Description = "The partition key, only used when we don't want to define partiontionig with key.")]
[Formattable] [Editor(RuleFieldEditor.Text)]
public string Payload { get; set; } [Formattable]
public string PartitionKey { get; set; }
[Display(Name = "Key", Description = "The message key, commonly used for partitioning.")]
[Editor(RuleFieldEditor.Text)] [Display(Name = "Partition Count", Description = "Define the number of partitions for specific topic.")]
[Formattable] [Editor(RuleFieldEditor.Text)]
public string Key { get; set; } public int PartitionCount { get; set; }
[Display(Name = "Partition Key", Description = "The partition key, only used when we don't want to define partiontionig with key.")] [Display(Name = "Headers (Optional)", Description = "The message headers in the format '[Key]=[Value]', one entry per line.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.TextArea)]
[Formattable] [Formattable]
public string PartitionKey { get; set; } public string Headers { get; set; }
[Display(Name = "Partition Count", Description = "Define the number of partitions for specific topic.")] [Display(Name = "Schema (Optional)", Description = "Define a specific AVRO schema in JSON format.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.TextArea)]
public int PartitionCount { get; set; } public string Schema { get; set; }
[Display(Name = "Headers (Optional)", Description = "The message headers in the format '[Key]=[Value]', one entry per line.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Headers { get; set; }
[Display(Name = "Schema (Optional)", Description = "Define a specific AVRO schema in JSON format.")]
[Editor(RuleFieldEditor.TextArea)]
public string Schema { get; set; }
}
} }

157
backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaActionHandler.cs

@ -10,114 +10,113 @@ using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Kafka namespace Squidex.Extensions.Actions.Kafka;
public sealed class KafkaActionHandler : RuleActionHandler<KafkaAction, KafkaJob>
{ {
public sealed class KafkaActionHandler : RuleActionHandler<KafkaAction, KafkaJob> private const string Description = "Push to Kafka";
private readonly KafkaProducer kafkaProducer;
public KafkaActionHandler(RuleEventFormatter formatter, KafkaProducer kafkaProducer)
: base(formatter)
{
this.kafkaProducer = kafkaProducer;
}
protected override async Task<(string Description, KafkaJob Data)> CreateJobAsync(EnrichedEvent @event, KafkaAction action)
{ {
private const string Description = "Push to Kafka"; string value, key;
private readonly KafkaProducer kafkaProducer;
public KafkaActionHandler(RuleEventFormatter formatter, KafkaProducer kafkaProducer) if (!string.IsNullOrEmpty(action.Payload))
: base(formatter)
{ {
this.kafkaProducer = kafkaProducer; value = await FormatAsync(action.Payload, @event);
} }
else
protected override async Task<(string Description, KafkaJob Data)> CreateJobAsync(EnrichedEvent @event, KafkaAction action)
{ {
string value, key; value = ToEnvelopeJson(@event);
}
if (!string.IsNullOrEmpty(action.Payload)) if (!string.IsNullOrEmpty(action.Key))
{ {
value = await FormatAsync(action.Payload, @event); key = await FormatAsync(action.Key, @event);
} }
else else
{ {
value = ToEnvelopeJson(@event); key = @event.Name;
} }
if (!string.IsNullOrEmpty(action.Key)) var ruleJob = new KafkaJob
{ {
key = await FormatAsync(action.Key, @event); TopicName = action.TopicName,
} MessageKey = key,
else MessageValue = value,
{ Headers = await ParseHeadersAsync(action.Headers, @event),
key = @event.Name; Schema = action.Schema,
} PartitionKey = await FormatAsync(action.PartitionKey, @event),
PartitionCount = action.PartitionCount
};
return (Description, ruleJob);
}
var ruleJob = new KafkaJob private async Task<Dictionary<string, string>> ParseHeadersAsync(string headers, EnrichedEvent @event)
{ {
TopicName = action.TopicName, if (string.IsNullOrWhiteSpace(headers))
MessageKey = key, {
MessageValue = value, return null;
Headers = await ParseHeadersAsync(action.Headers, @event),
Schema = action.Schema,
PartitionKey = await FormatAsync(action.PartitionKey, @event),
PartitionCount = action.PartitionCount
};
return (Description, ruleJob);
} }
private async Task<Dictionary<string, string>> ParseHeadersAsync(string headers, EnrichedEvent @event) var headersDictionary = new Dictionary<string, string>();
{
if (string.IsNullOrWhiteSpace(headers))
{
return null;
}
var headersDictionary = new Dictionary<string, string>(); var lines = headers.Split('\n');
var lines = headers.Split('\n'); foreach (var line in lines)
{
var indexEqual = line.IndexOf('=', StringComparison.Ordinal);
foreach (var line in lines) if (indexEqual > 0 && indexEqual < line.Length - 1)
{ {
var indexEqual = line.IndexOf('=', StringComparison.Ordinal); var key = line[..indexEqual];
var val = line[(indexEqual + 1)..];
if (indexEqual > 0 && indexEqual < line.Length - 1) val = await FormatAsync(val, @event);
{
var key = line[..indexEqual];
var val = line[(indexEqual + 1)..];
val = await FormatAsync(val, @event); headersDictionary[key] = val;
headersDictionary[key] = val;
}
} }
return headersDictionary;
} }
protected override async Task<Result> ExecuteJobAsync(KafkaJob job, return headersDictionary;
CancellationToken ct = default) }
protected override async Task<Result> ExecuteJobAsync(KafkaJob job,
CancellationToken ct = default)
{
try
{ {
try await kafkaProducer.SendAsync(job, ct);
{
await kafkaProducer.SendAsync(job, ct);
return Result.Success($"Event pushed to {job.TopicName} kafka topic with {job.MessageKey} message key."); return Result.Success($"Event pushed to {job.TopicName} kafka topic with {job.MessageKey} message key.");
} }
catch (Exception ex) catch (Exception ex)
{ {
return Result.Failed(ex, $"Push to Kafka failed: {ex}"); return Result.Failed(ex, $"Push to Kafka failed: {ex}");
}
} }
} }
}
public sealed class KafkaJob public sealed class KafkaJob
{ {
public string TopicName { get; set; } public string TopicName { get; set; }
public string MessageKey { get; set; } public string MessageKey { get; set; }
public string MessageValue { get; set; } public string MessageValue { get; set; }
public string Schema { get; set; } public string Schema { get; set; }
public string PartitionKey { get; set; } public string PartitionKey { get; set; }
public Dictionary<string, string> Headers { get; set; } public Dictionary<string, string> Headers { get; set; }
public int PartitionCount { get; set; } public int PartitionCount { get; set; }
}
} }

21
backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaPlugin.cs

@ -10,21 +10,20 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.Kafka namespace Squidex.Extensions.Actions.Kafka;
public sealed class KafkaPlugin : IPlugin
{ {
public sealed class KafkaPlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) var options = config.GetSection("kafka").Get<KafkaProducerOptions>() ?? new ();
{
var options = config.GetSection("kafka").Get<KafkaProducerOptions>() ?? new ();
if (options.IsProducerConfigured()) if (options.IsProducerConfigured())
{ {
services.AddRuleAction<KafkaAction, KafkaActionHandler>(); services.AddRuleAction<KafkaAction, KafkaActionHandler>();
services.AddSingleton<KafkaProducer>(); services.AddSingleton<KafkaProducer>();
services.AddSingleton(Options.Create(options)); services.AddSingleton(Options.Create(options));
}
} }
} }
} }

395
backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaProducer.cs

@ -17,267 +17,266 @@ using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
using Schema = Avro.Schema; using Schema = Avro.Schema;
namespace Squidex.Extensions.Actions.Kafka namespace Squidex.Extensions.Actions.Kafka;
{
public sealed class KafkaProducer
{
private readonly IProducer<string, string> textProducer;
private readonly IProducer<string, GenericRecord> avroProducer;
private readonly ISchemaRegistryClient schemaRegistry;
private readonly IJsonSerializer jsonSerializer;
public KafkaProducer(IOptions<KafkaProducerOptions> options, IJsonSerializer jsonSerializer, public sealed class KafkaProducer
ILogger<KafkaProducer> log) {
{ private readonly IProducer<string, string> textProducer;
this.jsonSerializer = jsonSerializer; private readonly IProducer<string, GenericRecord> avroProducer;
private readonly ISchemaRegistryClient schemaRegistry;
private readonly IJsonSerializer jsonSerializer;
textProducer = new ProducerBuilder<string, string>(options.Value) public KafkaProducer(IOptions<KafkaProducerOptions> options, IJsonSerializer jsonSerializer,
.SetErrorHandler((p, error) => ILogger<KafkaProducer> log)
{ {
LogError(log, error); this.jsonSerializer = jsonSerializer;
})
.SetLogHandler((p, message) =>
{
LogMessage(log, message);
})
.SetKeySerializer(Serializers.Utf8)
.SetValueSerializer(Serializers.Utf8)
.Build();
if (options.Value.IsSchemaRegistryConfigured()) textProducer = new ProducerBuilder<string, string>(options.Value)
.SetErrorHandler((p, error) =>
{ {
schemaRegistry = new CachedSchemaRegistryClient(options.Value.SchemaRegistry); LogError(log, error);
})
avroProducer = new ProducerBuilder<string, GenericRecord>(options.Value) .SetLogHandler((p, message) =>
.SetErrorHandler((p, error) =>
{
LogError(log, error);
})
.SetLogHandler((p, message) =>
{
LogMessage(log, message);
})
.SetKeySerializer(Serializers.Utf8)
.SetValueSerializer(new AvroSerializer<GenericRecord>(schemaRegistry, options.Value.AvroSerializer))
.Build();
}
}
private static void LogMessage(ILogger<KafkaProducer> log, LogMessage message)
{
var level = LogLevel.Information;
switch (message.Level)
{ {
case SyslogLevel.Emergency: LogMessage(log, message);
level = LogLevel.Error; })
break; .SetKeySerializer(Serializers.Utf8)
case SyslogLevel.Alert: .SetValueSerializer(Serializers.Utf8)
level = LogLevel.Error; .Build();
break;
case SyslogLevel.Critical:
level = LogLevel.Error;
break;
case SyslogLevel.Error:
level = LogLevel.Error;
break;
case SyslogLevel.Warning:
level = LogLevel.Warning;
break;
case SyslogLevel.Notice:
level = LogLevel.Information;
break;
case SyslogLevel.Info:
level = LogLevel.Information;
break;
case SyslogLevel.Debug:
level = LogLevel.Debug;
break;
}
log.Log(level, "Kafka log {name}: {message}.", message.Name, message.Message); if (options.Value.IsSchemaRegistryConfigured())
{
schemaRegistry = new CachedSchemaRegistryClient(options.Value.SchemaRegistry);
avroProducer = new ProducerBuilder<string, GenericRecord>(options.Value)
.SetErrorHandler((p, error) =>
{
LogError(log, error);
})
.SetLogHandler((p, message) =>
{
LogMessage(log, message);
})
.SetKeySerializer(Serializers.Utf8)
.SetValueSerializer(new AvroSerializer<GenericRecord>(schemaRegistry, options.Value.AvroSerializer))
.Build();
} }
}
private static void LogMessage(ILogger<KafkaProducer> log, LogMessage message)
{
var level = LogLevel.Information;
private static void LogError(ILogger<KafkaProducer> log, Error error) switch (message.Level)
{ {
log.LogWarning("Kafka error with {code} and {reason}.", error.Code, error.Reason); case SyslogLevel.Emergency:
level = LogLevel.Error;
break;
case SyslogLevel.Alert:
level = LogLevel.Error;
break;
case SyslogLevel.Critical:
level = LogLevel.Error;
break;
case SyslogLevel.Error:
level = LogLevel.Error;
break;
case SyslogLevel.Warning:
level = LogLevel.Warning;
break;
case SyslogLevel.Notice:
level = LogLevel.Information;
break;
case SyslogLevel.Info:
level = LogLevel.Information;
break;
case SyslogLevel.Debug:
level = LogLevel.Debug;
break;
} }
public async Task SendAsync(KafkaJob job, log.Log(level, "Kafka log {name}: {message}.", message.Name, message.Message);
CancellationToken ct) }
private static void LogError(ILogger<KafkaProducer> log, Error error)
{
log.LogWarning("Kafka error with {code} and {reason}.", error.Code, error.Reason);
}
public async Task SendAsync(KafkaJob job,
CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(job.Schema))
{ {
if (!string.IsNullOrWhiteSpace(job.Schema)) var value = CreateAvroRecord(job.MessageValue, job.Schema);
{
var value = CreateAvroRecord(job.MessageValue, job.Schema);
var message = new Message<string, GenericRecord> { Value = value }; var message = new Message<string, GenericRecord> { Value = value };
await ProduceAsync(avroProducer, message, job, ct); await ProduceAsync(avroProducer, message, job, ct);
} }
else else
{ {
var message = new Message<string, string> { Value = job.MessageValue }; var message = new Message<string, string> { Value = job.MessageValue };
await ProduceAsync(textProducer, message, job, ct); await ProduceAsync(textProducer, message, job, ct);
}
} }
}
private static async Task ProduceAsync<T>(IProducer<string, T> producer, Message<string, T> message, KafkaJob job,
CancellationToken ct)
{
message.Key = job.MessageKey;
private static async Task ProduceAsync<T>(IProducer<string, T> producer, Message<string, T> message, KafkaJob job, if (job.Headers?.Count > 0)
CancellationToken ct)
{ {
message.Key = job.MessageKey; message.Headers = new Headers();
if (job.Headers?.Count > 0) foreach (var header in job.Headers)
{ {
message.Headers = new Headers(); message.Headers.Add(header.Key, Encoding.UTF8.GetBytes(header.Value));
foreach (var header in job.Headers)
{
message.Headers.Add(header.Key, Encoding.UTF8.GetBytes(header.Value));
}
} }
}
if (!string.IsNullOrWhiteSpace(job.PartitionKey) && job.PartitionCount > 0) if (!string.IsNullOrWhiteSpace(job.PartitionKey) && job.PartitionCount > 0)
{ {
var partition = Math.Abs(job.PartitionKey.GetHashCode(StringComparison.Ordinal)) % job.PartitionCount; var partition = Math.Abs(job.PartitionKey.GetHashCode(StringComparison.Ordinal)) % job.PartitionCount;
await producer.ProduceAsync(new TopicPartition(job.TopicName, partition), message, ct); await producer.ProduceAsync(new TopicPartition(job.TopicName, partition), message, ct);
} }
else else
{ {
await producer.ProduceAsync(job.TopicName, message, ct); await producer.ProduceAsync(job.TopicName, message, ct);
}
} }
}
private GenericRecord CreateAvroRecord(string json, string avroSchema) private GenericRecord CreateAvroRecord(string json, string avroSchema)
{
try
{ {
try var schema = (RecordSchema)Schema.Parse(avroSchema);
{
var schema = (RecordSchema)Schema.Parse(avroSchema);
var jsonObject = jsonSerializer.Deserialize<JsonObject>(json); var jsonObject = jsonSerializer.Deserialize<JsonObject>(json);
var result = (GenericRecord)GetValue(jsonObject, schema); var result = (GenericRecord)GetValue(jsonObject, schema);
return result; return result;
}
catch (JsonException ex)
{
throw new InvalidOperationException($"Failed to parse json: {json}, got {ex.Message}", ex);
}
} }
catch (JsonException ex)
public void Dispose()
{ {
textProducer?.Dispose(); throw new InvalidOperationException($"Failed to parse json: {json}, got {ex.Message}", ex);
avroProducer?.Dispose();
} }
}
public void Dispose()
{
textProducer?.Dispose();
avroProducer?.Dispose();
}
private static object GetValue(JsonValue value, Schema schema) private static object GetValue(JsonValue value, Schema schema)
{
switch (value.Value)
{ {
switch (value.Value) case bool b when IsTypeOrUnionWith(schema, Schema.Type.Boolean):
{ return b;
case bool b when IsTypeOrUnionWith(schema, Schema.Type.Boolean): case double d when IsTypeOrUnionWith(schema, Schema.Type.Long):
return b; return (long)d;
case double d when IsTypeOrUnionWith(schema, Schema.Type.Long): case double d when IsTypeOrUnionWith(schema, Schema.Type.Float):
return (long)d; return (float)d;
case double d when IsTypeOrUnionWith(schema, Schema.Type.Float): case double d when IsTypeOrUnionWith(schema, Schema.Type.Int):
return (float)d; return (int)d;
case double d when IsTypeOrUnionWith(schema, Schema.Type.Int): case double d when IsTypeOrUnionWith(schema, Schema.Type.Double):
return (int)d; return d;
case double d when IsTypeOrUnionWith(schema, Schema.Type.Double): case string s when IsTypeOrUnionWith(schema, Schema.Type.String):
return d; return s;
case string s when IsTypeOrUnionWith(schema, Schema.Type.String): case JsonObject o when IsTypeOrUnionWith(schema, Schema.Type.Map):
return s; {
case JsonObject o when IsTypeOrUnionWith(schema, Schema.Type.Map): var mapResult = new Dictionary<string, object>();
if (schema is UnionSchema union)
{ {
var mapResult = new Dictionary<string, object>(); var map = (MapSchema)union.Schemas.FirstOrDefault(x => x.Tag == Schema.Type.Map);
if (schema is UnionSchema union) foreach (var (key, childValue) in o)
{ {
var map = (MapSchema)union.Schemas.FirstOrDefault(x => x.Tag == Schema.Type.Map); mapResult.Add(key, GetValue(childValue, map?.ValueSchema));
foreach (var (key, childValue) in o)
{
mapResult.Add(key, GetValue(childValue, map?.ValueSchema));
}
} }
else if (schema is MapSchema map) }
else if (schema is MapSchema map)
{
foreach (var (key, childValue) in o)
{ {
foreach (var (key, childValue) in o) mapResult.Add(key, GetValue(childValue, map?.ValueSchema));
{
mapResult.Add(key, GetValue(childValue, map?.ValueSchema));
}
} }
return mapResult;
} }
case JsonObject o when IsTypeOrUnionWith(schema, Schema.Type.Record): return mapResult;
{ }
GenericRecord result = null;
if (schema is UnionSchema union) case JsonObject o when IsTypeOrUnionWith(schema, Schema.Type.Record):
{ {
var record = (RecordSchema)union.Schemas.FirstOrDefault(x => x.Tag == Schema.Type.Record); GenericRecord result = null;
result = new GenericRecord(record); if (schema is UnionSchema union)
{
var record = (RecordSchema)union.Schemas.FirstOrDefault(x => x.Tag == Schema.Type.Record);
result = new GenericRecord(record);
foreach (var (key, childValue) in o) foreach (var (key, childValue) in o)
{
if (record != null && record.TryGetField(key, out var field))
{ {
if (record != null && record.TryGetField(key, out var field)) result.Add(key, GetValue(childValue, field.Schema));
{
result.Add(key, GetValue(childValue, field.Schema));
}
} }
} }
else if (schema is RecordSchema record) }
{ else if (schema is RecordSchema record)
result = new GenericRecord(record); {
result = new GenericRecord(record);
foreach (var (key, childValue) in o) foreach (var (key, childValue) in o)
{
if (record.TryGetField(key, out var field))
{ {
if (record.TryGetField(key, out var field)) result.Add(key, GetValue(childValue, field.Schema));
{
result.Add(key, GetValue(childValue, field.Schema));
}
} }
} }
return result;
} }
case JsonArray a when IsTypeOrUnionWith(schema, Schema.Type.Array): return result;
}
case JsonArray a when IsTypeOrUnionWith(schema, Schema.Type.Array):
{
var result = new List<object>();
if (schema is UnionSchema union)
{ {
var result = new List<object>(); var arraySchema = (ArraySchema)union.Schemas.FirstOrDefault(x => x.Tag == Schema.Type.Array);
if (schema is UnionSchema union) foreach (var item in a)
{ {
var arraySchema = (ArraySchema)union.Schemas.FirstOrDefault(x => x.Tag == Schema.Type.Array); result.Add(GetValue(item, arraySchema?.ItemSchema));
foreach (var item in a)
{
result.Add(GetValue(item, arraySchema?.ItemSchema));
}
} }
else if (schema is ArraySchema array) }
else if (schema is ArraySchema array)
{
foreach (var item in a)
{ {
foreach (var item in a) result.Add(GetValue(item, array.ItemSchema));
{
result.Add(GetValue(item, array.ItemSchema));
}
} }
return result.ToArray();
} }
}
return null; return result.ToArray();
}
} }
private static bool IsTypeOrUnionWith(Schema schema, Schema.Type expected) return null;
{ }
return schema.Tag == expected || (schema is UnionSchema union && union.Schemas.Any(x => x.Tag == expected));
} private static bool IsTypeOrUnionWith(Schema schema, Schema.Type expected)
{
return schema.Tag == expected || (schema is UnionSchema union && union.Schemas.Any(x => x.Tag == expected));
} }
} }

25
backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaProducerOptions.cs

@ -9,22 +9,21 @@ using Confluent.Kafka;
using Confluent.SchemaRegistry; using Confluent.SchemaRegistry;
using Confluent.SchemaRegistry.Serdes; using Confluent.SchemaRegistry.Serdes;
namespace Squidex.Extensions.Actions.Kafka namespace Squidex.Extensions.Actions.Kafka;
public class KafkaProducerOptions : ProducerConfig
{ {
public class KafkaProducerOptions : ProducerConfig public SchemaRegistryConfig SchemaRegistry { get; set; }
{
public SchemaRegistryConfig SchemaRegistry { get; set; }
public AvroSerializerConfig AvroSerializer { get; set; } public AvroSerializerConfig AvroSerializer { get; set; }
public bool IsProducerConfigured() public bool IsProducerConfigured()
{ {
return !string.IsNullOrWhiteSpace(BootstrapServers); return !string.IsNullOrWhiteSpace(BootstrapServers);
} }
public bool IsSchemaRegistryConfigured() public bool IsSchemaRegistryConfigured()
{ {
return !string.IsNullOrWhiteSpace(SchemaRegistry?.Url); return !string.IsNullOrWhiteSpace(SchemaRegistry?.Url);
}
} }
} }

89
backend/extensions/Squidex.Extensions/Actions/Medium/MediumAction.cs

@ -10,50 +10,49 @@ using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Medium namespace Squidex.Extensions.Actions.Medium;
[RuleAction(
Title = "Medium",
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M3.795 8.48a1.239 1.239 0 0 0-.404-1.045l-2.987-3.6v-.537H9.68l7.171 15.727 6.304-15.727H32v.537l-2.556 2.449a.749.749 0 0 0-.284.717v18a.749.749 0 0 0 .284.716l2.493 2.449v.537H19.39v-.537l2.583-2.509c.253-.253.253-.328.253-.717V10.392l-7.187 18.251h-.969L5.703 10.392v12.232a1.69 1.69 0 0 0 .463 1.404l3.36 4.08v.536H-.001v-.537l3.36-4.08c.36-.371.52-.893.435-1.403V8.48z'/></svg>",
IconColor = "#00ab6c",
Display = "Post to Medium",
Description = "Create a new story or post at medium.",
ReadMore = "https://medium.com/")]
public sealed record MediumAction : RuleAction
{ {
[RuleAction( [LocalizedRequired]
Title = "Medium", [Display(Name = "Access Token", Description = "The self issued access token.")]
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M3.795 8.48a1.239 1.239 0 0 0-.404-1.045l-2.987-3.6v-.537H9.68l7.171 15.727 6.304-15.727H32v.537l-2.556 2.449a.749.749 0 0 0-.284.717v18a.749.749 0 0 0 .284.716l2.493 2.449v.537H19.39v-.537l2.583-2.509c.253-.253.253-.328.253-.717V10.392l-7.187 18.251h-.969L5.703 10.392v12.232a1.69 1.69 0 0 0 .463 1.404l3.36 4.08v.536H-.001v-.537l3.36-4.08c.36-.371.52-.893.435-1.403V8.48z'/></svg>", [Editor(RuleFieldEditor.Text)]
IconColor = "#00ab6c", public string AccessToken { get; set; }
Display = "Post to Medium",
Description = "Create a new story or post at medium.", [LocalizedRequired]
ReadMore = "https://medium.com/")] [Display(Name = "Title", Description = "The title, used for the url.")]
public sealed record MediumAction : RuleAction [Editor(RuleFieldEditor.Text)]
{ [Formattable]
[LocalizedRequired] public string Title { get; set; }
[Display(Name = "Access Token", Description = "The self issued access token.")]
[Editor(RuleFieldEditor.Text)] [LocalizedRequired]
public string AccessToken { get; set; } [Display(Name = "Content", Description = "The content, either html or markdown.")]
[Editor(RuleFieldEditor.TextArea)]
[LocalizedRequired] [Formattable]
[Display(Name = "Title", Description = "The title, used for the url.")] public string Content { get; set; }
[Editor(RuleFieldEditor.Text)]
[Formattable] [Display(Name = "Canonical Url", Description = "The original home of this content, if it was originally published elsewhere.")]
public string Title { get; set; } [Editor(RuleFieldEditor.Text)]
[Formattable]
[LocalizedRequired] public string CanonicalUrl { get; set; }
[Display(Name = "Content", Description = "The content, either html or markdown.")]
[Editor(RuleFieldEditor.TextArea)] [Display(Name = "Tags", Description = "The optional comma separated list of tags.")]
[Formattable] [Editor(RuleFieldEditor.Text)]
public string Content { get; set; } [Formattable]
public string Tags { get; set; }
[Display(Name = "Canonical Url", Description = "The original home of this content, if it was originally published elsewhere.")]
[Editor(RuleFieldEditor.Text)] [Display(Name = "Publication Id", Description = "Optional publication id.")]
[Formattable] [Editor(RuleFieldEditor.Text)]
public string CanonicalUrl { get; set; } public string PublicationId { get; set; }
[Display(Name = "Tags", Description = "The optional comma separated list of tags.")] [Display(Name = "Is Html", Description = "Indicates whether the content is markdown or html.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
[Formattable] public bool IsHtml { get; set; }
public string Tags { get; set; }
[Display(Name = "Publication Id", Description = "Optional publication id.")]
[Editor(RuleFieldEditor.Text)]
public string PublicationId { get; set; }
[Display(Name = "Is Html", Description = "Indicates whether the content is markdown or html.")]
[Editor(RuleFieldEditor.Text)]
public bool IsHtml { get; set; }
}
} }

193
backend/extensions/Squidex.Extensions/Actions/Medium/MediumActionHandler.cs

@ -13,142 +13,141 @@ using Squidex.Infrastructure.Json;
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Medium namespace Squidex.Extensions.Actions.Medium;
public sealed class MediumActionHandler : RuleActionHandler<MediumAction, MediumJob>
{ {
public sealed class MediumActionHandler : RuleActionHandler<MediumAction, MediumJob> private const string Description = "Post to medium";
private readonly IHttpClientFactory httpClientFactory;
private readonly IJsonSerializer serializer;
private sealed class UserResponse
{ {
private const string Description = "Post to medium"; public UserResponseData Data { get; set; }
}
private readonly IHttpClientFactory httpClientFactory; private sealed class UserResponseData
private readonly IJsonSerializer serializer; {
public string Id { get; set; }
}
private sealed class UserResponse public MediumActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory, IJsonSerializer serializer)
: base(formatter)
{
this.httpClientFactory = httpClientFactory;
this.serializer = serializer;
}
protected override async Task<(string Description, MediumJob Data)> CreateJobAsync(EnrichedEvent @event, MediumAction action)
{
var ruleJob = new MediumJob { AccessToken = action.AccessToken, PublicationId = action.PublicationId };
var requestBody = new
{ {
public UserResponseData Data { get; set; } title = await FormatAsync(action.Title, @event),
} contentFormat = action.IsHtml ? "html" : "markdown",
content = await FormatAsync(action.Content, @event),
canonicalUrl = await FormatAsync(action.CanonicalUrl, @event),
tags = await ParseTagsAsync(@event, action)
};
private sealed class UserResponseData ruleJob.RequestBody = ToJson(requestBody);
return (Description, ruleJob);
}
private async Task<string[]> ParseTagsAsync(EnrichedEvent @event, MediumAction action)
{
if (string.IsNullOrWhiteSpace(action.Tags))
{ {
public string Id { get; set; } return null;
} }
public MediumActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory, IJsonSerializer serializer) try
: base(formatter)
{ {
this.httpClientFactory = httpClientFactory; var jsonTags = await FormatAsync(action.Tags, @event);
this.serializer = serializer; return serializer.Deserialize<string[]>(jsonTags);
} }
catch
protected override async Task<(string Description, MediumJob Data)> CreateJobAsync(EnrichedEvent @event, MediumAction action)
{ {
var ruleJob = new MediumJob { AccessToken = action.AccessToken, PublicationId = action.PublicationId }; return action.Tags.Split(',');
var requestBody = new
{
title = await FormatAsync(action.Title, @event),
contentFormat = action.IsHtml ? "html" : "markdown",
content = await FormatAsync(action.Content, @event),
canonicalUrl = await FormatAsync(action.CanonicalUrl, @event),
tags = await ParseTagsAsync(@event, action)
};
ruleJob.RequestBody = ToJson(requestBody);
return (Description, ruleJob);
} }
}
private async Task<string[]> ParseTagsAsync(EnrichedEvent @event, MediumAction action) protected override async Task<Result> ExecuteJobAsync(MediumJob job,
CancellationToken ct = default)
{
using (var httpClient = httpClientFactory.CreateClient())
{ {
if (string.IsNullOrWhiteSpace(action.Tags)) httpClient.Timeout = TimeSpan.FromSeconds(4);
{ httpClient.DefaultRequestHeaders.Add("Accept", "application/json");
return null; httpClient.DefaultRequestHeaders.Add("Accept-Charset", "utf-8");
} httpClient.DefaultRequestHeaders.Add("User-Agent", "Squidex Headless CMS");
try string path;
{
var jsonTags = await FormatAsync(action.Tags, @event);
return serializer.Deserialize<string[]>(jsonTags); if (!string.IsNullOrWhiteSpace(job.PublicationId))
}
catch
{ {
return action.Tags.Split(','); path = $"v1/publications/{job.PublicationId}/posts";
} }
} else
protected override async Task<Result> ExecuteJobAsync(MediumJob job,
CancellationToken ct = default)
{
using (var httpClient = httpClientFactory.CreateClient())
{ {
httpClient.Timeout = TimeSpan.FromSeconds(4); HttpResponseMessage response = null;
httpClient.DefaultRequestHeaders.Add("Accept", "application/json");
httpClient.DefaultRequestHeaders.Add("Accept-Charset", "utf-8");
httpClient.DefaultRequestHeaders.Add("User-Agent", "Squidex Headless CMS");
string path;
if (!string.IsNullOrWhiteSpace(job.PublicationId)) var meRequest = BuildMeRequest(job);
try
{ {
path = $"v1/publications/{job.PublicationId}/posts"; response = await httpClient.SendAsync(meRequest, ct);
}
else
{
HttpResponseMessage response = null;
var meRequest = BuildMeRequest(job);
try
{
response = await httpClient.SendAsync(meRequest, ct);
var responseString = await response.Content.ReadAsStringAsync(ct);
var responseJson = serializer.Deserialize<UserResponse>(responseString);
var id = responseJson.Data?.Id; var responseString = await response.Content.ReadAsStringAsync(ct);
var responseJson = serializer.Deserialize<UserResponse>(responseString);
path = $"v1/users/{id}/posts"; var id = responseJson.Data?.Id;
}
catch (Exception ex)
{
var requestDump = DumpFormatter.BuildDump(meRequest, response, ex.ToString());
return Result.Failed(ex, requestDump); path = $"v1/users/{id}/posts";
}
} }
catch (Exception ex)
{
var requestDump = DumpFormatter.BuildDump(meRequest, response, ex.ToString());
return await httpClient.OneWayRequestAsync(BuildPostRequest(job, path), job.RequestBody, ct); return Result.Failed(ex, requestDump);
}
} }
}
private static HttpRequestMessage BuildPostRequest(MediumJob job, string path) return await httpClient.OneWayRequestAsync(BuildPostRequest(job, path), job.RequestBody, ct);
{
var request = new HttpRequestMessage(HttpMethod.Post, $"https://api.medium.com/{path}")
{
Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json")
};
request.Headers.Add("Authorization", $"Bearer {job.AccessToken}");
return request;
} }
}
private static HttpRequestMessage BuildMeRequest(MediumJob job) private static HttpRequestMessage BuildPostRequest(MediumJob job, string path)
{
var request = new HttpRequestMessage(HttpMethod.Post, $"https://api.medium.com/{path}")
{ {
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.medium.com/v1/me"); Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json")
};
request.Headers.Add("Authorization", $"Bearer {job.AccessToken}"); request.Headers.Add("Authorization", $"Bearer {job.AccessToken}");
return request; return request;
}
} }
public sealed class MediumJob private static HttpRequestMessage BuildMeRequest(MediumJob job)
{ {
public string RequestBody { get; set; } var request = new HttpRequestMessage(HttpMethod.Get, "https://api.medium.com/v1/me");
public string PublicationId { get; set; } request.Headers.Add("Authorization", $"Bearer {job.AccessToken}");
public string AccessToken { get; set; } return request;
} }
} }
public sealed class MediumJob
{
public string RequestBody { get; set; }
public string PublicationId { get; set; }
public string AccessToken { get; set; }
}

11
backend/extensions/Squidex.Extensions/Actions/Medium/MediumPlugin.cs

@ -9,13 +9,12 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.Medium namespace Squidex.Extensions.Actions.Medium;
public sealed class MediumPlugin : IPlugin
{ {
public sealed class MediumPlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) services.AddRuleAction<MediumAction, MediumActionHandler>();
{
services.AddRuleAction<MediumAction, MediumActionHandler>();
}
} }
} }

51
backend/extensions/Squidex.Extensions/Actions/Notification/NotificationAction.cs

@ -10,34 +10,33 @@ using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Notification namespace Squidex.Extensions.Actions.Notification;
[RuleAction(
Title = "Notification",
IconImage = "<svg version='1.1' xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><path d='M20.016 15.984v-12h-16.031v14.016l2.016-2.016h14.016zM20.016 2.016c1.078 0 1.969 0.891 1.969 1.969v12c0 1.078-0.891 2.016-1.969 2.016h-14.016l-3.984 3.984v-18c0-1.078 0.891-1.969 1.969-1.969h16.031z'></path></svg>",
IconColor = "#3389ff",
Display = "Send a notification",
Description = "Send an integrated notification to a user.")]
public sealed record NotificationAction : RuleAction
{ {
[RuleAction( [LocalizedRequired]
Title = "Notification", [Display(Name = "User", Description = "The user id or email.")]
IconImage = "<svg version='1.1' xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><path d='M20.016 15.984v-12h-16.031v14.016l2.016-2.016h14.016zM20.016 2.016c1.078 0 1.969 0.891 1.969 1.969v12c0 1.078-0.891 2.016-1.969 2.016h-14.016l-3.984 3.984v-18c0-1.078 0.891-1.969 1.969-1.969h16.031z'></path></svg>", [Editor(RuleFieldEditor.Text)]
IconColor = "#3389ff", public string User { get; set; }
Display = "Send a notification",
Description = "Send an integrated notification to a user.")]
public sealed record NotificationAction : RuleAction
{
[LocalizedRequired]
[Display(Name = "User", Description = "The user id or email.")]
[Editor(RuleFieldEditor.Text)]
public string User { get; set; }
[LocalizedRequired] [LocalizedRequired]
[Display(Name = "Title", Description = "The text to send.")] [Display(Name = "Title", Description = "The text to send.")]
[Editor(RuleFieldEditor.TextArea)] [Editor(RuleFieldEditor.TextArea)]
[Formattable] [Formattable]
public string Text { get; set; } public string Text { get; set; }
[Display(Name = "Url", Description = "The optional url to attach to the notification.")] [Display(Name = "Url", Description = "The optional url to attach to the notification.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
[Formattable] [Formattable]
public string Url { get; set; } public string Url { get; set; }
[Display(Name = "Client", Description = "An optional client name.")] [Display(Name = "Client", Description = "An optional client name.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
public string Client { get; set; } public string Client { get; set; }
}
} }

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

@ -12,79 +12,78 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Shared.Users; using Squidex.Shared.Users;
namespace Squidex.Extensions.Actions.Notification namespace Squidex.Extensions.Actions.Notification;
public sealed class NotificationActionHandler : RuleActionHandler<NotificationAction, CreateComment>
{ {
public sealed class NotificationActionHandler : RuleActionHandler<NotificationAction, CreateComment> private const string Description = "Send a Notification";
{ private readonly ICommandBus commandBus;
private const string Description = "Send a Notification"; private readonly IUserResolver userResolver;
private readonly ICommandBus commandBus;
private readonly IUserResolver userResolver;
public NotificationActionHandler(RuleEventFormatter formatter, ICommandBus commandBus, IUserResolver userResolver) public NotificationActionHandler(RuleEventFormatter formatter, ICommandBus commandBus, IUserResolver userResolver)
: base(formatter) : base(formatter)
{ {
this.commandBus = commandBus; this.commandBus = commandBus;
this.userResolver = userResolver; this.userResolver = userResolver;
} }
protected override async Task<(string Description, CreateComment Data)> CreateJobAsync(EnrichedEvent @event, NotificationAction action) protected override async Task<(string Description, CreateComment Data)> CreateJobAsync(EnrichedEvent @event, NotificationAction action)
{
if (@event is EnrichedUserEventBase userEvent)
{ {
if (@event is EnrichedUserEventBase userEvent) var user = await userResolver.FindByIdOrEmailAsync(action.User);
if (user == null)
{ {
var user = await userResolver.FindByIdOrEmailAsync(action.User); throw new InvalidOperationException($"Cannot find user by '{action.User}'");
}
if (user == null) var actor = userEvent.Actor;
{
throw new InvalidOperationException($"Cannot find user by '{action.User}'");
}
var actor = userEvent.Actor; if (!string.IsNullOrEmpty(action.Client))
{
actor = RefToken.Client(action.Client);
}
if (!string.IsNullOrEmpty(action.Client)) var ruleJob = new CreateComment
{ {
actor = RefToken.Client(action.Client); 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);
var ruleJob = new CreateComment if (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri))
{ {
AppId = CommentsCommand.NoApp, ruleJob.Url = uri;
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 (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri))
{
ruleJob.Url = uri;
}
} }
return (Description, ruleJob);
} }
return ("Ignore", new CreateComment()); return (Description, ruleJob);
} }
protected override async Task<Result> ExecuteJobAsync(CreateComment job, return ("Ignore", new CreateComment());
CancellationToken ct = default) }
{
var command = job;
if (command.CommentsId == default)
{
return Result.Ignored();
}
await commandBus.PublishAsync(command, ct); protected override async Task<Result> ExecuteJobAsync(CreateComment job,
CancellationToken ct = default)
{
var command = job;
return Result.Success($"Notified: {command.Text}"); if (command.CommentsId == default)
{
return Result.Ignored();
} }
await commandBus.PublishAsync(command, ct);
return Result.Success($"Notified: {command.Text}");
} }
} }

11
backend/extensions/Squidex.Extensions/Actions/Notification/NotificationPlugin.cs

@ -9,13 +9,12 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.Notification namespace Squidex.Extensions.Actions.Notification;
public sealed class NotificationPlugin : IPlugin
{ {
public sealed class NotificationPlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) services.AddRuleAction<NotificationAction, NotificationActionHandler>();
{
services.AddRuleAction<NotificationAction, NotificationActionHandler>();
}
} }
} }

77
backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchAction.cs

@ -10,44 +10,43 @@ using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.OpenSearch namespace Squidex.Extensions.Actions.OpenSearch;
[RuleAction(
Title = "OpenSearch",
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><path d='M61.737 23.5a2.263 2.263 0 0 0-2.262 2.263c0 18.618-15.094 33.712-33.712 33.712a2.263 2.263 0 1 0 0 4.525C46.88 64 64 46.88 64 25.763a2.263 2.263 0 0 0-2.263-2.263Z' fill='#fff'/><path d='M48.081 38c2.176-3.55 4.28-8.282 3.866-14.908C51.09 9.367 38.66-1.045 26.921.084c-4.596.441-9.314 4.187-8.895 10.896.182 2.916 1.61 4.637 3.928 5.96 2.208 1.26 5.044 2.057 8.259 2.961 3.883 1.092 8.388 2.32 11.85 4.87 4.15 3.058 6.986 6.603 6.018 13.229Z' fill='#fff'/><path d='M3.919 14C1.743 17.55-.361 22.282.052 28.908.91 42.633 13.342 53.045 25.08 51.916c4.596-.441 9.314-4.187 8.895-10.896-.182-2.916-1.61-4.637-3.928-5.96-2.208-1.26-5.044-2.057-8.259-2.961-3.883-1.092-8.388-2.32-11.85-4.87C5.787 24.17 2.95 20.625 3.919 14Z' fill='#fff'/></svg>",
IconColor = "#005EB8",
Display = "Populate OpenSearch index",
Description = "Populate a full text search index in OpenSearch.",
ReadMore = "https://opensearch.org/")]
public sealed record OpenSearchAction : RuleAction
{ {
[RuleAction( [AbsoluteUrl]
Title = "OpenSearch", [LocalizedRequired]
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><path d='M61.737 23.5a2.263 2.263 0 0 0-2.262 2.263c0 18.618-15.094 33.712-33.712 33.712a2.263 2.263 0 1 0 0 4.525C46.88 64 64 46.88 64 25.763a2.263 2.263 0 0 0-2.263-2.263Z' fill='#fff'/><path d='M48.081 38c2.176-3.55 4.28-8.282 3.866-14.908C51.09 9.367 38.66-1.045 26.921.084c-4.596.441-9.314 4.187-8.895 10.896.182 2.916 1.61 4.637 3.928 5.96 2.208 1.26 5.044 2.057 8.259 2.961 3.883 1.092 8.388 2.32 11.85 4.87 4.15 3.058 6.986 6.603 6.018 13.229Z' fill='#fff'/><path d='M3.919 14C1.743 17.55-.361 22.282.052 28.908.91 42.633 13.342 53.045 25.08 51.916c4.596-.441 9.314-4.187 8.895-10.896-.182-2.916-1.61-4.637-3.928-5.96-2.208-1.26-5.044-2.057-8.259-2.961-3.883-1.092-8.388-2.32-11.85-4.87C5.787 24.17 2.95 20.625 3.919 14Z' fill='#fff'/></svg>", [Display(Name = "Server Url", Description = "The url to the instance or cluster.")]
IconColor = "#005EB8", [Editor(RuleFieldEditor.Url)]
Display = "Populate OpenSearch index", public Uri Host { get; set; }
Description = "Populate a full text search index in OpenSearch.",
ReadMore = "https://opensearch.org/")] [LocalizedRequired]
public sealed record OpenSearchAction : RuleAction [Display(Name = "Index Name", Description = "The name of the index.")]
{ [Editor(RuleFieldEditor.Text)]
[AbsoluteUrl] [Formattable]
[LocalizedRequired] public string IndexName { get; set; }
[Display(Name = "Server Url", Description = "The url to the instance or cluster.")]
[Editor(RuleFieldEditor.Url)] [Display(Name = "Username", Description = "The optional username.")]
public Uri Host { get; set; } [Editor(RuleFieldEditor.Text)]
public string Username { get; set; }
[LocalizedRequired]
[Display(Name = "Index Name", Description = "The name of the index.")] [Display(Name = "Password", Description = "The optional password.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
[Formattable] public string Password { get; set; }
public string IndexName { get; set; }
[Display(Name = "Document", Description = "The optional custom document.")]
[Display(Name = "Username", Description = "The optional username.")] [Editor(RuleFieldEditor.TextArea)]
[Editor(RuleFieldEditor.Text)] [Formattable]
public string Username { get; set; } public string Document { get; set; }
[Display(Name = "Password", Description = "The optional password.")] [Display(Name = "Deletion", Description = "The condition when to delete the document.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
public string Password { get; set; } public string Delete { get; set; }
[Display(Name = "Document", Description = "The optional custom document.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Document { get; set; }
[Display(Name = "Deletion", Description = "The condition when to delete the document.")]
[Editor(RuleFieldEditor.Text)]
public string Delete { get; set; }
}
} }

219
backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchActionHandler.cs

@ -16,154 +16,153 @@ using Squidex.Infrastructure.Json;
#pragma warning disable IDE0059 // Value assigned to symbol is never used #pragma warning disable IDE0059 // Value assigned to symbol is never used
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.OpenSearch namespace Squidex.Extensions.Actions.OpenSearch;
public sealed class OpenSearchActionHandler : RuleActionHandler<OpenSearchAction, OpenSearchJob>
{ {
public sealed class OpenSearchActionHandler : RuleActionHandler<OpenSearchAction, OpenSearchJob> private readonly ClientPool<(Uri Host, string Username, string Password), OpenSearchLowLevelClient> clients;
{ private readonly IScriptEngine scriptEngine;
private readonly ClientPool<(Uri Host, string Username, string Password), OpenSearchLowLevelClient> clients; private readonly IJsonSerializer serializer;
private readonly IScriptEngine scriptEngine;
private readonly IJsonSerializer serializer;
public OpenSearchActionHandler(RuleEventFormatter formatter, IScriptEngine scriptEngine, IJsonSerializer serializer) public OpenSearchActionHandler(RuleEventFormatter formatter, IScriptEngine scriptEngine, IJsonSerializer serializer)
: base(formatter) : base(formatter)
{
clients = new ClientPool<(Uri Host, string Username, string Password), OpenSearchLowLevelClient>(key =>
{ {
clients = new ClientPool<(Uri Host, string Username, string Password), OpenSearchLowLevelClient>(key => var config = new ConnectionConfiguration(key.Host);
if (!string.IsNullOrEmpty(key.Username) && !string.IsNullOrWhiteSpace(key.Password))
{ {
var config = new ConnectionConfiguration(key.Host); config = config.BasicAuthentication(key.Username, key.Password);
}
if (!string.IsNullOrEmpty(key.Username) && !string.IsNullOrWhiteSpace(key.Password)) return new OpenSearchLowLevelClient(config);
{ });
config = config.BasicAuthentication(key.Username, key.Password);
}
return new OpenSearchLowLevelClient(config); this.scriptEngine = scriptEngine;
}); this.serializer = serializer;
}
this.scriptEngine = scriptEngine; protected override async Task<(string Description, OpenSearchJob Data)> CreateJobAsync(EnrichedEvent @event, OpenSearchAction action)
this.serializer = serializer; {
} var delete = @event.ShouldDelete(scriptEngine, action.Delete);
protected override async Task<(string Description, OpenSearchJob Data)> CreateJobAsync(EnrichedEvent @event, OpenSearchAction action) string contentId;
{
var delete = @event.ShouldDelete(scriptEngine, action.Delete);
string contentId; if (@event is IEnrichedEntityEvent enrichedEntityEvent)
{
contentId = enrichedEntityEvent.Id.ToString();
}
else
{
contentId = DomainId.NewGuid().ToString();
}
if (@event is IEnrichedEntityEvent enrichedEntityEvent) var ruleDescription = string.Empty;
{ var ruleJob = new OpenSearchJob
contentId = enrichedEntityEvent.Id.ToString(); {
} IndexName = await FormatAsync(action.IndexName, @event),
else ServerHost = action.Host.ToString(),
{ ServerUser = action.Username,
contentId = DomainId.NewGuid().ToString(); ServerPassword = action.Password,
} ContentId = contentId
};
if (delete)
{
ruleDescription = $"Delete entry index: {action.IndexName}";
}
else
{
ruleDescription = $"Upsert to index: {action.IndexName}";
var ruleDescription = string.Empty; OpenSearchContent content;
var ruleJob = new OpenSearchJob try
{
IndexName = await FormatAsync(action.IndexName, @event),
ServerHost = action.Host.ToString(),
ServerUser = action.Username,
ServerPassword = action.Password,
ContentId = contentId
};
if (delete)
{
ruleDescription = $"Delete entry index: {action.IndexName}";
}
else
{ {
ruleDescription = $"Upsert to index: {action.IndexName}"; string jsonString;
OpenSearchContent content; if (!string.IsNullOrEmpty(action.Document))
try
{ {
string jsonString; jsonString = await FormatAsync(action.Document, @event);
jsonString = jsonString?.Trim();
if (!string.IsNullOrEmpty(action.Document))
{
jsonString = await FormatAsync(action.Document, @event);
jsonString = jsonString?.Trim();
}
else
{
jsonString = ToJson(@event);
}
content = serializer.Deserialize<OpenSearchContent>(jsonString);
} }
catch (Exception ex) else
{ {
content = new OpenSearchContent jsonString = ToJson(@event);
{
More = new Dictionary<string, object>
{
["error"] = $"Invalid JSON: {ex.Message}"
}
};
} }
content.ContentId = contentId; content = serializer.Deserialize<OpenSearchContent>(jsonString);
ruleJob.Content = serializer.Serialize(content, true);
} }
catch (Exception ex)
{
content = new OpenSearchContent
{
More = new Dictionary<string, object>
{
["error"] = $"Invalid JSON: {ex.Message}"
}
};
}
content.ContentId = contentId;
return (ruleDescription, ruleJob); ruleJob.Content = serializer.Serialize(content, true);
} }
protected override async Task<Result> ExecuteJobAsync(OpenSearchJob job, return (ruleDescription, ruleJob);
CancellationToken ct = default) }
protected override async Task<Result> ExecuteJobAsync(OpenSearchJob job,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(job.ServerHost))
{ {
if (string.IsNullOrWhiteSpace(job.ServerHost)) return Result.Ignored();
{ }
return Result.Ignored();
}
var client = await clients.GetClientAsync((new Uri(job.ServerHost, UriKind.Absolute), job.ServerUser, job.ServerPassword)); var client = await clients.GetClientAsync((new Uri(job.ServerHost, UriKind.Absolute), job.ServerUser, job.ServerPassword));
try try
{
if (job.Content != null)
{ {
if (job.Content != null) var response = await client.IndexAsync<StringResponse>(job.IndexName, job.ContentId, job.Content, ctx: ct);
{
var response = await client.IndexAsync<StringResponse>(job.IndexName, job.ContentId, job.Content, ctx: ct);
return Result.SuccessOrFailed(response.OriginalException, response.Body); return Result.SuccessOrFailed(response.OriginalException, response.Body);
}
else
{
var response = await client.DeleteAsync<StringResponse>(job.IndexName, job.ContentId, ctx: ct);
return Result.SuccessOrFailed(response.OriginalException, response.Body);
}
} }
catch (OpenSearchClientException ex) else
{ {
return Result.Failed(ex); var response = await client.DeleteAsync<StringResponse>(job.IndexName, job.ContentId, ctx: ct);
return Result.SuccessOrFailed(response.OriginalException, response.Body);
} }
} }
catch (OpenSearchClientException ex)
{
return Result.Failed(ex);
}
} }
}
public sealed class OpenSearchContent public sealed class OpenSearchContent
{ {
public string ContentId { get; set; } public string ContentId { get; set; }
[JsonExtensionData] [JsonExtensionData]
public Dictionary<string, object> More { get; set; } = new Dictionary<string, object>(); public Dictionary<string, object> More { get; set; } = new Dictionary<string, object>();
} }
public sealed class OpenSearchJob public sealed class OpenSearchJob
{ {
public string ServerHost { get; set; } public string ServerHost { get; set; }
public string ServerUser { get; set; } public string ServerUser { get; set; }
public string ServerPassword { get; set; } public string ServerPassword { get; set; }
public string ContentId { get; set; } public string ContentId { get; set; }
public string Content { get; set; } public string Content { get; set; }
public string IndexName { get; set; } public string IndexName { get; set; }
}
} }

11
backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchPlugin.cs

@ -9,13 +9,12 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.OpenSearch namespace Squidex.Extensions.Actions.OpenSearch;
public sealed class OpenSearchPlugin : IPlugin
{ {
public sealed class OpenSearchPlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) services.AddRuleAction<OpenSearchAction, OpenSearchActionHandler>();
{
services.AddRuleAction<OpenSearchAction, OpenSearchActionHandler>();
}
} }
} }

39
backend/extensions/Squidex.Extensions/Actions/Prerender/PrerenderAction.cs

@ -10,26 +10,25 @@ using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Prerender namespace Squidex.Extensions.Actions.Prerender;
[RuleAction(
Title = "Prerender",
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M2.073 17.984l8.646-5.36v-1.787L.356 17.325v1.318l10.363 6.488v-1.787zM29.927 17.984l-8.646-5.36v-1.787l10.363 6.488v1.318l-10.363 6.488v-1.787zM18.228 6.693l-6.276 19.426 1.656.548 6.276-19.426z'/></svg>",
IconColor = "#2c3e50",
Display = "Recache URL",
Description = "Prerender a javascript website for bots.",
ReadMore = "https://prerender.io")]
public sealed record PrerenderAction : RuleAction
{ {
[RuleAction( [LocalizedRequired]
Title = "Prerender", [Display(Name = "Token", Description = "The prerender token from your account.")]
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M2.073 17.984l8.646-5.36v-1.787L.356 17.325v1.318l10.363 6.488v-1.787zM29.927 17.984l-8.646-5.36v-1.787l10.363 6.488v1.318l-10.363 6.488v-1.787zM18.228 6.693l-6.276 19.426 1.656.548 6.276-19.426z'/></svg>", [Editor(RuleFieldEditor.Text)]
IconColor = "#2c3e50", [Formattable]
Display = "Recache URL", public string Token { get; set; }
Description = "Prerender a javascript website for bots.",
ReadMore = "https://prerender.io")]
public sealed record PrerenderAction : RuleAction
{
[LocalizedRequired]
[Display(Name = "Token", Description = "The prerender token from your account.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string Token { get; set; }
[LocalizedRequired] [LocalizedRequired]
[Display(Name = "Url", Description = "The url to recache.")] [Display(Name = "Url", Description = "The url to recache.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
public string Url { get; set; } public string Url { get; set; }
}
} }

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

@ -11,45 +11,44 @@ using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Prerender namespace Squidex.Extensions.Actions.Prerender;
public sealed class PrerenderActionHandler : RuleActionHandler<PrerenderAction, PrerenderJob>
{ {
public sealed class PrerenderActionHandler : RuleActionHandler<PrerenderAction, PrerenderJob> private readonly IHttpClientFactory httpClientFactory;
{
private readonly IHttpClientFactory httpClientFactory;
public PrerenderActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory) public PrerenderActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory)
: base(formatter) : base(formatter)
{ {
this.httpClientFactory = httpClientFactory; this.httpClientFactory = httpClientFactory;
} }
protected override async Task<(string Description, PrerenderJob Data)> CreateJobAsync(EnrichedEvent @event, PrerenderAction action) protected override async Task<(string Description, PrerenderJob Data)> CreateJobAsync(EnrichedEvent @event, PrerenderAction action)
{ {
var url = await FormatAsync(action.Url, @event); var url = await FormatAsync(action.Url, @event);
var request = new { prerenderToken = action.Token, url }; var request = new { prerenderToken = action.Token, url };
var requestBody = ToJson(request); var requestBody = ToJson(request);
return ($"Recache {url}", new PrerenderJob { RequestBody = requestBody }); return ($"Recache {url}", new PrerenderJob { RequestBody = requestBody });
} }
protected override async Task<Result> ExecuteJobAsync(PrerenderJob job, protected override async Task<Result> ExecuteJobAsync(PrerenderJob job,
CancellationToken ct = default) CancellationToken ct = default)
{
using (var httpClient = httpClientFactory.CreateClient())
{ {
using (var httpClient = httpClientFactory.CreateClient()) var request = new HttpRequestMessage(HttpMethod.Post, "https://api.prerender.io/recache")
{ {
var request = new HttpRequestMessage(HttpMethod.Post, "https://api.prerender.io/recache") Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json")
{ };
Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json")
};
return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct); return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct);
}
} }
} }
}
public sealed class PrerenderJob public sealed class PrerenderJob
{ {
public string RequestBody { get; set; } public string RequestBody { get; set; }
}
} }

11
backend/extensions/Squidex.Extensions/Actions/Prerender/PrerenderPlugin.cs

@ -9,13 +9,12 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.Prerender namespace Squidex.Extensions.Actions.Prerender;
public sealed class PrerenderPlugin : IPlugin
{ {
public sealed class PrerenderPlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) services.AddRuleAction<PrerenderAction, PrerenderActionHandler>();
{
services.AddRuleAction<PrerenderAction, PrerenderActionHandler>();
}
} }
} }

83
backend/extensions/Squidex.Extensions/Actions/RuleHelper.cs

@ -10,64 +10,63 @@ using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Infrastructure.Http; using Squidex.Infrastructure.Http;
namespace Squidex.Extensions.Actions namespace Squidex.Extensions.Actions;
public static class RuleHelper
{ {
public static class RuleHelper public static bool ShouldDelete(this EnrichedEvent @event, IScriptEngine scriptEngine, string expression)
{ {
public static bool ShouldDelete(this EnrichedEvent @event, IScriptEngine scriptEngine, string expression) if (!string.IsNullOrWhiteSpace(expression))
{ {
if (!string.IsNullOrWhiteSpace(expression)) // Script vars are just wrappers over dictionaries for better performance.
var vars = new EventScriptVars
{ {
// Script vars are just wrappers over dictionaries for better performance. Event = @event
var vars = new EventScriptVars };
{
Event = @event
};
return scriptEngine.Evaluate(vars, expression);
}
return IsContentDeletion(@event) || IsAssetDeletion(@event); return scriptEngine.Evaluate(vars, expression);
} }
public static bool IsContentDeletion(this EnrichedEvent @event) return IsContentDeletion(@event) || IsAssetDeletion(@event);
{ }
return @event is EnrichedContentEvent { Type: EnrichedContentEventType.Deleted or EnrichedContentEventType.Unpublished };
}
public static bool IsAssetDeletion(this EnrichedEvent @event) public static bool IsContentDeletion(this EnrichedEvent @event)
{ {
return @event is EnrichedAssetEvent { Type: EnrichedAssetEventType.Deleted }; return @event is EnrichedContentEvent { Type: EnrichedContentEventType.Deleted or EnrichedContentEventType.Unpublished };
} }
public static async Task<Result> OneWayRequestAsync(this HttpClient client, HttpRequestMessage request, string requestBody = null, public static bool IsAssetDeletion(this EnrichedEvent @event)
CancellationToken ct = default) {
{ return @event is EnrichedAssetEvent { Type: EnrichedAssetEventType.Deleted };
HttpResponseMessage response = null; }
try
{
response = await client.SendAsync(request, ct);
var responseString = await response.Content.ReadAsStringAsync(ct); public static async Task<Result> OneWayRequestAsync(this HttpClient client, HttpRequestMessage request, string requestBody = null,
var requestDump = DumpFormatter.BuildDump(request, response, requestBody, responseString); CancellationToken ct = default)
{
HttpResponseMessage response = null;
try
{
response = await client.SendAsync(request, ct);
if (!response.IsSuccessStatusCode) var responseString = await response.Content.ReadAsStringAsync(ct);
{ var requestDump = DumpFormatter.BuildDump(request, response, requestBody, responseString);
var ex = new HttpRequestException($"Response code does not indicate success: {(int)response.StatusCode} ({response.StatusCode}).");
return Result.Failed(ex, requestDump); if (!response.IsSuccessStatusCode)
}
else
{
return Result.Success(requestDump);
}
}
catch (Exception ex)
{ {
var requestDump = DumpFormatter.BuildDump(request, response, requestBody, ex.ToString()); var ex = new HttpRequestException($"Response code does not indicate success: {(int)response.StatusCode} ({response.StatusCode}).");
return Result.Failed(ex, requestDump); return Result.Failed(ex, requestDump);
} }
else
{
return Result.Success(requestDump);
}
}
catch (Exception ex)
{
var requestDump = DumpFormatter.BuildDump(request, response, requestBody, ex.ToString());
return Result.Failed(ex, requestDump);
} }
} }
} }

29
backend/extensions/Squidex.Extensions/Actions/Script/ScriptAction.cs

@ -10,20 +10,19 @@ using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Script namespace Squidex.Extensions.Actions.Script;
[RuleAction(
Title = "Script",
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'><path d='M112.155 67.644h84.212v236.019c0 106.375-50.969 143.497-132.414 143.497-19.944 0-45.429-3.324-62.052-8.864l9.419-68.146c11.635 3.878 26.594 6.648 43.214 6.648 35.458 0 57.621-16.068 57.621-73.687V67.644zM269.484 354.634c22.161 11.635 57.62 23.27 93.632 23.27 38.783 0 59.282-16.066 59.282-40.998 0-22.715-17.729-36.565-62.606-52.079-62.053-22.162-103.05-56.512-103.05-111.36 0-63.715 53.741-111.917 141.278-111.917 42.662 0 73.132 8.313 95.295 18.838l-18.839 67.592c-14.404-7.201-41.553-17.729-77.562-17.729-36.567 0-54.297 17.175-54.297 36.013 0 23.824 20.499 34.349 69.256 53.188 65.928 24.378 96.4 58.728 96.4 111.915 0 62.606-47.647 115.794-150.143 115.794-42.662 0-84.77-11.636-105.82-23.27l17.174-69.257z'/></svg>",
IconColor = "#f0be25",
Display = "Run a Script",
Description = "Runs a custom Javascript")]
public sealed record ScriptAction : RuleAction
{ {
[RuleAction( [LocalizedRequired]
Title = "Script", [Display(Name = "Script", Description = "The script to render.")]
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'><path d='M112.155 67.644h84.212v236.019c0 106.375-50.969 143.497-132.414 143.497-19.944 0-45.429-3.324-62.052-8.864l9.419-68.146c11.635 3.878 26.594 6.648 43.214 6.648 35.458 0 57.621-16.068 57.621-73.687V67.644zM269.484 354.634c22.161 11.635 57.62 23.27 93.632 23.27 38.783 0 59.282-16.066 59.282-40.998 0-22.715-17.729-36.565-62.606-52.079-62.053-22.162-103.05-56.512-103.05-111.36 0-63.715 53.741-111.917 141.278-111.917 42.662 0 73.132 8.313 95.295 18.838l-18.839 67.592c-14.404-7.201-41.553-17.729-77.562-17.729-36.567 0-54.297 17.175-54.297 36.013 0 23.824 20.499 34.349 69.256 53.188 65.928 24.378 96.4 58.728 96.4 111.915 0 62.606-47.647 115.794-150.143 115.794-42.662 0-84.77-11.636-105.82-23.27l17.174-69.257z'/></svg>", [Editor(RuleFieldEditor.Javascript)]
IconColor = "#f0be25", [Formattable]
Display = "Run a Script", public string Script { get; set; }
Description = "Runs a custom Javascript")]
public sealed record ScriptAction : RuleAction
{
[LocalizedRequired]
[Display(Name = "Script", Description = "The script to render.")]
[Editor(RuleFieldEditor.Javascript)]
[Formattable]
public string Script { get; set; }
}
} }

57
backend/extensions/Squidex.Extensions/Actions/Script/ScriptActionHandler.cs

@ -11,44 +11,43 @@ using Squidex.Domain.Apps.Core.Scripting;
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Script namespace Squidex.Extensions.Actions.Script;
public sealed class ScriptActionHandler : RuleActionHandler<ScriptAction, ScriptJob>
{ {
public sealed class ScriptActionHandler : RuleActionHandler<ScriptAction, ScriptJob> private readonly IScriptEngine scriptEngine;
{
private readonly IScriptEngine scriptEngine;
public ScriptActionHandler(RuleEventFormatter formatter, IScriptEngine scriptEngine) public ScriptActionHandler(RuleEventFormatter formatter, IScriptEngine scriptEngine)
: base(formatter) : base(formatter)
{ {
this.scriptEngine = scriptEngine; this.scriptEngine = scriptEngine;
} }
protected override Task<(string Description, ScriptJob Data)> CreateJobAsync(EnrichedEvent @event, ScriptAction action) protected override Task<(string Description, ScriptJob Data)> CreateJobAsync(EnrichedEvent @event, ScriptAction action)
{ {
var job = new ScriptJob { Script = action.Script, Event = @event }; var job = new ScriptJob { Script = action.Script, Event = @event };
return Task.FromResult(($"Run a script", job)); return Task.FromResult(($"Run a script", job));
} }
protected override async Task<Result> ExecuteJobAsync(ScriptJob job, protected override async Task<Result> ExecuteJobAsync(ScriptJob job,
CancellationToken ct = default) CancellationToken ct = default)
{
// Script vars are just wrappers over dictionaries for better performance.
var vars = new EventScriptVars
{ {
// Script vars are just wrappers over dictionaries for better performance. Event = job.Event
var vars = new EventScriptVars };
{
Event = job.Event
};
var result = await scriptEngine.ExecuteAsync(vars, job.Script, ct: ct); var result = await scriptEngine.ExecuteAsync(vars, job.Script, ct: ct);
return Result.Success(result.ToString()); return Result.Success(result.ToString());
}
} }
}
public sealed class ScriptJob public sealed class ScriptJob
{ {
public EnrichedEvent Event { get; set; } public EnrichedEvent Event { get; set; }
public string Script { get; set; } public string Script { get; set; }
}
} }

11
backend/extensions/Squidex.Extensions/Actions/Script/ScriptPlugin.cs

@ -9,13 +9,12 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.Script namespace Squidex.Extensions.Actions.Script;
public sealed class ScriptPlugin : IPlugin
{ {
public sealed class ScriptPlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) services.AddRuleAction<ScriptAction, ScriptActionHandler>();
{
services.AddRuleAction<ScriptAction, ScriptActionHandler>();
}
} }
} }

97
backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRAction.cs

@ -13,65 +13,64 @@ using Squidex.Infrastructure.Validation;
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.SignalR namespace Squidex.Extensions.Actions.SignalR;
[RuleAction(
Title = "Azure SignalR",
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M.011 16L0 6.248l12-1.63V16zM14 4.328L29.996 2v14H14zM30 18l-.004 14L14 29.75V18zM12 29.495L.01 27.851.009 18H12z'/></svg>",
IconColor = "#1566BF",
Display = "Send to Azure SignalR",
Description = "Send a message to Azure SignalR.",
ReadMore = "https://azure.microsoft.com/fr-fr/services/signalr-service/")]
public sealed record SignalRAction : RuleAction
{ {
[RuleAction( [LocalizedRequired]
Title = "Azure SignalR", [Display(Name = "Connection", Description = "The connection string to the Azure SignalR.")]
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M.011 16L0 6.248l12-1.63V16zM14 4.328L29.996 2v14H14zM30 18l-.004 14L14 29.75V18zM12 29.495L.01 27.851.009 18H12z'/></svg>", [Editor(RuleFieldEditor.Text)]
IconColor = "#1566BF", [Formattable]
Display = "Send to Azure SignalR", public string ConnectionString { get; set; }
Description = "Send a message to Azure SignalR.",
ReadMore = "https://azure.microsoft.com/fr-fr/services/signalr-service/")]
public sealed record SignalRAction : RuleAction
{
[LocalizedRequired]
[Display(Name = "Connection", Description = "The connection string to the Azure SignalR.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string ConnectionString { get; set; }
[LocalizedRequired] [LocalizedRequired]
[Display(Name = "Hub Name", Description = "The name of the hub.")] [Display(Name = "Hub Name", Description = "The name of the hub.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
[Formattable] [Formattable]
public string HubName { get; set; } public string HubName { get; set; }
[LocalizedRequired] [LocalizedRequired]
[Display(Name = "Action", Description = "* Broadcast = send to all users.\n * User = send to all target users(s).\n * Group = send to all target group(s).")] [Display(Name = "Action", Description = "* Broadcast = send to all users.\n * User = send to all target users(s).\n * Group = send to all target group(s).")]
public ActionTypeEnum Action { get; set; } public ActionTypeEnum Action { get; set; }
[Display(Name = "Methode Name", Description = "Set the Name of the hub method received by the customer.")] [Display(Name = "Methode Name", Description = "Set the Name of the hub method received by the customer.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
public string MethodName { get; set; } public string MethodName { get; set; }
[Display(Name = "Target (Optional)", Description = "Define target users or groups by id or name. One item per line. Not needed for Broadcast action.")] [Display(Name = "Target (Optional)", Description = "Define target users or groups by id or name. One item per line. Not needed for Broadcast action.")]
[Editor(RuleFieldEditor.TextArea)] [Editor(RuleFieldEditor.TextArea)]
[Formattable] [Formattable]
public string Target { get; set; } public string Target { get; set; }
[Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")] [Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")]
[Editor(RuleFieldEditor.TextArea)] [Editor(RuleFieldEditor.TextArea)]
[Formattable] [Formattable]
public string Payload { get; set; } public string Payload { get; set; }
protected override IEnumerable<ValidationError> CustomValidate() protected override IEnumerable<ValidationError> CustomValidate()
{
if (HubName != null && !Regex.IsMatch(HubName, "^[a-z][a-z0-9]{2,}(\\-[a-z0-9]+)*$"))
{ {
if (HubName != null && !Regex.IsMatch(HubName, "^[a-z][a-z0-9]{2,}(\\-[a-z0-9]+)*$")) yield return new ValidationError("Hub must be valid azure hub name.", nameof(HubName));
{ }
yield return new ValidationError("Hub must be valid azure hub name.", nameof(HubName));
}
if (Action != ActionTypeEnum.Broadcast && string.IsNullOrWhiteSpace(Target)) if (Action != ActionTypeEnum.Broadcast && string.IsNullOrWhiteSpace(Target))
{ {
yield return new ValidationError("Target must be specified with 'User' or 'Group' Action.", nameof(HubName)); yield return new ValidationError("Target must be specified with 'User' or 'Group' Action.", nameof(HubName));
}
} }
} }
}
public enum ActionTypeEnum public enum ActionTypeEnum
{ {
Broadcast, Broadcast,
User, User,
Group Group
}
} }

145
backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRActionHandler.cs

@ -12,100 +12,99 @@ using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.SignalR namespace Squidex.Extensions.Actions.SignalR;
public sealed class SignalRActionHandler : RuleActionHandler<SignalRAction, SignalRJob>
{ {
public sealed class SignalRActionHandler : RuleActionHandler<SignalRAction, SignalRJob> private readonly ClientPool<(string ConnectionString, string HubName), ServiceManager> clients;
public SignalRActionHandler(RuleEventFormatter formatter)
: base(formatter)
{
clients = new ClientPool<(string ConnectionString, string HubName), ServiceManager>(key =>
{
var serviceManager = new ServiceManagerBuilder()
.WithOptions(option =>
{
option.ConnectionString = key.ConnectionString;
option.ServiceTransportType = ServiceTransportType.Transient;
})
.BuildServiceManager();
return serviceManager;
});
}
protected override async Task<(string Description, SignalRJob Data)> CreateJobAsync(EnrichedEvent @event, SignalRAction action)
{ {
private readonly ClientPool<(string ConnectionString, string HubName), ServiceManager> clients; var hubName = await FormatAsync(action.HubName, @event);
public SignalRActionHandler(RuleEventFormatter formatter) string requestBody;
: base(formatter)
if (!string.IsNullOrWhiteSpace(action.Payload))
{ {
clients = new ClientPool<(string ConnectionString, string HubName), ServiceManager>(key => requestBody = await FormatAsync(action.Payload, @event);
{
var serviceManager = new ServiceManagerBuilder()
.WithOptions(option =>
{
option.ConnectionString = key.ConnectionString;
option.ServiceTransportType = ServiceTransportType.Transient;
})
.BuildServiceManager();
return serviceManager;
});
} }
else
protected override async Task<(string Description, SignalRJob Data)> CreateJobAsync(EnrichedEvent @event, SignalRAction action)
{ {
var hubName = await FormatAsync(action.HubName, @event); requestBody = ToEnvelopeJson(@event);
}
string requestBody;
if (!string.IsNullOrWhiteSpace(action.Payload)) var target = (await FormatAsync(action.Target, @event)) ?? string.Empty;
{
requestBody = await FormatAsync(action.Payload, @event);
}
else
{
requestBody = ToEnvelopeJson(@event);
}
var target = (await FormatAsync(action.Target, @event)) ?? string.Empty; var ruleDescription = $"Send SignalRJob to signalR hub '{hubName}'";
var ruleDescription = $"Send SignalRJob to signalR hub '{hubName}'"; var ruleJob = new SignalRJob
{
Action = action.Action,
ConnectionString = action.ConnectionString,
HubName = hubName,
MethodName = action.MethodName,
MethodPayload = requestBody,
Targets = target.Split("\n")
};
return (ruleDescription, ruleJob);
}
var ruleJob = new SignalRJob protected override async Task<Result> ExecuteJobAsync(SignalRJob job,
{ CancellationToken ct = default)
Action = action.Action, {
ConnectionString = action.ConnectionString, var signalR = await clients.GetClientAsync((job.ConnectionString, job.HubName));
HubName = hubName,
MethodName = action.MethodName,
MethodPayload = requestBody,
Targets = target.Split("\n")
};
return (ruleDescription, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(SignalRJob job, await using (var signalRContext = await signalR.CreateHubContextAsync(job.HubName, cancellationToken: ct))
CancellationToken ct = default)
{ {
var signalR = await clients.GetClientAsync((job.ConnectionString, job.HubName)); var methodeName = !string.IsNullOrWhiteSpace(job.MethodName) ? job.MethodName : "push";
await using (var signalRContext = await signalR.CreateHubContextAsync(job.HubName, cancellationToken: ct)) switch (job.Action)
{ {
var methodeName = !string.IsNullOrWhiteSpace(job.MethodName) ? job.MethodName : "push"; case ActionTypeEnum.Broadcast:
await signalRContext.Clients.All.SendAsync(methodeName, job.MethodPayload, ct);
switch (job.Action) break;
{ case ActionTypeEnum.User:
case ActionTypeEnum.Broadcast: await signalRContext.Clients.Users(job.Targets).SendAsync(methodeName, job.MethodPayload, ct);
await signalRContext.Clients.All.SendAsync(methodeName, job.MethodPayload, ct); break;
break; case ActionTypeEnum.Group:
case ActionTypeEnum.User: await signalRContext.Clients.Groups(job.Targets).SendAsync(methodeName, job.MethodPayload, ct);
await signalRContext.Clients.Users(job.Targets).SendAsync(methodeName, job.MethodPayload, ct); break;
break;
case ActionTypeEnum.Group:
await signalRContext.Clients.Groups(job.Targets).SendAsync(methodeName, job.MethodPayload, ct);
break;
}
} }
return Result.Complete();
} }
return Result.Complete();
} }
}
public sealed class SignalRJob public sealed class SignalRJob
{ {
public string ConnectionString { get; set; } public string ConnectionString { get; set; }
public string HubName { get; set; } public string HubName { get; set; }
public ActionTypeEnum Action { get; set; } public ActionTypeEnum Action { get; set; }
public string MethodName { get; set; } public string MethodName { get; set; }
public string MethodPayload { get; set; } public string MethodPayload { get; set; }
public string[] Targets { get; set; } public string[] Targets { get; set; }
}
} }

11
backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRPlugin.cs

@ -9,13 +9,12 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.SignalR namespace Squidex.Extensions.Actions.SignalR;
public sealed class SignalRPlugin : IPlugin
{ {
public sealed class SignalRPlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) services.AddRuleAction<SignalRAction, SignalRActionHandler>();
{
services.AddRuleAction<SignalRAction, SignalRActionHandler>();
}
} }
} }

41
backend/extensions/Squidex.Extensions/Actions/Slack/SlackAction.cs

@ -10,27 +10,26 @@ using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Slack namespace Squidex.Extensions.Actions.Slack;
[RuleAction(
Title = "Slack",
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 26 28'><path d='M23.734 12.125c1.281 0 2.266.938 2.266 2.219 0 1-.516 1.703-1.453 2.031l-2.688.922.875 2.609c.078.234.109.484.109.734 0 1.234-1 2.266-2.234 2.266a2.271 2.271 0 0 1-2.172-1.547l-.859-2.578-4.844 1.656.859 2.562c.078.234.125.484.125.734 0 1.219-1 2.266-2.25 2.266a2.25 2.25 0 0 1-2.156-1.547l-.859-2.547-2.391.828c-.25.078-.516.141-.781.141-1.266 0-2.219-.938-2.219-2.203 0-.969.625-1.844 1.547-2.156l2.438-.828-1.641-4.891-2.438.844c-.25.078-.5.125-.75.125-1.25 0-2.219-.953-2.219-2.203 0-.969.625-1.844 1.547-2.156l2.453-.828-.828-2.484a2.337 2.337 0 0 1-.125-.734c0-1.234 1-2.266 2.25-2.266a2.25 2.25 0 0 1 2.156 1.547l.844 2.5L13.14 5.5 12.296 3a2.337 2.337 0 0 1-.125-.734c0-1.234 1.016-2.266 2.25-2.266.984 0 1.859.625 2.172 1.547l.828 2.516 2.531-.859c.219-.063.438-.094.672-.094 1.219 0 2.266.906 2.266 2.156 0 .969-.75 1.781-1.625 2.078l-2.453.844 1.641 4.937 2.562-.875a2.32 2.32 0 0 1 .719-.125zm-12.406 4.094l4.844-1.641-1.641-4.922-4.844 1.672z'/></svg>",
IconColor = "#5c3a58",
Display = "Send to Slack",
Description = "Create a status update to a slack channel.",
ReadMore = "https://slack.com")]
public sealed record SlackAction : RuleAction
{ {
[RuleAction( [AbsoluteUrl]
Title = "Slack", [LocalizedRequired]
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 26 28'><path d='M23.734 12.125c1.281 0 2.266.938 2.266 2.219 0 1-.516 1.703-1.453 2.031l-2.688.922.875 2.609c.078.234.109.484.109.734 0 1.234-1 2.266-2.234 2.266a2.271 2.271 0 0 1-2.172-1.547l-.859-2.578-4.844 1.656.859 2.562c.078.234.125.484.125.734 0 1.219-1 2.266-2.25 2.266a2.25 2.25 0 0 1-2.156-1.547l-.859-2.547-2.391.828c-.25.078-.516.141-.781.141-1.266 0-2.219-.938-2.219-2.203 0-.969.625-1.844 1.547-2.156l2.438-.828-1.641-4.891-2.438.844c-.25.078-.5.125-.75.125-1.25 0-2.219-.953-2.219-2.203 0-.969.625-1.844 1.547-2.156l2.453-.828-.828-2.484a2.337 2.337 0 0 1-.125-.734c0-1.234 1-2.266 2.25-2.266a2.25 2.25 0 0 1 2.156 1.547l.844 2.5L13.14 5.5 12.296 3a2.337 2.337 0 0 1-.125-.734c0-1.234 1.016-2.266 2.25-2.266.984 0 1.859.625 2.172 1.547l.828 2.516 2.531-.859c.219-.063.438-.094.672-.094 1.219 0 2.266.906 2.266 2.156 0 .969-.75 1.781-1.625 2.078l-2.453.844 1.641 4.937 2.562-.875a2.32 2.32 0 0 1 .719-.125zm-12.406 4.094l4.844-1.641-1.641-4.922-4.844 1.672z'/></svg>", [Display(Name = "Webhook Url", Description = "The slack webhook url.")]
IconColor = "#5c3a58", [Editor(RuleFieldEditor.Text)]
Display = "Send to Slack", public Uri WebhookUrl { get; set; }
Description = "Create a status update to a slack channel.",
ReadMore = "https://slack.com")]
public sealed record SlackAction : RuleAction
{
[AbsoluteUrl]
[LocalizedRequired]
[Display(Name = "Webhook Url", Description = "The slack webhook url.")]
[Editor(RuleFieldEditor.Text)]
public Uri WebhookUrl { get; set; }
[LocalizedRequired] [LocalizedRequired]
[Display(Name = "Text", Description = "The text that is sent as message to slack.")] [Display(Name = "Text", Description = "The text that is sent as message to slack.")]
[Editor(RuleFieldEditor.TextArea)] [Editor(RuleFieldEditor.TextArea)]
[Formattable] [Formattable]
public string Text { get; set; } public string Text { get; set; }
}
} }

69
backend/extensions/Squidex.Extensions/Actions/Slack/SlackActionHandler.cs

@ -11,54 +11,53 @@ using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Slack namespace Squidex.Extensions.Actions.Slack;
public sealed class SlackActionHandler : RuleActionHandler<SlackAction, SlackJob>
{ {
public sealed class SlackActionHandler : RuleActionHandler<SlackAction, SlackJob> private const string Description = "Send message to slack";
private readonly IHttpClientFactory httpClientFactory;
public SlackActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory)
: base(formatter)
{ {
private const string Description = "Send message to slack"; this.httpClientFactory = httpClientFactory;
}
private readonly IHttpClientFactory httpClientFactory; protected override async Task<(string Description, SlackJob Data)> CreateJobAsync(EnrichedEvent @event, SlackAction action)
{
var body = new { text = await FormatAsync(action.Text, @event) };
public SlackActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory) var ruleJob = new SlackJob
: base(formatter)
{ {
this.httpClientFactory = httpClientFactory; RequestUrl = action.WebhookUrl.ToString(),
} RequestBody = ToJson(body)
};
return (Description, ruleJob);
}
protected override async Task<(string Description, SlackJob Data)> CreateJobAsync(EnrichedEvent @event, SlackAction action) protected override async Task<Result> ExecuteJobAsync(SlackJob job,
CancellationToken ct = default)
{
using (var httpClient = httpClientFactory.CreateClient())
{ {
var body = new { text = await FormatAsync(action.Text, @event) }; httpClient.Timeout = TimeSpan.FromSeconds(2);
var ruleJob = new SlackJob var request = new HttpRequestMessage(HttpMethod.Post, job.RequestUrl)
{ {
RequestUrl = action.WebhookUrl.ToString(), Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json")
RequestBody = ToJson(body)
}; };
return (Description, ruleJob); return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct);
}
protected override async Task<Result> ExecuteJobAsync(SlackJob job,
CancellationToken ct = default)
{
using (var httpClient = httpClientFactory.CreateClient())
{
httpClient.Timeout = TimeSpan.FromSeconds(2);
var request = new HttpRequestMessage(HttpMethod.Post, job.RequestUrl)
{
Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json")
};
return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct);
}
} }
} }
}
public sealed class SlackJob public sealed class SlackJob
{ {
public string RequestUrl { get; set; } public string RequestUrl { get; set; }
public string RequestBody { get; set; } public string RequestBody { get; set; }
}
} }

11
backend/extensions/Squidex.Extensions/Actions/Slack/SlackPlugin.cs

@ -9,13 +9,12 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.Slack namespace Squidex.Extensions.Actions.Slack;
public sealed class SlackPlugin : IPlugin
{ {
public sealed class SlackPlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) services.AddRuleAction<SlackAction, SlackActionHandler>();
{
services.AddRuleAction<SlackAction, SlackActionHandler>();
}
} }
} }

47
backend/extensions/Squidex.Extensions/Actions/Twitter/TweetAction.cs

@ -10,31 +10,30 @@ using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Twitter namespace Squidex.Extensions.Actions.Twitter;
[RuleAction(
Title = "Twitter",
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M32 7.075a12.941 12.941 0 0 1-3.769 1.031 6.601 6.601 0 0 0 2.887-3.631 13.21 13.21 0 0 1-4.169 1.594A6.565 6.565 0 0 0 22.155 4a6.563 6.563 0 0 0-6.563 6.563c0 .512.056 1.012.169 1.494A18.635 18.635 0 0 1 2.23 5.195a6.56 6.56 0 0 0-.887 3.3 6.557 6.557 0 0 0 2.919 5.463 6.565 6.565 0 0 1-2.975-.819v.081a6.565 6.565 0 0 0 5.269 6.437 6.574 6.574 0 0 1-2.968.112 6.588 6.588 0 0 0 6.131 4.563 13.17 13.17 0 0 1-9.725 2.719 18.568 18.568 0 0 0 10.069 2.95c12.075 0 18.681-10.006 18.681-18.681 0-.287-.006-.569-.019-.85A13.216 13.216 0 0 0 32 7.076z'/></svg>",
IconColor = "#1da1f2",
Display = "Tweet",
Description = "Tweet an update with your twitter account.",
ReadMore = "https://twitter.com")]
public sealed record TweetAction : RuleAction
{ {
[RuleAction( [LocalizedRequired]
Title = "Twitter", [Display(Name = "Access Token", Description = " The generated access token.")]
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M32 7.075a12.941 12.941 0 0 1-3.769 1.031 6.601 6.601 0 0 0 2.887-3.631 13.21 13.21 0 0 1-4.169 1.594A6.565 6.565 0 0 0 22.155 4a6.563 6.563 0 0 0-6.563 6.563c0 .512.056 1.012.169 1.494A18.635 18.635 0 0 1 2.23 5.195a6.56 6.56 0 0 0-.887 3.3 6.557 6.557 0 0 0 2.919 5.463 6.565 6.565 0 0 1-2.975-.819v.081a6.565 6.565 0 0 0 5.269 6.437 6.574 6.574 0 0 1-2.968.112 6.588 6.588 0 0 0 6.131 4.563 13.17 13.17 0 0 1-9.725 2.719 18.568 18.568 0 0 0 10.069 2.95c12.075 0 18.681-10.006 18.681-18.681 0-.287-.006-.569-.019-.85A13.216 13.216 0 0 0 32 7.076z'/></svg>", [Editor(RuleFieldEditor.Text)]
IconColor = "#1da1f2", public string AccessToken { get; set; }
Display = "Tweet",
Description = "Tweet an update with your twitter account.",
ReadMore = "https://twitter.com")]
public sealed record TweetAction : RuleAction
{
[LocalizedRequired]
[Display(Name = "Access Token", Description = " The generated access token.")]
[Editor(RuleFieldEditor.Text)]
public string AccessToken { get; set; }
[LocalizedRequired] [LocalizedRequired]
[Display(Name = "Access Secret", Description = " The generated access secret.")] [Display(Name = "Access Secret", Description = " The generated access secret.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
public string AccessSecret { get; set; } public string AccessSecret { get; set; }
[LocalizedRequired] [LocalizedRequired]
[Display(Name = "Text", Description = "The text that is sent as tweet to twitter.")] [Display(Name = "Text", Description = "The text that is sent as tweet to twitter.")]
[Editor(RuleFieldEditor.TextArea)] [Editor(RuleFieldEditor.TextArea)]
[Formattable] [Formattable]
public string Text { get; set; } public string Text { get; set; }
}
} }

79
backend/extensions/Squidex.Extensions/Actions/Twitter/TweetActionHandler.cs

@ -12,58 +12,57 @@ using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Twitter namespace Squidex.Extensions.Actions.Twitter;
public sealed class TweetActionHandler : RuleActionHandler<TweetAction, TweetJob>
{ {
public sealed class TweetActionHandler : RuleActionHandler<TweetAction, TweetJob> private const string Description = "Send a tweet";
{
private const string Description = "Send a tweet";
private readonly TwitterOptions twitterOptions; private readonly TwitterOptions twitterOptions;
public TweetActionHandler(RuleEventFormatter formatter, IOptions<TwitterOptions> twitterOptions) public TweetActionHandler(RuleEventFormatter formatter, IOptions<TwitterOptions> twitterOptions)
: base(formatter) : base(formatter)
{ {
this.twitterOptions = twitterOptions.Value; this.twitterOptions = twitterOptions.Value;
} }
protected override async Task<(string Description, TweetJob Data)> CreateJobAsync(EnrichedEvent @event, TweetAction action) protected override async Task<(string Description, TweetJob Data)> CreateJobAsync(EnrichedEvent @event, TweetAction action)
{
var ruleJob = new TweetJob
{ {
var ruleJob = new TweetJob Text = await FormatAsync(action.Text, @event),
{ AccessToken = action.AccessToken,
Text = await FormatAsync(action.Text, @event), AccessSecret = action.AccessSecret
AccessToken = action.AccessToken, };
AccessSecret = action.AccessSecret
}; return (Description, ruleJob);
}
return (Description, ruleJob); protected override async Task<Result> ExecuteJobAsync(TweetJob job,
} CancellationToken ct = default)
{
var tokens = Tokens.Create(
twitterOptions.ClientId,
twitterOptions.ClientSecret,
job.AccessToken,
job.AccessSecret);
protected override async Task<Result> ExecuteJobAsync(TweetJob job, var request = new Dictionary<string, object>
CancellationToken ct = default)
{ {
var tokens = Tokens.Create( ["status"] = job.Text
twitterOptions.ClientId, };
twitterOptions.ClientSecret,
job.AccessToken,
job.AccessSecret);
var request = new Dictionary<string, object> await tokens.Statuses.UpdateAsync(request, ct);
{
["status"] = job.Text
};
await tokens.Statuses.UpdateAsync(request, ct); return Result.Success($"Tweeted: {job.Text}");
return Result.Success($"Tweeted: {job.Text}");
}
} }
}
public sealed class TweetJob public sealed class TweetJob
{ {
public string AccessToken { get; set; } public string AccessToken { get; set; }
public string AccessSecret { get; set; } public string AccessSecret { get; set; }
public string Text { get; set; } public string Text { get; set; }
}
} }

11
backend/extensions/Squidex.Extensions/Actions/Twitter/TwitterOptions.cs

@ -5,12 +5,11 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
namespace Squidex.Extensions.Actions.Twitter namespace Squidex.Extensions.Actions.Twitter;
public sealed class TwitterOptions
{ {
public sealed class TwitterOptions public string ClientId { get; set; }
{
public string ClientId { get; set; }
public string ClientSecret { get; set; } public string ClientSecret { get; set; }
}
} }

15
backend/extensions/Squidex.Extensions/Actions/Twitter/TwitterPlugin.cs

@ -9,16 +9,15 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.Twitter namespace Squidex.Extensions.Actions.Twitter;
public sealed class TwitterPlugin : IPlugin
{ {
public sealed class TwitterPlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) services.Configure<TwitterOptions>(
{ config.GetSection("twitter"));
services.Configure<TwitterOptions>(
config.GetSection("twitter"));
services.AddRuleAction<TweetAction, TweetActionHandler>(); services.AddRuleAction<TweetAction, TweetActionHandler>();
}
} }
} }

63
backend/extensions/Squidex.Extensions/Actions/Typesense/TypesenseAction.cs

@ -10,41 +10,40 @@ using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Typesense namespace Squidex.Extensions.Actions.Typesense;
[RuleAction(
Title = "Typesense",
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 49.293 50.853'><path d='M15.074 15.493a8.19 8.19 0 0 1 .165 1.601c0 .479-.055.994-.165 1.546l-7.013-.055v18.552c0 1.546.718 2.32 2.154 2.32h4.196c.258.625.386 1.25.386 1.877 0 .625-.036 1.012-.11 1.159-1.693.22-3.442.331-5.245.331-3.57 0-5.356-1.527-5.356-4.582V18.585l-3.92.055A7.91 7.91 0 0 1 0 17.094c0-.515.055-1.049.166-1.601l3.92.055V9.751c0-.994.147-1.694.442-2.098.294-.442.865-.663 1.711-.663H7.73l.331.331v8.283z'/><path d='M18.296 40.848c.036-.81.257-1.693.662-2.65.442-.994.94-1.767 1.491-2.32 2.908 1.583 5.466 2.375 7.675 2.375 1.214 0 2.19-.24 2.926-.718.773-.479 1.16-1.123 1.16-1.933 0-1.288-.994-2.319-2.982-3.092l-3.092-1.16c-4.638-1.692-6.957-4.398-6.957-8.116 0-1.325.24-2.503.718-3.533a7.992 7.992 0 0 1 2.098-2.706c.92-.773 2.006-1.362 3.258-1.767 1.251-.405 2.65-.607 4.196-.607.7 0 1.472.055 2.32.165.882.11 1.766.277 2.65.497.883.184 1.73.405 2.54.663s1.508.534 2.097.828c0 .92-.184 1.877-.552 2.871-.368.994-.865 1.73-1.49 2.209-2.909-1.288-5.43-1.933-7.565-1.933-.957 0-1.712.24-2.264.718-.552.442-.828 1.03-.828 1.767 0 1.141.92 2.043 2.761 2.706l3.368 1.214c2.43.847 4.233 2.006 5.411 3.479 1.178 1.472 1.767 3.184 1.767 5.135 0 2.613-.976 4.711-2.927 6.294-1.95 1.546-4.748 2.32-8.392 2.32-3.57 0-6.92-.903-10.049-2.706z' style='fill:#fffff;fill-opacity:1' transform='translate(0 -.354)'/><path d='M45.373 50.687V.166A9.626 9.626 0 0 1 47.25 0c.736 0 1.417.055 2.042.166v50.521a11.8 11.8 0 0 1-2.042.166c-.7 0-1.326-.056-1.878-.166z'/></svg>",
IconColor = "#1035bc",
Display = "Populate Typesense index",
Description = "Populate a full text search index in Typesense.",
ReadMore = "https://www.elastic.co/")]
public sealed record TypesenseAction : RuleAction
{ {
[RuleAction( [AbsoluteUrl]
Title = "Typesense", [LocalizedRequired]
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 49.293 50.853'><path d='M15.074 15.493a8.19 8.19 0 0 1 .165 1.601c0 .479-.055.994-.165 1.546l-7.013-.055v18.552c0 1.546.718 2.32 2.154 2.32h4.196c.258.625.386 1.25.386 1.877 0 .625-.036 1.012-.11 1.159-1.693.22-3.442.331-5.245.331-3.57 0-5.356-1.527-5.356-4.582V18.585l-3.92.055A7.91 7.91 0 0 1 0 17.094c0-.515.055-1.049.166-1.601l3.92.055V9.751c0-.994.147-1.694.442-2.098.294-.442.865-.663 1.711-.663H7.73l.331.331v8.283z'/><path d='M18.296 40.848c.036-.81.257-1.693.662-2.65.442-.994.94-1.767 1.491-2.32 2.908 1.583 5.466 2.375 7.675 2.375 1.214 0 2.19-.24 2.926-.718.773-.479 1.16-1.123 1.16-1.933 0-1.288-.994-2.319-2.982-3.092l-3.092-1.16c-4.638-1.692-6.957-4.398-6.957-8.116 0-1.325.24-2.503.718-3.533a7.992 7.992 0 0 1 2.098-2.706c.92-.773 2.006-1.362 3.258-1.767 1.251-.405 2.65-.607 4.196-.607.7 0 1.472.055 2.32.165.882.11 1.766.277 2.65.497.883.184 1.73.405 2.54.663s1.508.534 2.097.828c0 .92-.184 1.877-.552 2.871-.368.994-.865 1.73-1.49 2.209-2.909-1.288-5.43-1.933-7.565-1.933-.957 0-1.712.24-2.264.718-.552.442-.828 1.03-.828 1.767 0 1.141.92 2.043 2.761 2.706l3.368 1.214c2.43.847 4.233 2.006 5.411 3.479 1.178 1.472 1.767 3.184 1.767 5.135 0 2.613-.976 4.711-2.927 6.294-1.95 1.546-4.748 2.32-8.392 2.32-3.57 0-6.92-.903-10.049-2.706z' style='fill:#fffff;fill-opacity:1' transform='translate(0 -.354)'/><path d='M45.373 50.687V.166A9.626 9.626 0 0 1 47.25 0c.736 0 1.417.055 2.042.166v50.521a11.8 11.8 0 0 1-2.042.166c-.7 0-1.326-.056-1.878-.166z'/></svg>", [Display(Name = "Server Url", Description = "The url to the instance or cluster.")]
IconColor = "#1035bc", [Editor(RuleFieldEditor.Url)]
Display = "Populate Typesense index", public Uri Host { get; set; }
Description = "Populate a full text search index in Typesense.",
ReadMore = "https://www.elastic.co/")]
public sealed record TypesenseAction : RuleAction
{
[AbsoluteUrl]
[LocalizedRequired]
[Display(Name = "Server Url", Description = "The url to the instance or cluster.")]
[Editor(RuleFieldEditor.Url)]
public Uri Host { get; set; }
[LocalizedRequired] [LocalizedRequired]
[Display(Name = "Index Name", Description = "The name of the index.")] [Display(Name = "Index Name", Description = "The name of the index.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
[Formattable] [Formattable]
public string IndexName { get; set; } public string IndexName { get; set; }
[LocalizedRequired] [LocalizedRequired]
[Display(Name = "Api Key", Description = "The api key.")] [Display(Name = "Api Key", Description = "The api key.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
public string ApiKey { get; set; } public string ApiKey { get; set; }
[Display(Name = "Document", Description = "The optional custom document.")] [Display(Name = "Document", Description = "The optional custom document.")]
[Editor(RuleFieldEditor.TextArea)] [Editor(RuleFieldEditor.TextArea)]
[Formattable] [Formattable]
public string Document { get; set; } public string Document { get; set; }
[Display(Name = "Deletion", Description = "The condition when to delete the document.")] [Display(Name = "Deletion", Description = "The condition when to delete the document.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.Text)]
public string Delete { get; set; } public string Delete { get; set; }
}
} }

199
backend/extensions/Squidex.Extensions/Actions/Typesense/TypesenseActionHandler.cs

@ -16,140 +16,139 @@ using Squidex.Infrastructure.Json;
#pragma warning disable IDE0059 // Value assigned to symbol is never used #pragma warning disable IDE0059 // Value assigned to symbol is never used
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Typesense namespace Squidex.Extensions.Actions.Typesense;
public sealed class TypesenseActionHandler : RuleActionHandler<TypesenseAction, TypesenseJob>
{ {
public sealed class TypesenseActionHandler : RuleActionHandler<TypesenseAction, TypesenseJob> private readonly IScriptEngine scriptEngine;
private readonly IHttpClientFactory httpClientFactory;
private readonly IJsonSerializer serializer;
public TypesenseActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory, IScriptEngine scriptEngine, IJsonSerializer serializer)
: base(formatter)
{
this.scriptEngine = scriptEngine;
this.httpClientFactory = httpClientFactory;
this.serializer = serializer;
}
protected override async Task<(string Description, TypesenseJob Data)> CreateJobAsync(EnrichedEvent @event, TypesenseAction action)
{ {
private readonly IScriptEngine scriptEngine; var delete = @event.ShouldDelete(scriptEngine, action.Delete);
private readonly IHttpClientFactory httpClientFactory;
private readonly IJsonSerializer serializer; string contentId;
public TypesenseActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory, IScriptEngine scriptEngine, IJsonSerializer serializer) if (@event is IEnrichedEntityEvent enrichedEntityEvent)
: base(formatter)
{ {
this.scriptEngine = scriptEngine; contentId = enrichedEntityEvent.Id.ToString();
this.httpClientFactory = httpClientFactory;
this.serializer = serializer;
} }
else
protected override async Task<(string Description, TypesenseJob Data)> CreateJobAsync(EnrichedEvent @event, TypesenseAction action)
{ {
var delete = @event.ShouldDelete(scriptEngine, action.Delete); contentId = DomainId.NewGuid().ToString();
}
string contentId;
if (@event is IEnrichedEntityEvent enrichedEntityEvent) var indexName = await FormatAsync(action.IndexName, @event);
{
contentId = enrichedEntityEvent.Id.ToString();
}
else
{
contentId = DomainId.NewGuid().ToString();
}
var indexName = await FormatAsync(action.IndexName, @event); var ruleDescription = string.Empty;
var ruleJob = new TypesenseJob
{
ServerUrl = $"{action.Host.ToString().TrimEnd('/')}/collections/{indexName}/documents",
ServerKey = action.ApiKey,
ContentId = contentId
};
var ruleDescription = string.Empty; if (delete)
var ruleJob = new TypesenseJob {
{ ruleDescription = $"Delete entry index: {action.IndexName}";
ServerUrl = $"{action.Host.ToString().TrimEnd('/')}/collections/{indexName}/documents", }
ServerKey = action.ApiKey, else
ContentId = contentId {
}; ruleDescription = $"Upsert to index: {action.IndexName}";
if (delete) TypesenseContent content;
{ try
ruleDescription = $"Delete entry index: {action.IndexName}";
}
else
{ {
ruleDescription = $"Upsert to index: {action.IndexName}"; string jsonString;
TypesenseContent content; if (!string.IsNullOrEmpty(action.Document))
try
{ {
string jsonString; jsonString = await FormatAsync(action.Document, @event);
jsonString = jsonString?.Trim();
if (!string.IsNullOrEmpty(action.Document))
{
jsonString = await FormatAsync(action.Document, @event);
jsonString = jsonString?.Trim();
}
else
{
jsonString = ToJson(@event);
}
content = serializer.Deserialize<TypesenseContent>(jsonString);
} }
catch (Exception ex) else
{ {
content = new TypesenseContent jsonString = ToJson(@event);
{
More = new Dictionary<string, object>
{
["error"] = $"Invalid JSON: {ex.Message}"
}
};
} }
content.Id = contentId; content = serializer.Deserialize<TypesenseContent>(jsonString);
ruleJob.Content = serializer.Serialize(content, true);
} }
catch (Exception ex)
{
content = new TypesenseContent
{
More = new Dictionary<string, object>
{
["error"] = $"Invalid JSON: {ex.Message}"
}
};
}
content.Id = contentId;
return (ruleDescription, ruleJob); ruleJob.Content = serializer.Serialize(content, true);
} }
protected override async Task<Result> ExecuteJobAsync(TypesenseJob job, return (ruleDescription, ruleJob);
CancellationToken ct = default) }
protected override async Task<Result> ExecuteJobAsync(TypesenseJob job,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(job.ServerUrl))
{ {
if (string.IsNullOrWhiteSpace(job.ServerUrl)) return Result.Ignored();
{ }
return Result.Ignored();
}
var httpClient = httpClientFactory.CreateClient(); var httpClient = httpClientFactory.CreateClient();
HttpRequestMessage request; HttpRequestMessage request;
if (job.Content != null) if (job.Content != null)
{ {
request = new HttpRequestMessage(HttpMethod.Post, $"{job.ServerUrl}?action=upsert") request = new HttpRequestMessage(HttpMethod.Post, $"{job.ServerUrl}?action=upsert")
{
Content = new StringContent(job.Content, Encoding.UTF8, "application/json")
};
}
else
{ {
request = new HttpRequestMessage(HttpMethod.Delete, $"{job.ServerUrl}/{job.ContentId}"); Content = new StringContent(job.Content, Encoding.UTF8, "application/json")
} };
}
else
{
request = new HttpRequestMessage(HttpMethod.Delete, $"{job.ServerUrl}/{job.ContentId}");
}
using (request) using (request)
{ {
request.Headers.TryAddWithoutValidation("X-Typesense-Api-Key", job.ServerKey); request.Headers.TryAddWithoutValidation("X-Typesense-Api-Key", job.ServerKey);
return await httpClient.OneWayRequestAsync(request, job.Content, ct); return await httpClient.OneWayRequestAsync(request, job.Content, ct);
}
} }
} }
}
public sealed class TypesenseContent public sealed class TypesenseContent
{ {
public string Id { get; set; } public string Id { get; set; }
[JsonExtensionData] [JsonExtensionData]
public Dictionary<string, object> More { get; set; } = new Dictionary<string, object>(); public Dictionary<string, object> More { get; set; } = new Dictionary<string, object>();
} }
public sealed class TypesenseJob public sealed class TypesenseJob
{ {
public string ServerUrl { get; set; } public string ServerUrl { get; set; }
public string ServerKey { get; set; } public string ServerKey { get; set; }
public string Content { get; set; } public string Content { get; set; }
public string ContentId { get; set; } public string ContentId { get; set; }
}
} }

11
backend/extensions/Squidex.Extensions/Actions/Typesense/TypesensePlugin.cs

@ -9,13 +9,12 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.Typesense namespace Squidex.Extensions.Actions.Typesense;
public sealed class TypesensePlugin : IPlugin
{ {
public sealed class TypesensePlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) services.AddRuleAction<TypesenseAction, TypesenseActionHandler>();
{
services.AddRuleAction<TypesenseAction, TypesenseActionHandler>();
}
} }
} }

91
backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs

@ -12,51 +12,50 @@ using Squidex.Infrastructure.Validation;
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Webhook namespace Squidex.Extensions.Actions.Webhook;
[RuleAction(
Title = "Webhook",
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 28 28'><path d='M5.95 27.125h-.262C1.75 26.425 0 23.187 0 20.3c0-2.713 1.575-5.688 5.075-6.563V9.712c0-.525.35-.875.875-.875s.875.35.875.875v4.725c0 .438-.35.787-.7.875-2.975.438-4.375 2.8-4.375 4.988s1.313 4.55 4.2 5.075h.175a.907.907 0 0 1 .7 1.05c-.088.438-.438.7-.875.7zM21.175 27.387c-2.8 0-5.775-1.662-6.65-5.075H9.712c-.525 0-.875-.35-.875-.875s.35-.875.875-.875h5.512c.438 0 .787.35.875.7.438 2.975 2.8 4.288 4.988 4.375 2.188 0 4.55-1.313 5.075-4.2v-.088a.908.908 0 0 1 1.05-.7.908.908 0 0 1 .7 1.05v.088c-.612 3.85-3.85 5.6-6.737 5.6zM21.525 18.55c-.525 0-.875-.35-.875-.875v-4.813c0-.438.35-.787.7-.875 2.975-.438 4.288-2.8 4.375-4.987 0-2.188-1.313-4.55-4.2-5.075h-.088c-.525-.175-.875-.613-.787-1.05s.525-.788 1.05-.7h.088c3.938.7 5.688 3.937 5.688 6.825 0 2.713-1.662 5.688-5.075 6.563v4.113c0 .438-.438.875-.875.875zM1.137 6.737H.962c-.438-.087-.788-.525-.7-.963v-.087c.7-3.938 3.85-5.688 6.737-5.688h.087c2.712 0 5.688 1.662 6.563 5.075h4.025c.525 0 .875.35.875.875s-.35.875-.875.875h-4.725c-.438 0-.788-.35-.875-.7-.438-2.975-2.8-4.288-4.988-4.375-2.188 0-4.55 1.313-5.075 4.2v.087c-.088.438-.438.7-.875.7z'/><path d='M7 10.588c-.875 0-1.837-.35-2.538-1.05a3.591 3.591 0 0 1 0-5.075C5.162 3.851 6.037 3.5 7 3.5s1.838.35 2.537 1.05c.7.7 1.05 1.575 1.05 2.537s-.35 1.837-1.05 2.538c-.7.612-1.575.963-2.537.963zM7 5.25c-.438 0-.875.175-1.225.525a1.795 1.795 0 0 0 2.538 2.538c.35-.35.525-.788.525-1.313s-.175-.875-.525-1.225S7.525 5.25 7 5.25zM21.088 23.887a3.65 3.65 0 0 1-2.537-1.05 3.591 3.591 0 0 1 0-5.075c.7-.7 1.575-1.05 2.537-1.05s1.838.35 2.537 1.05c.7.7 1.05 1.575 1.05 2.538s-.35 1.837-1.05 2.537c-.787.7-1.662 1.05-2.537 1.05zm0-5.337c-.525 0-.963.175-1.313.525a1.795 1.795 0 0 0 2.537 2.538c.35-.35.525-.788.525-1.313s-.175-.963-.525-1.313-.787-.438-1.225-.438zM20.387 10.588c-.875 0-1.837-.35-2.537-1.05S16.8 7.963 16.8 7.001s.35-1.837 1.05-2.538c.7-.612 1.662-.962 2.537-.962s1.838.35 2.538 1.05c1.4 1.4 1.4 3.675 0 5.075-.7.612-1.575.963-2.538.963zm0-5.338c-.525 0-.962.175-1.313.525s-.525.788-.525 1.313.175.962.525 1.313c.7.7 1.838.7 2.538 0s.7-1.838 0-2.538c-.263-.438-.7-.612-1.225-.612zM7.087 23.887c-.875 0-1.837-.35-2.538-1.05s-1.05-1.575-1.05-2.537.35-1.838 1.05-2.538c.7-.612 1.575-.962 2.538-.962s1.837.35 2.538 1.05c1.4 1.4 1.4 3.675 0 5.075-.7.612-1.575.962-2.538.962zm0-5.337c-.525 0-.962.175-1.313.525s-.525.788-.525 1.313.175.963.525 1.313a1.794 1.794 0 1 0 2.538-2.537c-.263-.438-.7-.612-1.225-.612z'/></svg>",
IconColor = "#4bb958",
Display = "Send webhook",
Description = "Invoke HTTP endpoints on a target system.",
ReadMore = "https://en.wikipedia.org/wiki/Webhook")]
public sealed record WebhookAction : RuleAction
{ {
[RuleAction( [LocalizedRequired]
Title = "Webhook", [Display(Name = "Url", Description = "The url to the webhook.")]
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 28 28'><path d='M5.95 27.125h-.262C1.75 26.425 0 23.187 0 20.3c0-2.713 1.575-5.688 5.075-6.563V9.712c0-.525.35-.875.875-.875s.875.35.875.875v4.725c0 .438-.35.787-.7.875-2.975.438-4.375 2.8-4.375 4.988s1.313 4.55 4.2 5.075h.175a.907.907 0 0 1 .7 1.05c-.088.438-.438.7-.875.7zM21.175 27.387c-2.8 0-5.775-1.662-6.65-5.075H9.712c-.525 0-.875-.35-.875-.875s.35-.875.875-.875h5.512c.438 0 .787.35.875.7.438 2.975 2.8 4.288 4.988 4.375 2.188 0 4.55-1.313 5.075-4.2v-.088a.908.908 0 0 1 1.05-.7.908.908 0 0 1 .7 1.05v.088c-.612 3.85-3.85 5.6-6.737 5.6zM21.525 18.55c-.525 0-.875-.35-.875-.875v-4.813c0-.438.35-.787.7-.875 2.975-.438 4.288-2.8 4.375-4.987 0-2.188-1.313-4.55-4.2-5.075h-.088c-.525-.175-.875-.613-.787-1.05s.525-.788 1.05-.7h.088c3.938.7 5.688 3.937 5.688 6.825 0 2.713-1.662 5.688-5.075 6.563v4.113c0 .438-.438.875-.875.875zM1.137 6.737H.962c-.438-.087-.788-.525-.7-.963v-.087c.7-3.938 3.85-5.688 6.737-5.688h.087c2.712 0 5.688 1.662 6.563 5.075h4.025c.525 0 .875.35.875.875s-.35.875-.875.875h-4.725c-.438 0-.788-.35-.875-.7-.438-2.975-2.8-4.288-4.988-4.375-2.188 0-4.55 1.313-5.075 4.2v.087c-.088.438-.438.7-.875.7z'/><path d='M7 10.588c-.875 0-1.837-.35-2.538-1.05a3.591 3.591 0 0 1 0-5.075C5.162 3.851 6.037 3.5 7 3.5s1.838.35 2.537 1.05c.7.7 1.05 1.575 1.05 2.537s-.35 1.837-1.05 2.538c-.7.612-1.575.963-2.537.963zM7 5.25c-.438 0-.875.175-1.225.525a1.795 1.795 0 0 0 2.538 2.538c.35-.35.525-.788.525-1.313s-.175-.875-.525-1.225S7.525 5.25 7 5.25zM21.088 23.887a3.65 3.65 0 0 1-2.537-1.05 3.591 3.591 0 0 1 0-5.075c.7-.7 1.575-1.05 2.537-1.05s1.838.35 2.537 1.05c.7.7 1.05 1.575 1.05 2.538s-.35 1.837-1.05 2.537c-.787.7-1.662 1.05-2.537 1.05zm0-5.337c-.525 0-.963.175-1.313.525a1.795 1.795 0 0 0 2.537 2.538c.35-.35.525-.788.525-1.313s-.175-.963-.525-1.313-.787-.438-1.225-.438zM20.387 10.588c-.875 0-1.837-.35-2.537-1.05S16.8 7.963 16.8 7.001s.35-1.837 1.05-2.538c.7-.612 1.662-.962 2.537-.962s1.838.35 2.538 1.05c1.4 1.4 1.4 3.675 0 5.075-.7.612-1.575.963-2.538.963zm0-5.338c-.525 0-.962.175-1.313.525s-.525.788-.525 1.313.175.962.525 1.313c.7.7 1.838.7 2.538 0s.7-1.838 0-2.538c-.263-.438-.7-.612-1.225-.612zM7.087 23.887c-.875 0-1.837-.35-2.538-1.05s-1.05-1.575-1.05-2.537.35-1.838 1.05-2.538c.7-.612 1.575-.962 2.538-.962s1.837.35 2.538 1.05c1.4 1.4 1.4 3.675 0 5.075-.7.612-1.575.962-2.538.962zm0-5.337c-.525 0-.962.175-1.313.525s-.525.788-.525 1.313.175.963.525 1.313a1.794 1.794 0 1 0 2.538-2.537c-.263-.438-.7-.612-1.225-.612z'/></svg>", [Editor(RuleFieldEditor.Text)]
IconColor = "#4bb958", [Formattable]
Display = "Send webhook", public Uri Url { get; set; }
Description = "Invoke HTTP endpoints on a target system.",
ReadMore = "https://en.wikipedia.org/wiki/Webhook")] [LocalizedRequired]
public sealed record WebhookAction : RuleAction [Display(Name = "Method", Description = "The type of the request.")]
{ public WebhookMethod Method { get; set; }
[LocalizedRequired]
[Display(Name = "Url", Description = "The url to the webhook.")] [Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")]
[Editor(RuleFieldEditor.Text)] [Editor(RuleFieldEditor.TextArea)]
[Formattable] [Formattable]
public Uri Url { get; set; } public string Payload { get; set; }
[LocalizedRequired] [Display(Name = "Payload Type", Description = "The mime type of the payload.")]
[Display(Name = "Method", Description = "The type of the request.")] [Editor(RuleFieldEditor.Text)]
public WebhookMethod Method { get; set; } public string PayloadType { get; set; }
[Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")] [Display(Name = "Headers (Optional)", Description = "The message headers in the format '[Key]=[Value]', one entry per line.")]
[Editor(RuleFieldEditor.TextArea)] [Editor(RuleFieldEditor.TextArea)]
[Formattable] public string Headers { get; set; }
public string Payload { get; set; }
[Display(Name = "Shared Secret", Description = "The shared secret that is used to calculate the payload signature.")]
[Display(Name = "Payload Type", Description = "The mime type of the payload.")] [Editor(RuleFieldEditor.Text)]
[Editor(RuleFieldEditor.Text)] public string SharedSecret { get; set; }
public string PayloadType { get; set; } }
[Display(Name = "Headers (Optional)", Description = "The message headers in the format '[Key]=[Value]', one entry per line.")] public enum WebhookMethod
[Editor(RuleFieldEditor.TextArea)] {
public string Headers { get; set; } POST,
PUT,
[Display(Name = "Shared Secret", Description = "The shared secret that is used to calculate the payload signature.")] GET,
[Editor(RuleFieldEditor.Text)] DELETE,
public string SharedSecret { get; set; } PATCH
}
public enum WebhookMethod
{
POST,
PUT,
GET,
DELETE,
PATCH
}
} }

205
backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs

@ -12,146 +12,145 @@ using Squidex.Infrastructure;
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Webhook namespace Squidex.Extensions.Actions.Webhook;
public sealed class WebhookActionHandler : RuleActionHandler<WebhookAction, WebhookJob>
{ {
public sealed class WebhookActionHandler : RuleActionHandler<WebhookAction, WebhookJob> private readonly IHttpClientFactory httpClientFactory;
public WebhookActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory)
: base(formatter)
{ {
private readonly IHttpClientFactory httpClientFactory; this.httpClientFactory = httpClientFactory;
}
public WebhookActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory) protected override async Task<(string Description, WebhookJob Data)> CreateJobAsync(EnrichedEvent @event, WebhookAction action)
: base(formatter) {
{ var requestUrl = await FormatAsync(action.Url, @event);
this.httpClientFactory = httpClientFactory; var requestBody = string.Empty;
} var requestSignature = string.Empty;
protected override async Task<(string Description, WebhookJob Data)> CreateJobAsync(EnrichedEvent @event, WebhookAction action) if (action.Method != WebhookMethod.GET)
{ {
var requestUrl = await FormatAsync(action.Url, @event); if (!string.IsNullOrEmpty(action.Payload))
var requestBody = string.Empty;
var requestSignature = string.Empty;
if (action.Method != WebhookMethod.GET)
{ {
if (!string.IsNullOrEmpty(action.Payload)) requestBody = await FormatAsync(action.Payload, @event);
{
requestBody = await FormatAsync(action.Payload, @event);
}
else
{
requestBody = ToEnvelopeJson(@event);
}
requestSignature = $"{requestBody}{action.SharedSecret}".ToSha256Base64();
} }
else
var ruleDescription = $"Send event to webhook '{requestUrl}'";
var ruleJob = new WebhookJob
{ {
Method = action.Method, requestBody = ToEnvelopeJson(@event);
RequestUrl = await FormatAsync(action.Url.ToString(), @event), }
RequestSignature = requestSignature,
RequestBody = requestBody, requestSignature = $"{requestBody}{action.SharedSecret}".ToSha256Base64();
RequestBodyType = action.PayloadType,
Headers = await ParseHeadersAsync(action.Headers, @event)
};
return (ruleDescription, ruleJob);
} }
private async Task<Dictionary<string, string>> ParseHeadersAsync(string headers, EnrichedEvent @event) var ruleDescription = $"Send event to webhook '{requestUrl}'";
var ruleJob = new WebhookJob
{ {
if (string.IsNullOrWhiteSpace(headers)) Method = action.Method,
{ RequestUrl = await FormatAsync(action.Url.ToString(), @event),
return null; RequestSignature = requestSignature,
} RequestBody = requestBody,
RequestBodyType = action.PayloadType,
Headers = await ParseHeadersAsync(action.Headers, @event)
};
return (ruleDescription, ruleJob);
}
var headersDictionary = new Dictionary<string, string>(); private async Task<Dictionary<string, string>> ParseHeadersAsync(string headers, EnrichedEvent @event)
{
if (string.IsNullOrWhiteSpace(headers))
{
return null;
}
var lines = headers.Split('\n'); var headersDictionary = new Dictionary<string, string>();
foreach (var line in lines) var lines = headers.Split('\n');
{
var indexEqual = line.IndexOf('=', StringComparison.Ordinal);
if (indexEqual > 0 && indexEqual < line.Length - 1) foreach (var line in lines)
{ {
var headerKey = line[..indexEqual]; var indexEqual = line.IndexOf('=', StringComparison.Ordinal);
var headerValue = line[(indexEqual + 1)..];
headerValue = await FormatAsync(headerValue, @event); if (indexEqual > 0 && indexEqual < line.Length - 1)
{
var headerKey = line[..indexEqual];
var headerValue = line[(indexEqual + 1)..];
headersDictionary[headerKey] = headerValue; headerValue = await FormatAsync(headerValue, @event);
}
}
return headersDictionary; headersDictionary[headerKey] = headerValue;
}
} }
protected override async Task<Result> ExecuteJobAsync(WebhookJob job, return headersDictionary;
CancellationToken ct = default) }
{
var httpClient = httpClientFactory.CreateClient();
var method = HttpMethod.Post; protected override async Task<Result> ExecuteJobAsync(WebhookJob job,
CancellationToken ct = default)
{
var httpClient = httpClientFactory.CreateClient();
switch (job.Method) var method = HttpMethod.Post;
{
case WebhookMethod.PUT:
method = HttpMethod.Put;
break;
case WebhookMethod.GET:
method = HttpMethod.Get;
break;
case WebhookMethod.DELETE:
method = HttpMethod.Delete;
break;
case WebhookMethod.PATCH:
method = HttpMethod.Patch;
break;
}
using var request = new HttpRequestMessage(method, job.RequestUrl); switch (job.Method)
{
case WebhookMethod.PUT:
method = HttpMethod.Put;
break;
case WebhookMethod.GET:
method = HttpMethod.Get;
break;
case WebhookMethod.DELETE:
method = HttpMethod.Delete;
break;
case WebhookMethod.PATCH:
method = HttpMethod.Patch;
break;
}
if (!string.IsNullOrEmpty(job.RequestBody) && job.Method != WebhookMethod.GET) using var request = new HttpRequestMessage(method, job.RequestUrl);
{
var mediaType = job.RequestBodyType.Or("application/json");
request.Content = new StringContent(job.RequestBody, Encoding.UTF8, mediaType); if (!string.IsNullOrEmpty(job.RequestBody) && job.Method != WebhookMethod.GET)
} {
var mediaType = job.RequestBodyType.Or("application/json");
request.Headers.Add("User-Agent", "Squidex Webhook"); request.Content = new StringContent(job.RequestBody, Encoding.UTF8, mediaType);
}
if (job.Headers != null) request.Headers.Add("User-Agent", "Squidex Webhook");
{
foreach (var (key, value) in job.Headers)
{
request.Headers.TryAddWithoutValidation(key, value);
}
}
if (!string.IsNullOrWhiteSpace(job.RequestSignature)) if (job.Headers != null)
{
foreach (var (key, value) in job.Headers)
{ {
request.Headers.Add("X-Signature", job.RequestSignature); request.Headers.TryAddWithoutValidation(key, value);
} }
}
request.Headers.Add("X-Application", "Squidex Webhook"); if (!string.IsNullOrWhiteSpace(job.RequestSignature))
{
return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct); request.Headers.Add("X-Signature", job.RequestSignature);
} }
request.Headers.Add("X-Application", "Squidex Webhook");
return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct);
} }
}
public sealed class WebhookJob public sealed class WebhookJob
{ {
public WebhookMethod Method { get; set; } public WebhookMethod Method { get; set; }
public string RequestUrl { get; set; } public string RequestUrl { get; set; }
public string RequestSignature { get; set; } public string RequestSignature { get; set; }
public string RequestBody { get; set; } public string RequestBody { get; set; }
public string RequestBodyType { get; set; } public string RequestBodyType { get; set; }
public Dictionary<string, string> Headers { get; set; } public Dictionary<string, string> Headers { get; set; }
}
} }

11
backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookPlugin.cs

@ -9,13 +9,12 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.Webhook namespace Squidex.Extensions.Actions.Webhook;
public sealed class WebhookPlugin : IPlugin
{ {
public sealed class WebhookPlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) services.AddRuleAction<WebhookAction, WebhookActionHandler>();
{
services.AddRuleAction<WebhookAction, WebhookActionHandler>();
}
} }
} }

109
backend/extensions/Squidex.Extensions/Assets/Azure/AzureMetadataSource.cs

@ -14,83 +14,82 @@ using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Extensions.Assets.Azure namespace Squidex.Extensions.Assets.Azure;
public sealed class AzureMetadataSource : IAssetMetadataSource
{ {
public sealed class AzureMetadataSource : IAssetMetadataSource private const long MaxSize = 5 * 1025 * 1024;
private readonly ILogger<AzureMetadataSource> log;
private readonly ComputerVisionClient client;
private readonly char[] trimChars =
{ {
private const long MaxSize = 5 * 1025 * 1024; ' ',
private readonly ILogger<AzureMetadataSource> log; '_',
private readonly ComputerVisionClient client; '-'
private readonly char[] trimChars = };
{ private readonly List<VisualFeatureTypes?> features = new List<VisualFeatureTypes?>
' ', {
'_', VisualFeatureTypes.Categories,
'-' VisualFeatureTypes.Description,
}; VisualFeatureTypes.Color
private readonly List<VisualFeatureTypes?> features = new List<VisualFeatureTypes?> };
{
VisualFeatureTypes.Categories,
VisualFeatureTypes.Description,
VisualFeatureTypes.Color
};
public int Order => int.MaxValue; public int Order => int.MaxValue;
public AzureMetadataSource(IOptions<AzureMetadataSourceOptions> options, public AzureMetadataSource(IOptions<AzureMetadataSourceOptions> options,
ILogger<AzureMetadataSource> log) ILogger<AzureMetadataSource> log)
{
client = new ComputerVisionClient(new ApiKeyServiceClientCredentials(options.Value.ApiKey))
{ {
client = new ComputerVisionClient(new ApiKeyServiceClientCredentials(options.Value.ApiKey)) Endpoint = options.Value.Endpoint
{ };
Endpoint = options.Value.Endpoint
};
this.log = log; this.log = log;
} }
public async Task EnhanceAsync(UploadAssetCommand command, public async Task EnhanceAsync(UploadAssetCommand command,
CancellationToken ct) CancellationToken ct)
{
try
{ {
try if (command.Type == AssetType.Image && command.File.FileSize <= MaxSize)
{ {
if (command.Type == AssetType.Image && command.File.FileSize <= MaxSize) await using (var stream = command.File.OpenRead())
{ {
await using (var stream = command.File.OpenRead()) var result = await client.AnalyzeImageInStreamAsync(stream, features, cancellationToken: ct);
{
var result = await client.AnalyzeImageInStreamAsync(stream, features, cancellationToken: ct);
command.Tags ??= new HashSet<string>(); command.Tags ??= new HashSet<string>();
if (result.Color?.DominantColorForeground != null) if (result.Color?.DominantColorForeground != null)
{ {
command.Tags.Add($"color/{result.Color.DominantColorForeground.Trim(trimChars).ToLowerInvariant()}"); command.Tags.Add($"color/{result.Color.DominantColorForeground.Trim(trimChars).ToLowerInvariant()}");
} }
if (result.Categories != null) if (result.Categories != null)
{
foreach (var category in result.Categories.OrderByDescending(x => x.Score).Take(3))
{ {
foreach (var category in result.Categories.OrderByDescending(x => x.Score).Take(3)) command.Tags.Add($"category/{category.Name.Trim(trimChars).ToLowerInvariant()}");
{
command.Tags.Add($"category/{category.Name.Trim(trimChars).ToLowerInvariant()}");
}
} }
}
var description = result.Description?.Captions.MaxBy(x => x.Confidence)?.Text; var description = result.Description?.Captions.MaxBy(x => x.Confidence)?.Text;
if (description != null) if (description != null)
{ {
command.Metadata["caption"] = JsonValue.Create(description); command.Metadata["caption"] = JsonValue.Create(description);
}
} }
} }
} }
catch (Exception ex)
{
log.LogError(ex, "Failed to enrich asset.");
}
} }
catch (Exception ex)
public IEnumerable<string> Format(IAssetEntity asset)
{ {
yield break; log.LogError(ex, "Failed to enrich asset.");
} }
} }
public IEnumerable<string> Format(IAssetEntity asset)
{
yield break;
}
} }

21
backend/extensions/Squidex.Extensions/Assets/Azure/AzureMetadataSourceOptions.cs

@ -5,19 +5,18 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
namespace Squidex.Extensions.Assets.Azure namespace Squidex.Extensions.Assets.Azure;
public sealed class AzureMetadataSourceOptions
{ {
public sealed class AzureMetadataSourceOptions public string Endpoint { get; set; }
{
public string Endpoint { get; set; }
public string ApiKey { get; set; } public string ApiKey { get; set; }
public bool IsConfigured() public bool IsConfigured()
{ {
return return
!string.IsNullOrWhiteSpace(Endpoint) && !string.IsNullOrWhiteSpace(Endpoint) &&
!string.IsNullOrWhiteSpace(ApiKey); !string.IsNullOrWhiteSpace(ApiKey);
}
} }
} }

19
backend/extensions/Squidex.Extensions/Assets/Azure/AzureMetadataSourcePlugin.cs

@ -11,19 +11,18 @@ using Microsoft.Extensions.Options;
using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Assets.Azure namespace Squidex.Extensions.Assets.Azure;
public sealed class AzureMetadataSourcePlugin : IPlugin
{ {
public sealed class AzureMetadataSourcePlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) var options = config.GetSection("assets:azurecognitive").Get<AzureMetadataSourceOptions>() ?? new ();
{
var options = config.GetSection("assets:azurecognitive").Get<AzureMetadataSourceOptions>() ?? new ();
if (options.IsConfigured()) if (options.IsConfigured())
{ {
services.AddSingleton<IAssetMetadataSource, AzureMetadataSource>(); services.AddSingleton<IAssetMetadataSource, AzureMetadataSource>();
services.AddSingleton(Options.Create(options)); services.AddSingleton(Options.Create(options));
}
} }
} }
} }

57
backend/extensions/Squidex.Extensions/Samples/AssetStore/MemoryAssetStorePlugin.cs

@ -13,44 +13,43 @@ using Microsoft.Extensions.DependencyInjection;
using Squidex.Assets; using Squidex.Assets;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Samples.AssetStore namespace Squidex.Extensions.Samples.AssetStore;
public sealed class MemoryAssetStorePlugin : IPlugin, IStartupFilter
{ {
public sealed class MemoryAssetStorePlugin : IPlugin, IStartupFilter public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{ {
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) return builder =>
{ {
return builder => builder.Use(async (context, next) =>
{ {
builder.Use(async (context, next) => if (context.Request.Path.StartsWithSegments("/api/assets/memory", StringComparison.Ordinal))
{ {
if (context.Request.Path.StartsWithSegments("/api/assets/memory", StringComparison.Ordinal)) context.Response.StatusCode = 200;
{
context.Response.StatusCode = 200;
await context.Response.WriteAsync("Memory Asset Store used.");
}
else
{
await next();
}
});
next(builder);
};
}
public void ConfigureServices(IServiceCollection services, IConfiguration config) await context.Response.WriteAsync("Memory Asset Store used.");
{ }
var storeType = config.GetValue<string>("assetStore:type"); else
{
await next();
}
});
next(builder);
};
}
var isMemoryAssetsUsed = string.Equals(storeType, "Memory", StringComparison.OrdinalIgnoreCase); public void ConfigureServices(IServiceCollection services, IConfiguration config)
{
var storeType = config.GetValue<string>("assetStore:type");
if (isMemoryAssetsUsed) var isMemoryAssetsUsed = string.Equals(storeType, "Memory", StringComparison.OrdinalIgnoreCase);
{
services.AddSingleton<IStartupFilter>(this); if (isMemoryAssetsUsed)
{
services.AddSingleton<IStartupFilter>(this);
services.AddSingleton<IAssetStore, MemoryAssetStore>(); services.AddSingleton<IAssetStore, MemoryAssetStore>();
}
} }
} }
} }

21
backend/extensions/Squidex.Extensions/Samples/Controllers/PluginController.cs

@ -9,19 +9,18 @@ using Microsoft.AspNetCore.Mvc;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Web; using Squidex.Web;
namespace Squidex.Extensions.Samples.Controllers namespace Squidex.Extensions.Samples.Controllers;
public sealed class PluginController : ApiController
{ {
public sealed class PluginController : ApiController public PluginController(ICommandBus commandBus)
: base(commandBus)
{ {
public PluginController(ICommandBus commandBus) }
: base(commandBus)
{
}
[Route("plugins/sample")] [Route("plugins/sample")]
public IActionResult Test() public IActionResult Test()
{ {
return Ok(new { text = "I am Plugin" }); return Ok(new { text = "I am Plugin" });
}
} }
} }

145
backend/extensions/Squidex.Extensions/Samples/Middleware/DoubleLinkedContentMiddleware.cs

@ -12,103 +12,102 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Extensions.Samples.Middleware namespace Squidex.Extensions.Samples.Middleware;
public sealed class DoubleLinkedContentMiddleware : ICustomCommandMiddleware
{ {
public sealed class DoubleLinkedContentMiddleware : ICustomCommandMiddleware private readonly IContentLoader contentLoader;
public DoubleLinkedContentMiddleware(IContentLoader contentLoader)
{ {
private readonly IContentLoader contentLoader; this.contentLoader = contentLoader;
}
public DoubleLinkedContentMiddleware(IContentLoader contentLoader) public async Task HandleAsync(CommandContext context, NextDelegate next,
{ CancellationToken ct)
this.contentLoader = contentLoader; {
} await next(context, ct);
public async Task HandleAsync(CommandContext context, NextDelegate next, if (context.Command is UpdateContent update && context.IsCompleted && update.SchemaId.Name == "source")
CancellationToken ct)
{ {
await next(context, ct); // After a change is made, the content is put to the command context.
var content = context.Result<IContentEntity>();
var contentPrevious =
await contentLoader.GetAsync(
content.AppId.Id,
content.Id,
content.Version - 1,
ct);
// The data might have been changed within the domain object. Therefore we do not use the data fro mthe command.
var oldReferenceId = GetReference(contentPrevious?.Data);
var newReferenceId = GetReference(content.Data);
// If nothing has been changed we can just stop here.
if (newReferenceId == oldReferenceId)
{
return;
}
if (context.Command is UpdateContent update && context.IsCompleted && update.SchemaId.Name == "source") if (oldReferenceId != null)
{ {
// After a change is made, the content is put to the command context. var oldReferenced = await contentLoader.GetAsync(content.AppId.Id, DomainId.Create(oldReferenceId), ct: ct);
var content = context.Result<IContentEntity>();
var contentPrevious =
await contentLoader.GetAsync(
content.AppId.Id,
content.Id,
content.Version - 1,
ct);
// The data might have been changed within the domain object. Therefore we do not use the data fro mthe command.
var oldReferenceId = GetReference(contentPrevious?.Data);
var newReferenceId = GetReference(content.Data);
// If nothing has been changed we can just stop here.
if (newReferenceId == oldReferenceId)
{
return;
}
if (oldReferenceId != null) if (oldReferenced != null)
{ {
var oldReferenced = await contentLoader.GetAsync(content.AppId.Id, DomainId.Create(oldReferenceId), ct: ct); var data = oldReferenced.Data.Clone();
if (oldReferenced != null) // Remove the reference from the old referenced content.
{ data.Remove("referencing");
var data = oldReferenced.Data.Clone();
// Remove the reference from the old referenced content.
data.Remove("referencing");
await UpdateReferencing(context, oldReferenced, data, ct); await UpdateReferencing(context, oldReferenced, data, ct);
}
} }
}
if (newReferenceId != null) if (newReferenceId != null)
{
var newReferenced = await contentLoader.GetAsync(content.AppId.Id, DomainId.Create(newReferenceId), ct: ct);
if (newReferenced != null)
{ {
var newReferenced = await contentLoader.GetAsync(content.AppId.Id, DomainId.Create(newReferenceId), ct: ct); var data = newReferenced.Data.Clone();
if (newReferenced != null) // Add the reference to the new referenced content.
data["referencing"] = new ContentFieldData
{ {
var data = newReferenced.Data.Clone(); ["iv"] = JsonValue.Array(content.Id)
};
// Add the reference to the new referenced content. await UpdateReferencing(context, newReferenced, data, ct);
data["referencing"] = new ContentFieldData
{
["iv"] = JsonValue.Array(content.Id)
};
await UpdateReferencing(context, newReferenced, data, ct);
}
} }
} }
} }
}
private static async Task UpdateReferencing(CommandContext context, IContentEntity reference, ContentData data, private static async Task UpdateReferencing(CommandContext context, IContentEntity reference, ContentData data,
CancellationToken ct) CancellationToken ct)
{
// Also set the expected version, otherwise it will be overriden with the version from the request.
await context.CommandBus.PublishAsync(new UpdateContent
{ {
// Also set the expected version, otherwise it will be overriden with the version from the request. AppId = reference.AppId,
await context.CommandBus.PublishAsync(new UpdateContent SchemaId = reference.SchemaId,
{ ContentId = reference.Id,
AppId = reference.AppId, DoNotScript = true,
SchemaId = reference.SchemaId, DoNotValidate = true,
ContentId = reference.Id, Data = data,
DoNotScript = true, ExpectedVersion = reference.Version
DoNotValidate = true, }, ct);
Data = data, }
ExpectedVersion = reference.Version
}, ct);
}
private static string GetReference(ContentData data) private static string GetReference(ContentData data)
{
if (data != null && data.TryGetValue("reference", out ContentFieldData fieldData))
{ {
if (data != null && data.TryGetValue("reference", out ContentFieldData fieldData)) return fieldData.Values.OfType<JsonArray>().SelectMany(x => x).SingleOrDefault().ToString();
{
return fieldData.Values.OfType<JsonArray>().SelectMany(x => x).SingleOrDefault().ToString();
}
return null;
} }
return null;
} }
} }

205
backend/extensions/Squidex.Extensions/Text/Azure/AzureIndexDefinition.cs

@ -8,133 +8,132 @@
using System.Reflection; using System.Reflection;
using Azure.Search.Documents.Indexes.Models; using Azure.Search.Documents.Indexes.Models;
namespace Squidex.Extensions.Text.Azure namespace Squidex.Extensions.Text.Azure;
public static class AzureIndexDefinition
{ {
public static class AzureIndexDefinition private static readonly Dictionary<string, (string Field, string Analyzer)> FieldAnalyzers = new (StringComparer.OrdinalIgnoreCase)
{ {
private static readonly Dictionary<string, (string Field, string Analyzer)> FieldAnalyzers = new (StringComparer.OrdinalIgnoreCase) ["iv"] = ("iv", LexicalAnalyzerName.StandardLucene.ToString()),
{ ["zh"] = ("zh", LexicalAnalyzerName.ZhHansLucene.ToString())
["iv"] = ("iv", LexicalAnalyzerName.StandardLucene.ToString()), };
["zh"] = ("zh", LexicalAnalyzerName.ZhHansLucene.ToString())
};
static AzureIndexDefinition() static AzureIndexDefinition()
{ {
var analyzers = var analyzers =
typeof(LexicalAnalyzerName) typeof(LexicalAnalyzerName)
.GetProperties(BindingFlags.Public | BindingFlags.Static) .GetProperties(BindingFlags.Public | BindingFlags.Static)
.Select(x => x.GetValue(null)) .Select(x => x.GetValue(null))
.Select(x => x.ToString()) .Select(x => x.ToString())
.OrderBy(x => x) .OrderBy(x => x)
.ToList(); .ToList();
var addedLanguage = new HashSet<string>(); var addedLanguage = new HashSet<string>();
foreach (var analyzer in analyzers) foreach (var analyzer in analyzers)
{ {
var indexOfDot = analyzer.IndexOf('.', StringComparison.Ordinal); var indexOfDot = analyzer.IndexOf('.', StringComparison.Ordinal);
if (indexOfDot > 0) if (indexOfDot > 0)
{ {
var language = analyzer[..indexOfDot]; var language = analyzer[..indexOfDot];
var isValidLanguage = var isValidLanguage =
language.Length == 2 || language.Length == 2 ||
language.StartsWith("zh-", StringComparison.Ordinal); language.StartsWith("zh-", StringComparison.Ordinal);
if (isValidLanguage && addedLanguage.Add(language)) if (isValidLanguage && addedLanguage.Add(language))
{ {
var fieldName = language.Replace('-', '_'); var fieldName = language.Replace('-', '_');
FieldAnalyzers[language] = (fieldName, analyzer); FieldAnalyzers[language] = (fieldName, analyzer);
}
} }
} }
} }
}
public static string GetFieldName(string key) public static string GetFieldName(string key)
{
if (FieldAnalyzers.TryGetValue(key, out var analyzer))
{ {
if (FieldAnalyzers.TryGetValue(key, out var analyzer)) return analyzer.Field;
{ }
return analyzer.Field;
}
if (key.Length > 0) if (key.Length > 0)
{ {
var language = key[2..]; var language = key[2..];
if (FieldAnalyzers.TryGetValue(language, out analyzer)) if (FieldAnalyzers.TryGetValue(language, out analyzer))
{ {
return analyzer.Field; return analyzer.Field;
}
} }
return "iv";
} }
public static SearchIndex Create(string indexName) return "iv";
}
public static SearchIndex Create(string indexName)
{
var fields = new List<SearchField>
{ {
var fields = new List<SearchField> new SimpleField("docId", SearchFieldDataType.String)
{ {
new SimpleField("docId", SearchFieldDataType.String) IsKey = true
{ },
IsKey = true new SimpleField("appId", SearchFieldDataType.String)
},
new SimpleField("appId", SearchFieldDataType.String)
{
IsFilterable = true
},
new SimpleField("appName", SearchFieldDataType.String)
{
IsFilterable = false
},
new SimpleField("contentId", SearchFieldDataType.String)
{
IsFilterable = false
},
new SimpleField("schemaId", SearchFieldDataType.String)
{
IsFilterable = true
},
new SimpleField("schemaName", SearchFieldDataType.String)
{
IsFilterable = false
},
new SimpleField("serveAll", SearchFieldDataType.Boolean)
{
IsFilterable = true
},
new SimpleField("servePublished", SearchFieldDataType.Boolean)
{
IsFilterable = true
},
new SimpleField("geoObject", SearchFieldDataType.GeographyPoint)
{
IsFilterable = true
},
new SimpleField("geoField", SearchFieldDataType.String)
{
IsFilterable = true
}
};
foreach (var (field, analyzer) in FieldAnalyzers.Values)
{ {
fields.Add( IsFilterable = true
new SearchableField(field) },
{ new SimpleField("appName", SearchFieldDataType.String)
IsFilterable = false, {
IsFacetable = false, IsFilterable = false
AnalyzerName = analyzer },
}); new SimpleField("contentId", SearchFieldDataType.String)
} {
IsFilterable = false
var index = new SearchIndex(indexName) },
new SimpleField("schemaId", SearchFieldDataType.String)
{ {
Fields = fields IsFilterable = true
}; },
new SimpleField("schemaName", SearchFieldDataType.String)
{
IsFilterable = false
},
new SimpleField("serveAll", SearchFieldDataType.Boolean)
{
IsFilterable = true
},
new SimpleField("servePublished", SearchFieldDataType.Boolean)
{
IsFilterable = true
},
new SimpleField("geoObject", SearchFieldDataType.GeographyPoint)
{
IsFilterable = true
},
new SimpleField("geoField", SearchFieldDataType.String)
{
IsFilterable = true
}
};
return index; foreach (var (field, analyzer) in FieldAnalyzers.Values)
{
fields.Add(
new SearchableField(field)
{
IsFilterable = false,
IsFacetable = false,
AnalyzerName = analyzer
});
} }
var index = new SearchIndex(indexName)
{
Fields = fields
};
return index;
} }
} }

241
backend/extensions/Squidex.Extensions/Text/Azure/AzureTextIndex.cs

@ -15,170 +15,169 @@ using Squidex.Domain.Apps.Entities.Contents.Text;
using Squidex.Hosting; using Squidex.Hosting;
using Squidex.Infrastructure; using Squidex.Infrastructure;
namespace Squidex.Extensions.Text.Azure namespace Squidex.Extensions.Text.Azure;
public sealed class AzureTextIndex : IInitializable, ITextIndex
{ {
public sealed class AzureTextIndex : IInitializable, ITextIndex private readonly SearchIndexClient indexClient;
private readonly SearchClient searchClient;
private readonly QueryParser queryParser = new QueryParser(AzureIndexDefinition.GetFieldName);
public AzureTextIndex(
string serviceEndpoint,
string serviceApiKey,
string indexName)
{ {
private readonly SearchIndexClient indexClient; indexClient = new SearchIndexClient(new Uri(serviceEndpoint), new AzureKeyCredential(serviceApiKey));
private readonly SearchClient searchClient;
private readonly QueryParser queryParser = new QueryParser(AzureIndexDefinition.GetFieldName);
public AzureTextIndex(
string serviceEndpoint,
string serviceApiKey,
string indexName)
{
indexClient = new SearchIndexClient(new Uri(serviceEndpoint), new AzureKeyCredential(serviceApiKey));
searchClient = indexClient.GetSearchClient(indexName); searchClient = indexClient.GetSearchClient(indexName);
} }
public async Task InitializeAsync(
CancellationToken ct)
{
await CreateIndexAsync(ct);
}
public async Task ClearAsync( public async Task InitializeAsync(
CancellationToken ct = default) CancellationToken ct)
{ {
await indexClient.DeleteIndexAsync(searchClient.IndexName, ct); await CreateIndexAsync(ct);
}
await CreateIndexAsync(ct); public async Task ClearAsync(
} CancellationToken ct = default)
{
await indexClient.DeleteIndexAsync(searchClient.IndexName, ct);
private async Task CreateIndexAsync( await CreateIndexAsync(ct);
CancellationToken ct) }
{
var index = AzureIndexDefinition.Create(searchClient.IndexName);
await indexClient.CreateOrUpdateIndexAsync(index, true, true, ct); private async Task CreateIndexAsync(
} CancellationToken ct)
{
var index = AzureIndexDefinition.Create(searchClient.IndexName);
public async Task ExecuteAsync(IndexCommand[] commands, await indexClient.CreateOrUpdateIndexAsync(index, true, true, ct);
CancellationToken ct = default) }
{
var batch = IndexDocumentsBatch.Create<SearchDocument>();
commands.Foreach(x => CommandFactory.CreateCommands(x, batch.Actions)); public async Task ExecuteAsync(IndexCommand[] commands,
CancellationToken ct = default)
{
var batch = IndexDocumentsBatch.Create<SearchDocument>();
if (batch.Actions.Count == 0) commands.Foreach(x => CommandFactory.CreateCommands(x, batch.Actions));
{
return;
}
await searchClient.IndexDocumentsAsync(batch, cancellationToken: ct); if (batch.Actions.Count == 0)
{
return;
} }
public async Task<List<DomainId>> SearchAsync(IAppEntity app, GeoQuery query, SearchScope scope, await searchClient.IndexDocumentsAsync(batch, cancellationToken: ct);
CancellationToken ct = default) }
{
Guard.NotNull(app);
Guard.NotNull(query);
var result = new List<(DomainId Id, double Score)>(); public async Task<List<DomainId>> SearchAsync(IAppEntity app, GeoQuery query, SearchScope scope,
CancellationToken ct = default)
{
Guard.NotNull(app);
Guard.NotNull(query);
await SearchAsync(result, "*", BuildGeoQuery(query, scope), query.Take, 1, ct); var result = new List<(DomainId Id, double Score)>();
return result.OrderByDescending(x => x.Score).Select(x => x.Id).Distinct().ToList(); await SearchAsync(result, "*", BuildGeoQuery(query, scope), query.Take, 1, ct);
}
public async Task<List<DomainId>> SearchAsync(IAppEntity app, TextQuery query, SearchScope scope, return result.OrderByDescending(x => x.Score).Select(x => x.Id).Distinct().ToList();
CancellationToken ct = default) }
{
Guard.NotNull(app);
Guard.NotNull(query);
var parsed = queryParser.Parse(query.Text); public async Task<List<DomainId>> SearchAsync(IAppEntity app, TextQuery query, SearchScope scope,
CancellationToken ct = default)
{
Guard.NotNull(app);
Guard.NotNull(query);
if (parsed == null) var parsed = queryParser.Parse(query.Text);
{
return null;
}
var result = new List<(DomainId Id, double Score)>(); if (parsed == null)
{
return null;
}
if (query.RequiredSchemaIds?.Count > 0) var result = new List<(DomainId Id, double Score)>();
{
await SearchBySchemaAsync(result, parsed.Text, query.RequiredSchemaIds, scope, query.Take, 1, ct);
}
else if (query.PreferredSchemaId == null)
{
await SearchByAppAsync(result, parsed.Text, app, scope, query.Take, 1, ct);
}
else
{
var halfTake = query.Take / 2;
var schemaIds = Enumerable.Repeat(query.PreferredSchemaId.Value, 1); if (query.RequiredSchemaIds?.Count > 0)
{
await SearchBySchemaAsync(result, parsed.Text, query.RequiredSchemaIds, scope, query.Take, 1, ct);
}
else if (query.PreferredSchemaId == null)
{
await SearchByAppAsync(result, parsed.Text, app, scope, query.Take, 1, ct);
}
else
{
var halfTake = query.Take / 2;
await SearchBySchemaAsync(result, parsed.Text, schemaIds, scope, halfTake, 1.1, ct); var schemaIds = Enumerable.Repeat(query.PreferredSchemaId.Value, 1);
await SearchByAppAsync(result, parsed.Text, app, scope, halfTake, 1, ct);
}
return result.OrderByDescending(x => x.Score).Select(x => x.Id).Distinct().ToList(); await SearchBySchemaAsync(result, parsed.Text, schemaIds, scope, halfTake, 1.1, ct);
await SearchByAppAsync(result, parsed.Text, app, scope, halfTake, 1, ct);
} }
private Task SearchBySchemaAsync(List<(DomainId, double)> result, string text, IEnumerable<DomainId> schemaIds, SearchScope scope, int take, double factor, return result.OrderByDescending(x => x.Score).Select(x => x.Id).Distinct().ToList();
CancellationToken ct = default) }
{
var searchField = GetServeField(scope);
var filter = $"{string.Join(" or ", schemaIds.Select(x => $"schemaId eq '{x}'"))} and {searchField} eq true"; private Task SearchBySchemaAsync(List<(DomainId, double)> result, string text, IEnumerable<DomainId> schemaIds, SearchScope scope, int take, double factor,
CancellationToken ct = default)
{
var searchField = GetServeField(scope);
return SearchAsync(result, text, filter, take, factor, ct); var filter = $"{string.Join(" or ", schemaIds.Select(x => $"schemaId eq '{x}'"))} and {searchField} eq true";
}
private Task SearchByAppAsync(List<(DomainId, double)> result, string text, IAppEntity app, SearchScope scope, int take, double factor, return SearchAsync(result, text, filter, take, factor, ct);
CancellationToken ct = default) }
{
var searchField = GetServeField(scope); private Task SearchByAppAsync(List<(DomainId, double)> result, string text, IAppEntity app, SearchScope scope, int take, double factor,
CancellationToken ct = default)
{
var searchField = GetServeField(scope);
var filter = $"appId eq '{app.Id}' and {searchField} eq true"; var filter = $"appId eq '{app.Id}' and {searchField} eq true";
return SearchAsync(result, text, filter, take, factor, ct); return SearchAsync(result, text, filter, take, factor, ct);
} }
private async Task SearchAsync(List<(DomainId, double)> result, string text, string filter, int take, double factor, private async Task SearchAsync(List<(DomainId, double)> result, string text, string filter, int take, double factor,
CancellationToken ct = default) CancellationToken ct = default)
{
var searchOptions = new SearchOptions
{ {
var searchOptions = new SearchOptions Filter = filter
{ };
Filter = filter
};
searchOptions.Select.Add("contentId"); searchOptions.Select.Add("contentId");
searchOptions.Size = take; searchOptions.Size = take;
searchOptions.QueryType = SearchQueryType.Full; searchOptions.QueryType = SearchQueryType.Full;
var results = await searchClient.SearchAsync<SearchDocument>(text, searchOptions, ct); var results = await searchClient.SearchAsync<SearchDocument>(text, searchOptions, ct);
await foreach (var item in results.Value.GetResultsAsync().WithCancellation(ct)) await foreach (var item in results.Value.GetResultsAsync().WithCancellation(ct))
{
if (item != null)
{ {
if (item != null) var id = DomainId.Create(item.Document["contentId"].ToString());
{
var id = DomainId.Create(item.Document["contentId"].ToString());
result.Add((id, factor * item.Score ?? 0)); result.Add((id, factor * item.Score ?? 0));
}
} }
} }
}
private static string BuildGeoQuery(GeoQuery query, SearchScope scope) private static string BuildGeoQuery(GeoQuery query, SearchScope scope)
{ {
var (schema, field, lat, lng, radius, _) = query; var (schema, field, lat, lng, radius, _) = query;
var searchField = GetServeField(scope); var searchField = GetServeField(scope);
var searchDistance = radius / 1000; var searchDistance = radius / 1000;
return $"schemaId eq '{schema}' and geoField eq '{field}' and geo.distance(geoObject, geography'POINT({lng} {lat})') lt {searchDistance} and {searchField} eq true"; return $"schemaId eq '{schema}' and geoField eq '{field}' and geo.distance(geoObject, geography'POINT({lng} {lat})') lt {searchDistance} and {searchField} eq true";
} }
private static string GetServeField(SearchScope scope) private static string GetServeField(SearchScope scope)
{ {
return scope == SearchScope.Published ? return scope == SearchScope.Published ?
"servePublished" : "servePublished" :
"serveAll"; "serveAll";
}
} }
} }

59
backend/extensions/Squidex.Extensions/Text/Azure/AzureTextPlugin.cs

@ -12,50 +12,49 @@ using Squidex.Hosting;
using Squidex.Hosting.Configuration; using Squidex.Hosting.Configuration;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Text.Azure namespace Squidex.Extensions.Text.Azure;
public sealed class AzureTextPlugin : IPlugin
{ {
public sealed class AzureTextPlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) var fullTextType = config.GetValue<string>("fullText:type");
if (string.Equals(fullTextType, "Azure", StringComparison.OrdinalIgnoreCase))
{ {
var fullTextType = config.GetValue<string>("fullText:type"); var serviceEndpoint = config.GetValue<string>("fullText:azure:serviceEndpoint");
if (string.Equals(fullTextType, "Azure", StringComparison.OrdinalIgnoreCase)) if (string.IsNullOrWhiteSpace(serviceEndpoint))
{ {
var serviceEndpoint = config.GetValue<string>("fullText:azure:serviceEndpoint"); var error = new ConfigurationError("Value is required.", "fullText:azure:serviceEndpoint");
if (string.IsNullOrWhiteSpace(serviceEndpoint))
{
var error = new ConfigurationError("Value is required.", "fullText:azure:serviceEndpoint");
throw new ConfigurationException(error); throw new ConfigurationException(error);
} }
var serviceApiKey = config.GetValue<string>("fullText:azure:apiKey"); var serviceApiKey = config.GetValue<string>("fullText:azure:apiKey");
if (string.IsNullOrWhiteSpace(serviceApiKey)) if (string.IsNullOrWhiteSpace(serviceApiKey))
{ {
var error = new ConfigurationError("Value is required.", "fullText:azure:apiKey"); var error = new ConfigurationError("Value is required.", "fullText:azure:apiKey");
throw new ConfigurationException(error); throw new ConfigurationException(error);
} }
var indexName = config.GetValue<string>("fullText:azure:indexName"); var indexName = config.GetValue<string>("fullText:azure:indexName");
if (string.IsNullOrWhiteSpace(indexName)) if (string.IsNullOrWhiteSpace(indexName))
{ {
indexName = "squidex-index"; indexName = "squidex-index";
} }
services.AddSingleton( services.AddSingleton(
c => new AzureTextIndex(serviceEndpoint, serviceApiKey, indexName)); c => new AzureTextIndex(serviceEndpoint, serviceApiKey, indexName));
services.AddSingleton<ITextIndex>( services.AddSingleton<ITextIndex>(
c => c.GetRequiredService<AzureTextIndex>()); c => c.GetRequiredService<AzureTextIndex>());
services.AddSingleton<IInitializable>( services.AddSingleton<IInitializable>(
c => c.GetRequiredService<AzureTextIndex>()); c => c.GetRequiredService<AzureTextIndex>());
}
} }
} }
} }

149
backend/extensions/Squidex.Extensions/Text/Azure/CommandFactory.cs

@ -10,106 +10,105 @@ using Azure.Search.Documents.Models;
using NetTopologySuite.Geometries; using NetTopologySuite.Geometries;
using Squidex.Domain.Apps.Entities.Contents.Text; using Squidex.Domain.Apps.Entities.Contents.Text;
namespace Squidex.Extensions.Text.Azure namespace Squidex.Extensions.Text.Azure;
public static class CommandFactory
{ {
public static class CommandFactory public static void CreateCommands(IndexCommand command, IList<IndexDocumentsAction<SearchDocument>> batch)
{ {
public static void CreateCommands(IndexCommand command, IList<IndexDocumentsAction<SearchDocument>> batch) switch (command)
{ {
switch (command) case UpsertIndexEntry upsert:
{ UpsertTextEntry(upsert, batch);
case UpsertIndexEntry upsert: break;
UpsertTextEntry(upsert, batch); case UpdateIndexEntry update:
break; UpdateEntry(update, batch);
case UpdateIndexEntry update: break;
UpdateEntry(update, batch); case DeleteIndexEntry delete:
break; DeleteEntry(delete, batch);
case DeleteIndexEntry delete: break;
DeleteEntry(delete, batch);
break;
}
} }
}
private static void UpsertTextEntry(UpsertIndexEntry upsert, IList<IndexDocumentsAction<SearchDocument>> batch) private static void UpsertTextEntry(UpsertIndexEntry upsert, IList<IndexDocumentsAction<SearchDocument>> batch)
{ {
var geoField = string.Empty; var geoField = string.Empty;
var geoObject = (object)null; var geoObject = (object)null;
if (upsert.GeoObjects != null) if (upsert.GeoObjects != null)
{
foreach (var (key, value) in upsert.GeoObjects)
{ {
foreach (var (key, value) in upsert.GeoObjects) if (value is Point point)
{ {
if (value is Point point) geoField = key;
geoObject = new
{ {
geoField = key; type = "Point",
geoObject = new coordinates = new[]
{ {
type = "Point", point.Coordinate.X,
coordinates = new[] point.Coordinate.Y
{ }
point.Coordinate.X, };
point.Coordinate.Y break;
}
};
break;
}
} }
} }
}
if (upsert.Texts != null || geoObject != null) if (upsert.Texts != null || geoObject != null)
{
var document = new SearchDocument
{ {
var document = new SearchDocument ["docId"] = upsert.DocId.ToBase64(),
{ ["appId"] = upsert.AppId.Id.ToString(),
["docId"] = upsert.DocId.ToBase64(), ["appName"] = upsert.AppId.Name,
["appId"] = upsert.AppId.Id.ToString(), ["contentId"] = upsert.ContentId.ToString(),
["appName"] = upsert.AppId.Name, ["schemaId"] = upsert.SchemaId.Id.ToString(),
["contentId"] = upsert.ContentId.ToString(), ["schemaName"] = upsert.SchemaId.Name,
["schemaId"] = upsert.SchemaId.Id.ToString(), ["serveAll"] = upsert.ServeAll,
["schemaName"] = upsert.SchemaId.Name, ["servePublished"] = upsert.ServePublished,
["serveAll"] = upsert.ServeAll, ["geoField"] = geoField,
["servePublished"] = upsert.ServePublished, ["geoObject"] = geoObject
["geoField"] = geoField, };
["geoObject"] = geoObject
};
foreach (var (key, value) in upsert.Texts)
{
var text = value;
var languageCode = AzureIndexDefinition.GetFieldName(key); foreach (var (key, value) in upsert.Texts)
{
var text = value;
if (document.TryGetValue(languageCode, out var existing)) var languageCode = AzureIndexDefinition.GetFieldName(key);
{
text = $"{existing} {value}";
}
document[languageCode] = text; if (document.TryGetValue(languageCode, out var existing))
{
text = $"{existing} {value}";
} }
batch.Add(IndexDocumentsAction.MergeOrUpload(document)); document[languageCode] = text;
} }
}
private static void UpdateEntry(UpdateIndexEntry update, IList<IndexDocumentsAction<SearchDocument>> batch)
{
var document = new SearchDocument
{
["docId"] = update.DocId.ToBase64(),
["serveAll"] = update.ServeAll,
["servePublished"] = update.ServePublished
};
batch.Add(IndexDocumentsAction.MergeOrUpload(document)); batch.Add(IndexDocumentsAction.MergeOrUpload(document));
} }
}
private static void DeleteEntry(DeleteIndexEntry delete, IList<IndexDocumentsAction<SearchDocument>> batch) private static void UpdateEntry(UpdateIndexEntry update, IList<IndexDocumentsAction<SearchDocument>> batch)
{
var document = new SearchDocument
{ {
batch.Add(IndexDocumentsAction.Delete("docId", delete.DocId.ToBase64())); ["docId"] = update.DocId.ToBase64(),
} ["serveAll"] = update.ServeAll,
["servePublished"] = update.ServePublished
};
private static string ToBase64(this string value) batch.Add(IndexDocumentsAction.MergeOrUpload(document));
{ }
return Convert.ToBase64String(Encoding.Default.GetBytes(value));
} private static void DeleteEntry(DeleteIndexEntry delete, IList<IndexDocumentsAction<SearchDocument>> batch)
{
batch.Add(IndexDocumentsAction.Delete("docId", delete.DocId.ToBase64()));
}
private static string ToBase64(this string value)
{
return Convert.ToBase64String(Encoding.Default.GetBytes(value));
} }
} }

173
backend/extensions/Squidex.Extensions/Text/ElasticSearch/CommandFactory.cs

@ -8,122 +8,121 @@
using NetTopologySuite.Geometries; using NetTopologySuite.Geometries;
using Squidex.Domain.Apps.Entities.Contents.Text; using Squidex.Domain.Apps.Entities.Contents.Text;
namespace Squidex.Extensions.Text.ElasticSearch namespace Squidex.Extensions.Text.ElasticSearch;
public static class CommandFactory
{ {
public static class CommandFactory public static void CreateCommands(IndexCommand command, List<object> args, string indexName)
{ {
public static void CreateCommands(IndexCommand command, List<object> args, string indexName) switch (command)
{ {
switch (command) case UpsertIndexEntry upsert:
{ UpsertEntry(upsert, args, indexName);
case UpsertIndexEntry upsert: break;
UpsertEntry(upsert, args, indexName); case UpdateIndexEntry update:
break; UpdateEntry(update, args, indexName);
case UpdateIndexEntry update: break;
UpdateEntry(update, args, indexName); case DeleteIndexEntry delete:
break; DeleteEntry(delete, args, indexName);
case DeleteIndexEntry delete: break;
DeleteEntry(delete, args, indexName);
break;
}
} }
}
private static void UpsertEntry(UpsertIndexEntry upsert, List<object> args, string indexName) private static void UpsertEntry(UpsertIndexEntry upsert, List<object> args, string indexName)
{ {
var geoField = string.Empty; var geoField = string.Empty;
var geoObject = (object)null; var geoObject = (object)null;
if (upsert.GeoObjects != null) if (upsert.GeoObjects != null)
{
foreach (var (key, value) in upsert.GeoObjects)
{ {
foreach (var (key, value) in upsert.GeoObjects) if (value is Point point)
{ {
if (value is Point point) geoField = key;
geoObject = new
{ {
geoField = key; lat = point.Coordinate.X,
geoObject = new lon = point.Coordinate.Y
{ };
lat = point.Coordinate.X, break;
lon = point.Coordinate.Y
};
break;
}
} }
} }
}
if (upsert.Texts != null || geoObject != null) if (upsert.Texts != null || geoObject != null)
{
args.Add(new
{ {
args.Add(new index = new
{ {
index = new _id = upsert.DocId,
{ _index = indexName
_id = upsert.DocId, }
_index = indexName });
}
});
var texts = new Dictionary<string, string>();
foreach (var (key, value) in upsert.Texts) var texts = new Dictionary<string, string>();
{
var text = value;
var languageCode = ElasticSearchIndexDefinition.GetFieldName(key); foreach (var (key, value) in upsert.Texts)
{
var text = value;
if (texts.TryGetValue(languageCode, out var existing)) var languageCode = ElasticSearchIndexDefinition.GetFieldName(key);
{
text = $"{existing} {value}";
}
texts[languageCode] = text; if (texts.TryGetValue(languageCode, out var existing))
{
text = $"{existing} {value}";
} }
args.Add(new texts[languageCode] = text;
{
appId = upsert.AppId.Id.ToString(),
appName = upsert.AppId.Name,
contentId = upsert.ContentId.ToString(),
schemaId = upsert.SchemaId.Id.ToString(),
schemaName = upsert.SchemaId.Name,
serveAll = upsert.ServeAll,
servePublished = upsert.ServePublished,
texts,
geoField,
geoObject
});
} }
}
private static void UpdateEntry(UpdateIndexEntry update, List<object> args, string indexName)
{
args.Add(new args.Add(new
{ {
update = new appId = upsert.AppId.Id.ToString(),
{ appName = upsert.AppId.Name,
_id = update.DocId, contentId = upsert.ContentId.ToString(),
_index = indexName schemaId = upsert.SchemaId.Id.ToString(),
} schemaName = upsert.SchemaId.Name,
serveAll = upsert.ServeAll,
servePublished = upsert.ServePublished,
texts,
geoField,
geoObject
}); });
}
}
args.Add(new private static void UpdateEntry(UpdateIndexEntry update, List<object> args, string indexName)
{
args.Add(new
{
update = new
{ {
doc = new _id = update.DocId,
{ _index = indexName
serveAll = update.ServeAll, }
servePublished = update.ServePublished });
}
});
}
private static void DeleteEntry(DeleteIndexEntry delete, List<object> args, string indexName) args.Add(new
{ {
args.Add(new doc = new
{ {
delete = new serveAll = update.ServeAll,
{ servePublished = update.ServePublished
_id = delete.DocId, }
_index = indexName });
} }
});
} private static void DeleteEntry(DeleteIndexEntry delete, List<object> args, string indexName)
{
args.Add(new
{
delete = new
{
_id = delete.DocId,
_index = indexName
}
});
} }
} }

95
backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchClient.cs

@ -7,72 +7,71 @@
using Elasticsearch.Net; using Elasticsearch.Net;
namespace Squidex.Extensions.Text.ElasticSearch namespace Squidex.Extensions.Text.ElasticSearch;
public sealed class ElasticSearchClient : IElasticSearchClient
{ {
public sealed class ElasticSearchClient : IElasticSearchClient private readonly IElasticLowLevelClient elasticSearch;
public ElasticSearchClient(string configurationString)
{ {
private readonly IElasticLowLevelClient elasticSearch; var config = new ConnectionConfiguration(new Uri(configurationString));
public ElasticSearchClient(string configurationString) elasticSearch = new ElasticLowLevelClient(config);
{ }
var config = new ConnectionConfiguration(new Uri(configurationString));
elasticSearch = new ElasticLowLevelClient(config); public async Task CreateIndexAsync<T>(string indexName, T request,
} CancellationToken ct)
{
var result = await elasticSearch.Indices.PutMappingAsync<StringResponse>(indexName, CreatePost(request), ctx: ct);
public async Task CreateIndexAsync<T>(string indexName, T request, if (!result.Success)
CancellationToken ct)
{ {
var result = await elasticSearch.Indices.PutMappingAsync<StringResponse>(indexName, CreatePost(request), ctx: ct); throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
if (!result.Success)
{
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
}
} }
}
public async Task BulkAsync<T>(List<T> requests, public async Task BulkAsync<T>(List<T> requests,
CancellationToken ct) CancellationToken ct)
{ {
var result = await elasticSearch.BulkAsync<StringResponse>(CreatePost(requests), ctx: ct); var result = await elasticSearch.BulkAsync<StringResponse>(CreatePost(requests), ctx: ct);
if (!result.Success) if (!result.Success)
{ {
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException); throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
}
} }
}
public async Task<List<dynamic>> SearchAsync<T>(string indexName, T request, public async Task<List<dynamic>> SearchAsync<T>(string indexName, T request,
CancellationToken ct) CancellationToken ct)
{ {
var result = await elasticSearch.SearchAsync<DynamicResponse>(indexName, CreatePost(request), ctx: ct); var result = await elasticSearch.SearchAsync<DynamicResponse>(indexName, CreatePost(request), ctx: ct);
if (!result.Success) if (!result.Success)
{ {
throw result.OriginalException; throw result.OriginalException;
} }
var hits = new List<dynamic>(); var hits = new List<dynamic>();
foreach (var item in result.Body.hits.hits) foreach (var item in result.Body.hits.hits)
{
if (item != null)
{ {
if (item != null) hits.Add(item);
{
hits.Add(item);
}
} }
return hits;
} }
private static PostData CreatePost<T>(List<T> requests) return hits;
{ }
return PostData.MultiJson(requests.OfType<object>());
}
private static PostData CreatePost<T>(T data) private static PostData CreatePost<T>(List<T> requests)
{ {
return new SerializableData<T>(data); return PostData.MultiJson(requests.OfType<object>());
} }
private static PostData CreatePost<T>(T data)
{
return new SerializableData<T>(data);
} }
} }

173
backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchIndexDefinition.cs

@ -5,119 +5,118 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
namespace Squidex.Extensions.Text.ElasticSearch namespace Squidex.Extensions.Text.ElasticSearch;
public static class ElasticSearchIndexDefinition
{ {
public static class ElasticSearchIndexDefinition private static readonly Dictionary<string, string> FieldPaths;
private static readonly Dictionary<string, string> FieldAnalyzers = new Dictionary<string, string>
{ {
private static readonly Dictionary<string, string> FieldPaths; ["ar"] = "arabic",
private static readonly Dictionary<string, string> FieldAnalyzers = new Dictionary<string, string> ["hy"] = "armenian",
{ ["eu"] = "basque",
["ar"] = "arabic", ["bn"] = "bengali",
["hy"] = "armenian", ["br"] = "brazilian",
["eu"] = "basque", ["bg"] = "bulgarian",
["bn"] = "bengali", ["ca"] = "catalan",
["br"] = "brazilian", ["zh"] = "cjk",
["bg"] = "bulgarian", ["ja"] = "cjk",
["ca"] = "catalan", ["ko"] = "cjk",
["zh"] = "cjk", ["cs"] = "czech",
["ja"] = "cjk", ["da"] = "danish",
["ko"] = "cjk", ["nl"] = "dutch",
["cs"] = "czech", ["en"] = "english",
["da"] = "danish", ["fi"] = "finnish",
["nl"] = "dutch", ["fr"] = "french",
["en"] = "english", ["gl"] = "galician",
["fi"] = "finnish", ["de"] = "german",
["fr"] = "french", ["el"] = "greek",
["gl"] = "galician", ["hi"] = "hindi",
["de"] = "german", ["hu"] = "hungarian",
["el"] = "greek", ["id"] = "indonesian",
["hi"] = "hindi", ["ga"] = "irish",
["hu"] = "hungarian", ["it"] = "italian",
["id"] = "indonesian", ["lv"] = "latvian",
["ga"] = "irish", ["lt"] = "lithuanian",
["it"] = "italian", ["no"] = "norwegian",
["lv"] = "latvian", ["pt"] = "portuguese",
["lt"] = "lithuanian", ["ro"] = "romanian",
["no"] = "norwegian", ["ru"] = "russian",
["pt"] = "portuguese", ["ku"] = "sorani",
["ro"] = "romanian", ["es"] = "spanish",
["ru"] = "russian", ["sv"] = "swedish",
["ku"] = "sorani", ["tr"] = "turkish",
["es"] = "spanish", ["th"] = "thai"
["sv"] = "swedish", };
["tr"] = "turkish",
["th"] = "thai"
};
static ElasticSearchIndexDefinition() static ElasticSearchIndexDefinition()
{
FieldPaths = FieldAnalyzers.ToDictionary(x => x.Key, x => $"texts.{x.Key}");
}
public static string GetFieldName(string key)
{
if (FieldAnalyzers.ContainsKey(key))
{ {
FieldPaths = FieldAnalyzers.ToDictionary(x => x.Key, x => $"texts.{x.Key}"); return key;
} }
public static string GetFieldName(string key) if (key.Length > 0)
{ {
if (FieldAnalyzers.ContainsKey(key)) var language = key[2..];
{
return key;
}
if (key.Length > 0) if (FieldAnalyzers.ContainsKey(language))
{ {
var language = key[2..]; return language;
if (FieldAnalyzers.ContainsKey(language))
{
return language;
}
} }
}
return "iv"; return "iv";
}
public static string GetFieldPath(string key)
{
if (FieldPaths.TryGetValue(key, out var path))
{
return path;
} }
public static string GetFieldPath(string key) if (key.Length > 0)
{ {
if (FieldPaths.TryGetValue(key, out var path)) var language = key[2..];
if (FieldPaths.TryGetValue(language, out path))
{ {
return path; return path;
} }
}
if (key.Length > 0) return "texts.iv";
{ }
var language = key[2..];
if (FieldPaths.TryGetValue(language, out path)) public static Task ApplyAsync(IElasticSearchClient client, string indexName,
CancellationToken ct = default)
{
var query = new
{
properties = new Dictionary<string, object>
{
["geoObject"] = new
{ {
return path; type = "geo_point"
} }
} }
};
return "texts.iv"; foreach (var (key, analyzer) in FieldAnalyzers)
}
public static Task ApplyAsync(IElasticSearchClient client, string indexName,
CancellationToken ct = default)
{ {
var query = new query.properties[GetFieldPath(key)] = new
{ {
properties = new Dictionary<string, object> type = "text",
{ analyzer
["geoObject"] = new
{
type = "geo_point"
}
}
}; };
foreach (var (key, analyzer) in FieldAnalyzers)
{
query.properties[GetFieldPath(key)] = new
{
type = "text",
analyzer
};
}
return client.CreateIndexAsync(indexName, query, ct);
} }
return client.CreateIndexAsync(indexName, query, ct);
} }
} }

313
backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextIndex.cs

@ -13,219 +13,218 @@ using Squidex.Hosting;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Json;
namespace Squidex.Extensions.Text.ElasticSearch namespace Squidex.Extensions.Text.ElasticSearch;
public sealed class ElasticSearchTextIndex : ITextIndex, IInitializable
{ {
public sealed class ElasticSearchTextIndex : ITextIndex, IInitializable private static readonly Regex LanguageRegex = new Regex(@"[^\w]+([a-z\-_]{2,}):", RegexOptions.Compiled | RegexOptions.ExplicitCapture);
private static readonly Regex LanguageRegexStart = new Regex(@"$^([a-z\-_]{2,}):", RegexOptions.Compiled | RegexOptions.ExplicitCapture);
private readonly IJsonSerializer jsonSerializer;
private readonly IElasticSearchClient elasticClient;
private readonly QueryParser queryParser = new QueryParser(ElasticSearchIndexDefinition.GetFieldPath);
private readonly string indexName;
public ElasticSearchTextIndex(IElasticSearchClient elasticClient, string indexName, IJsonSerializer jsonSerializer)
{ {
private static readonly Regex LanguageRegex = new Regex(@"[^\w]+([a-z\-_]{2,}):", RegexOptions.Compiled | RegexOptions.ExplicitCapture); this.elasticClient = elasticClient;
private static readonly Regex LanguageRegexStart = new Regex(@"$^([a-z\-_]{2,}):", RegexOptions.Compiled | RegexOptions.ExplicitCapture); this.indexName = indexName;
private readonly IJsonSerializer jsonSerializer; this.jsonSerializer = jsonSerializer;
private readonly IElasticSearchClient elasticClient; }
private readonly QueryParser queryParser = new QueryParser(ElasticSearchIndexDefinition.GetFieldPath);
private readonly string indexName; public Task InitializeAsync(
CancellationToken ct)
public ElasticSearchTextIndex(IElasticSearchClient elasticClient, string indexName, IJsonSerializer jsonSerializer) {
{ return ElasticSearchIndexDefinition.ApplyAsync(elasticClient, indexName, ct);
this.elasticClient = elasticClient; }
this.indexName = indexName;
this.jsonSerializer = jsonSerializer; public Task ClearAsync(
} CancellationToken ct = default)
{
return Task.CompletedTask;
}
public Task InitializeAsync( public Task ExecuteAsync(IndexCommand[] commands,
CancellationToken ct) CancellationToken ct = default)
{
var args = new List<object>();
foreach (var command in commands)
{ {
return ElasticSearchIndexDefinition.ApplyAsync(elasticClient, indexName, ct); CommandFactory.CreateCommands(command, args, indexName);
} }
public Task ClearAsync( if (args.Count == 0)
CancellationToken ct = default)
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task ExecuteAsync(IndexCommand[] commands, return elasticClient.BulkAsync(args, ct);
CancellationToken ct = default) }
{
var args = new List<object>();
foreach (var command in commands)
{
CommandFactory.CreateCommands(command, args, indexName);
}
if (args.Count == 0) public async Task<List<DomainId>> SearchAsync(IAppEntity app, GeoQuery query, SearchScope scope,
{ CancellationToken ct = default)
return Task.CompletedTask; {
} Guard.NotNull(app);
Guard.NotNull(query);
return elasticClient.BulkAsync(args, ct); var serveField = GetServeField(scope);
}
public async Task<List<DomainId>> SearchAsync(IAppEntity app, GeoQuery query, SearchScope scope, var elasticQuery = new
CancellationToken ct = default)
{ {
Guard.NotNull(app); query = new
Guard.NotNull(query);
var serveField = GetServeField(scope);
var elasticQuery = new
{ {
query = new @bool = new
{ {
@bool = new filter = new object[]
{ {
filter = new object[] new
{ {
new term = new Dictionary<string, object>
{ {
term = new Dictionary<string, object> ["schemaId.keyword"] = query.SchemaId.ToString()
{ }
["schemaId.keyword"] = query.SchemaId.ToString() },
} new
}, {
new term = new Dictionary<string, string>
{ {
term = new Dictionary<string, string> ["geoField.keyword"] = query.Field
{ }
["geoField.keyword"] = query.Field },
} new
}, {
new term = new Dictionary<string, string>
{ {
term = new Dictionary<string, string> [serveField] = "true"
{ }
[serveField] = "true" },
} new
}, {
new geo_distance = new
{ {
geo_distance = new geoObject = new
{ {
geoObject = new lat = query.Latitude,
{ lon = query.Longitude
lat = query.Latitude, },
lon = query.Longitude distance = $"{query.Radius}m"
},
distance = $"{query.Radius}m"
}
} }
} }
} }
}, }
_source = new[] },
{ _source = new[]
"contentId" {
}, "contentId"
size = query.Take },
}; size = query.Take
};
return await SearchAsync(elasticQuery, ct); return await SearchAsync(elasticQuery, ct);
} }
public async Task<List<DomainId>> SearchAsync(IAppEntity app, TextQuery query, SearchScope scope, public async Task<List<DomainId>> SearchAsync(IAppEntity app, TextQuery query, SearchScope scope,
CancellationToken ct = default) CancellationToken ct = default)
{ {
Guard.NotNull(app); Guard.NotNull(app);
Guard.NotNull(query); Guard.NotNull(query);
var parsed = queryParser.Parse(query.Text); var parsed = queryParser.Parse(query.Text);
if (parsed == null) if (parsed == null)
{ {
return null; return null;
} }
var serveField = GetServeField(scope); var serveField = GetServeField(scope);
var elasticQuery = new var elasticQuery = new
{
query = new
{ {
query = new @bool = new
{ {
@bool = new filter = new List<object>
{ {
filter = new List<object> new
{ {
new term = new Dictionary<string, object>
{
term = new Dictionary<string, object>
{
["appId.keyword"] = app.Id.ToString()
}
},
new
{ {
term = new Dictionary<string, string> ["appId.keyword"] = app.Id.ToString()
{
[serveField] = "true"
}
} }
}, },
must = new new
{ {
query_string = new term = new Dictionary<string, string>
{ {
query = parsed.Text [serveField] = "true"
} }
}, }
should = new List<object>() },
} must = new
}, {
_source = new[] query_string = new
{ {
"contentId" query = parsed.Text
}, }
size = query.Take },
}; should = new List<object>()
}
},
_source = new[]
{
"contentId"
},
size = query.Take
};
if (query.RequiredSchemaIds?.Count > 0) if (query.RequiredSchemaIds?.Count > 0)
{
var bySchema = new
{ {
var bySchema = new terms = new Dictionary<string, object>
{ {
terms = new Dictionary<string, object> ["schemaId.keyword"] = query.RequiredSchemaIds.Select(x => x.ToString()).ToArray()
{ }
["schemaId.keyword"] = query.RequiredSchemaIds.Select(x => x.ToString()).ToArray() };
}
};
elasticQuery.query.@bool.filter.Add(bySchema); elasticQuery.query.@bool.filter.Add(bySchema);
} }
else if (query.PreferredSchemaId.HasValue) else if (query.PreferredSchemaId.HasValue)
{
var bySchema = new
{ {
var bySchema = new terms = new Dictionary<string, object>
{ {
terms = new Dictionary<string, object> ["schemaId.keyword"] = query.PreferredSchemaId.ToString()
{ }
["schemaId.keyword"] = query.PreferredSchemaId.ToString() };
}
};
elasticQuery.query.@bool.should.Add(bySchema);
}
var json = jsonSerializer.Serialize(elasticQuery, true);
return await SearchAsync(elasticQuery, ct); elasticQuery.query.@bool.should.Add(bySchema);
} }
private async Task<List<DomainId>> SearchAsync(object query, var json = jsonSerializer.Serialize(elasticQuery, true);
CancellationToken ct)
{
var hits = await elasticClient.SearchAsync(indexName, query, ct);
var ids = new List<DomainId>(); return await SearchAsync(elasticQuery, ct);
}
foreach (var item in hits) private async Task<List<DomainId>> SearchAsync(object query,
{ CancellationToken ct)
ids.Add(DomainId.Create(item["_source"]["contentId"])); {
} var hits = await elasticClient.SearchAsync(indexName, query, ct);
return ids; var ids = new List<DomainId>();
}
private static string GetServeField(SearchScope scope) foreach (var item in hits)
{ {
return scope == SearchScope.Published ? "servePublished" : "serveAll"; ids.Add(DomainId.Create(item["_source"]["contentId"]));
} }
return ids;
}
private static string GetServeField(SearchScope scope)
{
return scope == SearchScope.Published ? "servePublished" : "serveAll";
} }
} }

69
backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextPlugin.cs

@ -13,56 +13,55 @@ using Squidex.Hosting.Configuration;
using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Text.ElasticSearch namespace Squidex.Extensions.Text.ElasticSearch;
public sealed class ElasticSearchTextPlugin : IPlugin
{ {
public sealed class ElasticSearchTextPlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) var fullTextType = config.GetValue<string>("fullText:type");
if (string.Equals(fullTextType, "elastic", StringComparison.OrdinalIgnoreCase))
{ {
var fullTextType = config.GetValue<string>("fullText:type"); var configuration = config.GetValue<string>("fullText:elastic:configuration");
if (string.Equals(fullTextType, "elastic", StringComparison.OrdinalIgnoreCase)) if (string.IsNullOrWhiteSpace(configuration))
{ {
var configuration = config.GetValue<string>("fullText:elastic:configuration"); var error = new ConfigurationError("Value is required.", "fullText:elastic:configuration");
if (string.IsNullOrWhiteSpace(configuration)) throw new ConfigurationException(error);
{ }
var error = new ConfigurationError("Value is required.", "fullText:elastic:configuration");
throw new ConfigurationException(error); var indexName = config.GetValue<string>("fullText:elastic:indexName");
}
var indexName = config.GetValue<string>("fullText:elastic:indexName"); if (string.IsNullOrWhiteSpace(indexName))
{
indexName = "squidex-index";
}
if (string.IsNullOrWhiteSpace(indexName)) var openSearch = config.GetValue<bool>("fullText:elastic:openSearch");
{
indexName = "squidex-index";
}
var openSearch = config.GetValue<bool>("fullText:elastic:openSearch"); services.AddSingleton(c =>
{
IElasticSearchClient elasticSearchClient;
services.AddSingleton(c => if (openSearch)
{ {
IElasticSearchClient elasticSearchClient; elasticSearchClient = new OpenSearchClient(configuration);
}
if (openSearch) else
{ {
elasticSearchClient = new OpenSearchClient(configuration); elasticSearchClient = new ElasticSearchClient(configuration);
} }
else
{
elasticSearchClient = new ElasticSearchClient(configuration);
}
return new ElasticSearchTextIndex(elasticSearchClient, indexName, c.GetRequiredService<IJsonSerializer>()); return new ElasticSearchTextIndex(elasticSearchClient, indexName, c.GetRequiredService<IJsonSerializer>());
}); });
services.AddSingleton<ITextIndex>( services.AddSingleton<ITextIndex>(
c => c.GetRequiredService<ElasticSearchTextIndex>()); c => c.GetRequiredService<ElasticSearchTextIndex>());
services.AddSingleton<IInitializable>( services.AddSingleton<IInitializable>(
c => c.GetRequiredService<ElasticSearchTextIndex>()); c => c.GetRequiredService<ElasticSearchTextIndex>());
}
} }
} }
} }

19
backend/extensions/Squidex.Extensions/Text/ElasticSearch/IElasticSearchClient.cs

@ -5,17 +5,16 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
namespace Squidex.Extensions.Text.ElasticSearch namespace Squidex.Extensions.Text.ElasticSearch;
public interface IElasticSearchClient
{ {
public interface IElasticSearchClient Task CreateIndexAsync<T>(string indexName, T request,
{ CancellationToken ct);
Task CreateIndexAsync<T>(string indexName, T request,
CancellationToken ct);
Task BulkAsync<T>(List<T> requests, Task BulkAsync<T>(List<T> requests,
CancellationToken ct); CancellationToken ct);
Task<List<dynamic>> SearchAsync<T>(string indexName, T request, Task<List<dynamic>> SearchAsync<T>(string indexName, T request,
CancellationToken ct); CancellationToken ct);
}
} }

95
backend/extensions/Squidex.Extensions/Text/ElasticSearch/OpenSearchClient.cs

@ -7,72 +7,71 @@
using OpenSearch.Net; using OpenSearch.Net;
namespace Squidex.Extensions.Text.ElasticSearch namespace Squidex.Extensions.Text.ElasticSearch;
public sealed class OpenSearchClient : IElasticSearchClient
{ {
public sealed class OpenSearchClient : IElasticSearchClient private readonly IOpenSearchLowLevelClient openSearch;
public OpenSearchClient(string configurationString)
{ {
private readonly IOpenSearchLowLevelClient openSearch; var config = new ConnectionConfiguration(new Uri(configurationString));
public OpenSearchClient(string configurationString) openSearch = new OpenSearchLowLevelClient(config);
{ }
var config = new ConnectionConfiguration(new Uri(configurationString));
openSearch = new OpenSearchLowLevelClient(config); public async Task CreateIndexAsync<T>(string indexName, T request,
} CancellationToken ct)
{
var result = await openSearch.Indices.PutMappingAsync<StringResponse>(indexName, CreatePost(request), ctx: ct);
public async Task CreateIndexAsync<T>(string indexName, T request, if (!result.Success)
CancellationToken ct)
{ {
var result = await openSearch.Indices.PutMappingAsync<StringResponse>(indexName, CreatePost(request), ctx: ct); throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
if (!result.Success)
{
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
}
} }
}
public async Task BulkAsync<T>(List<T> requests, public async Task BulkAsync<T>(List<T> requests,
CancellationToken ct) CancellationToken ct)
{ {
var result = await openSearch.BulkAsync<StringResponse>(CreatePost(requests), ctx: ct); var result = await openSearch.BulkAsync<StringResponse>(CreatePost(requests), ctx: ct);
if (!result.Success) if (!result.Success)
{ {
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException); throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
}
} }
}
public async Task<List<dynamic>> SearchAsync<T>(string indexName, T request, public async Task<List<dynamic>> SearchAsync<T>(string indexName, T request,
CancellationToken ct) CancellationToken ct)
{ {
var result = await openSearch.SearchAsync<DynamicResponse>(indexName, CreatePost(request), ctx: ct); var result = await openSearch.SearchAsync<DynamicResponse>(indexName, CreatePost(request), ctx: ct);
if (!result.Success) if (!result.Success)
{ {
throw result.OriginalException; throw result.OriginalException;
} }
var hits = new List<dynamic>(); var hits = new List<dynamic>();
foreach (var item in result.Body.hits.hits) foreach (var item in result.Body.hits.hits)
{
if (item != null)
{ {
if (item != null) hits.Add(item);
{
hits.Add(item);
}
} }
return hits;
} }
private static PostData CreatePost<T>(List<T> requests) return hits;
{ }
return PostData.MultiJson(requests.OfType<object>());
}
private static PostData CreatePost<T>(T data) private static PostData CreatePost<T>(List<T> requests)
{ {
return new SerializableData<T>(data); return PostData.MultiJson(requests.OfType<object>());
} }
private static PostData CreatePost<T>(T data)
{
return new SerializableData<T>(data);
} }
} }

137
backend/extensions/Squidex.Extensions/Validation/CompositeUniqueValidator.cs

@ -13,101 +13,100 @@ using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Queries; using Squidex.Infrastructure.Queries;
namespace Squidex.Extensions.Validation namespace Squidex.Extensions.Validation;
internal sealed class CompositeUniqueValidator : IValidator
{ {
internal sealed class CompositeUniqueValidator : IValidator private readonly string tag;
private readonly IContentRepository contentRepository;
public CompositeUniqueValidator(string tag, IContentRepository contentRepository)
{ {
private readonly string tag; this.tag = tag;
private readonly IContentRepository contentRepository;
public CompositeUniqueValidator(string tag, IContentRepository contentRepository) this.contentRepository = contentRepository;
{ }
this.tag = tag;
this.contentRepository = contentRepository; public void Validate(object value, ValidationContext context)
{
if (value is ContentData data)
{
context.Root.AddTask(async ct => await ValidateAsync(data, context));
} }
}
private async Task ValidateAsync(ContentData data, ValidationContext context)
{
var validateableFields = context.Root.Schema.Fields.Where(IsValidateableField);
public void Validate(object value, ValidationContext context) var filters = new List<FilterNode<ClrValue>>();
foreach (var field in validateableFields)
{ {
if (value is ContentData data) var fieldValue = TryGetValue(field, data);
if (fieldValue != null)
{ {
context.Root.AddTask(async ct => await ValidateAsync(data, context)); filters.Add(ClrFilter.Eq($"data.{field.Name}.iv", fieldValue));
} }
} }
private async Task ValidateAsync(ContentData data, ValidationContext context) if (filters.Count > 0)
{ {
var validateableFields = context.Root.Schema.Fields.Where(IsValidateableField); var filter = ClrFilter.And(filters);
var filters = new List<FilterNode<ClrValue>>(); var found = await contentRepository.QueryIdsAsync(context.Root.AppId.Id, context.Root.SchemaId.Id, filter);
foreach (var field in validateableFields) if (found.Any(x => x.Id != context.Root.ContentId))
{ {
var fieldValue = TryGetValue(field, data); context.AddError(Enumerable.Empty<string>(), "A content with the same values already exist.");
if (fieldValue != null)
{
filters.Add(ClrFilter.Eq($"data.{field.Name}.iv", fieldValue));
}
} }
}
}
if (filters.Count > 0) private static ClrValue TryGetValue(IRootField field, ContentData data)
{ {
var filter = ClrFilter.And(filters); var value = JsonValue.Null;
var found = await contentRepository.QueryIdsAsync(context.Root.AppId.Id, context.Root.SchemaId.Id, filter);
if (found.Any(x => x.Id != context.Root.ContentId)) if (data.TryGetValue(field.Name, out var fieldValue))
{ {
context.AddError(Enumerable.Empty<string>(), "A content with the same values already exist."); if (fieldValue.TryGetValue(InvariantPartitioning.Key, out var temp) && temp != default)
} {
value = temp;
} }
} }
private static ClrValue TryGetValue(IRootField field, ContentData data) switch (field.RawProperties)
{ {
var value = JsonValue.Null; case BooleanFieldProperties when value.Value is bool b:
return b;
if (data.TryGetValue(field.Name, out var fieldValue)) case BooleanFieldProperties when value.Value == default:
{ return ClrValue.Null;
if (fieldValue.TryGetValue(InvariantPartitioning.Key, out var temp) && temp != default) case NumberFieldProperties when value.Value is double n:
return n;
case NumberFieldProperties when value.Value == default:
return ClrValue.Null;
case StringFieldProperties when value.Value is string s:
return s;
case StringFieldProperties when value.Value == default:
return ClrValue.Null;
case ReferencesFieldProperties when value.Value is JsonArray a:
if (a.FirstOrDefault().Value is string first)
{ {
value = temp; return first;
} }
}
switch (field.RawProperties) break;
{
case BooleanFieldProperties when value.Value is bool b:
return b;
case BooleanFieldProperties when value.Value == default:
return ClrValue.Null;
case NumberFieldProperties when value.Value is double n:
return n;
case NumberFieldProperties when value.Value == default:
return ClrValue.Null;
case StringFieldProperties when value.Value is string s:
return s;
case StringFieldProperties when value.Value == default:
return ClrValue.Null;
case ReferencesFieldProperties when value.Value is JsonArray a:
if (a.FirstOrDefault().Value is string first)
{
return first;
}
break;
}
return null;
} }
private bool IsValidateableField(IRootField field) return null;
{ }
return
field.Partitioning == Partitioning.Invariant && private bool IsValidateableField(IRootField field)
field.RawProperties.Tags?.Contains(tag) == true && {
field.RawProperties is BooleanFieldProperties or NumberFieldProperties or ReferencesFieldProperties or StringFieldProperties; return
} field.Partitioning == Partitioning.Invariant &&
field.RawProperties.Tags?.Contains(tag) == true &&
field.RawProperties is BooleanFieldProperties or NumberFieldProperties or ReferencesFieldProperties or StringFieldProperties;
} }
} }

43
backend/extensions/Squidex.Extensions/Validation/CompositeUniqueValidatorFactory.cs

@ -8,39 +8,38 @@
using Squidex.Domain.Apps.Core.ValidateContent; using Squidex.Domain.Apps.Core.ValidateContent;
using Squidex.Domain.Apps.Entities.Contents.Repositories; using Squidex.Domain.Apps.Entities.Contents.Repositories;
namespace Squidex.Extensions.Validation namespace Squidex.Extensions.Validation;
public sealed class CompositeUniqueValidatorFactory : IValidatorsFactory
{ {
public sealed class CompositeUniqueValidatorFactory : IValidatorsFactory private const string Prefix = "unique:";
private readonly IContentRepository contentRepository;
public CompositeUniqueValidatorFactory(IContentRepository contentRepository)
{ {
private const string Prefix = "unique:"; this.contentRepository = contentRepository;
private readonly IContentRepository contentRepository; }
public CompositeUniqueValidatorFactory(IContentRepository contentRepository) public IEnumerable<IValidator> CreateContentValidators(ValidationContext context, ValidatorFactory createFieldValidator)
{
foreach (var validatorTag in ValidatorTags(context.Root.Schema.Properties.Tags))
{ {
this.contentRepository = contentRepository; yield return new CompositeUniqueValidator(validatorTag, contentRepository);
} }
}
public IEnumerable<IValidator> CreateContentValidators(ValidationContext context, ValidatorFactory createFieldValidator) private static IEnumerable<string> ValidatorTags(IEnumerable<string> tags)
{
if (tags == null)
{ {
foreach (var validatorTag in ValidatorTags(context.Root.Schema.Properties.Tags)) yield break;
{
yield return new CompositeUniqueValidator(validatorTag, contentRepository);
}
} }
private static IEnumerable<string> ValidatorTags(IEnumerable<string> tags) foreach (var tag in tags)
{ {
if (tags == null) if (tag.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase) && tag.Length > Prefix.Length)
{
yield break;
}
foreach (var tag in tags)
{ {
if (tag.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase) && tag.Length > Prefix.Length) yield return tag;
{
yield return tag;
}
} }
} }
} }

11
backend/extensions/Squidex.Extensions/Validation/CompositeUniqueValidatorPlugin.cs

@ -10,13 +10,12 @@ using Microsoft.Extensions.DependencyInjection;
using Squidex.Domain.Apps.Core.ValidateContent; using Squidex.Domain.Apps.Core.ValidateContent;
using Squidex.Infrastructure.Plugins; using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Validation namespace Squidex.Extensions.Validation;
public sealed class CompositeUniqueValidatorPlugin : IPlugin
{ {
public sealed class CompositeUniqueValidatorPlugin : IPlugin public void ConfigureServices(IServiceCollection services, IConfiguration config)
{ {
public void ConfigureServices(IServiceCollection services, IConfiguration config) services.AddSingleton<IValidatorsFactory, CompositeUniqueValidatorFactory>();
{
services.AddSingleton<IValidatorsFactory, CompositeUniqueValidatorFactory>();
}
} }
} }

241
backend/i18n/translator/Squidex.Translator/Commands.cs

@ -13,175 +13,174 @@ using Squidex.Translator.State;
#pragma warning disable CA1822 // Mark members as static #pragma warning disable CA1822 // Mark members as static
namespace Squidex.Translator namespace Squidex.Translator;
public class Commands
{ {
public class Commands [Command(Name = "info", Description = "Shows information about the translator.")]
public void Info()
{
var version = typeof(Commands).Assembly.GetName().Version;
Console.WriteLine($"Squidex Translator Version v{version}");
}
[Command(Name = "translate", Description = "Translates different parts.")]
[SubCommand]
public class Translate
{ {
[Command(Name = "info", Description = "Shows information about the translator.")] [Command(Name = "check-backend", Description = "Check backend files.")]
public void Info() public void CheckBackend(TranslateArguments arguments)
{ {
var version = typeof(Commands).Assembly.GetName().Version; var (folder, service) = Setup(arguments, "backend");
Console.WriteLine($"Squidex Translator Version v{version}"); new CheckBackend(folder, service).Run();
} }
[Command(Name = "translate", Description = "Translates different parts.")] [Command(Name = "check-frontend", Description = "Check frontend files.")]
[SubCommand] public void CheckFrontend(TranslateArguments arguments)
public class Translate
{ {
[Command(Name = "check-backend", Description = "Check backend files.")] var (folder, service) = Setup(arguments, "frontend");
public void CheckBackend(TranslateArguments arguments)
{
var (folder, service) = Setup(arguments, "backend");
new CheckBackend(folder, service).Run(); new CheckFrontend(folder, service).Run();
} }
[Command(Name = "check-frontend", Description = "Check frontend files.")] [Command(Name = "backend", Description = "Translate backend files.")]
public void CheckFrontend(TranslateArguments arguments) public void Backend(TranslateArguments arguments)
{ {
var (folder, service) = Setup(arguments, "frontend"); var (folder, service) = Setup(arguments, "backend");
new CheckFrontend(folder, service).Run(); new TranslateBackend(folder, service).Run();
} }
[Command(Name = "backend", Description = "Translate backend files.")] [Command(Name = "templates", Description = "Translate angular templates.")]
public void Backend(TranslateArguments arguments) public void Templates(TranslateArguments arguments)
{ {
var (folder, service) = Setup(arguments, "backend"); var (folder, service) = Setup(arguments, "frontend");
new TranslateBackend(folder, service).Run(); new TranslateTemplates(folder, service).Run(arguments.Report);
} }
[Command(Name = "templates", Description = "Translate angular templates.")] [Command(Name = "typescript", Description = "Translate typescript files.")]
public void Templates(TranslateArguments arguments) public void Typescript(TranslateArguments arguments)
{ {
var (folder, service) = Setup(arguments, "frontend"); var (folder, service) = Setup(arguments, "frontend");
new TranslateTemplates(folder, service).Run(arguments.Report); new TranslateTypescript(folder, service).Run();
} }
[Command(Name = "typescript", Description = "Translate typescript files.")] [Command(Name = "gen-backend", Description = "Generate the backend translations.")]
public void Typescript(TranslateArguments arguments) public void GenerateBackend(TranslateArguments arguments)
{ {
var (folder, service) = Setup(arguments, "frontend"); var (folder, service) = Setup(arguments, "backend");
new TranslateTypescript(folder, service).Run(); new GenerateBackendResources(folder, service).Run();
} }
[Command(Name = "gen-backend", Description = "Generate the backend translations.")] [Command(Name = "gen-frontend", Description = "Generate the frontend translations.")]
public void GenerateBackend(TranslateArguments arguments) public void GenerateFrontend(TranslateArguments arguments)
{ {
var (folder, service) = Setup(arguments, "backend"); var (folder, service) = Setup(arguments, "frontend");
new GenerateBackendResources(folder, service).Run(); new GenerateFrontendResources(folder, service).Run();
} }
[Command(Name = "gen-frontend", Description = "Generate the frontend translations.")] [Command(Name = "clean-backend", Description = "Clean the backend translations.")]
public void GenerateFrontend(TranslateArguments arguments) public void CleanBackend(TranslateArguments arguments)
{ {
var (folder, service) = Setup(arguments, "frontend"); var (_, service) = Setup(arguments, "backend");
new GenerateFrontendResources(folder, service).Run(); Helper.CleanOtherLocales(service);
}
[Command(Name = "clean-backend", Description = "Clean the backend translations.")] service.Save();
public void CleanBackend(TranslateArguments arguments) }
{
var (_, service) = Setup(arguments, "backend");
Helper.CleanOtherLocales(service); [Command(Name = "clean-frontend", Description = "Clean the frontend translations.")]
public void CleanFrontend(TranslateArguments arguments)
{
var (_, service) = Setup(arguments, "frontend");
service.Save(); Helper.CleanOtherLocales(service);
}
[Command(Name = "clean-frontend", Description = "Clean the frontend translations.")] service.Save();
public void CleanFrontend(TranslateArguments arguments) }
{
var (_, service) = Setup(arguments, "frontend");
Helper.CleanOtherLocales(service); [Command(Name = "gen-keys", Description = "Generate the keys for translations.")]
public void GenerateBackendKeys(TranslateArguments arguments)
{
var (backendFolder, serviceBackend) = Setup(arguments, "backend");
service.Save(); new GenerateKeys(backendFolder, serviceBackend, "backend_keys.json").Run();
}
[Command(Name = "gen-keys", Description = "Generate the keys for translations.")] var (frontendFolder, frontendService) = Setup(arguments, "frontend");
public void GenerateBackendKeys(TranslateArguments arguments)
{
var (backendFolder, serviceBackend) = Setup(arguments, "backend");
new GenerateKeys(backendFolder, serviceBackend, "backend_keys.json").Run(); new GenerateKeys(frontendFolder, frontendService, "frontend_keys.json").Run();
}
var (frontendFolder, frontendService) = Setup(arguments, "frontend"); [Command(Name = "migrate-backend", Description = "Migrate the backend files.")]
public void MigrateBackend(TranslateArguments arguments)
{
var (_, service) = Setup(arguments, "backend");
new GenerateKeys(frontendFolder, frontendService, "frontend_keys.json").Run(); service.Migrate();
} }
[Command(Name = "migrate-backend", Description = "Migrate the backend files.")] [Command(Name = "migrate-frontend", Description = "Migrate the frontend files.")]
public void MigrateBackend(TranslateArguments arguments) public void MigrateFrontend(TranslateArguments arguments)
{ {
var (_, service) = Setup(arguments, "backend"); var (_, service) = Setup(arguments, "frontend");
service.Migrate(); service.Migrate();
} }
[Command(Name = "migrate-frontend", Description = "Migrate the frontend files.")] private static (DirectoryInfo, TranslationService) Setup(TranslateArguments arguments, string fileName)
public void MigrateFrontend(TranslateArguments arguments) {
if (!Directory.Exists(arguments.Folder))
{ {
var (_, service) = Setup(arguments, "frontend"); throw new ArgumentException("Folder does not exist.", nameof(arguments));
service.Migrate();
} }
private static (DirectoryInfo, TranslationService) Setup(TranslateArguments arguments, string fileName) var supportedLocales = new string[] { "en", "nl", "it", "zh" };
{
if (!Directory.Exists(arguments.Folder))
{
throw new ArgumentException("Folder does not exist.", nameof(arguments));
}
var supportedLocales = new string[] { "en", "nl", "it", "zh" };
var locales = supportedLocales; var locales = supportedLocales;
if (arguments.Locales != null && arguments.Locales.Any()) if (arguments.Locales != null && arguments.Locales.Any())
{ {
locales = supportedLocales.Intersect(arguments.Locales).ToArray(); locales = supportedLocales.Intersect(arguments.Locales).ToArray();
} }
if (locales.Length == 0) if (locales.Length == 0)
{ {
locales = supportedLocales; locales = supportedLocales;
} }
var translationsDirectory = new DirectoryInfo(Path.Combine(arguments.Folder, "backend", "i18n")); var translationsDirectory = new DirectoryInfo(Path.Combine(arguments.Folder, "backend", "i18n"));
var translationsService = new TranslationService(translationsDirectory, fileName, locales, arguments.SingleWords); var translationsService = new TranslationService(translationsDirectory, fileName, locales, arguments.SingleWords);
return (new DirectoryInfo(arguments.Folder), translationsService); return (new DirectoryInfo(arguments.Folder), translationsService);
}
} }
}
[Validator(typeof(Validator))] [Validator(typeof(Validator))]
public sealed class TranslateArguments : IArgumentModel public sealed class TranslateArguments : IArgumentModel
{ {
[Operand(Name = "folder", Description = "The squidex folder.")] [Operand(Name = "folder", Description = "The squidex folder.")]
public string Folder { get; set; } public string Folder { get; set; }
[Option(LongName = "single", ShortName = "s", Description = "Single words only.")] [Option(LongName = "single", ShortName = "s", Description = "Single words only.")]
public bool SingleWords { get; set; } public bool SingleWords { get; set; }
[Option(LongName = "report", ShortName = "r")] [Option(LongName = "report", ShortName = "r")]
public bool Report { get; set; } public bool Report { get; set; }
[Option(LongName = "locale", ShortName = "l")] [Option(LongName = "locale", ShortName = "l")]
public IEnumerable<string> Locales { get; set; } public IEnumerable<string> Locales { get; set; }
public sealed class Validator : AbstractValidator<TranslateArguments> public sealed class Validator : AbstractValidator<TranslateArguments>
{
public Validator()
{ {
public Validator() RuleFor(x => x.Folder).NotEmpty();
{
RuleFor(x => x.Folder).NotEmpty();
}
} }
} }
} }

19
backend/i18n/translator/Squidex.Translator/Extensions.cs

@ -5,18 +5,17 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
namespace Squidex.Translator namespace Squidex.Translator;
public static class Extensions
{ {
public static class Extensions public static bool IsPotentialText(this string text)
{ {
public static bool IsPotentialText(this string text) return !string.IsNullOrWhiteSpace(text) && text.Any(c => char.IsLetter(c));
{ }
return !string.IsNullOrWhiteSpace(text) && text.Any(c => char.IsLetter(c));
}
public static bool IsPotentialMultiWordText(this string text) public static bool IsPotentialMultiWordText(this string text)
{ {
return text.Contains(' ', StringComparison.Ordinal) && text.IsPotentialText(); return text.Contains(' ', StringComparison.Ordinal) && text.IsPotentialText();
}
} }
} }

63
backend/i18n/translator/Squidex.Translator/Processes/Backend.cs

@ -5,51 +5,50 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
namespace Squidex.Translator.Processes namespace Squidex.Translator.Processes;
public static class Backend
{ {
public static class Backend public static DirectoryInfo GetFolder(DirectoryInfo folder)
{ {
public static DirectoryInfo GetFolder(DirectoryInfo folder) return new DirectoryInfo(Path.Combine(folder.FullName, "backend", "src"));
{ }
return new DirectoryInfo(Path.Combine(folder.FullName, "backend", "src"));
}
public static IEnumerable<(FileInfo, string)> GetFiles(DirectoryInfo folder) public static IEnumerable<(FileInfo, string)> GetFiles(DirectoryInfo folder)
{
var files =
folder.GetFiles(@"*.cs", SearchOption.AllDirectories).Union(
folder.GetFiles(@"*.cshtml", SearchOption.AllDirectories));
foreach (var file in files)
{ {
var files = var relativeName = Helper.RelativeName(file, folder);
folder.GetFiles(@"*.cs", SearchOption.AllDirectories).Union(
folder.GetFiles(@"*.cshtml", SearchOption.AllDirectories));
foreach (var file in files) if (relativeName.Contains("/obj/", StringComparison.Ordinal) ||
relativeName.Contains("/bin/", StringComparison.Ordinal))
{ {
var relativeName = Helper.RelativeName(file, folder); continue;
if (relativeName.Contains("/obj/", StringComparison.Ordinal) ||
relativeName.Contains("/bin/", StringComparison.Ordinal))
{
continue;
}
yield return (file, relativeName);
} }
yield return (file, relativeName);
} }
}
public static IEnumerable<(FileInfo, string)> GetFilesCS(DirectoryInfo folder)
{
var files = folder.GetFiles(@"*AssetsController.cs", SearchOption.AllDirectories);
public static IEnumerable<(FileInfo, string)> GetFilesCS(DirectoryInfo folder) foreach (var file in files)
{ {
var files = folder.GetFiles(@"*AssetsController.cs", SearchOption.AllDirectories); var relativeName = Helper.RelativeName(file, folder);
foreach (var file in files) if (relativeName.Contains("/obj/", StringComparison.Ordinal) ||
relativeName.Contains("/bin/", StringComparison.Ordinal))
{ {
var relativeName = Helper.RelativeName(file, folder); continue;
if (relativeName.Contains("/obj/", StringComparison.Ordinal) ||
relativeName.Contains("/bin/", StringComparison.Ordinal))
{
continue;
}
yield return (file, relativeName);
} }
yield return (file, relativeName);
} }
} }
} }

63
backend/i18n/translator/Squidex.Translator/Processes/CheckBackend.cs

@ -8,54 +8,53 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Squidex.Translator.State; using Squidex.Translator.State;
namespace Squidex.Translator.Processes namespace Squidex.Translator.Processes;
public class CheckBackend
{ {
public class CheckBackend private readonly TranslationService service;
private readonly DirectoryInfo folder;
public CheckBackend(DirectoryInfo folder, TranslationService service)
{ {
private readonly TranslationService service; this.folder = Backend.GetFolder(folder);
private readonly DirectoryInfo folder;
public CheckBackend(DirectoryInfo folder, TranslationService service) this.service = service;
{ }
this.folder = Backend.GetFolder(folder);
this.service = service; public void Run()
} {
var all = new HashSet<string>();
public void Run() foreach (var (file, relativeName) in Backend.GetFiles(folder))
{ {
var all = new HashSet<string>(); var content = File.ReadAllText(file.FullName);
foreach (var (file, relativeName) in Backend.GetFiles(folder)) var translations = new HashSet<string>();
{
var content = File.ReadAllText(file.FullName);
var translations = new HashSet<string>(); void AddTranslations(string regex)
{
var matches = Regex.Matches(content, regex, RegexOptions.Singleline | RegexOptions.ExplicitCapture);
void AddTranslations(string regex) foreach (Match match in matches)
{ {
var matches = Regex.Matches(content, regex, RegexOptions.Singleline | RegexOptions.ExplicitCapture); var key = match.Groups["Key"].Value;
foreach (Match match in matches)
{
var key = match.Groups["Key"].Value;
translations.Add(key); translations.Add(key);
all.Add(key); all.Add(key);
}
} }
AddTranslations("T\\.Get\\(\"(?<Key>[^\"]*)\"");
AddTranslations("\"(?<Key>history\\.[^\"]*)\"");
Helper.CheckForFile(service, relativeName, translations);
} }
Helper.CheckUnused(service, all); AddTranslations("T\\.Get\\(\"(?<Key>[^\"]*)\"");
Helper.CheckOtherLocales(service); AddTranslations("\"(?<Key>history\\.[^\"]*)\"");
service.Save(); Helper.CheckForFile(service, relativeName, translations);
} }
Helper.CheckUnused(service, all);
Helper.CheckOtherLocales(service);
service.Save();
} }
} }

125
backend/i18n/translator/Squidex.Translator/Processes/CheckFrontend.cs

@ -8,98 +8,97 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Squidex.Translator.State; using Squidex.Translator.State;
namespace Squidex.Translator.Processes namespace Squidex.Translator.Processes;
public class CheckFrontend
{ {
public class CheckFrontend private readonly TranslationService service;
private readonly DirectoryInfo folder;
public CheckFrontend(DirectoryInfo folder, TranslationService service)
{ {
private readonly TranslationService service; this.folder = Frontend.GetFolder(folder);
private readonly DirectoryInfo folder;
public CheckFrontend(DirectoryInfo folder, TranslationService service) this.service = service;
{ }
this.folder = Frontend.GetFolder(folder);
this.service = service; public void Run()
} {
var all = new HashSet<string>();
public void Run() foreach (var (file, relativeName) in Frontend.GetTemplateFiles(folder))
{ {
var all = new HashSet<string>(); var translations = GetTranslationsInTemplate(file);
foreach (var (file, relativeName) in Frontend.GetTemplateFiles(folder)) foreach (var translation in translations)
{ {
var translations = GetTranslationsInTemplate(file); all.Add(translation);
}
foreach (var translation in translations) Helper.CheckForFile(service, relativeName, translations);
{ }
all.Add(translation);
}
Helper.CheckForFile(service, relativeName, translations); foreach (var (file, relativeName) in Frontend.GetTypescriptFiles(folder))
} {
var translations = GetTranslationsInTypescript(file);
foreach (var (file, relativeName) in Frontend.GetTypescriptFiles(folder)) foreach (var translation in translations)
{ {
var translations = GetTranslationsInTypescript(file); all.Add(translation);
}
foreach (var translation in translations) Helper.CheckForFile(service, relativeName, translations);
{ }
all.Add(translation);
}
Helper.CheckForFile(service, relativeName, translations); Helper.CheckUnused(service, all);
} Helper.CheckOtherLocales(service);
Helper.CheckUnused(service, all); service.Save();
Helper.CheckOtherLocales(service); }
service.Save(); private static HashSet<string> GetTranslationsInTemplate(FileInfo file)
} {
var content = File.ReadAllText(file.FullName);
private static HashSet<string> GetTranslationsInTemplate(FileInfo file) var translations = new HashSet<string>();
{
var content = File.ReadAllText(file.FullName);
var translations = new HashSet<string>(); void AddTranslations(string regex)
{
var matches = Regex.Matches(content, regex, RegexOptions.Singleline | RegexOptions.ExplicitCapture);
void AddTranslations(string regex) foreach (Match match in matches)
{ {
var matches = Regex.Matches(content, regex, RegexOptions.Singleline | RegexOptions.ExplicitCapture); translations.Add(match.Groups["Key"].Value);
foreach (Match match in matches)
{
translations.Add(match.Groups["Key"].Value);
}
} }
}
AddTranslations("\"i18n\\:(?<Key>[^\"]+)\""); AddTranslations("\"i18n\\:(?<Key>[^\"]+)\"");
AddTranslations("'i18n\\:(?<Key>[^\']+)'"); AddTranslations("'i18n\\:(?<Key>[^\']+)'");
AddTranslations("'(?<Key>[^\']+)' \\| sqxTranslate"); AddTranslations("'(?<Key>[^\']+)' \\| sqxTranslate");
return translations; return translations;
} }
private static HashSet<string> GetTranslationsInTypescript(FileInfo file) private static HashSet<string> GetTranslationsInTypescript(FileInfo file)
{ {
var content = File.ReadAllText(file.FullName); var content = File.ReadAllText(file.FullName);
var translations = new HashSet<string>(); var translations = new HashSet<string>();
void AddTranslations(string regex) void AddTranslations(string regex)
{ {
var matches = Regex.Matches(content, regex, RegexOptions.Singleline | RegexOptions.ExplicitCapture); var matches = Regex.Matches(content, regex, RegexOptions.Singleline | RegexOptions.ExplicitCapture);
foreach (Match match in matches) foreach (Match match in matches)
{ {
translations.Add(match.Groups["Key"].Value); translations.Add(match.Groups["Key"].Value);
}
} }
}
AddTranslations("'i18n\\:(?<Key>[^\']+)'"); AddTranslations("'i18n\\:(?<Key>[^\']+)'");
AddTranslations("localizer.get\\('(?<Key>[^\']+)'\\)"); AddTranslations("localizer.get\\('(?<Key>[^\']+)'\\)");
AddTranslations("localizer.getOrKey\\('(?<Key>[^\']+)'\\)"); AddTranslations("localizer.getOrKey\\('(?<Key>[^\']+)'\\)");
return translations; return translations;
}
} }
} }

45
backend/i18n/translator/Squidex.Translator/Processes/Frontend.cs

@ -5,38 +5,37 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
namespace Squidex.Translator.Processes namespace Squidex.Translator.Processes;
public static class Frontend
{ {
public static class Frontend public static DirectoryInfo GetFolder(DirectoryInfo folder)
{ {
public static DirectoryInfo GetFolder(DirectoryInfo folder) return new DirectoryInfo(Path.Combine(folder.FullName, "frontend", "src", "app"));
{ }
return new DirectoryInfo(Path.Combine(folder.FullName, "frontend", "src", "app"));
}
public static IEnumerable<(FileInfo, string)> GetTypescriptFiles(DirectoryInfo folder) public static IEnumerable<(FileInfo, string)> GetTypescriptFiles(DirectoryInfo folder)
{ {
var files = folder.GetFiles(@"*.ts", SearchOption.AllDirectories); var files = folder.GetFiles(@"*.ts", SearchOption.AllDirectories);
foreach (var file in files) foreach (var file in files)
{
if (file.Name.EndsWith(".spec.ts", StringComparison.OrdinalIgnoreCase))
{ {
if (file.Name.EndsWith(".spec.ts", StringComparison.OrdinalIgnoreCase)) continue;
{
continue;
}
yield return (file, Helper.RelativeName(file, folder));
} }
yield return (file, Helper.RelativeName(file, folder));
} }
}
public static IEnumerable<(FileInfo, string)> GetTemplateFiles(DirectoryInfo folder) public static IEnumerable<(FileInfo, string)> GetTemplateFiles(DirectoryInfo folder)
{ {
var files = folder.GetFiles(@"*.html", SearchOption.AllDirectories); var files = folder.GetFiles(@"*.html", SearchOption.AllDirectories);
foreach (var file in files) foreach (var file in files)
{ {
yield return (file, Helper.RelativeName(file, folder)); yield return (file, Helper.RelativeName(file, folder));
}
} }
} }
} }

71
backend/i18n/translator/Squidex.Translator/Processes/GenerateBackendResources.cs

@ -9,60 +9,59 @@ using System.Resources.NetStandard;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Squidex.Translator.State; using Squidex.Translator.State;
namespace Squidex.Translator.Processes namespace Squidex.Translator.Processes;
public sealed class GenerateBackendResources
{ {
public sealed class GenerateBackendResources private readonly TranslationService service;
private readonly DirectoryInfo folder;
public GenerateBackendResources(DirectoryInfo folder, TranslationService service)
{ {
private readonly TranslationService service; this.folder = new DirectoryInfo(Path.Combine(folder.FullName, "backend", "src", "Squidex.Shared"));
private readonly DirectoryInfo folder;
this.service = service;
}
public GenerateBackendResources(DirectoryInfo folder, TranslationService service) public void Run()
{
foreach (var locale in service.SupportedLocales)
{ {
this.folder = new DirectoryInfo(Path.Combine(folder.FullName, "backend", "src", "Squidex.Shared")); var name = locale ==
service.MainLocale ?
$"Texts.resx" :
$"Texts.{locale}.resx";
this.service = service; var fullName = Path.Combine(folder.FullName, name);
}
public void Run() using (var writer = new ResXResourceWriter(fullName))
{
foreach (var locale in service.SupportedLocales)
{ {
var name = locale == var texts = service.GetTextsWithFallback(locale);
service.MainLocale ?
$"Texts.resx" :
$"Texts.{locale}.resx";
var fullName = Path.Combine(folder.FullName, name); foreach (var (key, value) in texts)
using (var writer = new ResXResourceWriter(fullName))
{ {
var texts = service.GetTextsWithFallback(locale); writer.AddResource(key, value);
foreach (var (key, value) in texts) if (key.StartsWith("annotations_", StringComparison.OrdinalIgnoreCase))
{ {
writer.AddResource(key, value); var i = 0;
if (key.StartsWith("annotations_", StringComparison.OrdinalIgnoreCase))
{
var i = 0;
var dotnetKey = $"dotnet_{key}"; var dotnetKey = $"dotnet_{key}";
var dotnetValue = Regex.Replace(value, "{[^}]*}", m => $"{{{i++}}}"); var dotnetValue = Regex.Replace(value, "{[^}]*}", m => $"{{{i++}}}");
writer.AddResource(dotnetKey, dotnetValue); writer.AddResource(dotnetKey, dotnetValue);
}
} }
} }
}
var text = File.ReadAllText(fullName); var text = File.ReadAllText(fullName);
text = text.Replace("System.Resources.NetStandard.ResXResourceReader, System.Resources.NetStandard, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089", StringComparison.Ordinal);
text = text.Replace("System.Resources.NetStandard.ResXResourceWriter, System.Resources.NetStandard, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089", StringComparison.Ordinal);
File.WriteAllText(fullName, text); text = text.Replace("System.Resources.NetStandard.ResXResourceReader, System.Resources.NetStandard, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089", StringComparison.Ordinal);
} text = text.Replace("System.Resources.NetStandard.ResXResourceWriter, System.Resources.NetStandard, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089", StringComparison.Ordinal);
service.Save(); File.WriteAllText(fullName, text);
} }
service.Save();
} }
} }

45
backend/i18n/translator/Squidex.Translator/Processes/GenerateFrontendResources.cs

@ -7,37 +7,36 @@
using Squidex.Translator.State; using Squidex.Translator.State;
namespace Squidex.Translator.Processes namespace Squidex.Translator.Processes;
public sealed class GenerateFrontendResources
{ {
public sealed class GenerateFrontendResources private readonly TranslationService service;
{ private readonly DirectoryInfo folder;
private readonly TranslationService service;
private readonly DirectoryInfo folder;
public GenerateFrontendResources(DirectoryInfo folder, TranslationService service) public GenerateFrontendResources(DirectoryInfo folder, TranslationService service)
{ {
this.folder = new DirectoryInfo(Path.Combine(folder.FullName, "backend", "i18n")); this.folder = new DirectoryInfo(Path.Combine(folder.FullName, "backend", "i18n"));
this.service = service; this.service = service;
} }
public void Run() public void Run()
{
foreach (var locale in service.SupportedLocales)
{ {
foreach (var locale in service.SupportedLocales) var fullName = Path.Combine(folder.FullName, $"frontend_{locale}.json");
{
var fullName = Path.Combine(folder.FullName, $"frontend_{locale}.json");
if (!folder.Exists)
{
Directory.CreateDirectory(folder.FullName);
}
var texts = service.GetTextsWithFallback(locale);
service.WriteTo(texts, fullName); if (!folder.Exists)
{
Directory.CreateDirectory(folder.FullName);
} }
service.Save(); var texts = service.GetTextsWithFallback(locale);
service.WriteTo(texts, fullName);
} }
service.Save();
} }
} }

51
backend/i18n/translator/Squidex.Translator/Processes/GenerateKeys.cs

@ -7,40 +7,39 @@
using Squidex.Translator.State; using Squidex.Translator.State;
namespace Squidex.Translator.Processes namespace Squidex.Translator.Processes;
public sealed class GenerateKeys
{ {
public sealed class GenerateKeys private readonly TranslationService service;
private readonly string fileName;
private readonly DirectoryInfo folder;
public GenerateKeys(DirectoryInfo folder, TranslationService service, string fileName)
{ {
private readonly TranslationService service; this.folder = folder;
private readonly string fileName; this.service = service;
private readonly DirectoryInfo folder; this.fileName = fileName;
}
public GenerateKeys(DirectoryInfo folder, TranslationService service, string fileName) public void Run()
{ {
this.folder = folder; var keys = new TranslatedTexts();
this.service = service;
this.fileName = fileName;
}
public void Run() foreach (var text in service.MainTranslations)
{ {
var keys = new TranslatedTexts(); keys.Add(text.Key, string.Empty);
}
foreach (var text in service.MainTranslations)
{
keys.Add(text.Key, string.Empty);
}
var fullName = Path.Combine(folder.FullName, fileName); var fullName = Path.Combine(folder.FullName, fileName);
if (!folder.Exists) if (!folder.Exists)
{ {
Directory.CreateDirectory(folder.FullName); Directory.CreateDirectory(folder.FullName);
} }
service.WriteTo(keys, fullName); service.WriteTo(keys, fullName);
service.Save(); service.Save();
}
} }
} }

217
backend/i18n/translator/Squidex.Translator/Processes/Helper.cs

@ -7,162 +7,161 @@
using Squidex.Translator.State; using Squidex.Translator.State;
namespace Squidex.Translator.Processes namespace Squidex.Translator.Processes;
public static class Helper
{ {
public static class Helper public static string RelativeName(FileInfo file, DirectoryInfo folder)
{ {
public static string RelativeName(FileInfo file, DirectoryInfo folder) return file.FullName[folder.FullName.Length..].Replace("\\", "/", StringComparison.Ordinal);
{ }
return file.FullName[folder.FullName.Length..].Replace("\\", "/", StringComparison.Ordinal);
}
public static void CheckOtherLocales(TranslationService service) public static void CheckOtherLocales(TranslationService service)
{ {
var mainTranslations = service.MainTranslations; var mainTranslations = service.MainTranslations;
foreach (var (locale, texts) in service.Translations.Where(x => x.Key != service.MainLocale)) foreach (var (locale, texts) in service.Translations.Where(x => x.Key != service.MainLocale))
{ {
Console.WriteLine(); Console.WriteLine();
Console.WriteLine("----- CHECKING <{0}> -----", locale); Console.WriteLine("----- CHECKING <{0}> -----", locale);
var notTranslated = mainTranslations.Keys.Except(texts.Keys).ToList(); var notTranslated = mainTranslations.Keys.Except(texts.Keys).ToList();
var notUsing = texts.Keys.Except(mainTranslations.Keys).ToList(); var notUsing = texts.Keys.Except(mainTranslations.Keys).ToList();
if (notTranslated.Count > 0 || notUsing.Count > 0) if (notTranslated.Count > 0 || notUsing.Count > 0)
{
if (notTranslated.Count > 0)
{ {
if (notTranslated.Count > 0) Console.WriteLine();
{ Console.WriteLine("Translations missing:");
Console.WriteLine();
Console.WriteLine("Translations missing:");
foreach (var key in notTranslated.OrderBy(x => x))
{
Console.Write(" * ");
Console.WriteLine(key);
}
}
if (notUsing.Count > 0) foreach (var key in notTranslated.OrderBy(x => x))
{ {
Console.WriteLine(); Console.Write(" * ");
Console.WriteLine("Translations not used:"); Console.WriteLine(key);
foreach (var key in notUsing.OrderBy(x => x))
{
Console.Write(" * ");
Console.WriteLine(key);
}
} }
} }
else
if (notUsing.Count > 0)
{ {
Console.WriteLine("> No errors found"); Console.WriteLine();
Console.WriteLine("Translations not used:");
foreach (var key in notUsing.OrderBy(x => x))
{
Console.Write(" * ");
Console.WriteLine(key);
}
} }
} }
else
{
Console.WriteLine("> No errors found");
}
} }
}
public static void CleanOtherLocales(TranslationService service)
{
var mainTranslations = service.MainTranslations;
public static void CleanOtherLocales(TranslationService service) foreach (var (locale, texts) in service.Translations.Where(x => x.Key != service.MainLocale))
{ {
var mainTranslations = service.MainTranslations; Console.WriteLine();
Console.WriteLine("----- CLEANING <{0}> -----", locale);
foreach (var (locale, texts) in service.Translations.Where(x => x.Key != service.MainLocale)) var notUsed = texts.Keys.Except(mainTranslations.Keys).ToList();
if (notUsed.Count > 0)
{ {
Console.WriteLine(); foreach (var unused in notUsed)
Console.WriteLine("----- CLEANING <{0}> -----", locale); {
texts.Remove(unused);
}
var notUsed = texts.Keys.Except(mainTranslations.Keys).ToList(); Console.WriteLine("Cleaned {0} translations.", notUsed.Count);
}
else
{
Console.WriteLine("> No errors found");
}
}
}
if (notUsed.Count > 0) public static void CheckUnused(TranslationService service, HashSet<string> translations)
{ {
foreach (var unused in notUsed) var notUsing = new SortedSet<string>();
{
texts.Remove(unused);
}
Console.WriteLine("Cleaned {0} translations.", notUsed.Count); foreach (var key in service.MainTranslations.Keys)
} {
else if (!translations.Contains(key) &&
{ !key.StartsWith("common.", StringComparison.OrdinalIgnoreCase) &&
Console.WriteLine("> No errors found"); !key.StartsWith("dotnet_", StringComparison.OrdinalIgnoreCase) &&
} !key.StartsWith("validation.", StringComparison.OrdinalIgnoreCase) &&
!key.StartsWith("rules.simulation.error", StringComparison.OrdinalIgnoreCase))
{
notUsing.Add(key);
} }
} }
public static void CheckUnused(TranslationService service, HashSet<string> translations) if (notUsing.Count > 0)
{ {
var notUsing = new SortedSet<string>(); Console.WriteLine("Translations not used:");
foreach (var key in service.MainTranslations.Keys) foreach (var key in notUsing)
{ {
if (!translations.Contains(key) && Console.Write(" * ");
!key.StartsWith("common.", StringComparison.OrdinalIgnoreCase) && Console.WriteLine(key);
!key.StartsWith("dotnet_", StringComparison.OrdinalIgnoreCase) &&
!key.StartsWith("validation.", StringComparison.OrdinalIgnoreCase) &&
!key.StartsWith("rules.simulation.error", StringComparison.OrdinalIgnoreCase))
{
notUsing.Add(key);
}
} }
}
}
if (notUsing.Count > 0) public static void CheckForFile(TranslationService service, string relativeName, HashSet<string> translations)
{
if (translations.Count > 0)
{
var prefixes = new HashSet<string>();
foreach (var key in translations.ToList())
{ {
Console.WriteLine("Translations not used:"); if (service.MainTranslations.ContainsKey(key))
{
translations.Remove(key);
}
foreach (var key in notUsing) var parts = key.Split(".");
if (parts.Length > 1 && parts[0] != "common" && parts[0] != "validation")
{ {
Console.Write(" * "); prefixes.Add(parts[0]);
Console.WriteLine(key);
} }
} }
}
public static void CheckForFile(TranslationService service, string relativeName, HashSet<string> translations) if (HasInvalidPrefixes(prefixes) || translations.Count > 0)
{
if (translations.Count > 0)
{ {
var prefixes = new HashSet<string>(); Console.WriteLine("Errors in file {0}.", relativeName);
foreach (var key in translations.ToList()) if (HasInvalidPrefixes(prefixes))
{ {
if (service.MainTranslations.ContainsKey(key)) Console.WriteLine(" > Multiple prefixes found: {0}", string.Join(",", prefixes));
{
translations.Remove(key);
}
var parts = key.Split(".");
if (parts.Length > 1 && parts[0] != "common" && parts[0] != "validation")
{
prefixes.Add(parts[0]);
}
} }
if (HasInvalidPrefixes(prefixes) || translations.Count > 0) if (translations.Count > 0)
{ {
Console.WriteLine("Errors in file {0}.", relativeName); foreach (var key in translations)
if (HasInvalidPrefixes(prefixes))
{
Console.WriteLine(" > Multiple prefixes found: {0}", string.Join(",", prefixes));
}
if (translations.Count > 0)
{ {
foreach (var key in translations) Console.Write(" * ");
{ Console.WriteLine(key);
Console.Write(" * ");
Console.WriteLine(key);
}
} }
Console.WriteLine();
} }
Console.WriteLine();
} }
} }
}
private static bool HasInvalidPrefixes(HashSet<string> prefixes) private static bool HasInvalidPrefixes(HashSet<string> prefixes)
{ {
return prefixes.Count > 1; return prefixes.Count > 1;
}
} }
} }

65
backend/i18n/translator/Squidex.Translator/Processes/TranslateBackend.cs

@ -8,54 +8,53 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Squidex.Translator.State; using Squidex.Translator.State;
namespace Squidex.Translator.Processes namespace Squidex.Translator.Processes;
public class TranslateBackend
{ {
public class TranslateBackend private readonly TranslationService service;
private readonly DirectoryInfo folder;
public TranslateBackend(DirectoryInfo folder, TranslationService service)
{ {
private readonly TranslationService service; this.folder = Backend.GetFolder(folder);
private readonly DirectoryInfo folder;
this.service = service;
}
public TranslateBackend(DirectoryInfo folder, TranslationService service) public void Run()
{
foreach (var (file, relativeName) in Backend.GetFilesCS(folder))
{ {
this.folder = Backend.GetFolder(folder); var content = File.ReadAllText(file.FullName);
this.service = service; var isReplaced = false;
}
public void Run() content = Regex.Replace(content, "\"[^\"]*\"", match =>
{
foreach (var (file, relativeName) in Backend.GetFilesCS(folder))
{ {
var content = File.ReadAllText(file.FullName); var value = match.Value[1..^1];
var isReplaced = false; string result = null;
content = Regex.Replace(content, "\"[^\"]*\"", match => if (value.IsPotentialMultiWordText())
{ {
var value = match.Value[1..^1]; service.Translate(relativeName, value, "Code", key =>
string result = null;
if (value.IsPotentialMultiWordText())
{ {
service.Translate(relativeName, value, "Code", key => result = $"T.Get(\"{key}\")";
{
result = $"T.Get(\"{key}\")";
isReplaced = true; isReplaced = true;
}); });
} }
return result ?? $"\"{value}\""; return result ?? $"\"{value}\"";
}); });
if (isReplaced) if (isReplaced)
{ {
Console.WriteLine("-----------------------------"); Console.WriteLine("-----------------------------");
Console.WriteLine("FILE {0} done", relativeName); Console.WriteLine("FILE {0} done", relativeName);
File.WriteAllText(file.FullName, content); File.WriteAllText(file.FullName, content);
}
} }
} }
} }

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

Loading…
Cancel
Save