diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..49fbc667 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,71 @@ +# Changelog + +All notable code and behaviour changes to uSync are recorded here. + +This file tracks changes to how uSync _behaves_. For changes to the on-disk +`.config` file format (which can cause items to report as changed and prompt a +re-export), see [`changes/format.md`](changes/format.md). + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +History is backfilled from the v18 release history starting at `v18.0.0`. + +## [Unreleased] + +### Changed + +- **Handler settings now inherit from `HandlerDefaults`.** When a handler has its + own settings block, its values are layered _over_ the set's `HandlerDefaults` + instead of replacing them wholesale. A handler now only needs to specify the + settings it wants to change from the defaults. + - The additional `Settings` dictionary is merged key-by-key (the handler's own + keys win), so a per-key default such as `CreateOnly` set in `HandlerDefaults` + now cascades to handlers that define their own block. + - The strongly typed properties (`UseFlatStructure`, `GuidNames`, etc.) also + cascade, via a new `IServiceCollection.ConfigureHandlerSet(...)` extension + that binds each handler's configuration on top of a clone of the defaults at + options-binding time. + + > **Breaking:** Previously a handler that defined its own block ignored + > `HandlerDefaults` entirely. Configurations that relied on that replacement + > behaviour (i.e. expected a handler block to _reset_ settings back to their + > built-in defaults rather than inherit the set defaults) will now see the + > inherited values instead. Review any set that mixes `HandlerDefaults` with + > per-handler blocks. + +### Fixed + +- `HandlerSettings.Clone()` no longer drops the `CreateClean` and + `FullFileOnDifference` properties. Handlers that set either value in their own + block were previously resolved as `false` regardless; they are now honoured. + +## [18.0.3] - 2026-07-22 + +### Fixed + +- Culture-variant property values were dropped on import because of a + `culture`/`cultures` typo. Variant properties now import correctly. (#1000) + +## [18.0.2] - 2026-07-13 + +### Added + +- `ISyncContainerHandler` — lets a handler whose items live in container (folder) + items export those containers one at a time. The Library **Element** handler + implements it, so container folders are no longer left behind when items are + exported individually (e.g. a dependency-based push); previously the folders + were only written during a full export. (#980) + +### Changed + +- **Extender API:** `ISyncContainerHandler.ExportContainer` now takes a `Udi`. + +### Fixed + +- Element container nodes now resolve to the Element handler by type name. +- Merged the latest `v17/main` fixes and performance improvements into v18. + +## [18.0.0] - 2026-06-25 + +### Added + +- Initial uSync release for **Umbraco 18**. diff --git a/uSync.BackOffice/Configuration/HandlerSetConfigurationExtensions.cs b/uSync.BackOffice/Configuration/HandlerSetConfigurationExtensions.cs new file mode 100644 index 00000000..e7c734d4 --- /dev/null +++ b/uSync.BackOffice/Configuration/HandlerSetConfigurationExtensions.cs @@ -0,0 +1,62 @@ +using System.Linq; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace uSync.BackOffice.Configuration; + +/// +/// Service collection helpers for registering a uSync handler set from configuration. +/// +public static class HandlerSetConfigurationExtensions +{ + /// + /// Bind a handler set from configuration, and layer each named handler's own settings + /// over the top of the set's . + /// + /// + /// + /// This is a drop-in replacement for services.Configure<uSyncHandlerSetSettings>(setName, section). + /// + /// + /// After the normal bind, each handler that has its own block is re-bound on top of a clone of + /// the resolved . Because configuration binding + /// only writes the keys that are actually present, a handler inherits every default it does not + /// explicitly override - including the strongly typed properties (UseFlatStructure, GuidNames, etc.) + /// that can't be merged after binding (an unset boolean is indistinguishable from one set to its + /// default value once bound). + /// + /// + /// The additional dictionary is also merged here, and is + /// merged again (idempotently) at resolution time in + /// so that keys added to + /// by later post-configure steps still cascade. + /// + /// + public static IServiceCollection ConfigureHandlerSet(this IServiceCollection services, string setName, IConfigurationSection section) + { + services.Configure(setName, section); + services.PostConfigure(setName, options => MergeHandlerDefaults(options, section)); + return services; + } + + /// + /// Re-bind every named handler in the set on top of a clone of the handler defaults. + /// + internal static void MergeHandlerDefaults(uSyncHandlerSetSettings options, IConfigurationSection section) + { + if (options.Handlers is null || options.Handlers.Count == 0) + return; + + var handlersSection = section.GetSection("Handlers"); + + foreach (var alias in options.Handlers.Keys.ToList()) + { + // start from the resolved defaults, then bind the handler's own raw config over the + // top - only the keys present in the handler block will override the defaults. + var merged = options.HandlerDefaults.Clone(); + handlersSection.GetSection(alias).Bind(merged); + options.Handlers[alias] = merged; + } + } +} diff --git a/uSync.BackOffice/Configuration/uSyncHandlerSetSettings.cs b/uSync.BackOffice/Configuration/uSyncHandlerSetSettings.cs index 868c21b8..e728edf5 100644 --- a/uSync.BackOffice/Configuration/uSyncHandlerSetSettings.cs +++ b/uSync.BackOffice/Configuration/uSyncHandlerSetSettings.cs @@ -48,10 +48,16 @@ public class uSyncHandlerSetSettings public static class HandlerSetSettingsExtensions { /// - /// Get the handler settings for the named handler - (will load defaults if no specific handler settings are found) + /// Get the handler settings for the named handler. /// + /// + /// When the handler has its own settings block, those settings are merged over the top of + /// (see ), + /// so a handler only needs to specify the additional settings it wants to change from the defaults. + /// If the handler has no block of its own, the defaults are used as-is. + /// public static HandlerSettings GetHandlerSettings(this uSyncHandlerSetSettings handlerSet, string alias) => handlerSet.Handlers.TryGetValue(alias, out var value) - ? value.Clone() + ? value.MergeWithDefaults(handlerSet.HandlerDefaults) : handlerSet.HandlerDefaults.Clone(); } diff --git a/uSync.BackOffice/Configuration/uSyncHandlerSettings.cs b/uSync.BackOffice/Configuration/uSyncHandlerSettings.cs index 4fe46a19..91047977 100644 --- a/uSync.BackOffice/Configuration/uSyncHandlerSettings.cs +++ b/uSync.BackOffice/Configuration/uSyncHandlerSettings.cs @@ -128,8 +128,51 @@ public static HandlerSettings Clone(this HandlerSettings settings) UseFlatStructure = settings.UseFlatStructure, Group = settings.Group, GuidNames = settings.GuidNames, - Settings = new Dictionary(settings.Settings, StringComparer.InvariantCultureIgnoreCase) + CreateClean = settings.CreateClean, + FullFileOnDifference = settings.FullFileOnDifference, + Settings = settings.Settings is not null + ? new Dictionary(settings.Settings, StringComparer.InvariantCultureIgnoreCase) + : new Dictionary(StringComparer.InvariantCultureIgnoreCase) }; } + /// + /// Merge a handler's own settings over the top of a set of default settings. + /// + /// + /// + /// Returns a new so neither input is mutated. + /// + /// + /// The strongly typed properties (Enabled, UseFlatStructure, GuidNames, etc.) are taken from + /// . Configuration binding cannot tell an unset boolean from + /// one explicitly set to its default value, so we can't reliably layer these over the defaults + /// without risking overriding a deliberately-set value - the handler's own block wins for them. + /// + /// + /// The additional dictionary is merged, because a + /// key is only present when it has been explicitly configured. The + /// provide the base and the handler's own keys take precedence - so a per-key default (e.g. + /// CreateOnly) set in HandlerDefaults now cascades to handlers that define their own block. + /// + /// + public static HandlerSettings MergeWithDefaults(this HandlerSettings handlerSettings, HandlerSettings defaults) + { + var merged = handlerSettings.Clone(); + + // start from the defaults, then layer the handler's own keys on top. + var settings = defaults.Settings is not null + ? new Dictionary(defaults.Settings, StringComparer.InvariantCultureIgnoreCase) + : new Dictionary(StringComparer.InvariantCultureIgnoreCase); + + if (handlerSettings.Settings is not null) + { + foreach (var setting in handlerSettings.Settings) + settings[setting.Key] = setting.Value; + } + + merged.Settings = settings; + return merged; + } + } diff --git a/uSync.BackOffice/uSyncBackOfficeBuilderExtensions.cs b/uSync.BackOffice/uSyncBackOfficeBuilderExtensions.cs index 917fcbdb..632ca13c 100644 --- a/uSync.BackOffice/uSyncBackOfficeBuilderExtensions.cs +++ b/uSync.BackOffice/uSyncBackOfficeBuilderExtensions.cs @@ -55,9 +55,11 @@ public static IUmbracoBuilder AdduSync(this IUmbracoBuilder builder, Action(uSync.Sets.DefaultSet, + // default handler options, other people can load their own names handler options and + // they can be used throughout uSync (so complete will do this). + // ConfigureHandlerSet also layers each handler's own settings over the HandlerDefaults, + // so a handler only needs to specify the settings it wants to change from the defaults. + builder.Services.ConfigureHandlerSet(uSync.Sets.DefaultSet, builder.Config.GetSection(uSync.Configuration.ConfigDefaultSet)); // Setup uSync core. diff --git a/uSync.Tests/Configuration/HandlerSetOptionsBindingTests.cs b/uSync.Tests/Configuration/HandlerSetOptionsBindingTests.cs new file mode 100644 index 00000000..32bd7cbf --- /dev/null +++ b/uSync.Tests/Configuration/HandlerSetOptionsBindingTests.cs @@ -0,0 +1,147 @@ +using System.Collections.Generic; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +using NUnit.Framework; + +using uSync.BackOffice.Configuration; +using uSync.BackOffice.Extensions; + +namespace uSync.Tests.Configuration; + +/// +/// Tests that layers a handler's +/// own configuration over the top of the set's HandlerDefaults - including the strongly typed +/// properties that can only be merged at bind time. +/// +[TestFixture] +public class HandlerSetOptionsBindingTests +{ + private const string SetPath = "uSync:Sets:Default"; + + private static uSyncHandlerSetSettings ResolveSet(Dictionary config) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(config) + .Build(); + + var services = new ServiceCollection(); + services.AddOptions(); + services.ConfigureHandlerSet("Default", configuration.GetSection(SetPath)); + + var provider = services.BuildServiceProvider(); + return provider.GetRequiredService>().Get("Default"); + } + + [Test] + public void StronglyTypedPropertiesCascadeFromDefaults() + { + // ContentHandler only sets CreateOnly - everything else should come from HandlerDefaults. + var set = ResolveSet(new() + { + [$"{SetPath}:HandlerDefaults:UseFlatStructure"] = "false", + [$"{SetPath}:HandlerDefaults:GuidNames"] = "true", + [$"{SetPath}:HandlerDefaults:Settings:CreateOnly"] = "false", + [$"{SetPath}:HandlerDefaults:Settings:FromDefaults"] = "default-value", + [$"{SetPath}:Handlers:ContentHandler:Settings:CreateOnly"] = "true", + }); + + var settings = set.GetHandlerSettings("ContentHandler"); + + Assert.Multiple(() => + { + // strongly typed defaults now cascade (the bit that needed the options layer) + Assert.That(settings.UseFlatStructure, Is.False, "UseFlatStructure should inherit from defaults"); + Assert.That(settings.GuidNames, Is.True, "GuidNames should inherit from defaults"); + // dictionary merge + Assert.That(settings.IsCreateOnly(), Is.True, "handler's own CreateOnly should win"); + Assert.That(settings.GetSetting("FromDefaults", string.Empty), Is.EqualTo("default-value")); + }); + } + + [Test] + public void HandlerCanOverrideStronglyTypedDefault() + { + var set = ResolveSet(new() + { + [$"{SetPath}:HandlerDefaults:UseFlatStructure"] = "false", + [$"{SetPath}:HandlerDefaults:GuidNames"] = "true", + [$"{SetPath}:Handlers:FlatHandler:UseFlatStructure"] = "true", + }); + + var settings = set.GetHandlerSettings("FlatHandler"); + + Assert.Multiple(() => + { + Assert.That(settings.UseFlatStructure, Is.True, "handler override should win over default"); + Assert.That(settings.GuidNames, Is.True, "unset property should still inherit from defaults"); + }); + } + + [Test] + public void MinimalBlockInheritsAllUnsetDefaults() + { + // a handler that only sets one property (here Enabled) must still inherit every + // other default - this is the "lots of handlers with near-empty blocks" case. + var set = ResolveSet(new() + { + [$"{SetPath}:HandlerDefaults:UseFlatStructure"] = "false", + [$"{SetPath}:HandlerDefaults:GuidNames"] = "true", + [$"{SetPath}:HandlerDefaults:Settings:CreateOnly"] = "true", + [$"{SetPath}:Handlers:MinimalHandler:Enabled"] = "true", + }); + + var settings = set.GetHandlerSettings("MinimalHandler"); + + Assert.Multiple(() => + { + Assert.That(settings.Enabled, Is.True); + Assert.That(settings.UseFlatStructure, Is.False, "should inherit default, not the C# default of true"); + Assert.That(settings.GuidNames, Is.True); + Assert.That(settings.IsCreateOnly(), Is.True, "should inherit default settings key"); + }); + } + + [Test] + public void EmptyBlockHandlerIsNotEvenBound() + { + // an empty {} block emits no config leaves, so the handler never enters options.Handlers + // and is resolved purely from the defaults via the Clone() fallback. + var set = ResolveSet(new() + { + [$"{SetPath}:HandlerDefaults:UseFlatStructure"] = "false", + [$"{SetPath}:HandlerDefaults:GuidNames"] = "true", + // note: no leaf keys under EmptyBlockHandler are possible from an empty object. + }); + + Assert.That(set.Handlers.ContainsKey("EmptyBlockHandler"), Is.False); + + var settings = set.GetHandlerSettings("EmptyBlockHandler"); + + Assert.Multiple(() => + { + Assert.That(settings.UseFlatStructure, Is.False); + Assert.That(settings.GuidNames, Is.True); + }); + } + + [Test] + public void HandlerWithoutOwnBlockStillUsesDefaults() + { + var set = ResolveSet(new() + { + [$"{SetPath}:HandlerDefaults:UseFlatStructure"] = "false", + [$"{SetPath}:HandlerDefaults:Settings:CreateOnly"] = "true", + }); + + var settings = set.GetHandlerSettings("SomeHandlerWithNoBlock"); + + Assert.Multiple(() => + { + Assert.That(settings.UseFlatStructure, Is.False); + Assert.That(settings.IsCreateOnly(), Is.True); + }); + } +} diff --git a/uSync.Tests/Configuration/HandlerSettingsMergeTests.cs b/uSync.Tests/Configuration/HandlerSettingsMergeTests.cs new file mode 100644 index 00000000..fc613762 --- /dev/null +++ b/uSync.Tests/Configuration/HandlerSettingsMergeTests.cs @@ -0,0 +1,128 @@ +using NUnit.Framework; + +using uSync.BackOffice.Configuration; +using uSync.BackOffice.Extensions; + +namespace uSync.Tests.Configuration; + +/// +/// Tests for how a handler's own settings are merged with the HandlerDefaults +/// when resolved via . +/// +[TestFixture] +public class HandlerSettingsMergeTests +{ + private static uSyncHandlerSetSettings BuildSet() + { + var set = new uSyncHandlerSetSettings + { + HandlerDefaults = new HandlerSettings + { + UseFlatStructure = false, + } + }; + + set.HandlerDefaults.Settings["CreateOnly"] = "false"; + set.HandlerDefaults.Settings["FromDefaults"] = "default-value"; + + return set; + } + + [Test] + public void HandlerWithoutOwnBlock_UsesDefaults() + { + var set = BuildSet(); + + var settings = set.GetHandlerSettings("ContentHandler"); + + Assert.Multiple(() => + { + Assert.That(settings.IsCreateOnly(), Is.False); + Assert.That(settings.GetSetting("FromDefaults", string.Empty), Is.EqualTo("default-value")); + Assert.That(settings.UseFlatStructure, Is.False); + }); + } + + [Test] + public void HandlerOwnKey_OverridesDefaultKey() + { + // the #953 scenario: HandlerDefaults set CreateOnly false, + // the named handler explicitly turns it on. + var set = BuildSet(); + var handler = new HandlerSettings(); + handler.Settings["CreateOnly"] = "true"; + set.Handlers["ContentHandler"] = handler; + + var settings = set.GetHandlerSettings("ContentHandler"); + + Assert.That(settings.IsCreateOnly(), Is.True); + } + + [Test] + public void HandlerWithOwnBlock_InheritsDefaultKeysItDoesNotSet() + { + // previously a handler with its own block ignored HandlerDefaults entirely. + var set = BuildSet(); + var handler = new HandlerSettings(); + handler.Settings["CreateOnly"] = "true"; + set.Handlers["ContentHandler"] = handler; + + var settings = set.GetHandlerSettings("ContentHandler"); + + Assert.That(settings.GetSetting("FromDefaults", string.Empty), Is.EqualTo("default-value")); + } + + [Test] + public void Merge_DoesNotMutateInputs() + { + var set = BuildSet(); + var handler = new HandlerSettings(); + handler.Settings["CreateOnly"] = "true"; + set.Handlers["ContentHandler"] = handler; + + _ = set.GetHandlerSettings("ContentHandler"); + + Assert.Multiple(() => + { + // defaults should not have gained the handler's key + Assert.That(set.HandlerDefaults.Settings.ContainsKey("CreateOnly"), Is.True); + Assert.That(set.HandlerDefaults.Settings["CreateOnly"], Is.EqualTo("false")); + // handler block should not have gained the default's key + Assert.That(handler.Settings.ContainsKey("FromDefaults"), Is.False); + }); + } + + [Test] + public void Clone_PreservesCreateCleanAndFullFileOnDifference() + { + // regression: Clone() used to drop these two properties. + var settings = new HandlerSettings + { + CreateClean = true, + FullFileOnDifference = true, + }; + + var clone = settings.Clone(); + + Assert.Multiple(() => + { + Assert.That(clone.CreateClean, Is.True); + Assert.That(clone.FullFileOnDifference, Is.True); + }); + } + + [Test] + public void MergeWithDefaults_TakesStronglyTypedPropertiesFromHandler() + { + var defaults = new HandlerSettings { GuidNames = true, CreateClean = true }; + var handler = new HandlerSettings { GuidNames = false, CreateClean = false }; + + var merged = handler.MergeWithDefaults(defaults); + + Assert.Multiple(() => + { + Assert.That(merged.GuidNames, Is.False); + Assert.That(merged.CreateClean, Is.False); + }); + } +}