using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Blocks; using Umbraco.Cms.Core.Models.Editors; using Umbraco.Cms.Core.PropertyEditors.ValueConverters; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; public abstract class BlockValuePropertyValueEditorBase : DataValueEditor, IDataValueReference, IDataValueTags where TValue : BlockValue, new() where TLayout : class, IBlockLayoutItem, new() { private readonly IDataTypeConfigurationCache _dataTypeConfigurationCache; private readonly PropertyEditorCollection _propertyEditors; private readonly IJsonSerializer _jsonSerializer; private readonly DataValueReferenceFactoryCollection _dataValueReferenceFactoryCollection; private readonly BlockEditorVarianceHandler _blockEditorVarianceHandler; private BlockEditorValues? _blockEditorValues; private readonly ILanguageService _languageService; protected BlockValuePropertyValueEditorBase( PropertyEditorCollection propertyEditors, IDataTypeConfigurationCache dataTypeConfigurationCache, IShortStringHelper shortStringHelper, IJsonSerializer jsonSerializer, DataValueReferenceFactoryCollection dataValueReferenceFactoryCollection, BlockEditorVarianceHandler blockEditorVarianceHandler, ILanguageService languageService, IIOHelper ioHelper, DataEditorAttribute attribute) : base(shortStringHelper, jsonSerializer, ioHelper, attribute) { _propertyEditors = propertyEditors; _dataTypeConfigurationCache = dataTypeConfigurationCache; _jsonSerializer = jsonSerializer; _dataValueReferenceFactoryCollection = dataValueReferenceFactoryCollection; _blockEditorVarianceHandler = blockEditorVarianceHandler; _languageService = languageService; } /// public abstract IEnumerable GetReferences(object? value); protected abstract TValue CreateWithLayout(IEnumerable layout); protected BlockEditorValues BlockEditorValues { get => _blockEditorValues ?? throw new NullReferenceException($"The property {nameof(BlockEditorValues)} must be initialized at value editor construction"); set => _blockEditorValues = value; } protected IEnumerable GetBlockValueReferences(TValue blockValue) { var result = new HashSet(); BlockPropertyValue[] blockPropertyValues = blockValue.ContentData.Concat(blockValue.SettingsData) .SelectMany(x => x.Values).ToArray(); if (blockPropertyValues.Any(p => p.PropertyType is null)) { throw new ArgumentException("One or more block properties did not have a resolved property type. Block editor values must be resolved before attempting to find references within them.", nameof(blockValue)); } foreach (IGrouping valuesByPropertyEditorAlias in blockPropertyValues.GroupBy(x => x.PropertyType!.PropertyEditorAlias, x => x.Value)) { if (!_propertyEditors.TryGet(valuesByPropertyEditorAlias.Key, out IDataEditor? dataEditor)) { continue; } var distinctValues = valuesByPropertyEditorAlias.Distinct().ToArray(); if (dataEditor.GetValueEditor() is IDataValueReference reference) { foreach (UmbracoEntityReference value in distinctValues.SelectMany(reference.GetReferences)) { result.Add(value); } } IEnumerable references = _dataValueReferenceFactoryCollection.GetReferences(dataEditor, distinctValues); foreach (UmbracoEntityReference value in references) { result.Add(value); } } return result; } /// public abstract IEnumerable GetTags(object? value, object? dataTypeConfiguration, int? languageId); protected IEnumerable GetBlockValueTags(TValue blockValue, int? languageId) { var result = new List(); // loop through all content and settings data foreach (BlockItemData row in blockValue.ContentData.Concat(blockValue.SettingsData)) { foreach (BlockPropertyValue blockPropertyValue in row.Values) { if (blockPropertyValue.PropertyType is null) { throw new ArgumentException("One or more block properties did not have a resolved property type. Block editor values must be resolved before attempting to find tags within them.", nameof(blockValue)); } IDataEditor? propEditor = _propertyEditors[blockPropertyValue.PropertyType.PropertyEditorAlias]; IDataValueEditor? valueEditor = propEditor?.GetValueEditor(); if (valueEditor is not IDataValueTags tagsProvider) { continue; } object? configuration = _dataTypeConfigurationCache.GetConfiguration(blockPropertyValue.PropertyType.DataTypeKey); var tagLanguageId = blockPropertyValue.Culture is not null ? _languageService.GetAsync(blockPropertyValue.Culture).GetAwaiter().GetResult()?.Id : languageId; result.AddRange(tagsProvider.GetTags(blockPropertyValue.Value, configuration, tagLanguageId)); } } return result; } protected void MapBlockValueFromEditor(TValue? editedBlockValue, TValue? currentBlockValue, Guid contentKey) { MapBlockItemDataFromEditor( editedBlockValue?.ContentData ?? [], currentBlockValue?.ContentData ?? [], contentKey); MapBlockItemDataFromEditor( editedBlockValue?.SettingsData ?? [], currentBlockValue?.SettingsData ?? [], contentKey); } private void MapBlockItemDataFromEditor(List editedItems, List currentItems, Guid contentKey) { // Create mapping between edited and current block items. IEnumerable> itemsMapping = GetBlockStatesMapping(editedItems, currentItems, (mapping, current) => mapping.Edited?.Key == current.Key); foreach (BlockStateMapping itemMapping in itemsMapping) { // Create mapping between edited and current block item values. IEnumerable> valuesMapping = GetBlockStatesMapping(itemMapping.Edited?.Values, itemMapping.Current?.Values, (mapping, current) => mapping.Edited?.Alias == current.Alias); foreach (BlockStateMapping valueMapping in valuesMapping) { BlockPropertyValue? editedValue = valueMapping.Edited; BlockPropertyValue? currentValue = valueMapping.Current; IPropertyType propertyType = editedValue?.PropertyType ?? currentValue?.PropertyType ?? throw new ArgumentException("One or more block properties did not have a resolved property type. Block editor values must be resolved before attempting to map them from editor.", nameof(editedItems)); // Lookup the property editor. IDataEditor? propertyEditor = _propertyEditors[propertyType.PropertyEditorAlias]; if (propertyEditor is null) { continue; } // Fetch the property types prevalue. var configuration = _dataTypeConfigurationCache.GetConfiguration(propertyType.DataTypeKey); // Create a real content property data object. var propertyData = new ContentPropertyData(editedValue?.Value, configuration) { ContentKey = contentKey, PropertyTypeKey = propertyType.Key, }; // Get the property editor to do it's conversion. IDataValueEditor valueEditor = propertyEditor.GetValueEditor(); var newValue = valueEditor.FromEditor(propertyData, currentValue?.Value); // Update the raw value since this is what will get serialized out. if (editedValue != null) { editedValue.Value = newValue; } } } } private sealed class BlockStateMapping { public T? Edited { get; set; } public T? Current { get; set; } } private static IEnumerable> GetBlockStatesMapping(IList? editedItems, IList? currentItems, Func, T, bool> condition) { // filling with edited items first List> mapping = editedItems? .Select(editedItem => new BlockStateMapping { Current = default, Edited = editedItem, }) .ToList() ?? []; if (currentItems is null) { return mapping; } // then adding current items foreach (T currentItem in currentItems) { BlockStateMapping? mappingItem = mapping.FirstOrDefault(x => condition(x, currentItem)); if (mappingItem == null) // if there is no edited item, then adding just current { mapping.Add(new BlockStateMapping { Current = currentItem, Edited = default, }); } else { mappingItem.Current = currentItem; } } return mapping; } protected void MapBlockValueToEditor(IProperty property, TValue blockValue, string? culture, string? segment) { MapBlockItemDataToEditor(property, blockValue.ContentData, culture, segment); MapBlockItemDataToEditor(property, blockValue.SettingsData, culture, segment); _blockEditorVarianceHandler.AlignExposeVariance(blockValue); } protected IEnumerable ConfiguredElementTypeKeys(IBlockConfiguration configuration) { yield return configuration.ContentElementTypeKey; if (configuration.SettingsElementTypeKey is not null) { yield return configuration.SettingsElementTypeKey.Value; } } private void MapBlockItemDataToEditor(IProperty property, List items, string? culture, string? segment) { var valueEditorsByKey = new Dictionary(); foreach (BlockItemData item in items) { // if changes were made to the element type variations, we need those changes reflected in the block property values. // for regular content this happens when a content type is saved (copies of property values are created in the DB), // but for local block level properties we don't have that kind of handling, so we to do it manually. // to be friendly we'll map "formerly invariant properties" to the default language ISO code instead of performing a // hard reset of the property values (which would likely be the most correct thing to do from a data point of view). item.Values = _blockEditorVarianceHandler.AlignPropertyVarianceAsync(item.Values, culture).GetAwaiter().GetResult(); foreach (BlockPropertyValue blockPropertyValue in item.Values) { IPropertyType? propertyType = blockPropertyValue.PropertyType; if (propertyType is null) { throw new ArgumentException("One or more block properties did not have a resolved property type. Block editor values must be resolved before attempting to map them to editor.", nameof(items)); } IDataEditor? propertyEditor = _propertyEditors[propertyType.PropertyEditorAlias]; if (propertyEditor is null) { // leave the current block property value as-is - will be used to render a fallback output in the client continue; } if (!valueEditorsByKey.TryGetValue(propertyType.DataTypeKey, out IDataValueEditor? valueEditor)) { var configuration = _dataTypeConfigurationCache.GetConfiguration(propertyType.DataTypeKey); valueEditor = propertyEditor.GetValueEditor(configuration); valueEditorsByKey.Add(propertyType.DataTypeKey, valueEditor); } var tempProp = new Property(propertyType); tempProp.SetValue(blockPropertyValue.Value, blockPropertyValue.Culture, blockPropertyValue.Segment); var editorValue = valueEditor.ToEditor(tempProp, blockPropertyValue.Culture, blockPropertyValue.Segment); // update the raw value since this is what will get serialized out blockPropertyValue.Value = editorValue; } } } /// /// Updates the invariant data in the source with the invariant data in the value if allowed /// /// /// /// /// internal virtual BlockEditorData? UpdateSourceInvariantData(BlockEditorData? source, BlockEditorData? target, bool canUpdateInvariantData) { if (source is null && target is null) { return null; } if (source is null) { return MergeNewInvariant(target!, canUpdateInvariantData); } if (target is null) { return MergeRemovalInvariant(source, canUpdateInvariantData); } return MergeInvariant(source, target, canUpdateInvariantData); } internal virtual object? MergeVariantInvariantPropertyValue( object? sourceValue, object? targetValue, bool canUpdateInvariantData, HashSet allowedCultures) { if (canUpdateInvariantData is false && targetValue is null) { return sourceValue; } BlockEditorData? source = BlockEditorValues.DeserializeAndClean(sourceValue); BlockEditorData? target = BlockEditorValues.DeserializeAndClean(targetValue); TValue? mergedBlockValue = MergeVariantInvariantPropertyValueTyped(source, target, canUpdateInvariantData, allowedCultures); if (mergedBlockValue is null) { return null; } return _jsonSerializer.Serialize(mergedBlockValue); } internal virtual TValue? MergeVariantInvariantPropertyValueTyped( BlockEditorData? source, BlockEditorData? target, bool canUpdateInvariantData, HashSet allowedCultures) { var mergedInvariant = UpdateSourceInvariantData(source, target, canUpdateInvariantData); // if the structure (invariant) is not defined after merger, the target content does not matter if (mergedInvariant?.Layout is null) { return null; } // since we merged the invariant data (layout) before we get to this point // we just need an empty valid object to run comparisons at this point if (source is null) { source = new BlockEditorData([], new TValue()); } // update the target with the merged invariant target!.BlockValue.Layout = mergedInvariant.BlockValue.Layout; // remove all the blocks that are no longer part of the layout target.BlockValue.ContentData.RemoveAll(contentBlock => target.Layout!.Any(layoutItem => layoutItem.ReferencesContent(contentBlock.Key)) is false); // remove any exposes that no longer have content assigned to them target.BlockValue.Expose.RemoveAll(expose => target.BlockValue.ContentData.Any(data => data.Key == expose.ContentKey) is false); target.BlockValue.SettingsData.RemoveAll(settingsBlock => target.Layout!.Any(layoutItem => layoutItem.ReferencesSetting(settingsBlock.Key)) is false); CleanupVariantValues(source.BlockValue.ContentData, target.BlockValue.ContentData, canUpdateInvariantData, allowedCultures); CleanupVariantValues(source.BlockValue.SettingsData, target.BlockValue.SettingsData, canUpdateInvariantData, allowedCultures); // every source block value for a culture that is not allowed to be edited should be present on the target RestoreMissingValues( source.BlockValue.ContentData, target.BlockValue.ContentData, mergedInvariant.Layout, (layoutItem, itemData) => layoutItem.ContentKey == itemData.Key, canUpdateInvariantData, allowedCultures); RestoreMissingValues( source.BlockValue.SettingsData, target.BlockValue.SettingsData, mergedInvariant.Layout, (layoutItem, itemData) => layoutItem.SettingsKey == itemData.Key, canUpdateInvariantData, allowedCultures); // update the expose list from source for any blocks that were restored var missingSourceExposes = source.BlockValue.Expose.Where(sourceExpose => target.BlockValue.Expose.Any(targetExpose => targetExpose.ContentKey == sourceExpose.ContentKey) is false && target.BlockValue.ContentData.Any(data => data.Key == sourceExpose.ContentKey)).ToList(); foreach (BlockItemVariation missingSourceExpose in missingSourceExposes) { target.BlockValue.Expose.Add(missingSourceExpose); } return target.BlockValue; } private void RestoreMissingValues( List sourceBlockItemData, List targetBlockItemData, IEnumerable mergedLayout, Func relevantBlockItemMatcher, bool canUpdateInvariantData, HashSet allowedCultures) { IEnumerable blockItemsToCheck = sourceBlockItemData.Where(itemData => mergedLayout.Any(layoutItem => relevantBlockItemMatcher(layoutItem, itemData))); foreach (BlockItemData blockItemData in blockItemsToCheck) { var relevantValues = blockItemData.Values.Where(value => (value.Culture is null && canUpdateInvariantData is false) || (value.Culture is not null && allowedCultures.Contains(value.Culture) is false)).ToList(); if (relevantValues.Count < 1) { continue; } BlockItemData targetBlockData = targetBlockItemData.FirstOrDefault(itemData => itemData.Key == blockItemData.Key) ?? new BlockItemData(blockItemData.Key, blockItemData.ContentTypeKey, blockItemData.ContentTypeAlias); foreach (BlockPropertyValue missingValue in relevantValues.Where(value => targetBlockData.Values.Any(targetValue => targetValue.Alias == value.Alias && targetValue.Culture == value.Culture && targetValue.Segment == value.Segment) is false)) { targetBlockData.Values.Add(missingValue); } if (targetBlockItemData.Any(existingBlockItemData => existingBlockItemData.Key == targetBlockData.Key) is false) { targetBlockItemData.Add(blockItemData); } } } private void CleanupVariantValues( List sourceBlockItems, List targetBlockItems, bool canUpdateInvariantData, HashSet allowedCultures) { // merge the source values into the target values per culture foreach (BlockItemData targetBlockItem in targetBlockItems) { BlockItemData? sourceBlockItem = sourceBlockItems.FirstOrDefault(i => i.Key == targetBlockItem.Key); var valuesToRemove = new List(); foreach (BlockPropertyValue targetBlockPropertyValue in targetBlockItem.Values) { BlockPropertyValue? sourceBlockPropertyValue = sourceBlockItem?.Values.FirstOrDefault(v => v.Alias == targetBlockPropertyValue.Alias && v.Culture == targetBlockPropertyValue.Culture); // todo double check if this path can have an invariant value, but it shouldn't right??? // => it can be a null culture, but we shouldn't do anything? as the invariant section should have done it already if ((targetBlockPropertyValue.Culture is null && canUpdateInvariantData == false) || (targetBlockPropertyValue.Culture is not null && allowedCultures.Contains(targetBlockPropertyValue.Culture) is false)) { // not allowed to update this culture, set the value back to the source if (sourceBlockPropertyValue is null) { valuesToRemove.Add(targetBlockPropertyValue); } else { targetBlockPropertyValue.Value = sourceBlockPropertyValue.Value; } continue; } // is this another editor that supports partial merging? i.e. blocks within blocks. IDataEditor? mergingDataEditor = null; var shouldPerformPartialMerge = targetBlockPropertyValue.PropertyType is not null && _propertyEditors.TryGet(targetBlockPropertyValue.PropertyType.PropertyEditorAlias, out mergingDataEditor) && mergingDataEditor.CanMergePartialPropertyValues(targetBlockPropertyValue.PropertyType); if (shouldPerformPartialMerge is false) { continue; } // marge subdata targetBlockPropertyValue.Value = mergingDataEditor!.MergeVariantInvariantPropertyValue( sourceBlockPropertyValue?.Value, targetBlockPropertyValue.Value, canUpdateInvariantData, allowedCultures); } foreach (BlockPropertyValue value in valuesToRemove) { targetBlockItem.Values.Remove(value); } } } private BlockEditorData? MergeNewInvariant(BlockEditorData target, bool canUpdateInvariantData) { if (canUpdateInvariantData is false) { // source value was null and not allowed to update the structure which is invariant => nothing remains return null; } // create a new source object based on the target value that only has the invariant data (structure) return target.Layout is not null ? new BlockEditorData([], CreateWithLayout(target.Layout)) : null; } private BlockEditorData? MergeRemovalInvariant(BlockEditorData source, bool canUpdateInvariantData) { if (canUpdateInvariantData) { // if the structure is removed, everything is gone anyway return null; } // create a new target object based on the source value that only has the invariant data (structure) return source.Layout is not null ? new BlockEditorData([], CreateWithLayout(source.Layout)) : null; } private BlockEditorData MergeInvariant(BlockEditorData source, BlockEditorData target, bool canUpdateInvariantData) { if (canUpdateInvariantData) { source.BlockValue.Layout = target.BlockValue.Layout; source.BlockValue.Expose = target.BlockValue.Expose; } return source; } internal virtual object? MergePartialPropertyValueForCulture(object? sourceValue, object? targetValue, string? culture) { if (sourceValue is null) { return null; } // parse the source value as block editor data BlockEditorData? sourceBlockEditorValues = BlockEditorValues.DeserializeAndClean(sourceValue); if (sourceBlockEditorValues?.Layout is null) { return null; } // parse the target value as block editor data (fallback to an empty set of block editor data) BlockEditorData targetBlockEditorValues = (targetValue is not null ? BlockEditorValues.DeserializeAndClean(targetValue) : null) ?? new BlockEditorData([], CreateWithLayout(sourceBlockEditorValues.Layout)); TValue mergeResult = MergeBlockEditorDataForCulture(sourceBlockEditorValues.BlockValue, targetBlockEditorValues.BlockValue, culture); return _jsonSerializer.Serialize(mergeResult); } protected TValue MergeBlockEditorDataForCulture(TValue sourceBlockValue, TValue targetBlockValue, string? culture) { // structure is global, layout and expose follows structure targetBlockValue.Layout = sourceBlockValue.Layout; targetBlockValue.Expose = sourceBlockValue.Expose; MergePartialPropertyValueForCulture(sourceBlockValue.ContentData, targetBlockValue.ContentData, culture); MergePartialPropertyValueForCulture(sourceBlockValue.SettingsData, targetBlockValue.SettingsData, culture); return targetBlockValue; } private void MergePartialPropertyValueForCulture(List sourceBlockItems, List targetBlockItems, string? culture) { // remove all target blocks that are not part of the source blocks (structure is global) targetBlockItems.RemoveAll(pb => sourceBlockItems.Any(eb => eb.Key == pb.Key) is false); // merge the source values into the target values for culture foreach (BlockItemData sourceBlockItem in sourceBlockItems) { BlockItemData? targetBlockItem = targetBlockItems.FirstOrDefault(i => i.Key == sourceBlockItem.Key); if (targetBlockItem is null) { targetBlockItem = new BlockItemData( sourceBlockItem.Key, sourceBlockItem.ContentTypeKey, sourceBlockItem.ContentTypeAlias); // NOTE: this only works because targetBlockItem is by ref! targetBlockItems.Add(targetBlockItem); } foreach (BlockPropertyValue sourceBlockPropertyValue in sourceBlockItem.Values) { // is this another editor that supports partial merging? i.e. blocks within blocks. IDataEditor? mergingDataEditor = null; var shouldPerformPartialMerge = sourceBlockPropertyValue.PropertyType is not null && _propertyEditors.TryGet(sourceBlockPropertyValue.PropertyType.PropertyEditorAlias, out mergingDataEditor) && mergingDataEditor.CanMergePartialPropertyValues(sourceBlockPropertyValue.PropertyType); if (shouldPerformPartialMerge is false && sourceBlockPropertyValue.Culture != culture) { // skip for now (irrelevant for the current culture, but might be included in the next pass) continue; } BlockPropertyValue? targetBlockPropertyValue = targetBlockItem .Values .FirstOrDefault(v => v.Alias == sourceBlockPropertyValue.Alias && v.Culture == sourceBlockPropertyValue.Culture && v.Segment == sourceBlockPropertyValue.Segment); if (targetBlockPropertyValue is null) { targetBlockPropertyValue = new BlockPropertyValue { Alias = sourceBlockPropertyValue.Alias, Culture = sourceBlockPropertyValue.Culture, Segment = sourceBlockPropertyValue.Segment }; targetBlockItem.Values.Add(targetBlockPropertyValue); } // assign source value to target value (or perform partial merge, depending on context) targetBlockPropertyValue.Value = shouldPerformPartialMerge is false ? sourceBlockPropertyValue.Value : mergingDataEditor!.MergePartialPropertyValueForCulture(sourceBlockPropertyValue.Value, targetBlockPropertyValue.Value, culture); } } } }