Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df16c114b6 | ||
|
|
fb1e16ff36 | ||
|
|
d889de6af0 | ||
|
|
d8f4342a86 | ||
|
|
774b2d4822 | ||
|
|
022439065f | ||
|
|
ba6ec7abcf | ||
|
|
5d4d3a51ea | ||
|
|
c6c5a9e7e6 | ||
|
|
065e567f11 | ||
|
|
58a1c15626 | ||
|
|
c087ce9fb5 | ||
|
|
cda880bb62 | ||
|
|
b3e6477df9 | ||
|
|
8f3ff26005 | ||
|
|
05f8158e4a | ||
|
|
28b849f2d3 | ||
|
|
8dd8820fa3 | ||
|
|
ca7dcd5150 | ||
|
|
6b76230da5 | ||
|
|
ceef53d624 | ||
|
|
5bb53172aa | ||
|
|
aa9473131b | ||
|
|
35a3a2455c | ||
|
|
2e3628dad3 | ||
|
|
5a20452e7a | ||
|
|
0e14ace89a | ||
|
|
adeddeb148 | ||
|
|
c9059c7b07 | ||
|
|
1ee24595d0 | ||
|
|
ab8b8b48d4 | ||
|
|
4ac8a397d0 | ||
|
|
feb1689848 | ||
|
|
a14b908574 | ||
|
|
0f4b784c23 | ||
|
|
ebc0ef36f5 | ||
|
|
925d6bc430 | ||
|
|
acfaf23e43 | ||
|
|
cb23c84c3d | ||
|
|
7c1b907410 | ||
|
|
564ca0384b | ||
|
|
fc3b13c85c | ||
|
|
9f633416b1 | ||
|
|
91381604dd | ||
|
|
c566dd0a71 | ||
|
|
ea78147657 | ||
|
|
0a5189e54a | ||
|
|
102e4aa80b | ||
|
|
0bec947b8b | ||
|
|
8bcd5990c2 | ||
|
|
d0e7ef0169 | ||
|
|
e2cf205d34 | ||
|
|
1dfcd9332a | ||
|
|
b2b45b016d | ||
|
|
ef610c7d23 | ||
|
|
bf0270b244 | ||
|
|
dc77f37129 | ||
|
|
d84186061c | ||
|
|
87bc6522f1 | ||
|
|
c300ebf94a | ||
|
|
7fdf9c12f3 | ||
|
|
e2252a8634 | ||
|
|
190e3d373a | ||
|
|
dbecec3451 | ||
|
|
d92e6bbeff | ||
|
|
e56ddcc3f9 | ||
|
|
84a1ca8335 | ||
|
|
fb8c3b19ce | ||
|
|
b940b27ad2 | ||
|
|
d416d352e0 | ||
|
|
7a7aadffe5 | ||
|
|
0ac6e8500a | ||
|
|
4c35c0c2e9 | ||
|
|
a15d340608 | ||
|
|
de47e0b1f7 | ||
|
|
969ae87798 | ||
|
|
37e2458475 | ||
|
|
2461853b11 | ||
|
|
fe3318ef79 | ||
|
|
828e359666 | ||
|
|
a5b7e0dac1 | ||
|
|
9fe9469e3a | ||
|
|
16c97d613f | ||
|
|
5a6b18307d | ||
|
|
3ee7b142b3 | ||
|
|
ae92b0b0a9 | ||
|
|
8503762431 | ||
|
|
86abc3528d | ||
|
|
97a005e709 | ||
|
|
30b4370044 | ||
|
|
cffac2a990 | ||
|
|
7433641348 | ||
|
|
c45b12ec58 | ||
|
|
6883c6fcfd | ||
|
|
22c4bc7835 | ||
|
|
1e82376420 | ||
|
|
e8586493e2 | ||
|
|
d5d0ce68aa | ||
|
|
764d4eb1d7 | ||
|
|
aa854da3f4 | ||
|
|
5dd28e7a13 | ||
|
|
d0fc7dc8a0 | ||
|
|
87b24912c7 | ||
|
|
f6c70e8429 | ||
|
|
89baa9482b |
@@ -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.
|
||||
@@ -23,7 +23,7 @@ env:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
@@ -38,6 +38,12 @@ jobs:
|
||||
- name: Setup .NET from global.json
|
||||
uses: actions/setup-dotnet@v5
|
||||
|
||||
- name: Setup Java 21
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Cache SonarQube packages
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
@@ -60,7 +66,8 @@ jobs:
|
||||
dotnet-sonarscanner begin \
|
||||
/k:"$SONAR_PROJECT_KEY" \
|
||||
/o:"$SONAR_ORGANIZATION" \
|
||||
/d:sonar.token="$SONAR_TOKEN"
|
||||
/d:sonar.token="$SONAR_TOKEN" \
|
||||
/d:sonar.scanner.skipJreProvisioning=true
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore umbraco.sln
|
||||
@@ -69,12 +76,23 @@ jobs:
|
||||
run: GITHUB_ENV=/dev/null dotnet build umbraco.sln --no-restore -clp:ErrorsOnly # prevent sonar MSBuild integration from writing malformed values to $GITHUB_ENV
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
id: tests
|
||||
continue-on-error: true
|
||||
run: |
|
||||
dotnet-coverage collect \
|
||||
"dotnet test tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj --no-build" \
|
||||
--output TestResults/coverage.xml \
|
||||
--output-format xml
|
||||
|
||||
- name: Warn on test failure
|
||||
if: steps.tests.outcome == 'failure'
|
||||
run: |
|
||||
if [ -f TestResults/coverage.xml ]; then
|
||||
echo "::warning::Unit tests failed - SonarCloud analysis will proceed with the collected coverage data"
|
||||
else
|
||||
echo "::warning::Unit tests failed and no coverage data was collected"
|
||||
fi
|
||||
|
||||
- name: End analysis
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
@@ -558,6 +558,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
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<!-- Package Validation -->
|
||||
<PropertyGroup>
|
||||
<GenerateCompatibilitySuppressionFile>false</GenerateCompatibilitySuppressionFile>
|
||||
<EnablePackageValidation>false</EnablePackageValidation> <!-- TODO (V18): Set to true once this version is released. -->
|
||||
<EnablePackageValidation>true</EnablePackageValidation>
|
||||
<PackageValidationBaselineVersion>18.0.0</PackageValidationBaselineVersion>
|
||||
<EnableStrictModeForCompatibleFrameworksInPackage>true</EnableStrictModeForCompatibleFrameworksInPackage>
|
||||
<EnableStrictModeForCompatibleTfms>true</EnableStrictModeForCompatibleTfms>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<PackageVersion Include="MailKit" Version="4.16.0" />
|
||||
<PackageVersion Include="Markdig" Version="1.1.3" />
|
||||
<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" />
|
||||
|
||||
+32
-98
@@ -825,74 +825,31 @@ stages:
|
||||
publishFeedCredentials: "MyGet - Umbraco Nightly"
|
||||
${{ else }}:
|
||||
publishFeedCredentials: "MyGet - Pre-releases"
|
||||
# Pre-release/nightly feeds: keep the `latest` dist-tag default (no `next` split).
|
||||
- job:
|
||||
displayName: Push to pre-release feed (npm)
|
||||
steps:
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: npm
|
||||
- bash: |
|
||||
# Check if we are on a nightly build
|
||||
if [ $isNightly = "False" ]; then
|
||||
echo "##[debug]Prerelease build detected"
|
||||
registry="https://www.myget.org/F/umbracoprereleases/npm/"
|
||||
else
|
||||
echo "##[debug]Nightly build detected"
|
||||
registry="https://www.myget.org/F/umbraconightly/npm/"
|
||||
fi
|
||||
echo "@umbraco-cms:registry=$registry" >> .npmrc
|
||||
env:
|
||||
isNightly: ${{parameters.isNightly}}
|
||||
workingDirectory: $(Pipeline.Workspace)/npm
|
||||
displayName: Add scoped registry to .npmrc
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm (MyGet)
|
||||
inputs:
|
||||
workingFile: "$(Pipeline.Workspace)/npm/.npmrc"
|
||||
- template: templates/npm-publish.yml
|
||||
parameters:
|
||||
artifactName: npm
|
||||
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
|
||||
- bash: |
|
||||
# Setup temp npm project to load in defaults from the local .npmrc
|
||||
npm init -y
|
||||
|
||||
# Find the first .tgz file in the current directory and publish it
|
||||
files=( ./*.tgz )
|
||||
npm publish "${files[0]}"
|
||||
displayName: Push to npm (MyGet)
|
||||
workingDirectory: $(Pipeline.Workspace)/npm
|
||||
displayName: Push to npm (MyGet)
|
||||
${{ if eq(parameters.isNightly, true) }}:
|
||||
registry: https://www.myget.org/F/umbraconightly/npm/
|
||||
${{ else }}:
|
||||
registry: https://www.myget.org/F/umbracoprereleases/npm/
|
||||
- job: PublishTestHelpersNpm
|
||||
displayName: Push TestHelpers to pre-release feed (npm)
|
||||
steps:
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: npm-testhelpers
|
||||
- bash: |
|
||||
# Check if we are on a nightly build
|
||||
if [ $isNightly = "False" ]; then
|
||||
echo "##[debug]Prerelease build detected"
|
||||
registry="https://www.myget.org/F/umbracoprereleases/npm/"
|
||||
else
|
||||
echo "##[debug]Nightly build detected"
|
||||
registry="https://www.myget.org/F/umbraconightly/npm/"
|
||||
fi
|
||||
echo "@umbraco-cms:registry=$registry" >> .npmrc
|
||||
env:
|
||||
isNightly: ${{parameters.isNightly}}
|
||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
||||
displayName: Add scoped registry to .npmrc
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm (MyGet)
|
||||
inputs:
|
||||
workingFile: "$(Pipeline.Workspace)/npm-testhelpers/.npmrc"
|
||||
- template: templates/npm-publish.yml
|
||||
parameters:
|
||||
artifactName: npm-testhelpers
|
||||
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
|
||||
- bash: |
|
||||
# Setup temp npm project to load in defaults from the local .npmrc
|
||||
npm init -y
|
||||
|
||||
# Find the first .tgz file in the current directory and publish it
|
||||
files=( ./*.tgz )
|
||||
npm publish "${files[0]}"
|
||||
displayName: Push test helpers to npm (MyGet)
|
||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
||||
displayName: Push test helpers to npm (MyGet)
|
||||
${{ if eq(parameters.isNightly, true) }}:
|
||||
registry: https://www.myget.org/F/umbraconightly/npm/
|
||||
${{ else }}:
|
||||
registry: https://www.myget.org/F/umbracoprereleases/npm/
|
||||
|
||||
- stage: Deploy_NuGet
|
||||
displayName: NuGet release
|
||||
@@ -941,53 +898,30 @@ stages:
|
||||
condition: and(in(dependencies.Deploy_NuGet.result, 'Succeeded', 'SucceededWithIssues'), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.npmDeploy}}))
|
||||
dependsOn:
|
||||
- Deploy_NuGet
|
||||
variables:
|
||||
# `latest` for stable releases, `next` for prereleases.
|
||||
npmDistTag: $[ iif(eq(stageDependencies.Build.A.outputs['build.NBGV_PrereleaseVersionNoLeadingHyphen'], ''), 'latest', 'next') ]
|
||||
jobs:
|
||||
- job: Publish
|
||||
displayName: Push to NPM
|
||||
steps:
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: npm
|
||||
- bash: echo "@umbraco-cms:registry=https://registry.npmjs.org" >> .npmrc
|
||||
workingDirectory: $(Pipeline.Workspace)/npm
|
||||
displayName: Add scoped registry to .npmrc
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm
|
||||
inputs:
|
||||
workingFile: $(Pipeline.Workspace)/npm/.npmrc
|
||||
- template: templates/npm-publish.yml
|
||||
parameters:
|
||||
artifactName: npm
|
||||
registry: https://registry.npmjs.org/
|
||||
customEndpoint: "NPM - Umbraco Backoffice"
|
||||
- script: |
|
||||
# Setup temp npm project to load in defaults from the local .npmrc
|
||||
npm init -y
|
||||
|
||||
# Find the first .tgz file in the current directory and publish it
|
||||
files=( ./*.tgz )
|
||||
npm publish "${files[0]}"
|
||||
displayName: Push to npm
|
||||
workingDirectory: $(Pipeline.Workspace)/npm
|
||||
displayName: Push to npm
|
||||
npmTag: $(npmDistTag)
|
||||
- job: PublishTestHelpers
|
||||
displayName: Push Test Helpers to NPM
|
||||
steps:
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: npm-testhelpers
|
||||
- bash: echo "@umbraco-cms:registry=https://registry.npmjs.org" >> .npmrc
|
||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
||||
displayName: Add scoped registry to .npmrc
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm
|
||||
inputs:
|
||||
workingFile: $(Pipeline.Workspace)/npm-testhelpers/.npmrc
|
||||
- template: templates/npm-publish.yml
|
||||
parameters:
|
||||
artifactName: npm-testhelpers
|
||||
registry: https://registry.npmjs.org/
|
||||
customEndpoint: "NPM - Umbraco Backoffice"
|
||||
- script: |
|
||||
# Setup temp npm project to load in defaults from the local .npmrc
|
||||
npm init -y
|
||||
|
||||
# Find the first .tgz file in the current directory and publish it
|
||||
files=( ./*.tgz )
|
||||
npm publish "${files[0]}"
|
||||
displayName: Push test helpers to npm
|
||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
||||
displayName: Push test helpers to npm
|
||||
npmTag: $(npmDistTag)
|
||||
|
||||
- stage: Upload_API_Docs
|
||||
pool:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
parameters:
|
||||
- name: artifactName # "npm" or "npm-testhelpers"
|
||||
type: string
|
||||
- name: registry # scoped-registry URL to publish to
|
||||
type: string
|
||||
- name: customEndpoint # npmAuthenticate service connection(s)
|
||||
type: string
|
||||
- name: displayName # label for the publish step
|
||||
type: string
|
||||
- name: npmTag # dist-tag to publish under
|
||||
type: string
|
||||
default: latest
|
||||
|
||||
steps:
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: ${{ parameters.artifactName }}
|
||||
- script: npm config set @umbraco-cms:registry ${{ parameters.registry }} --location=project
|
||||
displayName: Add scoped registry to .npmrc
|
||||
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm
|
||||
inputs:
|
||||
workingFile: $(Pipeline.Workspace)/${{ parameters.artifactName }}/.npmrc
|
||||
customEndpoint: ${{ parameters.customEndpoint }}
|
||||
- script: npm publish *.tgz --tag ${{ parameters.npmTag }}
|
||||
displayName: ${{ parameters.displayName }}
|
||||
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
|
||||
@@ -59,6 +59,7 @@ public static class UmbracoBuilderApiExtensions
|
||||
string? jsonOptionsName = null)
|
||||
where TConfigureOptions : ConfigureUmbracoOpenApiOptionsBase
|
||||
{
|
||||
apiName = apiName.ToLowerInvariant();
|
||||
builder.Services.AddOpenApi(apiName);
|
||||
builder.Services.ConfigureOptions<TConfigureOptions>();
|
||||
builder.Services.AddOpenApiDocumentToUi(apiName, apiTitle);
|
||||
|
||||
@@ -2,9 +2,9 @@ using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.AspNetCore.OpenApi;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Api.Common.Attributes;
|
||||
using Umbraco.Cms.Api.Common.DependencyInjection;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
@@ -116,12 +116,20 @@ public sealed class BackOfficeOpenApiDocumentBuilder
|
||||
/// <param name="builder">The Umbraco builder to register services against.</param>
|
||||
internal void Build(IUmbracoBuilder builder)
|
||||
{
|
||||
// AddOpenApi lowercases the document name when registering its keyed services (https://github.com/dotnet/aspnetcore/blob/v10.0.9/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs#L64),
|
||||
// so we must normalise here to keep AddOpenApiDocumentToUi and ReplaceOpenApiSchemaService in sync.
|
||||
string lowercasedDocumentName = DocumentName.ToLowerInvariant();
|
||||
|
||||
builder.Services.AddOpenApi(
|
||||
DocumentName,
|
||||
lowercasedDocumentName,
|
||||
options =>
|
||||
{
|
||||
// ShouldInclude matches [MapToApi] case-insensitively to align with how documents are registered.
|
||||
options.ShouldInclude = apiDescription =>
|
||||
apiDescription.ActionDescriptor.HasMapToApiAttribute(DocumentName);
|
||||
apiDescription.ActionDescriptor.EndpointMetadata
|
||||
?.OfType<MapToApiAttribute>()
|
||||
.Any(a => a.ApiName.Equals(DocumentName, StringComparison.OrdinalIgnoreCase))
|
||||
?? false;
|
||||
|
||||
options.CreateSchemaReferenceId = UmbracoSchemaIdGenerator.CreateSchemaReferenceId;
|
||||
|
||||
@@ -158,12 +166,12 @@ public sealed class BackOfficeOpenApiDocumentBuilder
|
||||
|
||||
if (_includedInUi)
|
||||
{
|
||||
builder.Services.AddOpenApiDocumentToUi(DocumentName, _uiTitle ?? _title);
|
||||
builder.Services.AddOpenApiDocumentToUi(lowercasedDocumentName, _uiTitle ?? _title ?? DocumentName);
|
||||
}
|
||||
|
||||
if (_httpJsonOptionsFactory is not null)
|
||||
{
|
||||
builder.Services.ReplaceOpenApiSchemaService(DocumentName, _httpJsonOptionsFactory);
|
||||
builder.Services.ReplaceOpenApiSchemaService(lowercasedDocumentName, _httpJsonOptionsFactory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+802
-679
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -59,7 +59,7 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
|
||||
private int? _skipver;
|
||||
private RoslynCompiler? _roslynCompiler;
|
||||
private ModelsBuilderSettings _config;
|
||||
private bool _disposedValue;
|
||||
private volatile bool _disposedValue;
|
||||
|
||||
public InMemoryModelFactory(
|
||||
Lazy<UmbracoServices> umbracoServices,
|
||||
@@ -280,25 +280,34 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
|
||||
}
|
||||
}
|
||||
|
||||
// don't use an upgradeable lock here because only 1 thread at a time could enter it
|
||||
try
|
||||
// The factory is disposed on application shutdown (via IRegisteredObject.Stop), but in-flight
|
||||
// requests can still reach this point. Bail out with the current models rather than touching
|
||||
// the disposed lock. The catch below covers the small window where disposal happens after this
|
||||
// check but before (or while) the lock is acquired.
|
||||
if (_disposedValue)
|
||||
{
|
||||
_locker.EnterReadLock();
|
||||
if (_hasModels)
|
||||
{
|
||||
return _infos;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_locker.IsReadLockHeld)
|
||||
{
|
||||
_locker.ExitReadLock();
|
||||
}
|
||||
return _infos;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// don't use an upgradeable lock here because only 1 thread at a time could enter it
|
||||
try
|
||||
{
|
||||
_locker.EnterReadLock();
|
||||
if (_hasModels)
|
||||
{
|
||||
return _infos;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_locker.IsReadLockHeld)
|
||||
{
|
||||
_locker.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
_locker.EnterUpgradeableReadLock();
|
||||
|
||||
if (_hasModels)
|
||||
@@ -359,6 +368,12 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
|
||||
|
||||
return _infos;
|
||||
}
|
||||
catch (ObjectDisposedException ex)
|
||||
{
|
||||
// Expected when the factory is disposed during shutdown mid-request; log so an unexpected disposal stays traceable.
|
||||
_logger.LogDebug(ex, "EnsureModels interrupted by object disposal (assumed application shutdown); returning current models.");
|
||||
return _infos;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_locker.IsWriteLockHeld)
|
||||
|
||||
@@ -467,6 +467,20 @@ public static class DistributedCacheExtensions
|
||||
|
||||
#endregion
|
||||
|
||||
#region ElementContainerCacheRefresher
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates the id/key map for the specified deleted element containers (folders).
|
||||
/// </summary>
|
||||
/// <param name="dc">The distributed cache.</param>
|
||||
/// <param name="deletedContainers">The element containers that were deleted.</param>
|
||||
public static void RemoveElementContainerCache(this DistributedCache dc, IEnumerable<EntityContainer> deletedContainers)
|
||||
=> dc.RefreshByPayload(
|
||||
ElementContainerCacheRefresher.UniqueId,
|
||||
deletedContainers.Select(container => new ElementContainerCacheRefresher.JsonPayload(container.Id, container.Key)));
|
||||
|
||||
#endregion
|
||||
|
||||
#region Published Snapshot
|
||||
|
||||
/// <summary>
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates element caches when an element container (folder) is deleted, so that its key→id mapping
|
||||
/// is evicted from <see cref="Services.IIdKeyMap"/> on every server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Element container deletions only publish <see cref="EntityContainerDeletedNotification"/> and an
|
||||
/// <see cref="ElementTreeChangeNotification"/> for the contained elements - never for the container node
|
||||
/// itself, so without this handler the container's stale id/key mapping survives until the next app
|
||||
/// restart (see #23072).
|
||||
/// </remarks>
|
||||
public sealed class ElementContainerDeletedDistributedCacheNotificationHandler
|
||||
: DeletedDistributedCacheNotificationHandlerBase<EntityContainer, EntityContainerDeletedNotification>
|
||||
{
|
||||
private readonly DistributedCache _distributedCache;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ElementContainerDeletedDistributedCacheNotificationHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="distributedCache">The distributed cache.</param>
|
||||
public ElementContainerDeletedDistributedCacheNotificationHandler(DistributedCache distributedCache)
|
||||
=> _distributedCache = distributedCache;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Handle(IEnumerable<EntityContainer> entities, IDictionary<string, object?> state)
|
||||
{
|
||||
EntityContainer[] elementContainers = entities
|
||||
.Where(container => container.ContainerObjectType == Constants.ObjectTypes.ElementContainer)
|
||||
.ToArray();
|
||||
|
||||
if (elementContainers.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_distributedCache.RemoveElementContainerCache(elementContainers);
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -18,5 +18,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Core.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Provides cache refresh functionality for element containers (folders).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A deleted container's node id is never reused, so its key→id mapping in <see cref="IIdKeyMap"/> must be
|
||||
/// evicted on every server. Otherwise a container recreated under the same key resolves to the stale id and
|
||||
/// the element tree's children query returns nothing until the next app restart. This refresher only evicts
|
||||
/// the id/key map - element data is unaffected by container changes, so it deliberately avoids the broader
|
||||
/// invalidation performed by <see cref="ElementCacheRefresher"/>.
|
||||
/// </remarks>
|
||||
public sealed class ElementContainerCacheRefresher : PayloadCacheRefresherBase<ElementContainerCacheRefresherNotification, ElementContainerCacheRefresher.JsonPayload>
|
||||
{
|
||||
private readonly IIdKeyMap _idKeyMap;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresher"/> class.
|
||||
/// </summary>
|
||||
public ElementContainerCacheRefresher(
|
||||
AppCaches appCaches,
|
||||
IJsonSerializer serializer,
|
||||
IIdKeyMap idKeyMap,
|
||||
IEventAggregator eventAggregator,
|
||||
ICacheRefresherNotificationFactory factory)
|
||||
: base(appCaches, serializer, eventAggregator, factory)
|
||||
=> _idKeyMap = idKeyMap;
|
||||
|
||||
#region Json
|
||||
|
||||
/// <summary>
|
||||
/// Represents a JSON-serializable payload identifying an element container that changed.
|
||||
/// </summary>
|
||||
public class JsonPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="JsonPayload"/> class.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique integer identifier for the container.</param>
|
||||
/// <param name="key">The unique GUID key associated with the container.</param>
|
||||
public JsonPayload(int id, Guid key)
|
||||
{
|
||||
Id = id;
|
||||
Key = key;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique integer identifier for the container.
|
||||
/// </summary>
|
||||
public int Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique GUID key associated with the container.
|
||||
/// </summary>
|
||||
public Guid Key { get; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Define
|
||||
|
||||
/// <summary>
|
||||
/// Represents a unique identifier for the cache refresher.
|
||||
/// </summary>
|
||||
public static readonly Guid UniqueId = Guid.Parse("9C9D8B0E-2F1A-4D63-9C2E-7E6B5A4F3C21");
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Guid RefresherUniqueId => UniqueId;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Name => "Element Container Cache Refresher";
|
||||
|
||||
#endregion
|
||||
|
||||
#region Refresher
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Refresh(JsonPayload[] payloads)
|
||||
{
|
||||
foreach (JsonPayload payload in payloads)
|
||||
{
|
||||
// Clearing by id also evicts the key→id direction, as the id/key map keeps both in sync.
|
||||
_idKeyMap.ClearCache(payload.Id);
|
||||
}
|
||||
|
||||
base.Refresh(payloads);
|
||||
}
|
||||
|
||||
// These events should never trigger. Everything should be PAYLOAD/JSON.
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void RefreshAll() => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Refresh(int id) => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Refresh(Guid id) => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Remove(int id) => throw new NotSupportedException();
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -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>
|
||||
@@ -109,6 +114,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;
|
||||
}
|
||||
|
||||
@@ -20,5 +20,12 @@ public class IndexingSettings
|
||||
/// <summary>
|
||||
/// Gets or sets a value for how many items to index at a time.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the primary lever for the peak memory used while (re)building an index: a full page of
|
||||
/// content and its property data is held in memory at once, so lowering this value reduces rebuild
|
||||
/// memory at the cost of more, smaller batches. Lower it on very large sites that hit memory pressure
|
||||
/// during a rebuild.
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticBatchSize)]
|
||||
public int BatchSize { get; set; } = StaticBatchSize;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
builder
|
||||
@@ -100,6 +101,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
.AddUmbracoOptions<CacheSettings>()
|
||||
.AddUmbracoOptions<SystemDateMigrationSettings>()
|
||||
.AddUmbracoOptions<DistributedJobSettings>()
|
||||
.AddUmbracoOptions<ScheduledPublishingSettings>(options => options.ValidateOnStart())
|
||||
.AddUmbracoOptions<BackOfficeTokenCookieSettings>()
|
||||
.AddUmbracoOptions<WebsiteSettings>()
|
||||
.AddUmbracoOptions<SignalRSettings>();
|
||||
|
||||
@@ -402,6 +402,7 @@
|
||||
<key alias="invalidMediaType">The chosen media type is invalid.</key>
|
||||
<key alias="invalidContentType">The chosen content is of invalid type.</key>
|
||||
<key alias="missingContent">The chosen content does not exist.</key>
|
||||
<key alias="missingMedia">The chosen media does not exist.</key>
|
||||
<key alias="multipleMediaNotAllowed">Multiple selected media is not allowed.</key>
|
||||
<key alias="notOneOfOptions">The value '%0%' is not one of the available options.</key>
|
||||
<key alias="multipleNotOneOfOptions">The values '%0%' are not found in the the available options.</key>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -24,4 +24,9 @@ public enum TaggableObjectTypes
|
||||
/// Represents member entities (user accounts).
|
||||
/// </summary>
|
||||
Member,
|
||||
|
||||
/// <summary>
|
||||
/// Represents element entities.
|
||||
/// </summary>
|
||||
Element,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
|
||||
namespace Umbraco.Cms.Core.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// A notification that is used to trigger the Element Container Cache Refresher.
|
||||
/// </summary>
|
||||
public class ElementContainerCacheRefresherNotification : CacheRefresherNotification
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresherNotification"/> class.
|
||||
/// </summary>
|
||||
/// <param name="messageObject">The refresher payload.</param>
|
||||
/// <param name="messageType">Type of the cache refresher message, <see cref="MessageType"/>.</param>
|
||||
public ElementContainerCacheRefresherNotification(object messageObject, MessageType messageType)
|
||||
: base(messageObject, messageType)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Parses the comma-separated content type keys stored in a picker's "allowed content types" configuration value
|
||||
/// (e.g. <see cref="ContentPickerConfiguration.AllowedContentTypeIds"/> or <see cref="ElementPickerConfiguration.AllowedContentTypeIds"/>).
|
||||
/// </summary>
|
||||
internal static class AllowedContentTypeKeysParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses the configured value into the set of allowed content type keys.
|
||||
/// </summary>
|
||||
/// <param name="configValue">The comma-separated configuration value. Non-GUID entries are ignored.</param>
|
||||
/// <returns>The set of allowed content type keys, or an empty set when nothing is configured.</returns>
|
||||
public static HashSet<Guid> Parse(string? configValue)
|
||||
{
|
||||
if (configValue.IsNullOrWhiteSpace())
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new HashSet<Guid>();
|
||||
foreach (var entry in configValue.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (Guid.TryParse(entry, out Guid guid))
|
||||
{
|
||||
result.Add(guid);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,10 @@ public class ContentPickerConfiguration : IIgnoreUserStartNodesConfig
|
||||
/// <inheritdoc />
|
||||
[ConfigurationField(Constants.DataTypes.ReservedPreValueKeys.IgnoreUserStartNodes)]
|
||||
public bool IgnoreUserStartNodes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the content type filter for allowed selections.
|
||||
/// </summary>
|
||||
[ConfigurationField("allowedContentTypes")]
|
||||
public string? AllowedContentTypeIds { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Editors;
|
||||
using Umbraco.Cms.Core.Models.Validation;
|
||||
using Umbraco.Cms.Core.PropertyEditors.Validation;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Strings;
|
||||
@@ -70,13 +74,21 @@ public class ContentPickerPropertyEditor : DataEditor, IValueSchemaProvider
|
||||
/// <param name="jsonSerializer">The JSON serializer.</param>
|
||||
/// <param name="ioHelper">The IO helper.</param>
|
||||
/// <param name="attribute">The data editor attribute.</param>
|
||||
/// <param name="coreScopeProvider">The core scope provider.</param>
|
||||
/// <param name="contentService">The content service.</param>
|
||||
/// <param name="localizedTextService">The localized text service.</param>
|
||||
public ContentPickerPropertyValueEditor(
|
||||
IShortStringHelper shortStringHelper,
|
||||
IJsonSerializer jsonSerializer,
|
||||
IIOHelper ioHelper,
|
||||
DataEditorAttribute attribute)
|
||||
DataEditorAttribute attribute,
|
||||
ICoreScopeProvider coreScopeProvider,
|
||||
IContentService contentService,
|
||||
ILocalizedTextService localizedTextService)
|
||||
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
|
||||
{
|
||||
Validators.Add(new TypedValidatorRunner<string, ContentPickerConfiguration>(
|
||||
new AllowedTypeValidator(localizedTextService, contentService, coreScopeProvider)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -134,4 +146,61 @@ public class ContentPickerPropertyEditor : DataEditor, IValueSchemaProvider
|
||||
return guidUdi.Guid;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the selected content matches the allowed content types configured for the property editor.
|
||||
/// </summary>
|
||||
/// <param name="localizedTextService">The localized text service.</param>
|
||||
/// <param name="contentService">The content service.</param>
|
||||
/// <param name="coreScopeProvider">The core scope provider.</param>
|
||||
internal sealed class AllowedTypeValidator(ILocalizedTextService localizedTextService, IContentService contentService, ICoreScopeProvider coreScopeProvider)
|
||||
: ITypedValidator<string, ContentPickerConfiguration>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<ValidationResult> Validate(
|
||||
string? value,
|
||||
ContentPickerConfiguration? configuration,
|
||||
string? valueType,
|
||||
PropertyValidationContext validationContext)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) ||
|
||||
configuration is null ||
|
||||
Guid.TryParse(value, out Guid id) is false)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
HashSet<Guid> allowedContentTypeKeys = AllowedContentTypeKeysParser.Parse(configuration.AllowedContentTypeIds);
|
||||
|
||||
// No filter configured — all content types are allowed.
|
||||
if (allowedContentTypeKeys.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
using ICoreScope scope = coreScopeProvider.CreateCoreScope();
|
||||
Guid? key = contentService.GetById(id)?.ContentType?.Key;
|
||||
scope.Complete();
|
||||
|
||||
if (key is null)
|
||||
{
|
||||
return [new ValidationResult(
|
||||
localizedTextService.Localize(
|
||||
"validation",
|
||||
"missingContent"),
|
||||
["value"])];
|
||||
}
|
||||
|
||||
if (allowedContentTypeKeys.Contains(key.Value) is false)
|
||||
{
|
||||
return [new ValidationResult(
|
||||
localizedTextService.Localize(
|
||||
"validation",
|
||||
"invalidObjectType"),
|
||||
["value"])];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,32 @@ public class ElementPickerConfiguration : IIgnoreUserStartNodesConfig
|
||||
/// <inheritdoc />
|
||||
[ConfigurationField(Constants.DataTypes.ReservedPreValueKeys.IgnoreUserStartNodes)]
|
||||
public bool IgnoreUserStartNodes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the validation limits for the number of elements allowed.
|
||||
/// </summary>
|
||||
[ConfigurationField("validationLimit")]
|
||||
public NumberRange? ValidationLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the content type filter for allowed selections.
|
||||
/// </summary>
|
||||
[ConfigurationField("allowedContentTypes")]
|
||||
public string? AllowedContentTypeIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Represents a numeric range with optional minimum and maximum values.
|
||||
/// </summary>
|
||||
public class NumberRange
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum value of the range.
|
||||
/// </summary>
|
||||
public int? Min { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum value of the range.
|
||||
/// </summary>
|
||||
public int? Max { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Editors;
|
||||
using Umbraco.Cms.Core.Models.Validation;
|
||||
using Umbraco.Cms.Core.PropertyEditors.Validation;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Strings;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Element picker property editor that stores element keys
|
||||
/// Element picker property editor that stores element keys.
|
||||
/// </summary>
|
||||
[DataEditor(
|
||||
Constants.PropertyEditors.Aliases.ElementPicker,
|
||||
@@ -17,6 +23,11 @@ public class ElementPickerPropertyEditor : DataEditor
|
||||
{
|
||||
private readonly IIOHelper _ioHelper;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ElementPickerPropertyEditor" /> class.
|
||||
/// </summary>
|
||||
/// <param name="dataValueEditorFactory">The data value editor factory.</param>
|
||||
/// <param name="ioHelper">The IO helper.</param>
|
||||
public ElementPickerPropertyEditor(IDataValueEditorFactory dataValueEditorFactory, IIOHelper ioHelper)
|
||||
: base(dataValueEditorFactory)
|
||||
{
|
||||
@@ -28,21 +39,44 @@ public class ElementPickerPropertyEditor : DataEditor
|
||||
protected override IConfigurationEditor CreateConfigurationEditor() =>
|
||||
new ElementPickerConfigurationEditor(_ioHelper);
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override IDataValueEditor CreateValueEditor() =>
|
||||
DataValueEditorFactory.Create<ElementPickerPropertyValueEditor>(Attribute!);
|
||||
|
||||
/// <summary>
|
||||
/// Provides the value editor for the element picker property editor.
|
||||
/// </summary>
|
||||
internal sealed class ElementPickerPropertyValueEditor : DataValueEditor, IDataValueReference
|
||||
{
|
||||
private readonly IJsonSerializer _jsonSerializer;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ElementPickerPropertyValueEditor" /> class.
|
||||
/// </summary>
|
||||
/// <param name="shortStringHelper">The short string helper.</param>
|
||||
/// <param name="jsonSerializer">The JSON serializer.</param>
|
||||
/// <param name="ioHelper">The IO helper.</param>
|
||||
/// <param name="attribute">The data editor attribute.</param>
|
||||
/// <param name="localizedTextService">The localized text service.</param>
|
||||
/// <param name="elementService">The element service.</param>
|
||||
/// <param name="coreScopeProvider">The core scope provider.</param>
|
||||
public ElementPickerPropertyValueEditor(
|
||||
IShortStringHelper shortStringHelper,
|
||||
IJsonSerializer jsonSerializer,
|
||||
IIOHelper ioHelper,
|
||||
DataEditorAttribute attribute)
|
||||
DataEditorAttribute attribute,
|
||||
ILocalizedTextService localizedTextService,
|
||||
IElementService elementService,
|
||||
ICoreScopeProvider coreScopeProvider)
|
||||
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
|
||||
=> _jsonSerializer = jsonSerializer;
|
||||
{
|
||||
_jsonSerializer = jsonSerializer;
|
||||
Validators.Add(new TypedValidatorRunner<List<string>, ElementPickerConfiguration>(
|
||||
new MinMaxValidator(localizedTextService),
|
||||
new AllowedTypeValidator(localizedTextService, elementService, coreScopeProvider)));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<UmbracoEntityReference> GetReferences(object? value)
|
||||
{
|
||||
var asString = value as string ?? value?.ToString();
|
||||
@@ -63,4 +97,144 @@ public class ElementPickerPropertyEditor : DataEditor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator to ensure that the number of selected elements is within the configured min/max limits, if any.
|
||||
/// </summary>
|
||||
internal sealed class MinMaxValidator : ITypedValidator<List<string>, ElementPickerConfiguration>
|
||||
{
|
||||
private readonly ILocalizedTextService _localizedTextService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MinMaxValidator" /> class.
|
||||
/// </summary>
|
||||
/// <param name="localizedTextService">The localized text service.</param>
|
||||
public MinMaxValidator(ILocalizedTextService localizedTextService)
|
||||
=> _localizedTextService = localizedTextService;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<ValidationResult> Validate(
|
||||
List<string>? value,
|
||||
ElementPickerConfiguration? configuration,
|
||||
string? valueType,
|
||||
PropertyValidationContext validationContext)
|
||||
{
|
||||
var validationResults = new List<ValidationResult>();
|
||||
|
||||
if (configuration is null || configuration.ValidationLimit is null)
|
||||
{
|
||||
return validationResults;
|
||||
}
|
||||
|
||||
if (configuration.ValidationLimit.Min is int min and > 0 && (value is null || value.Count < min))
|
||||
{
|
||||
validationResults.Add(new ValidationResult(
|
||||
_localizedTextService.Localize(
|
||||
"validation",
|
||||
"entriesShort",
|
||||
[min.ToString(), (min - (value?.Count ?? 0)).ToString()]),
|
||||
["value"]));
|
||||
}
|
||||
|
||||
if (value is null)
|
||||
{
|
||||
return validationResults;
|
||||
}
|
||||
|
||||
if (configuration.ValidationLimit.Max is int max and > 0 && value.Count > max)
|
||||
{
|
||||
validationResults.Add(new ValidationResult(
|
||||
_localizedTextService.Localize(
|
||||
"validation",
|
||||
"entriesExceed",
|
||||
[max.ToString(), (value.Count - max).ToString()]),
|
||||
["value"]));
|
||||
}
|
||||
|
||||
return validationResults;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator to ensure that all selected elements are of an allowed content type, if any are configured.
|
||||
/// </summary>
|
||||
internal sealed class AllowedTypeValidator : ITypedValidator<List<string>, ElementPickerConfiguration>
|
||||
{
|
||||
private readonly ILocalizedTextService _localizedTextService;
|
||||
private readonly IElementService _elementService;
|
||||
private readonly ICoreScopeProvider _coreScopeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AllowedTypeValidator" /> class.
|
||||
/// </summary>
|
||||
/// <param name="localizedTextService">The localized text service.</param>
|
||||
/// <param name="elementService">The element service.</param>
|
||||
/// <param name="coreScopeProvider">The core scope provider.</param>
|
||||
public AllowedTypeValidator(
|
||||
ILocalizedTextService localizedTextService,
|
||||
IElementService elementService,
|
||||
ICoreScopeProvider coreScopeProvider)
|
||||
{
|
||||
_localizedTextService = localizedTextService;
|
||||
_elementService = elementService;
|
||||
_coreScopeProvider = coreScopeProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<ValidationResult> Validate(
|
||||
List<string>? value,
|
||||
ElementPickerConfiguration? configuration,
|
||||
string? valueType,
|
||||
PropertyValidationContext validationContext)
|
||||
{
|
||||
if (value is null || value.Count == 0 || configuration is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
HashSet<Guid> allowedContentTypeKeys = AllowedContentTypeKeysParser.Parse(configuration.AllowedContentTypeIds);
|
||||
|
||||
// No filter configured — all element types are allowed.
|
||||
if (allowedContentTypeKeys.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
Guid[] elementIds = value
|
||||
.Where(v => Guid.TryParse(v, out _))
|
||||
.Select(Guid.Parse)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
|
||||
IElement[] elements = _elementService.GetByIds(elementIds).ToArray();
|
||||
scope.Complete();
|
||||
|
||||
// Compare against the distinct requested keys (not the raw value count, which may include
|
||||
// duplicates or non-GUID entries) so existing elements aren't incorrectly reported as missing.
|
||||
if (elements.Length != elementIds.Length)
|
||||
{
|
||||
return [
|
||||
new ValidationResult(
|
||||
_localizedTextService.Localize("validation", "missingContent"),
|
||||
["value"])
|
||||
];
|
||||
}
|
||||
|
||||
foreach (IElement element in elements)
|
||||
{
|
||||
if (allowedContentTypeKeys.Contains(element.ContentType.Key) is false)
|
||||
{
|
||||
return
|
||||
[
|
||||
new ValidationResult(
|
||||
_localizedTextService.Localize("validation", "invalidObjectType"),
|
||||
["value"])
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ internal sealed class EntityDataPickerPropertyEditor : DataEditor
|
||||
/// <summary>
|
||||
/// Validates the min/max configuration for the entity data picker property editor.
|
||||
/// </summary>
|
||||
internal sealed class MinMaxValidator : ITypedJsonValidator<EntityDataPickerDto, EntityDataPickerConfiguration>
|
||||
internal sealed class MinMaxValidator : ITypedValidator<EntityDataPickerDto, EntityDataPickerConfiguration>
|
||||
{
|
||||
private readonly ILocalizedTextService _localizedTextService;
|
||||
|
||||
|
||||
@@ -9,17 +9,13 @@ namespace Umbraco.Cms.Core.PropertyEditors.Validation;
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The type of the value consumed by the validator.</typeparam>
|
||||
/// <typeparam name="TConfiguration">The type of the configuration consumed by validator.</typeparam>
|
||||
public interface ITypedJsonValidator<TValue, TConfiguration>
|
||||
[Obsolete("Use ITypedValidator instead; the validator contract is not JSON-specific. Scheduled for removal in Umbraco 20.")]
|
||||
public interface ITypedJsonValidator<TValue, TConfiguration> : ITypedValidator<TValue, TConfiguration>
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the specified value against the configuration.
|
||||
/// </summary>
|
||||
/// <param name="value">The deserialized value to validate.</param>
|
||||
/// <param name="configuration">The data type configuration.</param>
|
||||
/// <param name="valueType">The value type.</param>
|
||||
/// <param name="validationContext">The property validation context.</param>
|
||||
/// <returns>A collection of validation results.</returns>
|
||||
public abstract IEnumerable<ValidationResult> Validate(
|
||||
// Re-declared (rather than purely inherited from ITypedValidator) so the ITypedJsonValidator.Validate member
|
||||
// remains present for binary compatibility with consumers compiled against this interface in v15-v17.
|
||||
// TODO (V20): remove together with this interface.
|
||||
new IEnumerable<ValidationResult> Validate(
|
||||
TValue? value,
|
||||
TConfiguration? configuration,
|
||||
string? valueType,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Umbraco.Cms.Core.Models.Validation;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// A validator that operates on an already-typed value and configuration.
|
||||
/// <remarks>
|
||||
/// Used together with an <see cref="IValueValidator"/> runner that materializes the typed value: see
|
||||
/// <see cref="TypedValidatorRunner{TValue,TConfiguration}"/> for value editors whose value is already typed, and
|
||||
/// <see cref="TypedJsonValidatorRunner{TValue,TConfiguration}"/> for JSON based value editors, where the value is deserialized once before validation.
|
||||
/// </remarks>
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The type of the value consumed by the validator.</typeparam>
|
||||
/// <typeparam name="TConfiguration">The type of the configuration consumed by validator.</typeparam>
|
||||
public interface ITypedValidator<TValue, TConfiguration>
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the specified value against the configuration.
|
||||
/// </summary>
|
||||
/// <param name="value">The typed value to validate.</param>
|
||||
/// <param name="configuration">The data type configuration.</param>
|
||||
/// <param name="valueType">The value type.</param>
|
||||
/// <param name="validationContext">The property validation context.</param>
|
||||
/// <returns>A collection of validation results.</returns>
|
||||
IEnumerable<ValidationResult> Validate(
|
||||
TValue? value,
|
||||
TConfiguration? configuration,
|
||||
string? valueType,
|
||||
PropertyValidationContext validationContext);
|
||||
}
|
||||
@@ -6,26 +6,47 @@ namespace Umbraco.Cms.Core.PropertyEditors.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// <para>
|
||||
/// An aggregate validator for JSON based value editors, to avoid doing multiple deserialization.
|
||||
/// An aggregate <see cref="IValueValidator"/> for JSON based value editors. Deserializes the editor value into
|
||||
/// <typeparamref name="TValue"/> once (avoiding repeated deserialization), casts the configuration once, and passes both
|
||||
/// to each <see cref="ITypedValidator{TValue,TConfiguration}"/>, aggregating the results.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Will deserialize once, and cast the configuration once, and pass those values to each <see cref="ITypedJsonValidator{TValue,TConfiguration}"/>, aggregating the results.
|
||||
/// Use this runner when the editor value reaching validation is raw JSON that must be deserialized before validation —
|
||||
/// typically an array of complex objects, such as a media picker storing crop data, which the backoffice JSON object
|
||||
/// converter leaves as un-typed JSON nodes rather than a typed CLR value.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When the editor value is already the typed CLR value (so only a cast is needed, with no deserialization) use
|
||||
/// <see cref="TypedValidatorRunner{TValue,TConfiguration}"/> instead. That is the only difference between the two runners:
|
||||
/// this one deserializes, the other casts.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The type of the expected value.</typeparam>
|
||||
/// <typeparam name="TConfiguration">The type of the expected configuration</typeparam>
|
||||
/// <seealso cref="TypedValidatorRunner{TValue,TConfiguration}"/>
|
||||
public class TypedJsonValidatorRunner<TValue, TConfiguration> : IValueValidator
|
||||
where TValue : class
|
||||
{
|
||||
private readonly IJsonSerializer _jsonSerializer;
|
||||
private readonly ITypedJsonValidator<TValue, TConfiguration>[] _validators;
|
||||
private readonly ITypedValidator<TValue, TConfiguration>[] _validators;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TypedJsonValidatorRunner{TValue, TConfiguration}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="jsonSerializer">The JSON serializer.</param>
|
||||
/// <param name="validators">The collection of validators to run.</param>
|
||||
[Obsolete("Use the constructor accepting ITypedValidator instances. Scheduled for removal in Umbraco 20.")]
|
||||
public TypedJsonValidatorRunner(IJsonSerializer jsonSerializer, params ITypedJsonValidator<TValue, TConfiguration>[] validators)
|
||||
: this(jsonSerializer, (ITypedValidator<TValue, TConfiguration>[])validators)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TypedJsonValidatorRunner{TValue, TConfiguration}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="jsonSerializer">The JSON serializer.</param>
|
||||
/// <param name="validators">The collection of validators to run.</param>
|
||||
public TypedJsonValidatorRunner(IJsonSerializer jsonSerializer, params ITypedValidator<TValue, TConfiguration>[] validators)
|
||||
{
|
||||
_jsonSerializer = jsonSerializer;
|
||||
_validators = validators;
|
||||
@@ -51,7 +72,7 @@ public class TypedJsonValidatorRunner<TValue, TConfiguration> : IValueValidator
|
||||
return validationResults;
|
||||
}
|
||||
|
||||
foreach (ITypedJsonValidator<TValue, TConfiguration> validator in _validators)
|
||||
foreach (ITypedValidator<TValue, TConfiguration> validator in _validators)
|
||||
{
|
||||
validationResults.AddRange(validator.Validate(deserializedValue, configuration, valueType, validationContext));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Umbraco.Cms.Core.Models.Validation;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// <para>
|
||||
/// An aggregate <see cref="IValueValidator"/> that casts the editor value once and passes it, along with the cast
|
||||
/// configuration, to each <see cref="ITypedValidator{TValue,TConfiguration}"/>, aggregating the results.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Use this runner when the editor value reaching validation is already the typed CLR value (<typeparamref name="TValue"/>),
|
||||
/// so a cast is all that is needed — for example a content picker (value is a <see cref="string"/>) or an element picker
|
||||
/// (value is a <c>List<string></c>, since the backoffice JSON object converter resolves an array of scalars into a typed list).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When the editor value is instead raw JSON that must be deserialized into <typeparamref name="TValue"/> before validation —
|
||||
/// typically an array of complex objects, such as a media picker storing crop data — use <see cref="TypedJsonValidatorRunner{TValue,TConfiguration}"/>
|
||||
/// instead. That is the only difference between the two runners: this one casts, the other deserializes.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The type of the expected value.</typeparam>
|
||||
/// <typeparam name="TConfiguration">The type of the expected configuration.</typeparam>
|
||||
/// <seealso cref="TypedJsonValidatorRunner{TValue,TConfiguration}"/>
|
||||
public class TypedValidatorRunner<TValue, TConfiguration> : IValueValidator
|
||||
where TValue : class
|
||||
{
|
||||
private readonly ITypedValidator<TValue, TConfiguration>[] _validators;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TypedValidatorRunner{TValue, TConfiguration}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="validators">The collection of validators to run.</param>
|
||||
public TypedValidatorRunner(params ITypedValidator<TValue, TConfiguration>[] validators)
|
||||
=> _validators = validators;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<ValidationResult> Validate(
|
||||
object? value,
|
||||
string? valueType,
|
||||
object? dataTypeConfiguration,
|
||||
PropertyValidationContext validationContext)
|
||||
{
|
||||
if (dataTypeConfiguration is not TConfiguration configuration)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (value is not null and not TValue)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var typedValue = value as TValue;
|
||||
|
||||
return _validators
|
||||
.SelectMany(v => v.Validate(typedValue, configuration, valueType, validationContext))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -103,11 +103,19 @@ internal sealed class ContentEditingService
|
||||
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(
|
||||
ContentCreateModel createModel,
|
||||
Guid userKey)
|
||||
=> await ValidateCulturesAndPropertiesAsync(
|
||||
{
|
||||
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
|
||||
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
|
||||
{
|
||||
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
|
||||
}
|
||||
|
||||
return await ValidateCulturesAndPropertiesAsync(
|
||||
createModel,
|
||||
createModel.ContentTypeKey,
|
||||
createModel.Variants.Select(variant => variant.Culture),
|
||||
userKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Attempt<ContentCreateResult, ContentEditingOperationStatus>> CreateAsync(ContentCreateModel createModel, Guid userKey)
|
||||
@@ -211,6 +219,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<ContentEditingOperationStatus> UpdateTemplateAsync(IContent content, Guid? templateKey)
|
||||
{
|
||||
if (templateKey == null)
|
||||
@@ -258,8 +275,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)
|
||||
@@ -268,6 +285,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
|
||||
|
||||
@@ -500,6 +500,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,
|
||||
|
||||
@@ -661,6 +665,25 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
|
||||
return filteredContentTypes.Any();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that content of the requested type is allowed to be created under the requested parent, applying the
|
||||
/// same "allowed at root", "allowed as child" and content type filter rules that are enforced when the content is
|
||||
/// actually created. This allows the validation endpoints to be consistent with creation.
|
||||
/// </summary>
|
||||
/// <param name="createModel">The content creation model.</param>
|
||||
/// <returns>The operation status; <see cref="ContentEditingOperationStatus.Success"/> when creation is allowed.</returns>
|
||||
protected async Task<ContentEditingOperationStatus> ValidateCreationAllowedAsync(ContentCreationModelBase createModel)
|
||||
{
|
||||
TContentType? contentType = ContentTypeService.Get(createModel.ContentTypeKey);
|
||||
if (contentType is null)
|
||||
{
|
||||
return ContentEditingOperationStatus.ContentTypeNotFound;
|
||||
}
|
||||
|
||||
(int? _, ContentEditingOperationStatus operationStatus) = await TryGetAndValidateParentIdAsync(createModel.ParentKey, contentType);
|
||||
return operationStatus;
|
||||
}
|
||||
|
||||
private void UpdateNames(ContentEditingModelBase contentEditingModelBase, TContent content, TContentType contentType)
|
||||
{
|
||||
if (contentType.VariesByCulture())
|
||||
|
||||
@@ -90,9 +90,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.
|
||||
@@ -115,16 +116,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
|
||||
{
|
||||
@@ -142,4 +134,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."),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1652,7 +1652,13 @@ public class ContentService : PublishableContentServiceBase<IContent>, IContentS
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -1690,6 +1696,43 @@ public class ContentService : PublishableContentServiceBase<IContent>, IContentS
|
||||
}
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -418,6 +418,22 @@ public interface IContentService : IPublishableContentService<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>
|
||||
|
||||
@@ -338,6 +338,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.
|
||||
|
||||
@@ -54,6 +54,18 @@ public interface ITagService : IService
|
||||
/// </summary>
|
||||
IEnumerable<TaggedEntity> GetTaggedMembersByTag(string tag, string? group = null, string? culture = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all elements tagged with any tag in the specified group.
|
||||
/// </summary>
|
||||
// TODO (V19): Remove the default implementation from this interface.
|
||||
IEnumerable<TaggedEntity> GetTaggedElementsByTagGroup(string group, string? culture = null) => [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all elements tagged with the specified tag.
|
||||
/// </summary>
|
||||
// TODO (V19): Remove the default implementation from this interface.
|
||||
IEnumerable<TaggedEntity> GetTaggedElementsByTag(string tag, string? group = null, string? culture = null) => [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all tags.
|
||||
/// </summary>
|
||||
@@ -100,6 +112,12 @@ public interface ITagService : IService
|
||||
/// </summary>
|
||||
IEnumerable<ITag> GetAllMemberTags(string? group = null, string? culture = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all element tags.
|
||||
/// </summary>
|
||||
// TODO (V19): Remove the default implementation from this interface.
|
||||
IEnumerable<ITag> GetAllElementTags(string? group = null, string? culture = null) => [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all tags attached to an entity via a property.
|
||||
/// </summary>
|
||||
|
||||
@@ -87,7 +87,15 @@ internal sealed class MediaEditingService
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(MediaCreateModel createModel)
|
||||
=> await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
|
||||
{
|
||||
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
|
||||
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
|
||||
{
|
||||
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
|
||||
}
|
||||
|
||||
return await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Attempt<MediaCreateResult, ContentEditingOperationStatus>> CreateAsync(MediaCreateModel createModel, Guid userKey)
|
||||
@@ -169,6 +177,13 @@ 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);
|
||||
@@ -191,8 +206,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)
|
||||
@@ -203,6 +218,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>
|
||||
|
||||
@@ -1309,6 +1309,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))
|
||||
{
|
||||
@@ -1347,6 +1356,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>
|
||||
|
||||
@@ -101,6 +101,24 @@ public class TagService : RepositoryService, ITagService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TaggedEntity> GetTaggedElementsByTagGroup(string group, string? culture = null)
|
||||
{
|
||||
using (ScopeProvider.CreateCoreScope(autoComplete: true))
|
||||
{
|
||||
return _tagRepository.GetTaggedEntitiesByTagGroup(TaggableObjectTypes.Element, group, culture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TaggedEntity> GetTaggedElementsByTag(string tag, string? group = null, string? culture = null)
|
||||
{
|
||||
using (ScopeProvider.CreateCoreScope(autoComplete: true))
|
||||
{
|
||||
return _tagRepository.GetTaggedEntitiesByTag(TaggableObjectTypes.Element, tag, group, culture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ITag> GetAllTags(string? group = null, string? culture = null)
|
||||
{
|
||||
@@ -162,6 +180,15 @@ public class TagService : RepositoryService, ITagService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ITag> GetAllElementTags(string? group = null, string? culture = null)
|
||||
{
|
||||
using (ScopeProvider.CreateCoreScope(autoComplete: true))
|
||||
{
|
||||
return _tagRepository.GetTagsForEntityType(TaggableObjectTypes.Element, group, culture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ITag> GetTagsForProperty(int contentId, string propertyTypeAlias, string? group = null, string? culture = null)
|
||||
{
|
||||
|
||||
@@ -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.Models;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
@@ -23,7 +25,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;
|
||||
@@ -33,6 +38,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.
|
||||
@@ -44,7 +50,8 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
|
||||
ILogger<ScheduledPublishingJob> logger,
|
||||
IServerMessenger serverMessenger,
|
||||
ICoreScopeProvider scopeProvider,
|
||||
TimeProvider timeProvider)
|
||||
TimeProvider timeProvider,
|
||||
IOptionsMonitor<ScheduledPublishingSettings> scheduledPublishingSettings)
|
||||
{
|
||||
_contentService = contentService;
|
||||
_elementService = elementService;
|
||||
@@ -53,6 +60,7 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
|
||||
_serverMessenger = serverMessenger;
|
||||
_scopeProvider = scopeProvider;
|
||||
_timeProvider = timeProvider;
|
||||
_scheduledPublishingSettings = scheduledPublishingSettings;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
|
||||
+58
-7
@@ -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,14 +75,23 @@ 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();
|
||||
@@ -99,18 +112,56 @@ public class TouchServerJob : RecurringBackgroundJobBase
|
||||
_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 />
|
||||
|
||||
@@ -466,6 +466,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
.AddNotificationHandler<MemberTypeChangedNotification, MemberTypeChangedDistributedCacheNotificationHandler>()
|
||||
.AddNotificationHandler<ContentTreeChangeNotification, ContentTreeChangeDistributedCacheNotificationHandler>()
|
||||
.AddNotificationHandler<ElementTreeChangeNotification, ElementTreeChangeDistributedCacheNotificationHandler>()
|
||||
.AddNotificationHandler<EntityContainerDeletedNotification, ElementContainerDeletedDistributedCacheNotificationHandler>()
|
||||
;
|
||||
|
||||
// add notification handlers for auditing
|
||||
|
||||
@@ -91,7 +91,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
builder.AddNotificationHandler<ExternalMemberCacheRefresherNotification, ExternalMemberIndexingNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<LanguageCacheRefresherNotification, LanguageIndexingNotificationHandler>();
|
||||
|
||||
builder.AddNotificationHandler<UmbracoRequestBeginNotification, RebuildOnStartupHandler>();
|
||||
builder.AddNotificationAsyncHandler<UmbracoApplicationStartedNotification, RebuildOnStartedHandler>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -170,13 +170,7 @@ public class ContentIndexPopulator : IndexPopulator<IUmbracoContentIndex>
|
||||
{
|
||||
content = _contentService.GetPagedDescendants(contentParentId, pageIndex, pageSize, out _).ToArray();
|
||||
|
||||
var valueSets = _contentValueSetBuilder.GetValueSets(content).ToArray();
|
||||
|
||||
// ReSharper disable once PossibleMultipleEnumeration
|
||||
foreach (IIndex index in indexes)
|
||||
{
|
||||
index.IndexItems(valueSets);
|
||||
}
|
||||
ValueSetIndexer.IndexItems(indexes, _contentValueSetBuilder.GetValueSets(content));
|
||||
|
||||
pageIndex++;
|
||||
}
|
||||
@@ -216,12 +210,7 @@ public class ContentIndexPopulator : IndexPopulator<IUmbracoContentIndex>
|
||||
}
|
||||
}
|
||||
|
||||
var valueSets = _contentValueSetBuilder.GetValueSets(indexableContent.ToArray()).ToArray();
|
||||
|
||||
foreach (IIndex index in indexes)
|
||||
{
|
||||
index.IndexItems(valueSets);
|
||||
}
|
||||
ValueSetIndexer.IndexItems(indexes, _contentValueSetBuilder.GetValueSets(indexableContent.ToArray()));
|
||||
|
||||
pageIndex++;
|
||||
}
|
||||
|
||||
@@ -49,13 +49,7 @@ internal sealed class DeliveryApiContentIndexPopulator : IndexPopulator
|
||||
_deliveryApiContentIndexHelper.EnumerateApplicableDescendantsForContentIndex(
|
||||
Constants.System.Root,
|
||||
descendants =>
|
||||
{
|
||||
ValueSet[] valueSets = _deliveryContentIndexValueSetBuilder.GetValueSets(descendants).ToArray();
|
||||
foreach (IIndex index in indexes)
|
||||
{
|
||||
index.IndexItems(valueSets);
|
||||
}
|
||||
});
|
||||
ValueSetIndexer.IndexItems(indexes, _deliveryContentIndexValueSetBuilder.GetValueSets(descendants)));
|
||||
}
|
||||
|
||||
public override bool IsRegistered(IIndex index)
|
||||
|
||||
@@ -107,11 +107,7 @@ public class MediaIndexPopulator : IndexPopulator<IUmbracoContentIndex>
|
||||
{
|
||||
media = _mediaService.GetPagedDescendants(mediaParentId, pageIndex, _indexingSettings.BatchSize, out _).ToArray();
|
||||
|
||||
// ReSharper disable once PossibleMultipleEnumeration
|
||||
foreach (IIndex index in indexes)
|
||||
{
|
||||
index.IndexItems(_mediaValueSetBuilder.GetValueSets(media));
|
||||
}
|
||||
ValueSetIndexer.IndexItems(indexes, _mediaValueSetBuilder.GetValueSets(media));
|
||||
|
||||
pageIndex++;
|
||||
}
|
||||
|
||||
@@ -41,11 +41,7 @@ public class MemberIndexPopulator : IndexPopulator<IUmbracoMemberIndex>
|
||||
{
|
||||
members = _memberService.GetAll(pageIndex, pageSize, out _).ToArray();
|
||||
|
||||
// ReSharper disable once PossibleMultipleEnumeration
|
||||
foreach (IIndex index in indexes)
|
||||
{
|
||||
index.IndexItems(_valueSetBuilder.GetValueSets(members));
|
||||
}
|
||||
ValueSetIndexer.IndexItems(indexes, _valueSetBuilder.GetValueSets(members));
|
||||
|
||||
pageIndex++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.Examine;
|
||||
|
||||
/// <summary>
|
||||
/// Handles how the indexes are rebuilt after startup.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Once the application has fully started this rebuilds the Examine indexes if they are empty.
|
||||
/// If it is a cold boot, they are all rebuilt.
|
||||
/// </remarks>
|
||||
public sealed class RebuildOnStartedHandler : INotificationAsyncHandler<UmbracoApplicationStartedNotification>
|
||||
{
|
||||
// The notification is published again on restart, but the indexes only need to be
|
||||
// considered for rebuilding once per application lifetime.
|
||||
private static int _hasRun;
|
||||
|
||||
private readonly ISyncBootStateAccessor _syncBootStateAccessor;
|
||||
private readonly IIndexRebuilder _indexRebuilder;
|
||||
private readonly IRuntimeState _runtimeState;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Umbraco.Cms.Infrastructure.Examine.RebuildOnStartedHandler"/> class, responsible for handling index rebuilds during application startup.
|
||||
/// </summary>
|
||||
/// <param name="syncBootStateAccessor">Provides access to the application's synchronous boot state, used to determine if the system is ready for index rebuilding.</param>
|
||||
/// <param name="indexRebuilder">The service responsible for rebuilding Examine indexes.</param>
|
||||
/// <param name="runtimeState">Provides information about the current runtime state of the Umbraco application.</param>
|
||||
public RebuildOnStartedHandler(
|
||||
ISyncBootStateAccessor syncBootStateAccessor,
|
||||
IIndexRebuilder indexRebuilder,
|
||||
IRuntimeState runtimeState)
|
||||
{
|
||||
_syncBootStateAccessor = syncBootStateAccessor;
|
||||
_indexRebuilder = indexRebuilder;
|
||||
_runtimeState = runtimeState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Once the application has fully started, schedule an index rebuild for any empty indexes (or all if it's a cold boot).
|
||||
/// </summary>
|
||||
/// <param name="notification">The notification.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
public async Task HandleAsync(UmbracoApplicationStartedNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_runtimeState.Level != RuntimeLevel.Run)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Interlocked.CompareExchange(ref _hasRun, 1, 0) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SyncBootState bootState = _syncBootStateAccessor.GetSyncBootState();
|
||||
|
||||
// if it's not a cold boot, only rebuild empty ones
|
||||
await _indexRebuilder.RebuildIndexesAsync(
|
||||
bootState != SyncBootState.ColdBoot,
|
||||
TimeSpan.FromMinutes(1));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ namespace Umbraco.Cms.Infrastructure.Examine;
|
||||
/// On the first HTTP request this will rebuild the Examine indexes if they are empty.
|
||||
/// If it is a cold boot, they are all rebuilt.
|
||||
/// </remarks>
|
||||
[Obsolete("Superseded by RebuildOnStartedHandler. Scheduled for removal in Umbraco 19.")]
|
||||
public sealed class RebuildOnStartupHandler : INotificationHandler<UmbracoRequestBeginNotification>
|
||||
{
|
||||
// These must be static because notification handlers are transient.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using Examine;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.Examine;
|
||||
|
||||
/// <summary>
|
||||
/// Writes a batch of <see cref="ValueSet" />s to one or more indexes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When a single index is registered the value sets are streamed straight through, so a lazily-built
|
||||
/// sequence is enumerated once and never fully materialised in memory — keeping the common single-index
|
||||
/// rebuild's peak memory down. When multiple indexes are registered the sequence is materialised once and
|
||||
/// reused, so the (potentially expensive) value sets are not rebuilt per index.
|
||||
/// </remarks>
|
||||
internal static class ValueSetIndexer
|
||||
{
|
||||
public static void IndexItems(IReadOnlyList<IIndex> indexes, IEnumerable<ValueSet> valueSets)
|
||||
{
|
||||
switch (indexes.Count)
|
||||
{
|
||||
case 0:
|
||||
return;
|
||||
case 1:
|
||||
indexes[0].IndexItems(valueSets);
|
||||
return;
|
||||
default:
|
||||
ValueSet[] materialized = valueSets as ValueSet[] ?? valueSets.ToArray();
|
||||
foreach (IIndex index in indexes)
|
||||
{
|
||||
index.IndexItems(materialized);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,12 +107,15 @@ public class PackageMigrationRunner
|
||||
}
|
||||
|
||||
/// <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
@@ -1273,6 +1273,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>
|
||||
|
||||
@@ -647,6 +647,8 @@ ON (tagset.tag = {cmsTags}.tag AND tagset.{group} = {cmsTags}.{group} AND COALES
|
||||
return Constants.ObjectTypes.Media;
|
||||
case TaggableObjectTypes.Member:
|
||||
return Constants.ObjectTypes.Member;
|
||||
case TaggableObjectTypes.Element:
|
||||
return Constants.ObjectTypes.Element;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(type));
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ public abstract class DateTimePropertyEditorBase : DataEditor, IValueSchemaProvi
|
||||
/// <summary>
|
||||
/// Validates the date time selection for the DateTime2 property editor.
|
||||
/// </summary>
|
||||
private class DateTimeValidator : ITypedJsonValidator<DateTimeEditorValue, DateTimeConfiguration>
|
||||
private class DateTimeValidator : ITypedValidator<DateTimeEditorValue, DateTimeConfiguration>
|
||||
{
|
||||
private readonly ILocalizedTextService _localizedTextService;
|
||||
|
||||
|
||||
@@ -511,7 +511,7 @@ public class MediaPicker3PropertyEditor : DataEditor, IValueSchemaProvider
|
||||
/// <summary>
|
||||
/// Validates the min/max configuration for the media picker property editor.
|
||||
/// </summary>
|
||||
internal sealed class MinMaxValidator : ITypedJsonValidator<List<MediaWithCropsDto>, MediaPicker3Configuration>
|
||||
internal sealed class MinMaxValidator : ITypedValidator<List<MediaWithCropsDto>, MediaPicker3Configuration>
|
||||
{
|
||||
private readonly ILocalizedTextService _localizedTextService;
|
||||
|
||||
@@ -573,7 +573,7 @@ public class MediaPicker3PropertyEditor : DataEditor, IValueSchemaProvider
|
||||
/// <summary>
|
||||
/// Validates the allowed type configuration for the media picker property editor.
|
||||
/// </summary>
|
||||
internal sealed class AllowedTypeValidator : ITypedJsonValidator<List<MediaWithCropsDto>, MediaPicker3Configuration>
|
||||
internal sealed class AllowedTypeValidator : ITypedValidator<List<MediaWithCropsDto>, MediaPicker3Configuration>
|
||||
{
|
||||
private readonly ILocalizedTextService _localizedTextService;
|
||||
private readonly IMediaService _mediaService;
|
||||
@@ -615,10 +615,26 @@ public class MediaPicker3PropertyEditor : DataEditor, IValueSchemaProvider
|
||||
.Where(x => x.MediaTypeAlias.IsNullOrWhiteSpace() is false)
|
||||
.Select(x => x.MediaTypeAlias);
|
||||
|
||||
IEnumerable<Guid> retrievedMediaKeys = value
|
||||
Guid[] retrievedMediaKeys = value
|
||||
.Where(x => x.MediaTypeAlias.IsNullOrWhiteSpace())
|
||||
.Select(x => x.MediaKey);
|
||||
IEnumerable<IMedia> retrievedMedia = _mediaService.GetByIds(retrievedMediaKeys);
|
||||
.Select(x => x.MediaKey)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
IMedia[] retrievedMedia = _mediaService.GetByIds(retrievedMediaKeys).ToArray();
|
||||
|
||||
// If any of the media we had to look up (to resolve the type) could not be found, the selection
|
||||
// references media that no longer exists, so the configured allowed types cannot be verified.
|
||||
// Compare against the distinct requested keys so duplicates aren't reported as missing.
|
||||
if (retrievedMedia.Length != retrievedMediaKeys.Length)
|
||||
{
|
||||
return
|
||||
[
|
||||
new ValidationResult(
|
||||
_localizedTextService.Localize("validation", "missingMedia"),
|
||||
["value"])
|
||||
];
|
||||
}
|
||||
|
||||
IEnumerable<string> retrievedTypeAliases = retrievedMedia
|
||||
.Select(x => x.ContentType.Alias);
|
||||
|
||||
@@ -644,7 +660,7 @@ public class MediaPicker3PropertyEditor : DataEditor, IValueSchemaProvider
|
||||
/// <summary>
|
||||
/// Validates the start node configuration for the media picker property editor.
|
||||
/// </summary>
|
||||
internal sealed class StartNodeValidator : ITypedJsonValidator<List<MediaWithCropsDto>, MediaPicker3Configuration>
|
||||
internal sealed class StartNodeValidator : ITypedValidator<List<MediaWithCropsDto>, MediaPicker3Configuration>
|
||||
{
|
||||
private readonly ILocalizedTextService _localizedTextService;
|
||||
private readonly IMediaNavigationQueryService _mediaNavigationQueryService;
|
||||
|
||||
@@ -217,7 +217,7 @@ public class MultiNodeTreePickerPropertyEditor : DataEditor, IValueSchemaProvide
|
||||
/// <summary>
|
||||
/// Validates the min/max configuration for the multi-node tree picker property editor.
|
||||
/// </summary>
|
||||
internal sealed class MinMaxValidator : ITypedJsonValidator<EditorEntityReference[], MultiNodePickerConfiguration>
|
||||
internal sealed class MinMaxValidator : ITypedValidator<EditorEntityReference[], MultiNodePickerConfiguration>
|
||||
{
|
||||
private readonly ILocalizedTextService _localizedTextService;
|
||||
|
||||
@@ -275,7 +275,7 @@ public class MultiNodeTreePickerPropertyEditor : DataEditor, IValueSchemaProvide
|
||||
/// <summary>
|
||||
/// Validates the selected object type for the multi-node tree picker property editor.
|
||||
/// </summary>
|
||||
internal sealed class ObjectTypeValidator : ITypedJsonValidator<EditorEntityReference[], MultiNodePickerConfiguration>
|
||||
internal sealed class ObjectTypeValidator : ITypedValidator<EditorEntityReference[], MultiNodePickerConfiguration>
|
||||
{
|
||||
private readonly ILocalizedTextService _localizedTextService;
|
||||
private readonly ICoreScopeProvider _coreScopeProvider;
|
||||
@@ -366,7 +366,7 @@ public class MultiNodeTreePickerPropertyEditor : DataEditor, IValueSchemaProvide
|
||||
/// <summary>
|
||||
/// Validates the selected content type for the multi-node tree picker property editor.
|
||||
/// </summary>
|
||||
internal sealed class ContentTypeValidator : ITypedJsonValidator<EditorEntityReference[], MultiNodePickerConfiguration>
|
||||
internal sealed class ContentTypeValidator : ITypedValidator<EditorEntityReference[], MultiNodePickerConfiguration>
|
||||
{
|
||||
private readonly ILocalizedTextService _localizedTextService;
|
||||
private readonly ICoreScopeProvider _coreScopeProvider;
|
||||
|
||||
@@ -391,7 +391,7 @@ public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference, I
|
||||
public string? Culture { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class MinMaxValidator : ITypedJsonValidator<LinkDisplay[], MultiUrlPickerConfiguration>
|
||||
internal sealed class MinMaxValidator : ITypedValidator<LinkDisplay[], MultiUrlPickerConfiguration>
|
||||
{
|
||||
private readonly ILocalizedTextService _localizedTextService;
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
@@ -49,6 +47,7 @@ public abstract class SystemTextJsonSerializerBase : IJsonSerializer
|
||||
value = jsonString.IsNullOrWhiteSpace()
|
||||
? null
|
||||
: Deserialize<T>(jsonString);
|
||||
|
||||
return value != null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,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>
|
||||
@@ -38,6 +42,10 @@ public class DistributedJobService : IDistributedJobService
|
||||
_distributedBackgroundJobs = distributedBackgroundJobs;
|
||||
_logger = logger;
|
||||
_settings = settings.Value;
|
||||
_clockAlignedJobNames = _distributedBackgroundJobs
|
||||
.Where(x => x.AlignToClock)
|
||||
.Select(x => x.Name)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -47,9 +55,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)
|
||||
{
|
||||
@@ -77,6 +88,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)
|
||||
{
|
||||
@@ -116,11 +160,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>();
|
||||
|
||||
@@ -83,6 +83,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;
|
||||
|
||||
@@ -35,7 +34,7 @@ internal sealed class SeedingNotificationHandler : INotificationAsyncHandler<Umb
|
||||
UmbracoApplicationStartingNotification notification,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_runtimeState.Level <= RuntimeLevel.Install || (_runtimeState.Level == RuntimeLevel.Upgrade && _globalSettings.ShowMaintenancePageWhenInUpgradeState))
|
||||
if (_runtimeState.ShouldSkipStartupSeeding(_globalSettings))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,20 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
|
||||
private readonly ConcurrentDictionary<string, IPublishedContent> _publishedContentCache = [];
|
||||
|
||||
// Monotonic counter bumped whenever the in-memory cache (L0/L1) is invalidated or refreshed.
|
||||
// GetNodeAsync captures it before reading the backing store and re-checks it before writing
|
||||
// back, so a snapshot read before a concurrent publish/refresh is never written over the
|
||||
// refreshed entry — preventing the stale-set clobber that otherwise persists until a full clear.
|
||||
//
|
||||
// Deliberately a single global counter, not per-key: any invalidation invalidates every in-flight
|
||||
// read-through. The only cost is an occasional skipped cache population when a read-through for one
|
||||
// key overlaps an unrelated publish — a re-miss on the next request, never stale data. A per-key
|
||||
// scheme would avoid that but needs a global epoch for bulk clears plus an exact per-key bump on
|
||||
// every mutated cache key, which is easy to get wrong and would silently reintroduce the clobber.
|
||||
// Global is correctness-robust; only revisit if read-through churn under heavy concurrent
|
||||
// publishing ever shows up in profiling.
|
||||
private long _cacheGeneration;
|
||||
|
||||
private HashSet<Guid> SeedKeys
|
||||
{
|
||||
get
|
||||
@@ -129,15 +143,28 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
}
|
||||
|
||||
(bool exists, ContentCacheNode? contentCacheNode) = await _hybridCache.TryGetValueAsync<ContentCacheNode?>(cacheKey, CancellationToken.None);
|
||||
|
||||
// A value found in the backing store is already current, so it can always populate the caches
|
||||
// below; only a value built from the read-through DB fetch needs the generation guard.
|
||||
bool snapshotIsCurrent = true;
|
||||
if (exists is false)
|
||||
{
|
||||
// Capture the cache generation before reading the backing store. If a concurrent publish or
|
||||
// invalidation bumps the generation while we read and build below, the snapshot we hold is
|
||||
// stale and must not be written back over the refreshed entries (the clobber that leaves
|
||||
// memory permanently stale until a full clear).
|
||||
long generation = Interlocked.Read(ref _cacheGeneration);
|
||||
|
||||
bool ancestorCheckFailed;
|
||||
(contentCacheNode, ancestorCheckFailed) = await GetContentCacheNodeFromRepo();
|
||||
|
||||
snapshotIsCurrent = IsCacheGenerationCurrent(generation);
|
||||
|
||||
// Only cache the result if the ancestor check didn't fail.
|
||||
// When content exists in DB but the ancestor check fails, this could be a transient
|
||||
// race condition during cache rebuild. Caching null would poison the distributed cache.
|
||||
if (ancestorCheckFailed is false)
|
||||
// Skip the write when the generation moved — a refresh has superseded this snapshot.
|
||||
if (ancestorCheckFailed is false && snapshotIsCurrent)
|
||||
{
|
||||
await _hybridCache.SetAsync(
|
||||
cacheKey,
|
||||
@@ -153,7 +180,10 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
}
|
||||
|
||||
IPublishedContent? result = _publishedContentFactory.ToIPublishedContent(contentCacheNode, preview).CreateModel(_publishedModelFactory);
|
||||
if (result is not null)
|
||||
|
||||
// Only populate the L0 cache when our snapshot is still current; otherwise a concurrent
|
||||
// refresh has already written fresher content and we must not overwrite it with this one.
|
||||
if (result is not null && snapshotIsCurrent)
|
||||
{
|
||||
_publishedContentCache[cacheKey] = result;
|
||||
}
|
||||
@@ -185,6 +215,13 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
|
||||
private bool GetPreview() => _previewService.IsInPreview();
|
||||
|
||||
// Bumped after every in-memory cache invalidation/refresh so in-flight read-through snapshots
|
||||
// (see GetNodeAsync) can detect they have been superseded and skip writing back stale content.
|
||||
private void InvalidateMemoryCacheGeneration() => Interlocked.Increment(ref _cacheGeneration);
|
||||
|
||||
private bool IsCacheGenerationCurrent(long capturedGeneration)
|
||||
=> Interlocked.Read(ref _cacheGeneration) == capturedGeneration;
|
||||
|
||||
public IEnumerable<IPublishedContent> GetByContentType(IPublishedContentType contentType)
|
||||
{
|
||||
using ICoreScope scope = _scopeProvider.CreateCoreScope();
|
||||
@@ -198,6 +235,10 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
|
||||
public async Task ClearMemoryCacheAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Bump first so any read-through that read the backing store before this clear is rejected
|
||||
// when it tries to write back, even while the reseed below is still running.
|
||||
InvalidateMemoryCacheGeneration();
|
||||
|
||||
_publishedContentCache.Clear();
|
||||
await _hybridCache.RemoveByTagAsync(Constants.Cache.Tags.Content, cancellationToken);
|
||||
|
||||
@@ -227,11 +268,13 @@ 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 _);
|
||||
InvalidateMemoryCacheGeneration();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Either no published node in the database cache, or the ancestor path is no longer published —
|
||||
// remove any stale published entry from the local memory cache.
|
||||
// remove any stale published entry from the local memory cache. ClearPublishedCacheAsync
|
||||
// bumps the generation itself, so this path is already covered.
|
||||
await ClearPublishedCacheAsync(key);
|
||||
}
|
||||
|
||||
@@ -425,12 +468,17 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
ClearConvertedContentCache(contentTypeIdsAsArray);
|
||||
}
|
||||
|
||||
public void ClearConvertedContentCache() => _publishedContentCache.Clear();
|
||||
public void ClearConvertedContentCache()
|
||||
{
|
||||
_publishedContentCache.Clear();
|
||||
InvalidateMemoryCacheGeneration();
|
||||
}
|
||||
|
||||
public void ClearConvertedContentCache(IReadOnlyCollection<int> contentTypeIds)
|
||||
{
|
||||
var ids = contentTypeIds as int[] ?? contentTypeIds.ToArray();
|
||||
_publishedContentCache.RemoveAll(content => ids.Contains(content.Value.ContentType.Id));
|
||||
InvalidateMemoryCacheGeneration();
|
||||
}
|
||||
|
||||
private async Task ClearPublishedCacheAsync(Guid key)
|
||||
@@ -438,6 +486,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
var cacheKey = GetCacheKey(key, false);
|
||||
await _hybridCache.RemoveAsync(cacheKey);
|
||||
_publishedContentCache.Remove(cacheKey, out _);
|
||||
InvalidateMemoryCacheGeneration();
|
||||
}
|
||||
|
||||
private static string ContentTypeIdTag(int contentTypeId)
|
||||
|
||||
@@ -34,6 +34,20 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
|
||||
private readonly ConcurrentDictionary<Guid, IPublishedContent> _publishedContentCache = [];
|
||||
|
||||
// Monotonic counter bumped whenever the in-memory cache (L0/L1) is invalidated or refreshed.
|
||||
// GetNodeAsync captures it before reading the backing store and re-checks it before writing
|
||||
// back, so a snapshot read before a concurrent refresh is never written over the refreshed
|
||||
// entry — preventing the stale-set clobber that otherwise persists until a full clear.
|
||||
//
|
||||
// Deliberately a single global counter, not per-key: any invalidation invalidates every in-flight
|
||||
// read-through. The only cost is an occasional skipped cache population when a read-through for one
|
||||
// key overlaps an unrelated refresh — a re-miss on the next request, never stale data. A per-key
|
||||
// scheme would avoid that but needs a global epoch for bulk clears plus an exact per-key bump on
|
||||
// every mutated cache key, which is easy to get wrong and would silently reintroduce the clobber.
|
||||
// Global is correctness-robust; only revisit if read-through churn under heavy concurrent
|
||||
// refreshing ever shows up in profiling.
|
||||
private long _cacheGeneration;
|
||||
|
||||
private HashSet<Guid>? _seedKeys;
|
||||
private HashSet<Guid> SeedKeys
|
||||
{
|
||||
@@ -124,11 +138,24 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
|
||||
string cacheKey = GetCacheKey(key);
|
||||
(bool exists, ContentCacheNode? contentCacheNode) = await _hybridCache.TryGetValueAsync<ContentCacheNode?>(cacheKey, CancellationToken.None);
|
||||
|
||||
// A value found in the backing store is already current, so it can always populate the caches
|
||||
// below; only a value built from the read-through DB fetch needs the generation guard.
|
||||
bool snapshotIsCurrent = true;
|
||||
if (exists is false)
|
||||
{
|
||||
// Capture the cache generation before reading the backing store. If a concurrent refresh or
|
||||
// invalidation bumps the generation while we read and build below, the snapshot we hold is
|
||||
// stale and must not be written back over the refreshed entries (the clobber that leaves
|
||||
// memory permanently stale until a full clear).
|
||||
long generation = Interlocked.Read(ref _cacheGeneration);
|
||||
|
||||
contentCacheNode = await GetContentCacheNodeFromRepo();
|
||||
snapshotIsCurrent = IsCacheGenerationCurrent(generation);
|
||||
|
||||
// We don't want to cache removed items, this may cause issues if the L2 serializer changes.
|
||||
if (contentCacheNode is not null)
|
||||
// Skip the write when the generation moved — a refresh has superseded this snapshot.
|
||||
if (contentCacheNode is not null && snapshotIsCurrent)
|
||||
{
|
||||
await _hybridCache.SetAsync(
|
||||
cacheKey,
|
||||
@@ -144,7 +171,10 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
}
|
||||
|
||||
IPublishedContent? result = _publishedContentFactory.ToIPublishedMedia(contentCacheNode).CreateModel(_publishedModelFactory);
|
||||
if (result is not null)
|
||||
|
||||
// Only populate the L0 cache when our snapshot is still current; otherwise a concurrent
|
||||
// refresh has already written fresher content and we must not overwrite it with this one.
|
||||
if (result is not null && snapshotIsCurrent)
|
||||
{
|
||||
_publishedContentCache[key] = result;
|
||||
}
|
||||
@@ -160,6 +190,13 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
}
|
||||
}
|
||||
|
||||
// Bumped after every in-memory cache invalidation/refresh so in-flight read-through snapshots
|
||||
// (see GetNodeAsync) can detect they have been superseded and skip writing back stale content.
|
||||
private void InvalidateMemoryCacheGeneration() => Interlocked.Increment(ref _cacheGeneration);
|
||||
|
||||
private bool IsCacheGenerationCurrent(long capturedGeneration)
|
||||
=> Interlocked.Read(ref _cacheGeneration) == capturedGeneration;
|
||||
|
||||
public async Task<bool> HasContentByIdAsync(int id)
|
||||
{
|
||||
Attempt<Guid> keyAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Media);
|
||||
@@ -186,6 +223,7 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
var cacheNode = _cacheNodeFactory.ToContentCacheNode(media);
|
||||
await _databaseCacheRepository.RefreshMediaAsync(cacheNode);
|
||||
_publishedContentCache.Remove(media.Key, out _);
|
||||
InvalidateMemoryCacheGeneration();
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
@@ -263,9 +301,12 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
{
|
||||
await _hybridCache.SetAsync(GetCacheKey(publishedNode.Key), publishedNode, GetEntryOptions(publishedNode.Key));
|
||||
_publishedContentCache.Remove(key, out _);
|
||||
InvalidateMemoryCacheGeneration();
|
||||
}
|
||||
else
|
||||
{
|
||||
// RemoveFromMemoryCacheAsync → ClearPublishedCacheAsync bumps the generation itself,
|
||||
// so this path is already covered.
|
||||
await RemoveFromMemoryCacheAsync(key);
|
||||
}
|
||||
|
||||
@@ -274,6 +315,10 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
|
||||
public async Task ClearMemoryCacheAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Bump first so any read-through that read the backing store before this clear is rejected
|
||||
// when it tries to write back, even while the reseed below is still running.
|
||||
InvalidateMemoryCacheGeneration();
|
||||
|
||||
_publishedContentCache.Clear();
|
||||
await _hybridCache.RemoveByTagAsync(Constants.Cache.Tags.Media, cancellationToken);
|
||||
|
||||
@@ -295,12 +340,17 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
ClearConvertedContentCache(mediaTypeIdsAsArray);
|
||||
}
|
||||
|
||||
public void ClearConvertedContentCache() => _publishedContentCache.Clear();
|
||||
public void ClearConvertedContentCache()
|
||||
{
|
||||
_publishedContentCache.Clear();
|
||||
InvalidateMemoryCacheGeneration();
|
||||
}
|
||||
|
||||
public void ClearConvertedContentCache(IReadOnlyCollection<int> mediaTypeIds)
|
||||
{
|
||||
var ids = mediaTypeIds as int[] ?? mediaTypeIds.ToArray();
|
||||
_publishedContentCache.RemoveAll(content => ids.Contains(content.Value.ContentType.Id));
|
||||
InvalidateMemoryCacheGeneration();
|
||||
}
|
||||
|
||||
public void Rebuild(IReadOnlyCollection<int> contentTypeIds)
|
||||
@@ -358,6 +408,7 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
{
|
||||
await _hybridCache.RemoveAsync(GetCacheKey(key));
|
||||
_publishedContentCache.Remove(key, out _);
|
||||
InvalidateMemoryCacheGeneration();
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -5,6 +5,7 @@ TypeScript/Lit web components library for the Umbraco CMS backoffice. Published
|
||||
## Documentation Structure
|
||||
|
||||
### Architecture & Design
|
||||
|
||||
- **[Architecture](./docs/architecture.md)** - Technology stack, design philosophy, developer roles, package system, import map pipeline, design patterns
|
||||
- **[Manifests & Aliases](./docs/manifests.md)** - Manifest shape, alias conventions, alias constants, how aliases connect extensions, registration, registry operations, kind merging
|
||||
- **[Entities](./docs/entities.md)** - Entity types, entity context, how entityType connects workspaces/trees/actions/routing
|
||||
@@ -18,9 +19,11 @@ TypeScript/Lit web components library for the Umbraco CMS backoffice. Published
|
||||
- **[Value Summary](./docs/value-summary.md)** - `valueSummary` extension type; rendering compact values in collection views, batch resolver pattern, coordinator
|
||||
|
||||
### Development
|
||||
|
||||
- **[Commands](./docs/commands.md)** - Build, test, and development commands
|
||||
|
||||
### Code Quality
|
||||
|
||||
- **[Style Guide](./docs/style-guide.md)** - Naming and formatting conventions
|
||||
- **[Design Choices](./docs/design-choices.md)** - Visual restraint: icons, colours, buttons, and UX copy
|
||||
- **[Clean Code](./docs/clean-code.md)** - Best practices and SOLID principles
|
||||
@@ -28,10 +31,12 @@ TypeScript/Lit web components library for the Umbraco CMS backoffice. Published
|
||||
- **[Testing](./docs/testing.md)** - Testing strategy, priority by code area, MSW mocking, test patterns
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- **[Error Handling](./docs/error-handling.md)** - Error patterns and debugging
|
||||
- **[Edge Cases](./docs/edge-cases.md)** - Common pitfalls and gotchas
|
||||
|
||||
### Security & AI
|
||||
|
||||
- **[Security](./docs/security.md)** - XSS prevention, authentication, input validation
|
||||
- **[Agentic Workflow](./docs/agentic-workflow.md)** - Three-phase AI development process
|
||||
|
||||
@@ -41,17 +46,17 @@ TypeScript/Lit web components library for the Umbraco CMS backoffice. Published
|
||||
|
||||
**Before performing any of these actions, you MUST read the linked doc first:**
|
||||
|
||||
| Before you... | Read |
|
||||
|----------------|------|
|
||||
| Deprecate or remove a public API | [docs/deprecation.md](./docs/deprecation.md) — requires **both** `@deprecated` JSDoc **and** runtime `UmbDeprecation` warning |
|
||||
| Create a new element or component | [docs/style-guide.md](./docs/style-guide.md) |
|
||||
| Build, style, or write copy for any UI | [docs/design-choices.md](./docs/design-choices.md) — default to no icon, no colour, terse contextual copy |
|
||||
| Create a repository or data source | [docs/repositories.md](./docs/repositories.md) + [docs/data-flow.md](./docs/data-flow.md) |
|
||||
| Add error handling or debugging | [docs/error-handling.md](./docs/error-handling.md) |
|
||||
| Write or modify tests | [docs/testing.md](./docs/testing.md) |
|
||||
| Work with auth or security | [docs/security.md](./docs/security.md) + [docs/edge-cases.md](./docs/edge-cases.md) |
|
||||
| Scaffold a new package or module | [docs/package-development.md](./docs/package-development.md) |
|
||||
| Write or change observers / `Umb*State` usage | [docs/state-system.md](./docs/state-system.md) — states already deduplicate; do not add "is this a re-emit?" guards |
|
||||
| Before you... | Read |
|
||||
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Deprecate or remove a public API | [docs/deprecation.md](./docs/deprecation.md) — requires **both** `@deprecated` JSDoc **and** runtime `UmbDeprecation` warning |
|
||||
| Create a new element or component | [docs/style-guide.md](./docs/style-guide.md) |
|
||||
| Build, style, or write copy for any UI | [docs/design-choices.md](./docs/design-choices.md) — default to no icon, no colour, terse contextual copy |
|
||||
| Create a repository or data source | [docs/repositories.md](./docs/repositories.md) + [docs/data-flow.md](./docs/data-flow.md) |
|
||||
| Add error handling or debugging | [docs/error-handling.md](./docs/error-handling.md) |
|
||||
| Write or modify tests | [docs/testing.md](./docs/testing.md) |
|
||||
| Work with auth or security | [docs/security.md](./docs/security.md) + [docs/edge-cases.md](./docs/edge-cases.md) |
|
||||
| Scaffold a new package or module | [docs/package-development.md](./docs/package-development.md) |
|
||||
| Write or change observers / `Umb*State` usage | [docs/state-system.md](./docs/state-system.md) — states already deduplicate; do not add "is this a re-emit?" guards |
|
||||
|
||||
This is not optional. Skipping these leads to convention violations that are caught in review.
|
||||
|
||||
@@ -79,24 +84,24 @@ cd src/Umbraco.Web.UI.Client && npm install && npm run dev
|
||||
|
||||
See **[Commands](./docs/commands.md)** for all available commands.
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Development | `npm run dev` |
|
||||
| Testing (all) | `npm test` |
|
||||
| Task | Command |
|
||||
| ----------------------- | --------------------------------------------------------- |
|
||||
| Development | `npm run dev` |
|
||||
| Testing (all) | `npm test` |
|
||||
| Testing (specific file) | `npm test -- --files "src/packages/path/to/file.test.ts"` |
|
||||
| Build | `npm run build` |
|
||||
| Lint | `npm run lint:fix` |
|
||||
| Circular dep check | `npm run check:circular` |
|
||||
| Build | `npm run build` |
|
||||
| Lint | `npm run lint:fix` |
|
||||
| Circular dep check | `npm run check:circular` |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Item | Details |
|
||||
|------|---------|
|
||||
| **Config** | `package.json`, `vite.config.ts`, `.env` (create `.env.local`) |
|
||||
| **Element naming** | `umb-{feature}-{component}` for core; package devs use own prefix |
|
||||
| **Directory structure** | See [Architecture](./docs/architecture.md#architecture-pattern) |
|
||||
| Item | Details |
|
||||
| ----------------------- | ----------------------------------------------------------------- |
|
||||
| **Config** | `package.json`, `vite.config.ts`, `.env` (create `.env.local`) |
|
||||
| **Element naming** | `umb-{feature}-{component}` for core; package devs use own prefix |
|
||||
| **Directory structure** | See [Architecture](./docs/architecture.md#architecture-pattern) |
|
||||
|
||||
---
|
||||
|
||||
@@ -117,6 +122,7 @@ The `npm pack` process (prepack hook) runs `devops/publish/cleanse-pkg.js` which
|
||||
Uses the `semver` package (npm's own semver library) for robust parsing:
|
||||
|
||||
**Pre-release packages (0.x.y)**
|
||||
|
||||
```
|
||||
Input: ^0.85.0 or 0.85.0
|
||||
Output: >=0.85.0 <1.0.0
|
||||
@@ -126,6 +132,7 @@ Why: Pre-release caret (^0.85.0) only allows patch updates (0.85.x).
|
||||
```
|
||||
|
||||
**Stable packages with caret (major ≥ 1)**
|
||||
|
||||
```
|
||||
Input: ^3.3.1
|
||||
Output: ^3.3.1 (kept as-is)
|
||||
@@ -134,6 +141,7 @@ Why: Caret already implements the correct range: >=3.3.1 <4.0.0
|
||||
```
|
||||
|
||||
**Stable exact versions (major ≥ 1)**
|
||||
|
||||
```
|
||||
Input: 3.16.0 (from @tiptap/*)
|
||||
Output: ^3.16.0
|
||||
@@ -145,14 +153,14 @@ Why: Normalizes to conventional semver format
|
||||
|
||||
```json
|
||||
{
|
||||
"peerDependencies": {
|
||||
"lit": "^3.3.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"@umbraco-ui/uui": "^2.0.0-alpha.1",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"@tiptap/core": "^3.16.0",
|
||||
"@hey-api/openapi-ts": ">=0.85.0 <1.0.0"
|
||||
}
|
||||
"peerDependencies": {
|
||||
"lit": "^3.3.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"@umbraco-ui/uui": "^2.0.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"@tiptap/core": "^3.16.0",
|
||||
"@hey-api/openapi-ts": ">=0.85.0 <1.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -168,9 +176,9 @@ When using `@umbraco-cms/backoffice`:
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `package.json` | Root package with exports and workspace references |
|
||||
| File | Purpose |
|
||||
| ------------------------------- | ---------------------------------------------------------------- |
|
||||
| `package.json` | Root package with exports and workspace references |
|
||||
| `devops/publish/cleanse-pkg.js` | Script that runs during `npm pack` to hoist and convert versions |
|
||||
| `src/external/*` | Dependency wrapper packages |
|
||||
| `src/packages/core` | Contains `@hey-api/openapi-ts` and other utilities |
|
||||
| `src/external/*` | Dependency wrapper packages |
|
||||
| `src/packages/core` | Contains `@hey-api/openapi-ts` and other utilities |
|
||||
|
||||
+4
-4
@@ -3963,9 +3963,9 @@
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@umbraco-ui/uui": {
|
||||
"version": "2.0.0-rc.2",
|
||||
"resolved": "https://registry.npmjs.org/@umbraco-ui/uui/-/uui-2.0.0-rc.2.tgz",
|
||||
"integrity": "sha512-/eRw8byM1zysUF61VrM3MQzZnvuZHuerhiW13eR5vPAxvWT1I5n/jeh+qTKKd3mgZi/rvAV1fnRS05ipqu2hMQ==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@umbraco-ui/uui/-/uui-2.0.0.tgz",
|
||||
"integrity": "sha512-snH3u1C4gpvO/bxTfTJV6lF3HVpru5v0ssMbz0aESOt66gRyGf//fshgUHgvYa5gc3wE2Fr4uGx8mKgUC/GrXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"culori": "^4.0.2",
|
||||
@@ -16447,7 +16447,7 @@
|
||||
"src/external/uui": {
|
||||
"name": "@umbraco-backoffice/uui",
|
||||
"dependencies": {
|
||||
"@umbraco-ui/uui": "^2.0.0-rc.2"
|
||||
"@umbraco-ui/uui": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"src/libs/class-api": {
|
||||
|
||||
@@ -298,4 +298,4 @@
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,12 +260,9 @@ export class UmbAppElement extends UmbLitElement {
|
||||
// Register Core extensions (this is specifically done here because we need these extensions to be registered before the application is initialized)
|
||||
onInit(this, umbExtensionsRegistry);
|
||||
|
||||
// Register public extensions (login extensions) in parallel with the auth flow below.
|
||||
const registerPublicExtensions = new UmbServerExtensionRegistrator(
|
||||
this,
|
||||
umbExtensionsRegistry,
|
||||
).registerPublicExtensions();
|
||||
new UmbAppEntryPointExtensionInitializer(this, umbExtensionsRegistry);
|
||||
// Register public extensions (login extensions)
|
||||
await new UmbServerExtensionRegistrator(this, umbExtensionsRegistry).registerPublicExtensions();
|
||||
const entryPointInitializer = new UmbAppEntryPointExtensionInitializer(this, umbExtensionsRegistry);
|
||||
|
||||
// Try to initialise the auth flow and get the runtime status
|
||||
try {
|
||||
@@ -281,8 +278,11 @@ export class UmbAppElement extends UmbLitElement {
|
||||
await this.#setAuthStatus();
|
||||
}
|
||||
|
||||
// The login screen needs the public extensions before routing.
|
||||
await registerPublicExtensions;
|
||||
// The login screen decides which auth provider to use from the registered
|
||||
// `authProvider` extensions. App-entry-points may register or unregister those during
|
||||
// their async onInit, so wait for them to settle before routing — otherwise on a slow
|
||||
// connection the decision races and falls back to the local login.
|
||||
await this.observe(entryPointInitializer.loaded).asPromise();
|
||||
|
||||
// Initialise the router
|
||||
this.#redirect();
|
||||
|
||||
@@ -1557,8 +1557,16 @@ export default {
|
||||
chooseChildNode: 'اختر العقدة الفرعية',
|
||||
compositionsDescription:
|
||||
'ارث التبويبات والخصائص من نوع مستند موجود. سيتم إضافة التبويبات الجديدة إلى نوع الوثيقة الحالي أو دمجها إذا كان هناك تبويب بنفس الاسم.',
|
||||
compositionsDescriptionMediaType:
|
||||
'ارث التبويبات والخصائص من نوع وسائط موجود. سيتم إضافة التبويبات الجديدة إلى نوع الوسائط الحالي أو دمجها إذا كان هناك تبويب بنفس الاسم.',
|
||||
compositionsDescriptionMemberType:
|
||||
'ارث التبويبات والخصائص من نوع عضو موجود. سيتم إضافة التبويبات الجديدة إلى نوع العضو الحالي أو دمجها إذا كان هناك تبويب بنفس الاسم.',
|
||||
compositionInUse: 'هذا النوع من المحتوى قيد الاستخدام في تركيب، وبالتالي لا يمكن تركيبه بنفسه.\n ',
|
||||
compositionInUseMediaType: 'هذا النوع من الوسائط قيد الاستخدام في تركيب، وبالتالي لا يمكن تركيبه بنفسه.\n ',
|
||||
compositionInUseMemberType: 'هذا النوع من الأعضاء قيد الاستخدام في تركيب، وبالتالي لا يمكن تركيبه بنفسه.\n ',
|
||||
noAvailableCompositions: 'لا توجد أنواع محتوى متاحة لاستخدامها كتركيب.',
|
||||
noAvailableCompositionsMediaType: 'لا توجد أنواع وسائط متاحة لاستخدامها كتركيب.',
|
||||
noAvailableCompositionsMemberType: 'لا توجد أنواع أعضاء متاحة لاستخدامها كتركيب.',
|
||||
compositionRemoveWarning:
|
||||
'إزالة التركيب ستؤدي إلى حذف جميع بيانات الخصائص المرتبطة. بمجرد حفظ نوع الوثيقة لا يوجد طريق للعودة.',
|
||||
availableEditors: 'إنشاء جديد',
|
||||
@@ -1592,6 +1600,8 @@ export default {
|
||||
tabHasNoSortOrder: 'التبويب ليس له ترتيب فرز',
|
||||
compositionUsageHeading: 'أين يتم استخدام هذا التركيب؟',
|
||||
compositionUsageSpecification: 'يتم استخدام هذا التركيب حاليًا في تركيب أنواع المحتوى التالية:\n ',
|
||||
compositionUsageSpecificationMediaType: 'يتم استخدام هذا التركيب حاليًا في تركيب أنواع الوسائط التالية:\n ',
|
||||
compositionUsageSpecificationMemberType: 'يتم استخدام هذا التركيب حاليًا في تركيب أنواع الأعضاء التالية:\n ',
|
||||
variantsHeading: 'السماح بالاختلافات',
|
||||
cultureVariantHeading: 'السماح بالاختلاف حسب الثقافة',
|
||||
segmentVariantHeading: 'السماح بالتجزئة',
|
||||
|
||||
@@ -1469,8 +1469,16 @@ export default {
|
||||
chooseChildNode: 'Odaberite podređeni čvor',
|
||||
compositionsDescription:
|
||||
'Naslijediti kartice i svojstva iz postojeće vrste dokumenta. Nove kartice će biti\n dodano trenutnoj vrsti dokumenta ili spojeno ako postoji kartica s identičnim imenom.\n ',
|
||||
compositionsDescriptionMediaType:
|
||||
'Naslijediti kartice i svojstva iz postojeće vrste medija. Nove kartice će biti\n dodano trenutnoj vrsti medija ili spojeno ako postoji kartica s identičnim imenom.\n ',
|
||||
compositionsDescriptionMemberType:
|
||||
'Naslijediti kartice i svojstva iz postojeće vrste člana. Nove kartice će biti\n dodano trenutnoj vrsti člana ili spojeno ako postoji kartica s identičnim imenom.\n ',
|
||||
compositionInUse: 'Ovaj tip sadržaja se koristi u kompoziciji i stoga se ne može sam sastaviti.\n ',
|
||||
compositionInUseMediaType: 'Ovaj tip medija se koristi u kompoziciji i stoga se ne može sam sastaviti.\n ',
|
||||
compositionInUseMemberType: 'Ovaj tip člana se koristi u kompoziciji i stoga se ne može sam sastaviti.\n ',
|
||||
noAvailableCompositions: 'Nema dostupnih tipova sadržaja za upotrebu kao kompozicija.',
|
||||
noAvailableCompositionsMediaType: 'Nema dostupnih tipova medija za upotrebu kao kompozicija.',
|
||||
noAvailableCompositionsMemberType: 'Nema dostupnih tipova člana za upotrebu kao kompozicija.',
|
||||
compositionRemoveWarning:
|
||||
'Uklanjanje kompozicije će izbrisati sve povezane podatke o svojstvu. Jednom ti\n sačuvajte tip dokumenta, nema povratka.\n ',
|
||||
availableEditors: 'Napravi novi',
|
||||
@@ -1505,6 +1513,8 @@ export default {
|
||||
tabHasNoSortOrder: 'kartica nema redoslijed sortiranja',
|
||||
compositionUsageHeading: 'Gdje se koristi ovaj sastav?',
|
||||
compositionUsageSpecification: 'Ovaj sastav se trenutno koristi u sastavu sljedećih\n tipa sadržaja:\n ',
|
||||
compositionUsageSpecificationMediaType: 'Ovaj sastav se trenutno koristi u sastavu sljedećih\n tipa medija:\n ',
|
||||
compositionUsageSpecificationMemberType: 'Ovaj sastav se trenutno koristi u sastavu sljedećih\n tipa člana:\n ',
|
||||
variantsHeading: 'Dozvoli varijacije',
|
||||
cultureVariantHeading: 'Dozvolite varirati u zavisnosti od kulture',
|
||||
segmentVariantHeading: 'Dozvoli segmentaciju',
|
||||
|
||||
@@ -1369,8 +1369,16 @@ export default {
|
||||
chooseChildNode: 'Vybrat podřízený uzel',
|
||||
compositionsDescription:
|
||||
'Zdědí záložky a vlastnosti z existujícího typu dokumentu. Nové záložky budou přidány do aktuálního typu dokumentu nebo sloučeny, pokud existuje záložka se stejným názvem.',
|
||||
compositionsDescriptionMediaType:
|
||||
'Zdědí záložky a vlastnosti z existujícího typu média. Nové záložky budou přidány do aktuálního typu média nebo sloučeny, pokud existuje záložka se stejným názvem.',
|
||||
compositionsDescriptionMemberType:
|
||||
'Zdědí záložky a vlastnosti z existujícího typu člena. Nové záložky budou přidány do aktuálního typu člena nebo sloučeny, pokud existuje záložka se stejným názvem.',
|
||||
compositionInUse: 'Tento typ obsahu se používá ve složení, a proto jej nelze poskládat.',
|
||||
compositionInUseMediaType: 'Tento typ média se používá ve složení, a proto jej nelze poskládat.',
|
||||
compositionInUseMemberType: 'Tento typ člena se používá ve složení, a proto jej nelze poskládat.',
|
||||
noAvailableCompositions: 'Nejsou k dispozici žádné typy obsahu, které lze použít jako složení.',
|
||||
noAvailableCompositionsMediaType: 'Nejsou k dispozici žádné typy média, které lze použít jako složení.',
|
||||
noAvailableCompositionsMemberType: 'Nejsou k dispozici žádné typy člena, které lze použít jako složení.',
|
||||
compositionRemoveWarning:
|
||||
'Odebráním složení odstraníte všechna související data vlastností. Jakmile uložíte typ dokumentu, již není cesta zpět.',
|
||||
availableEditors: 'Vytvořit nové',
|
||||
@@ -1403,6 +1411,8 @@ export default {
|
||||
tabHasNoSortOrder: 'záložka nemá žádné řazení',
|
||||
compositionUsageHeading: 'Kde se toto složení používá?',
|
||||
compositionUsageSpecification: 'Toto složení se v současnosti používá ve složení následujících typů obsahu:',
|
||||
compositionUsageSpecificationMediaType: 'Toto složení se v současnosti používá ve složení následujících typů média:',
|
||||
compositionUsageSpecificationMemberType: 'Toto složení se v současnosti používá ve složení následujících typů člena:',
|
||||
variantsHeading: 'Povolit různé jazyky',
|
||||
variantsDescription: 'Povolit editorům vytvářet obsah tohoto typu v různých jazycích.',
|
||||
allowVaryByCulture: 'Povolit různé jazyky',
|
||||
|
||||
@@ -1593,9 +1593,19 @@ export default {
|
||||
chooseChildNode: 'Dewis nod blentyn',
|
||||
compositionsDescription:
|
||||
"Etifeddu tabiau a phriodweddau o fath o ddogfen sy'n bodoli eisoes. Bydd tabiau newydd yn cael eu ychwanegu at y fath o ddogfen bresennol neu eu cyfuno os mae tab gyda enw yr union yr un fath yn bodoli eisoes.",
|
||||
compositionsDescriptionMediaType:
|
||||
"Etifeddu tabiau a phriodweddau o fath o gyfrwng sy'n bodoli eisoes. Bydd tabiau newydd yn cael eu ychwanegu at y fath o gyfrwng bresennol neu eu cyfuno os mae tab gyda enw yr union yr un fath yn bodoli eisoes.",
|
||||
compositionsDescriptionMemberType:
|
||||
"Etifeddu tabiau a phriodweddau o fath o aelod sy'n bodoli eisoes. Bydd tabiau newydd yn cael eu ychwanegu at y fath o aelod bresennol neu eu cyfuno os mae tab gyda enw yr union yr un fath yn bodoli eisoes.",
|
||||
compositionInUse:
|
||||
"Mae'r math o gynnwys yma wedi'i ddefnyddio mewn cyfansoddiad, felly ni ellir ei gyfansoddi ei hunan.",
|
||||
compositionInUseMediaType:
|
||||
"Mae'r math o gyfrwng yma wedi'i ddefnyddio mewn cyfansoddiad, felly ni ellir ei gyfansoddi ei hunan.",
|
||||
compositionInUseMemberType:
|
||||
"Mae'r math o aelod yma wedi'i ddefnyddio mewn cyfansoddiad, felly ni ellir ei gyfansoddi ei hunan.",
|
||||
noAvailableCompositions: "Nid oes unrhyw fathau o gynnwys ar gael i'w defnyddio fel cyfansoddiad.",
|
||||
noAvailableCompositionsMediaType: "Nid oes unrhyw fathau o gyfrwng ar gael i'w defnyddio fel cyfansoddiad.",
|
||||
noAvailableCompositionsMemberType: "Nid oes unrhyw fathau o aelod ar gael i'w defnyddio fel cyfansoddiad.",
|
||||
compositionRemoveWarning:
|
||||
"Bydd dileu cyfansoddiad yn dileu'r holl ddata eiddo priodwedd gysylltiedig. Ar ôl i chi arbed y math o ddogfen, bydd ddim ffordd nôl.",
|
||||
availableEditors: 'Golygyddion ar gael',
|
||||
@@ -1633,6 +1643,10 @@ export default {
|
||||
compositionUsageHeading: "Ble mae'r cyfansoddiad yma'n cael ei ddefnyddio?",
|
||||
compositionUsageSpecification:
|
||||
"Mae'r cyfansoddiad yma yn cael ei ddefnyddio'n bresennol yng nghyfansoddiad o'r mathau o gynnwys ganlynol:",
|
||||
compositionUsageSpecificationMediaType:
|
||||
"Mae'r cyfansoddiad yma yn cael ei ddefnyddio'n bresennol yng nghyfansoddiad o'r mathau o gyfrwng ganlynol:",
|
||||
compositionUsageSpecificationMemberType:
|
||||
"Mae'r cyfansoddiad yma yn cael ei ddefnyddio'n bresennol yng nghyfansoddiad o'r mathau o aelod ganlynol:",
|
||||
variantsHeading: 'Caniatáu amrywiadau',
|
||||
cultureVariantHeading: 'Caniatáu amrywiad yn ôl ddiwylliant',
|
||||
segmentVariantHeading: 'Caniatáu segmentiad',
|
||||
|
||||
@@ -1798,9 +1798,19 @@ export default {
|
||||
chooseChildNode: 'Vælg child node',
|
||||
compositionsDescription:
|
||||
'Nedarv faner og egenskaber fra en anden dokumenttype. Nye faner vil blive\n tilføjet den nuværende dokumenttype eller sammenflettet hvis fanenavnene er ens.\n ',
|
||||
compositionsDescriptionMediaType:
|
||||
'Nedarv faner og egenskaber fra en anden medietype. Nye faner vil blive\n tilføjet den nuværende medietype eller sammenflettet hvis fanenavnene er ens.\n ',
|
||||
compositionsDescriptionMemberType:
|
||||
'Nedarv faner og egenskaber fra en anden medlemstype. Nye faner vil blive\n tilføjet den nuværende medlemstype eller sammenflettet hvis fanenavnene er ens.\n ',
|
||||
compositionInUse:
|
||||
'Indholdstypen bliver brugt i en komposition og kan derfor ikke blive anvendt som\n komposition\n ',
|
||||
compositionInUseMediaType:
|
||||
'Medietypen bliver brugt i en komposition og kan derfor ikke blive anvendt som\n komposition\n ',
|
||||
compositionInUseMemberType:
|
||||
'Medlemstypen bliver brugt i en komposition og kan derfor ikke blive anvendt som\n komposition\n ',
|
||||
noAvailableCompositions: 'Der er ingen indholdstyper tilgængelige at bruge som komposition',
|
||||
noAvailableCompositionsMediaType: 'Der er ingen medietyper tilgængelige at bruge som komposition',
|
||||
noAvailableCompositionsMemberType: 'Der er ingen medlemstyper tilgængelige at bruge som komposition',
|
||||
compositionRemoveWarning:
|
||||
'Når du fjerner en komposition vil alle associerede indholdsdata blive slettet.\n Når først dokumenttypen er gemt, er der ingen vej tilbage.\n ',
|
||||
availableEditors: 'Opret ny indstilling',
|
||||
@@ -1838,6 +1848,8 @@ export default {
|
||||
tabHasNoSortOrder: 'fane har ingen sorteringsrækkefølge',
|
||||
compositionUsageHeading: 'Hvor er denne komposition brugt?',
|
||||
compositionUsageSpecification: 'Denne komposition brugt i kompositionen af de følgende indholdstyper:\n ',
|
||||
compositionUsageSpecificationMediaType: 'Denne komposition brugt i kompositionen af de følgende medietyper:\n ',
|
||||
compositionUsageSpecificationMemberType: 'Denne komposition brugt i kompositionen af de følgende medlemstyper:\n ',
|
||||
variantsHeading: 'Tillad variationer',
|
||||
cultureVariantHeading: 'Tillad sprogvariation',
|
||||
segmentVariantHeading: 'Tillad segmentering',
|
||||
|
||||
@@ -1057,7 +1057,7 @@ export default {
|
||||
greeting5: 'Willkommen',
|
||||
greeting6: 'Willkommen',
|
||||
instruction: 'Hier anmelden:',
|
||||
signInWith: 'Anmelden mit',
|
||||
signInWith: 'Anmelden mit {0}',
|
||||
timeout: 'Sitzung abgelaufen',
|
||||
forgottenPassword: 'Kennwort vergessen?',
|
||||
forgottenPasswordInstruction:
|
||||
@@ -1583,9 +1583,19 @@ export default {
|
||||
chooseChildNode: 'Wählen Sie einen Unterknoten',
|
||||
compositionsDescription:
|
||||
'Übernimm Tabs und Eigenschaften vone einem vorhandenen Inhaltstyp. Neue Tabs werden zum vorliegenden Inhaltstyp hinzugefügt oder mit einem gleichnamigen Tab zusammengeführt.',
|
||||
compositionsDescriptionMediaType:
|
||||
'Übernimm Tabs und Eigenschaften vone einem vorhandenen Medientyp. Neue Tabs werden zum vorliegenden Medientyp hinzugefügt oder mit einem gleichnamigen Tab zusammengeführt.',
|
||||
compositionsDescriptionMemberType:
|
||||
'Übernimm Tabs und Eigenschaften vone einem vorhandenen Mitgliedstyp. Neue Tabs werden zum vorliegenden Mitgliedstyp hinzugefügt oder mit einem gleichnamigen Tab zusammengeführt.',
|
||||
compositionInUse:
|
||||
'Dieser Inhaltstyp wird in einer Mischung verwendet und kann deshalb nicht selbst zusammengemischt werden.',
|
||||
compositionInUseMediaType:
|
||||
'Dieser Medientyp wird in einer Mischung verwendet und kann deshalb nicht selbst zusammengemischt werden.',
|
||||
compositionInUseMemberType:
|
||||
'Dieser Mitgliedstyp wird in einer Mischung verwendet und kann deshalb nicht selbst zusammengemischt werden.',
|
||||
noAvailableCompositions: 'Es sind keine Inhaltstypen für eine Mischung vorhanden.',
|
||||
noAvailableCompositionsMediaType: 'Es sind keine Medientypen für eine Mischung vorhanden.',
|
||||
noAvailableCompositionsMemberType: 'Es sind keine Mitgliedstypen für eine Mischung vorhanden.',
|
||||
availableEditors: 'Neu anlegen',
|
||||
reuse: 'Vorhandenen nutzen',
|
||||
editorSettings: 'Editor-Einstellungen',
|
||||
@@ -1620,6 +1630,10 @@ export default {
|
||||
compositionUsageHeading: 'Wo wird diese Mischung verwendet?',
|
||||
compositionUsageSpecification:
|
||||
'\n Diese Mischung wird aktuell in den Mischungen folgender Dokumenttypen verwendet:\n ',
|
||||
compositionUsageSpecificationMediaType:
|
||||
'\n Diese Mischung wird aktuell in den Mischungen folgender Medientypen verwendet:\n ',
|
||||
compositionUsageSpecificationMemberType:
|
||||
'\n Diese Mischung wird aktuell in den Mischungen folgender Mitgliedstypen verwendet:\n ',
|
||||
variantsHeading: 'Kultur basierte Variationen zulassen',
|
||||
variantsDescription: 'Editoren erlauben, Inhalt dieses Typs in verschiedenen Sprachen anzulegen',
|
||||
allowVaryByCulture: 'Kultur basierte Variationen zulassen',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user