* Block level variance - initial commit * Remove TODOs * Only convert RTEs with blocks * Fix JSON paths for block level property validation * Rename Properties to Values * Correct the JSON path of block level validation errors * Make it possible to skip content migration + ensure backwards compat for the new block format * Partial culture variance publishing at property level * UDI to key conversion for block editors - draft, WIP, do NOT merge 😄 (#16970) * Convert block UDIs to GUIDs * Fix merge * Fix merge issues * Rework nested layout item key parsing for backwards compatibility * Clean-up * Reverse block layout item key calculation * Review * Use IOptions to skip content migrations * Remove "published" from data editor feature naming, as it can be used in other contexts too * Parallel migration * Don't use deprecated constructor * Ensure that layout follows structure for partial publishing * Block Grid element level variance + tests (incl. refactor of element level variation tests) * Rollback unintended changes to Program.cs * Fix bad casing * Minor formatting * RTE element level variance + tests * Remove obsoleted constructors * Use Umbraco.RichText instead of Umbraco.TinyMCE as layout alias for blocks in the RTE * Fix bad merge * Temporary fix for new cache in integration tests * Add EditorAlias to block level properties * Remove the unintended PropertyEditorAlias output for block values * Add EditorAlias to Datatype Item model * Update OpenApi.json * Introduce "expose" for blocks * Strict (explicit) handling for Expose * Improve handling of document and element level variance changes * Refactor variance alignment for published rendering * Block UDI to Key conversion should also register as a conversion * Convert newly added RTE unit test to new RTE blocks format * Minor review changes * Run memory intensive tests on Linux only * Add tests proving that AllowEditInvariantFromNonDefault has effect for block level variance too * Fix the Platform annotations * Removed Platform annotations for tests. * Fix merge * Obsolete old PublishCulture extension * More fixing bad merge --------- Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com> Co-authored-by: nikolajlauridsen <nikolajlauridsen@protonmail.ch>
346 lines
15 KiB
C#
346 lines
15 KiB
C#
// Copyright (c) Umbraco.
|
|
// See LICENSE for more details.
|
|
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Umbraco.Cms.Core.Cache;
|
|
using Umbraco.Cms.Core.Cache.PropertyEditors;
|
|
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.Validators;
|
|
using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
|
|
using Umbraco.Cms.Core.Security;
|
|
using Umbraco.Cms.Core.Serialization;
|
|
using Umbraco.Cms.Core.Services;
|
|
using Umbraco.Cms.Core.Strings;
|
|
using Umbraco.Cms.Core.Templates;
|
|
using Umbraco.Extensions;
|
|
|
|
namespace Umbraco.Cms.Core.PropertyEditors;
|
|
|
|
/// <summary>
|
|
/// Represents a rich text property editor.
|
|
/// </summary>
|
|
[DataEditor(
|
|
Constants.PropertyEditors.Aliases.RichText,
|
|
ValueType = ValueTypes.Text,
|
|
ValueEditorIsReusable = true)]
|
|
public class RichTextPropertyEditor : DataEditor
|
|
{
|
|
private readonly IIOHelper _ioHelper;
|
|
private readonly IRichTextPropertyIndexValueFactory _richTextPropertyIndexValueFactory;
|
|
|
|
/// <summary>
|
|
/// The constructor will setup the property editor based on the attribute if one is found.
|
|
/// </summary>
|
|
public RichTextPropertyEditor(
|
|
IDataValueEditorFactory dataValueEditorFactory,
|
|
IIOHelper ioHelper,
|
|
IRichTextPropertyIndexValueFactory richTextPropertyIndexValueFactory)
|
|
: base(dataValueEditorFactory)
|
|
{
|
|
_ioHelper = ioHelper;
|
|
_richTextPropertyIndexValueFactory = richTextPropertyIndexValueFactory;
|
|
SupportsReadOnly = true;
|
|
}
|
|
|
|
public override IPropertyIndexValueFactory PropertyIndexValueFactory => _richTextPropertyIndexValueFactory;
|
|
|
|
public override bool SupportsConfigurableElements => true;
|
|
|
|
/// <inheritdoc />
|
|
public override bool CanMergePartialPropertyValues(IPropertyType propertyType) => propertyType.VariesByCulture() is false;
|
|
|
|
/// <inheritdoc />
|
|
public override object? MergePartialPropertyValueForCulture(object? sourceValue, object? targetValue, string? culture)
|
|
{
|
|
var valueEditor = (RichTextPropertyValueEditor)GetValueEditor();
|
|
return valueEditor.MergePartialPropertyValueForCulture(sourceValue, targetValue, culture);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a custom value editor
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
protected override IDataValueEditor CreateValueEditor() =>
|
|
DataValueEditorFactory.Create<RichTextPropertyValueEditor>(Attribute!);
|
|
|
|
protected override IConfigurationEditor CreateConfigurationEditor() =>
|
|
new RichTextConfigurationEditor(_ioHelper);
|
|
|
|
/// <summary>
|
|
/// A custom value editor to ensure that images and blocks are parsed when being persisted and formatted correctly for
|
|
/// display in the editor
|
|
/// </summary>
|
|
internal class RichTextPropertyValueEditor : BlockValuePropertyValueEditorBase<RichTextBlockValue, RichTextBlockLayoutItem>
|
|
{
|
|
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
|
private readonly ILocalizedTextService _localizedTextService;
|
|
private readonly IHtmlSanitizer _htmlSanitizer;
|
|
private readonly HtmlImageSourceParser _imageSourceParser;
|
|
private readonly HtmlLocalLinkParser _localLinkParser;
|
|
private readonly RichTextEditorPastedImages _pastedImages;
|
|
private readonly IJsonSerializer _jsonSerializer;
|
|
private readonly IBlockEditorElementTypeCache _elementTypeCache;
|
|
private readonly IRichTextRequiredValidator _richTextRequiredValidator;
|
|
private readonly ILogger<RichTextPropertyValueEditor> _logger;
|
|
|
|
public RichTextPropertyValueEditor(
|
|
DataEditorAttribute attribute,
|
|
PropertyEditorCollection propertyEditors,
|
|
IDataTypeConfigurationCache dataTypeReadCache,
|
|
ILogger<RichTextPropertyValueEditor> logger,
|
|
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
|
|
ILocalizedTextService localizedTextService,
|
|
IShortStringHelper shortStringHelper,
|
|
HtmlImageSourceParser imageSourceParser,
|
|
HtmlLocalLinkParser localLinkParser,
|
|
RichTextEditorPastedImages pastedImages,
|
|
IJsonSerializer jsonSerializer,
|
|
IIOHelper ioHelper,
|
|
IHtmlSanitizer htmlSanitizer,
|
|
IBlockEditorElementTypeCache elementTypeCache,
|
|
IPropertyValidationService propertyValidationService,
|
|
DataValueReferenceFactoryCollection dataValueReferenceFactoryCollection,
|
|
IRichTextRequiredValidator richTextRequiredValidator,
|
|
BlockEditorVarianceHandler blockEditorVarianceHandler)
|
|
: base(attribute, propertyEditors, dataTypeReadCache, localizedTextService, logger, shortStringHelper, jsonSerializer, ioHelper, dataValueReferenceFactoryCollection, blockEditorVarianceHandler)
|
|
{
|
|
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
|
_localizedTextService = localizedTextService;
|
|
_imageSourceParser = imageSourceParser;
|
|
_localLinkParser = localLinkParser;
|
|
_pastedImages = pastedImages;
|
|
_htmlSanitizer = htmlSanitizer;
|
|
_elementTypeCache = elementTypeCache;
|
|
_richTextRequiredValidator = richTextRequiredValidator;
|
|
_jsonSerializer = jsonSerializer;
|
|
_logger = logger;
|
|
|
|
Validators.Add(new RichTextEditorBlockValidator(propertyValidationService, CreateBlockEditorValues(), elementTypeCache, jsonSerializer, logger));
|
|
}
|
|
|
|
public override IValueRequiredValidator RequiredValidator => _richTextRequiredValidator;
|
|
|
|
protected override RichTextBlockValue CreateWithLayout(IEnumerable<RichTextBlockLayoutItem> layout) => new(layout);
|
|
|
|
/// <inheritdoc />
|
|
public override object? ConfigurationObject
|
|
{
|
|
get => base.ConfigurationObject;
|
|
set
|
|
{
|
|
if (value == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(value));
|
|
}
|
|
|
|
if (!(value is RichTextConfiguration configuration))
|
|
{
|
|
throw new ArgumentException(
|
|
$"Expected a {typeof(RichTextConfiguration).Name} instance, but got {value.GetType().Name}.",
|
|
nameof(value));
|
|
}
|
|
|
|
base.ConfigurationObject = value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve references from <see cref="IDataValueEditor" /> values
|
|
/// </summary>
|
|
/// <param name="value"></param>
|
|
/// <returns></returns>
|
|
public override IEnumerable<UmbracoEntityReference> GetReferences(object? value)
|
|
{
|
|
if (TryParseEditorValue(value, out RichTextEditorValue? richTextEditorValue) is false)
|
|
{
|
|
return Array.Empty<UmbracoEntityReference>();
|
|
}
|
|
|
|
var references = new List<UmbracoEntityReference>();
|
|
|
|
// image references from markup
|
|
references.AddRange(_imageSourceParser
|
|
.FindUdisFromDataAttributes(richTextEditorValue.Markup)
|
|
.Select(udi => new UmbracoEntityReference(udi)));
|
|
|
|
// local link references from markup
|
|
references.AddRange(_localLinkParser
|
|
.FindUdisFromLocalLinks(richTextEditorValue.Markup)
|
|
.WhereNotNull()
|
|
.Select(udi => new UmbracoEntityReference(udi)));
|
|
|
|
// references from blocksIg
|
|
if (richTextEditorValue.Blocks is not null)
|
|
{
|
|
BlockEditorData<RichTextBlockValue, RichTextBlockLayoutItem>? blockEditorData = ConvertAndClean(richTextEditorValue.Blocks);
|
|
if (blockEditorData is not null)
|
|
{
|
|
references.AddRange(GetBlockValueReferences(blockEditorData.BlockValue));
|
|
}
|
|
}
|
|
|
|
return references;
|
|
}
|
|
|
|
public override IEnumerable<ITag> GetTags(object? value, object? dataTypeConfiguration, int? languageId)
|
|
{
|
|
if (TryParseEditorValue(value, out RichTextEditorValue? richTextEditorValue) is false || richTextEditorValue.Blocks is null)
|
|
{
|
|
return Array.Empty<ITag>();
|
|
}
|
|
|
|
BlockEditorData<RichTextBlockValue, RichTextBlockLayoutItem>? blockEditorData = ConvertAndClean(richTextEditorValue.Blocks);
|
|
if (blockEditorData is null)
|
|
{
|
|
return Array.Empty<ITag>();
|
|
}
|
|
|
|
return GetBlockValueTags(blockEditorData.BlockValue, languageId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Format the data for the editor
|
|
/// </summary>
|
|
/// <param name="property"></param>
|
|
/// <param name="culture"></param>
|
|
/// <param name="segment"></param>
|
|
public override object? ToEditor(IProperty property, string? culture = null, string? segment = null)
|
|
{
|
|
var value = property.GetValue(culture, segment);
|
|
if (TryParseEditorValue(value, out RichTextEditorValue? richTextEditorValue) is false)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
richTextEditorValue.Markup = _imageSourceParser.EnsureImageSources(richTextEditorValue.Markup);
|
|
|
|
// return json convertable object
|
|
return CleanAndMapBlocks(richTextEditorValue, blockValue => MapBlockValueToEditor(property, blockValue, culture, segment));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Format the data for persistence
|
|
/// </summary>
|
|
/// <param name="editorValue"></param>
|
|
/// <param name="currentValue"></param>
|
|
/// <returns></returns>
|
|
public override object? FromEditor(ContentPropertyData editorValue, object? currentValue)
|
|
{
|
|
if (TryParseEditorValue(editorValue.Value, out RichTextEditorValue? richTextEditorValue) is false)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
Guid userKey = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Key ??
|
|
Constants.Security.SuperUserKey;
|
|
|
|
var config = editorValue.DataTypeConfiguration as RichTextConfiguration;
|
|
Guid mediaParentId = config?.MediaParentId ?? Guid.Empty;
|
|
|
|
if (string.IsNullOrWhiteSpace(richTextEditorValue.Markup))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var parseAndSavedTempImages = _pastedImages
|
|
.FindAndPersistPastedTempImagesAsync(richTextEditorValue.Markup, mediaParentId, userKey)
|
|
.GetAwaiter()
|
|
.GetResult();
|
|
var editorValueWithMediaUrlsRemoved = _imageSourceParser.RemoveImageSources(parseAndSavedTempImages);
|
|
var sanitized = _htmlSanitizer.Sanitize(editorValueWithMediaUrlsRemoved);
|
|
|
|
richTextEditorValue.Markup = sanitized.NullOrWhiteSpaceAsNull() ?? string.Empty;
|
|
|
|
RichTextEditorValue cleanedUpRichTextEditorValue = CleanAndMapBlocks(richTextEditorValue, MapBlockValueFromEditor);
|
|
|
|
// return json
|
|
return RichTextPropertyEditorHelper.SerializeRichTextEditorValue(cleanedUpRichTextEditorValue, _jsonSerializer);
|
|
}
|
|
|
|
public override IEnumerable<Guid> ConfiguredElementTypeKeys()
|
|
{
|
|
var configuration = ConfigurationObject as RichTextConfiguration;
|
|
return configuration?.Blocks?.SelectMany(ConfiguredElementTypeKeys) ?? Enumerable.Empty<Guid>();
|
|
}
|
|
|
|
internal override object? MergePartialPropertyValueForCulture(object? sourceValue, object? targetValue, string? culture)
|
|
{
|
|
if (sourceValue is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (TryParseEditorValue(sourceValue, out RichTextEditorValue? sourceRichTextEditorValue) is false
|
|
|| sourceRichTextEditorValue.Blocks is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
BlockEditorData<RichTextBlockValue, RichTextBlockLayoutItem>? sourceBlockEditorData = ConvertAndClean(sourceRichTextEditorValue.Blocks);
|
|
if (sourceBlockEditorData?.Layout is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
TryParseEditorValue(targetValue, out RichTextEditorValue? targetRichTextEditorValue);
|
|
|
|
BlockEditorData<RichTextBlockValue, RichTextBlockLayoutItem> targetBlockEditorData =
|
|
(targetRichTextEditorValue?.Blocks is not null ? ConvertAndClean(targetRichTextEditorValue.Blocks) : null)
|
|
?? new BlockEditorData<RichTextBlockValue, RichTextBlockLayoutItem>([], CreateWithLayout(sourceBlockEditorData.Layout));
|
|
|
|
RichTextBlockValue blocksMergeResult = MergeBlockEditorDataForCulture(sourceBlockEditorData.BlockValue, targetBlockEditorData.BlockValue, culture);
|
|
|
|
// structure is global, and markup follows structure
|
|
var mergedEditorValue = new RichTextEditorValue { Markup = sourceRichTextEditorValue.Markup, Blocks = blocksMergeResult };
|
|
return RichTextPropertyEditorHelper.SerializeRichTextEditorValue(mergedEditorValue, _jsonSerializer);
|
|
}
|
|
|
|
private bool TryParseEditorValue(object? value, [NotNullWhen(true)] out RichTextEditorValue? richTextEditorValue)
|
|
=> RichTextPropertyEditorHelper.TryParseRichTextEditorValue(value, _jsonSerializer, _logger, out richTextEditorValue);
|
|
|
|
private RichTextEditorValue CleanAndMapBlocks(RichTextEditorValue richTextEditorValue, Action<RichTextBlockValue> handleMapping)
|
|
{
|
|
if (richTextEditorValue.Blocks is null)
|
|
{
|
|
// no blocks defined, store empty block value
|
|
return MarkupWithEmptyBlocks();
|
|
}
|
|
|
|
BlockEditorData<RichTextBlockValue, RichTextBlockLayoutItem>? blockEditorData = ConvertAndClean(richTextEditorValue.Blocks);
|
|
|
|
if (blockEditorData is not null)
|
|
{
|
|
handleMapping(blockEditorData.BlockValue);
|
|
return new RichTextEditorValue
|
|
{
|
|
Markup = richTextEditorValue.Markup,
|
|
Blocks = blockEditorData.BlockValue,
|
|
};
|
|
}
|
|
|
|
// could not deserialize the blocks or handle the mapping, store empty block value
|
|
return MarkupWithEmptyBlocks();
|
|
|
|
RichTextEditorValue MarkupWithEmptyBlocks() => new()
|
|
{
|
|
Markup = richTextEditorValue.Markup,
|
|
Blocks = new RichTextBlockValue(),
|
|
};
|
|
}
|
|
|
|
private BlockEditorData<RichTextBlockValue, RichTextBlockLayoutItem>? ConvertAndClean(RichTextBlockValue blockValue)
|
|
{
|
|
BlockEditorValues<RichTextBlockValue, RichTextBlockLayoutItem> blockEditorValues = CreateBlockEditorValues();
|
|
return blockEditorValues.ConvertAndClean(blockValue);
|
|
}
|
|
|
|
private BlockEditorValues<RichTextBlockValue, RichTextBlockLayoutItem> CreateBlockEditorValues()
|
|
=> new(new RichTextEditorBlockDataConverter(_jsonSerializer), _elementTypeCache, _logger);
|
|
}
|
|
}
|