Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9044100f4b | ||
|
|
16f4a82ff4 | ||
|
|
dbecec3451 | ||
|
|
d92e6bbeff | ||
|
|
e56ddcc3f9 | ||
|
|
fb8c3b19ce | ||
|
|
7a7aadffe5 | ||
|
|
0ac6e8500a | ||
|
|
4c35c0c2e9 | ||
|
|
de47e0b1f7 | ||
|
|
969ae87798 | ||
|
|
828e359666 | ||
|
|
a5b7e0dac1 | ||
|
|
86abc3528d | ||
|
|
c45b12ec58 | ||
|
|
22c4bc7835 | ||
|
|
1e82376420 | ||
|
|
aa854da3f4 | ||
|
|
28cdbe5317 | ||
|
|
3dbd4baefe | ||
|
|
fa5dd209c1 | ||
|
|
4cc4acee62 | ||
|
|
62663d9573 | ||
|
|
b582f9d2ef | ||
|
|
ad90db8b38 | ||
|
|
8e3b821a55 | ||
|
|
8ac989c4e3 | ||
|
|
3913a61b74 | ||
|
|
90bedcd42e | ||
|
|
c54189aa90 | ||
|
|
88ec0a248f | ||
|
|
3e22733081 | ||
|
|
232077e820 | ||
|
|
943d1eeccd | ||
|
|
b3666dad8b | ||
|
|
28a403361e | ||
|
|
8e6a791de0 | ||
|
|
5ffea3152b | ||
|
|
2043ff1dbd |
@@ -0,0 +1,135 @@
|
||||
---
|
||||
name: umb-release-notes
|
||||
description: Improve a set of auto-generated GitHub release notes for an Umbraco CMS release. Cross-checks the notes against every PR carrying the release label, adds any that are missing, re-files every PR under the most appropriate category, and strips purely-internal entries. Use whenever the user asks to tidy up, improve, complete, or recategorize release notes for a given version, or mentions a release-notes text file plus a version number.
|
||||
argument-hint: <version> <path-to-generated-notes-file>
|
||||
---
|
||||
|
||||
# Umbraco CMS - Improve Release Notes
|
||||
|
||||
Takes a file of auto-generated GitHub release notes and produces an improved version that:
|
||||
|
||||
1. **Is complete** — every merged PR carrying the `release/<version>` label appears.
|
||||
2. **Is well-categorized** — every PR sits under the most appropriate heading.
|
||||
3. **Is free of noise** — purely-internal entries of no value to a reader are removed.
|
||||
|
||||
The result is written to a **new** file alongside the input, so the user can diff the two.
|
||||
|
||||
**Run autonomously.** Do NOT use `AskUserQuestion` once the required arguments (version and input file path) are available — only ask if one of them is missing from `$ARGUMENTS` and cannot be inferred (see Arguments). Beyond that, make the categorization calls yourself using the rules below; if a handful are genuinely borderline, place them anyway and note the borderline ones in your closing summary so the user can override.
|
||||
|
||||
## Arguments
|
||||
|
||||
`$ARGUMENTS` contains two values:
|
||||
|
||||
1. **Version** — e.g. `17.5.0`, `18.1.0`. The GitHub label to search is `release/<version>` (so version `17.5.0` → label `release/17.5.0`).
|
||||
2. **Input file path** — full path to the text file holding the auto-generated notes (e.g. `C:\Temp\release-17.5.0-rc.md`).
|
||||
|
||||
If either is missing, ask the user once for the missing value, then proceed.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Run `gh auth status`. If it fails, tell the user to authenticate `gh` (e.g. `gh auth login`) and stop — the skill needs the GitHub CLI to query PRs. The repo is always `umbraco/Umbraco-CMS`.
|
||||
|
||||
## Procedure
|
||||
|
||||
### 1. Read the input notes
|
||||
|
||||
Read the input file. Note its structure — it is GitHub's generated format:
|
||||
|
||||
- A leading HTML comment (`<!-- Release notes generated ... -->`).
|
||||
- A `## What's Changed` heading followed by `### <emoji> <Category>` sub-headings, each with `* <title> by @<author> in <url>` bullets.
|
||||
- A trailing `## New Contributors` section and a `**Full Changelog**: ...` line.
|
||||
|
||||
Extract the set of PR numbers already present (parse the `/pull/<number>` from each bullet). Preserve each existing bullet's **exact text** (title, author, URL) when you re-emit it — only its category placement may change.
|
||||
|
||||
### 2. Fetch every labelled PR
|
||||
|
||||
```bash
|
||||
gh pr list --repo umbraco/Umbraco-CMS --label "release/<version>" --state closed --limit 1000 \
|
||||
--json number,title,author,labels,mergedAt \
|
||||
--jq '.[] | select(.mergedAt != null) | "\(.number)\t\(.author.login)\t\([.labels[].name] | join(", "))\t\(.title)"' | sort -n
|
||||
```
|
||||
|
||||
This is the authoritative list of what the release *should* contain. Each row gives number, author, labels, title.
|
||||
|
||||
**Guard against silent truncation.** `gh pr list` caps at `--limit` without warning, so a large release could drop the overflow and the skill would still look "complete". Count the returned rows and compare against the limit:
|
||||
|
||||
```bash
|
||||
gh pr list --repo umbraco/Umbraco-CMS --label "release/<version>" --state closed --limit 1000 --json number --jq 'length'
|
||||
```
|
||||
|
||||
If this equals 1000, the limit was hit — raise `--limit` and re-fetch before continuing. Do **not** proceed on a truncated list.
|
||||
|
||||
### 3. Reconcile
|
||||
|
||||
- **Missing labelled PRs** (labelled but not in the input file): these must be **added**. Build a bullet as `* <title> by @<author> in https://github.com/umbraco/Umbraco-CMS/pull/<number>`.
|
||||
- **Author handle.** `<author>` in the template is the raw `.author.login` value — the bullet supplies the leading `@`, so do not prepend another. `gh`'s `.author.login` already returns bot accounts with the `[bot]` suffix as part of the login — Dependabot comes back as `dependabot[bot]`, not `dependabot` or `app/dependabot` (the `app/` form only appears in git committer metadata and CODEOWNERS, never in `gh`'s JSON). So the login is already in the right shape; use it verbatim (e.g. `.author.login` of `dependabot[bot]` renders as `@dependabot[bot]`, matching what GitHub's generator wrote for the existing bullets). The only thing to guard against is accidentally stripping or altering the `[bot]` suffix.
|
||||
- **PRs in the file but not labelled**: keep them. The generated notes span a commit range (see the `Full Changelog` compare link), so they legitimately include backports / earlier-version PRs that lack the current label. For any of these you need to categorize, fetch its labels with:
|
||||
|
||||
```bash
|
||||
gh pr view <number> --repo umbraco/Umbraco-CMS --json number,title,labels \
|
||||
--jq '"\(.number)\t\([.labels[].name] | join(", "))\t\(.title)"'
|
||||
```
|
||||
|
||||
Do **not** invent or alter the `New Contributors` section — carry it over verbatim. You cannot reliably recompute first-time contributors, so leave it as the generator produced it (mention this in the summary).
|
||||
|
||||
### 4. Categorize every PR
|
||||
|
||||
Use exactly these headings, in this order. Omit any heading that ends up with no entries.
|
||||
|
||||
| Heading | What goes here | Primary signal |
|
||||
|---|---|---|
|
||||
| `### 🙌 Notable Changes` | **Don't recategorize existing entries.** Label-driven — but still add any missing PR carrying this label here. | label `category/notable` |
|
||||
| `### 💥 Breaking Changes` | **Don't recategorize existing entries.** Label-driven — but still add any missing PR carrying this label here. | label `category/breaking` |
|
||||
| `### 📦 Dependencies` | Dependency bumps | label `dependencies`; or dependabot author |
|
||||
| `### 🚀 New Features` | New user- or developer-facing capability | label `type/feature` / `category/feature`; or title introduces/adds a genuinely new capability |
|
||||
| `### 🚤 Performance` | Performance improvements | label `category/performance`; or `Performance:` title prefix |
|
||||
| `### 🌈 Accessibility Improvements` | A11y improvements (labels, contrast, keyboard) | label `category/accessibility` / `accessibility`; or clear a11y intent (e.g. "improve contrast", "missing labels") |
|
||||
| `### 🐛 Bug Fixes` | Fixes to broken/incorrect behaviour | default for anything describing a fix |
|
||||
| `### 🧪 Testing` | Test additions/changes only | label `category/test-automation` / `area/test`; or `E2E`/`QA`/"acceptance tests"/"unit test coverage"/"add tests" titles |
|
||||
| `### 🛡️ Code Quality, Documentation and Refactoring` | Refactors, deprecations, API tidy-ups, XML/MD documentation, knowledge-base (`MD`) updates | label `category/refactor`; or titles about refactoring, deprecating, renaming, documenting, constants extraction, MD/CLAUDE.md content |
|
||||
| `### 🧑💻 Developer Experience` | Things that improve the experience of developers building on or contributing to Umbraco — dev tooling, build/watch ergonomics, test mocks/harnesses, backoffice dev utilities | `Developer Experience` title prefix; dev tooling; mock/harness changes |
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Notable and Breaking are off-limits for recategorization** — never move a PR that is *already in the input file* into or out of these sections; they are driven purely by their labels and the generator placed them correctly. This does **not** exempt them from completeness: a PR discovered as missing in step 3 that carries `category/notable` or `category/breaking` must still be **added** under the matching section.
|
||||
- Label signals beat title wording, except a `Performance:`/`Developer Experience:` title prefix is decisive for its section.
|
||||
- A PR with both `type/feature` and `category/refactor` whose title clearly describes a refactor (e.g. "swap relative imports", "re-export type") belongs under Code Quality, not New Features.
|
||||
- "Add ... tests"/"unit test coverage" → Testing, even if it also touches docs. If a PR adds XML documentation *and* tests, lead with where the title's emphasis lies (documentation → Code Quality; test coverage → Testing).
|
||||
- When a PR is genuinely 50/50, pick the more reader-useful heading and list it in your closing summary as borderline.
|
||||
|
||||
### 5. Remove purely-internal noise
|
||||
|
||||
Drop entries that have **no value to anyone reading release notes** — pure repository plumbing with no shipped impact. Examples:
|
||||
|
||||
- Branch/merge maintenance ("Fix main branch after merge issue").
|
||||
- CI/pipeline fixes that don't change the product.
|
||||
- Reverts of changes that never shipped in a release.
|
||||
|
||||
**Keep** anything that ships in the product or genuinely helps developers building on Umbraco — that includes documentation/MD updates, dev tooling, and test mocks (those go to Code Quality or Developer Experience, they are *not* noise). When unsure whether something is noise, keep it and flag it in the summary rather than silently dropping it. List every removal in your closing summary.
|
||||
|
||||
### 6. Write the output
|
||||
|
||||
Write to a new file in the **same folder** as the input, named by appending ` - with updates` before the extension:
|
||||
|
||||
- Input `C:\Temp\release-17.5.0-rc.md` → Output `C:\Temp\release-17.5.0-rc - with updates.md`
|
||||
|
||||
Preserve the leading HTML comment, the `## What's Changed` heading, the `## New Contributors` section, and the `**Full Changelog**` line exactly. Only the `### <category>` groupings and their bullets change.
|
||||
|
||||
### 7. Report
|
||||
|
||||
Give a concise summary:
|
||||
|
||||
- Count of PRs added (with their numbers), and which categories they landed in.
|
||||
- Notable recategorizations (PRs moved out of the catch-all Bug Fixes into Features/Performance/Testing/etc.).
|
||||
- Every entry removed, with the one-line reason.
|
||||
- Any borderline calls the user may want to override.
|
||||
- The output file path.
|
||||
|
||||
## Verification
|
||||
|
||||
Before reporting done, confirm:
|
||||
|
||||
- Every PR number from step 2 is present in the output (except any you deliberately removed in step 5 — and those must be in the removal list).
|
||||
- No PR appears under more than one heading.
|
||||
- Notable and Breaking sections are byte-for-byte unchanged from the input.
|
||||
- The header comment, New Contributors, and Full Changelog lines are intact.
|
||||
@@ -545,6 +545,8 @@ Allowed, but cheap to write and cheaper to leave behind. Keep them short and tra
|
||||
|
||||
Verify any test you add for a bug fix actually catches the bug: either write the failing test first (TDD), or temporarily revert the production change and confirm the test fails before re-applying. A test that passes both ways proves nothing. Watch for coincidental passes — default seed/sort orders can make a buggy path produce the right answer for the test's specific inputs; construct inputs so the broken and fixed behaviours give visibly different results.
|
||||
|
||||
For integration tests that exercise caching or cache refreshers, see `tests/Umbraco.Tests.Integration/CLAUDE.md` — the harness disables caching by default, which can produce false greens.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
@@ -45,15 +45,15 @@
|
||||
<PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.1" />
|
||||
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.1" />
|
||||
<PackageVersion Include="Dazinator.Extensions.FileProviders" Version="2.0.0" />
|
||||
<PackageVersion Include="Examine" Version="3.7.1" />
|
||||
<PackageVersion Include="Examine.Core" Version="3.7.1" />
|
||||
<PackageVersion Include="Examine" Version="3.8.0" />
|
||||
<PackageVersion Include="Examine.Core" Version="3.8.0" />
|
||||
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
|
||||
<PackageVersion Include="JsonPatch.Net" Version="3.3.0" />
|
||||
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
|
||||
<PackageVersion Include="MailKit" Version="4.16.0" />
|
||||
<PackageVersion Include="Markdig" Version="0.45.0" />
|
||||
<PackageVersion Include="Markdown" Version="2.2.1" />
|
||||
<PackageVersion Include="MessagePack" Version="3.1.4" />
|
||||
<PackageVersion Include="MessagePack" Version="3.1.7" />
|
||||
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
|
||||
<PackageVersion Include="MiniProfiler.Shared" Version="4.5.4" />
|
||||
<PackageVersion Include="ncrontab" Version="3.4.0" />
|
||||
@@ -92,4 +92,4 @@
|
||||
<!-- TODO: Remove this pinned dependency when Examine updates its Microsoft.AspNetCore.DataProtection reference. -->
|
||||
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.6" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.Security.Authorization;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
using Umbraco.Cms.Core.Actions;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Document;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an API endpoint for sorting the root-level documents by a system field.
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
public class SortChildrenAtRootDocumentController : DocumentControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IContentEditingService _contentEditingService;
|
||||
private readonly IEntityService _entityService;
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SortChildrenAtRootDocumentController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="authorizationService">Service used to authorize user actions.</param>
|
||||
/// <param name="contentEditingService">Service for editing and managing content.</param>
|
||||
/// <param name="entityService">Service used to resolve the children to authorize.</param>
|
||||
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
|
||||
public SortChildrenAtRootDocumentController(
|
||||
IAuthorizationService authorizationService,
|
||||
IContentEditingService contentEditingService,
|
||||
IEntityService entityService,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_contentEditingService = contentEditingService;
|
||||
_entityService = entityService;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the root-level documents by a system field.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
|
||||
/// <param name="requestModel">The field to sort by and the sort direction.</param>
|
||||
/// <returns>
|
||||
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
|
||||
/// returns <c>200 OK</c> if sorting succeeds or <c>400 Bad Request</c> if the field is not recognised.
|
||||
/// </returns>
|
||||
[HttpPut("root/sort-children")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[EndpointSummary("Sorts the root-level documents by a field.")]
|
||||
[EndpointDescription("Sorts the root-level documents by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so documents that do not vary by it (or an unrecognised culture) fall back to the invariant name.")]
|
||||
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, SortDocumentChildrenByFieldRequestModel requestModel)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
ContentPermissionResource.WithKeys(ActionSort.ActionLetter, (Guid?)null),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
|
||||
_authorizationService,
|
||||
_entityService,
|
||||
User,
|
||||
parentKey: null,
|
||||
UmbracoObjectTypes.Document,
|
||||
childKeys => ContentPermissionResource.WithKeys(ActionSort.ActionLetter, childKeys),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!childrenAuthorized)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
ContentEditingOperationStatus result = await _contentEditingService.SortByFieldAsync(
|
||||
null,
|
||||
requestModel.Field,
|
||||
requestModel.Direction,
|
||||
requestModel.Culture,
|
||||
CurrentUserKey(_backOfficeSecurityAccessor));
|
||||
|
||||
return result == ContentEditingOperationStatus.Success
|
||||
? Ok()
|
||||
: ContentEditingOperationStatusResult(result);
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.Security.Authorization;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
using Umbraco.Cms.Core.Actions;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Document;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an API endpoint for sorting the children of a document by a system field.
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
public class SortChildrenDocumentController : DocumentControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IContentEditingService _contentEditingService;
|
||||
private readonly IEntityService _entityService;
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SortChildrenDocumentController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="authorizationService">Service used to authorize user actions.</param>
|
||||
/// <param name="contentEditingService">Service for editing and managing content.</param>
|
||||
/// <param name="entityService">Service used to resolve the children to authorize.</param>
|
||||
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
|
||||
public SortChildrenDocumentController(
|
||||
IAuthorizationService authorizationService,
|
||||
IContentEditingService contentEditingService,
|
||||
IEntityService entityService,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_contentEditingService = contentEditingService;
|
||||
_entityService = entityService;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the child documents of the specified parent document by a system field.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
|
||||
/// <param name="id">The unique identifier of the parent document whose children should be sorted.</param>
|
||||
/// <param name="requestModel">The field to sort by and the sort direction.</param>
|
||||
/// <returns>
|
||||
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
|
||||
/// returns <c>200 OK</c> if sorting succeeds, <c>400 Bad Request</c> if the field is not recognised, or <c>404 Not Found</c> if the parent document does not exist.
|
||||
/// </returns>
|
||||
[HttpPut("{id:guid}/sort-children")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[EndpointSummary("Sorts the children of a document by a field.")]
|
||||
[EndpointDescription("Sorts the children of the specified parent document by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so children that do not vary by it (or an unrecognised culture) fall back to the invariant name.")]
|
||||
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, Guid id, SortDocumentChildrenByFieldRequestModel requestModel)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
ContentPermissionResource.WithKeys(ActionSort.ActionLetter, id),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
|
||||
_authorizationService,
|
||||
_entityService,
|
||||
User,
|
||||
id,
|
||||
UmbracoObjectTypes.Document,
|
||||
childKeys => ContentPermissionResource.WithKeys(ActionSort.ActionLetter, childKeys),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!childrenAuthorized)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
ContentEditingOperationStatus result = await _contentEditingService.SortByFieldAsync(
|
||||
id,
|
||||
requestModel.Field,
|
||||
requestModel.Direction,
|
||||
requestModel.Culture,
|
||||
CurrentUserKey(_backOfficeSecurityAccessor));
|
||||
|
||||
return result == ContentEditingOperationStatus.Success
|
||||
? Ok()
|
||||
: ContentEditingOperationStatusResult(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.Security.Authorization;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an API endpoint for sorting the root-level media items by a system field.
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
public class SortChildrenAtRootMediaController : MediaControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IMediaEditingService _mediaEditingService;
|
||||
private readonly IEntityService _entityService;
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SortChildrenAtRootMediaController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="authorizationService">Service used to authorize user actions.</param>
|
||||
/// <param name="mediaEditingService">Service responsible for editing and sorting media items.</param>
|
||||
/// <param name="entityService">Service used to resolve the children to authorize.</param>
|
||||
/// <param name="backOfficeSecurityAccessor">Accessor for backoffice security context.</param>
|
||||
public SortChildrenAtRootMediaController(
|
||||
IAuthorizationService authorizationService,
|
||||
IMediaEditingService mediaEditingService,
|
||||
IEntityService entityService,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_mediaEditingService = mediaEditingService;
|
||||
_entityService = entityService;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the root-level media items by a system field.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
|
||||
/// <param name="requestModel">The field to sort by and the sort direction.</param>
|
||||
/// <returns>
|
||||
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
|
||||
/// returns <c>200 OK</c> if sorting succeeds or <c>400 Bad Request</c> if the field is not recognised.
|
||||
/// </returns>
|
||||
[HttpPut("root/sort-children")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[EndpointSummary("Sorts the root-level media items by a field.")]
|
||||
[EndpointDescription("Sorts the root-level media items by a system field in the given direction.")]
|
||||
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, SortMediaChildrenByFieldRequestModel requestModel)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
MediaPermissionResource.Root(),
|
||||
AuthorizationPolicies.MediaPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
|
||||
_authorizationService,
|
||||
_entityService,
|
||||
User,
|
||||
parentKey: null,
|
||||
UmbracoObjectTypes.Media,
|
||||
childKeys => MediaPermissionResource.WithKeys(childKeys),
|
||||
AuthorizationPolicies.MediaPermissionByResource);
|
||||
|
||||
if (!childrenAuthorized)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
ContentEditingOperationStatus result = await _mediaEditingService.SortByFieldAsync(
|
||||
null,
|
||||
requestModel.Field,
|
||||
requestModel.Direction,
|
||||
CurrentUserKey(_backOfficeSecurityAccessor));
|
||||
|
||||
return result == ContentEditingOperationStatus.Success
|
||||
? Ok()
|
||||
: ContentEditingOperationStatusResult(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.Security.Authorization;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an API endpoint for sorting the children of a media item by a system field.
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
public class SortChildrenMediaController : MediaControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IMediaEditingService _mediaEditingService;
|
||||
private readonly IEntityService _entityService;
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SortChildrenMediaController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="authorizationService">Service used to authorize user actions.</param>
|
||||
/// <param name="mediaEditingService">Service responsible for editing and sorting media items.</param>
|
||||
/// <param name="entityService">Service used to resolve the children to authorize.</param>
|
||||
/// <param name="backOfficeSecurityAccessor">Accessor for backoffice security context.</param>
|
||||
public SortChildrenMediaController(
|
||||
IAuthorizationService authorizationService,
|
||||
IMediaEditingService mediaEditingService,
|
||||
IEntityService entityService,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_mediaEditingService = mediaEditingService;
|
||||
_entityService = entityService;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the child media items of the specified parent media item by a system field.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
|
||||
/// <param name="id">The unique identifier of the parent media item whose children should be sorted.</param>
|
||||
/// <param name="requestModel">The field to sort by and the sort direction.</param>
|
||||
/// <returns>
|
||||
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
|
||||
/// returns <c>200 OK</c> if sorting succeeds, <c>400 Bad Request</c> if the field is not recognised, or <c>404 Not Found</c> if the parent media item does not exist.
|
||||
/// </returns>
|
||||
[HttpPut("{id:guid}/sort-children")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[EndpointSummary("Sorts the children of a media item by a field.")]
|
||||
[EndpointDescription("Sorts the children of the specified parent media item by a system field in the given direction.")]
|
||||
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, Guid id, SortMediaChildrenByFieldRequestModel requestModel)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
MediaPermissionResource.WithKeys(id),
|
||||
AuthorizationPolicies.MediaPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
|
||||
_authorizationService,
|
||||
_entityService,
|
||||
User,
|
||||
id,
|
||||
UmbracoObjectTypes.Media,
|
||||
childKeys => MediaPermissionResource.WithKeys(childKeys),
|
||||
AuthorizationPolicies.MediaPermissionByResource);
|
||||
|
||||
if (!childrenAuthorized)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
ContentEditingOperationStatus result = await _mediaEditingService.SortByFieldAsync(
|
||||
id,
|
||||
requestModel.Field,
|
||||
requestModel.Direction,
|
||||
CurrentUserKey(_backOfficeSecurityAccessor));
|
||||
|
||||
return result == ContentEditingOperationStatus.Success
|
||||
? Ok()
|
||||
: ContentEditingOperationStatusResult(result);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.DataType;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.PropertyEditors;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
@@ -16,6 +19,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
private readonly IDataValueEditorFactory _dataValueEditorFactory;
|
||||
private readonly IConfigurationEditorJsonSerializer _configurationEditorJsonSerializer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DataTypePresentationFactory> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DataTypePresentationFactory"/> class, which is responsible for creating data type presentation models.
|
||||
@@ -25,18 +29,46 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
/// <param name="dataValueEditorFactory">Factory for creating data value editors.</param>
|
||||
/// <param name="configurationEditorJsonSerializer">Serializer for configuration editor JSON data.</param>
|
||||
/// <param name="timeProvider">Provides the current time for time-dependent operations.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public DataTypePresentationFactory(
|
||||
IDataTypeContainerService dataTypeContainerService,
|
||||
PropertyEditorCollection propertyEditorCollection,
|
||||
IDataValueEditorFactory dataValueEditorFactory,
|
||||
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer,
|
||||
TimeProvider timeProvider)
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DataTypePresentationFactory> logger)
|
||||
{
|
||||
_dataTypeContainerService = dataTypeContainerService;
|
||||
_propertyEditorCollection = propertyEditorCollection;
|
||||
_dataValueEditorFactory = dataValueEditorFactory;
|
||||
_configurationEditorJsonSerializer = configurationEditorJsonSerializer;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DataTypePresentationFactory"/> class, which is responsible for creating data type presentation models.
|
||||
/// </summary>
|
||||
/// <param name="dataTypeContainerService">Service used to manage data type containers.</param>
|
||||
/// <param name="propertyEditorCollection">A collection containing all available property editors.</param>
|
||||
/// <param name="dataValueEditorFactory">Factory for creating data value editors.</param>
|
||||
/// <param name="configurationEditorJsonSerializer">Serializer for configuration editor JSON data.</param>
|
||||
/// <param name="timeProvider">Provides the current time for time-dependent operations.</param>
|
||||
[Obsolete("Please use the constructor that takes all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public DataTypePresentationFactory(
|
||||
IDataTypeContainerService dataTypeContainerService,
|
||||
PropertyEditorCollection propertyEditorCollection,
|
||||
IDataValueEditorFactory dataValueEditorFactory,
|
||||
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer,
|
||||
TimeProvider timeProvider)
|
||||
: this(
|
||||
dataTypeContainerService,
|
||||
propertyEditorCollection,
|
||||
dataValueEditorFactory,
|
||||
configurationEditorJsonSerializer,
|
||||
timeProvider,
|
||||
StaticServiceProvider.Instance.GetRequiredService<ILogger<DataTypePresentationFactory>>())
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -72,7 +104,6 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
dataType.Key = requestModel.Id.Value;
|
||||
}
|
||||
|
||||
|
||||
return Attempt.SucceedWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.Success, dataType);
|
||||
}
|
||||
|
||||
@@ -82,7 +113,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
{
|
||||
try
|
||||
{
|
||||
var parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
|
||||
EntityContainer? parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
|
||||
|
||||
return parent is null
|
||||
? Attempt.FailWithStatus(DataTypeOperationStatus.ParentNotFound, 0)
|
||||
@@ -97,6 +128,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
return Attempt.SucceedWithStatus(DataTypeOperationStatus.Success, Constants.System.Root);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<Attempt<IDataType, DataTypeOperationStatus>> CreateAsync(UpdateDataTypeRequestModel requestModel, IDataType current)
|
||||
{
|
||||
if (!_propertyEditorCollection.TryGet(requestModel.EditorAlias, out IDataEditor? editor))
|
||||
@@ -104,7 +136,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
return Task.FromResult(Attempt.FailWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.PropertyEditorNotFound, new DataType(new VoidEditor(_dataValueEditorFactory), _configurationEditorJsonSerializer) ));
|
||||
}
|
||||
|
||||
IDataType dataType = (IDataType)current.DeepClone();
|
||||
var dataType = (IDataType)current.DeepClone();
|
||||
|
||||
IDictionary<string, object> configurationData = MapConfigurationData(requestModel, editor);
|
||||
dataType.Name = requestModel.Name;
|
||||
@@ -119,12 +151,26 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
|
||||
private ValueStorageType GetEditorValueStorageType(IDataEditor editor, IDictionary<string, object> configurationData)
|
||||
{
|
||||
var configurationObject = editor.GetConfigurationEditor()
|
||||
.ToConfigurationObject(configurationData, _configurationEditorJsonSerializer);
|
||||
|
||||
if (configurationObject is IConfigureValueType configureValueType)
|
||||
// Only editors whose configuration object implements IConfigureValueType derive their storage
|
||||
// type from the configuration. Building the typed configuration object can throw for editors
|
||||
// whose stored configuration doesn't cleanly deserialize into their configuration type; that
|
||||
// must not fail the save, so fall back to the value editor's value type in that case.
|
||||
try
|
||||
{
|
||||
return ValueTypes.ToStorageType(configureValueType.ValueType);
|
||||
if (editor.GetConfigurationEditor().ToConfigurationObject(configurationData, _configurationEditorJsonSerializer)
|
||||
is IConfigureValueType configureValueType)
|
||||
{
|
||||
return ValueTypes.ToStorageType(configureValueType.ValueType);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Configuration editors are third-party and can throw anything when the stored configuration
|
||||
// doesn't deserialize into their configuration type. Fall back to the value editor's value type
|
||||
// rather than failing the save, but log so the misconfiguration remains observable.
|
||||
_logger.LogError(
|
||||
"Could not build the configuration object for editor {EditorAlias} to determine its value storage type; falling back to the value editor's value type.",
|
||||
editor.Alias);
|
||||
}
|
||||
|
||||
var valueType = editor.GetValueEditor().ValueType;
|
||||
|
||||
+546
@@ -10888,6 +10888,150 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/document/{id}/sort-children": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Document"
|
||||
],
|
||||
"summary": "Sorts the children of a document by a field.",
|
||||
"description": "Sorts the children of the specified parent document by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so children that do not vary by it (or an unrecognised culture) fall back to the invariant name.",
|
||||
"operationId": "PutDocumentByIdSortChildren",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Backoffice-User": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/document/{id}/unpublish": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -11282,6 +11426,113 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/document/root/sort-children": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Document"
|
||||
],
|
||||
"summary": "Sorts the root-level documents by a field.",
|
||||
"description": "Sorts the root-level documents by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so documents that do not vary by it (or an unrecognised culture) fall back to the invariant name.",
|
||||
"operationId": "PutDocumentRootSortChildren",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Backoffice-User": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/document/sort": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -19158,6 +19409,150 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/media/{id}/sort-children": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Media"
|
||||
],
|
||||
"summary": "Sorts the children of a media item by a field.",
|
||||
"description": "Sorts the children of the specified parent media item by a system field in the given direction. Media items do not vary by culture, so any supplied culture is ignored.",
|
||||
"operationId": "PutMediaByIdSortChildren",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Backoffice-User": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/media/{id}/validate": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -19408,6 +19803,113 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/media/root/sort-children": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Media"
|
||||
],
|
||||
"summary": "Sorts the root-level media items by a field.",
|
||||
"description": "Sorts the root-level media items by a system field in the given direction. Media items do not vary by culture, so any supplied culture is ignored.",
|
||||
"operationId": "PutMediaRootSortChildren",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Backoffice-User": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/media/sort": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -39856,6 +40358,14 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ContentSortFieldModel": {
|
||||
"enum": [
|
||||
"Name",
|
||||
"CreateDate",
|
||||
"UpdateDate"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"CopyDataTypeRequestModel": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -50162,6 +50672,42 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SortDocumentChildrenByFieldRequestModel": {
|
||||
"required": [
|
||||
"direction",
|
||||
"field"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"field": {
|
||||
"$ref": "#/components/schemas/ContentSortFieldModel"
|
||||
},
|
||||
"direction": {
|
||||
"$ref": "#/components/schemas/DirectionModel"
|
||||
},
|
||||
"culture": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SortMediaChildrenByFieldRequestModel": {
|
||||
"required": [
|
||||
"direction",
|
||||
"field"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"field": {
|
||||
"$ref": "#/components/schemas/ContentSortFieldModel"
|
||||
},
|
||||
"direction": {
|
||||
"$ref": "#/components/schemas/DirectionModel"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SortingRequestModel": {
|
||||
"required": [
|
||||
"sorting"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Security.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Authorizes permissions on all direct children of a node.
|
||||
/// </summary>
|
||||
internal static class AllChildrenAuthorizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether the user is authorized for every direct child of the given parent (or the root).
|
||||
/// </summary>
|
||||
/// <param name="authorizationService">The authorization service.</param>
|
||||
/// <param name="entityService">The entity service used to resolve the children.</param>
|
||||
/// <param name="user">The current user.</param>
|
||||
/// <param name="parentKey">The parent key, or <c>null</c> to authorize the root-level children.</param>
|
||||
/// <param name="objectType">The object type of the children (and parent).</param>
|
||||
/// <param name="resourceFactory">Builds the permission resource to authorize a batch of child keys against.</param>
|
||||
/// <param name="policy">The authorization policy to apply.</param>
|
||||
/// <returns><c>true</c> if the user is authorized against all children; otherwise <c>false</c>.</returns>
|
||||
public static async Task<bool> IsAuthorizedForChildrenAsync(
|
||||
IAuthorizationService authorizationService,
|
||||
IEntityService entityService,
|
||||
ClaimsPrincipal user,
|
||||
Guid? parentKey,
|
||||
UmbracoObjectTypes objectType,
|
||||
Func<IEnumerable<Guid>, IPermissionResource> resourceFactory,
|
||||
string policy)
|
||||
{
|
||||
const int pageSize = 500;
|
||||
var page = 0;
|
||||
long total;
|
||||
do
|
||||
{
|
||||
Guid[] childKeys = entityService
|
||||
.GetPagedChildren(parentKey, [objectType], objectType, page * pageSize, pageSize, out total)
|
||||
.Select(child => child.Key)
|
||||
.ToArray();
|
||||
|
||||
if (childKeys.Length > 0)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await authorizationService.AuthorizeResourceAsync(
|
||||
user,
|
||||
resourceFactory(childKeys),
|
||||
policy);
|
||||
|
||||
if (authorizationResult.Succeeded is false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
while (page * pageSize < total);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Models.ContentEditing;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
|
||||
/// <summary>
|
||||
/// Base request model for sorting the children of a node by a system field.
|
||||
/// </summary>
|
||||
public abstract class SortChildrenByFieldRequestModelBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the system field to sort the children by.
|
||||
/// The create and update dates are node-level (not culture-specific).
|
||||
/// </summary>
|
||||
public required ContentSortField Field { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the direction to sort in.
|
||||
/// </summary>
|
||||
public required Direction Direction { get; init; }
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
using Umbraco.Cms.Core.Models.ContentEditing;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
|
||||
/// <summary>
|
||||
/// Request model for sorting the children of a document by a system field.
|
||||
/// </summary>
|
||||
public class SortDocumentChildrenByFieldRequestModel : SortChildrenByFieldRequestModelBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the culture whose variant name to sort by, or <c>null</c> to sort by the invariant name.
|
||||
/// Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a document that
|
||||
/// does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.
|
||||
/// </summary>
|
||||
public string? Culture { get; init; }
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
|
||||
/// <summary>
|
||||
/// Request model for sorting the children of a media item by a system field.
|
||||
/// Media items do not vary by culture, so no culture is accepted.
|
||||
/// </summary>
|
||||
public class SortMediaChildrenByFieldRequestModel : SortChildrenByFieldRequestModelBase
|
||||
{
|
||||
}
|
||||
@@ -15,8 +15,9 @@ SQLite-specific EF Core provider for Umbraco CMS. Contains SQLite migrations and
|
||||
This is a thin provider project that implements SQLite-specific functionality for the EF Core persistence layer:
|
||||
|
||||
1. **Migration Provider** - Executes SQLite-specific migrations
|
||||
2. **Migration Provider Setup** - Configures DbContext to use SQLite
|
||||
2. **Migration Provider Setup** - Configures DbContext to use SQLite (incl. transient-error retry)
|
||||
3. **Migrations** - SQLite-specific migration files for OpenIddict tables
|
||||
4. **Retrying Execution Strategy** - Retries transient SQLite lock errors on EF Core operations
|
||||
|
||||
### Folder Structure
|
||||
|
||||
@@ -30,7 +31,8 @@ Umbraco.Cms.Persistence.EFCore.Sqlite/
|
||||
│ └── UmbracoDbContextModelSnapshot.cs # Current model state
|
||||
├── EFCoreSqliteComposer.cs # DI registration
|
||||
├── SqliteMigrationProvider.cs # IMigrationProvider impl
|
||||
└── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
|
||||
├── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
|
||||
└── SqliteRetryingExecutionStrategy.cs # IExecutionStrategy for transient lock errors
|
||||
```
|
||||
|
||||
### Relationship with Parent Project
|
||||
@@ -65,7 +67,19 @@ Registers `IMigrationProvider` and `IMigrationProviderSetup` for SQLite.
|
||||
|
||||
### SqliteMigrationProviderSetup (line 11-14)
|
||||
|
||||
Configures `DbContextOptionsBuilder` with `UseSqlite` and migrations assembly.
|
||||
Configures `DbContextOptionsBuilder` with `UseSqlite`, the migrations assembly, and the
|
||||
`SqliteRetryingExecutionStrategy` (see below). Invoked from
|
||||
`UmbracoDbContext.ConfigureOptions` for every `UmbracoDbContext` instance, so all EF Core
|
||||
access to the Umbraco database (including OpenIddict's token store) inherits the retry.
|
||||
|
||||
### SqliteRetryingExecutionStrategy
|
||||
|
||||
Custom `Microsoft.EntityFrameworkCore.Storage.ExecutionStrategy` that retries on transient
|
||||
SQLite errors (`SQLITE_BUSY`, `SQLITE_LOCKED`) using `SqliteExceptionExtensions.IsBusyOrLocked`
|
||||
from the parent project. Defaults inherit `ExecutionStrategy.DefaultMaxRetryCount` (6) and
|
||||
`ExecutionStrategy.DefaultMaxDelay` (30s), giving a ~56-second retry budget — see the class's
|
||||
XML doc for the rationale and the unattended-upgrade escape hatch for very long migrations.
|
||||
Added to resolve issue #22939 (OpenIddict token reads failing during long migrations).
|
||||
|
||||
---
|
||||
|
||||
@@ -122,7 +136,8 @@ All tables prefixed with `umbraco`:
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SqliteMigrationProvider.cs` | Migration execution |
|
||||
| `SqliteMigrationProviderSetup.cs` | DbContext configuration |
|
||||
| `SqliteMigrationProviderSetup.cs` | DbContext configuration (UseSqlite + retry strategy) |
|
||||
| `SqliteRetryingExecutionStrategy.cs` | Retry on transient SQLite BUSY/LOCKED errors |
|
||||
| `EFCoreSqliteComposer.cs` | DI registration |
|
||||
| `Migrations/*.cs` | Migration files |
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Persistence.EFCore.Migrations;
|
||||
|
||||
namespace Umbraco.Cms.Persistence.EFCore.Sqlite;
|
||||
@@ -15,6 +14,15 @@ public class SqliteMigrationProviderSetup : IMigrationProviderSetup
|
||||
/// <inheritdoc />
|
||||
public void Setup(DbContextOptionsBuilder builder, string? connectionString)
|
||||
{
|
||||
builder.UseSqlite(connectionString, x => x.MigrationsAssembly(GetType().Assembly.FullName));
|
||||
builder.UseSqlite(connectionString, x =>
|
||||
{
|
||||
x.MigrationsAssembly(GetType().Assembly.FullName);
|
||||
|
||||
// Retry transient SQLite errors (BUSY / LOCKED). See SqliteRetryingExecutionStrategy
|
||||
// for the rationale — long-running migrations or schema-modifying operations can
|
||||
// briefly lock the database in a way that surfaces as a hard error to concurrent
|
||||
// EF Core readers (notably OpenIddict token validation). See issue #22939.
|
||||
x.ExecutionStrategy(deps => new SqliteRetryingExecutionStrategy(deps));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace Umbraco.Cms.Persistence.EFCore.Sqlite;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core execution strategy that retries on transient SQLite errors (BUSY / LOCKED).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// SQLite serialises writers at the database level, and schema-modifying statements briefly
|
||||
/// block readers — even in WAL mode. Without retries, concurrent EF Core reads (for example
|
||||
/// OpenIddict's token validation against <c>umbracoOpenIddictTokens</c>) surface those
|
||||
/// transient locks as <see cref="SqliteException"/> and fail the caller's request.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Microsoft does not ship a built-in execution strategy for SQLite (only the SQL Server
|
||||
/// equivalent), so we provide this one. It piggy-backs on <see cref="ExecutionStrategy"/>'s
|
||||
/// default exponential backoff and re-uses its inherited
|
||||
/// <see cref="ExecutionStrategy.DefaultMaxRetryCount"/> (6) and
|
||||
/// <see cref="ExecutionStrategy.DefaultMaxDelay"/> (30 seconds), which produce a delay
|
||||
/// schedule of roughly 0s, 1s, 3s, 7s, 15s, 30s — a ~56-second retry window.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// On top of those EF Core delays, <c>SQLITE_BUSY</c> (error 5) is also retried internally
|
||||
/// by Microsoft.Data.Sqlite for up to the connection's <c>Default Timeout</c> (30 seconds
|
||||
/// by default) per attempt. <c>SQLITE_LOCKED</c> (error 6) is not — it returns immediately,
|
||||
/// so EF Core's retry budget is the only buffer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class SqliteRetryingExecutionStrategy : ExecutionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SqliteRetryingExecutionStrategy"/> class
|
||||
/// with default retry settings inherited from <see cref="ExecutionStrategy"/>.
|
||||
/// </summary>
|
||||
/// <param name="dependencies">Parameter object containing service dependencies.</param>
|
||||
public SqliteRetryingExecutionStrategy(ExecutionStrategyDependencies dependencies)
|
||||
: this(dependencies, DefaultMaxRetryCount, DefaultMaxDelay)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SqliteRetryingExecutionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dependencies">Parameter object containing service dependencies.</param>
|
||||
/// <param name="maxRetryCount">The maximum number of retry attempts.</param>
|
||||
/// <param name="maxRetryDelay">The maximum delay between retries.</param>
|
||||
public SqliteRetryingExecutionStrategy(
|
||||
ExecutionStrategyDependencies dependencies,
|
||||
int maxRetryCount,
|
||||
TimeSpan maxRetryDelay)
|
||||
: base(dependencies, maxRetryCount, maxRetryDelay)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool ShouldRetryOn(Exception exception)
|
||||
{
|
||||
// EF Core wraps provider exceptions, so walk the inner-exception chain.
|
||||
for (Exception? current = exception; current is not null; current = current.InnerException)
|
||||
{
|
||||
if (current is SqliteException sqlite && sqlite.IsBusyOrLocked())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+1
-7
@@ -184,17 +184,11 @@ internal sealed class SqliteEFCoreDistributedLockingMechanism<T> : IDistributedL
|
||||
throw new ArgumentException($"LockObject with id={LockId} does not exist.");
|
||||
}
|
||||
}
|
||||
catch (SqliteException ex) when (IsBusyOrLocked(ex))
|
||||
catch (SqliteException ex) when (ex.IsBusyOrLocked())
|
||||
{
|
||||
throw new DistributedWriteLockTimeoutException(LockId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsBusyOrLocked(SqliteException ex) =>
|
||||
ex.SqliteErrorCode
|
||||
is raw.SQLITE_BUSY
|
||||
or raw.SQLITE_LOCKED
|
||||
or raw.SQLITE_LOCKED_SHAREDCACHE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using SQLitePCL;
|
||||
|
||||
namespace Umbraco.Cms.Persistence.EFCore;
|
||||
|
||||
/// <summary>
|
||||
/// SQLite-specific exception helpers for code running on the EF Core persistence stack.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A parallel helper exists at <c>Umbraco.Cms.Persistence.Sqlite.Services.SqliteExceptionExtensions</c>
|
||||
/// for the NPoco stack. Both stacks are independent (neither references the other) so the small
|
||||
/// duplication is intentional — keeps the layering clean.
|
||||
/// </remarks>
|
||||
public static class SqliteExceptionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines if the SQLite exception is a BUSY or LOCKED error.
|
||||
/// </summary>
|
||||
/// <param name="ex">The SQLite exception to check.</param>
|
||||
/// <returns><c>true</c> if the error is BUSY, LOCKED, or LOCKED_SHAREDCACHE; otherwise <c>false</c>.</returns>
|
||||
public static bool IsBusyOrLocked(this SqliteException ex) =>
|
||||
ex.SqliteErrorCode
|
||||
is raw.SQLITE_BUSY
|
||||
or raw.SQLITE_LOCKED
|
||||
or raw.SQLITE_LOCKED_SHAREDCACHE;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace Umbraco.Cms.Core.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Reports the approximate size of an in-memory cache, for diagnostics and observability.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implemented by in-memory caches whose footprint scales with the size of the content tree, so their
|
||||
/// retained entry count can be logged (e.g. by a periodic diagnostics job) during full-tree operations
|
||||
/// such as reindexing or crawling the published site.
|
||||
/// <para>
|
||||
/// The reported value is an approximate <em>entry count</em>, not a byte measurement: per-entry size
|
||||
/// varies widely, so the count is intended as a <em>trend</em> signal (a count that grows during a
|
||||
/// tree-walk and never falls indicates unbounded retention) and for <em>attribution</em> (which cache is
|
||||
/// largest when the process heap grows), rather than as an absolute memory figure. Absolute bytes are
|
||||
/// obtained from process-level totals (managed heap / working set) and a GC dump. The count is read
|
||||
/// without locking.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IMemoryCacheSizeReporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a human-readable name identifying the cache in diagnostic output.
|
||||
/// </summary>
|
||||
string CacheName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the approximate number of entries currently retained in the cache.
|
||||
/// </summary>
|
||||
/// <returns>The approximate entry count.</returns>
|
||||
long GetApproximateCount();
|
||||
|
||||
/// <summary>
|
||||
/// Gets an approximate retained size of the cache in bytes, or <c>null</c> when the cache cannot be
|
||||
/// cheaply sized.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Where provided, this is a coarse estimate (underlying content / structural size, not a precise
|
||||
/// managed-heap measurement) for the same trend/attribution purpose as the entry count. Absolute bytes
|
||||
/// come from a GC dump.
|
||||
/// </remarks>
|
||||
/// <returns>The approximate size in bytes, or <c>null</c> if not available.</returns>
|
||||
long? GetApproximateBytes() => null;
|
||||
}
|
||||
+10
-1
@@ -23,5 +23,14 @@ public sealed class LanguageDeletedDistributedCacheNotificationHandler : Deleted
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Handle(IEnumerable<ILanguage> entities, IDictionary<string, object?> state)
|
||||
=> _distributedCache.RemoveLanguageCache(entities);
|
||||
{
|
||||
_distributedCache.RemoveLanguageCache(entities);
|
||||
|
||||
// User groups cache their allowed language ids, so a deleted language must be evicted from
|
||||
// them too - otherwise a stale, now-missing id lingers on the cached user group and breaks
|
||||
// reads that resolve those ids. This is a deliberately coarse refresh of the entire user group
|
||||
// and user caches (RefreshAll also clears IUser): we can't know which groups reference the
|
||||
// language without a query, and language deletion is rare enough that a full refresh is fine.
|
||||
_distributedCache.RefreshAllUserGroupCache();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,8 +368,17 @@ public class ObjectCacheAppCache : IAppPolicyCache, IDisposable
|
||||
}
|
||||
|
||||
// Ensure key is removed from set when evicted from cache
|
||||
return options.RegisterPostEvictionCallback((key, _, _, _) =>
|
||||
return options.RegisterPostEvictionCallback((key, _, reason, _) =>
|
||||
{
|
||||
// Removed and Replaced evictions don't need pruning here: the Remove/Clear call sites already
|
||||
// prune the tracking set synchronously under the write lock, and a Replaced key still has a
|
||||
// live entry (the synchronous Set re-added it). Pruning here instead runs on a background
|
||||
// thread and races with that re-add, dropping a key whose entry is still cached. (#23064)
|
||||
if (reason is EvictionReason.Removed or EvictionReason.Replaced)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_locker.TryEnterWriteLock(_writeLockTimeout) is false)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace Umbraco.Cms.Core.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Estimates the total size of a large collection by sizing a bounded sample and extrapolating across the
|
||||
/// full count, so a per-tick size diagnostic does not pay an O(n) cost on very large caches.
|
||||
/// </summary>
|
||||
internal static class SampledSizeEstimator
|
||||
{
|
||||
/// <summary>
|
||||
/// Sizes up to <paramref name="maxSample" /> items from <paramref name="items" /> and scales the sampled
|
||||
/// average across <paramref name="count" />.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The item type.</typeparam>
|
||||
/// <param name="count">The total number of items in the collection.</param>
|
||||
/// <param name="items">The items to sample (enumerated lazily; only the first <paramref name="maxSample" /> are read).</param>
|
||||
/// <param name="sizeOf">Returns the approximate size, in bytes, of a single item.</param>
|
||||
/// <param name="maxSample">The maximum number of items to size before extrapolating.</param>
|
||||
/// <returns>The extrapolated approximate total size in bytes.</returns>
|
||||
public static long Estimate<T>(int count, IEnumerable<T> items, Func<T, long> sizeOf, int maxSample = 1000)
|
||||
{
|
||||
if (count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
long sampled = 0;
|
||||
long sampledBytes = 0;
|
||||
foreach (T item in items)
|
||||
{
|
||||
sampledBytes += sizeOf(item);
|
||||
if (++sampled >= maxSample)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return sampled == 0 ? 0 : count * (sampledBytes / sampled);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,11 @@ public class ContentSettings
|
||||
/// </summary>
|
||||
internal const bool StaticResolveUrlsFromTextString = false;
|
||||
|
||||
/// <summary>
|
||||
/// The default value for whether sorting children by a field fires per-item notifications.
|
||||
/// </summary>
|
||||
internal const bool StaticSortChildrenByFieldFiresNotifications = false;
|
||||
|
||||
/// <summary>
|
||||
/// The default preview badge markup template.
|
||||
/// </summary>
|
||||
@@ -110,6 +115,18 @@ public class ContentSettings
|
||||
[DefaultValue(StaticResolveUrlsFromTextString)]
|
||||
public bool ResolveUrlsFromTextString { get; set; } = StaticResolveUrlsFromTextString;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether sorting the children of a node by a field fires
|
||||
/// per-item save/sort notifications (and therefore webhooks).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Defaults to <c>false</c>: the children are reordered with a single set-based update and a branch
|
||||
/// cache refresh, without per-item notifications. Set to <c>true</c> to restore per-item notifications
|
||||
/// (and webhooks), accepting the additional performance cost on nodes with many children.
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticSortChildrenByFieldFiresNotifications)]
|
||||
public bool SortChildrenByFieldFiresNotifications { get; set; } = StaticSortChildrenByFieldFiresNotifications;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for the collection of error pages.
|
||||
/// </summary>
|
||||
|
||||
@@ -30,6 +30,17 @@ public class DatabaseServerMessengerSettings
|
||||
/// </summary>
|
||||
internal const string StaticTimeBetweenPruneOperations = "00:01:00"; // TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// The default timeout for a single synchronization operation.
|
||||
/// </summary>
|
||||
internal const string StaticSyncTimeout = "00:01:00"; // TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default timeout for a single synchronization operation, for use as a fallback when an invalid
|
||||
/// <see cref="SyncTimeout" /> is configured.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultSyncTimeout = TimeSpan.Parse(StaticSyncTimeout);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for the maximum number of instructions that can be processed at startup; otherwise the server
|
||||
/// cold-boots (rebuilds its caches).
|
||||
@@ -55,4 +66,13 @@ public class DatabaseServerMessengerSettings
|
||||
/// </summary>
|
||||
[DefaultValue(StaticTimeBetweenPruneOperations)]
|
||||
public TimeSpan TimeBetweenPruneOperations { get; set; } = TimeSpan.Parse(StaticTimeBetweenPruneOperations);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum time to wait for a single synchronization operation to complete before it is
|
||||
/// considered stalled (for example, blocked on a hung database connection) and abandoned, so the recurring
|
||||
/// job keeps running rather than stopping permanently. This bounds how long the job waits on a single sync,
|
||||
/// not how long a stalled connection itself takes to recover (which is governed by the database timeouts).
|
||||
/// </summary>
|
||||
[DefaultValue(StaticSyncTimeout)]
|
||||
public TimeSpan SyncTimeout { get; set; } = DefaultSyncTimeout;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,17 @@ public class DatabaseServerRegistrarSettings
|
||||
/// </summary>
|
||||
internal const string StaticStaleServerTimeout = "00:02:00";
|
||||
|
||||
/// <summary>
|
||||
/// The default timeout for a single server touch operation.
|
||||
/// </summary>
|
||||
internal const string StaticTouchTimeout = "00:01:00"; // TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default timeout for a single server touch operation, for use as a fallback when an invalid
|
||||
/// <see cref="TouchTimeout" /> is configured.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultTouchTimeout = TimeSpan.Parse(StaticTouchTimeout);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for the amount of time to wait between calls to the database on the background thread.
|
||||
/// </summary>
|
||||
@@ -31,4 +42,13 @@ public class DatabaseServerRegistrarSettings
|
||||
/// </summary>
|
||||
[DefaultValue(StaticStaleServerTimeout)]
|
||||
public TimeSpan StaleServerTimeout { get; set; } = TimeSpan.Parse(StaticStaleServerTimeout);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum time to wait for a single server touch operation to complete before it is
|
||||
/// considered stalled (for example, blocked on a hung database connection) and abandoned, so the recurring
|
||||
/// job keeps running rather than stopping permanently. This bounds how long the job waits on a single touch,
|
||||
/// not how long a stalled connection itself takes to recover (which is governed by the database timeouts).
|
||||
/// </summary>
|
||||
[DefaultValue(StaticTouchTimeout)]
|
||||
public TimeSpan TouchTimeout { get; set; } = DefaultTouchTimeout;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ public class LoggingSettings
|
||||
/// </summary>
|
||||
internal const string StaticFileNameFormatArguments = "MachineName";
|
||||
|
||||
/// <summary>
|
||||
/// The default mode for enriching log events with a session identifier.
|
||||
/// </summary>
|
||||
internal const SessionIdLoggingMode StaticSessionIdLogging = SessionIdLoggingMode.SessionId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for the maximum age of a log file.
|
||||
/// </summary>
|
||||
@@ -70,4 +75,16 @@ public class LoggingSettings
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticFileNameFormatArguments)]
|
||||
public string FileNameFormatArguments { get; set; } = StaticFileNameFormatArguments;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value determining how log events are enriched with a session identifier.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Defaults to <see cref="SessionIdLoggingMode.SessionId" /> for backward compatibility. Set to
|
||||
/// <see cref="SessionIdLoggingMode.CookieHash" /> or <see cref="SessionIdLoggingMode.None" /> to avoid the
|
||||
/// blocking session-store load that resolving the actual session id incurs per request when the session is
|
||||
/// backed by an <c>IDistributedCache</c>.
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticSessionIdLogging)]
|
||||
public SessionIdLoggingMode SessionIdLogging { get; set; } = StaticSessionIdLogging;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace Umbraco.Cms.Core.Configuration.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Settings for scheduled publishing.
|
||||
/// </summary>
|
||||
[UmbracoOptions(Constants.Configuration.ConfigScheduledPublishing)]
|
||||
public class ScheduledPublishingSettings
|
||||
{
|
||||
private const string StaticPeriod = "00:01:00";
|
||||
private const bool StaticAlignToClock = false; // TODO (V19): Switch this to true.
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for how often scheduled publishing runs.
|
||||
/// </summary>
|
||||
[DefaultValue(StaticPeriod)]
|
||||
public TimeSpan Period { get; set; } = TimeSpan.Parse(StaticPeriod);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether scheduled publishing runs are aligned to clock boundaries
|
||||
/// derived from <see cref="Period" /> (for example, on the minute, or every N seconds), rather than drifting
|
||||
/// based on when the previous run completed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When enabled, <see cref="Period" /> must be a whole number of seconds that divides evenly into one hour
|
||||
/// (for example 10, 12, 15, 20, 30 or 60 seconds) so that boundaries land on consistent clock times.
|
||||
/// Boundaries are anchored to <strong>UTC</strong>, not the server's local time zone; for sub-minute and
|
||||
/// whole-minute periods this is indistinguishable from local time at the second level.
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticAlignToClock)]
|
||||
public bool AlignToClock { get; set; } = StaticAlignToClock;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
namespace Umbraco.Cms.Core.Configuration.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Determines how request logging enriches log events with a session identifier.
|
||||
/// </summary>
|
||||
public enum SessionIdLoggingMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Do not enrich log events with a session identifier.
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Enrich log events with the actual ASP.NET Core session id. This is the default and matches the
|
||||
/// historical behaviour, but reading the session id forces the session to be loaded from its store, which
|
||||
/// is a blocking round-trip per request when the session is backed by an <c>IDistributedCache</c>.
|
||||
/// </summary>
|
||||
SessionId,
|
||||
|
||||
/// <summary>
|
||||
/// Enrich log events with a one-way hash of the session cookie value. This provides the same per-session
|
||||
/// correlation as <see cref="SessionId" /> without loading the session from its store, so it never incurs
|
||||
/// a distributed-cache round-trip.
|
||||
/// </summary>
|
||||
CookieHash,
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Umbraco.Cms.Core.Configuration.Models.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for configuration represented as <see cref="ScheduledPublishingSettings" />.
|
||||
/// </summary>
|
||||
public class ScheduledPublishingSettingsValidator : ConfigurationValidatorBase, IValidateOptions<ScheduledPublishingSettings>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public ValidateOptionsResult Validate(string? name, ScheduledPublishingSettings options)
|
||||
{
|
||||
if (options.Period <= TimeSpan.Zero)
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"Configuration entry {Constants.Configuration.ConfigScheduledPublishing}:Period must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.AlignToClock && IsCleanDivisorOfAnHour(options.Period) == false)
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"Configuration entry {Constants.Configuration.ConfigScheduledPublishing}:Period must be a whole number of seconds that divides evenly into one hour (3600 seconds) when {Constants.Configuration.ConfigScheduledPublishing}:AlignToClock is enabled, e.g. 10, 12, 15, 20, 30 or 60 seconds.");
|
||||
}
|
||||
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
|
||||
private static bool IsCleanDivisorOfAnHour(TimeSpan period)
|
||||
{
|
||||
var totalSeconds = period.TotalSeconds;
|
||||
|
||||
// Must be a positive, whole number of seconds (no sub-second component).
|
||||
if (totalSeconds <= 0 || totalSeconds != Math.Floor(totalSeconds))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return 3600 % (long)totalSeconds == 0;
|
||||
}
|
||||
}
|
||||
@@ -291,6 +291,11 @@ public static partial class Constants
|
||||
/// </summary>
|
||||
public const string ConfigDistributedJobs = ConfigPrefix + "DistributedJobs";
|
||||
|
||||
/// <summary>
|
||||
/// The configuration key for scheduled publishing settings.
|
||||
/// </summary>
|
||||
public const string ConfigScheduledPublishing = ConfigPrefix + "ScheduledPublishing";
|
||||
|
||||
/// <summary>
|
||||
/// The configuration key for backoffice token cookie settings.
|
||||
/// </summary>
|
||||
|
||||
@@ -57,6 +57,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
builder.Services.AddSingleton<IValidateOptions<RequestHandlerSettings>, RequestHandlerSettingsValidator>();
|
||||
builder.Services.AddSingleton<IValidateOptions<UnattendedSettings>, UnattendedSettingsValidator>();
|
||||
builder.Services.AddSingleton<IValidateOptions<SecuritySettings>, SecuritySettingsValidator>();
|
||||
builder.Services.AddSingleton<IValidateOptions<ScheduledPublishingSettings>, ScheduledPublishingSettingsValidator>();
|
||||
|
||||
// Register configuration sections.
|
||||
// TODO (V18): Remove the registrations of UserPasswordConfigurationSettings and MemberPasswordConfigurationSettings.
|
||||
@@ -102,6 +103,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
.AddUmbracoOptions<CacheSettings>()
|
||||
.AddUmbracoOptions<SystemDateMigrationSettings>()
|
||||
.AddUmbracoOptions<DistributedJobSettings>()
|
||||
.AddUmbracoOptions<ScheduledPublishingSettings>(options => options.ValidateOnStart())
|
||||
.AddUmbracoOptions<BackOfficeTokenCookieSettings>()
|
||||
.AddUmbracoOptions<WebsiteSettings>()
|
||||
.AddUmbracoOptions<SignalRSettings>();
|
||||
|
||||
@@ -377,9 +377,11 @@ namespace Umbraco.Cms.Core.DependencyInjection
|
||||
Services.AddUnique<DocumentNavigationService, DocumentNavigationService>();
|
||||
Services.AddUnique<IDocumentNavigationQueryService>(x => x.GetRequiredService<DocumentNavigationService>());
|
||||
Services.AddUnique<IDocumentNavigationManagementService>(x => x.GetRequiredService<DocumentNavigationService>());
|
||||
Services.AddSingleton<IMemoryCacheSizeReporter>(x => x.GetRequiredService<DocumentNavigationService>());
|
||||
Services.AddUnique<MediaNavigationService, MediaNavigationService>();
|
||||
Services.AddUnique<IMediaNavigationQueryService>(x => x.GetRequiredService<MediaNavigationService>());
|
||||
Services.AddUnique<IMediaNavigationManagementService>(x => x.GetRequiredService<MediaNavigationService>());
|
||||
Services.AddSingleton<IMemoryCacheSizeReporter>(x => x.GetRequiredService<MediaNavigationService>());
|
||||
|
||||
Services.AddUnique<PublishStatusService, PublishStatusService>();
|
||||
Services.AddUnique<IPublishStatusManagementService>(x => x.GetRequiredService<PublishStatusService>());
|
||||
@@ -453,7 +455,9 @@ namespace Umbraco.Cms.Core.DependencyInjection
|
||||
Services.AddUnique<IElementSwitchValidator, ElementSwitchValidator>();
|
||||
|
||||
// Routing
|
||||
Services.AddUnique<IDocumentUrlService, DocumentUrlService>();
|
||||
Services.AddUnique<DocumentUrlService, DocumentUrlService>();
|
||||
Services.AddUnique<IDocumentUrlService>(x => x.GetRequiredService<DocumentUrlService>());
|
||||
Services.AddSingleton<IMemoryCacheSizeReporter>(x => x.GetRequiredService<DocumentUrlService>());
|
||||
Services.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, DocumentUrlServiceInitializerNotificationHandler>();
|
||||
Services.AddUnique<IDocumentUrlAliasService, DocumentUrlAliasService>();
|
||||
Services.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, DocumentUrlAliasServiceInitializerNotificationHandler>();
|
||||
|
||||
@@ -405,7 +405,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
|
||||
<!-- The following key get these tokens passed in:
|
||||
0: Comma delimitted list of headers found
|
||||
-->
|
||||
|
||||
@@ -454,7 +454,8 @@
|
||||
<key alias="httpsCheckConfigurationRectifyNotPossible">Mae gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i 'false' yn eich ffeil appSettings.json. Unwaith y byddwch yn cyrchu'r wefan hon gan ddefnyddio'r cynllun HTTPS, dylid gosod hwnnw i 'true'.</key>
|
||||
<key alias="httpsCheckConfigurationCheckResult">Mae'r gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i '%0%' yn eich ffeil appSettings.json, mae eich cwcis %1% wedi'u marcio'n ddiogel.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue">Mae gosodiad yr ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod i <strong>%0%</strong>.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod, felly bydd URL y rhaglen yn cael ei ganfod yn awtomatig o geisiadau sy'n dod i mewn. Argymhellir ei osod yn benodol.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod ac mae canfod URL y rhaglen yn awtomatig wedi'i analluogi (mae 'Umbraco:CMS:WebRouting:ApplicationUrlDetection' yn 'None'). Ni fydd nodweddion sydd angen URL absoliwt, fel e-byst ailosod cyfrinair a gwahoddiadau, yn gweithio. Gosodwch URL y rhaglen yn benodol, neu galluogwch ganfod yn awtomatig.]]></key>
|
||||
<key alias="smtpMailSettingsNotFound">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp'.</key>
|
||||
<key alias="smtpMailSettingsHostNotConfigured">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp:Host'.</key>
|
||||
<key alias="smtpMailSettingsConnectionFail">Methwyd cyrraedd y gweinydd SMTP a ffurfweddwyd gyda gwesteiwr '%0%' a phorth '%1%'. Gwiriwch i sicrhau bod y gosodiadau SMTP yn y ffurfweddiad 'Umbraco:CMS:Global:Smtp' yn gywir.</key>
|
||||
|
||||
@@ -463,7 +463,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
|
||||
<!-- The following key get these tokens passed in:
|
||||
0: Comma delimitted list of headers found
|
||||
-->
|
||||
|
||||
@@ -452,7 +452,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
|
||||
<key alias="clickJackingCheckHeaderFound">
|
||||
<![CDATA[The header or meta-tag <strong>X-Frame-Options</strong> used to control whether a site can be IFRAMEd by another was found.]]></key>
|
||||
<key alias="clickJackingCheckHeaderNotFound">
|
||||
|
||||
@@ -403,7 +403,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
|
||||
<!-- The following key get these tokens passed in:
|
||||
0: Comma delimitted list of headers found
|
||||
-->
|
||||
|
||||
@@ -730,6 +730,10 @@ public static partial class StringExtensions
|
||||
/// </summary>
|
||||
/// <param name="fileName">The file name to convert.</param>
|
||||
/// <returns>A friendly name with the extension stripped, underscores and dashes converted to spaces, and title case applied.</returns>
|
||||
/// <remarks>
|
||||
/// Mirrored client-side in <c>src/Umbraco.Web.UI.Client/src/packages/media/media/utils/to-friendly-name.function.ts</c>;
|
||||
/// keep the two implementations in sync.
|
||||
/// </remarks>
|
||||
public static string ToFriendlyName(this string fileName)
|
||||
{
|
||||
// strip the file extension
|
||||
|
||||
@@ -44,28 +44,34 @@ public class UmbracoApplicationUrlCheck : HealthCheck
|
||||
|
||||
private HealthCheckStatus CheckUmbracoApplicationUrl()
|
||||
{
|
||||
var url = _webRoutingSettings.CurrentValue.UmbracoApplicationUrl;
|
||||
WebRoutingSettings settings = _webRoutingSettings.CurrentValue;
|
||||
var url = settings.UmbracoApplicationUrl;
|
||||
|
||||
string resultMessage;
|
||||
StatusResultType resultType;
|
||||
var success = false;
|
||||
|
||||
if (url.IsNullOrWhiteSpace())
|
||||
if (url.IsNullOrWhiteSpace() is false)
|
||||
{
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
|
||||
resultType = StatusResultType.Warning;
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", [url]);
|
||||
resultType = StatusResultType.Success;
|
||||
}
|
||||
else if (settings.ApplicationUrlDetection == ApplicationUrlDetection.None)
|
||||
{
|
||||
// No explicit URL and auto-detection is disabled, so the application URL can never be established.
|
||||
// Features that require an absolute URL (e.g. password reset and invitation emails) will not work.
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultError");
|
||||
resultType = StatusResultType.Error;
|
||||
}
|
||||
else
|
||||
{
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", new[] { url });
|
||||
resultType = StatusResultType.Success;
|
||||
success = true;
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
|
||||
resultType = StatusResultType.Warning;
|
||||
}
|
||||
|
||||
return new HealthCheckStatus(resultMessage)
|
||||
{
|
||||
ResultType = resultType,
|
||||
ReadMoreLink = success
|
||||
ReadMoreLink = resultType == StatusResultType.Success
|
||||
? null
|
||||
: Constants.HealthChecks.DocumentationLinks.Security.UmbracoApplicationUrlCheck,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Umbraco.Cms.Core.Models.ContentEditing;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a system field that a node's children can be sorted by.
|
||||
/// </summary>
|
||||
public enum ContentSortField
|
||||
{
|
||||
/// <summary>
|
||||
/// Sort by the node's name.
|
||||
/// </summary>
|
||||
Name,
|
||||
|
||||
/// <summary>
|
||||
/// Sort by the date the node was created.
|
||||
/// </summary>
|
||||
CreateDate,
|
||||
|
||||
/// <summary>
|
||||
/// Sort by the date the node was last updated.
|
||||
/// </summary>
|
||||
UpdateDate,
|
||||
}
|
||||
@@ -16,6 +16,19 @@ public interface IContentRepository<in TId, TEntity> : IReadWriteQueryRepository
|
||||
/// </summary>
|
||||
int RecycleBinId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Updates the sort order of the specified nodes so that each node's sort order matches its
|
||||
/// position in the supplied (already ordered) collection, in a single set-based update.
|
||||
/// </summary>
|
||||
/// <param name="orderedNodeIds">The node identifiers in their desired order.</param>
|
||||
/// <remarks>
|
||||
/// This persists the sort order directly and does not load the entities or fire any notifications;
|
||||
/// callers are responsible for any required cache refresh and auditing.
|
||||
/// </remarks>
|
||||
// TODO (V19): Remove the default implementation.
|
||||
void UpdateSortOrder(IReadOnlyList<int> orderedNodeIds)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <summary>
|
||||
/// Gets versions.
|
||||
/// </summary>
|
||||
|
||||
@@ -332,6 +332,15 @@ internal sealed class ContentEditingService
|
||||
Guid userKey)
|
||||
=> await HandleSortAsync(parentKey, sortingModels, userKey);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ContentEditingOperationStatus> SortByFieldAsync(
|
||||
Guid? parentKey,
|
||||
ContentSortField field,
|
||||
Direction direction,
|
||||
string? culture,
|
||||
Guid userKey)
|
||||
=> await HandleSortByFieldAsync(parentKey, field, direction, culture, userKey);
|
||||
|
||||
private async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCulturesAndPropertiesAsync(
|
||||
ContentEditingModelBase contentEditingModelBase,
|
||||
Guid contentTypeKey,
|
||||
@@ -384,8 +393,8 @@ internal sealed class ContentEditingService
|
||||
protected override OperationResult? Delete(IContent content, int userId) => ContentService.Delete(content, userId);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IEnumerable<IContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
|
||||
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: null);
|
||||
protected override IEnumerable<IContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total)
|
||||
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: ordering);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContentEditingOperationStatus Sort(IEnumerable<IContent> items, int userId)
|
||||
@@ -394,6 +403,13 @@ internal sealed class ContentEditingService
|
||||
return OperationResultToOperationStatus(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId)
|
||||
{
|
||||
OperationResult result = ContentService.SortChildren(parentId, orderedChildIds, userId);
|
||||
return OperationResultToOperationStatus(result);
|
||||
}
|
||||
|
||||
private async Task<ContentEditingOperationStatus> Save(IContent content, Guid userKey)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -458,6 +458,10 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
|
||||
{
|
||||
// these are the only result states currently expected from the invoked IContentService operations
|
||||
OperationResultType.Success => ContentEditingOperationStatus.Success,
|
||||
|
||||
// a no-op (e.g. sorting children when nothing needs reordering) is a successful outcome, not an error
|
||||
OperationResultType.NoOperation => ContentEditingOperationStatus.Success,
|
||||
|
||||
OperationResultType.FailedCancelledByEvent => ContentEditingOperationStatus.CancelledByNotification,
|
||||
OperationResultType.FailedCannot => ContentEditingOperationStatus.CannotDeleteWhenReferenced,
|
||||
|
||||
|
||||
@@ -86,9 +86,10 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
|
||||
/// <param name="parentId">The parent identifier.</param>
|
||||
/// <param name="pageIndex">The zero-based page index.</param>
|
||||
/// <param name="pageSize">The page size.</param>
|
||||
/// <param name="ordering">The ordering to apply, or <c>null</c> to use the default (sort order).</param>
|
||||
/// <param name="total">The total number of children.</param>
|
||||
/// <returns>The paged children.</returns>
|
||||
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total);
|
||||
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total);
|
||||
|
||||
/// <summary>
|
||||
/// Handles the sorting operation asynchronously.
|
||||
@@ -111,16 +112,7 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
|
||||
return ContentEditingOperationStatus.NotFound;
|
||||
}
|
||||
|
||||
const int pageSize = 500;
|
||||
var pageNumber = 0;
|
||||
IEnumerable<TContent> page = GetPagedChildren(contentId.Value, pageNumber++, pageSize, out var total);
|
||||
var children = new List<TContent>((int)total);
|
||||
children.AddRange(page);
|
||||
while (pageNumber * pageSize < total)
|
||||
{
|
||||
page = GetPagedChildren(contentId.Value, pageNumber++, pageSize, out _);
|
||||
children.AddRange(page);
|
||||
}
|
||||
List<TContent> children = LoadAllChildren(contentId.Value, ordering: null);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -138,4 +130,102 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
|
||||
return ContentEditingOperationStatus.SortingInvalid;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles sorting a parent's children by a system field asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="parentKey">The optional parent key.</param>
|
||||
/// <param name="field">The system field to sort the children by.</param>
|
||||
/// <param name="direction">The direction to sort in.</param>
|
||||
/// <param name="culture">The culture whose variant name to sort by, or <c>null</c> to sort by the invariant name. Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a child that does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.</param>
|
||||
/// <param name="userKey">The user key performing the operation.</param>
|
||||
/// <returns>The operation status.</returns>
|
||||
protected async Task<ContentEditingOperationStatus> HandleSortByFieldAsync(
|
||||
Guid? parentKey,
|
||||
ContentSortField field,
|
||||
Direction direction,
|
||||
string? culture,
|
||||
Guid userKey)
|
||||
{
|
||||
var contentId = parentKey.HasValue
|
||||
? ContentService.GetById(parentKey.Value)?.Id
|
||||
: Constants.System.Root;
|
||||
|
||||
if (contentId.HasValue is false)
|
||||
{
|
||||
return ContentEditingOperationStatus.NotFound;
|
||||
}
|
||||
|
||||
Ordering ordering = BuildOrdering(field, direction, culture);
|
||||
|
||||
// The database does the ordering (matching the list view and the order shown in the sort UI).
|
||||
if (ContentSettings.SortChildrenByFieldFiresNotifications)
|
||||
{
|
||||
// Opt-in path: load the children and persist via the standard sort, firing per-item
|
||||
// save/sort notifications (and therefore webhooks), at the cost of loading every child.
|
||||
List<TContent> orderedChildren = LoadAllChildren(contentId.Value, ordering);
|
||||
if (orderedChildren.Count == 0)
|
||||
{
|
||||
return ContentEditingOperationStatus.Success;
|
||||
}
|
||||
|
||||
return Sort(orderedChildren, await GetUserIdAsync(userKey));
|
||||
}
|
||||
|
||||
// Default path: persist the resulting order with a single set-based update and a branch cache
|
||||
// refresh, without loading every child or firing per-item notifications.
|
||||
List<int> orderedChildIds = LoadOrderedChildIds(contentId.Value, ordering);
|
||||
if (orderedChildIds.Count == 0)
|
||||
{
|
||||
// Nothing to sort - the order is trivially correct.
|
||||
return ContentEditingOperationStatus.Success;
|
||||
}
|
||||
|
||||
return SortChildrenInBulk(contentId.Value, orderedChildIds, await GetUserIdAsync(userKey));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists the supplied (already ordered) child identifiers as the new sort order, without loading
|
||||
/// the children or firing per-item notifications.
|
||||
/// </summary>
|
||||
/// <param name="parentId">The parent identifier, or the root identifier for root-level sorting.</param>
|
||||
/// <param name="orderedChildIds">The child identifiers in their desired order.</param>
|
||||
/// <param name="userId">The user performing the operation.</param>
|
||||
/// <returns>The operation status.</returns>
|
||||
protected abstract ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId);
|
||||
|
||||
private List<int> LoadOrderedChildIds(int contentId, Ordering ordering)
|
||||
=> LoadAllChildren(contentId, ordering, child => child.Id);
|
||||
|
||||
private List<TContent> LoadAllChildren(int contentId, Ordering? ordering)
|
||||
=> LoadAllChildren(contentId, ordering, child => child);
|
||||
|
||||
// Pages through all children, projecting each page with the selector so callers that only need a
|
||||
// lightweight value (e.g. the id) don't retain every loaded child.
|
||||
private List<TResult> LoadAllChildren<TResult>(int contentId, Ordering? ordering, Func<TContent, TResult> selector)
|
||||
{
|
||||
const int pageSize = 500;
|
||||
var pageNumber = 0;
|
||||
IEnumerable<TContent> page = GetPagedChildren(contentId, pageNumber++, pageSize, ordering, out var total);
|
||||
var results = new List<TResult>((int)total);
|
||||
results.AddRange(page.Select(selector));
|
||||
while (pageNumber * pageSize < total)
|
||||
{
|
||||
page = GetPagedChildren(contentId, pageNumber++, pageSize, ordering, out _);
|
||||
results.AddRange(page.Select(selector));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static Ordering BuildOrdering(ContentSortField field, Direction direction, string? culture)
|
||||
=> field switch
|
||||
{
|
||||
// Name is variant - the culture selects the variant name to order by (invariant content and media
|
||||
// ignore it). Create and update dates are node-level, so the culture does not apply.
|
||||
ContentSortField.Name => Ordering.By("name", direction, culture),
|
||||
ContentSortField.CreateDate => Ordering.By("createDate", direction),
|
||||
ContentSortField.UpdateDate => Ordering.By("updateDate", direction),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort field."),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3137,7 +3137,13 @@ public class ContentService : RepositoryService, IContentService
|
||||
{
|
||||
scope.WriteLock(Constants.Locks.ContentTree);
|
||||
|
||||
OperationResult ret = Sort(scope, itemsA, userId, evtMsgs);
|
||||
// Reload within the lock so sorting operates on fully-loaded entities. Callers may pass
|
||||
// partially-loaded content (e.g. loaded with loadTemplates: false or without property data),
|
||||
// and saving those directly would wipe the template and property data (#23120).
|
||||
// GetByIds returns items in the requested order, preserving the caller's ordering that drives the sort.
|
||||
IContent[] reloaded = GetByIds(itemsA.Select(x => x.Id).ToArray()).ToArray();
|
||||
|
||||
OperationResult ret = Sort(scope, reloaded, userId, evtMsgs);
|
||||
scope.Complete();
|
||||
return ret;
|
||||
}
|
||||
@@ -3175,6 +3181,43 @@ public class ContentService : RepositoryService, IContentService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
|
||||
{
|
||||
EventMessages evtMsgs = EventMessagesFactory.Get();
|
||||
if (orderedChildIds.Count == 0)
|
||||
{
|
||||
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
|
||||
}
|
||||
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
scope.WriteLock(Constants.Locks.ContentTree);
|
||||
|
||||
_documentRepository.UpdateSortOrder(orderedChildIds);
|
||||
|
||||
// Sort order lives in umbracoNode; neither the published cache nor the content repository cache keeps
|
||||
// a separate serialized copy of it, so refreshing the affected branch (which invalidates both and has
|
||||
// them reload from umbracoNode) is enough to pick up the new order without re-saving each child.
|
||||
if (parentId == Constants.System.Root)
|
||||
{
|
||||
IContent[] roots = GetByIds(orderedChildIds).ToArray();
|
||||
scope.Notifications.Publish(new ContentTreeChangeNotification(roots, TreeChangeTypes.RefreshNode, evtMsgs));
|
||||
}
|
||||
else
|
||||
{
|
||||
IContent? parent = GetById(parentId);
|
||||
if (parent is not null)
|
||||
{
|
||||
scope.Notifications.Publish(new ContentTreeChangeNotification(parent, TreeChangeTypes.RefreshBranch, evtMsgs));
|
||||
}
|
||||
}
|
||||
|
||||
Audit(AuditType.Sort, userId, parentId);
|
||||
|
||||
scope.Complete();
|
||||
return OperationResult.Succeed(evtMsgs);
|
||||
}
|
||||
|
||||
private OperationResult Sort(ICoreScope scope, IContent[] itemsA, int userId, EventMessages eventMessages)
|
||||
{
|
||||
var sortingNotification = new ContentSortingNotification(itemsA, eventMessages);
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Runtime.CompilerServices;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
@@ -22,7 +23,7 @@ namespace Umbraco.Cms.Core.Services;
|
||||
/// <summary>
|
||||
/// Implements <see href="IDocumentUrlService" /> operations for handling document URLs.
|
||||
/// </summary>
|
||||
public class DocumentUrlService : IDocumentUrlService
|
||||
public class DocumentUrlService : IDocumentUrlService, IMemoryCacheSizeReporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents the key used to identify the URL generation rebuild operation.
|
||||
@@ -53,6 +54,33 @@ public class DocumentUrlService : IDocumentUrlService
|
||||
/// <inheritdoc/>
|
||||
public bool IsInitialized { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CacheName => "Document URL segments";
|
||||
|
||||
/// <inheritdoc />
|
||||
public long GetApproximateCount() => _documentUrlCache.Count;
|
||||
|
||||
/// <inheritdoc />
|
||||
// The dictionary is enumerated directly (not via .Values, which snapshot-copies the whole collection).
|
||||
public long? GetApproximateBytes()
|
||||
=> SampledSizeEstimator.Estimate(_documentUrlCache.Count, _documentUrlCache, static kvp => EstimateUrlSegmentCacheBytes(kvp.Value));
|
||||
|
||||
private static long EstimateUrlSegmentCacheBytes(UrlSegmentCache entry)
|
||||
{
|
||||
// UrlCacheKey (struct: Guid + nullable int + bool) + dictionary bucket + the cache object header.
|
||||
long bytes = 64 + (entry.PrimarySegment.Length * 2L);
|
||||
if (entry.AlternateSegments is not null)
|
||||
{
|
||||
bytes += 24; // array header
|
||||
foreach (var segment in entry.AlternateSegments)
|
||||
{
|
||||
bytes += 16 + ((segment?.Length ?? 0) * 2L);
|
||||
}
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Struct-based cache key for memory-efficient URL segment caching.
|
||||
/// Uses LanguageId instead of culture string to reduce memory footprint.
|
||||
|
||||
@@ -95,6 +95,18 @@ public interface IContentEditingService
|
||||
/// <returns>The operation status indicating success or failure.</returns>
|
||||
Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey);
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the children of a parent by a system field.
|
||||
/// </summary>
|
||||
/// <param name="parentKey">The unique identifier of the parent, or <c>null</c> for root-level sorting.</param>
|
||||
/// <param name="field">The system field to sort the children by.</param>
|
||||
/// <param name="direction">The direction to sort in.</param>
|
||||
/// <param name="culture">The culture whose variant name to sort by, or <c>null</c> to sort by the invariant name. Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a child that does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.</param>
|
||||
/// <param name="userKey">The unique identifier of the user performing the action.</param>
|
||||
/// <returns>The operation status indicating success or failure.</returns>
|
||||
Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, string? culture, Guid userKey)
|
||||
=> throw new NotImplementedException(); // TODO (V19): Remove default implementation.
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a content item whether it is in the recycle bin or not.
|
||||
/// </summary>
|
||||
|
||||
@@ -542,6 +542,22 @@ public interface IContentService : IContentServiceBase<IContent>
|
||||
/// <returns>The operation result.</returns>
|
||||
OperationResult Sort(IEnumerable<int>? ids, int userId = Constants.Security.SuperUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the children of a parent by persisting the supplied (already ordered) child identifiers
|
||||
/// as the new sort order, in a single set-based update.
|
||||
/// </summary>
|
||||
/// <param name="parentId">The identifier of the parent, or <see cref="Constants.System.Root"/> for the root.</param>
|
||||
/// <param name="orderedChildIds">The child document identifiers, in the desired order.</param>
|
||||
/// <param name="userId">The identifier of the user performing the action.</param>
|
||||
/// <returns>The operation result.</returns>
|
||||
/// <remarks>
|
||||
/// Unlike <see cref="Sort(IEnumerable{int}?, int)" />, this does not load the children or fire per-item
|
||||
/// save/sort notifications; it persists the order directly and refreshes the affected cache branch.
|
||||
/// </remarks>
|
||||
// TODO (V19): Remove the default implementation.
|
||||
OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
#endregion
|
||||
|
||||
#region Publish Document
|
||||
|
||||
@@ -118,6 +118,18 @@ public interface IMediaEditingService
|
||||
/// </returns>
|
||||
Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey);
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the children of a parent by a system field.
|
||||
/// </summary>
|
||||
/// <param name="parentKey">The unique identifier of the parent, or <c>null</c> for root-level sorting.</param>
|
||||
/// <param name="field">The system field to sort the children by.</param>
|
||||
/// <param name="direction">The direction to sort in.</param>
|
||||
/// <param name="userKey">The unique identifier of the user performing the operation.</param>
|
||||
/// <returns>The operation status indicating the operation outcome.</returns>
|
||||
/// <remarks>Media items never vary by culture, so children are always ordered by the invariant name.</remarks>
|
||||
Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, Guid userKey)
|
||||
=> throw new NotImplementedException(); // TODO (V19): Remove default implementation.
|
||||
|
||||
/// <summary>
|
||||
/// Permanently deletes a media item from the recycle bin.
|
||||
/// </summary>
|
||||
|
||||
@@ -359,6 +359,22 @@ public interface IMediaService : IContentServiceBase<IMedia>
|
||||
/// <returns>True if sorting succeeded, otherwise False</returns>
|
||||
bool Sort(IEnumerable<IMedia> items, int userId = Constants.Security.SuperUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the children of a parent by persisting the supplied (already ordered) child identifiers
|
||||
/// as the new sort order, in a single set-based update.
|
||||
/// </summary>
|
||||
/// <param name="parentId">The identifier of the parent, or <see cref="Constants.System.Root"/> for the root.</param>
|
||||
/// <param name="orderedChildIds">The child media identifiers, in the desired order.</param>
|
||||
/// <param name="userId">The identifier of the user performing the action.</param>
|
||||
/// <returns>The operation result.</returns>
|
||||
/// <remarks>
|
||||
/// Unlike <see cref="Sort(IEnumerable{IMedia}, int)" />, this does not load the children or fire per-item
|
||||
/// save/sort notifications; it persists the order directly and refreshes the affected cache branch.
|
||||
/// </remarks>
|
||||
// TODO (V19): Remove the default implementation.
|
||||
OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="IMedia" /> object using the alias of the <see cref="IMediaType" />
|
||||
/// that this Media should based on.
|
||||
|
||||
@@ -165,6 +165,12 @@ internal sealed class MediaEditingService
|
||||
public async Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey)
|
||||
=> await HandleSortAsync(parentKey, sortingModels, userKey);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, Guid userKey)
|
||||
|
||||
// Media never varies by culture, so children are always ordered by the invariant name.
|
||||
=> await HandleSortByFieldAsync(parentKey, field, direction, culture: null, userKey);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IMedia New(string? name, int parentId, IMediaType mediaType)
|
||||
=> new Models.Media(name, parentId, mediaType);
|
||||
@@ -187,8 +193,8 @@ internal sealed class MediaEditingService
|
||||
=> ContentService.Delete(media, userId).Result;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
|
||||
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total);
|
||||
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total)
|
||||
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, filter: null, ordering: ordering);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContentEditingOperationStatus Sort(IEnumerable<IMedia> items, int userId)
|
||||
@@ -199,6 +205,13 @@ internal sealed class MediaEditingService
|
||||
: ContentEditingOperationStatus.CancelledByNotification;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId)
|
||||
{
|
||||
OperationResult result = ContentService.SortChildren(parentId, orderedChildIds, userId);
|
||||
return OperationResultToOperationStatus(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a media item to the repository.
|
||||
/// </summary>
|
||||
|
||||
@@ -1414,6 +1414,15 @@ namespace Umbraco.Cms.Core.Services
|
||||
{
|
||||
scope.WriteLock(Constants.Locks.MediaTree);
|
||||
|
||||
// Reload within the lock so sorting operates on fully-loaded entities. Callers may pass
|
||||
// partially-loaded media (e.g. without property data), and saving those directly would
|
||||
// wipe the property data (#23120). Preserve the caller's ordering, which drives the sort.
|
||||
var reloadedById = GetByIds(itemsA.Select(x => x.Id)).ToDictionary(x => x.Id);
|
||||
itemsA = itemsA
|
||||
.Select(x => reloadedById.TryGetValue(x.Id, out IMedia? media) ? media : null)
|
||||
.WhereNotNull()
|
||||
.ToArray();
|
||||
|
||||
var savingNotification = new MediaSavingNotification(itemsA, messages);
|
||||
if (scope.Notifications.PublishCancelable(savingNotification))
|
||||
{
|
||||
@@ -1452,6 +1461,43 @@ namespace Umbraco.Cms.Core.Services
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
|
||||
{
|
||||
EventMessages evtMsgs = EventMessagesFactory.Get();
|
||||
if (orderedChildIds.Count == 0)
|
||||
{
|
||||
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
|
||||
}
|
||||
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
scope.WriteLock(Constants.Locks.MediaTree);
|
||||
|
||||
_mediaRepository.UpdateSortOrder(orderedChildIds);
|
||||
|
||||
// Sort order lives in umbracoNode; neither the published cache nor the media repository cache keeps
|
||||
// a separate serialized copy of it, so refreshing the affected branch (which invalidates both and has
|
||||
// them reload from umbracoNode) is enough to pick up the new order without re-saving each child.
|
||||
if (parentId == Constants.System.Root)
|
||||
{
|
||||
IMedia[] roots = GetByIds(orderedChildIds).ToArray();
|
||||
scope.Notifications.Publish(new MediaTreeChangeNotification(roots, TreeChangeTypes.RefreshNode, evtMsgs));
|
||||
}
|
||||
else
|
||||
{
|
||||
IMedia? parent = GetById(parentId);
|
||||
if (parent is not null)
|
||||
{
|
||||
scope.Notifications.Publish(new MediaTreeChangeNotification(parent, TreeChangeTypes.RefreshBranch, evtMsgs));
|
||||
}
|
||||
}
|
||||
|
||||
Audit(AuditType.Sort, userId, parentId);
|
||||
|
||||
scope.Complete();
|
||||
return OperationResult.Succeed(evtMsgs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the data integrity of the media tree and optionally fixes detected issues.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Navigation;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
@@ -76,6 +77,27 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
private NavigationSnapshot _navigation = new(new(), []);
|
||||
private NavigationSnapshot _recycleBinNavigation = new(new(), []);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the approximate number of nodes currently held in memory across the active navigation
|
||||
/// structure and the recycle bin structure, for diagnostics. Each snapshot reference is read once,
|
||||
/// so the count is consistent per structure even if a rebuild swaps a snapshot concurrently.
|
||||
/// </summary>
|
||||
private protected long GetNavigationNodeCount()
|
||||
=> _navigation.Structure.Count + _recycleBinNavigation.Structure.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets an approximate retained size, in bytes, of the navigation structures (active tree plus
|
||||
/// recycle bin), for diagnostics. Sampled and structural — a coarse estimate, not a heap measurement.
|
||||
/// </summary>
|
||||
private protected long GetNavigationApproximateBytes()
|
||||
=> EstimateStructureBytes(_navigation.Structure) + EstimateStructureBytes(_recycleBinNavigation.Structure);
|
||||
|
||||
// The dictionary is enumerated directly (not via .Values, which snapshot-copies the whole collection).
|
||||
// Per-node estimate: fixed fields (key, content-type key, parent, sort order, lock) + dictionary bucket,
|
||||
// plus an allowance per child key (held in the child set and the cached ordered array).
|
||||
private static long EstimateStructureBytes(ConcurrentDictionary<Guid, NavigationNode> structure)
|
||||
=> SampledSizeEstimator.Estimate(structure.Count, structure, static kvp => 120 + (40L * kvp.Value.Children.Count));
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ContentNavigationServiceBase{TContentType, TContentTypeService}"/> class.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
@@ -13,8 +14,17 @@ namespace Umbraco.Cms.Core.Services.Navigation;
|
||||
/// and implements both <see cref="IDocumentNavigationQueryService"/> and <see cref="IDocumentNavigationManagementService"/>
|
||||
/// to provide a complete set of navigation operations for document content.
|
||||
/// </remarks>
|
||||
internal sealed class DocumentNavigationService : ContentNavigationServiceBase<IContentType, IContentTypeService>, IDocumentNavigationQueryService, IDocumentNavigationManagementService
|
||||
internal sealed class DocumentNavigationService : ContentNavigationServiceBase<IContentType, IContentTypeService>, IDocumentNavigationQueryService, IDocumentNavigationManagementService, IMemoryCacheSizeReporter
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CacheName => "Document navigation";
|
||||
|
||||
/// <inheritdoc />
|
||||
public long GetApproximateCount() => GetNavigationNodeCount();
|
||||
|
||||
/// <inheritdoc />
|
||||
public long? GetApproximateBytes() => GetNavigationApproximateBytes();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DocumentNavigationService"/> class.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
@@ -13,8 +14,17 @@ namespace Umbraco.Cms.Core.Services.Navigation;
|
||||
/// and implements both <see cref="IMediaNavigationQueryService"/> and <see cref="IMediaNavigationManagementService"/>
|
||||
/// to provide a complete set of navigation operations for media content.
|
||||
/// </remarks>
|
||||
internal sealed class MediaNavigationService : ContentNavigationServiceBase<IMediaType, IMediaTypeService>, IMediaNavigationQueryService, IMediaNavigationManagementService
|
||||
internal sealed class MediaNavigationService : ContentNavigationServiceBase<IMediaType, IMediaTypeService>, IMediaNavigationQueryService, IMediaNavigationManagementService, IMemoryCacheSizeReporter
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string CacheName => "Media navigation";
|
||||
|
||||
/// <inheritdoc />
|
||||
public long GetApproximateCount() => GetNavigationNodeCount();
|
||||
|
||||
/// <inheritdoc />
|
||||
public long? GetApproximateBytes() => GetNavigationApproximateBytes();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MediaNavigationService"/> class.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
|
||||
/// <summary>
|
||||
/// A background job that will be executed by an available server. With a single server setup this will always be the same.
|
||||
@@ -16,6 +16,19 @@ public interface IDistributedBackgroundJob
|
||||
/// </summary>
|
||||
TimeSpan Period { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the job's runs should be aligned to clock boundaries derived from <see cref="Period" />.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <c>true</c>, the job becomes runnable on the next clock boundary that is a multiple of <see cref="Period" />
|
||||
/// (measured from a fixed <strong>UTC</strong> origin, so boundaries fall on round clock times such as on the minute
|
||||
/// or every N seconds) rather than at <c>LastRun + Period</c>.
|
||||
/// For predictable boundaries <see cref="Period" /> should divide evenly into one hour.
|
||||
/// The scheduler may cache this value when it first evaluates registered jobs; changing it at runtime may require an application restart.
|
||||
/// Defaults to <c>false</c>, preserving the original drift-from-completion behaviour.
|
||||
/// </remarks>
|
||||
bool AlignToClock => false;
|
||||
|
||||
/// <summary>
|
||||
/// Run the job.
|
||||
/// </summary>
|
||||
|
||||
+10
-2
@@ -2,7 +2,9 @@
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
@@ -22,7 +24,10 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
|
||||
public string Name => "ScheduledPublishingJob";
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan Period => TimeSpan.FromMinutes(1);
|
||||
public TimeSpan Period => _scheduledPublishingSettings.CurrentValue.Period;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool AlignToClock => _scheduledPublishingSettings.CurrentValue.AlignToClock;
|
||||
|
||||
|
||||
private readonly IContentService _contentService;
|
||||
@@ -31,6 +36,7 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IServerMessenger _serverMessenger;
|
||||
private readonly IUmbracoContextFactory _umbracoContextFactory;
|
||||
private readonly IOptionsMonitor<ScheduledPublishingSettings> _scheduledPublishingSettings;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ScheduledPublishingJob" /> class.
|
||||
@@ -41,7 +47,8 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
|
||||
ILogger<ScheduledPublishingJob> logger,
|
||||
IServerMessenger serverMessenger,
|
||||
ICoreScopeProvider scopeProvider,
|
||||
TimeProvider timeProvider)
|
||||
TimeProvider timeProvider,
|
||||
IOptionsMonitor<ScheduledPublishingSettings> scheduledPublishingSettings)
|
||||
{
|
||||
_contentService = contentService;
|
||||
_umbracoContextFactory = umbracoContextFactory;
|
||||
@@ -49,6 +56,7 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
|
||||
_serverMessenger = serverMessenger;
|
||||
_scopeProvider = scopeProvider;
|
||||
_timeProvider = timeProvider;
|
||||
_scheduledPublishingSettings = scheduledPublishingSettings;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Periodically logs, at debug level, the approximate entry count of each in-memory cache that
|
||||
/// scales with the size of the content tree, together with process-level memory totals.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Intended as observability for memory usage during full-tree operations (reindexing, crawling the
|
||||
/// published site). The per-cache counts are a trend/attribution signal — a count that climbs and
|
||||
/// never falls indicates unbounded retention; the managed-heap and working-set totals give the
|
||||
/// absolute memory picture. Runs on all servers because memory is per-process, and does nothing unless
|
||||
/// debug logging is enabled for this job.
|
||||
/// </remarks>
|
||||
public class MemoryCacheSizeReportingJob : RecurringBackgroundJobBase
|
||||
{
|
||||
private readonly IEnumerable<IMemoryCacheSizeReporter> _reporters;
|
||||
private readonly ILogger<MemoryCacheSizeReportingJob> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MemoryCacheSizeReportingJob" /> class.
|
||||
/// </summary>
|
||||
/// <param name="reporters">The in-memory caches that report their size.</param>
|
||||
/// <param name="logger">The typed logger.</param>
|
||||
public MemoryCacheSizeReportingJob(
|
||||
IEnumerable<IMemoryCacheSizeReporter> reporters,
|
||||
ILogger<MemoryCacheSizeReportingJob> logger)
|
||||
: base(TimeSpan.FromMinutes(1))
|
||||
{
|
||||
_reporters = reporters;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the server roles on which this job runs.
|
||||
/// </summary>
|
||||
/// <remarks>Runs on all servers, because the reported memory is per-process.</remarks>
|
||||
public override ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task RunJobAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Reporting is debug-only; skip the work entirely when debug logging is not enabled.
|
||||
if (_logger.IsEnabled(LogLevel.Debug) is false)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
foreach (IMemoryCacheSizeReporter reporter in _reporters)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
long? approximateBytes = reporter.GetApproximateBytes();
|
||||
if (approximateBytes is null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"In-memory cache size: {CacheName} = {EntryCount} entries (bytes: n/a — use a GC dump)",
|
||||
reporter.CacheName,
|
||||
reporter.GetApproximateCount());
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"In-memory cache size: {CacheName} = {EntryCount} entries (~{ApproximateBytes} bytes)",
|
||||
reporter.CacheName,
|
||||
reporter.GetApproximateCount(),
|
||||
approximateBytes.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// The reporters above cover the L0 converted-content caches and the baseline structures. The
|
||||
// HybridCache L1 (Microsoft's in-process tier of ContentCacheNode entries, behind L0) does not
|
||||
// expose an entry count; capture it from a GC dump when a finer breakdown is needed. The process
|
||||
// totals below give the overall picture.
|
||||
_logger.LogDebug(
|
||||
"Process memory: managed heap {ManagedHeapBytes} bytes, working set {WorkingSetBytes} bytes",
|
||||
GC.GetTotalMemory(forceFullCollection: false),
|
||||
Environment.WorkingSet);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
+60
-8
@@ -4,7 +4,6 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
|
||||
@@ -26,6 +25,8 @@ public class InstructionProcessJob : RecurringBackgroundJobBase
|
||||
|
||||
private readonly ILogger<InstructionProcessJob> _logger;
|
||||
private readonly IServerMessenger _messenger;
|
||||
private readonly TimeSpan _syncTimeout;
|
||||
private Task? _inFlightSync;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InstructionProcessJob" /> class.
|
||||
@@ -41,27 +42,78 @@ public class InstructionProcessJob : RecurringBackgroundJobBase
|
||||
{
|
||||
_messenger = messenger;
|
||||
_logger = logger;
|
||||
_syncTimeout = ValidateSyncTimeout(globalSettings.Value.DatabaseServerMessenger.SyncTimeout);
|
||||
}
|
||||
|
||||
// A non-positive timeout would make every sync "time out" immediately (or throw from WaitAsync for a
|
||||
// negative value), so guard against misconfiguration and fall back to the default. Timeout.InfiniteTimeSpan
|
||||
// is allowed as an explicit opt-out that restores the unbounded wait.
|
||||
private TimeSpan ValidateSyncTimeout(TimeSpan configuredSyncTimeout)
|
||||
{
|
||||
if (configuredSyncTimeout > TimeSpan.Zero || configuredSyncTimeout == Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
return configuredSyncTimeout;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Configured DatabaseServerMessenger.SyncTimeout of {ConfiguredSyncTimeout} is not valid; it must be positive (or Timeout.InfiniteTimeSpan to disable the timeout). Falling back to {DefaultSyncTimeout}.",
|
||||
configuredSyncTimeout,
|
||||
DatabaseServerMessengerSettings.DefaultSyncTimeout);
|
||||
|
||||
return DatabaseServerMessengerSettings.DefaultSyncTimeout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the instruction processing job asynchronously by synchronizing messages using the messenger service.
|
||||
/// Logs an error if the synchronization fails, but always completes the task.
|
||||
/// Logs an error if the synchronization fails or stalls, but always completes the task so polling continues.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
|
||||
/// <returns>
|
||||
/// A completed task representing the asynchronous operation.
|
||||
/// A task representing the asynchronous operation.
|
||||
/// </returns>
|
||||
public override Task RunJobAsync(CancellationToken cancellationToken)
|
||||
public override async Task RunJobAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// If a previous sync is still running (e.g. blocked on a hung database connection after a timeout),
|
||||
// skip starting another. This bounds us to a single in-flight call instead of accumulating blocked
|
||||
// thread-pool threads, and logs the stall once rather than on every interval until it recovers.
|
||||
if (_inFlightSync is { IsCompleted: false })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// IServerMessenger.Sync() is synchronous and cannot observe the cancellation token, so a hung database
|
||||
// connection would otherwise block this job's recurring loop indefinitely and silently stop cache
|
||||
// polling until the process is recycled. Offload it to the thread pool and bound the wait so the loop
|
||||
// survives and keeps polling; the in-flight call keeps running until its connection faults (bounded by
|
||||
// the database command/connection timeout, not by SyncTimeout), after which syncing resumes without a recycle.
|
||||
//
|
||||
// The loop is already started under ExecutionContext.SuppressFlow() (see RecurringBackgroundJobHostedService.StartAsync),
|
||||
// which is what makes offloading the scope-creating Sync() to Task.Run safe for the static ambient scope stack.
|
||||
var syncTask = Task.Run(_messenger.Sync, cancellationToken);
|
||||
_inFlightSync = syncTask;
|
||||
|
||||
// Observe the task's eventual fault on every exit path (timeout, shutdown cancellation, or a late
|
||||
// failure once we have stopped awaiting it) so it never surfaces as an UnobservedTaskException.
|
||||
_ = syncTask.ContinueWith(
|
||||
static t => _ = t.Exception,
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
|
||||
try
|
||||
{
|
||||
_messenger.Sync();
|
||||
await syncTask.WaitAsync(_syncTimeout, cancellationToken);
|
||||
_logger.LogDebug("Synchronized cache instructions.");
|
||||
}
|
||||
catch (Exception e)
|
||||
catch (TimeoutException)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Cache instruction sync did not complete within {SyncTimeout} and may be stalled on a hung database connection. Cache updates are paused on this server until the stalled connection recovers.",
|
||||
_syncTimeout);
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(e, "Failed (will repeat).");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
+71
-9
@@ -33,6 +33,8 @@ public class TouchServerJob : RecurringBackgroundJobBase
|
||||
private readonly IServerRoleAccessor _serverRoleAccessor;
|
||||
private readonly IDisposable? _onChangeRegistration;
|
||||
private GlobalSettings _globalSettings;
|
||||
private TimeSpan _touchTimeout;
|
||||
private Task? _inFlightTouch;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TouchServerJob" /> class.
|
||||
@@ -55,11 +57,13 @@ public class TouchServerJob : RecurringBackgroundJobBase
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings.CurrentValue;
|
||||
_serverRoleAccessor = serverRoleAccessor;
|
||||
_touchTimeout = ValidateTouchTimeout(globalSettings.CurrentValue.DatabaseServerRegistrar.TouchTimeout);
|
||||
|
||||
_onChangeRegistration = globalSettings.OnChange(x =>
|
||||
{
|
||||
_globalSettings = x;
|
||||
Period = x.DatabaseServerRegistrar.WaitTimeBetweenCalls;
|
||||
_touchTimeout = ValidateTouchTimeout(x.DatabaseServerRegistrar.TouchTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,35 +75,93 @@ public class TouchServerJob : RecurringBackgroundJobBase
|
||||
/// <returns>
|
||||
/// A completed task when the job has finished running.
|
||||
/// </returns>
|
||||
public override Task RunJobAsync(CancellationToken cancellationToken)
|
||||
public override async Task RunJobAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// If the IServerRoleAccessor has been changed away from ElectedServerRoleAccessor this task no longer makes sense,
|
||||
// since all it's used for is to allow the ElectedServerRoleAccessor
|
||||
// to figure out what role a given server has, so we just stop this task.
|
||||
if (_serverRoleAccessor is not ElectedServerRoleAccessor)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
return;
|
||||
}
|
||||
|
||||
// If a previous touch is still running (e.g. blocked on a hung database connection after a timeout),
|
||||
// skip starting another. This bounds us to a single in-flight call instead of accumulating blocked
|
||||
// thread-pool threads (each contending for the servers lock), and logs the stall once rather than on
|
||||
// every interval until it recovers.
|
||||
if (_inFlightTouch is { IsCompleted: false })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var serverAddress = _hostingEnvironment.ApplicationMainUrl?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(serverAddress))
|
||||
{
|
||||
_logger.LogWarning("No umbracoApplicationUrl for service (yet), skip.");
|
||||
return Task.CompletedTask;
|
||||
// No application URL is known yet: either detection is off (WebRouting:ApplicationUrlDetection is
|
||||
// None with no UmbracoApplicationUrl set), or detection is on but no request has been served yet.
|
||||
// Register with the machine name as a placeholder so server-role election can still proceed (uniqueness
|
||||
// comes from the server identity, not this address). If a URL is later detected from a request, the next
|
||||
// touch overwrites the placeholder.
|
||||
serverAddress = Environment.MachineName;
|
||||
_logger.LogDebug(
|
||||
"No application URL available; registering server with placeholder address {ServerAddress}.",
|
||||
serverAddress);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Registering server with application URL {ServerAddress}.", serverAddress);
|
||||
}
|
||||
|
||||
// IServerRegistrationService.TouchServer() runs a synchronous database write and cannot observe the
|
||||
// cancellation token, so a hung connection would otherwise block this job's recurring loop indefinitely
|
||||
// and silently stop server-registration heartbeats until the process is recycled. Offload it to the
|
||||
// thread pool and bound the wait so the loop survives and keeps touching.
|
||||
// (See InstructionProcessJob for the same pattern and the ExecutionContext.SuppressFlow rationale.)
|
||||
TimeSpan staleServerTimeout = _globalSettings.DatabaseServerRegistrar.StaleServerTimeout;
|
||||
var touchTask = Task.Run(() => _serverRegistrationService.TouchServer(serverAddress, staleServerTimeout), cancellationToken);
|
||||
_inFlightTouch = touchTask;
|
||||
|
||||
// Observe the task's eventual fault on every exit path (timeout, shutdown cancellation, or a late
|
||||
// failure once we have stopped awaiting it) so it never surfaces as an UnobservedTaskException.
|
||||
_ = touchTask.ContinueWith(
|
||||
static t => _ = t.Exception,
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
|
||||
try
|
||||
{
|
||||
_serverRegistrationService.TouchServer(
|
||||
serverAddress,
|
||||
_globalSettings.DatabaseServerRegistrar.StaleServerTimeout);
|
||||
await touchTask.WaitAsync(_touchTimeout, cancellationToken);
|
||||
_logger.LogDebug("Touched server registration for {ServerAddress}.", serverAddress);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (TimeoutException)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Touching the server registration did not complete within {TouchTimeout} and may be stalled on a hung database connection. Server registration is paused on this server until the stalled connection recovers.",
|
||||
_touchTimeout);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update server record in database.");
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
// A non-positive timeout would make every touch "time out" immediately (or throw from WaitAsync for a
|
||||
// negative value), so guard against misconfiguration and fall back to the default. Timeout.InfiniteTimeSpan
|
||||
// is allowed as an explicit opt-out that restores the unbounded wait.
|
||||
private TimeSpan ValidateTouchTimeout(TimeSpan configuredTouchTimeout)
|
||||
{
|
||||
if (configuredTouchTimeout > TimeSpan.Zero || configuredTouchTimeout == Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
return configuredTouchTimeout;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Configured DatabaseServerRegistrar.TouchTimeout of {ConfiguredTouchTimeout} is not valid; it must be positive (or Timeout.InfiniteTimeSpan to disable the timeout). Falling back to {DefaultTouchTimeout}.",
|
||||
configuredTouchTimeout,
|
||||
DatabaseServerRegistrarSettings.DefaultTouchTimeout);
|
||||
|
||||
return DatabaseServerRegistrarSettings.DefaultTouchTimeout;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -25,6 +25,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
builder.Services.AddRecurringBackgroundJob<InstructionProcessJob>();
|
||||
builder.Services.AddRecurringBackgroundJob<TouchServerJob>();
|
||||
builder.Services.AddRecurringBackgroundJob<ReportSiteJob>();
|
||||
builder.Services.AddRecurringBackgroundJob<MemoryCacheSizeReportingJob>();
|
||||
|
||||
builder.Services.AddSingleton<IDistributedBackgroundJob, WebhookFiring>();
|
||||
builder.Services.AddSingleton<IDistributedBackgroundJob, ContentVersionCleanupJob>();
|
||||
|
||||
@@ -131,12 +131,15 @@ public class PackageMigrationRunner
|
||||
=> RunPackagePlansAsync(plansToRun).GetAwaiter().GetResult();
|
||||
|
||||
/// <summary>
|
||||
/// Runs the all specified package migration plans and publishes a <see cref="MigrationPlansExecutedNotification" />
|
||||
/// if all are successful.
|
||||
/// Runs all the specified package migration plans and publishes a <see cref="MigrationPlansExecutedNotification" />.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All plans are run to completion even if one fails, so that one package's failure does not block another's.
|
||||
/// A failed plan is reported via <see cref="ExecutedMigrationPlan.Successful" /> on the returned result rather
|
||||
/// than by throwing; callers must inspect the results to detect a failure.
|
||||
/// </remarks>
|
||||
/// <param name="plansToRun"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="Exception">If any plan fails it will throw an exception.</exception>
|
||||
public async Task<IEnumerable<ExecutedMigrationPlan>> RunPackagePlansAsync(IEnumerable<string> plansToRun)
|
||||
{
|
||||
List<ExecutedMigrationPlan> results = new();
|
||||
|
||||
@@ -11,6 +11,7 @@ using Umbraco.Cms.Core.Exceptions;
|
||||
using Umbraco.Cms.Core.Logging;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.Migrations;
|
||||
using Umbraco.Cms.Infrastructure.Migrations.Install;
|
||||
using Umbraco.Cms.Infrastructure.Migrations.Upgrade;
|
||||
using Umbraco.Cms.Infrastructure.Runtime;
|
||||
@@ -163,7 +164,23 @@ public class UnattendedUpgrader : INotificationAsyncHandler<RuntimeUnattendedUpg
|
||||
|
||||
try
|
||||
{
|
||||
await _packageMigrationRunner.RunPackagePlansAsync(pendingMigrations);
|
||||
IEnumerable<ExecutedMigrationPlan> executedPlans =
|
||||
await _packageMigrationRunner.RunPackagePlansAsync(pendingMigrations);
|
||||
|
||||
// Failed plans are reported via the result, not by throwing (the runner deliberately runs all plans to
|
||||
// completion so one package's failure doesn't block another's). Surface them as a boot failure here so the
|
||||
// failure is observable, mirroring the core upgrade path - otherwise the migration stays pending and the
|
||||
// runtime re-derives Upgrading on every boot, leaving the site stuck on the maintenance page.
|
||||
// All failures are reported together.
|
||||
var failedPlans = executedPlans.Where(plan => plan.Successful is false).ToList();
|
||||
if (failedPlans.Count > 0)
|
||||
{
|
||||
SetRuntimeError(CreatePackageMigrationError(failedPlans));
|
||||
notification.UnattendedUpgradeResult =
|
||||
RuntimeUnattendedUpgradeNotification.UpgradeResult.HasErrors;
|
||||
return;
|
||||
}
|
||||
|
||||
notification.UnattendedUpgradeResult = RuntimeUnattendedUpgradeNotification.UpgradeResult.PackageMigrationComplete;
|
||||
|
||||
// Migration plans may have changed published content, so refresh the distributed cache to ensure consistency on first request.
|
||||
@@ -200,6 +217,22 @@ public class UnattendedUpgrader : INotificationAsyncHandler<RuntimeUnattendedUpg
|
||||
}
|
||||
}
|
||||
|
||||
private static Exception CreatePackageMigrationError(IReadOnlyList<ExecutedMigrationPlan> failedPlans)
|
||||
{
|
||||
static Exception ToException(ExecutedMigrationPlan plan)
|
||||
=> plan.Exception ?? new UnattendedInstallException(
|
||||
$"An error occurred while running the unattended package migration '{plan.Plan.Name}'.");
|
||||
|
||||
if (failedPlans.Count == 1)
|
||||
{
|
||||
return ToException(failedPlans[0]);
|
||||
}
|
||||
|
||||
return new AggregateException(
|
||||
$"{failedPlans.Count} unattended package migrations failed: {string.Join(", ", failedPlans.Select(plan => plan.Plan.Name))}.",
|
||||
failedPlans.Select(ToException));
|
||||
}
|
||||
|
||||
private void SetRuntimeError(Exception exception)
|
||||
=> _runtimeState.Configure(
|
||||
RuntimeLevel.BootFailed,
|
||||
|
||||
+30
@@ -1297,6 +1297,36 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement
|
||||
/// </summary>
|
||||
public abstract int RecycleBinId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UpdateSortOrder(IReadOnlyList<int> orderedNodeIds)
|
||||
{
|
||||
if (orderedNodeIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var nodeTable = SqlSyntax.GetQuotedTableName(NodeDto.TableName);
|
||||
var idColumn = SqlSyntax.GetQuotedColumnName(NodeDto.IdColumnName);
|
||||
var sortOrderColumn = SqlSyntax.GetQuotedColumnName(NodeDto.SortOrderColumnName);
|
||||
|
||||
// Each node's new sort order is its position in the ordered collection.
|
||||
var ordered = orderedNodeIds
|
||||
.Select((id, sortOrder) => new KeyValuePair<int, int>(id, sortOrder))
|
||||
.ToList();
|
||||
|
||||
// Two parameters per node (id + sort order), so batch to stay within the SQL Server parameter limit.
|
||||
foreach (IEnumerable<KeyValuePair<int, int>> group in ordered.InGroupsOf(Constants.Sql.MaxParameterCount / 2))
|
||||
{
|
||||
List<KeyValuePair<int, int>> groupList = group.ToList();
|
||||
var args = groupList.SelectMany(pair => new object[] { pair.Key, pair.Value }).ToArray();
|
||||
var whenClauses = string.Join(" ", groupList.Select((_, i) => $"WHEN @{i * 2} THEN @{(i * 2) + 1}"));
|
||||
var inClause = string.Join(", ", groupList.Select((_, i) => $"@{i * 2}"));
|
||||
|
||||
var sql = $"UPDATE {nodeTable} SET {sortOrderColumn} = CASE {idColumn} {whenClauses} END WHERE {idColumn} IN ({inClause})";
|
||||
Database.Execute(sql, args);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all entities that are currently in the recycle bin.
|
||||
/// </summary>
|
||||
|
||||
@@ -20,6 +20,10 @@ public class DistributedJobService : IDistributedJobService
|
||||
private readonly ILogger<DistributedJobService> _logger;
|
||||
private readonly DistributedJobSettings _settings;
|
||||
|
||||
// Which jobs align to the clock is a startup configuration concern (changing it requires a restart), so it is
|
||||
// captured once in the constructor rather than re-evaluated on every poll.
|
||||
private readonly HashSet<string> _clockAlignedJobNames;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DistributedJobService"/> class.
|
||||
/// </summary>
|
||||
@@ -58,6 +62,10 @@ public class DistributedJobService : IDistributedJobService
|
||||
_distributedBackgroundJobs = distributedBackgroundJobs;
|
||||
_logger = logger;
|
||||
_settings = settings.Value;
|
||||
_clockAlignedJobNames = _distributedBackgroundJobs
|
||||
.Where(x => x.AlignToClock)
|
||||
.Select(x => x.Name)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -67,9 +75,12 @@ public class DistributedJobService : IDistributedJobService
|
||||
|
||||
scope.EagerWriteLock(Constants.Locks.DistributedJobs);
|
||||
|
||||
DateTime utcNow = DateTime.UtcNow;
|
||||
|
||||
IEnumerable<DistributedBackgroundJobModel> jobs = _distributedJobRepository.GetAll();
|
||||
DistributedBackgroundJobModel? job = jobs.FirstOrDefault(x => x.LastRun < DateTime.UtcNow - x.Period
|
||||
&& (x.IsRunning is false || x.LastAttemptedRun < DateTime.UtcNow - x.Period - _settings.MaximumExecutionTime));
|
||||
DistributedBackgroundJobModel? job = jobs.FirstOrDefault(x =>
|
||||
IsDue(x, utcNow, _clockAlignedJobNames.Contains(x.Name))
|
||||
&& (x.IsRunning is false || x.LastAttemptedRun < utcNow - x.Period - _settings.MaximumExecutionTime));
|
||||
|
||||
if (job is null)
|
||||
{
|
||||
@@ -97,6 +108,39 @@ public class DistributedJobService : IDistributedJobService
|
||||
return distributedJob;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a job is due to run.
|
||||
/// </summary>
|
||||
/// <param name="job">The job state.</param>
|
||||
/// <param name="utcNow">The current UTC time.</param>
|
||||
/// <param name="aligned">
|
||||
/// Whether the job's runs are aligned to clock boundaries (see <see cref="IDistributedBackgroundJob.AlignToClock" />).
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// For non-aligned jobs the period counts from the previous run's completion (<c>LastRun + Period</c>, drifting).
|
||||
/// For aligned jobs the job is due once a clock boundary — a multiple of the period measured from a fixed UTC
|
||||
/// origin, so boundaries fall on round clock times such as on the minute — has fallen strictly after the previous
|
||||
/// run's completion. Boundaries are in UTC, not the server's local time zone. This is overrun-safe: if a run takes
|
||||
/// longer than the period, the boundary it would have targeted has already passed, so the missed boundary is
|
||||
/// skipped rather than triggering back-to-back runs.
|
||||
/// </remarks>
|
||||
internal static bool IsDue(DistributedBackgroundJobModel job, DateTime utcNow, bool aligned)
|
||||
{
|
||||
if (aligned == false || job.Period <= TimeSpan.Zero)
|
||||
{
|
||||
return job.LastRun < utcNow - job.Period;
|
||||
}
|
||||
|
||||
long periodTicks = job.Period.Ticks;
|
||||
|
||||
// Floor the current UTC time to the most recent clock boundary. Ticks count from a fixed origin (0001-01-01), and
|
||||
// a day divides evenly by any clean sub-hour period, so boundaries fall on round clock times (e.g. each :10s).
|
||||
long ticksSinceBoundary = utcNow.Ticks % periodTicks;
|
||||
long currentBoundaryTicks = utcNow.Ticks - ticksSinceBoundary;
|
||||
|
||||
return currentBoundaryTicks > job.LastRun.Ticks;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task FinishAsync(string jobName)
|
||||
{
|
||||
@@ -136,11 +180,25 @@ public class DistributedJobService : IDistributedJobService
|
||||
return;
|
||||
}
|
||||
|
||||
// Clock-aligned jobs only hit their boundaries as tightly as the poll interval allows. If the poll interval
|
||||
// is longer than the job's period, boundaries between polls are silently missed.
|
||||
foreach (IDistributedBackgroundJob job in _distributedBackgroundJobs)
|
||||
{
|
||||
if (job.AlignToClock && job.Period < _settings.Period)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Distributed background job '{JobName}' aligns to the clock with a period of {Period}, but the distributed job poll interval is longer ({PollInterval}). Clock boundaries shorter than the poll interval will be missed; set Umbraco:CMS:DistributedJobs:Period to be no longer than the job period.",
|
||||
job.Name,
|
||||
job.Period,
|
||||
_settings.Period);
|
||||
}
|
||||
}
|
||||
|
||||
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
|
||||
scope.WriteLock(Constants.Locks.DistributedJobs);
|
||||
|
||||
DistributedBackgroundJobModel[] existingJobs = _distributedJobRepository.GetAll().ToArray();
|
||||
var existingJobsByName = existingJobs.ToDictionary(x => x.Name);
|
||||
Dictionary<string, DistributedBackgroundJobModel> existingJobsByName = existingJobs.ToDictionary(x => x.Name);
|
||||
|
||||
// Collect all changes first, then execute - minimizes time spent in the critical section
|
||||
var jobsToAdd = new List<DistributedBackgroundJobModel>();
|
||||
|
||||
@@ -273,9 +273,41 @@ HybridCache API is experimental (suppressed with `#pragma warning disable EXTEXP
|
||||
|
||||
Before returning cached content, verifies ancestor path is published via `_publishStatusQueryService.HasPublishedAncestorPath()`. Returns null if parent unpublished.
|
||||
|
||||
### In-Memory Content Cache (DocumentCacheService.cs line 39)
|
||||
### In-Memory Content Cache (the L0 converted-content cache)
|
||||
|
||||
Secondary `ConcurrentDictionary<string, IPublishedContent>` caches converted objects, since `ContentCacheNode` to `IPublishedContent` conversion is expensive.
|
||||
The converted `IPublishedContent` objects are cached in `ConvertedPublishedContentCache<TKey>`
|
||||
(`Services/ConvertedPublishedContentCache.cs`), used by `DocumentCacheService` (`<string>`) and
|
||||
`MediaCacheService` (`<Guid>`), since `ContentCacheNode` → `IPublishedContent` conversion is expensive.
|
||||
This is the single insert/remove/clear path for the L0 cache (the seam a later bounded/eviction-aware
|
||||
implementation slots into), and it tracks both the entry count and an approximate retained byte total.
|
||||
The cache is currently **unbounded** — only evicted on content change / explicit clear, so walking the
|
||||
whole published tree (Delivery API crawl, sitemap, warm-up) retains the whole tree's converted form.
|
||||
Bounding it with a scan-resistant policy is tracked separately; the observability below quantifies it.
|
||||
|
||||
### Memory observability
|
||||
|
||||
The in-memory structures whose footprint scales with the size of the content tree implement
|
||||
`IMemoryCacheSizeReporter` (`Umbraco.Cms.Core.Cache`), exposing an approximate retained **entry count** and
|
||||
(where cheaply derivable) an approximate **byte** estimate:
|
||||
|
||||
| Reporter (`CacheName`) | Structure | Byte estimate |
|
||||
|------------------------|-----------|---------------|
|
||||
| `Published content (converted, L0)` | `DocumentCacheService` L0 cache | running total of per-entry node-size estimates |
|
||||
| `Published media (converted, L0)` | `MediaCacheService` L0 cache | running total of per-entry node-size estimates |
|
||||
| `Document URL segments` | `DocumentUrlService._documentUrlCache` (≈ documents × cultures × draft/published) | sampled structural estimate |
|
||||
| `Document navigation` / `Media navigation` | the in-memory navigation trees (active + recycle bin) | sampled structural estimate |
|
||||
|
||||
`MemoryCacheSizeReportingJob` (a recurring job, all server roles, 1-minute period) logs each count and byte
|
||||
estimate plus `GC.GetTotalMemory` and `Environment.WorkingSet` **at `Debug` level** — enable `Debug` for
|
||||
`Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.MemoryCacheSizeReportingJob` to capture, e.g. during a
|
||||
reindex or crawl. Counts/bytes are a **trend/attribution** signal (a value that climbs and never falls
|
||||
indicates unbounded retention). The byte figures are coarse approximations, **not** a heap measurement: the
|
||||
L0 estimate is an *underlying-content lower bound* (`ContentCacheNodeSizeEstimator` sums the source node's
|
||||
stored content without decompressing or walking the converted graph, so it omits the property-editor-driven
|
||||
conversion blow-up); true per-object bytes come from a GC dump. Note the tiers: **L0** is the
|
||||
converted-`IPublishedContent` cache reported above; **L1** is Microsoft HybridCache's in-process tier of
|
||||
`ContentCacheNode` entries (behind L0); **L2** is the optional distributed tier. The HybridCache **L1** has
|
||||
no exposed count/size — measure it from the GC dump until a sized backing cache is wired up (PR 3).
|
||||
|
||||
### Known Technical Debt
|
||||
|
||||
|
||||
+8
-2
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
@@ -42,8 +43,12 @@ public static class UmbracoBuilderExtensions
|
||||
builder.Services.AddSingleton<IDomainCache, DomainCache>();
|
||||
builder.Services.AddSingleton<IElementsCache, ElementsDictionaryAppCache>();
|
||||
builder.Services.AddSingleton<IPublishedContentTypeCache, PublishedContentTypeCache>();
|
||||
builder.Services.AddSingleton<IDocumentCacheService, DocumentCacheService>();
|
||||
builder.Services.AddSingleton<IMediaCacheService, MediaCacheService>();
|
||||
builder.Services.AddSingleton<DocumentCacheService>();
|
||||
builder.Services.AddSingleton<IDocumentCacheService>(s => s.GetRequiredService<DocumentCacheService>());
|
||||
builder.Services.AddSingleton<MediaCacheService>();
|
||||
builder.Services.AddSingleton<IMediaCacheService>(s => s.GetRequiredService<MediaCacheService>());
|
||||
builder.Services.AddSingleton<IMemoryCacheSizeReporter>(s => s.GetRequiredService<DocumentCacheService>());
|
||||
builder.Services.AddSingleton<IMemoryCacheSizeReporter>(s => s.GetRequiredService<MediaCacheService>());
|
||||
builder.Services.AddSingleton<IMemberCacheService, MemberCacheService>();
|
||||
builder.Services.AddSingleton<IDomainCacheService, DomainCacheService>();
|
||||
builder.Services.AddSingleton<IPublishedContentFactory, PublishedContentFactory>();
|
||||
@@ -77,6 +82,7 @@ public static class UmbracoBuilderExtensions
|
||||
builder.AddNotificationHandler<ContentTypeChangedNotification, DeferredCacheRebuildNotificationHandler>();
|
||||
builder.AddNotificationHandler<MediaTypeChangedNotification, DeferredCacheRebuildNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, SeedingNotificationHandler>();
|
||||
builder.AddNotificationHandler<UmbracoApplicationStartingNotification, DomainCacheSeedingNotificationHandler>();
|
||||
builder.AddCacheSeeding();
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="IRuntimeState"/> used by the cache startup notification handlers.
|
||||
/// </summary>
|
||||
internal static class RuntimeStateExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true when startup cache seeding should be skipped because the site is not yet serving
|
||||
/// front-end content, i.e. it is installing (or below) or upgrading with the maintenance page shown.
|
||||
/// </summary>
|
||||
/// <param name="state">The runtime state.</param>
|
||||
/// <param name="globalSettings">The global settings.</param>
|
||||
public static bool ShouldSkipStartupSeeding(this IRuntimeState state, GlobalSettings globalSettings)
|
||||
=> state.Level <= RuntimeLevel.Install
|
||||
|| (state.Level == RuntimeLevel.Upgrade && globalSettings.ShowMaintenancePageWhenInUpgradeState);
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.HybridCache.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers;
|
||||
|
||||
internal sealed class DomainCacheSeedingNotificationHandler : INotificationHandler<UmbracoApplicationStartingNotification>
|
||||
{
|
||||
private readonly IDomainCacheService _domainCacheService;
|
||||
private readonly IRuntimeState _runtimeState;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
public DomainCacheSeedingNotificationHandler(IDomainCacheService domainCacheService, IRuntimeState runtimeState, IOptions<GlobalSettings> globalSettings)
|
||||
{
|
||||
_domainCacheService = domainCacheService;
|
||||
_runtimeState = runtimeState;
|
||||
_globalSettings = globalSettings.Value;
|
||||
}
|
||||
|
||||
public void Handle(UmbracoApplicationStartingNotification notification)
|
||||
{
|
||||
if (_runtimeState.ShouldSkipStartupSeeding(_globalSettings))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Force eager population of the lazily-loaded domain cache.
|
||||
_domainCacheService.GetAll(includeWildcards: true);
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -1,11 +1,10 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.HybridCache.Services;
|
||||
using Umbraco.Cms.Infrastructure.HybridCache.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers;
|
||||
|
||||
@@ -29,7 +28,7 @@ internal sealed class SeedingNotificationHandler : INotificationAsyncHandler<Umb
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
if (_runtimeState.Level <= RuntimeLevel.Install || (_runtimeState.Level == RuntimeLevel.Upgrade && _globalSettings.ShowMaintenancePageWhenInUpgradeState))
|
||||
if (_runtimeState.ShouldSkipStartupSeeding(_globalSettings))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,26 @@ internal struct LazyCompressedString
|
||||
|
||||
public static implicit operator string(LazyCompressedString l) => l.ToString();
|
||||
|
||||
/// <summary>
|
||||
/// Returns an approximate byte count for this value without decompressing it: the compressed byte
|
||||
/// length while still compressed, otherwise the approximate UTF-16 byte size (character count × 2) of
|
||||
/// the already-decompressed string. Never triggers decompression and never throws — intended for cheap
|
||||
/// size diagnostics. Returning UTF-16 bytes here keeps the decompressed estimate consistent with how
|
||||
/// plain strings are sized elsewhere.
|
||||
/// </summary>
|
||||
public int GetApproximateByteCount()
|
||||
{
|
||||
lock (_locker)
|
||||
{
|
||||
if (_bytes is not null)
|
||||
{
|
||||
return _bytes.Length;
|
||||
}
|
||||
|
||||
return _str is null ? 0 : _str.Length * 2;
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] GetBytes()
|
||||
{
|
||||
if (_bytes == null)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using Umbraco.Cms.Infrastructure.HybridCache.Serialization;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Produces a cheap, approximate byte size for a <see cref="ContentCacheNode" />, used to track the
|
||||
/// retained size of the L0 converted-content cache.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The estimate sums the node's stored string/property content without decompressing
|
||||
/// <see cref="LazyCompressedString" /> values or walking the converted object graph, so it is safe to
|
||||
/// call on the cache-insert path. It is an <em>underlying-content</em> figure and a lower bound on the
|
||||
/// true managed heap cost — it deliberately ignores the (highly property-editor-dependent) blow-up from
|
||||
/// converting stored values into their typed model. The true heap figure comes from a GC dump.
|
||||
/// </remarks>
|
||||
internal static class ContentCacheNodeSizeEstimator
|
||||
{
|
||||
// Rough allowances for object headers and dictionary/array bookkeeping (x64).
|
||||
private const int BaseOverheadBytes = 64;
|
||||
private const int PerPropertyOverheadBytes = 24;
|
||||
|
||||
public static long EstimateBytes(ContentCacheNode node)
|
||||
{
|
||||
long bytes = BaseOverheadBytes;
|
||||
|
||||
ContentData? data = node.Data;
|
||||
if (data is null)
|
||||
{
|
||||
return bytes;
|
||||
}
|
||||
|
||||
bytes += EstimateStringBytes(data.Name) + EstimateStringBytes(data.UrlSegment);
|
||||
|
||||
foreach (KeyValuePair<string, PropertyData[]> property in data.Properties)
|
||||
{
|
||||
bytes += EstimateStringBytes(property.Key);
|
||||
foreach (PropertyData propertyData in property.Value)
|
||||
{
|
||||
bytes += PerPropertyOverheadBytes
|
||||
+ EstimateStringBytes(propertyData.Culture)
|
||||
+ EstimateStringBytes(propertyData.Segment)
|
||||
+ EstimateValueBytes(propertyData.Value);
|
||||
}
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static long EstimateStringBytes(string? value) => value is null ? 0 : value.Length * 2L;
|
||||
|
||||
private static long EstimateValueBytes(object? value) => value switch
|
||||
{
|
||||
null => 0,
|
||||
LazyCompressedString lazyCompressedString => lazyCompressedString.GetApproximateByteCount(),
|
||||
string stringValue => stringValue.Length * 2L,
|
||||
byte[] bytes => bytes.Length,
|
||||
_ => 8,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Encapsulates the in-process (L0) cache of converted <see cref="IPublishedContent" /> behind a single
|
||||
/// insert/remove/clear path, tracking both the entry count and an approximate retained byte total.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Routing every mutation through this one type keeps the byte total consistent by construction —
|
||||
/// there is exactly one place that adds on insert and subtracts on remove/clear. The byte total is an
|
||||
/// <em>approximation</em> (the per-entry size is supplied by the caller and the running total is updated
|
||||
/// without locking the whole structure), suitable for diagnostics, not exact accounting.
|
||||
/// This is also the seam the later bounded/eviction-aware implementation slots into.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TKey">The cache key type (string for documents, Guid for media).</typeparam>
|
||||
internal sealed class ConvertedPublishedContentCache<TKey>
|
||||
where TKey : notnull
|
||||
{
|
||||
private readonly ConcurrentDictionary<TKey, CacheEntry> _cache = new();
|
||||
private long _approximateSizeInBytes;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of entries currently held.
|
||||
/// </summary>
|
||||
public long Count => _cache.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the approximate retained size, in bytes, of the cached entries.
|
||||
/// </summary>
|
||||
public long ApproximateSizeInBytes => Interlocked.Read(ref _approximateSizeInBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to get a cached converted content item.
|
||||
/// </summary>
|
||||
public bool TryGet(TKey key, out IPublishedContent? content)
|
||||
{
|
||||
if (_cache.TryGetValue(key, out CacheEntry entry))
|
||||
{
|
||||
content = entry.Content;
|
||||
return true;
|
||||
}
|
||||
|
||||
content = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or replaces a cached converted content item, adjusting the running byte total by the supplied
|
||||
/// per-entry size estimate.
|
||||
/// </summary>
|
||||
public void Set(TKey key, IPublishedContent content, long approximateSizeInBytes)
|
||||
{
|
||||
var entry = new CacheEntry(content, approximateSizeInBytes);
|
||||
|
||||
// Compute the delta against any existing entry so overwrites don't inflate the total. A concurrent
|
||||
// Set/Remove for the same key can make this off by one entry's size; acceptable for a diagnostic
|
||||
// counter, and Clear() re-establishes the baseline.
|
||||
long delta = approximateSizeInBytes;
|
||||
if (_cache.TryGetValue(key, out CacheEntry existing))
|
||||
{
|
||||
delta -= existing.Size;
|
||||
}
|
||||
|
||||
_cache[key] = entry;
|
||||
Interlocked.Add(ref _approximateSizeInBytes, delta);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a cached entry, subtracting its size from the running total.
|
||||
/// </summary>
|
||||
public bool Remove(TKey key)
|
||||
{
|
||||
if (_cache.TryRemove(key, out CacheEntry entry))
|
||||
{
|
||||
Interlocked.Add(ref _approximateSizeInBytes, -entry.Size);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes every entry whose content matches the predicate, subtracting their sizes from the total.
|
||||
/// </summary>
|
||||
public void RemoveWhere(Func<IPublishedContent, bool> predicate)
|
||||
{
|
||||
foreach (KeyValuePair<TKey, CacheEntry> kvp in _cache)
|
||||
{
|
||||
if (predicate(kvp.Value.Content) && _cache.TryRemove(kvp.Key, out CacheEntry removed))
|
||||
{
|
||||
Interlocked.Add(ref _approximateSizeInBytes, -removed.Size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all entries and resets the running byte total.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_cache.Clear();
|
||||
Interlocked.Exchange(ref _approximateSizeInBytes, 0);
|
||||
}
|
||||
|
||||
private readonly record struct CacheEntry(IPublishedContent Content, long Size);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
#if DEBUG
|
||||
using System.Diagnostics;
|
||||
#endif
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Caching.Hybrid;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
@@ -20,7 +20,7 @@ using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
|
||||
|
||||
internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
internal sealed class DocumentCacheService : IDocumentCacheService, IMemoryCacheSizeReporter
|
||||
{
|
||||
private readonly IDatabaseCacheRepository _databaseCacheRepository;
|
||||
private readonly IIdKeyMap _idKeyMap;
|
||||
@@ -36,7 +36,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
private readonly ILogger<DocumentCacheService> _logger;
|
||||
private HashSet<Guid>? _seedKeys;
|
||||
|
||||
private readonly ConcurrentDictionary<string, IPublishedContent> _publishedContentCache = [];
|
||||
private readonly ConvertedPublishedContentCache<string> _publishedContentCache = new();
|
||||
|
||||
private HashSet<Guid> SeedKeys
|
||||
{
|
||||
@@ -86,6 +86,15 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CacheName => "Published content (converted, L0)";
|
||||
|
||||
/// <inheritdoc />
|
||||
public long GetApproximateCount() => _publishedContentCache.Count;
|
||||
|
||||
/// <inheritdoc />
|
||||
public long? GetApproximateBytes() => _publishedContentCache.ApproximateSizeInBytes;
|
||||
|
||||
public async Task<IPublishedContent?> GetByKeyAsync(Guid key, bool? preview = null)
|
||||
{
|
||||
bool calculatedPreview = preview ?? GetPreview();
|
||||
@@ -110,7 +119,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
public bool TryGetCached(Guid key, bool preview, out IPublishedContent? content)
|
||||
{
|
||||
// Mirror the L0 (published content cache) fast path in GetNodeAsync.
|
||||
if (preview is false && _publishedContentCache.TryGetValue(GetCacheKey(key, preview), out content))
|
||||
if (preview is false && _publishedContentCache.TryGet(GetCacheKey(key, preview), out content))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -123,7 +132,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
{
|
||||
var cacheKey = GetCacheKey(key, preview);
|
||||
|
||||
if (preview is false && _publishedContentCache.TryGetValue(cacheKey, out IPublishedContent? cached))
|
||||
if (preview is false && _publishedContentCache.TryGet(cacheKey, out IPublishedContent? cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
@@ -155,7 +164,10 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
IPublishedContent? result = _publishedContentFactory.ToIPublishedContent(contentCacheNode, preview).CreateModel(_publishedModelFactory);
|
||||
if (result is not null)
|
||||
{
|
||||
_publishedContentCache[cacheKey] = result;
|
||||
// The size estimate runs unconditionally (not only when reporting is enabled): it is cheap
|
||||
// (O(properties), no IO/decompression) and only on the cache-miss path, and keeping the running
|
||||
// total always-current means it is accurate the moment debug reporting is switched on.
|
||||
_publishedContentCache.Set(cacheKey, result, ContentCacheNodeSizeEstimator.EstimateBytes(contentCacheNode));
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -226,7 +238,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
{
|
||||
var cacheKey = GetCacheKey(publishedNode.Key, false);
|
||||
await _hybridCache.SetAsync(cacheKey, publishedNode, GetEntryOptions(publishedNode.Key, false), GenerateTags(publishedNode));
|
||||
_publishedContentCache.Remove(cacheKey, out _);
|
||||
_publishedContentCache.Remove(cacheKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -428,14 +440,14 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
public void ClearConvertedContentCache(IReadOnlyCollection<int> contentTypeIds)
|
||||
{
|
||||
var ids = contentTypeIds as int[] ?? contentTypeIds.ToArray();
|
||||
_publishedContentCache.RemoveAll(content => ids.Contains(content.Value.ContentType.Id));
|
||||
_publishedContentCache.RemoveWhere(content => ids.Contains(content.ContentType.Id));
|
||||
}
|
||||
|
||||
private async Task ClearPublishedCacheAsync(Guid key)
|
||||
{
|
||||
var cacheKey = GetCacheKey(key, false);
|
||||
await _hybridCache.RemoveAsync(cacheKey);
|
||||
_publishedContentCache.Remove(cacheKey, out _);
|
||||
_publishedContentCache.Remove(cacheKey);
|
||||
}
|
||||
|
||||
private static string ContentTypeIdTag(int contentTypeId)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Concurrent;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
@@ -9,23 +9,41 @@ using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.Changes;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implements <see cref="IDomainCacheService" />, providing an in-memory cache of the configured <see cref="Domain" />s.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The cache is lazily populated from the database on first access and kept up to date in response to domain
|
||||
/// cache refresher notifications. It is registered as a singleton, so a single instance serves all requests.
|
||||
/// </remarks>
|
||||
public class DomainCacheService : IDomainCacheService
|
||||
{
|
||||
private readonly IDomainService _domainService;
|
||||
private readonly ICoreScopeProvider _coreScopeProvider;
|
||||
private readonly ConcurrentDictionary<int, Domain> _domains;
|
||||
private bool _initialized = false;
|
||||
private readonly Lock _initializationLock = new();
|
||||
|
||||
// Both fields are written under _initializationLock but read on the hot path (request routing) without
|
||||
// it. Marking them volatile makes those lock-free reads acquire-reads, so a reader is guaranteed to see
|
||||
// the fully populated dictionary and the completed-initialization flag together, never a stale or
|
||||
// half-published value. This is required for correctness on weak memory models such as ARM; on x86/x64
|
||||
// ordinary reads already have acquire semantics, but we cannot rely on that.
|
||||
private volatile ConcurrentDictionary<int, Domain> _domains = new();
|
||||
private volatile bool _initialized;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DomainCacheService" /> class.
|
||||
/// </summary>
|
||||
/// <param name="domainService">The service used to load domains from the database.</param>
|
||||
/// <param name="coreScopeProvider">The provider used to create scopes for database access.</param>
|
||||
public DomainCacheService(IDomainService domainService, ICoreScopeProvider coreScopeProvider)
|
||||
{
|
||||
_domainService = domainService;
|
||||
_coreScopeProvider = coreScopeProvider;
|
||||
_domains = new ConcurrentDictionary<int, Domain>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Domain> GetAll(bool includeWildcards)
|
||||
{
|
||||
InitializeIfMissing();
|
||||
@@ -34,22 +52,38 @@ public class DomainCacheService : IDomainCacheService
|
||||
: _domains.Select(x => x.Value).OrderBy(x => x.SortOrder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the domains on first access, ensuring the cache is populated before any caller reads from it.
|
||||
/// </summary>
|
||||
private void InitializeIfMissing()
|
||||
{
|
||||
// Lazy, on-demand initialization triggered by the first request to reach the cache.
|
||||
// The flag must only be set to true *after* the domains have been loaded and published.
|
||||
// Setting it beforehand creates a window where a concurrent caller observes _initialized == true,
|
||||
// skips loading, and reads an empty domain cache. On a multi-site setup that empties domain
|
||||
// resolution, causing every site to fall back to the first root node (see ContentFinderByUrlNew).
|
||||
// The double-checked lock ensures a single load while concurrent readers block until it completes.
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_initialized = true;
|
||||
LoadDomains();
|
||||
|
||||
lock (_initializationLock)
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LoadDomains();
|
||||
_initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Domain> GetAssigned(int documentId, bool includeWildcards = false)
|
||||
{
|
||||
InitializeIfMissing();
|
||||
// probably this could be optimized with an index
|
||||
// but then we'd need a custom DomainStore of some sort
|
||||
IEnumerable<Domain> list = _domains.Values.Where(x => x.ContentId == documentId);
|
||||
if (includeWildcards == false)
|
||||
{
|
||||
@@ -66,6 +100,7 @@ public class DomainCacheService : IDomainCacheService
|
||||
return documentId > 0 && GetAssigned(documentId, includeWildcards).Any();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Refresh(DomainCacheRefresher.JsonPayload[] payloads)
|
||||
{
|
||||
foreach (DomainCacheRefresher.JsonPayload payload in payloads)
|
||||
@@ -102,20 +137,23 @@ public class DomainCacheService : IDomainCacheService
|
||||
continue; // anomaly
|
||||
}
|
||||
|
||||
var newDomain = new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder);
|
||||
|
||||
// Feels wierd to use key and oldvalue, but we're using neither when updating.
|
||||
_domains.AddOrUpdate(
|
||||
domain.Id,
|
||||
new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder),
|
||||
(key, oldValue) => newDomain);
|
||||
_domains[domain.Id] = new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the configured domains from the database into a fresh dictionary and atomically swaps it in
|
||||
/// as the current cache.
|
||||
/// </summary>
|
||||
private void LoadDomains()
|
||||
{
|
||||
// Build the replacement set in a local dictionary and publish it with a single write to the
|
||||
// (volatile) _domains field. A reader never observes a partially populated cache during a RefreshAll
|
||||
// rebuild, and the published set contains exactly the current domains (any removed since the last
|
||||
// load are absent).
|
||||
var newDomains = new ConcurrentDictionary<int, Domain>();
|
||||
using (ICoreScope scope = _coreScopeProvider.CreateCoreScope())
|
||||
{
|
||||
scope.ReadLock(Constants.Locks.Domains);
|
||||
@@ -124,11 +162,11 @@ public class DomainCacheService : IDomainCacheService
|
||||
.Where(x => x.RootContentId.HasValue && x.LanguageIsoCode.IsNullOrWhiteSpace() == false)
|
||||
.Select(x => new Domain(x.Id, x.DomainName, x.RootContentId!.Value, x.LanguageIsoCode!, x.IsWildcard, x.SortOrder)))
|
||||
{
|
||||
_domains.AddOrUpdate(domain.Id, domain, (key, oldValue) => domain);
|
||||
newDomains[domain.Id] = domain;
|
||||
}
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
|
||||
_domains = newDomains;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#if DEBUG
|
||||
using System.Diagnostics;
|
||||
#endif
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Caching.Hybrid;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
@@ -19,7 +19,7 @@ using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
|
||||
|
||||
internal sealed class MediaCacheService : IMediaCacheService
|
||||
internal sealed class MediaCacheService : IMediaCacheService, IMemoryCacheSizeReporter
|
||||
{
|
||||
private readonly IDatabaseCacheRepository _databaseCacheRepository;
|
||||
private readonly IIdKeyMap _idKeyMap;
|
||||
@@ -32,7 +32,7 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
private readonly ILogger<MediaCacheService> _logger;
|
||||
private readonly CacheSettings _cacheSettings;
|
||||
|
||||
private readonly ConcurrentDictionary<Guid, IPublishedContent> _publishedContentCache = [];
|
||||
private readonly ConvertedPublishedContentCache<Guid> _publishedContentCache = new();
|
||||
|
||||
private HashSet<Guid>? _seedKeys;
|
||||
private HashSet<Guid> SeedKeys
|
||||
@@ -79,6 +79,15 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CacheName => "Published media (converted, L0)";
|
||||
|
||||
/// <inheritdoc />
|
||||
public long GetApproximateCount() => _publishedContentCache.Count;
|
||||
|
||||
/// <inheritdoc />
|
||||
public long? GetApproximateBytes() => _publishedContentCache.ApproximateSizeInBytes;
|
||||
|
||||
public async Task<IPublishedContent?> GetByKeyAsync(Guid key)
|
||||
{
|
||||
Attempt<int> idAttempt = _idKeyMap.GetIdForKey(key, UmbracoObjectTypes.Media);
|
||||
@@ -106,7 +115,7 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
public bool TryGetCached(Guid key, out IPublishedContent? content)
|
||||
{
|
||||
// Mirror the L0 (published content cache) fast path in GetNodeAsync.
|
||||
if (_publishedContentCache.TryGetValue(key, out content))
|
||||
if (_publishedContentCache.TryGet(key, out content))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -117,7 +126,7 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
|
||||
private async Task<IPublishedContent?> GetNodeAsync(Guid key)
|
||||
{
|
||||
if (_publishedContentCache.TryGetValue(key, out IPublishedContent? cached))
|
||||
if (_publishedContentCache.TryGet(key, out IPublishedContent? cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
@@ -146,7 +155,10 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
IPublishedContent? result = _publishedContentFactory.ToIPublishedMedia(contentCacheNode).CreateModel(_publishedModelFactory);
|
||||
if (result is not null)
|
||||
{
|
||||
_publishedContentCache[key] = result;
|
||||
// The size estimate runs unconditionally (not only when reporting is enabled): it is cheap
|
||||
// (O(properties), no IO/decompression) and only on the cache-miss path, and keeping the running
|
||||
// total always-current means it is accurate the moment debug reporting is switched on.
|
||||
_publishedContentCache.Set(key, result, ContentCacheNodeSizeEstimator.EstimateBytes(contentCacheNode));
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -185,7 +197,7 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
|
||||
var cacheNode = _cacheNodeFactory.ToContentCacheNode(media);
|
||||
await _databaseCacheRepository.RefreshMediaAsync(cacheNode);
|
||||
_publishedContentCache.Remove(media.Key, out _);
|
||||
_publishedContentCache.Remove(media.Key);
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
@@ -262,7 +274,7 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
if (publishedNode is not null)
|
||||
{
|
||||
await _hybridCache.SetAsync(GetCacheKey(publishedNode.Key), publishedNode, GetEntryOptions(publishedNode.Key));
|
||||
_publishedContentCache.Remove(key, out _);
|
||||
_publishedContentCache.Remove(key);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -300,7 +312,7 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
public void ClearConvertedContentCache(IReadOnlyCollection<int> mediaTypeIds)
|
||||
{
|
||||
var ids = mediaTypeIds as int[] ?? mediaTypeIds.ToArray();
|
||||
_publishedContentCache.RemoveAll(content => ids.Contains(content.Value.ContentType.Id));
|
||||
_publishedContentCache.RemoveWhere(content => ids.Contains(content.ContentType.Id));
|
||||
}
|
||||
|
||||
public void Rebuild(IReadOnlyCollection<int> contentTypeIds)
|
||||
@@ -356,7 +368,7 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
private async Task ClearPublishedCacheAsync(Guid key)
|
||||
{
|
||||
await _hybridCache.RemoveAsync(GetCacheKey(key));
|
||||
_publishedContentCache.Remove(key, out _);
|
||||
_publishedContentCache.Remove(key);
|
||||
}
|
||||
|
||||
private static string MediaTypeIdTag(int mediaTypeId)
|
||||
|
||||
@@ -1,34 +1,120 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Net;
|
||||
using Umbraco.Cms.Core.Web;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Web.Common.AspNetCore;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the current session identifier and reads, writes and clears session values using the
|
||||
/// ASP.NET Core <see cref="ISession" /> exposed on the current <see cref="HttpContext" />.
|
||||
/// </summary>
|
||||
internal sealed class AspNetCoreSessionManager : ISessionIdResolver, ISessionManager
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly IOptions<SessionOptions> _sessionOptions;
|
||||
private readonly IOptionsMonitor<LoggingSettings> _loggingSettings;
|
||||
|
||||
public AspNetCoreSessionManager(IHttpContextAccessor httpContextAccessor) =>
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
|
||||
public string? SessionId
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AspNetCoreSessionManager" /> class.
|
||||
/// </summary>
|
||||
/// <param name="httpContextAccessor">Provides access to the current <see cref="HttpContext" />.</param>
|
||||
/// <param name="sessionOptions">The configured session options, used to determine the session cookie name.</param>
|
||||
/// <param name="loggingSettings">The logging settings, used to determine how the session id is resolved for log enrichment.</param>
|
||||
public AspNetCoreSessionManager(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IOptions<SessionOptions> sessionOptions,
|
||||
IOptionsMonitor<LoggingSettings> loggingSettings)
|
||||
{
|
||||
get
|
||||
{
|
||||
HttpContext? httpContext = _httpContextAccessor.HttpContext;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_sessionOptions = sessionOptions;
|
||||
_loggingSettings = loggingSettings;
|
||||
}
|
||||
|
||||
return IsSessionsAvailable
|
||||
? httpContext?.Session.Id
|
||||
: "0";
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// The resolved value depends on <see cref="LoggingSettings.SessionIdLogging" />: the actual session id
|
||||
/// (default), a one-way hash of the session cookie, or nothing.
|
||||
/// </remarks>
|
||||
public string? SessionId =>
|
||||
_loggingSettings.CurrentValue.SessionIdLogging switch
|
||||
{
|
||||
SessionIdLoggingMode.None => null,
|
||||
SessionIdLoggingMode.CookieHash => ResolveSessionCookieHash(),
|
||||
_ => ResolveSessionId(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the actual ASP.NET Core session id, but only when an established session cookie is present.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reading Session.Id forces a synchronous, blocking load from the session store. When sessions are
|
||||
/// backed by IDistributedCache (e.g. load-balanced setups), that is a network round-trip incurred on
|
||||
/// every request that resolves the id for logging - even anonymous requests that never use session.
|
||||
/// Only an established session sends back the session cookie, so its absence means there is nothing
|
||||
/// meaningful to load (see #23082).
|
||||
/// </remarks>
|
||||
private string? ResolveSessionId()
|
||||
{
|
||||
if (IsSessionsAvailable is false)
|
||||
{
|
||||
return "0";
|
||||
}
|
||||
|
||||
HttpContext? httpContext = _httpContextAccessor.HttpContext;
|
||||
if (httpContext is null || TryGetSessionCookieValue(httpContext, out _) is false)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return httpContext.Session.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If session isn't enabled this will throw an exception so we check
|
||||
/// Resolves a one-way hash of the session cookie value, which correlates requests to the same session
|
||||
/// without loading the session from its store.
|
||||
/// </summary>
|
||||
private bool IsSessionsAvailable => !(_httpContextAccessor.HttpContext?.Features.Get<ISessionFeature>()?.Session is null);
|
||||
private string? ResolveSessionCookieHash()
|
||||
{
|
||||
HttpContext? httpContext = _httpContextAccessor.HttpContext;
|
||||
if (httpContext is null || TryGetSessionCookieValue(httpContext, out var cookieValue) is false)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Never log the raw cookie value - it is effectively a bearer token for the session. A one-way hash
|
||||
// preserves per-session correlation without exposing the cookie and without loading the session.
|
||||
return cookieValue!.GenerateHash<SHA256>();
|
||||
}
|
||||
|
||||
private bool TryGetSessionCookieValue(HttpContext httpContext, out string? value)
|
||||
{
|
||||
var sessionCookieName = _sessionOptions.Value.Cookie.Name;
|
||||
if (sessionCookieName is null)
|
||||
{
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return httpContext.Request.Cookies.TryGetValue(sessionCookieName, out value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether session is available for the current request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Accessing <see cref="HttpContext.Session" /> throws an <see cref="InvalidOperationException" /> when the
|
||||
/// session middleware has not been configured (i.e. <c>UseSession</c> was not called), so this is checked
|
||||
/// before reading from or writing to the session.
|
||||
/// </remarks>
|
||||
private bool IsSessionsAvailable => _httpContextAccessor.HttpContext?.Features.Get<ISessionFeature>()?.Session is not null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? GetSessionValue(string key)
|
||||
{
|
||||
if (!IsSessionsAvailable)
|
||||
@@ -39,6 +125,7 @@ internal sealed class AspNetCoreSessionManager : ISessionIdResolver, ISessionMan
|
||||
return _httpContextAccessor.HttpContext?.Session.GetString(key);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetSessionValue(string key, string value)
|
||||
{
|
||||
if (!IsSessionsAvailable)
|
||||
@@ -49,6 +136,7 @@ internal sealed class AspNetCoreSessionManager : ISessionIdResolver, ISessionMan
|
||||
_httpContextAccessor.HttpContext?.Session.SetString(key, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearSessionValue(string key)
|
||||
{
|
||||
if (!IsSessionsAvailable)
|
||||
|
||||
@@ -108,7 +108,6 @@ class UmbStoryBookElement extends UmbLitElement {
|
||||
...publishCacheManifests,
|
||||
...relationsManifests,
|
||||
...rteManifests,
|
||||
...searchManifests,
|
||||
...segmentManifests,
|
||||
...settingsManifests,
|
||||
...staticFileManifests,
|
||||
@@ -190,25 +189,25 @@ export const parameters = {
|
||||
},
|
||||
},
|
||||
backgrounds: {
|
||||
options: {
|
||||
greyish: {
|
||||
options: {
|
||||
greyish: {
|
||||
name: 'Greyish',
|
||||
value: '#F3F3F5',
|
||||
},
|
||||
|
||||
white: {
|
||||
white: {
|
||||
name: 'White',
|
||||
value: '#ffffff',
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
setCustomElements(customElementManifests);
|
||||
export const tags = ['autodocs'];
|
||||
|
||||
export const initialGlobals = {
|
||||
backgrounds: {
|
||||
value: 'greyish'
|
||||
}
|
||||
backgrounds: {
|
||||
value: 'greyish'
|
||||
}
|
||||
};
|
||||
|
||||
+493
-492
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ export default {
|
||||
changeDataType: 'Datatype aanpassen',
|
||||
copy: 'Kopiëren',
|
||||
create: 'Nieuw',
|
||||
createFor: (name: string) => (name ? `Item aanmaken voor ${name}` : 'Aanmaken'),
|
||||
export: 'Export',
|
||||
createPackage: 'Nieuwe package',
|
||||
createGroup: 'Groep maken',
|
||||
@@ -56,6 +57,7 @@ export default {
|
||||
setGroup: 'Groep instellen',
|
||||
sort: 'Sorteren',
|
||||
translate: 'Vertalen',
|
||||
trash: 'Verwijderen',
|
||||
update: 'Bijwerken',
|
||||
setPermissions: 'Rechten instellen',
|
||||
unlock: 'Deblokkeer',
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@umbraco-ui/uui": "^1.17.3",
|
||||
"@umbraco-ui/uui-css": "^1.17.3"
|
||||
"@umbraco-ui/uui": "^1.18.0",
|
||||
"@umbraco-ui/uui-css": "^1.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -705,10 +705,14 @@ export class UmbBlockGridEntriesContext
|
||||
const allowedBlocks = this.#allowedBlockTypes.getValue();
|
||||
if (allowedBlocks.length === 0) return false;
|
||||
|
||||
// Manager may have been torn down (e.g. when navigating away) while the form-control
|
||||
// mixin still runs validators from updated(). Treat as valid in that case.
|
||||
if (!this._manager) return true;
|
||||
|
||||
const allowedKeys = allowedBlocks.map((x) => x.contentElementTypeKey);
|
||||
// get content for each layout entry:
|
||||
const invalidEntries = layoutEntries.filter((entry) => {
|
||||
const contentTypeKey = this._manager!.getContentTypeKeyOfContentKey(entry.contentKey);
|
||||
const contentTypeKey = this._manager?.getContentTypeKeyOfContentKey(entry.contentKey);
|
||||
if (!contentTypeKey) {
|
||||
// We could not find the content type key, so we cant determin if this is valid or not when the content is missing.
|
||||
// This should be captured elsewhere as the Block then becomes invalid. So the unsupported Block should capture this.
|
||||
|
||||
+1
-1
@@ -399,7 +399,7 @@ export abstract class UmbContentDetailWorkspaceContextBase<
|
||||
|
||||
public async loadLanguages() {
|
||||
// TODO: If we don't end up having a Global Context for languages, then we should at least change this into using a asObservable which should be returned from the repository. [Nl]
|
||||
const { data } = await this.#languageRepository.requestCollection({});
|
||||
const { data } = await this.#languageRepository.requestAllItems();
|
||||
this.#languages.setValue(data?.items ?? []);
|
||||
}
|
||||
|
||||
|
||||
+17
-3
@@ -89,10 +89,16 @@ export abstract class UmbMenuTreeStructureWorkspaceContextBase extends UmbContex
|
||||
let structureItems: Array<UmbStructureItemModel> = [];
|
||||
|
||||
const unique = (await this.observe(uniqueObservable, () => {})?.asPromise()) as string;
|
||||
if (unique === undefined) throw new Error('Unique is not available');
|
||||
if (unique === undefined) {
|
||||
if (this._host) console.warn('[UmbMenuTreeStructureWorkspaceContextBase] unique not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const entityType = (await this.observe(entityTypeObservable, () => {})?.asPromise()) as string;
|
||||
if (!entityType) throw new Error('Entity type is not available');
|
||||
if (!entityType) {
|
||||
if (this._host) console.warn('[UmbMenuTreeStructureWorkspaceContextBase] entityType not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const treeRepository = await createExtensionApiByAlias<UmbTreeRepository<UmbTreeItemModel, UmbTreeRootModel>>(
|
||||
this,
|
||||
@@ -115,6 +121,7 @@ export abstract class UmbMenuTreeStructureWorkspaceContextBase extends UmbContex
|
||||
const isRoot = entityType === root?.entityType;
|
||||
|
||||
// If the entity type is different from the root entity type, then we can request the ancestors.
|
||||
let ancestorData: Array<UmbTreeItemModel> | undefined;
|
||||
if (!isRoot) {
|
||||
const { data } = await treeRepository.requestTreeItemAncestors({ treeItem: { unique, entityType } });
|
||||
|
||||
@@ -128,12 +135,19 @@ export abstract class UmbMenuTreeStructureWorkspaceContextBase extends UmbContex
|
||||
};
|
||||
});
|
||||
|
||||
this.#setAncestorData(data);
|
||||
ancestorData = data;
|
||||
|
||||
structureItems.push(...ancestorItems);
|
||||
}
|
||||
}
|
||||
|
||||
// Guard: this context may have been destroyed while the async requests were in flight.
|
||||
if (!this._host) return;
|
||||
|
||||
if (ancestorData) {
|
||||
this.#setAncestorData(ancestorData);
|
||||
}
|
||||
|
||||
this.#structure.setValue(structureItems);
|
||||
this.#setParentData(structureItems);
|
||||
|
||||
|
||||
+12
-3
@@ -90,7 +90,6 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um
|
||||
(value) => {
|
||||
// Workspace has changed from new to existing
|
||||
if (value === false && this.#isNew === true) {
|
||||
// TODO: We do not need to request here as we already know the structure and unique
|
||||
this.#requestStructure();
|
||||
}
|
||||
this.#isNew = value;
|
||||
@@ -144,10 +143,16 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um
|
||||
let structureItems: Array<UmbVariantStructureItemModel> = [];
|
||||
|
||||
const unique = (await this.observe(uniqueObservable, () => {})?.asPromise()) as string;
|
||||
if (unique === undefined) throw new Error('Unique is not available');
|
||||
if (unique === undefined) {
|
||||
if (this._host) console.warn('[UmbMenuVariantTreeStructureWorkspaceContextBase] unique not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const entityType = (await this.observe(entityTypeObservable, () => {})?.asPromise()) as string;
|
||||
if (!entityType) throw new Error('Entity type is not available');
|
||||
if (!entityType) {
|
||||
if (this._host) console.warn('[UmbMenuVariantTreeStructureWorkspaceContextBase] entityType not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: introduce variant tree item model
|
||||
const treeRepository = await createExtensionApiByAlias<UmbTreeRepository<any, UmbTreeRootModel>>(
|
||||
@@ -186,6 +191,10 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um
|
||||
|
||||
structureItems.push(...treeItemAncestors);
|
||||
|
||||
// Guard: this context may have been destroyed while the async requests were in flight
|
||||
// (e.g. a condition such as IS_NOT_TRASHED flips before the API response arrives).
|
||||
if (!this._host) return;
|
||||
|
||||
this.#structure.setValue(structureItems);
|
||||
this.#setParentData(structureItems);
|
||||
this.#setAncestorData(data);
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './constants.js';
|
||||
export * from './data-mapper/index.js';
|
||||
export * from './detail/index.js';
|
||||
export * from './item/index.js';
|
||||
export * from './pagination/index.js';
|
||||
export * from './repository-base.js';
|
||||
export * from './repository-details.manager.js';
|
||||
export * from './repository-items.manager.js';
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { expect } from '@open-wc/testing';
|
||||
import { fetchAllPages } from './fetch-all-pages.function.js';
|
||||
import type { UmbDataSourceResponse } from '../data-source-response.interface.js';
|
||||
import type { UmbPagedModel } from '../types.js';
|
||||
|
||||
interface TestItem {
|
||||
id: number;
|
||||
}
|
||||
|
||||
const buildFakeFetcher = (allItems: Array<TestItem>) => {
|
||||
const calls: Array<{ skip: number; take: number }> = [];
|
||||
const fetchPage = async (skip: number, take: number) => {
|
||||
calls.push({ skip, take });
|
||||
return { data: { items: allItems.slice(skip, skip + take), total: allItems.length } };
|
||||
};
|
||||
return { fetchPage, calls };
|
||||
};
|
||||
|
||||
describe('fetchAllPages', () => {
|
||||
it('returns all items in a single call when total <= take', async () => {
|
||||
const all: Array<TestItem> = [{ id: 1 }, { id: 2 }];
|
||||
const { fetchPage, calls } = buildFakeFetcher(all);
|
||||
|
||||
const { data } = await fetchAllPages(fetchPage, 10);
|
||||
|
||||
expect(data?.items).to.eql(all);
|
||||
expect(data?.total).to.equal(2);
|
||||
expect(calls).to.have.lengthOf(1);
|
||||
expect(calls[0]).to.eql({ skip: 0, take: 10 });
|
||||
});
|
||||
|
||||
it('pages through and returns all items when total > take', async () => {
|
||||
const all: Array<TestItem> = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }];
|
||||
const { fetchPage, calls } = buildFakeFetcher(all);
|
||||
|
||||
const { data } = await fetchAllPages(fetchPage, 2);
|
||||
|
||||
expect(data?.items).to.eql(all);
|
||||
expect(data?.total).to.equal(5);
|
||||
expect(calls.map((c) => c.skip)).to.eql([0, 2, 4]);
|
||||
});
|
||||
|
||||
it('makes no extra call when the last page exactly fills `take`', async () => {
|
||||
const all: Array<TestItem> = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
|
||||
const { fetchPage, calls } = buildFakeFetcher(all);
|
||||
|
||||
const { data } = await fetchAllPages(fetchPage, 2);
|
||||
|
||||
expect(data?.items).to.eql(all);
|
||||
expect(data?.total).to.equal(4);
|
||||
expect(calls).to.have.lengthOf(2);
|
||||
});
|
||||
|
||||
it('returns an empty result when there are no items', async () => {
|
||||
const { fetchPage, calls } = buildFakeFetcher([]);
|
||||
|
||||
const { data } = await fetchAllPages(fetchPage, 100);
|
||||
|
||||
expect(data?.items).to.eql([]);
|
||||
expect(data?.total).to.equal(0);
|
||||
expect(calls).to.have.lengthOf(1);
|
||||
});
|
||||
|
||||
it('returns the error and stops paging when a page fetch fails', async () => {
|
||||
let callCount = 0;
|
||||
const fetchPage = async (skip: number, take: number) => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return { data: { items: [{ id: 1 }, { id: 2 }] as Array<TestItem>, total: 100 } };
|
||||
}
|
||||
return { error: new Error('boom') };
|
||||
};
|
||||
|
||||
const { data, error } = await fetchAllPages<TestItem>(fetchPage, 2);
|
||||
|
||||
expect(data).to.be.undefined;
|
||||
expect(error).to.exist;
|
||||
expect(callCount).to.equal(2);
|
||||
});
|
||||
|
||||
it('returns a synthesised error when the fetcher returns neither data nor error', async () => {
|
||||
const fetchPage = async () => ({}) as UmbDataSourceResponse<UmbPagedModel<TestItem>>;
|
||||
|
||||
const { data, error } = await fetchAllPages<TestItem>(fetchPage, 2);
|
||||
|
||||
expect(data).to.be.undefined;
|
||||
expect(error).to.be.an.instanceOf(Error);
|
||||
});
|
||||
|
||||
it('rejects when `take` is not a positive finite number', async () => {
|
||||
const { fetchPage } = buildFakeFetcher([{ id: 1 }, { id: 2 }]);
|
||||
|
||||
for (const invalid of [0, -1, NaN, Number.POSITIVE_INFINITY]) {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await fetchAllPages(fetchPage, invalid);
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown, `take=${invalid}`).to.be.an.instanceOf(RangeError);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns an error if the server delivers an empty page before reaching the reported total', async () => {
|
||||
// Surfaces (rather than masks) a server that reports more items than it returns. Also guards against
|
||||
// an infinite loop if `total` and the actual items disagree.
|
||||
let callCount = 0;
|
||||
const fetchPage = async (skip: number, take: number) => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return { data: { items: [{ id: 1 }, { id: 2 }] as Array<TestItem>, total: 10 } };
|
||||
}
|
||||
return { data: { items: [] as Array<TestItem>, total: 10 } };
|
||||
};
|
||||
|
||||
const { data, error } = await fetchAllPages<TestItem>(fetchPage, 2);
|
||||
|
||||
expect(data).to.be.undefined;
|
||||
expect(error).to.be.an.instanceOf(Error);
|
||||
expect(callCount).to.equal(2);
|
||||
});
|
||||
});
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import type { UmbDataSourceResponse } from '../data-source-response.interface.js';
|
||||
import type { UmbPagedModel } from '../types.js';
|
||||
|
||||
/**
|
||||
* A function that returns a single page of an offset-paginated collection.
|
||||
* @template T - The type of items in the page.
|
||||
*/
|
||||
export type UmbOffsetPageFetcher<T> = (
|
||||
skip: number,
|
||||
take: number,
|
||||
) => Promise<UmbDataSourceResponse<UmbPagedModel<T>>>;
|
||||
|
||||
/**
|
||||
* Pages through an offset-paginated data source, accumulating every item until `total` has been reached.
|
||||
* Use when a caller genuinely needs the full set rather than a single page — for example, populating a
|
||||
* dropdown of every configured language. Returns the same `{ data: { items, total } }` shape as a
|
||||
* single-page fetch, or `{ error }` if any page fails.
|
||||
*
|
||||
* If the server reports a higher `total` than it actually delivers — i.e. an empty page is returned
|
||||
* before `allItems.length` reaches `total` — the function fails with an error rather than silently
|
||||
* truncating, since a partial "fetch all" result would mislead the caller.
|
||||
* @param {UmbOffsetPageFetcher} fetchPage - Called once per page with the current `skip` and `take`.
|
||||
* @param {number} take - Page size used for every request. Must be a positive finite number.
|
||||
* @returns {Promise} A promise resolving to all items, or the first error encountered.
|
||||
* @throws {RangeError} If `take` is not a positive finite number.
|
||||
*/
|
||||
export async function fetchAllPages<T>(
|
||||
fetchPage: UmbOffsetPageFetcher<T>,
|
||||
take: number,
|
||||
): Promise<UmbDataSourceResponse<UmbPagedModel<T>>> {
|
||||
if (!Number.isFinite(take) || take <= 0) {
|
||||
throw new RangeError(`fetchAllPages: \`take\` must be a positive finite number, got ${take}.`);
|
||||
}
|
||||
|
||||
const allItems: Array<T> = [];
|
||||
let skip = 0;
|
||||
let total = Number.POSITIVE_INFINITY;
|
||||
|
||||
while (allItems.length < total) {
|
||||
const { data, error } = await fetchPage(skip, take);
|
||||
if (error) return { error };
|
||||
if (!data) return { error: new Error('fetchAllPages: page fetcher returned neither data nor error.') };
|
||||
|
||||
// If the server reports more items than it delivers, fail rather than silently truncating —
|
||||
// also guards against an infinite loop on a misbehaving source.
|
||||
if (data.items.length === 0 && allItems.length < data.total) {
|
||||
return {
|
||||
error: new Error(
|
||||
`fetchAllPages: page fetcher returned an empty page after ${allItems.length} items but reported a total of ${data.total}.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
allItems.push(...data.items);
|
||||
total = data.total;
|
||||
skip += data.items.length;
|
||||
}
|
||||
|
||||
return { data: { items: allItems, total: allItems.length } };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './fetch-all-pages.function.js';
|
||||
+20
@@ -1,21 +1,26 @@
|
||||
import { UMB_DUPLICATE_TO_MODAL } from './modal/duplicate-to-modal.token.js';
|
||||
import type { MetaEntityActionDuplicateToKind, UmbDuplicateToRepository } from './types.js';
|
||||
import type { UmbTreeRepository } from '../../data/tree-repository.interface.js';
|
||||
import { UmbEntityActionBase, UmbRequestReloadStructureForEntityEvent } from '@umbraco-cms/backoffice/entity-action';
|
||||
import { umbOpenModal } from '@umbraco-cms/backoffice/modal';
|
||||
import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry';
|
||||
import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action';
|
||||
import { linkEntityExpansionEntries } from '@umbraco-cms/backoffice/utils';
|
||||
|
||||
export class UmbDuplicateToEntityAction extends UmbEntityActionBase<MetaEntityActionDuplicateToKind> {
|
||||
override async execute() {
|
||||
if (!this.args.unique) throw new Error('Unique is not available');
|
||||
if (!this.args.entityType) throw new Error('Entity Type is not available');
|
||||
|
||||
const ancestors = await this.#requestAncestors();
|
||||
|
||||
const value = await umbOpenModal(this, UMB_DUPLICATE_TO_MODAL, {
|
||||
data: {
|
||||
unique: this.args.unique,
|
||||
entityType: this.args.entityType,
|
||||
treeAlias: this.args.meta.treeAlias,
|
||||
foldersOnly: this.args.meta.foldersOnly,
|
||||
treeExpansion: ancestors.length ? linkEntityExpansionEntries(ancestors) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -40,6 +45,21 @@ export class UmbDuplicateToEntityAction extends UmbEntityActionBase<MetaEntityAc
|
||||
this.#reloadMenu();
|
||||
}
|
||||
|
||||
async #requestAncestors() {
|
||||
try {
|
||||
const treeRepository = await createExtensionApiByAlias<UmbTreeRepository>(this, this.args.meta.treeRepositoryAlias);
|
||||
const { data } =
|
||||
(await treeRepository?.requestTreeItemAncestors({
|
||||
treeItem: { unique: this.args.unique!, entityType: this.args.entityType! },
|
||||
})) ?? {};
|
||||
// Exclude self — the API returns the descendant as part of the ancestors list, but we only want to expand its parents.
|
||||
return data?.filter((item) => item.unique !== this.args.unique) ?? [];
|
||||
} catch {
|
||||
// Tree pre-expansion is a UX convenience — if it fails the modal still opens normally.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async #reloadMenu() {
|
||||
const actionEventContext = await this.getContext(UMB_ACTION_EVENT_CONTEXT);
|
||||
if (!actionEventContext) throw new Error('Action event context is not available');
|
||||
|
||||
+9
-1
@@ -11,6 +11,10 @@ export class UmbDuplicateToModalElement extends UmbModalBaseElement<UmbDuplicate
|
||||
@state()
|
||||
private _destinationUnique?: string | null;
|
||||
|
||||
private get _treeExpansion() {
|
||||
return this.data?.treeExpansion ?? [];
|
||||
}
|
||||
|
||||
#onTreeSelectionChange(event: UmbSelectionChangeEvent) {
|
||||
const target = event.target as UmbTreeElement;
|
||||
const selection = target.getSelection();
|
||||
@@ -32,6 +36,7 @@ export class UmbDuplicateToModalElement extends UmbModalBaseElement<UmbDuplicate
|
||||
.props=${{
|
||||
foldersOnly: this.data?.foldersOnly,
|
||||
expandTreeRoot: true,
|
||||
expansion: this._treeExpansion,
|
||||
}}
|
||||
@selection-change=${this.#onTreeSelectionChange}></umb-tree>
|
||||
</uui-box>
|
||||
@@ -43,7 +48,10 @@ export class UmbDuplicateToModalElement extends UmbModalBaseElement<UmbDuplicate
|
||||
|
||||
#renderActions() {
|
||||
return html`
|
||||
<uui-button slot="actions" label=${this.localize.term('general_cancel')} @click="${this._rejectModal}"></uui-button>
|
||||
<uui-button
|
||||
slot="actions"
|
||||
label=${this.localize.term('general_cancel')}
|
||||
@click="${this._rejectModal}"></uui-button>
|
||||
<uui-button
|
||||
slot="actions"
|
||||
color="positive"
|
||||
|
||||
+2
@@ -1,11 +1,13 @@
|
||||
import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity';
|
||||
import { UmbModalToken } from '@umbraco-cms/backoffice/modal';
|
||||
import type { UmbEntityExpansionModel } from '@umbraco-cms/backoffice/utils';
|
||||
|
||||
export const UMB_DUPLICATE_TO_MODAL_ALIAS = 'Umb.Modal.DuplicateTo';
|
||||
|
||||
export interface UmbDuplicateToModalData extends UmbEntityModel {
|
||||
treeAlias: string;
|
||||
foldersOnly?: boolean;
|
||||
treeExpansion?: UmbEntityExpansionModel;
|
||||
}
|
||||
|
||||
export interface UmbDuplicateToModalValue {
|
||||
|
||||
+24
-1
@@ -1,11 +1,13 @@
|
||||
import { UMB_TREE_PICKER_MODAL } from '../../tree-picker-modal/index.js';
|
||||
import type { UmbTreeItemModel } from '../../types.js';
|
||||
import type { UmbTreeRepository } from '../../data/tree-repository.interface.js';
|
||||
import type { UmbMoveRepository } from './move-repository.interface.js';
|
||||
import type { MetaEntityActionMoveToKind } from './types.js';
|
||||
import { UmbEntityActionBase, UmbRequestReloadStructureForEntityEvent } from '@umbraco-cms/backoffice/entity-action';
|
||||
import { umbOpenModal } from '@umbraco-cms/backoffice/modal';
|
||||
import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry';
|
||||
import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action';
|
||||
import { linkEntityExpansionEntries } from '@umbraco-cms/backoffice/utils';
|
||||
|
||||
export class UmbMoveToEntityAction extends UmbEntityActionBase<MetaEntityActionMoveToKind> {
|
||||
protected async _getPickableFilter(unique: string): Promise<((item: UmbTreeItemModel) => boolean) | undefined> {
|
||||
@@ -16,12 +18,18 @@ export class UmbMoveToEntityAction extends UmbEntityActionBase<MetaEntityActionM
|
||||
if (!this.args.unique) throw new Error('Unique is not available');
|
||||
if (!this.args.entityType) throw new Error('Entity Type is not available');
|
||||
|
||||
const [ancestors, pickableFilter] = await Promise.all([
|
||||
this.#requestAncestors(),
|
||||
this._getPickableFilter(this.args.unique),
|
||||
]);
|
||||
|
||||
const value = await umbOpenModal(this, UMB_TREE_PICKER_MODAL, {
|
||||
data: {
|
||||
treeAlias: this.args.meta.treeAlias,
|
||||
foldersOnly: this.args.meta.foldersOnly,
|
||||
expandTreeRoot: true,
|
||||
pickableFilter: await this._getPickableFilter(this.args.unique),
|
||||
treeExpansion: ancestors.length ? linkEntityExpansionEntries(ancestors) : undefined,
|
||||
pickableFilter,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -43,6 +51,21 @@ export class UmbMoveToEntityAction extends UmbEntityActionBase<MetaEntityActionM
|
||||
this.#reloadMenu();
|
||||
}
|
||||
|
||||
async #requestAncestors() {
|
||||
try {
|
||||
const treeRepository = await createExtensionApiByAlias<UmbTreeRepository>(this, this.args.meta.treeRepositoryAlias);
|
||||
const { data } =
|
||||
(await treeRepository?.requestTreeItemAncestors({
|
||||
treeItem: { unique: this.args.unique!, entityType: this.args.entityType! },
|
||||
})) ?? {};
|
||||
// Exclude self — the API returns the descendant as part of the ancestors list, but we only want to expand its parents.
|
||||
return data?.filter((item) => item.unique !== this.args.unique) ?? [];
|
||||
} catch {
|
||||
// Tree pre-expansion is a UX convenience — if it fails the modal still opens normally.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async #reloadMenu() {
|
||||
const actionEventContext = await this.getContext(UMB_ACTION_EVENT_CONTEXT);
|
||||
if (!actionEventContext) throw new Error('Action Event Context is not available');
|
||||
|
||||
+4
@@ -75,6 +75,10 @@ export class UmbTreePickerModalElement<TreeItemType extends UmbTreeItemModelBase
|
||||
...this._selectionConfiguration,
|
||||
multiple,
|
||||
};
|
||||
|
||||
if (this.data?.treeExpansion !== undefined) {
|
||||
this._pickerContext.expansion.setExpansion(this.data.treeExpansion);
|
||||
}
|
||||
}
|
||||
|
||||
if (_changedProperties.has('value')) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { UmbTreeItemModel, UmbTreeStartNode } from '../types.js';
|
||||
import type { UmbPathPattern, UmbPathPatternParamsType } from '@umbraco-cms/backoffice/router';
|
||||
import type { UmbModalToken, UmbPickerModalData, UmbPickerModalValue } from '@umbraco-cms/backoffice/modal';
|
||||
import type { UmbWorkspaceModalData } from '@umbraco-cms/backoffice/workspace';
|
||||
import type { UmbEntityExpansionModel } from '@umbraco-cms/backoffice/utils';
|
||||
|
||||
export interface UmbTreePickerModalCreateActionData<PathPatternParamsType extends UmbPathPatternParamsType> {
|
||||
label: string;
|
||||
@@ -17,6 +18,7 @@ export interface UmbTreePickerModalData<
|
||||
> extends UmbPickerModalData<TreeItemType> {
|
||||
hideTreeRoot?: boolean;
|
||||
expandTreeRoot?: boolean;
|
||||
treeExpansion?: UmbEntityExpansionModel;
|
||||
treeAlias?: string;
|
||||
// TODO: create action should be replaces by entity actions in the pickers. Then we also open up for creating folders, choosing where to place items etc. [MR]
|
||||
createAction?: UmbTreePickerModalCreateActionData<PathPatternParamsType>;
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ export class UmbDictionaryTableCollectionViewElement extends UmbLitElement {
|
||||
async #observeCollectionItems() {
|
||||
if (!this.#collectionContext) return;
|
||||
|
||||
const { data: languageData } = await this.#languageCollectionRepository.requestCollection({});
|
||||
const { data: languageData } = await this.#languageCollectionRepository.requestAllItems();
|
||||
if (!languageData) return;
|
||||
|
||||
this.observe(
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ export class UmbWorkspaceViewDictionaryEditorElement extends UmbLitElement {
|
||||
}
|
||||
|
||||
override async firstUpdated() {
|
||||
const { data } = await this.#languageCollectionRepository.requestCollection({});
|
||||
const { data } = await this.#languageCollectionRepository.requestAllItems();
|
||||
if (data) {
|
||||
this._languages = data.items;
|
||||
}
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@ export class UmbCultureAndHostnamesModalElement extends UmbModalBaseElement<
|
||||
}
|
||||
|
||||
async #requestLanguages() {
|
||||
const { data } = await this.#languageCollectionRepository.requestCollection({ take: 999 });
|
||||
const { data } = await this.#languageCollectionRepository.requestAllItems();
|
||||
// Set to empty array if no data, to indicate loading is complete
|
||||
this._languageModel = data?.items ?? [];
|
||||
}
|
||||
|
||||
+21
-1
@@ -2,6 +2,7 @@ import { UMB_DOCUMENT_ENTITY_TYPE, UMB_DOCUMENT_ROOT_ENTITY_TYPE } from '../../e
|
||||
import { UmbDocumentItemRepository } from '../../item/index.js';
|
||||
import { UMB_DUPLICATE_DOCUMENT_MODAL } from './modal/index.js';
|
||||
import { UmbDuplicateDocumentRepository } from './repository/index.js';
|
||||
import { UmbDocumentTreeRepository } from '../../tree/index.js';
|
||||
import { umbOpenModal } from '@umbraco-cms/backoffice/modal';
|
||||
import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action';
|
||||
import { UmbEntityActionBase, UmbRequestReloadChildrenOfEntityEvent } from '@umbraco-cms/backoffice/entity-action';
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
UmbDocumentTypeDetailRepository,
|
||||
UmbDocumentTypeStructureRepository,
|
||||
} from '@umbraco-cms/backoffice/document-type';
|
||||
import { linkEntityExpansionEntries } from '@umbraco-cms/backoffice/utils';
|
||||
|
||||
export class UmbDuplicateDocumentEntityAction extends UmbEntityActionBase<never> {
|
||||
override async execute() {
|
||||
@@ -16,13 +18,17 @@ export class UmbDuplicateDocumentEntityAction extends UmbEntityActionBase<never>
|
||||
if (!this.args.entityType) throw new Error('Entity Type is not available');
|
||||
|
||||
const duplicateRepository = new UmbDuplicateDocumentRepository(this);
|
||||
const selectableFilter = await this.#getSelectableFilterByDocumentUnique(this.args.unique);
|
||||
const [selectableFilter, ancestors] = await Promise.all([
|
||||
this.#getSelectableFilterByDocumentUnique(this.args.unique),
|
||||
this.#requestAncestors(),
|
||||
]);
|
||||
|
||||
const value = await umbOpenModal(this, UMB_DUPLICATE_DOCUMENT_MODAL, {
|
||||
data: {
|
||||
unique: this.args.unique,
|
||||
entityType: this.args.entityType,
|
||||
selectableFilter,
|
||||
treeExpansion: ancestors.length ? linkEntityExpansionEntries(ancestors) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -43,6 +49,20 @@ export class UmbDuplicateDocumentEntityAction extends UmbEntityActionBase<never>
|
||||
this.#reloadMenu(destinationUnique);
|
||||
}
|
||||
|
||||
async #requestAncestors() {
|
||||
try {
|
||||
const treeRepository = new UmbDocumentTreeRepository(this);
|
||||
const { data } = await treeRepository.requestTreeItemAncestors({
|
||||
treeItem: { unique: this.args.unique!, entityType: this.args.entityType! },
|
||||
});
|
||||
// Exclude self — the API returns the descendant as part of the ancestors list, but we only want to expand its parents.
|
||||
return data?.filter((item) => item.unique !== this.args.unique) ?? [];
|
||||
} catch {
|
||||
// Tree pre-expansion is a UX convenience — if it fails the modal still opens normally.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async #getSelectableFilterByDocumentUnique(documentUnique: string) {
|
||||
// 1. Get the document to find its type
|
||||
const itemRepository = new UmbDocumentItemRepository(this);
|
||||
|
||||
+12
-2
@@ -21,6 +21,10 @@ export class UmbDocumentDuplicateToModalElement extends UmbModalBaseElement<
|
||||
@state()
|
||||
private _destinationUnique?: string | null;
|
||||
|
||||
private get _treeExpansion() {
|
||||
return this.data?.treeExpansion ?? [];
|
||||
}
|
||||
|
||||
#onTreeSelectionChange(event: UmbSelectionChangeEvent) {
|
||||
const target = event.target as UmbTreeElement;
|
||||
const selection = target.getSelection();
|
||||
@@ -58,11 +62,14 @@ export class UmbDocumentDuplicateToModalElement extends UmbModalBaseElement<
|
||||
expandTreeRoot: true,
|
||||
hideTreeItemActions: true,
|
||||
selectableFilter: this.#selectableFilter,
|
||||
expansion: this._treeExpansion,
|
||||
}}
|
||||
@selection-change=${this.#onTreeSelectionChange}></umb-tree>
|
||||
</uui-box>
|
||||
<uui-box headline=${this.localize.term('general_options')}>
|
||||
<umb-property-layout label=${this.localize.term('defaultdialogs_relateToOriginalLabel')} orientation="vertical"
|
||||
<umb-property-layout
|
||||
label=${this.localize.term('defaultdialogs_relateToOriginalLabel')}
|
||||
orientation="vertical"
|
||||
><div slot="editor">
|
||||
<uui-toggle
|
||||
@change=${this.#onRelateToOriginalChange}
|
||||
@@ -85,7 +92,10 @@ export class UmbDocumentDuplicateToModalElement extends UmbModalBaseElement<
|
||||
|
||||
#renderActions() {
|
||||
return html`
|
||||
<uui-button slot="actions" label=${this.localize.term('general_cancel')} @click="${this._rejectModal}"></uui-button>
|
||||
<uui-button
|
||||
slot="actions"
|
||||
label=${this.localize.term('general_cancel')}
|
||||
@click="${this._rejectModal}"></uui-button>
|
||||
<uui-button
|
||||
slot="actions"
|
||||
color="positive"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user