Browse Source

File scoped namespace.

pull/938/head
Sebastian 3 years ago
parent
commit
f7424dfd62
  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
- name: RUN TEST
uses: kohlerdominik/docker-run-action@v1.0.2
uses: kohlerdominik/docker-run-action@v1.1.0
with:
image: squidex/build
environment: |
@ -124,7 +124,7 @@ jobs:
run: dotnet test /src/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated
- name: RUN TEST on path
uses: kohlerdominik/docker-run-action@v1.0.2
uses: kohlerdominik/docker-run-action@v1.1.0
with:
image: squidex/build
environment: |
@ -138,7 +138,7 @@ jobs:
run: dotnet test /src/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated
- name: RUN TEST with dedicated collections
uses: kohlerdominik/docker-run-action@v1.0.2
uses: kohlerdominik/docker-run-action@v1.1.0
with:
image: squidex/build
environment: |

6
.github/workflows/release.yml

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

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

@ -12,35 +12,34 @@ using OpenTelemetry.Trace;
using Squidex.Infrastructure;
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)
{
this.config = config;
}
public Configurator(IConfiguration 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.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)
{
this.config = config;
}
public void Configure(TracerProviderBuilder builder)
{
// 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
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
builder.AddOtlpExporter(options =>
{
config.GetSection("logging:otlp").Bind(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.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 HttpContextWrapper httpContextWrapper;
private readonly IHttpContextAccessor httpContextAccessor;
public sealed class HttpContextWrapper : IContextWrapper
internal HttpContextWrapper(IHttpContextAccessor httpContextAccessor)
{
private readonly IHttpContextAccessor 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;
}
this.httpContextAccessor = httpContextAccessor;
}
public string GetUserAgent()
{
return httpContextAccessor.HttpContext?.Request?.Headers["User-Agent"].ToString() ?? string.Empty;
}
public string GetHttpMethod()
{
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)
{
logger.Log(exception, httpContextWrapper);
}
}
catch
if (exception != null && exception is not DomainException && exception is not OperationCanceledException)
{
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.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)
{
this.projectId = projectId;
}
public void Configure(TracerProviderBuilder builder)
{
builder.UseStackdriverExporter(projectId);
}
public Configurator(string projectId)
{
this.projectId = 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)
{
return;
}
public void ConfigureServices(IServiceCollection services, IConfiguration config)
{
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))
{
return;
}
if (string.IsNullOrWhiteSpace(projectId))
{
return;
}
services.AddSingleton<ITelemetryConfigurator>(
new Configurator(projectId));
services.AddSingleton<ITelemetryConfigurator>(
new Configurator(projectId));
services.AddSingleton<ILogAppender,
StackdriverSeverityLogAppender>();
services.AddSingleton<ILogAppender,
StackdriverSeverityLogAppender>();
services.AddSingleton<ILogAppender,
StackdriverExceptionHandler>();
services.AddSingleton<ILogAppender,
StackdriverExceptionHandler>();
var serviceName = config.GetValue<string>("logging:name") ?? "Squidex";
var serviceVersion = Assembly.GetEntryAssembly()?.GetName().Version?.ToString();
var serviceName = config.GetValue<string>("logging:name") ?? "Squidex";
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;
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.Debug:
return "DEBUG";
case SemanticLogLevel.Information:
return "INFO";
case SemanticLogLevel.Warning:
return "WARNING";
case SemanticLogLevel.Error:
return "ERROR";
case SemanticLogLevel.Fatal:
return "CRITICAL";
default:
return "DEFAULT";
}
case SemanticLogLevel.Trace:
return "DEBUG";
case SemanticLogLevel.Debug:
return "DEBUG";
case SemanticLogLevel.Information:
return "INFO";
case SemanticLogLevel.Warning:
return "WARNING";
case SemanticLogLevel.Error:
return "ERROR";
case SemanticLogLevel.Fatal:
return "CRITICAL";
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.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(
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
{
[LocalizedRequired]
[Display(Name = "Application Id", Description = "The application ID.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string AppId { get; set; }
[LocalizedRequired]
[Display(Name = "Application Id", Description = "The application ID.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string AppId { get; set; }
[LocalizedRequired]
[Display(Name = "Api Key", Description = "The API key to grant access to Squidex.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string ApiKey { get; set; }
[LocalizedRequired]
[Display(Name = "Api Key", Description = "The API key to grant access to Squidex.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string ApiKey { get; set; }
[LocalizedRequired]
[Display(Name = "Index Name", Description = "The name of the index.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string IndexName { get; set; }
[LocalizedRequired]
[Display(Name = "Index Name", Description = "The name of the index.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string IndexName { get; set; }
[Display(Name = "Document", Description = "The optional custom document.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Document { 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 entry.")]
[Editor(RuleFieldEditor.Text)]
public string Delete { get; set; }
}
[Display(Name = "Deletion", Description = "The condition when to delete the entry.")]
[Editor(RuleFieldEditor.Text)]
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 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 IJsonSerializer serializer;
private readonly ClientPool<(string AppId, string ApiKey, string IndexName), ISearchIndex> clients;
private readonly IScriptEngine scriptEngine;
private readonly IJsonSerializer serializer;
public AlgoliaActionHandler(RuleEventFormatter formatter, IScriptEngine scriptEngine, IJsonSerializer serializer)
: base(formatter)
public AlgoliaActionHandler(RuleEventFormatter formatter, IScriptEngine scriptEngine, IJsonSerializer serializer)
: 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.serializer = serializer;
}
this.scriptEngine = scriptEngine;
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 contentId = entityEvent.Id.ToString();
var content = (AlgoliaContent)null;
var ruleDescription = string.Empty;
var contentId = entityEvent.Id.ToString();
var content = (AlgoliaContent)null;
if (delete)
{
ruleDescription = $"Delete entry from Algolia index: {action.IndexName}";
}
else
if (delete)
{
ruleDescription = $"Delete entry from Algolia index: {action.IndexName}";
}
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;
if (!string.IsNullOrEmpty(action.Document))
{
jsonString = await FormatAsync(action.Document, @event);
jsonString = jsonString?.Trim();
}
else
{
jsonString = ToJson(@event);
}
content = serializer.Deserialize<AlgoliaContent>(jsonString);
jsonString = await FormatAsync(action.Document, @event);
jsonString = jsonString?.Trim();
}
catch (Exception ex)
else
{
content = new AlgoliaContent
{
More = new Dictionary<string, object>
{
["error"] = $"Invalid JSON: {ex.Message}"
}
};
jsonString = ToJson(@event);
}
content.ObjectID = contentId;
content = serializer.Deserialize<AlgoliaContent>(jsonString);
}
var ruleJob = new AlgoliaJob
catch (Exception ex)
{
AppId = action.AppId,
ApiKey = action.ApiKey,
Content = serializer.Serialize(content, true),
ContentId = contentId,
IndexName = await FormatAsync(action.IndexName, @event)
};
content = new AlgoliaContent
{
More = new Dictionary<string, object>
{
["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,
CancellationToken ct = default)
return ("Ignore", new AlgoliaJob());
}
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));
}
else
{
var response = await index.DeleteObjectAsync(job.ContentId, null, ct);
return Result.Success(serializer.Serialize(response, true));
}
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
{
[JsonPropertyName("objectID")]
public string ObjectID { get; set; }
public sealed class AlgoliaContent
{
[JsonPropertyName("objectID")]
public string ObjectID { get; set; }
[JsonExtensionData]
public Dictionary<string, object> More { get; set; } = new Dictionary<string, object>();
}
[JsonExtensionData]
public Dictionary<string, object> More { get; set; } = new Dictionary<string, object>();
}
public sealed class AlgoliaJob
{
public string AppId { get; set; }
public sealed class AlgoliaJob
{
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 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.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(
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
{
[LocalizedRequired]
[Display(Name = "Connection", Description = "The connection string to the storage account.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string ConnectionString { get; set; }
[LocalizedRequired]
[Display(Name = "Connection", Description = "The connection string to the storage account.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string ConnectionString { get; set; }
[LocalizedRequired]
[Display(Name = "Queue", Description = "The name of the queue.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string Queue { get; set; }
[LocalizedRequired]
[Display(Name = "Queue", Description = "The name of the queue.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string Queue { get; set; }
[Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Payload { get; set; }
[Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
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
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)
: base(formatter)
public AzureQueueActionHandler(RuleEventFormatter 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 queueRef = queueClient.GetQueueReference(key.QueueName);
var queueClient = storageAccount.CreateCloudQueueClient();
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);
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);
requestBody = await FormatAsync(action.Payload, @event);
}
protected override async Task<Result> ExecuteJobAsync(AzureQueueJob job,
CancellationToken ct = default)
else
{
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 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
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 readonly Func<TKey, Task<TClient>> factory;
private static readonly TimeSpan TimeToLive = TimeSpan.FromMinutes(30);
private readonly MemoryCache memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private readonly Func<TKey, Task<TClient>> factory;
public ClientPool(Func<TKey, TClient> factory)
{
this.factory = x => Task.FromResult(factory(x));
}
public ClientPool(Func<TKey, TClient> factory)
{
this.factory = x => Task.FromResult(factory(x));
}
public ClientPool(Func<TKey, Task<TClient>> factory)
{
this.factory = factory;
}
public ClientPool(Func<TKey, Task<TClient>> 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);
memoryCache.Set(key, client, TimeToLive);
}
client = await factory(key);
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.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(
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
{
[LocalizedRequired]
[Display(Name = "Text", Description = "The comment text.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Text { get; set; }
[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.")]
[Editor(RuleFieldEditor.Text)]
public string Client { get; set; }
}
[Display(Name = "Client", Description = "An optional client name.")]
[Editor(RuleFieldEditor.Text)]
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.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)
: base(formatter)
{
this.commandBus = commandBus;
}
public CommentActionHandler(RuleEventFormatter formatter, ICommandBus commandBus)
: base(formatter)
{
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
};
ruleJob.Text = await FormatAsync(action.Text, @event);
if (!string.IsNullOrEmpty(action.Client))
{
ruleJob.Actor = RefToken.Client(action.Client);
}
else
{
ruleJob.Actor = contentEvent.Actor;
}
AppId = contentEvent.AppId
};
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,
CancellationToken ct = default)
{
var command = job;
return ("Ignore", new CreateComment());
}
if (command.CommentsId == default)
{
return Result.Ignored();
}
protected override async Task<Result> ExecuteJobAsync(CreateComment job,
CancellationToken ct = default)
{
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 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.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(
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
{
[LocalizedRequired]
[Display(Name = "Data", Description = "The content data.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Data { get; set; }
[LocalizedRequired]
[Display(Name = "Data", Description = "The content data.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Data { get; set; }
[LocalizedRequired]
[Display(Name = "Schema", Description = "The name of the schema.")]
[Editor(RuleFieldEditor.Text)]
public string Schema { get; set; }
[LocalizedRequired]
[Display(Name = "Schema", Description = "The name of the schema.")]
[Editor(RuleFieldEditor.Text)]
public string Schema { get; set; }
[Display(Name = "Client", Description = "An optional client name.")]
[Editor(RuleFieldEditor.Text)]
public string Client { get; set; }
[Display(Name = "Client", Description = "An optional client name.")]
[Editor(RuleFieldEditor.Text)]
public string Client { get; set; }
[Display(Name = "Publish", Description = "Publish the content.")]
[Editor(RuleFieldEditor.Text)]
public bool Publish { get; set; }
}
[Display(Name = "Publish", Description = "Publish the content.")]
[Editor(RuleFieldEditor.Text)]
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 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 readonly IAppProvider appProvider;
private readonly IJsonSerializer jsonSerializer;
private const string Description = "Create a content";
private readonly ICommandBus commandBus;
private readonly IAppProvider appProvider;
private readonly IJsonSerializer jsonSerializer;
public CreateContentActionHandler(RuleEventFormatter formatter, IAppProvider appProvider, ICommandBus commandBus, IJsonSerializer jsonSerializer)
: base(formatter)
{
this.appProvider = appProvider;
this.commandBus = commandBus;
this.jsonSerializer = jsonSerializer;
}
public CreateContentActionHandler(RuleEventFormatter formatter, IAppProvider appProvider, ICommandBus commandBus, IJsonSerializer jsonSerializer)
: base(formatter)
{
this.appProvider = appProvider;
this.commandBus = commandBus;
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
};
var schema = await appProvider.GetSchemaAsync(@event.AppId.Id, action.Schema, true);
AppId = @event.AppId
};
if (schema == null)
{
throw new InvalidOperationException($"Cannot find schema '{action.Schema}'");
}
var schema = await appProvider.GetSchemaAsync(@event.AppId.Id, action.Schema, true);
ruleJob.SchemaId = schema.NamedId();
var json = await FormatAsync(action.Data, @event);
if (schema == null)
{
throw new InvalidOperationException($"Cannot find schema '{action.Schema}'");
}
ruleJob.Data = jsonSerializer.Deserialize<ContentData>(json);
ruleJob.SchemaId = schema.NamedId();
if (!string.IsNullOrEmpty(action.Client))
{
ruleJob.Actor = RefToken.Client(action.Client);
}
else if (@event is EnrichedUserEventBase userEvent)
{
ruleJob.Actor = userEvent.Actor;
}
var json = await FormatAsync(action.Data, @event);
if (action.Publish)
{
ruleJob.Status = Status.Published;
}
ruleJob.Data = jsonSerializer.Deserialize<ContentData>(json);
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,
CancellationToken ct = default)
if (action.Publish)
{
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 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.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(
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
{
[AbsoluteUrl]
[LocalizedRequired]
[Display(Name = "Server Url", Description = "The url to the discourse server.")]
[Editor(RuleFieldEditor.Url)]
public Uri Url { get; set; }
[LocalizedRequired]
[Display(Name = "Api Key", Description = "The api key to authenticate to your discourse server.")]
[Editor(RuleFieldEditor.Text)]
public string ApiKey { get; set; }
[LocalizedRequired]
[Display(Name = "Api User", Description = "The api username to authenticate to your discourse server.")]
[Editor(RuleFieldEditor.Text)]
public string ApiUsername { get; set; }
[LocalizedRequired]
[Display(Name = "Text", Description = "The text as markdown.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Text { get; set; }
[Display(Name = "Title", Description = "The optional title when creating new topics.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
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; }
}
[AbsoluteUrl]
[LocalizedRequired]
[Display(Name = "Server Url", Description = "The url to the discourse server.")]
[Editor(RuleFieldEditor.Url)]
public Uri Url { get; set; }
[LocalizedRequired]
[Display(Name = "Api Key", Description = "The api key to authenticate to your discourse server.")]
[Editor(RuleFieldEditor.Text)]
public string ApiKey { get; set; }
[LocalizedRequired]
[Display(Name = "Api User", Description = "The api username to authenticate to your discourse server.")]
[Editor(RuleFieldEditor.Text)]
public string ApiUsername { get; set; }
[LocalizedRequired]
[Display(Name = "Text", Description = "The text as markdown.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Text { get; set; }
[Display(Name = "Title", Description = "The optional title when creating new topics.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
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
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";
private const string DescriptionCreateTopic = "Create discourse Topic";
this.httpClientFactory = httpClientFactory;
}
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)
: base(formatter)
var json = new Dictionary<string, object>
{
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}";
var json = new Dictionary<string, object>
{
["title"] = await FormatAsync(action.Title, @event)
};
if (action.Topic != null)
{
json.Add("topic_id", action.Topic.Value);
}
json.Add("topic_id", action.Topic.Value);
}
if (action.Category != null)
{
json.Add("category", action.Category.Value);
}
if (action.Category != null)
{
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
{
ApiKey = action.ApiKey,
ApiUserName = action.ApiUsername,
RequestUrl = url,
RequestBody = requestBody
};
var description =
action.Topic != null ?
DescriptionCreateTopic :
DescriptionCreatePost;
return (description, ruleJob);
}
var ruleJob = new DiscourseJob
{
ApiKey = action.ApiKey,
ApiUserName = action.ApiUsername,
RequestUrl = url,
RequestBody = requestBody
};
var description =
action.Topic != null ?
DescriptionCreateTopic :
DescriptionCreatePost;
return (description, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(DiscourseJob job,
CancellationToken ct = default)
protected override async Task<Result> ExecuteJobAsync(DiscourseJob job,
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")
})
{
request.Headers.TryAddWithoutValidation("Api-Key", job.ApiKey);
request.Headers.TryAddWithoutValidation("Api-Username", job.ApiUserName);
return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct);
}
Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json")
})
{
request.Headers.TryAddWithoutValidation("Api-Key", job.ApiKey);
request.Headers.TryAddWithoutValidation("Api-Username", job.ApiUserName);
return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct);
}
}
}
}
public sealed class DiscourseJob
{
public string ApiKey { get; set; }
public sealed class DiscourseJob
{
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 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.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(
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
{
[AbsoluteUrl]
[LocalizedRequired]
[Display(Name = "Server Url", Description = "The url to the instance or cluster.")]
[Editor(RuleFieldEditor.Url)]
public Uri Host { get; set; }
[LocalizedRequired]
[Display(Name = "Index Name", Description = "The name of the index.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string IndexName { get; set; }
[Display(Name = "Username", Description = "The optional username.")]
[Editor(RuleFieldEditor.Text)]
public string Username { get; set; }
[Display(Name = "Password", Description = "The optional password.")]
[Editor(RuleFieldEditor.Text)]
public string Password { 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; }
}
[AbsoluteUrl]
[LocalizedRequired]
[Display(Name = "Server Url", Description = "The url to the instance or cluster.")]
[Editor(RuleFieldEditor.Url)]
public Uri Host { get; set; }
[LocalizedRequired]
[Display(Name = "Index Name", Description = "The name of the index.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string IndexName { get; set; }
[Display(Name = "Username", Description = "The optional username.")]
[Editor(RuleFieldEditor.Text)]
public string Username { get; set; }
[Display(Name = "Password", Description = "The optional password.")]
[Editor(RuleFieldEditor.Text)]
public string Password { 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 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 IJsonSerializer serializer;
private readonly ClientPool<(Uri Host, string Username, string Password), ElasticLowLevelClient> clients;
private readonly IScriptEngine scriptEngine;
private readonly IJsonSerializer serializer;
public ElasticSearchActionHandler(RuleEventFormatter formatter, IScriptEngine scriptEngine, IJsonSerializer serializer)
: base(formatter)
public ElasticSearchActionHandler(RuleEventFormatter formatter, IScriptEngine scriptEngine, IJsonSerializer serializer)
: 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))
{
config = config.BasicAuthentication(key.Username, key.Password);
}
return new ElasticLowLevelClient(config);
});
return new ElasticLowLevelClient(config);
});
this.scriptEngine = scriptEngine;
this.serializer = serializer;
}
this.scriptEngine = scriptEngine;
this.serializer = serializer;
}
protected override async Task<(string Description, ElasticSearchJob Data)> CreateJobAsync(EnrichedEvent @event, ElasticSearchAction action)
{
var delete = @event.ShouldDelete(scriptEngine, action.Delete);
protected override async Task<(string Description, ElasticSearchJob Data)> CreateJobAsync(EnrichedEvent @event, ElasticSearchAction action)
{
var delete = @event.ShouldDelete(scriptEngine, action.Delete);
string contentId;
string contentId;
if (@event is IEnrichedEntityEvent enrichedEntityEvent)
{
contentId = enrichedEntityEvent.Id.ToString();
}
else
{
contentId = DomainId.NewGuid().ToString();
}
if (@event is IEnrichedEntityEvent enrichedEntityEvent)
{
contentId = enrichedEntityEvent.Id.ToString();
}
else
{
contentId = DomainId.NewGuid().ToString();
}
var ruleDescription = string.Empty;
var ruleJob = new ElasticSearchJob
{
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}";
var ruleDescription = string.Empty;
var ruleJob = new ElasticSearchJob
{
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
ElasticSearchContent content;
try
{
ruleDescription = $"Upsert to index: {action.IndexName}";
string jsonString;
ElasticSearchContent content;
try
if (!string.IsNullOrEmpty(action.Document))
{
string jsonString;
if (!string.IsNullOrEmpty(action.Document))
{
jsonString = await FormatAsync(action.Document, @event);
jsonString = jsonString?.Trim();
}
else
{
jsonString = ToJson(@event);
}
content = serializer.Deserialize<ElasticSearchContent>(jsonString);
jsonString = await FormatAsync(action.Document, @event);
jsonString = jsonString?.Trim();
}
catch (Exception ex)
else
{
content = new ElasticSearchContent
{
More = new Dictionary<string, object>
{
["error"] = $"Invalid JSON: {ex.Message}"
}
};
jsonString = ToJson(@event);
}
content.ContentId = contentId;
ruleJob.Content = serializer.Serialize(content, true);
content = serializer.Deserialize<ElasticSearchContent>(jsonString);
}
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,
CancellationToken ct = default)
return (ruleDescription, ruleJob);
}
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);
}
else
{
var response = await client.DeleteAsync<StringResponse>(job.IndexName, job.ContentId, ctx: ct);
return Result.SuccessOrFailed(response.OriginalException, response.Body);
}
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 string ContentId { get; set; }
public sealed class ElasticSearchContent
{
public string ContentId { get; set; }
[JsonExtensionData]
public Dictionary<string, object> More { get; set; } = new Dictionary<string, object>();
}
[JsonExtensionData]
public Dictionary<string, object> More { get; set; } = new Dictionary<string, object>();
}
public sealed class ElasticSearchJob
{
public string ServerHost { get; set; }
public sealed class ElasticSearchJob
{
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 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.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(
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
{
[LocalizedRequired]
[Display(Name = "Server Host", Description = "The IP address or host to the SMTP server.")]
[Editor(RuleFieldEditor.Text)]
public string ServerHost { get; set; }
[LocalizedRequired]
[Display(Name = "Server Host", Description = "The IP address or host to the SMTP server.")]
[Editor(RuleFieldEditor.Text)]
public string ServerHost { get; set; }
[LocalizedRequired]
[Display(Name = "Server Port", Description = "The port to the SMTP server.")]
[Editor(RuleFieldEditor.Text)]
public int ServerPort { get; set; }
[LocalizedRequired]
[Display(Name = "Server Port", Description = "The port to the SMTP server.")]
[Editor(RuleFieldEditor.Text)]
public int ServerPort { get; set; }
[LocalizedRequired]
[Display(Name = "Username", Description = "The username for the SMTP server.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string ServerUsername { get; set; }
[LocalizedRequired]
[Display(Name = "Username", Description = "The username for the SMTP server.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string ServerUsername { get; set; }
[LocalizedRequired]
[Display(Name = "Password", Description = "The password for the SMTP server.")]
[Editor(RuleFieldEditor.Password)]
public string ServerPassword { get; set; }
[LocalizedRequired]
[Display(Name = "Password", Description = "The password for the SMTP server.")]
[Editor(RuleFieldEditor.Password)]
public string ServerPassword { get; set; }
[LocalizedRequired]
[Display(Name = "From Address", Description = "The email sending address.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string MessageFrom { get; set; }
[LocalizedRequired]
[Display(Name = "From Address", Description = "The email sending address.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string MessageFrom { get; set; }
[LocalizedRequired]
[Display(Name = "To Address", Description = "The email message will be sent to.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string MessageTo { get; set; }
[LocalizedRequired]
[Display(Name = "To Address", Description = "The email message will be sent to.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string MessageTo { get; set; }
[LocalizedRequired]
[Display(Name = "Subject", Description = "The subject line for this email message.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string MessageSubject { get; set; }
[LocalizedRequired]
[Display(Name = "Subject", Description = "The subject line for this email message.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string MessageSubject { get; set; }
[LocalizedRequired]
[Display(Name = "Body", Description = "The message body.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string MessageBody { get; set; }
}
[LocalizedRequired]
[Display(Name = "Body", Description = "The message body.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
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
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,
ServerPort = action.ServerPort,
ServerUsername = await FormatAsync(action.ServerUsername, @event),
MessageFrom = await FormatAsync(action.MessageFrom, @event),
MessageTo = await FormatAsync(action.MessageTo, @event),
MessageSubject = await FormatAsync(action.MessageSubject, @event),
MessageBody = await FormatAsync(action.MessageBody, @event)
};
var description = $"Send an email to {action.MessageTo}";
return (description, ruleJob);
}
ServerHost = action.ServerHost,
ServerPassword = action.ServerPassword,
ServerPort = action.ServerPort,
ServerUsername = await FormatAsync(action.ServerUsername, @event),
MessageFrom = await FormatAsync(action.MessageFrom, @event),
MessageTo = await FormatAsync(action.MessageTo, @event),
MessageSubject = await FormatAsync(action.MessageSubject, @event),
MessageBody = await FormatAsync(action.MessageBody, @event)
};
var description = $"Send an email to {action.MessageTo}";
return (description, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(EmailJob job,
CancellationToken ct = default)
protected override async Task<Result> ExecuteJobAsync(EmailJob job,
CancellationToken ct = default)
{
using (var smtpClient = new SmtpClient())
{
using (var smtpClient = new SmtpClient())
{
await smtpClient.ConnectAsync(job.ServerHost, job.ServerPort, cancellationToken: ct);
await smtpClient.AuthenticateAsync(job.ServerUsername, job.ServerPassword, ct);
await smtpClient.ConnectAsync(job.ServerHost, job.ServerPort, cancellationToken: ct);
var smtpMessage = new MimeMessage();
await smtpClient.AuthenticateAsync(job.ServerUsername, job.ServerPassword, ct);
smtpMessage.From.Add(MailboxAddress.Parse(
job.MessageFrom));
var smtpMessage = new MimeMessage();
smtpMessage.To.Add(MailboxAddress.Parse(
job.MessageTo));
smtpMessage.From.Add(MailboxAddress.Parse(
job.MessageFrom));
smtpMessage.Body = new TextPart(TextFormat.Html)
{
Text = job.MessageBody
};
smtpMessage.To.Add(MailboxAddress.Parse(
job.MessageTo));
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 int ServerPort { get; set; }
public sealed class EmailJob
{
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 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.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(
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
{
[LocalizedRequired]
[Display(Name = "Api Key", Description = "The API key to grant access to Squidex.")]
[Editor(RuleFieldEditor.Text)]
public string ApiKey { get; set; }
[LocalizedRequired]
[Display(Name = "Api Key", Description = "The API key to grant access to Squidex.")]
[Editor(RuleFieldEditor.Text)]
public string ApiKey { get; set; }
[LocalizedRequired]
[Display(Name = "Service Id", Description = "The ID of the fastly service.")]
[Editor(RuleFieldEditor.Text)]
public string ServiceId { get; set; }
}
[LocalizedRequired]
[Display(Name = "Service Id", Description = "The ID of the fastly service.")]
[Editor(RuleFieldEditor.Text)]
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
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)
: base(formatter)
if (@event is IEnrichedEntityEvent entityEvent)
{
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;
if (@event is IEnrichedEntityEvent entityEvent)
{
id = DomainId.Combine(@event.AppId.Id, entityEvent.Id).ToString();
}
var ruleJob = new FastlyJob
{
Key = id,
FastlyApiKey = action.ApiKey,
FastlyServiceID = action.ServiceId
};
Key = id,
FastlyApiKey = action.ApiKey,
FastlyServiceID = action.ServiceId
};
return (Description, ruleJob);
}
return (Description, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(FastlyJob job,
CancellationToken ct = default)
protected override async Task<Result> ExecuteJobAsync(FastlyJob job,
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))
{
request.Headers.Add("Fastly-Key", job.FastlyApiKey);
using (var request = new HttpRequestMessage(HttpMethod.Post, requestUrl))
{
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 string FastlyApiKey { get; set; }
public sealed class FastlyJob
{
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 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.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(
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
{
[LocalizedRequired]
[Display(Name = "Topic Name", Description = "The name of the topic.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string TopicName { get; set; }
[Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Payload { get; set; }
[Display(Name = "Key", Description = "The message key, commonly used for partitioning.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string Key { get; set; }
[Display(Name = "Partition Key", Description = "The partition key, only used when we don't want to define partiontionig with key.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string PartitionKey { get; set; }
[Display(Name = "Partition Count", Description = "Define the number of partitions for specific topic.")]
[Editor(RuleFieldEditor.Text)]
public int PartitionCount { 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; }
}
[LocalizedRequired]
[Display(Name = "Topic Name", Description = "The name of the topic.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string TopicName { get; set; }
[Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Payload { get; set; }
[Display(Name = "Key", Description = "The message key, commonly used for partitioning.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string Key { get; set; }
[Display(Name = "Partition Key", Description = "The partition key, only used when we don't want to define partiontionig with key.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string PartitionKey { get; set; }
[Display(Name = "Partition Count", Description = "Define the number of partitions for specific topic.")]
[Editor(RuleFieldEditor.Text)]
public int PartitionCount { 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
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";
private readonly KafkaProducer kafkaProducer;
string value, key;
public KafkaActionHandler(RuleEventFormatter formatter, KafkaProducer kafkaProducer)
: base(formatter)
if (!string.IsNullOrEmpty(action.Payload))
{
this.kafkaProducer = kafkaProducer;
value = await FormatAsync(action.Payload, @event);
}
protected override async Task<(string Description, KafkaJob Data)> CreateJobAsync(EnrichedEvent @event, KafkaAction action)
else
{
string value, key;
value = ToEnvelopeJson(@event);
}
if (!string.IsNullOrEmpty(action.Payload))
{
value = await FormatAsync(action.Payload, @event);
}
else
{
value = ToEnvelopeJson(@event);
}
if (!string.IsNullOrEmpty(action.Key))
{
key = await FormatAsync(action.Key, @event);
}
else
{
key = @event.Name;
}
if (!string.IsNullOrEmpty(action.Key))
{
key = await FormatAsync(action.Key, @event);
}
else
{
key = @event.Name;
}
var ruleJob = new KafkaJob
{
TopicName = action.TopicName,
MessageKey = key,
MessageValue = value,
Headers = await ParseHeadersAsync(action.Headers, @event),
Schema = action.Schema,
PartitionKey = await FormatAsync(action.PartitionKey, @event),
PartitionCount = action.PartitionCount
};
return (Description, ruleJob);
}
var ruleJob = new KafkaJob
{
TopicName = action.TopicName,
MessageKey = key,
MessageValue = value,
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)
{
if (string.IsNullOrWhiteSpace(headers))
{
return null;
}
private async Task<Dictionary<string, string>> ParseHeadersAsync(string headers, EnrichedEvent @event)
{
if (string.IsNullOrWhiteSpace(headers))
{
return null;
}
var headersDictionary = new Dictionary<string, string>();
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)
{
var key = line[..indexEqual];
var val = line[(indexEqual + 1)..];
val = await FormatAsync(val, @event);
val = await FormatAsync(val, @event);
headersDictionary[key] = val;
}
headersDictionary[key] = val;
}
return headersDictionary;
}
protected override async Task<Result> ExecuteJobAsync(KafkaJob job,
CancellationToken ct = default)
return headersDictionary;
}
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.");
}
catch (Exception ex)
{
return Result.Failed(ex, $"Push to Kafka failed: {ex}");
}
return Result.Success($"Event pushed to {job.TopicName} kafka topic with {job.MessageKey} message key.");
}
catch (Exception ex)
{
return Result.Failed(ex, $"Push to Kafka failed: {ex}");
}
}
}
public sealed class KafkaJob
{
public string TopicName { get; set; }
public sealed class KafkaJob
{
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 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())
{
services.AddRuleAction<KafkaAction, KafkaActionHandler>();
if (options.IsProducerConfigured())
{
services.AddRuleAction<KafkaAction, KafkaActionHandler>();
services.AddSingleton<KafkaProducer>();
services.AddSingleton(Options.Create(options));
}
services.AddSingleton<KafkaProducer>();
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 Schema = Avro.Schema;
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;
namespace Squidex.Extensions.Actions.Kafka;
public KafkaProducer(IOptions<KafkaProducerOptions> options, IJsonSerializer jsonSerializer,
ILogger<KafkaProducer> log)
{
this.jsonSerializer = jsonSerializer;
public sealed class KafkaProducer
{
private readonly IProducer<string, string> textProducer;
private readonly IProducer<string, GenericRecord> avroProducer;
private readonly ISchemaRegistryClient schemaRegistry;
private readonly IJsonSerializer jsonSerializer;
textProducer = new ProducerBuilder<string, string>(options.Value)
.SetErrorHandler((p, error) =>
{
LogError(log, error);
})
.SetLogHandler((p, message) =>
{
LogMessage(log, message);
})
.SetKeySerializer(Serializers.Utf8)
.SetValueSerializer(Serializers.Utf8)
.Build();
public KafkaProducer(IOptions<KafkaProducerOptions> options, IJsonSerializer jsonSerializer,
ILogger<KafkaProducer> log)
{
this.jsonSerializer = jsonSerializer;
if (options.Value.IsSchemaRegistryConfigured())
textProducer = new ProducerBuilder<string, string>(options.Value)
.SetErrorHandler((p, error) =>
{
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;
switch (message.Level)
LogError(log, error);
})
.SetLogHandler((p, message) =>
{
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;
}
LogMessage(log, message);
})
.SetKeySerializer(Serializers.Utf8)
.SetValueSerializer(Serializers.Utf8)
.Build();
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,
CancellationToken ct)
log.Log(level, "Kafka log {name}: {message}.", message.Name, message.Message);
}
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);
}
else
{
var message = new Message<string, string> { Value = job.MessageValue };
await ProduceAsync(avroProducer, message, job, ct);
}
else
{
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,
CancellationToken ct)
if (job.Headers?.Count > 0)
{
message.Key = job.MessageKey;
message.Headers = new Headers();
if (job.Headers?.Count > 0)
foreach (var header in job.Headers)
{
message.Headers = new Headers();
foreach (var header in job.Headers)
{
message.Headers.Add(header.Key, Encoding.UTF8.GetBytes(header.Value));
}
message.Headers.Add(header.Key, Encoding.UTF8.GetBytes(header.Value));
}
}
if (!string.IsNullOrWhiteSpace(job.PartitionKey) && job.PartitionCount > 0)
{
var partition = Math.Abs(job.PartitionKey.GetHashCode(StringComparison.Ordinal)) % job.PartitionCount;
if (!string.IsNullOrWhiteSpace(job.PartitionKey) && job.PartitionCount > 0)
{
var partition = Math.Abs(job.PartitionKey.GetHashCode(StringComparison.Ordinal)) % job.PartitionCount;
await producer.ProduceAsync(new TopicPartition(job.TopicName, partition), message, ct);
}
else
{
await producer.ProduceAsync(job.TopicName, message, ct);
}
await producer.ProduceAsync(new TopicPartition(job.TopicName, partition), message, ct);
}
else
{
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;
}
catch (JsonException ex)
{
throw new InvalidOperationException($"Failed to parse json: {json}, got {ex.Message}", ex);
}
return result;
}
public void Dispose()
catch (JsonException ex)
{
textProducer?.Dispose();
avroProducer?.Dispose();
throw new InvalidOperationException($"Failed to parse json: {json}, got {ex.Message}", ex);
}
}
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 double d when IsTypeOrUnionWith(schema, Schema.Type.Long):
return (long)d;
case double d when IsTypeOrUnionWith(schema, Schema.Type.Float):
return (float)d;
case double d when IsTypeOrUnionWith(schema, Schema.Type.Int):
return (int)d;
case double d when IsTypeOrUnionWith(schema, Schema.Type.Double):
return d;
case string s when IsTypeOrUnionWith(schema, Schema.Type.String):
return s;
case JsonObject o when IsTypeOrUnionWith(schema, Schema.Type.Map):
case bool b when IsTypeOrUnionWith(schema, Schema.Type.Boolean):
return b;
case double d when IsTypeOrUnionWith(schema, Schema.Type.Long):
return (long)d;
case double d when IsTypeOrUnionWith(schema, Schema.Type.Float):
return (float)d;
case double d when IsTypeOrUnionWith(schema, Schema.Type.Int):
return (int)d;
case double d when IsTypeOrUnionWith(schema, Schema.Type.Double):
return d;
case string s when IsTypeOrUnionWith(schema, Schema.Type.String):
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);
foreach (var (key, childValue) in o)
{
mapResult.Add(key, GetValue(childValue, map?.ValueSchema));
}
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):
{
GenericRecord result = null;
return mapResult;
}
if (schema is UnionSchema union)
{
var record = (RecordSchema)union.Schemas.FirstOrDefault(x => x.Tag == Schema.Type.Record);
case JsonObject o when IsTypeOrUnionWith(schema, 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)
{
result = new GenericRecord(record);
}
else if (schema is RecordSchema 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);
foreach (var item in a)
{
result.Add(GetValue(item, arraySchema?.ItemSchema));
}
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 schema.Tag == expected || (schema is UnionSchema union && union.Schemas.Any(x => x.Tag == expected));
}
return null;
}
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.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()
{
return !string.IsNullOrWhiteSpace(BootstrapServers);
}
public bool IsProducerConfigured()
{
return !string.IsNullOrWhiteSpace(BootstrapServers);
}
public bool IsSchemaRegistryConfigured()
{
return !string.IsNullOrWhiteSpace(SchemaRegistry?.Url);
}
public bool IsSchemaRegistryConfigured()
{
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.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(
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
{
[LocalizedRequired]
[Display(Name = "Access Token", Description = "The self issued access token.")]
[Editor(RuleFieldEditor.Text)]
public string AccessToken { get; set; }
[LocalizedRequired]
[Display(Name = "Title", Description = "The title, used for the url.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string Title { get; set; }
[LocalizedRequired]
[Display(Name = "Content", Description = "The content, either html or markdown.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Content { get; set; }
[Display(Name = "Canonical Url", Description = "The original home of this content, if it was originally published elsewhere.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string CanonicalUrl { get; set; }
[Display(Name = "Tags", Description = "The optional comma separated list of tags.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
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; }
}
[LocalizedRequired]
[Display(Name = "Access Token", Description = "The self issued access token.")]
[Editor(RuleFieldEditor.Text)]
public string AccessToken { get; set; }
[LocalizedRequired]
[Display(Name = "Title", Description = "The title, used for the url.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string Title { get; set; }
[LocalizedRequired]
[Display(Name = "Content", Description = "The content, either html or markdown.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Content { get; set; }
[Display(Name = "Canonical Url", Description = "The original home of this content, if it was originally published elsewhere.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string CanonicalUrl { get; set; }
[Display(Name = "Tags", Description = "The optional comma separated list of tags.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
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
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 readonly IJsonSerializer serializer;
private sealed class UserResponseData
{
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)
: base(formatter)
try
{
this.httpClientFactory = httpClientFactory;
var jsonTags = await FormatAsync(action.Tags, @event);
this.serializer = serializer;
return serializer.Deserialize<string[]>(jsonTags);
}
protected override async Task<(string Description, MediumJob Data)> CreateJobAsync(EnrichedEvent @event, MediumAction action)
catch
{
var ruleJob = new MediumJob { AccessToken = action.AccessToken, PublicationId = action.PublicationId };
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);
return action.Tags.Split(',');
}
}
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))
{
return null;
}
httpClient.Timeout = TimeSpan.FromSeconds(4);
httpClient.DefaultRequestHeaders.Add("Accept", "application/json");
httpClient.DefaultRequestHeaders.Add("Accept-Charset", "utf-8");
httpClient.DefaultRequestHeaders.Add("User-Agent", "Squidex Headless CMS");
try
{
var jsonTags = await FormatAsync(action.Tags, @event);
string path;
return serializer.Deserialize<string[]>(jsonTags);
}
catch
if (!string.IsNullOrWhiteSpace(job.PublicationId))
{
return action.Tags.Split(',');
path = $"v1/publications/{job.PublicationId}/posts";
}
}
protected override async Task<Result> ExecuteJobAsync(MediumJob job,
CancellationToken ct = default)
{
using (var httpClient = httpClientFactory.CreateClient())
else
{
httpClient.Timeout = TimeSpan.FromSeconds(4);
httpClient.DefaultRequestHeaders.Add("Accept", "application/json");
httpClient.DefaultRequestHeaders.Add("Accept-Charset", "utf-8");
httpClient.DefaultRequestHeaders.Add("User-Agent", "Squidex Headless CMS");
string path;
HttpResponseMessage response = null;
if (!string.IsNullOrWhiteSpace(job.PublicationId))
var meRequest = BuildMeRequest(job);
try
{
path = $"v1/publications/{job.PublicationId}/posts";
}
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);
response = await httpClient.SendAsync(meRequest, ct);
var id = responseJson.Data?.Id;
var responseString = await response.Content.ReadAsStringAsync(ct);
var responseJson = serializer.Deserialize<UserResponse>(responseString);
path = $"v1/users/{id}/posts";
}
catch (Exception ex)
{
var requestDump = DumpFormatter.BuildDump(meRequest, response, ex.ToString());
var id = responseJson.Data?.Id;
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)
{
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;
return await httpClient.OneWayRequestAsync(BuildPostRequest(job, path), job.RequestBody, ct);
}
}
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 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.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(
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
{
[LocalizedRequired]
[Display(Name = "User", Description = "The user id or email.")]
[Editor(RuleFieldEditor.Text)]
public string User { get; set; }
[LocalizedRequired]
[Display(Name = "User", Description = "The user id or email.")]
[Editor(RuleFieldEditor.Text)]
public string User { get; set; }
[LocalizedRequired]
[Display(Name = "Title", Description = "The text to send.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Text { get; set; }
[LocalizedRequired]
[Display(Name = "Title", Description = "The text to send.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Text { get; set; }
[Display(Name = "Url", Description = "The optional url to attach to the notification.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string Url { get; set; }
[Display(Name = "Url", Description = "The optional url to attach to the notification.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string Url { get; set; }
[Display(Name = "Client", Description = "An optional client name.")]
[Editor(RuleFieldEditor.Text)]
public string Client { get; set; }
}
[Display(Name = "Client", Description = "An optional client name.")]
[Editor(RuleFieldEditor.Text)]
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.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 readonly IUserResolver userResolver;
private const string Description = "Send a Notification";
private readonly ICommandBus commandBus;
private readonly IUserResolver userResolver;
public NotificationActionHandler(RuleEventFormatter formatter, ICommandBus commandBus, IUserResolver userResolver)
: base(formatter)
{
this.commandBus = commandBus;
public NotificationActionHandler(RuleEventFormatter formatter, ICommandBus commandBus, IUserResolver userResolver)
: base(formatter)
{
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)
{
throw new InvalidOperationException($"Cannot find user by '{action.User}'");
}
var actor = userEvent.Actor;
var actor = userEvent.Actor;
if (!string.IsNullOrEmpty(action.Client))
{
actor = RefToken.Client(action.Client);
}
if (!string.IsNullOrEmpty(action.Client))
{
actor = RefToken.Client(action.Client);
}
var ruleJob = new CreateComment
{
AppId = CommentsCommand.NoApp,
Actor = actor,
CommentId = DomainId.NewGuid(),
CommentsId = DomainId.Create(user.Id),
FromRule = true,
Text = await FormatAsync(action.Text, @event)
};
if (!string.IsNullOrWhiteSpace(action.Url))
{
var url = await FormatAsync(action.Url, @event);
var ruleJob = new CreateComment
if (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri))
{
AppId = CommentsCommand.NoApp,
Actor = actor,
CommentId = DomainId.NewGuid(),
CommentsId = DomainId.Create(user.Id),
FromRule = true,
Text = await FormatAsync(action.Text, @event)
};
if (!string.IsNullOrWhiteSpace(action.Url))
{
var url = await FormatAsync(action.Url, @event);
if (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri))
{
ruleJob.Url = uri;
}
ruleJob.Url = uri;
}
return (Description, ruleJob);
}
return ("Ignore", new CreateComment());
return (Description, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(CreateComment job,
CancellationToken ct = default)
{
var command = job;
if (command.CommentsId == default)
{
return Result.Ignored();
}
return ("Ignore", new CreateComment());
}
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 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.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(
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
{
[AbsoluteUrl]
[LocalizedRequired]
[Display(Name = "Server Url", Description = "The url to the instance or cluster.")]
[Editor(RuleFieldEditor.Url)]
public Uri Host { get; set; }
[LocalizedRequired]
[Display(Name = "Index Name", Description = "The name of the index.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string IndexName { get; set; }
[Display(Name = "Username", Description = "The optional username.")]
[Editor(RuleFieldEditor.Text)]
public string Username { get; set; }
[Display(Name = "Password", Description = "The optional password.")]
[Editor(RuleFieldEditor.Text)]
public string Password { 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; }
}
[AbsoluteUrl]
[LocalizedRequired]
[Display(Name = "Server Url", Description = "The url to the instance or cluster.")]
[Editor(RuleFieldEditor.Url)]
public Uri Host { get; set; }
[LocalizedRequired]
[Display(Name = "Index Name", Description = "The name of the index.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string IndexName { get; set; }
[Display(Name = "Username", Description = "The optional username.")]
[Editor(RuleFieldEditor.Text)]
public string Username { get; set; }
[Display(Name = "Password", Description = "The optional password.")]
[Editor(RuleFieldEditor.Text)]
public string Password { 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 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 IJsonSerializer serializer;
private readonly ClientPool<(Uri Host, string Username, string Password), OpenSearchLowLevelClient> clients;
private readonly IScriptEngine scriptEngine;
private readonly IJsonSerializer serializer;
public OpenSearchActionHandler(RuleEventFormatter formatter, IScriptEngine scriptEngine, IJsonSerializer serializer)
: base(formatter)
public OpenSearchActionHandler(RuleEventFormatter formatter, IScriptEngine scriptEngine, IJsonSerializer serializer)
: 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))
{
config = config.BasicAuthentication(key.Username, key.Password);
}
return new OpenSearchLowLevelClient(config);
});
return new OpenSearchLowLevelClient(config);
});
this.scriptEngine = scriptEngine;
this.serializer = serializer;
}
this.scriptEngine = scriptEngine;
this.serializer = serializer;
}
protected override async Task<(string Description, OpenSearchJob Data)> CreateJobAsync(EnrichedEvent @event, OpenSearchAction action)
{
var delete = @event.ShouldDelete(scriptEngine, action.Delete);
protected override async Task<(string Description, OpenSearchJob Data)> CreateJobAsync(EnrichedEvent @event, OpenSearchAction action)
{
var delete = @event.ShouldDelete(scriptEngine, action.Delete);
string contentId;
string contentId;
if (@event is IEnrichedEntityEvent enrichedEntityEvent)
{
contentId = enrichedEntityEvent.Id.ToString();
}
else
{
contentId = DomainId.NewGuid().ToString();
}
if (@event is IEnrichedEntityEvent enrichedEntityEvent)
{
contentId = enrichedEntityEvent.Id.ToString();
}
else
{
contentId = DomainId.NewGuid().ToString();
}
var ruleDescription = string.Empty;
var ruleJob = new OpenSearchJob
{
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}";
var ruleDescription = string.Empty;
var ruleJob = new OpenSearchJob
{
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
OpenSearchContent content;
try
{
ruleDescription = $"Upsert to index: {action.IndexName}";
string jsonString;
OpenSearchContent content;
try
if (!string.IsNullOrEmpty(action.Document))
{
string jsonString;
if (!string.IsNullOrEmpty(action.Document))
{
jsonString = await FormatAsync(action.Document, @event);
jsonString = jsonString?.Trim();
}
else
{
jsonString = ToJson(@event);
}
content = serializer.Deserialize<OpenSearchContent>(jsonString);
jsonString = await FormatAsync(action.Document, @event);
jsonString = jsonString?.Trim();
}
catch (Exception ex)
else
{
content = new OpenSearchContent
{
More = new Dictionary<string, object>
{
["error"] = $"Invalid JSON: {ex.Message}"
}
};
jsonString = ToJson(@event);
}
content.ContentId = contentId;
ruleJob.Content = serializer.Serialize(content, true);
content = serializer.Deserialize<OpenSearchContent>(jsonString);
}
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,
CancellationToken ct = default)
return (ruleDescription, ruleJob);
}
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);
}
else
{
var response = await client.DeleteAsync<StringResponse>(job.IndexName, job.ContentId, ctx: ct);
return Result.SuccessOrFailed(response.OriginalException, response.Body);
}
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 string ContentId { get; set; }
public sealed class OpenSearchContent
{
public string ContentId { get; set; }
[JsonExtensionData]
public Dictionary<string, object> More { get; set; } = new Dictionary<string, object>();
}
[JsonExtensionData]
public Dictionary<string, object> More { get; set; } = new Dictionary<string, object>();
}
public sealed class OpenSearchJob
{
public string ServerHost { get; set; }
public sealed class OpenSearchJob
{
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 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.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(
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
{
[LocalizedRequired]
[Display(Name = "Token", Description = "The prerender token from your account.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string Token { get; set; }
[LocalizedRequired]
[Display(Name = "Token", Description = "The prerender token from your account.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string Token { get; set; }
[LocalizedRequired]
[Display(Name = "Url", Description = "The url to recache.")]
[Editor(RuleFieldEditor.Text)]
public string Url { get; set; }
}
[LocalizedRequired]
[Display(Name = "Url", Description = "The url to recache.")]
[Editor(RuleFieldEditor.Text)]
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
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)
: base(formatter)
{
this.httpClientFactory = httpClientFactory;
}
public PrerenderActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory)
: base(formatter)
{
this.httpClientFactory = httpClientFactory;
}
protected override async Task<(string Description, PrerenderJob Data)> CreateJobAsync(EnrichedEvent @event, PrerenderAction action)
{
var url = await FormatAsync(action.Url, @event);
protected override async Task<(string Description, PrerenderJob Data)> CreateJobAsync(EnrichedEvent @event, PrerenderAction action)
{
var url = await FormatAsync(action.Url, @event);
var request = new { prerenderToken = action.Token, url };
var requestBody = ToJson(request);
var request = new { prerenderToken = action.Token, url };
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,
CancellationToken ct = default)
protected override async Task<Result> ExecuteJobAsync(PrerenderJob job,
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 string RequestBody { get; set; }
}
public sealed class PrerenderJob
{
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 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.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.
var vars = new EventScriptVars
{
Event = @event
};
return scriptEngine.Evaluate(vars, expression);
}
Event = @event
};
return IsContentDeletion(@event) || IsAssetDeletion(@event);
return scriptEngine.Evaluate(vars, expression);
}
public static bool IsContentDeletion(this EnrichedEvent @event)
{
return @event is EnrichedContentEvent { Type: EnrichedContentEventType.Deleted or EnrichedContentEventType.Unpublished };
}
return IsContentDeletion(@event) || IsAssetDeletion(@event);
}
public static bool IsAssetDeletion(this EnrichedEvent @event)
{
return @event is EnrichedAssetEvent { Type: EnrichedAssetEventType.Deleted };
}
public static bool IsContentDeletion(this EnrichedEvent @event)
{
return @event is EnrichedContentEvent { Type: EnrichedContentEventType.Deleted or EnrichedContentEventType.Unpublished };
}
public static async Task<Result> OneWayRequestAsync(this HttpClient client, HttpRequestMessage request, string requestBody = null,
CancellationToken ct = default)
{
HttpResponseMessage response = null;
try
{
response = await client.SendAsync(request, ct);
public static bool IsAssetDeletion(this EnrichedEvent @event)
{
return @event is EnrichedAssetEvent { Type: EnrichedAssetEventType.Deleted };
}
var responseString = await response.Content.ReadAsStringAsync(ct);
var requestDump = DumpFormatter.BuildDump(request, response, requestBody, responseString);
public static async Task<Result> OneWayRequestAsync(this HttpClient client, HttpRequestMessage request, string requestBody = null,
CancellationToken ct = default)
{
HttpResponseMessage response = null;
try
{
response = await client.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
var ex = new HttpRequestException($"Response code does not indicate success: {(int)response.StatusCode} ({response.StatusCode}).");
var responseString = await response.Content.ReadAsStringAsync(ct);
var requestDump = DumpFormatter.BuildDump(request, response, requestBody, responseString);
return Result.Failed(ex, requestDump);
}
else
{
return Result.Success(requestDump);
}
}
catch (Exception ex)
if (!response.IsSuccessStatusCode)
{
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);
}
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.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(
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
{
[LocalizedRequired]
[Display(Name = "Script", Description = "The script to render.")]
[Editor(RuleFieldEditor.Javascript)]
[Formattable]
public string Script { get; set; }
}
[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
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)
: base(formatter)
{
this.scriptEngine = scriptEngine;
}
public ScriptActionHandler(RuleEventFormatter formatter, IScriptEngine scriptEngine)
: base(formatter)
{
this.scriptEngine = scriptEngine;
}
protected override Task<(string Description, ScriptJob Data)> CreateJobAsync(EnrichedEvent @event, ScriptAction action)
{
var job = new ScriptJob { Script = action.Script, Event = @event };
protected override Task<(string Description, ScriptJob Data)> CreateJobAsync(EnrichedEvent @event, ScriptAction action)
{
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,
CancellationToken ct = default)
protected override async Task<Result> ExecuteJobAsync(ScriptJob job,
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.
var vars = new EventScriptVars
{
Event = job.Event
};
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 EnrichedEvent Event { get; set; }
public sealed class ScriptJob
{
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 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
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(
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
{
[LocalizedRequired]
[Display(Name = "Connection", Description = "The connection string to the Azure SignalR.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string ConnectionString { get; set; }
[LocalizedRequired]
[Display(Name = "Connection", Description = "The connection string to the Azure SignalR.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string ConnectionString { get; set; }
[LocalizedRequired]
[Display(Name = "Hub Name", Description = "The name of the hub.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string HubName { get; set; }
[LocalizedRequired]
[Display(Name = "Hub Name", Description = "The name of the hub.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string HubName { get; set; }
[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).")]
public ActionTypeEnum Action { get; set; }
[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).")]
public ActionTypeEnum Action { get; set; }
[Display(Name = "Methode Name", Description = "Set the Name of the hub method received by the customer.")]
[Editor(RuleFieldEditor.Text)]
public string MethodName { get; set; }
[Display(Name = "Methode Name", Description = "Set the Name of the hub method received by the customer.")]
[Editor(RuleFieldEditor.Text)]
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.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Target { 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.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Target { get; set; }
[Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Payload { get; set; }
[Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
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))
{
yield return new ValidationError("Target must be specified with 'User' or 'Group' Action.", nameof(HubName));
}
if (Action != ActionTypeEnum.Broadcast && string.IsNullOrWhiteSpace(Target))
{
yield return new ValidationError("Target must be specified with 'User' or 'Group' Action.", nameof(HubName));
}
}
}
public enum ActionTypeEnum
{
Broadcast,
User,
Group
}
public enum ActionTypeEnum
{
Broadcast,
User,
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
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)
: base(formatter)
string requestBody;
if (!string.IsNullOrWhiteSpace(action.Payload))
{
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;
});
requestBody = await FormatAsync(action.Payload, @event);
}
protected override async Task<(string Description, SignalRJob Data)> CreateJobAsync(EnrichedEvent @event, SignalRAction action)
else
{
var hubName = await FormatAsync(action.HubName, @event);
string requestBody;
requestBody = ToEnvelopeJson(@event);
}
if (!string.IsNullOrWhiteSpace(action.Payload))
{
requestBody = await FormatAsync(action.Payload, @event);
}
else
{
requestBody = ToEnvelopeJson(@event);
}
var target = (await FormatAsync(action.Target, @event)) ?? string.Empty;
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
{
Action = action.Action,
ConnectionString = action.ConnectionString,
HubName = hubName,
MethodName = action.MethodName,
MethodPayload = requestBody,
Targets = target.Split("\n")
};
return (ruleDescription, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(SignalRJob job,
CancellationToken ct = default)
{
var signalR = await clients.GetClientAsync((job.ConnectionString, job.HubName));
protected override async Task<Result> ExecuteJobAsync(SignalRJob job,
CancellationToken ct = default)
await using (var signalRContext = await signalR.CreateHubContextAsync(job.HubName, cancellationToken: ct))
{
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";
switch (job.Action)
{
case ActionTypeEnum.Broadcast:
await signalRContext.Clients.All.SendAsync(methodeName, job.MethodPayload, ct);
break;
case ActionTypeEnum.User:
await signalRContext.Clients.Users(job.Targets).SendAsync(methodeName, job.MethodPayload, ct);
break;
case ActionTypeEnum.Group:
await signalRContext.Clients.Groups(job.Targets).SendAsync(methodeName, job.MethodPayload, ct);
break;
}
case ActionTypeEnum.Broadcast:
await signalRContext.Clients.All.SendAsync(methodeName, job.MethodPayload, ct);
break;
case ActionTypeEnum.User:
await signalRContext.Clients.Users(job.Targets).SendAsync(methodeName, job.MethodPayload, ct);
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 string ConnectionString { get; set; }
public sealed class SignalRJob
{
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 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.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(
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
{
[AbsoluteUrl]
[LocalizedRequired]
[Display(Name = "Webhook Url", Description = "The slack webhook url.")]
[Editor(RuleFieldEditor.Text)]
public Uri WebhookUrl { get; set; }
[AbsoluteUrl]
[LocalizedRequired]
[Display(Name = "Webhook Url", Description = "The slack webhook url.")]
[Editor(RuleFieldEditor.Text)]
public Uri WebhookUrl { get; set; }
[LocalizedRequired]
[Display(Name = "Text", Description = "The text that is sent as message to slack.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Text { get; set; }
}
[LocalizedRequired]
[Display(Name = "Text", Description = "The text that is sent as message to slack.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
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
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)
: base(formatter)
var ruleJob = new SlackJob
{
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(),
RequestBody = ToJson(body)
Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json")
};
return (Description, ruleJob);
}
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);
}
return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct);
}
}
}
public sealed class SlackJob
{
public string RequestUrl { get; set; }
public sealed class SlackJob
{
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 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.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(
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
{
[LocalizedRequired]
[Display(Name = "Access Token", Description = " The generated access token.")]
[Editor(RuleFieldEditor.Text)]
public string AccessToken { get; set; }
[LocalizedRequired]
[Display(Name = "Access Token", Description = " The generated access token.")]
[Editor(RuleFieldEditor.Text)]
public string AccessToken { get; set; }
[LocalizedRequired]
[Display(Name = "Access Secret", Description = " The generated access secret.")]
[Editor(RuleFieldEditor.Text)]
public string AccessSecret { get; set; }
[LocalizedRequired]
[Display(Name = "Access Secret", Description = " The generated access secret.")]
[Editor(RuleFieldEditor.Text)]
public string AccessSecret { get; set; }
[LocalizedRequired]
[Display(Name = "Text", Description = "The text that is sent as tweet to twitter.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Text { get; set; }
}
[LocalizedRequired]
[Display(Name = "Text", Description = "The text that is sent as tweet to twitter.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
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
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)
: base(formatter)
{
this.twitterOptions = twitterOptions.Value;
}
public TweetActionHandler(RuleEventFormatter formatter, IOptions<TwitterOptions> twitterOptions)
: base(formatter)
{
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,
AccessSecret = action.AccessSecret
};
Text = await FormatAsync(action.Text, @event),
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,
CancellationToken ct = default)
var request = new Dictionary<string, object>
{
var tokens = Tokens.Create(
twitterOptions.ClientId,
twitterOptions.ClientSecret,
job.AccessToken,
job.AccessSecret);
["status"] = job.Text
};
var request = new Dictionary<string, object>
{
["status"] = job.Text
};
await tokens.Statuses.UpdateAsync(request, ct);
await tokens.Statuses.UpdateAsync(request, ct);
return Result.Success($"Tweeted: {job.Text}");
}
return Result.Success($"Tweeted: {job.Text}");
}
}
public sealed class TweetJob
{
public string AccessToken { get; set; }
public sealed class TweetJob
{
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.
// ==========================================================================
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 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.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(
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
{
[AbsoluteUrl]
[LocalizedRequired]
[Display(Name = "Server Url", Description = "The url to the instance or cluster.")]
[Editor(RuleFieldEditor.Url)]
public Uri Host { get; set; }
[AbsoluteUrl]
[LocalizedRequired]
[Display(Name = "Server Url", Description = "The url to the instance or cluster.")]
[Editor(RuleFieldEditor.Url)]
public Uri Host { get; set; }
[LocalizedRequired]
[Display(Name = "Index Name", Description = "The name of the index.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string IndexName { get; set; }
[LocalizedRequired]
[Display(Name = "Index Name", Description = "The name of the index.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string IndexName { get; set; }
[LocalizedRequired]
[Display(Name = "Api Key", Description = "The api key.")]
[Editor(RuleFieldEditor.Text)]
public string ApiKey { get; set; }
[LocalizedRequired]
[Display(Name = "Api Key", Description = "The api key.")]
[Editor(RuleFieldEditor.Text)]
public string ApiKey { get; set; }
[Display(Name = "Document", Description = "The optional custom document.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Document { 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; }
}
[Display(Name = "Deletion", Description = "The condition when to delete the document.")]
[Editor(RuleFieldEditor.Text)]
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 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;
private readonly IHttpClientFactory httpClientFactory;
private readonly IJsonSerializer serializer;
var delete = @event.ShouldDelete(scriptEngine, action.Delete);
string contentId;
public TypesenseActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory, IScriptEngine scriptEngine, IJsonSerializer serializer)
: base(formatter)
if (@event is IEnrichedEntityEvent enrichedEntityEvent)
{
this.scriptEngine = scriptEngine;
this.httpClientFactory = httpClientFactory;
this.serializer = serializer;
contentId = enrichedEntityEvent.Id.ToString();
}
protected override async Task<(string Description, TypesenseJob Data)> CreateJobAsync(EnrichedEvent @event, TypesenseAction action)
else
{
var delete = @event.ShouldDelete(scriptEngine, action.Delete);
string contentId;
contentId = DomainId.NewGuid().ToString();
}
if (@event is IEnrichedEntityEvent enrichedEntityEvent)
{
contentId = enrichedEntityEvent.Id.ToString();
}
else
{
contentId = DomainId.NewGuid().ToString();
}
var indexName = await FormatAsync(action.IndexName, @event);
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;
var ruleJob = new TypesenseJob
{
ServerUrl = $"{action.Host.ToString().TrimEnd('/')}/collections/{indexName}/documents",
ServerKey = action.ApiKey,
ContentId = contentId
};
if (delete)
{
ruleDescription = $"Delete entry index: {action.IndexName}";
}
else
{
ruleDescription = $"Upsert to index: {action.IndexName}";
if (delete)
{
ruleDescription = $"Delete entry index: {action.IndexName}";
}
else
TypesenseContent content;
try
{
ruleDescription = $"Upsert to index: {action.IndexName}";
string jsonString;
TypesenseContent content;
try
if (!string.IsNullOrEmpty(action.Document))
{
string jsonString;
if (!string.IsNullOrEmpty(action.Document))
{
jsonString = await FormatAsync(action.Document, @event);
jsonString = jsonString?.Trim();
}
else
{
jsonString = ToJson(@event);
}
content = serializer.Deserialize<TypesenseContent>(jsonString);
jsonString = await FormatAsync(action.Document, @event);
jsonString = jsonString?.Trim();
}
catch (Exception ex)
else
{
content = new TypesenseContent
{
More = new Dictionary<string, object>
{
["error"] = $"Invalid JSON: {ex.Message}"
}
};
jsonString = ToJson(@event);
}
content.Id = contentId;
ruleJob.Content = serializer.Serialize(content, true);
content = serializer.Deserialize<TypesenseContent>(jsonString);
}
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,
CancellationToken ct = default)
return (ruleDescription, ruleJob);
}
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)
{
request = new HttpRequestMessage(HttpMethod.Post, $"{job.ServerUrl}?action=upsert")
{
Content = new StringContent(job.Content, Encoding.UTF8, "application/json")
};
}
else
if (job.Content != null)
{
request = new HttpRequestMessage(HttpMethod.Post, $"{job.ServerUrl}?action=upsert")
{
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)
{
request.Headers.TryAddWithoutValidation("X-Typesense-Api-Key", job.ServerKey);
using (request)
{
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 string Id { get; set; }
public sealed class TypesenseContent
{
public string Id { get; set; }
[JsonExtensionData]
public Dictionary<string, object> More { get; set; } = new Dictionary<string, object>();
}
[JsonExtensionData]
public Dictionary<string, object> More { get; set; } = new Dictionary<string, object>();
}
public sealed class TypesenseJob
{
public string ServerUrl { get; set; }
public sealed class TypesenseJob
{
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 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
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(
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
{
[LocalizedRequired]
[Display(Name = "Url", Description = "The url to the webhook.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public Uri Url { get; set; }
[LocalizedRequired]
[Display(Name = "Method", Description = "The type of the request.")]
public WebhookMethod Method { get; set; }
[Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Payload { get; set; }
[Display(Name = "Payload Type", Description = "The mime type of the payload.")]
[Editor(RuleFieldEditor.Text)]
public string PayloadType { get; set; }
[Display(Name = "Headers (Optional)", Description = "The message headers in the format '[Key]=[Value]', one entry per line.")]
[Editor(RuleFieldEditor.TextArea)]
public string Headers { get; set; }
[Display(Name = "Shared Secret", Description = "The shared secret that is used to calculate the payload signature.")]
[Editor(RuleFieldEditor.Text)]
public string SharedSecret { get; set; }
}
public enum WebhookMethod
{
POST,
PUT,
GET,
DELETE,
PATCH
}
[LocalizedRequired]
[Display(Name = "Url", Description = "The url to the webhook.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public Uri Url { get; set; }
[LocalizedRequired]
[Display(Name = "Method", Description = "The type of the request.")]
public WebhookMethod Method { get; set; }
[Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Payload { get; set; }
[Display(Name = "Payload Type", Description = "The mime type of the payload.")]
[Editor(RuleFieldEditor.Text)]
public string PayloadType { get; set; }
[Display(Name = "Headers (Optional)", Description = "The message headers in the format '[Key]=[Value]', one entry per line.")]
[Editor(RuleFieldEditor.TextArea)]
public string Headers { get; set; }
[Display(Name = "Shared Secret", Description = "The shared secret that is used to calculate the payload signature.")]
[Editor(RuleFieldEditor.Text)]
public string SharedSecret { get; set; }
}
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
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)
: base(formatter)
{
this.httpClientFactory = httpClientFactory;
}
protected override async Task<(string Description, WebhookJob Data)> CreateJobAsync(EnrichedEvent @event, WebhookAction action)
{
var requestUrl = await FormatAsync(action.Url, @event);
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);
var requestBody = string.Empty;
var requestSignature = string.Empty;
if (action.Method != WebhookMethod.GET)
if (!string.IsNullOrEmpty(action.Payload))
{
if (!string.IsNullOrEmpty(action.Payload))
{
requestBody = await FormatAsync(action.Payload, @event);
}
else
{
requestBody = ToEnvelopeJson(@event);
}
requestSignature = $"{requestBody}{action.SharedSecret}".ToSha256Base64();
requestBody = await FormatAsync(action.Payload, @event);
}
var ruleDescription = $"Send event to webhook '{requestUrl}'";
var ruleJob = new WebhookJob
else
{
Method = action.Method,
RequestUrl = await FormatAsync(action.Url.ToString(), @event),
RequestSignature = requestSignature,
RequestBody = requestBody,
RequestBodyType = action.PayloadType,
Headers = await ParseHeadersAsync(action.Headers, @event)
};
return (ruleDescription, ruleJob);
requestBody = ToEnvelopeJson(@event);
}
requestSignature = $"{requestBody}{action.SharedSecret}".ToSha256Base64();
}
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))
{
return null;
}
Method = action.Method,
RequestUrl = await FormatAsync(action.Url.ToString(), @event),
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 indexEqual = line.IndexOf('=', StringComparison.Ordinal);
var lines = headers.Split('\n');
if (indexEqual > 0 && indexEqual < line.Length - 1)
{
var headerKey = line[..indexEqual];
var headerValue = line[(indexEqual + 1)..];
foreach (var line in lines)
{
var indexEqual = line.IndexOf('=', StringComparison.Ordinal);
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,
CancellationToken ct = default)
{
var httpClient = httpClientFactory.CreateClient();
return headersDictionary;
}
var method = HttpMethod.Post;
protected override async Task<Result> ExecuteJobAsync(WebhookJob job,
CancellationToken ct = default)
{
var httpClient = httpClientFactory.CreateClient();
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;
}
var method = HttpMethod.Post;
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)
{
var mediaType = job.RequestBodyType.Or("application/json");
using var request = new HttpRequestMessage(method, job.RequestUrl);
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)
{
foreach (var (key, value) in job.Headers)
{
request.Headers.TryAddWithoutValidation(key, value);
}
}
request.Headers.Add("User-Agent", "Squidex Webhook");
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");
return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct);
if (!string.IsNullOrWhiteSpace(job.RequestSignature))
{
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 WebhookMethod Method { get; set; }
public sealed class WebhookJob
{
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 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.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,
ILogger<AzureMetadataSource> log)
public AzureMetadataSource(IOptions<AzureMetadataSourceOptions> options,
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,
CancellationToken ct)
public async Task EnhanceAsync(UploadAssetCommand command,
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)
{
command.Tags.Add($"color/{result.Color.DominantColorForeground.Trim(trimChars).ToLowerInvariant()}");
}
if (result.Color?.DominantColorForeground != null)
{
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)
{
command.Metadata["caption"] = JsonValue.Create(description);
}
if (description != null)
{
command.Metadata["caption"] = JsonValue.Create(description);
}
}
}
catch (Exception ex)
{
log.LogError(ex, "Failed to enrich asset.");
}
}
public IEnumerable<string> Format(IAssetEntity asset)
catch (Exception ex)
{
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.
// ==========================================================================
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()
{
return
!string.IsNullOrWhiteSpace(Endpoint) &&
!string.IsNullOrWhiteSpace(ApiKey);
}
public bool IsConfigured()
{
return
!string.IsNullOrWhiteSpace(Endpoint) &&
!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.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())
{
services.AddSingleton<IAssetMetadataSource, AzureMetadataSource>();
services.AddSingleton(Options.Create(options));
}
if (options.IsConfigured())
{
services.AddSingleton<IAssetMetadataSource, AzureMetadataSource>();
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.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;
await context.Response.WriteAsync("Memory Asset Store used.");
}
else
{
await next();
}
});
next(builder);
};
}
context.Response.StatusCode = 200;
public void ConfigureServices(IServiceCollection services, IConfiguration config)
{
var storeType = config.GetValue<string>("assetStore:type");
await context.Response.WriteAsync("Memory Asset Store used.");
}
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)
{
services.AddSingleton<IStartupFilter>(this);
var isMemoryAssetsUsed = string.Equals(storeType, "Memory", StringComparison.OrdinalIgnoreCase);
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.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")]
public IActionResult Test()
{
return Ok(new { text = "I am Plugin" });
}
[Route("plugins/sample")]
public IActionResult Test()
{
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.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)
{
this.contentLoader = contentLoader;
}
public async Task HandleAsync(CommandContext context, NextDelegate next,
CancellationToken ct)
{
await next(context, ct);
public async Task HandleAsync(CommandContext context, NextDelegate next,
CancellationToken ct)
if (context.Command is UpdateContent update && context.IsCompleted && update.SchemaId.Name == "source")
{
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 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;
}
var oldReferenced = await contentLoader.GetAsync(content.AppId.Id, DomainId.Create(oldReferenceId), ct: ct);
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)
{
var data = oldReferenced.Data.Clone();
// Remove the reference from the old referenced content.
data.Remove("referencing");
// 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.
data["referencing"] = new ContentFieldData
{
["iv"] = JsonValue.Array(content.Id)
};
await UpdateReferencing(context, newReferenced, data, ct);
}
await UpdateReferencing(context, newReferenced, data, ct);
}
}
}
}
private static async Task UpdateReferencing(CommandContext context, IContentEntity reference, ContentData data,
CancellationToken ct)
private static async Task UpdateReferencing(CommandContext context, IContentEntity reference, ContentData data,
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.
await context.CommandBus.PublishAsync(new UpdateContent
{
AppId = reference.AppId,
SchemaId = reference.SchemaId,
ContentId = reference.Id,
DoNotScript = true,
DoNotValidate = true,
Data = data,
ExpectedVersion = reference.Version
}, ct);
}
AppId = reference.AppId,
SchemaId = reference.SchemaId,
ContentId = reference.Id,
DoNotScript = true,
DoNotValidate = true,
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 null;
return fieldData.Values.OfType<JsonArray>().SelectMany(x => x).SingleOrDefault().ToString();
}
return null;
}
}

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

@ -8,133 +8,132 @@
using System.Reflection;
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()
{
var analyzers =
typeof(LexicalAnalyzerName)
.GetProperties(BindingFlags.Public | BindingFlags.Static)
.Select(x => x.GetValue(null))
.Select(x => x.ToString())
.OrderBy(x => x)
.ToList();
static AzureIndexDefinition()
{
var analyzers =
typeof(LexicalAnalyzerName)
.GetProperties(BindingFlags.Public | BindingFlags.Static)
.Select(x => x.GetValue(null))
.Select(x => x.ToString())
.OrderBy(x => x)
.ToList();
var addedLanguage = new HashSet<string>();
var addedLanguage = new HashSet<string>();
foreach (var analyzer in analyzers)
{
var indexOfDot = analyzer.IndexOf('.', StringComparison.Ordinal);
foreach (var analyzer in analyzers)
{
var indexOfDot = analyzer.IndexOf('.', StringComparison.Ordinal);
if (indexOfDot > 0)
{
var language = analyzer[..indexOfDot];
if (indexOfDot > 0)
{
var language = analyzer[..indexOfDot];
var isValidLanguage =
language.Length == 2 ||
language.StartsWith("zh-", StringComparison.Ordinal);
var isValidLanguage =
language.Length == 2 ||
language.StartsWith("zh-", StringComparison.Ordinal);
if (isValidLanguage && addedLanguage.Add(language))
{
var fieldName = language.Replace('-', '_');
if (isValidLanguage && addedLanguage.Add(language))
{
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)
{
var language = key[2..];
if (key.Length > 0)
{
var language = key[2..];
if (FieldAnalyzers.TryGetValue(language, out analyzer))
{
return analyzer.Field;
}
if (FieldAnalyzers.TryGetValue(language, out analyzer))
{
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
},
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)
IsKey = true
},
new SimpleField("appId", SearchFieldDataType.String)
{
fields.Add(
new SearchableField(field)
{
IsFilterable = false,
IsFacetable = false,
AnalyzerName = analyzer
});
}
var index = new SearchIndex(indexName)
IsFilterable = true
},
new SimpleField("appName", SearchFieldDataType.String)
{
IsFilterable = false
},
new SimpleField("contentId", SearchFieldDataType.String)
{
IsFilterable = false
},
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.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;
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));
indexClient = new SearchIndexClient(new Uri(serviceEndpoint), new AzureKeyCredential(serviceApiKey));
searchClient = indexClient.GetSearchClient(indexName);
}
public async Task InitializeAsync(
CancellationToken ct)
{
await CreateIndexAsync(ct);
}
searchClient = indexClient.GetSearchClient(indexName);
}
public async Task ClearAsync(
CancellationToken ct = default)
{
await indexClient.DeleteIndexAsync(searchClient.IndexName, ct);
public async Task InitializeAsync(
CancellationToken ct)
{
await CreateIndexAsync(ct);
}
await CreateIndexAsync(ct);
}
public async Task ClearAsync(
CancellationToken ct = default)
{
await indexClient.DeleteIndexAsync(searchClient.IndexName, ct);
private async Task CreateIndexAsync(
CancellationToken ct)
{
var index = AzureIndexDefinition.Create(searchClient.IndexName);
await CreateIndexAsync(ct);
}
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,
CancellationToken ct = default)
{
var batch = IndexDocumentsBatch.Create<SearchDocument>();
await indexClient.CreateOrUpdateIndexAsync(index, true, true, ct);
}
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)
{
return;
}
commands.Foreach(x => CommandFactory.CreateCommands(x, batch.Actions));
await searchClient.IndexDocumentsAsync(batch, cancellationToken: ct);
if (batch.Actions.Count == 0)
{
return;
}
public async Task<List<DomainId>> SearchAsync(IAppEntity app, GeoQuery query, SearchScope scope,
CancellationToken ct = default)
{
Guard.NotNull(app);
Guard.NotNull(query);
await searchClient.IndexDocumentsAsync(batch, cancellationToken: ct);
}
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,
CancellationToken ct = default)
{
Guard.NotNull(app);
Guard.NotNull(query);
return result.OrderByDescending(x => x.Score).Select(x => x.Id).Distinct().ToList();
}
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)
{
return null;
}
var parsed = queryParser.Parse(query.Text);
var result = new List<(DomainId Id, double Score)>();
if (parsed == null)
{
return null;
}
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;
var result = new List<(DomainId Id, double Score)>();
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);
await SearchByAppAsync(result, parsed.Text, app, scope, halfTake, 1, ct);
}
var schemaIds = Enumerable.Repeat(query.PreferredSchemaId.Value, 1);
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,
CancellationToken ct = default)
{
var searchField = GetServeField(scope);
return result.OrderByDescending(x => x.Score).Select(x => x.Id).Distinct().ToList();
}
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,
CancellationToken ct = default)
{
var searchField = GetServeField(scope);
return SearchAsync(result, text, filter, take, factor, ct);
}
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,
CancellationToken ct = default)
private async Task SearchAsync(List<(DomainId, double)> result, string text, string filter, int take, double factor,
CancellationToken ct = default)
{
var searchOptions = new SearchOptions
{
var searchOptions = new SearchOptions
{
Filter = filter
};
Filter = filter
};
searchOptions.Select.Add("contentId");
searchOptions.Size = take;
searchOptions.QueryType = SearchQueryType.Full;
searchOptions.Select.Add("contentId");
searchOptions.Size = take;
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)
{
var (schema, field, lat, lng, radius, _) = query;
private static string BuildGeoQuery(GeoQuery query, SearchScope scope)
{
var (schema, field, lat, lng, radius, _) = query;
var searchField = GetServeField(scope);
var searchDistance = radius / 1000;
var searchField = GetServeField(scope);
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)
{
return scope == SearchScope.Published ?
"servePublished" :
"serveAll";
}
private static string GetServeField(SearchScope scope)
{
return scope == SearchScope.Published ?
"servePublished" :
"serveAll";
}
}

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

@ -12,50 +12,49 @@ using Squidex.Hosting;
using Squidex.Hosting.Configuration;
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");
if (string.IsNullOrWhiteSpace(serviceEndpoint))
{
var error = new ConfigurationError("Value is required.", "fullText:azure: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))
{
var error = new ConfigurationError("Value is required.", "fullText:azure:apiKey");
if (string.IsNullOrWhiteSpace(serviceApiKey))
{
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))
{
indexName = "squidex-index";
}
if (string.IsNullOrWhiteSpace(indexName))
{
indexName = "squidex-index";
}
services.AddSingleton(
c => new AzureTextIndex(serviceEndpoint, serviceApiKey, indexName));
services.AddSingleton(
c => new AzureTextIndex(serviceEndpoint, serviceApiKey, indexName));
services.AddSingleton<ITextIndex>(
c => c.GetRequiredService<AzureTextIndex>());
services.AddSingleton<ITextIndex>(
c => c.GetRequiredService<AzureTextIndex>());
services.AddSingleton<IInitializable>(
c => c.GetRequiredService<AzureTextIndex>());
}
services.AddSingleton<IInitializable>(
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 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);
break;
case UpdateIndexEntry update:
UpdateEntry(update, batch);
break;
case DeleteIndexEntry delete:
DeleteEntry(delete, batch);
break;
}
case UpsertIndexEntry upsert:
UpsertTextEntry(upsert, batch);
break;
case UpdateIndexEntry update:
UpdateEntry(update, batch);
break;
case DeleteIndexEntry delete:
DeleteEntry(delete, batch);
break;
}
}
private static void UpsertTextEntry(UpsertIndexEntry upsert, IList<IndexDocumentsAction<SearchDocument>> batch)
{
var geoField = string.Empty;
var geoObject = (object)null;
private static void UpsertTextEntry(UpsertIndexEntry upsert, IList<IndexDocumentsAction<SearchDocument>> batch)
{
var geoField = string.Empty;
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;
geoObject = new
type = "Point",
coordinates = new[]
{
type = "Point",
coordinates = new[]
{
point.Coordinate.X,
point.Coordinate.Y
}
};
break;
}
point.Coordinate.X,
point.Coordinate.Y
}
};
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(),
["appName"] = upsert.AppId.Name,
["contentId"] = upsert.ContentId.ToString(),
["schemaId"] = upsert.SchemaId.Id.ToString(),
["schemaName"] = upsert.SchemaId.Name,
["serveAll"] = upsert.ServeAll,
["servePublished"] = upsert.ServePublished,
["geoField"] = geoField,
["geoObject"] = geoObject
};
foreach (var (key, value) in upsert.Texts)
{
var text = value;
["docId"] = upsert.DocId.ToBase64(),
["appId"] = upsert.AppId.Id.ToString(),
["appName"] = upsert.AppId.Name,
["contentId"] = upsert.ContentId.ToString(),
["schemaId"] = upsert.SchemaId.Id.ToString(),
["schemaName"] = upsert.SchemaId.Name,
["serveAll"] = upsert.ServeAll,
["servePublished"] = upsert.ServePublished,
["geoField"] = geoField,
["geoObject"] = geoObject
};
var languageCode = AzureIndexDefinition.GetFieldName(key);
foreach (var (key, value) in upsert.Texts)
{
var text = value;
if (document.TryGetValue(languageCode, out var existing))
{
text = $"{existing} {value}";
}
var languageCode = AzureIndexDefinition.GetFieldName(key);
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));
}
}
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)
{
return Convert.ToBase64String(Encoding.Default.GetBytes(value));
}
batch.Add(IndexDocumentsAction.MergeOrUpload(document));
}
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 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);
break;
case UpdateIndexEntry update:
UpdateEntry(update, args, indexName);
break;
case DeleteIndexEntry delete:
DeleteEntry(delete, args, indexName);
break;
}
case UpsertIndexEntry upsert:
UpsertEntry(upsert, args, indexName);
break;
case UpdateIndexEntry update:
UpdateEntry(update, args, indexName);
break;
case DeleteIndexEntry delete:
DeleteEntry(delete, args, indexName);
break;
}
}
private static void UpsertEntry(UpsertIndexEntry upsert, List<object> args, string indexName)
{
var geoField = string.Empty;
var geoObject = (object)null;
private static void UpsertEntry(UpsertIndexEntry upsert, List<object> args, string indexName)
{
var geoField = string.Empty;
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;
geoObject = new
{
lat = point.Coordinate.X,
lon = point.Coordinate.Y
};
break;
}
lat = point.Coordinate.X,
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
}
});
var texts = new Dictionary<string, string>();
_id = upsert.DocId,
_index = indexName
}
});
foreach (var (key, value) in upsert.Texts)
{
var text = value;
var texts = new Dictionary<string, string>();
var languageCode = ElasticSearchIndexDefinition.GetFieldName(key);
foreach (var (key, value) in upsert.Texts)
{
var text = value;
if (texts.TryGetValue(languageCode, out var existing))
{
text = $"{existing} {value}";
}
var languageCode = ElasticSearchIndexDefinition.GetFieldName(key);
texts[languageCode] = text;
if (texts.TryGetValue(languageCode, out var existing))
{
text = $"{existing} {value}";
}
args.Add(new
{
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
});
texts[languageCode] = text;
}
}
private static void UpdateEntry(UpdateIndexEntry update, List<object> args, string indexName)
{
args.Add(new
{
update = new
{
_id = update.DocId,
_index = indexName
}
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
});
}
}
args.Add(new
private static void UpdateEntry(UpdateIndexEntry update, List<object> args, string indexName)
{
args.Add(new
{
update = new
{
doc = new
{
serveAll = update.ServeAll,
servePublished = update.ServePublished
}
});
}
_id = update.DocId,
_index = indexName
}
});
private static void DeleteEntry(DeleteIndexEntry delete, List<object> args, string indexName)
args.Add(new
{
args.Add(new
doc = new
{
delete = new
{
_id = delete.DocId,
_index = indexName
}
});
}
serveAll = update.ServeAll,
servePublished = update.ServePublished
}
});
}
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;
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)
{
var config = new ConnectionConfiguration(new Uri(configurationString));
elasticSearch = new ElasticLowLevelClient(config);
}
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,
CancellationToken ct)
if (!result.Success)
{
var result = await elasticSearch.Indices.PutMappingAsync<StringResponse>(indexName, CreatePost(request), ctx: ct);
if (!result.Success)
{
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
}
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
}
}
public async Task BulkAsync<T>(List<T> requests,
CancellationToken ct)
{
var result = await elasticSearch.BulkAsync<StringResponse>(CreatePost(requests), ctx: ct);
public async Task BulkAsync<T>(List<T> requests,
CancellationToken ct)
{
var result = await elasticSearch.BulkAsync<StringResponse>(CreatePost(requests), ctx: ct);
if (!result.Success)
{
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
}
if (!result.Success)
{
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
}
}
public async Task<List<dynamic>> SearchAsync<T>(string indexName, T request,
CancellationToken ct)
{
var result = await elasticSearch.SearchAsync<DynamicResponse>(indexName, CreatePost(request), ctx: ct);
public async Task<List<dynamic>> SearchAsync<T>(string indexName, T request,
CancellationToken ct)
{
var result = await elasticSearch.SearchAsync<DynamicResponse>(indexName, CreatePost(request), ctx: ct);
if (!result.Success)
{
throw result.OriginalException;
}
if (!result.Success)
{
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 PostData.MultiJson(requests.OfType<object>());
}
return hits;
}
private static PostData CreatePost<T>(T data)
{
return new SerializableData<T>(data);
}
private static PostData CreatePost<T>(List<T> requests)
{
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.
// ==========================================================================
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;
private static readonly Dictionary<string, string> FieldAnalyzers = new Dictionary<string, string>
{
["ar"] = "arabic",
["hy"] = "armenian",
["eu"] = "basque",
["bn"] = "bengali",
["br"] = "brazilian",
["bg"] = "bulgarian",
["ca"] = "catalan",
["zh"] = "cjk",
["ja"] = "cjk",
["ko"] = "cjk",
["cs"] = "czech",
["da"] = "danish",
["nl"] = "dutch",
["en"] = "english",
["fi"] = "finnish",
["fr"] = "french",
["gl"] = "galician",
["de"] = "german",
["el"] = "greek",
["hi"] = "hindi",
["hu"] = "hungarian",
["id"] = "indonesian",
["ga"] = "irish",
["it"] = "italian",
["lv"] = "latvian",
["lt"] = "lithuanian",
["no"] = "norwegian",
["pt"] = "portuguese",
["ro"] = "romanian",
["ru"] = "russian",
["ku"] = "sorani",
["es"] = "spanish",
["sv"] = "swedish",
["tr"] = "turkish",
["th"] = "thai"
};
["ar"] = "arabic",
["hy"] = "armenian",
["eu"] = "basque",
["bn"] = "bengali",
["br"] = "brazilian",
["bg"] = "bulgarian",
["ca"] = "catalan",
["zh"] = "cjk",
["ja"] = "cjk",
["ko"] = "cjk",
["cs"] = "czech",
["da"] = "danish",
["nl"] = "dutch",
["en"] = "english",
["fi"] = "finnish",
["fr"] = "french",
["gl"] = "galician",
["de"] = "german",
["el"] = "greek",
["hi"] = "hindi",
["hu"] = "hungarian",
["id"] = "indonesian",
["ga"] = "irish",
["it"] = "italian",
["lv"] = "latvian",
["lt"] = "lithuanian",
["no"] = "norwegian",
["pt"] = "portuguese",
["ro"] = "romanian",
["ru"] = "russian",
["ku"] = "sorani",
["es"] = "spanish",
["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))
{
return key;
}
var language = key[2..];
if (key.Length > 0)
if (FieldAnalyzers.ContainsKey(language))
{
var language = key[2..];
if (FieldAnalyzers.ContainsKey(language))
{
return 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;
}
}
if (key.Length > 0)
{
var language = key[2..];
return "texts.iv";
}
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";
}
public static Task ApplyAsync(IElasticSearchClient client, string indexName,
CancellationToken ct = default)
foreach (var (key, analyzer) in FieldAnalyzers)
{
var query = new
query.properties[GetFieldPath(key)] = new
{
properties = new Dictionary<string, object>
{
["geoObject"] = new
{
type = "geo_point"
}
}
type = "text",
analyzer
};
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.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);
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)
{
this.elasticClient = elasticClient;
this.indexName = indexName;
this.jsonSerializer = jsonSerializer;
}
this.elasticClient = elasticClient;
this.indexName = indexName;
this.jsonSerializer = jsonSerializer;
}
public Task InitializeAsync(
CancellationToken ct)
{
return ElasticSearchIndexDefinition.ApplyAsync(elasticClient, indexName, ct);
}
public Task ClearAsync(
CancellationToken ct = default)
{
return Task.CompletedTask;
}
public Task InitializeAsync(
CancellationToken ct)
public Task ExecuteAsync(IndexCommand[] commands,
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(
CancellationToken ct = default)
if (args.Count == 0)
{
return Task.CompletedTask;
}
public Task ExecuteAsync(IndexCommand[] commands,
CancellationToken ct = default)
{
var args = new List<object>();
foreach (var command in commands)
{
CommandFactory.CreateCommands(command, args, indexName);
}
return elasticClient.BulkAsync(args, ct);
}
if (args.Count == 0)
{
return Task.CompletedTask;
}
public async Task<List<DomainId>> SearchAsync(IAppEntity app, GeoQuery query, SearchScope scope,
CancellationToken ct = default)
{
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,
CancellationToken ct = default)
var elasticQuery = new
{
Guard.NotNull(app);
Guard.NotNull(query);
var serveField = GetServeField(scope);
var elasticQuery = new
query = 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()
}
},
new
["schemaId.keyword"] = query.SchemaId.ToString()
}
},
new
{
term = new Dictionary<string, string>
{
term = new Dictionary<string, string>
{
["geoField.keyword"] = query.Field
}
},
new
["geoField.keyword"] = query.Field
}
},
new
{
term = new Dictionary<string, string>
{
term = new Dictionary<string, string>
{
[serveField] = "true"
}
},
new
[serveField] = "true"
}
},
new
{
geo_distance = new
{
geo_distance = new
geoObject = new
{
geoObject = new
{
lat = query.Latitude,
lon = query.Longitude
},
distance = $"{query.Radius}m"
}
lat = query.Latitude,
lon = query.Longitude
},
distance = $"{query.Radius}m"
}
}
}
},
_source = new[]
{
"contentId"
},
size = query.Take
};
}
},
_source = new[]
{
"contentId"
},
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,
CancellationToken ct = default)
{
Guard.NotNull(app);
Guard.NotNull(query);
public async Task<List<DomainId>> SearchAsync(IAppEntity app, TextQuery query, SearchScope scope,
CancellationToken ct = default)
{
Guard.NotNull(app);
Guard.NotNull(query);
var parsed = queryParser.Parse(query.Text);
var parsed = queryParser.Parse(query.Text);
if (parsed == null)
{
return null;
}
if (parsed == 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>
{
["appId.keyword"] = app.Id.ToString()
}
},
new
term = new Dictionary<string, object>
{
term = new Dictionary<string, string>
{
[serveField] = "true"
}
["appId.keyword"] = app.Id.ToString()
}
},
must = new
new
{
query_string = new
term = new Dictionary<string, string>
{
query = parsed.Text
[serveField] = "true"
}
},
should = new List<object>()
}
},
_source = new[]
{
"contentId"
},
size = query.Take
};
}
},
must = new
{
query_string = new
{
query = parsed.Text
}
},
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);
}
else if (query.PreferredSchemaId.HasValue)
elasticQuery.query.@bool.filter.Add(bySchema);
}
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()
}
};
elasticQuery.query.@bool.should.Add(bySchema);
}
var json = jsonSerializer.Serialize(elasticQuery, true);
["schemaId.keyword"] = query.PreferredSchemaId.ToString()
}
};
return await SearchAsync(elasticQuery, ct);
elasticQuery.query.@bool.should.Add(bySchema);
}
private async Task<List<DomainId>> SearchAsync(object query,
CancellationToken ct)
{
var hits = await elasticClient.SearchAsync(indexName, query, ct);
var json = jsonSerializer.Serialize(elasticQuery, true);
var ids = new List<DomainId>();
return await SearchAsync(elasticQuery, ct);
}
foreach (var item in hits)
{
ids.Add(DomainId.Create(item["_source"]["contentId"]));
}
private async Task<List<DomainId>> SearchAsync(object query,
CancellationToken ct)
{
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.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))
{
var error = new ConfigurationError("Value is required.", "fullText:elastic:configuration");
throw new ConfigurationException(error);
}
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))
{
indexName = "squidex-index";
}
var openSearch = config.GetValue<bool>("fullText:elastic:openSearch");
var openSearch = config.GetValue<bool>("fullText:elastic:openSearch");
services.AddSingleton(c =>
{
IElasticSearchClient elasticSearchClient;
services.AddSingleton(c =>
if (openSearch)
{
IElasticSearchClient elasticSearchClient;
if (openSearch)
{
elasticSearchClient = new OpenSearchClient(configuration);
}
else
{
elasticSearchClient = new ElasticSearchClient(configuration);
}
elasticSearchClient = new OpenSearchClient(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>(
c => c.GetRequiredService<ElasticSearchTextIndex>());
services.AddSingleton<ITextIndex>(
c => c.GetRequiredService<ElasticSearchTextIndex>());
services.AddSingleton<IInitializable>(
c => c.GetRequiredService<ElasticSearchTextIndex>());
}
services.AddSingleton<IInitializable>(
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.
// ==========================================================================
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,
CancellationToken ct);
Task BulkAsync<T>(List<T> requests,
CancellationToken ct);
Task<List<dynamic>> SearchAsync<T>(string indexName, T request,
CancellationToken ct);
}
Task<List<dynamic>> SearchAsync<T>(string indexName, T request,
CancellationToken ct);
}

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

@ -7,72 +7,71 @@
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)
{
var config = new ConnectionConfiguration(new Uri(configurationString));
openSearch = new OpenSearchLowLevelClient(config);
}
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,
CancellationToken ct)
if (!result.Success)
{
var result = await openSearch.Indices.PutMappingAsync<StringResponse>(indexName, CreatePost(request), ctx: ct);
if (!result.Success)
{
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
}
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
}
}
public async Task BulkAsync<T>(List<T> requests,
CancellationToken ct)
{
var result = await openSearch.BulkAsync<StringResponse>(CreatePost(requests), ctx: ct);
public async Task BulkAsync<T>(List<T> requests,
CancellationToken ct)
{
var result = await openSearch.BulkAsync<StringResponse>(CreatePost(requests), ctx: ct);
if (!result.Success)
{
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
}
if (!result.Success)
{
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
}
}
public async Task<List<dynamic>> SearchAsync<T>(string indexName, T request,
CancellationToken ct)
{
var result = await openSearch.SearchAsync<DynamicResponse>(indexName, CreatePost(request), ctx: ct);
public async Task<List<dynamic>> SearchAsync<T>(string indexName, T request,
CancellationToken ct)
{
var result = await openSearch.SearchAsync<DynamicResponse>(indexName, CreatePost(request), ctx: ct);
if (!result.Success)
{
throw result.OriginalException;
}
if (!result.Success)
{
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 PostData.MultiJson(requests.OfType<object>());
}
return hits;
}
private static PostData CreatePost<T>(T data)
{
return new SerializableData<T>(data);
}
private static PostData CreatePost<T>(List<T> requests)
{
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.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;
private readonly IContentRepository contentRepository;
this.tag = tag;
public CompositeUniqueValidator(string tag, IContentRepository contentRepository)
{
this.tag = tag;
this.contentRepository = contentRepository;
}
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);
if (fieldValue != null)
{
filters.Add(ClrFilter.Eq($"data.{field.Name}.iv", fieldValue));
}
context.AddError(Enumerable.Empty<string>(), "A content with the same values already exist.");
}
}
}
if (filters.Count > 0)
{
var filter = ClrFilter.And(filters);
var found = await contentRepository.QueryIdsAsync(context.Root.AppId.Id, context.Root.SchemaId.Id, filter);
private static ClrValue TryGetValue(IRootField field, ContentData data)
{
var value = JsonValue.Null;
if (found.Any(x => x.Id != context.Root.ContentId))
{
context.AddError(Enumerable.Empty<string>(), "A content with the same values already exist.");
}
if (data.TryGetValue(field.Name, out var fieldValue))
{
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;
if (data.TryGetValue(field.Name, out var fieldValue))
{
if (fieldValue.TryGetValue(InvariantPartitioning.Key, out var temp) && temp != default)
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)
{
value = temp;
return first;
}
}
switch (field.RawProperties)
{
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;
break;
}
private bool IsValidateableField(IRootField field)
{
return
field.Partitioning == Partitioning.Invariant &&
field.RawProperties.Tags?.Contains(tag) == true &&
field.RawProperties is BooleanFieldProperties or NumberFieldProperties or ReferencesFieldProperties or StringFieldProperties;
}
return null;
}
private bool IsValidateableField(IRootField field)
{
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.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:";
private readonly IContentRepository contentRepository;
this.contentRepository = 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 return new CompositeUniqueValidator(validatorTag, contentRepository);
}
yield break;
}
private static IEnumerable<string> ValidatorTags(IEnumerable<string> tags)
foreach (var tag in tags)
{
if (tags == null)
{
yield break;
}
foreach (var tag in tags)
if (tag.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase) && tag.Length > Prefix.Length)
{
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.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
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.")]
public void Info()
[Command(Name = "check-backend", Description = "Check backend files.")]
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.")]
[SubCommand]
public class Translate
[Command(Name = "check-frontend", Description = "Check frontend files.")]
public void CheckFrontend(TranslateArguments arguments)
{
[Command(Name = "check-backend", Description = "Check backend files.")]
public void CheckBackend(TranslateArguments arguments)
{
var (folder, service) = Setup(arguments, "backend");
var (folder, service) = Setup(arguments, "frontend");
new CheckBackend(folder, service).Run();
}
new CheckFrontend(folder, service).Run();
}
[Command(Name = "check-frontend", Description = "Check frontend files.")]
public void CheckFrontend(TranslateArguments arguments)
{
var (folder, service) = Setup(arguments, "frontend");
[Command(Name = "backend", Description = "Translate backend files.")]
public void Backend(TranslateArguments arguments)
{
var (folder, service) = Setup(arguments, "backend");
new CheckFrontend(folder, service).Run();
}
new TranslateBackend(folder, service).Run();
}
[Command(Name = "backend", Description = "Translate backend files.")]
public void Backend(TranslateArguments arguments)
{
var (folder, service) = Setup(arguments, "backend");
[Command(Name = "templates", Description = "Translate angular templates.")]
public void Templates(TranslateArguments arguments)
{
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.")]
public void Templates(TranslateArguments arguments)
{
var (folder, service) = Setup(arguments, "frontend");
[Command(Name = "typescript", Description = "Translate typescript files.")]
public void Typescript(TranslateArguments arguments)
{
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.")]
public void Typescript(TranslateArguments arguments)
{
var (folder, service) = Setup(arguments, "frontend");
[Command(Name = "gen-backend", Description = "Generate the backend translations.")]
public void GenerateBackend(TranslateArguments arguments)
{
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.")]
public void GenerateBackend(TranslateArguments arguments)
{
var (folder, service) = Setup(arguments, "backend");
[Command(Name = "gen-frontend", Description = "Generate the frontend translations.")]
public void GenerateFrontend(TranslateArguments arguments)
{
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.")]
public void GenerateFrontend(TranslateArguments arguments)
{
var (folder, service) = Setup(arguments, "frontend");
[Command(Name = "clean-backend", Description = "Clean the backend translations.")]
public void CleanBackend(TranslateArguments arguments)
{
var (_, service) = Setup(arguments, "backend");
new GenerateFrontendResources(folder, service).Run();
}
Helper.CleanOtherLocales(service);
[Command(Name = "clean-backend", Description = "Clean the backend translations.")]
public void CleanBackend(TranslateArguments arguments)
{
var (_, service) = Setup(arguments, "backend");
service.Save();
}
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.")]
public void CleanFrontend(TranslateArguments arguments)
{
var (_, service) = Setup(arguments, "frontend");
service.Save();
}
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.")]
public void GenerateBackendKeys(TranslateArguments arguments)
{
var (backendFolder, serviceBackend) = Setup(arguments, "backend");
var (frontendFolder, frontendService) = Setup(arguments, "frontend");
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.")]
public void MigrateBackend(TranslateArguments arguments)
{
var (_, service) = Setup(arguments, "backend");
[Command(Name = "migrate-frontend", Description = "Migrate the frontend files.")]
public void MigrateFrontend(TranslateArguments arguments)
{
var (_, service) = Setup(arguments, "frontend");
service.Migrate();
}
service.Migrate();
}
[Command(Name = "migrate-frontend", Description = "Migrate the frontend files.")]
public void MigrateFrontend(TranslateArguments arguments)
private static (DirectoryInfo, TranslationService) Setup(TranslateArguments arguments, string fileName)
{
if (!Directory.Exists(arguments.Folder))
{
var (_, service) = Setup(arguments, "frontend");
service.Migrate();
throw new ArgumentException("Folder does not exist.", nameof(arguments));
}
private static (DirectoryInfo, TranslationService) Setup(TranslateArguments arguments, string fileName)
{
if (!Directory.Exists(arguments.Folder))
{
throw new ArgumentException("Folder does not exist.", nameof(arguments));
}
var supportedLocales = new string[] { "en", "nl", "it", "zh" };
var supportedLocales = new string[] { "en", "nl", "it", "zh" };
var locales = supportedLocales;
var locales = supportedLocales;
if (arguments.Locales != null && arguments.Locales.Any())
{
locales = supportedLocales.Intersect(arguments.Locales).ToArray();
}
if (arguments.Locales != null && arguments.Locales.Any())
{
locales = supportedLocales.Intersect(arguments.Locales).ToArray();
}
if (locales.Length == 0)
{
locales = supportedLocales;
}
if (locales.Length == 0)
{
locales = supportedLocales;
}
var translationsDirectory = new DirectoryInfo(Path.Combine(arguments.Folder, "backend", "i18n"));
var translationsService = new TranslationService(translationsDirectory, fileName, locales, arguments.SingleWords);
var translationsDirectory = new DirectoryInfo(Path.Combine(arguments.Folder, "backend", "i18n"));
var translationsService = new TranslationService(translationsDirectory, fileName, locales, arguments.SingleWords);
return (new DirectoryInfo(arguments.Folder), translationsService);
}
return (new DirectoryInfo(arguments.Folder), translationsService);
}
}
[Validator(typeof(Validator))]
public sealed class TranslateArguments : IArgumentModel
{
[Operand(Name = "folder", Description = "The squidex folder.")]
public string Folder { get; set; }
[Validator(typeof(Validator))]
public sealed class TranslateArguments : IArgumentModel
{
[Operand(Name = "folder", Description = "The squidex folder.")]
public string Folder { get; set; }
[Option(LongName = "single", ShortName = "s", Description = "Single words only.")]
public bool SingleWords { get; set; }
[Option(LongName = "single", ShortName = "s", Description = "Single words only.")]
public bool SingleWords { get; set; }
[Option(LongName = "report", ShortName = "r")]
public bool Report { get; set; }
[Option(LongName = "report", ShortName = "r")]
public bool Report { get; set; }
[Option(LongName = "locale", ShortName = "l")]
public IEnumerable<string> Locales { get; set; }
[Option(LongName = "locale", ShortName = "l")]
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.
// ==========================================================================
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)
{
return text.Contains(' ', StringComparison.Ordinal) && text.IsPotentialText();
}
public static bool IsPotentialMultiWordText(this string text)
{
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.
// ==========================================================================
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 =
folder.GetFiles(@"*.cs", SearchOption.AllDirectories).Union(
folder.GetFiles(@"*.cshtml", 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);
if (relativeName.Contains("/obj/", StringComparison.Ordinal) ||
relativeName.Contains("/bin/", StringComparison.Ordinal))
{
continue;
}
yield return (file, relativeName);
continue;
}
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);
if (relativeName.Contains("/obj/", StringComparison.Ordinal) ||
relativeName.Contains("/bin/", StringComparison.Ordinal))
{
continue;
}
yield return (file, relativeName);
continue;
}
yield return (file, relativeName);
}
}
}

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

@ -8,54 +8,53 @@
using System.Text.RegularExpressions;
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;
private readonly DirectoryInfo folder;
this.folder = Backend.GetFolder(folder);
public CheckBackend(DirectoryInfo folder, TranslationService service)
{
this.folder = Backend.GetFolder(folder);
this.service = service;
}
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 content = File.ReadAllText(file.FullName);
var translations = new HashSet<string>();
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);
foreach (Match match in matches)
{
var key = match.Groups["Key"].Value;
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);
Helper.CheckOtherLocales(service);
AddTranslations("T\\.Get\\(\"(?<Key>[^\"]*)\"");
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 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;
private readonly DirectoryInfo folder;
this.folder = Frontend.GetFolder(folder);
public CheckFrontend(DirectoryInfo folder, TranslationService service)
{
this.folder = Frontend.GetFolder(folder);
this.service = service;
}
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)
{
all.Add(translation);
}
Helper.CheckForFile(service, relativeName, translations);
}
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)
{
all.Add(translation);
}
Helper.CheckForFile(service, relativeName, translations);
}
Helper.CheckForFile(service, relativeName, translations);
}
Helper.CheckUnused(service, all);
Helper.CheckOtherLocales(service);
Helper.CheckUnused(service, all);
Helper.CheckOtherLocales(service);
service.Save();
}
service.Save();
}
private static HashSet<string> GetTranslationsInTemplate(FileInfo file)
{
var content = File.ReadAllText(file.FullName);
private static HashSet<string> GetTranslationsInTemplate(FileInfo file)
{
var content = File.ReadAllText(file.FullName);
var translations = new HashSet<string>();
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);
foreach (Match match in matches)
{
translations.Add(match.Groups["Key"].Value);
}
translations.Add(match.Groups["Key"].Value);
}
}
AddTranslations("\"i18n\\:(?<Key>[^\"]+)\"");
AddTranslations("'i18n\\:(?<Key>[^\']+)'");
AddTranslations("'(?<Key>[^\']+)' \\| sqxTranslate");
AddTranslations("\"i18n\\:(?<Key>[^\"]+)\"");
AddTranslations("'i18n\\:(?<Key>[^\']+)'");
AddTranslations("'(?<Key>[^\']+)' \\| sqxTranslate");
return translations;
}
return translations;
}
private static HashSet<string> GetTranslationsInTypescript(FileInfo file)
{
var content = File.ReadAllText(file.FullName);
private static HashSet<string> GetTranslationsInTypescript(FileInfo file)
{
var content = File.ReadAllText(file.FullName);
var translations = new HashSet<string>();
var translations = new HashSet<string>();
void AddTranslations(string regex)
{
var matches = Regex.Matches(content, regex, RegexOptions.Singleline | RegexOptions.ExplicitCapture);
void AddTranslations(string regex)
{
var matches = Regex.Matches(content, regex, RegexOptions.Singleline | RegexOptions.ExplicitCapture);
foreach (Match match in matches)
{
translations.Add(match.Groups["Key"].Value);
}
foreach (Match match in matches)
{
translations.Add(match.Groups["Key"].Value);
}
}
AddTranslations("'i18n\\:(?<Key>[^\']+)'");
AddTranslations("localizer.get\\('(?<Key>[^\']+)'\\)");
AddTranslations("localizer.getOrKey\\('(?<Key>[^\']+)'\\)");
AddTranslations("'i18n\\:(?<Key>[^\']+)'");
AddTranslations("localizer.get\\('(?<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.
// ==========================================================================
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)
{
var files = folder.GetFiles(@"*.ts", SearchOption.AllDirectories);
public static IEnumerable<(FileInfo, string)> GetTypescriptFiles(DirectoryInfo folder)
{
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;
}
yield return (file, Helper.RelativeName(file, folder));
continue;
}
yield return (file, Helper.RelativeName(file, folder));
}
}
public static IEnumerable<(FileInfo, string)> GetTemplateFiles(DirectoryInfo folder)
{
var files = folder.GetFiles(@"*.html", SearchOption.AllDirectories);
public static IEnumerable<(FileInfo, string)> GetTemplateFiles(DirectoryInfo folder)
{
var files = folder.GetFiles(@"*.html", SearchOption.AllDirectories);
foreach (var file in files)
{
yield return (file, Helper.RelativeName(file, folder));
}
foreach (var file in files)
{
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 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;
private readonly DirectoryInfo folder;
this.folder = new DirectoryInfo(Path.Combine(folder.FullName, "backend", "src", "Squidex.Shared"));
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()
{
foreach (var locale in service.SupportedLocales)
using (var writer = new ResXResourceWriter(fullName))
{
var name = locale ==
service.MainLocale ?
$"Texts.resx" :
$"Texts.{locale}.resx";
var texts = service.GetTextsWithFallback(locale);
var fullName = Path.Combine(folder.FullName, name);
using (var writer = new ResXResourceWriter(fullName))
foreach (var (key, value) in texts)
{
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);
if (key.StartsWith("annotations_", StringComparison.OrdinalIgnoreCase))
{
var i = 0;
var i = 0;
var dotnetKey = $"dotnet_{key}";
var dotnetValue = Regex.Replace(value, "{[^}]*}", m => $"{{{i++}}}");
var dotnetKey = $"dotnet_{key}";
var dotnetValue = Regex.Replace(value, "{[^}]*}", m => $"{{{i++}}}");
writer.AddResource(dotnetKey, dotnetValue);
}
writer.AddResource(dotnetKey, dotnetValue);
}
}
}
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);
var text = File.ReadAllText(fullName);
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;
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)
{
this.folder = new DirectoryInfo(Path.Combine(folder.FullName, "backend", "i18n"));
public GenerateFrontendResources(DirectoryInfo folder, TranslationService service)
{
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");
if (!folder.Exists)
{
Directory.CreateDirectory(folder.FullName);
}
var texts = service.GetTextsWithFallback(locale);
var fullName = Path.Combine(folder.FullName, $"frontend_{locale}.json");
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;
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;
private readonly string fileName;
private readonly DirectoryInfo folder;
this.folder = folder;
this.service = service;
this.fileName = fileName;
}
public GenerateKeys(DirectoryInfo folder, TranslationService service, string fileName)
{
this.folder = folder;
this.service = service;
this.fileName = fileName;
}
public void Run()
{
var keys = new TranslatedTexts();
public void Run()
foreach (var text in service.MainTranslations)
{
var keys = new TranslatedTexts();
foreach (var text in service.MainTranslations)
{
keys.Add(text.Key, string.Empty);
}
keys.Add(text.Key, string.Empty);
}
var fullName = Path.Combine(folder.FullName, fileName);
var fullName = Path.Combine(folder.FullName, fileName);
if (!folder.Exists)
{
Directory.CreateDirectory(folder.FullName);
}
if (!folder.Exists)
{
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;
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)
{
var mainTranslations = service.MainTranslations;
public static void CheckOtherLocales(TranslationService service)
{
var mainTranslations = service.MainTranslations;
foreach (var (locale, texts) in service.Translations.Where(x => x.Key != service.MainLocale))
{
Console.WriteLine();
Console.WriteLine("----- CHECKING <{0}> -----", locale);
foreach (var (locale, texts) in service.Translations.Where(x => x.Key != service.MainLocale))
{
Console.WriteLine();
Console.WriteLine("----- CHECKING <{0}> -----", locale);
var notTranslated = mainTranslations.Keys.Except(texts.Keys).ToList();
var notUsing = texts.Keys.Except(mainTranslations.Keys).ToList();
var notTranslated = mainTranslations.Keys.Except(texts.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:");
foreach (var key in notTranslated.OrderBy(x => x))
{
Console.Write(" * ");
Console.WriteLine(key);
}
}
Console.WriteLine();
Console.WriteLine("Translations missing:");
if (notUsing.Count > 0)
foreach (var key in notTranslated.OrderBy(x => x))
{
Console.WriteLine();
Console.WriteLine("Translations not used:");
foreach (var key in notUsing.OrderBy(x => x))
{
Console.Write(" * ");
Console.WriteLine(key);
}
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();
Console.WriteLine("----- CLEANING <{0}> -----", locale);
foreach (var unused in notUsed)
{
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)
{
foreach (var unused in notUsed)
{
texts.Remove(unused);
}
public static void CheckUnused(TranslationService service, HashSet<string> translations)
{
var notUsing = new SortedSet<string>();
Console.WriteLine("Cleaned {0} translations.", notUsed.Count);
}
else
{
Console.WriteLine("> No errors found");
}
foreach (var key in service.MainTranslations.Keys)
{
if (!translations.Contains(key) &&
!key.StartsWith("common.", StringComparison.OrdinalIgnoreCase) &&
!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) &&
!key.StartsWith("common.", StringComparison.OrdinalIgnoreCase) &&
!key.StartsWith("dotnet_", StringComparison.OrdinalIgnoreCase) &&
!key.StartsWith("validation.", StringComparison.OrdinalIgnoreCase) &&
!key.StartsWith("rules.simulation.error", StringComparison.OrdinalIgnoreCase))
{
notUsing.Add(key);
}
Console.Write(" * ");
Console.WriteLine(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(" * ");
Console.WriteLine(key);
prefixes.Add(parts[0]);
}
}
}
public static void CheckForFile(TranslationService service, string relativeName, HashSet<string> translations)
{
if (translations.Count > 0)
if (HasInvalidPrefixes(prefixes) || 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))
{
translations.Remove(key);
}
var parts = key.Split(".");
if (parts.Length > 1 && parts[0] != "common" && parts[0] != "validation")
{
prefixes.Add(parts[0]);
}
Console.WriteLine(" > Multiple prefixes found: {0}", string.Join(",", prefixes));
}
if (HasInvalidPrefixes(prefixes) || translations.Count > 0)
if (translations.Count > 0)
{
Console.WriteLine("Errors in file {0}.", relativeName);
if (HasInvalidPrefixes(prefixes))
{
Console.WriteLine(" > Multiple prefixes found: {0}", string.Join(",", prefixes));
}
if (translations.Count > 0)
foreach (var key in translations)
{
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)
{
return prefixes.Count > 1;
}
private static bool HasInvalidPrefixes(HashSet<string> prefixes)
{
return prefixes.Count > 1;
}
}

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

@ -8,54 +8,53 @@
using System.Text.RegularExpressions;
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;
private readonly DirectoryInfo folder;
this.folder = Backend.GetFolder(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()
{
foreach (var (file, relativeName) in Backend.GetFilesCS(folder))
content = Regex.Replace(content, "\"[^\"]*\"", match =>
{
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];
string result = null;
if (value.IsPotentialMultiWordText())
service.Translate(relativeName, value, "Code", key =>
{
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)
{
Console.WriteLine("-----------------------------");
Console.WriteLine("FILE {0} done", relativeName);
if (isReplaced)
{
Console.WriteLine("-----------------------------");
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