Browse Source

Flows (#1217)

pull/1218/head
Sebastian Stehle 8 months ago
committed by GitHub
parent
commit
75a079bbcb
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      .github/workflows/dev.yml
  2. 2
      .github/workflows/release.yml
  3. 3
      Dockerfile
  4. 33
      backend/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaAction.cs
  5. 150
      backend/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaActionHandler.cs
  6. 159
      backend/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaFlowStep.cs
  7. 5
      backend/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaPlugin.cs
  8. 31
      backend/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueAction.cs
  9. 73
      backend/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueActionHandler.cs
  10. 92
      backend/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueFlowStep.cs
  11. 5
      backend/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueuePlugin.cs
  12. 23
      backend/extensions/Squidex.Extensions/Actions/Comment/CommentAction.cs
  13. 67
      backend/extensions/Squidex.Extensions/Actions/Comment/CommentActionHandler.cs
  14. 87
      backend/extensions/Squidex.Extensions/Actions/Comment/CommentFlowStep.cs
  15. 5
      backend/extensions/Squidex.Extensions/Actions/Comment/CommentPlugin.cs
  16. 29
      backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentAction.cs
  17. 67
      backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentActionHandler.cs
  18. 111
      backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentFlowStep.cs
  19. 5
      backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentPlugin.cs
  20. 22
      backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectAction.cs
  21. 183
      backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectActionHandler.cs
  22. 188
      backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectFlowStep.cs
  23. 5
      backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectPlugin.cs
  24. 35
      backend/extensions/Squidex.Extensions/Actions/Discourse/DiscourseAction.cs
  25. 86
      backend/extensions/Squidex.Extensions/Actions/Discourse/DiscourseActionHandler.cs
  26. 127
      backend/extensions/Squidex.Extensions/Actions/Discourse/DiscourseFlowStep.cs
  27. 5
      backend/extensions/Squidex.Extensions/Actions/Discourse/DiscoursePlugin.cs
  28. 33
      backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchAction.cs
  29. 157
      backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchActionHandler.cs
  30. 174
      backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchFlowStep.cs
  31. 5
      backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchPlugin.cs
  32. 48
      backend/extensions/Squidex.Extensions/Actions/Email/EmailAction.cs
  33. 90
      backend/extensions/Squidex.Extensions/Actions/Email/EmailActionHandler.cs
  34. 118
      backend/extensions/Squidex.Extensions/Actions/Email/EmailFlowStep.cs
  35. 5
      backend/extensions/Squidex.Extensions/Actions/Email/EmailPlugin.cs
  36. 23
      backend/extensions/Squidex.Extensions/Actions/Fastly/FastlyAction.cs
  37. 60
      backend/extensions/Squidex.Extensions/Actions/Fastly/FastlyActionHandler.cs
  38. 78
      backend/extensions/Squidex.Extensions/Actions/Fastly/FastlyFlowStep.cs
  39. 5
      backend/extensions/Squidex.Extensions/Actions/Fastly/FastlyPlugin.cs
  40. 44
      backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaAction.cs
  41. 117
      backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaActionHandler.cs
  42. 138
      backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaFlowStep.cs
  43. 27
      backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaMessageRequest.cs
  44. 5
      backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaPlugin.cs
  45. 4
      backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaProducer.cs
  46. 37
      backend/extensions/Squidex.Extensions/Actions/Medium/MediumAction.cs
  47. 136
      backend/extensions/Squidex.Extensions/Actions/Medium/MediumActionHandler.cs
  48. 165
      backend/extensions/Squidex.Extensions/Actions/Medium/MediumFlowStep.cs
  49. 5
      backend/extensions/Squidex.Extensions/Actions/Medium/MediumPlugin.cs
  50. 28
      backend/extensions/Squidex.Extensions/Actions/Notification/NotificationAction.cs
  51. 72
      backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs
  52. 97
      backend/extensions/Squidex.Extensions/Actions/Notification/NotificationFlowStep.cs
  53. 5
      backend/extensions/Squidex.Extensions/Actions/Notification/NotificationPlugin.cs
  54. 33
      backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchAction.cs
  55. 157
      backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchActionHandler.cs
  56. 174
      backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchFlowStep.cs
  57. 5
      backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchPlugin.cs
  58. 24
      backend/extensions/Squidex.Extensions/Actions/Prerender/PrerenderAction.cs
  59. 45
      backend/extensions/Squidex.Extensions/Actions/Prerender/PrerenderActionHandler.cs
  60. 74
      backend/extensions/Squidex.Extensions/Actions/Prerender/PrerenderFlowStep.cs
  61. 5
      backend/extensions/Squidex.Extensions/Actions/Prerender/PrerenderPlugin.cs
  62. 44
      backend/extensions/Squidex.Extensions/Actions/RuleHelper.cs
  63. 21
      backend/extensions/Squidex.Extensions/Actions/Script/ScriptAction.cs
  64. 65
      backend/extensions/Squidex.Extensions/Actions/Script/ScriptActionHandler.cs
  65. 46
      backend/extensions/Squidex.Extensions/Actions/Script/ScriptFlowStep.cs
  66. 5
      backend/extensions/Squidex.Extensions/Actions/Script/ScriptPlugin.cs
  67. 54
      backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRAction.cs
  68. 103
      backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRActionHandler.cs
  69. 8
      backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRActionType.cs
  70. 132
      backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRFlowStep.cs
  71. 5
      backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRPlugin.cs
  72. 27
      backend/extensions/Squidex.Extensions/Actions/Slack/SlackAction.cs
  73. 52
      backend/extensions/Squidex.Extensions/Actions/Slack/SlackActionHandler.cs
  74. 76
      backend/extensions/Squidex.Extensions/Actions/Slack/SlackFlowStep.cs
  75. 6
      backend/extensions/Squidex.Extensions/Actions/Slack/SlackPlugin.cs
  76. 26
      backend/extensions/Squidex.Extensions/Actions/Twitter/TweetAction.cs
  77. 62
      backend/extensions/Squidex.Extensions/Actions/Twitter/TweetActionHandler.cs
  78. 79
      backend/extensions/Squidex.Extensions/Actions/Twitter/TweetFlowStep.cs
  79. 5
      backend/extensions/Squidex.Extensions/Actions/Twitter/TwitterPlugin.cs
  80. 32
      backend/extensions/Squidex.Extensions/Actions/Typesense/TypesenseAction.cs
  81. 138
      backend/extensions/Squidex.Extensions/Actions/Typesense/TypesenseActionHandler.cs
  82. 155
      backend/extensions/Squidex.Extensions/Actions/Typesense/TypesenseFlowStep.cs
  83. 6
      backend/extensions/Squidex.Extensions/Actions/Typesense/TypesensePlugin.cs
  84. 42
      backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs
  85. 144
      backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs
  86. 146
      backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookFlowStep.cs
  87. 11
      backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookMethod.cs
  88. 11
      backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookPlugin.cs
  89. 1
      backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj
  90. 5
      backend/i18n/clean.bat
  91. 7
      backend/i18n/clean.ps1
  92. 7
      backend/i18n/clean.sh
  93. 3
      backend/i18n/extract_keys.bat
  94. 5
      backend/i18n/extract_keys.ps1
  95. 5
      backend/i18n/extract_keys.sh
  96. 32
      backend/i18n/frontend_en.json
  97. 30
      backend/i18n/frontend_fr.json
  98. 30
      backend/i18n/frontend_it.json
  99. 30
      backend/i18n/frontend_nl.json
  100. 30
      backend/i18n/frontend_pt.json

2
.github/workflows/dev.yml

@ -42,7 +42,7 @@ jobs:
dotnet-version: 8.0.x
- name: Test - TestContainers
run: dotnet test backend/Squidex.sln --filter Category=TestContainers
run: dotnet test backend/Squidex.sln --filter Category=TestContainer
test-mongo:
runs-on: ubuntu-latest

2
.github/workflows/release.yml

@ -37,7 +37,7 @@ jobs:
dotnet-version: 8.0.x
- name: Test - TestContainers
run: dotnet test backend/Squidex.sln --filter Category=TestContainers
run: dotnet test backend/Squidex.sln --filter Category=TestContainer
test-mongo:
runs-on: ubuntu-latest

3
Dockerfile

@ -70,7 +70,8 @@ RUN npm install --loglevel=error --force
COPY frontend .
# Build Frontend
RUN npm run test:coverage \
RUN npm run lint \
&& npm run test:coverage \
&& npm run build
RUN cp -a build /build/

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

@ -5,46 +5,31 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
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/")]
[Obsolete("Has been replaced by flows.")]
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 = "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; }
[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; }
public override FlowStep ToFlowStep()
{
return SimpleMapper.Map(this, new AlgoliaFlowStep());
}
}

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

@ -1,150 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text.Json.Serialization;
using Algolia.Search.Clients;
using Newtonsoft.Json.Linq;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Scripting;
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;
public sealed class AlgoliaActionHandler(RuleEventFormatter formatter, IScriptEngine scriptEngine, IJsonSerializer serializer) : RuleActionHandler<AlgoliaAction, AlgoliaJob>(formatter)
{
private readonly ClientPool<(string AppId, string ApiKey, string IndexName), ISearchIndex> clients = new ClientPool<(string AppId, string ApiKey, string IndexName), ISearchIndex>(key =>
{
var client = new SearchClient(key.AppId, key.ApiKey);
return client.InitIndex(key.IndexName);
});
protected override async Task<(string Description, AlgoliaJob Data)> CreateJobAsync(EnrichedEvent @event, AlgoliaAction action)
{
if (@event is not IEnrichedEntityEvent entityEvent)
{
return ("Ignore", new AlgoliaJob());
}
var delete = @event.ShouldDelete(scriptEngine, action.Delete);
var ruleDescription = string.Empty;
var contentId = entityEvent.Id.ToString();
var content = (AlgoliaContent?)null;
var indexName = (await FormatAsync(action.IndexName, @event))!;
if (delete)
{
ruleDescription = $"Delete entry from Algolia index: {indexName}";
}
else
{
ruleDescription = $"Add entry to Algolia index: {indexName}";
try
{
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!);
}
catch (Exception ex)
{
content = new AlgoliaContent
{
More = new Dictionary<string, object>
{
["error"] = $"Invalid JSON: {ex.Message}",
},
};
}
content.ObjectID = contentId;
}
var ruleJob = new AlgoliaJob
{
AppId = action.AppId,
ApiKey = action.ApiKey,
Content = delete ? null : serializer.Serialize(content, true),
ContentId = contentId,
IndexName = indexName,
};
return (ruleDescription, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(AlgoliaJob job,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(job.AppId))
{
return Result.Ignored();
}
var index = await clients.GetClientAsync((job.AppId, job.ApiKey, job.IndexName));
try
{
if (job.Content != null)
{
var raw = new[]
{
new JRaw(job.Content),
};
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));
}
}
catch (Exception ex)
{
return Result.Failed(ex);
}
}
}
public sealed class AlgoliaContent
{
[JsonPropertyName("objectID")]
public string ObjectID { get; set; }
[JsonExtensionData]
public Dictionary<string, object> More { get; set; } = [];
}
public sealed class AlgoliaJob
{
public string AppId { get; set; }
public string ApiKey { get; set; }
public string ContentId { get; set; }
public string IndexName { get; set; }
public string? Content { get; set; }
}

159
backend/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaFlowStep.cs

@ -0,0 +1,159 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Algolia.Search.Clients;
using Newtonsoft.Json.Linq;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Algolia;
[FlowStep(
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/")]
#pragma warning disable CS0618 // Type or member is obsolete
public record AlgoliaFlowStep : FlowStep, IConvertibleToAction
#pragma warning restore CS0618 // Type or member is obsolete
{
private static readonly ClientPool<(string AppId, string ApiKey, string IndexName), ISearchIndex> Clients = new ClientPool<(string AppId, string ApiKey, string IndexName), ISearchIndex>(key =>
{
var client = new SearchClient(key.AppId, key.ApiKey);
return client.InitIndex(key.IndexName);
});
[LocalizedRequired]
[Display(Name = "Application Id", Description = "The application ID.")]
[Editor(FlowStepEditor.Text)]
[Expression]
public string AppId { get; set; }
[LocalizedRequired]
[Display(Name = "Api Key", Description = "The API key to grant access to Squidex.")]
[Editor(FlowStepEditor.Text)]
[Expression]
public string ApiKey { get; set; }
[LocalizedRequired]
[Display(Name = "Index Name", Description = "The name of the index.")]
[Editor(FlowStepEditor.Text)]
[Expression]
public string IndexName { get; set; }
[Display(Name = "Document", Description = "The optional custom document.")]
[Editor(FlowStepEditor.TextArea)]
[Expression(ExpressionFallback.Event)]
public string? Document { get; set; }
[Display(Name = "Deletion", Description = "The condition when to delete the entry.")]
[Editor(FlowStepEditor.Text)]
public string? Delete { get; set; }
public override ValueTask PrepareAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
var @event = ((FlowEventContext)executionContext.Context).Event;
if (!@event.ShouldDelete(executionContext, Delete))
{
Document = null;
return default;
}
AlgoliaContent content;
try
{
content = executionContext.DeserializeJson<AlgoliaContent>(Document!);
content.ObjectID = @event.GetOrCreateId().Id;
}
catch (Exception ex)
{
content = new AlgoliaContent
{
More = new Dictionary<string, object>
{
["error"] = $"Invalid JSON: {ex.Message}",
},
ObjectID = @event.GetOrCreateId().Id,
};
}
Document = executionContext.SerializeJson(content);
return default;
}
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
var @event = ((FlowEventContext)executionContext.Context).Event;
var (id, isGenerated) = @event.GetOrCreateId();
if (isGenerated && Document == null)
{
executionContext.LogSkipped("Can only delete content for static identities.");
return Next();
}
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
try
{
var serializer = executionContext.Resolve<IJsonSerializer>();
var index = await Clients.GetClientAsync((AppId, ApiKey, IndexName));
if (Document != null)
{
var response = await index.SaveObjectsAsync([new JRaw(Document)], null, ct, true);
executionContext.Log($"Document with ID '{id}' upserted", serializer.Serialize(response, true));
}
else
{
var response = await index.DeleteObjectAsync(id, null, ct);
executionContext.Log($"Document with ID '{id}' deleted", serializer.Serialize(response, true));
}
return Next();
}
catch (Exception ex)
{
executionContext.Log("Failed with error", ex.Message);
throw;
}
}
#pragma warning disable CS0618 // Type or member is obsolete
public RuleAction ToAction()
{
return SimpleMapper.Map(this, new AlgoliaAction());
}
#pragma warning restore CS0618 // Type or member is obsolete
private sealed class AlgoliaContent
{
[JsonPropertyName("objectID")]
public string ObjectID { get; set; }
[JsonExtensionData]
public Dictionary<string, object> More { get; set; } = [];
}
}

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

@ -15,6 +15,9 @@ public sealed class AlgoliaPlugin : IPlugin
{
public void ConfigureServices(IServiceCollection services, IConfiguration config)
{
services.AddRuleAction<AlgoliaAction, AlgoliaActionHandler>();
services.AddFlowStep<AlgoliaFlowStep>();
#pragma warning disable CS0618 // Type or member is obsolete
services.AddRuleAction<AlgoliaAction>();
#pragma warning restore CS0618 // Type or member is obsolete
}
}

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

@ -5,45 +5,26 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
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/")]
[Obsolete("Has been replaced by flows.")]
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 = "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; }
protected override IEnumerable<ValidationError> CustomValidate()
public override FlowStep ToFlowStep()
{
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));
}
return SimpleMapper.Map(this, new AzureQueueFlowStep());
}
}

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

@ -1,73 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Queue;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.AzureQueue;
public sealed class AzureQueueActionHandler(RuleEventFormatter formatter) : RuleActionHandler<AzureQueueAction, AzureQueueJob>(formatter)
{
private readonly ClientPool<(string ConnectionString, string QueueName), CloudQueue> clients = new ClientPool<(string ConnectionString, string QueueName), CloudQueue>(key =>
{
var storageAccount = CloudStorageAccount.Parse(key.ConnectionString);
var queueClient = storageAccount.CreateCloudQueueClient();
var queueRef = queueClient.GetQueueReference(key.QueueName);
return queueRef;
});
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))
{
requestBody = await FormatAsync(action.Payload, @event);
}
else
{
requestBody = ToEnvelopeJson(@event);
}
var ruleText = $"Send AzureQueueJob to azure queue '{queueName}'";
var ruleJob = new AzureQueueJob
{
QueueConnectionString = action.ConnectionString,
QueueName = queueName!,
MessageBodyV2 = requestBody,
};
return (ruleText, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(AzureQueueJob job,
CancellationToken ct = default)
{
var queue = await clients.GetClientAsync((job.QueueConnectionString, job.QueueName));
await queue.AddMessageAsync(new CloudQueueMessage(job.MessageBodyV2), null, null, null, null, ct);
return Result.Complete();
}
}
public sealed class AzureQueueJob
{
public string QueueConnectionString { get; set; }
public string QueueName { get; set; }
public string? MessageBodyV2 { get; set; }
}

92
backend/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueFlowStep.cs

@ -0,0 +1,92 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Queue;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.AzureQueue;
[FlowStep(
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/")]
#pragma warning disable CS0618 // Type or member is obsolete
public sealed partial record AzureQueueFlowStep : FlowStep, IConvertibleToAction
#pragma warning restore CS0618 // Type or member is obsolete
{
[LocalizedRequired]
[Display(Name = "Connection", Description = "The connection string to the storage account.")]
[Editor(FlowStepEditor.Text)]
[Expression]
public string ConnectionString { get; set; }
[LocalizedRequired]
[Display(Name = "Queue", Description = "The name of the queue.")]
[Editor(FlowStepEditor.Text)]
[Expression]
public string Queue { get; set; }
[Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")]
[Editor(FlowStepEditor.TextArea)]
[Expression(ExpressionFallback.Envelope)]
public string? Payload { get; set; }
private static readonly ClientPool<(string ConnectionString, string QueueName), CloudQueue> Clients = new ClientPool<(string ConnectionString, string QueueName), CloudQueue>(key =>
{
var storageAccount = CloudStorageAccount.Parse(key.ConnectionString);
var queueClient = storageAccount.CreateCloudQueueClient();
var queueRef = queueClient.GetQueueReference(key.QueueName);
return queueRef;
});
public override ValueTask ValidateAsync(FlowValidationContext validationContext, AddStepError addError,
CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(Queue) && !QueueNameRegex().IsMatch(Queue))
{
addError(nameof(Queue), "Queue must be valid azure queue name.");
}
return base.ValidateAsync(validationContext, addError, ct);
}
public async override ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
var queue = await Clients.GetClientAsync((ConnectionString, Queue));
await queue.AddMessageAsync(new CloudQueueMessage(Payload), null, null, null, null, ct);
return Next();
}
#pragma warning disable CS0618 // Type or member is obsolete
public RuleAction ToAction()
{
return SimpleMapper.Map(this, new AzureQueueAction());
}
#pragma warning restore CS0618 // Type or member is obsolete
[GeneratedRegex("^[a-z][a-z0-9]{2,}(\\-[a-z0-9]+)*$")]
private static partial Regex QueueNameRegex();
}

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

@ -15,6 +15,9 @@ public sealed class AzureQueuePlugin : IPlugin
{
public void ConfigureServices(IServiceCollection services, IConfiguration config)
{
services.AddRuleAction<AzureQueueAction, AzureQueueActionHandler>();
services.AddFlowStep<AzureQueueFlowStep>();
#pragma warning disable CS0618 // Type or member is obsolete
services.AddRuleAction<AzureQueueAction>();
#pragma warning restore CS0618 // Type or member is obsolete
}
}

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

@ -5,28 +5,23 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
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.")]
[Obsolete("Has been replaced by flows.")]
public sealed record CommentAction : RuleAction
{
[LocalizedRequired]
[Display(Name = "Text", Description = "The comment text.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Text { get; set; }
[Display(Name = "Client", Description = "An optional client name.")]
[Editor(RuleFieldEditor.Text)]
public string? Client { get; set; }
public override FlowStep ToFlowStep()
{
return SimpleMapper.Map(this, new CommentFlowStep());
}
}

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

@ -1,67 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Entities.Collaboration;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure;
namespace Squidex.Extensions.Actions.Comment;
public sealed class CommentActionHandler(RuleEventFormatter formatter, ICollaborationService collaboration) : RuleActionHandler<CommentAction, CommentCreated>(formatter)
{
private const string Description = "Send a Comment";
protected override async Task<(string Description, CommentCreated Data)> CreateJobAsync(EnrichedEvent @event, CommentAction action)
{
if (@event is not EnrichedContentEvent contentEvent)
{
return ("Ignore", new CommentCreated());
}
var ruleJob = new CommentCreated
{
AppId = contentEvent.AppId,
};
var text = await FormatAsync(action.Text, @event);
if (string.IsNullOrWhiteSpace(text))
{
return ("NoText", new CommentCreated());
}
ruleJob.Text = text;
if (!string.IsNullOrEmpty(action.Client))
{
ruleJob.Actor = RefToken.Client(action.Client);
}
else
{
ruleJob.Actor = contentEvent.Actor;
}
ruleJob.CommentsId = contentEvent.Id;
return (Description, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(CommentCreated job,
CancellationToken ct = default)
{
if (job.CommentsId == default)
{
return Result.Ignored();
}
await collaboration.CommentAsync(job.AppId, job.CommentsId, job.Text, job.Actor, job.Url, true, ct);
return Result.Success($"Commented: {job.Text}");
}
}

87
backend/extensions/Squidex.Extensions/Actions/Comment/CommentFlowStep.cs

@ -0,0 +1,87 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Entities.Collaboration;
using Squidex.Flows;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Comment;
[FlowStep(
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.")]
#pragma warning disable CS0618 // Type or member is obsolete
public sealed record CommentFlowStep : FlowStep, IConvertibleToAction
#pragma warning restore CS0618 // Type or member is obsolete
{
[LocalizedRequired]
[Display(Name = "Text", Description = "The comment text.")]
[Editor(FlowStepEditor.TextArea)]
[Expression]
public string Text { get; set; }
[Display(Name = "Client", Description = "An optional client name.")]
[Editor(FlowStepEditor.Text)]
public string? Client { get; set; }
public async override ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
var @event = ((FlowEventContext)executionContext.Context).Event;
if (@event is not EnrichedContentEvent contentEvent)
{
executionContext.LogSkipped("Invalid event.");
return Next();
}
if (string.IsNullOrWhiteSpace(Text))
{
executionContext.LogSkipped("No text.");
return Next();
}
RefToken actor;
if (!string.IsNullOrEmpty(Client))
{
actor = RefToken.Client(Client);
}
else
{
actor = contentEvent.Actor;
}
var collaboration = executionContext.Resolve<ICollaborationService>();
await collaboration.CommentAsync(
contentEvent.AppId,
contentEvent.Id,
Text,
actor,
null,
true,
ct);
executionContext.Log("Commented", Text);
return Next();
}
#pragma warning disable CS0618 // Type or member is obsolete
public RuleAction ToAction()
{
return SimpleMapper.Map(this, new CommentAction());
}
#pragma warning restore CS0618 // Type or member is obsolete
}

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

@ -15,6 +15,9 @@ public sealed class CommentPlugin : IPlugin
{
public void ConfigureServices(IServiceCollection services, IConfiguration config)
{
services.AddRuleAction<CommentAction, CommentActionHandler>();
services.AddFlowStep<CommentFlowStep>();
#pragma warning disable CS0618 // Type or member is obsolete
services.AddRuleAction<CommentAction>();
#pragma warning restore CS0618 // Type or member is obsolete
}
}

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

@ -5,37 +5,28 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
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.")]
[Obsolete("Has been replaced by flows.")]
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 = "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; }
public string? Client { get; set; }
[Display(Name = "Publish", Description = "Publish the content.")]
[Editor(RuleFieldEditor.Text)]
public bool Publish { get; set; }
public override FlowStep ToFlowStep()
{
return SimpleMapper.Map(this, new CreateContentFlowStep());
}
}

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

@ -1,67 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Json;
using Command = Squidex.Domain.Apps.Entities.Contents.Commands.CreateContent;
namespace Squidex.Extensions.Actions.CreateContent;
public sealed class CreateContentActionHandler(RuleEventFormatter formatter, IAppProvider appProvider, ICommandBus commandBus, IJsonSerializer jsonSerializer) : RuleActionHandler<CreateContentAction, Command>(formatter)
{
private const string Description = "Create a content";
protected override async Task<(string Description, Command Data)> CreateJobAsync(EnrichedEvent @event, CreateContentAction action)
{
var ruleJob = new Command
{
AppId = @event.AppId,
};
var schema = await appProvider.GetSchemaAsync(@event.AppId.Id, action.Schema, true)
?? throw new InvalidOperationException($"Cannot find schema '{action.Schema}'");
ruleJob.SchemaId = schema.NamedId();
ruleJob.FromRule = true;
var json = await FormatAsync(action.Data, @event);
ruleJob.Data = jsonSerializer.Deserialize<ContentData>(json!);
if (!string.IsNullOrEmpty(action.Client))
{
ruleJob.Actor = RefToken.Client(action.Client);
}
else if (@event is EnrichedUserEventBase userEvent)
{
ruleJob.Actor = userEvent.Actor;
}
if (action.Publish)
{
ruleJob.Status = Status.Published;
}
return (Description, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(Command job,
CancellationToken ct = default)
{
var command = job;
await commandBus.PublishAsync(command, ct);
return Result.Success($"Created to: {command.SchemaId.Name}");
}
}

111
backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentFlowStep.cs

@ -0,0 +1,111 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities;
using Squidex.Flows;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
using Command = Squidex.Domain.Apps.Entities.Contents.Commands.CreateContent;
namespace Squidex.Extensions.Actions.CreateContent;
[FlowStep(
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.")]
#pragma warning disable CS0618 // Type or member is obsolete
public sealed record CreateContentFlowStep : FlowStep, IConvertibleToAction
#pragma warning restore CS0618 // Type or member is obsolete
{
[LocalizedRequired]
[Display(Name = "Data", Description = "The content data.")]
[Editor(FlowStepEditor.TextArea)]
[Expression]
public string Data { get; set; }
[LocalizedRequired]
[Display(Name = "Schema", Description = "The name of the schema.")]
[Editor(FlowStepEditor.Text)]
public string Schema { get; set; }
[Display(Name = "Client", Description = "An optional client name.")]
[Editor(FlowStepEditor.Text)]
public string? Client { get; set; }
[Display(Name = "Publish", Description = "Publish the content.")]
[Editor(FlowStepEditor.Text)]
public bool Publish { get; set; }
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
var @event = ((FlowEventContext)executionContext.Context).Event;
var command = new Command
{
AppId = @event.AppId,
};
var schema =
await executionContext.Resolve<IAppProvider>()
.GetSchemaAsync(@event.AppId.Id, Schema, true, ct);
if (schema == null)
{
executionContext.LogSkipped("Invalid Schema.");
return Next();
}
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
command.SchemaId = schema.NamedId();
command.FromRule = true;
command.Data = executionContext.DeserializeJson<ContentData>(Data);
if (!string.IsNullOrEmpty(Client))
{
command.Actor = RefToken.Client(Client);
}
else if (@event is EnrichedUserEventBase userEvent)
{
command.Actor = userEvent.Actor;
}
if (Publish)
{
command.Status = Status.Published;
}
await executionContext.Resolve<ICommandBus>()
.PublishAsync(command, ct);
executionContext.Log($"Content created for schema '{schema.Name}', ID: {command.ContentId}");
return Next();
}
#pragma warning disable CS0618 // Type or member is obsolete
public RuleAction ToAction()
{
return SimpleMapper.Map(this, new CreateContentAction());
}
#pragma warning restore CS0618 // Type or member is obsolete
}

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

@ -15,6 +15,9 @@ public sealed class CreateContentPlugin : IPlugin
{
public void ConfigureServices(IServiceCollection services, IConfiguration config)
{
services.AddRuleAction<CreateContentAction, CreateContentActionHandler>();
services.AddFlowStep<CreateContentFlowStep>();
#pragma warning disable CS0618 // Type or member is obsolete
services.AddRuleAction<CreateContentAction>();
#pragma warning restore CS0618 // Type or member is obsolete
}
}

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

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

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

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

188
backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectFlowStep.cs

@ -0,0 +1,188 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.Net.Http.Json;
using System.Text.RegularExpressions;
using Google.Apis.Json;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Flows;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Reflection;
using Squidex.Text;
namespace Squidex.Extensions.Actions.DeepDetect;
[FlowStep(
Title = "DeepDetect",
IconImage = "<svg viewBox='0 0 28 28' xmlns='http://www.w3.org/2000/svg'><g style='stroke-width:1.24962' fill='none'><path fill='#ff5252' d='M13 21.92H0v-8.032h9.386V10.92h3.57v11zm-9.386-4.889v1.702H9.43v-1.702z' style='stroke-width:1.24962' transform='matrix(.78667 0 0 .81405 2.529 2.668)'/><path fill='#fff' d='M29.164 21.92h-13V14.028H25.7V5.92h3.464zm-9.536-4.804v1.673H25.7v-1.673z' style='stroke-width:1.24962' transform='matrix(.78667 0 0 .81405 2.529 2.668)'/></g></svg>",
IconColor = "#526a75",
Display = "Annotate image",
Description = "Annotate an image using deep detect.")]
#pragma warning disable CS0618 // Type or member is obsolete
public sealed partial record DeepDetectFlowStep : FlowStep, IConvertibleToAction
#pragma warning restore CS0618 // Type or member is obsolete
{
[Display(Name = "Min Propability", Description = "The minimum probability for objects to be recognized (0 - 100).")]
[Editor(FlowStepEditor.Number)]
public long MinimumPropability { get; set; }
[Display(Name = "Max Tags", Description = "The maximum number of tags to use.")]
[Editor(FlowStepEditor.Number)]
public long MaximumTags { get; set; }
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
var @event = ((FlowEventContext)executionContext.Context).Event;
if (@event is not EnrichedAssetEvent assetEvent)
{
executionContext.LogSkipped("Invalid event.");
return Next();
}
if (assetEvent.AssetType != AssetType.Image)
{
executionContext.LogSkipped("Invalid event (not an image).");
return Next();
}
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
var urlToDownload =
executionContext.Resolve<IUrlGenerator>()
.AssetContent(assetEvent.AppId, assetEvent.Id.ToString(), assetEvent.FileVersion);
var httpClient =
executionContext.Resolve<IHttpClientFactory>()
.CreateClient("DeepDetect");
var response = await httpClient.PostAsJsonAsync("predict", new
{
service = "squidexdetector",
output = new
{
best = MaximumTags,
confidence_threshold = MinimumPropability / 100d,
},
data = new[]
{
urlToDownload,
},
}, ct);
var responseBody = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
executionContext.Log($"Failed with status code {response.StatusCode}", responseBody);
response.EnsureSuccessStatusCode();
}
var jsonResponse = executionContext.DeserializeJson<DetectResponse>(responseBody);
var tags = jsonResponse!.Body.Predictions.SelectMany(x => x.Classes);
if (!tags.Any())
{
executionContext.Log("Warning: No tags returned.", responseBody);
return Next();
}
var app =
await executionContext.Resolve<IAppProvider>()
.GetAppAsync(assetEvent.AppId.Id, true, ct);
if (app == null)
{
executionContext.LogSkipped("App not found.");
return Next();
}
var context = Context.Admin(app);
var asset =
await executionContext.Resolve<IAssetQueryService>()
.FindAsync(context, assetEvent.Id, ct: ct);
if (asset == null)
{
executionContext.LogSkipped("Asset not found.");
return Next();
}
var command = new AnnotateAsset
{
Tags = asset.TagNames,
AssetId = asset.Id,
AppId = asset.AppId,
Actor = assetEvent.Actor,
FromRule = true,
};
foreach (var tag in tags)
{
var tagParts = tag.Cat.Split(',')[0].Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (IdRegex().IsMatch(tagParts[0]))
{
tagParts = tagParts.Skip(1).ToArray();
}
var tagName = string.Join('_', tagParts.Select(x => x.Slugify()));
command.Tags.Add($"ai/{tagName}");
}
await executionContext.Resolve<ICommandBus>()
.PublishAsync(command, ct);
executionContext.Log("Tags Added.", responseBody);
return Next();
}
#pragma warning disable CS0618 // Type or member is obsolete
public RuleAction ToAction()
{
return SimpleMapper.Map(this, new DeepDetectAction());
}
#pragma warning restore CS0618 // Type or member is obsolete
private sealed class DetectResponse
{
public DetectBody Body { get; set; }
}
private sealed class DetectBody
{
public DetectPredications[] Predictions { get; set; }
}
private sealed class DetectPredications
{
public DetectClass[] Classes { get; set; }
}
private sealed class DetectClass
{
public double Prob { get; set; }
public string Cat { get; set; }
}
[GeneratedRegex("^n[0-9]+$")]
private static partial Regex IdRegex();
}

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

@ -27,6 +27,9 @@ internal sealed class DeepDetectPlugin : IPlugin
client.BaseAddress = uri;
});
services.AddRuleAction<DeepDetectAction, DeepDetectActionHandler>();
services.AddFlowStep<DeepDetectFlowStep>();
#pragma warning disable CS0618 // Type or member is obsolete
services.AddRuleAction<DeepDetectAction>();
#pragma warning restore CS0618 // Type or member is obsolete
}
}

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

@ -5,54 +5,37 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
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/")]
[Obsolete("Has been replaced by flows.")]
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; }
public override FlowStep ToFlowStep()
{
return SimpleMapper.Map(this, new DiscourseFlowStep());
}
}

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

@ -1,86 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Discourse;
public sealed class DiscourseActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory) : RuleActionHandler<DiscourseAction, DiscourseJob>(formatter)
{
private const string DescriptionCreatePost = "Create discourse Post";
private const string DescriptionCreateTopic = "Create discourse Topic";
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}";
var json = new Dictionary<string, object?>
{
["title"] = await FormatAsync(action.Title!, @event),
};
if (action.Topic != null)
{
json.Add("topic_id", action.Topic.Value);
}
if (action.Category != null)
{
json.Add("category", action.Category.Value);
}
json["raw"] = await FormatAsync(action.Text, @event);
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);
}
protected override async Task<Result> ExecuteJobAsync(DiscourseJob job,
CancellationToken ct = default)
{
var httpClient = httpClientFactory.CreateClient("DiscourseAction");
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);
}
}
public sealed class DiscourseJob
{
public string ApiKey { get; set; }
public string ApiUserName { get; set; }
public string RequestUrl { get; set; }
public string RequestBody { get; set; }
}

127
backend/extensions/Squidex.Extensions/Actions/Discourse/DiscourseFlowStep.cs

@ -0,0 +1,127 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.Text;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Extensions.Actions.Algolia;
using Squidex.Flows;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Discourse;
[FlowStep(
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/")]
#pragma warning disable CS0618 // Type or member is obsolete
public sealed record DiscourseFlowStep : FlowStep, IConvertibleToAction
#pragma warning restore CS0618 // Type or member is obsolete
{
[AbsoluteUrl]
[LocalizedRequired]
[Display(Name = "Server Url", Description = "The url to the discourse server.")]
[Editor(FlowStepEditor.Url)]
public Uri Url { get; set; }
[LocalizedRequired]
[Display(Name = "Api Key", Description = "The api key to authenticate to your discourse server.")]
[Editor(FlowStepEditor.Text)]
public string ApiKey { get; set; }
[LocalizedRequired]
[Display(Name = "Api User", Description = "The api username to authenticate to your discourse server.")]
[Editor(FlowStepEditor.Text)]
public string ApiUsername { get; set; }
[LocalizedRequired]
[Display(Name = "Text", Description = "The text as markdown.")]
[Editor(FlowStepEditor.TextArea)]
[Expression]
public string Text { get; set; }
[Display(Name = "Title", Description = "The optional title when creating new topics.")]
[Editor(FlowStepEditor.Text)]
[Expression]
public string? Title { get; set; }
[Display(Name = "Topic", Description = "The optional topic id.")]
[Editor(FlowStepEditor.Text)]
public int? Topic { get; set; }
[Display(Name = "Category", Description = "The optional category id.")]
[Editor(FlowStepEditor.Text)]
public int? Category { get; set; }
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
var url = $"{Url.ToString().TrimEnd('/')}/posts.json?api_key={ApiKey}&api_username={ApiUsername}";
var body = new Dictionary<string, object?>
{
["title"] = Title,
};
if (Topic != null)
{
body.Add("topic_id", Topic.Value);
}
if (Category != null)
{
body.Add("category", Category.Value);
}
body["raw"] = Text;
var jsonRequest = executionContext.SerializeJson(body);
var httpClient =
executionContext.Resolve<IHttpClientFactory>()
.CreateClient("DiscourseAction");
var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(jsonRequest, Encoding.UTF8, "application/json"),
};
request.Headers.TryAddWithoutValidation("Api-Key", ApiKey);
request.Headers.TryAddWithoutValidation("Api-Username", ApiUsername);
var (_, dump) = await httpClient.SendAsync(executionContext, request, jsonRequest, ct);
if (Topic != null)
{
executionContext.Log("Created post in topic {Topic}", dump);
}
else
{
executionContext.Log("Created Topic", dump);
}
return Next();
}
#pragma warning disable CS0618 // Type or member is obsolete
public RuleAction ToAction()
{
return SimpleMapper.Map(this, new AlgoliaAction());
}
#pragma warning restore CS0618 // Type or member is obsolete
}

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

@ -17,6 +17,9 @@ public sealed class DiscoursePlugin : IPlugin
{
services.AddHttpClient("DiscourseAction");
services.AddRuleAction<DiscourseAction, DiscourseActionHandler>();
services.AddFlowStep<DiscourseFlowStep>();
#pragma warning disable CS0618 // Type or member is obsolete
services.AddRuleAction<DiscourseAction>();
#pragma warning restore CS0618 // Type or member is obsolete
}
}

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

@ -5,48 +5,33 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
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/")]
[Obsolete("Has been replaced by flows.")]
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; }
public override FlowStep ToFlowStep()
{
return SimpleMapper.Map(this, new ElasticSearchFlowStep());
}
}

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

@ -1,157 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text.Json.Serialization;
using Elasticsearch.Net;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Infrastructure;
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;
public sealed class ElasticSearchActionHandler(RuleEventFormatter formatter, IScriptEngine scriptEngine, IJsonSerializer serializer) : RuleActionHandler<ElasticSearchAction, ElasticSearchJob>(formatter)
{
private readonly ClientPool<(Uri Host, string? Username, string? Password), ElasticLowLevelClient> 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))
{
config = config.BasicAuthentication(key.Username, key.Password);
}
return new ElasticLowLevelClient(config);
});
protected override async Task<(string Description, ElasticSearchJob Data)> CreateJobAsync(EnrichedEvent @event, ElasticSearchAction action)
{
var delete = @event.ShouldDelete(scriptEngine, action.Delete);
string contentId;
if (@event is IEnrichedEntityEvent enrichedEntityEvent)
{
contentId = enrichedEntityEvent.Id.ToString();
}
else
{
contentId = DomainId.NewGuid().ToString();
}
var ruleText = 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)
{
ruleText = $"Delete entry index: {ruleJob.IndexName}";
}
else
{
ruleText = $"Upsert to index: {ruleJob.IndexName}";
ElasticSearchContent content;
try
{
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!);
}
catch (Exception ex)
{
content = new ElasticSearchContent
{
More = new Dictionary<string, object>
{
["error"] = $"Invalid JSON: {ex.Message}",
},
};
}
content.ContentId = contentId;
ruleJob.Content = serializer.Serialize(content, true);
}
return (ruleText, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(ElasticSearchJob job,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(job.ServerHost))
{
return Result.Ignored();
}
var client = await clients.GetClientAsync((new Uri(job.ServerHost, UriKind.Absolute), job.ServerUser, job.ServerPassword));
try
{
if (job.Content != null)
{
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);
}
}
catch (ElasticsearchClientException ex)
{
return Result.Failed(ex);
}
}
}
public sealed class ElasticSearchContent
{
public string ContentId { get; set; }
[JsonExtensionData]
public Dictionary<string, object> More { get; set; } = [];
}
public sealed class ElasticSearchJob
{
public string ServerHost { get; set; }
public string? ServerUser { get; set; }
public string? ServerPassword { get; set; }
public string ContentId { get; set; }
public string Content { get; set; }
public string IndexName { get; set; }
}

174
backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchFlowStep.cs

@ -0,0 +1,174 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Elasticsearch.Net;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.ElasticSearch;
[FlowStep(
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/")]
#pragma warning disable CS0618 // Type or member is obsolete
public sealed record ElasticSearchFlowStep : FlowStep, IConvertibleToAction
#pragma warning restore CS0618 // Type or member is obsolete
{
[AbsoluteUrl]
[LocalizedRequired]
[Display(Name = "Server Url", Description = "The url to the instance or cluster.")]
[Editor(FlowStepEditor.Url)]
public Uri Host { get; set; }
[LocalizedRequired]
[Display(Name = "Index Name", Description = "The name of the index.")]
[Editor(FlowStepEditor.Text)]
[Expression]
public string IndexName { get; set; }
[Display(Name = "Username", Description = "The optional username.")]
[Editor(FlowStepEditor.Text)]
public string? Username { get; set; }
[Display(Name = "Password", Description = "The optional password.")]
[Editor(FlowStepEditor.Text)]
public string? Password { get; set; }
[Display(Name = "Document", Description = "The optional custom document.")]
[Editor(FlowStepEditor.TextArea)]
[Expression(ExpressionFallback.Event)]
public string? Document { get; set; }
[Display(Name = "Deletion", Description = "The condition when to delete the document.")]
[Editor(FlowStepEditor.Text)]
public string? Delete { get; set; }
private static readonly ClientPool<(Uri Host, string? Username, string? Password), ElasticLowLevelClient> Clients = new (key =>
{
var config = new ConnectionConfiguration(key.Host);
if (!string.IsNullOrEmpty(key.Username) && !string.IsNullOrWhiteSpace(key.Password))
{
config = config.BasicAuthentication(key.Username, key.Password);
}
return new ElasticLowLevelClient(config);
});
public override ValueTask PrepareAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
var @event = ((FlowEventContext)executionContext.Context).Event;
if (!@event.ShouldDelete(executionContext, Delete))
{
Document = null;
return default;
}
ElasticSearchContent content;
try
{
content = executionContext.DeserializeJson<ElasticSearchContent>(Document!);
content.ContentId = @event.GetOrCreateId().Id;
}
catch (Exception ex)
{
content = new ElasticSearchContent
{
More = new Dictionary<string, object>
{
["error"] = $"Invalid JSON: {ex.Message}",
},
ContentId = @event.GetOrCreateId().Id,
};
}
Document = executionContext.SerializeJson(content);
return default;
}
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
var @event = ((FlowEventContext)executionContext.Context).Event;
var (id, isGenerated) = @event.GetOrCreateId();
if (isGenerated && Document == null)
{
executionContext.LogSkipped("Can only delete content for static identities.");
return Next();
}
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
try
{
void HandleResult(StringResponse response, string message)
{
if (response.OriginalException != null)
{
executionContext.Log("Failed with error", response.OriginalException.Message);
throw response.OriginalException;
}
var serializer = executionContext.Resolve<IJsonSerializer>();
executionContext.Log(message, serializer.Serialize(response, true));
}
var client = await Clients.GetClientAsync((Host, Username, Password));
if (Document != null)
{
var response = await client.IndexAsync<StringResponse>(IndexName, id, Document, ctx: ct);
HandleResult(response, $"Document with ID '{id}' upserted");
}
else
{
var response = await client.DeleteAsync<StringResponse>(IndexName, id, ctx: ct);
HandleResult(response, $"Document with ID '{id}' deleted");
}
return Next();
}
catch (ElasticsearchClientException ex)
{
executionContext.Log("Failed with error", ex.Message);
throw;
}
}
#pragma warning disable CS0618 // Type or member is obsolete
public RuleAction ToAction()
{
return SimpleMapper.Map(this, new ElasticSearchAction());
}
#pragma warning restore CS0618 // Type or member is obsolete
private sealed class ElasticSearchContent
{
public string ContentId { get; set; }
[JsonExtensionData]
public Dictionary<string, object> More { get; set; } = [];
}
}

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

@ -15,6 +15,9 @@ public sealed class ElasticSearchPlugin : IPlugin
{
public void ConfigureServices(IServiceCollection services, IConfiguration config)
{
services.AddRuleAction<ElasticSearchAction, ElasticSearchActionHandler>();
services.AddFlowStep<ElasticSearchFlowStep>();
#pragma warning disable CS0618 // Type or member is obsolete
services.AddRuleAction<ElasticSearchAction>();
#pragma warning restore CS0618 // Type or member is obsolete
}
}

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

@ -5,62 +5,40 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
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")]
[Obsolete("Has been replaced by flows.")]
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 Port", Description = "The port to the SMTP server.")]
[Editor(RuleFieldEditor.Text)]
public int ServerPort { get; set; }
[Display(Name = "Username", Description = "The username for the SMTP server.")]
[Editor(RuleFieldEditor.Text)]
[Formattable]
public string ServerUsername { get; set; }
[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 = "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 = "Body", Description = "The message body.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string MessageBody { get; set; }
public string ServerUsername { get; set; }
public string ServerPassword { get; set; }
public override FlowStep ToFlowStep()
{
return SimpleMapper.Map(this, new EmailFlowStep());
}
}

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

@ -1,90 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using MailKit.Net.Smtp;
using MimeKit;
using MimeKit.Text;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Email;
public sealed class EmailActionHandler(RuleEventFormatter formatter) : RuleActionHandler<EmailAction, EmailJob>(formatter)
{
protected override async Task<(string Description, EmailJob Data)> CreateJobAsync(EnrichedEvent @event, EmailAction action)
{
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 {ruleJob.MessageTo}";
return (description, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(EmailJob job,
CancellationToken ct = default)
{
using (var smtpClient = new SmtpClient())
{
await smtpClient.ConnectAsync(job.ServerHost, job.ServerPort, cancellationToken: ct);
if (!string.IsNullOrWhiteSpace(job.ServerUsername) && !string.IsNullOrWhiteSpace(job.ServerPassword))
{
await smtpClient.AuthenticateAsync(job.ServerUsername, job.ServerPassword, ct);
}
var smtpMessage = new MimeMessage();
smtpMessage.From.Add(MailboxAddress.Parse(
job.MessageFrom));
smtpMessage.To.Add(MailboxAddress.Parse(
job.MessageTo));
smtpMessage.Body = new TextPart(TextFormat.Html)
{
Text = job.MessageBody,
};
smtpMessage.Subject = job.MessageSubject;
await smtpClient.SendAsync(smtpMessage, ct);
}
return Result.Complete();
}
}
public sealed class EmailJob
{
public int ServerPort { get; set; }
public string ServerHost { get; set; }
public string ServerUsername { get; set; }
public string ServerPassword { get; set; }
public string MessageFrom { get; set; }
public string MessageTo { get; set; }
public string MessageSubject { get; set; }
public string MessageBody { get; set; }
}

118
backend/extensions/Squidex.Extensions/Actions/Email/EmailFlowStep.cs

@ -0,0 +1,118 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using MailKit.Net.Smtp;
using MimeKit;
using MimeKit.Text;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Email;
[FlowStep(
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")]
#pragma warning disable CS0618 // Type or member is obsolete
internal sealed record EmailFlowStep : FlowStep, IConvertibleToAction
#pragma warning restore CS0618 // Type or member is obsolete
{
[LocalizedRequired]
[Display(Name = "Server Host", Description = "The IP address or host to the SMTP server.")]
[Editor(FlowStepEditor.Text)]
public string ServerHost { get; set; }
[LocalizedRequired]
[Display(Name = "Server Port", Description = "The port to the SMTP server.")]
[Editor(FlowStepEditor.Text)]
public int ServerPort { get; set; }
[Display(Name = "Username", Description = "The username for the SMTP server.")]
[Editor(FlowStepEditor.Text)]
[Expression]
public string ServerUsername { get; set; }
[Display(Name = "Password", Description = "The password for the SMTP server.")]
[Editor(FlowStepEditor.Password)]
public string ServerPassword { get; set; }
[LocalizedRequired]
[Display(Name = "From Address", Description = "The email sending address.")]
[Editor(FlowStepEditor.Text)]
[Expression]
public string MessageFrom { get; set; }
[LocalizedRequired]
[Display(Name = "To Address", Description = "The email message will be sent to.")]
[Editor(FlowStepEditor.Text)]
[Expression]
public string MessageTo { get; set; }
[LocalizedRequired]
[Display(Name = "Subject", Description = "The subject line for this email message.")]
[Editor(FlowStepEditor.Text)]
[Expression]
public string MessageSubject { get; set; }
[LocalizedRequired]
[Display(Name = "Body", Description = "The message body.")]
[Editor(FlowStepEditor.TextArea)]
[Expression]
public string MessageBody { get; set; }
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
using var smtpClient = new SmtpClient();
await smtpClient.ConnectAsync(ServerHost, ServerPort, cancellationToken: ct);
if (!string.IsNullOrWhiteSpace(ServerUsername) && !string.IsNullOrWhiteSpace(ServerPassword))
{
await smtpClient.AuthenticateAsync(ServerUsername, ServerPassword, ct);
}
var smtpMessage = new MimeMessage
{
Body = new TextPart(TextFormat.Html)
{
Text = MessageBody,
},
Subject = MessageSubject,
};
smtpMessage.From.Add(MailboxAddress.Parse(
MessageFrom));
smtpMessage.To.Add(MailboxAddress.Parse(
MessageTo));
await smtpClient.SendAsync(smtpMessage, ct);
executionContext.Log($"Email sent to {MessageTo}");
return Next();
}
#pragma warning disable CS0618 // Type or member is obsolete
public RuleAction ToAction()
{
return SimpleMapper.Map(this, new EmailAction());
}
#pragma warning restore CS0618 // Type or member is obsolete
}

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

@ -15,6 +15,9 @@ public sealed class EmailPlugin : IPlugin
{
public void ConfigureServices(IServiceCollection services, IConfiguration config)
{
services.AddRuleAction<EmailAction, EmailActionHandler>();
services.AddFlowStep<EmailFlowStep>();
#pragma warning disable CS0618 // Type or member is obsolete
services.AddRuleAction<EmailAction>();
#pragma warning restore CS0618 // Type or member is obsolete
}
}

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

@ -5,29 +5,24 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
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/")]
[Obsolete("Has been replaced by flows.")]
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 = "Service Id", Description = "The ID of the fastly service.")]
[Editor(RuleFieldEditor.Text)]
public string ServiceId { get; set; }
public override FlowStep ToFlowStep()
{
return SimpleMapper.Map(this, new FastlyFlowStep());
}
}

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

@ -1,60 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Infrastructure;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Fastly;
public sealed class FastlyActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory) : RuleActionHandler<FastlyAction, FastlyJob>(formatter)
{
private const string Description = "Purge key in fastly";
protected override (string Description, FastlyJob Data) CreateJob(EnrichedEvent @event, FastlyAction action)
{
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,
};
return (Description, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(FastlyJob job,
CancellationToken ct = default)
{
var httpClient = httpClientFactory.CreateClient("FastlyAction");
var requestUrl = $"/service/{job.FastlyServiceID}/purge/{job.Key}";
var request = new HttpRequestMessage(HttpMethod.Post, requestUrl);
request.Headers.Add("Fastly-Key", job.FastlyApiKey);
return await httpClient.OneWayRequestAsync(request, ct: ct);
}
}
public sealed class FastlyJob
{
public string FastlyApiKey { get; set; }
public string FastlyServiceID { get; set; }
public string Key { get; set; }
}

78
backend/extensions/Squidex.Extensions/Actions/Fastly/FastlyFlowStep.cs

@ -0,0 +1,78 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Flows;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Fastly;
[FlowStep(
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/")]
#pragma warning disable CS0618 // Type or member is obsolete
public sealed record FastlyFlowStep : FlowStep, IConvertibleToAction
#pragma warning restore CS0618 // Type or member is obsolete
{
[LocalizedRequired]
[Display(Name = "Api Key", Description = "The API key to grant access to Squidex.")]
[Editor(FlowStepEditor.Text)]
public string ApiKey { get; set; }
[LocalizedRequired]
[Display(Name = "Service Id", Description = "The ID of the fastly service.")]
[Editor(FlowStepEditor.Text)]
public string ServiceId { get; set; }
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
var @event = ((FlowEventContext)executionContext.Context).Event;
var id = string.Empty;
if (@event is IEnrichedEntityEvent entityEvent)
{
id = DomainId.Combine(@event.AppId.Id, entityEvent.Id).ToString();
}
var httpClient =
executionContext.Resolve<IHttpClientFactory>()
.CreateClient("FastlyAction");
var requestUrl = $"/service/{ServiceId}/purge/{id}";
var request = new HttpRequestMessage(HttpMethod.Post, requestUrl);
request.Headers.Add("Fastly-Key", ApiKey);
var (_, dump) = await httpClient.SendAsync(executionContext, request, ct: ct);
executionContext.Log("Cache invalidated", dump);
return Next();
}
#pragma warning disable CS0618 // Type or member is obsolete
public RuleAction ToAction()
{
return SimpleMapper.Map(this, new FastlyAction());
}
#pragma warning restore CS0618 // Type or member is obsolete
}

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

@ -21,6 +21,9 @@ public sealed class FastlyPlugin : IPlugin
options.Timeout = TimeSpan.FromSeconds(2);
});
services.AddRuleAction<FastlyAction, FastlyActionHandler>();
services.AddFlowStep<FastlyFlowStep>();
#pragma warning disable CS0618 // Type or member is obsolete
services.AddRuleAction<FastlyAction>();
#pragma warning restore CS0618 // Type or member is obsolete
}
}

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

@ -6,54 +6,34 @@
// ==========================================================================
#if INCLUDE_KAFKA
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
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")]
[Obsolete("Has been replaced by flows.")]
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? Headers { get; set; }
public string? Schema { get; set; }
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; }
public override FlowStep ToFlowStep()
{
return SimpleMapper.Map(this, new KafkaFlowStep());
}
}
#endif

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

@ -1,117 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
#if INCLUDE_KAFKA
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Kafka;
public sealed class KafkaActionHandler(RuleEventFormatter formatter, KafkaProducer kafkaProducer) : RuleActionHandler<KafkaAction, KafkaJob>(formatter)
{
private const string Description = "Push to Kafka";
protected override async Task<(string Description, KafkaJob Data)> CreateJobAsync(EnrichedEvent @event, KafkaAction action)
{
string? value, key;
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;
}
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;
}
var headersDictionary = new Dictionary<string, string>();
var lines = headers.Split('\n');
foreach (var line in lines)
{
var indexEqual = line.IndexOf('=', StringComparison.Ordinal);
if (indexEqual > 0 && indexEqual < line.Length - 1)
{
var headerKey = line[..indexEqual];
var headerValue = line[(indexEqual + 1)..];
headerValue = await FormatAsync(headerValue, @event);
headersDictionary[headerKey] = headerValue!;
}
}
return headersDictionary;
}
protected override async Task<Result> ExecuteJobAsync(KafkaJob job,
CancellationToken ct = default)
{
try
{
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}");
}
}
}
public sealed class KafkaJob
{
public string TopicName { get; set; }
public string? MessageKey { get; set; }
public string? MessageValue { get; set; }
public string? Schema { get; set; }
public string? PartitionKey { get; set; }
public Dictionary<string, string>? Headers { get; set; }
public int PartitionCount { get; set; }
}
#endif

138
backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaFlowStep.cs

@ -0,0 +1,138 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
#if INCLUDE_KAFKA
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Kafka;
[FlowStep(
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")]
#pragma warning disable CS0618 // Type or member is obsolete
public sealed record KafkaFlowStep : FlowStep, IConvertibleToAction
#pragma warning restore CS0618 // Type or member is obsolete
{
[LocalizedRequired]
[Display(Name = "Topic Name", Description = "The name of the topic.")]
[Editor(FlowStepEditor.Text)]
[Expression]
public string TopicName { get; set; }
[Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")]
[Editor(FlowStepEditor.TextArea)]
[Expression(ExpressionFallback.Envelope)]
public string? Payload { get; set; }
[Display(Name = "Key", Description = "The message key, commonly used for partitioning.")]
[Editor(FlowStepEditor.Text)]
[Expression]
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(FlowStepEditor.Text)]
[Expression]
public string? PartitionKey { get; set; }
[Display(Name = "Partition Count", Description = "Define the number of partitions for specific topic.")]
[Editor(FlowStepEditor.Text)]
public int PartitionCount { get; set; }
[Display(Name = "Headers (Optional)", Description = "The message headers in the format '[Key]=[Value]', one entry per line.")]
[Editor(FlowStepEditor.TextArea)]
[Expression]
public string? Headers { get; set; }
[Display(Name = "Schema (Optional)", Description = "Define a specific AVRO schema in JSON format.")]
[Editor(FlowStepEditor.TextArea)]
public string? Schema { get; set; }
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
var @event = ((FlowEventContext)executionContext.Context).Event;
var key = Key;
if (string.IsNullOrWhiteSpace(key))
{
key = @event.Name;
}
try
{
var request = new KafkaMessageRequest
{
Headers = ParseHeaders(Headers),
MessageKey = key,
MessageValue = Payload,
PartitionCount = PartitionCount,
PartitionKey = PartitionKey,
Schema = Schema,
TopicName = TopicName,
};
await executionContext.Resolve<KafkaProducer>()
.SendAsync(request, ct);
executionContext.Log($"Event pushed to {TopicName} kafka topic with '{key}' message key.");
return Next();
}
catch (Exception ex)
{
executionContext.Log("Push to kafka failed", ex.Message);
throw;
}
}
private static Dictionary<string, string>? ParseHeaders(string? headers)
{
if (string.IsNullOrWhiteSpace(headers))
{
return null;
}
var headersDictionary = new Dictionary<string, string>();
foreach (var line in headers.Split('\n'))
{
var indexEqual = line.IndexOf('=', StringComparison.Ordinal);
if (indexEqual > 0 && indexEqual < line.Length - 1)
{
var headerKey = line[..indexEqual];
var headerValue = line[(indexEqual + 1)..];
headersDictionary[headerKey] = headerValue!;
}
}
return headersDictionary;
}
#pragma warning disable CS0618 // Type or member is obsolete
public RuleAction ToAction()
{
return SimpleMapper.Map(this, new KafkaAction());
}
#pragma warning restore CS0618 // Type or member is obsolete
}
#endif

27
backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaMessageRequest.cs

@ -0,0 +1,27 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
#if INCLUDE_KAFKA
namespace Squidex.Extensions.Actions.Kafka;
public sealed class KafkaMessageRequest
{
public string TopicName { get; set; }
public string? MessageKey { get; set; }
public string? MessageValue { get; set; }
public string? Schema { get; set; }
public string? PartitionKey { get; set; }
public Dictionary<string, string>? Headers { get; set; }
public int PartitionCount { get; set; }
}
#endif

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

@ -21,7 +21,10 @@ public sealed class KafkaPlugin : IPlugin
if (options.IsProducerConfigured())
{
services.AddRuleAction<KafkaAction, KafkaActionHandler>();
services.AddFlowStep<KafkaFlowStep>();
#pragma warning disable CS0618 // Type or member is obsolete
services.AddRuleAction<KafkaAction>();
#pragma warning restore CS0618 // Type or member is obsolete
services.AddSingleton<KafkaProducer>();
services.AddSingleton(Options.Create(options));

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

@ -104,7 +104,7 @@ public sealed class KafkaProducer
log.LogWarning("Kafka error with {code} and {reason}.", error.Code, error.Reason);
}
public async Task SendAsync(KafkaJob job,
public async Task SendAsync(KafkaMessageRequest job,
CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(job.Schema))
@ -123,7 +123,7 @@ public sealed class KafkaProducer
}
}
private static async Task ProduceAsync<T>(IProducer<string, T> producer, Message<string, T> message, KafkaJob job,
private static async Task ProduceAsync<T>(IProducer<string, T> producer, Message<string, T> message, KafkaMessageRequest job,
CancellationToken ct)
{
message.Key = job.MessageKey!;

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

@ -5,54 +5,35 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
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/")]
[Obsolete("Has been replaced by flows.")]
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; }
public override FlowStep ToFlowStep()
{
return SimpleMapper.Map(this, new MediumFlowStep());
}
}

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

@ -1,136 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Infrastructure.Http;
using Squidex.Infrastructure.Json;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Medium;
public sealed class MediumActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory, IJsonSerializer serializer) : RuleActionHandler<MediumAction, MediumJob>(formatter)
{
private const string Description = "Post to medium";
private sealed class UserResponse
{
public UserResponseData Data { get; set; }
}
private sealed class UserResponseData
{
public string Id { get; set; }
}
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
{
title = await FormatAsync(action.Title, @event),
contentFormat = action.IsHtml ? "html" : "markdown",
content = await FormatAsync(action.Content, @event),
canonicalUrl = await FormatAsync(action.CanonicalUrl, @event),
tags = await ParseTagsAsync(@event, action),
};
ruleJob.RequestBody = ToJson(requestBody);
return (Description, ruleJob);
}
private async Task<string[]?> ParseTagsAsync(EnrichedEvent @event, MediumAction action)
{
if (string.IsNullOrWhiteSpace(action.Tags))
{
return null;
}
try
{
var jsonTags = await FormatAsync(action.Tags, @event);
return serializer.Deserialize<string[]>(jsonTags!);
}
catch
{
return action.Tags.Split(',');
}
}
protected override async Task<Result> ExecuteJobAsync(MediumJob job,
CancellationToken ct = default)
{
var httpClient = httpClientFactory.CreateClient("MediumAction");
string path;
if (!string.IsNullOrWhiteSpace(job.PublicationId))
{
path = $"/v1/publications/{job.PublicationId}/posts";
}
else
{
HttpResponseMessage? response = null;
var meRequest = BuildGetRequest(job, "/v1/me");
try
{
response = await httpClient.SendAsync(meRequest, ct);
var responseString = await response.Content.ReadAsStringAsync(ct);
var responseJson = serializer.Deserialize<UserResponse>(responseString);
var id = responseJson.Data?.Id;
path = $"/v1/users/{id}/posts";
}
catch (Exception ex)
{
var requestDump = DumpFormatter.BuildDump(meRequest, response, ex.ToString());
return Result.Failed(ex, requestDump);
}
}
return await httpClient.OneWayRequestAsync(BuildPostRequest(job, path), job.RequestBody, ct);
}
private static HttpRequestMessage BuildPostRequest(MediumJob job, string path)
{
var request = new HttpRequestMessage(HttpMethod.Post, path)
{
Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json"),
};
request.Headers.Add("Authorization", $"Bearer {job.AccessToken}");
return request;
}
private static HttpRequestMessage BuildGetRequest(MediumJob job, string path)
{
var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Headers.Add("Authorization", $"Bearer {job.AccessToken}");
return request;
}
}
public sealed class MediumJob
{
public string RequestBody { get; set; }
public string? PublicationId { get; set; }
public string AccessToken { get; set; }
}

165
backend/extensions/Squidex.Extensions/Actions/Medium/MediumFlowStep.cs

@ -0,0 +1,165 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.Text;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Medium;
[FlowStep(
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/")]
#pragma warning disable CS0618 // Type or member is obsolete
public sealed record MediumFlowStep : FlowStep, IConvertibleToAction
#pragma warning restore CS0618 // Type or member is obsolete
{
[LocalizedRequired]
[Display(Name = "Access Token", Description = "The self issued access token.")]
[Editor(FlowStepEditor.Text)]
public string AccessToken { get; set; }
[LocalizedRequired]
[Display(Name = "Title", Description = "The title, used for the url.")]
[Editor(FlowStepEditor.Text)]
[Expression]
public string Title { get; set; }
[LocalizedRequired]
[Display(Name = "Content", Description = "The content, either html or markdown.")]
[Editor(FlowStepEditor.TextArea)]
[Expression]
public string Content { get; set; }
[Display(Name = "Canonical Url", Description = "The original home of this content, if it was originally published elsewhere.")]
[Editor(FlowStepEditor.Text)]
[Expression]
public string? CanonicalUrl { get; set; }
[Display(Name = "Tags", Description = "The optional comma separated list of tags.")]
[Editor(FlowStepEditor.Text)]
[Expression]
public string? Tags { get; set; }
[Display(Name = "Publication Id", Description = "Optional publication id.")]
[Editor(FlowStepEditor.Text)]
public string? PublicationId { get; set; }
[Display(Name = "Is Html", Description = "Indicates whether the content is markdown or html.")]
[Editor(FlowStepEditor.Text)]
public bool IsHtml { get; set; }
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
var httpClient =
executionContext.Resolve<IHttpClientFactory>()
.CreateClient("MediumAction");
string path;
if (!string.IsNullOrWhiteSpace(PublicationId))
{
path = $"/v1/publications/{PublicationId}/posts";
}
else
{
var meRequest = BuildGetRequest("/v1/me");
var (responseString, _) = await httpClient.SendAsync(executionContext, meRequest, null, ct);
var responseJson = executionContext.DeserializeJson<UserResponse>(responseString);
var id = responseJson.Data?.Id;
path = $"/v1/users/{id}/posts";
}
var requestBody = new
{
title = Title,
contentFormat = IsHtml ? "html" : "markdown",
content = Content,
canonicalUrl = CanonicalUrl,
tags = ParseTags(executionContext),
};
var requestJson = executionContext.SerializeJson(requestBody);
var (_, dump) = await httpClient.SendAsync(executionContext, BuildPostRequest(path, requestJson), requestJson, ct);
executionContext.Log("Post created", dump);
return Next();
}
private HttpRequestMessage BuildPostRequest(string path, string body)
{
var request = new HttpRequestMessage(HttpMethod.Post, path)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
request.Headers.Add("Authorization", $"Bearer {AccessToken}");
return request;
}
private HttpRequestMessage BuildGetRequest(string path)
{
var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Headers.Add("Authorization", $"Bearer {AccessToken}");
return request;
}
private string[]? ParseTags(FlowExecutionContext executionContext)
{
if (string.IsNullOrWhiteSpace(Tags))
{
return null;
}
try
{
return executionContext.DeserializeJson<string[]>(Tags);
}
catch
{
return Tags.Split(',');
}
}
#pragma warning disable CS0618 // Type or member is obsolete
public RuleAction ToAction()
{
return SimpleMapper.Map(this, new MediumAction());
}
#pragma warning restore CS0618 // Type or member is obsolete
private sealed class UserResponse
{
public UserResponseData Data { get; set; }
}
private sealed class UserResponseData
{
public string Id { get; set; }
}
}

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

@ -24,6 +24,9 @@ public sealed class MediumPlugin : IPlugin
options.DefaultRequestHeaders.Add("User-Agent", "Squidex Headless CMS");
});
services.AddRuleAction<MediumAction, MediumActionHandler>();
services.AddFlowStep<MediumFlowStep>();
#pragma warning disable CS0618 // Type or member is obsolete
services.AddRuleAction<MediumAction>();
#pragma warning restore CS0618 // Type or member is obsolete
}
}

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

@ -5,38 +5,28 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
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.")]
[Obsolete("Has been replaced by flows.")]
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 = "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 = "Client", Description = "An optional client name.")]
[Editor(RuleFieldEditor.Text)]
public string? Client { get; set; }
public override FlowStep ToFlowStep()
{
return SimpleMapper.Map(this, new NotificationFlowStep());
}
}

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

@ -1,72 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Entities.Collaboration;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure;
using Squidex.Shared.Users;
namespace Squidex.Extensions.Actions.Notification;
public sealed class NotificationActionHandler(RuleEventFormatter formatter, ICollaborationService collaboration, IUserResolver userResolver) : RuleActionHandler<NotificationAction, CommentCreated>(formatter)
{
private const string Description = "Send a Notification";
protected override async Task<(string Description, CommentCreated Data)> CreateJobAsync(EnrichedEvent @event, NotificationAction action)
{
if (@event is not EnrichedUserEventBase userEvent)
{
return ("Ignore", new CommentCreated());
}
var user = await userResolver.FindByIdOrEmailAsync(action.User)
?? throw new InvalidOperationException($"Cannot find user by '{action.User}'");
var actor = userEvent.Actor;
if (!string.IsNullOrEmpty(action.Client))
{
actor = RefToken.Client(action.Client);
}
var ruleJob = new CommentCreated
{
Actor = actor,
CommentId = DomainId.NewGuid(),
CommentsId = DomainId.Create(user.Id),
FromRule = true,
Text = (await FormatAsync(action.Text, @event))!,
};
if (!string.IsNullOrWhiteSpace(action.Url))
{
var url = await FormatAsync(action.Url, @event);
if (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri))
{
ruleJob.Url = uri;
}
}
return (Description, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(CommentCreated job,
CancellationToken ct = default)
{
if (job.CommentsId == default)
{
return Result.Ignored();
}
await collaboration.NotifyAsync(job.CommentsId.ToString(), job.Text, job.Actor, job.Url, true, ct);
return Result.Success($"Notified: {job.Text}");
}
}

97
backend/extensions/Squidex.Extensions/Actions/Notification/NotificationFlowStep.cs

@ -0,0 +1,97 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Entities.Collaboration;
using Squidex.Flows;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
using Squidex.Shared.Users;
namespace Squidex.Extensions.Actions.Notification;
[FlowStep(
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.")]
#pragma warning disable CS0618 // Type or member is obsolete
public sealed record NotificationFlowStep : FlowStep, IConvertibleToAction
#pragma warning restore CS0618 // Type or member is obsolete
{
[LocalizedRequired]
[Display(Name = "User", Description = "The user id or email.")]
[Editor(FlowStepEditor.Text)]
public string User { get; set; }
[LocalizedRequired]
[Display(Name = "Title", Description = "The text to send.")]
[Editor(FlowStepEditor.TextArea)]
[Expression]
public string Text { get; set; }
[Display(Name = "Url", Description = "The optional url to attach to the notification.")]
[Editor(FlowStepEditor.Text)]
[Expression]
public string? Url { get; set; }
[Display(Name = "Client", Description = "An optional client name.")]
[Editor(FlowStepEditor.Text)]
public string? Client { get; set; }
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
var @event = ((FlowEventContext)executionContext.Context).Event;
if (@event is not EnrichedUserEventBase userEvent)
{
executionContext.LogSkipped("Not an event with user information");
return Next();
}
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
var user =
await executionContext.Resolve<IUserResolver>()
.FindByIdOrEmailAsync(User, ct)
?? throw new InvalidOperationException($"Cannot find user by '{User}'");
var actor = userEvent.Actor;
if (!string.IsNullOrEmpty(Client))
{
actor = RefToken.Client(Client);
}
Uri? url = null;
if (!string.IsNullOrWhiteSpace(Url) && !Uri.TryCreate(Url, UriKind.RelativeOrAbsolute, out url))
{
executionContext.Log($"Invalid URL: {Url}");
}
await executionContext.Resolve<ICollaborationService>()
.NotifyAsync(user.Id, Text, actor, url, true, ct);
executionContext.Log("Notified", Text);
return Next();
}
#pragma warning disable CS0618 // Type or member is obsolete
public RuleAction ToAction()
{
return SimpleMapper.Map(this, new NotificationAction());
}
#pragma warning restore CS0618 // Type or member is obsolete
}

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

@ -15,6 +15,9 @@ public sealed class NotificationPlugin : IPlugin
{
public void ConfigureServices(IServiceCollection services, IConfiguration config)
{
services.AddRuleAction<NotificationAction, NotificationActionHandler>();
services.AddFlowStep<NotificationFlowStep>();
#pragma warning disable CS0618 // Type or member is obsolete
services.AddRuleAction<NotificationAction>();
#pragma warning restore CS0618 // Type or member is obsolete
}
}

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

@ -5,48 +5,33 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
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/")]
[Obsolete("Has been replaced by flows.")]
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; }
public override FlowStep ToFlowStep()
{
return SimpleMapper.Map(this, new OpenSearchFlowStep());
}
}

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

@ -1,157 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text.Json.Serialization;
using OpenSearch.Net;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Infrastructure;
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;
public sealed class OpenSearchActionHandler(RuleEventFormatter formatter, IScriptEngine scriptEngine, IJsonSerializer serializer) : RuleActionHandler<OpenSearchAction, OpenSearchJob>(formatter)
{
private readonly ClientPool<(Uri Host, string? Username, string? Password), OpenSearchLowLevelClient> 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))
{
config = config.BasicAuthentication(key.Username, key.Password);
}
return new OpenSearchLowLevelClient(config);
});
protected override async Task<(string Description, OpenSearchJob Data)> CreateJobAsync(EnrichedEvent @event, OpenSearchAction action)
{
var delete = @event.ShouldDelete(scriptEngine, action.Delete);
string contentId;
if (@event is IEnrichedEntityEvent enrichedEntityEvent)
{
contentId = enrichedEntityEvent.Id.ToString();
}
else
{
contentId = DomainId.NewGuid().ToString();
}
var ruleText = 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)
{
ruleText = $"Delete entry index: {ruleJob.IndexName}";
}
else
{
ruleText = $"Upsert to index: {ruleJob.IndexName}";
OpenSearchContent content;
try
{
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!);
}
catch (Exception ex)
{
content = new OpenSearchContent
{
More = new Dictionary<string, object>
{
["error"] = $"Invalid JSON: {ex.Message}",
},
};
}
content.ContentId = contentId;
ruleJob.Content = serializer.Serialize(content, true);
}
return (ruleText, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(OpenSearchJob job,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(job.ServerHost))
{
return Result.Ignored();
}
var client = await clients.GetClientAsync((new Uri(job.ServerHost, UriKind.Absolute), job.ServerUser, job.ServerPassword));
try
{
if (job.Content != null)
{
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);
}
}
catch (OpenSearchClientException ex)
{
return Result.Failed(ex);
}
}
}
public sealed class OpenSearchContent
{
public string ContentId { get; set; }
[JsonExtensionData]
public Dictionary<string, object> More { get; set; } = [];
}
public sealed class OpenSearchJob
{
public string ServerHost { get; set; }
public string? ServerUser { get; set; }
public string? ServerPassword { get; set; }
public string ContentId { get; set; }
public string Content { get; set; }
public string IndexName { get; set; }
}

174
backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchFlowStep.cs

@ -0,0 +1,174 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using OpenSearch.Net;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.OpenSearch;
[FlowStep(
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/")]
#pragma warning disable CS0618 // Type or member is obsolete
public sealed record OpenSearchFlowStep : FlowStep, IConvertibleToAction
#pragma warning restore CS0618 // Type or member is obsolete
{
[AbsoluteUrl]
[LocalizedRequired]
[Display(Name = "Server Url", Description = "The url to the instance or cluster.")]
[Editor(FlowStepEditor.Url)]
public Uri Host { get; set; }
[LocalizedRequired]
[Display(Name = "Index Name", Description = "The name of the index.")]
[Editor(FlowStepEditor.Text)]
[Expression]
public string IndexName { get; set; }
[Display(Name = "Username", Description = "The optional username.")]
[Editor(FlowStepEditor.Text)]
public string? Username { get; set; }
[Display(Name = "Password", Description = "The optional password.")]
[Editor(FlowStepEditor.Text)]
public string? Password { get; set; }
[Display(Name = "Document", Description = "The optional custom document.")]
[Editor(FlowStepEditor.TextArea)]
[Expression(ExpressionFallback.Event)]
public string? Document { get; set; }
[Display(Name = "Deletion", Description = "The condition when to delete the document.")]
[Editor(FlowStepEditor.Text)]
public string? Delete { get; set; }
private static readonly ClientPool<(Uri Host, string? Username, string? Password), OpenSearchLowLevelClient> Clients = new (key =>
{
var config = new ConnectionConfiguration(key.Host);
if (!string.IsNullOrEmpty(key.Username) && !string.IsNullOrWhiteSpace(key.Password))
{
config = config.BasicAuthentication(key.Username, key.Password);
}
return new OpenSearchLowLevelClient(config);
});
public override ValueTask PrepareAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
var @event = ((FlowEventContext)executionContext.Context).Event;
if (@event.ShouldDelete(executionContext, Delete))
{
OpenSearchContent content;
try
{
content = executionContext.DeserializeJson<OpenSearchContent>(Document!);
}
catch (Exception ex)
{
content = new OpenSearchContent
{
More = new Dictionary<string, object>
{
["error"] = $"Invalid JSON: {ex.Message}",
},
};
}
Document = executionContext.SerializeJson(content);
}
else
{
Document = null;
}
return base.PrepareAsync(executionContext, ct);
}
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
var @event = ((FlowEventContext)executionContext.Context).Event;
var (id, isGenerated) = @event.GetOrCreateId();
if (isGenerated && Document == null)
{
executionContext.LogSkipped("Can only delete content for static identities.");
return Next();
}
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
try
{
void HandleResult(StringResponse response, string message)
{
if (response.OriginalException != null)
{
executionContext.Log("Failed with error", response.OriginalException.Message);
throw response.OriginalException;
}
var serializer = executionContext.Resolve<IJsonSerializer>();
executionContext.Log(message, serializer.Serialize(response, true));
}
var client = await Clients.GetClientAsync((Host, Username, Password));
if (Document != null)
{
var response = await client.IndexAsync<StringResponse>(IndexName, id, Document, ctx: ct);
HandleResult(response, $"Document with ID '{id}' upserted");
}
else
{
var response = await client.DeleteAsync<StringResponse>(IndexName, id, ctx: ct);
HandleResult(response, $"Document with ID '{id}' deleted");
}
return Next();
}
catch (OpenSearchClientException ex)
{
executionContext.Log("Failed with error", ex.Message);
throw;
}
}
#pragma warning disable CS0618 // Type or member is obsolete
public RuleAction ToAction()
{
return SimpleMapper.Map(this, new OpenSearchAction());
}
#pragma warning restore CS0618 // Type or member is obsolete
private sealed class OpenSearchContent
{
public string ContentId { get; set; }
[JsonExtensionData]
public Dictionary<string, object> More { get; set; } = [];
}
}

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

@ -15,6 +15,9 @@ public sealed class OpenSearchPlugin : IPlugin
{
public void ConfigureServices(IServiceCollection services, IConfiguration config)
{
services.AddRuleAction<OpenSearchAction, OpenSearchActionHandler>();
services.AddFlowStep<OpenSearchFlowStep>();
#pragma warning disable CS0618 // Type or member is obsolete
services.AddRuleAction<OpenSearchAction>();
#pragma warning restore CS0618 // Type or member is obsolete
}
}

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

@ -5,30 +5,24 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
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")]
[Obsolete("Has been replaced by flows.")]
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 = "Url", Description = "The url to recache.")]
[Editor(RuleFieldEditor.Text)]
public string Url { get; set; }
public override FlowStep ToFlowStep()
{
return SimpleMapper.Map(this, new PrerenderFlowStep());
}
}

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

@ -1,45 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Prerender;
public sealed class PrerenderActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory) : RuleActionHandler<PrerenderAction, PrerenderJob>(formatter)
{
protected override async Task<(string Description, PrerenderJob Data)> CreateJobAsync(EnrichedEvent @event, PrerenderAction action)
{
var url = await FormatAsync(action.Url, @event);
var requestObject = new { prerenderToken = action.Token, url };
var requestBody = ToJson(requestObject);
return ($"Recache {url}", new PrerenderJob { RequestBody = requestBody });
}
protected override async Task<Result> ExecuteJobAsync(PrerenderJob job,
CancellationToken ct = default)
{
var httpClient = httpClientFactory.CreateClient("Prerender");
var request = new HttpRequestMessage(HttpMethod.Post, "/recache")
{
Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json"),
};
return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct);
}
}
public sealed class PrerenderJob
{
public string RequestBody { get; set; }
}

74
backend/extensions/Squidex.Extensions/Actions/Prerender/PrerenderFlowStep.cs

@ -0,0 +1,74 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.Text;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Prerender;
[FlowStep(
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")]
#pragma warning disable CS0618 // Type or member is obsolete
public sealed record PrerenderFlowStep : FlowStep, IConvertibleToAction
#pragma warning restore CS0618 // Type or member is obsolete
{
[LocalizedRequired]
[Display(Name = "Token", Description = "The prerender token from your account.")]
[Editor(FlowStepEditor.Text)]
[Expression]
public string Token { get; set; }
[LocalizedRequired]
[Display(Name = "Url", Description = "The url to recache.")]
[Editor(FlowStepEditor.Text)]
[Expression]
public string Url { get; set; }
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
var requestObject = new { prerenderToken = Token, Url };
var requestBody = executionContext.SerializeJson(requestObject);
var request = new HttpRequestMessage(HttpMethod.Post, "/recache")
{
Content = new StringContent(requestBody, Encoding.UTF8, "application/json"),
};
var httpClient =
executionContext.Resolve<IHttpClientFactory>()
.CreateClient("Prerender");
var (_, dump) = await httpClient.SendAsync(executionContext, request, requestBody, ct);
executionContext.Log("Success", dump);
return Next();
}
#pragma warning disable CS0618 // Type or member is obsolete
public RuleAction ToAction()
{
return SimpleMapper.Map(this, new PrerenderAction());
}
#pragma warning restore CS0618 // Type or member is obsolete
}

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

@ -20,6 +20,9 @@ public sealed class PrerenderPlugin : IPlugin
options.BaseAddress = new Uri("https://api.prerender.io");
});
services.AddRuleAction<PrerenderAction, PrerenderActionHandler>();
services.AddFlowStep<PrerenderFlowStep>();
#pragma warning disable CS0618 // Type or member is obsolete
services.AddRuleAction<PrerenderAction>();
#pragma warning restore CS0618 // Type or member is obsolete
}
}

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

@ -5,9 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Flows;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Http;
namespace Squidex.Extensions.Actions;
@ -30,6 +31,16 @@ public static class RuleHelper
return IsContentDeletion(@event) || IsAssetDeletion(@event);
}
public static bool ShouldDelete(this EnrichedEvent @event, FlowExecutionContext context, string? expression)
{
if (!string.IsNullOrWhiteSpace(expression))
{
return context.Evaluate(expression, context.Context);
}
return IsContentDeletion(@event) || IsAssetDeletion(@event);
}
public static bool IsContentDeletion(this EnrichedEvent @event)
{
return @event is EnrichedContentEvent { Type: EnrichedContentEventType.Deleted or EnrichedContentEventType.Unpublished };
@ -40,7 +51,10 @@ public static class RuleHelper
return @event is EnrichedAssetEvent { Type: EnrichedAssetEventType.Deleted };
}
public static async Task<Result> OneWayRequestAsync(this HttpClient client, HttpRequestMessage request, string? requestBody = null,
public static async Task<(string Response, string Dump)> SendAsync(this HttpClient client,
FlowExecutionContext executionContext,
HttpRequestMessage request,
string? requestBody = null,
CancellationToken ct = default)
{
HttpResponseMessage? response = null;
@ -53,20 +67,30 @@ public static class RuleHelper
if (!response.IsSuccessStatusCode)
{
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);
executionContext.Log("Http request failed", requestDump);
throw new HttpRequestException($"Response code does not indicate success: {(int)response.StatusCode} ({response.StatusCode}).");
}
return (responseString, requestDump);
}
catch (Exception ex)
{
var requestDump = DumpFormatter.BuildDump(request, response, requestBody, ex.ToString());
return Result.Failed(ex, requestDump);
executionContext.Log("Http request failed", requestDump);
throw;
}
}
public static (string Id, bool IsGenerated) GetOrCreateId(this EnrichedEvent @event)
{
if (@event is IEnrichedEntityEvent enrichedEntityEvent)
{
return (enrichedEntityEvent.Id.ToString(), false);
}
else
{
return (DomainId.NewGuid().ToString(), true);
}
}
}

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

@ -5,24 +5,21 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
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")]
[Obsolete("Has been replaced by flows.")]
public sealed record ScriptAction : RuleAction
{
[LocalizedRequired]
[Display(Name = "Script", Description = "The script to render.")]
[Editor(RuleFieldEditor.Javascript)]
[Formattable]
public string Script { get; set; }
public override FlowStep ToFlowStep()
{
return SimpleMapper.Map(this, new ScriptFlowStep());
}
}

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

@ -1,65 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Security.Claims;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Shared;
using Squidex.Shared.Identity;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Script;
public sealed class ScriptActionHandler(RuleEventFormatter formatter, IScriptEngine scriptEngine) : RuleActionHandler<ScriptAction, ScriptJob>(formatter)
{
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));
}
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
{
Event = job.Event,
AppId = job.Event.AppId.Id,
AppName = job.Event.AppId.Name,
};
if (job.Event is EnrichedUserEventBase)
{
vars.User = AllPrinicpal();
}
var result = await scriptEngine.ExecuteAsync(vars, job.Script, ct: ct);
return Result.Success(result.ToString());
}
private static ClaimsPrincipal AllPrinicpal()
{
var claimsIdentity = new ClaimsIdentity();
var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
claimsIdentity.AddClaim(new Claim(SquidexClaimTypes.Permissions, PermissionIds.All));
return claimsPrincipal;
}
}
public sealed class ScriptJob
{
public EnrichedEvent Event { get; set; }
public string Script { get; set; }
}

46
backend/extensions/Squidex.Extensions/Actions/Script/ScriptFlowStep.cs

@ -0,0 +1,46 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
#pragma warning disable CS0618 // Type or member is obsolete
namespace Squidex.Extensions.Actions.Script;
[FlowStep(
Title = "Script",
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' height='24' viewBox='0 -960 960 960' width='24'><path d='M300-360q-25 0-42.5-17.5T240-420v-40h60v40h60v-180h60v180q0 25-17.5 42.5T360-360h-60zm220 0q-17 0-28.5-11.5T480-400v-40h60v20h80v-40H520q-17 0-28.5-11.5T480-500v-60q0-17 11.5-28.5T520-600h120q17 0 28.5 11.5T680-560v40h-60v-20h-80v40h100q17 0 28.5 11.5T680-460v60q0 17-11.5 28.5T640-360H520z'/></svg>",
IconColor = "#3389ff",
Display = "Execute a script",
Description = "Execute custom code in Javascript.")]
[NoRetry]
public sealed record ScriptFlowStep : FlowStep, IConvertibleToAction
{
[Script]
[Display(Name = "Script", Description = "The script to execute.")]
[Editor(FlowStepEditor.TextArea)]
public string? Script { get; set; }
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(Script))
{
await executionContext.RenderAsync($"Script({Script})", executionContext.Context);
}
return Next();
}
public RuleAction ToAction()
{
return SimpleMapper.Map(this, new ScriptAction());
}
}

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

@ -15,6 +15,9 @@ public sealed class ScriptPlugin : IPlugin
{
public void ConfigureServices(IServiceCollection services, IConfiguration config)
{
services.AddRuleAction<ScriptAction, ScriptActionHandler>();
services.AddFlowStep<ScriptFlowStep>();
#pragma warning disable CS0618 // Type or member is obsolete
services.AddRuleAction<ScriptAction>();
#pragma warning restore CS0618 // Type or member is obsolete
}
}

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

@ -5,72 +5,32 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
#pragma warning disable MA0048 // File name must match type name
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/")]
[Obsolete("Has been replaced by flows.")]
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 = "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; }
public SignalRActionType 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 = "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; }
protected override IEnumerable<ValidationError> CustomValidate()
public override FlowStep ToFlowStep()
{
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));
}
if (Action != ActionTypeEnum.Broadcast && string.IsNullOrWhiteSpace(Target))
{
yield return new ValidationError("Target must be specified with 'User' or 'Group' Action.", nameof(HubName));
}
return SimpleMapper.Map(this, new SignalRFlowStep());
}
}
public enum ActionTypeEnum
{
Broadcast,
User,
Group,
}

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

@ -1,103 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.AspNetCore.SignalR;
using Microsoft.Azure.SignalR.Management;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.SignalR;
public sealed class SignalRActionHandler(RuleEventFormatter formatter) : RuleActionHandler<SignalRAction, SignalRJob>(formatter)
{
private readonly ClientPool<(string ConnectionString, string HubName), ServiceManager> 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)
{
var hubName = await FormatAsync(action.HubName, @event);
string? requestBody;
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 ruleText = $"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 (ruleText, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(SignalRJob job,
CancellationToken ct = default)
{
var signalR = await clients.GetClientAsync((job.ConnectionString, job.HubName));
await using (var signalRContext = await signalR.CreateHubContextAsync(job.HubName, cancellationToken: ct))
{
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;
}
}
return Result.Complete();
}
}
public sealed class SignalRJob
{
public string ConnectionString { get; set; }
public string HubName { get; set; }
public ActionTypeEnum Action { get; set; }
public string? MethodName { get; set; }
public string MethodPayload { get; set; }
public string[] Targets { get; set; }
}

8
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/FormattableAttribute.cs → backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRActionType.cs

@ -5,9 +5,11 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Core.HandleRules;
namespace Squidex.Extensions.Actions.SignalR;
[AttributeUsage(AttributeTargets.Property)]
public sealed class FormattableAttribute : Attribute
public enum SignalRActionType
{
Broadcast,
User,
Group,
}

132
backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRFlowStep.cs

@ -0,0 +1,132 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Azure.SignalR.Management;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.SignalR;
[FlowStep(
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/en-en/services/signalr-service/")]
#pragma warning disable CS0618 // Type or member is obsolete
public sealed partial record SignalRFlowStep : FlowStep, IConvertibleToAction
#pragma warning restore CS0618 // Type or member is obsolete
{
[LocalizedRequired]
[Display(Name = "Connection", Description = "The connection string to the Azure SignalR.")]
[Editor(FlowStepEditor.Text)]
[Expression]
public string ConnectionString { get; set; }
[LocalizedRequired]
[Display(Name = "Hub Name", Description = "The name of the hub.")]
[Editor(FlowStepEditor.Text)]
[Expression]
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 SignalRActionType Action { get; set; }
[Display(Name = "Methode Name", Description = "Set the Name of the hub method received by the customer.")]
[Editor(FlowStepEditor.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(FlowStepEditor.TextArea)]
[Expression]
public string? Target { get; set; }
[Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")]
[Editor(FlowStepEditor.TextArea)]
[Expression(ExpressionFallback.Envelope)]
public string? Payload { get; set; }
private static readonly ClientPool<(string ConnectionString, string HubName), ServiceManager> Clients = new (key =>
{
var serviceManager = new ServiceManagerBuilder()
.WithOptions(option =>
{
option.ConnectionString = key.ConnectionString;
option.ServiceTransportType = ServiceTransportType.Transient;
})
.BuildServiceManager();
return serviceManager;
});
public override ValueTask ValidateAsync(FlowValidationContext validationContext, AddStepError addError,
CancellationToken ct)
{
if (HubName != null && !HubNameRegex().IsMatch(HubName))
{
addError(nameof(HubName), "Hub must be valid azure hub name.");
}
if (Action != SignalRActionType.Broadcast && string.IsNullOrWhiteSpace(Target))
{
addError(nameof(HubName), "Hub must be valid azure hub name.");
}
return default;
}
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
var signalR = await Clients.GetClientAsync((ConnectionString, HubName));
var targets = Target?.Split("\n") ?? [];
await using (var signalRContext = await signalR.CreateHubContextAsync(HubName, ct))
{
var methodeName = !string.IsNullOrWhiteSpace(MethodName) ? MethodName : "push";
switch (Action)
{
case SignalRActionType.Broadcast:
await signalRContext.Clients.All.SendAsync(methodeName, Payload, ct);
break;
case SignalRActionType.User:
await signalRContext.Clients.Users(targets).SendAsync(methodeName, Payload, ct);
break;
case SignalRActionType.Group:
await signalRContext.Clients.Groups(targets).SendAsync(methodeName, Payload, ct);
break;
}
}
return Next();
}
#pragma warning disable CS0618 // Type or member is obsolete
public RuleAction ToAction()
{
return SimpleMapper.Map(this, new SignalRAction());
}
#pragma warning restore CS0618 // Type or member is obsolete
[GeneratedRegex("^[a-z][a-z0-9]{2,}(\\-[a-z0-9]+)*$")]
private static partial Regex HubNameRegex();
}

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

@ -15,6 +15,9 @@ public sealed class SignalRPlugin : IPlugin
{
public void ConfigureServices(IServiceCollection services, IConfiguration config)
{
services.AddRuleAction<SignalRAction, SignalRActionHandler>();
services.AddFlowStep<SignalRFlowStep>();
#pragma warning disable CS0618 // Type or member is obsolete
services.AddRuleAction<SignalRAction>();
#pragma warning restore CS0618 // Type or member is obsolete
}
}

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

@ -5,31 +5,26 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Extensions.Actions.Slack;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Slack;
namespace Migrations.OldActions;
[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")]
[Obsolete("Has been replaced by flows.")]
public sealed record SlackAction : RuleAction
{
[AbsoluteUrl]
[LocalizedRequired]
[Display(Name = "Webhook Url", Description = "The slack webhook url.")]
[Editor(RuleFieldEditor.Text)]
public Uri WebhookUrl { get; set; }
[LocalizedRequired]
[Display(Name = "Text", Description = "The text that is sent as message to slack.")]
[Editor(RuleFieldEditor.TextArea)]
[Formattable]
public string Text { get; set; }
public override FlowStep ToFlowStep()
{
return SimpleMapper.Map(this, new SlackFlowStep());
}
}

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

@ -1,52 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Slack;
public sealed class SlackActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory) : RuleActionHandler<SlackAction, SlackJob>(formatter)
{
private const string Description = "Send message to slack";
protected override async Task<(string Description, SlackJob Data)> CreateJobAsync(EnrichedEvent @event, SlackAction action)
{
var body = new { text = await FormatAsync(action.Text, @event) };
var ruleJob = new SlackJob
{
RequestUrl = action.WebhookUrl.ToString(),
RequestBody = ToJson(body),
};
return (Description, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(SlackJob job,
CancellationToken ct = default)
{
var httpClient = httpClientFactory.CreateClient("SlackAction");
var request = new HttpRequestMessage(HttpMethod.Post, job.RequestUrl)
{
Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json"),
};
return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct);
}
}
public sealed class SlackJob
{
public string RequestUrl { get; set; }
public string RequestBody { get; set; }
}

76
backend/extensions/Squidex.Extensions/Actions/Slack/SlackFlowStep.cs

@ -0,0 +1,76 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.Text;
using Google.Apis.Json;
using Migrations.OldActions;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Slack;
[FlowStep(
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")]
#pragma warning disable CS0618 // Type or member is obsolete
public sealed record SlackFlowStep : FlowStep, IConvertibleToAction
#pragma warning restore CS0618 // Type or member is obsolete
{
[AbsoluteUrl]
[LocalizedRequired]
[Display(Name = "Webhook Url", Description = "The slack webhook url.")]
[Editor(FlowStepEditor.Text)]
public Uri WebhookUrl { get; set; }
[LocalizedRequired]
[Display(Name = "Text", Description = "The text that is sent as message to slack.")]
[Editor(FlowStepEditor.TextArea)]
[Expression]
public string Text { get; set; }
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
var body = new { text = Text };
var jsonRequest = executionContext.SerializeJson(body);
var request = new HttpRequestMessage(HttpMethod.Post, WebhookUrl)
{
Content = new StringContent(jsonRequest, Encoding.UTF8, "application/json"),
};
var httpClient =
executionContext.Resolve<IHttpClientFactory>()
.CreateClient("SlackAction");
var (_, dump) = await httpClient.SendAsync(executionContext, request, jsonRequest, ct);
executionContext.Log("Notification sent", dump);
return Next();
}
#pragma warning disable CS0618 // Type or member is obsolete
public RuleAction ToAction()
{
return SimpleMapper.Map(this, new SlackAction());
}
#pragma warning restore CS0618 // Type or member is obsolete
}

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

@ -7,6 +7,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Migrations.OldActions;
using Squidex.Infrastructure.Plugins;
namespace Squidex.Extensions.Actions.Slack;
@ -20,6 +21,9 @@ public sealed class SlackPlugin : IPlugin
options.Timeout = TimeSpan.FromSeconds(2);
});
services.AddRuleAction<SlackAction, SlackActionHandler>();
services.AddFlowStep<SlackFlowStep>();
#pragma warning disable CS0618 // Type or member is obsolete
services.AddRuleAction<SlackAction>();
#pragma warning restore CS0618 // Type or member is obsolete
}
}

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

@ -5,35 +5,27 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
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")]
[Obsolete("Has been replaced by flows.")]
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 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; }
public override FlowStep ToFlowStep()
{
return SimpleMapper.Map(this, new TweetFlowStep());
}
}

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

@ -1,62 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using CoreTweet;
using Microsoft.Extensions.Options;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Twitter;
public sealed class TweetActionHandler(RuleEventFormatter formatter, IOptions<TwitterOptions> twitterOptions) : RuleActionHandler<TweetAction, TweetJob>(formatter)
{
private const string Description = "Send a tweet";
private readonly TwitterOptions twitterOptions = twitterOptions.Value;
protected override async Task<(string Description, TweetJob Data)> CreateJobAsync(EnrichedEvent @event, TweetAction action)
{
var ruleJob = new TweetJob
{
Text = (await FormatAsync(action.Text, @event))!,
AccessToken = action.AccessToken,
AccessSecret = action.AccessSecret,
};
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);
var request = new Dictionary<string, object>
{
["status"] = job.Text,
};
await tokens.Statuses.UpdateAsync(request, ct);
return Result.Success($"Tweeted: {job.Text}");
}
}
public sealed class TweetJob
{
public string AccessToken { get; set; }
public string AccessSecret { get; set; }
public string Text { get; set; }
}

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

@ -0,0 +1,79 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using CoreTweet;
using Microsoft.Extensions.Options;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Twitter;
[FlowStep(
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")]
#pragma warning disable CS0618 // Type or member is obsolete
public sealed record TweetFlowStep : FlowStep, IConvertibleToAction
#pragma warning restore CS0618 // Type or member is obsolete
{
[LocalizedRequired]
[Display(Name = "Access Token", Description = " The generated access token.")]
[Editor(FlowStepEditor.Text)]
public string AccessToken { get; set; }
[LocalizedRequired]
[Display(Name = "Access Secret", Description = " The generated access secret.")]
[Editor(FlowStepEditor.Text)]
public string AccessSecret { get; set; }
[LocalizedRequired]
[Display(Name = "Text", Description = "The text that is sent as tweet to twitter.")]
[Editor(FlowStepEditor.TextArea)]
[Expression]
public string Text { get; set; }
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
var twitterOptions = executionContext.Resolve<IOptions<TwitterOptions>>().Value;
var tokens = Tokens.Create(
twitterOptions.ClientId,
twitterOptions.ClientSecret,
AccessToken,
AccessSecret);
var request = new Dictionary<string, object>
{
["status"] = Text,
};
await tokens.Statuses.UpdateAsync(request, ct);
executionContext.Log("Tweeted", Text);
return Next();
}
#pragma warning disable CS0618 // Type or member is obsolete
public RuleAction ToAction()
{
return SimpleMapper.Map(this, new TweetAction());
}
#pragma warning restore CS0618 // Type or member is obsolete
}

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

@ -18,6 +18,9 @@ public sealed class TwitterPlugin : IPlugin
services.Configure<TwitterOptions>(
config.GetSection("twitter"));
services.AddRuleAction<TweetAction, TweetActionHandler>();
services.AddFlowStep<TweetFlowStep>();
#pragma warning disable CS0618 // Type or member is obsolete
services.AddRuleAction<TweetAction>();
#pragma warning restore CS0618 // Type or member is obsolete
}
}

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

@ -5,45 +5,31 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
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/")]
[Obsolete("Has been replaced by flows.")]
public sealed record TypesenseAction : RuleAction
{
[AbsoluteUrl]
[LocalizedRequired]
[Display(Name = "Server Url", Description = "The url to the instance or cluster.")]
[Editor(RuleFieldEditor.Url)]
public Uri Host { get; set; }
[LocalizedRequired]
[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; }
[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; }
public override FlowStep ToFlowStep()
{
return SimpleMapper.Map(this, new TypesenseFlowStep());
}
}

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

@ -1,138 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text;
using System.Text.Json.Serialization;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Infrastructure;
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;
public sealed class TypesenseActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory, IScriptEngine scriptEngine, IJsonSerializer serializer) : RuleActionHandler<TypesenseAction, TypesenseJob>(formatter)
{
protected override async Task<(string Description, TypesenseJob Data)> CreateJobAsync(EnrichedEvent @event, TypesenseAction action)
{
var delete = @event.ShouldDelete(scriptEngine, action.Delete);
string contentId;
if (@event is IEnrichedEntityEvent enrichedEntityEvent)
{
contentId = enrichedEntityEvent.Id.ToString();
}
else
{
contentId = DomainId.NewGuid().ToString();
}
var indexName = await FormatAsync(action.IndexName, @event);
var ruleText = string.Empty;
var ruleJob = new TypesenseJob
{
ServerUrl = $"{action.Host.ToString().TrimEnd('/')}/collections/{indexName}/documents",
ServerKey = action.ApiKey,
ContentId = contentId,
};
if (delete)
{
ruleText = $"Delete entry index: {indexName}";
}
else
{
ruleText = $"Upsert to index: {indexName}";
TypesenseContent content;
try
{
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!);
}
catch (Exception ex)
{
content = new TypesenseContent
{
More = new Dictionary<string, object>
{
["error"] = $"Invalid JSON: {ex.Message}",
},
};
}
content.Id = contentId;
ruleJob.Content = serializer.Serialize(content, true);
}
return (ruleText, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(TypesenseJob job,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(job.ServerUrl))
{
return Result.Ignored();
}
var httpClient = httpClientFactory.CreateClient("TypesenseAction");
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
{
request = new HttpRequestMessage(HttpMethod.Delete, $"{job.ServerUrl}/{job.ContentId}");
}
request.Headers.TryAddWithoutValidation("X-Typesense-Api-Key", job.ServerKey);
return await httpClient.OneWayRequestAsync(request, job.Content, ct);
}
}
public sealed class TypesenseContent
{
public string Id { get; set; }
[JsonExtensionData]
public Dictionary<string, object> More { get; set; } = [];
}
public sealed class TypesenseJob
{
public string ServerUrl { get; set; }
public string ServerKey { get; set; }
public string Content { get; set; }
public string ContentId { get; set; }
}

155
backend/extensions/Squidex.Extensions/Actions/Typesense/TypesenseFlowStep.cs

@ -0,0 +1,155 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.Text;
using System.Text.Json.Serialization;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.Typesense;
[FlowStep(
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/")]
#pragma warning disable CS0618 // Type or member is obsolete
public sealed record TypesenseFlowStep : FlowStep, IConvertibleToAction
#pragma warning restore CS0618 // Type or member is obsolete
{
[AbsoluteUrl]
[LocalizedRequired]
[Display(Name = "Server Url", Description = "The url to the instance or cluster.")]
[Editor(FlowStepEditor.Url)]
public Uri Host { get; set; }
[LocalizedRequired]
[Display(Name = "Index Name", Description = "The name of the index.")]
[Editor(FlowStepEditor.Text)]
[Expression]
public string IndexName { get; set; }
[LocalizedRequired]
[Display(Name = "Api Key", Description = "The api key.")]
[Editor(FlowStepEditor.Text)]
public string ApiKey { get; set; }
[Display(Name = "Document", Description = "The optional custom document.")]
[Editor(FlowStepEditor.TextArea)]
[Expression]
public string? Document { get; set; }
[Display(Name = "Deletion", Description = "The condition when to delete the document.")]
[Editor(FlowStepEditor.Text)]
public string? Delete { get; set; }
public override ValueTask PrepareAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
var @event = ((FlowEventContext)executionContext.Context).Event;
if (!@event.ShouldDelete(executionContext, Delete))
{
Document = null;
return default;
}
TypesenseContent content;
try
{
content = executionContext.DeserializeJson<TypesenseContent>(Document!);
content.Id = @event.GetOrCreateId().Id;
}
catch (Exception ex)
{
content = new TypesenseContent
{
More = new Dictionary<string, object>
{
["error"] = $"Invalid JSON: {ex.Message}",
},
Id = @event.GetOrCreateId().Id,
};
}
Document = executionContext.SerializeJson(content);
return base.PrepareAsync(executionContext, ct);
}
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
var @event = ((FlowEventContext)executionContext.Context).Event;
var (id, isGenerated) = @event.GetOrCreateId();
if (isGenerated && Document == null)
{
executionContext.LogSkipped("Can only delete content for static identities.");
return Next();
}
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
async Task SendAsync(HttpRequestMessage request, string? body, string message)
{
request.Headers.TryAddWithoutValidation("X-Typesense-Api-Key", ApiKey);
var httpClient =
executionContext.Resolve<IHttpClientFactory>()
.CreateClient("TypesenseAction");
var (_, dump) = await httpClient.SendAsync(executionContext, request, body, ct);
executionContext.Log(message, dump);
}
if (Document != null)
{
var request = new HttpRequestMessage(HttpMethod.Post, $"{Host}?action=upsert")
{
Content = new StringContent(Document, Encoding.UTF8, "application/json"),
};
await SendAsync(request, Document, $"Document with ID '{id}' upserted");
}
else
{
var request = new HttpRequestMessage(HttpMethod.Delete, $"{Host}/{id}");
await SendAsync(request, Document, $"Document with ID '{id}' deleted");
}
return Next();
}
#pragma warning disable CS0618 // Type or member is obsolete
public RuleAction ToAction()
{
return SimpleMapper.Map(this, new TypesenseAction());
}
#pragma warning restore CS0618 // Type or member is obsolete
public sealed class TypesenseContent
{
public string Id { get; set; }
[JsonExtensionData]
public Dictionary<string, object> More { get; set; } = [];
}
}

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

@ -16,6 +16,10 @@ public sealed class TypesensePlugin : IPlugin
public void ConfigureServices(IServiceCollection services, IConfiguration config)
{
services.AddHttpClient("TypesenseAction");
services.AddRuleAction<TypesenseAction, TypesenseActionHandler>();
services.AddFlowStep<TypesenseFlowStep>();
#pragma warning disable CS0618 // Type or member is obsolete
services.AddRuleAction<TypesenseAction>();
#pragma warning restore CS0618 // Type or member is obsolete
}
}

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

@ -5,58 +5,32 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
#pragma warning disable MA0048 // File name must match type name
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")]
[Obsolete("Has been replaced by flows.")]
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)]
[Formattable]
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,
public override FlowStep ToFlowStep()
{
return SimpleMapper.Map(this, new WebhookFlowStep());
}
}

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

@ -1,144 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Infrastructure;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Extensions.Actions.Webhook;
public sealed class WebhookActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory) : RuleActionHandler<WebhookAction, WebhookJob>(formatter)
{
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;
if (action.Method != WebhookMethod.GET)
{
if (!string.IsNullOrEmpty(action.Payload))
{
requestBody = await FormatAsync(action.Payload, @event);
}
else
{
requestBody = ToEnvelopeJson(@event);
}
requestSignature = $"{requestBody}{action.SharedSecret}".ToSha256Base64();
}
var ruleText = $"Send event to webhook '{requestUrl}'";
var ruleJob = new WebhookJob
{
Method = action.Method,
RequestUrl = (await FormatAsync(action.Url.ToString(), @event))!,
RequestSignature = requestSignature,
RequestBody = requestBody!,
RequestBodyType = action.PayloadType,
Headers = await ParseHeadersAsync(action.Headers, @event),
};
return (ruleText, ruleJob);
}
private async Task<Dictionary<string, string>?> ParseHeadersAsync(string? headers, EnrichedEvent @event)
{
if (string.IsNullOrWhiteSpace(headers))
{
return null;
}
var headersDictionary = new Dictionary<string, string>();
var lines = headers.Split('\n');
foreach (var line in lines)
{
var indexEqual = line.IndexOf('=', StringComparison.Ordinal);
if (indexEqual > 0 && indexEqual < line.Length - 1)
{
var headerKey = line[..indexEqual];
var headerValue = line[(indexEqual + 1)..];
headerValue = await FormatAsync(headerValue, @event);
headersDictionary[headerKey] = headerValue!;
}
}
return headersDictionary;
}
protected override async Task<Result> ExecuteJobAsync(WebhookJob job,
CancellationToken ct = default)
{
var httpClient = httpClientFactory.CreateClient("WebhookAction");
var method = HttpMethod.Post;
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 request = new HttpRequestMessage(method, job.RequestUrl);
if (!string.IsNullOrEmpty(job.RequestBody) && job.Method != WebhookMethod.GET)
{
var mediaType = job.RequestBodyType.Or("application/json");
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);
}
}
if (!string.IsNullOrWhiteSpace(job.RequestSignature))
{
request.Headers.Add("X-Signature", job.RequestSignature);
}
return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct);
}
}
public sealed class WebhookJob
{
public WebhookMethod Method { get; set; }
public string RequestUrl { get; set; }
public string RequestSignature { get; set; }
public string RequestBody { get; set; }
public string? RequestBodyType { get; set; }
public Dictionary<string, string>? Headers { get; set; }
}

146
backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookFlowStep.cs

@ -0,0 +1,146 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.Text;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Flows.Steps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
#pragma warning disable CS0618 // Type or member is obsolete
namespace Squidex.Extensions.Actions.Webhook;
[FlowStep(
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 WebhookFlowStep : FlowStep, IConvertibleToAction
{
[LocalizedRequired]
[Display(Name = "Method", Description = "The type of the request.")]
public WebhookMethod Method { get; set; }
[LocalizedRequired]
[Display(Name = "Url", Description = "The URL to the webhook.")]
[Expression]
public Uri Url { get; set; }
[Expression(ExpressionFallback.Envelope)]
[Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")]
[Editor(FlowStepEditor.TextArea)]
public string? Payload { get; set; }
[Expression]
[Display(Name = "Headers (Optional)", Description = "The message headers in the format '[Key]=[Value]', one entry per line.")]
[Editor(FlowStepEditor.TextArea)]
public string? Headers { get; set; }
[Display(Name = "Payload Type", Description = "The mime type of the payload.")]
[Editor(FlowStepEditor.Text)]
public string? PayloadType { get; set; }
[Display(Name = "Shared Secret", Description = "The shared secret that is used to calculate the payload signature.")]
[Editor(FlowStepEditor.Text)]
public string? SharedSecret { get; set; }
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
var method = HttpMethod.Post;
switch (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 (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
var httpClient = executionContext.Resolve<IHttpClientFactory>().CreateClient("FlowClient");
var request = new HttpRequestMessage(method, Url);
if (!string.IsNullOrEmpty(Payload) && Method != WebhookMethod.GET)
{
var mediaType = PayloadType;
if (string.IsNullOrEmpty(mediaType))
{
mediaType = "application/json";
}
request.Content = new StringContent(Payload, Encoding.UTF8, mediaType);
}
var headers = ParseHeaders();
if (headers != null)
{
foreach (var (key, value) in headers)
{
request.Headers.TryAddWithoutValidation(key, value);
}
}
var signature = $"{Payload}{SharedSecret}".ToSha256Base64();
request.Headers.Add("X-Signature", signature);
var (_, dump) = await httpClient.SendAsync(executionContext, request, Payload, ct);
executionContext.Log("HTTP request sent", dump);
return Next();
}
private Dictionary<string, string>? ParseHeaders()
{
if (string.IsNullOrWhiteSpace(Headers))
{
return null;
}
var headersDictionary = new Dictionary<string, string>();
var lines = Headers.Split('\n');
foreach (var line in lines)
{
var indexEqual = line.IndexOf('=', StringComparison.Ordinal);
if (indexEqual > 0 && indexEqual < line.Length - 1)
{
var headerKey = line[..indexEqual];
var headerValue = line[(indexEqual + 1)..];
headersDictionary[headerKey] = headerValue!;
}
}
return headersDictionary;
}
public RuleAction ToAction()
{
return SimpleMapper.Map(this, new WebhookAction());
}
}

11
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EditorAttribute.cs → backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookMethod.cs

@ -5,10 +5,13 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Core.HandleRules;
namespace Squidex.Extensions.Actions.Webhook;
[AttributeUsage(AttributeTargets.Property)]
public sealed class EditorAttribute(RuleFieldEditor editor) : Attribute
public enum WebhookMethod
{
public RuleFieldEditor Editor { get; } = editor;
POST,
PUT,
GET,
DELETE,
PATCH,
}

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

@ -15,12 +15,9 @@ public sealed class WebhookPlugin : IPlugin
{
public void ConfigureServices(IServiceCollection services, IConfiguration config)
{
services.AddHttpClient("WebhookPlugin", options =>
{
options.DefaultRequestHeaders.Add("User-Agent", "Squidex Webhook");
options.DefaultRequestHeaders.Add("X-Application", "Squidex Webhook");
});
services.AddRuleAction<WebhookAction, WebhookActionHandler>();
services.AddFlowStep<WebhookFlowStep>();
#pragma warning disable CS0618 // Type or member is obsolete
services.AddRuleAction<WebhookAction>();
#pragma warning restore CS0618 // Type or member is obsolete
}
}

1
backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj

@ -28,6 +28,7 @@
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
<PackageReference Include="Microsoft.OData.Core" Version="8.2.1" />
<PackageReference Include="NodaTime" Version="3.2.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="OpenSearch.Net" Version="1.8.0" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.9.0" />

5
backend/i18n/clean.bat

@ -1,5 +0,0 @@
cd translator\Squidex.Translator
dotnet run translate clean-backend ..\..\..\..
dotnet run translate clean-frontend ..\..\..\..

7
backend/i18n/clean.ps1

@ -0,0 +1,7 @@
cd translator\Squidex.Translator
dotnet run --no-restore translate clean-backend ..\..\..\..
dotnet run --no-restore translate clean-frontend ..\..\..\..
cd ..\..

7
backend/i18n/clean.sh

@ -0,0 +1,7 @@
cd translator\Squidex.Translator
/usr/local/share/dotnet/dotnet run --no-restore run translate clean-backend ..\..\..\..
/usr/local/share/dotnet/dotnet run --no-restore run translate clean-frontend ..\..\..\..
cd ..\..

3
backend/i18n/extract_keys.bat

@ -1,3 +0,0 @@
cd translator\Squidex.Translator
dotnet run translate check-frontend ..\..\..\.. -l en --fix

5
backend/i18n/extract_keys.ps1

@ -0,0 +1,5 @@
cd translator\Squidex.Translator
dotnet run translate check-frontend --no-restore ..\..\..\.. -l en --fix
cd ..\..

5
backend/i18n/extract_keys.sh

@ -0,0 +1,5 @@
cd translator\Squidex.Translator
/usr/local/share/dotnet/dotnet run --no-restore run translate check-frontend ..\..\..\.. -l en --fix
cd ..\..

32
backend/i18n/frontend_en.json

@ -220,6 +220,7 @@
"common.clusterPageTitle": "Cluster",
"common.collapse": "Collapse",
"common.comments": "Comments",
"common.completed": "Completed",
"common.components": "Components",
"common.condition": "Condition",
"common.conditions": "Conditions",
@ -274,6 +275,7 @@
"common.files": "Files",
"common.filters": "Filters",
"common.finish": "Finish",
"common.flow": "Flow",
"common.folder": "Folder",
"common.folders": "Folders",
"common.from": "From",
@ -318,6 +320,7 @@
"common.options": "Options",
"common.or": "or",
"common.order": "Order",
"common.output": "Output",
"common.pagerInfo": "{itemFirst}-{itemLast} of {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?",
"common.pagerReload": "Click to reload view and get total number of items",
@ -349,7 +352,7 @@
"common.references": "References",
"common.refresh": "Refresh",
"common.remember": "Don't ask again",
"common.remove": "remove",
"common.remove": "Remove",
"common.rename": "Rename",
"common.renameTag": "Rename Tag",
"common.reply": "Reply",
@ -379,6 +382,7 @@
"common.skipped": "Skipped",
"common.slug": "Slug",
"common.stars.max": "Must not have more than 15 stars",
"common.started": "Started",
"common.status": "Status",
"common.statusChangeTo": "Change to",
"common.submit": "Submit",
@ -628,7 +632,6 @@
"jobs.restoreStoppedLabel": "Stopped",
"jobs.restoreTitle": "Restore Backup",
"jobs.started": "Job started, it can take several minutes to complete.",
"jobs.startedLabel": "Started",
"languages.add": "Add Language",
"languages.add.description": "Add a new language that you want to support for your content.",
"languages.add.title": "Add a new Language",
@ -701,8 +704,8 @@
"roles.revokeFailed": "Failed to revoke role. Please reload.",
"roles.roleNamePlaceholder": "Enter role name",
"roles.updateFailed": "Failed to update role. Please reload.",
"rules.actionData": "Action Data",
"rules.actionHint": "The selection of the action type cannot be changed later.",
"rules.addStep": "Add Step",
"rules.addTrigger": "Add trigger",
"rules.advancedFormattingHint": "You can use advanced formatting",
"rules.cancelFailed": "Failed to cancel rule. Please reload.",
"rules.condition": "Condition",
@ -721,6 +724,7 @@
"rules.conditions.usageLimit": "Limit",
"rules.conditions.usageLimitHint": "The monthly api calls to trigger.",
"rules.create": "New Rule",
"rules.created": "Rule has been created successfully.",
"rules.createFailed": "Failed to create rule. Please reload.",
"rules.createTooltip": "New Rule",
"rules.deleteConfirmText": "Do you really want to delete the rule?",
@ -731,7 +735,6 @@
"rules.empty": "No rule created yet.",
"rules.emptyAddRule": "Add Rule",
"rules.enqueued": "Rule has been added to the queue.",
"rules.enrichedEvent": "Enriched Event",
"rules.itemPageTitle": "Rule",
"rules.listPageTitle": "Rules",
"rules.loadFailed": "Failed to load Rules. Please reload.",
@ -741,7 +744,11 @@
"rules.refreshEventsTooltip": "Refresh Events",
"rules.refreshTooltip": "Refresh Rules",
"rules.reloaded": "Rules reloaded.",
"rules.removeElementText": "Do you really want to remove this element (step or trigger)",
"rules.removeElementTitle": "Remove element",
"rules.restarted": "Rule will start to run in a few seconds.",
"rules.ruleEvents.attempt": "Attempt",
"rules.ruleEvents.attempts": "Attempts",
"rules.ruleEvents.cancelAllConfirmText": "Do you really want to cancel all events?",
"rules.ruleEvents.cancelAllConfirmTitle": "Cancel all events",
"rules.ruleEvents.cancelConfirmText": "Do you really want to cancel this event?",
@ -750,14 +757,12 @@
"rules.ruleEvents.enqueue": "Enqueue",
"rules.ruleEvents.enqueued": "Events enqueued. Will be resend in a few seconds.",
"rules.ruleEvents.enqueueFailed": "Failed to enqueue rule event. Please reload.",
"rules.ruleEvents.lastInvokedLabel": "Last Invocation",
"rules.ruleEvents.listPageTitle": "Rule Events",
"rules.ruleEvents.loadFailed": "Failed to load events. Please reload.",
"rules.ruleEvents.nextAttemptLabel": "Next",
"rules.ruleEvents.numAttemptsLabel": "Attempts",
"rules.ruleEvents.reloaded": "RuleEvents reloaded.",
"rules.ruleSimulator.listPageTitle": "Simulator",
"rules.ruleSyntax.if": "If",
"rules.ruleSyntax.if": "when",
"rules.ruleSyntax.then": "then",
"rules.run": "Replay from Events",
"rules.runFailed": "Failed to run rule. Please reload.",
@ -768,8 +773,6 @@
"rules.schemas.hint": "Define on which schemas and changes you want to run the content trigger.",
"rules.simulate": "Simulate",
"rules.simulateTooltip": "Simulate this rules using the last 100 events.",
"rules.simulation.actionCreated": "Job is created from the enriched event and action and added to a job queue.",
"rules.simulation.actionExecuted": "Job is taken from the queue and executed.",
"rules.simulation.errorConditionDoesNotMatch": "STOP: Javascript expressions in trigger do not match to the event.",
"rules.simulation.errorConditionPrecheckDoesNotMatch": "STOP: Condition in trigger does not match to the event.",
"rules.simulation.errorDisabled": "STOP: Rule is disabled.",
@ -785,13 +788,20 @@
"rules.simulation.eventQueried": "Event is queried from the database",
"rules.simulation.eventTriggerChecked": "Event is tested to see if it matchs to the trigger and the basic conditions.",
"rules.simulator": "Simulator",
"rules.step.add": "Add Step",
"rules.step.edit": "Edit Step",
"rules.stepIgnoreError": "Ignore error",
"rules.stepIgnoreErrorHint": "Ignore when this step fails and continue with the next step (after all retries), otherwise completes the flow.",
"rules.stepNameHint": "The name of the rule. Shown in logs and debug views.",
"rules.stop": "Rule will stop soon.",
"rules.trigger.add": "Add Trigger",
"rules.trigger.edit": "Edit Trigger",
"rules.triggerAll": "Trigger on all content events",
"rules.triggerConfirmText": "Do you really want to trigger the rule?",
"rules.triggerConfirmTitle": "Trigger rule",
"rules.triggerFailed": "Failed to trigger rule. Please reload.",
"rules.triggerHint": "The selection of the trigger type cannot be changed later.",
"rules.unnamed": "Unnamed Rule",
"rules.updated": "Rule has been updated successfully.",
"rules.updateFailed": "Failed to update rule. Please reload.",
"rules.when": "When",
"schemas.addField": "Add Field",

30
backend/i18n/frontend_fr.json

@ -220,6 +220,7 @@
"common.clusterPageTitle": "Grappe",
"common.collapse": "Collapse",
"common.comments": "commentaires",
"common.completed": "Completed",
"common.components": "Composants",
"common.condition": "Condition",
"common.conditions": "Conditions",
@ -274,6 +275,7 @@
"common.files": "Des dossiers",
"common.filters": "Filtres",
"common.finish": "Finish",
"common.flow": "Flow",
"common.folder": "Dossier",
"common.folders": "Dossiers",
"common.from": "Depuis",
@ -318,6 +320,7 @@
"common.options": "Options",
"common.or": "ou",
"common.order": "Order",
"common.output": "Output",
"common.pagerInfo": "{itemFirst}-{itemLast} de {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} Du total?",
"common.pagerReload": "Cliquez pour recharger la vue et obtenir le nombre total d'articles",
@ -349,7 +352,7 @@
"common.references": "Les références",
"common.refresh": "Rafraîchir",
"common.remember": "Ne demande plus",
"common.remove": "remove",
"common.remove": "Remove",
"common.rename": "Renommer",
"common.renameTag": "Renommer la balise",
"common.reply": "Reply",
@ -379,6 +382,7 @@
"common.skipped": "Ignoré",
"common.slug": "Limace",
"common.stars.max": "Ne doit pas avoir plus de 15 étoiles",
"common.started": "Started",
"common.status": "Statut",
"common.statusChangeTo": "Passer à",
"common.submit": "Soumettre",
@ -628,7 +632,6 @@
"jobs.restoreStoppedLabel": "Stopped",
"jobs.restoreTitle": "Restore Backup",
"jobs.started": "Job started, it can take several minutes to complete.",
"jobs.startedLabel": "Started",
"languages.add": "Ajouter une langue",
"languages.add.description": "Ajoutez une nouvelle langue que vous souhaitez prendre en charge pour votre contenu.",
"languages.add.title": "Ajouter une nouvelle langue",
@ -701,8 +704,8 @@
"roles.revokeFailed": "Échec de la révocation du rôle. Veuillez recharger.",
"roles.roleNamePlaceholder": "Entrez le nom du rôle",
"roles.updateFailed": "Échec de la mise à jour du rôle. Veuillez recharger.",
"rules.actionData": "Données d'action",
"rules.actionHint": "La sélection du type d'action ne peut pas être modifiée ultérieurement.",
"rules.addStep": "Add Step",
"rules.addTrigger": "Add trigger",
"rules.advancedFormattingHint": "Vous pouvez utiliser le formatage avancé",
"rules.cancelFailed": "Échec de l'annulation de la règle. Veuillez recharger.",
"rules.condition": "Condition",
@ -721,6 +724,7 @@
"rules.conditions.usageLimit": "Limite",
"rules.conditions.usageLimitHint": "Les appels API mensuels à déclencher.",
"rules.create": "Nouvelle règle",
"rules.created": "Rule has been created successfully.",
"rules.createFailed": "Échec de la création de la règle. Veuillez recharger.",
"rules.createTooltip": "Nouvelle règle",
"rules.deleteConfirmText": "Voulez-vous vraiment supprimer la règle\u00A0?",
@ -731,7 +735,6 @@
"rules.empty": "Aucune règle n'a encore été créée.",
"rules.emptyAddRule": "Ajouter une règle",
"rules.enqueued": "La règle a été ajoutée à la file d'attente.",
"rules.enrichedEvent": "Événement enrichi",
"rules.itemPageTitle": "Règle",
"rules.listPageTitle": "Règles",
"rules.loadFailed": "Échec du chargement des règles. Veuillez recharger.",
@ -741,7 +744,11 @@
"rules.refreshEventsTooltip": "Actualiser les événements",
"rules.refreshTooltip": "Actualiser les règles",
"rules.reloaded": "Règles rechargées.",
"rules.removeElementText": "Do you really want to remove this element (step or trigger)",
"rules.removeElementTitle": "Remove element",
"rules.restarted": "La règle commencera à s'exécuter dans quelques secondes.",
"rules.ruleEvents.attempt": "Attempt",
"rules.ruleEvents.attempts": "Attempts",
"rules.ruleEvents.cancelAllConfirmText": "Voulez-vous vraiment annuler tous les événements\u00A0?",
"rules.ruleEvents.cancelAllConfirmTitle": "Annuler tous les événements",
"rules.ruleEvents.cancelConfirmText": "Voulez-vous vraiment annuler cet événement\u00A0?",
@ -750,11 +757,9 @@
"rules.ruleEvents.enqueue": "Mettre en file d'attente",
"rules.ruleEvents.enqueued": "Événements mis en file d'attente. Sera renvoyé dans quelques secondes.",
"rules.ruleEvents.enqueueFailed": "Échec de la mise en file d'attente de l'événement de règle. Veuillez recharger.",
"rules.ruleEvents.lastInvokedLabel": "Dernière invocation",
"rules.ruleEvents.listPageTitle": "Événements de règle",
"rules.ruleEvents.loadFailed": "Échec du chargement des événements. Veuillez recharger.",
"rules.ruleEvents.nextAttemptLabel": "Suivant",
"rules.ruleEvents.numAttemptsLabel": "Tentatives",
"rules.ruleEvents.reloaded": "RuleEvents rechargé.",
"rules.ruleSimulator.listPageTitle": "Simulateur",
"rules.ruleSyntax.if": "Si",
@ -768,8 +773,6 @@
"rules.schemas.hint": "Définissez sur quels schémas et modifications vous souhaitez exécuter le déclencheur de contenu.",
"rules.simulate": "Simuler",
"rules.simulateTooltip": "Simulez ces règles en utilisant les 100 derniers événements.",
"rules.simulation.actionCreated": "La tâche est créée à partir de l'événement et de l'action enrichis et ajoutée à une file d'attente de tâches.",
"rules.simulation.actionExecuted": "Le travail est extrait de la file d'attente et exécuté.",
"rules.simulation.errorConditionDoesNotMatch": "STOP\u00A0: les expressions Javascript dans le déclencheur ne correspondent pas à l'événement.",
"rules.simulation.errorConditionPrecheckDoesNotMatch": "STOP\u00A0: la condition dans le déclencheur ne correspond pas à l'événement.",
"rules.simulation.errorDisabled": "STOP\u00A0: la règle est désactivée.",
@ -785,13 +788,20 @@
"rules.simulation.eventQueried": "L'événement est interrogé à partir de la base de données",
"rules.simulation.eventTriggerChecked": "L'événement est testé pour voir s'il correspond au déclencheur et aux conditions de base.",
"rules.simulator": "Simulateur",
"rules.step.add": "Add Step",
"rules.step.edit": "Edit Step",
"rules.stepIgnoreError": "Ignore error",
"rules.stepIgnoreErrorHint": "Ignore when this step fails and continue with the next step (after all retries), otherwise completes the flow.",
"rules.stepNameHint": "The name of the rule. Shown in logs and debug views.",
"rules.stop": "La règle s'arrêtera bientôt.",
"rules.trigger.add": "Add Trigger",
"rules.trigger.edit": "Edit Trigger",
"rules.triggerAll": "Déclencher sur tous les événements de contenu",
"rules.triggerConfirmText": "Voulez-vous vraiment déclencher la règle\u00A0?",
"rules.triggerConfirmTitle": "Règle de déclenchement",
"rules.triggerFailed": "Échec du déclenchement de la règle. Veuillez recharger.",
"rules.triggerHint": "La sélection du type de déclencheur ne peut pas être modifiée ultérieurement.",
"rules.unnamed": "Règle sans nom",
"rules.updated": "Rule has been updated successfully.",
"rules.updateFailed": "Échec de la mise à jour de la règle. Veuillez recharger.",
"rules.when": "Quand",
"schemas.addField": "Ajouter le champ",

30
backend/i18n/frontend_it.json

@ -220,6 +220,7 @@
"common.clusterPageTitle": "Cluster",
"common.collapse": "Collapse",
"common.comments": "Commenti",
"common.completed": "Completed",
"common.components": "Components",
"common.condition": "Condition",
"common.conditions": "Conditions",
@ -274,6 +275,7 @@
"common.files": "Campi",
"common.filters": "Filtri",
"common.finish": "Finish",
"common.flow": "Flow",
"common.folder": "Cartella",
"common.folders": "Cartelle",
"common.from": "From",
@ -318,6 +320,7 @@
"common.options": "Options",
"common.or": "o",
"common.order": "Order",
"common.output": "Output",
"common.pagerInfo": "{itemFirst}-{itemLast} of {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?",
"common.pagerReload": "Click to reload view and get total number of items",
@ -349,7 +352,7 @@
"common.references": "References",
"common.refresh": "Aggiorna",
"common.remember": "Ricorda la mia decisione",
"common.remove": "remove",
"common.remove": "Remove",
"common.rename": "Rinomina",
"common.renameTag": "Rename Tag",
"common.reply": "Reply",
@ -379,6 +382,7 @@
"common.skipped": "Skipped",
"common.slug": "Slug",
"common.stars.max": "Non deve avere più di 15 stelle",
"common.started": "Started",
"common.status": "Stato",
"common.statusChangeTo": "Cambia in",
"common.submit": "Invia",
@ -628,7 +632,6 @@
"jobs.restoreStoppedLabel": "Stopped",
"jobs.restoreTitle": "Restore Backup",
"jobs.started": "Job started, it can take several minutes to complete.",
"jobs.startedLabel": "Started",
"languages.add": "Aggiungi lingua",
"languages.add.description": "Add a new language that you want to support for your content.",
"languages.add.title": "Add a new Language",
@ -701,8 +704,8 @@
"roles.revokeFailed": "Non è stato possibile rimuovere il ruolo. Per favore ricarica.",
"roles.roleNamePlaceholder": "Inserisci il nome del ruolo",
"roles.updateFailed": "Non è stato possibile aggiornare il ruolo. Per favore ricarica.",
"rules.actionData": "Action Data",
"rules.actionHint": "The selection of the action type cannot be changed later.",
"rules.addStep": "Add Step",
"rules.addTrigger": "Add trigger",
"rules.advancedFormattingHint": "You can use advanced formatting",
"rules.cancelFailed": "Non è stato possibile eliminare la regola. Per favore ricarica.",
"rules.condition": "Condition",
@ -721,6 +724,7 @@
"rules.conditions.usageLimit": "Limit",
"rules.conditions.usageLimitHint": "The monthly api calls to trigger.",
"rules.create": "Crea un nuova Regola",
"rules.created": "Rule has been created successfully.",
"rules.createFailed": "Non è stato possibile creare una nuova regola. Per favore ricarica.",
"rules.createTooltip": "Nuova regola",
"rules.deleteConfirmText": "Sei sicuro di voler eliminare la regola?",
@ -731,7 +735,6 @@
"rules.empty": "Nessuna regola è stato ancora creata.",
"rules.emptyAddRule": "Aggiungi una regola",
"rules.enqueued": "La regola è stata aggiunta alle code.",
"rules.enrichedEvent": "Enriched Event",
"rules.itemPageTitle": "Rule",
"rules.listPageTitle": "Regole",
"rules.loadFailed": "Non è stato possibile caricare le regole. Per favore ricarica.",
@ -741,7 +744,11 @@
"rules.refreshEventsTooltip": "Aggiorna gli Eventi",
"rules.refreshTooltip": "Aggiorna le Regole",
"rules.reloaded": "Regole ricaricate.",
"rules.removeElementText": "Do you really want to remove this element (step or trigger)",
"rules.removeElementTitle": "Remove element",
"rules.restarted": "La Regola sarà eseguita fra pochi secondi.",
"rules.ruleEvents.attempt": "Attempt",
"rules.ruleEvents.attempts": "Attempts",
"rules.ruleEvents.cancelAllConfirmText": "Do you really want to cancel all events?",
"rules.ruleEvents.cancelAllConfirmTitle": "Cancel all events",
"rules.ruleEvents.cancelConfirmText": "Do you really want to cancel this event?",
@ -750,11 +757,9 @@
"rules.ruleEvents.enqueue": "Metti in coda",
"rules.ruleEvents.enqueued": "Eventi messo in coda. L'evento potrà essere rieseguito fra pochi secondi.",
"rules.ruleEvents.enqueueFailed": "Non è stato possibile mettere in coda l'evento della regola. Per favore ricarica.",
"rules.ruleEvents.lastInvokedLabel": "Ultima chiamata",
"rules.ruleEvents.listPageTitle": "Eventi della Regola",
"rules.ruleEvents.loadFailed": "Non è stato possibile caricare gli eventi. Per favore ricarica.",
"rules.ruleEvents.nextAttemptLabel": "Successivo",
"rules.ruleEvents.numAttemptsLabel": "Tentativi",
"rules.ruleEvents.reloaded": "Eventi della regola ricaricati.",
"rules.ruleSimulator.listPageTitle": "Simulator",
"rules.ruleSyntax.if": "Se",
@ -768,8 +773,6 @@
"rules.schemas.hint": "Define on which schemas and changes you want to run the content trigger.",
"rules.simulate": "Simulate",
"rules.simulateTooltip": "Simulate this rules using the last 100 events.",
"rules.simulation.actionCreated": "Job is created from the enriched event and action and added to a job queue.",
"rules.simulation.actionExecuted": "Job is taken from the queue and executed.",
"rules.simulation.errorConditionDoesNotMatch": "STOP: Javascript expressions in trigger do not match to the event.",
"rules.simulation.errorConditionPrecheckDoesNotMatch": "STOP: Condition in trigger does not match to the event.",
"rules.simulation.errorDisabled": "STOP: Rule is disabled.",
@ -785,13 +788,20 @@
"rules.simulation.eventQueried": "Event is queried from the database",
"rules.simulation.eventTriggerChecked": "Event is tested to see if it matchs to the trigger and the basic conditions.",
"rules.simulator": "Simulator",
"rules.step.add": "Add Step",
"rules.step.edit": "Edit Step",
"rules.stepIgnoreError": "Ignore error",
"rules.stepIgnoreErrorHint": "Ignore when this step fails and continue with the next step (after all retries), otherwise completes the flow.",
"rules.stepNameHint": "The name of the rule. Shown in logs and debug views.",
"rules.stop": "La regola si fermerà al più presto.",
"rules.trigger.add": "Add Trigger",
"rules.trigger.edit": "Edit Trigger",
"rules.triggerAll": "Trigger on all content events",
"rules.triggerConfirmText": "Sei sicuro che voler attivare la regola?",
"rules.triggerConfirmTitle": "Attiva la regola",
"rules.triggerFailed": "Non è stato possibile attivare la regola. Per favore ricarica.",
"rules.triggerHint": "The selection of the trigger type cannot be changed later.",
"rules.unnamed": "Regola senza nome",
"rules.updated": "Rule has been updated successfully.",
"rules.updateFailed": "Non è stato possibile aggiornare la regola. Per favore ricarica.",
"rules.when": "When",
"schemas.addField": "Aggiungi un Campo",

30
backend/i18n/frontend_nl.json

@ -220,6 +220,7 @@
"common.clusterPageTitle": "Cluster",
"common.collapse": "Collapse",
"common.comments": "Reacties",
"common.completed": "Completed",
"common.components": "Componenten",
"common.condition": "Condition",
"common.conditions": "Conditions",
@ -274,6 +275,7 @@
"common.files": "Bestanden",
"common.filters": "Filters",
"common.finish": "Finish",
"common.flow": "Flow",
"common.folder": "Map",
"common.folders": "Mappen",
"common.from": "Van",
@ -318,6 +320,7 @@
"common.options": "Options",
"common.or": "of",
"common.order": "Order",
"common.output": "Output",
"common.pagerInfo": "{itemFirst} - {itemLast} van {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?",
"common.pagerReload": "Click to reload view and get total number of items",
@ -349,7 +352,7 @@
"common.references": "References",
"common.refresh": "Vernieuwen",
"common.remember": "Onthoud mijn keuze",
"common.remove": "remove",
"common.remove": "Remove",
"common.rename": "Hernoemen",
"common.renameTag": "Hernoem Tag",
"common.reply": "Reply",
@ -379,6 +382,7 @@
"common.skipped": "Overgeslagen",
"common.slug": "Slug",
"common.stars.max": "Mag niet meer dan 15 sterren hebben",
"common.started": "Started",
"common.status": "Status",
"common.statusChangeTo": "Wijzigen in",
"common.submit": "Verzenden",
@ -628,7 +632,6 @@
"jobs.restoreStoppedLabel": "Stopped",
"jobs.restoreTitle": "Restore Backup",
"jobs.started": "Job started, it can take several minutes to complete.",
"jobs.startedLabel": "Started",
"languages.add": "Taal toevoegen",
"languages.add.description": "Voeg een nieuwe taal toe die u wilt ondersteunen voor uw inhoud.",
"languages.add.title": "Nieuwe taal toevoegen",
@ -701,8 +704,8 @@
"roles.revokeFailed": "Kan rol niet intrekken. Laad opnieuw.",
"roles.roleNamePlaceholder": "Voer de rolnaam in",
"roles.updateFailed": "Update rol mislukt. Laad opnieuw.",
"rules.actionData": "Actiegegevens",
"rules.actionHint": "De selectie van het actietype kan later niet worden gewijzigd.",
"rules.addStep": "Add Step",
"rules.addTrigger": "Add trigger",
"rules.advancedFormattingHint": "You can use advanced formatting",
"rules.cancelFailed": "Annuleren van regel is mislukt. Laad opnieuw.",
"rules.condition": "Condition",
@ -721,6 +724,7 @@
"rules.conditions.usageLimit": "Limit",
"rules.conditions.usageLimitHint": "The monthly api calls to trigger.",
"rules.create": "Maak een nieuwe regel",
"rules.created": "Rule has been created successfully.",
"rules.createFailed": "Maken van regel is mislukt. Laad opnieuw.",
"rules.createTooltip": "Nieuwe regel",
"rules.deleteConfirmText": "Wil je de regel echt verwijderen?",
@ -731,7 +735,6 @@
"rules.empty": "Nog geen regel aangemaakt.",
"rules.emptyAddRule": "Regel toevoegen",
"rules.enqueued": "Regel is toegevoegd aan de wachtrij.",
"rules.enrichedEvent": "Verrijk Evenement",
"rules.itemPageTitle": "Regels",
"rules.listPageTitle": "Regels",
"rules.loadFailed": "Laden van regels is mislukt. Laad opnieuw.",
@ -741,7 +744,11 @@
"rules.refreshEventsTooltip": "Ververs evenementen",
"rules.refreshTooltip": "Vernieuwingsregels",
"rules.reloaded": "Regels herladen.",
"rules.removeElementText": "Do you really want to remove this element (step or trigger)",
"rules.removeElementTitle": "Remove element",
"rules.restarted": "Regel begint over een paar seconden te lopen.",
"rules.ruleEvents.attempt": "Attempt",
"rules.ruleEvents.attempts": "Attempts",
"rules.ruleEvents.cancelAllConfirmText": "Weet je zeker dat je alle evenementen wilt annuleren?",
"rules.ruleEvents.cancelAllConfirmTitle": "Annuleer alle evenementen",
"rules.ruleEvents.cancelConfirmText": "Weet je zeker dat je dit evenement wilt annuleren?",
@ -750,11 +757,9 @@
"rules.ruleEvents.enqueue": "In de wachtrij plaatsen",
"rules.ruleEvents.enqueued": "Evenementen in de wachtrij geplaatst. Worden over enkele seconden opnieuw verzonden.",
"rules.ruleEvents.enqueueFailed": "Kan regelgebeurtenis niet in wachtrij plaatsen. Laad opnieuw.",
"rules.ruleEvents.lastInvokedLabel": "Laatste aanroep",
"rules.ruleEvents.listPageTitle": "Regelgebeurtenissen",
"rules.ruleEvents.loadFailed": "Kan evenementen niet laden. Laad opnieuw.",
"rules.ruleEvents.nextAttemptLabel": "Volgende",
"rules.ruleEvents.numAttemptsLabel": "Pogingen",
"rules.ruleEvents.reloaded": "RuleEvents herladen.",
"rules.ruleSimulator.listPageTitle": "Simulator",
"rules.ruleSyntax.if": "If",
@ -768,8 +773,6 @@
"rules.schemas.hint": "Define on which schemas and changes you want to run the content trigger.",
"rules.simulate": "Simuleren",
"rules.simulateTooltip": "Simuleer deze regels met behulp van de laatste 100 gebeurtenissen.",
"rules.simulation.actionCreated": "Taak is gemaakt op basis van de verrijkte gebeurtenis en actie en toegevoegd aan een taakwachtrij.",
"rules.simulation.actionExecuted": "Taak wordt uit de wachtrij gehaald en uitgevoerd.",
"rules.simulation.errorConditionDoesNotMatch": "STOP: Javescript-expressies in trigger komen niet overeen met de gebeurtenis.",
"rules.simulation.errorConditionPrecheckDoesNotMatch": "STOP: Conditie in trigger komt niet overeen met de gebeurtenis.",
"rules.simulation.errorDisabled": "STOP: Regel is uitgeschakeld.",
@ -785,13 +788,20 @@
"rules.simulation.eventQueried": "Gebeurtenis is opgevraagd vanuit de database",
"rules.simulation.eventTriggerChecked": "Het evenement is getest om te zien of het overeenkomt met de trigger en de basisvoorwaarden.",
"rules.simulator": "Simulator",
"rules.step.add": "Add Step",
"rules.step.edit": "Edit Step",
"rules.stepIgnoreError": "Ignore error",
"rules.stepIgnoreErrorHint": "Ignore when this step fails and continue with the next step (after all retries), otherwise completes the flow.",
"rules.stepNameHint": "The name of the rule. Shown in logs and debug views.",
"rules.stop": "Regel stopt binnenkort.",
"rules.trigger.add": "Add Trigger",
"rules.trigger.edit": "Edit Trigger",
"rules.triggerAll": "Trigger on all content events",
"rules.triggerConfirmText": "Wil je echt de regel activeren?",
"rules.triggerConfirmTitle": "Trigger regel",
"rules.triggerFailed": "Kan regel niet activeren. Laad opnieuw.",
"rules.triggerHint": "De selectie van het triggertype kan later niet worden gewijzigd.",
"rules.unnamed": "Naamloos regel",
"rules.updated": "Rule has been updated successfully.",
"rules.updateFailed": "Update regel mislukt. Laad opnieuw.",
"rules.when": "When",
"schemas.addField": "Veld toevoegen",

30
backend/i18n/frontend_pt.json

@ -220,6 +220,7 @@
"common.clusterPageTitle": "Cluster",
"common.collapse": "Collapse",
"common.comments": "Comentários",
"common.completed": "Completed",
"common.components": "Componentes",
"common.condition": "Condição",
"common.conditions": "Condições",
@ -274,6 +275,7 @@
"common.files": "Ficheiros",
"common.filters": "Filtros",
"common.finish": "Finish",
"common.flow": "Flow",
"common.folder": "Pasta",
"common.folders": "Pastas",
"common.from": "De",
@ -318,6 +320,7 @@
"common.options": "Options",
"common.or": "ou",
"common.order": "Order",
"common.output": "Output",
"common.pagerInfo": "{itemFirst}-{itemLast} de {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} do total?",
"common.pagerReload": "Clique para recarregar a vista e obter o número total de itens",
@ -349,7 +352,7 @@
"common.references": "References",
"common.refresh": "Refrescar",
"common.remember": "Não pergunte de novo.",
"common.remove": "remove",
"common.remove": "Remove",
"common.rename": "Renomear",
"common.renameTag": "Renomear Etiqueta",
"common.reply": "Reply",
@ -379,6 +382,7 @@
"common.skipped": "Ignorado",
"common.slug": "slug",
"common.stars.max": "Não deve ter mais de 15 estrelas",
"common.started": "Started",
"common.status": "Estado",
"common.statusChangeTo": "Mudar para",
"common.submit": "Enviar",
@ -628,7 +632,6 @@
"jobs.restoreStoppedLabel": "Stopped",
"jobs.restoreTitle": "Restore Backup",
"jobs.started": "Job started, it can take several minutes to complete.",
"jobs.startedLabel": "Started",
"languages.add": "Adicionar linguagem",
"languages.add.description": "Adicione um novo idioma que pretende apoiar para o seu conteúdo.",
"languages.add.title": "Adicione uma nova linguagem",
@ -701,8 +704,8 @@
"roles.revokeFailed": "Falhou em revogar o grupo. Por favor, recarregue.",
"roles.roleNamePlaceholder": "Insira o nome da grupo",
"roles.updateFailed": "Falhou na atualização do grupo. Por favor, recarregue.",
"rules.actionData": "Dados de Ação",
"rules.actionHint": "A seleção do tipo de ação não pode ser alterada mais tarde.",
"rules.addStep": "Add Step",
"rules.addTrigger": "Add trigger",
"rules.advancedFormattingHint": "Você pode usar formatação avançada",
"rules.cancelFailed": "Falhou em cancelar a regra. Por favor, recarregue.",
"rules.condition": "Condition",
@ -721,6 +724,7 @@
"rules.conditions.usageLimit": "Limite",
"rules.conditions.usageLimitHint": "A Api mensal chama para desencadear.",
"rules.create": "Nova Regra",
"rules.created": "Rule has been created successfully.",
"rules.createFailed": "Falhou em criar regra. Por favor, recarregue.",
"rules.createTooltip": "Nova Regra",
"rules.deleteConfirmText": "Quer mesmo apagar a regra?",
@ -731,7 +735,6 @@
"rules.empty": "Nenhuma regra criada ainda.",
"rules.emptyAddRule": "Adicionar Regra",
"rules.enqueued": "A regra foi adicionada à fila.",
"rules.enrichedEvent": "Evento Enriquecido",
"rules.itemPageTitle": "Regra",
"rules.listPageTitle": "Regras",
"rules.loadFailed": "Falhou em carregar as regras. Por favor, recarregue.",
@ -741,7 +744,11 @@
"rules.refreshEventsTooltip": "Atualizar eventos",
"rules.refreshTooltip": "Atualizar regras",
"rules.reloaded": "Regras recarregadas.",
"rules.removeElementText": "Do you really want to remove this element (step or trigger)",
"rules.removeElementTitle": "Remove element",
"rules.restarted": "A regra começará a funcionar em alguns segundos.",
"rules.ruleEvents.attempt": "Attempt",
"rules.ruleEvents.attempts": "Attempts",
"rules.ruleEvents.cancelAllConfirmText": "Quer mesmo cancelar todos os eventos?",
"rules.ruleEvents.cancelAllConfirmTitle": "Cancelar todos os eventos",
"rules.ruleEvents.cancelConfirmText": "Quer mesmo cancelar este evento?",
@ -750,11 +757,9 @@
"rules.ruleEvents.enqueue": "Enqueue",
"rules.ruleEvents.enqueued": "Eventos encadeados. Será reensificada em alguns segundos.",
"rules.ruleEvents.enqueueFailed": "Falhou em encascar o evento de regras. Por favor, recarregue.",
"rules.ruleEvents.lastInvokedLabel": "Última Invocação",
"rules.ruleEvents.listPageTitle": "Eventos de Regras",
"rules.ruleEvents.loadFailed": "Falhou em carregar eventos. Por favor, recarregue.",
"rules.ruleEvents.nextAttemptLabel": "A seguir",
"rules.ruleEvents.numAttemptsLabel": "Tentativas",
"rules.ruleEvents.reloaded": "RegrasEventos recarregados.",
"rules.ruleSimulator.listPageTitle": "Simulador",
"rules.ruleSyntax.if": "If",
@ -768,8 +773,6 @@
"rules.schemas.hint": "Define on which schemas and changes you want to run the content trigger.",
"rules.simulate": "Simular",
"rules.simulateTooltip": "Simular estas regras usando os últimos 100 eventos.",
"rules.simulation.actionCreated": "O trabalho é criado a partir do evento e ação enriquecidos e adicionado a uma fila de trabalho.",
"rules.simulation.actionExecuted": "O trabalho é tirado da fila e executado.",
"rules.simulation.errorConditionDoesNotMatch": "STOP: As expressões javascript no gatilho não correspondem ao evento.",
"rules.simulation.errorConditionPrecheckDoesNotMatch": "STOP: A condição no gatilho não corresponde ao evento.",
"rules.simulation.errorDisabled": "STOP: A regra está desativada.",
@ -785,13 +788,20 @@
"rules.simulation.eventQueried": "Evento é consultado a partir da base de dados",
"rules.simulation.eventTriggerChecked": "O evento é testado para ver se corresponde ao gatilho e às condições básicas.",
"rules.simulator": "Simulador",
"rules.step.add": "Add Step",
"rules.step.edit": "Edit Step",
"rules.stepIgnoreError": "Ignore error",
"rules.stepIgnoreErrorHint": "Ignore when this step fails and continue with the next step (after all retries), otherwise completes the flow.",
"rules.stepNameHint": "The name of the rule. Shown in logs and debug views.",
"rules.stop": "A regra vai parar em breve.",
"rules.trigger.add": "Add Trigger",
"rules.trigger.edit": "Edit Trigger",
"rules.triggerAll": "Gatilho em todos os eventos de conteúdo",
"rules.triggerConfirmText": "Quer mesmo desencadear a regra?",
"rules.triggerConfirmTitle": "Regra do gatilho",
"rules.triggerFailed": "Falhou em desencadear a regra. Por favor, recarregue.",
"rules.triggerHint": "A seleção do tipo de gatilho não pode ser alterada mais tarde.",
"rules.unnamed": "Regra sem nome",
"rules.updated": "Rule has been updated successfully.",
"rules.updateFailed": "Falhou na atualização da regra. Por favor, recarregue.",
"rules.when": "When",
"schemas.addField": "Adicionar Campo",

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

Loading…
Cancel
Save