Compare commits

..
Author SHA1 Message Date
b57867ab70 Global Elements: Reusable Content of Blocks (#22448)
* Slice 1: Reference Model

A block layout item's contentKey can point to either local inline
content or a library element. For shared content, the `isSharedContent`
flag is set to `true`.

* Slice 2: Insert Block from Library

* Slice 3: Transfer to Library

* Slice 4: Disconnect from Library

* Slice 5: Inline Element Editing from Block Context

* [WIP] Slice 6: Publish Awareness

* fix(block): address code review findings

Critical:
- disconnectFromLibrary now sets initial expose for new local content
  and cleans up resolved variant state entry
- Extract #updateExposedState() in entry elements, called from all three
  observers (hasExpose, isLibraryElement, sharedContentVariantState) to
  prevent stale unpublished state on library blocks

Important:
- Guard #fetchLibraryElement against already-resolved elements to prevent
  redundant server requests
- Hoist UmbElementDetailRepository to class field in entry elements to
  avoid accumulating dead controllers

Suggestion:
- Fix umb-localize key attributes to use literal keys instead of
  resolved strings from localize.term()

* Enable reusable elements in block editors, including indexing for search and output rendering

* Update cache levels for property value converters.

* Add keys to block layout items

* fix(block): address review findings for reusable block content

- Use DocumentVariantStateModel.DRAFT enum instead of magic string
  in both block-list and block-grid entry elements
- Strip isSharedContent from layout during clipboard write to prevent
  pasted blocks from incorrectly appearing as library references
- Guard #setInitialBlockExpose in disconnectFromLibrary against missing
  content type structure
- Store all element variants and resolve against active variantId for
  correct multi-culture state display
- Add already-resolved guard to #fetchLibraryElement
- Add JSDoc on isLibraryElement and sharedContentVariantState observables
- Add .trim() to transfer modal name validation

* feat(block): add layout key and migrate identity from contentKey to key

BREAKING: UmbBlockLayoutBaseModel now requires a `key: string` property.
Plugin code that creates layout objects without `key` will get a compile
error.

- Change UmbArrayState identity functions to use `(x) => x.key`
- Add `layout` setter on entry elements (list, grid, single, rte) that
  extracts both layoutKey and contentKey from the layout object
- Deprecate `contentKey` setter on entry elements (use `layout` instead)
- Add `layoutKey` read-only getter for sorter identity
- Add `setLayoutKey()` / `layoutByKey()` / `getLayoutByKey()` methods
- Update `transferToLibrary` and `disconnectFromLibrary` to take layoutKey
- Update delete operations to find by layout key, only remove shared
  content/settings/exposes if no other layout references the same contentKey
- Migrate grid recursive area operations to use key for identity
- Update `unique` observable on entry context to derive from layout key
- Generate new key on property value clone
- Backwards compat: `setLayouts` assigns `key ??= contentKey` for
  persisted data without key
- Strip `isSharedContent` from clipboard layout clone
- Update sorter configs and repeat key functions

* Add tests proving that reusable content can work with RTEs too

* i18n: capitalize Element/Library in disconnect-from-library strings

Per Niels' feedback on PR #22448 — Element and Library are product nouns
and should be capitalized to distinguish from generic uses.

* refactor(block): rename layout/library APIs for consistency

- `setLayoutKey`/`getLayoutKey` → `setKey`/`getKey` on entry context
- `layoutByKey`/`getLayoutByKey` → `byKey`/`getByKey` on entries context
- `layoutKey` property → `key` on block entry elements (list/grid/single/rte)
- `data-layout-key` attribute → `data-key` on `umb-rte-block`
- `insertLibraryElementReference` → `insertLibraryElement` on manager
- `transferToLibrary`/`disconnectFromLibrary` `layoutKey` param → `key`
- `delete(layoutKey)` param → `delete(key)` on entries context
- `allowedLibraryElementTypeKeys` → `libraryAllowedElementTypeKeys` on catalogue modal data

* refactor(block): convert catalogue modal value to discriminated union

`UmbBlockCatalogueModalValue` was a single object with optional `create`,
`clipboard`, and `library` fields, which let invalid combinations type-check.
Convert it to a true discriminated union so consumers must narrow with `'in'`
before accessing the variant payload.

Update all four entries contexts (block-list, block-grid, block-rte, block-single)
to use `value && 'create' in value` style narrowing in their `onSubmit` handlers.

* refactor(block): move library transfer/disconnect handlers to Block Manager

The block entry elements (`umb-block-list-entry`, `umb-block-grid-entry`) each
duplicated the orchestration for transferring a local block's content to the
Element Library and disconnecting a referenced library element back to local
content. The handlers opened modals, scaffolded element data, called the
element repository, and finally mutated manager state — all from the UI element.

Move that logic to the manager as `requestTransferToLibrary(key)` and
`requestDisconnectFromLibrary(key)`. The "request" prefix marks the
user-confirmed flows; the bare `transferToLibrary` / `disconnectFromLibrary`
methods remain as the pure state mutations.

Entry elements now delegate to the manager, which also lets us drop the
per-element `UmbElementDetailRepository` field and the modal-related imports
from the elements.

Confirm modal headlines/labels are now passed as localization keys, letting
the modal handle its own string resolution (per Niels' review feedback).

* refactor(block): deprecation hygiene around contentKey setters

- Stop calling the (deprecated) `setContentKey()` from `set layout` on the
  entry elements. The layout already carries the contentKey, so internal flows
  no longer need the fallback path.
- Add `UmbDeprecation` runtime warnings to all four `set contentKey` element
  setters (list/grid/single/rte) and to `UmbBlockEntryContext.setContentKey`.
  JSDoc `@deprecated` alone is not enough — runtime warnings are required per
  the Web.UI.Client deprecation policy.

* fix(block): preserve isSharedContent through clipboard copy/paste

When copying a block that references a library Element, we were stripping
`isSharedContent` from the cloned layout so that pasting always produced a
local copy. Per Niels' review feedback, the expected behaviour is the inverse:
a copied library-referencing block should paste as a reference. If the user
wants a local copy after paste, they explicitly disconnect from the library.

- Remove the `delete clonedLayout.isSharedContent` in `#copyToClipboard` for
  both block-list and block-grid.
- Branch in `_insertBlockFromPropertyValue` so layouts with `isSharedContent`
  route through the manager's `insertLibraryElement(contentKey, originData)`
  flow rather than expecting matching `contentData` (which the clipboard
  payload deliberately doesn't carry for references).

* refactor(block): centralise library element resolution in the Block Manager

Per Niels' review: the entry context shouldn't be the place where the safety
fetch for library element content lives — there could be other call sites,
and the manager already owns the resolved-elements state.

Add a layouts observer in `UmbBlockManagerContext` that watches `_layouts`
and, for any layout where `isSharedContent` is set, kicks off
`#fetchLibraryElement(contentKey)`. The fetch already dedupes, so this is
safe to call repeatedly.

In return, drop both `_manager.ensureContentResolved(contentKey)` calls from
`UmbBlockEntryContext` (`setContentKey` and `#observeContentData`). Also
derive `#contentKey` from the observed layout so internal flows have access
to it without callers having to push it through the deprecated setter.

* docs(block): add follow-up TODOs from review

- Mark `#fetchLibraryElement` for `@madsrasmussen` to replace with a batching
  manager that bundles multiple element requests into a single round-trip.
  Today's per-key fetch becomes N+1 on pages with many shared blocks.
- Update the catalogue modal TODO to reflect that the catalogue is conceptually
  a Modal/Flow extension point — not a Workspace as the previous comment
  implied. Captures the open question about an extensible "Library tab"
  surface for other content sources.

* fix(block): break circular dep between manager context and modals barrel

`UmbBlockManagerContext` imported `UMB_BLOCK_TRANSFER_TO_LIBRARY_MODAL` from
`../modals/index.js` (the barrel). That barrel transitively pulled in the
catalogue modal element, which sits downstream of the manager — creating:

  context/index → block-manager.context → modals/index
    → modals/block-catalogue/index → block-catalogue-modal.element

Import the token directly from `transfer-to-library-modal.token.ts` instead.

* Elements: Contextualize variant blocks rendering for invariant content (#22790)

* Contextualize variant blocks rendering for invariant content

* Initialize local language variables in a more readable way

* Also filter out whitespace cultures

* Add XML docs.

* feat(block): migrate library transfer/disconnect to blockAction extensions

The `#renderTransferToLibraryAction()` / `#renderDisconnectFromLibraryAction()`
render methods (and their handlers) were commented out when `main` was merged
in, leaving these flows unrendered. Migrate them to the new `blockAction`
extension type (PR #22459) so they:

- Render through `<umb-block-action-list>` like the other common actions.
- Reuse automatically across Block List, Block Grid, Single Block, and RTE
  Block editors — no per-editor code.
- Honour visibility via manifest conditions, not inline state branches.

Additions:
- `UMB_BLOCK_ENTRY_IS_LIBRARY_ELEMENT_CONDITION` — boolean-match condition
  observing the existing `context.isLibraryElement` observable. Used inverted
  by the two new actions.
- `Umb.BlockAction.TransferToLibrary` (weight 250, `icon-link`) — visible when
  `isLibraryElement` is false and the entry is not read-only.
- `Umb.BlockAction.DisconnectFromLibrary` (weight 250, `icon-unlink`) — visible
  when `isLibraryElement` is true and the entry is not read-only. The two
  actions are mutually exclusive so sharing a weight is safe.
- Two thin proxy methods on `UmbBlockEntryContext`
  (`requestTransferToLibrary()` / `requestDisconnectFromLibrary()`) mirroring
  the established `requestDelete()` pattern — actions consume only the entry
  context and call into the manager via these proxies.

Removals:
- The commented-out `#renderTransferToLibraryAction` /
  `#renderDisconnectFromLibraryAction` blocks and their handlers in
  `block-list-entry.element.ts` and `block-grid-entry.element.ts`.

* Renamed `sharedContentVariantStateOf` to `elementStateOf`

* TODO comments and prettify

* Removed `ensureContentResolved`

turns out it was redundant.

* Inlined `transferToLibrary` and `disconnectFromLibrary`

Both were single-use imperative helpers called only by their respective
`request*` counterparts in the same file, with no external callers. The
"request" / "do" split was speculative; folding them in reduces surface
area and matches the recent `ensureContentResolved` cleanup.

* Lifted library-allowed element-type fetch to base entries context

All four block variants (list, grid, rte, single) had the same six-line
block fetching the element-type uniques that overlap with the block
types. Moved into a protected helper `_getLibraryAllowedElementTypeKeys`
on UmbBlockEntriesContext so each variant just calls it.

* Removed `@property` decorator from `layout` setter

The setter had no matching getter, which Lit warns about (and will error
on in a future version) for reactive properties. Since no render template
reads `this.layout` and the setter's effects flow through the entry
context's own observables, the reactive tracking is unused — dropping the
decorator silences the warning without behaviour change.

Consumers using `.layout=${x}` in Lit templates are unaffected; that's
property assignment, not attribute reflection, and doesn't require the
property to be reactive.

* feat(components): adds `umb-entity-frame` component + Storybook stories

Cherry picked from PR https://github.com/umbraco/Umbraco-CMS/pull/22844

* Fixed block delete passing contentKey where layout key is required

`UmbBlockEntriesContext.delete()` was changed earlier on this branch to
take the layout `key` (so that multiple layouts referencing one shared
contentKey can be deleted independently). Two callers still passed
`contentKey`, which made `delete` throw "Cannot delete block, missing
layout for X" the moment a user tried to remove a block:

- `UmbBlockEntryContext.delete()` — fires on user delete from the UI.
- `block-workspace.context.ts` modal-rejected handler — fires when
  cancelling a brand-new block in live-editing mode.

Both now pass the layout key.

* Added `umb-entity-frame` to Block editor entry UI

Adds `--umb-color-reference` and `--umb-color-reference-contrast` CSS variables

* 🧹 Linting

* Block Single: derive `_exposed` from library element variant state

Aligns block-single-entry with block-list-entry and block-grid-entry:
library-element references now compute their unpublished/draft state
from the shared element's variant state instead of the (always-missing)
expose entry. Without this, inserted Library Elements always appeared
as Draft in single-block editors.

Also sets the `is-reference` attribute when the block is a library
reference, which activates the existing `:host([is-reference])` styles.

* Block entries: collapse `_isReferenceAttr` into `_isLibraryElement`

The two fields were always set together to the same value across all
three entry elements. `_isReferenceAttr` existed only because `@state`
doesn't reflect to an HTML attribute. Decorating the existing
`_isLibraryElement` field with `@property({ attribute: 'is-reference',
reflect: true })` covers both jobs — it reflects to the attribute (for
the existing `:host([is-reference])` CSS) and is still read from JS by
`#updateExposedState()`.

* Fix build errors after merges

* Refine block-catalogue-modal Library tab

- Convert _hasLibraryElements from @state() to native private field
  (set once in connectedCallback before first render; no reactivity needed)
- Promote inline .props object to #libraryTreeProps class field
  (stable reference avoids re-setting umb-tree props on every render)
- Remove self-documenting comment from #librarySelectableFilter
- Remove stale TODO comment

* Wire Library tab search in block-catalogue-modal

- Route tree selection through pickerContext.selection (unified path with
  search-result selections; removes direct writes to this.value from tree handlers)
- Observe pickerContext.selection.selection to drive this.value
- Observe pickerContext.search.query to hide tree while a search is active
- Configure selection as single-select (setMultiple(false))
- Pass selectionManager to tree props for visual selection state
- Add Umb.PickerSearchResultItem.Element manifest and element under
  src/packages/elements/picker/ so search results render correctly

* Backoffice: Simplify insertLibraryElement in block-manager.context

Library elements do not need an expose entry — exposure is derived from
the element's own variant state. Remove the redundant fetchLibraryElement
call and expose-setting logic; the layout observer already handles the
fetch automatically when the layout is appended.

* Backoffice: Rename transfer-to-library to transfer-to-element-library

* Sets the Entity Frame color for non-references

* "Transfer to Library" modal updates

Pre-populates the name field.

* Backoffice: Rename disconnect-from-library to disconnect-from-element-library

* Checks published visibility for Block entry items

+ markup tweaks

* Backoffice: Fix block showing as unsupported after Transfer to Element Library

After a transfer the manager assigns a new UUID (created.unique) to the
layout's contentKey. The entry context was not re-observing content for
the new key, leaving it permanently watching the old (now-gone) content.

Two interacting issues:

1. #observeContentData() was never re-called when layout.contentKey
   changed — only when the layout key itself changed or the manager
   first connected.  A new observer on this.contentKey now re-calls it
   on every contentKey change, with this.#contentKey synced first
   (because #observeLayout() assigns it AFTER _layout.setValue() emits,
   so downstream callbacks would otherwise read the stale value).

2. The guard 'if (unsupported !== true)' permanently locked the flag once
   it was set by the transient {content:undefined, isLibrary:false}
   emission during the transfer.  Replaced with #structurallyUnsupported
   — only set by #getContentStructure / #observeBlockType when the block
   type or element type is genuinely absent — so the content observer can
   freely reset the flag for all other transitions including transfer.

* Block Single: CSS selector fix

* Backoffice: Show link icon in block entry tabs for library elements

When a block is transferred to the Element Library (a shared element), add a
<uui-icon name="link"> to the entity-frame tab to make the library/shared
status visually clearer alongside the existing purple colour theme.

Applies to block-list, block-grid, and block-single entry components.
The icon is shown conditionally when _isLibraryElement is true.

* `requestTransferToElementLibrary` removed the `name` parameter

as can be retrieved from the context itself.

* fix(block): make block action href and validation data path reactive

`umb-block-action.element.ts` previously resolved `getHref()` and
`getValidationDataPath()` once in the `api` setter via `.then()`,
freezing the values for the lifetime of the action component. When a
block's `contentKey` changes at runtime (e.g. after disconnecting from
the Element Library), the edit button kept navigating to the stale path.

Add optional `hrefObservable` and `validationDataPathObservable` to
`UmbBlockAction`. When an action provides these observables the element
subscribes to them reactively; otherwise it falls back to the existing
one-shot promise path (non-breaking for third-party actions).

`UmbEditContentBlockAction` now observes `workspaceEditContentPath` and
`contentKey` from the block entry context and pushes updates into states,
resolving the stale-href bug on disconnect.

Resolves the [LK] TODO in block-action.element.ts.

* fix(block): refresh expose observer after disconnect from element library

After "Disconnect from Element Library" the block's layout.contentKey
changes from the shared element's UUID to a fresh local content key. The
expose observer ('observeExpose' in #gotVariantId) was bound to the old
key and never re-bound because #gotVariantId only runs when variantId
changes, not when contentKey changes — leaving _hasExpose false and the
block showing a stale "Draft"/unpublished badge.

Re-running #gotVariantId alongside #observeContentData in the contentKey
observer ensures the expose subscription always targets the current key,
mirroring the existing pattern already applied for the content observer.

* fix(block): correct workspace tabs and submit label for library elements

When a block references a Library Element (isSharedContent: true), opening
the block workspace via the "Edit Settings" action now shows only the
"Settings" tab. The "Content" tab is hidden because the content is owned
by the shared element and is not editable in the local block workspace.

Surfaces `hasContent` on the block workspace context, and gates
the Content workspace view on a new `Umb.Condition.BlockWorkspaceHasContent`
condition — mirroring the existing `Umb.Condition.BlockWorkspaceHasSettings`
pattern. Also removes the dead `TODO_conditions` block from the Content
view manifest.

* refactor(block): rename LibraryElement to SharedContent for naming consistency

Aligns block symbols that describe a block's content being shared/referenced
with the existing 'isSharedContent' layout flag and 'sharedContentVariantState',
retiring the inconsistent 'LibraryElement' naming for that concept.

- Entry state: isLibraryElement -> isSharedContent; #libraryElementWorkspacePath
  -> #sharedContentWorkspacePath; the three entry elements' _isLibraryElement
  -> _isSharedContent.
- Manager: insertLibraryElement -> insertSharedContent; #fetchLibraryElement
  -> #fetchSharedContent; #resolvedLibraryElements(Variants)
  -> #resolvedSharedContent(Variants).
- Condition: UmbBlockEntryIsLibraryElementCondition
  -> UmbBlockEntryHasSharedContentCondition (alias 'Umb.Condition.BlockEntryHasSharedContent').
- Route segment 'library-element' -> 'library'.

Genuine Element Library feature references are intentionally kept: the
transfer/disconnect actions and modals, and the catalogue picker UI
(#hasLibraryElements, #renderLibrary, blockEditor_tabLibrary, the
{ library: { elementKey } } modal value, libraryAllowedElementTypeKeys).

Pure rename, no behaviour change.

* fix(block): address PR review feedback on client-side files

- block-catalogue-modal: add UmbDeselectedEvent import; correctly type
  #onLibraryElementDeselected parameter (was UmbSelectedEvent)
- block-catalogue-modal: fix #librarySelectableFilter to handle
  undefined documentType.unique via nullish coalesce
- block-manager: setLayouts no longer mutates incoming layout objects
  in-place; uses map+spread to ensure backwards-compat key backfill
  without side effects on the caller's array
- block-grid-to-block-copy-translator: clipboard layout key now uses
  gridLayout.key (layout identity) rather than gridLayout.contentKey,
  which would break when the same shared-content element appears in
  multiple layout entries

* Fix low-hanging PR review comments

* Clarify why top-level aggregation works in effect

* Rename IsSharedContent (server-side)

* Rename IsSharedContent (client-side)

to `IsExternalContent`

* Added comments to clarify retries in tests

* Replace "isSharedContent" with "isExternalContent"

* Block: address review feedback — naming, comments, and small refactors

- requestTransferToElementLibrary / requestDisconnectFromElementLibrary → requestTransferToExternalContent / requestDisconnectFromExternalContent (manager + entry context + action callers)
- .addAdditionalPath('library') → 'element'
- #resolvedExternalContent / #resolvedExternalContentVariants → #externalContentValues / #externalContentVariants
- elementStateOf → externalContentStateOf
- hrefObservable / validationDataPathObservable → href / validationDataPath (interface + action impls + default kind element)
- _hasExpose → _localExpose (grid, list, single entry elements)
- BlockWorkspaceHasContentConditionConfig / BlockEntryHasSettingsConditionConfig: type alias → interface
- Remove implementation-specific / AI-ish comments from block-entry, block-manager, action files, block-workspace
- Reuse #elementRepository field in requestTransfer/Disconnect; remove local instantiations
- #fetchExternalContent now accepts an array — one call per layout-state update instead of N
- getHref / getValidationDataPath in edit-content/edit-settings actions now resolve via the observable

* Block: fix CI lint errors — remove unused #context fields and suppress empty-interface rule

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-06-23 15:35:35 +01:00
leekelleher f365493f0b Bump version to 19.0.0-beta1. 2026-06-01 18:08:32 +01:00
532 changed files with 5587 additions and 16060 deletions
-135
View File
@@ -1,135 +0,0 @@
---
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.
+12 -4
View File
@@ -70,6 +70,18 @@ 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
@@ -124,10 +136,6 @@ 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
-99
View File
@@ -1,99 +0,0 @@
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"
@@ -1,7 +0,0 @@
{
"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"
}
-3
View File
@@ -121,6 +121,3 @@ trace.zip
/tests/Umbraco.Tests.Integration/umbraco-package-schema.json
/src/Umbraco.Cms/appsettings-schema.json
.playwright-mcp/
# SonarQube local analysis cache
.sonarqube/
+1
View File
@@ -48,6 +48,7 @@ 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
-2
View File
@@ -558,8 +558,6 @@ 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
-10
View File
@@ -64,14 +64,4 @@
</_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>
+4 -4
View File
@@ -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.8.0" />
<PackageVersion Include="Examine.Core" Version="3.8.0" />
<PackageVersion Include="Examine" Version="3.7.1" />
<PackageVersion Include="Examine.Core" Version="3.7.1" />
<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.7" />
<PackageVersion Include="MessagePack" Version="3.1.4" />
<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>
+98 -32
View File
@@ -825,31 +825,74 @@ 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:
- template: templates/npm-publish.yml
parameters:
artifactName: npm
- 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"
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
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/
- 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
- job: PublishTestHelpersNpm
displayName: Push TestHelpers to pre-release feed (npm)
steps:
- template: templates/npm-publish.yml
parameters:
artifactName: npm-testhelpers
- 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"
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
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/
- 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
- stage: Deploy_NuGet
displayName: NuGet release
@@ -898,30 +941,53 @@ 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:
- template: templates/npm-publish.yml
parameters:
artifactName: npm
registry: https://registry.npmjs.org/
- 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
customEndpoint: "NPM - Umbraco Backoffice"
displayName: Push to npm
npmTag: $(npmDistTag)
- 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
- job: PublishTestHelpers
displayName: Push Test Helpers to NPM
steps:
- template: templates/npm-publish.yml
parameters:
artifactName: npm-testhelpers
registry: https://registry.npmjs.org/
- 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
customEndpoint: "NPM - Umbraco Backoffice"
displayName: Push test helpers to npm
npmTag: $(npmDistTag)
- 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
- stage: Upload_API_Docs
pool:
-28
View File
@@ -1,28 +0,0 @@
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 }}
@@ -1,7 +1,5 @@
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;
@@ -343,12 +341,6 @@ 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.
@@ -1,100 +0,0 @@
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);
}
}
@@ -1,102 +0,0 @@
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);
}
}
@@ -1,98 +0,0 @@
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);
}
}
@@ -1,100 +0,0 @@
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);
}
}
@@ -2,7 +2,6 @@ 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;
@@ -33,13 +32,11 @@ 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)
{
RedirectUrlOperationStatus status = _redirectUrlService.DeleteWithStatus(id);
return Task.FromResult(RedirectUrlOperationStatusResult(status));
_redirectUrlService.Delete(id);
return Task.FromResult<IActionResult>(Ok());
}
}
@@ -1,8 +1,6 @@
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;
@@ -16,31 +14,4 @@ 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()),
});
}
@@ -32,7 +32,6 @@ 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,8 +1,5 @@
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;
@@ -19,7 +16,6 @@ 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.
@@ -29,46 +25,18 @@ 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,
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>>())
{
_dataTypeContainerService = dataTypeContainerService;
_propertyEditorCollection = propertyEditorCollection;
_dataValueEditorFactory = dataValueEditorFactory;
_configurationEditorJsonSerializer = configurationEditorJsonSerializer;
_timeProvider = timeProvider;
}
/// <inheritdoc />
@@ -104,6 +72,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
dataType.Key = requestModel.Id.Value;
}
return Attempt.SucceedWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.Success, dataType);
}
@@ -113,7 +82,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
{
try
{
EntityContainer? parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
var parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
return parent is null
? Attempt.FailWithStatus(DataTypeOperationStatus.ParentNotFound, 0)
@@ -128,7 +97,6 @@ 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))
@@ -136,7 +104,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
return Task.FromResult(Attempt.FailWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.PropertyEditorNotFound, new DataType(new VoidEditor(_dataValueEditorFactory), _configurationEditorJsonSerializer) ));
}
var dataType = (IDataType)current.DeepClone();
IDataType dataType = (IDataType)current.DeepClone();
IDictionary<string, object> configurationData = MapConfigurationData(requestModel, editor);
dataType.Name = requestModel.Name;
@@ -151,26 +119,12 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
private ValueStorageType GetEditorValueStorageType(IDataEditor editor, IDictionary<string, object> configurationData)
{
// 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
var configurationObject = editor.GetConfigurationEditor()
.ToConfigurationObject(configurationData, _configurationEditorJsonSerializer);
if (configurationObject is IConfigureValueType configureValueType)
{
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);
return ValueTypes.ToStorageType(configureValueType.ValueType);
}
var valueType = editor.GetValueEditor().ValueType;
File diff suppressed because it is too large Load Diff
@@ -1,64 +0,0 @@
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;
}
}
@@ -1,21 +0,0 @@
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; }
}
@@ -1,16 +0,0 @@
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; }
}
@@ -1,9 +0,0 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
/// <summary>
/// Request model for sorting the children of a media item by a system field.
/// Media items do not vary by culture, so no culture is accepted.
/// </summary>
public class SortMediaChildrenByFieldRequestModel : SortChildrenByFieldRequestModelBase
{
}
@@ -15,9 +15,8 @@ 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 (incl. transient-error retry)
2. **Migration Provider Setup** - Configures DbContext to use SQLite
3. **Migrations** - SQLite-specific migration files for OpenIddict tables
4. **Retrying Execution Strategy** - Retries transient SQLite lock errors on EF Core operations
### Folder Structure
@@ -31,8 +30,7 @@ Umbraco.Cms.Persistence.EFCore.Sqlite/
│ └── UmbracoDbContextModelSnapshot.cs # Current model state
├── EFCoreSqliteComposer.cs # DI registration
├── SqliteMigrationProvider.cs # IMigrationProvider impl
── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
└── SqliteRetryingExecutionStrategy.cs # IExecutionStrategy for transient lock errors
── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
```
### Relationship with Parent Project
@@ -67,19 +65,7 @@ Registers `IMigrationProvider` and `IMigrationProviderSetup` for SQLite.
### SqliteMigrationProviderSetup (line 11-14)
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).
Configures `DbContextOptionsBuilder` with `UseSqlite` and migrations assembly.
---
@@ -136,8 +122,7 @@ All tables prefixed with `umbraco`:
| File | Purpose |
|------|---------|
| `SqliteMigrationProvider.cs` | Migration execution |
| `SqliteMigrationProviderSetup.cs` | DbContext configuration (UseSqlite + retry strategy) |
| `SqliteRetryingExecutionStrategy.cs` | Retry on transient SQLite BUSY/LOCKED errors |
| `SqliteMigrationProviderSetup.cs` | DbContext configuration |
| `EFCoreSqliteComposer.cs` | DI registration |
| `Migrations/*.cs` | Migration files |
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Umbraco.Cms.Core;
using Umbraco.Cms.Persistence.EFCore.Migrations;
namespace Umbraco.Cms.Persistence.EFCore.Sqlite;
@@ -14,15 +15,6 @@ public class SqliteMigrationProviderSetup : IMigrationProviderSetup
/// <inheritdoc />
public void Setup(DbContextOptionsBuilder builder, string? connectionString)
{
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));
});
builder.UseSqlite(connectionString, x => x.MigrationsAssembly(GetType().Assembly.FullName));
}
}
@@ -1,71 +0,0 @@
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;
}
}
@@ -184,11 +184,17 @@ internal sealed class SqliteEFCoreDistributedLockingMechanism<T> : IDistributedL
throw new ArgumentException($"LockObject with id={LockId} does not exist.");
}
}
catch (SqliteException ex) when (ex.IsBusyOrLocked())
catch (SqliteException ex) when (IsBusyOrLocked(ex))
{
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;
}
}
@@ -1,26 +0,0 @@
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 => false;
public bool ShowInNotifier => true;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementContainerMove : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => false;
public bool ShowInNotifier => true;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementContainerNew : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => false;
public bool ShowInNotifier => true;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementContainerUpdate : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => false;
public bool ShowInNotifier => true;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementCopy : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => false;
public bool ShowInNotifier => true;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementDelete : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => false;
public bool ShowInNotifier => true;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementMove : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => false;
public bool ShowInNotifier => true;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
+1 -1
View File
@@ -21,7 +21,7 @@ public class ActionElementNew : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => false;
public bool ShowInNotifier => true;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementPublish : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => false;
public bool ShowInNotifier => true;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementRollback : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => false;
public bool ShowInNotifier => true;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementUpdate : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => false;
public bool ShowInNotifier => true;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -467,20 +467,6 @@ 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>
@@ -1,43 +0,0 @@
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);
}
}
@@ -18,14 +18,5 @@ public sealed class LanguageDeletedDistributedCacheNotificationHandler : Deleted
/// <inheritdoc />
protected override void Handle(IEnumerable<ILanguage> entities, IDictionary<string, object?> state)
{
_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();
}
=> _distributedCache.RemoveLanguageCache(entities);
}
+1 -10
View File
@@ -368,17 +368,8 @@ public class ObjectCacheAppCache : IAppPolicyCache, IDisposable
}
// Ensure key is removed from set when evicted from cache
return options.RegisterPostEvictionCallback((key, _, reason, _) =>
return options.RegisterPostEvictionCallback((key, _, _, _) =>
{
// 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)
@@ -1,109 +0,0 @@
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,11 +16,6 @@ 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>
@@ -114,18 +109,6 @@ 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,17 +30,6 @@ 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).
@@ -66,13 +55,4 @@ 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,17 +20,6 @@ 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>
@@ -42,13 +31,4 @@ public class DatabaseServerRegistrarSettings
/// </summary>
[DefaultValue(StaticStaleServerTimeout)]
public TimeSpan StaleServerTimeout { get; set; } = TimeSpan.Parse(StaticStaleServerTimeout);
/// <summary>
/// Gets or sets the maximum time to wait for a single server touch operation to complete before it is
/// considered stalled (for example, blocked on a hung database connection) and abandoned, so the recurring
/// job keeps running rather than stopping permanently. This bounds how long the job waits on a single touch,
/// not how long a stalled connection itself takes to recover (which is governed by the database timeouts).
/// </summary>
[DefaultValue(StaticTouchTimeout)]
public TimeSpan TouchTimeout { get; set; } = DefaultTouchTimeout;
}
@@ -32,11 +32,6 @@ 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>
@@ -75,16 +70,4 @@ 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;
}
@@ -1,33 +0,0 @@
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;
}
@@ -1,29 +0,0 @@
// 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,
}
@@ -1,43 +0,0 @@
// 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,11 +291,6 @@ 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,7 +57,6 @@ 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
@@ -101,7 +100,6 @@ public static partial class UmbracoBuilderExtensions
.AddUmbracoOptions<CacheSettings>()
.AddUmbracoOptions<SystemDateMigrationSettings>()
.AddUmbracoOptions<DistributedJobSettings>()
.AddUmbracoOptions<ScheduledPublishingSettings>(options => options.ValidateOnStart())
.AddUmbracoOptions<BackOfficeTokenCookieSettings>()
.AddUmbracoOptions<WebsiteSettings>()
.AddUmbracoOptions<SignalRSettings>();
@@ -405,8 +405,7 @@
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"><![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>
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -454,8 +454,7 @@
<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"><![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="umbracoApplicationUrlCheckResultFalse">Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod.</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,7 +402,6 @@
<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>
@@ -464,8 +463,7 @@
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"><![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="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -452,8 +452,7 @@
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"><![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="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</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,8 +403,7 @@
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"><![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>
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -730,10 +730,6 @@ 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,34 +44,28 @@ public class UmbracoApplicationUrlCheck : HealthCheck
private HealthCheckStatus CheckUmbracoApplicationUrl()
{
WebRoutingSettings settings = _webRoutingSettings.CurrentValue;
var url = settings.UmbracoApplicationUrl;
var url = _webRoutingSettings.CurrentValue.UmbracoApplicationUrl;
string resultMessage;
StatusResultType resultType;
var success = false;
if (url.IsNullOrWhiteSpace() is false)
{
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
if (url.IsNullOrWhiteSpace())
{
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
resultType = StatusResultType.Warning;
}
else
{
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", new[] { url });
resultType = StatusResultType.Success;
success = true;
}
return new HealthCheckStatus(resultMessage)
{
ResultType = resultType,
ReadMoreLink = resultType == StatusResultType.Success
ReadMoreLink = success
? null
: Constants.HealthChecks.DocumentationLinks.Security.UmbracoApplicationUrlCheck,
};
@@ -64,4 +64,8 @@ public class BlockGridLayoutItem : BlockLayoutItemBase
/// <inheritdoc />
public override bool ReferencesSetting(Guid key)
=> SettingsKey == key || Areas.Any(area => area.ContainsSetting(key));
/// <inheritdoc />
public override IEnumerable<IBlockLayoutItem> GetContainedLayouts()
=> Areas.SelectMany(area => area.Items);
}
@@ -5,12 +5,18 @@ namespace Umbraco.Cms.Core.Models.Blocks;
/// </summary>
public abstract class BlockLayoutItemBase : IBlockLayoutItem
{
/// <inheritdoc />
public Guid Key { get; set; }
/// <inheritdoc />
public Guid ContentKey { get; set; }
/// <inheritdoc />
public Guid? SettingsKey { get; set; }
/// <inheritdoc />
public bool IsExternalContent { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="BlockLayoutItemBase" /> class.
/// </summary>
@@ -44,4 +50,7 @@ public abstract class BlockLayoutItemBase : IBlockLayoutItem
/// <inheritdoc />
public virtual bool ReferencesSetting(Guid key)
=> SettingsKey == key;
/// <inheritdoc />
public virtual IEnumerable<IBlockLayoutItem> GetContainedLayouts() => [];
}
@@ -8,6 +8,18 @@ namespace Umbraco.Cms.Core.Models.Blocks;
/// </summary>
public interface IBlockLayoutItem
{
/// <summary>
/// Gets or sets the layout item key.
/// </summary>
/// <value>
/// The layout item key.
/// </value>
/// <remarks>
/// Uniquely identifies a layout item. Previously the <see cref="ContentKey"/> could be used for this, but
/// with reusable elements, the same <see cref="ContentKey"/> can appear multiple times in one layout.
/// </remarks>
public Guid Key { get; set; }
/// <summary>
/// Gets or sets the content key.
/// </summary>
@@ -24,6 +36,11 @@ public interface IBlockLayoutItem
/// </value>
public Guid? SettingsKey { get; set; }
/// <summary>
/// Indicates if the content source is local or originates from the element service.
/// </summary>
public bool IsExternalContent { get; set; }
/// <summary>
/// Determines whether this layout item references the specified content key.
/// </summary>
@@ -41,4 +58,10 @@ public interface IBlockLayoutItem
/// <c>true</c> if this layout item references the specified settings key; otherwise, <c>false</c>.
/// </returns>
public bool ReferencesSetting(Guid key) => SettingsKey == key;
/// <summary>
/// Returns any nested layouts for this layout (e.g. area layouts for the Block Grid).
/// </summary>
/// <returns>The nested layouts.</returns>
public IEnumerable<IBlockLayoutItem> GetContainedLayouts();
}
@@ -1,22 +0,0 @@
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,9 +24,4 @@ public enum TaggableObjectTypes
/// Represents member entities (user accounts).
/// </summary>
Member,
/// <summary>
/// Represents element entities.
/// </summary>
Element,
}
@@ -1,19 +0,0 @@
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)
{
}
}
@@ -1,30 +0,0 @@
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)
{
}
}
@@ -1,34 +0,0 @@
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)
{
}
}
@@ -1,30 +0,0 @@
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)
{
}
}
@@ -1,30 +0,0 @@
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,19 +16,6 @@ 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>
@@ -1,34 +0,0 @@
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,10 +8,4 @@ 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,16 +1,12 @@
// 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;
@@ -74,21 +70,13 @@ 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,
ICoreScopeProvider coreScopeProvider,
IContentService contentService,
ILocalizedTextService localizedTextService)
DataEditorAttribute attribute)
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
{
Validators.Add(new TypedValidatorRunner<string, ContentPickerConfiguration>(
new AllowedTypeValidator(localizedTextService, contentService, coreScopeProvider)));
}
/// <inheritdoc />
@@ -146,61 +134,4 @@ 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,32 +8,4 @@ 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,19 +1,13 @@
using System.ComponentModel.DataAnnotations;
using Umbraco.Cms.Core.IO;
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,
@@ -23,11 +17,6 @@ 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)
{
@@ -39,44 +28,21 @@ 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,
ILocalizedTextService localizedTextService,
IElementService elementService,
ICoreScopeProvider coreScopeProvider)
DataEditorAttribute attribute)
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
{
_jsonSerializer = jsonSerializer;
Validators.Add(new TypedValidatorRunner<List<string>, ElementPickerConfiguration>(
new MinMaxValidator(localizedTextService),
new AllowedTypeValidator(localizedTextService, elementService, coreScopeProvider)));
}
=> _jsonSerializer = jsonSerializer;
/// <inheritdoc/>
public IEnumerable<UmbracoEntityReference> GetReferences(object? value)
{
var asString = value as string ?? value?.ToString();
@@ -97,144 +63,4 @@ 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 : ITypedValidator<EntityDataPickerDto, EntityDataPickerConfiguration>
internal sealed class MinMaxValidator : ITypedJsonValidator<EntityDataPickerDto, EntityDataPickerConfiguration>
{
private readonly ILocalizedTextService _localizedTextService;
@@ -1,12 +1,11 @@
namespace Umbraco.Cms.Core.PropertyEditors;
/// <summary>
/// Represents a property index value factory specifically for block-based property values.
/// Represents a property index value factory specifically for block grid properties.
/// </summary>
/// <remarks>
/// This marker interface allows for specialized indexing of block content,
/// such as Block List, Block Grid, and Rich Text block values.
/// This marker interface allows for specialized indexing of block grid content.
/// </remarks>
public interface IBlockValuePropertyIndexValueFactory : IPropertyIndexValueFactory
public interface IBlockGridPropertyIndexValueFactory : IPropertyIndexValueFactory
{
}
@@ -0,0 +1,11 @@
namespace Umbraco.Cms.Core.PropertyEditors;
/// <summary>
/// Represents a property index value factory specifically for block list properties.
/// </summary>
/// <remarks>
/// This marker interface allows for specialized indexing of block list content.
/// </remarks>
public interface IBlockListPropertyIndexValueFactory : IPropertyIndexValueFactory
{
}
@@ -0,0 +1,11 @@
namespace Umbraco.Cms.Core.PropertyEditors;
/// <summary>
/// Represents a property index value factory specifically for single block properties.
/// </summary>
/// <remarks>
/// This marker interface allows for specialized indexing of single block content.
/// </remarks>
public interface ISingleBlockPropertyIndexValueFactory : IPropertyIndexValueFactory
{
}
@@ -9,13 +9,17 @@ 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>
[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>
public interface ITypedJsonValidator<TValue, TConfiguration>
{
// 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(
/// <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(
TValue? value,
TConfiguration? configuration,
string? valueType,
@@ -1,31 +0,0 @@
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,47 +6,26 @@ namespace Umbraco.Cms.Core.PropertyEditors.Validation;
/// <summary>
/// <para>
/// 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.
/// An aggregate validator for JSON based value editors, to avoid doing multiple deserialization.
/// </para>
/// <para>
/// 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.
/// Will deserialize once, and cast the configuration once, and pass those values to each <see cref="ITypedJsonValidator{TValue,TConfiguration}"/>, aggregating the results.
/// </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 ITypedValidator<TValue, TConfiguration>[] _validators;
private readonly ITypedJsonValidator<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;
@@ -72,7 +51,7 @@ public class TypedJsonValidatorRunner<TValue, TConfiguration> : IValueValidator
return validationResults;
}
foreach (ITypedValidator<TValue, TConfiguration> validator in _validators)
foreach (ITypedJsonValidator<TValue, TConfiguration> validator in _validators)
{
validationResults.AddRange(validator.Validate(deserializedValue, configuration, valueType, validationContext));
}
@@ -1,60 +0,0 @@
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&lt;string&gt;</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();
}
}
@@ -3,7 +3,17 @@ using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Core.PublishedCache;
/// <summary>
/// A service for converting <see cref="BlockItemData"/> into <see cref="IPublishedElement"/>.
/// </summary>
public interface IBlockElementService
{
Task<IPublishedElement?> BuildElementAsync(BlockItemData blockItemData, bool? preview = null);
/// <summary>
/// Creates an <see cref="IPublishedElement"/> instance from <see cref="BlockItemData"/>.
/// </summary>
/// <param name="owner">The <see cref="IPublishedElement"/> that contains the block property which is the origin to the <see cref="BlockItemData"/>.</param>
/// <param name="blockItemData">The <see cref="BlockItemData"/> containing the data to convert into an <see cref="IPublishedElement"/>.</param>
/// <param name="preview">Whether to perform the conversion for preview.</param>
/// <returns>The created <see cref="IPublishedElement"/>, or null if an element could not be created from the <see cref="BlockItemData"/>.</returns>
Task<IPublishedElement?> BuildElementAsync(IPublishedElement owner, BlockItemData blockItemData, bool? preview = null);
}
@@ -211,15 +211,6 @@ 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)
@@ -267,8 +258,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, Ordering? ordering, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: ordering);
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);
/// <inheritdoc />
protected override ContentEditingOperationStatus Sort(IEnumerable<IContent> items, int userId)
@@ -277,13 +268,6 @@ 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,10 +500,6 @@ 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,
@@ -90,10 +90,9 @@ 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, Ordering? ordering, out long total);
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total);
/// <summary>
/// Handles the sorting operation asynchronously.
@@ -116,7 +115,16 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
return ContentEditingOperationStatus.NotFound;
}
List<TContent> children = LoadAllChildren(contentId.Value, ordering: null);
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);
}
try
{
@@ -134,102 +142,4 @@ 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."),
};
}
+1 -44
View File
@@ -1652,13 +1652,7 @@ public class ContentService : PublishableContentServiceBase<IContent>, IContentS
{
scope.WriteLock(Constants.Locks.ContentTree);
// 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);
OperationResult ret = Sort(scope, itemsA, userId, evtMsgs);
scope.Complete();
return ret;
}
@@ -1696,43 +1690,6 @@ 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,18 +95,6 @@ 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,22 +418,6 @@ 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,18 +118,6 @@ 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,22 +338,6 @@ 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,105 +15,26 @@ 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>
-18
View File
@@ -54,18 +54,6 @@ 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>
@@ -112,12 +100,6 @@ 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>
@@ -169,13 +169,6 @@ 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);
@@ -198,8 +191,8 @@ internal sealed class MediaEditingService
=> ContentService.Delete(media, userId).Result;
/// <inheritdoc />
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);
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total);
/// <inheritdoc />
protected override ContentEditingOperationStatus Sort(IEnumerable<IMedia> items, int userId)
@@ -210,13 +203,6 @@ 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>
-46
View File
@@ -1309,15 +1309,6 @@ 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))
{
@@ -1356,43 +1347,6 @@ namespace Umbraco.Cms.Core.Services
}
/// <inheritdoc />
public OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
{
EventMessages evtMsgs = EventMessagesFactory.Get();
if (orderedChildIds.Count == 0)
{
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
}
using ICoreScope scope = ScopeProvider.CreateCoreScope();
scope.WriteLock(Constants.Locks.MediaTree);
_mediaRepository.UpdateSortOrder(orderedChildIds);
// Sort order lives in umbracoNode; neither the published cache nor the media repository cache keeps
// a separate serialized copy of it, so refreshing the affected branch (which invalidates both and has
// them reload from umbracoNode) is enough to pick up the new order without re-saving each child.
if (parentId == Constants.System.Root)
{
IMedia[] roots = GetByIds(orderedChildIds).ToArray();
scope.Notifications.Publish(new MediaTreeChangeNotification(roots, TreeChangeTypes.RefreshNode, evtMsgs));
}
else
{
IMedia? parent = GetById(parentId);
if (parent is not null)
{
scope.Notifications.Publish(new MediaTreeChangeNotification(parent, TreeChangeTypes.RefreshBranch, evtMsgs));
}
}
Audit(AuditType.Sort, userId, parentId);
scope.Complete();
return OperationResult.Succeed(evtMsgs);
}
/// <summary>
/// Checks the data integrity of the media tree and optionally fixes detected issues.
/// </summary>
@@ -1,27 +0,0 @@
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,
}

Some files were not shown because too many files have changed in this diff Show More