Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b836b44343 | ||
|
|
d8f4342a86 | ||
|
|
022439065f | ||
|
|
065e567f11 | ||
|
|
58a1c15626 | ||
|
|
05f8158e4a | ||
|
|
28b849f2d3 | ||
|
|
ca7dcd5150 | ||
|
|
ceef53d624 | ||
|
|
5bb53172aa | ||
|
|
aa9473131b | ||
|
|
35a3a2455c | ||
|
|
5a20452e7a | ||
|
|
adeddeb148 | ||
|
|
ab8b8b48d4 | ||
|
|
feb1689848 | ||
|
|
a14b908574 | ||
|
|
925d6bc430 | ||
|
|
acfaf23e43 | ||
|
|
7c1b907410 | ||
|
|
564ca0384b | ||
|
|
9f633416b1 | ||
|
|
91381604dd | ||
|
|
c566dd0a71 | ||
|
|
ea78147657 | ||
|
|
0a5189e54a | ||
|
|
102e4aa80b | ||
|
|
0bec947b8b | ||
|
|
d0e7ef0169 | ||
|
|
e2cf205d34 | ||
|
|
bf0270b244 | ||
|
|
dc77f37129 | ||
|
|
dbecec3451 | ||
|
|
d92e6bbeff | ||
|
|
e56ddcc3f9 | ||
|
|
fb8c3b19ce | ||
|
|
7a7aadffe5 | ||
|
|
0ac6e8500a | ||
|
|
4c35c0c2e9 | ||
|
|
de47e0b1f7 | ||
|
|
969ae87798 | ||
|
|
fe3318ef79 | ||
|
|
828e359666 | ||
|
|
a5b7e0dac1 | ||
|
|
86abc3528d | ||
|
|
7433641348 | ||
|
|
c45b12ec58 | ||
|
|
22c4bc7835 | ||
|
|
1e82376420 | ||
|
|
764d4eb1d7 | ||
|
|
aa854da3f4 | ||
|
|
28cdbe5317 | ||
|
|
3dbd4baefe | ||
|
|
fa5dd209c1 | ||
|
|
4cc4acee62 | ||
|
|
d0fc7dc8a0 | ||
|
|
62663d9573 | ||
|
|
b582f9d2ef | ||
|
|
ad90db8b38 | ||
|
|
8e3b821a55 | ||
|
|
8ac989c4e3 | ||
|
|
89baa9482b | ||
|
|
3913a61b74 | ||
|
|
90bedcd42e | ||
|
|
c54189aa90 | ||
|
|
88ec0a248f | ||
|
|
3e22733081 | ||
|
|
232077e820 | ||
|
|
943d1eeccd | ||
|
|
b3666dad8b | ||
|
|
28a403361e | ||
|
|
8e6a791de0 | ||
|
|
5ffea3152b | ||
|
|
2043ff1dbd |
@@ -0,0 +1,135 @@
|
||||
---
|
||||
name: umb-release-notes
|
||||
description: Improve a set of auto-generated GitHub release notes for an Umbraco CMS release. Cross-checks the notes against every PR carrying the release label, adds any that are missing, re-files every PR under the most appropriate category, and strips purely-internal entries. Use whenever the user asks to tidy up, improve, complete, or recategorize release notes for a given version, or mentions a release-notes text file plus a version number.
|
||||
argument-hint: <version> <path-to-generated-notes-file>
|
||||
---
|
||||
|
||||
# Umbraco CMS - Improve Release Notes
|
||||
|
||||
Takes a file of auto-generated GitHub release notes and produces an improved version that:
|
||||
|
||||
1. **Is complete** — every merged PR carrying the `release/<version>` label appears.
|
||||
2. **Is well-categorized** — every PR sits under the most appropriate heading.
|
||||
3. **Is free of noise** — purely-internal entries of no value to a reader are removed.
|
||||
|
||||
The result is written to a **new** file alongside the input, so the user can diff the two.
|
||||
|
||||
**Run autonomously.** Do NOT use `AskUserQuestion` once the required arguments (version and input file path) are available — only ask if one of them is missing from `$ARGUMENTS` and cannot be inferred (see Arguments). Beyond that, make the categorization calls yourself using the rules below; if a handful are genuinely borderline, place them anyway and note the borderline ones in your closing summary so the user can override.
|
||||
|
||||
## Arguments
|
||||
|
||||
`$ARGUMENTS` contains two values:
|
||||
|
||||
1. **Version** — e.g. `17.5.0`, `18.1.0`. The GitHub label to search is `release/<version>` (so version `17.5.0` → label `release/17.5.0`).
|
||||
2. **Input file path** — full path to the text file holding the auto-generated notes (e.g. `C:\Temp\release-17.5.0-rc.md`).
|
||||
|
||||
If either is missing, ask the user once for the missing value, then proceed.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Run `gh auth status`. If it fails, tell the user to authenticate `gh` (e.g. `gh auth login`) and stop — the skill needs the GitHub CLI to query PRs. The repo is always `umbraco/Umbraco-CMS`.
|
||||
|
||||
## Procedure
|
||||
|
||||
### 1. Read the input notes
|
||||
|
||||
Read the input file. Note its structure — it is GitHub's generated format:
|
||||
|
||||
- A leading HTML comment (`<!-- Release notes generated ... -->`).
|
||||
- A `## What's Changed` heading followed by `### <emoji> <Category>` sub-headings, each with `* <title> by @<author> in <url>` bullets.
|
||||
- A trailing `## New Contributors` section and a `**Full Changelog**: ...` line.
|
||||
|
||||
Extract the set of PR numbers already present (parse the `/pull/<number>` from each bullet). Preserve each existing bullet's **exact text** (title, author, URL) when you re-emit it — only its category placement may change.
|
||||
|
||||
### 2. Fetch every labelled PR
|
||||
|
||||
```bash
|
||||
gh pr list --repo umbraco/Umbraco-CMS --label "release/<version>" --state closed --limit 1000 \
|
||||
--json number,title,author,labels,mergedAt \
|
||||
--jq '.[] | select(.mergedAt != null) | "\(.number)\t\(.author.login)\t\([.labels[].name] | join(", "))\t\(.title)"' | sort -n
|
||||
```
|
||||
|
||||
This is the authoritative list of what the release *should* contain. Each row gives number, author, labels, title.
|
||||
|
||||
**Guard against silent truncation.** `gh pr list` caps at `--limit` without warning, so a large release could drop the overflow and the skill would still look "complete". Count the returned rows and compare against the limit:
|
||||
|
||||
```bash
|
||||
gh pr list --repo umbraco/Umbraco-CMS --label "release/<version>" --state closed --limit 1000 --json number --jq 'length'
|
||||
```
|
||||
|
||||
If this equals 1000, the limit was hit — raise `--limit` and re-fetch before continuing. Do **not** proceed on a truncated list.
|
||||
|
||||
### 3. Reconcile
|
||||
|
||||
- **Missing labelled PRs** (labelled but not in the input file): these must be **added**. Build a bullet as `* <title> by @<author> in https://github.com/umbraco/Umbraco-CMS/pull/<number>`.
|
||||
- **Author handle.** `<author>` in the template is the raw `.author.login` value — the bullet supplies the leading `@`, so do not prepend another. `gh`'s `.author.login` already returns bot accounts with the `[bot]` suffix as part of the login — Dependabot comes back as `dependabot[bot]`, not `dependabot` or `app/dependabot` (the `app/` form only appears in git committer metadata and CODEOWNERS, never in `gh`'s JSON). So the login is already in the right shape; use it verbatim (e.g. `.author.login` of `dependabot[bot]` renders as `@dependabot[bot]`, matching what GitHub's generator wrote for the existing bullets). The only thing to guard against is accidentally stripping or altering the `[bot]` suffix.
|
||||
- **PRs in the file but not labelled**: keep them. The generated notes span a commit range (see the `Full Changelog` compare link), so they legitimately include backports / earlier-version PRs that lack the current label. For any of these you need to categorize, fetch its labels with:
|
||||
|
||||
```bash
|
||||
gh pr view <number> --repo umbraco/Umbraco-CMS --json number,title,labels \
|
||||
--jq '"\(.number)\t\([.labels[].name] | join(", "))\t\(.title)"'
|
||||
```
|
||||
|
||||
Do **not** invent or alter the `New Contributors` section — carry it over verbatim. You cannot reliably recompute first-time contributors, so leave it as the generator produced it (mention this in the summary).
|
||||
|
||||
### 4. Categorize every PR
|
||||
|
||||
Use exactly these headings, in this order. Omit any heading that ends up with no entries.
|
||||
|
||||
| Heading | What goes here | Primary signal |
|
||||
|---|---|---|
|
||||
| `### 🙌 Notable Changes` | **Don't recategorize existing entries.** Label-driven — but still add any missing PR carrying this label here. | label `category/notable` |
|
||||
| `### 💥 Breaking Changes` | **Don't recategorize existing entries.** Label-driven — but still add any missing PR carrying this label here. | label `category/breaking` |
|
||||
| `### 📦 Dependencies` | Dependency bumps | label `dependencies`; or dependabot author |
|
||||
| `### 🚀 New Features` | New user- or developer-facing capability | label `type/feature` / `category/feature`; or title introduces/adds a genuinely new capability |
|
||||
| `### 🚤 Performance` | Performance improvements | label `category/performance`; or `Performance:` title prefix |
|
||||
| `### 🌈 Accessibility Improvements` | A11y improvements (labels, contrast, keyboard) | label `category/accessibility` / `accessibility`; or clear a11y intent (e.g. "improve contrast", "missing labels") |
|
||||
| `### 🐛 Bug Fixes` | Fixes to broken/incorrect behaviour | default for anything describing a fix |
|
||||
| `### 🧪 Testing` | Test additions/changes only | label `category/test-automation` / `area/test`; or `E2E`/`QA`/"acceptance tests"/"unit test coverage"/"add tests" titles |
|
||||
| `### 🛡️ Code Quality, Documentation and Refactoring` | Refactors, deprecations, API tidy-ups, XML/MD documentation, knowledge-base (`MD`) updates | label `category/refactor`; or titles about refactoring, deprecating, renaming, documenting, constants extraction, MD/CLAUDE.md content |
|
||||
| `### 🧑💻 Developer Experience` | Things that improve the experience of developers building on or contributing to Umbraco — dev tooling, build/watch ergonomics, test mocks/harnesses, backoffice dev utilities | `Developer Experience` title prefix; dev tooling; mock/harness changes |
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Notable and Breaking are off-limits for recategorization** — never move a PR that is *already in the input file* into or out of these sections; they are driven purely by their labels and the generator placed them correctly. This does **not** exempt them from completeness: a PR discovered as missing in step 3 that carries `category/notable` or `category/breaking` must still be **added** under the matching section.
|
||||
- Label signals beat title wording, except a `Performance:`/`Developer Experience:` title prefix is decisive for its section.
|
||||
- A PR with both `type/feature` and `category/refactor` whose title clearly describes a refactor (e.g. "swap relative imports", "re-export type") belongs under Code Quality, not New Features.
|
||||
- "Add ... tests"/"unit test coverage" → Testing, even if it also touches docs. If a PR adds XML documentation *and* tests, lead with where the title's emphasis lies (documentation → Code Quality; test coverage → Testing).
|
||||
- When a PR is genuinely 50/50, pick the more reader-useful heading and list it in your closing summary as borderline.
|
||||
|
||||
### 5. Remove purely-internal noise
|
||||
|
||||
Drop entries that have **no value to anyone reading release notes** — pure repository plumbing with no shipped impact. Examples:
|
||||
|
||||
- Branch/merge maintenance ("Fix main branch after merge issue").
|
||||
- CI/pipeline fixes that don't change the product.
|
||||
- Reverts of changes that never shipped in a release.
|
||||
|
||||
**Keep** anything that ships in the product or genuinely helps developers building on Umbraco — that includes documentation/MD updates, dev tooling, and test mocks (those go to Code Quality or Developer Experience, they are *not* noise). When unsure whether something is noise, keep it and flag it in the summary rather than silently dropping it. List every removal in your closing summary.
|
||||
|
||||
### 6. Write the output
|
||||
|
||||
Write to a new file in the **same folder** as the input, named by appending ` - with updates` before the extension:
|
||||
|
||||
- Input `C:\Temp\release-17.5.0-rc.md` → Output `C:\Temp\release-17.5.0-rc - with updates.md`
|
||||
|
||||
Preserve the leading HTML comment, the `## What's Changed` heading, the `## New Contributors` section, and the `**Full Changelog**` line exactly. Only the `### <category>` groupings and their bullets change.
|
||||
|
||||
### 7. Report
|
||||
|
||||
Give a concise summary:
|
||||
|
||||
- Count of PRs added (with their numbers), and which categories they landed in.
|
||||
- Notable recategorizations (PRs moved out of the catch-all Bug Fixes into Features/Performance/Testing/etc.).
|
||||
- Every entry removed, with the one-line reason.
|
||||
- Any borderline calls the user may want to override.
|
||||
- The output file path.
|
||||
|
||||
## Verification
|
||||
|
||||
Before reporting done, confirm:
|
||||
|
||||
- Every PR number from step 2 is present in the output (except any you deliberately removed in step 5 — and those must be in the removal list).
|
||||
- No PR appears under more than one heading.
|
||||
- Notable and Breaking sections are byte-for-byte unchanged from the input.
|
||||
- The header comment, New Contributors, and Full Changelog lines are intact.
|
||||
@@ -545,6 +545,8 @@ Allowed, but cheap to write and cheaper to leave behind. Keep them short and tra
|
||||
|
||||
Verify any test you add for a bug fix actually catches the bug: either write the failing test first (TDD), or temporarily revert the production change and confirm the test fails before re-applying. A test that passes both ways proves nothing. Watch for coincidental passes — default seed/sort orders can make a buggy path produce the right answer for the test's specific inputs; construct inputs so the broken and fixed behaviours give visibly different results.
|
||||
|
||||
For integration tests that exercise caching or cache refreshers, see `tests/Umbraco.Tests.Integration/CLAUDE.md` — the harness disables caching by default, which can produce false greens.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
@@ -45,15 +45,15 @@
|
||||
<PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.1" />
|
||||
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.1" />
|
||||
<PackageVersion Include="Dazinator.Extensions.FileProviders" Version="2.0.0" />
|
||||
<PackageVersion Include="Examine" Version="3.7.1" />
|
||||
<PackageVersion Include="Examine.Core" Version="3.7.1" />
|
||||
<PackageVersion Include="Examine" Version="3.8.0" />
|
||||
<PackageVersion Include="Examine.Core" Version="3.8.0" />
|
||||
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
|
||||
<PackageVersion Include="JsonPatch.Net" Version="3.3.0" />
|
||||
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
|
||||
<PackageVersion Include="MailKit" Version="4.16.0" />
|
||||
<PackageVersion Include="Markdig" Version="0.45.0" />
|
||||
<PackageVersion Include="Markdown" Version="2.2.1" />
|
||||
<PackageVersion Include="MessagePack" Version="3.1.4" />
|
||||
<PackageVersion Include="MessagePack" Version="3.1.7" />
|
||||
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
|
||||
<PackageVersion Include="MiniProfiler.Shared" Version="4.5.4" />
|
||||
<PackageVersion Include="ncrontab" Version="3.4.0" />
|
||||
@@ -92,4 +92,4 @@
|
||||
<!-- TODO: Remove this pinned dependency when Examine updates its Microsoft.AspNetCore.DataProtection reference. -->
|
||||
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.6" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
+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 }}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.Security.Authorization;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
using Umbraco.Cms.Core.Actions;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Document;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an API endpoint for sorting the root-level documents by a system field.
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
public class SortChildrenAtRootDocumentController : DocumentControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IContentEditingService _contentEditingService;
|
||||
private readonly IEntityService _entityService;
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SortChildrenAtRootDocumentController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="authorizationService">Service used to authorize user actions.</param>
|
||||
/// <param name="contentEditingService">Service for editing and managing content.</param>
|
||||
/// <param name="entityService">Service used to resolve the children to authorize.</param>
|
||||
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
|
||||
public SortChildrenAtRootDocumentController(
|
||||
IAuthorizationService authorizationService,
|
||||
IContentEditingService contentEditingService,
|
||||
IEntityService entityService,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_contentEditingService = contentEditingService;
|
||||
_entityService = entityService;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the root-level documents by a system field.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
|
||||
/// <param name="requestModel">The field to sort by and the sort direction.</param>
|
||||
/// <returns>
|
||||
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
|
||||
/// returns <c>200 OK</c> if sorting succeeds or <c>400 Bad Request</c> if the field is not recognised.
|
||||
/// </returns>
|
||||
[HttpPut("root/sort-children")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[EndpointSummary("Sorts the root-level documents by a field.")]
|
||||
[EndpointDescription("Sorts the root-level documents by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so documents that do not vary by it (or an unrecognised culture) fall back to the invariant name.")]
|
||||
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, SortDocumentChildrenByFieldRequestModel requestModel)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
ContentPermissionResource.WithKeys(ActionSort.ActionLetter, (Guid?)null),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
|
||||
_authorizationService,
|
||||
_entityService,
|
||||
User,
|
||||
parentKey: null,
|
||||
UmbracoObjectTypes.Document,
|
||||
childKeys => ContentPermissionResource.WithKeys(ActionSort.ActionLetter, childKeys),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!childrenAuthorized)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
ContentEditingOperationStatus result = await _contentEditingService.SortByFieldAsync(
|
||||
null,
|
||||
requestModel.Field,
|
||||
requestModel.Direction,
|
||||
requestModel.Culture,
|
||||
CurrentUserKey(_backOfficeSecurityAccessor));
|
||||
|
||||
return result == ContentEditingOperationStatus.Success
|
||||
? Ok()
|
||||
: ContentEditingOperationStatusResult(result);
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.Security.Authorization;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
using Umbraco.Cms.Core.Actions;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Document;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an API endpoint for sorting the children of a document by a system field.
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
public class SortChildrenDocumentController : DocumentControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IContentEditingService _contentEditingService;
|
||||
private readonly IEntityService _entityService;
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SortChildrenDocumentController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="authorizationService">Service used to authorize user actions.</param>
|
||||
/// <param name="contentEditingService">Service for editing and managing content.</param>
|
||||
/// <param name="entityService">Service used to resolve the children to authorize.</param>
|
||||
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
|
||||
public SortChildrenDocumentController(
|
||||
IAuthorizationService authorizationService,
|
||||
IContentEditingService contentEditingService,
|
||||
IEntityService entityService,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_contentEditingService = contentEditingService;
|
||||
_entityService = entityService;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the child documents of the specified parent document by a system field.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
|
||||
/// <param name="id">The unique identifier of the parent document whose children should be sorted.</param>
|
||||
/// <param name="requestModel">The field to sort by and the sort direction.</param>
|
||||
/// <returns>
|
||||
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
|
||||
/// returns <c>200 OK</c> if sorting succeeds, <c>400 Bad Request</c> if the field is not recognised, or <c>404 Not Found</c> if the parent document does not exist.
|
||||
/// </returns>
|
||||
[HttpPut("{id:guid}/sort-children")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[EndpointSummary("Sorts the children of a document by a field.")]
|
||||
[EndpointDescription("Sorts the children of the specified parent document by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so children that do not vary by it (or an unrecognised culture) fall back to the invariant name.")]
|
||||
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, Guid id, SortDocumentChildrenByFieldRequestModel requestModel)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
ContentPermissionResource.WithKeys(ActionSort.ActionLetter, id),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
|
||||
_authorizationService,
|
||||
_entityService,
|
||||
User,
|
||||
id,
|
||||
UmbracoObjectTypes.Document,
|
||||
childKeys => ContentPermissionResource.WithKeys(ActionSort.ActionLetter, childKeys),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!childrenAuthorized)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
ContentEditingOperationStatus result = await _contentEditingService.SortByFieldAsync(
|
||||
id,
|
||||
requestModel.Field,
|
||||
requestModel.Direction,
|
||||
requestModel.Culture,
|
||||
CurrentUserKey(_backOfficeSecurityAccessor));
|
||||
|
||||
return result == ContentEditingOperationStatus.Success
|
||||
? Ok()
|
||||
: ContentEditingOperationStatusResult(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.Security.Authorization;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an API endpoint for sorting the root-level media items by a system field.
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
public class SortChildrenAtRootMediaController : MediaControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IMediaEditingService _mediaEditingService;
|
||||
private readonly IEntityService _entityService;
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SortChildrenAtRootMediaController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="authorizationService">Service used to authorize user actions.</param>
|
||||
/// <param name="mediaEditingService">Service responsible for editing and sorting media items.</param>
|
||||
/// <param name="entityService">Service used to resolve the children to authorize.</param>
|
||||
/// <param name="backOfficeSecurityAccessor">Accessor for backoffice security context.</param>
|
||||
public SortChildrenAtRootMediaController(
|
||||
IAuthorizationService authorizationService,
|
||||
IMediaEditingService mediaEditingService,
|
||||
IEntityService entityService,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_mediaEditingService = mediaEditingService;
|
||||
_entityService = entityService;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the root-level media items by a system field.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
|
||||
/// <param name="requestModel">The field to sort by and the sort direction.</param>
|
||||
/// <returns>
|
||||
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
|
||||
/// returns <c>200 OK</c> if sorting succeeds or <c>400 Bad Request</c> if the field is not recognised.
|
||||
/// </returns>
|
||||
[HttpPut("root/sort-children")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[EndpointSummary("Sorts the root-level media items by a field.")]
|
||||
[EndpointDescription("Sorts the root-level media items by a system field in the given direction.")]
|
||||
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, SortMediaChildrenByFieldRequestModel requestModel)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
MediaPermissionResource.Root(),
|
||||
AuthorizationPolicies.MediaPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
|
||||
_authorizationService,
|
||||
_entityService,
|
||||
User,
|
||||
parentKey: null,
|
||||
UmbracoObjectTypes.Media,
|
||||
childKeys => MediaPermissionResource.WithKeys(childKeys),
|
||||
AuthorizationPolicies.MediaPermissionByResource);
|
||||
|
||||
if (!childrenAuthorized)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
ContentEditingOperationStatus result = await _mediaEditingService.SortByFieldAsync(
|
||||
null,
|
||||
requestModel.Field,
|
||||
requestModel.Direction,
|
||||
CurrentUserKey(_backOfficeSecurityAccessor));
|
||||
|
||||
return result == ContentEditingOperationStatus.Success
|
||||
? Ok()
|
||||
: ContentEditingOperationStatusResult(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.Security.Authorization;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an API endpoint for sorting the children of a media item by a system field.
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
public class SortChildrenMediaController : MediaControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IMediaEditingService _mediaEditingService;
|
||||
private readonly IEntityService _entityService;
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SortChildrenMediaController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="authorizationService">Service used to authorize user actions.</param>
|
||||
/// <param name="mediaEditingService">Service responsible for editing and sorting media items.</param>
|
||||
/// <param name="entityService">Service used to resolve the children to authorize.</param>
|
||||
/// <param name="backOfficeSecurityAccessor">Accessor for backoffice security context.</param>
|
||||
public SortChildrenMediaController(
|
||||
IAuthorizationService authorizationService,
|
||||
IMediaEditingService mediaEditingService,
|
||||
IEntityService entityService,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_mediaEditingService = mediaEditingService;
|
||||
_entityService = entityService;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the child media items of the specified parent media item by a system field.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
|
||||
/// <param name="id">The unique identifier of the parent media item whose children should be sorted.</param>
|
||||
/// <param name="requestModel">The field to sort by and the sort direction.</param>
|
||||
/// <returns>
|
||||
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
|
||||
/// returns <c>200 OK</c> if sorting succeeds, <c>400 Bad Request</c> if the field is not recognised, or <c>404 Not Found</c> if the parent media item does not exist.
|
||||
/// </returns>
|
||||
[HttpPut("{id:guid}/sort-children")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[EndpointSummary("Sorts the children of a media item by a field.")]
|
||||
[EndpointDescription("Sorts the children of the specified parent media item by a system field in the given direction.")]
|
||||
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, Guid id, SortMediaChildrenByFieldRequestModel requestModel)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
MediaPermissionResource.WithKeys(id),
|
||||
AuthorizationPolicies.MediaPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
|
||||
_authorizationService,
|
||||
_entityService,
|
||||
User,
|
||||
id,
|
||||
UmbracoObjectTypes.Media,
|
||||
childKeys => MediaPermissionResource.WithKeys(childKeys),
|
||||
AuthorizationPolicies.MediaPermissionByResource);
|
||||
|
||||
if (!childrenAuthorized)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
ContentEditingOperationStatus result = await _mediaEditingService.SortByFieldAsync(
|
||||
id,
|
||||
requestModel.Field,
|
||||
requestModel.Direction,
|
||||
CurrentUserKey(_backOfficeSecurityAccessor));
|
||||
|
||||
return result == ContentEditingOperationStatus.Success
|
||||
? Ok()
|
||||
: ContentEditingOperationStatusResult(result);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.DataType;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.PropertyEditors;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
@@ -16,6 +19,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
private readonly IDataValueEditorFactory _dataValueEditorFactory;
|
||||
private readonly IConfigurationEditorJsonSerializer _configurationEditorJsonSerializer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DataTypePresentationFactory> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DataTypePresentationFactory"/> class, which is responsible for creating data type presentation models.
|
||||
@@ -25,18 +29,46 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
/// <param name="dataValueEditorFactory">Factory for creating data value editors.</param>
|
||||
/// <param name="configurationEditorJsonSerializer">Serializer for configuration editor JSON data.</param>
|
||||
/// <param name="timeProvider">Provides the current time for time-dependent operations.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public DataTypePresentationFactory(
|
||||
IDataTypeContainerService dataTypeContainerService,
|
||||
PropertyEditorCollection propertyEditorCollection,
|
||||
IDataValueEditorFactory dataValueEditorFactory,
|
||||
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer,
|
||||
TimeProvider timeProvider)
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DataTypePresentationFactory> logger)
|
||||
{
|
||||
_dataTypeContainerService = dataTypeContainerService;
|
||||
_propertyEditorCollection = propertyEditorCollection;
|
||||
_dataValueEditorFactory = dataValueEditorFactory;
|
||||
_configurationEditorJsonSerializer = configurationEditorJsonSerializer;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DataTypePresentationFactory"/> class, which is responsible for creating data type presentation models.
|
||||
/// </summary>
|
||||
/// <param name="dataTypeContainerService">Service used to manage data type containers.</param>
|
||||
/// <param name="propertyEditorCollection">A collection containing all available property editors.</param>
|
||||
/// <param name="dataValueEditorFactory">Factory for creating data value editors.</param>
|
||||
/// <param name="configurationEditorJsonSerializer">Serializer for configuration editor JSON data.</param>
|
||||
/// <param name="timeProvider">Provides the current time for time-dependent operations.</param>
|
||||
[Obsolete("Please use the constructor that takes all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public DataTypePresentationFactory(
|
||||
IDataTypeContainerService dataTypeContainerService,
|
||||
PropertyEditorCollection propertyEditorCollection,
|
||||
IDataValueEditorFactory dataValueEditorFactory,
|
||||
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer,
|
||||
TimeProvider timeProvider)
|
||||
: this(
|
||||
dataTypeContainerService,
|
||||
propertyEditorCollection,
|
||||
dataValueEditorFactory,
|
||||
configurationEditorJsonSerializer,
|
||||
timeProvider,
|
||||
StaticServiceProvider.Instance.GetRequiredService<ILogger<DataTypePresentationFactory>>())
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -72,7 +104,6 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
dataType.Key = requestModel.Id.Value;
|
||||
}
|
||||
|
||||
|
||||
return Attempt.SucceedWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.Success, dataType);
|
||||
}
|
||||
|
||||
@@ -82,7 +113,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
{
|
||||
try
|
||||
{
|
||||
var parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
|
||||
EntityContainer? parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
|
||||
|
||||
return parent is null
|
||||
? Attempt.FailWithStatus(DataTypeOperationStatus.ParentNotFound, 0)
|
||||
@@ -97,6 +128,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
return Attempt.SucceedWithStatus(DataTypeOperationStatus.Success, Constants.System.Root);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<Attempt<IDataType, DataTypeOperationStatus>> CreateAsync(UpdateDataTypeRequestModel requestModel, IDataType current)
|
||||
{
|
||||
if (!_propertyEditorCollection.TryGet(requestModel.EditorAlias, out IDataEditor? editor))
|
||||
@@ -104,7 +136,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
return Task.FromResult(Attempt.FailWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.PropertyEditorNotFound, new DataType(new VoidEditor(_dataValueEditorFactory), _configurationEditorJsonSerializer) ));
|
||||
}
|
||||
|
||||
IDataType dataType = (IDataType)current.DeepClone();
|
||||
var dataType = (IDataType)current.DeepClone();
|
||||
|
||||
IDictionary<string, object> configurationData = MapConfigurationData(requestModel, editor);
|
||||
dataType.Name = requestModel.Name;
|
||||
@@ -119,12 +151,26 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
|
||||
private ValueStorageType GetEditorValueStorageType(IDataEditor editor, IDictionary<string, object> configurationData)
|
||||
{
|
||||
var configurationObject = editor.GetConfigurationEditor()
|
||||
.ToConfigurationObject(configurationData, _configurationEditorJsonSerializer);
|
||||
|
||||
if (configurationObject is IConfigureValueType configureValueType)
|
||||
// Only editors whose configuration object implements IConfigureValueType derive their storage
|
||||
// type from the configuration. Building the typed configuration object can throw for editors
|
||||
// whose stored configuration doesn't cleanly deserialize into their configuration type; that
|
||||
// must not fail the save, so fall back to the value editor's value type in that case.
|
||||
try
|
||||
{
|
||||
return ValueTypes.ToStorageType(configureValueType.ValueType);
|
||||
if (editor.GetConfigurationEditor().ToConfigurationObject(configurationData, _configurationEditorJsonSerializer)
|
||||
is IConfigureValueType configureValueType)
|
||||
{
|
||||
return ValueTypes.ToStorageType(configureValueType.ValueType);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Configuration editors are third-party and can throw anything when the stored configuration
|
||||
// doesn't deserialize into their configuration type. Fall back to the value editor's value type
|
||||
// rather than failing the save, but log so the misconfiguration remains observable.
|
||||
_logger.LogError(
|
||||
"Could not build the configuration object for editor {EditorAlias} to determine its value storage type; falling back to the value editor's value type.",
|
||||
editor.Alias);
|
||||
}
|
||||
|
||||
var valueType = editor.GetValueEditor().ValueType;
|
||||
|
||||
+546
@@ -10888,6 +10888,150 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/document/{id}/sort-children": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Document"
|
||||
],
|
||||
"summary": "Sorts the children of a document by a field.",
|
||||
"description": "Sorts the children of the specified parent document by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so children that do not vary by it (or an unrecognised culture) fall back to the invariant name.",
|
||||
"operationId": "PutDocumentByIdSortChildren",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "The resource is protected and requires an authentication token"
|
||||
},
|
||||
"403": {
|
||||
"description": "The authenticated user does not have access to this resource",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Backoffice-User": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/document/{id}/unpublish": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -11282,6 +11426,113 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/document/root/sort-children": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Document"
|
||||
],
|
||||
"summary": "Sorts the root-level documents by a field.",
|
||||
"description": "Sorts the root-level documents by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so documents that do not vary by it (or an unrecognised culture) fall back to the invariant name.",
|
||||
"operationId": "PutDocumentRootSortChildren",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "The resource is protected and requires an authentication token"
|
||||
},
|
||||
"403": {
|
||||
"description": "The authenticated user does not have access to this resource",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Backoffice-User": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/document/sort": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -19158,6 +19409,150 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/media/{id}/sort-children": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Media"
|
||||
],
|
||||
"summary": "Sorts the children of a media item by a field.",
|
||||
"description": "Sorts the children of the specified parent media item by a system field in the given direction. Media items do not vary by culture, so any supplied culture is ignored.",
|
||||
"operationId": "PutMediaByIdSortChildren",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "The resource is protected and requires an authentication token"
|
||||
},
|
||||
"403": {
|
||||
"description": "The authenticated user does not have access to this resource",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Backoffice-User": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/media/{id}/validate": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -19408,6 +19803,113 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/media/root/sort-children": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Media"
|
||||
],
|
||||
"summary": "Sorts the root-level media items by a field.",
|
||||
"description": "Sorts the root-level media items by a system field in the given direction. Media items do not vary by culture, so any supplied culture is ignored.",
|
||||
"operationId": "PutMediaRootSortChildren",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "The resource is protected and requires an authentication token"
|
||||
},
|
||||
"403": {
|
||||
"description": "The authenticated user does not have access to this resource",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Backoffice-User": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/media/sort": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -39856,6 +40358,14 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ContentSortFieldModel": {
|
||||
"enum": [
|
||||
"Name",
|
||||
"CreateDate",
|
||||
"UpdateDate"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"CopyDataTypeRequestModel": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -50162,6 +50672,42 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SortDocumentChildrenByFieldRequestModel": {
|
||||
"required": [
|
||||
"direction",
|
||||
"field"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"field": {
|
||||
"$ref": "#/components/schemas/ContentSortFieldModel"
|
||||
},
|
||||
"direction": {
|
||||
"$ref": "#/components/schemas/DirectionModel"
|
||||
},
|
||||
"culture": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SortMediaChildrenByFieldRequestModel": {
|
||||
"required": [
|
||||
"direction",
|
||||
"field"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"field": {
|
||||
"$ref": "#/components/schemas/ContentSortFieldModel"
|
||||
},
|
||||
"direction": {
|
||||
"$ref": "#/components/schemas/DirectionModel"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SortingRequestModel": {
|
||||
"required": [
|
||||
"sorting"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Security.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Authorizes permissions on all direct children of a node.
|
||||
/// </summary>
|
||||
internal static class AllChildrenAuthorizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether the user is authorized for every direct child of the given parent (or the root).
|
||||
/// </summary>
|
||||
/// <param name="authorizationService">The authorization service.</param>
|
||||
/// <param name="entityService">The entity service used to resolve the children.</param>
|
||||
/// <param name="user">The current user.</param>
|
||||
/// <param name="parentKey">The parent key, or <c>null</c> to authorize the root-level children.</param>
|
||||
/// <param name="objectType">The object type of the children (and parent).</param>
|
||||
/// <param name="resourceFactory">Builds the permission resource to authorize a batch of child keys against.</param>
|
||||
/// <param name="policy">The authorization policy to apply.</param>
|
||||
/// <returns><c>true</c> if the user is authorized against all children; otherwise <c>false</c>.</returns>
|
||||
public static async Task<bool> IsAuthorizedForChildrenAsync(
|
||||
IAuthorizationService authorizationService,
|
||||
IEntityService entityService,
|
||||
ClaimsPrincipal user,
|
||||
Guid? parentKey,
|
||||
UmbracoObjectTypes objectType,
|
||||
Func<IEnumerable<Guid>, IPermissionResource> resourceFactory,
|
||||
string policy)
|
||||
{
|
||||
const int pageSize = 500;
|
||||
var page = 0;
|
||||
long total;
|
||||
do
|
||||
{
|
||||
Guid[] childKeys = entityService
|
||||
.GetPagedChildren(parentKey, [objectType], objectType, page * pageSize, pageSize, out total)
|
||||
.Select(child => child.Key)
|
||||
.ToArray();
|
||||
|
||||
if (childKeys.Length > 0)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await authorizationService.AuthorizeResourceAsync(
|
||||
user,
|
||||
resourceFactory(childKeys),
|
||||
policy);
|
||||
|
||||
if (authorizationResult.Succeeded is false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
while (page * pageSize < total);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Models.ContentEditing;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
|
||||
/// <summary>
|
||||
/// Base request model for sorting the children of a node by a system field.
|
||||
/// </summary>
|
||||
public abstract class SortChildrenByFieldRequestModelBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the system field to sort the children by.
|
||||
/// The create and update dates are node-level (not culture-specific).
|
||||
/// </summary>
|
||||
public required ContentSortField Field { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the direction to sort in.
|
||||
/// </summary>
|
||||
public required Direction Direction { get; init; }
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
using Umbraco.Cms.Core.Models.ContentEditing;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
|
||||
/// <summary>
|
||||
/// Request model for sorting the children of a document by a system field.
|
||||
/// </summary>
|
||||
public class SortDocumentChildrenByFieldRequestModel : SortChildrenByFieldRequestModelBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the culture whose variant name to sort by, or <c>null</c> to sort by the invariant name.
|
||||
/// Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a document that
|
||||
/// does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.
|
||||
/// </summary>
|
||||
public string? Culture { get; init; }
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
|
||||
/// <summary>
|
||||
/// Request model for sorting the children of a media item by a system field.
|
||||
/// Media items do not vary by culture, so no culture is accepted.
|
||||
/// </summary>
|
||||
public class SortMediaChildrenByFieldRequestModel : SortChildrenByFieldRequestModelBase
|
||||
{
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -15,8 +15,9 @@ SQLite-specific EF Core provider for Umbraco CMS. Contains SQLite migrations and
|
||||
This is a thin provider project that implements SQLite-specific functionality for the EF Core persistence layer:
|
||||
|
||||
1. **Migration Provider** - Executes SQLite-specific migrations
|
||||
2. **Migration Provider Setup** - Configures DbContext to use SQLite
|
||||
2. **Migration Provider Setup** - Configures DbContext to use SQLite (incl. transient-error retry)
|
||||
3. **Migrations** - SQLite-specific migration files for OpenIddict tables
|
||||
4. **Retrying Execution Strategy** - Retries transient SQLite lock errors on EF Core operations
|
||||
|
||||
### Folder Structure
|
||||
|
||||
@@ -30,7 +31,8 @@ Umbraco.Cms.Persistence.EFCore.Sqlite/
|
||||
│ └── UmbracoDbContextModelSnapshot.cs # Current model state
|
||||
├── EFCoreSqliteComposer.cs # DI registration
|
||||
├── SqliteMigrationProvider.cs # IMigrationProvider impl
|
||||
└── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
|
||||
├── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
|
||||
└── SqliteRetryingExecutionStrategy.cs # IExecutionStrategy for transient lock errors
|
||||
```
|
||||
|
||||
### Relationship with Parent Project
|
||||
@@ -65,7 +67,19 @@ Registers `IMigrationProvider` and `IMigrationProviderSetup` for SQLite.
|
||||
|
||||
### SqliteMigrationProviderSetup (line 11-14)
|
||||
|
||||
Configures `DbContextOptionsBuilder` with `UseSqlite` and migrations assembly.
|
||||
Configures `DbContextOptionsBuilder` with `UseSqlite`, the migrations assembly, and the
|
||||
`SqliteRetryingExecutionStrategy` (see below). Invoked from
|
||||
`UmbracoDbContext.ConfigureOptions` for every `UmbracoDbContext` instance, so all EF Core
|
||||
access to the Umbraco database (including OpenIddict's token store) inherits the retry.
|
||||
|
||||
### SqliteRetryingExecutionStrategy
|
||||
|
||||
Custom `Microsoft.EntityFrameworkCore.Storage.ExecutionStrategy` that retries on transient
|
||||
SQLite errors (`SQLITE_BUSY`, `SQLITE_LOCKED`) using `SqliteExceptionExtensions.IsBusyOrLocked`
|
||||
from the parent project. Defaults inherit `ExecutionStrategy.DefaultMaxRetryCount` (6) and
|
||||
`ExecutionStrategy.DefaultMaxDelay` (30s), giving a ~56-second retry budget — see the class's
|
||||
XML doc for the rationale and the unattended-upgrade escape hatch for very long migrations.
|
||||
Added to resolve issue #22939 (OpenIddict token reads failing during long migrations).
|
||||
|
||||
---
|
||||
|
||||
@@ -122,7 +136,8 @@ All tables prefixed with `umbraco`:
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SqliteMigrationProvider.cs` | Migration execution |
|
||||
| `SqliteMigrationProviderSetup.cs` | DbContext configuration |
|
||||
| `SqliteMigrationProviderSetup.cs` | DbContext configuration (UseSqlite + retry strategy) |
|
||||
| `SqliteRetryingExecutionStrategy.cs` | Retry on transient SQLite BUSY/LOCKED errors |
|
||||
| `EFCoreSqliteComposer.cs` | DI registration |
|
||||
| `Migrations/*.cs` | Migration files |
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Persistence.EFCore.Migrations;
|
||||
|
||||
namespace Umbraco.Cms.Persistence.EFCore.Sqlite;
|
||||
@@ -15,6 +14,15 @@ public class SqliteMigrationProviderSetup : IMigrationProviderSetup
|
||||
/// <inheritdoc />
|
||||
public void Setup(DbContextOptionsBuilder builder, string? connectionString)
|
||||
{
|
||||
builder.UseSqlite(connectionString, x => x.MigrationsAssembly(GetType().Assembly.FullName));
|
||||
builder.UseSqlite(connectionString, x =>
|
||||
{
|
||||
x.MigrationsAssembly(GetType().Assembly.FullName);
|
||||
|
||||
// Retry transient SQLite errors (BUSY / LOCKED). See SqliteRetryingExecutionStrategy
|
||||
// for the rationale — long-running migrations or schema-modifying operations can
|
||||
// briefly lock the database in a way that surfaces as a hard error to concurrent
|
||||
// EF Core readers (notably OpenIddict token validation). See issue #22939.
|
||||
x.ExecutionStrategy(deps => new SqliteRetryingExecutionStrategy(deps));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace Umbraco.Cms.Persistence.EFCore.Sqlite;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core execution strategy that retries on transient SQLite errors (BUSY / LOCKED).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// SQLite serialises writers at the database level, and schema-modifying statements briefly
|
||||
/// block readers — even in WAL mode. Without retries, concurrent EF Core reads (for example
|
||||
/// OpenIddict's token validation against <c>umbracoOpenIddictTokens</c>) surface those
|
||||
/// transient locks as <see cref="SqliteException"/> and fail the caller's request.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Microsoft does not ship a built-in execution strategy for SQLite (only the SQL Server
|
||||
/// equivalent), so we provide this one. It piggy-backs on <see cref="ExecutionStrategy"/>'s
|
||||
/// default exponential backoff and re-uses its inherited
|
||||
/// <see cref="ExecutionStrategy.DefaultMaxRetryCount"/> (6) and
|
||||
/// <see cref="ExecutionStrategy.DefaultMaxDelay"/> (30 seconds), which produce a delay
|
||||
/// schedule of roughly 0s, 1s, 3s, 7s, 15s, 30s — a ~56-second retry window.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// On top of those EF Core delays, <c>SQLITE_BUSY</c> (error 5) is also retried internally
|
||||
/// by Microsoft.Data.Sqlite for up to the connection's <c>Default Timeout</c> (30 seconds
|
||||
/// by default) per attempt. <c>SQLITE_LOCKED</c> (error 6) is not — it returns immediately,
|
||||
/// so EF Core's retry budget is the only buffer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class SqliteRetryingExecutionStrategy : ExecutionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SqliteRetryingExecutionStrategy"/> class
|
||||
/// with default retry settings inherited from <see cref="ExecutionStrategy"/>.
|
||||
/// </summary>
|
||||
/// <param name="dependencies">Parameter object containing service dependencies.</param>
|
||||
public SqliteRetryingExecutionStrategy(ExecutionStrategyDependencies dependencies)
|
||||
: this(dependencies, DefaultMaxRetryCount, DefaultMaxDelay)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SqliteRetryingExecutionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dependencies">Parameter object containing service dependencies.</param>
|
||||
/// <param name="maxRetryCount">The maximum number of retry attempts.</param>
|
||||
/// <param name="maxRetryDelay">The maximum delay between retries.</param>
|
||||
public SqliteRetryingExecutionStrategy(
|
||||
ExecutionStrategyDependencies dependencies,
|
||||
int maxRetryCount,
|
||||
TimeSpan maxRetryDelay)
|
||||
: base(dependencies, maxRetryCount, maxRetryDelay)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool ShouldRetryOn(Exception exception)
|
||||
{
|
||||
// EF Core wraps provider exceptions, so walk the inner-exception chain.
|
||||
for (Exception? current = exception; current is not null; current = current.InnerException)
|
||||
{
|
||||
if (current is SqliteException sqlite && sqlite.IsBusyOrLocked())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+1
-7
@@ -184,17 +184,11 @@ internal sealed class SqliteEFCoreDistributedLockingMechanism<T> : IDistributedL
|
||||
throw new ArgumentException($"LockObject with id={LockId} does not exist.");
|
||||
}
|
||||
}
|
||||
catch (SqliteException ex) when (IsBusyOrLocked(ex))
|
||||
catch (SqliteException ex) when (ex.IsBusyOrLocked())
|
||||
{
|
||||
throw new DistributedWriteLockTimeoutException(LockId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsBusyOrLocked(SqliteException ex) =>
|
||||
ex.SqliteErrorCode
|
||||
is raw.SQLITE_BUSY
|
||||
or raw.SQLITE_LOCKED
|
||||
or raw.SQLITE_LOCKED_SHAREDCACHE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using SQLitePCL;
|
||||
|
||||
namespace Umbraco.Cms.Persistence.EFCore;
|
||||
|
||||
/// <summary>
|
||||
/// SQLite-specific exception helpers for code running on the EF Core persistence stack.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A parallel helper exists at <c>Umbraco.Cms.Persistence.Sqlite.Services.SqliteExceptionExtensions</c>
|
||||
/// for the NPoco stack. Both stacks are independent (neither references the other) so the small
|
||||
/// duplication is intentional — keeps the layering clean.
|
||||
/// </remarks>
|
||||
public static class SqliteExceptionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines if the SQLite exception is a BUSY or LOCKED error.
|
||||
/// </summary>
|
||||
/// <param name="ex">The SQLite exception to check.</param>
|
||||
/// <returns><c>true</c> if the error is BUSY, LOCKED, or LOCKED_SHAREDCACHE; otherwise <c>false</c>.</returns>
|
||||
public static bool IsBusyOrLocked(this SqliteException ex) =>
|
||||
ex.SqliteErrorCode
|
||||
is raw.SQLITE_BUSY
|
||||
or raw.SQLITE_LOCKED
|
||||
or raw.SQLITE_LOCKED_SHAREDCACHE;
|
||||
}
|
||||
+10
-1
@@ -23,5 +23,14 @@ public sealed class LanguageDeletedDistributedCacheNotificationHandler : Deleted
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Handle(IEnumerable<ILanguage> entities, IDictionary<string, object?> state)
|
||||
=> _distributedCache.RemoveLanguageCache(entities);
|
||||
{
|
||||
_distributedCache.RemoveLanguageCache(entities);
|
||||
|
||||
// User groups cache their allowed language ids, so a deleted language must be evicted from
|
||||
// them too - otherwise a stale, now-missing id lingers on the cached user group and breaks
|
||||
// reads that resolve those ids. This is a deliberately coarse refresh of the entire user group
|
||||
// and user caches (RefreshAll also clears IUser): we can't know which groups reference the
|
||||
// language without a query, and language deletion is rare enough that a full refresh is fine.
|
||||
_distributedCache.RefreshAllUserGroupCache();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,8 +368,17 @@ public class ObjectCacheAppCache : IAppPolicyCache, IDisposable
|
||||
}
|
||||
|
||||
// Ensure key is removed from set when evicted from cache
|
||||
return options.RegisterPostEvictionCallback((key, _, _, _) =>
|
||||
return options.RegisterPostEvictionCallback((key, _, reason, _) =>
|
||||
{
|
||||
// Removed and Replaced evictions don't need pruning here: the Remove/Clear call sites already
|
||||
// prune the tracking set synchronously under the write lock, and a Replaced key still has a
|
||||
// live entry (the synchronous Set re-added it). Pruning here instead runs on a background
|
||||
// thread and races with that re-add, dropping a key whose entry is still cached. (#23064)
|
||||
if (reason is EvictionReason.Removed or EvictionReason.Replaced)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_locker.TryEnterWriteLock(_writeLockTimeout) is false)
|
||||
|
||||
@@ -16,6 +16,11 @@ public class ContentSettings
|
||||
/// </summary>
|
||||
internal const bool StaticResolveUrlsFromTextString = false;
|
||||
|
||||
/// <summary>
|
||||
/// The default value for whether sorting children by a field fires per-item notifications.
|
||||
/// </summary>
|
||||
internal const bool StaticSortChildrenByFieldFiresNotifications = false;
|
||||
|
||||
/// <summary>
|
||||
/// The default preview badge markup template.
|
||||
/// </summary>
|
||||
@@ -110,6 +115,18 @@ public class ContentSettings
|
||||
[DefaultValue(StaticResolveUrlsFromTextString)]
|
||||
public bool ResolveUrlsFromTextString { get; set; } = StaticResolveUrlsFromTextString;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether sorting the children of a node by a field fires
|
||||
/// per-item save/sort notifications (and therefore webhooks).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Defaults to <c>false</c>: the children are reordered with a single set-based update and a branch
|
||||
/// cache refresh, without per-item notifications. Set to <c>true</c> to restore per-item notifications
|
||||
/// (and webhooks), accepting the additional performance cost on nodes with many children.
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticSortChildrenByFieldFiresNotifications)]
|
||||
public bool SortChildrenByFieldFiresNotifications { get; set; } = StaticSortChildrenByFieldFiresNotifications;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for the collection of error pages.
|
||||
/// </summary>
|
||||
|
||||
@@ -30,6 +30,17 @@ public class DatabaseServerMessengerSettings
|
||||
/// </summary>
|
||||
internal const string StaticTimeBetweenPruneOperations = "00:01:00"; // TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// The default timeout for a single synchronization operation.
|
||||
/// </summary>
|
||||
internal const string StaticSyncTimeout = "00:01:00"; // TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default timeout for a single synchronization operation, for use as a fallback when an invalid
|
||||
/// <see cref="SyncTimeout" /> is configured.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultSyncTimeout = TimeSpan.Parse(StaticSyncTimeout);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for the maximum number of instructions that can be processed at startup; otherwise the server
|
||||
/// cold-boots (rebuilds its caches).
|
||||
@@ -55,4 +66,13 @@ public class DatabaseServerMessengerSettings
|
||||
/// </summary>
|
||||
[DefaultValue(StaticTimeBetweenPruneOperations)]
|
||||
public TimeSpan TimeBetweenPruneOperations { get; set; } = TimeSpan.Parse(StaticTimeBetweenPruneOperations);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum time to wait for a single synchronization operation to complete before it is
|
||||
/// considered stalled (for example, blocked on a hung database connection) and abandoned, so the recurring
|
||||
/// job keeps running rather than stopping permanently. This bounds how long the job waits on a single sync,
|
||||
/// not how long a stalled connection itself takes to recover (which is governed by the database timeouts).
|
||||
/// </summary>
|
||||
[DefaultValue(StaticSyncTimeout)]
|
||||
public TimeSpan SyncTimeout { get; set; } = DefaultSyncTimeout;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,17 @@ public class DatabaseServerRegistrarSettings
|
||||
/// </summary>
|
||||
internal const string StaticStaleServerTimeout = "00:02:00";
|
||||
|
||||
/// <summary>
|
||||
/// The default timeout for a single server touch operation.
|
||||
/// </summary>
|
||||
internal const string StaticTouchTimeout = "00:01:00"; // TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default timeout for a single server touch operation, for use as a fallback when an invalid
|
||||
/// <see cref="TouchTimeout" /> is configured.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultTouchTimeout = TimeSpan.Parse(StaticTouchTimeout);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for the amount of time to wait between calls to the database on the background thread.
|
||||
/// </summary>
|
||||
@@ -31,4 +42,13 @@ public class DatabaseServerRegistrarSettings
|
||||
/// </summary>
|
||||
[DefaultValue(StaticStaleServerTimeout)]
|
||||
public TimeSpan StaleServerTimeout { get; set; } = TimeSpan.Parse(StaticStaleServerTimeout);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum time to wait for a single server touch operation to complete before it is
|
||||
/// considered stalled (for example, blocked on a hung database connection) and abandoned, so the recurring
|
||||
/// job keeps running rather than stopping permanently. This bounds how long the job waits on a single touch,
|
||||
/// not how long a stalled connection itself takes to recover (which is governed by the database timeouts).
|
||||
/// </summary>
|
||||
[DefaultValue(StaticTouchTimeout)]
|
||||
public TimeSpan TouchTimeout { get; set; } = DefaultTouchTimeout;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
// TODO (V18): Remove the registrations of UserPasswordConfigurationSettings and MemberPasswordConfigurationSettings.
|
||||
@@ -102,6 +103,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
.AddUmbracoOptions<CacheSettings>()
|
||||
.AddUmbracoOptions<SystemDateMigrationSettings>()
|
||||
.AddUmbracoOptions<DistributedJobSettings>()
|
||||
.AddUmbracoOptions<ScheduledPublishingSettings>(options => options.ValidateOnStart())
|
||||
.AddUmbracoOptions<BackOfficeTokenCookieSettings>()
|
||||
.AddUmbracoOptions<WebsiteSettings>()
|
||||
.AddUmbracoOptions<SignalRSettings>();
|
||||
|
||||
@@ -405,7 +405,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
|
||||
<!-- The following key get these tokens passed in:
|
||||
0: Comma delimitted list of headers found
|
||||
-->
|
||||
|
||||
@@ -454,7 +454,8 @@
|
||||
<key alias="httpsCheckConfigurationRectifyNotPossible">Mae gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i 'false' yn eich ffeil appSettings.json. Unwaith y byddwch yn cyrchu'r wefan hon gan ddefnyddio'r cynllun HTTPS, dylid gosod hwnnw i 'true'.</key>
|
||||
<key alias="httpsCheckConfigurationCheckResult">Mae'r gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i '%0%' yn eich ffeil appSettings.json, mae eich cwcis %1% wedi'u marcio'n ddiogel.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue">Mae gosodiad yr ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod i <strong>%0%</strong>.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod, felly bydd URL y rhaglen yn cael ei ganfod yn awtomatig o geisiadau sy'n dod i mewn. Argymhellir ei osod yn benodol.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod ac mae canfod URL y rhaglen yn awtomatig wedi'i analluogi (mae 'Umbraco:CMS:WebRouting:ApplicationUrlDetection' yn 'None'). Ni fydd nodweddion sydd angen URL absoliwt, fel e-byst ailosod cyfrinair a gwahoddiadau, yn gweithio. Gosodwch URL y rhaglen yn benodol, neu galluogwch ganfod yn awtomatig.]]></key>
|
||||
<key alias="smtpMailSettingsNotFound">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp'.</key>
|
||||
<key alias="smtpMailSettingsHostNotConfigured">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp:Host'.</key>
|
||||
<key alias="smtpMailSettingsConnectionFail">Methwyd cyrraedd y gweinydd SMTP a ffurfweddwyd gyda gwesteiwr '%0%' a phorth '%1%'. Gwiriwch i sicrhau bod y gosodiadau SMTP yn y ffurfweddiad 'Umbraco:CMS:Global:Smtp' yn gywir.</key>
|
||||
|
||||
@@ -463,7 +463,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
|
||||
<!-- The following key get these tokens passed in:
|
||||
0: Comma delimitted list of headers found
|
||||
-->
|
||||
|
||||
@@ -452,7 +452,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
|
||||
<key alias="clickJackingCheckHeaderFound">
|
||||
<![CDATA[The header or meta-tag <strong>X-Frame-Options</strong> used to control whether a site can be IFRAMEd by another was found.]]></key>
|
||||
<key alias="clickJackingCheckHeaderNotFound">
|
||||
|
||||
@@ -403,7 +403,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
|
||||
<!-- The following key get these tokens passed in:
|
||||
0: Comma delimitted list of headers found
|
||||
-->
|
||||
|
||||
@@ -730,6 +730,10 @@ public static partial class StringExtensions
|
||||
/// </summary>
|
||||
/// <param name="fileName">The file name to convert.</param>
|
||||
/// <returns>A friendly name with the extension stripped, underscores and dashes converted to spaces, and title case applied.</returns>
|
||||
/// <remarks>
|
||||
/// Mirrored client-side in <c>src/Umbraco.Web.UI.Client/src/packages/media/media/utils/to-friendly-name.function.ts</c>;
|
||||
/// keep the two implementations in sync.
|
||||
/// </remarks>
|
||||
public static string ToFriendlyName(this string fileName)
|
||||
{
|
||||
// strip the file extension
|
||||
|
||||
@@ -44,28 +44,34 @@ public class UmbracoApplicationUrlCheck : HealthCheck
|
||||
|
||||
private HealthCheckStatus CheckUmbracoApplicationUrl()
|
||||
{
|
||||
var url = _webRoutingSettings.CurrentValue.UmbracoApplicationUrl;
|
||||
WebRoutingSettings settings = _webRoutingSettings.CurrentValue;
|
||||
var url = settings.UmbracoApplicationUrl;
|
||||
|
||||
string resultMessage;
|
||||
StatusResultType resultType;
|
||||
var success = false;
|
||||
|
||||
if (url.IsNullOrWhiteSpace())
|
||||
if (url.IsNullOrWhiteSpace() is false)
|
||||
{
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
|
||||
resultType = StatusResultType.Warning;
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", [url]);
|
||||
resultType = StatusResultType.Success;
|
||||
}
|
||||
else if (settings.ApplicationUrlDetection == ApplicationUrlDetection.None)
|
||||
{
|
||||
// No explicit URL and auto-detection is disabled, so the application URL can never be established.
|
||||
// Features that require an absolute URL (e.g. password reset and invitation emails) will not work.
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultError");
|
||||
resultType = StatusResultType.Error;
|
||||
}
|
||||
else
|
||||
{
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", new[] { url });
|
||||
resultType = StatusResultType.Success;
|
||||
success = true;
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
|
||||
resultType = StatusResultType.Warning;
|
||||
}
|
||||
|
||||
return new HealthCheckStatus(resultMessage)
|
||||
{
|
||||
ResultType = resultType,
|
||||
ReadMoreLink = success
|
||||
ReadMoreLink = resultType == StatusResultType.Success
|
||||
? null
|
||||
: Constants.HealthChecks.DocumentationLinks.Security.UmbracoApplicationUrlCheck,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Umbraco.Cms.Core.Models.ContentEditing;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a system field that a node's children can be sorted by.
|
||||
/// </summary>
|
||||
public enum ContentSortField
|
||||
{
|
||||
/// <summary>
|
||||
/// Sort by the node's name.
|
||||
/// </summary>
|
||||
Name,
|
||||
|
||||
/// <summary>
|
||||
/// Sort by the date the node was created.
|
||||
/// </summary>
|
||||
CreateDate,
|
||||
|
||||
/// <summary>
|
||||
/// Sort by the date the node was last updated.
|
||||
/// </summary>
|
||||
UpdateDate,
|
||||
}
|
||||
@@ -16,6 +16,19 @@ public interface IContentRepository<in TId, TEntity> : IReadWriteQueryRepository
|
||||
/// </summary>
|
||||
int RecycleBinId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Updates the sort order of the specified nodes so that each node's sort order matches its
|
||||
/// position in the supplied (already ordered) collection, in a single set-based update.
|
||||
/// </summary>
|
||||
/// <param name="orderedNodeIds">The node identifiers in their desired order.</param>
|
||||
/// <remarks>
|
||||
/// This persists the sort order directly and does not load the entities or fire any notifications;
|
||||
/// callers are responsible for any required cache refresh and auditing.
|
||||
/// </remarks>
|
||||
// TODO (V19): Remove the default implementation.
|
||||
void UpdateSortOrder(IReadOnlyList<int> orderedNodeIds)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <summary>
|
||||
/// Gets versions.
|
||||
/// </summary>
|
||||
|
||||
@@ -105,7 +105,15 @@ internal sealed class ContentEditingService
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(ContentCreateModel createModel, Guid userKey)
|
||||
=> await ValidateCulturesAndPropertiesAsync(createModel, createModel.ContentTypeKey, await GetCulturesToValidate(createModel.Variants.Select(variant => variant.Culture), userKey));
|
||||
{
|
||||
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
|
||||
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
|
||||
{
|
||||
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
|
||||
}
|
||||
|
||||
return await ValidateCulturesAndPropertiesAsync(createModel, createModel.ContentTypeKey, await GetCulturesToValidate(createModel.Variants.Select(variant => variant.Culture), userKey));
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<string?>?> GetCulturesToValidate(IEnumerable<string?>? cultures, Guid userKey)
|
||||
{
|
||||
@@ -332,6 +340,15 @@ internal sealed class ContentEditingService
|
||||
Guid userKey)
|
||||
=> await HandleSortAsync(parentKey, sortingModels, userKey);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ContentEditingOperationStatus> SortByFieldAsync(
|
||||
Guid? parentKey,
|
||||
ContentSortField field,
|
||||
Direction direction,
|
||||
string? culture,
|
||||
Guid userKey)
|
||||
=> await HandleSortByFieldAsync(parentKey, field, direction, culture, userKey);
|
||||
|
||||
private async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCulturesAndPropertiesAsync(
|
||||
ContentEditingModelBase contentEditingModelBase,
|
||||
Guid contentTypeKey,
|
||||
@@ -384,8 +401,8 @@ internal sealed class ContentEditingService
|
||||
protected override OperationResult? Delete(IContent content, int userId) => ContentService.Delete(content, userId);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IEnumerable<IContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
|
||||
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: null);
|
||||
protected override IEnumerable<IContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total)
|
||||
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: ordering);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContentEditingOperationStatus Sort(IEnumerable<IContent> items, int userId)
|
||||
@@ -394,6 +411,13 @@ internal sealed class ContentEditingService
|
||||
return OperationResultToOperationStatus(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId)
|
||||
{
|
||||
OperationResult result = ContentService.SortChildren(parentId, orderedChildIds, userId);
|
||||
return OperationResultToOperationStatus(result);
|
||||
}
|
||||
|
||||
private async Task<ContentEditingOperationStatus> Save(IContent content, Guid userKey)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -458,6 +458,10 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
|
||||
{
|
||||
// these are the only result states currently expected from the invoked IContentService operations
|
||||
OperationResultType.Success => ContentEditingOperationStatus.Success,
|
||||
|
||||
// a no-op (e.g. sorting children when nothing needs reordering) is a successful outcome, not an error
|
||||
OperationResultType.NoOperation => ContentEditingOperationStatus.Success,
|
||||
|
||||
OperationResultType.FailedCancelledByEvent => ContentEditingOperationStatus.CancelledByNotification,
|
||||
OperationResultType.FailedCannot => ContentEditingOperationStatus.CannotDeleteWhenReferenced,
|
||||
|
||||
@@ -619,6 +623,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())
|
||||
|
||||
@@ -86,9 +86,10 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
|
||||
/// <param name="parentId">The parent identifier.</param>
|
||||
/// <param name="pageIndex">The zero-based page index.</param>
|
||||
/// <param name="pageSize">The page size.</param>
|
||||
/// <param name="ordering">The ordering to apply, or <c>null</c> to use the default (sort order).</param>
|
||||
/// <param name="total">The total number of children.</param>
|
||||
/// <returns>The paged children.</returns>
|
||||
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total);
|
||||
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total);
|
||||
|
||||
/// <summary>
|
||||
/// Handles the sorting operation asynchronously.
|
||||
@@ -111,16 +112,7 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
|
||||
return ContentEditingOperationStatus.NotFound;
|
||||
}
|
||||
|
||||
const int pageSize = 500;
|
||||
var pageNumber = 0;
|
||||
IEnumerable<TContent> page = GetPagedChildren(contentId.Value, pageNumber++, pageSize, out var total);
|
||||
var children = new List<TContent>((int)total);
|
||||
children.AddRange(page);
|
||||
while (pageNumber * pageSize < total)
|
||||
{
|
||||
page = GetPagedChildren(contentId.Value, pageNumber++, pageSize, out _);
|
||||
children.AddRange(page);
|
||||
}
|
||||
List<TContent> children = LoadAllChildren(contentId.Value, ordering: null);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -138,4 +130,102 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
|
||||
return ContentEditingOperationStatus.SortingInvalid;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles sorting a parent's children by a system field asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="parentKey">The optional parent key.</param>
|
||||
/// <param name="field">The system field to sort the children by.</param>
|
||||
/// <param name="direction">The direction to sort in.</param>
|
||||
/// <param name="culture">The culture whose variant name to sort by, or <c>null</c> to sort by the invariant name. Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a child that does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.</param>
|
||||
/// <param name="userKey">The user key performing the operation.</param>
|
||||
/// <returns>The operation status.</returns>
|
||||
protected async Task<ContentEditingOperationStatus> HandleSortByFieldAsync(
|
||||
Guid? parentKey,
|
||||
ContentSortField field,
|
||||
Direction direction,
|
||||
string? culture,
|
||||
Guid userKey)
|
||||
{
|
||||
var contentId = parentKey.HasValue
|
||||
? ContentService.GetById(parentKey.Value)?.Id
|
||||
: Constants.System.Root;
|
||||
|
||||
if (contentId.HasValue is false)
|
||||
{
|
||||
return ContentEditingOperationStatus.NotFound;
|
||||
}
|
||||
|
||||
Ordering ordering = BuildOrdering(field, direction, culture);
|
||||
|
||||
// The database does the ordering (matching the list view and the order shown in the sort UI).
|
||||
if (ContentSettings.SortChildrenByFieldFiresNotifications)
|
||||
{
|
||||
// Opt-in path: load the children and persist via the standard sort, firing per-item
|
||||
// save/sort notifications (and therefore webhooks), at the cost of loading every child.
|
||||
List<TContent> orderedChildren = LoadAllChildren(contentId.Value, ordering);
|
||||
if (orderedChildren.Count == 0)
|
||||
{
|
||||
return ContentEditingOperationStatus.Success;
|
||||
}
|
||||
|
||||
return Sort(orderedChildren, await GetUserIdAsync(userKey));
|
||||
}
|
||||
|
||||
// Default path: persist the resulting order with a single set-based update and a branch cache
|
||||
// refresh, without loading every child or firing per-item notifications.
|
||||
List<int> orderedChildIds = LoadOrderedChildIds(contentId.Value, ordering);
|
||||
if (orderedChildIds.Count == 0)
|
||||
{
|
||||
// Nothing to sort - the order is trivially correct.
|
||||
return ContentEditingOperationStatus.Success;
|
||||
}
|
||||
|
||||
return SortChildrenInBulk(contentId.Value, orderedChildIds, await GetUserIdAsync(userKey));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists the supplied (already ordered) child identifiers as the new sort order, without loading
|
||||
/// the children or firing per-item notifications.
|
||||
/// </summary>
|
||||
/// <param name="parentId">The parent identifier, or the root identifier for root-level sorting.</param>
|
||||
/// <param name="orderedChildIds">The child identifiers in their desired order.</param>
|
||||
/// <param name="userId">The user performing the operation.</param>
|
||||
/// <returns>The operation status.</returns>
|
||||
protected abstract ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId);
|
||||
|
||||
private List<int> LoadOrderedChildIds(int contentId, Ordering ordering)
|
||||
=> LoadAllChildren(contentId, ordering, child => child.Id);
|
||||
|
||||
private List<TContent> LoadAllChildren(int contentId, Ordering? ordering)
|
||||
=> LoadAllChildren(contentId, ordering, child => child);
|
||||
|
||||
// Pages through all children, projecting each page with the selector so callers that only need a
|
||||
// lightweight value (e.g. the id) don't retain every loaded child.
|
||||
private List<TResult> LoadAllChildren<TResult>(int contentId, Ordering? ordering, Func<TContent, TResult> selector)
|
||||
{
|
||||
const int pageSize = 500;
|
||||
var pageNumber = 0;
|
||||
IEnumerable<TContent> page = GetPagedChildren(contentId, pageNumber++, pageSize, ordering, out var total);
|
||||
var results = new List<TResult>((int)total);
|
||||
results.AddRange(page.Select(selector));
|
||||
while (pageNumber * pageSize < total)
|
||||
{
|
||||
page = GetPagedChildren(contentId, pageNumber++, pageSize, ordering, out _);
|
||||
results.AddRange(page.Select(selector));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static Ordering BuildOrdering(ContentSortField field, Direction direction, string? culture)
|
||||
=> field switch
|
||||
{
|
||||
// Name is variant - the culture selects the variant name to order by (invariant content and media
|
||||
// ignore it). Create and update dates are node-level, so the culture does not apply.
|
||||
ContentSortField.Name => Ordering.By("name", direction, culture),
|
||||
ContentSortField.CreateDate => Ordering.By("createDate", direction),
|
||||
ContentSortField.UpdateDate => Ordering.By("updateDate", direction),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort field."),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3137,7 +3137,13 @@ public class ContentService : RepositoryService, IContentService
|
||||
{
|
||||
scope.WriteLock(Constants.Locks.ContentTree);
|
||||
|
||||
OperationResult ret = Sort(scope, itemsA, userId, evtMsgs);
|
||||
// Reload within the lock so sorting operates on fully-loaded entities. Callers may pass
|
||||
// partially-loaded content (e.g. loaded with loadTemplates: false or without property data),
|
||||
// and saving those directly would wipe the template and property data (#23120).
|
||||
// GetByIds returns items in the requested order, preserving the caller's ordering that drives the sort.
|
||||
IContent[] reloaded = GetByIds(itemsA.Select(x => x.Id).ToArray()).ToArray();
|
||||
|
||||
OperationResult ret = Sort(scope, reloaded, userId, evtMsgs);
|
||||
scope.Complete();
|
||||
return ret;
|
||||
}
|
||||
@@ -3175,6 +3181,43 @@ public class ContentService : RepositoryService, IContentService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
|
||||
{
|
||||
EventMessages evtMsgs = EventMessagesFactory.Get();
|
||||
if (orderedChildIds.Count == 0)
|
||||
{
|
||||
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
|
||||
}
|
||||
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
scope.WriteLock(Constants.Locks.ContentTree);
|
||||
|
||||
_documentRepository.UpdateSortOrder(orderedChildIds);
|
||||
|
||||
// Sort order lives in umbracoNode; neither the published cache nor the content repository cache keeps
|
||||
// a separate serialized copy of it, so refreshing the affected branch (which invalidates both and has
|
||||
// them reload from umbracoNode) is enough to pick up the new order without re-saving each child.
|
||||
if (parentId == Constants.System.Root)
|
||||
{
|
||||
IContent[] roots = GetByIds(orderedChildIds).ToArray();
|
||||
scope.Notifications.Publish(new ContentTreeChangeNotification(roots, TreeChangeTypes.RefreshNode, evtMsgs));
|
||||
}
|
||||
else
|
||||
{
|
||||
IContent? parent = GetById(parentId);
|
||||
if (parent is not null)
|
||||
{
|
||||
scope.Notifications.Publish(new ContentTreeChangeNotification(parent, TreeChangeTypes.RefreshBranch, evtMsgs));
|
||||
}
|
||||
}
|
||||
|
||||
Audit(AuditType.Sort, userId, parentId);
|
||||
|
||||
scope.Complete();
|
||||
return OperationResult.Succeed(evtMsgs);
|
||||
}
|
||||
|
||||
private OperationResult Sort(ICoreScope scope, IContent[] itemsA, int userId, EventMessages eventMessages)
|
||||
{
|
||||
var sortingNotification = new ContentSortingNotification(itemsA, eventMessages);
|
||||
|
||||
@@ -95,6 +95,18 @@ public interface IContentEditingService
|
||||
/// <returns>The operation status indicating success or failure.</returns>
|
||||
Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey);
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the children of a parent by a system field.
|
||||
/// </summary>
|
||||
/// <param name="parentKey">The unique identifier of the parent, or <c>null</c> for root-level sorting.</param>
|
||||
/// <param name="field">The system field to sort the children by.</param>
|
||||
/// <param name="direction">The direction to sort in.</param>
|
||||
/// <param name="culture">The culture whose variant name to sort by, or <c>null</c> to sort by the invariant name. Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a child that does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.</param>
|
||||
/// <param name="userKey">The unique identifier of the user performing the action.</param>
|
||||
/// <returns>The operation status indicating success or failure.</returns>
|
||||
Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, string? culture, Guid userKey)
|
||||
=> throw new NotImplementedException(); // TODO (V19): Remove default implementation.
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a content item whether it is in the recycle bin or not.
|
||||
/// </summary>
|
||||
|
||||
@@ -542,6 +542,22 @@ public interface IContentService : IContentServiceBase<IContent>
|
||||
/// <returns>The operation result.</returns>
|
||||
OperationResult Sort(IEnumerable<int>? ids, int userId = Constants.Security.SuperUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the children of a parent by persisting the supplied (already ordered) child identifiers
|
||||
/// as the new sort order, in a single set-based update.
|
||||
/// </summary>
|
||||
/// <param name="parentId">The identifier of the parent, or <see cref="Constants.System.Root"/> for the root.</param>
|
||||
/// <param name="orderedChildIds">The child document identifiers, in the desired order.</param>
|
||||
/// <param name="userId">The identifier of the user performing the action.</param>
|
||||
/// <returns>The operation result.</returns>
|
||||
/// <remarks>
|
||||
/// Unlike <see cref="Sort(IEnumerable{int}?, int)" />, this does not load the children or fire per-item
|
||||
/// save/sort notifications; it persists the order directly and refreshes the affected cache branch.
|
||||
/// </remarks>
|
||||
// TODO (V19): Remove the default implementation.
|
||||
OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
#endregion
|
||||
|
||||
#region Publish Document
|
||||
|
||||
@@ -118,6 +118,18 @@ public interface IMediaEditingService
|
||||
/// </returns>
|
||||
Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey);
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the children of a parent by a system field.
|
||||
/// </summary>
|
||||
/// <param name="parentKey">The unique identifier of the parent, or <c>null</c> for root-level sorting.</param>
|
||||
/// <param name="field">The system field to sort the children by.</param>
|
||||
/// <param name="direction">The direction to sort in.</param>
|
||||
/// <param name="userKey">The unique identifier of the user performing the operation.</param>
|
||||
/// <returns>The operation status indicating the operation outcome.</returns>
|
||||
/// <remarks>Media items never vary by culture, so children are always ordered by the invariant name.</remarks>
|
||||
Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, Guid userKey)
|
||||
=> throw new NotImplementedException(); // TODO (V19): Remove default implementation.
|
||||
|
||||
/// <summary>
|
||||
/// Permanently deletes a media item from the recycle bin.
|
||||
/// </summary>
|
||||
|
||||
@@ -359,6 +359,22 @@ public interface IMediaService : IContentServiceBase<IMedia>
|
||||
/// <returns>True if sorting succeeded, otherwise False</returns>
|
||||
bool Sort(IEnumerable<IMedia> items, int userId = Constants.Security.SuperUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the children of a parent by persisting the supplied (already ordered) child identifiers
|
||||
/// as the new sort order, in a single set-based update.
|
||||
/// </summary>
|
||||
/// <param name="parentId">The identifier of the parent, or <see cref="Constants.System.Root"/> for the root.</param>
|
||||
/// <param name="orderedChildIds">The child media identifiers, in the desired order.</param>
|
||||
/// <param name="userId">The identifier of the user performing the action.</param>
|
||||
/// <returns>The operation result.</returns>
|
||||
/// <remarks>
|
||||
/// Unlike <see cref="Sort(IEnumerable{IMedia}, int)" />, this does not load the children or fire per-item
|
||||
/// save/sort notifications; it persists the order directly and refreshes the affected cache branch.
|
||||
/// </remarks>
|
||||
// TODO (V19): Remove the default implementation.
|
||||
OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="IMedia" /> object using the alias of the <see cref="IMediaType" />
|
||||
/// that this Media should based on.
|
||||
|
||||
@@ -83,7 +83,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)
|
||||
@@ -165,6 +173,12 @@ internal sealed class MediaEditingService
|
||||
public async Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey)
|
||||
=> await HandleSortAsync(parentKey, sortingModels, userKey);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, Guid userKey)
|
||||
|
||||
// Media never varies by culture, so children are always ordered by the invariant name.
|
||||
=> await HandleSortByFieldAsync(parentKey, field, direction, culture: null, userKey);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IMedia New(string? name, int parentId, IMediaType mediaType)
|
||||
=> new Models.Media(name, parentId, mediaType);
|
||||
@@ -187,8 +201,8 @@ internal sealed class MediaEditingService
|
||||
=> ContentService.Delete(media, userId).Result;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
|
||||
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total);
|
||||
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total)
|
||||
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, filter: null, ordering: ordering);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContentEditingOperationStatus Sort(IEnumerable<IMedia> items, int userId)
|
||||
@@ -199,6 +213,13 @@ internal sealed class MediaEditingService
|
||||
: ContentEditingOperationStatus.CancelledByNotification;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId)
|
||||
{
|
||||
OperationResult result = ContentService.SortChildren(parentId, orderedChildIds, userId);
|
||||
return OperationResultToOperationStatus(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a media item to the repository.
|
||||
/// </summary>
|
||||
|
||||
@@ -1414,6 +1414,15 @@ namespace Umbraco.Cms.Core.Services
|
||||
{
|
||||
scope.WriteLock(Constants.Locks.MediaTree);
|
||||
|
||||
// Reload within the lock so sorting operates on fully-loaded entities. Callers may pass
|
||||
// partially-loaded media (e.g. without property data), and saving those directly would
|
||||
// wipe the property data (#23120). Preserve the caller's ordering, which drives the sort.
|
||||
var reloadedById = GetByIds(itemsA.Select(x => x.Id)).ToDictionary(x => x.Id);
|
||||
itemsA = itemsA
|
||||
.Select(x => reloadedById.TryGetValue(x.Id, out IMedia? media) ? media : null)
|
||||
.WhereNotNull()
|
||||
.ToArray();
|
||||
|
||||
var savingNotification = new MediaSavingNotification(itemsA, messages);
|
||||
if (scope.Notifications.PublishCancelable(savingNotification))
|
||||
{
|
||||
@@ -1452,6 +1461,43 @@ namespace Umbraco.Cms.Core.Services
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
|
||||
{
|
||||
EventMessages evtMsgs = EventMessagesFactory.Get();
|
||||
if (orderedChildIds.Count == 0)
|
||||
{
|
||||
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
|
||||
}
|
||||
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
scope.WriteLock(Constants.Locks.MediaTree);
|
||||
|
||||
_mediaRepository.UpdateSortOrder(orderedChildIds);
|
||||
|
||||
// Sort order lives in umbracoNode; neither the published cache nor the media repository cache keeps
|
||||
// a separate serialized copy of it, so refreshing the affected branch (which invalidates both and has
|
||||
// them reload from umbracoNode) is enough to pick up the new order without re-saving each child.
|
||||
if (parentId == Constants.System.Root)
|
||||
{
|
||||
IMedia[] roots = GetByIds(orderedChildIds).ToArray();
|
||||
scope.Notifications.Publish(new MediaTreeChangeNotification(roots, TreeChangeTypes.RefreshNode, evtMsgs));
|
||||
}
|
||||
else
|
||||
{
|
||||
IMedia? parent = GetById(parentId);
|
||||
if (parent is not null)
|
||||
{
|
||||
scope.Notifications.Publish(new MediaTreeChangeNotification(parent, TreeChangeTypes.RefreshBranch, evtMsgs));
|
||||
}
|
||||
}
|
||||
|
||||
Audit(AuditType.Sort, userId, parentId);
|
||||
|
||||
scope.Complete();
|
||||
return OperationResult.Succeed(evtMsgs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the data integrity of the media tree and optionally fixes detected issues.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
|
||||
/// <summary>
|
||||
/// A background job that will be executed by an available server. With a single server setup this will always be the same.
|
||||
@@ -16,6 +16,19 @@ public interface IDistributedBackgroundJob
|
||||
/// </summary>
|
||||
TimeSpan Period { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the job's runs should be aligned to clock boundaries derived from <see cref="Period" />.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <c>true</c>, the job becomes runnable on the next clock boundary that is a multiple of <see cref="Period" />
|
||||
/// (measured from a fixed <strong>UTC</strong> origin, so boundaries fall on round clock times such as on the minute
|
||||
/// or every N seconds) rather than at <c>LastRun + Period</c>.
|
||||
/// For predictable boundaries <see cref="Period" /> should divide evenly into one hour.
|
||||
/// The scheduler may cache this value when it first evaluates registered jobs; changing it at runtime may require an application restart.
|
||||
/// Defaults to <c>false</c>, preserving the original drift-from-completion behaviour.
|
||||
/// </remarks>
|
||||
bool AlignToClock => false;
|
||||
|
||||
/// <summary>
|
||||
/// Run the job.
|
||||
/// </summary>
|
||||
|
||||
+10
-2
@@ -2,7 +2,9 @@
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
@@ -22,7 +24,10 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
|
||||
public string Name => "ScheduledPublishingJob";
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan Period => TimeSpan.FromMinutes(1);
|
||||
public TimeSpan Period => _scheduledPublishingSettings.CurrentValue.Period;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool AlignToClock => _scheduledPublishingSettings.CurrentValue.AlignToClock;
|
||||
|
||||
|
||||
private readonly IContentService _contentService;
|
||||
@@ -31,6 +36,7 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IServerMessenger _serverMessenger;
|
||||
private readonly IUmbracoContextFactory _umbracoContextFactory;
|
||||
private readonly IOptionsMonitor<ScheduledPublishingSettings> _scheduledPublishingSettings;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ScheduledPublishingJob" /> class.
|
||||
@@ -41,7 +47,8 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
|
||||
ILogger<ScheduledPublishingJob> logger,
|
||||
IServerMessenger serverMessenger,
|
||||
ICoreScopeProvider scopeProvider,
|
||||
TimeProvider timeProvider)
|
||||
TimeProvider timeProvider,
|
||||
IOptionsMonitor<ScheduledPublishingSettings> scheduledPublishingSettings)
|
||||
{
|
||||
_contentService = contentService;
|
||||
_umbracoContextFactory = umbracoContextFactory;
|
||||
@@ -49,6 +56,7 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
|
||||
_serverMessenger = serverMessenger;
|
||||
_scopeProvider = scopeProvider;
|
||||
_timeProvider = timeProvider;
|
||||
_scheduledPublishingSettings = scheduledPublishingSettings;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
+60
-8
@@ -4,7 +4,6 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
|
||||
@@ -26,6 +25,8 @@ public class InstructionProcessJob : RecurringBackgroundJobBase
|
||||
|
||||
private readonly ILogger<InstructionProcessJob> _logger;
|
||||
private readonly IServerMessenger _messenger;
|
||||
private readonly TimeSpan _syncTimeout;
|
||||
private Task? _inFlightSync;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InstructionProcessJob" /> class.
|
||||
@@ -41,27 +42,78 @@ public class InstructionProcessJob : RecurringBackgroundJobBase
|
||||
{
|
||||
_messenger = messenger;
|
||||
_logger = logger;
|
||||
_syncTimeout = ValidateSyncTimeout(globalSettings.Value.DatabaseServerMessenger.SyncTimeout);
|
||||
}
|
||||
|
||||
// A non-positive timeout would make every sync "time out" immediately (or throw from WaitAsync for a
|
||||
// negative value), so guard against misconfiguration and fall back to the default. Timeout.InfiniteTimeSpan
|
||||
// is allowed as an explicit opt-out that restores the unbounded wait.
|
||||
private TimeSpan ValidateSyncTimeout(TimeSpan configuredSyncTimeout)
|
||||
{
|
||||
if (configuredSyncTimeout > TimeSpan.Zero || configuredSyncTimeout == Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
return configuredSyncTimeout;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Configured DatabaseServerMessenger.SyncTimeout of {ConfiguredSyncTimeout} is not valid; it must be positive (or Timeout.InfiniteTimeSpan to disable the timeout). Falling back to {DefaultSyncTimeout}.",
|
||||
configuredSyncTimeout,
|
||||
DatabaseServerMessengerSettings.DefaultSyncTimeout);
|
||||
|
||||
return DatabaseServerMessengerSettings.DefaultSyncTimeout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the instruction processing job asynchronously by synchronizing messages using the messenger service.
|
||||
/// Logs an error if the synchronization fails, but always completes the task.
|
||||
/// Logs an error if the synchronization fails or stalls, but always completes the task so polling continues.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
|
||||
/// <returns>
|
||||
/// A completed task representing the asynchronous operation.
|
||||
/// A task representing the asynchronous operation.
|
||||
/// </returns>
|
||||
public override Task RunJobAsync(CancellationToken cancellationToken)
|
||||
public override async Task RunJobAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// If a previous sync is still running (e.g. blocked on a hung database connection after a timeout),
|
||||
// skip starting another. This bounds us to a single in-flight call instead of accumulating blocked
|
||||
// thread-pool threads, and logs the stall once rather than on every interval until it recovers.
|
||||
if (_inFlightSync is { IsCompleted: false })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// IServerMessenger.Sync() is synchronous and cannot observe the cancellation token, so a hung database
|
||||
// connection would otherwise block this job's recurring loop indefinitely and silently stop cache
|
||||
// polling until the process is recycled. Offload it to the thread pool and bound the wait so the loop
|
||||
// survives and keeps polling; the in-flight call keeps running until its connection faults (bounded by
|
||||
// the database command/connection timeout, not by SyncTimeout), after which syncing resumes without a recycle.
|
||||
//
|
||||
// The loop is already started under ExecutionContext.SuppressFlow() (see RecurringBackgroundJobHostedService.StartAsync),
|
||||
// which is what makes offloading the scope-creating Sync() to Task.Run safe for the static ambient scope stack.
|
||||
var syncTask = Task.Run(_messenger.Sync, cancellationToken);
|
||||
_inFlightSync = syncTask;
|
||||
|
||||
// Observe the task's eventual fault on every exit path (timeout, shutdown cancellation, or a late
|
||||
// failure once we have stopped awaiting it) so it never surfaces as an UnobservedTaskException.
|
||||
_ = syncTask.ContinueWith(
|
||||
static t => _ = t.Exception,
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
|
||||
try
|
||||
{
|
||||
_messenger.Sync();
|
||||
await syncTask.WaitAsync(_syncTimeout, cancellationToken);
|
||||
_logger.LogDebug("Synchronized cache instructions.");
|
||||
}
|
||||
catch (Exception e)
|
||||
catch (TimeoutException)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Cache instruction sync did not complete within {SyncTimeout} and may be stalled on a hung database connection. Cache updates are paused on this server until the stalled connection recovers.",
|
||||
_syncTimeout);
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(e, "Failed (will repeat).");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
+71
-9
@@ -33,6 +33,8 @@ public class TouchServerJob : RecurringBackgroundJobBase
|
||||
private readonly IServerRoleAccessor _serverRoleAccessor;
|
||||
private readonly IDisposable? _onChangeRegistration;
|
||||
private GlobalSettings _globalSettings;
|
||||
private TimeSpan _touchTimeout;
|
||||
private Task? _inFlightTouch;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TouchServerJob" /> class.
|
||||
@@ -55,11 +57,13 @@ public class TouchServerJob : RecurringBackgroundJobBase
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings.CurrentValue;
|
||||
_serverRoleAccessor = serverRoleAccessor;
|
||||
_touchTimeout = ValidateTouchTimeout(globalSettings.CurrentValue.DatabaseServerRegistrar.TouchTimeout);
|
||||
|
||||
_onChangeRegistration = globalSettings.OnChange(x =>
|
||||
{
|
||||
_globalSettings = x;
|
||||
Period = x.DatabaseServerRegistrar.WaitTimeBetweenCalls;
|
||||
_touchTimeout = ValidateTouchTimeout(x.DatabaseServerRegistrar.TouchTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,35 +75,93 @@ public class TouchServerJob : RecurringBackgroundJobBase
|
||||
/// <returns>
|
||||
/// A completed task when the job has finished running.
|
||||
/// </returns>
|
||||
public override Task RunJobAsync(CancellationToken cancellationToken)
|
||||
public override async Task RunJobAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// If the IServerRoleAccessor has been changed away from ElectedServerRoleAccessor this task no longer makes sense,
|
||||
// since all it's used for is to allow the ElectedServerRoleAccessor
|
||||
// to figure out what role a given server has, so we just stop this task.
|
||||
if (_serverRoleAccessor is not ElectedServerRoleAccessor)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
return;
|
||||
}
|
||||
|
||||
// If a previous touch is still running (e.g. blocked on a hung database connection after a timeout),
|
||||
// skip starting another. This bounds us to a single in-flight call instead of accumulating blocked
|
||||
// thread-pool threads (each contending for the servers lock), and logs the stall once rather than on
|
||||
// every interval until it recovers.
|
||||
if (_inFlightTouch is { IsCompleted: false })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var serverAddress = _hostingEnvironment.ApplicationMainUrl?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(serverAddress))
|
||||
{
|
||||
_logger.LogWarning("No umbracoApplicationUrl for service (yet), skip.");
|
||||
return Task.CompletedTask;
|
||||
// No application URL is known yet: either detection is off (WebRouting:ApplicationUrlDetection is
|
||||
// None with no UmbracoApplicationUrl set), or detection is on but no request has been served yet.
|
||||
// Register with the machine name as a placeholder so server-role election can still proceed (uniqueness
|
||||
// comes from the server identity, not this address). If a URL is later detected from a request, the next
|
||||
// touch overwrites the placeholder.
|
||||
serverAddress = Environment.MachineName;
|
||||
_logger.LogDebug(
|
||||
"No application URL available; registering server with placeholder address {ServerAddress}.",
|
||||
serverAddress);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Registering server with application URL {ServerAddress}.", serverAddress);
|
||||
}
|
||||
|
||||
// IServerRegistrationService.TouchServer() runs a synchronous database write and cannot observe the
|
||||
// cancellation token, so a hung connection would otherwise block this job's recurring loop indefinitely
|
||||
// and silently stop server-registration heartbeats until the process is recycled. Offload it to the
|
||||
// thread pool and bound the wait so the loop survives and keeps touching.
|
||||
// (See InstructionProcessJob for the same pattern and the ExecutionContext.SuppressFlow rationale.)
|
||||
TimeSpan staleServerTimeout = _globalSettings.DatabaseServerRegistrar.StaleServerTimeout;
|
||||
var touchTask = Task.Run(() => _serverRegistrationService.TouchServer(serverAddress, staleServerTimeout), cancellationToken);
|
||||
_inFlightTouch = touchTask;
|
||||
|
||||
// Observe the task's eventual fault on every exit path (timeout, shutdown cancellation, or a late
|
||||
// failure once we have stopped awaiting it) so it never surfaces as an UnobservedTaskException.
|
||||
_ = touchTask.ContinueWith(
|
||||
static t => _ = t.Exception,
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
|
||||
try
|
||||
{
|
||||
_serverRegistrationService.TouchServer(
|
||||
serverAddress,
|
||||
_globalSettings.DatabaseServerRegistrar.StaleServerTimeout);
|
||||
await touchTask.WaitAsync(_touchTimeout, cancellationToken);
|
||||
_logger.LogDebug("Touched server registration for {ServerAddress}.", serverAddress);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (TimeoutException)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Touching the server registration did not complete within {TouchTimeout} and may be stalled on a hung database connection. Server registration is paused on this server until the stalled connection recovers.",
|
||||
_touchTimeout);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update server record in database.");
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
// A non-positive timeout would make every touch "time out" immediately (or throw from WaitAsync for a
|
||||
// negative value), so guard against misconfiguration and fall back to the default. Timeout.InfiniteTimeSpan
|
||||
// is allowed as an explicit opt-out that restores the unbounded wait.
|
||||
private TimeSpan ValidateTouchTimeout(TimeSpan configuredTouchTimeout)
|
||||
{
|
||||
if (configuredTouchTimeout > TimeSpan.Zero || configuredTouchTimeout == Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
return configuredTouchTimeout;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Configured DatabaseServerRegistrar.TouchTimeout of {ConfiguredTouchTimeout} is not valid; it must be positive (or Timeout.InfiniteTimeSpan to disable the timeout). Falling back to {DefaultTouchTimeout}.",
|
||||
configuredTouchTimeout,
|
||||
DatabaseServerRegistrarSettings.DefaultTouchTimeout);
|
||||
|
||||
return DatabaseServerRegistrarSettings.DefaultTouchTimeout;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -93,7 +93,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,12 +131,15 @@ public class PackageMigrationRunner
|
||||
=> RunPackagePlansAsync(plansToRun).GetAwaiter().GetResult();
|
||||
|
||||
/// <summary>
|
||||
/// Runs the all specified package migration plans and publishes a <see cref="MigrationPlansExecutedNotification" />
|
||||
/// if all are successful.
|
||||
/// Runs all the specified package migration plans and publishes a <see cref="MigrationPlansExecutedNotification" />.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All plans are run to completion even if one fails, so that one package's failure does not block another's.
|
||||
/// A failed plan is reported via <see cref="ExecutedMigrationPlan.Successful" /> on the returned result rather
|
||||
/// than by throwing; callers must inspect the results to detect a failure.
|
||||
/// </remarks>
|
||||
/// <param name="plansToRun"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="Exception">If any plan fails it will throw an exception.</exception>
|
||||
public async Task<IEnumerable<ExecutedMigrationPlan>> RunPackagePlansAsync(IEnumerable<string> plansToRun)
|
||||
{
|
||||
List<ExecutedMigrationPlan> results = new();
|
||||
|
||||
@@ -11,6 +11,7 @@ using Umbraco.Cms.Core.Exceptions;
|
||||
using Umbraco.Cms.Core.Logging;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.Migrations;
|
||||
using Umbraco.Cms.Infrastructure.Migrations.Install;
|
||||
using Umbraco.Cms.Infrastructure.Migrations.Upgrade;
|
||||
using Umbraco.Cms.Infrastructure.Runtime;
|
||||
@@ -163,7 +164,23 @@ public class UnattendedUpgrader : INotificationAsyncHandler<RuntimeUnattendedUpg
|
||||
|
||||
try
|
||||
{
|
||||
await _packageMigrationRunner.RunPackagePlansAsync(pendingMigrations);
|
||||
IEnumerable<ExecutedMigrationPlan> executedPlans =
|
||||
await _packageMigrationRunner.RunPackagePlansAsync(pendingMigrations);
|
||||
|
||||
// Failed plans are reported via the result, not by throwing (the runner deliberately runs all plans to
|
||||
// completion so one package's failure doesn't block another's). Surface them as a boot failure here so the
|
||||
// failure is observable, mirroring the core upgrade path - otherwise the migration stays pending and the
|
||||
// runtime re-derives Upgrading on every boot, leaving the site stuck on the maintenance page.
|
||||
// All failures are reported together.
|
||||
var failedPlans = executedPlans.Where(plan => plan.Successful is false).ToList();
|
||||
if (failedPlans.Count > 0)
|
||||
{
|
||||
SetRuntimeError(CreatePackageMigrationError(failedPlans));
|
||||
notification.UnattendedUpgradeResult =
|
||||
RuntimeUnattendedUpgradeNotification.UpgradeResult.HasErrors;
|
||||
return;
|
||||
}
|
||||
|
||||
notification.UnattendedUpgradeResult = RuntimeUnattendedUpgradeNotification.UpgradeResult.PackageMigrationComplete;
|
||||
|
||||
// Migration plans may have changed published content, so refresh the distributed cache to ensure consistency on first request.
|
||||
@@ -200,6 +217,22 @@ public class UnattendedUpgrader : INotificationAsyncHandler<RuntimeUnattendedUpg
|
||||
}
|
||||
}
|
||||
|
||||
private static Exception CreatePackageMigrationError(IReadOnlyList<ExecutedMigrationPlan> failedPlans)
|
||||
{
|
||||
static Exception ToException(ExecutedMigrationPlan plan)
|
||||
=> plan.Exception ?? new UnattendedInstallException(
|
||||
$"An error occurred while running the unattended package migration '{plan.Plan.Name}'.");
|
||||
|
||||
if (failedPlans.Count == 1)
|
||||
{
|
||||
return ToException(failedPlans[0]);
|
||||
}
|
||||
|
||||
return new AggregateException(
|
||||
$"{failedPlans.Count} unattended package migrations failed: {string.Join(", ", failedPlans.Select(plan => plan.Plan.Name))}.",
|
||||
failedPlans.Select(ToException));
|
||||
}
|
||||
|
||||
private void SetRuntimeError(Exception exception)
|
||||
=> _runtimeState.Configure(
|
||||
RuntimeLevel.BootFailed,
|
||||
|
||||
+30
@@ -1297,6 +1297,36 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement
|
||||
/// </summary>
|
||||
public abstract int RecycleBinId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UpdateSortOrder(IReadOnlyList<int> orderedNodeIds)
|
||||
{
|
||||
if (orderedNodeIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var nodeTable = SqlSyntax.GetQuotedTableName(NodeDto.TableName);
|
||||
var idColumn = SqlSyntax.GetQuotedColumnName(NodeDto.IdColumnName);
|
||||
var sortOrderColumn = SqlSyntax.GetQuotedColumnName(NodeDto.SortOrderColumnName);
|
||||
|
||||
// Each node's new sort order is its position in the ordered collection.
|
||||
var ordered = orderedNodeIds
|
||||
.Select((id, sortOrder) => new KeyValuePair<int, int>(id, sortOrder))
|
||||
.ToList();
|
||||
|
||||
// Two parameters per node (id + sort order), so batch to stay within the SQL Server parameter limit.
|
||||
foreach (IEnumerable<KeyValuePair<int, int>> group in ordered.InGroupsOf(Constants.Sql.MaxParameterCount / 2))
|
||||
{
|
||||
List<KeyValuePair<int, int>> groupList = group.ToList();
|
||||
var args = groupList.SelectMany(pair => new object[] { pair.Key, pair.Value }).ToArray();
|
||||
var whenClauses = string.Join(" ", groupList.Select((_, i) => $"WHEN @{i * 2} THEN @{(i * 2) + 1}"));
|
||||
var inClause = string.Join(", ", groupList.Select((_, i) => $"@{i * 2}"));
|
||||
|
||||
var sql = $"UPDATE {nodeTable} SET {sortOrderColumn} = CASE {idColumn} {whenClauses} END WHERE {idColumn} IN ({inClause})";
|
||||
Database.Execute(sql, args);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all entities that are currently in the recycle bin.
|
||||
/// </summary>
|
||||
|
||||
@@ -20,6 +20,10 @@ public class DistributedJobService : IDistributedJobService
|
||||
private readonly ILogger<DistributedJobService> _logger;
|
||||
private readonly DistributedJobSettings _settings;
|
||||
|
||||
// Which jobs align to the clock is a startup configuration concern (changing it requires a restart), so it is
|
||||
// captured once in the constructor rather than re-evaluated on every poll.
|
||||
private readonly HashSet<string> _clockAlignedJobNames;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DistributedJobService"/> class.
|
||||
/// </summary>
|
||||
@@ -58,6 +62,10 @@ public class DistributedJobService : IDistributedJobService
|
||||
_distributedBackgroundJobs = distributedBackgroundJobs;
|
||||
_logger = logger;
|
||||
_settings = settings.Value;
|
||||
_clockAlignedJobNames = _distributedBackgroundJobs
|
||||
.Where(x => x.AlignToClock)
|
||||
.Select(x => x.Name)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -67,9 +75,12 @@ public class DistributedJobService : IDistributedJobService
|
||||
|
||||
scope.EagerWriteLock(Constants.Locks.DistributedJobs);
|
||||
|
||||
DateTime utcNow = DateTime.UtcNow;
|
||||
|
||||
IEnumerable<DistributedBackgroundJobModel> jobs = _distributedJobRepository.GetAll();
|
||||
DistributedBackgroundJobModel? job = jobs.FirstOrDefault(x => x.LastRun < DateTime.UtcNow - x.Period
|
||||
&& (x.IsRunning is false || x.LastAttemptedRun < DateTime.UtcNow - x.Period - _settings.MaximumExecutionTime));
|
||||
DistributedBackgroundJobModel? job = jobs.FirstOrDefault(x =>
|
||||
IsDue(x, utcNow, _clockAlignedJobNames.Contains(x.Name))
|
||||
&& (x.IsRunning is false || x.LastAttemptedRun < utcNow - x.Period - _settings.MaximumExecutionTime));
|
||||
|
||||
if (job is null)
|
||||
{
|
||||
@@ -97,6 +108,39 @@ public class DistributedJobService : IDistributedJobService
|
||||
return distributedJob;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a job is due to run.
|
||||
/// </summary>
|
||||
/// <param name="job">The job state.</param>
|
||||
/// <param name="utcNow">The current UTC time.</param>
|
||||
/// <param name="aligned">
|
||||
/// Whether the job's runs are aligned to clock boundaries (see <see cref="IDistributedBackgroundJob.AlignToClock" />).
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// For non-aligned jobs the period counts from the previous run's completion (<c>LastRun + Period</c>, drifting).
|
||||
/// For aligned jobs the job is due once a clock boundary — a multiple of the period measured from a fixed UTC
|
||||
/// origin, so boundaries fall on round clock times such as on the minute — has fallen strictly after the previous
|
||||
/// run's completion. Boundaries are in UTC, not the server's local time zone. This is overrun-safe: if a run takes
|
||||
/// longer than the period, the boundary it would have targeted has already passed, so the missed boundary is
|
||||
/// skipped rather than triggering back-to-back runs.
|
||||
/// </remarks>
|
||||
internal static bool IsDue(DistributedBackgroundJobModel job, DateTime utcNow, bool aligned)
|
||||
{
|
||||
if (aligned == false || job.Period <= TimeSpan.Zero)
|
||||
{
|
||||
return job.LastRun < utcNow - job.Period;
|
||||
}
|
||||
|
||||
long periodTicks = job.Period.Ticks;
|
||||
|
||||
// Floor the current UTC time to the most recent clock boundary. Ticks count from a fixed origin (0001-01-01), and
|
||||
// a day divides evenly by any clean sub-hour period, so boundaries fall on round clock times (e.g. each :10s).
|
||||
long ticksSinceBoundary = utcNow.Ticks % periodTicks;
|
||||
long currentBoundaryTicks = utcNow.Ticks - ticksSinceBoundary;
|
||||
|
||||
return currentBoundaryTicks > job.LastRun.Ticks;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task FinishAsync(string jobName)
|
||||
{
|
||||
@@ -136,11 +180,25 @@ public class DistributedJobService : IDistributedJobService
|
||||
return;
|
||||
}
|
||||
|
||||
// Clock-aligned jobs only hit their boundaries as tightly as the poll interval allows. If the poll interval
|
||||
// is longer than the job's period, boundaries between polls are silently missed.
|
||||
foreach (IDistributedBackgroundJob job in _distributedBackgroundJobs)
|
||||
{
|
||||
if (job.AlignToClock && job.Period < _settings.Period)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Distributed background job '{JobName}' aligns to the clock with a period of {Period}, but the distributed job poll interval is longer ({PollInterval}). Clock boundaries shorter than the poll interval will be missed; set Umbraco:CMS:DistributedJobs:Period to be no longer than the job period.",
|
||||
job.Name,
|
||||
job.Period,
|
||||
_settings.Period);
|
||||
}
|
||||
}
|
||||
|
||||
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
|
||||
scope.WriteLock(Constants.Locks.DistributedJobs);
|
||||
|
||||
DistributedBackgroundJobModel[] existingJobs = _distributedJobRepository.GetAll().ToArray();
|
||||
var existingJobsByName = existingJobs.ToDictionary(x => x.Name);
|
||||
Dictionary<string, DistributedBackgroundJobModel> existingJobsByName = existingJobs.ToDictionary(x => x.Name);
|
||||
|
||||
// Collect all changes first, then execute - minimizes time spent in the critical section
|
||||
var jobsToAdd = new List<DistributedBackgroundJobModel>();
|
||||
|
||||
@@ -77,6 +77,7 @@ public static class UmbracoBuilderExtensions
|
||||
builder.AddNotificationHandler<ContentTypeChangedNotification, DeferredCacheRebuildNotificationHandler>();
|
||||
builder.AddNotificationHandler<MediaTypeChangedNotification, DeferredCacheRebuildNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, SeedingNotificationHandler>();
|
||||
builder.AddNotificationHandler<UmbracoApplicationStartingNotification, DomainCacheSeedingNotificationHandler>();
|
||||
builder.AddCacheSeeding();
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="IRuntimeState"/> used by the cache startup notification handlers.
|
||||
/// </summary>
|
||||
internal static class RuntimeStateExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true when startup cache seeding should be skipped because the site is not yet serving
|
||||
/// front-end content, i.e. it is installing (or below) or upgrading with the maintenance page shown.
|
||||
/// </summary>
|
||||
/// <param name="state">The runtime state.</param>
|
||||
/// <param name="globalSettings">The global settings.</param>
|
||||
public static bool ShouldSkipStartupSeeding(this IRuntimeState state, GlobalSettings globalSettings)
|
||||
=> state.Level <= RuntimeLevel.Install
|
||||
|| (state.Level == RuntimeLevel.Upgrade && globalSettings.ShowMaintenancePageWhenInUpgradeState);
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.HybridCache.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers;
|
||||
|
||||
internal sealed class DomainCacheSeedingNotificationHandler : INotificationHandler<UmbracoApplicationStartingNotification>
|
||||
{
|
||||
private readonly IDomainCacheService _domainCacheService;
|
||||
private readonly IRuntimeState _runtimeState;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
public DomainCacheSeedingNotificationHandler(IDomainCacheService domainCacheService, IRuntimeState runtimeState, IOptions<GlobalSettings> globalSettings)
|
||||
{
|
||||
_domainCacheService = domainCacheService;
|
||||
_runtimeState = runtimeState;
|
||||
_globalSettings = globalSettings.Value;
|
||||
}
|
||||
|
||||
public void Handle(UmbracoApplicationStartingNotification notification)
|
||||
{
|
||||
if (_runtimeState.ShouldSkipStartupSeeding(_globalSettings))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Force eager population of the lazily-loaded domain cache.
|
||||
_domainCacheService.GetAll(includeWildcards: true);
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -1,11 +1,10 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.HybridCache.Services;
|
||||
using Umbraco.Cms.Infrastructure.HybridCache.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers;
|
||||
|
||||
@@ -29,7 +28,7 @@ internal sealed class SeedingNotificationHandler : INotificationAsyncHandler<Umb
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
if (_runtimeState.Level <= RuntimeLevel.Install || (_runtimeState.Level == RuntimeLevel.Upgrade && _globalSettings.ShowMaintenancePageWhenInUpgradeState))
|
||||
if (_runtimeState.ShouldSkipStartupSeeding(_globalSettings))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -423,12 +466,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)
|
||||
@@ -436,6 +484,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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Concurrent;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
@@ -9,23 +9,41 @@ using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.Changes;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implements <see cref="IDomainCacheService" />, providing an in-memory cache of the configured <see cref="Domain" />s.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The cache is lazily populated from the database on first access and kept up to date in response to domain
|
||||
/// cache refresher notifications. It is registered as a singleton, so a single instance serves all requests.
|
||||
/// </remarks>
|
||||
public class DomainCacheService : IDomainCacheService
|
||||
{
|
||||
private readonly IDomainService _domainService;
|
||||
private readonly ICoreScopeProvider _coreScopeProvider;
|
||||
private readonly ConcurrentDictionary<int, Domain> _domains;
|
||||
private bool _initialized = false;
|
||||
private readonly Lock _initializationLock = new();
|
||||
|
||||
// Both fields are written under _initializationLock but read on the hot path (request routing) without
|
||||
// it. Marking them volatile makes those lock-free reads acquire-reads, so a reader is guaranteed to see
|
||||
// the fully populated dictionary and the completed-initialization flag together, never a stale or
|
||||
// half-published value. This is required for correctness on weak memory models such as ARM; on x86/x64
|
||||
// ordinary reads already have acquire semantics, but we cannot rely on that.
|
||||
private volatile ConcurrentDictionary<int, Domain> _domains = new();
|
||||
private volatile bool _initialized;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DomainCacheService" /> class.
|
||||
/// </summary>
|
||||
/// <param name="domainService">The service used to load domains from the database.</param>
|
||||
/// <param name="coreScopeProvider">The provider used to create scopes for database access.</param>
|
||||
public DomainCacheService(IDomainService domainService, ICoreScopeProvider coreScopeProvider)
|
||||
{
|
||||
_domainService = domainService;
|
||||
_coreScopeProvider = coreScopeProvider;
|
||||
_domains = new ConcurrentDictionary<int, Domain>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Domain> GetAll(bool includeWildcards)
|
||||
{
|
||||
InitializeIfMissing();
|
||||
@@ -34,22 +52,38 @@ public class DomainCacheService : IDomainCacheService
|
||||
: _domains.Select(x => x.Value).OrderBy(x => x.SortOrder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the domains on first access, ensuring the cache is populated before any caller reads from it.
|
||||
/// </summary>
|
||||
private void InitializeIfMissing()
|
||||
{
|
||||
// Lazy, on-demand initialization triggered by the first request to reach the cache.
|
||||
// The flag must only be set to true *after* the domains have been loaded and published.
|
||||
// Setting it beforehand creates a window where a concurrent caller observes _initialized == true,
|
||||
// skips loading, and reads an empty domain cache. On a multi-site setup that empties domain
|
||||
// resolution, causing every site to fall back to the first root node (see ContentFinderByUrlNew).
|
||||
// The double-checked lock ensures a single load while concurrent readers block until it completes.
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_initialized = true;
|
||||
LoadDomains();
|
||||
|
||||
lock (_initializationLock)
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LoadDomains();
|
||||
_initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Domain> GetAssigned(int documentId, bool includeWildcards = false)
|
||||
{
|
||||
InitializeIfMissing();
|
||||
// probably this could be optimized with an index
|
||||
// but then we'd need a custom DomainStore of some sort
|
||||
IEnumerable<Domain> list = _domains.Values.Where(x => x.ContentId == documentId);
|
||||
if (includeWildcards == false)
|
||||
{
|
||||
@@ -66,6 +100,7 @@ public class DomainCacheService : IDomainCacheService
|
||||
return documentId > 0 && GetAssigned(documentId, includeWildcards).Any();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Refresh(DomainCacheRefresher.JsonPayload[] payloads)
|
||||
{
|
||||
foreach (DomainCacheRefresher.JsonPayload payload in payloads)
|
||||
@@ -102,20 +137,23 @@ public class DomainCacheService : IDomainCacheService
|
||||
continue; // anomaly
|
||||
}
|
||||
|
||||
var newDomain = new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder);
|
||||
|
||||
// Feels wierd to use key and oldvalue, but we're using neither when updating.
|
||||
_domains.AddOrUpdate(
|
||||
domain.Id,
|
||||
new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder),
|
||||
(key, oldValue) => newDomain);
|
||||
_domains[domain.Id] = new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the configured domains from the database into a fresh dictionary and atomically swaps it in
|
||||
/// as the current cache.
|
||||
/// </summary>
|
||||
private void LoadDomains()
|
||||
{
|
||||
// Build the replacement set in a local dictionary and publish it with a single write to the
|
||||
// (volatile) _domains field. A reader never observes a partially populated cache during a RefreshAll
|
||||
// rebuild, and the published set contains exactly the current domains (any removed since the last
|
||||
// load are absent).
|
||||
var newDomains = new ConcurrentDictionary<int, Domain>();
|
||||
using (ICoreScope scope = _coreScopeProvider.CreateCoreScope())
|
||||
{
|
||||
scope.ReadLock(Constants.Locks.Domains);
|
||||
@@ -124,11 +162,11 @@ public class DomainCacheService : IDomainCacheService
|
||||
.Where(x => x.RootContentId.HasValue && x.LanguageIsoCode.IsNullOrWhiteSpace() == false)
|
||||
.Select(x => new Domain(x.Id, x.DomainName, x.RootContentId!.Value, x.LanguageIsoCode!, x.IsWildcard, x.SortOrder)))
|
||||
{
|
||||
_domains.AddOrUpdate(domain.Id, domain, (key, oldValue) => domain);
|
||||
newDomains[domain.Id] = domain;
|
||||
}
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
|
||||
_domains = newDomains;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -357,6 +407,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)
|
||||
|
||||
@@ -108,7 +108,6 @@ class UmbStoryBookElement extends UmbLitElement {
|
||||
...publishCacheManifests,
|
||||
...relationsManifests,
|
||||
...rteManifests,
|
||||
...searchManifests,
|
||||
...segmentManifests,
|
||||
...settingsManifests,
|
||||
...staticFileManifests,
|
||||
@@ -190,25 +189,25 @@ export const parameters = {
|
||||
},
|
||||
},
|
||||
backgrounds: {
|
||||
options: {
|
||||
greyish: {
|
||||
options: {
|
||||
greyish: {
|
||||
name: 'Greyish',
|
||||
value: '#F3F3F5',
|
||||
},
|
||||
|
||||
white: {
|
||||
white: {
|
||||
name: 'White',
|
||||
value: '#ffffff',
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
setCustomElements(customElementManifests);
|
||||
export const tags = ['autodocs'];
|
||||
|
||||
export const initialGlobals = {
|
||||
backgrounds: {
|
||||
value: 'greyish'
|
||||
}
|
||||
backgrounds: {
|
||||
value: 'greyish'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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": "^1.17.0-rc.5",
|
||||
"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": "^1.18.1",
|
||||
"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 |
|
||||
|
||||
+493
-492
File diff suppressed because it is too large
Load Diff
@@ -258,12 +258,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 {
|
||||
@@ -279,8 +276,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();
|
||||
|
||||
@@ -1556,8 +1556,16 @@ export default {
|
||||
chooseChildNode: 'اختر العقدة الفرعية',
|
||||
compositionsDescription:
|
||||
'ارث التبويبات والخصائص من نوع مستند موجود. سيتم إضافة التبويبات الجديدة إلى نوع الوثيقة الحالي أو دمجها إذا كان هناك تبويب بنفس الاسم.',
|
||||
compositionsDescriptionMediaType:
|
||||
'ارث التبويبات والخصائص من نوع وسائط موجود. سيتم إضافة التبويبات الجديدة إلى نوع الوسائط الحالي أو دمجها إذا كان هناك تبويب بنفس الاسم.',
|
||||
compositionsDescriptionMemberType:
|
||||
'ارث التبويبات والخصائص من نوع عضو موجود. سيتم إضافة التبويبات الجديدة إلى نوع العضو الحالي أو دمجها إذا كان هناك تبويب بنفس الاسم.',
|
||||
compositionInUse: 'هذا النوع من المحتوى قيد الاستخدام في تركيب، وبالتالي لا يمكن تركيبه بنفسه.\n ',
|
||||
compositionInUseMediaType: 'هذا النوع من الوسائط قيد الاستخدام في تركيب، وبالتالي لا يمكن تركيبه بنفسه.\n ',
|
||||
compositionInUseMemberType: 'هذا النوع من الأعضاء قيد الاستخدام في تركيب، وبالتالي لا يمكن تركيبه بنفسه.\n ',
|
||||
noAvailableCompositions: 'لا توجد أنواع محتوى متاحة لاستخدامها كتركيب.',
|
||||
noAvailableCompositionsMediaType: 'لا توجد أنواع وسائط متاحة لاستخدامها كتركيب.',
|
||||
noAvailableCompositionsMemberType: 'لا توجد أنواع أعضاء متاحة لاستخدامها كتركيب.',
|
||||
compositionRemoveWarning:
|
||||
'إزالة التركيب ستؤدي إلى حذف جميع بيانات الخصائص المرتبطة. بمجرد حفظ نوع الوثيقة لا يوجد طريق للعودة.',
|
||||
availableEditors: 'إنشاء جديد',
|
||||
@@ -1591,6 +1599,8 @@ export default {
|
||||
tabHasNoSortOrder: 'التبويب ليس له ترتيب فرز',
|
||||
compositionUsageHeading: 'أين يتم استخدام هذا التركيب؟',
|
||||
compositionUsageSpecification: 'يتم استخدام هذا التركيب حاليًا في تركيب أنواع المحتوى التالية:\n ',
|
||||
compositionUsageSpecificationMediaType: 'يتم استخدام هذا التركيب حاليًا في تركيب أنواع الوسائط التالية:\n ',
|
||||
compositionUsageSpecificationMemberType: 'يتم استخدام هذا التركيب حاليًا في تركيب أنواع الأعضاء التالية:\n ',
|
||||
variantsHeading: 'السماح بالاختلافات',
|
||||
cultureVariantHeading: 'السماح بالاختلاف حسب الثقافة',
|
||||
segmentVariantHeading: 'السماح بالتجزئة',
|
||||
|
||||
@@ -1489,8 +1489,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',
|
||||
@@ -1525,6 +1533,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',
|
||||
|
||||
@@ -1381,8 +1381,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é',
|
||||
@@ -1415,6 +1423,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',
|
||||
|
||||
@@ -1606,9 +1606,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',
|
||||
@@ -1646,6 +1656,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',
|
||||
|
||||
@@ -1749,9 +1749,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',
|
||||
@@ -1789,6 +1799,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',
|
||||
bottomText:
|
||||
'<p style="text-align:right;">© 2001 - %0% <br /><a href="https://umbraco.com" style="text-decoration: none" target="_blank" rel="noopener">umbraco.org</a></p> ',
|
||||
@@ -1553,9 +1553,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',
|
||||
@@ -1590,6 +1600,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',
|
||||
|
||||
@@ -1802,8 +1802,16 @@ export default {
|
||||
chooseChildNode: 'Choose child node',
|
||||
compositionsDescription:
|
||||
'Inherit tabs and properties from an existing Document Type. New tabs will be added to the current Document Type or merged if a tab with an identical name exists.',
|
||||
compositionInUse: 'This Content Type is used in a composition, and therefore cannot be composed itself.',
|
||||
noAvailableCompositions: 'There are no Content Types available to use as a composition.',
|
||||
compositionsDescriptionMediaType:
|
||||
'Inherit tabs and properties from an existing Media Type. New tabs will be added to the current Media Type or merged if a tab with an identical name exists.',
|
||||
compositionsDescriptionMemberType:
|
||||
'Inherit tabs and properties from an existing Member Type. New tabs will be added to the current Member Type or merged if a tab with an identical name exists.',
|
||||
compositionInUse: 'This Document Type is used in a composition, and therefore cannot be composed itself.',
|
||||
compositionInUseMediaType: 'This Media Type is used in a composition, and therefore cannot be composed itself.',
|
||||
compositionInUseMemberType: 'This Member Type is used in a composition, and therefore cannot be composed itself.',
|
||||
noAvailableCompositions: 'There are no Document Types available to use as a composition.',
|
||||
noAvailableCompositionsMediaType: 'There are no Media Types available to use as a composition.',
|
||||
noAvailableCompositionsMemberType: 'There are no Member Types available to use as a composition.',
|
||||
compositionRemoveWarning:
|
||||
"Removing a composition will delete all the associated property data. Once you save the Document Type there's no way back.",
|
||||
availableEditors: 'Create new',
|
||||
@@ -1841,7 +1849,11 @@ export default {
|
||||
tabHasNoSortOrder: 'tab has no sort order',
|
||||
compositionUsageHeading: 'Where is this composition used?',
|
||||
compositionUsageSpecification:
|
||||
'This composition is currently used in the composition of the following Content Types:',
|
||||
'This composition is currently used in the composition of the following Document Types:',
|
||||
compositionUsageSpecificationMediaType:
|
||||
'This composition is currently used in the composition of the following Media Types:',
|
||||
compositionUsageSpecificationMemberType:
|
||||
'This composition is currently used in the composition of the following Member Types:',
|
||||
variantsHeading: 'Variation',
|
||||
cultureVariantHeading: 'Allow vary by culture',
|
||||
segmentVariantHeading: 'Allow segmentation',
|
||||
|
||||
@@ -1131,9 +1131,19 @@ export default {
|
||||
chooseChildNode: 'Elegir nodo hijo',
|
||||
compositionsDescription:
|
||||
'Heredar pestañas y propiedades de un tipo de documento existente. Nuevas pestañas serán añadidas al tipo de documento actual o mezcladas si una pestaña con nombre idéntico ya existe.',
|
||||
compositionsDescriptionMediaType:
|
||||
'Heredar pestañas y propiedades de un tipo de medio existente. Nuevas pestañas serán añadidas al tipo de medio actual o mezcladas si una pestaña con nombre idéntico ya existe.',
|
||||
compositionsDescriptionMemberType:
|
||||
'Heredar pestañas y propiedades de un tipo de miembro existente. Nuevas pestañas serán añadidas al tipo de miembro actual o mezcladas si una pestaña con nombre idéntico ya existe.',
|
||||
compositionInUse:
|
||||
'Este tipo de contenido es usado en una composición, y por tanto no puede no puede ser compuesto.',
|
||||
'Este tipo de contenido es usado en una composición, y por tanto no puede ser compuesto.',
|
||||
compositionInUseMediaType:
|
||||
'Este tipo de medio es usado en una composición, y por tanto no puede ser compuesto.',
|
||||
compositionInUseMemberType:
|
||||
'Este tipo de miembro es usado en una composición, y por tanto no puede ser compuesto.',
|
||||
noAvailableCompositions: 'No hay tipos de contenido disponibles para usar como composición.',
|
||||
noAvailableCompositionsMediaType: 'No hay tipos de medio disponibles para usar como composición.',
|
||||
noAvailableCompositionsMemberType: 'No hay tipos de miembro disponibles para usar como composición.',
|
||||
availableEditors: 'Editores disponibles',
|
||||
reuse: 'Reusar',
|
||||
editorSettings: 'Configuración de editor',
|
||||
|
||||
@@ -924,7 +924,7 @@ export default {
|
||||
greeting5: 'Bienvenue',
|
||||
greeting6: 'Bienvenue',
|
||||
instruction: 'Connectez-vous ci-dessous',
|
||||
signInWith: 'Identifiez-vous avec',
|
||||
signInWith: 'Identifiez-vous avec {0}',
|
||||
timeout: 'La session a expiré',
|
||||
bottomText:
|
||||
'<p style="text-align:right;">© 2001 - %0% <br /><a href="https://umbraco.com" style="text-decoration: none" target="_blank" rel="noopener">Umbraco.com</a></p> ',
|
||||
@@ -1403,10 +1403,20 @@ export default {
|
||||
childNodesDescription: 'Autorisez la création de contenu des types spécifiés sous le contenu de ce type-ci',
|
||||
chooseChildNode: 'Choisissez les noeuds enfants',
|
||||
compositionsDescription:
|
||||
"Hériter des onglets et propriétés d'un type de document existant. De nouveaux onglets seront ajoutés au type de document actuel, ou fusionnés s'il existe un onglet avec un nom sililaire.",
|
||||
"Hériter des onglets et propriétés d'un type de document existant. De nouveaux onglets seront ajoutés au type de document actuel, ou fusionnés s'il existe un onglet avec un nom similaire.",
|
||||
compositionsDescriptionMediaType:
|
||||
"Hériter des onglets et propriétés d'un type de media existant. De nouveaux onglets seront ajoutés au type de media actuel, ou fusionnés s'il existe un onglet avec un nom similaire.",
|
||||
compositionsDescriptionMemberType:
|
||||
"Hériter des onglets et propriétés d'un type de membre existant. De nouveaux onglets seront ajoutés au type de membre actuel, ou fusionnés s'il existe un onglet avec un nom similaire.",
|
||||
compositionInUse:
|
||||
'Ce type de contenu est utilisé dans une composition, et ne peut donc pas être lui-même un composé.',
|
||||
compositionInUseMediaType:
|
||||
'Ce type de media est utilisé dans une composition, et ne peut donc pas être lui-même un composé.',
|
||||
compositionInUseMemberType:
|
||||
'Ce type de membre est utilisé dans une composition, et ne peut donc pas être lui-même un composé.',
|
||||
noAvailableCompositions: "Il n'y a pas de type de contenu disponible à utiliser dans une composition.",
|
||||
noAvailableCompositionsMediaType: "Il n'y a pas de type de media disponible à utiliser dans une composition.",
|
||||
noAvailableCompositionsMemberType: "Il n'y a pas de type de membre disponible à utiliser dans une composition.",
|
||||
compositionRemoveWarning:
|
||||
"La suppression d'une composition supprimera les données de toutes les propriétés associées. Une fois que vous sauvegardez le type de document, il n'y a plus moyen de faire marche arrière.",
|
||||
availableEditors: 'Editeurs disponibles',
|
||||
@@ -1443,6 +1453,10 @@ export default {
|
||||
compositionUsageHeading: 'Où cette composition est-elle utilisée?',
|
||||
compositionUsageSpecification:
|
||||
'Cette composition est actuellement utilisée dans la composition des types de contenu suivants :',
|
||||
compositionUsageSpecificationMediaType:
|
||||
'Cette composition est actuellement utilisée dans la composition des types de media suivants :',
|
||||
compositionUsageSpecificationMemberType:
|
||||
'Cette composition est actuellement utilisée dans la composition des types de membre suivants :',
|
||||
variantsHeading: 'Permettre une variation par culture',
|
||||
variantsDescription: 'Permettre aux éditeurs de créer du contenu de ce type dans différentes langues.',
|
||||
allowVaryByCulture: 'Permettre une variation par culture',
|
||||
|
||||
@@ -1533,8 +1533,16 @@ export default {
|
||||
chooseChildNode: 'Odaberite podređeni čvor',
|
||||
compositionsDescription:
|
||||
'Naslijediti kartice i svojstva iz postojeće vrste dokumenta. Nove kartice bit će\n dodane trenutnoj vrsti dokumenta ili spojene ako postoji kartica s identičnim imenom.\n ',
|
||||
compositionsDescriptionMediaType:
|
||||
'Naslijediti kartice i svojstva iz postojeće vrste medija. Nove kartice bit će\n dodane trenutnoj vrsti medija ili spojene ako postoji kartica s identičnim imenom.\n ',
|
||||
compositionsDescriptionMemberType:
|
||||
'Naslijediti kartice i svojstva iz postojeće vrste člana. Nove kartice bit će\n dodane trenutnoj vrsti člana ili spojene ako postoji kartica s identičnim imenom.\n ',
|
||||
compositionInUse: 'Ova vrsta sadržaja se koristi u kompoziciji i stoga se ne može sam sastaviti.\n ',
|
||||
compositionInUseMediaType: 'Ova vrsta medija se koristi u kompoziciji i stoga se ne može sam sastaviti.\n ',
|
||||
compositionInUseMemberType: 'Ova vrsta člana se koristi u kompoziciji i stoga se ne može sam sastaviti.\n ',
|
||||
noAvailableCompositions: 'Nema dostupnih vrsta sadržaja za upotrebu kao kompozicija.',
|
||||
noAvailableCompositionsMediaType: 'Nema dostupnih vrsta medija za upotrebu kao kompozicija.',
|
||||
noAvailableCompositionsMemberType: 'Nema dostupnih vrsta člana za upotrebu kao kompozicija.',
|
||||
compositionRemoveWarning:
|
||||
'Uklanjanje kompozicije će obrisati sve povezane podatke o svojstvu. Jednom kada spremite vrstu dokumenta, nema povratka.\n ',
|
||||
availableEditors: 'Napravi novi',
|
||||
@@ -1568,6 +1576,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 vrsta sadržaja:\n ',
|
||||
compositionUsageSpecificationMediaType: 'Ovaj sastav se trenutno koristi u sastavu sljedećih\n vrsta medija:\n ',
|
||||
compositionUsageSpecificationMemberType: 'Ovaj sastav se trenutno koristi u sastavu sljedećih\n vrsta člana:\n ',
|
||||
variantsHeading: 'Dozvoli varijacije',
|
||||
cultureVariantHeading: 'Dozvolite varirati u zavisnosti od kulture',
|
||||
segmentVariantHeading: 'Dozvoli segmentaciju',
|
||||
|
||||
@@ -1528,9 +1528,19 @@ export default {
|
||||
chooseChildNode: 'Scegli nodo figlio',
|
||||
compositionsDescription:
|
||||
'Eredita schede e proprietà da un tipo di documento esistente. Le nuove schede verranno aggiunte al tipo di documento corrente o unite se esiste una scheda con un nome identico.',
|
||||
compositionsDescriptionMediaType:
|
||||
'Eredita schede e proprietà da un tipo di media esistente. Le nuove schede verranno aggiunte al tipo di media corrente o unite se esiste una scheda con un nome identico.',
|
||||
compositionsDescriptionMemberType:
|
||||
'Eredita schede e proprietà da un tipo di membro esistente. Le nuove schede verranno aggiunte al tipo di membro corrente o unite se esiste una scheda con un nome identico.',
|
||||
compositionInUse:
|
||||
'Questo tipo di contenuto è utlizzato in una composizione, e quindi non può essere composto da se stesso.',
|
||||
'Questo tipo di contenuto è utilizzato in una composizione, e quindi non può essere composto da se stesso.',
|
||||
compositionInUseMediaType:
|
||||
'Questo tipo di media è utilizzato in una composizione, e quindi non può essere composto da se stesso.',
|
||||
compositionInUseMemberType:
|
||||
'Questo tipo di membro è utilizzato in una composizione, e quindi non può essere composto da se stesso.',
|
||||
noAvailableCompositions: 'Non ci sono tipi di contenuto utilizzabili come composizione.',
|
||||
noAvailableCompositionsMediaType: 'Non ci sono tipi di media utilizzabili come composizione.',
|
||||
noAvailableCompositionsMemberType: 'Non ci sono tipi di membro utilizzabili come composizione.',
|
||||
compositionRemoveWarning:
|
||||
'Rimuovendo una composizione si elimineranno tutti i dati associati ad essa. Una volta salvato il tipo di documento non ci sarà nessun modo di recuperare i dati.',
|
||||
availableEditors: 'Crea nuovo',
|
||||
@@ -1568,6 +1578,8 @@ export default {
|
||||
tabHasNoSortOrder: 'la scheda non ha un ordine',
|
||||
compositionUsageHeading: 'Dove è usata questa composizione?',
|
||||
compositionUsageSpecification: 'Questa composizione è usata nella composizione dei seguenti tipi di contenuto:',
|
||||
compositionUsageSpecificationMediaType: 'Questa composizione è usata nella composizione dei seguenti tipi di media:',
|
||||
compositionUsageSpecificationMemberType: 'Questa composizione è usata nella composizione dei seguenti tipi di membro:',
|
||||
variantsHeading: 'Consenti variazioni',
|
||||
cultureVariantHeading: 'Consenti variazioni in base alla lingua',
|
||||
segmentVariantHeading: 'Consenti segmentazione',
|
||||
|
||||
@@ -888,8 +888,16 @@ export default {
|
||||
chooseChildNode: '子ノードの選択',
|
||||
compositionsDescription:
|
||||
'既存ドキュメント タイプのタブとプロパティを継承。新しいタブを現在のドキュメント タイプに追加、または同じ名前のタブがある場合はマージされます。',
|
||||
compositionsDescriptionMediaType:
|
||||
'既存メディア タイプのタブとプロパティを継承。新しいタブを現在のメディア タイプに追加、または同じ名前のタブがある場合はマージされます。',
|
||||
compositionsDescriptionMemberType:
|
||||
'既存メンバー タイプのタブとプロパティを継承。新しいタブを現在のメンバー タイプに追加、または同じ名前のタブがある場合はマージされます。',
|
||||
compositionInUse: 'このコンテンツ タイプが構成で使用されるため、自身を構成することはできません。',
|
||||
compositionInUseMediaType: 'このメディア タイプが構成で使用されるため、自身を構成することはできません。',
|
||||
compositionInUseMemberType: 'このメンバー タイプが構成で使用されるため、自身を構成することはできません。',
|
||||
noAvailableCompositions: '構成に使用できるコンテンツ タイプはありません。',
|
||||
noAvailableCompositionsMediaType: '構成に使用できるメディア タイプはありません。',
|
||||
noAvailableCompositionsMemberType: '構成に使用できるメンバー タイプはありません。',
|
||||
availableEditors: '使用可能なエディター',
|
||||
reuse: '再利用',
|
||||
editorSettings: 'エディター設定',
|
||||
|
||||
@@ -713,7 +713,7 @@ export default {
|
||||
greeting5: 'Velkommen',
|
||||
greeting6: 'Velkommen',
|
||||
instruction: 'Logg på nedenfor',
|
||||
signInWith: 'Logg på med',
|
||||
signInWith: 'Logg på med {0}',
|
||||
timeout: 'Din sesjon er utløpt',
|
||||
bottomText:
|
||||
'<p style="text-align:right;">© 2001 - %0% <br /><a href="https://umbraco.com" style="text-decoration: none" target="_blank" rel="noopener">umbraco.com</a></p> ',
|
||||
|
||||
@@ -18,6 +18,7 @@ export default {
|
||||
changeDataType: 'Datatype aanpassen',
|
||||
copy: 'Kopiëren',
|
||||
create: 'Nieuw',
|
||||
createFor: (name: string) => (name ? `Item aanmaken voor ${name}` : 'Aanmaken'),
|
||||
export: 'Export',
|
||||
createPackage: 'Nieuwe package',
|
||||
createGroup: 'Groep maken',
|
||||
@@ -56,6 +57,7 @@ export default {
|
||||
setGroup: 'Groep instellen',
|
||||
sort: 'Sorteren',
|
||||
translate: 'Vertalen',
|
||||
trash: 'Verwijderen',
|
||||
update: 'Bijwerken',
|
||||
setPermissions: 'Rechten instellen',
|
||||
unlock: 'Deblokkeer',
|
||||
@@ -944,7 +946,7 @@ export default {
|
||||
greeting5: 'Welkom',
|
||||
greeting6: 'Welkom',
|
||||
instruction: 'log hieronder in',
|
||||
signInWith: 'Inloggen met',
|
||||
signInWith: 'Inloggen met {0}',
|
||||
timeout: 'Sessie is verlopen',
|
||||
bottomText:
|
||||
'<p style="text-align:right;">© 2001 - %0% <br /><a href="https://umbraco.com" style="text-decoration: none" target="_blank" rel="noopener">umbraco.com</a></p>',
|
||||
@@ -1455,9 +1457,19 @@ export default {
|
||||
chooseChildNode: 'Kies onderliggende node',
|
||||
compositionsDescription:
|
||||
'Overgeërfde tabs en properties van een bestaand documenttype. Nieuwe tabs\n worden toegevoegd aan het huidige documenttype of samengevoegd als een tab met dezelfde naam al bestaat.\n ',
|
||||
compositionsDescriptionMediaType:
|
||||
'Overgeërfde tabs en properties van een bestaand mediatype. Nieuwe tabs\n worden toegevoegd aan het huidige mediatype of samengevoegd als een tab met dezelfde naam al bestaat.\n ',
|
||||
compositionsDescriptionMemberType:
|
||||
'Overgeërfde tabs en properties van een bestaand lidtype. Nieuwe tabs\n worden toegevoegd aan het huidige lidtype of samengevoegd als een tab met dezelfde naam al bestaat.\n ',
|
||||
compositionInUse:
|
||||
'Dit contenttype wordt gebruikt in een compositie en kan daarom niet zelf een\n compositie worden.\n ',
|
||||
compositionInUseMediaType:
|
||||
'Dit mediatype wordt gebruikt in een compositie en kan daarom niet zelf een\n compositie worden.\n ',
|
||||
compositionInUseMemberType:
|
||||
'Dit lidtype wordt gebruikt in een compositie en kan daarom niet zelf een\n compositie worden.\n ',
|
||||
noAvailableCompositions: 'Er zijn geen contenttypen beschikbaar om als compositie te gebruiken.',
|
||||
noAvailableCompositionsMediaType: 'Er zijn geen mediatypen beschikbaar om als compositie te gebruiken.',
|
||||
noAvailableCompositionsMemberType: 'Er zijn geen lidtypen beschikbaar om als compositie te gebruiken.',
|
||||
compositionRemoveWarning:
|
||||
'Een compositie verwijderen zal alle bijbehorende eigenschapsdata ook\n verwijderen. Zodra je het documenttype hebt opgeslagen is er geen weg meer terug.\n ',
|
||||
availableEditors: 'Beschikbare editors',
|
||||
@@ -1495,6 +1507,10 @@ export default {
|
||||
compositionUsageHeading: 'Waar wordt deze compositie gebruikt?',
|
||||
compositionUsageSpecification:
|
||||
'Deze samenstelling wordt momenteel gebruikt bij de samenstelling van de\n volgende inhoudstypen:\n ',
|
||||
compositionUsageSpecificationMediaType:
|
||||
'Deze samenstelling wordt momenteel gebruikt bij de samenstelling van de\n volgende mediatypen:\n ',
|
||||
compositionUsageSpecificationMemberType:
|
||||
'Deze samenstelling wordt momenteel gebruikt bij de samenstelling van de\n volgende lidtypen:\n ',
|
||||
variantsHeading: 'Variaties toestaan',
|
||||
cultureVariantHeading: 'Variëren per cultuur toestaan',
|
||||
segmentVariantHeading: 'Segmentatie toestaan',
|
||||
|
||||
@@ -1074,8 +1074,16 @@ export default {
|
||||
chooseChildNode: 'Wybierz węzeł dziecka',
|
||||
compositionsDescription:
|
||||
'Odziedzicz zakładki i właściwości z istniejącego typu dokumentu. Nowe zakładki będą dodane do bieżącego typu dokumentu lub złączone jeśli zakładka z identyczną nazwą już istnieje.',
|
||||
compositionsDescriptionMediaType:
|
||||
'Odziedzicz zakładki i właściwości z istniejącego typu mediów. Nowe zakładki będą dodane do bieżącego typu mediów lub złączone jeśli zakładka z identyczną nazwą już istnieje.',
|
||||
compositionsDescriptionMemberType:
|
||||
'Odziedzicz zakładki i właściwości z istniejącego typu członka. Nowe zakładki będą dodane do bieżącego typu członka lub złączone jeśli zakładka z identyczną nazwą już istnieje.',
|
||||
compositionInUse: 'Ten typ zawartości jest używany w kompozycji, przez co sam nie może być złożony.',
|
||||
compositionInUseMediaType: 'Ten typ mediów jest używany w kompozycji, przez co sam nie może być złożony.',
|
||||
compositionInUseMemberType: 'Ten typ członka jest używany w kompozycji, przez co sam nie może być złożony.',
|
||||
noAvailableCompositions: 'Brak możliwych typów zawartości do użycia jako kompozycja.',
|
||||
noAvailableCompositionsMediaType: 'Brak możliwych typów mediów do użycia jako kompozycja.',
|
||||
noAvailableCompositionsMemberType: 'Brak możliwych typów członka do użycia jako kompozycja.',
|
||||
availableEditors: 'Dostępni edytorzy',
|
||||
reuse: 'Użyj ponownie',
|
||||
editorSettings: 'Ustawienia edytora',
|
||||
|
||||
@@ -1728,8 +1728,16 @@ export default {
|
||||
chooseChildNode: 'Escolher nó filho',
|
||||
compositionsDescription:
|
||||
'Herde separadores e propriedades de um Tipo de Documento existente. Novos separadores serão adicionados ao Tipo de Documento atual ou fundidos se existir um separador com um nome idêntico.',
|
||||
compositionsDescriptionMediaType:
|
||||
'Herde separadores e propriedades de um Tipo de Multimédia existente. Novos separadores serão adicionados ao Tipo de Multimédia atual ou fundidos se existir um separador com um nome idêntico.',
|
||||
compositionsDescriptionMemberType:
|
||||
'Herde separadores e propriedades de um Tipo de Membro existente. Novos separadores serão adicionados ao Tipo de Membro atual ou fundidos se existir um separador com um nome idêntico.',
|
||||
compositionInUse: 'Este Tipo de Conteúdo é usado numa composição e, portanto, não pode ser composto ele próprio.',
|
||||
compositionInUseMediaType: 'Este Tipo de Multimédia é usado numa composição e, portanto, não pode ser composto ele próprio.',
|
||||
compositionInUseMemberType: 'Este Tipo de Membro é usado numa composição e, portanto, não pode ser composto ele próprio.',
|
||||
noAvailableCompositions: 'Não existem Tipos de Conteúdo disponíveis para usar como composição.',
|
||||
noAvailableCompositionsMediaType: 'Não existem Tipos de Multimédia disponíveis para usar como composição.',
|
||||
noAvailableCompositionsMemberType: 'Não existem Tipos de Membro disponíveis para usar como composição.',
|
||||
compositionRemoveWarning:
|
||||
'Remover uma composição eliminará todos os dados de propriedade associados. Depois de guardar o Tipo de Documento, não há como voltar atrás.',
|
||||
availableEditors: 'Criar novo',
|
||||
@@ -1766,6 +1774,8 @@ export default {
|
||||
tabHasNoSortOrder: 'o separador não tem ordem',
|
||||
compositionUsageHeading: 'Onde é usada esta composição?',
|
||||
compositionUsageSpecification: 'Esta composição é atualmente usada na composição dos seguintes Tipos de Conteúdo:',
|
||||
compositionUsageSpecificationMediaType: 'Esta composição é atualmente usada na composição dos seguintes Tipos de Multimédia:',
|
||||
compositionUsageSpecificationMemberType: 'Esta composição é atualmente usada na composição dos seguintes Tipos de Membro:',
|
||||
variantsHeading: 'Variação',
|
||||
cultureVariantHeading: 'Permitir variar por cultura',
|
||||
segmentVariantHeading: 'Permitir segmentação',
|
||||
|
||||
@@ -275,11 +275,23 @@ export default {
|
||||
chooseChildNode: 'Выбрать дочерний узел',
|
||||
compositionsDescription:
|
||||
'Унаследовать вкладки и свойства из уже существующего типа документов. Вкладки будут либо добавлены в создаваемый тип, либо в случае совпадения названий вкладок будут добавлены наследуемые свойства.',
|
||||
compositionsDescriptionMediaType:
|
||||
'Унаследовать вкладки и свойства из уже существующего типа медиа. Вкладки будут либо добавлены в создаваемый тип, либо в случае совпадения названий вкладок будут добавлены наследуемые свойства.',
|
||||
compositionsDescriptionMemberType:
|
||||
'Унаследовать вкладки и свойства из уже существующего типа участников. Вкладки будут либо добавлены в создаваемый тип, либо в случае совпадения названий вкладок будут добавлены наследуемые свойства.',
|
||||
compositionInUse:
|
||||
'Этот тип документов уже участвует в композиции другого типа, поэтому сам не может быть композицией.',
|
||||
compositionInUseMediaType:
|
||||
'Этот тип медиа уже участвует в композиции другого типа, поэтому сам не может быть композицией.',
|
||||
compositionInUseMemberType:
|
||||
'Этот тип участников уже участвует в композиции другого типа, поэтому сам не может быть композицией.',
|
||||
compositionUsageHeading: 'Где используется эта композиция?',
|
||||
compositionUsageSpecification: 'Эта композиция сейчас используется при создании следующих типов документов:',
|
||||
compositionUsageSpecificationMediaType: 'Эта композиция сейчас используется при создании следующих типов медиа:',
|
||||
compositionUsageSpecificationMemberType: 'Эта композиция сейчас используется при создании следующих типов участников:',
|
||||
noAvailableCompositions: 'В настоящее время нет типов документов, допустимых для построения композиции.',
|
||||
noAvailableCompositionsMediaType: 'В настоящее время нет типов медиа, допустимых для построения композиции.',
|
||||
noAvailableCompositionsMemberType: 'В настоящее время нет типов участников, допустимых для построения композиции.',
|
||||
availableEditors: 'Доступные редакторы',
|
||||
reuse: 'Переиспользовать',
|
||||
editorSettings: 'Установки редактора',
|
||||
|
||||
@@ -697,7 +697,7 @@ export default {
|
||||
greeting5: 'Välkommen',
|
||||
greeting6: 'Välkommen',
|
||||
instruction: 'Logga in nedan',
|
||||
signInWith: 'Logga in med',
|
||||
signInWith: 'Logga in med {0}',
|
||||
timeout: 'Sessionen har nått sin maxgräns',
|
||||
},
|
||||
main: {
|
||||
|
||||
@@ -1376,8 +1376,16 @@ export default {
|
||||
chooseChildNode: 'Alt düğümü seçin',
|
||||
compositionsDescription:
|
||||
'Mevcut bir belge türünden sekmeleri ve özellikleri devralın. Mevcut belge türüne yeni sekmeler eklenecek veya aynı ada sahip bir sekme varsa birleştirilecektir.',
|
||||
compositionsDescriptionMediaType:
|
||||
'Mevcut bir medya türünden sekmeleri ve özellikleri devralın. Mevcut medya türüne yeni sekmeler eklenecek veya aynı ada sahip bir sekme varsa birleştirilecektir.',
|
||||
compositionsDescriptionMemberType:
|
||||
'Mevcut bir üye türünden sekmeleri ve özellikleri devralın. Mevcut üye türüne yeni sekmeler eklenecek veya aynı ada sahip bir sekme varsa birleştirilecektir.',
|
||||
compositionInUse: 'Bu içerik türü bir bestede kullanıldığından kendi başına oluşturulamaz.',
|
||||
compositionInUseMediaType: 'Bu medya türü bir bestede kullanıldığından kendi başına oluşturulamaz.',
|
||||
compositionInUseMemberType: 'Bu üye türü bir bestede kullanıldığından kendi başına oluşturulamaz.',
|
||||
noAvailableCompositions: 'Beste olarak kullanılabilecek içerik türü yok.',
|
||||
noAvailableCompositionsMediaType: 'Beste olarak kullanılabilecek medya türü yok.',
|
||||
noAvailableCompositionsMemberType: 'Beste olarak kullanılabilecek üye türü yok.',
|
||||
compositionRemoveWarning:
|
||||
'Bir kompozisyonun kaldırılması, ilişkili tüm özellik verilerini silecektir. Belge türünü kaydettikten sonra geri dönüş yoktur.',
|
||||
availableEditors: 'Yeni oluştur',
|
||||
@@ -1412,6 +1420,8 @@ export default {
|
||||
tabHasNoSortOrder: 'sekmesinde sıralama düzeni yok',
|
||||
compositionUsageHeading: 'Bu beste nerede kullanılıyor?',
|
||||
compositionUsageSpecification: 'Bu beste şu anda aşağıdaki içerik türlerinin oluşturulmasında kullanılmaktadır:',
|
||||
compositionUsageSpecificationMediaType: 'Bu beste şu anda aşağıdaki medya türlerinin oluşturulmasında kullanılmaktadır:',
|
||||
compositionUsageSpecificationMemberType: 'Bu beste şu anda aşağıdaki üye türlerinin oluşturulmasında kullanılmaktadır:',
|
||||
cultureVariantHeading: 'Kültüre göre değişikliklere izin ver',
|
||||
segmentVariantHeading: 'Segmentasyona izin ver',
|
||||
cultureVariantLabel: 'Kültüre göre değişiklik yapın',
|
||||
|
||||
@@ -275,10 +275,20 @@ export default {
|
||||
chooseChildNode: 'Вибрати дочірній вузол',
|
||||
compositionsDescription:
|
||||
'Успадкувати вкладки та властивості з існуючого типу документів. Вкладки будуть або додані до створюваного типу, або у разі збігу назв вкладок будуть додані успадковані властивості.',
|
||||
compositionsDescriptionMediaType:
|
||||
'Успадкувати вкладки та властивості з існуючого типу медіа. Вкладки будуть або додані до створюваного типу, або у разі збігу назв вкладок будуть додані успадковані властивості.',
|
||||
compositionsDescriptionMemberType:
|
||||
'Успадкувати вкладки та властивості з існуючого типу учасників. Вкладки будуть або додані до створюваного типу, або у разі збігу назв вкладок будуть додані успадковані властивості.',
|
||||
compositionInUse: 'Цей тип документів вже бере участь у композиції іншого типу, тому сам може бути композицією.',
|
||||
compositionInUseMediaType: 'Цей тип медіа вже бере участь у композиції іншого типу, тому сам може бути композицією.',
|
||||
compositionInUseMemberType: 'Цей тип учасників вже бере участь у композиції іншого типу, тому сам може бути композицією.',
|
||||
compositionUsageHeading: 'Де використовується ця композиція?',
|
||||
compositionUsageSpecification: 'Ця композиція зараз використовується при створенні таких типів документів:',
|
||||
compositionUsageSpecificationMediaType: 'Ця композиція зараз використовується при створенні таких типів медіа:',
|
||||
compositionUsageSpecificationMemberType: 'Ця композиція зараз використовується при створенні таких типів учасників:',
|
||||
noAvailableCompositions: 'Наразі немає типів документів, допустимих побудови композиції.',
|
||||
noAvailableCompositionsMediaType: 'Наразі немає типів медіа, допустимих побудови композиції.',
|
||||
noAvailableCompositionsMemberType: 'Наразі немає типів учасників, допустимих побудови композиції.',
|
||||
availableEditors: 'Доступні редактори',
|
||||
reuse: 'Перевикористати',
|
||||
editorSettings: 'Налаштування редактора',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user