From 6b584497a03d3047f1a68f06bba09f7acd6ca6f0 Mon Sep 17 00:00:00 2001 From: yv01p Date: Tue, 23 Dec 2025 20:08:55 +0000 Subject: [PATCH] refactor(core): delegate publish operations to ContentPublishOperationService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces publishing method implementations with delegations: - Publish/Unpublish - PerformScheduledPublish - PublishBranch - GetContentScheduleByContentId (int and Guid overloads) - PersistContentSchedule - GetContentSchedulesByIds - GetContentForExpiration/Release - IsPathPublishable/IsPathPublished - SendToPublication - GetPublishedChildren Removes ~1600 lines of implementation that now lives in ContentPublishOperationService: Private/internal methods deleted: - CommitDocumentChanges (internal wrapper) - CommitDocumentChangesInternal (~330 lines) - PerformScheduledPublishingExpiration - PerformScheduledPublishingRelease - PublishBranch (internal overload) - PublishBranchItem - PublishBranch_PublishCultures - PublishBranch_ShouldPublish - EnsureCultures - ProvidedCulturesIndicatePublishAll - GetPublishedDescendants - GetPublishedDescendantsLocked - StrategyCanPublish - StrategyPublish - StrategyCanUnpublish - StrategyUnpublish - IsDefaultCulture - IsMandatoryCulture - GetLanguageDetailsForAuditEntry (overload) Kept HasUnsavedChanges (used by MoveToRecycleBin). ContentService.cs reduced from 3037 lines to 1443 lines. Part of ContentService refactoring Phase 5. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/Umbraco.Core/Services/ContentService.cs | 1625 +------------------ 1 file changed, 16 insertions(+), 1609 deletions(-) diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index 6bde9c7068..7f7f3c76d3 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -523,35 +523,14 @@ public class ContentService : RepositoryService, IContentService /// public ContentScheduleCollection GetContentScheduleByContentId(int contentId) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.GetContentSchedule(contentId); - } - } + => PublishOperationService.GetContentScheduleByContentId(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); - } + => PublishOperationService.GetContentScheduleByContentId(contentId); /// public void PersistContentSchedule(IContent content, ContentScheduleCollection contentSchedule) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - scope.WriteLock(Constants.Locks.ContentTree); - _documentRepository.PersistContentSchedule(content, contentSchedule); - scope.Complete(); - } - } + => PublishOperationService.PersistContentSchedule(content, contentSchedule); /// /// @@ -642,20 +621,8 @@ public class ContentService : RepositoryService, IContentService public IEnumerable GetAncestors(IContent content) => CrudService.GetAncestors(content); - /// - /// Gets a collection of published objects by Parent Id - /// - /// Id of the Parent to retrieve Children from - /// An Enumerable list of published objects public IEnumerable GetPublishedChildren(int id) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - IQuery? query = Query().Where(x => x.ParentId == id && x.Published); - return _documentRepository.Get(query).OrderBy(x => x.SortOrder); - } - } + => PublishOperationService.GetPublishedChildren(id); /// public IEnumerable GetPagedChildren(int id, long pageIndex, int pageSize, out long totalChildren, IQuery? filter = null, Ordering? ordering = null) @@ -734,23 +701,11 @@ public class ContentService : RepositoryService, IContentService /// public IEnumerable GetContentForExpiration(DateTime date) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.GetContentForExpiration(date); - } - } + => PublishOperationService.GetContentForExpiration(date); /// public IEnumerable GetContentForRelease(DateTime date) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.GetContentForRelease(date); - } - } + => PublishOperationService.GetContentForRelease(date); /// /// Gets a collection of an objects, which resides in the Recycle Bin @@ -785,62 +740,14 @@ public class ContentService : RepositoryService, IContentService /// public IDictionary> GetContentSchedulesByIds(Guid[] keys) - { - if (keys.Length == 0) - { - return ImmutableDictionary>.Empty; - } + => PublishOperationService.GetContentSchedulesByIds(keys); - List contentIds = []; - foreach (var key in keys) - { - Attempt contentId = _idKeyMap.GetIdForKey(key, UmbracoObjectTypes.Document); - if (contentId.Success is false) - { - continue; - } - - contentIds.Add(contentId.Result); - } - - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.GetContentSchedulesByIds(contentIds.ToArray()); - } - } - - /// - /// Checks if the passed in can be published based on the ancestors publish state. - /// - /// to check if ancestors are published - /// True if the Content can be published, otherwise False + /// public bool IsPathPublishable(IContent content) - { - // fast - if (content.ParentId == Constants.System.Root) - { - return true; // root content is always publishable - } - - if (content.Trashed) - { - return false; // trashed content is never publishable - } - - // not trashed and has a parent: publishable if the parent is path-published - IContent? parent = GetById(content.ParentId); - return parent == null || IsPathPublished(parent); - } + => PublishOperationService.IsPathPublishable(content); public bool IsPathPublished(IContent? content) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.IsPathPublished(content); - } - } + => PublishOperationService.IsPathPublished(content); #endregion @@ -856,1076 +763,20 @@ public class ContentService : RepositoryService, IContentService /// 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; - } - } + => PublishOperationService.Publish(content, cultures, userId); /// public PublishResult Unpublish(IContent content, string? culture = "*", int userId = Constants.Security.SuperUserId) - { - if (content == null) - { - throw new ArgumentNullException(nameof(content)); - } + => PublishOperationService.Unpublish(content, culture, userId); - EventMessages evtMsgs = EventMessagesFactory.Get(); - - culture = culture?.NullOrWhiteSpaceAsNull().EnsureCultureCode(); - - PublishedState publishedState = content.PublishedState; - if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished) - { - throw new InvalidOperationException( - $"Cannot save-and-publish (un)publishing content, use the dedicated {nameof(CommitDocumentChanges)} method."); - } - - // cannot accept invariant (null or empty) culture for variant content type - // cannot accept a specific culture for invariant content type (but '*' is ok) - if (content.ContentType.VariesByCulture()) - { - if (culture == null) - { - throw new NotSupportedException("Invariant culture is not supported by variant content types."); - } - } - else - { - if (culture != null && culture != "*") - { - throw new NotSupportedException( - $"Culture \"{culture}\" is not supported by invariant content types."); - } - } - - // if the content is not published, nothing to do - if (!content.Published) - { - return new PublishResult(PublishResultType.SuccessUnpublishAlready, evtMsgs, content); - } - - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - scope.WriteLock(Constants.Locks.ContentTree); - - var allLangs = _languageRepository.GetMany().ToList(); - - var savingNotification = new ContentSavingNotification(content, evtMsgs); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, content); - } - - // all cultures = unpublish whole - if (culture == "*" || (!content.ContentType.VariesByCulture() && culture == null)) - { - // Unpublish the culture, this will change the document state to Publishing! ... which is expected because this will - // essentially be re-publishing the document with the requested culture removed - // We are however unpublishing all cultures, so we will set this to unpublishing. - content.UnpublishCulture(culture); - content.PublishedState = PublishedState.Unpublishing; - PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, savingNotification.State, userId); - scope.Complete(); - return result; - } - else - { - // Unpublish the culture, this will change the document state to Publishing! ... which is expected because this will - // essentially be re-publishing the document with the requested culture removed. - // The call to CommitDocumentChangesInternal will perform all the checks like if this is a mandatory culture or the last culture being unpublished - // and will then unpublish the document accordingly. - // If the result of this is false it means there was no culture to unpublish (i.e. it was already unpublished or it did not exist) - var removed = content.UnpublishCulture(culture); - - // Save and publish any changes - PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, savingNotification.State, userId); - - scope.Complete(); - - // In one case the result will be PublishStatusType.FailedPublishNothingToPublish which means that no cultures - // were specified to be published which will be the case when removed is false. In that case - // we want to swap the result type to PublishResultType.SuccessUnpublishAlready (that was the expectation before). - if (result.Result == PublishResultType.FailedPublishNothingToPublish && !removed) - { - return new PublishResult(PublishResultType.SuccessUnpublishAlready, evtMsgs, content); - } - - return result; - } - } - } - - /// - /// Publishes/unpublishes any pending publishing changes made to the document. - /// - /// - /// - /// This MUST NOT be called from within this service, this used to be a public API and must only be used outside of - /// this service. - /// Internally in this service, calls must be made to CommitDocumentChangesInternal - /// - /// This is the underlying logic for both publishing and unpublishing any document - /// - /// Pending publishing/unpublishing changes on a document are made with calls to - /// and - /// . - /// - /// - /// When publishing or unpublishing a single culture, or all cultures, use - /// and . But if the flexibility to both publish and unpublish in a single operation is - /// required - /// then this method needs to be used in combination with - /// and - /// on the content itself - this prepares the content, but does not commit anything - and then, invoke - /// to actually commit the changes to the database. - /// - /// The document is *always* saved, even when publishing fails. - /// - internal PublishResult CommitDocumentChanges(IContent content, int userId = Constants.Security.SuperUserId) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - EventMessages evtMsgs = EventMessagesFactory.Get(); - - scope.WriteLock(Constants.Locks.ContentTree); - - var savingNotification = new ContentSavingNotification(content, evtMsgs); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, content); - } - - var allLangs = _languageRepository.GetMany().ToList(); - - PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, savingNotification.State, userId); - scope.Complete(); - return result; - } - } - - /// - /// Handles a lot of business logic cases for how the document should be persisted - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// Business logic cases such: as unpublishing a mandatory culture, or unpublishing the last culture, checking for - /// pending scheduled publishing, etc... is dealt with in this method. - /// There is quite a lot of cases to take into account along with logic that needs to deal with scheduled - /// saving/publishing, branch saving/publishing, etc... - /// - /// - private PublishResult CommitDocumentChangesInternal( - ICoreScope scope, - IContent content, - EventMessages eventMessages, - IReadOnlyCollection allLangs, - IDictionary? notificationState, - int userId, - bool branchOne = false, - bool branchRoot = false) - { - if (scope == null) - { - throw new ArgumentNullException(nameof(scope)); - } - - if (content == null) - { - throw new ArgumentNullException(nameof(content)); - } - - if (eventMessages == null) - { - throw new ArgumentNullException(nameof(eventMessages)); - } - - PublishResult? publishResult = null; - PublishResult? unpublishResult = null; - - // nothing set = republish it all - if (content.PublishedState != PublishedState.Publishing && - content.PublishedState != PublishedState.Unpublishing) - { - content.PublishedState = PublishedState.Publishing; - } - - // State here is either Publishing or Unpublishing - // Publishing to unpublish a culture may end up unpublishing everything so these flags can be flipped later - var publishing = content.PublishedState == PublishedState.Publishing; - var unpublishing = content.PublishedState == PublishedState.Unpublishing; - - var variesByCulture = content.ContentType.VariesByCulture(); - - // Track cultures that are being published, changed, unpublished - IReadOnlyList? culturesPublishing = null; - IReadOnlyList? culturesUnpublishing = null; - IReadOnlyList? culturesChanging = variesByCulture - ? content.CultureInfos?.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList() - : null; - - var isNew = !content.HasIdentity; - TreeChangeTypes changeType = isNew ? TreeChangeTypes.RefreshNode : TreeChangeTypes.RefreshBranch; - var previouslyPublished = content.HasIdentity && content.Published; - - // Inline method to persist the document with the documentRepository since this logic could be called a couple times below - void SaveDocument(IContent c) - { - // save, always - if (c.HasIdentity == false) - { - c.CreatorId = userId; - } - - c.WriterId = userId; - - // saving does NOT change the published version, unless PublishedState is Publishing or Unpublishing - _documentRepository.Save(c); - } - - if (publishing) - { - // Determine cultures publishing/unpublishing which will be based on previous calls to content.PublishCulture and ClearPublishInfo - culturesUnpublishing = content.GetCulturesUnpublishing(); - culturesPublishing = variesByCulture - ? content.PublishCultureInfos?.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList() - : null; - - // ensure that the document can be published, and publish handling events, business rules, etc - publishResult = StrategyCanPublish( - scope, - content, /*checkPath:*/ - !branchOne || branchRoot, - culturesPublishing, - culturesUnpublishing, - eventMessages, - allLangs, - notificationState); - - if (publishResult.Success) - { - // raise Publishing notification - if (scope.Notifications.PublishCancelable( - new ContentPublishingNotification(content, eventMessages).WithState(notificationState))) - { - _logger.LogInformation("Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, content.Id, "publishing was cancelled"); - return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, eventMessages, content); - } - - // note: StrategyPublish flips the PublishedState to Publishing! - publishResult = StrategyPublish(content, culturesPublishing, culturesUnpublishing, eventMessages); - - // Check if a culture has been unpublished and if there are no cultures left, and then unpublish document as a whole - if (publishResult.Result == PublishResultType.SuccessUnpublishCulture && - content.PublishCultureInfos?.Count == 0) - { - // This is a special case! We are unpublishing the last culture and to persist that we need to re-publish without any cultures - // so the state needs to remain Publishing to do that. However, we then also need to unpublish the document and to do that - // the state needs to be Unpublishing and it cannot be both. This state is used within the documentRepository to know how to - // persist certain things. So before proceeding below, we need to save the Publishing state to publish no cultures, then we can - // mark the document for Unpublishing. - SaveDocument(content); - - // Set the flag to unpublish and continue - unpublishing = content.Published; // if not published yet, nothing to do - } - } - else - { - // in a branch, just give up - if (branchOne && !branchRoot) - { - return publishResult; - } - - // Check for mandatory culture missing, and then unpublish document as a whole - if (publishResult.Result == PublishResultType.FailedPublishMandatoryCultureMissing) - { - publishing = false; - unpublishing = content.Published; // if not published yet, nothing to do - - // we may end up in a state where we won't publish nor unpublish - // keep going, though, as we want to save anyways - } - - // reset published state from temp values (publishing, unpublishing) to original value - // (published, unpublished) in order to save the document, unchanged - yes, this is odd, - // but: (a) it means we don't reproduce the PublishState logic here and (b) setting the - // PublishState to anything other than Publishing or Unpublishing - which is precisely - // what we want to do here - throws - content.Published = content.Published; - } - } - - // won't happen in a branch - if (unpublishing) - { - IContent? newest = GetById(content.Id); // ensure we have the newest version - in scope - if (content.VersionId != newest?.VersionId) - { - return new PublishResult(PublishResultType.FailedPublishConcurrencyViolation, eventMessages, content); - } - - if (content.Published) - { - // ensure that the document can be unpublished, and unpublish - // handling events, business rules, etc - // note: StrategyUnpublish flips the PublishedState to Unpublishing! - // note: This unpublishes the entire document (not different variants) - unpublishResult = StrategyCanUnpublish(scope, content, eventMessages, notificationState); - if (unpublishResult.Success) - { - unpublishResult = StrategyUnpublish(content, eventMessages); - } - else - { - // reset published state from temp values (publishing, unpublishing) to original value - // (published, unpublished) in order to save the document, unchanged - yes, this is odd, - // but: (a) it means we don't reproduce the PublishState logic here and (b) setting the - // PublishState to anything other than Publishing or Unpublishing - which is precisely - // what we want to do here - throws - content.Published = content.Published; - return unpublishResult; - } - } - else - { - // already unpublished - optimistic concurrency collision, really, - // and I am not sure at all what we should do, better die fast, else - // we may end up corrupting the db - throw new InvalidOperationException("Concurrency collision."); - } - } - - // Persist the document - SaveDocument(content); - - // we have tried to unpublish - won't happen in a branch - if (unpublishing) - { - // and succeeded, trigger events - if (unpublishResult?.Success ?? false) - { - // events and audit - scope.Notifications.Publish( - new ContentUnpublishedNotification(content, eventMessages).WithState(notificationState)); - scope.Notifications.Publish(new ContentTreeChangeNotification( - content, - TreeChangeTypes.RefreshBranch, - variesByCulture ? culturesPublishing.IsCollectionEmpty() ? null : culturesPublishing : null, - variesByCulture ? culturesUnpublishing.IsCollectionEmpty() ? null : culturesUnpublishing : ["*"], - eventMessages)); - - if (culturesUnpublishing != null) - { - // This will mean that that we unpublished a mandatory culture or we unpublished the last culture. - var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesUnpublishing); - Audit(AuditType.UnpublishVariant, userId, content.Id, $"Unpublished languages: {langs}", langs); - - if (publishResult == null) - { - throw new PanicException("publishResult == null - should not happen"); - } - - switch (publishResult.Result) - { - case PublishResultType.FailedPublishMandatoryCultureMissing: - // Occurs when a mandatory culture was unpublished (which means we tried publishing the document without a mandatory culture) - - // Log that the whole content item has been unpublished due to mandatory culture unpublished - Audit(AuditType.Unpublish, userId, content.Id, "Unpublished (mandatory language unpublished)"); - return new PublishResult(PublishResultType.SuccessUnpublishMandatoryCulture, eventMessages, content); - case PublishResultType.SuccessUnpublishCulture: - // Occurs when the last culture is unpublished - Audit(AuditType.Unpublish, userId, content.Id, "Unpublished (last language unpublished)"); - return new PublishResult(PublishResultType.SuccessUnpublishLastCulture, eventMessages, content); - } - } - - Audit(AuditType.Unpublish, userId, content.Id); - return new PublishResult(PublishResultType.SuccessUnpublish, eventMessages, content); - } - - // or, failed - scope.Notifications.Publish(new ContentTreeChangeNotification(content, changeType, eventMessages)); - return new PublishResult(PublishResultType.FailedUnpublish, eventMessages, content); // bah - } - - // we have tried to publish - if (publishing) - { - // and succeeded, trigger events - if (publishResult?.Success ?? false) - { - if (isNew == false && previouslyPublished == false) - { - changeType = TreeChangeTypes.RefreshBranch; // whole branch - } - else if (isNew == false && previouslyPublished) - { - changeType = TreeChangeTypes.RefreshNode; // single node - } - - // invalidate the node/branch - // for branches, handled by SaveAndPublishBranch - if (!branchOne) - { - scope.Notifications.Publish( - new ContentTreeChangeNotification( - content, - changeType, - variesByCulture ? culturesPublishing.IsCollectionEmpty() ? null : culturesPublishing : ["*"], - variesByCulture ? culturesUnpublishing.IsCollectionEmpty() ? null : culturesUnpublishing : null, - eventMessages)); - scope.Notifications.Publish( - new ContentPublishedNotification(content, eventMessages).WithState(notificationState)); - } - - // it was not published and now is... descendants that were 'published' (but - // had an unpublished ancestor) are 're-published' ie not explicitly published - // but back as 'published' nevertheless - if (!branchOne && isNew == false && previouslyPublished == false && HasChildren(content.Id)) - { - IContent[] descendants = GetPublishedDescendantsLocked(content).ToArray(); - scope.Notifications.Publish( - new ContentPublishedNotification(descendants, eventMessages).WithState(notificationState)); - } - - switch (publishResult.Result) - { - case PublishResultType.SuccessPublish: - Audit(AuditType.Publish, userId, content.Id); - break; - case PublishResultType.SuccessPublishCulture: - if (culturesPublishing != null) - { - var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesPublishing); - Audit(AuditType.PublishVariant, userId, content.Id, $"Published languages: {langs}", langs); - } - - break; - case PublishResultType.SuccessUnpublishCulture: - if (culturesUnpublishing != null) - { - var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesUnpublishing); - Audit(AuditType.UnpublishVariant, userId, content.Id, $"Unpublished languages: {langs}", langs); - } - - break; - } - - return publishResult; - } - } - - // should not happen - if (branchOne && !branchRoot) - { - throw new PanicException("branchOne && !branchRoot - should not happen"); - } - - // if publishing didn't happen or if it has failed, we still need to log which cultures were saved - if (!branchOne && (publishResult == null || !publishResult.Success)) - { - if (culturesChanging != null) - { - var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesChanging); - Audit(AuditType.SaveVariant, userId, content.Id, $"Saved languages: {langs}", langs); - } - else - { - Audit(AuditType.Save, userId, content.Id); - } - } - - // or, failed - scope.Notifications.Publish(new ContentTreeChangeNotification(content, changeType, eventMessages)); - return publishResult!; - } /// public IEnumerable PerformScheduledPublish(DateTime date) - { - var allLangs = new Lazy>(() => _languageRepository.GetMany().ToList()); - EventMessages evtMsgs = EventMessagesFactory.Get(); - var results = new List(); - - PerformScheduledPublishingRelease(date, results, evtMsgs, allLangs); - PerformScheduledPublishingExpiration(date, results, evtMsgs, allLangs); - - return results; - } - - private void PerformScheduledPublishingExpiration(DateTime date, List results, EventMessages evtMsgs, Lazy> allLangs) - { - using ICoreScope scope = ScopeProvider.CreateCoreScope(); - - // do a fast read without any locks since this executes often to see if we even need to proceed - if (_documentRepository.HasContentForExpiration(date)) - { - // now take a write lock since we'll be updating - scope.WriteLock(Constants.Locks.ContentTree); - - foreach (IContent d in _documentRepository.GetContentForExpiration(date)) - { - ContentScheduleCollection contentSchedule = _documentRepository.GetContentSchedule(d.Id); - if (d.ContentType.VariesByCulture()) - { - // find which cultures have pending schedules - var pendingCultures = contentSchedule.GetPending(ContentScheduleAction.Expire, date) - .Select(x => x.Culture) - .Distinct() - .ToList(); - - if (pendingCultures.Count == 0) - { - continue; // shouldn't happen but no point in processing this document if there's nothing there - } - - var savingNotification = new ContentSavingNotification(d, evtMsgs); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - results.Add(new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, d)); - continue; - } - - foreach (var c in pendingCultures) - { - // Clear this schedule for this culture - contentSchedule.Clear(c, ContentScheduleAction.Expire, date); - - // set the culture to be published - d.UnpublishCulture(c); - } - - _documentRepository.PersistContentSchedule(d, contentSchedule); - PublishResult result = CommitDocumentChangesInternal(scope, d, evtMsgs, allLangs.Value, savingNotification.State, d.WriterId); - if (result.Success == false) - { - _logger.LogError(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, result.Result); - } - - results.Add(result); - } - else - { - // Clear this schedule for this culture - contentSchedule.Clear(ContentScheduleAction.Expire, date); - _documentRepository.PersistContentSchedule(d, contentSchedule); - PublishResult result = Unpublish(d, userId: d.WriterId); - if (result.Success == false) - { - _logger.LogError(null, "Failed to unpublish document id={DocumentId}, reason={Reason}.", d.Id, result.Result); - } - - results.Add(result); - } - } - - _documentRepository.ClearSchedule(date, ContentScheduleAction.Expire); - } - - scope.Complete(); - } - - private void PerformScheduledPublishingRelease(DateTime date, List results, EventMessages evtMsgs, Lazy> allLangs) - { - using ICoreScope scope = ScopeProvider.CreateCoreScope(); - - // do a fast read without any locks since this executes often to see if we even need to proceed - if (_documentRepository.HasContentForRelease(date)) - { - // now take a write lock since we'll be updating - scope.WriteLock(Constants.Locks.ContentTree); - - foreach (IContent d in _documentRepository.GetContentForRelease(date)) - { - ContentScheduleCollection contentSchedule = _documentRepository.GetContentSchedule(d.Id); - if (d.ContentType.VariesByCulture()) - { - // find which cultures have pending schedules - var pendingCultures = contentSchedule.GetPending(ContentScheduleAction.Release, date) - .Select(x => x.Culture) - .Distinct() - .ToList(); - - if (pendingCultures.Count == 0) - { - continue; // shouldn't happen but no point in processing this document if there's nothing there - } - var savingNotification = new ContentSavingNotification(d, evtMsgs); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - results.Add(new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, d)); - continue; - } - - - var publishing = true; - foreach (var culture in pendingCultures) - { - // Clear this schedule for this culture - contentSchedule.Clear(culture, ContentScheduleAction.Release, date); - - if (d.Trashed) - { - continue; // won't publish - } - - // publish the culture values and validate the property values, if validation fails, log the invalid properties so the develeper has an idea of what has failed - IProperty[]? invalidProperties = null; - CultureImpact impact = _cultureImpactFactory.ImpactExplicit(culture, IsDefaultCulture(allLangs.Value, culture)); - var tryPublish = d.PublishCulture(impact, date, _propertyEditorCollection) && - _propertyValidationService.Value.IsPropertyDataValid(d, out invalidProperties, impact); - if (invalidProperties != null && invalidProperties.Length > 0) - { - _logger.LogWarning( - "Scheduled publishing will fail for document {DocumentId} and culture {Culture} because of invalid properties {InvalidProperties}", - d.Id, - culture, - string.Join(",", invalidProperties.Select(x => x.Alias))); - } - - publishing &= tryPublish; // set the culture to be published - if (!publishing) - { - } - } - - PublishResult result; - - if (d.Trashed) - { - result = new PublishResult(PublishResultType.FailedPublishIsTrashed, evtMsgs, d); - } - else if (!publishing) - { - result = new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, d); - } - else - { - _documentRepository.PersistContentSchedule(d, contentSchedule); - result = CommitDocumentChangesInternal(scope, d, evtMsgs, allLangs.Value, savingNotification.State, d.WriterId); - } - - if (result.Success == false) - { - _logger.LogError(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, result.Result); - } - - results.Add(result); - } - else - { - // Clear this schedule - contentSchedule.Clear(ContentScheduleAction.Release, date); - - PublishResult? result = null; - - if (d.Trashed) - { - result = new PublishResult(PublishResultType.FailedPublishIsTrashed, evtMsgs, d); - } - else - { - _documentRepository.PersistContentSchedule(d, contentSchedule); - result = Publish(d, d.AvailableCultures.ToArray(), userId: d.WriterId); - } - - if (result.Success == false) - { - _logger.LogError(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, result.Result); - } - - results.Add(result); - } - } - - _documentRepository.ClearSchedule(date, ContentScheduleAction.Release); - } - - scope.Complete(); - } - - // utility 'PublishCultures' func used by SaveAndPublishBranch - private bool PublishBranch_PublishCultures(IContent content, HashSet culturesToPublish, IReadOnlyCollection allLangs) - { - // variant content type - publish specified cultures - // invariant content type - publish only the invariant culture - - var publishTime = DateTime.UtcNow; - if (content.ContentType.VariesByCulture()) - { - return culturesToPublish.All(culture => - { - CultureImpact? impact = _cultureImpactFactory.Create(culture, IsDefaultCulture(allLangs, culture), content); - return content.PublishCulture(impact, publishTime, _propertyEditorCollection) && - _propertyValidationService.Value.IsPropertyDataValid(content, out _, impact); - }); - } - - return content.PublishCulture(_cultureImpactFactory.ImpactInvariant(), publishTime, _propertyEditorCollection) - && _propertyValidationService.Value.IsPropertyDataValid(content, out _, _cultureImpactFactory.ImpactInvariant()); - } - - // utility 'ShouldPublish' func used by PublishBranch - private static HashSet? PublishBranch_ShouldPublish(ref HashSet? cultures, string c, bool published, bool edited, bool isRoot, PublishBranchFilter publishBranchFilter) - { - // if published, republish - if (published) - { - cultures ??= new HashSet(); // empty means 'already published' - - if (edited || publishBranchFilter.HasFlag(PublishBranchFilter.ForceRepublish)) - { - cultures.Add(c); // means 'republish this culture' - } - - return cultures; - } - - // if not published, publish if force/root else do nothing - if (!publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished) && !isRoot) - { - return cultures; // null means 'nothing to do' - } - - cultures ??= new HashSet(); - - cultures.Add(c); // means 'publish this culture' - return cultures; - } + => PublishOperationService.PerformScheduledPublish(date); /// public IEnumerable PublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string[] cultures, int userId = Constants.Security.SuperUserId) - { - // note: EditedValue and PublishedValue are objects here, so it is important to .Equals() - // and not to == them, else we would be comparing references, and that is a bad thing - - cultures = EnsureCultures(content, cultures); - - string? defaultCulture; - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - defaultCulture = _languageRepository.GetDefaultIsoCode(); - scope.Complete(); - } - - // determines cultures to be published - // can be: null (content is not impacted), an empty set (content is impacted but already published), or cultures - HashSet? ShouldPublish(IContent c) - { - var isRoot = c.Id == content.Id; - HashSet? culturesToPublish = null; - - // invariant content type - if (!c.ContentType.VariesByCulture()) - { - return PublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, publishBranchFilter); - } - - // variant content type, specific cultures - if (c.Published) - { - // then some (and maybe all) cultures will be 'already published' (unless forcing), - // others will have to 'republish this culture' - foreach (var culture in cultures) - { - // We could be publishing a parent invariant page, with descendents that are variant. - // So convert the invariant request to a request for the default culture. - var specificCulture = culture == "*" ? defaultCulture : culture; - - PublishBranch_ShouldPublish(ref culturesToPublish, specificCulture, c.IsCulturePublished(specificCulture), c.IsCultureEdited(specificCulture), isRoot, publishBranchFilter); - } - - return culturesToPublish; - } - - // if not published, publish if forcing unpublished/root else do nothing - return publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished) || isRoot - ? new HashSet(cultures) // means 'publish specified cultures' - : null; // null means 'nothing to do' - } - - return PublishBranch(content, ShouldPublish, PublishBranch_PublishCultures, userId); - } - - private static string[] EnsureCultures(IContent content, string[] cultures) - { - // Ensure consistent indication of "all cultures" for variant content. - if (content.ContentType.VariesByCulture() is false && ProvidedCulturesIndicatePublishAll(cultures)) - { - cultures = ["*"]; - } - - return cultures.Select(x => x.EnsureCultureCode()!).ToArray(); - } - - private static bool ProvidedCulturesIndicatePublishAll(string[] cultures) => cultures.Length == 0 || (cultures.Length == 1 && cultures[0] == "invariant"); - - internal IEnumerable PublishBranch( - IContent document, - Func?> shouldPublish, - Func, IReadOnlyCollection, bool> publishCultures, - int userId = Constants.Security.SuperUserId) - { - if (shouldPublish == null) - { - throw new ArgumentNullException(nameof(shouldPublish)); - } - - if (publishCultures == null) - { - throw new ArgumentNullException(nameof(publishCultures)); - } - - EventMessages eventMessages = EventMessagesFactory.Get(); - var results = new List(); - var publishedDocuments = new List(); - - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - scope.WriteLock(Constants.Locks.ContentTree); - - var allLangs = _languageRepository.GetMany().ToList(); - - if (!document.HasIdentity) - { - throw new InvalidOperationException("Cannot not branch-publish a new document."); - } - - PublishedState publishedState = document.PublishedState; - if (publishedState == PublishedState.Publishing) - { - throw new InvalidOperationException("Cannot mix PublishCulture and SaveAndPublishBranch."); - } - - // deal with the branch root - if it fails, abort - HashSet? culturesToPublish = shouldPublish(document); - PublishResult? result = PublishBranchItem(scope, document, culturesToPublish, publishCultures, true, publishedDocuments, eventMessages, userId, allLangs, out IDictionary? notificationState); - if (result != null) - { - results.Add(result); - if (!result.Success) - { - return results; - } - } - - HashSet culturesPublished = culturesToPublish ?? []; - - // deal with descendants - // if one fails, abort its branch - var exclude = new HashSet(); - - int count; - var page = 0; - const int pageSize = 100; - do - { - count = 0; - - // important to order by Path ASC so make it explicit in case defaults change - // ReSharper disable once RedundantArgumentDefaultValue - foreach (IContent d in GetPagedDescendants(document.Id, page, pageSize, out _, ordering: Ordering.By("Path", Direction.Ascending))) - { - count++; - - // if parent is excluded, exclude child too - if (exclude.Contains(d.ParentId)) - { - exclude.Add(d.Id); - continue; - } - - // no need to check path here, parent has to be published here - culturesToPublish = shouldPublish(d); - result = PublishBranchItem(scope, d, culturesToPublish, publishCultures, false, publishedDocuments, eventMessages, userId, allLangs, out _); - if (result != null) - { - results.Add(result); - if (result.Success) - { - culturesPublished.UnionWith(culturesToPublish ?? []); - continue; - } - } - - // if we could not publish the document, cut its branch - exclude.Add(d.Id); - } - - page++; - } - while (count > 0); - - Audit(AuditType.Publish, userId, document.Id, "Branch published"); - - // trigger events for the entire branch - // (SaveAndPublishBranchOne does *not* do it) - var variesByCulture = document.ContentType.VariesByCulture(); - scope.Notifications.Publish( - new ContentTreeChangeNotification( - document, - TreeChangeTypes.RefreshBranch, - variesByCulture ? culturesPublished.IsCollectionEmpty() ? null : culturesPublished : ["*"], - null, - eventMessages)); - scope.Notifications.Publish(new ContentPublishedNotification(publishedDocuments, eventMessages, true).WithState(notificationState)); - - scope.Complete(); - } - - return results; - } - - // shouldPublish: a function determining whether the document has changes that need to be published - // note - 'force' is handled by 'editing' - // publishValues: a function publishing values (using the appropriate PublishCulture calls) - private PublishResult? PublishBranchItem( - ICoreScope scope, - IContent document, - HashSet? culturesToPublish, - Func, IReadOnlyCollection, - bool> publishCultures, - bool isRoot, - ICollection publishedDocuments, - EventMessages evtMsgs, - int userId, - IReadOnlyCollection allLangs, - out IDictionary? initialNotificationState) - { - initialNotificationState = new Dictionary(); - - // we need to guard against unsaved changes before proceeding; the document will be saved, but we're not firing any saved notifications - if (HasUnsavedChanges(document)) - { - return new PublishResult(PublishResultType.FailedPublishUnsavedChanges, evtMsgs, document); - } - - // null = do not include - if (culturesToPublish == null) - { - return null; - } - - // empty = already published - if (culturesToPublish.Count == 0) - { - return new PublishResult(PublishResultType.SuccessPublishAlready, evtMsgs, document); - } - - var savingNotification = new ContentSavingNotification(document, evtMsgs); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, document); - } - - // publish & check if values are valid - if (!publishCultures(document, culturesToPublish, allLangs)) - { - // TODO: Based on this callback behavior there is no way to know which properties may have been invalid if this failed, see other results of FailedPublishContentInvalid - return new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, document); - } - - PublishResult result = CommitDocumentChangesInternal(scope, document, evtMsgs, allLangs, savingNotification.State, userId, true, isRoot); - if (result.Success) - { - publishedDocuments.Add(document); - } - - return result; - } + => PublishOperationService.PublishBranch(content, publishBranchFilter, cultures, userId); #endregion @@ -2173,67 +1024,9 @@ public class ContentService : RepositoryService, IContentService return parentKeyAttempt.Success; } - /// - /// Sends an to Publication, which executes handlers and events for the 'Send to Publication' - /// action. - /// - /// The to send to publication - /// Optional Id of the User issuing the send to publication - /// True if sending publication was successful otherwise false + /// public bool SendToPublication(IContent? content, int userId = Constants.Security.SuperUserId) - { - if (content is null) - { - return false; - } - - EventMessages evtMsgs = EventMessagesFactory.Get(); - - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - var sendingToPublishNotification = new ContentSendingToPublishNotification(content, evtMsgs); - if (scope.Notifications.PublishCancelable(sendingToPublishNotification)) - { - scope.Complete(); - return false; - } - - // track the cultures changing for auditing - var culturesChanging = content.ContentType.VariesByCulture() - ? string.Join(",", content.CultureInfos!.Values.Where(x => x.IsDirty()).Select(x => x.Culture)) - : null; - - // TODO: Currently there's no way to change track which variant properties have changed, we only have change - // tracking enabled on all values on the Property which doesn't allow us to know which variants have changed. - // in this particular case, determining which cultures have changed works with the above with names since it will - // have always changed if it's been saved in the back office but that's not really fail safe. - - // Save before raising event - OperationResult saveResult = Save(content, userId); - - // always complete (but maybe return a failed status) - scope.Complete(); - - if (!saveResult.Success) - { - return saveResult.Success; - } - - scope.Notifications.Publish( - new ContentSentToPublishNotification(content, evtMsgs).WithStateFrom(sendingToPublishNotification)); - - if (culturesChanging != null) - { - Audit(AuditType.SendToPublishVariant, userId, content.Id, $"Send To Publish for cultures: {culturesChanging}", culturesChanging); - } - else - { - Audit(AuditType.SendToPublish, userId, content.Id); - } - - return saveResult.Success; - } - } + => PublishOperationService.SendToPublication(content, userId); /// /// Sorts a collection of objects by updating the SortOrder according @@ -2288,48 +1081,6 @@ public class ContentService : RepositoryService, IContentService #endregion - #region Internal Methods - - /// - /// Gets a collection of descendants by the first Parent. - /// - /// item to retrieve Descendants from - /// An Enumerable list of objects - internal IEnumerable GetPublishedDescendants(IContent content) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return GetPublishedDescendantsLocked(content).ToArray(); // ToArray important in uow! - } - } - - internal IEnumerable GetPublishedDescendantsLocked(IContent content) - { - var pathMatch = content.Path + ","; - IQuery query = Query() - .Where(x => x.Id != content.Id && x.Path.StartsWith(pathMatch) /*&& culture.Trashed == false*/); - IEnumerable contents = _documentRepository.Get(query); - - // beware! contents contains all published version below content - // including those that are not directly published because below an unpublished content - // these must be filtered out here - var parents = new List { content.Id }; - if (contents is not null) - { - foreach (IContent c in contents) - { - if (parents.Contains(c.ParentId)) - { - yield return c; - parents.Add(c.Id); - } - } - } - } - - #endregion - #region Private Methods private void Audit(AuditType type, int userId, int objectId, string? message = null, string? parameters = null) => @@ -2348,350 +1099,6 @@ public class ContentService : RepositoryService, IContentService parameters); } - private string GetLanguageDetailsForAuditEntry(IEnumerable affectedCultures) - => GetLanguageDetailsForAuditEntry(_languageRepository.GetMany(), affectedCultures); - - private static string GetLanguageDetailsForAuditEntry(IEnumerable languages, IEnumerable affectedCultures) - { - IEnumerable languageIsoCodes = languages - .Where(x => affectedCultures.InvariantContains(x.IsoCode)) - .Select(x => x.IsoCode); - return string.Join(", ", languageIsoCodes); - } - - private static bool IsDefaultCulture(IReadOnlyCollection? langs, string culture) => - langs?.Any(x => x.IsDefault && x.IsoCode.InvariantEquals(culture)) ?? false; - - private bool IsMandatoryCulture(IReadOnlyCollection langs, string culture) => - langs.Any(x => x.IsMandatory && x.IsoCode.InvariantEquals(culture)); - - #endregion - - #region Publishing Strategies - - /// - /// Ensures that a document can be published - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - private PublishResult StrategyCanPublish( - ICoreScope scope, - IContent content, - bool checkPath, - IReadOnlyList? culturesPublishing, - IReadOnlyCollection? culturesUnpublishing, - EventMessages evtMsgs, - IReadOnlyCollection allLangs, - IDictionary? notificationState) - { - var variesByCulture = content.ContentType.VariesByCulture(); - - // If it's null it's invariant - CultureImpact[] impactsToPublish = culturesPublishing == null - ? new[] { _cultureImpactFactory.ImpactInvariant() } - : culturesPublishing.Select(x => - _cultureImpactFactory.ImpactExplicit( - x, - allLangs.Any(lang => lang.IsoCode.InvariantEquals(x) && lang.IsMandatory))) - .ToArray(); - - // publish the culture(s) - var publishTime = DateTime.UtcNow; - if (!impactsToPublish.All(impact => content.PublishCulture(impact, publishTime, _propertyEditorCollection))) - { - return new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, content); - } - - // Validate the property values - IProperty[]? invalidProperties = null; - if (!impactsToPublish.All(x => - _propertyValidationService.Value.IsPropertyDataValid(content, out invalidProperties, x))) - { - return new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, content) - { - InvalidProperties = invalidProperties, - }; - } - - // Check if mandatory languages fails, if this fails it will mean anything that the published flag on the document will - // be changed to Unpublished and any culture currently published will not be visible. - if (variesByCulture) - { - if (culturesPublishing == null) - { - throw new InvalidOperationException( - "Internal error, variesByCulture but culturesPublishing is null."); - } - - if (content.Published && culturesPublishing.Count == 0 && culturesUnpublishing?.Count == 0) - { - // no published cultures = cannot be published - // This will occur if for example, a culture that is already unpublished is sent to be unpublished again, or vice versa, in that case - // there will be nothing to publish/unpublish. - return new PublishResult(PublishResultType.FailedPublishNothingToPublish, evtMsgs, content); - } - - // missing mandatory culture = cannot be published - IEnumerable mandatoryCultures = allLangs.Where(x => x.IsMandatory).Select(x => x.IsoCode); - var mandatoryMissing = mandatoryCultures.Any(x => - !content.PublishedCultures.Contains(x, StringComparer.OrdinalIgnoreCase)); - if (mandatoryMissing) - { - return new PublishResult(PublishResultType.FailedPublishMandatoryCultureMissing, evtMsgs, content); - } - - if (culturesPublishing.Count == 0 && culturesUnpublishing?.Count > 0) - { - return new PublishResult(PublishResultType.SuccessUnpublishCulture, evtMsgs, content); - } - } - - // ensure that the document has published values - // either because it is 'publishing' or because it already has a published version - if (content.PublishedState != PublishedState.Publishing && content.PublishedVersionId == 0) - { - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) cannot be published: {Reason}", - content.Name, - content.Id, - "document does not have published values"); - return new PublishResult(PublishResultType.FailedPublishNothingToPublish, evtMsgs, content); - } - - ContentScheduleCollection contentSchedule = _documentRepository.GetContentSchedule(content.Id); - - // loop over each culture publishing - or InvariantCulture for invariant - foreach (var culture in culturesPublishing ?? new[] { Constants.System.InvariantCulture }) - { - // ensure that the document status is correct - // note: culture will be string.Empty for invariant - switch (content.GetStatus(contentSchedule, culture)) - { - case ContentStatus.Expired: - if (!variesByCulture) - { - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, content.Id, "document has expired"); - } - else - { - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) culture {Culture} cannot be published: {Reason}", content.Name, content.Id, culture, "document culture has expired"); - } - - return new PublishResult( - !variesByCulture - ? PublishResultType.FailedPublishHasExpired : PublishResultType.FailedPublishCultureHasExpired, - evtMsgs, - content); - - case ContentStatus.AwaitingRelease: - if (!variesByCulture) - { - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) cannot be published: {Reason}", - content.Name, - content.Id, - "document is awaiting release"); - } - else - { - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) culture {Culture} cannot be published: {Reason}", - content.Name, - content.Id, - culture, - "document has culture awaiting release"); - } - - return new PublishResult( - !variesByCulture - ? PublishResultType.FailedPublishAwaitingRelease - : PublishResultType.FailedPublishCultureAwaitingRelease, - evtMsgs, - content); - - case ContentStatus.Trashed: - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) cannot be published: {Reason}", - content.Name, - content.Id, - "document is trashed"); - return new PublishResult(PublishResultType.FailedPublishIsTrashed, evtMsgs, content); - } - } - - if (checkPath) - { - // check if the content can be path-published - // root content can be published - // else check ancestors - we know we are not trashed - var pathIsOk = content.ParentId == Constants.System.Root || IsPathPublished(GetParent(content)); - if (!pathIsOk) - { - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) cannot be published: {Reason}", - content.Name, - content.Id, - "parent is not published"); - return new PublishResult(PublishResultType.FailedPublishPathNotPublished, evtMsgs, content); - } - } - - // If we are both publishing and unpublishing cultures, then return a mixed status - if (variesByCulture && culturesPublishing?.Count > 0 && culturesUnpublishing?.Count > 0) - { - return new PublishResult(PublishResultType.SuccessMixedCulture, evtMsgs, content); - } - - return new PublishResult(evtMsgs, content); - } - - /// - /// Publishes a document - /// - /// - /// - /// - /// - /// - /// - /// It is assumed that all publishing checks have passed before calling this method like - /// - /// - private PublishResult StrategyPublish( - IContent content, - IReadOnlyCollection? culturesPublishing, - IReadOnlyCollection? culturesUnpublishing, - EventMessages evtMsgs) - { - // change state to publishing - content.PublishedState = PublishedState.Publishing; - - // if this is a variant then we need to log which cultures have been published/unpublished and return an appropriate result - if (content.ContentType.VariesByCulture()) - { - if (content.Published && culturesUnpublishing?.Count == 0 && culturesPublishing?.Count == 0) - { - return new PublishResult(PublishResultType.FailedPublishNothingToPublish, evtMsgs, content); - } - - if (culturesUnpublishing?.Count > 0) - { - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) cultures: {Cultures} have been unpublished.", - content.Name, - content.Id, - string.Join(",", culturesUnpublishing)); - } - - if (culturesPublishing?.Count > 0) - { - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) cultures: {Cultures} have been published.", - content.Name, - content.Id, - string.Join(",", culturesPublishing)); - } - - if (culturesUnpublishing?.Count > 0 && culturesPublishing?.Count > 0) - { - return new PublishResult(PublishResultType.SuccessMixedCulture, evtMsgs, content); - } - - if (culturesUnpublishing?.Count > 0 && culturesPublishing?.Count == 0) - { - return new PublishResult(PublishResultType.SuccessUnpublishCulture, evtMsgs, content); - } - - return new PublishResult(PublishResultType.SuccessPublishCulture, evtMsgs, content); - } - - _logger.LogInformation("Document {ContentName} (id={ContentId}) has been published.", content.Name, content.Id); - return new PublishResult(evtMsgs, content); - } - - /// - /// Ensures that a document can be unpublished - /// - /// - /// - /// - /// - /// - private PublishResult StrategyCanUnpublish( - ICoreScope scope, - IContent content, - EventMessages evtMsgs, - IDictionary? notificationState) - { - // raise Unpublishing notification - ContentUnpublishingNotification notification = new ContentUnpublishingNotification(content, evtMsgs).WithState(notificationState); - var notificationResult = scope.Notifications.PublishCancelable(notification); - - if (notificationResult) - { - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) cannot be unpublished: unpublishing was cancelled.", content.Name, content.Id); - return new PublishResult(PublishResultType.FailedUnpublishCancelledByEvent, evtMsgs, content); - } - - return new PublishResult(PublishResultType.SuccessUnpublish, evtMsgs, content); - } - - /// - /// Unpublishes a document - /// - /// - /// - /// - /// - /// It is assumed that all unpublishing checks have passed before calling this method like - /// - /// - private PublishResult StrategyUnpublish(IContent content, EventMessages evtMsgs) - { - var attempt = new PublishResult(PublishResultType.SuccessUnpublish, evtMsgs, content); - - // TODO: What is this check?? we just created this attempt and of course it is Success?! - if (attempt.Success == false) - { - return attempt; - } - - // if the document has any release dates set to before now, - // they should be removed so they don't interrupt an unpublish - // otherwise it would remain released == published - ContentScheduleCollection contentSchedule = _documentRepository.GetContentSchedule(content.Id); - IReadOnlyList pastReleases = - contentSchedule.GetPending(ContentScheduleAction.Expire, DateTime.UtcNow); - foreach (ContentSchedule p in pastReleases) - { - contentSchedule.Remove(p); - } - - if (pastReleases.Count > 0) - { - _logger.LogInformation( - "Document {ContentName} (id={ContentId}) had its release date removed, because it was unpublished.", content.Name, content.Id); - } - - _documentRepository.PersistContentSchedule(content, contentSchedule); - - // change state to unpublishing - content.PublishedState = PublishedState.Unpublishing; - - _logger.LogInformation("Document {ContentName} (id={ContentId}) has been unpublished.", content.Name, content.Id); - return attempt; - } - #endregion #region Content Types