diff --git a/Squidex.sln b/Squidex.sln
index 1c40c8524..81f85c0f9 100644
--- a/Squidex.sln
+++ b/Squidex.sln
@@ -8,8 +8,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "infrastructure", "infrastru
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "domain", "domain", "{4C6B06C2-6D77-4E0E-AE32-D7050236433A}"
EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Domain.Apps.Core", "src\Squidex.Domain.Apps.Core\Squidex.Domain.Apps.Core.csproj", "{47F3C27E-698B-4EDF-A7E8-D7F4232AFBB0}"
-EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Infrastructure", "src\Squidex.Infrastructure\Squidex.Infrastructure.csproj", "{BD1C30A8-8FFA-4A92-A9BD-B67B1CDDD84C}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Domain.Apps.Events", "src\Squidex.Domain.Apps.Events\Squidex.Domain.Apps.Events.csproj", "{25F66C64-058A-4D44-BC0C-F12A054F9A91}"
@@ -65,6 +63,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
stylecop.json = stylecop.json
EndProjectSection
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Domain.Apps.Core.Model", "src\Squidex.Domain.Apps.Core.Model\Squidex.Domain.Apps.Core.Model.csproj", "{F0A83301-50A5-40EA-A1A2-07C7858F5A3F}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Domain.Apps.Core.Operations", "src\Squidex.Domain.Apps.Core.Operations\Squidex.Domain.Apps.Core.Operations.csproj", "{6B3F75B6-5888-468E-BA4F-4FC725DAEF31}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -83,14 +85,6 @@ Global
{61F6BBCE-A080-4400-B194-70E2F5D2096E}.Release|Any CPU.Build.0 = Release|Any CPU
{61F6BBCE-A080-4400-B194-70E2F5D2096E}.Release|x64.ActiveCfg = Release|Any CPU
{61F6BBCE-A080-4400-B194-70E2F5D2096E}.Release|x86.ActiveCfg = Release|Any CPU
- {47F3C27E-698B-4EDF-A7E8-D7F4232AFBB0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {47F3C27E-698B-4EDF-A7E8-D7F4232AFBB0}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {47F3C27E-698B-4EDF-A7E8-D7F4232AFBB0}.Debug|x64.ActiveCfg = Debug|Any CPU
- {47F3C27E-698B-4EDF-A7E8-D7F4232AFBB0}.Debug|x86.ActiveCfg = Debug|Any CPU
- {47F3C27E-698B-4EDF-A7E8-D7F4232AFBB0}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {47F3C27E-698B-4EDF-A7E8-D7F4232AFBB0}.Release|Any CPU.Build.0 = Release|Any CPU
- {47F3C27E-698B-4EDF-A7E8-D7F4232AFBB0}.Release|x64.ActiveCfg = Release|Any CPU
- {47F3C27E-698B-4EDF-A7E8-D7F4232AFBB0}.Release|x86.ActiveCfg = Release|Any CPU
{BD1C30A8-8FFA-4A92-A9BD-B67B1CDDD84C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BD1C30A8-8FFA-4A92-A9BD-B67B1CDDD84C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BD1C30A8-8FFA-4A92-A9BD-B67B1CDDD84C}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -299,12 +293,35 @@ Global
{7931187E-A1E6-4F89-8BC8-20A1E445579F}.Release|x64.Build.0 = Release|Any CPU
{7931187E-A1E6-4F89-8BC8-20A1E445579F}.Release|x86.ActiveCfg = Release|Any CPU
{7931187E-A1E6-4F89-8BC8-20A1E445579F}.Release|x86.Build.0 = Release|Any CPU
+ {F0A83301-50A5-40EA-A1A2-07C7858F5A3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F0A83301-50A5-40EA-A1A2-07C7858F5A3F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F0A83301-50A5-40EA-A1A2-07C7858F5A3F}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F0A83301-50A5-40EA-A1A2-07C7858F5A3F}.Debug|x64.Build.0 = Debug|Any CPU
+ {F0A83301-50A5-40EA-A1A2-07C7858F5A3F}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F0A83301-50A5-40EA-A1A2-07C7858F5A3F}.Debug|x86.Build.0 = Debug|Any CPU
+ {F0A83301-50A5-40EA-A1A2-07C7858F5A3F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F0A83301-50A5-40EA-A1A2-07C7858F5A3F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F0A83301-50A5-40EA-A1A2-07C7858F5A3F}.Release|x64.ActiveCfg = Release|Any CPU
+ {F0A83301-50A5-40EA-A1A2-07C7858F5A3F}.Release|x64.Build.0 = Release|Any CPU
+ {F0A83301-50A5-40EA-A1A2-07C7858F5A3F}.Release|x86.ActiveCfg = Release|Any CPU
+ {F0A83301-50A5-40EA-A1A2-07C7858F5A3F}.Release|x86.Build.0 = Release|Any CPU
+ {6B3F75B6-5888-468E-BA4F-4FC725DAEF31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6B3F75B6-5888-468E-BA4F-4FC725DAEF31}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6B3F75B6-5888-468E-BA4F-4FC725DAEF31}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {6B3F75B6-5888-468E-BA4F-4FC725DAEF31}.Debug|x64.Build.0 = Debug|Any CPU
+ {6B3F75B6-5888-468E-BA4F-4FC725DAEF31}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {6B3F75B6-5888-468E-BA4F-4FC725DAEF31}.Debug|x86.Build.0 = Debug|Any CPU
+ {6B3F75B6-5888-468E-BA4F-4FC725DAEF31}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6B3F75B6-5888-468E-BA4F-4FC725DAEF31}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6B3F75B6-5888-468E-BA4F-4FC725DAEF31}.Release|x64.ActiveCfg = Release|Any CPU
+ {6B3F75B6-5888-468E-BA4F-4FC725DAEF31}.Release|x64.Build.0 = Release|Any CPU
+ {6B3F75B6-5888-468E-BA4F-4FC725DAEF31}.Release|x86.ActiveCfg = Release|Any CPU
+ {6B3F75B6-5888-468E-BA4F-4FC725DAEF31}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
- {47F3C27E-698B-4EDF-A7E8-D7F4232AFBB0} = {C9809D59-6665-471E-AD87-5AC624C65892}
{BD1C30A8-8FFA-4A92-A9BD-B67B1CDDD84C} = {8CF53B92-5EB1-461D-98F8-70DA9B603FBF}
{25F66C64-058A-4D44-BC0C-F12A054F9A91} = {C9809D59-6665-471E-AD87-5AC624C65892}
{A85201C6-6AF8-4B63-8365-08F741050438} = {C9809D59-6665-471E-AD87-5AC624C65892}
@@ -328,6 +345,8 @@ Global
{42184546-E3CB-4D4F-9495-43979B9C63B9} = {C0D540F0-9158-4528-BFD8-BEAE6EAE45EA}
{EF75E488-1324-4E18-A1BD-D3A05AE67B1F} = {8CF53B92-5EB1-461D-98F8-70DA9B603FBF}
{7931187E-A1E6-4F89-8BC8-20A1E445579F} = {8CF53B92-5EB1-461D-98F8-70DA9B603FBF}
+ {F0A83301-50A5-40EA-A1A2-07C7858F5A3F} = {C9809D59-6665-471E-AD87-5AC624C65892}
+ {6B3F75B6-5888-468E-BA4F-4FC725DAEF31} = {C9809D59-6665-471E-AD87-5AC624C65892}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {02F2E872-3141-44F5-BD6A-33CD84E9FE08}
diff --git a/Squidex.sln.DotSettings b/Squidex.sln.DotSettings
index 322a6eebd..0e0d4202d 100644
--- a/Squidex.sln.DotSettings
+++ b/Squidex.sln.DotSettings
@@ -13,6 +13,11 @@
True
True
+
+
+
+
+
<?xml version="1.0" encoding="utf-16"?><Profile name="Header"><CSUpdateFileHeader>True</CSUpdateFileHeader></Profile>
<?xml version="1.0" encoding="utf-16"?><Profile name="Namespaces"><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings><EmbraceInRegion>False</EmbraceInRegion><RegionName></RegionName></CSOptimizeUsings><CSUpdateFileHeader>True</CSUpdateFileHeader></Profile>
<?xml version="1.0" encoding="utf-16"?><Profile name="Typescript"><JsInsertSemicolon>True</JsInsertSemicolon><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><CorrectVariableKindsDescriptor>True</CorrectVariableKindsDescriptor><VariablesToInnerScopesDescriptor>True</VariablesToInnerScopesDescriptor><StringToTemplatesDescriptor>True</StringToTemplatesDescriptor><RemoveRedundantQualifiersTs>True</RemoveRedundantQualifiersTs><OptimizeImportsTs>True</OptimizeImportsTs></Profile>
diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs
new file mode 100644
index 000000000..28e17d013
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs
@@ -0,0 +1,59 @@
+// ==========================================================================
+// AppClient.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Apps
+{
+ public sealed class AppClient
+ {
+ private readonly string secret;
+ private string name;
+ private AppClientPermission permission;
+
+ public string Name
+ {
+ get { return name; }
+ }
+
+ public string Secret
+ {
+ get { return secret; }
+ }
+
+ public AppClientPermission Permission
+ {
+ get { return permission; }
+ }
+
+ public AppClient(string name, string secret, AppClientPermission permission)
+ {
+ Guard.NotNullOrEmpty(name, nameof(name));
+ Guard.NotNullOrEmpty(secret, nameof(secret));
+ Guard.Enum(permission, nameof(permission));
+
+ this.name = name;
+ this.secret = secret;
+ this.permission = permission;
+ }
+
+ public void Update(AppClientPermission newPermission)
+ {
+ Guard.Enum(newPermission, nameof(newPermission));
+
+ permission = newPermission;
+ }
+
+ public void Rename(string newName)
+ {
+ Guard.NotNullOrEmpty(newName, nameof(newName));
+
+ name = newName;
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/AppClientPermission.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/AppClientPermission.cs
new file mode 100644
index 000000000..cbc4cdaf2
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Apps/AppClientPermission.cs
@@ -0,0 +1,17 @@
+// ==========================================================================
+// AppClientPermission.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Apps
+{
+ public enum AppClientPermission
+ {
+ Developer,
+ Editor,
+ Reader
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/AppClients.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/AppClients.cs
new file mode 100644
index 000000000..a92de925b
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Apps/AppClients.cs
@@ -0,0 +1,37 @@
+// ==========================================================================
+// AppClients.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Apps
+{
+ public sealed class AppClients : DictionaryBase
+ {
+ public void Add(string id, AppClient client)
+ {
+ Guard.NotNullOrEmpty(id, nameof(id));
+ Guard.NotNull(client, nameof(client));
+
+ Inner.Add(id, client);
+ }
+
+ public void Add(string id, string secret)
+ {
+ Guard.NotNullOrEmpty(id, nameof(id));
+
+ Inner.Add(id, new AppClient(id, secret, AppClientPermission.Editor));
+ }
+
+ public void Revoke(string id)
+ {
+ Guard.NotNullOrEmpty(id, nameof(id));
+
+ Inner.Remove(id);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/AppContributorPermission.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/AppContributorPermission.cs
new file mode 100644
index 000000000..c05d7527a
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Apps/AppContributorPermission.cs
@@ -0,0 +1,17 @@
+// ==========================================================================
+// AppContributorPermission.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Apps
+{
+ public enum AppContributorPermission
+ {
+ Owner,
+ Developer,
+ Editor
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/AppContributors.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/AppContributors.cs
new file mode 100644
index 000000000..9c4ce924d
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Apps/AppContributors.cs
@@ -0,0 +1,30 @@
+// ==========================================================================
+// AppContributors.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Apps
+{
+ public sealed class AppContributors : DictionaryBase
+ {
+ public void Assign(string contributorId, AppContributorPermission permission)
+ {
+ Guard.NotNullOrEmpty(contributorId, nameof(contributorId));
+ Guard.Enum(permission, nameof(permission));
+
+ Inner[contributorId] = permission;
+ }
+
+ public void Remove(string contributorId)
+ {
+ Guard.NotNullOrEmpty(contributorId, nameof(contributorId));
+
+ Inner.Remove(contributorId);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/AppPermission.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/AppPermission.cs
new file mode 100644
index 000000000..e9bf142a6
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Apps/AppPermission.cs
@@ -0,0 +1,18 @@
+// ==========================================================================
+// AppPermission.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Apps
+{
+ public enum AppPermission
+ {
+ Owner,
+ Developer,
+ Editor,
+ Reader
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/AppPlan.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/AppPlan.cs
new file mode 100644
index 000000000..83c9506b7
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Apps/AppPlan.cs
@@ -0,0 +1,29 @@
+// ==========================================================================
+// AppPlan.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Apps
+{
+ public sealed class AppPlan
+ {
+ public RefToken Owner { get; }
+
+ public string PlanId { get; }
+
+ public AppPlan(RefToken owner, string planId)
+ {
+ Guard.NotNull(owner, nameof(owner));
+ Guard.NotNullOrEmpty(planId, nameof(planId));
+
+ Owner = owner;
+
+ PlanId = planId;
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/Json/AppClientsConverter.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/Json/AppClientsConverter.cs
new file mode 100644
index 000000000..16570c87b
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Apps/Json/AppClientsConverter.cs
@@ -0,0 +1,43 @@
+// ==========================================================================
+// AppClientsConverter.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System.Collections.Generic;
+using Newtonsoft.Json;
+using Squidex.Infrastructure.Json;
+
+namespace Squidex.Domain.Apps.Core.Apps.Json
+{
+ public sealed class AppClientsConverter : JsonClassConverter
+ {
+ protected override void WriteValue(JsonWriter writer, AppClients value, JsonSerializer serializer)
+ {
+ var json = new Dictionary(value.Count);
+
+ foreach (var client in value)
+ {
+ json.Add(client.Key, new JsonAppClient(client.Value));
+ }
+
+ serializer.Serialize(writer, json);
+ }
+
+ protected override AppClients ReadValue(JsonReader reader, JsonSerializer serializer)
+ {
+ var json = serializer.Deserialize>(reader);
+
+ var clients = new AppClients();
+
+ foreach (var client in json)
+ {
+ clients.Add(client.Key, client.Value.ToClient());
+ }
+
+ return clients;
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/Json/AppContributorsConverter.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/Json/AppContributorsConverter.cs
new file mode 100644
index 000000000..efa326377
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Apps/Json/AppContributorsConverter.cs
@@ -0,0 +1,43 @@
+// ==========================================================================
+// AppContributorsConverter.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System.Collections.Generic;
+using Newtonsoft.Json;
+using Squidex.Infrastructure.Json;
+
+namespace Squidex.Domain.Apps.Core.Apps.Json
+{
+ public sealed class AppContributorsConverter : JsonClassConverter
+ {
+ protected override void WriteValue(JsonWriter writer, AppContributors value, JsonSerializer serializer)
+ {
+ var json = new Dictionary(value.Count);
+
+ foreach (var contributor in value)
+ {
+ json.Add(contributor.Key, contributor.Value);
+ }
+
+ serializer.Serialize(writer, json);
+ }
+
+ protected override AppContributors ReadValue(JsonReader reader, JsonSerializer serializer)
+ {
+ var json = serializer.Deserialize>(reader);
+
+ var contributors = new AppContributors();
+
+ foreach (var contributor in json)
+ {
+ contributors.Assign(contributor.Key, contributor.Value);
+ }
+
+ return contributors;
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/Json/JsonAppClient.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/Json/JsonAppClient.cs
new file mode 100644
index 000000000..c3b11189e
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Apps/Json/JsonAppClient.cs
@@ -0,0 +1,39 @@
+// ==========================================================================
+// JsonAppClient.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Newtonsoft.Json;
+using Squidex.Infrastructure.Reflection;
+
+namespace Squidex.Domain.Apps.Core.Apps.Json
+{
+ public class JsonAppClient
+ {
+ [JsonProperty]
+ public string Name { get; set; }
+
+ [JsonProperty]
+ public string Secret { get; set; }
+
+ [JsonProperty]
+ public AppClientPermission Permission { get; set; }
+
+ public JsonAppClient()
+ {
+ }
+
+ public JsonAppClient(AppClient client)
+ {
+ SimpleMapper.Map(client, this);
+ }
+
+ public AppClient ToClient()
+ {
+ return new AppClient(Name, Secret, Permission);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/Json/JsonLanguageConfig.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/Json/JsonLanguageConfig.cs
new file mode 100644
index 000000000..f85cb70d3
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Apps/Json/JsonLanguageConfig.cs
@@ -0,0 +1,40 @@
+// ==========================================================================
+// JsonLanguageConfig.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System.Linq;
+using Newtonsoft.Json;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.Reflection;
+
+namespace Squidex.Domain.Apps.Core.Apps.Json
+{
+ public class JsonLanguageConfig
+ {
+ [JsonProperty]
+ public Language[] Fallback { get; set; }
+
+ [JsonProperty]
+ public bool IsOptional { get; set; }
+
+ public JsonLanguageConfig()
+ {
+ }
+
+ public JsonLanguageConfig(LanguageConfig config)
+ {
+ SimpleMapper.Map(config, this);
+
+ Fallback = config.LanguageFallbacks.ToArray();
+ }
+
+ public LanguageConfig ToConfig(string language)
+ {
+ return new LanguageConfig(language, IsOptional, Fallback);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/Json/LanguagesConfigConverter.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/Json/LanguagesConfigConverter.cs
new file mode 100644
index 000000000..af52f34a9
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Apps/Json/LanguagesConfigConverter.cs
@@ -0,0 +1,45 @@
+// ==========================================================================
+// AppClientsConverter.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System.Collections.Generic;
+using Newtonsoft.Json;
+using Squidex.Infrastructure.Json;
+
+namespace Squidex.Domain.Apps.Core.Apps.Json
+{
+ public sealed class LanguagesConfigConverter : JsonClassConverter
+ {
+ protected override void WriteValue(JsonWriter writer, LanguagesConfig value, JsonSerializer serializer)
+ {
+ var json = new Dictionary(value.Count);
+
+ foreach (LanguageConfig config in value)
+ {
+ json.Add(config.Language, new JsonLanguageConfig(config));
+ }
+
+ serializer.Serialize(writer, json);
+ }
+
+ protected override LanguagesConfig ReadValue(JsonReader reader, JsonSerializer serializer)
+ {
+ var json = serializer.Deserialize>(reader);
+
+ var languagesConfig = new LanguageConfig[json.Count];
+
+ var i = 0;
+
+ foreach (var config in json)
+ {
+ languagesConfig[i++] = config.Value.ToConfig(config.Key);
+ }
+
+ return LanguagesConfig.Build(languagesConfig);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/LanguageConfig.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/LanguageConfig.cs
new file mode 100644
index 000000000..b1d507067
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Apps/LanguageConfig.cs
@@ -0,0 +1,66 @@
+// ==========================================================================
+// LanguageConfig.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System.Collections.Generic;
+using System.Linq;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Apps
+{
+ public sealed class LanguageConfig : IFieldPartitionItem
+ {
+ private readonly Language language;
+ private readonly Language[] languageFallbacks;
+ private readonly bool isOptional;
+
+ public bool IsOptional
+ {
+ get { return isOptional; }
+ }
+
+ public Language Language
+ {
+ get { return language; }
+ }
+
+ public IEnumerable LanguageFallbacks
+ {
+ get { return languageFallbacks; }
+ }
+
+ string IFieldPartitionItem.Key
+ {
+ get { return language.Iso2Code; }
+ }
+
+ string IFieldPartitionItem.Name
+ {
+ get { return language.EnglishName; }
+ }
+
+ IEnumerable IFieldPartitionItem.Fallback
+ {
+ get { return LanguageFallbacks.Select(x => x.Iso2Code); }
+ }
+
+ public LanguageConfig(Language language, bool isOptional = false, IEnumerable fallback = null)
+ : this(language, isOptional, fallback?.ToArray())
+ {
+ }
+
+ public LanguageConfig(Language language, bool isOptional = false, params Language[] fallback)
+ {
+ Guard.NotNull(language, nameof(language));
+
+ this.isOptional = isOptional;
+
+ this.language = language;
+ this.languageFallbacks = fallback ?? new Language[0];
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/LanguagesConfig.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/LanguagesConfig.cs
new file mode 100644
index 000000000..57f857877
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Apps/LanguagesConfig.cs
@@ -0,0 +1,178 @@
+// ==========================================================================
+// LanguagesConfig.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using Squidex.Infrastructure;
+
+#pragma warning disable IDE0016 // Use 'throw' expression
+
+namespace Squidex.Domain.Apps.Core.Apps
+{
+ public sealed class LanguagesConfig : IFieldPartitioning
+ {
+ private State state;
+
+ public LanguageConfig Master
+ {
+ get { return state.Master; }
+ }
+
+ IFieldPartitionItem IFieldPartitioning.Master
+ {
+ get { return state.Master; }
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return state.Languages.Values.GetEnumerator();
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return state.Languages.Values.GetEnumerator();
+ }
+
+ public int Count
+ {
+ get { return state.Languages.Count; }
+ }
+
+ private LanguagesConfig(ICollection configs)
+ {
+ Guard.NotNull(configs, nameof(configs));
+
+ state = new State(configs.ToImmutableDictionary(x => x.Language), configs.FirstOrDefault());
+ }
+
+ public static LanguagesConfig Build(params LanguageConfig[] configs)
+ {
+ Guard.NotNull(configs, nameof(configs));
+
+ return new LanguagesConfig(configs);
+ }
+
+ public static LanguagesConfig Build(params Language[] languages)
+ {
+ Guard.NotNull(languages, nameof(languages));
+
+ return new LanguagesConfig(languages.Select(x => new LanguageConfig(x, false)).ToList());
+ }
+
+ public void MakeMaster(Language language)
+ {
+ Guard.NotNull(language, nameof(language));
+
+ state = new State(state.Languages, state.Languages[language]);
+ }
+
+ public void Set(LanguageConfig config)
+ {
+ Guard.NotNull(config, nameof(config));
+
+ state = new State(state.Languages.SetItem(config.Language, config), state.Master?.Language == config.Language ? config : state.Master);
+ }
+
+ public void Remove(Language language)
+ {
+ Guard.NotNull(language, nameof(language));
+
+ var newLanguages =
+ state.Languages.Values.Where(x => x.Language != language)
+ .Select(config =>
+ {
+ return new LanguageConfig(
+ config.Language,
+ config.IsOptional,
+ config.LanguageFallbacks.Except(new[] { language }));
+ })
+ .ToImmutableDictionary(x => x.Language);
+
+ var newMaster =
+ state.Master.Language != language ?
+ state.Master :
+ state.Languages.Values.FirstOrDefault();
+
+ state = new State(newLanguages, newMaster);
+ }
+
+ public bool Contains(Language language)
+ {
+ return language != null && state.Languages.ContainsKey(language);
+ }
+
+ public bool TryGetConfig(Language language, out LanguageConfig config)
+ {
+ return state.Languages.TryGetValue(language, out config);
+ }
+
+ public bool TryGetItem(string key, out IFieldPartitionItem item)
+ {
+ if (Language.IsValidLanguage(key) && state.Languages.TryGetValue(key, out var value))
+ {
+ item = value;
+
+ return true;
+ }
+ else
+ {
+ item = null;
+
+ return false;
+ }
+ }
+
+ private sealed class State
+ {
+ public ImmutableDictionary Languages { get; }
+
+ public LanguageConfig Master { get; }
+
+ public State(ImmutableDictionary languages, LanguageConfig master)
+ {
+ foreach (var languageConfig in languages.Values)
+ {
+ foreach (var fallback in languageConfig.LanguageFallbacks)
+ {
+ if (!languages.ContainsKey(fallback))
+ {
+ var message = $"Config for language '{languageConfig.Language.Iso2Code}' contains unsupported fallback language '{fallback.Iso2Code}'";
+
+ throw new InvalidOperationException(message);
+ }
+ }
+ }
+
+ Languages = languages;
+
+ if (master == null)
+ {
+ throw new InvalidOperationException("Config has no master language.");
+ }
+
+ this.Master = master;
+ }
+ }
+
+ public PartitionResolver ToResolver()
+ {
+ return partitioning =>
+ {
+ if (partitioning.Equals(Partitioning.Invariant))
+ {
+ return InvariantPartitioning.Instance;
+ }
+
+ return this;
+ };
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/RoleExtension.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/RoleExtension.cs
new file mode 100644
index 000000000..40245615f
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Apps/RoleExtension.cs
@@ -0,0 +1,30 @@
+// ==========================================================================
+// RoleExtension.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Apps
+{
+ public static class RoleExtension
+ {
+ public static AppPermission ToAppPermission(this AppClientPermission clientPermission)
+ {
+ Guard.Enum(clientPermission, nameof(clientPermission));
+
+ return (AppPermission)Enum.Parse(typeof(AppPermission), clientPermission.ToString());
+ }
+
+ public static AppPermission ToAppPermission(this AppContributorPermission contributorPermission)
+ {
+ Guard.Enum(contributorPermission, nameof(contributorPermission));
+
+ return (AppPermission)Enum.Parse(typeof(AppPermission), contributorPermission.ToString());
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/ContentData.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/ContentData.cs
new file mode 100644
index 000000000..81f11e599
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Contents/ContentData.cs
@@ -0,0 +1,89 @@
+// ==========================================================================
+// ContentData.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.Json;
+
+namespace Squidex.Domain.Apps.Core.Contents
+{
+ public abstract class ContentData : Dictionary, IEquatable>
+ {
+ public IEnumerable> ValidValues
+ {
+ get { return this.Where(x => x.Value != null); }
+ }
+
+ protected ContentData(IEqualityComparer comparer)
+ : base(comparer)
+ {
+ }
+
+ protected ContentData(IDictionary copy, IEqualityComparer comparer)
+ : base(copy, comparer)
+ {
+ }
+
+ protected static TResult Merge(TResult source, TResult target) where TResult : ContentData
+ {
+ if (ReferenceEquals(target, source))
+ {
+ return source;
+ }
+
+ foreach (var otherValue in source)
+ {
+ var fieldValue = target.GetOrAdd(otherValue.Key, x => new ContentFieldData());
+
+ foreach (var value in otherValue.Value)
+ {
+ fieldValue[value.Key] = value.Value;
+ }
+ }
+
+ return target;
+ }
+
+ protected static TResult Clean(TResult source, TResult target) where TResult : ContentData
+ {
+ foreach (var fieldValue in source.ValidValues)
+ {
+ var resultValue = new ContentFieldData();
+
+ foreach (var partitionValue in fieldValue.Value.Where(x => !x.Value.IsNull()))
+ {
+ resultValue[partitionValue.Key] = partitionValue.Value;
+ }
+
+ if (resultValue.Count > 0)
+ {
+ target[fieldValue.Key] = resultValue;
+ }
+ }
+
+ return target;
+ }
+
+ public override bool Equals(object obj)
+ {
+ return Equals(obj as ContentData);
+ }
+
+ public bool Equals(ContentData other)
+ {
+ return other != null && (ReferenceEquals(this, other) || this.EqualsDictionary(other));
+ }
+
+ public override int GetHashCode()
+ {
+ return this.DictionaryHashCode();
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/ContentFieldData.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/ContentFieldData.cs
new file mode 100644
index 000000000..cdf5f8464
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Contents/ContentFieldData.cs
@@ -0,0 +1,54 @@
+// ==========================================================================
+// ContentFieldData.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+using Newtonsoft.Json.Linq;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Contents
+{
+ public sealed class ContentFieldData : Dictionary, IEquatable
+ {
+ private static readonly JTokenEqualityComparer JTokenEqualityComparer = new JTokenEqualityComparer();
+
+ public ContentFieldData()
+ : base(StringComparer.OrdinalIgnoreCase)
+ {
+ }
+
+ public ContentFieldData AddValue(string key, JToken value)
+ {
+ Guard.NotNullOrEmpty(key, nameof(key));
+
+ this[key] = value;
+
+ return this;
+ }
+
+ public ContentFieldData AddValue(JToken value)
+ {
+ return AddValue(InvariantPartitioning.Instance.Master.Key, value);
+ }
+
+ public override bool Equals(object obj)
+ {
+ return Equals(obj as ContentFieldData);
+ }
+
+ public bool Equals(ContentFieldData other)
+ {
+ return other != null && (ReferenceEquals(this, other) || this.EqualsDictionary(other, EqualityComparer.Default, JTokenEqualityComparer));
+ }
+
+ public override int GetHashCode()
+ {
+ return this.DictionaryHashCode(EqualityComparer.Default, JTokenEqualityComparer);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/IdContentData.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/IdContentData.cs
new file mode 100644
index 000000000..524e90f82
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Contents/IdContentData.cs
@@ -0,0 +1,51 @@
+// ==========================================================================
+// IdContentData.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Contents
+{
+ public sealed class IdContentData : ContentData, IEquatable
+ {
+ public IdContentData()
+ : base(EqualityComparer.Default)
+ {
+ }
+
+ public IdContentData(IdContentData copy)
+ : base(copy, EqualityComparer.Default)
+ {
+ }
+
+ public IdContentData MergeInto(IdContentData target)
+ {
+ return Merge(this, target);
+ }
+
+ public IdContentData ToCleaned()
+ {
+ return Clean(this, new IdContentData());
+ }
+
+ public IdContentData AddField(long id, ContentFieldData data)
+ {
+ Guard.GreaterThan(id, 0, nameof(id));
+
+ this[id] = data;
+
+ return this;
+ }
+
+ public bool Equals(IdContentData other)
+ {
+ return base.Equals(other);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs
new file mode 100644
index 000000000..71cc639f0
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs
@@ -0,0 +1,45 @@
+// ==========================================================================
+// NamedContentData.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Contents
+{
+ public sealed class NamedContentData : ContentData, IEquatable
+ {
+ public NamedContentData()
+ : base(StringComparer.OrdinalIgnoreCase)
+ {
+ }
+
+ public NamedContentData MergeInto(NamedContentData target)
+ {
+ return Merge(this, target);
+ }
+
+ public NamedContentData ToCleaned()
+ {
+ return Clean(this, new NamedContentData());
+ }
+
+ public NamedContentData AddField(string name, ContentFieldData data)
+ {
+ Guard.NotNullOrEmpty(name, nameof(name));
+
+ this[name] = data;
+
+ return this;
+ }
+
+ public bool Equals(NamedContentData other)
+ {
+ return base.Equals(other);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs
new file mode 100644
index 000000000..c2f84034d
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs
@@ -0,0 +1,17 @@
+// ==========================================================================
+// Status.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Contents
+{
+ public enum Status
+ {
+ Draft,
+ Archived,
+ Published
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/StatusFlow.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/StatusFlow.cs
new file mode 100644
index 000000000..30fbf23e1
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Contents/StatusFlow.cs
@@ -0,0 +1,33 @@
+// ==========================================================================
+// StatusFlow.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Squidex.Domain.Apps.Core.Contents
+{
+ public static class StatusFlow
+ {
+ private static readonly Dictionary Flow = new Dictionary
+ {
+ [Status.Draft] = new[] { Status.Published, Status.Archived },
+ [Status.Archived] = new[] { Status.Draft },
+ [Status.Published] = new[] { Status.Draft, Status.Archived }
+ };
+
+ public static bool Exists(Status status)
+ {
+ return Flow.ContainsKey(status);
+ }
+
+ public static bool CanChange(Status status, Status toStatus)
+ {
+ return Flow.TryGetValue(status, out var state) && state.Contains(toStatus);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/DictionaryBase.cs b/src/Squidex.Domain.Apps.Core.Model/DictionaryBase.cs
new file mode 100644
index 000000000..6b3734c6a
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/DictionaryBase.cs
@@ -0,0 +1,63 @@
+// ==========================================================================
+// DictionaryBase.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System.Collections;
+using System.Collections.Generic;
+
+namespace Squidex.Domain.Apps.Core
+{
+ public abstract class DictionaryBase : IReadOnlyDictionary
+ {
+ private readonly Dictionary inner = new Dictionary();
+
+ public TValue this[TKey key]
+ {
+ get { return inner[key]; }
+ }
+
+ public IEnumerable Keys
+ {
+ get { return inner.Keys; }
+ }
+
+ public IEnumerable Values
+ {
+ get { return inner.Values; }
+ }
+
+ public int Count
+ {
+ get { return inner.Count; }
+ }
+
+ protected Dictionary Inner
+ {
+ get { return inner; }
+ }
+
+ public bool ContainsKey(TKey key)
+ {
+ return inner.ContainsKey(key);
+ }
+
+ public bool TryGetValue(TKey key, out TValue value)
+ {
+ return inner.TryGetValue(key, out value);
+ }
+
+ IEnumerator> IEnumerable>.GetEnumerator()
+ {
+ return inner.GetEnumerator();
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return inner.GetEnumerator();
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Read/Apps/IAppClientEntity.cs b/src/Squidex.Domain.Apps.Core.Model/IFieldPartitionItem.cs
similarity index 60%
rename from src/Squidex.Domain.Apps.Read/Apps/IAppClientEntity.cs
rename to src/Squidex.Domain.Apps.Core.Model/IFieldPartitionItem.cs
index 73cae6fba..39a4434d2 100644
--- a/src/Squidex.Domain.Apps.Read/Apps/IAppClientEntity.cs
+++ b/src/Squidex.Domain.Apps.Core.Model/IFieldPartitionItem.cs
@@ -1,21 +1,23 @@
// ==========================================================================
-// IAppClientEntity.cs
+// IFieldPartitionItem.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
-using Squidex.Domain.Apps.Core.Apps;
+using System.Collections.Generic;
-namespace Squidex.Domain.Apps.Read.Apps
+namespace Squidex.Domain.Apps.Core
{
- public interface IAppClientEntity
+ public interface IFieldPartitionItem
{
+ string Key { get; }
+
string Name { get; }
- string Secret { get; }
+ bool IsOptional { get; }
- AppClientPermission Permission { get; }
+ IEnumerable Fallback { get; }
}
}
diff --git a/src/Squidex.Domain.Apps.Core.Model/IFieldPartitioning.cs b/src/Squidex.Domain.Apps.Core.Model/IFieldPartitioning.cs
new file mode 100644
index 000000000..ff4f97928
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/IFieldPartitioning.cs
@@ -0,0 +1,19 @@
+// ==========================================================================
+// IFieldPartitioning.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System.Collections.Generic;
+
+namespace Squidex.Domain.Apps.Core
+{
+ public interface IFieldPartitioning : IReadOnlyCollection
+ {
+ IFieldPartitionItem Master { get; }
+
+ bool TryGetItem(string key, out IFieldPartitionItem item);
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/InvariantPartitioning.cs b/src/Squidex.Domain.Apps.Core.Model/InvariantPartitioning.cs
new file mode 100644
index 000000000..5dd1ad0f8
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/InvariantPartitioning.cs
@@ -0,0 +1,73 @@
+// ==========================================================================
+// InvariantPartitioning.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Squidex.Domain.Apps.Core
+{
+ public sealed class InvariantPartitioning : IFieldPartitioning, IFieldPartitionItem
+ {
+ public static readonly InvariantPartitioning Instance = new InvariantPartitioning();
+
+ public int Count
+ {
+ get { return 1; }
+ }
+
+ public IFieldPartitionItem Master
+ {
+ get { return this; }
+ }
+
+ string IFieldPartitionItem.Key
+ {
+ get { return "iv"; }
+ }
+
+ string IFieldPartitionItem.Name
+ {
+ get { return "Invariant"; }
+ }
+
+ bool IFieldPartitionItem.IsOptional
+ {
+ get { return false; }
+ }
+
+ IEnumerable IFieldPartitionItem.Fallback
+ {
+ get { return Enumerable.Empty(); }
+ }
+
+ private InvariantPartitioning()
+ {
+ }
+
+ public bool TryGetItem(string key, out IFieldPartitionItem item)
+ {
+ var isFound = string.Equals(key, "iv", StringComparison.OrdinalIgnoreCase);
+
+ item = isFound ? this : null;
+
+ return isFound;
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ yield return this;
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ yield return this;
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Partitioning.cs b/src/Squidex.Domain.Apps.Core.Model/Partitioning.cs
new file mode 100644
index 000000000..74f2ea770
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Partitioning.cs
@@ -0,0 +1,50 @@
+// ==========================================================================
+// Partitioning.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core
+{
+ public delegate IFieldPartitioning PartitionResolver(Partitioning key);
+
+ public sealed class Partitioning : IEquatable
+ {
+ public static readonly Partitioning Invariant = new Partitioning("invariant");
+ public static readonly Partitioning Language = new Partitioning("language");
+
+ public string Key { get; }
+
+ public Partitioning(string key)
+ {
+ Guard.NotNullOrEmpty(key, nameof(key));
+
+ Key = key;
+ }
+
+ public override bool Equals(object obj)
+ {
+ return Equals(obj as Partitioning);
+ }
+
+ public bool Equals(Partitioning other)
+ {
+ return string.Equals(other?.Key, Key, StringComparison.OrdinalIgnoreCase);
+ }
+
+ public override int GetHashCode()
+ {
+ return Key.GetHashCode();
+ }
+
+ public override string ToString()
+ {
+ return Key;
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/PartitioningExtensions.cs b/src/Squidex.Domain.Apps.Core.Model/PartitioningExtensions.cs
new file mode 100644
index 000000000..a7c70286c
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/PartitioningExtensions.cs
@@ -0,0 +1,27 @@
+// ==========================================================================
+// PartitioningExtensions.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+
+namespace Squidex.Domain.Apps.Core
+{
+ public static class PartitioningExtensions
+ {
+ private static readonly HashSet AllowedPartitions = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ Partitioning.Language.Key,
+ Partitioning.Invariant.Key
+ };
+
+ public static bool IsValidPartitioning(this string value)
+ {
+ return value == null || AllowedPartitions.Contains(value);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsField.cs
new file mode 100644
index 000000000..3b9d9deae
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsField.cs
@@ -0,0 +1,28 @@
+// ==========================================================================
+// AssetsField.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public sealed class AssetsField : Field
+ {
+ public AssetsField(long id, string name, Partitioning partitioning)
+ : base(id, name, partitioning, new AssetsFieldProperties())
+ {
+ }
+
+ public AssetsField(long id, string name, Partitioning partitioning, AssetsFieldProperties properties)
+ : base(id, name, partitioning, properties)
+ {
+ }
+
+ public override T Accept(IFieldVisitor visitor)
+ {
+ return visitor.Visit(this);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsFieldProperties.cs
new file mode 100644
index 000000000..cf1ad40a6
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsFieldProperties.cs
@@ -0,0 +1,25 @@
+// ==========================================================================
+// AssetsFieldProperties.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ [TypeName(nameof(AssetsField))]
+ public sealed class AssetsFieldProperties : FieldProperties
+ {
+ public int? MinItems { get; set; }
+
+ public int? MaxItems { get; set; }
+
+ public override T Accept(IFieldPropertiesVisitor visitor)
+ {
+ return visitor.Visit(this);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanField.cs
new file mode 100644
index 000000000..70dc99448
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanField.cs
@@ -0,0 +1,28 @@
+// ==========================================================================
+// BooleanField.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public sealed class BooleanField : Field
+ {
+ public BooleanField(long id, string name, Partitioning partitioning)
+ : base(id, name, partitioning, new BooleanFieldProperties())
+ {
+ }
+
+ public BooleanField(long id, string name, Partitioning partitioning, BooleanFieldProperties properties)
+ : base(id, name, partitioning, properties)
+ {
+ }
+
+ public override T Accept(IFieldVisitor visitor)
+ {
+ return visitor.Visit(this);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanFieldEditor.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanFieldEditor.cs
new file mode 100644
index 000000000..6be38b6f9
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanFieldEditor.cs
@@ -0,0 +1,16 @@
+// ==========================================================================
+// BooleanFieldEditor.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public enum BooleanFieldEditor
+ {
+ Checkbox,
+ Toggle
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanFieldProperties.cs
new file mode 100644
index 000000000..f773ce620
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanFieldProperties.cs
@@ -0,0 +1,25 @@
+// ==========================================================================
+// BooleanFieldProperties.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ [TypeName(nameof(BooleanField))]
+ public sealed class BooleanFieldProperties : FieldProperties
+ {
+ public bool? DefaultValue { get; set; }
+
+ public BooleanFieldEditor Editor { get; set; }
+
+ public override T Accept(IFieldPropertiesVisitor visitor)
+ {
+ return visitor.Visit(this);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeCalculatedDefaultValue.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeCalculatedDefaultValue.cs
new file mode 100644
index 000000000..0e71b0187
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeCalculatedDefaultValue.cs
@@ -0,0 +1,16 @@
+// ==========================================================================
+// DateTimeCalculatedDefaultValue.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public enum DateTimeCalculatedDefaultValue
+ {
+ Now,
+ Today
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeField.cs
new file mode 100644
index 000000000..f3b1ba6fc
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeField.cs
@@ -0,0 +1,28 @@
+// ==========================================================================
+// DateTimeField.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public sealed class DateTimeField : Field
+ {
+ public DateTimeField(long id, string name, Partitioning partitioning)
+ : base(id, name, partitioning, new DateTimeFieldProperties())
+ {
+ }
+
+ public DateTimeField(long id, string name, Partitioning partitioning, DateTimeFieldProperties properties)
+ : base(id, name, partitioning, properties)
+ {
+ }
+
+ public override T Accept(IFieldVisitor visitor)
+ {
+ return visitor.Visit(this);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeFieldEditor.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeFieldEditor.cs
new file mode 100644
index 000000000..cb2be2f19
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeFieldEditor.cs
@@ -0,0 +1,16 @@
+// ==========================================================================
+// DateTimeFieldEditor.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public enum DateTimeFieldEditor
+ {
+ Date,
+ DateTime
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeFieldProperties.cs
new file mode 100644
index 000000000..9c03a46c8
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeFieldProperties.cs
@@ -0,0 +1,32 @@
+// ==========================================================================
+// DateTimeFieldProperties.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using NodaTime;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ [TypeName(nameof(DateTimeField))]
+ public sealed class DateTimeFieldProperties : FieldProperties
+ {
+ public Instant? MaxValue { get; set; }
+
+ public Instant? MinValue { get; set; }
+
+ public Instant? DefaultValue { get; set; }
+
+ public DateTimeFieldEditor Editor { get; set; }
+
+ public DateTimeCalculatedDefaultValue? CalculatedDefaultValue { get; set; }
+
+ public override T Accept(IFieldPropertiesVisitor visitor)
+ {
+ return visitor.Visit(this);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Field.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Field.cs
new file mode 100644
index 000000000..2f9edf612
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/Field.cs
@@ -0,0 +1,95 @@
+// ==========================================================================
+// Field.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public abstract class Field
+ {
+ private readonly long fieldId;
+ private readonly Partitioning partitioning;
+ private readonly string fieldName;
+ private bool isDisabled;
+ private bool isHidden;
+ private bool isLocked;
+
+ public long Id
+ {
+ get { return fieldId; }
+ }
+
+ public string Name
+ {
+ get { return fieldName; }
+ }
+
+ public bool IsLocked
+ {
+ get { return isLocked; }
+ }
+
+ public bool IsHidden
+ {
+ get { return isHidden; }
+ }
+
+ public bool IsDisabled
+ {
+ get { return isDisabled; }
+ }
+
+ public Partitioning Partitioning
+ {
+ get { return partitioning; }
+ }
+
+ public abstract FieldProperties RawProperties { get; }
+
+ protected Field(long id, string name, Partitioning partitioning)
+ {
+ Guard.NotNullOrEmpty(name, nameof(name));
+ Guard.NotNull(partitioning, nameof(partitioning));
+ Guard.GreaterThan(id, 0, nameof(id));
+
+ fieldId = id;
+ fieldName = name;
+
+ this.partitioning = partitioning;
+ }
+
+ public void Lock()
+ {
+ isLocked = true;
+ }
+
+ public void Hide()
+ {
+ isHidden = true;
+ }
+
+ public void Show()
+ {
+ isHidden = false;
+ }
+
+ public void Disable()
+ {
+ isDisabled = true;
+ }
+
+ public void Enable()
+ {
+ isDisabled = false;
+ }
+
+ public abstract void Update(FieldProperties newProperties);
+
+ public abstract T Accept(IFieldVisitor visitor);
+ }
+}
\ No newline at end of file
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs
new file mode 100644
index 000000000..c975e4ef9
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs
@@ -0,0 +1,21 @@
+// ==========================================================================
+// FieldProperties.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public abstract class FieldProperties : NamedElementPropertiesBase
+ {
+ public bool IsRequired { get; set; }
+
+ public bool IsListField { get; set; }
+
+ public string Placeholder { get; set; }
+
+ public abstract T Accept(IFieldPropertiesVisitor visitor);
+ }
+}
\ No newline at end of file
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldRegistry.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldRegistry.cs
new file mode 100644
index 000000000..bf94a3a13
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldRegistry.cs
@@ -0,0 +1,116 @@
+// ==========================================================================
+// FieldRegistry.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public sealed class FieldRegistry
+ {
+ private delegate Field FactoryFunction(long id, string name, Partitioning partitioning, FieldProperties properties);
+
+ private readonly TypeNameRegistry typeNameRegistry;
+ private readonly Dictionary fieldsByPropertyType = new Dictionary();
+
+ private sealed class Registered
+ {
+ private readonly FactoryFunction fieldFactory;
+ private readonly Type propertiesType;
+
+ public Type PropertiesType
+ {
+ get { return propertiesType; }
+ }
+
+ public Registered(FactoryFunction fieldFactory, Type propertiesType)
+ {
+ this.fieldFactory = fieldFactory;
+ this.propertiesType = propertiesType;
+ }
+
+ public Field CreateField(long id, string name, Partitioning partitioning, FieldProperties properties)
+ {
+ return fieldFactory(id, name, partitioning, properties);
+ }
+ }
+
+ public FieldRegistry(TypeNameRegistry typeNameRegistry)
+ {
+ Guard.NotNull(typeNameRegistry, nameof(typeNameRegistry));
+
+ this.typeNameRegistry = typeNameRegistry;
+
+ Add(
+ (id, name, partitioning, properties) =>
+ new BooleanField(id, name, partitioning, (BooleanFieldProperties)properties));
+
+ Add(
+ (id, name, partitioning, properties) =>
+ new NumberField(id, name, partitioning, (NumberFieldProperties)properties));
+
+ Add(
+ (id, name, partitioning, properties) =>
+ new StringField(id, name, partitioning, (StringFieldProperties)properties));
+
+ Add(
+ (id, name, partitioning, properties) =>
+ new JsonField(id, name, partitioning, (JsonFieldProperties)properties));
+
+ Add(
+ (id, name, partitioning, properties) =>
+ new AssetsField(id, name, partitioning, (AssetsFieldProperties)properties));
+
+ Add(
+ (id, name, partitioning, properties) =>
+ new GeolocationField(id, name, partitioning, (GeolocationFieldProperties)properties));
+
+ Add(
+ (id, name, partitioning, properties) =>
+ new ReferencesField(id, name, partitioning, (ReferencesFieldProperties)properties));
+
+ Add(
+ (id, name, partitioning, properties) =>
+ new DateTimeField(id, name, partitioning, (DateTimeFieldProperties)properties));
+
+ Add(
+ (id, name, partitioning, properties) =>
+ new TagsField(id, name, partitioning, (TagsFieldProperties)properties));
+
+ typeNameRegistry.MapObsolete(typeof(ReferencesFieldProperties), "DateTime");
+
+ typeNameRegistry.MapObsolete(typeof(DateTimeFieldProperties), "References");
+ }
+
+ private void Add(FactoryFunction fieldFactory)
+ {
+ Guard.NotNull(fieldFactory, nameof(fieldFactory));
+
+ typeNameRegistry.Map(typeof(TFieldProperties));
+
+ var registered = new Registered(fieldFactory, typeof(TFieldProperties));
+
+ fieldsByPropertyType[registered.PropertiesType] = registered;
+ }
+
+ public Field CreateField(long id, string name, Partitioning partitioning, FieldProperties properties)
+ {
+ Guard.NotNull(properties, nameof(properties));
+
+ var registered = fieldsByPropertyType.GetOrDefault(properties.GetType());
+
+ if (registered == null)
+ {
+ throw new InvalidOperationException($"The field property '{properties.GetType()}' is not supported.");
+ }
+
+ return registered.CreateField(id, name, partitioning, properties);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Field{T}.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Field{T}.cs
new file mode 100644
index 000000000..edc05dbb2
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/Field{T}.cs
@@ -0,0 +1,54 @@
+// ==========================================================================
+// Field_Generic.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using Squidex.Infrastructure;
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public abstract class Field : Field where T : FieldProperties, new()
+ {
+ private T properties;
+
+ public T Properties
+ {
+ get { return properties; }
+ }
+
+ public override FieldProperties RawProperties
+ {
+ get { return properties; }
+ }
+
+ protected Field(long id, string name, Partitioning partitioning, T properties)
+ : base(id, name, partitioning)
+ {
+ Guard.NotNull(properties, nameof(properties));
+
+ this.properties = properties;
+ }
+
+ public override void Update(FieldProperties newProperties)
+ {
+ var typedProperties = ValidateProperties(newProperties);
+
+ properties = typedProperties;
+ }
+
+ private T ValidateProperties(FieldProperties newProperties)
+ {
+ Guard.NotNull(newProperties, nameof(newProperties));
+
+ if (!(newProperties is T typedProperties))
+ {
+ throw new ArgumentException($"Properties must be of type '{typeof(T)}", nameof(newProperties));
+ }
+
+ return typedProperties;
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationField.cs
new file mode 100644
index 000000000..fc5b313a0
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationField.cs
@@ -0,0 +1,28 @@
+// ==========================================================================
+// GeolocationField.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public sealed class GeolocationField : Field
+ {
+ public GeolocationField(long id, string name, Partitioning partitioning)
+ : base(id, name, partitioning, new GeolocationFieldProperties())
+ {
+ }
+
+ public GeolocationField(long id, string name, Partitioning partitioning, GeolocationFieldProperties properties)
+ : base(id, name, partitioning, properties)
+ {
+ }
+
+ public override T Accept(IFieldVisitor visitor)
+ {
+ return visitor.Visit(this);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationFieldEditor.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationFieldEditor.cs
new file mode 100644
index 000000000..d6f3acff7
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationFieldEditor.cs
@@ -0,0 +1,15 @@
+// ==========================================================================
+// GeolocationFieldEditor.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public enum GeolocationFieldEditor
+ {
+ Map
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationFieldProperties.cs
new file mode 100644
index 000000000..5e4b34d4e
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationFieldProperties.cs
@@ -0,0 +1,23 @@
+// ==========================================================================
+// GeolocationFieldProperties.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ [TypeName(nameof(GeolocationField))]
+ public sealed class GeolocationFieldProperties : FieldProperties
+ {
+ public GeolocationFieldEditor Editor { get; set; }
+
+ public override T Accept(IFieldPropertiesVisitor visitor)
+ {
+ return visitor.Visit(this);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldPropertiesVisitor.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldPropertiesVisitor.cs
new file mode 100644
index 000000000..97087296a
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldPropertiesVisitor.cs
@@ -0,0 +1,31 @@
+// ==========================================================================
+// IFieldPropertiesVisitor.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public interface IFieldPropertiesVisitor
+ {
+ T Visit(AssetsFieldProperties properties);
+
+ T Visit(BooleanFieldProperties properties);
+
+ T Visit(DateTimeFieldProperties properties);
+
+ T Visit(GeolocationFieldProperties properties);
+
+ T Visit(JsonFieldProperties properties);
+
+ T Visit(NumberFieldProperties properties);
+
+ T Visit(ReferencesFieldProperties properties);
+
+ T Visit(StringFieldProperties properties);
+
+ T Visit(TagsFieldProperties properties);
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldVisitor.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldVisitor.cs
new file mode 100644
index 000000000..eecd20a73
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldVisitor.cs
@@ -0,0 +1,31 @@
+// ==========================================================================
+// IFieldVisitor.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public interface IFieldVisitor
+ {
+ T Visit(AssetsField field);
+
+ T Visit(BooleanField field);
+
+ T Visit(DateTimeField field);
+
+ T Visit(GeolocationField field);
+
+ T Visit(JsonField field);
+
+ T Visit(NumberField field);
+
+ T Visit(ReferencesField field);
+
+ T Visit(StringField field);
+
+ T Visit(TagsField field);
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs
new file mode 100644
index 000000000..51b103012
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs
@@ -0,0 +1,36 @@
+// ==========================================================================
+// JsonFieldModel.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Newtonsoft.Json;
+
+namespace Squidex.Domain.Apps.Core.Schemas.Json
+{
+ public sealed class JsonFieldModel
+ {
+ [JsonProperty]
+ public long Id { get; set; }
+
+ [JsonProperty]
+ public bool IsHidden { get; set; }
+
+ [JsonProperty]
+ public bool IsLocked { get; set; }
+
+ [JsonProperty]
+ public bool IsDisabled { get; set; }
+
+ [JsonProperty]
+ public string Name { get; set; }
+
+ [JsonProperty]
+ public string Partitioning { get; set; }
+
+ [JsonProperty]
+ public FieldProperties Properties { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs
new file mode 100644
index 000000000..f2c6695bd
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs
@@ -0,0 +1,99 @@
+// ==========================================================================
+// JsonSchemaModel.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System.Collections.Generic;
+using System.Linq;
+using Newtonsoft.Json;
+
+namespace Squidex.Domain.Apps.Core.Schemas.Json
+{
+ public sealed class JsonSchemaModel
+ {
+ [JsonProperty]
+ public string Name { get; set; }
+
+ [JsonProperty]
+ public bool IsPublished { get; set; }
+
+ [JsonProperty]
+ public SchemaProperties Properties { get; set; }
+
+ [JsonProperty]
+ public List Fields { get; set; }
+
+ public JsonSchemaModel()
+ {
+ }
+
+ public JsonSchemaModel(Schema schema)
+ {
+ Name = schema.Name;
+
+ Properties = schema.Properties;
+
+ Fields =
+ schema.Fields?.Select(x =>
+ new JsonFieldModel
+ {
+ Id = x.Id,
+ Name = x.Name,
+ IsHidden = x.IsHidden,
+ IsLocked = x.IsLocked,
+ IsDisabled = x.IsDisabled,
+ Partitioning = x.Partitioning.Key,
+ Properties = x.RawProperties
+ }).ToList();
+
+ IsPublished = schema.IsPublished;
+ }
+
+ public Schema ToSchema(FieldRegistry fieldRegistry)
+ {
+ var schema = new Schema(Name);
+
+ if (Fields != null)
+ {
+ foreach (var fieldModel in Fields)
+ {
+ var parititonKey = new Partitioning(fieldModel.Partitioning);
+
+ var field = fieldRegistry.CreateField(fieldModel.Id, fieldModel.Name, parititonKey, fieldModel.Properties);
+
+ if (fieldModel.IsDisabled)
+ {
+ field.Disable();
+ }
+
+ if (fieldModel.IsLocked)
+ {
+ field.Lock();
+ }
+
+ if (fieldModel.IsHidden)
+ {
+ field.Hide();
+ }
+
+ schema.AddField(field);
+ }
+ }
+
+ if (IsPublished)
+ {
+ schema.Publish();
+ }
+
+ if (Properties != null)
+ {
+ schema.Update(Properties);
+ }
+
+ return schema;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/SchemaConverter.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/SchemaConverter.cs
new file mode 100644
index 000000000..317afa717
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/SchemaConverter.cs
@@ -0,0 +1,36 @@
+// ==========================================================================
+// SchemaConverter.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Newtonsoft.Json;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.Json;
+
+namespace Squidex.Domain.Apps.Core.Schemas.Json
+{
+ public sealed class SchemaConverter : JsonClassConverter
+ {
+ private readonly FieldRegistry fieldRegistry;
+
+ public SchemaConverter(FieldRegistry fieldRegistry)
+ {
+ Guard.NotNull(fieldRegistry, nameof(fieldRegistry));
+
+ this.fieldRegistry = fieldRegistry;
+ }
+
+ protected override void WriteValue(JsonWriter writer, Schema value, JsonSerializer serializer)
+ {
+ serializer.Serialize(writer, new JsonSchemaModel(value));
+ }
+
+ protected override Schema ReadValue(JsonReader reader, JsonSerializer serializer)
+ {
+ return serializer.Deserialize(reader).ToSchema(fieldRegistry);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonField.cs
new file mode 100644
index 000000000..49fab3979
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonField.cs
@@ -0,0 +1,28 @@
+// ==========================================================================
+// JsonField.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public sealed class JsonField : Field
+ {
+ public JsonField(long id, string name, Partitioning partitioning)
+ : base(id, name, partitioning, new JsonFieldProperties())
+ {
+ }
+
+ public JsonField(long id, string name, Partitioning partitioning, JsonFieldProperties properties)
+ : base(id, name, partitioning, properties)
+ {
+ }
+
+ public override T Accept(IFieldVisitor visitor)
+ {
+ return visitor.Visit(this);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs
new file mode 100644
index 000000000..c432be09a
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs
@@ -0,0 +1,21 @@
+// ==========================================================================
+// JsonFieldProperties.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ [TypeName(nameof(JsonField))]
+ public sealed class JsonFieldProperties : FieldProperties
+ {
+ public override T Accept(IFieldPropertiesVisitor visitor)
+ {
+ return visitor.Visit(this);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/NamedElementPropertiesBase.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/NamedElementPropertiesBase.cs
new file mode 100644
index 000000000..7f5a9d572
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/NamedElementPropertiesBase.cs
@@ -0,0 +1,17 @@
+// ==========================================================================
+// NamedElementPropertiesBase.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public abstract class NamedElementPropertiesBase
+ {
+ public string Label { get; set; }
+
+ public string Hints { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberField.cs
new file mode 100644
index 000000000..d19b05c39
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberField.cs
@@ -0,0 +1,28 @@
+// ==========================================================================
+// NumberField.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public sealed class NumberField : Field
+ {
+ public NumberField(long id, string name, Partitioning partitioning)
+ : base(id, name, partitioning, new NumberFieldProperties())
+ {
+ }
+
+ public NumberField(long id, string name, Partitioning partitioning, NumberFieldProperties properties)
+ : base(id, name, partitioning, properties)
+ {
+ }
+
+ public override T Accept(IFieldVisitor visitor)
+ {
+ return visitor.Visit(this);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberFieldEditor.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberFieldEditor.cs
new file mode 100644
index 000000000..28f164ef8
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberFieldEditor.cs
@@ -0,0 +1,18 @@
+// ==========================================================================
+// NumberFieldEditor.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public enum NumberFieldEditor
+ {
+ Input,
+ Radio,
+ Dropdown,
+ Stars
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberFieldProperties.cs
new file mode 100644
index 000000000..ce1703faa
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberFieldProperties.cs
@@ -0,0 +1,31 @@
+// ==========================================================================
+// NumberFieldProperties.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ [TypeName(nameof(NumberField))]
+ public sealed class NumberFieldProperties : FieldProperties
+ {
+ public double? MaxValue { get; set; }
+
+ public double? MinValue { get; set; }
+
+ public double? DefaultValue { get; set; }
+
+ public double[] AllowedValues { get; set; }
+
+ public NumberFieldEditor Editor { get; set; }
+
+ public override T Accept(IFieldPropertiesVisitor visitor)
+ {
+ return visitor.Visit(this);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesField.cs
new file mode 100644
index 000000000..f2dc2bc5b
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesField.cs
@@ -0,0 +1,28 @@
+// ==========================================================================
+// ReferencesField.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public sealed class ReferencesField : Field
+ {
+ public ReferencesField(long id, string name, Partitioning partitioning)
+ : base(id, name, partitioning, new ReferencesFieldProperties())
+ {
+ }
+
+ public ReferencesField(long id, string name, Partitioning partitioning, ReferencesFieldProperties properties)
+ : base(id, name, partitioning, properties)
+ {
+ }
+
+ public override T Accept(IFieldVisitor visitor)
+ {
+ return visitor.Visit(this);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs
new file mode 100644
index 000000000..8a4f76192
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs
@@ -0,0 +1,28 @@
+// ==========================================================================
+// ReferencesFieldProperties.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ [TypeName(nameof(ReferencesField))]
+ public sealed class ReferencesFieldProperties : FieldProperties
+ {
+ public int? MinItems { get; set; }
+
+ public int? MaxItems { get; set; }
+
+ public Guid SchemaId { get; set; }
+
+ public override T Accept(IFieldPropertiesVisitor visitor)
+ {
+ return visitor.Visit(this);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Schema.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Schema.cs
new file mode 100644
index 000000000..8a7a06d40
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/Schema.cs
@@ -0,0 +1,120 @@
+// ==========================================================================
+// Schema.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public sealed class Schema
+ {
+ private readonly string name;
+ private readonly List fieldsOrdered = new List();
+ private readonly Dictionary fieldsById = new Dictionary();
+ private readonly Dictionary fieldsByName = new Dictionary();
+ private SchemaProperties properties = new SchemaProperties();
+ private bool isPublished;
+
+ public string Name
+ {
+ get { return name; }
+ }
+
+ public bool IsPublished
+ {
+ get { return isPublished; }
+ }
+
+ public IReadOnlyList Fields
+ {
+ get { return fieldsOrdered; }
+ }
+
+ public IReadOnlyDictionary FieldsById
+ {
+ get { return fieldsById; }
+ }
+
+ public IReadOnlyDictionary FieldsByName
+ {
+ get { return fieldsByName; }
+ }
+
+ public SchemaProperties Properties
+ {
+ get { return properties; }
+ }
+
+ public void Publish()
+ {
+ isPublished = true;
+ }
+
+ public void Unpublish()
+ {
+ isPublished = false;
+ }
+
+ public Schema(string name)
+ {
+ Guard.NotNullOrEmpty(name, nameof(name));
+
+ this.name = name;
+ }
+
+ public void Update(SchemaProperties newProperties)
+ {
+ Guard.NotNull(newProperties, nameof(newProperties));
+
+ properties = newProperties;
+ }
+
+ public void DeleteField(long fieldId)
+ {
+ if (!fieldsById.TryGetValue(fieldId, out var field))
+ {
+ return;
+ }
+
+ fieldsById.Remove(fieldId);
+ fieldsByName.Remove(field.Name);
+ fieldsOrdered.Remove(field);
+ }
+
+ public void ReorderFields(List ids)
+ {
+ Guard.NotNull(ids, nameof(ids));
+
+ if (ids.Count != fieldsOrdered.Count || ids.Any(x => !fieldsById.ContainsKey(x)))
+ {
+ throw new ArgumentException("Ids must cover all fields.", nameof(ids));
+ }
+
+ var fields = fieldsOrdered.ToList();
+
+ fieldsOrdered.Clear();
+ fieldsOrdered.AddRange(fields.OrderBy(f => ids.IndexOf(f.Id)));
+ }
+
+ public void AddField(Field field)
+ {
+ Guard.NotNull(field, nameof(field));
+
+ if (fieldsByName.ContainsKey(field.Name) || fieldsById.ContainsKey(field.Id))
+ {
+ throw new ArgumentException($"A field with name '{field.Name}' already exists.", nameof(field));
+ }
+
+ fieldsById.Add(field.Id, field);
+ fieldsByName.Add(field.Name, field);
+ fieldsOrdered.Add(field);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaProperties.cs
new file mode 100644
index 000000000..4731958e7
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaProperties.cs
@@ -0,0 +1,14 @@
+// ==========================================================================
+// SchemaProperties.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public sealed class SchemaProperties : NamedElementPropertiesBase
+ {
+ }
+}
\ No newline at end of file
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/StringField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/StringField.cs
new file mode 100644
index 000000000..d2587fd79
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/StringField.cs
@@ -0,0 +1,28 @@
+// ==========================================================================
+// StringField.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public sealed class StringField : Field
+ {
+ public StringField(long id, string name, Partitioning partitioning)
+ : base(id, name, partitioning, new StringFieldProperties())
+ {
+ }
+
+ public StringField(long id, string name, Partitioning partitioning, StringFieldProperties properties)
+ : base(id, name, partitioning, properties)
+ {
+ }
+
+ public override T Accept(IFieldVisitor visitor)
+ {
+ return visitor.Visit(this);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldEditor.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldEditor.cs
new file mode 100644
index 000000000..1226fef19
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldEditor.cs
@@ -0,0 +1,20 @@
+// ==========================================================================
+// StringFieldEditor.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public enum StringFieldEditor
+ {
+ Input,
+ Markdown,
+ Dropdown,
+ Radio,
+ RichText,
+ TextArea
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs
new file mode 100644
index 000000000..5263cc2e7
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs
@@ -0,0 +1,35 @@
+// ==========================================================================
+// StringFieldProperties.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ [TypeName(nameof(StringField))]
+ public sealed class StringFieldProperties : FieldProperties
+ {
+ public int? MinLength { get; set; }
+
+ public int? MaxLength { get; set; }
+
+ public string DefaultValue { get; set; }
+
+ public string Pattern { get; set; }
+
+ public string PatternMessage { get; set; }
+
+ public string[] AllowedValues { get; set; }
+
+ public StringFieldEditor Editor { get; set; }
+
+ public override T Accept(IFieldPropertiesVisitor visitor)
+ {
+ return visitor.Visit(this);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsField.cs
new file mode 100644
index 000000000..a36d061f1
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsField.cs
@@ -0,0 +1,28 @@
+// ==========================================================================
+// TagsField.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ public sealed class TagsField : Field
+ {
+ public TagsField(long id, string name, Partitioning partitioning)
+ : base(id, name, partitioning, new TagsFieldProperties())
+ {
+ }
+
+ public TagsField(long id, string name, Partitioning partitioning, TagsFieldProperties properties)
+ : base(id, name, partitioning, properties)
+ {
+ }
+
+ public override T Accept(IFieldVisitor visitor)
+ {
+ return visitor.Visit(this);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsFieldProperties.cs
new file mode 100644
index 000000000..9e10e634b
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsFieldProperties.cs
@@ -0,0 +1,25 @@
+// ==========================================================================
+// TagsField.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Schemas
+{
+ [TypeName(nameof(TagsField))]
+ public sealed class TagsFieldProperties : FieldProperties
+ {
+ public int? MinItems { get; set; }
+
+ public int? MaxItems { get; set; }
+
+ public override T Accept(IFieldPropertiesVisitor visitor)
+ {
+ return visitor.Visit(this);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj b/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj
new file mode 100644
index 000000000..754774561
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj
@@ -0,0 +1,20 @@
+
+
+ netstandard2.0
+ Squidex.Domain.Apps.Core
+
+
+ full
+ True
+
+
+
+
+
+
+
+
+
+ ..\..\Squidex.ruleset
+
+
diff --git a/src/Squidex.Domain.Apps.Core.Model/Webhooks/WebhookSchema.cs b/src/Squidex.Domain.Apps.Core.Model/Webhooks/WebhookSchema.cs
new file mode 100644
index 000000000..169e85b7d
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Model/Webhooks/WebhookSchema.cs
@@ -0,0 +1,25 @@
+// ==========================================================================
+// WebhookSchema.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+
+namespace Squidex.Domain.Apps.Core.Webhooks
+{
+ public sealed class WebhookSchema
+ {
+ public Guid SchemaId { get; set; }
+
+ public bool SendCreate { get; set; }
+
+ public bool SendUpdate { get; set; }
+
+ public bool SendDelete { get; set; }
+
+ public bool SendPublish { get; set; }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ContentConverter.cs b/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ContentConverter.cs
new file mode 100644
index 000000000..87c986cb9
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ContentConverter.cs
@@ -0,0 +1,202 @@
+// ==========================================================================
+// ContentConverter.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using Newtonsoft.Json.Linq;
+using Squidex.Domain.Apps.Core.Apps;
+using Squidex.Domain.Apps.Core.Contents;
+using Squidex.Domain.Apps.Core.Schemas;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.Json;
+
+namespace Squidex.Domain.Apps.Core.ConvertContent
+{
+ public static class ContentConverter
+ {
+ public static NamedContentData ToNameModel(this IdContentData source, Schema schema, bool decodeJsonField)
+ {
+ Guard.NotNull(schema, nameof(schema));
+
+ var result = new NamedContentData();
+
+ foreach (var fieldValue in source)
+ {
+ if (!schema.FieldsById.TryGetValue(fieldValue.Key, out var field))
+ {
+ continue;
+ }
+
+ if (decodeJsonField && field is JsonField)
+ {
+ var encodedValue = new ContentFieldData();
+
+ foreach (var partitionValue in fieldValue.Value)
+ {
+ if (partitionValue.Value.IsNull())
+ {
+ encodedValue[partitionValue.Key] = null;
+ }
+ else
+ {
+ var value = Encoding.UTF8.GetString(Convert.FromBase64String(partitionValue.Value.ToString()));
+
+ encodedValue[partitionValue.Key] = JToken.Parse(value);
+ }
+ }
+
+ result[field.Name] = encodedValue;
+ }
+ else
+ {
+ result[field.Name] = fieldValue.Value;
+ }
+ }
+
+ return result;
+ }
+
+ public static IdContentData ToIdModel(this NamedContentData content, Schema schema, bool encodeJsonField)
+ {
+ Guard.NotNull(schema, nameof(schema));
+
+ var result = new IdContentData();
+
+ foreach (var fieldValue in content)
+ {
+ if (!schema.FieldsByName.TryGetValue(fieldValue.Key, out var field))
+ {
+ continue;
+ }
+
+ var fieldId = field.Id;
+
+ if (encodeJsonField && field is JsonField)
+ {
+ var encodedValue = new ContentFieldData();
+
+ foreach (var partitionValue in fieldValue.Value)
+ {
+ if (partitionValue.Value.IsNull())
+ {
+ encodedValue[partitionValue.Key] = null;
+ }
+ else
+ {
+ var value = Convert.ToBase64String(Encoding.UTF8.GetBytes(partitionValue.Value.ToString()));
+
+ encodedValue[partitionValue.Key] = value;
+ }
+ }
+
+ result[fieldId] = encodedValue;
+ }
+ else
+ {
+ result[fieldId] = fieldValue.Value;
+ }
+ }
+
+ return result;
+ }
+
+ public static NamedContentData ToApiModel(this NamedContentData content, Schema schema, LanguagesConfig languagesConfig, bool excludeHidden = true)
+ {
+ Guard.NotNull(schema, nameof(schema));
+ Guard.NotNull(languagesConfig, nameof(languagesConfig));
+
+ var codeForInvariant = InvariantPartitioning.Instance.Master.Key;
+ var codeForMasterLanguage = languagesConfig.Master.Language.Iso2Code;
+
+ var result = new NamedContentData();
+
+ foreach (var fieldValue in content)
+ {
+ if (!schema.FieldsByName.TryGetValue(fieldValue.Key, out var field) || (excludeHidden && field.IsHidden))
+ {
+ continue;
+ }
+
+ var fieldResult = new ContentFieldData();
+ var fieldValues = fieldValue.Value;
+
+ if (field.Partitioning.Equals(Partitioning.Language))
+ {
+ foreach (var languageConfig in languagesConfig)
+ {
+ var languageCode = languageConfig.Key;
+
+ if (fieldValues.TryGetValue(languageCode, out var value))
+ {
+ fieldResult.Add(languageCode, value);
+ }
+ else if (languageConfig == languagesConfig.Master && fieldValues.TryGetValue(codeForInvariant, out value))
+ {
+ fieldResult.Add(languageCode, value);
+ }
+ }
+ }
+ else
+ {
+ if (fieldValues.TryGetValue(codeForInvariant, out var value))
+ {
+ fieldResult.Add(codeForInvariant, value);
+ }
+ else if (fieldValues.TryGetValue(codeForMasterLanguage, out value))
+ {
+ fieldResult.Add(codeForInvariant, value);
+ }
+ else if (fieldValues.Count > 0)
+ {
+ fieldResult.Add(codeForInvariant, fieldValues.Values.First());
+ }
+ }
+
+ result.Add(field.Name, fieldResult);
+ }
+
+ return result;
+ }
+
+ public static object ToLanguageModel(this NamedContentData content, LanguagesConfig languagesConfig, IReadOnlyCollection languagePreferences = null)
+ {
+ Guard.NotNull(languagesConfig, nameof(languagesConfig));
+
+ if (languagePreferences == null || languagePreferences.Count == 0)
+ {
+ return content;
+ }
+
+ if (languagePreferences.Count == 1 && languagesConfig.TryGetConfig(languagePreferences.First(), out var languageConfig))
+ {
+ languagePreferences = languagePreferences.Union(languageConfig.LanguageFallbacks).ToList();
+ }
+
+ var result = new Dictionary();
+
+ foreach (var fieldValue in content)
+ {
+ var fieldValues = fieldValue.Value;
+
+ foreach (var language in languagePreferences)
+ {
+ if (fieldValues.TryGetValue(language, out var value) && value != null)
+ {
+ result[fieldValue.Key] = value;
+
+ break;
+ }
+ }
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/ContentEnricher.cs b/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/ContentEnricher.cs
new file mode 100644
index 000000000..0aabe935c
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/ContentEnricher.cs
@@ -0,0 +1,78 @@
+// ==========================================================================
+// ContentEnricher.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Newtonsoft.Json.Linq;
+using NodaTime;
+using Squidex.Domain.Apps.Core.Contents;
+using Squidex.Domain.Apps.Core.Schemas;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.Json;
+
+namespace Squidex.Domain.Apps.Core.EnrichContent
+{
+ public sealed class ContentEnricher
+ {
+ private readonly Schema schema;
+ private readonly PartitionResolver partitionResolver;
+
+ public ContentEnricher(Schema schema, PartitionResolver partitionResolver)
+ {
+ Guard.NotNull(schema, nameof(schema));
+ Guard.NotNull(partitionResolver, nameof(partitionResolver));
+
+ this.schema = schema;
+
+ this.partitionResolver = partitionResolver;
+ }
+
+ public void Enrich(NamedContentData data)
+ {
+ Guard.NotNull(data, nameof(data));
+
+ foreach (var field in schema.Fields)
+ {
+ var fieldData = data.GetOrCreate(field.Name, k => new ContentFieldData());
+ var fieldPartition = partitionResolver(field.Partitioning);
+
+ foreach (var partitionItem in fieldPartition)
+ {
+ Enrich(field, fieldData, partitionItem);
+ }
+
+ if (fieldData.Count > 0)
+ {
+ data[field.Name] = fieldData;
+ }
+ }
+ }
+
+ private static void Enrich(Field field, ContentFieldData fieldData, IFieldPartitionItem partitionItem)
+ {
+ Guard.NotNull(fieldData, nameof(fieldData));
+
+ var defaultValue = DefaultValueFactory.CreateDefaultValue(field, SystemClock.Instance.GetCurrentInstant());
+
+ if (field.RawProperties.IsRequired || defaultValue.IsNull())
+ {
+ return;
+ }
+
+ var key = partitionItem.Key;
+
+ if (!fieldData.TryGetValue(key, out var value) || ShouldApplyDefaultValue(field, value))
+ {
+ fieldData.AddValue(key, defaultValue);
+ }
+ }
+
+ private static bool ShouldApplyDefaultValue(Field field, JToken value)
+ {
+ return value.IsNull() || (field is StringField && value is JValue jValue && Equals(jValue.Value, string.Empty));
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/ContentEnrichmentExtensions.cs b/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/ContentEnrichmentExtensions.cs
new file mode 100644
index 000000000..68a5e044d
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/ContentEnrichmentExtensions.cs
@@ -0,0 +1,23 @@
+// ==========================================================================
+// ContentExtensions.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Squidex.Domain.Apps.Core.Contents;
+using Squidex.Domain.Apps.Core.Schemas;
+
+namespace Squidex.Domain.Apps.Core.EnrichContent
+{
+ public static class ContentEnrichmentExtensions
+ {
+ public static void Enrich(this NamedContentData data, Schema schema, PartitionResolver partitionResolver)
+ {
+ var enricher = new ContentEnricher(schema, partitionResolver);
+
+ enricher.Enrich(data);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/DefaultValueFactory.cs b/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/DefaultValueFactory.cs
new file mode 100644
index 000000000..0cbd275e2
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/DefaultValueFactory.cs
@@ -0,0 +1,87 @@
+// ==========================================================================
+// ValidatorsFactory.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Newtonsoft.Json.Linq;
+using NodaTime;
+using Squidex.Domain.Apps.Core.Schemas;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.EnrichContent
+{
+ public sealed class DefaultValueFactory : IFieldPropertiesVisitor
+ {
+ private readonly Instant now;
+
+ private DefaultValueFactory(Instant now)
+ {
+ this.now = now;
+ }
+
+ public static JToken CreateDefaultValue(Field field, Instant now)
+ {
+ Guard.NotNull(field, nameof(field));
+
+ return field.RawProperties.Accept(new DefaultValueFactory(now));
+ }
+
+ public JToken Visit(AssetsFieldProperties properties)
+ {
+ return new JArray();
+ }
+
+ public JToken Visit(BooleanFieldProperties properties)
+ {
+ return properties.DefaultValue;
+ }
+
+ public JToken Visit(GeolocationFieldProperties properties)
+ {
+ return JValue.CreateNull();
+ }
+
+ public JToken Visit(JsonFieldProperties properties)
+ {
+ return JValue.CreateNull();
+ }
+
+ public JToken Visit(NumberFieldProperties properties)
+ {
+ return properties.DefaultValue;
+ }
+
+ public JToken Visit(ReferencesFieldProperties properties)
+ {
+ return new JArray();
+ }
+
+ public JToken Visit(StringFieldProperties properties)
+ {
+ return properties.DefaultValue;
+ }
+
+ public JToken Visit(TagsFieldProperties properties)
+ {
+ return new JArray();
+ }
+
+ public JToken Visit(DateTimeFieldProperties properties)
+ {
+ if (properties.CalculatedDefaultValue == DateTimeCalculatedDefaultValue.Now)
+ {
+ return now.ToString();
+ }
+
+ if (properties.CalculatedDefaultValue == DateTimeCalculatedDefaultValue.Today)
+ {
+ return now.ToString().Substring(10);
+ }
+
+ return properties.DefaultValue?.ToString();
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ContentReferencesExtensions.cs b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ContentReferencesExtensions.cs
new file mode 100644
index 000000000..d5813b87f
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ContentReferencesExtensions.cs
@@ -0,0 +1,75 @@
+// ==========================================================================
+// ContentReferencesExtensions.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Squidex.Domain.Apps.Core.Contents;
+using Squidex.Domain.Apps.Core.Schemas;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.Json;
+
+namespace Squidex.Domain.Apps.Core.ExtractReferenceIds
+{
+ public static class ContentReferencesExtensions
+ {
+ public static IdContentData ToCleanedReferences(this IdContentData source, Schema schema, ISet deletedReferencedIds)
+ {
+ Guard.NotNull(schema, nameof(schema));
+ Guard.NotNull(deletedReferencedIds, nameof(deletedReferencedIds));
+
+ var result = new IdContentData(source);
+
+ foreach (var field in schema.Fields)
+ {
+ var fieldData = source.GetOrDefault(field.Id);
+
+ if (fieldData == null)
+ {
+ continue;
+ }
+
+ foreach (var partitionValue in fieldData.Where(x => !x.Value.IsNull()).ToList())
+ {
+ var newValue = field.CleanReferences(partitionValue.Value, deletedReferencedIds);
+
+ fieldData[partitionValue.Key] = newValue;
+ }
+ }
+
+ return result;
+ }
+
+ public static IEnumerable GetReferencedIds(this IdContentData source, Schema schema)
+ {
+ Guard.NotNull(schema, nameof(schema));
+
+ var foundReferences = new HashSet();
+
+ foreach (var field in schema.Fields)
+ {
+ var fieldData = source.GetOrDefault(field.Id);
+
+ if (fieldData == null)
+ {
+ continue;
+ }
+
+ foreach (var partitionValue in fieldData.Where(x => !x.Value.IsNull()))
+ {
+ var ids = field.ExtractReferences(partitionValue.Value);
+
+ foreach (var id in ids.Where(x => foundReferences.Add(x)))
+ {
+ yield return id;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesCleaner.cs b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesCleaner.cs
new file mode 100644
index 000000000..31f67410f
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesCleaner.cs
@@ -0,0 +1,58 @@
+// ==========================================================================
+// ReferenceExtractor.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Newtonsoft.Json.Linq;
+using Squidex.Domain.Apps.Core.Schemas;
+using Squidex.Infrastructure.Json;
+
+namespace Squidex.Domain.Apps.Core.ExtractReferenceIds
+{
+ public static class ReferencesCleaner
+ {
+ public static JToken CleanReferences(this Field field, JToken value, ISet oldReferences)
+ {
+ if ((field is AssetsField || field is ReferencesField) && !value.IsNull())
+ {
+ switch (field)
+ {
+ case AssetsField assetsField:
+ return Visit(assetsField, value, oldReferences);
+
+ case ReferencesField referencesField:
+ return Visit(referencesField, value, oldReferences);
+ }
+ }
+
+ return value;
+ }
+
+ private static JToken Visit(AssetsField field, JToken value, IEnumerable oldReferences)
+ {
+ var oldIds = field.ExtractReferences(value).ToList();
+ var newIds = oldIds.Except(oldReferences).ToList();
+
+ return oldIds.Count != newIds.Count ? JToken.FromObject(newIds) : value;
+ }
+
+ private static JToken Visit(ReferencesField field, JToken value, ICollection oldReferences)
+ {
+ if (oldReferences.Contains(field.Properties.SchemaId))
+ {
+ return new JArray();
+ }
+
+ var oldIds = field.ExtractReferences(value).ToList();
+ var newIds = oldIds.Except(oldReferences).ToList();
+
+ return oldIds.Count != newIds.Count ? JToken.FromObject(newIds) : value;
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtractor.cs b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtractor.cs
new file mode 100644
index 000000000..f70c8b9f0
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtractor.cs
@@ -0,0 +1,63 @@
+// ==========================================================================
+// ReferenceExtractor.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Newtonsoft.Json.Linq;
+using Squidex.Domain.Apps.Core.Schemas;
+
+namespace Squidex.Domain.Apps.Core.ExtractReferenceIds
+{
+ public static class ReferencesExtractor
+ {
+ public static IEnumerable ExtractReferences(this Field field, JToken value)
+ {
+ switch (field)
+ {
+ case AssetsField assetsField:
+ return Visit(assetsField, value);
+
+ case ReferencesField referencesField:
+ return Visit(referencesField, value);
+ }
+
+ return Enumerable.Empty();
+ }
+
+ public static IEnumerable Visit(AssetsField field, JToken value)
+ {
+ IEnumerable result = null;
+ try
+ {
+ result = value?.ToObject>();
+ }
+ catch
+ {
+ result = null;
+ }
+
+ return result ?? Enumerable.Empty();
+ }
+
+ private static IEnumerable Visit(ReferencesField field, JToken value)
+ {
+ IEnumerable result = null;
+ try
+ {
+ result = value?.ToObject>() ?? Enumerable.Empty();
+ }
+ catch
+ {
+ result = Enumerable.Empty();
+ }
+
+ return result.Union(new[] { field.Properties.SchemaId });
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/GenerateEdmSchema/EdmSchemaExtensions.cs b/src/Squidex.Domain.Apps.Core.Operations/GenerateEdmSchema/EdmSchemaExtensions.cs
new file mode 100644
index 000000000..767f89839
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/GenerateEdmSchema/EdmSchemaExtensions.cs
@@ -0,0 +1,61 @@
+// ==========================================================================
+// EdmSchemaExtensions.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Linq;
+using Microsoft.OData.Edm;
+using Squidex.Domain.Apps.Core.Schemas;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.GenerateEdmSchema
+{
+ public static class EdmSchemaExtensions
+ {
+ public static string EscapeEdmField(this string field)
+ {
+ return field.Replace("-", "_");
+ }
+
+ public static string UnescapeEdmField(this string field)
+ {
+ return field.Replace("_", "-");
+ }
+
+ public static EdmComplexType BuildEdmType(this Schema schema, PartitionResolver partitionResolver, Func typeResolver)
+ {
+ Guard.NotNull(typeResolver, nameof(typeResolver));
+ Guard.NotNull(partitionResolver, nameof(partitionResolver));
+
+ var schemaName = schema.Name.ToPascalCase();
+
+ var edmType = new EdmComplexType("Squidex", schemaName);
+
+ foreach (var field in schema.FieldsByName.Values.Where(x => !x.IsHidden))
+ {
+ var edmValueType = EdmTypeVisitor.CreateEdmType(field);
+
+ if (edmValueType == null)
+ {
+ continue;
+ }
+
+ var partitionType = typeResolver(new EdmComplexType("Squidex", $"{schemaName}{field.Name.ToPascalCase()}Property"));
+ var partition = partitionResolver(field.Partitioning);
+
+ foreach (var partitionItem in partition)
+ {
+ partitionType.AddStructuralProperty(partitionItem.Key, edmValueType);
+ }
+
+ edmType.AddStructuralProperty(field.Name.EscapeEdmField(), new EdmComplexTypeReference(partitionType, false));
+ }
+
+ return edmType;
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/GenerateEdmSchema/EdmTypeVisitor.cs b/src/Squidex.Domain.Apps.Core.Operations/GenerateEdmSchema/EdmTypeVisitor.cs
new file mode 100644
index 000000000..c80d94fc1
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/GenerateEdmSchema/EdmTypeVisitor.cs
@@ -0,0 +1,77 @@
+// ==========================================================================
+// EdmTypeVisitor.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Microsoft.OData.Edm;
+using Squidex.Domain.Apps.Core.Schemas;
+
+namespace Squidex.Domain.Apps.Core.GenerateEdmSchema
+{
+ public sealed class EdmTypeVisitor : IFieldVisitor
+ {
+ private static readonly EdmTypeVisitor Instance = new EdmTypeVisitor();
+
+ private EdmTypeVisitor()
+ {
+ }
+
+ public static IEdmTypeReference CreateEdmType(Field field)
+ {
+ return field.Accept(Instance);
+ }
+
+ public IEdmTypeReference Visit(AssetsField field)
+ {
+ return CreatePrimitive(EdmPrimitiveTypeKind.String, field);
+ }
+
+ public IEdmTypeReference Visit(BooleanField field)
+ {
+ return CreatePrimitive(EdmPrimitiveTypeKind.Boolean, field);
+ }
+
+ public IEdmTypeReference Visit(DateTimeField field)
+ {
+ return CreatePrimitive(EdmPrimitiveTypeKind.DateTimeOffset, field);
+ }
+
+ public IEdmTypeReference Visit(GeolocationField field)
+ {
+ return null;
+ }
+
+ public IEdmTypeReference Visit(JsonField field)
+ {
+ return null;
+ }
+
+ public IEdmTypeReference Visit(NumberField field)
+ {
+ return CreatePrimitive(EdmPrimitiveTypeKind.Double, field);
+ }
+
+ public IEdmTypeReference Visit(ReferencesField field)
+ {
+ return CreatePrimitive(EdmPrimitiveTypeKind.String, field);
+ }
+
+ public IEdmTypeReference Visit(StringField field)
+ {
+ return CreatePrimitive(EdmPrimitiveTypeKind.String, field);
+ }
+
+ public IEdmTypeReference Visit(TagsField field)
+ {
+ return CreatePrimitive(EdmPrimitiveTypeKind.String, field);
+ }
+
+ private static IEdmTypeReference CreatePrimitive(EdmPrimitiveTypeKind kind, Field field)
+ {
+ return EdmCoreModel.Instance.GetPrimitive(kind, !field.RawProperties.IsRequired);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/ContentSchemaBuilder.cs b/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/ContentSchemaBuilder.cs
new file mode 100644
index 000000000..9ba399359
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/ContentSchemaBuilder.cs
@@ -0,0 +1,57 @@
+// ==========================================================================
+// ContentSchemaBuilder.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using NJsonSchema;
+using Squidex.Domain.Apps.Core.Schemas;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.GenerateJsonSchema
+{
+ public sealed class ContentSchemaBuilder
+ {
+ public JsonSchema4 CreateContentSchema(Schema schema, JsonSchema4 dataSchema)
+ {
+ Guard.NotNull(schema, nameof(schema));
+ Guard.NotNull(dataSchema, nameof(dataSchema));
+
+ var schemaName = schema.Properties.Label.WithFallback(schema.Name);
+
+ var contentSchema = new JsonSchema4
+ {
+ Properties =
+ {
+ ["id"] = CreateProperty($"The id of the {schemaName} content."),
+ ["data"] = CreateProperty($"The data of the {schemaName}.", dataSchema),
+ ["version"] = CreateProperty($"The version of the {schemaName}.", JsonObjectType.Number),
+ ["created"] = CreateProperty($"The date and time when the {schemaName} content has been created.", "date-time"),
+ ["createdBy"] = CreateProperty($"The user that has created the {schemaName} content."),
+ ["lastModified"] = CreateProperty($"The date and time when the {schemaName} content has been modified last.", "date-time"),
+ ["lastModifiedBy"] = CreateProperty($"The user that has updated the {schemaName} content last.")
+ },
+ Type = JsonObjectType.Object
+ };
+
+ return contentSchema;
+ }
+
+ private static JsonProperty CreateProperty(string description, JsonSchema4 dataSchema)
+ {
+ return new JsonProperty { Description = description, IsRequired = true, Type = JsonObjectType.Object, Reference = dataSchema };
+ }
+
+ private static JsonProperty CreateProperty(string description, JsonObjectType type)
+ {
+ return new JsonProperty { Description = description, IsRequired = true, Type = type };
+ }
+
+ private static JsonProperty CreateProperty(string description, string format = null)
+ {
+ return new JsonProperty { Description = description, Format = format, IsRequired = true, Type = JsonObjectType.String };
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonSchemaExtensions.cs b/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonSchemaExtensions.cs
new file mode 100644
index 000000000..d20a28607
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonSchemaExtensions.cs
@@ -0,0 +1,72 @@
+// ==========================================================================
+// JsonSchemaExtensions.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Linq;
+using NJsonSchema;
+using Squidex.Domain.Apps.Core.Schemas;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.GenerateJsonSchema
+{
+ public static class JsonSchemaExtensions
+ {
+ public static JsonSchema4 BuildJsonSchema(this Schema schema, PartitionResolver partitionResolver, Func schemaResolver)
+ {
+ Guard.NotNull(schemaResolver, nameof(schemaResolver));
+ Guard.NotNull(partitionResolver, nameof(partitionResolver));
+
+ var schemaName = schema.Name.ToPascalCase();
+
+ var jsonTypeVisitor = new JsonTypeVisitor(schemaResolver);
+ var jsonSchema = new JsonSchema4 { Type = JsonObjectType.Object };
+
+ foreach (var field in schema.Fields.Where(x => !x.IsHidden))
+ {
+ var partitionProperty = CreateProperty(field);
+ var partitionObject = new JsonSchema4 { Type = JsonObjectType.Object, AllowAdditionalProperties = false };
+ var partition = partitionResolver(field.Partitioning);
+
+ foreach (var partitionItem in partition)
+ {
+ var partitionItemProperty = field.Accept(jsonTypeVisitor);
+
+ partitionItemProperty.Description = partitionItem.Name;
+ partitionObject.Properties.Add(partitionItem.Key, partitionItemProperty);
+ }
+
+ partitionProperty.Reference = schemaResolver($"{schemaName}{field.Name.ToPascalCase()}Property", partitionObject);
+
+ jsonSchema.Properties.Add(field.Name, partitionProperty);
+ }
+
+ return jsonSchema;
+ }
+
+ public static JsonProperty CreateProperty(Field field)
+ {
+ var jsonProperty = new JsonProperty { IsRequired = field.RawProperties.IsRequired, Type = JsonObjectType.Object };
+
+ if (!string.IsNullOrWhiteSpace(field.RawProperties.Hints))
+ {
+ jsonProperty.Description = field.RawProperties.Hints;
+ }
+ else
+ {
+ jsonProperty.Description = field.Name;
+ }
+
+ if (!string.IsNullOrWhiteSpace(field.RawProperties.Hints))
+ {
+ jsonProperty.Description += $" ({field.RawProperties.Hints}).";
+ }
+
+ return jsonProperty;
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs b/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs
new file mode 100644
index 000000000..a2c3b6f7d
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs
@@ -0,0 +1,163 @@
+// ==========================================================================
+// JsonTypeVisitor.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.ObjectModel;
+using NJsonSchema;
+using Squidex.Domain.Apps.Core.Schemas;
+
+namespace Squidex.Domain.Apps.Core.GenerateJsonSchema
+{
+ public sealed class JsonTypeVisitor : IFieldVisitor
+ {
+ private readonly Func schemaResolver;
+
+ public JsonTypeVisitor(Func schemaResolver)
+ {
+ this.schemaResolver = schemaResolver;
+ }
+
+ public JsonProperty Visit(AssetsField field)
+ {
+ return CreateProperty(field, jsonProperty =>
+ {
+ var itemSchema = schemaResolver("AssetItem", new JsonSchema4 { Type = JsonObjectType.String });
+
+ jsonProperty.Type = JsonObjectType.Array;
+ jsonProperty.Item = itemSchema;
+ });
+ }
+
+ public JsonProperty Visit(BooleanField field)
+ {
+ return CreateProperty(field, jsonProperty =>
+ {
+ jsonProperty.Type = JsonObjectType.Boolean;
+ });
+ }
+
+ public JsonProperty Visit(DateTimeField field)
+ {
+ return CreateProperty(field, jsonProperty =>
+ {
+ jsonProperty.Type = JsonObjectType.String;
+ jsonProperty.Format = JsonFormatStrings.DateTime;
+ });
+ }
+
+ public JsonProperty Visit(GeolocationField field)
+ {
+ return CreateProperty(field, jsonProperty =>
+ {
+ var geolocationSchema = new JsonSchema4
+ {
+ AllowAdditionalProperties = false
+ };
+
+ geolocationSchema.Properties.Add("latitude", new JsonProperty
+ {
+ Type = JsonObjectType.Number,
+ Minimum = -90,
+ Maximum = 90,
+ IsRequired = true
+ });
+
+ geolocationSchema.Properties.Add("longitude", new JsonProperty
+ {
+ Type = JsonObjectType.Number,
+ Minimum = -180,
+ Maximum = 180,
+ IsRequired = true
+ });
+
+ var schemaReference = schemaResolver("GeolocationDto", geolocationSchema);
+
+ jsonProperty.Type = JsonObjectType.Object;
+ jsonProperty.Reference = schemaReference;
+ });
+ }
+
+ public JsonProperty Visit(JsonField field)
+ {
+ return CreateProperty(field, jsonProperty =>
+ {
+ jsonProperty.Type = JsonObjectType.Object;
+ });
+ }
+
+ public JsonProperty Visit(NumberField field)
+ {
+ return CreateProperty(field, jsonProperty =>
+ {
+ jsonProperty.Type = JsonObjectType.Number;
+
+ if (field.Properties.MinValue.HasValue)
+ {
+ jsonProperty.Minimum = (decimal)field.Properties.MinValue.Value;
+ }
+
+ if (field.Properties.MaxValue.HasValue)
+ {
+ jsonProperty.Maximum = (decimal)field.Properties.MaxValue.Value;
+ }
+ });
+ }
+
+ public JsonProperty Visit(ReferencesField field)
+ {
+ return CreateProperty(field, jsonProperty =>
+ {
+ var itemSchema = schemaResolver("ReferenceItem", new JsonSchema4 { Type = JsonObjectType.String });
+
+ jsonProperty.Type = JsonObjectType.Array;
+ jsonProperty.Item = itemSchema;
+ });
+ }
+
+ public JsonProperty Visit(StringField field)
+ {
+ return CreateProperty(field, jsonProperty =>
+ {
+ jsonProperty.Type = JsonObjectType.String;
+
+ jsonProperty.MinLength = field.Properties.MinLength;
+ jsonProperty.MaxLength = field.Properties.MaxLength;
+
+ if (field.Properties.AllowedValues != null)
+ {
+ var names = jsonProperty.EnumerationNames = jsonProperty.EnumerationNames ?? new Collection();
+
+ foreach (var value in field.Properties.AllowedValues)
+ {
+ names.Add(value);
+ }
+ }
+ });
+ }
+
+ public JsonProperty Visit(TagsField field)
+ {
+ return CreateProperty(field, jsonProperty =>
+ {
+ var itemSchema = schemaResolver("TagsItem", new JsonSchema4 { Type = JsonObjectType.String });
+
+ jsonProperty.Type = JsonObjectType.Array;
+ jsonProperty.Item = itemSchema;
+ });
+ }
+
+ private static JsonProperty CreateProperty(Field field, Action updater)
+ {
+ var property = new JsonProperty { IsRequired = field.RawProperties.IsRequired };
+
+ updater(property);
+
+ return property;
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs
new file mode 100644
index 000000000..8ceadc1ba
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs
@@ -0,0 +1,132 @@
+// ==========================================================================
+// ContentDataObject.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System.Collections.Generic;
+using Jint;
+using Jint.Native;
+using Jint.Native.Object;
+using Jint.Runtime.Descriptors;
+using Squidex.Domain.Apps.Core.Contents;
+using Squidex.Infrastructure;
+
+#pragma warning disable RECS0133 // Parameter name differs in base declaration
+
+namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper
+{
+ public sealed class ContentDataObject : ObjectInstance
+ {
+ private readonly NamedContentData contentData;
+ private HashSet fieldsToDelete;
+ private Dictionary fieldProperties;
+ private bool isChanged;
+
+ public ContentDataObject(Engine engine, NamedContentData contentData)
+ : base(engine)
+ {
+ Extensible = true;
+
+ this.contentData = contentData;
+ }
+
+ public void MarkChanged()
+ {
+ isChanged = true;
+ }
+
+ public bool TryUpdate(out NamedContentData result)
+ {
+ result = contentData;
+
+ if (isChanged)
+ {
+ if (fieldsToDelete != null)
+ {
+ foreach (var field in fieldsToDelete)
+ {
+ contentData.Remove(field);
+ }
+ }
+
+ if (fieldProperties != null)
+ {
+ foreach (var kvp in fieldProperties)
+ {
+ if (kvp.Value.ContentField.TryUpdate(out var fieldData))
+ {
+ contentData[kvp.Key] = fieldData;
+ }
+ }
+ }
+ }
+
+ return isChanged;
+ }
+
+ public override void RemoveOwnProperty(string propertyName)
+ {
+ if (fieldsToDelete == null)
+ {
+ fieldsToDelete = new HashSet();
+ }
+
+ fieldsToDelete.Add(propertyName);
+ fieldProperties?.Remove(propertyName);
+
+ MarkChanged();
+ }
+
+ public override bool DefineOwnProperty(string propertyName, PropertyDescriptor desc, bool throwOnError)
+ {
+ EnsurePropertiesInitialized();
+
+ if (!fieldProperties.ContainsKey(propertyName))
+ {
+ fieldProperties[propertyName] = new ContentDataProperty(this) { Value = desc.Value };
+ }
+
+ return true;
+ }
+
+ public override void Put(string propertyName, JsValue value, bool throwOnError)
+ {
+ EnsurePropertiesInitialized();
+
+ fieldProperties.GetOrAdd(propertyName, x => new ContentDataProperty(this)).Value = value;
+ }
+
+ public override PropertyDescriptor GetOwnProperty(string propertyName)
+ {
+ EnsurePropertiesInitialized();
+
+ return fieldProperties.GetOrDefault(propertyName) ?? new PropertyDescriptor(new ObjectInstance(Engine) { Extensible = true }, true, false, true);
+ }
+
+ public override IEnumerable> GetOwnProperties()
+ {
+ EnsurePropertiesInitialized();
+
+ foreach (var property in fieldProperties)
+ {
+ yield return new KeyValuePair(property.Key, property.Value);
+ }
+ }
+
+ private void EnsurePropertiesInitialized()
+ {
+ if (fieldProperties == null)
+ {
+ fieldProperties = new Dictionary(contentData.Count);
+
+ foreach (var kvp in contentData)
+ {
+ fieldProperties.Add(kvp.Key, new ContentDataProperty(this, new ContentFieldObject(this, kvp.Value, false)));
+ }
+ }
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataProperty.cs b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataProperty.cs
new file mode 100644
index 000000000..6542d772b
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataProperty.cs
@@ -0,0 +1,68 @@
+// ==========================================================================
+// ContentFieldProperty.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Jint.Native;
+using Jint.Runtime;
+using Jint.Runtime.Descriptors;
+using Squidex.Domain.Apps.Core.Contents;
+
+namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper
+{
+ public sealed class ContentDataProperty : PropertyDescriptor
+ {
+ private readonly ContentDataObject contentData;
+ private ContentFieldObject contentField;
+ private JsValue value;
+
+ public override JsValue Value
+ {
+ get
+ {
+ return value;
+ }
+ set
+ {
+ if (!Equals(this.value, value))
+ {
+ if (value == null || !value.IsObject())
+ {
+ throw new JavaScriptException("Can only assign object to content data.");
+ }
+
+ var obj = value.AsObject();
+
+ contentField = new ContentFieldObject(contentData, new ContentFieldData(), true);
+
+ foreach (var kvp in obj.GetOwnProperties())
+ {
+ contentField.Put(kvp.Key, kvp.Value.Value, true);
+ }
+
+ this.value = new JsValue(contentField);
+ }
+ }
+ }
+
+ public ContentFieldObject ContentField
+ {
+ get { return contentField; }
+ }
+
+ public ContentDataProperty(ContentDataObject contentData, ContentFieldObject contentField = null)
+ : base(null, true, true, true)
+ {
+ this.contentData = contentData;
+ this.contentField = contentField;
+
+ if (contentField != null)
+ {
+ value = new JsValue(contentField);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldObject.cs b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldObject.cs
new file mode 100644
index 000000000..e4eed7641
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldObject.cs
@@ -0,0 +1,137 @@
+// ==========================================================================
+// ContentFieldObject.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System.Collections.Generic;
+using Jint.Native.Object;
+using Jint.Runtime.Descriptors;
+using Squidex.Domain.Apps.Core.Contents;
+using Squidex.Infrastructure;
+
+#pragma warning disable RECS0133 // Parameter name differs in base declaration
+
+namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper
+{
+ public sealed class ContentFieldObject : ObjectInstance
+ {
+ private readonly ContentDataObject contentData;
+ private readonly ContentFieldData fieldData;
+ private HashSet valuesToDelete;
+ private Dictionary valueProperties;
+ private bool isChanged;
+
+ public ContentFieldData FieldData
+ {
+ get { return fieldData; }
+ }
+
+ public ContentFieldObject(ContentDataObject contentData, ContentFieldData fieldData, bool isNew)
+ : base(contentData.Engine)
+ {
+ Extensible = true;
+
+ this.contentData = contentData;
+ this.fieldData = fieldData;
+
+ if (isNew)
+ {
+ MarkChanged();
+ }
+ }
+
+ public void MarkChanged()
+ {
+ isChanged = true;
+
+ contentData.MarkChanged();
+ }
+
+ public bool TryUpdate(out ContentFieldData result)
+ {
+ result = fieldData;
+
+ if (isChanged)
+ {
+ if (valuesToDelete != null)
+ {
+ foreach (var field in valuesToDelete)
+ {
+ fieldData.Remove(field);
+ }
+ }
+
+ if (valueProperties != null)
+ {
+ foreach (var kvp in valueProperties)
+ {
+ if (kvp.Value.IsChanged)
+ {
+ fieldData[kvp.Key] = kvp.Value.ContentValue;
+ }
+ }
+ }
+ }
+
+ return isChanged;
+ }
+
+ public override void RemoveOwnProperty(string propertyName)
+ {
+ if (valuesToDelete == null)
+ {
+ valuesToDelete = new HashSet();
+ }
+
+ valuesToDelete.Add(propertyName);
+ valueProperties?.Remove(propertyName);
+
+ MarkChanged();
+ }
+
+ public override bool DefineOwnProperty(string propertyName, PropertyDescriptor desc, bool throwOnError)
+ {
+ EnsurePropertiesInitialized();
+
+ if (!valueProperties.ContainsKey(propertyName))
+ {
+ valueProperties[propertyName] = new ContentFieldProperty(this) { Value = desc.Value };
+ }
+
+ return true;
+ }
+
+ public override PropertyDescriptor GetOwnProperty(string propertyName)
+ {
+ EnsurePropertiesInitialized();
+
+ return valueProperties?.GetOrDefault(propertyName) ?? PropertyDescriptor.Undefined;
+ }
+
+ public override IEnumerable> GetOwnProperties()
+ {
+ EnsurePropertiesInitialized();
+
+ foreach (var property in valueProperties)
+ {
+ yield return new KeyValuePair(property.Key, property.Value);
+ }
+ }
+
+ private void EnsurePropertiesInitialized()
+ {
+ if (valueProperties == null)
+ {
+ valueProperties = new Dictionary(FieldData.Count);
+
+ foreach (var kvp in FieldData)
+ {
+ valueProperties.Add(kvp.Key, new ContentFieldProperty(this, kvp.Value));
+ }
+ }
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldProperty.cs b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldProperty.cs
new file mode 100644
index 000000000..01e50da58
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldProperty.cs
@@ -0,0 +1,59 @@
+// ==========================================================================
+// ContentFieldProperty.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Jint.Native;
+using Jint.Runtime.Descriptors;
+using Newtonsoft.Json.Linq;
+
+namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper
+{
+ public sealed class ContentFieldProperty : PropertyDescriptor
+ {
+ private readonly ContentFieldObject contentField;
+ private JToken contentValue;
+ private JsValue value;
+ private bool isChanged;
+
+ public override JsValue Value
+ {
+ get
+ {
+ return value ?? (value = JsonMapper.Map(contentValue, contentField.Engine));
+ }
+ set
+ {
+ if (!Equals(this.value, value))
+ {
+ this.value = value;
+
+ contentValue = null;
+ contentField.MarkChanged();
+
+ isChanged = true;
+ }
+ }
+ }
+
+ public JToken ContentValue
+ {
+ get { return contentValue ?? (contentValue = JsonMapper.Map(value)); }
+ }
+
+ public bool IsChanged
+ {
+ get { return isChanged; }
+ }
+
+ public ContentFieldProperty(ContentFieldObject contentField, JToken contentValue = null)
+ : base(null, true, true, true)
+ {
+ this.contentField = contentField;
+ this.contentValue = contentValue;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/JsonMapper.cs b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/JsonMapper.cs
new file mode 100644
index 000000000..6704c5440
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/JsonMapper.cs
@@ -0,0 +1,146 @@
+// ==========================================================================
+// JsonMapper.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using Jint;
+using Jint.Native;
+using Jint.Native.Object;
+using Newtonsoft.Json.Linq;
+
+namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper
+{
+ public static class JsonMapper
+ {
+ public static JsValue Map(JToken value, Engine engine)
+ {
+ if (value == null)
+ {
+ return JsValue.Null;
+ }
+
+ switch (value.Type)
+ {
+ case JTokenType.Date:
+ case JTokenType.Guid:
+ case JTokenType.String:
+ case JTokenType.Uri:
+ case JTokenType.TimeSpan:
+ return new JsValue((string)value);
+ case JTokenType.Null:
+ return JsValue.Null;
+ case JTokenType.Undefined:
+ return JsValue.Undefined;
+ case JTokenType.Integer:
+ return new JsValue((long)value);
+ case JTokenType.Float:
+ return new JsValue((double)value);
+ case JTokenType.Boolean:
+ return new JsValue((bool)value);
+ case JTokenType.Object:
+ return FromObject(value, engine);
+ case JTokenType.Array:
+ {
+ var arr = (JArray)value;
+
+ var target = new JsValue[arr.Count];
+
+ for (var i = 0; i < arr.Count; i++)
+ {
+ target[i] = Map(arr[i], engine);
+ }
+
+ return engine.Array.Construct(target);
+ }
+ }
+
+ throw new ArgumentException("Invalid json type.", nameof(value));
+ }
+
+ private static JsValue FromObject(JToken value, Engine engine)
+ {
+ var obj = (JObject)value;
+
+ var target = new ObjectInstance(engine);
+
+ foreach (var property in obj)
+ {
+ target.FastAddProperty(property.Key, Map(property.Value, engine), false, true, true);
+ }
+
+ return target;
+ }
+
+ public static JToken Map(JsValue value)
+ {
+ if (value == null || value.IsNull())
+ {
+ return JValue.CreateNull();
+ }
+
+ if (value.IsUndefined())
+ {
+ return JValue.CreateUndefined();
+ }
+
+ if (value.IsString())
+ {
+ return new JValue(value.AsString());
+ }
+
+ if (value.IsBoolean())
+ {
+ return new JValue(value.AsBoolean());
+ }
+
+ if (value.IsNumber())
+ {
+ return new JValue(value.AsNumber());
+ }
+
+ if (value.IsDate())
+ {
+ return new JValue(value.AsDate().ToDateTime());
+ }
+
+ if (value.IsRegExp())
+ {
+ return JValue.CreateString(value.AsRegExp().Value?.ToString());
+ }
+
+ if (value.IsArray())
+ {
+ var arr = value.AsArray();
+
+ var target = new JArray();
+
+ for (var i = 0; i < arr.GetLength(); i++)
+ {
+ target.Add(Map(arr.Get(i.ToString())));
+ }
+
+ return target;
+ }
+
+ if (value.IsObject())
+ {
+ var obj = value.AsObject();
+
+ var target = new JObject();
+
+ foreach (var kvp in obj.GetOwnProperties())
+ {
+ target[kvp.Key] = Map(kvp.Value.Value);
+ }
+
+ return target;
+ }
+
+ throw new ArgumentException("Invalid json type.", nameof(value));
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptEngine.cs b/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptEngine.cs
new file mode 100644
index 000000000..bad0351f6
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptEngine.cs
@@ -0,0 +1,21 @@
+// ==========================================================================
+// IScriptEngine.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Squidex.Domain.Apps.Core.Contents;
+
+namespace Squidex.Domain.Apps.Core.Scripting
+{
+ public interface IScriptEngine
+ {
+ void Execute(ScriptContext context, string script);
+
+ NamedContentData ExecuteAndTransform(ScriptContext context, string script);
+
+ NamedContentData Transform(ScriptContext context, string script);
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs b/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs
new file mode 100644
index 000000000..6ab62dc73
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs
@@ -0,0 +1,178 @@
+// ==========================================================================
+// JintScriptEngine.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using Jint;
+using Jint.Native.Object;
+using Jint.Parser;
+using Jint.Runtime;
+using Squidex.Domain.Apps.Core.Contents;
+using Squidex.Domain.Apps.Core.Scripting.ContentWrapper;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.Scripting
+{
+ public sealed class JintScriptEngine : IScriptEngine
+ {
+ public TimeSpan Timeout { get; set; } = TimeSpan.FromMilliseconds(200);
+
+ public void Execute(ScriptContext context, string script)
+ {
+ Guard.NotNull(context, nameof(context));
+
+ if (!string.IsNullOrWhiteSpace(script))
+ {
+ var engine = CreateScriptEngine(context);
+
+ EnableDisallow(engine);
+ EnableReject(engine);
+
+ Execute(engine, script);
+ }
+ }
+
+ public NamedContentData ExecuteAndTransform(ScriptContext context, string script)
+ {
+ Guard.NotNull(context, nameof(context));
+
+ var result = context.Data;
+
+ if (!string.IsNullOrWhiteSpace(script))
+ {
+ var engine = CreateScriptEngine(context);
+
+ EnableDisallow(engine);
+ EnableReject(engine);
+
+ engine.SetValue("operation", new Action(() =>
+ {
+ var dataInstance = engine.GetValue("ctx").AsObject().Get("data");
+
+ if (dataInstance != null && dataInstance.IsObject() && dataInstance.AsObject() is ContentDataObject data)
+ {
+ data.TryUpdate(out result);
+ }
+ }));
+
+ engine.SetValue("replace", new Action(() =>
+ {
+ var dataInstance = engine.GetValue("ctx").AsObject().Get("data");
+
+ if (dataInstance != null && dataInstance.IsObject() && dataInstance.AsObject() is ContentDataObject data)
+ {
+ data.TryUpdate(out result);
+ }
+ }));
+
+ Execute(engine, script);
+ }
+
+ return result;
+ }
+
+ public NamedContentData Transform(ScriptContext context, string script)
+ {
+ Guard.NotNull(context, nameof(context));
+
+ var result = context.Data;
+
+ if (!string.IsNullOrWhiteSpace(script))
+ {
+ try
+ {
+ var engine = CreateScriptEngine(context);
+
+ engine.SetValue("replace", new Action(() =>
+ {
+ var dataInstance = engine.GetValue("ctx").AsObject().Get("data");
+
+ if (dataInstance != null && dataInstance.IsObject() && dataInstance.AsObject() is ContentDataObject data)
+ {
+ data.TryUpdate(out result);
+ }
+ }));
+
+ engine.Execute(script);
+ }
+ catch (Exception)
+ {
+ result = context.Data;
+ }
+ }
+
+ return result;
+ }
+
+ private static void Execute(Engine engine, string script)
+ {
+ try
+ {
+ engine.Execute(script);
+ }
+ catch (ParserException ex)
+ {
+ throw new ValidationException($"Failed to execute script with javascript syntaxs error.", new ValidationError(ex.Message));
+ }
+ catch (JavaScriptException ex)
+ {
+ throw new ValidationException($"Failed to execute script with javascript error.", new ValidationError(ex.Message));
+ }
+ }
+
+ private Engine CreateScriptEngine(ScriptContext context)
+ {
+ var engine = new Engine(options => options.TimeoutInterval(Timeout).Strict());
+
+ var contextInstance = new ObjectInstance(engine);
+
+ if (context.Data != null)
+ {
+ contextInstance.FastAddProperty("data", new ContentDataObject(engine, context.Data), true, true, true);
+ }
+
+ if (context.OldData != null)
+ {
+ contextInstance.FastAddProperty("oldData", new ContentDataObject(engine, context.OldData), true, true, true);
+ }
+
+ if (context.User != null)
+ {
+ contextInstance.FastAddProperty("user", new JintUser(engine, context.User), false, true, false);
+ }
+
+ if (!string.IsNullOrWhiteSpace(context.Operation))
+ {
+ contextInstance.FastAddProperty("operation", context.Operation, false, true, false);
+ }
+
+ engine.SetValue("ctx", contextInstance);
+
+ return engine;
+ }
+
+ private static void EnableDisallow(Engine engine)
+ {
+ engine.SetValue("disallow", new Action(message =>
+ {
+ var exMessage = !string.IsNullOrWhiteSpace(message) ? message : "Not allowed";
+
+ throw new DomainForbiddenException(exMessage);
+ }));
+ }
+
+ private static void EnableReject(Engine engine)
+ {
+ engine.SetValue("reject", new Action(message =>
+ {
+ var errors = !string.IsNullOrWhiteSpace(message) ? new[] { new ValidationError(message) } : null;
+
+ throw new ValidationException($"Script rejected the operation.", errors);
+ }));
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintUser.cs b/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintUser.cs
new file mode 100644
index 000000000..8974099d1
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintUser.cs
@@ -0,0 +1,50 @@
+// ==========================================================================
+// JintUser.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System.Linq;
+using System.Security.Claims;
+using Jint;
+using Jint.Native;
+using Jint.Native.Object;
+using Squidex.Infrastructure.Security;
+
+namespace Squidex.Domain.Apps.Core.Scripting
+{
+ public sealed class JintUser : ObjectInstance
+ {
+ public JintUser(Engine engine, ClaimsPrincipal principal)
+ : base(engine)
+ {
+ var subjectId = principal.OpenIdSubject();
+
+ var isClient = string.IsNullOrWhiteSpace(subjectId);
+
+ if (!isClient)
+ {
+ FastAddProperty("id", subjectId, false, true, false);
+ FastAddProperty("isClient", false, false, true, false);
+ }
+ else
+ {
+ FastAddProperty("id", principal.OpenIdClientId(), false, true, false);
+ FastAddProperty("isClient", true, false, true, false);
+ }
+
+ FastAddProperty("email", principal.OpenIdEmail(), false, true, false);
+
+ var claimsInstance = new ObjectInstance(engine);
+
+ foreach (var group in principal.Claims.GroupBy(x => x.Type))
+ {
+ claimsInstance.FastAddProperty(group.Key, engine.Array.Construct(group.Select(x => new JsValue(x.Value)).ToArray()), false, true, false);
+ }
+
+ FastAddProperty("claims", claimsInstance, false, true, false);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptContext.cs b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptContext.cs
new file mode 100644
index 000000000..fcc5a0733
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptContext.cs
@@ -0,0 +1,27 @@
+// ==========================================================================
+// ScriptContext.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Security.Claims;
+using Squidex.Domain.Apps.Core.Contents;
+
+namespace Squidex.Domain.Apps.Core.Scripting
+{
+ public sealed class ScriptContext
+ {
+ public ClaimsPrincipal User { get; set; }
+
+ public Guid ContentId { get; set; }
+
+ public NamedContentData Data { get; set; }
+
+ public NamedContentData OldData { get; set; }
+
+ public string Operation { get; set; }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj b/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
new file mode 100644
index 000000000..ec9a21f4c
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
@@ -0,0 +1,28 @@
+
+
+ netstandard2.0
+ Squidex.Domain.Apps.Core
+
+
+ full
+ True
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ..\..\Squidex.ruleset
+
+
diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidationExtensions.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidationExtensions.cs
new file mode 100644
index 000000000..e08089efc
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidationExtensions.cs
@@ -0,0 +1,43 @@
+// ==========================================================================
+// ContentExtensions.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Squidex.Domain.Apps.Core.Contents;
+using Squidex.Domain.Apps.Core.Schemas;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.ValidateContent
+{
+ public static class ContentValidationExtensions
+ {
+ public static async Task ValidateAsync(this NamedContentData data, ValidationContext context, Schema schema, PartitionResolver partitionResolver, IList errors)
+ {
+ var validator = new ContentValidator(schema, partitionResolver, context);
+
+ await validator.ValidateAsync(data);
+
+ foreach (var error in validator.Errors)
+ {
+ errors.Add(error);
+ }
+ }
+
+ public static async Task ValidatePartialAsync(this NamedContentData data, ValidationContext context, Schema schema, PartitionResolver partitionResolver, IList errors)
+ {
+ var validator = new ContentValidator(schema, partitionResolver, context);
+
+ await validator.ValidatePartialAsync(data);
+
+ foreach (var error in validator.Errors)
+ {
+ errors.Add(error);
+ }
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs
new file mode 100644
index 000000000..d9470234f
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs
@@ -0,0 +1,142 @@
+// ==========================================================================
+// ContentValidator.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Newtonsoft.Json.Linq;
+using Squidex.Domain.Apps.Core.Contents;
+using Squidex.Domain.Apps.Core.Schemas;
+using Squidex.Infrastructure;
+
+#pragma warning disable 168
+
+namespace Squidex.Domain.Apps.Core.ValidateContent
+{
+ public sealed class ContentValidator
+ {
+ private readonly Schema schema;
+ private readonly PartitionResolver partitionResolver;
+ private readonly ValidationContext context;
+ private readonly ConcurrentBag errors = new ConcurrentBag();
+
+ public IReadOnlyCollection Errors
+ {
+ get { return errors; }
+ }
+
+ public ContentValidator(Schema schema, PartitionResolver partitionResolver, ValidationContext context)
+ {
+ Guard.NotNull(schema, nameof(schema));
+ Guard.NotNull(partitionResolver, nameof(partitionResolver));
+
+ this.schema = schema;
+ this.context = context;
+ this.partitionResolver = partitionResolver;
+ }
+
+ public Task ValidatePartialAsync(NamedContentData data)
+ {
+ Guard.NotNull(data, nameof(data));
+
+ var tasks = new List();
+
+ foreach (var fieldData in data)
+ {
+ var fieldName = fieldData.Key;
+
+ if (!schema.FieldsByName.TryGetValue(fieldData.Key, out var field))
+ {
+ errors.AddError(" is not a known field.", fieldName);
+ }
+ else
+ {
+ tasks.Add(ValidateFieldPartialAsync(field, fieldData.Value));
+ }
+ }
+
+ return Task.WhenAll(tasks);
+ }
+
+ private Task ValidateFieldPartialAsync(Field field, ContentFieldData fieldData)
+ {
+ var partitioning = field.Partitioning;
+ var partition = partitionResolver(partitioning);
+
+ var tasks = new List();
+
+ foreach (var partitionValues in fieldData)
+ {
+ if (partition.TryGetItem(partitionValues.Key, out var item))
+ {
+ tasks.Add(field.ValidateAsync(partitionValues.Value, context.Optional(item.IsOptional), m => errors.AddError(m, field, item)));
+ }
+ else
+ {
+ errors.AddError($" has an unsupported {partitioning.Key} value '{partitionValues.Key}'.", field);
+ }
+ }
+
+ return Task.WhenAll(tasks);
+ }
+
+ public Task ValidateAsync(NamedContentData data)
+ {
+ Guard.NotNull(data, nameof(data));
+
+ ValidateUnknownFields(data);
+
+ var tasks = new List();
+
+ foreach (var field in schema.FieldsByName.Values)
+ {
+ var fieldData = data.GetOrCreate(field.Name, k => new ContentFieldData());
+
+ tasks.Add(ValidateFieldAsync(field, fieldData));
+ }
+
+ return Task.WhenAll(tasks);
+ }
+
+ private void ValidateUnknownFields(NamedContentData data)
+ {
+ foreach (var fieldData in data)
+ {
+ if (!schema.FieldsByName.ContainsKey(fieldData.Key))
+ {
+ errors.AddError(" is not a known field.", fieldData.Key);
+ }
+ }
+ }
+
+ private Task ValidateFieldAsync(Field field, ContentFieldData fieldData)
+ {
+ var partitioning = field.Partitioning;
+ var partition = partitionResolver(partitioning);
+
+ var tasks = new List();
+
+ foreach (var partitionValues in fieldData)
+ {
+ if (!partition.TryGetItem(partitionValues.Key, out var _))
+ {
+ errors.AddError($" has an unsupported {partitioning.Key} value '{partitionValues.Key}'.", field);
+ }
+ }
+
+ foreach (var item in partition)
+ {
+ var value = fieldData.GetOrCreate(item.Key, k => JValue.CreateNull());
+
+ tasks.Add(field.ValidateAsync(value, context.Optional(item.IsOptional), m => errors.AddError(m, field, item)));
+ }
+
+ return Task.WhenAll(tasks);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/FieldExtensions.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/FieldExtensions.cs
new file mode 100644
index 000000000..b56bf1908
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/FieldExtensions.cs
@@ -0,0 +1,58 @@
+// ==========================================================================
+// FieldExtensions.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Concurrent;
+using System.Threading.Tasks;
+using Newtonsoft.Json.Linq;
+using Squidex.Domain.Apps.Core.Schemas;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.Json;
+
+namespace Squidex.Domain.Apps.Core.ValidateContent
+{
+ public static class FieldExtensions
+ {
+ public static void AddError(this ConcurrentBag errors, string message, Field field, IFieldPartitionItem partitionItem = null)
+ {
+ AddError(errors, message, !string.IsNullOrWhiteSpace(field.RawProperties.Label) ? field.RawProperties.Label : field.Name, field.Name, partitionItem);
+ }
+
+ public static void AddError(this ConcurrentBag errors, string message, string fieldName, IFieldPartitionItem partitionItem = null)
+ {
+ AddError(errors, message, fieldName, fieldName, partitionItem);
+ }
+
+ public static void AddError(this ConcurrentBag errors, string message, string displayName, string fieldName, IFieldPartitionItem partitionItem = null)
+ {
+ if (partitionItem != null && partitionItem != InvariantPartitioning.Instance.Master)
+ {
+ displayName += $" ({partitionItem.Key})";
+ }
+
+ errors.Add(new ValidationError(message.Replace("", displayName), fieldName));
+ }
+
+ public static async Task ValidateAsync(this Field field, JToken value, ValidationContext context, Action addError)
+ {
+ try
+ {
+ var typedValue = value.IsNull() ? null : JsonValueConverter.ConvertValue(field, value);
+
+ foreach (var validator in ValidatorsFactory.CreateValidators(field))
+ {
+ await validator.ValidateAsync(typedValue, context, addError);
+ }
+ }
+ catch
+ {
+ addError(" is not a valid value.");
+ }
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs
new file mode 100644
index 000000000..00f4f3e9d
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs
@@ -0,0 +1,113 @@
+// ==========================================================================
+// JsonValueConverter.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+using Newtonsoft.Json.Linq;
+using NodaTime.Text;
+using Squidex.Domain.Apps.Core.Schemas;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Core.ValidateContent
+{
+ public sealed class JsonValueConverter : IFieldVisitor