using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using Umbraco.Core.Composing; using Umbraco.Core.Events; using Umbraco.Core.Exceptions; using Umbraco.Core.IO; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Models.Membership; using Umbraco.Core.Persistence.DatabaseModelDefinitions; using Umbraco.Core.Persistence.Querying; using Umbraco.Core.Persistence.Repositories; using Umbraco.Core.Scoping; using Umbraco.Core.Services.Changes; namespace Umbraco.Core.Services.Implement { /// /// Implements the content service. /// internal class ContentService : RepositoryService, IContentService { private readonly IDocumentRepository _documentRepository; private readonly IEntityRepository _entityRepository; private readonly IAuditRepository _auditRepository; private readonly IContentTypeRepository _contentTypeRepository; private readonly IDocumentBlueprintRepository _documentBlueprintRepository; private readonly ILanguageRepository _languageRepository; private readonly MediaFileSystem _mediaFileSystem; private IQuery _queryNotTrashed; #region Constructors public ContentService(IScopeProvider provider, ILogger logger, IEventMessagesFactory eventMessagesFactory, MediaFileSystem mediaFileSystem, IDocumentRepository documentRepository, IEntityRepository entityRepository, IAuditRepository auditRepository, IContentTypeRepository contentTypeRepository, IDocumentBlueprintRepository documentBlueprintRepository, ILanguageRepository languageRepository) : base(provider, logger, eventMessagesFactory) { _mediaFileSystem = mediaFileSystem; _documentRepository = documentRepository; _entityRepository = entityRepository; _auditRepository = auditRepository; _contentTypeRepository = contentTypeRepository; _documentBlueprintRepository = documentBlueprintRepository; _languageRepository = languageRepository; } #endregion #region Static queries // lazy-constructed because when the ctor runs, the query factory may not be ready private IQuery QueryNotTrashed => _queryNotTrashed ?? (_queryNotTrashed = Query().Where(x => x.Trashed == false)); #endregion #region Count public int CountPublished(string contentTypeAlias = null) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.CountPublished(contentTypeAlias); } } public int Count(string contentTypeAlias = null) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.Count(contentTypeAlias); } } public int CountChildren(int parentId, string contentTypeAlias = null) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.CountChildren(parentId, contentTypeAlias); } } public int CountDescendants(int parentId, string contentTypeAlias = null) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.CountDescendants(parentId, contentTypeAlias); } } #endregion #region Permissions /// /// Used to bulk update the permissions set for a content item. This will replace all permissions /// assigned to an entity with a list of user id & permission pairs. /// /// public void SetPermissions(EntityPermissionSet permissionSet) { using (var scope = ScopeProvider.CreateScope()) { scope.WriteLock(Constants.Locks.ContentTree); _documentRepository.ReplaceContentPermissions(permissionSet); scope.Complete(); } } /// /// Assigns a single permission to the current content item for the specified group ids /// /// /// /// public void SetPermission(IContent entity, char permission, IEnumerable groupIds) { using (var scope = ScopeProvider.CreateScope()) { scope.WriteLock(Constants.Locks.ContentTree); _documentRepository.AssignEntityPermission(entity, permission, groupIds); scope.Complete(); } } /// /// Returns implicit/inherited permissions assigned to the content item for all user groups /// /// /// public EntityPermissionCollection GetPermissions(IContent content) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.GetPermissionsForEntity(content.Id); } } #endregion #region Create /// /// Creates an object using the alias of the /// that this Content should based on. /// /// /// Note that using this method will simply return a new IContent without any identity /// as it has not yet been persisted. It is intended as a shortcut to creating new content objects /// that does not invoke a save operation against the database. /// /// Name of the Content object /// Id of Parent for the new Content /// Alias of the /// Optional id of the user creating the content /// public IContent Create(string name, Guid parentId, string contentTypeAlias, int userId = 0) { //fixme - what about culture? var parent = GetById(parentId); return Create(name, parent, contentTypeAlias, userId); } /// /// Creates an object of a specified content type. /// /// This method simply returns a new, non-persisted, IContent without any identity. It /// is intended as a shortcut to creating new content objects that does not invoke a save /// operation against the database. /// /// The name of the content object. /// The identifier of the parent, or -1. /// The alias of the content type. /// The optional id of the user creating the content. /// The content object. public IContent Create(string name, int parentId, string contentTypeAlias, int userId = 0) { //fixme - what about culture? var contentType = GetContentType(contentTypeAlias); if (contentType == null) throw new ArgumentException("No content type with that alias.", nameof(contentTypeAlias)); var parent = parentId > 0 ? GetById(parentId) : null; if (parentId > 0 && parent == null) throw new ArgumentException("No content with that id.", nameof(parentId)); var content = new Content(name, parentId, contentType); using (var scope = ScopeProvider.CreateScope()) { CreateContent(scope, content, parent, userId, false); scope.Complete(); } return content; } /// /// Creates an object of a specified content type, under a parent. /// /// This method simply returns a new, non-persisted, IContent without any identity. It /// is intended as a shortcut to creating new content objects that does not invoke a save /// operation against the database. /// /// The name of the content object. /// The parent content object. /// The alias of the content type. /// The optional id of the user creating the content. /// The content object. public IContent Create(string name, IContent parent, string contentTypeAlias, int userId = 0) { //fixme - what about culture? if (parent == null) throw new ArgumentNullException(nameof(parent)); using (var scope = ScopeProvider.CreateScope()) { // not locking since not saving anything var contentType = GetContentType(contentTypeAlias); if (contentType == null) throw new ArgumentException("No content type with that alias.", nameof(contentTypeAlias)); // causes rollback var content = new Content(name, parent, contentType); CreateContent(scope, content, parent, userId, false); scope.Complete(); return content; } } /// /// Creates an object of a specified content type. /// /// This method returns a new, persisted, IContent with an identity. /// The name of the content object. /// The identifier of the parent, or -1. /// The alias of the content type. /// The optional id of the user creating the content. /// The content object. public IContent CreateAndSave(string name, int parentId, string contentTypeAlias, int userId = 0) { //fixme - what about culture? using (var scope = ScopeProvider.CreateScope()) { // locking the content tree secures content types too scope.WriteLock(Constants.Locks.ContentTree); var contentType = GetContentType(contentTypeAlias); // + locks if (contentType == null) throw new ArgumentException("No content type with that alias.", nameof(contentTypeAlias)); // causes rollback var parent = parentId > 0 ? GetById(parentId) : null; // + locks if (parentId > 0 && parent == null) throw new ArgumentException("No content with that id.", nameof(parentId)); // causes rollback var content = parentId > 0 ? new Content(name, parent, contentType) : new Content(name, parentId, contentType); CreateContent(scope, content, parent, userId, true); scope.Complete(); return content; } } /// /// Creates an object of a specified content type, under a parent. /// /// This method returns a new, persisted, IContent with an identity. /// The name of the content object. /// The parent content object. /// The alias of the content type. /// The optional id of the user creating the content. /// The content object. public IContent CreateAndSave(string name, IContent parent, string contentTypeAlias, int userId = 0) { //fixme - what about culture? if (parent == null) throw new ArgumentNullException(nameof(parent)); using (var scope = ScopeProvider.CreateScope()) { // locking the content tree secures content types too scope.WriteLock(Constants.Locks.ContentTree); var contentType = GetContentType(contentTypeAlias); // + locks if (contentType == null) throw new ArgumentException("No content type with that alias.", nameof(contentTypeAlias)); // causes rollback var content = new Content(name, parent, contentType); CreateContent(scope, content, parent, userId, true); scope.Complete(); return content; } } private void CreateContent(IScope scope, Content content, IContent parent, int userId, bool withIdentity) { content.CreatorId = userId; content.WriterId = userId; if (withIdentity) { // if saving is cancelled, content remains without an identity var saveEventArgs = new SaveEventArgs(content); if (scope.Events.DispatchCancelable(Saving, this, saveEventArgs, "Saving")) return; _documentRepository.Save(content); saveEventArgs.CanCancel = false; scope.Events.Dispatch(Saved, this, saveEventArgs, "Saved"); scope.Events.Dispatch(TreeChanged, this, new TreeChange(content, TreeChangeTypes.RefreshNode).ToEventArgs()); } scope.Events.Dispatch(Created, this, new NewEventArgs(content, false, content.ContentType.Alias, parent)); if (withIdentity == false) return; Audit(AuditType.New, $"Content '{content.Name}' was created with Id {content.Id}", content.CreatorId, content.Id); } #endregion #region Get, Has, Is /// /// Gets an object by Id /// /// Id of the Content to retrieve /// public IContent GetById(int id) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.Get(id); } } /// /// Gets an object by Id /// /// Ids of the Content to retrieve /// public IEnumerable GetByIds(IEnumerable ids) { var idsA = ids.ToArray(); if (idsA.Length == 0) return Enumerable.Empty(); using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); var items = _documentRepository.GetMany(idsA); var index = items.ToDictionary(x => x.Id, x => x); return idsA.Select(x => index.TryGetValue(x, out var c) ? c : null).WhereNotNull(); } } /// /// Gets an object by its 'UniqueId' /// /// Guid key of the Content to retrieve /// public IContent GetById(Guid key) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.Get(key); } } /// /// Gets objects by Ids /// /// Ids of the Content to retrieve /// public IEnumerable GetByIds(IEnumerable ids) { var idsA = ids.ToArray(); if (idsA.Length == 0) return Enumerable.Empty(); using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); var items = _documentRepository.GetMany(idsA); var index = items.ToDictionary(x => x.Key, x => x); return idsA.Select(x => index.TryGetValue(x, out var c) ? c : null).WhereNotNull(); } } /// /// Gets a collection of objects by the Id of the /// /// Id of the /// An Enumerable list of objects public IEnumerable GetByType(int id) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); var query = Query().Where(x => x.ContentTypeId == id); return _documentRepository.Get(query); } } internal IEnumerable GetPublishedContentOfContentType(int id) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); var query = Query().Where(x => x.ContentTypeId == id); return _documentRepository.Get(query); } } /// /// Gets a collection of objects by Level /// /// The level to retrieve Content from /// An Enumerable list of objects /// Contrary to most methods, this method filters out trashed content items. public IEnumerable GetByLevel(int level) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); var query = Query().Where(x => x.Level == level && x.Trashed == false); return _documentRepository.Get(query); } } /// /// Gets a specific version of an item. /// /// Id of the version to retrieve /// An item public IContent GetVersion(int versionId) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.GetVersion(versionId); } } /// /// Gets a collection of an objects versions by Id /// /// /// An Enumerable list of objects public IEnumerable GetVersions(int id) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.GetAllVersions(id); } } /// /// Gets a list of all version Ids for the given content item ordered so latest is first /// /// /// The maximum number of rows to return /// public IEnumerable GetVersionIds(int id, int maxRows) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { return _documentRepository.GetVersionIds(id, maxRows); } } /// /// Gets a collection of objects, which are ancestors of the current content. /// /// Id of the to retrieve ancestors for /// An Enumerable list of objects public IEnumerable GetAncestors(int id) { // intentionnaly not locking var content = GetById(id); return GetAncestors(content); } /// /// Gets a collection of objects, which are ancestors of the current content. /// /// to retrieve ancestors for /// An Enumerable list of objects public IEnumerable GetAncestors(IContent content) { //null check otherwise we get exceptions if (content.Path.IsNullOrWhiteSpace()) return Enumerable.Empty(); var rootId = Constants.System.Root.ToInvariantString(); var ids = content.Path.Split(',') .Where(x => x != rootId && x != content.Id.ToString(CultureInfo.InvariantCulture)).Select(int.Parse).ToArray(); if (ids.Any() == false) return new List(); using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.GetMany(ids); } } /// /// Gets a collection of objects by Parent Id /// /// Id of the Parent to retrieve Children from /// An Enumerable list of objects public IEnumerable GetChildren(int id) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); var query = Query().Where(x => x.ParentId == id); return _documentRepository.Get(query).OrderBy(x => x.SortOrder); } } /// /// 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 (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); var query = Query().Where(x => x.ParentId == id && x.Published); return _documentRepository.Get(query).OrderBy(x => x.SortOrder); } } /// public IEnumerable GetPagedChildren(int id, long pageIndex, int pageSize, out long totalChildren, string filter = null, Ordering ordering = null) { var filterQuery = filter.IsNullOrWhiteSpace() ? null : Query().Where(x => x.Name.Contains(filter)); return GetPagedChildren(id, pageIndex, pageSize, out totalChildren, filterQuery, ordering); } /// public IEnumerable GetPagedChildren(int id, long pageIndex, int pageSize, out long totalChildren, IQuery filter, Ordering ordering = null) { if (pageIndex < 0) throw new ArgumentOutOfRangeException(nameof(pageIndex)); if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize)); if (ordering == null) ordering = Ordering.By("sortOrder"); using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); var query = Query().Where(x => x.ParentId == id); return _documentRepository.GetPage(query, pageIndex, pageSize, out totalChildren, filter, ordering); } } /// public IEnumerable GetPagedDescendants(int id, long pageIndex, int pageSize, out long totalChildren, string orderBy = "Path", Direction orderDirection = Direction.Ascending, string filter = "") { var filterQuery = filter.IsNullOrWhiteSpace() ? null : Query().Where(x => x.Name.Contains(filter)); return GetPagedDescendants(id, pageIndex, pageSize, out totalChildren, orderBy, orderDirection, true, filterQuery); } /// public IEnumerable GetPagedDescendants(int id, long pageIndex, int pageSize, out long totalChildren, string orderBy, Direction orderDirection, bool orderBySystemField, IQuery filter) { if (pageIndex < 0) throw new ArgumentOutOfRangeException(nameof(pageIndex)); if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize)); using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); var query = Query(); //if the id is System Root, then just get all if (id != Constants.System.Root) { var contentPath = _entityRepository.GetAllPaths(Constants.ObjectTypes.Document, id).ToArray(); if (contentPath.Length == 0) { totalChildren = 0; return Enumerable.Empty(); } query.Where(x => x.Path.SqlStartsWith($"{contentPath[0].Path},", TextColumnType.NVarchar)); } return _documentRepository.GetPage(query, pageIndex, pageSize, out totalChildren, filter, Ordering.By(orderBy, orderDirection, isCustomField: !orderBySystemField)); } } /// /// Gets a collection of objects by its name or partial name /// /// Id of the Parent to retrieve Children from /// Full or partial name of the children /// An Enumerable list of objects public IEnumerable GetChildren(int parentId, string name) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); var query = Query().Where(x => x.ParentId == parentId && x.Name.Contains(name)); return _documentRepository.Get(query); } } /// /// Gets a collection of objects by Parent Id /// /// Id of the Parent to retrieve Descendants from /// An Enumerable list of objects public IEnumerable GetDescendants(int id) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); var content = GetById(id); if (content == null) { scope.Complete(); // else causes rollback return Enumerable.Empty(); } var pathMatch = content.Path + ","; var query = Query().Where(x => x.Id != content.Id && x.Path.StartsWith(pathMatch)); return _documentRepository.Get(query); } } /// /// Gets a collection of objects by Parent Id /// /// item to retrieve Descendants from /// An Enumerable list of objects public IEnumerable GetDescendants(IContent content) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); var pathMatch = content.Path + ","; var query = Query().Where(x => x.Id != content.Id && x.Path.StartsWith(pathMatch)); return _documentRepository.Get(query); } } /// /// Gets the parent of the current content as an item. /// /// Id of the to retrieve the parent from /// Parent object public IContent GetParent(int id) { // intentionnaly not locking var content = GetById(id); return GetParent(content); } /// /// Gets the parent of the current content as an item. /// /// to retrieve the parent from /// Parent object public IContent GetParent(IContent content) { if (content.ParentId == Constants.System.Root || content.ParentId == Constants.System.RecycleBinContent) return null; return GetById(content.ParentId); } /// /// Gets a collection of objects, which reside at the first level / root /// /// An Enumerable list of objects public IEnumerable GetRootContent() { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); var query = Query().Where(x => x.ParentId == Constants.System.Root); return _documentRepository.Get(query); } } /// /// Gets all published content items /// /// internal IEnumerable GetAllPublished() { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.Get(QueryNotTrashed); } } /// /// Gets a collection of objects, which has an expiration date less than or equal to today. /// /// An Enumerable list of objects public IEnumerable GetContentForExpiration() { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); var query = Query().Where(x => x.Published && x.ExpireDate <= DateTime.Now); return _documentRepository.Get(query); } } /// /// Gets a collection of objects, which has a release date less than or equal to today. /// /// An Enumerable list of objects public IEnumerable GetContentForRelease() { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); var query = Query().Where(x => x.Published == false && x.ReleaseDate <= DateTime.Now); return _documentRepository.Get(query); } } /// /// Gets a collection of an objects, which resides in the Recycle Bin /// /// An Enumerable list of objects public IEnumerable GetContentInRecycleBin() { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); var query = Query().Where(x => x.Path.StartsWith(Constants.System.RecycleBinContentPathPrefix)); return _documentRepository.Get(query); } } /// /// Checks whether an item has any children /// /// Id of the /// True if the content has any children otherwise False public bool HasChildren(int id) { return CountChildren(id) > 0; } /// /// Checks if the passed in can be published based on the anscestors publish state. /// /// to check if anscestors 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 var parent = GetById(content.ParentId); return parent == null || IsPathPublished(parent); } public bool IsPathPublished(IContent content) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.IsPathPublished(content); } } #endregion #region Save, Publish, Unpublish // fixme - kill all those raiseEvents /// public OperationResult Save(IContent content, int userId = 0, bool raiseEvents = true) { var publishedState = content.PublishedState; if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished) throw new InvalidOperationException("Cannot save (un)publishing content, use the dedicated SavePublished method."); var evtMsgs = EventMessagesFactory.Get(); using (var scope = ScopeProvider.CreateScope()) { var saveEventArgs = new SaveEventArgs(content, evtMsgs); if (raiseEvents && scope.Events.DispatchCancelable(Saving, this, saveEventArgs, "Saving")) { scope.Complete(); return OperationResult.Cancel(evtMsgs); } scope.WriteLock(Constants.Locks.ContentTree); if (content.HasIdentity == false) content.CreatorId = userId; content.WriterId = userId; _documentRepository.Save(content); if (raiseEvents) { saveEventArgs.CanCancel = false; scope.Events.Dispatch(Saved, this, saveEventArgs, "Saved"); } var changeType = TreeChangeTypes.RefreshNode; scope.Events.Dispatch(TreeChanged, this, new TreeChange(content, changeType).ToEventArgs()); Audit(AuditType.Save, "Saved by user", userId, content.Id); scope.Complete(); } return OperationResult.Succeed(evtMsgs); } /// public OperationResult Save(IEnumerable contents, int userId = 0, bool raiseEvents = true) { var evtMsgs = EventMessagesFactory.Get(); var contentsA = contents.ToArray(); using (var scope = ScopeProvider.CreateScope()) { var saveEventArgs = new SaveEventArgs(contentsA, evtMsgs); if (raiseEvents && scope.Events.DispatchCancelable(Saving, this, saveEventArgs, "Saving")) { scope.Complete(); return OperationResult.Cancel(evtMsgs); } var treeChanges = contentsA.Select(x => new TreeChange(x, TreeChangeTypes.RefreshNode)); scope.WriteLock(Constants.Locks.ContentTree); foreach (var content in contentsA) { if (content.HasIdentity == false) content.CreatorId = userId; content.WriterId = userId; _documentRepository.Save(content); } if (raiseEvents) { saveEventArgs.CanCancel = false; scope.Events.Dispatch(Saved, this, saveEventArgs, "Saved"); } scope.Events.Dispatch(TreeChanged, this, treeChanges.ToEventArgs()); Audit(AuditType.Save, "Bulk-saved by user", userId == -1 ? 0 : userId, Constants.System.Root); scope.Complete(); } return OperationResult.Succeed(evtMsgs); } /// public PublishResult SaveAndPublish(IContent content, string culture = "*", int userId = 0, bool raiseEvents = true) { var evtMsgs = EventMessagesFactory.Get(); var publishedState = content.PublishedState; if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished) throw new InvalidOperationException($"Cannot save-and-publish (un)publishing content, use the dedicated {nameof(SavePublishing)} 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.IsNullOrWhiteSpace()) throw new NotSupportedException("Invariant culture is not supported by variant content types."); } else { if (!culture.IsNullOrWhiteSpace() && culture != "*") throw new NotSupportedException($"Culture \"{culture}\" is not supported by invariant content types."); } // if culture is specific, first publish the invariant values, then publish the culture itself. // if culture is '*', then publish them all (including variants) // explicitely SaveAndPublish a specific culture also publishes invariant values if (!culture.IsNullOrWhiteSpace() && culture != "*") { // publish the invariant values var publishInvariant = content.PublishCulture(null); if (!publishInvariant) return new PublishResult(PublishResultType.FailedContentInvalid, evtMsgs, content); } // publish the culture(s) var publishCulture = content.PublishCulture(culture); if (!publishCulture) return new PublishResult(PublishResultType.FailedContentInvalid, evtMsgs, content); // finally, "save publishing" // what happens next depends on whether the content can be published or not return SavePublishing(content, userId, raiseEvents); } /// public UnpublishResult Unpublish(IContent content, string culture = "*", int userId = 0) { var evtMsgs = EventMessagesFactory.Get(); culture = culture.NullOrWhiteSpaceAsNull(); var publishedState = content.PublishedState; if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished) throw new InvalidOperationException($"Cannot save-and-publish (un)publishing content, use the dedicated {nameof(SavePublishing)} 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 UnpublishResult(UnpublishResultType.SuccessAlready, evtMsgs, content); // all cultures = unpublish whole if (culture == "*" || (!content.ContentType.VariesByCulture() && culture == null)) { ((Content) content).PublishedState = PublishedState.Unpublishing; } else { // if the culture we want to unpublish was already unpublished, nothing to do if (!content.WasCulturePublished(culture)) return new UnpublishResult(UnpublishResultType.SuccessAlready, evtMsgs, content); // unpublish the culture content.UnpublishCulture(culture); } // finally, "save publishing" // what happens next depends on whether the content can be published or not using (var scope = ScopeProvider.CreateScope()) { var saved = SavePublishing(content, userId); if (saved.Success) { UnpublishResultType result; if (culture == "*" || culture == null) { Audit(AuditType.Unpublish, "Unpublished by user", userId, content.Id); result = UnpublishResultType.Success; } else { Audit(AuditType.Unpublish, $"Culture \"{culture}\" unpublished by user", userId, content.Id); if (!content.Published) Audit(AuditType.Unpublish, $"Unpublished (culture \"{culture}\" is mandatory) by user", userId, content.Id); result = content.Published ? UnpublishResultType.SuccessCulture : UnpublishResultType.SuccessMandatoryCulture; } scope.Complete(); return new UnpublishResult(result, evtMsgs, content); } // failed - map result var r = saved.Result == PublishResultType.FailedCancelledByEvent ? UnpublishResultType.FailedCancelledByEvent : UnpublishResultType.Failed; return new UnpublishResult(r, evtMsgs, content); } } /// public PublishResult SavePublishing(IContent content, int userId = 0, bool raiseEvents = true) { var evtMsgs = EventMessagesFactory.Get(); PublishResult publishResult = null; UnpublishResult unpublishResult = null; // nothing set = republish it all if (content.PublishedState != PublishedState.Publishing && content.PublishedState != PublishedState.Unpublishing) ((Content) content).PublishedState = PublishedState.Publishing; // state here is either Publishing or Unpublishing var publishing = content.PublishedState == PublishedState.Publishing; var unpublishing = content.PublishedState == PublishedState.Unpublishing; using (var scope = ScopeProvider.CreateScope()) { // is the content going to end up published, or unpublished? if (publishing && content.ContentType.VariesByCulture()) { var publishedCultures = content.PublishedCultures.ToList(); var cannotBePublished = publishedCultures.Count == 0; // no published cultures = cannot be published if (!cannotBePublished) { var mandatoryCultures = _languageRepository.GetMany().Where(x => x.IsMandatory).Select(x => x.IsoCode); cannotBePublished = mandatoryCultures.Any(x => !publishedCultures.Contains(x, StringComparer.OrdinalIgnoreCase)); // missing mandatory culture = cannot be published } if (cannotBePublished) { 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 anways } } var isNew = !content.HasIdentity; var changeType = isNew ? TreeChangeTypes.RefreshNode : TreeChangeTypes.RefreshBranch; var previouslyPublished = content.HasIdentity && content.Published; scope.WriteLock(Constants.Locks.ContentTree); // always save var saveEventArgs = new SaveEventArgs(content, evtMsgs); if (raiseEvents && scope.Events.DispatchCancelable(Saving, this, saveEventArgs, "Saving")) { scope.Complete(); return new PublishResult(PublishResultType.FailedCancelledByEvent, evtMsgs, content); } if (publishing) { // ensure that the document can be published, and publish // handling events, business rules, etc // note: StrategyPublish flips the PublishedState to Publishing! publishResult = StrategyCanPublish(scope, content, userId, /*checkPath:*/ true, evtMsgs); if (publishResult.Success) publishResult = StrategyPublish(scope, content, /*canPublish:*/ true, userId, evtMsgs); if (!publishResult.Success) ((Content) content).Published = content.Published; // reset published state = save unchanged } if (unpublishing) { var newest = GetById(content.Id); // ensure we have the newest version - in scope if (content.VersionId != newest.VersionId) // but use the original object if it's already the newest version content = newest; if (content.Published) { // ensure that the document can be unpublished, and unpublish // handling events, business rules, etc // note: StrategyUnpublish flips the PublishedState to Unpublishing! unpublishResult = StrategyCanUnpublish(scope, content, userId, evtMsgs); if (unpublishResult.Success) unpublishResult = StrategyUnpublish(scope, content, true, userId, evtMsgs); if (!unpublishResult.Success) ((Content) content).Published = content.Published; // reset published state = save unchanged } 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."); } } // save, always if (content.HasIdentity == false) content.CreatorId = userId; content.WriterId = userId; // saving does NOT change the published version, unless PublishedState is Publishing or Unpublishing _documentRepository.Save(content); // raise the Saved event, always if (raiseEvents) { saveEventArgs.CanCancel = false; scope.Events.Dispatch(Saved, this, saveEventArgs, "Saved"); } if (unpublishing) // we have tried to unpublish { if (unpublishResult.Success) // and succeeded, trigger events { // events and audit scope.Events.Dispatch(Unpublished, this, new PublishEventArgs(content, false, false), "Unpublished"); scope.Events.Dispatch(TreeChanged, this, new TreeChange(content, TreeChangeTypes.RefreshBranch).ToEventArgs()); Audit(AuditType.Unpublish, "Unpublished by user", userId, content.Id); scope.Complete(); return new PublishResult(PublishResultType.Success, evtMsgs, content); } // or, failed scope.Events.Dispatch(TreeChanged, this, new TreeChange(content, changeType).ToEventArgs()); scope.Complete(); // compete the save return new PublishResult(PublishResultType.FailedToUnpublish, evtMsgs, content); // bah } if (publishing) // we have tried to publish { if (publishResult.Success) // and succeeded, trigger events { if (isNew == false && previouslyPublished == false) changeType = TreeChangeTypes.RefreshBranch; // whole branch // invalidate the node/branch scope.Events.Dispatch(TreeChanged, this, new TreeChange(content, changeType).ToEventArgs()); scope.Events.Dispatch(Published, this, new PublishEventArgs(content, false, false), "Published"); // if was not published and now is... descendants that were 'published' (but // had an unpublished ancestor) are 're-published' ie not explicitely published // but back as 'published' nevertheless if (isNew == false && previouslyPublished == false && HasChildren(content.Id)) { var descendants = GetPublishedDescendantsLocked(content).ToArray(); scope.Events.Dispatch(Published, this, new PublishEventArgs(descendants, false, false), "Published"); } Audit(AuditType.Publish, "Published by user", userId, content.Id); scope.Complete(); return publishResult; } // or, failed scope.Events.Dispatch(TreeChanged, this, new TreeChange(content, changeType).ToEventArgs()); scope.Complete(); // compete the save return publishResult; } // both publishing and unpublishing are false // this means that we wanted to publish, in a variant scenario, a document that // was not published yet, and we could not, due to cultures issues // // raise event (we saved), report scope.Events.Dispatch(TreeChanged, this, new TreeChange(content, changeType).ToEventArgs()); scope.Complete(); // compete the save return new PublishResult(PublishResultType.FailedByCulture, evtMsgs, content); } } /// public IEnumerable PerformScheduledPublish() { using (var scope = ScopeProvider.CreateScope()) { scope.WriteLock(Constants.Locks.ContentTree); foreach (var d in GetContentForRelease()) { PublishResult result; try { d.ReleaseDate = null; d.PublishCulture(); // fixme variants? result = SaveAndPublish(d, userId: d.WriterId); if (result.Success == false) Logger.Error(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, result.Result); } catch (Exception e) { Logger.Error(e, "Failed to publish document id={DocumentId}, an exception was thrown.", d.Id); throw; } yield return result; } foreach (var d in GetContentForExpiration()) { try { d.ExpireDate = null; var result = Unpublish(d, userId: d.WriterId); if (result.Success == false) Logger.Error(null, "Failed to unpublish document id={DocumentId}, reason={Reason}.", d.Id, result.Result); } catch (Exception e) { Logger.Error(e, "Failed to unpublish document id={DocumentId}, an exception was thrown.", d.Id); throw; } } scope.Complete(); } } /// public IEnumerable SaveAndPublishBranch(IContent content, bool force, string culture = "*", int userId = 0) { // 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 bool IsEditing(IContent c, string l) => c.PublishName != c.Name || c.PublishedCultures.Any(x => c.GetCultureName(x) != c.GetPublishName(x)) || c.Properties.Any(x => x.Values.Where(y => culture == "*" || y.Culture == l).Any(y => !y.EditedValue.Equals(y.PublishedValue))); return SaveAndPublishBranch(content, force, document => IsEditing(document, culture), document => document.PublishCulture(culture), userId); } /// public IEnumerable SaveAndPublishBranch(IContent document, bool force, Func editing, Func publishCultures, int userId = 0) { var evtMsgs = EventMessagesFactory.Get(); var results = new List(); var publishedDocuments = new List(); using (var scope = ScopeProvider.CreateScope()) { scope.WriteLock(Constants.Locks.ContentTree); // fixme events?! if (!document.HasIdentity) throw new InvalidOperationException("Do not branch-publish a new document."); var publishedState = ((Content) document).PublishedState; if (publishedState == PublishedState.Publishing) throw new InvalidOperationException("Do not publish values when publishing branches."); // deal with the branch root - if it fails, abort var result = SaveAndPublishBranchOne(scope, document, editing, publishCultures, true, publishedDocuments, evtMsgs, userId); results.Add(result); if (!result.Success) return results; // deal with descendants // if one fails, abort its branch var exclude = new HashSet(); foreach (var d in GetDescendants(document)) { // if parent is excluded, exclude document and ignore // if not forcing, and not publishing, exclude document and ignore if (exclude.Contains(d.ParentId) || !force && !d.Published) { exclude.Add(d.Id); continue; } // no need to check path here, // 1. because we know the parent is path-published (we just published it) // 2. because it would not work as nothing's been written out to the db until the uow completes result = SaveAndPublishBranchOne(scope, d, editing, publishCultures, false, publishedDocuments, evtMsgs, userId); results.Add(result); if (result.Success) continue; // abort branch exclude.Add(d.Id); } scope.Events.Dispatch(TreeChanged, this, new TreeChange(document, TreeChangeTypes.RefreshBranch).ToEventArgs()); scope.Events.Dispatch(Published, this, new PublishEventArgs(publishedDocuments, false, false), "Published"); Audit(AuditType.Publish, "Branch published by user", userId, document.Id); scope.Complete(); } return results; } private PublishResult SaveAndPublishBranchOne(IScope scope, IContent document, Func editing, Func publishValues, bool checkPath, List publishedDocuments, EventMessages evtMsgs, int userId) { // if already published, and values haven't changed - i.e. not changing anything // nothing to do - fixme - unless we *want* to bump dates? if (document.Published && (editing == null || !editing(document))) return new PublishResult(PublishResultType.SuccessAlready, evtMsgs, document); // publish & check if values are valid if (publishValues != null && !publishValues(document)) return new PublishResult(PublishResultType.FailedContentInvalid, evtMsgs, document); // check if we can publish var result = StrategyCanPublish(scope, document, userId, checkPath, evtMsgs); if (!result.Success) return result; // publish - should be successful var publishResult = StrategyPublish(scope, document, /*canPublish:*/ true, userId, evtMsgs); if (!publishResult.Success) throw new Exception("oops: failed to publish."); // save document.WriterId = userId; _documentRepository.Save(document); publishedDocuments.Add(document); return publishResult; } #endregion #region Delete /// public OperationResult Delete(IContent content, int userId) { var evtMsgs = EventMessagesFactory.Get(); using (var scope = ScopeProvider.CreateScope()) { var deleteEventArgs = new DeleteEventArgs(content, evtMsgs); if (scope.Events.DispatchCancelable(Deleting, this, deleteEventArgs, nameof(Deleting))) { scope.Complete(); return OperationResult.Cancel(evtMsgs); } scope.WriteLock(Constants.Locks.ContentTree); // if it's not trashed yet, and published, we should unpublish // but... Unpublishing event makes no sense (not going to cancel?) and no need to save // just raise the event if (content.Trashed == false && content.Published) scope.Events.Dispatch(Unpublished, this, new PublishEventArgs(content, false, false), nameof(Unpublished)); DeleteLocked(scope, content); scope.Events.Dispatch(TreeChanged, this, new TreeChange(content, TreeChangeTypes.Remove).ToEventArgs()); Audit(AuditType.Delete, "Deleted by user", userId, content.Id); scope.Complete(); } return OperationResult.Succeed(evtMsgs); } private void DeleteLocked(IScope scope, IContent content) { // then recursively delete descendants, bottom-up // just repository.Delete + an event var stack = new Stack(); stack.Push(content); var level = 1; while (stack.Count > 0) { var c = stack.Peek(); IContent[] cc; if (c.Level == level) while ((cc = c.Children(this).ToArray()).Length > 0) { foreach (var ci in cc) stack.Push(ci); c = cc[cc.Length - 1]; } c = stack.Pop(); level = c.Level; _documentRepository.Delete(c); var args = new DeleteEventArgs(c, false); // raise event & get flagged files scope.Events.Dispatch(Deleted, this, args, nameof(Deleted)); // fixme not going to work, do it differently _mediaFileSystem.DeleteFiles(args.MediaFilesToDelete, // remove flagged files (file, e) => Logger.Error(e, "An error occurred while deleting file attached to nodes: {File}", file)); } } //TODO: // both DeleteVersions methods below have an issue. Sort of. They do NOT take care of files the way // Delete does - for a good reason: the file may be referenced by other, non-deleted, versions. BUT, // if that's not the case, then the file will never be deleted, because when we delete the content, // the version referencing the file will not be there anymore. SO, we can leak files. /// /// Permanently deletes versions from an object prior to a specific date. /// This method will never delete the latest version of a content item. /// /// Id of the object to delete versions from /// Latest version date /// Optional Id of the User deleting versions of a Content object public void DeleteVersions(int id, DateTime versionDate, int userId = 0) { using (var scope = ScopeProvider.CreateScope()) { var deleteRevisionsEventArgs = new DeleteRevisionsEventArgs(id, dateToRetain: versionDate); if (scope.Events.DispatchCancelable(DeletingVersions, this, deleteRevisionsEventArgs)) { scope.Complete(); return; } scope.WriteLock(Constants.Locks.ContentTree); _documentRepository.DeleteVersions(id, versionDate); deleteRevisionsEventArgs.CanCancel = false; scope.Events.Dispatch(DeletedVersions, this, deleteRevisionsEventArgs); Audit(AuditType.Delete, "Delete (by version date) by user", userId, Constants.System.Root); scope.Complete(); } } /// /// Permanently deletes specific version(s) from an object. /// This method will never delete the latest version of a content item. /// /// Id of the object to delete a version from /// Id of the version to delete /// Boolean indicating whether to delete versions prior to the versionId /// Optional Id of the User deleting versions of a Content object public void DeleteVersion(int id, int versionId, bool deletePriorVersions, int userId = 0) { using (var scope = ScopeProvider.CreateScope()) { if (scope.Events.DispatchCancelable(DeletingVersions, this, new DeleteRevisionsEventArgs(id, /*specificVersion:*/ versionId))) { scope.Complete(); return; } if (deletePriorVersions) { var content = GetVersion(versionId); // fixme nesting uow? DeleteVersions(id, content.UpdateDate, userId); } scope.WriteLock(Constants.Locks.ContentTree); var c = _documentRepository.Get(id); if (c.VersionId != versionId) // don't delete the current version _documentRepository.DeleteVersion(versionId); scope.Events.Dispatch(DeletedVersions, this, new DeleteRevisionsEventArgs(id, false,/* specificVersion:*/ versionId)); Audit(AuditType.Delete, "Delete (by version) by user", userId, Constants.System.Root); scope.Complete(); } } #endregion #region Move, RecycleBin /// public OperationResult MoveToRecycleBin(IContent content, int userId) { var evtMsgs = EventMessagesFactory.Get(); var moves = new List>(); using (var scope = ScopeProvider.CreateScope()) { scope.WriteLock(Constants.Locks.ContentTree); var originalPath = content.Path; var moveEventInfo = new MoveEventInfo(content, originalPath, Constants.System.RecycleBinContent); var moveEventArgs = new MoveEventArgs(evtMsgs, moveEventInfo); if (scope.Events.DispatchCancelable(Trashing, this, moveEventArgs, nameof(Trashing))) { scope.Complete(); return OperationResult.Cancel(evtMsgs); // causes rollback } // if it's published we may want to force-unpublish it - that would be backward-compatible... but... // making a radical decision here: trashing is equivalent to moving under an unpublished node so // it's NOT unpublishing, only the content is now masked - allowing us to restore it if wanted //if (content.HasPublishedVersion) //{ } PerformMoveLocked(content, Constants.System.RecycleBinContent, null, userId, moves, true); scope.Events.Dispatch(TreeChanged, this, new TreeChange(content, TreeChangeTypes.RefreshBranch).ToEventArgs()); var moveInfo = moves .Select(x => new MoveEventInfo(x.Item1, x.Item2, x.Item1.ParentId)) .ToArray(); moveEventArgs.CanCancel = false; moveEventArgs.MoveInfoCollection = moveInfo; scope.Events.Dispatch(Trashed, this, moveEventArgs, nameof(Trashed)); Audit(AuditType.Move, "Moved to Recycle Bin by user", userId, content.Id); scope.Complete(); } return OperationResult.Succeed(evtMsgs); } /// /// Moves an object to a new location by changing its parent id. /// /// /// If the object is already published it will be /// published after being moved to its new location. Otherwise it'll just /// be saved with a new parent id. /// /// The to move /// Id of the Content's new Parent /// Optional Id of the User moving the Content public void Move(IContent content, int parentId, int userId = 0) { // if moving to the recycle bin then use the proper method if (parentId == Constants.System.RecycleBinContent) { MoveToRecycleBin(content, userId); return; } var moves = new List>(); using (var scope = ScopeProvider.CreateScope()) { scope.WriteLock(Constants.Locks.ContentTree); var parent = parentId == Constants.System.Root ? null : GetById(parentId); if (parentId != Constants.System.Root && (parent == null || parent.Trashed)) throw new InvalidOperationException("Parent does not exist or is trashed."); // causes rollback var moveEventInfo = new MoveEventInfo(content, content.Path, parentId); var moveEventArgs = new MoveEventArgs(moveEventInfo); if (scope.Events.DispatchCancelable(Moving, this, moveEventArgs, nameof(Moving))) { scope.Complete(); return; // causes rollback } // if content was trashed, and since we're not moving to the recycle bin, // indicate that the trashed status should be changed to false, else just // leave it unchanged var trashed = content.Trashed ? false : (bool?)null; // if the content was trashed under another content, and so has a published version, // it cannot move back as published but has to be unpublished first - that's for the // root content, everything underneath will retain its published status if (content.Trashed && content.Published) { // however, it had been masked when being trashed, so there's no need for // any special event here - just change its state ((Content) content).PublishedState = PublishedState.Unpublishing; } PerformMoveLocked(content, parentId, parent, userId, moves, trashed); scope.Events.Dispatch(TreeChanged, this, new TreeChange(content, TreeChangeTypes.RefreshBranch).ToEventArgs()); var moveInfo = moves //changes .Select(x => new MoveEventInfo(x.Item1, x.Item2, x.Item1.ParentId)) .ToArray(); moveEventArgs.MoveInfoCollection = moveInfo; moveEventArgs.CanCancel = false; scope.Events.Dispatch(Moved, this, moveEventArgs, nameof(Moved)); Audit(AuditType.Move, "Moved by user", userId, content.Id); scope.Complete(); } } // MUST be called from within WriteLock // trash indicates whether we are trashing, un-trashing, or not changing anything private void PerformMoveLocked(IContent content, int parentId, IContent parent, int userId, ICollection> moves, bool? trash) { content.WriterId = userId; content.ParentId = parentId; // get the level delta (old pos to new pos) var levelDelta = parent == null ? 1 - content.Level + (parentId == Constants.System.RecycleBinContent ? 1 : 0) : parent.Level + 1 - content.Level; var paths = new Dictionary(); moves.Add(Tuple.Create(content, content.Path)); // capture original path // get before moving, in case uow is immediate var descendants = GetDescendants(content); // these will be updated by the repo because we changed parentId //content.Path = (parent == null ? "-1" : parent.Path) + "," + content.Id; //content.SortOrder = ((ContentRepository) repository).NextChildSortOrder(parentId); //content.Level += levelDelta; PerformMoveContentLocked(content, userId, trash); // if uow is not immediate, content.Path will be updated only when the UOW commits, // and because we want it now, we have to calculate it by ourselves //paths[content.Id] = content.Path; paths[content.Id] = (parent == null ? (parentId == Constants.System.RecycleBinContent ? "-1,-20" : "-1") : parent.Path) + "," + content.Id; foreach (var descendant in descendants) { moves.Add(Tuple.Create(descendant, descendant.Path)); // capture original path // update path and level since we do not update parentId if (paths.ContainsKey(descendant.ParentId) == false) Console.WriteLine("oops on " + descendant.ParentId + " for " + content.Path + " " + parent?.Path); descendant.Path = paths[descendant.Id] = paths[descendant.ParentId] + "," + descendant.Id; Console.WriteLine("path " + descendant.Id + " = " + paths[descendant.Id]); descendant.Level += levelDelta; PerformMoveContentLocked(descendant, userId, trash); } } private void PerformMoveContentLocked(IContent content, int userId, bool? trash) { if (trash.HasValue) ((ContentBase) content).Trashed = trash.Value; content.WriterId = userId; _documentRepository.Save(content); } /// /// Empties the Recycle Bin by deleting all that resides in the bin /// public OperationResult EmptyRecycleBin() { var nodeObjectType = Constants.ObjectTypes.Document; var deleted = new List(); var evtMsgs = EventMessagesFactory.Get(); using (var scope = ScopeProvider.CreateScope()) { scope.WriteLock(Constants.Locks.ContentTree); // v7 EmptyingRecycleBin and EmptiedRecycleBin events are greatly simplified since // each deleted items will have its own deleting/deleted events. so, files and such // are managed by Delete, and not here. // no idea what those events are for, keep a simplified version var recycleBinEventArgs = new RecycleBinEventArgs(nodeObjectType, evtMsgs); if (scope.Events.DispatchCancelable(EmptyingRecycleBin, this, recycleBinEventArgs)) { scope.Complete(); return OperationResult.Cancel(evtMsgs); } // emptying the recycle bin means deleting whetever is in there - do it properly! var query = Query().Where(x => x.ParentId == Constants.System.RecycleBinContent); var contents = _documentRepository.Get(query).ToArray(); foreach (var content in contents) { DeleteLocked(scope, content); deleted.Add(content); } recycleBinEventArgs.CanCancel = false; recycleBinEventArgs.RecycleBinEmptiedSuccessfully = true; // oh my?! scope.Events.Dispatch(EmptiedRecycleBin, this, recycleBinEventArgs); scope.Events.Dispatch(TreeChanged, this, deleted.Select(x => new TreeChange(x, TreeChangeTypes.Remove)).ToEventArgs()); Audit(AuditType.Delete, "Recycle Bin emptied by user", 0, Constants.System.RecycleBinContent); scope.Complete(); } return OperationResult.Succeed(evtMsgs); } #endregion #region Others /// /// Copies an object by creating a new Content object of the same type and copies all data from the current /// to the new copy which is returned. Recursively copies all children. /// /// The to copy /// Id of the Content's new Parent /// Boolean indicating whether the copy should be related to the original /// Optional Id of the User copying the Content /// The newly created object public IContent Copy(IContent content, int parentId, bool relateToOriginal, int userId = 0) { return Copy(content, parentId, relateToOriginal, true, userId); } /// /// Copies an object by creating a new Content object of the same type and copies all data from the current /// to the new copy which is returned. /// /// The to copy /// Id of the Content's new Parent /// Boolean indicating whether the copy should be related to the original /// A value indicating whether to recursively copy children. /// Optional Id of the User copying the Content /// The newly created object public IContent Copy(IContent content, int parentId, bool relateToOriginal, bool recursive, int userId = 0) { var copy = content.DeepCloneWithResetIdentities(); copy.ParentId = parentId; using (var scope = ScopeProvider.CreateScope()) { var copyEventArgs = new CopyEventArgs(content, copy, true, parentId, relateToOriginal); if (scope.Events.DispatchCancelable(Copying, this, copyEventArgs)) { scope.Complete(); return null; } // note - relateToOriginal is not managed here, // it's just part of the Copied event args so the RelateOnCopyHandler knows what to do // meaning that the event has to trigger for every copied content including descendants var copies = new List>(); scope.WriteLock(Constants.Locks.ContentTree); // a copy is not published (but not really unpublishing either) // update the create author and last edit author if (copy.Published) ((Content) copy).Published = false; copy.CreatorId = userId; copy.WriterId = userId; //get the current permissions, if there are any explicit ones they need to be copied var currentPermissions = GetPermissions(content); currentPermissions.RemoveWhere(p => p.IsDefaultPermissions); // save and flush because we need the ID for the recursive Copying events _documentRepository.Save(copy); //add permissions if (currentPermissions.Count > 0) { var permissionSet = new ContentPermissionSet(copy, currentPermissions); _documentRepository.AddOrUpdatePermissions(permissionSet); } // keep track of copies copies.Add(Tuple.Create(content, copy)); var idmap = new Dictionary { [content.Id] = copy.Id }; if (recursive) // process descendants { foreach (var descendant in GetDescendants(content)) { // if parent has not been copied, skip, else gets its copy id if (idmap.TryGetValue(descendant.ParentId, out parentId) == false) continue; var descendantCopy = descendant.DeepCloneWithResetIdentities(); descendantCopy.ParentId = parentId; if (scope.Events.DispatchCancelable(Copying, this, new CopyEventArgs(descendant, descendantCopy, parentId))) continue; // a copy is not published (but not really unpublishing either) // update the create author and last edit author if (descendantCopy.Published) ((Content) descendantCopy).Published = false; descendantCopy.CreatorId = userId; descendantCopy.WriterId = userId; // save and flush (see above) _documentRepository.Save(descendantCopy); copies.Add(Tuple.Create(descendant, descendantCopy)); idmap[descendant.Id] = descendantCopy.Id; } } // not handling tags here, because // - tags should be handled by the content repository // - a copy is unpublished and therefore has no impact on tags in DB scope.Events.Dispatch(TreeChanged, this, new TreeChange(copy, TreeChangeTypes.RefreshBranch).ToEventArgs()); foreach (var x in copies) scope.Events.Dispatch(Copied, this, new CopyEventArgs(x.Item1, x.Item2, false, x.Item2.ParentId, relateToOriginal)); Audit(AuditType.Copy, "Copy Content performed by user", userId, content.Id); scope.Complete(); } return copy; } /// /// 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 issueing the send to publication /// True if sending publication was succesfull otherwise false public bool SendToPublication(IContent content, int userId = 0) { using (var scope = ScopeProvider.CreateScope()) { var sendToPublishEventArgs = new SendToPublishEventArgs(content); if (scope.Events.DispatchCancelable(SendingToPublish, this, sendToPublishEventArgs)) { scope.Complete(); return false; } //Save before raising event // fixme - nesting uow? Save(content, userId); sendToPublishEventArgs.CanCancel = false; scope.Events.Dispatch(SentToPublish, this, sendToPublishEventArgs); Audit(AuditType.SendToPublish, "Send to Publish performed by user", content.WriterId, content.Id); } return true; } /// /// Sorts a collection of objects by updating the SortOrder according /// to the ordering of items in the passed in . /// /// /// Using this method will ensure that the Published-state is maintained upon sorting /// so the cache is updated accordingly - as needed. /// /// /// /// /// True if sorting succeeded, otherwise False public bool Sort(IEnumerable items, int userId = 0, bool raiseEvents = true) { var itemsA = items.ToArray(); if (itemsA.Length == 0) return true; using (var scope = ScopeProvider.CreateScope()) { scope.WriteLock(Constants.Locks.ContentTree); var ret = Sort(scope, itemsA, userId, raiseEvents); scope.Complete(); return ret; } } /// /// Sorts a collection of objects by updating the SortOrder according /// to the ordering of items identified by the . /// /// /// Using this method will ensure that the Published-state is maintained upon sorting /// so the cache is updated accordingly - as needed. /// /// /// /// /// True if sorting succeeded, otherwise False public bool Sort(IEnumerable ids, int userId = 0, bool raiseEvents = true) { var idsA = ids.ToArray(); if (idsA.Length == 0) return true; using (var scope = ScopeProvider.CreateScope()) { scope.WriteLock(Constants.Locks.ContentTree); var itemsA = GetByIds(idsA).ToArray(); var ret = Sort(scope, itemsA, userId, raiseEvents); scope.Complete(); return ret; } } private bool Sort(IScope scope, IContent[] itemsA, int userId, bool raiseEvents) { var saveEventArgs = new SaveEventArgs(itemsA); if (raiseEvents && scope.Events.DispatchCancelable(Saving, this, saveEventArgs, "Saving")) return false; var published = new List(); var saved = new List(); var sortOrder = 0; foreach (var content in itemsA) { // if the current sort order equals that of the content we don't // need to update it, so just increment the sort order and continue. if (content.SortOrder == sortOrder) { sortOrder++; continue; } // else update content.SortOrder = sortOrder++; content.WriterId = userId; // if it's published, register it, no point running StrategyPublish // since we're not really publishing it and it cannot be cancelled etc if (content.Published) published.Add(content); // save saved.Add(content); _documentRepository.Save(content); } if (raiseEvents) { saveEventArgs.CanCancel = false; scope.Events.Dispatch(Saved, this, saveEventArgs, "Saved"); } scope.Events.Dispatch(TreeChanged, this, saved.Select(x => new TreeChange(x, TreeChangeTypes.RefreshNode)).ToEventArgs()); if (raiseEvents && published.Any()) scope.Events.Dispatch(Published, this, new PublishEventArgs(published, false, false), "Published"); Audit(AuditType.Sort, "Sorting content performed by user", userId, 0); return true; } #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 (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); return GetPublishedDescendantsLocked(content).ToArray(); // ToArray important in uow! } } internal IEnumerable GetPublishedDescendantsLocked(IContent content) { var pathMatch = content.Path + ","; var query = Query().Where(x => x.Id != content.Id && x.Path.StartsWith(pathMatch) /*&& x.Trashed == false*/); var 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 }; foreach (var c in contents) { if (parents.Contains(c.ParentId)) { yield return c; parents.Add(c.Id); } } } #endregion #region Private Methods private void Audit(AuditType type, string message, int userId, int objectId) { _auditRepository.Save(new AuditItem(objectId, message, type, userId)); } #endregion #region Event Handlers /// /// Occurs before Delete /// public static event TypedEventHandler> Deleting; /// /// Occurs after Delete /// public static event TypedEventHandler> Deleted; /// /// Occurs before Delete Versions /// public static event TypedEventHandler DeletingVersions; /// /// Occurs after Delete Versions /// public static event TypedEventHandler DeletedVersions; /// /// Occurs before Save /// public static event TypedEventHandler> Saving; /// /// Occurs after Save /// public static event TypedEventHandler> Saved; /// /// Occurs after Create /// /// /// Please note that the Content object has been created, but might not have been saved /// so it does not have an identity yet (meaning no Id has been set). /// public static event TypedEventHandler> Created; /// /// Occurs before Copy /// public static event TypedEventHandler> Copying; /// /// Occurs after Copy /// public static event TypedEventHandler> Copied; /// /// Occurs before Content is moved to Recycle Bin /// public static event TypedEventHandler> Trashing; /// /// Occurs after Content is moved to Recycle Bin /// public static event TypedEventHandler> Trashed; /// /// Occurs before Move /// public static event TypedEventHandler> Moving; /// /// Occurs after Move /// public static event TypedEventHandler> Moved; /// /// Occurs before Rollback /// public static event TypedEventHandler> RollingBack; /// /// Occurs after Rollback /// public static event TypedEventHandler> RolledBack; /// /// Occurs before Send to Publish /// public static event TypedEventHandler> SendingToPublish; /// /// Occurs after Send to Publish /// public static event TypedEventHandler> SentToPublish; /// /// Occurs before the Recycle Bin is emptied /// public static event TypedEventHandler EmptyingRecycleBin; /// /// Occurs after the Recycle Bin has been Emptied /// public static event TypedEventHandler EmptiedRecycleBin; /// /// Occurs before publish /// public static event TypedEventHandler> Publishing; /// /// Occurs after publish /// public static event TypedEventHandler> Published; /// /// Occurs before unpublish /// public static event TypedEventHandler> Unpublishing; /// /// Occurs after unpublish /// public static event TypedEventHandler> Unpublished; /// /// Occurs after change. /// internal static event TypedEventHandler.EventArgs> TreeChanged; /// /// Occurs after a blueprint has been saved. /// public static event TypedEventHandler> SavedBlueprint; /// /// Occurs after a blueprint has been deleted. /// public static event TypedEventHandler> DeletedBlueprint; #endregion #region Publishing Strategies // ensures that a document can be published internal PublishResult StrategyCanPublish(IScope scope, IContent content, int userId, bool checkPath, EventMessages evtMsgs) { // raise Publishing event if (scope.Events.DispatchCancelable(Publishing, this, new PublishEventArgs(content, evtMsgs))) { Logger.Info("Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, content.Id, "publishing was cancelled"); return new PublishResult(PublishResultType.FailedCancelledByEvent, evtMsgs, content); } // ensure that the document has published values // either because it is 'publishing' or because it already has a published version if (((Content) content).PublishedState != PublishedState.Publishing && content.PublishedVersionId == 0) { Logger.Info("Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, content.Id, "document does not have published values"); return new PublishResult(PublishResultType.FailedNoPublishedValues, evtMsgs, content); } // ensure that the document status is correct switch (content.Status) { case ContentStatus.Expired: Logger.Info("Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, content.Id, "document has expired"); return new PublishResult(PublishResultType.FailedHasExpired, evtMsgs, content); case ContentStatus.AwaitingRelease: Logger.Info("Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, content.Id, "document is awaiting release"); return new PublishResult(PublishResultType.FailedAwaitingRelease, evtMsgs, content); case ContentStatus.Trashed: Logger.Info("Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, content.Id, "document is trashed"); return new PublishResult(PublishResultType.FailedIsTrashed, evtMsgs, content); } if (!checkPath) return new PublishResult(evtMsgs, content); // 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 == false) { Logger.Info("Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, content.Id, "parent is not published"); return new PublishResult(PublishResultType.FailedPathNotPublished, evtMsgs, content); } return new PublishResult(evtMsgs, content); } // publishes a document internal PublishResult StrategyPublish(IScope scope, IContent content, bool canPublish, int userId, EventMessages evtMsgs) { // note: when used at top-level, StrategyCanPublish with checkPath=true should have run already // and alreadyCheckedCanPublish should be true, so not checking again. when used at nested level, // there is no need to check the path again. so, checkPath=false in StrategyCanPublish below var result = canPublish ? new PublishResult(evtMsgs, content) // already know we can : StrategyCanPublish(scope, content, userId, /*checkPath:*/ false, evtMsgs); // else check if (result.Success == false) return result; // change state to publishing ((Content) content).PublishedState = PublishedState.Publishing; Logger.Info("Document {ContentName} (id={ContentId}) has been published.", content.Name, content.Id); return result; } // ensures that a document can be unpublished internal UnpublishResult StrategyCanUnpublish(IScope scope, IContent content, int userId, EventMessages evtMsgs) { // raise Unpublishing event if (scope.Events.DispatchCancelable(Unpublishing, this, new PublishEventArgs(content, evtMsgs))) { Logger.Info("Document {ContentName} (id={ContentId}) cannot be unpublished: unpublishing was cancelled.", content.Name, content.Id); return new UnpublishResult(UnpublishResultType.FailedCancelledByEvent, evtMsgs, content); } return new UnpublishResult(evtMsgs, content); } // unpublishes a document internal UnpublishResult StrategyUnpublish(IScope scope, IContent content, bool canUnpublish, int userId, EventMessages evtMsgs) { var attempt = canUnpublish ? new UnpublishResult(evtMsgs, content) // already know we can : StrategyCanUnpublish(scope, content, userId, evtMsgs); // else check if (attempt.Success == false) return attempt; // if the document has a release date set to before now, // it should be removed so it doesn't interrupt an unpublish // otherwise it would remain released == published if (content.ReleaseDate.HasValue && content.ReleaseDate.Value <= DateTime.Now) { content.ReleaseDate = null; Logger.Info("Document {ContentName} (id={ContentId}) had its release date removed, because it was unpublished.", content.Name, content.Id); } // change state to unpublishing ((Content) content).PublishedState = PublishedState.Unpublishing; Logger.Info("Document {ContentName} (id={ContentId}) has been unpublished.", content.Name, content.Id); return attempt; } #endregion #region Content Types /// /// Deletes all content of specified type. All children of deleted content is moved to Recycle Bin. /// /// /// This needs extra care and attention as its potentially a dangerous and extensive operation. /// Deletes content items of the specified type, and only that type. Does *not* handle content types /// inheritance and compositions, which need to be managed outside of this method. /// /// Id of the /// Optional Id of the user issueing the delete operation public void DeleteOfTypes(IEnumerable contentTypeIds, int userId = 0) { //TODO: This currently this is called from the ContentTypeService but that needs to change, // if we are deleting a content type, we should just delete the data and do this operation slightly differently. // This method will recursively go lookup every content item, check if any of it's descendants are // of a different type, move them to the recycle bin, then permanently delete the content items. // The main problem with this is that for every content item being deleted, events are raised... // which we need for many things like keeping caches in sync, but we can surely do this MUCH better. var changes = new List>(); var moves = new List>(); var contentTypeIdsA = contentTypeIds.ToArray(); // using an immediate uow here because we keep making changes with // PerformMoveLocked and DeleteLocked that must be applied immediately, // no point queuing operations // using (var scope = ScopeProvider.CreateScope()) { scope.WriteLock(Constants.Locks.ContentTree); var query = Query().WhereIn(x => x.ContentTypeId, contentTypeIdsA); var contents = _documentRepository.Get(query).ToArray(); if (scope.Events.DispatchCancelable(Deleting, this, new DeleteEventArgs(contents), nameof(Deleting))) { scope.Complete(); return; } // order by level, descending, so deepest first - that way, we cannot move // a content of the deleted type, to the recycle bin (and then delete it...) foreach (var content in contents.OrderByDescending(x => x.ParentId)) { // if it's not trashed yet, and published, we should unpublish // but... Unpublishing event makes no sense (not going to cancel?) and no need to save // just raise the event if (content.Trashed == false && content.Published) scope.Events.Dispatch(Unpublished, this, new PublishEventArgs(content, false, false), nameof(Unpublished)); // if current content has children, move them to trash var c = content; var childQuery = Query().Where(x => x.ParentId == c.Id); var children = _documentRepository.Get(childQuery); foreach (var child in children) { // see MoveToRecycleBin PerformMoveLocked(child, Constants.System.RecycleBinContent, null, userId, moves, true); changes.Add(new TreeChange(content, TreeChangeTypes.RefreshBranch)); } // delete content // triggers the deleted event (and handles the files) DeleteLocked(scope, content); changes.Add(new TreeChange(content, TreeChangeTypes.Remove)); } var moveInfos = moves .Select(x => new MoveEventInfo(x.Item1, x.Item2, x.Item1.ParentId)) .ToArray(); if (moveInfos.Length > 0) scope.Events.Dispatch(Trashed, this, new MoveEventArgs(false, moveInfos), nameof(Trashed)); scope.Events.Dispatch(TreeChanged, this, changes.ToEventArgs()); Audit(AuditType.Delete, $"Delete Content of Type {string.Join(",", contentTypeIdsA)} performed by user", userId, Constants.System.Root); scope.Complete(); } } /// /// Deletes all content items of specified type. All children of deleted content item is moved to Recycle Bin. /// /// This needs extra care and attention as its potentially a dangerous and extensive operation /// Id of the /// Optional id of the user deleting the media public void DeleteOfType(int contentTypeId, int userId = 0) { DeleteOfTypes(new[] { contentTypeId }, userId); } private IContentType GetContentType(IScope scope, string contentTypeAlias) { if (string.IsNullOrWhiteSpace(contentTypeAlias)) throw new ArgumentNullOrEmptyException(nameof(contentTypeAlias)); scope.ReadLock(Constants.Locks.ContentTypes); var query = Query().Where(x => x.Alias == contentTypeAlias); var contentType = _contentTypeRepository.Get(query).FirstOrDefault(); if (contentType == null) throw new Exception($"No ContentType matching the passed in Alias: '{contentTypeAlias}' was found"); // causes rollback return contentType; } private IContentType GetContentType(string contentTypeAlias) { if (string.IsNullOrWhiteSpace(contentTypeAlias)) throw new ArgumentNullOrEmptyException(nameof(contentTypeAlias)); using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { return GetContentType(scope, contentTypeAlias); } } #endregion #region Blueprints public IContent GetBlueprintById(int id) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); var blueprint = _documentBlueprintRepository.Get(id); if (blueprint != null) ((Content) blueprint).Blueprint = true; return blueprint; } } public IContent GetBlueprintById(Guid id) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); var blueprint = _documentBlueprintRepository.Get(id); if (blueprint != null) ((Content) blueprint).Blueprint = true; return blueprint; } } public void SaveBlueprint(IContent content, int userId = 0) { //always ensure the blueprint is at the root if (content.ParentId != -1) content.ParentId = -1; ((Content) content).Blueprint = true; using (var scope = ScopeProvider.CreateScope()) { scope.WriteLock(Constants.Locks.ContentTree); if (string.IsNullOrWhiteSpace(content.Name)) { throw new ArgumentException("Cannot save content blueprint with empty name."); } if (content.HasIdentity == false) { content.CreatorId = userId; } content.WriterId = userId; _documentBlueprintRepository.Save(content); scope.Events.Dispatch(SavedBlueprint, this, new SaveEventArgs(content), "SavedBlueprint"); scope.Complete(); } } public void DeleteBlueprint(IContent content, int userId = 0) { using (var scope = ScopeProvider.CreateScope()) { scope.WriteLock(Constants.Locks.ContentTree); _documentBlueprintRepository.Delete(content); scope.Events.Dispatch(DeletedBlueprint, this, new DeleteEventArgs(content), nameof(DeletedBlueprint)); scope.Complete(); } } public IContent CreateContentFromBlueprint(IContent blueprint, string name, int userId = 0) { if (blueprint == null) throw new ArgumentNullException(nameof(blueprint)); var contentType = blueprint.ContentType; var content = new Content(name, -1, contentType); content.Path = string.Concat(content.ParentId.ToString(), ",", content.Id); content.CreatorId = userId; content.WriterId = userId; foreach (var property in blueprint.Properties) content.SetValue(property.Alias, property.GetValue()); //fixme doesn't take into account variants return content; } public IEnumerable GetBlueprintsForContentTypes(params int[] contentTypeId) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { var query = Query(); if (contentTypeId.Length > 0) { query.Where(x => contentTypeId.Contains(x.ContentTypeId)); } return _documentBlueprintRepository.Get(query).Select(x => { ((Content) x).Blueprint = true; return x; }); } } public void DeleteBlueprintsOfTypes(IEnumerable contentTypeIds, int userId = 0) { using (var scope = ScopeProvider.CreateScope()) { scope.WriteLock(Constants.Locks.ContentTree); var contentTypeIdsA = contentTypeIds.ToArray(); var query = Query(); if (contentTypeIdsA.Length > 0) query.Where(x => contentTypeIdsA.Contains(x.ContentTypeId)); var blueprints = _documentBlueprintRepository.Get(query).Select(x => { ((Content) x).Blueprint = true; return x; }).ToArray(); foreach (var blueprint in blueprints) { _documentBlueprintRepository.Delete(blueprint); } scope.Events.Dispatch(DeletedBlueprint, this, new DeleteEventArgs(blueprints), nameof(DeletedBlueprint)); scope.Complete(); } } public void DeleteBlueprintsOfType(int contentTypeId, int userId = 0) { DeleteBlueprintsOfTypes(new[] { contentTypeId }, userId); } #endregion #region Rollback public OperationResult Rollback(int id, int versionId, string culture = "*", int userId = 0) { var evtMsgs = EventMessagesFactory.Get(); //Get the current copy of the node var content = GetById(id); //Get the version var version = GetVersion(versionId); //Good ole null checks if (content == null || version == null) { return new OperationResult(OperationResultType.FailedCannot, evtMsgs); } //Copy the changes from the version content.CopyFrom(version, culture); //Save the content for the rollback var rollbackSaveResult = Save(content, userId); //Depending on the save result - is what we log & audit along with what we return if(rollbackSaveResult.Success == false) { //Log the error/warning Logger.Error("User '{UserId}' was unable to rollback content '{ContentId}' to version '{VersionId}'", userId, id, versionId); } else { //Logging & Audit message Logger.Error("User '{UserId}' rolled back content '{ContentId}' to version '{VersionId}'", userId, id, versionId); Audit(AuditType.RollBack, $"Content '{content.Name}' was rolled back to version '{versionId}'", userId, id); } return rollbackSaveResult; } #endregion } }