Compare commits

...
Author SHA1 Message Date
Laura NetoandGitHub df16c114b6 OpenApi: Lowercase document name on registration to match AddOpenApi internal behaviour (closes #23210) (#23212)
* Lowercase OpenAPI document name on registration to match AddOpenApi internal behaviour

AddOpenApi lowercases the document name when registering its keyed services, so
ReplaceOpenApiSchemaService must receive the same lowercased key or the lookup
throws. BackOfficeOpenApiDocumentBuilder now computes a normalised registration
name and uses it for all DI calls, while keeping DocumentName in its original
casing. ShouldInclude matches [MapToApi] case-insensitively to align with how
documents are registered, and the UI dropdown label falls back to DocumentName
(original casing) rather than the lowercased registration key.
AddUmbracoOpenApiDocument applies the same normalisation for its apiName parameter.

* Add regression tests for mixed-case OpenAPI document name registration

Covers the bug scenario where AddBackOfficeOpenApiDocument with a mixed-case
name and WithJsonOptions threw InvalidOperationException at startup, and verifies
that ShouldInclude matches [MapToApi] case-insensitively.
2026-06-26 13:59:27 +02:00
fb1e16ff36 Table dates and User dates (#22169)
User-collection-table didn´t format and if you have da backoffice the time is still Am/pm

Co-authored-by: Lucas Bach Bisgaard <lucas.bisgaard@kraftvaerk.com>
Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
Co-authored-by: Mads Rasmussen <madsr@hey.com>
2026-06-26 09:23:21 +02:00
Niels Lyngsø d889de6af0 Merge branch 'v17/dev' 2026-06-26 09:19:08 +02:00
d8f4342a86 Fix detail data request manager failing when items hit 40, making document unusable (#23164)
Fix detail data request manager failing as soon as the number of items requested hits the UmbItemDataApiGetRequestController batch limit (40)

Co-authored-by: Paul Woodland <paul.woodland@pwnewmedia.com>
2026-06-26 09:09:11 +02:00
774b2d4822 Fix detail data request manager failing when items hit 40, making document unusable (#23164)
Fix detail data request manager failing as soon as the number of items requested hits the UmbItemDataApiGetRequestController batch limit (40)

Co-authored-by: Paul Woodland <paul.woodland@pwnewmedia.com>
2026-06-26 09:08:14 +02:00
Niels LyngsøandGitHub 022439065f Block Workspace: avoid JS error if destroyed (#23200)
avoid JS error if Block Workspace Context was destroyed while awaiting a frame
2026-06-25 15:59:33 +02:00
Laura NetoandGitHub ba6ec7abcf Re-enable package validation (#23197) 2026-06-25 08:43:56 +00:00
Laura Neto 5d4d3a51ea Merge branch 'release/18.0' 2026-06-25 07:16:30 +01:00
Jacob Overgaard c6c5a9e7e6 Merge remote-tracking branch 'origin/v17/dev' 2026-06-24 11:51:40 +02:00
065e567f11 Media: Add umb-media-thumbnail with configurable checkerboard background (closes #23177) (#23178)
* feat(media): add umb-thumbnail and configurable checkerboard background

Adds `umb-thumbnail` as the recommended alias of `umb-imaging-thumbnail`
(the original tag stays registered for backwards compatibility), and makes
the checkerboard background opt-out via the `--umb-thumbnail-background` CSS
custom property plus an `img` part for full styling control. Also fixes an
action-event listener leak in the thumbnail element, and adds a Storybook
story, an MDX guide, and component tests.

Closes #23177

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(media): address PR review on umb-thumbnail

- Rephrase the imaging-thumbnail JSDoc to a neutral alias statement instead of
  a "prefer" wording that read like an undeclared deprecation.
- Guard the thumbnail tests so a renamed private field fails loudly rather than
  producing vacuous assertions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(media): make umb-thumbnail canonical, deprecate umb-imaging-thumbnail

Invert the inheritance so the implementation lives on `UmbThumbnailElement`
(`umb-thumbnail`) and `UmbImagingThumbnailElement` (`umb-imaging-thumbnail`)
is the thin subclass. Removing the old tag is now just deleting one file.

The deprecated subclass emits a one-time `UmbDeprecation` warning (a
module-level guard avoids per-instance console spam) and carries a
`@deprecated` JSDoc, scheduled for removal in Umbraco 19.

Migrate the four internal consumers to `umb-thumbnail` so the deprecation
warning targets external code only, not our own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(media): trim deprecated-alias thumbnail tests to a registration guard

The img part, checkerboard default and --umb-thumbnail-background override are
covered by thumbnail.element.test.ts and inherited from UmbThumbnailElement, so
re-asserting them on the umb-imaging-thumbnail subclass only tested inheritance.
Keep a single backwards-compat guard that the deprecated alias stays registered
and on the inheritance chain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(media): rename canonical thumbnail to umb-media-thumbnail; alias keeps @deprecated, no runtime warning

Per review (Niels): the forward-looking name is `umb-media-thumbnail`
(`UmbMediaThumbnailElement`), leaving room for non-media thumbnails later. The
implementation, CSS custom property (`--umb-media-thumbnail-background`), story,
guide and internal consumers all use the new name.

`umb-imaging-thumbnail` stays registered as a thin alias and keeps its
`@deprecated` JSDoc (IDE signal) but no longer emits a runtime UmbDeprecation
warning — both tags fly for now. Docs and comments lead with umb-media-thumbnail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:37:53 +00:00
58a1c15626 Deprecations: Annotate warnings with caller origin and suppress core noise in production (#23188)
* feat(core): annotate deprecation warnings with caller origin, suppress core noise in production

Deprecation warnings now state where the call most likely came from — Umbraco
core, an /App_Plugins package, or other custom code — by classifying the call
stack (first frame not under /umbraco/backoffice/ is the caller). This answers
the Codegarden feedback that you can't tell whose code triggered a warning.

In production builds, core-origin warnings are suppressed (a consumer can't act
on Umbraco's own code); package/external/unknown origins are always shown. The
production signal is the client build, not the server runtime mode — the latter
is unreliable since Umbraco Cloud defaults to BackofficeDevelopment. The new
umbIsProductionBuild() reads Vite's import.meta.env.PROD (substituted to true in
the shipped core bundle) and falls back to false outside a Vite build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(core): cleaner deprecation output and drop the throw for stack capture

Read new Error().stack directly instead of throwing and catching — the stack is
populated on construction. Annotate the warning with the resolved origin on its
own line rather than a bracketed prefix, and rely on the browser's native
expandable stack on console.warn for the full clickable trace instead of
printing one ourselves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(core): trim inline comments in deprecation utils

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(core): address PR review on deprecation origin

- Clarify umbIsProductionBuild docs: in Vite dev import.meta.env is defined
  (PROD false); the guard is for non-Vite contexts (tsc pass, web-test-runner).
- Strip query/fragment from parsed frame URLs so the external-origin label
  can't carry ?/# noise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:29:21 +00:00
Laura Neto c087ce9fb5 Bump the version of the Umbraco.TheStarterKit to 18.0.0 in the UmbracoProject template 2026-06-24 11:23:37 +02:00
Jacob OvergaardandClaude Opus 4.8 cda880bb62 test(hybridcache): use v18 IDatabaseCacheRepository method names in stale-set race test
DocumentHybridCacheStaleSetRaceTests came from the v17 PR #23169 and merged
forward into main (v18) unchanged, but on v18 IDatabaseCacheRepository renamed
GetContentSourceAsync -> GetDocumentSourceAsync and
GetContentSourceForPublishStatesAsync -> GetDocumentSourceForPublishStatesAsync.
The mock setups still referenced the v17 names, failing the build with CS1061.

Rename the two Moq setups to the v18 method names so the test compiles and the
mocks actually intercept the calls DocumentCacheService makes. v17 keeps the
Content* names and is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:06:16 +02:00
Jacob Overgaard b3e6477df9 Merge remote-tracking branch 'origin/v17/dev' 2026-06-23 14:33:28 +02:00
Jacob Overgaard 8f3ff26005 Merge remote-tracking branch 'origin/release/18.0' 2026-06-23 14:32:19 +02:00
Jacob Overgaard 05f8158e4a Merge remote-tracking branch 'origin/release/17.5.0' into v17/dev 2026-06-23 14:29:24 +02:00
Jacob Overgaard 28b849f2d3 build(deps): bumps @umbraco-ui/uui to 1.18.1 2026-06-23 11:54:32 +02:00
Jacob Overgaard 8dd8820fa3 build(deps): bumps @umbraco-ui/uui to 2.0.0 2026-06-23 11:54:00 +02:00
ca7dcd5150 Published Cache: Guard against cache poisoning from a render-vs-publish race (#23169)
* Guard against cache poisoning from concurrency

* Resolve code review comments relating to tests.

* Avoid unnecessary second invalidation of memory cache generatio.

* Tighten the cache-generation guard.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-23 10:14:46 +02:00
Laura Neto 6b76230da5 Bump version to 18.0.0. 2026-06-23 09:38:59 +02:00
Kenn JacobsenandGitHub ceef53d624 Examine: Queue cold boot Examine reindexing at start-up, not at first request (Closes #22883) (#23181)
* Queue cold boot Examine reindexing at start-up, not at first request

* Review comments from Claude
2026-06-23 08:40:24 +02:00
Andy ButlandandGitHub 5bb53172aa ModelsBuilder: Avoid ObjectDisposedException in InMemoryModelFactory during shutdown (#23171)
* Guard EnsureModels against disposed lock on shutdown.

* Addressed code review comment.
2026-06-23 07:13:49 +02:00
Andy ButlandandGitHub aa9473131b Content Types: Show the correct type name (Media/Member Type) in the Compositions dialog (closes #23102) (#23118)
* Display appropriate content type name in compositions dialog localised texts.

* Fix composition dialog translation typos and link references to the matching workspace

- fr: "sililaire" -> "similaire"
- it: "utlizzato" -> "utilizzato"
- es: remove duplicated "no puede no puede"

The reference list now builds its workspace edit href from the modal's
entityType instead of hardcoding document-type, so links resolve correctly
when the dialog is used for Media Types and Member Types.
2026-06-22 16:28:59 +01:00
Mads RasmussenandGitHub 35a3a2455c Entity Data Picker: Implement interaction memory + sync picker memory across all picker inputs (#23172)
* Integrate interaction memories into entity data picker

* Skip resetting unchanged data source API

Add an early-return guard in setDataSourceApi to avoid re-setting the same UmbPickerDataSource instance. Prevents rebuilding the modal token/route (which would close and reopen an open picker modal) on every re-render by only updating when the API actually changes.

* Add UmbEntityInputInteractionMemoryManager + implement across current inputs with memory

* clean up comment
2026-06-22 14:12:55 +00:00
Andy Butland 2e3628dad3 Bump version to 18.0.0-rc4. 2026-06-19 18:50:40 +02:00
Andy Butland 5a20452e7a Bump version to 17.5.0-rc3. 2026-06-19 18:44:26 +02:00
0e14ace89a Element Picker: Adds valueSummary display and value resolver (#23154)
* feat(elements): add value summary for Element Picker property editor

Adds a valueSummary extension so picked element names appear in collection
view columns. Includes a value-type constant, batch resolver, variant-aware
element, and a resolver unit test (11 cases).

* fix(elements): address PR review feedback on element picker value summary

- Call removeUmbControllerByAlias when removing a stale resolver so the
  named observer controller is released from the element's controller list
- Call setData on existing resolvers when _value refreshes so renames are
  reflected without recreating the resolver
- Remove redundant valueResolver re-export from resolver file (barrel handles it)


---------

Co-authored-by: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
2026-06-19 14:11:12 +00:00
Andy Butland adeddeb148 Complete bump version to 17.5.0-rc2. 2026-06-19 15:51:10 +02:00
c9059c7b07 UFM: Add umbElementName component (#23162)
* feat(ufm): add umbElementName UFM component

Adds a new UFM component that resolves Element display names from element
keys, mirroring the umbContentName component pattern. Uses the variant-aware
UmbElementItemDataResolver (via UmbElementItemRepository) for proper
culture/variant handling and (Untitled) fallback.

Also exports UmbElementItemDataResolver from the public
@umbraco-cms/backoffice/element entry point, matching the pattern used by
the documents package.

* fix(ufm): clear stale value and destroy resolvers in element-name element

Clear this.value when the render context produces no usable input, preventing
stale names from lingering when the context changes. Also destroy each
UmbElementItemDataResolver after getName() to avoid accumulating controller
registrations on the host element.

* Update src/Umbraco.Web.UI.Client/src/packages/ufm/components/element-name/element-name.element.ts

Co-authored-by: Andy Butland <abutland73@gmail.com>

* test(ufm): add umbElementName parsing test to marked-ufm.test.ts

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-19 13:26:20 +00:00
Andy Butland 1ee24595d0 Fixed build error with integration tests. 2026-06-19 14:24:41 +02:00
Lee KelleherandGitHub ab8b8b48d4 Block RTE: Implement unsupported block rendering (closes #23071) (#23126)
* Block RTE: Implement unsupported block rendering

* Fixes `.ProseMirror-selectednode` focus ring

* Markup tidy-up

* Adds test for `umb-unsupported-rte-block`

* Block RTE: Reflect unsupported state as a host attribute

Replaces @state() + toggleAttribute() with @property({ reflect: true })
so Lit manages the 'unsupported' attribute sync during the update cycle,
avoiding constructor-time attribute access flagged by the linter.

Also removes the now-inert uui-text/uui-font classes from the block
wrapper div (backing styles were removed with UmbTextStyles).

* Adds JSDoc comment to `unsupported` property

* Block RTE: Extract #observeBlockViewProps() to reduce constructor size

Moves the block-view-props observer setup out of the constructor into a
dedicated #observeBlockViewProps() method, following the same pattern as
#observeData(). Reduces constructor from 123 to 64 lines (threshold: 70).
2026-06-19 13:08:00 +01:00
Jacob Overgaard 4ac8a397d0 Merge branch 'v17/dev'
# Conflicts:
#	src/Umbraco.Web.UI.Client/src/packages/content/content/types.ts
2026-06-19 13:58:25 +02:00
Jacob Overgaard feb1689848 Merge branch 'release/17.5.0' into v17/dev 2026-06-19 13:56:04 +02:00
Jacob OvergaardandClaude Opus 4.8 a14b908574 test(backoffice): fix host type in extension-initializer-base test
The `loaded`-signal test (added in #23167) built its host with
`UmbControllerHostElementMixin(HTMLElement)`, mirroring the older
`UmbBaseExtensionInitializer` tests. But `UmbExtensionInitializerBase`
requires a full `UmbElement` host, so the test failed `tsc` (TS2345)
under the root tsconfig. The product build excludes `*.test.ts`, so it
slipped through CI but breaks `npm run compile`/the editor.

Use `UmbElementMixin(HTMLElement)`, matching what production callers pass
(app/backoffice/preview elements are all UmbElements).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:55:56 +02:00
Andy Butland 0f4b784c23 Merge branch 'main' of https://github.com/umbraco/Umbraco-CMS 2026-06-19 13:40:22 +02:00
Andy Butland ebc0ef36f5 Merge branch 'v17/dev' 2026-06-19 13:40:09 +02:00
Jacob Overgaard 925d6bc430 Merge remote-tracking branch 'origin/release/17.5.0' into v17/dev 2026-06-19 13:37:44 +02:00
Jacob OvergaardandClaude Opus 4.8 acfaf23e43 Merge external-login entrypoint-race fix into v17/dev
Reconciles app.element.ts with #23020 (parallelized public extensions).
Kept the boot gate (await the app-entry-point initializer before routing)
and restored a blocking inline `await registerPublicExtensions()` instead
of the parallelized deferred form — a marginally slower but more robust
boot, identical to the release/17.5.0 fix (no empty-first-pass timing
reliance). extension-initializer-base.ts, the unit test, the acceptance
test and playwright config merge cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:35:21 +02:00
Jacob OvergaardandClaude Opus 4.8 cb23c84c3d External login: wait for app-entry-points before the login provider decision (#23167)
* External login: wait for app-entry-points before the login provider decision

The backoffice boot stopped waiting for app-entry-point extensions to settle
before deciding which auth provider to use (regression introduced in #22522).
On a slow connection an externally registered authProvider (e.g. Umbraco ID)
is not registered yet when the login screen renders, so the user is dropped on
the local login instead of being redirected to the external provider.

- extension-initializer-base: `loaded` re-arms to `undefined` while a pass is in
  flight and resolves to `true` unconditionally (including zero extensions), so
  `.asPromise()` gates correctly and never hangs on a default install (which has
  no app-entry-points) — the reason the await was removed in the first place.
- app.element: restore the awaited boot gate before routing.

Tests:
- Unit test for the `loaded` signal contract (zero extensions resolves; a late,
  slow extension is awaited).
- Playwright acceptance test that deploys an app-entry-point registering an
  authProvider after a delay and asserts it is offered on the login screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(backoffice): guard the loaded-gate timing for permission loading

Add a test asserting the collection initializer's `loaded` does not open the
gate (`#loadedGuard` awaits it via `.asPromise()`, fronting private-extension
and user-permission loading) until the initially-registered extensions have
instantiated. Addresses the #22522 "user permissions resolved too late" concern
in writing; user-permission condition resolution itself lives in
UmbBaseExtensionInitializer (covered by base-extension-initializer.race.test.ts)
and is untouched by this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(backoffice): harden loaded signal + narrow acceptance test glob (review)

Address PR review feedback:
- extension-initializer-base: only the latest processing pass settles `loaded`
  (monotonic pass id), so a slow earlier pass can't unblock waiters early when
  the async observer overlaps passes; and use `Promise.allSettled` so a throwing
  `instantiateExtension` can't leave `loaded` stuck at `undefined` (hanging the
  boot gate) — failures are logged rather than swallowed.
- playwright.config: narrow the project glob to `**/*.spec.ts` so Playwright
  doesn't try to load the App_Plugins `entry-point.js` ESM fixture as a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:28:23 +02:00
Niels LyngsøandGitHub 7c1b907410 Block Workspace: Support variant properties and make '$settings' a key-value-object (Closes #23095) (#23123)
* map settings to become a key-value-object

* implement type safety for block label ufm values

* added TODOs

* support variant value in Block Workspace Label
2026-06-19 11:26:18 +00:00
Niels Lyngsø 564ca0384b Squashed commit of the following:
commit cd132f44b1
Author: Niels Lyngsø <niels.lyngso@gmail.com>
Date:   Fri Jun 19 13:18:07 2026 +0200

    correct to use display: block;

commit a9ffa9f90b
Author: Andreas Lykke Borg <72602768+andreaslborg@users.noreply.github.com>
Date:   Mon Jun 15 20:56:51 2026 +0200

    Added css styling to block list and single to respect custom width
2026-06-19 13:25:17 +02:00
Niels Lyngsø fc3b13c85c Squashed commit of the following:
commit cd132f44b1
Author: Niels Lyngsø <niels.lyngso@gmail.com>
Date:   Fri Jun 19 13:18:07 2026 +0200

    correct to use display: block;

commit a9ffa9f90b
Author: Andreas Lykke Borg <72602768+andreaslborg@users.noreply.github.com>
Date:   Mon Jun 15 20:56:51 2026 +0200

    Added css styling to block list and single to respect custom width
2026-06-19 13:24:56 +02:00
9f633416b1 External login: wait for app-entry-points before the login provider decision (#23167)
* External login: wait for app-entry-points before the login provider decision

The backoffice boot stopped waiting for app-entry-point extensions to settle
before deciding which auth provider to use (regression introduced in #22522).
On a slow connection an externally registered authProvider (e.g. Umbraco ID)
is not registered yet when the login screen renders, so the user is dropped on
the local login instead of being redirected to the external provider.

- extension-initializer-base: `loaded` re-arms to `undefined` while a pass is in
  flight and resolves to `true` unconditionally (including zero extensions), so
  `.asPromise()` gates correctly and never hangs on a default install (which has
  no app-entry-points) — the reason the await was removed in the first place.
- app.element: restore the awaited boot gate before routing.

Tests:
- Unit test for the `loaded` signal contract (zero extensions resolves; a late,
  slow extension is awaited).
- Playwright acceptance test that deploys an app-entry-point registering an
  authProvider after a delay and asserts it is offered on the login screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(backoffice): guard the loaded-gate timing for permission loading

Add a test asserting the collection initializer's `loaded` does not open the
gate (`#loadedGuard` awaits it via `.asPromise()`, fronting private-extension
and user-permission loading) until the initially-registered extensions have
instantiated. Addresses the #22522 "user permissions resolved too late" concern
in writing; user-permission condition resolution itself lives in
UmbBaseExtensionInitializer (covered by base-extension-initializer.race.test.ts)
and is untouched by this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(backoffice): harden loaded signal + narrow acceptance test glob (review)

Address PR review feedback:
- extension-initializer-base: only the latest processing pass settles `loaded`
  (monotonic pass id), so a slow earlier pass can't unblock waiters early when
  the async observer overlaps passes; and use `Promise.allSettled` so a throwing
  `instantiateExtension` can't leave `loaded` stuck at `undefined` (hanging the
  boot gate) — failures are logged rather than swallowed.
- playwright.config: narrow the project glob to `**/*.spec.ts` so Playwright
  doesn't try to load the App_Plugins `entry-point.js` ESM fixture as a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:11:28 +01:00
Lee KelleherandGitHub 91381604dd UFM: Add umbMemberName component (closes #22147) (#23165)
* Adds UFM Member Name component

This is to support the standalone Member Picker values.

* fix(ufm): clear stale value on empty member picker; validate UDI-extracted GUIDs

* test(ufm): add umbMemberName parsing tests to marked-ufm.test.ts
2026-06-19 09:48:51 +00:00
Jacob OvergaardandClaude Opus 4.8 c566dd0a71 fix(backoffice): harden loaded signal + narrow acceptance test glob (review)
Address PR review feedback:
- extension-initializer-base: only the latest processing pass settles `loaded`
  (monotonic pass id), so a slow earlier pass can't unblock waiters early when
  the async observer overlaps passes; and use `Promise.allSettled` so a throwing
  `instantiateExtension` can't leave `loaded` stuck at `undefined` (hanging the
  boot gate) — failures are logged rather than swallowed.
- playwright.config: narrow the project glob to `**/*.spec.ts` so Playwright
  doesn't try to load the App_Plugins `entry-point.js` ESM fixture as a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:26 +02:00
Jacob OvergaardandClaude Opus 4.8 ea78147657 test(backoffice): guard the loaded-gate timing for permission loading
Add a test asserting the collection initializer's `loaded` does not open the
gate (`#loadedGuard` awaits it via `.asPromise()`, fronting private-extension
and user-permission loading) until the initially-registered extensions have
instantiated. Addresses the #22522 "user permissions resolved too late" concern
in writing; user-permission condition resolution itself lives in
UmbBaseExtensionInitializer (covered by base-extension-initializer.race.test.ts)
and is untouched by this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:19:39 +02:00
Andy ButlandandGitHub 0a5189e54a Content & Media: Enforce content type filters and allowed children/root rules when validating a create (#23163)
* Validate for content type filters on create document and media validation.

* Addressed code review feedback on tests.
2026-06-19 11:16:47 +02:00
Jacob OvergaardandClaude Opus 4.8 102e4aa80b External login: wait for app-entry-points before the login provider decision
The backoffice boot stopped waiting for app-entry-point extensions to settle
before deciding which auth provider to use (regression introduced in #22522).
On a slow connection an externally registered authProvider (e.g. Umbraco ID)
is not registered yet when the login screen renders, so the user is dropped on
the local login instead of being redirected to the external provider.

- extension-initializer-base: `loaded` re-arms to `undefined` while a pass is in
  flight and resolves to `true` unconditionally (including zero extensions), so
  `.asPromise()` gates correctly and never hangs on a default install (which has
  no app-entry-points) — the reason the await was removed in the first place.
- app.element: restore the awaited boot gate before routing.

Tests:
- Unit test for the `loaded` signal contract (zero extensions resolves; a late,
  slow extension is awaited).
- Playwright acceptance test that deploys an app-entry-point registering an
  authProvider after a delay and asserts it is offered on the login screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:10:01 +02:00
Andy ButlandandGitHub 0bec947b8b Examine: Stream value sets to a single index during rebuild to reduce reindex peak memory (#23150)
* Stream value sets to a single index during rebuild to reduce reindex peak memory.

* Applied code review feedback.
2026-06-19 06:32:18 +02:00
Andy Butland 8bcd5990c2 Merge branch 'v17/dev' 2026-06-17 20:00:48 +02:00
Andy Butland d0e7ef0169 Merge branch 'release/17.5.0' into v17/dev 2026-06-17 19:31:46 +02:00
Jesper MadsenandJacob Overgaard e2cf205d34 Let the external login button show "sign in with {providername}" in languages (#23135) 2026-06-17 15:57:57 +02:00
Jesper MadsenandJacob Overgaard 1dfcd9332a Let the external login button show "sign in with {providername}" in languages (#23135) 2026-06-17 15:57:40 +02:00
Jesper MadsenandGitHub b2b45b016d Let the external login button show "sign in with {providername}" in languages (#23135) 2026-06-17 15:56:59 +02:00
Jacob Overgaard ef610c7d23 Merge remote-tracking branch 'origin/release/18.0' 2026-06-17 15:51:52 +02:00
Jacob Overgaard bf0270b244 build: deploy to npm through a template 2026-06-17 15:49:00 +02:00
Jacob OvergaardandClaude Opus 4.7 dc77f37129 Build: tag prerelease npm publishes with 'next' dist-tag (#22909)
* Build: tag prerelease npm publishes with 'next' dist-tag

Prereleases that flow through Deploy_Npm (e.g. 18.0.0-beta1) currently
land on the `latest` dist-tag, so a bare `npm install @umbraco-cms/backoffice`
resolves to an unstable build. Switch to `--tag next` when
NBGV_PrereleaseVersion is non-empty, leaving `latest` for stable releases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Build: address review feedback on npm prerelease dist-tag

- Add Build to Deploy_Npm dependsOn so stageDependencies.Build.A.outputs
  resolves explicitly (mirrors the Upload_API_Docs pattern).
- Pass npmPrereleaseVersion via env: instead of inline macro expansion in
  bash, so an unset variable won't be interpreted as command substitution.

* Build: source npmPrereleaseVersion via dependencies, not dependsOn

Switches the variable mapping from stageDependencies (which needs Build
in dependsOn) to dependencies.Build.outputs[...], matching the pattern
the stage's condition already uses on line 941. Avoids drawing a
redundant parallel arrow from Build to Deploy_Npm in the ADO stage
graph — Build is already in the ancestor chain via Deploy_NuGet.

* Build: align Deploy_Npm with Umbraco Deploy publish pattern

- Use stageDependencies form in variables: (dependencies.* only works in conditions).
- Source NBGV_PrereleaseVersionNoLeadingHyphen for a cleaner check.
- Replace echo >> .npmrc with npm config set --location=project.
- Collapse if/else into a tag=latest|next shell variable; single npm publish *.tgz.
- Drop unnecessary env: passthrough and npm init -y.

Per Ronald's feedback on PR #22909 — mirrors the Deploy pipeline's release stage.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-17 15:48:53 +02:00
Jacob OvergaardandClaude Opus 4.8 d84186061c fix(tests): point DomainCacheServiceTests mocks at GetAllAsync
IDomainService.GetAll was removed in #22629; DomainCacheServiceTests was
added later in #23084 against a stale base and still mocked the removed
method, breaking the Release build on release/18.0. Production
DomainCacheService already calls GetAllAsync, so update the four mock
setups to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:52:52 +02:00
Jacob Overgaard 87bc6522f1 build: deploy to npm through a template 2026-06-17 14:26:25 +02:00
Jacob OvergaardandClaude Opus 4.7 c300ebf94a Build: tag prerelease npm publishes with 'next' dist-tag (#22909)
* Build: tag prerelease npm publishes with 'next' dist-tag

Prereleases that flow through Deploy_Npm (e.g. 18.0.0-beta1) currently
land on the `latest` dist-tag, so a bare `npm install @umbraco-cms/backoffice`
resolves to an unstable build. Switch to `--tag next` when
NBGV_PrereleaseVersion is non-empty, leaving `latest` for stable releases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Build: address review feedback on npm prerelease dist-tag

- Add Build to Deploy_Npm dependsOn so stageDependencies.Build.A.outputs
  resolves explicitly (mirrors the Upload_API_Docs pattern).
- Pass npmPrereleaseVersion via env: instead of inline macro expansion in
  bash, so an unset variable won't be interpreted as command substitution.

* Build: source npmPrereleaseVersion via dependencies, not dependsOn

Switches the variable mapping from stageDependencies (which needs Build
in dependsOn) to dependencies.Build.outputs[...], matching the pattern
the stage's condition already uses on line 941. Avoids drawing a
redundant parallel arrow from Build to Deploy_Npm in the ADO stage
graph — Build is already in the ancestor chain via Deploy_NuGet.

* Build: align Deploy_Npm with Umbraco Deploy publish pattern

- Use stageDependencies form in variables: (dependencies.* only works in conditions).
- Source NBGV_PrereleaseVersionNoLeadingHyphen for a cleaner check.
- Replace echo >> .npmrc with npm config set --location=project.
- Collapse if/else into a tag=latest|next shell variable; single npm publish *.tgz.
- Drop unnecessary env: passthrough and npm init -y.

Per Ronald's feedback on PR #22909 — mirrors the Deploy pipeline's release stage.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-17 14:00:19 +02:00
7fdf9c12f3 Build: tag prerelease npm publishes with 'next' dist-tag (#22909)
* Build: tag prerelease npm publishes with 'next' dist-tag

Prereleases that flow through Deploy_Npm (e.g. 18.0.0-beta1) currently
land on the `latest` dist-tag, so a bare `npm install @umbraco-cms/backoffice`
resolves to an unstable build. Switch to `--tag next` when
NBGV_PrereleaseVersion is non-empty, leaving `latest` for stable releases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Build: address review feedback on npm prerelease dist-tag

- Add Build to Deploy_Npm dependsOn so stageDependencies.Build.A.outputs
  resolves explicitly (mirrors the Upload_API_Docs pattern).
- Pass npmPrereleaseVersion via env: instead of inline macro expansion in
  bash, so an unset variable won't be interpreted as command substitution.

* Build: source npmPrereleaseVersion via dependencies, not dependsOn

Switches the variable mapping from stageDependencies (which needs Build
in dependsOn) to dependencies.Build.outputs[...], matching the pattern
the stage's condition already uses on line 941. Avoids drawing a
redundant parallel arrow from Build to Deploy_Npm in the ADO stage
graph — Build is already in the ancestor chain via Deploy_NuGet.

* Build: align Deploy_Npm with Umbraco Deploy publish pattern

- Use stageDependencies form in variables: (dependencies.* only works in conditions).
- Source NBGV_PrereleaseVersionNoLeadingHyphen for a cleaner check.
- Replace echo >> .npmrc with npm config set --location=project.
- Collapse if/else into a tag=latest|next shell variable; single npm publish *.tgz.
- Drop unnecessary env: passthrough and npm init -y.

Per Ronald's feedback on PR #22909 — mirrors the Deploy pipeline's release stage.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-17 13:59:17 +02:00
Ronald BarendseandAndy Butland e2252a8634 Tests: Remove AutoFixture from the shipped Umbraco.Cms.Tests package (#23141)
Tests: Remove AutoFixture from Umbraco.Cms.Tests package
2026-06-17 09:50:01 +02:00
Ronald BarendseandGitHub 190e3d373a Tests: Remove AutoFixture from the shipped Umbraco.Cms.Tests package (#23141)
Tests: Remove AutoFixture from Umbraco.Cms.Tests package
2026-06-17 09:47:22 +02:00
dbecec3451 Cache: Populate the domain cache eagerly during start-up (#23139)
* Populate the domain cache eagerly during start-up

* Added extension method to encapsulate and test logic for skipping startup seeding.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-16 13:19:09 +00:00
Andy ButlandandGitHub d92e6bbeff Tiptap: Preserve wrapping link when editing an image (closes #23013) (#23019)
* Retain surrounding link when editing image details in rich text editor.

* Add JSDocs.
2026-06-16 11:06:45 +01:00
e56ddcc3f9 Tiptap RTE: Fixes toolbar button active state not updating on collapsed-cursor mark toggle (closes #22907) (#22929)
* update tiptap event listneres

* Tiptap: Add regression test for toolbar button active state on collapsed-cursor toggle (closes #22907)

Tests verify the `transaction` listener wiring that fixes the stored-mark active-state bug.

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-06-16 09:56:42 +00:00
Laura NetoandGitHub 84a1ca8335 SonarCloud: Improve workflow (#23080)
* SonarCloud: allow unit test failures without failing the analysis

Test failures should not block SonarCloud analysis - coverage data is
still collected by dotnet-coverage regardless of test outcome. The
regular CI pipeline is the correct gate for test pass/fail.

* SonarCloud: install Java 21 explicitly and skip JRE provisioning

- Add actions/setup-java@v5 (temurin-21) so JAVA_HOME always points to Java 21
- Pass sonar.scanner.skipJreProvisioning=true in the begin command since Java 21 is installed explicitly, removing the need for the scanner to download a JRE at runtime

* SonarCloud: clear SONARQUBE_SCANNER_PARAMS after begin

Prevents the End analysis step from re-applying sonar params that begin already wrote to the analysis config, eliminating the "Ignoring property from env variable" warning.

* SonarCloud: always cancel in-progress runs on new push

* TEMP: add failing test to verify pipeline resilience — revert before merge

* Revert "TEMP: add failing test to verify pipeline resilience — revert before merge"

This reverts commit 828a7510cb.

* Revert "SonarCloud: clear SONARQUBE_SCANNER_PARAMS after begin"

This reverts commit 1911b65db1.

* Make SonarCloud workflow resilient to build and test failures

* Fix inaccurate warning message when unit tests fail

* Revert build step resilience, keep test failure warning

* Improve test failure warning with coverage file check

* Temporary: add failing test to verify SonarCloud workflow resilience

* Revert "Temporary: add failing test to verify SonarCloud workflow resilience"

This reverts commit 71ecd61034.
2026-06-16 09:31:50 +00:00
fb8c3b19ce Backoffice: Fix core circular import (fetchAllPages) breaking the test check (#23133)
Backoffice: Move fetchAllPages into the repository module to break a core circular import

#22765 added the offset pagination helper `fetchAllPages` under
`@umbraco-cms/backoffice/utils`, but its contract is expressed entirely in
repository-owned types (`UmbDataSourceResponse<UmbPagedModel<T>>`). That made
`utils` import `repository` while `repository` already imports `utils`,
introducing a 17th core bidirectional module import and tripping
`check:module-dependencies` (threshold 16) — failing the `test` job on every
open PR.

Relocate the helper (and its test) into the `repository` module, which
legitimately owns those types, and export it from
`@umbraco-cms/backoffice/repository`. The sole consumer
(UmbLanguageCollectionRepository) already imports from that module. Core
bidirectional imports are back to 16.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 09:51:36 +01:00
Andy Butland b940b27ad2 Updated OpenApi.json. 2026-06-16 10:09:54 +02:00
Andy Butland d416d352e0 Merge branch 'v17/dev' 2026-06-16 09:26:33 +02:00
Andy ButlandandGitHub 7a7aadffe5 User Groups: Clear the user group cache when a language is deleted (closes #23121) (#23131)
* Ensure user group cache is cleared on language delete.

* Apply code review feedback.
2026-06-16 08:47:46 +02:00
Andy ButlandandGitHub 0ac6e8500a Background Jobs: Recover cache-sync and server-touch jobs when a database call hangs (closes #23106) (#23119)
* Recover cache-sync job if database Sync() hangs.

* Apply also to TouchServerJob.

* Addressed code review comments.

* Added debug logging to allow monitorring of job runs.

* Added tests verifying that jobs resume after an inflight call completes.
2026-06-16 08:01:14 +02:00
4c35c0c2e9 Management API: Add endpoints to sort the children of a document or media item by a system field (#23077)
* Add endpoints for sorting documents and media by system fields.

* Addressed code review feedback.

* Persist sort-children-by-field with a single set-based update.

* Addressed second round of code review feedback.

* DRYed up similar code, improved comments.

* Split tests into individual class files

* Added test for combined sort of invariant and variant children.

* Addressed further code review feedback.

* Include test scenario from #23128 for SortChildren()

* Renamed children authorizer as it is generic, not specific for sorting - and updated XML docs accordingly

---------

Co-authored-by: kjac <kja@umbraco.dk>
2026-06-16 05:29:47 +00:00
Nhu DinhandGitHub a15d340608 E2E: QA Added acceptance tests for published element extensions (#23030)
* Added api helper for creating element type with compositions

* Added api helper for creating template with displaying element picker

* Added tests for published element extensions

* Make tests run in the pipeline

* Fixed suggestions

* Fixed comment

* Reverted npm command
2026-06-16 03:29:45 +00:00
Andy ButlandandGitHub de47e0b1f7 Content/Media: Reload content on Sort to avoid data loss with partially loaded entities (closes #23120) (#23128)
Ensure calling sort for content or media with partially loaded entities doesn't lose the unloaded data on persistence.
2026-06-15 19:31:34 +02:00
Andy Butland 969ae87798 Added TODO to adjust the ScheduledPublishingSettings.AlignToClock default to true. 2026-06-15 16:39:23 +02:00
Andy Butland 37e2458475 Merge branch 'release/18.0' of https://github.com/umbraco/Umbraco-CMS into release/18.0 2026-06-15 16:20:46 +02:00
Andy Butland 2461853b11 Published Cache: Fix multi-site domains falling back to the first root node after restart (#23084)
* Prevent empty domain cache during concurrent initialization.

* Addressed code review comments and added further comment to the code.

* Use Lock object.
2026-06-15 16:20:11 +02:00
Andy Butland fe3318ef79 Published Cache: Fix multi-site domains falling back to the first root node after restart (#23084)
* Prevent empty domain cache during concurrent initialization.

* Addressed code review comments and added further comment to the code.

* Use Lock object.
2026-06-15 16:19:24 +02:00
Andy ButlandandGitHub 828e359666 Languages: Page through to load all configured languages (#22765)
* Ensure all languages are retrieved handling rare (theoretical?) case where the number of languages exceeds the default page size.

* Addressed code review feedback.

* Addressed further code review feedback.
2026-06-15 16:17:30 +02:00
a5b7e0dac1 Scheduled publishing: Add configurable period and optional clock-aligned scheduling (#23127)
* Add configurable period for scheduled publishing task with optional clock alignment.

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Addressed code review comments.

* Clarified the maths, improved comments and test coverage.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-15 13:48:34 +00:00
Nicklas KramerandGitHub 9fe9469e3a Integration Tests: Adjusting Status Codes for Failing Tests (#23125)
Adjusting expected status codes
2026-06-15 15:39:47 +02:00
16c97d613f Elements: Invalidate the id/key map when an element container is deleted (closes #23072) (#23074)
* Refresh the element container cache on delete to ensure the id/key map is invalidated.

* Rename and additional asserts in test.

* Use a dedicated refresher for element container id/key map eviction

Routing container-delete invalidation through ElementCacheRefresher cleared
the entire elements cache on every payload, so deleting a container triggered
a full clear even though no element data changed (and a second clear on top of
the ElementTreeChangeNotification refresh when the container held elements).

Add a dedicated ElementContainerCacheRefresher whose only job is to evict the
container's IIdKeyMap entry, and route EntityContainerDeletedNotification
through it instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Addressed code review feedback.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:16:31 +02:00
Nhu DinhandGitHub 5a6b18307d E2E: QA Added acceptance tests for broken publish path in legacy routing (#23090)
* Added ui helper for does not contain text

* Added constant for cannot be routed in content

* Added ui helper for verify document does not contain link

* Added api helper for unpublish document and publish chain of document

* Added api helper for updating document type

* Added tests for handling broken publish path in backoffice

* Added tests for handling broken publish path in delivery API

* Make tests run in the pipeline

* Clean up

* Fixed comments

* Reverted npm command
2026-06-15 09:55:26 +00:00
Nhu DinhandGitHub 3ee7b142b3 E2E: Fixed failing acceptance tests for user group, backoffice search and content picker (#23108)
* Updated backoffice search element tests to match the test helper changes

* Added step to delete user group before deleting language

* Updated tests for element folder permission due to recent changes

* Fix failing tests for content picker and element with element picker due to UI changes
2026-06-15 08:57:13 +00:00
Andy Butland ae92b0b0a9 Fixed failing unit test. 2026-06-15 07:07:51 +02:00
Andy Butland 8503762431 Merge branch 'v17/dev' 2026-06-15 06:49:06 +02:00
Andy ButlandandGitHub 86abc3528d Request Logging: Avoid forcing a session store load when resolving the session id for logging (closes #23082) (#23083)
* Guard read of session ID for log enrichment by presence of session cookie.

* Renamed tests.

* Add configurable option for session ID logging, retaining backward compatibility but giving options to skip session Id logging or use a cookie hash.
2026-06-15 06:47:59 +02:00
Andy Butland 97a005e709 Merge branch 'release/18.0' 2026-06-15 06:42:11 +02:00
Andy Butland 30b4370044 Merge branch 'v17/dev' 2026-06-15 06:40:59 +02:00
Andy Butland cffac2a990 Dependencies: Update MessagePack to 3.1.7 to address security advisories (#23113)
Update MessagePack dependency to 3.1.7.
2026-06-15 06:37:13 +02:00
Andy Butland 7433641348 Dependencies: Update MessagePack to 3.1.7 to address security advisories (#23113)
Update MessagePack dependency to 3.1.7.
2026-06-15 06:35:25 +02:00
Andy ButlandandGitHub c45b12ec58 Dependencies: Update MessagePack to 3.1.7 to address security advisories (#23113)
Update MessagePack dependency to 3.1.7.
2026-06-15 06:34:57 +02:00
Kenn JacobsenandGitHub 6883c6fcfd Tags: Expand ITagService to handle Elements (#23117) 2026-06-15 06:28:52 +02:00
Andy ButlandandGitHub 22c4bc7835 Package migrations: Surface a failed unattended package migration as a boot failure instead of getting stuck on the upgrade screen (#23114)
* Surface a package migration exception as a boot failure, avoiding being stuck in an upgrading state.

* Addressed code review feedback.

* Fix failing integration tests.
2026-06-15 11:58:58 +09:00
Andy ButlandandGitHub 1e82376420 Skills: Add umb-release-notes skill for improving generated release notes (#23112)
* Add skill to help with improving the generated GitHub release notes.

* Addressed skill review feedback.
2026-06-15 11:41:57 +09:00
Andy Butland e8586493e2 Merge branch 'v17/dev' 2026-06-13 15:42:41 +02:00
d5d0ce68aa Property editors: Add configuration and validation of allowed types for element picker, document picker, media picker (#23026)
* add allowed type for element picker

* update validation and unit test

* remove redundant code

* update tests name

* revert code GetReferences

* remove un-using code and add more check value

* remove redundant param

* add allowed type for content picker, update validation

* add min max validation into element and its unit test

* update media picker validation

* split validation runner into other class

* update SystemTextJsonSerializerBase back to old code

* update unit tests

* Resolved some code warnings.

* Remove accidentally committed file

* Introduce ITypedValidator and obsolete ITypeJsonValidator to better reflect validators that may or may not contain JSON editor values.

* Extraced ParseAllowedContentTypeKeys into a common helper.

* Aligned parameters on AllowedTypeValidator.

* Align parsing of allowed type Ids on client between document and media pickers.

* Restored validation of media where provided key can't be retrieved.

* Aligned document, media and element configuration labels and weights.

* Added additional unit tests.

* Addressed code review comments.

* Resolved further code warnings and code tidy.

* Resolve potential binary breaking change concern with obsolete ITypedJsonValidator.

* Reuse shared DocumentTypePicker for picker allowed-types config.

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-13 13:30:34 +00:00
Zeegaan 764d4eb1d7 bump version 2026-06-11 14:02:32 +09:00
Mads RasmussenandGitHub aa854da3f4 Backoffice: Pre-expand tree to target entity when opening Duplicate and Move To modals (closes #22015) (#23063)
* Support tree expansion in generic Duplicate To modal

* Add expansion prop to tree picker modal types

* Apply expansion from data to picker context

* move to action: populate tree picker expansion with ancestors

* Pass tree expansion to duplicate document modal

* Extract ancestor fetching into private method

* Use UmbDocumentTreeRepository directly

* Exclude self from ancestor results

* make name more explicit

* Guard ancestor fetch and simplify expansion

* Only set treeExpansion when ancestors exist

* fix type issues

* Parallelize ancestor and pickable filter fetch

* Use getter for treeExpansion; remove unused imports
2026-06-09 16:53:37 +02:00
Andy ButlandandGitHub 5dd28e7a13 Tests: Fix failing Management API integration tests (closes #23076) (#23081)
* Include currently missing management API tests in the CI build.

* Fixed failing tests.

* Revert the pipeline updates.

* Addressed code review feedback.
2026-06-07 08:40:23 +02:00
Andy Butland d0fc7dc8a0 Tiptap RTE: Ignore no-op transactions in onUpdate to prevent phantom dirty state (closes #22767) (#22781)
Ignore Tiptap no-op transactions in onUpdate to prevent phantom dirty state.
2026-06-05 15:56:23 +02:00
Niels LyngsøandGitHub 87b24912c7 V18: Beta UI adjustments (#22869) 2026-06-05 15:10:54 +02:00
Andy Butland f6c70e8429 Bump version to 18.0.0-rc3. 2026-06-05 06:46:44 +02:00
Andy Butland 89baa9482b Background Jobs: Resolve server role so recurring jobs run when no application URL is configured (#23033)
Resolve server role when no application URL is configured.
2026-06-04 06:40:00 +02:00
323 changed files with 11516 additions and 1641 deletions
+135
View File
@@ -0,0 +1,135 @@
---
name: umb-release-notes
description: Improve a set of auto-generated GitHub release notes for an Umbraco CMS release. Cross-checks the notes against every PR carrying the release label, adds any that are missing, re-files every PR under the most appropriate category, and strips purely-internal entries. Use whenever the user asks to tidy up, improve, complete, or recategorize release notes for a given version, or mentions a release-notes text file plus a version number.
argument-hint: <version> <path-to-generated-notes-file>
---
# Umbraco CMS - Improve Release Notes
Takes a file of auto-generated GitHub release notes and produces an improved version that:
1. **Is complete** — every merged PR carrying the `release/<version>` label appears.
2. **Is well-categorized** — every PR sits under the most appropriate heading.
3. **Is free of noise** — purely-internal entries of no value to a reader are removed.
The result is written to a **new** file alongside the input, so the user can diff the two.
**Run autonomously.** Do NOT use `AskUserQuestion` once the required arguments (version and input file path) are available — only ask if one of them is missing from `$ARGUMENTS` and cannot be inferred (see Arguments). Beyond that, make the categorization calls yourself using the rules below; if a handful are genuinely borderline, place them anyway and note the borderline ones in your closing summary so the user can override.
## Arguments
`$ARGUMENTS` contains two values:
1. **Version** — e.g. `17.5.0`, `18.1.0`. The GitHub label to search is `release/<version>` (so version `17.5.0` → label `release/17.5.0`).
2. **Input file path** — full path to the text file holding the auto-generated notes (e.g. `C:\Temp\release-17.5.0-rc.md`).
If either is missing, ask the user once for the missing value, then proceed.
## Prerequisites
Run `gh auth status`. If it fails, tell the user to authenticate `gh` (e.g. `gh auth login`) and stop — the skill needs the GitHub CLI to query PRs. The repo is always `umbraco/Umbraco-CMS`.
## Procedure
### 1. Read the input notes
Read the input file. Note its structure — it is GitHub's generated format:
- A leading HTML comment (`<!-- Release notes generated ... -->`).
- A `## What's Changed` heading followed by `### <emoji> <Category>` sub-headings, each with `* <title> by @<author> in <url>` bullets.
- A trailing `## New Contributors` section and a `**Full Changelog**: ...` line.
Extract the set of PR numbers already present (parse the `/pull/<number>` from each bullet). Preserve each existing bullet's **exact text** (title, author, URL) when you re-emit it — only its category placement may change.
### 2. Fetch every labelled PR
```bash
gh pr list --repo umbraco/Umbraco-CMS --label "release/<version>" --state closed --limit 1000 \
--json number,title,author,labels,mergedAt \
--jq '.[] | select(.mergedAt != null) | "\(.number)\t\(.author.login)\t\([.labels[].name] | join(", "))\t\(.title)"' | sort -n
```
This is the authoritative list of what the release *should* contain. Each row gives number, author, labels, title.
**Guard against silent truncation.** `gh pr list` caps at `--limit` without warning, so a large release could drop the overflow and the skill would still look "complete". Count the returned rows and compare against the limit:
```bash
gh pr list --repo umbraco/Umbraco-CMS --label "release/<version>" --state closed --limit 1000 --json number --jq 'length'
```
If this equals 1000, the limit was hit — raise `--limit` and re-fetch before continuing. Do **not** proceed on a truncated list.
### 3. Reconcile
- **Missing labelled PRs** (labelled but not in the input file): these must be **added**. Build a bullet as `* <title> by @<author> in https://github.com/umbraco/Umbraco-CMS/pull/<number>`.
- **Author handle.** `<author>` in the template is the raw `.author.login` value — the bullet supplies the leading `@`, so do not prepend another. `gh`'s `.author.login` already returns bot accounts with the `[bot]` suffix as part of the login — Dependabot comes back as `dependabot[bot]`, not `dependabot` or `app/dependabot` (the `app/` form only appears in git committer metadata and CODEOWNERS, never in `gh`'s JSON). So the login is already in the right shape; use it verbatim (e.g. `.author.login` of `dependabot[bot]` renders as `@dependabot[bot]`, matching what GitHub's generator wrote for the existing bullets). The only thing to guard against is accidentally stripping or altering the `[bot]` suffix.
- **PRs in the file but not labelled**: keep them. The generated notes span a commit range (see the `Full Changelog` compare link), so they legitimately include backports / earlier-version PRs that lack the current label. For any of these you need to categorize, fetch its labels with:
```bash
gh pr view <number> --repo umbraco/Umbraco-CMS --json number,title,labels \
--jq '"\(.number)\t\([.labels[].name] | join(", "))\t\(.title)"'
```
Do **not** invent or alter the `New Contributors` section — carry it over verbatim. You cannot reliably recompute first-time contributors, so leave it as the generator produced it (mention this in the summary).
### 4. Categorize every PR
Use exactly these headings, in this order. Omit any heading that ends up with no entries.
| Heading | What goes here | Primary signal |
|---|---|---|
| `### 🙌 Notable Changes` | **Don't recategorize existing entries.** Label-driven — but still add any missing PR carrying this label here. | label `category/notable` |
| `### 💥 Breaking Changes` | **Don't recategorize existing entries.** Label-driven — but still add any missing PR carrying this label here. | label `category/breaking` |
| `### 📦 Dependencies` | Dependency bumps | label `dependencies`; or dependabot author |
| `### 🚀 New Features` | New user- or developer-facing capability | label `type/feature` / `category/feature`; or title introduces/adds a genuinely new capability |
| `### 🚤 Performance` | Performance improvements | label `category/performance`; or `Performance:` title prefix |
| `### 🌈 Accessibility Improvements` | A11y improvements (labels, contrast, keyboard) | label `category/accessibility` / `accessibility`; or clear a11y intent (e.g. "improve contrast", "missing labels") |
| `### 🐛 Bug Fixes` | Fixes to broken/incorrect behaviour | default for anything describing a fix |
| `### 🧪 Testing` | Test additions/changes only | label `category/test-automation` / `area/test`; or `E2E`/`QA`/"acceptance tests"/"unit test coverage"/"add tests" titles |
| `### 🛡️ Code Quality, Documentation and Refactoring` | Refactors, deprecations, API tidy-ups, XML/MD documentation, knowledge-base (`MD`) updates | label `category/refactor`; or titles about refactoring, deprecating, renaming, documenting, constants extraction, MD/CLAUDE.md content |
| `### 🧑‍💻 Developer Experience` | Things that improve the experience of developers building on or contributing to Umbraco — dev tooling, build/watch ergonomics, test mocks/harnesses, backoffice dev utilities | `Developer Experience` title prefix; dev tooling; mock/harness changes |
**Rules:**
- **Notable and Breaking are off-limits for recategorization** — never move a PR that is *already in the input file* into or out of these sections; they are driven purely by their labels and the generator placed them correctly. This does **not** exempt them from completeness: a PR discovered as missing in step 3 that carries `category/notable` or `category/breaking` must still be **added** under the matching section.
- Label signals beat title wording, except a `Performance:`/`Developer Experience:` title prefix is decisive for its section.
- A PR with both `type/feature` and `category/refactor` whose title clearly describes a refactor (e.g. "swap relative imports", "re-export type") belongs under Code Quality, not New Features.
- "Add ... tests"/"unit test coverage" → Testing, even if it also touches docs. If a PR adds XML documentation *and* tests, lead with where the title's emphasis lies (documentation → Code Quality; test coverage → Testing).
- When a PR is genuinely 50/50, pick the more reader-useful heading and list it in your closing summary as borderline.
### 5. Remove purely-internal noise
Drop entries that have **no value to anyone reading release notes** — pure repository plumbing with no shipped impact. Examples:
- Branch/merge maintenance ("Fix main branch after merge issue").
- CI/pipeline fixes that don't change the product.
- Reverts of changes that never shipped in a release.
**Keep** anything that ships in the product or genuinely helps developers building on Umbraco — that includes documentation/MD updates, dev tooling, and test mocks (those go to Code Quality or Developer Experience, they are *not* noise). When unsure whether something is noise, keep it and flag it in the summary rather than silently dropping it. List every removal in your closing summary.
### 6. Write the output
Write to a new file in the **same folder** as the input, named by appending ` - with updates` before the extension:
- Input `C:\Temp\release-17.5.0-rc.md` → Output `C:\Temp\release-17.5.0-rc - with updates.md`
Preserve the leading HTML comment, the `## What's Changed` heading, the `## New Contributors` section, and the `**Full Changelog**` line exactly. Only the `### <category>` groupings and their bullets change.
### 7. Report
Give a concise summary:
- Count of PRs added (with their numbers), and which categories they landed in.
- Notable recategorizations (PRs moved out of the catch-all Bug Fixes into Features/Performance/Testing/etc.).
- Every entry removed, with the one-line reason.
- Any borderline calls the user may want to override.
- The output file path.
## Verification
Before reporting done, confirm:
- Every PR number from step 2 is present in the output (except any you deliberately removed in step 5 — and those must be in the removal list).
- No PR appears under more than one heading.
- Notable and Breaking sections are byte-for-byte unchanged from the input.
- The header comment, New Contributors, and Full Changelog lines are intact.
+20 -2
View File
@@ -23,7 +23,7 @@ env:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: true
jobs:
analyze:
@@ -38,6 +38,12 @@ jobs:
- name: Setup .NET from global.json
uses: actions/setup-dotnet@v5
- name: Setup Java 21
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "21"
- name: Cache SonarQube packages
uses: actions/cache@v5
with:
@@ -60,7 +66,8 @@ jobs:
dotnet-sonarscanner begin \
/k:"$SONAR_PROJECT_KEY" \
/o:"$SONAR_ORGANIZATION" \
/d:sonar.token="$SONAR_TOKEN"
/d:sonar.token="$SONAR_TOKEN" \
/d:sonar.scanner.skipJreProvisioning=true
- name: Restore
run: dotnet restore umbraco.sln
@@ -69,12 +76,23 @@ jobs:
run: GITHUB_ENV=/dev/null dotnet build umbraco.sln --no-restore -clp:ErrorsOnly # prevent sonar MSBuild integration from writing malformed values to $GITHUB_ENV
- name: Run unit tests with coverage
id: tests
continue-on-error: true
run: |
dotnet-coverage collect \
"dotnet test tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj --no-build" \
--output TestResults/coverage.xml \
--output-format xml
- name: Warn on test failure
if: steps.tests.outcome == 'failure'
run: |
if [ -f TestResults/coverage.xml ]; then
echo "::warning::Unit tests failed - SonarCloud analysis will proceed with the collected coverage data"
else
echo "::warning::Unit tests failed and no coverage data was collected"
fi
- name: End analysis
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+2
View File
@@ -558,6 +558,8 @@ Allowed, but cheap to write and cheaper to leave behind. Keep them short and tra
Verify any test you add for a bug fix actually catches the bug: either write the failing test first (TDD), or temporarily revert the production change and confirm the test fails before re-applying. A test that passes both ways proves nothing. Watch for coincidental passes — default seed/sort orders can make a buggy path produce the right answer for the test's specific inputs; construct inputs so the broken and fixed behaviours give visibly different results.
For integration tests that exercise caching or cache refreshers, see `tests/Umbraco.Tests.Integration/CLAUDE.md` — the harness disables caching by default, which can produce false greens.
---
## Quick Reference
+1 -1
View File
@@ -40,7 +40,7 @@
<!-- Package Validation -->
<PropertyGroup>
<GenerateCompatibilitySuppressionFile>false</GenerateCompatibilitySuppressionFile>
<EnablePackageValidation>false</EnablePackageValidation> <!-- TODO (V18): Set to true once this version is released. -->
<EnablePackageValidation>true</EnablePackageValidation>
<PackageValidationBaselineVersion>18.0.0</PackageValidationBaselineVersion>
<EnableStrictModeForCompatibleFrameworksInPackage>true</EnableStrictModeForCompatibleFrameworksInPackage>
<EnableStrictModeForCompatibleTfms>true</EnableStrictModeForCompatibleTfms>
+1 -1
View File
@@ -57,7 +57,7 @@
<PackageVersion Include="MailKit" Version="4.16.0" />
<PackageVersion Include="Markdig" Version="1.1.3" />
<PackageVersion Include="Markdown" Version="2.2.1" />
<PackageVersion Include="MessagePack" Version="3.1.4" />
<PackageVersion Include="MessagePack" Version="3.1.7" />
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
<PackageVersion Include="MiniProfiler.Shared" Version="4.5.4" />
<PackageVersion Include="ncrontab" Version="3.4.0" />
+32 -98
View File
@@ -825,74 +825,31 @@ stages:
publishFeedCredentials: "MyGet - Umbraco Nightly"
${{ else }}:
publishFeedCredentials: "MyGet - Pre-releases"
# Pre-release/nightly feeds: keep the `latest` dist-tag default (no `next` split).
- job:
displayName: Push to pre-release feed (npm)
steps:
- checkout: none
- download: current
artifact: npm
- bash: |
# Check if we are on a nightly build
if [ $isNightly = "False" ]; then
echo "##[debug]Prerelease build detected"
registry="https://www.myget.org/F/umbracoprereleases/npm/"
else
echo "##[debug]Nightly build detected"
registry="https://www.myget.org/F/umbraconightly/npm/"
fi
echo "@umbraco-cms:registry=$registry" >> .npmrc
env:
isNightly: ${{parameters.isNightly}}
workingDirectory: $(Pipeline.Workspace)/npm
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm (MyGet)
inputs:
workingFile: "$(Pipeline.Workspace)/npm/.npmrc"
- template: templates/npm-publish.yml
parameters:
artifactName: npm
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
- bash: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push to npm (MyGet)
workingDirectory: $(Pipeline.Workspace)/npm
displayName: Push to npm (MyGet)
${{ if eq(parameters.isNightly, true) }}:
registry: https://www.myget.org/F/umbraconightly/npm/
${{ else }}:
registry: https://www.myget.org/F/umbracoprereleases/npm/
- job: PublishTestHelpersNpm
displayName: Push TestHelpers to pre-release feed (npm)
steps:
- checkout: none
- download: current
artifact: npm-testhelpers
- bash: |
# Check if we are on a nightly build
if [ $isNightly = "False" ]; then
echo "##[debug]Prerelease build detected"
registry="https://www.myget.org/F/umbracoprereleases/npm/"
else
echo "##[debug]Nightly build detected"
registry="https://www.myget.org/F/umbraconightly/npm/"
fi
echo "@umbraco-cms:registry=$registry" >> .npmrc
env:
isNightly: ${{parameters.isNightly}}
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm (MyGet)
inputs:
workingFile: "$(Pipeline.Workspace)/npm-testhelpers/.npmrc"
- template: templates/npm-publish.yml
parameters:
artifactName: npm-testhelpers
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
- bash: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push test helpers to npm (MyGet)
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
displayName: Push test helpers to npm (MyGet)
${{ if eq(parameters.isNightly, true) }}:
registry: https://www.myget.org/F/umbraconightly/npm/
${{ else }}:
registry: https://www.myget.org/F/umbracoprereleases/npm/
- stage: Deploy_NuGet
displayName: NuGet release
@@ -941,53 +898,30 @@ stages:
condition: and(in(dependencies.Deploy_NuGet.result, 'Succeeded', 'SucceededWithIssues'), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.npmDeploy}}))
dependsOn:
- Deploy_NuGet
variables:
# `latest` for stable releases, `next` for prereleases.
npmDistTag: $[ iif(eq(stageDependencies.Build.A.outputs['build.NBGV_PrereleaseVersionNoLeadingHyphen'], ''), 'latest', 'next') ]
jobs:
- job: Publish
displayName: Push to NPM
steps:
- checkout: none
- download: current
artifact: npm
- bash: echo "@umbraco-cms:registry=https://registry.npmjs.org" >> .npmrc
workingDirectory: $(Pipeline.Workspace)/npm
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm
inputs:
workingFile: $(Pipeline.Workspace)/npm/.npmrc
- template: templates/npm-publish.yml
parameters:
artifactName: npm
registry: https://registry.npmjs.org/
customEndpoint: "NPM - Umbraco Backoffice"
- script: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push to npm
workingDirectory: $(Pipeline.Workspace)/npm
displayName: Push to npm
npmTag: $(npmDistTag)
- job: PublishTestHelpers
displayName: Push Test Helpers to NPM
steps:
- checkout: none
- download: current
artifact: npm-testhelpers
- bash: echo "@umbraco-cms:registry=https://registry.npmjs.org" >> .npmrc
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm
inputs:
workingFile: $(Pipeline.Workspace)/npm-testhelpers/.npmrc
- template: templates/npm-publish.yml
parameters:
artifactName: npm-testhelpers
registry: https://registry.npmjs.org/
customEndpoint: "NPM - Umbraco Backoffice"
- script: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push test helpers to npm
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
displayName: Push test helpers to npm
npmTag: $(npmDistTag)
- stage: Upload_API_Docs
pool:
+28
View File
@@ -0,0 +1,28 @@
parameters:
- name: artifactName # "npm" or "npm-testhelpers"
type: string
- name: registry # scoped-registry URL to publish to
type: string
- name: customEndpoint # npmAuthenticate service connection(s)
type: string
- name: displayName # label for the publish step
type: string
- name: npmTag # dist-tag to publish under
type: string
default: latest
steps:
- checkout: none
- download: current
artifact: ${{ parameters.artifactName }}
- script: npm config set @umbraco-cms:registry ${{ parameters.registry }} --location=project
displayName: Add scoped registry to .npmrc
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
- task: npmAuthenticate@0
displayName: Authenticate with npm
inputs:
workingFile: $(Pipeline.Workspace)/${{ parameters.artifactName }}/.npmrc
customEndpoint: ${{ parameters.customEndpoint }}
- script: npm publish *.tgz --tag ${{ parameters.npmTag }}
displayName: ${{ parameters.displayName }}
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
@@ -59,6 +59,7 @@ public static class UmbracoBuilderApiExtensions
string? jsonOptionsName = null)
where TConfigureOptions : ConfigureUmbracoOpenApiOptionsBase
{
apiName = apiName.ToLowerInvariant();
builder.Services.AddOpenApi(apiName);
builder.Services.ConfigureOptions<TConfigureOptions>();
builder.Services.AddOpenApiDocumentToUi(apiName, apiTitle);
@@ -2,9 +2,9 @@ using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Api.Common.Attributes;
using Umbraco.Cms.Api.Common.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Common.OpenApi;
@@ -116,12 +116,20 @@ public sealed class BackOfficeOpenApiDocumentBuilder
/// <param name="builder">The Umbraco builder to register services against.</param>
internal void Build(IUmbracoBuilder builder)
{
// AddOpenApi lowercases the document name when registering its keyed services (https://github.com/dotnet/aspnetcore/blob/v10.0.9/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs#L64),
// so we must normalise here to keep AddOpenApiDocumentToUi and ReplaceOpenApiSchemaService in sync.
string lowercasedDocumentName = DocumentName.ToLowerInvariant();
builder.Services.AddOpenApi(
DocumentName,
lowercasedDocumentName,
options =>
{
// ShouldInclude matches [MapToApi] case-insensitively to align with how documents are registered.
options.ShouldInclude = apiDescription =>
apiDescription.ActionDescriptor.HasMapToApiAttribute(DocumentName);
apiDescription.ActionDescriptor.EndpointMetadata
?.OfType<MapToApiAttribute>()
.Any(a => a.ApiName.Equals(DocumentName, StringComparison.OrdinalIgnoreCase))
?? false;
options.CreateSchemaReferenceId = UmbracoSchemaIdGenerator.CreateSchemaReferenceId;
@@ -158,12 +166,12 @@ public sealed class BackOfficeOpenApiDocumentBuilder
if (_includedInUi)
{
builder.Services.AddOpenApiDocumentToUi(DocumentName, _uiTitle ?? _title);
builder.Services.AddOpenApiDocumentToUi(lowercasedDocumentName, _uiTitle ?? _title ?? DocumentName);
}
if (_httpJsonOptionsFactory is not null)
{
builder.Services.ReplaceOpenApiSchemaService(DocumentName, _httpJsonOptionsFactory);
builder.Services.ReplaceOpenApiSchemaService(lowercasedDocumentName, _httpJsonOptionsFactory);
}
}
}
@@ -0,0 +1,100 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Document;
/// <summary>
/// Provides an API endpoint for sorting the root-level documents by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenAtRootDocumentController : DocumentControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IContentEditingService _contentEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenAtRootDocumentController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="contentEditingService">Service for editing and managing content.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
public SortChildrenAtRootDocumentController(
IAuthorizationService authorizationService,
IContentEditingService contentEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_contentEditingService = contentEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the root-level documents by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds or <c>400 Bad Request</c> if the field is not recognised.
/// </returns>
[HttpPut("root/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[EndpointSummary("Sorts the root-level documents by a field.")]
[EndpointDescription("Sorts the root-level documents by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so documents that do not vary by it (or an unrecognised culture) fall back to the invariant name.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, SortDocumentChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionSort.ActionLetter, (Guid?)null),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
parentKey: null,
UmbracoObjectTypes.Document,
childKeys => ContentPermissionResource.WithKeys(ActionSort.ActionLetter, childKeys),
AuthorizationPolicies.ContentPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _contentEditingService.SortByFieldAsync(
null,
requestModel.Field,
requestModel.Direction,
requestModel.Culture,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(result);
}
}
@@ -0,0 +1,102 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Document;
/// <summary>
/// Provides an API endpoint for sorting the children of a document by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenDocumentController : DocumentControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IContentEditingService _contentEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenDocumentController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="contentEditingService">Service for editing and managing content.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
public SortChildrenDocumentController(
IAuthorizationService authorizationService,
IContentEditingService contentEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_contentEditingService = contentEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the child documents of the specified parent document by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="id">The unique identifier of the parent document whose children should be sorted.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds, <c>400 Bad Request</c> if the field is not recognised, or <c>404 Not Found</c> if the parent document does not exist.
/// </returns>
[HttpPut("{id:guid}/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Sorts the children of a document by a field.")]
[EndpointDescription("Sorts the children of the specified parent document by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so children that do not vary by it (or an unrecognised culture) fall back to the invariant name.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, Guid id, SortDocumentChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionSort.ActionLetter, id),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
id,
UmbracoObjectTypes.Document,
childKeys => ContentPermissionResource.WithKeys(ActionSort.ActionLetter, childKeys),
AuthorizationPolicies.ContentPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _contentEditingService.SortByFieldAsync(
id,
requestModel.Field,
requestModel.Direction,
requestModel.Culture,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(result);
}
}
@@ -0,0 +1,98 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Media;
/// <summary>
/// Provides an API endpoint for sorting the root-level media items by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenAtRootMediaController : MediaControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IMediaEditingService _mediaEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenAtRootMediaController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="mediaEditingService">Service responsible for editing and sorting media items.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for backoffice security context.</param>
public SortChildrenAtRootMediaController(
IAuthorizationService authorizationService,
IMediaEditingService mediaEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_mediaEditingService = mediaEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the root-level media items by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds or <c>400 Bad Request</c> if the field is not recognised.
/// </returns>
[HttpPut("root/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[EndpointSummary("Sorts the root-level media items by a field.")]
[EndpointDescription("Sorts the root-level media items by a system field in the given direction.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, SortMediaChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
MediaPermissionResource.Root(),
AuthorizationPolicies.MediaPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
parentKey: null,
UmbracoObjectTypes.Media,
childKeys => MediaPermissionResource.WithKeys(childKeys),
AuthorizationPolicies.MediaPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _mediaEditingService.SortByFieldAsync(
null,
requestModel.Field,
requestModel.Direction,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(result);
}
}
@@ -0,0 +1,100 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Media;
/// <summary>
/// Provides an API endpoint for sorting the children of a media item by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenMediaController : MediaControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IMediaEditingService _mediaEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenMediaController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="mediaEditingService">Service responsible for editing and sorting media items.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for backoffice security context.</param>
public SortChildrenMediaController(
IAuthorizationService authorizationService,
IMediaEditingService mediaEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_mediaEditingService = mediaEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the child media items of the specified parent media item by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="id">The unique identifier of the parent media item whose children should be sorted.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds, <c>400 Bad Request</c> if the field is not recognised, or <c>404 Not Found</c> if the parent media item does not exist.
/// </returns>
[HttpPut("{id:guid}/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Sorts the children of a media item by a field.")]
[EndpointDescription("Sorts the children of the specified parent media item by a system field in the given direction.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, Guid id, SortMediaChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
MediaPermissionResource.WithKeys(id),
AuthorizationPolicies.MediaPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
id,
UmbracoObjectTypes.Media,
childKeys => MediaPermissionResource.WithKeys(childKeys),
AuthorizationPolicies.MediaPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _mediaEditingService.SortByFieldAsync(
id,
requestModel.Field,
requestModel.Direction,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(result);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,64 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Security.Authorization;
/// <summary>
/// Authorizes permissions on all direct children of a node.
/// </summary>
internal static class AllChildrenAuthorizer
{
/// <summary>
/// Determines whether the user is authorized for every direct child of the given parent (or the root).
/// </summary>
/// <param name="authorizationService">The authorization service.</param>
/// <param name="entityService">The entity service used to resolve the children.</param>
/// <param name="user">The current user.</param>
/// <param name="parentKey">The parent key, or <c>null</c> to authorize the root-level children.</param>
/// <param name="objectType">The object type of the children (and parent).</param>
/// <param name="resourceFactory">Builds the permission resource to authorize a batch of child keys against.</param>
/// <param name="policy">The authorization policy to apply.</param>
/// <returns><c>true</c> if the user is authorized against all children; otherwise <c>false</c>.</returns>
public static async Task<bool> IsAuthorizedForChildrenAsync(
IAuthorizationService authorizationService,
IEntityService entityService,
ClaimsPrincipal user,
Guid? parentKey,
UmbracoObjectTypes objectType,
Func<IEnumerable<Guid>, IPermissionResource> resourceFactory,
string policy)
{
const int pageSize = 500;
var page = 0;
long total;
do
{
Guid[] childKeys = entityService
.GetPagedChildren(parentKey, [objectType], objectType, page * pageSize, pageSize, out total)
.Select(child => child.Key)
.ToArray();
if (childKeys.Length > 0)
{
AuthorizationResult authorizationResult = await authorizationService.AuthorizeResourceAsync(
user,
resourceFactory(childKeys),
policy);
if (authorizationResult.Succeeded is false)
{
return false;
}
}
page++;
}
while (page * pageSize < total);
return true;
}
}
@@ -0,0 +1,21 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models.ContentEditing;
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
/// <summary>
/// Base request model for sorting the children of a node by a system field.
/// </summary>
public abstract class SortChildrenByFieldRequestModelBase
{
/// <summary>
/// Gets or sets the system field to sort the children by.
/// The create and update dates are node-level (not culture-specific).
/// </summary>
public required ContentSortField Field { get; init; }
/// <summary>
/// Gets or sets the direction to sort in.
/// </summary>
public required Direction Direction { get; init; }
}
@@ -0,0 +1,16 @@
using Umbraco.Cms.Core.Models.ContentEditing;
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
/// <summary>
/// Request model for sorting the children of a document by a system field.
/// </summary>
public class SortDocumentChildrenByFieldRequestModel : SortChildrenByFieldRequestModelBase
{
/// <summary>
/// Gets or sets the culture whose variant name to sort by, or <c>null</c> to sort by the invariant name.
/// Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a document that
/// does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.
/// </summary>
public string? Culture { get; init; }
}
@@ -0,0 +1,9 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
/// <summary>
/// Request model for sorting the children of a media item by a system field.
/// Media items do not vary by culture, so no culture is accepted.
/// </summary>
public class SortMediaChildrenByFieldRequestModel : SortChildrenByFieldRequestModelBase
{
}
@@ -59,7 +59,7 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
private int? _skipver;
private RoslynCompiler? _roslynCompiler;
private ModelsBuilderSettings _config;
private bool _disposedValue;
private volatile bool _disposedValue;
public InMemoryModelFactory(
Lazy<UmbracoServices> umbracoServices,
@@ -280,25 +280,34 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
}
}
// don't use an upgradeable lock here because only 1 thread at a time could enter it
try
// The factory is disposed on application shutdown (via IRegisteredObject.Stop), but in-flight
// requests can still reach this point. Bail out with the current models rather than touching
// the disposed lock. The catch below covers the small window where disposal happens after this
// check but before (or while) the lock is acquired.
if (_disposedValue)
{
_locker.EnterReadLock();
if (_hasModels)
{
return _infos;
}
}
finally
{
if (_locker.IsReadLockHeld)
{
_locker.ExitReadLock();
}
return _infos;
}
try
{
// don't use an upgradeable lock here because only 1 thread at a time could enter it
try
{
_locker.EnterReadLock();
if (_hasModels)
{
return _infos;
}
}
finally
{
if (_locker.IsReadLockHeld)
{
_locker.ExitReadLock();
}
}
_locker.EnterUpgradeableReadLock();
if (_hasModels)
@@ -359,6 +368,12 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
return _infos;
}
catch (ObjectDisposedException ex)
{
// Expected when the factory is disposed during shutdown mid-request; log so an unexpected disposal stays traceable.
_logger.LogDebug(ex, "EnsureModels interrupted by object disposal (assumed application shutdown); returning current models.");
return _infos;
}
finally
{
if (_locker.IsWriteLockHeld)
@@ -467,6 +467,20 @@ public static class DistributedCacheExtensions
#endregion
#region ElementContainerCacheRefresher
/// <summary>
/// Invalidates the id/key map for the specified deleted element containers (folders).
/// </summary>
/// <param name="dc">The distributed cache.</param>
/// <param name="deletedContainers">The element containers that were deleted.</param>
public static void RemoveElementContainerCache(this DistributedCache dc, IEnumerable<EntityContainer> deletedContainers)
=> dc.RefreshByPayload(
ElementContainerCacheRefresher.UniqueId,
deletedContainers.Select(container => new ElementContainerCacheRefresher.JsonPayload(container.Id, container.Key)));
#endregion
#region Published Snapshot
/// <summary>
@@ -0,0 +1,43 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Cache;
/// <summary>
/// Invalidates element caches when an element container (folder) is deleted, so that its key→id mapping
/// is evicted from <see cref="Services.IIdKeyMap"/> on every server.
/// </summary>
/// <remarks>
/// Element container deletions only publish <see cref="EntityContainerDeletedNotification"/> and an
/// <see cref="ElementTreeChangeNotification"/> for the contained elements - never for the container node
/// itself, so without this handler the container's stale id/key mapping survives until the next app
/// restart (see #23072).
/// </remarks>
public sealed class ElementContainerDeletedDistributedCacheNotificationHandler
: DeletedDistributedCacheNotificationHandlerBase<EntityContainer, EntityContainerDeletedNotification>
{
private readonly DistributedCache _distributedCache;
/// <summary>
/// Initializes a new instance of the <see cref="ElementContainerDeletedDistributedCacheNotificationHandler"/> class.
/// </summary>
/// <param name="distributedCache">The distributed cache.</param>
public ElementContainerDeletedDistributedCacheNotificationHandler(DistributedCache distributedCache)
=> _distributedCache = distributedCache;
/// <inheritdoc />
protected override void Handle(IEnumerable<EntityContainer> entities, IDictionary<string, object?> state)
{
EntityContainer[] elementContainers = entities
.Where(container => container.ContainerObjectType == Constants.ObjectTypes.ElementContainer)
.ToArray();
if (elementContainers.Length == 0)
{
return;
}
_distributedCache.RemoveElementContainerCache(elementContainers);
}
}
@@ -18,5 +18,14 @@ public sealed class LanguageDeletedDistributedCacheNotificationHandler : Deleted
/// <inheritdoc />
protected override void Handle(IEnumerable<ILanguage> entities, IDictionary<string, object?> state)
=> _distributedCache.RemoveLanguageCache(entities);
{
_distributedCache.RemoveLanguageCache(entities);
// User groups cache their allowed language ids, so a deleted language must be evicted from
// them too - otherwise a stale, now-missing id lingers on the cached user group and breaks
// reads that resolve those ids. This is a deliberately coarse refresh of the entire user group
// and user caches (RefreshAll also clears IUser): we can't know which groups reference the
// language without a query, and language deletion is rare enough that a full refresh is fine.
_distributedCache.RefreshAllUserGroupCache();
}
}
@@ -0,0 +1,109 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Core.Cache;
/// <summary>
/// Provides cache refresh functionality for element containers (folders).
/// </summary>
/// <remarks>
/// A deleted container's node id is never reused, so its key→id mapping in <see cref="IIdKeyMap"/> must be
/// evicted on every server. Otherwise a container recreated under the same key resolves to the stale id and
/// the element tree's children query returns nothing until the next app restart. This refresher only evicts
/// the id/key map - element data is unaffected by container changes, so it deliberately avoids the broader
/// invalidation performed by <see cref="ElementCacheRefresher"/>.
/// </remarks>
public sealed class ElementContainerCacheRefresher : PayloadCacheRefresherBase<ElementContainerCacheRefresherNotification, ElementContainerCacheRefresher.JsonPayload>
{
private readonly IIdKeyMap _idKeyMap;
/// <summary>
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresher"/> class.
/// </summary>
public ElementContainerCacheRefresher(
AppCaches appCaches,
IJsonSerializer serializer,
IIdKeyMap idKeyMap,
IEventAggregator eventAggregator,
ICacheRefresherNotificationFactory factory)
: base(appCaches, serializer, eventAggregator, factory)
=> _idKeyMap = idKeyMap;
#region Json
/// <summary>
/// Represents a JSON-serializable payload identifying an element container that changed.
/// </summary>
public class JsonPayload
{
/// <summary>
/// Initializes a new instance of the <see cref="JsonPayload"/> class.
/// </summary>
/// <param name="id">The unique integer identifier for the container.</param>
/// <param name="key">The unique GUID key associated with the container.</param>
public JsonPayload(int id, Guid key)
{
Id = id;
Key = key;
}
/// <summary>
/// Gets the unique integer identifier for the container.
/// </summary>
public int Id { get; }
/// <summary>
/// Gets the unique GUID key associated with the container.
/// </summary>
public Guid Key { get; }
}
#endregion
#region Define
/// <summary>
/// Represents a unique identifier for the cache refresher.
/// </summary>
public static readonly Guid UniqueId = Guid.Parse("9C9D8B0E-2F1A-4D63-9C2E-7E6B5A4F3C21");
/// <inheritdoc/>
public override Guid RefresherUniqueId => UniqueId;
/// <inheritdoc/>
public override string Name => "Element Container Cache Refresher";
#endregion
#region Refresher
/// <inheritdoc/>
public override void Refresh(JsonPayload[] payloads)
{
foreach (JsonPayload payload in payloads)
{
// Clearing by id also evicts the key→id direction, as the id/key map keeps both in sync.
_idKeyMap.ClearCache(payload.Id);
}
base.Refresh(payloads);
}
// These events should never trigger. Everything should be PAYLOAD/JSON.
/// <inheritdoc/>
public override void RefreshAll() => throw new NotSupportedException();
/// <inheritdoc/>
public override void Refresh(int id) => throw new NotSupportedException();
/// <inheritdoc/>
public override void Refresh(Guid id) => throw new NotSupportedException();
/// <inheritdoc/>
public override void Remove(int id) => throw new NotSupportedException();
#endregion
}
@@ -16,6 +16,11 @@ public class ContentSettings
/// </summary>
internal const bool StaticResolveUrlsFromTextString = false;
/// <summary>
/// The default value for whether sorting children by a field fires per-item notifications.
/// </summary>
internal const bool StaticSortChildrenByFieldFiresNotifications = false;
/// <summary>
/// The default preview badge markup template.
/// </summary>
@@ -109,6 +114,18 @@ public class ContentSettings
[DefaultValue(StaticResolveUrlsFromTextString)]
public bool ResolveUrlsFromTextString { get; set; } = StaticResolveUrlsFromTextString;
/// <summary>
/// Gets or sets a value indicating whether sorting the children of a node by a field fires
/// per-item save/sort notifications (and therefore webhooks).
/// </summary>
/// <remarks>
/// Defaults to <c>false</c>: the children are reordered with a single set-based update and a branch
/// cache refresh, without per-item notifications. Set to <c>true</c> to restore per-item notifications
/// (and webhooks), accepting the additional performance cost on nodes with many children.
/// </remarks>
[DefaultValue(StaticSortChildrenByFieldFiresNotifications)]
public bool SortChildrenByFieldFiresNotifications { get; set; } = StaticSortChildrenByFieldFiresNotifications;
/// <summary>
/// Gets or sets a value for the collection of error pages.
/// </summary>
@@ -30,6 +30,17 @@ public class DatabaseServerMessengerSettings
/// </summary>
internal const string StaticTimeBetweenPruneOperations = "00:01:00"; // TimeSpan.FromMinutes(1);
/// <summary>
/// The default timeout for a single synchronization operation.
/// </summary>
internal const string StaticSyncTimeout = "00:01:00"; // TimeSpan.FromMinutes(1);
/// <summary>
/// Gets the default timeout for a single synchronization operation, for use as a fallback when an invalid
/// <see cref="SyncTimeout" /> is configured.
/// </summary>
public static readonly TimeSpan DefaultSyncTimeout = TimeSpan.Parse(StaticSyncTimeout);
/// <summary>
/// Gets or sets a value for the maximum number of instructions that can be processed at startup; otherwise the server
/// cold-boots (rebuilds its caches).
@@ -55,4 +66,13 @@ public class DatabaseServerMessengerSettings
/// </summary>
[DefaultValue(StaticTimeBetweenPruneOperations)]
public TimeSpan TimeBetweenPruneOperations { get; set; } = TimeSpan.Parse(StaticTimeBetweenPruneOperations);
/// <summary>
/// Gets or sets the maximum time to wait for a single synchronization operation to complete before it is
/// considered stalled (for example, blocked on a hung database connection) and abandoned, so the recurring
/// job keeps running rather than stopping permanently. This bounds how long the job waits on a single sync,
/// not how long a stalled connection itself takes to recover (which is governed by the database timeouts).
/// </summary>
[DefaultValue(StaticSyncTimeout)]
public TimeSpan SyncTimeout { get; set; } = DefaultSyncTimeout;
}
@@ -20,6 +20,17 @@ public class DatabaseServerRegistrarSettings
/// </summary>
internal const string StaticStaleServerTimeout = "00:02:00";
/// <summary>
/// The default timeout for a single server touch operation.
/// </summary>
internal const string StaticTouchTimeout = "00:01:00"; // TimeSpan.FromMinutes(1);
/// <summary>
/// Gets the default timeout for a single server touch operation, for use as a fallback when an invalid
/// <see cref="TouchTimeout" /> is configured.
/// </summary>
public static readonly TimeSpan DefaultTouchTimeout = TimeSpan.Parse(StaticTouchTimeout);
/// <summary>
/// Gets or sets a value for the amount of time to wait between calls to the database on the background thread.
/// </summary>
@@ -31,4 +42,13 @@ public class DatabaseServerRegistrarSettings
/// </summary>
[DefaultValue(StaticStaleServerTimeout)]
public TimeSpan StaleServerTimeout { get; set; } = TimeSpan.Parse(StaticStaleServerTimeout);
/// <summary>
/// Gets or sets the maximum time to wait for a single server touch operation to complete before it is
/// considered stalled (for example, blocked on a hung database connection) and abandoned, so the recurring
/// job keeps running rather than stopping permanently. This bounds how long the job waits on a single touch,
/// not how long a stalled connection itself takes to recover (which is governed by the database timeouts).
/// </summary>
[DefaultValue(StaticTouchTimeout)]
public TimeSpan TouchTimeout { get; set; } = DefaultTouchTimeout;
}
@@ -20,5 +20,12 @@ public class IndexingSettings
/// <summary>
/// Gets or sets a value for how many items to index at a time.
/// </summary>
/// <remarks>
/// This is the primary lever for the peak memory used while (re)building an index: a full page of
/// content and its property data is held in memory at once, so lowering this value reduces rebuild
/// memory at the cost of more, smaller batches. Lower it on very large sites that hit memory pressure
/// during a rebuild.
/// </remarks>
[DefaultValue(StaticBatchSize)]
public int BatchSize { get; set; } = StaticBatchSize;
}
@@ -32,6 +32,11 @@ public class LoggingSettings
/// </summary>
internal const string StaticFileNameFormatArguments = "MachineName";
/// <summary>
/// The default mode for enriching log events with a session identifier.
/// </summary>
internal const SessionIdLoggingMode StaticSessionIdLogging = SessionIdLoggingMode.SessionId;
/// <summary>
/// Gets or sets a value for the maximum age of a log file.
/// </summary>
@@ -70,4 +75,16 @@ public class LoggingSettings
/// </remarks>
[DefaultValue(StaticFileNameFormatArguments)]
public string FileNameFormatArguments { get; set; } = StaticFileNameFormatArguments;
/// <summary>
/// Gets or sets a value determining how log events are enriched with a session identifier.
/// </summary>
/// <remarks>
/// Defaults to <see cref="SessionIdLoggingMode.SessionId" /> for backward compatibility. Set to
/// <see cref="SessionIdLoggingMode.CookieHash" /> or <see cref="SessionIdLoggingMode.None" /> to avoid the
/// blocking session-store load that resolving the actual session id incurs per request when the session is
/// backed by an <c>IDistributedCache</c>.
/// </remarks>
[DefaultValue(StaticSessionIdLogging)]
public SessionIdLoggingMode SessionIdLogging { get; set; } = StaticSessionIdLogging;
}
@@ -0,0 +1,33 @@
using System.ComponentModel;
namespace Umbraco.Cms.Core.Configuration.Models;
/// <summary>
/// Settings for scheduled publishing.
/// </summary>
[UmbracoOptions(Constants.Configuration.ConfigScheduledPublishing)]
public class ScheduledPublishingSettings
{
private const string StaticPeriod = "00:01:00";
private const bool StaticAlignToClock = false; // TODO (V19): Switch this to true.
/// <summary>
/// Gets or sets a value for how often scheduled publishing runs.
/// </summary>
[DefaultValue(StaticPeriod)]
public TimeSpan Period { get; set; } = TimeSpan.Parse(StaticPeriod);
/// <summary>
/// Gets or sets a value indicating whether scheduled publishing runs are aligned to clock boundaries
/// derived from <see cref="Period" /> (for example, on the minute, or every N seconds), rather than drifting
/// based on when the previous run completed.
/// </summary>
/// <remarks>
/// When enabled, <see cref="Period" /> must be a whole number of seconds that divides evenly into one hour
/// (for example 10, 12, 15, 20, 30 or 60 seconds) so that boundaries land on consistent clock times.
/// Boundaries are anchored to <strong>UTC</strong>, not the server's local time zone; for sub-minute and
/// whole-minute periods this is indistinguishable from local time at the second level.
/// </remarks>
[DefaultValue(StaticAlignToClock)]
public bool AlignToClock { get; set; } = StaticAlignToClock;
}
@@ -0,0 +1,29 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
namespace Umbraco.Cms.Core.Configuration.Models;
/// <summary>
/// Determines how request logging enriches log events with a session identifier.
/// </summary>
public enum SessionIdLoggingMode
{
/// <summary>
/// Do not enrich log events with a session identifier.
/// </summary>
None = 0,
/// <summary>
/// Enrich log events with the actual ASP.NET Core session id. This is the default and matches the
/// historical behaviour, but reading the session id forces the session to be loaded from its store, which
/// is a blocking round-trip per request when the session is backed by an <c>IDistributedCache</c>.
/// </summary>
SessionId,
/// <summary>
/// Enrich log events with a one-way hash of the session cookie value. This provides the same per-session
/// correlation as <see cref="SessionId" /> without loading the session from its store, so it never incurs
/// a distributed-cache round-trip.
/// </summary>
CookieHash,
}
@@ -0,0 +1,43 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Options;
namespace Umbraco.Cms.Core.Configuration.Models.Validation;
/// <summary>
/// Validator for configuration represented as <see cref="ScheduledPublishingSettings" />.
/// </summary>
public class ScheduledPublishingSettingsValidator : ConfigurationValidatorBase, IValidateOptions<ScheduledPublishingSettings>
{
/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, ScheduledPublishingSettings options)
{
if (options.Period <= TimeSpan.Zero)
{
return ValidateOptionsResult.Fail(
$"Configuration entry {Constants.Configuration.ConfigScheduledPublishing}:Period must be greater than zero.");
}
if (options.AlignToClock && IsCleanDivisorOfAnHour(options.Period) == false)
{
return ValidateOptionsResult.Fail(
$"Configuration entry {Constants.Configuration.ConfigScheduledPublishing}:Period must be a whole number of seconds that divides evenly into one hour (3600 seconds) when {Constants.Configuration.ConfigScheduledPublishing}:AlignToClock is enabled, e.g. 10, 12, 15, 20, 30 or 60 seconds.");
}
return ValidateOptionsResult.Success;
}
private static bool IsCleanDivisorOfAnHour(TimeSpan period)
{
var totalSeconds = period.TotalSeconds;
// Must be a positive, whole number of seconds (no sub-second component).
if (totalSeconds <= 0 || totalSeconds != Math.Floor(totalSeconds))
{
return false;
}
return 3600 % (long)totalSeconds == 0;
}
}
@@ -291,6 +291,11 @@ public static partial class Constants
/// </summary>
public const string ConfigDistributedJobs = ConfigPrefix + "DistributedJobs";
/// <summary>
/// The configuration key for scheduled publishing settings.
/// </summary>
public const string ConfigScheduledPublishing = ConfigPrefix + "ScheduledPublishing";
/// <summary>
/// The configuration key for backoffice token cookie settings.
/// </summary>
@@ -57,6 +57,7 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<IValidateOptions<RequestHandlerSettings>, RequestHandlerSettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<UnattendedSettings>, UnattendedSettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<SecuritySettings>, SecuritySettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<ScheduledPublishingSettings>, ScheduledPublishingSettingsValidator>();
// Register configuration sections.
builder
@@ -100,6 +101,7 @@ public static partial class UmbracoBuilderExtensions
.AddUmbracoOptions<CacheSettings>()
.AddUmbracoOptions<SystemDateMigrationSettings>()
.AddUmbracoOptions<DistributedJobSettings>()
.AddUmbracoOptions<ScheduledPublishingSettings>(options => options.ValidateOnStart())
.AddUmbracoOptions<BackOfficeTokenCookieSettings>()
.AddUmbracoOptions<WebsiteSettings>()
.AddUmbracoOptions<SignalRSettings>();
@@ -402,6 +402,7 @@
<key alias="invalidMediaType">The chosen media type is invalid.</key>
<key alias="invalidContentType">The chosen content is of invalid type.</key>
<key alias="missingContent">The chosen content does not exist.</key>
<key alias="missingMedia">The chosen media does not exist.</key>
<key alias="multipleMediaNotAllowed">Multiple selected media is not allowed.</key>
<key alias="notOneOfOptions">The value '%0%' is not one of the available options.</key>
<key alias="multipleNotOneOfOptions">The values '%0%' are not found in the the available options.</key>
@@ -0,0 +1,22 @@
namespace Umbraco.Cms.Core.Models.ContentEditing;
/// <summary>
/// Represents a system field that a node's children can be sorted by.
/// </summary>
public enum ContentSortField
{
/// <summary>
/// Sort by the node's name.
/// </summary>
Name,
/// <summary>
/// Sort by the date the node was created.
/// </summary>
CreateDate,
/// <summary>
/// Sort by the date the node was last updated.
/// </summary>
UpdateDate,
}
@@ -24,4 +24,9 @@ public enum TaggableObjectTypes
/// Represents member entities (user accounts).
/// </summary>
Member,
/// <summary>
/// Represents element entities.
/// </summary>
Element,
}
@@ -0,0 +1,19 @@
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Core.Notifications;
/// <summary>
/// A notification that is used to trigger the Element Container Cache Refresher.
/// </summary>
public class ElementContainerCacheRefresherNotification : CacheRefresherNotification
{
/// <summary>
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresherNotification"/> class.
/// </summary>
/// <param name="messageObject">The refresher payload.</param>
/// <param name="messageType">Type of the cache refresher message, <see cref="MessageType"/>.</param>
public ElementContainerCacheRefresherNotification(object messageObject, MessageType messageType)
: base(messageObject, messageType)
{
}
}
@@ -16,6 +16,19 @@ public interface IContentRepository<in TId, TEntity> : IReadWriteQueryRepository
/// </summary>
int RecycleBinId { get; }
/// <summary>
/// Updates the sort order of the specified nodes so that each node's sort order matches its
/// position in the supplied (already ordered) collection, in a single set-based update.
/// </summary>
/// <param name="orderedNodeIds">The node identifiers in their desired order.</param>
/// <remarks>
/// This persists the sort order directly and does not load the entities or fire any notifications;
/// callers are responsible for any required cache refresh and auditing.
/// </remarks>
// TODO (V19): Remove the default implementation.
void UpdateSortOrder(IReadOnlyList<int> orderedNodeIds)
=> throw new NotImplementedException();
/// <summary>
/// Gets versions.
/// </summary>
@@ -0,0 +1,34 @@
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.PropertyEditors;
/// <summary>
/// Parses the comma-separated content type keys stored in a picker's "allowed content types" configuration value
/// (e.g. <see cref="ContentPickerConfiguration.AllowedContentTypeIds"/> or <see cref="ElementPickerConfiguration.AllowedContentTypeIds"/>).
/// </summary>
internal static class AllowedContentTypeKeysParser
{
/// <summary>
/// Parses the configured value into the set of allowed content type keys.
/// </summary>
/// <param name="configValue">The comma-separated configuration value. Non-GUID entries are ignored.</param>
/// <returns>The set of allowed content type keys, or an empty set when nothing is configured.</returns>
public static HashSet<Guid> Parse(string? configValue)
{
if (configValue.IsNullOrWhiteSpace())
{
return [];
}
var result = new HashSet<Guid>();
foreach (var entry in configValue.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries))
{
if (Guid.TryParse(entry, out Guid guid))
{
result.Add(guid);
}
}
return result;
}
}
@@ -8,4 +8,10 @@ public class ContentPickerConfiguration : IIgnoreUserStartNodesConfig
/// <inheritdoc />
[ConfigurationField(Constants.DataTypes.ReservedPreValueKeys.IgnoreUserStartNodes)]
public bool IgnoreUserStartNodes { get; set; }
/// <summary>
/// Gets or sets the content type filter for allowed selections.
/// </summary>
[ConfigurationField("allowedContentTypes")]
public string? AllowedContentTypeIds { get; set; }
}
@@ -1,12 +1,16 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Nodes;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Editors;
using Umbraco.Cms.Core.Models.Validation;
using Umbraco.Cms.Core.PropertyEditors.Validation;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
@@ -70,13 +74,21 @@ public class ContentPickerPropertyEditor : DataEditor, IValueSchemaProvider
/// <param name="jsonSerializer">The JSON serializer.</param>
/// <param name="ioHelper">The IO helper.</param>
/// <param name="attribute">The data editor attribute.</param>
/// <param name="coreScopeProvider">The core scope provider.</param>
/// <param name="contentService">The content service.</param>
/// <param name="localizedTextService">The localized text service.</param>
public ContentPickerPropertyValueEditor(
IShortStringHelper shortStringHelper,
IJsonSerializer jsonSerializer,
IIOHelper ioHelper,
DataEditorAttribute attribute)
DataEditorAttribute attribute,
ICoreScopeProvider coreScopeProvider,
IContentService contentService,
ILocalizedTextService localizedTextService)
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
{
Validators.Add(new TypedValidatorRunner<string, ContentPickerConfiguration>(
new AllowedTypeValidator(localizedTextService, contentService, coreScopeProvider)));
}
/// <inheritdoc />
@@ -134,4 +146,61 @@ public class ContentPickerPropertyEditor : DataEditor, IValueSchemaProvider
return guidUdi.Guid;
}
}
/// <summary>
/// Validates that the selected content matches the allowed content types configured for the property editor.
/// </summary>
/// <param name="localizedTextService">The localized text service.</param>
/// <param name="contentService">The content service.</param>
/// <param name="coreScopeProvider">The core scope provider.</param>
internal sealed class AllowedTypeValidator(ILocalizedTextService localizedTextService, IContentService contentService, ICoreScopeProvider coreScopeProvider)
: ITypedValidator<string, ContentPickerConfiguration>
{
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(
string? value,
ContentPickerConfiguration? configuration,
string? valueType,
PropertyValidationContext validationContext)
{
if (string.IsNullOrEmpty(value) ||
configuration is null ||
Guid.TryParse(value, out Guid id) is false)
{
return [];
}
HashSet<Guid> allowedContentTypeKeys = AllowedContentTypeKeysParser.Parse(configuration.AllowedContentTypeIds);
// No filter configured — all content types are allowed.
if (allowedContentTypeKeys.Count == 0)
{
return [];
}
using ICoreScope scope = coreScopeProvider.CreateCoreScope();
Guid? key = contentService.GetById(id)?.ContentType?.Key;
scope.Complete();
if (key is null)
{
return [new ValidationResult(
localizedTextService.Localize(
"validation",
"missingContent"),
["value"])];
}
if (allowedContentTypeKeys.Contains(key.Value) is false)
{
return [new ValidationResult(
localizedTextService.Localize(
"validation",
"invalidObjectType"),
["value"])];
}
return [];
}
}
}
@@ -8,4 +8,32 @@ public class ElementPickerConfiguration : IIgnoreUserStartNodesConfig
/// <inheritdoc />
[ConfigurationField(Constants.DataTypes.ReservedPreValueKeys.IgnoreUserStartNodes)]
public bool IgnoreUserStartNodes { get; set; }
/// <summary>
/// Gets or sets the validation limits for the number of elements allowed.
/// </summary>
[ConfigurationField("validationLimit")]
public NumberRange? ValidationLimit { get; set; }
/// <summary>
/// Gets or sets the content type filter for allowed selections.
/// </summary>
[ConfigurationField("allowedContentTypes")]
public string? AllowedContentTypeIds { get; set; }
/// <summary>
/// Represents a numeric range with optional minimum and maximum values.
/// </summary>
public class NumberRange
{
/// <summary>
/// Gets or sets the minimum value of the range.
/// </summary>
public int? Min { get; set; }
/// <summary>
/// Gets or sets the maximum value of the range.
/// </summary>
public int? Max { get; set; }
}
}
@@ -1,13 +1,19 @@
using Umbraco.Cms.Core.IO;
using System.ComponentModel.DataAnnotations;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Editors;
using Umbraco.Cms.Core.Models.Validation;
using Umbraco.Cms.Core.PropertyEditors.Validation;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.PropertyEditors;
/// <summary>
/// Element picker property editor that stores element keys
/// Element picker property editor that stores element keys.
/// </summary>
[DataEditor(
Constants.PropertyEditors.Aliases.ElementPicker,
@@ -17,6 +23,11 @@ public class ElementPickerPropertyEditor : DataEditor
{
private readonly IIOHelper _ioHelper;
/// <summary>
/// Initializes a new instance of the <see cref="ElementPickerPropertyEditor" /> class.
/// </summary>
/// <param name="dataValueEditorFactory">The data value editor factory.</param>
/// <param name="ioHelper">The IO helper.</param>
public ElementPickerPropertyEditor(IDataValueEditorFactory dataValueEditorFactory, IIOHelper ioHelper)
: base(dataValueEditorFactory)
{
@@ -28,21 +39,44 @@ public class ElementPickerPropertyEditor : DataEditor
protected override IConfigurationEditor CreateConfigurationEditor() =>
new ElementPickerConfigurationEditor(_ioHelper);
/// <inheritdoc/>
protected override IDataValueEditor CreateValueEditor() =>
DataValueEditorFactory.Create<ElementPickerPropertyValueEditor>(Attribute!);
/// <summary>
/// Provides the value editor for the element picker property editor.
/// </summary>
internal sealed class ElementPickerPropertyValueEditor : DataValueEditor, IDataValueReference
{
private readonly IJsonSerializer _jsonSerializer;
/// <summary>
/// Initializes a new instance of the <see cref="ElementPickerPropertyValueEditor" /> class.
/// </summary>
/// <param name="shortStringHelper">The short string helper.</param>
/// <param name="jsonSerializer">The JSON serializer.</param>
/// <param name="ioHelper">The IO helper.</param>
/// <param name="attribute">The data editor attribute.</param>
/// <param name="localizedTextService">The localized text service.</param>
/// <param name="elementService">The element service.</param>
/// <param name="coreScopeProvider">The core scope provider.</param>
public ElementPickerPropertyValueEditor(
IShortStringHelper shortStringHelper,
IJsonSerializer jsonSerializer,
IIOHelper ioHelper,
DataEditorAttribute attribute)
DataEditorAttribute attribute,
ILocalizedTextService localizedTextService,
IElementService elementService,
ICoreScopeProvider coreScopeProvider)
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
=> _jsonSerializer = jsonSerializer;
{
_jsonSerializer = jsonSerializer;
Validators.Add(new TypedValidatorRunner<List<string>, ElementPickerConfiguration>(
new MinMaxValidator(localizedTextService),
new AllowedTypeValidator(localizedTextService, elementService, coreScopeProvider)));
}
/// <inheritdoc/>
public IEnumerable<UmbracoEntityReference> GetReferences(object? value)
{
var asString = value as string ?? value?.ToString();
@@ -63,4 +97,144 @@ public class ElementPickerPropertyEditor : DataEditor
}
}
}
/// <summary>
/// Validator to ensure that the number of selected elements is within the configured min/max limits, if any.
/// </summary>
internal sealed class MinMaxValidator : ITypedValidator<List<string>, ElementPickerConfiguration>
{
private readonly ILocalizedTextService _localizedTextService;
/// <summary>
/// Initializes a new instance of the <see cref="MinMaxValidator" /> class.
/// </summary>
/// <param name="localizedTextService">The localized text service.</param>
public MinMaxValidator(ILocalizedTextService localizedTextService)
=> _localizedTextService = localizedTextService;
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(
List<string>? value,
ElementPickerConfiguration? configuration,
string? valueType,
PropertyValidationContext validationContext)
{
var validationResults = new List<ValidationResult>();
if (configuration is null || configuration.ValidationLimit is null)
{
return validationResults;
}
if (configuration.ValidationLimit.Min is int min and > 0 && (value is null || value.Count < min))
{
validationResults.Add(new ValidationResult(
_localizedTextService.Localize(
"validation",
"entriesShort",
[min.ToString(), (min - (value?.Count ?? 0)).ToString()]),
["value"]));
}
if (value is null)
{
return validationResults;
}
if (configuration.ValidationLimit.Max is int max and > 0 && value.Count > max)
{
validationResults.Add(new ValidationResult(
_localizedTextService.Localize(
"validation",
"entriesExceed",
[max.ToString(), (value.Count - max).ToString()]),
["value"]));
}
return validationResults;
}
}
/// <summary>
/// Validator to ensure that all selected elements are of an allowed content type, if any are configured.
/// </summary>
internal sealed class AllowedTypeValidator : ITypedValidator<List<string>, ElementPickerConfiguration>
{
private readonly ILocalizedTextService _localizedTextService;
private readonly IElementService _elementService;
private readonly ICoreScopeProvider _coreScopeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="AllowedTypeValidator" /> class.
/// </summary>
/// <param name="localizedTextService">The localized text service.</param>
/// <param name="elementService">The element service.</param>
/// <param name="coreScopeProvider">The core scope provider.</param>
public AllowedTypeValidator(
ILocalizedTextService localizedTextService,
IElementService elementService,
ICoreScopeProvider coreScopeProvider)
{
_localizedTextService = localizedTextService;
_elementService = elementService;
_coreScopeProvider = coreScopeProvider;
}
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(
List<string>? value,
ElementPickerConfiguration? configuration,
string? valueType,
PropertyValidationContext validationContext)
{
if (value is null || value.Count == 0 || configuration is null)
{
return [];
}
HashSet<Guid> allowedContentTypeKeys = AllowedContentTypeKeysParser.Parse(configuration.AllowedContentTypeIds);
// No filter configured — all element types are allowed.
if (allowedContentTypeKeys.Count == 0)
{
return [];
}
Guid[] elementIds = value
.Where(v => Guid.TryParse(v, out _))
.Select(Guid.Parse)
.Distinct()
.ToArray();
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
IElement[] elements = _elementService.GetByIds(elementIds).ToArray();
scope.Complete();
// Compare against the distinct requested keys (not the raw value count, which may include
// duplicates or non-GUID entries) so existing elements aren't incorrectly reported as missing.
if (elements.Length != elementIds.Length)
{
return [
new ValidationResult(
_localizedTextService.Localize("validation", "missingContent"),
["value"])
];
}
foreach (IElement element in elements)
{
if (allowedContentTypeKeys.Contains(element.ContentType.Key) is false)
{
return
[
new ValidationResult(
_localizedTextService.Localize("validation", "invalidObjectType"),
["value"])
];
}
}
return [];
}
}
}
@@ -67,7 +67,7 @@ internal sealed class EntityDataPickerPropertyEditor : DataEditor
/// <summary>
/// Validates the min/max configuration for the entity data picker property editor.
/// </summary>
internal sealed class MinMaxValidator : ITypedJsonValidator<EntityDataPickerDto, EntityDataPickerConfiguration>
internal sealed class MinMaxValidator : ITypedValidator<EntityDataPickerDto, EntityDataPickerConfiguration>
{
private readonly ILocalizedTextService _localizedTextService;
@@ -9,17 +9,13 @@ namespace Umbraco.Cms.Core.PropertyEditors.Validation;
/// </summary>
/// <typeparam name="TValue">The type of the value consumed by the validator.</typeparam>
/// <typeparam name="TConfiguration">The type of the configuration consumed by validator.</typeparam>
public interface ITypedJsonValidator<TValue, TConfiguration>
[Obsolete("Use ITypedValidator instead; the validator contract is not JSON-specific. Scheduled for removal in Umbraco 20.")]
public interface ITypedJsonValidator<TValue, TConfiguration> : ITypedValidator<TValue, TConfiguration>
{
/// <summary>
/// Validates the specified value against the configuration.
/// </summary>
/// <param name="value">The deserialized value to validate.</param>
/// <param name="configuration">The data type configuration.</param>
/// <param name="valueType">The value type.</param>
/// <param name="validationContext">The property validation context.</param>
/// <returns>A collection of validation results.</returns>
public abstract IEnumerable<ValidationResult> Validate(
// Re-declared (rather than purely inherited from ITypedValidator) so the ITypedJsonValidator.Validate member
// remains present for binary compatibility with consumers compiled against this interface in v15-v17.
// TODO (V20): remove together with this interface.
new IEnumerable<ValidationResult> Validate(
TValue? value,
TConfiguration? configuration,
string? valueType,
@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using Umbraco.Cms.Core.Models.Validation;
namespace Umbraco.Cms.Core.PropertyEditors.Validation;
/// <summary>
/// A validator that operates on an already-typed value and configuration.
/// <remarks>
/// Used together with an <see cref="IValueValidator"/> runner that materializes the typed value: see
/// <see cref="TypedValidatorRunner{TValue,TConfiguration}"/> for value editors whose value is already typed, and
/// <see cref="TypedJsonValidatorRunner{TValue,TConfiguration}"/> for JSON based value editors, where the value is deserialized once before validation.
/// </remarks>
/// </summary>
/// <typeparam name="TValue">The type of the value consumed by the validator.</typeparam>
/// <typeparam name="TConfiguration">The type of the configuration consumed by validator.</typeparam>
public interface ITypedValidator<TValue, TConfiguration>
{
/// <summary>
/// Validates the specified value against the configuration.
/// </summary>
/// <param name="value">The typed value to validate.</param>
/// <param name="configuration">The data type configuration.</param>
/// <param name="valueType">The value type.</param>
/// <param name="validationContext">The property validation context.</param>
/// <returns>A collection of validation results.</returns>
IEnumerable<ValidationResult> Validate(
TValue? value,
TConfiguration? configuration,
string? valueType,
PropertyValidationContext validationContext);
}
@@ -6,26 +6,47 @@ namespace Umbraco.Cms.Core.PropertyEditors.Validation;
/// <summary>
/// <para>
/// An aggregate validator for JSON based value editors, to avoid doing multiple deserialization.
/// An aggregate <see cref="IValueValidator"/> for JSON based value editors. Deserializes the editor value into
/// <typeparamref name="TValue"/> once (avoiding repeated deserialization), casts the configuration once, and passes both
/// to each <see cref="ITypedValidator{TValue,TConfiguration}"/>, aggregating the results.
/// </para>
/// <para>
/// Will deserialize once, and cast the configuration once, and pass those values to each <see cref="ITypedJsonValidator{TValue,TConfiguration}"/>, aggregating the results.
/// Use this runner when the editor value reaching validation is raw JSON that must be deserialized before validation —
/// typically an array of complex objects, such as a media picker storing crop data, which the backoffice JSON object
/// converter leaves as un-typed JSON nodes rather than a typed CLR value.
/// </para>
/// <para>
/// When the editor value is already the typed CLR value (so only a cast is needed, with no deserialization) use
/// <see cref="TypedValidatorRunner{TValue,TConfiguration}"/> instead. That is the only difference between the two runners:
/// this one deserializes, the other casts.
/// </para>
/// </summary>
/// <typeparam name="TValue">The type of the expected value.</typeparam>
/// <typeparam name="TConfiguration">The type of the expected configuration</typeparam>
/// <seealso cref="TypedValidatorRunner{TValue,TConfiguration}"/>
public class TypedJsonValidatorRunner<TValue, TConfiguration> : IValueValidator
where TValue : class
{
private readonly IJsonSerializer _jsonSerializer;
private readonly ITypedJsonValidator<TValue, TConfiguration>[] _validators;
private readonly ITypedValidator<TValue, TConfiguration>[] _validators;
/// <summary>
/// Initializes a new instance of the <see cref="TypedJsonValidatorRunner{TValue, TConfiguration}"/> class.
/// </summary>
/// <param name="jsonSerializer">The JSON serializer.</param>
/// <param name="validators">The collection of validators to run.</param>
[Obsolete("Use the constructor accepting ITypedValidator instances. Scheduled for removal in Umbraco 20.")]
public TypedJsonValidatorRunner(IJsonSerializer jsonSerializer, params ITypedJsonValidator<TValue, TConfiguration>[] validators)
: this(jsonSerializer, (ITypedValidator<TValue, TConfiguration>[])validators)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="TypedJsonValidatorRunner{TValue, TConfiguration}"/> class.
/// </summary>
/// <param name="jsonSerializer">The JSON serializer.</param>
/// <param name="validators">The collection of validators to run.</param>
public TypedJsonValidatorRunner(IJsonSerializer jsonSerializer, params ITypedValidator<TValue, TConfiguration>[] validators)
{
_jsonSerializer = jsonSerializer;
_validators = validators;
@@ -51,7 +72,7 @@ public class TypedJsonValidatorRunner<TValue, TConfiguration> : IValueValidator
return validationResults;
}
foreach (ITypedJsonValidator<TValue, TConfiguration> validator in _validators)
foreach (ITypedValidator<TValue, TConfiguration> validator in _validators)
{
validationResults.AddRange(validator.Validate(deserializedValue, configuration, valueType, validationContext));
}
@@ -0,0 +1,60 @@
using System.ComponentModel.DataAnnotations;
using Umbraco.Cms.Core.Models.Validation;
namespace Umbraco.Cms.Core.PropertyEditors.Validation;
/// <summary>
/// <para>
/// An aggregate <see cref="IValueValidator"/> that casts the editor value once and passes it, along with the cast
/// configuration, to each <see cref="ITypedValidator{TValue,TConfiguration}"/>, aggregating the results.
/// </para>
/// <para>
/// Use this runner when the editor value reaching validation is already the typed CLR value (<typeparamref name="TValue"/>),
/// so a cast is all that is needed — for example a content picker (value is a <see cref="string"/>) or an element picker
/// (value is a <c>List&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();
}
}
@@ -103,11 +103,19 @@ internal sealed class ContentEditingService
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(
ContentCreateModel createModel,
Guid userKey)
=> await ValidateCulturesAndPropertiesAsync(
{
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
{
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
}
return await ValidateCulturesAndPropertiesAsync(
createModel,
createModel.ContentTypeKey,
createModel.Variants.Select(variant => variant.Culture),
userKey);
}
/// <inheritdoc />
public async Task<Attempt<ContentCreateResult, ContentEditingOperationStatus>> CreateAsync(ContentCreateModel createModel, Guid userKey)
@@ -211,6 +219,15 @@ internal sealed class ContentEditingService
Guid userKey)
=> await HandleSortAsync(parentKey, sortingModels, userKey);
/// <inheritdoc />
public async Task<ContentEditingOperationStatus> SortByFieldAsync(
Guid? parentKey,
ContentSortField field,
Direction direction,
string? culture,
Guid userKey)
=> await HandleSortByFieldAsync(parentKey, field, direction, culture, userKey);
private async Task<ContentEditingOperationStatus> UpdateTemplateAsync(IContent content, Guid? templateKey)
{
if (templateKey == null)
@@ -258,8 +275,8 @@ internal sealed class ContentEditingService
protected override OperationResult? Delete(IContent content, int userId) => ContentService.Delete(content, userId);
/// <inheritdoc />
protected override IEnumerable<IContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: null);
protected override IEnumerable<IContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: ordering);
/// <inheritdoc />
protected override ContentEditingOperationStatus Sort(IEnumerable<IContent> items, int userId)
@@ -268,6 +285,13 @@ internal sealed class ContentEditingService
return OperationResultToOperationStatus(result);
}
/// <inheritdoc />
protected override ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId)
{
OperationResult result = ContentService.SortChildren(parentId, orderedChildIds, userId);
return OperationResultToOperationStatus(result);
}
private async Task<ContentEditingOperationStatus> Save(IContent content, Guid userKey)
{
try
@@ -500,6 +500,10 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
{
// these are the only result states currently expected from the invoked IContentService operations
OperationResultType.Success => ContentEditingOperationStatus.Success,
// a no-op (e.g. sorting children when nothing needs reordering) is a successful outcome, not an error
OperationResultType.NoOperation => ContentEditingOperationStatus.Success,
OperationResultType.FailedCancelledByEvent => ContentEditingOperationStatus.CancelledByNotification,
OperationResultType.FailedCannot => ContentEditingOperationStatus.CannotDeleteWhenReferenced,
@@ -661,6 +665,25 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
return filteredContentTypes.Any();
}
/// <summary>
/// Validates that content of the requested type is allowed to be created under the requested parent, applying the
/// same "allowed at root", "allowed as child" and content type filter rules that are enforced when the content is
/// actually created. This allows the validation endpoints to be consistent with creation.
/// </summary>
/// <param name="createModel">The content creation model.</param>
/// <returns>The operation status; <see cref="ContentEditingOperationStatus.Success"/> when creation is allowed.</returns>
protected async Task<ContentEditingOperationStatus> ValidateCreationAllowedAsync(ContentCreationModelBase createModel)
{
TContentType? contentType = ContentTypeService.Get(createModel.ContentTypeKey);
if (contentType is null)
{
return ContentEditingOperationStatus.ContentTypeNotFound;
}
(int? _, ContentEditingOperationStatus operationStatus) = await TryGetAndValidateParentIdAsync(createModel.ParentKey, contentType);
return operationStatus;
}
private void UpdateNames(ContentEditingModelBase contentEditingModelBase, TContent content, TContentType contentType)
{
if (contentType.VariesByCulture())
@@ -90,9 +90,10 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
/// <param name="parentId">The parent identifier.</param>
/// <param name="pageIndex">The zero-based page index.</param>
/// <param name="pageSize">The page size.</param>
/// <param name="ordering">The ordering to apply, or <c>null</c> to use the default (sort order).</param>
/// <param name="total">The total number of children.</param>
/// <returns>The paged children.</returns>
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total);
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total);
/// <summary>
/// Handles the sorting operation asynchronously.
@@ -115,16 +116,7 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
return ContentEditingOperationStatus.NotFound;
}
const int pageSize = 500;
var pageNumber = 0;
IEnumerable<TContent> page = GetPagedChildren(contentId.Value, pageNumber++, pageSize, out var total);
var children = new List<TContent>((int)total);
children.AddRange(page);
while (pageNumber * pageSize < total)
{
page = GetPagedChildren(contentId.Value, pageNumber++, pageSize, out _);
children.AddRange(page);
}
List<TContent> children = LoadAllChildren(contentId.Value, ordering: null);
try
{
@@ -142,4 +134,102 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
return ContentEditingOperationStatus.SortingInvalid;
}
}
/// <summary>
/// Handles sorting a parent's children by a system field asynchronously.
/// </summary>
/// <param name="parentKey">The optional parent key.</param>
/// <param name="field">The system field to sort the children by.</param>
/// <param name="direction">The direction to sort in.</param>
/// <param name="culture">The culture whose variant name to sort by, or <c>null</c> to sort by the invariant name. Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a child that does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.</param>
/// <param name="userKey">The user key performing the operation.</param>
/// <returns>The operation status.</returns>
protected async Task<ContentEditingOperationStatus> HandleSortByFieldAsync(
Guid? parentKey,
ContentSortField field,
Direction direction,
string? culture,
Guid userKey)
{
var contentId = parentKey.HasValue
? ContentService.GetById(parentKey.Value)?.Id
: Constants.System.Root;
if (contentId.HasValue is false)
{
return ContentEditingOperationStatus.NotFound;
}
Ordering ordering = BuildOrdering(field, direction, culture);
// The database does the ordering (matching the list view and the order shown in the sort UI).
if (ContentSettings.SortChildrenByFieldFiresNotifications)
{
// Opt-in path: load the children and persist via the standard sort, firing per-item
// save/sort notifications (and therefore webhooks), at the cost of loading every child.
List<TContent> orderedChildren = LoadAllChildren(contentId.Value, ordering);
if (orderedChildren.Count == 0)
{
return ContentEditingOperationStatus.Success;
}
return Sort(orderedChildren, await GetUserIdAsync(userKey));
}
// Default path: persist the resulting order with a single set-based update and a branch cache
// refresh, without loading every child or firing per-item notifications.
List<int> orderedChildIds = LoadOrderedChildIds(contentId.Value, ordering);
if (orderedChildIds.Count == 0)
{
// Nothing to sort - the order is trivially correct.
return ContentEditingOperationStatus.Success;
}
return SortChildrenInBulk(contentId.Value, orderedChildIds, await GetUserIdAsync(userKey));
}
/// <summary>
/// Persists the supplied (already ordered) child identifiers as the new sort order, without loading
/// the children or firing per-item notifications.
/// </summary>
/// <param name="parentId">The parent identifier, or the root identifier for root-level sorting.</param>
/// <param name="orderedChildIds">The child identifiers in their desired order.</param>
/// <param name="userId">The user performing the operation.</param>
/// <returns>The operation status.</returns>
protected abstract ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId);
private List<int> LoadOrderedChildIds(int contentId, Ordering ordering)
=> LoadAllChildren(contentId, ordering, child => child.Id);
private List<TContent> LoadAllChildren(int contentId, Ordering? ordering)
=> LoadAllChildren(contentId, ordering, child => child);
// Pages through all children, projecting each page with the selector so callers that only need a
// lightweight value (e.g. the id) don't retain every loaded child.
private List<TResult> LoadAllChildren<TResult>(int contentId, Ordering? ordering, Func<TContent, TResult> selector)
{
const int pageSize = 500;
var pageNumber = 0;
IEnumerable<TContent> page = GetPagedChildren(contentId, pageNumber++, pageSize, ordering, out var total);
var results = new List<TResult>((int)total);
results.AddRange(page.Select(selector));
while (pageNumber * pageSize < total)
{
page = GetPagedChildren(contentId, pageNumber++, pageSize, ordering, out _);
results.AddRange(page.Select(selector));
}
return results;
}
private static Ordering BuildOrdering(ContentSortField field, Direction direction, string? culture)
=> field switch
{
// Name is variant - the culture selects the variant name to order by (invariant content and media
// ignore it). Create and update dates are node-level, so the culture does not apply.
ContentSortField.Name => Ordering.By("name", direction, culture),
ContentSortField.CreateDate => Ordering.By("createDate", direction),
ContentSortField.UpdateDate => Ordering.By("updateDate", direction),
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort field."),
};
}
+44 -1
View File
@@ -1652,7 +1652,13 @@ public class ContentService : PublishableContentServiceBase<IContent>, IContentS
{
scope.WriteLock(Constants.Locks.ContentTree);
OperationResult ret = Sort(scope, itemsA, userId, evtMsgs);
// Reload within the lock so sorting operates on fully-loaded entities. Callers may pass
// partially-loaded content (e.g. loaded with loadTemplates: false or without property data),
// and saving those directly would wipe the template and property data (#23120).
// GetByIds returns items in the requested order, preserving the caller's ordering that drives the sort.
IContent[] reloaded = GetByIds(itemsA.Select(x => x.Id).ToArray()).ToArray();
OperationResult ret = Sort(scope, reloaded, userId, evtMsgs);
scope.Complete();
return ret;
}
@@ -1690,6 +1696,43 @@ public class ContentService : PublishableContentServiceBase<IContent>, IContentS
}
}
/// <inheritdoc />
public OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
{
EventMessages evtMsgs = EventMessagesFactory.Get();
if (orderedChildIds.Count == 0)
{
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
}
using ICoreScope scope = ScopeProvider.CreateCoreScope();
scope.WriteLock(Constants.Locks.ContentTree);
_documentRepository.UpdateSortOrder(orderedChildIds);
// Sort order lives in umbracoNode; neither the published cache nor the content repository cache keeps
// a separate serialized copy of it, so refreshing the affected branch (which invalidates both and has
// them reload from umbracoNode) is enough to pick up the new order without re-saving each child.
if (parentId == Constants.System.Root)
{
IContent[] roots = GetByIds(orderedChildIds).ToArray();
scope.Notifications.Publish(new ContentTreeChangeNotification(roots, TreeChangeTypes.RefreshNode, evtMsgs));
}
else
{
IContent? parent = GetById(parentId);
if (parent is not null)
{
scope.Notifications.Publish(new ContentTreeChangeNotification(parent, TreeChangeTypes.RefreshBranch, evtMsgs));
}
}
Audit(AuditType.Sort, userId, parentId);
scope.Complete();
return OperationResult.Succeed(evtMsgs);
}
private OperationResult Sort(ICoreScope scope, IContent[] itemsA, int userId, EventMessages eventMessages)
{
var sortingNotification = new ContentSortingNotification(itemsA, eventMessages);
@@ -95,6 +95,18 @@ public interface IContentEditingService
/// <returns>The operation status indicating success or failure.</returns>
Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey);
/// <summary>
/// Sorts the children of a parent by a system field.
/// </summary>
/// <param name="parentKey">The unique identifier of the parent, or <c>null</c> for root-level sorting.</param>
/// <param name="field">The system field to sort the children by.</param>
/// <param name="direction">The direction to sort in.</param>
/// <param name="culture">The culture whose variant name to sort by, or <c>null</c> to sort by the invariant name. Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a child that does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.</param>
/// <param name="userKey">The unique identifier of the user performing the action.</param>
/// <returns>The operation status indicating success or failure.</returns>
Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, string? culture, Guid userKey)
=> throw new NotImplementedException(); // TODO (V19): Remove default implementation.
/// <summary>
/// Deletes a content item whether it is in the recycle bin or not.
/// </summary>
@@ -418,6 +418,22 @@ public interface IContentService : IPublishableContentService<IContent>
/// <returns>The operation result.</returns>
OperationResult Sort(IEnumerable<int>? ids, int userId = Constants.Security.SuperUserId);
/// <summary>
/// Sorts the children of a parent by persisting the supplied (already ordered) child identifiers
/// as the new sort order, in a single set-based update.
/// </summary>
/// <param name="parentId">The identifier of the parent, or <see cref="Constants.System.Root"/> for the root.</param>
/// <param name="orderedChildIds">The child document identifiers, in the desired order.</param>
/// <param name="userId">The identifier of the user performing the action.</param>
/// <returns>The operation result.</returns>
/// <remarks>
/// Unlike <see cref="Sort(IEnumerable{int}?, int)" />, this does not load the children or fire per-item
/// save/sort notifications; it persists the order directly and refreshes the affected cache branch.
/// </remarks>
// TODO (V19): Remove the default implementation.
OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
=> throw new NotImplementedException();
#endregion
#region Publish Document
@@ -118,6 +118,18 @@ public interface IMediaEditingService
/// </returns>
Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey);
/// <summary>
/// Sorts the children of a parent by a system field.
/// </summary>
/// <param name="parentKey">The unique identifier of the parent, or <c>null</c> for root-level sorting.</param>
/// <param name="field">The system field to sort the children by.</param>
/// <param name="direction">The direction to sort in.</param>
/// <param name="userKey">The unique identifier of the user performing the operation.</param>
/// <returns>The operation status indicating the operation outcome.</returns>
/// <remarks>Media items never vary by culture, so children are always ordered by the invariant name.</remarks>
Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, Guid userKey)
=> throw new NotImplementedException(); // TODO (V19): Remove default implementation.
/// <summary>
/// Permanently deletes a media item from the recycle bin.
/// </summary>
@@ -338,6 +338,22 @@ public interface IMediaService : IContentServiceBase<IMedia>
/// <returns>True if sorting succeeded, otherwise False</returns>
bool Sort(IEnumerable<IMedia> items, int userId = Constants.Security.SuperUserId);
/// <summary>
/// Sorts the children of a parent by persisting the supplied (already ordered) child identifiers
/// as the new sort order, in a single set-based update.
/// </summary>
/// <param name="parentId">The identifier of the parent, or <see cref="Constants.System.Root"/> for the root.</param>
/// <param name="orderedChildIds">The child media identifiers, in the desired order.</param>
/// <param name="userId">The identifier of the user performing the action.</param>
/// <returns>The operation result.</returns>
/// <remarks>
/// Unlike <see cref="Sort(IEnumerable{IMedia}, int)" />, this does not load the children or fire per-item
/// save/sort notifications; it persists the order directly and refreshes the affected cache branch.
/// </remarks>
// TODO (V19): Remove the default implementation.
OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
=> throw new NotImplementedException();
/// <summary>
/// Creates an <see cref="IMedia" /> object using the alias of the <see cref="IMediaType" />
/// that this Media should based on.
+18
View File
@@ -54,6 +54,18 @@ public interface ITagService : IService
/// </summary>
IEnumerable<TaggedEntity> GetTaggedMembersByTag(string tag, string? group = null, string? culture = null);
/// <summary>
/// Gets all elements tagged with any tag in the specified group.
/// </summary>
// TODO (V19): Remove the default implementation from this interface.
IEnumerable<TaggedEntity> GetTaggedElementsByTagGroup(string group, string? culture = null) => [];
/// <summary>
/// Gets all elements tagged with the specified tag.
/// </summary>
// TODO (V19): Remove the default implementation from this interface.
IEnumerable<TaggedEntity> GetTaggedElementsByTag(string tag, string? group = null, string? culture = null) => [];
/// <summary>
/// Gets all tags.
/// </summary>
@@ -100,6 +112,12 @@ public interface ITagService : IService
/// </summary>
IEnumerable<ITag> GetAllMemberTags(string? group = null, string? culture = null);
/// <summary>
/// Gets all element tags.
/// </summary>
// TODO (V19): Remove the default implementation from this interface.
IEnumerable<ITag> GetAllElementTags(string? group = null, string? culture = null) => [];
/// <summary>
/// Gets all tags attached to an entity via a property.
/// </summary>
@@ -87,7 +87,15 @@ internal sealed class MediaEditingService
/// <inheritdoc />
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(MediaCreateModel createModel)
=> await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
{
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
{
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
}
return await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
}
/// <inheritdoc />
public async Task<Attempt<MediaCreateResult, ContentEditingOperationStatus>> CreateAsync(MediaCreateModel createModel, Guid userKey)
@@ -169,6 +177,13 @@ internal sealed class MediaEditingService
public async Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey)
=> await HandleSortAsync(parentKey, sortingModels, userKey);
/// <inheritdoc />
public async Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, Guid userKey)
// Media never varies by culture, so children are always ordered by the invariant name.
=> await HandleSortByFieldAsync(parentKey, field, direction, culture: null, userKey);
/// <inheritdoc />
protected override IMedia New(string name, int parentId, IMediaType mediaType)
=> new Models.Media(name, parentId, mediaType);
@@ -191,8 +206,8 @@ internal sealed class MediaEditingService
=> ContentService.Delete(media, userId).Result;
/// <inheritdoc />
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total);
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, filter: null, ordering: ordering);
/// <inheritdoc />
protected override ContentEditingOperationStatus Sort(IEnumerable<IMedia> items, int userId)
@@ -203,6 +218,13 @@ internal sealed class MediaEditingService
: ContentEditingOperationStatus.CancelledByNotification;
}
/// <inheritdoc />
protected override ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId)
{
OperationResult result = ContentService.SortChildren(parentId, orderedChildIds, userId);
return OperationResultToOperationStatus(result);
}
/// <summary>
/// Saves a media item to the repository.
/// </summary>
+46
View File
@@ -1309,6 +1309,15 @@ namespace Umbraco.Cms.Core.Services
{
scope.WriteLock(Constants.Locks.MediaTree);
// Reload within the lock so sorting operates on fully-loaded entities. Callers may pass
// partially-loaded media (e.g. without property data), and saving those directly would
// wipe the property data (#23120). Preserve the caller's ordering, which drives the sort.
var reloadedById = GetByIds(itemsA.Select(x => x.Id)).ToDictionary(x => x.Id);
itemsA = itemsA
.Select(x => reloadedById.TryGetValue(x.Id, out IMedia? media) ? media : null)
.WhereNotNull()
.ToArray();
var savingNotification = new MediaSavingNotification(itemsA, messages);
if (scope.Notifications.PublishCancelable(savingNotification))
{
@@ -1347,6 +1356,43 @@ namespace Umbraco.Cms.Core.Services
}
/// <inheritdoc />
public OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
{
EventMessages evtMsgs = EventMessagesFactory.Get();
if (orderedChildIds.Count == 0)
{
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
}
using ICoreScope scope = ScopeProvider.CreateCoreScope();
scope.WriteLock(Constants.Locks.MediaTree);
_mediaRepository.UpdateSortOrder(orderedChildIds);
// Sort order lives in umbracoNode; neither the published cache nor the media repository cache keeps
// a separate serialized copy of it, so refreshing the affected branch (which invalidates both and has
// them reload from umbracoNode) is enough to pick up the new order without re-saving each child.
if (parentId == Constants.System.Root)
{
IMedia[] roots = GetByIds(orderedChildIds).ToArray();
scope.Notifications.Publish(new MediaTreeChangeNotification(roots, TreeChangeTypes.RefreshNode, evtMsgs));
}
else
{
IMedia? parent = GetById(parentId);
if (parent is not null)
{
scope.Notifications.Publish(new MediaTreeChangeNotification(parent, TreeChangeTypes.RefreshBranch, evtMsgs));
}
}
Audit(AuditType.Sort, userId, parentId);
scope.Complete();
return OperationResult.Succeed(evtMsgs);
}
/// <summary>
/// Checks the data integrity of the media tree and optionally fixes detected issues.
/// </summary>
+27
View File
@@ -101,6 +101,24 @@ public class TagService : RepositoryService, ITagService
}
}
/// <inheritdoc />
public IEnumerable<TaggedEntity> GetTaggedElementsByTagGroup(string group, string? culture = null)
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
return _tagRepository.GetTaggedEntitiesByTagGroup(TaggableObjectTypes.Element, group, culture);
}
}
/// <inheritdoc />
public IEnumerable<TaggedEntity> GetTaggedElementsByTag(string tag, string? group = null, string? culture = null)
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
return _tagRepository.GetTaggedEntitiesByTag(TaggableObjectTypes.Element, tag, group, culture);
}
}
/// <inheritdoc />
public IEnumerable<ITag> GetAllTags(string? group = null, string? culture = null)
{
@@ -162,6 +180,15 @@ public class TagService : RepositoryService, ITagService
}
}
/// <inheritdoc />
public IEnumerable<ITag> GetAllElementTags(string? group = null, string? culture = null)
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
return _tagRepository.GetTagsForEntityType(TaggableObjectTypes.Element, group, culture);
}
}
/// <inheritdoc />
public IEnumerable<ITag> GetTagsForProperty(int contentId, string propertyTypeAlias, string? group = null, string? culture = null)
{
@@ -1,4 +1,4 @@
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// A background job that will be executed by an available server. With a single server setup this will always be the same.
@@ -16,6 +16,19 @@ public interface IDistributedBackgroundJob
/// </summary>
TimeSpan Period { get; }
/// <summary>
/// Gets a value indicating whether the job's runs should be aligned to clock boundaries derived from <see cref="Period" />.
/// </summary>
/// <remarks>
/// When <c>true</c>, the job becomes runnable on the next clock boundary that is a multiple of <see cref="Period" />
/// (measured from a fixed <strong>UTC</strong> origin, so boundaries fall on round clock times such as on the minute
/// or every N seconds) rather than at <c>LastRun + Period</c>.
/// For predictable boundaries <see cref="Period" /> should divide evenly into one hour.
/// The scheduler may cache this value when it first evaluates registered jobs; changing it at runtime may require an application restart.
/// Defaults to <c>false</c>, preserving the original drift-from-completion behaviour.
/// </remarks>
bool AlignToClock => false;
/// <summary>
/// Run the job.
/// </summary>
@@ -2,7 +2,9 @@
// See LICENSE for more details.
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
@@ -23,7 +25,10 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
public string Name => "ScheduledPublishingJob";
/// <inheritdoc />
public TimeSpan Period => TimeSpan.FromMinutes(1);
public TimeSpan Period => _scheduledPublishingSettings.CurrentValue.Period;
/// <inheritdoc />
public bool AlignToClock => _scheduledPublishingSettings.CurrentValue.AlignToClock;
private readonly IContentService _contentService;
@@ -33,6 +38,7 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
private readonly TimeProvider _timeProvider;
private readonly IServerMessenger _serverMessenger;
private readonly IUmbracoContextFactory _umbracoContextFactory;
private readonly IOptionsMonitor<ScheduledPublishingSettings> _scheduledPublishingSettings;
/// <summary>
/// Initializes a new instance of the <see cref="ScheduledPublishingJob" /> class.
@@ -44,7 +50,8 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
ILogger<ScheduledPublishingJob> logger,
IServerMessenger serverMessenger,
ICoreScopeProvider scopeProvider,
TimeProvider timeProvider)
TimeProvider timeProvider,
IOptionsMonitor<ScheduledPublishingSettings> scheduledPublishingSettings)
{
_contentService = contentService;
_elementService = elementService;
@@ -53,6 +60,7 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
_serverMessenger = serverMessenger;
_scopeProvider = scopeProvider;
_timeProvider = timeProvider;
_scheduledPublishingSettings = scheduledPublishingSettings;
}
/// <inheritdoc />
@@ -4,7 +4,6 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
@@ -26,6 +25,8 @@ public class InstructionProcessJob : RecurringBackgroundJobBase
private readonly ILogger<InstructionProcessJob> _logger;
private readonly IServerMessenger _messenger;
private readonly TimeSpan _syncTimeout;
private Task? _inFlightSync;
/// <summary>
/// Initializes a new instance of the <see cref="InstructionProcessJob" /> class.
@@ -41,27 +42,78 @@ public class InstructionProcessJob : RecurringBackgroundJobBase
{
_messenger = messenger;
_logger = logger;
_syncTimeout = ValidateSyncTimeout(globalSettings.Value.DatabaseServerMessenger.SyncTimeout);
}
// A non-positive timeout would make every sync "time out" immediately (or throw from WaitAsync for a
// negative value), so guard against misconfiguration and fall back to the default. Timeout.InfiniteTimeSpan
// is allowed as an explicit opt-out that restores the unbounded wait.
private TimeSpan ValidateSyncTimeout(TimeSpan configuredSyncTimeout)
{
if (configuredSyncTimeout > TimeSpan.Zero || configuredSyncTimeout == Timeout.InfiniteTimeSpan)
{
return configuredSyncTimeout;
}
_logger.LogWarning(
"Configured DatabaseServerMessenger.SyncTimeout of {ConfiguredSyncTimeout} is not valid; it must be positive (or Timeout.InfiniteTimeSpan to disable the timeout). Falling back to {DefaultSyncTimeout}.",
configuredSyncTimeout,
DatabaseServerMessengerSettings.DefaultSyncTimeout);
return DatabaseServerMessengerSettings.DefaultSyncTimeout;
}
/// <summary>
/// Executes the instruction processing job asynchronously by synchronizing messages using the messenger service.
/// Logs an error if the synchronization fails, but always completes the task.
/// Logs an error if the synchronization fails or stalls, but always completes the task so polling continues.
/// </summary>
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
/// <returns>
/// A completed task representing the asynchronous operation.
/// A task representing the asynchronous operation.
/// </returns>
public override Task RunJobAsync(CancellationToken cancellationToken)
public override async Task RunJobAsync(CancellationToken cancellationToken)
{
// If a previous sync is still running (e.g. blocked on a hung database connection after a timeout),
// skip starting another. This bounds us to a single in-flight call instead of accumulating blocked
// thread-pool threads, and logs the stall once rather than on every interval until it recovers.
if (_inFlightSync is { IsCompleted: false })
{
return;
}
// IServerMessenger.Sync() is synchronous and cannot observe the cancellation token, so a hung database
// connection would otherwise block this job's recurring loop indefinitely and silently stop cache
// polling until the process is recycled. Offload it to the thread pool and bound the wait so the loop
// survives and keeps polling; the in-flight call keeps running until its connection faults (bounded by
// the database command/connection timeout, not by SyncTimeout), after which syncing resumes without a recycle.
//
// The loop is already started under ExecutionContext.SuppressFlow() (see RecurringBackgroundJobHostedService.StartAsync),
// which is what makes offloading the scope-creating Sync() to Task.Run safe for the static ambient scope stack.
var syncTask = Task.Run(_messenger.Sync, cancellationToken);
_inFlightSync = syncTask;
// Observe the task's eventual fault on every exit path (timeout, shutdown cancellation, or a late
// failure once we have stopped awaiting it) so it never surfaces as an UnobservedTaskException.
_ = syncTask.ContinueWith(
static t => _ = t.Exception,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
try
{
_messenger.Sync();
await syncTask.WaitAsync(_syncTimeout, cancellationToken);
_logger.LogDebug("Synchronized cache instructions.");
}
catch (Exception e)
catch (TimeoutException)
{
_logger.LogError(
"Cache instruction sync did not complete within {SyncTimeout} and may be stalled on a hung database connection. Cache updates are paused on this server until the stalled connection recovers.",
_syncTimeout);
}
catch (Exception e) when (e is not OperationCanceledException)
{
_logger.LogError(e, "Failed (will repeat).");
}
return Task.CompletedTask;
}
}
@@ -33,6 +33,8 @@ public class TouchServerJob : RecurringBackgroundJobBase
private readonly IServerRoleAccessor _serverRoleAccessor;
private readonly IDisposable? _onChangeRegistration;
private GlobalSettings _globalSettings;
private TimeSpan _touchTimeout;
private Task? _inFlightTouch;
/// <summary>
/// Initializes a new instance of the <see cref="TouchServerJob" /> class.
@@ -55,11 +57,13 @@ public class TouchServerJob : RecurringBackgroundJobBase
_logger = logger;
_globalSettings = globalSettings.CurrentValue;
_serverRoleAccessor = serverRoleAccessor;
_touchTimeout = ValidateTouchTimeout(globalSettings.CurrentValue.DatabaseServerRegistrar.TouchTimeout);
_onChangeRegistration = globalSettings.OnChange(x =>
{
_globalSettings = x;
Period = x.DatabaseServerRegistrar.WaitTimeBetweenCalls;
_touchTimeout = ValidateTouchTimeout(x.DatabaseServerRegistrar.TouchTimeout);
});
}
@@ -71,14 +75,23 @@ public class TouchServerJob : RecurringBackgroundJobBase
/// <returns>
/// A completed task when the job has finished running.
/// </returns>
public override Task RunJobAsync(CancellationToken cancellationToken)
public override async Task RunJobAsync(CancellationToken cancellationToken)
{
// If the IServerRoleAccessor has been changed away from ElectedServerRoleAccessor this task no longer makes sense,
// since all it's used for is to allow the ElectedServerRoleAccessor
// to figure out what role a given server has, so we just stop this task.
if (_serverRoleAccessor is not ElectedServerRoleAccessor)
{
return Task.CompletedTask;
return;
}
// If a previous touch is still running (e.g. blocked on a hung database connection after a timeout),
// skip starting another. This bounds us to a single in-flight call instead of accumulating blocked
// thread-pool threads (each contending for the servers lock), and logs the stall once rather than on
// every interval until it recovers.
if (_inFlightTouch is { IsCompleted: false })
{
return;
}
var serverAddress = _hostingEnvironment.ApplicationMainUrl?.ToString();
@@ -99,18 +112,56 @@ public class TouchServerJob : RecurringBackgroundJobBase
_logger.LogDebug("Registering server with application URL {ServerAddress}.", serverAddress);
}
// IServerRegistrationService.TouchServer() runs a synchronous database write and cannot observe the
// cancellation token, so a hung connection would otherwise block this job's recurring loop indefinitely
// and silently stop server-registration heartbeats until the process is recycled. Offload it to the
// thread pool and bound the wait so the loop survives and keeps touching.
// (See InstructionProcessJob for the same pattern and the ExecutionContext.SuppressFlow rationale.)
TimeSpan staleServerTimeout = _globalSettings.DatabaseServerRegistrar.StaleServerTimeout;
var touchTask = Task.Run(() => _serverRegistrationService.TouchServer(serverAddress, staleServerTimeout), cancellationToken);
_inFlightTouch = touchTask;
// Observe the task's eventual fault on every exit path (timeout, shutdown cancellation, or a late
// failure once we have stopped awaiting it) so it never surfaces as an UnobservedTaskException.
_ = touchTask.ContinueWith(
static t => _ = t.Exception,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
try
{
_serverRegistrationService.TouchServer(
serverAddress,
_globalSettings.DatabaseServerRegistrar.StaleServerTimeout);
await touchTask.WaitAsync(_touchTimeout, cancellationToken);
_logger.LogDebug("Touched server registration for {ServerAddress}.", serverAddress);
}
catch (Exception ex)
catch (TimeoutException)
{
_logger.LogError(
"Touching the server registration did not complete within {TouchTimeout} and may be stalled on a hung database connection. Server registration is paused on this server until the stalled connection recovers.",
_touchTimeout);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Failed to update server record in database.");
}
}
return Task.CompletedTask;
// A non-positive timeout would make every touch "time out" immediately (or throw from WaitAsync for a
// negative value), so guard against misconfiguration and fall back to the default. Timeout.InfiniteTimeSpan
// is allowed as an explicit opt-out that restores the unbounded wait.
private TimeSpan ValidateTouchTimeout(TimeSpan configuredTouchTimeout)
{
if (configuredTouchTimeout > TimeSpan.Zero || configuredTouchTimeout == Timeout.InfiniteTimeSpan)
{
return configuredTouchTimeout;
}
_logger.LogWarning(
"Configured DatabaseServerRegistrar.TouchTimeout of {ConfiguredTouchTimeout} is not valid; it must be positive (or Timeout.InfiniteTimeSpan to disable the timeout). Falling back to {DefaultTouchTimeout}.",
configuredTouchTimeout,
DatabaseServerRegistrarSettings.DefaultTouchTimeout);
return DatabaseServerRegistrarSettings.DefaultTouchTimeout;
}
/// <inheritdoc />
@@ -466,6 +466,7 @@ public static partial class UmbracoBuilderExtensions
.AddNotificationHandler<MemberTypeChangedNotification, MemberTypeChangedDistributedCacheNotificationHandler>()
.AddNotificationHandler<ContentTreeChangeNotification, ContentTreeChangeDistributedCacheNotificationHandler>()
.AddNotificationHandler<ElementTreeChangeNotification, ElementTreeChangeDistributedCacheNotificationHandler>()
.AddNotificationHandler<EntityContainerDeletedNotification, ElementContainerDeletedDistributedCacheNotificationHandler>()
;
// add notification handlers for auditing
@@ -91,7 +91,7 @@ public static partial class UmbracoBuilderExtensions
builder.AddNotificationHandler<ExternalMemberCacheRefresherNotification, ExternalMemberIndexingNotificationHandler>();
builder.AddNotificationAsyncHandler<LanguageCacheRefresherNotification, LanguageIndexingNotificationHandler>();
builder.AddNotificationHandler<UmbracoRequestBeginNotification, RebuildOnStartupHandler>();
builder.AddNotificationAsyncHandler<UmbracoApplicationStartedNotification, RebuildOnStartedHandler>();
return builder;
}
@@ -170,13 +170,7 @@ public class ContentIndexPopulator : IndexPopulator<IUmbracoContentIndex>
{
content = _contentService.GetPagedDescendants(contentParentId, pageIndex, pageSize, out _).ToArray();
var valueSets = _contentValueSetBuilder.GetValueSets(content).ToArray();
// ReSharper disable once PossibleMultipleEnumeration
foreach (IIndex index in indexes)
{
index.IndexItems(valueSets);
}
ValueSetIndexer.IndexItems(indexes, _contentValueSetBuilder.GetValueSets(content));
pageIndex++;
}
@@ -216,12 +210,7 @@ public class ContentIndexPopulator : IndexPopulator<IUmbracoContentIndex>
}
}
var valueSets = _contentValueSetBuilder.GetValueSets(indexableContent.ToArray()).ToArray();
foreach (IIndex index in indexes)
{
index.IndexItems(valueSets);
}
ValueSetIndexer.IndexItems(indexes, _contentValueSetBuilder.GetValueSets(indexableContent.ToArray()));
pageIndex++;
}
@@ -49,13 +49,7 @@ internal sealed class DeliveryApiContentIndexPopulator : IndexPopulator
_deliveryApiContentIndexHelper.EnumerateApplicableDescendantsForContentIndex(
Constants.System.Root,
descendants =>
{
ValueSet[] valueSets = _deliveryContentIndexValueSetBuilder.GetValueSets(descendants).ToArray();
foreach (IIndex index in indexes)
{
index.IndexItems(valueSets);
}
});
ValueSetIndexer.IndexItems(indexes, _deliveryContentIndexValueSetBuilder.GetValueSets(descendants)));
}
public override bool IsRegistered(IIndex index)
@@ -107,11 +107,7 @@ public class MediaIndexPopulator : IndexPopulator<IUmbracoContentIndex>
{
media = _mediaService.GetPagedDescendants(mediaParentId, pageIndex, _indexingSettings.BatchSize, out _).ToArray();
// ReSharper disable once PossibleMultipleEnumeration
foreach (IIndex index in indexes)
{
index.IndexItems(_mediaValueSetBuilder.GetValueSets(media));
}
ValueSetIndexer.IndexItems(indexes, _mediaValueSetBuilder.GetValueSets(media));
pageIndex++;
}
@@ -41,11 +41,7 @@ public class MemberIndexPopulator : IndexPopulator<IUmbracoMemberIndex>
{
members = _memberService.GetAll(pageIndex, pageSize, out _).ToArray();
// ReSharper disable once PossibleMultipleEnumeration
foreach (IIndex index in indexes)
{
index.IndexItems(_valueSetBuilder.GetValueSets(members));
}
ValueSetIndexer.IndexItems(indexes, _valueSetBuilder.GetValueSets(members));
pageIndex++;
}
@@ -0,0 +1,66 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Infrastructure.Examine;
/// <summary>
/// Handles how the indexes are rebuilt after startup.
/// </summary>
/// <remarks>
/// Once the application has fully started this rebuilds the Examine indexes if they are empty.
/// If it is a cold boot, they are all rebuilt.
/// </remarks>
public sealed class RebuildOnStartedHandler : INotificationAsyncHandler<UmbracoApplicationStartedNotification>
{
// The notification is published again on restart, but the indexes only need to be
// considered for rebuilding once per application lifetime.
private static int _hasRun;
private readonly ISyncBootStateAccessor _syncBootStateAccessor;
private readonly IIndexRebuilder _indexRebuilder;
private readonly IRuntimeState _runtimeState;
/// <summary>
/// Initializes a new instance of the <see cref="Umbraco.Cms.Infrastructure.Examine.RebuildOnStartedHandler"/> class, responsible for handling index rebuilds during application startup.
/// </summary>
/// <param name="syncBootStateAccessor">Provides access to the application's synchronous boot state, used to determine if the system is ready for index rebuilding.</param>
/// <param name="indexRebuilder">The service responsible for rebuilding Examine indexes.</param>
/// <param name="runtimeState">Provides information about the current runtime state of the Umbraco application.</param>
public RebuildOnStartedHandler(
ISyncBootStateAccessor syncBootStateAccessor,
IIndexRebuilder indexRebuilder,
IRuntimeState runtimeState)
{
_syncBootStateAccessor = syncBootStateAccessor;
_indexRebuilder = indexRebuilder;
_runtimeState = runtimeState;
}
/// <summary>
/// Once the application has fully started, schedule an index rebuild for any empty indexes (or all if it's a cold boot).
/// </summary>
/// <param name="notification">The notification.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public async Task HandleAsync(UmbracoApplicationStartedNotification notification, CancellationToken cancellationToken)
{
if (_runtimeState.Level != RuntimeLevel.Run)
{
return;
}
if (Interlocked.CompareExchange(ref _hasRun, 1, 0) != 0)
{
return;
}
SyncBootState bootState = _syncBootStateAccessor.GetSyncBootState();
// if it's not a cold boot, only rebuild empty ones
await _indexRebuilder.RebuildIndexesAsync(
bootState != SyncBootState.ColdBoot,
TimeSpan.FromMinutes(1));
}
}
@@ -13,6 +13,7 @@ namespace Umbraco.Cms.Infrastructure.Examine;
/// On the first HTTP request this will rebuild the Examine indexes if they are empty.
/// If it is a cold boot, they are all rebuilt.
/// </remarks>
[Obsolete("Superseded by RebuildOnStartedHandler. Scheduled for removal in Umbraco 19.")]
public sealed class RebuildOnStartupHandler : INotificationHandler<UmbracoRequestBeginNotification>
{
// These must be static because notification handlers are transient.
@@ -0,0 +1,35 @@
using Examine;
namespace Umbraco.Cms.Infrastructure.Examine;
/// <summary>
/// Writes a batch of <see cref="ValueSet" />s to one or more indexes.
/// </summary>
/// <remarks>
/// When a single index is registered the value sets are streamed straight through, so a lazily-built
/// sequence is enumerated once and never fully materialised in memory — keeping the common single-index
/// rebuild's peak memory down. When multiple indexes are registered the sequence is materialised once and
/// reused, so the (potentially expensive) value sets are not rebuilt per index.
/// </remarks>
internal static class ValueSetIndexer
{
public static void IndexItems(IReadOnlyList<IIndex> indexes, IEnumerable<ValueSet> valueSets)
{
switch (indexes.Count)
{
case 0:
return;
case 1:
indexes[0].IndexItems(valueSets);
return;
default:
ValueSet[] materialized = valueSets as ValueSet[] ?? valueSets.ToArray();
foreach (IIndex index in indexes)
{
index.IndexItems(materialized);
}
return;
}
}
}
@@ -107,12 +107,15 @@ public class PackageMigrationRunner
}
/// <summary>
/// Runs the all specified package migration plans and publishes a <see cref="MigrationPlansExecutedNotification" />
/// if all are successful.
/// Runs all the specified package migration plans and publishes a <see cref="MigrationPlansExecutedNotification" />.
/// </summary>
/// <remarks>
/// All plans are run to completion even if one fails, so that one package's failure does not block another's.
/// A failed plan is reported via <see cref="ExecutedMigrationPlan.Successful" /> on the returned result rather
/// than by throwing; callers must inspect the results to detect a failure.
/// </remarks>
/// <param name="plansToRun"></param>
/// <returns></returns>
/// <exception cref="Exception">If any plan fails it will throw an exception.</exception>
public async Task<IEnumerable<ExecutedMigrationPlan>> RunPackagePlansAsync(IEnumerable<string> plansToRun)
{
List<ExecutedMigrationPlan> results = new();
@@ -11,6 +11,7 @@ using Umbraco.Cms.Core.Exceptions;
using Umbraco.Cms.Core.Logging;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Migrations;
using Umbraco.Cms.Infrastructure.Migrations.Install;
using Umbraco.Cms.Infrastructure.Migrations.Upgrade;
using Umbraco.Cms.Infrastructure.Runtime;
@@ -163,7 +164,23 @@ public class UnattendedUpgrader : INotificationAsyncHandler<RuntimeUnattendedUpg
try
{
await _packageMigrationRunner.RunPackagePlansAsync(pendingMigrations);
IEnumerable<ExecutedMigrationPlan> executedPlans =
await _packageMigrationRunner.RunPackagePlansAsync(pendingMigrations);
// Failed plans are reported via the result, not by throwing (the runner deliberately runs all plans to
// completion so one package's failure doesn't block another's). Surface them as a boot failure here so the
// failure is observable, mirroring the core upgrade path - otherwise the migration stays pending and the
// runtime re-derives Upgrading on every boot, leaving the site stuck on the maintenance page.
// All failures are reported together.
var failedPlans = executedPlans.Where(plan => plan.Successful is false).ToList();
if (failedPlans.Count > 0)
{
SetRuntimeError(CreatePackageMigrationError(failedPlans));
notification.UnattendedUpgradeResult =
RuntimeUnattendedUpgradeNotification.UpgradeResult.HasErrors;
return;
}
notification.UnattendedUpgradeResult = RuntimeUnattendedUpgradeNotification.UpgradeResult.PackageMigrationComplete;
// Migration plans may have changed published content, so refresh the distributed cache to ensure consistency on first request.
@@ -200,6 +217,22 @@ public class UnattendedUpgrader : INotificationAsyncHandler<RuntimeUnattendedUpg
}
}
private static Exception CreatePackageMigrationError(IReadOnlyList<ExecutedMigrationPlan> failedPlans)
{
static Exception ToException(ExecutedMigrationPlan plan)
=> plan.Exception ?? new UnattendedInstallException(
$"An error occurred while running the unattended package migration '{plan.Plan.Name}'.");
if (failedPlans.Count == 1)
{
return ToException(failedPlans[0]);
}
return new AggregateException(
$"{failedPlans.Count} unattended package migrations failed: {string.Join(", ", failedPlans.Select(plan => plan.Plan.Name))}.",
failedPlans.Select(ToException));
}
private void SetRuntimeError(Exception exception)
=> _runtimeState.Configure(
RuntimeLevel.BootFailed,
@@ -1273,6 +1273,36 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement
/// </summary>
public abstract int RecycleBinId { get; }
/// <inheritdoc />
public void UpdateSortOrder(IReadOnlyList<int> orderedNodeIds)
{
if (orderedNodeIds.Count == 0)
{
return;
}
var nodeTable = SqlSyntax.GetQuotedTableName(NodeDto.TableName);
var idColumn = SqlSyntax.GetQuotedColumnName(NodeDto.IdColumnName);
var sortOrderColumn = SqlSyntax.GetQuotedColumnName(NodeDto.SortOrderColumnName);
// Each node's new sort order is its position in the ordered collection.
var ordered = orderedNodeIds
.Select((id, sortOrder) => new KeyValuePair<int, int>(id, sortOrder))
.ToList();
// Two parameters per node (id + sort order), so batch to stay within the SQL Server parameter limit.
foreach (IEnumerable<KeyValuePair<int, int>> group in ordered.InGroupsOf(Constants.Sql.MaxParameterCount / 2))
{
List<KeyValuePair<int, int>> groupList = group.ToList();
var args = groupList.SelectMany(pair => new object[] { pair.Key, pair.Value }).ToArray();
var whenClauses = string.Join(" ", groupList.Select((_, i) => $"WHEN @{i * 2} THEN @{(i * 2) + 1}"));
var inClause = string.Join(", ", groupList.Select((_, i) => $"@{i * 2}"));
var sql = $"UPDATE {nodeTable} SET {sortOrderColumn} = CASE {idColumn} {whenClauses} END WHERE {idColumn} IN ({inClause})";
Database.Execute(sql, args);
}
}
/// <summary>
/// Gets all entities that are currently in the recycle bin.
/// </summary>
@@ -647,6 +647,8 @@ ON (tagset.tag = {cmsTags}.tag AND tagset.{group} = {cmsTags}.{group} AND COALES
return Constants.ObjectTypes.Media;
case TaggableObjectTypes.Member:
return Constants.ObjectTypes.Member;
case TaggableObjectTypes.Element:
return Constants.ObjectTypes.Element;
default:
throw new ArgumentOutOfRangeException(nameof(type));
}
@@ -199,7 +199,7 @@ public abstract class DateTimePropertyEditorBase : DataEditor, IValueSchemaProvi
/// <summary>
/// Validates the date time selection for the DateTime2 property editor.
/// </summary>
private class DateTimeValidator : ITypedJsonValidator<DateTimeEditorValue, DateTimeConfiguration>
private class DateTimeValidator : ITypedValidator<DateTimeEditorValue, DateTimeConfiguration>
{
private readonly ILocalizedTextService _localizedTextService;
@@ -511,7 +511,7 @@ public class MediaPicker3PropertyEditor : DataEditor, IValueSchemaProvider
/// <summary>
/// Validates the min/max configuration for the media picker property editor.
/// </summary>
internal sealed class MinMaxValidator : ITypedJsonValidator<List<MediaWithCropsDto>, MediaPicker3Configuration>
internal sealed class MinMaxValidator : ITypedValidator<List<MediaWithCropsDto>, MediaPicker3Configuration>
{
private readonly ILocalizedTextService _localizedTextService;
@@ -573,7 +573,7 @@ public class MediaPicker3PropertyEditor : DataEditor, IValueSchemaProvider
/// <summary>
/// Validates the allowed type configuration for the media picker property editor.
/// </summary>
internal sealed class AllowedTypeValidator : ITypedJsonValidator<List<MediaWithCropsDto>, MediaPicker3Configuration>
internal sealed class AllowedTypeValidator : ITypedValidator<List<MediaWithCropsDto>, MediaPicker3Configuration>
{
private readonly ILocalizedTextService _localizedTextService;
private readonly IMediaService _mediaService;
@@ -615,10 +615,26 @@ public class MediaPicker3PropertyEditor : DataEditor, IValueSchemaProvider
.Where(x => x.MediaTypeAlias.IsNullOrWhiteSpace() is false)
.Select(x => x.MediaTypeAlias);
IEnumerable<Guid> retrievedMediaKeys = value
Guid[] retrievedMediaKeys = value
.Where(x => x.MediaTypeAlias.IsNullOrWhiteSpace())
.Select(x => x.MediaKey);
IEnumerable<IMedia> retrievedMedia = _mediaService.GetByIds(retrievedMediaKeys);
.Select(x => x.MediaKey)
.Distinct()
.ToArray();
IMedia[] retrievedMedia = _mediaService.GetByIds(retrievedMediaKeys).ToArray();
// If any of the media we had to look up (to resolve the type) could not be found, the selection
// references media that no longer exists, so the configured allowed types cannot be verified.
// Compare against the distinct requested keys so duplicates aren't reported as missing.
if (retrievedMedia.Length != retrievedMediaKeys.Length)
{
return
[
new ValidationResult(
_localizedTextService.Localize("validation", "missingMedia"),
["value"])
];
}
IEnumerable<string> retrievedTypeAliases = retrievedMedia
.Select(x => x.ContentType.Alias);
@@ -644,7 +660,7 @@ public class MediaPicker3PropertyEditor : DataEditor, IValueSchemaProvider
/// <summary>
/// Validates the start node configuration for the media picker property editor.
/// </summary>
internal sealed class StartNodeValidator : ITypedJsonValidator<List<MediaWithCropsDto>, MediaPicker3Configuration>
internal sealed class StartNodeValidator : ITypedValidator<List<MediaWithCropsDto>, MediaPicker3Configuration>
{
private readonly ILocalizedTextService _localizedTextService;
private readonly IMediaNavigationQueryService _mediaNavigationQueryService;
@@ -217,7 +217,7 @@ public class MultiNodeTreePickerPropertyEditor : DataEditor, IValueSchemaProvide
/// <summary>
/// Validates the min/max configuration for the multi-node tree picker property editor.
/// </summary>
internal sealed class MinMaxValidator : ITypedJsonValidator<EditorEntityReference[], MultiNodePickerConfiguration>
internal sealed class MinMaxValidator : ITypedValidator<EditorEntityReference[], MultiNodePickerConfiguration>
{
private readonly ILocalizedTextService _localizedTextService;
@@ -275,7 +275,7 @@ public class MultiNodeTreePickerPropertyEditor : DataEditor, IValueSchemaProvide
/// <summary>
/// Validates the selected object type for the multi-node tree picker property editor.
/// </summary>
internal sealed class ObjectTypeValidator : ITypedJsonValidator<EditorEntityReference[], MultiNodePickerConfiguration>
internal sealed class ObjectTypeValidator : ITypedValidator<EditorEntityReference[], MultiNodePickerConfiguration>
{
private readonly ILocalizedTextService _localizedTextService;
private readonly ICoreScopeProvider _coreScopeProvider;
@@ -366,7 +366,7 @@ public class MultiNodeTreePickerPropertyEditor : DataEditor, IValueSchemaProvide
/// <summary>
/// Validates the selected content type for the multi-node tree picker property editor.
/// </summary>
internal sealed class ContentTypeValidator : ITypedJsonValidator<EditorEntityReference[], MultiNodePickerConfiguration>
internal sealed class ContentTypeValidator : ITypedValidator<EditorEntityReference[], MultiNodePickerConfiguration>
{
private readonly ILocalizedTextService _localizedTextService;
private readonly ICoreScopeProvider _coreScopeProvider;
@@ -391,7 +391,7 @@ public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference, I
public string? Culture { get; set; }
}
internal sealed class MinMaxValidator : ITypedJsonValidator<LinkDisplay[], MultiUrlPickerConfiguration>
internal sealed class MinMaxValidator : ITypedValidator<LinkDisplay[], MultiUrlPickerConfiguration>
{
private readonly ILocalizedTextService _localizedTextService;
@@ -2,8 +2,6 @@ using System.Diagnostics.CodeAnalysis;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Extensions;
@@ -49,6 +47,7 @@ public abstract class SystemTextJsonSerializerBase : IJsonSerializer
value = jsonString.IsNullOrWhiteSpace()
? null
: Deserialize<T>(jsonString);
return value != null;
}
}
@@ -18,6 +18,10 @@ public class DistributedJobService : IDistributedJobService
private readonly ILogger<DistributedJobService> _logger;
private readonly DistributedJobSettings _settings;
// Which jobs align to the clock is a startup configuration concern (changing it requires a restart), so it is
// captured once in the constructor rather than re-evaluated on every poll.
private readonly HashSet<string> _clockAlignedJobNames;
/// <summary>
/// Initializes a new instance of the <see cref="DistributedJobService"/> class.
/// </summary>
@@ -38,6 +42,10 @@ public class DistributedJobService : IDistributedJobService
_distributedBackgroundJobs = distributedBackgroundJobs;
_logger = logger;
_settings = settings.Value;
_clockAlignedJobNames = _distributedBackgroundJobs
.Where(x => x.AlignToClock)
.Select(x => x.Name)
.ToHashSet();
}
/// <inheritdoc />
@@ -47,9 +55,12 @@ public class DistributedJobService : IDistributedJobService
scope.EagerWriteLock(Constants.Locks.DistributedJobs);
DateTime utcNow = DateTime.UtcNow;
IEnumerable<DistributedBackgroundJobModel> jobs = _distributedJobRepository.GetAll();
DistributedBackgroundJobModel? job = jobs.FirstOrDefault(x => x.LastRun < DateTime.UtcNow - x.Period
&& (x.IsRunning is false || x.LastAttemptedRun < DateTime.UtcNow - x.Period - _settings.MaximumExecutionTime));
DistributedBackgroundJobModel? job = jobs.FirstOrDefault(x =>
IsDue(x, utcNow, _clockAlignedJobNames.Contains(x.Name))
&& (x.IsRunning is false || x.LastAttemptedRun < utcNow - x.Period - _settings.MaximumExecutionTime));
if (job is null)
{
@@ -77,6 +88,39 @@ public class DistributedJobService : IDistributedJobService
return distributedJob;
}
/// <summary>
/// Determines whether a job is due to run.
/// </summary>
/// <param name="job">The job state.</param>
/// <param name="utcNow">The current UTC time.</param>
/// <param name="aligned">
/// Whether the job's runs are aligned to clock boundaries (see <see cref="IDistributedBackgroundJob.AlignToClock" />).
/// </param>
/// <remarks>
/// For non-aligned jobs the period counts from the previous run's completion (<c>LastRun + Period</c>, drifting).
/// For aligned jobs the job is due once a clock boundary — a multiple of the period measured from a fixed UTC
/// origin, so boundaries fall on round clock times such as on the minute — has fallen strictly after the previous
/// run's completion. Boundaries are in UTC, not the server's local time zone. This is overrun-safe: if a run takes
/// longer than the period, the boundary it would have targeted has already passed, so the missed boundary is
/// skipped rather than triggering back-to-back runs.
/// </remarks>
internal static bool IsDue(DistributedBackgroundJobModel job, DateTime utcNow, bool aligned)
{
if (aligned == false || job.Period <= TimeSpan.Zero)
{
return job.LastRun < utcNow - job.Period;
}
long periodTicks = job.Period.Ticks;
// Floor the current UTC time to the most recent clock boundary. Ticks count from a fixed origin (0001-01-01), and
// a day divides evenly by any clean sub-hour period, so boundaries fall on round clock times (e.g. each :10s).
long ticksSinceBoundary = utcNow.Ticks % periodTicks;
long currentBoundaryTicks = utcNow.Ticks - ticksSinceBoundary;
return currentBoundaryTicks > job.LastRun.Ticks;
}
/// <inheritdoc />
public async Task FinishAsync(string jobName)
{
@@ -116,11 +160,25 @@ public class DistributedJobService : IDistributedJobService
return;
}
// Clock-aligned jobs only hit their boundaries as tightly as the poll interval allows. If the poll interval
// is longer than the job's period, boundaries between polls are silently missed.
foreach (IDistributedBackgroundJob job in _distributedBackgroundJobs)
{
if (job.AlignToClock && job.Period < _settings.Period)
{
_logger.LogWarning(
"Distributed background job '{JobName}' aligns to the clock with a period of {Period}, but the distributed job poll interval is longer ({PollInterval}). Clock boundaries shorter than the poll interval will be missed; set Umbraco:CMS:DistributedJobs:Period to be no longer than the job period.",
job.Name,
job.Period,
_settings.Period);
}
}
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
scope.WriteLock(Constants.Locks.DistributedJobs);
DistributedBackgroundJobModel[] existingJobs = _distributedJobRepository.GetAll().ToArray();
var existingJobsByName = existingJobs.ToDictionary(x => x.Name);
Dictionary<string, DistributedBackgroundJobModel> existingJobsByName = existingJobs.ToDictionary(x => x.Name);
// Collect all changes first, then execute - minimizes time spent in the critical section
var jobsToAdd = new List<DistributedBackgroundJobModel>();
@@ -83,6 +83,7 @@ public static class UmbracoBuilderExtensions
builder.AddNotificationHandler<ContentTypeChangedNotification, DeferredCacheRebuildNotificationHandler>();
builder.AddNotificationHandler<MediaTypeChangedNotification, DeferredCacheRebuildNotificationHandler>();
builder.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, SeedingNotificationHandler>();
builder.AddNotificationHandler<UmbracoApplicationStartingNotification, DomainCacheSeedingNotificationHandler>();
builder.AddCacheSeeding();
return builder;
}
@@ -0,0 +1,21 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Infrastructure.HybridCache.Extensions;
/// <summary>
/// Provides extension methods for <see cref="IRuntimeState"/> used by the cache startup notification handlers.
/// </summary>
internal static class RuntimeStateExtensions
{
/// <summary>
/// Returns true when startup cache seeding should be skipped because the site is not yet serving
/// front-end content, i.e. it is installing (or below) or upgrading with the maintenance page shown.
/// </summary>
/// <param name="state">The runtime state.</param>
/// <param name="globalSettings">The global settings.</param>
public static bool ShouldSkipStartupSeeding(this IRuntimeState state, GlobalSettings globalSettings)
=> state.Level <= RuntimeLevel.Install
|| (state.Level == RuntimeLevel.Upgrade && globalSettings.ShowMaintenancePageWhenInUpgradeState);
}
@@ -0,0 +1,34 @@
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.HybridCache.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers;
internal sealed class DomainCacheSeedingNotificationHandler : INotificationHandler<UmbracoApplicationStartingNotification>
{
private readonly IDomainCacheService _domainCacheService;
private readonly IRuntimeState _runtimeState;
private readonly GlobalSettings _globalSettings;
public DomainCacheSeedingNotificationHandler(IDomainCacheService domainCacheService, IRuntimeState runtimeState, IOptions<GlobalSettings> globalSettings)
{
_domainCacheService = domainCacheService;
_runtimeState = runtimeState;
_globalSettings = globalSettings.Value;
}
public void Handle(UmbracoApplicationStartingNotification notification)
{
if (_runtimeState.ShouldSkipStartupSeeding(_globalSettings))
{
return;
}
// Force eager population of the lazily-loaded domain cache.
_domainCacheService.GetAll(includeWildcards: true);
}
}
@@ -1,11 +1,10 @@
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.HybridCache.Services;
using Umbraco.Cms.Infrastructure.HybridCache.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers;
@@ -35,7 +34,7 @@ internal sealed class SeedingNotificationHandler : INotificationAsyncHandler<Umb
UmbracoApplicationStartingNotification notification,
CancellationToken cancellationToken)
{
if (_runtimeState.Level <= RuntimeLevel.Install || (_runtimeState.Level == RuntimeLevel.Upgrade && _globalSettings.ShowMaintenancePageWhenInUpgradeState))
if (_runtimeState.ShouldSkipStartupSeeding(_globalSettings))
{
return;
}
@@ -38,6 +38,20 @@ internal sealed class DocumentCacheService : IDocumentCacheService
private readonly ConcurrentDictionary<string, IPublishedContent> _publishedContentCache = [];
// Monotonic counter bumped whenever the in-memory cache (L0/L1) is invalidated or refreshed.
// GetNodeAsync captures it before reading the backing store and re-checks it before writing
// back, so a snapshot read before a concurrent publish/refresh is never written over the
// refreshed entry — preventing the stale-set clobber that otherwise persists until a full clear.
//
// Deliberately a single global counter, not per-key: any invalidation invalidates every in-flight
// read-through. The only cost is an occasional skipped cache population when a read-through for one
// key overlaps an unrelated publish — a re-miss on the next request, never stale data. A per-key
// scheme would avoid that but needs a global epoch for bulk clears plus an exact per-key bump on
// every mutated cache key, which is easy to get wrong and would silently reintroduce the clobber.
// Global is correctness-robust; only revisit if read-through churn under heavy concurrent
// publishing ever shows up in profiling.
private long _cacheGeneration;
private HashSet<Guid> SeedKeys
{
get
@@ -129,15 +143,28 @@ internal sealed class DocumentCacheService : IDocumentCacheService
}
(bool exists, ContentCacheNode? contentCacheNode) = await _hybridCache.TryGetValueAsync<ContentCacheNode?>(cacheKey, CancellationToken.None);
// A value found in the backing store is already current, so it can always populate the caches
// below; only a value built from the read-through DB fetch needs the generation guard.
bool snapshotIsCurrent = true;
if (exists is false)
{
// Capture the cache generation before reading the backing store. If a concurrent publish or
// invalidation bumps the generation while we read and build below, the snapshot we hold is
// stale and must not be written back over the refreshed entries (the clobber that leaves
// memory permanently stale until a full clear).
long generation = Interlocked.Read(ref _cacheGeneration);
bool ancestorCheckFailed;
(contentCacheNode, ancestorCheckFailed) = await GetContentCacheNodeFromRepo();
snapshotIsCurrent = IsCacheGenerationCurrent(generation);
// Only cache the result if the ancestor check didn't fail.
// When content exists in DB but the ancestor check fails, this could be a transient
// race condition during cache rebuild. Caching null would poison the distributed cache.
if (ancestorCheckFailed is false)
// Skip the write when the generation moved — a refresh has superseded this snapshot.
if (ancestorCheckFailed is false && snapshotIsCurrent)
{
await _hybridCache.SetAsync(
cacheKey,
@@ -153,7 +180,10 @@ internal sealed class DocumentCacheService : IDocumentCacheService
}
IPublishedContent? result = _publishedContentFactory.ToIPublishedContent(contentCacheNode, preview).CreateModel(_publishedModelFactory);
if (result is not null)
// Only populate the L0 cache when our snapshot is still current; otherwise a concurrent
// refresh has already written fresher content and we must not overwrite it with this one.
if (result is not null && snapshotIsCurrent)
{
_publishedContentCache[cacheKey] = result;
}
@@ -185,6 +215,13 @@ internal sealed class DocumentCacheService : IDocumentCacheService
private bool GetPreview() => _previewService.IsInPreview();
// Bumped after every in-memory cache invalidation/refresh so in-flight read-through snapshots
// (see GetNodeAsync) can detect they have been superseded and skip writing back stale content.
private void InvalidateMemoryCacheGeneration() => Interlocked.Increment(ref _cacheGeneration);
private bool IsCacheGenerationCurrent(long capturedGeneration)
=> Interlocked.Read(ref _cacheGeneration) == capturedGeneration;
public IEnumerable<IPublishedContent> GetByContentType(IPublishedContentType contentType)
{
using ICoreScope scope = _scopeProvider.CreateCoreScope();
@@ -198,6 +235,10 @@ internal sealed class DocumentCacheService : IDocumentCacheService
public async Task ClearMemoryCacheAsync(CancellationToken cancellationToken)
{
// Bump first so any read-through that read the backing store before this clear is rejected
// when it tries to write back, even while the reseed below is still running.
InvalidateMemoryCacheGeneration();
_publishedContentCache.Clear();
await _hybridCache.RemoveByTagAsync(Constants.Cache.Tags.Content, cancellationToken);
@@ -227,11 +268,13 @@ internal sealed class DocumentCacheService : IDocumentCacheService
var cacheKey = GetCacheKey(publishedNode.Key, false);
await _hybridCache.SetAsync(cacheKey, publishedNode, GetEntryOptions(publishedNode.Key, false), GenerateTags(publishedNode));
_publishedContentCache.Remove(cacheKey, out _);
InvalidateMemoryCacheGeneration();
}
else
{
// Either no published node in the database cache, or the ancestor path is no longer published —
// remove any stale published entry from the local memory cache.
// remove any stale published entry from the local memory cache. ClearPublishedCacheAsync
// bumps the generation itself, so this path is already covered.
await ClearPublishedCacheAsync(key);
}
@@ -425,12 +468,17 @@ internal sealed class DocumentCacheService : IDocumentCacheService
ClearConvertedContentCache(contentTypeIdsAsArray);
}
public void ClearConvertedContentCache() => _publishedContentCache.Clear();
public void ClearConvertedContentCache()
{
_publishedContentCache.Clear();
InvalidateMemoryCacheGeneration();
}
public void ClearConvertedContentCache(IReadOnlyCollection<int> contentTypeIds)
{
var ids = contentTypeIds as int[] ?? contentTypeIds.ToArray();
_publishedContentCache.RemoveAll(content => ids.Contains(content.Value.ContentType.Id));
InvalidateMemoryCacheGeneration();
}
private async Task ClearPublishedCacheAsync(Guid key)
@@ -438,6 +486,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService
var cacheKey = GetCacheKey(key, false);
await _hybridCache.RemoveAsync(cacheKey);
_publishedContentCache.Remove(cacheKey, out _);
InvalidateMemoryCacheGeneration();
}
private static string ContentTypeIdTag(int contentTypeId)
@@ -34,6 +34,20 @@ internal sealed class MediaCacheService : IMediaCacheService
private readonly ConcurrentDictionary<Guid, IPublishedContent> _publishedContentCache = [];
// Monotonic counter bumped whenever the in-memory cache (L0/L1) is invalidated or refreshed.
// GetNodeAsync captures it before reading the backing store and re-checks it before writing
// back, so a snapshot read before a concurrent refresh is never written over the refreshed
// entry — preventing the stale-set clobber that otherwise persists until a full clear.
//
// Deliberately a single global counter, not per-key: any invalidation invalidates every in-flight
// read-through. The only cost is an occasional skipped cache population when a read-through for one
// key overlaps an unrelated refresh — a re-miss on the next request, never stale data. A per-key
// scheme would avoid that but needs a global epoch for bulk clears plus an exact per-key bump on
// every mutated cache key, which is easy to get wrong and would silently reintroduce the clobber.
// Global is correctness-robust; only revisit if read-through churn under heavy concurrent
// refreshing ever shows up in profiling.
private long _cacheGeneration;
private HashSet<Guid>? _seedKeys;
private HashSet<Guid> SeedKeys
{
@@ -124,11 +138,24 @@ internal sealed class MediaCacheService : IMediaCacheService
string cacheKey = GetCacheKey(key);
(bool exists, ContentCacheNode? contentCacheNode) = await _hybridCache.TryGetValueAsync<ContentCacheNode?>(cacheKey, CancellationToken.None);
// A value found in the backing store is already current, so it can always populate the caches
// below; only a value built from the read-through DB fetch needs the generation guard.
bool snapshotIsCurrent = true;
if (exists is false)
{
// Capture the cache generation before reading the backing store. If a concurrent refresh or
// invalidation bumps the generation while we read and build below, the snapshot we hold is
// stale and must not be written back over the refreshed entries (the clobber that leaves
// memory permanently stale until a full clear).
long generation = Interlocked.Read(ref _cacheGeneration);
contentCacheNode = await GetContentCacheNodeFromRepo();
snapshotIsCurrent = IsCacheGenerationCurrent(generation);
// We don't want to cache removed items, this may cause issues if the L2 serializer changes.
if (contentCacheNode is not null)
// Skip the write when the generation moved — a refresh has superseded this snapshot.
if (contentCacheNode is not null && snapshotIsCurrent)
{
await _hybridCache.SetAsync(
cacheKey,
@@ -144,7 +171,10 @@ internal sealed class MediaCacheService : IMediaCacheService
}
IPublishedContent? result = _publishedContentFactory.ToIPublishedMedia(contentCacheNode).CreateModel(_publishedModelFactory);
if (result is not null)
// Only populate the L0 cache when our snapshot is still current; otherwise a concurrent
// refresh has already written fresher content and we must not overwrite it with this one.
if (result is not null && snapshotIsCurrent)
{
_publishedContentCache[key] = result;
}
@@ -160,6 +190,13 @@ internal sealed class MediaCacheService : IMediaCacheService
}
}
// Bumped after every in-memory cache invalidation/refresh so in-flight read-through snapshots
// (see GetNodeAsync) can detect they have been superseded and skip writing back stale content.
private void InvalidateMemoryCacheGeneration() => Interlocked.Increment(ref _cacheGeneration);
private bool IsCacheGenerationCurrent(long capturedGeneration)
=> Interlocked.Read(ref _cacheGeneration) == capturedGeneration;
public async Task<bool> HasContentByIdAsync(int id)
{
Attempt<Guid> keyAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Media);
@@ -186,6 +223,7 @@ internal sealed class MediaCacheService : IMediaCacheService
var cacheNode = _cacheNodeFactory.ToContentCacheNode(media);
await _databaseCacheRepository.RefreshMediaAsync(cacheNode);
_publishedContentCache.Remove(media.Key, out _);
InvalidateMemoryCacheGeneration();
scope.Complete();
}
@@ -263,9 +301,12 @@ internal sealed class MediaCacheService : IMediaCacheService
{
await _hybridCache.SetAsync(GetCacheKey(publishedNode.Key), publishedNode, GetEntryOptions(publishedNode.Key));
_publishedContentCache.Remove(key, out _);
InvalidateMemoryCacheGeneration();
}
else
{
// RemoveFromMemoryCacheAsync → ClearPublishedCacheAsync bumps the generation itself,
// so this path is already covered.
await RemoveFromMemoryCacheAsync(key);
}
@@ -274,6 +315,10 @@ internal sealed class MediaCacheService : IMediaCacheService
public async Task ClearMemoryCacheAsync(CancellationToken cancellationToken)
{
// Bump first so any read-through that read the backing store before this clear is rejected
// when it tries to write back, even while the reseed below is still running.
InvalidateMemoryCacheGeneration();
_publishedContentCache.Clear();
await _hybridCache.RemoveByTagAsync(Constants.Cache.Tags.Media, cancellationToken);
@@ -295,12 +340,17 @@ internal sealed class MediaCacheService : IMediaCacheService
ClearConvertedContentCache(mediaTypeIdsAsArray);
}
public void ClearConvertedContentCache() => _publishedContentCache.Clear();
public void ClearConvertedContentCache()
{
_publishedContentCache.Clear();
InvalidateMemoryCacheGeneration();
}
public void ClearConvertedContentCache(IReadOnlyCollection<int> mediaTypeIds)
{
var ids = mediaTypeIds as int[] ?? mediaTypeIds.ToArray();
_publishedContentCache.RemoveAll(content => ids.Contains(content.Value.ContentType.Id));
InvalidateMemoryCacheGeneration();
}
public void Rebuild(IReadOnlyCollection<int> contentTypeIds)
@@ -358,6 +408,7 @@ internal sealed class MediaCacheService : IMediaCacheService
{
await _hybridCache.RemoveAsync(GetCacheKey(key));
_publishedContentCache.Remove(key, out _);
InvalidateMemoryCacheGeneration();
}
private static string MediaTypeIdTag(int mediaTypeId)
@@ -1,34 +1,120 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Net;
using Umbraco.Cms.Core.Web;
using Umbraco.Extensions;
namespace Umbraco.Cms.Web.Common.AspNetCore;
/// <summary>
/// Resolves the current session identifier and reads, writes and clears session values using the
/// ASP.NET Core <see cref="ISession" /> exposed on the current <see cref="HttpContext" />.
/// </summary>
internal sealed class AspNetCoreSessionManager : ISessionIdResolver, ISessionManager
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IOptions<SessionOptions> _sessionOptions;
private readonly IOptionsMonitor<LoggingSettings> _loggingSettings;
public AspNetCoreSessionManager(IHttpContextAccessor httpContextAccessor) =>
_httpContextAccessor = httpContextAccessor;
public string? SessionId
/// <summary>
/// Initializes a new instance of the <see cref="AspNetCoreSessionManager" /> class.
/// </summary>
/// <param name="httpContextAccessor">Provides access to the current <see cref="HttpContext" />.</param>
/// <param name="sessionOptions">The configured session options, used to determine the session cookie name.</param>
/// <param name="loggingSettings">The logging settings, used to determine how the session id is resolved for log enrichment.</param>
public AspNetCoreSessionManager(
IHttpContextAccessor httpContextAccessor,
IOptions<SessionOptions> sessionOptions,
IOptionsMonitor<LoggingSettings> loggingSettings)
{
get
{
HttpContext? httpContext = _httpContextAccessor.HttpContext;
_httpContextAccessor = httpContextAccessor;
_sessionOptions = sessionOptions;
_loggingSettings = loggingSettings;
}
return IsSessionsAvailable
? httpContext?.Session.Id
: "0";
/// <inheritdoc />
/// <remarks>
/// The resolved value depends on <see cref="LoggingSettings.SessionIdLogging" />: the actual session id
/// (default), a one-way hash of the session cookie, or nothing.
/// </remarks>
public string? SessionId =>
_loggingSettings.CurrentValue.SessionIdLogging switch
{
SessionIdLoggingMode.None => null,
SessionIdLoggingMode.CookieHash => ResolveSessionCookieHash(),
_ => ResolveSessionId(),
};
/// <summary>
/// Resolves the actual ASP.NET Core session id, but only when an established session cookie is present.
/// </summary>
/// <remarks>
/// Reading Session.Id forces a synchronous, blocking load from the session store. When sessions are
/// backed by IDistributedCache (e.g. load-balanced setups), that is a network round-trip incurred on
/// every request that resolves the id for logging - even anonymous requests that never use session.
/// Only an established session sends back the session cookie, so its absence means there is nothing
/// meaningful to load (see #23082).
/// </remarks>
private string? ResolveSessionId()
{
if (IsSessionsAvailable is false)
{
return "0";
}
HttpContext? httpContext = _httpContextAccessor.HttpContext;
if (httpContext is null || TryGetSessionCookieValue(httpContext, out _) is false)
{
return null;
}
return httpContext.Session.Id;
}
/// <summary>
/// If session isn't enabled this will throw an exception so we check
/// Resolves a one-way hash of the session cookie value, which correlates requests to the same session
/// without loading the session from its store.
/// </summary>
private bool IsSessionsAvailable => !(_httpContextAccessor.HttpContext?.Features.Get<ISessionFeature>()?.Session is null);
private string? ResolveSessionCookieHash()
{
HttpContext? httpContext = _httpContextAccessor.HttpContext;
if (httpContext is null || TryGetSessionCookieValue(httpContext, out var cookieValue) is false)
{
return null;
}
// Never log the raw cookie value - it is effectively a bearer token for the session. A one-way hash
// preserves per-session correlation without exposing the cookie and without loading the session.
return cookieValue!.GenerateHash<SHA256>();
}
private bool TryGetSessionCookieValue(HttpContext httpContext, out string? value)
{
var sessionCookieName = _sessionOptions.Value.Cookie.Name;
if (sessionCookieName is null)
{
value = null;
return false;
}
return httpContext.Request.Cookies.TryGetValue(sessionCookieName, out value);
}
/// <summary>
/// Gets a value indicating whether session is available for the current request.
/// </summary>
/// <remarks>
/// Accessing <see cref="HttpContext.Session" /> throws an <see cref="InvalidOperationException" /> when the
/// session middleware has not been configured (i.e. <c>UseSession</c> was not called), so this is checked
/// before reading from or writing to the session.
/// </remarks>
private bool IsSessionsAvailable => _httpContextAccessor.HttpContext?.Features.Get<ISessionFeature>()?.Session is not null;
/// <inheritdoc />
public string? GetSessionValue(string key)
{
if (!IsSessionsAvailable)
@@ -39,6 +125,7 @@ internal sealed class AspNetCoreSessionManager : ISessionIdResolver, ISessionMan
return _httpContextAccessor.HttpContext?.Session.GetString(key);
}
/// <inheritdoc />
public void SetSessionValue(string key, string value)
{
if (!IsSessionsAvailable)
@@ -49,6 +136,7 @@ internal sealed class AspNetCoreSessionManager : ISessionIdResolver, ISessionMan
_httpContextAccessor.HttpContext?.Session.SetString(key, value);
}
/// <inheritdoc />
public void ClearSessionValue(string key)
{
if (!IsSessionsAvailable)
+44 -36
View File
@@ -5,6 +5,7 @@ TypeScript/Lit web components library for the Umbraco CMS backoffice. Published
## Documentation Structure
### Architecture & Design
- **[Architecture](./docs/architecture.md)** - Technology stack, design philosophy, developer roles, package system, import map pipeline, design patterns
- **[Manifests & Aliases](./docs/manifests.md)** - Manifest shape, alias conventions, alias constants, how aliases connect extensions, registration, registry operations, kind merging
- **[Entities](./docs/entities.md)** - Entity types, entity context, how entityType connects workspaces/trees/actions/routing
@@ -18,9 +19,11 @@ TypeScript/Lit web components library for the Umbraco CMS backoffice. Published
- **[Value Summary](./docs/value-summary.md)** - `valueSummary` extension type; rendering compact values in collection views, batch resolver pattern, coordinator
### Development
- **[Commands](./docs/commands.md)** - Build, test, and development commands
### Code Quality
- **[Style Guide](./docs/style-guide.md)** - Naming and formatting conventions
- **[Design Choices](./docs/design-choices.md)** - Visual restraint: icons, colours, buttons, and UX copy
- **[Clean Code](./docs/clean-code.md)** - Best practices and SOLID principles
@@ -28,10 +31,12 @@ TypeScript/Lit web components library for the Umbraco CMS backoffice. Published
- **[Testing](./docs/testing.md)** - Testing strategy, priority by code area, MSW mocking, test patterns
### Troubleshooting
- **[Error Handling](./docs/error-handling.md)** - Error patterns and debugging
- **[Edge Cases](./docs/edge-cases.md)** - Common pitfalls and gotchas
### Security & AI
- **[Security](./docs/security.md)** - XSS prevention, authentication, input validation
- **[Agentic Workflow](./docs/agentic-workflow.md)** - Three-phase AI development process
@@ -41,17 +46,17 @@ TypeScript/Lit web components library for the Umbraco CMS backoffice. Published
**Before performing any of these actions, you MUST read the linked doc first:**
| Before you... | Read |
|----------------|------|
| Deprecate or remove a public API | [docs/deprecation.md](./docs/deprecation.md) — requires **both** `@deprecated` JSDoc **and** runtime `UmbDeprecation` warning |
| Create a new element or component | [docs/style-guide.md](./docs/style-guide.md) |
| Build, style, or write copy for any UI | [docs/design-choices.md](./docs/design-choices.md) — default to no icon, no colour, terse contextual copy |
| Create a repository or data source | [docs/repositories.md](./docs/repositories.md) + [docs/data-flow.md](./docs/data-flow.md) |
| Add error handling or debugging | [docs/error-handling.md](./docs/error-handling.md) |
| Write or modify tests | [docs/testing.md](./docs/testing.md) |
| Work with auth or security | [docs/security.md](./docs/security.md) + [docs/edge-cases.md](./docs/edge-cases.md) |
| Scaffold a new package or module | [docs/package-development.md](./docs/package-development.md) |
| Write or change observers / `Umb*State` usage | [docs/state-system.md](./docs/state-system.md) — states already deduplicate; do not add "is this a re-emit?" guards |
| Before you... | Read |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| Deprecate or remove a public API | [docs/deprecation.md](./docs/deprecation.md) — requires **both** `@deprecated` JSDoc **and** runtime `UmbDeprecation` warning |
| Create a new element or component | [docs/style-guide.md](./docs/style-guide.md) |
| Build, style, or write copy for any UI | [docs/design-choices.md](./docs/design-choices.md) — default to no icon, no colour, terse contextual copy |
| Create a repository or data source | [docs/repositories.md](./docs/repositories.md) + [docs/data-flow.md](./docs/data-flow.md) |
| Add error handling or debugging | [docs/error-handling.md](./docs/error-handling.md) |
| Write or modify tests | [docs/testing.md](./docs/testing.md) |
| Work with auth or security | [docs/security.md](./docs/security.md) + [docs/edge-cases.md](./docs/edge-cases.md) |
| Scaffold a new package or module | [docs/package-development.md](./docs/package-development.md) |
| Write or change observers / `Umb*State` usage | [docs/state-system.md](./docs/state-system.md) — states already deduplicate; do not add "is this a re-emit?" guards |
This is not optional. Skipping these leads to convention violations that are caught in review.
@@ -79,24 +84,24 @@ cd src/Umbraco.Web.UI.Client && npm install && npm run dev
See **[Commands](./docs/commands.md)** for all available commands.
| Task | Command |
|------|---------|
| Development | `npm run dev` |
| Testing (all) | `npm test` |
| Task | Command |
| ----------------------- | --------------------------------------------------------- |
| Development | `npm run dev` |
| Testing (all) | `npm test` |
| Testing (specific file) | `npm test -- --files "src/packages/path/to/file.test.ts"` |
| Build | `npm run build` |
| Lint | `npm run lint:fix` |
| Circular dep check | `npm run check:circular` |
| Build | `npm run build` |
| Lint | `npm run lint:fix` |
| Circular dep check | `npm run check:circular` |
---
## Quick Reference
| Item | Details |
|------|---------|
| **Config** | `package.json`, `vite.config.ts`, `.env` (create `.env.local`) |
| **Element naming** | `umb-{feature}-{component}` for core; package devs use own prefix |
| **Directory structure** | See [Architecture](./docs/architecture.md#architecture-pattern) |
| Item | Details |
| ----------------------- | ----------------------------------------------------------------- |
| **Config** | `package.json`, `vite.config.ts`, `.env` (create `.env.local`) |
| **Element naming** | `umb-{feature}-{component}` for core; package devs use own prefix |
| **Directory structure** | See [Architecture](./docs/architecture.md#architecture-pattern) |
---
@@ -117,6 +122,7 @@ The `npm pack` process (prepack hook) runs `devops/publish/cleanse-pkg.js` which
Uses the `semver` package (npm's own semver library) for robust parsing:
**Pre-release packages (0.x.y)**
```
Input: ^0.85.0 or 0.85.0
Output: >=0.85.0 <1.0.0
@@ -126,6 +132,7 @@ Why: Pre-release caret (^0.85.0) only allows patch updates (0.85.x).
```
**Stable packages with caret (major ≥ 1)**
```
Input: ^3.3.1
Output: ^3.3.1 (kept as-is)
@@ -134,6 +141,7 @@ Why: Caret already implements the correct range: >=3.3.1 <4.0.0
```
**Stable exact versions (major ≥ 1)**
```
Input: 3.16.0 (from @tiptap/*)
Output: ^3.16.0
@@ -145,14 +153,14 @@ Why: Normalizes to conventional semver format
```json
{
"peerDependencies": {
"lit": "^3.3.1",
"rxjs": "^7.8.2",
"@umbraco-ui/uui": "^2.0.0-alpha.1",
"monaco-editor": "^0.55.1",
"@tiptap/core": "^3.16.0",
"@hey-api/openapi-ts": ">=0.85.0 <1.0.0"
}
"peerDependencies": {
"lit": "^3.3.1",
"rxjs": "^7.8.2",
"@umbraco-ui/uui": "^2.0.0",
"monaco-editor": "^0.55.1",
"@tiptap/core": "^3.16.0",
"@hey-api/openapi-ts": ">=0.85.0 <1.0.0"
}
}
```
@@ -168,9 +176,9 @@ When using `@umbraco-cms/backoffice`:
### Key Files
| File | Purpose |
|------|---------|
| `package.json` | Root package with exports and workspace references |
| File | Purpose |
| ------------------------------- | ---------------------------------------------------------------- |
| `package.json` | Root package with exports and workspace references |
| `devops/publish/cleanse-pkg.js` | Script that runs during `npm pack` to hoist and convert versions |
| `src/external/*` | Dependency wrapper packages |
| `src/packages/core` | Contains `@hey-api/openapi-ts` and other utilities |
| `src/external/*` | Dependency wrapper packages |
| `src/packages/core` | Contains `@hey-api/openapi-ts` and other utilities |
+4 -4
View File
@@ -3963,9 +3963,9 @@
"link": true
},
"node_modules/@umbraco-ui/uui": {
"version": "2.0.0-rc.2",
"resolved": "https://registry.npmjs.org/@umbraco-ui/uui/-/uui-2.0.0-rc.2.tgz",
"integrity": "sha512-/eRw8byM1zysUF61VrM3MQzZnvuZHuerhiW13eR5vPAxvWT1I5n/jeh+qTKKd3mgZi/rvAV1fnRS05ipqu2hMQ==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@umbraco-ui/uui/-/uui-2.0.0.tgz",
"integrity": "sha512-snH3u1C4gpvO/bxTfTJV6lF3HVpru5v0ssMbz0aESOt66gRyGf//fshgUHgvYa5gc3wE2Fr4uGx8mKgUC/GrXQ==",
"license": "MIT",
"dependencies": {
"culori": "^4.0.2",
@@ -16447,7 +16447,7 @@
"src/external/uui": {
"name": "@umbraco-backoffice/uui",
"dependencies": {
"@umbraco-ui/uui": "^2.0.0-rc.2"
"@umbraco-ui/uui": "^2.0.0"
}
},
"src/libs/class-api": {
+1 -1
View File
@@ -298,4 +298,4 @@
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}
}
@@ -260,12 +260,9 @@ export class UmbAppElement extends UmbLitElement {
// Register Core extensions (this is specifically done here because we need these extensions to be registered before the application is initialized)
onInit(this, umbExtensionsRegistry);
// Register public extensions (login extensions) in parallel with the auth flow below.
const registerPublicExtensions = new UmbServerExtensionRegistrator(
this,
umbExtensionsRegistry,
).registerPublicExtensions();
new UmbAppEntryPointExtensionInitializer(this, umbExtensionsRegistry);
// Register public extensions (login extensions)
await new UmbServerExtensionRegistrator(this, umbExtensionsRegistry).registerPublicExtensions();
const entryPointInitializer = new UmbAppEntryPointExtensionInitializer(this, umbExtensionsRegistry);
// Try to initialise the auth flow and get the runtime status
try {
@@ -281,8 +278,11 @@ export class UmbAppElement extends UmbLitElement {
await this.#setAuthStatus();
}
// The login screen needs the public extensions before routing.
await registerPublicExtensions;
// The login screen decides which auth provider to use from the registered
// `authProvider` extensions. App-entry-points may register or unregister those during
// their async onInit, so wait for them to settle before routing — otherwise on a slow
// connection the decision races and falls back to the local login.
await this.observe(entryPointInitializer.loaded).asPromise();
// Initialise the router
this.#redirect();
@@ -1557,8 +1557,16 @@ export default {
chooseChildNode: 'اختر العقدة الفرعية',
compositionsDescription:
'ارث التبويبات والخصائص من نوع مستند موجود. سيتم إضافة التبويبات الجديدة إلى نوع الوثيقة الحالي أو دمجها إذا كان هناك تبويب بنفس الاسم.',
compositionsDescriptionMediaType:
'ارث التبويبات والخصائص من نوع وسائط موجود. سيتم إضافة التبويبات الجديدة إلى نوع الوسائط الحالي أو دمجها إذا كان هناك تبويب بنفس الاسم.',
compositionsDescriptionMemberType:
'ارث التبويبات والخصائص من نوع عضو موجود. سيتم إضافة التبويبات الجديدة إلى نوع العضو الحالي أو دمجها إذا كان هناك تبويب بنفس الاسم.',
compositionInUse: 'هذا النوع من المحتوى قيد الاستخدام في تركيب، وبالتالي لا يمكن تركيبه بنفسه.\n ',
compositionInUseMediaType: 'هذا النوع من الوسائط قيد الاستخدام في تركيب، وبالتالي لا يمكن تركيبه بنفسه.\n ',
compositionInUseMemberType: 'هذا النوع من الأعضاء قيد الاستخدام في تركيب، وبالتالي لا يمكن تركيبه بنفسه.\n ',
noAvailableCompositions: 'لا توجد أنواع محتوى متاحة لاستخدامها كتركيب.',
noAvailableCompositionsMediaType: 'لا توجد أنواع وسائط متاحة لاستخدامها كتركيب.',
noAvailableCompositionsMemberType: 'لا توجد أنواع أعضاء متاحة لاستخدامها كتركيب.',
compositionRemoveWarning:
'إزالة التركيب ستؤدي إلى حذف جميع بيانات الخصائص المرتبطة. بمجرد حفظ نوع الوثيقة لا يوجد طريق للعودة.',
availableEditors: 'إنشاء جديد',
@@ -1592,6 +1600,8 @@ export default {
tabHasNoSortOrder: 'التبويب ليس له ترتيب فرز',
compositionUsageHeading: 'أين يتم استخدام هذا التركيب؟',
compositionUsageSpecification: 'يتم استخدام هذا التركيب حاليًا في تركيب أنواع المحتوى التالية:\n ',
compositionUsageSpecificationMediaType: 'يتم استخدام هذا التركيب حاليًا في تركيب أنواع الوسائط التالية:\n ',
compositionUsageSpecificationMemberType: 'يتم استخدام هذا التركيب حاليًا في تركيب أنواع الأعضاء التالية:\n ',
variantsHeading: 'السماح بالاختلافات',
cultureVariantHeading: 'السماح بالاختلاف حسب الثقافة',
segmentVariantHeading: 'السماح بالتجزئة',
@@ -1469,8 +1469,16 @@ export default {
chooseChildNode: 'Odaberite podređeni čvor',
compositionsDescription:
'Naslijediti kartice i svojstva iz postojeće vrste dokumenta. Nove kartice će biti\n dodano trenutnoj vrsti dokumenta ili spojeno ako postoji kartica s identičnim imenom.\n ',
compositionsDescriptionMediaType:
'Naslijediti kartice i svojstva iz postojeće vrste medija. Nove kartice će biti\n dodano trenutnoj vrsti medija ili spojeno ako postoji kartica s identičnim imenom.\n ',
compositionsDescriptionMemberType:
'Naslijediti kartice i svojstva iz postojeće vrste člana. Nove kartice će biti\n dodano trenutnoj vrsti člana ili spojeno ako postoji kartica s identičnim imenom.\n ',
compositionInUse: 'Ovaj tip sadržaja se koristi u kompoziciji i stoga se ne može sam sastaviti.\n ',
compositionInUseMediaType: 'Ovaj tip medija se koristi u kompoziciji i stoga se ne može sam sastaviti.\n ',
compositionInUseMemberType: 'Ovaj tip člana se koristi u kompoziciji i stoga se ne može sam sastaviti.\n ',
noAvailableCompositions: 'Nema dostupnih tipova sadržaja za upotrebu kao kompozicija.',
noAvailableCompositionsMediaType: 'Nema dostupnih tipova medija za upotrebu kao kompozicija.',
noAvailableCompositionsMemberType: 'Nema dostupnih tipova člana za upotrebu kao kompozicija.',
compositionRemoveWarning:
'Uklanjanje kompozicije će izbrisati sve povezane podatke o svojstvu. Jednom ti\n sačuvajte tip dokumenta, nema povratka.\n ',
availableEditors: 'Napravi novi',
@@ -1505,6 +1513,8 @@ export default {
tabHasNoSortOrder: 'kartica nema redoslijed sortiranja',
compositionUsageHeading: 'Gdje se koristi ovaj sastav?',
compositionUsageSpecification: 'Ovaj sastav se trenutno koristi u sastavu sljedećih\n tipa sadržaja:\n ',
compositionUsageSpecificationMediaType: 'Ovaj sastav se trenutno koristi u sastavu sljedećih\n tipa medija:\n ',
compositionUsageSpecificationMemberType: 'Ovaj sastav se trenutno koristi u sastavu sljedećih\n tipa člana:\n ',
variantsHeading: 'Dozvoli varijacije',
cultureVariantHeading: 'Dozvolite varirati u zavisnosti od kulture',
segmentVariantHeading: 'Dozvoli segmentaciju',
@@ -1369,8 +1369,16 @@ export default {
chooseChildNode: 'Vybrat podřízený uzel',
compositionsDescription:
'Zdědí záložky a vlastnosti z existujícího typu dokumentu. Nové záložky budou přidány do aktuálního typu dokumentu nebo sloučeny, pokud existuje záložka se stejným názvem.',
compositionsDescriptionMediaType:
'Zdědí záložky a vlastnosti z existujícího typu média. Nové záložky budou přidány do aktuálního typu média nebo sloučeny, pokud existuje záložka se stejným názvem.',
compositionsDescriptionMemberType:
'Zdědí záložky a vlastnosti z existujícího typu člena. Nové záložky budou přidány do aktuálního typu člena nebo sloučeny, pokud existuje záložka se stejným názvem.',
compositionInUse: 'Tento typ obsahu se používá ve složení, a proto jej nelze poskládat.',
compositionInUseMediaType: 'Tento typ média se používá ve složení, a proto jej nelze poskládat.',
compositionInUseMemberType: 'Tento typ člena se používá ve složení, a proto jej nelze poskládat.',
noAvailableCompositions: 'Nejsou k dispozici žádné typy obsahu, které lze použít jako složení.',
noAvailableCompositionsMediaType: 'Nejsou k dispozici žádné typy média, které lze použít jako složení.',
noAvailableCompositionsMemberType: 'Nejsou k dispozici žádné typy člena, které lze použít jako složení.',
compositionRemoveWarning:
'Odebráním složení odstraníte všechna související data vlastností. Jakmile uložíte typ dokumentu, již není cesta zpět.',
availableEditors: 'Vytvořit nové',
@@ -1403,6 +1411,8 @@ export default {
tabHasNoSortOrder: 'záložka nemá žádné řazení',
compositionUsageHeading: 'Kde se toto složení používá?',
compositionUsageSpecification: 'Toto složení se v současnosti používá ve složení následujících typů obsahu:',
compositionUsageSpecificationMediaType: 'Toto složení se v současnosti používá ve složení následujících typů média:',
compositionUsageSpecificationMemberType: 'Toto složení se v současnosti používá ve složení následujících typů člena:',
variantsHeading: 'Povolit různé jazyky',
variantsDescription: 'Povolit editorům vytvářet obsah tohoto typu v různých jazycích.',
allowVaryByCulture: 'Povolit různé jazyky',
@@ -1593,9 +1593,19 @@ export default {
chooseChildNode: 'Dewis nod blentyn',
compositionsDescription:
"Etifeddu tabiau a phriodweddau o fath o ddogfen sy'n bodoli eisoes. Bydd tabiau newydd yn cael eu ychwanegu at y fath o ddogfen bresennol neu eu cyfuno os mae tab gyda enw yr union yr un fath yn bodoli eisoes.",
compositionsDescriptionMediaType:
"Etifeddu tabiau a phriodweddau o fath o gyfrwng sy'n bodoli eisoes. Bydd tabiau newydd yn cael eu ychwanegu at y fath o gyfrwng bresennol neu eu cyfuno os mae tab gyda enw yr union yr un fath yn bodoli eisoes.",
compositionsDescriptionMemberType:
"Etifeddu tabiau a phriodweddau o fath o aelod sy'n bodoli eisoes. Bydd tabiau newydd yn cael eu ychwanegu at y fath o aelod bresennol neu eu cyfuno os mae tab gyda enw yr union yr un fath yn bodoli eisoes.",
compositionInUse:
"Mae'r math o gynnwys yma wedi'i ddefnyddio mewn cyfansoddiad, felly ni ellir ei gyfansoddi ei hunan.",
compositionInUseMediaType:
"Mae'r math o gyfrwng yma wedi'i ddefnyddio mewn cyfansoddiad, felly ni ellir ei gyfansoddi ei hunan.",
compositionInUseMemberType:
"Mae'r math o aelod yma wedi'i ddefnyddio mewn cyfansoddiad, felly ni ellir ei gyfansoddi ei hunan.",
noAvailableCompositions: "Nid oes unrhyw fathau o gynnwys ar gael i'w defnyddio fel cyfansoddiad.",
noAvailableCompositionsMediaType: "Nid oes unrhyw fathau o gyfrwng ar gael i'w defnyddio fel cyfansoddiad.",
noAvailableCompositionsMemberType: "Nid oes unrhyw fathau o aelod ar gael i'w defnyddio fel cyfansoddiad.",
compositionRemoveWarning:
"Bydd dileu cyfansoddiad yn dileu'r holl ddata eiddo priodwedd gysylltiedig. Ar ôl i chi arbed y math o ddogfen, bydd ddim ffordd nôl.",
availableEditors: 'Golygyddion ar gael',
@@ -1633,6 +1643,10 @@ export default {
compositionUsageHeading: "Ble mae'r cyfansoddiad yma'n cael ei ddefnyddio?",
compositionUsageSpecification:
"Mae'r cyfansoddiad yma yn cael ei ddefnyddio'n bresennol yng nghyfansoddiad o'r mathau o gynnwys ganlynol:",
compositionUsageSpecificationMediaType:
"Mae'r cyfansoddiad yma yn cael ei ddefnyddio'n bresennol yng nghyfansoddiad o'r mathau o gyfrwng ganlynol:",
compositionUsageSpecificationMemberType:
"Mae'r cyfansoddiad yma yn cael ei ddefnyddio'n bresennol yng nghyfansoddiad o'r mathau o aelod ganlynol:",
variantsHeading: 'Caniatáu amrywiadau',
cultureVariantHeading: 'Caniatáu amrywiad yn ôl ddiwylliant',
segmentVariantHeading: 'Caniatáu segmentiad',
@@ -1798,9 +1798,19 @@ export default {
chooseChildNode: 'Vælg child node',
compositionsDescription:
'Nedarv faner og egenskaber fra en anden dokumenttype. Nye faner vil blive\n tilføjet den nuværende dokumenttype eller sammenflettet hvis fanenavnene er ens.\n ',
compositionsDescriptionMediaType:
'Nedarv faner og egenskaber fra en anden medietype. Nye faner vil blive\n tilføjet den nuværende medietype eller sammenflettet hvis fanenavnene er ens.\n ',
compositionsDescriptionMemberType:
'Nedarv faner og egenskaber fra en anden medlemstype. Nye faner vil blive\n tilføjet den nuværende medlemstype eller sammenflettet hvis fanenavnene er ens.\n ',
compositionInUse:
'Indholdstypen bliver brugt i en komposition og kan derfor ikke blive anvendt som\n komposition\n ',
compositionInUseMediaType:
'Medietypen bliver brugt i en komposition og kan derfor ikke blive anvendt som\n komposition\n ',
compositionInUseMemberType:
'Medlemstypen bliver brugt i en komposition og kan derfor ikke blive anvendt som\n komposition\n ',
noAvailableCompositions: 'Der er ingen indholdstyper tilgængelige at bruge som komposition',
noAvailableCompositionsMediaType: 'Der er ingen medietyper tilgængelige at bruge som komposition',
noAvailableCompositionsMemberType: 'Der er ingen medlemstyper tilgængelige at bruge som komposition',
compositionRemoveWarning:
'Når du fjerner en komposition vil alle associerede indholdsdata blive slettet.\n Når først dokumenttypen er gemt, er der ingen vej tilbage.\n ',
availableEditors: 'Opret ny indstilling',
@@ -1838,6 +1848,8 @@ export default {
tabHasNoSortOrder: 'fane har ingen sorteringsrækkefølge',
compositionUsageHeading: 'Hvor er denne komposition brugt?',
compositionUsageSpecification: 'Denne komposition brugt i kompositionen af de følgende indholdstyper:\n ',
compositionUsageSpecificationMediaType: 'Denne komposition brugt i kompositionen af de følgende medietyper:\n ',
compositionUsageSpecificationMemberType: 'Denne komposition brugt i kompositionen af de følgende medlemstyper:\n ',
variantsHeading: 'Tillad variationer',
cultureVariantHeading: 'Tillad sprogvariation',
segmentVariantHeading: 'Tillad segmentering',
@@ -1057,7 +1057,7 @@ export default {
greeting5: 'Willkommen',
greeting6: 'Willkommen',
instruction: 'Hier anmelden:',
signInWith: 'Anmelden mit',
signInWith: 'Anmelden mit {0}',
timeout: 'Sitzung abgelaufen',
forgottenPassword: 'Kennwort vergessen?',
forgottenPasswordInstruction:
@@ -1583,9 +1583,19 @@ export default {
chooseChildNode: 'Wählen Sie einen Unterknoten',
compositionsDescription:
'Übernimm Tabs und Eigenschaften vone einem vorhandenen Inhaltstyp. Neue Tabs werden zum vorliegenden Inhaltstyp hinzugefügt oder mit einem gleichnamigen Tab zusammengeführt.',
compositionsDescriptionMediaType:
'Übernimm Tabs und Eigenschaften vone einem vorhandenen Medientyp. Neue Tabs werden zum vorliegenden Medientyp hinzugefügt oder mit einem gleichnamigen Tab zusammengeführt.',
compositionsDescriptionMemberType:
'Übernimm Tabs und Eigenschaften vone einem vorhandenen Mitgliedstyp. Neue Tabs werden zum vorliegenden Mitgliedstyp hinzugefügt oder mit einem gleichnamigen Tab zusammengeführt.',
compositionInUse:
'Dieser Inhaltstyp wird in einer Mischung verwendet und kann deshalb nicht selbst zusammengemischt werden.',
compositionInUseMediaType:
'Dieser Medientyp wird in einer Mischung verwendet und kann deshalb nicht selbst zusammengemischt werden.',
compositionInUseMemberType:
'Dieser Mitgliedstyp wird in einer Mischung verwendet und kann deshalb nicht selbst zusammengemischt werden.',
noAvailableCompositions: 'Es sind keine Inhaltstypen für eine Mischung vorhanden.',
noAvailableCompositionsMediaType: 'Es sind keine Medientypen für eine Mischung vorhanden.',
noAvailableCompositionsMemberType: 'Es sind keine Mitgliedstypen für eine Mischung vorhanden.',
availableEditors: 'Neu anlegen',
reuse: 'Vorhandenen nutzen',
editorSettings: 'Editor-Einstellungen',
@@ -1620,6 +1630,10 @@ export default {
compositionUsageHeading: 'Wo wird diese Mischung verwendet?',
compositionUsageSpecification:
'\n Diese Mischung wird aktuell in den Mischungen folgender Dokumenttypen verwendet:\n ',
compositionUsageSpecificationMediaType:
'\n Diese Mischung wird aktuell in den Mischungen folgender Medientypen verwendet:\n ',
compositionUsageSpecificationMemberType:
'\n Diese Mischung wird aktuell in den Mischungen folgender Mitgliedstypen verwendet:\n ',
variantsHeading: 'Kultur basierte Variationen zulassen',
variantsDescription: 'Editoren erlauben, Inhalt dieses Typs in verschiedenen Sprachen anzulegen',
allowVaryByCulture: 'Kultur basierte Variationen zulassen',

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