Compare commits

...
Author SHA1 Message Date
da43086017 Merge commit from fork
Co-authored-by: kjac <kja@umbraco.dk>
2025-07-29 05:10:52 +02:00
Andy Butland 852192a5d2 Bumped package.json version to 15.4.4. 2025-07-24 09:27:21 +02:00
Andy Butland f4498c3d05 Bumped version to 15.4.4. 2025-07-21 11:56:22 +02:00
Andy ButlandandCopilot 9b2fd1253b Optimize initialization of document URLs on start-up (#19498)
* Optimize initialization of document URLs on startup.

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-10 10:31:26 +02:00
Andy Butland 024697f3bf Bumped version to 15.4.3 2025-06-10 10:28:55 +02:00
Andy ButlandandGitHub d920e93d1e Merge commit from fork 2025-06-03 05:21:11 +02:00
Andy Butland 853c1acf7e Bumped version to 15.4.2 2025-05-20 13:10:02 +01:00
Andy ButlandandGitHub 0ad020f0ce Restored interface methods on obsolete IBackgroundTaskQueue (#19223)
* Restore interface methods on obsolete IBackgroundTaskQueue.

* Fixed typos in comment.
2025-05-05 07:38:31 +02:00
Andy Butland fc1455e0d8 Bumped version to 15.4.1. 2025-05-03 17:28:44 +02:00
Andy Butland 15a8b4066a Bumped version to 15.4.0. 2025-04-30 15:04:08 +02:00
Andy ButlandandGitHub 38f6e2b0da Updates Examine to latest patch release. (#19193) 2025-04-30 09:57:55 +02:00
Andy Butland 14f60a108a V13: Clear Member Username Cache in Load Balanced Environments (#19191)
* Clear usernamekey

* Odd explaining comment

* Update src/Umbraco.Core/Cache/Refreshers/Implement/MemberCacheRefresher.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Make UserNameCachePrefix readonly for better immutabilityly

* Move prefix to CacheKeys constants

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
# Conflicts:
#	src/Umbraco.Core/Cache/CacheKeys.cs
2025-04-29 18:10:44 +02:00
Jacob Overgaard 375a0b4388 bump to 15.4.0-rc3 2025-04-29 15:34:59 +02:00
Jacob OvergaardandGitHub 6300ccc211 fix: The server path is not appended to stylesheets before load in Backoffice (#19192)
* build(deps): bump tiptap from 2.11.5 to 2.11.7

* fix: prepends the system `/css` folder to stylesheets before attempting to load them

* fix: adds more safety around path assumptions

* chore: eslint fix

* fix: prepend only the system path to picked stylesheets
2025-04-29 13:33:26 +00:00
Jacob Overgaard 5b6f544d2a build: restores some of the behavior from V13 in relation to StaticAssets (#19189)
In v13, the StaticAssets build was only triggered based on the existence of either the output folder or a preserve.* marker file. Here, we also additionally check for the node_modules/.package-lock.json file before reinstalling npm dependencies. We also now only run `npm install` rather than `npm ci` to optimise the build.
2025-04-29 13:10:29 +02:00
e932fa5404 Webhook log authorization and file system path checks (#19177)
* Add authorization for webhooks to item and log endpoints.

* Remove full path details from exception when requesting a path outside of the physical file system's root.

* Added missing usings.

* Revert changes to the webhook items API

---------

Co-authored-by: kjac <kja@umbraco.dk>
2025-04-28 12:10:43 +00:00
mole 83e580c3a7 Fix nuget publish stage 2025-04-25 13:31:34 +02:00
Andy Butlandandmole 31ee35e721 Fixed error with reflection on integration test configure builder attributes, so integration tests can be created outside of the Umbraco integration test project (#19077)
* Fixed error with reflection on integration test configure builder attributes, so integration tests can be created outside of the Umbraco integration test project.

* Fix nullability

---------

Co-authored-by: mole <nikolajlauridsen@protonmail.ch>
2025-04-24 13:18:57 +02:00
MoleandKenn Jacobsen cf0f3f1380 V15: Ensure elements cache is cleared on subscribers in load balanced scenarios (#19128)
* Clear elementscache from cache refreshers

* Add very simple test ensuring the elements cache is cleared

---------

Co-authored-by: Kenn Jacobsen <kja@umbraco.dk>
2025-04-24 13:18:53 +02:00
Sven Geusensandkjac 31c3f5ae0c Remove fake null checks as they are no longer needed after merge into v16 (#19109) 2025-04-24 12:28:54 +02:00
Sven GeusensandMigaroez c38faec74b V15/fix/sub variant block deletion (#18802)
* Fix

* Editors with access should be able to clear a blocklist value

* Writeup around block element level variation

* Dissallow values to be removed a limited language user does not have permissions to

* Remove commented out code

* improved comments

* Improve expose list for limited language access sub variant block lists
2025-04-21 10:37:14 +02:00
Andy Butlandandleekelleher 1e521760df Removes tree actions from tree selection in duplicate document dialog. (#19053)
(cherry picked from commit 1ccb5cc09e)
2025-04-16 18:23:26 +01:00
Jacob Overgaard 8b759cf3a4 bump version to 15.4.0-rc2 2025-04-16 14:47:55 +02:00
29c0151460 Scaffold content for content templates serverside (#19054)
* Scaffold content for content templates serverside

* Generated client types and methods from API.

* Retrieve scaffolded blueprint when creating documents from a blueprint.

* Use introduced helper method on existing read.

* Cleaned up imports.

* feat: moves scaffold service logic to data source and make shallow repository method

* feat: follows UmbDataSourceResponse interface and reorders public/private methods

* Bumped version to 15.4.0-r2.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
2025-04-16 12:39:07 +00:00
Jacob Overgaard cc9c33bfe6 fix: adds missing export 2025-04-15 12:57:57 +02:00
Jacob Overgaard 6edffd9f08 docs: fix import path 2025-04-15 12:23:37 +02:00
Andy Butland 981f173a79 Clarified "too many" entries validation message. (#19040) 2025-04-15 08:35:49 +02:00
Andy Butland 432dda8c47 Adds some missing mime types to ensure uploaded audio and video displays with preview. (#19039) 2025-04-15 08:04:11 +02:00
Andy Butlandandkjac a9fc88d7e6 Make sure not to early return when verifying ancestor path is published without completing scope (#19029)
* Make sure not to early return when verifying ancestor path is published without completing scope.

* Added comment.
2025-04-14 17:46:57 +02:00
Kenn JacobsenandGitHub 7495c3c7b2 Treat content schedule dates as UTC (#19028) 2025-04-14 14:50:02 +02:00
f9496e8067 Ensure dates read from the database are treated as local when constructing entities (2) (#19013)
* Revert "Ensure dates read from the database are treated as local when constructing entities (#18989)"

This reverts commit 7b10d39d66.

* Avoid system dates stored with local server time being defaulted to UTC on database read.

* ContentScheduleDto.Date is UTC

---------

Co-authored-by: kjac <kja@umbraco.dk>
2025-04-14 10:55:36 +02:00
Andy Butlandandmole 7b10d39d66 Ensure dates read from the database are treated as local when constructing entities (#18989)
* Ensure dates read from the database are treated as local when constructing entities.

* Fixed typos in comments.
2025-04-11 13:19:13 +02:00
Niels LyngsøandGitHub 97b3023e14 make sure only to prepend relative URLs (#18998) 2025-04-10 14:56:14 +01:00
82 changed files with 1993 additions and 402 deletions
+3 -3
View File
@@ -45,8 +45,8 @@
<PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageVersion Include="Dazinator.Extensions.FileProviders" Version="2.0.0" />
<PackageVersion Include="Examine" Version="3.7.0" />
<PackageVersion Include="Examine.Core" Version="3.7.0" />
<PackageVersion Include="Examine" Version="3.7.1" />
<PackageVersion Include="Examine.Core" Version="3.7.1" />
<PackageVersion Include="HtmlAgilityPack" Version="1.11.74" />
<PackageVersion Include="JsonPatch.Net" Version="3.1.1" />
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
@@ -100,4 +100,4 @@
<!-- Examine.Lucene brings in a vulnerable version of Lucene.Net.Replicator -->
<PackageVersion Include="Lucene.Net.Replicator" Version="4.8.0-beta00017" />
</ItemGroup>
</Project>
</Project>
+4
View File
@@ -829,6 +829,8 @@ stages:
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.myGetDeploy}}))
jobs:
- job:
pool:
vmImage: "windows-latest" # NuGetCommand@2 is no longer supported on Ubuntu 24.04 so we'll use windows until an alternative is available.
displayName: Push to pre-release feed
steps:
- checkout: none
@@ -890,6 +892,8 @@ stages:
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.nuGetDeploy}}))
jobs:
- job:
pool:
vmImage: "windows-latest" # NuGetCommand@2 is no longer supported on Ubuntu 24.04 so we'll use windows until an alternative is available.
displayName: Push to NuGet
steps:
- checkout: none
@@ -18,7 +18,12 @@ internal sealed class DeliveryApiOutputCachePolicy : IOutputCachePolicy
.RequestServices
.GetRequiredService<IRequestPreviewService>();
context.EnableOutputCaching = requestPreviewService.IsPreview() is false;
IApiAccessService apiAccessService = context
.HttpContext
.RequestServices
.GetRequiredService<IApiAccessService>();
context.EnableOutputCaching = requestPreviewService.IsPreview() is false && apiAccessService.HasPublicAccess();
context.ResponseExpirationTimeSpan = _duration;
return ValueTask.CompletedTask;
@@ -0,0 +1,37 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.DocumentBlueprint;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.Authorization;
namespace Umbraco.Cms.Api.Management.Controllers.DocumentBlueprint;
[ApiVersion("1.0")]
[Authorize(Policy = AuthorizationPolicies.TreeAccessDocuments)]
public class ScaffoldDocumentBlueprintController : DocumentBlueprintControllerBase
{
private readonly IContentBlueprintEditingService _contentBlueprintEditingService;
private readonly IUmbracoMapper _umbracoMapper;
public ScaffoldDocumentBlueprintController(IContentBlueprintEditingService contentBlueprintEditingService, IUmbracoMapper umbracoMapper)
{
_contentBlueprintEditingService = contentBlueprintEditingService;
_umbracoMapper = umbracoMapper;
}
[HttpGet("{id:guid}/scaffold")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(DocumentBlueprintResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Scaffold(CancellationToken cancellationToken, Guid id)
{
IContent? blueprint = await _contentBlueprintEditingService.GetScaffoldedAsync(id);
return blueprint is not null
? Ok(_umbracoMapper.Map<DocumentBlueprintResponseModel>(blueprint))
: DocumentBlueprintNotFound();
}
}
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.Factories;
@@ -5,11 +6,13 @@ using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Api.Management.ViewModels.Webhook.Logs;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Web.Common.Authorization;
namespace Umbraco.Cms.Api.Management.Controllers.Webhook.Logs;
[VersionedApiBackOfficeRoute($"{Constants.UdiEntityType.Webhook}")]
[ApiExplorerSettings(GroupName = "Webhook")]
[Authorize(Policy = AuthorizationPolicies.TreeAccessWebhooks)]
public class WebhookLogControllerBase : ManagementApiControllerBase
{
protected PagedViewModel<WebhookLogResponseModel> CreatePagedWebhookLogResponseModel(PagedModel<WebhookLog> logs, IWebhookPresentationFactory webhookPresentationFactory)
@@ -3485,6 +3485,66 @@
]
}
},
"/umbraco/management/api/v1/document-blueprint/{id}/scaffold": {
"get": {
"tags": [
"Document Blueprint"
],
"operationId": "GetDocumentBlueprintByIdScaffold",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/DocumentBlueprintResponseModel"
}
]
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
},
"403": {
"description": "The authenticated user does not have access to this resource"
}
},
"security": [
{
"Backoffice User": [ ]
}
]
}
},
"/umbraco/management/api/v1/document-blueprint/folder": {
"post": {
"tags": [
@@ -26,23 +26,36 @@
<ProjectReference Include="..\Umbraco.Web.Website\Umbraco.Web.Website.csproj" />
</ItemGroup>
<!-- Restore and build backoffice project -->
<!-- General ignored files -->
<ItemGroup>
<Content Remove="wwwroot\umbraco\assets\README.md" />
</ItemGroup>
<!-- BEGIN: Restore and build backoffice project -->
<PropertyGroup>
<BackofficeProjectDirectory Condition="'$(BackofficeProjectDirectory)' == ''">..\Umbraco.Web.UI.Client\</BackofficeProjectDirectory>
<BackofficeAssetsPath>wwwroot\umbraco\backoffice</BackofficeAssetsPath>
<BackofficeAssetsPath>$(ProjectDir)wwwroot\umbraco\backoffice</BackofficeAssetsPath>
</PropertyGroup>
<ItemGroup>
<BackofficeAssetsInputs Include="$(BackofficeProjectDirectory)package.json;$(BackofficeProjectDirectory)package-lock.json;$(BackofficeProjectDirectory)src\**" Exclude="$(DefaultItemExcludes)" />
<Content Remove="$(BackofficeAssetsPath)\**" />
</ItemGroup>
<Target Name="RestoreBackoffice" Inputs="$(BackofficeProjectDirectory)package-lock.json" Outputs="$(BackofficeProjectDirectory)node_modules\.package-lock.json">
<Message Importance="high" Text="Restoring Backoffice NPM packages..." />
<Exec Command="npm ci --no-fund --no-audit --prefer-offline" WorkingDirectory="$(BackofficeProjectDirectory)" />
<Target Name="BuildStaticAssetsPreconditions" BeforeTargets="AssignTargetPaths">
<Message Text="Skip BuildBackoffice target because UmbracoBuild is '$(UmbracoBuild)' (this is not Visual Studio)" Importance="high" Condition="'$(UmbracoBuild)' != ''" />
<Message Text="Skip BuildBackoffice target because '$(BackofficeAssetsPath)' already exists" Importance="high" Condition="Exists('$(BackofficeAssetsPath)')" />
<Message Text="Call BuildBackoffice target because UmbracoBuild is empty (this is Visual Studio) and '$(BackofficeAssetsPath)' doesn't exist" Importance="high" Condition="'$(UmbracoBuild)' == '' and !Exists('$(BackofficeAssetsPath)')" />
<CallTarget Targets="BuildBackoffice" Condition="'$(UmbracoBuild)' == '' and !Exists('$(BackofficeAssetsPath)')" />
</Target>
<Target Name="BuildBackoffice" DependsOnTargets="RestoreBackoffice" BeforeTargets="AssignTargetPaths" Inputs="@(BackofficeAssetsInputs)" Outputs="$(IntermediateOutputPath)backoffice.complete.txt">
<Target Name="RestoreBackoffice" Inputs="$(BackofficeProjectDirectory)package-lock.json" Outputs="$(BackofficeProjectDirectory)node_modules\.package-lock.json">
<Message Importance="high" Text="Restoring Backoffice NPM packages..." />
<Exec Command="npm i --no-fund --no-audit" WorkingDirectory="$(BackofficeProjectDirectory)" />
</Target>
<Target Name="BuildBackoffice" DependsOnTargets="RestoreBackoffice">
<Message Importance="high" Text="Executing Backoffice NPM build script..." />
<Exec Command="npm run build:for:cms" WorkingDirectory="$(BackofficeProjectDirectory)" />
<ItemGroup>
@@ -61,27 +74,46 @@
</DefineStaticWebAssets>
</Target>
<!-- Restore and build login project -->
<Target Name="CleanStaticAssetsPreconditions" AfterTargets="Clean" Condition="'$(UmbracoBuild)' == ''">
<Message Text="Skip CleanBackoffice target because '$(BackofficeAssetsPath)' doesn't exist" Importance="high" Condition="!Exists('$(BackofficeAssetsPath)')" />
<Message Text="Skip CleanBackoffice target because preserve.backoffice marker file exists" Importance="high" Condition="Exists('$(BackofficeAssetsPath)') and Exists('$(SolutionDir)preserve.backoffice')" />
<Message Text="Call CleanBackoffice target because '$(BackofficeAssetsPath)' exists and preserve.backoffice marker file doesn't exist" Importance="high" Condition="Exists('$(BackofficeAssetsPath)') and !Exists('$(SolutionDir)preserve.backoffice')" />
<CallTarget Targets="CleanBackoffice" Condition="Exists('$(BackofficeAssetsPath)') and !Exists('$(SolutionDir)preserve.backoffice')" />
</Target>
<Target Name="CleanBackoffice">
<ItemGroup>
<BackofficeDirectories Include="$(BackofficeAssetsPath)" />
</ItemGroup>
<RemoveDir Directories="@(BackofficeDirectories)" />
</Target>
<!-- END: Restore and build backoffice project -->
<!-- BEGIN: Restore and build login project -->
<PropertyGroup>
<LoginProjectDirectory Condition="'$(LoginProjectDirectory)' == ''">..\Umbraco.Web.UI.Login\</LoginProjectDirectory>
<LoginAssetsPath>wwwroot\umbraco\login</LoginAssetsPath>
<LoginAssetsPath>$(ProjectDir)wwwroot\umbraco\login</LoginAssetsPath>
</PropertyGroup>
<ItemGroup>
<LoginAssetsInputs Include="$(LoginProjectDirectory)**" Exclude="$(DefaultItemExcludes)" />
<Content Remove="$(LoginAssetsPath)\**" />
</ItemGroup>
<ItemGroup>
<Content Remove="wwwroot\umbraco\assets\README.md" />
</ItemGroup>
<Target Name="BuildLoginStaticAssetsPreconditions" BeforeTargets="AssignTargetPaths">
<Message Text="Skip BuildLogin target because UmbracoBuild is '$(UmbracoBuild)' (this is not Visual Studio)" Importance="high" Condition="'$(UmbracoBuild)' != ''" />
<Message Text="Skip BuildLogin target because '$(LoginAssetsPath)' already exists" Importance="high" Condition="Exists('$(LoginAssetsPath)')" />
<Message Text="Call BuildLogin target because UmbracoBuild is empty (this is Visual Studio) and '$(LoginAssetsPath)' doesn't exist" Importance="high" Condition="'$(UmbracoBuild)' == '' and !Exists('$(LoginAssetsPath)')" />
<CallTarget Targets="BuildLogin" Condition="'$(UmbracoBuild)' == '' and !Exists('$(LoginAssetsPath)')" />
</Target>
<Target Name="RestoreLogin" Inputs="$(LoginProjectDirectory)package-lock.json" Outputs="$(LoginProjectDirectory)node_modules/.package-lock.json">
<Message Importance="high" Text="Restoring Login NPM packages..." />
<Exec Command="npm ci --no-fund --no-audit --prefer-offline" WorkingDirectory="$(LoginProjectDirectory)" />
<Exec Command="npm i --no-fund --no-audit" WorkingDirectory="$(LoginProjectDirectory)" />
</Target>
<Target Name="BuildLogin" DependsOnTargets="RestoreLogin" BeforeTargets="AssignTargetPaths" Inputs="@(LoginAssetsInputs)" Outputs="$(IntermediateOutputPath)login.complete.txt">
<Target Name="BuildLogin" DependsOnTargets="RestoreLogin">
<Message Importance="high" Text="Executing Login NPM build script..." />
<Exec Command="npm run build" WorkingDirectory="$(LoginProjectDirectory)" />
<ItemGroup>
@@ -99,4 +131,19 @@
<Output TaskParameter="Assets" ItemName="StaticWebAsset" />
</DefineStaticWebAssets>
</Target>
<Target Name="CleanLoginStaticAssetsPreconditions" AfterTargets="Clean" Condition="'$(UmbracoBuild)' == ''">
<Message Text="Skip CleanLogin target because '$(LoginAssetsPath)' doesn't exist" Importance="high" Condition="!Exists('$(LoginAssetsPath)')" />
<Message Text="Skip CleanLogin target because preserve.login marker file exists" Importance="high" Condition="Exists('$(LoginAssetsPath)') and Exists('$(SolutionDir)preserve.login')" />
<Message Text="Call CleanLogin target because '$(LoginAssetsPath)' exists and preserve.login marker file doesn't exist" Importance="high" Condition="Exists('$(LoginAssetsPath)') and !Exists('$(SolutionDir)preserve.login')" />
<CallTarget Targets="CleanLogin" Condition="Exists('$(LoginAssetsPath)') and !Exists('$(SolutionDir)preserve.login')" />
</Target>
<Target Name="CleanLogin">
<ItemGroup>
<LoginDirectories Include="$(LoginAssetsPath)" />
</ItemGroup>
<RemoveDir Directories="@(LoginDirectories)" />
</Target>
<!-- END: Restore and build login project -->
</Project>
+2
View File
@@ -22,4 +22,6 @@ public static class CacheKeys
public const string PreviewPropertyCacheKeyPrefix = "Cache.Property.CacheValues[D:";
public const string PropertyCacheKeyPrefix = "Cache.Property.CacheValues[P:";
public const string MemberUserNameCachePrefix = "uRepo_userNameKey+";
}
@@ -1,3 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
@@ -21,9 +23,11 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
private readonly IDocumentNavigationManagementService _documentNavigationManagementService;
private readonly IContentService _contentService;
private readonly IDocumentCacheService _documentCacheService;
private readonly ICacheManager _cacheManager;
private readonly IPublishStatusManagementService _publishStatusManagementService;
private readonly IIdKeyMap _idKeyMap;
[Obsolete("Use the constructor with ICacheManager instead, scheduled for removal in V17.")]
public ContentCacheRefresher(
AppCaches appCaches,
IJsonSerializer serializer,
@@ -38,6 +42,39 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
IContentService contentService,
IPublishStatusManagementService publishStatusManagementService,
IDocumentCacheService documentCacheService)
: this(
appCaches,
serializer,
idKeyMap,
domainService,
eventAggregator,
factory,
documentUrlService,
domainCacheService,
documentNavigationQueryService,
documentNavigationManagementService,
contentService,
publishStatusManagementService,
documentCacheService,
StaticServiceProvider.Instance.GetRequiredService<ICacheManager>())
{
}
public ContentCacheRefresher(
AppCaches appCaches,
IJsonSerializer serializer,
IIdKeyMap idKeyMap,
IDomainService domainService,
IEventAggregator eventAggregator,
ICacheRefresherNotificationFactory factory,
IDocumentUrlService documentUrlService,
IDomainCacheService domainCacheService,
IDocumentNavigationQueryService documentNavigationQueryService,
IDocumentNavigationManagementService documentNavigationManagementService,
IContentService contentService,
IPublishStatusManagementService publishStatusManagementService,
IDocumentCacheService documentCacheService,
ICacheManager cacheManager)
: base(appCaches, serializer, eventAggregator, factory)
{
_idKeyMap = idKeyMap;
@@ -49,6 +86,11 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
_contentService = contentService;
_documentCacheService = documentCacheService;
_publishStatusManagementService = publishStatusManagementService;
// TODO: Ideally we should inject IElementsCache
// this interface is in infrastructure, and changing this is very breaking
// so as long as we have the cache manager, which casts the IElementsCache to a simple AppCache we might as well use that.
_cacheManager = cacheManager;
}
#region Indirect
@@ -83,6 +125,13 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
AppCaches.RuntimeCache.ClearOfType<PublicAccessEntry>();
AppCaches.RuntimeCache.ClearByKey(CacheKeys.ContentRecycleBinCacheKey);
// Ideally, we'd like to not have to clear the entire cache here. However, this was the existing behavior in NuCache.
// The reason for this is that we have no way to know which elements are affected by the changes or what their keys are.
// This is because currently published elements live exclusively in a JSON blob in the umbracoPropertyData table.
// This means that the only way to resolve these keys is to actually parse this data with a specific value converter, and for all cultures, which is not possible.
// If published elements become their own entities with relations, instead of just property data, we can revisit this.
_cacheManager.ElementsCache.Clear();
var idsRemoved = new HashSet<int>();
IAppPolicyCache isolatedCache = AppCaches.IsolatedCaches.GetOrCreate<IContent>();
@@ -1,3 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
@@ -18,7 +20,9 @@ public sealed class MediaCacheRefresher : PayloadCacheRefresherBase<MediaCacheRe
private readonly IMediaNavigationManagementService _mediaNavigationManagementService;
private readonly IMediaService _mediaService;
private readonly IMediaCacheService _mediaCacheService;
private readonly ICacheManager _cacheManager;
[Obsolete("Use the constructor with ICacheManager instead, scheduled for removal in V17.")]
public MediaCacheRefresher(
AppCaches appCaches,
IJsonSerializer serializer,
@@ -29,6 +33,31 @@ public sealed class MediaCacheRefresher : PayloadCacheRefresherBase<MediaCacheRe
IMediaNavigationManagementService mediaNavigationManagementService,
IMediaService mediaService,
IMediaCacheService mediaCacheService)
: this(
appCaches,
serializer,
idKeyMap,
eventAggregator,
factory,
mediaNavigationQueryService,
mediaNavigationManagementService,
mediaService,
mediaCacheService,
StaticServiceProvider.Instance.GetRequiredService<ICacheManager>())
{
}
public MediaCacheRefresher(
AppCaches appCaches,
IJsonSerializer serializer,
IIdKeyMap idKeyMap,
IEventAggregator eventAggregator,
ICacheRefresherNotificationFactory factory,
IMediaNavigationQueryService mediaNavigationQueryService,
IMediaNavigationManagementService mediaNavigationManagementService,
IMediaService mediaService,
IMediaCacheService mediaCacheService,
ICacheManager cacheManager)
: base(appCaches, serializer, eventAggregator, factory)
{
_idKeyMap = idKeyMap;
@@ -36,6 +65,9 @@ public sealed class MediaCacheRefresher : PayloadCacheRefresherBase<MediaCacheRe
_mediaNavigationManagementService = mediaNavigationManagementService;
_mediaService = mediaService;
_mediaCacheService = mediaCacheService;
// TODO: Use IElementsCache instead of ICacheManager, see ContentCacheRefresher for more information.
_cacheManager = cacheManager;
}
#region Indirect
@@ -87,6 +119,13 @@ public sealed class MediaCacheRefresher : PayloadCacheRefresherBase<MediaCacheRe
AppCaches.RuntimeCache.ClearByKey(CacheKeys.MediaRecycleBinCacheKey);
Attempt<IAppPolicyCache?> mediaCache = AppCaches.IsolatedCaches.Get<IMedia>();
// Ideally, we'd like to not have to clear the entire cache here. However, this was the existing behavior in NuCache.
// The reason for this is that we have no way to know which elements are affected by the changes or what their keys are.
// This is because currently published elements live exclusively in a JSON blob in the umbracoPropertyData table.
// This means that the only way to resolve these keys is to actually parse this data with a specific value converter, and for all cultures, which is not possible.
// If published elements become their own entities with relations, instead of just property data, we can revisit this.
_cacheManager.ElementsCache.Clear();
foreach (JsonPayload payload in payloads)
{
if (payload.ChangeTypes == TreeChangeTypes.Remove)
@@ -71,11 +71,22 @@ public sealed class MemberCacheRefresher : PayloadCacheRefresherBase<MemberCache
foreach (JsonPayload p in payloads)
{
_idKeyMap.ClearCache(p.Id);
if (memberCache.Success)
if (memberCache.Success is false)
{
memberCache.Result?.Clear(RepositoryCacheKeys.GetKey<IMember, int>(p.Id));
memberCache.Result?.Clear(RepositoryCacheKeys.GetKey<IMember, string>(p.Username));
continue;
}
memberCache.Result?.Clear(RepositoryCacheKeys.GetKey<IMember, int>(p.Id));
memberCache.Result?.Clear(RepositoryCacheKeys.GetKey<IMember, string>(p.Username));
// This specific cache key was introduced to fix an issue where the member username could not be the same as the member id, because the cache keys collided.
// This is done in a bit of a hacky way, because the cache key is created internally in the repository, but we need to clear it here.
// Ideally, we want to use a shared way of generating the key between this and the repository.
// Additionally, the RepositoryCacheKeys actually caches the string to avoid re-allocating memory; we would like to also use this in the repository
// See:
// https://github.com/umbraco/Umbraco-CMS/pull/17350
// https://github.com/umbraco/Umbraco-CMS/pull/17815
memberCache.Result?.Clear(RepositoryCacheKeys.GetKey<IMember, string>(CacheKeys.MemberUserNameCachePrefix + p.Username));
}
}
}
@@ -5,14 +5,17 @@ namespace Umbraco.Extensions;
public static class ContentSettingsExtensions
{
/// <summary>
/// Determines if file extension is allowed for upload based on (optional) white list and black list
/// held in settings.
/// Allow upload if extension is whitelisted OR if there is no whitelist and extension is NOT blacklisted.
/// Determines if file extension is allowed for upload based on (optional) allow list and deny list held in settings.
/// Disallowed file extensions are only considered if there are no allowed file extensions.
/// </summary>
public static bool IsFileAllowedForUpload(this ContentSettings contentSettings, string extension) =>
contentSettings.AllowedUploadedFileExtensions.Any(x => x.InvariantEquals(extension)) ||
(contentSettings.AllowedUploadedFileExtensions.Any() == false &&
contentSettings.DisallowedUploadedFileExtensions.Any(x => x.InvariantEquals(extension)) == false);
/// <param name="contentSettings">The content settings.</param>
/// <param name="extension">The file extension.</param>
/// <returns>
/// <c>true</c> if the file extension is allowed for upload; otherwise, <c>false</c>.
/// </returns>
public static bool IsFileAllowedForUpload(this ContentSettings contentSettings, string extension)
=> contentSettings.AllowedUploadedFileExtensions.Any(x => x.InvariantEquals(extension.Trim())) ||
(contentSettings.AllowedUploadedFileExtensions.Any() == false && contentSettings.DisallowedUploadedFileExtensions.Any(x => x.InvariantEquals(extension.Trim())) == false);
/// <summary>
/// Gets the auto-fill configuration for a specified property alias.
@@ -377,7 +377,7 @@
<key alias="invalidEmpty">Value cannot be empty</key>
<key alias="invalidPattern">Value is invalid, it does not match the correct pattern</key>
<key alias="entriesShort"><![CDATA[Minimum %0% entries, requires <strong>%1%</strong> more.]]></key>
<key alias="entriesExceed"><![CDATA[Maximum %0% entries, <strong>%1%</strong> too many.]]></key>
<key alias="entriesExceed"><![CDATA[Maximum %0% entries, you have entered <strong>%1%</strong> too many.]]></key>
<key alias="stringLengthExceeded">The string length exceeds the maximum length of %0% characters, %1% too many.</key>
<key alias="entriesAreasMismatch">The content amount requirements are not met for one or more areas.</key>
<key alias="invalidMemberGroupName">Invalid member group name</key>
@@ -394,7 +394,7 @@
<key alias="unexpectedRange">The value %0% is not expected to contain a range</key>
<key alias="invalidRange">The value %0% is not expected to have a to value less than the from value</key>
<key alias="entriesShort"><![CDATA[Minimum %0% entries, requires <strong>%1%</strong> more.]]></key>
<key alias="entriesExceed"><![CDATA[Maximum %0% entries, <strong>%1%</strong> too many.]]></key>
<key alias="entriesExceed"><![CDATA[Maximum %0% entries, you have entered <strong>%1%</strong> too many.]]></key>
<key alias="stringLengthExceeded">The string length exceeds the maximum length of %0% characters, %1% too many.</key>
<key alias="entriesAreasMismatch">The content amount requirements are not met for one or more areas.</key>
<key alias="invalidMediaType">The chosen media type is invalid.</key>
@@ -245,13 +245,13 @@ public static class ContentExtensions
}
IEnumerable<ContentSchedule> expires = contentSchedule.GetSchedule(culture, ContentScheduleAction.Expire);
if (expires != null && expires.Any(x => x.Date > DateTime.MinValue && DateTime.Now > x.Date))
if (expires != null && expires.Any(x => x.Date > DateTime.MinValue && DateTime.UtcNow > x.Date))
{
return ContentStatus.Expired;
}
IEnumerable<ContentSchedule> release = contentSchedule.GetSchedule(culture, ContentScheduleAction.Release);
if (release != null && release.Any(x => x.Date > DateTime.MinValue && x.Date > DateTime.Now))
if (release != null && release.Any(x => x.Date > DateTime.MinValue && x.Date > DateTime.UtcNow))
{
return ContentStatus.AwaitingRelease;
}
@@ -9,7 +9,7 @@ namespace Umbraco.Cms.Core.HostedServices;
public interface IBackgroundTaskQueue
{
/// <summary>
/// Enqueue a work item to be executed on in the background.
/// Enqueue a work item to be executed in the background.
/// </summary>
void QueueBackgroundWorkItem(Func<CancellationToken, Task> workItem);
+1 -1
View File
@@ -358,7 +358,7 @@ namespace Umbraco.Cms.Core.IO
// nothing prevents us to reach the file, security-wise, yet it is outside
// this filesystem's root - throw
throw new UnauthorizedAccessException($"File original: [{originalPath}] full: [{path}] is outside this filesystem's root.");
throw new UnauthorizedAccessException($"Requested path {originalPath} is outside this filesystem's root.");
}
/// <summary>
@@ -1,8 +1,10 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services.OperationStatus;
@@ -35,6 +37,21 @@ internal sealed class ContentBlueprintEditingService
return await Task.FromResult(blueprint);
}
public Task<IContent?> GetScaffoldedAsync(Guid key)
{
IContent? blueprint = ContentService.GetBlueprintById(key);
if (blueprint is null)
{
return Task.FromResult<IContent?>(null);
}
using ICoreScope scope = CoreScopeProvider.CreateCoreScope();
scope.Notifications.Publish(new ContentScaffoldedNotification(blueprint, blueprint, Constants.System.Root, new EventMessages()));
scope.Complete();
return Task.FromResult<IContent?>(blueprint);
}
public async Task<Attempt<PagedModel<IContent>?, ContentEditingOperationStatus>> GetPagedByContentTypeAsync(Guid contentTypeKey, int skip, int take)
{
IContentType? contentType = await ContentTypeService.GetAsync(contentTypeKey);
+45 -25
View File
@@ -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; }
}
}
@@ -168,45 +193,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);
if (existingCacheModel.UrlSegments.Any(x => x.Segment == model.UrlSegment) is false)
{
existingCacheModel.UrlSegments.Add(new PublishedDocumentUrlSegments.UrlSegment(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 (urlSegments.FirstOrDefault(x => x.Segment == segment) is null)
{
urlSegments.Add(new PublishedDocumentUrlSegments.UrlSegment(segment, isPrimary));
}
return urlSegments;
return cacheModels.Values;
}
private void RemoveFromCache(IScopeContext scopeContext, Guid documentKey, string isoCode, bool isDraft)
@@ -8,6 +8,8 @@ public interface IContentBlueprintEditingService
{
Task<IContent?> GetAsync(Guid key);
Task<IContent?> GetScaffoldedAsync(Guid key) => Task.FromResult<IContent?>(null);
Task<Attempt<PagedModel<IContent>?, ContentEditingOperationStatus>> GetPagedByContentTypeAsync(
Guid contentTypeKey,
int skip,
@@ -9,4 +9,13 @@ namespace Umbraco.Cms.Infrastructure.HostedServices;
[Obsolete("This has been relocated into Umbraco.Cms.Core. This definition in Umbraco.Cms.Infrastructure is scheduled for removal in Umbraco 17.")]
public interface IBackgroundTaskQueue : Core.HostedServices.IBackgroundTaskQueue
{
/// <summary>
/// Enqueue a work item to be executed in the background.
/// </summary>
void QueueBackgroundWorkItem(Func<CancellationToken, Task> workItem);
/// <summary>
/// Dequeue the first item on the queue.
/// </summary>
Task<Func<CancellationToken, Task>?> DequeueAsync(CancellationToken cancellationToken);
}
@@ -27,11 +27,11 @@ internal class AccessDto
[ForeignKey(typeof(NodeDto), Name = "FK_umbracoAccess_umbracoNode_id2")]
public int NoAccessNodeId { get; set; }
[Column("createDate")]
[Column("createDate", ForceToUtc = false)]
[Constraint(Default = SystemMethods.CurrentDateTime)]
public DateTime CreateDate { get; set; }
[Column("updateDate")]
[Column("updateDate", ForceToUtc = false)]
[Constraint(Default = SystemMethods.CurrentDateTime)]
public DateTime UpdateDate { get; set; }
@@ -25,11 +25,11 @@ internal class AccessRuleDto
[Column("ruleType")]
public string? RuleType { get; set; }
[Column("createDate")]
[Column("createDate", ForceToUtc = false)]
[Constraint(Default = SystemMethods.CurrentDateTime)]
public DateTime CreateDate { get; set; }
[Column("updateDate")]
[Column("updateDate", ForceToUtc = false)]
[Constraint(Default = SystemMethods.CurrentDateTime)]
public DateTime UpdateDate { get; set; }
}
@@ -30,7 +30,7 @@ internal class AuditEntryDto
[Length(Constants.Audit.IpLength)]
public string? PerformingIp { get; set; }
[Column("eventDateUtc")]
[Column("eventDateUtc", ForceToUtc = false)]
[Constraint(Default = SystemMethods.CurrentDateTime)]
public DateTime EventDateUtc { get; set; }
@@ -29,7 +29,7 @@ public class ConsentDto
[Length(512)]
public string? Action { get; set; }
[Column("createDate")]
[Column("createDate", ForceToUtc = false)]
[Constraint(Default = SystemMethods.CurrentDateTime)]
public DateTime CreateDate { get; set; }
@@ -24,6 +24,7 @@ internal class ContentScheduleDto
[NullSetting(NullSetting = NullSettings.Null)] // can be invariant
public int? LanguageId { get; set; }
// NOTE: this date is explicitly stored and treated as UTC despite the lack of "Utc" postfix.
[Column("date")]
public DateTime Date { get; set; }
@@ -27,6 +27,6 @@ internal class ContentVersionCleanupPolicyDto
[NullSetting(NullSetting = NullSettings.Null)]
public int? KeepLatestVersionPerDayForDays { get; set; }
[Column("updated")]
[Column("updated", ForceToUtc = false)]
public DateTime Updated { get; set; }
}
@@ -33,7 +33,7 @@ internal class ContentVersionCultureVariationDto
[Column("name")]
public string? Name { get; set; }
[Column("date")] // TODO: db rename to 'updateDate'
[Column("date", ForceToUtc = false)] // TODO: db rename to 'updateDate'
public DateTime UpdateDate { get; set; }
[Column("availableUserId")] // TODO: db rename to 'updateDate'
@@ -22,7 +22,7 @@ public class ContentVersionDto
[Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_NodeId", ForColumns = "nodeId,current", IncludeColumns = "id,versionDate,text,userId,preventCleanup")]
public int NodeId { get; set; }
[Column("versionDate")] // TODO: db rename to 'updateDate'
[Column("versionDate", ForceToUtc = false)] // TODO: db rename to 'updateDate'
[Constraint(Default = SystemMethods.CurrentDateTime)]
public DateTime VersionDate { get; set; }
@@ -27,7 +27,7 @@ public class CreatedPackageSchemaDto
[NullSetting(NullSetting = NullSettings.NotNull)]
public string Value { get; set; } = null!;
[Column("updateDate")]
[Column("updateDate", ForceToUtc = false)]
[Constraint(Default = SystemMethods.CurrentDateTime)]
public DateTime UpdateDate { get; set; }
@@ -20,6 +20,6 @@ internal class DocumentPublishedReadOnlyDto
[Column("newest")]
public bool Newest { get; set; }
[Column("updateDate")]
[Column("updateDate", ForceToUtc = false)]
public DateTime VersionDate { get; set; }
}
@@ -42,7 +42,7 @@ internal class ExternalLoginDto
[Index(IndexTypes.NonClustered, ForColumns = "loginProvider,providerKey", Name = "IX_" + TableName + "_ProviderKey")]
public string ProviderKey { get; set; } = null!;
[Column("createDate")]
[Column("createDate", ForceToUtc = false)]
[Constraint(Default = SystemMethods.CurrentDateTime)]
public DateTime CreateDate { get; set; }
@@ -31,7 +31,7 @@ internal class ExternalLoginTokenDto
[NullSetting(NullSetting = NullSettings.NotNull)]
public string Value { get; set; } = null!;
[Column("createDate")]
[Column("createDate", ForceToUtc = false)]
[Constraint(Default = SystemMethods.CurrentDateTime)]
public DateTime CreateDate { get; set; }
@@ -23,7 +23,7 @@ internal class KeyValueDto
[NullSetting(NullSetting = NullSettings.Null)]
public string? Value { get; set; }
[Column("updated")]
[Column("updated", ForceToUtc = false)]
[Constraint(Default = SystemMethods.CurrentDateTime)]
public DateTime UpdateDate { get; set; }
@@ -35,7 +35,7 @@ internal class LogDto
[NullSetting(NullSetting = NullSettings.Null)]
public string? EntityType { get; set; }
[Column("Datestamp")]
[Column("Datestamp", ForceToUtc = false)]
[Constraint(Default = SystemMethods.CurrentDateTime)]
[Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_datestamp", ForColumns = "Datestamp,userId,NodeId")]
public DateTime Datestamp { get; set; }
@@ -45,7 +45,7 @@ internal class MemberDto
[Length(255)]
public string? SecurityStampToken { get; set; }
[Column("emailConfirmedDate")]
[Column("emailConfirmedDate", ForceToUtc = false)]
[NullSetting(NullSetting = NullSettings.Null)]
public DateTime? EmailConfirmedDate { get; set; }
@@ -62,15 +62,15 @@ internal class MemberDto
[Constraint(Default = 1)]
public bool IsApproved { get; set; }
[Column("lastLoginDate")]
[Column("lastLoginDate", ForceToUtc = false)]
[NullSetting(NullSetting = NullSettings.Null)]
public DateTime? LastLoginDate { get; set; }
[Column("lastLockoutDate")]
[Column("lastLockoutDate", ForceToUtc = false)]
[NullSetting(NullSetting = NullSettings.Null)]
public DateTime? LastLockoutDate { get; set; }
[Column("lastPasswordChangeDate")]
[Column("lastPasswordChangeDate", ForceToUtc = false)]
[NullSetting(NullSetting = NullSettings.Null)]
public DateTime? LastPasswordChangeDate { get; set; }
@@ -70,7 +70,7 @@ public class NodeDto
[Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_ObjectType", ForColumns = "nodeObjectType,trashed", IncludeColumns = "uniqueId,parentId,level,path,sortOrder,nodeUser,text,createDate")]
public Guid? NodeObjectType { get; set; }
[Column("createDate")]
[Column("createDate", ForceToUtc = false)]
[Constraint(Default = SystemMethods.CurrentDateTime)]
public DateTime CreateDate { get; set; }
}
@@ -27,7 +27,7 @@ internal class RelationDto
[ForeignKey(typeof(RelationTypeDto))]
public int RelationType { get; set; }
[Column("datetime")]
[Column("datetime", ForceToUtc = false)]
[Constraint(Default = SystemMethods.CurrentDateTime)]
public DateTime Datetime { get; set; }
@@ -23,11 +23,11 @@ internal class ServerRegistrationDto
[Index(IndexTypes.UniqueNonClustered, Name = "IX_computerName")] // server identity is unique
public string? ServerIdentity { get; set; }
[Column("registeredDate")]
[Column("registeredDate", ForceToUtc = false)]
[Constraint(Default = SystemMethods.CurrentDateTime)]
public DateTime DateRegistered { get; set; }
[Column("lastNotifiedDate")]
[Column("lastNotifiedDate", ForceToUtc = false)]
public DateTime DateAccessed { get; set; }
[Column("isActive")]
@@ -73,32 +73,32 @@ public class UserDto
[NullSetting(NullSetting = NullSettings.Null)]
public int? FailedLoginAttempts { get; set; }
[Column("lastLockoutDate")]
[Column("lastLockoutDate", ForceToUtc = false)]
[NullSetting(NullSetting = NullSettings.Null)]
public DateTime? LastLockoutDate { get; set; }
[Column("lastPasswordChangeDate")]
[Column("lastPasswordChangeDate", ForceToUtc = false)]
[NullSetting(NullSetting = NullSettings.Null)]
public DateTime? LastPasswordChangeDate { get; set; }
[Column("lastLoginDate")]
[Column("lastLoginDate", ForceToUtc = false)]
[NullSetting(NullSetting = NullSettings.Null)]
public DateTime? LastLoginDate { get; set; }
[Column("emailConfirmedDate")]
[Column("emailConfirmedDate", ForceToUtc = false)]
[NullSetting(NullSetting = NullSettings.Null)]
public DateTime? EmailConfirmedDate { get; set; }
[Column("invitedDate")]
[Column("invitedDate", ForceToUtc = false)]
[NullSetting(NullSetting = NullSettings.Null)]
public DateTime? InvitedDate { get; set; }
[Column("createDate")]
[Column("createDate", ForceToUtc = false)]
[NullSetting(NullSetting = NullSettings.NotNull)]
[Constraint(Default = SystemMethods.CurrentDateTime)]
public DateTime CreateDate { get; set; } = DateTime.Now;
[Column("updateDate")]
[Column("updateDate", ForceToUtc = false)]
[NullSetting(NullSetting = NullSettings.NotNull)]
[Constraint(Default = SystemMethods.CurrentDateTime)]
public DateTime UpdateDate { get; set; } = DateTime.Now;
@@ -44,12 +44,12 @@ public class UserGroupDto
[Obsolete("Is not used anymore Use UserGroup2PermissionDtos instead. This will be removed in Umbraco 18.")]
public string? DefaultPermissions { get; set; }
[Column("createDate")]
[Column("createDate", ForceToUtc = false)]
[NullSetting(NullSetting = NullSettings.NotNull)]
[Constraint(Default = SystemMethods.CurrentDateTime)]
public DateTime CreateDate { get; set; }
[Column("updateDate")]
[Column("updateDate", ForceToUtc = false)]
[NullSetting(NullSetting = NullSettings.NotNull)]
[Constraint(Default = SystemMethods.CurrentDateTime)]
public DateTime UpdateDate { get; set; }
@@ -1,4 +1,4 @@
using NPoco;
using NPoco;
using Umbraco.Cms.Core;
using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations;
@@ -24,7 +24,7 @@ internal class WebhookLogDto
[NullSetting(NullSetting = NullSettings.NotNull)]
public string StatusCode { get; set; } = string.Empty;
[Column(Name = "date")]
[Column(Name = "date", ForceToUtc = false)]
[Index(IndexTypes.NonClustered, Name = "IX_" + Constants.DatabaseSchema.Tables.WebhookLog + "_date")]
[NullSetting(NullSetting = NullSettings.NotNull)]
public DateTime Date { get; set; }
@@ -39,7 +39,6 @@ public class MemberRepository : ContentRepositoryBase<int, IMember, MemberReposi
private readonly ITagRepository _tagRepository;
private bool _passwordConfigInitialized;
private string? _passwordConfigJson;
private const string UsernameCacheKey = "uRepo_userNameKey+";
public MemberRepository(
IScopeAccessor scopeAccessor,
@@ -327,7 +326,7 @@ public class MemberRepository : ContentRepositoryBase<int, IMember, MemberReposi
}
public IMember? GetByUsername(string? username) =>
_memberByUsernameCachePolicy.GetByUserName(UsernameCacheKey, username, PerformGetByUsername, PerformGetAllByUsername);
_memberByUsernameCachePolicy.GetByUserName(CacheKeys.MemberUserNameCachePrefix, username, PerformGetByUsername, PerformGetAllByUsername);
public int[] GetMemberIds(string[] usernames)
{
@@ -609,7 +608,7 @@ public class MemberRepository : ContentRepositoryBase<int, IMember, MemberReposi
protected override void PersistDeletedItem(IMember entity)
{
_memberByUsernameCachePolicy.DeleteByUserName(UsernameCacheKey, entity.Username);
_memberByUsernameCachePolicy.DeleteByUserName(CacheKeys.MemberUserNameCachePrefix, entity.Username);
base.PersistDeletedItem(entity);
}
@@ -943,7 +942,7 @@ public class MemberRepository : ContentRepositoryBase<int, IMember, MemberReposi
OnUowRefreshedEntity(new MemberRefreshNotification(entity, new EventMessages()));
_memberByUsernameCachePolicy.DeleteByUserName(UsernameCacheKey, entity.Username);
_memberByUsernameCachePolicy.DeleteByUserName(CacheKeys.MemberUserNameCachePrefix, entity.Username);
entity.ResetDirtyProperties();
}
@@ -10,6 +10,7 @@ using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.PropertyEditors;
@@ -291,6 +292,11 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
bool canUpdateInvariantData,
HashSet<string> allowedCultures)
{
if (canUpdateInvariantData is false && targetValue is null)
{
return sourceValue;
}
BlockEditorData<TValue, TLayout>? source = BlockEditorValues.DeserializeAndClean(sourceValue);
BlockEditorData<TValue, TLayout>? target = BlockEditorValues.DeserializeAndClean(targetValue);
@@ -310,31 +316,29 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
bool canUpdateInvariantData,
HashSet<string> allowedCultures)
{
source = UpdateSourceInvariantData(source, target, canUpdateInvariantData);
var mergedInvariant = UpdateSourceInvariantData(source, target, canUpdateInvariantData);
if (source is null && target is null)
// if the structure (invariant) is not defined after merger, the target content does not matter
if (mergedInvariant is null)
{
return null;
}
if (source is null && target?.Layout is not null)
// since we merged the invariant data (layout) before we get to this point
// we just need an empty valid object to run comparisons at this point
if (source is null)
{
source = new BlockEditorData<TValue, TLayout>([], CreateWithLayout(target.Layout));
}
else if (target is null && source?.Layout is not null)
{
target = new BlockEditorData<TValue, TLayout>([], CreateWithLayout(source.Layout));
source = new BlockEditorData<TValue, TLayout>([], new TValue());
}
// at this point the layout should have been merged or fallback created
if (source is null || target is null)
{
throw new ArgumentException("invalid sourceValue or targetValue");
}
// update the target with the merged invariant
target!.BlockValue.Layout = mergedInvariant.BlockValue.Layout;
// remove all the blocks that are no longer part of the layout
target.BlockValue.ContentData.RemoveAll(contentBlock =>
target.Layout!.Any(layoutItem => layoutItem.ReferencesContent(contentBlock.Key)) is false);
// remove any exposes that no longer have content assigned to them
target.BlockValue.Expose.RemoveAll(expose => target.BlockValue.ContentData.Any(data => data.Key == expose.ContentKey) is false);
target.BlockValue.SettingsData.RemoveAll(settingsBlock =>
target.Layout!.Any(layoutItem => layoutItem.ReferencesSetting(settingsBlock.Key)) is false);
@@ -342,16 +346,80 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
CleanupVariantValues(source.BlockValue.ContentData, target.BlockValue.ContentData, canUpdateInvariantData, allowedCultures);
CleanupVariantValues(source.BlockValue.SettingsData, target.BlockValue.SettingsData, canUpdateInvariantData, allowedCultures);
// every source block value for a culture that is not allowed to be edited should be present on the target
RestoreMissingValues(
source.BlockValue.ContentData,
target.BlockValue.ContentData,
mergedInvariant.Layout!,
(layoutItem, itemData) => layoutItem.ContentKey == itemData.Key,
canUpdateInvariantData,
allowedCultures);
RestoreMissingValues(
source.BlockValue.SettingsData,
target.BlockValue.SettingsData,
mergedInvariant.Layout!,
(layoutItem, itemData) => layoutItem.SettingsKey == itemData.Key,
canUpdateInvariantData,
allowedCultures);
// update the expose list from source for any blocks that were restored
var missingSourceExposes =
source.BlockValue.Expose.Where(sourceExpose =>
target.BlockValue.Expose.Any(targetExpose => targetExpose.ContentKey == sourceExpose.ContentKey) is false
&& target.BlockValue.ContentData.Any(data => data.Key == sourceExpose.ContentKey)).ToList();
foreach (BlockItemVariation missingSourceExpose in missingSourceExposes)
{
target.BlockValue.Expose.Add(missingSourceExpose);
}
return target.BlockValue;
}
private void RestoreMissingValues(
List<BlockItemData> sourceBlockItemData,
List<BlockItemData> targetBlockItemData,
IEnumerable<TLayout> mergedLayout,
Func<TLayout, BlockItemData, bool> relevantBlockItemMatcher,
bool canUpdateInvariantData,
HashSet<string> allowedCultures)
{
IEnumerable<BlockItemData> blockItemsToCheck = sourceBlockItemData.Where(itemData =>
mergedLayout.Any(layoutItem => relevantBlockItemMatcher(layoutItem, itemData)));
foreach (BlockItemData blockItemData in blockItemsToCheck)
{
var relevantValues = blockItemData.Values.Where(value =>
(value.Culture is null && canUpdateInvariantData is false)
|| (value.Culture is not null && allowedCultures.Contains(value.Culture) is false)).ToList();
if (relevantValues.Count < 1)
{
continue;
}
BlockItemData targetBlockData =
targetBlockItemData.FirstOrDefault(itemData => itemData.Key == blockItemData.Key)
?? new BlockItemData(blockItemData.Key, blockItemData.ContentTypeKey, blockItemData.ContentTypeAlias);
foreach (BlockPropertyValue missingValue in relevantValues.Where(value => targetBlockData.Values.Any(targetValue =>
targetValue.Alias == value.Alias
&& targetValue.Culture == value.Culture
&& targetValue.Segment == value.Segment) is false))
{
targetBlockData.Values.Add(missingValue);
}
if (targetBlockItemData.Any(existingBlockItemData => existingBlockItemData.Key == targetBlockData.Key) is false)
{
targetBlockItemData.Add(blockItemData);
}
}
}
private void CleanupVariantValues(
List<BlockItemData> sourceBlockItems,
List<BlockItemData> targetBlockItems,
bool canUpdateInvariantData,
HashSet<string> allowedCultures)
{
// merge the source values into the target values for culture
// merge the source values into the target values per culture
foreach (BlockItemData targetBlockItem in targetBlockItems)
{
BlockItemData? sourceBlockItem = sourceBlockItems.FirstOrDefault(i => i.Key == targetBlockItem.Key);
@@ -25,65 +25,40 @@ internal sealed class CacheRefreshingNotificationHandler :
{
private readonly IDocumentCacheService _documentCacheService;
private readonly IMediaCacheService _mediaCacheService;
private readonly IElementsCache _elementsCache;
private readonly IRelationService _relationService;
private readonly IPublishedContentTypeCache _publishedContentTypeCache;
public CacheRefreshingNotificationHandler(
IDocumentCacheService documentCacheService,
IMediaCacheService mediaCacheService,
IElementsCache elementsCache,
IRelationService relationService,
IPublishedContentTypeCache publishedContentTypeCache)
{
_documentCacheService = documentCacheService;
_mediaCacheService = mediaCacheService;
_elementsCache = elementsCache;
_relationService = relationService;
_publishedContentTypeCache = publishedContentTypeCache;
}
public async Task HandleAsync(ContentRefreshNotification notification, CancellationToken cancellationToken)
{
ClearElementsCache();
await _documentCacheService.RefreshContentAsync(notification.Entity);
}
=> await _documentCacheService.RefreshContentAsync(notification.Entity);
public async Task HandleAsync(ContentDeletedNotification notification, CancellationToken cancellationToken)
{
foreach (IContent deletedEntity in notification.DeletedEntities)
{
ClearElementsCache();
await _documentCacheService.DeleteItemAsync(deletedEntity);
}
}
public async Task HandleAsync(MediaRefreshNotification notification, CancellationToken cancellationToken)
{
ClearElementsCache();
await _mediaCacheService.RefreshMediaAsync(notification.Entity);
}
=> await _mediaCacheService.RefreshMediaAsync(notification.Entity);
public async Task HandleAsync(MediaDeletedNotification notification, CancellationToken cancellationToken)
{
foreach (IMedia deletedEntity in notification.DeletedEntities)
{
ClearElementsCache();
await _mediaCacheService.DeleteItemAsync(deletedEntity);
}
}
private void ClearElementsCache()
{
// Ideally we'd like to not have to clear the entire cache here. However, this was the existing behavior in NuCache.
// The reason for this is that we have no way to know which elements are affected by the changes. or what their keys are.
// This is because currently published elements lives exclusively in a JSON blob in the umbracoPropertyData table.
// This means that the only way to resolve these keys are to actually parse this data with a specific value converter, and for all cultures, which is not feasible.
// If published elements become their own entities with relations, instead of just property data, we can revisit this,
_elementsCache.Clear();
}
public Task HandleAsync(ContentTypeRefreshedNotification notification, CancellationToken cancellationToken)
{
const ContentTypeChangeTypes types // only for those that have been refreshed
@@ -1,4 +1,4 @@
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
@@ -115,12 +115,10 @@ internal sealed class DocumentCacheService : IDocumentCacheService
// When unpublishing a node, a payload with RefreshBranch is published, so we don't have to worry about this.
// Similarly, when a branch is published, next time the content is requested, the parent will be published,
// this works because we don't cache null values.
if (preview is false && contentCacheNode is not null)
if (preview is false && contentCacheNode is not null && HasPublishedAncestorPath(contentCacheNode.Key) is false)
{
if (HasPublishedAncestorPath(contentCacheNode.Key) is false)
{
return null;
}
// Careful not to early return here. We need to complete the scope even if returning null.
contentCacheNode = null;
}
scope.Complete();
+141 -141
View File
@@ -1,32 +1,32 @@
{
"name": "@umbraco-cms/backoffice",
"version": "15.4.0-rc",
"version": "15.4.0-rc3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@umbraco-cms/backoffice",
"version": "15.4.0-rc",
"version": "15.4.0-rc3",
"license": "MIT",
"workspaces": [
"./src/packages/*"
],
"dependencies": {
"@tiptap/core": "2.11.5",
"@tiptap/extension-character-count": "^2.11.5",
"@tiptap/extension-image": "2.11.5",
"@tiptap/extension-link": "2.11.5",
"@tiptap/extension-placeholder": "2.11.5",
"@tiptap/extension-subscript": "2.11.5",
"@tiptap/extension-superscript": "2.11.5",
"@tiptap/extension-table": "2.11.5",
"@tiptap/extension-table-cell": "2.11.5",
"@tiptap/extension-table-header": "2.11.5",
"@tiptap/extension-table-row": "2.11.5",
"@tiptap/extension-text-align": "2.11.5",
"@tiptap/extension-underline": "2.11.5",
"@tiptap/pm": "2.11.5",
"@tiptap/starter-kit": "2.11.5",
"@tiptap/core": "2.11.7",
"@tiptap/extension-character-count": "2.11.7",
"@tiptap/extension-image": "2.11.7",
"@tiptap/extension-link": "2.11.7",
"@tiptap/extension-placeholder": "2.11.7",
"@tiptap/extension-subscript": "2.11.7",
"@tiptap/extension-superscript": "2.11.7",
"@tiptap/extension-table": "2.11.7",
"@tiptap/extension-table-cell": "2.11.7",
"@tiptap/extension-table-header": "2.11.7",
"@tiptap/extension-table-row": "2.11.7",
"@tiptap/extension-text-align": "2.11.7",
"@tiptap/extension-underline": "2.11.7",
"@tiptap/pm": "2.11.7",
"@tiptap/starter-kit": "2.11.7",
"@types/diff": "^7.0.1",
"@umbraco-ui/uui": "^1.13.0",
"@umbraco-ui/uui-css": "^1.13.0",
@@ -2534,9 +2534,9 @@
}
},
"node_modules/@tiptap/core": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.11.5.tgz",
"integrity": "sha512-jb0KTdUJaJY53JaN7ooY3XAxHQNoMYti/H6ANo707PsLXVeEqJ9o8+eBup1JU5CuwzrgnDc2dECt2WIGX9f8Jw==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.11.7.tgz",
"integrity": "sha512-zN+NFFxLsxNEL8Qioc+DL6b8+Tt2bmRbXH22Gk6F6nD30x83eaUSFlSv3wqvgyCq3I1i1NO394So+Agmayx6rQ==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2547,9 +2547,9 @@
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.11.5.tgz",
"integrity": "sha512-MZfcRIzKRD8/J1hkt/eYv49060GTL6qGR3NY/oTDuw2wYzbQXXLEbjk8hxAtjwNn7G+pWQv3L+PKFzZDxibLuA==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.11.7.tgz",
"integrity": "sha512-liD8kWowl3CcYCG9JQlVx1eSNc/aHlt6JpVsuWvzq6J8APWX693i3+zFqyK2eCDn0k+vW62muhSBe3u09hA3Zw==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2560,9 +2560,9 @@
}
},
"node_modules/@tiptap/extension-bold": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.11.5.tgz",
"integrity": "sha512-OAq03MHEbl7MtYCUzGuwb0VpOPnM0k5ekMbEaRILFU5ZC7cEAQ36XmPIw1dQayrcuE8GZL35BKub2qtRxyC9iA==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.11.7.tgz",
"integrity": "sha512-VTR3JlldBixXbjpLTFme/Bxf1xeUgZZY3LTlt5JDlCW3CxO7k05CIa+kEZ8LXpog5annytZDUVtWqxrNjmsuHQ==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2573,9 +2573,9 @@
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.11.5.tgz",
"integrity": "sha512-VXwHlX6A/T6FAspnyjbKDO0TQ+oetXuat6RY1/JxbXphH42nLuBaGWJ6pgy6xMl6XY8/9oPkTNrfJw/8/eeRwA==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.11.7.tgz",
"integrity": "sha512-WbPogE2/Q3e3/QYgbT1Sj4KQUfGAJNc5pvb7GrUbvRQsAh7HhtuO8hqdDwH8dEdD/cNUehgt17TO7u8qV6qeBw==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2586,9 +2586,9 @@
}
},
"node_modules/@tiptap/extension-character-count": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-2.11.5.tgz",
"integrity": "sha512-Da2VGb7ClmKwXdQdQC2735qylYD8/MQAPA0skPEcHxcDTDuI8ibyIDnMPnczgS/hR5g0TYE2DQp/dkhJXeovkQ==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-2.11.7.tgz",
"integrity": "sha512-gcVbKou+uxzg8N0BBKceLwtpWvN8g2TIjTuCdyAcAPukX63DqVWOkofFHn1RqZbstJmtF4pTGZs9OH/GJrp27Q==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2600,9 +2600,9 @@
}
},
"node_modules/@tiptap/extension-code": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.11.5.tgz",
"integrity": "sha512-xOvHevNIQIcCCVn9tpvXa1wBp0wHN/2umbAZGTVzS+AQtM7BTo0tz8IyzwxkcZJaImONcUVYLOLzt2AgW1LltA==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.11.7.tgz",
"integrity": "sha512-VpPO1Uy/eF4hYOpohS/yMOcE1C07xmMj0/D989D9aS1x95jWwUVrSkwC+PlWMUBx9PbY2NRsg1ZDwVvlNKZ6yQ==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2613,9 +2613,9 @@
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.11.5.tgz",
"integrity": "sha512-ksxMMvqLDlC+ftcQLynqZMdlJT1iHYZorXsXw/n+wuRd7YElkRkd6YWUX/Pq/njFY6lDjKiqFLEXBJB8nrzzBA==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.11.7.tgz",
"integrity": "sha512-To/y/2H04VWqiANy53aXjV7S6fA86c2759RsH1hTIe57jA1KyE7I5tlAofljOLZK/covkGmPeBddSPHGJbz++Q==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2627,9 +2627,9 @@
}
},
"node_modules/@tiptap/extension-document": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.11.5.tgz",
"integrity": "sha512-7I4BRTpIux2a0O2qS3BDmyZ5LGp3pszKbix32CmeVh7lN9dV7W5reDqtJJ9FCZEEF+pZ6e1/DQA362dflwZw2g==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.11.7.tgz",
"integrity": "sha512-95ouJXPjdAm9+VBRgFo4lhDoMcHovyl/awORDI8gyEn0Rdglt+ZRZYoySFzbVzer9h0cre+QdIwr9AIzFFbfdA==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2640,9 +2640,9 @@
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.11.5.tgz",
"integrity": "sha512-uIN7L3FU0904ec7FFFbndO7RQE/yiON4VzAMhNn587LFMyWO8US139HXIL4O8dpZeYwYL3d1FnDTflZl6CwLlg==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.11.7.tgz",
"integrity": "sha512-63mL+nxQILizsr5NbmgDeOjFEWi34BLt7evwL6UUZEVM15K8V1G8pD9Y0kCXrZYpHWz0tqFRXdrhDz0Ppu8oVw==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2654,9 +2654,9 @@
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.11.5.tgz",
"integrity": "sha512-kcWa+Xq9cb6lBdiICvLReuDtz/rLjFKHWpW3jTTF3FiP3wx4H8Rs6bzVtty7uOVTfwupxZRiKICAMEU6iT0xrQ==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.11.7.tgz",
"integrity": "sha512-EceesmPG7FyjXZ8EgeJPUov9G1mAf2AwdypxBNH275g6xd5dmU/KvjoFZjmQ0X1ve7mS+wNupVlGxAEUYoveew==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2668,9 +2668,9 @@
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.11.5.tgz",
"integrity": "sha512-q9doeN+Yg9F5QNTG8pZGYfNye3tmntOwch683v0CCVCI4ldKaLZ0jG3NbBTq+mosHYdgOH2rNbIORlRRsQ+iYQ==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.11.7.tgz",
"integrity": "sha512-zTkZSA6q+F5sLOdCkiC2+RqJQN0zdsJqvFIOVFL/IDVOnq6PZO5THzwRRLvOSnJJl3edRQCl/hUgS0L5sTInGQ==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2681,9 +2681,9 @@
}
},
"node_modules/@tiptap/extension-heading": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.11.5.tgz",
"integrity": "sha512-x/MV53psJ9baRcZ4k4WjnCUBMt8zCX7mPlKVT+9C/o+DEs/j/qxPLs95nHeQv70chZpSwCQCt93xMmuF0kPoAg==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.11.7.tgz",
"integrity": "sha512-8kWh7y4Rd2fwxfWOhFFWncHdkDkMC1Z60yzIZWjIu72+6yQxvo8w3yeb7LI7jER4kffbMmadgcfhCHC/fkObBA==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2694,9 +2694,9 @@
}
},
"node_modules/@tiptap/extension-history": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.11.5.tgz",
"integrity": "sha512-b+wOS33Dz1azw6F1i9LFTEIJ/gUui0Jwz5ZvmVDpL2ZHBhq1Ui0/spTT+tuZOXq7Y/uCbKL8Liu4WoedIvhboQ==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.11.7.tgz",
"integrity": "sha512-Cu5x3aS13I040QSRoLdd+w09G4OCVfU+azpUqxufZxeNs9BIJC+0jowPLeOxKDh6D5GGT2A8sQtxc6a/ssbs8g==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2708,9 +2708,9 @@
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.11.5.tgz",
"integrity": "sha512-3up2r1Du8/5/4ZYzTC0DjTwhgPI3dn8jhOCLu73m5F3OGvK/9whcXoeWoX103hYMnGDxBlfOje71yQuN35FL4A==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.11.7.tgz",
"integrity": "sha512-uVmQwD2dzZ5xwmvUlciy0ItxOdOfQjH6VLmu80zyJf8Yu7mvwP8JyxoXUX0vd1xHpwAhgQ9/ozjIWYGIw79DPQ==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2722,9 +2722,9 @@
}
},
"node_modules/@tiptap/extension-image": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.11.5.tgz",
"integrity": "sha512-HbUq9AL8gb8eSuQfY/QKkvMc66ZFN/b6jvQAILGArNOgalUfGizoC6baKTJShaExMSPjBZlaAHtJiQKPaGRHaA==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.11.7.tgz",
"integrity": "sha512-YvCmTDB7Oo+A56tR4S/gcNaYpqU4DDlSQcRp5IQvmQV5EekSe0lnEazGDoqOCwsit9qQhj4MPQJhKrnaWrJUrg==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2735,9 +2735,9 @@
}
},
"node_modules/@tiptap/extension-italic": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.11.5.tgz",
"integrity": "sha512-9VGfb2/LfPhQ6TjzDwuYLRvw0A6VGbaIp3F+5Mql8XVdTBHb2+rhELbyhNGiGVR78CaB/EiKb6dO9xu/tBWSYA==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.11.7.tgz",
"integrity": "sha512-r985bkQfG0HMpmCU0X0p/Xe7U1qgRm2mxvcp6iPCuts2FqxaCoyfNZ8YnMsgVK1mRhM7+CQ5SEg2NOmQNtHvPw==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2748,9 +2748,9 @@
}
},
"node_modules/@tiptap/extension-link": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.11.5.tgz",
"integrity": "sha512-4Iu/aPzevbYpe50xDI0ZkqRa6nkZ9eF270Ue2qaF3Ab47nehj+9Jl78XXzo8+LTyFMnrETI73TAs1aC/IGySeQ==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.11.7.tgz",
"integrity": "sha512-qKIowE73aAUrnQCIifYP34xXOHOsZw46cT/LBDlb0T60knVfQoKVE4ku08fJzAV+s6zqgsaaZ4HVOXkQYLoW7g==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.2.0"
@@ -2765,9 +2765,9 @@
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.11.5.tgz",
"integrity": "sha512-Mp5RD/pbkfW1vdc6xMVxXYcta73FOwLmblQlFNn/l/E5/X1DUSA4iGhgDDH4EWO3swbs03x2f7Zka/Xoj3+WLg==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.11.7.tgz",
"integrity": "sha512-6ikh7Y+qAbkSuIHXPIINqfzmWs5uIGrylihdZ9adaIyvrN1KSnWIqrZIk/NcZTg5YFIJlXrnGSRSjb/QM3WUhw==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2778,9 +2778,9 @@
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.11.5.tgz",
"integrity": "sha512-Cu8KwruBNWAaEfshRQR0yOSaUKAeEwxW7UgbvF9cN/zZuKgK5uZosPCPTehIFCcRe+TBpRtZQh+06f/gNYpYYg==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.11.7.tgz",
"integrity": "sha512-bLGCHDMB0vbJk7uu8bRg8vES3GsvxkX7Cgjgm/6xysHFbK98y0asDtNxkW1VvuRreNGz4tyB6vkcVCfrxl4jKw==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2791,9 +2791,9 @@
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.11.5.tgz",
"integrity": "sha512-YFBWeg7xu/sBnsDIF/+nh9Arf7R0h07VZMd0id5Ydd2Qe3c1uIZwXxeINVtH0SZozuPIQFAT8ICe9M0RxmE+TA==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.11.7.tgz",
"integrity": "sha512-Pl3B4q6DJqTvvAdraqZaNP9Hh0UWEHL5nNdxhaRNuhKaUo7lq8wbDSIxIW3lvV0lyCs0NfyunkUvSm1CXb6d4Q==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2804,9 +2804,9 @@
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.11.5.tgz",
"integrity": "sha512-Pr+0Ju/l2ZvXMd9VQxtaoSZbs0BBp1jbBDqwms88ctpyvQFRfLSfSkqudQcSHyw2ROOz2E31p/7I7fpI8Y0CLA==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.11.7.tgz",
"integrity": "sha512-/06zXV4HIjYoiaUq1fVJo/RcU8pHbzx21evOpeG/foCfNpMI4xLU/vnxdUi6/SQqpZMY0eFutDqod1InkSOqsg==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2818,9 +2818,9 @@
}
},
"node_modules/@tiptap/extension-strike": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.11.5.tgz",
"integrity": "sha512-PVfUiCqrjvsLpbIoVlegSY8RlkR64F1Rr2RYmiybQfGbg+AkSZXDeO0eIrc03//4gua7D9DfIozHmAKv1KN3ow==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.11.7.tgz",
"integrity": "sha512-D6GYiW9F24bvAY7XMOARNZbC8YGPzdzWdXd8VOOJABhf4ynMi/oW4NNiko+kZ67jn3EGaKoz32VMJzNQgYi1HA==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2831,9 +2831,9 @@
}
},
"node_modules/@tiptap/extension-subscript": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-subscript/-/extension-subscript-2.11.5.tgz",
"integrity": "sha512-VpaSzxku/Bcvf4SgDB2K5d0E+FNA/56iJHMygg/WXsq2F4tMMUEivQHI/n+17ndUEO4Wybz0wItnM1G2JfRuLQ==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-subscript/-/extension-subscript-2.11.7.tgz",
"integrity": "sha512-I25ZexCddFJ9701DCCtQbX3Vtxzj5d9ss2GAXVweIUCdATCScaebsznyUQoN5papmhTxXsw5OD+K2ZHxP82pew==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2844,9 +2844,9 @@
}
},
"node_modules/@tiptap/extension-superscript": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-superscript/-/extension-superscript-2.11.5.tgz",
"integrity": "sha512-sK6v2G0zFfGW+j9CmYp2e+tyZ3FTa3dP0xY4kJzefgZcHhMJLlLnjxBRwHCSi/jj5ie6WdZT4KoEooxnPs1Vzw==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-superscript/-/extension-superscript-2.11.7.tgz",
"integrity": "sha512-dNRpCcRJs0Qvv0sZRgbH7Y5hDVbWsGSZjtwFCs/mysPrvHqmXjzo7568kYWTggxEYxnXw6n0FfkCAEHlt0N90Q==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2857,9 +2857,9 @@
}
},
"node_modules/@tiptap/extension-table": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.11.5.tgz",
"integrity": "sha512-NKXLhKWdAdURklm98YkCd2ai4fh8jY8HS/+X2s/2QiQt8Z98CU1keCm35fJEEExM234iB/hCqG5vY4JgTc0Tvw==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.11.7.tgz",
"integrity": "sha512-rfwWkNXz/EZuhc8lylsCWPbx0Xr5FlIhreWFyeoXYrDEO3x4ytYcVOpNmbabJYP2semfM0PvPR5o84zfFkLZyg==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2871,9 +2871,9 @@
}
},
"node_modules/@tiptap/extension-table-cell": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.11.5.tgz",
"integrity": "sha512-S967Au0pgeULstP3FaasOf/LEh72p61Ooh1PcUMF/az4x8EeGgpcEUARpVUxsGxLFvogv6LmhPHZdtcGgdHcBw==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.11.7.tgz",
"integrity": "sha512-JMOkSYRckc5SJP86yGGiHzCxCR8ecrRENvTWAKib6qer2tutxs5u42W+Z8uTcHC2dRz7Fv54snOkDoqPwkf6cw==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2884,9 +2884,9 @@
}
},
"node_modules/@tiptap/extension-table-header": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.11.5.tgz",
"integrity": "sha512-O1iBtzZP1XZDi4h1Xmgq1T63il+fpKPvBIMZ0JJH9TyCw5i5rcrMLL2dyy5zaWK3BFRJuYBNSke4c+VWnr/g6w==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.11.7.tgz",
"integrity": "sha512-wPRKpliS5QQXgsp//ZjXrHMdLICMkjg2fUrQinOiBa7wDL5C7Y+SehtuK4s2tjeAkyAdj+nepfftyBRIlUSMXg==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2897,9 +2897,9 @@
}
},
"node_modules/@tiptap/extension-table-row": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.11.5.tgz",
"integrity": "sha512-+/VWhCuW24BcM5aaIc/f0bC6ZR1Q5gnuqw13MIo7gyPx7iIY6BXK8roGiZSs8wYAN4uBEf3EKFm0bSZwQuAeyg==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.11.7.tgz",
"integrity": "sha512-K254RiXWGXGjz5Cm835hqfQiwnYXm8aw6oOa3isDh4A1B+1Ev4DB2vEDKMrgaOor3nbTsSYmAx2iEMrZSbpaRg==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2910,9 +2910,9 @@
}
},
"node_modules/@tiptap/extension-text": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.11.5.tgz",
"integrity": "sha512-Gq1WwyhFpCbEDrLPIHt5A8aLSlf8bfz4jm417c8F/JyU0J5dtYdmx0RAxjnLw1i7ZHE7LRyqqAoS0sl7JHDNSQ==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.11.7.tgz",
"integrity": "sha512-wObCn8qZkIFnXTLvBP+X8KgaEvTap/FJ/i4hBMfHBCKPGDx99KiJU6VIbDXG8d5ZcFZE0tOetK1pP5oI7qgMlQ==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2923,9 +2923,9 @@
}
},
"node_modules/@tiptap/extension-text-align": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.11.5.tgz",
"integrity": "sha512-Ei0zDpH5N9EV59ogydK4HTKa4lCPicCsQllM5n/Nf2tUJPir3aiYxzJ73FzhComD4Hpo1ANYnmssBhy8QeoPZA==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.11.7.tgz",
"integrity": "sha512-3M8zd9ROADXazVNpgR6Ejs1evSvBveN36qN4GgV71GqrNlTcjqYgQcXFLQrsd2hnE+aXir8/8bLJ+aaJXDninA==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2936,9 +2936,9 @@
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.11.5.tgz",
"integrity": "sha512-YUmYl0gILSd/u/ZkOmNxjNXVw+mu8fpC2f8G4I4tLODm0zCx09j9DDEJXSrM5XX72nxJQqtSQsCpNKnL0hfeEQ==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.11.7.tgz",
"integrity": "sha512-LHO6DBg/9SkCQFdWlVfw9nolUmw+Cid94WkTY+7IwrpyG2+ZGQxnKpCJCKyeaFNbDoYAtvu0vuTsSXeCkgShcA==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2949,9 +2949,9 @@
}
},
"node_modules/@tiptap/extension-underline": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.11.5.tgz",
"integrity": "sha512-YpWHXNIkSoRSuzT2cvgKpyJ2tTz3LzqkTM64uC+uTJ8cUkvXIWUWejJR42q8ma/mTlQe4lHff4IQ0Sf58Digtw==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.11.7.tgz",
"integrity": "sha512-NtoQw6PGijOAtXC6G+0Aq0/Z5wwEjPhNHs8nsjXogfWIgaj/aI4/zfBnA06eI3WT+emMYQTl0fTc4CUPnLVU8g==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2962,9 +2962,9 @@
}
},
"node_modules/@tiptap/pm": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.11.5.tgz",
"integrity": "sha512-z9JFtqc5ZOsdQLd9vRnXfTCQ8v5ADAfRt9Nm7SqP6FUHII8E1hs38ACzf5xursmth/VonJYb5+73Pqxk1hGIPw==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.11.7.tgz",
"integrity": "sha512-7gEEfz2Q6bYKXM07vzLUD0vqXFhC5geWRA6LCozTiLdVFDdHWiBrvb2rtkL5T7mfLq03zc1QhH7rI3F6VntOEA==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.2.1",
@@ -2981,7 +2981,7 @@
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-trailing-node": "^3.0.0",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.37.0"
@@ -2992,32 +2992,32 @@
}
},
"node_modules/@tiptap/starter-kit": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.11.5.tgz",
"integrity": "sha512-SLI7Aj2ruU1t//6Mk8f+fqW+18uTqpdfLUJYgwu0CkqBckrkRZYZh6GVLk/02k3H2ki7QkFxiFbZrdbZdng0JA==",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.11.7.tgz",
"integrity": "sha512-K+q51KwNU/l0kqRuV5e1824yOLVftj6kGplGQLvJG56P7Rb2dPbM/JeaDbxQhnHT/KDGamG0s0Po0M3pPY163A==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^2.11.5",
"@tiptap/extension-blockquote": "^2.11.5",
"@tiptap/extension-bold": "^2.11.5",
"@tiptap/extension-bullet-list": "^2.11.5",
"@tiptap/extension-code": "^2.11.5",
"@tiptap/extension-code-block": "^2.11.5",
"@tiptap/extension-document": "^2.11.5",
"@tiptap/extension-dropcursor": "^2.11.5",
"@tiptap/extension-gapcursor": "^2.11.5",
"@tiptap/extension-hard-break": "^2.11.5",
"@tiptap/extension-heading": "^2.11.5",
"@tiptap/extension-history": "^2.11.5",
"@tiptap/extension-horizontal-rule": "^2.11.5",
"@tiptap/extension-italic": "^2.11.5",
"@tiptap/extension-list-item": "^2.11.5",
"@tiptap/extension-ordered-list": "^2.11.5",
"@tiptap/extension-paragraph": "^2.11.5",
"@tiptap/extension-strike": "^2.11.5",
"@tiptap/extension-text": "^2.11.5",
"@tiptap/extension-text-style": "^2.11.5",
"@tiptap/pm": "^2.11.5"
"@tiptap/core": "^2.11.7",
"@tiptap/extension-blockquote": "^2.11.7",
"@tiptap/extension-bold": "^2.11.7",
"@tiptap/extension-bullet-list": "^2.11.7",
"@tiptap/extension-code": "^2.11.7",
"@tiptap/extension-code-block": "^2.11.7",
"@tiptap/extension-document": "^2.11.7",
"@tiptap/extension-dropcursor": "^2.11.7",
"@tiptap/extension-gapcursor": "^2.11.7",
"@tiptap/extension-hard-break": "^2.11.7",
"@tiptap/extension-heading": "^2.11.7",
"@tiptap/extension-history": "^2.11.7",
"@tiptap/extension-horizontal-rule": "^2.11.7",
"@tiptap/extension-italic": "^2.11.7",
"@tiptap/extension-list-item": "^2.11.7",
"@tiptap/extension-ordered-list": "^2.11.7",
"@tiptap/extension-paragraph": "^2.11.7",
"@tiptap/extension-strike": "^2.11.7",
"@tiptap/extension-text": "^2.11.7",
"@tiptap/extension-text-style": "^2.11.7",
"@tiptap/pm": "^2.11.7"
},
"funding": {
"type": "github",
+16 -16
View File
@@ -1,7 +1,7 @@
{
"name": "@umbraco-cms/backoffice",
"license": "MIT",
"version": "15.4.0-rc",
"version": "15.4.4",
"type": "module",
"exports": {
".": null,
@@ -202,21 +202,21 @@
"npm": ">=10.9"
},
"dependencies": {
"@tiptap/core": "2.11.5",
"@tiptap/extension-character-count": "^2.11.5",
"@tiptap/extension-image": "2.11.5",
"@tiptap/extension-link": "2.11.5",
"@tiptap/extension-placeholder": "2.11.5",
"@tiptap/extension-subscript": "2.11.5",
"@tiptap/extension-superscript": "2.11.5",
"@tiptap/extension-table": "2.11.5",
"@tiptap/extension-table-cell": "2.11.5",
"@tiptap/extension-table-header": "2.11.5",
"@tiptap/extension-table-row": "2.11.5",
"@tiptap/extension-text-align": "2.11.5",
"@tiptap/extension-underline": "2.11.5",
"@tiptap/pm": "2.11.5",
"@tiptap/starter-kit": "2.11.5",
"@tiptap/core": "2.11.7",
"@tiptap/extension-character-count": "2.11.7",
"@tiptap/extension-image": "2.11.7",
"@tiptap/extension-link": "2.11.7",
"@tiptap/extension-placeholder": "2.11.7",
"@tiptap/extension-subscript": "2.11.7",
"@tiptap/extension-superscript": "2.11.7",
"@tiptap/extension-table": "2.11.7",
"@tiptap/extension-table-cell": "2.11.7",
"@tiptap/extension-table-header": "2.11.7",
"@tiptap/extension-table-row": "2.11.7",
"@tiptap/extension-text-align": "2.11.7",
"@tiptap/extension-underline": "2.11.7",
"@tiptap/pm": "2.11.7",
"@tiptap/starter-kit": "2.11.7",
"@types/diff": "^7.0.1",
"@umbraco-ui/uui": "^1.13.0",
"@umbraco-ui/uui-css": "^1.13.0",
@@ -2097,7 +2097,7 @@ export default {
duplicateUsername: "Username '%0%' is already taken",
customValidation: 'Custom validation',
entriesShort: 'Minimum %0% entries, requires <strong>%1%</strong> more.',
entriesExceed: 'Maximum %0% entries, <strong>%1%</strong> too many.',
entriesExceed: 'Maximum %0% entries, you have entered <strong>%1%</strong> too many.',
entriesAreasMismatch: 'The content amount requirements are not met for one or more areas.',
},
healthcheck: {
@@ -2168,7 +2168,7 @@ export default {
invalidPattern: 'Value is invalid, it does not match the correct pattern',
customValidation: 'Custom validation',
entriesShort: 'Minimum %0% entries, requires <strong>%1%</strong> more.',
entriesExceed: 'Maximum %0% entries, <strong>%1%</strong> too many.',
entriesExceed: 'Maximum %0% entries, you have entered <strong>%1%</strong> too many.',
entriesAreasMismatch: 'The content amount requirements are not met for one or more areas.',
invalidMemberGroupName: 'Invalid member group name',
invalidUserGroupName: 'Invalid user group name',
File diff suppressed because one or more lines are too long
@@ -3523,6 +3523,12 @@ export type PutDocumentBlueprintByIdMoveData = {
export type PutDocumentBlueprintByIdMoveResponse = (string);
export type GetDocumentBlueprintByIdScaffoldData = {
id: string;
};
export type GetDocumentBlueprintByIdScaffoldResponse = ((DocumentBlueprintResponseModel));
export type PostDocumentBlueprintFolderData = {
requestBody?: (CreateFolderRequestModel);
};
@@ -1,4 +1,5 @@
export * from './validation.context.js';
export * from './validation.context-token.js';
export * from './validation-messages.manager.js';
export * from './server-model-validator.context-token.js';
export * from './server-model-validator.context.js';
@@ -4,10 +4,23 @@ import { UMB_DOCUMENT_BLUEPRINT_DETAIL_STORE_CONTEXT } from './document-blueprin
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbDetailRepositoryBase } from '@umbraco-cms/backoffice/repository';
export class UmbDocumentBlueprintDetailRepository extends UmbDetailRepositoryBase<UmbDocumentBlueprintDetailModel> {
export class UmbDocumentBlueprintDetailRepository extends UmbDetailRepositoryBase<
UmbDocumentBlueprintDetailModel,
UmbDocumentBlueprintServerDataSource
> {
constructor(host: UmbControllerHost) {
super(host, UmbDocumentBlueprintServerDataSource, UMB_DOCUMENT_BLUEPRINT_DETAIL_STORE_CONTEXT);
}
/**
* Gets an existing document blueprint by its unique identifier for scaffolding purposes, i.e. to create a new document based on an existing blueprint.
* @param {string} unique - The unique identifier of the document blueprint.
* @returns {UmbRepositoryResponse<UmbDocumentBlueprintDetailModel>} - The document blueprint data.
* @memberof UmbDocumentBlueprintDetailRepository
*/
scaffoldByUnique(unique: string) {
return this.detailDataSource.scaffoldByUnique(unique);
}
}
export { UmbDocumentBlueprintDetailRepository as api };
@@ -1,9 +1,10 @@
import type { UmbDocumentBlueprintDetailModel } from '../../types.js';
import { UMB_DOCUMENT_BLUEPRINT_ENTITY_TYPE } from '../../entity.js';
import { UmbId } from '@umbraco-cms/backoffice/id';
import type { UmbDetailDataSource } from '@umbraco-cms/backoffice/repository';
import type { UmbDataSourceResponse, UmbDetailDataSource } from '@umbraco-cms/backoffice/repository';
import type {
CreateDocumentBlueprintRequestModel,
DocumentBlueprintResponseModel,
UpdateDocumentBlueprintRequestModel,
} from '@umbraco-cms/backoffice/external/backend-api';
import { DocumentBlueprintService } from '@umbraco-cms/backoffice/external/backend-api';
@@ -74,7 +75,7 @@ export class UmbDocumentBlueprintServerDataSource implements UmbDetailDataSource
* @returns {*}
* @memberof UmbDocumentBlueprintServerDataSource
*/
async read(unique: string) {
async read(unique: string): Promise<UmbDataSourceResponse<UmbDocumentBlueprintDetailModel>> {
if (!unique) throw new Error('Unique is missing');
const { data, error } = await tryExecuteAndNotify(
@@ -86,35 +87,24 @@ export class UmbDocumentBlueprintServerDataSource implements UmbDetailDataSource
return { error };
}
// TODO: make data mapper to prevent errors
const document: UmbDocumentBlueprintDetailModel = {
entityType: UMB_DOCUMENT_BLUEPRINT_ENTITY_TYPE,
unique: data.id,
values: data.values.map((value) => {
return {
editorAlias: value.editorAlias,
culture: value.culture || null,
segment: value.segment || null,
alias: value.alias,
value: value.value,
};
}),
variants: data.variants.map((variant) => {
return {
state: variant.state,
culture: variant.culture || null,
segment: variant.segment || null,
name: variant.name,
publishDate: variant.publishDate || null,
createDate: variant.createDate,
updateDate: variant.updateDate,
};
}),
documentType: {
unique: data.documentType.id,
collection: data.documentType.collection ? { unique: data.documentType.collection.id } : null,
},
};
const document = this.#createDocumentBlueprintDetailModel(data);
return { data: document };
}
async scaffoldByUnique(unique: string): Promise<UmbDataSourceResponse<UmbDocumentBlueprintDetailModel>> {
if (!unique) throw new Error('Unique is missing');
const { data, error } = await tryExecuteAndNotify(
this.#host,
DocumentBlueprintService.getDocumentBlueprintByIdScaffold({ id: unique }),
);
if (error || !data) {
return { error };
}
const document = this.#createDocumentBlueprintDetailModel(data);
return { data: document };
}
@@ -196,4 +186,35 @@ export class UmbDocumentBlueprintServerDataSource implements UmbDetailDataSource
// TODO: update to delete when implemented
return tryExecuteAndNotify(this.#host, DocumentBlueprintService.deleteDocumentBlueprintById({ id: unique }));
}
#createDocumentBlueprintDetailModel(data: DocumentBlueprintResponseModel): UmbDocumentBlueprintDetailModel {
return {
entityType: UMB_DOCUMENT_BLUEPRINT_ENTITY_TYPE,
unique: data.id,
values: data.values.map((value) => {
return {
editorAlias: value.editorAlias,
culture: value.culture || null,
segment: value.segment || null,
alias: value.alias,
value: value.value,
};
}),
variants: data.variants.map((variant) => {
return {
state: variant.state,
culture: variant.culture || null,
segment: variant.segment || null,
name: variant.name,
publishDate: variant.publishDate || null,
createDate: variant.createDate,
updateDate: variant.updateDate,
};
}),
documentType: {
unique: data.documentType.id,
collection: data.documentType.collection ? { unique: data.documentType.collection.id } : null,
},
};
}
}
@@ -48,7 +48,10 @@ export class UmbDocumentDuplicateToModalElement extends UmbModalBaseElement<
<uui-box id="tree-box" headline="Duplicate to">
<umb-tree
alias=${UMB_DOCUMENT_TREE_ALIAS}
.props=${{ expandTreeRoot: true }}
.props=${{
expandTreeRoot: true,
hideTreeItemActions: true,
}}
@selection-change=${this.#onTreeSelectionChange}></umb-tree>
</uui-box>
<uui-box headline="Options">
@@ -211,14 +211,16 @@ export class UmbDocumentWorkspaceContext
async create(parent: UmbEntityModel, documentTypeUnique: string, blueprintUnique?: string) {
if (blueprintUnique) {
const blueprintRepository = new UmbDocumentBlueprintDetailRepository(this);
const { data } = await blueprintRepository.requestByUnique(blueprintUnique);
const { data } = await blueprintRepository.scaffoldByUnique(blueprintUnique);
if (!data) throw new Error('Blueprint data is missing');
return this.createScaffold({
parent,
preset: {
documentType: data?.documentType,
values: data?.values,
variants: data?.variants as Array<UmbDocumentVariantModel>,
documentType: data.documentType,
values: data.values,
variants: data.variants as Array<UmbDocumentVariantModel>,
},
});
}
@@ -78,7 +78,12 @@ export class UmbInputImageCropperFieldElement extends UmbLitElement {
get source(): string {
if (this.src) {
return `${this._serverUrl}${this.src}`;
// Test that URL is relative:
if (this.src.startsWith('/')) {
return `${this._serverUrl}${this.src}`;
} else {
return this.src;
}
}
return this.fileDataUrl ?? '';
@@ -461,6 +461,7 @@ export function getMimeTypeFromExtension(extension: string): string | null {
'.onetoc2': 'application/onenote',
'.opf': 'application/oebps-package+xml',
'.oprc': 'application/vnd.palm',
'.opus': 'audio/ogg',
'.org': 'application/vnd.lotus-organizer',
'.osf': 'application/vnd.yamaha.openscoreformat',
'.osfpvg': 'application/vnd.yamaha.openscoreformat.osfpvg+xml',
@@ -744,6 +745,8 @@ export function getMimeTypeFromExtension(extension: string): string | null {
'.wbxml': 'application/vnd.wap.wbxml',
'.wcm': 'application/vnd.ms-works',
'.wdb': 'application/vnd.ms-works',
'.weba': 'audio/webm',
'.webm': 'video/webm',
'.webp': 'image/webp',
'.wiz': 'application/msword',
'.wks': 'application/vnd.ms-works',
@@ -2,7 +2,7 @@ import { umbDataTypeMockDb } from '../../../../../mocks/data/data-type/data-type
import { html } from '@umbraco-cms/backoffice/external/lit';
import type { Meta } from '@storybook/web-components';
import './property-editor-ui-tiny-mce-stylesheets-configuration.element.js';
import './property-editor-ui-stylesheet-picker.element.js';
import type { UmbDataTypeDetailModel } from '@umbraco-cms/backoffice/data-type';
const dataTypeData = umbDataTypeMockDb.read('dt-richTextEditor') as unknown as UmbDataTypeDetailModel;
@@ -10,7 +10,7 @@ const dataTypeData = umbDataTypeMockDb.read('dt-richTextEditor') as unknown as U
export default {
title: 'Property Editor UIs/Stylesheet Picker',
component: 'umb-property-editor-ui-stylesheet-picker',
id: 'umb-property-editor-ui-sstylesheet-picker',
id: 'umb-property-editor-ui-stylesheet-picker',
} as Meta;
export const AAAOverview = ({ value }: any) =>
@@ -1,6 +1,15 @@
import type { UmbTiptapExtensionApi } from '../../extensions/types.js';
import type { UmbTiptapStatusbarValue, UmbTiptapToolbarValue } from '../types.js';
import { css, customElement, html, map, property, state, unsafeCSS, when } from '@umbraco-cms/backoffice/external/lit';
import {
css,
customElement,
html,
property,
repeat,
state,
unsafeCSS,
when,
} from '@umbraco-cms/backoffice/external/lit';
import { loadManifestApi } from '@umbraco-cms/backoffice/extension-api';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { Editor } from '@umbraco-cms/backoffice/external/tiptap';
@@ -16,6 +25,12 @@ import './tiptap-statusbar.element.js';
const TIPTAP_CORE_EXTENSION_ALIAS = 'Umb.Tiptap.RichTextEssentials';
/**
* The root path for the stylesheets on the server.
* This is used to load the stylesheets from the server as a workaround until the server supports virtual paths.
*/
const STYLESHEET_ROOT_PATH = '/css';
@customElement('umb-input-tiptap')
export class UmbInputTiptapElement extends UmbFormControlMixin<string, typeof UmbLitElement, string>(UmbLitElement) {
#stylesheets = new Set(['/umbraco/backoffice/css/rte-content.css']);
@@ -129,7 +144,13 @@ export class UmbInputTiptapElement extends UmbFormControlMixin<string, typeof Um
const stylesheets = this.configuration?.getValueByAlias<Array<string>>('stylesheets');
if (stylesheets?.length) {
stylesheets.forEach((x) => this.#stylesheets.add(x));
stylesheets.forEach((stylesheet) => {
const linkHref =
stylesheet.startsWith('http') || stylesheet.startsWith(STYLESHEET_ROOT_PATH)
? stylesheet
: `${STYLESHEET_ROOT_PATH}${stylesheet}`;
this.#stylesheets.add(linkHref);
});
}
this._toolbar = this.configuration?.getValueByAlias<UmbTiptapToolbarValue>('toolbar') ?? [[[]]];
@@ -182,7 +203,11 @@ export class UmbInputTiptapElement extends UmbFormControlMixin<string, typeof Um
#renderStyles() {
if (!this._styles?.length) return;
return html`
${map(this.#stylesheets, (stylesheet) => html`<link rel="stylesheet" href=${stylesheet} />`)}
${repeat(
this.#stylesheets,
(stylesheet) => stylesheet,
(stylesheet) => html`<link rel="stylesheet" href="${stylesheet}" />`,
)}
<style>
${this._styles.map((style) => unsafeCSS(style))}
</style>
@@ -189,25 +189,48 @@ public abstract class UmbracoIntegrationTest : UmbracoIntegrationTestBase
private void ExecuteBuilderAttributes(IUmbracoBuilder builder)
{
// todo better errors
Type? testClassType = GetTestClassType()
?? throw new Exception($"Could not find test class for {TestContext.CurrentContext.Test.FullName} in order to execute builder attributes.");
// execute builder attributes defined on method
foreach (ConfigureBuilderAttribute builderAttribute in Type.GetType(TestContext.CurrentContext.Test.ClassName)
.GetMethods().First(m => m.Name == TestContext.CurrentContext.Test.MethodName)
.GetCustomAttributes(typeof(ConfigureBuilderAttribute), true))
// Execute builder attributes defined on method.
foreach (ConfigureBuilderAttribute builderAttribute in GetConfigureBuilderAttributes<ConfigureBuilderAttribute>(testClassType))
{
builderAttribute.Execute(builder);
}
// execute builder attributes defined on method with param value passtrough from testcase
foreach (ConfigureBuilderTestCaseAttribute builderAttribute in Type.GetType(TestContext.CurrentContext.Test.ClassName)
.GetMethods().First(m => m.Name == TestContext.CurrentContext.Test.MethodName)
.GetCustomAttributes(typeof(ConfigureBuilderTestCaseAttribute), true))
// Execute builder attributes defined on method with param value pass through from test case.
foreach (ConfigureBuilderTestCaseAttribute builderAttribute in GetConfigureBuilderAttributes<ConfigureBuilderTestCaseAttribute>(testClassType))
{
builderAttribute.Execute(builder);
}
}
private static Type? GetTestClassType()
{
string testClassName = TestContext.CurrentContext.Test.ClassName;
// Try resolving the type name directly (which will work for tests in this assembly).
Type testClass = Type.GetType(testClassName);
if (testClass is not null)
{
return testClass;
}
// Try scanning the loaded assemblies to see if we can find the class by full name. This will be necessary
// for integration test projects using the base classess provided by Umbraco.
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
return assemblies
.SelectMany(a => a.GetTypes().Where(t => t.FullName == testClassName))
.FirstOrDefault();
}
private static IEnumerable<TAttribute> GetConfigureBuilderAttributes<TAttribute>(Type testClassType)
where TAttribute : Attribute =>
testClassType
.GetMethods().First(m => m.Name == TestContext.CurrentContext.Test.MethodName)
.GetCustomAttributes(typeof(TAttribute), true)
.Cast<TAttribute>();
/// <summary>
/// Hook for altering UmbracoBuilder setup
/// </summary>
@@ -57,7 +57,7 @@ public abstract class UmbracoIntegrationTestWithContent : UmbracoIntegrationTest
// Create and Save Content "Text Page 1" based on "umbTextpage" -> 1054
Subpage = ContentBuilder.CreateSimpleContent(ContentType, "Text Page 1", Textpage.Id);
Subpage.Key = new Guid(SubPageKey);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddMinutes(-5), null);
ContentService.Save(Subpage, -1, contentSchedule);
// Create and Save Content "Text Page 1" based on "umbTextpage" -> 1055
@@ -0,0 +1,51 @@
using NUnit.Framework;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Services.Changes;
using Umbraco.Cms.Infrastructure.HybridCache;
using Umbraco.Cms.Tests.Common.Testing;
using Umbraco.Cms.Tests.Integration.Testing;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Cache;
// We need to make sure that it's the distributed cache refreshers that refresh the elements cache
// see: https://github.com/umbraco/Umbraco-CMS/issues/18467
[TestFixture]
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerFixture)]
internal sealed class DistributedCacheRefresherTests : UmbracoIntegrationTest
{
private IElementsCache ElementsCache => GetRequiredService<IElementsCache>();
private ContentCacheRefresher ContentCacheRefresher => GetRequiredService<ContentCacheRefresher>();
private MediaCacheRefresher MediaCacheRefresher => GetRequiredService<MediaCacheRefresher>();
[Test]
public void DistributedContentCacheRefresherClearsElementsCache()
{
var cacheKey = "test";
PopulateCache("test");
ContentCacheRefresher.Refresh([new ContentCacheRefresher.JsonPayload()]);
Assert.IsNull(ElementsCache.Get(cacheKey));
}
[Test]
public void DistributedMediaCacheRefresherClearsElementsCache()
{
var cacheKey = "test";
PopulateCache("test");
MediaCacheRefresher.Refresh([new MediaCacheRefresher.JsonPayload(1, Guid.NewGuid(), TreeChangeTypes.RefreshAll)]);
Assert.IsNull(ElementsCache.Get(cacheKey));
}
private void PopulateCache(string key)
{
ElementsCache.Get(key, () => new object());
// Just making sure something is in the cache now.
Assert.IsNotNull(ElementsCache.Get(key));
}
}
@@ -214,7 +214,7 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent
ctVariant.Variations = ContentVariation.Culture;
ContentTypeService.Save(ctVariant);
var now = DateTime.Now;
var now = DateTime.UtcNow;
// 10x invariant content, half is scheduled to be published in 5 seconds, the other half is scheduled to be unpublished in 5 seconds
var invariant = new List<IContent>();
@@ -321,7 +321,7 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent
// Act
var content = ContentService.CreateAndSave("Test", Constants.System.Root, "umbTextpage");
var contentSchedule = ContentScheduleCollection.CreateWithEntry(null, DateTime.Now.AddHours(2));
var contentSchedule = ContentScheduleCollection.CreateWithEntry(null, DateTime.UtcNow.AddHours(2));
ContentService.Save(content, Constants.Security.SuperUserId, contentSchedule);
Assert.AreEqual(1, contentSchedule.FullSchedule.Count);
@@ -676,7 +676,7 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent
var root = ContentService.GetById(Textpage.Id);
ContentService.Publish(root!, root!.AvailableCultures.ToArray());
var content = ContentService.GetById(Subpage.Id);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(null, DateTime.Now.AddSeconds(1));
var contentSchedule = ContentScheduleCollection.CreateWithEntry(null, DateTime.UtcNow.AddSeconds(1));
ContentService.PersistContentSchedule(content!, contentSchedule);
ContentService.Publish(content, content.AvailableCultures.ToArray());
@@ -1386,7 +1386,7 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent
{
// Arrange
var content = ContentService.GetById(Subpage.Id); // This Content expired 5min ago
var contentSchedule = ContentScheduleCollection.CreateWithEntry(null, DateTime.Now.AddMinutes(-5));
var contentSchedule = ContentScheduleCollection.CreateWithEntry(null, DateTime.UtcNow.AddMinutes(-5));
ContentService.Save(content, contentSchedule: contentSchedule);
var parent = ContentService.GetById(Textpage.Id);
@@ -1416,7 +1416,7 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent
var content = ContentBuilder.CreateBasicContent(contentType);
content.SetCultureName("Hello", "en-US");
var contentSchedule = ContentScheduleCollection.CreateWithEntry("en-US", null, DateTime.Now.AddMinutes(-5));
var contentSchedule = ContentScheduleCollection.CreateWithEntry("en-US", null, DateTime.UtcNow.AddMinutes(-5));
ContentService.Save(content, contentSchedule: contentSchedule);
var published = ContentService.Publish(content, new[] { "en-US" });
@@ -1431,7 +1431,7 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent
{
// Arrange
var content = ContentService.GetById(Subpage.Id);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddHours(2), null);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddHours(2), null);
ContentService.Save(content, Constants.Security.SuperUserId, contentSchedule);
var parent = ContentService.GetById(Textpage.Id);
@@ -1488,7 +1488,7 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent
content.Properties[0].SetValue("Foo", string.Empty);
contentService.Save(content);
contentService.PersistContentSchedule(content,
ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddHours(2), null));
ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddHours(2), null));
// Act
var result = contentService.Publish(content, Array.Empty<string>(), userId: Constants.Security.SuperUserId);
@@ -1540,7 +1540,7 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent
contentService.Publish(content, Array.Empty<string>());
contentService.PersistContentSchedule(content,
ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddHours(2), null));
ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddHours(2), null));
contentService.Save(content);
// Act
@@ -1568,7 +1568,7 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent
var content = ContentBuilder.CreateBasicContent(contentType);
content.SetCultureName("Hello", "en-US");
var contentSchedule = ContentScheduleCollection.CreateWithEntry("en-US", DateTime.Now.AddHours(2), null);
var contentSchedule = ContentScheduleCollection.CreateWithEntry("en-US", DateTime.UtcNow.AddHours(2), null);
ContentService.Save(content, contentSchedule: contentSchedule);
var published = ContentService.Publish(content, new[] { "en-US" });
@@ -230,7 +230,7 @@ public class DocumentUrlServiceTests : UmbracoIntegrationTestWithContent
// Create a subpage
var subsubpage = ContentBuilder.CreateSimpleContent(ContentType, documentName, Subpage.Id);
subsubpage.Key = Guid.Parse(documentKey);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddMinutes(-5), null);
ContentService.Save(subsubpage, -1, contentSchedule);
if (loadDraft is false)
@@ -248,7 +248,7 @@ public class DocumentUrlServiceTests : UmbracoIntegrationTestWithContent
// Create a second root
var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null);
secondRoot.Key = new Guid("8E21BCD4-02CA-483D-84B0-1FC92702E198");
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddMinutes(-5), null);
ContentService.Save(secondRoot, -1, contentSchedule);
if (loadDraft is false)
@@ -266,7 +266,7 @@ public class DocumentUrlServiceTests : UmbracoIntegrationTestWithContent
{
// Create a second root
var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddMinutes(-5), null);
ContentService.Save(secondRoot, -1, contentSchedule);
// Create a child of second root
@@ -62,7 +62,7 @@ public class DocumentUrlServiceTests_HideTopLevel_False : UmbracoIntegrationTest
// Create a subpage
var subsubpage = ContentBuilder.CreateSimpleContent(ContentType, "Sub Page 1", Subpage.Id);
subsubpage.Key = new Guid("DF49F477-12F2-4E33-8563-91A7CC1DCDBB");
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddMinutes(-5), null);
ContentService.Save(subsubpage, -1, contentSchedule);
if (loadDraft is false)
@@ -81,7 +81,7 @@ public class DocumentUrlServiceTests_HideTopLevel_False : UmbracoIntegrationTest
// Create a second root
var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null);
secondRoot.Key = new Guid("8E21BCD4-02CA-483D-84B0-1FC92702E198");
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddMinutes(-5), null);
ContentService.Save(secondRoot, -1, contentSchedule);
if (loadDraft is false)
@@ -100,7 +100,7 @@ public class DocumentUrlServiceTests_HideTopLevel_False : UmbracoIntegrationTest
{
// Create a second root
var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddMinutes(-5), null);
ContentService.Save(secondRoot, -1, contentSchedule);
// Create a child of second root
@@ -164,7 +164,7 @@ public class PublishStatusServiceTest : UmbracoIntegrationTestWithContent
{
var grandchild = ContentBuilder.CreateSimpleContent(ContentType, "Grandchild", Subpage2.Id);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddMinutes(-5), null);
ContentService.Save(grandchild, -1, contentSchedule);
var publishResults = ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]);
@@ -12,7 +12,7 @@ public class PublishedUrlInfoProviderTests : PublishedUrlInfoProviderTestsBase
{
// Create a second root
var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddMinutes(-5), null);
ContentService.Save(secondRoot, -1, contentSchedule);
// Create a child of second root
@@ -19,7 +19,7 @@ public class PublishedUrlInfoProvider_hidetoplevel_false : PublishedUrlInfoProvi
{
// Create a second root
var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddMinutes(-5), null);
ContentService.Save(secondRoot, -1, contentSchedule);
// Create a child of second root
@@ -14,6 +14,10 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.PropertyEditors;
internal partial class BlockListElementLevelVariationTests
{
/// <summary>
/// Tests whether the user can update the variant values of existing blocks inside an invariant blocklist
/// </summary>
/// <param name="updateWithLimitedUserAccess">true => danish only which is not the default. false => admin which is all languages</param>
[TestCase(true)]
[TestCase(false)]
[ConfigureBuilder(ActionName = nameof(ConfigureAllowEditInvariantFromNonDefaultTrue))]
@@ -169,6 +173,10 @@ internal partial class BlockListElementLevelVariationTests
}
}
/// <summary>
/// Tests whether the user can add new variant blocks to an invariant blocklist
/// </summary>
/// <param name="updateWithLimitedUserAccess">true => danish only which is not the default. false => admin which is all languages</param>
[TestCase(true)]
[TestCase(false)]
[ConfigureBuilder(ActionName = nameof(ConfigureAllowEditInvariantFromNonDefaultTrue))]
@@ -299,6 +307,10 @@ internal partial class BlockListElementLevelVariationTests
}
}
/// <summary>
/// Tests whether the user can update the variant values of existing blocks inside an invariant blocklist
/// </summary>
/// <param name="updateWithLimitedUserAccess">true => danish only which is not the default. false => admin which is all languages</param>
[TestCase(true)]
[TestCase(false)]
public async Task Can_Handle_Limited_User_Access_To_Languages_Without_AllowEditInvariantFromNonDefault(bool updateWithLimitedUserAccess)
@@ -457,6 +469,10 @@ internal partial class BlockListElementLevelVariationTests
}
}
/// <summary>
/// Tests whether the user can add new variant blocks to an invariant blocklist
/// </summary>
/// <param name="updateWithLimitedUserAccess">true => danish only which is not the default. false => admin which is all languages</param>
[TestCase(true)]
[TestCase(false)]
public async Task Can_Handle_Limited_User_Access_To_Languages_Without_AllowEditInvariantFromNonDefault_WithoutInitialValue(bool updateWithLimitedUserAccess)
@@ -524,14 +540,14 @@ internal partial class BlockListElementLevelVariationTests
{
InvariantProperties = new[]
{
new PropertyValueModel { Alias = "blocks", Value = JsonSerializer.Serialize(blockListValue) }
new PropertyValueModel { Alias = "blocks", Value = JsonSerializer.Serialize(blockListValue) },
},
Variants = new[]
{
new VariantModel { Name = content.GetCultureName("en-US")!, Culture = "en-US", Properties = [] },
new VariantModel { Name = content.GetCultureName("da-DK")!, Culture = "da-DK", Properties = [] },
new VariantModel { Name = content.GetCultureName("de-DE")!, Culture = "de-DE", Properties = [] }
}
new VariantModel { Name = content.GetCultureName("de-DE")!, Culture = "de-DE", Properties = [] },
},
};
var result = await ContentEditingService.UpdateAsync(content.Key, updateModel, userKey);
@@ -539,27 +555,12 @@ internal partial class BlockListElementLevelVariationTests
content = ContentService.GetById(content.Key);
var savedBlocksValue = content?.Properties["blocks"]?.GetValue()?.ToString();
Assert.NotNull(savedBlocksValue);
blockListValue = JsonSerializer.Deserialize<BlockListValue>(savedBlocksValue);
blockListValue = savedBlocksValue is null ? null : JsonSerializer.Deserialize<BlockListValue>(savedBlocksValue);
// the Danish values should be updated regardless of the executing user
Assert.Multiple(() =>
{
Assert.AreEqual("#1: The second content value in Danish", blockListValue.ContentData[0].Values.Single(v => v.Culture == "da-DK").Value);
Assert.AreEqual("#1: The second settings value in Danish", blockListValue.SettingsData[0].Values.Single(v => v.Culture == "da-DK").Value);
Assert.AreEqual("#2: The second content value in Danish", blockListValue.ContentData[1].Values.Single(v => v.Culture == "da-DK").Value);
Assert.AreEqual("#2: The second settings value in Danish", blockListValue.SettingsData[1].Values.Single(v => v.Culture == "da-DK").Value);
});
// limited user access means invariant, English and German should not have been updated - changes should be rolled back to the initial block values
// limited user access means invariant data is inaccessible since AllowEditInvariantFromNonDefault is disabled
if (updateWithLimitedUserAccess)
{
Assert.Multiple(() =>
{
Assert.AreEqual(1, blockListValue.ContentData[0].Values.Count);
Assert.AreEqual(1, blockListValue.ContentData[1].Values.Count);
});
Assert.IsNull(blockListValue);
}
else
{
@@ -581,6 +582,542 @@ internal partial class BlockListElementLevelVariationTests
Assert.AreEqual("#2: The second settings value in English", blockListValue.SettingsData[1].Values[1].Value);
Assert.AreEqual("#2: The second content value in German", blockListValue.ContentData[1].Values[3].Value);
Assert.AreEqual("#2: The second settings value in German", blockListValue.SettingsData[1].Values[3].Value);
Assert.AreEqual("#1: The second content value in Danish", blockListValue.ContentData[0].Values.Single(v => v.Culture == "da-DK").Value);
Assert.AreEqual("#1: The second settings value in Danish", blockListValue.SettingsData[0].Values.Single(v => v.Culture == "da-DK").Value);
Assert.AreEqual("#2: The second content value in Danish", blockListValue.ContentData[1].Values.Single(v => v.Culture == "da-DK").Value);
Assert.AreEqual("#2: The second settings value in Danish", blockListValue.SettingsData[1].Values.Single(v => v.Culture == "da-DK").Value);
});
}
}
/// <summary>
/// Tests whether the user can add/remove new variant blocks to an invariant blocklist
/// </summary>
/// <param name="updateWithLimitedUserAccess">true => danish only which is not the default. false => admin which is all languages</param>
[TestCase(true)]
[TestCase(false)]
public async Task Can_Handle_BlockStructureManipulation_For_Limited_Users_Without_AllowEditInvariantFromNonDefault(
bool updateWithLimitedUserAccess)
{
await LanguageService.CreateAsync(
new Language("de-DE", "German"), Constants.Security.SuperUserKey);
var userKey = updateWithLimitedUserAccess
? (await CreateLimitedUser()).Key
: Constants.Security.SuperUserKey;
var elementType = CreateElementType(ContentVariation.Culture);
var blockListDataType = await CreateBlockListDataType(elementType);
var contentType = CreateContentType(ContentVariation.Culture, blockListDataType);
var content = CreateContent(contentType, elementType, [], false);
content.SetCultureName("Home (de)", "de-DE");
ContentService.Save(content);
var firstContentElementKey = Guid.NewGuid();
var firstSettingsElementKey = Guid.NewGuid();
var secondContentElementKey = Guid.NewGuid();
var secondSettingsElementKey = Guid.NewGuid();
var blockListValue = BlockListPropertyValue(
elementType,
[
(
firstContentElementKey,
firstSettingsElementKey,
new BlockProperty(
new List<BlockPropertyValue> {
new() { Alias = "invariantText", Value = "#1: The first invariant content value" },
new() { Alias = "variantText", Value = "#1: The first content value in English", Culture = "en-US" },
new() { Alias = "variantText", Value = "#1: The first content value in Danish", Culture = "da-DK" },
new() { Alias = "variantText", Value = "#1: The first content value in German", Culture = "de-DE" },
},
new List<BlockPropertyValue> {
new() { Alias = "invariantText", Value = "#1: The first invariant settings value" },
new() { Alias = "variantText", Value = "#1: The first settings value in English", Culture = "en-US" },
new() { Alias = "variantText", Value = "#1: The first settings value in Danish", Culture = "da-DK" },
new() { Alias = "variantText", Value = "#1: The first settings value in German", Culture = "de-DE" },
},
null,
null)),
(
secondContentElementKey,
secondSettingsElementKey,
new BlockProperty(
new List<BlockPropertyValue> {
new() { Alias = "invariantText", Value = "#2: The first invariant content value" },
new() { Alias = "variantText", Value = "#2: The first content value in English", Culture = "en-US" },
new() { Alias = "variantText", Value = "#2: The first content value in Danish", Culture = "da-DK" },
new() { Alias = "variantText", Value = "#2: The first content value in German", Culture = "de-DE" },
},
new List<BlockPropertyValue> {
new() { Alias = "invariantText", Value = "#2: The first invariant settings value" },
new() { Alias = "variantText", Value = "#2: The first settings value in English", Culture = "en-US" },
new() { Alias = "variantText", Value = "#2: The first settings value in Danish", Culture = "da-DK" },
new() { Alias = "variantText", Value = "#2: The first settings value in German", Culture = "de-DE" },
},
null,
null))
]);
content.Properties["blocks"]!.SetValue(JsonSerializer.Serialize(blockListValue));
ContentService.Save(content);
var newContentElementKey = Guid.NewGuid();
RemoveBlock(blockListValue, firstContentElementKey);
AddBlock(
blockListValue,
new BlockItemData
{
Key = newContentElementKey,
ContentTypeAlias = elementType.Alias,
ContentTypeKey = elementType.Key,
Values = new List<BlockPropertyValue> {
new() { Alias = "invariantText", Value = "#new: The new invariant settings value" },
new() { Alias = "variantText", Value = "#new: The new settings value in English", Culture = "en-US" },
new() { Alias = "variantText", Value = "#new: The new settings value in Danish", Culture = "da-DK" },
new() { Alias = "variantText", Value = "#new: The new settings value in German", Culture = "de-DE" },
},
},
null,
elementType);
var updateModel = new ContentUpdateModel
{
InvariantProperties = new[]
{
new PropertyValueModel { Alias = "blocks", Value = JsonSerializer.Serialize(blockListValue) }
},
Variants = new[]
{
new VariantModel { Name = content.GetCultureName("en-US")!, Culture = "en-US", Properties = [] },
new VariantModel { Name = content.GetCultureName("da-DK")!, Culture = "da-DK", Properties = [] },
new VariantModel { Name = content.GetCultureName("de-DE")!, Culture = "de-DE", Properties = [] }
},
};
var result = await ContentEditingService.UpdateAsync(content.Key, updateModel, userKey);
Assert.IsTrue(result.Success);
content = ContentService.GetById(content.Key);
var savedBlocksValue = content?.Properties["blocks"]?.GetValue()?.ToString();
Assert.NotNull(savedBlocksValue);
blockListValue = JsonSerializer.Deserialize<BlockListValue>(savedBlocksValue);
if (updateWithLimitedUserAccess)
{
Assert.Multiple(() =>
{
// new one can't be added
Assert.AreEqual(0, blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Count(layoutItem => layoutItem.ContentKey == newContentElementKey));
Assert.AreEqual(0, blockListValue.ContentData.Sum(contentData => contentData.Values.Count(value => value.Value?.ToString()?.StartsWith("#new") == true)));
Assert.AreEqual(0, blockListValue.SettingsData.Sum(settingsData => settingsData.Values.Count(value => value.Value?.ToString()?.StartsWith("#new") == true)));
// can't remove first
Assert.AreEqual(1, blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Count(layoutItem => layoutItem.ContentKey == firstContentElementKey));
Assert.AreEqual(1, blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Count(layoutItem => layoutItem.SettingsKey == firstSettingsElementKey));
Assert.AreEqual(4, blockListValue.ContentData.Sum(contentData => contentData.Values.Count(value => value.Value?.ToString()?.StartsWith("#1") == true)));
Assert.AreEqual(4, blockListValue.SettingsData.Sum(settingsData => settingsData.Values.Count(value => value.Value?.ToString()?.StartsWith("#1") == true)));
// second wasn't touched
Assert.AreEqual(1, blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Count(layoutItem => layoutItem.SettingsKey == secondSettingsElementKey));
Assert.AreEqual(1, blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Count(layoutItem => layoutItem.ContentKey == secondContentElementKey));
Assert.AreEqual(4, blockListValue.ContentData.Sum(contentData => contentData.Values.Count(value => value.Value?.ToString()?.StartsWith("#2") == true)));
Assert.AreEqual(4, blockListValue.SettingsData.Sum(settingsData => settingsData.Values.Count(value => value.Value?.ToString()?.StartsWith("#2") == true)));
});
}
else
{
Assert.Multiple(() =>
{
// add new one, did not add settings
Assert.AreEqual(1, blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Count(layoutItem => layoutItem.ContentKey == newContentElementKey));
Assert.AreEqual(4, blockListValue.ContentData.Sum(contentData => contentData.Values.Count(value => value.Value?.ToString()?.StartsWith("#new") == true)));
Assert.AreEqual(0, blockListValue.SettingsData.Sum(settingsData => settingsData.Values.Count(value => value.Value?.ToString()?.StartsWith("#new") == true)));
// first one removed
Assert.AreEqual(0, blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Count(layoutItem => layoutItem.ContentKey == firstContentElementKey));
Assert.AreEqual(0, blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Count(layoutItem => layoutItem.SettingsKey == firstSettingsElementKey));
Assert.AreEqual(0, blockListValue.ContentData.Sum(contentData => contentData.Values.Count(value => value.Value?.ToString()?.StartsWith("#1") == true)));
Assert.AreEqual(0, blockListValue.SettingsData.Sum(settingsData => settingsData.Values.Count(value => value.Value?.ToString()?.StartsWith("#1") == true)));
// second wasn't touched
Assert.AreEqual(1, blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Count(layoutItem => layoutItem.SettingsKey == secondSettingsElementKey));
Assert.AreEqual(1, blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Count(layoutItem => layoutItem.ContentKey == secondContentElementKey));
Assert.AreEqual(4, blockListValue.ContentData.Sum(contentData => contentData.Values.Count(value => value.Value?.ToString()?.StartsWith("#2") == true)));
Assert.AreEqual(4, blockListValue.SettingsData.Sum(settingsData => settingsData.Values.Count(value => value.Value?.ToString()?.StartsWith("#2") == true)));
});
}
}
/// <summary>
/// Tests whether the user can add/remove new variant blocks to an invariant blocklist
/// </summary>
/// <param name="updateWithLimitedUserAccess">true => danish only which is not the default. false => admin which is all languages</param>
[TestCase(true)]
[TestCase(false)]
[ConfigureBuilder(ActionName = nameof(ConfigureAllowEditInvariantFromNonDefaultTrue))]
public async Task Can_Handle_BlockStructureManipulation_For_Limited_Users_With_AllowEditInvariantFromNonDefault(
bool updateWithLimitedUserAccess)
{
await LanguageService.CreateAsync(
new Language("de-DE", "German"), Constants.Security.SuperUserKey);
var userKey = updateWithLimitedUserAccess
? (await CreateLimitedUser()).Key
: Constants.Security.SuperUserKey;
var elementType = CreateElementType(ContentVariation.Culture);
var blockListDataType = await CreateBlockListDataType(elementType);
var contentType = CreateContentType(ContentVariation.Culture, blockListDataType);
var content = CreateContent(contentType, elementType, [], false);
content.SetCultureName("Home (de)", "de-DE");
ContentService.Save(content);
var firstContentElementKey = Guid.NewGuid();
var firstSettingsElementKey = Guid.NewGuid();
var blockListValue = BlockListPropertyValue(
elementType,
[
(
firstContentElementKey,
firstSettingsElementKey,
new BlockProperty(
new List<BlockPropertyValue> {
new() { Alias = "invariantText", Value = "#1: The first invariant content value" },
new() { Alias = "variantText", Value = "#1: The first content value in English", Culture = "en-US" },
new() { Alias = "variantText", Value = "#1: The first content value in Danish", Culture = "da-DK" },
new() { Alias = "variantText", Value = "#1: The first content value in German", Culture = "de-DE" },
},
new List<BlockPropertyValue> {
new() { Alias = "invariantText", Value = "#1: The first invariant settings value" },
new() { Alias = "variantText", Value = "#1: The first settings value in English", Culture = "en-US" },
new() { Alias = "variantText", Value = "#1: The first settings value in Danish", Culture = "da-DK" },
new() { Alias = "variantText", Value = "#1: The first settings value in German", Culture = "de-DE" },
},
null,
null)),
(
Guid.NewGuid(),
Guid.NewGuid(),
new BlockProperty(
new List<BlockPropertyValue> {
new() { Alias = "invariantText", Value = "#2: The first invariant content value" },
new() { Alias = "variantText", Value = "#2: The first content value in English", Culture = "en-US" },
new() { Alias = "variantText", Value = "#2: The first content value in Danish", Culture = "da-DK" },
new() { Alias = "variantText", Value = "#2: The first content value in German", Culture = "de-DE" },
},
new List<BlockPropertyValue> {
new() { Alias = "invariantText", Value = "#2: The first invariant settings value" },
new() { Alias = "variantText", Value = "#2: The first settings value in English", Culture = "en-US" },
new() { Alias = "variantText", Value = "#2: The first settings value in Danish", Culture = "da-DK" },
new() { Alias = "variantText", Value = "#2: The first settings value in German", Culture = "de-DE" },
},
null,
null))
]);
content.Properties["blocks"]!.SetValue(JsonSerializer.Serialize(blockListValue));
ContentService.Save(content);
var newContentElementKey = Guid.NewGuid();
RemoveBlock(blockListValue, firstContentElementKey);
AddBlock(
blockListValue,
new BlockItemData
{
Key = newContentElementKey,
ContentTypeAlias = elementType.Alias,
ContentTypeKey = elementType.Key,
Values = new List<BlockPropertyValue> {
new() { Alias = "invariantText", Value = "#new: The new invariant settings value" },
new() { Alias = "variantText", Value = "#new: The new settings value in English", Culture = "en-US" },
new() { Alias = "variantText", Value = "#new: The new settings value in Danish", Culture = "da-DK" },
new() { Alias = "variantText", Value = "#new: The new settings value in German", Culture = "de-DE" },
},
},
null,
elementType);
var updateModel = new ContentUpdateModel
{
InvariantProperties = new[]
{
new PropertyValueModel { Alias = "blocks", Value = JsonSerializer.Serialize(blockListValue) }
},
Variants = new[]
{
new VariantModel { Name = content.GetCultureName("en-US")!, Culture = "en-US", Properties = [] },
new VariantModel { Name = content.GetCultureName("da-DK")!, Culture = "da-DK", Properties = [] },
new VariantModel { Name = content.GetCultureName("de-DE")!, Culture = "de-DE", Properties = [] }
},
};
var result = await ContentEditingService.UpdateAsync(content.Key, updateModel, userKey);
Assert.IsTrue(result.Success);
content = ContentService.GetById(content.Key);
var savedBlocksValue = content?.Properties["blocks"]?.GetValue()?.ToString();
Assert.NotNull(savedBlocksValue);
blockListValue = JsonSerializer.Deserialize<BlockListValue>(savedBlocksValue);
// In both cases we are allowed to change the invariant structure
// But the amount of new cultured values we can add differs
Assert.Multiple(() =>
{
Assert.AreEqual(1, blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Count(layoutItem => layoutItem.ContentKey == newContentElementKey));
Assert.AreEqual(0, blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Count(layoutItem => layoutItem.ContentKey == firstContentElementKey));
Assert.AreEqual(0, blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Count(layoutItem => layoutItem.SettingsKey == firstSettingsElementKey));
Assert.AreEqual(updateWithLimitedUserAccess ? 2 : 4, blockListValue.ContentData.Sum(contentData => contentData.Values.Count(value => value.Value?.ToString()?.StartsWith("#new") == true)));
});
}
/// <summary>
/// Tests whether the user can update the variant values of existing blocks inside an invariant blocklist
/// </summary>
/// <param name="updateWithLimitedUserAccess">true => danish only which is not the default. false => admin which is all languages</param>
[TestCase(true)]
[TestCase(false)]
public async Task Can_ClearBlocks_Limited_User_Access_To_Languages_Without_AllowEditInvariantFromNonDefault(bool updateWithLimitedUserAccess)
{
await LanguageService.CreateAsync(
new Language("de-DE", "German"), Constants.Security.SuperUserKey);
var userKey = updateWithLimitedUserAccess
? (await CreateLimitedUser()).Key
: Constants.Security.SuperUserKey;
var elementType = CreateElementType(ContentVariation.Culture);
var blockListDataType = await CreateBlockListDataType(elementType);
var contentType = CreateContentType(ContentVariation.Culture, blockListDataType);
var content = CreateContent(contentType, elementType, [], false);
content.SetCultureName("Home (de)", "de-DE");
ContentService.Save(content);
var blockListValue = BlockListPropertyValue(
elementType,
[
(
Guid.NewGuid(),
Guid.NewGuid(),
new BlockProperty(
new List<BlockPropertyValue> {
new() { Alias = "invariantText", Value = "#1: The first invariant content value" },
new() { Alias = "variantText", Value = "#1: The first content value in English", Culture = "en-US" },
new() { Alias = "variantText", Value = "#1: The first content value in Danish", Culture = "da-DK" },
new() { Alias = "variantText", Value = "#1: The first content value in German", Culture = "de-DE" }
},
new List<BlockPropertyValue> {
new() { Alias = "invariantText", Value = "#1: The first invariant settings value" },
new() { Alias = "variantText", Value = "#1: The first settings value in English", Culture = "en-US" },
new() { Alias = "variantText", Value = "#1: The first settings value in Danish", Culture = "da-DK" },
new() { Alias = "variantText", Value = "#1: The first settings value in German", Culture = "de-DE" }
},
null,
null
)
),
(
Guid.NewGuid(),
Guid.NewGuid(),
new BlockProperty(
new List<BlockPropertyValue> {
new() { Alias = "invariantText", Value = "#2: The first invariant content value" },
new() { Alias = "variantText", Value = "#2: The first content value in English", Culture = "en-US" },
new() { Alias = "variantText", Value = "#2: The first content value in Danish", Culture = "da-DK" },
new() { Alias = "variantText", Value = "#2: The first content value in German", Culture = "de-DE" }
},
new List<BlockPropertyValue> {
new() { Alias = "invariantText", Value = "#2: The first invariant settings value" },
new() { Alias = "variantText", Value = "#2: The first settings value in English", Culture = "en-US" },
new() { Alias = "variantText", Value = "#2: The first settings value in Danish", Culture = "da-DK" },
new() { Alias = "variantText", Value = "#2: The first settings value in German", Culture = "de-DE" }
},
null,
null
)
)
]
);
var serializedBlockListValue = JsonSerializer.Serialize(blockListValue);
content.Properties["blocks"]!.SetValue(serializedBlockListValue);
ContentService.Save(content);
var updateModel = new ContentUpdateModel
{
InvariantProperties = new[]
{
new PropertyValueModel { Alias = "blocks", Value = null },
},
Variants = new[]
{
new VariantModel { Name = content.GetCultureName("en-US")!, Culture = "en-US", Properties = [] },
new VariantModel { Name = content.GetCultureName("da-DK")!, Culture = "da-DK", Properties = [] },
new VariantModel { Name = content.GetCultureName("de-DE")!, Culture = "de-DE", Properties = [] },
},
};
var result = await ContentEditingService.UpdateAsync(content.Key, updateModel, userKey);
Assert.IsTrue(result.Success);
content = ContentService.GetById(content.Key);
var savedBlocksValue = content?.Properties["blocks"]?.GetValue()?.ToString();
// limited user access means English and German should not have been updated - changes should be rolled back to the initial block values
if (updateWithLimitedUserAccess)
{
Assert.NotNull(savedBlocksValue);
Assert.AreEqual(serializedBlockListValue, savedBlocksValue);
}
else
{
Assert.IsNull(savedBlocksValue);
}
}
/// <summary>
/// Tests whether the user can add/remove a value for a given culture
/// </summary>
/// <param name="updateWithLimitedUserAccess">true => danish only which is not the default. false => admin which is all languages</param>
[TestCase(true)]
[TestCase(false)]
public async Task Can_Handle_ValueRemoval_For_Limited_Users(
bool updateWithLimitedUserAccess)
{
await LanguageService.CreateAsync(
new Language("de-DE", "German"), Constants.Security.SuperUserKey);
var userKey = updateWithLimitedUserAccess
? (await CreateLimitedUser()).Key
: Constants.Security.SuperUserKey;
var elementType = CreateElementType(ContentVariation.Culture);
var blockListDataType = await CreateBlockListDataType(elementType);
var contentType = CreateContentType(ContentVariation.Culture, blockListDataType);
var content = CreateContent(contentType, elementType, [], false);
content.SetCultureName("Home (de)", "de-DE");
ContentService.Save(content);
var firstContentElementKey = Guid.NewGuid();
var firstSettingsElementKey = Guid.NewGuid();
var secondContentElementKey = Guid.NewGuid();
var secondSettingsElementKey = Guid.NewGuid();
var blockListValue = BlockListPropertyValue(
elementType,
[
(
firstContentElementKey,
firstSettingsElementKey,
new BlockProperty(
new List<BlockPropertyValue> {
new() { Alias = "invariantText", Value = "#1: The first invariant content value" },
new() { Alias = "variantText", Value = "#1: The first content value in English", Culture = "en-US" },
new() { Alias = "variantText", Value = "#1: The first content value in Danish", Culture = "da-DK" },
new() { Alias = "variantText", Value = "#1: The first content value in German", Culture = "de-DE" },
},
new List<BlockPropertyValue> {
new() { Alias = "invariantText", Value = "#1: The first invariant settings value" },
new() { Alias = "variantText", Value = "#1: The first settings value in English", Culture = "en-US" },
new() { Alias = "variantText", Value = "#1: The first settings value in Danish", Culture = "da-DK" },
new() { Alias = "variantText", Value = "#1: The first settings value in German", Culture = "de-DE" },
},
null,
null)),
(
secondContentElementKey,
secondSettingsElementKey,
new BlockProperty(
new List<BlockPropertyValue> {
new() { Alias = "invariantText", Value = "#2: The first invariant content value" },
new() { Alias = "variantText", Value = "#2: The first content value in English", Culture = "en-US" },
new() { Alias = "variantText", Value = "#2: The first content value in Danish", Culture = "da-DK" },
new() { Alias = "variantText", Value = "#2: The first content value in German", Culture = "de-DE" },
},
new List<BlockPropertyValue> {
new() { Alias = "invariantText", Value = "#2: The first invariant settings value" },
new() { Alias = "variantText", Value = "#2: The first settings value in English", Culture = "en-US" },
new() { Alias = "variantText", Value = "#2: The first settings value in Danish", Culture = "da-DK" },
new() { Alias = "variantText", Value = "#2: The first settings value in German", Culture = "de-DE" },
},
null,
null))
]);
content.Properties["blocks"]!.SetValue(JsonSerializer.Serialize(blockListValue));
ContentService.Save(content);
// remove a value the limited user can remove
blockListValue.ContentData.First().Values.RemoveAll(value => value.Culture == "da-DK");
blockListValue.SettingsData.First().Values.RemoveAll(value => value.Culture == "da-DK");
// remove a value the admin user can remove
blockListValue.ContentData.First().Values.RemoveAll(value => value.Culture == "en-US");
blockListValue.SettingsData.First().Values.RemoveAll(value => value.Culture == "en-US");
var updateModel = new ContentUpdateModel
{
InvariantProperties = new[]
{
new PropertyValueModel { Alias = "blocks", Value = JsonSerializer.Serialize(blockListValue) }
},
Variants = new[]
{
new VariantModel { Name = content.GetCultureName("en-US")!, Culture = "en-US", Properties = [] },
new VariantModel { Name = content.GetCultureName("da-DK")!, Culture = "da-DK", Properties = [] },
new VariantModel { Name = content.GetCultureName("de-DE")!, Culture = "de-DE", Properties = [] }
},
};
var result = await ContentEditingService.UpdateAsync(content.Key, updateModel, userKey);
Assert.IsTrue(result.Success);
content = ContentService.GetById(content.Key);
var savedBlocksValue = content?.Properties["blocks"]?.GetValue()?.ToString();
Assert.NotNull(savedBlocksValue);
blockListValue = JsonSerializer.Deserialize<BlockListValue>(savedBlocksValue);
if (updateWithLimitedUserAccess)
{
Assert.Multiple(() =>
{
// Should only have removed the danish value
Assert.AreEqual(1, blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Count(layoutItem => layoutItem.ContentKey == firstContentElementKey));
Assert.AreEqual(1, blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Count(layoutItem => layoutItem.SettingsKey == firstSettingsElementKey));
Assert.AreEqual(3, blockListValue.ContentData.Sum(contentData => contentData.Values.Count(value => value.Value?.ToString()?.StartsWith("#1") == true)));
Assert.AreEqual(3, blockListValue.SettingsData.Sum(settingsData => settingsData.Values.Count(value => value.Value?.ToString()?.StartsWith("#1") == true)));
Assert.AreEqual(0, blockListValue.ContentData.First().Values.Count(value => value.Culture == "da-DK"));
Assert.AreEqual(1, blockListValue.ContentData.First().Values.Count(value => value.Culture == "en-US"));
Assert.AreEqual(0, blockListValue.SettingsData.First().Values.Count(value => value.Culture == "da-DK"));
Assert.AreEqual(1, blockListValue.SettingsData.First().Values.Count(value => value.Culture == "en-US"));
// second wasn't touched
Assert.AreEqual(1, blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Count(layoutItem => layoutItem.SettingsKey == secondSettingsElementKey));
Assert.AreEqual(1, blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Count(layoutItem => layoutItem.ContentKey == secondContentElementKey));
Assert.AreEqual(4, blockListValue.ContentData.Sum(contentData => contentData.Values.Count(value => value.Value?.ToString()?.StartsWith("#2") == true)));
Assert.AreEqual(4, blockListValue.SettingsData.Sum(settingsData => settingsData.Values.Count(value => value.Value?.ToString()?.StartsWith("#2") == true)));
});
}
else
{
Assert.Multiple(() =>
{
// both danish and english should be removed
Assert.AreEqual(1, blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Count(layoutItem => layoutItem.ContentKey == firstContentElementKey));
Assert.AreEqual(1, blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Count(layoutItem => layoutItem.SettingsKey == firstSettingsElementKey));
Assert.AreEqual(2, blockListValue.ContentData.Sum(contentData => contentData.Values.Count(value => value.Value?.ToString()?.StartsWith("#1") == true)));
Assert.AreEqual(2, blockListValue.SettingsData.Sum(settingsData => settingsData.Values.Count(value => value.Value?.ToString()?.StartsWith("#1") == true)));
Assert.AreEqual(0, blockListValue.ContentData.First().Values.Count(value => value.Culture == "da-DK"));
Assert.AreEqual(0, blockListValue.ContentData.First().Values.Count(value => value.Culture == "en-US"));
Assert.AreEqual(0, blockListValue.SettingsData.First().Values.Count(value => value.Culture == "da-DK"));
Assert.AreEqual(0, blockListValue.SettingsData.First().Values.Count(value => value.Culture == "en-US"));
// second wasn't touched
Assert.AreEqual(1, blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Count(layoutItem => layoutItem.SettingsKey == secondSettingsElementKey));
Assert.AreEqual(1, blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Count(layoutItem => layoutItem.ContentKey == secondContentElementKey));
Assert.AreEqual(4, blockListValue.ContentData.Sum(contentData => contentData.Values.Count(value => value.Value?.ToString()?.StartsWith("#2") == true)));
Assert.AreEqual(4, blockListValue.SettingsData.Sum(settingsData => settingsData.Values.Count(value => value.Value?.ToString()?.StartsWith("#2") == true)));
});
}
}
@@ -940,4 +1477,55 @@ internal partial class BlockListElementLevelVariationTests
return user;
}
private void AddBlock(BlockListValue listValue, BlockItemData contentData, BlockItemData? settingsData, IContentType elementType)
{
listValue.ContentData.Add(contentData);
if (settingsData != null)
{
listValue.SettingsData.Add(settingsData);
}
var cultures = elementType.VariesByCulture()
? contentData.Values.Select(value => value.Culture)
.WhereNotNull()
.Distinct()
.ToArray()
: [null];
if (cultures.Any() is false)
{
cultures = [null];
}
var segments = elementType.VariesBySegment()
? contentData.Values.Select(value => value.Segment)
.Distinct()
.ToArray()
: [null];
foreach (var exposeItem in cultures.SelectMany(culture => segments.Select(segment =>
new BlockItemVariation(contentData.Key, culture, segment))))
{
listValue.Expose.Add(exposeItem);
}
listValue.Layout[Constants.PropertyEditors.Aliases.BlockList] = listValue
.Layout[Constants.PropertyEditors.Aliases.BlockList]
.Append(new BlockListLayoutItem { ContentKey = contentData.Key, SettingsKey = settingsData?.Key });
}
private void RemoveBlock(BlockListValue listValue, Guid blockKey)
{
// remove the item from the layout
var layoutItem = listValue.Layout[Constants.PropertyEditors.Aliases.BlockList].First(x => x.ContentKey == blockKey);
listValue.Layout[Constants.PropertyEditors.Aliases.BlockList] = listValue.Layout[Constants.PropertyEditors.Aliases.BlockList].Where(layout => layout.ContentKey != blockKey);
listValue.ContentData.RemoveAll(contentData => contentData.Key == blockKey);
if (layoutItem.SettingsKey != null)
{
listValue.SettingsData.RemoveAll(settingsData => settingsData.Key == layoutItem.SettingsKey);
}
listValue.Expose.RemoveAll(exposeItem => exposeItem.ContentKey == blockKey);
}
}
@@ -0,0 +1,67 @@
# Block Element Level Variant
### Notes
- When talking about variant data, we mean language variant
- Segment variants are not taken into account at this moment but can be expected to work in a similar manner
### What is it
When an element document type supports variant data (marked as variant and has a property marked as variant) is used
inside an invariant property on a variant document type, that property is considered to have element level variant data
which is a form of partial variant data.
When a property editor supports partial variant data (`IDataEditor.CanMergePartialPropertyValues()`) the
`ContentEditingService` will run the `IDataEditor.MergeVariantInvariantPropertyValue(...)` method to get a valid
value according to the rules defined for that propertyEditor
Block Element level variant data is this within all (core) property editors derived from blocks
- Umbraco.BlockList
- Umbraco.BlockGrid
- Umbraco.RichText
Most logic regarding this feature can be found in `BlockValuePropertyValueEditorBase`
### Axioms
1. A `null` value for a property, including element level variation based properties, is a valid value
2. The invariant value holds the structure/representation of the underlying variant values
3. The structure takes precedence over the underlying data
## Editing Data
### Access to invariant data
- All Languages: The user has access to all languages
- Default Language: The user has access to the language that is defined as the default
- AllowEditInvariantFromNonDefault: Configuration setting
| All Languages | Default Language | AllowEditInvariantFromNonDefault | Can Edit Invariant |
|---------------|------------------|----------------------------------|--------------------|
| True | Inherits True | N/A | True |
| False | True | N/A | True |
| False | False | True | True |
| False | False | False | False |
### Rules derived from the axioms
- A user with access to invariant data is allowed to add or remove blocks even if those blocks hold language variant
data they do not have access to.
- A user without access to invariant data is NOT allowed to add or remove blocks.
- A user can only edit element variant properties for the languages they have access to.
- A user is allowed to clear (set value to `null`) an element level variation as long as they have access to edit invariant data.
## Exposing
When a block is defined on invariant level but a language has not had its variant fields filled in yet,
the variant version of the block might be empty or considered not ready for publishing. The Expose feature allows
editors to define in which culture a block is ready to be consumed by the publishing process.
The client currently adds a blocks culture to the expose list when editing for that blocks starts in the culture,
more precisely when inline editor for the block is opened.
From an API perspective you are allowed to add and remove cultures from the expose list as long as you have the permissions to do so
### Axioms
- Expose data is linked to the same permissions as variant editing
- Variant blocks that are not exposed for a specific culture should not be processed by the publish feature
### Rules derived from the axioms
- Only a user with access to a language should be able to remove or add a block to the expose list for that language
- A block that is not exposed for a given language should not exist in the published value of the document for that language
- A block that is not exposed should not be processed when running validation during publishing or by running the validation separately.
@@ -0,0 +1,60 @@
using NUnit.Framework;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Tests.Integration.Attributes;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services;
public partial class ContentBlueprintEditingServiceTests
{
public static void AddScaffoldedNotificationHandler(IUmbracoBuilder builder)
=> builder.AddNotificationHandler<ContentScaffoldedNotification, ContentScaffoldedNotificationHandler>();
[TestCase(true)]
[TestCase(false)]
[ConfigureBuilder(ActionName = nameof(AddScaffoldedNotificationHandler))]
public async Task Can_Get_Scaffold(bool variant)
{
var blueprint = await (variant ? CreateVariantContentBlueprint() : CreateInvariantContentBlueprint());
try
{
ContentScaffoldedNotificationHandler.ContentScaffolded = notification =>
{
foreach (var propertyValue in notification.Scaffold.Properties.SelectMany(property => property.Values))
{
propertyValue.EditedValue += " scaffolded edited";
propertyValue.PublishedValue += " scaffolded published";
}
};
var result = await ContentBlueprintEditingService.GetScaffoldedAsync(blueprint.Key);
Assert.IsNotNull(result);
Assert.AreEqual(blueprint.Key, result.Key);
var propertyValues = result.Properties.SelectMany(property => property.Values).ToArray();
Assert.IsNotEmpty(propertyValues);
Assert.Multiple(() =>
{
Assert.IsTrue(propertyValues.All(value => value.EditedValue is string stringValue && stringValue.EndsWith(" scaffolded edited")));
Assert.IsTrue(propertyValues.All(value => value.PublishedValue is string stringValue && stringValue.EndsWith(" scaffolded published")));
});
}
finally
{
ContentScaffoldedNotificationHandler.ContentScaffolded = null;
}
}
[Test]
public async Task Cannot_Get_Non_Existing_Scaffold()
{
var result = await ContentBlueprintEditingService.GetScaffoldedAsync(Guid.NewGuid());
Assert.IsNull(result);
}
public class ContentScaffoldedNotificationHandler : INotificationHandler<ContentScaffoldedNotification>
{
public static Action<ContentScaffoldedNotification>? ContentScaffolded { get; set; }
public void Handle(ContentScaffoldedNotification notification) => ContentScaffolded?.Invoke(notification);
}
}
@@ -14,7 +14,7 @@ public partial class ContentPublishingServiceTests
var result = await ContentPublishingService.PublishAsync(
Textpage.Key,
MakeModel(ContentScheduleCollection.CreateWithEntry("*", DateTime.Now.AddDays(1), null)),
MakeModel(ContentScheduleCollection.CreateWithEntry("*", DateTime.UtcNow.AddDays(1), null)),
Constants.Security.SuperUserKey);
Assert.IsTrue(result.Success);
@@ -25,7 +25,7 @@ public partial class ContentPublishingServiceTests
[Test]
public async Task Publish_Single_Item_Does_Not_Publish_Children_In_The_Future()
{
await ContentPublishingService.PublishAsync(Textpage.Key, MakeModel(ContentScheduleCollection.CreateWithEntry("*", DateTime.Now.AddDays(1), null)), Constants.Security.SuperUserKey);
await ContentPublishingService.PublishAsync(Textpage.Key, MakeModel(ContentScheduleCollection.CreateWithEntry("*", DateTime.UtcNow.AddDays(1), null)), Constants.Security.SuperUserKey);
VerifyIsNotPublished(Textpage.Key);
VerifyIsNotPublished(Subpage.Key);
@@ -36,7 +36,7 @@ public partial class ContentPublishingServiceTests
{
await ContentPublishingService.PublishAsync(Textpage.Key, MakeModel(_allCultures), Constants.Security.SuperUserKey);
var result = await ContentPublishingService.PublishAsync(Subpage.Key, MakeModel(ContentScheduleCollection.CreateWithEntry("*", DateTime.Now.AddDays(1), null)), Constants.Security.SuperUserKey);
var result = await ContentPublishingService.PublishAsync(Subpage.Key, MakeModel(ContentScheduleCollection.CreateWithEntry("*", DateTime.UtcNow.AddDays(1), null)), Constants.Security.SuperUserKey);
Assert.IsTrue(result.Success);
Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Status);
@@ -968,7 +968,7 @@ public class EntityServiceTests : UmbracoIntegrationTest
// Create and Save Content "Text Page 1" based on "umbTextpage" -> 1054
_subpage = ContentBuilder.CreateSimpleContent(_contentType, "Text Page 1", _textpage.Id);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddMinutes(-5), null);
ContentService.Save(_subpage, -1, contentSchedule);
// Create and Save Content "Text Page 2" based on "umbTextpage" -> 1055
@@ -286,5 +286,8 @@
<Compile Update="ManagementApi\Services\UserStartNodeEntitiesServiceMediaTests.RootUserAccessEntities.cs">
<DependentUpon>UserStartNodeEntitiesServiceMediaTests.cs</DependentUpon>
</Compile>
<Compile Update="Umbraco.Infrastructure\Services\ContentBlueprintEditingServiceTests.GetScaffold.cs">
<DependentUpon>ContentBlueprintEditingServiceTests.cs</DependentUpon>
</Compile>
</ItemGroup>
</Project>
@@ -0,0 +1,76 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using NUnit.Framework;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Extensions;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration;
[TestFixture]
public class ContentSettingsExtensionsTests
{
[TestCase("jpg")]
[TestCase("JPG")]
[TestCase("jpg ")]
public void IsFileAllowedForUpload_Allows_File_In_Allow_List(string extension)
{
var contentSettings = new ContentSettings
{
AllowedUploadedFileExtensions = ["jpg", "png"],
};
Assert.IsTrue(contentSettings.IsFileAllowedForUpload(extension));
}
[TestCase("gif")]
[TestCase("GIF")]
[TestCase("gif ")]
public void IsFileAllowedForUpload_Rejects_File_Not_In_Allow_List(string extension)
{
var contentSettings = new ContentSettings
{
AllowedUploadedFileExtensions = ["jpg", "png"],
};
Assert.IsFalse(contentSettings.IsFileAllowedForUpload(extension));
}
[TestCase("jpg")]
[TestCase("JPG")]
[TestCase("jpg ")]
public void IsFileAllowedForUpload_Allows_File_Not_In_Disallow_List(string extension)
{
var contentSettings = new ContentSettings
{
DisallowedUploadedFileExtensions = ["gif", "png"],
};
Assert.IsTrue(contentSettings.IsFileAllowedForUpload(extension));
}
[TestCase("gif")]
[TestCase("GIF")]
[TestCase("gif ")]
public void IsFileAllowedForUpload_Rejects_File_In_Disallow_List(string extension)
{
var contentSettings = new ContentSettings
{
DisallowedUploadedFileExtensions = ["gif", "png"],
};
Assert.IsFalse(contentSettings.IsFileAllowedForUpload(extension));
}
[Test]
public void IsFileAllowedForUpload_Allows_File_In_Allow_List_Even_If_Also_In_Disallow_List()
{
var contentSettings = new ContentSettings
{
AllowedUploadedFileExtensions = ["jpg", "png"],
DisallowedUploadedFileExtensions = ["jpg"],
};
Assert.IsTrue(contentSettings.IsFileAllowedForUpload("jpg"));
}
}
@@ -0,0 +1,136 @@
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Models;
using Umbraco.Extensions;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Extensions;
public partial class ContentExtensionsTests
{
[Test]
public void GetStatus_WhenTrashed_ReturnsTrashed()
{
var contentMock = new Mock<IContent>();
contentMock.SetupGet(c => c.Trashed).Returns(true);
var result = contentMock.Object.GetStatus(new ContentScheduleCollection());
Assert.AreEqual(ContentStatus.Trashed, result);
}
[TestCase(true, ContentStatus.Published)]
[TestCase(false, ContentStatus.Unpublished)]
public void GetStatus_WithEmptySchedule_ReturnsPublishState(bool published, ContentStatus expectedStatus)
{
var contentTypeMock = new Mock<ISimpleContentType>();
contentTypeMock.SetupGet(c => c.Variations).Returns(ContentVariation.Nothing);
var mock = new Mock<IContent>();
mock.SetupGet(c => c.ContentType).Returns(contentTypeMock.Object);
mock.SetupGet(c => c.Published).Returns(published);
var result = mock.Object.GetStatus(new ContentScheduleCollection());
Assert.AreEqual(expectedStatus, result);
}
[TestCase(1)]
[TestCase(10)]
[TestCase(60)]
[TestCase(120)]
[TestCase(1000)]
public void GetStatus_WithPendingExpiry_ForInvariant_ReturnsExpired(int minutesFromExpiry)
{
var contentTypeMock = new Mock<ISimpleContentType>();
contentTypeMock.SetupGet(c => c.Variations).Returns(ContentVariation.Nothing);
var mock = new Mock<IContent>();
mock.SetupGet(c => c.ContentType).Returns(contentTypeMock.Object);
var schedule = ContentScheduleCollection.CreateWithEntry(null, DateTime.UtcNow.AddMinutes(-1 * minutesFromExpiry));
var result = mock.Object.GetStatus(schedule);
Assert.AreEqual(ContentStatus.Expired, result);
}
[TestCase(1)]
[TestCase(10)]
[TestCase(60)]
[TestCase(120)]
[TestCase(1000)]
public void GetStatus_WithPendingRelease_ForInvariant_ReturnsAwaitingRelease(int minutesUntilRelease)
{
var contentTypeMock = new Mock<ISimpleContentType>();
contentTypeMock.SetupGet(c => c.Variations).Returns(ContentVariation.Nothing);
var mock = new Mock<IContent>();
mock.SetupGet(c => c.ContentType).Returns(contentTypeMock.Object);
var schedule = ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddMinutes(minutesUntilRelease), null);
var result = mock.Object.GetStatus(schedule);
Assert.AreEqual(ContentStatus.AwaitingRelease, result);
}
[TestCase(1)]
[TestCase(10)]
[TestCase(60)]
[TestCase(120)]
[TestCase(1000)]
public void GetStatus_WithPastReleaseAndFutureExpiry_ForInvariant_ReturnsPublishedState(int minutes)
{
var contentTypeMock = new Mock<ISimpleContentType>();
contentTypeMock.SetupGet(c => c.Variations).Returns(ContentVariation.Nothing);
var mock = new Mock<IContent>();
mock.SetupGet(c => c.ContentType).Returns(contentTypeMock.Object);
mock.SetupGet(c => c.Published).Returns(true);
var schedule = ContentScheduleCollection.CreateWithEntry(DateTime.UtcNow.AddMinutes(-1 * minutes), DateTime.UtcNow.AddMinutes(minutes));
var result = mock.Object.GetStatus(schedule);
Assert.AreEqual(ContentStatus.Published, result);
}
[TestCase(1)]
[TestCase(10)]
[TestCase(60)]
[TestCase(120)]
[TestCase(1000)]
public void GetStatus_WithPendingExpiry_ForVariant_ReturnsExpired(int minutesFromExpiry)
{
var contentTypeMock = new Mock<ISimpleContentType>();
contentTypeMock.SetupGet(c => c.Variations).Returns(ContentVariation.Culture);
var mock = new Mock<IContent>();
mock.SetupGet(c => c.ContentType).Returns(contentTypeMock.Object);
var schedule = ContentScheduleCollection.CreateWithEntry("en-US", null, DateTime.UtcNow.AddMinutes(-1 * minutesFromExpiry));
var result = mock.Object.GetStatus(schedule, "en-US");
Assert.AreEqual(ContentStatus.Expired, result);
}
[TestCase(1)]
[TestCase(10)]
[TestCase(60)]
[TestCase(120)]
[TestCase(1000)]
public void GetStatus_WithPendingRelease_ForVariant_ReturnsAwaitingRelease(int minutesUntilRelease)
{
var contentTypeMock = new Mock<ISimpleContentType>();
contentTypeMock.SetupGet(c => c.Variations).Returns(ContentVariation.Culture);
var mock = new Mock<IContent>();
mock.SetupGet(c => c.ContentType).Returns(contentTypeMock.Object);
var schedule = ContentScheduleCollection.CreateWithEntry("en-US", DateTime.UtcNow.AddMinutes(minutesUntilRelease), null);
var result = mock.Object.GetStatus(schedule, "en-US");
Assert.AreEqual(ContentStatus.AwaitingRelease, result);
}
[TestCase(1)]
[TestCase(10)]
[TestCase(60)]
[TestCase(120)]
[TestCase(1000)]
public void GetStatus_WithPastReleaseAndFutureExpiry_ForVariant_ReturnsPublishedState(int minutes)
{
var contentTypeMock = new Mock<ISimpleContentType>();
contentTypeMock.SetupGet(c => c.Variations).Returns(ContentVariation.Culture);
var mock = new Mock<IContent>();
mock.SetupGet(c => c.ContentType).Returns(contentTypeMock.Object);
mock.SetupGet(c => c.Published).Returns(true);
var schedule = ContentScheduleCollection.CreateWithEntry("en-US", DateTime.UtcNow.AddMinutes(-1 * minutes), DateTime.UtcNow.AddMinutes(minutes));
var result = mock.Object.GetStatus(schedule, "en-US");
Assert.AreEqual(ContentStatus.Published, result);
}
}
@@ -0,0 +1,8 @@
using NUnit.Framework;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Extensions;
[TestFixture]
public partial class ContentExtensionsTests
{
}
@@ -0,0 +1,127 @@
using NUnit.Framework;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services;
[TestFixture]
public class DocumentUrlServiceTests
{
[Test]
public void ConvertToCacheModel_Converts_Single_Document_With_Single_Segment_To_Expected_Cache_Model()
{
var segments = new List<PublishedDocumentUrlSegment>
{
new()
{
DocumentKey = Guid.NewGuid(),
IsDraft = false,
IsPrimary = true,
LanguageId = 1,
UrlSegment = "test-segment",
},
};
var cacheModels = DocumentUrlService.ConvertToCacheModel(segments).ToList();
Assert.AreEqual(1, cacheModels.Count);
Assert.AreEqual(segments[0].DocumentKey, cacheModels[0].DocumentKey);
Assert.AreEqual(1, cacheModels[0].LanguageId);
Assert.AreEqual(1, cacheModels[0].UrlSegments.Count);
Assert.AreEqual("test-segment", cacheModels[0].UrlSegments[0].Segment);
Assert.IsTrue(cacheModels[0].UrlSegments[0].IsPrimary);
}
[Test]
public void ConvertToCacheModel_Converts_Multiple_Documents_With_Single_Segment_To_Expected_Cache_Model()
{
var segments = new List<PublishedDocumentUrlSegment>
{
new()
{
DocumentKey = Guid.NewGuid(),
IsDraft = false,
IsPrimary = true,
LanguageId = 1,
UrlSegment = "test-segment",
},
new()
{
DocumentKey = Guid.NewGuid(),
IsDraft = false,
IsPrimary = true,
LanguageId = 1,
UrlSegment = "test-segment-2",
},
};
var cacheModels = DocumentUrlService.ConvertToCacheModel(segments).ToList();
Assert.AreEqual(2, cacheModels.Count);
Assert.AreEqual(segments[0].DocumentKey, cacheModels[0].DocumentKey);
Assert.AreEqual(segments[1].DocumentKey, cacheModels[1].DocumentKey);
Assert.AreEqual(1, cacheModels[0].LanguageId);
Assert.AreEqual(1, cacheModels[1].LanguageId);
Assert.AreEqual(1, cacheModels[0].UrlSegments.Count);
Assert.AreEqual("test-segment", cacheModels[0].UrlSegments[0].Segment);
Assert.AreEqual(1, cacheModels[1].UrlSegments.Count);
Assert.AreEqual("test-segment-2", cacheModels[1].UrlSegments[0].Segment);
Assert.IsTrue(cacheModels[0].UrlSegments[0].IsPrimary);
Assert.IsTrue(cacheModels[1].UrlSegments[0].IsPrimary);
}
[Test]
public void ConvertToCacheModel_Converts_Single_Document_With_Multiple_Segments_To_Expected_Cache_Model()
{
var documentKey = Guid.NewGuid();
var segments = new List<PublishedDocumentUrlSegment>
{
new()
{
DocumentKey = documentKey,
IsDraft = false,
IsPrimary = true,
LanguageId = 1,
UrlSegment = "test-segment",
},
new()
{
DocumentKey = documentKey,
IsDraft = false,
IsPrimary = false,
LanguageId = 1,
UrlSegment = "test-segment-2",
},
};
var cacheModels = DocumentUrlService.ConvertToCacheModel(segments).ToList();
Assert.AreEqual(1, cacheModels.Count);
Assert.AreEqual(documentKey, cacheModels[0].DocumentKey);
Assert.AreEqual(1, cacheModels[0].LanguageId);
Assert.AreEqual(2, cacheModels[0].UrlSegments.Count);
Assert.AreEqual("test-segment", cacheModels[0].UrlSegments[0].Segment);
Assert.AreEqual("test-segment-2", cacheModels[0].UrlSegments[1].Segment);
Assert.IsTrue(cacheModels[0].UrlSegments[0].IsPrimary);
Assert.IsFalse(cacheModels[0].UrlSegments[1].IsPrimary);
}
[Test]
public void ConvertToCacheModel_Performance_Test()
{
const int NumberOfSegments = 1;
var segments = Enumerable.Range(0, NumberOfSegments)
.Select((x, i) => new PublishedDocumentUrlSegment
{
DocumentKey = Guid.NewGuid(),
IsDraft = false,
IsPrimary = true,
LanguageId = 1,
UrlSegment = $"test-segment-{x + 1}",
});
var cacheModels = DocumentUrlService.ConvertToCacheModel(segments).ToList();
Assert.AreEqual(NumberOfSegments, cacheModels.Count);
// Benchmarking (for NumberOfSegments = 50000):
// - Initial implementation (15.4): ~28s
// - Current implementation: ~100ms
}
}
@@ -46,5 +46,8 @@
<Compile Update="Umbraco.Cms.Api.Management\Services\BackOfficeExternalLoginServiceTests.UnLinkLoginAsync.cs">
<DependentUpon>BackOfficeExternalLoginServiceTests.cs</DependentUpon>
</Compile>
<Compile Update="Umbraco.Core\Extensions\ContentExtensionsTests.GetStatus.cs">
<DependentUpon>ContentExtensionsTests.cs</DependentUpon>
</Compile>
</ItemGroup>
</Project>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
"version": "15.4.0-rc",
"version": "15.4.4",
"assemblyVersion": {
"precision": "build"
},