From 12b8e8ec4663896cf1e6d0d91756f2d762ddc08a Mon Sep 17 00:00:00 2001 From: Kevin Jump Date: Wed, 4 Mar 2026 12:04:47 +0000 Subject: [PATCH 1/6] Add support for direct migration of nested content to blocklist. (as part of a sync). --- .../NestedContentMigratingConfig.cs | 61 ++++++++++++++ .../Mappers/NestedContentToBlockListHelper.cs | 83 +++++++++++++++++++ uSync.Core/Mapping/SyncBlockMapperBase.cs | 9 +- .../Serializers/DataTypeSerializer.cs | 8 +- 4 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 uSync.Core/DataTypes/DataTypeSerializers/NestedContentMigratingConfig.cs create mode 100644 uSync.Core/Mapping/Mappers/NestedContentToBlockListHelper.cs diff --git a/uSync.Core/DataTypes/DataTypeSerializers/NestedContentMigratingConfig.cs b/uSync.Core/DataTypes/DataTypeSerializers/NestedContentMigratingConfig.cs new file mode 100644 index 000000000..eec1de0c3 --- /dev/null +++ b/uSync.Core/DataTypes/DataTypeSerializers/NestedContentMigratingConfig.cs @@ -0,0 +1,61 @@ +using System.Text.Json.Nodes; + +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Services; + +using uSync.Core.Extensions; + +namespace uSync.Core.DataTypes.DataTypeSerializers; + + +/// +/// migrates nested content to a block list element. +/// + +internal class NestedContentMigratingConfig : ConfigurationSerializerBase, IConfigurationSerializer +{ + private readonly IContentTypeService _contentTypeService; + + public NestedContentMigratingConfig(IContentTypeService contentTypeService) + { + this._contentTypeService = contentTypeService; + } + + public string Name => nameof(NestedContentMigratingConfig); + public string[] Editors => [SyncLegacyTypes.NestedContent, SyncLegacyTypes.OurNestedContent]; + + public string? GetEditorAlias() => Constants.PropertyEditors.Aliases.BlockList; + public string? GetEditorUIAlias() => "Umb.PropertyEditorUi.BlockList"; + + public override IDictionary GetConfigurationImport(IDictionary configuration) + { + var config = new BlockListConfiguration(); + + if (configuration.TryGetValue("minItems", out var min) && int.TryParse(min?.ToString(), out var minItems)) + config.ValidationLimit.Min = minItems; + + if (configuration.TryGetValue("maxItems", out var max) && int.TryParse(max?.ToString(), out var maxItems)) + config.ValidationLimit.Max = maxItems; + + if (configuration.TryGetValue("contentTypes", out var contentTypes) && contentTypes is JsonArray contentTypesArray) + { + var blocks = new List(); + foreach (var contentType in contentTypesArray.Cast()) + { + if (contentType["ncAlias"]?.ToString() is not string alias) continue; + var contentTypeItem = _contentTypeService.Get(alias); + if (contentTypeItem is null) continue; + blocks.Add(new BlockListConfiguration.BlockConfiguration + { + ContentElementTypeKey = contentTypeItem.Key, + }); + } + + config.Blocks = [.. blocks]; + } + + var result = config.SerializeJsonString().DeserializeJson>() ?? configuration; + return result; + } +} diff --git a/uSync.Core/Mapping/Mappers/NestedContentToBlockListHelper.cs b/uSync.Core/Mapping/Mappers/NestedContentToBlockListHelper.cs new file mode 100644 index 000000000..870e00133 --- /dev/null +++ b/uSync.Core/Mapping/Mappers/NestedContentToBlockListHelper.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Text; + +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Services; + +using uSync.Core.Extensions; + +namespace uSync.Core.Mapping.Mappers; + +/// +/// helper methods that can convert nested content to block list values. +/// +internal class NestedContentToBlockListHelper +{ + private readonly IContentTypeService _contentTypeService; + + public NestedContentToBlockListHelper(IContentTypeService contentTypeService) + { + _contentTypeService = contentTypeService; + } + + private static string[] _reservedProperties = ["ncContentTypeAlias", "key", "name"]; + + /// + /// converts a nested content value to a block list value. This is used when we are converting a nested content property to a block list property. + /// + /// the nested content value to convert + /// the converted block list value + public string ConvertNestedContentToBlockList(string nestedContentValue) + { + if (nestedContentValue.Contains("ncContentTypeAlias") is false) return nestedContentValue; + + var nestedContent = nestedContentValue.DeserializeJson>>(); + if (nestedContent == null) return nestedContentValue; + + BlockListValue blockListValue = new BlockListValue(); + + foreach (var item in nestedContent) + { + var contentTypeAlias = item.TryGetValue("ncContentTypeAlias", out var alias) ? alias?.ToString() : null; + if (contentTypeAlias == null) continue; + + var contentType = _contentTypeService.Get(contentTypeAlias); + if (contentType == null) continue; + + var blockItemData = new BlockItemData + { + ContentTypeKey = contentType.Key, + Key = item.TryGetValue("key", out var key) && Guid.TryParse(key?.ToString(), out var guidKey) ? guidKey : Guid.NewGuid(), + }; + + foreach (var value in item.Keys) + { + if (_reservedProperties.Contains(value)) continue; + blockItemData.Values.Add(new BlockPropertyValue + { + Alias = value, + Value = item[value] + }); + } + + blockListValue.ContentData.Add(blockItemData); + } + + blockListValue.Expose = [.. blockListValue.ContentData.Select(x => new BlockItemVariation(x.Key, null, null))]; + blockListValue.Layout = new Dictionary> + { + { + "Umbraco.BlockList", + blockListValue.ContentData.Select( + x => new BlockListLayoutItem + { + ContentKey = x.Key, + SettingsKey = null, + }) + } + }; + + return blockListValue.SerializeJsonString() ?? nestedContentValue; + } +} diff --git a/uSync.Core/Mapping/SyncBlockMapperBase.cs b/uSync.Core/Mapping/SyncBlockMapperBase.cs index 5a38ddabe..ae6cb3f91 100644 --- a/uSync.Core/Mapping/SyncBlockMapperBase.cs +++ b/uSync.Core/Mapping/SyncBlockMapperBase.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Text.Json.Nodes; + using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Blocks; @@ -10,6 +11,7 @@ using uSync.Core.Dependency; using uSync.Core.Extensions; +using uSync.Core.Mapping.Mappers; namespace uSync.Core.Mapping; @@ -70,7 +72,12 @@ public SyncBlockMapperBase( private async Task ProcessBlockValuesAsync(string value, Func> GetValueMethod) { var blockValue = SyncBlockMapperBase.GetBlockValue(value); - if (blockValue == null) return value; + if (blockValue == null) + { + if (value.Contains("ncContentTypeAlias") is false) return value; + var nestedContentHelper = new NestedContentToBlockListHelper(_contentTypeService); + return nestedContentHelper.ConvertNestedContentToBlockList(value); + } List blocks = [ ..blockValue.ContentData, diff --git a/uSync.Core/Serialization/Serializers/DataTypeSerializer.cs b/uSync.Core/Serialization/Serializers/DataTypeSerializer.cs index 163935385..c47d5e00f 100644 --- a/uSync.Core/Serialization/Serializers/DataTypeSerializer.cs +++ b/uSync.Core/Serialization/Serializers/DataTypeSerializer.cs @@ -145,7 +145,7 @@ protected override async Task> DeserializeCoreAsync(XElem // config if (ShouldDesterilizeConfig(name, editorAlias, options)) { - details.AddRange(DeserializeConfiguration(item, node)); + details.AddRange(DeserializeConfiguration(item, node, editorAlias)); } details.AddNotNull(await SetFolderFromElementAsync(item, info?.Element("Folder"))); @@ -174,7 +174,7 @@ private static ValueStorageType GetEditorValueStorageType(IDataEditor? editor) return null; } - private List DeserializeConfiguration(IDataType item, XElement node) + private List DeserializeConfiguration(IDataType item, XElement node, string editorAlias) { var config = node.Element("Config").ValueOrDefault(string.Empty); if (string.IsNullOrEmpty(config)) return []; @@ -191,10 +191,10 @@ private List DeserializeConfiguration(IDataType item, XElement node importData = importData.ConvertToCamelCase(); // multiple serializers can run per property. - var serializers = _configurationSerializers.GetSerializers(item.EditorAlias); + var serializers = _configurationSerializers.GetSerializers(editorAlias); foreach (var serializer in serializers) { - logger.LogDebug("Running Configuration Serializer : {name} for {type}", serializer.Name, item.EditorAlias); + logger.LogDebug("Running Configuration Serializer : {name} for {type}", serializer.Name, editorAlias); importData = serializer.GetConfigurationImport(importData); } From 887fbc0e4085de2e35801b15828221327dc8c260 Mon Sep 17 00:00:00 2001 From: Kevin Jump Date: Wed, 4 Mar 2026 12:05:00 +0000 Subject: [PATCH 2/6] media item - logging parent fail --- .../Serialization/Serializers/MediaSerializer.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/uSync.Core/Serialization/Serializers/MediaSerializer.cs b/uSync.Core/Serialization/Serializers/MediaSerializer.cs index d6f56dcd1..9b9fe8c1d 100644 --- a/uSync.Core/Serialization/Serializers/MediaSerializer.cs +++ b/uSync.Core/Serialization/Serializers/MediaSerializer.cs @@ -190,9 +190,17 @@ private static string GetFilePath(string? value) { return uSyncTaskHelper.FromResultOf(() => { - var parentId = parent != null ? parent.Id : -1; - var item = _mediaService.CreateMedia(alias, parentId, itemType); - return Attempt.Succeed((IMedia)item); + try + { + var parentId = parent != null ? parent.Id : -1; + var item = _mediaService.CreateMedia(alias, parentId, itemType); + return Attempt.Succeed((IMedia)item); + } + catch (Exception ex) + { + logger.LogError(ex, "Error creating media item with alias {alias} and parent {parentId}", alias, parent?.Id); + throw; + } }); } From 232337aeb1dd016002b4bff148069963b50f201c Mon Sep 17 00:00:00 2001 From: Kevin Jump Date: Wed, 4 Mar 2026 14:32:13 +0000 Subject: [PATCH 3/6] Update uSync.Core/Serialization/Serializers/MediaSerializer.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- uSync.Core/Serialization/Serializers/MediaSerializer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uSync.Core/Serialization/Serializers/MediaSerializer.cs b/uSync.Core/Serialization/Serializers/MediaSerializer.cs index 9b9fe8c1d..43353cef5 100644 --- a/uSync.Core/Serialization/Serializers/MediaSerializer.cs +++ b/uSync.Core/Serialization/Serializers/MediaSerializer.cs @@ -198,7 +198,7 @@ private static string GetFilePath(string? value) } catch (Exception ex) { - logger.LogError(ex, "Error creating media item with alias {alias} and parent {parentId}", alias, parent?.Id); + logger.LogError(ex, "Error creating media item with alias {alias} and parent {parentId}", alias, parentId); throw; } From 39d1f6ee7d7cf463bea24330c49052a4e4ab975a Mon Sep 17 00:00:00 2001 From: Kevin Jump Date: Wed, 4 Mar 2026 14:32:53 +0000 Subject: [PATCH 4/6] Update uSync.Core/Mapping/Mappers/NestedContentToBlockListHelper.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Mappers/NestedContentToBlockListHelper.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/uSync.Core/Mapping/Mappers/NestedContentToBlockListHelper.cs b/uSync.Core/Mapping/Mappers/NestedContentToBlockListHelper.cs index 870e00133..f2e719440 100644 --- a/uSync.Core/Mapping/Mappers/NestedContentToBlockListHelper.cs +++ b/uSync.Core/Mapping/Mappers/NestedContentToBlockListHelper.cs @@ -32,7 +32,17 @@ public string ConvertNestedContentToBlockList(string nestedContentValue) { if (nestedContentValue.Contains("ncContentTypeAlias") is false) return nestedContentValue; - var nestedContent = nestedContentValue.DeserializeJson>>(); + List>? nestedContent; + try + { + nestedContent = nestedContentValue.DeserializeJson>>(); + } + catch (Exception) + { + // If deserialization fails (e.g. malformed or partially corrupted JSON), + // fall back to returning the original value. + return nestedContentValue; + } if (nestedContent == null) return nestedContentValue; BlockListValue blockListValue = new BlockListValue(); From 95d7a4776f9f81b44f758755972a90f9a378b973 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:41:05 +0000 Subject: [PATCH 5/6] Use OfType instead of Cast when iterating nested content config array (#904) * Initial plan * Use OfType instead of Cast for resilience to non-object array entries Co-authored-by: KevinJump <431231+KevinJump@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: KevinJump <431231+KevinJump@users.noreply.github.com> --- .../DataTypeSerializers/NestedContentMigratingConfig.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uSync.Core/DataTypes/DataTypeSerializers/NestedContentMigratingConfig.cs b/uSync.Core/DataTypes/DataTypeSerializers/NestedContentMigratingConfig.cs index eec1de0c3..4a134d76d 100644 --- a/uSync.Core/DataTypes/DataTypeSerializers/NestedContentMigratingConfig.cs +++ b/uSync.Core/DataTypes/DataTypeSerializers/NestedContentMigratingConfig.cs @@ -41,7 +41,7 @@ public override IDictionary GetConfigurationImport(IDictionary(); - foreach (var contentType in contentTypesArray.Cast()) + foreach (var contentType in contentTypesArray.OfType()) { if (contentType["ncAlias"]?.ToString() is not string alias) continue; var contentTypeItem = _contentTypeService.Get(alias); From 0d215479af0b957113031d2ab6bef2084e5e48d9 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:57:22 +0000 Subject: [PATCH 6/6] Add NUnit migration tests for NestedContentMigratingConfig (#905) * Initial plan * Fix MediaSerializer build error and plan nested content migration tests Co-authored-by: KevinJump <431231+KevinJump@users.noreply.github.com> * Add NUnit migration tests for NestedContentMigratingConfig Co-authored-by: KevinJump <431231+KevinJump@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: KevinJump <431231+KevinJump@users.noreply.github.com> --- .../Serializers/MediaSerializer.cs | 2 +- .../Migrations/NestedContentMigrationTests.cs | 204 ++++++++++++++++++ .../appsettings-schema.Umbraco.Cms.json | 0 uSync.Tests/umbraco-package-schema.json | 0 4 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 uSync.Tests/Migrations/NestedContentMigrationTests.cs mode change 100644 => 100755 uSync.Tests/appsettings-schema.Umbraco.Cms.json mode change 100644 => 100755 uSync.Tests/umbraco-package-schema.json diff --git a/uSync.Core/Serialization/Serializers/MediaSerializer.cs b/uSync.Core/Serialization/Serializers/MediaSerializer.cs index 43353cef5..9b9fe8c1d 100644 --- a/uSync.Core/Serialization/Serializers/MediaSerializer.cs +++ b/uSync.Core/Serialization/Serializers/MediaSerializer.cs @@ -198,7 +198,7 @@ private static string GetFilePath(string? value) } catch (Exception ex) { - logger.LogError(ex, "Error creating media item with alias {alias} and parent {parentId}", alias, parentId); + logger.LogError(ex, "Error creating media item with alias {alias} and parent {parentId}", alias, parent?.Id); throw; } diff --git a/uSync.Tests/Migrations/NestedContentMigrationTests.cs b/uSync.Tests/Migrations/NestedContentMigrationTests.cs new file mode 100644 index 000000000..758dca5ed --- /dev/null +++ b/uSync.Tests/Migrations/NestedContentMigrationTests.cs @@ -0,0 +1,204 @@ +using System; + +using Moq; + +using NUnit.Framework; + +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; + +using uSync.Core.DataTypes.DataTypeSerializers; + +namespace uSync.Tests.Migrations; + +[TestFixture] +internal class NestedContentMigrationTests : MigrationTestBase +{ + private NestedContentMigratingConfig _serializer; + private Mock _mockContentTypeService; + + [SetUp] + public void Setup() + { + _mockContentTypeService = new Mock(); + + var articleContentType = new Mock(); + articleContentType.Setup(x => x.Key).Returns(Guid.Parse("cc07b313-0843-4aa8-bbda-871c8da728c8")); + + var blogContentType = new Mock(); + blogContentType.Setup(x => x.Key).Returns(Guid.Parse("4c52d8ab-54e6-40cd-999c-7a5f24903e4d")); + + _mockContentTypeService.Setup(x => x.Get("articleType")).Returns(articleContentType.Object); + _mockContentTypeService.Setup(x => x.Get("blogType")).Returns(blogContentType.Object); + + _serializer = new NestedContentMigratingConfig(_mockContentTypeService.Object); + } + + private static readonly string FullMigrationSource = @"{ + ""minItems"": 1, + ""maxItems"": 5, + ""contentTypes"": [ + { ""ncAlias"": ""articleType"" }, + { ""ncAlias"": ""blogType"" } + ] +}"; + + private static readonly string FullMigrationTarget = @"{ + ""blocks"": [ + { + ""contentElementTypeKey"": ""cc07b313-0843-4aa8-bbda-871c8da728c8"", + ""settingsElementTypeKey"": null + }, + { + ""contentElementTypeKey"": ""4c52d8ab-54e6-40cd-999c-7a5f24903e4d"", + ""settingsElementTypeKey"": null + } + ], + ""useSingleBlockMode"": false, + ""validationLimit"": { + ""max"": 5, + ""min"": 1 + } +}"; + + [Test] + public void FullMigrationTest() + => TestSerializerPropertyMigration(_serializer, FullMigrationSource, FullMigrationTarget); + + private static readonly string MinOnlySource = @"{ + ""minItems"": 2, + ""contentTypes"": [ + { ""ncAlias"": ""articleType"" } + ] +}"; + + private static readonly string MinOnlyTarget = @"{ + ""blocks"": [ + { + ""contentElementTypeKey"": ""cc07b313-0843-4aa8-bbda-871c8da728c8"", + ""settingsElementTypeKey"": null + } + ], + ""useSingleBlockMode"": false, + ""validationLimit"": { + ""max"": null, + ""min"": 2 + } +}"; + + [Test] + public void MinOnlyMigrationTest() + => TestSerializerPropertyMigration(_serializer, MinOnlySource, MinOnlyTarget); + + private static readonly string MaxOnlySource = @"{ + ""maxItems"": 3, + ""contentTypes"": [ + { ""ncAlias"": ""blogType"" } + ] +}"; + + private static readonly string MaxOnlyTarget = @"{ + ""blocks"": [ + { + ""contentElementTypeKey"": ""4c52d8ab-54e6-40cd-999c-7a5f24903e4d"", + ""settingsElementTypeKey"": null + } + ], + ""useSingleBlockMode"": false, + ""validationLimit"": { + ""max"": 3, + ""min"": null + } +}"; + + [Test] + public void MaxOnlyMigrationTest() + => TestSerializerPropertyMigration(_serializer, MaxOnlySource, MaxOnlyTarget); + + private static readonly string NoValidationLimitsSource = @"{ + ""contentTypes"": [ + { ""ncAlias"": ""articleType"" } + ] +}"; + + private static readonly string NoValidationLimitsTarget = @"{ + ""blocks"": [ + { + ""contentElementTypeKey"": ""cc07b313-0843-4aa8-bbda-871c8da728c8"", + ""settingsElementTypeKey"": null + } + ], + ""useSingleBlockMode"": false, + ""validationLimit"": { + ""max"": null, + ""min"": null + } +}"; + + [Test] + public void NoValidationLimitsMigrationTest() + => TestSerializerPropertyMigration(_serializer, NoValidationLimitsSource, NoValidationLimitsTarget); + + private static readonly string UnknownAliasSource = @"{ + ""contentTypes"": [ + { ""ncAlias"": ""unknownType"" } + ] +}"; + + private static readonly string UnknownAliasTarget = @"{ + ""blocks"": null, + ""useSingleBlockMode"": false, + ""validationLimit"": { + ""max"": null, + ""min"": null + } +}"; + + [Test] + public void UnknownAliasIsSkippedTest() + => TestSerializerPropertyMigration(_serializer, UnknownAliasSource, UnknownAliasTarget); + + private static readonly string MixedAliasesSource = @"{ + ""contentTypes"": [ + { ""ncAlias"": ""articleType"" }, + { ""ncAlias"": ""unknownType"" } + ] +}"; + + private static readonly string MixedAliasesTarget = @"{ + ""blocks"": [ + { + ""contentElementTypeKey"": ""cc07b313-0843-4aa8-bbda-871c8da728c8"", + ""settingsElementTypeKey"": null + } + ], + ""useSingleBlockMode"": false, + ""validationLimit"": { + ""max"": null, + ""min"": null + } +}"; + + [Test] + public void UnknownAliasInMixedListIsSkippedTest() + => TestSerializerPropertyMigration(_serializer, MixedAliasesSource, MixedAliasesTarget); + + private static readonly string EmptyContentTypesSource = @"{ + ""minItems"": 1, + ""maxItems"": 2, + ""contentTypes"": [] +}"; + + private static readonly string EmptyContentTypesTarget = @"{ + ""blocks"": null, + ""useSingleBlockMode"": false, + ""validationLimit"": { + ""max"": 2, + ""min"": 1 + } +}"; + + [Test] + public void EmptyContentTypesMigrationTest() + => TestSerializerPropertyMigration(_serializer, EmptyContentTypesSource, EmptyContentTypesTarget); +} diff --git a/uSync.Tests/appsettings-schema.Umbraco.Cms.json b/uSync.Tests/appsettings-schema.Umbraco.Cms.json old mode 100644 new mode 100755 diff --git a/uSync.Tests/umbraco-package-schema.json b/uSync.Tests/umbraco-package-schema.json old mode 100644 new mode 100755