diff --git a/uSync.Core/Extensions/JsonTextExtensions.cs b/uSync.Core/Extensions/JsonTextExtensions.cs index 8f8a04129..b67e1d01c 100644 --- a/uSync.Core/Extensions/JsonTextExtensions.cs +++ b/uSync.Core/Extensions/JsonTextExtensions.cs @@ -31,6 +31,8 @@ public static class JsonTextExtensions new JsonUdiRangeConverter(), new JsonBooleanConverter(), new JsonXElementConverter(), + new JsonBlockListLayoutItemConverter(), + new JsonBlockGridLayoutItemConverter() } }; diff --git a/uSync.Core/Json/JsonBlockItemConverters.cs b/uSync.Core/Json/JsonBlockItemConverters.cs new file mode 100644 index 000000000..529b65d87 --- /dev/null +++ b/uSync.Core/Json/JsonBlockItemConverters.cs @@ -0,0 +1,78 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using Umbraco.Cms.Core.Models.Blocks; + +namespace uSync.Core.Json; + +/// +/// in v16 the BlockLayoutItem(s) have obsolete properties that are sometimes set and sometimes not +/// this leads to false positive's when looking for changes. +/// +/// these two custom converters write those properties out as null, so they never change. causing +/// the serialized json to be consistent. +/// + +public abstract class JsonBlockItemConverterBase : JsonConverter + where T : BlockLayoutItemBase, new() +{ + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException("Invalid JSON expecting start object"); + + var item = new T(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + return item; + + if (reader.TokenType != JsonTokenType.PropertyName) + throw new JsonException("Expecting property name"); + + var propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName) + { + case "contentKey": + var contentKey = reader.GetString(); + if (contentKey != null && Guid.TryParse(contentKey, out var contentGuid)) + item.ContentKey = contentGuid; + break; + case "settingsKey": + var settingsKey = reader.GetString(); + if (settingsKey != null && Guid.TryParse(settingsKey, out var settingsGuid)) + item.SettingsKey = settingsGuid; + break; + default: + // we don't care about the obsolete properties here... + break; + } + } + + throw new JsonException("Unexpected end of JSON"); + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + // we could just not write out the obsolete properties, but they will appear as null it + // lots of people's existing exports , so we can write them out as null to keep things consistent. + writer.WriteStartObject(); + writer.WriteString("contentKey", value.ContentKey.ToString()); + writer.WriteNull("contentUdi"); + + if (value.SettingsKey.HasValue && value.SettingsKey != Guid.Empty) + writer.WriteString("settingsKey", value.SettingsKey.ToString()); + else + writer.WriteNull("settingsKey"); + + writer.WriteNull("settingsUdi"); + writer.WriteEndObject(); + } +} + +public class JsonBlockListLayoutItemConverter : JsonBlockItemConverterBase { } + +public class JsonBlockGridLayoutItemConverter : JsonBlockItemConverterBase { } diff --git a/uSync.Core/Json/XElementJsonConverter.cs b/uSync.Core/Json/XElementJsonConverter.cs index 22b2a392b..23959e33e 100644 --- a/uSync.Core/Json/XElementJsonConverter.cs +++ b/uSync.Core/Json/XElementJsonConverter.cs @@ -16,3 +16,4 @@ public override void Write(Utf8JsonWriter writer, XElement value, JsonSerializer writer.WriteStringValue(value.ToString()); } } + diff --git a/uSync.Core/Serialization/Serializers/ContentSerializer.cs b/uSync.Core/Serialization/Serializers/ContentSerializer.cs index 95cfd0e4d..1f41ecb6f 100644 --- a/uSync.Core/Serialization/Serializers/ContentSerializer.cs +++ b/uSync.Core/Serialization/Serializers/ContentSerializer.cs @@ -175,19 +175,6 @@ protected override async Task> DeserializeCoreAsync(XEleme details.AddRange(await DeserializeBaseAsync(item, node, options)); - var infoNode = node.Element(uSyncConstants.Xml.Info); - - if (infoNode is not null) - { - var trashed = infoNode.Element("Trashed").ValueOrDefault(false); - var restoreParent = infoNode.Element("Trashed")?.Attribute("Parent").ValueOrDefault(Guid.Empty) ?? Guid.Empty; - details.AddNotNull(HandleTrashedState(item, trashed, restoreParent)); - } - - // cultures... - - - details.AddNotNull(await DeserializeTemplate(item, node)); var propertiesAttempt = await DeserializePropertiesAsync(item, node, options); @@ -303,11 +290,15 @@ public int DeserializeWriterInfo(IContent item, XElement node, SyncSerializerOpt /// public override async Task> DeserializeSecondPassAsync(IContent item, XElement node, SyncSerializerOptions options) { + var details = new List(); + + // move trashed state to second pass, as the item needs an Id for the relation to work. + details.AddNotNull(await DeserializeTrashed(node, item, Constants.Conventions.RelationTypes.RelateParentDocumentOnDeleteAlias)); + // move sort to second pass, as if we attempt to set this // on a brand new item, it doesn't get set. // doing it on second pass ensures it gets set on the item // after it has been saved by umbraco. - var details = new List(); if (!options.GetSetting( uSyncConstants.DefaultSettings.IgnoreSortOrder, uSyncConstants.DefaultSettings.IgnoreSortOrder_Default)) @@ -417,41 +408,12 @@ private static ContentSchedule GetContentScheduleFromNode(XElement scheduleNode) return null; } + // trashed helpers. + protected override void MoveToRecycleBin(IContent item) => contentService.MoveToRecycleBin(item); + protected override void SetTrashed(IContent item) => ((ContentBase)item).Trashed = true; + protected override void MoveItem(IContent item, int parentId) => contentService.Move(item, parentId); + protected override IContent? GetByKey(Guid id) => contentService.GetById(id); - protected override uSyncChange? HandleTrashedState(IContent item, bool trashed, Guid restoreParentKey) - { - if (!trashed && item.Trashed) - { - // if the item is trashed, then the change of it's parent - // should restore it (as long as we do a move!) - - - var restoreParentId = GetRelationParentId(item, restoreParentKey, Constants.Conventions.RelationTypes.RelateParentDocumentOnDeleteAlias); - contentService.Move(item, restoreParentId); - - // clean out any relations for this item (some versions of Umbraco don't do this on a Move) - CleanRelations(item, Constants.Conventions.RelationTypes.RelateParentDocumentOnDeleteAlias); - - return uSyncChange.Update("Restored", item.Name ?? item.Id.ToString(), "Recycle Bin", restoreParentKey.ToString()); - - } - else if (trashed && !item.Trashed) - { - // not already in the recycle bin? - if (item.ParentId > Constants.System.RecycleBinContent) - { - // clean any relations that may be there (stops an error) - CleanRelations(item, Constants.Conventions.RelationTypes.RelateParentDocumentOnDeleteAlias); - - // move to the recycle bin - contentService.MoveToRecycleBin(item); - } - - return uSyncChange.Update("Moved to Bin", item.Name ?? item.Id.ToString(), "", "Recycle Bin"); - } - - return null; - } protected virtual Attempt DoSaveOrPublish(IContent item, XElement node, SyncSerializerOptions options) { diff --git a/uSync.Core/Serialization/Serializers/ContentSerializerBase.cs b/uSync.Core/Serialization/Serializers/ContentSerializerBase.cs index db9bcdcef..fd9ffc3ef 100644 --- a/uSync.Core/Serialization/Serializers/ContentSerializerBase.cs +++ b/uSync.Core/Serialization/Serializers/ContentSerializerBase.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using System.Globalization; using System.Text.RegularExpressions; @@ -649,9 +649,69 @@ private static bool IsUpdatedValue(object? current, object? newValue) return null; } + protected virtual void MoveToRecycleBin(TObject item) { } + protected virtual void SetTrashed(TObject item) { } + protected virtual void MoveItem(TObject item, int parentId) { } + protected virtual TObject? GetByKey(Guid id) => default; + + protected async Task DeserializeTrashed(XElement node, TObject item, string relationAlias) + { + var info = node.Element(uSyncConstants.Xml.Info); + if (info is null) return null; + + var trashed = info.Element("Trashed").ValueOrDefault(false); + var restoreParent = info.Element("Trashed")?.Attribute("Parent").ValueOrDefault(Guid.Empty) ?? Guid.Empty; + return await HandleTrashedState(item, trashed, restoreParent, relationAlias); + } + + [Obsolete("Use HandleTrashedState with relationAlias, will be removed in v18")] protected virtual uSyncChange? HandleTrashedState(TObject item, bool trashed, Guid restoreParent) => uSyncChange.NoChange($"Member/{item.Name}", item.Name ?? item.Id.ToString()); + protected virtual Task HandleTrashedState(TObject item, bool trashed, Guid restoreParent, string relationAlias) + { + if (!trashed && item.Trashed) + { + // if the item is trashed, then the change of it's parent + // should restore it (as long as we do a move!) + + var restoreParentId = GetRelationParentId(item, restoreParent, relationAlias); + MoveItem(item, restoreParentId); + + // clean out any relations for this item (some versions of Umbraco don't do this on a Move) + CleanRelations(item, relationAlias); + + return Task.FromResult(uSyncChange.Update("Restored", item.Name ?? item.Id.ToString(), "Recycle Bin", restoreParent.ToString())); + + } + else if (trashed && !item.Trashed) + { + // not already in the recycle bin? + if (item.ParentId > Constants.System.RecycleBinContent) + { + // clean any relations that may be there (stops an error) + CleanRelations(item, relationAlias); + + // move to the recycle bin + MoveToRecycleBin(item); + } + else + { + // on first import the item might be in the recycle bin, but not marked as trash. + // but one does not simple set 'trashed' on a content item. + SetTrashed(item); + + AddRelation(relationAlias, restoreParent, item.Id); + } + + return Task.FromResult(uSyncChange.Update("Moved to Bin", item.Name ?? item.Id.ToString(), "", "Recycle Bin")); + } + + return Task.FromResult(null); + + } + + protected async Task GetExportValueAsync(object? value, IPropertyType propertyType, string culture, string segment) { if (value is null) return string.Empty; @@ -1012,7 +1072,19 @@ protected void CleanRelations(TObject item, string relationType) { logger.LogWarning(exception, "Error cleaning up relations: {id}", item.Id); } + } + + /// + /// Adds a relation for the given item. If the relation already exists, it is not added again. + /// + private void AddRelation(string relationAlias, Guid parentKey, int childId) + { + var existing = relationService.GetByChildId(childId, relationAlias); + if (existing.Any()) return; + var parent = GetByKey(parentKey); + if (parent != null && childId > 0) + relationService.Relate(parent.Id, childId, relationAlias); } protected int GetRelationParentId(TObject item, Guid restoreParentKey, string relationType) @@ -1041,8 +1113,8 @@ protected int GetRelationParentId(TObject item, Guid restoreParentKey, string re private List GetExcludedProperties(SyncSerializerOptions options) { List exclude = [.. dontSerialize]; - - var excludeOptions = options.GetSetting(uSyncConstants.DefaultSettings.DoNotSerialize, + + var excludeOptions = options.GetSetting(uSyncConstants.DefaultSettings.DoNotSerialize, uSyncConstants.DefaultSettings.DoNotSerialize_Default); if (!string.IsNullOrWhiteSpace(excludeOptions)) @@ -1065,7 +1137,7 @@ private List GetExcludedProperties(SyncSerializerOptions options) } catch (ArgumentException ex) { - logger.LogDebug("Unable to parse pattern '{pattern}' from '{settingsKey}' as Regex. {error}. Pattern will not be considered.", pattern, + logger.LogDebug("Unable to parse pattern '{pattern}' from '{settingsKey}' as Regex. {error}. Pattern will not be considered.", pattern, uSyncConstants.DefaultSettings.DoNotSerializePattern, ex.Message); return null; } diff --git a/uSync.Core/Serialization/Serializers/MediaSerializer.cs b/uSync.Core/Serialization/Serializers/MediaSerializer.cs index 5d24ca8b4..14937da7d 100644 --- a/uSync.Core/Serialization/Serializers/MediaSerializer.cs +++ b/uSync.Core/Serialization/Serializers/MediaSerializer.cs @@ -59,19 +59,13 @@ protected override async Task> DeserializeCoreAsync(XElement details.AddRange(await DeserializeBaseAsync(item, node, options)); - var info = node.Element(uSyncConstants.Xml.Info); - if (info is not null) - { - var trashed = info.Element("Trashed").ValueOrDefault(false); - var restoreParent = info.Element("Trashed")?.Attribute("Parent").ValueOrDefault(Guid.Empty) ?? Guid.Empty; - details.AddNotNull(HandleTrashedState(item, trashed, restoreParent)); - } - var propertyAttempt = await DeserializePropertiesAsync(item, node, options); if (!propertyAttempt.Success) return SyncAttempt.Fail(item.Name ?? item.Id.ToString(), item, ChangeType.Fail, "Failed to save properties", propertyAttempt.Exception ?? new Exception($"Error with properties {item.Id}")); + var info = node.Element(uSyncConstants.Xml.Info); + if (!options.GetSetting(uSyncConstants.DefaultSettings.IgnoreSortOrder, uSyncConstants.DefaultSettings.IgnoreSortOrder_Default)) { var sortOrder = info?.Element(uSyncConstants.Xml.SortOrder).ValueOrDefault(-1) ?? -1; @@ -103,33 +97,20 @@ protected override async Task> DeserializeCoreAsync(XElement return SyncAttempt.Succeed(item.Name ?? item.Id.ToString(), item, ChangeType.Import, "", true, propertyAttempt.Result); } - protected override uSyncChange? HandleTrashedState(IMedia item, bool trashed, Guid restoreParentKey) + public override async Task> DeserializeSecondPassAsync(IMedia item, XElement node, SyncSerializerOptions options) { - if (!trashed && item.Trashed) - { - // if the item is trashed, then moving it back to the parent value - // restores it. - - var restoreParentId = GetRelationParentId(item, restoreParentKey, Constants.Conventions.RelationTypes.RelateParentMediaFolderOnDeleteAlias); - _mediaService.Move(item, restoreParentId); - - CleanRelations(item, Constants.Conventions.RelationTypes.RelateParentMediaFolderOnDeleteAlias); - - return uSyncChange.Update("Restored", item.Name ?? item.Id.ToString(), "Recycle Bin", item.ParentId.ToString()); - } - else if (trashed && !item.Trashed) - { - // clean any rouge relations - CleanRelations(item, Constants.Conventions.RelationTypes.RelateParentMediaFolderOnDeleteAlias); - - // move to the recycle bin - _mediaService.MoveToRecycleBin(item); - return uSyncChange.Update("Moved to Bin", item.Name ?? item.Id.ToString(), "", "Recycle Bin"); - } + var details = new List(); + details.AddNotNull(await DeserializeTrashed(node, item, Constants.Conventions.RelationTypes.RelateParentMediaFolderOnDeleteAlias)); - return null; + return SyncAttempt.Succeed(item.Name ?? item.Id.ToString(), item, + details.Count == 0 ? ChangeType.NoChange : ChangeType.Import, details); } + protected override void MoveToRecycleBin(IMedia item) => _mediaService.MoveToRecycleBin(item); + protected override void SetTrashed(IMedia item) => ((ContentBase)item).Trashed = true; + protected override void MoveItem(IMedia item, int parentId) => _mediaService.Move(item, parentId); + protected override IMedia? GetByKey(Guid id) => _mediaService.GetById(id); + protected override async Task> SerializeCoreAsync(IMedia item, SyncSerializerOptions options) { var node = InitializeNode(item, item.ContentType.Alias, options);