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 +}