diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs index 10f193131320..6cad2b13bfcd 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs @@ -30,6 +30,7 @@ protected IActionResult DocumentEditingOperationStatusResult( where TContentModelBase : ContentModelBase => ContentEditingOperationStatusResult(status, requestModel, validationResult); + // TODO ELEMENTS: move this to ContentControllerBase protected IActionResult DocumentPublishingOperationStatusResult( ContentPublishingOperationStatus status, IEnumerable? invalidPropertyAliases = null, diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/ByKeyElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/ByKeyElementController.cs new file mode 100644 index 000000000000..50f22fca2515 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/ByKeyElementController.cs @@ -0,0 +1,46 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Element; + +[ApiVersion("1.0")] +public class ByKeyElementController : ElementControllerBase +{ + private readonly IUmbracoMapper _umbracoMapper; + private readonly IElementService _elementService; + + public ByKeyElementController(IUmbracoMapper umbracoMapper, IElementService elementService) + { + _umbracoMapper = umbracoMapper; + _elementService = elementService; + } + + [HttpGet("{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(DocumentResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public Task ByKey(CancellationToken cancellationToken, Guid id) + { + // TODO ELEMENTS: move logic to a presentation factory + IElement? element = _elementService.GetById(id); + if (element is null) + { + return Task.FromResult(ContentEditingOperationStatusResult(ContentEditingOperationStatus.NotFound)); + } + + ContentScheduleCollection contentScheduleCollection = _elementService.GetContentScheduleByContentId(id); + + var model = new ElementResponseModel(); + _umbracoMapper.Map(element, model); + _umbracoMapper.Map(contentScheduleCollection, model); + + return Task.FromResult(Ok(model)); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/CreateElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/CreateElementController.cs new file mode 100644 index 000000000000..364c6519b69c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/CreateElementController.cs @@ -0,0 +1,46 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Element; + +[ApiVersion("1.0")] +public class CreateElementController : ElementControllerBase +{ + private readonly IElementEditingPresentationFactory _elementEditingPresentationFactory; + private readonly IElementEditingService _elementEditingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public CreateElementController( + IElementEditingPresentationFactory elementEditingPresentationFactory, + IElementEditingService elementEditingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _elementEditingPresentationFactory = elementEditingPresentationFactory; + _elementEditingService = elementEditingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpPost] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Create(CancellationToken cancellationToken, CreateElementRequestModel requestModel) + { + ElementCreateModel model = _elementEditingPresentationFactory.MapCreateModel(requestModel); + Attempt result = + await _elementEditingService.CreateAsync(model, CurrentUserKey(_backOfficeSecurityAccessor)); + + return result.Success + ? CreatedAtId(controller => nameof(controller.ByKey), result.Result.Content!.Key) + : ContentEditingOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/DeleteElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/DeleteElementController.cs new file mode 100644 index 000000000000..0eb097dd6865 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/DeleteElementController.cs @@ -0,0 +1,39 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Element; + +[ApiVersion("1.0")] +public class DeleteElementController : ElementControllerBase +{ + private readonly IElementEditingService _elementEditingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public DeleteElementController( + IElementEditingService elementEditingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _elementEditingService = elementEditingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpDelete("{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Delete(CancellationToken cancellationToken, Guid id) + { + Attempt result = await _elementEditingService.DeleteAsync(id, CurrentUserKey(_backOfficeSecurityAccessor)); + + return result.Success + ? Ok() + : ContentEditingOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/ElementControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/ElementControllerBase.cs new file mode 100644 index 000000000000..1ca18f073baf --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/ElementControllerBase.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Controllers.Content; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.Controllers.Element; + +[VersionedApiBackOfficeRoute(Constants.UdiEntityType.Element)] +[ApiExplorerSettings(GroupName = nameof(Constants.UdiEntityType.Element))] +// TODO ELEMENTS: backoffice authorization policies +[Authorize(Policy = AuthorizationPolicies.TreeAccessDocuments)] +public class ElementControllerBase : ContentControllerBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/ByKeyElementFolderController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/ByKeyElementFolderController.cs new file mode 100644 index 000000000000..b4e00354961c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/ByKeyElementFolderController.cs @@ -0,0 +1,25 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Folder; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Folder; + +[ApiVersion("1.0")] +public class ByKeyElementFolderController : ElementFolderControllerBase +{ + public ByKeyElementFolderController( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IElementContainerService elementContainerService) + : base(backOfficeSecurityAccessor, elementContainerService) + { + } + + [HttpGet("{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(FolderResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task ByKey(CancellationToken cancellationToken, Guid id) => await GetFolderAsync(id); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/CreateElementFolderController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/CreateElementFolderController.cs new file mode 100644 index 000000000000..ddd0545e9d6e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/CreateElementFolderController.cs @@ -0,0 +1,29 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Folder; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Folder; + +[ApiVersion("1.0")] +public class CreateElementFolderController : ElementFolderControllerBase +{ + public CreateElementFolderController( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IElementContainerService elementContainerService) + : base(backOfficeSecurityAccessor, elementContainerService) + { + } + + [HttpPost] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Create(CancellationToken cancellationToken, CreateFolderRequestModel createFolderRequestModel) + => await CreateFolderAsync( + createFolderRequestModel, + controller => nameof(controller.ByKey)); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/DeleteElementFolderController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/DeleteElementFolderController.cs new file mode 100644 index 000000000000..ac89673e59c6 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/DeleteElementFolderController.cs @@ -0,0 +1,25 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Folder; + +[ApiVersion("1.0")] +public class DeleteElementFolderController : ElementFolderControllerBase +{ + public DeleteElementFolderController( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IElementContainerService elementContainerService) + : base(backOfficeSecurityAccessor, elementContainerService) + { + } + + [HttpDelete("{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Delete(CancellationToken cancellationToken, Guid id) => await DeleteFolderAsync(id); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/ElementFolderControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/ElementFolderControllerBase.cs new file mode 100644 index 000000000000..4051924c6d29 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/ElementFolderControllerBase.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Folder; + +[VersionedApiBackOfficeRoute($"{Constants.UdiEntityType.Element}/folder")] +[ApiExplorerSettings(GroupName = nameof(Constants.UdiEntityType.Element))] +// TODO ELEMENTS: backoffice authorization policies +public abstract class ElementFolderControllerBase : FolderManagementControllerBase +{ + protected ElementFolderControllerBase( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IElementContainerService elementContainerService) + : base(backOfficeSecurityAccessor, elementContainerService) + { + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/UpdateElementFolderController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/UpdateElementFolderController.cs new file mode 100644 index 000000000000..d65921c104a6 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Folder/UpdateElementFolderController.cs @@ -0,0 +1,27 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Folder; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Folder; + +[ApiVersion("1.0")] +public class UpdateElementFolderController : ElementFolderControllerBase +{ + public UpdateElementFolderController( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IElementContainerService elementContainerService) + : base(backOfficeSecurityAccessor, elementContainerService) + { + } + + [HttpPut("{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Update(CancellationToken cancellationToken, Guid id, UpdateFolderResponseModel updateFolderResponseModel) + => await UpdateFolderAsync(id, updateFolderResponseModel); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/PublishElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/PublishElementController.cs new file mode 100644 index 000000000000..bb6449459f7c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/PublishElementController.cs @@ -0,0 +1,58 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Element; + +[ApiVersion("1.0")] +public class PublishElementController : ElementControllerBase +{ + private readonly IElementPublishingService _elementPublishingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IDocumentPresentationFactory _documentPresentationFactory; + + public PublishElementController( + IElementPublishingService elementPublishingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IDocumentPresentationFactory documentPresentationFactory) + { + _elementPublishingService = elementPublishingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _documentPresentationFactory = documentPresentationFactory; + } + + [HttpPut("{id:guid}/publish")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Publish(CancellationToken cancellationToken, Guid id, PublishElementRequestModel requestModel) + { + // TODO ELEMENTS: IDocumentPresentationFactory carries the implementation of this mapping - it should probably be renamed + var tempModel = new PublishDocumentRequestModel { PublishSchedules = requestModel.PublishSchedules }; + Attempt, ContentPublishingOperationStatus> modelResult = _documentPresentationFactory.CreateCulturePublishScheduleModels(tempModel); + + if (modelResult.Success is false) + { + // TODO ELEMENTS: use refactored DocumentPublishingOperationStatusResult from DocumentControllerBase once it's ready + return BadRequest(); + } + + Attempt attempt = await _elementPublishingService.PublishAsync( + id, + modelResult.Result, + CurrentUserKey(_backOfficeSecurityAccessor)); + return attempt.Success + ? Ok() + // TODO ELEMENTS: use refactored DocumentPublishingOperationStatusResult from DocumentControllerBase once it's ready + : BadRequest(); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/AncestorsElementTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/AncestorsElementTreeController.cs new file mode 100644 index 000000000000..bf73496d4c4c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/AncestorsElementTreeController.cs @@ -0,0 +1,23 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Tree; + +[ApiVersion("1.0")] +public class AncestorsElementTreeController : ElementTreeControllerBase +{ + public AncestorsElementTreeController(IEntityService entityService, IUmbracoMapper umbracoMapper) + : base(entityService, umbracoMapper) + { + } + + [HttpGet("ancestors")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task>> Ancestors(CancellationToken cancellationToken, Guid descendantId) + => await GetAncestors(descendantId); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/ChildrenElementTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/ChildrenElementTreeController.cs new file mode 100644 index 000000000000..500a865ade1b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/ChildrenElementTreeController.cs @@ -0,0 +1,27 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Tree; + +[ApiVersion("1.0")] +public class ChildrenElementTreeController : ElementTreeControllerBase +{ + public ChildrenElementTreeController(IEntityService entityService, IUmbracoMapper umbracoMapper) + : base(entityService, umbracoMapper) + { + } + + [HttpGet("children")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + public async Task>> Children(CancellationToken cancellationToken, Guid parentId, int skip = 0, int take = 100, bool foldersOnly = false) + { + RenderFoldersOnly(foldersOnly); + return await GetChildren(parentId, skip, take); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/ElementTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/ElementTreeControllerBase.cs new file mode 100644 index 000000000000..d9e7a5380bc9 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/ElementTreeControllerBase.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Controllers.Tree; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Api.Management.ViewModels.DocumentType; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Tree; + +[VersionedApiBackOfficeRoute($"{Constants.Web.RoutePath.Tree}/{Constants.UdiEntityType.Element}")] +[ApiExplorerSettings(GroupName = nameof(Constants.UdiEntityType.Element))] +// TODO ELEMENTS: backoffice authorization policies +public class ElementTreeControllerBase : FolderTreeControllerBase +{ + private readonly IUmbracoMapper _umbracoMapper; + + public ElementTreeControllerBase(IEntityService entityService, IUmbracoMapper umbracoMapper) + : base(entityService) + => _umbracoMapper = umbracoMapper; + + protected override UmbracoObjectTypes ItemObjectType => UmbracoObjectTypes.Element; + + protected override UmbracoObjectTypes FolderObjectType => UmbracoObjectTypes.ElementContainer; + + protected override Ordering ItemOrdering + { + get + { + var ordering = Ordering.By(nameof(Infrastructure.Persistence.Dtos.NodeDto.NodeObjectType), Direction.Descending); // We need to override to change direction + ordering.Next = Ordering.By(nameof(Infrastructure.Persistence.Dtos.NodeDto.Text)); + + return ordering; + } + } + + protected override ElementTreeItemResponseModel[] MapTreeItemViewModels(Guid? parentKey, IEntitySlim[] entities) + => entities.Select(entity => + { + ElementTreeItemResponseModel responseModel = MapTreeItemViewModel(parentKey, entity); + if (entity is IContentEntitySlim contentEntitySlim) + { + responseModel.HasChildren = entity.HasChildren; + responseModel.ElementType = _umbracoMapper.Map(contentEntitySlim)!; + } + + return responseModel; + }).ToArray(); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/RootElementTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/RootElementTreeController.cs new file mode 100644 index 000000000000..bb2ddc430f8b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/RootElementTreeController.cs @@ -0,0 +1,31 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Tree; + +[ApiVersion("1.0")] +public class RootElementTreeController : ElementTreeControllerBase +{ + public RootElementTreeController(IEntityService entityService, IUmbracoMapper umbracoMapper) + : base(entityService, umbracoMapper) + { + } + + [HttpGet("root")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + public async Task>> Root( + CancellationToken cancellationToken, + int skip = 0, + int take = 100, + bool foldersOnly = false) + { + RenderFoldersOnly(foldersOnly); + return await GetRoot(skip, take); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/SiblingsElementTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/SiblingsElementTreeController.cs new file mode 100644 index 000000000000..4f7e0f9a828d --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/Tree/SiblingsElementTreeController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Element.Tree; + +public class SiblingsElementTreeController : ElementTreeControllerBase +{ + public SiblingsElementTreeController(IEntityService entityService, IUmbracoMapper umbracoMapper) + : base(entityService, umbracoMapper) + { + } + + [HttpGet("siblings")] + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public async Task>> Siblings( + CancellationToken cancellationToken, + Guid target, + int before, + int after, + bool foldersOnly = false) + { + RenderFoldersOnly(foldersOnly); + return await GetSiblings(target, before, after); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/UnpublishElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/UnpublishElementController.cs new file mode 100644 index 000000000000..5104186f4cea --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/UnpublishElementController.cs @@ -0,0 +1,46 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Element; + +[ApiVersion("1.0")] +public class UnpublishElementController : ElementControllerBase +{ + private readonly IElementPublishingService _elementPublishingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IDocumentPresentationFactory _documentPresentationFactory; + + public UnpublishElementController( + IElementPublishingService elementPublishingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IDocumentPresentationFactory documentPresentationFactory) + { + _elementPublishingService = elementPublishingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _documentPresentationFactory = documentPresentationFactory; + } + + [HttpPut("{id:guid}/unpublish")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Unpublish(CancellationToken cancellationToken, Guid id, UnpublishElementRequestModel requestModel) + { + Attempt attempt = await _elementPublishingService.UnpublishAsync( + id, + requestModel.Cultures, + CurrentUserKey(_backOfficeSecurityAccessor)); + return attempt.Success + ? Ok() + // TODO ELEMENTS: use refactored DocumentPublishingOperationStatusResult from DocumentControllerBase once it's ready + : BadRequest(); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/UpdateElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/UpdateElementController.cs new file mode 100644 index 000000000000..42248d214c4c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/UpdateElementController.cs @@ -0,0 +1,46 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Element; + +[ApiVersion("1.0")] +public class UpdateElementController : ElementControllerBase +{ + private readonly IElementEditingPresentationFactory _elementEditingPresentationFactory; + private readonly IElementEditingService _elementEditingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public UpdateElementController( + IElementEditingPresentationFactory elementEditingPresentationFactory, + IElementEditingService elementEditingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _elementEditingPresentationFactory = elementEditingPresentationFactory; + _elementEditingService = elementEditingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpPut("{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Update(CancellationToken cancellationToken, Guid id, UpdateElementRequestModel requestModel) + { + ElementUpdateModel model = _elementEditingPresentationFactory.MapUpdateModel(requestModel); + Attempt result = + await _elementEditingService.UpdateAsync(id, model, CurrentUserKey(_backOfficeSecurityAccessor)); + + return result.Success + ? Ok() + : ContentEditingOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/ValidateCreateElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/ValidateCreateElementController.cs new file mode 100644 index 000000000000..eb384b0e8061 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/ValidateCreateElementController.cs @@ -0,0 +1,46 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Element; + +[ApiVersion("1.0")] +public class ValidateCreateElementController : ElementControllerBase +{ + private readonly IElementEditingPresentationFactory _elementEditingPresentationFactory; + private readonly IElementEditingService _elementEditingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public ValidateCreateElementController( + IElementEditingPresentationFactory elementEditingPresentationFactory, + IElementEditingService elementEditingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _elementEditingPresentationFactory = elementEditingPresentationFactory; + _elementEditingService = elementEditingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpPost("validate")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Validate(CancellationToken cancellationToken, CreateElementRequestModel requestModel) + { + ElementCreateModel model = _elementEditingPresentationFactory.MapCreateModel(requestModel); + Attempt result = + await _elementEditingService.ValidateCreateAsync(model, CurrentUserKey(_backOfficeSecurityAccessor)); + + return result.Success + ? Ok() + : ContentEditingOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Element/ValidateUpdateElementController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Element/ValidateUpdateElementController.cs new file mode 100644 index 000000000000..814c668d59f9 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Element/ValidateUpdateElementController.cs @@ -0,0 +1,46 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Element; + +[ApiVersion("1.0")] +public class ValidateUpdateElementController : ElementControllerBase +{ + private readonly IElementEditingPresentationFactory _elementEditingPresentationFactory; + private readonly IElementEditingService _elementEditingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public ValidateUpdateElementController( + IElementEditingPresentationFactory elementEditingPresentationFactory, + IElementEditingService elementEditingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _elementEditingPresentationFactory = elementEditingPresentationFactory; + _elementEditingService = elementEditingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpPut("{id:guid}/validate")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Validate(CancellationToken cancellationToken, Guid id, ValidateUpdateElementRequestModel requestModel) + { + ValidateElementUpdateModel model = _elementEditingPresentationFactory.MapValidateUpdateModel(requestModel); + Attempt result = + await _elementEditingService.ValidateUpdateAsync(id, model, CurrentUserKey(_backOfficeSecurityAccessor)); + + return result.Success + ? Ok() + : ContentEditingOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/ElementBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/ElementBuilderExtensions.cs new file mode 100644 index 000000000000..f711be00ff2d --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/ElementBuilderExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.Mapping.Element; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Mapping; + +namespace Umbraco.Cms.Api.Management.DependencyInjection; + +internal static class ElementBuilderExtensions +{ + internal static IUmbracoBuilder AddElements(this IUmbracoBuilder builder) + { + builder.Services.AddTransient(); + + builder.WithCollectionBuilder() + .Add(); + + return builder; + } +} diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs index 48e53c97e0d3..a853a2145677 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs @@ -36,6 +36,7 @@ public static IUmbracoBuilder AddUmbracoManagementApi(this IUmbracoBuilder build .AddConfigurationFactories() .AddDocuments() .AddDocumentTypes() + .AddElements() .AddMedia() .AddMediaTypes() .AddMemberGroups() diff --git a/src/Umbraco.Cms.Api.Management/Factories/ElementEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/ElementEditingPresentationFactory.cs new file mode 100644 index 000000000000..55ab29d5c515 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/ElementEditingPresentationFactory.cs @@ -0,0 +1,28 @@ +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Api.Management.Factories; + +internal sealed class ElementEditingPresentationFactory : ContentEditingPresentationFactory, IElementEditingPresentationFactory +{ + public ElementCreateModel MapCreateModel(CreateElementRequestModel requestModel) + { + ElementCreateModel model = MapContentEditingModel(requestModel); + model.Key = requestModel.Id; + model.ContentTypeKey = requestModel.ElementType.Id; + model.ParentKey = requestModel.Parent?.Id; + + return model; + } + + public ElementUpdateModel MapUpdateModel(UpdateElementRequestModel requestModel) + => MapContentEditingModel(requestModel); + + public ValidateElementUpdateModel MapValidateUpdateModel(ValidateUpdateElementRequestModel requestModel) + { + ValidateElementUpdateModel model = MapContentEditingModel(requestModel); + model.Cultures = requestModel.Cultures; + + return model; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/IElementEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IElementEditingPresentationFactory.cs new file mode 100644 index 000000000000..61463d619908 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/IElementEditingPresentationFactory.cs @@ -0,0 +1,13 @@ +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Api.Management.Factories; + +public interface IElementEditingPresentationFactory +{ + ElementCreateModel MapCreateModel(CreateElementRequestModel requestModel); + + ElementUpdateModel MapUpdateModel(UpdateElementRequestModel requestModel); + + ValidateElementUpdateModel MapValidateUpdateModel(ValidateUpdateElementRequestModel requestModel); +} diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Content/ContentMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Content/ContentMapDefinition.cs index 3fd3fe6a7c29..ac4d1ef9fc31 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Content/ContentMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Content/ContentMapDefinition.cs @@ -1,4 +1,6 @@ using Umbraco.Cms.Api.Management.ViewModels.Content; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.PropertyEditors; @@ -77,4 +79,33 @@ protected IEnumerable MapVariantViewModels(TContent source, V })) .ToArray(); } + + protected void MapContentScheduleCollection(ContentScheduleCollection source, TContentResponseModel target, MapperContext context) + where TContentResponseModel : ContentResponseModelBase + where TPublishableVariantResponseModelBase : PublishableVariantResponseModelBase, TVariantViewModel + { + foreach (ContentSchedule schedule in source.FullSchedule) + { + TPublishableVariantResponseModelBase? variant = target.Variants + .FirstOrDefault(v => + v.Culture == schedule.Culture || + (IsInvariant(v.Culture) && IsInvariant(schedule.Culture))); + if (variant is null) + { + continue; + } + + switch (schedule.Action) + { + case ContentScheduleAction.Release: + variant.ScheduledPublishDate = new DateTimeOffset(schedule.Date, TimeSpan.Zero); + break; + case ContentScheduleAction.Expire: + variant.ScheduledUnpublishDate = new DateTimeOffset(schedule.Date, TimeSpan.Zero); + break; + } + } + } + + private static bool IsInvariant(string? culture) => culture.IsNullOrWhiteSpace() || culture == Core.Constants.System.InvariantCulture; } diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Content/DocumentVariantStateHelper.cs b/src/Umbraco.Cms.Api.Management/Mapping/Content/DocumentVariantStateHelper.cs index 6d7539b82584..316faa0d8ebd 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Content/DocumentVariantStateHelper.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Content/DocumentVariantStateHelper.cs @@ -4,9 +4,10 @@ namespace Umbraco.Cms.Api.Management.Mapping.Content; +// TODO ELEMENTS: rename this to VariantStateHelper or ContentVariantStateHelper (depending on the new name for DocumentVariantState) internal static class DocumentVariantStateHelper { - internal static DocumentVariantState GetState(IContent content, string? culture) + internal static DocumentVariantState GetState(IPublishableContentBase content, string? culture) => GetState( content, culture, diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs index 15eb6bafd8ec..5b6d1dacac8d 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs @@ -116,29 +116,5 @@ private void Map(IContent source, DocumentBlueprintResponseModel target, MapperC } private void Map(ContentScheduleCollection source, DocumentResponseModel target, MapperContext context) - { - foreach (ContentSchedule schedule in source.FullSchedule) - { - DocumentVariantResponseModel? variant = target.Variants - .FirstOrDefault(v => - v.Culture == schedule.Culture || - (IsInvariant(v.Culture) && IsInvariant(schedule.Culture))); - if (variant is null) - { - continue; - } - - switch (schedule.Action) - { - case ContentScheduleAction.Release: - variant.ScheduledPublishDate = new DateTimeOffset(schedule.Date, TimeSpan.Zero); - break; - case ContentScheduleAction.Expire: - variant.ScheduledUnpublishDate = new DateTimeOffset(schedule.Date, TimeSpan.Zero); - break; - } - } - } - - private static bool IsInvariant(string? culture) => culture.IsNullOrWhiteSpace() || culture == Core.Constants.System.InvariantCulture; + => MapContentScheduleCollection(source, target, context); } diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Element/ElementMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Element/ElementMapDefinition.cs new file mode 100644 index 000000000000..4d3d853c36de --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Mapping/Element/ElementMapDefinition.cs @@ -0,0 +1,43 @@ +using Umbraco.Cms.Api.Management.Mapping.Content; +using Umbraco.Cms.Api.Management.ViewModels.DocumentType; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; + +namespace Umbraco.Cms.Api.Management.Mapping.Element; + +public class ElementMapDefinition : ContentMapDefinition, IMapDefinition +{ + public ElementMapDefinition(PropertyEditorCollection propertyEditorCollection) + : base(propertyEditorCollection) + { + } + + public void DefineMaps(IUmbracoMapper mapper) + { + mapper.Define((_, _) => new ElementResponseModel(), Map); + mapper.Define(Map); + } + + // Umbraco.Code.MapAll + private void Map(IElement source, ElementResponseModel target, MapperContext context) + { + target.Id = source.Key; + target.ElementType = context.Map(source.ContentType)!; + target.Values = MapValueViewModels(source.Properties); + target.Variants = MapVariantViewModels( + source, + (culture, _, documentVariantViewModel) => + { + documentVariantViewModel.State = DocumentVariantStateHelper.GetState(source, culture); + documentVariantViewModel.PublishDate = culture == null + ? source.PublishDate + : source.GetPublishDate(culture); + }); + target.IsTrashed = source.Trashed; + } + + private void Map(ContentScheduleCollection source, ElementResponseModel target, MapperContext context) + => MapContentScheduleCollection(source, target, context); +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Content/PublishableVariantResponseModelBase.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Content/PublishableVariantResponseModelBase.cs new file mode 100644 index 000000000000..18c7f95a195b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Content/PublishableVariantResponseModelBase.cs @@ -0,0 +1,14 @@ +using Umbraco.Cms.Api.Management.ViewModels.Document; + +namespace Umbraco.Cms.Api.Management.ViewModels.Content; + +public abstract class PublishableVariantResponseModelBase : VariantResponseModelBase +{ + public DocumentVariantState State { get; set; } + + public DateTimeOffset? PublishDate { get; set; } + + public DateTimeOffset? ScheduledPublishDate { get; set; } + + public DateTimeOffset? ScheduledUnpublishDate { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantResponseModel.cs index b6990c1b3c71..27b9dc92c826 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantResponseModel.cs @@ -2,13 +2,6 @@ namespace Umbraco.Cms.Api.Management.ViewModels.Document; -public class DocumentVariantResponseModel : VariantResponseModelBase +public class DocumentVariantResponseModel : PublishableVariantResponseModelBase { - public DocumentVariantState State { get; set; } - - public DateTimeOffset? PublishDate { get; set; } - - public DateTimeOffset? ScheduledPublishDate { get; set; } - - public DateTimeOffset? ScheduledUnpublishDate { get; set; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantState.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantState.cs index 3ed51114e100..03b1eecea00d 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantState.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantState.cs @@ -3,6 +3,7 @@ /// /// The saved state of a content item /// +// TODO ELEMENTS: move this to ViewModels.Content and rename it to VariantState or ContentVariantState (shared between document and element variants) public enum DocumentVariantState { /// diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PublishDocumentRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PublishDocumentRequestModel.cs index 64cac6202e17..6016bf588aa2 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PublishDocumentRequestModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PublishDocumentRequestModel.cs @@ -5,7 +5,7 @@ public class PublishDocumentRequestModel public required IEnumerable PublishSchedules { get; set; } } - +// TODO ELEMENTS: move the following classes to ViewModels.Content public class CultureAndScheduleRequestModel { /// @@ -19,7 +19,6 @@ public class CultureAndScheduleRequestModel public ScheduleRequestModel? Schedule { get; set; } } - public class ScheduleRequestModel { public DateTimeOffset? PublishTime { get; set; } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/CreateElementRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/CreateElementRequestModel.cs new file mode 100644 index 000000000000..03ca583f6d60 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/CreateElementRequestModel.cs @@ -0,0 +1,8 @@ +using Umbraco.Cms.Api.Management.ViewModels.Content; + +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class CreateElementRequestModel : CreateContentWithParentRequestModelBase +{ + public required ReferenceByIdModel ElementType { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementResponseModel.cs new file mode 100644 index 000000000000..92059ecdc4fc --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementResponseModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class ElementResponseModel : ElementResponseModelBase +{ + public bool IsTrashed { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementResponseModelBase.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementResponseModelBase.cs new file mode 100644 index 000000000000..6702b85c8a6c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementResponseModelBase.cs @@ -0,0 +1,13 @@ +using Umbraco.Cms.Api.Management.ViewModels.Content; +using Umbraco.Cms.Api.Management.ViewModels.DocumentType; +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public abstract class ElementResponseModelBase + : ContentResponseModelBase + where TValueResponseModelBase : ValueModelBase + where TVariantResponseModel : VariantResponseModelBase +{ + public DocumentTypeReferenceResponseModel ElementType { get; set; } = new(); +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementValueModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementValueModel.cs new file mode 100644 index 000000000000..1222c4a96592 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementValueModel.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class ElementValueModel : ValueModelBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementValueResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementValueResponseModel.cs new file mode 100644 index 000000000000..f4b688332a4e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementValueResponseModel.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class ElementValueResponseModel : ValueResponseModelBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVariantRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVariantRequestModel.cs new file mode 100644 index 000000000000..69b9e252bf29 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVariantRequestModel.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class ElementVariantRequestModel : VariantModelBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVariantResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVariantResponseModel.cs new file mode 100644 index 000000000000..af1586761141 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVariantResponseModel.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Api.Management.ViewModels.Content; + +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class ElementVariantResponseModel : PublishableVariantResponseModelBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/PublishElementRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/PublishElementRequestModel.cs new file mode 100644 index 000000000000..7758cb5578ff --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/PublishElementRequestModel.cs @@ -0,0 +1,8 @@ +using Umbraco.Cms.Api.Management.ViewModels.Document; + +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class PublishElementRequestModel +{ + public required IEnumerable PublishSchedules { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/UnpublishElementRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/UnpublishElementRequestModel.cs new file mode 100644 index 000000000000..7951d410d734 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/UnpublishElementRequestModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class UnpublishElementRequestModel +{ + public ISet? Cultures { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/UpdateElementRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/UpdateElementRequestModel.cs new file mode 100644 index 000000000000..570fdbb0ee0c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/UpdateElementRequestModel.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Api.Management.ViewModels.Content; + +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class UpdateElementRequestModel : UpdateContentRequestModelBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/ValidateUpdateElementRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ValidateUpdateElementRequestModel.cs new file mode 100644 index 000000000000..52a4cdcf4537 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ValidateUpdateElementRequestModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class ValidateUpdateElementRequestModel : UpdateElementRequestModel +{ + public ISet? Cultures { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Tree/ElementTreeItemResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Tree/ElementTreeItemResponseModel.cs new file mode 100644 index 000000000000..727b062f9506 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Tree/ElementTreeItemResponseModel.cs @@ -0,0 +1,8 @@ +using Umbraco.Cms.Api.Management.ViewModels.DocumentType; + +namespace Umbraco.Cms.Api.Management.ViewModels.Tree; + +public class ElementTreeItemResponseModel : FolderTreeItemResponseModel +{ + public DocumentTypeReferenceResponseModel? ElementType { get; set; } +} diff --git a/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs b/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs index 3786951370e4..577f922bd05e 100644 --- a/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs +++ b/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs @@ -227,6 +227,18 @@ public static void RefreshMediaCache(this DistributedCache dc, IEnumerable dc.RefreshByPayload(ElementCacheRefresher.UniqueId, new ElementCacheRefresher.JsonPayload(0, Guid.Empty, TreeChangeTypes.RefreshAll).Yield()); + + + public static void RefreshElementCache(this DistributedCache dc, IEnumerable> changes) + => dc.RefreshByPayload(ElementCacheRefresher.UniqueId, changes.DistinctBy(x => (x.Item.Id, x.Item.Key, x.ChangeTypes)).Select(x => new ElementCacheRefresher.JsonPayload(x.Item.Id, x.Item.Key, x.ChangeTypes))); + + #endregion + #region Published Snapshot public static void RefreshAllPublishedSnapshot(this DistributedCache dc) @@ -234,6 +246,7 @@ public static void RefreshAllPublishedSnapshot(this DistributedCache dc) // note: refresh all content & media caches does refresh content types too dc.RefreshAllContentCache(); dc.RefreshAllMediaCache(); + dc.RefreshAllElementCache(); dc.RefreshAllDomainCache(); } diff --git a/src/Umbraco.Core/Cache/NotificationHandlers/Implement/ElementTreeChangeDistributedCacheNotificationHandler.cs b/src/Umbraco.Core/Cache/NotificationHandlers/Implement/ElementTreeChangeDistributedCacheNotificationHandler.cs new file mode 100644 index 000000000000..ea769fecb10c --- /dev/null +++ b/src/Umbraco.Core/Cache/NotificationHandlers/Implement/ElementTreeChangeDistributedCacheNotificationHandler.cs @@ -0,0 +1,28 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services.Changes; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Cache; + +/// +public class ElementTreeChangeDistributedCacheNotificationHandler : TreeChangeDistributedCacheNotificationHandlerBase +{ + private readonly DistributedCache _distributedCache; + + /// + /// Initializes a new instance of the class. + /// + /// The distributed cache. + public ElementTreeChangeDistributedCacheNotificationHandler(DistributedCache distributedCache) + => _distributedCache = distributedCache; + + /// + [Obsolete("Scheduled for removal in Umbraco 18.")] + protected override void Handle(IEnumerable> entities) + => Handle(entities, new Dictionary()); + + /// + protected override void Handle(IEnumerable> entities, IDictionary state) + => _distributedCache.RefreshElementCache(entities); +} diff --git a/src/Umbraco.Core/Cache/Refreshers/Implement/ElementCacheRefresher.cs b/src/Umbraco.Core/Cache/Refreshers/Implement/ElementCacheRefresher.cs new file mode 100644 index 000000000000..80fe63d9a007 --- /dev/null +++ b/src/Umbraco.Core/Cache/Refreshers/Implement/ElementCacheRefresher.cs @@ -0,0 +1,137 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.Changes; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Cache; + +public sealed class ElementCacheRefresher : PayloadCacheRefresherBase +{ + private readonly IIdKeyMap _idKeyMap; + private readonly IElementCacheService _elementCacheService; + private readonly ICacheManager _cacheManager; + + public ElementCacheRefresher( + AppCaches appCaches, + IJsonSerializer serializer, + IIdKeyMap idKeyMap, + IEventAggregator eventAggregator, + ICacheRefresherNotificationFactory factory, + IElementCacheService elementCacheService, + ICacheManager cacheManager) + : base(appCaches, serializer, eventAggregator, factory) + { + _idKeyMap = idKeyMap; + _elementCacheService = elementCacheService; + + // TODO: Use IElementsCache instead of ICacheManager, see ContentCacheRefresher for more information. + _cacheManager = cacheManager; + } + + #region Json + + public class JsonPayload + { + public JsonPayload(int id, Guid key, TreeChangeTypes changeTypes) + { + Id = id; + Key = key; + ChangeTypes = changeTypes; + } + + public int Id { get; } + + public Guid Key { get; } + + public TreeChangeTypes ChangeTypes { get; } + + // TODO ELEMENTS: should we support (un)published cultures in this payload? see ContentCacheRefresher.JsonPayload + } + + #endregion + + #region Define + + public static readonly Guid UniqueId = Guid.Parse("EE5BB23A-A656-4F7E-A234-16F21AAABFD1"); + + public override Guid RefresherUniqueId => UniqueId; + + public override string Name => "Element Cache Refresher"; + + #endregion + + #region Refresher + + public override void Refresh(JsonPayload[] payloads) + { + // TODO ELEMENTS: implement recycle bin + // AppCaches.RuntimeCache.ClearByKey(CacheKeys.ElementRecycleBinCacheKey); + + // Ideally, we'd like to not have to clear the entire cache here. However, this was the existing behavior in NuCache. + // The reason for this is that we have no way to know which elements are affected by the changes or what their keys are. + // This is because currently published elements live exclusively in a JSON blob in the umbracoPropertyData table. + // This means that the only way to resolve these keys is to actually parse this data with a specific value converter, and for all cultures, which is not possible. + // If published elements become their own entities with relations, instead of just property data, we can revisit this. + _cacheManager.ElementsCache.Clear(); + + IAppPolicyCache isolatedCache = AppCaches.IsolatedCaches.GetOrCreate(); + + foreach (JsonPayload payload in payloads) + { + // By INT Id + isolatedCache.Clear(RepositoryCacheKeys.GetKey(payload.Id)); + + // By GUID Key + isolatedCache.Clear(RepositoryCacheKeys.GetKey(payload.Key)); + + HandleMemoryCache(payload); + + // TODO ELEMENTS: if we need published status caching for elements (e.g. for seeding purposes), make sure + // it is kept in sync here (see ContentCacheRefresher) + + if (payload.ChangeTypes == TreeChangeTypes.Remove) + { + _idKeyMap.ClearCache(payload.Id); + } + } + + AppCaches.ClearPartialViewCache(); + + base.Refresh(payloads); + } + + private void HandleMemoryCache(JsonPayload payload) + { + if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshAll)) + { + _elementCacheService.ClearMemoryCacheAsync(CancellationToken.None).GetAwaiter().GetResult(); + } + else if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshNode) || payload.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch)) + { + // NOTE: RefreshBranch might be triggered even though elements do not support branch publishing + _elementCacheService.RefreshMemoryCacheAsync(payload.Key).GetAwaiter().GetResult(); + } + + if (payload.ChangeTypes.HasType(TreeChangeTypes.Remove)) + { + _elementCacheService.RemoveFromMemoryCacheAsync(payload.Key).GetAwaiter().GetResult(); + } + } + + // these events should never trigger + // everything should be JSON + public override void RefreshAll() => throw new NotSupportedException(); + + public override void Refresh(int id) => throw new NotSupportedException(); + + public override void Refresh(Guid id) => throw new NotSupportedException(); + + public override void Remove(int id) => throw new NotSupportedException(); + + #endregion +} diff --git a/src/Umbraco.Core/Constants-ObjectTypes.cs b/src/Umbraco.Core/Constants-ObjectTypes.cs index 5f57548ffd99..a338d4601b1d 100644 --- a/src/Umbraco.Core/Constants-ObjectTypes.cs +++ b/src/Umbraco.Core/Constants-ObjectTypes.cs @@ -29,6 +29,10 @@ public static class ObjectTypes public static readonly Guid DocumentType = new(Strings.DocumentType); + public static readonly Guid Element = new(Strings.Element); + + public static readonly Guid ElementContainer = new(Strings.ElementContainer); + public static readonly Guid Media = new(Strings.Media); public static readonly Guid MediaType = new(Strings.MediaType); @@ -91,6 +95,10 @@ public static class Strings public const string DocumentType = "A2CB7800-F571-4787-9638-BC48539A0EFB"; + public const string Element = "3D7B623C-94B1-487D-8554-A46EC37568BE"; + + public const string ElementContainer = "2815B0CF-9706-499F-AA2A-8A4C7AEF005D"; + public const string Media = "B796F64C-1F99-4FFB-B886-4BF4BC011A9C"; public const string MediaRecycleBin = "CF3D8E34-1C1C-41e9-AE56-878B57B32113"; diff --git a/src/Umbraco.Core/Constants-PropertyEditors.cs b/src/Umbraco.Core/Constants-PropertyEditors.cs index 0d0b5d97675a..a3c9986fc043 100644 --- a/src/Umbraco.Core/Constants-PropertyEditors.cs +++ b/src/Umbraco.Core/Constants-PropertyEditors.cs @@ -234,6 +234,11 @@ public static class Aliases /// Configuration-less time. /// public const string PlainTime = "Umbraco.Plain.Time"; + + /// + /// Element Picker. + /// + public const string ElementPicker = "Umbraco.ElementPicker"; } /// diff --git a/src/Umbraco.Core/Constants-UdiEntityType.cs b/src/Umbraco.Core/Constants-UdiEntityType.cs index 9c2a25b3148d..60b78c70177d 100644 --- a/src/Umbraco.Core/Constants-UdiEntityType.cs +++ b/src/Umbraco.Core/Constants-UdiEntityType.cs @@ -28,6 +28,7 @@ public static class UdiEntityType public const string DocumentType = "document-type"; public const string DocumentTypeContainer = "document-type-container"; public const string Element = "element"; + public const string ElementContainer = "element-container"; public const string Media = "media"; public const string MediaType = "media-type"; public const string MediaTypeContainer = "media-type-container"; diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index cb5020f5b9ee..8ddc9d53551f 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -297,10 +297,15 @@ private void AddCoreServices() Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); + Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); diff --git a/src/Umbraco.Core/Extensions/ContentExtensions.cs b/src/Umbraco.Core/Extensions/ContentExtensions.cs index 35b9d945d8cf..659bd964a131 100644 --- a/src/Umbraco.Core/Extensions/ContentExtensions.cs +++ b/src/Umbraco.Core/Extensions/ContentExtensions.cs @@ -228,7 +228,7 @@ public static XElement ToXml(this IContent content, IEntityXmlSerializer seriali /// /// Gets the current status of the Content /// - public static ContentStatus GetStatus(this IContent content, ContentScheduleCollection contentSchedule, string? culture = null) + public static ContentStatus GetStatus(this IPublishableContentBase content, ContentScheduleCollection contentSchedule, string? culture = null) { if (content.Trashed) { diff --git a/src/Umbraco.Core/Extensions/PublishedContentExtensions.cs b/src/Umbraco.Core/Extensions/PublishedContentExtensions.cs index f8ef83c35030..d8efe0fdfd0d 100644 --- a/src/Umbraco.Core/Extensions/PublishedContentExtensions.cs +++ b/src/Umbraco.Core/Extensions/PublishedContentExtensions.cs @@ -28,7 +28,7 @@ public static class PublishedContentExtensions /// The specific culture to get the name for. If null is used the current culture is used (Default is /// null). /// - public static string Name(this IPublishedContent content, IVariationContextAccessor? variationContextAccessor, string? culture = null) + public static string Name(this IPublishedElement content, IVariationContextAccessor? variationContextAccessor, string? culture = null) { if (content == null) { diff --git a/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs b/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs index a5adc0de2a26..fd0894762168 100644 --- a/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs +++ b/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs @@ -76,6 +76,10 @@ public static GuidUdi GetUdi(this EntityContainer entity) { entityType = Constants.UdiEntityType.DocumentBlueprintContainer; } + else if (entity.ContainedObjectType == Constants.ObjectTypes.Element) + { + entityType = Constants.UdiEntityType.ElementContainer; + } else { throw new NotSupportedException($"Contained object type {entity.ContainedObjectType} is not supported."); diff --git a/src/Umbraco.Core/Models/Content.cs b/src/Umbraco.Core/Models/Content.cs index 880c7b6bd00e..44494139f101 100644 --- a/src/Umbraco.Core/Models/Content.cs +++ b/src/Umbraco.Core/Models/Content.cs @@ -1,6 +1,4 @@ -using System.Collections.Specialized; using System.Runtime.Serialization; -using Umbraco.Extensions; namespace Umbraco.Cms.Core.Models; @@ -9,12 +7,8 @@ namespace Umbraco.Cms.Core.Models; /// [Serializable] [DataContract(IsReference = true)] -public class Content : ContentBase, IContent +public class Content : PublishableContentBase, IContent { - private HashSet? _editedCultures; - private bool _published; - private PublishedState _publishedState; - private ContentCultureInfosCollection? _publishInfos; private int? _templateId; /// @@ -55,13 +49,6 @@ public Content(string name, IContent parent, IContentType contentType, int userI public Content(string name, IContent parent, IContentType contentType, PropertyCollection properties, string? culture = null) : base(name, parent, contentType, properties, culture) { - if (contentType == null) - { - throw new ArgumentNullException(nameof(contentType)); - } - - _publishedState = PublishedState.Unpublished; - PublishedVersionId = 0; } /// @@ -102,13 +89,6 @@ public Content(string name, int parentId, IContentType contentType, int userId, public Content(string? name, int parentId, IContentType? contentType, PropertyCollection properties, string? culture = null) : base(name, parentId, contentType, properties, culture) { - if (contentType == null) - { - throw new ArgumentNullException(nameof(contentType)); - } - - _publishedState = PublishedState.Unpublished; - PublishedVersionId = 0; } /// @@ -126,278 +106,13 @@ public int? TemplateId set => SetPropertyValueAndDetectChanges(value, ref _templateId, nameof(TemplateId)); } - /// - /// Gets or sets a value indicating whether this content item is published or not. - /// - /// - /// the setter is should only be invoked from - /// - the ContentFactory when creating a content entity from a dto - /// - the ContentRepository when updating a content entity - /// - [DataMember] - public bool Published - { - get => _published; - set - { - SetPropertyValueAndDetectChanges(value, ref _published, nameof(Published)); - _publishedState = _published ? PublishedState.Published : PublishedState.Unpublished; - } - } - - /// - /// Gets the published state of the content item. - /// - /// - /// The state should be Published or Unpublished, depending on whether Published - /// is true or false, but can also temporarily be Publishing or Unpublishing when the - /// content item is about to be saved. - /// - [DataMember] - public PublishedState PublishedState - { - get => _publishedState; - set - { - if (value != PublishedState.Publishing && value != PublishedState.Unpublishing) - { - throw new ArgumentException("Invalid state, only Publishing and Unpublishing are accepted."); - } - - _publishedState = value; - } - } - - [IgnoreDataMember] - public bool Edited { get; set; } - - /// - [IgnoreDataMember] - public DateTime? PublishDate { get; set; } // set by persistence - - /// - [IgnoreDataMember] - public int? PublisherId { get; set; } // set by persistence - /// [IgnoreDataMember] public int? PublishTemplateId { get; set; } // set by persistence - /// - [IgnoreDataMember] - public string? PublishName { get; set; } // set by persistence - - /// - [IgnoreDataMember] - public IEnumerable? EditedCultures - { - get => CultureInfos?.Keys.Where(IsCultureEdited); - set => _editedCultures = value == null ? null : new HashSet(value, StringComparer.OrdinalIgnoreCase); - } - - /// - [IgnoreDataMember] - public IEnumerable PublishedCultures => _publishInfos?.Keys ?? []; - - /// - public bool IsCulturePublished(string culture) - - // just check _publishInfos - // a non-available culture could not become published anyways - => !culture.IsNullOrWhiteSpace() && _publishInfos != null && _publishInfos.ContainsKey(culture); - - /// - public bool IsCultureEdited(string culture) - => IsCultureAvailable(culture) && // is available, and - (!IsCulturePublished(culture) || // is not published, or - (_editedCultures != null && _editedCultures.Contains(culture))); // is edited - - /// - [IgnoreDataMember] - public ContentCultureInfosCollection? PublishCultureInfos - { - get - { - if (_publishInfos != null) - { - return _publishInfos; - } - - _publishInfos = new ContentCultureInfosCollection(); - _publishInfos.CollectionChanged += PublishNamesCollectionChanged; - return _publishInfos; - } - - set - { - if (_publishInfos != null) - { - _publishInfos.ClearCollectionChangedEvents(); - } - - _publishInfos = value; - if (_publishInfos != null) - { - _publishInfos.CollectionChanged += PublishNamesCollectionChanged; - } - } - } - - /// - public string? GetPublishName(string? culture) - { - if (culture.IsNullOrWhiteSpace()) - { - return PublishName; - } - - if (!ContentType.VariesByCulture()) - { - return null; - } - - if (_publishInfos == null) - { - return null; - } - - return _publishInfos.TryGetValue(culture!, out ContentCultureInfos infos) ? infos.Name : null; - } - - /// - public DateTime? GetPublishDate(string culture) - { - if (culture.IsNullOrWhiteSpace()) - { - return PublishDate; - } - - if (!ContentType.VariesByCulture()) - { - return null; - } - - if (_publishInfos == null) - { - return null; - } - - return _publishInfos.TryGetValue(culture, out ContentCultureInfos infos) ? infos.Date : null; - } - - [IgnoreDataMember] - public int PublishedVersionId { get; set; } - [DataMember] public bool Blueprint { get; set; } - public override void ResetWereDirtyProperties() - { - base.ResetWereDirtyProperties(); - _previousPublishCultureChanges.updatedCultures = null; - _previousPublishCultureChanges.removedCultures = null; - _previousPublishCultureChanges.addedCultures = null; - } - - public override void ResetDirtyProperties(bool rememberDirty) - { - base.ResetDirtyProperties(rememberDirty); - - if (rememberDirty) - { - _previousPublishCultureChanges.addedCultures = - _currentPublishCultureChanges.addedCultures == null || - _currentPublishCultureChanges.addedCultures.Count == 0 - ? null - : new HashSet(_currentPublishCultureChanges.addedCultures, StringComparer.InvariantCultureIgnoreCase); - _previousPublishCultureChanges.removedCultures = - _currentPublishCultureChanges.removedCultures == null || - _currentPublishCultureChanges.removedCultures.Count == 0 - ? null - : new HashSet(_currentPublishCultureChanges.removedCultures, StringComparer.InvariantCultureIgnoreCase); - _previousPublishCultureChanges.updatedCultures = - _currentPublishCultureChanges.updatedCultures == null || - _currentPublishCultureChanges.updatedCultures.Count == 0 - ? null - : new HashSet(_currentPublishCultureChanges.updatedCultures, StringComparer.InvariantCultureIgnoreCase); - } - else - { - _previousPublishCultureChanges.addedCultures = null; - _previousPublishCultureChanges.removedCultures = null; - _previousPublishCultureChanges.updatedCultures = null; - } - - _currentPublishCultureChanges.addedCultures?.Clear(); - _currentPublishCultureChanges.removedCultures?.Clear(); - _currentPublishCultureChanges.updatedCultures?.Clear(); - - // take care of the published state - _publishedState = _published ? PublishedState.Published : PublishedState.Unpublished; - - if (_publishInfos == null) - { - return; - } - - foreach (ContentCultureInfos infos in _publishInfos) - { - infos.ResetDirtyProperties(rememberDirty); - } - } - - /// - /// Overridden to check special keys. - public override bool IsPropertyDirty(string propertyName) - { - // Special check here since we want to check if the request is for changed cultures - if (propertyName.StartsWith(ChangeTrackingPrefix.PublishedCulture)) - { - var culture = propertyName.TrimStart(ChangeTrackingPrefix.PublishedCulture); - return _currentPublishCultureChanges.addedCultures?.Contains(culture) ?? false; - } - - if (propertyName.StartsWith(ChangeTrackingPrefix.UnpublishedCulture)) - { - var culture = propertyName.TrimStart(ChangeTrackingPrefix.UnpublishedCulture); - return _currentPublishCultureChanges.removedCultures?.Contains(culture) ?? false; - } - - if (propertyName.StartsWith(ChangeTrackingPrefix.ChangedCulture)) - { - var culture = propertyName.TrimStart(ChangeTrackingPrefix.ChangedCulture); - return _currentPublishCultureChanges.updatedCultures?.Contains(culture) ?? false; - } - - return base.IsPropertyDirty(propertyName); - } - - /// - /// Overridden to check special keys. - public override bool WasPropertyDirty(string propertyName) - { - // Special check here since we want to check if the request is for changed cultures - if (propertyName.StartsWith(ChangeTrackingPrefix.PublishedCulture)) - { - var culture = propertyName.TrimStart(ChangeTrackingPrefix.PublishedCulture); - return _previousPublishCultureChanges.addedCultures?.Contains(culture) ?? false; - } - - if (propertyName.StartsWith(ChangeTrackingPrefix.UnpublishedCulture)) - { - var culture = propertyName.TrimStart(ChangeTrackingPrefix.UnpublishedCulture); - return _previousPublishCultureChanges.removedCultures?.Contains(culture) ?? false; - } - - if (propertyName.StartsWith(ChangeTrackingPrefix.ChangedCulture)) - { - var culture = propertyName.TrimStart(ChangeTrackingPrefix.ChangedCulture); - return _previousPublishCultureChanges.updatedCultures?.Contains(culture) ?? false; - } - - return base.WasPropertyDirty(propertyName); - } - /// /// Creates a deep clone of the current entity with its identity and it's property identities reset /// @@ -416,152 +131,4 @@ public IContent DeepCloneWithResetIdentities() return clone; } - - /// - /// Handles culture infos collection changes. - /// - private void PublishNamesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) - { - OnPropertyChanged(nameof(PublishCultureInfos)); - - // we don't need to handle other actions, only add/remove, however we could implement Replace and track updated cultures in _updatedCultures too - // which would allows us to continue doing WasCulturePublished, but don't think we need it anymore - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - { - ContentCultureInfos? cultureInfo = e.NewItems?.Cast().First(); - if (_currentPublishCultureChanges.addedCultures == null) - { - _currentPublishCultureChanges.addedCultures = - new HashSet(StringComparer.InvariantCultureIgnoreCase); - } - - if (_currentPublishCultureChanges.updatedCultures == null) - { - _currentPublishCultureChanges.updatedCultures = - new HashSet(StringComparer.InvariantCultureIgnoreCase); - } - - if (cultureInfo is not null) - { - _currentPublishCultureChanges.addedCultures.Add(cultureInfo.Culture); - _currentPublishCultureChanges.updatedCultures.Add(cultureInfo.Culture); - _currentPublishCultureChanges.removedCultures?.Remove(cultureInfo.Culture); - } - - break; - } - - case NotifyCollectionChangedAction.Remove: - { - // Remove listening for changes - ContentCultureInfos? cultureInfo = e.OldItems?.Cast().First(); - if (_currentPublishCultureChanges.removedCultures == null) - { - _currentPublishCultureChanges.removedCultures = - new HashSet(StringComparer.InvariantCultureIgnoreCase); - } - - if (cultureInfo is not null) - { - _currentPublishCultureChanges.removedCultures.Add(cultureInfo.Culture); - _currentPublishCultureChanges.updatedCultures?.Remove(cultureInfo.Culture); - _currentPublishCultureChanges.addedCultures?.Remove(cultureInfo.Culture); - } - - break; - } - - case NotifyCollectionChangedAction.Replace: - { - // Replace occurs when an Update occurs - ContentCultureInfos? cultureInfo = e.NewItems?.Cast().First(); - if (_currentPublishCultureChanges.updatedCultures == null) - { - _currentPublishCultureChanges.updatedCultures = - new HashSet(StringComparer.InvariantCultureIgnoreCase); - } - - if (cultureInfo is not null) - { - _currentPublishCultureChanges.updatedCultures.Add(cultureInfo.Culture); - } - - break; - } - } - } - - /// - /// Changes the for the current content object - /// - /// New ContentType for this content - /// Leaves PropertyTypes intact after change - internal void ChangeContentType(IContentType contentType) => ChangeContentType(contentType, false); - - /// - /// Changes the for the current content object and removes PropertyTypes, - /// which are not part of the new ContentType. - /// - /// New ContentType for this content - /// Boolean indicating whether to clear PropertyTypes upon change - internal void ChangeContentType(IContentType contentType, bool clearProperties) - { - ChangeContentType(new SimpleContentType(contentType)); - - if (clearProperties) - { - Properties.EnsureCleanPropertyTypes(contentType.CompositionPropertyTypes); - } - else - { - Properties.EnsurePropertyTypes(contentType.CompositionPropertyTypes); - } - - Properties.ClearCollectionChangedEvents(); // be sure not to double add - Properties.CollectionChanged += PropertiesChanged; - } - - protected override void PerformDeepClone(object clone) - { - base.PerformDeepClone(clone); - - var clonedContent = (Content)clone; - - // TODO: need to reset change tracking bits - - // if culture infos exist then deal with event bindings - if (clonedContent._publishInfos != null) - { - // Clear this event handler if any - clonedContent._publishInfos.ClearCollectionChangedEvents(); - - // Manually deep clone - clonedContent._publishInfos = (ContentCultureInfosCollection?)_publishInfos?.DeepClone(); - if (clonedContent._publishInfos is not null) - { - // Re-assign correct event handler - clonedContent._publishInfos.CollectionChanged += clonedContent.PublishNamesCollectionChanged; - } - } - - clonedContent._currentPublishCultureChanges.updatedCultures = null; - clonedContent._currentPublishCultureChanges.addedCultures = null; - clonedContent._currentPublishCultureChanges.removedCultures = null; - - clonedContent._previousPublishCultureChanges.updatedCultures = null; - clonedContent._previousPublishCultureChanges.addedCultures = null; - clonedContent._previousPublishCultureChanges.removedCultures = null; - } - - #region Used for change tracking - - private (HashSet? addedCultures, HashSet? removedCultures, HashSet? updatedCultures) - _currentPublishCultureChanges; - - private (HashSet? addedCultures, HashSet? removedCultures, HashSet? updatedCultures) - _previousPublishCultureChanges; - - #endregion } diff --git a/src/Umbraco.Core/Models/ContentEditing/ElementCreateModel.cs b/src/Umbraco.Core/Models/ContentEditing/ElementCreateModel.cs new file mode 100644 index 000000000000..cc1826108b23 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/ElementCreateModel.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +public class ElementCreateModel : ContentCreationModelBase +{ +} diff --git a/src/Umbraco.Core/Models/ContentEditing/ElementCreateResult.cs b/src/Umbraco.Core/Models/ContentEditing/ElementCreateResult.cs new file mode 100644 index 000000000000..5ba776841391 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/ElementCreateResult.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +public class ElementCreateResult : ContentCreateResultBase +{ +} diff --git a/src/Umbraco.Core/Models/ContentEditing/ElementUpdateModel.cs b/src/Umbraco.Core/Models/ContentEditing/ElementUpdateModel.cs new file mode 100644 index 000000000000..5e69dd41203b --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/ElementUpdateModel.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +public class ElementUpdateModel : ContentEditingModelBase +{ +} diff --git a/src/Umbraco.Core/Models/ContentEditing/ElementUpdateResult.cs b/src/Umbraco.Core/Models/ContentEditing/ElementUpdateResult.cs new file mode 100644 index 000000000000..ce71bb11e03d --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/ElementUpdateResult.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +public class ElementUpdateResult : ContentUpdateResultBase +{ +} diff --git a/src/Umbraco.Core/Models/ContentEditing/ValidateElementUpdateModel.cs b/src/Umbraco.Core/Models/ContentEditing/ValidateElementUpdateModel.cs new file mode 100644 index 000000000000..6c385ff70ed0 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/ValidateElementUpdateModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +public class ValidateElementUpdateModel : ElementUpdateModel +{ + public ISet? Cultures { get; set; } +} diff --git a/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingResult.cs b/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingResult.cs index a9307f197208..031edce6d4fe 100644 --- a/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingResult.cs +++ b/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingResult.cs @@ -2,7 +2,7 @@ public sealed class ContentPublishingResult { - public IContent? Content { get; init; } + public IPublishableContentBase? Content { get; init; } public IEnumerable InvalidPropertyAliases { get; set; } = []; } diff --git a/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs b/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs index c922cea2b35d..e8f6f1232940 100644 --- a/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs +++ b/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs @@ -67,7 +67,7 @@ public static void TouchCulture(this IContentBase content, string? culture) /// these dates assigned to them differ by a couple of Ticks, but we need to ensure they are persisted at the exact /// same time. /// - public static void AdjustDates(this IContent content, DateTime date, bool publishing) + public static void AdjustDates(this IPublishableContentBase content, DateTime date, bool publishing) { if (content.EditedCultures is not null) { @@ -129,7 +129,7 @@ public static void AdjustDates(this IContent content, DateTime date, bool publis /// Gets the cultures that have been flagged for unpublishing. /// /// Gets cultures for which content.UnpublishCulture() has been invoked. - public static IReadOnlyList? GetCulturesUnpublishing(this IContent content) + public static IReadOnlyList? GetCulturesUnpublishing(this IPublishableContentBase content) { if (!content.Published || !content.ContentType.VariesByCulture() || !content.IsPropertyDirty("PublishCultureInfos")) @@ -147,7 +147,7 @@ public static void AdjustDates(this IContent content, DateTime date, bool publis /// /// Copies values from another document. /// - public static void CopyFrom(this IContent content, IContent other, string? culture = "*") + public static void CopyFrom(this IContent content, IPublishableContentBase other, string? culture = "*") { if (other.ContentTypeId != content.ContentTypeId) { @@ -243,7 +243,7 @@ public static void CopyFrom(this IContent content, IContent other, string? cultu } } - public static void SetPublishInfo(this IContent content, string? culture, string? name, DateTime date) + public static void SetPublishInfo(this IPublishableContentBase content, string? culture, string? name, DateTime date) { if (name == null) { @@ -273,7 +273,7 @@ public static void SetPublishInfo(this IContent content, string? culture, string } // sets the edited cultures on the content - public static void SetCultureEdited(this IContent content, IEnumerable? cultures) + public static void SetCultureEdited(this IPublishableContentBase content, IEnumerable? cultures) { if (cultures == null) { @@ -299,7 +299,7 @@ public static void SetCultureEdited(this IContent content, IEnumerable? /// A value indicating whether it was possible to publish the names and values for the specified /// culture(s). The method may fail if required names are not set, but it does NOT validate property data /// - public static bool PublishCulture(this IContent content, CultureImpact? impact, DateTime publishTime, PropertyEditorCollection propertyEditorCollection) + public static bool PublishCulture(this IPublishableContentBase content, CultureImpact? impact, DateTime publishTime, PropertyEditorCollection propertyEditorCollection) { if (impact == null) { @@ -368,7 +368,7 @@ public static bool PublishCulture(this IContent content, CultureImpact? impact, return true; } - private static void PublishPropertyValues(IContent content, IProperty property, string? culture, PropertyEditorCollection propertyEditorCollection) + private static void PublishPropertyValues(IPublishableContentBase content, IProperty property, string? culture, PropertyEditorCollection propertyEditorCollection) { // if the content varies by culture, let data editor opt-in to perform partial property publishing (per culture) if (content.ContentType.VariesByCulture() @@ -390,7 +390,7 @@ private static void PublishPropertyValues(IContent content, IProperty property, /// /// /// - public static bool UnpublishCulture(this IContent content, string? culture = "*") + public static bool UnpublishCulture(this IPublishableContentBase content, string? culture = "*") { culture = culture?.NullOrWhiteSpaceAsNull(); @@ -428,7 +428,7 @@ public static bool UnpublishCulture(this IContent content, string? culture = "*" return keepProcessing; } - public static void ClearPublishInfos(this IContent content) => content.PublishCultureInfos = null; + public static void ClearPublishInfos(this IPublishableContentBase content) => content.PublishCultureInfos = null; /// /// Returns false if the culture is already unpublished @@ -436,7 +436,7 @@ public static bool UnpublishCulture(this IContent content, string? culture = "*" /// /// /// - public static bool ClearPublishInfo(this IContent content, string? culture) + public static bool ClearPublishInfo(this IPublishableContentBase content, string? culture) { if (culture == null) { diff --git a/src/Umbraco.Core/Models/Element.cs b/src/Umbraco.Core/Models/Element.cs new file mode 100644 index 000000000000..6c969162485c --- /dev/null +++ b/src/Umbraco.Core/Models/Element.cs @@ -0,0 +1,108 @@ +using System.Runtime.Serialization; + +namespace Umbraco.Cms.Core.Models; + +/// +/// Represents an Element object +/// +[Serializable] +[DataContract(IsReference = true)] +public class Element : PublishableContentBase, IElement +{ + /// + /// Constructor for creating an Element object + /// + /// Name of the element + /// ContentType for the current Element object + /// An optional culture. + public Element(string name, IContentType contentType, string? culture = null) + : this(name, contentType, new PropertyCollection(), culture) + { + } + + /// + /// Constructor for creating an Element object + /// + /// Name of the element + /// ContentType for the current Element object + /// The identifier of the user creating the Element object + /// An optional culture. + public Element(string name, IContentType contentType, int userId, string? culture = null) + : this(name, contentType, new PropertyCollection(), culture) + { + CreatorId = userId; + WriterId = userId; + } + + /// + /// Constructor for creating an Element object + /// + /// Name of the element + /// ContentType for the current Element object + /// Collection of properties + /// An optional culture. + public Element(string name, IContentType contentType, PropertyCollection properties, string? culture = null) + : base(name, Constants.System.Root, contentType, properties, culture) + { + } + + + /// + /// Constructor for creating an Element object + /// + /// Name of the element + /// Id of the Parent folder + /// ContentType for the current Element object + /// An optional culture. + public Element(string? name, int parentId, IContentType? contentType, string? culture = null) + : this(name, parentId, contentType, new PropertyCollection(), culture) + { + } + + /// + /// Constructor for creating an Element object + /// + /// Name of the element + /// Id of the Parent folder + /// ContentType for the current Element object + /// The identifier of the user creating the Element object + /// An optional culture. + public Element(string name, int parentId, IContentType contentType, int userId, string? culture = null) + : this(name, parentId, contentType, new PropertyCollection(), culture) + { + CreatorId = userId; + WriterId = userId; + } + + /// + /// Constructor for creating an Element object + /// + /// Name of the element + /// Id of the Parent folder + /// ContentType for the current Element object + /// Collection of properties + /// An optional culture. + public Element(string? name, int parentId, IContentType? contentType, PropertyCollection properties, string? culture = null) + : base(name, parentId, contentType, properties, culture) + { + } + + /// + /// Creates a deep clone of the current entity with its identity and it's property identities reset + /// + /// + public IElement DeepCloneWithResetIdentities() + { + var clone = (Element)DeepClone(); + clone.Key = Guid.Empty; + clone.VersionId = clone.PublishedVersionId = 0; + clone.ResetIdentity(); + + foreach (IProperty property in clone.Properties) + { + property.ResetIdentity(); + } + + return clone; + } +} diff --git a/src/Umbraco.Core/Models/Entities/DocumentEntitySlim.cs b/src/Umbraco.Core/Models/Entities/DocumentEntitySlim.cs index a5c0ca23c990..8225f7afd53a 100644 --- a/src/Umbraco.Core/Models/Entities/DocumentEntitySlim.cs +++ b/src/Umbraco.Core/Models/Entities/DocumentEntitySlim.cs @@ -3,40 +3,6 @@ namespace Umbraco.Cms.Core.Models.Entities; /// /// Implements . /// -public class DocumentEntitySlim : ContentEntitySlim, IDocumentEntitySlim +public class DocumentEntitySlim : PublishableContentEntitySlim, IDocumentEntitySlim { - private static readonly IReadOnlyDictionary Empty = new Dictionary(); - - private IReadOnlyDictionary? _cultureNames; - private IEnumerable? _editedCultures; - private IEnumerable? _publishedCultures; - - /// - public IReadOnlyDictionary CultureNames - { - get => _cultureNames ?? Empty; - set => _cultureNames = value; - } - - /// - public IEnumerable PublishedCultures - { - get => _publishedCultures ?? Enumerable.Empty(); - set => _publishedCultures = value; - } - - /// - public IEnumerable EditedCultures - { - get => _editedCultures ?? Enumerable.Empty(); - set => _editedCultures = value; - } - - public ContentVariation Variations { get; set; } - - /// - public bool Published { get; set; } - - /// - public bool Edited { get; set; } } diff --git a/src/Umbraco.Core/Models/Entities/ElementEntitySlim.cs b/src/Umbraco.Core/Models/Entities/ElementEntitySlim.cs new file mode 100644 index 000000000000..1e1c9656c1d5 --- /dev/null +++ b/src/Umbraco.Core/Models/Entities/ElementEntitySlim.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Models.Entities; + +public class ElementEntitySlim : PublishableContentEntitySlim +{ +} diff --git a/src/Umbraco.Core/Models/Entities/IDocumentEntitySlim.cs b/src/Umbraco.Core/Models/Entities/IDocumentEntitySlim.cs index 75e16476c25d..71902ef472a3 100644 --- a/src/Umbraco.Core/Models/Entities/IDocumentEntitySlim.cs +++ b/src/Umbraco.Core/Models/Entities/IDocumentEntitySlim.cs @@ -3,35 +3,6 @@ namespace Umbraco.Cms.Core.Models.Entities; /// /// Represents a lightweight document entity, managed by the entity service. /// -public interface IDocumentEntitySlim : IContentEntitySlim +public interface IDocumentEntitySlim : IPublishableContentEntitySlim { - /// - /// Gets the variant name for each culture - /// - IReadOnlyDictionary CultureNames { get; } - - /// - /// Gets the published cultures. - /// - IEnumerable PublishedCultures { get; } - - /// - /// Gets the edited cultures. - /// - IEnumerable EditedCultures { get; } - - /// - /// Gets the content variation of the content type. - /// - ContentVariation Variations { get; } - - /// - /// Gets a value indicating whether the content is published. - /// - bool Published { get; } - - /// - /// Gets a value indicating whether the content has been edited. - /// - bool Edited { get; } } diff --git a/src/Umbraco.Core/Models/Entities/IElementEntitySlim.cs b/src/Umbraco.Core/Models/Entities/IElementEntitySlim.cs new file mode 100644 index 000000000000..15814380a8f5 --- /dev/null +++ b/src/Umbraco.Core/Models/Entities/IElementEntitySlim.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Models.Entities; + +public interface IElementEntitySlim : IPublishableContentEntitySlim +{ +} diff --git a/src/Umbraco.Core/Models/Entities/IPublishableContentEntitySlim.cs b/src/Umbraco.Core/Models/Entities/IPublishableContentEntitySlim.cs new file mode 100644 index 000000000000..2db5e1ae069e --- /dev/null +++ b/src/Umbraco.Core/Models/Entities/IPublishableContentEntitySlim.cs @@ -0,0 +1,34 @@ +namespace Umbraco.Cms.Core.Models.Entities; + +public interface IPublishableContentEntitySlim : IContentEntitySlim +{ + /// + /// Gets the variant name for each culture + /// + IReadOnlyDictionary CultureNames { get; } + + /// + /// Gets the published cultures. + /// + IEnumerable PublishedCultures { get; } + + /// + /// Gets the edited cultures. + /// + IEnumerable EditedCultures { get; } + + /// + /// Gets the content variation of the content type. + /// + ContentVariation Variations { get; } + + /// + /// Gets a value indicating whether the content is published. + /// + bool Published { get; } + + /// + /// Gets a value indicating whether the content has been edited. + /// + bool Edited { get; } +} diff --git a/src/Umbraco.Core/Models/Entities/PublishableContentEntitySlim.cs b/src/Umbraco.Core/Models/Entities/PublishableContentEntitySlim.cs new file mode 100644 index 000000000000..9653936427eb --- /dev/null +++ b/src/Umbraco.Core/Models/Entities/PublishableContentEntitySlim.cs @@ -0,0 +1,39 @@ +namespace Umbraco.Cms.Core.Models.Entities; + +public abstract class PublishableContentEntitySlim : ContentEntitySlim +{ + private static readonly IReadOnlyDictionary Empty = new Dictionary(); + + private IReadOnlyDictionary? _cultureNames; + private IEnumerable? _editedCultures; + private IEnumerable? _publishedCultures; + + /// + public IReadOnlyDictionary CultureNames + { + get => _cultureNames ?? Empty; + set => _cultureNames = value; + } + + /// + public IEnumerable PublishedCultures + { + get => _publishedCultures ?? Enumerable.Empty(); + set => _publishedCultures = value; + } + + /// + public IEnumerable EditedCultures + { + get => _editedCultures ?? Enumerable.Empty(); + set => _editedCultures = value; + } + + public ContentVariation Variations { get; set; } + + /// + public bool Published { get; set; } + + /// + public bool Edited { get; set; } +} diff --git a/src/Umbraco.Core/Models/EntityContainer.cs b/src/Umbraco.Core/Models/EntityContainer.cs index 6033c6dfa937..30b51c8817cf 100644 --- a/src/Umbraco.Core/Models/EntityContainer.cs +++ b/src/Umbraco.Core/Models/EntityContainer.cs @@ -13,6 +13,7 @@ public sealed class EntityContainer : TreeEntityBase, IUmbracoEntity { Constants.ObjectTypes.DocumentType, Constants.ObjectTypes.DocumentTypeContainer }, { Constants.ObjectTypes.MediaType, Constants.ObjectTypes.MediaTypeContainer }, { Constants.ObjectTypes.DocumentBlueprint, Constants.ObjectTypes.DocumentBlueprintContainer }, + { Constants.ObjectTypes.Element, Constants.ObjectTypes.ElementContainer }, }; /// diff --git a/src/Umbraco.Core/Models/IContent.cs b/src/Umbraco.Core/Models/IContent.cs index 9e36306cfc78..9570dd745f45 100644 --- a/src/Umbraco.Core/Models/IContent.cs +++ b/src/Umbraco.Core/Models/IContent.cs @@ -6,35 +6,13 @@ namespace Umbraco.Cms.Core.Models; /// /// A document can be published, rendered by a template. /// -public interface IContent : IContentBase +public interface IContent : IPublishableContentBase { /// /// Gets or sets the template id used to render the content. /// int? TemplateId { get; set; } - /// - /// Gets a value indicating whether the content is published. - /// - /// The property tells you which version of the content is currently published. - bool Published { get; set; } - - PublishedState PublishedState { get; set; } - - /// - /// Gets a value indicating whether the content has been edited. - /// - /// - /// Will return `true` once unpublished edits have been made after the version with - /// has been published. - /// - bool Edited { get; set; } - - /// - /// Gets the version identifier for the currently published version of the content. - /// - int PublishedVersionId { get; set; } - /// /// Gets a value indicating whether the content item is a blueprint. /// @@ -46,90 +24,6 @@ public interface IContent : IContentBase /// When editing the content, the template can change, but this will not until the content is published. int? PublishTemplateId { get; set; } - /// - /// Gets the name of the published version of the content. - /// - /// When editing the content, the name can change, but this will not until the content is published. - string? PublishName { get; set; } - - /// - /// Gets the identifier of the user who published the content. - /// - int? PublisherId { get; set; } - - /// - /// Gets the date and time the content was published. - /// - DateTime? PublishDate { get; set; } - - /// - /// Gets the published culture infos of the content. - /// - /// - /// - /// Because a dictionary key cannot be null this cannot get the invariant - /// name, which must be get via the property. - /// - /// - ContentCultureInfosCollection? PublishCultureInfos { get; set; } - - /// - /// Gets the published cultures. - /// - IEnumerable PublishedCultures { get; } - - /// - /// Gets the edited cultures. - /// - IEnumerable? EditedCultures { get; set; } - - /// - /// Gets a value indicating whether a culture is published. - /// - /// - /// - /// A culture becomes published whenever values for this culture are published, - /// and the content published name for this culture is non-null. It becomes non-published - /// whenever values for this culture are unpublished. - /// - /// - /// A culture becomes published as soon as PublishCulture has been invoked, - /// even though the document might not have been saved yet (and can have no identity). - /// - /// Does not support the '*' wildcard (returns false). - /// - bool IsCulturePublished(string culture); - - /// - /// Gets the date a culture was published. - /// - DateTime? GetPublishDate(string culture); - - /// - /// Gets a value indicated whether a given culture is edited. - /// - /// - /// - /// A culture is edited when it is available, and not published or published but - /// with changes. - /// - /// A culture can be edited even though the document might now have been saved yet (and can have no identity). - /// Does not support the '*' wildcard (returns false). - /// - bool IsCultureEdited(string culture); - - /// - /// Gets the name of the published version of the content for a given culture. - /// - /// - /// When editing the content, the name can change, but this will not until the content is published. - /// - /// When is null, gets the invariant - /// language, which is the value of the property. - /// - /// - string? GetPublishName(string? culture); - /// /// Creates a deep clone of the current entity with its identity/alias and it's property identities reset /// diff --git a/src/Umbraco.Core/Models/IElement.cs b/src/Umbraco.Core/Models/IElement.cs new file mode 100644 index 000000000000..610086dfc977 --- /dev/null +++ b/src/Umbraco.Core/Models/IElement.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Core.Models; + +public interface IElement : IPublishableContentBase +{ + /// + /// Creates a deep clone of the current entity with its identity/alias and it's property identities reset + /// + /// + IElement DeepCloneWithResetIdentities(); +} diff --git a/src/Umbraco.Core/Models/IPublishableContentBase.cs b/src/Umbraco.Core/Models/IPublishableContentBase.cs new file mode 100644 index 000000000000..f6e687349af4 --- /dev/null +++ b/src/Umbraco.Core/Models/IPublishableContentBase.cs @@ -0,0 +1,110 @@ +namespace Umbraco.Cms.Core.Models; + +public interface IPublishableContentBase : IContentBase +{ + /// + /// Gets a value indicating whether the content is published. + /// + /// The property tells you which version of the content is currently published. + bool Published { get; set; } + + PublishedState PublishedState { get; set; } + + /// + /// Gets a value indicating whether the content has been edited. + /// + /// + /// Will return `true` once unpublished edits have been made after the version with + /// has been published. + /// + bool Edited { get; set; } + + /// + /// Gets the version identifier for the currently published version of the content. + /// + int PublishedVersionId { get; set; } + + /// + /// Gets the name of the published version of the content. + /// + /// When editing the content, the name can change, but this will not until the content is published. + string? PublishName { get; set; } + + /// + /// Gets the identifier of the user who published the content. + /// + int? PublisherId { get; set; } + + /// + /// Gets the date and time the content was published. + /// + DateTime? PublishDate { get; set; } + + /// + /// Gets the published culture infos of the content. + /// + /// + /// + /// Because a dictionary key cannot be null this cannot get the invariant + /// name, which must be get via the property. + /// + /// + ContentCultureInfosCollection? PublishCultureInfos { get; set; } + + /// + /// Gets the published cultures. + /// + IEnumerable PublishedCultures { get; } + + /// + /// Gets the edited cultures. + /// + IEnumerable? EditedCultures { get; set; } + + /// + /// Gets a value indicating whether a culture is published. + /// + /// + /// + /// A culture becomes published whenever values for this culture are published, + /// and the content published name for this culture is non-null. It becomes non-published + /// whenever values for this culture are unpublished. + /// + /// + /// A culture becomes published as soon as PublishCulture has been invoked, + /// even though the document might not have been saved yet (and can have no identity). + /// + /// Does not support the '*' wildcard (returns false). + /// + bool IsCulturePublished(string culture); + + /// + /// Gets the date a culture was published. + /// + DateTime? GetPublishDate(string culture); + + /// + /// Gets a value indicated whether a given culture is edited. + /// + /// + /// + /// A culture is edited when it is available, and not published or published but + /// with changes. + /// + /// A culture can be edited even though the document might now have been saved yet (and can have no identity). + /// Does not support the '*' wildcard (returns false). + /// + bool IsCultureEdited(string culture); + + /// + /// Gets the name of the published version of the content for a given culture. + /// + /// + /// When editing the content, the name can change, but this will not until the content is published. + /// + /// When is null, gets the invariant + /// language, which is the value of the property. + /// + /// + string? GetPublishName(string? culture); +} diff --git a/src/Umbraco.Core/Models/PublishableContentBase.cs b/src/Umbraco.Core/Models/PublishableContentBase.cs new file mode 100644 index 000000000000..75ee915326e7 --- /dev/null +++ b/src/Umbraco.Core/Models/PublishableContentBase.cs @@ -0,0 +1,452 @@ +namespace Umbraco.Cms.Core.Models; + +using System.Collections.Specialized; +using System.Runtime.Serialization; +using Umbraco.Extensions; + +// TODO ELEMENTS: ensure property annotations ect. are up to date from Content +public abstract class PublishableContentBase : ContentBase, IPublishableContentBase +{ + private HashSet? _editedCultures; + private bool _published; + private PublishedState _publishedState; + private ContentCultureInfosCollection? _publishInfos; + + protected PublishableContentBase(string? name, int parentId, IContentTypeComposition? contentType, IPropertyCollection properties, string? culture = null) + : base(name, parentId, contentType, properties, culture) + { + if (contentType == null) + { + throw new ArgumentNullException(nameof(contentType)); + } + + _publishedState = PublishedState.Unpublished; + PublishedVersionId = 0; + } + + protected PublishableContentBase(string? name, IContentBase? parent, IContentTypeComposition contentType, IPropertyCollection properties, string? culture = null) + : base(name, parent, contentType, properties, culture) + { + if (contentType == null) + { + throw new ArgumentNullException(nameof(contentType)); + } + + _publishedState = PublishedState.Unpublished; + PublishedVersionId = 0; + } + + /// + /// Gets or sets a value indicating whether this content item is published or not. + /// + /// + /// the setter is should only be invoked from + /// - the ContentFactory when creating a content entity from a dto + /// - the ContentRepository when updating a content entity + /// + [DataMember] + public bool Published + { + get => _published; + set + { + SetPropertyValueAndDetectChanges(value, ref _published, nameof(Published)); + _publishedState = _published ? PublishedState.Published : PublishedState.Unpublished; + } + } + + /// + /// Gets the published state of the content item. + /// + /// + /// The state should be Published or Unpublished, depending on whether Published + /// is true or false, but can also temporarily be Publishing or Unpublishing when the + /// content item is about to be saved. + /// + [DataMember] + public PublishedState PublishedState + { + get => _publishedState; + set + { + if (value != PublishedState.Publishing && value != PublishedState.Unpublishing) + { + throw new ArgumentException("Invalid state, only Publishing and Unpublishing are accepted."); + } + + _publishedState = value; + } + } + + + [IgnoreDataMember] + public bool Edited { get; set; } + + /// + [IgnoreDataMember] + public DateTime? PublishDate { get; set; } // set by persistence + + /// + [IgnoreDataMember] + public int? PublisherId { get; set; } // set by persistence + + /// + [IgnoreDataMember] + public string? PublishName { get; set; } // set by persistence + + /// + [IgnoreDataMember] + public IEnumerable? EditedCultures + { + get => CultureInfos?.Keys.Where(IsCultureEdited); + set => _editedCultures = value == null ? null : new HashSet(value, StringComparer.OrdinalIgnoreCase); + } + + /// + [IgnoreDataMember] + public IEnumerable PublishedCultures => _publishInfos?.Keys ?? []; + + /// + public bool IsCulturePublished(string culture) + + // just check _publishInfos + // a non-available culture could not become published anyways + => !culture.IsNullOrWhiteSpace() && _publishInfos != null && _publishInfos.ContainsKey(culture); + + /// + public bool IsCultureEdited(string culture) + => IsCultureAvailable(culture) && // is available, and + (!IsCulturePublished(culture) || // is not published, or + (_editedCultures != null && _editedCultures.Contains(culture))); // is edited + + /// + [IgnoreDataMember] + public ContentCultureInfosCollection? PublishCultureInfos + { + get + { + if (_publishInfos != null) + { + return _publishInfos; + } + + _publishInfos = new ContentCultureInfosCollection(); + _publishInfos.CollectionChanged += PublishNamesCollectionChanged; + return _publishInfos; + } + + set + { + if (_publishInfos != null) + { + _publishInfos.ClearCollectionChangedEvents(); + } + + _publishInfos = value; + if (_publishInfos != null) + { + _publishInfos.CollectionChanged += PublishNamesCollectionChanged; + } + } + } + + /// + public string? GetPublishName(string? culture) + { + if (culture.IsNullOrWhiteSpace()) + { + return PublishName; + } + + if (!ContentType.VariesByCulture()) + { + return null; + } + + if (_publishInfos == null) + { + return null; + } + + return _publishInfos.TryGetValue(culture!, out ContentCultureInfos infos) ? infos.Name : null; + } + + /// + public DateTime? GetPublishDate(string culture) + { + if (culture.IsNullOrWhiteSpace()) + { + return PublishDate; + } + + if (!ContentType.VariesByCulture()) + { + return null; + } + + if (_publishInfos == null) + { + return null; + } + + return _publishInfos.TryGetValue(culture, out ContentCultureInfos infos) ? infos.Date : null; + } + + [IgnoreDataMember] + public int PublishedVersionId { get; set; } + + public override void ResetWereDirtyProperties() + { + base.ResetWereDirtyProperties(); + _previousPublishCultureChanges.updatedCultures = null; + _previousPublishCultureChanges.removedCultures = null; + _previousPublishCultureChanges.addedCultures = null; + } + + public override void ResetDirtyProperties(bool rememberDirty) + { + base.ResetDirtyProperties(rememberDirty); + + if (rememberDirty) + { + _previousPublishCultureChanges.addedCultures = + _currentPublishCultureChanges.addedCultures == null || + _currentPublishCultureChanges.addedCultures.Count == 0 + ? null + : new HashSet(_currentPublishCultureChanges.addedCultures, StringComparer.InvariantCultureIgnoreCase); + _previousPublishCultureChanges.removedCultures = + _currentPublishCultureChanges.removedCultures == null || + _currentPublishCultureChanges.removedCultures.Count == 0 + ? null + : new HashSet(_currentPublishCultureChanges.removedCultures, StringComparer.InvariantCultureIgnoreCase); + _previousPublishCultureChanges.updatedCultures = + _currentPublishCultureChanges.updatedCultures == null || + _currentPublishCultureChanges.updatedCultures.Count == 0 + ? null + : new HashSet(_currentPublishCultureChanges.updatedCultures, StringComparer.InvariantCultureIgnoreCase); + } + else + { + _previousPublishCultureChanges.addedCultures = null; + _previousPublishCultureChanges.removedCultures = null; + _previousPublishCultureChanges.updatedCultures = null; + } + + _currentPublishCultureChanges.addedCultures?.Clear(); + _currentPublishCultureChanges.removedCultures?.Clear(); + _currentPublishCultureChanges.updatedCultures?.Clear(); + + // take care of the published state + _publishedState = _published ? PublishedState.Published : PublishedState.Unpublished; + + if (_publishInfos == null) + { + return; + } + + foreach (ContentCultureInfos infos in _publishInfos) + { + infos.ResetDirtyProperties(rememberDirty); + } + } + + /// + /// Overridden to check special keys. + public override bool IsPropertyDirty(string propertyName) + { + // Special check here since we want to check if the request is for changed cultures + if (propertyName.StartsWith(ChangeTrackingPrefix.PublishedCulture)) + { + var culture = propertyName.TrimStart(ChangeTrackingPrefix.PublishedCulture); + return _currentPublishCultureChanges.addedCultures?.Contains(culture) ?? false; + } + + if (propertyName.StartsWith(ChangeTrackingPrefix.UnpublishedCulture)) + { + var culture = propertyName.TrimStart(ChangeTrackingPrefix.UnpublishedCulture); + return _currentPublishCultureChanges.removedCultures?.Contains(culture) ?? false; + } + + if (propertyName.StartsWith(ChangeTrackingPrefix.ChangedCulture)) + { + var culture = propertyName.TrimStart(ChangeTrackingPrefix.ChangedCulture); + return _currentPublishCultureChanges.updatedCultures?.Contains(culture) ?? false; + } + + return base.IsPropertyDirty(propertyName); + } + + /// + /// Overridden to check special keys. + public override bool WasPropertyDirty(string propertyName) + { + // Special check here since we want to check if the request is for changed cultures + if (propertyName.StartsWith(ChangeTrackingPrefix.PublishedCulture)) + { + var culture = propertyName.TrimStart(ChangeTrackingPrefix.PublishedCulture); + return _previousPublishCultureChanges.addedCultures?.Contains(culture) ?? false; + } + + if (propertyName.StartsWith(ChangeTrackingPrefix.UnpublishedCulture)) + { + var culture = propertyName.TrimStart(ChangeTrackingPrefix.UnpublishedCulture); + return _previousPublishCultureChanges.removedCultures?.Contains(culture) ?? false; + } + + if (propertyName.StartsWith(ChangeTrackingPrefix.ChangedCulture)) + { + var culture = propertyName.TrimStart(ChangeTrackingPrefix.ChangedCulture); + return _previousPublishCultureChanges.updatedCultures?.Contains(culture) ?? false; + } + + return base.WasPropertyDirty(propertyName); + } + + /// + /// Handles culture infos collection changes. + /// + private void PublishNamesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + OnPropertyChanged(nameof(PublishCultureInfos)); + + // we don't need to handle other actions, only add/remove, however we could implement Replace and track updated cultures in _updatedCultures too + // which would allows us to continue doing WasCulturePublished, but don't think we need it anymore + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + { + ContentCultureInfos? cultureInfo = e.NewItems?.Cast().First(); + if (_currentPublishCultureChanges.addedCultures == null) + { + _currentPublishCultureChanges.addedCultures = + new HashSet(StringComparer.InvariantCultureIgnoreCase); + } + + if (_currentPublishCultureChanges.updatedCultures == null) + { + _currentPublishCultureChanges.updatedCultures = + new HashSet(StringComparer.InvariantCultureIgnoreCase); + } + + if (cultureInfo is not null) + { + _currentPublishCultureChanges.addedCultures.Add(cultureInfo.Culture); + _currentPublishCultureChanges.updatedCultures.Add(cultureInfo.Culture); + _currentPublishCultureChanges.removedCultures?.Remove(cultureInfo.Culture); + } + + break; + } + + case NotifyCollectionChangedAction.Remove: + { + // Remove listening for changes + ContentCultureInfos? cultureInfo = e.OldItems?.Cast().First(); + if (_currentPublishCultureChanges.removedCultures == null) + { + _currentPublishCultureChanges.removedCultures = + new HashSet(StringComparer.InvariantCultureIgnoreCase); + } + + if (cultureInfo is not null) + { + _currentPublishCultureChanges.removedCultures.Add(cultureInfo.Culture); + _currentPublishCultureChanges.updatedCultures?.Remove(cultureInfo.Culture); + _currentPublishCultureChanges.addedCultures?.Remove(cultureInfo.Culture); + } + + break; + } + + case NotifyCollectionChangedAction.Replace: + { + // Replace occurs when an Update occurs + ContentCultureInfos? cultureInfo = e.NewItems?.Cast().First(); + if (_currentPublishCultureChanges.updatedCultures == null) + { + _currentPublishCultureChanges.updatedCultures = + new HashSet(StringComparer.InvariantCultureIgnoreCase); + } + + if (cultureInfo is not null) + { + _currentPublishCultureChanges.updatedCultures.Add(cultureInfo.Culture); + } + + break; + } + } + } + + /// + /// Changes the for the current content object + /// + /// New ContentType for this content + /// Leaves PropertyTypes intact after change + internal void ChangeContentType(IContentType contentType) => ChangeContentType(contentType, false); + + /// + /// Changes the for the current content object and removes PropertyTypes, + /// which are not part of the new ContentType. + /// + /// New ContentType for this content + /// Boolean indicating whether to clear PropertyTypes upon change + internal void ChangeContentType(IContentType contentType, bool clearProperties) + { + ChangeContentType(new SimpleContentType(contentType)); + + if (clearProperties) + { + Properties.EnsureCleanPropertyTypes(contentType.CompositionPropertyTypes); + } + else + { + Properties.EnsurePropertyTypes(contentType.CompositionPropertyTypes); + } + + Properties.ClearCollectionChangedEvents(); // be sure not to double add + Properties.CollectionChanged += PropertiesChanged; + } + + protected override void PerformDeepClone(object clone) + { + base.PerformDeepClone(clone); + + var clonedContent = (PublishableContentBase)clone; + + // TODO: need to reset change tracking bits + + // if culture infos exist then deal with event bindings + if (clonedContent._publishInfos != null) + { + // Clear this event handler if any + clonedContent._publishInfos.ClearCollectionChangedEvents(); + + // Manually deep clone + clonedContent._publishInfos = (ContentCultureInfosCollection?)_publishInfos?.DeepClone(); + if (clonedContent._publishInfos is not null) + { + // Re-assign correct event handler + clonedContent._publishInfos.CollectionChanged += clonedContent.PublishNamesCollectionChanged; + } + } + + clonedContent._currentPublishCultureChanges.updatedCultures = null; + clonedContent._currentPublishCultureChanges.addedCultures = null; + clonedContent._currentPublishCultureChanges.removedCultures = null; + + clonedContent._previousPublishCultureChanges.updatedCultures = null; + clonedContent._previousPublishCultureChanges.addedCultures = null; + clonedContent._previousPublishCultureChanges.removedCultures = null; + } + + #region Used for change tracking + + private (HashSet? addedCultures, HashSet? removedCultures, HashSet? updatedCultures) + _currentPublishCultureChanges; + + private (HashSet? addedCultures, HashSet? removedCultures, HashSet? updatedCultures) + _previousPublishCultureChanges; + + #endregion +} diff --git a/src/Umbraco.Core/Models/PublishedContent/IPublishedContent.cs b/src/Umbraco.Core/Models/PublishedContent/IPublishedContent.cs index 6fefac904011..eff211af747e 100644 --- a/src/Umbraco.Core/Models/PublishedContent/IPublishedContent.cs +++ b/src/Umbraco.Core/Models/PublishedContent/IPublishedContent.cs @@ -9,93 +9,16 @@ namespace Umbraco.Cms.Core.Models.PublishedContent; /// public interface IPublishedContent : IPublishedElement { - // TODO: IPublishedContent properties colliding with models - // we need to find a way to remove as much clutter as possible from IPublishedContent, - // since this is preventing someone from creating a property named 'Path' and have it - // in a model, for instance. we could move them all under one unique property eg - // Infos, so we would do .Infos.SortOrder - just an idea - not going to do it in v8 - - /// - /// Gets the unique identifier of the content item. - /// - int Id { get; } - - /// - /// Gets the name of the content item for the current culture. - /// - string Name { get; } - /// /// Gets the URL segment of the content item for the current culture. /// string? UrlSegment { get; } - /// - /// Gets the sort order of the content item. - /// - int SortOrder { get; } - - /// - /// Gets the tree level of the content item. - /// - int Level { get; } - - /// - /// Gets the tree path of the content item. - /// - string Path { get; } - /// /// Gets the identifier of the template to use to render the content item. /// int? TemplateId { get; } - /// - /// Gets the identifier of the user who created the content item. - /// - int CreatorId { get; } - - /// - /// Gets the date the content item was created. - /// - DateTime CreateDate { get; } - - /// - /// Gets the identifier of the user who last updated the content item. - /// - int WriterId { get; } - - /// - /// Gets the date the content item was last updated. - /// - /// - /// For published content items, this is also the date the item was published. - /// - /// This date is always global to the content item, see CultureDate() for the - /// date each culture was published. - /// - /// - DateTime UpdateDate { get; } - - /// - /// Gets available culture infos. - /// - /// - /// - /// Contains only those culture that are available. For a published content, these are - /// the cultures that are published. For a draft content, those that are 'available' ie - /// have a non-empty content name. - /// - /// Does not contain the invariant culture. - /// // TODO? - /// - IReadOnlyDictionary Cultures { get; } - - /// - /// Gets the type of the content item (document, media...). - /// - PublishedItemType ItemType { get; } - /// /// Gets the parent of the content item. /// @@ -104,44 +27,18 @@ public interface IPublishedContent : IPublishedElement IPublishedContent? Parent { get; } /// - /// Gets a value indicating whether the content is draft. + /// Gets the children of the content item that are available for the current culture. /// - /// - /// - /// A content is draft when it is the unpublished version of a content, which may - /// have a published version, or not. - /// - /// - /// When retrieving documents from cache in non-preview mode, IsDraft is always false, - /// as only published documents are returned. When retrieving in preview mode, IsDraft can - /// either be true (document is not published, or has been edited, and what is returned - /// is the edited version) or false (document is published, and has not been edited, and - /// what is returned is the published version). - /// - /// - bool IsDraft(string? culture = null); + [Obsolete("Please use either the IPublishedContent.Children() extension method in the Umbraco.Extensions namespace, or IDocumentNavigationQueryService if you only need keys. Scheduled for removal in V16.")] + IEnumerable Children { get; } /// - /// Gets a value indicating whether the content is published. + /// Gets the tree level of the content item. /// - /// - /// A content is published when it has a published version. - /// - /// When retrieving documents from cache in non-preview mode, IsPublished is always - /// true, as only published documents are returned. When retrieving in draft mode, IsPublished - /// can either be true (document has a published version) or false (document has no - /// published version). - /// - /// - /// It is therefore possible for both IsDraft and IsPublished to be true at the same - /// time, meaning that the content is the draft version, and a published version exists. - /// - /// - bool IsPublished(string? culture = null); + int Level { get; } /// - /// Gets the children of the content item that are available for the current culture. + /// Gets the tree path of the content item. /// - [Obsolete("Please use either the IPublishedContent.Children() extension method in the Umbraco.Extensions namespace, or IDocumentNavigationQueryService if you only need keys. Scheduled for removal in V16.")] - IEnumerable Children { get; } + string Path { get; } } diff --git a/src/Umbraco.Core/Models/PublishedContent/IPublishedElement.cs b/src/Umbraco.Core/Models/PublishedContent/IPublishedElement.cs index a198064137dc..3ab2a9993def 100644 --- a/src/Umbraco.Core/Models/PublishedContent/IPublishedElement.cs +++ b/src/Umbraco.Core/Models/PublishedContent/IPublishedElement.cs @@ -47,4 +47,101 @@ public interface IPublishedElement IPublishedProperty? GetProperty(string alias); #endregion + + /// + /// Gets the unique identifier of the content item. + /// + int Id { get; } + + /// + /// Gets the name of the content item for the current culture. + /// + string Name { get; } + + /// + /// Gets the sort order of the content item. + /// + int SortOrder { get; } + + /// + /// Gets the identifier of the user who created the content item. + /// + int CreatorId { get; } + + /// + /// Gets the date the content item was created. + /// + DateTime CreateDate { get; } + + /// + /// Gets the identifier of the user who last updated the content item. + /// + int WriterId { get; } + + /// + /// Gets the date the content item was last updated. + /// + /// + /// For published content items, this is also the date the item was published. + /// + /// This date is always global to the content item, see CultureDate() for the + /// date each culture was published. + /// + /// + DateTime UpdateDate { get; } + + /// + /// Gets available culture infos. + /// + /// + /// + /// Contains only those culture that are available. For a published content, these are + /// the cultures that are published. For a draft content, those that are 'available' ie + /// have a non-empty content name. + /// + /// Does not contain the invariant culture. + /// // TODO? + /// + IReadOnlyDictionary Cultures { get; } + + /// + /// Gets the type of the content item (document, media...). + /// + PublishedItemType ItemType { get; } + + /// + /// Gets a value indicating whether the content is draft. + /// + /// + /// + /// A content is draft when it is the unpublished version of a content, which may + /// have a published version, or not. + /// + /// + /// When retrieving documents from cache in non-preview mode, IsDraft is always false, + /// as only published documents are returned. When retrieving in preview mode, IsDraft can + /// either be true (document is not published, or has been edited, and what is returned + /// is the edited version) or false (document is published, and has not been edited, and + /// what is returned is the published version). + /// + /// + bool IsDraft(string? culture = null); + + /// + /// Gets a value indicating whether the content is published. + /// + /// + /// A content is published when it has a published version. + /// + /// When retrieving documents from cache in non-preview mode, IsPublished is always + /// true, as only published documents are returned. When retrieving in draft mode, IsPublished + /// can either be true (document has a published version) or false (document has no + /// published version). + /// + /// + /// It is therefore possible for both IsDraft and IsPublished to be true at the same + /// time, meaning that the content is the draft version, and a published version exists. + /// + /// + bool IsPublished(string? culture = null); } diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishableContentBase.cs b/src/Umbraco.Core/Models/PublishedContent/PublishableContentBase.cs new file mode 100644 index 000000000000..565f6a2e7637 --- /dev/null +++ b/src/Umbraco.Core/Models/PublishedContent/PublishableContentBase.cs @@ -0,0 +1,51 @@ +using System.Diagnostics; + +namespace Umbraco.Cms.Core.Models.PublishedContent; + +/// +/// Provide an abstract base class for publishable content implementations (like IPublishedContent and IPublishedElement implementations). +/// +[DebuggerDisplay("Content Id: {Id}")] +public abstract class PublishableContentBase +{ + public abstract IPublishedContentType ContentType { get; } + + /// + public abstract Guid Key { get; } + + /// + public abstract int Id { get; } + + /// + public abstract int SortOrder { get; } + + /// + public abstract int CreatorId { get; } + + /// + public abstract DateTime CreateDate { get; } + + /// + public abstract int WriterId { get; } + + /// + public abstract DateTime UpdateDate { get; } + + /// + public abstract IReadOnlyDictionary Cultures { get; } + + /// + public abstract PublishedItemType ItemType { get; } + + /// + public abstract bool IsDraft(string? culture = null); + + /// + public abstract bool IsPublished(string? culture = null); + + /// + public abstract IEnumerable Properties { get; } + + /// + public abstract IPublishedProperty? GetProperty(string alias); +} diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedContentBase.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedContentBase.cs index 3687e1b7f3f2..ccbfb5698a95 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedContentBase.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedContentBase.cs @@ -1,7 +1,5 @@ -using System.Diagnostics; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.DependencyInjection; -using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Services.Navigation; using Umbraco.Extensions; @@ -12,21 +10,14 @@ namespace Umbraco.Cms.Core.Models.PublishedContent /// /// This base class does which (a) consistently resolves and caches the URL, (b) provides an implementation /// for this[alias], and (c) provides basic content set management. - [DebuggerDisplay("Content Id: {Id}")] - public abstract class PublishedContentBase : IPublishedContent + // TODO ELEMENTS: correct version for the obsolete message here + [Obsolete("Please implement PublishableContentBase instead. Scheduled for removal in VXX")] + public abstract class PublishedContentBase : PublishableContentBase, IPublishedContent { private readonly IVariationContextAccessor? _variationContextAccessor; protected PublishedContentBase(IVariationContextAccessor? variationContextAccessor) => _variationContextAccessor = variationContextAccessor; - public abstract IPublishedContentType ContentType { get; } - - /// - public abstract Guid Key { get; } - - /// - public abstract int Id { get; } - /// public virtual string Name => this.Name(_variationContextAccessor); @@ -34,9 +25,6 @@ public abstract class PublishedContentBase : IPublishedContent [Obsolete("Please use GetUrlSegment() on IDocumentUrlService instead. Scheduled for removal in V16.")] public virtual string? UrlSegment => this.UrlSegment(_variationContextAccessor); - /// - public abstract int SortOrder { get; } - /// [Obsolete("Not supported for members, scheduled for removal in v17")] public abstract int Level { get; } @@ -48,30 +36,6 @@ public abstract class PublishedContentBase : IPublishedContent /// public abstract int? TemplateId { get; } - /// - public abstract int CreatorId { get; } - - /// - public abstract DateTime CreateDate { get; } - - /// - public abstract int WriterId { get; } - - /// - public abstract DateTime UpdateDate { get; } - - /// - public abstract IReadOnlyDictionary Cultures { get; } - - /// - public abstract PublishedItemType ItemType { get; } - - /// - public abstract bool IsDraft(string? culture = null); - - /// - public abstract bool IsPublished(string? culture = null); - /// [Obsolete("Please use TryGetParentKey() on IDocumentNavigationQueryService or IMediaNavigationQueryService instead. Scheduled for removal in V16.")] public abstract IPublishedContent? Parent { get; } @@ -80,13 +44,6 @@ public abstract class PublishedContentBase : IPublishedContent [Obsolete("Please use TryGetChildrenKeys() on IDocumentNavigationQueryService or IMediaNavigationQueryService instead. Scheduled for removal in V16.")] public virtual IEnumerable Children => GetChildren(); - - /// - public abstract IEnumerable Properties { get; } - - /// - public abstract IPublishedProperty? GetProperty(string alias); - private IEnumerable GetChildren() { INavigationQueryService? navigationQueryService; diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedContentExtensionsForModels.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedContentExtensionsForModels.cs index 2b123a33a948..4370c7cfd28a 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedContentExtensionsForModels.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedContentExtensionsForModels.cs @@ -16,6 +16,23 @@ public static class PublishedContentExtensionsForModels public static IPublishedContent? CreateModel( this IPublishedContent? content, IPublishedModelFactory? publishedModelFactory) + => CreateModel(content, publishedModelFactory); + + /// + /// Creates a strongly typed published content model for an internal published element. + /// + /// The internal published element. + /// The published model factory + /// The strongly typed published element model. + public static IPublishedElement? CreateModel( + this IPublishedElement? element, + IPublishedModelFactory? publishedModelFactory) + => CreateModel(element, publishedModelFactory); + + private static T? CreateModel( + IPublishedElement? content, + IPublishedModelFactory? publishedModelFactory) + where T : IPublishedElement { if (publishedModelFactory == null) { @@ -24,7 +41,7 @@ public static class PublishedContentExtensionsForModels if (content == null) { - return null; + return default; } // get model @@ -36,10 +53,10 @@ public static class PublishedContentExtensionsForModels } // if factory returns a different type, throw - if (!(model is IPublishedContent publishedContent)) + if (!(model is T publishedContent)) { throw new InvalidOperationException( - $"Factory returned model of type {model.GetType().FullName} which does not implement IPublishedContent."); + $"Factory returned model of type {model.GetType().FullName} which does not implement {typeof(T).Name}."); } return publishedContent; diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedContentWrapped.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedContentWrapped.cs index 807943edff04..cfaf985c19d5 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedContentWrapped.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedContentWrapped.cs @@ -22,6 +22,7 @@ namespace Umbraco.Cms.Core.Models.PublishedContent; /// wrap and extend another IPublishedContent. /// [DebuggerDisplay("{Id}: {Name} ({ContentType?.Alias})")] +// TODO ELEMENTS: this should probably inherit PublishedElementWrapped, instead of all this code duplication public abstract class PublishedContentWrapped : IPublishedContent { private readonly IPublishedContent _content; diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedElementWrapped.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedElementWrapped.cs index d56230cbfad0..07670b9f0f1a 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedElementWrapped.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedElementWrapped.cs @@ -33,6 +33,28 @@ protected PublishedElementWrapped(IPublishedElement content, IPublishedValueFall /// public IPublishedProperty? GetProperty(string alias) => _content.GetProperty(alias); + public int Id => _content.Id; + + public string Name => _content.Name; + + public int SortOrder => _content.SortOrder; + + public int CreatorId => _content.CreatorId; + + public DateTime CreateDate => _content.CreateDate; + + public int WriterId => _content.WriterId; + + public DateTime UpdateDate => _content.UpdateDate; + + public IReadOnlyDictionary Cultures => _content.Cultures; + + public PublishedItemType ItemType => _content.ItemType; + + public bool IsDraft(string? culture = null) => _content.IsDraft(culture); + + public bool IsPublished(string? culture = null) => _content.IsPublished(culture); + /// /// Gets the wrapped content. /// diff --git a/src/Umbraco.Core/Models/UmbracoObjectTypes.cs b/src/Umbraco.Core/Models/UmbracoObjectTypes.cs index 6c8e8d746662..2ca8060ea0c8 100644 --- a/src/Umbraco.Core/Models/UmbracoObjectTypes.cs +++ b/src/Umbraco.Core/Models/UmbracoObjectTypes.cs @@ -180,4 +180,20 @@ public enum UmbracoObjectTypes [UmbracoObjectType(Constants.ObjectTypes.Strings.IdReservation)] [FriendlyName("Identifier Reservation")] IdReservation, + + /// + /// Element + /// + [UmbracoObjectType(Constants.ObjectTypes.Strings.Element, typeof(IElement))] + [FriendlyName("Element")] + [UmbracoUdiType(Constants.UdiEntityType.Element)] + Element, + + /// + /// Element container. + /// + [UmbracoObjectType(Constants.ObjectTypes.Strings.ElementContainer)] + [FriendlyName("Element Container")] + [UmbracoUdiType(Constants.UdiEntityType.ElementContainer)] + ElementContainer, } diff --git a/src/Umbraco.Core/Notifications/ElementCacheRefresherNotification.cs b/src/Umbraco.Core/Notifications/ElementCacheRefresherNotification.cs new file mode 100644 index 000000000000..f1937471d399 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementCacheRefresherNotification.cs @@ -0,0 +1,23 @@ +using Umbraco.Cms.Core.Sync; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the Element Cache Refresher. +/// +public class ElementCacheRefresherNotification : CacheRefresherNotification +{ + /// + /// Initializes a new instance of the + /// + /// + /// The refresher payload. + /// + /// + /// Type of the cache refresher message, + /// + public ElementCacheRefresherNotification(object messageObject, MessageType messageType) + : base(messageObject, messageType) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementDeletedNotification.cs b/src/Umbraco.Core/Notifications/ElementDeletedNotification.cs new file mode 100644 index 000000000000..acafafd2f174 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementDeletedNotification.cs @@ -0,0 +1,15 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the IElementService when the Delete and EmptyRecycleBin methods are called in the API. +/// +public sealed class ElementDeletedNotification : DeletedNotification +{ + public ElementDeletedNotification(IElement target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementDeletedVersionsNotification.cs b/src/Umbraco.Core/Notifications/ElementDeletedVersionsNotification.cs new file mode 100644 index 000000000000..f70a2f6bf044 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementDeletedVersionsNotification.cs @@ -0,0 +1,38 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the IElementService when the DeleteVersion and DeleteVersions methods are called in the API, and the version has been deleted. +/// +public sealed class ElementDeletedVersionsNotification : DeletedVersionsNotification +{ + /// + /// Initializes a new instance of the . + /// + /// + /// Gets the ID of the object being deleted. + /// + /// + /// Initializes a new instance of the . + /// + /// + /// Gets the id of the IElement object version being deleted. + /// + /// + /// False by default. + /// + /// + /// Gets the latest version date. + /// + public ElementDeletedVersionsNotification( + int id, + EventMessages messages, + int specificVersion = default, + bool deletePriorVersions = false, + DateTime dateToRetain = default) + : base(id, messages, specificVersion, deletePriorVersions, dateToRetain) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementDeletingNotification.cs b/src/Umbraco.Core/Notifications/ElementDeletingNotification.cs new file mode 100644 index 000000000000..aec5e6419d6c --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementDeletingNotification.cs @@ -0,0 +1,20 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the IElementService when the DeleteElementOfType, Delete and EmptyRecycleBin methods are called in the API. +/// +public sealed class ElementDeletingNotification : DeletingNotification +{ + public ElementDeletingNotification(IElement target, EventMessages messages) + : base(target, messages) + { + } + + public ElementDeletingNotification(IEnumerable target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementDeletingVersionsNotification.cs b/src/Umbraco.Core/Notifications/ElementDeletingVersionsNotification.cs new file mode 100644 index 000000000000..0a499fe248c6 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementDeletingVersionsNotification.cs @@ -0,0 +1,33 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the IElementService when the DeleteVersion and DeleteVersions methods are called in the API. +/// +public sealed class ElementDeletingVersionsNotification : DeletingVersionsNotification +{ + /// + /// Initializes a new instance of the . + /// + /// + /// Gets the ID of the object being deleted. + /// + /// + /// Initializes a new instance of the . + /// + /// + /// Gets the id of the IElement object version being deleted. + /// + /// + /// False by default. + /// + /// + /// Gets the latest version date. + /// + public ElementDeletingVersionsNotification(int id, EventMessages messages, int specificVersion = default, bool deletePriorVersions = false, DateTime dateToRetain = default) + : base(id, messages, specificVersion, deletePriorVersions, dateToRetain) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementPublishedNotification.cs b/src/Umbraco.Core/Notifications/ElementPublishedNotification.cs new file mode 100644 index 000000000000..69d9b4713173 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementPublishedNotification.cs @@ -0,0 +1,26 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the IElementService when the Publish method is called in the API and after data has been published. +/// Called after an element has been published. +/// +public sealed class ElementPublishedNotification : EnumerableObjectNotification +{ + public ElementPublishedNotification(IElement target, EventMessages messages) + : base(target, messages) + { + } + + public ElementPublishedNotification(IEnumerable target, EventMessages messages) + : base(target, messages) + { + } + + /// + /// Gets a enumeration of which are being published. + /// + public IEnumerable PublishedEntities => Target; +} diff --git a/src/Umbraco.Core/Notifications/ElementPublishingNotification.cs b/src/Umbraco.Core/Notifications/ElementPublishingNotification.cs new file mode 100644 index 000000000000..6e6cdad27353 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementPublishingNotification.cs @@ -0,0 +1,26 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the IElementService when the Publishing method is called in the API. +/// Called while publishing an element but before the element has been published. Cancel the operation to prevent the publish. +/// +public sealed class ElementPublishingNotification : CancelableEnumerableObjectNotification +{ + public ElementPublishingNotification(IElement target, EventMessages messages) + : base(target, messages) + { + } + + public ElementPublishingNotification(IEnumerable target, EventMessages messages) + : base(target, messages) + { + } + + /// + /// Gets a enumeration of which are being published. + /// + public IEnumerable PublishedEntities => Target; +} diff --git a/src/Umbraco.Core/Notifications/ElementSavedNotification.cs b/src/Umbraco.Core/Notifications/ElementSavedNotification.cs new file mode 100644 index 000000000000..786d82c85e73 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementSavedNotification.cs @@ -0,0 +1,26 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the IElementService when the Save method is called in the API and after the data has been persisted. +/// +public sealed class ElementSavedNotification : SavedNotification +{ + /// + /// Initializes a new instance of the + /// + public ElementSavedNotification(IElement target, EventMessages messages) + : base(target, messages) + { + } + + /// + /// Gets a enumeration of . + /// + public ElementSavedNotification(IEnumerable target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementSavingNotification.cs b/src/Umbraco.Core/Notifications/ElementSavingNotification.cs new file mode 100644 index 000000000000..8382a321ff03 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementSavingNotification.cs @@ -0,0 +1,26 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the IElementService when the Save method is called in the API. +/// +public sealed class ElementSavingNotification : SavingNotification +{ + /// + /// Initializes a new instance of the + /// + public ElementSavingNotification(IElement target, EventMessages messages) + : base(target, messages) + { + } + + /// + /// Gets a enumeration of . + /// + public ElementSavingNotification(IEnumerable target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementTreeChangeNotification.cs b/src/Umbraco.Core/Notifications/ElementTreeChangeNotification.cs new file mode 100644 index 000000000000..75db23a35395 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementTreeChangeNotification.cs @@ -0,0 +1,45 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.Changes; + +namespace Umbraco.Cms.Core.Notifications; + +public sealed class ElementTreeChangeNotification : TreeChangeNotification +{ + public ElementTreeChangeNotification(TreeChange target, EventMessages messages) + : base(target, messages) + { + } + + public ElementTreeChangeNotification(IEnumerable> target, EventMessages messages) + : base( + target, messages) + { + } + + public ElementTreeChangeNotification( + IEnumerable target, + TreeChangeTypes changeTypes, + EventMessages messages) + : base(target.Select(x => new TreeChange(x, changeTypes)), messages) + { + } + + public ElementTreeChangeNotification( + IElement target, + TreeChangeTypes changeTypes, + EventMessages messages) + : base(new TreeChange(target, changeTypes), messages) + { + } + + public ElementTreeChangeNotification( + IElement target, + TreeChangeTypes changeTypes, + IEnumerable? publishedCultures, + IEnumerable? unpublishedCultures, + EventMessages messages) + : base(new TreeChange(target, changeTypes, publishedCultures, unpublishedCultures), messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementUnpublishedNotification.cs b/src/Umbraco.Core/Notifications/ElementUnpublishedNotification.cs new file mode 100644 index 000000000000..17fbf4e67d8e --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementUnpublishedNotification.cs @@ -0,0 +1,24 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the IElementService when the UnPublish method is called in the API and after data has been unpublished. +/// +public sealed class ElementUnpublishedNotification : EnumerableObjectNotification +{ + public ElementUnpublishedNotification(IElement target, EventMessages messages) + : base(target, messages) + { + } + + public ElementUnpublishedNotification(IEnumerable target, EventMessages messages) + : base(target, messages) + { + } + /// + /// Gets a enumeration of which are being unpublished. + /// + public IEnumerable UnpublishedEntities => Target; +} diff --git a/src/Umbraco.Core/Notifications/ElementUnpublishingNotification.cs b/src/Umbraco.Core/Notifications/ElementUnpublishingNotification.cs new file mode 100644 index 000000000000..67349fcf0b46 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementUnpublishingNotification.cs @@ -0,0 +1,24 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the IElementService when the UnPublishing method is called in the API. +/// +public sealed class ElementUnpublishingNotification : CancelableEnumerableObjectNotification +{ + public ElementUnpublishingNotification(IElement target, EventMessages messages) + : base(target, messages) + { + } + + public ElementUnpublishingNotification(IEnumerable target, EventMessages messages) + : base(target, messages) + { + } + /// + /// Gets a enumeration of which are being unpublished. + /// + public IEnumerable UnpublishedEntities => Target; +} diff --git a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs index cfa564ca43ef..8165645db889 100644 --- a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs +++ b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs @@ -37,6 +37,9 @@ public static class Tables public const string Document = TableNamePrefix + "Document"; public const string DocumentCultureVariation = TableNamePrefix + "DocumentCultureVariation"; public const string DocumentVersion = TableNamePrefix + "DocumentVersion"; + public const string Element = TableNamePrefix + "Element"; + public const string ElementCultureVariation = TableNamePrefix + "ElementCultureVariation"; + public const string ElementVersion = TableNamePrefix + "ElementVersion"; public const string DocumentUrl = TableNamePrefix + "DocumentUrl"; public const string MediaVersion = TableNamePrefix + "MediaVersion"; public const string ContentSchedule = TableNamePrefix + "ContentSchedule"; diff --git a/src/Umbraco.Core/Persistence/Constants-Locks.cs b/src/Umbraco.Core/Persistence/Constants-Locks.cs index cc6c5d48538e..cec443fc6199 100644 --- a/src/Umbraco.Core/Persistence/Constants-Locks.cs +++ b/src/Umbraco.Core/Persistence/Constants-Locks.cs @@ -90,5 +90,10 @@ public static class Locks /// All document URLs. /// public const int DocumentUrls = -345; + + /// + /// The entire element tree, i.e. all element items. + /// + public const int ElementTree = -346; } } diff --git a/src/Umbraco.Core/Persistence/Repositories/IDocumentRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IDocumentRepository.cs index f6ee0b692651..5f0452f01c43 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IDocumentRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IDocumentRepository.cs @@ -3,64 +3,8 @@ namespace Umbraco.Cms.Core.Persistence.Repositories; -public interface IDocumentRepository : IContentRepository, IReadRepository +public interface IDocumentRepository : IPublishableContentRepository { - /// - /// Gets publish/unpublish schedule for a content node. - /// - /// - /// - /// - /// - ContentScheduleCollection GetContentSchedule(int contentId); - - /// - /// Persists publish/unpublish schedule for a content node. - /// - /// - /// - void PersistContentSchedule(IContent content, ContentScheduleCollection schedule); - - /// - /// Clears the publishing schedule for all entries having an a date before (lower than, or equal to) a specified date. - /// - void ClearSchedule(DateTime date); - - void ClearSchedule(DateTime date, ContentScheduleAction action); - - bool HasContentForExpiration(DateTime date); - - bool HasContentForRelease(DateTime date); - - /// - /// Gets objects having an expiration date before (lower than, or equal to) a specified date. - /// - /// - /// The content returned from this method may be culture variant, in which case you can use - /// to get the status for a specific culture. - /// - IEnumerable GetContentForExpiration(DateTime date); - - /// - /// Gets objects having a release date before (lower than, or equal to) a specified date. - /// - /// - /// The content returned from this method may be culture variant, in which case you can use - /// to get the status for a specific culture. - /// - IEnumerable GetContentForRelease(DateTime date); - - /// - /// Get the count of published items - /// - /// - /// - /// We require this on the repo because the IQuery{IContent} cannot supply the 'newest' parameter - /// - int CountPublished(string? contentTypeAlias = null); - - bool IsPathPublished(IContent? content); - /// /// 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. @@ -93,4 +37,6 @@ public interface IDocumentRepository : IContentRepository, IReadR /// Returns true if there is any content in the recycle bin /// bool RecycleBinSmells(); + + bool IsPathPublished(IContent? content); } diff --git a/src/Umbraco.Core/Persistence/Repositories/IElementContainerRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IElementContainerRepository.cs new file mode 100644 index 000000000000..85eeaaf09dca --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IElementContainerRepository.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Persistence.Repositories; + +public interface IElementContainerRepository : IEntityContainerRepository +{ +} diff --git a/src/Umbraco.Core/Persistence/Repositories/IElementRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IElementRepository.cs new file mode 100644 index 000000000000..cf9f9f2dc9e0 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IElementRepository.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Persistence.Repositories; + +public interface IElementRepository : IPublishableContentRepository +{ +} diff --git a/src/Umbraco.Core/Persistence/Repositories/IPublishableContentRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IPublishableContentRepository.cs new file mode 100644 index 000000000000..f543ad23336e --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IPublishableContentRepository.cs @@ -0,0 +1,66 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Persistence.Repositories; + +// TODO ELEMENTS: fully define this interface +public interface IPublishableContentRepository : IContentRepository, + IReadRepository + where TContent : IPublishableContentBase +{ + /// + /// Gets publish/unpublish schedule for a content node. + /// + /// + /// + /// + /// + ContentScheduleCollection GetContentSchedule(int contentId); + + /// + /// Persists publish/unpublish schedule for a content node. + /// + /// + /// + void PersistContentSchedule(IPublishableContentBase content, ContentScheduleCollection schedule); + + /// + /// Clears the publishing schedule for all entries having an a date before (lower than, or equal to) a specified date. + /// + void ClearSchedule(DateTime date); + + void ClearSchedule(DateTime date, ContentScheduleAction action); + + bool HasContentForExpiration(DateTime date); + + bool HasContentForRelease(DateTime date); + + /// + /// Gets objects having an expiration date before (lower than, or equal to) a specified date. + /// + /// + /// The content returned from this method may be culture variant, in which case you can use + /// to get the status for a specific culture. + /// + IEnumerable GetContentForExpiration(DateTime date); + + /// + /// Gets objects having a release date before (lower than, or equal to) a specified date. + /// + /// + /// The content returned from this method may be culture variant, in which case you can use + /// to get the status for a specific culture. + /// + IEnumerable GetContentForRelease(DateTime date); + + /// + /// Get the count of published items + /// + /// + /// + /// We require this on the repo because the IQuery{TContent} cannot supply the 'newest' parameter + /// + int CountPublished(string? contentTypeAlias = null); + + bool IsPathPublished(TContent? content); +} + diff --git a/src/Umbraco.Core/PropertyEditors/ElementPickerPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/ElementPickerPropertyEditor.cs new file mode 100644 index 000000000000..1cbaed584cff --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/ElementPickerPropertyEditor.cs @@ -0,0 +1,39 @@ +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Editors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Strings; + +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Element picker property editor that stores element keys +/// +[DataEditor( + Constants.PropertyEditors.Aliases.ElementPicker, + ValueType = ValueTypes.Json, + ValueEditorIsReusable = true)] +public class ElementPickerPropertyEditor : DataEditor +{ + public ElementPickerPropertyEditor(IDataValueEditorFactory dataValueEditorFactory) + : base(dataValueEditorFactory) + => SupportsReadOnly = true; + + protected override IDataValueEditor CreateValueEditor() => + DataValueEditorFactory.Create(Attribute!); + + internal sealed class ElementPickerPropertyValueEditor : DataValueEditor, IDataValueReference + { + public ElementPickerPropertyValueEditor( + IShortStringHelper shortStringHelper, + IJsonSerializer jsonSerializer, + IIOHelper ioHelper, + DataEditorAttribute attribute) + : base(shortStringHelper, jsonSerializer, ioHelper, attribute) + { + } + + // TODO ELEMENTS: implement reference tracking from element picker + public IEnumerable GetReferences(object? value) => []; + } +} diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/ElementPickerValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/ElementPickerValueConverter.cs new file mode 100644 index 000000000000..4a37b16581d0 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/ElementPickerValueConverter.cs @@ -0,0 +1,75 @@ +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +public class ElementPickerValueConverter : PropertyValueConverterBase, IDeliveryApiPropertyValueConverter +{ + private readonly IJsonSerializer _jsonSerializer; + private readonly IElementCacheService _elementCacheService; + private readonly IVariationContextAccessor _variationContextAccessor; + + public ElementPickerValueConverter(IJsonSerializer jsonSerializer, IElementCacheService elementCacheService, IVariationContextAccessor variationContextAccessor) + { + _jsonSerializer = jsonSerializer; + _elementCacheService = elementCacheService; + _variationContextAccessor = variationContextAccessor; + } + + public override bool IsConverter(IPublishedPropertyType propertyType) + => Constants.PropertyEditors.Aliases.ElementPicker.Equals(propertyType.EditorAlias); + + public override Type GetPropertyValueType(IPublishedPropertyType propertyType) + => typeof(IEnumerable); + + public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType) + => PropertyCacheLevel.Elements; + + public override bool? IsValue(object? value, PropertyValueLevel level) => + value is not null && value.ToString() != "[]"; + + public override object ConvertSourceToIntermediate(IPublishedElement owner, IPublishedPropertyType propertyType, object? source, bool preview) + => source?.ToString()!; + + public override object? ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + { + var value = inter as string; + if (value.IsNullOrWhiteSpace()) + { + return null; + } + + Guid[]? keys = _jsonSerializer.Deserialize(value); + if (keys is null) + { + return null; + } + + IEnumerable elements = keys + .Select(key => _elementCacheService.GetByKeyAsync(key, preview).GetAwaiter().GetResult()) + .WhereNotNull(); + + if (preview is false && _variationContextAccessor.VariationContext?.Culture is not null) + { + elements = elements + .Where(element => element.IsPublished(_variationContextAccessor.VariationContext.Culture)); + } + + return elements.ToArray(); + } + + // TODO KJA: implement Delivery API + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) + => GetPropertyCacheLevel(propertyType); + + // TODO KJA: implement Delivery API + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) + => GetPropertyValueType(propertyType); + + // TODO KJA: implement Delivery API + public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview, bool expanding) + => null; +} diff --git a/src/Umbraco.Core/PublishedCache/IElementCacheService.cs b/src/Umbraco.Core/PublishedCache/IElementCacheService.cs new file mode 100644 index 000000000000..ee30cefb55da --- /dev/null +++ b/src/Umbraco.Core/PublishedCache/IElementCacheService.cs @@ -0,0 +1,15 @@ +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.PublishedCache; + +// TODO ELEMENTS: refactor IDocumentCacheService into a common base interface and use it both here and for IDocumentCacheService +public interface IElementCacheService +{ + Task GetByKeyAsync(Guid key, bool? preview = null); + + Task ClearMemoryCacheAsync(CancellationToken cancellationToken); + + Task RefreshMemoryCacheAsync(Guid key); + + Task RemoveFromMemoryCacheAsync(Guid key); +} diff --git a/src/Umbraco.Core/PublishedCache/PublishedElement.cs b/src/Umbraco.Core/PublishedCache/PublishedElement.cs index 9b0c6bcce664..bf768aaab94f 100644 --- a/src/Umbraco.Core/PublishedCache/PublishedElement.cs +++ b/src/Umbraco.Core/PublishedCache/PublishedElement.cs @@ -65,6 +65,10 @@ public PublishedElement(IPublishedContentType contentType, Guid key, Dictionary< }) .ToArray() ?? []; + + Name = string.Empty; + Path = string.Empty; + Cultures = new Dictionary(); } // initializes a new instance of the PublishedElement class @@ -99,4 +103,20 @@ public PublishedElement(IPublishedContentType contentType, Guid key, Dictionary< IPublishedProperty? property = index < 0 ? null : _propertiesArray?[index]; return property; } + + // TODO ELEMENTS: figure out what to do with all these + // perhaps replace this whole class with PublishedElementWrapped? in that case, we also need to do the same for PublishedContent + public int Id { get; } + public string Name { get; } + public int SortOrder { get; } + public int Level { get; } + public string Path { get; } + public int CreatorId { get; } + public DateTime CreateDate { get; } + public int WriterId { get; } + public DateTime UpdateDate { get; } + public IReadOnlyDictionary Cultures { get; } + public PublishedItemType ItemType { get; } + public bool IsDraft(string? culture = null) => throw new NotImplementedException(); + public bool IsPublished(string? culture = null) => throw new NotImplementedException(); } diff --git a/src/Umbraco.Core/Services/ContentPublishingService.cs b/src/Umbraco.Core/Services/ContentPublishingService.cs index ddabe194484c..e5c0bb5c68aa 100644 --- a/src/Umbraco.Core/Services/ContentPublishingService.cs +++ b/src/Umbraco.Core/Services/ContentPublishingService.cs @@ -1,28 +1,20 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.ContentPublishing; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services.OperationStatus; -using Umbraco.Extensions; namespace Umbraco.Cms.Core.Services; -internal sealed class ContentPublishingService : IContentPublishingService +internal sealed class ContentPublishingService : ContentPublishingServiceBase, IContentPublishingService { private const string PublishBranchOperationType = "ContentPublishBranch"; private readonly ICoreScopeProvider _coreScopeProvider; private readonly IContentService _contentService; private readonly IUserIdKeyResolver _userIdKeyResolver; - private readonly IContentValidationService _contentValidationService; - private readonly IContentTypeService _contentTypeService; - private readonly ILanguageService _languageService; - private ContentSettings _contentSettings; - private readonly IRelationService _relationService; private readonly ILogger _logger; private readonly ILongRunningOperationService _longRunningOperationService; @@ -37,231 +29,22 @@ public ContentPublishingService( IRelationService relationService, ILogger logger, ILongRunningOperationService longRunningOperationService) + : base( + coreScopeProvider, + contentService, + userIdKeyResolver, + contentValidationService, + contentTypeService, + languageService, + optionsMonitor, + relationService, + logger) { _coreScopeProvider = coreScopeProvider; _contentService = contentService; _userIdKeyResolver = userIdKeyResolver; - _contentValidationService = contentValidationService; - _contentTypeService = contentTypeService; - _languageService = languageService; - _relationService = relationService; _logger = logger; _longRunningOperationService = longRunningOperationService; - _contentSettings = optionsMonitor.CurrentValue; - optionsMonitor.OnChange((contentSettings) => - { - _contentSettings = contentSettings; - }); - } - - /// - public async Task> PublishAsync( - Guid key, - ICollection culturesToPublishOrSchedule, - Guid userKey) - { - var culturesToPublishImmediately = - culturesToPublishOrSchedule.Where(culture => culture.Schedule is null).Select(c => c.Culture ?? Constants.System.InvariantCulture).ToHashSet(); - - ContentScheduleCollection schedules = _contentService.GetContentScheduleByContentId(key); - - foreach (CulturePublishScheduleModel cultureToSchedule in culturesToPublishOrSchedule.Where(c => c.Schedule is not null)) - { - var culture = cultureToSchedule.Culture ?? Constants.System.InvariantCulture; - - if (cultureToSchedule.Schedule!.PublishDate is null) - { - schedules.RemoveIfExists(culture, ContentScheduleAction.Release); - } - else - { - schedules.AddOrUpdate(culture, cultureToSchedule.Schedule!.PublishDate.Value.UtcDateTime, ContentScheduleAction.Release); - } - - if (cultureToSchedule.Schedule!.UnpublishDate is null) - { - schedules.RemoveIfExists(culture, ContentScheduleAction.Expire); - } - else - { - schedules.AddOrUpdate(culture, cultureToSchedule.Schedule!.UnpublishDate.Value.UtcDateTime, ContentScheduleAction.Expire); - } - } - - var cultureAndSchedule = new CultureAndScheduleModel - { - CulturesToPublishImmediately = culturesToPublishImmediately, - Schedules = schedules, - }; - - return await PublishAsync(key, cultureAndSchedule, userKey); - } - - /// - [Obsolete("Use non obsoleted version instead. Scheduled for removal in v17")] - public async Task> PublishAsync( - Guid key, - CultureAndScheduleModel cultureAndSchedule, - Guid userKey) - { - using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); - IContent? content = _contentService.GetById(key); - if (content is null) - { - scope.Complete(); - return Attempt.FailWithStatus(ContentPublishingOperationStatus.ContentNotFound, new ContentPublishingResult()); - } - - // If nothing is requested for publish or scheduling, clear all schedules and publish nothing. - if (cultureAndSchedule.CulturesToPublishImmediately.Count == 0 && - cultureAndSchedule.Schedules.FullSchedule.Count == 0) - { - _contentService.PersistContentSchedule(content, cultureAndSchedule.Schedules); - scope.Complete(); - return Attempt.SucceedWithStatus( - ContentPublishingOperationStatus.Success, - new ContentPublishingResult { Content = content }); - } - - ISet culturesToPublishImmediately = cultureAndSchedule.CulturesToPublishImmediately; - - var cultures = - culturesToPublishImmediately.Union( - cultureAndSchedule.Schedules.FullSchedule.Select(x => x.Culture)).ToArray(); - - // If cultures are provided for non variant content, and they include the default culture, consider - // the request as valid for publishing the content. - // This is necessary as in a bulk publishing context the cultures are selected and provided from the - // list of languages. - bool variesByCulture = content.ContentType.VariesByCulture(); - if (!variesByCulture) - { - ILanguage? defaultLanguage = await _languageService.GetDefaultLanguageAsync(); - if (defaultLanguage is not null) - { - if (cultures.Contains(defaultLanguage.IsoCode)) - { - cultures = ["*"]; - } - - if (culturesToPublishImmediately.Contains(defaultLanguage.IsoCode)) - { - culturesToPublishImmediately = new HashSet { "*" }; - } - } - } - - if (variesByCulture) - { - if (cultures.Any() is false) - { - scope.Complete(); - return Attempt.FailWithStatus(ContentPublishingOperationStatus.CultureMissing, new ContentPublishingResult()); - } - - if (cultures.Any(x => x == Constants.System.InvariantCulture)) - { - scope.Complete(); - return Attempt.FailWithStatus(ContentPublishingOperationStatus.CannotPublishInvariantWhenVariant, new ContentPublishingResult()); - } - - IEnumerable validCultures = (await _languageService.GetAllAsync()).Select(x => x.IsoCode); - if (validCultures.ContainsAll(cultures) is false) - { - scope.Complete(); - return Attempt.FailWithStatus(ContentPublishingOperationStatus.InvalidCulture, new ContentPublishingResult()); - } - } - else - { - if (cultures.Length != 1 || cultures.Any(x => x != Constants.System.InvariantCulture)) - { - scope.Complete(); - return Attempt.FailWithStatus(ContentPublishingOperationStatus.InvalidCulture, new ContentPublishingResult()); - } - } - - ContentValidationResult validationResult = await ValidateCurrentContentAsync(content, cultures); - if (validationResult.ValidationErrors.Any()) - { - scope.Complete(); - return Attempt.FailWithStatus(ContentPublishingOperationStatus.ContentInvalid, new ContentPublishingResult - { - Content = content, - InvalidPropertyAliases = validationResult.ValidationErrors.Select(property => property.Alias).ToArray() - }); - } - - - var userId = await _userIdKeyResolver.GetAsync(userKey); - - PublishResult? result = null; - if (culturesToPublishImmediately.Any()) - { - result = _contentService.Publish(content, culturesToPublishImmediately.ToArray(), userId); - } - - if (result?.Success != false && cultureAndSchedule.Schedules.FullSchedule.Any()) - { - _contentService.PersistContentSchedule(result?.Content ?? content, cultureAndSchedule.Schedules); - result = new PublishResult( - PublishResultType.SuccessPublish, - result?.EventMessages ?? new EventMessages(), - result?.Content ?? content); - } - - scope.Complete(); - - if (result is null) - { - return Attempt.FailWithStatus(ContentPublishingOperationStatus.NothingToPublish, new ContentPublishingResult()); - } - - ContentPublishingOperationStatus contentPublishingOperationStatus = ToContentPublishingOperationStatus(result); - return contentPublishingOperationStatus is ContentPublishingOperationStatus.Success - ? Attempt.SucceedWithStatus( - ToContentPublishingOperationStatus(result), - new ContentPublishingResult { Content = content }) - : Attempt.FailWithStatus(ToContentPublishingOperationStatus(result), new ContentPublishingResult - { - Content = content, - InvalidPropertyAliases = result.InvalidProperties?.Select(property => property.Alias).ToArray() - ?? Enumerable.Empty() - }); - } - - private async Task ValidateCurrentContentAsync(IContent content, string[] cultures) - { - IEnumerable effectiveCultures = content.ContentType.VariesByCulture() - ? cultures.Union([null]) - : [null]; - - // Would be better to be able to use a mapper/factory, but currently all that functionality is very much presentation logic. - var model = new ContentUpdateModel() - { - // NOTE KJA: this needs redoing; we need to make an informed decision whether to include invariant properties, depending on if editing invariant properties is allowed on all variants, or if the default language is included in cultures - Properties = effectiveCultures.SelectMany(culture => - content.Properties.Select(property => property.PropertyType.VariesByCulture() == (culture is not null) - ? new PropertyValueModel - { - Alias = property.Alias, - Value = property.GetValue(culture: culture, segment: null, published: false), - Culture = culture - } - : null) - .WhereNotNull()) - .ToArray(), - Variants = cultures.Select(culture => new VariantModel() - { - Name = content.GetPublishName(culture) ?? string.Empty, - Culture = culture, - Segment = null - }).ToArray() - }; - - IContentType? contentType = _contentTypeService.Get(content.ContentType.Key)!; - ContentValidationResult validationResult = await _contentValidationService.ValidatePropertiesAsync(model, contentType, cultures); - return validationResult; } /// @@ -394,173 +177,6 @@ await _longRunningOperationService return MapInternalPublishingAttempt(result.Result); } - /// - public async Task> UnpublishAsync(Guid key, ISet? cultures, Guid userKey) - { - using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); - IContent? content = _contentService.GetById(key); - if (content is null) - { - scope.Complete(); - return Attempt.Fail(ContentPublishingOperationStatus.ContentNotFound); - } - - if (_contentSettings.DisableUnpublishWhenReferenced && _relationService.IsRelated(content.Id, RelationDirectionFilter.Child)) - { - scope.Complete(); - return Attempt.Fail(ContentPublishingOperationStatus.CannotUnpublishWhenReferenced); - } - - var userId = await _userIdKeyResolver.GetAsync(userKey); - - // If cultures are provided for non variant content, and they include the default culture, consider - // the request as valid for unpublishing the content. - // This is necessary as in a bulk unpublishing context the cultures are selected and provided from the - // list of languages. - if (cultures is not null && !content.ContentType.VariesByCulture()) - { - ILanguage? defaultLanguage = await _languageService.GetDefaultLanguageAsync(); - if (defaultLanguage is not null && cultures.Contains(defaultLanguage.IsoCode)) - { - cultures = null; - } - } - - Attempt attempt; - if (cultures is null) - { - attempt = await UnpublishInvariantAsync( - content, - userId); - - scope.Complete(); - return attempt; - } - - if (cultures.Any() is false) - { - scope.Complete(); - return Attempt.Fail(ContentPublishingOperationStatus.CultureMissing); - } - - if (cultures.Contains("*")) - { - attempt = await UnpublishAllCulturesAsync( - content, - userId); - } - else - { - attempt = await UnpublishMultipleCultures( - content, - cultures, - userId); - } - scope.Complete(); - - return attempt; - } - - private Task> UnpublishAllCulturesAsync(IContent content, int userId) - { - if (content.ContentType.VariesByCulture() is false) - { - return Task.FromResult(Attempt.Fail(ContentPublishingOperationStatus.CannotPublishVariantWhenNotVariant)); - } - - using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); - PublishResult result = _contentService.Unpublish(content, "*", userId); - scope.Complete(); - - ContentPublishingOperationStatus contentPublishingOperationStatus = ToContentPublishingOperationStatus(result); - return Task.FromResult(contentPublishingOperationStatus is ContentPublishingOperationStatus.Success - ? Attempt.Succeed(ToContentPublishingOperationStatus(result)) - : Attempt.Fail(ToContentPublishingOperationStatus(result))); - } - - private async Task> UnpublishMultipleCultures(IContent content, ISet cultures, int userId) - { - using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); - - if (content.ContentType.VariesByCulture() is false) - { - scope.Complete(); - return Attempt.Fail(ContentPublishingOperationStatus.CannotPublishVariantWhenNotVariant); - } - - var validCultures = (await _languageService.GetAllAsync()).Select(x => x.IsoCode).ToArray(); - - foreach (var culture in cultures) - { - if (validCultures.Contains(culture) is false) - { - scope.Complete(); - return Attempt.Fail(ContentPublishingOperationStatus.InvalidCulture); - } - - PublishResult result = _contentService.Unpublish(content, culture, userId); - - ContentPublishingOperationStatus contentPublishingOperationStatus = ToContentPublishingOperationStatus(result); - - if (contentPublishingOperationStatus is not ContentPublishingOperationStatus.Success) - { - return Attempt.Fail(ToContentPublishingOperationStatus(result)); - } - } - - scope.Complete(); - return Attempt.Succeed(ContentPublishingOperationStatus.Success); - } - - - private Task> UnpublishInvariantAsync(IContent content, int userId) - { - using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); - - if (content.ContentType.VariesByCulture()) - { - return Task.FromResult(Attempt.Fail(ContentPublishingOperationStatus.CannotPublishInvariantWhenVariant)); - } - - PublishResult result = _contentService.Unpublish(content, null, userId); - scope.Complete(); - - ContentPublishingOperationStatus contentPublishingOperationStatus = ToContentPublishingOperationStatus(result); - return Task.FromResult(contentPublishingOperationStatus is ContentPublishingOperationStatus.Success - ? Attempt.Succeed(ToContentPublishingOperationStatus(result)) - : Attempt.Fail(ToContentPublishingOperationStatus(result))); - } - - private static ContentPublishingOperationStatus ToContentPublishingOperationStatus(PublishResult publishResult) - => publishResult.Result switch - { - PublishResultType.SuccessPublish => ContentPublishingOperationStatus.Success, - PublishResultType.SuccessPublishCulture => ContentPublishingOperationStatus.Success, - PublishResultType.SuccessPublishAlready => ContentPublishingOperationStatus.Success, - PublishResultType.SuccessUnpublish => ContentPublishingOperationStatus.Success, - PublishResultType.SuccessUnpublishAlready => ContentPublishingOperationStatus.Success, - PublishResultType.SuccessUnpublishCulture => ContentPublishingOperationStatus.Success, - PublishResultType.SuccessUnpublishMandatoryCulture => ContentPublishingOperationStatus.Success, - PublishResultType.SuccessUnpublishLastCulture => ContentPublishingOperationStatus.Success, - PublishResultType.SuccessMixedCulture => ContentPublishingOperationStatus.Success, - // PublishResultType.FailedPublish => expr, <-- never used directly in a PublishResult - PublishResultType.FailedPublishPathNotPublished => ContentPublishingOperationStatus.PathNotPublished, - PublishResultType.FailedPublishHasExpired => ContentPublishingOperationStatus.HasExpired, - PublishResultType.FailedPublishAwaitingRelease => ContentPublishingOperationStatus.AwaitingRelease, - PublishResultType.FailedPublishCultureHasExpired => ContentPublishingOperationStatus.CultureHasExpired, - PublishResultType.FailedPublishCultureAwaitingRelease => ContentPublishingOperationStatus.CultureAwaitingRelease, - PublishResultType.FailedPublishIsTrashed => ContentPublishingOperationStatus.InTrash, - PublishResultType.FailedPublishCancelledByEvent => ContentPublishingOperationStatus.CancelledByEvent, - PublishResultType.FailedPublishContentInvalid => ContentPublishingOperationStatus.ContentInvalid, - PublishResultType.FailedPublishNothingToPublish => ContentPublishingOperationStatus.NothingToPublish, - PublishResultType.FailedPublishMandatoryCultureMissing => ContentPublishingOperationStatus.MandatoryCultureMissing, - PublishResultType.FailedPublishConcurrencyViolation => ContentPublishingOperationStatus.ConcurrencyViolation, - PublishResultType.FailedPublishUnsavedChanges => ContentPublishingOperationStatus.UnsavedChanges, - PublishResultType.FailedUnpublish => ContentPublishingOperationStatus.Failed, - PublishResultType.FailedUnpublishCancelledByEvent => ContentPublishingOperationStatus.CancelledByEvent, - _ => throw new ArgumentOutOfRangeException() - }; - private Attempt MapInternalPublishingAttempt( Attempt minimalAttempt) => minimalAttempt.Success diff --git a/src/Umbraco.Core/Services/ContentPublishingServiceBase.cs b/src/Umbraco.Core/Services/ContentPublishingServiceBase.cs new file mode 100644 index 000000000000..bb21cef74fc5 --- /dev/null +++ b/src/Umbraco.Core/Services/ContentPublishingServiceBase.cs @@ -0,0 +1,429 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Services; + +internal abstract class ContentPublishingServiceBase + where TContent : class, IPublishableContentBase + where TContentService : IPublishableContentService +{ + private readonly ICoreScopeProvider _coreScopeProvider; + private readonly TContentService _contentService; + private readonly IUserIdKeyResolver _userIdKeyResolver; + private readonly IContentValidationService _contentValidationService; + private readonly IContentTypeService _contentTypeService; + private readonly ILanguageService _languageService; + private ContentSettings _contentSettings; + private readonly IRelationService _relationService; + private readonly ILogger> _logger; + + protected ContentPublishingServiceBase( + ICoreScopeProvider coreScopeProvider, + TContentService contentService, + IUserIdKeyResolver userIdKeyResolver, + IContentValidationService contentValidationService, + IContentTypeService contentTypeService, + ILanguageService languageService, + IOptionsMonitor optionsMonitor, + IRelationService relationService, + ILogger> logger) + { + _coreScopeProvider = coreScopeProvider; + _contentService = contentService; + _userIdKeyResolver = userIdKeyResolver; + _contentValidationService = contentValidationService; + _contentTypeService = contentTypeService; + _languageService = languageService; + _relationService = relationService; + _logger = logger; + _contentSettings = optionsMonitor.CurrentValue; + optionsMonitor.OnChange((contentSettings) => + { + _contentSettings = contentSettings; + }); + } + + /// + public async Task> PublishAsync( + Guid key, + ICollection culturesToPublishOrSchedule, + Guid userKey) + { + var culturesToPublishImmediately = + culturesToPublishOrSchedule.Where(culture => culture.Schedule is null).Select(c => c.Culture ?? Constants.System.InvariantCulture).ToHashSet(); + + ContentScheduleCollection schedules = _contentService.GetContentScheduleByContentId(key); + + foreach (CulturePublishScheduleModel cultureToSchedule in culturesToPublishOrSchedule.Where(c => c.Schedule is not null)) + { + var culture = cultureToSchedule.Culture ?? Constants.System.InvariantCulture; + + if (cultureToSchedule.Schedule!.PublishDate is null) + { + schedules.RemoveIfExists(culture, ContentScheduleAction.Release); + } + else + { + schedules.AddOrUpdate(culture, cultureToSchedule.Schedule!.PublishDate.Value.UtcDateTime, ContentScheduleAction.Release); + } + + if (cultureToSchedule.Schedule!.UnpublishDate is null) + { + schedules.RemoveIfExists(culture, ContentScheduleAction.Expire); + } + else + { + schedules.AddOrUpdate(culture, cultureToSchedule.Schedule!.UnpublishDate.Value.UtcDateTime, ContentScheduleAction.Expire); + } + } + + var cultureAndSchedule = new CultureAndScheduleModel + { + CulturesToPublishImmediately = culturesToPublishImmediately, + Schedules = schedules, + }; + + return await PublishAsync(key, cultureAndSchedule, userKey); + } + + /// + [Obsolete("Use non obsoleted version instead. Scheduled for removal in v17")] + public async Task> PublishAsync( + Guid key, + CultureAndScheduleModel cultureAndSchedule, + Guid userKey) + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + TContent? content = _contentService.GetById(key); + if (content is null) + { + scope.Complete(); + return Attempt.FailWithStatus(ContentPublishingOperationStatus.ContentNotFound, new ContentPublishingResult()); + } + + // If nothing is requested for publish or scheduling, clear all schedules and publish nothing. + if (cultureAndSchedule.CulturesToPublishImmediately.Count == 0 && + cultureAndSchedule.Schedules.FullSchedule.Count == 0) + { + _contentService.PersistContentSchedule(content, cultureAndSchedule.Schedules); + scope.Complete(); + return Attempt.SucceedWithStatus( + ContentPublishingOperationStatus.Success, + new ContentPublishingResult { Content = content }); + } + + ISet culturesToPublishImmediately = cultureAndSchedule.CulturesToPublishImmediately; + + var cultures = + culturesToPublishImmediately.Union( + cultureAndSchedule.Schedules.FullSchedule.Select(x => x.Culture)).ToArray(); + + // If cultures are provided for non variant content, and they include the default culture, consider + // the request as valid for publishing the content. + // This is necessary as in a bulk publishing context the cultures are selected and provided from the + // list of languages. + bool variesByCulture = content.ContentType.VariesByCulture(); + if (!variesByCulture) + { + ILanguage? defaultLanguage = await _languageService.GetDefaultLanguageAsync(); + if (defaultLanguage is not null) + { + if (cultures.Contains(defaultLanguage.IsoCode)) + { + cultures = ["*"]; + } + + if (culturesToPublishImmediately.Contains(defaultLanguage.IsoCode)) + { + culturesToPublishImmediately = new HashSet { "*" }; + } + } + } + + if (variesByCulture) + { + if (cultures.Any() is false) + { + scope.Complete(); + return Attempt.FailWithStatus(ContentPublishingOperationStatus.CultureMissing, new ContentPublishingResult()); + } + + if (cultures.Any(x => x == Constants.System.InvariantCulture)) + { + scope.Complete(); + return Attempt.FailWithStatus(ContentPublishingOperationStatus.CannotPublishInvariantWhenVariant, new ContentPublishingResult()); + } + + IEnumerable validCultures = (await _languageService.GetAllAsync()).Select(x => x.IsoCode); + if (validCultures.ContainsAll(cultures) is false) + { + scope.Complete(); + return Attempt.FailWithStatus(ContentPublishingOperationStatus.InvalidCulture, new ContentPublishingResult()); + } + } + else + { + if (cultures.Length != 1 || cultures.Any(x => x != Constants.System.InvariantCulture)) + { + scope.Complete(); + return Attempt.FailWithStatus(ContentPublishingOperationStatus.InvalidCulture, new ContentPublishingResult()); + } + } + + ContentValidationResult validationResult = await ValidateCurrentContentAsync(content, cultures); + if (validationResult.ValidationErrors.Any()) + { + scope.Complete(); + return Attempt.FailWithStatus(ContentPublishingOperationStatus.ContentInvalid, new ContentPublishingResult + { + Content = content, + InvalidPropertyAliases = validationResult.ValidationErrors.Select(property => property.Alias).ToArray() + }); + } + + + var userId = await _userIdKeyResolver.GetAsync(userKey); + + PublishResult? result = null; + if (culturesToPublishImmediately.Any()) + { + result = _contentService.Publish(content, culturesToPublishImmediately.ToArray(), userId); + } + + if (result?.Success != false && cultureAndSchedule.Schedules.FullSchedule.Any()) + { + _contentService.PersistContentSchedule(result?.Content ?? content, cultureAndSchedule.Schedules); + result = new PublishResult( + PublishResultType.SuccessPublish, + result?.EventMessages ?? new EventMessages(), + result?.Content ?? content); + } + + scope.Complete(); + + if (result is null) + { + return Attempt.FailWithStatus(ContentPublishingOperationStatus.NothingToPublish, new ContentPublishingResult()); + } + + ContentPublishingOperationStatus contentPublishingOperationStatus = ToContentPublishingOperationStatus(result); + return contentPublishingOperationStatus is ContentPublishingOperationStatus.Success + ? Attempt.SucceedWithStatus( + ToContentPublishingOperationStatus(result), + new ContentPublishingResult { Content = content }) + : Attempt.FailWithStatus(ToContentPublishingOperationStatus(result), new ContentPublishingResult + { + Content = content, + InvalidPropertyAliases = result.InvalidProperties?.Select(property => property.Alias).ToArray() + ?? Enumerable.Empty() + }); + } + + private async Task ValidateCurrentContentAsync(TContent content, string[] cultures) + { + IEnumerable effectiveCultures = content.ContentType.VariesByCulture() + ? cultures.Union([null]) + : [null]; + + // Would be better to be able to use a mapper/factory, but currently all that functionality is very much presentation logic. + var model = new ContentUpdateModel() + { + // NOTE KJA: this needs redoing; we need to make an informed decision whether to include invariant properties, depending on if editing invariant properties is allowed on all variants, or if the default language is included in cultures + Properties = effectiveCultures.SelectMany(culture => + content.Properties.Select(property => property.PropertyType.VariesByCulture() == (culture is not null) + ? new PropertyValueModel + { + Alias = property.Alias, + Value = property.GetValue(culture: culture, segment: null, published: false), + Culture = culture + } + : null) + .WhereNotNull()) + .ToArray(), + Variants = cultures.Select(culture => new VariantModel() + { + Name = content.GetPublishName(culture) ?? string.Empty, + Culture = culture, + Segment = null + }).ToArray() + }; + + IContentType? contentType = _contentTypeService.Get(content.ContentType.Key)!; + ContentValidationResult validationResult = await _contentValidationService.ValidatePropertiesAsync(model, contentType, cultures); + return validationResult; + } + + /// + public async Task> UnpublishAsync(Guid key, ISet? cultures, Guid userKey) + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + TContent? content = _contentService.GetById(key); + if (content is null) + { + scope.Complete(); + return Attempt.Fail(ContentPublishingOperationStatus.ContentNotFound); + } + + if (_contentSettings.DisableUnpublishWhenReferenced && _relationService.IsRelated(content.Id, RelationDirectionFilter.Child)) + { + scope.Complete(); + return Attempt.Fail(ContentPublishingOperationStatus.CannotUnpublishWhenReferenced); + } + + var userId = await _userIdKeyResolver.GetAsync(userKey); + + // If cultures are provided for non variant content, and they include the default culture, consider + // the request as valid for unpublishing the content. + // This is necessary as in a bulk unpublishing context the cultures are selected and provided from the + // list of languages. + if (cultures is not null && !content.ContentType.VariesByCulture()) + { + ILanguage? defaultLanguage = await _languageService.GetDefaultLanguageAsync(); + if (defaultLanguage is not null && cultures.Contains(defaultLanguage.IsoCode)) + { + cultures = null; + } + } + + Attempt attempt; + if (cultures is null) + { + attempt = await UnpublishInvariantAsync( + content, + userId); + + scope.Complete(); + return attempt; + } + + if (cultures.Any() is false) + { + scope.Complete(); + return Attempt.Fail(ContentPublishingOperationStatus.CultureMissing); + } + + if (cultures.Contains("*")) + { + attempt = await UnpublishAllCulturesAsync( + content, + userId); + } + else + { + attempt = await UnpublishMultipleCultures( + content, + cultures, + userId); + } + scope.Complete(); + + return attempt; + } + + private Task> UnpublishAllCulturesAsync(TContent content, int userId) + { + if (content.ContentType.VariesByCulture() is false) + { + return Task.FromResult(Attempt.Fail(ContentPublishingOperationStatus.CannotPublishVariantWhenNotVariant)); + } + + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + PublishResult result = _contentService.Unpublish(content, "*", userId); + scope.Complete(); + + ContentPublishingOperationStatus contentPublishingOperationStatus = ToContentPublishingOperationStatus(result); + return Task.FromResult(contentPublishingOperationStatus is ContentPublishingOperationStatus.Success + ? Attempt.Succeed(ToContentPublishingOperationStatus(result)) + : Attempt.Fail(ToContentPublishingOperationStatus(result))); + } + + private async Task> UnpublishMultipleCultures(TContent content, ISet cultures, int userId) + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + + if (content.ContentType.VariesByCulture() is false) + { + scope.Complete(); + return Attempt.Fail(ContentPublishingOperationStatus.CannotPublishVariantWhenNotVariant); + } + + var validCultures = (await _languageService.GetAllAsync()).Select(x => x.IsoCode).ToArray(); + + foreach (var culture in cultures) + { + if (validCultures.Contains(culture) is false) + { + scope.Complete(); + return Attempt.Fail(ContentPublishingOperationStatus.InvalidCulture); + } + + PublishResult result = _contentService.Unpublish(content, culture, userId); + + ContentPublishingOperationStatus contentPublishingOperationStatus = ToContentPublishingOperationStatus(result); + + if (contentPublishingOperationStatus is not ContentPublishingOperationStatus.Success) + { + return Attempt.Fail(ToContentPublishingOperationStatus(result)); + } + } + + scope.Complete(); + return Attempt.Succeed(ContentPublishingOperationStatus.Success); + } + + private Task> UnpublishInvariantAsync(TContent content, int userId) + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + + if (content.ContentType.VariesByCulture()) + { + return Task.FromResult(Attempt.Fail(ContentPublishingOperationStatus.CannotPublishInvariantWhenVariant)); + } + + PublishResult result = _contentService.Unpublish(content, null, userId); + scope.Complete(); + + ContentPublishingOperationStatus contentPublishingOperationStatus = ToContentPublishingOperationStatus(result); + return Task.FromResult(contentPublishingOperationStatus is ContentPublishingOperationStatus.Success + ? Attempt.Succeed(ToContentPublishingOperationStatus(result)) + : Attempt.Fail(ToContentPublishingOperationStatus(result))); + } + + protected static ContentPublishingOperationStatus ToContentPublishingOperationStatus(PublishResult publishResult) + => publishResult.Result switch + { + PublishResultType.SuccessPublish => ContentPublishingOperationStatus.Success, + PublishResultType.SuccessPublishCulture => ContentPublishingOperationStatus.Success, + PublishResultType.SuccessPublishAlready => ContentPublishingOperationStatus.Success, + PublishResultType.SuccessUnpublish => ContentPublishingOperationStatus.Success, + PublishResultType.SuccessUnpublishAlready => ContentPublishingOperationStatus.Success, + PublishResultType.SuccessUnpublishCulture => ContentPublishingOperationStatus.Success, + PublishResultType.SuccessUnpublishMandatoryCulture => ContentPublishingOperationStatus.Success, + PublishResultType.SuccessUnpublishLastCulture => ContentPublishingOperationStatus.Success, + PublishResultType.SuccessMixedCulture => ContentPublishingOperationStatus.Success, + // PublishResultType.FailedPublish => expr, <-- never used directly in a PublishResult + PublishResultType.FailedPublishPathNotPublished => ContentPublishingOperationStatus.PathNotPublished, + PublishResultType.FailedPublishHasExpired => ContentPublishingOperationStatus.HasExpired, + PublishResultType.FailedPublishAwaitingRelease => ContentPublishingOperationStatus.AwaitingRelease, + PublishResultType.FailedPublishCultureHasExpired => ContentPublishingOperationStatus.CultureHasExpired, + PublishResultType.FailedPublishCultureAwaitingRelease => ContentPublishingOperationStatus.CultureAwaitingRelease, + PublishResultType.FailedPublishIsTrashed => ContentPublishingOperationStatus.InTrash, + PublishResultType.FailedPublishCancelledByEvent => ContentPublishingOperationStatus.CancelledByEvent, + PublishResultType.FailedPublishContentInvalid => ContentPublishingOperationStatus.ContentInvalid, + PublishResultType.FailedPublishNothingToPublish => ContentPublishingOperationStatus.NothingToPublish, + PublishResultType.FailedPublishMandatoryCultureMissing => ContentPublishingOperationStatus.MandatoryCultureMissing, + PublishResultType.FailedPublishConcurrencyViolation => ContentPublishingOperationStatus.ConcurrencyViolation, + PublishResultType.FailedPublishUnsavedChanges => ContentPublishingOperationStatus.UnsavedChanges, + PublishResultType.FailedUnpublish => ContentPublishingOperationStatus.Failed, + PublishResultType.FailedUnpublishCancelledByEvent => ContentPublishingOperationStatus.CancelledByEvent, + _ => throw new ArgumentOutOfRangeException() + }; +} diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index 0c3b61a1c368..860a981d1a87 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -6,7 +6,6 @@ using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Events; -using Umbraco.Cms.Core.Exceptions; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Models.Membership; @@ -25,10 +24,8 @@ namespace Umbraco.Cms.Core.Services; /// /// Implements the content service. /// -public class ContentService : RepositoryService, IContentService +public class ContentService : PublishableContentServiceBase, IContentService { - private readonly IAuditRepository _auditRepository; - private readonly IContentTypeRepository _contentTypeRepository; private readonly IDocumentBlueprintRepository _documentBlueprintRepository; private readonly IDocumentRepository _documentRepository; private readonly IEntityRepository _entityRepository; @@ -64,12 +61,21 @@ public ContentService( IIdKeyMap idKeyMap, IOptionsMonitor optionsMonitor, IRelationService relationService) - : base(provider, loggerFactory, eventMessagesFactory) + : base( + provider, + loggerFactory, + eventMessagesFactory, + auditRepository, + contentTypeRepository, + documentRepository, + languageRepository, + propertyValidationService, + cultureImpactFactory, + propertyEditorCollection, + idKeyMap) { _documentRepository = documentRepository; _entityRepository = entityRepository; - _auditRepository = auditRepository; - _contentTypeRepository = contentTypeRepository; _documentBlueprintRepository = documentBlueprintRepository; _languageRepository = languageRepository; _propertyValidationService = propertyValidationService; @@ -231,46 +237,6 @@ public OperationResult Rollback(int id, int versionId, string culture = "*", int #endregion - #region Count - - public int CountPublished(string? contentTypeAlias = null) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.CountPublished(contentTypeAlias); - } - } - - public int Count(string? contentTypeAlias = null) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.Count(contentTypeAlias); - } - } - - public int CountChildren(int parentId, string? contentTypeAlias = null) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.CountChildren(parentId, contentTypeAlias); - } - } - - public int CountDescendants(int parentId, string? contentTypeAlias = null) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.CountDescendants(parentId, contentTypeAlias); - } - } - - #endregion - #region Permissions /// @@ -508,94 +474,6 @@ public IContent CreateAndSave(string name, IContent parent, string contentTypeAl #region Get, Has, Is - /// - /// Gets an object by Id - /// - /// Id of the Content to retrieve - /// - /// - /// - public IContent? GetById(int id) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(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 (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - IEnumerable items = _documentRepository.GetMany(idsA); - var index = items.ToDictionary(x => x.Id, x => x); - return idsA.Select(x => index.GetValueOrDefault(x)).WhereNotNull(); - } - } - - /// - /// Gets an object by its 'UniqueId' - /// - /// Guid key of the Content to retrieve - /// - /// - /// - public IContent? GetById(Guid key) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.Get(key); - } - } - - /// - public ContentScheduleCollection GetContentScheduleByContentId(int contentId) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.GetContentSchedule(contentId); - } - } - - public ContentScheduleCollection GetContentScheduleByContentId(Guid contentId) - { - Attempt idAttempt = _idKeyMap.GetIdForKey(contentId, UmbracoObjectTypes.Document); - if (idAttempt.Success is false) - { - return new ContentScheduleCollection(); - } - - return GetContentScheduleByContentId(idAttempt.Result); - } - - /// - public void PersistContentSchedule(IContent content, ContentScheduleCollection contentSchedule) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - scope.WriteLock(Constants.Locks.ContentTree); - _documentRepository.PersistContentSchedule(content, contentSchedule); - scope.Complete(); - } - } - /// /// /// @@ -604,99 +482,6 @@ public void PersistContentSchedule(IContent content, ContentScheduleCollection c Attempt IContentServiceBase.Save(IEnumerable contents, int userId) => Attempt.Succeed(Save(contents, userId)); - /// - /// Gets objects by Ids - /// - /// Ids of the Content to retrieve - /// - /// - /// - public IEnumerable GetByIds(IEnumerable ids) - { - Guid[] idsA = ids.ToArray(); - if (idsA.Length == 0) - { - return Enumerable.Empty(); - } - - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - IEnumerable? items = _documentRepository.GetMany(idsA); - - if (items is not null) - { - var index = items.ToDictionary(x => x.Key, x => x); - - return idsA.Select(x => index.GetValueOrDefault(x)).WhereNotNull(); - } - - return Enumerable.Empty(); - } - } - - /// - public IEnumerable GetPagedOfType( - int contentTypeId, - long pageIndex, - int pageSize, - out long totalRecords, - IQuery? filter = null, - Ordering? ordering = null) - { - if (pageIndex < 0) - { - throw new ArgumentOutOfRangeException(nameof(pageIndex)); - } - - if (pageSize <= 0) - { - throw new ArgumentOutOfRangeException(nameof(pageSize)); - } - - ordering ??= Ordering.By("sortOrder"); - - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.GetPage( - Query()?.Where(x => x.ContentTypeId == contentTypeId), - pageIndex, - pageSize, - out totalRecords, - filter, - ordering); - } - } - - /// - public IEnumerable GetPagedOfTypes(int[] contentTypeIds, long pageIndex, int pageSize, out long totalRecords, IQuery? filter, Ordering? ordering = null) - { - if (pageIndex < 0) - { - throw new ArgumentOutOfRangeException(nameof(pageIndex)); - } - - if (pageSize <= 0) - { - throw new ArgumentOutOfRangeException(nameof(pageSize)); - } - - ordering ??= Ordering.By("sortOrder"); - - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.GetPage( - Query()?.Where(x => contentTypeIds.Contains(x.ContentTypeId)), - pageIndex, - pageSize, - out totalRecords, - filter, - ordering); - } - } - /// /// Gets a collection of objects by Level /// @@ -713,61 +498,6 @@ public IEnumerable GetByLevel(int level) } } - /// - /// Gets a specific version of an item. - /// - /// Id of the version to retrieve - /// An item - public IContent? GetVersion(int versionId) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(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 (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.GetAllVersions(id); - } - } - - /// - /// Gets a collection of an objects versions by Id - /// - /// An Enumerable list of objects - public IEnumerable GetVersionsSlim(int id, int skip, int take) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.GetAllVersionsSlim(id, skip, take); - } - } - - /// - /// Gets a list of all version Ids for the given content item ordered so latest is first - /// - /// - /// The maximum number of rows to return - /// - public IEnumerable GetVersionIds(int id, int maxRows) - { - using (ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _documentRepository.GetVersionIds(id, maxRows); - } - } - /// /// Gets a collection of objects, which are ancestors of the current content. /// @@ -920,22 +650,6 @@ private IEnumerable GetPagedLocked(IQuery? query, long pageI 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 || - content is null) - { - return null; - } - - return GetById(content.ParentId); - } - /// /// Gets a collection of objects, which reside at the first level / root /// @@ -963,26 +677,6 @@ internal IEnumerable GetAllPublished() } } - /// - public IEnumerable GetContentForExpiration(DateTime date) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.GetContentForExpiration(date); - } - } - - /// - public IEnumerable GetContentForRelease(DateTime date) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.GetContentForRelease(date); - } - } - /// /// Gets a collection of an objects, which resides in the Recycle Bin /// @@ -1000,13 +694,6 @@ public IEnumerable GetPagedContentInRecycleBin(long pageIndex, int pag } } - /// - /// Checks whether an item has any children - /// - /// Id of the - /// True if the content has any children otherwise False - public bool HasChildren(int id) => CountChildren(id) > 0; - /// /// Checks if the passed in can be published based on the ancestors publish state. /// @@ -1030,923 +717,56 @@ public bool IsPathPublishable(IContent content) return parent == null || IsPathPublished(parent); } - public bool IsPathPublished(IContent? content) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentRepository.IsPathPublished(content); - } - } - #endregion #region Save, Publish, Unpublish - /// - public OperationResult Save(IContent content, int? userId = null, ContentScheduleCollection? contentSchedule = null) - { - PublishedState publishedState = content.PublishedState; - if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished) - { - throw new InvalidOperationException( - $"Cannot save (un)publishing content with name: {content.Name} - and state: {content.PublishedState}, use the dedicated SavePublished method."); - } - - if (content.Name != null && content.Name.Length > 255) - { - throw new InvalidOperationException( - $"Content with the name {content.Name} cannot be more than 255 characters in length."); - } - - EventMessages eventMessages = EventMessagesFactory.Get(); - - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - var savingNotification = new ContentSavingNotification(content, eventMessages); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - scope.Complete(); - return OperationResult.Cancel(eventMessages); - } - - scope.WriteLock(Constants.Locks.ContentTree); - userId ??= Constants.Security.SuperUserId; - - if (content.HasIdentity == false) - { - content.CreatorId = userId.Value; - } - - content.WriterId = userId.Value; - - // track the cultures that have changed - List? culturesChanging = content.ContentType.VariesByCulture() - ? content.CultureInfos?.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList() - : 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. - _documentRepository.Save(content); - - if (contentSchedule != null) - { - _documentRepository.PersistContentSchedule(content, contentSchedule); - } - - scope.Notifications.Publish( - new ContentSavedNotification(content, eventMessages).WithStateFrom(savingNotification)); - - // TODO: we had code here to FORCE that this event can never be suppressed. But that just doesn't make a ton of sense?! - // I understand that if its suppressed that the caches aren't updated, but that would be expected. If someone - // is supressing events then I think it's expected that nothing will happen. They are probably doing it for perf - // reasons like bulk import and in those cases we don't want this occuring. - scope.Notifications.Publish( - new ContentTreeChangeNotification(content, TreeChangeTypes.RefreshNode, eventMessages)); - - if (culturesChanging != null) - { - var langs = GetLanguageDetailsForAuditEntry(culturesChanging); - Audit(AuditType.SaveVariant, userId.Value, content.Id, $"Saved languages: {langs}", langs); - } - else - { - Audit(AuditType.Save, userId.Value, content.Id); - } - - scope.Complete(); - } - - return OperationResult.Succeed(eventMessages); - } - - /// - public OperationResult Save(IEnumerable contents, int userId = Constants.Security.SuperUserId) - { - EventMessages eventMessages = EventMessagesFactory.Get(); - IContent[] contentsA = contents.ToArray(); - - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - var savingNotification = new ContentSavingNotification(contentsA, eventMessages); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - scope.Complete(); - return OperationResult.Cancel(eventMessages); - } - - scope.WriteLock(Constants.Locks.ContentTree); - foreach (IContent content in contentsA) - { - if (content.HasIdentity == false) - { - content.CreatorId = userId; - } - - content.WriterId = userId; - - _documentRepository.Save(content); - } - - scope.Notifications.Publish( - new ContentSavedNotification(contentsA, eventMessages).WithStateFrom(savingNotification)); - - // TODO: See note above about supressing events - scope.Notifications.Publish( - new ContentTreeChangeNotification(contentsA, TreeChangeTypes.RefreshNode, eventMessages)); - - string contentIds = string.Join(", ", contentsA.Select(x => x.Id)); - Audit(AuditType.Save, userId, Constants.System.Root, $"Saved multiple content items (#{contentIds.Length})"); - - scope.Complete(); - } - - return OperationResult.Succeed(eventMessages); - } - - /// - 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.Now; - foreach (CultureImpact? impact in impacts) - { - content.PublishCulture(impact, publishTime, _propertyEditorCollection); - } - - // Change state to publishing - content.PublishedState = PublishedState.Publishing; - - PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, new Dictionary(), userId); - scope.Complete(); - return result; - } - } - - /// - public PublishResult Unpublish(IContent content, string? culture = "*", int userId = Constants.Security.SuperUserId) - { - if (content == null) - { - throw new ArgumentNullException(nameof(content)); - } - - EventMessages evtMsgs = EventMessagesFactory.Get(); - - culture = culture?.NullOrWhiteSpaceAsNull().EnsureCultureCode(); - - PublishedState publishedState = content.PublishedState; - if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished) - { - throw new InvalidOperationException( - $"Cannot save-and-publish (un)publishing content, use the dedicated {nameof(CommitDocumentChanges)} method."); - } - - // cannot accept invariant (null or empty) culture for variant content type - // cannot accept a specific culture for invariant content type (but '*' is ok) - if (content.ContentType.VariesByCulture()) - { - if (culture == null) - { - throw new NotSupportedException("Invariant culture is not supported by variant content types."); - } - } - else - { - if (culture != null && culture != "*") - { - throw new NotSupportedException( - $"Culture \"{culture}\" is not supported by invariant content types."); - } - } - - // if the content is not published, nothing to do - if (!content.Published) - { - return new PublishResult(PublishResultType.SuccessUnpublishAlready, evtMsgs, content); - } - - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - scope.WriteLock(Constants.Locks.ContentTree); - - var allLangs = _languageRepository.GetMany().ToList(); - - var savingNotification = new ContentSavingNotification(content, evtMsgs); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, content); - } - - // all cultures = unpublish whole - if (culture == "*" || (!content.ContentType.VariesByCulture() && culture == null)) - { - // Unpublish the culture, this will change the document state to Publishing! ... which is expected because this will - // essentially be re-publishing the document with the requested culture removed - // We are however unpublishing all cultures, so we will set this to unpublishing. - content.UnpublishCulture(culture); - content.PublishedState = PublishedState.Unpublishing; - PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, savingNotification.State, userId); - scope.Complete(); - return result; - } - else - { - // Unpublish the culture, this will change the document state to Publishing! ... which is expected because this will - // essentially be re-publishing the document with the requested culture removed. - // The call to CommitDocumentChangesInternal will perform all the checks like if this is a mandatory culture or the last culture being unpublished - // and will then unpublish the document accordingly. - // If the result of this is false it means there was no culture to unpublish (i.e. it was already unpublished or it did not exist) - var removed = content.UnpublishCulture(culture); - - // Save and publish any changes - PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, savingNotification.State, userId); - - scope.Complete(); - - // In one case the result will be PublishStatusType.FailedPublishNothingToPublish which means that no cultures - // were specified to be published which will be the case when removed is false. In that case - // we want to swap the result type to PublishResultType.SuccessUnpublishAlready (that was the expectation before). - if (result.Result == PublishResultType.FailedPublishNothingToPublish && !removed) - { - return new PublishResult(PublishResultType.SuccessUnpublishAlready, evtMsgs, content); - } - - return result; - } - } - } - - /// - /// Publishes/unpublishes any pending publishing changes made to the document. - /// - /// - /// - /// This MUST NOT be called from within this service, this used to be a public API and must only be used outside of - /// this service. - /// Internally in this service, calls must be made to CommitDocumentChangesInternal - /// - /// This is the underlying logic for both publishing and unpublishing any document - /// - /// Pending publishing/unpublishing changes on a document are made with calls to - /// and - /// . - /// - /// - /// When publishing or unpublishing a single culture, or all cultures, use - /// and . But if the flexibility to both publish and unpublish in a single operation is - /// required - /// then this method needs to be used in combination with - /// and - /// on the content itself - this prepares the content, but does not commit anything - and then, invoke - /// to actually commit the changes to the database. - /// - /// The document is *always* saved, even when publishing fails. - /// - internal PublishResult CommitDocumentChanges(IContent content, int userId = Constants.Security.SuperUserId) - { - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - EventMessages evtMsgs = EventMessagesFactory.Get(); - - scope.WriteLock(Constants.Locks.ContentTree); - - var savingNotification = new ContentSavingNotification(content, evtMsgs); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, content); - } - - var allLangs = _languageRepository.GetMany().ToList(); - - PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, savingNotification.State, userId); - scope.Complete(); - return result; - } - } - - /// - /// Handles a lot of business logic cases for how the document should be persisted - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// Business logic cases such: as unpublishing a mandatory culture, or unpublishing the last culture, checking for - /// pending scheduled publishing, etc... is dealt with in this method. - /// There is quite a lot of cases to take into account along with logic that needs to deal with scheduled - /// saving/publishing, branch saving/publishing, etc... - /// - /// - private PublishResult CommitDocumentChangesInternal( - ICoreScope scope, - IContent content, - EventMessages eventMessages, - IReadOnlyCollection allLangs, - IDictionary? notificationState, - int userId, - bool branchOne = false, - bool branchRoot = false) - { - if (scope == null) - { - throw new ArgumentNullException(nameof(scope)); - } - - if (content == null) - { - throw new ArgumentNullException(nameof(content)); - } - - if (eventMessages == null) - { - throw new ArgumentNullException(nameof(eventMessages)); - } - - PublishResult? publishResult = null; - PublishResult? unpublishResult = null; - - // nothing set = republish it all - if (content.PublishedState != PublishedState.Publishing && - content.PublishedState != PublishedState.Unpublishing) - { - content.PublishedState = PublishedState.Publishing; - } - - // State here is either Publishing or Unpublishing - // Publishing to unpublish a culture may end up unpublishing everything so these flags can be flipped later - var publishing = content.PublishedState == PublishedState.Publishing; - var unpublishing = content.PublishedState == PublishedState.Unpublishing; - - var variesByCulture = content.ContentType.VariesByCulture(); - - // Track cultures that are being published, changed, unpublished - IReadOnlyList? culturesPublishing = null; - IReadOnlyList? culturesUnpublishing = null; - IReadOnlyList? culturesChanging = variesByCulture - ? content.CultureInfos?.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList() - : null; - - var isNew = !content.HasIdentity; - TreeChangeTypes changeType = isNew ? TreeChangeTypes.RefreshNode : TreeChangeTypes.RefreshBranch; - var previouslyPublished = content.HasIdentity && content.Published; - - // Inline method to persist the document with the documentRepository since this logic could be called a couple times below - void SaveDocument(IContent c) - { - // save, always - if (c.HasIdentity == false) - { - c.CreatorId = userId; - } - - c.WriterId = userId; - - // saving does NOT change the published version, unless PublishedState is Publishing or Unpublishing - _documentRepository.Save(c); - } - - if (publishing) - { - // Determine cultures publishing/unpublishing which will be based on previous calls to content.PublishCulture and ClearPublishInfo - culturesUnpublishing = content.GetCulturesUnpublishing(); - culturesPublishing = variesByCulture - ? content.PublishCultureInfos?.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList() - : null; - - // ensure that the document can be published, and publish handling events, business rules, etc - publishResult = StrategyCanPublish( - scope, - content, /*checkPath:*/ - !branchOne || branchRoot, - culturesPublishing, - culturesUnpublishing, - eventMessages, - allLangs, - notificationState); - - if (publishResult.Success) - { - // raise Publishing notification - if (scope.Notifications.PublishCancelable( - new ContentPublishingNotification(content, eventMessages).WithState(notificationState))) - { - _logger.LogInformation("Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, content.Id, "publishing was cancelled"); - return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, eventMessages, content); - } - - // note: StrategyPublish flips the PublishedState to Publishing! - publishResult = StrategyPublish(content, culturesPublishing, culturesUnpublishing, eventMessages); - - // Check if a culture has been unpublished and if there are no cultures left, and then unpublish document as a whole - if (publishResult.Result == PublishResultType.SuccessUnpublishCulture && - content.PublishCultureInfos?.Count == 0) - { - // This is a special case! We are unpublishing the last culture and to persist that we need to re-publish without any cultures - // so the state needs to remain Publishing to do that. However, we then also need to unpublish the document and to do that - // the state needs to be Unpublishing and it cannot be both. This state is used within the documentRepository to know how to - // persist certain things. So before proceeding below, we need to save the Publishing state to publish no cultures, then we can - // mark the document for Unpublishing. - SaveDocument(content); - - // Set the flag to unpublish and continue - unpublishing = content.Published; // if not published yet, nothing to do - } - } - else - { - // in a branch, just give up - if (branchOne && !branchRoot) - { - return publishResult; - } - - // Check for mandatory culture missing, and then unpublish document as a whole - if (publishResult.Result == PublishResultType.FailedPublishMandatoryCultureMissing) - { - publishing = false; - unpublishing = content.Published; // if not published yet, nothing to do - - // we may end up in a state where we won't publish nor unpublish - // keep going, though, as we want to save anyways - } - - // reset published state from temp values (publishing, unpublishing) to original value - // (published, unpublished) in order to save the document, unchanged - yes, this is odd, - // but: (a) it means we don't reproduce the PublishState logic here and (b) setting the - // PublishState to anything other than Publishing or Unpublishing - which is precisely - // what we want to do here - throws - content.Published = content.Published; - } - } - - // won't happen in a branch - if (unpublishing) - { - IContent? newest = GetById(content.Id); // ensure we have the newest version - in scope - if (content.VersionId != newest?.VersionId) - { - return new PublishResult(PublishResultType.FailedPublishConcurrencyViolation, eventMessages, content); - } - - if (content.Published) - { - // ensure that the document can be unpublished, and unpublish - // handling events, business rules, etc - // note: StrategyUnpublish flips the PublishedState to Unpublishing! - // note: This unpublishes the entire document (not different variants) - unpublishResult = StrategyCanUnpublish(scope, content, eventMessages, notificationState); - if (unpublishResult.Success) - { - unpublishResult = StrategyUnpublish(content, eventMessages); - } - else - { - // reset published state from temp values (publishing, unpublishing) to original value - // (published, unpublished) in order to save the document, unchanged - yes, this is odd, - // but: (a) it means we don't reproduce the PublishState logic here and (b) setting the - // PublishState to anything other than Publishing or Unpublishing - which is precisely - // what we want to do here - throws - content.Published = content.Published; - return unpublishResult; - } - } - else - { - // already unpublished - optimistic concurrency collision, really, - // and I am not sure at all what we should do, better die fast, else - // we may end up corrupting the db - throw new InvalidOperationException("Concurrency collision."); - } - } - - // Persist the document - SaveDocument(content); - - // we have tried to unpublish - won't happen in a branch - if (unpublishing) - { - // and succeeded, trigger events - if (unpublishResult?.Success ?? false) - { - // events and audit - scope.Notifications.Publish( - new ContentUnpublishedNotification(content, eventMessages).WithState(notificationState)); - scope.Notifications.Publish(new ContentTreeChangeNotification( - content, - TreeChangeTypes.RefreshBranch, - variesByCulture ? culturesPublishing.IsCollectionEmpty() ? null : culturesPublishing : null, - variesByCulture ? culturesUnpublishing.IsCollectionEmpty() ? null : culturesUnpublishing : ["*"], - eventMessages)); - - if (culturesUnpublishing != null) - { - // This will mean that that we unpublished a mandatory culture or we unpublished the last culture. - var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesUnpublishing); - Audit(AuditType.UnpublishVariant, userId, content.Id, $"Unpublished languages: {langs}", langs); - - if (publishResult == null) - { - throw new PanicException("publishResult == null - should not happen"); - } - - switch (publishResult.Result) - { - case PublishResultType.FailedPublishMandatoryCultureMissing: - // Occurs when a mandatory culture was unpublished (which means we tried publishing the document without a mandatory culture) - - // Log that the whole content item has been unpublished due to mandatory culture unpublished - Audit(AuditType.Unpublish, userId, content.Id, "Unpublished (mandatory language unpublished)"); - return new PublishResult(PublishResultType.SuccessUnpublishMandatoryCulture, eventMessages, content); - case PublishResultType.SuccessUnpublishCulture: - // Occurs when the last culture is unpublished - Audit(AuditType.Unpublish, userId, content.Id, "Unpublished (last language unpublished)"); - return new PublishResult(PublishResultType.SuccessUnpublishLastCulture, eventMessages, content); - } - } - - Audit(AuditType.Unpublish, userId, content.Id); - return new PublishResult(PublishResultType.SuccessUnpublish, eventMessages, content); - } - - // or, failed - scope.Notifications.Publish(new ContentTreeChangeNotification(content, changeType, eventMessages)); - return new PublishResult(PublishResultType.FailedUnpublish, eventMessages, content); // bah - } - - // we have tried to publish - if (publishing) - { - // and succeeded, trigger events - if (publishResult?.Success ?? false) - { - if (isNew == false && previouslyPublished == false) - { - changeType = TreeChangeTypes.RefreshBranch; // whole branch - } - else if (isNew == false && previouslyPublished) - { - changeType = TreeChangeTypes.RefreshNode; // single node - } - - // invalidate the node/branch - // for branches, handled by SaveAndPublishBranch - if (!branchOne) - { - scope.Notifications.Publish( - new ContentTreeChangeNotification( - content, - changeType, - variesByCulture ? culturesPublishing.IsCollectionEmpty() ? null : culturesPublishing : ["*"], - variesByCulture ? culturesUnpublishing.IsCollectionEmpty() ? null : culturesUnpublishing : null, - eventMessages)); - scope.Notifications.Publish( - new ContentPublishedNotification(content, eventMessages).WithState(notificationState)); - } - - // it was not published and now is... descendants that were 'published' (but - // had an unpublished ancestor) are 're-published' ie not explicitly published - // but back as 'published' nevertheless - if (!branchOne && isNew == false && previouslyPublished == false && HasChildren(content.Id)) - { - IContent[] descendants = GetPublishedDescendantsLocked(content).ToArray(); - scope.Notifications.Publish( - new ContentPublishedNotification(descendants, eventMessages).WithState(notificationState)); - } - - switch (publishResult.Result) - { - case PublishResultType.SuccessPublish: - Audit(AuditType.Publish, userId, content.Id); - break; - case PublishResultType.SuccessPublishCulture: - if (culturesPublishing != null) - { - var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesPublishing); - Audit(AuditType.PublishVariant, userId, content.Id, $"Published languages: {langs}", langs); - } - - break; - case PublishResultType.SuccessUnpublishCulture: - if (culturesUnpublishing != null) - { - var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesUnpublishing); - Audit(AuditType.UnpublishVariant, userId, content.Id, $"Unpublished languages: {langs}", langs); - } - - break; - } - - return publishResult; - } - } - - // should not happen - if (branchOne && !branchRoot) - { - throw new PanicException("branchOne && !branchRoot - should not happen"); - } - - // if publishing didn't happen or if it has failed, we still need to log which cultures were saved - if (!branchOne && (publishResult == null || !publishResult.Success)) - { - if (culturesChanging != null) - { - var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesChanging); - Audit(AuditType.SaveVariant, userId, content.Id, $"Saved languages: {langs}", langs); - } - else - { - Audit(AuditType.Save, userId, content.Id); - } - } - - // or, failed - scope.Notifications.Publish(new ContentTreeChangeNotification(content, changeType, eventMessages)); - return publishResult!; - } - - /// - public IEnumerable PerformScheduledPublish(DateTime date) - { - var allLangs = new Lazy>(() => _languageRepository.GetMany().ToList()); - EventMessages evtMsgs = EventMessagesFactory.Get(); - var results = new List(); - - PerformScheduledPublishingRelease(date, results, evtMsgs, allLangs); - PerformScheduledPublishingExpiration(date, results, evtMsgs, allLangs); - - return results; - } - - private void PerformScheduledPublishingExpiration(DateTime date, List results, EventMessages evtMsgs, Lazy> allLangs) + /// + /// 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(); - - // do a fast read without any locks since this executes often to see if we even need to proceed - if (_documentRepository.HasContentForExpiration(date)) + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { - // 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(); + EventMessages evtMsgs = EventMessagesFactory.Get(); - // 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)) + var savingNotification = new ContentSavingNotification(content, evtMsgs); + if (scope.Notifications.PublishCancelable(savingNotification)) { - 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); - } + return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, content); } - _documentRepository.ClearSchedule(date, ContentScheduleAction.Release); - } + var allLangs = _languageRepository.GetMany().ToList(); - scope.Complete(); + PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, savingNotification.State, userId); + scope.Complete(); + return result; + } } // utility 'PublishCultures' func used by SaveAndPublishBranch @@ -2202,194 +1022,46 @@ internal IEnumerable PublishBranch( IReadOnlyCollection allLangs, out IDictionary? initialNotificationState) { - initialNotificationState = new Dictionary(); - - // we need to guard against unsaved changes before proceeding; the document will be saved, but we're not firing any saved notifications - if (HasUnsavedChanges(document)) - { - return new PublishResult(PublishResultType.FailedPublishUnsavedChanges, evtMsgs, document); - } - - // null = do not include - if (culturesToPublish == null) - { - return null; - } - - // empty = already published - if (culturesToPublish.Count == 0) - { - return new PublishResult(PublishResultType.SuccessPublishAlready, evtMsgs, document); - } - - var savingNotification = new ContentSavingNotification(document, evtMsgs); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, document); - } - - // publish & check if values are valid - if (!publishCultures(document, culturesToPublish, allLangs)) - { - // TODO: Based on this callback behavior there is no way to know which properties may have been invalid if this failed, see other results of FailedPublishContentInvalid - return new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, document); - } - - PublishResult result = CommitDocumentChangesInternal(scope, document, evtMsgs, allLangs, savingNotification.State, userId, true, isRoot); - if (result.Success) - { - publishedDocuments.Add(document); - } - - return result; - } - - #endregion - - #region Delete - - /// - public OperationResult Delete(IContent content, int userId = Constants.Security.SuperUserId) - { - EventMessages eventMessages = EventMessagesFactory.Get(); - - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - if (scope.Notifications.PublishCancelable(new ContentDeletingNotification(content, eventMessages))) - { - scope.Complete(); - return OperationResult.Cancel(eventMessages); - } - - 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.Notifications.Publish(new ContentUnpublishedNotification(content, eventMessages)); - } - - DeleteLocked(scope, content, eventMessages); - - scope.Notifications.Publish( - new ContentTreeChangeNotification(content, TreeChangeTypes.Remove, eventMessages)); - Audit(AuditType.Delete, userId, content.Id); - - scope.Complete(); - } - - return OperationResult.Succeed(eventMessages); - } - - private void DeleteLocked(ICoreScope scope, IContent content, EventMessages evtMsgs) - { - void DoDelete(IContent c) - { - _documentRepository.Delete(c); - scope.Notifications.Publish(new ContentDeletedNotification(c, evtMsgs)); - - // media files deleted by QueuingEventDispatcher - } - - const int pageSize = 500; - var total = long.MaxValue; - while (total > 0) - { - // get descendants - ordered from deepest to shallowest - IEnumerable descendants = GetPagedDescendants(content.Id, 0, pageSize, out total, ordering: Ordering.By("Path", Direction.Descending)); - foreach (IContent c in descendants) - { - DoDelete(c); - } - } - - DoDelete(content); - } - - // TODO: both DeleteVersions methods below have an issue. Sort of. They do NOT take care of files the way - // Delete does - for a good reason: the file may be referenced by other, non-deleted, versions. BUT, - // if that's not the case, then the file will never be deleted, because when we delete the content, - // the version referencing the file will not be there anymore. SO, we can leak files. - - /// - /// Permanently deletes versions from an object prior to a specific date. - /// This method will never delete the latest version of a content item. - /// - /// Id of the object to delete versions from - /// Latest version date - /// Optional Id of the User deleting versions of a Content object - public void DeleteVersions(int id, DateTime versionDate, int userId = Constants.Security.SuperUserId) - { - EventMessages evtMsgs = EventMessagesFactory.Get(); - - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - var deletingVersionsNotification = - new ContentDeletingVersionsNotification(id, evtMsgs, dateToRetain: versionDate); - if (scope.Notifications.PublishCancelable(deletingVersionsNotification)) - { - scope.Complete(); - return; - } - - scope.WriteLock(Constants.Locks.ContentTree); - _documentRepository.DeleteVersions(id, versionDate); - - scope.Notifications.Publish( - new ContentDeletedVersionsNotification(id, evtMsgs, dateToRetain: versionDate).WithStateFrom( - deletingVersionsNotification)); - Audit(AuditType.Delete, userId, Constants.System.Root, "Delete (by version date)"); - - 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 = Constants.Security.SuperUserId) - { - EventMessages evtMsgs = EventMessagesFactory.Get(); + initialNotificationState = new Dictionary(); - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + // 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)) { - var deletingVersionsNotification = new ContentDeletingVersionsNotification(id, evtMsgs, versionId); - if (scope.Notifications.PublishCancelable(deletingVersionsNotification)) - { - scope.Complete(); - return; - } + return new PublishResult(PublishResultType.FailedPublishUnsavedChanges, evtMsgs, document); + } - if (deletePriorVersions) - { - IContent? content = GetVersion(versionId); - DeleteVersions(id, content?.UpdateDate ?? DateTime.Now, userId); - } + // null = do not include + if (culturesToPublish == null) + { + return null; + } - scope.WriteLock(Constants.Locks.ContentTree); - IContent? c = _documentRepository.Get(id); + // empty = already published + if (culturesToPublish.Count == 0) + { + return new PublishResult(PublishResultType.SuccessPublishAlready, evtMsgs, document); + } - // don't delete the current or published version - if (c?.VersionId != versionId && - c?.PublishedVersionId != versionId) - { - _documentRepository.DeleteVersion(versionId); - } + var savingNotification = new ContentSavingNotification(document, evtMsgs); + if (scope.Notifications.PublishCancelable(savingNotification)) + { + return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, document); + } - scope.Notifications.Publish( - new ContentDeletedVersionsNotification(id, evtMsgs, versionId).WithStateFrom( - deletingVersionsNotification)); - Audit(AuditType.Delete, userId, Constants.System.Root, "Delete (by version)"); + // 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); + } - scope.Complete(); + PublishResult result = CommitDocumentChangesInternal(scope, document, evtMsgs, allLangs, savingNotification.State, userId, true, isRoot); + if (result.Success) + { + publishedDocuments.Add(document); } + + return result; } #endregion @@ -3014,8 +1686,6 @@ private OperationResult Sort(ICoreScope scope, IContent[] itemsA, int userId, Ev return OperationResult.Succeed(eventMessages); } - private static bool HasUnsavedChanges(IContent content) => content.HasIdentity is false || content.IsDirty(); - public ContentDataIntegrityReport CheckDataIntegrity(ContentDataIntegrityReportOptions options) { using (ICoreScope scope = ScopeProvider.CreateCoreScope()) @@ -3055,383 +1725,16 @@ internal IEnumerable GetPublishedDescendants(IContent content) } } - 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) => - _auditRepository.Save(new AuditItem(objectId, type, userId, UmbracoObjectTypes.Document.GetName(), message, parameters)); - - private string GetLanguageDetailsForAuditEntry(IEnumerable affectedCultures) - => GetLanguageDetailsForAuditEntry(_languageRepository.GetMany(), affectedCultures); - - private static string GetLanguageDetailsForAuditEntry(IEnumerable languages, IEnumerable affectedCultures) - { - IEnumerable languageIsoCodes = languages - .Where(x => affectedCultures.InvariantContains(x.IsoCode)) - .Select(x => x.IsoCode); - return string.Join(", ", languageIsoCodes); - } - - private static bool IsDefaultCulture(IReadOnlyCollection? langs, string culture) => - langs?.Any(x => x.IsDefault && x.IsoCode.InvariantEquals(culture)) ?? false; - + // TODO ELEMENTS: not used? clean up! 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.Now; - 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.Now); - 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 /// @@ -3534,48 +1837,6 @@ public void DeleteOfTypes(IEnumerable contentTypeIds, int userId = Constant public void DeleteOfType(int contentTypeId, int userId = Constants.Security.SuperUserId) => DeleteOfTypes(new[] { contentTypeId }, userId); - private IContentType GetContentType(ICoreScope scope, string contentTypeAlias) - { - if (contentTypeAlias == null) - { - throw new ArgumentNullException(nameof(contentTypeAlias)); - } - - if (string.IsNullOrWhiteSpace(contentTypeAlias)) - { - throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(contentTypeAlias)); - } - - scope.ReadLock(Constants.Locks.ContentTypes); - - IQuery query = Query().Where(x => x.Alias == contentTypeAlias); - IContentType? contentType = _contentTypeRepository.Get(query).FirstOrDefault() - ?? - // causes rollback - throw new Exception($"No ContentType matching the passed in Alias: '{contentTypeAlias}'" + - $" was found"); - - return contentType; - } - - private IContentType GetContentType(string contentTypeAlias) - { - if (contentTypeAlias == null) - { - throw new ArgumentNullException(nameof(contentTypeAlias)); - } - - if (string.IsNullOrWhiteSpace(contentTypeAlias)) - { - throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(contentTypeAlias)); - } - - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return GetContentType(scope, contentTypeAlias); - } - } - #endregion #region Blueprints @@ -3765,4 +2026,93 @@ public void DeleteBlueprintsOfType(int contentTypeId, int userId = Constants.Sec #endregion + #region Abstract implementations + + protected override UmbracoObjectTypes ContentObjectType => UmbracoObjectTypes.Document; + + protected override int[] ReadLockIds => WriteLockIds; + + protected override int[] WriteLockIds => new[] { Constants.Locks.ContentTree }; + + protected override bool SupportsBranchPublishing => true; + + protected override ILogger Logger => _logger; + + protected override IContent CreateContentInstance(string name, int parentId, IContentType contentType, int userId) + => new Content(name, parentId, contentType, userId); + + protected override IContent CreateContentInstance(string name, IContent parent, IContentType contentType, int userId) + => new Content(name, parent, contentType, userId); + + protected override void DeleteLocked(ICoreScope scope, IContent content, EventMessages evtMsgs) + { + void DoDelete(IContent c) + { + _documentRepository.Delete(c); + scope.Notifications.Publish(new ContentDeletedNotification(c, evtMsgs)); + + // media files deleted by QueuingEventDispatcher + } + + const int pageSize = 500; + var total = long.MaxValue; + while (total > 0) + { + // get descendants - ordered from deepest to shallowest + IEnumerable descendants = GetPagedDescendants(content.Id, 0, pageSize, out total, ordering: Ordering.By("Path", Direction.Descending)); + foreach (IContent c in descendants) + { + DoDelete(c); + } + } + + DoDelete(content); + } + + protected override SavingNotification SavingNotification(IContent content, EventMessages eventMessages) + => new ContentSavingNotification(content, eventMessages); + + protected override SavedNotification SavedNotification(IContent content, EventMessages eventMessages) + => new ContentSavedNotification(content, eventMessages); + + protected override SavingNotification SavingNotification(IEnumerable content, EventMessages eventMessages) + => new ContentSavingNotification(content, eventMessages); + + protected override SavedNotification SavedNotification(IEnumerable content, EventMessages eventMessages) + => new ContentSavedNotification(content, eventMessages); + + protected override TreeChangeNotification TreeChangeNotification(IContent content, TreeChangeTypes changeTypes, EventMessages eventMessages) + => new ContentTreeChangeNotification(content, changeTypes, eventMessages); + + protected override TreeChangeNotification TreeChangeNotification(IContent content, TreeChangeTypes changeTypes, IEnumerable? publishedCultures, IEnumerable? unpublishedCultures, EventMessages eventMessages) + => new ContentTreeChangeNotification(content, changeTypes, publishedCultures, unpublishedCultures, eventMessages); + + protected override TreeChangeNotification TreeChangeNotification(IEnumerable content, TreeChangeTypes changeTypes, EventMessages eventMessages) + => new ContentTreeChangeNotification(content, changeTypes, eventMessages); + + protected override DeletingNotification DeletingNotification(IContent content, EventMessages eventMessages) + => new ContentDeletingNotification(content, eventMessages); + + protected override CancelableEnumerableObjectNotification PublishingNotification(IContent content, EventMessages eventMessages) + => new ContentPublishingNotification(content, eventMessages); + + protected override IStatefulNotification PublishedNotification(IContent content, EventMessages eventMessages) + => new ContentPublishedNotification(content, eventMessages); + + protected override IStatefulNotification PublishedNotification(IEnumerable content, EventMessages eventMessages) + => new ContentPublishedNotification(content, eventMessages); + + protected override CancelableEnumerableObjectNotification UnpublishingNotification(IContent content, EventMessages eventMessages) + => new ContentUnpublishingNotification(content, eventMessages); + + protected override IStatefulNotification UnpublishedNotification(IContent content, EventMessages eventMessages) + => new ContentUnpublishedNotification(content, eventMessages); + + protected override DeletingVersionsNotification DeletingVersionsNotification(int id, EventMessages messages, int specificVersion = default, bool deletePriorVersions = false, DateTime dateToRetain = default) + => new ContentDeletingVersionsNotification(id, messages, specificVersion, deletePriorVersions, dateToRetain); + + protected override DeletedVersionsNotification DeletedVersionsNotification(int id, EventMessages messages, int specificVersion = default, bool deletePriorVersions = false, DateTime dateToRetain = default) + => new ContentDeletedVersionsNotification(id, messages, specificVersion, deletePriorVersions, dateToRetain); + + #endregion } diff --git a/src/Umbraco.Core/Services/CultureImpactFactory.cs b/src/Umbraco.Core/Services/CultureImpactFactory.cs index 3dd9b6b1f0b1..6225f51255b1 100644 --- a/src/Umbraco.Core/Services/CultureImpactFactory.cs +++ b/src/Umbraco.Core/Services/CultureImpactFactory.cs @@ -19,7 +19,7 @@ public CultureImpactFactory(IOptionsMonitor contentSettings) } /// - public CultureImpact? Create(string? culture, bool isDefault, IContent content) + public CultureImpact? Create(string? culture, bool isDefault, IContentBase content) { TryCreate(culture, isDefault, content.ContentType.Variations, true, _contentSettings.AllowEditInvariantFromNonDefault, out CultureImpact? impact); diff --git a/src/Umbraco.Core/Services/ElementContainerService.cs b/src/Umbraco.Core/Services/ElementContainerService.cs new file mode 100644 index 000000000000..7b0c75ebcb73 --- /dev/null +++ b/src/Umbraco.Core/Services/ElementContainerService.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; + +namespace Umbraco.Cms.Core.Services; + +internal sealed class ElementContainerService : EntityTypeContainerService, IElementContainerService +{ + public ElementContainerService( + ICoreScopeProvider provider, + ILoggerFactory loggerFactory, + IEventMessagesFactory eventMessagesFactory, + IElementContainerRepository entityContainerRepository, + IAuditRepository auditRepository, + IEntityRepository entityRepository, + IUserIdKeyResolver userIdKeyResolver) + : base(provider, loggerFactory, eventMessagesFactory, entityContainerRepository, auditRepository, entityRepository, userIdKeyResolver) + { + } + + protected override Guid ContainedObjectType => Constants.ObjectTypes.Element; + + protected override UmbracoObjectTypes ContainerObjectType => UmbracoObjectTypes.ElementContainer; + + protected override int[] ReadLockIds => new [] { Constants.Locks.ElementTree }; + + protected override int[] WriteLockIds => ReadLockIds; +} diff --git a/src/Umbraco.Core/Services/ElementEditingService.cs b/src/Umbraco.Core/Services/ElementEditingService.cs new file mode 100644 index 000000000000..5fad676e6bd8 --- /dev/null +++ b/src/Umbraco.Core/Services/ElementEditingService.cs @@ -0,0 +1,174 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +internal sealed class ElementEditingService + : ContentEditingServiceBase, IElementEditingService +{ + private readonly ILogger _logger; + private readonly IElementContainerService _containerService; + + public ElementEditingService( + IElementService elementService, + IContentTypeService contentTypeService, + PropertyEditorCollection propertyEditorCollection, + IDataTypeService dataTypeService, + ILogger logger, + ICoreScopeProvider scopeProvider, + IUserIdKeyResolver userIdKeyResolver, + IElementValidationService validationService, + IOptionsMonitor optionsMonitor, + IRelationService relationService, + IElementContainerService containerService) + : base( + elementService, + contentTypeService, + propertyEditorCollection, + dataTypeService, + logger, + scopeProvider, + userIdKeyResolver, + validationService, + optionsMonitor, + relationService) + { + _logger = logger; + _containerService = containerService; + } + + public Task GetAsync(Guid key) + { + IElement? element = ContentService.GetById(key); + return Task.FromResult(element); + } + + // TODO ELEMENTS: implement validation here + public Task> ValidateCreateAsync(ElementCreateModel createModel, Guid userKey) + => Task.FromResult(Attempt.Succeed(ContentEditingOperationStatus.Success, new ())); + + // TODO ELEMENTS: implement validation here + public Task> ValidateUpdateAsync(Guid key, ValidateElementUpdateModel updateModel, Guid userKey) + => Task.FromResult(Attempt.Succeed(ContentEditingOperationStatus.Success, new ())); + + public async Task> CreateAsync(ElementCreateModel createModel, Guid userKey) + { + if (await ValidateCulturesAsync(createModel) is false) + { + return Attempt.FailWithStatus(ContentEditingOperationStatus.InvalidCulture, new ElementCreateResult()); + } + + Attempt result = await MapCreate(createModel); + if (result.Success == false) + { + return result; + } + + // the create mapping might succeed, but this doesn't mean the model is valid at property level. + // we'll return the actual property validation status if the entire operation succeeds. + ContentEditingOperationStatus validationStatus = result.Status; + ContentValidationResult validationResult = result.Result.ValidationResult; + + // TODO ELEMENTS: we need a fix for this; see ContentEditingService + IElement element = result.Result.Content!; + // IElement element = await EnsureOnlyAllowedFieldsAreUpdated(result.Result.Content!, userKey); + + ContentEditingOperationStatus saveStatus = await Save(element, userKey); + return saveStatus == ContentEditingOperationStatus.Success + ? Attempt.SucceedWithStatus(validationStatus, new ElementCreateResult { Content = element, ValidationResult = validationResult }) + : Attempt.FailWithStatus(saveStatus, new ElementCreateResult { Content = element }); + } + + public async Task> UpdateAsync(Guid key, ElementUpdateModel updateModel, Guid userKey) + { + IElement? element = ContentService.GetById(key); + if (element is null) + { + return Attempt.FailWithStatus(ContentEditingOperationStatus.NotFound, new ElementUpdateResult()); + } + + if (await ValidateCulturesAsync(updateModel) is false) + { + return Attempt.FailWithStatus(ContentEditingOperationStatus.InvalidCulture, new ElementUpdateResult { Content = element }); + } + + Attempt result = await MapUpdate(element, updateModel); + if (result.Success == false) + { + return Attempt.FailWithStatus(result.Status, result.Result); + } + + // the update mapping might succeed, but this doesn't mean the model is valid at property level. + // we'll return the actual property validation status if the entire operation succeeds. + ContentEditingOperationStatus validationStatus = result.Status; + ContentValidationResult validationResult = result.Result.ValidationResult; + + // TODO ELEMENTS: we need a fix for this; see ContentEditingService + // element = await EnsureOnlyAllowedFieldsAreUpdated(element, userKey); + + ContentEditingOperationStatus saveStatus = await Save(element, userKey); + return saveStatus == ContentEditingOperationStatus.Success + ? Attempt.SucceedWithStatus(validationStatus, new ElementUpdateResult { Content = element, ValidationResult = validationResult }) + : Attempt.FailWithStatus(saveStatus, new ElementUpdateResult { Content = element }); + } + + public async Task> DeleteAsync(Guid key, Guid userKey) + => await HandleDeleteAsync(key, userKey, false); + + protected override IElement New(string? name, int parentId, IContentType contentType) + => new Element(name, parentId, contentType); + + protected override async Task<(int? ParentId, ContentEditingOperationStatus OperationStatus)> TryGetAndValidateParentIdAsync(Guid? parentKey, IContentType contentType) + { + if (parentKey.HasValue is false) + { + return (Constants.System.Root, ContentEditingOperationStatus.Success); + } + + EntityContainer? container = await _containerService.GetAsync(parentKey.Value); + return container is not null + ? (container.Id, ContentEditingOperationStatus.Success) + : (null, ContentEditingOperationStatus.ParentNotFound); + } + + protected override OperationResult? Move(IElement element, int newParentId, int userId) + => throw new NotImplementedException("TODO ELEMENTS: implement move"); + + protected override IElement? Copy(IElement element, int newParentId, bool relateToOriginal, bool includeDescendants, int userId) + => throw new NotImplementedException("TODO ELEMENTS: implement copy"); + + protected override OperationResult? MoveToRecycleBin(IElement element, int userId) + => throw new NotImplementedException("TODO ELEMENTS: implement recycle bin"); + + protected override OperationResult? Delete(IElement element, int userId) + => ContentService.Delete(element, userId); + + private async Task Save(IElement content, Guid userKey) + { + try + { + var currentUserId = await GetUserIdAsync(userKey); + OperationResult saveResult = ContentService.Save(content, currentUserId); + return saveResult.Result switch + { + // these are the only result states currently expected from Save + OperationResultType.Success => ContentEditingOperationStatus.Success, + OperationResultType.FailedCancelledByEvent => ContentEditingOperationStatus.CancelledByNotification, + + // for any other state we'll return "unknown" so we know that we need to amend this + _ => ContentEditingOperationStatus.Unknown + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Content save operation failed"); + return ContentEditingOperationStatus.Unknown; + } + } +} diff --git a/src/Umbraco.Core/Services/ElementPublishingService.cs b/src/Umbraco.Core/Services/ElementPublishingService.cs new file mode 100644 index 000000000000..1d9ba9ab39a1 --- /dev/null +++ b/src/Umbraco.Core/Services/ElementPublishingService.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Scoping; + +namespace Umbraco.Cms.Core.Services; + +internal sealed class ElementPublishingService : ContentPublishingServiceBase, IElementPublishingService +{ + public ElementPublishingService( + ICoreScopeProvider coreScopeProvider, + IElementService contentService, + IUserIdKeyResolver userIdKeyResolver, + IContentValidationService contentValidationService, + IContentTypeService contentTypeService, + ILanguageService languageService, + IOptionsMonitor optionsMonitor, + IRelationService relationService, + ILogger> logger) + : base( + coreScopeProvider, + contentService, + userIdKeyResolver, + contentValidationService, + contentTypeService, + languageService, + optionsMonitor, + relationService, + logger) + { + } +} diff --git a/src/Umbraco.Core/Services/ElementService.cs b/src/Umbraco.Core/Services/ElementService.cs new file mode 100644 index 000000000000..afd9d47c9dd1 --- /dev/null +++ b/src/Umbraco.Core/Services/ElementService.cs @@ -0,0 +1,166 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.Changes; +using Umbraco.Cms.Core.Strings; + +namespace Umbraco.Cms.Core.Services; + +public class ElementService : PublishableContentServiceBase, IElementService +{ + private readonly IElementRepository _elementRepository; + private readonly ILogger _logger; + private readonly IShortStringHelper _shortStringHelper; + + public ElementService( + ICoreScopeProvider provider, + ILoggerFactory loggerFactory, + IEventMessagesFactory eventMessagesFactory, + IAuditRepository auditRepository, + IContentTypeRepository contentTypeRepository, + IElementRepository elementRepository, + ILanguageRepository languageRepository, + Lazy propertyValidationService, + ICultureImpactFactory cultureImpactFactory, + PropertyEditorCollection propertyEditorCollection, + IIdKeyMap idKeyMap, + IShortStringHelper shortStringHelper) + : base( + provider, + loggerFactory, + eventMessagesFactory, + auditRepository, + contentTypeRepository, + elementRepository, + languageRepository, + propertyValidationService, + cultureImpactFactory, + propertyEditorCollection, + idKeyMap) + { + _elementRepository = elementRepository; + _shortStringHelper = shortStringHelper; + _logger = loggerFactory.CreateLogger(); + } + + #region Create + + public IElement Create(string name, string contentTypeAlias, int userId = Constants.Security.SuperUserId) + { + IContentType contentType = GetContentType(contentTypeAlias) + // causes rollback + ?? throw new ArgumentException("No content type with that alias.", nameof(contentTypeAlias)); + + var element = new Element(name, contentType, userId); + + return element; + } + + #endregion + + #region Others + + // TODO ELEMENTS: create abstractions of the implementations in this region, and share them with ContentService + + Attempt IContentServiceBase.Save(IEnumerable contents, int userId) => + Attempt.Succeed(Save(contents, userId)); + + public ContentDataIntegrityReport CheckDataIntegrity(ContentDataIntegrityReportOptions options) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + scope.WriteLock(Constants.Locks.ContentTree); + + ContentDataIntegrityReport report = _elementRepository.CheckDataIntegrity(options); + + if (report.FixedIssues.Count > 0) + { + // The event args needs a content item so we'll make a fake one with enough properties to not cause a null ref + var root = new Element("root", -1, new ContentType(_shortStringHelper, -1)) { Id = -1, Key = Guid.Empty }; + scope.Notifications.Publish(new ElementTreeChangeNotification(root, TreeChangeTypes.RefreshAll, EventMessagesFactory.Get())); + } + + scope.Complete(); + + return report; + } + } + + #endregion + + #region Abstract implementations + + protected override UmbracoObjectTypes ContentObjectType => UmbracoObjectTypes.Element; + + protected override int[] ReadLockIds => WriteLockIds; + + protected override int[] WriteLockIds => new[] { Constants.Locks.ElementTree }; + + protected override bool SupportsBranchPublishing => false; + + protected override ILogger Logger => _logger; + + protected override IElement CreateContentInstance(string name, int parentId, IContentType contentType, int userId) + => new Element(name, contentType, userId); + + // TODO ELEMENTS: this should only be on the content service + protected override IElement CreateContentInstance(string name, IElement parent, IContentType contentType, int userId) + => throw new InvalidOperationException("Elements cannot be nested underneath one another"); + + protected override void DeleteLocked(ICoreScope scope, IElement content, EventMessages evtMsgs) + { + _elementRepository.Delete(content); + scope.Notifications.Publish(new ElementDeletedNotification(content, evtMsgs)); + } + + protected override SavingNotification SavingNotification(IElement content, EventMessages eventMessages) + => new ElementSavingNotification(content, eventMessages); + + protected override SavedNotification SavedNotification(IElement content, EventMessages eventMessages) + => new ElementSavedNotification(content, eventMessages); + + protected override SavingNotification SavingNotification(IEnumerable content, EventMessages eventMessages) + => new ElementSavingNotification(content, eventMessages); + + protected override SavedNotification SavedNotification(IEnumerable content, EventMessages eventMessages) + => new ElementSavedNotification(content, eventMessages); + + protected override TreeChangeNotification TreeChangeNotification(IElement content, TreeChangeTypes changeTypes, EventMessages eventMessages) + => new ElementTreeChangeNotification(content, changeTypes, eventMessages); + + protected override TreeChangeNotification TreeChangeNotification(IElement content, TreeChangeTypes changeTypes, IEnumerable? publishedCultures, IEnumerable? unpublishedCultures, EventMessages eventMessages) + => new ElementTreeChangeNotification(content, changeTypes, publishedCultures, unpublishedCultures, eventMessages); + + protected override TreeChangeNotification TreeChangeNotification(IEnumerable content, TreeChangeTypes changeTypes, EventMessages eventMessages) + => new ElementTreeChangeNotification(content, changeTypes, eventMessages); + + protected override DeletingNotification DeletingNotification(IElement content, EventMessages eventMessages) + => new ElementDeletingNotification(content, eventMessages); + + protected override CancelableEnumerableObjectNotification PublishingNotification(IElement content, EventMessages eventMessages) + => new ElementPublishingNotification(content, eventMessages); + + protected override IStatefulNotification PublishedNotification(IElement content, EventMessages eventMessages) + => new ElementPublishedNotification(content, eventMessages); + + protected override IStatefulNotification PublishedNotification(IEnumerable content, EventMessages eventMessages) + => new ElementPublishedNotification(content, eventMessages); + + protected override CancelableEnumerableObjectNotification UnpublishingNotification(IElement content, EventMessages eventMessages) + => new ElementUnpublishingNotification(content, eventMessages); + + protected override IStatefulNotification UnpublishedNotification(IElement content, EventMessages eventMessages) + => new ElementUnpublishedNotification(content, eventMessages); + + protected override DeletingVersionsNotification DeletingVersionsNotification(int id, EventMessages messages, int specificVersion = default, bool deletePriorVersions = false, DateTime dateToRetain = default) + => new ElementDeletingVersionsNotification(id, messages, specificVersion, deletePriorVersions, dateToRetain); + + protected override DeletedVersionsNotification DeletedVersionsNotification(int id, EventMessages messages, int specificVersion = default, bool deletePriorVersions = false, DateTime dateToRetain = default) + => new ElementDeletedVersionsNotification(id, messages, specificVersion, deletePriorVersions, dateToRetain); + + #endregion +} diff --git a/src/Umbraco.Core/Services/ElementValidationService.cs b/src/Umbraco.Core/Services/ElementValidationService.cs new file mode 100644 index 000000000000..98b3523fce0b --- /dev/null +++ b/src/Umbraco.Core/Services/ElementValidationService.cs @@ -0,0 +1,18 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Core.Services; + +internal sealed class ElementValidationService : ContentValidationServiceBase, IElementValidationService +{ + public ElementValidationService(IPropertyValidationService propertyValidationService, ILanguageService languageService) + : base(propertyValidationService, languageService) + { + } + + public async Task ValidatePropertiesAsync( + ContentEditingModelBase contentEditingModelBase, + IContentType contentType, + IEnumerable? culturesToValidate = null) + => await HandlePropertiesValidationAsync(contentEditingModelBase, contentType, culturesToValidate); +} diff --git a/src/Umbraco.Core/Services/IContentService.cs b/src/Umbraco.Core/Services/IContentService.cs index a7bde2dc46a8..bbe73e5ff17a 100644 --- a/src/Umbraco.Core/Services/IContentService.cs +++ b/src/Umbraco.Core/Services/IContentService.cs @@ -1,5 +1,3 @@ -using Microsoft.Extensions.DependencyInjection; -using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Persistence.Querying; @@ -9,7 +7,7 @@ namespace Umbraco.Cms.Core.Services; /// /// Defines the ContentService, which is an easy access to operations involving /// -public interface IContentService : IContentServiceBase +public interface IContentService : IPublishableContentService { #region Rollback @@ -111,13 +109,6 @@ IContent CreateBlueprintFromContent(IContent blueprint, string name, int userId /// ContentScheduleCollection GetContentScheduleByContentId(int contentId); - /// - /// Persists publish/unpublish schedule for a content node. - /// - /// - /// - void PersistContentSchedule(IContent content, ContentScheduleCollection contentSchedule); - /// /// Gets documents. /// @@ -279,26 +270,6 @@ IContent CreateBlueprintFromContent(IContent blueprint, string name, int userId #region Save, Delete Document - /// - /// Saves a document. - /// - OperationResult Save(IContent content, int? userId = null, ContentScheduleCollection? contentSchedule = null); - - /// - /// Saves documents. - /// - // TODO: why only 1 result not 1 per content?! - OperationResult Save(IEnumerable contents, int userId = Constants.Security.SuperUserId); - - /// - /// Deletes a document. - /// - /// - /// This method will also delete associated media files, child content and possibly associated domains. - /// This method entirely clears the content from the database. - /// - OperationResult Delete(IContent content, int userId = Constants.Security.SuperUserId); - /// /// Deletes all documents of a given document type. /// @@ -382,19 +353,6 @@ IContent CreateBlueprintFromContent(IContent blueprint, string name, int userId #region Publish Document - /// - /// Publishes a document. - /// - /// - /// When a culture is being published, it includes all varying values along with all invariant values. - /// Wildcards (*) can be used as culture identifier to publish all cultures. - /// An empty array (or a wildcard) can be passed for culture invariant content. - /// - /// The document to publish. - /// The cultures to publish. - /// The identifier of the user performing the action. - PublishResult Publish(IContent content, string[] cultures, int userId = Constants.Security.SuperUserId); - /// /// Publishes a document branch. /// @@ -426,22 +384,6 @@ IContent CreateBlueprintFromContent(IContent blueprint, string name, int userId /// IEnumerable PublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string[] cultures, int userId = Constants.Security.SuperUserId); - /// - /// Unpublishes a document. - /// - /// - /// - /// By default, unpublishes the document as a whole, but it is possible to specify a culture to be - /// unpublished. Depending on whether that culture is mandatory, and other cultures remain published, - /// the document as a whole may or may not remain published. - /// - /// - /// If the content type is variant, then culture can be either '*' or an actual culture, but neither null nor - /// empty. If the content type is invariant, then culture can be either '*' or null or empty. - /// - /// - PublishResult Unpublish(IContent content, string? culture = "*", int userId = Constants.Security.SuperUserId); - /// /// Gets a value indicating whether a document is path-publishable. /// @@ -522,7 +464,4 @@ IContent CreateBlueprintFromContent(IContent blueprint, string name, int userId #endregion Task EmptyRecycleBinAsync(Guid userId); - -ContentScheduleCollection GetContentScheduleByContentId(Guid contentId) => StaticServiceProvider.Instance - .GetRequiredService().GetContentScheduleByContentId(contentId); } diff --git a/src/Umbraco.Core/Services/ICultureImpactFactory.cs b/src/Umbraco.Core/Services/ICultureImpactFactory.cs index 986b2f1aed2e..d204aeca07c3 100644 --- a/src/Umbraco.Core/Services/ICultureImpactFactory.cs +++ b/src/Umbraco.Core/Services/ICultureImpactFactory.cs @@ -14,7 +14,7 @@ public interface ICultureImpactFactory /// /// Validates that the culture is compatible with the variation. /// - CultureImpact? Create(string culture, bool isDefault, IContent content); + CultureImpact? Create(string culture, bool isDefault, IContentBase content); /// /// Gets the impact of 'all' cultures (including the invariant culture). diff --git a/src/Umbraco.Core/Services/IElementContainerService.cs b/src/Umbraco.Core/Services/IElementContainerService.cs new file mode 100644 index 000000000000..0af43ed6602f --- /dev/null +++ b/src/Umbraco.Core/Services/IElementContainerService.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services; + +public interface IElementContainerService : IEntityTypeContainerService +{ +} diff --git a/src/Umbraco.Core/Services/IElementEditingService.cs b/src/Umbraco.Core/Services/IElementEditingService.cs new file mode 100644 index 000000000000..37ca15017b82 --- /dev/null +++ b/src/Umbraco.Core/Services/IElementEditingService.cs @@ -0,0 +1,21 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +// TODO ELEMENTS: fully define this interface +public interface IElementEditingService +{ + Task GetAsync(Guid key); + + Task> ValidateCreateAsync(ElementCreateModel createModel, Guid userKey); + + Task> ValidateUpdateAsync(Guid key, ValidateElementUpdateModel updateModel, Guid userKey); + + Task> CreateAsync(ElementCreateModel createModel, Guid userKey); + + Task> UpdateAsync(Guid key, ElementUpdateModel updateModel, Guid userKey); + + Task> DeleteAsync(Guid key, Guid userKey); +} diff --git a/src/Umbraco.Core/Services/IElementPublishingService.cs b/src/Umbraco.Core/Services/IElementPublishingService.cs new file mode 100644 index 000000000000..c42c3ca9fcb0 --- /dev/null +++ b/src/Umbraco.Core/Services/IElementPublishingService.cs @@ -0,0 +1,28 @@ +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +public interface IElementPublishingService +{ + /// + /// Publishes an element. + /// + /// The key of the element. + /// The cultures to publish or schedule. + /// The identifier of the user performing the operation. + /// + Task> PublishAsync( + Guid key, + ICollection culturesToPublishOrSchedule, + Guid userKey); + + /// + /// Unpublishes multiple cultures of an element. + /// + /// The key of the element. + /// The cultures to unpublish. Use null to unpublish all cultures. + /// The identifier of the user performing the operation. + /// Status of the publish operation. + Task> UnpublishAsync(Guid key, ISet? cultures, Guid userKey); +} diff --git a/src/Umbraco.Core/Services/IElementService.cs b/src/Umbraco.Core/Services/IElementService.cs new file mode 100644 index 000000000000..75b647b69926 --- /dev/null +++ b/src/Umbraco.Core/Services/IElementService.cs @@ -0,0 +1,11 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services; + +// TODO ELEMENTS: fully define this interface +public interface IElementService : IPublishableContentService +{ + IElement Create(string name, string contentTypeAlias, int userId = Constants.Security.SuperUserId); + + IElement? GetById(Guid key); +} diff --git a/src/Umbraco.Core/Services/IElementValidationService.cs b/src/Umbraco.Core/Services/IElementValidationService.cs new file mode 100644 index 000000000000..6a31991cd0e8 --- /dev/null +++ b/src/Umbraco.Core/Services/IElementValidationService.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services; + +internal interface IElementValidationService : IContentValidationServiceBase +{ +} diff --git a/src/Umbraco.Core/Services/IPropertyValidationService.cs b/src/Umbraco.Core/Services/IPropertyValidationService.cs index 5937aab40eac..96f55bfaa31c 100644 --- a/src/Umbraco.Core/Services/IPropertyValidationService.cs +++ b/src/Umbraco.Core/Services/IPropertyValidationService.cs @@ -10,7 +10,7 @@ public interface IPropertyValidationService /// /// Validates the content item's properties pass validation rules /// - bool IsPropertyDataValid(IContent content, out IProperty[] invalidProperties, CultureImpact? impact); + bool IsPropertyDataValid(IPublishableContentBase content, out IProperty[] invalidProperties, CultureImpact? impact); /// /// Gets a value indicating whether the property has valid values. diff --git a/src/Umbraco.Core/Services/IPublishableContentService.cs b/src/Umbraco.Core/Services/IPublishableContentService.cs new file mode 100644 index 000000000000..cb8f00c537f5 --- /dev/null +++ b/src/Umbraco.Core/Services/IPublishableContentService.cs @@ -0,0 +1,60 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services; + +// TODO ELEMENTS: fully define this interface +public interface IPublishableContentService : IContentServiceBase + where TContent : class, IPublishableContentBase +{ + /// + /// Saves content. + /// + OperationResult Save(TContent content, int? userId = null, ContentScheduleCollection? contentSchedule = null); + + /// + /// Deletes content. + /// + /// + /// This method will also delete associated media files, child content and possibly associated domains. + /// This method entirely clears the content from the database. + /// + OperationResult Delete(TContent content, int userId = Constants.Security.SuperUserId); + + ContentScheduleCollection GetContentScheduleByContentId(Guid contentId); + + /// + /// Persists publish/unpublish schedule for content. + /// + /// + /// + void PersistContentSchedule(IPublishableContentBase content, ContentScheduleCollection contentSchedule); + + /// + /// Publishes content + /// + /// + /// When a culture is being published, it includes all varying values along with all invariant values. + /// Wildcards (*) can be used as culture identifier to publish all cultures. + /// An empty array (or a wildcard) can be passed for culture invariant content. + /// + /// The content to publish. + /// The cultures to publish. + /// The identifier of the user performing the action. + PublishResult Publish(TContent content, string[] cultures, int userId = Constants.Security.SuperUserId); + + /// + /// Unpublishes content. + /// + /// + /// + /// By default, unpublishes the content as a whole, but it is possible to specify a culture to be + /// unpublished. Depending on whether that culture is mandatory, and other cultures remain published, + /// the content as a whole may or may not remain published. + /// + /// + /// If the content type is variant, then culture can be either '*' or an actual culture, but neither null nor + /// empty. If the content type is invariant, then culture can be either '*' or null or empty. + /// + /// + PublishResult Unpublish(TContent content, string? culture = "*", int userId = Constants.Security.SuperUserId); +} diff --git a/src/Umbraco.Core/Services/PropertyValidationService.cs b/src/Umbraco.Core/Services/PropertyValidationService.cs index 1c44d71cf1e2..d898e333a872 100644 --- a/src/Umbraco.Core/Services/PropertyValidationService.cs +++ b/src/Umbraco.Core/Services/PropertyValidationService.cs @@ -135,7 +135,7 @@ public IEnumerable ValidatePropertyValue( } /// - public bool IsPropertyDataValid(IContent content, out IProperty[] invalidProperties, CultureImpact? impact) + public bool IsPropertyDataValid(IPublishableContentBase content, out IProperty[] invalidProperties, CultureImpact? impact) { // select invalid properties invalidProperties = content.Properties.Where(x => diff --git a/src/Umbraco.Core/Services/PublishResult.cs b/src/Umbraco.Core/Services/PublishResult.cs index 4e009cb49c90..341e05c79977 100644 --- a/src/Umbraco.Core/Services/PublishResult.cs +++ b/src/Umbraco.Core/Services/PublishResult.cs @@ -6,12 +6,12 @@ namespace Umbraco.Cms.Core.Services; /// /// Represents the result of publishing a document. /// -public class PublishResult : OperationResult +public class PublishResult : OperationResult { /// /// Initializes a new instance of the class. /// - public PublishResult(PublishResultType resultType, EventMessages? eventMessages, IContent content) + public PublishResult(PublishResultType resultType, EventMessages? eventMessages, IPublishableContentBase content) : base(resultType, eventMessages, content) { } @@ -19,7 +19,7 @@ public PublishResult(PublishResultType resultType, EventMessages? eventMessages, /// /// Initializes a new instance of the class. /// - public PublishResult(EventMessages eventMessages, IContent content) + public PublishResult(EventMessages eventMessages, IPublishableContentBase content) : base(PublishResultType.SuccessPublish, eventMessages, content) { } @@ -27,7 +27,7 @@ public PublishResult(EventMessages eventMessages, IContent content) /// /// Gets the document. /// - public IContent Content => Entity ?? throw new InvalidOperationException("The content entity was null. Nullability must have been circumvented when constructing this instance. Please don't do that."); + public IPublishableContentBase Content => Entity ?? throw new InvalidOperationException("The content entity was null. Nullability must have been circumvented when constructing this instance. Please don't do that."); /// /// Gets or sets the invalid properties, if the status failed due to validation. diff --git a/src/Umbraco.Core/Services/PublishableContentServiceBase.cs b/src/Umbraco.Core/Services/PublishableContentServiceBase.cs new file mode 100644 index 000000000000..bb185c9af75b --- /dev/null +++ b/src/Umbraco.Core/Services/PublishableContentServiceBase.cs @@ -0,0 +1,1854 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Exceptions; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Persistence.Querying; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.Changes; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Services; + +// TODO ELEMENTS: ensure this implementation is up to date with the current state of ContentService +// TODO ELEMENTS: everything structural (children, ancestors, descendants, branches, sort) should be omitted from this base +// TODO ELEMENTS: implement recycle bin +// TODO ELEMENTS: implement copy and move +// TODO ELEMENTS: replace all "document" with "content" (variables, names and comments) +// TODO ELEMENTS: ensure all read and write locks use the abstract lock IDs (ReadLockIds, WriteLockIds) +// TODO ELEMENTS: rename _documentRepository to _contentRepository +public abstract class PublishableContentServiceBase : RepositoryService + where TContent : class, IPublishableContentBase +{ + private readonly IAuditRepository _auditRepository; + private readonly IContentTypeRepository _contentTypeRepository; + private readonly IPublishableContentRepository _documentRepository; + private readonly ILanguageRepository _languageRepository; + private readonly Lazy _propertyValidationService; + private readonly ICultureImpactFactory _cultureImpactFactory; + private readonly PropertyEditorCollection _propertyEditorCollection; + private readonly IIdKeyMap _idKeyMap; + + protected PublishableContentServiceBase( + ICoreScopeProvider provider, + ILoggerFactory loggerFactory, + IEventMessagesFactory eventMessagesFactory, + IAuditRepository auditRepository, + IContentTypeRepository contentTypeRepository, + IPublishableContentRepository contentRepository, + ILanguageRepository languageRepository, + Lazy propertyValidationService, + ICultureImpactFactory cultureImpactFactory, + PropertyEditorCollection propertyEditorCollection, + IIdKeyMap idKeyMap) + : base(provider, loggerFactory, eventMessagesFactory) + { + _auditRepository = auditRepository; + _contentTypeRepository = contentTypeRepository; + _documentRepository = contentRepository; + _languageRepository = languageRepository; + _propertyValidationService = propertyValidationService; + _cultureImpactFactory = cultureImpactFactory; + _propertyEditorCollection = propertyEditorCollection; + _idKeyMap = idKeyMap; + } + + protected abstract UmbracoObjectTypes ContentObjectType { get; } + + protected abstract int[] ReadLockIds { get; } + + protected abstract int[] WriteLockIds { get; } + + protected abstract bool SupportsBranchPublishing { get; } + + protected abstract ILogger> Logger { get; } + + protected abstract TContent CreateContentInstance(string name, int parentId, IContentType contentType, int userId); + + protected abstract TContent CreateContentInstance(string name, TContent parent, IContentType contentType, int userId); + + protected virtual PublishResult CommitDocumentChanges( + ICoreScope scope, + TContent content, + EventMessages eventMessages, + IReadOnlyCollection allLangs, + IDictionary? notificationState, + int userId) + => CommitDocumentChangesInternal(scope, content, eventMessages, allLangs, notificationState, userId); + + protected abstract void DeleteLocked(ICoreScope scope, TContent content, EventMessages evtMsgs); + + protected abstract SavingNotification SavingNotification(TContent content, EventMessages eventMessages); + + protected abstract SavedNotification SavedNotification(TContent content, EventMessages eventMessages); + + protected abstract SavingNotification SavingNotification(IEnumerable content, EventMessages eventMessages); + + protected abstract SavedNotification SavedNotification(IEnumerable content, EventMessages eventMessages); + + protected abstract TreeChangeNotification TreeChangeNotification(TContent content, TreeChangeTypes changeTypes, EventMessages eventMessages); + + protected abstract TreeChangeNotification TreeChangeNotification(TContent content, TreeChangeTypes changeTypes, IEnumerable? publishedCultures, IEnumerable? unpublishedCultures, EventMessages eventMessages); + + protected abstract TreeChangeNotification TreeChangeNotification(IEnumerable content, TreeChangeTypes changeTypes, EventMessages eventMessages); + + protected abstract DeletingNotification DeletingNotification(TContent content, EventMessages eventMessages); + + // TODO ELEMENTS: create a base class for publishing notifications to reuse between IContent and IElement + protected abstract CancelableEnumerableObjectNotification PublishingNotification(TContent content, EventMessages eventMessages); + + protected abstract IStatefulNotification PublishedNotification(TContent content, EventMessages eventMessages); + + protected abstract IStatefulNotification PublishedNotification(IEnumerable content, EventMessages eventMessages); + + protected abstract CancelableEnumerableObjectNotification UnpublishingNotification(TContent content, EventMessages eventMessages); + + protected abstract IStatefulNotification UnpublishedNotification(TContent content, EventMessages eventMessages); + + protected abstract DeletingVersionsNotification DeletingVersionsNotification(int id, EventMessages messages, int specificVersion = default, bool deletePriorVersions = false, DateTime dateToRetain = default); + + protected abstract DeletedVersionsNotification DeletedVersionsNotification(int id, EventMessages messages, int specificVersion = default, bool deletePriorVersions = false, DateTime dateToRetain = default); + + + #region Count + + public int CountPublished(string? contentTypeAlias = null) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.CountPublished(contentTypeAlias); + } + } + + public int Count(string? contentTypeAlias = null) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.Count(contentTypeAlias); + } + } + + public int CountChildren(int parentId, string? contentTypeAlias = null) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.CountChildren(parentId, contentTypeAlias); + } + } + + public int CountDescendants(int parentId, string? contentTypeAlias = null) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.CountDescendants(parentId, contentTypeAlias); + } + } + + #endregion + + #region Get, Has, Is + + /// + /// Gets an object by Id + /// + /// Id of the Content to retrieve + /// + /// + /// + public TContent? GetById(int id) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + 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 (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + IEnumerable items = _documentRepository.GetMany(idsA); + var index = items.ToDictionary(x => x.Id, x => x); + return idsA.Select(x => index.GetValueOrDefault(x)).WhereNotNull(); + } + } + + /// + /// Gets an object by its 'UniqueId' + /// + /// Guid key of the Content to retrieve + /// + /// + /// + public TContent? GetById(Guid key) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.Get(key); + } + } + + /// + public ContentScheduleCollection GetContentScheduleByContentId(int contentId) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.GetContentSchedule(contentId); + } + } + + public ContentScheduleCollection GetContentScheduleByContentId(Guid contentId) + { + Attempt idAttempt = _idKeyMap.GetIdForKey(contentId, UmbracoObjectTypes.Document); + if (idAttempt.Success is false) + { + return new ContentScheduleCollection(); + } + + return GetContentScheduleByContentId(idAttempt.Result); + } + + /// + public void PersistContentSchedule(IPublishableContentBase content, ContentScheduleCollection contentSchedule) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + scope.WriteLock(WriteLockIds); + _documentRepository.PersistContentSchedule(content, contentSchedule); + scope.Complete(); + } + } + + /// + /// Gets objects by Ids + /// + /// Ids of the Content to retrieve + /// + /// + /// + public IEnumerable GetByIds(IEnumerable ids) + { + Guid[] idsA = ids.ToArray(); + if (idsA.Length == 0) + { + return Enumerable.Empty(); + } + + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + IEnumerable? items = _documentRepository.GetMany(idsA); + + if (items is not null) + { + var index = items.ToDictionary(x => x.Key, x => x); + + return idsA.Select(x => index.GetValueOrDefault(x)).WhereNotNull(); + } + + return Enumerable.Empty(); + } + } + + /// + public IEnumerable GetPagedOfType( + int contentTypeId, + long pageIndex, + int pageSize, + out long totalRecords, + IQuery? filter = null, + Ordering? ordering = null) + { + if (pageIndex < 0) + { + throw new ArgumentOutOfRangeException(nameof(pageIndex)); + } + + if (pageSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageSize)); + } + + ordering ??= Ordering.By("sortOrder"); + + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.GetPage( + Query()?.Where(x => x.ContentTypeId == contentTypeId), + pageIndex, + pageSize, + out totalRecords, + filter, + ordering); + } + } + + /// + public IEnumerable GetPagedOfTypes(int[] contentTypeIds, long pageIndex, int pageSize, out long totalRecords, IQuery? filter, Ordering? ordering = null) + { + if (pageIndex < 0) + { + throw new ArgumentOutOfRangeException(nameof(pageIndex)); + } + + if (pageSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageSize)); + } + + ordering ??= Ordering.By("sortOrder"); + + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.GetPage( + Query()?.Where(x => contentTypeIds.Contains(x.ContentTypeId)), + pageIndex, + pageSize, + out totalRecords, + filter, + ordering); + } + } + + /// + /// Gets a specific version of an item. + /// + /// Id of the version to retrieve + /// An item + public TContent? GetVersion(int versionId) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.GetVersion(versionId); + } + } + + /// + /// Gets a collection of an objects versions by Id + /// + /// + /// An Enumerable list of objects + public IEnumerable GetVersions(int id) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.GetAllVersions(id); + } + } + + /// + /// Gets a collection of an objects versions by Id + /// + /// An Enumerable list of objects + public IEnumerable GetVersionsSlim(int id, int skip, int take) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.GetAllVersionsSlim(id, skip, take); + } + } + + /// + /// Gets a list of all version Ids for the given content item ordered so latest is first + /// + /// + /// The maximum number of rows to return + /// + public IEnumerable GetVersionIds(int id, int maxRows) + { + using (ScopeProvider.CreateCoreScope(autoComplete: true)) + { + return _documentRepository.GetVersionIds(id, maxRows); + } + } + + /// + public IEnumerable GetContentForExpiration(DateTime date) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.GetContentForExpiration(date); + } + } + + /// + public IEnumerable GetContentForRelease(DateTime date) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.GetContentForRelease(date); + } + } + + /// + /// Checks whether an item has any children + /// + /// Id of the + /// True if the content has any children otherwise False + public bool HasChildren(int id) => CountChildren(id) > 0; + + public bool IsPathPublished(TContent? content) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return _documentRepository.IsPathPublished(content); + } + } + + /// + /// Gets the parent of the current content as an item. + /// + /// to retrieve the parent from + /// Parent object + public TContent? GetParent(TContent? content) + { + if (content?.ParentId == Constants.System.Root || content?.ParentId == Constants.System.RecycleBinContent || + content is null) + { + return null; + } + + return GetById(content.ParentId); + } + + #endregion + + #region Save, Publish, Unpublish + + /// + public OperationResult Save(TContent content, int? userId = null, ContentScheduleCollection? contentSchedule = null) + { + PublishedState publishedState = content.PublishedState; + if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished) + { + throw new InvalidOperationException( + $"Cannot save (un)publishing content with name: {content.Name} - and state: {content.PublishedState}, use the dedicated SavePublished method."); + } + + if (content.Name != null && content.Name.Length > 255) + { + throw new InvalidOperationException( + $"Content with the name {content.Name} cannot be more than 255 characters in length."); + } + + EventMessages eventMessages = EventMessagesFactory.Get(); + + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + SavingNotification savingNotification = SavingNotification(content, eventMessages); + if (scope.Notifications.PublishCancelable(savingNotification)) + { + scope.Complete(); + return OperationResult.Cancel(eventMessages); + } + + scope.WriteLock(WriteLockIds); + userId ??= Constants.Security.SuperUserId; + + if (content.HasIdentity == false) + { + content.CreatorId = userId.Value; + } + + content.WriterId = userId.Value; + + // track the cultures that have changed + List? culturesChanging = content.ContentType.VariesByCulture() + ? content.CultureInfos?.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList() + : 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. + _documentRepository.Save(content); + + if (contentSchedule != null) + { + _documentRepository.PersistContentSchedule(content, contentSchedule); + } + + scope.Notifications.Publish(SavedNotification(content, eventMessages).WithStateFrom(savingNotification)); + + // TODO: we had code here to FORCE that this event can never be suppressed. But that just doesn't make a ton of sense?! + // I understand that if its suppressed that the caches aren't updated, but that would be expected. If someone + // is supressing events then I think it's expected that nothing will happen. They are probably doing it for perf + // reasons like bulk import and in those cases we don't want this occuring. + scope.Notifications.Publish(TreeChangeNotification(content, TreeChangeTypes.RefreshNode, eventMessages)); + + if (culturesChanging != null) + { + var langs = GetLanguageDetailsForAuditEntry(culturesChanging); + Audit(AuditType.SaveVariant, userId.Value, content.Id, $"Saved languages: {langs}", langs); + } + else + { + Audit(AuditType.Save, userId.Value, content.Id); + } + + scope.Complete(); + } + + return OperationResult.Succeed(eventMessages); + } + + /// + public OperationResult Save(IEnumerable contents, int userId = Constants.Security.SuperUserId) + { + EventMessages eventMessages = EventMessagesFactory.Get(); + TContent[] contentsA = contents.ToArray(); + + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + SavingNotification savingNotification = SavingNotification(contentsA, eventMessages); + if (scope.Notifications.PublishCancelable(savingNotification)) + { + scope.Complete(); + return OperationResult.Cancel(eventMessages); + } + + scope.WriteLock(WriteLockIds); + foreach (TContent content in contentsA) + { + if (content.HasIdentity == false) + { + content.CreatorId = userId; + } + + content.WriterId = userId; + + _documentRepository.Save(content); + } + + scope.Notifications.Publish(SavedNotification(contentsA, eventMessages).WithStateFrom(savingNotification)); + + // TODO: See note above about supressing events + scope.Notifications.Publish(TreeChangeNotification(contentsA, TreeChangeTypes.RefreshNode, eventMessages)); + + string contentIds = string.Join(", ", contentsA.Select(x => x.Id)); + Audit(AuditType.Save, userId, Constants.System.Root, $"Saved multiple content items (#{contentIds.Length})"); + + scope.Complete(); + } + + return OperationResult.Succeed(eventMessages); + } + + /// + public PublishResult Publish(TContent 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(WriteLockIds); + + 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.Now; + foreach (CultureImpact? impact in impacts) + { + content.PublishCulture(impact, publishTime, _propertyEditorCollection); + } + + // Change state to publishing + content.PublishedState = PublishedState.Publishing; + + PublishResult result = CommitDocumentChanges(scope, content, evtMsgs, allLangs, new Dictionary(), userId); + scope.Complete(); + return result; + } + } + + /// + public PublishResult Unpublish(TContent content, string? culture = "*", int userId = Constants.Security.SuperUserId) + { + if (content == null) + { + throw new ArgumentNullException(nameof(content)); + } + + EventMessages evtMsgs = EventMessagesFactory.Get(); + + culture = culture?.NullOrWhiteSpaceAsNull().EnsureCultureCode(); + + PublishedState publishedState = content.PublishedState; + if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished) + { + throw new InvalidOperationException( + $"Cannot save-and-publish (un)publishing content, use the dedicated {nameof(CommitDocumentChanges)} method."); + } + + // cannot accept invariant (null or empty) culture for variant content type + // cannot accept a specific culture for invariant content type (but '*' is ok) + if (content.ContentType.VariesByCulture()) + { + if (culture == null) + { + throw new NotSupportedException("Invariant culture is not supported by variant content types."); + } + } + else + { + if (culture != null && culture != "*") + { + throw new NotSupportedException( + $"Culture \"{culture}\" is not supported by invariant content types."); + } + } + + // if the content is not published, nothing to do + if (!content.Published) + { + return new PublishResult(PublishResultType.SuccessUnpublishAlready, evtMsgs, content); + } + + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + scope.WriteLock(WriteLockIds); + + var allLangs = _languageRepository.GetMany().ToList(); + + SavingNotification savingNotification = SavingNotification(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 = CommitDocumentChanges(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 = CommitDocumentChanges(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; + } + } + } + + /// + 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(WriteLockIds); + + foreach (TContent 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 + } + + SavingNotification savingNotification = SavingNotification(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 = CommitDocumentChanges(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(WriteLockIds); + + foreach (TContent 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 + } + SavingNotification savingNotification = SavingNotification(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 = CommitDocumentChanges(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(); + } + + /// + /// 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... + /// + /// + protected PublishResult CommitDocumentChangesInternal( + ICoreScope scope, + TContent 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 || SupportsBranchPublishing is false ? 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(TContent 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( + PublishingNotification(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) + { + TContent? 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(UnpublishedNotification(content, eventMessages).WithState(notificationState)); + scope.Notifications.Publish(TreeChangeNotification( + content, + SupportsBranchPublishing ? TreeChangeTypes.RefreshBranch : TreeChangeTypes.RefreshNode, + 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(TreeChangeNotification(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 && SupportsBranchPublishing) + { + 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( + TreeChangeNotification( + content, + changeType, + variesByCulture ? culturesPublishing.IsCollectionEmpty() ? null : culturesPublishing : ["*"], + variesByCulture ? culturesUnpublishing.IsCollectionEmpty() ? null : culturesUnpublishing : null, + eventMessages)); + scope.Notifications.Publish( + PublishedNotification(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)) + { + TContent[] descendants = GetPublishedDescendantsLocked(content).ToArray(); + scope.Notifications.Publish( + PublishedNotification(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(TreeChangeNotification(content, changeType, eventMessages)); + return publishResult!; + } + + #endregion + + #region Delete + + /// + public OperationResult Delete(TContent content, int userId = Constants.Security.SuperUserId) + { + EventMessages eventMessages = EventMessagesFactory.Get(); + + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + if (scope.Notifications.PublishCancelable(DeletingNotification(content, eventMessages))) + { + scope.Complete(); + return OperationResult.Cancel(eventMessages); + } + + scope.WriteLock(WriteLockIds); + + // if it's not trashed yet, and published, we should unpublish + // but... Unpublishing event makes no sense (not going to cancel?) and no need to save + // just raise the event + if (content.Trashed == false && content.Published) + { + scope.Notifications.Publish(UnpublishedNotification(content, eventMessages)); + } + + DeleteLocked(scope, content, eventMessages); + + scope.Notifications.Publish(TreeChangeNotification(content, TreeChangeTypes.Remove, eventMessages)); + Audit(AuditType.Delete, userId, content.Id); + + scope.Complete(); + } + + return OperationResult.Succeed(eventMessages); + } + + // TODO: both DeleteVersions methods below have an issue. Sort of. They do NOT take care of files the way + // Delete does - for a good reason: the file may be referenced by other, non-deleted, versions. BUT, + // if that's not the case, then the file will never be deleted, because when we delete the content, + // the version referencing the file will not be there anymore. SO, we can leak files. + + /// + /// Permanently deletes versions from an object prior to a specific date. + /// This method will never delete the latest version of a content item. + /// + /// Id of the object to delete versions from + /// Latest version date + /// Optional Id of the User deleting versions of a Content object + public void DeleteVersions(int id, DateTime versionDate, int userId = Constants.Security.SuperUserId) + { + EventMessages evtMsgs = EventMessagesFactory.Get(); + + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + var deletingVersionsNotification = + new ContentDeletingVersionsNotification(id, evtMsgs, dateToRetain: versionDate); + if (scope.Notifications.PublishCancelable(deletingVersionsNotification)) + { + scope.Complete(); + return; + } + + scope.WriteLock(WriteLockIds); + _documentRepository.DeleteVersions(id, versionDate); + + scope.Notifications.Publish( + new ContentDeletedVersionsNotification(id, evtMsgs, dateToRetain: versionDate).WithStateFrom( + deletingVersionsNotification)); + Audit(AuditType.Delete, userId, Constants.System.Root, "Delete (by version date)"); + + 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 = Constants.Security.SuperUserId) + { + EventMessages evtMsgs = EventMessagesFactory.Get(); + + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + var deletingVersionsNotification = new ContentDeletingVersionsNotification(id, evtMsgs, versionId); + if (scope.Notifications.PublishCancelable(deletingVersionsNotification)) + { + scope.Complete(); + return; + } + + if (deletePriorVersions) + { + TContent? content = GetVersion(versionId); + DeleteVersions(id, content?.UpdateDate ?? DateTime.Now, userId); + } + + scope.WriteLock(WriteLockIds); + TContent? c = _documentRepository.Get(id); + + // don't delete the current or published version + if (c?.VersionId != versionId && + c?.PublishedVersionId != versionId) + { + _documentRepository.DeleteVersion(versionId); + } + + scope.Notifications.Publish( + new ContentDeletedVersionsNotification(id, evtMsgs, versionId).WithStateFrom( + deletingVersionsNotification)); + Audit(AuditType.Delete, userId, Constants.System.Root, "Delete (by version)"); + + scope.Complete(); + } + } + + #endregion + + #region Others + + protected static bool HasUnsavedChanges(TContent content) => content.HasIdentity is false || content.IsDirty(); + + protected static bool IsDefaultCulture(IReadOnlyCollection? langs, string culture) => + langs?.Any(x => x.IsDefault && x.IsoCode.InvariantEquals(culture)) ?? false; + + #endregion + + #region Internal Methods + + internal IEnumerable GetPublishedDescendantsLocked(TContent 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 (TContent c in contents) + { + if (parents.Contains(c.ParentId)) + { + yield return c; + parents.Add(c.Id); + } + } + } + } + + #endregion + + #region Auditing + + protected void Audit(AuditType type, int userId, int objectId, string? message = null, string? parameters = null) => + _auditRepository.Save(new AuditItem(objectId, type, userId, UmbracoObjectTypes.Document.GetName(), message, parameters)); + + + protected string GetLanguageDetailsForAuditEntry(IEnumerable affectedCultures) + => GetLanguageDetailsForAuditEntry(_languageRepository.GetMany(), affectedCultures); + + protected static string GetLanguageDetailsForAuditEntry(IEnumerable languages, IEnumerable affectedCultures) + { + IEnumerable languageIsoCodes = languages + .Where(x => affectedCultures.InvariantContains(x.IsoCode)) + .Select(x => x.IsoCode); + return string.Join(", ", languageIsoCodes); + } + + #endregion + + #region Content Types + + private IContentType GetContentType(ICoreScope scope, string contentTypeAlias) + { + if (contentTypeAlias == null) + { + throw new ArgumentNullException(nameof(contentTypeAlias)); + } + + if (string.IsNullOrWhiteSpace(contentTypeAlias)) + { + throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(contentTypeAlias)); + } + + scope.ReadLock(ReadLockIds); + + IQuery query = Query().Where(x => x.Alias == contentTypeAlias); + IContentType? contentType = _contentTypeRepository.Get(query).FirstOrDefault() + ?? + // causes rollback + throw new Exception($"No ContentType matching the passed in Alias: '{contentTypeAlias}'" + + $" was found"); + + return contentType; + } + + protected IContentType GetContentType(string contentTypeAlias) + { + if (contentTypeAlias == null) + { + throw new ArgumentNullException(nameof(contentTypeAlias)); + } + + if (string.IsNullOrWhiteSpace(contentTypeAlias)) + { + throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(contentTypeAlias)); + } + + using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + return GetContentType(scope, contentTypeAlias); + } + } + + #endregion + + #region Publishing Strategies + + /// + /// Ensures that a document can be published + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + private PublishResult StrategyCanPublish( + ICoreScope scope, + TContent 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.Now; + 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 && SupportsBranchPublishing) + { + // 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( + TContent 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, + TContent content, + EventMessages evtMsgs, + IDictionary? notificationState) + { + // raise Unpublishing notification + CancelableEnumerableObjectNotification notification = UnpublishingNotification(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(TContent 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.Now); + 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 +} diff --git a/src/Umbraco.Core/UdiEntityTypeHelper.cs b/src/Umbraco.Core/UdiEntityTypeHelper.cs index 40d5b8dd5990..aa995e411717 100644 --- a/src/Umbraco.Core/UdiEntityTypeHelper.cs +++ b/src/Umbraco.Core/UdiEntityTypeHelper.cs @@ -46,6 +46,10 @@ public static string FromUmbracoObjectType(UmbracoObjectTypes umbracoObjectType) return Constants.UdiEntityType.FormsDataSource; case UmbracoObjectTypes.Language: return Constants.UdiEntityType.Language; + case UmbracoObjectTypes.Element: + return Constants.UdiEntityType.Element; + case UmbracoObjectTypes.ElementContainer: + return Constants.UdiEntityType.ElementContainer; } throw new NotSupportedException( diff --git a/src/Umbraco.Core/UdiParser.cs b/src/Umbraco.Core/UdiParser.cs index ed1f16ae2881..f3244650c31a 100644 --- a/src/Umbraco.Core/UdiParser.cs +++ b/src/Umbraco.Core/UdiParser.cs @@ -211,6 +211,7 @@ public static Dictionary GetKnownUdiTypes() => { Constants.UdiEntityType.DocumentType, UdiType.GuidUdi }, { Constants.UdiEntityType.DocumentTypeContainer, UdiType.GuidUdi }, { Constants.UdiEntityType.Element, UdiType.GuidUdi }, + { Constants.UdiEntityType.ElementContainer, UdiType.GuidUdi }, { Constants.UdiEntityType.Media, UdiType.GuidUdi }, { Constants.UdiEntityType.MediaType, UdiType.GuidUdi }, { Constants.UdiEntityType.MediaTypeContainer, UdiType.GuidUdi }, diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index 70e5969d0483..c85a156da4a4 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -412,6 +412,7 @@ public static IUmbracoBuilder AddCoreNotifications(this IUmbracoBuilder builder) .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() + .AddNotificationHandler() ; // add notification handlers for auditing diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs index 1b624aa5f618..3a9d7082df19 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs @@ -3,6 +3,7 @@ using Umbraco.Cms.Core.DynamicRoot.QuerySteps; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Persistence.Factories; using Umbraco.Cms.Infrastructure.Persistence.Repositories; using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; using Umbraco.Cms.Infrastructure.Services.Implement; @@ -83,6 +84,10 @@ internal static IUmbracoBuilder AddRepositories(this IUmbracoBuilder builder) builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); + builder.Services.AddUnique(); + builder.Services.AddUnique(); + // TODO ELEMENTS: implement versioning + // builder.Services.AddUnique(); return builder; } diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs index e99ce4658731..102cc743b6dd 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs @@ -1052,6 +1052,7 @@ private void CreateLockData() _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.WebhookLogs, Name = "WebhookLogs" }); _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.LongRunningOperations, Name = "LongRunningOperations" }); _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.DocumentUrls, Name = "DocumentUrls" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.ElementTree, Name = "ElementTree" }); } private void CreateContentTypeData() diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs index d454d97847ce..731ed5f4b61b 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs @@ -33,6 +33,7 @@ public class DatabaseSchemaCreator typeof(ContentVersionDto), typeof(MediaVersionDto), typeof(DocumentDto), + typeof(ElementDto), typeof(ContentTypeTemplateDto), typeof(DataTypeDto), typeof(DictionaryDto), @@ -71,6 +72,7 @@ public class DatabaseSchemaCreator typeof(UserStartNodeDto), typeof(ContentNuDto), typeof(DocumentVersionDto), + typeof(ElementVersionDto), typeof(DocumentUrlDto), typeof(KeyValueDto), typeof(UserLoginDto), @@ -78,6 +80,7 @@ public class DatabaseSchemaCreator typeof(AuditEntryDto), typeof(ContentVersionCultureVariationDto), typeof(DocumentCultureVariationDto), + typeof(ElementCultureVariationDto), typeof(ContentScheduleDto), typeof(LogViewerQueryDto), typeof(ContentVersionCleanupPolicyDto), diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/ElementCultureVariationDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/ElementCultureVariationDto.cs new file mode 100644 index 000000000000..d7c98f367e71 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/ElementCultureVariationDto.cs @@ -0,0 +1,52 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +[TableName(TableName)] +[PrimaryKey("id")] +[ExplicitColumns] +internal sealed class ElementCultureVariationDto +{ + public const string TableName = Constants.DatabaseSchema.Tables.ElementCultureVariation; + + [Column("id")] + [PrimaryKeyColumn] + public int Id { get; set; } + + [Column("nodeId")] + [ForeignKey(typeof(NodeDto))] + [Index(IndexTypes.UniqueNonClustered, Name = "IX_" + TableName + "_NodeId", ForColumns = "nodeId,languageId")] + public int NodeId { get; set; } + + [Column("languageId")] + [ForeignKey(typeof(LanguageDto))] + [Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_LanguageId")] + public int LanguageId { get; set; } + + // this is convenient to carry the culture around, but has no db counterpart + [Ignore] + public string? Culture { get; set; } + + // authority on whether a culture has been edited + [Column("edited")] + public bool Edited { get; set; } + + // de-normalized for perfs + // (means there is a current content version culture variation for the language) + [Column("available")] + public bool Available { get; set; } + + // de-normalized for perfs + // (means there is a published content version culture variation for the language) + [Column("published")] + public bool Published { get; set; } + + // de-normalized for perfs + // (when available, copies name from current content version culture variation for the language) + // (otherwise, it's the published one, 'cos we need to have one) + [Column("name")] + [NullSetting(NullSetting = NullSettings.Null)] + public string? Name { get; set; } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/ElementDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/ElementDto.cs new file mode 100644 index 000000000000..b84974c6f52e --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/ElementDto.cs @@ -0,0 +1,41 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +[TableName(TableName)] +[PrimaryKey("nodeId", AutoIncrement = false)] +[ExplicitColumns] +public sealed class ElementDto +{ + private const string TableName = Constants.DatabaseSchema.Tables.Element; + + [Column("nodeId")] + [PrimaryKeyColumn(AutoIncrement = false)] + [ForeignKey(typeof(ContentDto))] + public int NodeId { get; set; } + + [Column("published")] + [Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_Published")] + public bool Published { get; set; } + + [Column("edited")] + public bool Edited { get; set; } + + [ResultColumn] + [Reference(ReferenceType.OneToOne, ReferenceMemberName = "NodeId")] + public ContentDto ContentDto { get; set; } = null!; + + // although a content has many content versions, + // they can only be loaded one by one (as several content), + // so this here is a OneToOne reference + [ResultColumn] + [Reference(ReferenceType.OneToOne)] + public ElementVersionDto ElementVersionDto { get; set; } = null!; + + // same + [ResultColumn] + [Reference(ReferenceType.OneToOne)] + public ElementVersionDto? PublishedVersionDto { get; set; } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/ElementVersionDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/ElementVersionDto.cs new file mode 100644 index 000000000000..b8878b173efc --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/ElementVersionDto.cs @@ -0,0 +1,27 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +[TableName(TableName)] +[PrimaryKey("id", AutoIncrement = false)] +[ExplicitColumns] +public sealed class ElementVersionDto +{ + public const string TableName = Constants.DatabaseSchema.Tables.ElementVersion; + + [Column("id")] + [PrimaryKeyColumn(AutoIncrement = false)] + [ForeignKey(typeof(ContentVersionDto))] + [Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_id_published", ForColumns = "id,published", IncludeColumns = "templateId")] + public int Id { get; set; } + + [Column("published")] + [Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_published", ForColumns = "published", IncludeColumns = "id,templateId")] + public bool Published { get; set; } + + [ResultColumn] + [Reference(ReferenceType.OneToOne)] + public ContentVersionDto ContentVersionDto { get; set; } = null!; +} diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/ContentBaseFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/ContentBaseFactory.cs index 01817252dc59..c71f30d2a4fe 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/ContentBaseFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/ContentBaseFactory.cs @@ -73,6 +73,73 @@ public static Content BuildEntity(DocumentDto dto, IContentType? contentType) } } + /// + /// Builds an IElement item from a dto and content type. + /// + // TODO ELEMENTS: refactor and reuse code from BuildEntity(DocumentDto dto, IContentType? contentType) + public static IElement BuildEntity(ElementDto dto, IContentType? contentType) + { + ArgumentNullException.ThrowIfNull(contentType); + + ContentDto contentDto = dto.ContentDto; + NodeDto nodeDto = contentDto.NodeDto; + ElementVersionDto elementVersionDto = dto.ElementVersionDto; + ContentVersionDto contentVersionDto = elementVersionDto.ContentVersionDto; + ElementVersionDto? publishedVersionDto = dto.PublishedVersionDto; + + var content = new Element( + nodeDto.Text ?? throw new ArgumentException("The element did not have a name", nameof(dto)), + contentType); + + try + { + content.DisableChangeTracking(); + + content.Id = dto.NodeId; + content.Key = nodeDto.UniqueId; + content.VersionId = contentVersionDto.Id; + + content.Name = contentVersionDto.Text; + + content.Path = nodeDto.Path; + content.Level = nodeDto.Level; + content.ParentId = nodeDto.ParentId; + content.SortOrder = nodeDto.SortOrder; + content.Trashed = nodeDto.Trashed; + + content.CreatorId = nodeDto.UserId ?? Constants.Security.UnknownUserId; + content.WriterId = contentVersionDto.UserId ?? Constants.Security.UnknownUserId; + content.CreateDate = nodeDto.CreateDate; + content.UpdateDate = contentVersionDto.VersionDate; + + content.Published = dto.Published; + content.Edited = dto.Edited; + + if (publishedVersionDto != null) + { + // We need this to get the proper versionId to match to unpublished values. + // This is only needed if the content has been published before. + content.PublishedVersionId = publishedVersionDto.Id; + if (dto.Published) + { + content.PublishDate = publishedVersionDto.ContentVersionDto.VersionDate; + content.PublishName = publishedVersionDto.ContentVersionDto.Text; + content.PublisherId = publishedVersionDto.ContentVersionDto.UserId; + } + } + + // templates = ignored, managed by the repository + + // reset dirty initial properties (U4-1946) + content.ResetDirtyProperties(false); + return content; + } + finally + { + content.EnableChangeTracking(); + } + } + /// /// Builds a Media item from a dto and content type. /// @@ -188,8 +255,26 @@ public static DocumentDto BuildDto(IContent entity, Guid objectType) return dto; } + /// + /// Builds a dto from an IElement item. + /// + public static ElementDto BuildDto(IElement entity, Guid objectType) + { + ContentDto contentDto = BuildContentDto(entity, objectType); + + var dto = new ElementDto + { + NodeId = entity.Id, + Published = entity.Published, + ContentDto = contentDto, + ElementVersionDto = BuildElementVersionDto(entity, contentDto), + }; + + return dto; + } + public static IEnumerable<(ContentSchedule Model, ContentScheduleDto Dto)> BuildScheduleDto( - IContent entity, + IPublishableContentBase entity, ContentScheduleCollection contentSchedule, ILanguageRepository languageRepository) => contentSchedule.FullSchedule.Select(x => @@ -312,6 +397,21 @@ private static DocumentVersionDto BuildDocumentVersionDto(IContent entity, Conte return dto; } + // always build the current / VersionPk dto + // we're never going to build / save old versions (which are immutable) + private static ElementVersionDto BuildElementVersionDto(IElement entity, ContentDto contentDto) + { + var dto = new ElementVersionDto + { + Id = entity.VersionId, + Published = false, // always building the current, unpublished one + + ContentVersionDto = BuildContentVersionDto(entity, contentDto), + }; + + return dto; + } + private static MediaVersionDto BuildMediaVersionDto(MediaUrlGeneratorCollection mediaUrlGenerators, IMedia entity, ContentDto contentDto) { // try to get a path from the string being stored for media diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/ElementRepository.cs b/src/Umbraco.Infrastructure/Persistence/Factories/ElementRepository.cs new file mode 100644 index 000000000000..55a1c8b49620 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Factories/ElementRepository.cs @@ -0,0 +1,1654 @@ +using System.Globalization; +using Microsoft.Extensions.Logging; +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence; +using Umbraco.Cms.Core.Persistence.Querying; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Persistence.Querying; +using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Persistence.Factories; + +/// +/// Represents a repository for doing CRUD operations for . +/// +// TODO ELEMENTS: refactor and reuse code from DocumentRepository (note there is an NPoco issue with generics, so we have to live with a certain amount of code duplication) +public class ElementRepository : ContentRepositoryBase, IElementRepository +{ + private readonly AppCaches _appCaches; + private readonly ElementByGuidReadRepository _elementByGuidReadRepository; + private readonly IContentTypeRepository _contentTypeRepository; + private readonly IJsonSerializer _serializer; + private readonly ITagRepository _tagRepository; + + /// + /// Constructor + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// Lazy property value collection - must be lazy because we have a circular dependency since some property editors + /// require services, yet these services require property editors + /// + public ElementRepository( + IScopeAccessor scopeAccessor, + AppCaches appCaches, + ILogger logger, + ILoggerFactory loggerFactory, + IContentTypeRepository contentTypeRepository, + ITagRepository tagRepository, + ILanguageRepository languageRepository, + IRelationRepository relationRepository, + IRelationTypeRepository relationTypeRepository, + PropertyEditorCollection propertyEditors, + DataValueReferenceFactoryCollection dataValueReferenceFactories, + IDataTypeService dataTypeService, + IJsonSerializer serializer, + IEventAggregator eventAggregator) + : base(scopeAccessor, appCaches, logger, languageRepository, relationRepository, relationTypeRepository, + propertyEditors, dataValueReferenceFactories, dataTypeService, eventAggregator) + { + _contentTypeRepository = + contentTypeRepository ?? throw new ArgumentNullException(nameof(contentTypeRepository)); + _tagRepository = tagRepository ?? throw new ArgumentNullException(nameof(tagRepository)); + _serializer = serializer; + _appCaches = appCaches; + _elementByGuidReadRepository = new ElementByGuidReadRepository(this, scopeAccessor, appCaches, + loggerFactory.CreateLogger()); + } + + protected override ElementRepository This => this; + + /// + /// Default is to always ensure all elements have unique names + /// + protected virtual bool EnsureUniqueNaming { get; } = true; + + /// + public ContentScheduleCollection GetContentSchedule(int contentId) + { + var result = new ContentScheduleCollection(); + + List? scheduleDtos = Database.Fetch(Sql() + .Select() + .From() + .Where(x => x.NodeId == contentId)); + + foreach (ContentScheduleDto? scheduleDto in scheduleDtos) + { + result.Add(new ContentSchedule(scheduleDto.Id, + LanguageRepository.GetIsoCodeById(scheduleDto.LanguageId) ?? string.Empty, + scheduleDto.Date, + scheduleDto.Action == ContentScheduleAction.Release.ToString() + ? ContentScheduleAction.Release + : ContentScheduleAction.Expire)); + } + + return result; + } + + protected override string ApplySystemOrdering(ref Sql sql, Ordering ordering) + { + // note: 'updater' is the user who created the latest draft version, + // we don't have an 'updater' per culture (should we?) + if (ordering.OrderBy.InvariantEquals("updater")) + { + Sql joins = Sql() + .InnerJoin("updaterUser") + .On((version, user) => version.UserId == user.Id, + aliasRight: "updaterUser"); + + // see notes in ApplyOrdering: the field MUST be selected + aliased + sql = Sql( + InsertBefore(sql, "FROM", + ", " + SqlSyntax.GetFieldName(x => x.UserName, "updaterUser") + " AS ordering "), + sql.Arguments); + + sql = InsertJoins(sql, joins); + + return "ordering"; + } + + if (ordering.OrderBy.InvariantEquals("published")) + { + // no culture, assume invariant and simply order by published. + if (ordering.Culture.IsNullOrWhiteSpace()) + { + return SqlSyntax.GetFieldName(x => x.Published); + } + + // invariant: left join will yield NULL and we must use pcv to determine published + // variant: left join may yield NULL or something, and that determines published + + Sql joins = Sql() + .InnerJoin("ctype").On( + (content, contentType) => content.ContentTypeId == contentType.NodeId, aliasRight: "ctype") + // left join on optional culture variation + //the magic "[[[ISOCODE]]]" parameter value will be replaced in ContentRepositoryBase.GetPage() by the actual ISO code + .LeftJoin(nested => + nested.InnerJoin("langp").On( + (ccv, lang) => ccv.LanguageId == lang.Id && lang.IsoCode == "[[[ISOCODE]]]", "ccvp", + "langp"), + "ccvp") + .On((version, ccv) => version.Id == ccv.VersionId, + "pcv", "ccvp"); + + sql = InsertJoins(sql, joins); + + // see notes in ApplyOrdering: the field MUST be selected + aliased, and we cannot have + // the whole CASE fragment in ORDER BY due to it not being detected by NPoco + var sqlText = InsertBefore(sql.SQL, "FROM", + + // when invariant, ie 'variations' does not have the culture flag (value 1), it should be safe to simply use the published flag on umbracoElement, + // otherwise check if there's a version culture variation for the lang, via ccv.id + $", (CASE WHEN (ctype.variations & 1) = 0 THEN ({SqlSyntax.GetFieldName(x => x.Published)}) ELSE (CASE WHEN ccvp.id IS NULL THEN 0 ELSE 1 END) END) AS ordering "); // trailing space is important! + + sql = Sql(sqlText, sql.Arguments); + + return "ordering"; + } + + return base.ApplySystemOrdering(ref sql, ordering); + } + + private IEnumerable MapDtosToContent(List dtos, + bool withCache = false, + bool loadProperties = true, + bool loadVariants = true) + { + var temps = new List>(); + var contentTypes = new Dictionary(); + + var content = new IElement[dtos.Count]; + + for (var i = 0; i < dtos.Count; i++) + { + ElementDto dto = dtos[i]; + + if (withCache) + { + // if the cache contains the (proper version of the) item, use it + IElement? cached = + IsolatedCache.GetCacheItem(RepositoryCacheKeys.GetKey(dto.NodeId)); + if (cached != null && cached.VersionId == dto.ElementVersionDto.ContentVersionDto.Id) + { + content[i] = cached; + continue; + } + } + + // else, need to build it + + // get the content type - the repository is full cache *but* still deep-clones + // whatever comes out of it, so use our own local index here to avoid this + var contentTypeId = dto.ContentDto.ContentTypeId; + if (contentTypes.TryGetValue(contentTypeId, out IContentType? contentType) == false) + { + contentTypes[contentTypeId] = contentType = _contentTypeRepository.Get(contentTypeId); + } + + IElement c = content[i] = ContentBaseFactory.BuildEntity(dto, contentType); + + // need temps, for properties, templates and variations + var versionId = dto.ElementVersionDto.Id; + var publishedVersionId = dto.Published ? dto.PublishedVersionDto!.Id : 0; + var temp = new TempContent(dto.NodeId, versionId, publishedVersionId, contentType, c); + + temps.Add(temp); + } + + IDictionary? properties = null; + if (loadProperties) + { + // load all properties for all elements from database in 1 query - indexed by version id + properties = GetPropertyCollections(temps); + } + + // assign templates and properties + foreach (TempContent temp in temps) + { + // set properties + if (loadProperties) + { + if (properties?.ContainsKey(temp.VersionId) ?? false) + { + temp.Content!.Properties = properties[temp.VersionId]; + } + else + { + throw new InvalidOperationException($"No property data found for version: '{temp.VersionId}'."); + } + } + } + + if (loadVariants) + { + // set variations, if varying + temps = temps.Where(x => x.ContentType?.VariesByCulture() ?? false).ToList(); + if (temps.Count > 0) + { + // load all variations for all elements from database, in one query + IDictionary> contentVariations = GetContentVariations(temps); + IDictionary> elementVariations = GetElementVariations(temps); + foreach (TempContent temp in temps) + { + SetVariations(temp.Content, contentVariations, elementVariations); + } + } + } + + + foreach (IElement c in content) + { + c.ResetDirtyProperties(false); // reset dirty initial properties (U4-1946) + } + + return content; + } + + private IElement MapDtoToContent(ElementDto dto) + { + IContentType? contentType = _contentTypeRepository.Get(dto.ContentDto.ContentTypeId); + IElement content = ContentBaseFactory.BuildEntity(dto, contentType); + + try + { + content.DisableChangeTracking(); + + // get properties - indexed by version id + var versionId = dto.ElementVersionDto.Id; + + // TODO: shall we get published properties or not? + //var publishedVersionId = dto.Published ? dto.PublishedVersionDto.Id : 0; + var publishedVersionId = dto.PublishedVersionDto?.Id ?? 0; + + var temp = new TempContent(dto.NodeId, versionId, publishedVersionId, contentType); + var ltemp = new List> {temp}; + IDictionary properties = GetPropertyCollections(ltemp); + content.Properties = properties[dto.ElementVersionDto.Id]; + + // set variations, if varying + if (contentType?.VariesByCulture() ?? false) + { + IDictionary> contentVariations = GetContentVariations(ltemp); + IDictionary> elementVariations = GetElementVariations(ltemp); + SetVariations(content, contentVariations, elementVariations); + } + + // reset dirty initial properties (U4-1946) + content.ResetDirtyProperties(false); + return content; + } + finally + { + content.EnableChangeTracking(); + } + } + + private void SetVariations(IElement? element, IDictionary> contentVariations, + IDictionary> elementVariations) + { + if (element is null) + { + return; + } + + if (contentVariations.TryGetValue(element.VersionId, out List? contentVariation)) + { + foreach (ContentVariation v in contentVariation) + { + element.SetCultureInfo(v.Culture, v.Name, v.Date); + } + } + + if (element.PublishedState is PublishedState.Published && element.PublishedVersionId > 0 && contentVariations.TryGetValue(element.PublishedVersionId, out contentVariation)) + { + foreach (ContentVariation v in contentVariation) + { + element.SetPublishInfo(v.Culture, v.Name, v.Date); + } + } + + if (elementVariations.TryGetValue(element.Id, out List? elementVariation)) + { + element.SetCultureEdited(elementVariation.Where(x => x.Edited).Select(x => x.Culture)); + } + } + + private IDictionary> GetContentVariations(List> temps) + where T : class, IContentBase + { + var versions = new List(); + foreach (TempContent temp in temps) + { + versions.Add(temp.VersionId); + if (temp.PublishedVersionId > 0) + { + versions.Add(temp.PublishedVersionId); + } + } + + if (versions.Count == 0) + { + return new Dictionary>(); + } + + IEnumerable dtos = + Database.FetchByGroups(versions, Constants.Sql.MaxParameterCount, + batch + => Sql() + .Select() + .From() + .WhereIn(x => x.VersionId, batch)); + + var variations = new Dictionary>(); + + foreach (ContentVersionCultureVariationDto dto in dtos) + { + if (!variations.TryGetValue(dto.VersionId, out List? variation)) + { + variations[dto.VersionId] = variation = new List(); + } + + variation.Add(new ContentVariation + { + Culture = LanguageRepository.GetIsoCodeById(dto.LanguageId), Name = dto.Name, Date = dto.UpdateDate + }); + } + + return variations; + } + + private IDictionary> GetElementVariations(List> temps) + where T : class, IContentBase + { + IEnumerable ids = temps.Select(x => x.Id); + + IEnumerable dtos = Database.FetchByGroups(ids, + Constants.Sql.MaxParameterCount, batch => + Sql() + .Select() + .From() + .WhereIn(x => x.NodeId, batch)); + + var variations = new Dictionary>(); + + foreach (ElementCultureVariationDto dto in dtos) + { + if (!variations.TryGetValue(dto.NodeId, out List? variation)) + { + variations[dto.NodeId] = variation = new List(); + } + + variation.Add(new ElementVariation + { + Culture = LanguageRepository.GetIsoCodeById(dto.LanguageId), Edited = dto.Edited + }); + } + + return variations; + } + + private IEnumerable GetContentVariationDtos(IElement element, bool publishing) + { + if (element.CultureInfos is not null) + { + // create dtos for the 'current' (non-published) version, all cultures + // ReSharper disable once UseDeconstruction + foreach (ContentCultureInfos cultureInfo in element.CultureInfos) + { + yield return new ContentVersionCultureVariationDto + { + VersionId = element.VersionId, + LanguageId = + LanguageRepository.GetIdByIsoCode(cultureInfo.Culture) ?? + throw new InvalidOperationException("Not a valid culture."), + Culture = cultureInfo.Culture, + Name = cultureInfo.Name, + UpdateDate = + element.GetUpdateDate(cultureInfo.Culture) ?? DateTime.MinValue // we *know* there is a value + }; + } + } + + // if not publishing, we're just updating the 'current' (non-published) version, + // so there are no DTOs to create for the 'published' version which remains unchanged + if (!publishing) + { + yield break; + } + + if (element.PublishCultureInfos is not null) + { + // create dtos for the 'published' version, for published cultures (those having a name) + // ReSharper disable once UseDeconstruction + foreach (ContentCultureInfos cultureInfo in element.PublishCultureInfos) + { + yield return new ContentVersionCultureVariationDto + { + VersionId = element.PublishedVersionId, + LanguageId = + LanguageRepository.GetIdByIsoCode(cultureInfo.Culture) ?? + throw new InvalidOperationException("Not a valid culture."), + Culture = cultureInfo.Culture, + Name = cultureInfo.Name, + UpdateDate = + element.GetPublishDate(cultureInfo.Culture) ?? DateTime.MinValue // we *know* there is a value + }; + } + } + } + + private IEnumerable GetElementVariationDtos(IElement element, + HashSet editedCultures) + { + IEnumerable + allCultures = element.AvailableCultures.Union(element.PublishedCultures); // union = distinct + foreach (var culture in allCultures) + { + var dto = new ElementCultureVariationDto + { + NodeId = element.Id, + LanguageId = + LanguageRepository.GetIdByIsoCode(culture) ?? + throw new InvalidOperationException("Not a valid culture."), + Culture = culture, + Name = element.GetCultureName(culture) ?? element.GetPublishName(culture), + Available = element.IsCultureAvailable(culture), + Published = element.IsCulturePublished(culture), + // note: can't use IsCultureEdited at that point - hasn't been updated yet - see PersistUpdatedItem + Edited = element.IsCultureAvailable(culture) && + (!element.IsCulturePublished(culture) || + (editedCultures != null && editedCultures.Contains(culture))) + }; + + yield return dto; + } + } + + private class ContentVariation + { + public string? Culture { get; set; } + public string? Name { get; set; } + public DateTime Date { get; set; } + } + + private class ElementVariation + { + public string? Culture { get; set; } + public bool Edited { get; set; } + } + + #region Repository Base + + protected override Guid NodeObjectTypeId => Constants.ObjectTypes.Element; + + protected override IElement? PerformGet(int id) + { + Sql sql = GetBaseQuery(QueryType.Single) + .Where(x => x.NodeId == id) + .SelectTop(1); + + ElementDto? dto = Database.Fetch(sql).FirstOrDefault(); + return dto == null + ? null + : MapDtoToContent(dto); + } + + protected override IEnumerable PerformGetAll(params int[]? ids) + { + Sql sql = GetBaseQuery(QueryType.Many); + + if (ids?.Any() ?? false) + { + sql.WhereIn(x => x.NodeId, ids); + } + + return MapDtosToContent(Database.Fetch(sql)); + } + + protected override IEnumerable PerformGetByQuery(IQuery query) + { + Sql sqlClause = GetBaseQuery(QueryType.Many); + + var translator = new SqlTranslator(sqlClause, query); + Sql sql = translator.Translate(); + + AddGetByQueryOrderBy(sql); + + return MapDtosToContent(Database.Fetch(sql)); + } + + private void AddGetByQueryOrderBy(Sql sql) => + sql + .OrderBy(x => x.Level) + .OrderBy(x => x.SortOrder); + + protected override Sql GetBaseQuery(QueryType queryType) => GetBaseQuery(queryType, true); + + // gets the COALESCE expression for variant/invariant name + private string VariantNameSqlExpression + => SqlContext.VisitDto((ccv, node) => ccv.Name ?? node.Text, "ccv") + .Sql; + + protected Sql GetBaseQuery(QueryType queryType, bool current) + { + Sql sql = SqlContext.Sql(); + + switch (queryType) + { + case QueryType.Count: + sql = sql.SelectCount(); + break; + case QueryType.Ids: + sql = sql.Select(x => x.NodeId); + break; + case QueryType.Single: + case QueryType.Many: + // R# may flag this ambiguous and red-squiggle it, but it is not + sql = sql.Select(r => + r.Select(elementDto => elementDto.ContentDto, r1 => + r1.Select(contentDto => contentDto.NodeDto)) + .Select(elementDto => elementDto.ElementVersionDto, r1 => + r1.Select(elementVersionDto => elementVersionDto.ContentVersionDto)) + .Select(elementDto => elementDto.PublishedVersionDto, "pdv", r1 => + r1.Select(elementVersionDto => elementVersionDto!.ContentVersionDto, "pcv"))) + + // select the variant name, coalesce to the invariant name, as "variantName" + .AndSelect(VariantNameSqlExpression + " AS variantName"); + break; + } + + sql + .From() + .InnerJoin().On(left => left.NodeId, right => right.NodeId) + .InnerJoin().On(left => left.NodeId, right => right.NodeId) + + // inner join on mandatory edited version + .InnerJoin() + .On((left, right) => left.NodeId == right.NodeId) + .InnerJoin() + .On((left, right) => left.Id == right.Id) + + // left join on optional published version + .LeftJoin(nested => + nested.InnerJoin("pdv") + .On((left, right) => left.Id == right.Id && right.Published, + "pcv", "pdv"), "pcv") + .On((left, right) => left.NodeId == right.NodeId, aliasRight: "pcv") + + // TODO: should we be joining this when the query type is not single/many? + // left join on optional culture variation + //the magic "[[[ISOCODE]]]" parameter value will be replaced in ContentRepositoryBase.GetPage() by the actual ISO code + .LeftJoin(nested => + nested.InnerJoin("lang").On( + (ccv, lang) => ccv.LanguageId == lang.Id && lang.IsoCode == "[[[ISOCODE]]]", "ccv", "lang"), "ccv") + .On((version, ccv) => version.Id == ccv.VersionId, + aliasRight: "ccv"); + + sql + .Where(x => x.NodeObjectType == NodeObjectTypeId); + + // this would ensure we don't get the published version - keep for reference + //sql + // .WhereAny( + // x => x.Where((x1, x2) => x1.Id != x2.Id, alias2: "pcv"), + // x => x.WhereNull(x1 => x1.Id, "pcv") + // ); + + if (current) + { + sql.Where(x => x.Current); // always get the current version + } + + return sql; + } + + protected override Sql GetBaseQuery(bool isCount) => + GetBaseQuery(isCount ? QueryType.Count : QueryType.Single); + + // ah maybe not, that what's used for eg Exists in base repo + protected override string GetBaseWhereClause() => $"{Constants.DatabaseSchema.Tables.Node}.id = @id"; + + protected override IEnumerable GetDeleteClauses() + { + var list = new List + { + "DELETE FROM " + Constants.DatabaseSchema.Tables.ContentSchedule + " WHERE nodeId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.User2NodeNotify + " WHERE nodeId = @id", + // TODO ELEMENTS: include these if applicable, or clean up if not + // "DELETE FROM " + Constants.DatabaseSchema.Tables.UserGroup2GranularPermission + " WHERE uniqueId IN (SELECT uniqueId FROM umbracoNode WHERE id = @id)", + // "DELETE FROM " + Constants.DatabaseSchema.Tables.UserStartNode + " WHERE startNode = @id", + // "UPDATE " + Constants.DatabaseSchema.Tables.UserGroup + + // " SET startContentId = NULL WHERE startContentId = @id", + // "DELETE FROM " + Constants.DatabaseSchema.Tables.Relation + " WHERE parentId = @id", + // "DELETE FROM " + Constants.DatabaseSchema.Tables.Relation + " WHERE childId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.TagRelationship + " WHERE nodeId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.Element + " WHERE nodeId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.ElementCultureVariation + " WHERE nodeId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.ElementVersion + " WHERE id IN (SELECT id FROM " + + Constants.DatabaseSchema.Tables.ContentVersion + " WHERE nodeId = @id)", + "DELETE FROM " + Constants.DatabaseSchema.Tables.PropertyData + " WHERE versionId IN (SELECT id FROM " + + Constants.DatabaseSchema.Tables.ContentVersion + " WHERE nodeId = @id)", + "DELETE FROM " + Constants.DatabaseSchema.Tables.ContentVersionCultureVariation + + " WHERE versionId IN (SELECT id FROM " + Constants.DatabaseSchema.Tables.ContentVersion + + " WHERE nodeId = @id)", + "DELETE FROM " + Constants.DatabaseSchema.Tables.ContentVersion + " WHERE nodeId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.Content + " WHERE nodeId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.Node + " WHERE id = @id", + }; + return list; + } + + #endregion + + #region Versions + + public override IEnumerable GetAllVersions(int nodeId) + { + Sql sql = GetBaseQuery(QueryType.Many, false) + .Where(x => x.NodeId == nodeId) + .OrderByDescending(x => x.Current) + .AndByDescending(x => x.VersionDate); + + return MapDtosToContent(Database.Fetch(sql), true); + } + + // TODO: This method needs to return a readonly version of IContent! The content returned + // from this method does not contain all of the data required to re-persist it and if that + // is attempted some odd things will occur. + // Either we create an IContentReadOnly (which ultimately we should for vNext so we can + // differentiate between methods that return entities that can be re-persisted or not), or + // in the meantime to not break API compatibility, we can add a property to IContentBase + // (or go further and have it on IUmbracoEntity): "IsReadOnly" and if that is true we throw + // an exception if that entity is passed to a Save method. + // Ideally we return "Slim" versions of content for all sorts of methods here and in ContentService. + // Perhaps another non-breaking alternative is to have new services like IContentServiceReadOnly + // which can return IContentReadOnly. + // We have the ability with `MapDtosToContent` to reduce the amount of data looked up for a + // content item. Ideally for paged data that populates list views, these would be ultra slim + // content items, there's no reason to populate those with really anything apart from property data, + // but until we do something like the above, we can't do that since it would be breaking and unclear. + public override IEnumerable GetAllVersionsSlim(int nodeId, int skip, int take) + { + Sql sql = GetBaseQuery(QueryType.Many, false) + .Where(x => x.NodeId == nodeId) + .OrderByDescending(x => x.Current) + .AndByDescending(x => x.VersionDate); + + var pageIndex = skip / take; + + return MapDtosToContent(Database.Page(pageIndex + 1, take, sql).Items, true, + // load bare minimum, need variants though since this is used to rollback with variants + false, false); + } + + public override IElement? GetVersion(int versionId) + { + Sql sql = GetBaseQuery(QueryType.Single, false) + .Where(x => x.Id == versionId); + + ElementDto? dto = Database.Fetch(sql).FirstOrDefault(); + return dto == null ? null : MapDtoToContent(dto); + } + + // deletes a specific version + public override void DeleteVersion(int versionId) + { + // TODO: test object node type? + + // get the version we want to delete + SqlTemplate template = SqlContext.Templates.Get("Umbraco.Core.ElementRepository.GetVersion", tsql => + tsql.Select() + .AndSelect() + .From() + .InnerJoin() + .On((c, d) => c.Id == d.Id) + .Where(x => x.Id == SqlTemplate.Arg("versionId")) + ); + ElementVersionDto? versionDto = + Database.Fetch(template.Sql(new {versionId})).FirstOrDefault(); + + // nothing to delete + if (versionDto == null) + { + return; + } + + // don't delete the current or published version + if (versionDto.ContentVersionDto.Current) + { + throw new InvalidOperationException("Cannot delete the current version."); + } + + if (versionDto.Published) + { + throw new InvalidOperationException("Cannot delete the published version."); + } + + PerformDeleteVersion(versionDto.ContentVersionDto.NodeId, versionId); + } + + // deletes all versions of an entity, older than a date. + public override void DeleteVersions(int nodeId, DateTime versionDate) + { + // TODO: test object node type? + + // get the versions we want to delete, excluding the current one + SqlTemplate template = SqlContext.Templates.Get("Umbraco.Core.ElementRepository.GetVersions", tsql => + tsql.Select() + .From() + .InnerJoin() + .On((c, d) => c.Id == d.Id) + .Where(x => + x.NodeId == SqlTemplate.Arg("nodeId") && !x.Current && + x.VersionDate < SqlTemplate.Arg("versionDate")) + .Where(x => !x.Published) + ); + List? versionDtos = + Database.Fetch(template.Sql(new {nodeId, versionDate})); + foreach (ContentVersionDto? versionDto in versionDtos) + { + PerformDeleteVersion(versionDto.NodeId, versionDto.Id); + } + } + + protected override void PerformDeleteVersion(int id, int versionId) + { + Database.Delete("WHERE versionId = @versionId", new {versionId}); + Database.Delete("WHERE versionId = @versionId", new {versionId}); + Database.Delete("WHERE id = @versionId", new {versionId}); + Database.Delete("WHERE id = @versionId", new {versionId}); + } + + #endregion + + #region Persist + + protected override void PersistNewItem(IElement entity) + { + entity.AddingEntity(); + + var publishing = entity.PublishedState == PublishedState.Publishing; + + // sanitize names + SanitizeNames(entity, publishing); + + // ensure that strings don't contain characters that are invalid in xml + // TODO: do we really want to keep doing this here? + entity.SanitizeEntityPropertiesForXmlStorage(); + + // create the dto + ElementDto dto = ContentBaseFactory.BuildDto(entity, NodeObjectTypeId); + + // derive path and level from parent + NodeDto parent = GetParentNodeDto(entity.ParentId); + var level = parent.Level + 1; + + var sortOrderExists = SortorderExists(entity.ParentId, entity.SortOrder); + // if the sortorder of the entity already exists get a new one, else use the sortOrder of the entity + var sortOrder = sortOrderExists ? GetNewChildSortOrder(entity.ParentId, 0) : entity.SortOrder; + + // persist the node dto + NodeDto nodeDto = dto.ContentDto.NodeDto; + nodeDto.Path = parent.Path; + nodeDto.Level = Convert.ToInt16(level); + nodeDto.SortOrder = sortOrder; + + // see if there's a reserved identifier for this unique id + // and then either update or insert the node dto + var id = GetReservedId(nodeDto.UniqueId); + if (id > 0) + { + nodeDto.NodeId = id; + } + else + { + Database.Insert(nodeDto); + } + + nodeDto.Path = string.Concat(parent.Path, ",", nodeDto.NodeId); + nodeDto.ValidatePathWithException(); + Database.Update(nodeDto); + + // update entity + entity.Id = nodeDto.NodeId; + entity.Path = nodeDto.Path; + entity.SortOrder = sortOrder; + entity.Level = level; + + // persist the content dto + ContentDto contentDto = dto.ContentDto; + contentDto.NodeId = nodeDto.NodeId; + Database.Insert(contentDto); + + // persist the content version dto + ContentVersionDto contentVersionDto = dto.ElementVersionDto.ContentVersionDto; + contentVersionDto.NodeId = nodeDto.NodeId; + contentVersionDto.Current = !publishing; + Database.Insert(contentVersionDto); + entity.VersionId = contentVersionDto.Id; + + // persist the element version dto + ElementVersionDto elementVersionDto = dto.ElementVersionDto; + elementVersionDto.Id = entity.VersionId; + if (publishing) + { + elementVersionDto.Published = true; + } + + Database.Insert(elementVersionDto); + + // and again in case we're publishing immediately + if (publishing) + { + entity.PublishedVersionId = entity.VersionId; + contentVersionDto.Id = 0; + contentVersionDto.Current = true; + contentVersionDto.Text = entity.Name; + Database.Insert(contentVersionDto); + entity.VersionId = contentVersionDto.Id; + + elementVersionDto.Id = entity.VersionId; + elementVersionDto.Published = false; + Database.Insert(elementVersionDto); + } + + // persist the property data + IEnumerable propertyDataDtos = PropertyFactory.BuildDtos(entity.ContentType.Variations, + entity.VersionId, entity.PublishedVersionId, entity.Properties, LanguageRepository, out var edited, + out HashSet? editedCultures); + foreach (PropertyDataDto propertyDataDto in propertyDataDtos) + { + Database.Insert(propertyDataDto); + } + + // if !publishing, we may have a new name != current publish name, + // also impacts 'edited' + if (!publishing && entity.PublishName != entity.Name) + { + edited = true; + } + + // persist the element dto + // at that point, when publishing, the entity still has its old Published value + // so we need to explicitly update the dto to persist the correct value + if (entity.PublishedState == PublishedState.Publishing) + { + dto.Published = true; + } + + dto.NodeId = nodeDto.NodeId; + entity.Edited = dto.Edited = !dto.Published || edited; // if not published, always edited + Database.Insert(dto); + + // persist the variations + if (entity.ContentType.VariesByCulture()) + { + // names also impact 'edited' + // ReSharper disable once UseDeconstruction + foreach (ContentCultureInfos cultureInfo in entity.CultureInfos!) + { + if (cultureInfo.Name != entity.GetPublishName(cultureInfo.Culture)) + { + (editedCultures ??= new HashSet(StringComparer.OrdinalIgnoreCase)).Add(cultureInfo.Culture); + } + } + + // refresh content + entity.SetCultureEdited(editedCultures!); + + // bump dates to align cultures to version + entity.AdjustDates(contentVersionDto.VersionDate, publishing); + + // insert content variations + Database.BulkInsertRecords(GetContentVariationDtos(entity, publishing)); + + // insert element variations + Database.BulkInsertRecords(GetElementVariationDtos(entity, editedCultures!)); + } + + // trigger here, before we reset Published etc + // TODO ELEMENTS: implement + // OnUowRefreshedEntity(new ContentRefreshNotification(entity, new EventMessages())); + + // flip the entity's published property + // this also flips its published state + // note: what depends on variations (eg PublishNames) is managed directly by the content + if (entity.PublishedState == PublishedState.Publishing) + { + entity.Published = true; + entity.PublisherId = entity.WriterId; + entity.PublishName = entity.Name; + entity.PublishDate = entity.UpdateDate; + + SetEntityTags(entity, _tagRepository, _serializer); + } + else if (entity.PublishedState == PublishedState.Unpublishing) + { + entity.Published = false; + entity.PublisherId = null; + entity.PublishName = null; + entity.PublishDate = null; + + ClearEntityTags(entity, _tagRepository); + } + + PersistRelations(entity); + + entity.ResetDirtyProperties(); + + // troubleshooting + //if (Database.ExecuteScalar($"SELECT COUNT(*) FROM {Constants.DatabaseSchema.Tables.ElementVersion} JOIN {Constants.DatabaseSchema.Tables.ContentVersion} ON {Constants.DatabaseSchema.Tables.ElementVersion}.id={Constants.DatabaseSchema.Tables.ContentVersion}.id WHERE published=1 AND nodeId=" + content.Id) > 1) + //{ + // Debugger.Break(); + // throw new Exception("oops"); + //} + //if (Database.ExecuteScalar($"SELECT COUNT(*) FROM {Constants.DatabaseSchema.Tables.ElementVersion} JOIN {Constants.DatabaseSchema.Tables.ContentVersion} ON {Constants.DatabaseSchema.Tables.ElementVersion}.id={Constants.DatabaseSchema.Tables.ContentVersion}.id WHERE [current]=1 AND nodeId=" + content.Id) > 1) + //{ + // Debugger.Break(); + // throw new Exception("oops"); + //} + } + + protected override void PersistUpdatedItem(IElement entity) + { + var isEntityDirty = entity.IsDirty(); + var editedSnapshot = entity.Edited; + + // check if we need to make any database changes at all + if ((entity.PublishedState == PublishedState.Published || entity.PublishedState == PublishedState.Unpublished) + && !isEntityDirty && !entity.IsAnyUserPropertyDirty()) + { + return; // no change to save, do nothing, don't even update dates + } + + // whatever we do, we must check that we are saving the current version + ContentVersionDto? version = Database.Fetch(SqlContext.Sql().Select() + .From().Where(x => x.Id == entity.VersionId)).FirstOrDefault(); + if (version == null || !version.Current) + { + throw new InvalidOperationException("Cannot save a non-current version."); + } + + // update + entity.UpdatingEntity(); + + // Check if this entity is being moved as a descendant as part of a bulk moving operations. + // In this case we can bypass a lot of the below operations which will make this whole operation go much faster. + // When moving we don't need to create new versions, etc... because we cannot roll this operation back anyways. + var isMoving = entity.IsMoving(); + // TODO: I'm sure we can also detect a "Copy" (of a descendant) operation and probably perform similar checks below. + // There is probably more stuff that would be required for copying but I'm sure not all of this logic would be, we could more than likely boost + // copy performance by 95% just like we did for Move + + + var publishing = entity.PublishedState == PublishedState.Publishing; + + if (!isMoving) + { + // check if we need to create a new version + if (publishing && entity.PublishedVersionId > 0) + { + // published version is not published anymore + Database.Execute(Sql().Update(u => u.Set(x => x.Published, false)) + .Where(x => x.Id == entity.PublishedVersionId)); + } + + // sanitize names + SanitizeNames(entity, publishing); + + // ensure that strings don't contain characters that are invalid in xml + // TODO: do we really want to keep doing this here? + entity.SanitizeEntityPropertiesForXmlStorage(); + + // if parent has changed, get path, level and sort order + if (entity.IsPropertyDirty("ParentId")) + { + NodeDto parent = GetParentNodeDto(entity.ParentId); + entity.Path = string.Concat(parent.Path, ",", entity.Id); + entity.Level = parent.Level + 1; + entity.SortOrder = GetNewChildSortOrder(entity.ParentId, 0); + } + } + + // create the dto + ElementDto dto = ContentBaseFactory.BuildDto(entity, NodeObjectTypeId); + + // update the node dto + NodeDto nodeDto = dto.ContentDto.NodeDto; + nodeDto.ValidatePathWithException(); + Database.Update(nodeDto); + + if (!isMoving) + { + // update the content dto + Database.Update(dto.ContentDto); + + // update the content & element version dtos + ContentVersionDto contentVersionDto = dto.ElementVersionDto.ContentVersionDto; + ElementVersionDto elementVersionDto = dto.ElementVersionDto; + if (publishing) + { + elementVersionDto.Published = true; // now published + contentVersionDto.Current = false; // no more current + } + + // Ensure existing version retains current preventCleanup flag (both saving and publishing). + contentVersionDto.PreventCleanup = version.PreventCleanup; + + Database.Update(contentVersionDto); + Database.Update(elementVersionDto); + + // and, if publishing, insert new content & element version dtos + if (publishing) + { + entity.PublishedVersionId = entity.VersionId; + + contentVersionDto.Id = 0; // want a new id + contentVersionDto.Current = true; // current version + contentVersionDto.Text = entity.Name; + contentVersionDto.PreventCleanup = false; // new draft version disregards prevent cleanup flag + Database.Insert(contentVersionDto); + entity.VersionId = elementVersionDto.Id = contentVersionDto.Id; // get the new id + + elementVersionDto.Published = false; // non-published version + Database.Insert(elementVersionDto); + } + + // replace the property data (rather than updating) + // only need to delete for the version that existed, the new version (if any) has no property data yet + var versionToDelete = publishing ? entity.PublishedVersionId : entity.VersionId; + + // insert property data + ReplacePropertyValues(entity, versionToDelete, publishing ? entity.PublishedVersionId : 0, out var edited, + out HashSet? editedCultures); + + // if !publishing, we may have a new name != current publish name, + // also impacts 'edited' + if (!publishing && entity.PublishName != entity.Name) + { + edited = true; + } + + // To establish the new value of "edited" we compare all properties publishedValue to editedValue and look + // for differences. + // + // If we SaveAndPublish but the publish fails (e.g. already scheduled for release) + // we have lost the publishedValue on IElement (in memory vs database) so we cannot correctly make that comparison. + // + // This is a slight change to behaviour, historically a publish, followed by change & save, followed by undo change & save + // would change edited back to false. + if (!publishing && editedSnapshot) + { + edited = true; + } + + if (entity.ContentType.VariesByCulture()) + { + // names also impact 'edited' + // ReSharper disable once UseDeconstruction + foreach (ContentCultureInfos cultureInfo in entity.CultureInfos!) + { + if (cultureInfo.Name != entity.GetPublishName(cultureInfo.Culture)) + { + edited = true; + (editedCultures ??= new HashSet(StringComparer.OrdinalIgnoreCase)).Add(cultureInfo + .Culture); + + // TODO: change tracking + // at the moment, we don't do any dirty tracking on property values, so we don't know whether the + // culture has just been edited or not, so we don't update its update date - that date only changes + // when the name is set, and it all works because the controller does it - but, if someone uses a + // service to change a property value and save (without setting name), the update date does not change. + } + } + + // refresh content + entity.SetCultureEdited(editedCultures!); + + // bump dates to align cultures to version + entity.AdjustDates(contentVersionDto.VersionDate, publishing); + + // replace the content version variations (rather than updating) + // only need to delete for the version that existed, the new version (if any) has no property data yet + Sql deleteContentVariations = Sql().Delete() + .Where(x => x.VersionId == versionToDelete); + Database.Execute(deleteContentVariations); + + // replace the element version variations (rather than updating) + Sql deleteElementVariations = Sql().Delete() + .Where(x => x.NodeId == entity.Id); + Database.Execute(deleteElementVariations); + + // TODO: NPoco InsertBulk issue? + // we should use the native NPoco InsertBulk here but it causes problems (not sure exactly all scenarios) + // but by using SQL Server and updating a variants name will cause: Unable to cast object of type + // 'Umbraco.Core.Persistence.FaultHandling.RetryDbConnection' to type 'System.Data.SqlClient.SqlConnection'. + // (same in PersistNewItem above) + + // insert content variations + Database.BulkInsertRecords(GetContentVariationDtos(entity, publishing)); + + // insert element variations + Database.BulkInsertRecords(GetElementVariationDtos(entity, editedCultures!)); + } + + // update the element dto + // at that point, when un/publishing, the entity still has its old Published value + // so we need to explicitly update the dto to persist the correct value + if (entity.PublishedState == PublishedState.Publishing) + { + dto.Published = true; + } + else if (entity.PublishedState == PublishedState.Unpublishing) + { + dto.Published = false; + } + + entity.Edited = dto.Edited = !dto.Published || edited; // if not published, always edited + Database.Update(dto); + + // if entity is publishing, update tags, else leave tags there + // means that implicitly unpublished, or trashed, entities *still* have tags in db + if (entity.PublishedState == PublishedState.Publishing) + { + SetEntityTags(entity, _tagRepository, _serializer); + } + } + + // trigger here, before we reset Published etc + // TODO ELEMENTS: implement + // OnUowRefreshedEntity(new ContentRefreshNotification(entity, new EventMessages())); + + if (!isMoving) + { + // flip the entity's published property + // this also flips its published state + if (entity.PublishedState == PublishedState.Publishing) + { + entity.Published = true; + entity.PublisherId = entity.WriterId; + entity.PublishName = entity.Name; + entity.PublishDate = entity.UpdateDate; + + SetEntityTags(entity, _tagRepository, _serializer); + } + else if (entity.PublishedState == PublishedState.Unpublishing) + { + entity.Published = false; + entity.PublisherId = null; + entity.PublishName = null; + entity.PublishDate = null; + + ClearEntityTags(entity, _tagRepository); + } + + PersistRelations(entity); + + // TODO: note re. tags: explicitly unpublished entities have cleared tags, but masked or trashed entities *still* have tags in the db - so what? + } + + entity.ResetDirtyProperties(); + + // troubleshooting + //if (Database.ExecuteScalar($"SELECT COUNT(*) FROM {Constants.DatabaseSchema.Tables.ElementVersion} JOIN {Constants.DatabaseSchema.Tables.ContentVersion} ON {Constants.DatabaseSchema.Tables.ElementVersion}.id={Constants.DatabaseSchema.Tables.ContentVersion}.id WHERE published=1 AND nodeId=" + content.Id) > 1) + //{ + // Debugger.Break(); + // throw new Exception("oops"); + //} + //if (Database.ExecuteScalar($"SELECT COUNT(*) FROM {Constants.DatabaseSchema.Tables.ElementVersion} JOIN {Constants.DatabaseSchema.Tables.ContentVersion} ON {Constants.DatabaseSchema.Tables.ElementVersion}.id={Constants.DatabaseSchema.Tables.ContentVersion}.id WHERE [current]=1 AND nodeId=" + content.Id) > 1) + //{ + // Debugger.Break(); + // throw new Exception("oops"); + //} + } + + /// + public void PersistContentSchedule(IPublishableContentBase content, ContentScheduleCollection contentSchedule) + { + if (content == null) + { + throw new ArgumentNullException(nameof(content)); + } + + if (contentSchedule == null) + { + throw new ArgumentNullException(nameof(contentSchedule)); + } + + var schedules = ContentBaseFactory.BuildScheduleDto(content, contentSchedule, LanguageRepository).ToList(); + + //remove any that no longer exist + IEnumerable ids = schedules.Where(x => x.Model.Id != Guid.Empty).Select(x => x.Model.Id).Distinct(); + Database.Execute(Sql() + .Delete() + .Where(x => x.NodeId == content.Id) + .WhereNotIn(x => x.Id, ids)); + + //add/update the rest + foreach ((ContentSchedule Model, ContentScheduleDto Dto) schedule in schedules) + { + if (schedule.Model.Id == Guid.Empty) + { + schedule.Model.Id = schedule.Dto.Id = Guid.NewGuid(); + Database.Insert(schedule.Dto); + } + else + { + Database.Update(schedule.Dto); + } + } + } + + protected override void PersistDeletedItem(IElement entity) + { + // Raise event first else potential FK issues + OnUowRemovingEntity(entity); + + //now let the normal delete clauses take care of everything else + base.PersistDeletedItem(entity); + } + + #endregion + + #region Content Repository + + public int CountPublished(string? contentTypeAlias = null) + { + Sql sql = SqlContext.Sql(); + if (contentTypeAlias.IsNullOrWhiteSpace()) + { + sql.SelectCount() + .From() + .InnerJoin() + .On(left => left.NodeId, right => right.NodeId) + .Where(x => x.NodeObjectType == NodeObjectTypeId && x.Trashed == false) + .Where(x => x.Published); + } + else + { + sql.SelectCount() + .From() + .InnerJoin() + .On(left => left.NodeId, right => right.NodeId) + .InnerJoin() + .On(left => left.NodeId, right => right.NodeId) + .InnerJoin() + .On(left => left.NodeId, right => right.ContentTypeId) + .Where(x => x.NodeObjectType == NodeObjectTypeId && x.Trashed == false) + .Where(x => x.Alias == contentTypeAlias) + .Where(x => x.Published); + } + + return Database.ExecuteScalar(sql); + } + + /// + public override IEnumerable GetPage(IQuery? query, + long pageIndex, int pageSize, out long totalRecords, + IQuery? filter, Ordering? ordering) + { + Sql? filterSql = null; + + // if we have a filter, map its clauses to an Sql statement + if (filter != null) + { + // if the clause works on "name", we need to swap the field and use the variantName instead, + // so that querying also works on variant content (for instance when searching a listview). + + // figure out how the "name" field is going to look like - so we can look for it + var nameField = SqlContext.VisitModelField(x => x.Name); + + filterSql = Sql(); + foreach (Tuple filterClause in filter.GetWhereClauses()) + { + var clauseSql = filterClause.Item1; + var clauseArgs = filterClause.Item2; + + // replace the name field + // we cannot reference an aliased field in a WHERE clause, so have to repeat the expression here + clauseSql = clauseSql.Replace(nameField, VariantNameSqlExpression); + + // append the clause + filterSql.Append($"AND ({clauseSql})", clauseArgs); + } + } + + return GetPage(query, pageIndex, pageSize, out totalRecords, + x => MapDtosToContent(x), + filterSql, + ordering); + } + + // NOTE: Elements cannot have unpublished parents + public bool IsPathPublished(IElement? content) + => content is { Trashed: false, Published: true }; + + #endregion + + #region Recycle Bin + + public override int RecycleBinId => Constants.System.RecycleBinContent; + + public bool RecycleBinSmells() + { + IAppPolicyCache cache = _appCaches.RuntimeCache; + var cacheKey = CacheKeys.ContentRecycleBinCacheKey; + + // always cache either true or false + return cache.GetCacheItem(cacheKey, () => CountChildren(RecycleBinId) > 0); + } + + #endregion + + #region Read Repository implementation for Guid keys + + public IElement? Get(Guid id) => _elementByGuidReadRepository.Get(id); + + IEnumerable IReadRepository.GetMany(params Guid[]? ids) => + _elementByGuidReadRepository.GetMany(ids); + + public bool Exists(Guid id) => _elementByGuidReadRepository.Exists(id); + + // reading repository purely for looking up by GUID + // TODO: ugly and to fix we need to decouple the IRepositoryQueryable -> IRepository -> IReadRepository which should all be separate things! + // This sub-repository pattern is super old and totally unecessary anymore, caching can be handled in much nicer ways without this + private class ElementByGuidReadRepository : EntityRepositoryBase + { + private readonly ElementRepository _outerRepo; + + public ElementByGuidReadRepository(ElementRepository outerRepo, IScopeAccessor scopeAccessor, AppCaches cache, + ILogger logger) + : base(scopeAccessor, cache, logger) => + _outerRepo = outerRepo; + + protected override IElement? PerformGet(Guid id) + { + Sql sql = _outerRepo.GetBaseQuery(QueryType.Single) + .Where(x => x.UniqueId == id); + + ElementDto? dto = Database.Fetch(sql.SelectTop(1)).FirstOrDefault(); + + if (dto == null) + { + return null; + } + + IElement element = _outerRepo.MapDtoToContent(dto); + + return element; + } + + protected override IEnumerable PerformGetAll(params Guid[]? ids) + { + Sql sql = _outerRepo.GetBaseQuery(QueryType.Many); + if (ids?.Length > 0) + { + sql.WhereIn(x => x.UniqueId, ids); + } + + return _outerRepo.MapDtosToContent(Database.Fetch(sql)); + } + + protected override IEnumerable PerformGetByQuery(IQuery query) => + throw new InvalidOperationException("This method won't be implemented."); + + protected override IEnumerable GetDeleteClauses() => + throw new InvalidOperationException("This method won't be implemented."); + + protected override void PersistNewItem(IElement entity) => + throw new InvalidOperationException("This method won't be implemented."); + + protected override void PersistUpdatedItem(IElement entity) => + throw new InvalidOperationException("This method won't be implemented."); + + protected override Sql GetBaseQuery(bool isCount) => + throw new InvalidOperationException("This method won't be implemented."); + + protected override string GetBaseWhereClause() => + throw new InvalidOperationException("This method won't be implemented."); + } + + #endregion + + #region Schedule + + /// + public void ClearSchedule(DateTime date) + { + Sql sql = Sql().Delete().Where(x => x.Date <= date); + Database.Execute(sql); + } + + /// + public void ClearSchedule(DateTime date, ContentScheduleAction action) + { + var a = action.ToString(); + Sql sql = Sql().Delete() + .Where(x => x.Date <= date && x.Action == a); + Database.Execute(sql); + } + + private Sql GetSqlForHasScheduling(ContentScheduleAction action, DateTime date) + { + SqlTemplate template = SqlContext.Templates.Get("Umbraco.Core.ElementRepository.GetSqlForHasScheduling", + tsql => tsql + .SelectCount() + .From() + .Where(x => + x.Action == SqlTemplate.Arg("action") && x.Date <= SqlTemplate.Arg("date"))); + + Sql sql = template.Sql(action.ToString(), date); + return sql; + } + + public bool HasContentForExpiration(DateTime date) + { + Sql sql = GetSqlForHasScheduling(ContentScheduleAction.Expire, date); + return Database.ExecuteScalar(sql) > 0; + } + + public bool HasContentForRelease(DateTime date) + { + Sql sql = GetSqlForHasScheduling(ContentScheduleAction.Release, date); + return Database.ExecuteScalar(sql) > 0; + } + + /// + public IEnumerable GetContentForRelease(DateTime date) + { + var action = ContentScheduleAction.Release.ToString(); + + Sql sql = GetBaseQuery(QueryType.Many) + .WhereIn(x => x.NodeId, Sql() + .Select(x => x.NodeId) + .From() + .Where(x => x.Action == action && x.Date <= date)); + + AddGetByQueryOrderBy(sql); + + return MapDtosToContent(Database.Fetch(sql)); + } + + /// + public IEnumerable GetContentForExpiration(DateTime date) + { + var action = ContentScheduleAction.Expire.ToString(); + + Sql sql = GetBaseQuery(QueryType.Many) + .WhereIn(x => x.NodeId, Sql() + .Select(x => x.NodeId) + .From() + .Where(x => x.Action == action && x.Date <= date)); + + AddGetByQueryOrderBy(sql); + + return MapDtosToContent(Database.Fetch(sql)); + } + + #endregion + + #region Utilities + + private void SanitizeNames(IElement content, bool publishing) + { + // a content item *must* have an invariant name, and invariant published name + // else we just cannot write the invariant rows (node, content version...) to the database + + // ensure that we have an invariant name + // invariant content = must be there already, else throw + // variant content = update with default culture or anything really + EnsureInvariantNameExists(content); + + // ensure that invariant name is unique + EnsureInvariantNameIsUnique(content); + + // and finally, + // ensure that each culture has a unique node name + // no published name = not published + // else, it needs to be unique + EnsureVariantNamesAreUnique(content, publishing); + } + + private void EnsureInvariantNameExists(IElement content) + { + if (content.ContentType.VariesByCulture()) + { + // content varies by culture + // then it must have at least a variant name, else it makes no sense + if (content.CultureInfos?.Count == 0) + { + throw new InvalidOperationException("Cannot save content with an empty name."); + } + + // and then, we need to set the invariant name implicitly, + // using the default culture if it has a name, otherwise anything we can + var defaultCulture = LanguageRepository.GetDefaultIsoCode(); + content.Name = defaultCulture != null && + (content.CultureInfos?.TryGetValue(defaultCulture, out ContentCultureInfos cultureName) ?? + false) + ? cultureName.Name! + : content.CultureInfos![0].Name!; + } + else + { + // content is invariant, and invariant content must have an explicit invariant name + if (string.IsNullOrWhiteSpace(content.Name)) + { + throw new InvalidOperationException("Cannot save content with an empty name."); + } + } + } + + private void EnsureInvariantNameIsUnique(IElement content) => + content.Name = EnsureUniqueNodeName(content.ParentId, content.Name, content.Id); + + protected override string? EnsureUniqueNodeName(int parentId, string? nodeName, int id = 0) => + EnsureUniqueNaming == false ? nodeName : base.EnsureUniqueNodeName(parentId, nodeName, id); + + private SqlTemplate SqlEnsureVariantNamesAreUnique => SqlContext.Templates.Get( + "Umbraco.Core.DomainRepository.EnsureVariantNamesAreUnique", tsql => tsql + .Select(x => x.Id, x => x.Name, x => x.LanguageId) + .From() + .InnerJoin() + .On(x => x.Id, x => x.VersionId) + .InnerJoin().On(x => x.NodeId, x => x.NodeId) + .Where(x => x.Current == SqlTemplate.Arg("current")) + .Where(x => x.NodeObjectType == SqlTemplate.Arg("nodeObjectType") && + x.ParentId == SqlTemplate.Arg("parentId") && + x.NodeId != SqlTemplate.Arg("id")) + .OrderBy(x => x.LanguageId)); + + private void EnsureVariantNamesAreUnique(IElement content, bool publishing) + { + if (!EnsureUniqueNaming || !content.ContentType.VariesByCulture() || content.CultureInfos?.Count == 0) + { + return; + } + + // get names per culture, at same level (ie all siblings) + Sql sql = SqlEnsureVariantNamesAreUnique.Sql(true, NodeObjectTypeId, content.ParentId, content.Id); + var names = Database.Fetch(sql) + .GroupBy(x => x.LanguageId) + .ToDictionary(x => x.Key, x => x); + + if (names.Count == 0) + { + return; + } + + // note: the code below means we are going to unique-ify every culture names, regardless + // of whether the name has changed (ie the culture has been updated) - some saving culture + // fr-FR could cause culture en-UK name to change - not sure that is clean + + if (content.CultureInfos is null) + { + return; + } + + foreach (ContentCultureInfos cultureInfo in content.CultureInfos) + { + var langId = LanguageRepository.GetIdByIsoCode(cultureInfo.Culture); + if (!langId.HasValue) + { + continue; + } + + if (!names.TryGetValue(langId.Value, out IGrouping? cultureNames)) + { + continue; + } + + // get a unique name + IEnumerable otherNames = + cultureNames.Select(x => new SimilarNodeName {Id = x.Id, Name = x.Name}); + var uniqueName = SimilarNodeName.GetUniqueName(otherNames, 0, cultureInfo.Name); + + if (uniqueName == content.GetCultureName(cultureInfo.Culture)) + { + continue; + } + + // update the name, and the publish name if published + content.SetCultureName(uniqueName, cultureInfo.Culture); + if (publishing && (content.PublishCultureInfos?.ContainsKey(cultureInfo.Culture) ?? false)) + { + content.SetPublishInfo(cultureInfo.Culture, uniqueName, + DateTime.Now); //TODO: This is weird, this call will have already been made in the SetCultureName + } + } + } + + // ReSharper disable once ClassNeverInstantiated.Local + private class CultureNodeName + { + public int Id { get; set; } + public string? Name { get; set; } + public int LanguageId { get; set; } + } + + #endregion +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentRepository.cs index 687a878c83c6..569811d225ed 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentRepository.cs @@ -1346,7 +1346,7 @@ protected override void PersistUpdatedItem(IContent entity) } /// - public void PersistContentSchedule(IContent content, ContentScheduleCollection contentSchedule) + public void PersistContentSchedule(IPublishableContentBase content, ContentScheduleCollection contentSchedule) { if (content == null) { diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ElementContainerRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ElementContainerRepository.cs new file mode 100644 index 000000000000..34ad09cdfe26 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ElementContainerRepository.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Infrastructure.Scoping; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; + +internal sealed class ElementContainerRepository : EntityContainerRepository, IElementContainerRepository +{ + public ElementContainerRepository( + IScopeAccessor scopeAccessor, + AppCaches cache, + ILogger logger) + : base(scopeAccessor, cache, logger, Constants.ObjectTypes.ElementContainer) + { + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityContainerRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityContainerRepository.cs index ca329b4e7227..2c2ec2ad63da 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityContainerRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityContainerRepository.cs @@ -24,6 +24,7 @@ public EntityContainerRepository(IScopeAccessor scopeAccessor, AppCaches cache, { Constants.ObjectTypes.DocumentTypeContainer, Constants.ObjectTypes.MediaTypeContainer, Constants.ObjectTypes.DataTypeContainer, Constants.ObjectTypes.DocumentBlueprintContainer, + Constants.ObjectTypes.ElementContainer, }; NodeObjectTypeId = containerObjectType; if (allowedContainers.Contains(NodeObjectTypeId) == false) diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs index d98657578af0..e22f5c1dbede 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs @@ -69,8 +69,9 @@ public IEnumerable GetPagedResultsByQuery(IQuery qu objectType == Constants.ObjectTypes.Document || objectType == Constants.ObjectTypes.DocumentBlueprint); var isMedia = objectTypes.Any(objectType => objectType == Constants.ObjectTypes.Media); var isMember = objectTypes.Any(objectType => objectType == Constants.ObjectTypes.Member); + var isElement = objectTypes.Any(objectType => objectType == Constants.ObjectTypes.Element); - Sql sql = GetBaseWhere(isContent, isMedia, isMember, false, s => + Sql sql = GetBaseWhere(isContent, isMedia, isMember, isElement, false, s => { sqlCustomization?.Invoke(s); @@ -87,7 +88,7 @@ public IEnumerable GetPagedResultsByQuery(IQuery qu var translator = new SqlTranslator(sql, query); sql = translator.Translate(); - sql = AddGroupBy(isContent, isMedia, isMember, sql, ordering.IsEmpty); + sql = AddGroupBy(isContent, isMedia, isMember, isElement, sql, ordering.IsEmpty); if (!ordering.IsEmpty) { @@ -111,13 +112,13 @@ public IEnumerable GetPagedResultsByQuery(IQuery qu public IEntitySlim? Get(Guid key) { - Sql sql = GetBaseWhere(false, false, false, false, key); + Sql sql = GetBaseWhere(false, false, false, false, false, key); BaseDto? dto = Database.FirstOrDefault(sql); return dto == null ? null : BuildEntity(dto); } - private IEntitySlim? GetEntity(Sql sql, bool isContent, bool isMedia, bool isMember) + private IEntitySlim? GetEntity(Sql sql, bool isContent, bool isMedia, bool isMember, bool isElement) { // isContent is going to return a 1:M result now with the variants so we need to do different things if (isContent) @@ -127,6 +128,13 @@ public IEnumerable GetPagedResultsByQuery(IQuery qu return cdtos.Count == 0 ? null : BuildVariants(BuildDocumentEntity(cdtos[0])); } + if (isElement) + { + List? cdtos = Database.Fetch(sql); + + return cdtos.Count == 0 ? null : BuildVariants(BuildElementEntity(cdtos[0])); + } + BaseDto? dto = isMedia ? Database.FirstOrDefault(sql) : Database.FirstOrDefault(sql); @@ -268,14 +276,15 @@ private long GetNumberOfSiblingsOutsideSiblingRange( objectTypeId == Constants.ObjectTypes.DocumentBlueprint; var isMedia = objectTypeId == Constants.ObjectTypes.Media; var isMember = objectTypeId == Constants.ObjectTypes.Member; + var isElement = objectTypeId == Constants.ObjectTypes.Element; - Sql sql = GetFullSqlForEntityType(isContent, isMedia, isMember, objectTypeId, key); - return GetEntity(sql, isContent, isMedia, isMember); + Sql sql = GetFullSqlForEntityType(isContent, isMedia, isMember, isElement, objectTypeId, key); + return GetEntity(sql, isContent, isMedia, isMember, isElement); } public IEntitySlim? Get(int id) { - Sql sql = GetBaseWhere(false, false, false, false, id); + Sql sql = GetBaseWhere(false, false, false, false, false, id); BaseDto? dto = Database.FirstOrDefault(sql); return dto == null ? null : BuildEntity(dto); } @@ -286,9 +295,10 @@ private long GetNumberOfSiblingsOutsideSiblingRange( objectTypeId == Constants.ObjectTypes.DocumentBlueprint; var isMedia = objectTypeId == Constants.ObjectTypes.Media; var isMember = objectTypeId == Constants.ObjectTypes.Member; + var isElement = objectTypeId == Constants.ObjectTypes.Element; - Sql sql = GetFullSqlForEntityType(isContent, isMedia, isMember, objectTypeId, id); - return GetEntity(sql, isContent, isMedia, isMember); + Sql sql = GetFullSqlForEntityType(isContent, isMedia, isMember, isElement, objectTypeId, id); + return GetEntity(sql, isContent, isMedia, isMember, isElement); } public IEnumerable GetAll(Guid objectType, params int[] ids) => @@ -301,7 +311,7 @@ public IEnumerable GetAll(Guid objectType, params Guid[] keys) => ? PerformGetAll(objectType, sql => sql.WhereIn(x => x.UniqueId, keys.Distinct())) : PerformGetAll(objectType); - private IEnumerable GetEntities(Sql sql, bool isContent, bool isMedia, bool isMember) + private IEnumerable GetEntities(Sql sql, bool isContent, bool isMedia, bool isMember, bool isElement) { // isContent is going to return a 1:M result now with the variants so we need to do different things if (isContent) @@ -313,6 +323,15 @@ private IEnumerable GetEntities(Sql sql, bool isConten : BuildVariants(cdtos.Select(BuildDocumentEntity)).ToList(); } + if (isElement) + { + List? cdtos = Database.Fetch(sql); + + return cdtos.Count == 0 + ? Enumerable.Empty() + : BuildVariants(cdtos.Select(BuildElementEntity)).ToList(); + } + IEnumerable? dtos = isMedia ? (IEnumerable)Database.Fetch(sql) : Database.Fetch(sql); @@ -328,9 +347,10 @@ private IEnumerable PerformGetAll(Guid objectType, Action sql = GetFullSqlForEntityType(isContent, isMedia, isMember, objectType, filter); - return GetEntities(sql, isContent, isMedia, isMember); + Sql sql = GetFullSqlForEntityType(isContent, isMedia, isMember, isElement, objectType, filter); + return GetEntities(sql, isContent, isMedia, isMember, isElement); } private IEnumerable PerformGetAll( @@ -342,9 +362,10 @@ private IEnumerable PerformGetAll( objectTypes.Contains(Constants.ObjectTypes.DocumentBlueprint); var isMedia = objectTypes.Contains(Constants.ObjectTypes.Media); var isMember = objectTypes.Contains(Constants.ObjectTypes.Member); + var isElement = objectTypes.Contains(Constants.ObjectTypes.Element); - Sql sql = GetFullSqlForEntityType(isContent, isMedia, isMember, objectTypes, ordering, filter); - return GetEntities(sql, isContent, isMedia, isMember); + Sql sql = GetFullSqlForEntityType(isContent, isMedia, isMember, isElement, objectTypes, ordering, filter); + return GetEntities(sql, isContent, isMedia, isMember, isElement); } public IEnumerable GetAllPaths(Guid objectType, params int[]? ids) => @@ -368,10 +389,10 @@ private IEnumerable PerformGetAllPaths(Guid objectType, Action GetByQuery(IQuery query) { - Sql sqlClause = GetBase(false, false, false, null); + Sql sqlClause = GetBase(false, false, false, false, null); var translator = new SqlTranslator(sqlClause, query); Sql sql = translator.Translate(); - sql = AddGroupBy(false, false, false, sql, true); + sql = AddGroupBy(false, false, false, false, sql, true); List? dtos = Database.Fetch(sql); return dtos.Select(BuildEntity).ToList(); } @@ -382,14 +403,15 @@ public IEnumerable GetByQuery(IQuery query, Guid ob objectType == Constants.ObjectTypes.DocumentBlueprint; var isMedia = objectType == Constants.ObjectTypes.Media; var isMember = objectType == Constants.ObjectTypes.Member; + var isElement = objectType == Constants.ObjectTypes.Element; - Sql sql = GetBaseWhere(isContent, isMedia, isMember, false, null, new[] { objectType }); + Sql sql = GetBaseWhere(isContent, isMedia, isMember, isElement, false, null, new[] { objectType }); var translator = new SqlTranslator(sql, query); sql = translator.Translate(); - sql = AddGroupBy(isContent, isMedia, isMember, sql, true); + sql = AddGroupBy(isContent, isMedia, isMember, isElement, sql, true); - return GetEntities(sql, isContent, isMedia, isMember); + return GetEntities(sql, isContent, isMedia, isMember, isElement); } public UmbracoObjectTypes GetObjectType(int id) @@ -479,18 +501,20 @@ public bool Exists(int id) return Database.ExecuteScalar(sql) > 0; } - private DocumentEntitySlim BuildVariants(DocumentEntitySlim entity) - => BuildVariants(new[] { entity }).First(); + private TEntity BuildVariants(TEntity entity) + where TEntity : PublishableContentEntitySlim + => BuildVariants([entity]).First(); - private IEnumerable BuildVariants(IEnumerable entities) + private IEnumerable BuildVariants(IEnumerable entities) + where TEntity : PublishableContentEntitySlim { - List? v = null; + List? v = null; var entitiesList = entities.ToList(); - foreach (DocumentEntitySlim e in entitiesList) + foreach (TEntity e in entitiesList) { if (e.Variations.VariesByCulture()) { - (v ??= new List()).Add(e); + (v ??= new List()).Add(e); } } @@ -508,7 +532,7 @@ private IEnumerable BuildVariants(IEnumerable x.NodeId).ToDictionary(x => x.Key, x => x); - foreach (DocumentEntitySlim e in v) + foreach (TEntity e in v) { // since we're only iterating on entities that vary, we must have something IGrouping edtos = xdtos[e.Id]; @@ -560,59 +584,61 @@ protected Sql GetVariantInfos(IEnumerable ids) => .OrderBy(x => x.Id); // gets the full sql for a given object type and a given unique id - protected Sql GetFullSqlForEntityType(bool isContent, bool isMedia, bool isMember, Guid objectType, + protected Sql GetFullSqlForEntityType(bool isContent, bool isMedia, bool isMember, bool isElement, Guid objectType, Guid uniqueId) { - Sql sql = GetBaseWhere(isContent, isMedia, isMember, false, objectType, uniqueId); - return AddGroupBy(isContent, isMedia, isMember, sql, true); + Sql sql = GetBaseWhere(isContent, isMedia, isMember, isElement, false, objectType, uniqueId); + return AddGroupBy(isContent, isMedia, isMember, isElement, sql, true); } // gets the full sql for a given object type and a given node id - protected Sql GetFullSqlForEntityType(bool isContent, bool isMedia, bool isMember, Guid objectType, + protected Sql GetFullSqlForEntityType(bool isContent, bool isMedia, bool isMember, bool isElement, Guid objectType, int nodeId) { - Sql sql = GetBaseWhere(isContent, isMedia, isMember, false, objectType, nodeId); - return AddGroupBy(isContent, isMedia, isMember, sql, true); + Sql sql = GetBaseWhere(isContent, isMedia, isMember, isElement, false, objectType, nodeId); + return AddGroupBy(isContent, isMedia, isMember, isElement, sql, true); } // gets the full sql for a given object type, with a given filter - protected Sql GetFullSqlForEntityType(bool isContent, bool isMedia, bool isMember, Guid objectType, + protected Sql GetFullSqlForEntityType(bool isContent, bool isMedia, bool isMember, bool isElement, Guid objectType, Action>? filter) { - Sql sql = GetBaseWhere(isContent, isMedia, isMember, false, filter, new[] { objectType }); - return AddGroupBy(isContent, isMedia, isMember, sql, true); + Sql sql = GetBaseWhere(isContent, isMedia, isMember, isElement, false, filter, new[] { objectType }); + return AddGroupBy(isContent, isMedia, isMember, isElement, sql, true); } protected Sql GetFullSqlForEntityType( bool isContent, bool isMedia, bool isMember, + bool isElement, Guid objectType, Ordering ordering, Action>? filter) - => GetFullSqlForEntityType(isContent, isMedia, isMember, [objectType], ordering, filter); + => GetFullSqlForEntityType(isContent, isMedia, isMember, isElement, [objectType], ordering, filter); protected Sql GetFullSqlForEntityType( bool isContent, bool isMedia, bool isMember, + bool isElement, Guid[] objectTypes, Ordering ordering, Action>? filter) { - Sql sql = GetBaseWhere(isContent, isMedia, isMember, false, filter, objectTypes); - AddGroupBy(isContent, isMedia, isMember, sql, false); + Sql sql = GetBaseWhere(isContent, isMedia, isMember, isElement, false, filter, objectTypes); + AddGroupBy(isContent, isMedia, isMember, isElement, sql, false); ApplyOrdering(ref sql, ordering); return sql; } - protected Sql GetBase(bool isContent, bool isMedia, bool isMember, Action>? filter, bool isCount = false) - => GetBase(isContent, isMedia, isMember, filter, [], isCount); + protected Sql GetBase(bool isContent, bool isMedia, bool isMember, bool isElement, Action>? filter, bool isCount = false) + => GetBase(isContent, isMedia, isMember, isElement, filter, [], isCount); // gets the base SELECT + FROM [+ filter] sql // always from the 'current' content version - protected Sql GetBase(bool isContent, bool isMedia, bool isMember, Action>? filter, Guid[] objectTypes, bool isCount = false) + protected Sql GetBase(bool isContent, bool isMedia, bool isMember, bool isElement, Action>? filter, Guid[] objectTypes, bool isCount = false) { Sql sql = Sql(); @@ -640,7 +666,7 @@ protected Sql GetBase(bool isContent, bool isMedia, bool isMember, sql.Append($", SUM(CASE WHEN child.nodeObjectType IN ('{objectTypesForInClause}') THEN 1 ELSE 0 END) AS children"); } - if (isContent || isMedia || isMember) + if (isContent || isMedia || isMember || isElement) { sql .AndSelect(x => Alias(x.Id, "versionId"), x => x.VersionDate) @@ -659,6 +685,12 @@ protected Sql GetBase(bool isContent, bool isMedia, bool isMember, .AndSelect(x => x.Published, x => x.Edited); } + if (isElement) + { + sql + .AndSelect(x => x.Published, x => x.Edited); + } + if (isMedia) { sql @@ -669,7 +701,7 @@ protected Sql GetBase(bool isContent, bool isMedia, bool isMember, sql .From(); - if (isContent || isMedia || isMember) + if (isContent || isMedia || isMember || isElement) { sql .LeftJoin() @@ -687,6 +719,12 @@ protected Sql GetBase(bool isContent, bool isMedia, bool isMember, .LeftJoin().On((left, right) => left.NodeId == right.NodeId); } + if (isElement) + { + sql + .LeftJoin().On((left, right) => left.NodeId == right.NodeId); + } + if (isMedia) { sql @@ -710,10 +748,10 @@ protected Sql GetBase(bool isContent, bool isMedia, bool isMember, // gets the base SELECT + FROM [+ filter] + WHERE sql // for a given object type, with a given filter - protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isMember, bool isCount, + protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isMember, bool isElement, bool isCount, Action>? filter, Guid[] objectTypes) { - Sql sql = GetBase(isContent, isMedia, isMember, filter, objectTypes, isCount); + Sql sql = GetBase(isContent, isMedia, isMember, isElement, filter, objectTypes, isCount); if (objectTypes.Length > 0) { sql.WhereIn(x => x.NodeObjectType, objectTypes); @@ -724,39 +762,39 @@ protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isMem // gets the base SELECT + FROM + WHERE sql // for a given node id - protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isMember, bool isCount, int id) + protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isMember, bool isElement, bool isCount, int id) { - Sql sql = GetBase(isContent, isMedia, isMember, null, isCount) + Sql sql = GetBase(isContent, isMedia, isMember, isElement, null, isCount) .Where(x => x.NodeId == id); - return AddGroupBy(isContent, isMedia, isMember, sql, true); + return AddGroupBy(isContent, isMedia, isMember, isElement, sql, true); } // gets the base SELECT + FROM + WHERE sql // for a given unique id - protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isMember, bool isCount, Guid uniqueId) + protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isMember, bool isElement, bool isCount, Guid uniqueId) { - Sql sql = GetBase(isContent, isMedia, isMember, null, isCount) + Sql sql = GetBase(isContent, isMedia, isMember, isElement, null, isCount) .Where(x => x.UniqueId == uniqueId); - return AddGroupBy(isContent, isMedia, isMember, sql, true); + return AddGroupBy(isContent, isMedia, isMember, isElement, sql, true); } // gets the base SELECT + FROM + WHERE sql // for a given object type and node id - protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isMember, bool isCount, Guid objectType, + protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isMember, bool isElement, bool isCount, Guid objectType, int nodeId) => - GetBase(isContent, isMedia, isMember, null, isCount) + GetBase(isContent, isMedia, isMember, isElement, null, isCount) .Where(x => x.NodeId == nodeId && x.NodeObjectType == objectType); // gets the base SELECT + FROM + WHERE sql // for a given object type and unique id - protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isMember, bool isCount, Guid objectType, + protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isMember, bool isElement, bool isCount, Guid objectType, Guid uniqueId) => - GetBase(isContent, isMedia, isMember, null, isCount) + GetBase(isContent, isMedia, isMember, isElement, null, isCount) .Where(x => x.UniqueId == uniqueId && x.NodeObjectType == objectType); // gets the GROUP BY / ORDER BY sql // required in order to count children - protected Sql AddGroupBy(bool isContent, bool isMedia, bool isMember, Sql sql, + protected Sql AddGroupBy(bool isContent, bool isMedia, bool isMember, bool isElement, Sql sql, bool defaultSort) { sql @@ -769,6 +807,12 @@ protected Sql AddGroupBy(bool isContent, bool isMedia, bool isMembe .AndBy(x => x.Published, x => x.Edited); } + if (isElement) + { + sql + .AndBy(x => x.Published, x => x.Edited); + } + if (isMedia) { sql @@ -776,7 +820,7 @@ protected Sql AddGroupBy(bool isContent, bool isMedia, bool isMembe } - if (isContent || isMedia || isMember) + if (isContent || isMedia || isMember || isElement) { sql .AndBy(x => x.Id, x => x.VersionDate) @@ -890,6 +934,18 @@ private class DocumentEntityDto : BaseDto public bool Edited { get; set; } } + /// + /// The DTO used to fetch results for an element item with its variation info + /// + private class ElementEntityDto : BaseDto + { + public ContentVariation Variations { get; set; } + + public bool Published { get; set; } + + public bool Edited { get; set; } + } + /// /// The DTO used to fetch results for a media item with its media path info /// @@ -1045,6 +1101,23 @@ private static DocumentEntitySlim BuildDocumentEntity(BaseDto dto) return entity; } + private static ElementEntitySlim BuildElementEntity(BaseDto dto) + { + // EntitySlim does not track changes + var entity = new ElementEntitySlim(); + BuildContentEntity(entity, dto); + + if (dto is ElementEntityDto contentDto) + { + // fill in the invariant info + entity.Edited = contentDto.Edited; + entity.Published = contentDto.Published; + entity.Variations = contentDto.Variations; + } + + return entity; + } + private static MemberEntitySlim BuildMemberEntity(BaseDto dto) { // EntitySlim does not track changes diff --git a/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs index ca625dacdf5c..1e487120c27e 100644 --- a/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs @@ -46,6 +46,7 @@ public static IUmbracoBuilder AddUmbracoHybridCache(this IUmbracoBuilder builder builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Umbraco.PublishedCache.HybridCache/Factories/CacheNodeFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Factories/CacheNodeFactory.cs index ba2b955afe46..d94bfa22d0ce 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Factories/CacheNodeFactory.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Factories/CacheNodeFactory.cs @@ -38,7 +38,7 @@ public ContentCacheNode ToContentCacheNode(IContent content, bool preview) }; } - private static bool GetPublishedValue(IContent content, bool preview) + private static bool GetPublishedValue(IPublishableContentBase content, bool preview) { switch (content.PublishedState) { @@ -85,6 +85,26 @@ public ContentCacheNode ToContentCacheNode(IMedia media) }; } + public ContentCacheNode ToContentCacheNode(IElement element, bool preview) + { + ContentData contentData = GetContentData( + element, + GetPublishedValue(element, preview), + null, + element.PublishCultureInfos?.Values.Select(x => x.Culture).ToHashSet() ?? []); + return new ContentCacheNode + { + Id = element.Id, + Key = element.Key, + SortOrder = element.SortOrder, + CreateDate = element.CreateDate, + CreatorId = element.CreatorId, + ContentTypeId = element.ContentTypeId, + Data = contentData, + IsDraft = false, + }; + } + private ContentData GetContentData(IContentBase content, bool published, int? templateId, ISet publishedCultures) { var propertyData = new Dictionary(); @@ -129,10 +149,11 @@ private ContentData GetContentData(IContentBase content, bool published, int? te // sanitize - names should be ok but ... never knows if (content.ContentType.VariesByCulture()) { - ContentCultureInfosCollection? infos = content is IContent document + var publishableContent = content as IPublishableContentBase; + ContentCultureInfosCollection? infos = publishableContent is not null ? published - ? document.PublishCultureInfos - : document.CultureInfos + ? publishableContent.PublishCultureInfos + : publishableContent.CultureInfos : content.CultureInfos; // ReSharper disable once UseDeconstruction @@ -140,7 +161,7 @@ private ContentData GetContentData(IContentBase content, bool published, int? te { foreach (ContentCultureInfos cultureInfo in infos) { - var cultureIsDraft = !published && content is IContent d && d.IsCultureEdited(cultureInfo.Culture); + var cultureIsDraft = !published && publishableContent is not null && publishableContent.IsCultureEdited(cultureInfo.Culture); cultureData[cultureInfo.Culture] = new CultureVariation { Name = cultureInfo.Name, diff --git a/src/Umbraco.PublishedCache.HybridCache/Factories/ICacheNodeFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Factories/ICacheNodeFactory.cs index f16ea2b16260..8a55e4867240 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Factories/ICacheNodeFactory.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Factories/ICacheNodeFactory.cs @@ -6,4 +6,6 @@ internal interface ICacheNodeFactory { ContentCacheNode ToContentCacheNode(IContent content, bool preview); ContentCacheNode ToContentCacheNode(IMedia media); + + ContentCacheNode ToContentCacheNode(IElement element, bool preview); } diff --git a/src/Umbraco.PublishedCache.HybridCache/Factories/IPublishedContentFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Factories/IPublishedContentFactory.cs index c5bfe4fe9efb..2dc284c1285e 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Factories/IPublishedContentFactory.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Factories/IPublishedContentFactory.cs @@ -9,4 +9,6 @@ internal interface IPublishedContentFactory IPublishedContent? ToIPublishedMedia(ContentCacheNode contentCacheNode); IPublishedMember ToPublishedMember(IMember member); + + IPublishedElement? ToIPublishedElement(ContentCacheNode contentCacheNode, bool preview); } diff --git a/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs index 57863dc98698..00c4caa01605 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs @@ -23,16 +23,7 @@ public PublishedContentFactory( public IPublishedContent? ToIPublishedContent(ContentCacheNode contentCacheNode, bool preview) { - IPublishedContentType contentType = _publishedContentTypeCache.Get(PublishedItemType.Content, contentCacheNode.ContentTypeId); - var contentNode = new ContentNode( - contentCacheNode.Id, - contentCacheNode.Key, - contentCacheNode.SortOrder, - contentCacheNode.CreateDate, - contentCacheNode.CreatorId, - contentType, - preview ? contentCacheNode.Data : null, - preview ? null : contentCacheNode.Data); + ContentNode contentNode = CreateContentNode(contentCacheNode, preview); IPublishedContent? model = GetModel(contentNode, preview); @@ -44,6 +35,21 @@ public PublishedContentFactory( return model; } + public IPublishedElement? ToIPublishedElement(ContentCacheNode contentCacheNode, bool preview) + { + ContentNode contentNode = CreateContentNode(contentCacheNode, preview); + + IPublishedElement? model = GetPublishedElement(contentNode, preview); + + if (preview) + { + // TODO ELEMENTS: what is the element equivalent of this? + // return model ?? GetPublishedContentAsDraft(model); + } + + return model; + } + public IPublishedContent? ToIPublishedMedia(ContentCacheNode contentCacheNode) { IPublishedContentType contentType = _publishedContentTypeCache.Get(PublishedItemType.Media, contentCacheNode.ContentTypeId); @@ -88,6 +94,20 @@ public IPublishedMember ToPublishedMember(IMember member) return new PublishedMember(member, contentNode, _elementsCache, _variationContextAccessor); } + private ContentNode CreateContentNode(ContentCacheNode contentCacheNode, bool preview) + { + IPublishedContentType contentType = _publishedContentTypeCache.Get(PublishedItemType.Content, contentCacheNode.ContentTypeId); + return new ContentNode( + contentCacheNode.Id, + contentCacheNode.Key, + contentCacheNode.SortOrder, + contentCacheNode.CreateDate, + contentCacheNode.CreatorId, + contentType, + preview ? contentCacheNode.Data : null, + preview ? null : contentCacheNode.Data); + } + private static Dictionary GetPropertyValues(IPublishedContentType contentType, IMember member) { var properties = member @@ -122,6 +142,7 @@ private static void AddIf(IPublishedContentType contentType, IDictionary content == null ? null : diff --git a/src/Umbraco.PublishedCache.HybridCache/PublishedContent.cs b/src/Umbraco.PublishedCache.HybridCache/PublishedContent.cs index 29a865f099aa..0d58b3fcf3f8 100644 --- a/src/Umbraco.PublishedCache.HybridCache/PublishedContent.cs +++ b/src/Umbraco.PublishedCache.HybridCache/PublishedContent.cs @@ -2,82 +2,27 @@ using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core; using Umbraco.Cms.Core.DependencyInjection; -using Umbraco.Cms.Core.Exceptions; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Navigation; -using Umbraco.Cms.Core.PublishedCache; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.HybridCache; -internal class PublishedContent : PublishedContentBase +internal class PublishedContent : PublishedElement, IPublishedContent { - private IPublishedProperty[] _properties; - private readonly ContentNode _contentNode; - private IReadOnlyDictionary? _cultures; - private readonly string? _urlSegment; - private readonly IReadOnlyDictionary? _cultureInfos; - private readonly string _contentName; - private readonly bool _published; - public PublishedContent( ContentNode contentNode, bool preview, IElementsCache elementsCache, IVariationContextAccessor variationContextAccessor) - : base(variationContextAccessor) + : base(contentNode, preview, elementsCache, variationContextAccessor) { - VariationContextAccessor = variationContextAccessor; - _contentNode = contentNode; - ContentData? contentData = preview ? _contentNode.DraftModel : _contentNode.PublishedModel; - if (contentData is null) - { - throw new ArgumentNullException(nameof(contentData)); - } - - _cultureInfos = contentData.CultureInfos; - _contentName = contentData.Name; - _urlSegment = contentData.UrlSegment; - _published = contentData.Published; - - IsPreviewing = preview; - - var properties = new IPublishedProperty[_contentNode.ContentType.PropertyTypes.Count()]; - var i = 0; - foreach (IPublishedPropertyType propertyType in _contentNode.ContentType.PropertyTypes) - { - // add one property per property type - this is required, for the indexing to work - // if contentData supplies pdatas, use them, else use null - contentData.Properties.TryGetValue(propertyType.Alias, out PropertyData[]? propertyDatas); // else will be null - properties[i++] = new PublishedProperty(propertyType, this, propertyDatas, elementsCache, propertyType.CacheLevel); - } - - _properties = properties; - - Id = contentNode.Id; - Key = contentNode.Key; - CreatorId = contentNode.CreatorId; - CreateDate = contentNode.CreateDate; - SortOrder = contentNode.SortOrder; - WriterId = contentData.WriterId; - TemplateId = contentData.TemplateId; - UpdateDate = contentData.VersionDate; } - public override IPublishedContentType ContentType => _contentNode.ContentType; - - public override Guid Key { get; } - - public override IEnumerable Properties => _properties; - - public override int Id { get; } - - public override int SortOrder { get; } - [Obsolete] - public override string Path + public string Path { get { @@ -119,28 +64,15 @@ private UmbracoObjectTypes GetObjectType() } } - public override int? TemplateId { get; } - - public override int CreatorId { get; } - - public override DateTime CreateDate { get; } - - public override int WriterId { get; } - - public override DateTime UpdateDate { get; } - - public bool IsPreviewing { get; } - - // Needed for publishedProperty - internal IVariationContextAccessor VariationContextAccessor { get; } + public int? TemplateId => ContentData.TemplateId; [Obsolete("Use the INavigationQueryService instead, scheduled for removal in v17")] - public override int Level + public int Level { get { INavigationQueryService? navigationQueryService; - switch (_contentNode.ContentType.ItemType) + switch (ContentNode.ContentType.ItemType) { case PublishedItemType.Content: navigationQueryService = StaticServiceProvider.Instance.GetRequiredService(); @@ -149,7 +81,7 @@ public override int Level navigationQueryService = StaticServiceProvider.Instance.GetRequiredService(); break; default: - throw new NotImplementedException("Level is not implemented for " + _contentNode.ContentType.ItemType); + throw new NotImplementedException("Level is not implemented for " + ContentNode.ContentType.ItemType); } // Attempt to retrieve the level, returning 0 if it fails or if level is null. @@ -163,111 +95,39 @@ public override int Level } [Obsolete("Please use TryGetParentKey() on IDocumentNavigationQueryService or IMediaNavigationQueryService instead. Scheduled for removal in V16.")] - public override IPublishedContent? Parent => GetParent(); + public IPublishedContent? Parent => GetParent(); /// - public override IReadOnlyDictionary Cultures - { - get - { - if (_cultures != null) - { - return _cultures; - } - - if (!ContentType.VariesByCulture()) - { - return _cultures = new Dictionary - { - { string.Empty, new PublishedCultureInfo(string.Empty, _contentName, _urlSegment, CreateDate) }, - }; - } - - if (_cultureInfos == null) - { - throw new PanicException("_contentDate.CultureInfos is null."); - } - - - return _cultures = _cultureInfos - .ToDictionary( - x => x.Key, - x => new PublishedCultureInfo(x.Key, x.Value.Name, x.Value.UrlSegment, x.Value.Date), - StringComparer.OrdinalIgnoreCase); - } - } - - /// - public override PublishedItemType ItemType => _contentNode.ContentType.ItemType; - - public override IPublishedProperty? GetProperty(string alias) - { - var index = _contentNode.ContentType.GetPropertyIndex(alias); - if (index < 0) - { - return null; // happens when 'alias' does not match a content type property alias - } - - // should never happen - properties array must be in sync with property type - if (index >= _properties.Length) - { - throw new IndexOutOfRangeException( - "Index points outside the properties array, which means the properties array is corrupt."); - } - - IPublishedProperty property = _properties[index]; - return property; - } - - public override bool IsDraft(string? culture = null) - { - // if this is the 'published' published content, nothing can be draft - if (_published) - { - return false; - } - - // not the 'published' published content, and does not vary = must be draft - if (!ContentType.VariesByCulture()) - { - return true; - } + [Obsolete("Please use TryGetChildrenKeys() on IDocumentNavigationQueryService or IMediaNavigationQueryService instead. Scheduled for removal in V16.")] + public virtual IEnumerable Children => GetChildren(); - // handle context culture - culture ??= VariationContextAccessor?.VariationContext?.Culture ?? string.Empty; - - // not the 'published' published content, and varies - // = depends on the culture - return _cultureInfos is not null && _cultureInfos.TryGetValue(culture, out CultureVariation? cvar) && cvar.IsDraft; - } + /// + [Obsolete("Please use GetUrlSegment() on IDocumentUrlService instead. Scheduled for removal in V16.")] + public virtual string? UrlSegment => this.UrlSegment(VariationContextAccessor); - public override bool IsPublished(string? culture = null) + private IPublishedContent? GetParent() { - // whether we are the 'draft' or 'published' content, need to determine whether - // there is a 'published' version for the specified culture (or at all, for - // invariant content items) - - // if there is no 'published' published content, no culture can be published - if (!_contentNode.HasPublished) - { - return false; - } + INavigationQueryService? navigationQueryService; + IPublishedStatusFilteringService? publishedStatusFilteringService; - // if there is a 'published' published content, and does not vary = published - if (!ContentType.VariesByCulture()) + switch (ContentType.ItemType) { - return true; + case PublishedItemType.Content: + navigationQueryService = StaticServiceProvider.Instance.GetRequiredService(); + publishedStatusFilteringService = StaticServiceProvider.Instance.GetRequiredService(); + break; + case PublishedItemType.Media: + navigationQueryService = StaticServiceProvider.Instance.GetRequiredService(); + publishedStatusFilteringService = StaticServiceProvider.Instance.GetRequiredService(); + break; + default: + throw new NotImplementedException("Level is not implemented for " + ContentType.ItemType); } - // handle context culture - culture ??= VariationContextAccessor.VariationContext?.Culture ?? string.Empty; - - // there is a 'published' published content, and varies - // = depends on the culture - return _contentNode.HasPublishedCulture(culture); + return this.Parent(navigationQueryService, publishedStatusFilteringService); } - private IPublishedContent? GetParent() + private IEnumerable GetChildren() { INavigationQueryService? navigationQueryService; IPublishedStatusFilteringService? publishedStatusFilteringService; @@ -286,6 +146,6 @@ public override bool IsPublished(string? culture = null) throw new NotImplementedException("Level is not implemented for " + ContentType.ItemType); } - return this.Parent(navigationQueryService, publishedStatusFilteringService); + return this.Children(navigationQueryService, publishedStatusFilteringService); } } diff --git a/src/Umbraco.PublishedCache.HybridCache/PublishedElement.cs b/src/Umbraco.PublishedCache.HybridCache/PublishedElement.cs new file mode 100644 index 000000000000..07991795484e --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/PublishedElement.cs @@ -0,0 +1,186 @@ +using Umbraco.Cms.Core.Exceptions; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.HybridCache; + +internal class PublishedElement : PublishableContentBase, IPublishedElement +{ + private IPublishedProperty[] _properties; + private IReadOnlyDictionary? _cultures; + private readonly string? _urlSegment; + private readonly IReadOnlyDictionary? _cultureInfos; + private readonly string _contentName; + private readonly bool _published; + + public PublishedElement( + ContentNode contentNode, + bool preview, + IElementsCache elementsCache, + IVariationContextAccessor variationContextAccessor) + { + VariationContextAccessor = variationContextAccessor; + ContentNode = contentNode; + ContentData? contentData = preview ? ContentNode.DraftModel : ContentNode.PublishedModel; + if (contentData is null) + { + throw new ArgumentNullException(nameof(contentData)); + } + ContentData = contentData; + + _cultureInfos = contentData.CultureInfos; + _contentName = contentData.Name; + _urlSegment = contentData.UrlSegment; + _published = contentData.Published; + + var properties = new IPublishedProperty[ContentNode.ContentType.PropertyTypes.Count()]; + var i = 0; + foreach (IPublishedPropertyType propertyType in ContentNode.ContentType.PropertyTypes) + { + // add one property per property type - this is required, for the indexing to work + // if contentData supplies pdatas, use them, else use null + contentData.Properties.TryGetValue(propertyType.Alias, out PropertyData[]? propertyDatas); // else will be null + properties[i++] = new PublishedProperty(propertyType, this, variationContextAccessor, preview, propertyDatas, elementsCache, propertyType.CacheLevel); + } + + _properties = properties; + + Id = contentNode.Id; + Key = contentNode.Key; + CreatorId = contentNode.CreatorId; + CreateDate = contentNode.CreateDate; + SortOrder = contentNode.SortOrder; + WriterId = contentData.WriterId; + UpdateDate = contentData.VersionDate; + } + + protected ContentNode ContentNode { get; } + + protected ContentData ContentData { get; } + + public override IPublishedContentType ContentType => ContentNode.ContentType; + + public override Guid Key { get; } + + public override IEnumerable Properties => _properties; + + public override int Id { get; } + + /// + public string Name => this.Name(VariationContextAccessor); + + public override int SortOrder { get; } + + public override int CreatorId { get; } + + public override DateTime CreateDate { get; } + + public override int WriterId { get; } + + public override DateTime UpdateDate { get; } + + // Needed for publishedProperty + internal IVariationContextAccessor VariationContextAccessor { get; } + + /// + public override IReadOnlyDictionary Cultures + { + get + { + if (_cultures != null) + { + return _cultures; + } + + if (!ContentType.VariesByCulture()) + { + return _cultures = new Dictionary + { + { string.Empty, new PublishedCultureInfo(string.Empty, _contentName, _urlSegment, CreateDate) }, + }; + } + + if (_cultureInfos == null) + { + throw new PanicException("_contentDate.CultureInfos is null."); + } + + + return _cultures = _cultureInfos + .ToDictionary( + x => x.Key, + x => new PublishedCultureInfo(x.Key, x.Value.Name, x.Value.UrlSegment, x.Value.Date), + StringComparer.OrdinalIgnoreCase); + } + } + + /// + public override PublishedItemType ItemType => ContentNode.ContentType.ItemType; + + public override IPublishedProperty? GetProperty(string alias) + { + var index = ContentNode.ContentType.GetPropertyIndex(alias); + if (index < 0) + { + return null; // happens when 'alias' does not match a content type property alias + } + + // should never happen - properties array must be in sync with property type + if (index >= _properties.Length) + { + throw new IndexOutOfRangeException( + "Index points outside the properties array, which means the properties array is corrupt."); + } + + IPublishedProperty property = _properties[index]; + return property; + } + + public override bool IsDraft(string? culture = null) + { + // if this is the 'published' published content, nothing can be draft + if (_published) + { + return false; + } + + // not the 'published' published content, and does not vary = must be draft + if (!ContentType.VariesByCulture()) + { + return true; + } + + // handle context culture + culture ??= VariationContextAccessor?.VariationContext?.Culture ?? string.Empty; + + // not the 'published' published content, and varies + // = depends on the culture + return _cultureInfos is not null && _cultureInfos.TryGetValue(culture, out CultureVariation? cvar) && cvar.IsDraft; + } + + public override bool IsPublished(string? culture = null) + { + // whether we are the 'draft' or 'published' content, need to determine whether + // there is a 'published' version for the specified culture (or at all, for + // invariant content items) + + // if there is no 'published' published content, no culture can be published + if (!ContentNode.HasPublished) + { + return false; + } + + // if there is a 'published' published content, and does not vary = published + if (!ContentType.VariesByCulture()) + { + return true; + } + + // handle context culture + culture ??= VariationContextAccessor.VariationContext?.Culture ?? string.Empty; + + // there is a 'published' published content, and varies + // = depends on the culture + return ContentNode.HasPublishedCulture(culture); + } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs b/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs index 83f063a2b4c6..c109470b9f87 100644 --- a/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs +++ b/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs @@ -10,8 +10,9 @@ namespace Umbraco.Cms.Infrastructure.HybridCache; internal sealed class PublishedProperty : PublishedPropertyBase { - private readonly PublishedContent _content; + private readonly IPublishedElement _element; private readonly bool _isPreviewing; + private readonly IVariationContextAccessor _variationContextAccessor; private readonly IElementsCache _elementsCache; private readonly bool _isMember; private string? _valuesCacheKey; @@ -35,7 +36,9 @@ internal sealed class PublishedProperty : PublishedPropertyBase // initializes a published content property with a value public PublishedProperty( IPublishedPropertyType propertyType, - PublishedContent content, + IPublishedElement element, + IVariationContextAccessor variationContextAccessor, + bool preview, PropertyData[]? sourceValues, IElementsCache elementsElementsCache, PropertyCacheLevel referenceCacheLevel = PropertyCacheLevel.Element) @@ -64,21 +67,22 @@ public PublishedProperty( } } - _content = content; - _isPreviewing = content.IsPreviewing; - _isMember = content.ContentType.ItemType == PublishedItemType.Member; + _element = element; + _variationContextAccessor = variationContextAccessor; + _isPreviewing = preview; + _isMember = element.ContentType.ItemType == PublishedItemType.Member; _elementsCache = elementsElementsCache; // this variable is used for contextualizing the variation level when calculating property values. // it must be set to the union of variance (the combination of content type and property type variance). - _variations = propertyType.Variations | content.ContentType.Variations; + _variations = propertyType.Variations | element.ContentType.Variations; _sourceVariations = propertyType.Variations; _propertyTypeAlias = propertyType.Alias; } // used to cache the CacheValues of this property - internal string ValuesCacheKey => _valuesCacheKey ??= PropertyCacheValues(_content.Key, Alias, _isPreviewing); + internal string ValuesCacheKey => _valuesCacheKey ??= PropertyCacheValues(_element.Key, Alias, _isPreviewing); private static string PropertyCacheValues(Guid contentUid, string typeAlias, bool previewing) { @@ -93,7 +97,7 @@ private static string PropertyCacheValues(Guid contentUid, string typeAlias, boo // determines whether a property has value public override bool HasValue(string? culture = null, string? segment = null) { - _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, _propertyTypeAlias, ref culture, ref segment); + _variationContextAccessor.ContextualizeVariation(_variations, _element.Id, ref culture, ref segment); var value = GetSourceValue(culture, segment); var hasValue = PropertyType.IsValue(value, PropertyValueLevel.Source); @@ -107,7 +111,7 @@ public override bool HasValue(string? culture = null, string? segment = null) public override object? GetSourceValue(string? culture = null, string? segment = null) { - _content.VariationContextAccessor.ContextualizeVariation(_sourceVariations, _content.Id, _propertyTypeAlias, ref culture, ref segment); + _variationContextAccessor.ContextualizeVariation(_sourceVariations, _element.Id, ref culture, ref segment); // source values are tightly bound to the property/schema culture and segment configurations, so we need to // sanitize the contextualized culture/segment states before using them to access the source values. @@ -140,17 +144,17 @@ public override bool HasValue(string? culture = null, string? segment = null) return _interValue; } - _interValue = PropertyType.ConvertSourceToInter(_content, _sourceValue, _isPreviewing); + _interValue = PropertyType.ConvertSourceToInter(_element, _sourceValue, _isPreviewing); _interInitialized = true; return _interValue; } - return PropertyType.ConvertSourceToInter(_content, GetSourceValue(culture, segment), _isPreviewing); + return PropertyType.ConvertSourceToInter(_element, GetSourceValue(culture, segment), _isPreviewing); } public override object? GetValue(string? culture = null, string? segment = null) { - _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, _propertyTypeAlias, ref culture, ref segment); + _variationContextAccessor.ContextualizeVariation(_variations, _element.Id, ref culture, ref segment); object? value; CacheValue cacheValues = GetCacheValues(PropertyType.CacheLevel).For(culture, segment); @@ -163,7 +167,7 @@ public override bool HasValue(string? culture = null, string? segment = null) return cacheValues.ObjectValue; } - cacheValues.ObjectValue = PropertyType.ConvertInterToObject(_content, initialCacheLevel, GetInterValue(culture, segment), _isPreviewing); + cacheValues.ObjectValue = PropertyType.ConvertInterToObject(_element, initialCacheLevel, GetInterValue(culture, segment), _isPreviewing); cacheValues.ObjectInitialized = true; value = cacheValues.ObjectValue; @@ -213,7 +217,7 @@ private CacheValues GetCacheValues(IAppCache? cache) public override object? GetDeliveryApiValue(bool expanding, string? culture = null, string? segment = null) { - _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, _propertyTypeAlias, ref culture, ref segment); + _variationContextAccessor.ContextualizeVariation(_variations, _element.Id, ref culture, ref segment); object? value; CacheValue cacheValues = GetCacheValues(expanding ? PropertyType.DeliveryApiCacheLevelForExpansion : PropertyType.DeliveryApiCacheLevel).For(culture, segment); @@ -222,7 +226,7 @@ private CacheValues GetCacheValues(IAppCache? cache) // initial reference cache level always is .Content const PropertyCacheLevel initialCacheLevel = PropertyCacheLevel.Element; - object? GetDeliveryApiObject() => PropertyType.ConvertInterToDeliveryApiObject(_content, initialCacheLevel, GetInterValue(culture, segment), _isPreviewing, expanding); + object? GetDeliveryApiObject() => PropertyType.ConvertInterToDeliveryApiObject(_element, initialCacheLevel, GetInterValue(culture, segment), _isPreviewing, expanding); value = expanding ? GetDeliveryApiExpandedObject(cacheValues, GetDeliveryApiObject) : GetDeliveryApiDefaultObject(cacheValues, GetDeliveryApiObject); diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/ElementCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/ElementCacheService.cs new file mode 100644 index 000000000000..f18205459414 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Services/ElementCacheService.cs @@ -0,0 +1,58 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.HybridCache.Factories; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Services; + +// TODO ELEMENTS: implement IPublishedElement cache with an actual cache behind (see DocumentCacheService) +internal class ElementCacheService : IElementCacheService +{ + private readonly IElementService _elementService; + private readonly ICacheNodeFactory _cacheNodeFactory; + private readonly IPublishedContentFactory _publishedContentFactory; + private readonly IPublishedModelFactory _publishedModelFactory; + + public ElementCacheService( + IElementService elementService, + ICacheNodeFactory cacheNodeFactory, + IPublishedContentFactory publishedContentFactory, + IPublishedModelFactory publishedModelFactory) + { + _elementService = elementService; + _cacheNodeFactory = cacheNodeFactory; + _publishedContentFactory = publishedContentFactory; + _publishedModelFactory = publishedModelFactory; + } + + public Task GetByKeyAsync(Guid key, bool? preview = null) + { + IPublishedElement? result = null; + IElement? element = _elementService.GetById(key); + if (element is null) + { + return Task.FromResult(result); + } + + if (preview is not true && element.Published is false) + { + return Task.FromResult(result); + } + + preview ??= false; + var cacheNode = _cacheNodeFactory.ToContentCacheNode(element, preview.Value); + result = _publishedContentFactory.ToIPublishedElement(cacheNode, preview.Value); + return Task.FromResult(result.CreateModel(_publishedModelFactory)); + } + + // TODO ELEMENTS: implement memory cache + public Task ClearMemoryCacheAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + // TODO ELEMENTS: implement memory cache + public Task RefreshMemoryCacheAsync(Guid key) => Task.CompletedTask; + + // TODO ELEMENTS: implement memory cache + public Task RemoveFromMemoryCacheAsync(Guid key) => Task.CompletedTask; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/element-picker/Umbraco.ElementPicker.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/element-picker/Umbraco.ElementPicker.ts new file mode 100644 index 000000000000..886bf3171fdf --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/element-picker/Umbraco.ElementPicker.ts @@ -0,0 +1,10 @@ +import type { ManifestPropertyEditorSchema } from '@umbraco-cms/backoffice/property-editor'; + +export const manifest: ManifestPropertyEditorSchema = { + type: 'propertyEditorSchema', + name: 'Element Picker', + alias: 'Umbraco.ElementPicker', + meta: { + defaultPropertyEditorUiAlias: 'Umb.PropertyEditorUi.ElementPicker', + }, +}; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/element-picker/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/element-picker/manifests.ts new file mode 100644 index 000000000000..3e9d1a9c92b2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/element-picker/manifests.ts @@ -0,0 +1,18 @@ +import { manifest as schemaManifest } from './Umbraco.ElementPicker.js'; + +export const manifests: Array = [ + { + type: 'propertyEditorUi', + alias: 'Umb.PropertyEditorUi.ElementPicker', + name: 'Element Picker Property Editor UI', + element: () => import('./property-editor-ui-element-picker.element.js'), + meta: { + label: 'Element Picker', + propertyEditorSchemaAlias: 'Umbraco.ElementPicker', + icon: 'icon-edit', + group: 'common', + supportsReadOnly: true, + }, + }, + schemaManifest, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/element-picker/property-editor-ui-element-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/element-picker/property-editor-ui-element-picker.element.ts new file mode 100644 index 000000000000..5bef4fc89017 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/element-picker/property-editor-ui-element-picker.element.ts @@ -0,0 +1,72 @@ +import { css, customElement, html, property } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import type { UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/property-editor'; +import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; + +@customElement('umb-property-editor-ui-element-picker') +export class UmbPropertyEditorUIElementPicker + extends UmbFormControlMixin | undefined, typeof UmbLitElement>(UmbLitElement, undefined) + implements UmbPropertyEditorUiElement +{ + /** + * Sets the input to readonly mode, meaning value cannot be changed but still able to read and select its content. + * @type {boolean} + * @attr + * @default false + */ + @property({ type: Boolean, reflect: true }) + readonly = false; + + /** + * Sets the input to mandatory, meaning validation will fail if the value is empty. + * @type {boolean} + */ + @property({ type: Boolean }) + mandatory?: boolean; + @property({ type: String }) + mandatoryMessage = UMB_VALIDATION_EMPTY_LOCALIZATION_KEY; + + /** + * The name of this field. + * @type {string} + */ + @property({ type: String }) + name?: string; + + #onInput(event: InputEvent) { + this.value = JSON.parse((event.target as HTMLTextAreaElement).value); + this.dispatchEvent(new UmbChangeEvent()); + } + + override render() { + return html` + + `; + } + + static override readonly styles = [ + UmbTextStyles, + css` + uui-textarea { + width: 100%; + } + `, + ]; +} + +export default UmbPropertyEditorUIElementPicker; + +declare global { + interface HTMLElementTagNameMap { + 'umb-property-editor-ui-element-picker': UmbPropertyEditorUIElementPicker; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/manifests.ts index 092f9d72b758..c4fa5d8c24c8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/manifests.ts @@ -22,6 +22,7 @@ import { manifests as textareaManifests } from './textarea/manifests.js'; import { manifests as textBoxManifests } from './text-box/manifests.js'; import { manifests as toggleManifests } from './toggle/manifests.js'; import { manifests as contentPickerManifests } from './content-picker/manifests.js'; +import { manifests as elementPickerManifests } from './element-picker/manifests.js'; export const manifests: Array = [ ...checkboxListManifests, @@ -40,6 +41,7 @@ export const manifests: Array = [ ...textBoxManifests, ...toggleManifests, ...contentPickerManifests, + ...elementPickerManifests, acceptedType, colorEditor, dimensions, diff --git a/tests/Umbraco.Tests.Common/Builders/ContentCultureInfosCollectionBuilder.cs b/tests/Umbraco.Tests.Common/Builders/ContentCultureInfosCollectionBuilder.cs index b70437a31d4c..741d81ac8008 100644 --- a/tests/Umbraco.Tests.Common/Builders/ContentCultureInfosCollectionBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/ContentCultureInfosCollectionBuilder.cs @@ -5,12 +5,12 @@ namespace Umbraco.Cms.Tests.Common.Builders; -public class ContentCultureInfosCollectionBuilder : ChildBuilderBase, +public class ContentCultureInfosCollectionBuilder : ChildBuilderBase, IBuildContentCultureInfosCollection { private readonly List _cultureInfosBuilders; - public ContentCultureInfosCollectionBuilder(ContentBuilder parentBuilder) : base(parentBuilder) => + public ContentCultureInfosCollectionBuilder(IBuildContentCultureInfosCollection parentBuilder) : base(parentBuilder) => _cultureInfosBuilders = new List(); public ContentCultureInfosBuilder AddCultureInfos() diff --git a/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs b/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs index 54e9090ddb4e..ba11ffaeef95 100644 --- a/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs @@ -10,7 +10,7 @@ namespace Umbraco.Cms.Tests.Common.Builders; public class ContentTypeBuilder - : ContentTypeBaseBuilder, + : ContentTypeBaseBuilder, IWithPropertyTypeIdsIncrementingFrom, IBuildPropertyTypes { @@ -30,7 +30,7 @@ public ContentTypeBuilder() { } - public ContentTypeBuilder(ContentBuilder parentBuilder) + public ContentTypeBuilder(IBuildContentTypes parentBuilder) : base(parentBuilder) { } @@ -169,6 +169,16 @@ public static ContentType CreateBasicContentType(string alias = "basePage", stri .Build(); } + public static ContentType CreateBasicElementType(string alias = "elementType", string name = "Element Type") + { + var builder = new ContentTypeBuilder(); + return (ContentType)builder + .WithAlias(alias) + .WithName(name) + .WithIsElement(true) + .Build(); + } + public static ContentType CreateSimpleContentType2(string alias, string name, IContentType parent = null, bool randomizeAliases = false, string propertyGroupAlias = "content", string propertyGroupName = "Content") { var builder = CreateSimpleContentTypeHelper(alias, name, parent, randomizeAliases: randomizeAliases, propertyGroupAlias: propertyGroupAlias, propertyGroupName: propertyGroupName); @@ -198,6 +208,9 @@ public static ContentType CreateSimpleContentType( int defaultTemplateId = 0) => (ContentType)CreateSimpleContentTypeHelper(alias, name, parent, propertyTypeCollection, randomizeAliases, propertyGroupAlias, propertyGroupName, mandatoryProperties, defaultTemplateId).Build(); + public static IContentType CreateSimpleElementType(string alias = "elementType", string name = "Element Type") + => CreateSimpleContentTypeHelper(alias, name).WithIsElement(true).Build(); + public static ContentTypeBuilder CreateSimpleContentTypeHelper( string alias = null, string name = null, @@ -254,13 +267,16 @@ public static ContentTypeBuilder CreateSimpleContentTypeHelper( .Done(); } - builder = builder - .AddAllowedTemplate() - .WithId(defaultTemplateId) - .WithAlias("textPage") - .WithName("Textpage") - .Done() - .WithDefaultTemplateId(defaultTemplateId); + if (defaultTemplateId > 0) + { + builder = builder + .AddAllowedTemplate() + .WithId(defaultTemplateId) + .WithAlias("textPage") + .WithName("Textpage") + .Done() + .WithDefaultTemplateId(defaultTemplateId); + } return builder; } diff --git a/tests/Umbraco.Tests.Common/Builders/ElementBuilder.cs b/tests/Umbraco.Tests.Common/Builders/ElementBuilder.cs new file mode 100644 index 000000000000..12c54033c5a2 --- /dev/null +++ b/tests/Umbraco.Tests.Common/Builders/ElementBuilder.cs @@ -0,0 +1,271 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Globalization; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Common.Builders.Interfaces; +using Umbraco.Cms.Tests.Common.Extensions; + +namespace Umbraco.Cms.Tests.Common.Builders; + +public class ElementBuilder + : BuilderBase, + IBuildContentTypes, + IBuildContentCultureInfosCollection, + IWithIdBuilder, + IWithKeyBuilder, + IWithCreatorIdBuilder, + IWithCreateDateBuilder, + IWithUpdateDateBuilder, + IWithNameBuilder, + IWithTrashedBuilder, + IWithLevelBuilder, + IWithCultureInfoBuilder, + IWithPropertyValues +{ + private readonly IDictionary _cultureNames = new Dictionary(); + private ContentCultureInfosCollection _contentCultureInfosCollection; + private ContentCultureInfosCollectionBuilder _contentCultureInfosCollectionBuilder; + private IContentType _contentType; + private ContentTypeBuilder _contentTypeBuilder; + private DateTime? _createDate; + private int? _creatorId; + private CultureInfo _cultureInfo; + + private int? _id; + private Guid? _key; + private int? _level; + private string _name; + private GenericDictionaryBuilder _propertyDataBuilder; + private object _propertyValues; + private string _propertyValuesCulture; + private string _propertyValuesSegment; + private int? _sortOrder; + private bool? _trashed; + private DateTime? _updateDate; + private int? _versionId; + + DateTime? IWithCreateDateBuilder.CreateDate + { + get => _createDate; + set => _createDate = value; + } + + int? IWithCreatorIdBuilder.CreatorId + { + get => _creatorId; + set => _creatorId = value; + } + + CultureInfo IWithCultureInfoBuilder.CultureInfo + { + get => _cultureInfo; + set => _cultureInfo = value; + } + + int? IWithIdBuilder.Id + { + get => _id; + set => _id = value; + } + + Guid? IWithKeyBuilder.Key + { + get => _key; + set => _key = value; + } + + int? IWithLevelBuilder.Level + { + get => _level; + set => _level = value; + } + + string IWithNameBuilder.Name + { + get => _name; + set => _name = value; + } + + object IWithPropertyValues.PropertyValues + { + get => _propertyValues; + set => _propertyValues = value; + } + + string IWithPropertyValues.PropertyValuesCulture + { + get => _propertyValuesCulture; + set => _propertyValuesCulture = value; + } + + string IWithPropertyValues.PropertyValuesSegment + { + get => _propertyValuesSegment; + set => _propertyValuesSegment = value; + } + + bool? IWithTrashedBuilder.Trashed + { + get => _trashed; + set => _trashed = value; + } + + DateTime? IWithUpdateDateBuilder.UpdateDate + { + get => _updateDate; + set => _updateDate = value; + } + + public ElementBuilder WithVersionId(int versionId) + { + _versionId = versionId; + return this; + } + + public ElementBuilder WithContentType(IContentType contentType) + { + _contentTypeBuilder = null; + _contentType = contentType; + return this; + } + + public ElementBuilder WithContentCultureInfosCollection( + ContentCultureInfosCollection contentCultureInfosCollection) + { + _contentCultureInfosCollectionBuilder = null; + _contentCultureInfosCollection = contentCultureInfosCollection; + return this; + } + + public ElementBuilder WithCultureName(string culture, string name = "") + { + if (string.IsNullOrWhiteSpace(name)) + { + if (_cultureNames.TryGetValue(culture, out _)) + { + _cultureNames.Remove(culture); + } + } + else + { + _cultureNames[culture] = name; + } + + return this; + } + + public ContentTypeBuilder AddContentType() + { + _contentType = null; + var builder = new ContentTypeBuilder(this); + _contentTypeBuilder = builder; + return builder; + } + + public GenericDictionaryBuilder AddPropertyData() + { + var builder = new GenericDictionaryBuilder(this); + _propertyDataBuilder = builder; + return builder; + } + + public ContentCultureInfosCollectionBuilder AddContentCultureInfosCollection() + { + _contentCultureInfosCollection = null; + var builder = new ContentCultureInfosCollectionBuilder(this); + _contentCultureInfosCollectionBuilder = builder; + return builder; + } + + public override Element Build() + { + var id = _id ?? 0; + var versionId = _versionId ?? 0; + var key = _key ?? Guid.NewGuid(); + var createDate = _createDate ?? DateTime.Now; + var updateDate = _updateDate ?? DateTime.Now; + var name = _name ?? Guid.NewGuid().ToString(); + var creatorId = _creatorId ?? 0; + var level = _level ?? 1; + var path = $"-1,{id}"; + var sortOrder = _sortOrder ?? 0; + var trashed = _trashed ?? false; + var culture = _cultureInfo?.Name; + var propertyValues = _propertyValues; + var propertyValuesCulture = _propertyValuesCulture; + var propertyValuesSegment = _propertyValuesSegment; + + if (_contentTypeBuilder is null && _contentType is null) + { + throw new InvalidOperationException( + "A content item cannot be constructed without providing a content type. Use AddContentType() or WithContentType()."); + } + + var contentType = _contentType ?? _contentTypeBuilder.Build(); + + var element = new Element(name, contentType, culture) + { + Id = id, VersionId = versionId, Key = key, CreateDate = createDate, + UpdateDate = updateDate, + CreatorId = creatorId, + Level = level, + Path = path, + SortOrder = sortOrder, + Trashed = trashed + }; + + foreach (var cultureName in _cultureNames) + { + element.SetCultureName(cultureName.Value, cultureName.Key); + } + + if (_propertyDataBuilder != null || propertyValues != null) + { + if (_propertyDataBuilder != null) + { + var propertyData = _propertyDataBuilder.Build(); + foreach (var keyValuePair in propertyData) + { + element.SetValue(keyValuePair.Key, keyValuePair.Value); + } + } + else + { + element.PropertyValues(propertyValues, propertyValuesCulture, propertyValuesSegment); + } + + element.ResetDirtyProperties(false); + } + + if (_contentCultureInfosCollection is not null || _contentCultureInfosCollectionBuilder is not null) + { + var contentCultureInfos = + _contentCultureInfosCollection ?? _contentCultureInfosCollectionBuilder.Build(); + element.PublishCultureInfos = contentCultureInfos; + } + + return element; + } + + public static Element CreateBasicElement(IContentType contentType, int id = 0) => + new ElementBuilder() + .WithId(id) + .WithContentType(contentType) + .WithName("Element") + .Build(); + + public static Element CreateSimpleElement(IContentType contentType, string name = "Element", string? culture = null, string? segment = null) + => new ElementBuilder() + .WithContentType(contentType) + .WithName(name) + .WithPropertyValues(new + { + title = "This is the element title", + bodyText = "This is the element body text", + author = "Some One" + }) + .Build(); + +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Cache/DistributedCacheRefresherTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Cache/DistributedCacheRefresherTests.cs index 0b9fd4755def..7e999b550a62 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Cache/DistributedCacheRefresherTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Cache/DistributedCacheRefresherTests.cs @@ -19,6 +19,8 @@ internal sealed class DistributedCacheRefresherTests : UmbracoIntegrationTest private MediaCacheRefresher MediaCacheRefresher => GetRequiredService(); + private ElementCacheRefresher ElementCacheRefresher => GetRequiredService(); + [Test] public void DistributedContentCacheRefresherClearsElementsCache() { @@ -41,6 +43,17 @@ public void DistributedMediaCacheRefresherClearsElementsCache() Assert.IsNull(ElementsCache.Get(cacheKey)); } + [Test] + public void DistributedElementCacheRefresherClearsElementsCache() + { + var cacheKey = "test"; + PopulateCache("test"); + + ElementCacheRefresher.Refresh([new ElementCacheRefresher.JsonPayload(1, Guid.NewGuid(), TreeChangeTypes.RefreshAll)]); + + Assert.IsNull(ElementsCache.Get(cacheKey)); + } + private void PopulateCache(string key) { ElementsCache.Get(key, () => new object()); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.Publish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.Publish.cs new file mode 100644 index 000000000000..f094056995cc --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.Publish.cs @@ -0,0 +1,101 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class ElementPublishingServiceTests +{ + [Test] + public async Task Can_Publish_Invariant() + { + var elementType = await SetupInvariantElementTypeAsync(); + var element = await CreateInvariantContentAsync(elementType); + + var publishAttempt = await ElementPublishingService.PublishAsync( + element.Key, + [new() { Culture = null }], + Constants.Security.SuperUserKey); + + Assert.IsTrue(publishAttempt.Success); + + element = await ElementEditingService.GetAsync(element.Key); + Assert.NotNull(element!.PublishDate); + + var publishedElement = await ElementCacheService.GetByKeyAsync(element.Key, false); + Assert.NotNull(publishedElement); + Assert.IsTrue(publishedElement.IsPublished()); + } + + [Test] + public async Task Can_Publish_Variant_Single_Culture() + { + var (langEn, langDa, langBe, elementType) = await SetupVariantElementTypeAsync(); + var element = await CreateVariantElementAsync(langEn, langDa, langBe, elementType); + + var publishAttempt = await ElementPublishingService.PublishAsync( + element.Key, + [new() { Culture = langEn.IsoCode }], + Constants.Security.SuperUserKey); + + Assert.IsTrue(publishAttempt.Success); + element = await ElementEditingService.GetAsync(element.Key); + Assert.AreEqual(1, element!.PublishedCultures.Count()); + + var publishedElement = await ElementCacheService.GetByKeyAsync(element.Key, false); + Assert.NotNull(publishedElement); + Assert.IsTrue(publishedElement.IsPublished(langEn.IsoCode)); + Assert.IsFalse(publishedElement.IsPublished(langDa.IsoCode)); + Assert.IsFalse(publishedElement.IsPublished(langBe.IsoCode)); + } + + [Test] + public async Task Can_Publish_Variant_Some_Cultures() + { + var (langEn, langDa, langBe, elementType) = await SetupVariantElementTypeAsync(); + var element = await CreateVariantElementAsync(langEn, langDa, langBe, elementType); + + var publishAttempt = await ElementPublishingService.PublishAsync( + element.Key, + [ + new() { Culture = langEn.IsoCode }, + new() { Culture = langDa.IsoCode }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(publishAttempt.Success); + element = await ElementEditingService.GetAsync(element.Key); + Assert.AreEqual(2, element!.PublishedCultures.Count()); + + var publishedElement = await ElementCacheService.GetByKeyAsync(element.Key, false); + Assert.NotNull(publishedElement); + Assert.IsTrue(publishedElement.IsPublished(langEn.IsoCode)); + Assert.IsTrue(publishedElement.IsPublished(langDa.IsoCode)); + Assert.IsFalse(publishedElement.IsPublished(langBe.IsoCode)); + } + + [Test] + public async Task Can_Publish_Variant_All_Cultures() + { + var (langEn, langDa, langBe, elementType) = await SetupVariantElementTypeAsync(); + var element = await CreateVariantElementAsync(langEn, langDa, langBe, elementType); + + var publishAttempt = await ElementPublishingService.PublishAsync( + element.Key, + [ + new() { Culture = langEn.IsoCode }, + new() { Culture = langDa.IsoCode }, + new() { Culture = langBe.IsoCode }, + ], + Constants.Security.SuperUserKey); + + Assert.IsTrue(publishAttempt.Success); + element = await ElementEditingService.GetAsync(element.Key); + Assert.AreEqual(3, element!.PublishedCultures.Count()); + + var publishedElement = await ElementCacheService.GetByKeyAsync(element.Key, false); + Assert.NotNull(publishedElement); + Assert.IsTrue(publishedElement.IsPublished(langEn.IsoCode)); + Assert.IsTrue(publishedElement.IsPublished(langDa.IsoCode)); + Assert.IsTrue(publishedElement.IsPublished(langBe.IsoCode)); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.Unpublish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.Unpublish.cs new file mode 100644 index 000000000000..060c32e8d0d8 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.Unpublish.cs @@ -0,0 +1,114 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class ElementPublishingServiceTests +{ + [Test] + public async Task Can_Unpublish_Invariant() + { + var elementType = await SetupInvariantElementTypeAsync(); + var element = await CreateInvariantContentAsync(elementType); + + await ElementPublishingService.PublishAsync( + element.Key, + [new() { Culture = Constants.System.InvariantCulture }], + Constants.Security.SuperUserKey); + + var unpublishAttempt = await ElementPublishingService.UnpublishAsync( + element.Key, + null, + Constants.Security.SuperUserKey); + + Assert.IsTrue(unpublishAttempt.Success); + element = await ElementEditingService.GetAsync(element.Key); + Assert.IsNull(element!.PublishDate); + + var publishedElement = await ElementCacheService.GetByKeyAsync(element.Key, false); + Assert.IsNull(publishedElement); + } + + [Test] + public async Task Can_Unpublish_Variant_Single_Culture() + { + var (langEn, langDa, langBe, elementType) = await SetupVariantElementTypeAsync(); + var element = await CreateVariantElementAsync(langEn, langDa, langBe, elementType); + + await ElementPublishingService.PublishAsync( + element.Key, + [new() { Culture = langEn.IsoCode }], + Constants.Security.SuperUserKey); + + var unpublishAttempt = await ElementPublishingService.UnpublishAsync( + element.Key, + new HashSet([langEn.IsoCode]), + Constants.Security.SuperUserKey); + + Assert.IsTrue(unpublishAttempt.Success); + element = await ElementEditingService.GetAsync(element.Key); + Assert.AreEqual(0, element!.PublishedCultures.Count()); + + var publishedElement = await ElementCacheService.GetByKeyAsync(element.Key, false); + Assert.IsNull(publishedElement); + } + + [Test] + public async Task Can_Unpublish_Variant_Some_Cultures() + { + var (langEn, langDa, langBe, elementType) = await SetupVariantElementTypeAsync(); + var element = await CreateVariantElementAsync(langEn, langDa, langBe, elementType); + + await ElementPublishingService.PublishAsync( + element.Key, + [ + new() { Culture = langEn.IsoCode }, + new() { Culture = langDa.IsoCode }, + new() { Culture = langBe.IsoCode }, + ], + Constants.Security.SuperUserKey); + + var unpublishAttempt = await ElementPublishingService.UnpublishAsync( + element.Key, + new HashSet([langEn.IsoCode, langDa.IsoCode]), + Constants.Security.SuperUserKey); + + Assert.IsTrue(unpublishAttempt.Success); + element = await ElementEditingService.GetAsync(element.Key); + Assert.AreEqual(1, element!.PublishedCultures.Count()); + + var publishedElement = await ElementCacheService.GetByKeyAsync(element.Key, false); + Assert.NotNull(publishedElement); + Assert.IsFalse(publishedElement.IsPublished(langEn.IsoCode)); + Assert.IsFalse(publishedElement.IsPublished(langDa.IsoCode)); + Assert.IsTrue(publishedElement.IsPublished(langBe.IsoCode)); + } + + [Test] + public async Task Can_Unpublish_Variant_All_Cultures() + { + var (langEn, langDa, langBe, elementType) = await SetupVariantElementTypeAsync(); + var element = await CreateVariantElementAsync(langEn, langDa, langBe, elementType); + + await ElementPublishingService.PublishAsync( + element.Key, + [ + new() { Culture = langEn.IsoCode }, + new() { Culture = langDa.IsoCode }, + new() { Culture = langBe.IsoCode }, + ], + Constants.Security.SuperUserKey); + + var unpublishAttempt = await ElementPublishingService.UnpublishAsync( + element.Key, + new HashSet([langEn.IsoCode, langDa.IsoCode, langBe.IsoCode]), + Constants.Security.SuperUserKey); + + Assert.IsTrue(unpublishAttempt.Success); + element = await ElementEditingService.GetAsync(element.Key); + Assert.AreEqual(0, element!.PublishedCultures.Count()); + + var publishedElement = await ElementCacheService.GetByKeyAsync(element.Key, false); + Assert.IsNull(publishedElement); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.cs new file mode 100644 index 000000000000..4aa81786c34c --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementPublishingServiceTests.cs @@ -0,0 +1,206 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +[TestFixture] +[UmbracoTest( + Database = UmbracoTestOptions.Database.NewSchemaPerTest, + PublishedRepositoryEvents = true, + WithApplication = true)] +public partial class ElementPublishingServiceTests : UmbracoIntegrationTest +{ + [SetUp] + public new void Setup() => ContentRepositoryBase.ThrowOnWarning = true; + + [TearDown] + public void Teardown() => ContentRepositoryBase.ThrowOnWarning = false; + + private IElementPublishingService ElementPublishingService => GetRequiredService(); + + private ILanguageService LanguageService => GetRequiredService(); + + private ITemplateService TemplateService => GetRequiredService(); + + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IElementCacheService ElementCacheService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private async Task<(ILanguage LangEn, ILanguage LangDa, ILanguage LangBe, IContentType ContentType)> SetupVariantElementTypeAsync() + { + var langEn = (await LanguageService.GetAsync("en-US"))!; + var langDa = new LanguageBuilder() + .WithCultureInfo("da-DK") + .Build(); + await LanguageService.CreateAsync(langDa, Constants.Security.SuperUserKey); + var langBe = new LanguageBuilder() + .WithCultureInfo("nl-BE") + .Build(); + await LanguageService.CreateAsync(langBe, Constants.Security.SuperUserKey); + + var template = TemplateBuilder.CreateTextPageTemplate(); + await TemplateService.CreateAsync(template, Constants.Security.SuperUserKey); + + var contentType = new ContentTypeBuilder() + .WithAlias("variantContent") + .WithName("Variant Content") + .WithContentVariation(ContentVariation.Culture) + .AddPropertyGroup() + .WithAlias("content") + .WithName("Content") + .WithSupportsPublishing(true) + .Done() + .AddPropertyType() + .WithAlias("title") + .WithName("Title") + .WithMandatory(true) + .WithVariations(ContentVariation.Culture) + .Done() + .Build(); + + contentType.AllowedAsRoot = true; + var createAttempt = await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + if (createAttempt.Success is false) + { + throw new Exception("Something unexpected went wrong setting up the test data structure"); + } + + contentType.AllowedContentTypes = [new ContentTypeSort(contentType.Key, 1, contentType.Alias)]; + var updateAttempt = await ContentTypeService.UpdateAsync(contentType, Constants.Security.SuperUserKey); + if (updateAttempt.Success is false) + { + throw new Exception("Something unexpected went wrong setting up the test data structure"); + } + + return (langEn, langDa, langBe, contentType); + } + + private async Task CreateVariantElementAsync( + ILanguage langEn, + ILanguage langDa, + ILanguage langBe, + IContentType contentType, + Guid? parentKey = null, + string? englishTitleValue = "Test title") + { + var documentKey = Guid.NewGuid(); + + var createModel = new ElementCreateModel + { + Key = documentKey, + ContentTypeKey = contentType.Key, + ParentKey = parentKey, + Properties = [ + new PropertyValueModel + { + Alias = "title", + Value = englishTitleValue, + Culture = langEn.IsoCode + }, + new PropertyValueModel + { + Alias = "title", + Value = "Test titel", + Culture = langDa.IsoCode + }, + new PropertyValueModel + { + Alias = "title", + Value = "Titel van de test", + Culture = langBe.IsoCode + } + ], + Variants = + [ + new VariantModel { Name = langEn.CultureName, Culture = langEn.IsoCode }, + new VariantModel { Name = langDa.CultureName, Culture = langDa.IsoCode }, + new VariantModel { Name = langBe.CultureName, Culture = langBe.IsoCode } + ], + }; + + var createAttempt = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + if (createAttempt.Success is false) + { + throw new Exception("Something unexpected went wrong setting up the test data"); + } + + return createAttempt.Result.Content!; + } + + private async Task SetupInvariantElementTypeAsync() + { + var template = TemplateBuilder.CreateTextPageTemplate(); + await TemplateService.CreateAsync(template, Constants.Security.SuperUserKey); + + var contentType = new ContentTypeBuilder() + .WithAlias("invariantContent") + .WithName("Invariant Content") + .WithAllowAsRoot(true) + .AddPropertyGroup() + .WithAlias("content") + .WithName("Content") + .WithSupportsPublishing(true) + .Done() + .AddPropertyType() + .WithAlias("title") + .WithName("Title") + .WithMandatory(true) + .Done() + .Build(); + + var createAttempt = await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + if (createAttempt.Success is false) + { + throw new Exception("Something unexpected went wrong setting up the test data structure"); + } + + contentType.AllowedContentTypes = [new ContentTypeSort(contentType.Key, 1, contentType.Alias)]; + var updateAttempt = await ContentTypeService.UpdateAsync(contentType, Constants.Security.SuperUserKey); + if (updateAttempt.Success is false) + { + throw new Exception("Something unexpected went wrong setting up the test data structure"); + } + + return contentType; + } + + private async Task CreateInvariantContentAsync(IContentType contentType, Guid? parentKey = null, string? titleValue = "Test title") + { + var documentKey = Guid.NewGuid(); + + var createModel = new ElementCreateModel + { + Key = documentKey, + ContentTypeKey = contentType.Key, + Variants = [new () { Name = "Test" }], + ParentKey = parentKey, + Properties = + [ + new PropertyValueModel + { + Alias = "title", + Value = titleValue, + } + ], + }; + + var createAttempt = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + if (createAttempt.Success is false) + { + throw new Exception($"Something unexpected went wrong setting up the test data. Status: {createAttempt.Status}"); + } + + return createAttempt.Result.Content!; + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementServiceTests.cs new file mode 100644 index 000000000000..8bc807f66001 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ElementServiceTests.cs @@ -0,0 +1,44 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +[TestFixture] +[UmbracoTest( + Database = UmbracoTestOptions.Database.NewSchemaPerTest, + PublishedRepositoryEvents = true, + WithApplication = true)] +public class ElementServiceTests : UmbracoIntegrationTest +{ + private IElementService ElementService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + [Test] + public async Task Can_Save_Element() + { + var elementType = ContentTypeBuilder.CreateSimpleElementType(); + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + + // Arrange + var element = ElementService.Create("My Element", elementType.Alias); + element.SetValue("title", "The Element Title"); + + // Act + var result = ElementService.Save(element); + + // Assert + Assert.That(result.Success, Is.True); + Assert.That(result.Result, Is.EqualTo(OperationResultType.Success)); + + element = ElementService.GetById(element.Key); + Assert.That(element, Is.Not.Null); + Assert.That(element.HasIdentity, Is.True); + Assert.That(element.Name, Is.EqualTo("My Element")); + Assert.That(element.GetValue("title"), Is.EqualTo("The Element Title")); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/ElementRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/ElementRepositoryTest.cs new file mode 100644 index 000000000000..5774decbe73f --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/ElementRepositoryTest.cs @@ -0,0 +1,351 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Persistence.Factories; +using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Persistence.Repositories; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public class ElementRepositoryTest : UmbracoIntegrationTest +{ + [SetUp] + public void SetUpData() + { + ContentRepositoryBase.ThrowOnWarning = true; + } + + [TearDown] + public void Teardown() => ContentRepositoryBase.ThrowOnWarning = false; + + private ContentType _contentType; + + private IDataTypeService DataTypeService => GetRequiredService(); + + private PropertyEditorCollection PropertyEditorCollection => GetRequiredService(); + + private IConfigurationEditorJsonSerializer ConfigurationEditorJsonSerializer => + GetRequiredService(); + + private ElementRepository CreateRepository(IScopeAccessor scopeAccessor, out ContentTypeRepository contentTypeRepository, out DataTypeRepository dtdRepository, AppCaches appCaches = null) + { + appCaches ??= AppCaches; + + var ctRepository = CreateRepository(scopeAccessor, out contentTypeRepository); + var editors = new PropertyEditorCollection(new DataEditorCollection(() => Enumerable.Empty())); + dtdRepository = new DataTypeRepository(scopeAccessor, appCaches, editors, LoggerFactory.CreateLogger(), LoggerFactory, ConfigurationEditorJsonSerializer); + return ctRepository; + } + + private ElementRepository CreateRepository(IScopeAccessor scopeAccessor, out ContentTypeRepository contentTypeRepository, AppCaches appCaches = null) + { + appCaches ??= AppCaches; + + var runtimeSettingsMock = new Mock>(); + runtimeSettingsMock.Setup(x => x.CurrentValue).Returns(new RuntimeSettings()); + + var templateRepository = new TemplateRepository(scopeAccessor, appCaches, LoggerFactory.CreateLogger(), GetRequiredService(), ShortStringHelper, Mock.Of(), runtimeSettingsMock.Object); + var tagRepository = new TagRepository(scopeAccessor, appCaches, LoggerFactory.CreateLogger()); + var commonRepository = + new ContentTypeCommonRepository(scopeAccessor, templateRepository, appCaches, ShortStringHelper); + var languageRepository = + new LanguageRepository(scopeAccessor, appCaches, LoggerFactory.CreateLogger()); + contentTypeRepository = new ContentTypeRepository(scopeAccessor, appCaches, LoggerFactory.CreateLogger(), commonRepository, languageRepository, ShortStringHelper, IdKeyMap); + var relationTypeRepository = new RelationTypeRepository(scopeAccessor, AppCaches.Disabled, LoggerFactory.CreateLogger()); + var entityRepository = new EntityRepository(scopeAccessor, AppCaches.Disabled); + var relationRepository = new RelationRepository(scopeAccessor, LoggerFactory.CreateLogger(), relationTypeRepository, entityRepository); + var propertyEditors = + new PropertyEditorCollection(new DataEditorCollection(() => Enumerable.Empty())); + var dataValueReferences = + new DataValueReferenceFactoryCollection(() => Enumerable.Empty()); + var repository = new ElementRepository( + scopeAccessor, + appCaches, + LoggerFactory.CreateLogger(), + LoggerFactory, + contentTypeRepository, + tagRepository, + languageRepository, + relationRepository, + relationTypeRepository, + propertyEditors, + dataValueReferences, + DataTypeService, + ConfigurationEditorJsonSerializer, + Mock.Of()); + return repository; + } + + [Test] + public void CacheActiveForIntsAndGuids() + { + var realCache = new AppCaches( + new ObjectCacheAppCache(), + new DictionaryAppCache(), + new IsolatedCaches(t => new ObjectCacheAppCache())); + + var provider = ScopeProvider; + var scopeAccessor = ScopeAccessor; + + using (var scope = provider.CreateScope()) + { + var repository = CreateRepository((IScopeAccessor)provider, out var contentTypeRepository, realCache); + + var udb = scopeAccessor.AmbientScope.Database; + + udb.EnableSqlCount = false; + + var contentType = ContentTypeBuilder.CreateBasicElementType(); + contentTypeRepository.Save(contentType); + var content = ElementBuilder.CreateBasicElement(contentType); + repository.Save(content); + + udb.EnableSqlCount = true; + + // go get it, this should already be cached since the default repository key is the INT + repository.Get(content.Id); + Assert.AreEqual(0, udb.SqlCount); + + // retrieve again, this should use cache + repository.Get(content.Id); + Assert.AreEqual(0, udb.SqlCount); + + // reset counter + udb.EnableSqlCount = false; + udb.EnableSqlCount = true; + + // now get by GUID, this won't be cached yet because the default repo key is not a GUID + repository.Get(content.Key); + var sqlCount = udb.SqlCount; + Assert.Greater(sqlCount, 0); + + // retrieve again, this should use cache now + repository.Get(content.Key); + Assert.AreEqual(sqlCount, udb.SqlCount); + } + } + + [Test] + public void CreateVersions() + { + var provider = ScopeProvider; + using (var scope = provider.CreateScope()) + { + var repository = CreateRepository((IScopeAccessor)provider, out var contentTypeRepository, out DataTypeRepository _); + var versions = new List(); + var hasPropertiesContentType = ContentTypeBuilder.CreateSimpleElementType(); + contentTypeRepository.Save(hasPropertiesContentType); + + IElement element1 = ElementBuilder.CreateSimpleElement(hasPropertiesContentType); + + // save = create the initial version + repository.Save(element1); + + versions.Add(element1.VersionId); // the first version + + // publish = new edit version + element1.SetValue("title", "title"); + element1.PublishCulture(CultureImpact.Invariant, DateTime.Now, PropertyEditorCollection); + element1.PublishedState = PublishedState.Publishing; + repository.Save(element1); + + versions.Add(element1.VersionId); // NEW VERSION + + // new edit version has been created + Assert.AreNotEqual(versions[^2], versions[^1]); + Assert.IsTrue(element1.Published); + Assert.AreEqual(PublishedState.Published, element1.PublishedState); + Assert.AreEqual(versions[^1], repository.Get(element1.Id)!.VersionId); + + // misc checks + Assert.AreEqual(true, ScopeAccessor.AmbientScope.Database.ExecuteScalar( + $"SELECT published FROM {Constants.DatabaseSchema.Tables.Element} WHERE nodeId=@id", + new { id = element1.Id })); + + // change something + // save = update the current (draft) version + element1.Name = "name-1"; + element1.SetValue("title", "title-1"); + repository.Save(element1); + + versions.Add(element1.VersionId); // the same version + + // no new version has been created + Assert.AreEqual(versions[^2], versions[^1]); + Assert.IsTrue(element1.Published); + Assert.AreEqual(versions[^1], repository.Get(element1.Id)!.VersionId); + + // misc checks + Assert.AreEqual( + true, + ScopeAccessor.AmbientScope.Database.ExecuteScalar( + $"SELECT published FROM {Constants.DatabaseSchema.Tables.Element} WHERE nodeId=@id", + new { id = element1.Id })); + + // unpublish = no impact on versions + element1.PublishedState = PublishedState.Unpublishing; + repository.Save(element1); + + versions.Add(element1.VersionId); // the same version + + // no new version has been created + Assert.AreEqual(versions[^2], versions[^1]); + Assert.IsFalse(element1.Published); + Assert.AreEqual(PublishedState.Unpublished, element1.PublishedState); + Assert.AreEqual(versions[^1], repository.Get(element1.Id)!.VersionId); + + // misc checks + Assert.AreEqual( + false, + ScopeAccessor.AmbientScope.Database.ExecuteScalar( + $"SELECT published FROM {Constants.DatabaseSchema.Tables.Element} WHERE nodeId=@id", + new { id = element1.Id })); + + // change something + // save = update the current (draft) version + element1.Name = "name-2"; + element1.SetValue("title", "title-2"); + repository.Save(element1); + + versions.Add(element1.VersionId); // the same version + + // no new version has been created + Assert.AreEqual(versions[^2], versions[^1]); + Assert.AreEqual(versions[^1], repository.Get(element1.Id)!.VersionId); + + // misc checks + Assert.AreEqual( + false, + ScopeAccessor.AmbientScope.Database.ExecuteScalar( + $"SELECT published FROM {Constants.DatabaseSchema.Tables.Element} WHERE nodeId=@id", + new { id = element1.Id })); + + // publish = version + element1.PublishCulture(CultureImpact.Invariant, DateTime.Now, PropertyEditorCollection); + element1.PublishedState = PublishedState.Publishing; + repository.Save(element1); + + versions.Add(element1.VersionId); // NEW VERSION + + // new version has been created + Assert.AreNotEqual(versions[^2], versions[^1]); + Assert.IsTrue(element1.Published); + Assert.AreEqual(PublishedState.Published, element1.PublishedState); + Assert.AreEqual(versions[^1], repository.Get(element1.Id)!.VersionId); + + // misc checks + Assert.AreEqual( + true, + ScopeAccessor.AmbientScope.Database.ExecuteScalar( + $"SELECT published FROM {Constants.DatabaseSchema.Tables.Element} WHERE nodeId=@id", + new { id = element1.Id })); + + // change something + // save = update the current (draft) version + element1.Name = "name-3"; + element1.SetValue("title", "title-3"); + + //// Thread.Sleep(2000); // force date change + + repository.Save(element1); + + versions.Add(element1.VersionId); // the same version + + // no new version has been created + Assert.AreEqual(versions[^2], versions[^1]); + Assert.AreEqual(versions[^1], repository.Get(element1.Id)!.VersionId); + + // misc checks + Assert.AreEqual( + true, + ScopeAccessor.AmbientScope.Database.ExecuteScalar( + $"SELECT published FROM {Constants.DatabaseSchema.Tables.Element} WHERE nodeId=@id", + new { id = element1.Id })); + + // publish = new version + element1.Name = "name-4"; + element1.SetValue("title", "title-4"); + element1.PublishCulture(CultureImpact.Invariant, DateTime.Now, PropertyEditorCollection); + element1.PublishedState = PublishedState.Publishing; + repository.Save(element1); + + versions.Add(element1.VersionId); // NEW VERSION + + // a new version has been created + Assert.AreNotEqual(versions[^2], versions[^1]); + Assert.IsTrue(element1.Published); + Assert.AreEqual(PublishedState.Published, element1.PublishedState); + Assert.AreEqual(versions[^1], repository.Get(element1.Id)!.VersionId); + + // misc checks + Assert.AreEqual( + true, + ScopeAccessor.AmbientScope.Database.ExecuteScalar( + $"SELECT published FROM {Constants.DatabaseSchema.Tables.Element} WHERE nodeId=@id", + new { id = element1.Id })); + + // all versions + var allVersions = repository.GetAllVersions(element1.Id).ToArray(); + Assert.Multiple(() => + { + Assert.AreEqual(4, allVersions.Length); + Assert.IsTrue(allVersions.All(v => v.PublishedVersionId == 3)); + Assert.AreEqual(4, allVersions.DistinctBy(v => v.VersionId).Count()); + for (var versionId = 1; versionId <= 4; versionId++) + { + Assert.IsNotNull(allVersions.FirstOrDefault(v => v.VersionId == versionId)); + } + }); + + // Console.WriteLine(); + // foreach (var v in versions) + // { + // Console.WriteLine(v); + // } + // + // Console.WriteLine(); + // foreach (var v in allVersions) + // { + // Console.WriteLine($"{v.Id} {v.VersionId} {(v.Published ? "+" : "-")}pub pk={v.VersionId} ppk={v.PublishedVersionId} name=\"{v.Name}\" pname=\"{v.PublishName}\""); + // } + + // get older version + var element = repository.GetVersion(versions[^4]); + Assert.AreNotEqual(0, element.VersionId); + Assert.AreEqual(versions[^4], element.VersionId); + Assert.AreEqual("name-4", element1.Name); + Assert.AreEqual("title-4", element1.GetValue("title")); + Assert.AreEqual("name-2", element.Name); + Assert.AreEqual("title-2", element.GetValue("title")); + + // get all versions - most recent first + allVersions = repository.GetAllVersions(element1.Id).ToArray(); + var expVersions = versions.Distinct().Reverse().ToArray(); + Assert.AreEqual(expVersions.Length, allVersions.Length); + for (var i = 0; i < expVersions.Length; i++) + { + Assert.AreEqual(expVersions[i], allVersions[i].VersionId); + } + } + } + + // TODO ELEMENTS: port over all relevant tests from DocumentRepositoryTest +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.cs new file mode 100644 index 000000000000..0237399fab28 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementContainerServiceTests.cs @@ -0,0 +1,228 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public class ElementContainerServiceTests : UmbracoIntegrationTest +{ + private IElementContainerService ElementContainerService => GetRequiredService(); + + [Test] + public async Task Can_Create_Container_At_Root() + { + var result = await ElementContainerService.CreateAsync(null, "Root Container", null, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(result.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, result.Status); + }); + + var created = await ElementContainerService.GetAsync(result.Result.Key); + Assert.NotNull(created); + Assert.Multiple(() => + { + Assert.AreEqual("Root Container", created.Name); + Assert.AreEqual(Constants.System.Root, created.ParentId); + }); + } + + [Test] + public async Task Can_Create_Child_Container() + { + EntityContainer root = (await ElementContainerService.CreateAsync(null, "Root Container", null, Constants.Security.SuperUserKey)).Result; + + var result = await ElementContainerService.CreateAsync(null, "Child Container", root.Key, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(result.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, result.Status); + }); + + var created = await ElementContainerService.GetAsync(result.Result.Key); + Assert.NotNull(created); + Assert.Multiple(() => + { + Assert.AreEqual("Child Container", created.Name); + Assert.AreEqual(root.Id, created.ParentId); + }); + } + + [Test] + public async Task Can_Create_Container_With_Explicit_Key() + { + var key = Guid.NewGuid(); + var result = await ElementContainerService.CreateAsync(key, "Root Container", null, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(result.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, result.Status); + Assert.AreEqual(key, result.Result.Key); + }); + + var created = await ElementContainerService.GetAsync(key); + Assert.NotNull(created); + Assert.Multiple(() => + { + Assert.AreEqual("Root Container", created.Name); + Assert.AreEqual(Constants.System.Root, created.ParentId); + }); + } + + [Test] + public async Task Can_Update_Container_At_Root() + { + var key = (await ElementContainerService.CreateAsync(null, "Root Container", null, Constants.Security.SuperUserKey)).Result.Key; + + var result = await ElementContainerService.UpdateAsync(key, "Root Container UPDATED", Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(result.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, result.Status); + }); + + var updated = await ElementContainerService.GetAsync(key); + Assert.NotNull(updated); + Assert.Multiple(() => + { + Assert.AreEqual("Root Container UPDATED", updated.Name); + Assert.AreEqual(Constants.System.Root, updated.ParentId); + }); + } + + [Test] + public async Task Can_Update_Child_Container() + { + EntityContainer root = (await ElementContainerService.CreateAsync(null, "Root Container", null, Constants.Security.SuperUserKey)).Result; + EntityContainer child = (await ElementContainerService.CreateAsync(null, "Child Container", root.Key, Constants.Security.SuperUserKey)).Result; + + var result = await ElementContainerService.UpdateAsync(child.Key, "Child Container UPDATED", Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(result.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, result.Status); + }); + + EntityContainer updated = await ElementContainerService.GetAsync(child.Key); + Assert.NotNull(updated); + Assert.Multiple(() => + { + Assert.AreEqual("Child Container UPDATED", updated.Name); + Assert.AreEqual(root.Id, updated.ParentId); + }); + } + + [Test] + public async Task Can_Get_Container_At_Root() + { + EntityContainer root = (await ElementContainerService.CreateAsync(null,"Root Container", null, Constants.Security.SuperUserKey)).Result; + + EntityContainer created = await ElementContainerService.GetAsync(root.Key); + Assert.NotNull(created); + Assert.Multiple(() => + { + Assert.AreEqual("Root Container", created.Name); + Assert.AreEqual(Constants.System.Root, created.ParentId); + }); + } + + [Test] + public async Task Can_Get_Child_Container() + { + EntityContainer root = (await ElementContainerService.CreateAsync(null,"Root Container", null, Constants.Security.SuperUserKey)).Result; + EntityContainer child = (await ElementContainerService.CreateAsync(null, "Child Container", root.Key, Constants.Security.SuperUserKey)).Result; + + EntityContainer created = await ElementContainerService.GetAsync(child.Key); + Assert.IsNotNull(created); + Assert.Multiple(() => + { + Assert.AreEqual("Child Container", created.Name); + Assert.AreEqual(root.Id, child.ParentId); + }); + } + + [Test] + public async Task Can_Delete_Container_At_Root() + { + EntityContainer root = (await ElementContainerService.CreateAsync(null,"Root Container", null, Constants.Security.SuperUserKey)).Result; + + var result = await ElementContainerService.DeleteAsync(root.Key, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(result.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, result.Status); + }); + + var current = await ElementContainerService.GetAsync(root.Key); + Assert.IsNull(current); + } + + [Test] + public async Task Can_Delete_Child_Container() + { + EntityContainer root = (await ElementContainerService.CreateAsync(null,"Root Container", null, Constants.Security.SuperUserKey)).Result; + EntityContainer child = (await ElementContainerService.CreateAsync(null, "Child Container", root.Key, Constants.Security.SuperUserKey)).Result; + + var result = await ElementContainerService.DeleteAsync(child.Key, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsTrue(result.Success); + Assert.AreEqual(EntityContainerOperationStatus.Success, result.Status); + }); + + child = await ElementContainerService.GetAsync(child.Key); + Assert.IsNull(child); + + root = await ElementContainerService.GetAsync(root.Key); + Assert.IsNotNull(root); + } + + [Test] + public async Task Cannot_Create_Child_Container_Below_Invalid_Parent() + { + var key = Guid.NewGuid(); + var result = await ElementContainerService.CreateAsync(key, "Child Container", Guid.NewGuid(), Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsFalse(result.Success); + Assert.AreEqual(EntityContainerOperationStatus.ParentNotFound, result.Status); + }); + + var created = await ElementContainerService.GetAsync(key); + Assert.IsNull(created); + } + + [Test] + public async Task Cannot_Delete_Container_With_Child_Container() + { + EntityContainer root = (await ElementContainerService.CreateAsync(null,"Root Container", null, Constants.Security.SuperUserKey)).Result; + EntityContainer child = (await ElementContainerService.CreateAsync(null, "Child Container", root.Key, Constants.Security.SuperUserKey)).Result; + + var result = await ElementContainerService.DeleteAsync(root.Key, Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsFalse(result.Success); + Assert.AreEqual(EntityContainerOperationStatus.NotEmpty, result.Status); + }); + + var current = await ElementContainerService.GetAsync(root.Key); + Assert.IsNotNull(current); + } + + [Test] + public async Task Cannot_Delete_Non_Existing_Container() + { + var result = await ElementContainerService.DeleteAsync(Guid.NewGuid(), Constants.Security.SuperUserKey); + Assert.Multiple(() => + { + Assert.IsFalse(result.Success); + Assert.AreEqual(EntityContainerOperationStatus.NotFound, result.Status); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Create.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Create.cs new file mode 100644 index 000000000000..9b0ccb89ebf4 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Create.cs @@ -0,0 +1,472 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Common.Builders; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +public partial class ElementEditingServiceTests +{ + [Test] + public async Task Can_Create_At_Root() + { + var elementType = await CreateInvariantElementType(); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Test Create" } + ], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "The title value" }, + new PropertyValueModel { Alias = "text", Value = "The text value" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + VerifyCreate(result.Result.Content); + + // re-get and re-test + VerifyCreate(await ElementEditingService.GetAsync(result.Result.Content!.Key)); + + void VerifyCreate(IElement? createdElement) + { + Assert.IsNotNull(createdElement); + Assert.AreNotEqual(Guid.Empty, createdElement.Key); + Assert.IsTrue(createdElement.HasIdentity); + Assert.AreEqual("Test Create", createdElement.Name); + Assert.AreEqual("The title value", createdElement.GetValue("title")); + Assert.AreEqual("The text value", createdElement.GetValue("text")); + } + } + + [Test] + public async Task Can_Create_In_A_Folder() + { + var elementType = await CreateInvariantElementType(); + + var containerKey = Guid.NewGuid(); + var container = (await ElementContainerService.CreateAsync(containerKey, "Root Container", null, Constants.Security.SuperUserKey)).Result; + + var elementKey = Guid.NewGuid(); + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = containerKey, + Key = elementKey, + Variants = + [ + new VariantModel { Name = "Test Create" } + ], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "The title value" }, + new PropertyValueModel { Alias = "text", Value = "The text value" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + + var element = await ElementEditingService.GetAsync(elementKey); + Assert.NotNull(element); + Assert.AreEqual(container.Id, element.ParentId); + + var children = GetFolderChildren(containerKey); + Assert.AreEqual(1, children.Length); + Assert.AreEqual(elementKey, children[0].Key); + } + + [Test] + public async Task Can_Create_Without_Properties() + { + var elementType = await CreateInvariantElementType(); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Test Create" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + Assert.IsNotNull(result.Result); + Assert.IsTrue(result.Result.Content!.HasIdentity); + Assert.AreEqual(null, result.Result.Content!.GetValue("title")); + Assert.AreEqual(null, result.Result.Content!.GetValue("text")); + } + + [TestCase(true)] + [TestCase(false)] + public async Task Can_Create_With_Property_Validation(bool addValidProperties) + { + var elementType = await CreateInvariantElementType(); + elementType.PropertyTypes.First(pt => pt.Alias == "title").Mandatory = true; + elementType.PropertyTypes.First(pt => pt.Alias == "text").ValidationRegExp = "^\\d*$"; + elementType.AllowedAsRoot = true; + await ContentTypeService.UpdateAsync(elementType, Constants.Security.SuperUserKey); + + var titleValue = addValidProperties ? "The title value" : null; + var textValue = addValidProperties ? "12345" : "This is not a number"; + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Test Create" } + ], + Properties = new[] + { + new PropertyValueModel { Alias = "title", Value = titleValue }, + new PropertyValueModel { Alias = "text", Value = textValue } + }, + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + + // success is expected regardless of property level validation - the validation error status is communicated in the attempt status (see below) + Assert.IsTrue(result.Success); + Assert.AreEqual(addValidProperties ? ContentEditingOperationStatus.Success : ContentEditingOperationStatus.PropertyValidationError, result.Status); + Assert.IsNotNull(result.Result); + + if (addValidProperties is false) + { + Assert.AreEqual(2, result.Result.ValidationResult.ValidationErrors.Count()); + Assert.IsNotNull(result.Result.ValidationResult.ValidationErrors.FirstOrDefault(v => v.Alias == "title" && v.ErrorMessages.Length == 1)); + Assert.IsNotNull(result.Result.ValidationResult.ValidationErrors.FirstOrDefault(v => v.Alias == "text" && v.ErrorMessages.Length == 1)); + } + + // NOTE: creation must be successful, even if the mandatory property is missing (publishing however should not!) + Assert.IsTrue(result.Result.Content!.HasIdentity); + Assert.AreEqual(titleValue, result.Result.Content!.GetValue("title")); + Assert.AreEqual(textValue, result.Result.Content!.GetValue("text")); + } + + [Test] + public async Task Can_Create_With_Explicit_Key() + { + var elementType = await CreateInvariantElementType(); + + var key = Guid.NewGuid(); + var createModel = new ElementCreateModel + { + Key = key, + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Test Create" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + Assert.IsNotNull(result.Result.Content); + Assert.IsTrue(result.Result.Content.HasIdentity); + Assert.AreEqual(key, result.Result.Content.Key); + + var element = await ElementEditingService.GetAsync(key); + Assert.IsNotNull(element); + Assert.AreEqual(result.Result.Content.Id, element.Id); + } + + [Test] + public async Task Can_Create_Culture_Variant() + { + var elementType = await CreateVariantElementType(); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The Invariant Title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The English Title", Culture = "en-US" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The Danish Title", Culture = "da-DK" } + ], + Variants = + [ + new VariantModel { Culture = "en-US", Name = "The English Name" }, + new VariantModel { Culture = "da-DK", Name = "The Danish Name" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + Assert.IsNotNull(result.Result.Content); + VerifyCreate(result.Result.Content); + + // re-get and re-test + VerifyCreate(await ElementEditingService.GetAsync(result.Result.Content.Key)); + + void VerifyCreate(IElement? createdElement) + { + Assert.IsNotNull(createdElement); + Assert.AreEqual("The English Name", createdElement.GetCultureName("en-US")); + Assert.AreEqual("The Danish Name", createdElement.GetCultureName("da-DK")); + Assert.AreEqual("The Invariant Title", createdElement.GetValue("invariantTitle")); + Assert.AreEqual("The English Title", createdElement.GetValue("variantTitle", "en-US")); + Assert.AreEqual("The Danish Title", createdElement.GetValue("variantTitle", "da-DK")); + } + } + + [Test] + public async Task Can_Create_Segment_Variant() + { + var elementType = await CreateVariantElementType(ContentVariation.Segment); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The Invariant Title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The Default Title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The Seg-1 Title", Segment = "seg-1" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The Seg-2 Title", Segment = "seg-2" } + ], + Variants = + [ + new VariantModel { Name = "The Name" }, + new VariantModel { Segment = "seg-1", Name = "The Name" }, + new VariantModel { Segment = "seg-2", Name = "The Name" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + Assert.IsNotNull(result.Result.Content); + VerifyCreate(result.Result.Content); + + // re-get and re-test + VerifyCreate(await ElementEditingService.GetAsync(result.Result.Content.Key)); + + void VerifyCreate(IElement? createdElement) + { + Assert.IsNotNull(createdElement); + Assert.Multiple(() => + { + Assert.AreEqual("The Name", createdElement.Name); + Assert.AreEqual("The Invariant Title", createdElement.GetValue("invariantTitle")); + Assert.AreEqual("The Default Title", createdElement.GetValue("variantTitle", segment: null)); + Assert.AreEqual("The Seg-1 Title", createdElement.GetValue("variantTitle", segment: "seg-1")); + Assert.AreEqual("The Seg-2 Title", createdElement.GetValue("variantTitle", segment: "seg-2")); + }); + } + } + + [Test] + public async Task Can_Create_Culture_And_Segment_Variant() + { + var elementType = await CreateVariantElementType(ContentVariation.CultureAndSegment); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The Invariant Title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The Default Title in English", Culture = "en-US" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The Seg-1 Title in English", Culture = "en-US", Segment = "seg-1" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The Seg-2 Title in English", Culture = "en-US", Segment = "seg-2" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The Default Title in Danish", Culture = "da-DK" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The Seg-1 Title in Danish", Culture = "da-DK", Segment = "seg-1" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The Seg-2 Title in Danish", Culture = "da-DK", Segment = "seg-2" } + ], + Variants = + [ + new VariantModel { Name = "The English Name", Culture = "en-US" }, + new VariantModel { Name = "The English Name", Culture = "en-US", Segment = "seg-1" }, + new VariantModel { Name = "The English Name", Culture = "en-US", Segment = "seg-2" }, + new VariantModel { Name = "The Danish Name", Culture = "da-DK" }, + new VariantModel { Name = "The Danish Name", Culture = "da-DK", Segment = "seg-1" }, + new VariantModel { Name = "The Danish Name", Culture = "da-DK", Segment = "seg-2" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + Assert.IsNotNull(result.Result.Content); + VerifyCreate(result.Result.Content); + + // re-get and re-test + VerifyCreate(await ElementEditingService.GetAsync(result.Result.Content.Key)); + + void VerifyCreate(IElement? createdElement) + { + Assert.IsNotNull(createdElement); + Assert.Multiple(() => + { + Assert.AreEqual("The English Name", createdElement.GetCultureName("en-US")); + Assert.AreEqual("The Danish Name", createdElement.GetCultureName("da-DK")); + Assert.AreEqual("The Invariant Title", createdElement.GetValue("invariantTitle")); + Assert.AreEqual("The Default Title in English", createdElement.GetValue("variantTitle", culture: "en-US", segment: null)); + Assert.AreEqual("The Seg-1 Title in English", createdElement.GetValue("variantTitle", culture: "en-US", segment: "seg-1")); + Assert.AreEqual("The Seg-2 Title in English", createdElement.GetValue("variantTitle", culture: "en-US", segment: "seg-2")); + Assert.AreEqual("The Default Title in Danish", createdElement.GetValue("variantTitle", culture: "da-DK", segment: null)); + Assert.AreEqual("The Seg-1 Title in Danish", createdElement.GetValue("variantTitle", culture: "da-DK", segment: "seg-1")); + Assert.AreEqual("The Seg-2 Title in Danish", createdElement.GetValue("variantTitle", culture: "da-DK", segment: "seg-2")); + }); + } + } + + [Test] + public async Task Cannot_Create_Without_Element_Type() + { + var createModel = new ElementCreateModel + { + ContentTypeKey = Guid.NewGuid(), + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Test Create" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.ContentTypeNotFound, result.Status); + Assert.IsNotNull(result.Result); + Assert.IsNull(result.Result.Content); + } + + [Test] + public async Task Cannot_Create_With_Non_Existing_Properties() + { + var elementType = await CreateInvariantElementType(); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Test Create" } + ], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "The title value" }, + new PropertyValueModel { Alias = "no_such_property", Value = "No such property value" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.PropertyTypeNotFound, result.Status); + Assert.IsNotNull(result.Result); + Assert.IsNull(result.Result.Content); + } + + [Test] + public async Task Cannot_Create_Invariant_Element_Without_Name() + { + var elementType = await CreateInvariantElementType(); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Variants = [], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "The title value" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.ContentTypeCultureVarianceMismatch, result.Status); + Assert.IsNotNull(result.Result); + Assert.IsNull(result.Result.Content); + } + + [TestCase(ContentVariation.Culture)] + [TestCase(ContentVariation.Segment)] + public async Task Cannot_Create_With_Variant_Property_Value_For_Invariant_Content(ContentVariation contentVariation) + { + var elementType = await CreateInvariantElementType(); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Test Create" } + ], + Properties = + [ + new PropertyValueModel + { + Alias = "title", + Value = "The title value", + }, + new PropertyValueModel + { + Alias = "bodyText", + Value = "The body text value", + Culture = contentVariation is ContentVariation.Culture ? "en-US" : null, + Segment = contentVariation is ContentVariation.Segment ? "segment" : null, + } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.PropertyTypeNotFound, result.Status); + Assert.IsNotNull(result.Result); + Assert.IsNull(result.Result.Content); + } + + [Test] + // TODO ELEMENTS: make ContentEditingServiceBase element aware so it can guard against this test case + // TODO ELEMENTS: create a similar test for content creation based on element types + public async Task Cannot_Create_Element_Based_On_NonElement_ContentType() + { + var contentType = ContentTypeBuilder.CreateSimpleContentType(); + Assert.IsFalse(contentType.IsElement); + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + var createModel = new ElementCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Test Create" } + ], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "The title value" }, + new PropertyValueModel { Alias = "bodyText", Value = "The body text" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.NotAllowed, result.Status); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Delete.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Delete.cs new file mode 100644 index 000000000000..1f0d7b752ade --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Delete.cs @@ -0,0 +1,31 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +public partial class ElementEditingServiceTests +{ + [TestCase(true)] + [TestCase(false)] + public async Task Can_Delete_FromOutsideOfRecycleBin(bool variant) + { + var element = await (variant ? CreateCultureVariantElement() : CreateInvariantElement()); + + var result = await ElementEditingService.DeleteAsync(element.Key, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + + // re-get and verify deletion + element = await ElementEditingService.GetAsync(element.Key); + Assert.IsNull(element); + } + + [Test] + public async Task Cannot_Delete_Non_Existing() + { + var result = await ElementEditingService.DeleteAsync(Guid.NewGuid(), Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.NotFound, result.Status); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Update.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Update.cs new file mode 100644 index 000000000000..d087c3943131 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.Update.cs @@ -0,0 +1,183 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +public partial class ElementEditingServiceTests +{ + [Test] + public async Task Can_Update_Invariant() + { + var element = await CreateInvariantElement(); + + var updateModel = new ElementUpdateModel + { + Variants = + [ + new VariantModel { Name = "Updated Name" } + ], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "The updated title" }, + new PropertyValueModel { Alias = "text", Value = "The updated text" } + ], + }; + + var result = await ElementEditingService.UpdateAsync(element.Key, updateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + VerifyUpdate(result.Result.Content); + + // re-get and re-test + VerifyUpdate(await ElementEditingService.GetAsync(element.Key)); + + void VerifyUpdate(IElement? updatedElement) + { + Assert.IsNotNull(updatedElement); + Assert.AreEqual("Updated Name", updatedElement.Name); + Assert.AreEqual("The updated title", updatedElement.GetValue("title")); + Assert.AreEqual("The updated text", updatedElement.GetValue("text")); + } + } + + [Test] + public async Task Can_Update_Culture_Variant() + { + var element = await CreateCultureVariantElement(); + + var updateModel = new ElementUpdateModel + { + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The updated invariant title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated English title", Culture = "en-US" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated Danish title", Culture = "da-DK" }, + ], + Variants = + [ + new VariantModel { Culture = "en-US", Name = "Updated English Name" }, + new VariantModel { Culture = "da-DK", Name = "Updated Danish Name" } + ], + }; + + var result = await ElementEditingService.UpdateAsync(element.Key, updateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + VerifyUpdate(result.Result.Content); + + // re-get and re-test + VerifyUpdate(await ElementEditingService.GetAsync(element.Key)); + + void VerifyUpdate(IElement? updatedElement) + { + Assert.IsNotNull(updatedElement); + Assert.AreEqual("Updated English Name", updatedElement.GetCultureName("en-US")); + Assert.AreEqual("Updated Danish Name", updatedElement.GetCultureName("da-DK")); + Assert.AreEqual("The updated invariant title", updatedElement.GetValue("invariantTitle")); + Assert.AreEqual("The updated English title", updatedElement.GetValue("variantTitle", "en-US")); + Assert.AreEqual("The updated Danish title", updatedElement.GetValue("variantTitle", "da-DK")); + } + } + + [Test] + public async Task Can_Update_Segment_Variant() + { + var element = await CreateSegmentVariantElement(); + + var updateModel = new ElementUpdateModel + { + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The updated invariant title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated default title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated seg-1 title", Segment = "seg-1" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated seg-2 title", Segment = "seg-2" } + ], + Variants = + [ + new VariantModel { Name = "The Updated Name" }, + new VariantModel { Segment = "seg-1", Name = "The Updated Name" }, + new VariantModel { Segment = "seg-2", Name = "The Updated Name" } + ], + }; + + var result = await ElementEditingService.UpdateAsync(element.Key, updateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + VerifyUpdate(result.Result.Content); + + // re-get and re-test + VerifyUpdate(await ElementEditingService.GetAsync(element.Key)); + + void VerifyUpdate(IElement? updatedElement) + { + Assert.IsNotNull(updatedElement); + Assert.Multiple(() => + { + Assert.AreEqual("The Updated Name", updatedElement.Name); + Assert.AreEqual("The updated invariant title", updatedElement.GetValue("invariantTitle")); + Assert.AreEqual("The updated default title", updatedElement.GetValue("variantTitle", segment: null)); + Assert.AreEqual("The updated seg-1 title", updatedElement.GetValue("variantTitle", segment: "seg-1")); + Assert.AreEqual("The updated seg-2 title", updatedElement.GetValue("variantTitle", segment: "seg-2")); + }); + } + } + + [Test] + public async Task Can_Update_Culture_And_Segment_Variant() + { + var element = await CreateCultureAndSegmentVariantElement(); + + var updateModel = new ElementUpdateModel + { + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The updated invariant title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated default title in English", Culture = "en-US" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated seg-1 title in English", Culture = "en-US", Segment = "seg-1" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated seg-2 title in English", Culture = "en-US", Segment = "seg-2" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated default title in Danish", Culture = "da-DK" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated seg-1 title in Danish", Culture = "da-DK", Segment = "seg-1" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The updated seg-2 title in Danish", Culture = "da-DK", Segment = "seg-2" } + ], + Variants = + [ + new VariantModel { Name = "The Updated English Name", Culture = "en-US" }, + new VariantModel { Name = "The Updated English Name", Culture = "en-US", Segment = "seg-1" }, + new VariantModel { Name = "The Updated English Name", Culture = "en-US", Segment = "seg-2" }, + new VariantModel { Name = "The Updated Danish Name", Culture = "da-DK" }, + new VariantModel { Name = "The Updated Danish Name", Culture = "da-DK", Segment = "seg-1" }, + new VariantModel { Name = "The Updated Danish Name", Culture = "da-DK", Segment = "seg-2" } + ], + }; + + var result = await ElementEditingService.UpdateAsync(element.Key, updateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + VerifyUpdate(result.Result.Content); + + // re-get and re-test + VerifyUpdate(await ElementEditingService.GetAsync(element.Key)); + + void VerifyUpdate(IElement? updatedElement) + { + Assert.IsNotNull(updatedElement); + Assert.Multiple(() => + { + Assert.AreEqual("The Updated English Name", updatedElement.GetCultureName("en-US")); + Assert.AreEqual("The Updated Danish Name", updatedElement.GetCultureName("da-DK")); + + Assert.AreEqual("The updated invariant title", updatedElement.GetValue("invariantTitle")); + Assert.AreEqual("The updated default title in English", updatedElement.GetValue("variantTitle", culture: "en-US", segment: null)); + Assert.AreEqual("The updated seg-1 title in English", updatedElement.GetValue("variantTitle", culture: "en-US", segment: "seg-1")); + Assert.AreEqual("The updated seg-2 title in English", updatedElement.GetValue("variantTitle", culture: "en-US", segment: "seg-2")); + Assert.AreEqual("The updated default title in Danish", updatedElement.GetValue("variantTitle", culture: "da-DK", segment: null)); + Assert.AreEqual("The updated seg-1 title in Danish", updatedElement.GetValue("variantTitle", culture: "da-DK", segment: "seg-1")); + Assert.AreEqual("The updated seg-2 title in Danish", updatedElement.GetValue("variantTitle", culture: "da-DK", segment: "seg-2")); + }); + } + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.cs new file mode 100644 index 000000000000..db5c4aeabe5c --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementEditingServiceTests.cs @@ -0,0 +1,216 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +/// +/// NOTE: ElementEditingService and ContentEditingService share most of their implementation. +/// +/// as such, these tests for ElementEditingService are not exhaustive, because that would require too much +/// duplication from the ContentEditingService tests, without any real added value. +/// +/// instead, these tests focus on validating that the most basic functionality is in place for element editing. +/// +[TestFixture] +[UmbracoTest( + Database = UmbracoTestOptions.Database.NewSchemaPerTest, + PublishedRepositoryEvents = true, + WithApplication = true)] +public partial class ElementEditingServiceTests : UmbracoIntegrationTest +{ + [SetUp] + public void Setup() => ContentRepositoryBase.ThrowOnWarning = true; + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private IElementEditingService ElementEditingService => GetRequiredService(); + + private IElementContainerService ElementContainerService => GetRequiredService(); + + private ILanguageService LanguageService => GetRequiredService(); + + private IEntityService EntityService => GetRequiredService(); + + private async Task CreateInvariantElementType(bool allowedAtRoot = true) + { + var elementType = new ContentTypeBuilder() + .WithAlias("invariantTest") + .WithName("Invariant Test") + .WithAllowAsRoot(allowedAtRoot) + .WithIsElement(true) + .AddPropertyType() + .WithAlias("title") + .WithName("Title") + .Done() + .AddPropertyType() + .WithAlias("text") + .WithName("Text") + .Done() + .Build(); + + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + return elementType; + } + + private async Task CreateVariantElementType(ContentVariation variation = ContentVariation.Culture, bool variantTitleAsMandatory = true, bool allowedAtRoot = true) + { + var language = new LanguageBuilder() + .WithCultureInfo("da-DK") + .Build(); + await LanguageService.CreateAsync(language, Constants.Security.SuperUserKey); + + var elementType = new ContentTypeBuilder() + .WithAlias("cultureVariationTest") + .WithName("Culture Variation Test") + .WithAllowAsRoot(allowedAtRoot) + .WithIsElement(true) + .WithContentVariation(variation) + .AddPropertyType() + .WithAlias("variantTitle") + .WithName("Variant Title") + .WithMandatory(variantTitleAsMandatory) + .WithVariations(variation) + .Done() + .AddPropertyType() + .WithAlias("invariantTitle") + .WithName("Invariant Title") + .WithVariations(ContentVariation.Nothing) + .Done() + .AddPropertyType() + .WithAlias("variantLabel") + .WithName("Variant Label") + .WithDataTypeId(Constants.DataTypes.LabelString) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.Label) + .WithVariations(variation) + .Done() + .Build(); + + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + return elementType; + } + + private async Task CreateInvariantElement() + { + var elementType = await CreateInvariantElementType(); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Variants = + [ + new VariantModel { Name = "Initial Name" } + ], + Properties = + [ + new PropertyValueModel { Alias = "title", Value = "The initial title" }, + new PropertyValueModel { Alias = "text", Value = "The initial text" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + return result.Result.Content!; + } + + private async Task CreateCultureVariantElement() + { + var elementType = await CreateVariantElementType(); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The initial invariant title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial English title", Culture = "en-US" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial Danish title", Culture = "da-DK" } + ], + Variants = + [ + new VariantModel { Culture = "en-US", Name = "Initial English Name" }, + new VariantModel { Culture = "da-DK", Name = "Initial Danish Name" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + return result.Result.Content!; + } + + private async Task CreateSegmentVariantElement() + { + var elementType = await CreateVariantElementType(ContentVariation.Segment); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The initial invariant title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial default title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial seg-1 title", Segment = "seg-1" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial seg-2 title", Segment = "seg-2" } + ], + Variants = + [ + new VariantModel { Segment = null, Name = "The Name" }, + new VariantModel { Segment = "seg-1", Name = "The Name" }, + new VariantModel { Segment = "seg-2", Name = "The Name" } + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + return result.Result.Content!; + } + + private async Task CreateCultureAndSegmentVariantElement() + { + var elementType = await CreateVariantElementType(ContentVariation.CultureAndSegment); + + var createModel = new ElementCreateModel + { + ContentTypeKey = elementType.Key, + ParentKey = Constants.System.RootKey, + Properties = + [ + new PropertyValueModel { Alias = "invariantTitle", Value = "The initial invariant title" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial title in English", Culture = "en-US" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial seg-1 title in English", Culture = "en-US", Segment = "seg-1" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial seg-2 title in English", Culture = "en-US", Segment = "seg-2" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial title in Danish", Culture = "da-DK" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial seg-1 title in Danish", Culture = "da-DK", Segment = "seg-1" }, + new PropertyValueModel { Alias = "variantTitle", Value = "The initial seg-2 title in Danish", Culture = "da-DK", Segment = "seg-2" } + ], + Variants = + [ + new VariantModel { Name = "The Name", Culture = "en-US", Segment = null }, + new VariantModel { Name = "The Name", Culture = "en-US", Segment = "seg-1" }, + new VariantModel { Name = "The Name", Culture = "en-US", Segment = "seg-2" }, + new VariantModel { Name = "The Name", Culture = "da-DK", Segment = null }, + new VariantModel { Name = "The Name", Culture = "da-DK", Segment = "seg-1" }, + new VariantModel { Name = "The Name", Culture = "da-DK", Segment = "seg-2" }, + ], + }; + + var result = await ElementEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + return result.Result.Content!; + } + + private IEntitySlim[] GetFolderChildren(Guid containerKey) + => EntityService.GetPagedChildren(containerKey, [UmbracoObjectTypes.ElementContainer], UmbracoObjectTypes.Element, 0, 100, out _).ToArray(); +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index 935828e92dde..70182a6758b4 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -301,5 +301,29 @@ ContentBlueprintEditingServiceTests.cs + + ElementEditingServiceTests.cs + + + ElementEditingServiceTests.cs + + + ElementEditingServiceTests.cs + + + ElementEditingServiceTests.cs + + + ElementEditingServiceTests.cs + + + ElementEditingServiceTests.cs + + + ElementPublishingServiceTests.cs + + + ElementPublishingServiceTests.cs + diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Cache/RefresherTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Cache/RefresherTests.cs index 3f00ac1e5ef8..27ecf666563a 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Cache/RefresherTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Cache/RefresherTests.cs @@ -91,6 +91,27 @@ public void ContentCacheRefresherCanDeserializeJsonPayloadWithCultures() }); } + [TestCase(TreeChangeTypes.None)] + [TestCase(TreeChangeTypes.RefreshAll)] + [TestCase(TreeChangeTypes.RefreshBranch)] + [TestCase(TreeChangeTypes.Remove)] + [TestCase(TreeChangeTypes.RefreshNode)] + public void ElementCacheRefresherCanDeserializeJsonPayload(TreeChangeTypes changeTypes) + { + var key = Guid.NewGuid(); + ElementCacheRefresher.JsonPayload[] source = + { + new(1234, key, changeTypes) + }; + + var json = JsonSerializer.Serialize(source); + var payload = JsonSerializer.Deserialize(json); + + Assert.AreEqual(1234, payload[0].Id); + Assert.AreEqual(key, payload[0].Key); + Assert.AreEqual(changeTypes, payload[0].ChangeTypes); + } + [Test] public void ContentTypeCacheRefresherCanDeserializeJsonPayload() { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs index 068bf44db66f..4987432aea70 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs @@ -132,7 +132,7 @@ public void Resolves_Types() public void GetDataEditors() { var types = _typeLoader.GetDataEditors(); - Assert.AreEqual(36, types.Count()); + Assert.AreEqual(37, types.Count()); } ///