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;
///
/// Implements the content service.
///
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 _logger;
private readonly Lazy _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? _queryNotTrashed;
private readonly Lazy _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? _queryOperationServiceLazy;
// Version operation service fields (for Phase 3 extracted version operations)
private readonly IContentVersionOperationService? _versionOperationService;
private readonly Lazy? _versionOperationServiceLazy;
// Move operation service fields (for Phase 4 extracted move operations)
private readonly IContentMoveOperationService? _moveOperationService;
private readonly Lazy? _moveOperationServiceLazy;
///
/// Gets the query operation service.
///
/// Thrown if the service was not properly initialized.
private IContentQueryOperationService QueryOperationService =>
_queryOperationService ?? _queryOperationServiceLazy?.Value
?? throw new InvalidOperationException("QueryOperationService not initialized. Ensure the service is properly injected via constructor.");
///
/// Gets the version operation service.
///
/// Thrown if the service was not properly initialized.
private IContentVersionOperationService VersionOperationService =>
_versionOperationService ?? _versionOperationServiceLazy?.Value
?? throw new InvalidOperationException("VersionOperationService not initialized. Ensure the service is properly injected via constructor.");
///
/// Gets the move operation service.
///
/// Thrown if the service was not properly initialized.
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 propertyValidationService,
IShortStringHelper shortStringHelper,
ICultureImpactFactory cultureImpactFactory,
IUserIdKeyResolver userIdKeyResolver,
PropertyEditorCollection propertyEditorCollection,
IIdKeyMap idKeyMap,
IOptionsMonitor 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();
ArgumentNullException.ThrowIfNull(crudService);
// Wrap in Lazy for consistent access pattern (already resolved, so returns immediately)
_crudServiceLazy = new Lazy(() => 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 propertyValidationService,
IShortStringHelper shortStringHelper,
ICultureImpactFactory cultureImpactFactory,
IUserIdKeyResolver userIdKeyResolver,
PropertyEditorCollection propertyEditorCollection,
IIdKeyMap idKeyMap,
IOptionsMonitor 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();
// Lazy resolution of IAuditService (from StaticServiceProvider)
_auditService = StaticServiceProvider.Instance.GetRequiredService();
// NEW: Lazy resolution of IContentCrudService
_crudServiceLazy = new Lazy(() =>
StaticServiceProvider.Instance.GetRequiredService(),
LazyThreadSafetyMode.ExecutionAndPublication);
// Phase 2: Lazy resolution of IContentQueryOperationService
_queryOperationServiceLazy = new Lazy(() =>
StaticServiceProvider.Instance.GetRequiredService(),
LazyThreadSafetyMode.ExecutionAndPublication);
// Phase 3: Lazy resolution of IContentVersionOperationService
_versionOperationServiceLazy = new Lazy(() =>
StaticServiceProvider.Instance.GetRequiredService(),
LazyThreadSafetyMode.ExecutionAndPublication);
// Phase 4: Lazy resolution of IContentMoveOperationService
_moveOperationServiceLazy = new Lazy(() =>
StaticServiceProvider.Instance.GetRequiredService(),
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 propertyValidationService,
IShortStringHelper shortStringHelper,
ICultureImpactFactory cultureImpactFactory,
IUserIdKeyResolver userIdKeyResolver,
PropertyEditorCollection propertyEditorCollection,
IIdKeyMap idKeyMap,
IOptionsMonitor 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();
// NEW: Lazy resolution of IContentCrudService
_crudServiceLazy = new Lazy(() =>
StaticServiceProvider.Instance.GetRequiredService(),
LazyThreadSafetyMode.ExecutionAndPublication);
// Phase 2: Lazy resolution of IContentQueryOperationService
_queryOperationServiceLazy = new Lazy(() =>
StaticServiceProvider.Instance.GetRequiredService(),
LazyThreadSafetyMode.ExecutionAndPublication);
// Phase 3: Lazy resolution of IContentVersionOperationService
_versionOperationServiceLazy = new Lazy(() =>
StaticServiceProvider.Instance.GetRequiredService(),
LazyThreadSafetyMode.ExecutionAndPublication);
// Phase 4: Lazy resolution of IContentMoveOperationService
_moveOperationServiceLazy = new Lazy(() =>
StaticServiceProvider.Instance.GetRequiredService(),
LazyThreadSafetyMode.ExecutionAndPublication);
}
#endregion
#region Static queries
// lazy-constructed because when the ctor runs, the query factory may not be ready
private IQuery QueryNotTrashed =>
_queryNotTrashed ??= Query().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
///
/// 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 & permission pairs.
///
///
public void SetPermissions(EntityPermissionSet permissionSet)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
_documentRepository.ReplaceContentPermissions(permissionSet);
scope.Complete();
}
}
///
/// Assigns a single permission to the current content item for the specified group ids
///
///
///
///
public void SetPermission(IContent entity, string permission, IEnumerable groupIds)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
_documentRepository.AssignEntityPermission(entity, permission, groupIds);
scope.Complete();
}
}
///
/// Returns implicit/inherited permissions assigned to the content item for all user groups
///
///
///
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
///
/// Creates an object using the alias of the
/// that this Content should based on.
///
///
/// 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.
///
/// Name of the Content object
/// Id of Parent for the new Content
/// Alias of the
/// Optional id of the user creating the content
///
///
///
public IContent Create(string name, Guid parentId, string contentTypeAlias, int userId = Constants.Security.SuperUserId)
=> CrudService.Create(name, parentId, contentTypeAlias, userId);
///
/// Creates an object of a specified content type.
///
///
/// 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.
///
/// The name of the content object.
/// The identifier of the parent, or -1.
/// The alias of the content type.
/// The optional id of the user creating the content.
/// The content object.
public IContent Create(string name, int parentId, string contentTypeAlias, int userId = Constants.Security.SuperUserId)
=> CrudService.Create(name, parentId, contentTypeAlias, userId);
///
/// Creates an object of a specified content type.
///
///
/// 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.
///
/// The name of the content object.
/// The identifier of the parent, or -1.
/// The content type of the content
/// The optional id of the user creating the content.
/// The content object.
public IContent Create(string name, int parentId, IContentType contentType, int userId = Constants.Security.SuperUserId)
=> CrudService.Create(name, parentId, contentType, userId);
///
/// Creates an object of a specified content type, under a parent.
///
///
/// 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.
///
/// The name of the content object.
/// The parent content object.
/// The alias of the content type.
/// The optional id of the user creating the content.
/// The content object.
public IContent Create(string name, IContent? parent, string contentTypeAlias, int userId = Constants.Security.SuperUserId)
=> CrudService.Create(name, parent, contentTypeAlias, userId);
///
/// Creates an object of a specified content type.
///
/// This method returns a new, persisted, IContent with an identity.
/// The name of the content object.
/// The identifier of the parent, or -1.
/// The alias of the content type.
/// The optional id of the user creating the content.
/// The content object.
public IContent CreateAndSave(string name, int parentId, string contentTypeAlias, int userId = Constants.Security.SuperUserId)
=> CrudService.CreateAndSave(name, parentId, contentTypeAlias, userId);
///
/// Creates an object of a specified content type, under a parent.
///
/// This method returns a new, persisted, IContent with an identity.
/// The name of the content object.
/// The parent content object.
/// The alias of the content type.
/// The optional id of the user creating the content.
/// The content object.
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
///
/// Gets an object by Id
///
/// Id of the Content to retrieve
///
///
///
public IContent? GetById(int id)
=> CrudService.GetById(id);
///
/// Gets an object by Id
///
/// Ids of the Content to retrieve
///
///
///
public IEnumerable GetByIds(IEnumerable ids)
=> CrudService.GetByIds(ids);
///
/// Gets an object by its 'UniqueId'
///
/// Guid key of the Content to retrieve
///
///
///
public IContent? GetById(Guid key)
=> CrudService.GetById(key);
///
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 idAttempt = _idKeyMap.GetIdForKey(contentId, UmbracoObjectTypes.Document);
if (idAttempt.Success is false)
{
return new ContentScheduleCollection();
}
return GetContentScheduleByContentId(idAttempt.Result);
}
///
public void PersistContentSchedule(IContent content, ContentScheduleCollection contentSchedule)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
_documentRepository.PersistContentSchedule(content, contentSchedule);
scope.Complete();
}
}
///
///
///
///
///
Attempt IContentServiceBase.Save(IEnumerable contents, int userId) =>
Attempt.Succeed(Save(contents, userId));
///
/// Gets objects by Ids
///
/// Ids of the Content to retrieve
///
///
///
public IEnumerable GetByIds(IEnumerable ids)
=> CrudService.GetByIds(ids);
///
public IEnumerable GetPagedOfType(
int contentTypeId,
long pageIndex,
int pageSize,
out long totalRecords,
IQuery? filter = null,
Ordering? ordering = null)
=> QueryOperationService.GetPagedOfType(contentTypeId, pageIndex, pageSize, out totalRecords, filter, ordering);
///
public IEnumerable GetPagedOfTypes(int[] contentTypeIds, long pageIndex, int pageSize, out long totalRecords, IQuery? filter, Ordering? ordering = null)
=> QueryOperationService.GetPagedOfTypes(contentTypeIds, pageIndex, pageSize, out totalRecords, filter, ordering);
///
/// Gets a collection of objects by Level
///
/// The level to retrieve Content from
/// An Enumerable list of objects
/// Contrary to most methods, this method filters out trashed content items.
public IEnumerable GetByLevel(int level)
=> QueryOperationService.GetByLevel(level);
///
/// Gets a specific version of an item.
///
/// Id of the version to retrieve
/// An item
public IContent? GetVersion(int versionId)
=> VersionOperationService.GetVersion(versionId);
///
/// Gets a collection of an objects versions by Id
///
///
/// An Enumerable list of objects
public IEnumerable GetVersions(int id)
=> VersionOperationService.GetVersions(id);
///
/// Gets a collection of an objects versions by Id
///
/// An Enumerable list of objects
public IEnumerable GetVersionsSlim(int id, int skip, int take)
=> VersionOperationService.GetVersionsSlim(id, skip, take);
///
/// Gets a list of all version Ids for the given content item ordered so latest is first
///
///
/// The maximum number of rows to return
///
public IEnumerable GetVersionIds(int id, int maxRows)
=> VersionOperationService.GetVersionIds(id, maxRows);
///
/// Gets a collection of objects, which are ancestors of the current content.
///
/// Id of the to retrieve ancestors for
/// An Enumerable list of objects
public IEnumerable GetAncestors(int id)
=> CrudService.GetAncestors(id);
///
/// Gets a collection of objects, which are ancestors of the current content.
///
/// to retrieve ancestors for
/// An Enumerable list of objects
public IEnumerable GetAncestors(IContent content)
=> CrudService.GetAncestors(content);
///
/// Gets a collection of published objects by Parent Id
///
/// Id of the Parent to retrieve Children from
/// An Enumerable list of published objects
public IEnumerable GetPublishedChildren(int id)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
IQuery? query = Query().Where(x => x.ParentId == id && x.Published);
return _documentRepository.Get(query).OrderBy(x => x.SortOrder);
}
}
///
public IEnumerable GetPagedChildren(int id, long pageIndex, int pageSize, out long totalChildren, IQuery? filter = null, Ordering? ordering = null)
=> CrudService.GetPagedChildren(id, pageIndex, pageSize, out totalChildren, filter, ordering);
///
public IEnumerable GetPagedDescendants(int id, long pageIndex, int pageSize, out long totalChildren, IQuery? filter = null, Ordering? ordering = null)
=> CrudService.GetPagedDescendants(id, pageIndex, pageSize, out totalChildren, filter, ordering);
private IQuery? GetPagedDescendantQuery(string contentPath)
{
IQuery? query = Query();
if (!contentPath.IsNullOrWhiteSpace())
{
query?.Where(x => x.Path.SqlStartsWith($"{contentPath},", TextColumnType.NVarchar));
}
return query;
}
private IEnumerable GetPagedLocked(IQuery? query, long pageIndex, int pageSize, out long totalChildren, IQuery? 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);
}
///
/// Gets the parent of the current content as an item.
///
/// Id of the to retrieve the parent from
/// Parent object
public IContent? GetParent(int id)
=> CrudService.GetParent(id);
///
/// Gets the parent of the current content as an item.
///
/// to retrieve the parent from
/// Parent object
public IContent? GetParent(IContent? content)
=> CrudService.GetParent(content);
///
/// Gets a collection of objects, which reside at the first level / root
///
/// An Enumerable list of objects
public IEnumerable GetRootContent()
=> CrudService.GetRootContent();
///
/// Gets all published content items
///
///
internal IEnumerable GetAllPublished()
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
return _documentRepository.Get(QueryNotTrashed);
}
}
///
public IEnumerable GetContentForExpiration(DateTime date)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
return _documentRepository.GetContentForExpiration(date);
}
}
///
public IEnumerable GetContentForRelease(DateTime date)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
return _documentRepository.GetContentForRelease(date);
}
}
///
/// Gets a collection of an objects, which resides in the Recycle Bin
///
/// An Enumerable list of objects
public IEnumerable GetPagedContentInRecycleBin(long pageIndex, int pageSize, out long totalRecords, IQuery? filter = null, Ordering? ordering = null)
=> MoveOperationService.GetPagedContentInRecycleBin(pageIndex, pageSize, out totalRecords, filter, ordering);
///
/// Checks whether an item has any children
///
/// Id of the
/// True if the content has any children otherwise False
public bool HasChildren(int id)
=> CrudService.HasChildren(id);
///
/// Checks whether a document with the specified id exists.
///
/// The document id.
/// True if the document exists; otherwise false.
public bool Exists(int id)
=> CrudService.Exists(id);
///
/// Checks whether a document with the specified key exists.
///
/// The document key.
/// True if the document exists; otherwise false.
public bool Exists(Guid key)
=> CrudService.Exists(key);
///
public IDictionary> GetContentSchedulesByIds(Guid[] keys)
{
if (keys.Length == 0)
{
return ImmutableDictionary>.Empty;
}
List contentIds = [];
foreach (var key in keys)
{
Attempt 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());
}
}
///
/// Checks if the passed in can be published based on the ancestors publish state.
///
/// to check if ancestors are published
/// True if the Content can be published, otherwise False
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
///
public OperationResult Save(IContent content, int? userId = null, ContentScheduleCollection? contentSchedule = null)
=> CrudService.Save(content, userId, contentSchedule);
///
public OperationResult Save(IEnumerable contents, int userId = Constants.Security.SuperUserId)
=> CrudService.Save(contents, userId);
///
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 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(), userId);
scope.Complete();
return result;
}
}
///
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;
}
}
}
///
/// Publishes/unpublishes any pending publishing changes made to the document.
///
///
///
/// 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
///
/// This is the underlying logic for both publishing and unpublishing any document
///
/// Pending publishing/unpublishing changes on a document are made with calls to
/// and
/// .
///
///
/// When publishing or unpublishing a single culture, or all cultures, use
/// and . 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
/// and
/// on the content itself - this prepares the content, but does not commit anything - and then, invoke
/// to actually commit the changes to the database.
///
/// The document is *always* saved, even when publishing fails.
///
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;
}
}
///
/// Handles a lot of business logic cases for how the document should be persisted
///
///
///
///
///
///
///
///
///
///
///
///
/// 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...
///
///
private PublishResult CommitDocumentChangesInternal(
ICoreScope scope,
IContent content,
EventMessages eventMessages,
IReadOnlyCollection allLangs,
IDictionary? 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? culturesPublishing = null;
IReadOnlyList? culturesUnpublishing = null;
IReadOnlyList? 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!;
}
///
public IEnumerable PerformScheduledPublish(DateTime date)
{
var allLangs = new Lazy>(() => _languageRepository.GetMany().ToList());
EventMessages evtMsgs = EventMessagesFactory.Get();
var results = new List();
PerformScheduledPublishingRelease(date, results, evtMsgs, allLangs);
PerformScheduledPublishingExpiration(date, results, evtMsgs, allLangs);
return results;
}
private void PerformScheduledPublishingExpiration(DateTime date, List results, EventMessages evtMsgs, Lazy> 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 results, EventMessages evtMsgs, Lazy> 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 culturesToPublish, IReadOnlyCollection 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? PublishBranch_ShouldPublish(ref HashSet? cultures, string c, bool published, bool edited, bool isRoot, PublishBranchFilter publishBranchFilter)
{
// if published, republish
if (published)
{
cultures ??= new HashSet(); // empty means 'already published'
if (edited || publishBranchFilter.HasFlag(PublishBranchFilter.ForceRepublish))
{
cultures.Add(c); // 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();
cultures.Add(c); // means 'publish this culture'
return cultures;
}
///
public IEnumerable 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? ShouldPublish(IContent c)
{
var isRoot = c.Id == content.Id;
HashSet? 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(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 PublishBranch(
IContent document,
Func?> shouldPublish,
Func, IReadOnlyCollection, 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();
var publishedDocuments = new List();
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? culturesToPublish = shouldPublish(document);
PublishResult? result = PublishBranchItem(scope, document, culturesToPublish, publishCultures, true, publishedDocuments, eventMessages, userId, allLangs, out IDictionary? notificationState);
if (result != null)
{
results.Add(result);
if (!result.Success)
{
return results;
}
}
HashSet culturesPublished = culturesToPublish ?? [];
// deal with descendants
// if one fails, abort its branch
var exclude = new HashSet();
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? culturesToPublish,
Func, IReadOnlyCollection,
bool> publishCultures,
bool isRoot,
ICollection publishedDocuments,
EventMessages evtMsgs,
int userId,
IReadOnlyCollection allLangs,
out IDictionary? initialNotificationState)
{
initialNotificationState = new Dictionary();
// 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
///
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 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.
///
/// Permanently deletes versions from an object prior to a specific date.
/// This method will never delete the latest version of a content item.
///
/// Id of the object to delete versions from
/// Latest version date
/// Optional Id of the User deleting versions of a Content object
public void DeleteVersions(int id, DateTime versionDate, int userId = Constants.Security.SuperUserId)
=> VersionOperationService.DeleteVersions(id, versionDate, userId);
///
/// Permanently deletes specific version(s) from an object.
/// This method will never delete the latest version of a content item.
///
/// Id of the object to delete a version from
/// Id of the version to delete
/// Boolean indicating whether to delete versions prior to the versionId
/// Optional Id of the User deleting versions of a Content object
public void DeleteVersion(int id, int versionId, bool deletePriorVersions, int userId = Constants.Security.SuperUserId)
=> VersionOperationService.DeleteVersion(id, versionId, deletePriorVersions, userId);
#endregion
#region Move, RecycleBin
///
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(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[] moveInfo = moves
.Select(x => new MoveToRecycleBinEventInfo(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);
}
///
/// Moves an object to a new location by changing its parent id.
///
///
/// If the 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.
///
/// The to move
/// Id of the Content's new Parent
/// Optional Id of the User moving the Content
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();
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? 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 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 EmptyRecycleBinAsync(Guid userId)
=> await MoveOperationService.EmptyRecycleBinAsync(userId);
///
/// Empties the Recycle Bin by deleting all that resides in the bin
///
public OperationResult EmptyRecycleBin(int userId = Constants.Security.SuperUserId)
=> MoveOperationService.EmptyRecycleBin(userId);
public bool RecycleBinSmells()
=> MoveOperationService.RecycleBinSmells();
#endregion
#region Others
///
/// Copies an 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.
///
/// The to copy
/// Id of the Content's new Parent
/// Boolean indicating whether the copy should be related to the original
/// Optional Id of the User copying the Content
/// The newly created object
public IContent? Copy(IContent content, int parentId, bool relateToOriginal, int userId = Constants.Security.SuperUserId)
=> MoveOperationService.Copy(content, parentId, relateToOriginal, userId);
///
/// Copies an 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.
///
/// The to copy
/// Id of the Content's new Parent
/// Boolean indicating whether the copy should be related to the original
/// A value indicating whether to recursively copy children.
/// Optional Id of the User copying the Content
/// The newly created object
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 parentKeyAttempt = _idKeyMap.GetKeyForId(parentId, UmbracoObjectTypes.Document);
parentKey = parentKeyAttempt.Success ? parentKeyAttempt.Result : null;
return parentKeyAttempt.Success;
}
///
/// Sends an to Publication, which executes handlers and events for the 'Send to Publication'
/// action.
///
/// The to send to publication
/// Optional Id of the User issuing the send to publication
/// True if sending publication was successful otherwise false
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;
}
}
///
/// Sorts a collection of objects by updating the SortOrder according
/// to the ordering of items in the passed in .
///
///
/// Using this method will ensure that the Published-state is maintained upon sorting
/// so the cache is updated accordingly - as needed.
///
///
///
/// Result indicating what action was taken when handling the command.
public OperationResult Sort(IEnumerable items, int userId = Constants.Security.SuperUserId)
=> MoveOperationService.Sort(items, userId);
///
/// Sorts a collection of objects by updating the SortOrder according
/// to the ordering of items identified by the .
///
///
/// Using this method will ensure that the Published-state is maintained upon sorting
/// so the cache is updated accordingly - as needed.
///
///
///
/// Result indicating what action was taken when handling the command.
public OperationResult Sort(IEnumerable? 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
///
/// Gets a collection of descendants by the first Parent.
///
/// item to retrieve Descendants from
/// An Enumerable list of objects
internal IEnumerable 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 GetPublishedDescendantsLocked(IContent content)
{
var pathMatch = content.Path + ",";
IQuery query = Query()
.Where(x => x.Id != content.Id && x.Path.StartsWith(pathMatch) /*&& culture.Trashed == false*/);
IEnumerable 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 { 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 affectedCultures)
=> GetLanguageDetailsForAuditEntry(_languageRepository.GetMany(), affectedCultures);
private static string GetLanguageDetailsForAuditEntry(IEnumerable languages, IEnumerable affectedCultures)
{
IEnumerable languageIsoCodes = languages
.Where(x => affectedCultures.InvariantContains(x.IsoCode))
.Select(x => x.IsoCode);
return string.Join(", ", languageIsoCodes);
}
private static bool IsDefaultCulture(IReadOnlyCollection? langs, string culture) =>
langs?.Any(x => x.IsDefault && x.IsoCode.InvariantEquals(culture)) ?? false;
private bool IsMandatoryCulture(IReadOnlyCollection langs, string culture) =>
langs.Any(x => x.IsMandatory && x.IsoCode.InvariantEquals(culture));
#endregion
#region Publishing Strategies
///
/// Ensures that a document can be published
///
///
///
///
///
///
///
///
///
///
private PublishResult StrategyCanPublish(
ICoreScope scope,
IContent content,
bool checkPath,
IReadOnlyList? culturesPublishing,
IReadOnlyCollection? culturesUnpublishing,
EventMessages evtMsgs,
IReadOnlyCollection allLangs,
IDictionary? 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 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);
}
///
/// Publishes a document
///
///
///
///
///
///
///
/// It is assumed that all publishing checks have passed before calling this method like
///
///
private PublishResult StrategyPublish(
IContent content,
IReadOnlyCollection? culturesPublishing,
IReadOnlyCollection? 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);
}
///
/// Ensures that a document can be unpublished
///
///
///
///
///
///
private PublishResult StrategyCanUnpublish(
ICoreScope scope,
IContent content,
EventMessages evtMsgs,
IDictionary? 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);
}
///
/// Unpublishes a document
///
///
///
///
///
/// It is assumed that all unpublishing checks have passed before calling this method like
///
///
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 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
///
/// Deletes all content of specified type. All children of deleted content is moved to Recycle Bin.
///
///
/// This needs extra care and attention as its potentially a dangerous and extensive operation.
///
/// 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.
///
///
/// Id of the
/// Optional Id of the user issuing the delete operation
public void DeleteOfTypes(IEnumerable 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>();
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 query = Query().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 childQuery = Query().Where(x => x.ParentId == c.Id);
IEnumerable children = _documentRepository.Get(childQuery);
foreach (IContent child in children)
{
// see MoveToRecycleBin
PerformMoveLocked(child, Constants.System.RecycleBinContent, null, userId, moves, true);
changes.Add(new TreeChange(content, TreeChangeTypes.RefreshBranch));
}
// delete content
// triggers the deleted event (and handles the files)
DeleteLocked(scope, content, eventMessages);
changes.Add(new TreeChange(content, TreeChangeTypes.Remove));
}
MoveToRecycleBinEventInfo[] moveInfos = moves
.Select(x => new MoveToRecycleBinEventInfo(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();
}
}
///
/// Deletes all content items of specified type. All children of deleted content item is moved to Recycle Bin.
///
/// This needs extra care and attention as its potentially a dangerous and extensive operation
/// Id of the
/// Optional id of the user deleting the media
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 query = Query().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 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;
}
///
[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 GetBlueprintsForContentTypes(params int[] contentTypeId)
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
IQuery query = Query();
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 contentTypeIdsAsList = [.. contentTypeId];
query.Where(x => contentTypeIdsAsList.Contains(x.ContentTypeId));
}
return _documentBlueprintRepository.Get(query).Select(x =>
{
x.Blueprint = true;
return x;
});
}
}
public void DeleteBlueprintsOfTypes(IEnumerable 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 query = Query();
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
}