diff --git a/src/Umbraco.Core/Services/ContentPublishOperationService.cs b/src/Umbraco.Core/Services/ContentPublishOperationService.cs
new file mode 100644
index 0000000000..629cb1f8a4
--- /dev/null
+++ b/src/Umbraco.Core/Services/ContentPublishOperationService.cs
@@ -0,0 +1,1758 @@
+using System.ComponentModel;
+using System.Collections.Immutable;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Umbraco.Cms.Core.Configuration.Models;
+using Umbraco.Cms.Core.Events;
+using Umbraco.Cms.Core.Exceptions;
+using Umbraco.Cms.Core.Models;
+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.Extensions;
+
+namespace Umbraco.Cms.Core.Services;
+
+///
+/// Implements content publishing operations.
+///
+public class ContentPublishOperationService : ContentServiceBase, IContentPublishOperationService
+{
+ private readonly ILogger _logger;
+ private readonly IContentCrudService _crudService;
+ private readonly ILanguageRepository _languageRepository;
+ private readonly Lazy _propertyValidationService;
+ private readonly ICultureImpactFactory _cultureImpactFactory;
+ private readonly PropertyEditorCollection _propertyEditorCollection;
+ private readonly IIdKeyMap _idKeyMap;
+
+ // Thread-safe ContentSettings (Critical Review fix 2.1)
+ private ContentSettings _contentSettings;
+ private readonly object _contentSettingsLock = new object();
+
+ public ContentPublishOperationService(
+ ICoreScopeProvider provider,
+ ILoggerFactory loggerFactory,
+ IEventMessagesFactory eventMessagesFactory,
+ IDocumentRepository documentRepository,
+ IAuditService auditService,
+ IUserIdKeyResolver userIdKeyResolver,
+ IContentCrudService crudService,
+ ILanguageRepository languageRepository,
+ Lazy propertyValidationService,
+ ICultureImpactFactory cultureImpactFactory,
+ PropertyEditorCollection propertyEditorCollection,
+ IIdKeyMap idKeyMap,
+ IOptionsMonitor optionsMonitor)
+ : base(provider, loggerFactory, eventMessagesFactory, documentRepository, auditService, userIdKeyResolver)
+ {
+ _logger = loggerFactory.CreateLogger();
+ _crudService = crudService ?? throw new ArgumentNullException(nameof(crudService));
+ _languageRepository = languageRepository ?? throw new ArgumentNullException(nameof(languageRepository));
+ _propertyValidationService = propertyValidationService ?? throw new ArgumentNullException(nameof(propertyValidationService));
+ _cultureImpactFactory = cultureImpactFactory ?? throw new ArgumentNullException(nameof(cultureImpactFactory));
+ _propertyEditorCollection = propertyEditorCollection ?? throw new ArgumentNullException(nameof(propertyEditorCollection));
+ _idKeyMap = idKeyMap ?? throw new ArgumentNullException(nameof(idKeyMap));
+
+ // Thread-safe settings initialization and subscription (Critical Review fix 2.1)
+ ArgumentNullException.ThrowIfNull(optionsMonitor);
+ lock (_contentSettingsLock)
+ {
+ _contentSettings = optionsMonitor.CurrentValue;
+ }
+ optionsMonitor.OnChange(settings =>
+ {
+ lock (_contentSettingsLock)
+ {
+ _contentSettings = settings;
+ }
+ });
+ }
+
+ ///
+ /// Thread-safe accessor for ContentSettings.
+ ///
+ private ContentSettings ContentSettings
+ {
+ get
+ {
+ lock (_contentSettingsLock)
+ {
+ return _contentSettings;
+ }
+ }
+ }
+
+ #region Publishing
+
+ ///
+ 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;
+ }
+ }
+
+ #endregion
+
+ #region Unpublishing
+
+ ///
+ 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;
+ }
+ }
+ }
+
+ #endregion
+
+ #region Document Changes (Advanced API)
+
+ ///
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
+ public PublishResult CommitDocumentChanges(IContent content, int userId = Constants.Security.SuperUserId, IDictionary? notificationState = null)
+ {
+ 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, notificationState ?? 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 = _crudService.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 && _crudService.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!;
+ }
+
+ #endregion
+
+ #region Scheduled Publishing
+
+ ///
+ 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);
+
+ // Explicit failure logging (Critical Review fix)
+ foreach (var result in results.Where(r => !r.Success))
+ {
+ _logger.LogError("Scheduled publishing failed for document id={DocumentId}, reason={Reason}", result.Content?.Id, result.Result);
+ }
+
+ 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
+ }
+
+ 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();
+ }
+
+ ///
+ 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);
+ }
+ }
+
+ #endregion
+
+ #region Schedule Management
+
+ ///
+ 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();
+ }
+ }
+
+ ///
+ public IDictionary> GetContentSchedulesByIds(Guid[] keys)
+ {
+ // Critical Review fix 2.4: Add null/empty check
+ if (keys == null || 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());
+ }
+ }
+
+ #endregion
+
+ #region Path Checks
+
+ ///
+ /// 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 = _crudService.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 Workflow
+
+ ///
+ /// 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 = _crudService.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;
+ }
+ }
+
+ #endregion
+
+ #region Published Content Queries
+
+ ///
+ /// 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);
+ }
+ }
+
+ #endregion
+
+ #region Branch Publishing
+
+ ///
+ 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 _crudService.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;
+ }
+
+ // 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;
+ }
+
+ #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(_crudService.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 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 static bool HasUnsavedChanges(IContent content) => content.HasIdentity is false || content.IsDirty();
+
+ 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
+}