Merge branch 'main' into v17/dev
This commit is contained in:
2
.github/contributing-backoffice.md
vendored
2
.github/contributing-backoffice.md
vendored
@@ -114,7 +114,7 @@ To declare the Published Cache Status Dashboard as a new manifest, we need to ad
|
||||
},
|
||||
conditions: [
|
||||
{
|
||||
alias: 'Umb.Condition.SectionAlias',
|
||||
alias: UMB_SECTION_ALIAS_CONDITION_ALIAS,
|
||||
match: 'Umb.Section.Settings',
|
||||
},
|
||||
],
|
||||
|
||||
4
.github/workflows/azure-backoffice.yml
vendored
4
.github/workflows/azure-backoffice.yml
vendored
@@ -20,7 +20,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build_and_deploy_job:
|
||||
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed' && (contains(github.event.pull_request.labels.*.name, 'preview/backoffice')))
|
||||
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed' && contains(github.event.pull_request.labels.*.name, 'preview/backoffice') && github.repository == github.event.pull_request.head.repo.full_name)
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and Deploy Job
|
||||
steps:
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
###### End of Repository/Build Configurations ######
|
||||
|
||||
close_pull_request_job:
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview/backoffice') && github.repository == github.event.pull_request.head.repo.full_name
|
||||
runs-on: ubuntu-latest
|
||||
name: Close Pull Request Job
|
||||
steps:
|
||||
|
||||
4
.github/workflows/azure-storybook.yml
vendored
4
.github/workflows/azure-storybook.yml
vendored
@@ -23,7 +23,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build_and_deploy_job:
|
||||
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed' && (contains(github.event.pull_request.labels.*.name, 'preview/storybook')))
|
||||
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed' && contains(github.event.pull_request.labels.*.name, 'preview/storybook') && github.repository == github.event.pull_request.head.repo.full_name)
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and Deploy Job
|
||||
steps:
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
###### End of Repository/Build Configurations ######
|
||||
|
||||
close_pull_request_job:
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview/storybook') && github.repository == github.event.pull_request.head.repo.full_name
|
||||
runs-on: ubuntu-latest
|
||||
name: Close Pull Request Job
|
||||
steps:
|
||||
|
||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -62,6 +62,7 @@
|
||||
"cwd": "${workspaceFolder}/src/Umbraco.Web.UI",
|
||||
"stopAtEntry": false,
|
||||
"requireExactSource": false,
|
||||
"postDebugTask": "kill-umbraco-web-ui",
|
||||
// Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser
|
||||
"serverReadyAction": {
|
||||
"action": "openExternally",
|
||||
@@ -96,6 +97,7 @@
|
||||
"stopAtEntry": false,
|
||||
"requireExactSource": false,
|
||||
"checkForDevCert": true,
|
||||
"postDebugTask": "kill-umbraco-web-ui",
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"ASPNETCORE_URLS": "https://localhost:44339",
|
||||
|
||||
19
.vscode/tasks.json
vendored
19
.vscode/tasks.json
vendored
@@ -6,10 +6,7 @@
|
||||
"detail": "Builds the client and SLN",
|
||||
"promptOnClose": true,
|
||||
"group": "build",
|
||||
"dependsOn": [
|
||||
"Client Build",
|
||||
"Dotnet build"
|
||||
],
|
||||
"dependsOn": ["Client Build", "Dotnet build"],
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
@@ -71,6 +68,20 @@
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "kill-umbraco-web-ui",
|
||||
"type": "shell",
|
||||
"problemMatcher": [],
|
||||
"osx": {
|
||||
"command": "pkill -f Umbraco.Web.UI"
|
||||
},
|
||||
"linux": {
|
||||
"command": "pkill -f Umbraco.Web.UI"
|
||||
},
|
||||
"windows": {
|
||||
"command": "taskkill /IM Umbraco.Web.UI.exe /F"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
<!-- Package Validation -->
|
||||
<PropertyGroup>
|
||||
<GenerateCompatibilitySuppressionFile>false</GenerateCompatibilitySuppressionFile>
|
||||
<EnablePackageValidation>false</EnablePackageValidation>
|
||||
<PackageValidationBaselineVersion>15.0.0</PackageValidationBaselineVersion>
|
||||
<EnablePackageValidation>true</EnablePackageValidation>
|
||||
<PackageValidationBaselineVersion>16.0.0</PackageValidationBaselineVersion>
|
||||
<EnableStrictModeForCompatibleFrameworksInPackage>true</EnableStrictModeForCompatibleFrameworksInPackage>
|
||||
<EnableStrictModeForCompatibleTfms>true</EnableStrictModeForCompatibleTfms>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Tree;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.DataType.Tree;
|
||||
|
||||
public class SiblingsDataTypeTreeController : DataTypeTreeControllerBase
|
||||
{
|
||||
public SiblingsDataTypeTreeController(IEntityService entityService, IDataTypeService dataTypeService)
|
||||
: base(entityService, dataTypeService)
|
||||
{
|
||||
}
|
||||
|
||||
[HttpGet("siblings")]
|
||||
[ProducesResponseType(typeof(IEnumerable<DataTypeTreeItemResponseModel>), StatusCodes.Status200OK)]
|
||||
public Task<ActionResult<IEnumerable<DataTypeTreeItemResponseModel>>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after)
|
||||
=> GetSiblings(target, before, after);
|
||||
}
|
||||
@@ -53,16 +53,6 @@ public class MoveDocumentController : DocumentControllerBase
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
ContentPermissionResource.WithKeys(ActionMove.ActionLetter, new[] { moveDocumentRequestModel.Target?.Id, id }),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
Attempt<IContent?, ContentEditingOperationStatus> result = await _contentEditingService.MoveAsync(
|
||||
id,
|
||||
moveDocumentRequestModel.Target?.Id,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
using Umbraco.Cms.Api.Management.Services.Entities;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Tree;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Document.Tree;
|
||||
|
||||
[ApiVersion("1.0")]
|
||||
public class SiblingsDocumentTreeController : DocumentTreeControllerBase
|
||||
{
|
||||
public SiblingsDocumentTreeController(
|
||||
IEntityService entityService,
|
||||
IUserStartNodeEntitiesService userStartNodeEntitiesService,
|
||||
IDataTypeService dataTypeService,
|
||||
IPublicAccessService publicAccessService,
|
||||
AppCaches appCaches,
|
||||
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
|
||||
IDocumentPresentationFactory documentPresentationFactory)
|
||||
: base(
|
||||
entityService,
|
||||
userStartNodeEntitiesService,
|
||||
dataTypeService,
|
||||
publicAccessService,
|
||||
appCaches,
|
||||
backofficeSecurityAccessor,
|
||||
documentPresentationFactory)
|
||||
{
|
||||
}
|
||||
|
||||
[HttpGet("siblings")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[ProducesResponseType(typeof(IEnumerable<DocumentTreeItemResponseModel>), StatusCodes.Status200OK)]
|
||||
public Task<ActionResult<IEnumerable<DocumentTreeItemResponseModel>>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after)
|
||||
=> GetSiblings(target, before, after);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Tree;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.DocumentBlueprint.Tree;
|
||||
|
||||
public class SiblingsDocumentBlueprintTreeController : DocumentBlueprintTreeControllerBase
|
||||
{
|
||||
public SiblingsDocumentBlueprintTreeController(IEntityService entityService, IDocumentPresentationFactory documentPresentationFactory)
|
||||
: base(entityService, documentPresentationFactory)
|
||||
{
|
||||
}
|
||||
|
||||
[HttpGet("siblings")]
|
||||
[ProducesResponseType(typeof(IEnumerable<DocumentBlueprintTreeItemResponseModel>), StatusCodes.Status200OK)]
|
||||
public Task<ActionResult<IEnumerable<DocumentBlueprintTreeItemResponseModel>>> Siblings(
|
||||
CancellationToken cancellationToken,
|
||||
Guid target,
|
||||
int before,
|
||||
int after) =>
|
||||
GetSiblings(target, before, after);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Tree;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.DocumentType.Tree;
|
||||
|
||||
public class SiblingsDocumentTypeTreeController : DocumentTypeTreeControllerBase
|
||||
{
|
||||
public SiblingsDocumentTypeTreeController(IEntityService entityService, IContentTypeService contentTypeService)
|
||||
: base(entityService, contentTypeService)
|
||||
{
|
||||
}
|
||||
|
||||
[HttpGet("siblings")]
|
||||
[ProducesResponseType(typeof(IEnumerable<DocumentTypeTreeItemResponseModel>), StatusCodes.Status200OK)]
|
||||
public Task<ActionResult<IEnumerable<DocumentTypeTreeItemResponseModel>>> Siblings(
|
||||
CancellationToken cancellationToken,
|
||||
Guid target,
|
||||
int before,
|
||||
int after) =>
|
||||
GetSiblings(target, before, after);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
using Umbraco.Cms.Api.Management.Services.Entities;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Tree;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Media.Tree;
|
||||
|
||||
public class SiblingsMediaTreeController : MediaTreeControllerBase
|
||||
{
|
||||
public SiblingsMediaTreeController(
|
||||
IEntityService entityService,
|
||||
IUserStartNodeEntitiesService userStartNodeEntitiesService,
|
||||
IDataTypeService dataTypeService,
|
||||
AppCaches appCaches,
|
||||
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
|
||||
IMediaPresentationFactory mediaPresentationFactory)
|
||||
: base(entityService, userStartNodeEntitiesService, dataTypeService, appCaches, backofficeSecurityAccessor, mediaPresentationFactory)
|
||||
{
|
||||
}
|
||||
|
||||
[HttpGet("siblings")]
|
||||
[ProducesResponseType(typeof(IEnumerable<MediaTreeItemResponseModel>), StatusCodes.Status200OK)]
|
||||
public Task<ActionResult<IEnumerable<MediaTreeItemResponseModel>>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after)
|
||||
=> GetSiblings(target, before, after);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Tree;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.MediaType.Tree;
|
||||
|
||||
public class SiblingsMediaTypeTreeController : MediaTypeTreeControllerBase
|
||||
{
|
||||
public SiblingsMediaTypeTreeController(IEntityService entityService, IMediaTypeService mediaTypeService)
|
||||
: base(entityService, mediaTypeService)
|
||||
{
|
||||
}
|
||||
|
||||
[HttpGet("siblings")]
|
||||
[ProducesResponseType(typeof(IEnumerable<MediaTypeTreeItemResponseModel>), StatusCodes.Status200OK)]
|
||||
public Task<ActionResult<IEnumerable<MediaTypeTreeItemResponseModel>>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after)
|
||||
=> GetSiblings(target, before, after);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Package;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Mapping;
|
||||
using Umbraco.Cms.Core.Packaging;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
@@ -14,12 +17,25 @@ namespace Umbraco.Cms.Api.Management.Controllers.Package;
|
||||
public class AllMigrationStatusPackageController : PackageControllerBase
|
||||
{
|
||||
private readonly IPackagingService _packagingService;
|
||||
private readonly IUmbracoMapper _umbracoMapper;
|
||||
private readonly IPackagePresentationFactory _packagePresentationFactory;
|
||||
|
||||
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in V18.")]
|
||||
public AllMigrationStatusPackageController(IPackagingService packagingService, IUmbracoMapper umbracoMapper)
|
||||
: this(packagingService, StaticServiceProvider.Instance.GetRequiredService<IPackagePresentationFactory>())
|
||||
{
|
||||
}
|
||||
|
||||
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in V18.")]
|
||||
public AllMigrationStatusPackageController(IPackagingService packagingService, IUmbracoMapper umbracoMapper, IPackagePresentationFactory packagePresentationFactory)
|
||||
: this(packagingService, packagePresentationFactory)
|
||||
{
|
||||
}
|
||||
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public AllMigrationStatusPackageController(IPackagingService packagingService, IPackagePresentationFactory packagePresentationFactory)
|
||||
{
|
||||
_packagingService = packagingService;
|
||||
_umbracoMapper = umbracoMapper;
|
||||
_packagePresentationFactory = packagePresentationFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -38,11 +54,7 @@ public class AllMigrationStatusPackageController : PackageControllerBase
|
||||
{
|
||||
PagedModel<InstalledPackage> migrationPlans = await _packagingService.GetInstalledPackagesFromMigrationPlansAsync(skip, take);
|
||||
|
||||
var viewModel = new PagedViewModel<PackageMigrationStatusResponseModel>
|
||||
{
|
||||
Total = migrationPlans.Total,
|
||||
Items = _umbracoMapper.MapEnumerable<InstalledPackage, PackageMigrationStatusResponseModel>(migrationPlans.Items)
|
||||
};
|
||||
PagedViewModel<PackageMigrationStatusResponseModel> viewModel = _packagePresentationFactory.CreatePackageMigrationStatusResponseModel(migrationPlans);
|
||||
|
||||
return Ok(viewModel);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ public class RebuildPublishedCacheController : PublishedCacheControllerBase
|
||||
[HttpPost("rebuild")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Rebuild(CancellationToken cancellationToken)
|
||||
public Task<IActionResult> Rebuild(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_databaseCacheRebuilder.IsRebuilding())
|
||||
{
|
||||
@@ -27,10 +27,10 @@ public class RebuildPublishedCacheController : PublishedCacheControllerBase
|
||||
Type = "Error",
|
||||
};
|
||||
|
||||
return await Task.FromResult(Conflict(problemDetails));
|
||||
return Task.FromResult<IActionResult>(Conflict(problemDetails));
|
||||
}
|
||||
|
||||
_databaseCacheRebuilder.Rebuild(true);
|
||||
return await Task.FromResult(Ok());
|
||||
return Task.FromResult<IActionResult>(Ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Tree;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Template.Tree;
|
||||
|
||||
public class SiblingsTemplateTreeController : TemplateTreeControllerBase
|
||||
{
|
||||
public SiblingsTemplateTreeController(IEntityService entityService)
|
||||
: base(entityService)
|
||||
{
|
||||
}
|
||||
|
||||
[HttpGet("siblings")]
|
||||
[ProducesResponseType(typeof(IEnumerable<NamedEntityTreeItemResponseModel>), StatusCodes.Status200OK)]
|
||||
public Task<ActionResult<IEnumerable<NamedEntityTreeItemResponseModel>>> Siblings(
|
||||
CancellationToken cancellationToken,
|
||||
Guid target,
|
||||
int before,
|
||||
int after) =>
|
||||
GetSiblings(target, before, after);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
|
||||
using Umbraco.Cms.Api.Management.ViewModels;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Tree;
|
||||
@@ -44,6 +44,23 @@ public abstract class EntityTreeControllerBase<TItem> : ManagementApiControllerB
|
||||
return Task.FromResult<ActionResult<PagedViewModel<TItem>>>(Ok(result));
|
||||
}
|
||||
|
||||
protected Task<ActionResult<IEnumerable<TItem>>> GetSiblings(Guid target, int before, int after)
|
||||
{
|
||||
IEntitySlim[] siblings = EntityService.GetSiblings(target, ItemObjectType, before, after, ItemOrdering).ToArray();
|
||||
if (siblings.Length == 0)
|
||||
{
|
||||
return Task.FromResult<ActionResult<IEnumerable<TItem>>>(NotFound());
|
||||
}
|
||||
|
||||
IEntitySlim? entity = siblings.FirstOrDefault();
|
||||
Guid? parentKey = entity?.ParentId > 0
|
||||
? EntityService.GetKey(entity.ParentId, ItemObjectType).Result
|
||||
: Constants.System.RootKey;
|
||||
|
||||
TItem[] treeItemsViewModels = MapTreeItemViewModels(parentKey, siblings);
|
||||
return Task.FromResult<ActionResult<IEnumerable<TItem>>>(Ok(treeItemsViewModels));
|
||||
}
|
||||
|
||||
protected virtual async Task<ActionResult<IEnumerable<TItem>>> GetAncestors(Guid descendantKey, bool includeSelf = true)
|
||||
{
|
||||
IEntitySlim[] ancestorEntities = await GetAncestorEntitiesAsync(descendantKey, includeSelf);
|
||||
|
||||
@@ -43,7 +43,7 @@ internal static class ApplicationBuilderExtensions
|
||||
|
||||
var response = new ProblemDetails
|
||||
{
|
||||
Title = exception.Message,
|
||||
Title = isDebug ? exception.Message : "Server Error",
|
||||
Detail = isDebug ? exception.StackTrace : null,
|
||||
Status = statusCode ?? StatusCodes.Status500InternalServerError,
|
||||
Instance = isDebug ? exception.GetType().Name : null,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Package;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Packaging;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Factories;
|
||||
@@ -8,4 +10,6 @@ public interface IPackagePresentationFactory
|
||||
PackageDefinition CreatePackageDefinition(CreatePackageRequestModel createPackageRequestModel);
|
||||
|
||||
PackageConfigurationResponseModel CreateConfigurationResponseModel();
|
||||
|
||||
PagedViewModel<PackageMigrationStatusResponseModel> CreatePackageMigrationStatusResponseModel(PagedModel<InstalledPackage> installedPackages) => new();
|
||||
}
|
||||
|
||||
@@ -6,25 +6,58 @@ using Umbraco.Cms.Core.Models.Membership;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Factories;
|
||||
|
||||
/// <summary>
|
||||
/// Defines factory methods for the creation of user presentation models.
|
||||
/// </summary>
|
||||
public interface IUserPresentationFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a response model for the provided user.
|
||||
/// </summary>
|
||||
UserResponseModel CreateResponseModel(IUser user);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a create model for a user based on the provided request model.
|
||||
/// </summary>
|
||||
Task<UserCreateModel> CreateCreationModelAsync(CreateUserRequestModel requestModel);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an invite model for a user based on the provided request model.
|
||||
/// </summary>
|
||||
Task<UserInviteModel> CreateInviteModelAsync(InviteUserRequestModel requestModel);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an update model for an existing user based on the provided request model.
|
||||
/// </summary>
|
||||
Task<UserUpdateModel> CreateUpdateModelAsync(Guid existingUserKey, UpdateUserRequestModel updateModel);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a response model for the current user based on the provided user.
|
||||
/// </summary>
|
||||
Task<CurrentUserResponseModel> CreateCurrentUserResponseModelAsync(IUser user);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an resend invite model for a user based on the provided request model.
|
||||
/// </summary>
|
||||
Task<UserResendInviteModel> CreateResendInviteModelAsync(ResendInviteUserRequestModel requestModel);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a user configuration model that contains the necessary data for user management operations.
|
||||
/// </summary>
|
||||
Task<UserConfigurationResponseModel> CreateUserConfigurationModelAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a current user configuration model that contains the necessary data for the current user's management operations.
|
||||
/// </summary>
|
||||
Task<CurrentUserConfigurationResponseModel> CreateCurrentUserConfigurationModelAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a user item response model for the provided user.
|
||||
/// </summary>
|
||||
UserItemResponseModel CreateItemResponseModel(IUser user);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a calculated user start nodes response model based on the provided user.
|
||||
/// </summary>
|
||||
Task<CalculatedUserStartNodesResponseModel> CreateCalculatedUserStartNodesResponseModelAsync(IUser user);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using System.Collections.Specialized;
|
||||
using System.Web;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Package;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Mapping;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Packaging;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Extensions;
|
||||
@@ -42,6 +44,25 @@ internal class PackagePresentationFactory : IPackagePresentationFactory
|
||||
MarketplaceUrl = GetMarketplaceUrl(),
|
||||
};
|
||||
|
||||
public PagedViewModel<PackageMigrationStatusResponseModel> CreatePackageMigrationStatusResponseModel(PagedModel<InstalledPackage> installedPackages)
|
||||
{
|
||||
InstalledPackage[] installedPackagesAsArray = installedPackages.Items as InstalledPackage[] ?? installedPackages.Items.ToArray();
|
||||
|
||||
return new PagedViewModel<PackageMigrationStatusResponseModel>
|
||||
{
|
||||
Total = installedPackages.Total,
|
||||
Items = installedPackagesAsArray
|
||||
.GroupBy(package => package.PackageName)
|
||||
.Select(packages => packages.OrderByDescending(package => package.HasPendingMigrations).First())
|
||||
.Select(package => new PackageMigrationStatusResponseModel
|
||||
{
|
||||
PackageName = package.PackageName ?? string.Empty,
|
||||
HasPendingMigrations = package.HasPendingMigrations,
|
||||
})
|
||||
.ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
private string GetMarketplaceUrl()
|
||||
{
|
||||
var uriBuilder = new UriBuilder(Constants.Marketplace.Url);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Api.Management.Mapping.Permissions;
|
||||
using Umbraco.Cms.Api.Management.Routing;
|
||||
using Umbraco.Cms.Api.Management.Security;
|
||||
using Umbraco.Cms.Api.Management.ViewModels;
|
||||
@@ -22,6 +23,9 @@ using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Factories;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating user presentation models, implementing <see cref="IUserPresentationFactory"/>.
|
||||
/// </summary>
|
||||
public class UserPresentationFactory : IUserPresentationFactory
|
||||
{
|
||||
private readonly IEntityService _entityService;
|
||||
@@ -34,9 +38,11 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
private readonly IPasswordConfigurationPresentationFactory _passwordConfigurationPresentationFactory;
|
||||
private readonly IBackOfficeExternalLoginProviders _externalLoginProviders;
|
||||
private readonly SecuritySettings _securitySettings;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IContentService _contentService;
|
||||
private readonly Dictionary<Type, IPermissionPresentationMapper> _permissionPresentationMappersByType;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserPresentationFactory"/> class.
|
||||
/// </summary>
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")]
|
||||
public UserPresentationFactory(
|
||||
IEntityService entityService,
|
||||
@@ -61,10 +67,15 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
securitySettings,
|
||||
externalLoginProviders,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IUserService>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<IContentService>())
|
||||
StaticServiceProvider.Instance.GetRequiredService<IContentService>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<IEnumerable<IPermissionPresentationMapper>>())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserPresentationFactory"/> class.
|
||||
/// </summary>
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")]
|
||||
public UserPresentationFactory(
|
||||
IEntityService entityService,
|
||||
AppCaches appCaches,
|
||||
@@ -78,6 +89,44 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
IBackOfficeExternalLoginProviders externalLoginProviders,
|
||||
IUserService userService,
|
||||
IContentService contentService)
|
||||
: this(
|
||||
entityService,
|
||||
appCaches,
|
||||
mediaFileManager,
|
||||
imageUrlGenerator,
|
||||
userGroupPresentationFactory,
|
||||
absoluteUrlBuilder,
|
||||
emailSender,
|
||||
passwordConfigurationPresentationFactory,
|
||||
securitySettings,
|
||||
externalLoginProviders,
|
||||
userService,
|
||||
contentService,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IEnumerable<IPermissionPresentationMapper>>())
|
||||
{
|
||||
}
|
||||
|
||||
// TODO (V17): Remove the unused userService and contentService parameters from this constructor.
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserPresentationFactory"/> class.
|
||||
/// </summary>
|
||||
public UserPresentationFactory(
|
||||
IEntityService entityService,
|
||||
AppCaches appCaches,
|
||||
MediaFileManager mediaFileManager,
|
||||
IImageUrlGenerator imageUrlGenerator,
|
||||
IUserGroupPresentationFactory userGroupPresentationFactory,
|
||||
IAbsoluteUrlBuilder absoluteUrlBuilder,
|
||||
IEmailSender emailSender,
|
||||
IPasswordConfigurationPresentationFactory passwordConfigurationPresentationFactory,
|
||||
IOptionsSnapshot<SecuritySettings> securitySettings,
|
||||
IBackOfficeExternalLoginProviders externalLoginProviders,
|
||||
#pragma warning disable IDE0060 // Remove unused parameter - need to keep these until the next major to avoid breaking changes and/or ambiguous constructor errors
|
||||
IUserService userService,
|
||||
IContentService contentService,
|
||||
#pragma warning restore IDE0060 // Remove unused parameter
|
||||
IEnumerable<IPermissionPresentationMapper> permissionPresentationMappers)
|
||||
{
|
||||
_entityService = entityService;
|
||||
_appCaches = appCaches;
|
||||
@@ -89,10 +138,10 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
_externalLoginProviders = externalLoginProviders;
|
||||
_securitySettings = securitySettings.Value;
|
||||
_absoluteUrlBuilder = absoluteUrlBuilder;
|
||||
_userService = userService;
|
||||
_contentService = contentService;
|
||||
_permissionPresentationMappersByType = permissionPresentationMappers.ToDictionary(x => x.PresentationModelToHandle);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public UserResponseModel CreateResponseModel(IUser user)
|
||||
{
|
||||
var responseModel = new UserResponseModel
|
||||
@@ -123,6 +172,7 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
return responseModel;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public UserItemResponseModel CreateItemResponseModel(IUser user) =>
|
||||
new()
|
||||
{
|
||||
@@ -130,9 +180,10 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
Name = user.Name ?? user.Username,
|
||||
AvatarUrls = user.GetUserAvatarUrls(_appCaches.RuntimeCache, _mediaFileManager, _imageUrlGenerator)
|
||||
.Select(url => _absoluteUrlBuilder.ToAbsoluteUrl(url).ToString()),
|
||||
Kind = user.Kind
|
||||
Kind = user.Kind,
|
||||
};
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UserCreateModel> CreateCreationModelAsync(CreateUserRequestModel requestModel)
|
||||
{
|
||||
var createModel = new UserCreateModel
|
||||
@@ -142,12 +193,13 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
Name = requestModel.Name,
|
||||
UserName = requestModel.UserName,
|
||||
UserGroupKeys = requestModel.UserGroupIds.Select(x => x.Id).ToHashSet(),
|
||||
Kind = requestModel.Kind
|
||||
Kind = requestModel.Kind,
|
||||
};
|
||||
|
||||
return Task.FromResult(createModel);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UserInviteModel> CreateInviteModelAsync(InviteUserRequestModel requestModel)
|
||||
{
|
||||
var inviteModel = new UserInviteModel
|
||||
@@ -162,6 +214,7 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
return Task.FromResult(inviteModel);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UserResendInviteModel> CreateResendInviteModelAsync(ResendInviteUserRequestModel requestModel)
|
||||
{
|
||||
var inviteModel = new UserResendInviteModel
|
||||
@@ -173,6 +226,7 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
return Task.FromResult(inviteModel);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<CurrentUserConfigurationResponseModel> CreateCurrentUserConfigurationModelAsync()
|
||||
{
|
||||
var model = new CurrentUserConfigurationResponseModel
|
||||
@@ -188,6 +242,7 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
return Task.FromResult(model);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UserConfigurationResponseModel> CreateUserConfigurationModelAsync() =>
|
||||
Task.FromResult(new UserConfigurationResponseModel
|
||||
{
|
||||
@@ -201,6 +256,7 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
AllowTwoFactor = _externalLoginProviders.HasDenyLocalLogin() is false,
|
||||
});
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UserUpdateModel> CreateUpdateModelAsync(Guid existingUserKey, UpdateUserRequestModel updateModel)
|
||||
{
|
||||
var model = new UserUpdateModel
|
||||
@@ -214,24 +270,24 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
HasContentRootAccess = updateModel.HasDocumentRootAccess,
|
||||
MediaStartNodeKeys = updateModel.MediaStartNodeIds.Select(x => x.Id).ToHashSet(),
|
||||
HasMediaRootAccess = updateModel.HasMediaRootAccess,
|
||||
UserGroupKeys = updateModel.UserGroupIds.Select(x => x.Id).ToHashSet()
|
||||
};
|
||||
|
||||
model.UserGroupKeys = updateModel.UserGroupIds.Select(x => x.Id).ToHashSet();
|
||||
|
||||
return Task.FromResult(model);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<CurrentUserResponseModel> CreateCurrentUserResponseModelAsync(IUser user)
|
||||
{
|
||||
var presentationUser = CreateResponseModel(user);
|
||||
var presentationGroups = await _userGroupPresentationFactory.CreateMultipleAsync(user.Groups);
|
||||
UserResponseModel presentationUser = CreateResponseModel(user);
|
||||
IEnumerable<UserGroupResponseModel> presentationGroups = await _userGroupPresentationFactory.CreateMultipleAsync(user.Groups);
|
||||
var languages = presentationGroups.SelectMany(x => x.Languages).Distinct().ToArray();
|
||||
var mediaStartNodeIds = user.CalculateMediaStartNodeIds(_entityService, _appCaches);
|
||||
var mediaStartNodeKeys = GetKeysFromIds(mediaStartNodeIds, UmbracoObjectTypes.Media);
|
||||
ISet<ReferenceByIdModel> mediaStartNodeKeys = GetKeysFromIds(mediaStartNodeIds, UmbracoObjectTypes.Media);
|
||||
var contentStartNodeIds = user.CalculateContentStartNodeIds(_entityService, _appCaches);
|
||||
var documentStartNodeKeys = GetKeysFromIds(contentStartNodeIds, UmbracoObjectTypes.Document);
|
||||
ISet<ReferenceByIdModel> documentStartNodeKeys = GetKeysFromIds(contentStartNodeIds, UmbracoObjectTypes.Document);
|
||||
|
||||
var permissions = GetAggregatedGranularPermissions(user, presentationGroups);
|
||||
HashSet<IPermissionPresentationModel> permissions = GetAggregatedGranularPermissions(user, presentationGroups);
|
||||
var fallbackPermissions = presentationGroups.SelectMany(x => x.FallbackPermissions).ToHashSet();
|
||||
|
||||
var hasAccessToAllLanguages = presentationGroups.Any(x => x.HasAccessToAllLanguages);
|
||||
@@ -263,70 +319,56 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
|
||||
private HashSet<IPermissionPresentationModel> GetAggregatedGranularPermissions(IUser user, IEnumerable<UserGroupResponseModel> presentationGroups)
|
||||
{
|
||||
var aggregatedPermissions = new HashSet<IPermissionPresentationModel>();
|
||||
|
||||
var permissions = presentationGroups.SelectMany(x => x.Permissions).ToHashSet();
|
||||
return GetAggregatedGranularPermissions(user, permissions);
|
||||
}
|
||||
|
||||
AggregateAndAddDocumentPermissions(user, aggregatedPermissions, permissions);
|
||||
private HashSet<IPermissionPresentationModel> GetAggregatedGranularPermissions(IUser user, HashSet<IPermissionPresentationModel> permissions)
|
||||
{
|
||||
// The raw permission data consists of several permissions for each entity (e.g. document), as permissions are assigned to user groups
|
||||
// and a user may be part of multiple groups. We want to aggregate this server-side so we return one set of aggregate permissions per
|
||||
// entity that the client will use.
|
||||
// We need to handle here not just permissions known to core (e.g. document and document property value permissions), but also custom
|
||||
// permissions defined by packages or implemetors.
|
||||
IEnumerable<(Type, IEnumerable<IPermissionPresentationModel>)> permissionModelsByType = permissions
|
||||
.GroupBy(x => x.GetType())
|
||||
.Select(x => (x.Key, x.Select(y => y)));
|
||||
|
||||
AggregateAndAddDocumentPropertyValuePermissions(aggregatedPermissions, permissions);
|
||||
var aggregatedPermissions = new HashSet<IPermissionPresentationModel>();
|
||||
foreach ((Type Type, IEnumerable<IPermissionPresentationModel> Models) permissionModelByType in permissionModelsByType)
|
||||
{
|
||||
if (_permissionPresentationMappersByType.TryGetValue(permissionModelByType.Type, out IPermissionPresentationMapper? mapper))
|
||||
{
|
||||
|
||||
IEnumerable<IPermissionPresentationModel> aggregatedModels = mapper.AggregatePresentationModels(user, permissionModelByType.Models);
|
||||
foreach (IPermissionPresentationModel aggregatedModel in aggregatedModels)
|
||||
{
|
||||
aggregatedPermissions.Add(aggregatedModel);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
IEnumerable<(string Context, ISet<string> Verbs)> groupedModels = permissionModelByType.Models
|
||||
.Where(x => x is UnknownTypePermissionPresentationModel)
|
||||
.Cast<UnknownTypePermissionPresentationModel>()
|
||||
.GroupBy(x => x.Context)
|
||||
.Select(x => (x.Key, (ISet<string>)x.SelectMany(y => y.Verbs).Distinct().ToHashSet()));
|
||||
|
||||
foreach ((string context, ISet<string> verbs) in groupedModels)
|
||||
{
|
||||
aggregatedPermissions.Add(new UnknownTypePermissionPresentationModel
|
||||
{
|
||||
Context = context,
|
||||
Verbs = verbs
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return aggregatedPermissions;
|
||||
}
|
||||
|
||||
private void AggregateAndAddDocumentPermissions(IUser user, HashSet<IPermissionPresentationModel> aggregatedPermissions, HashSet<IPermissionPresentationModel> permissions)
|
||||
{
|
||||
// The raw permission data consists of several permissions for each document. We want to aggregate this server-side so
|
||||
// we return one set of aggregate permissions per document that the client will use.
|
||||
|
||||
// Get the unique document keys that have granular permissions.
|
||||
IEnumerable<Guid> documentKeysWithGranularPermissions = permissions
|
||||
.Where(x => x is DocumentPermissionPresentationModel)
|
||||
.Cast<DocumentPermissionPresentationModel>()
|
||||
.Select(x => x.Document.Id)
|
||||
.Distinct();
|
||||
|
||||
foreach (Guid documentKey in documentKeysWithGranularPermissions)
|
||||
{
|
||||
// Retrieve the path of the document.
|
||||
var path = _contentService.GetById(documentKey)?.Path;
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// With the path we can call the same logic as used server-side for authorizing access to resources.
|
||||
EntityPermissionSet permissionsForPath = _userService.GetPermissionsForPath(user, path);
|
||||
aggregatedPermissions.Add(new DocumentPermissionPresentationModel
|
||||
{
|
||||
Document = new ReferenceByIdModel(documentKey),
|
||||
Verbs = permissionsForPath.GetAllPermissions()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void AggregateAndAddDocumentPropertyValuePermissions(HashSet<IPermissionPresentationModel> aggregatedPermissions, HashSet<IPermissionPresentationModel> permissions)
|
||||
{
|
||||
// We also have permissions for document type/property type combinations.
|
||||
// These don't have an ancestor relationship that we need to take into account, but should be aggregated
|
||||
// and included in the set.
|
||||
IEnumerable<((Guid DocumentTypeId, Guid PropertyTypeId) Key, ISet<string> Verbs)> documentTypePropertyTypeKeysWithGranularPermissions = permissions
|
||||
.Where(x => x is DocumentPropertyValuePermissionPresentationModel)
|
||||
.Cast<DocumentPropertyValuePermissionPresentationModel>()
|
||||
.GroupBy(x => (x.DocumentType.Id, x.PropertyType.Id))
|
||||
.Select(x => (x.Key, (ISet<string>)x.SelectMany(y => y.Verbs).Distinct().ToHashSet()));
|
||||
|
||||
foreach (((Guid DocumentTypeId, Guid PropertyTypeId) Key, ISet<string> Verbs) documentTypePropertyTypeKey in documentTypePropertyTypeKeysWithGranularPermissions)
|
||||
{
|
||||
aggregatedPermissions.Add(new DocumentPropertyValuePermissionPresentationModel
|
||||
{
|
||||
DocumentType = new ReferenceByIdModel(documentTypePropertyTypeKey.Key.DocumentTypeId),
|
||||
PropertyType = new ReferenceByIdModel(documentTypePropertyTypeKey.Key.PropertyTypeId),
|
||||
Verbs = documentTypePropertyTypeKey.Verbs
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<CalculatedUserStartNodesResponseModel> CreateCalculatedUserStartNodesResponseModelAsync(IUser user)
|
||||
{
|
||||
var mediaStartNodeIds = user.CalculateMediaStartNodeIds(_entityService, _appCaches);
|
||||
@@ -357,6 +399,6 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
: new HashSet<ReferenceByIdModel>(models);
|
||||
}
|
||||
|
||||
private bool HasRootAccess(IEnumerable<int>? startNodeIds)
|
||||
private static bool HasRootAccess(IEnumerable<int>? startNodeIds)
|
||||
=> startNodeIds?.Contains(Constants.System.Root) is true;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ public class PackageViewModelMapDefinition : IMapDefinition
|
||||
}
|
||||
|
||||
// Umbraco.Code.MapAll
|
||||
[Obsolete("Please use the IPackagePresentationFactory instead. Scheduled for removal in V18.")]
|
||||
private static void Map(InstalledPackage source, PackageMigrationStatusResponseModel target, MapperContext context)
|
||||
{
|
||||
if (source.PackageName is not null)
|
||||
|
||||
@@ -1,19 +1,50 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Management.ViewModels;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.UserGroup.Permissions;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Models.Membership.Permissions;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
|
||||
using Umbraco.Cms.Infrastructure.Persistence.Mappers;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Mapping.Permissions;
|
||||
|
||||
/// <summary>
|
||||
/// Mapping required for mapping all the way from viewmodel to database and back.
|
||||
/// Implements <see cref="IPermissionPresentationMapper" /> for document permissions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This mapping maps all the way from management api to database in one file intentionally, so it is very clear what it takes, if we wanna add permissions to media or other types in the future.
|
||||
/// </remarks>
|
||||
public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissionMapper
|
||||
{
|
||||
private readonly Lazy<IEntityService> _entityService;
|
||||
private readonly Lazy<IUserService> _userService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DocumentPermissionMapper"/> class.
|
||||
/// </summary>
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")]
|
||||
public DocumentPermissionMapper()
|
||||
: this(
|
||||
StaticServiceProvider.Instance.GetRequiredService<Lazy<IEntityService>>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<Lazy<IUserService>>())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DocumentPermissionMapper"/> class.
|
||||
/// </summary>
|
||||
public DocumentPermissionMapper(Lazy<IEntityService> entityService, Lazy<IUserService> userService)
|
||||
{
|
||||
_entityService = entityService;
|
||||
_userService = userService;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Context => DocumentGranularPermission.ContextType;
|
||||
|
||||
public IGranularPermission MapFromDto(UserGroup2GranularPermissionDto dto) =>
|
||||
new DocumentGranularPermission()
|
||||
{
|
||||
@@ -21,8 +52,10 @@ public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissi
|
||||
Permission = dto.Permission,
|
||||
};
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Type PresentationModelToHandle => typeof(DocumentPermissionPresentationModel);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<IPermissionPresentationModel> MapManyAsync(IEnumerable<IGranularPermission> granularPermissions)
|
||||
{
|
||||
IEnumerable<IGrouping<Guid?, IGranularPermission>> keyGroups = granularPermissions.GroupBy(x => x.Key);
|
||||
@@ -40,6 +73,7 @@ public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissi
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<IGranularPermission> MapToGranularPermissions(IPermissionPresentationModel permissionViewModel)
|
||||
{
|
||||
if (permissionViewModel is not DocumentPermissionPresentationModel documentPermissionPresentationModel)
|
||||
@@ -56,12 +90,14 @@ public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissi
|
||||
};
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var verb in documentPermissionPresentationModel.Verbs)
|
||||
{
|
||||
if (string.IsNullOrEmpty(verb))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return new DocumentGranularPermission
|
||||
{
|
||||
Key = documentPermissionPresentationModel.Document.Id,
|
||||
@@ -69,4 +105,37 @@ public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissi
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<IPermissionPresentationModel> AggregatePresentationModels(IUser user, IEnumerable<IPermissionPresentationModel> models)
|
||||
{
|
||||
// Get the unique document keys that have granular permissions.
|
||||
Guid[] documentKeysWithGranularPermissions = models
|
||||
.Cast<DocumentPermissionPresentationModel>()
|
||||
.Select(x => x.Document.Id)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
// Batch retrieve all documents by their keys.
|
||||
var documents = _entityService.Value.GetAll<IContent>(documentKeysWithGranularPermissions)
|
||||
.ToDictionary(doc => doc.Key, doc => doc.Path);
|
||||
|
||||
// Iterate through each document key that has granular permissions.
|
||||
foreach (Guid documentKey in documentKeysWithGranularPermissions)
|
||||
{
|
||||
// Retrieve the path from the pre-fetched documents.
|
||||
if (!documents.TryGetValue(documentKey, out var path) || string.IsNullOrEmpty(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// With the path we can call the same logic as used server-side for authorizing access to resources.
|
||||
EntityPermissionSet permissionsForPath = _userService.Value.GetPermissionsForPath(user, path);
|
||||
yield return new DocumentPermissionPresentationModel
|
||||
{
|
||||
Document = new ReferenceByIdModel(documentKey),
|
||||
Verbs = permissionsForPath.GetAllPermissions(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Umbraco.Cms.Api.Management.ViewModels;
|
||||
using Umbraco.Cms.Api.Management.ViewModels;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.UserGroup.Permissions;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Models.Membership.Permissions;
|
||||
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
|
||||
using Umbraco.Cms.Infrastructure.Persistence.Mappers;
|
||||
@@ -7,10 +8,18 @@ using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Mapping.Permissions;
|
||||
|
||||
/// <summary>
|
||||
/// Implements <see cref="IPermissionPresentationMapper" /> for document property value permissions.
|
||||
/// </summary>
|
||||
public class DocumentPropertyValuePermissionMapper : IPermissionPresentationMapper, IPermissionMapper
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public string Context => DocumentPropertyValueGranularPermission.ContextType;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Type PresentationModelToHandle => typeof(DocumentPropertyValuePermissionPresentationModel);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IGranularPermission MapFromDto(UserGroup2GranularPermissionDto dto) =>
|
||||
new DocumentPropertyValueGranularPermission()
|
||||
{
|
||||
@@ -18,8 +27,7 @@ public class DocumentPropertyValuePermissionMapper : IPermissionPresentationMapp
|
||||
Permission = dto.Permission,
|
||||
};
|
||||
|
||||
public Type PresentationModelToHandle => typeof(DocumentPropertyValuePermissionPresentationModel);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<IPermissionPresentationModel> MapManyAsync(IEnumerable<IGranularPermission> granularPermissions)
|
||||
{
|
||||
var intermediate = granularPermissions.Where(p => p.Key.HasValue).Select(p =>
|
||||
@@ -50,6 +58,7 @@ public class DocumentPropertyValuePermissionMapper : IPermissionPresentationMapp
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<IGranularPermission> MapToGranularPermissions(IPermissionPresentationModel permissionViewModel)
|
||||
{
|
||||
if (permissionViewModel is not DocumentPropertyValuePermissionPresentationModel documentTypePermissionPresentationModel)
|
||||
@@ -66,4 +75,23 @@ public class DocumentPropertyValuePermissionMapper : IPermissionPresentationMapp
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<IPermissionPresentationModel> AggregatePresentationModels(IUser user, IEnumerable<IPermissionPresentationModel> models)
|
||||
{
|
||||
IEnumerable<((Guid DocumentTypeId, Guid PropertyTypeId) Key, ISet<string> Verbs)> groupedModels = models
|
||||
.Cast<DocumentPropertyValuePermissionPresentationModel>()
|
||||
.GroupBy(x => (x.DocumentType.Id, x.PropertyType.Id))
|
||||
.Select(x => (x.Key, (ISet<string>)x.SelectMany(y => y.Verbs).Distinct().ToHashSet()));
|
||||
|
||||
foreach (((Guid DocumentTypeId, Guid PropertyTypeId) key, ISet<string> verbs) in groupedModels)
|
||||
{
|
||||
yield return new DocumentPropertyValuePermissionPresentationModel
|
||||
{
|
||||
DocumentType = new ReferenceByIdModel(key.DocumentTypeId),
|
||||
PropertyType = new ReferenceByIdModel(key.PropertyTypeId),
|
||||
Verbs = verbs
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,36 @@
|
||||
using Umbraco.Cms.Api.Management.ViewModels.UserGroup.Permissions;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Models.Membership.Permissions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Mapping.Permissions;
|
||||
|
||||
/// <summary>
|
||||
/// Defines methods for mapping and aggregating granular permissions to presentation models.
|
||||
/// </summary>
|
||||
public interface IPermissionPresentationMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the context type for the permissions being handled by this mapper.
|
||||
/// </summary>
|
||||
string Context { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type of the presentation model that this mapper handles.
|
||||
/// </summary>
|
||||
Type PresentationModelToHandle { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Maps a granular permission entity to a granular permission model.
|
||||
/// </summary>
|
||||
IEnumerable<IPermissionPresentationModel> MapManyAsync(IEnumerable<IGranularPermission> granularPermissions);
|
||||
|
||||
/// <summary>
|
||||
/// Maps a granular permission to a granular permission model.
|
||||
/// </summary>
|
||||
IEnumerable<IGranularPermission> MapToGranularPermissions(IPermissionPresentationModel permissionViewModel);
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates multiple permission presentation models into a collection containing only one item per entity with aggregated permissions.
|
||||
/// </summary>
|
||||
IEnumerable<IPermissionPresentationModel> AggregatePresentationModels(IUser user, IEnumerable<IPermissionPresentationModel> models) => [];
|
||||
}
|
||||
|
||||
11
src/Umbraco.Core/CompatibilitySuppressions.xml
Normal file
11
src/Umbraco.Core/CompatibilitySuppressions.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
|
||||
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Umbraco.Cms.Core.Configuration.Models.ContentSettings.get_Error404Collection</Target>
|
||||
<Left>lib/net9.0/Umbraco.Core.dll</Left>
|
||||
<Right>lib/net9.0/Umbraco.Core.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
</Suppressions>
|
||||
@@ -25,8 +25,8 @@ public class TypeFinderConfig : ITypeFinderConfig
|
||||
|
||||
var s = _settings.AssembliesAcceptingLoadExceptions;
|
||||
return _assembliesAcceptingLoadExceptions = string.IsNullOrWhiteSpace(s)
|
||||
? Array.Empty<string>()
|
||||
: s.Split(',').Select(x => x.Trim()).ToArray();
|
||||
? []
|
||||
: s.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ public class ContentSettings
|
||||
/// <summary>
|
||||
/// Gets or sets a value for the collection of error pages.
|
||||
/// </summary>
|
||||
public ISet<ContentErrorPage> Error404Collection { get; set; } = new HashSet<ContentErrorPage>();
|
||||
public IEnumerable<ContentErrorPage> Error404Collection { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for the preview badge mark-up.
|
||||
|
||||
@@ -4,10 +4,23 @@
|
||||
namespace Umbraco.Cms.Core.Configuration.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The serializer type that nucache uses to persist documents in the database.
|
||||
/// The serializer type that the published content cache uses to persist documents in the database.
|
||||
/// </summary>
|
||||
public enum NuCacheSerializerType
|
||||
{
|
||||
MessagePack = 1, // Default
|
||||
/// <summary>
|
||||
/// The default serializer type, which uses MessagePack for serialization.
|
||||
/// </summary>
|
||||
MessagePack = 1,
|
||||
|
||||
/// <summary>
|
||||
/// The legacy JSON serializer type, which uses JSON for serialization.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This option was provided for backward compatibility for the Umbraco cache implementation used from Umbraco 8 to 14 (NuCache).
|
||||
/// It is no longer supported with the cache implementation from Umbraco 15 based on .NET's Hybrid cache.
|
||||
/// Use the faster and more compact <see cref="MessagePack"/> instead.
|
||||
/// The option is kept available only for a more readable format suitable for testing purposes.
|
||||
/// </remarks>
|
||||
JSON = 2,
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ using System.Globalization;
|
||||
|
||||
namespace Umbraco.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides Extensions for <see cref="DateTime"/>.
|
||||
/// </summary>
|
||||
public static class DateTimeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
@@ -18,6 +21,7 @@ public static class DateTimeExtensions
|
||||
Hour,
|
||||
Minute,
|
||||
Second,
|
||||
Millisecond,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -35,32 +39,15 @@ public static class DateTimeExtensions
|
||||
/// <param name="truncateTo">The level to truncate the date to.</param>
|
||||
/// <returns>The truncated date.</returns>
|
||||
public static DateTime TruncateTo(this DateTime dt, DateTruncate truncateTo)
|
||||
=> truncateTo switch
|
||||
{
|
||||
if (truncateTo == DateTruncate.Year)
|
||||
{
|
||||
return new DateTime(dt.Year, 1, 1, 0, 0, 0, dt.Kind);
|
||||
}
|
||||
|
||||
if (truncateTo == DateTruncate.Month)
|
||||
{
|
||||
return new DateTime(dt.Year, dt.Month, 1, 0, 0, 0, dt.Kind);
|
||||
}
|
||||
|
||||
if (truncateTo == DateTruncate.Day)
|
||||
{
|
||||
return new DateTime(dt.Year, dt.Month, dt.Day, 0, 0, 0, dt.Kind);
|
||||
}
|
||||
|
||||
if (truncateTo == DateTruncate.Hour)
|
||||
{
|
||||
return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, 0, 0, dt.Kind);
|
||||
}
|
||||
|
||||
if (truncateTo == DateTruncate.Minute)
|
||||
{
|
||||
return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, 0, dt.Kind);
|
||||
}
|
||||
|
||||
return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.Kind);
|
||||
}
|
||||
DateTruncate.Year => new DateTime(dt.Year, 1, 1, 0, 0, 0, dt.Kind),
|
||||
DateTruncate.Month => new DateTime(dt.Year, dt.Month, 1, 0, 0, 0, dt.Kind),
|
||||
DateTruncate.Day => new DateTime(dt.Year, dt.Month, dt.Day, 0, 0, 0, dt.Kind),
|
||||
DateTruncate.Hour => new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, 0, 0, dt.Kind),
|
||||
DateTruncate.Minute => new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, 0, dt.Kind),
|
||||
DateTruncate.Second => new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.Kind),
|
||||
DateTruncate.Millisecond => new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.Millisecond, dt.Kind),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(truncateTo), truncateTo, "Invalid truncation level"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -509,8 +509,7 @@ public static class StringExtensions
|
||||
var convertToHex = input.ConvertToHex();
|
||||
var hexLength = convertToHex.Length < 32 ? convertToHex.Length : 32;
|
||||
var hex = convertToHex[..hexLength].PadLeft(32, '0');
|
||||
Guid output = Guid.Empty;
|
||||
return Guid.TryParse(hex, out output) ? output : Guid.Empty;
|
||||
return Guid.TryParse(hex, out Guid output) ? output : Guid.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -36,7 +36,6 @@ public class UserSettingsFactory : IUserSettingsFactory
|
||||
|
||||
private IEnumerable<ConsentLevelModel> CreateConsentLevelModels() =>
|
||||
Enum.GetValues<TelemetryLevel>()
|
||||
.ToList()
|
||||
.Select(level => new ConsentLevelModel
|
||||
{
|
||||
Level = level,
|
||||
|
||||
@@ -58,8 +58,7 @@ public class LoggingConfiguration : ILoggingConfiguration
|
||||
public string LogFileNameFormat { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string[] GetLogFileNameFormatArguments() => _logFileNameFormatArguments.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(x => x.Trim())
|
||||
public string[] GetLogFileNameFormatArguments() => _logFileNameFormatArguments.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(GetValue)
|
||||
.ToArray();
|
||||
|
||||
|
||||
@@ -71,12 +71,12 @@ public abstract class BlockEditorDataConverter<TValue, TLayout>
|
||||
|
||||
// this method is only meant to have any effect when migrating block editor values
|
||||
// from the original format to the new, variant enabled format
|
||||
private void AmendExpose(TValue value)
|
||||
=> value.Expose = value.ContentData.Select(cd => new BlockItemVariation(cd.Key, null, null)).ToList();
|
||||
private static void AmendExpose(TValue value)
|
||||
=> value.Expose = value.ContentData.ConvertAll(cd => new BlockItemVariation(cd.Key, null, null));
|
||||
|
||||
// this method is only meant to have any effect when migrating block editor values
|
||||
// from the original format to the new, variant enabled format
|
||||
private bool ConvertOriginalBlockFormat(List<BlockItemData> blockItemDatas)
|
||||
private static bool ConvertOriginalBlockFormat(List<BlockItemData> blockItemDatas)
|
||||
{
|
||||
var converted = false;
|
||||
foreach (BlockItemData blockItemData in blockItemDatas)
|
||||
|
||||
@@ -5,6 +5,7 @@ using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
|
||||
namespace Umbraco.Cms.Core.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// A notification that is used to trigger the IContentService when the SavedBlueprint method is called in the API.
|
||||
/// </summary>
|
||||
@@ -14,8 +15,21 @@ public sealed class ContentSavedBlueprintNotification : ObjectNotification<ICont
|
||||
: base(target, messages)
|
||||
{
|
||||
}
|
||||
|
||||
public ContentSavedBlueprintNotification(IContent target, IContent? createdFromContent, EventMessages messages)
|
||||
: base(target, messages)
|
||||
{
|
||||
CreatedFromContent = createdFromContent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Getting the saved blueprint <see cref="IContent"/> object.
|
||||
/// </summary>
|
||||
public IContent SavedBlueprint => Target;
|
||||
|
||||
/// <summary>
|
||||
/// Getting the saved blueprint <see cref="IContent"/> object.
|
||||
/// </summary>
|
||||
public IContent? CreatedFromContent { get; }
|
||||
|
||||
}
|
||||
|
||||
@@ -19,6 +19,17 @@ public interface IEntityRepository : IRepository
|
||||
|
||||
IEnumerable<IEntitySlim> GetAll(Guid objectType, params Guid[] keys);
|
||||
|
||||
/// <summary>
|
||||
/// Gets sibling entities of a specified target entity, within a given range before and after the target, ordered as specified.
|
||||
/// </summary>
|
||||
/// <param name="objectType">The object type key of the entities.</param>
|
||||
/// <param name="targetKey">The key of the target entity whose siblings are to be retrieved.</param>
|
||||
/// <param name="before">The number of siblings to retrieve before the target entity.</param>
|
||||
/// <param name="after">The number of siblings to retrieve after the target entity.</param>
|
||||
/// <param name="ordering">The ordering to apply to the siblings.</param>
|
||||
/// <returns>Enumerable of sibling entities.</returns>
|
||||
IEnumerable<IEntitySlim> GetSiblings(Guid objectType, Guid targetKey, int before, int after, Ordering ordering) => [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets entities for a query
|
||||
/// </summary>
|
||||
|
||||
@@ -115,7 +115,7 @@ public class MultiNodeTreePickerValueConverter : PropertyValueConverterBase, IDe
|
||||
udi,
|
||||
ref objectType,
|
||||
UmbracoObjectTypes.Document,
|
||||
id => _contentCache.GetById(guidUdi.Guid));
|
||||
id => _contentCache.GetById(preview, guidUdi.Guid));
|
||||
break;
|
||||
case Constants.UdiEntityType.Media:
|
||||
multiNodeTreePickerItem = GetPublishedContent(
|
||||
@@ -205,7 +205,7 @@ public class MultiNodeTreePickerValueConverter : PropertyValueConverterBase, IDe
|
||||
{
|
||||
Constants.UdiEntityType.Document => entityTypeUdis.Select(udi =>
|
||||
{
|
||||
IPublishedContent? content = _contentCache.GetById(udi.Guid);
|
||||
IPublishedContent? content = _contentCache.GetById(preview, udi.Guid);
|
||||
return content != null
|
||||
? _apiContentBuilder.Build(content)
|
||||
: null;
|
||||
|
||||
@@ -256,14 +256,14 @@ namespace Umbraco.Cms.Core.Routing
|
||||
// if a culture is specified, then try to get domains for that culture
|
||||
// (else cultureDomains will be null)
|
||||
// do NOT specify a default culture, else it would pick those domains
|
||||
IReadOnlyCollection<DomainAndUri>? cultureDomains = SelectByCulture(domainsAndUris, culture, defaultCulture: null);
|
||||
IReadOnlyList<DomainAndUri>? cultureDomains = SelectByCulture(domainsAndUris, culture, defaultCulture: null);
|
||||
IReadOnlyCollection<DomainAndUri> considerForBaseDomains = domainsAndUris;
|
||||
if (cultureDomains != null)
|
||||
{
|
||||
if (cultureDomains.Count == 1)
|
||||
{
|
||||
// only 1, return
|
||||
return cultureDomains.First();
|
||||
return cultureDomains[0];
|
||||
}
|
||||
|
||||
// else restrict to those domains, for base lookup
|
||||
@@ -272,11 +272,11 @@ namespace Umbraco.Cms.Core.Routing
|
||||
|
||||
// look for domains that would be the base of the uri
|
||||
// we need to order so example.com/foo matches before example.com/
|
||||
IReadOnlyCollection<DomainAndUri> baseDomains = SelectByBase(considerForBaseDomains.OrderByDescending(d => d.Uri.ToString()).ToList(), uri, culture);
|
||||
List<DomainAndUri> baseDomains = SelectByBase(considerForBaseDomains.OrderByDescending(d => d.Uri.ToString()).ToArray(), uri, culture);
|
||||
if (baseDomains.Count > 0)
|
||||
{
|
||||
// found, return
|
||||
return baseDomains.First();
|
||||
return baseDomains[0];
|
||||
}
|
||||
|
||||
// if nothing works, then try to run the filter to select a domain
|
||||
@@ -296,7 +296,7 @@ namespace Umbraco.Cms.Core.Routing
|
||||
private static bool MatchesCulture(DomainAndUri domain, string? culture)
|
||||
=> culture == null || domain.Culture.InvariantEquals(culture);
|
||||
|
||||
private static IReadOnlyCollection<DomainAndUri> SelectByBase(IReadOnlyCollection<DomainAndUri> domainsAndUris, Uri uri, string? culture)
|
||||
private static List<DomainAndUri> SelectByBase(DomainAndUri[] domainsAndUris, Uri uri, string? culture)
|
||||
{
|
||||
// look for domains that would be the base of the uri
|
||||
// ie current is www.example.com/foo/bar, look for domain www.example.com
|
||||
@@ -314,7 +314,7 @@ namespace Umbraco.Cms.Core.Routing
|
||||
return baseDomains;
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<DomainAndUri>? SelectByCulture(IReadOnlyCollection<DomainAndUri> domainsAndUris, string? culture, string? defaultCulture)
|
||||
private static List<DomainAndUri>? SelectByCulture(DomainAndUri[] domainsAndUris, string? culture, string? defaultCulture)
|
||||
{
|
||||
// we try our best to match cultures, but may end with a bogus domain
|
||||
if (culture is not null)
|
||||
@@ -434,13 +434,18 @@ namespace Umbraco.Cms.Core.Routing
|
||||
|
||||
private static Domain? FindDomainInPath(IEnumerable<Domain>? domains, string path, int? rootNodeId, bool isWildcard)
|
||||
{
|
||||
if (domains is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var stopNodeId = rootNodeId ?? -1;
|
||||
|
||||
return path.Split(Constants.CharArrays.Comma)
|
||||
.Reverse()
|
||||
.Select(s => int.Parse(s, CultureInfo.InvariantCulture))
|
||||
.TakeWhile(id => id != stopNodeId)
|
||||
.Select(id => domains?.FirstOrDefault(d => d.ContentId == id && d.IsWildcard == isWildcard))
|
||||
.Select(id => domains.FirstOrDefault(d => d.ContentId == id && d.IsWildcard == isWildcard))
|
||||
.FirstOrDefault(domain => domain is not null);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,11 +45,13 @@ internal sealed class ContentBlueprintEditingService
|
||||
return Task.FromResult<IContent?>(null);
|
||||
}
|
||||
|
||||
IContent scaffold = blueprint.DeepCloneWithResetIdentities();
|
||||
|
||||
using ICoreScope scope = CoreScopeProvider.CreateCoreScope();
|
||||
scope.Notifications.Publish(new ContentScaffoldedNotification(blueprint, blueprint, Constants.System.Root, new EventMessages()));
|
||||
scope.Notifications.Publish(new ContentScaffoldedNotification(blueprint, scaffold, Constants.System.Root, new EventMessages()));
|
||||
scope.Complete();
|
||||
|
||||
return Task.FromResult<IContent?>(blueprint);
|
||||
return Task.FromResult<IContent?>(scaffold);
|
||||
}
|
||||
|
||||
public async Task<Attempt<PagedModel<IContent>?, ContentEditingOperationStatus>> GetPagedByContentTypeAsync(Guid contentTypeKey, int skip, int take)
|
||||
@@ -112,7 +114,7 @@ internal sealed class ContentBlueprintEditingService
|
||||
|
||||
// Create Blueprint
|
||||
var currentUserId = await GetUserIdAsync(userKey);
|
||||
IContent blueprint = ContentService.CreateContentFromBlueprint(content, name, currentUserId);
|
||||
IContent blueprint = ContentService.CreateBlueprintFromContent(content, name, currentUserId);
|
||||
|
||||
if (key.HasValue)
|
||||
{
|
||||
@@ -120,7 +122,7 @@ internal sealed class ContentBlueprintEditingService
|
||||
}
|
||||
|
||||
// Save blueprint
|
||||
await SaveAsync(blueprint, userKey);
|
||||
await SaveAsync(blueprint, userKey, content);
|
||||
|
||||
return Attempt.SucceedWithStatus(ContentEditingOperationStatus.Success, new ContentCreateResult { Content = blueprint });
|
||||
}
|
||||
@@ -238,10 +240,10 @@ internal sealed class ContentBlueprintEditingService
|
||||
|
||||
protected override OperationResult? Delete(IContent content, int userId) => throw new NotImplementedException();
|
||||
|
||||
private async Task SaveAsync(IContent blueprint, Guid userKey)
|
||||
private async Task SaveAsync(IContent blueprint, Guid userKey, IContent? createdFromContent = null)
|
||||
{
|
||||
var currentUserId = await GetUserIdAsync(userKey);
|
||||
ContentService.SaveBlueprint(blueprint, currentUserId);
|
||||
ContentService.SaveBlueprint(blueprint, createdFromContent, currentUserId);
|
||||
}
|
||||
|
||||
private bool ValidateUniqueName(string name, IContent content)
|
||||
|
||||
@@ -3611,6 +3611,9 @@ public class ContentService : RepositoryService, IContentService
|
||||
}
|
||||
|
||||
public void SaveBlueprint(IContent content, int userId = Constants.Security.SuperUserId)
|
||||
=> SaveBlueprint(content, null, userId);
|
||||
|
||||
public void SaveBlueprint(IContent content, IContent? createdFromContent, int userId = Constants.Security.SuperUserId)
|
||||
{
|
||||
EventMessages evtMsgs = EventMessagesFactory.Get();
|
||||
|
||||
@@ -3631,7 +3634,7 @@ public class ContentService : RepositoryService, IContentService
|
||||
|
||||
Audit(AuditType.Save, userId, content.Id, $"Saved content template: {content.Name}");
|
||||
|
||||
scope.Notifications.Publish(new ContentSavedBlueprintNotification(content, evtMsgs));
|
||||
scope.Notifications.Publish(new ContentSavedBlueprintNotification(content, createdFromContent, evtMsgs));
|
||||
scope.Notifications.Publish(new ContentTreeChangeNotification(content, TreeChangeTypes.RefreshNode, evtMsgs));
|
||||
|
||||
scope.Complete();
|
||||
@@ -3654,12 +3657,12 @@ public class ContentService : RepositoryService, IContentService
|
||||
|
||||
private static readonly string?[] ArrayOfOneNullString = { null };
|
||||
|
||||
public IContent CreateContentFromBlueprint(IContent blueprint, string name, int userId = Constants.Security.SuperUserId)
|
||||
public IContent CreateBlueprintFromContent(
|
||||
IContent blueprint,
|
||||
string name,
|
||||
int userId = Constants.Security.SuperUserId)
|
||||
{
|
||||
if (blueprint == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(blueprint));
|
||||
}
|
||||
ArgumentNullException.ThrowIfNull(blueprint);
|
||||
|
||||
IContentType contentType = GetContentType(blueprint.ContentType.Alias);
|
||||
var content = new Content(name, -1, contentType);
|
||||
@@ -3672,8 +3675,7 @@ public class ContentService : RepositoryService, IContentService
|
||||
if (blueprint.CultureInfos?.Count > 0)
|
||||
{
|
||||
cultures = blueprint.CultureInfos.Values.Select(x => x.Culture);
|
||||
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
if (blueprint.CultureInfos.TryGetValue(_languageRepository.GetDefaultIsoCode(), out ContentCultureInfos defaultCulture))
|
||||
{
|
||||
defaultCulture.Name = name;
|
||||
@@ -3681,7 +3683,6 @@ public class ContentService : RepositoryService, IContentService
|
||||
|
||||
scope.Complete();
|
||||
}
|
||||
}
|
||||
|
||||
DateTime now = DateTime.Now;
|
||||
foreach (var culture in cultures)
|
||||
@@ -3701,6 +3702,11 @@ public class ContentService : RepositoryService, IContentService
|
||||
return content;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
[Obsolete("Use IContentBlueprintEditingService.GetScaffoldedAsync() instead. Scheduled for removal in V18.")]
|
||||
public IContent CreateContentFromBlueprint(IContent blueprint, string name, int userId = Constants.Security.SuperUserId)
|
||||
=> CreateBlueprintFromContent(blueprint, name, userId);
|
||||
|
||||
public IEnumerable<IContent> GetBlueprintsForContentTypes(params int[] contentTypeId)
|
||||
{
|
||||
using (ScopeProvider.CreateCoreScope(autoComplete: true))
|
||||
|
||||
@@ -50,7 +50,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
|
||||
|
||||
var currentCompositionAliases = currentCompositeKeys.Any()
|
||||
? allContentTypes.Where(ct => currentCompositeKeys.Contains(ct.Key)).Select(ct => ct.Alias).ToArray()
|
||||
: Array.Empty<string>();
|
||||
: [];
|
||||
|
||||
ContentTypeAvailableCompositionsResults availableCompositions = _contentTypeService.GetAvailableCompositeContentTypes(
|
||||
contentType,
|
||||
@@ -142,9 +142,9 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
|
||||
return Attempt.SucceedWithStatus<TContentType?, ContentTypeOperationStatus>(ContentTypeOperationStatus.Success, contentType);
|
||||
}
|
||||
|
||||
protected virtual async Task<ContentTypeOperationStatus> AdditionalCreateValidationAsync(
|
||||
protected virtual Task<ContentTypeOperationStatus> AdditionalCreateValidationAsync(
|
||||
ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model)
|
||||
=> await Task.FromResult(ContentTypeOperationStatus.Success);
|
||||
=> Task.FromResult(ContentTypeOperationStatus.Success);
|
||||
|
||||
#region Sanitization
|
||||
|
||||
@@ -346,7 +346,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
|
||||
return ContentTypeOperationStatus.Success;
|
||||
}
|
||||
|
||||
private ContentTypeOperationStatus ValidateProperties(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model, IContentTypeComposition[] allContentTypeCompositions)
|
||||
private static ContentTypeOperationStatus ValidateProperties(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model, IContentTypeComposition[] allContentTypeCompositions)
|
||||
{
|
||||
// grab all content types used for composition and/or inheritance
|
||||
Guid[] allCompositionKeys = KeysForCompositionTypes(model, CompositionType.Composition, CompositionType.Inheritance);
|
||||
@@ -365,7 +365,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
|
||||
return ContentTypeOperationStatus.Success;
|
||||
}
|
||||
|
||||
private ContentTypeOperationStatus ValidateContainers(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model, IContentTypeComposition[] allContentTypeCompositions)
|
||||
private static ContentTypeOperationStatus ValidateContainers(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model, IContentTypeComposition[] allContentTypeCompositions)
|
||||
{
|
||||
if (model.Containers.Any(container => Enum.TryParse<PropertyGroupType>(container.Type, out _) is false))
|
||||
{
|
||||
@@ -420,7 +420,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
|
||||
return ContentTypeAliasIsInUse(alias) is false;
|
||||
}
|
||||
|
||||
private bool IsReservedContentTypeAlias(string alias)
|
||||
private static bool IsReservedContentTypeAlias(string alias)
|
||||
{
|
||||
var reservedAliases = new[] { "system" };
|
||||
return reservedAliases.InvariantContains(alias);
|
||||
@@ -474,7 +474,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
|
||||
return contentType;
|
||||
}
|
||||
|
||||
private void UpdateAllowedContentTypes(
|
||||
private static void UpdateAllowedContentTypes(
|
||||
TContentType contentType,
|
||||
ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model,
|
||||
IContentTypeComposition[] allContentTypeCompositions)
|
||||
@@ -573,7 +573,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
|
||||
}
|
||||
}
|
||||
|
||||
private string PropertyGroupAlias(string? containerName)
|
||||
private static string PropertyGroupAlias(string? containerName)
|
||||
{
|
||||
if (containerName.IsNullOrWhiteSpace())
|
||||
{
|
||||
@@ -625,7 +625,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
|
||||
return propertyType;
|
||||
}
|
||||
|
||||
private void UpdateCompositions(
|
||||
private static void UpdateCompositions(
|
||||
TContentType contentType,
|
||||
ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model,
|
||||
IContentTypeComposition[] allContentTypeCompositions)
|
||||
@@ -658,7 +658,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateParentContentType(
|
||||
private static void UpdateParentContentType(
|
||||
TContentType contentType,
|
||||
ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model,
|
||||
IContentTypeComposition[] allContentTypeCompositions)
|
||||
@@ -677,7 +677,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
|
||||
|
||||
#region Shared between model validation and model update
|
||||
|
||||
private Guid[] GetDataTypeKeys(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model)
|
||||
private static Guid[] GetDataTypeKeys(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model)
|
||||
=> model.Properties.Select(property => property.DataTypeKey).Distinct().ToArray();
|
||||
|
||||
private async Task<IDataType[]> GetDataTypesAsync(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model)
|
||||
@@ -685,7 +685,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
|
||||
Guid[] dataTypeKeys = GetDataTypeKeys(model);
|
||||
return dataTypeKeys.Any()
|
||||
? (await _dataTypeService.GetAllAsync(GetDataTypeKeys(model))).ToArray()
|
||||
: Array.Empty<IDataType>();
|
||||
: [];
|
||||
}
|
||||
|
||||
private int? GetParentId(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model, Guid? containerKey)
|
||||
@@ -711,7 +711,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
|
||||
return Constants.System.Root;
|
||||
}
|
||||
|
||||
private Guid[] KeysForCompositionTypes(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model, params CompositionType[] compositionTypes)
|
||||
private static Guid[] KeysForCompositionTypes(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model, params CompositionType[] compositionTypes)
|
||||
=> model.Compositions
|
||||
.Where(c => compositionTypes.Contains(c.CompositionType))
|
||||
.Select(c => c.Key)
|
||||
|
||||
@@ -20,27 +20,27 @@ public class ElementSwitchValidator : IElementSwitchValidator
|
||||
_dataTypeService = dataTypeService;
|
||||
}
|
||||
|
||||
public async Task<bool> AncestorsAreAlignedAsync(IContentType contentType)
|
||||
public Task<bool> AncestorsAreAlignedAsync(IContentType contentType)
|
||||
{
|
||||
// this call does not return the system roots
|
||||
var ancestorIds = contentType.AncestorIds();
|
||||
if (ancestorIds.Length == 0)
|
||||
{
|
||||
// if there are no ancestors, validation passes
|
||||
return true;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
// if there are any ancestors where IsElement is different from the contentType, the validation fails
|
||||
return await Task.FromResult(_contentTypeService.GetMany(ancestorIds)
|
||||
return Task.FromResult(_contentTypeService.GetMany(ancestorIds)
|
||||
.Any(ancestor => ancestor.IsElement != contentType.IsElement) is false);
|
||||
}
|
||||
|
||||
public async Task<bool> DescendantsAreAlignedAsync(IContentType contentType)
|
||||
public Task<bool> DescendantsAreAlignedAsync(IContentType contentType)
|
||||
{
|
||||
IEnumerable<IContentType> descendants = _contentTypeService.GetDescendants(contentType.Id, false);
|
||||
|
||||
// if there are any descendants where IsElement is different from the contentType, the validation fails
|
||||
return await Task.FromResult(descendants.Any(descendant => descendant.IsElement != contentType.IsElement) is false);
|
||||
return Task.FromResult(descendants.Any(descendant => descendant.IsElement != contentType.IsElement) is false);
|
||||
}
|
||||
|
||||
public async Task<bool> ElementToDocumentNotUsedInBlockStructuresAsync(IContentTypeBase contentType)
|
||||
@@ -59,8 +59,8 @@ public class ElementSwitchValidator : IElementSwitchValidator
|
||||
.ConfiguredElementTypeKeys().Contains(contentType.Key)) is false;
|
||||
}
|
||||
|
||||
public async Task<bool> DocumentToElementHasNoContentAsync(IContentTypeBase contentType) =>
|
||||
public Task<bool> DocumentToElementHasNoContentAsync(IContentTypeBase contentType) =>
|
||||
|
||||
// if any content for the content type exists, the validation fails.
|
||||
await Task.FromResult(_contentTypeService.HasContentNodes(contentType.Id) is false);
|
||||
Task.FromResult(_contentTypeService.HasContentNodes(contentType.Id) is false);
|
||||
}
|
||||
|
||||
@@ -355,13 +355,13 @@ namespace Umbraco.Cms.Core.Services.Implement
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<IDataType>> GetByEditorAliasAsync(string[] propertyEditorAlias)
|
||||
public Task<IEnumerable<IDataType>> GetByEditorAliasAsync(string[] propertyEditorAlias)
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
|
||||
IQuery<IDataType> query = Query<IDataType>().Where(x => propertyEditorAlias.Contains(x.EditorAlias));
|
||||
IEnumerable<IDataType> dataTypes = _dataTypeRepository.Get(query).ToArray();
|
||||
ConvertMissingEditorsOfDataTypesToLabels(dataTypes);
|
||||
return await Task.FromResult(dataTypes);
|
||||
return Task.FromResult(dataTypes);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -396,7 +396,7 @@ namespace Umbraco.Cms.Core.Services.Implement
|
||||
return;
|
||||
}
|
||||
|
||||
ConvertMissingEditorsOfDataTypesToLabels(new[] { dataType });
|
||||
ConvertMissingEditorsOfDataTypesToLabels([dataType]);
|
||||
}
|
||||
|
||||
private void ConvertMissingEditorsOfDataTypesToLabels(IEnumerable<IDataType> dataTypes)
|
||||
@@ -763,7 +763,7 @@ namespace Umbraco.Cms.Core.Services.Implement
|
||||
var totalItems = combinedUsages.Count;
|
||||
|
||||
// Create the page of items.
|
||||
IList<(string PropertyAlias, Udi Udi)> pagedUsages = combinedUsages
|
||||
List<(string PropertyAlias, Udi Udi)> pagedUsages = combinedUsages
|
||||
.OrderBy(x => x.Udi.EntityType) // Document types first, then media types, then member types.
|
||||
.ThenBy(x => x.PropertyAlias)
|
||||
.Skip(skip)
|
||||
@@ -772,7 +772,7 @@ namespace Umbraco.Cms.Core.Services.Implement
|
||||
|
||||
// Get the content types for the UDIs referenced in the page of items to construct the response from.
|
||||
// They could be document, media or member types.
|
||||
IList<IContentTypeComposition> contentTypes = GetReferencedContentTypes(pagedUsages);
|
||||
List<IContentTypeComposition> contentTypes = GetReferencedContentTypes(pagedUsages);
|
||||
|
||||
IEnumerable<RelationItemModel> relations = pagedUsages
|
||||
.Select(x =>
|
||||
@@ -807,7 +807,7 @@ namespace Umbraco.Cms.Core.Services.Implement
|
||||
return Task.FromResult(pagedModel);
|
||||
}
|
||||
|
||||
private IList<IContentTypeComposition> GetReferencedContentTypes(IList<(string PropertyAlias, Udi Udi)> pagedUsages)
|
||||
private List<IContentTypeComposition> GetReferencedContentTypes(List<(string PropertyAlias, Udi Udi)> pagedUsages)
|
||||
{
|
||||
IEnumerable<IContentTypeComposition> documentTypes = GetContentTypes(
|
||||
pagedUsages,
|
||||
@@ -845,10 +845,10 @@ namespace Umbraco.Cms.Core.Services.Implement
|
||||
{
|
||||
IConfigurationEditor? configurationEditor = dataType.Editor?.GetConfigurationEditor();
|
||||
return configurationEditor == null
|
||||
? new[]
|
||||
{
|
||||
?
|
||||
[
|
||||
new ValidationResult($"Data type with editor alias {dataType.EditorAlias} does not have a configuration editor")
|
||||
}
|
||||
]
|
||||
: configurationEditor.Validate(dataType.ConfigurationData);
|
||||
}
|
||||
|
||||
|
||||
@@ -44,26 +44,51 @@ public class DocumentUrlService : IDocumentUrlService
|
||||
/// <summary>
|
||||
/// Model used to cache a single published document along with all it's URL segments.
|
||||
/// </summary>
|
||||
private class PublishedDocumentUrlSegments
|
||||
/// <remarks>Internal for the purpose of unit and benchmark testing.</remarks>
|
||||
internal class PublishedDocumentUrlSegments
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the document key.
|
||||
/// </summary>
|
||||
public required Guid DocumentKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the language Id.
|
||||
/// </summary>
|
||||
public required int LanguageId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the collection of <see cref="UrlSegment"/> for the document, language and state.
|
||||
/// </summary>
|
||||
public required IList<UrlSegment> UrlSegments { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the document is a draft version or not.
|
||||
/// </summary>
|
||||
public required bool IsDraft { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Model used to represent a URL segment for a document in the cache.
|
||||
/// </summary>
|
||||
public class UrlSegment
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UrlSegment"/> class.
|
||||
/// </summary>
|
||||
public UrlSegment(string segment, bool isPrimary)
|
||||
{
|
||||
Segment = segment;
|
||||
IsPrimary = isPrimary;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the URL segment string.
|
||||
/// </summary>
|
||||
public string Segment { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this URL segment is the primary one for the document, language and state.
|
||||
/// </summary>
|
||||
public bool IsPrimary { get; }
|
||||
}
|
||||
}
|
||||
@@ -116,14 +141,18 @@ public class DocumentUrlService : IDocumentUrlService
|
||||
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
|
||||
if (ShouldRebuildUrls())
|
||||
{
|
||||
_logger.LogInformation("Rebuilding all URLs.");
|
||||
_logger.LogInformation("Rebuilding all document URLs.");
|
||||
await RebuildAllUrlsAsync();
|
||||
}
|
||||
|
||||
_logger.LogInformation("Caching document URLs.");
|
||||
|
||||
IEnumerable<PublishedDocumentUrlSegment> publishedDocumentUrlSegments = _documentUrlRepository.GetAll();
|
||||
|
||||
IEnumerable<ILanguage> languages = await _languageService.GetAllAsync();
|
||||
var languageIdToIsoCode = languages.ToDictionary(x => x.Id, x => x.IsoCode);
|
||||
|
||||
int numberOfCachedUrls = 0;
|
||||
foreach (PublishedDocumentUrlSegments publishedDocumentUrlSegment in ConvertToCacheModel(publishedDocumentUrlSegments))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
@@ -134,9 +163,12 @@ public class DocumentUrlService : IDocumentUrlService
|
||||
if (languageIdToIsoCode.TryGetValue(publishedDocumentUrlSegment.LanguageId, out var isoCode))
|
||||
{
|
||||
UpdateCache(_coreScopeProvider.Context!, publishedDocumentUrlSegment, isoCode);
|
||||
numberOfCachedUrls++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Cached {NumberOfUrls} document URLs.", numberOfCachedUrls);
|
||||
|
||||
_isInitialized = true;
|
||||
scope.Complete();
|
||||
}
|
||||
@@ -168,45 +200,40 @@ public class DocumentUrlService : IDocumentUrlService
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
private static IEnumerable<PublishedDocumentUrlSegments> ConvertToCacheModel(IEnumerable<PublishedDocumentUrlSegment> publishedDocumentUrlSegments)
|
||||
/// <summary>
|
||||
/// Converts a collection of <see cref="PublishedDocumentUrlSegment"/> to a collection of <see cref="PublishedDocumentUrlSegments"/> for caching purposes.
|
||||
/// </summary>
|
||||
/// <param name="publishedDocumentUrlSegments">The collection of <see cref="PublishedDocumentUrlSegment"/> retrieved from the database on startup.</param>
|
||||
/// <returns>The collection of cache models.</returns>
|
||||
/// <remarks>Internal for the purpose of unit and benchmark testing.</remarks>
|
||||
internal static IEnumerable<PublishedDocumentUrlSegments> ConvertToCacheModel(IEnumerable<PublishedDocumentUrlSegment> publishedDocumentUrlSegments)
|
||||
{
|
||||
var cacheModels = new List<PublishedDocumentUrlSegments>();
|
||||
var cacheModels = new Dictionary<(Guid DocumentKey, int LanguageId, bool IsDraft), PublishedDocumentUrlSegments>();
|
||||
|
||||
foreach (PublishedDocumentUrlSegment model in publishedDocumentUrlSegments)
|
||||
{
|
||||
PublishedDocumentUrlSegments? existingCacheModel = GetModelFromCache(cacheModels, model);
|
||||
if (existingCacheModel is null)
|
||||
(Guid DocumentKey, int LanguageId, bool IsDraft) key = (model.DocumentKey, model.LanguageId, model.IsDraft);
|
||||
|
||||
if (!cacheModels.TryGetValue(key, out PublishedDocumentUrlSegments? existingCacheModel))
|
||||
{
|
||||
cacheModels.Add(new PublishedDocumentUrlSegments
|
||||
cacheModels[key] = new PublishedDocumentUrlSegments
|
||||
{
|
||||
DocumentKey = model.DocumentKey,
|
||||
LanguageId = model.LanguageId,
|
||||
UrlSegments = [new PublishedDocumentUrlSegments.UrlSegment(model.UrlSegment, model.IsPrimary)],
|
||||
IsDraft = model.IsDraft,
|
||||
});
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
existingCacheModel.UrlSegments = GetUpdatedUrlSegments(existingCacheModel.UrlSegments, model.UrlSegment, model.IsPrimary);
|
||||
}
|
||||
}
|
||||
|
||||
return cacheModels;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static PublishedDocumentUrlSegments? GetModelFromCache(List<PublishedDocumentUrlSegments> cacheModels, PublishedDocumentUrlSegment model)
|
||||
=> cacheModels
|
||||
.SingleOrDefault(x => x.DocumentKey == model.DocumentKey && x.LanguageId == model.LanguageId && x.IsDraft == model.IsDraft);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static IList<PublishedDocumentUrlSegments.UrlSegment> GetUpdatedUrlSegments(IList<PublishedDocumentUrlSegments.UrlSegment> urlSegments, string segment, bool isPrimary)
|
||||
if (existingCacheModel.UrlSegments.Any(x => x.Segment == model.UrlSegment) is false)
|
||||
{
|
||||
if (urlSegments.FirstOrDefault(x => x.Segment == segment) is null)
|
||||
{
|
||||
urlSegments.Add(new PublishedDocumentUrlSegments.UrlSegment(segment, isPrimary));
|
||||
existingCacheModel.UrlSegments.Add(new PublishedDocumentUrlSegments.UrlSegment(model.UrlSegment, model.IsPrimary));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return urlSegments;
|
||||
return cacheModels.Values;
|
||||
}
|
||||
|
||||
private void RemoveFromCache(IScopeContext scopeContext, Guid documentKey, string isoCode, bool isDraft)
|
||||
|
||||
@@ -318,6 +318,39 @@ public class EntityService : RepositoryService, IEntityService
|
||||
return children;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<IEntitySlim> GetSiblings(
|
||||
Guid key,
|
||||
UmbracoObjectTypes objectType,
|
||||
int before,
|
||||
int after,
|
||||
Ordering? ordering = null)
|
||||
{
|
||||
if (before < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(before), "The 'before' parameter must be greater than or equal to 0.");
|
||||
}
|
||||
|
||||
if (after < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(after), "The 'after' parameter must be greater than or equal to 0.");
|
||||
}
|
||||
|
||||
ordering ??= new Ordering("sortOrder");
|
||||
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
|
||||
IEnumerable<IEntitySlim> siblings = _entityRepository.GetSiblings(
|
||||
objectType.GetGuid(),
|
||||
key,
|
||||
before,
|
||||
after,
|
||||
ordering);
|
||||
|
||||
scope.Complete();
|
||||
return siblings;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual IEnumerable<IEntitySlim> GetDescendants(int id)
|
||||
{
|
||||
|
||||
@@ -52,18 +52,18 @@ internal abstract class EntityTypeContainerService<TTreeEntity, TEntityContainer
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<EntityContainer>> GetAsync(string name, int level)
|
||||
public Task<IEnumerable<EntityContainer>> GetAsync(string name, int level)
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
|
||||
ReadLock(scope);
|
||||
return await Task.FromResult(_entityContainerRepository.Get(name, level));
|
||||
return Task.FromResult(_entityContainerRepository.Get(name, level));
|
||||
}
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<EntityContainer>> GetAllAsync()
|
||||
public Task<IEnumerable<EntityContainer>> GetAllAsync()
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
|
||||
ReadLock(scope);
|
||||
return await Task.FromResult(_entityContainerRepository.GetMany());
|
||||
return Task.FromResult(_entityContainerRepository.GetMany());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -34,14 +34,37 @@ public interface ICacheInstructionService
|
||||
void DeliverInstructionsInBatches(IEnumerable<RefreshInstruction> instructions, string localIdentity);
|
||||
|
||||
/// <summary>
|
||||
/// Processes and then prunes pending database cache instructions.
|
||||
/// Processes pending database cache instructions.
|
||||
/// </summary>
|
||||
/// <param name="cacheRefreshers">Cache refreshers.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <param name="localIdentity">Local identity of the executing AppDomain.</param>
|
||||
/// <param name="lastId">Id of the latest processed instruction.</param>
|
||||
/// <returns>The processing result.</returns>
|
||||
ProcessInstructionsResult ProcessInstructions(
|
||||
CacheRefresherCollection cacheRefreshers,
|
||||
CancellationToken cancellationToken,
|
||||
string localIdentity,
|
||||
int lastId) =>
|
||||
ProcessInstructions(
|
||||
cacheRefreshers,
|
||||
ServerRole.Unknown,
|
||||
cancellationToken,
|
||||
localIdentity,
|
||||
lastPruned: DateTime.UtcNow,
|
||||
lastId);
|
||||
|
||||
/// <summary>
|
||||
/// Processes pending database cache instructions.
|
||||
/// </summary>
|
||||
/// <param name="cacheRefreshers">Cache refreshers.</param>
|
||||
/// <param name="serverRole">Server role.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <param name="localIdentity">Local identity of the executing AppDomain.</param>
|
||||
/// <param name="lastPruned">Date of last prune operation.</param>
|
||||
/// <param name="lastId">Id of the latest processed instruction</param>
|
||||
/// <param name="lastId">Id of the latest processed instruction.</param>
|
||||
/// <returns>The processing result.</returns>
|
||||
[Obsolete("Use the non-obsolete overload. Scheduled for removal in V17.")]
|
||||
ProcessInstructionsResult ProcessInstructions(
|
||||
CacheRefresherCollection cacheRefreshers,
|
||||
ServerRole serverRole,
|
||||
|
||||
@@ -47,16 +47,35 @@ public interface IContentService : IContentServiceBase<IContent>
|
||||
/// <summary>
|
||||
/// Saves a blueprint.
|
||||
/// </summary>
|
||||
[Obsolete("Please use the method taking all parameters. Scheduled for removal in Umbraco 18.")]
|
||||
void SaveBlueprint(IContent content, int userId = Constants.Security.SuperUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Saves a blueprint.
|
||||
/// </summary>
|
||||
void SaveBlueprint(IContent content, IContent? createdFromContent, int userId = Constants.Security.SuperUserId)
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
=> SaveBlueprint(content, userId);
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a blueprint.
|
||||
/// </summary>
|
||||
void DeleteBlueprint(IContent content, int userId = Constants.Security.SuperUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new content item from a blueprint.
|
||||
/// Creates a blueprint from a content item.
|
||||
/// </summary>
|
||||
// TODO: Remove the default implementation when CreateContentFromBlueprint is removed.
|
||||
IContent CreateBlueprintFromContent(IContent blueprint, string name, int userId = Constants.Security.SuperUserId)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <summary>
|
||||
/// (Deprecated) Creates a new content item from a blueprint.
|
||||
/// </summary>
|
||||
/// <remarks>If creating content from a blueprint, use <see cref="IContentBlueprintEditingService.GetScaffoldedAsync"/>
|
||||
/// instead. If creating a blueprint from content use <see cref="CreateBlueprintFromContent"/> instead.</remarks>
|
||||
[Obsolete("Use IContentBlueprintEditingService.GetScaffoldedAsync() instead. Scheduled for removal in V18.")]
|
||||
IContent CreateContentFromBlueprint(IContent blueprint, string name, int userId = Constants.Security.SuperUserId);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -170,6 +170,22 @@ public interface IEntityService
|
||||
/// <param name="objectType">The object type of the parent.</param>
|
||||
IEntitySlim? GetParent(int id, UmbracoObjectTypes objectType);
|
||||
|
||||
/// <summary>
|
||||
/// Gets sibling entities of a specified target entity, within a given range before and after the target, ordered as specified.
|
||||
/// </summary>
|
||||
/// <param name="key">The key of the target entity whose siblings are to be retrieved.</param>
|
||||
/// <param name="objectType">The object type key of the entities.</param>
|
||||
/// <param name="before">The number of siblings to retrieve before the target entity. Needs to be greater or equal to 0.</param>
|
||||
/// <param name="after">The number of siblings to retrieve after the target entity. Needs to be greater or equal to 0.</param>
|
||||
/// <param name="ordering">The ordering to apply to the siblings.</param>
|
||||
/// <returns>Enumerable of sibling entities.</returns>
|
||||
IEnumerable<IEntitySlim> GetSiblings(
|
||||
Guid key,
|
||||
UmbracoObjectTypes objectType,
|
||||
int before,
|
||||
int after,
|
||||
Ordering? ordering = null) => [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the children of an entity.
|
||||
/// </summary>
|
||||
|
||||
@@ -17,8 +17,8 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
private Lazy<Dictionary<string, Guid>> _contentTypeAliasToKeyMap;
|
||||
private ConcurrentDictionary<Guid, NavigationNode> _navigationStructure = new();
|
||||
private ConcurrentDictionary<Guid, NavigationNode> _recycleBinNavigationStructure = new();
|
||||
private IList<Guid> _roots = new List<Guid>();
|
||||
private IList<Guid> _recycleBinRoots = new List<Guid>();
|
||||
private HashSet<Guid> _roots = [];
|
||||
private HashSet<Guid> _recycleBinRoots = [];
|
||||
|
||||
protected ContentNavigationServiceBase(ICoreScopeProvider coreScopeProvider, INavigationRepository navigationRepository, TContentTypeService typeService)
|
||||
{
|
||||
@@ -321,7 +321,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private bool TryGetParentKeyFromStructure(ConcurrentDictionary<Guid, NavigationNode> structure, Guid childKey, out Guid? parentKey)
|
||||
private static bool TryGetParentKeyFromStructure(ConcurrentDictionary<Guid, NavigationNode> structure, Guid childKey, out Guid? parentKey)
|
||||
{
|
||||
if (structure.TryGetValue(childKey, out NavigationNode? childNode))
|
||||
{
|
||||
@@ -335,25 +335,32 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
}
|
||||
|
||||
private bool TryGetRootKeysFromStructure(
|
||||
IList<Guid> input,
|
||||
HashSet<Guid> input,
|
||||
out IEnumerable<Guid> rootKeys,
|
||||
Guid? contentTypeKey = null)
|
||||
{
|
||||
// Apply contentTypeKey filter
|
||||
IEnumerable<Guid> filteredKeys = contentTypeKey.HasValue
|
||||
? input.Where(key => _navigationStructure[key].ContentTypeKey == contentTypeKey.Value)
|
||||
: input;
|
||||
var keysWithSortOrder = new List<(Guid Key, int SortOrder)>(input.Count);
|
||||
foreach (Guid key in input)
|
||||
{
|
||||
NavigationNode navigationNode = _navigationStructure[key];
|
||||
|
||||
// Apply contentTypeKey filter
|
||||
if (contentTypeKey.HasValue && navigationNode.ContentTypeKey != contentTypeKey.Value)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
keysWithSortOrder.Add((key, navigationNode.SortOrder));
|
||||
}
|
||||
|
||||
// TODO can we make this more efficient?
|
||||
// Sort by SortOrder
|
||||
rootKeys = filteredKeys
|
||||
.OrderBy(key => _navigationStructure[key].SortOrder)
|
||||
.ToList();
|
||||
keysWithSortOrder.Sort((a, b) => a.SortOrder.CompareTo(b.SortOrder));
|
||||
rootKeys = keysWithSortOrder.ConvertAll(keyWithSortOrder => keyWithSortOrder.Key);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryGetChildrenKeysFromStructure(
|
||||
private static bool TryGetChildrenKeysFromStructure(
|
||||
ConcurrentDictionary<Guid, NavigationNode> structure,
|
||||
Guid parentKey,
|
||||
out IEnumerable<Guid> childrenKeys,
|
||||
@@ -367,12 +374,12 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
}
|
||||
|
||||
// Keep children keys ordered based on their SortOrder
|
||||
childrenKeys = GetOrderedChildren(parentNode, structure, contentTypeKey).ToList();
|
||||
childrenKeys = GetOrderedChildren(parentNode, structure, contentTypeKey);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryGetDescendantsKeysFromStructure(
|
||||
private static bool TryGetDescendantsKeysFromStructure(
|
||||
ConcurrentDictionary<Guid, NavigationNode> structure,
|
||||
Guid parentKey,
|
||||
out IEnumerable<Guid> descendantsKeys,
|
||||
@@ -393,7 +400,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryGetAncestorsKeysFromStructure(
|
||||
private static bool TryGetAncestorsKeysFromStructure(
|
||||
ConcurrentDictionary<Guid, NavigationNode> structure,
|
||||
Guid childKey,
|
||||
out IEnumerable<Guid> ancestorsKeys,
|
||||
@@ -421,7 +428,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryGetSiblingsKeysFromStructure(
|
||||
private static bool TryGetSiblingsKeysFromStructure(
|
||||
ConcurrentDictionary<Guid, NavigationNode> structure,
|
||||
Guid key,
|
||||
out IEnumerable<Guid> siblingsKeys,
|
||||
@@ -463,14 +470,14 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
return true;
|
||||
}
|
||||
|
||||
private void GetDescendantsRecursively(
|
||||
private static void GetDescendantsRecursively(
|
||||
ConcurrentDictionary<Guid, NavigationNode> structure,
|
||||
NavigationNode node,
|
||||
List<Guid> descendants,
|
||||
Guid? contentTypeKey = null)
|
||||
{
|
||||
// Get all children regardless of contentType
|
||||
var childrenKeys = GetOrderedChildren(node, structure).ToList();
|
||||
IReadOnlyList<Guid> childrenKeys = GetOrderedChildren(node, structure);
|
||||
foreach (Guid childKey in childrenKeys)
|
||||
{
|
||||
// Apply contentTypeKey filter
|
||||
@@ -487,7 +494,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryRemoveNodeFromParentInStructure(ConcurrentDictionary<Guid, NavigationNode> structure, Guid key, out NavigationNode? nodeToRemove)
|
||||
private static bool TryRemoveNodeFromParentInStructure(ConcurrentDictionary<Guid, NavigationNode> structure, Guid key, out NavigationNode? nodeToRemove)
|
||||
{
|
||||
if (structure.TryGetValue(key, out nodeToRemove) is false)
|
||||
{
|
||||
@@ -507,7 +514,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
{
|
||||
_recycleBinRoots.Add(node.Key);
|
||||
_roots.Remove(node.Key);
|
||||
var childrenKeys = GetOrderedChildren(node, _navigationStructure).ToList();
|
||||
IReadOnlyList<Guid> childrenKeys = GetOrderedChildren(node, _navigationStructure);
|
||||
|
||||
foreach (Guid childKey in childrenKeys)
|
||||
{
|
||||
@@ -530,7 +537,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
|
||||
private void RemoveDescendantsRecursively(NavigationNode node)
|
||||
{
|
||||
var childrenKeys = GetOrderedChildren(node, _recycleBinNavigationStructure).ToList();
|
||||
IReadOnlyList<Guid> childrenKeys = GetOrderedChildren(node, _recycleBinNavigationStructure);
|
||||
foreach (Guid childKey in childrenKeys)
|
||||
{
|
||||
if (_recycleBinNavigationStructure.TryGetValue(childKey, out NavigationNode? childNode) is false)
|
||||
@@ -551,7 +558,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
}
|
||||
|
||||
_recycleBinRoots.Remove(node.Key);
|
||||
var childrenKeys = GetOrderedChildren(node, _recycleBinNavigationStructure).ToList();
|
||||
IReadOnlyList<Guid> childrenKeys = GetOrderedChildren(node, _recycleBinNavigationStructure);
|
||||
|
||||
foreach (Guid childKey in childrenKeys)
|
||||
{
|
||||
@@ -570,24 +577,35 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<Guid> GetOrderedChildren(
|
||||
private static IReadOnlyList<Guid> GetOrderedChildren(
|
||||
NavigationNode node,
|
||||
ConcurrentDictionary<Guid, NavigationNode> structure,
|
||||
Guid? contentTypeKey = null)
|
||||
{
|
||||
IEnumerable<Guid> children = node
|
||||
.Children
|
||||
.Where(structure.ContainsKey);
|
||||
|
||||
// Apply contentTypeKey filter
|
||||
if (contentTypeKey.HasValue)
|
||||
if (node.Children.Count < 1)
|
||||
{
|
||||
children = children.Where(childKey => structure[childKey].ContentTypeKey == contentTypeKey.Value);
|
||||
return [];
|
||||
}
|
||||
|
||||
return children
|
||||
.OrderBy(childKey => structure[childKey].SortOrder)
|
||||
.ToList();
|
||||
var childrenWithSortOrder = new List<(Guid ChildNodeKey, int SortOrder)>(node.Children.Count);
|
||||
foreach (Guid childNodeKey in node.Children)
|
||||
{
|
||||
if (!structure.TryGetValue(childNodeKey, out NavigationNode? childNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply contentTypeKey filter
|
||||
if (contentTypeKey.HasValue && childNode.ContentTypeKey != contentTypeKey.Value)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
childrenWithSortOrder.Add((childNodeKey, childNode.SortOrder));
|
||||
}
|
||||
|
||||
childrenWithSortOrder.Sort((a, b) => a.SortOrder.CompareTo(b.SortOrder));
|
||||
return childrenWithSortOrder.ConvertAll(childWithSortOrder => childWithSortOrder.ChildNodeKey);
|
||||
}
|
||||
|
||||
private bool TryGetContentTypeKey(string contentTypeAlias, out Guid? contentTypeKey)
|
||||
@@ -613,7 +631,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void BuildNavigationDictionary(ConcurrentDictionary<Guid, NavigationNode> nodesStructure, IList<Guid> roots, IEnumerable<INavigationModel> entities)
|
||||
private static void BuildNavigationDictionary(ConcurrentDictionary<Guid, NavigationNode> nodesStructure, HashSet<Guid> roots, IEnumerable<INavigationModel> entities)
|
||||
{
|
||||
var entityList = entities.ToList();
|
||||
var idToKeyMap = entityList.ToDictionary(x => x.Id, x => x.Key);
|
||||
|
||||
@@ -14,11 +14,13 @@ public class ProcessInstructionsResult
|
||||
|
||||
public int LastId { get; private set; }
|
||||
|
||||
[Obsolete("Instruction pruning has been moved to a separate background job. Scheduled for removal in V18.")]
|
||||
public bool InstructionsWerePruned { get; private set; }
|
||||
|
||||
public static ProcessInstructionsResult AsCompleted(int numberOfInstructionsProcessed, int lastId) =>
|
||||
new() { NumberOfInstructionsProcessed = numberOfInstructionsProcessed, LastId = lastId };
|
||||
|
||||
[Obsolete("Instruction pruning has been moved to a separate background job. Scheduled for removal in V18.")]
|
||||
public static ProcessInstructionsResult AsCompletedAndPruned(int numberOfInstructionsProcessed, int lastId) =>
|
||||
new()
|
||||
{
|
||||
|
||||
@@ -23,23 +23,23 @@ public class ContentQueryService : IContentQueryService
|
||||
_coreScopeProvider = coreScopeProvider;
|
||||
}
|
||||
|
||||
public async Task<Attempt<ContentScheduleQueryResult?, ContentQueryOperationStatus>> GetWithSchedulesAsync(Guid id)
|
||||
public Task<Attempt<ContentScheduleQueryResult?, ContentQueryOperationStatus>> GetWithSchedulesAsync(Guid id)
|
||||
{
|
||||
using ICoreScope scope = _coreScopeProvider.CreateCoreScope(autoComplete: true);
|
||||
|
||||
IContent? content = await Task.FromResult(_contentService.GetById(id));
|
||||
IContent? content = _contentService.GetById(id);
|
||||
|
||||
if (content == null)
|
||||
{
|
||||
return Attempt<ContentScheduleQueryResult, ContentQueryOperationStatus>.Fail(ContentQueryOperationStatus
|
||||
.ContentNotFound);
|
||||
return Task.FromResult(Attempt<ContentScheduleQueryResult, ContentQueryOperationStatus>.Fail(ContentQueryOperationStatus
|
||||
.ContentNotFound));
|
||||
}
|
||||
|
||||
ContentScheduleCollection schedules = _contentService.GetContentScheduleByContentId(id);
|
||||
|
||||
return Attempt<ContentScheduleQueryResult?, ContentQueryOperationStatus>
|
||||
return Task.FromResult(Attempt<ContentScheduleQueryResult?, ContentQueryOperationStatus>
|
||||
.Succeed(
|
||||
ContentQueryOperationStatus.Success,
|
||||
new ContentScheduleQueryResult(content, schedules));
|
||||
new ContentScheduleQueryResult(content, schedules)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ public class RelationService : RepositoryService, IRelationService
|
||||
}
|
||||
|
||||
return relationTypeIds.Count == 0
|
||||
? Enumerable.Empty<IRelation>()
|
||||
? []
|
||||
: GetRelationsByListOfTypeIds(relationTypeIds);
|
||||
}
|
||||
|
||||
@@ -230,8 +230,8 @@ public class RelationService : RepositoryService, IRelationService
|
||||
IRelationType? relationType = GetRelationType(relationTypeAlias);
|
||||
|
||||
return relationType == null
|
||||
? Enumerable.Empty<IRelation>()
|
||||
: GetRelationsByListOfTypeIds(new[] { relationType.Id });
|
||||
? []
|
||||
: GetRelationsByListOfTypeIds([relationType.Id]);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -594,7 +594,7 @@ public class RelationService : RepositoryService, IRelationService
|
||||
|
||||
EventMessages eventMessages = EventMessagesFactory.Get();
|
||||
var savingNotification = new RelationTypeSavingNotification(relationType, eventMessages);
|
||||
if (scope.Notifications.PublishCancelable(savingNotification))
|
||||
if (await scope.Notifications.PublishCancelableAsync(savingNotification))
|
||||
{
|
||||
scope.Complete();
|
||||
return Attempt.FailWithStatus(RelationTypeOperationStatus.CancelledByNotification, relationType);
|
||||
@@ -659,7 +659,7 @@ public class RelationService : RepositoryService, IRelationService
|
||||
|
||||
EventMessages eventMessages = EventMessagesFactory.Get();
|
||||
var deletingNotification = new RelationTypeDeletingNotification(relationType, eventMessages);
|
||||
if (scope.Notifications.PublishCancelable(deletingNotification))
|
||||
if (await scope.Notifications.PublishCancelableAsync(deletingNotification))
|
||||
{
|
||||
scope.Complete();
|
||||
return Attempt.FailWithStatus<IRelationType?, RelationTypeOperationStatus>(RelationTypeOperationStatus.CancelledByNotification, null);
|
||||
@@ -670,7 +670,7 @@ public class RelationService : RepositoryService, IRelationService
|
||||
Audit(AuditType.Delete, currentUser, relationType.Id, "Deleted relation type");
|
||||
scope.Notifications.Publish(new RelationTypeDeletedNotification(relationType, eventMessages).WithStateFrom(deletingNotification));
|
||||
scope.Complete();
|
||||
return await Task.FromResult(Attempt.SucceedWithStatus<IRelationType?, RelationTypeOperationStatus>(RelationTypeOperationStatus.Success, relationType));
|
||||
return Attempt.SucceedWithStatus<IRelationType?, RelationTypeOperationStatus>(RelationTypeOperationStatus.Success, relationType);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -726,7 +726,7 @@ public class RelationService : RepositoryService, IRelationService
|
||||
return _relationTypeRepository.Get(query).FirstOrDefault();
|
||||
}
|
||||
|
||||
private IEnumerable<IRelation> GetRelationsByListOfTypeIds(IEnumerable<int> relationTypeIds)
|
||||
private List<IRelation> GetRelationsByListOfTypeIds(IEnumerable<int> relationTypeIds)
|
||||
{
|
||||
var relations = new List<IRelation>();
|
||||
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
|
||||
|
||||
@@ -39,8 +39,7 @@ public class StylesheetHelper
|
||||
// Only match first selector when chained together
|
||||
Styles = string.Join(
|
||||
Environment.NewLine,
|
||||
match.Groups["Styles"].Value.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)
|
||||
.Select(x => x.Trim()).ToArray()),
|
||||
match.Groups["Styles"].Value.Split(new[] { "\r\n", "\n" }, StringSplitOptions.TrimEntries)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,13 +30,13 @@ public class StylesheetRule
|
||||
// instead of using string interpolation (for increased performance)
|
||||
foreach (var style in
|
||||
Styles?.Split(Constants.CharArrays.Semicolon, StringSplitOptions.RemoveEmptyEntries) ??
|
||||
Array.Empty<string>())
|
||||
[])
|
||||
{
|
||||
sb.Append("\t").Append(style.StripNewLines().Trim()).Append(";").Append(Environment.NewLine);
|
||||
sb.Append('\t').Append(style.StripNewLines().Trim()).Append(';').Append(Environment.NewLine);
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append("}");
|
||||
sb.Append('}');
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace Umbraco.Cms.Core.Strings;
|
||||
/// Copyright (c) by Matthias Hertel, http://www.mathertel.de
|
||||
/// This work is licensed under a BSD style license. See http://www.mathertel.de/License.aspx
|
||||
/// </summary>
|
||||
internal class Diff
|
||||
internal sealed class Diff
|
||||
{
|
||||
/// <summary>
|
||||
/// Find the difference in 2 texts, comparing by text lines.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Infrastructure.Scoping;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// A background job that prunes cache instructions from the database.
|
||||
/// </summary>
|
||||
public class CacheInstructionsPruningJob : IRecurringBackgroundJob
|
||||
{
|
||||
private readonly IOptions<GlobalSettings> _globalSettings;
|
||||
private readonly ICacheInstructionRepository _cacheInstructionRepository;
|
||||
private readonly ICoreScopeProvider _scopeProvider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CacheInstructionsPruningJob"/> class.
|
||||
/// </summary>
|
||||
/// <param name="scopeProvider">Provides scopes for database operations.</param>
|
||||
/// <param name="globalSettings">The global settings configuration.</param>
|
||||
/// <param name="cacheInstructionRepository">The repository for cache instructions.</param>
|
||||
/// <param name="timeProvider">The time provider.</param>
|
||||
public CacheInstructionsPruningJob(
|
||||
IOptions<GlobalSettings> globalSettings,
|
||||
ICacheInstructionRepository cacheInstructionRepository,
|
||||
ICoreScopeProvider scopeProvider,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_globalSettings = globalSettings;
|
||||
_cacheInstructionRepository = cacheInstructionRepository;
|
||||
_scopeProvider = scopeProvider;
|
||||
_timeProvider = timeProvider;
|
||||
Period = globalSettings.Value.DatabaseServerMessenger.TimeBetweenPruneOperations;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler PeriodChanged
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan Period { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RunJobAsync()
|
||||
{
|
||||
DateTimeOffset pruneDate = _timeProvider.GetUtcNow() - _globalSettings.Value.DatabaseServerMessenger.TimeToRetainInstructions;
|
||||
using (ICoreScope scope = _scopeProvider.CreateCoreScope())
|
||||
{
|
||||
_cacheInstructionRepository.DeleteInstructionsOlderThan(pruneDate.DateTime);
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ using Umbraco.Cms.Infrastructure.Migrations;
|
||||
using Umbraco.Cms.Infrastructure.Migrations.Install;
|
||||
using Umbraco.Cms.Infrastructure.Persistence;
|
||||
using Umbraco.Cms.Infrastructure.Persistence.Mappers;
|
||||
using Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers;
|
||||
using Umbraco.Cms.Infrastructure.Routing;
|
||||
using Umbraco.Cms.Infrastructure.Runtime;
|
||||
using Umbraco.Cms.Infrastructure.Runtime.RuntimeModeValidators;
|
||||
@@ -352,11 +353,17 @@ public static partial class UmbracoBuilderExtensions
|
||||
.AddNotificationHandler<ContentSavingNotification, BlockGridPropertyNotificationHandler>()
|
||||
.AddNotificationHandler<ContentCopyingNotification, BlockGridPropertyNotificationHandler>()
|
||||
.AddNotificationHandler<ContentScaffoldedNotification, BlockGridPropertyNotificationHandler>()
|
||||
.AddNotificationHandler<ContentCopiedNotification, FileUploadPropertyEditor>()
|
||||
.AddNotificationHandler<ContentDeletedNotification, FileUploadPropertyEditor>()
|
||||
.AddNotificationHandler<MediaDeletedNotification, FileUploadPropertyEditor>()
|
||||
.AddNotificationHandler<MediaSavingNotification, FileUploadPropertyEditor>()
|
||||
.AddNotificationHandler<MemberDeletedNotification, FileUploadPropertyEditor>()
|
||||
.AddNotificationHandler<ContentSavingNotification, RichTextPropertyNotificationHandler>()
|
||||
.AddNotificationHandler<ContentCopyingNotification, RichTextPropertyNotificationHandler>()
|
||||
.AddNotificationHandler<ContentScaffoldedNotification, RichTextPropertyNotificationHandler>()
|
||||
.AddNotificationHandler<ContentCopiedNotification, FileUploadContentCopiedOrScaffoldedNotificationHandler>()
|
||||
.AddNotificationHandler<ContentScaffoldedNotification, FileUploadContentCopiedOrScaffoldedNotificationHandler>()
|
||||
.AddNotificationHandler<ContentSavedBlueprintNotification, FileUploadContentCopiedOrScaffoldedNotificationHandler>()
|
||||
.AddNotificationHandler<ContentDeletedNotification, FileUploadContentDeletedNotificationHandler>()
|
||||
.AddNotificationHandler<ContentDeletedBlueprintNotification, FileUploadContentDeletedNotificationHandler>()
|
||||
.AddNotificationHandler<MediaDeletedNotification, FileUploadContentDeletedNotificationHandler>()
|
||||
.AddNotificationHandler<MemberDeletedNotification, FileUploadContentDeletedNotificationHandler>()
|
||||
.AddNotificationHandler<MediaSavingNotification, FileUploadMediaSavingNotificationHandler>()
|
||||
.AddNotificationHandler<ContentCopiedNotification, ImageCropperPropertyEditor>()
|
||||
.AddNotificationHandler<ContentDeletedNotification, ImageCropperPropertyEditor>()
|
||||
.AddNotificationHandler<MediaDeletedNotification, ImageCropperPropertyEditor>()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Persistence.Querying;
|
||||
@@ -28,21 +28,28 @@ internal sealed class DeliveryApiContentIndexHelper : IDeliveryApiContentIndexHe
|
||||
public void EnumerateApplicableDescendantsForContentIndex(int rootContentId, Action<IContent[]> actionToPerform)
|
||||
{
|
||||
const int pageSize = 10000;
|
||||
var pageIndex = 0;
|
||||
EnumerateApplicableDescendantsForContentIndex(rootContentId, actionToPerform, pageSize);
|
||||
}
|
||||
|
||||
internal void EnumerateApplicableDescendantsForContentIndex(int rootContentId, Action<IContent[]> actionToPerform, int pageSize)
|
||||
{
|
||||
var itemIndex = 0;
|
||||
long total;
|
||||
|
||||
IQuery<IContent> query = _umbracoDatabaseFactory.SqlContext.Query<IContent>().Where(content => content.Trashed == false);
|
||||
|
||||
IContent[] descendants;
|
||||
IQuery<IContent> query = _umbracoDatabaseFactory.SqlContext.Query<IContent>().Where(content => content.Trashed == false);
|
||||
do
|
||||
{
|
||||
descendants = _contentService
|
||||
.GetPagedDescendants(rootContentId, pageIndex, pageSize, out _, query, Ordering.By("Path"))
|
||||
.GetPagedDescendants(rootContentId, itemIndex / pageSize, pageSize, out total, query, Ordering.By("Path"))
|
||||
.Where(descendant => _deliveryApiSettings.IsAllowedContentType(descendant.ContentType.Alias))
|
||||
.ToArray();
|
||||
|
||||
actionToPerform(descendants.ToArray());
|
||||
actionToPerform(descendants);
|
||||
|
||||
pageIndex++;
|
||||
itemIndex += pageSize;
|
||||
}
|
||||
while (descendants.Length == pageSize);
|
||||
while (descendants.Length > 0 && itemIndex < total);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache.PropertyEditors;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Defines extensions on <see cref="RichTextEditorValue"/>.
|
||||
/// </summary>
|
||||
internal static class RichTextEditorValueExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Ensures that the property type property is populated on all blocks.
|
||||
/// </summary>
|
||||
/// <param name="richTextEditorValue">The <see cref="RichTextEditorValue"/> providing the blocks.</param>
|
||||
/// <param name="elementTypeCache">Cache for element types.</param>
|
||||
public static void EnsurePropertyTypePopulatedOnBlocks(this RichTextEditorValue richTextEditorValue, IBlockEditorElementTypeCache elementTypeCache)
|
||||
{
|
||||
Guid[] elementTypeKeys = (richTextEditorValue.Blocks?.ContentData ?? [])
|
||||
.Select(x => x.ContentTypeKey)
|
||||
.Union((richTextEditorValue.Blocks?.SettingsData ?? [])
|
||||
.Select(x => x.ContentTypeKey))
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
IEnumerable<IContentType> elementTypes = elementTypeCache.GetMany(elementTypeKeys);
|
||||
|
||||
foreach (BlockItemData dataItem in (richTextEditorValue.Blocks?.ContentData ?? [])
|
||||
.Union(richTextEditorValue.Blocks?.SettingsData ?? []))
|
||||
{
|
||||
foreach (BlockPropertyValue item in dataItem.Values)
|
||||
{
|
||||
item.PropertyType = elementTypes.FirstOrDefault(x => x.Key == dataItem.ContentTypeKey)?.PropertyTypes.FirstOrDefault(pt => pt.Alias == item.Alias);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,8 +77,9 @@ public class PackageMigrationRunner
|
||||
/// </summary>
|
||||
public async Task<Attempt<bool, PackageMigrationOperationStatus>> RunPendingPackageMigrations(string packageName)
|
||||
{
|
||||
// Check if there are any migrations
|
||||
if (_packageMigrationPlans.ContainsKey(packageName) == false)
|
||||
// Check if there are any migrations (note that the key for _packageMigrationPlans is the migration plan name, not the package name).
|
||||
if (_packageMigrationPlans.Values
|
||||
.Any(x => x.PackageName.InvariantEquals(packageName)) is false)
|
||||
{
|
||||
return Attempt.FailWithStatus(PackageMigrationOperationStatus.NotFound, false);
|
||||
}
|
||||
@@ -121,8 +122,8 @@ public class PackageMigrationRunner
|
||||
}
|
||||
|
||||
using (_profilingLogger.TraceDuration<PackageMigrationRunner>(
|
||||
"Starting unattended package migration for " + migrationName,
|
||||
"Unattended upgrade completed for " + migrationName))
|
||||
"Starting package migration for " + migrationName,
|
||||
"Package migration completed for " + migrationName))
|
||||
{
|
||||
Upgrader upgrader = new(plan);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
@@ -63,7 +63,7 @@ public class MoveDocumentBlueprintsToFolders : MigrationBase
|
||||
}
|
||||
|
||||
blueprint.ParentId = container.Id;
|
||||
_contentService.SaveBlueprint(blueprint);
|
||||
_contentService.SaveBlueprint(blueprint, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1298,6 +1298,14 @@ namespace Umbraco.Extensions
|
||||
|
||||
#region Utilities
|
||||
|
||||
public static Sql<ISqlContext> AppendSubQuery(this Sql<ISqlContext> sql, Sql<ISqlContext> subQuery, string alias)
|
||||
{
|
||||
// Append the subquery as a derived table with an alias
|
||||
sql.Append("(").Append(subQuery.SQL, subQuery.Arguments).Append($") AS {alias}");
|
||||
|
||||
return sql;
|
||||
}
|
||||
|
||||
private static string[] GetColumns<TDto>(this Sql<ISqlContext> sql, string? tableAlias = null, string? referenceName = null, Expression<Func<TDto, object?>>[]? columnExpressions = null, bool withAlias = true, bool forInsert = false)
|
||||
{
|
||||
PocoData? pd = sql.SqlContext.PocoDataFactory.ForType(typeof (TDto));
|
||||
|
||||
@@ -145,6 +145,59 @@ internal class EntityRepository : RepositoryBase, IEntityRepositoryExtended
|
||||
return entity;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<IEntitySlim> GetSiblings(Guid objectType, Guid targetKey, int before, int after, Ordering ordering)
|
||||
{
|
||||
// Ideally we don't want to have to do a second query for the parent ID, but the siblings query is already messy enough
|
||||
// without us also having to do a nested query for the parent ID too.
|
||||
Sql<ISqlContext> parentIdQuery = Sql()
|
||||
.Select<NodeDto>(x => x.ParentId)
|
||||
.From<NodeDto>()
|
||||
.Where<NodeDto>(x => x.UniqueId == targetKey);
|
||||
var parentId = Database.ExecuteScalar<int>(parentIdQuery);
|
||||
|
||||
Sql<ISqlContext> orderingSql = Sql();
|
||||
ApplyOrdering(ref orderingSql, ordering);
|
||||
|
||||
// Get all children of the parent node which is not trashed, ordered by SortOrder, and assign each a row number.
|
||||
// These row numbers are important, we need them to select the "before" and "after" siblings of the target node.
|
||||
Sql<ISqlContext> rowNumberSql = Sql()
|
||||
.Select($"ROW_NUMBER() OVER ({orderingSql.SQL}) AS rn")
|
||||
.AndSelect<NodeDto>(n => n.UniqueId)
|
||||
.From<NodeDto>()
|
||||
.Where<NodeDto>(x => x.ParentId == parentId && x.Trashed == false);
|
||||
|
||||
// Find the specific row number of the target node.
|
||||
// We need this to determine the bounds of the row numbers to select.
|
||||
Sql<ISqlContext> targetRowSql = Sql()
|
||||
.Select("rn")
|
||||
.From().AppendSubQuery(rowNumberSql, "Target")
|
||||
.Where<NodeDto>(x => x.UniqueId == targetKey, "Target");
|
||||
|
||||
// We have to reuse the target row sql arguments, however, we also need to add the "before" and "after" values to the arguments.
|
||||
// If we try to do this directly in the params array it'll consider the initial argument array as a single argument.
|
||||
IEnumerable<object> beforeArguments = targetRowSql.Arguments.Concat([before]);
|
||||
IEnumerable<object> afterArguments = targetRowSql.Arguments.Concat([after]);
|
||||
|
||||
// Select the UniqueId of nodes which row number is within the specified range of the target node's row number.
|
||||
Sql<ISqlContext>? mainSql = Sql()
|
||||
.Select("UniqueId")
|
||||
.From().AppendSubQuery(rowNumberSql, "NumberedNodes")
|
||||
.Where($"rn >= ({targetRowSql.SQL}) - @3", beforeArguments.ToArray())
|
||||
.Where($"rn <= ({targetRowSql.SQL}) + @3", afterArguments.ToArray())
|
||||
.OrderBy("rn");
|
||||
|
||||
List<Guid>? keys = Database.Fetch<Guid>(mainSql);
|
||||
|
||||
if (keys is null || keys.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return PerformGetAll(objectType, ordering, sql => sql.WhereIn<NodeDto>(x => x.UniqueId, keys));
|
||||
}
|
||||
|
||||
|
||||
public IEntitySlim? Get(Guid key, Guid objectTypeId)
|
||||
{
|
||||
var isContent = objectTypeId == Constants.ObjectTypes.Document ||
|
||||
@@ -216,6 +269,20 @@ internal class EntityRepository : RepositoryBase, IEntityRepositoryExtended
|
||||
return GetEntities(sql, isContent, isMedia, isMember);
|
||||
}
|
||||
|
||||
private IEnumerable<IEntitySlim> PerformGetAll(
|
||||
Guid objectType,
|
||||
Ordering ordering,
|
||||
Action<Sql<ISqlContext>>? filter = null)
|
||||
{
|
||||
var isContent = objectType == Constants.ObjectTypes.Document ||
|
||||
objectType == Constants.ObjectTypes.DocumentBlueprint;
|
||||
var isMedia = objectType == Constants.ObjectTypes.Media;
|
||||
var isMember = objectType == Constants.ObjectTypes.Member;
|
||||
|
||||
Sql<ISqlContext> sql = GetFullSqlForEntityType(isContent, isMedia, isMember, objectType, ordering, filter);
|
||||
return GetEntities(sql, isContent, isMedia, isMember);
|
||||
}
|
||||
|
||||
public IEnumerable<TreeEntityPath> GetAllPaths(Guid objectType, params int[]? ids) =>
|
||||
ids?.Any() ?? false
|
||||
? PerformGetAllPaths(objectType, sql => sql.WhereIn<NodeDto>(x => x.NodeId, ids.Distinct()))
|
||||
@@ -452,6 +519,21 @@ internal class EntityRepository : RepositoryBase, IEntityRepositoryExtended
|
||||
return AddGroupBy(isContent, isMedia, isMember, sql, true);
|
||||
}
|
||||
|
||||
protected Sql<ISqlContext> GetFullSqlForEntityType(
|
||||
bool isContent,
|
||||
bool isMedia,
|
||||
bool isMember,
|
||||
Guid objectType,
|
||||
Ordering ordering,
|
||||
Action<Sql<ISqlContext>>? filter)
|
||||
{
|
||||
Sql<ISqlContext> sql = GetBaseWhere(isContent, isMedia, isMember, false, filter, new[] { objectType });
|
||||
AddGroupBy(isContent, isMedia, isMember, sql, false);
|
||||
ApplyOrdering(ref sql, ordering);
|
||||
|
||||
return sql;
|
||||
}
|
||||
|
||||
protected Sql<ISqlContext> GetBase(bool isContent, bool isMedia, bool isMember, Action<Sql<ISqlContext>>? filter, bool isCount = false)
|
||||
=> GetBase(isContent, isMedia, isMember, filter, [], isCount);
|
||||
|
||||
|
||||
@@ -57,10 +57,17 @@ internal class ExternalLoginRepository : EntityRepositoryBase<int, IIdentityUser
|
||||
Database.Delete<ExternalLoginDto>("WHERE userOrMemberKey=@userOrMemberKey", new { userOrMemberKey });
|
||||
|
||||
/// <inheritdoc />
|
||||
public void DeleteUserLoginsForRemovedProviders(IEnumerable<string> currentLoginProviders) =>
|
||||
Database.Execute(Sql()
|
||||
.Delete<ExternalLoginDto>()
|
||||
.WhereNotIn<ExternalLoginDto>(x => x.LoginProvider, currentLoginProviders));
|
||||
public void DeleteUserLoginsForRemovedProviders(IEnumerable<string> currentLoginProviders)
|
||||
{
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<ExternalLoginDto>(x => x.Id)
|
||||
.From<ExternalLoginDto>()
|
||||
.Where<ExternalLoginDto>(x => !x.LoginProvider.StartsWith(Constants.Security.MemberExternalAuthenticationTypePrefix)) // Only remove external logins relating to backoffice users, not members.
|
||||
.WhereNotIn<ExternalLoginDto>(x => x.LoginProvider, currentLoginProviders);
|
||||
|
||||
var toDelete = Database.Query<ExternalLoginDto>(sql).Select(x => x.Id).ToList();
|
||||
DeleteExternalLogins(toDelete);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Save(Guid userOrMemberKey, IEnumerable<IExternalLogin> logins)
|
||||
@@ -100,13 +107,7 @@ internal class ExternalLoginRepository : EntityRepositoryBase<int, IIdentityUser
|
||||
}
|
||||
|
||||
// do the deletes, updates and inserts
|
||||
if (toDelete.Count > 0)
|
||||
{
|
||||
// Before we can remove the external login, we must remove the external login tokens associated with that external login,
|
||||
// otherwise we'll get foreign key constraint errors
|
||||
Database.DeleteMany<ExternalLoginTokenDto>().Where(x => toDelete.Contains(x.ExternalLoginId)).Execute();
|
||||
Database.DeleteMany<ExternalLoginDto>().Where(x => toDelete.Contains(x.Id)).Execute();
|
||||
}
|
||||
DeleteExternalLogins(toDelete);
|
||||
|
||||
foreach (KeyValuePair<int, IExternalLogin> u in toUpdate)
|
||||
{
|
||||
@@ -116,6 +117,19 @@ internal class ExternalLoginRepository : EntityRepositoryBase<int, IIdentityUser
|
||||
Database.InsertBulk(toInsert.Select(i => ExternalLoginFactory.BuildDto(userOrMemberKey, i)));
|
||||
}
|
||||
|
||||
private void DeleteExternalLogins(List<int> externalLoginIds)
|
||||
{
|
||||
if (externalLoginIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Before we can remove the external login, we must remove the external login tokens associated with that external login,
|
||||
// otherwise we'll get foreign key constraint errors
|
||||
Database.DeleteMany<ExternalLoginTokenDto>().Where(x => externalLoginIds.Contains(x.ExternalLoginId)).Execute();
|
||||
Database.DeleteMany<ExternalLoginDto>().Where(x => externalLoginIds.Contains(x.Id)).Execute();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Save(Guid userOrMemberKey, IEnumerable<IExternalLoginToken> tokens)
|
||||
{
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
@@ -16,10 +14,16 @@ using Umbraco.Cms.Core.Strings;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an abstract base class for property value editors based on block editors.
|
||||
/// </summary>
|
||||
public abstract class BlockEditorPropertyValueEditor<TValue, TLayout> : BlockValuePropertyValueEditorBase<TValue, TLayout>
|
||||
where TValue : BlockValue<TLayout>, new()
|
||||
where TLayout : class, IBlockLayoutItem, new()
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BlockEditorPropertyValueEditor{TValue, TLayout}"/> class.
|
||||
/// </summary>
|
||||
protected BlockEditorPropertyValueEditor(
|
||||
PropertyEditorCollection propertyEditors,
|
||||
DataValueReferenceFactoryCollection dataValueReferenceFactories,
|
||||
@@ -62,13 +66,7 @@ public abstract class BlockEditorPropertyValueEditor<TValue, TLayout> : BlockVal
|
||||
return BlockEditorValues.DeserializeAndClean(rawJson)?.BlockValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensure that sub-editor values are translated through their ToEditor methods
|
||||
/// </summary>
|
||||
/// <param name="property"></param>
|
||||
/// <param name="culture"></param>
|
||||
/// <param name="segment"></param>
|
||||
/// <returns></returns>
|
||||
/// <inheritdoc />
|
||||
public override object ToEditor(IProperty property, string? culture = null, string? segment = null)
|
||||
{
|
||||
var val = property.GetValue(culture, segment);
|
||||
@@ -95,38 +93,48 @@ public abstract class BlockEditorPropertyValueEditor<TValue, TLayout> : BlockVal
|
||||
return blockEditorData.BlockValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensure that sub-editor values are translated through their FromEditor methods
|
||||
/// </summary>
|
||||
/// <param name="editorValue"></param>
|
||||
/// <param name="currentValue"></param>
|
||||
/// <returns></returns>
|
||||
/// <inheritdoc />
|
||||
public override object? FromEditor(ContentPropertyData editorValue, object? currentValue)
|
||||
{
|
||||
if (editorValue.Value == null || string.IsNullOrWhiteSpace(editorValue.Value.ToString()))
|
||||
// Note: we can't early return here if editorValue is null or empty, because these is the following case:
|
||||
// - current value not null (which means doc has at least one element in block list)
|
||||
// - editor value (new value) is null (which means doc has no elements in block list)
|
||||
// If we check editor value for null value and return before MapBlockValueFromEditor, then we will not trigger updates for properties.
|
||||
// For most of the properties this is fine, but for properties which contain other state it might be critical (e.g. file upload field).
|
||||
// So, we must run MapBlockValueFromEditor even if editorValue is null or string.IsNullOrWhiteSpace(editorValue.Value.ToString()) is true.
|
||||
|
||||
BlockEditorData<TValue, TLayout>? currentBlockEditorData = GetBlockEditorData(currentValue);
|
||||
BlockEditorData<TValue, TLayout>? blockEditorData = GetBlockEditorData(editorValue.Value);
|
||||
|
||||
// We can skip MapBlockValueFromEditor if both editorValue and currentValue values are empty.
|
||||
if (IsBlockEditorDataEmpty(currentBlockEditorData) && IsBlockEditorDataEmpty(blockEditorData))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
BlockEditorData<TValue, TLayout>? blockEditorData;
|
||||
MapBlockValueFromEditor(blockEditorData?.BlockValue, currentBlockEditorData?.BlockValue, editorValue.ContentKey);
|
||||
|
||||
if (IsBlockEditorDataEmpty(blockEditorData))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(blockEditorData.BlockValue);
|
||||
}
|
||||
|
||||
private BlockEditorData<TValue, TLayout>? GetBlockEditorData(object? value)
|
||||
{
|
||||
try
|
||||
{
|
||||
blockEditorData = BlockEditorValues.DeserializeAndClean(editorValue.Value);
|
||||
return BlockEditorValues.DeserializeAndClean(value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// if this occurs it means the data is invalid, shouldn't happen but has happened if we change the data format.
|
||||
return string.Empty;
|
||||
// If this occurs it means the data is invalid. It shouldn't happen could if we change the data format.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (blockEditorData == null || blockEditorData.BlockValue.ContentData.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
MapBlockValueFromEditor(blockEditorData.BlockValue);
|
||||
|
||||
// return json
|
||||
return JsonSerializer.Serialize(blockEditorData.BlockValue);
|
||||
}
|
||||
private static bool IsBlockEditorDataEmpty([NotNullWhen(false)] BlockEditorData<TValue, TLayout>? editorData)
|
||||
=> editorData is null || editorData.BlockValue.ContentData.Count == 0;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Umbraco.Cms.Core.Cache.PropertyEditors;
|
||||
using Umbraco.Cms.Core.Cache.PropertyEditors;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Models.Validation;
|
||||
@@ -88,7 +88,13 @@ public abstract class BlockEditorValidatorBase<TValue, TLayout> : ComplexEditorV
|
||||
|
||||
foreach (var group in itemDataGroups)
|
||||
{
|
||||
var allElementTypes = _elementTypeCache.GetMany(group.Items.Select(x => x.ContentTypeKey).ToArray()).ToDictionary(x => x.Key);
|
||||
Guid[] elementTypeKeys = group.Items.Select(x => x.ContentTypeKey).ToArray();
|
||||
if (elementTypeKeys.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var allElementTypes = _elementTypeCache.GetMany(elementTypeKeys).ToDictionary(x => x.Key);
|
||||
|
||||
for (var i = 0; i < group.Items.Length; i++)
|
||||
{
|
||||
|
||||
@@ -129,10 +129,115 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
|
||||
return result;
|
||||
}
|
||||
|
||||
protected void MapBlockValueFromEditor(TValue blockValue)
|
||||
[Obsolete("This method is no longer used within Umbraco. Please use the overload taking all parameters. Scheduled for removal in Umbraco 17.")]
|
||||
protected void MapBlockValueFromEditor(TValue blockValue) => MapBlockValueFromEditor(blockValue, null, Guid.Empty);
|
||||
|
||||
protected void MapBlockValueFromEditor(TValue? editedBlockValue, TValue? currentBlockValue, Guid contentKey)
|
||||
{
|
||||
MapBlockItemDataFromEditor(blockValue.ContentData);
|
||||
MapBlockItemDataFromEditor(blockValue.SettingsData);
|
||||
MapBlockItemDataFromEditor(
|
||||
editedBlockValue?.ContentData ?? [],
|
||||
currentBlockValue?.ContentData ?? [],
|
||||
contentKey);
|
||||
|
||||
MapBlockItemDataFromEditor(
|
||||
editedBlockValue?.SettingsData ?? [],
|
||||
currentBlockValue?.SettingsData ?? [],
|
||||
contentKey);
|
||||
}
|
||||
|
||||
private void MapBlockItemDataFromEditor(List<BlockItemData> editedItems, List<BlockItemData> currentItems, Guid contentKey)
|
||||
{
|
||||
// Create mapping between edited and current block items.
|
||||
IEnumerable<BlockStateMapping<BlockItemData>> itemsMapping = GetBlockStatesMapping(editedItems, currentItems, (mapping, current) => mapping.Edited?.Key == current.Key);
|
||||
|
||||
foreach (BlockStateMapping<BlockItemData> itemMapping in itemsMapping)
|
||||
{
|
||||
// Create mapping between edited and current block item values.
|
||||
IEnumerable<BlockStateMapping<BlockPropertyValue>> valuesMapping = GetBlockStatesMapping(itemMapping.Edited?.Values, itemMapping.Current?.Values, (mapping, current) => mapping.Edited?.Alias == current.Alias);
|
||||
|
||||
foreach (BlockStateMapping<BlockPropertyValue> valueMapping in valuesMapping)
|
||||
{
|
||||
BlockPropertyValue? editedValue = valueMapping.Edited;
|
||||
BlockPropertyValue? currentValue = valueMapping.Current;
|
||||
|
||||
IPropertyType propertyType = editedValue?.PropertyType
|
||||
?? currentValue?.PropertyType
|
||||
?? throw new ArgumentException("One or more block properties did not have a resolved property type. Block editor values must be resolved before attempting to map them from editor.", nameof(editedItems));
|
||||
|
||||
// Lookup the property editor.
|
||||
IDataEditor? propertyEditor = _propertyEditors[propertyType.PropertyEditorAlias];
|
||||
if (propertyEditor is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch the property types prevalue.
|
||||
var configuration = _dataTypeConfigurationCache.GetConfiguration(propertyType.DataTypeKey);
|
||||
|
||||
// Create a real content property data object.
|
||||
var propertyData = new ContentPropertyData(editedValue?.Value, configuration)
|
||||
{
|
||||
ContentKey = contentKey,
|
||||
PropertyTypeKey = propertyType.Key,
|
||||
};
|
||||
|
||||
// Get the property editor to do it's conversion.
|
||||
IDataValueEditor valueEditor = propertyEditor.GetValueEditor();
|
||||
var newValue = valueEditor.FromEditor(propertyData, currentValue?.Value);
|
||||
|
||||
// Update the raw value since this is what will get serialized out.
|
||||
if (editedValue != null)
|
||||
{
|
||||
editedValue.Value = newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockStateMapping<T>
|
||||
{
|
||||
public T? Edited { get; set; }
|
||||
|
||||
public T? Current { get; set; }
|
||||
}
|
||||
|
||||
private static IEnumerable<BlockStateMapping<T>> GetBlockStatesMapping<T>(IList<T>? editedItems, IList<T>? currentItems, Func<BlockStateMapping<T>, T, bool> condition)
|
||||
{
|
||||
// filling with edited items first
|
||||
List<BlockStateMapping<T>> mapping = editedItems?
|
||||
.Select(editedItem => new BlockStateMapping<T>
|
||||
{
|
||||
Current = default,
|
||||
Edited = editedItem,
|
||||
})
|
||||
.ToList()
|
||||
?? [];
|
||||
|
||||
if (currentItems is null)
|
||||
{
|
||||
return mapping;
|
||||
}
|
||||
|
||||
// then adding current items
|
||||
foreach (T currentItem in currentItems)
|
||||
{
|
||||
BlockStateMapping<T>? mappingItem = mapping.FirstOrDefault(x => condition(x, currentItem));
|
||||
|
||||
if (mappingItem == null) // if there is no edited item, then adding just current
|
||||
{
|
||||
mapping.Add(new BlockStateMapping<T>
|
||||
{
|
||||
Current = currentItem,
|
||||
Edited = default,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
mappingItem.Current = currentItem;
|
||||
}
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
protected void MapBlockValueToEditor(IProperty property, TValue blockValue, string? culture, string? segment)
|
||||
@@ -197,40 +302,6 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
|
||||
}
|
||||
}
|
||||
|
||||
private void MapBlockItemDataFromEditor(List<BlockItemData> items)
|
||||
{
|
||||
foreach (BlockItemData item in items)
|
||||
{
|
||||
foreach (BlockPropertyValue blockPropertyValue in item.Values)
|
||||
{
|
||||
IPropertyType? propertyType = blockPropertyValue.PropertyType;
|
||||
if (propertyType is null)
|
||||
{
|
||||
throw new ArgumentException("One or more block properties did not have a resolved property type. Block editor values must be resolved before attempting to map them from editor.", nameof(items));
|
||||
}
|
||||
|
||||
// Lookup the property editor
|
||||
IDataEditor? propertyEditor = _propertyEditors[propertyType.PropertyEditorAlias];
|
||||
if (propertyEditor is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch the property types prevalue
|
||||
var configuration = _dataTypeConfigurationCache.GetConfiguration(propertyType.DataTypeKey);
|
||||
|
||||
// Create a fake content property data object
|
||||
var propertyData = new ContentPropertyData(blockPropertyValue.Value, configuration);
|
||||
|
||||
// Get the property editor to do it's conversion
|
||||
var newValue = propertyEditor.GetValueEditor().FromEditor(propertyData, blockPropertyValue.Value);
|
||||
|
||||
// update the raw value since this is what will get serialized out
|
||||
blockPropertyValue.Value = newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the invariant data in the source with the invariant data in the value if allowed
|
||||
/// </summary>
|
||||
|
||||
@@ -10,10 +10,16 @@ using Umbraco.Cms.Core.Media;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
|
||||
// TODO (V17):
|
||||
// - Remove the implementation of INotificationHandler as these have all been refactored out into sepate notification handler classes.
|
||||
// - Remove the unused parameters from the constructor.
|
||||
|
||||
/// <summary>
|
||||
/// Defines the file upload property editor.
|
||||
/// </summary>
|
||||
[DataEditor(
|
||||
Constants.PropertyEditors.Aliases.UploadField,
|
||||
ValueEditorIsReusable = true)]
|
||||
@@ -22,12 +28,11 @@ public class FileUploadPropertyEditor : DataEditor, IMediaUrlGenerator,
|
||||
INotificationHandler<MediaDeletedNotification>, INotificationHandler<MediaSavingNotification>,
|
||||
INotificationHandler<MemberDeletedNotification>
|
||||
{
|
||||
private readonly IContentService _contentService;
|
||||
private readonly IOptionsMonitor<ContentSettings> _contentSettings;
|
||||
private readonly IIOHelper _ioHelper;
|
||||
private readonly MediaFileManager _mediaFileManager;
|
||||
private readonly UploadAutoFillProperties _uploadAutoFillProperties;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileUploadPropertyEditor"/> class.
|
||||
/// </summary>
|
||||
public FileUploadPropertyEditor(
|
||||
IDataValueEditorFactory dataValueEditorFactory,
|
||||
MediaFileManager mediaFileManager,
|
||||
@@ -37,14 +42,11 @@ public class FileUploadPropertyEditor : DataEditor, IMediaUrlGenerator,
|
||||
IIOHelper ioHelper)
|
||||
: base(dataValueEditorFactory)
|
||||
{
|
||||
_mediaFileManager = mediaFileManager ?? throw new ArgumentNullException(nameof(mediaFileManager));
|
||||
_contentSettings = contentSettings;
|
||||
_uploadAutoFillProperties = uploadAutoFillProperties;
|
||||
_contentService = contentService;
|
||||
_ioHelper = ioHelper;
|
||||
SupportsReadOnly = true;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool TryGetMediaPath(string? propertyEditorAlias, object? value, [MaybeNullWhen(false)] out string mediaPath)
|
||||
{
|
||||
if (propertyEditorAlias == Alias &&
|
||||
@@ -59,53 +61,6 @@ public class FileUploadPropertyEditor : DataEditor, IMediaUrlGenerator,
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Handle(ContentCopiedNotification notification)
|
||||
{
|
||||
// get the upload field properties with a value
|
||||
IEnumerable<IProperty> properties = notification.Original.Properties.Where(IsUploadField);
|
||||
|
||||
// copy files
|
||||
var isUpdated = false;
|
||||
foreach (IProperty property in properties)
|
||||
{
|
||||
// copy each of the property values (variants, segments) to the destination
|
||||
foreach (IPropertyValue propertyValue in property.Values)
|
||||
{
|
||||
var propVal = property.GetValue(propertyValue.Culture, propertyValue.Segment);
|
||||
if (propVal == null || !(propVal is string str) || str.IsNullOrWhiteSpace())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var sourcePath = _mediaFileManager.FileSystem.GetRelativePath(str);
|
||||
var copyPath = _mediaFileManager.CopyFile(notification.Copy, property.PropertyType, sourcePath);
|
||||
notification.Copy.SetValue(property.Alias, _mediaFileManager.FileSystem.GetUrl(copyPath),
|
||||
propertyValue.Culture, propertyValue.Segment);
|
||||
isUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// if updated, re-save the copy with the updated value
|
||||
if (isUpdated)
|
||||
{
|
||||
_contentService.Save(notification.Copy);
|
||||
}
|
||||
}
|
||||
|
||||
public void Handle(ContentDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
|
||||
|
||||
public void Handle(MediaDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
|
||||
|
||||
public void Handle(MediaSavingNotification notification)
|
||||
{
|
||||
foreach (IMedia entity in notification.SavedEntities)
|
||||
{
|
||||
AutoFillProperties(entity);
|
||||
}
|
||||
}
|
||||
|
||||
public void Handle(MemberDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IConfigurationEditor CreateConfigurationEditor() =>
|
||||
new FileUploadConfigurationEditor(_ioHelper);
|
||||
@@ -117,86 +72,43 @@ public class FileUploadPropertyEditor : DataEditor, IMediaUrlGenerator,
|
||||
protected override IDataValueEditor CreateValueEditor()
|
||||
=> DataValueEditorFactory.Create<FileUploadPropertyValueEditor>(Attribute!);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether a property is an upload field.
|
||||
/// </summary>
|
||||
/// <param name="property">The property.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the specified property is an upload field; otherwise, <c>false</c>.
|
||||
/// </returns>
|
||||
private static bool IsUploadField(IProperty property) => property.PropertyType.PropertyEditorAlias ==
|
||||
Constants.PropertyEditors.Aliases.UploadField;
|
||||
#region Obsolete notification handler notifications
|
||||
|
||||
/// <summary>
|
||||
/// The paths to all file upload property files contained within a collection of content entities
|
||||
/// </summary>
|
||||
/// <param name="entities"></param>
|
||||
private IEnumerable<string> ContainedFilePaths(IEnumerable<IContentBase> entities) => entities
|
||||
.SelectMany(x => x.Properties)
|
||||
.Where(IsUploadField)
|
||||
.SelectMany(GetFilePathsFromPropertyValues)
|
||||
.Distinct();
|
||||
|
||||
/// <summary>
|
||||
/// Look through all property values stored against the property and resolve any file paths stored
|
||||
/// </summary>
|
||||
/// <param name="prop"></param>
|
||||
/// <returns></returns>
|
||||
private IEnumerable<string> GetFilePathsFromPropertyValues(IProperty prop)
|
||||
/// <inheritdoc/>
|
||||
[Obsolete("This handler is no longer registered. Logic has been migrated to FileUploadContentCopiedNotificationHandler. Scheduled for removal in Umbraco 17.")]
|
||||
public void Handle(ContentCopiedNotification notification)
|
||||
{
|
||||
IReadOnlyCollection<IPropertyValue> propVals = prop.Values;
|
||||
foreach (IPropertyValue propertyValue in propVals)
|
||||
{
|
||||
// check if the published value contains data and return it
|
||||
var propVal = propertyValue.PublishedValue;
|
||||
if (propVal != null && propVal is string str1 && !str1.IsNullOrWhiteSpace())
|
||||
{
|
||||
yield return _mediaFileManager.FileSystem.GetRelativePath(str1);
|
||||
// This handler is no longer registered. Logic has been migrated to FileUploadContentCopiedNotificationHandler.
|
||||
}
|
||||
|
||||
// check if the edited value contains data and return it
|
||||
propVal = propertyValue.EditedValue;
|
||||
if (propVal != null && propVal is string str2 && !str2.IsNullOrWhiteSpace())
|
||||
/// <inheritdoc/>
|
||||
[Obsolete("This handler is no longer registered. Logic has been migrated to FileUploadMediaSavingNotificationHandler. Scheduled for removal in Umbraco 17.")]
|
||||
public void Handle(MediaSavingNotification notification)
|
||||
{
|
||||
yield return _mediaFileManager.FileSystem.GetRelativePath(str2);
|
||||
}
|
||||
}
|
||||
// This handler is no longer registered. Logic has been migrated to FileUploadMediaSavingNotificationHandler.
|
||||
}
|
||||
|
||||
private void DeleteContainedFiles(IEnumerable<IContentBase> deletedEntities)
|
||||
/// <inheritdoc/>
|
||||
[Obsolete("This handler is no longer registered. Logic has been migrated to FileUploadContentDeletedNotificationHandler. Scheduled for removal in Umbraco 17.")]
|
||||
public void Handle(ContentDeletedNotification notification)
|
||||
{
|
||||
IEnumerable<string> filePathsToDelete = ContainedFilePaths(deletedEntities);
|
||||
_mediaFileManager.DeleteMediaFiles(filePathsToDelete);
|
||||
// This handler is no longer registered. Logic has been migrated to FileUploadContentDeletedNotificationHandler.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-fill properties (or clear).
|
||||
/// </summary>
|
||||
private void AutoFillProperties(IContentBase model)
|
||||
{
|
||||
IEnumerable<IProperty> properties = model.Properties.Where(IsUploadField);
|
||||
|
||||
foreach (IProperty property in properties)
|
||||
/// <inheritdoc/>
|
||||
[Obsolete("This handler is no longer registered. Logic has been migrated to FileUploadMediaDeletedNotificationHandler. Scheduled for removal in Umbraco 17.")]
|
||||
public void Handle(MediaDeletedNotification notification)
|
||||
{
|
||||
ImagingAutoFillUploadField? autoFillConfig = _contentSettings.CurrentValue.GetConfig(property.Alias);
|
||||
if (autoFillConfig == null)
|
||||
{
|
||||
continue;
|
||||
// This handler is no longer registered. Logic has been migrated to FileUploadMediaDeletedNotificationHandler.
|
||||
}
|
||||
|
||||
foreach (IPropertyValue pvalue in property.Values)
|
||||
/// <inheritdoc/>
|
||||
[Obsolete("This handler is no longer registered. Logic has been migrated to FileUploadMemberDeletedNotificationHandler. Scheduled for removal in Umbraco 17.")]
|
||||
public void Handle(MemberDeletedNotification notification)
|
||||
{
|
||||
var svalue = property.GetValue(pvalue.Culture, pvalue.Segment) as string;
|
||||
if (string.IsNullOrWhiteSpace(svalue))
|
||||
{
|
||||
_uploadAutoFillProperties.Reset(model, autoFillConfig, pvalue.Culture, pvalue.Segment);
|
||||
}
|
||||
else
|
||||
{
|
||||
_uploadAutoFillProperties.Populate(model, autoFillConfig,
|
||||
_mediaFileManager.FileSystem.GetRelativePath(svalue), pvalue.Culture, pvalue.Segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
// This handler is no longer registered. Logic has been migrated to FileUploadMemberDeletedNotificationHandler.
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Strings;
|
||||
using Umbraco.Cms.Infrastructure.PropertyEditors;
|
||||
using Umbraco.Cms.Infrastructure.Scoping;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
@@ -23,12 +24,15 @@ namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
internal class FileUploadPropertyValueEditor : DataValueEditor
|
||||
{
|
||||
private readonly MediaFileManager _mediaFileManager;
|
||||
private readonly IJsonSerializer _jsonSerializer;
|
||||
private readonly ITemporaryFileService _temporaryFileService;
|
||||
private readonly IScopeProvider _scopeProvider;
|
||||
private readonly IFileStreamSecurityValidator _fileStreamSecurityValidator;
|
||||
private readonly FileUploadValueParser _valueParser;
|
||||
private ContentSettings _contentSettings;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileUploadPropertyValueEditor"/> class.
|
||||
/// </summary>
|
||||
public FileUploadPropertyValueEditor(
|
||||
DataEditorAttribute attribute,
|
||||
MediaFileManager mediaFileManager,
|
||||
@@ -42,10 +46,11 @@ internal class FileUploadPropertyValueEditor : DataValueEditor
|
||||
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
|
||||
{
|
||||
_mediaFileManager = mediaFileManager ?? throw new ArgumentNullException(nameof(mediaFileManager));
|
||||
_jsonSerializer = jsonSerializer;
|
||||
_temporaryFileService = temporaryFileService;
|
||||
_scopeProvider = scopeProvider;
|
||||
_fileStreamSecurityValidator = fileStreamSecurityValidator;
|
||||
_valueParser = new FileUploadValueParser(jsonSerializer);
|
||||
|
||||
_contentSettings = contentSettings.CurrentValue ?? throw new ArgumentNullException(nameof(contentSettings));
|
||||
contentSettings.OnChange(x => _contentSettings = x);
|
||||
|
||||
@@ -56,6 +61,7 @@ internal class FileUploadPropertyValueEditor : DataValueEditor
|
||||
IsAllowedInDataTypeConfiguration));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? ToEditor(IProperty property, string? culture = null, string? segment = null)
|
||||
{
|
||||
// the stored property value (if any) is the path to the file; convert it to the client model (FileUploadValue)
|
||||
@@ -63,11 +69,12 @@ internal class FileUploadPropertyValueEditor : DataValueEditor
|
||||
return propertyValue is string stringValue
|
||||
? new FileUploadValue
|
||||
{
|
||||
Src = stringValue
|
||||
Src = stringValue,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <summary>
|
||||
/// Converts the client model (FileUploadValue) into the value can be stored in the database (the file path).
|
||||
/// </summary>
|
||||
@@ -83,12 +90,12 @@ internal class FileUploadPropertyValueEditor : DataValueEditor
|
||||
/// </remarks>
|
||||
public override object? FromEditor(ContentPropertyData editorValue, object? currentValue)
|
||||
{
|
||||
FileUploadValue? editorModelValue = ParseFileUploadValue(editorValue.Value);
|
||||
FileUploadValue? editorModelValue = _valueParser.Parse(editorValue.Value);
|
||||
|
||||
// no change?
|
||||
// No change or created from blueprint.
|
||||
if (editorModelValue?.TemporaryFileId.HasValue is not true && string.IsNullOrEmpty(editorModelValue?.Src) is false)
|
||||
{
|
||||
return currentValue;
|
||||
return editorModelValue.Src;
|
||||
}
|
||||
|
||||
// the current editor value (if any) is the path to the file
|
||||
@@ -146,28 +153,8 @@ internal class FileUploadPropertyValueEditor : DataValueEditor
|
||||
return filepath is null ? null : _mediaFileManager.FileSystem.GetUrl(filepath);
|
||||
}
|
||||
|
||||
private FileUploadValue? ParseFileUploadValue(object? editorValue)
|
||||
{
|
||||
if (editorValue is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (editorValue is string sourceString && sourceString.DetectIsJson() is false)
|
||||
{
|
||||
return new FileUploadValue()
|
||||
{
|
||||
Src = sourceString
|
||||
};
|
||||
}
|
||||
|
||||
return _jsonSerializer.TryDeserialize(editorValue, out FileUploadValue? modelValue)
|
||||
? modelValue
|
||||
: throw new ArgumentException($"Could not parse editor value to a {nameof(FileUploadValue)} object.");
|
||||
}
|
||||
|
||||
private Guid? TryParseTemporaryFileKey(object? editorValue)
|
||||
=> ParseFileUploadValue(editorValue)?.TemporaryFileId;
|
||||
=> _valueParser.Parse(editorValue)?.TemporaryFileId;
|
||||
|
||||
private TemporaryFileModel? TryGetTemporaryFile(Guid temporaryFileKey)
|
||||
=> _temporaryFileService.GetAsync(temporaryFileKey).GetAwaiter().GetResult();
|
||||
@@ -199,8 +186,7 @@ internal class FileUploadPropertyValueEditor : DataValueEditor
|
||||
}
|
||||
|
||||
// get the filepath
|
||||
// in case we are using the old path scheme, try to re-use numbers (bah...)
|
||||
var filepath = _mediaFileManager.GetMediaPath(file.FileName, contentKey, propertyTypeKey); // fs-relative path
|
||||
string filepath = GetMediaPath(file, dataTypeConfiguration, contentKey, propertyTypeKey);
|
||||
|
||||
using (Stream filestream = file.OpenReadStream())
|
||||
{
|
||||
@@ -211,9 +197,19 @@ internal class FileUploadPropertyValueEditor : DataValueEditor
|
||||
|
||||
// TODO: Here it would make sense to do the auto-fill properties stuff but the API doesn't allow us to do that right
|
||||
// since we'd need to be able to return values for other properties from these methods
|
||||
_mediaFileManager.FileSystem.AddFile(filepath, filestream, true); // must overwrite!
|
||||
_mediaFileManager.FileSystem.AddFile(filepath, filestream, overrideIfExists: true); // must overwrite!
|
||||
}
|
||||
|
||||
return filepath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides media path.
|
||||
/// </summary>
|
||||
/// <returns>File system relative path</returns>
|
||||
protected virtual string GetMediaPath(TemporaryFileModel file, object? dataTypeConfiguration, Guid contentKey, Guid propertyTypeKey)
|
||||
{
|
||||
// in case we are using the old path scheme, try to re-use numbers (bah...)
|
||||
return _mediaFileManager.GetMediaPath(file.FileName, contentKey, propertyTypeKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.PropertyEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the parsing of raw values to <see cref="FileUploadValue"/> objects.
|
||||
/// </summary>
|
||||
internal sealed class FileUploadValueParser
|
||||
{
|
||||
private readonly IJsonSerializer _jsonSerializer;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileUploadValueParser"/> class.
|
||||
/// </summary>
|
||||
/// <param name="jsonSerializer"></param>
|
||||
public FileUploadValueParser(IJsonSerializer jsonSerializer) => _jsonSerializer = jsonSerializer;
|
||||
|
||||
/// <summary>
|
||||
/// Parses raw value to a <see cref="FileUploadValue"/>.
|
||||
/// </summary>
|
||||
/// <param name="editorValue">The editor value.</param>
|
||||
/// <returns><a cref="FileUploadValue"></a> value</returns>
|
||||
/// <exception cref="ArgumentException"></exception>
|
||||
public FileUploadValue? Parse(object? editorValue)
|
||||
{
|
||||
if (editorValue is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (editorValue is string sourceString && sourceString.DetectIsJson() is false)
|
||||
{
|
||||
return new FileUploadValue()
|
||||
{
|
||||
Src = sourceString,
|
||||
};
|
||||
}
|
||||
|
||||
return _jsonSerializer.TryDeserialize(editorValue, out FileUploadValue? modelValue)
|
||||
? modelValue
|
||||
: throw new ArgumentException($"Could not parse editor value to a {nameof(FileUploadValue)} object.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache.PropertyEditors;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.PropertyEditors;
|
||||
using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers;
|
||||
|
||||
/// <summary>
|
||||
/// Implements a notification handler that processes file uploads when content is copied or scaffolded from a blueprint, making
|
||||
/// sure the new content references a new instance of the file.
|
||||
/// </summary>
|
||||
internal sealed class FileUploadContentCopiedOrScaffoldedNotificationHandler : FileUploadNotificationHandlerBase,
|
||||
INotificationHandler<ContentCopiedNotification>,
|
||||
INotificationHandler<ContentScaffoldedNotification>,
|
||||
INotificationHandler<ContentSavedBlueprintNotification>
|
||||
{
|
||||
private readonly IContentService _contentService;
|
||||
private readonly BlockEditorValues<BlockListValue, BlockListLayoutItem> _blockListEditorValues;
|
||||
private readonly BlockEditorValues<BlockGridValue, BlockGridLayoutItem> _blockGridEditorValues;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileUploadContentCopiedOrScaffoldedNotificationHandler"/> class.
|
||||
/// </summary>
|
||||
public FileUploadContentCopiedOrScaffoldedNotificationHandler(
|
||||
IJsonSerializer jsonSerializer,
|
||||
MediaFileManager mediaFileManager,
|
||||
IBlockEditorElementTypeCache elementTypeCache,
|
||||
ILogger<FileUploadContentCopiedOrScaffoldedNotificationHandler> logger,
|
||||
IContentService contentService)
|
||||
: base(jsonSerializer, mediaFileManager, elementTypeCache)
|
||||
{
|
||||
_blockListEditorValues = new(new BlockListEditorDataConverter(jsonSerializer), elementTypeCache, logger);
|
||||
_blockGridEditorValues = new(new BlockGridEditorDataConverter(jsonSerializer), elementTypeCache, logger);
|
||||
_contentService = contentService;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Handle(ContentCopiedNotification notification) => Handle(notification.Original, notification.Copy, (IContent c) => _contentService.Save(c));
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Handle(ContentScaffoldedNotification notification) => Handle(notification.Original, notification.Scaffold);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Handle(ContentSavedBlueprintNotification notification)
|
||||
{
|
||||
if (notification.CreatedFromContent is null)
|
||||
{
|
||||
// If there is no original content, we don't need to copy files.
|
||||
return;
|
||||
}
|
||||
|
||||
Handle(notification.CreatedFromContent, notification.SavedBlueprint, (IContent c) => _contentService.SaveBlueprint(c, null));
|
||||
}
|
||||
|
||||
private void Handle(IContent source, IContent destination, Action<IContent>? postUpdateAction = null)
|
||||
{
|
||||
var isUpdated = false;
|
||||
|
||||
foreach (IProperty property in source.Properties)
|
||||
{
|
||||
if (IsUploadFieldPropertyType(property.PropertyType))
|
||||
{
|
||||
isUpdated |= UpdateUploadFieldProperty(destination, property);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsBlockListPropertyType(property.PropertyType))
|
||||
{
|
||||
isUpdated |= UpdateBlockProperty(destination, property, _blockListEditorValues);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsBlockGridPropertyType(property.PropertyType))
|
||||
{
|
||||
isUpdated |= UpdateBlockProperty(destination, property, _blockGridEditorValues);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsRichTextPropertyType(property.PropertyType))
|
||||
{
|
||||
isUpdated |= UpdateRichTextProperty(destination, property);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If updated, re-save the destination with the updated value.
|
||||
if (isUpdated && postUpdateAction is not null)
|
||||
{
|
||||
postUpdateAction(destination);
|
||||
}
|
||||
}
|
||||
|
||||
private bool UpdateUploadFieldProperty(IContent content, IProperty property)
|
||||
{
|
||||
var isUpdated = false;
|
||||
|
||||
// Copy each of the property values (variants, segments) to the destination.
|
||||
foreach (IPropertyValue propertyValue in property.Values)
|
||||
{
|
||||
var propVal = property.GetValue(propertyValue.Culture, propertyValue.Segment);
|
||||
if (propVal == null || propVal is not string sourceUrl || string.IsNullOrWhiteSpace(sourceUrl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var copyUrl = CopyFile(sourceUrl, content, property.PropertyType);
|
||||
|
||||
content.SetValue(property.Alias, copyUrl, propertyValue.Culture, propertyValue.Segment);
|
||||
|
||||
isUpdated = true;
|
||||
}
|
||||
|
||||
return isUpdated;
|
||||
}
|
||||
|
||||
private bool UpdateBlockProperty<TValue, TLayout>(IContent content, IProperty property, BlockEditorValues<TValue, TLayout> blockEditorValues)
|
||||
where TValue : BlockValue<TLayout>, new()
|
||||
where TLayout : class, IBlockLayoutItem, new()
|
||||
{
|
||||
var isUpdated = false;
|
||||
|
||||
foreach (IPropertyValue blockPropertyValue in property.Values)
|
||||
{
|
||||
var rawBlockPropertyValue = property.GetValue(blockPropertyValue.Culture, blockPropertyValue.Segment);
|
||||
|
||||
BlockEditorData<TValue, TLayout>? blockEditorData = GetBlockEditorData(rawBlockPropertyValue, blockEditorValues);
|
||||
|
||||
(bool hasUpdates, string? updatedValue) = UpdateBlockEditorData(content, blockEditorData);
|
||||
|
||||
if (hasUpdates)
|
||||
{
|
||||
content.SetValue(property.Alias, updatedValue, blockPropertyValue.Culture, blockPropertyValue.Segment);
|
||||
}
|
||||
|
||||
isUpdated |= hasUpdates;
|
||||
}
|
||||
|
||||
return isUpdated;
|
||||
}
|
||||
|
||||
private (bool, string?) UpdateBlockEditorData<TValue, TLayout>(IContent content, BlockEditorData<TValue, TLayout>? blockEditorData)
|
||||
where TValue : BlockValue<TLayout>, new()
|
||||
where TLayout : class, IBlockLayoutItem, new()
|
||||
{
|
||||
var isUpdated = false;
|
||||
|
||||
if (blockEditorData is null)
|
||||
{
|
||||
return (isUpdated, null);
|
||||
}
|
||||
|
||||
IEnumerable<BlockPropertyValue> blockPropertyValues = blockEditorData.BlockValue.ContentData
|
||||
.Concat(blockEditorData.BlockValue.SettingsData)
|
||||
.SelectMany(x => x.Values);
|
||||
|
||||
isUpdated = UpdateBlockPropertyValues(content, isUpdated, blockPropertyValues);
|
||||
|
||||
var updatedValue = JsonSerializer.Serialize(blockEditorData.BlockValue);
|
||||
|
||||
return (isUpdated, updatedValue);
|
||||
}
|
||||
|
||||
private bool UpdateRichTextProperty(IContent content, IProperty property)
|
||||
{
|
||||
var isUpdated = false;
|
||||
|
||||
foreach (IPropertyValue blockPropertyValue in property.Values)
|
||||
{
|
||||
var rawBlockPropertyValue = property.GetValue(blockPropertyValue.Culture, blockPropertyValue.Segment);
|
||||
|
||||
RichTextBlockValue? richTextBlockValue = GetRichTextBlockValue(rawBlockPropertyValue);
|
||||
|
||||
(bool hasUpdates, string? updatedValue) = UpdateBlockEditorData(content, richTextBlockValue);
|
||||
|
||||
if (hasUpdates && string.IsNullOrEmpty(updatedValue) is false)
|
||||
{
|
||||
RichTextEditorValue? richTextEditorValue = GetRichTextEditorValue(rawBlockPropertyValue);
|
||||
if (richTextEditorValue is not null)
|
||||
{
|
||||
richTextEditorValue.Blocks = JsonSerializer.Deserialize<RichTextBlockValue>(updatedValue);
|
||||
content.SetValue(property.Alias, JsonSerializer.Serialize(richTextEditorValue), blockPropertyValue.Culture, blockPropertyValue.Segment);
|
||||
}
|
||||
}
|
||||
|
||||
isUpdated |= hasUpdates;
|
||||
}
|
||||
|
||||
return isUpdated;
|
||||
}
|
||||
|
||||
private (bool, string?) UpdateBlockEditorData(IContent content, RichTextBlockValue? richTextBlockValue)
|
||||
{
|
||||
var isUpdated = false;
|
||||
|
||||
if (richTextBlockValue is null)
|
||||
{
|
||||
return (isUpdated, null);
|
||||
}
|
||||
|
||||
IEnumerable<BlockPropertyValue> blockPropertyValues = richTextBlockValue.ContentData
|
||||
.Concat(richTextBlockValue.SettingsData)
|
||||
.SelectMany(x => x.Values);
|
||||
|
||||
isUpdated = UpdateBlockPropertyValues(content, isUpdated, blockPropertyValues);
|
||||
|
||||
var updatedValue = JsonSerializer.Serialize(richTextBlockValue);
|
||||
|
||||
return (isUpdated, updatedValue);
|
||||
}
|
||||
|
||||
private bool UpdateBlockPropertyValues(IContent content, bool isUpdated, IEnumerable<BlockPropertyValue> blockPropertyValues)
|
||||
{
|
||||
foreach (BlockPropertyValue blockPropertyValue in blockPropertyValues)
|
||||
{
|
||||
if (blockPropertyValue.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
IPropertyType? propertyType = blockPropertyValue.PropertyType;
|
||||
|
||||
if (propertyType is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsUploadFieldPropertyType(propertyType))
|
||||
{
|
||||
isUpdated |= UpdateUploadFieldBlockPropertyValue(blockPropertyValue, content, propertyType);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsBlockListPropertyType(propertyType))
|
||||
{
|
||||
(bool hasUpdates, string? newValue) = UpdateBlockPropertyValue(blockPropertyValue, content, _blockListEditorValues);
|
||||
|
||||
isUpdated |= hasUpdates;
|
||||
|
||||
blockPropertyValue.Value = newValue;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsBlockGridPropertyType(propertyType))
|
||||
{
|
||||
(bool hasUpdates, string? newValue) = UpdateBlockPropertyValue(blockPropertyValue, content, _blockGridEditorValues);
|
||||
|
||||
isUpdated |= hasUpdates;
|
||||
|
||||
blockPropertyValue.Value = newValue;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsRichTextPropertyType(propertyType))
|
||||
{
|
||||
(bool hasUpdates, string? newValue) = UpdateRichTextPropertyValue(blockPropertyValue, content);
|
||||
|
||||
if (hasUpdates && string.IsNullOrEmpty(newValue) is false)
|
||||
{
|
||||
RichTextEditorValue? richTextEditorValue = GetRichTextEditorValue(blockPropertyValue.Value);
|
||||
if (richTextEditorValue is not null)
|
||||
{
|
||||
isUpdated |= hasUpdates;
|
||||
|
||||
richTextEditorValue.Blocks = JsonSerializer.Deserialize<RichTextBlockValue>(newValue);
|
||||
blockPropertyValue.Value = richTextEditorValue;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return isUpdated;
|
||||
}
|
||||
|
||||
private bool UpdateUploadFieldBlockPropertyValue(BlockPropertyValue blockItemDataValue, IContent content, IPropertyType propertyType)
|
||||
{
|
||||
FileUploadValue? fileUploadValue = FileUploadValueParser.Parse(blockItemDataValue.Value);
|
||||
|
||||
// if original value is empty, we do not need to copy file
|
||||
if (string.IsNullOrWhiteSpace(fileUploadValue?.Src))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var copyFileUrl = CopyFile(fileUploadValue.Src, content, propertyType);
|
||||
|
||||
blockItemDataValue.Value = copyFileUrl;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private (bool, string?) UpdateBlockPropertyValue<TValue, TLayout>(BlockPropertyValue blockItemDataValue, IContent content, BlockEditorValues<TValue, TLayout> blockEditorValues)
|
||||
where TValue : BlockValue<TLayout>, new()
|
||||
where TLayout : class, IBlockLayoutItem, new()
|
||||
{
|
||||
BlockEditorData<TValue, TLayout>? blockItemEditorDataValue = GetBlockEditorData(blockItemDataValue.Value, blockEditorValues);
|
||||
|
||||
return UpdateBlockEditorData(content, blockItemEditorDataValue);
|
||||
}
|
||||
|
||||
private (bool, string?) UpdateRichTextPropertyValue(BlockPropertyValue blockItemDataValue, IContent content)
|
||||
{
|
||||
RichTextBlockValue? richTextBlockValue = GetRichTextBlockValue(blockItemDataValue.Value);
|
||||
return UpdateBlockEditorData(content, richTextBlockValue);
|
||||
}
|
||||
|
||||
private string CopyFile(string sourceUrl, IContent destinationContent, IPropertyType propertyType)
|
||||
{
|
||||
var sourcePath = MediaFileManager.FileSystem.GetRelativePath(sourceUrl);
|
||||
var copyPath = MediaFileManager.CopyFile(destinationContent, propertyType, sourcePath);
|
||||
return MediaFileManager.FileSystem.GetUrl(copyPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache.PropertyEditors;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.PropertyEditors;
|
||||
using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Infrastructure.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers;
|
||||
|
||||
/// <summary>
|
||||
/// Provides base class for notification handler that processes file uploads when a content entity is deleted, removing associated files.
|
||||
/// </summary>
|
||||
internal sealed class FileUploadContentDeletedNotificationHandler : FileUploadNotificationHandlerBase,
|
||||
INotificationHandler<ContentDeletedNotification>,
|
||||
INotificationHandler<ContentDeletedBlueprintNotification>,
|
||||
INotificationHandler<MediaDeletedNotification>,
|
||||
INotificationHandler<MemberDeletedNotification>
|
||||
{
|
||||
private readonly BlockEditorValues<BlockListValue, BlockListLayoutItem> _blockListEditorValues;
|
||||
private readonly BlockEditorValues<BlockGridValue, BlockGridLayoutItem> _blockGridEditorValues;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileUploadContentDeletedNotificationHandler"/> class.
|
||||
/// </summary>
|
||||
public FileUploadContentDeletedNotificationHandler(
|
||||
IJsonSerializer jsonSerializer,
|
||||
MediaFileManager mediaFileManager,
|
||||
IBlockEditorElementTypeCache elementTypeCache,
|
||||
ILogger<FileUploadContentDeletedNotificationHandler> logger)
|
||||
: base(jsonSerializer, mediaFileManager, elementTypeCache)
|
||||
{
|
||||
_blockListEditorValues = new(new BlockListEditorDataConverter(jsonSerializer), elementTypeCache, logger);
|
||||
_blockGridEditorValues = new(new BlockGridEditorDataConverter(jsonSerializer), elementTypeCache, logger);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Handle(ContentDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Handle(ContentDeletedBlueprintNotification notification) => DeleteContainedFiles(notification.DeletedBlueprints);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Handle(MediaDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Handle(MemberDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all file upload property files contained within a collection of content entities.
|
||||
/// </summary>
|
||||
/// <param name="deletedEntities"></param>
|
||||
private void DeleteContainedFiles(IEnumerable<IContentBase> deletedEntities)
|
||||
{
|
||||
IReadOnlyList<string> filePathsToDelete = ContainedFilePaths(deletedEntities);
|
||||
MediaFileManager.DeleteMediaFiles(filePathsToDelete);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the paths to all file upload property files contained within a collection of content entities.
|
||||
/// </summary>
|
||||
private IReadOnlyList<string> ContainedFilePaths(IEnumerable<IContentBase> entities)
|
||||
{
|
||||
var paths = new List<string>();
|
||||
|
||||
foreach (IProperty? property in entities.SelectMany(x => x.Properties))
|
||||
{
|
||||
if (IsUploadFieldPropertyType(property.PropertyType))
|
||||
{
|
||||
paths.AddRange(GetPathsFromUploadFieldProperty(property));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsBlockListPropertyType(property.PropertyType))
|
||||
{
|
||||
paths.AddRange(GetPathsFromBlockProperty(property, _blockListEditorValues));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsBlockGridPropertyType(property.PropertyType))
|
||||
{
|
||||
paths.AddRange(GetPathsFromBlockProperty(property, _blockGridEditorValues));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsRichTextPropertyType(property.PropertyType))
|
||||
{
|
||||
paths.AddRange(GetPathsFromRichTextProperty(property));
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return paths.Distinct().ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
private IEnumerable<string> GetPathsFromUploadFieldProperty(IProperty property)
|
||||
{
|
||||
foreach (IPropertyValue propertyValue in property.Values)
|
||||
{
|
||||
if (propertyValue.PublishedValue != null && propertyValue.PublishedValue is string publishedUrl && !string.IsNullOrWhiteSpace(publishedUrl))
|
||||
{
|
||||
yield return MediaFileManager.FileSystem.GetRelativePath(publishedUrl);
|
||||
}
|
||||
|
||||
if (propertyValue.EditedValue != null && propertyValue.EditedValue is string editedUrl && !string.IsNullOrWhiteSpace(editedUrl))
|
||||
{
|
||||
yield return MediaFileManager.FileSystem.GetRelativePath(editedUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyCollection<string> GetPathsFromBlockProperty<TValue, TLayout>(IProperty property, BlockEditorValues<TValue, TLayout> blockEditorValues)
|
||||
where TValue : BlockValue<TLayout>, new()
|
||||
where TLayout : class, IBlockLayoutItem, new()
|
||||
{
|
||||
var paths = new List<string>();
|
||||
|
||||
foreach (IPropertyValue blockPropertyValue in property.Values)
|
||||
{
|
||||
paths.AddRange(GetPathsFromBlockValue(GetBlockEditorData(blockPropertyValue.PublishedValue, blockEditorValues)?.BlockValue));
|
||||
paths.AddRange(GetPathsFromBlockValue(GetBlockEditorData(blockPropertyValue.EditedValue, blockEditorValues)?.BlockValue));
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
private IReadOnlyCollection<string> GetPathsFromBlockValue(BlockValue? blockValue)
|
||||
{
|
||||
var paths = new List<string>();
|
||||
|
||||
if (blockValue is null)
|
||||
{
|
||||
return paths;
|
||||
}
|
||||
|
||||
IEnumerable<BlockPropertyValue> blockPropertyValues = blockValue.ContentData
|
||||
.Concat(blockValue.SettingsData)
|
||||
.SelectMany(x => x.Values);
|
||||
|
||||
foreach (BlockPropertyValue blockPropertyValue in blockPropertyValues)
|
||||
{
|
||||
if (blockPropertyValue.Value == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
IPropertyType? propertyType = blockPropertyValue.PropertyType;
|
||||
|
||||
if (propertyType == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsUploadFieldPropertyType(propertyType))
|
||||
{
|
||||
FileUploadValue? originalValue = FileUploadValueParser.Parse(blockPropertyValue.Value);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(originalValue?.Src))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
paths.Add(MediaFileManager.FileSystem.GetRelativePath(originalValue.Src));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsBlockListPropertyType(propertyType))
|
||||
{
|
||||
paths.AddRange(GetPathsFromBlockPropertyValue(blockPropertyValue, _blockListEditorValues));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsBlockGridPropertyType(propertyType))
|
||||
{
|
||||
paths.AddRange(GetPathsFromBlockPropertyValue(blockPropertyValue, _blockGridEditorValues));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsRichTextPropertyType(propertyType))
|
||||
{
|
||||
paths.AddRange(GetPathsFromRichTextPropertyValue(blockPropertyValue));
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
private IReadOnlyCollection<string> GetPathsFromBlockPropertyValue<TValue, TLayout>(BlockPropertyValue blockItemDataValue, BlockEditorValues<TValue, TLayout> blockEditorValues)
|
||||
where TValue : BlockValue<TLayout>, new()
|
||||
where TLayout : class, IBlockLayoutItem, new()
|
||||
{
|
||||
BlockEditorData<TValue, TLayout>? blockItemEditorDataValue = GetBlockEditorData(blockItemDataValue.Value, blockEditorValues);
|
||||
|
||||
return GetPathsFromBlockValue(blockItemEditorDataValue?.BlockValue);
|
||||
}
|
||||
|
||||
private IReadOnlyCollection<string> GetPathsFromRichTextProperty(IProperty property)
|
||||
{
|
||||
var paths = new List<string>();
|
||||
|
||||
IPropertyValue? propertyValue = property.Values.FirstOrDefault();
|
||||
if (propertyValue is null)
|
||||
{
|
||||
return paths;
|
||||
}
|
||||
|
||||
paths.AddRange(GetPathsFromBlockValue(GetRichTextBlockValue(propertyValue.PublishedValue)));
|
||||
paths.AddRange(GetPathsFromBlockValue(GetRichTextBlockValue(propertyValue.EditedValue)));
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
private IReadOnlyCollection<string> GetPathsFromRichTextPropertyValue(BlockPropertyValue blockItemDataValue)
|
||||
{
|
||||
RichTextEditorValue? richTextEditorValue = GetRichTextEditorValue(blockItemDataValue.Value);
|
||||
|
||||
// Ensure the property type is populated on all blocks.
|
||||
richTextEditorValue?.EnsurePropertyTypePopulatedOnBlocks(ElementTypeCache);
|
||||
|
||||
return GetPathsFromBlockValue(richTextEditorValue?.Blocks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Cache.PropertyEditors;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Media;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers;
|
||||
|
||||
/// <summary>
|
||||
/// Implements a notification handler that processes file uploads media is saved, completing properties on the media item.
|
||||
/// </summary>
|
||||
internal sealed class FileUploadMediaSavingNotificationHandler : FileUploadNotificationHandlerBase, INotificationHandler<MediaSavingNotification>
|
||||
{
|
||||
private readonly IOptionsMonitor<ContentSettings> _contentSettings;
|
||||
private readonly UploadAutoFillProperties _uploadAutoFillProperties;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileUploadMediaSavingNotificationHandler"/> class.
|
||||
/// </summary>
|
||||
public FileUploadMediaSavingNotificationHandler(
|
||||
IJsonSerializer jsonSerializer,
|
||||
MediaFileManager mediaFileManager,
|
||||
IBlockEditorElementTypeCache elementTypeCache,
|
||||
IOptionsMonitor<ContentSettings> contentSettings,
|
||||
UploadAutoFillProperties uploadAutoFillProperties)
|
||||
: base(jsonSerializer, mediaFileManager, elementTypeCache)
|
||||
{
|
||||
_contentSettings = contentSettings;
|
||||
_uploadAutoFillProperties = uploadAutoFillProperties;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Handle(MediaSavingNotification notification)
|
||||
{
|
||||
foreach (IMedia entity in notification.SavedEntities)
|
||||
{
|
||||
AutoFillProperties(entity);
|
||||
}
|
||||
}
|
||||
|
||||
private void AutoFillProperties(IContentBase model)
|
||||
{
|
||||
IEnumerable<IProperty> properties = model.Properties.Where(x => IsUploadFieldPropertyType(x.PropertyType));
|
||||
|
||||
foreach (IProperty property in properties)
|
||||
{
|
||||
ImagingAutoFillUploadField? autoFillConfig = _contentSettings.CurrentValue.GetConfig(property.Alias);
|
||||
if (autoFillConfig == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (IPropertyValue pvalue in property.Values)
|
||||
{
|
||||
var svalue = property.GetValue(pvalue.Culture, pvalue.Segment) as string;
|
||||
if (string.IsNullOrWhiteSpace(svalue))
|
||||
{
|
||||
_uploadAutoFillProperties.Reset(model, autoFillConfig, pvalue.Culture, pvalue.Segment);
|
||||
}
|
||||
else
|
||||
{
|
||||
_uploadAutoFillProperties.Populate(
|
||||
model,
|
||||
autoFillConfig,
|
||||
MediaFileManager.FileSystem.GetRelativePath(svalue),
|
||||
pvalue.Culture,
|
||||
pvalue.Segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache.PropertyEditors;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.PropertyEditors;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Infrastructure.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a base class for all notification handlers relating to file uploads in property editors.
|
||||
/// </summary>
|
||||
internal abstract class FileUploadNotificationHandlerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileUploadNotificationHandlerBase"/> class.
|
||||
/// </summary>
|
||||
protected FileUploadNotificationHandlerBase(
|
||||
IJsonSerializer jsonSerializer,
|
||||
MediaFileManager mediaFileManager,
|
||||
IBlockEditorElementTypeCache elementTypeCache)
|
||||
{
|
||||
JsonSerializer = jsonSerializer;
|
||||
MediaFileManager = mediaFileManager;
|
||||
ElementTypeCache = elementTypeCache;
|
||||
FileUploadValueParser = new FileUploadValueParser(jsonSerializer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="IJsonSerializer" /> used for serializing and deserializing values.
|
||||
/// </summary>
|
||||
protected IJsonSerializer JsonSerializer { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="MediaFileManager" /> used for managing media files.
|
||||
/// </summary>
|
||||
protected MediaFileManager MediaFileManager { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <IBlockEditorElementTypeCache> used for caching block editor element types.
|
||||
/// </summary>
|
||||
protected IBlockEditorElementTypeCache ElementTypeCache { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="FileUploadValueParser" /> used for parsing file upload values.
|
||||
/// </summary>
|
||||
protected FileUploadValueParser FileUploadValueParser { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether a property is an upload field.
|
||||
/// </summary>
|
||||
/// <param name="propertyType">The property type.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the specified property is an upload field; otherwise, <c>false</c>.
|
||||
/// </returns>
|
||||
protected static bool IsUploadFieldPropertyType(IPropertyType propertyType)
|
||||
=> propertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.UploadField;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether a property is an block list field.
|
||||
/// </summary>
|
||||
/// <param name="propertyType">The property type.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the specified property is an block list field; otherwise, <c>false</c>.
|
||||
/// </returns>
|
||||
protected static bool IsBlockListPropertyType(IPropertyType propertyType)
|
||||
=> propertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.BlockList;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether a property is an block grid field.
|
||||
/// </summary>
|
||||
/// <param name="propertyType">The property type.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the specified property is an block grid field; otherwise, <c>false</c>.
|
||||
/// </returns>
|
||||
protected static bool IsBlockGridPropertyType(IPropertyType propertyType)
|
||||
=> propertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.BlockGrid;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether a property is an rich text field (supporting blocks).
|
||||
/// </summary>
|
||||
/// <param name="propertyType">The property type.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the specified property is an rich text field; otherwise, <c>false</c>.
|
||||
/// </returns>
|
||||
protected static bool IsRichTextPropertyType(IPropertyType propertyType)
|
||||
=> propertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.RichText ||
|
||||
propertyType.PropertyEditorAlias == "Umbraco.TinyMCE";
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the block editor data value.
|
||||
/// </summary>
|
||||
protected static BlockEditorData<TValue, TLayout>? GetBlockEditorData<TValue, TLayout>(object? value, BlockEditorValues<TValue, TLayout> blockListEditorValues)
|
||||
where TValue : BlockValue<TLayout>, new()
|
||||
where TLayout : class, IBlockLayoutItem, new()
|
||||
{
|
||||
try
|
||||
{
|
||||
return blockListEditorValues.DeserializeAndClean(value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If this occurs it means the data is invalid. Shouldn't happen but could if we change the data format.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the rich text editor value.
|
||||
/// </summary>
|
||||
protected RichTextEditorValue? GetRichTextEditorValue(object? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
JsonSerializer.TryDeserialize(value, out RichTextEditorValue? richTextEditorValue);
|
||||
return richTextEditorValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the rich text block value.
|
||||
/// </summary>
|
||||
protected RichTextBlockValue? GetRichTextBlockValue(object? value)
|
||||
{
|
||||
RichTextEditorValue? richTextEditorValue = GetRichTextEditorValue(value);
|
||||
if (richTextEditorValue?.Blocks is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure the property type is populated on all blocks.
|
||||
richTextEditorValue.EnsurePropertyTypePopulatedOnBlocks(ElementTypeCache);
|
||||
|
||||
return richTextEditorValue.Blocks;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Strings;
|
||||
using Umbraco.Cms.Core.Templates;
|
||||
using Umbraco.Cms.Infrastructure.Extensions;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
@@ -33,8 +34,11 @@ public class RichTextPropertyEditor : DataEditor
|
||||
private readonly IRichTextPropertyIndexValueFactory _richTextPropertyIndexValueFactory;
|
||||
|
||||
/// <summary>
|
||||
/// The constructor will setup the property editor based on the attribute if one is found.
|
||||
/// Initializes a new instance of the <see cref="RichTextPropertyEditor"/> class.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The constructor will setup the property editor based on the attribute if one is found.
|
||||
/// </remarks>
|
||||
public RichTextPropertyEditor(
|
||||
IDataValueEditorFactory dataValueEditorFactory,
|
||||
IIOHelper ioHelper,
|
||||
@@ -43,6 +47,7 @@ public class RichTextPropertyEditor : DataEditor
|
||||
{
|
||||
_ioHelper = ioHelper;
|
||||
_richTextPropertyIndexValueFactory = richTextPropertyIndexValueFactory;
|
||||
|
||||
SupportsReadOnly = true;
|
||||
}
|
||||
|
||||
@@ -95,6 +100,7 @@ public class RichTextPropertyEditor : DataEditor
|
||||
private readonly IRichTextRequiredValidator _richTextRequiredValidator;
|
||||
private readonly IRichTextRegexValidator _richTextRegexValidator;
|
||||
private readonly ILogger<RichTextPropertyValueEditor> _logger;
|
||||
private readonly IBlockEditorElementTypeCache _elementTypeCache;
|
||||
|
||||
public RichTextPropertyValueEditor(
|
||||
DataEditorAttribute attribute,
|
||||
@@ -123,6 +129,7 @@ public class RichTextPropertyEditor : DataEditor
|
||||
_localLinkParser = localLinkParser;
|
||||
_pastedImages = pastedImages;
|
||||
_htmlSanitizer = htmlSanitizer;
|
||||
_elementTypeCache = elementTypeCache;
|
||||
_richTextRequiredValidator = richTextRequiredValidator;
|
||||
_richTextRegexValidator = richTextRegexValidator;
|
||||
_jsonSerializer = jsonSerializer;
|
||||
@@ -242,7 +249,23 @@ public class RichTextPropertyEditor : DataEditor
|
||||
/// <returns></returns>
|
||||
public override object? FromEditor(ContentPropertyData editorValue, object? currentValue)
|
||||
{
|
||||
if (TryParseEditorValue(editorValue.Value, out RichTextEditorValue? richTextEditorValue) is false)
|
||||
// See note on BlockEditorPropertyValueEditor.FromEditor for why we can't return early with only a null or empty editorValue.
|
||||
TryParseEditorValue(editorValue.Value, out RichTextEditorValue? richTextEditorValue);
|
||||
TryParseEditorValue(currentValue, out RichTextEditorValue? currentRichTextEditorValue);
|
||||
|
||||
// We can early return if we have a null value and the current value doesn't have any blocks.
|
||||
if (richTextEditorValue is null && currentRichTextEditorValue?.Blocks is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure the property type is populated on all blocks.
|
||||
richTextEditorValue?.EnsurePropertyTypePopulatedOnBlocks(_elementTypeCache);
|
||||
currentRichTextEditorValue?.EnsurePropertyTypePopulatedOnBlocks(_elementTypeCache);
|
||||
|
||||
RichTextEditorValue cleanedUpRichTextEditorValue = CleanAndMapBlocks(richTextEditorValue, blockValue => MapBlockValueFromEditor(blockValue, currentRichTextEditorValue?.Blocks, editorValue.ContentKey));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(richTextEditorValue?.Markup))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -253,11 +276,6 @@ public class RichTextPropertyEditor : DataEditor
|
||||
var config = editorValue.DataTypeConfiguration as RichTextConfiguration;
|
||||
Guid mediaParentId = config?.MediaParentId ?? Guid.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(richTextEditorValue.Markup))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parseAndSavedTempImages = _pastedImages
|
||||
.FindAndPersistPastedTempImagesAsync(richTextEditorValue.Markup, mediaParentId, userKey)
|
||||
.GetAwaiter()
|
||||
@@ -267,8 +285,6 @@ public class RichTextPropertyEditor : DataEditor
|
||||
|
||||
richTextEditorValue.Markup = sanitized.NullOrWhiteSpaceAsNull() ?? string.Empty;
|
||||
|
||||
RichTextEditorValue cleanedUpRichTextEditorValue = CleanAndMapBlocks(richTextEditorValue, MapBlockValueFromEditor);
|
||||
|
||||
// return json
|
||||
return RichTextPropertyEditorHelper.SerializeRichTextEditorValue(cleanedUpRichTextEditorValue, _jsonSerializer);
|
||||
}
|
||||
@@ -377,19 +393,26 @@ public class RichTextPropertyEditor : DataEditor
|
||||
private bool TryParseEditorValue(object? value, [NotNullWhen(true)] out RichTextEditorValue? richTextEditorValue)
|
||||
=> RichTextPropertyEditorHelper.TryParseRichTextEditorValue(value, _jsonSerializer, _logger, out richTextEditorValue);
|
||||
|
||||
private RichTextEditorValue CleanAndMapBlocks(RichTextEditorValue richTextEditorValue, Action<RichTextBlockValue> handleMapping)
|
||||
private RichTextEditorValue CleanAndMapBlocks(RichTextEditorValue? richTextEditorValue, Action<RichTextBlockValue> handleMapping)
|
||||
{
|
||||
if (richTextEditorValue.Blocks is null)
|
||||
// We handle mapping of blocks even if the edited value is empty, so property editors can clean up any resources
|
||||
// relating to removed blocks, e.g. files uploaded to the media library from the file upload property editor.
|
||||
BlockEditorData<RichTextBlockValue, RichTextBlockLayoutItem>? blockEditorData = null;
|
||||
if (richTextEditorValue?.Blocks is not null)
|
||||
{
|
||||
blockEditorData = ConvertAndClean(richTextEditorValue.Blocks);
|
||||
}
|
||||
|
||||
handleMapping(blockEditorData?.BlockValue ?? new RichTextBlockValue());
|
||||
|
||||
if (richTextEditorValue?.Blocks is null)
|
||||
{
|
||||
// no blocks defined, store empty block value
|
||||
return MarkupWithEmptyBlocks();
|
||||
}
|
||||
|
||||
BlockEditorData<RichTextBlockValue, RichTextBlockLayoutItem>? blockEditorData = ConvertAndClean(richTextEditorValue.Blocks);
|
||||
|
||||
if (blockEditorData is not null)
|
||||
{
|
||||
handleMapping(blockEditorData.BlockValue);
|
||||
return new RichTextEditorValue
|
||||
{
|
||||
Markup = richTextEditorValue.Markup,
|
||||
@@ -402,7 +425,7 @@ public class RichTextPropertyEditor : DataEditor
|
||||
|
||||
RichTextEditorValue MarkupWithEmptyBlocks() => new()
|
||||
{
|
||||
Markup = richTextEditorValue.Markup,
|
||||
Markup = richTextEditorValue?.Markup ?? string.Empty,
|
||||
Blocks = new RichTextBlockValue(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.RegularExpressions;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
@@ -50,7 +51,7 @@ internal class RichTextPropertyIndexValueFactory : BlockValuePropertyIndexValueF
|
||||
};
|
||||
|
||||
// the actual content (RTE content without markup, i.e. the actual words) must be indexed under the property alias
|
||||
var richTextWithoutMarkup = richTextEditorValue.Markup.StripHtml();
|
||||
var richTextWithoutMarkup = StripHtmlForIndexing(richTextEditorValue.Markup);
|
||||
if (richTextEditorValue.Blocks?.ContentData.Any() is not true)
|
||||
{
|
||||
// no blocks; index the content for the culture and be done with it
|
||||
@@ -132,4 +133,27 @@ internal class RichTextPropertyIndexValueFactory : BlockValuePropertyIndexValueF
|
||||
|
||||
protected override IEnumerable<RawDataItem> GetDataItems(RichTextEditorValue input, bool published)
|
||||
=> GetDataItems(input.Blocks?.ContentData ?? [], input.Blocks?.Expose ?? [], published);
|
||||
|
||||
/// <summary>
|
||||
/// Strips HTML tags from content while preserving whitespace from line breaks.
|
||||
/// This addresses the issue where <br> tags don't create word boundaries when HTML is stripped.
|
||||
/// </summary>
|
||||
/// <param name="html">The HTML content to strip</param>
|
||||
/// <returns>Plain text with proper word boundaries</returns>
|
||||
private static string StripHtmlForIndexing(string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Replace <br> and <br/> tags (with any amount of whitespace and attributes) with spaces
|
||||
// This regex matches:
|
||||
// - <br> (with / without spaces or attributes)
|
||||
// - <br /> (with / without spaces or attributes)
|
||||
html = Regex.Replace(html, @"<br\b[^>]*/?>\s*", " ", RegexOptions.IgnoreCase);
|
||||
|
||||
// Use the existing Microsoft StripHtml function for everything else
|
||||
return html.StripHtml();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
|
||||
/// <summary>
|
||||
/// A handler for Rich Text editors used to bind to notifications.
|
||||
/// </summary>
|
||||
public class RichTextPropertyNotificationHandler : BlockEditorPropertyNotificationHandlerBase<RichTextBlockLayoutItem>
|
||||
{
|
||||
public RichTextPropertyNotificationHandler(ILogger<RichTextPropertyNotificationHandler> logger)
|
||||
: base(logger)
|
||||
{
|
||||
}
|
||||
|
||||
protected override string EditorAlias => Constants.PropertyEditors.Aliases.RichText;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Models.TemporaryFile;
|
||||
using Umbraco.Cms.Core.Models.Validation;
|
||||
@@ -6,20 +6,20 @@ using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
|
||||
internal class TemporaryFileUploadValidator : IValueValidator
|
||||
public class TemporaryFileUploadValidator : IValueValidator
|
||||
{
|
||||
private readonly GetContentSettings _getContentSettings;
|
||||
private readonly ParseTemporaryFileKey _parseTemporaryFileKey;
|
||||
private readonly GetTemporaryFileModel _getTemporaryFileModel;
|
||||
private readonly ValidateFileType? _validateFileType;
|
||||
|
||||
internal delegate ContentSettings GetContentSettings();
|
||||
public delegate ContentSettings GetContentSettings();
|
||||
|
||||
internal delegate Guid? ParseTemporaryFileKey(object? editorValue);
|
||||
public delegate Guid? ParseTemporaryFileKey(object? editorValue);
|
||||
|
||||
internal delegate TemporaryFileModel? GetTemporaryFileModel(Guid temporaryFileKey);
|
||||
public delegate TemporaryFileModel? GetTemporaryFileModel(Guid temporaryFileKey);
|
||||
|
||||
internal delegate bool ValidateFileType(string extension, object? dataTypeConfiguration);
|
||||
public delegate bool ValidateFileType(string extension, object? dataTypeConfiguration);
|
||||
|
||||
public TemporaryFileUploadValidator(
|
||||
GetContentSettings getContentSettings,
|
||||
|
||||
@@ -122,42 +122,29 @@ namespace Umbraco.Cms
|
||||
/// <inheritdoc />
|
||||
public ProcessInstructionsResult ProcessInstructions(
|
||||
CacheRefresherCollection cacheRefreshers,
|
||||
ServerRole serverRole,
|
||||
CancellationToken cancellationToken,
|
||||
string localIdentity,
|
||||
DateTime lastPruned,
|
||||
int lastId)
|
||||
{
|
||||
using (!_profilingLogger.IsEnabled(Core.Logging.LogLevel.Debug) ? null : _profilingLogger.DebugDuration<CacheInstructionService>("Syncing from database..."))
|
||||
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
|
||||
{
|
||||
var numberOfInstructionsProcessed = ProcessDatabaseInstructions(cacheRefreshers, cancellationToken, localIdentity, ref lastId);
|
||||
|
||||
// Check for pruning throttling.
|
||||
if (cancellationToken.IsCancellationRequested || DateTime.UtcNow - lastPruned <=
|
||||
_globalSettings.DatabaseServerMessenger.TimeBetweenPruneOperations)
|
||||
{
|
||||
scope.Complete();
|
||||
return ProcessInstructionsResult.AsCompleted(numberOfInstructionsProcessed, lastId);
|
||||
}
|
||||
|
||||
var instructionsWerePruned = false;
|
||||
switch (serverRole)
|
||||
{
|
||||
case ServerRole.Single:
|
||||
case ServerRole.SchedulingPublisher:
|
||||
PruneOldInstructions();
|
||||
instructionsWerePruned = true;
|
||||
break;
|
||||
}
|
||||
|
||||
scope.Complete();
|
||||
|
||||
return instructionsWerePruned
|
||||
? ProcessInstructionsResult.AsCompletedAndPruned(numberOfInstructionsProcessed, lastId)
|
||||
: ProcessInstructionsResult.AsCompleted(numberOfInstructionsProcessed, lastId);
|
||||
}
|
||||
}
|
||||
/// <inheritdoc />
|
||||
[Obsolete("Use the non-obsolete overload. Scheduled for removal in V17.")]
|
||||
public ProcessInstructionsResult ProcessInstructions(
|
||||
CacheRefresherCollection cacheRefreshers,
|
||||
ServerRole serverRole,
|
||||
CancellationToken cancellationToken,
|
||||
string localIdentity,
|
||||
DateTime lastPruned,
|
||||
int lastId) =>
|
||||
ProcessInstructions(cacheRefreshers, cancellationToken, localIdentity, lastId);
|
||||
|
||||
private CacheInstruction CreateCacheInstruction(IEnumerable<RefreshInstruction> instructions, string localIdentity)
|
||||
=> new(
|
||||
@@ -486,21 +473,6 @@ namespace Umbraco.Cms
|
||||
|
||||
return jsonRefresher;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove old instructions from the database
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Always leave the last (most recent) record in the db table, this is so that not all instructions are removed which
|
||||
/// would cause
|
||||
/// the site to cold boot if there's been no instruction activity for more than TimeToRetainInstructions.
|
||||
/// See: http://issues.umbraco.org/issue/U4-7643#comment=67-25085
|
||||
/// </remarks>
|
||||
private void PruneOldInstructions()
|
||||
{
|
||||
DateTime pruneDate = DateTime.UtcNow - _globalSettings.DatabaseServerMessenger.TimeToRetainInstructions;
|
||||
_cacheInstructionRepository.DeleteInstructionsOlderThan(pruneDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,8 +331,9 @@ public class PackagingService : IPackagingService
|
||||
/// <inheritdoc/>
|
||||
public Task<PagedModel<InstalledPackage>> GetInstalledPackagesFromMigrationPlansAsync(int skip, int take)
|
||||
{
|
||||
IReadOnlyDictionary<string, string?>? keyValues =
|
||||
_keyValueService.FindByKeyPrefix(Constants.Conventions.Migrations.KeyValuePrefix);
|
||||
IReadOnlyDictionary<string, string?> keyValues =
|
||||
_keyValueService.FindByKeyPrefix(Constants.Conventions.Migrations.KeyValuePrefix)
|
||||
?? new Dictionary<string, string?>();
|
||||
|
||||
InstalledPackage[] installedPackages = _packageMigrationPlans
|
||||
.GroupBy(plan => (plan.PackageName, plan.PackageId))
|
||||
@@ -343,15 +344,21 @@ public class PackagingService : IPackagingService
|
||||
PackageName = group.Key.PackageName,
|
||||
};
|
||||
|
||||
var packageKey = Constants.Conventions.Migrations.KeyValuePrefix + (group.Key.PackageId ?? group.Key.PackageName);
|
||||
var currentState = keyValues?
|
||||
.GetValueOrDefault(packageKey);
|
||||
|
||||
package.PackageMigrationPlans = group
|
||||
.Select(plan => new InstalledPackageMigrationPlans
|
||||
.Select(plan =>
|
||||
{
|
||||
CurrentMigrationId = currentState,
|
||||
FinalMigrationId = plan.FinalState,
|
||||
// look for migration states in this order:
|
||||
// - plan name
|
||||
// - package identifier
|
||||
// - package name
|
||||
var currentState =
|
||||
keyValues.GetValueOrDefault($"{Constants.Conventions.Migrations.KeyValuePrefix}{plan.Name}")
|
||||
?? keyValues.GetValueOrDefault($"{Constants.Conventions.Migrations.KeyValuePrefix}{plan.PackageId ?? plan.PackageName}");
|
||||
|
||||
return new InstalledPackageMigrationPlans
|
||||
{
|
||||
CurrentMigrationId = currentState, FinalMigrationId = plan.FinalState,
|
||||
};
|
||||
});
|
||||
|
||||
return package;
|
||||
|
||||
@@ -16,12 +16,41 @@ namespace Umbraco.Cms.Infrastructure.Sync;
|
||||
/// </summary>
|
||||
public class BatchedDatabaseServerMessenger : DatabaseServerMessenger
|
||||
{
|
||||
private readonly IRequestAccessor _requestAccessor;
|
||||
private readonly IRequestCache _requestCache;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BatchedDatabaseServerMessenger" /> class.
|
||||
/// </summary>
|
||||
public BatchedDatabaseServerMessenger(
|
||||
IMainDom mainDom,
|
||||
CacheRefresherCollection cacheRefreshers,
|
||||
ILogger<BatchedDatabaseServerMessenger> logger,
|
||||
ISyncBootStateAccessor syncBootStateAccessor,
|
||||
IHostingEnvironment hostingEnvironment,
|
||||
ICacheInstructionService cacheInstructionService,
|
||||
IJsonSerializer jsonSerializer,
|
||||
IRequestCache requestCache,
|
||||
LastSyncedFileManager lastSyncedFileManager,
|
||||
IOptionsMonitor<GlobalSettings> globalSettings)
|
||||
: base(
|
||||
mainDom,
|
||||
cacheRefreshers,
|
||||
logger,
|
||||
true,
|
||||
syncBootStateAccessor,
|
||||
hostingEnvironment,
|
||||
cacheInstructionService,
|
||||
jsonSerializer,
|
||||
lastSyncedFileManager,
|
||||
globalSettings)
|
||||
{
|
||||
_requestCache = requestCache;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BatchedDatabaseServerMessenger" /> class.
|
||||
/// </summary>
|
||||
[Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in V18.")]
|
||||
public BatchedDatabaseServerMessenger(
|
||||
IMainDom mainDom,
|
||||
CacheRefresherCollection cacheRefreshers,
|
||||
@@ -35,11 +64,18 @@ public class BatchedDatabaseServerMessenger : DatabaseServerMessenger
|
||||
IRequestAccessor requestAccessor,
|
||||
LastSyncedFileManager lastSyncedFileManager,
|
||||
IOptionsMonitor<GlobalSettings> globalSettings)
|
||||
: base(mainDom, cacheRefreshers, serverRoleAccessor, logger, true, syncBootStateAccessor, hostingEnvironment,
|
||||
cacheInstructionService, jsonSerializer, lastSyncedFileManager, globalSettings)
|
||||
: this(
|
||||
mainDom,
|
||||
cacheRefreshers,
|
||||
logger,
|
||||
syncBootStateAccessor,
|
||||
hostingEnvironment,
|
||||
cacheInstructionService,
|
||||
jsonSerializer,
|
||||
requestCache,
|
||||
lastSyncedFileManager,
|
||||
globalSettings)
|
||||
{
|
||||
_requestCache = requestCache;
|
||||
_requestAccessor = requestAccessor;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -31,11 +31,9 @@ public abstract class DatabaseServerMessenger : ServerMessengerBase, IDisposable
|
||||
*/
|
||||
|
||||
private readonly IMainDom _mainDom;
|
||||
private readonly IServerRoleAccessor _serverRoleAccessor;
|
||||
private readonly ISyncBootStateAccessor _syncBootStateAccessor;
|
||||
private readonly ManualResetEvent _syncIdle;
|
||||
private bool _disposedValue;
|
||||
private DateTime _lastPruned;
|
||||
private DateTime _lastSync;
|
||||
private bool _syncing;
|
||||
|
||||
@@ -45,7 +43,6 @@ public abstract class DatabaseServerMessenger : ServerMessengerBase, IDisposable
|
||||
protected DatabaseServerMessenger(
|
||||
IMainDom mainDom,
|
||||
CacheRefresherCollection cacheRefreshers,
|
||||
IServerRoleAccessor serverRoleAccessor,
|
||||
ILogger<DatabaseServerMessenger> logger,
|
||||
bool distributedEnabled,
|
||||
ISyncBootStateAccessor syncBootStateAccessor,
|
||||
@@ -59,7 +56,6 @@ public abstract class DatabaseServerMessenger : ServerMessengerBase, IDisposable
|
||||
_cancellationToken = _cancellationTokenSource.Token;
|
||||
_mainDom = mainDom;
|
||||
_cacheRefreshers = cacheRefreshers;
|
||||
_serverRoleAccessor = serverRoleAccessor;
|
||||
_hostingEnvironment = hostingEnvironment;
|
||||
Logger = logger;
|
||||
_syncBootStateAccessor = syncBootStateAccessor;
|
||||
@@ -67,7 +63,7 @@ public abstract class DatabaseServerMessenger : ServerMessengerBase, IDisposable
|
||||
JsonSerializer = jsonSerializer;
|
||||
_lastSyncedFileManager = lastSyncedFileManager;
|
||||
GlobalSettings = globalSettings.CurrentValue;
|
||||
_lastPruned = _lastSync = DateTime.UtcNow;
|
||||
_lastSync = DateTime.UtcNow;
|
||||
_syncIdle = new ManualResetEvent(true);
|
||||
|
||||
globalSettings.OnChange(x => GlobalSettings = x);
|
||||
@@ -84,6 +80,36 @@ public abstract class DatabaseServerMessenger : ServerMessengerBase, IDisposable
|
||||
_initialized = new Lazy<SyncBootState?>(InitializeWithMainDom);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DatabaseServerMessenger" /> class.
|
||||
/// </summary>
|
||||
[Obsolete("Use the non-obsolete constructor. Scheduled for removal in V18.")]
|
||||
protected DatabaseServerMessenger(
|
||||
IMainDom mainDom,
|
||||
CacheRefresherCollection cacheRefreshers,
|
||||
IServerRoleAccessor serverRoleAccessor,
|
||||
ILogger<DatabaseServerMessenger> logger,
|
||||
bool distributedEnabled,
|
||||
ISyncBootStateAccessor syncBootStateAccessor,
|
||||
IHostingEnvironment hostingEnvironment,
|
||||
ICacheInstructionService cacheInstructionService,
|
||||
IJsonSerializer jsonSerializer,
|
||||
LastSyncedFileManager lastSyncedFileManager,
|
||||
IOptionsMonitor<GlobalSettings> globalSettings)
|
||||
: this(
|
||||
mainDom,
|
||||
cacheRefreshers,
|
||||
logger,
|
||||
distributedEnabled,
|
||||
syncBootStateAccessor,
|
||||
hostingEnvironment,
|
||||
cacheInstructionService,
|
||||
jsonSerializer,
|
||||
lastSyncedFileManager,
|
||||
globalSettings)
|
||||
{
|
||||
}
|
||||
|
||||
public GlobalSettings GlobalSettings { get; private set; }
|
||||
|
||||
protected ILogger<DatabaseServerMessenger> Logger { get; }
|
||||
@@ -146,17 +172,10 @@ public abstract class DatabaseServerMessenger : ServerMessengerBase, IDisposable
|
||||
{
|
||||
ProcessInstructionsResult result = CacheInstructionService.ProcessInstructions(
|
||||
_cacheRefreshers,
|
||||
_serverRoleAccessor.CurrentServerRole,
|
||||
_cancellationToken,
|
||||
LocalIdentity,
|
||||
_lastPruned,
|
||||
_lastSyncedFileManager.LastSyncedId);
|
||||
|
||||
if (result.InstructionsWerePruned)
|
||||
{
|
||||
_lastPruned = _lastSync;
|
||||
}
|
||||
|
||||
if (result.LastId > 0)
|
||||
{
|
||||
_lastSyncedFileManager.SaveLastSyncedId(result.LastId);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes/Deserializes <see cref="ContentCacheDataModel" /> document to the SQL Database as a string
|
||||
/// Serializes/Deserializes <see cref="ContentCacheDataModel" /> document to the SQL Database as a string.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resolved from the <see cref="IContentCacheDataSerializerFactory" />. This cannot be resolved from DI.
|
||||
@@ -11,12 +11,12 @@ namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
|
||||
internal interface IContentCacheDataSerializer
|
||||
{
|
||||
/// <summary>
|
||||
/// Deserialize the data into a <see cref="ContentCacheDataModel" />
|
||||
/// Deserialize the data into a <see cref="ContentCacheDataModel" />.
|
||||
/// </summary>
|
||||
ContentCacheDataModel? Deserialize(IReadOnlyContentBase content, string? stringData, byte[]? byteData, bool published);
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the <see cref="ContentCacheDataModel" />
|
||||
/// Serializes the <see cref="ContentCacheDataModel" />.
|
||||
/// </summary>
|
||||
ContentCacheDataSerializationResult Serialize(IReadOnlyContentBase content, ContentCacheDataModel model, bool published);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes/deserializes <see cref="ContentCacheDataModel" /> documents to the SQL Database as JSON.
|
||||
/// </summary>
|
||||
internal class JsonContentNestedDataSerializer : IContentCacheDataSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions _jsonSerializerOptions = new()
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters =
|
||||
{
|
||||
new JsonObjectConverter(),
|
||||
},
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -36,4 +43,27 @@ internal class JsonContentNestedDataSerializer : IContentCacheDataSerializer
|
||||
var json = JsonSerializer.Serialize(model, _jsonSerializerOptions);
|
||||
return new ContentCacheDataSerializationResult(json, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides a converter for handling JSON objects that can be of various types (string, number, boolean, null, or complex types).
|
||||
/// </summary>
|
||||
internal class JsonObjectConverter : JsonConverter<object>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> reader.TokenType switch
|
||||
{
|
||||
JsonTokenType.String => reader.GetString(),
|
||||
JsonTokenType.Number => reader.TryGetInt64(out var value) ? value : reader.GetDouble(),
|
||||
JsonTokenType.True => true,
|
||||
JsonTokenType.False => false,
|
||||
JsonTokenType.Null => null,
|
||||
_ => JsonDocument.ParseValue(ref reader).RootElement.Clone(), // fallback for complex types
|
||||
};
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
|
||||
=> JsonSerializer.Serialize(writer, value, value.GetType(), options);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text;
|
||||
using System.Text;
|
||||
using K4os.Compression.LZ4;
|
||||
using MessagePack;
|
||||
using MessagePack.Resolvers;
|
||||
@@ -8,14 +8,17 @@ using Umbraco.Cms.Core.PropertyEditors;
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes/Deserializes <see cref="ContentCacheDataModel" /> document to the SQL Database as bytes using
|
||||
/// MessagePack
|
||||
/// Serializes/deserializes <see cref="ContentCacheDataModel" /> documents to the SQL Database as bytes using
|
||||
/// MessagePack.
|
||||
/// </summary>
|
||||
internal sealed class MsgPackContentNestedDataSerializer : IContentCacheDataSerializer
|
||||
{
|
||||
private readonly MessagePackSerializerOptions _options;
|
||||
private readonly IPropertyCacheCompression _propertyOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MsgPackContentNestedDataSerializer"/> class.
|
||||
/// </summary>
|
||||
public MsgPackContentNestedDataSerializer(IPropertyCacheCompression propertyOptions)
|
||||
{
|
||||
_propertyOptions = propertyOptions ?? throw new ArgumentNullException(nameof(propertyOptions));
|
||||
@@ -40,6 +43,7 @@ internal sealed class MsgPackContentNestedDataSerializer : IContentCacheDataSeri
|
||||
.WithSecurity(MessagePackSecurity.UntrustedData);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ContentCacheDataModel? Deserialize(IReadOnlyContentBase content, string? stringData, byte[]? byteData, bool published)
|
||||
{
|
||||
if (byteData != null)
|
||||
@@ -62,6 +66,7 @@ internal sealed class MsgPackContentNestedDataSerializer : IContentCacheDataSeri
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ContentCacheDataSerializationResult Serialize(IReadOnlyContentBase content, ContentCacheDataModel model, bool published)
|
||||
{
|
||||
Compress(content, model, published);
|
||||
@@ -69,11 +74,10 @@ internal sealed class MsgPackContentNestedDataSerializer : IContentCacheDataSeri
|
||||
return new ContentCacheDataSerializationResult(null, bytes);
|
||||
}
|
||||
|
||||
public string ToJson(byte[] bin)
|
||||
{
|
||||
var json = MessagePackSerializer.ConvertToJson(bin, _options);
|
||||
return json;
|
||||
}
|
||||
/// <summary>
|
||||
/// Converts the binary MessagePack data to a JSON string representation.
|
||||
/// </summary>
|
||||
public string ToJson(byte[] bin) => MessagePackSerializer.ConvertToJson(bin, _options);
|
||||
|
||||
/// <summary>
|
||||
/// Used during serialization to compress properties
|
||||
|
||||
@@ -189,6 +189,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
builder.Services.AddRecurringBackgroundJob<WebhookFiring>();
|
||||
builder.Services.AddRecurringBackgroundJob<WebhookLoggingCleanup>();
|
||||
builder.Services.AddRecurringBackgroundJob<ReportSiteJob>();
|
||||
builder.Services.AddRecurringBackgroundJob<CacheInstructionsPruningJob>();
|
||||
|
||||
|
||||
builder.Services.AddSingleton(RecurringBackgroundJobHostedService.CreateHostedServiceFactory);
|
||||
|
||||
@@ -4,12 +4,12 @@ import remarkGfm from 'remark-gfm';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../@(src|libs|apps|storybook)/**/*.mdx', '../@(src|libs|apps|storybook)/**/*.stories.@(js|jsx|ts|tsx)'],
|
||||
|
||||
addons: [
|
||||
getAbsolutePath('@storybook/addon-links'),
|
||||
getAbsolutePath('@storybook/addon-essentials'),
|
||||
getAbsolutePath('@storybook/addon-a11y'),
|
||||
{
|
||||
name: '@storybook/addon-docs',
|
||||
name: getAbsolutePath('@storybook/addon-docs'),
|
||||
options: {
|
||||
mdxPluginOptions: {
|
||||
mdxCompileOptions: {
|
||||
@@ -19,10 +19,12 @@ const config: StorybookConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
framework: {
|
||||
name: getAbsolutePath('@storybook/web-components-vite'),
|
||||
options: {},
|
||||
},
|
||||
|
||||
staticDirs: [
|
||||
'../public-assets',
|
||||
'../public',
|
||||
@@ -32,12 +34,11 @@ const config: StorybookConfig = {
|
||||
to: 'assets/icons',
|
||||
},
|
||||
],
|
||||
|
||||
typescript: {
|
||||
check: true,
|
||||
},
|
||||
docs: {
|
||||
autodocs: true
|
||||
},
|
||||
|
||||
managerHead(head, { configType }) {
|
||||
const base = process.env.VITE_BASE_PATH || '/';
|
||||
const injections = [
|
||||
@@ -45,10 +46,12 @@ const config: StorybookConfig = {
|
||||
];
|
||||
return configType === 'PRODUCTION' ? `${injections.join('')}${head}` : head;
|
||||
},
|
||||
|
||||
refs: {
|
||||
uui: {
|
||||
title: 'Umbraco UI Library',
|
||||
url: 'https://62189360eeb21b003ab2f4ad-vfnpsanjps.chromatic.com/',
|
||||
url: 'https://uui.umbraco.com/',
|
||||
expanded: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { addons } from '@storybook/manager-api';
|
||||
import { addons } from 'storybook/manager-api';
|
||||
|
||||
addons.setConfig({
|
||||
enableShortcuts: false,
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'element-internals-polyfill';
|
||||
import '@umbraco-ui/uui';
|
||||
|
||||
import { html } from 'lit';
|
||||
import { setCustomElements } from '@storybook/web-components';
|
||||
import { setCustomElements } from '@storybook/web-components-vite';
|
||||
|
||||
import { startMockServiceWorker } from '../src/mocks';
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
|
||||
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import localRules from 'eslint-plugin-local-rules';
|
||||
import storybook from 'eslint-plugin-storybook';
|
||||
import wcPlugin from 'eslint-plugin-wc';
|
||||
import litPlugin from 'eslint-plugin-lit';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
@@ -13,8 +16,8 @@ export default [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
wcPlugin.configs['flat/recommended'],
|
||||
litPlugin.configs['flat/recommended'],
|
||||
jsdoc.configs['flat/recommended'], // We use the non typescript version to allow types to be defined in the jsdoc comments. This will allow js docs as an alternative to typescript types.
|
||||
litPlugin.configs['flat/recommended'], // We use the non typescript version to allow types to be defined in the jsdoc comments. This will allow js docs as an alternative to typescript types.
|
||||
jsdoc.configs['flat/recommended'],
|
||||
localRules.configs.all,
|
||||
eslintPluginPrettierRecommended,
|
||||
|
||||
@@ -101,4 +104,5 @@ export default [
|
||||
},
|
||||
},
|
||||
},
|
||||
...storybook.configs['flat/recommended'],
|
||||
];
|
||||
|
||||
20
src/Umbraco.Web.UI.Client/examples/collection/README.md
Normal file
20
src/Umbraco.Web.UI.Client/examples/collection/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Collection Example
|
||||
|
||||
This example demonstrates how to register a collection with collection views.
|
||||
|
||||
The example includes:
|
||||
|
||||
- Collection Registration
|
||||
- Collection Repository
|
||||
- Collection Pagination
|
||||
- Table Collection View
|
||||
- Card Collection View
|
||||
- Collection as a Dashboard
|
||||
- Collection as a Workspace View
|
||||
|
||||
TODO: This example is not complete, it is missing the following features:
|
||||
|
||||
- Collection Action
|
||||
- Collection Filtering
|
||||
- Entity Actions
|
||||
- Selection + Bulk Actions
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { ExampleCollectionItemModel } from '../repository/types.js';
|
||||
import type { UmbDefaultCollectionContext } from '@umbraco-cms/backoffice/collection';
|
||||
import { UMB_COLLECTION_CONTEXT } from '@umbraco-cms/backoffice/collection';
|
||||
import { css, html, customElement, state, repeat } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
|
||||
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
|
||||
|
||||
@customElement('example-card-collection-view')
|
||||
export class ExampleCardCollectionViewElement extends UmbLitElement {
|
||||
@state()
|
||||
private _items: Array<ExampleCollectionItemModel> = [];
|
||||
|
||||
#collectionContext?: UmbDefaultCollectionContext<ExampleCollectionItemModel>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.consumeContext(UMB_COLLECTION_CONTEXT, (instance) => {
|
||||
this.#collectionContext = instance;
|
||||
this.#observeCollectionItems();
|
||||
});
|
||||
}
|
||||
|
||||
#observeCollectionItems() {
|
||||
this.observe(this.#collectionContext?.items, (items) => (this._items = items || []), 'umbCollectionItemsObserver');
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div id="card-grid">
|
||||
${repeat(
|
||||
this._items,
|
||||
(item) => item.unique,
|
||||
(item) =>
|
||||
html` <uui-card>
|
||||
<uui-icon name="icon-newspaper"></uui-icon>
|
||||
<div>${item.name}</div>
|
||||
</uui-card>`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static override styles = [
|
||||
UmbTextStyles,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
grid-auto-rows: 200px;
|
||||
gap: var(--uui-size-space-5);
|
||||
}
|
||||
|
||||
uui-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
|
||||
uui-icon {
|
||||
font-size: 2em;
|
||||
margin-bottom: var(--uui-size-space-4);
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
export { ExampleCardCollectionViewElement as element };
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'example-card-collection-view': ExampleCardCollectionViewElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { EXAMPLE_COLLECTION_ALIAS } from '../constants.js';
|
||||
import { UMB_COLLECTION_ALIAS_CONDITION } from '@umbraco-cms/backoffice/collection';
|
||||
|
||||
export const manifests: Array<UmbExtensionManifest> = [
|
||||
{
|
||||
type: 'collectionView',
|
||||
alias: 'Example.CollectionView.Card',
|
||||
name: 'Example Card Collection View',
|
||||
js: () => import('./collection-view.element.js'),
|
||||
weight: 50,
|
||||
meta: {
|
||||
label: 'Card',
|
||||
icon: 'icon-grid',
|
||||
pathName: 'card',
|
||||
},
|
||||
conditions: [
|
||||
{
|
||||
alias: UMB_COLLECTION_ALIAS_CONDITION,
|
||||
match: EXAMPLE_COLLECTION_ALIAS,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
export const EXAMPLE_COLLECTION_ALIAS = 'Example.Collection';
|
||||
@@ -0,0 +1,20 @@
|
||||
import { EXAMPLE_COLLECTION_ALIAS } from './constants.js';
|
||||
import { EXAMPLE_COLLECTION_REPOSITORY_ALIAS } from './repository/constants.js';
|
||||
import { manifests as cardViewManifests } from './card-view/manifests.js';
|
||||
import { manifests as repositoryManifests } from './repository/manifests.js';
|
||||
import { manifests as tableViewManifests } from './table-view/manifests.js';
|
||||
|
||||
export const manifests: Array<UmbExtensionManifest> = [
|
||||
{
|
||||
type: 'collection',
|
||||
kind: 'default',
|
||||
alias: EXAMPLE_COLLECTION_ALIAS,
|
||||
name: 'Example Collection',
|
||||
meta: {
|
||||
repositoryAlias: EXAMPLE_COLLECTION_REPOSITORY_ALIAS,
|
||||
},
|
||||
},
|
||||
...cardViewManifests,
|
||||
...repositoryManifests,
|
||||
...tableViewManifests,
|
||||
];
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ExampleCollectionFilterModel, ExampleCollectionItemModel } from './types.js';
|
||||
import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository';
|
||||
import type { UmbCollectionRepository } from '@umbraco-cms/backoffice/collection';
|
||||
|
||||
export class ExampleCollectionRepository
|
||||
extends UmbRepositoryBase
|
||||
implements UmbCollectionRepository<ExampleCollectionItemModel, ExampleCollectionFilterModel>
|
||||
{
|
||||
async requestCollection(args: ExampleCollectionFilterModel) {
|
||||
const skip = args.skip || 0;
|
||||
const take = args.take || 10;
|
||||
|
||||
// Simulating a data fetch. This would in most cases be replaced with an API call.
|
||||
let items = [
|
||||
{
|
||||
unique: '3e31e9c5-7d66-4c99-a9e5-d9f2b1e2b22f',
|
||||
entityType: 'example',
|
||||
name: 'Example Item 1',
|
||||
},
|
||||
{
|
||||
unique: 'bc9b6e24-4b11-4dd6-8d4e-7c4f70e59f3c',
|
||||
entityType: 'example',
|
||||
name: 'Example Item 2',
|
||||
},
|
||||
{
|
||||
unique: '5a2f4e3a-ef7e-470e-8c3c-3d859c02ae0d',
|
||||
entityType: 'example',
|
||||
name: 'Example Item 3',
|
||||
},
|
||||
{
|
||||
unique: 'f4c3d8b8-6d79-4c87-9aa9-56b1d8fda702',
|
||||
entityType: 'example',
|
||||
name: 'Example Item 4',
|
||||
},
|
||||
{
|
||||
unique: 'c9f0a8a3-1b49-4724-bde3-70e31592eb6e',
|
||||
entityType: 'example',
|
||||
name: 'Example Item 5',
|
||||
},
|
||||
];
|
||||
|
||||
// Simulating filtering based on the args
|
||||
if (args.filter) {
|
||||
items = items.filter((item) => item.name.toLowerCase().includes(args.filter!.toLowerCase()));
|
||||
}
|
||||
|
||||
// Get the total number of items before pagination
|
||||
const totalItems = items.length;
|
||||
|
||||
// Simulating pagination
|
||||
const start = skip;
|
||||
const end = start + take;
|
||||
items = items.slice(start, end);
|
||||
|
||||
const data = {
|
||||
items,
|
||||
total: totalItems,
|
||||
};
|
||||
|
||||
return { data };
|
||||
}
|
||||
}
|
||||
|
||||
export { ExampleCollectionRepository as api };
|
||||
@@ -0,0 +1 @@
|
||||
export const EXAMPLE_COLLECTION_REPOSITORY_ALIAS = 'Example.Repository.Collection';
|
||||
@@ -0,0 +1,10 @@
|
||||
import { EXAMPLE_COLLECTION_REPOSITORY_ALIAS } from './constants.js';
|
||||
|
||||
export const manifests: Array<UmbExtensionManifest> = [
|
||||
{
|
||||
type: 'repository',
|
||||
alias: EXAMPLE_COLLECTION_REPOSITORY_ALIAS,
|
||||
name: 'Example Collection Repository',
|
||||
api: () => import('./collection.repository.js'),
|
||||
},
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user