Files
Umbraco-CMS/src/Umbraco.Core/Services/ContentService.cs
yv01p 60cdab8586 refactor(core): delegate Move/Copy/Sort operations to MoveOperationService
ContentService now delegates:
- Move (non-recycle bin moves)
- EmptyRecycleBin, RecycleBinSmells, GetPagedContentInRecycleBin
- Copy (both overloads)
- Sort (both overloads)

MoveToRecycleBin stays in facade for unpublish orchestration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 17:45:34 +00:00

3009 lines
133 KiB
C#

using System.Collections.Immutable;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Exceptions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Persistence;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services.Changes;
using Umbraco.Cms.Core.Strings;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Services;
/// <summary>
/// Implements the content service.
/// </summary>
public class ContentService : RepositoryService, IContentService
{
private readonly IAuditService _auditService;
private readonly IContentTypeRepository _contentTypeRepository;
private readonly IDocumentBlueprintRepository _documentBlueprintRepository;
private readonly IDocumentRepository _documentRepository;
private readonly IEntityRepository _entityRepository;
private readonly ILanguageRepository _languageRepository;
private readonly ILogger<ContentService> _logger;
private readonly Lazy<IPropertyValidationService> _propertyValidationService;
private readonly IShortStringHelper _shortStringHelper;
private readonly ICultureImpactFactory _cultureImpactFactory;
private readonly IUserIdKeyResolver _userIdKeyResolver;
private readonly PropertyEditorCollection _propertyEditorCollection;
private readonly IIdKeyMap _idKeyMap;
private ContentSettings _contentSettings;
private readonly IRelationService _relationService;
private IQuery<IContent>? _queryNotTrashed;
private readonly Lazy<IContentCrudService> _crudServiceLazy;
// Property for convenient access (deferred resolution for both paths)
private IContentCrudService CrudService => _crudServiceLazy.Value;
// Query operation service fields (for Phase 2 extracted query operations)
private readonly IContentQueryOperationService? _queryOperationService;
private readonly Lazy<IContentQueryOperationService>? _queryOperationServiceLazy;
// Version operation service fields (for Phase 3 extracted version operations)
private readonly IContentVersionOperationService? _versionOperationService;
private readonly Lazy<IContentVersionOperationService>? _versionOperationServiceLazy;
// Move operation service fields (for Phase 4 extracted move operations)
private readonly IContentMoveOperationService? _moveOperationService;
private readonly Lazy<IContentMoveOperationService>? _moveOperationServiceLazy;
/// <summary>
/// Gets the query operation service.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown if the service was not properly initialized.</exception>
private IContentQueryOperationService QueryOperationService =>
_queryOperationService ?? _queryOperationServiceLazy?.Value
?? throw new InvalidOperationException("QueryOperationService not initialized. Ensure the service is properly injected via constructor.");
/// <summary>
/// Gets the version operation service.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown if the service was not properly initialized.</exception>
private IContentVersionOperationService VersionOperationService =>
_versionOperationService ?? _versionOperationServiceLazy?.Value
?? throw new InvalidOperationException("VersionOperationService not initialized. Ensure the service is properly injected via constructor.");
/// <summary>
/// Gets the move operation service.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown if the service was not properly initialized.</exception>
private IContentMoveOperationService MoveOperationService =>
_moveOperationService ?? _moveOperationServiceLazy?.Value
?? throw new InvalidOperationException("MoveOperationService not initialized. Ensure the service is properly injected via constructor.");
#region Constructors
[Microsoft.Extensions.DependencyInjection.ActivatorUtilitiesConstructor]
public ContentService(
ICoreScopeProvider provider,
ILoggerFactory loggerFactory,
IEventMessagesFactory eventMessagesFactory,
IDocumentRepository documentRepository,
IEntityRepository entityRepository,
IAuditService auditService,
IContentTypeRepository contentTypeRepository,
IDocumentBlueprintRepository documentBlueprintRepository,
ILanguageRepository languageRepository,
Lazy<IPropertyValidationService> propertyValidationService,
IShortStringHelper shortStringHelper,
ICultureImpactFactory cultureImpactFactory,
IUserIdKeyResolver userIdKeyResolver,
PropertyEditorCollection propertyEditorCollection,
IIdKeyMap idKeyMap,
IOptionsMonitor<ContentSettings> optionsMonitor,
IRelationService relationService,
IContentCrudService crudService,
IContentQueryOperationService queryOperationService, // NEW PARAMETER - Phase 2 query operations
IContentVersionOperationService versionOperationService, // NEW PARAMETER - Phase 3 version operations
IContentMoveOperationService moveOperationService) // NEW PARAMETER - Phase 4 move operations
: base(provider, loggerFactory, eventMessagesFactory)
{
_documentRepository = documentRepository;
_entityRepository = entityRepository;
_auditService = auditService;
_contentTypeRepository = contentTypeRepository;
_documentBlueprintRepository = documentBlueprintRepository;
_languageRepository = languageRepository;
_propertyValidationService = propertyValidationService;
_shortStringHelper = shortStringHelper;
_cultureImpactFactory = cultureImpactFactory;
_userIdKeyResolver = userIdKeyResolver;
_propertyEditorCollection = propertyEditorCollection;
_idKeyMap = idKeyMap;
_contentSettings = optionsMonitor.CurrentValue;
optionsMonitor.OnChange((contentSettings) =>
{
_contentSettings = contentSettings;
});
_relationService = relationService;
_logger = loggerFactory.CreateLogger<ContentService>();
ArgumentNullException.ThrowIfNull(crudService);
// Wrap in Lazy for consistent access pattern (already resolved, so returns immediately)
_crudServiceLazy = new Lazy<IContentCrudService>(() => crudService);
// Phase 2: Query operation service (direct injection)
ArgumentNullException.ThrowIfNull(queryOperationService);
_queryOperationService = queryOperationService;
_queryOperationServiceLazy = null; // Not needed when directly injected
// Phase 3: Version operation service (direct injection)
ArgumentNullException.ThrowIfNull(versionOperationService);
_versionOperationService = versionOperationService;
_versionOperationServiceLazy = null; // Not needed when directly injected
// Phase 4: Move operation service (direct injection)
ArgumentNullException.ThrowIfNull(moveOperationService);
_moveOperationService = moveOperationService;
_moveOperationServiceLazy = null; // Not needed when directly injected
}
[Obsolete("Use the non-obsolete constructor instead. Scheduled removal in v19.")]
[EditorBrowsable(EditorBrowsableState.Never)]
public ContentService(
ICoreScopeProvider provider,
ILoggerFactory loggerFactory,
IEventMessagesFactory eventMessagesFactory,
IDocumentRepository documentRepository,
IEntityRepository entityRepository,
IAuditRepository auditRepository, // Old parameter (kept for signature compatibility)
IContentTypeRepository contentTypeRepository,
IDocumentBlueprintRepository documentBlueprintRepository,
ILanguageRepository languageRepository,
Lazy<IPropertyValidationService> propertyValidationService,
IShortStringHelper shortStringHelper,
ICultureImpactFactory cultureImpactFactory,
IUserIdKeyResolver userIdKeyResolver,
PropertyEditorCollection propertyEditorCollection,
IIdKeyMap idKeyMap,
IOptionsMonitor<ContentSettings> optionsMonitor,
IRelationService relationService)
: base(provider, loggerFactory, eventMessagesFactory)
{
// All existing field assignments...
_documentRepository = documentRepository ?? throw new ArgumentNullException(nameof(documentRepository));
_entityRepository = entityRepository ?? throw new ArgumentNullException(nameof(entityRepository));
_contentTypeRepository = contentTypeRepository ?? throw new ArgumentNullException(nameof(contentTypeRepository));
_documentBlueprintRepository = documentBlueprintRepository ?? throw new ArgumentNullException(nameof(documentBlueprintRepository));
_languageRepository = languageRepository ?? throw new ArgumentNullException(nameof(languageRepository));
_propertyValidationService = propertyValidationService ?? throw new ArgumentNullException(nameof(propertyValidationService));
_shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper));
_cultureImpactFactory = cultureImpactFactory ?? throw new ArgumentNullException(nameof(cultureImpactFactory));
_userIdKeyResolver = userIdKeyResolver ?? throw new ArgumentNullException(nameof(userIdKeyResolver));
_propertyEditorCollection = propertyEditorCollection ?? throw new ArgumentNullException(nameof(propertyEditorCollection));
_idKeyMap = idKeyMap ?? throw new ArgumentNullException(nameof(idKeyMap));
_contentSettings = optionsMonitor?.CurrentValue ?? throw new ArgumentNullException(nameof(optionsMonitor));
optionsMonitor.OnChange((contentSettings) =>
{
_contentSettings = contentSettings;
});
_relationService = relationService ?? throw new ArgumentNullException(nameof(relationService));
_logger = loggerFactory.CreateLogger<ContentService>();
// Lazy resolution of IAuditService (from StaticServiceProvider)
_auditService = StaticServiceProvider.Instance.GetRequiredService<IAuditService>();
// NEW: Lazy resolution of IContentCrudService
_crudServiceLazy = new Lazy<IContentCrudService>(() =>
StaticServiceProvider.Instance.GetRequiredService<IContentCrudService>(),
LazyThreadSafetyMode.ExecutionAndPublication);
// Phase 2: Lazy resolution of IContentQueryOperationService
_queryOperationServiceLazy = new Lazy<IContentQueryOperationService>(() =>
StaticServiceProvider.Instance.GetRequiredService<IContentQueryOperationService>(),
LazyThreadSafetyMode.ExecutionAndPublication);
// Phase 3: Lazy resolution of IContentVersionOperationService
_versionOperationServiceLazy = new Lazy<IContentVersionOperationService>(() =>
StaticServiceProvider.Instance.GetRequiredService<IContentVersionOperationService>(),
LazyThreadSafetyMode.ExecutionAndPublication);
// Phase 4: Lazy resolution of IContentMoveOperationService
_moveOperationServiceLazy = new Lazy<IContentMoveOperationService>(() =>
StaticServiceProvider.Instance.GetRequiredService<IContentMoveOperationService>(),
LazyThreadSafetyMode.ExecutionAndPublication);
}
[Obsolete("Use the non-obsolete constructor instead. Scheduled removal in v19.")]
[EditorBrowsable(EditorBrowsableState.Never)]
public ContentService(
ICoreScopeProvider provider,
ILoggerFactory loggerFactory,
IEventMessagesFactory eventMessagesFactory,
IDocumentRepository documentRepository,
IEntityRepository entityRepository,
IAuditRepository auditRepository, // Old parameter (kept for signature compatibility)
IAuditService auditService,
IContentTypeRepository contentTypeRepository,
IDocumentBlueprintRepository documentBlueprintRepository,
ILanguageRepository languageRepository,
Lazy<IPropertyValidationService> propertyValidationService,
IShortStringHelper shortStringHelper,
ICultureImpactFactory cultureImpactFactory,
IUserIdKeyResolver userIdKeyResolver,
PropertyEditorCollection propertyEditorCollection,
IIdKeyMap idKeyMap,
IOptionsMonitor<ContentSettings> optionsMonitor,
IRelationService relationService)
: base(provider, loggerFactory, eventMessagesFactory)
{
// All existing field assignments...
_documentRepository = documentRepository ?? throw new ArgumentNullException(nameof(documentRepository));
_entityRepository = entityRepository ?? throw new ArgumentNullException(nameof(entityRepository));
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
_contentTypeRepository = contentTypeRepository ?? throw new ArgumentNullException(nameof(contentTypeRepository));
_documentBlueprintRepository = documentBlueprintRepository ?? throw new ArgumentNullException(nameof(documentBlueprintRepository));
_languageRepository = languageRepository ?? throw new ArgumentNullException(nameof(languageRepository));
_propertyValidationService = propertyValidationService ?? throw new ArgumentNullException(nameof(propertyValidationService));
_shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper));
_cultureImpactFactory = cultureImpactFactory ?? throw new ArgumentNullException(nameof(cultureImpactFactory));
_userIdKeyResolver = userIdKeyResolver ?? throw new ArgumentNullException(nameof(userIdKeyResolver));
_propertyEditorCollection = propertyEditorCollection ?? throw new ArgumentNullException(nameof(propertyEditorCollection));
_idKeyMap = idKeyMap ?? throw new ArgumentNullException(nameof(idKeyMap));
_contentSettings = optionsMonitor?.CurrentValue ?? throw new ArgumentNullException(nameof(optionsMonitor));
optionsMonitor.OnChange((contentSettings) =>
{
_contentSettings = contentSettings;
});
_relationService = relationService ?? throw new ArgumentNullException(nameof(relationService));
_logger = loggerFactory.CreateLogger<ContentService>();
// NEW: Lazy resolution of IContentCrudService
_crudServiceLazy = new Lazy<IContentCrudService>(() =>
StaticServiceProvider.Instance.GetRequiredService<IContentCrudService>(),
LazyThreadSafetyMode.ExecutionAndPublication);
// Phase 2: Lazy resolution of IContentQueryOperationService
_queryOperationServiceLazy = new Lazy<IContentQueryOperationService>(() =>
StaticServiceProvider.Instance.GetRequiredService<IContentQueryOperationService>(),
LazyThreadSafetyMode.ExecutionAndPublication);
// Phase 3: Lazy resolution of IContentVersionOperationService
_versionOperationServiceLazy = new Lazy<IContentVersionOperationService>(() =>
StaticServiceProvider.Instance.GetRequiredService<IContentVersionOperationService>(),
LazyThreadSafetyMode.ExecutionAndPublication);
// Phase 4: Lazy resolution of IContentMoveOperationService
_moveOperationServiceLazy = new Lazy<IContentMoveOperationService>(() =>
StaticServiceProvider.Instance.GetRequiredService<IContentMoveOperationService>(),
LazyThreadSafetyMode.ExecutionAndPublication);
}
#endregion
#region Static queries
// lazy-constructed because when the ctor runs, the query factory may not be ready
private IQuery<IContent> QueryNotTrashed =>
_queryNotTrashed ??= Query<IContent>().Where(x => x.Trashed == false);
#endregion
#region Rollback
public OperationResult Rollback(int id, int versionId, string culture = "*", int userId = Constants.Security.SuperUserId)
=> VersionOperationService.Rollback(id, versionId, culture, userId);
#endregion
#region Count
public int CountPublished(string? contentTypeAlias = null)
=> QueryOperationService.CountPublished(contentTypeAlias);
public int Count(string? contentTypeAlias = null)
=> QueryOperationService.Count(contentTypeAlias);
public int CountChildren(int parentId, string? contentTypeAlias = null)
=> QueryOperationService.CountChildren(parentId, contentTypeAlias);
public int CountDescendants(int parentId, string? contentTypeAlias = null)
=> QueryOperationService.CountDescendants(parentId, contentTypeAlias);
#endregion
#region Permissions
/// <summary>
/// Used to bulk update the permissions set for a content item. This will replace all permissions
/// assigned to an entity with a list of user id &amp; permission pairs.
/// </summary>
/// <param name="permissionSet"></param>
public void SetPermissions(EntityPermissionSet permissionSet)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
_documentRepository.ReplaceContentPermissions(permissionSet);
scope.Complete();
}
}
/// <summary>
/// Assigns a single permission to the current content item for the specified group ids
/// </summary>
/// <param name="entity"></param>
/// <param name="permission"></param>
/// <param name="groupIds"></param>
public void SetPermission(IContent entity, string permission, IEnumerable<int> groupIds)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
_documentRepository.AssignEntityPermission(entity, permission, groupIds);
scope.Complete();
}
}
/// <summary>
/// Returns implicit/inherited permissions assigned to the content item for all user groups
/// </summary>
/// <param name="content"></param>
/// <returns></returns>
public EntityPermissionCollection GetPermissions(IContent content)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
return _documentRepository.GetPermissionsForEntity(content.Id);
}
}
#endregion
#region Create
/// <summary>
/// Creates an <see cref="IContent" /> object using the alias of the <see cref="IContentType" />
/// that this Content should based on.
/// </summary>
/// <remarks>
/// Note that using this method will simply return a new IContent without any identity
/// as it has not yet been persisted. It is intended as a shortcut to creating new content objects
/// that does not invoke a save operation against the database.
/// </remarks>
/// <param name="name">Name of the Content object</param>
/// <param name="parentId">Id of Parent for the new Content</param>
/// <param name="contentTypeAlias">Alias of the <see cref="IContentType" /></param>
/// <param name="userId">Optional id of the user creating the content</param>
/// <returns>
/// <see cref="IContent" />
/// </returns>
public IContent Create(string name, Guid parentId, string contentTypeAlias, int userId = Constants.Security.SuperUserId)
=> CrudService.Create(name, parentId, contentTypeAlias, userId);
/// <summary>
/// Creates an <see cref="IContent" /> object of a specified content type.
/// </summary>
/// <remarks>
/// This method simply returns a new, non-persisted, IContent without any identity. It
/// is intended as a shortcut to creating new content objects that does not invoke a save
/// operation against the database.
/// </remarks>
/// <param name="name">The name of the content object.</param>
/// <param name="parentId">The identifier of the parent, or -1.</param>
/// <param name="contentTypeAlias">The alias of the content type.</param>
/// <param name="userId">The optional id of the user creating the content.</param>
/// <returns>The content object.</returns>
public IContent Create(string name, int parentId, string contentTypeAlias, int userId = Constants.Security.SuperUserId)
=> CrudService.Create(name, parentId, contentTypeAlias, userId);
/// <summary>
/// Creates an <see cref="IContent" /> object of a specified content type.
/// </summary>
/// <remarks>
/// This method simply returns a new, non-persisted, IContent without any identity. It
/// is intended as a shortcut to creating new content objects that does not invoke a save
/// operation against the database.
/// </remarks>
/// <param name="name">The name of the content object.</param>
/// <param name="parentId">The identifier of the parent, or -1.</param>
/// <param name="contentType">The content type of the content</param>
/// <param name="userId">The optional id of the user creating the content.</param>
/// <returns>The content object.</returns>
public IContent Create(string name, int parentId, IContentType contentType, int userId = Constants.Security.SuperUserId)
=> CrudService.Create(name, parentId, contentType, userId);
/// <summary>
/// Creates an <see cref="IContent" /> object of a specified content type, under a parent.
/// </summary>
/// <remarks>
/// This method simply returns a new, non-persisted, IContent without any identity. It
/// is intended as a shortcut to creating new content objects that does not invoke a save
/// operation against the database.
/// </remarks>
/// <param name="name">The name of the content object.</param>
/// <param name="parent">The parent content object.</param>
/// <param name="contentTypeAlias">The alias of the content type.</param>
/// <param name="userId">The optional id of the user creating the content.</param>
/// <returns>The content object.</returns>
public IContent Create(string name, IContent? parent, string contentTypeAlias, int userId = Constants.Security.SuperUserId)
=> CrudService.Create(name, parent, contentTypeAlias, userId);
/// <summary>
/// Creates an <see cref="IContent" /> object of a specified content type.
/// </summary>
/// <remarks>This method returns a new, persisted, IContent with an identity.</remarks>
/// <param name="name">The name of the content object.</param>
/// <param name="parentId">The identifier of the parent, or -1.</param>
/// <param name="contentTypeAlias">The alias of the content type.</param>
/// <param name="userId">The optional id of the user creating the content.</param>
/// <returns>The content object.</returns>
public IContent CreateAndSave(string name, int parentId, string contentTypeAlias, int userId = Constants.Security.SuperUserId)
=> CrudService.CreateAndSave(name, parentId, contentTypeAlias, userId);
/// <summary>
/// Creates an <see cref="IContent" /> object of a specified content type, under a parent.
/// </summary>
/// <remarks>This method returns a new, persisted, IContent with an identity.</remarks>
/// <param name="name">The name of the content object.</param>
/// <param name="parent">The parent content object.</param>
/// <param name="contentTypeAlias">The alias of the content type.</param>
/// <param name="userId">The optional id of the user creating the content.</param>
/// <returns>The content object.</returns>
public IContent CreateAndSave(string name, IContent parent, string contentTypeAlias, int userId = Constants.Security.SuperUserId)
=> CrudService.CreateAndSave(name, parent, contentTypeAlias, userId);
#endregion
#region Get, Has, Is
/// <summary>
/// Gets an <see cref="IContent" /> object by Id
/// </summary>
/// <param name="id">Id of the Content to retrieve</param>
/// <returns>
/// <see cref="IContent" />
/// </returns>
public IContent? GetById(int id)
=> CrudService.GetById(id);
/// <summary>
/// Gets an <see cref="IContent" /> object by Id
/// </summary>
/// <param name="ids">Ids of the Content to retrieve</param>
/// <returns>
/// <see cref="IContent" />
/// </returns>
public IEnumerable<IContent> GetByIds(IEnumerable<int> ids)
=> CrudService.GetByIds(ids);
/// <summary>
/// Gets an <see cref="IContent" /> object by its 'UniqueId'
/// </summary>
/// <param name="key">Guid key of the Content to retrieve</param>
/// <returns>
/// <see cref="IContent" />
/// </returns>
public IContent? GetById(Guid key)
=> CrudService.GetById(key);
/// <inheritdoc />
public ContentScheduleCollection GetContentScheduleByContentId(int contentId)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
return _documentRepository.GetContentSchedule(contentId);
}
}
public ContentScheduleCollection GetContentScheduleByContentId(Guid contentId)
{
Attempt<int> idAttempt = _idKeyMap.GetIdForKey(contentId, UmbracoObjectTypes.Document);
if (idAttempt.Success is false)
{
return new ContentScheduleCollection();
}
return GetContentScheduleByContentId(idAttempt.Result);
}
/// <inheritdoc />
public void PersistContentSchedule(IContent content, ContentScheduleCollection contentSchedule)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
_documentRepository.PersistContentSchedule(content, contentSchedule);
scope.Complete();
}
}
/// <summary>
/// </summary>
/// <param name="contents"></param>
/// <param name="userId"></param>
/// <returns></returns>
Attempt<OperationResult?> IContentServiceBase<IContent>.Save(IEnumerable<IContent> contents, int userId) =>
Attempt.Succeed(Save(contents, userId));
/// <summary>
/// Gets <see cref="IContent" /> objects by Ids
/// </summary>
/// <param name="ids">Ids of the Content to retrieve</param>
/// <returns>
/// <see cref="IContent" />
/// </returns>
public IEnumerable<IContent> GetByIds(IEnumerable<Guid> ids)
=> CrudService.GetByIds(ids);
/// <inheritdoc />
public IEnumerable<IContent> GetPagedOfType(
int contentTypeId,
long pageIndex,
int pageSize,
out long totalRecords,
IQuery<IContent>? filter = null,
Ordering? ordering = null)
=> QueryOperationService.GetPagedOfType(contentTypeId, pageIndex, pageSize, out totalRecords, filter, ordering);
/// <inheritdoc />
public IEnumerable<IContent> GetPagedOfTypes(int[] contentTypeIds, long pageIndex, int pageSize, out long totalRecords, IQuery<IContent>? filter, Ordering? ordering = null)
=> QueryOperationService.GetPagedOfTypes(contentTypeIds, pageIndex, pageSize, out totalRecords, filter, ordering);
/// <summary>
/// Gets a collection of <see cref="IContent" /> objects by Level
/// </summary>
/// <param name="level">The level to retrieve Content from</param>
/// <returns>An Enumerable list of <see cref="IContent" /> objects</returns>
/// <remarks>Contrary to most methods, this method filters out trashed content items.</remarks>
public IEnumerable<IContent> GetByLevel(int level)
=> QueryOperationService.GetByLevel(level);
/// <summary>
/// Gets a specific version of an <see cref="IContent" /> item.
/// </summary>
/// <param name="versionId">Id of the version to retrieve</param>
/// <returns>An <see cref="IContent" /> item</returns>
public IContent? GetVersion(int versionId)
=> VersionOperationService.GetVersion(versionId);
/// <summary>
/// Gets a collection of an <see cref="IContent" /> objects versions by Id
/// </summary>
/// <param name="id"></param>
/// <returns>An Enumerable list of <see cref="IContent" /> objects</returns>
public IEnumerable<IContent> GetVersions(int id)
=> VersionOperationService.GetVersions(id);
/// <summary>
/// Gets a collection of an <see cref="IContent" /> objects versions by Id
/// </summary>
/// <returns>An Enumerable list of <see cref="IContent" /> objects</returns>
public IEnumerable<IContent> GetVersionsSlim(int id, int skip, int take)
=> VersionOperationService.GetVersionsSlim(id, skip, take);
/// <summary>
/// Gets a list of all version Ids for the given content item ordered so latest is first
/// </summary>
/// <param name="id"></param>
/// <param name="maxRows">The maximum number of rows to return</param>
/// <returns></returns>
public IEnumerable<int> GetVersionIds(int id, int maxRows)
=> VersionOperationService.GetVersionIds(id, maxRows);
/// <summary>
/// Gets a collection of <see cref="IContent" /> objects, which are ancestors of the current content.
/// </summary>
/// <param name="id">Id of the <see cref="IContent" /> to retrieve ancestors for</param>
/// <returns>An Enumerable list of <see cref="IContent" /> objects</returns>
public IEnumerable<IContent> GetAncestors(int id)
=> CrudService.GetAncestors(id);
/// <summary>
/// Gets a collection of <see cref="IContent" /> objects, which are ancestors of the current content.
/// </summary>
/// <param name="content"><see cref="IContent" /> to retrieve ancestors for</param>
/// <returns>An Enumerable list of <see cref="IContent" /> objects</returns>
public IEnumerable<IContent> GetAncestors(IContent content)
=> CrudService.GetAncestors(content);
/// <summary>
/// Gets a collection of published <see cref="IContent" /> objects by Parent Id
/// </summary>
/// <param name="id">Id of the Parent to retrieve Children from</param>
/// <returns>An Enumerable list of published <see cref="IContent" /> objects</returns>
public IEnumerable<IContent> GetPublishedChildren(int id)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
IQuery<IContent>? query = Query<IContent>().Where(x => x.ParentId == id && x.Published);
return _documentRepository.Get(query).OrderBy(x => x.SortOrder);
}
}
/// <inheritdoc />
public IEnumerable<IContent> GetPagedChildren(int id, long pageIndex, int pageSize, out long totalChildren, IQuery<IContent>? filter = null, Ordering? ordering = null)
=> CrudService.GetPagedChildren(id, pageIndex, pageSize, out totalChildren, filter, ordering);
/// <inheritdoc />
public IEnumerable<IContent> GetPagedDescendants(int id, long pageIndex, int pageSize, out long totalChildren, IQuery<IContent>? filter = null, Ordering? ordering = null)
=> CrudService.GetPagedDescendants(id, pageIndex, pageSize, out totalChildren, filter, ordering);
private IQuery<IContent>? GetPagedDescendantQuery(string contentPath)
{
IQuery<IContent>? query = Query<IContent>();
if (!contentPath.IsNullOrWhiteSpace())
{
query?.Where(x => x.Path.SqlStartsWith($"{contentPath},", TextColumnType.NVarchar));
}
return query;
}
private IEnumerable<IContent> GetPagedLocked(IQuery<IContent>? query, long pageIndex, int pageSize, out long totalChildren, IQuery<IContent>? filter, Ordering? ordering)
{
if (pageIndex < 0)
{
throw new ArgumentOutOfRangeException(nameof(pageIndex));
}
if (pageSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(pageSize));
}
if (ordering == null)
{
throw new ArgumentNullException(nameof(ordering));
}
return _documentRepository.GetPage(query, pageIndex, pageSize, out totalChildren, filter, ordering);
}
/// <summary>
/// Gets the parent of the current content as an <see cref="IContent" /> item.
/// </summary>
/// <param name="id">Id of the <see cref="IContent" /> to retrieve the parent from</param>
/// <returns>Parent <see cref="IContent" /> object</returns>
public IContent? GetParent(int id)
=> CrudService.GetParent(id);
/// <summary>
/// Gets the parent of the current content as an <see cref="IContent" /> item.
/// </summary>
/// <param name="content"><see cref="IContent" /> to retrieve the parent from</param>
/// <returns>Parent <see cref="IContent" /> object</returns>
public IContent? GetParent(IContent? content)
=> CrudService.GetParent(content);
/// <summary>
/// Gets a collection of <see cref="IContent" /> objects, which reside at the first level / root
/// </summary>
/// <returns>An Enumerable list of <see cref="IContent" /> objects</returns>
public IEnumerable<IContent> GetRootContent()
=> CrudService.GetRootContent();
/// <summary>
/// Gets all published content items
/// </summary>
/// <returns></returns>
internal IEnumerable<IContent> GetAllPublished()
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
return _documentRepository.Get(QueryNotTrashed);
}
}
/// <inheritdoc />
public IEnumerable<IContent> GetContentForExpiration(DateTime date)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
return _documentRepository.GetContentForExpiration(date);
}
}
/// <inheritdoc />
public IEnumerable<IContent> GetContentForRelease(DateTime date)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
return _documentRepository.GetContentForRelease(date);
}
}
/// <summary>
/// Gets a collection of an <see cref="IContent" /> objects, which resides in the Recycle Bin
/// </summary>
/// <returns>An Enumerable list of <see cref="IContent" /> objects</returns>
public IEnumerable<IContent> GetPagedContentInRecycleBin(long pageIndex, int pageSize, out long totalRecords, IQuery<IContent>? filter = null, Ordering? ordering = null)
=> MoveOperationService.GetPagedContentInRecycleBin(pageIndex, pageSize, out totalRecords, filter, ordering);
/// <summary>
/// Checks whether an <see cref="IContent" /> item has any children
/// </summary>
/// <param name="id">Id of the <see cref="IContent" /></param>
/// <returns>True if the content has any children otherwise False</returns>
public bool HasChildren(int id)
=> CrudService.HasChildren(id);
/// <summary>
/// Checks whether a document with the specified id exists.
/// </summary>
/// <param name="id">The document id.</param>
/// <returns>True if the document exists; otherwise false.</returns>
public bool Exists(int id)
=> CrudService.Exists(id);
/// <summary>
/// Checks whether a document with the specified key exists.
/// </summary>
/// <param name="key">The document key.</param>
/// <returns>True if the document exists; otherwise false.</returns>
public bool Exists(Guid key)
=> CrudService.Exists(key);
/// <inheritdoc/>
public IDictionary<int, IEnumerable<ContentSchedule>> GetContentSchedulesByIds(Guid[] keys)
{
if (keys.Length == 0)
{
return ImmutableDictionary<int, IEnumerable<ContentSchedule>>.Empty;
}
List<int> contentIds = [];
foreach (var key in keys)
{
Attempt<int> contentId = _idKeyMap.GetIdForKey(key, UmbracoObjectTypes.Document);
if (contentId.Success is false)
{
continue;
}
contentIds.Add(contentId.Result);
}
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
return _documentRepository.GetContentSchedulesByIds(contentIds.ToArray());
}
}
/// <summary>
/// Checks if the passed in <see cref="IContent" /> can be published based on the ancestors publish state.
/// </summary>
/// <param name="content"><see cref="IContent" /> to check if ancestors are published</param>
/// <returns>True if the Content can be published, otherwise False</returns>
public bool IsPathPublishable(IContent content)
{
// fast
if (content.ParentId == Constants.System.Root)
{
return true; // root content is always publishable
}
if (content.Trashed)
{
return false; // trashed content is never publishable
}
// not trashed and has a parent: publishable if the parent is path-published
IContent? parent = GetById(content.ParentId);
return parent == null || IsPathPublished(parent);
}
public bool IsPathPublished(IContent? content)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
return _documentRepository.IsPathPublished(content);
}
}
#endregion
#region Save, Publish, Unpublish
/// <inheritdoc />
public OperationResult Save(IContent content, int? userId = null, ContentScheduleCollection? contentSchedule = null)
=> CrudService.Save(content, userId, contentSchedule);
/// <inheritdoc />
public OperationResult Save(IEnumerable<IContent> contents, int userId = Constants.Security.SuperUserId)
=> CrudService.Save(contents, userId);
/// <inheritdoc/>
public PublishResult Publish(IContent content, string[] cultures, int userId = Constants.Security.SuperUserId)
{
if (content == null)
{
throw new ArgumentNullException(nameof(content));
}
if (cultures is null)
{
throw new ArgumentNullException(nameof(cultures));
}
if (cultures.Any(c => c.IsNullOrWhiteSpace()) || cultures.Distinct().Count() != cultures.Length)
{
throw new ArgumentException("Cultures cannot be null or whitespace", nameof(cultures));
}
cultures = cultures.Select(x => x.EnsureCultureCode()!).ToArray();
EventMessages evtMsgs = EventMessagesFactory.Get();
// we need to guard against unsaved changes before proceeding; the content will be saved, but we're not firing any saved notifications
if (HasUnsavedChanges(content))
{
return new PublishResult(PublishResultType.FailedPublishUnsavedChanges, evtMsgs, content);
}
if (content.Name != null && content.Name.Length > 255)
{
throw new InvalidOperationException("Name cannot be more than 255 characters in length.");
}
PublishedState publishedState = content.PublishedState;
if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished)
{
throw new InvalidOperationException(
$"Cannot save-and-publish (un)publishing content, use the dedicated {nameof(CommitDocumentChanges)} method.");
}
// cannot accept invariant (null or empty) culture for variant content type
// cannot accept a specific culture for invariant content type (but '*' is ok)
if (content.ContentType.VariesByCulture())
{
if (cultures.Length > 1 && cultures.Contains("*"))
{
throw new ArgumentException("Cannot combine wildcard and specific cultures when publishing variant content types.", nameof(cultures));
}
}
else
{
if (cultures.Length == 0)
{
cultures = new[] { "*" };
}
if (cultures[0] != "*" || cultures.Length > 1)
{
throw new ArgumentException($"Only wildcard culture is supported when publishing invariant content types.", nameof(cultures));
}
}
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
var allLangs = _languageRepository.GetMany().ToList();
// this will create the correct culture impact even if culture is * or null
IEnumerable<CultureImpact?> impacts =
cultures.Select(culture => _cultureImpactFactory.Create(culture, IsDefaultCulture(allLangs, culture), content));
// publish the culture(s)
// we don't care about the response here, this response will be rechecked below but we need to set the culture info values now.
var publishTime = DateTime.UtcNow;
foreach (CultureImpact? impact in impacts)
{
content.PublishCulture(impact, publishTime, _propertyEditorCollection);
}
// Change state to publishing
content.PublishedState = PublishedState.Publishing;
PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, new Dictionary<string, object?>(), userId);
scope.Complete();
return result;
}
}
/// <inheritdoc />
public PublishResult Unpublish(IContent content, string? culture = "*", int userId = Constants.Security.SuperUserId)
{
if (content == null)
{
throw new ArgumentNullException(nameof(content));
}
EventMessages evtMsgs = EventMessagesFactory.Get();
culture = culture?.NullOrWhiteSpaceAsNull().EnsureCultureCode();
PublishedState publishedState = content.PublishedState;
if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished)
{
throw new InvalidOperationException(
$"Cannot save-and-publish (un)publishing content, use the dedicated {nameof(CommitDocumentChanges)} method.");
}
// cannot accept invariant (null or empty) culture for variant content type
// cannot accept a specific culture for invariant content type (but '*' is ok)
if (content.ContentType.VariesByCulture())
{
if (culture == null)
{
throw new NotSupportedException("Invariant culture is not supported by variant content types.");
}
}
else
{
if (culture != null && culture != "*")
{
throw new NotSupportedException(
$"Culture \"{culture}\" is not supported by invariant content types.");
}
}
// if the content is not published, nothing to do
if (!content.Published)
{
return new PublishResult(PublishResultType.SuccessUnpublishAlready, evtMsgs, content);
}
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
var allLangs = _languageRepository.GetMany().ToList();
var savingNotification = new ContentSavingNotification(content, evtMsgs);
if (scope.Notifications.PublishCancelable(savingNotification))
{
return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, content);
}
// all cultures = unpublish whole
if (culture == "*" || (!content.ContentType.VariesByCulture() && culture == null))
{
// Unpublish the culture, this will change the document state to Publishing! ... which is expected because this will
// essentially be re-publishing the document with the requested culture removed
// We are however unpublishing all cultures, so we will set this to unpublishing.
content.UnpublishCulture(culture);
content.PublishedState = PublishedState.Unpublishing;
PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, savingNotification.State, userId);
scope.Complete();
return result;
}
else
{
// Unpublish the culture, this will change the document state to Publishing! ... which is expected because this will
// essentially be re-publishing the document with the requested culture removed.
// The call to CommitDocumentChangesInternal will perform all the checks like if this is a mandatory culture or the last culture being unpublished
// and will then unpublish the document accordingly.
// If the result of this is false it means there was no culture to unpublish (i.e. it was already unpublished or it did not exist)
var removed = content.UnpublishCulture(culture);
// Save and publish any changes
PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, savingNotification.State, userId);
scope.Complete();
// In one case the result will be PublishStatusType.FailedPublishNothingToPublish which means that no cultures
// were specified to be published which will be the case when removed is false. In that case
// we want to swap the result type to PublishResultType.SuccessUnpublishAlready (that was the expectation before).
if (result.Result == PublishResultType.FailedPublishNothingToPublish && !removed)
{
return new PublishResult(PublishResultType.SuccessUnpublishAlready, evtMsgs, content);
}
return result;
}
}
}
/// <summary>
/// Publishes/unpublishes any pending publishing changes made to the document.
/// </summary>
/// <remarks>
/// <para>
/// This MUST NOT be called from within this service, this used to be a public API and must only be used outside of
/// this service.
/// Internally in this service, calls must be made to CommitDocumentChangesInternal
/// </para>
/// <para>This is the underlying logic for both publishing and unpublishing any document</para>
/// <para>
/// Pending publishing/unpublishing changes on a document are made with calls to
/// <see cref="ContentRepositoryExtensions.PublishCulture" /> and
/// <see cref="ContentRepositoryExtensions.UnpublishCulture" />.
/// </para>
/// <para>
/// When publishing or unpublishing a single culture, or all cultures, use <see cref="SaveAndPublish" />
/// and <see cref="Unpublish" />. But if the flexibility to both publish and unpublish in a single operation is
/// required
/// then this method needs to be used in combination with <see cref="ContentRepositoryExtensions.PublishCulture" />
/// and <see cref="ContentRepositoryExtensions.UnpublishCulture" />
/// on the content itself - this prepares the content, but does not commit anything - and then, invoke
/// <see cref="CommitDocumentChanges" /> to actually commit the changes to the database.
/// </para>
/// <para>The document is *always* saved, even when publishing fails.</para>
/// </remarks>
internal PublishResult CommitDocumentChanges(IContent content, int userId = Constants.Security.SuperUserId)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
EventMessages evtMsgs = EventMessagesFactory.Get();
scope.WriteLock(Constants.Locks.ContentTree);
var savingNotification = new ContentSavingNotification(content, evtMsgs);
if (scope.Notifications.PublishCancelable(savingNotification))
{
return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, content);
}
var allLangs = _languageRepository.GetMany().ToList();
PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, savingNotification.State, userId);
scope.Complete();
return result;
}
}
/// <summary>
/// Handles a lot of business logic cases for how the document should be persisted
/// </summary>
/// <param name="scope"></param>
/// <param name="content"></param>
/// <param name="allLangs"></param>
/// <param name="notificationState"></param>
/// <param name="userId"></param>
/// <param name="branchOne"></param>
/// <param name="branchRoot"></param>
/// <param name="eventMessages"></param>
/// <returns></returns>
/// <remarks>
/// <para>
/// Business logic cases such: as unpublishing a mandatory culture, or unpublishing the last culture, checking for
/// pending scheduled publishing, etc... is dealt with in this method.
/// There is quite a lot of cases to take into account along with logic that needs to deal with scheduled
/// saving/publishing, branch saving/publishing, etc...
/// </para>
/// </remarks>
private PublishResult CommitDocumentChangesInternal(
ICoreScope scope,
IContent content,
EventMessages eventMessages,
IReadOnlyCollection<ILanguage> allLangs,
IDictionary<string, object?>? notificationState,
int userId,
bool branchOne = false,
bool branchRoot = false)
{
if (scope == null)
{
throw new ArgumentNullException(nameof(scope));
}
if (content == null)
{
throw new ArgumentNullException(nameof(content));
}
if (eventMessages == null)
{
throw new ArgumentNullException(nameof(eventMessages));
}
PublishResult? publishResult = null;
PublishResult? unpublishResult = null;
// nothing set = republish it all
if (content.PublishedState != PublishedState.Publishing &&
content.PublishedState != PublishedState.Unpublishing)
{
content.PublishedState = PublishedState.Publishing;
}
// State here is either Publishing or Unpublishing
// Publishing to unpublish a culture may end up unpublishing everything so these flags can be flipped later
var publishing = content.PublishedState == PublishedState.Publishing;
var unpublishing = content.PublishedState == PublishedState.Unpublishing;
var variesByCulture = content.ContentType.VariesByCulture();
// Track cultures that are being published, changed, unpublished
IReadOnlyList<string>? culturesPublishing = null;
IReadOnlyList<string>? culturesUnpublishing = null;
IReadOnlyList<string>? culturesChanging = variesByCulture
? content.CultureInfos?.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList()
: null;
var isNew = !content.HasIdentity;
TreeChangeTypes changeType = isNew ? TreeChangeTypes.RefreshNode : TreeChangeTypes.RefreshBranch;
var previouslyPublished = content.HasIdentity && content.Published;
// Inline method to persist the document with the documentRepository since this logic could be called a couple times below
void SaveDocument(IContent c)
{
// save, always
if (c.HasIdentity == false)
{
c.CreatorId = userId;
}
c.WriterId = userId;
// saving does NOT change the published version, unless PublishedState is Publishing or Unpublishing
_documentRepository.Save(c);
}
if (publishing)
{
// Determine cultures publishing/unpublishing which will be based on previous calls to content.PublishCulture and ClearPublishInfo
culturesUnpublishing = content.GetCulturesUnpublishing();
culturesPublishing = variesByCulture
? content.PublishCultureInfos?.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList()
: null;
// ensure that the document can be published, and publish handling events, business rules, etc
publishResult = StrategyCanPublish(
scope,
content, /*checkPath:*/
!branchOne || branchRoot,
culturesPublishing,
culturesUnpublishing,
eventMessages,
allLangs,
notificationState);
if (publishResult.Success)
{
// raise Publishing notification
if (scope.Notifications.PublishCancelable(
new ContentPublishingNotification(content, eventMessages).WithState(notificationState)))
{
_logger.LogInformation("Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, content.Id, "publishing was cancelled");
return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, eventMessages, content);
}
// note: StrategyPublish flips the PublishedState to Publishing!
publishResult = StrategyPublish(content, culturesPublishing, culturesUnpublishing, eventMessages);
// Check if a culture has been unpublished and if there are no cultures left, and then unpublish document as a whole
if (publishResult.Result == PublishResultType.SuccessUnpublishCulture &&
content.PublishCultureInfos?.Count == 0)
{
// This is a special case! We are unpublishing the last culture and to persist that we need to re-publish without any cultures
// so the state needs to remain Publishing to do that. However, we then also need to unpublish the document and to do that
// the state needs to be Unpublishing and it cannot be both. This state is used within the documentRepository to know how to
// persist certain things. So before proceeding below, we need to save the Publishing state to publish no cultures, then we can
// mark the document for Unpublishing.
SaveDocument(content);
// Set the flag to unpublish and continue
unpublishing = content.Published; // if not published yet, nothing to do
}
}
else
{
// in a branch, just give up
if (branchOne && !branchRoot)
{
return publishResult;
}
// Check for mandatory culture missing, and then unpublish document as a whole
if (publishResult.Result == PublishResultType.FailedPublishMandatoryCultureMissing)
{
publishing = false;
unpublishing = content.Published; // if not published yet, nothing to do
// we may end up in a state where we won't publish nor unpublish
// keep going, though, as we want to save anyways
}
// reset published state from temp values (publishing, unpublishing) to original value
// (published, unpublished) in order to save the document, unchanged - yes, this is odd,
// but: (a) it means we don't reproduce the PublishState logic here and (b) setting the
// PublishState to anything other than Publishing or Unpublishing - which is precisely
// what we want to do here - throws
content.Published = content.Published;
}
}
// won't happen in a branch
if (unpublishing)
{
IContent? newest = GetById(content.Id); // ensure we have the newest version - in scope
if (content.VersionId != newest?.VersionId)
{
return new PublishResult(PublishResultType.FailedPublishConcurrencyViolation, eventMessages, content);
}
if (content.Published)
{
// ensure that the document can be unpublished, and unpublish
// handling events, business rules, etc
// note: StrategyUnpublish flips the PublishedState to Unpublishing!
// note: This unpublishes the entire document (not different variants)
unpublishResult = StrategyCanUnpublish(scope, content, eventMessages, notificationState);
if (unpublishResult.Success)
{
unpublishResult = StrategyUnpublish(content, eventMessages);
}
else
{
// reset published state from temp values (publishing, unpublishing) to original value
// (published, unpublished) in order to save the document, unchanged - yes, this is odd,
// but: (a) it means we don't reproduce the PublishState logic here and (b) setting the
// PublishState to anything other than Publishing or Unpublishing - which is precisely
// what we want to do here - throws
content.Published = content.Published;
return unpublishResult;
}
}
else
{
// already unpublished - optimistic concurrency collision, really,
// and I am not sure at all what we should do, better die fast, else
// we may end up corrupting the db
throw new InvalidOperationException("Concurrency collision.");
}
}
// Persist the document
SaveDocument(content);
// we have tried to unpublish - won't happen in a branch
if (unpublishing)
{
// and succeeded, trigger events
if (unpublishResult?.Success ?? false)
{
// events and audit
scope.Notifications.Publish(
new ContentUnpublishedNotification(content, eventMessages).WithState(notificationState));
scope.Notifications.Publish(new ContentTreeChangeNotification(
content,
TreeChangeTypes.RefreshBranch,
variesByCulture ? culturesPublishing.IsCollectionEmpty() ? null : culturesPublishing : null,
variesByCulture ? culturesUnpublishing.IsCollectionEmpty() ? null : culturesUnpublishing : ["*"],
eventMessages));
if (culturesUnpublishing != null)
{
// This will mean that that we unpublished a mandatory culture or we unpublished the last culture.
var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesUnpublishing);
Audit(AuditType.UnpublishVariant, userId, content.Id, $"Unpublished languages: {langs}", langs);
if (publishResult == null)
{
throw new PanicException("publishResult == null - should not happen");
}
switch (publishResult.Result)
{
case PublishResultType.FailedPublishMandatoryCultureMissing:
// Occurs when a mandatory culture was unpublished (which means we tried publishing the document without a mandatory culture)
// Log that the whole content item has been unpublished due to mandatory culture unpublished
Audit(AuditType.Unpublish, userId, content.Id, "Unpublished (mandatory language unpublished)");
return new PublishResult(PublishResultType.SuccessUnpublishMandatoryCulture, eventMessages, content);
case PublishResultType.SuccessUnpublishCulture:
// Occurs when the last culture is unpublished
Audit(AuditType.Unpublish, userId, content.Id, "Unpublished (last language unpublished)");
return new PublishResult(PublishResultType.SuccessUnpublishLastCulture, eventMessages, content);
}
}
Audit(AuditType.Unpublish, userId, content.Id);
return new PublishResult(PublishResultType.SuccessUnpublish, eventMessages, content);
}
// or, failed
scope.Notifications.Publish(new ContentTreeChangeNotification(content, changeType, eventMessages));
return new PublishResult(PublishResultType.FailedUnpublish, eventMessages, content); // bah
}
// we have tried to publish
if (publishing)
{
// and succeeded, trigger events
if (publishResult?.Success ?? false)
{
if (isNew == false && previouslyPublished == false)
{
changeType = TreeChangeTypes.RefreshBranch; // whole branch
}
else if (isNew == false && previouslyPublished)
{
changeType = TreeChangeTypes.RefreshNode; // single node
}
// invalidate the node/branch
// for branches, handled by SaveAndPublishBranch
if (!branchOne)
{
scope.Notifications.Publish(
new ContentTreeChangeNotification(
content,
changeType,
variesByCulture ? culturesPublishing.IsCollectionEmpty() ? null : culturesPublishing : ["*"],
variesByCulture ? culturesUnpublishing.IsCollectionEmpty() ? null : culturesUnpublishing : null,
eventMessages));
scope.Notifications.Publish(
new ContentPublishedNotification(content, eventMessages).WithState(notificationState));
}
// it was not published and now is... descendants that were 'published' (but
// had an unpublished ancestor) are 're-published' ie not explicitly published
// but back as 'published' nevertheless
if (!branchOne && isNew == false && previouslyPublished == false && HasChildren(content.Id))
{
IContent[] descendants = GetPublishedDescendantsLocked(content).ToArray();
scope.Notifications.Publish(
new ContentPublishedNotification(descendants, eventMessages).WithState(notificationState));
}
switch (publishResult.Result)
{
case PublishResultType.SuccessPublish:
Audit(AuditType.Publish, userId, content.Id);
break;
case PublishResultType.SuccessPublishCulture:
if (culturesPublishing != null)
{
var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesPublishing);
Audit(AuditType.PublishVariant, userId, content.Id, $"Published languages: {langs}", langs);
}
break;
case PublishResultType.SuccessUnpublishCulture:
if (culturesUnpublishing != null)
{
var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesUnpublishing);
Audit(AuditType.UnpublishVariant, userId, content.Id, $"Unpublished languages: {langs}", langs);
}
break;
}
return publishResult;
}
}
// should not happen
if (branchOne && !branchRoot)
{
throw new PanicException("branchOne && !branchRoot - should not happen");
}
// if publishing didn't happen or if it has failed, we still need to log which cultures were saved
if (!branchOne && (publishResult == null || !publishResult.Success))
{
if (culturesChanging != null)
{
var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesChanging);
Audit(AuditType.SaveVariant, userId, content.Id, $"Saved languages: {langs}", langs);
}
else
{
Audit(AuditType.Save, userId, content.Id);
}
}
// or, failed
scope.Notifications.Publish(new ContentTreeChangeNotification(content, changeType, eventMessages));
return publishResult!;
}
/// <inheritdoc />
public IEnumerable<PublishResult> PerformScheduledPublish(DateTime date)
{
var allLangs = new Lazy<List<ILanguage>>(() => _languageRepository.GetMany().ToList());
EventMessages evtMsgs = EventMessagesFactory.Get();
var results = new List<PublishResult>();
PerformScheduledPublishingRelease(date, results, evtMsgs, allLangs);
PerformScheduledPublishingExpiration(date, results, evtMsgs, allLangs);
return results;
}
private void PerformScheduledPublishingExpiration(DateTime date, List<PublishResult> results, EventMessages evtMsgs, Lazy<List<ILanguage>> allLangs)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
// do a fast read without any locks since this executes often to see if we even need to proceed
if (_documentRepository.HasContentForExpiration(date))
{
// now take a write lock since we'll be updating
scope.WriteLock(Constants.Locks.ContentTree);
foreach (IContent d in _documentRepository.GetContentForExpiration(date))
{
ContentScheduleCollection contentSchedule = _documentRepository.GetContentSchedule(d.Id);
if (d.ContentType.VariesByCulture())
{
// find which cultures have pending schedules
var pendingCultures = contentSchedule.GetPending(ContentScheduleAction.Expire, date)
.Select(x => x.Culture)
.Distinct()
.ToList();
if (pendingCultures.Count == 0)
{
continue; // shouldn't happen but no point in processing this document if there's nothing there
}
var savingNotification = new ContentSavingNotification(d, evtMsgs);
if (scope.Notifications.PublishCancelable(savingNotification))
{
results.Add(new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, d));
continue;
}
foreach (var c in pendingCultures)
{
// Clear this schedule for this culture
contentSchedule.Clear(c, ContentScheduleAction.Expire, date);
// set the culture to be published
d.UnpublishCulture(c);
}
_documentRepository.PersistContentSchedule(d, contentSchedule);
PublishResult result = CommitDocumentChangesInternal(scope, d, evtMsgs, allLangs.Value, savingNotification.State, d.WriterId);
if (result.Success == false)
{
_logger.LogError(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, result.Result);
}
results.Add(result);
}
else
{
// Clear this schedule for this culture
contentSchedule.Clear(ContentScheduleAction.Expire, date);
_documentRepository.PersistContentSchedule(d, contentSchedule);
PublishResult result = Unpublish(d, userId: d.WriterId);
if (result.Success == false)
{
_logger.LogError(null, "Failed to unpublish document id={DocumentId}, reason={Reason}.", d.Id, result.Result);
}
results.Add(result);
}
}
_documentRepository.ClearSchedule(date, ContentScheduleAction.Expire);
}
scope.Complete();
}
private void PerformScheduledPublishingRelease(DateTime date, List<PublishResult> results, EventMessages evtMsgs, Lazy<List<ILanguage>> allLangs)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
// do a fast read without any locks since this executes often to see if we even need to proceed
if (_documentRepository.HasContentForRelease(date))
{
// now take a write lock since we'll be updating
scope.WriteLock(Constants.Locks.ContentTree);
foreach (IContent d in _documentRepository.GetContentForRelease(date))
{
ContentScheduleCollection contentSchedule = _documentRepository.GetContentSchedule(d.Id);
if (d.ContentType.VariesByCulture())
{
// find which cultures have pending schedules
var pendingCultures = contentSchedule.GetPending(ContentScheduleAction.Release, date)
.Select(x => x.Culture)
.Distinct()
.ToList();
if (pendingCultures.Count == 0)
{
continue; // shouldn't happen but no point in processing this document if there's nothing there
}
var savingNotification = new ContentSavingNotification(d, evtMsgs);
if (scope.Notifications.PublishCancelable(savingNotification))
{
results.Add(new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, d));
continue;
}
var publishing = true;
foreach (var culture in pendingCultures)
{
// Clear this schedule for this culture
contentSchedule.Clear(culture, ContentScheduleAction.Release, date);
if (d.Trashed)
{
continue; // won't publish
}
// publish the culture values and validate the property values, if validation fails, log the invalid properties so the develeper has an idea of what has failed
IProperty[]? invalidProperties = null;
CultureImpact impact = _cultureImpactFactory.ImpactExplicit(culture, IsDefaultCulture(allLangs.Value, culture));
var tryPublish = d.PublishCulture(impact, date, _propertyEditorCollection) &&
_propertyValidationService.Value.IsPropertyDataValid(d, out invalidProperties, impact);
if (invalidProperties != null && invalidProperties.Length > 0)
{
_logger.LogWarning(
"Scheduled publishing will fail for document {DocumentId} and culture {Culture} because of invalid properties {InvalidProperties}",
d.Id,
culture,
string.Join(",", invalidProperties.Select(x => x.Alias)));
}
publishing &= tryPublish; // set the culture to be published
if (!publishing)
{
}
}
PublishResult result;
if (d.Trashed)
{
result = new PublishResult(PublishResultType.FailedPublishIsTrashed, evtMsgs, d);
}
else if (!publishing)
{
result = new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, d);
}
else
{
_documentRepository.PersistContentSchedule(d, contentSchedule);
result = CommitDocumentChangesInternal(scope, d, evtMsgs, allLangs.Value, savingNotification.State, d.WriterId);
}
if (result.Success == false)
{
_logger.LogError(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, result.Result);
}
results.Add(result);
}
else
{
// Clear this schedule
contentSchedule.Clear(ContentScheduleAction.Release, date);
PublishResult? result = null;
if (d.Trashed)
{
result = new PublishResult(PublishResultType.FailedPublishIsTrashed, evtMsgs, d);
}
else
{
_documentRepository.PersistContentSchedule(d, contentSchedule);
result = Publish(d, d.AvailableCultures.ToArray(), userId: d.WriterId);
}
if (result.Success == false)
{
_logger.LogError(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, result.Result);
}
results.Add(result);
}
}
_documentRepository.ClearSchedule(date, ContentScheduleAction.Release);
}
scope.Complete();
}
// utility 'PublishCultures' func used by SaveAndPublishBranch
private bool PublishBranch_PublishCultures(IContent content, HashSet<string> culturesToPublish, IReadOnlyCollection<ILanguage> allLangs)
{
// variant content type - publish specified cultures
// invariant content type - publish only the invariant culture
var publishTime = DateTime.UtcNow;
if (content.ContentType.VariesByCulture())
{
return culturesToPublish.All(culture =>
{
CultureImpact? impact = _cultureImpactFactory.Create(culture, IsDefaultCulture(allLangs, culture), content);
return content.PublishCulture(impact, publishTime, _propertyEditorCollection) &&
_propertyValidationService.Value.IsPropertyDataValid(content, out _, impact);
});
}
return content.PublishCulture(_cultureImpactFactory.ImpactInvariant(), publishTime, _propertyEditorCollection)
&& _propertyValidationService.Value.IsPropertyDataValid(content, out _, _cultureImpactFactory.ImpactInvariant());
}
// utility 'ShouldPublish' func used by PublishBranch
private static HashSet<string>? PublishBranch_ShouldPublish(ref HashSet<string>? cultures, string c, bool published, bool edited, bool isRoot, PublishBranchFilter publishBranchFilter)
{
// if published, republish
if (published)
{
cultures ??= new HashSet<string>(); // empty means 'already published'
if (edited || publishBranchFilter.HasFlag(PublishBranchFilter.ForceRepublish))
{
cultures.Add(c); // <culture> means 'republish this culture'
}
return cultures;
}
// if not published, publish if force/root else do nothing
if (!publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished) && !isRoot)
{
return cultures; // null means 'nothing to do'
}
cultures ??= new HashSet<string>();
cultures.Add(c); // <culture> means 'publish this culture'
return cultures;
}
/// <inheritdoc />
public IEnumerable<PublishResult> PublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string[] cultures, int userId = Constants.Security.SuperUserId)
{
// note: EditedValue and PublishedValue are objects here, so it is important to .Equals()
// and not to == them, else we would be comparing references, and that is a bad thing
cultures = EnsureCultures(content, cultures);
string? defaultCulture;
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
defaultCulture = _languageRepository.GetDefaultIsoCode();
scope.Complete();
}
// determines cultures to be published
// can be: null (content is not impacted), an empty set (content is impacted but already published), or cultures
HashSet<string>? ShouldPublish(IContent c)
{
var isRoot = c.Id == content.Id;
HashSet<string>? culturesToPublish = null;
// invariant content type
if (!c.ContentType.VariesByCulture())
{
return PublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, publishBranchFilter);
}
// variant content type, specific cultures
if (c.Published)
{
// then some (and maybe all) cultures will be 'already published' (unless forcing),
// others will have to 'republish this culture'
foreach (var culture in cultures)
{
// We could be publishing a parent invariant page, with descendents that are variant.
// So convert the invariant request to a request for the default culture.
var specificCulture = culture == "*" ? defaultCulture : culture;
PublishBranch_ShouldPublish(ref culturesToPublish, specificCulture, c.IsCulturePublished(specificCulture), c.IsCultureEdited(specificCulture), isRoot, publishBranchFilter);
}
return culturesToPublish;
}
// if not published, publish if forcing unpublished/root else do nothing
return publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished) || isRoot
? new HashSet<string>(cultures) // means 'publish specified cultures'
: null; // null means 'nothing to do'
}
return PublishBranch(content, ShouldPublish, PublishBranch_PublishCultures, userId);
}
private static string[] EnsureCultures(IContent content, string[] cultures)
{
// Ensure consistent indication of "all cultures" for variant content.
if (content.ContentType.VariesByCulture() is false && ProvidedCulturesIndicatePublishAll(cultures))
{
cultures = ["*"];
}
return cultures.Select(x => x.EnsureCultureCode()!).ToArray();
}
private static bool ProvidedCulturesIndicatePublishAll(string[] cultures) => cultures.Length == 0 || (cultures.Length == 1 && cultures[0] == "invariant");
internal IEnumerable<PublishResult> PublishBranch(
IContent document,
Func<IContent, HashSet<string>?> shouldPublish,
Func<IContent, HashSet<string>, IReadOnlyCollection<ILanguage>, bool> publishCultures,
int userId = Constants.Security.SuperUserId)
{
if (shouldPublish == null)
{
throw new ArgumentNullException(nameof(shouldPublish));
}
if (publishCultures == null)
{
throw new ArgumentNullException(nameof(publishCultures));
}
EventMessages eventMessages = EventMessagesFactory.Get();
var results = new List<PublishResult>();
var publishedDocuments = new List<IContent>();
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
var allLangs = _languageRepository.GetMany().ToList();
if (!document.HasIdentity)
{
throw new InvalidOperationException("Cannot not branch-publish a new document.");
}
PublishedState publishedState = document.PublishedState;
if (publishedState == PublishedState.Publishing)
{
throw new InvalidOperationException("Cannot mix PublishCulture and SaveAndPublishBranch.");
}
// deal with the branch root - if it fails, abort
HashSet<string>? culturesToPublish = shouldPublish(document);
PublishResult? result = PublishBranchItem(scope, document, culturesToPublish, publishCultures, true, publishedDocuments, eventMessages, userId, allLangs, out IDictionary<string, object?>? notificationState);
if (result != null)
{
results.Add(result);
if (!result.Success)
{
return results;
}
}
HashSet<string> culturesPublished = culturesToPublish ?? [];
// deal with descendants
// if one fails, abort its branch
var exclude = new HashSet<int>();
int count;
var page = 0;
const int pageSize = 100;
do
{
count = 0;
// important to order by Path ASC so make it explicit in case defaults change
// ReSharper disable once RedundantArgumentDefaultValue
foreach (IContent d in GetPagedDescendants(document.Id, page, pageSize, out _, ordering: Ordering.By("Path", Direction.Ascending)))
{
count++;
// if parent is excluded, exclude child too
if (exclude.Contains(d.ParentId))
{
exclude.Add(d.Id);
continue;
}
// no need to check path here, parent has to be published here
culturesToPublish = shouldPublish(d);
result = PublishBranchItem(scope, d, culturesToPublish, publishCultures, false, publishedDocuments, eventMessages, userId, allLangs, out _);
if (result != null)
{
results.Add(result);
if (result.Success)
{
culturesPublished.UnionWith(culturesToPublish ?? []);
continue;
}
}
// if we could not publish the document, cut its branch
exclude.Add(d.Id);
}
page++;
}
while (count > 0);
Audit(AuditType.Publish, userId, document.Id, "Branch published");
// trigger events for the entire branch
// (SaveAndPublishBranchOne does *not* do it)
var variesByCulture = document.ContentType.VariesByCulture();
scope.Notifications.Publish(
new ContentTreeChangeNotification(
document,
TreeChangeTypes.RefreshBranch,
variesByCulture ? culturesPublished.IsCollectionEmpty() ? null : culturesPublished : ["*"],
null,
eventMessages));
scope.Notifications.Publish(new ContentPublishedNotification(publishedDocuments, eventMessages, true).WithState(notificationState));
scope.Complete();
}
return results;
}
// shouldPublish: a function determining whether the document has changes that need to be published
// note - 'force' is handled by 'editing'
// publishValues: a function publishing values (using the appropriate PublishCulture calls)
private PublishResult? PublishBranchItem(
ICoreScope scope,
IContent document,
HashSet<string>? culturesToPublish,
Func<IContent, HashSet<string>, IReadOnlyCollection<ILanguage>,
bool> publishCultures,
bool isRoot,
ICollection<IContent> publishedDocuments,
EventMessages evtMsgs,
int userId,
IReadOnlyCollection<ILanguage> allLangs,
out IDictionary<string, object?>? initialNotificationState)
{
initialNotificationState = new Dictionary<string, object?>();
// we need to guard against unsaved changes before proceeding; the document will be saved, but we're not firing any saved notifications
if (HasUnsavedChanges(document))
{
return new PublishResult(PublishResultType.FailedPublishUnsavedChanges, evtMsgs, document);
}
// null = do not include
if (culturesToPublish == null)
{
return null;
}
// empty = already published
if (culturesToPublish.Count == 0)
{
return new PublishResult(PublishResultType.SuccessPublishAlready, evtMsgs, document);
}
var savingNotification = new ContentSavingNotification(document, evtMsgs);
if (scope.Notifications.PublishCancelable(savingNotification))
{
return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, document);
}
// publish & check if values are valid
if (!publishCultures(document, culturesToPublish, allLangs))
{
// TODO: Based on this callback behavior there is no way to know which properties may have been invalid if this failed, see other results of FailedPublishContentInvalid
return new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, document);
}
PublishResult result = CommitDocumentChangesInternal(scope, document, evtMsgs, allLangs, savingNotification.State, userId, true, isRoot);
if (result.Success)
{
publishedDocuments.Add(document);
}
return result;
}
#endregion
#region Delete
/// <inheritdoc />
public OperationResult Delete(IContent content, int userId = Constants.Security.SuperUserId)
=> CrudService.Delete(content, userId);
private void DeleteLocked(ICoreScope scope, IContent content, EventMessages evtMsgs)
{
void DoDelete(IContent c)
{
_documentRepository.Delete(c);
scope.Notifications.Publish(new ContentDeletedNotification(c, evtMsgs));
// media files deleted by QueuingEventDispatcher
}
const int pageSize = 500;
var total = long.MaxValue;
while (total > 0)
{
// get descendants - ordered from deepest to shallowest
IEnumerable<IContent> descendants = GetPagedDescendants(content.Id, 0, pageSize, out total, ordering: Ordering.By("Path", Direction.Descending));
foreach (IContent c in descendants)
{
DoDelete(c);
}
}
DoDelete(content);
}
// TODO: both DeleteVersions methods below have an issue. Sort of. They do NOT take care of files the way
// Delete does - for a good reason: the file may be referenced by other, non-deleted, versions. BUT,
// if that's not the case, then the file will never be deleted, because when we delete the content,
// the version referencing the file will not be there anymore. SO, we can leak files.
/// <summary>
/// Permanently deletes versions from an <see cref="IContent" /> object prior to a specific date.
/// This method will never delete the latest version of a content item.
/// </summary>
/// <param name="id">Id of the <see cref="IContent" /> object to delete versions from</param>
/// <param name="versionDate">Latest version date</param>
/// <param name="userId">Optional Id of the User deleting versions of a Content object</param>
public void DeleteVersions(int id, DateTime versionDate, int userId = Constants.Security.SuperUserId)
=> VersionOperationService.DeleteVersions(id, versionDate, userId);
/// <summary>
/// Permanently deletes specific version(s) from an <see cref="IContent" /> object.
/// This method will never delete the latest version of a content item.
/// </summary>
/// <param name="id">Id of the <see cref="IContent" /> object to delete a version from</param>
/// <param name="versionId">Id of the version to delete</param>
/// <param name="deletePriorVersions">Boolean indicating whether to delete versions prior to the versionId</param>
/// <param name="userId">Optional Id of the User deleting versions of a Content object</param>
public void DeleteVersion(int id, int versionId, bool deletePriorVersions, int userId = Constants.Security.SuperUserId)
=> VersionOperationService.DeleteVersion(id, versionId, deletePriorVersions, userId);
#endregion
#region Move, RecycleBin
/// <inheritdoc />
public OperationResult MoveToRecycleBin(IContent content, int userId = Constants.Security.SuperUserId)
{
EventMessages eventMessages = EventMessagesFactory.Get();
var moves = new List<(IContent, string)>();
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
var originalPath = content.Path;
var moveEventInfo =
new MoveToRecycleBinEventInfo<IContent>(content, originalPath);
var movingToRecycleBinNotification =
new ContentMovingToRecycleBinNotification(moveEventInfo, eventMessages);
if (scope.Notifications.PublishCancelable(movingToRecycleBinNotification))
{
scope.Complete();
return OperationResult.Cancel(eventMessages); // causes rollback
}
// if it's published we may want to force-unpublish it - that would be backward-compatible... but...
// making a radical decision here: trashing is equivalent to moving under an unpublished node so
// it's NOT unpublishing, only the content is now masked - allowing us to restore it if wanted
// if (content.HasPublishedVersion)
// { }
PerformMoveLocked(content, Constants.System.RecycleBinContent, null, userId, moves, true);
scope.Notifications.Publish(
new ContentTreeChangeNotification(content, TreeChangeTypes.RefreshBranch, eventMessages));
MoveToRecycleBinEventInfo<IContent>[] moveInfo = moves
.Select(x => new MoveToRecycleBinEventInfo<IContent>(x.Item1, x.Item2))
.ToArray();
scope.Notifications.Publish(
new ContentMovedToRecycleBinNotification(moveInfo, eventMessages).WithStateFrom(
movingToRecycleBinNotification));
Audit(AuditType.Move, userId, content.Id, "Moved to recycle bin");
scope.Complete();
}
return OperationResult.Succeed(eventMessages);
}
/// <summary>
/// Moves an <see cref="IContent" /> object to a new location by changing its parent id.
/// </summary>
/// <remarks>
/// If the <see cref="IContent" /> object is already published it will be
/// published after being moved to its new location. Otherwise it'll just
/// be saved with a new parent id.
/// </remarks>
/// <param name="content">The <see cref="IContent" /> to move</param>
/// <param name="parentId">Id of the Content's new Parent</param>
/// <param name="userId">Optional Id of the User moving the Content</param>
public OperationResult Move(IContent content, int parentId, int userId = Constants.Security.SuperUserId)
{
// If moving to recycle bin, use MoveToRecycleBin which handles unpublish
if (parentId == Constants.System.RecycleBinContent)
{
return MoveToRecycleBin(content, userId);
}
return MoveOperationService.Move(content, parentId, userId);
}
// MUST be called from within WriteLock
// trash indicates whether we are trashing, un-trashing, or not changing anything
private void PerformMoveLocked(IContent content, int parentId, IContent? parent, int userId, ICollection<(IContent, string)> moves, bool? trash)
{
content.WriterId = userId;
content.ParentId = parentId;
// get the level delta (old pos to new pos)
// note that recycle bin (id:-20) level is 0!
var levelDelta = 1 - content.Level + (parent?.Level ?? 0);
var paths = new Dictionary<int, string>();
moves.Add((content, content.Path)); // capture original path
// need to store the original path to lookup descendants based on it below
var originalPath = content.Path;
// these will be updated by the repo because we changed parentId
// content.Path = (parent == null ? "-1" : parent.Path) + "," + content.Id;
// content.SortOrder = ((ContentRepository) repository).NextChildSortOrder(parentId);
// content.Level += levelDelta;
PerformMoveContentLocked(content, userId, trash);
// if uow is not immediate, content.Path will be updated only when the UOW commits,
// and because we want it now, we have to calculate it by ourselves
// paths[content.Id] = content.Path;
paths[content.Id] =
(parent == null
? parentId == Constants.System.RecycleBinContent ? "-1,-20" : Constants.System.RootString
: parent.Path) + "," + content.Id;
const int pageSize = 500;
IQuery<IContent>? query = GetPagedDescendantQuery(originalPath);
long total;
do
{
// We always page a page 0 because for each page, we are moving the result so the resulting total will be reduced
IEnumerable<IContent> descendants =
GetPagedLocked(query, 0, pageSize, out total, null, Ordering.By("Path"));
foreach (IContent descendant in descendants)
{
moves.Add((descendant, descendant.Path)); // capture original path
// update path and level since we do not update parentId
descendant.Path = paths[descendant.Id] = paths[descendant.ParentId] + "," + descendant.Id;
descendant.Level += levelDelta;
PerformMoveContentLocked(descendant, userId, trash);
}
}
while (total > pageSize);
}
private void PerformMoveContentLocked(IContent content, int userId, bool? trash)
{
if (trash.HasValue)
{
((ContentBase)content).Trashed = trash.Value;
}
content.WriterId = userId;
_documentRepository.Save(content);
}
public async Task<OperationResult> EmptyRecycleBinAsync(Guid userId)
=> await MoveOperationService.EmptyRecycleBinAsync(userId);
/// <summary>
/// Empties the Recycle Bin by deleting all <see cref="IContent" /> that resides in the bin
/// </summary>
public OperationResult EmptyRecycleBin(int userId = Constants.Security.SuperUserId)
=> MoveOperationService.EmptyRecycleBin(userId);
public bool RecycleBinSmells()
=> MoveOperationService.RecycleBinSmells();
#endregion
#region Others
/// <summary>
/// Copies an <see cref="IContent" /> object by creating a new Content object of the same type and copies all data from
/// the current
/// to the new copy which is returned. Recursively copies all children.
/// </summary>
/// <param name="content">The <see cref="IContent" /> to copy</param>
/// <param name="parentId">Id of the Content's new Parent</param>
/// <param name="relateToOriginal">Boolean indicating whether the copy should be related to the original</param>
/// <param name="userId">Optional Id of the User copying the Content</param>
/// <returns>The newly created <see cref="IContent" /> object</returns>
public IContent? Copy(IContent content, int parentId, bool relateToOriginal, int userId = Constants.Security.SuperUserId)
=> MoveOperationService.Copy(content, parentId, relateToOriginal, userId);
/// <summary>
/// Copies an <see cref="IContent" /> object by creating a new Content object of the same type and copies all data from
/// the current
/// to the new copy which is returned.
/// </summary>
/// <param name="content">The <see cref="IContent" /> to copy</param>
/// <param name="parentId">Id of the Content's new Parent</param>
/// <param name="relateToOriginal">Boolean indicating whether the copy should be related to the original</param>
/// <param name="recursive">A value indicating whether to recursively copy children.</param>
/// <param name="userId">Optional Id of the User copying the Content</param>
/// <returns>The newly created <see cref="IContent" /> object</returns>
public IContent? Copy(IContent content, int parentId, bool relateToOriginal, bool recursive, int userId = Constants.Security.SuperUserId)
=> MoveOperationService.Copy(content, parentId, relateToOriginal, recursive, userId);
private bool TryGetParentKey(int parentId, [NotNullWhen(true)] out Guid? parentKey)
{
Attempt<Guid> parentKeyAttempt = _idKeyMap.GetKeyForId(parentId, UmbracoObjectTypes.Document);
parentKey = parentKeyAttempt.Success ? parentKeyAttempt.Result : null;
return parentKeyAttempt.Success;
}
/// <summary>
/// Sends an <see cref="IContent" /> to Publication, which executes handlers and events for the 'Send to Publication'
/// action.
/// </summary>
/// <param name="content">The <see cref="IContent" /> to send to publication</param>
/// <param name="userId">Optional Id of the User issuing the send to publication</param>
/// <returns>True if sending publication was successful otherwise false</returns>
public bool SendToPublication(IContent? content, int userId = Constants.Security.SuperUserId)
{
if (content is null)
{
return false;
}
EventMessages evtMsgs = EventMessagesFactory.Get();
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
var sendingToPublishNotification = new ContentSendingToPublishNotification(content, evtMsgs);
if (scope.Notifications.PublishCancelable(sendingToPublishNotification))
{
scope.Complete();
return false;
}
// track the cultures changing for auditing
var culturesChanging = content.ContentType.VariesByCulture()
? string.Join(",", content.CultureInfos!.Values.Where(x => x.IsDirty()).Select(x => x.Culture))
: null;
// TODO: Currently there's no way to change track which variant properties have changed, we only have change
// tracking enabled on all values on the Property which doesn't allow us to know which variants have changed.
// in this particular case, determining which cultures have changed works with the above with names since it will
// have always changed if it's been saved in the back office but that's not really fail safe.
// Save before raising event
OperationResult saveResult = Save(content, userId);
// always complete (but maybe return a failed status)
scope.Complete();
if (!saveResult.Success)
{
return saveResult.Success;
}
scope.Notifications.Publish(
new ContentSentToPublishNotification(content, evtMsgs).WithStateFrom(sendingToPublishNotification));
if (culturesChanging != null)
{
Audit(AuditType.SendToPublishVariant, userId, content.Id, $"Send To Publish for cultures: {culturesChanging}", culturesChanging);
}
else
{
Audit(AuditType.SendToPublish, userId, content.Id);
}
return saveResult.Success;
}
}
/// <summary>
/// Sorts a collection of <see cref="IContent" /> objects by updating the SortOrder according
/// to the ordering of items in the passed in <paramref name="items" />.
/// </summary>
/// <remarks>
/// Using this method will ensure that the Published-state is maintained upon sorting
/// so the cache is updated accordingly - as needed.
/// </remarks>
/// <param name="items"></param>
/// <param name="userId"></param>
/// <returns>Result indicating what action was taken when handling the command.</returns>
public OperationResult Sort(IEnumerable<IContent> items, int userId = Constants.Security.SuperUserId)
=> MoveOperationService.Sort(items, userId);
/// <summary>
/// Sorts a collection of <see cref="IContent" /> objects by updating the SortOrder according
/// to the ordering of items identified by the <paramref name="ids" />.
/// </summary>
/// <remarks>
/// Using this method will ensure that the Published-state is maintained upon sorting
/// so the cache is updated accordingly - as needed.
/// </remarks>
/// <param name="ids"></param>
/// <param name="userId"></param>
/// <returns>Result indicating what action was taken when handling the command.</returns>
public OperationResult Sort(IEnumerable<int>? ids, int userId = Constants.Security.SuperUserId)
=> MoveOperationService.Sort(ids, userId);
private static bool HasUnsavedChanges(IContent content) => content.HasIdentity is false || content.IsDirty();
public ContentDataIntegrityReport CheckDataIntegrity(ContentDataIntegrityReportOptions options)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
ContentDataIntegrityReport report = _documentRepository.CheckDataIntegrity(options);
if (report.FixedIssues.Count > 0)
{
// The event args needs a content item so we'll make a fake one with enough properties to not cause a null ref
var root = new Content("root", -1, new ContentType(_shortStringHelper, -1)) { Id = -1, Key = Guid.Empty };
scope.Notifications.Publish(new ContentTreeChangeNotification(root, TreeChangeTypes.RefreshAll, EventMessagesFactory.Get()));
}
scope.Complete();
return report;
}
}
#endregion
#region Internal Methods
/// <summary>
/// Gets a collection of <see cref="IContent" /> descendants by the first Parent.
/// </summary>
/// <param name="content"><see cref="IContent" /> item to retrieve Descendants from</param>
/// <returns>An Enumerable list of <see cref="IContent" /> objects</returns>
internal IEnumerable<IContent> GetPublishedDescendants(IContent content)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
return GetPublishedDescendantsLocked(content).ToArray(); // ToArray important in uow!
}
}
internal IEnumerable<IContent> GetPublishedDescendantsLocked(IContent content)
{
var pathMatch = content.Path + ",";
IQuery<IContent> query = Query<IContent>()
.Where(x => x.Id != content.Id && x.Path.StartsWith(pathMatch) /*&& culture.Trashed == false*/);
IEnumerable<IContent> contents = _documentRepository.Get(query);
// beware! contents contains all published version below content
// including those that are not directly published because below an unpublished content
// these must be filtered out here
var parents = new List<int> { content.Id };
if (contents is not null)
{
foreach (IContent c in contents)
{
if (parents.Contains(c.ParentId))
{
yield return c;
parents.Add(c.Id);
}
}
}
}
#endregion
#region Private Methods
private void Audit(AuditType type, int userId, int objectId, string? message = null, string? parameters = null) =>
AuditAsync(type, userId, objectId, message, parameters).GetAwaiter().GetResult();
private async Task AuditAsync(AuditType type, int userId, int objectId, string? message = null, string? parameters = null)
{
Guid userKey = await _userIdKeyResolver.GetAsync(userId);
await _auditService.AddAsync(
type,
userKey,
objectId,
UmbracoObjectTypes.Document.GetName(),
message,
parameters);
}
private string GetLanguageDetailsForAuditEntry(IEnumerable<string> affectedCultures)
=> GetLanguageDetailsForAuditEntry(_languageRepository.GetMany(), affectedCultures);
private static string GetLanguageDetailsForAuditEntry(IEnumerable<ILanguage> languages, IEnumerable<string> affectedCultures)
{
IEnumerable<string> languageIsoCodes = languages
.Where(x => affectedCultures.InvariantContains(x.IsoCode))
.Select(x => x.IsoCode);
return string.Join(", ", languageIsoCodes);
}
private static bool IsDefaultCulture(IReadOnlyCollection<ILanguage>? langs, string culture) =>
langs?.Any(x => x.IsDefault && x.IsoCode.InvariantEquals(culture)) ?? false;
private bool IsMandatoryCulture(IReadOnlyCollection<ILanguage> langs, string culture) =>
langs.Any(x => x.IsMandatory && x.IsoCode.InvariantEquals(culture));
#endregion
#region Publishing Strategies
/// <summary>
/// Ensures that a document can be published
/// </summary>
/// <param name="scope"></param>
/// <param name="content"></param>
/// <param name="checkPath"></param>
/// <param name="culturesUnpublishing"></param>
/// <param name="evtMsgs"></param>
/// <param name="culturesPublishing"></param>
/// <param name="allLangs"></param>
/// <param name="notificationState"></param>
/// <returns></returns>
private PublishResult StrategyCanPublish(
ICoreScope scope,
IContent content,
bool checkPath,
IReadOnlyList<string>? culturesPublishing,
IReadOnlyCollection<string>? culturesUnpublishing,
EventMessages evtMsgs,
IReadOnlyCollection<ILanguage> allLangs,
IDictionary<string, object?>? notificationState)
{
var variesByCulture = content.ContentType.VariesByCulture();
// If it's null it's invariant
CultureImpact[] impactsToPublish = culturesPublishing == null
? new[] { _cultureImpactFactory.ImpactInvariant() }
: culturesPublishing.Select(x =>
_cultureImpactFactory.ImpactExplicit(
x,
allLangs.Any(lang => lang.IsoCode.InvariantEquals(x) && lang.IsMandatory)))
.ToArray();
// publish the culture(s)
var publishTime = DateTime.UtcNow;
if (!impactsToPublish.All(impact => content.PublishCulture(impact, publishTime, _propertyEditorCollection)))
{
return new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, content);
}
// Validate the property values
IProperty[]? invalidProperties = null;
if (!impactsToPublish.All(x =>
_propertyValidationService.Value.IsPropertyDataValid(content, out invalidProperties, x)))
{
return new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, content)
{
InvalidProperties = invalidProperties,
};
}
// Check if mandatory languages fails, if this fails it will mean anything that the published flag on the document will
// be changed to Unpublished and any culture currently published will not be visible.
if (variesByCulture)
{
if (culturesPublishing == null)
{
throw new InvalidOperationException(
"Internal error, variesByCulture but culturesPublishing is null.");
}
if (content.Published && culturesPublishing.Count == 0 && culturesUnpublishing?.Count == 0)
{
// no published cultures = cannot be published
// This will occur if for example, a culture that is already unpublished is sent to be unpublished again, or vice versa, in that case
// there will be nothing to publish/unpublish.
return new PublishResult(PublishResultType.FailedPublishNothingToPublish, evtMsgs, content);
}
// missing mandatory culture = cannot be published
IEnumerable<string> mandatoryCultures = allLangs.Where(x => x.IsMandatory).Select(x => x.IsoCode);
var mandatoryMissing = mandatoryCultures.Any(x =>
!content.PublishedCultures.Contains(x, StringComparer.OrdinalIgnoreCase));
if (mandatoryMissing)
{
return new PublishResult(PublishResultType.FailedPublishMandatoryCultureMissing, evtMsgs, content);
}
if (culturesPublishing.Count == 0 && culturesUnpublishing?.Count > 0)
{
return new PublishResult(PublishResultType.SuccessUnpublishCulture, evtMsgs, content);
}
}
// ensure that the document has published values
// either because it is 'publishing' or because it already has a published version
if (content.PublishedState != PublishedState.Publishing && content.PublishedVersionId == 0)
{
_logger.LogInformation(
"Document {ContentName} (id={ContentId}) cannot be published: {Reason}",
content.Name,
content.Id,
"document does not have published values");
return new PublishResult(PublishResultType.FailedPublishNothingToPublish, evtMsgs, content);
}
ContentScheduleCollection contentSchedule = _documentRepository.GetContentSchedule(content.Id);
// loop over each culture publishing - or InvariantCulture for invariant
foreach (var culture in culturesPublishing ?? new[] { Constants.System.InvariantCulture })
{
// ensure that the document status is correct
// note: culture will be string.Empty for invariant
switch (content.GetStatus(contentSchedule, culture))
{
case ContentStatus.Expired:
if (!variesByCulture)
{
_logger.LogInformation(
"Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, content.Id, "document has expired");
}
else
{
_logger.LogInformation(
"Document {ContentName} (id={ContentId}) culture {Culture} cannot be published: {Reason}", content.Name, content.Id, culture, "document culture has expired");
}
return new PublishResult(
!variesByCulture
? PublishResultType.FailedPublishHasExpired : PublishResultType.FailedPublishCultureHasExpired,
evtMsgs,
content);
case ContentStatus.AwaitingRelease:
if (!variesByCulture)
{
_logger.LogInformation(
"Document {ContentName} (id={ContentId}) cannot be published: {Reason}",
content.Name,
content.Id,
"document is awaiting release");
}
else
{
_logger.LogInformation(
"Document {ContentName} (id={ContentId}) culture {Culture} cannot be published: {Reason}",
content.Name,
content.Id,
culture,
"document has culture awaiting release");
}
return new PublishResult(
!variesByCulture
? PublishResultType.FailedPublishAwaitingRelease
: PublishResultType.FailedPublishCultureAwaitingRelease,
evtMsgs,
content);
case ContentStatus.Trashed:
_logger.LogInformation(
"Document {ContentName} (id={ContentId}) cannot be published: {Reason}",
content.Name,
content.Id,
"document is trashed");
return new PublishResult(PublishResultType.FailedPublishIsTrashed, evtMsgs, content);
}
}
if (checkPath)
{
// check if the content can be path-published
// root content can be published
// else check ancestors - we know we are not trashed
var pathIsOk = content.ParentId == Constants.System.Root || IsPathPublished(GetParent(content));
if (!pathIsOk)
{
_logger.LogInformation(
"Document {ContentName} (id={ContentId}) cannot be published: {Reason}",
content.Name,
content.Id,
"parent is not published");
return new PublishResult(PublishResultType.FailedPublishPathNotPublished, evtMsgs, content);
}
}
// If we are both publishing and unpublishing cultures, then return a mixed status
if (variesByCulture && culturesPublishing?.Count > 0 && culturesUnpublishing?.Count > 0)
{
return new PublishResult(PublishResultType.SuccessMixedCulture, evtMsgs, content);
}
return new PublishResult(evtMsgs, content);
}
/// <summary>
/// Publishes a document
/// </summary>
/// <param name="content"></param>
/// <param name="culturesUnpublishing"></param>
/// <param name="evtMsgs"></param>
/// <param name="culturesPublishing"></param>
/// <returns></returns>
/// <remarks>
/// It is assumed that all publishing checks have passed before calling this method like
/// <see cref="StrategyCanPublish" />
/// </remarks>
private PublishResult StrategyPublish(
IContent content,
IReadOnlyCollection<string>? culturesPublishing,
IReadOnlyCollection<string>? culturesUnpublishing,
EventMessages evtMsgs)
{
// change state to publishing
content.PublishedState = PublishedState.Publishing;
// if this is a variant then we need to log which cultures have been published/unpublished and return an appropriate result
if (content.ContentType.VariesByCulture())
{
if (content.Published && culturesUnpublishing?.Count == 0 && culturesPublishing?.Count == 0)
{
return new PublishResult(PublishResultType.FailedPublishNothingToPublish, evtMsgs, content);
}
if (culturesUnpublishing?.Count > 0)
{
_logger.LogInformation(
"Document {ContentName} (id={ContentId}) cultures: {Cultures} have been unpublished.",
content.Name,
content.Id,
string.Join(",", culturesUnpublishing));
}
if (culturesPublishing?.Count > 0)
{
_logger.LogInformation(
"Document {ContentName} (id={ContentId}) cultures: {Cultures} have been published.",
content.Name,
content.Id,
string.Join(",", culturesPublishing));
}
if (culturesUnpublishing?.Count > 0 && culturesPublishing?.Count > 0)
{
return new PublishResult(PublishResultType.SuccessMixedCulture, evtMsgs, content);
}
if (culturesUnpublishing?.Count > 0 && culturesPublishing?.Count == 0)
{
return new PublishResult(PublishResultType.SuccessUnpublishCulture, evtMsgs, content);
}
return new PublishResult(PublishResultType.SuccessPublishCulture, evtMsgs, content);
}
_logger.LogInformation("Document {ContentName} (id={ContentId}) has been published.", content.Name, content.Id);
return new PublishResult(evtMsgs, content);
}
/// <summary>
/// Ensures that a document can be unpublished
/// </summary>
/// <param name="scope"></param>
/// <param name="content"></param>
/// <param name="evtMsgs"></param>
/// <param name="notificationState"></param>
/// <returns></returns>
private PublishResult StrategyCanUnpublish(
ICoreScope scope,
IContent content,
EventMessages evtMsgs,
IDictionary<string, object?>? notificationState)
{
// raise Unpublishing notification
ContentUnpublishingNotification notification = new ContentUnpublishingNotification(content, evtMsgs).WithState(notificationState);
var notificationResult = scope.Notifications.PublishCancelable(notification);
if (notificationResult)
{
_logger.LogInformation(
"Document {ContentName} (id={ContentId}) cannot be unpublished: unpublishing was cancelled.", content.Name, content.Id);
return new PublishResult(PublishResultType.FailedUnpublishCancelledByEvent, evtMsgs, content);
}
return new PublishResult(PublishResultType.SuccessUnpublish, evtMsgs, content);
}
/// <summary>
/// Unpublishes a document
/// </summary>
/// <param name="content"></param>
/// <param name="evtMsgs"></param>
/// <returns></returns>
/// <remarks>
/// It is assumed that all unpublishing checks have passed before calling this method like
/// <see cref="StrategyCanUnpublish" />
/// </remarks>
private PublishResult StrategyUnpublish(IContent content, EventMessages evtMsgs)
{
var attempt = new PublishResult(PublishResultType.SuccessUnpublish, evtMsgs, content);
// TODO: What is this check?? we just created this attempt and of course it is Success?!
if (attempt.Success == false)
{
return attempt;
}
// if the document has any release dates set to before now,
// they should be removed so they don't interrupt an unpublish
// otherwise it would remain released == published
ContentScheduleCollection contentSchedule = _documentRepository.GetContentSchedule(content.Id);
IReadOnlyList<ContentSchedule> pastReleases =
contentSchedule.GetPending(ContentScheduleAction.Expire, DateTime.UtcNow);
foreach (ContentSchedule p in pastReleases)
{
contentSchedule.Remove(p);
}
if (pastReleases.Count > 0)
{
_logger.LogInformation(
"Document {ContentName} (id={ContentId}) had its release date removed, because it was unpublished.", content.Name, content.Id);
}
_documentRepository.PersistContentSchedule(content, contentSchedule);
// change state to unpublishing
content.PublishedState = PublishedState.Unpublishing;
_logger.LogInformation("Document {ContentName} (id={ContentId}) has been unpublished.", content.Name, content.Id);
return attempt;
}
#endregion
#region Content Types
/// <summary>
/// Deletes all content of specified type. All children of deleted content is moved to Recycle Bin.
/// </summary>
/// <remarks>
/// <para>This needs extra care and attention as its potentially a dangerous and extensive operation.</para>
/// <para>
/// Deletes content items of the specified type, and only that type. Does *not* handle content types
/// inheritance and compositions, which need to be managed outside of this method.
/// </para>
/// </remarks>
/// <param name="contentTypeIds">Id of the <see cref="IContentType" /></param>
/// <param name="userId">Optional Id of the user issuing the delete operation</param>
public void DeleteOfTypes(IEnumerable<int> contentTypeIds, int userId = Constants.Security.SuperUserId)
{
// TODO: This currently this is called from the ContentTypeService but that needs to change,
// if we are deleting a content type, we should just delete the data and do this operation slightly differently.
// This method will recursively go lookup every content item, check if any of it's descendants are
// of a different type, move them to the recycle bin, then permanently delete the content items.
// The main problem with this is that for every content item being deleted, events are raised...
// which we need for many things like keeping caches in sync, but we can surely do this MUCH better.
var changes = new List<TreeChange<IContent>>();
var moves = new List<(IContent, string)>();
var contentTypeIdsA = contentTypeIds.ToArray();
EventMessages eventMessages = EventMessagesFactory.Get();
// using an immediate uow here because we keep making changes with
// PerformMoveLocked and DeleteLocked that must be applied immediately,
// no point queuing operations
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
IQuery<IContent> query = Query<IContent>().WhereIn(x => x.ContentTypeId, contentTypeIdsA);
IContent[] contents = _documentRepository.Get(query).ToArray();
if (contents is null)
{
return;
}
if (scope.Notifications.PublishCancelable(new ContentDeletingNotification(contents, eventMessages)))
{
scope.Complete();
return;
}
// order by level, descending, so deepest first - that way, we cannot move
// a content of the deleted type, to the recycle bin (and then delete it...)
foreach (IContent content in contents.OrderByDescending(x => x.ParentId))
{
// if it's not trashed yet, and published, we should unpublish
// but... Unpublishing event makes no sense (not going to cancel?) and no need to save
// just raise the event
if (content.Trashed == false && content.Published)
{
scope.Notifications.Publish(new ContentUnpublishedNotification(content, eventMessages));
}
// if current content has children, move them to trash
IContent c = content;
IQuery<IContent> childQuery = Query<IContent>().Where(x => x.ParentId == c.Id);
IEnumerable<IContent> children = _documentRepository.Get(childQuery);
foreach (IContent child in children)
{
// see MoveToRecycleBin
PerformMoveLocked(child, Constants.System.RecycleBinContent, null, userId, moves, true);
changes.Add(new TreeChange<IContent>(content, TreeChangeTypes.RefreshBranch));
}
// delete content
// triggers the deleted event (and handles the files)
DeleteLocked(scope, content, eventMessages);
changes.Add(new TreeChange<IContent>(content, TreeChangeTypes.Remove));
}
MoveToRecycleBinEventInfo<IContent>[] moveInfos = moves
.Select(x => new MoveToRecycleBinEventInfo<IContent>(x.Item1, x.Item2))
.ToArray();
if (moveInfos.Length > 0)
{
scope.Notifications.Publish(new ContentMovedToRecycleBinNotification(moveInfos, eventMessages));
}
scope.Notifications.Publish(new ContentTreeChangeNotification(changes, eventMessages));
Audit(AuditType.Delete, userId, Constants.System.Root, $"Delete content of type {string.Join(",", contentTypeIdsA)}");
scope.Complete();
}
}
/// <summary>
/// Deletes all content items of specified type. All children of deleted content item is moved to Recycle Bin.
/// </summary>
/// <remarks>This needs extra care and attention as its potentially a dangerous and extensive operation</remarks>
/// <param name="contentTypeId">Id of the <see cref="IContentType" /></param>
/// <param name="userId">Optional id of the user deleting the media</param>
public void DeleteOfType(int contentTypeId, int userId = Constants.Security.SuperUserId) =>
DeleteOfTypes(new[] { contentTypeId }, userId);
private IContentType GetContentType(ICoreScope scope, string contentTypeAlias)
{
if (contentTypeAlias == null)
{
throw new ArgumentNullException(nameof(contentTypeAlias));
}
if (string.IsNullOrWhiteSpace(contentTypeAlias))
{
throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(contentTypeAlias));
}
scope.ReadLock(Constants.Locks.ContentTypes);
IQuery<IContentType> query = Query<IContentType>().Where(x => x.Alias == contentTypeAlias);
IContentType? contentType = _contentTypeRepository.Get(query).FirstOrDefault()
??
// causes rollback
throw new Exception($"No ContentType matching the passed in Alias: '{contentTypeAlias}'" +
$" was found");
return contentType;
}
private IContentType GetContentType(string contentTypeAlias)
{
if (contentTypeAlias == null)
{
throw new ArgumentNullException(nameof(contentTypeAlias));
}
if (string.IsNullOrWhiteSpace(contentTypeAlias))
{
throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(contentTypeAlias));
}
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
return GetContentType(scope, contentTypeAlias);
}
}
#endregion
#region Blueprints
public IContent? GetBlueprintById(int id)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
IContent? blueprint = _documentBlueprintRepository.Get(id);
if (blueprint != null)
{
blueprint.Blueprint = true;
}
return blueprint;
}
}
public IContent? GetBlueprintById(Guid id)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
IContent? blueprint = _documentBlueprintRepository.Get(id);
if (blueprint != null)
{
blueprint.Blueprint = true;
}
return blueprint;
}
}
public void SaveBlueprint(IContent content, int userId = Constants.Security.SuperUserId)
=> SaveBlueprint(content, null, userId);
public void SaveBlueprint(IContent content, IContent? createdFromContent, int userId = Constants.Security.SuperUserId)
{
EventMessages evtMsgs = EventMessagesFactory.Get();
content.Blueprint = true;
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
if (content.HasIdentity == false)
{
content.CreatorId = userId;
}
content.WriterId = userId;
_documentBlueprintRepository.Save(content);
Audit(AuditType.Save, userId, content.Id, $"Saved content template: {content.Name}");
scope.Notifications.Publish(new ContentSavedBlueprintNotification(content, createdFromContent, evtMsgs));
scope.Notifications.Publish(new ContentTreeChangeNotification(content, TreeChangeTypes.RefreshNode, evtMsgs));
scope.Complete();
}
}
public void DeleteBlueprint(IContent content, int userId = Constants.Security.SuperUserId)
{
EventMessages evtMsgs = EventMessagesFactory.Get();
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
_documentBlueprintRepository.Delete(content);
scope.Notifications.Publish(new ContentDeletedBlueprintNotification(content, evtMsgs));
scope.Notifications.Publish(new ContentTreeChangeNotification(content, TreeChangeTypes.Remove, evtMsgs));
scope.Complete();
}
}
private static readonly string?[] ArrayOfOneNullString = { null };
public IContent CreateBlueprintFromContent(
IContent blueprint,
string name,
int userId = Constants.Security.SuperUserId)
{
ArgumentNullException.ThrowIfNull(blueprint);
IContentType contentType = GetContentType(blueprint.ContentType.Alias);
var content = new Content(name, -1, contentType);
content.Path = string.Concat(content.ParentId.ToString(), ",", content.Id);
content.CreatorId = userId;
content.WriterId = userId;
IEnumerable<string?> cultures = ArrayOfOneNullString;
if (blueprint.CultureInfos?.Count > 0)
{
cultures = blueprint.CultureInfos.Values.Select(x => x.Culture);
using ICoreScope scope = ScopeProvider.CreateCoreScope();
if (blueprint.CultureInfos.TryGetValue(_languageRepository.GetDefaultIsoCode(), out ContentCultureInfos defaultCulture))
{
defaultCulture.Name = name;
}
scope.Complete();
}
DateTime now = DateTime.UtcNow;
foreach (var culture in cultures)
{
foreach (IProperty property in blueprint.Properties)
{
var propertyCulture = property.PropertyType.VariesByCulture() ? culture : null;
content.SetValue(property.Alias, property.GetValue(propertyCulture), propertyCulture);
}
if (!string.IsNullOrEmpty(culture))
{
content.SetCultureInfo(culture, blueprint.GetCultureName(culture), now);
}
}
return content;
}
/// <inheritdoc />
[Obsolete("Use IContentBlueprintEditingService.GetScaffoldedAsync() instead. Scheduled for removal in V18.")]
public IContent CreateContentFromBlueprint(IContent blueprint, string name, int userId = Constants.Security.SuperUserId)
=> CreateBlueprintFromContent(blueprint, name, userId);
public IEnumerable<IContent> GetBlueprintsForContentTypes(params int[] contentTypeId)
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
IQuery<IContent> query = Query<IContent>();
if (contentTypeId.Length > 0)
{
// Need to use a List here because the expression tree cannot convert the array when used in Contains.
// See ExpressionTests.Sql_In().
List<int> contentTypeIdsAsList = [.. contentTypeId];
query.Where(x => contentTypeIdsAsList.Contains(x.ContentTypeId));
}
return _documentBlueprintRepository.Get(query).Select(x =>
{
x.Blueprint = true;
return x;
});
}
}
public void DeleteBlueprintsOfTypes(IEnumerable<int> contentTypeIds, int userId = Constants.Security.SuperUserId)
{
EventMessages evtMsgs = EventMessagesFactory.Get();
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
// Need to use a List here because the expression tree cannot convert an array when used in Contains.
// See ExpressionTests.Sql_In().
var contentTypeIdsAsList = contentTypeIds.ToList();
IQuery<IContent> query = Query<IContent>();
if (contentTypeIdsAsList.Count > 0)
{
query.Where(x => contentTypeIdsAsList.Contains(x.ContentTypeId));
}
IContent[]? blueprints = _documentBlueprintRepository.Get(query)?.Select(x =>
{
x.Blueprint = true;
return x;
}).ToArray();
if (blueprints is not null)
{
foreach (IContent blueprint in blueprints)
{
_documentBlueprintRepository.Delete(blueprint);
}
scope.Notifications.Publish(new ContentDeletedBlueprintNotification(blueprints, evtMsgs));
scope.Notifications.Publish(new ContentTreeChangeNotification(blueprints, TreeChangeTypes.Remove, evtMsgs));
scope.Complete();
}
}
}
public void DeleteBlueprintsOfType(int contentTypeId, int userId = Constants.Security.SuperUserId) =>
DeleteBlueprintsOfTypes(new[] { contentTypeId }, userId);
#endregion
}