// Copyright (c) Umbraco. // See LICENSE for more details. using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Umbraco.Cms.Core.Hosting; 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.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors { /// /// Abstract class for block editor based editors /// public abstract class BlockEditorPropertyEditor : DataEditor { public const string ContentTypeKeyPropertyKey = "contentTypeKey"; public const string UdiPropertyKey = "udi"; public BlockEditorPropertyEditor( IDataValueEditorFactory dataValueEditorFactory, PropertyEditorCollection propertyEditors) : base(dataValueEditorFactory) => PropertyEditors = propertyEditors; private PropertyEditorCollection PropertyEditors { get; } #region Value Editor protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute); internal class BlockEditorPropertyValueEditor : DataValueEditor, IDataValueReference { private readonly PropertyEditorCollection _propertyEditors; private readonly IDataTypeService _dataTypeService; private readonly ILogger _logger; private readonly BlockEditorValues _blockEditorValues; public BlockEditorPropertyValueEditor( DataEditorAttribute attribute, PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, IContentTypeService contentTypeService, ILocalizedTextService textService, ILogger logger, IShortStringHelper shortStringHelper, IJsonSerializer jsonSerializer, IIOHelper ioHelper, IPropertyValidationService propertyValidationService) : base(textService, shortStringHelper, jsonSerializer, ioHelper, attribute) { _propertyEditors = propertyEditors; _dataTypeService = dataTypeService; _logger = logger; _blockEditorValues = new BlockEditorValues(new BlockListEditorDataConverter(), contentTypeService, _logger); Validators.Add(new BlockEditorValidator(propertyValidationService, _blockEditorValues,contentTypeService)); Validators.Add(new MinMaxValidator(_blockEditorValues, textService)); } public IEnumerable GetReferences(object value) { var rawJson = value == null ? string.Empty : value is string str ? str : value.ToString(); var result = new List(); var blockEditorData = _blockEditorValues.DeserializeAndClean(rawJson); if (blockEditorData == null) return Enumerable.Empty(); // loop through all content and settings data foreach (var row in blockEditorData.BlockValue.ContentData.Concat(blockEditorData.BlockValue.SettingsData)) { foreach (var prop in row.PropertyValues) { var propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias]; var valueEditor = propEditor?.GetValueEditor(); if (!(valueEditor is IDataValueReference reference)) continue; var val = prop.Value.Value?.ToString(); var refs = reference.GetReferences(val); result.AddRange(refs); } } return result; } #region Convert database // editor // note: there is NO variant support here /// /// Ensure that sub-editor values are translated through their ToEditor methods /// /// /// /// /// /// public override object ToEditor(IProperty property, string culture = null, string segment = null) { var val = property.GetValue(culture, segment); var valEditors = new Dictionary(); BlockEditorData blockEditorData; try { blockEditorData = _blockEditorValues.DeserializeAndClean(val); } catch (JsonSerializationException) { // if this occurs it means the data is invalid, shouldn't happen but has happened if we change the data format. return string.Empty; } if (blockEditorData == null) return string.Empty; foreach (var row in blockEditorData.BlockValue.ContentData) { foreach (var prop in row.PropertyValues) { // create a temp property with the value // - force it to be culture invariant as the block editor can't handle culture variant element properties prop.Value.PropertyType.Variations = ContentVariation.Nothing; var tempProp = new Property(prop.Value.PropertyType); tempProp.SetValue(prop.Value.Value); var propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias]; if (propEditor == null) { // NOTE: This logic was borrowed from Nested Content and I'm unsure why it exists. // if the property editor doesn't exist I think everything will break anyways? // update the raw value since this is what will get serialized out row.RawPropertyValues[prop.Key] = tempProp.GetValue()?.ToString(); continue; } var dataType = _dataTypeService.GetDataType(prop.Value.PropertyType.DataTypeId); if (dataType == null) { // deal with weird situations by ignoring them (no comment) row.PropertyValues.Remove(prop.Key); _logger.LogWarning( "ToEditor removed property value {PropertyKey} in row {RowId} for property type {PropertyTypeAlias}", prop.Key, row.Key, property.PropertyType.Alias); continue; } if (!valEditors.TryGetValue(dataType.Id, out var valEditor)) { var tempConfig = dataType.Configuration; valEditor = propEditor.GetValueEditor(tempConfig); valEditors.Add(dataType.Id, valEditor); } var convValue = valEditor.ToEditor(tempProp); // update the raw value since this is what will get serialized out row.RawPropertyValues[prop.Key] = convValue; } } // return json convertable object return blockEditorData.BlockValue; } /// /// Ensure that sub-editor values are translated through their FromEditor methods /// /// /// /// public override object FromEditor(ContentPropertyData editorValue, object currentValue) { if (editorValue.Value == null || string.IsNullOrWhiteSpace(editorValue.Value.ToString())) return null; BlockEditorData blockEditorData; try { blockEditorData = _blockEditorValues.DeserializeAndClean(editorValue.Value); } catch (JsonSerializationException) { // if this occurs it means the data is invalid, shouldn't happen but has happened if we change the data format. return string.Empty; } if (blockEditorData == null || blockEditorData.BlockValue.ContentData.Count == 0) return string.Empty; foreach (var row in blockEditorData.BlockValue.ContentData) { foreach (var prop in row.PropertyValues) { // Fetch the property types prevalue var propConfiguration = _dataTypeService.GetDataType(prop.Value.PropertyType.DataTypeId).Configuration; // Lookup the property editor var propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias]; if (propEditor == null) continue; // Create a fake content property data object var contentPropData = new ContentPropertyData(prop.Value.Value, propConfiguration); // Get the property editor to do it's conversion var newValue = propEditor.GetValueEditor().FromEditor(contentPropData, prop.Value.Value); // update the raw value since this is what will get serialized out row.RawPropertyValues[prop.Key] = newValue; } } // return json return JsonConvert.SerializeObject(blockEditorData.BlockValue); } #endregion } /// /// Validates the min/max of the block editor /// private class MinMaxValidator : IValueValidator { private readonly BlockEditorValues _blockEditorValues; private readonly ILocalizedTextService _textService; public MinMaxValidator(BlockEditorValues blockEditorValues, ILocalizedTextService textService) { _blockEditorValues = blockEditorValues; _textService = textService; } public IEnumerable Validate(object value, string valueType, object dataTypeConfiguration) { var blockConfig = (BlockListConfiguration)dataTypeConfiguration; if (blockConfig == null) yield break; var validationLimit = blockConfig.ValidationLimit; if (validationLimit == null) yield break; var blockEditorData = _blockEditorValues.DeserializeAndClean(value); if ((blockEditorData == null && validationLimit.Min.HasValue && validationLimit.Min > 0) || (blockEditorData != null && validationLimit.Min.HasValue && blockEditorData.Layout.Count() < validationLimit.Min)) { yield return new ValidationResult( _textService.Localize("validation", "entriesShort", new[] { validationLimit.Min.ToString(), (validationLimit.Min - blockEditorData.Layout.Count()).ToString() }), new[] { "minCount" }); } if (blockEditorData != null && validationLimit.Max.HasValue && blockEditorData.Layout.Count() > validationLimit.Max) { yield return new ValidationResult( _textService.Localize("validation", "entriesExceed", new[] { validationLimit.Max.ToString(), (blockEditorData.Layout.Count() - validationLimit.Max).ToString() }), new[] { "maxCount" }); } } } internal class BlockEditorValidator : ComplexEditorValidator { private readonly BlockEditorValues _blockEditorValues; private readonly IContentTypeService _contentTypeService; public BlockEditorValidator(IPropertyValidationService propertyValidationService, BlockEditorValues blockEditorValues, IContentTypeService contentTypeService) : base(propertyValidationService) { _blockEditorValues = blockEditorValues; _contentTypeService = contentTypeService; } protected override IEnumerable GetElementTypeValidation(object value) { var blockEditorData = _blockEditorValues.DeserializeAndClean(value); if (blockEditorData != null) { // There is no guarantee that the client will post data for every property defined in the Element Type but we still // need to validate that data for each property especially for things like 'required' data to work. // Lookup all element types for all content/settings and then we can populate any empty properties. var allElements = blockEditorData.BlockValue.ContentData.Concat(blockEditorData.BlockValue.SettingsData).ToList(); var allElementTypes = _contentTypeService.GetAll(allElements.Select(x => x.ContentTypeKey).ToArray()).ToDictionary(x => x.Key); foreach (var row in allElements) { if (!allElementTypes.TryGetValue(row.ContentTypeKey, out var elementType)) throw new InvalidOperationException($"No element type found with key {row.ContentTypeKey}"); // now ensure missing properties foreach (var elementTypeProp in elementType.CompositionPropertyTypes) { if (!row.PropertyValues.ContainsKey(elementTypeProp.Alias)) { // set values to null row.PropertyValues[elementTypeProp.Alias] = new BlockItemData.BlockPropertyValue(null, elementTypeProp); row.RawPropertyValues[elementTypeProp.Alias] = null; } } var elementValidation = new ElementTypeValidationModel(row.ContentTypeAlias, row.Key); foreach (var prop in row.PropertyValues) { elementValidation.AddPropertyTypeValidation( new PropertyTypeValidationModel(prop.Value.PropertyType, prop.Value.Value)); } yield return elementValidation; } } } } /// /// Used to deserialize json values and clean up any values based on the existence of element types and layout structure /// internal class BlockEditorValues { private readonly Lazy> _contentTypes; private readonly BlockEditorDataConverter _dataConverter; private readonly ILogger _logger; public BlockEditorValues(BlockEditorDataConverter dataConverter, IContentTypeService contentTypeService, ILogger logger) { _contentTypes = new Lazy>(() => contentTypeService.GetAll().ToDictionary(c => c.Key)); _dataConverter = dataConverter; _logger = logger; } private IContentType GetElementType(BlockItemData item) { _contentTypes.Value.TryGetValue(item.ContentTypeKey, out var contentType); return contentType; } public BlockEditorData DeserializeAndClean(object propertyValue) { if (propertyValue == null || string.IsNullOrWhiteSpace(propertyValue.ToString())) return null; var blockEditorData = _dataConverter.Deserialize(propertyValue.ToString()); if (blockEditorData.BlockValue.ContentData.Count == 0) { // if there's no content ensure there's no settings too blockEditorData.BlockValue.SettingsData.Clear(); return null; } var contentTypePropertyTypes = new Dictionary>(); // filter out any content that isn't referenced in the layout references foreach (var block in blockEditorData.BlockValue.ContentData.Where(x => blockEditorData.References.Any(r => r.ContentUdi == x.Udi))) { ResolveBlockItemData(block, contentTypePropertyTypes); } // filter out any settings that isn't referenced in the layout references foreach (var block in blockEditorData.BlockValue.SettingsData.Where(x => blockEditorData.References.Any(r => r.SettingsUdi == x.Udi))) { ResolveBlockItemData(block, contentTypePropertyTypes); } // remove blocks that couldn't be resolved blockEditorData.BlockValue.ContentData.RemoveAll(x => x.ContentTypeAlias.IsNullOrWhiteSpace()); blockEditorData.BlockValue.SettingsData.RemoveAll(x => x.ContentTypeAlias.IsNullOrWhiteSpace()); return blockEditorData; } private bool ResolveBlockItemData(BlockItemData block, Dictionary> contentTypePropertyTypes) { var contentType = GetElementType(block); if (contentType == null) return false; // get the prop types for this content type but keep a dictionary of found ones so we don't have to keep re-looking and re-creating // objects on each iteration. if (!contentTypePropertyTypes.TryGetValue(contentType.Alias, out var propertyTypes)) propertyTypes = contentTypePropertyTypes[contentType.Alias] = contentType.CompositionPropertyTypes.ToDictionary(x => x.Alias, x => x); var propValues = new Dictionary(); // find any keys that are not real property types and remove them foreach (var prop in block.RawPropertyValues.ToList()) { // doesn't exist so remove it if (!propertyTypes.TryGetValue(prop.Key, out var propType)) { block.RawPropertyValues.Remove(prop.Key); _logger.LogWarning("The property {PropertyKey} for block {BlockKey} was removed because the property type {PropertyTypeAlias} was not found on {ContentTypeAlias}", prop.Key, block.Key, prop.Key, contentType.Alias); } else { // set the value to include the resolved property type propValues[prop.Key] = new BlockItemData.BlockPropertyValue(prop.Value, propType); } } block.ContentTypeAlias = contentType.Alias; block.PropertyValues = propValues; return true; } } #endregion } }