Compare commits
160
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df16c114b6 | ||
|
|
fb1e16ff36 | ||
|
|
d889de6af0 | ||
|
|
d8f4342a86 | ||
|
|
774b2d4822 | ||
|
|
022439065f | ||
|
|
ba6ec7abcf | ||
|
|
5d4d3a51ea | ||
|
|
c6c5a9e7e6 | ||
|
|
065e567f11 | ||
|
|
58a1c15626 | ||
|
|
c087ce9fb5 | ||
|
|
cda880bb62 | ||
|
|
b3e6477df9 | ||
|
|
8f3ff26005 | ||
|
|
05f8158e4a | ||
|
|
28b849f2d3 | ||
|
|
8dd8820fa3 | ||
|
|
ca7dcd5150 | ||
|
|
6b76230da5 | ||
|
|
ceef53d624 | ||
|
|
5bb53172aa | ||
|
|
aa9473131b | ||
|
|
35a3a2455c | ||
|
|
2e3628dad3 | ||
|
|
5a20452e7a | ||
|
|
0e14ace89a | ||
|
|
adeddeb148 | ||
|
|
c9059c7b07 | ||
|
|
1ee24595d0 | ||
|
|
ab8b8b48d4 | ||
|
|
4ac8a397d0 | ||
|
|
feb1689848 | ||
|
|
a14b908574 | ||
|
|
0f4b784c23 | ||
|
|
ebc0ef36f5 | ||
|
|
925d6bc430 | ||
|
|
acfaf23e43 | ||
|
|
cb23c84c3d | ||
|
|
7c1b907410 | ||
|
|
564ca0384b | ||
|
|
fc3b13c85c | ||
|
|
9f633416b1 | ||
|
|
91381604dd | ||
|
|
c566dd0a71 | ||
|
|
ea78147657 | ||
|
|
0a5189e54a | ||
|
|
102e4aa80b | ||
|
|
0bec947b8b | ||
|
|
8bcd5990c2 | ||
|
|
d0e7ef0169 | ||
|
|
e2cf205d34 | ||
|
|
1dfcd9332a | ||
|
|
b2b45b016d | ||
|
|
ef610c7d23 | ||
|
|
bf0270b244 | ||
|
|
dc77f37129 | ||
|
|
d84186061c | ||
|
|
87bc6522f1 | ||
|
|
c300ebf94a | ||
|
|
7fdf9c12f3 | ||
|
|
e2252a8634 | ||
|
|
190e3d373a | ||
|
|
dbecec3451 | ||
|
|
d92e6bbeff | ||
|
|
e56ddcc3f9 | ||
|
|
84a1ca8335 | ||
|
|
fb8c3b19ce | ||
|
|
b940b27ad2 | ||
|
|
d416d352e0 | ||
|
|
7a7aadffe5 | ||
|
|
0ac6e8500a | ||
|
|
4c35c0c2e9 | ||
|
|
a15d340608 | ||
|
|
de47e0b1f7 | ||
|
|
969ae87798 | ||
|
|
37e2458475 | ||
|
|
2461853b11 | ||
|
|
fe3318ef79 | ||
|
|
828e359666 | ||
|
|
a5b7e0dac1 | ||
|
|
9fe9469e3a | ||
|
|
16c97d613f | ||
|
|
5a6b18307d | ||
|
|
3ee7b142b3 | ||
|
|
ae92b0b0a9 | ||
|
|
8503762431 | ||
|
|
86abc3528d | ||
|
|
97a005e709 | ||
|
|
30b4370044 | ||
|
|
cffac2a990 | ||
|
|
7433641348 | ||
|
|
c45b12ec58 | ||
|
|
6883c6fcfd | ||
|
|
22c4bc7835 | ||
|
|
1e82376420 | ||
|
|
e8586493e2 | ||
|
|
d5d0ce68aa | ||
|
|
764d4eb1d7 | ||
|
|
de61491093 | ||
|
|
aa854da3f4 | ||
|
|
deafc20db9 | ||
|
|
28cdbe5317 | ||
|
|
6d1487ef4e | ||
|
|
46aa184e10 | ||
|
|
3dbd4baefe | ||
|
|
c1ba303fdc | ||
|
|
fa5dd209c1 | ||
|
|
4cc4acee62 | ||
|
|
5dd28e7a13 | ||
|
|
d0fc7dc8a0 | ||
|
|
87b24912c7 | ||
|
|
62663d9573 | ||
|
|
b582f9d2ef | ||
|
|
a3424eb40d | ||
|
|
f6c70e8429 | ||
|
|
68d03ae7c4 | ||
|
|
1dbcf1037a | ||
|
|
27909ed18e | ||
|
|
91c9b79926 | ||
|
|
ad90db8b38 | ||
|
|
8e3b821a55 | ||
|
|
5ade6ae6ec | ||
|
|
7ca8d3d872 | ||
|
|
8ac989c4e3 | ||
|
|
fceb2f421f | ||
|
|
54827c4c92 | ||
|
|
89baa9482b | ||
|
|
3913a61b74 | ||
|
|
245bc336a5 | ||
|
|
c5efd65e23 | ||
|
|
cb9ac904f6 | ||
|
|
cc38e5724b | ||
|
|
90bedcd42e | ||
|
|
c54189aa90 | ||
|
|
88ec0a248f | ||
|
|
7158aec145 | ||
|
|
214fd03241 | ||
|
|
a3f65b2920 | ||
|
|
787f66be3d | ||
|
|
cd23fc75c5 | ||
|
|
230b5db528 | ||
|
|
2372c40056 | ||
|
|
75d2b0129d | ||
|
|
b410e060b6 | ||
|
|
3e22733081 | ||
|
|
a86777a8f2 | ||
|
|
232077e820 | ||
|
|
4e815d7e9d | ||
|
|
943d1eeccd | ||
|
|
b3666dad8b | ||
|
|
318843793f | ||
|
|
0adb5d21f5 | ||
|
|
28a403361e | ||
|
|
446a3795b7 | ||
|
|
3f0e0747fa | ||
|
|
f3471e961f | ||
|
|
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.
|
||||
+4
-12
@@ -70,18 +70,6 @@ trim_trailing_whitespace = true
|
||||
[*.less]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
##########################################
|
||||
# File Header (Uncomment to support file headers)
|
||||
# https://docs.microsoft.com/visualstudio/ide/reference/add-file-header
|
||||
##########################################
|
||||
|
||||
# [*.{cs,csx,cake,vb,vbx}]
|
||||
file_header_template = Copyright (c) Umbraco.\nSee LICENSE for more details.
|
||||
|
||||
# SA1636: File header copyright text should match
|
||||
# Justification: .editorconfig supports file headers. If this is changed to a value other than "none", a stylecop.json file will need to added to the project.
|
||||
# dotnet_diagnostic.SA1636.severity = none
|
||||
|
||||
##########################################
|
||||
# .NET Language Conventions
|
||||
# https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions
|
||||
@@ -136,6 +124,10 @@ dotnet_code_quality_unused_parameters = all:warning
|
||||
dotnet_style_operator_placement_when_wrapping = end_of_line
|
||||
# https://github.com/dotnet/roslyn/pull/40070
|
||||
dotnet_style_prefer_simplified_interpolation = true:warning
|
||||
# File header preferences
|
||||
file_header_template = Copyright (c) Umbraco.\nSee LICENSE for more details.
|
||||
dotnet_diagnostic.SA1633.severity = none # Suppressed until we decide to enforce it
|
||||
dotnet_diagnostic.SA1636.severity = none # Suppressed since we are using StyleCop
|
||||
|
||||
# C# Code Style Settings
|
||||
# https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#c-code-style-settings
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
name: "SonarQube Cloud - Analysis"
|
||||
|
||||
# This workflow runs the full SonarCloud analysis with the SONAR_TOKEN secret.
|
||||
# It is skipped for fork PRs since secrets are not available in that context.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "v*/dev"
|
||||
- "v*/main"
|
||||
- "release/*"
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
SONAR_PROJECT_KEY: umbraco_Umbraco-CMS
|
||||
SONAR_ORGANIZATION: umbraco
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Build and analyze
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork != true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET from global.json
|
||||
uses: actions/setup-dotnet@v5
|
||||
|
||||
- name: Setup Java 21
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Cache SonarQube packages
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.sonar/cache
|
||||
key: ${{ runner.os }}-sonar
|
||||
restore-keys: ${{ runner.os }}-sonar
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
dotnet tool install --global dotnet-sonarscanner
|
||||
dotnet tool install --global dotnet-coverage
|
||||
|
||||
- name: Load sonar params
|
||||
run: echo "SONARQUBE_SCANNER_PARAMS=$(jq -c . .github/workflows/sonarcloud/sonar-params.json)" >> $GITHUB_ENV
|
||||
|
||||
- name: Begin analysis
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
run: |
|
||||
dotnet-sonarscanner begin \
|
||||
/k:"$SONAR_PROJECT_KEY" \
|
||||
/o:"$SONAR_ORGANIZATION" \
|
||||
/d:sonar.token="$SONAR_TOKEN" \
|
||||
/d:sonar.scanner.skipJreProvisioning=true
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore umbraco.sln
|
||||
|
||||
- name: Build solution
|
||||
run: GITHUB_ENV=/dev/null dotnet build umbraco.sln --no-restore -clp:ErrorsOnly # prevent sonar MSBuild integration from writing malformed values to $GITHUB_ENV
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
id: tests
|
||||
continue-on-error: true
|
||||
run: |
|
||||
dotnet-coverage collect \
|
||||
"dotnet test tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj --no-build" \
|
||||
--output TestResults/coverage.xml \
|
||||
--output-format xml
|
||||
|
||||
- name: Warn on test failure
|
||||
if: steps.tests.outcome == 'failure'
|
||||
run: |
|
||||
if [ -f TestResults/coverage.xml ]; then
|
||||
echo "::warning::Unit tests failed - SonarCloud analysis will proceed with the collected coverage data"
|
||||
else
|
||||
echo "::warning::Unit tests failed and no coverage data was collected"
|
||||
fi
|
||||
|
||||
- name: End analysis
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
run: dotnet-sonarscanner end /d:sonar.token="$SONAR_TOKEN"
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"sonar.cs.vscoveragexml.reportsPaths": "TestResults/coverage.xml",
|
||||
"sonar.inclusions": "src/**,templates/**,tools/**,tests/**,.github/**,build/**",
|
||||
"sonar.exclusions": "**/bin/**,**/obj/**,**/node_modules/**,**/lang/*.ts,**/mocks/**,**/wwwroot/**,**/dist-cms/**,**/*.generated.cs,src/Umbraco.Web.UI/umbraco/**,src/Umbraco.Cms.Persistence.EFCore.*/Migrations/**,src/Umbraco.Web.UI.Client/src/packages/core/backend-api/**,**/.nuget/**",
|
||||
"sonar.test.inclusions": "tests/**,**/*.test.ts,**/*.spec.ts",
|
||||
"sonar.typescript.tsconfigPaths": "src/Umbraco.Web.UI.Client/tsconfig.json,src/Umbraco.Web.UI.Client/tsconfig.node.json,src/Umbraco.Web.UI.Login/tsconfig.json"
|
||||
}
|
||||
@@ -121,3 +121,6 @@ trace.zip
|
||||
/tests/Umbraco.Tests.Integration/umbraco-package-schema.json
|
||||
/src/Umbraco.Cms/appsettings-schema.json
|
||||
.playwright-mcp/
|
||||
|
||||
# SonarQube local analysis cache
|
||||
.sonarqube/
|
||||
|
||||
@@ -48,7 +48,6 @@ dotnet_analyzer_diagnostic.category-StyleCop.CSharp.OrderingRules.severity = sug
|
||||
dotnet_analyzer_diagnostic.category-StyleCop.CSharp.MaintainabilityRules.severity = suggestion
|
||||
dotnet_analyzer_diagnostic.category-StyleCop.CSharp.LayoutRules.severity = suggestion
|
||||
|
||||
dotnet_diagnostic.SA1636.severity = none # SA1636: File header copyright text should match
|
||||
dotnet_diagnostic.SA1101.severity = none # PrefixLocalCallsWithThis - stylecop appears to be ignoring dotnet_style_qualification_for_*
|
||||
dotnet_diagnostic.SA1309.severity = none # FieldNamesMustNotBeginWithUnderscore
|
||||
|
||||
|
||||
@@ -558,6 +558,8 @@ Allowed, but cheap to write and cheaper to leave behind. Keep them short and tra
|
||||
|
||||
Verify any test you add for a bug fix actually catches the bug: either write the failing test first (TDD), or temporarily revert the production change and confirm the test fails before re-applying. A test that passes both ways proves nothing. Watch for coincidental passes — default seed/sort orders can make a buggy path produce the right answer for the test's specific inputs; construct inputs so the broken and fixed behaviours give visibly different results.
|
||||
|
||||
For integration tests that exercise caching or cache refreshers, see `tests/Umbraco.Tests.Integration/CLAUDE.md` — the harness disables caching by default, which can produce false greens.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
+11
-1
@@ -40,7 +40,7 @@
|
||||
<!-- Package Validation -->
|
||||
<PropertyGroup>
|
||||
<GenerateCompatibilitySuppressionFile>false</GenerateCompatibilitySuppressionFile>
|
||||
<EnablePackageValidation>false</EnablePackageValidation> <!-- TODO (V18): Set to true once this version is released. -->
|
||||
<EnablePackageValidation>true</EnablePackageValidation>
|
||||
<PackageValidationBaselineVersion>18.0.0</PackageValidationBaselineVersion>
|
||||
<EnableStrictModeForCompatibleFrameworksInPackage>true</EnableStrictModeForCompatibleFrameworksInPackage>
|
||||
<EnableStrictModeForCompatibleTfms>true</EnableStrictModeForCompatibleTfms>
|
||||
@@ -64,4 +64,14 @@
|
||||
</_ProjectReferencesWithVersions>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<!-- Workaround for https://github.com/umbraco/Umbraco-CMS/issues/23018
|
||||
Due to the amount of XML documentation in this solution, the OpenAPI XML documentation source generator produces
|
||||
too many lines of code causing a StackOverflowException when running on IIS. For that reason we disable the analyzer.
|
||||
See https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/openapi-comments?view=aspnetcore-10.0#disabling-xml-documentation-support -->
|
||||
<Target Name="DisableCompileTimeOpenApiXmlGenerator" BeforeTargets="CoreCompile" Condition="'$(IsPackable)' != 'false' or '$(IsTestProject)' == 'true'">
|
||||
<ItemGroup>
|
||||
<Analyzer Remove="@(Analyzer)" Condition="'%(Filename)' == 'Microsoft.AspNetCore.OpenApi.SourceGenerators'" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -49,15 +49,15 @@
|
||||
<PackageVersion Include="Asp.Versioning.Mvc" Version="10.0.0" />
|
||||
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.0.0" />
|
||||
<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="1.1.3" />
|
||||
<PackageVersion Include="Markdown" Version="2.2.1" />
|
||||
<PackageVersion Include="MessagePack" Version="3.1.4" />
|
||||
<PackageVersion Include="MessagePack" Version="3.1.7" />
|
||||
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
|
||||
<PackageVersion Include="MiniProfiler.Shared" Version="4.5.4" />
|
||||
<PackageVersion Include="ncrontab" Version="3.4.0" />
|
||||
@@ -95,4 +95,4 @@
|
||||
<!-- TODO: Remove this pinned dependency when Examine updates its Microsoft.AspNetCore.DataProtection reference. -->
|
||||
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.7" />
|
||||
</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 }}
|
||||
@@ -59,6 +59,7 @@ public static class UmbracoBuilderApiExtensions
|
||||
string? jsonOptionsName = null)
|
||||
where TConfigureOptions : ConfigureUmbracoOpenApiOptionsBase
|
||||
{
|
||||
apiName = apiName.ToLowerInvariant();
|
||||
builder.Services.AddOpenApi(apiName);
|
||||
builder.Services.ConfigureOptions<TConfigureOptions>();
|
||||
builder.Services.AddOpenApiDocumentToUi(apiName, apiTitle);
|
||||
|
||||
@@ -2,9 +2,9 @@ using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.AspNetCore.OpenApi;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Api.Common.Attributes;
|
||||
using Umbraco.Cms.Api.Common.DependencyInjection;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
@@ -116,12 +116,20 @@ public sealed class BackOfficeOpenApiDocumentBuilder
|
||||
/// <param name="builder">The Umbraco builder to register services against.</param>
|
||||
internal void Build(IUmbracoBuilder builder)
|
||||
{
|
||||
// AddOpenApi lowercases the document name when registering its keyed services (https://github.com/dotnet/aspnetcore/blob/v10.0.9/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs#L64),
|
||||
// so we must normalise here to keep AddOpenApiDocumentToUi and ReplaceOpenApiSchemaService in sync.
|
||||
string lowercasedDocumentName = DocumentName.ToLowerInvariant();
|
||||
|
||||
builder.Services.AddOpenApi(
|
||||
DocumentName,
|
||||
lowercasedDocumentName,
|
||||
options =>
|
||||
{
|
||||
// ShouldInclude matches [MapToApi] case-insensitively to align with how documents are registered.
|
||||
options.ShouldInclude = apiDescription =>
|
||||
apiDescription.ActionDescriptor.HasMapToApiAttribute(DocumentName);
|
||||
apiDescription.ActionDescriptor.EndpointMetadata
|
||||
?.OfType<MapToApiAttribute>()
|
||||
.Any(a => a.ApiName.Equals(DocumentName, StringComparison.OrdinalIgnoreCase))
|
||||
?? false;
|
||||
|
||||
options.CreateSchemaReferenceId = UmbracoSchemaIdGenerator.CreateSchemaReferenceId;
|
||||
|
||||
@@ -158,12 +166,12 @@ public sealed class BackOfficeOpenApiDocumentBuilder
|
||||
|
||||
if (_includedInUi)
|
||||
{
|
||||
builder.Services.AddOpenApiDocumentToUi(DocumentName, _uiTitle ?? _title);
|
||||
builder.Services.AddOpenApiDocumentToUi(lowercasedDocumentName, _uiTitle ?? _title ?? DocumentName);
|
||||
}
|
||||
|
||||
if (_httpJsonOptionsFactory is not null)
|
||||
{
|
||||
builder.Services.ReplaceOpenApiSchemaService(DocumentName, _httpJsonOptionsFactory);
|
||||
builder.Services.ReplaceOpenApiSchemaService(lowercasedDocumentName, _httpJsonOptionsFactory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Schema;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.AspNetCore.OpenApi;
|
||||
@@ -341,6 +343,12 @@ public sealed class ContentTypeSchemaTransformer : IOpenApiSchemaTransformer, IO
|
||||
|
||||
var schemaId = GetSchemaId(jsonTypeInfo);
|
||||
|
||||
// Types that produce 'true' in JSON Schema (unconstrained: JsonNode, object, custom-converter types) should be inline {} rather than named components.
|
||||
if (jsonTypeInfo.Kind == JsonTypeInfoKind.None && jsonTypeInfo.GetJsonSchemaAsNode().GetValueKind() == JsonValueKind.True)
|
||||
{
|
||||
return new OpenApiSchema();
|
||||
}
|
||||
|
||||
// If this is one of the types we handle, and we already started generating it, return a placeholder
|
||||
// to avoid circular reference issues.
|
||||
// In the document transformer, these placeholders will be replaced with the actual schemas.
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -2,6 +2,7 @@ using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.RedirectUrlManagement;
|
||||
|
||||
@@ -32,11 +33,13 @@ public class DeleteByKeyRedirectUrlManagementController : RedirectUrlManagementC
|
||||
[MapToApiVersion("1.0")]
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[EndpointSummary("Deletes a redirect URL.")]
|
||||
[EndpointDescription("Deletes a redirect URL identified by the provided Id.")]
|
||||
public Task<IActionResult> DeleteByKey(CancellationToken cancellationToken, Guid id)
|
||||
{
|
||||
_redirectUrlService.Delete(id);
|
||||
return Task.FromResult<IActionResult>(Ok());
|
||||
RedirectUrlOperationStatus status = _redirectUrlService.DeleteWithStatus(id);
|
||||
return Task.FromResult(RedirectUrlOperationStatusResult(status));
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -1,6 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.Routing;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.RedirectUrlManagement;
|
||||
@@ -14,4 +16,31 @@ namespace Umbraco.Cms.Api.Management.Controllers.RedirectUrlManagement;
|
||||
[Authorize(Policy = AuthorizationPolicies.SectionAccessContent)]
|
||||
public class RedirectUrlManagementControllerBase : ManagementApiControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps a <see cref="RedirectUrlOperationStatus"/> to an appropriate <see cref="IActionResult"/>.
|
||||
/// </summary>
|
||||
/// <param name="status">The operation status to map.</param>
|
||||
/// <returns>An <see cref="IActionResult"/> describing the outcome of the operation.</returns>
|
||||
protected IActionResult RedirectUrlOperationStatusResult(RedirectUrlOperationStatus status) =>
|
||||
OperationStatusResult(status, problemDetailsBuilder => status switch
|
||||
{
|
||||
RedirectUrlOperationStatus.Success => Ok(),
|
||||
RedirectUrlOperationStatus.NotFound => NotFound(problemDetailsBuilder
|
||||
.WithTitle("The redirect URL could not be found")
|
||||
.Build()),
|
||||
RedirectUrlOperationStatus.CancelledByNotification => BadRequest(problemDetailsBuilder
|
||||
.WithTitle("Cancelled by notification")
|
||||
.WithDetail("A notification handler prevented the redirect URL operation.")
|
||||
.Build()),
|
||||
RedirectUrlOperationStatus.Unknown => StatusCode(
|
||||
StatusCodes.Status500InternalServerError,
|
||||
problemDetailsBuilder
|
||||
.WithTitle("Unknown error. Please see the log for more details.")
|
||||
.Build()),
|
||||
_ => StatusCode(
|
||||
StatusCodes.Status500InternalServerError,
|
||||
problemDetailsBuilder
|
||||
.WithTitle("Unknown redirect URL operation status.")
|
||||
.Build()),
|
||||
});
|
||||
}
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ public class CreateTemporaryFileController : TemporaryFileControllerBase
|
||||
|
||||
[HttpPost("")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[EndpointSummary("Creates a temporary file.")]
|
||||
|
||||
@@ -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;
|
||||
|
||||
+803
-680
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Security.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Authorizes permissions on all direct children of a node.
|
||||
/// </summary>
|
||||
internal static class AllChildrenAuthorizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether the user is authorized for every direct child of the given parent (or the root).
|
||||
/// </summary>
|
||||
/// <param name="authorizationService">The authorization service.</param>
|
||||
/// <param name="entityService">The entity service used to resolve the children.</param>
|
||||
/// <param name="user">The current user.</param>
|
||||
/// <param name="parentKey">The parent key, or <c>null</c> to authorize the root-level children.</param>
|
||||
/// <param name="objectType">The object type of the children (and parent).</param>
|
||||
/// <param name="resourceFactory">Builds the permission resource to authorize a batch of child keys against.</param>
|
||||
/// <param name="policy">The authorization policy to apply.</param>
|
||||
/// <returns><c>true</c> if the user is authorized against all children; otherwise <c>false</c>.</returns>
|
||||
public static async Task<bool> IsAuthorizedForChildrenAsync(
|
||||
IAuthorizationService authorizationService,
|
||||
IEntityService entityService,
|
||||
ClaimsPrincipal user,
|
||||
Guid? parentKey,
|
||||
UmbracoObjectTypes objectType,
|
||||
Func<IEnumerable<Guid>, IPermissionResource> resourceFactory,
|
||||
string policy)
|
||||
{
|
||||
const int pageSize = 500;
|
||||
var page = 0;
|
||||
long total;
|
||||
do
|
||||
{
|
||||
Guid[] childKeys = entityService
|
||||
.GetPagedChildren(parentKey, [objectType], objectType, page * pageSize, pageSize, out total)
|
||||
.Select(child => child.Key)
|
||||
.ToArray();
|
||||
|
||||
if (childKeys.Length > 0)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await authorizationService.AuthorizeResourceAsync(
|
||||
user,
|
||||
resourceFactory(childKeys),
|
||||
policy);
|
||||
|
||||
if (authorizationResult.Succeeded is false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
while (page * pageSize < total);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Models.ContentEditing;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
|
||||
/// <summary>
|
||||
/// Base request model for sorting the children of a node by a system field.
|
||||
/// </summary>
|
||||
public abstract class SortChildrenByFieldRequestModelBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the system field to sort the children by.
|
||||
/// The create and update dates are node-level (not culture-specific).
|
||||
/// </summary>
|
||||
public required ContentSortField Field { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the direction to sort in.
|
||||
/// </summary>
|
||||
public required Direction Direction { get; init; }
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
using Umbraco.Cms.Core.Models.ContentEditing;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
|
||||
/// <summary>
|
||||
/// Request model for sorting the children of a document by a system field.
|
||||
/// </summary>
|
||||
public class SortDocumentChildrenByFieldRequestModel : SortChildrenByFieldRequestModelBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the culture whose variant name to sort by, or <c>null</c> to sort by the invariant name.
|
||||
/// Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a document that
|
||||
/// does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.
|
||||
/// </summary>
|
||||
public string? Culture { get; init; }
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
|
||||
/// <summary>
|
||||
/// Request model for sorting the children of a media item by a system field.
|
||||
/// Media items do not vary by culture, so no culture is accepted.
|
||||
/// </summary>
|
||||
public class SortMediaChildrenByFieldRequestModel : SortChildrenByFieldRequestModelBase
|
||||
{
|
||||
}
|
||||
@@ -59,7 +59,7 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
|
||||
private int? _skipver;
|
||||
private RoslynCompiler? _roslynCompiler;
|
||||
private ModelsBuilderSettings _config;
|
||||
private bool _disposedValue;
|
||||
private volatile bool _disposedValue;
|
||||
|
||||
public InMemoryModelFactory(
|
||||
Lazy<UmbracoServices> umbracoServices,
|
||||
@@ -280,25 +280,34 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
|
||||
}
|
||||
}
|
||||
|
||||
// don't use an upgradeable lock here because only 1 thread at a time could enter it
|
||||
try
|
||||
// The factory is disposed on application shutdown (via IRegisteredObject.Stop), but in-flight
|
||||
// requests can still reach this point. Bail out with the current models rather than touching
|
||||
// the disposed lock. The catch below covers the small window where disposal happens after this
|
||||
// check but before (or while) the lock is acquired.
|
||||
if (_disposedValue)
|
||||
{
|
||||
_locker.EnterReadLock();
|
||||
if (_hasModels)
|
||||
{
|
||||
return _infos;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_locker.IsReadLockHeld)
|
||||
{
|
||||
_locker.ExitReadLock();
|
||||
}
|
||||
return _infos;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// don't use an upgradeable lock here because only 1 thread at a time could enter it
|
||||
try
|
||||
{
|
||||
_locker.EnterReadLock();
|
||||
if (_hasModels)
|
||||
{
|
||||
return _infos;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_locker.IsReadLockHeld)
|
||||
{
|
||||
_locker.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
_locker.EnterUpgradeableReadLock();
|
||||
|
||||
if (_hasModels)
|
||||
@@ -359,6 +368,12 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
|
||||
|
||||
return _infos;
|
||||
}
|
||||
catch (ObjectDisposedException ex)
|
||||
{
|
||||
// Expected when the factory is disposed during shutdown mid-request; log so an unexpected disposal stays traceable.
|
||||
_logger.LogDebug(ex, "EnsureModels interrupted by object disposal (assumed application shutdown); returning current models.");
|
||||
return _infos;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_locker.IsWriteLockHeld)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ public class ActionElementContainerDelete : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementContainerMove : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementContainerNew : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementContainerUpdate : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementCopy : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementDelete : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementMove : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementNew : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementPublish : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementRollback : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementUpdate : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -467,6 +467,20 @@ public static class DistributedCacheExtensions
|
||||
|
||||
#endregion
|
||||
|
||||
#region ElementContainerCacheRefresher
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates the id/key map for the specified deleted element containers (folders).
|
||||
/// </summary>
|
||||
/// <param name="dc">The distributed cache.</param>
|
||||
/// <param name="deletedContainers">The element containers that were deleted.</param>
|
||||
public static void RemoveElementContainerCache(this DistributedCache dc, IEnumerable<EntityContainer> deletedContainers)
|
||||
=> dc.RefreshByPayload(
|
||||
ElementContainerCacheRefresher.UniqueId,
|
||||
deletedContainers.Select(container => new ElementContainerCacheRefresher.JsonPayload(container.Id, container.Key)));
|
||||
|
||||
#endregion
|
||||
|
||||
#region Published Snapshot
|
||||
|
||||
/// <summary>
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates element caches when an element container (folder) is deleted, so that its key→id mapping
|
||||
/// is evicted from <see cref="Services.IIdKeyMap"/> on every server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Element container deletions only publish <see cref="EntityContainerDeletedNotification"/> and an
|
||||
/// <see cref="ElementTreeChangeNotification"/> for the contained elements - never for the container node
|
||||
/// itself, so without this handler the container's stale id/key mapping survives until the next app
|
||||
/// restart (see #23072).
|
||||
/// </remarks>
|
||||
public sealed class ElementContainerDeletedDistributedCacheNotificationHandler
|
||||
: DeletedDistributedCacheNotificationHandlerBase<EntityContainer, EntityContainerDeletedNotification>
|
||||
{
|
||||
private readonly DistributedCache _distributedCache;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ElementContainerDeletedDistributedCacheNotificationHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="distributedCache">The distributed cache.</param>
|
||||
public ElementContainerDeletedDistributedCacheNotificationHandler(DistributedCache distributedCache)
|
||||
=> _distributedCache = distributedCache;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Handle(IEnumerable<EntityContainer> entities, IDictionary<string, object?> state)
|
||||
{
|
||||
EntityContainer[] elementContainers = entities
|
||||
.Where(container => container.ContainerObjectType == Constants.ObjectTypes.ElementContainer)
|
||||
.ToArray();
|
||||
|
||||
if (elementContainers.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_distributedCache.RemoveElementContainerCache(elementContainers);
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -18,5 +18,14 @@ public sealed class LanguageDeletedDistributedCacheNotificationHandler : Deleted
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Handle(IEnumerable<ILanguage> entities, IDictionary<string, object?> state)
|
||||
=> _distributedCache.RemoveLanguageCache(entities);
|
||||
{
|
||||
_distributedCache.RemoveLanguageCache(entities);
|
||||
|
||||
// User groups cache their allowed language ids, so a deleted language must be evicted from
|
||||
// them too - otherwise a stale, now-missing id lingers on the cached user group and breaks
|
||||
// reads that resolve those ids. This is a deliberately coarse refresh of the entire user group
|
||||
// and user caches (RefreshAll also clears IUser): we can't know which groups reference the
|
||||
// language without a query, and language deletion is rare enough that a full refresh is fine.
|
||||
_distributedCache.RefreshAllUserGroupCache();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,8 +368,17 @@ public class ObjectCacheAppCache : IAppPolicyCache, IDisposable
|
||||
}
|
||||
|
||||
// Ensure key is removed from set when evicted from cache
|
||||
return options.RegisterPostEvictionCallback((key, _, _, _) =>
|
||||
return options.RegisterPostEvictionCallback((key, _, reason, _) =>
|
||||
{
|
||||
// Removed and Replaced evictions don't need pruning here: the Remove/Clear call sites already
|
||||
// prune the tracking set synchronously under the write lock, and a Replaced key still has a
|
||||
// live entry (the synchronous Set re-added it). Pruning here instead runs on a background
|
||||
// thread and races with that re-add, dropping a key whose entry is still cached. (#23064)
|
||||
if (reason is EvictionReason.Removed or EvictionReason.Replaced)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_locker.TryEnterWriteLock(_writeLockTimeout) is false)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Core.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Provides cache refresh functionality for element containers (folders).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A deleted container's node id is never reused, so its key→id mapping in <see cref="IIdKeyMap"/> must be
|
||||
/// evicted on every server. Otherwise a container recreated under the same key resolves to the stale id and
|
||||
/// the element tree's children query returns nothing until the next app restart. This refresher only evicts
|
||||
/// the id/key map - element data is unaffected by container changes, so it deliberately avoids the broader
|
||||
/// invalidation performed by <see cref="ElementCacheRefresher"/>.
|
||||
/// </remarks>
|
||||
public sealed class ElementContainerCacheRefresher : PayloadCacheRefresherBase<ElementContainerCacheRefresherNotification, ElementContainerCacheRefresher.JsonPayload>
|
||||
{
|
||||
private readonly IIdKeyMap _idKeyMap;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresher"/> class.
|
||||
/// </summary>
|
||||
public ElementContainerCacheRefresher(
|
||||
AppCaches appCaches,
|
||||
IJsonSerializer serializer,
|
||||
IIdKeyMap idKeyMap,
|
||||
IEventAggregator eventAggregator,
|
||||
ICacheRefresherNotificationFactory factory)
|
||||
: base(appCaches, serializer, eventAggregator, factory)
|
||||
=> _idKeyMap = idKeyMap;
|
||||
|
||||
#region Json
|
||||
|
||||
/// <summary>
|
||||
/// Represents a JSON-serializable payload identifying an element container that changed.
|
||||
/// </summary>
|
||||
public class JsonPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="JsonPayload"/> class.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique integer identifier for the container.</param>
|
||||
/// <param name="key">The unique GUID key associated with the container.</param>
|
||||
public JsonPayload(int id, Guid key)
|
||||
{
|
||||
Id = id;
|
||||
Key = key;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique integer identifier for the container.
|
||||
/// </summary>
|
||||
public int Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique GUID key associated with the container.
|
||||
/// </summary>
|
||||
public Guid Key { get; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Define
|
||||
|
||||
/// <summary>
|
||||
/// Represents a unique identifier for the cache refresher.
|
||||
/// </summary>
|
||||
public static readonly Guid UniqueId = Guid.Parse("9C9D8B0E-2F1A-4D63-9C2E-7E6B5A4F3C21");
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Guid RefresherUniqueId => UniqueId;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Name => "Element Container Cache Refresher";
|
||||
|
||||
#endregion
|
||||
|
||||
#region Refresher
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Refresh(JsonPayload[] payloads)
|
||||
{
|
||||
foreach (JsonPayload payload in payloads)
|
||||
{
|
||||
// Clearing by id also evicts the key→id direction, as the id/key map keeps both in sync.
|
||||
_idKeyMap.ClearCache(payload.Id);
|
||||
}
|
||||
|
||||
base.Refresh(payloads);
|
||||
}
|
||||
|
||||
// These events should never trigger. Everything should be PAYLOAD/JSON.
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void RefreshAll() => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Refresh(int id) => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Refresh(Guid id) => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Remove(int id) => throw new NotSupportedException();
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -16,6 +16,11 @@ public class ContentSettings
|
||||
/// </summary>
|
||||
internal const bool StaticResolveUrlsFromTextString = false;
|
||||
|
||||
/// <summary>
|
||||
/// The default value for whether sorting children by a field fires per-item notifications.
|
||||
/// </summary>
|
||||
internal const bool StaticSortChildrenByFieldFiresNotifications = false;
|
||||
|
||||
/// <summary>
|
||||
/// The default preview badge markup template.
|
||||
/// </summary>
|
||||
@@ -109,6 +114,18 @@ public class ContentSettings
|
||||
[DefaultValue(StaticResolveUrlsFromTextString)]
|
||||
public bool ResolveUrlsFromTextString { get; set; } = StaticResolveUrlsFromTextString;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether sorting the children of a node by a field fires
|
||||
/// per-item save/sort notifications (and therefore webhooks).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Defaults to <c>false</c>: the children are reordered with a single set-based update and a branch
|
||||
/// cache refresh, without per-item notifications. Set to <c>true</c> to restore per-item notifications
|
||||
/// (and webhooks), accepting the additional performance cost on nodes with many children.
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticSortChildrenByFieldFiresNotifications)]
|
||||
public bool SortChildrenByFieldFiresNotifications { get; set; } = StaticSortChildrenByFieldFiresNotifications;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for the collection of error pages.
|
||||
/// </summary>
|
||||
|
||||
@@ -30,6 +30,17 @@ public class DatabaseServerMessengerSettings
|
||||
/// </summary>
|
||||
internal const string StaticTimeBetweenPruneOperations = "00:01:00"; // TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// The default timeout for a single synchronization operation.
|
||||
/// </summary>
|
||||
internal const string StaticSyncTimeout = "00:01:00"; // TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default timeout for a single synchronization operation, for use as a fallback when an invalid
|
||||
/// <see cref="SyncTimeout" /> is configured.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultSyncTimeout = TimeSpan.Parse(StaticSyncTimeout);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for the maximum number of instructions that can be processed at startup; otherwise the server
|
||||
/// cold-boots (rebuilds its caches).
|
||||
@@ -55,4 +66,13 @@ public class DatabaseServerMessengerSettings
|
||||
/// </summary>
|
||||
[DefaultValue(StaticTimeBetweenPruneOperations)]
|
||||
public TimeSpan TimeBetweenPruneOperations { get; set; } = TimeSpan.Parse(StaticTimeBetweenPruneOperations);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum time to wait for a single synchronization operation to complete before it is
|
||||
/// considered stalled (for example, blocked on a hung database connection) and abandoned, so the recurring
|
||||
/// job keeps running rather than stopping permanently. This bounds how long the job waits on a single sync,
|
||||
/// not how long a stalled connection itself takes to recover (which is governed by the database timeouts).
|
||||
/// </summary>
|
||||
[DefaultValue(StaticSyncTimeout)]
|
||||
public TimeSpan SyncTimeout { get; set; } = DefaultSyncTimeout;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,17 @@ public class DatabaseServerRegistrarSettings
|
||||
/// </summary>
|
||||
internal const string StaticStaleServerTimeout = "00:02:00";
|
||||
|
||||
/// <summary>
|
||||
/// The default timeout for a single server touch operation.
|
||||
/// </summary>
|
||||
internal const string StaticTouchTimeout = "00:01:00"; // TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default timeout for a single server touch operation, for use as a fallback when an invalid
|
||||
/// <see cref="TouchTimeout" /> is configured.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultTouchTimeout = TimeSpan.Parse(StaticTouchTimeout);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for the amount of time to wait between calls to the database on the background thread.
|
||||
/// </summary>
|
||||
@@ -31,4 +42,13 @@ public class DatabaseServerRegistrarSettings
|
||||
/// </summary>
|
||||
[DefaultValue(StaticStaleServerTimeout)]
|
||||
public TimeSpan StaleServerTimeout { get; set; } = TimeSpan.Parse(StaticStaleServerTimeout);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum time to wait for a single server touch operation to complete before it is
|
||||
/// considered stalled (for example, blocked on a hung database connection) and abandoned, so the recurring
|
||||
/// job keeps running rather than stopping permanently. This bounds how long the job waits on a single touch,
|
||||
/// not how long a stalled connection itself takes to recover (which is governed by the database timeouts).
|
||||
/// </summary>
|
||||
[DefaultValue(StaticTouchTimeout)]
|
||||
public TimeSpan TouchTimeout { get; set; } = DefaultTouchTimeout;
|
||||
}
|
||||
|
||||
@@ -20,5 +20,12 @@ public class IndexingSettings
|
||||
/// <summary>
|
||||
/// Gets or sets a value for how many items to index at a time.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the primary lever for the peak memory used while (re)building an index: a full page of
|
||||
/// content and its property data is held in memory at once, so lowering this value reduces rebuild
|
||||
/// memory at the cost of more, smaller batches. Lower it on very large sites that hit memory pressure
|
||||
/// during a rebuild.
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticBatchSize)]
|
||||
public int BatchSize { get; set; } = StaticBatchSize;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ public class LoggingSettings
|
||||
/// </summary>
|
||||
internal const string StaticFileNameFormatArguments = "MachineName";
|
||||
|
||||
/// <summary>
|
||||
/// The default mode for enriching log events with a session identifier.
|
||||
/// </summary>
|
||||
internal const SessionIdLoggingMode StaticSessionIdLogging = SessionIdLoggingMode.SessionId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for the maximum age of a log file.
|
||||
/// </summary>
|
||||
@@ -70,4 +75,16 @@ public class LoggingSettings
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticFileNameFormatArguments)]
|
||||
public string FileNameFormatArguments { get; set; } = StaticFileNameFormatArguments;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value determining how log events are enriched with a session identifier.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Defaults to <see cref="SessionIdLoggingMode.SessionId" /> for backward compatibility. Set to
|
||||
/// <see cref="SessionIdLoggingMode.CookieHash" /> or <see cref="SessionIdLoggingMode.None" /> to avoid the
|
||||
/// blocking session-store load that resolving the actual session id incurs per request when the session is
|
||||
/// backed by an <c>IDistributedCache</c>.
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticSessionIdLogging)]
|
||||
public SessionIdLoggingMode SessionIdLogging { get; set; } = StaticSessionIdLogging;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace Umbraco.Cms.Core.Configuration.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Settings for scheduled publishing.
|
||||
/// </summary>
|
||||
[UmbracoOptions(Constants.Configuration.ConfigScheduledPublishing)]
|
||||
public class ScheduledPublishingSettings
|
||||
{
|
||||
private const string StaticPeriod = "00:01:00";
|
||||
private const bool StaticAlignToClock = false; // TODO (V19): Switch this to true.
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for how often scheduled publishing runs.
|
||||
/// </summary>
|
||||
[DefaultValue(StaticPeriod)]
|
||||
public TimeSpan Period { get; set; } = TimeSpan.Parse(StaticPeriod);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether scheduled publishing runs are aligned to clock boundaries
|
||||
/// derived from <see cref="Period" /> (for example, on the minute, or every N seconds), rather than drifting
|
||||
/// based on when the previous run completed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When enabled, <see cref="Period" /> must be a whole number of seconds that divides evenly into one hour
|
||||
/// (for example 10, 12, 15, 20, 30 or 60 seconds) so that boundaries land on consistent clock times.
|
||||
/// Boundaries are anchored to <strong>UTC</strong>, not the server's local time zone; for sub-minute and
|
||||
/// whole-minute periods this is indistinguishable from local time at the second level.
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticAlignToClock)]
|
||||
public bool AlignToClock { get; set; } = StaticAlignToClock;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
namespace Umbraco.Cms.Core.Configuration.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Determines how request logging enriches log events with a session identifier.
|
||||
/// </summary>
|
||||
public enum SessionIdLoggingMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Do not enrich log events with a session identifier.
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Enrich log events with the actual ASP.NET Core session id. This is the default and matches the
|
||||
/// historical behaviour, but reading the session id forces the session to be loaded from its store, which
|
||||
/// is a blocking round-trip per request when the session is backed by an <c>IDistributedCache</c>.
|
||||
/// </summary>
|
||||
SessionId,
|
||||
|
||||
/// <summary>
|
||||
/// Enrich log events with a one-way hash of the session cookie value. This provides the same per-session
|
||||
/// correlation as <see cref="SessionId" /> without loading the session from its store, so it never incurs
|
||||
/// a distributed-cache round-trip.
|
||||
/// </summary>
|
||||
CookieHash,
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Umbraco.Cms.Core.Configuration.Models.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for configuration represented as <see cref="ScheduledPublishingSettings" />.
|
||||
/// </summary>
|
||||
public class ScheduledPublishingSettingsValidator : ConfigurationValidatorBase, IValidateOptions<ScheduledPublishingSettings>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public ValidateOptionsResult Validate(string? name, ScheduledPublishingSettings options)
|
||||
{
|
||||
if (options.Period <= TimeSpan.Zero)
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"Configuration entry {Constants.Configuration.ConfigScheduledPublishing}:Period must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.AlignToClock && IsCleanDivisorOfAnHour(options.Period) == false)
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"Configuration entry {Constants.Configuration.ConfigScheduledPublishing}:Period must be a whole number of seconds that divides evenly into one hour (3600 seconds) when {Constants.Configuration.ConfigScheduledPublishing}:AlignToClock is enabled, e.g. 10, 12, 15, 20, 30 or 60 seconds.");
|
||||
}
|
||||
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
|
||||
private static bool IsCleanDivisorOfAnHour(TimeSpan period)
|
||||
{
|
||||
var totalSeconds = period.TotalSeconds;
|
||||
|
||||
// Must be a positive, whole number of seconds (no sub-second component).
|
||||
if (totalSeconds <= 0 || totalSeconds != Math.Floor(totalSeconds))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return 3600 % (long)totalSeconds == 0;
|
||||
}
|
||||
}
|
||||
@@ -291,6 +291,11 @@ public static partial class Constants
|
||||
/// </summary>
|
||||
public const string ConfigDistributedJobs = ConfigPrefix + "DistributedJobs";
|
||||
|
||||
/// <summary>
|
||||
/// The configuration key for scheduled publishing settings.
|
||||
/// </summary>
|
||||
public const string ConfigScheduledPublishing = ConfigPrefix + "ScheduledPublishing";
|
||||
|
||||
/// <summary>
|
||||
/// The configuration key for backoffice token cookie settings.
|
||||
/// </summary>
|
||||
|
||||
@@ -57,6 +57,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
builder.Services.AddSingleton<IValidateOptions<RequestHandlerSettings>, RequestHandlerSettingsValidator>();
|
||||
builder.Services.AddSingleton<IValidateOptions<UnattendedSettings>, UnattendedSettingsValidator>();
|
||||
builder.Services.AddSingleton<IValidateOptions<SecuritySettings>, SecuritySettingsValidator>();
|
||||
builder.Services.AddSingleton<IValidateOptions<ScheduledPublishingSettings>, ScheduledPublishingSettingsValidator>();
|
||||
|
||||
// Register configuration sections.
|
||||
builder
|
||||
@@ -100,6 +101,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
.AddUmbracoOptions<CacheSettings>()
|
||||
.AddUmbracoOptions<SystemDateMigrationSettings>()
|
||||
.AddUmbracoOptions<DistributedJobSettings>()
|
||||
.AddUmbracoOptions<ScheduledPublishingSettings>(options => options.ValidateOnStart())
|
||||
.AddUmbracoOptions<BackOfficeTokenCookieSettings>()
|
||||
.AddUmbracoOptions<WebsiteSettings>()
|
||||
.AddUmbracoOptions<SignalRSettings>();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -402,6 +402,7 @@
|
||||
<key alias="invalidMediaType">The chosen media type is invalid.</key>
|
||||
<key alias="invalidContentType">The chosen content is of invalid type.</key>
|
||||
<key alias="missingContent">The chosen content does not exist.</key>
|
||||
<key alias="missingMedia">The chosen media does not exist.</key>
|
||||
<key alias="multipleMediaNotAllowed">Multiple selected media is not allowed.</key>
|
||||
<key alias="notOneOfOptions">The value '%0%' is not one of the available options.</key>
|
||||
<key alias="multipleNotOneOfOptions">The values '%0%' are not found in the the available options.</key>
|
||||
@@ -463,7 +464,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,
|
||||
}
|
||||
@@ -24,4 +24,9 @@ public enum TaggableObjectTypes
|
||||
/// Represents member entities (user accounts).
|
||||
/// </summary>
|
||||
Member,
|
||||
|
||||
/// <summary>
|
||||
/// Represents element entities.
|
||||
/// </summary>
|
||||
Element,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
|
||||
namespace Umbraco.Cms.Core.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// A notification that is used to trigger the Element Container Cache Refresher.
|
||||
/// </summary>
|
||||
public class ElementContainerCacheRefresherNotification : CacheRefresherNotification
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresherNotification"/> class.
|
||||
/// </summary>
|
||||
/// <param name="messageObject">The refresher payload.</param>
|
||||
/// <param name="messageType">Type of the cache refresher message, <see cref="MessageType"/>.</param>
|
||||
public ElementContainerCacheRefresherNotification(object messageObject, MessageType messageType)
|
||||
: base(messageObject, messageType)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
|
||||
namespace Umbraco.Cms.Core.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Notification published after one or more redirect URLs have been deleted.
|
||||
/// </summary>
|
||||
public class RedirectUrlDeletedNotification : DeletedNotification<IRedirectUrl>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedirectUrlDeletedNotification" /> class with a single redirect URL.
|
||||
/// </summary>
|
||||
/// <param name="target">The redirect URL that was deleted.</param>
|
||||
/// <param name="messages">The event messages collection.</param>
|
||||
public RedirectUrlDeletedNotification(IRedirectUrl target, EventMessages messages)
|
||||
: base(target, messages)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedirectUrlDeletedNotification" /> class with multiple redirect URLs.
|
||||
/// </summary>
|
||||
/// <param name="target">The redirect URLs that were deleted.</param>
|
||||
/// <param name="messages">The event messages collection.</param>
|
||||
public RedirectUrlDeletedNotification(IEnumerable<IRedirectUrl> target, EventMessages messages)
|
||||
: base(target, messages)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
|
||||
namespace Umbraco.Cms.Core.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Notification published before one or more redirect URLs are deleted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This notification is cancelable, allowing handlers to prevent the delete operation
|
||||
/// by setting <see cref="ICancelableNotification.Cancel" /> to <c>true</c>.
|
||||
/// </remarks>
|
||||
public class RedirectUrlDeletingNotification : DeletingNotification<IRedirectUrl>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedirectUrlDeletingNotification" /> class with a single redirect URL.
|
||||
/// </summary>
|
||||
/// <param name="target">The redirect URL being deleted.</param>
|
||||
/// <param name="messages">The event messages collection.</param>
|
||||
public RedirectUrlDeletingNotification(IRedirectUrl target, EventMessages messages)
|
||||
: base(target, messages)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedirectUrlDeletingNotification" /> class with multiple redirect URLs.
|
||||
/// </summary>
|
||||
/// <param name="target">The redirect URLs being deleted.</param>
|
||||
/// <param name="messages">The event messages collection.</param>
|
||||
public RedirectUrlDeletingNotification(IEnumerable<IRedirectUrl> target, EventMessages messages)
|
||||
: base(target, messages)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
|
||||
namespace Umbraco.Cms.Core.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Notification published after a redirect URL has been saved.
|
||||
/// </summary>
|
||||
public class RedirectUrlSavedNotification : SavedNotification<IRedirectUrl>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedirectUrlSavedNotification" /> class.
|
||||
/// </summary>
|
||||
/// <param name="target">The redirect URL that was saved.</param>
|
||||
/// <param name="messages">The event messages collection.</param>
|
||||
public RedirectUrlSavedNotification(IRedirectUrl target, EventMessages messages)
|
||||
: base(target, messages)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedirectUrlSavedNotification" /> class.
|
||||
/// </summary>
|
||||
/// <param name="target">The redirect URLs that were saved.</param>
|
||||
/// <param name="messages">The event messages collection.</param>
|
||||
public RedirectUrlSavedNotification(IEnumerable<IRedirectUrl> target, EventMessages messages)
|
||||
: base(target, messages)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
|
||||
namespace Umbraco.Cms.Core.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Notification published before a redirect URL is saved.
|
||||
/// </summary>
|
||||
public class RedirectUrlSavingNotification : SavingNotification<IRedirectUrl>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedirectUrlSavingNotification" /> class.
|
||||
/// </summary>
|
||||
/// <param name="target">The redirect URL being saved.</param>
|
||||
/// <param name="messages">The event messages collection.</param>
|
||||
public RedirectUrlSavingNotification(IRedirectUrl target, EventMessages messages)
|
||||
: base(target, messages)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedirectUrlSavingNotification" /> class.
|
||||
/// </summary>
|
||||
/// <param name="target">The redirect URLs being saved.</param>
|
||||
/// <param name="messages">The event messages collection.</param>
|
||||
public RedirectUrlSavingNotification(IEnumerable<IRedirectUrl> target, EventMessages messages)
|
||||
: base(target, messages)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,19 @@ public interface IContentRepository<in TId, TEntity> : IReadWriteQueryRepository
|
||||
/// </summary>
|
||||
int RecycleBinId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Updates the sort order of the specified nodes so that each node's sort order matches its
|
||||
/// position in the supplied (already ordered) collection, in a single set-based update.
|
||||
/// </summary>
|
||||
/// <param name="orderedNodeIds">The node identifiers in their desired order.</param>
|
||||
/// <remarks>
|
||||
/// This persists the sort order directly and does not load the entities or fire any notifications;
|
||||
/// callers are responsible for any required cache refresh and auditing.
|
||||
/// </remarks>
|
||||
// TODO (V19): Remove the default implementation.
|
||||
void UpdateSortOrder(IReadOnlyList<int> orderedNodeIds)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <summary>
|
||||
/// Gets versions.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Parses the comma-separated content type keys stored in a picker's "allowed content types" configuration value
|
||||
/// (e.g. <see cref="ContentPickerConfiguration.AllowedContentTypeIds"/> or <see cref="ElementPickerConfiguration.AllowedContentTypeIds"/>).
|
||||
/// </summary>
|
||||
internal static class AllowedContentTypeKeysParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses the configured value into the set of allowed content type keys.
|
||||
/// </summary>
|
||||
/// <param name="configValue">The comma-separated configuration value. Non-GUID entries are ignored.</param>
|
||||
/// <returns>The set of allowed content type keys, or an empty set when nothing is configured.</returns>
|
||||
public static HashSet<Guid> Parse(string? configValue)
|
||||
{
|
||||
if (configValue.IsNullOrWhiteSpace())
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new HashSet<Guid>();
|
||||
foreach (var entry in configValue.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (Guid.TryParse(entry, out Guid guid))
|
||||
{
|
||||
result.Add(guid);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,10 @@ public class ContentPickerConfiguration : IIgnoreUserStartNodesConfig
|
||||
/// <inheritdoc />
|
||||
[ConfigurationField(Constants.DataTypes.ReservedPreValueKeys.IgnoreUserStartNodes)]
|
||||
public bool IgnoreUserStartNodes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the content type filter for allowed selections.
|
||||
/// </summary>
|
||||
[ConfigurationField("allowedContentTypes")]
|
||||
public string? AllowedContentTypeIds { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Editors;
|
||||
using Umbraco.Cms.Core.Models.Validation;
|
||||
using Umbraco.Cms.Core.PropertyEditors.Validation;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Strings;
|
||||
@@ -70,13 +74,21 @@ public class ContentPickerPropertyEditor : DataEditor, IValueSchemaProvider
|
||||
/// <param name="jsonSerializer">The JSON serializer.</param>
|
||||
/// <param name="ioHelper">The IO helper.</param>
|
||||
/// <param name="attribute">The data editor attribute.</param>
|
||||
/// <param name="coreScopeProvider">The core scope provider.</param>
|
||||
/// <param name="contentService">The content service.</param>
|
||||
/// <param name="localizedTextService">The localized text service.</param>
|
||||
public ContentPickerPropertyValueEditor(
|
||||
IShortStringHelper shortStringHelper,
|
||||
IJsonSerializer jsonSerializer,
|
||||
IIOHelper ioHelper,
|
||||
DataEditorAttribute attribute)
|
||||
DataEditorAttribute attribute,
|
||||
ICoreScopeProvider coreScopeProvider,
|
||||
IContentService contentService,
|
||||
ILocalizedTextService localizedTextService)
|
||||
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
|
||||
{
|
||||
Validators.Add(new TypedValidatorRunner<string, ContentPickerConfiguration>(
|
||||
new AllowedTypeValidator(localizedTextService, contentService, coreScopeProvider)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -134,4 +146,61 @@ public class ContentPickerPropertyEditor : DataEditor, IValueSchemaProvider
|
||||
return guidUdi.Guid;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the selected content matches the allowed content types configured for the property editor.
|
||||
/// </summary>
|
||||
/// <param name="localizedTextService">The localized text service.</param>
|
||||
/// <param name="contentService">The content service.</param>
|
||||
/// <param name="coreScopeProvider">The core scope provider.</param>
|
||||
internal sealed class AllowedTypeValidator(ILocalizedTextService localizedTextService, IContentService contentService, ICoreScopeProvider coreScopeProvider)
|
||||
: ITypedValidator<string, ContentPickerConfiguration>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<ValidationResult> Validate(
|
||||
string? value,
|
||||
ContentPickerConfiguration? configuration,
|
||||
string? valueType,
|
||||
PropertyValidationContext validationContext)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) ||
|
||||
configuration is null ||
|
||||
Guid.TryParse(value, out Guid id) is false)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
HashSet<Guid> allowedContentTypeKeys = AllowedContentTypeKeysParser.Parse(configuration.AllowedContentTypeIds);
|
||||
|
||||
// No filter configured — all content types are allowed.
|
||||
if (allowedContentTypeKeys.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
using ICoreScope scope = coreScopeProvider.CreateCoreScope();
|
||||
Guid? key = contentService.GetById(id)?.ContentType?.Key;
|
||||
scope.Complete();
|
||||
|
||||
if (key is null)
|
||||
{
|
||||
return [new ValidationResult(
|
||||
localizedTextService.Localize(
|
||||
"validation",
|
||||
"missingContent"),
|
||||
["value"])];
|
||||
}
|
||||
|
||||
if (allowedContentTypeKeys.Contains(key.Value) is false)
|
||||
{
|
||||
return [new ValidationResult(
|
||||
localizedTextService.Localize(
|
||||
"validation",
|
||||
"invalidObjectType"),
|
||||
["value"])];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,32 @@ public class ElementPickerConfiguration : IIgnoreUserStartNodesConfig
|
||||
/// <inheritdoc />
|
||||
[ConfigurationField(Constants.DataTypes.ReservedPreValueKeys.IgnoreUserStartNodes)]
|
||||
public bool IgnoreUserStartNodes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the validation limits for the number of elements allowed.
|
||||
/// </summary>
|
||||
[ConfigurationField("validationLimit")]
|
||||
public NumberRange? ValidationLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the content type filter for allowed selections.
|
||||
/// </summary>
|
||||
[ConfigurationField("allowedContentTypes")]
|
||||
public string? AllowedContentTypeIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Represents a numeric range with optional minimum and maximum values.
|
||||
/// </summary>
|
||||
public class NumberRange
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum value of the range.
|
||||
/// </summary>
|
||||
public int? Min { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum value of the range.
|
||||
/// </summary>
|
||||
public int? Max { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Editors;
|
||||
using Umbraco.Cms.Core.Models.Validation;
|
||||
using Umbraco.Cms.Core.PropertyEditors.Validation;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Strings;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Element picker property editor that stores element keys
|
||||
/// Element picker property editor that stores element keys.
|
||||
/// </summary>
|
||||
[DataEditor(
|
||||
Constants.PropertyEditors.Aliases.ElementPicker,
|
||||
@@ -17,6 +23,11 @@ public class ElementPickerPropertyEditor : DataEditor
|
||||
{
|
||||
private readonly IIOHelper _ioHelper;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ElementPickerPropertyEditor" /> class.
|
||||
/// </summary>
|
||||
/// <param name="dataValueEditorFactory">The data value editor factory.</param>
|
||||
/// <param name="ioHelper">The IO helper.</param>
|
||||
public ElementPickerPropertyEditor(IDataValueEditorFactory dataValueEditorFactory, IIOHelper ioHelper)
|
||||
: base(dataValueEditorFactory)
|
||||
{
|
||||
@@ -28,21 +39,44 @@ public class ElementPickerPropertyEditor : DataEditor
|
||||
protected override IConfigurationEditor CreateConfigurationEditor() =>
|
||||
new ElementPickerConfigurationEditor(_ioHelper);
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override IDataValueEditor CreateValueEditor() =>
|
||||
DataValueEditorFactory.Create<ElementPickerPropertyValueEditor>(Attribute!);
|
||||
|
||||
/// <summary>
|
||||
/// Provides the value editor for the element picker property editor.
|
||||
/// </summary>
|
||||
internal sealed class ElementPickerPropertyValueEditor : DataValueEditor, IDataValueReference
|
||||
{
|
||||
private readonly IJsonSerializer _jsonSerializer;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ElementPickerPropertyValueEditor" /> class.
|
||||
/// </summary>
|
||||
/// <param name="shortStringHelper">The short string helper.</param>
|
||||
/// <param name="jsonSerializer">The JSON serializer.</param>
|
||||
/// <param name="ioHelper">The IO helper.</param>
|
||||
/// <param name="attribute">The data editor attribute.</param>
|
||||
/// <param name="localizedTextService">The localized text service.</param>
|
||||
/// <param name="elementService">The element service.</param>
|
||||
/// <param name="coreScopeProvider">The core scope provider.</param>
|
||||
public ElementPickerPropertyValueEditor(
|
||||
IShortStringHelper shortStringHelper,
|
||||
IJsonSerializer jsonSerializer,
|
||||
IIOHelper ioHelper,
|
||||
DataEditorAttribute attribute)
|
||||
DataEditorAttribute attribute,
|
||||
ILocalizedTextService localizedTextService,
|
||||
IElementService elementService,
|
||||
ICoreScopeProvider coreScopeProvider)
|
||||
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
|
||||
=> _jsonSerializer = jsonSerializer;
|
||||
{
|
||||
_jsonSerializer = jsonSerializer;
|
||||
Validators.Add(new TypedValidatorRunner<List<string>, ElementPickerConfiguration>(
|
||||
new MinMaxValidator(localizedTextService),
|
||||
new AllowedTypeValidator(localizedTextService, elementService, coreScopeProvider)));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<UmbracoEntityReference> GetReferences(object? value)
|
||||
{
|
||||
var asString = value as string ?? value?.ToString();
|
||||
@@ -63,4 +97,144 @@ public class ElementPickerPropertyEditor : DataEditor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator to ensure that the number of selected elements is within the configured min/max limits, if any.
|
||||
/// </summary>
|
||||
internal sealed class MinMaxValidator : ITypedValidator<List<string>, ElementPickerConfiguration>
|
||||
{
|
||||
private readonly ILocalizedTextService _localizedTextService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MinMaxValidator" /> class.
|
||||
/// </summary>
|
||||
/// <param name="localizedTextService">The localized text service.</param>
|
||||
public MinMaxValidator(ILocalizedTextService localizedTextService)
|
||||
=> _localizedTextService = localizedTextService;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<ValidationResult> Validate(
|
||||
List<string>? value,
|
||||
ElementPickerConfiguration? configuration,
|
||||
string? valueType,
|
||||
PropertyValidationContext validationContext)
|
||||
{
|
||||
var validationResults = new List<ValidationResult>();
|
||||
|
||||
if (configuration is null || configuration.ValidationLimit is null)
|
||||
{
|
||||
return validationResults;
|
||||
}
|
||||
|
||||
if (configuration.ValidationLimit.Min is int min and > 0 && (value is null || value.Count < min))
|
||||
{
|
||||
validationResults.Add(new ValidationResult(
|
||||
_localizedTextService.Localize(
|
||||
"validation",
|
||||
"entriesShort",
|
||||
[min.ToString(), (min - (value?.Count ?? 0)).ToString()]),
|
||||
["value"]));
|
||||
}
|
||||
|
||||
if (value is null)
|
||||
{
|
||||
return validationResults;
|
||||
}
|
||||
|
||||
if (configuration.ValidationLimit.Max is int max and > 0 && value.Count > max)
|
||||
{
|
||||
validationResults.Add(new ValidationResult(
|
||||
_localizedTextService.Localize(
|
||||
"validation",
|
||||
"entriesExceed",
|
||||
[max.ToString(), (value.Count - max).ToString()]),
|
||||
["value"]));
|
||||
}
|
||||
|
||||
return validationResults;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator to ensure that all selected elements are of an allowed content type, if any are configured.
|
||||
/// </summary>
|
||||
internal sealed class AllowedTypeValidator : ITypedValidator<List<string>, ElementPickerConfiguration>
|
||||
{
|
||||
private readonly ILocalizedTextService _localizedTextService;
|
||||
private readonly IElementService _elementService;
|
||||
private readonly ICoreScopeProvider _coreScopeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AllowedTypeValidator" /> class.
|
||||
/// </summary>
|
||||
/// <param name="localizedTextService">The localized text service.</param>
|
||||
/// <param name="elementService">The element service.</param>
|
||||
/// <param name="coreScopeProvider">The core scope provider.</param>
|
||||
public AllowedTypeValidator(
|
||||
ILocalizedTextService localizedTextService,
|
||||
IElementService elementService,
|
||||
ICoreScopeProvider coreScopeProvider)
|
||||
{
|
||||
_localizedTextService = localizedTextService;
|
||||
_elementService = elementService;
|
||||
_coreScopeProvider = coreScopeProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<ValidationResult> Validate(
|
||||
List<string>? value,
|
||||
ElementPickerConfiguration? configuration,
|
||||
string? valueType,
|
||||
PropertyValidationContext validationContext)
|
||||
{
|
||||
if (value is null || value.Count == 0 || configuration is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
HashSet<Guid> allowedContentTypeKeys = AllowedContentTypeKeysParser.Parse(configuration.AllowedContentTypeIds);
|
||||
|
||||
// No filter configured — all element types are allowed.
|
||||
if (allowedContentTypeKeys.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
Guid[] elementIds = value
|
||||
.Where(v => Guid.TryParse(v, out _))
|
||||
.Select(Guid.Parse)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
|
||||
IElement[] elements = _elementService.GetByIds(elementIds).ToArray();
|
||||
scope.Complete();
|
||||
|
||||
// Compare against the distinct requested keys (not the raw value count, which may include
|
||||
// duplicates or non-GUID entries) so existing elements aren't incorrectly reported as missing.
|
||||
if (elements.Length != elementIds.Length)
|
||||
{
|
||||
return [
|
||||
new ValidationResult(
|
||||
_localizedTextService.Localize("validation", "missingContent"),
|
||||
["value"])
|
||||
];
|
||||
}
|
||||
|
||||
foreach (IElement element in elements)
|
||||
{
|
||||
if (allowedContentTypeKeys.Contains(element.ContentType.Key) is false)
|
||||
{
|
||||
return
|
||||
[
|
||||
new ValidationResult(
|
||||
_localizedTextService.Localize("validation", "invalidObjectType"),
|
||||
["value"])
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ internal sealed class EntityDataPickerPropertyEditor : DataEditor
|
||||
/// <summary>
|
||||
/// Validates the min/max configuration for the entity data picker property editor.
|
||||
/// </summary>
|
||||
internal sealed class MinMaxValidator : ITypedJsonValidator<EntityDataPickerDto, EntityDataPickerConfiguration>
|
||||
internal sealed class MinMaxValidator : ITypedValidator<EntityDataPickerDto, EntityDataPickerConfiguration>
|
||||
{
|
||||
private readonly ILocalizedTextService _localizedTextService;
|
||||
|
||||
|
||||
@@ -9,17 +9,13 @@ namespace Umbraco.Cms.Core.PropertyEditors.Validation;
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The type of the value consumed by the validator.</typeparam>
|
||||
/// <typeparam name="TConfiguration">The type of the configuration consumed by validator.</typeparam>
|
||||
public interface ITypedJsonValidator<TValue, TConfiguration>
|
||||
[Obsolete("Use ITypedValidator instead; the validator contract is not JSON-specific. Scheduled for removal in Umbraco 20.")]
|
||||
public interface ITypedJsonValidator<TValue, TConfiguration> : ITypedValidator<TValue, TConfiguration>
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the specified value against the configuration.
|
||||
/// </summary>
|
||||
/// <param name="value">The deserialized value to validate.</param>
|
||||
/// <param name="configuration">The data type configuration.</param>
|
||||
/// <param name="valueType">The value type.</param>
|
||||
/// <param name="validationContext">The property validation context.</param>
|
||||
/// <returns>A collection of validation results.</returns>
|
||||
public abstract IEnumerable<ValidationResult> Validate(
|
||||
// Re-declared (rather than purely inherited from ITypedValidator) so the ITypedJsonValidator.Validate member
|
||||
// remains present for binary compatibility with consumers compiled against this interface in v15-v17.
|
||||
// TODO (V20): remove together with this interface.
|
||||
new IEnumerable<ValidationResult> Validate(
|
||||
TValue? value,
|
||||
TConfiguration? configuration,
|
||||
string? valueType,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Umbraco.Cms.Core.Models.Validation;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// A validator that operates on an already-typed value and configuration.
|
||||
/// <remarks>
|
||||
/// Used together with an <see cref="IValueValidator"/> runner that materializes the typed value: see
|
||||
/// <see cref="TypedValidatorRunner{TValue,TConfiguration}"/> for value editors whose value is already typed, and
|
||||
/// <see cref="TypedJsonValidatorRunner{TValue,TConfiguration}"/> for JSON based value editors, where the value is deserialized once before validation.
|
||||
/// </remarks>
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The type of the value consumed by the validator.</typeparam>
|
||||
/// <typeparam name="TConfiguration">The type of the configuration consumed by validator.</typeparam>
|
||||
public interface ITypedValidator<TValue, TConfiguration>
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the specified value against the configuration.
|
||||
/// </summary>
|
||||
/// <param name="value">The typed value to validate.</param>
|
||||
/// <param name="configuration">The data type configuration.</param>
|
||||
/// <param name="valueType">The value type.</param>
|
||||
/// <param name="validationContext">The property validation context.</param>
|
||||
/// <returns>A collection of validation results.</returns>
|
||||
IEnumerable<ValidationResult> Validate(
|
||||
TValue? value,
|
||||
TConfiguration? configuration,
|
||||
string? valueType,
|
||||
PropertyValidationContext validationContext);
|
||||
}
|
||||
@@ -6,26 +6,47 @@ namespace Umbraco.Cms.Core.PropertyEditors.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// <para>
|
||||
/// An aggregate validator for JSON based value editors, to avoid doing multiple deserialization.
|
||||
/// An aggregate <see cref="IValueValidator"/> for JSON based value editors. Deserializes the editor value into
|
||||
/// <typeparamref name="TValue"/> once (avoiding repeated deserialization), casts the configuration once, and passes both
|
||||
/// to each <see cref="ITypedValidator{TValue,TConfiguration}"/>, aggregating the results.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Will deserialize once, and cast the configuration once, and pass those values to each <see cref="ITypedJsonValidator{TValue,TConfiguration}"/>, aggregating the results.
|
||||
/// Use this runner when the editor value reaching validation is raw JSON that must be deserialized before validation —
|
||||
/// typically an array of complex objects, such as a media picker storing crop data, which the backoffice JSON object
|
||||
/// converter leaves as un-typed JSON nodes rather than a typed CLR value.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When the editor value is already the typed CLR value (so only a cast is needed, with no deserialization) use
|
||||
/// <see cref="TypedValidatorRunner{TValue,TConfiguration}"/> instead. That is the only difference between the two runners:
|
||||
/// this one deserializes, the other casts.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The type of the expected value.</typeparam>
|
||||
/// <typeparam name="TConfiguration">The type of the expected configuration</typeparam>
|
||||
/// <seealso cref="TypedValidatorRunner{TValue,TConfiguration}"/>
|
||||
public class TypedJsonValidatorRunner<TValue, TConfiguration> : IValueValidator
|
||||
where TValue : class
|
||||
{
|
||||
private readonly IJsonSerializer _jsonSerializer;
|
||||
private readonly ITypedJsonValidator<TValue, TConfiguration>[] _validators;
|
||||
private readonly ITypedValidator<TValue, TConfiguration>[] _validators;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TypedJsonValidatorRunner{TValue, TConfiguration}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="jsonSerializer">The JSON serializer.</param>
|
||||
/// <param name="validators">The collection of validators to run.</param>
|
||||
[Obsolete("Use the constructor accepting ITypedValidator instances. Scheduled for removal in Umbraco 20.")]
|
||||
public TypedJsonValidatorRunner(IJsonSerializer jsonSerializer, params ITypedJsonValidator<TValue, TConfiguration>[] validators)
|
||||
: this(jsonSerializer, (ITypedValidator<TValue, TConfiguration>[])validators)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TypedJsonValidatorRunner{TValue, TConfiguration}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="jsonSerializer">The JSON serializer.</param>
|
||||
/// <param name="validators">The collection of validators to run.</param>
|
||||
public TypedJsonValidatorRunner(IJsonSerializer jsonSerializer, params ITypedValidator<TValue, TConfiguration>[] validators)
|
||||
{
|
||||
_jsonSerializer = jsonSerializer;
|
||||
_validators = validators;
|
||||
@@ -51,7 +72,7 @@ public class TypedJsonValidatorRunner<TValue, TConfiguration> : IValueValidator
|
||||
return validationResults;
|
||||
}
|
||||
|
||||
foreach (ITypedJsonValidator<TValue, TConfiguration> validator in _validators)
|
||||
foreach (ITypedValidator<TValue, TConfiguration> validator in _validators)
|
||||
{
|
||||
validationResults.AddRange(validator.Validate(deserializedValue, configuration, valueType, validationContext));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Umbraco.Cms.Core.Models.Validation;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// <para>
|
||||
/// An aggregate <see cref="IValueValidator"/> that casts the editor value once and passes it, along with the cast
|
||||
/// configuration, to each <see cref="ITypedValidator{TValue,TConfiguration}"/>, aggregating the results.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Use this runner when the editor value reaching validation is already the typed CLR value (<typeparamref name="TValue"/>),
|
||||
/// so a cast is all that is needed — for example a content picker (value is a <see cref="string"/>) or an element picker
|
||||
/// (value is a <c>List<string></c>, since the backoffice JSON object converter resolves an array of scalars into a typed list).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When the editor value is instead raw JSON that must be deserialized into <typeparamref name="TValue"/> before validation —
|
||||
/// typically an array of complex objects, such as a media picker storing crop data — use <see cref="TypedJsonValidatorRunner{TValue,TConfiguration}"/>
|
||||
/// instead. That is the only difference between the two runners: this one casts, the other deserializes.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The type of the expected value.</typeparam>
|
||||
/// <typeparam name="TConfiguration">The type of the expected configuration.</typeparam>
|
||||
/// <seealso cref="TypedJsonValidatorRunner{TValue,TConfiguration}"/>
|
||||
public class TypedValidatorRunner<TValue, TConfiguration> : IValueValidator
|
||||
where TValue : class
|
||||
{
|
||||
private readonly ITypedValidator<TValue, TConfiguration>[] _validators;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TypedValidatorRunner{TValue, TConfiguration}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="validators">The collection of validators to run.</param>
|
||||
public TypedValidatorRunner(params ITypedValidator<TValue, TConfiguration>[] validators)
|
||||
=> _validators = validators;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<ValidationResult> Validate(
|
||||
object? value,
|
||||
string? valueType,
|
||||
object? dataTypeConfiguration,
|
||||
PropertyValidationContext validationContext)
|
||||
{
|
||||
if (dataTypeConfiguration is not TConfiguration configuration)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (value is not null and not TValue)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var typedValue = value as TValue;
|
||||
|
||||
return _validators
|
||||
.SelectMany(v => v.Validate(typedValue, configuration, valueType, validationContext))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -103,11 +103,19 @@ internal sealed class ContentEditingService
|
||||
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(
|
||||
ContentCreateModel createModel,
|
||||
Guid userKey)
|
||||
=> await ValidateCulturesAndPropertiesAsync(
|
||||
{
|
||||
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
|
||||
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
|
||||
{
|
||||
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
|
||||
}
|
||||
|
||||
return await ValidateCulturesAndPropertiesAsync(
|
||||
createModel,
|
||||
createModel.ContentTypeKey,
|
||||
createModel.Variants.Select(variant => variant.Culture),
|
||||
userKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Attempt<ContentCreateResult, ContentEditingOperationStatus>> CreateAsync(ContentCreateModel createModel, Guid userKey)
|
||||
@@ -211,6 +219,15 @@ internal sealed class ContentEditingService
|
||||
Guid userKey)
|
||||
=> await HandleSortAsync(parentKey, sortingModels, userKey);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ContentEditingOperationStatus> SortByFieldAsync(
|
||||
Guid? parentKey,
|
||||
ContentSortField field,
|
||||
Direction direction,
|
||||
string? culture,
|
||||
Guid userKey)
|
||||
=> await HandleSortByFieldAsync(parentKey, field, direction, culture, userKey);
|
||||
|
||||
private async Task<ContentEditingOperationStatus> UpdateTemplateAsync(IContent content, Guid? templateKey)
|
||||
{
|
||||
if (templateKey == null)
|
||||
@@ -258,8 +275,8 @@ internal sealed class ContentEditingService
|
||||
protected override OperationResult? Delete(IContent content, int userId) => ContentService.Delete(content, userId);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IEnumerable<IContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
|
||||
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: null);
|
||||
protected override IEnumerable<IContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total)
|
||||
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: ordering);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContentEditingOperationStatus Sort(IEnumerable<IContent> items, int userId)
|
||||
@@ -268,6 +285,13 @@ internal sealed class ContentEditingService
|
||||
return OperationResultToOperationStatus(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId)
|
||||
{
|
||||
OperationResult result = ContentService.SortChildren(parentId, orderedChildIds, userId);
|
||||
return OperationResultToOperationStatus(result);
|
||||
}
|
||||
|
||||
private async Task<ContentEditingOperationStatus> Save(IContent content, Guid userKey)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -500,6 +500,10 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
|
||||
{
|
||||
// these are the only result states currently expected from the invoked IContentService operations
|
||||
OperationResultType.Success => ContentEditingOperationStatus.Success,
|
||||
|
||||
// a no-op (e.g. sorting children when nothing needs reordering) is a successful outcome, not an error
|
||||
OperationResultType.NoOperation => ContentEditingOperationStatus.Success,
|
||||
|
||||
OperationResultType.FailedCancelledByEvent => ContentEditingOperationStatus.CancelledByNotification,
|
||||
OperationResultType.FailedCannot => ContentEditingOperationStatus.CannotDeleteWhenReferenced,
|
||||
|
||||
@@ -661,6 +665,25 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
|
||||
return filteredContentTypes.Any();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that content of the requested type is allowed to be created under the requested parent, applying the
|
||||
/// same "allowed at root", "allowed as child" and content type filter rules that are enforced when the content is
|
||||
/// actually created. This allows the validation endpoints to be consistent with creation.
|
||||
/// </summary>
|
||||
/// <param name="createModel">The content creation model.</param>
|
||||
/// <returns>The operation status; <see cref="ContentEditingOperationStatus.Success"/> when creation is allowed.</returns>
|
||||
protected async Task<ContentEditingOperationStatus> ValidateCreationAllowedAsync(ContentCreationModelBase createModel)
|
||||
{
|
||||
TContentType? contentType = ContentTypeService.Get(createModel.ContentTypeKey);
|
||||
if (contentType is null)
|
||||
{
|
||||
return ContentEditingOperationStatus.ContentTypeNotFound;
|
||||
}
|
||||
|
||||
(int? _, ContentEditingOperationStatus operationStatus) = await TryGetAndValidateParentIdAsync(createModel.ParentKey, contentType);
|
||||
return operationStatus;
|
||||
}
|
||||
|
||||
private void UpdateNames(ContentEditingModelBase contentEditingModelBase, TContent content, TContentType contentType)
|
||||
{
|
||||
if (contentType.VariesByCulture())
|
||||
|
||||
@@ -90,9 +90,10 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
|
||||
/// <param name="parentId">The parent identifier.</param>
|
||||
/// <param name="pageIndex">The zero-based page index.</param>
|
||||
/// <param name="pageSize">The page size.</param>
|
||||
/// <param name="ordering">The ordering to apply, or <c>null</c> to use the default (sort order).</param>
|
||||
/// <param name="total">The total number of children.</param>
|
||||
/// <returns>The paged children.</returns>
|
||||
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total);
|
||||
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total);
|
||||
|
||||
/// <summary>
|
||||
/// Handles the sorting operation asynchronously.
|
||||
@@ -115,16 +116,7 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
|
||||
return ContentEditingOperationStatus.NotFound;
|
||||
}
|
||||
|
||||
const int pageSize = 500;
|
||||
var pageNumber = 0;
|
||||
IEnumerable<TContent> page = GetPagedChildren(contentId.Value, pageNumber++, pageSize, out var total);
|
||||
var children = new List<TContent>((int)total);
|
||||
children.AddRange(page);
|
||||
while (pageNumber * pageSize < total)
|
||||
{
|
||||
page = GetPagedChildren(contentId.Value, pageNumber++, pageSize, out _);
|
||||
children.AddRange(page);
|
||||
}
|
||||
List<TContent> children = LoadAllChildren(contentId.Value, ordering: null);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -142,4 +134,102 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
|
||||
return ContentEditingOperationStatus.SortingInvalid;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles sorting a parent's children by a system field asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="parentKey">The optional parent key.</param>
|
||||
/// <param name="field">The system field to sort the children by.</param>
|
||||
/// <param name="direction">The direction to sort in.</param>
|
||||
/// <param name="culture">The culture whose variant name to sort by, or <c>null</c> to sort by the invariant name. Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a child that does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.</param>
|
||||
/// <param name="userKey">The user key performing the operation.</param>
|
||||
/// <returns>The operation status.</returns>
|
||||
protected async Task<ContentEditingOperationStatus> HandleSortByFieldAsync(
|
||||
Guid? parentKey,
|
||||
ContentSortField field,
|
||||
Direction direction,
|
||||
string? culture,
|
||||
Guid userKey)
|
||||
{
|
||||
var contentId = parentKey.HasValue
|
||||
? ContentService.GetById(parentKey.Value)?.Id
|
||||
: Constants.System.Root;
|
||||
|
||||
if (contentId.HasValue is false)
|
||||
{
|
||||
return ContentEditingOperationStatus.NotFound;
|
||||
}
|
||||
|
||||
Ordering ordering = BuildOrdering(field, direction, culture);
|
||||
|
||||
// The database does the ordering (matching the list view and the order shown in the sort UI).
|
||||
if (ContentSettings.SortChildrenByFieldFiresNotifications)
|
||||
{
|
||||
// Opt-in path: load the children and persist via the standard sort, firing per-item
|
||||
// save/sort notifications (and therefore webhooks), at the cost of loading every child.
|
||||
List<TContent> orderedChildren = LoadAllChildren(contentId.Value, ordering);
|
||||
if (orderedChildren.Count == 0)
|
||||
{
|
||||
return ContentEditingOperationStatus.Success;
|
||||
}
|
||||
|
||||
return Sort(orderedChildren, await GetUserIdAsync(userKey));
|
||||
}
|
||||
|
||||
// Default path: persist the resulting order with a single set-based update and a branch cache
|
||||
// refresh, without loading every child or firing per-item notifications.
|
||||
List<int> orderedChildIds = LoadOrderedChildIds(contentId.Value, ordering);
|
||||
if (orderedChildIds.Count == 0)
|
||||
{
|
||||
// Nothing to sort - the order is trivially correct.
|
||||
return ContentEditingOperationStatus.Success;
|
||||
}
|
||||
|
||||
return SortChildrenInBulk(contentId.Value, orderedChildIds, await GetUserIdAsync(userKey));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists the supplied (already ordered) child identifiers as the new sort order, without loading
|
||||
/// the children or firing per-item notifications.
|
||||
/// </summary>
|
||||
/// <param name="parentId">The parent identifier, or the root identifier for root-level sorting.</param>
|
||||
/// <param name="orderedChildIds">The child identifiers in their desired order.</param>
|
||||
/// <param name="userId">The user performing the operation.</param>
|
||||
/// <returns>The operation status.</returns>
|
||||
protected abstract ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId);
|
||||
|
||||
private List<int> LoadOrderedChildIds(int contentId, Ordering ordering)
|
||||
=> LoadAllChildren(contentId, ordering, child => child.Id);
|
||||
|
||||
private List<TContent> LoadAllChildren(int contentId, Ordering? ordering)
|
||||
=> LoadAllChildren(contentId, ordering, child => child);
|
||||
|
||||
// Pages through all children, projecting each page with the selector so callers that only need a
|
||||
// lightweight value (e.g. the id) don't retain every loaded child.
|
||||
private List<TResult> LoadAllChildren<TResult>(int contentId, Ordering? ordering, Func<TContent, TResult> selector)
|
||||
{
|
||||
const int pageSize = 500;
|
||||
var pageNumber = 0;
|
||||
IEnumerable<TContent> page = GetPagedChildren(contentId, pageNumber++, pageSize, ordering, out var total);
|
||||
var results = new List<TResult>((int)total);
|
||||
results.AddRange(page.Select(selector));
|
||||
while (pageNumber * pageSize < total)
|
||||
{
|
||||
page = GetPagedChildren(contentId, pageNumber++, pageSize, ordering, out _);
|
||||
results.AddRange(page.Select(selector));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static Ordering BuildOrdering(ContentSortField field, Direction direction, string? culture)
|
||||
=> field switch
|
||||
{
|
||||
// Name is variant - the culture selects the variant name to order by (invariant content and media
|
||||
// ignore it). Create and update dates are node-level, so the culture does not apply.
|
||||
ContentSortField.Name => Ordering.By("name", direction, culture),
|
||||
ContentSortField.CreateDate => Ordering.By("createDate", direction),
|
||||
ContentSortField.UpdateDate => Ordering.By("updateDate", direction),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort field."),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1652,7 +1652,13 @@ public class ContentService : PublishableContentServiceBase<IContent>, IContentS
|
||||
{
|
||||
scope.WriteLock(Constants.Locks.ContentTree);
|
||||
|
||||
OperationResult ret = Sort(scope, itemsA, userId, evtMsgs);
|
||||
// Reload within the lock so sorting operates on fully-loaded entities. Callers may pass
|
||||
// partially-loaded content (e.g. loaded with loadTemplates: false or without property data),
|
||||
// and saving those directly would wipe the template and property data (#23120).
|
||||
// GetByIds returns items in the requested order, preserving the caller's ordering that drives the sort.
|
||||
IContent[] reloaded = GetByIds(itemsA.Select(x => x.Id).ToArray()).ToArray();
|
||||
|
||||
OperationResult ret = Sort(scope, reloaded, userId, evtMsgs);
|
||||
scope.Complete();
|
||||
return ret;
|
||||
}
|
||||
@@ -1690,6 +1696,43 @@ public class ContentService : PublishableContentServiceBase<IContent>, IContentS
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
|
||||
{
|
||||
EventMessages evtMsgs = EventMessagesFactory.Get();
|
||||
if (orderedChildIds.Count == 0)
|
||||
{
|
||||
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
|
||||
}
|
||||
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
scope.WriteLock(Constants.Locks.ContentTree);
|
||||
|
||||
_documentRepository.UpdateSortOrder(orderedChildIds);
|
||||
|
||||
// Sort order lives in umbracoNode; neither the published cache nor the content repository cache keeps
|
||||
// a separate serialized copy of it, so refreshing the affected branch (which invalidates both and has
|
||||
// them reload from umbracoNode) is enough to pick up the new order without re-saving each child.
|
||||
if (parentId == Constants.System.Root)
|
||||
{
|
||||
IContent[] roots = GetByIds(orderedChildIds).ToArray();
|
||||
scope.Notifications.Publish(new ContentTreeChangeNotification(roots, TreeChangeTypes.RefreshNode, evtMsgs));
|
||||
}
|
||||
else
|
||||
{
|
||||
IContent? parent = GetById(parentId);
|
||||
if (parent is not null)
|
||||
{
|
||||
scope.Notifications.Publish(new ContentTreeChangeNotification(parent, TreeChangeTypes.RefreshBranch, evtMsgs));
|
||||
}
|
||||
}
|
||||
|
||||
Audit(AuditType.Sort, userId, parentId);
|
||||
|
||||
scope.Complete();
|
||||
return OperationResult.Succeed(evtMsgs);
|
||||
}
|
||||
|
||||
private OperationResult Sort(ICoreScope scope, IContent[] itemsA, int userId, EventMessages eventMessages)
|
||||
{
|
||||
var sortingNotification = new ContentSortingNotification(itemsA, eventMessages);
|
||||
|
||||
@@ -95,6 +95,18 @@ public interface IContentEditingService
|
||||
/// <returns>The operation status indicating success or failure.</returns>
|
||||
Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey);
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the children of a parent by a system field.
|
||||
/// </summary>
|
||||
/// <param name="parentKey">The unique identifier of the parent, or <c>null</c> for root-level sorting.</param>
|
||||
/// <param name="field">The system field to sort the children by.</param>
|
||||
/// <param name="direction">The direction to sort in.</param>
|
||||
/// <param name="culture">The culture whose variant name to sort by, or <c>null</c> to sort by the invariant name. Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a child that does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.</param>
|
||||
/// <param name="userKey">The unique identifier of the user performing the action.</param>
|
||||
/// <returns>The operation status indicating success or failure.</returns>
|
||||
Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, string? culture, Guid userKey)
|
||||
=> throw new NotImplementedException(); // TODO (V19): Remove default implementation.
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a content item whether it is in the recycle bin or not.
|
||||
/// </summary>
|
||||
|
||||
@@ -418,6 +418,22 @@ public interface IContentService : IPublishableContentService<IContent>
|
||||
/// <returns>The operation result.</returns>
|
||||
OperationResult Sort(IEnumerable<int>? ids, int userId = Constants.Security.SuperUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the children of a parent by persisting the supplied (already ordered) child identifiers
|
||||
/// as the new sort order, in a single set-based update.
|
||||
/// </summary>
|
||||
/// <param name="parentId">The identifier of the parent, or <see cref="Constants.System.Root"/> for the root.</param>
|
||||
/// <param name="orderedChildIds">The child document identifiers, in the desired order.</param>
|
||||
/// <param name="userId">The identifier of the user performing the action.</param>
|
||||
/// <returns>The operation result.</returns>
|
||||
/// <remarks>
|
||||
/// Unlike <see cref="Sort(IEnumerable{int}?, int)" />, this does not load the children or fire per-item
|
||||
/// save/sort notifications; it persists the order directly and refreshes the affected cache branch.
|
||||
/// </remarks>
|
||||
// TODO (V19): Remove the default implementation.
|
||||
OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
#endregion
|
||||
|
||||
#region Publish Document
|
||||
|
||||
@@ -118,6 +118,18 @@ public interface IMediaEditingService
|
||||
/// </returns>
|
||||
Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey);
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the children of a parent by a system field.
|
||||
/// </summary>
|
||||
/// <param name="parentKey">The unique identifier of the parent, or <c>null</c> for root-level sorting.</param>
|
||||
/// <param name="field">The system field to sort the children by.</param>
|
||||
/// <param name="direction">The direction to sort in.</param>
|
||||
/// <param name="userKey">The unique identifier of the user performing the operation.</param>
|
||||
/// <returns>The operation status indicating the operation outcome.</returns>
|
||||
/// <remarks>Media items never vary by culture, so children are always ordered by the invariant name.</remarks>
|
||||
Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, Guid userKey)
|
||||
=> throw new NotImplementedException(); // TODO (V19): Remove default implementation.
|
||||
|
||||
/// <summary>
|
||||
/// Permanently deletes a media item from the recycle bin.
|
||||
/// </summary>
|
||||
|
||||
@@ -338,6 +338,22 @@ public interface IMediaService : IContentServiceBase<IMedia>
|
||||
/// <returns>True if sorting succeeded, otherwise False</returns>
|
||||
bool Sort(IEnumerable<IMedia> items, int userId = Constants.Security.SuperUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the children of a parent by persisting the supplied (already ordered) child identifiers
|
||||
/// as the new sort order, in a single set-based update.
|
||||
/// </summary>
|
||||
/// <param name="parentId">The identifier of the parent, or <see cref="Constants.System.Root"/> for the root.</param>
|
||||
/// <param name="orderedChildIds">The child media identifiers, in the desired order.</param>
|
||||
/// <param name="userId">The identifier of the user performing the action.</param>
|
||||
/// <returns>The operation result.</returns>
|
||||
/// <remarks>
|
||||
/// Unlike <see cref="Sort(IEnumerable{IMedia}, int)" />, this does not load the children or fire per-item
|
||||
/// save/sort notifications; it persists the order directly and refreshes the affected cache branch.
|
||||
/// </remarks>
|
||||
// TODO (V19): Remove the default implementation.
|
||||
OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="IMedia" /> object using the alias of the <see cref="IMediaType" />
|
||||
/// that this Media should based on.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Threading.Tasks;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
namespace Umbraco.Cms.Core.Services;
|
||||
|
||||
@@ -15,26 +15,105 @@ public interface IRedirectUrlService : IService
|
||||
/// <param name="contentKey">The content unique key.</param>
|
||||
/// <param name="culture">The culture.</param>
|
||||
/// <remarks>Is a proper Umbraco route eg /path/to/foo or 123/path/tofoo.</remarks>
|
||||
[Obsolete("Use RegisterWithStatus to support cancellation via notifications. Scheduled for removal in Umbraco 20.")]
|
||||
void Register(string url, Guid contentKey, string? culture = null);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a redirect URL.
|
||||
/// </summary>
|
||||
/// <param name="oldUrl">The previous Umbraco URL route the redirect is being created from.</param>
|
||||
/// <param name="contentKey">The content unique key.</param>
|
||||
/// <param name="culture">The culture.</param>
|
||||
/// <returns>
|
||||
/// An <see cref="Attempt{TResult,TStatus}" /> containing the registered redirect URL on success, or
|
||||
/// <see cref="RedirectUrlOperationStatus.CancelledByNotification" /> if a notification handler
|
||||
/// canceled the operation.
|
||||
/// </returns>
|
||||
// TODO (V20): Remove the default implementation, and rename this back to "Register" when the obsolete Register overload is removed.
|
||||
Attempt<IRedirectUrl?, RedirectUrlOperationStatus> RegisterWithStatus(string oldUrl, Guid contentKey, string? culture = null)
|
||||
{
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
Register(oldUrl, contentKey, culture);
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
return Attempt.SucceedWithStatus<IRedirectUrl?, RedirectUrlOperationStatus>(RedirectUrlOperationStatus.Success, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all redirect URLs for a given content.
|
||||
/// </summary>
|
||||
/// <param name="contentKey">The content unique key.</param>
|
||||
[Obsolete("Use DeleteContentRedirectUrlsWithStatus to support cancellation via notifications. Scheduled for removal in Umbraco 20.")]
|
||||
void DeleteContentRedirectUrls(Guid contentKey);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all redirect URLs for a given content, returning the operation status.
|
||||
/// </summary>
|
||||
/// <param name="contentKey">The content unique key.</param>
|
||||
/// <returns>
|
||||
/// <see cref="RedirectUrlOperationStatus.Success" /> on success, or
|
||||
/// <see cref="RedirectUrlOperationStatus.CancelledByNotification" /> if a notification handler
|
||||
/// canceled the operation.
|
||||
/// </returns>
|
||||
// TODO (V20): Remove the default implementation when the obsolete DeleteContentRedirectUrls overload is removed.
|
||||
RedirectUrlOperationStatus DeleteContentRedirectUrlsWithStatus(Guid contentKey)
|
||||
{
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
DeleteContentRedirectUrls(contentKey);
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
return RedirectUrlOperationStatus.Success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a redirect URL.
|
||||
/// </summary>
|
||||
/// <param name="redirectUrl">The redirect URL to delete.</param>
|
||||
[Obsolete("Use DeleteWithStatus(IRedirectUrl) to support cancellation via notifications. Scheduled for removal in Umbraco 20.")]
|
||||
void Delete(IRedirectUrl redirectUrl);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a redirect URL, returning the operation status.
|
||||
/// </summary>
|
||||
/// <param name="redirectUrl">The redirect URL to delete.</param>
|
||||
/// <returns>
|
||||
/// <see cref="RedirectUrlOperationStatus.Success" /> on success, or
|
||||
/// <see cref="RedirectUrlOperationStatus.CancelledByNotification" /> if a notification handler
|
||||
/// canceled the operation.
|
||||
/// </returns>
|
||||
// TODO (V20): Remove the default implementation when the obsolete Delete(IRedirectUrl) overload is removed.
|
||||
RedirectUrlOperationStatus DeleteWithStatus(IRedirectUrl redirectUrl)
|
||||
{
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
Delete(redirectUrl);
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
return RedirectUrlOperationStatus.Success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a redirect URL.
|
||||
/// </summary>
|
||||
/// <param name="id">The redirect URL identifier.</param>
|
||||
[Obsolete("Use DeleteWithStatus(Guid) to support cancellation via notifications. Scheduled for removal in Umbraco 20.")]
|
||||
void Delete(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a redirect URL by its identifier, returning the operation status.
|
||||
/// </summary>
|
||||
/// <param name="id">The redirect URL identifier.</param>
|
||||
/// <returns>
|
||||
/// <see cref="RedirectUrlOperationStatus.Success" /> on success,
|
||||
/// <see cref="RedirectUrlOperationStatus.NotFound" /> if no redirect URL with the given identifier exists, or
|
||||
/// <see cref="RedirectUrlOperationStatus.CancelledByNotification" /> if a notification handler
|
||||
/// canceled the operation.
|
||||
/// </returns>
|
||||
// TODO (V20): Remove the default implementation when the obsolete Delete(Guid) overload is removed.
|
||||
RedirectUrlOperationStatus DeleteWithStatus(Guid id)
|
||||
{
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
Delete(id);
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
return RedirectUrlOperationStatus.Success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all redirect URLs.
|
||||
/// </summary>
|
||||
|
||||
@@ -54,6 +54,18 @@ public interface ITagService : IService
|
||||
/// </summary>
|
||||
IEnumerable<TaggedEntity> GetTaggedMembersByTag(string tag, string? group = null, string? culture = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all elements tagged with any tag in the specified group.
|
||||
/// </summary>
|
||||
// TODO (V19): Remove the default implementation from this interface.
|
||||
IEnumerable<TaggedEntity> GetTaggedElementsByTagGroup(string group, string? culture = null) => [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all elements tagged with the specified tag.
|
||||
/// </summary>
|
||||
// TODO (V19): Remove the default implementation from this interface.
|
||||
IEnumerable<TaggedEntity> GetTaggedElementsByTag(string tag, string? group = null, string? culture = null) => [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all tags.
|
||||
/// </summary>
|
||||
@@ -100,6 +112,12 @@ public interface ITagService : IService
|
||||
/// </summary>
|
||||
IEnumerable<ITag> GetAllMemberTags(string? group = null, string? culture = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all element tags.
|
||||
/// </summary>
|
||||
// TODO (V19): Remove the default implementation from this interface.
|
||||
IEnumerable<ITag> GetAllElementTags(string? group = null, string? culture = null) => [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all tags attached to an entity via a property.
|
||||
/// </summary>
|
||||
|
||||
@@ -87,7 +87,15 @@ internal sealed class MediaEditingService
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(MediaCreateModel createModel)
|
||||
=> await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
|
||||
{
|
||||
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
|
||||
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
|
||||
{
|
||||
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
|
||||
}
|
||||
|
||||
return await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Attempt<MediaCreateResult, ContentEditingOperationStatus>> CreateAsync(MediaCreateModel createModel, Guid userKey)
|
||||
@@ -169,6 +177,13 @@ internal sealed class MediaEditingService
|
||||
public async Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey)
|
||||
=> await HandleSortAsync(parentKey, sortingModels, userKey);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, Guid userKey)
|
||||
|
||||
// Media never varies by culture, so children are always ordered by the invariant name.
|
||||
=> await HandleSortByFieldAsync(parentKey, field, direction, culture: null, userKey);
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IMedia New(string name, int parentId, IMediaType mediaType)
|
||||
=> new Models.Media(name, parentId, mediaType);
|
||||
@@ -191,8 +206,8 @@ internal sealed class MediaEditingService
|
||||
=> ContentService.Delete(media, userId).Result;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
|
||||
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total);
|
||||
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total)
|
||||
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, filter: null, ordering: ordering);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContentEditingOperationStatus Sort(IEnumerable<IMedia> items, int userId)
|
||||
@@ -203,6 +218,13 @@ internal sealed class MediaEditingService
|
||||
: ContentEditingOperationStatus.CancelledByNotification;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId)
|
||||
{
|
||||
OperationResult result = ContentService.SortChildren(parentId, orderedChildIds, userId);
|
||||
return OperationResultToOperationStatus(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a media item to the repository.
|
||||
/// </summary>
|
||||
|
||||
@@ -1309,6 +1309,15 @@ namespace Umbraco.Cms.Core.Services
|
||||
{
|
||||
scope.WriteLock(Constants.Locks.MediaTree);
|
||||
|
||||
// Reload within the lock so sorting operates on fully-loaded entities. Callers may pass
|
||||
// partially-loaded media (e.g. without property data), and saving those directly would
|
||||
// wipe the property data (#23120). Preserve the caller's ordering, which drives the sort.
|
||||
var reloadedById = GetByIds(itemsA.Select(x => x.Id)).ToDictionary(x => x.Id);
|
||||
itemsA = itemsA
|
||||
.Select(x => reloadedById.TryGetValue(x.Id, out IMedia? media) ? media : null)
|
||||
.WhereNotNull()
|
||||
.ToArray();
|
||||
|
||||
var savingNotification = new MediaSavingNotification(itemsA, messages);
|
||||
if (scope.Notifications.PublishCancelable(savingNotification))
|
||||
{
|
||||
@@ -1347,6 +1356,43 @@ namespace Umbraco.Cms.Core.Services
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
|
||||
{
|
||||
EventMessages evtMsgs = EventMessagesFactory.Get();
|
||||
if (orderedChildIds.Count == 0)
|
||||
{
|
||||
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
|
||||
}
|
||||
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
scope.WriteLock(Constants.Locks.MediaTree);
|
||||
|
||||
_mediaRepository.UpdateSortOrder(orderedChildIds);
|
||||
|
||||
// Sort order lives in umbracoNode; neither the published cache nor the media repository cache keeps
|
||||
// a separate serialized copy of it, so refreshing the affected branch (which invalidates both and has
|
||||
// them reload from umbracoNode) is enough to pick up the new order without re-saving each child.
|
||||
if (parentId == Constants.System.Root)
|
||||
{
|
||||
IMedia[] roots = GetByIds(orderedChildIds).ToArray();
|
||||
scope.Notifications.Publish(new MediaTreeChangeNotification(roots, TreeChangeTypes.RefreshNode, evtMsgs));
|
||||
}
|
||||
else
|
||||
{
|
||||
IMedia? parent = GetById(parentId);
|
||||
if (parent is not null)
|
||||
{
|
||||
scope.Notifications.Publish(new MediaTreeChangeNotification(parent, TreeChangeTypes.RefreshBranch, evtMsgs));
|
||||
}
|
||||
}
|
||||
|
||||
Audit(AuditType.Sort, userId, parentId);
|
||||
|
||||
scope.Complete();
|
||||
return OperationResult.Succeed(evtMsgs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the data integrity of the media tree and optionally fixes detected issues.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the status of a redirect URL operation.
|
||||
/// </summary>
|
||||
public enum RedirectUrlOperationStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// The operation completed successfully.
|
||||
/// </summary>
|
||||
Success,
|
||||
|
||||
/// <summary>
|
||||
/// The operation was cancelled by a notification handler.
|
||||
/// </summary>
|
||||
CancelledByNotification,
|
||||
|
||||
/// <summary>
|
||||
/// The operation failed because the redirect URL could not be found.
|
||||
/// </summary>
|
||||
NotFound,
|
||||
|
||||
/// <summary>
|
||||
/// An unknown error occurred during the operation.
|
||||
/// </summary>
|
||||
Unknown,
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
namespace Umbraco.Cms.Core.Services;
|
||||
|
||||
@@ -25,45 +27,141 @@ internal sealed class RedirectUrlService : RepositoryService, IRedirectUrlServic
|
||||
_redirectUrlRepository = redirectUrlRepository;
|
||||
|
||||
/// <inheritdoc/>
|
||||
[Obsolete("Use RegisterWithStatus instead. Scheduled for removal in Umbraco 20.")]
|
||||
public void Register(string url, Guid contentKey, string? culture = null)
|
||||
=> RegisterWithStatus(url, contentKey, culture);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Attempt<IRedirectUrl?, RedirectUrlOperationStatus> RegisterWithStatus(string oldUrl, Guid contentKey, string? culture = null)
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
IRedirectUrl? redir = _redirectUrlRepository.Get(url, contentKey, culture);
|
||||
IRedirectUrl? redir = _redirectUrlRepository.Get(oldUrl, contentKey, culture);
|
||||
if (redir != null)
|
||||
{
|
||||
redir.CreateDateUtc = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
redir = new RedirectUrl { Key = Guid.NewGuid(), Url = url, ContentKey = contentKey, Culture = culture };
|
||||
redir = new RedirectUrl { Key = Guid.NewGuid(), Url = oldUrl, ContentKey = contentKey, Culture = culture };
|
||||
}
|
||||
|
||||
// Use a detached EventMessages instance so a handler cancelling the save does not surface a
|
||||
// notification in the backoffice. Redirect creation is a silent side-effect of publishing, so a
|
||||
// cancellation is not something the editor triggered or can act on - unlike deletion (an explicit
|
||||
// editor action), where the sibling methods deliberately use EventMessagesFactory.Get() instead.
|
||||
var eventMessages = new EventMessages();
|
||||
var savingNotification = new RedirectUrlSavingNotification(redir, eventMessages);
|
||||
|
||||
if (scope.Notifications.PublishCancelable(savingNotification))
|
||||
{
|
||||
scope.Complete();
|
||||
return Attempt.FailWithStatus<IRedirectUrl?, RedirectUrlOperationStatus>(RedirectUrlOperationStatus.CancelledByNotification, redir);
|
||||
}
|
||||
|
||||
_redirectUrlRepository.Save(redir);
|
||||
|
||||
scope.Notifications.Publish(new RedirectUrlSavedNotification(redir, eventMessages)
|
||||
.WithStateFrom(savingNotification));
|
||||
|
||||
scope.Complete();
|
||||
return Attempt.SucceedWithStatus<IRedirectUrl?, RedirectUrlOperationStatus>(RedirectUrlOperationStatus.Success, redir);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Delete(IRedirectUrl redirectUrl)
|
||||
[Obsolete("Use DeleteWithStatus(IRedirectUrl) instead. Scheduled for removal in Umbraco 20.")]
|
||||
public void Delete(IRedirectUrl redirectUrl) => DeleteWithStatus(redirectUrl);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public RedirectUrlOperationStatus DeleteWithStatus(IRedirectUrl redirectUrl)
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
EventMessages eventMessages = EventMessagesFactory.Get();
|
||||
|
||||
var deletingNotification = new RedirectUrlDeletingNotification(redirectUrl, eventMessages);
|
||||
|
||||
if (scope.Notifications.PublishCancelable(deletingNotification))
|
||||
{
|
||||
scope.Complete();
|
||||
return RedirectUrlOperationStatus.CancelledByNotification;
|
||||
}
|
||||
|
||||
_redirectUrlRepository.Delete(redirectUrl);
|
||||
|
||||
scope.Notifications.Publish(new RedirectUrlDeletedNotification(redirectUrl, eventMessages)
|
||||
.WithStateFrom(deletingNotification));
|
||||
|
||||
scope.Complete();
|
||||
return RedirectUrlOperationStatus.Success;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Delete(Guid id)
|
||||
[Obsolete("Use DeleteWithStatus(Guid) instead. Scheduled for removal in Umbraco 20.")]
|
||||
public void Delete(Guid id) => DeleteWithStatus(id);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public RedirectUrlOperationStatus DeleteWithStatus(Guid id)
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
|
||||
IRedirectUrl? redirectUrl = _redirectUrlRepository.Get(id);
|
||||
|
||||
if (redirectUrl is null)
|
||||
{
|
||||
scope.Complete();
|
||||
return RedirectUrlOperationStatus.NotFound;
|
||||
}
|
||||
|
||||
EventMessages eventMessages = EventMessagesFactory.Get();
|
||||
var deletingNotification = new RedirectUrlDeletingNotification(redirectUrl, eventMessages);
|
||||
|
||||
if (scope.Notifications.PublishCancelable(deletingNotification))
|
||||
{
|
||||
scope.Complete();
|
||||
return RedirectUrlOperationStatus.CancelledByNotification;
|
||||
}
|
||||
|
||||
_redirectUrlRepository.Delete(id);
|
||||
|
||||
scope.Notifications.Publish(new RedirectUrlDeletedNotification(redirectUrl, eventMessages)
|
||||
.WithStateFrom(deletingNotification));
|
||||
|
||||
scope.Complete();
|
||||
return RedirectUrlOperationStatus.Success;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void DeleteContentRedirectUrls(Guid contentKey)
|
||||
[Obsolete("Use DeleteContentRedirectUrlsWithStatus instead. Scheduled for removal in Umbraco 20.")]
|
||||
public void DeleteContentRedirectUrls(Guid contentKey) => DeleteContentRedirectUrlsWithStatus(contentKey);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public RedirectUrlOperationStatus DeleteContentRedirectUrlsWithStatus(Guid contentKey)
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
|
||||
IRedirectUrl[] redirectUrls = _redirectUrlRepository.GetContentUrls(contentKey).ToArray();
|
||||
|
||||
if (redirectUrls.Length == 0)
|
||||
{
|
||||
scope.Complete();
|
||||
return RedirectUrlOperationStatus.Success;
|
||||
}
|
||||
|
||||
EventMessages eventMessages = EventMessagesFactory.Get();
|
||||
var deletingNotification = new RedirectUrlDeletingNotification(redirectUrls, eventMessages);
|
||||
|
||||
if (scope.Notifications.PublishCancelable(deletingNotification))
|
||||
{
|
||||
scope.Complete();
|
||||
return RedirectUrlOperationStatus.CancelledByNotification;
|
||||
}
|
||||
|
||||
_redirectUrlRepository.DeleteContentUrls(contentKey);
|
||||
|
||||
scope.Notifications.Publish(new RedirectUrlDeletedNotification(redirectUrls, eventMessages)
|
||||
.WithStateFrom(deletingNotification));
|
||||
|
||||
scope.Complete();
|
||||
return RedirectUrlOperationStatus.Success;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
@@ -101,6 +101,24 @@ public class TagService : RepositoryService, ITagService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TaggedEntity> GetTaggedElementsByTagGroup(string group, string? culture = null)
|
||||
{
|
||||
using (ScopeProvider.CreateCoreScope(autoComplete: true))
|
||||
{
|
||||
return _tagRepository.GetTaggedEntitiesByTagGroup(TaggableObjectTypes.Element, group, culture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TaggedEntity> GetTaggedElementsByTag(string tag, string? group = null, string? culture = null)
|
||||
{
|
||||
using (ScopeProvider.CreateCoreScope(autoComplete: true))
|
||||
{
|
||||
return _tagRepository.GetTaggedEntitiesByTag(TaggableObjectTypes.Element, tag, group, culture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ITag> GetAllTags(string? group = null, string? culture = null)
|
||||
{
|
||||
@@ -162,6 +180,15 @@ public class TagService : RepositoryService, ITagService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ITag> GetAllElementTags(string? group = null, string? culture = null)
|
||||
{
|
||||
using (ScopeProvider.CreateCoreScope(autoComplete: true))
|
||||
{
|
||||
return _tagRepository.GetTagsForEntityType(TaggableObjectTypes.Element, group, culture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ITag> GetTagsForProperty(int contentId, string propertyTypeAlias, string? group = null, string? culture = null)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
|
||||
/// <summary>
|
||||
/// A background job that will be executed by an available server. With a single server setup this will always be the same.
|
||||
@@ -16,6 +16,19 @@ public interface IDistributedBackgroundJob
|
||||
/// </summary>
|
||||
TimeSpan Period { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the job's runs should be aligned to clock boundaries derived from <see cref="Period" />.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <c>true</c>, the job becomes runnable on the next clock boundary that is a multiple of <see cref="Period" />
|
||||
/// (measured from a fixed <strong>UTC</strong> origin, so boundaries fall on round clock times such as on the minute
|
||||
/// or every N seconds) rather than at <c>LastRun + Period</c>.
|
||||
/// For predictable boundaries <see cref="Period" /> should divide evenly into one hour.
|
||||
/// The scheduler may cache this value when it first evaluates registered jobs; changing it at runtime may require an application restart.
|
||||
/// Defaults to <c>false</c>, preserving the original drift-from-completion behaviour.
|
||||
/// </remarks>
|
||||
bool AlignToClock => false;
|
||||
|
||||
/// <summary>
|
||||
/// Run the job.
|
||||
/// </summary>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user