diff --git a/uSync.Core/Serialization/Serializers/ContentSerializerBase.cs b/uSync.Core/Serialization/Serializers/ContentSerializerBase.cs index 00214dcd..328efedc 100644 --- a/uSync.Core/Serialization/Serializers/ContentSerializerBase.cs +++ b/uSync.Core/Serialization/Serializers/ContentSerializerBase.cs @@ -140,6 +140,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; + } + } } } @@ -315,6 +327,8 @@ protected override async Task> CanDeserializeAsync(XElement return SyncAttempt.Succeed("No check", ChangeType.NoChange); } + protected virtual async Task CreateParentIfMissingAsync(XElement parentNode, string path) => null; + protected virtual async Task> DeserializeBaseAsync(TObject item, XElement node, SyncSerializerOptions options) { var info = node?.Element(uSyncConstants.Xml.Info); @@ -355,6 +369,10 @@ protected virtual async Task> DeserializeBaseAsync(TObj } } + // last chance blueprints will create the missing containers. + parent ??= await CreateParentIfMissingAsync(parentNode, + info?.Element(uSyncConstants.Xml.Path).ValueOrDefault(string.Empty) ?? string.Empty); + if (parent != null) { parentId = parent.Id; @@ -918,6 +936,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) @@ -1007,9 +1040,18 @@ public override async Task> SerializeEmptyAsync(TObject it public override string ItemAlias(TObject item) => item.Name ?? item.Id.ToString(); - protected async Task FindParentAsync(XElement node, bool searchByAlias = false) + protected virtual async Task FindItemAsTreeEntityAsync(Guid key) + => await FindItemAsync(key); + + protected virtual async Task FindItemAsTreeEntityAsync(string alias) + => await FindItemAsync(alias); + + protected virtual async Task FindByPathAsTreeEntityAsync(IEnumerable folders, bool failIfNotExits) + => await FindByPathAsync(folders, failIfNotExits); + + protected async Task FindParentAsync(XElement node, bool searchByAlias = false) { - var item = default(TObject); + var item = default(ITreeEntity); if (node == null) return default; @@ -1019,7 +1061,7 @@ public override string ItemAlias(TObject item) if (logger.IsEnabled(LogLevel.Trace)) logger.LogTrace("Looking for Parent by Key {Key}", key); - item = await FindItemAsync(key); + item = await FindItemAsTreeEntityAsync(key); if (item != null) return item; } @@ -1032,7 +1074,7 @@ public override string ItemAlias(TObject item) if (!string.IsNullOrEmpty(alias)) { - item = await FindItemAsync(node.ValueOrDefault(alias)); + item = await FindItemAsTreeEntityAsync(node.ValueOrDefault(alias)); } } diff --git a/uSync.Core/Serialization/Serializers/ContentTemplateSerializer.cs b/uSync.Core/Serialization/Serializers/ContentTemplateSerializer.cs index 039346ec..de63fd30 100644 --- a/uSync.Core/Serialization/Serializers/ContentTemplateSerializer.cs +++ b/uSync.Core/Serialization/Serializers/ContentTemplateSerializer.cs @@ -7,6 +7,7 @@ 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; @@ -19,6 +20,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 +33,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; } @@ -115,6 +119,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; @@ -129,18 +134,104 @@ 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); + + parent = await FindItemAsTreeEntityAsync(parentKey); + + // the parent might not be another blueprint, but the + // DocumentBlueprintContainer (folder) the blueprint lives in. + 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); + } + + + protected override async Task FindItemAsTreeEntityAsync(Guid key) + => await base.FindItemAsTreeEntityAsync(key) ?? await FindContainerAsync(key); + + 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; + + return await FindOrCreateContainerAsync(key, name, path); + } + + /// + /// 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/SyncTreeSerializerBase.cs b/uSync.Core/Serialization/SyncTreeSerializerBase.cs index a83474f8..3dfaada6 100644 --- a/uSync.Core/Serialization/SyncTreeSerializerBase.cs +++ b/uSync.Core/Serialization/SyncTreeSerializerBase.cs @@ -88,7 +88,7 @@ protected virtual Task HasParentItemAsync(XElement node) /// /// calculates the Umbraco Path value for an item, based on the parent /// - protected string CalculateNodePath(TObject item, TObject? parent) + protected string CalculateNodePath(TObject item, ITreeEntity? parent) { if (parent == null) { @@ -103,7 +103,7 @@ protected string CalculateNodePath(TObject item, TObject? parent) /// /// calculates the Level based on the parent. /// - protected int CalculateNodeLevel(TObject item, TObject? parent) + protected int CalculateNodeLevel(TObject item, ITreeEntity? parent) { if (parent == null) {