diff --git a/src/Umbraco.Core/Notifications/ContentScaffoldedNotification.cs b/src/Umbraco.Core/Notifications/ContentScaffoldedNotification.cs new file mode 100644 index 0000000000..47eda5468d --- /dev/null +++ b/src/Umbraco.Core/Notifications/ContentScaffoldedNotification.cs @@ -0,0 +1,18 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// Notification that is send out when a Content item has been scaffolded from an original item and basic cleaning has been performed +/// +public sealed class ContentScaffoldedNotification : ScaffoldedNotification +{ + public ContentScaffoldedNotification(IContent original, IContent scaffold, int parentId, EventMessages messages) + : base(original, scaffold, parentId, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ScaffoldedNotification.cs b/src/Umbraco.Core/Notifications/ScaffoldedNotification.cs new file mode 100644 index 0000000000..f64bfd3933 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ScaffoldedNotification.cs @@ -0,0 +1,23 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Events; + +namespace Umbraco.Cms.Core.Notifications; + +public abstract class ScaffoldedNotification : CancelableObjectNotification + where T : class +{ + protected ScaffoldedNotification(T original, T scaffold, int parentId, EventMessages messages) + : base(original, messages) + { + Scaffold = scaffold; + ParentId = parentId; + } + + public T Original => Target; + + public T Scaffold { get; } + + public int ParentId { get; } +} diff --git a/src/Umbraco.Core/PropertyEditors/DataValueReferenceFactoryCollection.cs b/src/Umbraco.Core/PropertyEditors/DataValueReferenceFactoryCollection.cs index c173e766e0..e15f581809 100644 --- a/src/Umbraco.Core/PropertyEditors/DataValueReferenceFactoryCollection.cs +++ b/src/Umbraco.Core/PropertyEditors/DataValueReferenceFactoryCollection.cs @@ -32,15 +32,23 @@ public class DataValueReferenceFactoryCollection : BuilderCollectionBase(); - foreach (IProperty property in properties) + // Group by property editor alias to avoid duplicate lookups and optimize value parsing + foreach (var propertyValuesByPropertyEditorAlias in properties.GroupBy(x => x.PropertyType.PropertyEditorAlias, x => x.Values)) { - if (!propertyEditors.TryGet(property.PropertyType.PropertyEditorAlias, out IDataEditor? dataEditor)) + if (!propertyEditors.TryGet(propertyValuesByPropertyEditorAlias.Key, out IDataEditor? dataEditor)) { continue; } - // Only use edited value for now - references.UnionWith(GetReferences(dataEditor, property.Values.Select(x => x.EditedValue))); + // Use distinct values to avoid duplicate parsing of the same value + var values = new HashSet(properties.Count); + foreach (IPropertyValue propertyValue in propertyValuesByPropertyEditorAlias.SelectMany(x => x)) + { + values.Add(propertyValue.EditedValue); + values.Add(propertyValue.PublishedValue); + } + + references.UnionWith(GetReferences(dataEditor, values)); } return references; diff --git a/src/Umbraco.Core/PublishedCache/PublishedCacheBase.cs b/src/Umbraco.Core/PublishedCache/PublishedCacheBase.cs index c20ebf9284..2d4345c053 100644 --- a/src/Umbraco.Core/PublishedCache/PublishedCacheBase.cs +++ b/src/Umbraco.Core/PublishedCache/PublishedCacheBase.cs @@ -1,6 +1,8 @@ using System.Xml.XPath; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Xml; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PublishedCache; @@ -9,10 +11,24 @@ public abstract class PublishedCacheBase : IPublishedCache { private readonly IVariationContextAccessor? _variationContextAccessor; - public PublishedCacheBase(IVariationContextAccessor variationContextAccessor) => _variationContextAccessor = - variationContextAccessor ?? throw new ArgumentNullException(nameof(variationContextAccessor)); - protected PublishedCacheBase(bool previewDefault) => PreviewDefault = previewDefault; + [Obsolete("Use ctor with all parameters. This will be removed in V15")] + public PublishedCacheBase(IVariationContextAccessor variationContextAccessor) + : this(variationContextAccessor, false) + { + } + + [Obsolete("Use ctor with all parameters. This will be removed in V15")] + protected PublishedCacheBase(bool previewDefault) + : this(StaticServiceProvider.Instance.GetRequiredService(), previewDefault) + { + } + + public PublishedCacheBase(IVariationContextAccessor variationContextAccessor, bool previewDefault) + { + _variationContextAccessor = variationContextAccessor; + PreviewDefault = previewDefault; + } public bool PreviewDefault { get; } diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index d6531ca6e4..b7f83d7162 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -347,10 +347,13 @@ public static partial class UmbracoBuilderExtensions builder .AddNotificationHandler() .AddNotificationHandler() + .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() + .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() + .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs index 6569ad4d89..d917f90f33 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs @@ -1082,20 +1082,24 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement protected void PersistRelations(TEntity entity) { - // Get all references from our core built in DataEditors/Property Editors - // Along with seeing if developers want to collect additional references from the DataValueReferenceFactories collection + // Get all references and automatic relation type aliases ISet references = _dataValueReferenceFactories.GetAllReferences(entity.Properties, PropertyEditors); - - // First delete all auto-relations for this entity ISet automaticRelationTypeAliases = _dataValueReferenceFactories.GetAllAutomaticRelationTypesAliases(PropertyEditors); - RelationRepository.DeleteByParent(entity.Id, automaticRelationTypeAliases.ToArray()); if (references.Count == 0) { + // Delete all relations using the automatic relation type aliases + RelationRepository.DeleteByParent(entity.Id, automaticRelationTypeAliases.ToArray()); + // No need to add new references/relations return; } + // Lookup all relation type IDs + var relationTypeLookup = RelationTypeRepository.GetMany(Array.Empty()) + .Where(x => automaticRelationTypeAliases.Contains(x.Alias)) + .ToDictionary(x => x.Alias, x => x.Id); + // Lookup node IDs for all GUID based UDIs IEnumerable keys = references.Select(x => x.Udi).OfType().Select(x => x.Guid); var keysLookup = Database.FetchByGroups(keys, Constants.Sql.MaxParameterCount, guids => @@ -1106,15 +1110,17 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement .WhereIn(x => x.UniqueId, guids); }).ToDictionary(x => x.UniqueId, x => x.NodeId); - // Lookup all relation type IDs - var relationTypeLookup = RelationTypeRepository.GetMany(Array.Empty()).ToDictionary(x => x.Alias, x => x.Id); - // Get all valid relations - var relations = new List(references.Count); + var relations = new List<(int ChildId, int RelationTypeId)>(references.Count); foreach (UmbracoEntityReference reference in references) { - if (!automaticRelationTypeAliases.Contains(reference.RelationTypeAlias)) + if (string.IsNullOrEmpty(reference.RelationTypeAlias)) { + // Reference does not specify a relation type alias, so skip adding a relation + Logger.LogDebug("The reference to {Udi} does not specify a relation type alias, so it will not be saved as relation.", reference.Udi); + } + else if (!automaticRelationTypeAliases.Contains(reference.RelationTypeAlias)) + { // Returning a reference that doesn't use an automatic relation type is an issue that should be fixed in code Logger.LogError("The reference to {Udi} uses a relation type {RelationTypeAlias} that is not an automatic relation type.", reference.Udi, reference.RelationTypeAlias); } @@ -1130,12 +1136,24 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement } else { - relations.Add(new ReadOnlyRelation(entity.Id, id, relationTypeId)); + relations.Add((id, relationTypeId)); } } - // Save bulk relations - RelationRepository.SaveBulk(relations); + // Get all existing relations (optimize for adding new and keeping existing relations) + var query = Query().Where(x => x.ParentId == entity.Id).WhereIn(x => x.RelationTypeId, relationTypeLookup.Values); + var existingRelations = RelationRepository.GetPagedRelationsByQuery(query, 0, int.MaxValue, out _, null) + .ToDictionary(x => (x.ChildId, x.RelationTypeId)); // Relations are unique by parent ID, child ID and relation type ID + + // Add relations that don't exist yet + var relationsToAdd = relations.Except(existingRelations.Keys).Select(x => new ReadOnlyRelation(entity.Id, x.ChildId, x.RelationTypeId)); + RelationRepository.SaveBulk(relationsToAdd); + + // Delete relations that don't exist anymore + foreach (IRelation relation in existingRelations.Where(x => !relations.Contains(x.Key)).Select(x => x.Value)) + { + RelationRepository.Delete(relation); + } } /// diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs index 9739553b99..c281b0758e 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs @@ -48,14 +48,16 @@ internal abstract class BlockEditorPropertyValueEditor : DataValueEditor, IDataV /// public IEnumerable GetReferences(object? value) { - foreach (BlockItemData.BlockPropertyValue propertyValue in GetAllPropertyValues(value)) + // Group by property editor alias to avoid duplicate lookups and optimize value parsing + foreach (var valuesByPropertyEditorAlias in GetAllPropertyValues(value).GroupBy(x => x.PropertyType.PropertyEditorAlias, x => x.Value)) { - if (!_propertyEditors.TryGet(propertyValue.PropertyType.PropertyEditorAlias, out IDataEditor? dataEditor)) + if (!_propertyEditors.TryGet(valuesByPropertyEditorAlias.Key, out IDataEditor? dataEditor)) { continue; } - foreach (UmbracoEntityReference reference in _dataValueReferenceFactories.GetReferences(dataEditor, propertyValue.Value)) + // Use distinct values to avoid duplicate parsing of the same value + foreach (UmbracoEntityReference reference in _dataValueReferenceFactories.GetReferences(dataEditor, valuesByPropertyEditorAlias.Distinct())) { yield return reference; } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ComplexPropertyEditorContentNotificationHandler.cs b/src/Umbraco.Infrastructure/PropertyEditors/ComplexPropertyEditorContentNotificationHandler.cs index 2b4ac75042..ce867d29e4 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ComplexPropertyEditorContentNotificationHandler.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ComplexPropertyEditorContentNotificationHandler.cs @@ -10,9 +10,16 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; +/// +/// Handles nested Udi keys when +/// - saving: Empty keys get generated +/// - copy: keys get replaced by new ones while keeping references intact +/// - scaffolding: keys get replaced by new ones while keeping references intact +/// public abstract class ComplexPropertyEditorContentNotificationHandler : INotificationHandler, - INotificationHandler + INotificationHandler, + INotificationHandler { protected abstract string EditorAlias { get; } @@ -31,6 +38,12 @@ public abstract class ComplexPropertyEditorContentNotificationHandler : } } + public void Handle(ContentScaffoldedNotification notification) + { + IEnumerable props = notification.Scaffold.GetPropertiesByEditor(EditorAlias); + UpdatePropertyValues(props, false); + } + protected abstract string FormatPropertyValue(string rawJson, bool onlyMissingKeys); private void UpdatePropertyValues(IEnumerable props, bool onlyMissingKeys) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs index 87d80e3972..b6eae8cb9f 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs @@ -146,14 +146,16 @@ public class NestedContentPropertyEditor : DataEditor /// public IEnumerable GetReferences(object? value) { - foreach (NestedContentValues.NestedContentPropertyValue propertyValue in GetAllPropertyValues(value)) + // Group by property editor alias to avoid duplicate lookups and optimize value parsing + foreach (var valuesByPropertyEditorAlias in GetAllPropertyValues(value).GroupBy(x => x.PropertyType.PropertyEditorAlias, x => x.Value)) { - if (!_propertyEditors.TryGet(propertyValue.PropertyType.PropertyEditorAlias, out IDataEditor? dataEditor)) + if (!_propertyEditors.TryGet(valuesByPropertyEditorAlias.Key, out IDataEditor? dataEditor)) { continue; } - foreach (UmbracoEntityReference reference in _dataValueReferenceFactories.GetReferences(dataEditor, propertyValue.Value)) + // Use distinct values to avoid duplicate parsing of the same value + foreach (UmbracoEntityReference reference in _dataValueReferenceFactories.GetReferences(dataEditor, valuesByPropertyEditorAlias.Distinct())) { yield return reference; } diff --git a/src/Umbraco.PublishedCache.NuCache/ContentCache.cs b/src/Umbraco.PublishedCache.NuCache/ContentCache.cs index d854de4b95..6897f04d72 100644 --- a/src/Umbraco.PublishedCache.NuCache/ContentCache.cs +++ b/src/Umbraco.PublishedCache.NuCache/ContentCache.cs @@ -36,7 +36,7 @@ public class ContentCache : PublishedCacheBase, IPublishedContentCache, INavigab IDomainCache domainCache, IOptions globalSettings, IVariationContextAccessor variationContextAccessor) - : base(previewDefault) + : base(variationContextAccessor, previewDefault) { _snapshot = snapshot; _snapshotCache = snapshotCache; diff --git a/src/Umbraco.PublishedCache.NuCache/MediaCache.cs b/src/Umbraco.PublishedCache.NuCache/MediaCache.cs index 014140e884..516d2f555e 100644 --- a/src/Umbraco.PublishedCache.NuCache/MediaCache.cs +++ b/src/Umbraco.PublishedCache.NuCache/MediaCache.cs @@ -17,7 +17,7 @@ public class MediaCache : PublishedCacheBase, IPublishedMediaCache, INavigableDa #region Constructors public MediaCache(bool previewDefault, ContentStore.Snapshot snapshot, IVariationContextAccessor variationContextAccessor) - : base(previewDefault) + : base(variationContextAccessor, previewDefault) { _snapshot = snapshot; _variationContextAccessor = variationContextAccessor; diff --git a/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs b/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs index 6ba81172c8..a1ed75332b 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs @@ -19,6 +19,7 @@ using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.Editors; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Models.Validation; +using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Routing; @@ -683,17 +684,33 @@ public class ContentController : ContentControllerBase [OutgoingEditorModelEvent] public ActionResult GetEmptyBlueprint(int blueprintId, int parentId) { - IContent? blueprint = _contentService.GetBlueprintById(blueprintId); - if (blueprint == null) + IContent? scaffold; + using (ICoreScope scope = _scopeProvider.CreateCoreScope()) { - return NotFound(); + IContent? blueprint = _contentService.GetBlueprintById(blueprintId); + if (blueprint is null) + { + return NotFound(); + } + scaffold = (IContent)blueprint.DeepClone(); + + scaffold.Id = 0; + scaffold.Name = string.Empty; + scaffold.ParentId = parentId; + + var scaffoldedNotification = new ContentScaffoldedNotification(blueprint, scaffold, parentId, new EventMessages()); + if (scope.Notifications.PublishCancelable(scaffoldedNotification)) + { + scope.Complete(); + return Problem("Scaffolding was cancelled"); + } + + scope.Complete(); } - blueprint.Id = 0; - blueprint.Name = string.Empty; - blueprint.ParentId = parentId; - ContentItemDisplay? mapped = _umbracoMapper.Map(blueprint); + + ContentItemDisplay? mapped = _umbracoMapper.Map(scaffold); if (mapped is not null) {