Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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**.
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System.Linq;

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace uSync.BackOffice.Configuration;

/// <summary>
/// Service collection helpers for registering a uSync handler set from configuration.
/// </summary>
public static class HandlerSetConfigurationExtensions
{
/// <summary>
/// Bind a handler set from configuration, and layer each named handler's own settings
/// over the top of the set's <see cref="uSyncHandlerSetSettings.HandlerDefaults"/>.
/// </summary>
/// <remarks>
/// <para>
/// This is a drop-in replacement for <c>services.Configure&lt;uSyncHandlerSetSettings&gt;(setName, section)</c>.
/// </para>
/// <para>
/// After the normal bind, each handler that has its own block is re-bound on top of a clone of
/// the resolved <see cref="uSyncHandlerSetSettings.HandlerDefaults"/>. 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).
/// </para>
/// <para>
/// The additional <see cref="HandlerSettings.Settings"/> dictionary is also merged here, and is
/// merged again (idempotently) at resolution time in
/// <see cref="HandlerSetSettingsExtensions.GetHandlerSettings"/> so that keys added to
/// <see cref="uSyncHandlerSetSettings.HandlerDefaults"/> by <i>later</i> post-configure steps still cascade.
/// </para>
/// </remarks>
public static IServiceCollection ConfigureHandlerSet(this IServiceCollection services, string setName, IConfigurationSection section)
{
services.Configure<uSyncHandlerSetSettings>(setName, section);
services.PostConfigure<uSyncHandlerSetSettings>(setName, options => MergeHandlerDefaults(options, section));
return services;
}

/// <summary>
/// Re-bind every named handler in the set on top of a clone of the handler defaults.
/// </summary>
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;
}
}
}
10 changes: 8 additions & 2 deletions uSync.BackOffice/Configuration/uSyncHandlerSetSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,16 @@ public class uSyncHandlerSetSettings
public static class HandlerSetSettingsExtensions
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// When the handler has its own settings block, those settings are merged over the top of
/// <see cref="uSyncHandlerSetSettings.HandlerDefaults"/> (see <see cref="HandlerSettingsExtensions.MergeWithDefaults"/>),
/// 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.
/// </remarks>
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();
}
45 changes: 44 additions & 1 deletion uSync.BackOffice/Configuration/uSyncHandlerSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,51 @@ public static HandlerSettings Clone(this HandlerSettings settings)
UseFlatStructure = settings.UseFlatStructure,
Group = settings.Group,
GuidNames = settings.GuidNames,
Settings = new Dictionary<string, object?>(settings.Settings, StringComparer.InvariantCultureIgnoreCase)
CreateClean = settings.CreateClean,
FullFileOnDifference = settings.FullFileOnDifference,
Settings = settings.Settings is not null
? new Dictionary<string, object?>(settings.Settings, StringComparer.InvariantCultureIgnoreCase)
: new Dictionary<string, object?>(StringComparer.InvariantCultureIgnoreCase)
};
}

/// <summary>
/// Merge a handler's own settings over the top of a set of default settings.
/// </summary>
/// <remarks>
/// <para>
/// Returns a new <see cref="HandlerSettings"/> so neither input is mutated.
/// </para>
/// <para>
/// The strongly typed properties (Enabled, UseFlatStructure, GuidNames, etc.) are taken from
/// <paramref name="handlerSettings"/>. 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.
/// </para>
/// <para>
/// The additional <see cref="HandlerSettings.Settings"/> dictionary <i>is</i> merged, because a
/// key is only present when it has been explicitly configured. The <paramref name="defaults"/>
/// 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.
/// </para>
/// </remarks>
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<string, object?>(defaults.Settings, StringComparer.InvariantCultureIgnoreCase)
: new Dictionary<string, object?>(StringComparer.InvariantCultureIgnoreCase);

if (handlerSettings.Settings is not null)
{
foreach (var setting in handlerSettings.Settings)
settings[setting.Key] = setting.Value;
}

merged.Settings = settings;
return merged;
}

}
8 changes: 5 additions & 3 deletions uSync.BackOffice/uSyncBackOfficeBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,11 @@ public static IUmbracoBuilder AdduSync(this IUmbracoBuilder builder, Action<uSyn
}
options.ValidateDataAnnotations();

// default handler options, other people can load their own names handler options and
// they can be used throughout uSync (so complete will do this).
var handlerOptions = builder.Services.Configure<uSyncHandlerSetSettings>(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.
Expand Down
147 changes: 147 additions & 0 deletions uSync.Tests/Configuration/HandlerSetOptionsBindingTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests that <see cref="HandlerSetConfigurationExtensions.ConfigureHandlerSet"/> 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.
/// </summary>
[TestFixture]
public class HandlerSetOptionsBindingTests
{
private const string SetPath = "uSync:Sets:Default";

private static uSyncHandlerSetSettings ResolveSet(Dictionary<string, string> 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<IOptionsMonitor<uSyncHandlerSetSettings>>().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);
});
}
}
Loading
Loading