diff --git a/dist/build-package.ps1 b/dist/build-package.ps1 index c577f9c74..c369b4b97 100644 --- a/dist/build-package.ps1 +++ b/dist/build-package.ps1 @@ -117,7 +117,13 @@ $sln_name = "..\uSync.slnx"; ## restore up front with --force-evaluate so the build's implicit restore doesn't run in ## locked mode and fail on NU1403 package content-hash mismatches. --force-evaluate ## re-evaluates against the current cache (updating the lock file) instead of erroring. -dotnet restore $sln_name --force-evaluate +## +## -p:Configuration=$env matters: some projects (uSync.Extend) are excluded from the +## Debug solution configuration via in +## uSync.slnx. A restore with no configuration defaults to Debug and silently skips +## those projects, leaving their lock file stale for the --no-restore build/pack below +## (which run in $env/Release) - surfacing later as a NU1903 audit failure on pack. +dotnet restore $sln_name --force-evaluate -p:Configuration=$env ""; "##### Building project"; "--------------------------------"; "" ## --no-restore: we've already restored above, don't let the build kick off a second diff --git a/docs/perf/batch-save-investigation.md b/docs/perf/batch-save-investigation.md new file mode 100644 index 000000000..063a7c2b4 --- /dev/null +++ b/docs/perf/batch-save-investigation.md @@ -0,0 +1,180 @@ +# Investigation: batched content/media save on import (perf item #1) + +**Branch:** `v17/investigate/batch-save` +**Question:** would routing content/media imports through Umbraco's +`Save(IEnumerable<…>)` (the batch overload) meaningfully reduce database work, +given that (a) Umbraco's own batching is limited and (b) notifications may not be +batched? + +**Short answer:** No — not as a safe, general win. In the default configuration +the only saving would come at the cost of the per-item failure isolation that the +current default is deliberately designed to provide. In the opt-in "suppressed" +configuration the transaction and notifications are *already* batched by uSync's +ambient scope, so the batch overload adds almost nothing. Recommendation: **do not +wire up bulk `Save` for content/media.** Details and evidence below. + +--- + +## 1. What the batch overload actually does + +Decompiled from `Umbraco.Cms.Core.Services.ContentService` (Umbraco 17.3.0, +`Umbraco.Core.dll`). Media (`MediaService`) is equivalent. + +`Save(IContent)` — the per-item path uSync uses today: + +```csharp +using (ICoreScope scope = ScopeProvider.CreateCoreScope()) +{ + scope.WriteLock(Constants.Locks.ContentTree); + if (scope.Notifications.PublishCancelable(new ContentSavingNotification(content, …))) + { scope.Complete(); return Cancel; } + _documentRepository.Save(content); // 1 row write + scope.Notifications.Publish(new ContentSavedNotification(content, …)); + scope.Notifications.Publish(new ContentTreeChangeNotification(content, RefreshNode, …)); + Audit(...); + scope.Complete(); // 1 transaction commit +} +``` + +`Save(IEnumerable)` — the batch overload: + +```csharp +IContent[] array = contents.ToArray(); +using (ICoreScope scope = ScopeProvider.CreateCoreScope()) +{ + scope.WriteLock(Constants.Locks.ContentTree); + if (scope.Notifications.PublishCancelable(new ContentSavingNotification(array, …))) // ONE, batched + { scope.Complete(); return Cancel; } + foreach (IContent content in array) + _documentRepository.Save(content); // still 1 row write PER item + scope.Notifications.Publish(new ContentSavedNotification(array, …)); // ONE, batched + scope.Notifications.Publish(new ContentTreeChangeNotification(array, RefreshNode, …));// ONE, batched + Audit(...); + scope.Complete(); // ONE transaction commit +} +``` + +Key observations: + +- **The actual row writes are identical** — `_documentRepository.Save(content)` runs + once per item in both. The batch overload does **not** issue a single set-based + SQL statement; it loops. So there is **no reduction in the number of INSERT/UPDATE + round-trips**. +- The batch overload's savings are purely **structural**: 1 scope/transaction/commit + and 1 write-lock instead of N, and **notifications *are* batched** — `ContentSaving`, + `ContentSaved` and `ContentTreeChange` each fire **once with an array** rather than + N times. (This corrects the assumption that "notifications aren't batched" — at the + `ContentService` level they are, when you use the batch overload.) +- The batch overload also **skips** the per-item validation that `Save(IContent)` + performs: the `PublishedState` guard and the 255-char name-length check. It also + doesn't accept a `ContentSchedule`. + +So the theoretical benefit of switching is: **N transactions → 1, and N notification +dispatches → 1.** No change to the number of row writes. + +## 2. Does uSync actually pay "N transactions" today? It depends on config. + +uSync wraps an import handler run in +`ICoreScopeProvider.CreateNotificationScope(...)` +([`ScopeExtensions.cs`](../../uSync.BackOffice/Extensions/ScopeExtensions.cs)): + +```csharp +if (syncConfigService.Settings.DisableNotificationSuppression) + return null; // <-- default path +return scopeProvider.CreateCoreScope( + scopedNotificationPublisher: notificationPublisher, // SyncScopedNotificationPublisher + autoComplete: true); +``` + +`DisableNotificationSuppression` **defaults to `true`** on v16+ +([`uSyncSettings.cs:224`](../../uSync.BackOffice/Configuration/uSyncSettings.cs)), +so there are two very different runtime shapes: + +### Default config — `DisableNotificationSuppression = true` + +`CreateNotificationScope` returns **null**. There is **no uSync ambient scope** +around the import (`SyncService_Handlers.cs`: `scope?.Complete()` is a no-op). Each +per-item `contentService.Save(item)` therefore opens its **own** root scope → +its **own** transaction/commit, and fires its notifications **immediately**. + +- Here, N items really do mean **N transactions + N notification sets**. +- The batch overload *would* collapse these to 1 + 1. +- **BUT** this per-item, non-batched behaviour is a *deliberate design decision*. + From the setting's own XML docs: + + > on v16 the default is true, because some of the notifications appear to be + > closely coupled to the save/publish process, and if something goes wrong in one + > item's import it can cause a cascade of failures across everything that might have + > been imported along with it. If the notifications are not suppressed, then if an + > item fails to import it doesn't stop other items from being imported. + + Batch-saving reintroduces exactly the failure mode this default exists to avoid: + a single bad item (DB constraint, a throwing `Saving`/`Saved` handler, an + over-long name that the batch overload no longer validates) rolls back or aborts + the **whole batch**, and uSync loses its per-item error attribution (which item + failed, with what message). This matches the "batching causes issues" experience. + +### Opt-in config — `DisableNotificationSuppression = false` + +`CreateNotificationScope` returns a real ambient scope using +`SyncScopedNotificationPublisher` +([`SyncScopedNotificationPublisher.cs`](../../uSync.BackOffice/Notifications/SyncScopedNotificationPublisher.cs)). + +- **Transaction is already batched.** Umbraco scopes nest; the per-item + `contentService.Save` calls become child scopes that share the single ambient + transaction, which commits once when uSync completes the outer scope. So the + "N transactions" cost is **already gone** without the batch overload. +- **Notifications are already deferred and grouped.** The scoped publisher collects + every notification raised during the import and, at completion, dispatches them + grouped by type in one `IEventAggregator.Publish(items)` call per type — or, if + `BackgroundNotifications = true`, hands them to the background task queue. + + In this mode, switching to the batch overload only changes "N single-entity + `ContentSavedNotification`s, then group-published" into "1 array + `ContentSavedNotification`". That is a marginal allocation/dispatch difference, + **not** a database saving. + +## 3. Why the schema types already use the bulk path — and content doesn't + +This asymmetry is intentional. `ContentTypeSerializer`, `MediaTypeSerializer`, +`MemberTypeSerializer` and `DataTypeSerializer` already override `SaveAsync` and +honour `SerializerFlags.DoNotSave`, because saving a **doctype/datatype** triggers +expensive schema changes and full cache/nucache rebuilds — there, batching many into +one operation is a genuine, large win and the items are few. For **content/media** +items the per-save cost is dominated by the unavoidable per-row write, and the items +are many, so the batch overload buys far less while adding the atomicity risk above. + +The handler-level bulk hook does exist — +[`SyncHandlerRoot.ImportAllAsync`](../../uSync.BackOffice/SyncHandlers/SyncHandlerRoot.cs) +calls `serializer.SaveAsync(updates…)` — but only under +`if (options.Flags.HasFlag(SerializerFlags.DoNotSave))`, and `DoNotSave` is never set +for the content/media import path. So the plumbing is present but deliberately dormant +for these types. + +## 4. Conclusion & recommendation + +| Config | Transactions today | Notifications today | Batch overload benefit | Cost/risk | +|---|---|---|---|---| +| **Default** (`DisableNotificationSuppression = true`) | N (one per item) | N, fired immediately | N→1 txn, N→1 notifications | **Loses per-item failure isolation** (the reason this is the default); no reduction in row writes | +| **Suppressed** (`= false`) | 1 (ambient scope) | Deferred + grouped (opt. background) | ~none (already batched) | Marginal | + +**Recommendation: do not wire up bulk `Save(IEnumerable)` for content/media.** +It delivers no reduction in the dominant cost (per-row writes), its transaction/ +notification batching is either already provided by the suppressed-scope path or +directly conflicts with the intentional per-item isolation of the default path, and +it removes per-item error reporting plus two validation checks. + +If import throughput on large content sets is the goal, the existing, lower-risk +levers are configuration, not code: + +- **`DisableNotificationSuppression = false`** — collapses the import to a single + transaction and defers/groups notifications (this is the "batch" people actually + want, done at the scope level rather than the save level). +- **`BackgroundNotifications = true`** — moves notification processing off the import + thread entirely. + +If a save-level optimisation is ever revisited, the only shape worth prototyping is a +**bounded** batch (e.g. save in chunks of N with a per-chunk try/catch that falls back +to per-item on failure) so the failure blast radius stays small — and it should be +measured against a real content tree before adoption, since the row-write cost (which +batching does not change) is expected to dominate. diff --git a/uSync.BackOffice/Configuration/uSyncHandlerSettings.cs b/uSync.BackOffice/Configuration/uSyncHandlerSettings.cs index 3f3916481..4fe46a190 100644 --- a/uSync.BackOffice/Configuration/uSyncHandlerSettings.cs +++ b/uSync.BackOffice/Configuration/uSyncHandlerSettings.cs @@ -4,6 +4,8 @@ using Umbraco.Extensions; +using uSync.Core.Extensions; + namespace uSync.BackOffice.Configuration; /// @@ -89,10 +91,10 @@ public static class HandlerSettingsExtensions /// public static TResult GetSetting(this HandlerSettings settings, string key, TResult defaultValue) { - if (settings.Settings != null && settings.Settings.TryGetValue(key, out var value)) + if (settings.Settings != null && settings.Settings.TryGetValue(key, out var value) && value is not null) { - var attempt = value.TryConvertTo(); - if (attempt) return attempt.Result ?? defaultValue; + if (value.TryGetValueAs(out var result) && result is not null) + return result; } return defaultValue; diff --git a/uSync.BackOffice/Services/ISyncFileService.cs b/uSync.BackOffice/Services/ISyncFileService.cs index 1cafae4bd..3a56d4353 100644 --- a/uSync.BackOffice/Services/ISyncFileService.cs +++ b/uSync.BackOffice/Services/ISyncFileService.cs @@ -136,6 +136,16 @@ public interface ISyncFileService /// Task LoadXElementAsync(string file); + /// + /// load just the item key (the Key attribute on the root element) from a file. + /// + /// + /// This streams the file and stops at the root element, so we don't pay the cost + /// of parsing the whole document when all we need is the key (e.g. when working out + /// which items live in a folder for a 'clean' operation). + /// + Task LoadKeyFromFileAsync(string file); + /// /// merge all the files in the given folders into a single xml node, that can be bulk imported /// diff --git a/uSync.BackOffice/Services/SyncFileService.cs b/uSync.BackOffice/Services/SyncFileService.cs index ec556441f..415a962e0 100644 --- a/uSync.BackOffice/Services/SyncFileService.cs +++ b/uSync.BackOffice/Services/SyncFileService.cs @@ -211,6 +211,51 @@ public async Task LoadXElementAsync(string file) } } + private static readonly XmlReaderSettings _keyReaderSettings = new() + { + CheckCharacters = false, + Async = true, + IgnoreWhitespace = true, + IgnoreComments = true, + IgnoreProcessingInstructions = true, + DtdProcessing = DtdProcessing.Prohibit, + }; + + /// + public async Task LoadKeyFromFileAsync(string file) + { + EnsureFileExists(file); + + try + { + using (var stream = OpenRead(file)) + { + if (stream is null) + throw new FileNotFoundException($"Cannot create stream for {file}"); + + using (var reader = XmlReader.Create(stream, _keyReaderSettings.Clone())) + { + // move to the first (root) element and read its Key attribute, + // we don't need to read any further into the document. + while (await reader.ReadAsync()) + { + if (reader.NodeType != XmlNodeType.Element) continue; + + var key = reader.GetAttribute(global::uSync.Core.uSyncConstants.Xml.Key); + return Guid.TryParse(key, out var guid) ? guid : Guid.Empty; + } + } + } + } + catch (Exception ex) + { + _logger.LogWarning("Error while reading key from {file} {message}", file, ex.Message); + throw new Exception($"Error while reading key from {file}", ex); + } + + return Guid.Empty; + } + /// public async Task SaveFileAsync(string filename, Stream stream) { diff --git a/uSync.BackOffice/SyncHandlers/SyncHandlerBase.cs b/uSync.BackOffice/SyncHandlers/SyncHandlerBase.cs index b5a7d7f6b..ebe560d8a 100644 --- a/uSync.BackOffice/SyncHandlers/SyncHandlerBase.cs +++ b/uSync.BackOffice/SyncHandlers/SyncHandlerBase.cs @@ -99,9 +99,11 @@ protected override async Task> CleanFolderAsync(string private async Task GetCleanParentKeyAsync(string cleanFile) { - var node = await syncFileService.LoadXElementAsync(cleanFile); - if (node.GetKey() == Guid.Empty) return Guid.Empty; - return (await GetCleanParentAsync(cleanFile))?.Key; + // stream the key rather than parsing the whole file, and reuse it for the + // parent lookup so we don't read the clean file a second time. + var key = await syncFileService.LoadKeyFromFileAsync(cleanFile); + if (key == Guid.Empty) return Guid.Empty; + return (await GetFromServiceAsync(key))?.Key; } /// diff --git a/uSync.BackOffice/SyncHandlers/SyncHandlerRoot.cs b/uSync.BackOffice/SyncHandlers/SyncHandlerRoot.cs index b734ebab2..147ee0a0e 100644 --- a/uSync.BackOffice/SyncHandlers/SyncHandlerRoot.cs +++ b/uSync.BackOffice/SyncHandlers/SyncHandlerRoot.cs @@ -268,75 +268,79 @@ private HandlerSettings GetDefaultConfig() public async Task> ImportAllAsync(string[] folders, HandlerSettings config, uSyncImportOptions options) { var cacheKey = PrepCaches(); - runtimeCache.ClearByKey(cacheKey); - - options.Callbacks?.Update?.Invoke("Calculating import order", 1, 9); + try + { + options.Callbacks?.Update?.Invoke("Calculating import order", 1, 9); - var items = await GetMergedItemsAsync(folders, new SyncMergeOptions(options.Callbacks?.Update)); + var items = await GetMergedItemsAsync(folders, new SyncMergeOptions(options.Callbacks?.Update)); - options.Callbacks?.Update?.Invoke($"Processing {items.Count} items", 2, 9); + options.Callbacks?.Update?.Invoke($"Processing {items.Count} items", 2, 9); - // create the update list with items.count space. this is the max size we need this list. - List actions = new(items.Count); - List> updates = new(items.Count); - List cleanMarkers = []; + // create the update list with items.count space. this is the max size we need this list. + List actions = new(items.Count); + List> updates = new(items.Count); + List cleanMarkers = []; - int count = 0; - int total = items.Count; + int count = 0; + int total = items.Count; - options.Callbacks?.SetRange?.Invoke(count, total); + options.Callbacks?.SetRange?.Invoke(count, total); - foreach (var item in items) - { - count++; + foreach (var item in items) + { + count++; - var result = await ImportElementAsync(item.Node, item.FileName, config, options); - foreach (var attempt in result) - { - if (attempt.Success) + var result = await ImportElementAsync(item.Node, item.FileName, config, options); + foreach (var attempt in result) { - if (attempt.Change == ChangeType.Clean) - { - cleanMarkers.Add(item.Path); - } - else if (attempt.Item is not null && attempt.Item is TObject update) + if (attempt.Success) { - updates.Add(new ImportedItem(item.Node, update)); + if (attempt.Change == ChangeType.Clean) + { + cleanMarkers.Add(item.Path); + } + else if (attempt.Item is not null && attempt.Item is TObject update) + { + updates.Add(new ImportedItem(item.Node, update)); + } } - } - if (attempt.Change != ChangeType.Clean) - actions.Add(attempt); + if (attempt.Change != ChangeType.Clean) + actions.Add(attempt); + } } - } - // clean up memory we didn't use in the update list. - updates.TrimExcess(); + // clean up memory we didn't use in the update list. + updates.TrimExcess(); - // bulk save? - if (updates.Count > 0) - { - if (options.Flags.HasFlag(SerializerFlags.DoNotSave)) + // bulk save? + if (updates.Count > 0) { - await serializer.SaveAsync(updates.Select(x => x.Item)); - } + if (options.Flags.HasFlag(SerializerFlags.DoNotSave)) + { + await serializer.SaveAsync(updates.Select(x => x.Item)); + } - await PerformSecondPassImportsAsync(updates, actions, config, options.Callbacks?.Update); - } + await PerformSecondPassImportsAsync(updates, actions, config, options.Callbacks?.Update); + } - if (actions.All(x => x.Success) && cleanMarkers.Count > 0) - { - await PerformImportCleanAsync(cleanMarkers, actions, config, options.Callbacks?.Update); - } + if (actions.All(x => x.Success) && cleanMarkers.Count > 0) + { + await PerformImportCleanAsync(cleanMarkers, actions, config, options.Callbacks?.Update); + } - CleanCaches(cacheKey); - options.Callbacks?.Update?.Invoke("Done", 3, 3); + options.Callbacks?.Update?.Invoke("Done", 3, 3); - if (logger.IsEnabled(LogLevel.Debug)) - logger.LogDebug("ImportAll: {count} items imported", actions.Count); + if (logger.IsEnabled(LogLevel.Debug)) + logger.LogDebug("ImportAll: {count} items imported", actions.Count); - return actions; + return actions; + } + finally + { + CleanCaches(cacheKey); + } } /// @@ -707,8 +711,8 @@ protected async Task> GetFolderKeysAsync(string folder, bool flat) foreach (var file in files) { - var node = await syncFileService.LoadXElementAsync(file); - var key = node.GetKey(); + // we only need the key here, so stream it rather than parsing the whole file. + var key = await syncFileService.LoadKeyFromFileAsync(file); if (key != Guid.Empty) { keySet.Add(key); @@ -729,8 +733,8 @@ protected async Task> GetFolderKeysAsync(string folder, bool flat) /// protected async Task GetCleanParentAsync(string file) { - var node = await syncFileService.LoadXElementAsync(file); - var key = node.GetKey(); + // we only need the key to find the parent, so stream it rather than parsing the whole file. + var key = await syncFileService.LoadKeyFromFileAsync(file); if (key == Guid.Empty) return default; return await GetFromServiceAsync(key); } @@ -819,7 +823,19 @@ virtual protected async Task ShouldImportAsync(XElement node, HandlerSetti /// Export all items to a give folder on the disk /// virtual public async Task> ExportAllAsync(string[] folders, HandlerSettings settings, SyncUpdateCallback? callback) - => await ExportAllAsync(default, folders, settings, callback); + { + // scope the runtime cache (e.g. child-item lookups) to this export run, so the + // cache keys stay stable across async thread hops and are cleared when we're done. + var cacheKey = BeginCacheScope(); + try + { + return await ExportAllAsync(default, folders, settings, callback); + } + finally + { + EndCacheScope(cacheKey); + } + } /// @@ -1088,28 +1104,33 @@ public async Task> ReportAsync(string[] folders, Handle List actions = []; var cacheKey = PrepCaches(); + try + { + callback?.Invoke("Calculating order", 1, 3); - callback?.Invoke("Calculating order", 1, 3); + var items = await GetMergedItemsAsync(folders, new SyncMergeOptions(callback)); + var options = new uSyncImportOptions(); - var items = await GetMergedItemsAsync(folders, new SyncMergeOptions(callback)); - var options = new uSyncImportOptions(); + int count = 0; - int count = 0; + foreach (var item in items) + { + count++; + callback?.Invoke(Path.GetFileNameWithoutExtension(item.Path), count, items.Count); + actions.AddRange(await ReportElementAsync(item.Node, item.FileName, config, options)); + } - foreach (var item in items) + callback?.Invoke("Validating Report", 2, 3); + var validationActions = await ReportMissingParentsAsync([.. actions]); + actions.AddRange(ReportDeleteCheck(uSyncConfig.GetWorkingFolder(), validationActions)); + + callback?.Invoke($"Done ({this.ItemType})", 3, 3); + return actions; + } + finally { - count++; - callback?.Invoke(Path.GetFileNameWithoutExtension(item.Path), count, items.Count); - actions.AddRange(await ReportElementAsync(item.Node, item.FileName, config, options)); + CleanCaches(cacheKey); } - - callback?.Invoke("Validating Report", 2, 3); - var validationActions = await ReportMissingParentsAsync([.. actions]); - actions.AddRange(ReportDeleteCheck(uSyncConfig.GetWorkingFolder(), validationActions)); - - CleanCaches(cacheKey); - callback?.Invoke($"Done ({this.ItemType})", 3, 3); - return actions; } /// @@ -1600,6 +1621,13 @@ protected string[] GetDefaultHandlerFolders() /// protected virtual async Task CleanUpAsync(TObject item, string newFile, string folder) { + // when using a flat folder structure with guid file names, an item's file is always + // "{key}.{ext}" in the same folder - it never changes name or location - so there can + // never be a stale duplicate file to clean up. Skip the (recursive) folder scan, which + // otherwise runs on every save/move/delete. (Content/Media make the same check earlier.) + if (DefaultConfig.UseFlatStructure && DefaultConfig.GuidNames) + return; + var physicalFile = syncFileService.GetAbsPath(newFile); var files = syncFileService.GetFiles(folder, $"*.{this.uSyncConfig.Settings.DefaultExtension}"); @@ -1992,32 +2020,70 @@ protected string GetNameFromFileOrNode(string filename, XElement node) /// - /// get the key for any caches we might call (thread based cache value) + /// per-operation cache scope id. + /// + /// + /// this flows with the async operation (via AsyncLocal) so the runtime-cache keys + /// stay stable even when a continuation resumes on a different thread. Previously the + /// key was based on the managed thread id, which could change mid-operation across an + /// await - defeating the cache (misses) and leaving orphaned entries in the shared + /// runtime cache that the end-of-operation cleanup (running on another thread) missed. + /// + /// when no operation scope is active (ad-hoc lookups) we fall back to the managed + /// thread id, preserving the previous behavior for those callers. + /// + private readonly AsyncLocal _cacheScope = new(); + + /// + /// get the key for any caches we might call (scoped to the current operation) /// /// protected string GetCacheKeyBase() - => $"keyCache_{this.Alias}_{Environment.CurrentManagedThreadId}"; + => $"keyCache_{this.Alias}_{_cacheScope.Value ?? $"t{Environment.CurrentManagedThreadId}"}"; private string PrepCaches() { if (this.serializer is ISyncCachedSerializer cachedSerializer) cachedSerializer.InitializeCache(); - // make sure the runtime cache is clean. - var key = GetCacheKeyBase(); - - // this also clears the folder cache - as its a starts with call. - runtimeCache.ClearByKey(key); - return key; + return BeginCacheScope(); } private void CleanCaches(string cacheKey) { - runtimeCache.ClearByKey(cacheKey); + EndCacheScope(cacheKey); if (this.serializer is ISyncCachedSerializer cachedSerializer) cachedSerializer.DisposeCache(); + } + /// + /// begin a runtime-cache scope for a single logical operation (import/report/export). + /// + /// + /// the scope id flows with the async operation (see ) so the + /// cache keys stay stable across await/thread hops, and the entries can be reliably + /// cleared when the operation finishes. + /// + /// the base cache key for the scope. + private string BeginCacheScope() + { + _cacheScope.Value = Guid.NewGuid().ToString("N"); + + // this also clears the folder cache - as its a 'starts with' call. + var key = GetCacheKeyBase(); + runtimeCache.ClearByKey(key); + return key; + } + + /// + /// end the runtime-cache scope started by , clearing the + /// entries cached during the operation. + /// + private void EndCacheScope(string cacheKey) + { + runtimeCache.ClearByKey(cacheKey); + _cacheScope.Value = null; } #region roots notifications diff --git a/uSync.BackOffice/uSyncBackOfficeComposer.cs b/uSync.BackOffice/uSyncBackOfficeComposer.cs index 20aa8bec2..abdca6e98 100644 --- a/uSync.BackOffice/uSyncBackOfficeComposer.cs +++ b/uSync.BackOffice/uSyncBackOfficeComposer.cs @@ -18,10 +18,10 @@ public class uSyncBackOfficeComposer : IComposer /// public void Compose(IUmbracoBuilder builder) { - // uSync core will actually run when their is no back office loaded. + // uSync core will actually run when their is no back office loaded. //if (builder.IsUmbracoBackOfficeEnabled() is false) // return; - + builder.AdduSync(); } } diff --git a/uSync.Backoffice.Management.Client/uSyncManifestReader.cs b/uSync.Backoffice.Management.Client/uSyncManifestReader.cs index 1472203a1..741ccda2a 100644 --- a/uSync.Backoffice.Management.Client/uSyncManifestReader.cs +++ b/uSync.Backoffice.Management.Client/uSyncManifestReader.cs @@ -21,7 +21,7 @@ public void Compose(IUmbracoBuilder builder) { if (builder.IsUmbracoBackOfficeEnabled() is false) return; - // only load this when the backoffice is enabled. + // only load this when the backoffice is enabled. builder.Services.AddSingleton(); builder.Services.AddSingleton(); } diff --git a/uSync.Backoffice.Management.Client/usync-assets/src/components/usync-action-box.ts b/uSync.Backoffice.Management.Client/usync-assets/src/components/usync-action-box.ts index 64fb0da1b..aff66fccc 100644 --- a/uSync.Backoffice.Management.Client/usync-assets/src/components/usync-action-box.ts +++ b/uSync.Backoffice.Management.Client/usync-assets/src/components/usync-action-box.ts @@ -64,7 +64,7 @@ export class uSyncActionBox extends UmbLitElement {

- ${this.group?.groupName} + ${this.localize.termOrDefault(`uSync_group${this.group?.groupName}`, this.group?.groupName ?? '')}

diff --git a/uSync.Backoffice.Management.Client/usync-assets/src/components/usync-progress-box.ts b/uSync.Backoffice.Management.Client/usync-assets/src/components/usync-progress-box.ts index 98bc7153e..61b66bb43 100644 --- a/uSync.Backoffice.Management.Client/usync-assets/src/components/usync-progress-box.ts +++ b/uSync.Backoffice.Management.Client/usync-assets/src/components/usync-progress-box.ts @@ -63,6 +63,14 @@ export class uSyncProcessBox extends UmbElementMixin(LitElement) { }, 2000); } + #camelize(str: string) { + return str + .replace(/(?:^\w|[A-Z]|\b\w)/g, function (word: string, index: number) { + return index === 0 ? word.toLowerCase() : word.toUpperCase(); + }) + .replace(/\s+/g, ''); + } + render() { let actions = this.actions; if (!this.actions || this.actions.length === 0) { @@ -79,6 +87,8 @@ export class uSyncProcessBox extends UmbElementMixin(LitElement) { let actionHtml = actions?.map((action) => { if (action.status == HandlerStatus.COMPLETE) progress++; + const actionName = this.#camelize(action.name ?? 'unknown'); + return html`
-

${this.title}

+

${this.localize.termOrDefault(`uSync_group${this.title}`, this.title)}

${actionHtml}
${this.updateMsg?.message}
diff --git a/uSync.Backoffice.Management.Client/usync-assets/src/components/usync-results-group-view.ts b/uSync.Backoffice.Management.Client/usync-assets/src/components/usync-results-group-view.ts index 4a4f785bb..e1e5784fc 100644 --- a/uSync.Backoffice.Management.Client/usync-assets/src/components/usync-results-group-view.ts +++ b/uSync.Backoffice.Management.Client/usync-assets/src/components/usync-results-group-view.ts @@ -42,7 +42,7 @@ export class uSyncResultGroupView extends UmbLitElement {
(this.expanded = !this.expanded)}> -

${this.localize.term('uSync_' + this.groupName)}

+

${this.localize.termOrDefault('uSync_' + this.groupName, this.groupName)}

${changeCount}/${this.results?.length}

`; } diff --git a/uSync.Backoffice.Management.Client/usync-assets/src/dialogs/legacy-modal-element.ts b/uSync.Backoffice.Management.Client/usync-assets/src/dialogs/legacy-modal-element.ts index d757c8e18..5f160b483 100644 --- a/uSync.Backoffice.Management.Client/usync-assets/src/dialogs/legacy-modal-element.ts +++ b/uSync.Backoffice.Management.Client/usync-assets/src/dialogs/legacy-modal-element.ts @@ -30,7 +30,7 @@ export class uSyncLegacyModalElement extends UmbModalBaseElement< renderLegacyFolder(folder: string | null | undefined) { return folder === undefined || folder === null ? nothing - : html`${this.localize.term('uSync.legacyInfo', [folder])}`; + : html`${this.localize.termOrDefault('uSync.legacyInfo', 'uSync has found a legacy uSync folder', folder)}`; } renderLegacyTypes(legacyTypes: Array | undefined) { @@ -43,14 +43,12 @@ export class uSyncLegacyModalElement extends UmbModalBaseElement< }); return html`
- ${this.localize.term('uSync.legacyObsolete', [legacyTypeHtml])} + ${this.localize.termOrDefault('uSync.legacyObsolete', 'Obsolete DataTypes', legacyTypeHtml)}
`; } renderCopy() { - return html`${this.localize.term('uSync.legacyCopy', [ - this.data?.legacyFolder ?? 'uSync/v15', - ])} `; + return html`${this.localize.termOrDefault('uSync.legacyCopy', 'Copy to uSync folder', this.data?.legacyFolder ?? 'uSync/v15')} `; } static styles = css` diff --git a/uSync.Backoffice.Management.Client/usync-assets/src/dialogs/single-import-modal.element.ts b/uSync.Backoffice.Management.Client/usync-assets/src/dialogs/single-import-modal.element.ts index b2c8d35a1..518f3ba4e 100644 --- a/uSync.Backoffice.Management.Client/usync-assets/src/dialogs/single-import-modal.element.ts +++ b/uSync.Backoffice.Management.Client/usync-assets/src/dialogs/single-import-modal.element.ts @@ -65,7 +65,7 @@ export default class SyncImportSingleModalElement extends UmbModalBaseElement< color="positive" type="button" .state=${this.importState} - .label=${this.localize.term('uSync_importSingle')} + .label=${this.localize.termOrDefault('uSync_importSingle', 'Import')} @click="${this.#doImport}">`, )}
diff --git a/uSync.Backoffice.Management.Client/usync-assets/src/lang/files/da-dk.ts b/uSync.Backoffice.Management.Client/usync-assets/src/lang/files/da-dk.ts new file mode 100644 index 000000000..4b2fce8cc --- /dev/null +++ b/uSync.Backoffice.Management.Client/usync-assets/src/lang/files/da-dk.ts @@ -0,0 +1,191 @@ +export default { + uSync: { + section: 'Synkronisering', + name: 'uSync', + banner: 'uSync alt på én gang', + + migrate: 'Migrer', + defaultView: 'Standard', + settingsView: 'Indstillinger', + addons: 'Tilføjelser', + + groupEverything: 'Alt', + groupContent: 'Indhold', + groupSettings: 'Indstillinger', + groupForms: 'Formularer', + groupMedia: 'Medie', + groupMembers: 'Medlemmer', + + Report: 'Rapport', + Import: 'Importer', + Export: 'Eksporter', + ImportForce: 'Importer (Tvungen)', + ExportClean: 'Eksporter (Ren)', + ExportFile: 'Eksporter til fil', + ImportFile: 'Importer fra fil', + + noChange: 'Intet har ændret sig', + showAll: 'Vis alle elementer', + + detailHeadline: 'Registrerede ændringer', + detailHeader: 'Ting der er anderledes', + + runningInBackground: + 'uSync kører denne proces i baggrunden. Hvis du navigerer væk fra denne side, vil den fortsætte med at køre.', + + connectionLost: + 'Forbindelsen til serveren er gået tabt. Processen vil fortsætte med at køre i baggrunden, men du vil ikke se opdateringer her.', + + importSingle: 'Importer', + importSingleWarning: + 'Dette vil importere dette element til Umbraco. Hvis dette element har afhængigheder, vil de ikke blive importeret og skal løses manuelt.', + importSingleSuccess: 'Elementet er blevet importeret korrekt', + importSingleFailed: `

Der opstod en fejl ved import af elementet

%0%`, + + changeAction: 'Handling', + changeItem: 'Element', + changeDiffrence: 'Forskel', + changeCreate: 'Dette element findes ikke i Umbraco og oprettes', + noChangesImport: 'Der blev ikke foretaget ændringer i dette element', + noChangesReport: 'Ingen ændringer registreret', + noChangesDelete: 'Dette element er blevet slettet fra Umbraco', + + importHeader: 'Importer fra fil', + + success: 'Succes', + change: 'Ændring', + changeType: 'Type', + changeName: 'Navn', + changeDetail: 'Detalje', + changeHeading: 'Resultater', + changeCount: '{1}/{0} ændringer', + noChangeCount: '0/{0} ændringer', + + legacyInfo: `

uSync har fundet en ældre uSync-mappe på %0%.
+ Det er sandsynligt, at indholdet skal konverteres på en eller anden måde

`, + + legacyObsolete: `

Forældede datatyper

+
    %0%
+

Du kan konvertere disse datatyper ved hjælp af + uSync.Migrations
+ (I den fulde uSync-udgivelse vil konvertering ske her.)

`, + + legacyCopy: `

Kopiér til uSync/v14

+

Du kan kopiere din %0%-mappe til ~/uSync/v14-mappen
og køre en import.

+

Hvis intet skal konverteres, bør alt importeres korrekt.

+

Fjern eller omdøb %0%-mappen for at forhindre denne popup

`, + + hmacMismatch: `

HMAC-uoverensstemmelse

+

Det ser ud til, at indstillingen Imaging:HMAC, der bruges til at generere filerne i uSync-mappen, ikke stemmer overens med den aktuelle indstilling for dette websted.

+

Billeder i RTE-kontrolelementer vil have HMAC-værdien tilføjet til URL-værdien, og uden yderligere konfiguration vises disse billeder muligvis ikke korrekt.

+
  • Du kan aktivere "HMAC-mapping" i uSync,
  • +
  • eller du kan sikre, at HMAC-værdien i indstillingen Imaging:HMAC stemmer overens med den værdi, der bruges til at generere filerne i uSync-mappen
`, + formatMismatch: + 'Synkroniseringsfilformatversionen stemmer ikke overens med den forventede version. Dette kan indikere et potentielt kompatibilitetsproblem.', + + legacyBanner: + 'Dette websted indeholder filer fra en tidligere version af uSync. Se detaljerne under fanen Ældre.', + + legacyCopyTitle: 'Overskriv v%0%-filer', + legacyCopyContent: + 'Er du sikker på, at du vil overskrive indholdet af mappen %0% med de ældre uSync-mappefilerne?', + + legacyIgnoreTitle: 'Ignorer ældre filer', + legacyIgnoreContent: + 'Er du sikker på, at du vil ignorere filerne i den ældre uSync-mappe?', + + errorHeader: + 'Dette element stødte på en fejl under processen. Detaljerne er nedenfor:', + + uploadIntro: 'Vælg en zip-fil med uSync-filer, som du vil uploade', + uploadSuccess: 'Filerne er blevet uploadet og udtrukket til uSync-mappen', + uploadError: 'Der opstod en fejl ved upload af filerne', + + ILanguage: 'Sprog', + IDictionaryItem: 'Ordbogselementer', + IDataType: 'Datatyper', + ITemplate: 'Skabeloner', + IContentType: 'Indholdstyper', + IMediaType: 'Medietyper', + IMemberType: 'Medlemstyper', + IContent: 'Indhold', + IMedia: 'Medie', + IDomain: 'Domæner', + IWebhook: 'Webhooks', + IRelationType: 'Relationstyper', + MediaFile: 'Mediefiler', + XElement: 'Andet', + LanguageHandler: 'Sprog', + DictionaryHandler: 'Ordbogselementer', + DataTypeHandler: 'Datatyper', + TemplateHandler: 'Skabeloner', + ContentTypeHandler: 'Indholdstyper', + MediaTypeHandler: 'Medietyper', + MemberTypeHandler: 'Medlemstyper', + ContentHandler: 'Indhold', + MediaHandler: 'Medie', + RelationTypeHandler: 'Relationstyper', + EntityContainer: 'Containere', + }, + USyncSettings: { + settings: 'uSync-indstillinger', + filesAndFolders: 'Filer og mapper', + handlerDefaults: 'Handlerstandarder', + + processingMode: 'Behandlingstilstand', + processingModeDesc: + 'Hvordan uSync-processen kører, enten i baggrunden eller interaktivt (Normal)', + + importAtStartup: 'Importer ved opstart', + importAtStartupDesc: 'Kør en import af filer fra disken, når Umbraco starter', + + exportAtStartup: 'Eksporter ved opstart', + exportAtStartupDesc: 'Eksporter Umbraco-indstillingerne, når webstedet starter', + + exportOnSave: 'Eksporter ved gem', + exportOnSaveDesc: 'Generer uSync-filer, når elementer gemmes', + + uiEnabledGroups: 'Aktiverede UI-grupper', + uiEnabledGroupsDesc: 'Handlergrupper, der kan ses/bruges på dashboardet', + + failOnMissingParent: 'Fejl ved manglende overordnet', + failOnMissingParentDesc: 'Fejl ved manglende overordnet element', + + currentHandlerSet: 'Aktuelt sæt', + + handlerSet: 'Standardhandlersæt', + handlerSetDesc: 'Det standardhandlersæt, der bruges for webstedet', + + flatStructure: 'Flad struktur', + flatStructureDesc: 'Alle elementer af en type gemmes i en flad mappestruktur', + + guidNames: 'Brug GUID som filnavne', + guidNamesDesc: 'Brug et elements GUID som filnavn', + + handlerGroups: 'Handlergrupper', + handlerGroupsDesc: 'Grupper til at begrænse handlersættet til', + + disabledHandlers: 'Deaktiverede handlere', + disabledHandlersDesc: 'Handlere eksplicit deaktiveret for dette handlersæt', + + folders: 'Mapper', + foldersDesc: + 'Mapper uSync vil søge efter filer i (elementer gemmes normalt i den sidste mappe på listen)', + + rootSite: 'Rodwebsted', + rootSiteDesc: 'Er dette websted en rod for andre websteder.', + + rootLocked: 'Rod låst', + rootLockedDesc: 'Er ændringer for elementer fra rodwebstedet låst?', + + help: 'Indstillinger styres via filen appsettings.json. Se vores dokumentation', + + bootSettings: 'Indstillinger for første opstart 🥾', + + firstBoot: 'Importer ved første opstart', + firstBootDesc: 'Kør importprocessen ved webstedets første opstart', + + firstBootGroup: 'Grupper for første opstart', + firstBootGroupDesc: 'De grupper, der køres ved første opstart', + }, +}; diff --git a/uSync.Backoffice.Management.Client/usync-assets/src/lang/files/de-de.ts b/uSync.Backoffice.Management.Client/usync-assets/src/lang/files/de-de.ts new file mode 100644 index 000000000..899bc411c --- /dev/null +++ b/uSync.Backoffice.Management.Client/usync-assets/src/lang/files/de-de.ts @@ -0,0 +1,191 @@ +export default { + uSync: { + section: 'Synchronisation', + name: 'uSync', + banner: 'uSync für alles', + + migrate: 'Migrieren', + defaultView: 'Standard', + settingsView: 'Einstellungen', + addons: 'Erweiterungen', + + groupEverything: 'Alles', + groupContent: 'Inhalt', + groupSettings: 'Einstellungen', + groupForms: 'Formulare', + groupMedia: 'Medien', + groupMembers: 'Mitglieder', + + Report: 'Bericht', + Import: 'Importieren', + Export: 'Exportieren', + ImportForce: 'Importieren (Erzwingen)', + ExportClean: 'Exportieren (Bereinigt)', + ExportFile: 'In Datei exportieren', + ImportFile: 'Aus Datei importieren', + + noChange: 'Es hat sich nichts geändert', + showAll: 'Alle Elemente anzeigen', + + detailHeadline: 'Erkannte Änderungen', + detailHeader: 'Was sich unterscheidet', + + runningInBackground: + 'uSync führt diesen Prozess im Hintergrund aus. Wenn Sie diese Seite verlassen, wird er weiterhin ausgeführt.', + + connectionLost: + 'Die Verbindung zum Server wurde unterbrochen. Der Prozess wird im Hintergrund weiter ausgeführt, aber Sie werden hier keine Aktualisierungen sehen.', + + importSingle: 'Importieren', + importSingleWarning: + 'Dadurch wird dieses Element in Umbraco importiert. Wenn dieses Element Abhängigkeiten hat, werden diese nicht importiert und müssen manuell aufgelöst werden.', + importSingleSuccess: 'Das Element wurde erfolgreich importiert', + importSingleFailed: `

Beim Importieren des Elements ist ein Fehler aufgetreten

%0%`, + + changeAction: 'Aktion', + changeItem: 'Element', + changeDiffrence: 'Unterschied', + changeCreate: 'Dieses Element existiert nicht in Umbraco und wird erstellt', + noChangesImport: 'Es wurden keine Änderungen an diesem Element vorgenommen', + noChangesReport: 'Keine Änderungen erkannt', + noChangesDelete: 'Dieses Element wurde aus Umbraco gelöscht', + + importHeader: 'Aus Datei importieren', + + success: 'Erfolg', + change: 'Änderung', + changeType: 'Typ', + changeName: 'Name', + changeDetail: 'Detail', + changeHeading: 'Ergebnisse', + changeCount: '{1}/{0} Änderungen', + noChangeCount: '0/{0} Änderungen', + + legacyInfo: `

uSync hat einen Legacy-uSync-Ordner unter %0% gefunden.
+ Der Inhalt muss wahrscheinlich konvertiert werden

`, + + legacyObsolete: `

Veraltete Datentypen

+
    %0%
+

Sie können diese Datentypen mit + uSync.Migrations konvertieren
+ (In der vollständigen uSync-Version wird die Konvertierung hier stattfinden.)

`, + + legacyCopy: `

Nach uSync/v14 kopieren

+

Sie können Ihren %0%-Ordner in den ~/uSync/v14-Ordner kopieren
und einen Import ausführen.

+

Wenn nichts konvertiert werden muss, sollte alles korrekt importiert werden.

+

Entfernen oder benennen Sie den %0%-Ordner um, um dieses Popup zu verhindern

`, + + hmacMismatch: `

HMAC-Abweichung

+

Es scheint, dass die Imaging:HMAC-Einstellung, die zum Generieren der Dateien im uSync-Ordner verwendet wurde, nicht mit der aktuellen Einstellung dieser Website übereinstimmt.

+

Bilder in RTE-Steuerelementen haben den HMAC-Wert an die URL angehängt. Ohne zusätzliche Konfiguration werden diese Bilder möglicherweise nicht korrekt dargestellt.

+
  • Sie können die "HMAC-Zuordnung" in uSync aktivieren,
  • +
  • oder Sie können sicherstellen, dass der HMAC-Wert in der Imaging:HMAC-Einstellung mit dem Wert übereinstimmt, der zum Generieren der Dateien im uSync-Ordner verwendet wurde
`, + formatMismatch: + 'Die Formatversion der Synchronisierungsdatei stimmt nicht mit der erwarteten Version überein. Dies kann auf ein potenzielles Kompatibilitätsproblem hinweisen.', + + legacyBanner: + 'Diese Website enthält Dateien einer früheren Version von uSync. Details finden Sie auf der Registerkarte Legacy.', + + legacyCopyTitle: 'v%0%-Dateien überschreiben', + legacyCopyContent: + 'Möchten Sie den Inhalt des Ordners %0% wirklich mit den Legacy-uSync-Ordnerdateien überschreiben?', + + legacyIgnoreTitle: 'Legacy-Dateien ignorieren', + legacyIgnoreContent: + 'Möchten Sie die Dateien im Legacy-uSync-Ordner wirklich ignorieren?', + + errorHeader: + 'Bei diesem Element ist während des Prozesses ein Fehler aufgetreten. Die Details sind unten aufgeführt:', + + uploadIntro: 'Wählen Sie eine ZIP-Datei mit uSync-Dateien aus, die Sie hochladen möchten', + uploadSuccess: 'Die Dateien wurden hochgeladen und in den uSync-Ordner extrahiert', + uploadError: 'Beim Hochladen der Dateien ist ein Fehler aufgetreten', + + ILanguage: 'Sprache', + IDictionaryItem: 'Wörterbuchelemente', + IDataType: 'Datentypen', + ITemplate: 'Vorlagen', + IContentType: 'Inhaltstypen', + IMediaType: 'Medientypen', + IMemberType: 'Mitgliedstypen', + IContent: 'Inhalt', + IMedia: 'Medien', + IDomain: 'Domänen', + IWebhook: 'Webhooks', + IRelationType: 'Beziehungstypen', + MediaFile: 'Mediendateien', + XElement: 'Sonstiges', + LanguageHandler: 'Sprachen', + DictionaryHandler: 'Wörterbuchelemente', + DataTypeHandler: 'Datentypen', + TemplateHandler: 'Vorlagen', + ContentTypeHandler: 'Inhaltstypen', + MediaTypeHandler: 'Medientypen', + MemberTypeHandler: 'Mitgliedstypen', + ContentHandler: 'Inhalt', + MediaHandler: 'Medien', + RelationTypeHandler: 'Beziehungstypen', + EntityContainer: 'Container', + }, + USyncSettings: { + settings: 'uSync-Einstellungen', + filesAndFolders: 'Dateien und Ordner', + handlerDefaults: 'Handler-Standardwerte', + + processingMode: 'Verarbeitungsmodus', + processingModeDesc: + 'Wie der uSync-Prozess ausgeführt wird, entweder im Hintergrund oder interaktiv (Normal)', + + importAtStartup: 'Beim Start importieren', + importAtStartupDesc: 'Einen Import von Dateien von der Festplatte beim Start von Umbraco ausführen', + + exportAtStartup: 'Beim Start exportieren', + exportAtStartupDesc: 'Die Umbraco-Einstellungen beim Start der Website exportieren', + + exportOnSave: 'Beim Speichern exportieren', + exportOnSaveDesc: 'uSync-Dateien generieren, wenn Elemente gespeichert werden', + + uiEnabledGroups: 'UI-aktivierte Gruppen', + uiEnabledGroupsDesc: 'Handler-Gruppen, die im Dashboard angezeigt/verwendet werden können', + + failOnMissingParent: 'Fehler bei fehlendem Elternelement', + failOnMissingParentDesc: 'Fehler bei fehlendem Elternelement', + + currentHandlerSet: 'Aktuelles Set', + + handlerSet: 'Standard-Handler-Set', + handlerSetDesc: 'Das Standard-Handler-Set für die Website', + + flatStructure: 'Flache Struktur', + flatStructureDesc: 'Alle Elemente eines Typs werden in einer flachen Ordnerstruktur gespeichert', + + guidNames: 'GUIDs als Dateinamen verwenden', + guidNamesDesc: 'Die GUID eines Elements als Dateiname verwenden', + + handlerGroups: 'Handler-Gruppen', + handlerGroupsDesc: 'Gruppen zur Einschränkung des Handler-Sets', + + disabledHandlers: 'Deaktivierte Handler', + disabledHandlersDesc: 'Handler, die für dieses Handler-Set explizit deaktiviert sind', + + folders: 'Ordner', + foldersDesc: + 'Ordner, in denen uSync nach Dateien sucht (Elemente werden normalerweise im letzten Ordner der Liste gespeichert)', + + rootSite: 'Root-Website', + rootSiteDesc: 'Ist diese Website ein Root für andere Websites.', + + rootLocked: 'Root gesperrt', + rootLockedDesc: 'Sind Änderungen an Elementen der Root-Website gesperrt?', + + help: 'Die Einstellungen werden über die Datei appsettings.json gesteuert. Siehe unsere Dokumentation', + + bootSettings: 'Einstellungen für den ersten Start 🥾', + + firstBoot: 'Beim ersten Start importieren', + firstBootDesc: 'Den Importprozess beim ersten Start der Website ausführen', + + firstBootGroup: 'Gruppen für den ersten Start', + firstBootGroupDesc: 'Die Gruppen, die beim ersten Start ausgeführt werden', + }, +}; diff --git a/uSync.Backoffice.Management.Client/usync-assets/src/lang/files/en-us.ts b/uSync.Backoffice.Management.Client/usync-assets/src/lang/files/en-us.ts index a4a9a0ae9..25e83f335 100644 --- a/uSync.Backoffice.Management.Client/usync-assets/src/lang/files/en-us.ts +++ b/uSync.Backoffice.Management.Client/usync-assets/src/lang/files/en-us.ts @@ -4,6 +4,18 @@ export default { name: 'uSync', banner: 'uSync all the things', + migrate: 'Migrate', + defaultView: 'Default', + settingsView: 'Settings', + addons: 'Add-ons', + + groupEverything: 'Everything', + groupContent: 'Content', + groupSettings: 'Settings', + groupForms: 'Forms', + groupMedia: 'Media', + groupMembers: 'Members', + Report: 'Report', Import: 'Import', Export: 'Export', diff --git a/uSync.Backoffice.Management.Client/usync-assets/src/lang/files/es-es.ts b/uSync.Backoffice.Management.Client/usync-assets/src/lang/files/es-es.ts new file mode 100644 index 000000000..23afd411f --- /dev/null +++ b/uSync.Backoffice.Management.Client/usync-assets/src/lang/files/es-es.ts @@ -0,0 +1,191 @@ +export default { + uSync: { + section: 'Sincronización', + name: 'uSync', + banner: 'uSync para todo', + + migrate: 'Migrar', + defaultView: 'Predeterminado', + settingsView: 'Configuración', + addons: 'Complementos', + + groupEverything: 'Todo', + groupContent: 'Contenido', + groupSettings: 'Configuración', + groupForms: 'Formularios', + groupMedia: 'Medios', + groupMembers: 'Miembros', + + Report: 'Informe', + Import: 'Importar', + Export: 'Exportar', + ImportForce: 'Importar (Forzar)', + ExportClean: 'Exportar (Limpio)', + ExportFile: 'Exportar a archivo', + ImportFile: 'Importar desde archivo', + + noChange: 'Nada ha cambiado', + showAll: 'Mostrar todos los elementos', + + detailHeadline: 'Cambios detectados', + detailHeader: 'Cosas que son diferentes', + + runningInBackground: + 'uSync está ejecutando este proceso en segundo plano. Si navega fuera de esta página, continuará ejecutándose.', + + connectionLost: + 'Se ha perdido la conexión con el servidor. El proceso continuará ejecutándose en segundo plano, pero no verá actualizaciones aquí.', + + importSingle: 'Importar', + importSingleWarning: + 'Esto importará este elemento en Umbraco. Si este elemento tiene dependencias, no serán importadas y deberán resolverse manualmente.', + importSingleSuccess: 'El elemento se ha importado correctamente', + importSingleFailed: `

Se produjo un error al importar el elemento

%0%`, + + changeAction: 'Acción', + changeItem: 'Elemento', + changeDiffrence: 'Diferencia', + changeCreate: 'Este elemento no existe en Umbraco y está siendo creado', + noChangesImport: 'No se realizaron cambios en este elemento', + noChangesReport: 'No se detectaron cambios', + noChangesDelete: 'Este elemento ha sido eliminado de Umbraco', + + importHeader: 'Importar desde archivo', + + success: 'Éxito', + change: 'Cambio', + changeType: 'Tipo', + changeName: 'Nombre', + changeDetail: 'Detalle', + changeHeading: 'Resultados', + changeCount: '{1}/{0} cambios', + noChangeCount: '0/{0} cambios', + + legacyInfo: `

uSync ha encontrado una carpeta uSync heredada en %0%.
+ Es probable que su contenido necesite convertirse de alguna manera

`, + + legacyObsolete: `

Tipos de datos obsoletos

+
    %0%
+

Puede convertir estos tipos de datos usando + uSync.Migrations
+ (En la versión completa de uSync, la conversión se realizará aquí.)

`, + + legacyCopy: `

Copiar a uSync/v14

+

Puede copiar su carpeta %0% a la carpeta ~/uSync/v14
y ejecutar una importación.

+

Si nada necesita convertirse, todo debería importarse correctamente.

+

Elimine o cambie el nombre de la carpeta %0% para evitar este popup

`, + + hmacMismatch: `

Discrepancia HMAC

+

Parece que la configuración Imaging:HMAC utilizada para generar los archivos en la carpeta uSync no coincide con la configuración actual de este sitio.

+

Las imágenes dentro de los controles RTE tendrán el valor HMAC añadido a la URL, y sin configuración adicional, estas imágenes pueden no mostrarse correctamente.

+
  • Puede activar el "mapeo HMAC" en uSync,
  • +
  • o puede asegurarse de que el valor HMAC en la configuración Imaging:HMAC coincida con el valor utilizado para generar los archivos en la carpeta uSync
`, + formatMismatch: + 'La versión del formato del archivo de sincronización no coincide con la versión esperada. Esto puede indicar un posible problema de compatibilidad.', + + legacyBanner: + 'Este sitio contiene archivos de una versión anterior de uSync. Consulte los detalles en la pestaña Heredado.', + + legacyCopyTitle: 'Sobrescribir archivos v%0%', + legacyCopyContent: + '¿Está seguro de que desea sobrescribir el contenido de la carpeta %0% con los archivos de la carpeta uSync heredada?', + + legacyIgnoreTitle: 'Ignorar archivos heredados', + legacyIgnoreContent: + '¿Está seguro de que desea ignorar los archivos en la carpeta uSync heredada?', + + errorHeader: + 'Este elemento encontró un error durante el proceso. Los detalles se muestran a continuación:', + + uploadIntro: 'Seleccione un archivo zip que contenga los archivos uSync que desea cargar', + uploadSuccess: 'Los archivos han sido cargados y extraídos en la carpeta uSync', + uploadError: 'Se produjo un error al cargar los archivos', + + ILanguage: 'Idioma', + IDictionaryItem: 'Elementos del diccionario', + IDataType: 'Tipos de datos', + ITemplate: 'Plantillas', + IContentType: 'Tipos de contenido', + IMediaType: 'Tipos de medios', + IMemberType: 'Tipos de miembros', + IContent: 'Contenido', + IMedia: 'Medios', + IDomain: 'Dominios', + IWebhook: 'Webhooks', + IRelationType: 'Tipos de relaciones', + MediaFile: 'Archivos de medios', + XElement: 'Otro', + LanguageHandler: 'Idiomas', + DictionaryHandler: 'Elementos del diccionario', + DataTypeHandler: 'Tipos de datos', + TemplateHandler: 'Plantillas', + ContentTypeHandler: 'Tipos de contenido', + MediaTypeHandler: 'Tipos de medios', + MemberTypeHandler: 'Tipos de miembros', + ContentHandler: 'Contenido', + MediaHandler: 'Medios', + RelationTypeHandler: 'Tipos de relaciones', + EntityContainer: 'Contenedores', + }, + USyncSettings: { + settings: 'Configuración de uSync', + filesAndFolders: 'Archivos y carpetas', + handlerDefaults: 'Valores predeterminados del controlador', + + processingMode: 'Modo de procesamiento', + processingModeDesc: + 'Cómo se ejecuta el proceso uSync, ya sea en segundo plano o de forma interactiva (Normal)', + + importAtStartup: 'Importar al inicio', + importAtStartupDesc: 'Ejecutar una importación de archivos desde el disco cuando Umbraco se inicia', + + exportAtStartup: 'Exportar al inicio', + exportAtStartupDesc: 'Exportar la configuración de Umbraco cuando el sitio se inicia', + + exportOnSave: 'Exportar al guardar', + exportOnSaveDesc: 'Generar archivos uSync cuando se guardan elementos', + + uiEnabledGroups: 'Grupos habilitados en la interfaz', + uiEnabledGroupsDesc: 'Grupos de controladores que se pueden ver/usar en el panel', + + failOnMissingParent: 'Error si falta el padre', + failOnMissingParentDesc: 'Error si falta el elemento padre', + + currentHandlerSet: 'Conjunto actual', + + handlerSet: 'Conjunto de controladores predeterminado', + handlerSetDesc: 'El conjunto de controladores predeterminado a usar para el sitio', + + flatStructure: 'Estructura plana', + flatStructureDesc: 'Todos los elementos de un tipo se almacenan en una estructura de carpetas plana', + + guidNames: 'Usar GUID como nombres de archivo', + guidNamesDesc: 'Usar el GUID de un elemento como nombre de archivo', + + handlerGroups: 'Grupos de controladores', + handlerGroupsDesc: 'Grupos para limitar el conjunto de controladores', + + disabledHandlers: 'Controladores deshabilitados', + disabledHandlersDesc: 'Controladores explícitamente deshabilitados para este conjunto de controladores', + + folders: 'Carpetas', + foldersDesc: + 'Carpetas donde uSync buscará archivos (los elementos normalmente se guardan en la última carpeta de la lista)', + + rootSite: 'Sitio raíz', + rootSiteDesc: 'Este sitio es una raíz para otros sitios.', + + rootLocked: 'Raíz bloqueada', + rootLockedDesc: '¿Están bloqueados los cambios de los elementos que provienen del sitio raíz?', + + help: 'La configuración se controla mediante el archivo appsettings.json. Ver nuestra documentación', + + bootSettings: 'Configuración del primer arranque 🥾', + + firstBoot: 'Importar en el primer arranque', + firstBootDesc: 'Ejecutar el proceso de importación en el primer arranque del sitio', + + firstBootGroup: 'Grupos del primer arranque', + firstBootGroupDesc: 'Los grupos a ejecutar en el primer arranque', + }, +}; diff --git a/uSync.Backoffice.Management.Client/usync-assets/src/lang/files/fr-fr.ts b/uSync.Backoffice.Management.Client/usync-assets/src/lang/files/fr-fr.ts new file mode 100644 index 000000000..0f16f573c --- /dev/null +++ b/uSync.Backoffice.Management.Client/usync-assets/src/lang/files/fr-fr.ts @@ -0,0 +1,191 @@ +export default { + uSync: { + section: 'Synchronisation', + name: 'uSync', + banner: 'uSync pour tout synchroniser', + + migrate: 'Migrer', + defaultView: 'Défaut', + settingsView: 'Paramètres', + addons: 'Extensions', + + groupEverything: 'Tout', + groupContent: 'Contenu', + groupSettings: 'Paramètres', + groupForms: 'Formulaires', + groupMedia: 'Médias', + groupMembers: 'Membres', + + Report: 'Rapport', + Import: 'Importer', + Export: 'Exporter', + ImportForce: 'Importer (Forcer)', + ExportClean: 'Exporter (Propre)', + ExportFile: 'Exporter vers fichier', + ImportFile: 'Importer depuis fichier', + + noChange: 'Rien n\'a changé', + showAll: 'Afficher tous les éléments', + + detailHeadline: 'Modifications détectées', + detailHeader: 'Ce qui est différent', + + runningInBackground: + 'uSync exécute ce processus en arrière-plan. Si vous quittez cette page, il continuera de fonctionner.', + + connectionLost: + 'La connexion au serveur a été perdue. Le processus continuera de s\'exécuter en arrière-plan, mais vous ne verrez pas les mises à jour ici.', + + importSingle: 'Importer', + importSingleWarning: + 'Cela importera cet élément dans Umbraco. Si cet élément a des dépendances, elles ne seront pas importées et devront être résolues manuellement.', + importSingleSuccess: 'L\'élément a été importé avec succès', + importSingleFailed: `

Une erreur s'est produite lors de l'importation de l'élément

%0%`, + + changeAction: 'Action', + changeItem: 'Élément', + changeDiffrence: 'Différence', + changeCreate: 'Cet élément n\'existe pas dans Umbraco et est en cours de création', + noChangesImport: 'Aucune modification n\'a été apportée à cet élément', + noChangesReport: 'Aucune modification détectée', + noChangesDelete: 'Cet élément a été supprimé d\'Umbraco', + + importHeader: 'Importer depuis un fichier', + + success: 'Succès', + change: 'Modification', + changeType: 'Type', + changeName: 'Nom', + changeDetail: 'Détail', + changeHeading: 'Résultats', + changeCount: '{1}/{0} modifications', + noChangeCount: '0/{0} modifications', + + legacyInfo: `

uSync a trouvé un dossier uSync hérité à %0%.
+ Il est probable que son contenu devra être converti d'une façon ou d'une autre

`, + + legacyObsolete: `

Types de données obsolètes

+
    %0%
+

Vous pouvez convertir ces types de données en utilisant + uSync.Migrations
+ (Dans la version complète d'uSync, la conversion se fera ici.)

`, + + legacyCopy: `

Copier vers uSync/v14

+

Vous pouvez copier votre dossier %0% vers le dossier ~/uSync/v14
et exécuter une importation.

+

Si rien ne nécessite de conversion, tout devrait s'importer correctement.

+

Supprimez ou renommez le dossier %0% pour éviter cette popup

`, + + hmacMismatch: `

Incompatibilité HMAC

+

Il semble que le paramètre Imaging:HMAC utilisé pour générer les fichiers dans le dossier uSync ne corresponde pas au paramètre actuel de ce site.

+

Les images dans les contrôles RTE auront la valeur HMAC ajoutée à l'URL, et sans configuration supplémentaire, ces images pourraient ne pas s'afficher correctement.

+
  • Vous pouvez activer le "mappage HMAC" dans uSync,
  • +
  • ou vous pouvez vous assurer que la valeur HMAC dans le paramètre Imaging:HMAC correspond à la valeur utilisée pour générer les fichiers dans le dossier uSync
`, + formatMismatch: + 'La version du format du fichier de synchronisation ne correspond pas à la version attendue. Cela peut indiquer un problème de compatibilité potentiel.', + + legacyBanner: + 'Ce site contient des fichiers d\'une version précédente d\'uSync. Consultez les détails dans l\'onglet Héritage.', + + legacyCopyTitle: 'Écraser les fichiers v%0%', + legacyCopyContent: + 'Êtes-vous sûr de vouloir écraser le contenu du dossier %0% avec les fichiers du dossier uSync hérité ?', + + legacyIgnoreTitle: 'Ignorer les fichiers hérités', + legacyIgnoreContent: + 'Êtes-vous sûr de vouloir ignorer les fichiers dans le dossier uSync hérité ?', + + errorHeader: + 'Cet élément a rencontré une erreur lors du processus. Les détails sont ci-dessous :', + + uploadIntro: 'Sélectionnez un fichier zip contenant les fichiers uSync que vous souhaitez télécharger', + uploadSuccess: 'Les fichiers ont été téléchargés et extraits dans le dossier uSync', + uploadError: 'Une erreur s\'est produite lors du téléchargement des fichiers', + + ILanguage: 'Langue', + IDictionaryItem: 'Éléments du dictionnaire', + IDataType: 'Types de données', + ITemplate: 'Modèles', + IContentType: 'Types de contenu', + IMediaType: 'Types de médias', + IMemberType: 'Types de membres', + IContent: 'Contenu', + IMedia: 'Média', + IDomain: 'Domaines', + IWebhook: 'Webhooks', + IRelationType: 'Types de relations', + MediaFile: 'Fichiers médias', + XElement: 'Autre', + LanguageHandler: 'Langues', + DictionaryHandler: 'Éléments du dictionnaire', + DataTypeHandler: 'Types de données', + TemplateHandler: 'Modèles', + ContentTypeHandler: 'Types de contenu', + MediaTypeHandler: 'Types de médias', + MemberTypeHandler: 'Types de membres', + ContentHandler: 'Contenu', + MediaHandler: 'Média', + RelationTypeHandler: 'Types de relations', + EntityContainer: 'Conteneurs', + }, + USyncSettings: { + settings: 'Paramètres uSync', + filesAndFolders: 'Fichiers et dossiers', + handlerDefaults: 'Paramètres par défaut des gestionnaires', + + processingMode: 'Mode de traitement', + processingModeDesc: + 'Comment le processus uSync s\'exécute, soit en arrière-plan, soit de manière interactive (Normal)', + + importAtStartup: 'Importer au démarrage', + importAtStartupDesc: 'Exécuter une importation de fichiers depuis le disque au démarrage d\'Umbraco', + + exportAtStartup: 'Exporter au démarrage', + exportAtStartupDesc: 'Exporter les paramètres Umbraco au démarrage du site', + + exportOnSave: 'Exporter à l\'enregistrement', + exportOnSaveDesc: 'Générer des fichiers uSync lors de l\'enregistrement des éléments', + + uiEnabledGroups: 'Groupes activés dans l\'interface', + uiEnabledGroupsDesc: 'Groupes de gestionnaires visibles/utilisables sur le tableau de bord', + + failOnMissingParent: 'Échec si parent manquant', + failOnMissingParentDesc: 'Échec si l\'élément parent est manquant', + + currentHandlerSet: 'Ensemble actuel', + + handlerSet: 'Ensemble de gestionnaires par défaut', + handlerSetDesc: 'L\'ensemble de gestionnaires par défaut à utiliser pour le site', + + flatStructure: 'Structure plate', + flatStructureDesc: 'Tous les éléments d\'un type sont stockés dans une structure de dossiers plate', + + guidNames: 'Utiliser les GUID comme noms de fichiers', + guidNamesDesc: 'Utiliser le GUID d\'un élément comme nom de fichier', + + handlerGroups: 'Groupes de gestionnaires', + handlerGroupsDesc: 'Groupes pour limiter l\'ensemble de gestionnaires', + + disabledHandlers: 'Gestionnaires désactivés', + disabledHandlersDesc: 'Gestionnaires explicitement désactivés pour cet ensemble de gestionnaires', + + folders: 'Dossiers', + foldersDesc: + 'Dossiers dans lesquels uSync recherchera des fichiers (les éléments sont normalement enregistrés dans le dernier dossier de la liste)', + + rootSite: 'Site racine', + rootSiteDesc: 'Ce site est-il une racine pour d\'autres sites.', + + rootLocked: 'Racine verrouillée', + rootLockedDesc: 'Les modifications des éléments provenant du site racine sont-elles verrouillées ?', + + help: 'Les paramètres sont contrôlés via le fichier appsettings.json. Voir notre documentation', + + bootSettings: 'Paramètres du premier démarrage 🥾', + + firstBoot: 'Importer au premier démarrage', + firstBootDesc: 'Exécuter le processus d\'importation au premier démarrage du site', + + firstBootGroup: 'Groupes du premier démarrage', + firstBootGroupDesc: 'Les groupes à exécuter au premier démarrage', + }, +}; diff --git a/uSync.Backoffice.Management.Client/usync-assets/src/lang/files/nl-nl.ts b/uSync.Backoffice.Management.Client/usync-assets/src/lang/files/nl-nl.ts new file mode 100644 index 000000000..61133c2fe --- /dev/null +++ b/uSync.Backoffice.Management.Client/usync-assets/src/lang/files/nl-nl.ts @@ -0,0 +1,191 @@ +export default { + uSync: { + section: 'Synchronisatie', + name: 'uSync', + banner: 'uSync voor alles', + + migrate: 'Migreren', + defaultView: 'Standaard', + settingsView: 'Instellingen', + addons: 'Add-ons', + + groupEverything: 'Alles', + groupContent: 'Inhoud', + groupSettings: 'Instellingen', + groupForms: 'Formulieren', + groupMedia: 'Media', + groupMembers: 'Leden', + + Report: 'Rapport', + Import: 'Importeren', + Export: 'Exporteren', + ImportForce: 'Importeren (Forceren)', + ExportClean: 'Exporteren (Schoon)', + ExportFile: 'Exporteren naar bestand', + ImportFile: 'Importeren vanuit bestand', + + noChange: 'Er is niets veranderd', + showAll: 'Alle items weergeven', + + detailHeadline: 'Gedetecteerde wijzigingen', + detailHeader: 'Wat er anders is', + + runningInBackground: + 'uSync voert dit proces op de achtergrond uit. Als u van deze pagina navigeert, blijft het doorlopen.', + + connectionLost: + 'De verbinding met de server is verbroken. Het proces blijft op de achtergrond draaien, maar u ziet hier geen updates.', + + importSingle: 'Importeren', + importSingleWarning: + 'Dit importeert dit item in Umbraco. Als dit item afhankelijkheden heeft, worden deze niet geïmporteerd en moeten handmatig worden opgelost.', + importSingleSuccess: 'Het item is succesvol geïmporteerd', + importSingleFailed: `

Er is een fout opgetreden bij het importeren van het item

%0%`, + + changeAction: 'Actie', + changeItem: 'Item', + changeDiffrence: 'Verschil', + changeCreate: 'Dit item bestaat niet in Umbraco en wordt aangemaakt', + noChangesImport: 'Er zijn geen wijzigingen aangebracht aan dit item', + noChangesReport: 'Geen wijzigingen gedetecteerd', + noChangesDelete: 'Dit item is verwijderd uit Umbraco', + + importHeader: 'Importeren vanuit bestand', + + success: 'Succes', + change: 'Wijziging', + changeType: 'Type', + changeName: 'Naam', + changeDetail: 'Detail', + changeHeading: 'Resultaten', + changeCount: '{1}/{0} wijzigingen', + noChangeCount: '0/{0} wijzigingen', + + legacyInfo: `

uSync heeft een verouderde uSync-map gevonden op %0%.
+ Het is waarschijnlijk dat de inhoud op de een of andere manier geconverteerd moet worden

`, + + legacyObsolete: `

Verouderde gegevenstypen

+
    %0%
+

U kunt deze gegevenstypen converteren met + uSync.Migrations
+ (In de volledige uSync-release zal de conversie hier plaatsvinden.)

`, + + legacyCopy: `

Kopiëren naar uSync/v14

+

U kunt uw %0%-map kopiëren naar de ~/uSync/v14-map
en een import uitvoeren.

+

Als er niets geconverteerd hoeft te worden, zou alles correct moeten importeren.

+

Verwijder of hernoem de %0%-map om deze popup te voorkomen

`, + + hmacMismatch: `

HMAC-mismatch

+

Het lijkt erop dat de Imaging:HMAC-instelling die werd gebruikt om de bestanden in de uSync-map te genereren, niet overeenkomt met de huidige instelling voor deze site.

+

Afbeeldingen in RTE-besturingselementen hebben de HMAC-waarde toegevoegd aan de URL-waarde, en zonder aanvullende configuratie worden deze afbeeldingen mogelijk niet correct weergegeven.

+
  • U kunt "HMAC-mapping" inschakelen in uSync,
  • +
  • of u kunt ervoor zorgen dat de HMAC-waarde in de Imaging:HMAC-instelling overeenkomt met de waarde die werd gebruikt om de bestanden in de uSync-map te genereren
`, + formatMismatch: + 'De versie van het synchronisatiebestandsformaat komt niet overeen met de verwachte versie. Dit kan wijzen op een mogelijk compatibiliteitsprobleem.', + + legacyBanner: + 'Deze site bevat bestanden van een eerdere versie van uSync. Bekijk de details op het tabblad Verouderd.', + + legacyCopyTitle: 'v%0%-bestanden overschrijven', + legacyCopyContent: + 'Weet u zeker dat u de inhoud van de map %0% wilt overschrijven met de verouderde uSync-mapbestanden?', + + legacyIgnoreTitle: 'Verouderde bestanden negeren', + legacyIgnoreContent: + 'Weet u zeker dat u de bestanden in de verouderde uSync-map wilt negeren?', + + errorHeader: + 'Dit item heeft een fout ondervonden tijdens het proces. De details staan hieronder:', + + uploadIntro: 'Selecteer een zip-bestand met uSync-bestanden die u wilt uploaden', + uploadSuccess: 'De bestanden zijn geüpload en uitgepakt naar de uSync-map', + uploadError: 'Er is een fout opgetreden bij het uploaden van de bestanden', + + ILanguage: 'Taal', + IDictionaryItem: 'Woordenboekitems', + IDataType: 'Gegevenstypen', + ITemplate: 'Sjablonen', + IContentType: 'Inhoudstypen', + IMediaType: 'Mediatypen', + IMemberType: 'Ledentypen', + IContent: 'Inhoud', + IMedia: 'Media', + IDomain: 'Domeinen', + IWebhook: 'Webhooks', + IRelationType: 'Relatietypen', + MediaFile: 'Mediabestanden', + XElement: 'Overig', + LanguageHandler: 'Talen', + DictionaryHandler: 'Woordenboekitems', + DataTypeHandler: 'Gegevenstypen', + TemplateHandler: 'Sjablonen', + ContentTypeHandler: 'Inhoudstypen', + MediaTypeHandler: 'Mediatypen', + MemberTypeHandler: 'Ledentypen', + ContentHandler: 'Inhoud', + MediaHandler: 'Media', + RelationTypeHandler: 'Relatietypen', + EntityContainer: 'Containers', + }, + USyncSettings: { + settings: 'uSync-instellingen', + filesAndFolders: 'Bestanden en mappen', + handlerDefaults: 'Handler-standaardwaarden', + + processingMode: 'Verwerkingsmodus', + processingModeDesc: + 'Hoe het uSync-proces wordt uitgevoerd, op de achtergrond of interactief (Normaal)', + + importAtStartup: 'Importeren bij opstarten', + importAtStartupDesc: 'Een import van bestanden van de schijf uitvoeren wanneer Umbraco start', + + exportAtStartup: 'Exporteren bij opstarten', + exportAtStartupDesc: 'De Umbraco-instellingen exporteren wanneer de site opstart', + + exportOnSave: 'Exporteren bij opslaan', + exportOnSaveDesc: 'uSync-bestanden genereren wanneer items worden opgeslagen', + + uiEnabledGroups: 'UI-ingeschakelde groepen', + uiEnabledGroupsDesc: 'Handlergroepen die zichtbaar/bruikbaar zijn op het dashboard', + + failOnMissingParent: 'Fout bij ontbrekend bovenliggend item', + failOnMissingParentDesc: 'Fout bij ontbrekend bovenliggend item', + + currentHandlerSet: 'Huidige set', + + handlerSet: 'Standaard handlerset', + handlerSetDesc: 'De standaard handlerset voor de site', + + flatStructure: 'Platte structuur', + flatStructureDesc: 'Alle items van een type worden opgeslagen in een platte mappenstructuur', + + guidNames: 'GUID\'s als bestandsnamen gebruiken', + guidNamesDesc: 'De GUID van een item als bestandsnaam gebruiken', + + handlerGroups: 'Handlergroepen', + handlerGroupsDesc: 'Groepen om de handlerset te beperken', + + disabledHandlers: 'Uitgeschakelde handlers', + disabledHandlersDesc: 'Handlers die expliciet zijn uitgeschakeld voor deze handlerset', + + folders: 'Mappen', + foldersDesc: + 'Mappen waarin uSync naar bestanden zoekt (items worden normaal opgeslagen in de laatste map in de lijst)', + + rootSite: 'Rootsite', + rootSiteDesc: 'Is deze site een root voor andere sites.', + + rootLocked: 'Root vergrendeld', + rootLockedDesc: 'Zijn wijzigingen voor items van de rootsite vergrendeld?', + + help: 'Instellingen worden beheerd via het bestand appsettings.json. Zie onze documentatie', + + bootSettings: 'Instellingen voor eerste opstart 🥾', + + firstBoot: 'Importeren bij eerste opstart', + firstBootDesc: 'Het importproces uitvoeren bij de eerste opstart van de site', + + firstBootGroup: 'Groepen voor eerste opstart', + firstBootGroupDesc: 'De groepen die worden uitgevoerd bij de eerste opstart', + }, +}; diff --git a/uSync.Backoffice.Management.Client/usync-assets/src/lang/manifest.ts b/uSync.Backoffice.Management.Client/usync-assets/src/lang/manifest.ts index 756cc2bad..f63e24440 100644 --- a/uSync.Backoffice.Management.Client/usync-assets/src/lang/manifest.ts +++ b/uSync.Backoffice.Management.Client/usync-assets/src/lang/manifest.ts @@ -9,6 +9,56 @@ const localizations: Array = [ }, js: () => import('./files/en-us'), }, + { + type: 'localization', + alias: 'usync.lang.dadk', + name: 'Danish', + weight: 0, + meta: { + culture: 'da', + }, + js: () => import('./files/da-dk'), + }, + { + type: 'localization', + alias: 'usync.lang.frfr', + name: 'French', + weight: 0, + meta: { + culture: 'fr', + }, + js: () => import('./files/fr-fr'), + }, + { + type: 'localization', + alias: 'usync.lang.eses', + name: 'Spanish', + weight: 0, + meta: { + culture: 'es', + }, + js: () => import('./files/es-es'), + }, + { + type: 'localization', + alias: 'usync.lang.dede', + name: 'German', + weight: 0, + meta: { + culture: 'de', + }, + js: () => import('./files/de-de'), + }, + { + type: 'localization', + alias: 'usync.lang.nlnl', + name: 'Dutch', + weight: 0, + meta: { + culture: 'nl', + }, + js: () => import('./files/nl-nl'), + }, ]; export const manifests: UmbExtensionManifest[] = [...localizations]; diff --git a/uSync.Backoffice.Management.Client/usync-assets/src/tree/manifest.ts b/uSync.Backoffice.Management.Client/usync-assets/src/tree/manifest.ts index 750619e46..adf1916f3 100644 --- a/uSync.Backoffice.Management.Client/usync-assets/src/tree/manifest.ts +++ b/uSync.Backoffice.Management.Client/usync-assets/src/tree/manifest.ts @@ -64,7 +64,7 @@ const menuSidebarApp: UmbExtensionManifest = { name: 'uSync section sidebar menu', weight: 150, meta: { - label: 'Synchronisation', + label: '#uSync_section', menu: menu.alias, }, conditions: [ diff --git a/uSync.Backoffice.Management.Client/usync-assets/src/workspace/components/usync-action-button.ts b/uSync.Backoffice.Management.Client/usync-assets/src/workspace/components/usync-action-button.ts index ec76cd68e..9292483e8 100644 --- a/uSync.Backoffice.Management.Client/usync-assets/src/workspace/components/usync-action-button.ts +++ b/uSync.Backoffice.Management.Client/usync-assets/src/workspace/components/usync-action-button.ts @@ -46,7 +46,7 @@ export class SyncActionButtonElement extends UmbLitElement { return html` this.button?.color} look=${this.button?.look} state=${ifDefined(this.state)} @@ -63,7 +63,7 @@ export class SyncActionButtonElement extends UmbLitElement { const buttons = this.button?.children.map((item: SyncActionButton) => { return html` this.#onClick(item)}>`; }); @@ -79,7 +79,7 @@ export class SyncActionButtonElement extends UmbLitElement { color=${parent?.color} look=${parent?.look} @click=${() => this.#onClick(this.button)}> - ${this.localize.term(`uSync_${this.button?.label}`)} + ${this.localize.termOrDefault(`uSync_${this.button?.label}`, this.button?.label ?? '')} { render() { return html` - + ${this.renderForm()} ${this.renderResult()} `; @@ -38,7 +38,7 @@ export class uSyncImportModalDialog extends UmbModalBaseElement { renderForm() { if (this.result !== undefined) return; - return html` ${this.localize.term('uSync_uploadIntro')} + return html` ${this.localize.termOrDefault('uSync_uploadIntro', 'Select a zip file containing uSync files that you want to upload')}
{ return html`${when( this.result.success, - () => html`${this.localize.term('uSync_uploadSuccess')}`, - () => html`${this.localize.term('uSync_uploadError')} ${this.result?.errors}`, + () => html`${this.localize.termOrDefault('uSync_uploadSuccess', 'The files have been uploaded and extracted to the uSync folder')}`, + () => html`${this.localize.termOrDefault('uSync_uploadError', 'There was an error uploading the files')} ${this.result?.errors}`, )}
diff --git a/uSync.Backoffice.Management.Client/usync-assets/src/workspace/manifest.ts b/uSync.Backoffice.Management.Client/usync-assets/src/workspace/manifest.ts index 9ce5a601c..7f7b688d4 100644 --- a/uSync.Backoffice.Management.Client/usync-assets/src/workspace/manifest.ts +++ b/uSync.Backoffice.Management.Client/usync-assets/src/workspace/manifest.ts @@ -38,7 +38,7 @@ const workspaceViews: Array = [ js: () => import('./views/default/default.element.js'), weight: 300, meta: { - label: 'Default', + label: '#uSync_defaultView', pathname: 'default', icon: 'usync-logo', }, @@ -56,7 +56,7 @@ const workspaceViews: Array = [ js: () => import('./views/settings/settings.element.js'), weight: 200, meta: { - label: 'Settings', + label: '#uSync_settingsView', pathname: 'settings', icon: 'icon-settings', }, @@ -74,7 +74,7 @@ const workspaceViews: Array = [ js: () => import('./views/addons/addons.element.js'), weight: 100, meta: { - label: 'AddOns', + label: '#uSync_addons', pathname: 'addons', icon: 'icon-box', }, @@ -92,7 +92,7 @@ const workspaceViews: Array = [ js: () => import('./views/legacy/legacy.element.js'), weight: 150, meta: { - label: 'Migrate', + label: '#uSync_migrate', pathname: 'legacy', icon: 'icon-arrow-up', }, diff --git a/uSync.Backoffice.Management.Client/usync-assets/src/workspace/views/default/default.element.ts b/uSync.Backoffice.Management.Client/usync-assets/src/workspace/views/default/default.element.ts index dc83d335f..4e278bd7b 100644 --- a/uSync.Backoffice.Management.Client/usync-assets/src/workspace/views/default/default.element.ts +++ b/uSync.Backoffice.Management.Client/usync-assets/src/workspace/views/default/default.element.ts @@ -243,11 +243,11 @@ export class uSyncDefaultViewElement extends UmbLitElement { return html`
${this.localize.termOrDefault('USyncSettings_currentHandlerSet', 'Current Set')} { const select = e.target as HTMLSelectElement; @@ -264,7 +264,7 @@ export class uSyncDefaultViewElement extends UmbLitElement { : html`
- ${this.localize.term('uSync_legacyBanner')} + ${this.localize.termOrDefault('uSync_legacyBanner', 'This site contains files from a previous version of uSync, view the details in the legacy tab.')}
`; } @@ -333,15 +333,15 @@ export class uSyncDefaultViewElement extends UmbLitElement { if (!this._connected) { return html` `; } return html``; } diff --git a/uSync.Backoffice.Management.Client/usync-assets/src/workspace/views/legacy/legacy.element.ts b/uSync.Backoffice.Management.Client/usync-assets/src/workspace/views/legacy/legacy.element.ts index ced4c8734..c3000bd1b 100644 --- a/uSync.Backoffice.Management.Client/usync-assets/src/workspace/views/legacy/legacy.element.ts +++ b/uSync.Backoffice.Management.Client/usync-assets/src/workspace/views/legacy/legacy.element.ts @@ -42,12 +42,8 @@ export class SyncLegacyFilesElement extends UmbLitElement { const confirmContext = modalContext?.open(this, UMB_CONFIRM_MODAL, { data: { - headline: this.localize.term('uSync_legacyCopyTitle', [ - this._legacy?.latestVersion, - ]), - content: html`${this.localize.term('uSync_legacyCopyContent', [ - this._legacy?.latestFolder, - ])}`, + headline: this.localize.termOrDefault('uSync_legacyCopyTitle', 'Overwrite files', this._legacy?.latestVersion), + content: html`${this.localize.termOrDefault('uSync_legacyCopyContent', 'Are you sure you want to overwrite the contents of the folder with the legacy uSync folder files?', this._legacy?.latestFolder)}`, color: 'danger', confirmLabel: 'Copy', }, @@ -68,8 +64,8 @@ export class SyncLegacyFilesElement extends UmbLitElement { const modalContext = await this.getContext(UMB_MODAL_MANAGER_CONTEXT); const confirmContext = modalContext?.open(this, UMB_CONFIRM_MODAL, { data: { - headline: this.localize.term('uSync_legacyIgnoreTitle'), - content: html`${this.localize.term('uSync_legacyIgnoreContent')}`, + headline: this.localize.termOrDefault('uSync_legacyIgnoreTitle', 'Ignore legacy files'), + content: html`${this.localize.termOrDefault('uSync_legacyIgnoreContent', 'Are you sure you want to ignore the files in the legacy uSync folder?')}`, color: 'danger', confirmLabel: 'Ignore', }, diff --git a/uSync.Backoffice.Management.Client/usync-assets/src/workspace/views/settings/settings.element.ts b/uSync.Backoffice.Management.Client/usync-assets/src/workspace/views/settings/settings.element.ts index 0f39855e2..e4f2f2784 100644 --- a/uSync.Backoffice.Management.Client/usync-assets/src/workspace/views/settings/settings.element.ts +++ b/uSync.Backoffice.Management.Client/usync-assets/src/workspace/views/settings/settings.element.ts @@ -49,105 +49,107 @@ export class USyncSettingsViewElement extends UmbElementMixin(LitElement) {
- + - +
- + - + ${when( this.settings?.importOnFirstBoot, () => html` `, )} diff --git a/uSync.Core/Cache/SyncEntityCache.cs b/uSync.Core/Cache/SyncEntityCache.cs index 709dace78..e3e4400dd 100644 --- a/uSync.Core/Cache/SyncEntityCache.cs +++ b/uSync.Core/Cache/SyncEntityCache.cs @@ -39,7 +39,10 @@ public SyncEntityCache( public CachedName? GetName(int id) { if (!_cacheEnabled) return default; - return cache.GetCacheItem(id.ToString()); + // read from nameCache - this is where AddName stores CachedName values. + // (reading from `cache` returned IEntitySlim entries under the same key, + // which threw a swallowed InvalidCastException and never actually cached). + return nameCache.GetCacheItem(id.ToString()); } public void AddName(int id, Guid guid, string name) diff --git a/uSync.Core/Extensions/ConversionExtensions.cs b/uSync.Core/Extensions/ConversionExtensions.cs index 30fb394d6..3ca1616df 100644 --- a/uSync.Core/Extensions/ConversionExtensions.cs +++ b/uSync.Core/Extensions/ConversionExtensions.cs @@ -1,20 +1,16 @@ -using Umbraco.Extensions; - -namespace uSync.Core.Extensions; +namespace uSync.Core.Extensions; internal static class ConversionExtensions { public static TObject? GetValueAs(this object value) { if (value == null) return default; - var attempt = value.TryConvertTo(); - if (!attempt) return default; - return attempt.Result; + return value.TryGetValueAs(out var result) ? result : default; } public static Guid ConvertToGuid(this int value) { - byte[] bytes = new byte[16]; - BitConverter.GetBytes(value).CopyTo(bytes, 0); + Span bytes = stackalloc byte[16]; + BitConverter.TryWriteBytes(bytes, value); return new Guid(bytes); } } diff --git a/uSync.Core/Extensions/JsonTextExtensions.cs b/uSync.Core/Extensions/JsonTextExtensions.cs index cd6029aa2..ee4edfd8e 100644 --- a/uSync.Core/Extensions/JsonTextExtensions.cs +++ b/uSync.Core/Extensions/JsonTextExtensions.cs @@ -1,4 +1,5 @@ -using System.Diagnostics.CodeAnalysis; +using System.Buffers; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -389,12 +390,74 @@ public static bool TrySerializeJsonString(this object value, [MaybeNull] out str public static string SerializeJsonString(this object value, bool indent = true) => value is null ? string.Empty : JsonSerializer.Serialize(value, indent ? _defaultOptions : _flatOptions); - private static bool TryGetValueAs(this object value, [MaybeNullWhen(false)] out TObject result) + /// + /// Convert a value to the requested type. + /// + /// + /// Pre-empts the first-chance InvalidCastException that Umbraco's TryConvertTo + /// throws when converting a JsonElement to a value type (see uSync.Complete + /// issue #304). Settings/config values often arrive as JsonElement (bound from + /// appsettings.json); doing that conversion with System.Text.Json first means the + /// common path never throws. String conversions (which TryConvertTo already + /// handles cleanly) and anything STJ can't handle still fall back to TryConvertTo. + /// + public static bool TryGetValueAs(this object? value, [MaybeNullWhen(false)] out TObject result) { result = default; - if (value == null) return false; + if (value is null) return false; + + // Umbraco's TryConvertTo turns a JsonElement into a string cleanly, but throws + // (and swallows) an InvalidCastException for JsonElement -> value type. Do the + // value-type conversion with System.Text.Json first to avoid that noise; string + // and anything STJ can't handle fall through to TryConvertTo below. + if (value is JsonElement element && typeof(TObject) != typeof(string)) + { + try + { + result = element.Deserialize(_defaultOptions); + if (result is not null) return true; + } + catch + { + // not something STJ could convert directly - fall back to TryConvertTo below. + } + } + var attempt = value.TryConvertTo(); - if (attempt is false || attempt.Result is null) return attempt; + if (attempt.Success is false || attempt.Result is null) return false; + + result = attempt.Result; + return true; + } + + /// + /// Convert a value to the requested runtime type. + /// + /// + /// Non-generic companion to the generic TryGetValueAs for callers that only + /// have a runtime Type. Same JsonElement pre-check. + /// + public static bool TryGetValueAs(this object? value, Type targetType, [MaybeNullWhen(false)] out object result) + { + result = default; + if (value is null) return false; + + if (value is JsonElement element && targetType != typeof(string)) + { + try + { + result = element.Deserialize(targetType, _defaultOptions); + if (result is not null) return true; + } + catch + { + // not something STJ could convert directly - fall back to TryConvertTo below. + } + } + + var attempt = value.TryConvertTo(targetType); + if (attempt.Success is false || attempt.Result is null) return false; + result = attempt.Result; return true; } @@ -460,8 +523,7 @@ public static TResult GetPropertyValueOrDefault(this JsonObject obj, st if (obj.TryGetPropertyValue(propertyName, out var value) is false || value is null) return defaultValue; - var attempt = value.TryConvertTo(); - return attempt.ResultOr(defaultValue); + return value.TryGetValueAs(out var result) ? result : defaultValue; } public static bool TryGetPropertyAsArray(this JsonObject jsonObject, string propertyName, [MaybeNullWhen(false)] out JsonArray result) @@ -511,11 +573,24 @@ public static JsonArray GetPropertyAsArray(this JsonObject obj, string propertyN /// tells us if the json for an object is equal, helps when the config objects don't have their /// own Equals functions ///
- public static bool IsJsonEqual(this object currentObject, object newObject) + public static bool IsJsonEqual(this object? currentObject, object? newObject) { - var currentString = currentObject.SerializeJsonString(false); - var newString = newObject.SerializeJsonString(false); - return currentString == newString; + if (currentObject is null && newObject is null) + return true; + if (currentObject is null) + return false; + if (newObject is null) + return false; + + ArrayBufferWriter currentObjectBufferWriter = new(); + using Utf8JsonWriter currentObjectUtf8JsonWriter = new(currentObjectBufferWriter); + JsonSerializer.Serialize(currentObjectUtf8JsonWriter, currentObject, _flatOptions); + + ArrayBufferWriter newObjectBufferWriter = new(); + using Utf8JsonWriter newObjectUtf8JsonWriter = new(newObjectBufferWriter); + JsonSerializer.Serialize(newObjectUtf8JsonWriter, newObject, _flatOptions); + + return currentObjectBufferWriter.WrittenSpan.SequenceEqual(newObjectBufferWriter.WrittenSpan); } #endregion diff --git a/uSync.Core/Extensions/ListExtensions.cs b/uSync.Core/Extensions/ListExtensions.cs index e4005fdfd..d22b01496 100644 --- a/uSync.Core/Extensions/ListExtensions.cs +++ b/uSync.Core/Extensions/ListExtensions.cs @@ -1,5 +1,7 @@ using Umbraco.Extensions; +using uSync.Core.Extensions; + namespace uSync.Core; public static class ListExtensions @@ -40,10 +42,9 @@ internal static IEnumerable ConvertItems(this IList items) foreach (var item in items) { if (string.IsNullOrWhiteSpace(item)) continue; - var attempt = item.TryConvertTo(); - if (attempt.Success && attempt.Result is not null) + if (item.TryGetValueAs(out var result)) { - yield return attempt.Result; + yield return result; } } } diff --git a/uSync.Core/Extensions/ObjectPropertyExtensions.cs b/uSync.Core/Extensions/ObjectPropertyExtensions.cs index e7bcc9a2a..cd28752eb 100644 --- a/uSync.Core/Extensions/ObjectPropertyExtensions.cs +++ b/uSync.Core/Extensions/ObjectPropertyExtensions.cs @@ -75,11 +75,6 @@ private static TValue GetPropertyAs(PropertyInfo info, object property, var value = info.GetValue(property); if (value == null) return defaultValue; - var result = value.TryConvertTo(); - if (result.Success) - return result.Result ?? defaultValue; - - return defaultValue; - + return value.TryGetValueAs(out var result) ? result : defaultValue; } } diff --git a/uSync.Core/Extensions/StringExtensions.cs b/uSync.Core/Extensions/StringExtensions.cs index 72b7cd33e..5e11f5305 100644 --- a/uSync.Core/Extensions/StringExtensions.cs +++ b/uSync.Core/Extensions/StringExtensions.cs @@ -62,7 +62,7 @@ public static string ToShortKeyString(this Guid guid, int length = 26) // a Guid is 3 blocks + 8 bits // so it turns into a 3*8+2 = 26 chars string - var chars = new char[length]; + Span chars = stackalloc char[length]; var i = 0; var j = 0; diff --git a/uSync.Core/Extensions/XElementExtensions.cs b/uSync.Core/Extensions/XElementExtensions.cs index 968ca832e..c8c49565c 100644 --- a/uSync.Core/Extensions/XElementExtensions.cs +++ b/uSync.Core/Extensions/XElementExtensions.cs @@ -7,6 +7,8 @@ using Umbraco.Extensions; +using uSync.Core.Extensions; + namespace uSync.Core; public static class XElementExtensions @@ -157,13 +159,39 @@ public static string ValueOrDefault([AllowNull] this XElement? node, string defa public static TObject ValueOrDefault([AllowNull] this XElement? node, TObject defaultValue) { var value = node.ValueOrDefault(string.Empty); - if (value == string.Empty) return defaultValue; + if (value.Length == 0) return defaultValue; + + return value.ConvertOrDefault(defaultValue); + } + + /// + /// Convert a non-empty string value to the requested type. + /// + /// + /// These getters are called for (almost) every attribute of every node during a + /// report/import. The handful of value types actually used have direct, allocation + /// free parsers that are much cheaper than routing through Umbraco's reflection based + /// TryConvertTo. The typeof(TObject) == typeof(...) comparisons are folded to + /// constants by the JIT per generic instantiation, so the branches have no runtime + /// cost. Anything not matched falls through to TryGetValueAs. + /// + private static TObject ConvertOrDefault(this string value, TObject defaultValue) + { + if (typeof(TObject) == typeof(int)) + return int.TryParse(value, out var i) ? (TObject)(object)i : defaultValue; - var attempt = value.TryConvertTo(); - if (attempt) - return attempt.Result ?? defaultValue; + if (typeof(TObject) == typeof(Guid)) + return Guid.TryParse(value, out var g) ? (TObject)(object)g : defaultValue; - return defaultValue; + if (typeof(TObject) == typeof(bool)) + return bool.TryParse(value, out var b) ? (TObject)(object)b : defaultValue; + + if (typeof(TObject).IsEnum) + return Enum.TryParse(typeof(TObject), value, true, out var e) && e is TObject enumValue + ? enumValue + : defaultValue; + + return value.TryGetValueAs(out var result) ? result : defaultValue; } @@ -230,8 +258,7 @@ public static void CreateOrSetElement(this XElement node, string name, { if (node is null) return; - var attempt = value.TryConvertTo(); - if (attempt.Success) + if (value.TryGetValueAs(out var stringValue)) { var element = node.Element(name); if (element is null) @@ -240,7 +267,7 @@ public static void CreateOrSetElement(this XElement node, string name, node.Add(element); } - element.Value = attempt.Result ?? string.Empty; + element.Value = stringValue ?? string.Empty; } } @@ -308,13 +335,9 @@ public static string ValueOrDefault([AllowNull] this XAttribute? attribute, stri public static TObject ValueOrDefault([AllowNull] this XAttribute attribute, TObject defaultValue) { var value = attribute.ValueOrDefault(string.Empty); - if (value == string.Empty) return defaultValue; + if (value.Length == 0) return defaultValue; - var attempt = value.TryConvertTo(); - if (attempt) - return attempt.Result ?? defaultValue; - - return defaultValue; + return value.ConvertOrDefault(defaultValue); } #endregion @@ -335,17 +358,17 @@ public static TObject ValueOrDefault([AllowNull] this XAttribute attrib /// public static async Task MakePlatformSafeHashAsync(this XElement node) { - using (MemoryStream stream = new MemoryStream()) - { - await node.SaveAsync(stream, SaveOptions.None, CancellationToken.None); - stream.Seek(0, SeekOrigin.Begin); + using HashAlgorithm hashAlgorithm = CryptoConfig.AllowOnlyFipsAlgorithms ? SHA1.Create() : MD5.Create(); - using (HashAlgorithm hashAlgorithm = CryptoConfig.AllowOnlyFipsAlgorithms ? SHA1.Create() : MD5.Create()) - { - var hash = await hashAlgorithm.ComputeHashAsync(stream); - return Convert.ToHexStringLower(hash); - } + // stream the xml straight into the hash instead of buffering the whole + // serialized document into a MemoryStream first. CryptoStream feeds each + // written block to the algorithm as it arrives, so nothing is held in memory. + using (var cryptoStream = new CryptoStream(Stream.Null, hashAlgorithm, CryptoStreamMode.Write)) + { + await node.SaveAsync(cryptoStream, SaveOptions.None, CancellationToken.None); } + + return Convert.ToHexStringLower(hashAlgorithm.Hash!); } } diff --git a/uSync.Core/Mapping/Mappers/MediaPicker3Mapper.cs b/uSync.Core/Mapping/Mappers/MediaPicker3Mapper.cs index 0c08a0ec2..97103d03a 100644 --- a/uSync.Core/Mapping/Mappers/MediaPicker3Mapper.cs +++ b/uSync.Core/Mapping/Mappers/MediaPicker3Mapper.cs @@ -88,9 +88,8 @@ private static Guid GetGuidValue(JsonObject obj, string key) { if (obj != null && obj.ContainsKey(key)) { - var attempt = obj[key]?.ToString().TryConvertTo(); - if (attempt?.Success is true) - return attempt?.Result ?? Guid.Empty; + if (obj[key]?.ToString().TryGetValueAs(out var guid) is true) + return guid; } return Guid.Empty; diff --git a/uSync.Core/Mapping/Mappers/MemberGroupPickerManager.cs b/uSync.Core/Mapping/Mappers/MemberGroupPickerManager.cs index 4ebafba27..14eee395c 100644 --- a/uSync.Core/Mapping/Mappers/MemberGroupPickerManager.cs +++ b/uSync.Core/Mapping/Mappers/MemberGroupPickerManager.cs @@ -3,6 +3,7 @@ using Umbraco.Extensions; using uSync.Core.Dependency; +using uSync.Core.Extensions; using uSync.Core.Serialization; using static Umbraco.Cms.Core.Constants; @@ -30,11 +31,10 @@ public MemberGroupPickerMapper( ///
public override async Task GetExportValueAsync(object value, string editorAlias) { - var attempt = value.TryConvertTo(); - if (attempt.Success is false || attempt.Result is null) + if (value.TryGetValueAs(out var stringValue) is false) return await base.GetExportValueAsync(value, editorAlias); - var values = attempt.Result.ToDelimitedList().ConvertItems(); + var values = stringValue.ToDelimitedList().ConvertItems(); var groups = new List(); @@ -86,11 +86,10 @@ public override async Task> GetDependenciesAsync(ob return Enumerable.Empty(); // get the int value and load the group - var attempt = value.TryConvertTo(); - if (attempt.Success is false || attempt.Result is null) + if (value.TryGetValueAs(out var stringValue) is false) return await base.GetDependenciesAsync(value, editorAlias, flags); - var values = attempt.Result.ToDelimitedList().ConvertItems(); + var values = stringValue.ToDelimitedList().ConvertItems(); var dependencies = new List(); diff --git a/uSync.Core/Mapping/SyncValueMapperBase.cs b/uSync.Core/Mapping/SyncValueMapperBase.cs index 97fa70256..36cfe02a6 100644 --- a/uSync.Core/Mapping/SyncValueMapperBase.cs +++ b/uSync.Core/Mapping/SyncValueMapperBase.cs @@ -115,10 +115,7 @@ protected IEnumerable CreateDependencies(IEnumerable ud protected static TObject? GetValueAs(object value) { if (value == null) return default; - var attempt = value.TryConvertTo(); - if (!attempt) return default; - - return attempt.Result; + return value.TryGetValueAs(out var result) ? result : default; } } diff --git a/uSync.Core/Serialization/Serializers/ContentSerializerBase.cs b/uSync.Core/Serialization/Serializers/ContentSerializerBase.cs index 9d199a746..70e9c9a3f 100644 --- a/uSync.Core/Serialization/Serializers/ContentSerializerBase.cs +++ b/uSync.Core/Serialization/Serializers/ContentSerializerBase.cs @@ -142,6 +142,18 @@ protected virtual async Task SerializeInfoAsync(TObject item, SyncSeri parentKey = parent.Key; parentName = parent.Name; } + else + { + // the parent might not be of our object type (e.g a blueprint's + // parent can be a DocumentBlueprintContainer, not a DocumentBlueprint) + // so fall back to an untyped lookup rather than losing the parent entirely. + var entity = syncMappers.EntityCache.GetEntity(item.ParentId); + if (entity != null) + { + parentKey = entity.Key; + parentName = entity.Name ?? parentName; + } + } } } @@ -319,6 +331,17 @@ protected override async Task> CanDeserializeAsync(XElement protected abstract int RecycleBinId { get; } + /// + /// last chance creation of a missing parent - used by blueprints to create + /// the DocumentBlueprintContainer folder chain the blueprint lives in. + /// + /// + /// returns null by default, serializers that can create their own parents + /// (e.g the blueprint serializer) override this. + /// + protected virtual Task CreateParentIfMissingAsync(XElement parentNode, string path) + => Task.FromResult(null); + /// /// calculate what the parent, path and level should be for this item, based on the info in the file, and the current state of the system. /// @@ -349,6 +372,11 @@ protected override async Task> CanDeserializeAsync(XElement logger.LogDebug("Find Parent failed, will search by path {FriendlyPath}", friendlyPath); parentItem = await FindParentByPathAsync(friendlyPath); + + // last chance - let the serializer create the parent if it can + // (blueprints create the missing DocumentBlueprintContainer folders). + parentItem ??= await CreateParentIfMissingAsync(parentNode, friendlyPath); + return (parentItem?.Id ?? parentId, nodePath, nodeLevel); } @@ -660,8 +688,8 @@ private static bool IsUpdatedValue(object? current, object? newValue) if (current != null && newValue != null && current.GetType() != newValue.GetType()) { var currentType = current.GetType(); - var attempt = newValue.TryConvertTo(currentType); - if (attempt.Success) return !current.Equals(attempt.Result); + if (newValue.TryGetValueAs(currentType, out var converted)) + return !current.Equals(converted); } return true; @@ -907,6 +935,21 @@ private string GetFriendlyPath(string path) (item.Name ?? item.Id.ToString()).ToSafeAlias(shortStringHelper)); } + // some paths contain ids for items that are not of our object type + // (e.g a blueprint's parent can be a DocumentBlueprintContainer, not + // a DocumentBlueprint) - so for anything still unresolved, fall back + // to an untyped lookup rather than leaving the raw id in the path. + var unresolvedIds = lookups.Where(id => items.All(x => x.Id != id)); + foreach (var id in unresolvedIds) + { + var entity = syncMappers.EntityCache.GetEntity(id); + if (entity == null) continue; + + AddToNameCache(entity.Id, entity.Key, entity.Name ?? entity.Id.ToString()); + friendlyPath = friendlyPath.Replace($"[{entity.Id}]", + (entity.Name ?? entity.Id.ToString()).ToSafeAlias(shortStringHelper)); + } + return friendlyPath; } catch (Exception ex) diff --git a/uSync.Core/Serialization/Serializers/ContentTemplateSerializer.cs b/uSync.Core/Serialization/Serializers/ContentTemplateSerializer.cs index 5f8c19a35..32539fd6e 100644 --- a/uSync.Core/Serialization/Serializers/ContentTemplateSerializer.cs +++ b/uSync.Core/Serialization/Serializers/ContentTemplateSerializer.cs @@ -7,11 +7,13 @@ using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; +using Umbraco.Extensions; using uSync.Core.Documents; using uSync.Core.Extensions; using uSync.Core.Mapping; using uSync.Core.Models; +using uSync.Core.Serialization.Models; namespace uSync.Core.Serialization.Serializers; @@ -19,6 +21,7 @@ namespace uSync.Core.Serialization.Serializers; public class ContentTemplateSerializer : ContentSerializer, ISyncSerializer { private readonly IContentTypeService _contentTypeService; + private readonly IContentBlueprintContainerService _containerService; public ContentTemplateSerializer( IEntityService entityService, @@ -31,10 +34,12 @@ public ContentTemplateSerializer( SyncValueMapperCollection syncMappers, IUserService userService, ITemplateService templateService, - ISyncDocumentUrlCleaner urlCleaner) + ISyncDocumentUrlCleaner urlCleaner, + IContentBlueprintContainerService containerService) : base(entityService, languageService, relationService, shortStringHelper, logger, contentService, syncMappers, userService, templateService, urlCleaner) { _contentTypeService = contentTypeService; + _containerService = containerService; this.umbracoObjectType = UmbracoObjectTypes.DocumentBlueprint; } @@ -111,6 +116,7 @@ protected override async Task> DeserializeCoreAsync(XEleme // TODO: Umbraco 8 bug, the key is sometimes an old version var entity = entityService.Get(key); if (entity != null) + return contentService.GetBlueprintById(entity.Id); return null; @@ -125,18 +131,124 @@ protected override async Task> DeserializeCoreAsync(XEleme if (contentType == null) return Attempt.Fail(null, new ArgumentException($"Missing content Type {itemType}")); - IContent item; - if (parent != null) - { - item = new Content(alias, (IContent)parent, contentType); - } - else + // parent can be either an existing blueprint (unlikely) or the + // DocumentBlueprintContainer (folder) the blueprint lives in, so + // we create by id rather than assuming it's always an IContent. + var item = new Content(alias, parent?.Id ?? -1, contentType); + + return Attempt.Succeed(item); + }); + } + + protected override async Task> FindOrCreateAsync(XElement node) + { + var item = await FindItemAsync(node); + if (item is not null) return Attempt.Succeed(item); + + var info = node.Element(uSyncConstants.Xml.Info); + var alias = node.GetAlias(); + + var parentNode = info?.Element(uSyncConstants.Xml.Parent); + var parentKey = parentNode?.Attribute(uSyncConstants.Xml.Key).ValueOrDefault(Guid.Empty) ?? Guid.Empty; + + ITreeEntity? parent = null; + + if (parentKey != Guid.Empty) + { + item = await FindItemAsync(alias, parentKey); + if (item is not null) return Attempt.Succeed(item); + + // the parent might be another blueprint, or the + // DocumentBlueprintContainer (folder) the blueprint lives in. + parent = await FindItemAsync(parentKey); + parent ??= await FindContainerAsync(parentKey); + parent ??= await FindOrCreateContainerAsync(parentKey, parentNode?.Value ?? string.Empty, + info?.Element(uSyncConstants.Xml.Path).ValueOrDefault(string.Empty) ?? string.Empty); + } + + var contentTypeAlias = info?.Element("ContentType").ValueOrDefault(node.Name.LocalName) ?? node.Name.LocalName; + + return await CreateItemAsync(alias, parent, contentTypeAlias); + } + + + // a blueprint's parent can be the DocumentBlueprintContainer (folder) it + // lives in rather than another blueprint, so include containers when we + // resolve the parent for path/level calculations. + protected override async Task FindItemAsParent(Guid key) + { + var parent = await base.FindItemAsParent(key); + if (parent is not null) return parent; + + var container = await FindContainerAsync(key); + return container is null ? null : ToParentItem(container); + } + + private async Task FindContainerAsync(Guid key) + => await _containerService.GetAsync(key); + + protected override async Task CreateParentIfMissingAsync(XElement parentNode, string path) + { + var key = parentNode.Attribute(uSyncConstants.Xml.Key).ValueOrDefault(Guid.Empty); + var name = parentNode.ValueOrDefault(string.Empty); + if (key == Guid.Empty || string.IsNullOrEmpty(name)) return null; + + var container = await FindOrCreateContainerAsync(key, name, path); + return container is null ? null : ToParentItem(container); + } + + private static SyncParentItem ToParentItem(EntityContainer container) + => new() + { + Id = container.Id, + Key = container.Key, + Name = container.Name ?? container.Id.ToString(), + Path = container.Path, + Level = container.Level + }; + + /// + /// find (or create) the DocumentBlueprintContainer folder chain for a blueprint, + /// the same way missing folders get created on the way in for content types / data types. + /// + private async Task FindOrCreateContainerAsync(Guid key, string name, string friendlyPath) + { + var container = await FindContainerAsync(key); + if (container is not null) return container; + + if (string.IsNullOrWhiteSpace(name)) return null; + + // the friendly path is '/folder/folder/blueprintName' - the folder chain + // is everything except the blueprint's own name at the end. + var folderNames = friendlyPath.ToDelimitedList("/").ToList(); + if (folderNames.Count > 0) folderNames.RemoveAt(folderNames.Count - 1); + if (folderNames.Count == 0) folderNames.Add(name); + + EntityContainer? parent = null; + + for (var index = 0; index < folderNames.Count; index++) + { + var folderName = folderNames[index]; + var isTargetFolder = index == folderNames.Count - 1; + + var existing = (await _containerService.GetAsync(folderName, index + 1)) + .FirstOrDefault(x => x.Name.InvariantEquals(folderName) && + (parent == null || x.ParentId == parent.Id)); + + if (existing is not null) { - item = new Content(alias, -1, contentType); + parent = existing; + continue; } - return Attempt.Succeed(item); - }); + var containerKey = isTargetFolder ? key : Guid.NewGuid(); + var attempt = await _containerService.CreateAsync(containerKey, folderName, parent?.Key, Constants.Security.SuperUserKey); + if (!attempt.Success || attempt.Result is null) return parent; + + parent = attempt.Result; + } + + return parent; } protected override Task> DoSaveOrPublishAsync(IContent item, XElement node, SyncSerializerOptions options) diff --git a/uSync.Core/Serialization/Serializers/ContentTypeBaseSerializer.cs b/uSync.Core/Serialization/Serializers/ContentTypeBaseSerializer.cs index 4fa1acefe..c28b12763 100644 --- a/uSync.Core/Serialization/Serializers/ContentTypeBaseSerializer.cs +++ b/uSync.Core/Serialization/Serializers/ContentTypeBaseSerializer.cs @@ -153,17 +153,15 @@ protected void SerializeNewProperty(XElement node, IPropertyType propert { var value = propertyInfo.GetValue(property); - var attempt = value.TryConvertTo(); - if (attempt.Success) + // TryGetValueAs treats a null conversion result as failure, so fall back + // to an empty element - the property still gets recorded in the xml. + if (value.TryGetValueAs(out var converted)) { - if (attempt.Result != null) - { - node.Add(new XElement(propertyName, attempt.Result)); - } - else - { - node.Add(new XElement(propertyName, string.Empty)); - } + node.Add(new XElement(propertyName, converted)); + } + else + { + node.Add(new XElement(propertyName, string.Empty)); } } } @@ -727,19 +725,18 @@ protected void AddAlias(string alias) if (propertyInfo != null) { var value = node.Element(propertyName).ValueOrDefault(string.Empty); - var attempt = value.TryConvertTo(); - if (attempt.Success) + if (value.TryGetValueAs(out var converted)) { var current = ContentTypeBaseSerializer.GetPropertyAs(propertyInfo, property); - if (current == null || !current.Equals(attempt.Result)) + if (current == null || !current.Equals(converted)) { - propertyInfo.SetValue(property, attempt.Result); + propertyInfo.SetValue(property, converted); return uSyncChange.Update($"property/{propertyName}", propertyName, current.ToNonBlankValue(), - attempt.Result?.ToString()); + converted?.ToString()); } } } @@ -754,12 +751,7 @@ protected void AddAlias(string alias) var value = info.GetValue(property); if (value == null) return default; - var result = value.TryConvertTo(); - if (result.Success) - return result.Result; - - return default; - + return value.TryGetValueAs(out var result) ? result : default; } diff --git a/uSync.Core/Serialization/Serializers/ContentTypeSerializer.cs b/uSync.Core/Serialization/Serializers/ContentTypeSerializer.cs index 4f78e8c3b..f0d367b58 100644 --- a/uSync.Core/Serialization/Serializers/ContentTypeSerializer.cs +++ b/uSync.Core/Serialization/Serializers/ContentTypeSerializer.cs @@ -420,15 +420,14 @@ private List DeserializeCleanupHistory(IContentType item, XElement var current = GetPropertyAs(property, historyCleanup); if (element.Value != current) { - // now set it. - var updatedValue = element.Value.TryConvertTo(property.PropertyType); - if (updatedValue.Success) + // now set it. + if (element.Value.TryGetValueAs(property.PropertyType, out var updatedValue)) { if (logger.IsEnabled(LogLevel.Debug)) - logger.LogDebug("Saving HistoryCleanup Value: {name} {value}", element.Name.LocalName, updatedValue.Result); + logger.LogDebug("Saving HistoryCleanup Value: {name} {value}", element.Name.LocalName, updatedValue); - changes.AddUpdate($"{_historyCleanupName}:{element.Name.LocalName}", current.ToNonBlankValue(), updatedValue.Result, $"{_historyCleanupName}/{element.Name.LocalName}"); - property.SetValue(historyCleanup, updatedValue.Result); + changes.AddUpdate($"{_historyCleanupName}:{element.Name.LocalName}", current.ToNonBlankValue(), updatedValue, $"{_historyCleanupName}/{element.Name.LocalName}"); + property.SetValue(historyCleanup, updatedValue); } } } @@ -462,11 +461,6 @@ protected override XElement CleanseNode(XElement node) var value = info.GetValue(property); if (value is null) return default; - var result = value.TryConvertTo(); - if (result.Success) - return result.Result; - - return default; - + return value.TryGetValueAs(out var result) ? result : default; } } diff --git a/uSync.Core/Serialization/Serializers/DomainSerializer.cs b/uSync.Core/Serialization/Serializers/DomainSerializer.cs index a9860954c..5977c05de 100644 --- a/uSync.Core/Serialization/Serializers/DomainSerializer.cs +++ b/uSync.Core/Serialization/Serializers/DomainSerializer.cs @@ -167,8 +167,7 @@ private static int GetSortableValue(IDomain item) var result = property.GetValue(item); - var attempt = result.TryConvertTo(); - return attempt.Success ? attempt.Result : 0; + return result.TryGetValueAs(out var sortable) ? sortable : 0; } /// diff --git a/uSync.Core/Serialization/SyncSerializerOptions.cs b/uSync.Core/Serialization/SyncSerializerOptions.cs index f34b0bdbf..65ffa6808 100644 --- a/uSync.Core/Serialization/SyncSerializerOptions.cs +++ b/uSync.Core/Serialization/SyncSerializerOptions.cs @@ -2,6 +2,8 @@ using Umbraco.Extensions; +using uSync.Core.Extensions; + namespace uSync.Core.Serialization; /// @@ -70,11 +72,10 @@ public SyncSerializerOptions(SerializerFlags flags, Dictionary public TResult GetSetting(string key, TResult defaultValue) { - if (this.Settings?.TryGetValue(key, out var value) is true) + if (this.Settings?.TryGetValue(key, out var value) is true && value is not null) { - var attempt = value.TryConvertTo(); - if (attempt.Success && attempt.Result is not null) - return attempt.Result; + if (value.TryGetValueAs(out var result) && result is not null) + return result; } return defaultValue; diff --git a/uSync.Core/Serialization/SyncTreeSerializerBase.cs b/uSync.Core/Serialization/SyncTreeSerializerBase.cs index 69dc456b4..f38eac088 100644 --- a/uSync.Core/Serialization/SyncTreeSerializerBase.cs +++ b/uSync.Core/Serialization/SyncTreeSerializerBase.cs @@ -85,5 +85,5 @@ public override async Task IsCurrentAsync(XElement node, SyncSeriali /// does the parent item (as defined in the xml) exist in umbraco for this item? /// protected virtual Task HasParentItemAsync(XElement node) - => Task.FromResult(true); + => Task.FromResult(true); } diff --git a/uSync.Extend/packages.lock.json b/uSync.Extend/packages.lock.json index cb0131782..24c2b9eff 100644 --- a/uSync.Extend/packages.lock.json +++ b/uSync.Extend/packages.lock.json @@ -1209,7 +1209,7 @@ "Umbraco.Cms.Api.Common": { "type": "Transitive", "resolved": "18.0.0", - "contentHash": "Rmeql1eoKH2uy1hms5QChjDAgqRVNAlRXQs2xR6U9qLV8c4mS+gD0hcK7gLIMJw5UrVrN7sCZUzOeDkrvimU5g==", + "contentHash": "HA+3PEv7E67OkfxTWbMehFgeL1PnXTZlRNGqD14sRRaWyEVwvniGx97j0CoQYTizIEpXEz10tXAutJl4UWMupw==", "dependencies": { "Asp.Versioning.Mvc": "10.0.0", "Asp.Versioning.Mvc.ApiExplorer": "10.0.0", @@ -1252,7 +1252,7 @@ "Umbraco.Cms.Examine.Lucene": { "type": "Transitive", "resolved": "18.0.0", - "contentHash": "9H4lHCo7CPOD/2lQd7rJdc1g4oWjDNXjU4NUqfjlRVe9hnGdG+ZKNBBCIwYSE0T/0wdSbZQvMlHsY0XSZkJngQ==", + "contentHash": "cCObE4X3PrAk42P2DwpijLJNexZ8dp5cM8l3/5X1E7WSfwDNR3vFu8+PnOpgBGbdPiIJrDrt6nZWL59OqhVbZA==", "dependencies": { "Examine": "3.7.1", "Examine.Core": "3.7.1", @@ -1298,7 +1298,7 @@ "Umbraco.Cms.Infrastructure": { "type": "Transitive", "resolved": "18.0.0", - "contentHash": "oLci+0vYha0K7U5l+SnOx+JyUFFD2/gkWl+2k28Z4z4tIxX+KOZaYs2kcSQEEeyNm9EgSeAQAWSrUk75pM7z8A==", + "contentHash": "dtDxfWuFMTHyjSWbKe+mQBQi3dG4LJFepeyMmQOh3OoTXxbRKNkZqJczXe3UTw+EQTA2S36L1s90alHjvFz3AA==", "dependencies": { "Examine.Core": "3.7.1", "HtmlAgilityPack": "1.12.4", @@ -1342,7 +1342,7 @@ "Umbraco.Cms.PublishedCache.HybridCache": { "type": "Transitive", "resolved": "18.0.0", - "contentHash": "lae6QsI/fnjIE2XF2bxsjhYBzqbFar4rUl/D0e+GHpn+hxUpbACmnQ3vNNpsrs7vd1U1zOKi5C/wSyUuosvY8A==", + "contentHash": "DfEM9pid0R9X5ug5Rt2fEKM9rRUzWfHpOxMorAXhZLqPz1iZVIiJkLS2vTwj7s8BRGyMKXlHVlJMJFRjsWrQ3A==", "dependencies": { "Examine.Core": "3.7.1", "HtmlAgilityPack": "1.12.4", @@ -1390,7 +1390,7 @@ "Umbraco.Cms.Web.Common": { "type": "Transitive", "resolved": "18.0.0", - "contentHash": "Ej8KnY0S2ShgLpnvvNRUxpIsYhmK4/SbB4G+WQsoxsamkFfI4aWvfRo5dg8JgKO7WQrNYLKpeE/GCV37oCcbzg==", + "contentHash": "C8HQgeOlDRrqurElg63GhX7xXTYzxSOZJ9crxyiKOtgfXGlaQXSuOxWB4WM4wuKOh0jQPZiyB7+cjbaVGw7JhQ==", "dependencies": { "Asp.Versioning.Mvc": "10.0.0", "Asp.Versioning.Mvc.ApiExplorer": "10.0.0", @@ -1451,7 +1451,7 @@ "type": "CentralTransitive", "requested": "[18.0.0, )", "resolved": "18.0.0", - "contentHash": "/kPxriMZLu/BZAO3zcw1gsmutBmx/b/65kr41AVq9IQM5jH5/dNjnqs8VsMsw9uKImvG9/rLaArrqU0E3dfi2Q==", + "contentHash": "tuxXGtQV0yvF/Cf0TieLBlW6z+lFygL95IJmVCS2L4K0F+Dw5c3oZkIvZ/pPowopas11ptq3UpDfPzhS1WgULg==", "dependencies": { "Asp.Versioning.Mvc": "10.0.0", "Asp.Versioning.Mvc.ApiExplorer": "10.0.0", @@ -1512,7 +1512,7 @@ "type": "CentralTransitive", "requested": "[18.0.0, )", "resolved": "18.0.0", - "contentHash": "iRnhJV7aCZs947W10u2BRcldvl3pk6COdRur4LcV5GVlDPPwNfKs00oIZYGgMYhazE/sg0cUHbjJXGuR3doPvw==", + "contentHash": "VS0UNcn8DzCABYczauahvlb3pBqzw6nl7WRWZMOKRlTz0238ms9KtnplJqphwFrsF3OdyNijUFuNf1TwJv4dqA==", "dependencies": { "Microsoft.Extensions.Caching.Abstractions": "10.0.7", "Microsoft.Extensions.Caching.Memory": "10.0.7", @@ -1532,7 +1532,7 @@ "type": "CentralTransitive", "requested": "[18.0.0, )", "resolved": "18.0.0", - "contentHash": "H/dZgJ4y6Y5CIPKsm3YTtDiDQZQtWmQ5jj7E3h45Poixl0CzZ8CgOB9VQl0egum76rZOpONEf6WlsvfFkz1nLg==", + "contentHash": "VbucQZpsyq3VTeGLRvGvgl1Jz9gd546WOMGNtTZ/0oBgnDPM6OMb/z8CUrR9jRvwQJDtBropUJMxJoRyc8AbHA==", "dependencies": { "Asp.Versioning.Mvc": "10.0.0", "Asp.Versioning.Mvc.ApiExplorer": "10.0.0", diff --git a/uSync.Tests/Cache/SyncEntityCacheTests.cs b/uSync.Tests/Cache/SyncEntityCacheTests.cs new file mode 100644 index 000000000..62faf16f6 --- /dev/null +++ b/uSync.Tests/Cache/SyncEntityCacheTests.cs @@ -0,0 +1,75 @@ +using System; + +using Moq; + +using NUnit.Framework; + +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Services; + +using uSync.Core.Cache; + +namespace uSync.Tests.Cache; + +[TestFixture] +internal class SyncEntityCacheTests +{ + private Mock _entityServiceMock; + private Mock _contentTypeServiceMock; + private SyncEntityCache _cache; + + [SetUp] + public void Setup() + { + _entityServiceMock = new Mock(); + _contentTypeServiceMock = new Mock(); + _cache = new SyncEntityCache(_entityServiceMock.Object, _contentTypeServiceMock.Object); + } + + [Test] + public void AddName_ThenGetName_RoundTrips() + { + var id = 1234; + var key = Guid.NewGuid(); + + _cache.AddName(id, key, "Test Name"); + + var result = _cache.GetName(id); + + Assert.That(result, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(result.Key, Is.EqualTo(key)); + Assert.That(result.Name, Is.EqualTo("Test Name")); + }); + } + + // regression for uSync.Complete issue #304 - GetName used to read from the + // entity cache, which holds IEntitySlim objects under the same id key. That + // threw a swallowed InvalidCastException (IEntitySlim -> CachedName) and + // never returned the name. GetName must read from the name cache instead. + [Test] + public void GetName_WhenEntityCachedUnderSameId_StillReturnsName() + { + var id = 4321; + var key = Guid.NewGuid(); + + var entityMock = new Mock(); + entityMock.SetupGet(x => x.Id).Returns(id); + _entityServiceMock.Setup(x => x.Get(id)).Returns(entityMock.Object); + + // populate the entity cache for this id (as GetFriendlyPath does). + _ = _cache.GetEntity(id); + + _cache.AddName(id, key, "Real Name"); + + var result = _cache.GetName(id); + + Assert.That(result, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(result.Key, Is.EqualTo(key)); + Assert.That(result.Name, Is.EqualTo("Real Name")); + }); + } +} diff --git a/uSync.Tests/Extensions/TryGetValueAsTests.cs b/uSync.Tests/Extensions/TryGetValueAsTests.cs new file mode 100644 index 000000000..9005a57d4 --- /dev/null +++ b/uSync.Tests/Extensions/TryGetValueAsTests.cs @@ -0,0 +1,118 @@ +using System; +using System.Text.Json; + +using NUnit.Framework; + +using uSync.Core.Extensions; + +namespace uSync.Tests.Extensions; + +/// +/// tests for the JsonElement pre-check that avoids the swallowed +/// InvalidCastException Umbraco's TryConvertTo throws on JsonElement values +/// (uSync.Complete issue #304). +/// +[TestFixture] +internal class TryGetValueAsTests +{ + [Test] + public void JsonElementTrue_ConvertsToBool() + { + object value = JsonSerializer.SerializeToElement(true); + + var success = value.TryGetValueAs(out var result); + + Assert.Multiple(() => + { + Assert.That(success, Is.True); + Assert.That(result, Is.True); + }); + } + + [Test] + public void JsonElementFalse_ConvertsToBool() + { + object value = JsonSerializer.SerializeToElement(false); + + var success = value.TryGetValueAs(out var result); + + Assert.Multiple(() => + { + Assert.That(success, Is.True); + Assert.That(result, Is.False); + }); + } + + [Test] + public void JsonElementNumber_ConvertsToInt() + { + object value = JsonSerializer.SerializeToElement(42); + + var success = value.TryGetValueAs(out var result); + + Assert.Multiple(() => + { + Assert.That(success, Is.True); + Assert.That(result, Is.EqualTo(42)); + }); + } + + [Test] + public void JsonElementString_ConvertsToGuid() + { + var guid = Guid.NewGuid(); + object value = JsonSerializer.SerializeToElement(guid.ToString()); + + var success = value.TryGetValueAs(out var result); + + Assert.Multiple(() => + { + Assert.That(success, Is.True); + Assert.That(result, Is.EqualTo(guid)); + }); + } + + [Test] + public void JsonElementString_ConvertsToString() + { + object value = JsonSerializer.SerializeToElement("hello"); + + var success = value.TryGetValueAs(out var result); + + Assert.Multiple(() => + { + Assert.That(success, Is.True); + Assert.That(result, Is.EqualTo("hello")); + }); + } + + // a plain CLR value skips the JsonElement branch and still converts via + // the TryConvertTo fallback - behaviour must be unchanged for these. + [Test] + public void PlainString_ConvertsToInt_ViaFallback() + { + object value = "42"; + + var success = value.TryGetValueAs(out var result); + + Assert.Multiple(() => + { + Assert.That(success, Is.True); + Assert.That(result, Is.EqualTo(42)); + }); + } + + [Test] + public void Null_ReturnsFalse() + { + object? value = null; + + var success = value.TryGetValueAs(out var result); + + Assert.Multiple(() => + { + Assert.That(success, Is.False); + Assert.That(result, Is.False); + }); + } +}