Compare commits

...
Author SHA1 Message Date
b836b44343 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:24:35 +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
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
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
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
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
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 5a20452e7a Bump version to 17.5.0-rc3. 2026-06-19 18:44:26 +02:00
Andy Butland adeddeb148 Complete bump version to 17.5.0-rc2. 2026-06-19 15:51:10 +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 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
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
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
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 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
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
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
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 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
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 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
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 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
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
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
Niels LyngsøandGitHub 28cdbe5317 TipTap: Let the stylesheet load parallel to tiptap-extensions (#23024)
do not await stylesheets to be loaded before extensions
2026-06-09 11:14:49 +00:00
3dbd4baefe Backoffice Search: Batch the ancestors lookup for search results to avoid exceeding the maximum URL length (closes #23032) (#23048)
* Ensure requests to fetch ancestors after retrieving search results are batched to avoid a single query exceeding the maximum URL length.

* Guard against undefined ancestor entries from a failed batch

batchTryExecute resolves each chunk via tryExecute, which never rejects, so
a per-chunk failure comes back as a fulfilled result carrying an error and
leaves an undefined hole in the amalgamated data without surfacing an error.
Detect that before mapping and return an explicit error instead of throwing.

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

* Assert ancestor id uniqueness and silence direct-api lint rule

Strengthen the batching tests to assert every search-result id is requested
exactly once (Set size), not just that the total count matches. Add the
no-direct-api-import disable on the controller's api callback, matching the
existing url data sources, since the call is wrapped by the controller.

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

* Addessed Codescene warnings.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:31:37 +02:00
Andy ButlandandGitHub fa5dd209c1 Tags: Fix null reference error in tags input on render (closes #23044) (#23049)
Fix intermittent null reference exception in tags element.
2026-06-09 10:29:27 +02:00
Andy ButlandandGitHub 4cc4acee62 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-07 15:35:38 +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
62663d9573 Runtime Cache: Fix IAppPolicyCache.ClearByKey intermittently failing to clear cached items (closes #23064) (#23068)
* Prevent ClearByKey leaving stale runtime cache items.

* Lowered loop timer.

* Clarified code comment.

* Improve assertions and comments in tests.

---------

Co-authored-by: Kenn Jacobsen <kja@umbraco.dk>
2026-06-05 09:23:26 +02:00
b582f9d2ef Dependencies: Upgrade Examine to 3.8.0 (#23075)
Upgrade Examine to 3.8.0

Co-authored-by: Simon Gibbs <sgibbs@qmu.ac.uk>
2026-06-05 06:59:11 +02:00
ad90db8b38 Performance: Coalesce concurrent tree data requests (Management API client) (#23021)
* perf(tree): coalesce concurrent identical tree data requests

The tree data request manager hit the network on every call, so multiple
concurrent consumers (sidebar tree, breadcrumb structure, pickers) each
fetched the same data independently — e.g. three identical tree/document/root
requests per document-workspace load.

Apply the existing UmbManagementApiInFlightRequestCache (already used by the
item and detail request managers) to the tree request manager via a shared
static cache, coalescing concurrent identical root/children/ancestors/siblings
calls into a single in-flight request, cleared on settle (in-flight only, so
no stale-cache risk). The document tree opts in; other trees are unchanged
until they pass a cache.

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

* test(tree): cover request coalescing; address review feedback

- Add focused tests: concurrent identical root requests share one call,
  the in-flight entry is cleared on settle, and no cache means no coalescing.
- Build the cache key lazily (only when a cache is wired) so non-opted-in
  trees keep the original lightweight path.
- Constrain the #coalesce generic to drop the cast on cache.set.
- Document the new inflightRequestCache arg; trim the comment to one line.

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-04 10:40:58 +02:00
Erik-Jan WestendorpandAndy Butland 8e3b821a55 Localization: Add Dutch translations for create and delete actions (#23039)
Update nl.ts
2026-06-04 08:37:53 +02:00
Andy ButlandandGitHub 8ac989c4e3 Data Types: Tolerate invalid configuration when determining the editor value storage type (closes #23057) (#23058)
* Tolerate invalid data type configuration when getting the editor value storage type.

* Add logging in case of error.

* Resolve warning.

* Removed exception from warning (it's not useful).

* Log an error instead of a warning.
2026-06-04 14:38:21 +09: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
Andy ButlandandGitHub 3913a61b74 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:37:08 +02:00
Lee KelleherandGitHub 90bedcd42e Menu Structure: Guard against use-after-destroy in async structure request (#23055)
* Menu Structure: Guard against use-after-destroy in async structure request

When navigating to a trashed item, the IS_NOT_TRASHED condition initially
permits the standard menu structure context, which is then destroyed once the
workspace confirms the item is trashed. The in-flight async #requestStructure()
could resume after destruction and call setValue() on a completed subject,
throwing "_subject is undefined".

Guard the state mutations with the framework's existing _host-cleared-on-destroy
signal, and handle the previously fire-and-forget #requestStructure() promises so
a teardown mid-request is silently abandoned rather than surfacing as an uncaught
rejection. Applied to both the variant and non-variant menu structure base
contexts.

* Menu Structure: Make #requestStructure non-throwing instead of catching at call sites

Per PR review feedback: replace the blanket .catch(() => {}) wrappers with
early returns inside #requestStructure(). The _host guard already prevents
post-destroy state mutation; the throws only fire for can't-happen missing
observable states and were producing unhandled rejections with no caller
able to act on them.

* Added console warning, if the host is still available
2026-06-03 18:32:29 +02:00
Lee KelleherandGitHub c54189aa90 Block Grid: Guard validator against torn-down manager on navigation (#22852)
* Block Grid: Guard validator against torn-down manager on navigation

The form-control mixin's updated() hook runs validators when the element
re-renders during teardown. If navigation has already disposed _manager,
checkBlockTypeConfigurationValidity would throw "Cannot read properties
of undefined (reading 'getContentTypeKeyOfContentKey')".

Early-return as valid when the manager is gone and use optional chaining
on the per-entry lookup as a safety net.

* Removed optional chaining of `_manager`

As `_manager` has already been checked.

* Reverting the `_manager` optional chaining

As TypeScript compiler doesn't like it, (inside the `filter` callback).
2026-06-03 15:29:41 +02:00
88ec0a248f Media: Restore friendly naming of uploaded media items (closes #22989) (#22998)
* Update uploaded media file name to a friendly name.

* Correct test description for acronym handling.

The case JUST-A-FILE.jpg verifies all-uppercase words are preserved
as acronyms, not that lowercase words get lowercased.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Match server-side StripFileExtension semantics in toFriendlyName.

The TypeScript helper previously delegated to getFileExtension, which
diverges from the C# StripFileExtension on two edge cases:
- a trailing dot ("file.") is stripped by the server but not the client
- an "extension" containing whitespace is preserved by the server but
  stripped by the client

Inlined a stripFileExtension helper that mirrors the C# rules exactly,
making the "keep in sync" cross-reference accurate. Added tests for both
divergent cases and replaced the contrived leading/trailing whitespace
test with a realistic interior-whitespace case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add parity test for trailing-whitespace extension span.

Restores the '  spaced-name.jpg  ' case as a parity test against
StripFileExtension's "extension containing whitespace is preserved"
rule. Output is 'Spaced Name.Jpg' (Jpg title-cased, matching the
server's TextInfo.ToTitleCase behaviour on the now-unstripped extension).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Handle getContext rejection in ensureMediaNameFromFile.

getContext rejects on timeout when the dataset context never resolves;
callers used void ensureMediaNameFromFile(...) so an unhandled rejection
would bubble. Catch the rejection and treat it as an absent context.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 12:37:49 +01:00
Lee KelleherandGitHub 3e22733081 Document Recycle Bin: Checks user permission for Document "Read" (#23041)
* Adds conditions to Document Recycle Bin

that the user must have "Read" permission.

* Directly imports Media Recycle Bin condition

this will remove an extra fetch request.
2026-06-02 16:31:51 +02:00
Andy ButlandandGitHub 232077e820 EF Core: Retry transient SQLite lock errors during long-running operations (closes #22939) (#22969)
* Retry transient SQLite lock errors during long-running operations.

* Addressed code review comments.
2026-06-02 13:59:37 +02:00
Jacob Overgaard 943d1eeccd Merge branch 'release/17.5.0' into v17/dev 2026-06-02 12:44:29 +02:00
Jacob Overgaard b3666dad8b build(deps): bumps @umbraco-ui/uui to 1.18.0 2026-06-02 12:43:21 +02:00
Sven GeusensandAndy Butland 28a403361e Developer Experience: Improve cohost editor polyfill to work with dotnet watch (closes #22773) (#22999)
* Improve cohost polyfill

* Apply suggestions from code review

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

* Move <target/> part of the polyfill to targets file.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-01 14:38:35 +02:00
Jacob OvergaardandClaude Opus 4.7 8e6a791de0 Backoffice: Drop redundant search manifest import from Storybook preview
`core/manifests.ts` already imports and spreads `core/search/manifests.ts`
into its aggregate (line 23 + 58), so importing `searchManifests`
separately in `.storybook/preview.js` and spreading it next to
`coreManifests` registered the same manifests twice. Remove the redundant
import and spread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:47:38 +02:00
Jacob OvergaardandClaude Opus 4.7 5ffea3152b Backoffice: Repoint Storybook preview imports at umbraco-package.ts
PR #22957 deleted every package's `manifests.ts` and consolidated the
exports into `umbraco-package.ts`, but `.storybook/preview.js` still
imported from the old paths. The result was a Vite resolve error during
`npm run build-storybook` (first failure: "Could not resolve
../src/packages/block/manifests from .storybook/preview.js").

37 import paths swapped from `…/<pkg>/manifests` to
`…/<pkg>/umbraco-package`. The two packages that still expose their
manifests via a standalone `manifests.ts` — `core` and `core/search` —
are left untouched.

Verified by `npm run build-storybook` — succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:43:38 +02:00
Jacob OvergaardandClaude Opus 4.7 2043ff1dbd Tiptap: Fix dead manifests.js import in the input-tiptap story
PR #22995 added `input-tiptap.stories.ts` with an import from
`'../../manifests.js'`, but PR #22957 (already on release/17.5.0) had
deleted that file and moved the `manifests` array into
`umbraco-package.ts`. The merge into release/17.5.0 didn't catch the dead
import, so Storybook 404s on the story load.

Point the import at the new home — `manifests` is still exported by name,
so this is a one-line path fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:40:17 +02:00
271 changed files with 11069 additions and 1519 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.
+2
View File
@@ -545,6 +545,8 @@ Allowed, but cheap to write and cheaper to leave behind. Keep them short and tra
Verify any test you add for a bug fix actually catches the bug: either write the failing test first (TDD), or temporarily revert the production change and confirm the test fails before re-applying. A test that passes both ways proves nothing. Watch for coincidental passes — default seed/sort orders can make a buggy path produce the right answer for the test's specific inputs; construct inputs so the broken and fixed behaviours give visibly different results.
For integration tests that exercise caching or cache refreshers, see `tests/Umbraco.Tests.Integration/CLAUDE.md` — the harness disables caching by default, which can produce false greens.
---
## Quick Reference
+4 -4
View File
@@ -45,15 +45,15 @@
<PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.1" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.1" />
<PackageVersion Include="Dazinator.Extensions.FileProviders" Version="2.0.0" />
<PackageVersion Include="Examine" Version="3.7.1" />
<PackageVersion Include="Examine.Core" Version="3.7.1" />
<PackageVersion Include="Examine" Version="3.8.0" />
<PackageVersion Include="Examine.Core" Version="3.8.0" />
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
<PackageVersion Include="JsonPatch.Net" Version="3.3.0" />
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
<PackageVersion Include="MailKit" Version="4.16.0" />
<PackageVersion Include="Markdig" Version="0.45.0" />
<PackageVersion Include="Markdown" Version="2.2.1" />
<PackageVersion Include="MessagePack" Version="3.1.4" />
<PackageVersion Include="MessagePack" Version="3.1.7" />
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
<PackageVersion Include="MiniProfiler.Shared" Version="4.5.4" />
<PackageVersion Include="ncrontab" Version="3.4.0" />
@@ -92,4 +92,4 @@
<!-- TODO: Remove this pinned dependency when Examine updates its Microsoft.AspNetCore.DataProtection reference. -->
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.6" />
</ItemGroup>
</Project>
</Project>
+32 -98
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 }}
@@ -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);
}
}
@@ -1,5 +1,8 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Api.Management.ViewModels.DataType;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Serialization;
@@ -16,6 +19,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
private readonly IDataValueEditorFactory _dataValueEditorFactory;
private readonly IConfigurationEditorJsonSerializer _configurationEditorJsonSerializer;
private readonly TimeProvider _timeProvider;
private readonly ILogger<DataTypePresentationFactory> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DataTypePresentationFactory"/> class, which is responsible for creating data type presentation models.
@@ -25,18 +29,46 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
/// <param name="dataValueEditorFactory">Factory for creating data value editors.</param>
/// <param name="configurationEditorJsonSerializer">Serializer for configuration editor JSON data.</param>
/// <param name="timeProvider">Provides the current time for time-dependent operations.</param>
/// <param name="logger">The logger.</param>
public DataTypePresentationFactory(
IDataTypeContainerService dataTypeContainerService,
PropertyEditorCollection propertyEditorCollection,
IDataValueEditorFactory dataValueEditorFactory,
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer,
TimeProvider timeProvider)
TimeProvider timeProvider,
ILogger<DataTypePresentationFactory> logger)
{
_dataTypeContainerService = dataTypeContainerService;
_propertyEditorCollection = propertyEditorCollection;
_dataValueEditorFactory = dataValueEditorFactory;
_configurationEditorJsonSerializer = configurationEditorJsonSerializer;
_timeProvider = timeProvider;
_logger = logger;
}
/// <summary>
/// Initializes a new instance of the <see cref="DataTypePresentationFactory"/> class, which is responsible for creating data type presentation models.
/// </summary>
/// <param name="dataTypeContainerService">Service used to manage data type containers.</param>
/// <param name="propertyEditorCollection">A collection containing all available property editors.</param>
/// <param name="dataValueEditorFactory">Factory for creating data value editors.</param>
/// <param name="configurationEditorJsonSerializer">Serializer for configuration editor JSON data.</param>
/// <param name="timeProvider">Provides the current time for time-dependent operations.</param>
[Obsolete("Please use the constructor that takes all parameters. Scheduled for removal in Umbraco 19.")]
public DataTypePresentationFactory(
IDataTypeContainerService dataTypeContainerService,
PropertyEditorCollection propertyEditorCollection,
IDataValueEditorFactory dataValueEditorFactory,
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer,
TimeProvider timeProvider)
: this(
dataTypeContainerService,
propertyEditorCollection,
dataValueEditorFactory,
configurationEditorJsonSerializer,
timeProvider,
StaticServiceProvider.Instance.GetRequiredService<ILogger<DataTypePresentationFactory>>())
{
}
/// <inheritdoc />
@@ -72,7 +104,6 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
dataType.Key = requestModel.Id.Value;
}
return Attempt.SucceedWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.Success, dataType);
}
@@ -82,7 +113,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
{
try
{
var parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
EntityContainer? parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
return parent is null
? Attempt.FailWithStatus(DataTypeOperationStatus.ParentNotFound, 0)
@@ -97,6 +128,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
return Attempt.SucceedWithStatus(DataTypeOperationStatus.Success, Constants.System.Root);
}
/// <inheritdoc/>
public Task<Attempt<IDataType, DataTypeOperationStatus>> CreateAsync(UpdateDataTypeRequestModel requestModel, IDataType current)
{
if (!_propertyEditorCollection.TryGet(requestModel.EditorAlias, out IDataEditor? editor))
@@ -104,7 +136,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
return Task.FromResult(Attempt.FailWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.PropertyEditorNotFound, new DataType(new VoidEditor(_dataValueEditorFactory), _configurationEditorJsonSerializer) ));
}
IDataType dataType = (IDataType)current.DeepClone();
var dataType = (IDataType)current.DeepClone();
IDictionary<string, object> configurationData = MapConfigurationData(requestModel, editor);
dataType.Name = requestModel.Name;
@@ -119,12 +151,26 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
private ValueStorageType GetEditorValueStorageType(IDataEditor editor, IDictionary<string, object> configurationData)
{
var configurationObject = editor.GetConfigurationEditor()
.ToConfigurationObject(configurationData, _configurationEditorJsonSerializer);
if (configurationObject is IConfigureValueType configureValueType)
// Only editors whose configuration object implements IConfigureValueType derive their storage
// type from the configuration. Building the typed configuration object can throw for editors
// whose stored configuration doesn't cleanly deserialize into their configuration type; that
// must not fail the save, so fall back to the value editor's value type in that case.
try
{
return ValueTypes.ToStorageType(configureValueType.ValueType);
if (editor.GetConfigurationEditor().ToConfigurationObject(configurationData, _configurationEditorJsonSerializer)
is IConfigureValueType configureValueType)
{
return ValueTypes.ToStorageType(configureValueType.ValueType);
}
}
catch (Exception)
{
// Configuration editors are third-party and can throw anything when the stored configuration
// doesn't deserialize into their configuration type. Fall back to the value editor's value type
// rather than failing the save, but log so the misconfiguration remains observable.
_logger.LogError(
"Could not build the configuration object for editor {EditorAlias} to determine its value storage type; falling back to the value editor's value type.",
editor.Alias);
}
var valueType = editor.GetValueEditor().ValueType;
+546
View File
@@ -10888,6 +10888,150 @@
]
}
},
"/umbraco/management/api/v1/document/{id}/sort-children": {
"put": {
"tags": [
"Document"
],
"summary": "Sorts the children of a document by a field.",
"description": "Sorts the children of the specified parent document by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so children that do not vary by it (or an unrecognised culture) fall back to the invariant name.",
"operationId": "PutDocumentByIdSortChildren",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
},
"text/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
},
"application/*+json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
},
"400": {
"description": "Bad Request",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"404": {
"description": "Not Found",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
},
"403": {
"description": "The authenticated user does not have access to this resource",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
}
},
"security": [
{
"Backoffice-User": [ ]
}
]
}
},
"/umbraco/management/api/v1/document/{id}/unpublish": {
"put": {
"tags": [
@@ -11282,6 +11426,113 @@
]
}
},
"/umbraco/management/api/v1/document/root/sort-children": {
"put": {
"tags": [
"Document"
],
"summary": "Sorts the root-level documents by a field.",
"description": "Sorts the root-level documents by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so documents that do not vary by it (or an unrecognised culture) fall back to the invariant name.",
"operationId": "PutDocumentRootSortChildren",
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
},
"text/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
},
"application/*+json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
},
"400": {
"description": "Bad Request",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
},
"403": {
"description": "The authenticated user does not have access to this resource",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
}
},
"security": [
{
"Backoffice-User": [ ]
}
]
}
},
"/umbraco/management/api/v1/document/sort": {
"put": {
"tags": [
@@ -19158,6 +19409,150 @@
]
}
},
"/umbraco/management/api/v1/media/{id}/sort-children": {
"put": {
"tags": [
"Media"
],
"summary": "Sorts the children of a media item by a field.",
"description": "Sorts the children of the specified parent media item by a system field in the given direction. Media items do not vary by culture, so any supplied culture is ignored.",
"operationId": "PutMediaByIdSortChildren",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
},
"text/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
},
"application/*+json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
},
"400": {
"description": "Bad Request",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"404": {
"description": "Not Found",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
},
"403": {
"description": "The authenticated user does not have access to this resource",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
}
},
"security": [
{
"Backoffice-User": [ ]
}
]
}
},
"/umbraco/management/api/v1/media/{id}/validate": {
"put": {
"tags": [
@@ -19408,6 +19803,113 @@
]
}
},
"/umbraco/management/api/v1/media/root/sort-children": {
"put": {
"tags": [
"Media"
],
"summary": "Sorts the root-level media items by a field.",
"description": "Sorts the root-level media items by a system field in the given direction. Media items do not vary by culture, so any supplied culture is ignored.",
"operationId": "PutMediaRootSortChildren",
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
},
"text/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
},
"application/*+json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
},
"400": {
"description": "Bad Request",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
},
"403": {
"description": "The authenticated user does not have access to this resource",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
}
},
"security": [
{
"Backoffice-User": [ ]
}
]
}
},
"/umbraco/management/api/v1/media/sort": {
"put": {
"tags": [
@@ -39856,6 +40358,14 @@
},
"additionalProperties": false
},
"ContentSortFieldModel": {
"enum": [
"Name",
"CreateDate",
"UpdateDate"
],
"type": "string"
},
"CopyDataTypeRequestModel": {
"type": "object",
"properties": {
@@ -50162,6 +50672,42 @@
},
"additionalProperties": false
},
"SortDocumentChildrenByFieldRequestModel": {
"required": [
"direction",
"field"
],
"type": "object",
"properties": {
"field": {
"$ref": "#/components/schemas/ContentSortFieldModel"
},
"direction": {
"$ref": "#/components/schemas/DirectionModel"
},
"culture": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"SortMediaChildrenByFieldRequestModel": {
"required": [
"direction",
"field"
],
"type": "object",
"properties": {
"field": {
"$ref": "#/components/schemas/ContentSortFieldModel"
},
"direction": {
"$ref": "#/components/schemas/DirectionModel"
}
},
"additionalProperties": false
},
"SortingRequestModel": {
"required": [
"sorting"
@@ -0,0 +1,64 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Security.Authorization;
/// <summary>
/// Authorizes permissions on all direct children of a node.
/// </summary>
internal static class AllChildrenAuthorizer
{
/// <summary>
/// Determines whether the user is authorized for every direct child of the given parent (or the root).
/// </summary>
/// <param name="authorizationService">The authorization service.</param>
/// <param name="entityService">The entity service used to resolve the children.</param>
/// <param name="user">The current user.</param>
/// <param name="parentKey">The parent key, or <c>null</c> to authorize the root-level children.</param>
/// <param name="objectType">The object type of the children (and parent).</param>
/// <param name="resourceFactory">Builds the permission resource to authorize a batch of child keys against.</param>
/// <param name="policy">The authorization policy to apply.</param>
/// <returns><c>true</c> if the user is authorized against all children; otherwise <c>false</c>.</returns>
public static async Task<bool> IsAuthorizedForChildrenAsync(
IAuthorizationService authorizationService,
IEntityService entityService,
ClaimsPrincipal user,
Guid? parentKey,
UmbracoObjectTypes objectType,
Func<IEnumerable<Guid>, IPermissionResource> resourceFactory,
string policy)
{
const int pageSize = 500;
var page = 0;
long total;
do
{
Guid[] childKeys = entityService
.GetPagedChildren(parentKey, [objectType], objectType, page * pageSize, pageSize, out total)
.Select(child => child.Key)
.ToArray();
if (childKeys.Length > 0)
{
AuthorizationResult authorizationResult = await authorizationService.AuthorizeResourceAsync(
user,
resourceFactory(childKeys),
policy);
if (authorizationResult.Succeeded is false)
{
return false;
}
}
page++;
}
while (page * pageSize < total);
return true;
}
}
@@ -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)
@@ -15,8 +15,9 @@ SQLite-specific EF Core provider for Umbraco CMS. Contains SQLite migrations and
This is a thin provider project that implements SQLite-specific functionality for the EF Core persistence layer:
1. **Migration Provider** - Executes SQLite-specific migrations
2. **Migration Provider Setup** - Configures DbContext to use SQLite
2. **Migration Provider Setup** - Configures DbContext to use SQLite (incl. transient-error retry)
3. **Migrations** - SQLite-specific migration files for OpenIddict tables
4. **Retrying Execution Strategy** - Retries transient SQLite lock errors on EF Core operations
### Folder Structure
@@ -30,7 +31,8 @@ Umbraco.Cms.Persistence.EFCore.Sqlite/
│ └── UmbracoDbContextModelSnapshot.cs # Current model state
├── EFCoreSqliteComposer.cs # DI registration
├── SqliteMigrationProvider.cs # IMigrationProvider impl
── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
└── SqliteRetryingExecutionStrategy.cs # IExecutionStrategy for transient lock errors
```
### Relationship with Parent Project
@@ -65,7 +67,19 @@ Registers `IMigrationProvider` and `IMigrationProviderSetup` for SQLite.
### SqliteMigrationProviderSetup (line 11-14)
Configures `DbContextOptionsBuilder` with `UseSqlite` and migrations assembly.
Configures `DbContextOptionsBuilder` with `UseSqlite`, the migrations assembly, and the
`SqliteRetryingExecutionStrategy` (see below). Invoked from
`UmbracoDbContext.ConfigureOptions` for every `UmbracoDbContext` instance, so all EF Core
access to the Umbraco database (including OpenIddict's token store) inherits the retry.
### SqliteRetryingExecutionStrategy
Custom `Microsoft.EntityFrameworkCore.Storage.ExecutionStrategy` that retries on transient
SQLite errors (`SQLITE_BUSY`, `SQLITE_LOCKED`) using `SqliteExceptionExtensions.IsBusyOrLocked`
from the parent project. Defaults inherit `ExecutionStrategy.DefaultMaxRetryCount` (6) and
`ExecutionStrategy.DefaultMaxDelay` (30s), giving a ~56-second retry budget — see the class's
XML doc for the rationale and the unattended-upgrade escape hatch for very long migrations.
Added to resolve issue #22939 (OpenIddict token reads failing during long migrations).
---
@@ -122,7 +136,8 @@ All tables prefixed with `umbraco`:
| File | Purpose |
|------|---------|
| `SqliteMigrationProvider.cs` | Migration execution |
| `SqliteMigrationProviderSetup.cs` | DbContext configuration |
| `SqliteMigrationProviderSetup.cs` | DbContext configuration (UseSqlite + retry strategy) |
| `SqliteRetryingExecutionStrategy.cs` | Retry on transient SQLite BUSY/LOCKED errors |
| `EFCoreSqliteComposer.cs` | DI registration |
| `Migrations/*.cs` | Migration files |
@@ -1,5 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Umbraco.Cms.Core;
using Umbraco.Cms.Persistence.EFCore.Migrations;
namespace Umbraco.Cms.Persistence.EFCore.Sqlite;
@@ -15,6 +14,15 @@ public class SqliteMigrationProviderSetup : IMigrationProviderSetup
/// <inheritdoc />
public void Setup(DbContextOptionsBuilder builder, string? connectionString)
{
builder.UseSqlite(connectionString, x => x.MigrationsAssembly(GetType().Assembly.FullName));
builder.UseSqlite(connectionString, x =>
{
x.MigrationsAssembly(GetType().Assembly.FullName);
// Retry transient SQLite errors (BUSY / LOCKED). See SqliteRetryingExecutionStrategy
// for the rationale — long-running migrations or schema-modifying operations can
// briefly lock the database in a way that surfaces as a hard error to concurrent
// EF Core readers (notably OpenIddict token validation). See issue #22939.
x.ExecutionStrategy(deps => new SqliteRetryingExecutionStrategy(deps));
});
}
}
@@ -0,0 +1,71 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore.Storage;
namespace Umbraco.Cms.Persistence.EFCore.Sqlite;
/// <summary>
/// EF Core execution strategy that retries on transient SQLite errors (BUSY / LOCKED).
/// </summary>
/// <remarks>
/// <para>
/// SQLite serialises writers at the database level, and schema-modifying statements briefly
/// block readers — even in WAL mode. Without retries, concurrent EF Core reads (for example
/// OpenIddict's token validation against <c>umbracoOpenIddictTokens</c>) surface those
/// transient locks as <see cref="SqliteException"/> and fail the caller's request.
/// </para>
/// <para>
/// Microsoft does not ship a built-in execution strategy for SQLite (only the SQL Server
/// equivalent), so we provide this one. It piggy-backs on <see cref="ExecutionStrategy"/>'s
/// default exponential backoff and re-uses its inherited
/// <see cref="ExecutionStrategy.DefaultMaxRetryCount"/> (6) and
/// <see cref="ExecutionStrategy.DefaultMaxDelay"/> (30 seconds), which produce a delay
/// schedule of roughly 0s, 1s, 3s, 7s, 15s, 30s — a ~56-second retry window.
/// </para>
/// <para>
/// On top of those EF Core delays, <c>SQLITE_BUSY</c> (error 5) is also retried internally
/// by Microsoft.Data.Sqlite for up to the connection's <c>Default Timeout</c> (30 seconds
/// by default) per attempt. <c>SQLITE_LOCKED</c> (error 6) is not — it returns immediately,
/// so EF Core's retry budget is the only buffer.
/// </para>
/// </remarks>
public class SqliteRetryingExecutionStrategy : ExecutionStrategy
{
/// <summary>
/// Initializes a new instance of the <see cref="SqliteRetryingExecutionStrategy"/> class
/// with default retry settings inherited from <see cref="ExecutionStrategy"/>.
/// </summary>
/// <param name="dependencies">Parameter object containing service dependencies.</param>
public SqliteRetryingExecutionStrategy(ExecutionStrategyDependencies dependencies)
: this(dependencies, DefaultMaxRetryCount, DefaultMaxDelay)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SqliteRetryingExecutionStrategy"/> class.
/// </summary>
/// <param name="dependencies">Parameter object containing service dependencies.</param>
/// <param name="maxRetryCount">The maximum number of retry attempts.</param>
/// <param name="maxRetryDelay">The maximum delay between retries.</param>
public SqliteRetryingExecutionStrategy(
ExecutionStrategyDependencies dependencies,
int maxRetryCount,
TimeSpan maxRetryDelay)
: base(dependencies, maxRetryCount, maxRetryDelay)
{
}
/// <inheritdoc />
protected override bool ShouldRetryOn(Exception exception)
{
// EF Core wraps provider exceptions, so walk the inner-exception chain.
for (Exception? current = exception; current is not null; current = current.InnerException)
{
if (current is SqliteException sqlite && sqlite.IsBusyOrLocked())
{
return true;
}
}
return false;
}
}
@@ -184,17 +184,11 @@ internal sealed class SqliteEFCoreDistributedLockingMechanism<T> : IDistributedL
throw new ArgumentException($"LockObject with id={LockId} does not exist.");
}
}
catch (SqliteException ex) when (IsBusyOrLocked(ex))
catch (SqliteException ex) when (ex.IsBusyOrLocked())
{
throw new DistributedWriteLockTimeoutException(LockId);
}
});
}
private static bool IsBusyOrLocked(SqliteException ex) =>
ex.SqliteErrorCode
is raw.SQLITE_BUSY
or raw.SQLITE_LOCKED
or raw.SQLITE_LOCKED_SHAREDCACHE;
}
}
@@ -0,0 +1,26 @@
using Microsoft.Data.Sqlite;
using SQLitePCL;
namespace Umbraco.Cms.Persistence.EFCore;
/// <summary>
/// SQLite-specific exception helpers for code running on the EF Core persistence stack.
/// </summary>
/// <remarks>
/// A parallel helper exists at <c>Umbraco.Cms.Persistence.Sqlite.Services.SqliteExceptionExtensions</c>
/// for the NPoco stack. Both stacks are independent (neither references the other) so the small
/// duplication is intentional — keeps the layering clean.
/// </remarks>
public static class SqliteExceptionExtensions
{
/// <summary>
/// Determines if the SQLite exception is a BUSY or LOCKED error.
/// </summary>
/// <param name="ex">The SQLite exception to check.</param>
/// <returns><c>true</c> if the error is BUSY, LOCKED, or LOCKED_SHAREDCACHE; otherwise <c>false</c>.</returns>
public static bool IsBusyOrLocked(this SqliteException ex) =>
ex.SqliteErrorCode
is raw.SQLITE_BUSY
or raw.SQLITE_LOCKED
or raw.SQLITE_LOCKED_SHAREDCACHE;
}
@@ -23,5 +23,14 @@ public sealed class LanguageDeletedDistributedCacheNotificationHandler : Deleted
/// <inheritdoc />
protected override void Handle(IEnumerable<ILanguage> entities, IDictionary<string, object?> state)
=> _distributedCache.RemoveLanguageCache(entities);
{
_distributedCache.RemoveLanguageCache(entities);
// User groups cache their allowed language ids, so a deleted language must be evicted from
// them too - otherwise a stale, now-missing id lingers on the cached user group and breaks
// reads that resolve those ids. This is a deliberately coarse refresh of the entire user group
// and user caches (RefreshAll also clears IUser): we can't know which groups reference the
// language without a query, and language deletion is rare enough that a full refresh is fine.
_distributedCache.RefreshAllUserGroupCache();
}
}
+10 -1
View File
@@ -368,8 +368,17 @@ public class ObjectCacheAppCache : IAppPolicyCache, IDisposable
}
// Ensure key is removed from set when evicted from cache
return options.RegisterPostEvictionCallback((key, _, _, _) =>
return options.RegisterPostEvictionCallback((key, _, reason, _) =>
{
// Removed and Replaced evictions don't need pruning here: the Remove/Clear call sites already
// prune the tracking set synchronously under the write lock, and a Replaced key still has a
// live entry (the synchronous Set re-added it). Pruning here instead runs on a background
// thread and races with that re-add, dropping a key whose entry is still cached. (#23064)
if (reason is EvictionReason.Removed or EvictionReason.Replaced)
{
return;
}
try
{
if (_locker.TryEnterWriteLock(_writeLockTimeout) is false)
@@ -16,6 +16,11 @@ public class ContentSettings
/// </summary>
internal const bool StaticResolveUrlsFromTextString = false;
/// <summary>
/// The default value for whether sorting children by a field fires per-item notifications.
/// </summary>
internal const bool StaticSortChildrenByFieldFiresNotifications = false;
/// <summary>
/// The default preview badge markup template.
/// </summary>
@@ -110,6 +115,18 @@ public class ContentSettings
[DefaultValue(StaticResolveUrlsFromTextString)]
public bool ResolveUrlsFromTextString { get; set; } = StaticResolveUrlsFromTextString;
/// <summary>
/// Gets or sets a value indicating whether sorting the children of a node by a field fires
/// per-item save/sort notifications (and therefore webhooks).
/// </summary>
/// <remarks>
/// Defaults to <c>false</c>: the children are reordered with a single set-based update and a branch
/// cache refresh, without per-item notifications. Set to <c>true</c> to restore per-item notifications
/// (and webhooks), accepting the additional performance cost on nodes with many children.
/// </remarks>
[DefaultValue(StaticSortChildrenByFieldFiresNotifications)]
public bool SortChildrenByFieldFiresNotifications { get; set; } = StaticSortChildrenByFieldFiresNotifications;
/// <summary>
/// Gets or sets a value for the collection of error pages.
/// </summary>
@@ -30,6 +30,17 @@ public class DatabaseServerMessengerSettings
/// </summary>
internal const string StaticTimeBetweenPruneOperations = "00:01:00"; // TimeSpan.FromMinutes(1);
/// <summary>
/// The default timeout for a single synchronization operation.
/// </summary>
internal const string StaticSyncTimeout = "00:01:00"; // TimeSpan.FromMinutes(1);
/// <summary>
/// Gets the default timeout for a single synchronization operation, for use as a fallback when an invalid
/// <see cref="SyncTimeout" /> is configured.
/// </summary>
public static readonly TimeSpan DefaultSyncTimeout = TimeSpan.Parse(StaticSyncTimeout);
/// <summary>
/// Gets or sets a value for the maximum number of instructions that can be processed at startup; otherwise the server
/// cold-boots (rebuilds its caches).
@@ -55,4 +66,13 @@ public class DatabaseServerMessengerSettings
/// </summary>
[DefaultValue(StaticTimeBetweenPruneOperations)]
public TimeSpan TimeBetweenPruneOperations { get; set; } = TimeSpan.Parse(StaticTimeBetweenPruneOperations);
/// <summary>
/// Gets or sets the maximum time to wait for a single synchronization operation to complete before it is
/// considered stalled (for example, blocked on a hung database connection) and abandoned, so the recurring
/// job keeps running rather than stopping permanently. This bounds how long the job waits on a single sync,
/// not how long a stalled connection itself takes to recover (which is governed by the database timeouts).
/// </summary>
[DefaultValue(StaticSyncTimeout)]
public TimeSpan SyncTimeout { get; set; } = DefaultSyncTimeout;
}
@@ -20,6 +20,17 @@ public class DatabaseServerRegistrarSettings
/// </summary>
internal const string StaticStaleServerTimeout = "00:02:00";
/// <summary>
/// The default timeout for a single server touch operation.
/// </summary>
internal const string StaticTouchTimeout = "00:01:00"; // TimeSpan.FromMinutes(1);
/// <summary>
/// Gets the default timeout for a single server touch operation, for use as a fallback when an invalid
/// <see cref="TouchTimeout" /> is configured.
/// </summary>
public static readonly TimeSpan DefaultTouchTimeout = TimeSpan.Parse(StaticTouchTimeout);
/// <summary>
/// Gets or sets a value for the amount of time to wait between calls to the database on the background thread.
/// </summary>
@@ -31,4 +42,13 @@ public class DatabaseServerRegistrarSettings
/// </summary>
[DefaultValue(StaticStaleServerTimeout)]
public TimeSpan StaleServerTimeout { get; set; } = TimeSpan.Parse(StaticStaleServerTimeout);
/// <summary>
/// Gets or sets the maximum time to wait for a single server touch operation to complete before it is
/// considered stalled (for example, blocked on a hung database connection) and abandoned, so the recurring
/// job keeps running rather than stopping permanently. This bounds how long the job waits on a single touch,
/// not how long a stalled connection itself takes to recover (which is governed by the database timeouts).
/// </summary>
[DefaultValue(StaticTouchTimeout)]
public TimeSpan TouchTimeout { get; set; } = DefaultTouchTimeout;
}
@@ -20,5 +20,12 @@ public class IndexingSettings
/// <summary>
/// Gets or sets a value for how many items to index at a time.
/// </summary>
/// <remarks>
/// This is the primary lever for the peak memory used while (re)building an index: a full page of
/// content and its property data is held in memory at once, so lowering this value reduces rebuild
/// memory at the cost of more, smaller batches. Lower it on very large sites that hit memory pressure
/// during a rebuild.
/// </remarks>
[DefaultValue(StaticBatchSize)]
public int BatchSize { get; set; } = StaticBatchSize;
}
@@ -32,6 +32,11 @@ public class LoggingSettings
/// </summary>
internal const string StaticFileNameFormatArguments = "MachineName";
/// <summary>
/// The default mode for enriching log events with a session identifier.
/// </summary>
internal const SessionIdLoggingMode StaticSessionIdLogging = SessionIdLoggingMode.SessionId;
/// <summary>
/// Gets or sets a value for the maximum age of a log file.
/// </summary>
@@ -70,4 +75,16 @@ public class LoggingSettings
/// </remarks>
[DefaultValue(StaticFileNameFormatArguments)]
public string FileNameFormatArguments { get; set; } = StaticFileNameFormatArguments;
/// <summary>
/// Gets or sets a value determining how log events are enriched with a session identifier.
/// </summary>
/// <remarks>
/// Defaults to <see cref="SessionIdLoggingMode.SessionId" /> for backward compatibility. Set to
/// <see cref="SessionIdLoggingMode.CookieHash" /> or <see cref="SessionIdLoggingMode.None" /> to avoid the
/// blocking session-store load that resolving the actual session id incurs per request when the session is
/// backed by an <c>IDistributedCache</c>.
/// </remarks>
[DefaultValue(StaticSessionIdLogging)]
public SessionIdLoggingMode SessionIdLogging { get; set; } = StaticSessionIdLogging;
}
@@ -0,0 +1,33 @@
using System.ComponentModel;
namespace Umbraco.Cms.Core.Configuration.Models;
/// <summary>
/// Settings for scheduled publishing.
/// </summary>
[UmbracoOptions(Constants.Configuration.ConfigScheduledPublishing)]
public class ScheduledPublishingSettings
{
private const string StaticPeriod = "00:01:00";
private const bool StaticAlignToClock = false; // TODO (V19): Switch this to true.
/// <summary>
/// Gets or sets a value for how often scheduled publishing runs.
/// </summary>
[DefaultValue(StaticPeriod)]
public TimeSpan Period { get; set; } = TimeSpan.Parse(StaticPeriod);
/// <summary>
/// Gets or sets a value indicating whether scheduled publishing runs are aligned to clock boundaries
/// derived from <see cref="Period" /> (for example, on the minute, or every N seconds), rather than drifting
/// based on when the previous run completed.
/// </summary>
/// <remarks>
/// When enabled, <see cref="Period" /> must be a whole number of seconds that divides evenly into one hour
/// (for example 10, 12, 15, 20, 30 or 60 seconds) so that boundaries land on consistent clock times.
/// Boundaries are anchored to <strong>UTC</strong>, not the server's local time zone; for sub-minute and
/// whole-minute periods this is indistinguishable from local time at the second level.
/// </remarks>
[DefaultValue(StaticAlignToClock)]
public bool AlignToClock { get; set; } = StaticAlignToClock;
}
@@ -0,0 +1,29 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
namespace Umbraco.Cms.Core.Configuration.Models;
/// <summary>
/// Determines how request logging enriches log events with a session identifier.
/// </summary>
public enum SessionIdLoggingMode
{
/// <summary>
/// Do not enrich log events with a session identifier.
/// </summary>
None = 0,
/// <summary>
/// Enrich log events with the actual ASP.NET Core session id. This is the default and matches the
/// historical behaviour, but reading the session id forces the session to be loaded from its store, which
/// is a blocking round-trip per request when the session is backed by an <c>IDistributedCache</c>.
/// </summary>
SessionId,
/// <summary>
/// Enrich log events with a one-way hash of the session cookie value. This provides the same per-session
/// correlation as <see cref="SessionId" /> without loading the session from its store, so it never incurs
/// a distributed-cache round-trip.
/// </summary>
CookieHash,
}
@@ -0,0 +1,43 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Options;
namespace Umbraco.Cms.Core.Configuration.Models.Validation;
/// <summary>
/// Validator for configuration represented as <see cref="ScheduledPublishingSettings" />.
/// </summary>
public class ScheduledPublishingSettingsValidator : ConfigurationValidatorBase, IValidateOptions<ScheduledPublishingSettings>
{
/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, ScheduledPublishingSettings options)
{
if (options.Period <= TimeSpan.Zero)
{
return ValidateOptionsResult.Fail(
$"Configuration entry {Constants.Configuration.ConfigScheduledPublishing}:Period must be greater than zero.");
}
if (options.AlignToClock && IsCleanDivisorOfAnHour(options.Period) == false)
{
return ValidateOptionsResult.Fail(
$"Configuration entry {Constants.Configuration.ConfigScheduledPublishing}:Period must be a whole number of seconds that divides evenly into one hour (3600 seconds) when {Constants.Configuration.ConfigScheduledPublishing}:AlignToClock is enabled, e.g. 10, 12, 15, 20, 30 or 60 seconds.");
}
return ValidateOptionsResult.Success;
}
private static bool IsCleanDivisorOfAnHour(TimeSpan period)
{
var totalSeconds = period.TotalSeconds;
// Must be a positive, whole number of seconds (no sub-second component).
if (totalSeconds <= 0 || totalSeconds != Math.Floor(totalSeconds))
{
return false;
}
return 3600 % (long)totalSeconds == 0;
}
}
@@ -291,6 +291,11 @@ public static partial class Constants
/// </summary>
public const string ConfigDistributedJobs = ConfigPrefix + "DistributedJobs";
/// <summary>
/// The configuration key for scheduled publishing settings.
/// </summary>
public const string ConfigScheduledPublishing = ConfigPrefix + "ScheduledPublishing";
/// <summary>
/// The configuration key for backoffice token cookie settings.
/// </summary>
@@ -57,6 +57,7 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<IValidateOptions<RequestHandlerSettings>, RequestHandlerSettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<UnattendedSettings>, UnattendedSettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<SecuritySettings>, SecuritySettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<ScheduledPublishingSettings>, ScheduledPublishingSettingsValidator>();
// Register configuration sections.
// TODO (V18): Remove the registrations of UserPasswordConfigurationSettings and MemberPasswordConfigurationSettings.
@@ -102,6 +103,7 @@ public static partial class UmbracoBuilderExtensions
.AddUmbracoOptions<CacheSettings>()
.AddUmbracoOptions<SystemDateMigrationSettings>()
.AddUmbracoOptions<DistributedJobSettings>()
.AddUmbracoOptions<ScheduledPublishingSettings>(options => options.ValidateOnStart())
.AddUmbracoOptions<BackOfficeTokenCookieSettings>()
.AddUmbracoOptions<WebsiteSettings>()
.AddUmbracoOptions<SignalRSettings>();
@@ -405,7 +405,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -454,7 +454,8 @@
<key alias="httpsCheckConfigurationRectifyNotPossible">Mae gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i 'false' yn eich ffeil appSettings.json. Unwaith y byddwch yn cyrchu'r wefan hon gan ddefnyddio'r cynllun HTTPS, dylid gosod hwnnw i 'true'.</key>
<key alias="httpsCheckConfigurationCheckResult">Mae'r gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i '%0%' yn eich ffeil appSettings.json, mae eich cwcis %1% wedi'u marcio'n ddiogel.</key>
<key alias="umbracoApplicationUrlCheckResultTrue">Mae gosodiad yr ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod i <strong>%0%</strong>.</key>
<key alias="umbracoApplicationUrlCheckResultFalse">Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod, felly bydd URL y rhaglen yn cael ei ganfod yn awtomatig o geisiadau sy'n dod i mewn. Argymhellir ei osod yn benodol.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod ac mae canfod URL y rhaglen yn awtomatig wedi'i analluogi (mae 'Umbraco:CMS:WebRouting:ApplicationUrlDetection' yn 'None'). Ni fydd nodweddion sydd angen URL absoliwt, fel e-byst ailosod cyfrinair a gwahoddiadau, yn gweithio. Gosodwch URL y rhaglen yn benodol, neu galluogwch ganfod yn awtomatig.]]></key>
<key alias="smtpMailSettingsNotFound">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp'.</key>
<key alias="smtpMailSettingsHostNotConfigured">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp:Host'.</key>
<key alias="smtpMailSettingsConnectionFail">Methwyd cyrraedd y gweinydd SMTP a ffurfweddwyd gyda gwesteiwr '%0%' a phorth '%1%'. Gwiriwch i sicrhau bod y gosodiadau SMTP yn y ffurfweddiad 'Umbraco:CMS:Global:Smtp' yn gywir.</key>
@@ -463,7 +463,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -452,7 +452,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
<key alias="clickJackingCheckHeaderFound">
<![CDATA[The header or meta-tag <strong>X-Frame-Options</strong> used to control whether a site can be IFRAMEd by another was found.]]></key>
<key alias="clickJackingCheckHeaderNotFound">
@@ -403,7 +403,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -730,6 +730,10 @@ public static partial class StringExtensions
/// </summary>
/// <param name="fileName">The file name to convert.</param>
/// <returns>A friendly name with the extension stripped, underscores and dashes converted to spaces, and title case applied.</returns>
/// <remarks>
/// Mirrored client-side in <c>src/Umbraco.Web.UI.Client/src/packages/media/media/utils/to-friendly-name.function.ts</c>;
/// keep the two implementations in sync.
/// </remarks>
public static string ToFriendlyName(this string fileName)
{
// strip the file extension
@@ -44,28 +44,34 @@ public class UmbracoApplicationUrlCheck : HealthCheck
private HealthCheckStatus CheckUmbracoApplicationUrl()
{
var url = _webRoutingSettings.CurrentValue.UmbracoApplicationUrl;
WebRoutingSettings settings = _webRoutingSettings.CurrentValue;
var url = settings.UmbracoApplicationUrl;
string resultMessage;
StatusResultType resultType;
var success = false;
if (url.IsNullOrWhiteSpace())
if (url.IsNullOrWhiteSpace() is false)
{
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
resultType = StatusResultType.Warning;
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", [url]);
resultType = StatusResultType.Success;
}
else if (settings.ApplicationUrlDetection == ApplicationUrlDetection.None)
{
// No explicit URL and auto-detection is disabled, so the application URL can never be established.
// Features that require an absolute URL (e.g. password reset and invitation emails) will not work.
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultError");
resultType = StatusResultType.Error;
}
else
{
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", new[] { url });
resultType = StatusResultType.Success;
success = true;
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
resultType = StatusResultType.Warning;
}
return new HealthCheckStatus(resultMessage)
{
ResultType = resultType,
ReadMoreLink = success
ReadMoreLink = resultType == StatusResultType.Success
? null
: Constants.HealthChecks.DocumentationLinks.Security.UmbracoApplicationUrlCheck,
};
@@ -0,0 +1,22 @@
namespace Umbraco.Cms.Core.Models.ContentEditing;
/// <summary>
/// Represents a system field that a node's children can be sorted by.
/// </summary>
public enum ContentSortField
{
/// <summary>
/// Sort by the node's name.
/// </summary>
Name,
/// <summary>
/// Sort by the date the node was created.
/// </summary>
CreateDate,
/// <summary>
/// Sort by the date the node was last updated.
/// </summary>
UpdateDate,
}
@@ -16,6 +16,19 @@ public interface IContentRepository<in TId, TEntity> : IReadWriteQueryRepository
/// </summary>
int RecycleBinId { get; }
/// <summary>
/// Updates the sort order of the specified nodes so that each node's sort order matches its
/// position in the supplied (already ordered) collection, in a single set-based update.
/// </summary>
/// <param name="orderedNodeIds">The node identifiers in their desired order.</param>
/// <remarks>
/// This persists the sort order directly and does not load the entities or fire any notifications;
/// callers are responsible for any required cache refresh and auditing.
/// </remarks>
// TODO (V19): Remove the default implementation.
void UpdateSortOrder(IReadOnlyList<int> orderedNodeIds)
=> throw new NotImplementedException();
/// <summary>
/// Gets versions.
/// </summary>
@@ -105,7 +105,15 @@ internal sealed class ContentEditingService
/// <inheritdoc />
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(ContentCreateModel createModel, Guid userKey)
=> await ValidateCulturesAndPropertiesAsync(createModel, createModel.ContentTypeKey, await GetCulturesToValidate(createModel.Variants.Select(variant => variant.Culture), userKey));
{
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
{
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
}
return await ValidateCulturesAndPropertiesAsync(createModel, createModel.ContentTypeKey, await GetCulturesToValidate(createModel.Variants.Select(variant => variant.Culture), userKey));
}
private async Task<IEnumerable<string?>?> GetCulturesToValidate(IEnumerable<string?>? cultures, Guid userKey)
{
@@ -332,6 +340,15 @@ internal sealed class ContentEditingService
Guid userKey)
=> await HandleSortAsync(parentKey, sortingModels, userKey);
/// <inheritdoc />
public async Task<ContentEditingOperationStatus> SortByFieldAsync(
Guid? parentKey,
ContentSortField field,
Direction direction,
string? culture,
Guid userKey)
=> await HandleSortByFieldAsync(parentKey, field, direction, culture, userKey);
private async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCulturesAndPropertiesAsync(
ContentEditingModelBase contentEditingModelBase,
Guid contentTypeKey,
@@ -384,8 +401,8 @@ internal sealed class ContentEditingService
protected override OperationResult? Delete(IContent content, int userId) => ContentService.Delete(content, userId);
/// <inheritdoc />
protected override IEnumerable<IContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: null);
protected override IEnumerable<IContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: ordering);
/// <inheritdoc />
protected override ContentEditingOperationStatus Sort(IEnumerable<IContent> items, int userId)
@@ -394,6 +411,13 @@ internal sealed class ContentEditingService
return OperationResultToOperationStatus(result);
}
/// <inheritdoc />
protected override ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId)
{
OperationResult result = ContentService.SortChildren(parentId, orderedChildIds, userId);
return OperationResultToOperationStatus(result);
}
private async Task<ContentEditingOperationStatus> Save(IContent content, Guid userKey)
{
try
@@ -458,6 +458,10 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
{
// these are the only result states currently expected from the invoked IContentService operations
OperationResultType.Success => ContentEditingOperationStatus.Success,
// a no-op (e.g. sorting children when nothing needs reordering) is a successful outcome, not an error
OperationResultType.NoOperation => ContentEditingOperationStatus.Success,
OperationResultType.FailedCancelledByEvent => ContentEditingOperationStatus.CancelledByNotification,
OperationResultType.FailedCannot => ContentEditingOperationStatus.CannotDeleteWhenReferenced,
@@ -619,6 +623,25 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
return filteredContentTypes.Any();
}
/// <summary>
/// Validates that content of the requested type is allowed to be created under the requested parent, applying the
/// same "allowed at root", "allowed as child" and content type filter rules that are enforced when the content is
/// actually created. This allows the validation endpoints to be consistent with creation.
/// </summary>
/// <param name="createModel">The content creation model.</param>
/// <returns>The operation status; <see cref="ContentEditingOperationStatus.Success"/> when creation is allowed.</returns>
protected async Task<ContentEditingOperationStatus> ValidateCreationAllowedAsync(ContentCreationModelBase createModel)
{
TContentType? contentType = ContentTypeService.Get(createModel.ContentTypeKey);
if (contentType is null)
{
return ContentEditingOperationStatus.ContentTypeNotFound;
}
(int? _, ContentEditingOperationStatus operationStatus) = await TryGetAndValidateParentIdAsync(createModel.ParentKey, contentType);
return operationStatus;
}
private void UpdateNames(ContentEditingModelBase contentEditingModelBase, TContent content, TContentType contentType)
{
if (contentType.VariesByCulture())
@@ -86,9 +86,10 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
/// <param name="parentId">The parent identifier.</param>
/// <param name="pageIndex">The zero-based page index.</param>
/// <param name="pageSize">The page size.</param>
/// <param name="ordering">The ordering to apply, or <c>null</c> to use the default (sort order).</param>
/// <param name="total">The total number of children.</param>
/// <returns>The paged children.</returns>
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total);
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total);
/// <summary>
/// Handles the sorting operation asynchronously.
@@ -111,16 +112,7 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
return ContentEditingOperationStatus.NotFound;
}
const int pageSize = 500;
var pageNumber = 0;
IEnumerable<TContent> page = GetPagedChildren(contentId.Value, pageNumber++, pageSize, out var total);
var children = new List<TContent>((int)total);
children.AddRange(page);
while (pageNumber * pageSize < total)
{
page = GetPagedChildren(contentId.Value, pageNumber++, pageSize, out _);
children.AddRange(page);
}
List<TContent> children = LoadAllChildren(contentId.Value, ordering: null);
try
{
@@ -138,4 +130,102 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
return ContentEditingOperationStatus.SortingInvalid;
}
}
/// <summary>
/// Handles sorting a parent's children by a system field asynchronously.
/// </summary>
/// <param name="parentKey">The optional parent key.</param>
/// <param name="field">The system field to sort the children by.</param>
/// <param name="direction">The direction to sort in.</param>
/// <param name="culture">The culture whose variant name to sort by, or <c>null</c> to sort by the invariant name. Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a child that does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.</param>
/// <param name="userKey">The user key performing the operation.</param>
/// <returns>The operation status.</returns>
protected async Task<ContentEditingOperationStatus> HandleSortByFieldAsync(
Guid? parentKey,
ContentSortField field,
Direction direction,
string? culture,
Guid userKey)
{
var contentId = parentKey.HasValue
? ContentService.GetById(parentKey.Value)?.Id
: Constants.System.Root;
if (contentId.HasValue is false)
{
return ContentEditingOperationStatus.NotFound;
}
Ordering ordering = BuildOrdering(field, direction, culture);
// The database does the ordering (matching the list view and the order shown in the sort UI).
if (ContentSettings.SortChildrenByFieldFiresNotifications)
{
// Opt-in path: load the children and persist via the standard sort, firing per-item
// save/sort notifications (and therefore webhooks), at the cost of loading every child.
List<TContent> orderedChildren = LoadAllChildren(contentId.Value, ordering);
if (orderedChildren.Count == 0)
{
return ContentEditingOperationStatus.Success;
}
return Sort(orderedChildren, await GetUserIdAsync(userKey));
}
// Default path: persist the resulting order with a single set-based update and a branch cache
// refresh, without loading every child or firing per-item notifications.
List<int> orderedChildIds = LoadOrderedChildIds(contentId.Value, ordering);
if (orderedChildIds.Count == 0)
{
// Nothing to sort - the order is trivially correct.
return ContentEditingOperationStatus.Success;
}
return SortChildrenInBulk(contentId.Value, orderedChildIds, await GetUserIdAsync(userKey));
}
/// <summary>
/// Persists the supplied (already ordered) child identifiers as the new sort order, without loading
/// the children or firing per-item notifications.
/// </summary>
/// <param name="parentId">The parent identifier, or the root identifier for root-level sorting.</param>
/// <param name="orderedChildIds">The child identifiers in their desired order.</param>
/// <param name="userId">The user performing the operation.</param>
/// <returns>The operation status.</returns>
protected abstract ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId);
private List<int> LoadOrderedChildIds(int contentId, Ordering ordering)
=> LoadAllChildren(contentId, ordering, child => child.Id);
private List<TContent> LoadAllChildren(int contentId, Ordering? ordering)
=> LoadAllChildren(contentId, ordering, child => child);
// Pages through all children, projecting each page with the selector so callers that only need a
// lightweight value (e.g. the id) don't retain every loaded child.
private List<TResult> LoadAllChildren<TResult>(int contentId, Ordering? ordering, Func<TContent, TResult> selector)
{
const int pageSize = 500;
var pageNumber = 0;
IEnumerable<TContent> page = GetPagedChildren(contentId, pageNumber++, pageSize, ordering, out var total);
var results = new List<TResult>((int)total);
results.AddRange(page.Select(selector));
while (pageNumber * pageSize < total)
{
page = GetPagedChildren(contentId, pageNumber++, pageSize, ordering, out _);
results.AddRange(page.Select(selector));
}
return results;
}
private static Ordering BuildOrdering(ContentSortField field, Direction direction, string? culture)
=> field switch
{
// Name is variant - the culture selects the variant name to order by (invariant content and media
// ignore it). Create and update dates are node-level, so the culture does not apply.
ContentSortField.Name => Ordering.By("name", direction, culture),
ContentSortField.CreateDate => Ordering.By("createDate", direction),
ContentSortField.UpdateDate => Ordering.By("updateDate", direction),
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort field."),
};
}
+44 -1
View File
@@ -3137,7 +3137,13 @@ public class ContentService : RepositoryService, IContentService
{
scope.WriteLock(Constants.Locks.ContentTree);
OperationResult ret = Sort(scope, itemsA, userId, evtMsgs);
// Reload within the lock so sorting operates on fully-loaded entities. Callers may pass
// partially-loaded content (e.g. loaded with loadTemplates: false or without property data),
// and saving those directly would wipe the template and property data (#23120).
// GetByIds returns items in the requested order, preserving the caller's ordering that drives the sort.
IContent[] reloaded = GetByIds(itemsA.Select(x => x.Id).ToArray()).ToArray();
OperationResult ret = Sort(scope, reloaded, userId, evtMsgs);
scope.Complete();
return ret;
}
@@ -3175,6 +3181,43 @@ public class ContentService : RepositoryService, IContentService
}
}
/// <inheritdoc />
public OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
{
EventMessages evtMsgs = EventMessagesFactory.Get();
if (orderedChildIds.Count == 0)
{
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
}
using ICoreScope scope = ScopeProvider.CreateCoreScope();
scope.WriteLock(Constants.Locks.ContentTree);
_documentRepository.UpdateSortOrder(orderedChildIds);
// Sort order lives in umbracoNode; neither the published cache nor the content repository cache keeps
// a separate serialized copy of it, so refreshing the affected branch (which invalidates both and has
// them reload from umbracoNode) is enough to pick up the new order without re-saving each child.
if (parentId == Constants.System.Root)
{
IContent[] roots = GetByIds(orderedChildIds).ToArray();
scope.Notifications.Publish(new ContentTreeChangeNotification(roots, TreeChangeTypes.RefreshNode, evtMsgs));
}
else
{
IContent? parent = GetById(parentId);
if (parent is not null)
{
scope.Notifications.Publish(new ContentTreeChangeNotification(parent, TreeChangeTypes.RefreshBranch, evtMsgs));
}
}
Audit(AuditType.Sort, userId, parentId);
scope.Complete();
return OperationResult.Succeed(evtMsgs);
}
private OperationResult Sort(ICoreScope scope, IContent[] itemsA, int userId, EventMessages eventMessages)
{
var sortingNotification = new ContentSortingNotification(itemsA, eventMessages);
@@ -95,6 +95,18 @@ public interface IContentEditingService
/// <returns>The operation status indicating success or failure.</returns>
Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey);
/// <summary>
/// Sorts the children of a parent by a system field.
/// </summary>
/// <param name="parentKey">The unique identifier of the parent, or <c>null</c> for root-level sorting.</param>
/// <param name="field">The system field to sort the children by.</param>
/// <param name="direction">The direction to sort in.</param>
/// <param name="culture">The culture whose variant name to sort by, or <c>null</c> to sort by the invariant name. Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a child that does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.</param>
/// <param name="userKey">The unique identifier of the user performing the action.</param>
/// <returns>The operation status indicating success or failure.</returns>
Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, string? culture, Guid userKey)
=> throw new NotImplementedException(); // TODO (V19): Remove default implementation.
/// <summary>
/// Deletes a content item whether it is in the recycle bin or not.
/// </summary>
@@ -542,6 +542,22 @@ public interface IContentService : IContentServiceBase<IContent>
/// <returns>The operation result.</returns>
OperationResult Sort(IEnumerable<int>? ids, int userId = Constants.Security.SuperUserId);
/// <summary>
/// Sorts the children of a parent by persisting the supplied (already ordered) child identifiers
/// as the new sort order, in a single set-based update.
/// </summary>
/// <param name="parentId">The identifier of the parent, or <see cref="Constants.System.Root"/> for the root.</param>
/// <param name="orderedChildIds">The child document identifiers, in the desired order.</param>
/// <param name="userId">The identifier of the user performing the action.</param>
/// <returns>The operation result.</returns>
/// <remarks>
/// Unlike <see cref="Sort(IEnumerable{int}?, int)" />, this does not load the children or fire per-item
/// save/sort notifications; it persists the order directly and refreshes the affected cache branch.
/// </remarks>
// TODO (V19): Remove the default implementation.
OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
=> throw new NotImplementedException();
#endregion
#region Publish Document
@@ -118,6 +118,18 @@ public interface IMediaEditingService
/// </returns>
Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey);
/// <summary>
/// Sorts the children of a parent by a system field.
/// </summary>
/// <param name="parentKey">The unique identifier of the parent, or <c>null</c> for root-level sorting.</param>
/// <param name="field">The system field to sort the children by.</param>
/// <param name="direction">The direction to sort in.</param>
/// <param name="userKey">The unique identifier of the user performing the operation.</param>
/// <returns>The operation status indicating the operation outcome.</returns>
/// <remarks>Media items never vary by culture, so children are always ordered by the invariant name.</remarks>
Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, Guid userKey)
=> throw new NotImplementedException(); // TODO (V19): Remove default implementation.
/// <summary>
/// Permanently deletes a media item from the recycle bin.
/// </summary>
@@ -359,6 +359,22 @@ public interface IMediaService : IContentServiceBase<IMedia>
/// <returns>True if sorting succeeded, otherwise False</returns>
bool Sort(IEnumerable<IMedia> items, int userId = Constants.Security.SuperUserId);
/// <summary>
/// Sorts the children of a parent by persisting the supplied (already ordered) child identifiers
/// as the new sort order, in a single set-based update.
/// </summary>
/// <param name="parentId">The identifier of the parent, or <see cref="Constants.System.Root"/> for the root.</param>
/// <param name="orderedChildIds">The child media identifiers, in the desired order.</param>
/// <param name="userId">The identifier of the user performing the action.</param>
/// <returns>The operation result.</returns>
/// <remarks>
/// Unlike <see cref="Sort(IEnumerable{IMedia}, int)" />, this does not load the children or fire per-item
/// save/sort notifications; it persists the order directly and refreshes the affected cache branch.
/// </remarks>
// TODO (V19): Remove the default implementation.
OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
=> throw new NotImplementedException();
/// <summary>
/// Creates an <see cref="IMedia" /> object using the alias of the <see cref="IMediaType" />
/// that this Media should based on.
@@ -83,7 +83,15 @@ internal sealed class MediaEditingService
/// <inheritdoc />
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(MediaCreateModel createModel)
=> await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
{
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
{
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
}
return await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
}
/// <inheritdoc />
public async Task<Attempt<MediaCreateResult, ContentEditingOperationStatus>> CreateAsync(MediaCreateModel createModel, Guid userKey)
@@ -165,6 +173,12 @@ internal sealed class MediaEditingService
public async Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey)
=> await HandleSortAsync(parentKey, sortingModels, userKey);
/// <inheritdoc />
public async Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, Guid userKey)
// Media never varies by culture, so children are always ordered by the invariant name.
=> await HandleSortByFieldAsync(parentKey, field, direction, culture: null, userKey);
/// <inheritdoc />
protected override IMedia New(string? name, int parentId, IMediaType mediaType)
=> new Models.Media(name, parentId, mediaType);
@@ -187,8 +201,8 @@ internal sealed class MediaEditingService
=> ContentService.Delete(media, userId).Result;
/// <inheritdoc />
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total);
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, filter: null, ordering: ordering);
/// <inheritdoc />
protected override ContentEditingOperationStatus Sort(IEnumerable<IMedia> items, int userId)
@@ -199,6 +213,13 @@ internal sealed class MediaEditingService
: ContentEditingOperationStatus.CancelledByNotification;
}
/// <inheritdoc />
protected override ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId)
{
OperationResult result = ContentService.SortChildren(parentId, orderedChildIds, userId);
return OperationResultToOperationStatus(result);
}
/// <summary>
/// Saves a media item to the repository.
/// </summary>
+46
View File
@@ -1414,6 +1414,15 @@ namespace Umbraco.Cms.Core.Services
{
scope.WriteLock(Constants.Locks.MediaTree);
// Reload within the lock so sorting operates on fully-loaded entities. Callers may pass
// partially-loaded media (e.g. without property data), and saving those directly would
// wipe the property data (#23120). Preserve the caller's ordering, which drives the sort.
var reloadedById = GetByIds(itemsA.Select(x => x.Id)).ToDictionary(x => x.Id);
itemsA = itemsA
.Select(x => reloadedById.TryGetValue(x.Id, out IMedia? media) ? media : null)
.WhereNotNull()
.ToArray();
var savingNotification = new MediaSavingNotification(itemsA, messages);
if (scope.Notifications.PublishCancelable(savingNotification))
{
@@ -1452,6 +1461,43 @@ namespace Umbraco.Cms.Core.Services
}
/// <inheritdoc />
public OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
{
EventMessages evtMsgs = EventMessagesFactory.Get();
if (orderedChildIds.Count == 0)
{
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
}
using ICoreScope scope = ScopeProvider.CreateCoreScope();
scope.WriteLock(Constants.Locks.MediaTree);
_mediaRepository.UpdateSortOrder(orderedChildIds);
// Sort order lives in umbracoNode; neither the published cache nor the media repository cache keeps
// a separate serialized copy of it, so refreshing the affected branch (which invalidates both and has
// them reload from umbracoNode) is enough to pick up the new order without re-saving each child.
if (parentId == Constants.System.Root)
{
IMedia[] roots = GetByIds(orderedChildIds).ToArray();
scope.Notifications.Publish(new MediaTreeChangeNotification(roots, TreeChangeTypes.RefreshNode, evtMsgs));
}
else
{
IMedia? parent = GetById(parentId);
if (parent is not null)
{
scope.Notifications.Publish(new MediaTreeChangeNotification(parent, TreeChangeTypes.RefreshBranch, evtMsgs));
}
}
Audit(AuditType.Sort, userId, parentId);
scope.Complete();
return OperationResult.Succeed(evtMsgs);
}
/// <summary>
/// Checks the data integrity of the media tree and optionally fixes detected issues.
/// </summary>
@@ -1,4 +1,4 @@
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// A background job that will be executed by an available server. With a single server setup this will always be the same.
@@ -16,6 +16,19 @@ public interface IDistributedBackgroundJob
/// </summary>
TimeSpan Period { get; }
/// <summary>
/// Gets a value indicating whether the job's runs should be aligned to clock boundaries derived from <see cref="Period" />.
/// </summary>
/// <remarks>
/// When <c>true</c>, the job becomes runnable on the next clock boundary that is a multiple of <see cref="Period" />
/// (measured from a fixed <strong>UTC</strong> origin, so boundaries fall on round clock times such as on the minute
/// or every N seconds) rather than at <c>LastRun + Period</c>.
/// For predictable boundaries <see cref="Period" /> should divide evenly into one hour.
/// The scheduler may cache this value when it first evaluates registered jobs; changing it at runtime may require an application restart.
/// Defaults to <c>false</c>, preserving the original drift-from-completion behaviour.
/// </remarks>
bool AlignToClock => false;
/// <summary>
/// Run the job.
/// </summary>
@@ -2,7 +2,9 @@
// See LICENSE for more details.
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
@@ -22,7 +24,10 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
public string Name => "ScheduledPublishingJob";
/// <inheritdoc />
public TimeSpan Period => TimeSpan.FromMinutes(1);
public TimeSpan Period => _scheduledPublishingSettings.CurrentValue.Period;
/// <inheritdoc />
public bool AlignToClock => _scheduledPublishingSettings.CurrentValue.AlignToClock;
private readonly IContentService _contentService;
@@ -31,6 +36,7 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
private readonly TimeProvider _timeProvider;
private readonly IServerMessenger _serverMessenger;
private readonly IUmbracoContextFactory _umbracoContextFactory;
private readonly IOptionsMonitor<ScheduledPublishingSettings> _scheduledPublishingSettings;
/// <summary>
/// Initializes a new instance of the <see cref="ScheduledPublishingJob" /> class.
@@ -41,7 +47,8 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
ILogger<ScheduledPublishingJob> logger,
IServerMessenger serverMessenger,
ICoreScopeProvider scopeProvider,
TimeProvider timeProvider)
TimeProvider timeProvider,
IOptionsMonitor<ScheduledPublishingSettings> scheduledPublishingSettings)
{
_contentService = contentService;
_umbracoContextFactory = umbracoContextFactory;
@@ -49,6 +56,7 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
_serverMessenger = serverMessenger;
_scopeProvider = scopeProvider;
_timeProvider = timeProvider;
_scheduledPublishingSettings = scheduledPublishingSettings;
}
/// <inheritdoc />
@@ -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,35 +75,93 @@ public class TouchServerJob : RecurringBackgroundJobBase
/// <returns>
/// A completed task when the job has finished running.
/// </returns>
public override Task RunJobAsync(CancellationToken cancellationToken)
public override async Task RunJobAsync(CancellationToken cancellationToken)
{
// If the IServerRoleAccessor has been changed away from ElectedServerRoleAccessor this task no longer makes sense,
// since all it's used for is to allow the ElectedServerRoleAccessor
// to figure out what role a given server has, so we just stop this task.
if (_serverRoleAccessor is not ElectedServerRoleAccessor)
{
return Task.CompletedTask;
return;
}
// If a previous touch is still running (e.g. blocked on a hung database connection after a timeout),
// skip starting another. This bounds us to a single in-flight call instead of accumulating blocked
// thread-pool threads (each contending for the servers lock), and logs the stall once rather than on
// every interval until it recovers.
if (_inFlightTouch is { IsCompleted: false })
{
return;
}
var serverAddress = _hostingEnvironment.ApplicationMainUrl?.ToString();
if (string.IsNullOrWhiteSpace(serverAddress))
{
_logger.LogWarning("No umbracoApplicationUrl for service (yet), skip.");
return Task.CompletedTask;
// No application URL is known yet: either detection is off (WebRouting:ApplicationUrlDetection is
// None with no UmbracoApplicationUrl set), or detection is on but no request has been served yet.
// Register with the machine name as a placeholder so server-role election can still proceed (uniqueness
// comes from the server identity, not this address). If a URL is later detected from a request, the next
// touch overwrites the placeholder.
serverAddress = Environment.MachineName;
_logger.LogDebug(
"No application URL available; registering server with placeholder address {ServerAddress}.",
serverAddress);
}
else
{
_logger.LogDebug("Registering server with application URL {ServerAddress}.", serverAddress);
}
// IServerRegistrationService.TouchServer() runs a synchronous database write and cannot observe the
// cancellation token, so a hung connection would otherwise block this job's recurring loop indefinitely
// and silently stop server-registration heartbeats until the process is recycled. Offload it to the
// thread pool and bound the wait so the loop survives and keeps touching.
// (See InstructionProcessJob for the same pattern and the ExecutionContext.SuppressFlow rationale.)
TimeSpan staleServerTimeout = _globalSettings.DatabaseServerRegistrar.StaleServerTimeout;
var touchTask = Task.Run(() => _serverRegistrationService.TouchServer(serverAddress, staleServerTimeout), cancellationToken);
_inFlightTouch = touchTask;
// Observe the task's eventual fault on every exit path (timeout, shutdown cancellation, or a late
// failure once we have stopped awaiting it) so it never surfaces as an UnobservedTaskException.
_ = touchTask.ContinueWith(
static t => _ = t.Exception,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
try
{
_serverRegistrationService.TouchServer(
serverAddress,
_globalSettings.DatabaseServerRegistrar.StaleServerTimeout);
await touchTask.WaitAsync(_touchTimeout, cancellationToken);
_logger.LogDebug("Touched server registration for {ServerAddress}.", serverAddress);
}
catch (Exception ex)
catch (TimeoutException)
{
_logger.LogError(
"Touching the server registration did not complete within {TouchTimeout} and may be stalled on a hung database connection. Server registration is paused on this server until the stalled connection recovers.",
_touchTimeout);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Failed to update server record in database.");
}
}
return Task.CompletedTask;
// A non-positive timeout would make every touch "time out" immediately (or throw from WaitAsync for a
// negative value), so guard against misconfiguration and fall back to the default. Timeout.InfiniteTimeSpan
// is allowed as an explicit opt-out that restores the unbounded wait.
private TimeSpan ValidateTouchTimeout(TimeSpan configuredTouchTimeout)
{
if (configuredTouchTimeout > TimeSpan.Zero || configuredTouchTimeout == Timeout.InfiniteTimeSpan)
{
return configuredTouchTimeout;
}
_logger.LogWarning(
"Configured DatabaseServerRegistrar.TouchTimeout of {ConfiguredTouchTimeout} is not valid; it must be positive (or Timeout.InfiniteTimeSpan to disable the timeout). Falling back to {DefaultTouchTimeout}.",
configuredTouchTimeout,
DatabaseServerRegistrarSettings.DefaultTouchTimeout);
return DatabaseServerRegistrarSettings.DefaultTouchTimeout;
}
/// <inheritdoc />
@@ -93,7 +93,7 @@ public static partial class UmbracoBuilderExtensions
builder.AddNotificationHandler<ExternalMemberCacheRefresherNotification, ExternalMemberIndexingNotificationHandler>();
builder.AddNotificationAsyncHandler<LanguageCacheRefresherNotification, LanguageIndexingNotificationHandler>();
builder.AddNotificationHandler<UmbracoRequestBeginNotification, RebuildOnStartupHandler>();
builder.AddNotificationAsyncHandler<UmbracoApplicationStartedNotification, RebuildOnStartedHandler>();
return builder;
}
@@ -170,13 +170,7 @@ public class ContentIndexPopulator : IndexPopulator<IUmbracoContentIndex>
{
content = _contentService.GetPagedDescendants(contentParentId, pageIndex, pageSize, out _).ToArray();
var valueSets = _contentValueSetBuilder.GetValueSets(content).ToArray();
// ReSharper disable once PossibleMultipleEnumeration
foreach (IIndex index in indexes)
{
index.IndexItems(valueSets);
}
ValueSetIndexer.IndexItems(indexes, _contentValueSetBuilder.GetValueSets(content));
pageIndex++;
}
@@ -216,12 +210,7 @@ public class ContentIndexPopulator : IndexPopulator<IUmbracoContentIndex>
}
}
var valueSets = _contentValueSetBuilder.GetValueSets(indexableContent.ToArray()).ToArray();
foreach (IIndex index in indexes)
{
index.IndexItems(valueSets);
}
ValueSetIndexer.IndexItems(indexes, _contentValueSetBuilder.GetValueSets(indexableContent.ToArray()));
pageIndex++;
}
@@ -49,13 +49,7 @@ internal sealed class DeliveryApiContentIndexPopulator : IndexPopulator
_deliveryApiContentIndexHelper.EnumerateApplicableDescendantsForContentIndex(
Constants.System.Root,
descendants =>
{
ValueSet[] valueSets = _deliveryContentIndexValueSetBuilder.GetValueSets(descendants).ToArray();
foreach (IIndex index in indexes)
{
index.IndexItems(valueSets);
}
});
ValueSetIndexer.IndexItems(indexes, _deliveryContentIndexValueSetBuilder.GetValueSets(descendants)));
}
public override bool IsRegistered(IIndex index)
@@ -107,11 +107,7 @@ public class MediaIndexPopulator : IndexPopulator<IUmbracoContentIndex>
{
media = _mediaService.GetPagedDescendants(mediaParentId, pageIndex, _indexingSettings.BatchSize, out _).ToArray();
// ReSharper disable once PossibleMultipleEnumeration
foreach (IIndex index in indexes)
{
index.IndexItems(_mediaValueSetBuilder.GetValueSets(media));
}
ValueSetIndexer.IndexItems(indexes, _mediaValueSetBuilder.GetValueSets(media));
pageIndex++;
}
@@ -41,11 +41,7 @@ public class MemberIndexPopulator : IndexPopulator<IUmbracoMemberIndex>
{
members = _memberService.GetAll(pageIndex, pageSize, out _).ToArray();
// ReSharper disable once PossibleMultipleEnumeration
foreach (IIndex index in indexes)
{
index.IndexItems(_valueSetBuilder.GetValueSets(members));
}
ValueSetIndexer.IndexItems(indexes, _valueSetBuilder.GetValueSets(members));
pageIndex++;
}
@@ -0,0 +1,66 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Infrastructure.Examine;
/// <summary>
/// Handles how the indexes are rebuilt after startup.
/// </summary>
/// <remarks>
/// Once the application has fully started this rebuilds the Examine indexes if they are empty.
/// If it is a cold boot, they are all rebuilt.
/// </remarks>
public sealed class RebuildOnStartedHandler : INotificationAsyncHandler<UmbracoApplicationStartedNotification>
{
// The notification is published again on restart, but the indexes only need to be
// considered for rebuilding once per application lifetime.
private static int _hasRun;
private readonly ISyncBootStateAccessor _syncBootStateAccessor;
private readonly IIndexRebuilder _indexRebuilder;
private readonly IRuntimeState _runtimeState;
/// <summary>
/// Initializes a new instance of the <see cref="Umbraco.Cms.Infrastructure.Examine.RebuildOnStartedHandler"/> class, responsible for handling index rebuilds during application startup.
/// </summary>
/// <param name="syncBootStateAccessor">Provides access to the application's synchronous boot state, used to determine if the system is ready for index rebuilding.</param>
/// <param name="indexRebuilder">The service responsible for rebuilding Examine indexes.</param>
/// <param name="runtimeState">Provides information about the current runtime state of the Umbraco application.</param>
public RebuildOnStartedHandler(
ISyncBootStateAccessor syncBootStateAccessor,
IIndexRebuilder indexRebuilder,
IRuntimeState runtimeState)
{
_syncBootStateAccessor = syncBootStateAccessor;
_indexRebuilder = indexRebuilder;
_runtimeState = runtimeState;
}
/// <summary>
/// Once the application has fully started, schedule an index rebuild for any empty indexes (or all if it's a cold boot).
/// </summary>
/// <param name="notification">The notification.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public async Task HandleAsync(UmbracoApplicationStartedNotification notification, CancellationToken cancellationToken)
{
if (_runtimeState.Level != RuntimeLevel.Run)
{
return;
}
if (Interlocked.CompareExchange(ref _hasRun, 1, 0) != 0)
{
return;
}
SyncBootState bootState = _syncBootStateAccessor.GetSyncBootState();
// if it's not a cold boot, only rebuild empty ones
await _indexRebuilder.RebuildIndexesAsync(
bootState != SyncBootState.ColdBoot,
TimeSpan.FromMinutes(1));
}
}
@@ -13,6 +13,7 @@ namespace Umbraco.Cms.Infrastructure.Examine;
/// On the first HTTP request this will rebuild the Examine indexes if they are empty.
/// If it is a cold boot, they are all rebuilt.
/// </remarks>
[Obsolete("Superseded by RebuildOnStartedHandler. Scheduled for removal in Umbraco 19.")]
public sealed class RebuildOnStartupHandler : INotificationHandler<UmbracoRequestBeginNotification>
{
// These must be static because notification handlers are transient.
@@ -0,0 +1,35 @@
using Examine;
namespace Umbraco.Cms.Infrastructure.Examine;
/// <summary>
/// Writes a batch of <see cref="ValueSet" />s to one or more indexes.
/// </summary>
/// <remarks>
/// When a single index is registered the value sets are streamed straight through, so a lazily-built
/// sequence is enumerated once and never fully materialised in memory — keeping the common single-index
/// rebuild's peak memory down. When multiple indexes are registered the sequence is materialised once and
/// reused, so the (potentially expensive) value sets are not rebuilt per index.
/// </remarks>
internal static class ValueSetIndexer
{
public static void IndexItems(IReadOnlyList<IIndex> indexes, IEnumerable<ValueSet> valueSets)
{
switch (indexes.Count)
{
case 0:
return;
case 1:
indexes[0].IndexItems(valueSets);
return;
default:
ValueSet[] materialized = valueSets as ValueSet[] ?? valueSets.ToArray();
foreach (IIndex index in indexes)
{
index.IndexItems(materialized);
}
return;
}
}
}
@@ -131,12 +131,15 @@ public class PackageMigrationRunner
=> RunPackagePlansAsync(plansToRun).GetAwaiter().GetResult();
/// <summary>
/// Runs the all specified package migration plans and publishes a <see cref="MigrationPlansExecutedNotification" />
/// if all are successful.
/// Runs all the specified package migration plans and publishes a <see cref="MigrationPlansExecutedNotification" />.
/// </summary>
/// <remarks>
/// All plans are run to completion even if one fails, so that one package's failure does not block another's.
/// A failed plan is reported via <see cref="ExecutedMigrationPlan.Successful" /> on the returned result rather
/// than by throwing; callers must inspect the results to detect a failure.
/// </remarks>
/// <param name="plansToRun"></param>
/// <returns></returns>
/// <exception cref="Exception">If any plan fails it will throw an exception.</exception>
public async Task<IEnumerable<ExecutedMigrationPlan>> RunPackagePlansAsync(IEnumerable<string> plansToRun)
{
List<ExecutedMigrationPlan> results = new();
@@ -11,6 +11,7 @@ using Umbraco.Cms.Core.Exceptions;
using Umbraco.Cms.Core.Logging;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Migrations;
using Umbraco.Cms.Infrastructure.Migrations.Install;
using Umbraco.Cms.Infrastructure.Migrations.Upgrade;
using Umbraco.Cms.Infrastructure.Runtime;
@@ -163,7 +164,23 @@ public class UnattendedUpgrader : INotificationAsyncHandler<RuntimeUnattendedUpg
try
{
await _packageMigrationRunner.RunPackagePlansAsync(pendingMigrations);
IEnumerable<ExecutedMigrationPlan> executedPlans =
await _packageMigrationRunner.RunPackagePlansAsync(pendingMigrations);
// Failed plans are reported via the result, not by throwing (the runner deliberately runs all plans to
// completion so one package's failure doesn't block another's). Surface them as a boot failure here so the
// failure is observable, mirroring the core upgrade path - otherwise the migration stays pending and the
// runtime re-derives Upgrading on every boot, leaving the site stuck on the maintenance page.
// All failures are reported together.
var failedPlans = executedPlans.Where(plan => plan.Successful is false).ToList();
if (failedPlans.Count > 0)
{
SetRuntimeError(CreatePackageMigrationError(failedPlans));
notification.UnattendedUpgradeResult =
RuntimeUnattendedUpgradeNotification.UpgradeResult.HasErrors;
return;
}
notification.UnattendedUpgradeResult = RuntimeUnattendedUpgradeNotification.UpgradeResult.PackageMigrationComplete;
// Migration plans may have changed published content, so refresh the distributed cache to ensure consistency on first request.
@@ -200,6 +217,22 @@ public class UnattendedUpgrader : INotificationAsyncHandler<RuntimeUnattendedUpg
}
}
private static Exception CreatePackageMigrationError(IReadOnlyList<ExecutedMigrationPlan> failedPlans)
{
static Exception ToException(ExecutedMigrationPlan plan)
=> plan.Exception ?? new UnattendedInstallException(
$"An error occurred while running the unattended package migration '{plan.Plan.Name}'.");
if (failedPlans.Count == 1)
{
return ToException(failedPlans[0]);
}
return new AggregateException(
$"{failedPlans.Count} unattended package migrations failed: {string.Join(", ", failedPlans.Select(plan => plan.Plan.Name))}.",
failedPlans.Select(ToException));
}
private void SetRuntimeError(Exception exception)
=> _runtimeState.Configure(
RuntimeLevel.BootFailed,
@@ -1297,6 +1297,36 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement
/// </summary>
public abstract int RecycleBinId { get; }
/// <inheritdoc />
public void UpdateSortOrder(IReadOnlyList<int> orderedNodeIds)
{
if (orderedNodeIds.Count == 0)
{
return;
}
var nodeTable = SqlSyntax.GetQuotedTableName(NodeDto.TableName);
var idColumn = SqlSyntax.GetQuotedColumnName(NodeDto.IdColumnName);
var sortOrderColumn = SqlSyntax.GetQuotedColumnName(NodeDto.SortOrderColumnName);
// Each node's new sort order is its position in the ordered collection.
var ordered = orderedNodeIds
.Select((id, sortOrder) => new KeyValuePair<int, int>(id, sortOrder))
.ToList();
// Two parameters per node (id + sort order), so batch to stay within the SQL Server parameter limit.
foreach (IEnumerable<KeyValuePair<int, int>> group in ordered.InGroupsOf(Constants.Sql.MaxParameterCount / 2))
{
List<KeyValuePair<int, int>> groupList = group.ToList();
var args = groupList.SelectMany(pair => new object[] { pair.Key, pair.Value }).ToArray();
var whenClauses = string.Join(" ", groupList.Select((_, i) => $"WHEN @{i * 2} THEN @{(i * 2) + 1}"));
var inClause = string.Join(", ", groupList.Select((_, i) => $"@{i * 2}"));
var sql = $"UPDATE {nodeTable} SET {sortOrderColumn} = CASE {idColumn} {whenClauses} END WHERE {idColumn} IN ({inClause})";
Database.Execute(sql, args);
}
}
/// <summary>
/// Gets all entities that are currently in the recycle bin.
/// </summary>
@@ -20,6 +20,10 @@ public class DistributedJobService : IDistributedJobService
private readonly ILogger<DistributedJobService> _logger;
private readonly DistributedJobSettings _settings;
// Which jobs align to the clock is a startup configuration concern (changing it requires a restart), so it is
// captured once in the constructor rather than re-evaluated on every poll.
private readonly HashSet<string> _clockAlignedJobNames;
/// <summary>
/// Initializes a new instance of the <see cref="DistributedJobService"/> class.
/// </summary>
@@ -58,6 +62,10 @@ public class DistributedJobService : IDistributedJobService
_distributedBackgroundJobs = distributedBackgroundJobs;
_logger = logger;
_settings = settings.Value;
_clockAlignedJobNames = _distributedBackgroundJobs
.Where(x => x.AlignToClock)
.Select(x => x.Name)
.ToHashSet();
}
/// <inheritdoc />
@@ -67,9 +75,12 @@ public class DistributedJobService : IDistributedJobService
scope.EagerWriteLock(Constants.Locks.DistributedJobs);
DateTime utcNow = DateTime.UtcNow;
IEnumerable<DistributedBackgroundJobModel> jobs = _distributedJobRepository.GetAll();
DistributedBackgroundJobModel? job = jobs.FirstOrDefault(x => x.LastRun < DateTime.UtcNow - x.Period
&& (x.IsRunning is false || x.LastAttemptedRun < DateTime.UtcNow - x.Period - _settings.MaximumExecutionTime));
DistributedBackgroundJobModel? job = jobs.FirstOrDefault(x =>
IsDue(x, utcNow, _clockAlignedJobNames.Contains(x.Name))
&& (x.IsRunning is false || x.LastAttemptedRun < utcNow - x.Period - _settings.MaximumExecutionTime));
if (job is null)
{
@@ -97,6 +108,39 @@ public class DistributedJobService : IDistributedJobService
return distributedJob;
}
/// <summary>
/// Determines whether a job is due to run.
/// </summary>
/// <param name="job">The job state.</param>
/// <param name="utcNow">The current UTC time.</param>
/// <param name="aligned">
/// Whether the job's runs are aligned to clock boundaries (see <see cref="IDistributedBackgroundJob.AlignToClock" />).
/// </param>
/// <remarks>
/// For non-aligned jobs the period counts from the previous run's completion (<c>LastRun + Period</c>, drifting).
/// For aligned jobs the job is due once a clock boundary — a multiple of the period measured from a fixed UTC
/// origin, so boundaries fall on round clock times such as on the minute — has fallen strictly after the previous
/// run's completion. Boundaries are in UTC, not the server's local time zone. This is overrun-safe: if a run takes
/// longer than the period, the boundary it would have targeted has already passed, so the missed boundary is
/// skipped rather than triggering back-to-back runs.
/// </remarks>
internal static bool IsDue(DistributedBackgroundJobModel job, DateTime utcNow, bool aligned)
{
if (aligned == false || job.Period <= TimeSpan.Zero)
{
return job.LastRun < utcNow - job.Period;
}
long periodTicks = job.Period.Ticks;
// Floor the current UTC time to the most recent clock boundary. Ticks count from a fixed origin (0001-01-01), and
// a day divides evenly by any clean sub-hour period, so boundaries fall on round clock times (e.g. each :10s).
long ticksSinceBoundary = utcNow.Ticks % periodTicks;
long currentBoundaryTicks = utcNow.Ticks - ticksSinceBoundary;
return currentBoundaryTicks > job.LastRun.Ticks;
}
/// <inheritdoc />
public async Task FinishAsync(string jobName)
{
@@ -136,11 +180,25 @@ public class DistributedJobService : IDistributedJobService
return;
}
// Clock-aligned jobs only hit their boundaries as tightly as the poll interval allows. If the poll interval
// is longer than the job's period, boundaries between polls are silently missed.
foreach (IDistributedBackgroundJob job in _distributedBackgroundJobs)
{
if (job.AlignToClock && job.Period < _settings.Period)
{
_logger.LogWarning(
"Distributed background job '{JobName}' aligns to the clock with a period of {Period}, but the distributed job poll interval is longer ({PollInterval}). Clock boundaries shorter than the poll interval will be missed; set Umbraco:CMS:DistributedJobs:Period to be no longer than the job period.",
job.Name,
job.Period,
_settings.Period);
}
}
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
scope.WriteLock(Constants.Locks.DistributedJobs);
DistributedBackgroundJobModel[] existingJobs = _distributedJobRepository.GetAll().ToArray();
var existingJobsByName = existingJobs.ToDictionary(x => x.Name);
Dictionary<string, DistributedBackgroundJobModel> existingJobsByName = existingJobs.ToDictionary(x => x.Name);
// Collect all changes first, then execute - minimizes time spent in the critical section
var jobsToAdd = new List<DistributedBackgroundJobModel>();
@@ -77,6 +77,7 @@ public static class UmbracoBuilderExtensions
builder.AddNotificationHandler<ContentTypeChangedNotification, DeferredCacheRebuildNotificationHandler>();
builder.AddNotificationHandler<MediaTypeChangedNotification, DeferredCacheRebuildNotificationHandler>();
builder.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, SeedingNotificationHandler>();
builder.AddNotificationHandler<UmbracoApplicationStartingNotification, DomainCacheSeedingNotificationHandler>();
builder.AddCacheSeeding();
return builder;
}
@@ -0,0 +1,21 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Infrastructure.HybridCache.Extensions;
/// <summary>
/// Provides extension methods for <see cref="IRuntimeState"/> used by the cache startup notification handlers.
/// </summary>
internal static class RuntimeStateExtensions
{
/// <summary>
/// Returns true when startup cache seeding should be skipped because the site is not yet serving
/// front-end content, i.e. it is installing (or below) or upgrading with the maintenance page shown.
/// </summary>
/// <param name="state">The runtime state.</param>
/// <param name="globalSettings">The global settings.</param>
public static bool ShouldSkipStartupSeeding(this IRuntimeState state, GlobalSettings globalSettings)
=> state.Level <= RuntimeLevel.Install
|| (state.Level == RuntimeLevel.Upgrade && globalSettings.ShowMaintenancePageWhenInUpgradeState);
}
@@ -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;
@@ -29,7 +28,7 @@ internal sealed class SeedingNotificationHandler : INotificationAsyncHandler<Umb
CancellationToken cancellationToken)
{
if (_runtimeState.Level <= RuntimeLevel.Install || (_runtimeState.Level == RuntimeLevel.Upgrade && _globalSettings.ShowMaintenancePageWhenInUpgradeState))
if (_runtimeState.ShouldSkipStartupSeeding(_globalSettings))
{
return;
}
@@ -38,6 +38,20 @@ internal sealed class DocumentCacheService : IDocumentCacheService
private readonly ConcurrentDictionary<string, IPublishedContent> _publishedContentCache = [];
// Monotonic counter bumped whenever the in-memory cache (L0/L1) is invalidated or refreshed.
// GetNodeAsync captures it before reading the backing store and re-checks it before writing
// back, so a snapshot read before a concurrent publish/refresh is never written over the
// refreshed entry — preventing the stale-set clobber that otherwise persists until a full clear.
//
// Deliberately a single global counter, not per-key: any invalidation invalidates every in-flight
// read-through. The only cost is an occasional skipped cache population when a read-through for one
// key overlaps an unrelated publish — a re-miss on the next request, never stale data. A per-key
// scheme would avoid that but needs a global epoch for bulk clears plus an exact per-key bump on
// every mutated cache key, which is easy to get wrong and would silently reintroduce the clobber.
// Global is correctness-robust; only revisit if read-through churn under heavy concurrent
// publishing ever shows up in profiling.
private long _cacheGeneration;
private HashSet<Guid> SeedKeys
{
get
@@ -129,15 +143,28 @@ internal sealed class DocumentCacheService : IDocumentCacheService
}
(bool exists, ContentCacheNode? contentCacheNode) = await _hybridCache.TryGetValueAsync<ContentCacheNode?>(cacheKey, CancellationToken.None);
// A value found in the backing store is already current, so it can always populate the caches
// below; only a value built from the read-through DB fetch needs the generation guard.
bool snapshotIsCurrent = true;
if (exists is false)
{
// Capture the cache generation before reading the backing store. If a concurrent publish or
// invalidation bumps the generation while we read and build below, the snapshot we hold is
// stale and must not be written back over the refreshed entries (the clobber that leaves
// memory permanently stale until a full clear).
long generation = Interlocked.Read(ref _cacheGeneration);
bool ancestorCheckFailed;
(contentCacheNode, ancestorCheckFailed) = await GetContentCacheNodeFromRepo();
snapshotIsCurrent = IsCacheGenerationCurrent(generation);
// Only cache the result if the ancestor check didn't fail.
// When content exists in DB but the ancestor check fails, this could be a transient
// race condition during cache rebuild. Caching null would poison the distributed cache.
if (ancestorCheckFailed is false)
// Skip the write when the generation moved — a refresh has superseded this snapshot.
if (ancestorCheckFailed is false && snapshotIsCurrent)
{
await _hybridCache.SetAsync(
cacheKey,
@@ -153,7 +180,10 @@ internal sealed class DocumentCacheService : IDocumentCacheService
}
IPublishedContent? result = _publishedContentFactory.ToIPublishedContent(contentCacheNode, preview).CreateModel(_publishedModelFactory);
if (result is not null)
// Only populate the L0 cache when our snapshot is still current; otherwise a concurrent
// refresh has already written fresher content and we must not overwrite it with this one.
if (result is not null && snapshotIsCurrent)
{
_publishedContentCache[cacheKey] = result;
}
@@ -185,6 +215,13 @@ internal sealed class DocumentCacheService : IDocumentCacheService
private bool GetPreview() => _previewService.IsInPreview();
// Bumped after every in-memory cache invalidation/refresh so in-flight read-through snapshots
// (see GetNodeAsync) can detect they have been superseded and skip writing back stale content.
private void InvalidateMemoryCacheGeneration() => Interlocked.Increment(ref _cacheGeneration);
private bool IsCacheGenerationCurrent(long capturedGeneration)
=> Interlocked.Read(ref _cacheGeneration) == capturedGeneration;
public IEnumerable<IPublishedContent> GetByContentType(IPublishedContentType contentType)
{
using ICoreScope scope = _scopeProvider.CreateCoreScope();
@@ -198,6 +235,10 @@ internal sealed class DocumentCacheService : IDocumentCacheService
public async Task ClearMemoryCacheAsync(CancellationToken cancellationToken)
{
// Bump first so any read-through that read the backing store before this clear is rejected
// when it tries to write back, even while the reseed below is still running.
InvalidateMemoryCacheGeneration();
_publishedContentCache.Clear();
await _hybridCache.RemoveByTagAsync(Constants.Cache.Tags.Content, cancellationToken);
@@ -227,11 +268,13 @@ internal sealed class DocumentCacheService : IDocumentCacheService
var cacheKey = GetCacheKey(publishedNode.Key, false);
await _hybridCache.SetAsync(cacheKey, publishedNode, GetEntryOptions(publishedNode.Key, false), GenerateTags(publishedNode));
_publishedContentCache.Remove(cacheKey, out _);
InvalidateMemoryCacheGeneration();
}
else
{
// Either no published node in the database cache, or the ancestor path is no longer published —
// remove any stale published entry from the local memory cache.
// remove any stale published entry from the local memory cache. ClearPublishedCacheAsync
// bumps the generation itself, so this path is already covered.
await ClearPublishedCacheAsync(key);
}
@@ -423,12 +466,17 @@ internal sealed class DocumentCacheService : IDocumentCacheService
ClearConvertedContentCache(contentTypeIdsAsArray);
}
public void ClearConvertedContentCache() => _publishedContentCache.Clear();
public void ClearConvertedContentCache()
{
_publishedContentCache.Clear();
InvalidateMemoryCacheGeneration();
}
public void ClearConvertedContentCache(IReadOnlyCollection<int> contentTypeIds)
{
var ids = contentTypeIds as int[] ?? contentTypeIds.ToArray();
_publishedContentCache.RemoveAll(content => ids.Contains(content.Value.ContentType.Id));
InvalidateMemoryCacheGeneration();
}
private async Task ClearPublishedCacheAsync(Guid key)
@@ -436,6 +484,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService
var cacheKey = GetCacheKey(key, false);
await _hybridCache.RemoveAsync(cacheKey);
_publishedContentCache.Remove(cacheKey, out _);
InvalidateMemoryCacheGeneration();
}
private static string ContentTypeIdTag(int contentTypeId)
@@ -1,4 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
@@ -9,23 +9,41 @@ using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Changes;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
/// <summary>
/// Implements <see cref="IDomainCacheService" />, providing an in-memory cache of the configured <see cref="Domain" />s.
/// </summary>
/// <remarks>
/// The cache is lazily populated from the database on first access and kept up to date in response to domain
/// cache refresher notifications. It is registered as a singleton, so a single instance serves all requests.
/// </remarks>
public class DomainCacheService : IDomainCacheService
{
private readonly IDomainService _domainService;
private readonly ICoreScopeProvider _coreScopeProvider;
private readonly ConcurrentDictionary<int, Domain> _domains;
private bool _initialized = false;
private readonly Lock _initializationLock = new();
// Both fields are written under _initializationLock but read on the hot path (request routing) without
// it. Marking them volatile makes those lock-free reads acquire-reads, so a reader is guaranteed to see
// the fully populated dictionary and the completed-initialization flag together, never a stale or
// half-published value. This is required for correctness on weak memory models such as ARM; on x86/x64
// ordinary reads already have acquire semantics, but we cannot rely on that.
private volatile ConcurrentDictionary<int, Domain> _domains = new();
private volatile bool _initialized;
/// <summary>
/// Initializes a new instance of the <see cref="DomainCacheService" /> class.
/// </summary>
/// <param name="domainService">The service used to load domains from the database.</param>
/// <param name="coreScopeProvider">The provider used to create scopes for database access.</param>
public DomainCacheService(IDomainService domainService, ICoreScopeProvider coreScopeProvider)
{
_domainService = domainService;
_coreScopeProvider = coreScopeProvider;
_domains = new ConcurrentDictionary<int, Domain>();
}
/// <inheritdoc />
public IEnumerable<Domain> GetAll(bool includeWildcards)
{
InitializeIfMissing();
@@ -34,22 +52,38 @@ public class DomainCacheService : IDomainCacheService
: _domains.Select(x => x.Value).OrderBy(x => x.SortOrder);
}
/// <summary>
/// Loads the domains on first access, ensuring the cache is populated before any caller reads from it.
/// </summary>
private void InitializeIfMissing()
{
// Lazy, on-demand initialization triggered by the first request to reach the cache.
// The flag must only be set to true *after* the domains have been loaded and published.
// Setting it beforehand creates a window where a concurrent caller observes _initialized == true,
// skips loading, and reads an empty domain cache. On a multi-site setup that empties domain
// resolution, causing every site to fall back to the first root node (see ContentFinderByUrlNew).
// The double-checked lock ensures a single load while concurrent readers block until it completes.
if (_initialized)
{
return;
}
_initialized = true;
LoadDomains();
lock (_initializationLock)
{
if (_initialized)
{
return;
}
LoadDomains();
_initialized = true;
}
}
/// <inheritdoc />
public IEnumerable<Domain> GetAssigned(int documentId, bool includeWildcards = false)
{
InitializeIfMissing();
// probably this could be optimized with an index
// but then we'd need a custom DomainStore of some sort
IEnumerable<Domain> list = _domains.Values.Where(x => x.ContentId == documentId);
if (includeWildcards == false)
{
@@ -66,6 +100,7 @@ public class DomainCacheService : IDomainCacheService
return documentId > 0 && GetAssigned(documentId, includeWildcards).Any();
}
/// <inheritdoc />
public void Refresh(DomainCacheRefresher.JsonPayload[] payloads)
{
foreach (DomainCacheRefresher.JsonPayload payload in payloads)
@@ -102,20 +137,23 @@ public class DomainCacheService : IDomainCacheService
continue; // anomaly
}
var newDomain = new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder);
// Feels wierd to use key and oldvalue, but we're using neither when updating.
_domains.AddOrUpdate(
domain.Id,
new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder),
(key, oldValue) => newDomain);
_domains[domain.Id] = new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder);
break;
}
}
}
/// <summary>
/// Reads the configured domains from the database into a fresh dictionary and atomically swaps it in
/// as the current cache.
/// </summary>
private void LoadDomains()
{
// Build the replacement set in a local dictionary and publish it with a single write to the
// (volatile) _domains field. A reader never observes a partially populated cache during a RefreshAll
// rebuild, and the published set contains exactly the current domains (any removed since the last
// load are absent).
var newDomains = new ConcurrentDictionary<int, Domain>();
using (ICoreScope scope = _coreScopeProvider.CreateCoreScope())
{
scope.ReadLock(Constants.Locks.Domains);
@@ -124,11 +162,11 @@ public class DomainCacheService : IDomainCacheService
.Where(x => x.RootContentId.HasValue && x.LanguageIsoCode.IsNullOrWhiteSpace() == false)
.Select(x => new Domain(x.Id, x.DomainName, x.RootContentId!.Value, x.LanguageIsoCode!, x.IsWildcard, x.SortOrder)))
{
_domains.AddOrUpdate(domain.Id, domain, (key, oldValue) => domain);
newDomains[domain.Id] = domain;
}
scope.Complete();
}
_domains = newDomains;
}
}
@@ -34,6 +34,20 @@ internal sealed class MediaCacheService : IMediaCacheService
private readonly ConcurrentDictionary<Guid, IPublishedContent> _publishedContentCache = [];
// Monotonic counter bumped whenever the in-memory cache (L0/L1) is invalidated or refreshed.
// GetNodeAsync captures it before reading the backing store and re-checks it before writing
// back, so a snapshot read before a concurrent refresh is never written over the refreshed
// entry — preventing the stale-set clobber that otherwise persists until a full clear.
//
// Deliberately a single global counter, not per-key: any invalidation invalidates every in-flight
// read-through. The only cost is an occasional skipped cache population when a read-through for one
// key overlaps an unrelated refresh — a re-miss on the next request, never stale data. A per-key
// scheme would avoid that but needs a global epoch for bulk clears plus an exact per-key bump on
// every mutated cache key, which is easy to get wrong and would silently reintroduce the clobber.
// Global is correctness-robust; only revisit if read-through churn under heavy concurrent
// refreshing ever shows up in profiling.
private long _cacheGeneration;
private HashSet<Guid>? _seedKeys;
private HashSet<Guid> SeedKeys
{
@@ -124,11 +138,24 @@ internal sealed class MediaCacheService : IMediaCacheService
string cacheKey = GetCacheKey(key);
(bool exists, ContentCacheNode? contentCacheNode) = await _hybridCache.TryGetValueAsync<ContentCacheNode?>(cacheKey, CancellationToken.None);
// A value found in the backing store is already current, so it can always populate the caches
// below; only a value built from the read-through DB fetch needs the generation guard.
bool snapshotIsCurrent = true;
if (exists is false)
{
// Capture the cache generation before reading the backing store. If a concurrent refresh or
// invalidation bumps the generation while we read and build below, the snapshot we hold is
// stale and must not be written back over the refreshed entries (the clobber that leaves
// memory permanently stale until a full clear).
long generation = Interlocked.Read(ref _cacheGeneration);
contentCacheNode = await GetContentCacheNodeFromRepo();
snapshotIsCurrent = IsCacheGenerationCurrent(generation);
// We don't want to cache removed items, this may cause issues if the L2 serializer changes.
if (contentCacheNode is not null)
// Skip the write when the generation moved — a refresh has superseded this snapshot.
if (contentCacheNode is not null && snapshotIsCurrent)
{
await _hybridCache.SetAsync(
cacheKey,
@@ -144,7 +171,10 @@ internal sealed class MediaCacheService : IMediaCacheService
}
IPublishedContent? result = _publishedContentFactory.ToIPublishedMedia(contentCacheNode).CreateModel(_publishedModelFactory);
if (result is not null)
// Only populate the L0 cache when our snapshot is still current; otherwise a concurrent
// refresh has already written fresher content and we must not overwrite it with this one.
if (result is not null && snapshotIsCurrent)
{
_publishedContentCache[key] = result;
}
@@ -160,6 +190,13 @@ internal sealed class MediaCacheService : IMediaCacheService
}
}
// Bumped after every in-memory cache invalidation/refresh so in-flight read-through snapshots
// (see GetNodeAsync) can detect they have been superseded and skip writing back stale content.
private void InvalidateMemoryCacheGeneration() => Interlocked.Increment(ref _cacheGeneration);
private bool IsCacheGenerationCurrent(long capturedGeneration)
=> Interlocked.Read(ref _cacheGeneration) == capturedGeneration;
public async Task<bool> HasContentByIdAsync(int id)
{
Attempt<Guid> keyAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Media);
@@ -186,6 +223,7 @@ internal sealed class MediaCacheService : IMediaCacheService
var cacheNode = _cacheNodeFactory.ToContentCacheNode(media);
await _databaseCacheRepository.RefreshMediaAsync(cacheNode);
_publishedContentCache.Remove(media.Key, out _);
InvalidateMemoryCacheGeneration();
scope.Complete();
}
@@ -263,9 +301,12 @@ internal sealed class MediaCacheService : IMediaCacheService
{
await _hybridCache.SetAsync(GetCacheKey(publishedNode.Key), publishedNode, GetEntryOptions(publishedNode.Key));
_publishedContentCache.Remove(key, out _);
InvalidateMemoryCacheGeneration();
}
else
{
// RemoveFromMemoryCacheAsync → ClearPublishedCacheAsync bumps the generation itself,
// so this path is already covered.
await RemoveFromMemoryCacheAsync(key);
}
@@ -274,6 +315,10 @@ internal sealed class MediaCacheService : IMediaCacheService
public async Task ClearMemoryCacheAsync(CancellationToken cancellationToken)
{
// Bump first so any read-through that read the backing store before this clear is rejected
// when it tries to write back, even while the reseed below is still running.
InvalidateMemoryCacheGeneration();
_publishedContentCache.Clear();
await _hybridCache.RemoveByTagAsync(Constants.Cache.Tags.Media, cancellationToken);
@@ -295,12 +340,17 @@ internal sealed class MediaCacheService : IMediaCacheService
ClearConvertedContentCache(mediaTypeIdsAsArray);
}
public void ClearConvertedContentCache() => _publishedContentCache.Clear();
public void ClearConvertedContentCache()
{
_publishedContentCache.Clear();
InvalidateMemoryCacheGeneration();
}
public void ClearConvertedContentCache(IReadOnlyCollection<int> mediaTypeIds)
{
var ids = mediaTypeIds as int[] ?? mediaTypeIds.ToArray();
_publishedContentCache.RemoveAll(content => ids.Contains(content.Value.ContentType.Id));
InvalidateMemoryCacheGeneration();
}
public void Rebuild(IReadOnlyCollection<int> contentTypeIds)
@@ -357,6 +407,7 @@ internal sealed class MediaCacheService : IMediaCacheService
{
await _hybridCache.RemoveAsync(GetCacheKey(key));
_publishedContentCache.Remove(key, out _);
InvalidateMemoryCacheGeneration();
}
private static string MediaTypeIdTag(int mediaTypeId)
@@ -1,34 +1,120 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Net;
using Umbraco.Cms.Core.Web;
using Umbraco.Extensions;
namespace Umbraco.Cms.Web.Common.AspNetCore;
/// <summary>
/// Resolves the current session identifier and reads, writes and clears session values using the
/// ASP.NET Core <see cref="ISession" /> exposed on the current <see cref="HttpContext" />.
/// </summary>
internal sealed class AspNetCoreSessionManager : ISessionIdResolver, ISessionManager
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IOptions<SessionOptions> _sessionOptions;
private readonly IOptionsMonitor<LoggingSettings> _loggingSettings;
public AspNetCoreSessionManager(IHttpContextAccessor httpContextAccessor) =>
_httpContextAccessor = httpContextAccessor;
public string? SessionId
/// <summary>
/// Initializes a new instance of the <see cref="AspNetCoreSessionManager" /> class.
/// </summary>
/// <param name="httpContextAccessor">Provides access to the current <see cref="HttpContext" />.</param>
/// <param name="sessionOptions">The configured session options, used to determine the session cookie name.</param>
/// <param name="loggingSettings">The logging settings, used to determine how the session id is resolved for log enrichment.</param>
public AspNetCoreSessionManager(
IHttpContextAccessor httpContextAccessor,
IOptions<SessionOptions> sessionOptions,
IOptionsMonitor<LoggingSettings> loggingSettings)
{
get
{
HttpContext? httpContext = _httpContextAccessor.HttpContext;
_httpContextAccessor = httpContextAccessor;
_sessionOptions = sessionOptions;
_loggingSettings = loggingSettings;
}
return IsSessionsAvailable
? httpContext?.Session.Id
: "0";
/// <inheritdoc />
/// <remarks>
/// The resolved value depends on <see cref="LoggingSettings.SessionIdLogging" />: the actual session id
/// (default), a one-way hash of the session cookie, or nothing.
/// </remarks>
public string? SessionId =>
_loggingSettings.CurrentValue.SessionIdLogging switch
{
SessionIdLoggingMode.None => null,
SessionIdLoggingMode.CookieHash => ResolveSessionCookieHash(),
_ => ResolveSessionId(),
};
/// <summary>
/// Resolves the actual ASP.NET Core session id, but only when an established session cookie is present.
/// </summary>
/// <remarks>
/// Reading Session.Id forces a synchronous, blocking load from the session store. When sessions are
/// backed by IDistributedCache (e.g. load-balanced setups), that is a network round-trip incurred on
/// every request that resolves the id for logging - even anonymous requests that never use session.
/// Only an established session sends back the session cookie, so its absence means there is nothing
/// meaningful to load (see #23082).
/// </remarks>
private string? ResolveSessionId()
{
if (IsSessionsAvailable is false)
{
return "0";
}
HttpContext? httpContext = _httpContextAccessor.HttpContext;
if (httpContext is null || TryGetSessionCookieValue(httpContext, out _) is false)
{
return null;
}
return httpContext.Session.Id;
}
/// <summary>
/// If session isn't enabled this will throw an exception so we check
/// Resolves a one-way hash of the session cookie value, which correlates requests to the same session
/// without loading the session from its store.
/// </summary>
private bool IsSessionsAvailable => !(_httpContextAccessor.HttpContext?.Features.Get<ISessionFeature>()?.Session is null);
private string? ResolveSessionCookieHash()
{
HttpContext? httpContext = _httpContextAccessor.HttpContext;
if (httpContext is null || TryGetSessionCookieValue(httpContext, out var cookieValue) is false)
{
return null;
}
// Never log the raw cookie value - it is effectively a bearer token for the session. A one-way hash
// preserves per-session correlation without exposing the cookie and without loading the session.
return cookieValue!.GenerateHash<SHA256>();
}
private bool TryGetSessionCookieValue(HttpContext httpContext, out string? value)
{
var sessionCookieName = _sessionOptions.Value.Cookie.Name;
if (sessionCookieName is null)
{
value = null;
return false;
}
return httpContext.Request.Cookies.TryGetValue(sessionCookieName, out value);
}
/// <summary>
/// Gets a value indicating whether session is available for the current request.
/// </summary>
/// <remarks>
/// Accessing <see cref="HttpContext.Session" /> throws an <see cref="InvalidOperationException" /> when the
/// session middleware has not been configured (i.e. <c>UseSession</c> was not called), so this is checked
/// before reading from or writing to the session.
/// </remarks>
private bool IsSessionsAvailable => _httpContextAccessor.HttpContext?.Features.Get<ISessionFeature>()?.Session is not null;
/// <inheritdoc />
public string? GetSessionValue(string key)
{
if (!IsSessionsAvailable)
@@ -39,6 +125,7 @@ internal sealed class AspNetCoreSessionManager : ISessionIdResolver, ISessionMan
return _httpContextAccessor.HttpContext?.Session.GetString(key);
}
/// <inheritdoc />
public void SetSessionValue(string key, string value)
{
if (!IsSessionsAvailable)
@@ -49,6 +136,7 @@ internal sealed class AspNetCoreSessionManager : ISessionIdResolver, ISessionMan
_httpContextAccessor.HttpContext?.Session.SetString(key, value);
}
/// <inheritdoc />
public void ClearSessionValue(string key)
{
if (!IsSessionsAvailable)
@@ -108,7 +108,6 @@ class UmbStoryBookElement extends UmbLitElement {
...publishCacheManifests,
...relationsManifests,
...rteManifests,
...searchManifests,
...segmentManifests,
...settingsManifests,
...staticFileManifests,
@@ -190,25 +189,25 @@ export const parameters = {
},
},
backgrounds: {
options: {
greyish: {
options: {
greyish: {
name: 'Greyish',
value: '#F3F3F5',
},
white: {
white: {
name: 'White',
value: '#ffffff',
}
}
},
}
},
};
setCustomElements(customElementManifests);
export const tags = ['autodocs'];
export const initialGlobals = {
backgrounds: {
value: 'greyish'
}
backgrounds: {
value: 'greyish'
}
};
+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": "^1.17.0-rc.5",
"monaco-editor": "^0.55.1",
"@tiptap/core": "^3.16.0",
"@hey-api/openapi-ts": ">=0.85.0 <1.0.0"
}
"peerDependencies": {
"lit": "^3.3.1",
"rxjs": "^7.8.2",
"@umbraco-ui/uui": "^1.18.1",
"monaco-editor": "^0.55.1",
"@tiptap/core": "^3.16.0",
"@hey-api/openapi-ts": ">=0.85.0 <1.0.0"
}
}
```
@@ -168,9 +176,9 @@ When using `@umbraco-cms/backoffice`:
### Key Files
| File | Purpose |
|------|---------|
| `package.json` | Root package with exports and workspace references |
| File | Purpose |
| ------------------------------- | ---------------------------------------------------------------- |
| `package.json` | Root package with exports and workspace references |
| `devops/publish/cleanse-pkg.js` | Script that runs during `npm pack` to hoist and convert versions |
| `src/external/*` | Dependency wrapper packages |
| `src/packages/core` | Contains `@hey-api/openapi-ts` and other utilities |
| `src/external/*` | Dependency wrapper packages |
| `src/packages/core` | Contains `@hey-api/openapi-ts` and other utilities |
File diff suppressed because it is too large Load Diff
@@ -258,12 +258,9 @@ export class UmbAppElement extends UmbLitElement {
// Register Core extensions (this is specifically done here because we need these extensions to be registered before the application is initialized)
onInit(this, umbExtensionsRegistry);
// Register public extensions (login extensions) in parallel with the auth flow below.
const registerPublicExtensions = new UmbServerExtensionRegistrator(
this,
umbExtensionsRegistry,
).registerPublicExtensions();
new UmbAppEntryPointExtensionInitializer(this, umbExtensionsRegistry);
// Register public extensions (login extensions)
await new UmbServerExtensionRegistrator(this, umbExtensionsRegistry).registerPublicExtensions();
const entryPointInitializer = new UmbAppEntryPointExtensionInitializer(this, umbExtensionsRegistry);
// Try to initialise the auth flow and get the runtime status
try {
@@ -279,8 +276,11 @@ export class UmbAppElement extends UmbLitElement {
await this.#setAuthStatus();
}
// The login screen needs the public extensions before routing.
await registerPublicExtensions;
// The login screen decides which auth provider to use from the registered
// `authProvider` extensions. App-entry-points may register or unregister those during
// their async onInit, so wait for them to settle before routing — otherwise on a slow
// connection the decision races and falls back to the local login.
await this.observe(entryPointInitializer.loaded).asPromise();
// Initialise the router
this.#redirect();
@@ -1556,8 +1556,16 @@ export default {
chooseChildNode: 'اختر العقدة الفرعية',
compositionsDescription:
'ارث التبويبات والخصائص من نوع مستند موجود. سيتم إضافة التبويبات الجديدة إلى نوع الوثيقة الحالي أو دمجها إذا كان هناك تبويب بنفس الاسم.',
compositionsDescriptionMediaType:
'ارث التبويبات والخصائص من نوع وسائط موجود. سيتم إضافة التبويبات الجديدة إلى نوع الوسائط الحالي أو دمجها إذا كان هناك تبويب بنفس الاسم.',
compositionsDescriptionMemberType:
'ارث التبويبات والخصائص من نوع عضو موجود. سيتم إضافة التبويبات الجديدة إلى نوع العضو الحالي أو دمجها إذا كان هناك تبويب بنفس الاسم.',
compositionInUse: 'هذا النوع من المحتوى قيد الاستخدام في تركيب، وبالتالي لا يمكن تركيبه بنفسه.\n ',
compositionInUseMediaType: 'هذا النوع من الوسائط قيد الاستخدام في تركيب، وبالتالي لا يمكن تركيبه بنفسه.\n ',
compositionInUseMemberType: 'هذا النوع من الأعضاء قيد الاستخدام في تركيب، وبالتالي لا يمكن تركيبه بنفسه.\n ',
noAvailableCompositions: 'لا توجد أنواع محتوى متاحة لاستخدامها كتركيب.',
noAvailableCompositionsMediaType: 'لا توجد أنواع وسائط متاحة لاستخدامها كتركيب.',
noAvailableCompositionsMemberType: 'لا توجد أنواع أعضاء متاحة لاستخدامها كتركيب.',
compositionRemoveWarning:
'إزالة التركيب ستؤدي إلى حذف جميع بيانات الخصائص المرتبطة. بمجرد حفظ نوع الوثيقة لا يوجد طريق للعودة.',
availableEditors: 'إنشاء جديد',
@@ -1591,6 +1599,8 @@ export default {
tabHasNoSortOrder: 'التبويب ليس له ترتيب فرز',
compositionUsageHeading: 'أين يتم استخدام هذا التركيب؟',
compositionUsageSpecification: 'يتم استخدام هذا التركيب حاليًا في تركيب أنواع المحتوى التالية:\n ',
compositionUsageSpecificationMediaType: 'يتم استخدام هذا التركيب حاليًا في تركيب أنواع الوسائط التالية:\n ',
compositionUsageSpecificationMemberType: 'يتم استخدام هذا التركيب حاليًا في تركيب أنواع الأعضاء التالية:\n ',
variantsHeading: 'السماح بالاختلافات',
cultureVariantHeading: 'السماح بالاختلاف حسب الثقافة',
segmentVariantHeading: 'السماح بالتجزئة',
@@ -1489,8 +1489,16 @@ export default {
chooseChildNode: 'Odaberite podređeni čvor',
compositionsDescription:
'Naslijediti kartice i svojstva iz postojeće vrste dokumenta. Nove kartice će biti\n dodano trenutnoj vrsti dokumenta ili spojeno ako postoji kartica s identičnim imenom.\n ',
compositionsDescriptionMediaType:
'Naslijediti kartice i svojstva iz postojeće vrste medija. Nove kartice će biti\n dodano trenutnoj vrsti medija ili spojeno ako postoji kartica s identičnim imenom.\n ',
compositionsDescriptionMemberType:
'Naslijediti kartice i svojstva iz postojeće vrste člana. Nove kartice će biti\n dodano trenutnoj vrsti člana ili spojeno ako postoji kartica s identičnim imenom.\n ',
compositionInUse: 'Ovaj tip sadržaja se koristi u kompoziciji i stoga se ne može sam sastaviti.\n ',
compositionInUseMediaType: 'Ovaj tip medija se koristi u kompoziciji i stoga se ne može sam sastaviti.\n ',
compositionInUseMemberType: 'Ovaj tip člana se koristi u kompoziciji i stoga se ne može sam sastaviti.\n ',
noAvailableCompositions: 'Nema dostupnih tipova sadržaja za upotrebu kao kompozicija.',
noAvailableCompositionsMediaType: 'Nema dostupnih tipova medija za upotrebu kao kompozicija.',
noAvailableCompositionsMemberType: 'Nema dostupnih tipova člana za upotrebu kao kompozicija.',
compositionRemoveWarning:
'Uklanjanje kompozicije će izbrisati sve povezane podatke o svojstvu. Jednom ti\n sačuvajte tip dokumenta, nema povratka.\n ',
availableEditors: 'Napravi novi',
@@ -1525,6 +1533,8 @@ export default {
tabHasNoSortOrder: 'kartica nema redoslijed sortiranja',
compositionUsageHeading: 'Gdje se koristi ovaj sastav?',
compositionUsageSpecification: 'Ovaj sastav se trenutno koristi u sastavu sljedećih\n tipa sadržaja:\n ',
compositionUsageSpecificationMediaType: 'Ovaj sastav se trenutno koristi u sastavu sljedećih\n tipa medija:\n ',
compositionUsageSpecificationMemberType: 'Ovaj sastav se trenutno koristi u sastavu sljedećih\n tipa člana:\n ',
variantsHeading: 'Dozvoli varijacije',
cultureVariantHeading: 'Dozvolite varirati u zavisnosti od kulture',
segmentVariantHeading: 'Dozvoli segmentaciju',
@@ -1381,8 +1381,16 @@ export default {
chooseChildNode: 'Vybrat podřízený uzel',
compositionsDescription:
'Zdědí záložky a vlastnosti z existujícího typu dokumentu. Nové záložky budou přidány do aktuálního typu dokumentu nebo sloučeny, pokud existuje záložka se stejným názvem.',
compositionsDescriptionMediaType:
'Zdědí záložky a vlastnosti z existujícího typu média. Nové záložky budou přidány do aktuálního typu média nebo sloučeny, pokud existuje záložka se stejným názvem.',
compositionsDescriptionMemberType:
'Zdědí záložky a vlastnosti z existujícího typu člena. Nové záložky budou přidány do aktuálního typu člena nebo sloučeny, pokud existuje záložka se stejným názvem.',
compositionInUse: 'Tento typ obsahu se používá ve složení, a proto jej nelze poskládat.',
compositionInUseMediaType: 'Tento typ média se používá ve složení, a proto jej nelze poskládat.',
compositionInUseMemberType: 'Tento typ člena se používá ve složení, a proto jej nelze poskládat.',
noAvailableCompositions: 'Nejsou k dispozici žádné typy obsahu, které lze použít jako složení.',
noAvailableCompositionsMediaType: 'Nejsou k dispozici žádné typy média, které lze použít jako složení.',
noAvailableCompositionsMemberType: 'Nejsou k dispozici žádné typy člena, které lze použít jako složení.',
compositionRemoveWarning:
'Odebráním složení odstraníte všechna související data vlastností. Jakmile uložíte typ dokumentu, již není cesta zpět.',
availableEditors: 'Vytvořit nové',
@@ -1415,6 +1423,8 @@ export default {
tabHasNoSortOrder: 'záložka nemá žádné řazení',
compositionUsageHeading: 'Kde se toto složení používá?',
compositionUsageSpecification: 'Toto složení se v současnosti používá ve složení následujících typů obsahu:',
compositionUsageSpecificationMediaType: 'Toto složení se v současnosti používá ve složení následujících typů média:',
compositionUsageSpecificationMemberType: 'Toto složení se v současnosti používá ve složení následujících typů člena:',
variantsHeading: 'Povolit různé jazyky',
variantsDescription: 'Povolit editorům vytvářet obsah tohoto typu v různých jazycích.',
allowVaryByCulture: 'Povolit různé jazyky',
@@ -1606,9 +1606,19 @@ export default {
chooseChildNode: 'Dewis nod blentyn',
compositionsDescription:
"Etifeddu tabiau a phriodweddau o fath o ddogfen sy'n bodoli eisoes. Bydd tabiau newydd yn cael eu ychwanegu at y fath o ddogfen bresennol neu eu cyfuno os mae tab gyda enw yr union yr un fath yn bodoli eisoes.",
compositionsDescriptionMediaType:
"Etifeddu tabiau a phriodweddau o fath o gyfrwng sy'n bodoli eisoes. Bydd tabiau newydd yn cael eu ychwanegu at y fath o gyfrwng bresennol neu eu cyfuno os mae tab gyda enw yr union yr un fath yn bodoli eisoes.",
compositionsDescriptionMemberType:
"Etifeddu tabiau a phriodweddau o fath o aelod sy'n bodoli eisoes. Bydd tabiau newydd yn cael eu ychwanegu at y fath o aelod bresennol neu eu cyfuno os mae tab gyda enw yr union yr un fath yn bodoli eisoes.",
compositionInUse:
"Mae'r math o gynnwys yma wedi'i ddefnyddio mewn cyfansoddiad, felly ni ellir ei gyfansoddi ei hunan.",
compositionInUseMediaType:
"Mae'r math o gyfrwng yma wedi'i ddefnyddio mewn cyfansoddiad, felly ni ellir ei gyfansoddi ei hunan.",
compositionInUseMemberType:
"Mae'r math o aelod yma wedi'i ddefnyddio mewn cyfansoddiad, felly ni ellir ei gyfansoddi ei hunan.",
noAvailableCompositions: "Nid oes unrhyw fathau o gynnwys ar gael i'w defnyddio fel cyfansoddiad.",
noAvailableCompositionsMediaType: "Nid oes unrhyw fathau o gyfrwng ar gael i'w defnyddio fel cyfansoddiad.",
noAvailableCompositionsMemberType: "Nid oes unrhyw fathau o aelod ar gael i'w defnyddio fel cyfansoddiad.",
compositionRemoveWarning:
"Bydd dileu cyfansoddiad yn dileu'r holl ddata eiddo priodwedd gysylltiedig. Ar ôl i chi arbed y math o ddogfen, bydd ddim ffordd nôl.",
availableEditors: 'Golygyddion ar gael',
@@ -1646,6 +1656,10 @@ export default {
compositionUsageHeading: "Ble mae'r cyfansoddiad yma'n cael ei ddefnyddio?",
compositionUsageSpecification:
"Mae'r cyfansoddiad yma yn cael ei ddefnyddio'n bresennol yng nghyfansoddiad o'r mathau o gynnwys ganlynol:",
compositionUsageSpecificationMediaType:
"Mae'r cyfansoddiad yma yn cael ei ddefnyddio'n bresennol yng nghyfansoddiad o'r mathau o gyfrwng ganlynol:",
compositionUsageSpecificationMemberType:
"Mae'r cyfansoddiad yma yn cael ei ddefnyddio'n bresennol yng nghyfansoddiad o'r mathau o aelod ganlynol:",
variantsHeading: 'Caniatáu amrywiadau',
cultureVariantHeading: 'Caniatáu amrywiad yn ôl ddiwylliant',
segmentVariantHeading: 'Caniatáu segmentiad',
@@ -1749,9 +1749,19 @@ export default {
chooseChildNode: 'Vælg child node',
compositionsDescription:
'Nedarv faner og egenskaber fra en anden dokumenttype. Nye faner vil blive\n tilføjet den nuværende dokumenttype eller sammenflettet hvis fanenavnene er ens.\n ',
compositionsDescriptionMediaType:
'Nedarv faner og egenskaber fra en anden medietype. Nye faner vil blive\n tilføjet den nuværende medietype eller sammenflettet hvis fanenavnene er ens.\n ',
compositionsDescriptionMemberType:
'Nedarv faner og egenskaber fra en anden medlemstype. Nye faner vil blive\n tilføjet den nuværende medlemstype eller sammenflettet hvis fanenavnene er ens.\n ',
compositionInUse:
'Indholdstypen bliver brugt i en komposition og kan derfor ikke blive anvendt som\n komposition\n ',
compositionInUseMediaType:
'Medietypen bliver brugt i en komposition og kan derfor ikke blive anvendt som\n komposition\n ',
compositionInUseMemberType:
'Medlemstypen bliver brugt i en komposition og kan derfor ikke blive anvendt som\n komposition\n ',
noAvailableCompositions: 'Der er ingen indholdstyper tilgængelige at bruge som komposition',
noAvailableCompositionsMediaType: 'Der er ingen medietyper tilgængelige at bruge som komposition',
noAvailableCompositionsMemberType: 'Der er ingen medlemstyper tilgængelige at bruge som komposition',
compositionRemoveWarning:
'Når du fjerner en komposition vil alle associerede indholdsdata blive slettet.\n Når først dokumenttypen er gemt, er der ingen vej tilbage.\n ',
availableEditors: 'Opret ny indstilling',
@@ -1789,6 +1799,8 @@ export default {
tabHasNoSortOrder: 'fane har ingen sorteringsrækkefølge',
compositionUsageHeading: 'Hvor er denne komposition brugt?',
compositionUsageSpecification: 'Denne komposition brugt i kompositionen af de følgende indholdstyper:\n ',
compositionUsageSpecificationMediaType: 'Denne komposition brugt i kompositionen af de følgende medietyper:\n ',
compositionUsageSpecificationMemberType: 'Denne komposition brugt i kompositionen af de følgende medlemstyper:\n ',
variantsHeading: 'Tillad variationer',
cultureVariantHeading: 'Tillad sprogvariation',
segmentVariantHeading: 'Tillad segmentering',
@@ -1057,7 +1057,7 @@ export default {
greeting5: 'Willkommen',
greeting6: 'Willkommen',
instruction: 'Hier anmelden:',
signInWith: 'Anmelden mit',
signInWith: 'Anmelden mit {0}',
timeout: 'Sitzung abgelaufen',
bottomText:
'<p style="text-align:right;">&copy; 2001 - %0% <br /><a href="https://umbraco.com" style="text-decoration: none" target="_blank" rel="noopener">umbraco.org</a></p> ',
@@ -1553,9 +1553,19 @@ export default {
chooseChildNode: 'Wählen Sie einen Unterknoten',
compositionsDescription:
'Übernimm Tabs und Eigenschaften vone einem vorhandenen Inhaltstyp. Neue Tabs werden zum vorliegenden Inhaltstyp hinzugefügt oder mit einem gleichnamigen Tab zusammengeführt.',
compositionsDescriptionMediaType:
'Übernimm Tabs und Eigenschaften vone einem vorhandenen Medientyp. Neue Tabs werden zum vorliegenden Medientyp hinzugefügt oder mit einem gleichnamigen Tab zusammengeführt.',
compositionsDescriptionMemberType:
'Übernimm Tabs und Eigenschaften vone einem vorhandenen Mitgliedstyp. Neue Tabs werden zum vorliegenden Mitgliedstyp hinzugefügt oder mit einem gleichnamigen Tab zusammengeführt.',
compositionInUse:
'Dieser Inhaltstyp wird in einer Mischung verwendet und kann deshalb nicht selbst zusammengemischt werden.',
compositionInUseMediaType:
'Dieser Medientyp wird in einer Mischung verwendet und kann deshalb nicht selbst zusammengemischt werden.',
compositionInUseMemberType:
'Dieser Mitgliedstyp wird in einer Mischung verwendet und kann deshalb nicht selbst zusammengemischt werden.',
noAvailableCompositions: 'Es sind keine Inhaltstypen für eine Mischung vorhanden.',
noAvailableCompositionsMediaType: 'Es sind keine Medientypen für eine Mischung vorhanden.',
noAvailableCompositionsMemberType: 'Es sind keine Mitgliedstypen für eine Mischung vorhanden.',
availableEditors: 'Neu anlegen',
reuse: 'Vorhandenen nutzen',
editorSettings: 'Editor-Einstellungen',
@@ -1590,6 +1600,10 @@ export default {
compositionUsageHeading: 'Wo wird diese Mischung verwendet?',
compositionUsageSpecification:
'\n Diese Mischung wird aktuell in den Mischungen folgender Dokumenttypen verwendet:\n ',
compositionUsageSpecificationMediaType:
'\n Diese Mischung wird aktuell in den Mischungen folgender Medientypen verwendet:\n ',
compositionUsageSpecificationMemberType:
'\n Diese Mischung wird aktuell in den Mischungen folgender Mitgliedstypen verwendet:\n ',
variantsHeading: 'Kultur basierte Variationen zulassen',
variantsDescription: 'Editoren erlauben, Inhalt dieses Typs in verschiedenen Sprachen anzulegen',
allowVaryByCulture: 'Kultur basierte Variationen zulassen',
@@ -1802,8 +1802,16 @@ export default {
chooseChildNode: 'Choose child node',
compositionsDescription:
'Inherit tabs and properties from an existing Document Type. New tabs will be added to the current Document Type or merged if a tab with an identical name exists.',
compositionInUse: 'This Content Type is used in a composition, and therefore cannot be composed itself.',
noAvailableCompositions: 'There are no Content Types available to use as a composition.',
compositionsDescriptionMediaType:
'Inherit tabs and properties from an existing Media Type. New tabs will be added to the current Media Type or merged if a tab with an identical name exists.',
compositionsDescriptionMemberType:
'Inherit tabs and properties from an existing Member Type. New tabs will be added to the current Member Type or merged if a tab with an identical name exists.',
compositionInUse: 'This Document Type is used in a composition, and therefore cannot be composed itself.',
compositionInUseMediaType: 'This Media Type is used in a composition, and therefore cannot be composed itself.',
compositionInUseMemberType: 'This Member Type is used in a composition, and therefore cannot be composed itself.',
noAvailableCompositions: 'There are no Document Types available to use as a composition.',
noAvailableCompositionsMediaType: 'There are no Media Types available to use as a composition.',
noAvailableCompositionsMemberType: 'There are no Member Types available to use as a composition.',
compositionRemoveWarning:
"Removing a composition will delete all the associated property data. Once you save the Document Type there's no way back.",
availableEditors: 'Create new',
@@ -1841,7 +1849,11 @@ export default {
tabHasNoSortOrder: 'tab has no sort order',
compositionUsageHeading: 'Where is this composition used?',
compositionUsageSpecification:
'This composition is currently used in the composition of the following Content Types:',
'This composition is currently used in the composition of the following Document Types:',
compositionUsageSpecificationMediaType:
'This composition is currently used in the composition of the following Media Types:',
compositionUsageSpecificationMemberType:
'This composition is currently used in the composition of the following Member Types:',
variantsHeading: 'Variation',
cultureVariantHeading: 'Allow vary by culture',
segmentVariantHeading: 'Allow segmentation',
@@ -1131,9 +1131,19 @@ export default {
chooseChildNode: 'Elegir nodo hijo',
compositionsDescription:
'Heredar pestañas y propiedades de un tipo de documento existente. Nuevas pestañas serán añadidas al tipo de documento actual o mezcladas si una pestaña con nombre idéntico ya existe.',
compositionsDescriptionMediaType:
'Heredar pestañas y propiedades de un tipo de medio existente. Nuevas pestañas serán añadidas al tipo de medio actual o mezcladas si una pestaña con nombre idéntico ya existe.',
compositionsDescriptionMemberType:
'Heredar pestañas y propiedades de un tipo de miembro existente. Nuevas pestañas serán añadidas al tipo de miembro actual o mezcladas si una pestaña con nombre idéntico ya existe.',
compositionInUse:
'Este tipo de contenido es usado en una composición, y por tanto no puede no puede ser compuesto.',
'Este tipo de contenido es usado en una composición, y por tanto no puede ser compuesto.',
compositionInUseMediaType:
'Este tipo de medio es usado en una composición, y por tanto no puede ser compuesto.',
compositionInUseMemberType:
'Este tipo de miembro es usado en una composición, y por tanto no puede ser compuesto.',
noAvailableCompositions: 'No hay tipos de contenido disponibles para usar como composición.',
noAvailableCompositionsMediaType: 'No hay tipos de medio disponibles para usar como composición.',
noAvailableCompositionsMemberType: 'No hay tipos de miembro disponibles para usar como composición.',
availableEditors: 'Editores disponibles',
reuse: 'Reusar',
editorSettings: 'Configuración de editor',
@@ -924,7 +924,7 @@ export default {
greeting5: 'Bienvenue',
greeting6: 'Bienvenue',
instruction: 'Connectez-vous ci-dessous',
signInWith: 'Identifiez-vous avec',
signInWith: 'Identifiez-vous avec {0}',
timeout: 'La session a expiré',
bottomText:
'<p style="text-align:right;">&copy; 2001 - %0% <br /><a href="https://umbraco.com" style="text-decoration: none" target="_blank" rel="noopener">Umbraco.com</a></p> ',
@@ -1403,10 +1403,20 @@ export default {
childNodesDescription: 'Autorisez la création de contenu des types spécifiés sous le contenu de ce type-ci',
chooseChildNode: 'Choisissez les noeuds enfants',
compositionsDescription:
"Hériter des onglets et propriétés d'un type de document existant. De nouveaux onglets seront ajoutés au type de document actuel, ou fusionnés s'il existe un onglet avec un nom sililaire.",
"Hériter des onglets et propriétés d'un type de document existant. De nouveaux onglets seront ajoutés au type de document actuel, ou fusionnés s'il existe un onglet avec un nom similaire.",
compositionsDescriptionMediaType:
"Hériter des onglets et propriétés d'un type de media existant. De nouveaux onglets seront ajoutés au type de media actuel, ou fusionnés s'il existe un onglet avec un nom similaire.",
compositionsDescriptionMemberType:
"Hériter des onglets et propriétés d'un type de membre existant. De nouveaux onglets seront ajoutés au type de membre actuel, ou fusionnés s'il existe un onglet avec un nom similaire.",
compositionInUse:
'Ce type de contenu est utilisé dans une composition, et ne peut donc pas être lui-même un composé.',
compositionInUseMediaType:
'Ce type de media est utilisé dans une composition, et ne peut donc pas être lui-même un composé.',
compositionInUseMemberType:
'Ce type de membre est utilisé dans une composition, et ne peut donc pas être lui-même un composé.',
noAvailableCompositions: "Il n'y a pas de type de contenu disponible à utiliser dans une composition.",
noAvailableCompositionsMediaType: "Il n'y a pas de type de media disponible à utiliser dans une composition.",
noAvailableCompositionsMemberType: "Il n'y a pas de type de membre disponible à utiliser dans une composition.",
compositionRemoveWarning:
"La suppression d'une composition supprimera les données de toutes les propriétés associées. Une fois que vous sauvegardez le type de document, il n'y a plus moyen de faire marche arrière.",
availableEditors: 'Editeurs disponibles',
@@ -1443,6 +1453,10 @@ export default {
compositionUsageHeading: 'Où cette composition est-elle utilisée?',
compositionUsageSpecification:
'Cette composition est actuellement utilisée dans la composition des types de contenu suivants :',
compositionUsageSpecificationMediaType:
'Cette composition est actuellement utilisée dans la composition des types de media suivants :',
compositionUsageSpecificationMemberType:
'Cette composition est actuellement utilisée dans la composition des types de membre suivants :',
variantsHeading: 'Permettre une variation par culture',
variantsDescription: 'Permettre aux éditeurs de créer du contenu de ce type dans différentes langues.',
allowVaryByCulture: 'Permettre une variation par culture',
@@ -1533,8 +1533,16 @@ export default {
chooseChildNode: 'Odaberite podređeni čvor',
compositionsDescription:
'Naslijediti kartice i svojstva iz postojeće vrste dokumenta. Nove kartice bit će\n dodane trenutnoj vrsti dokumenta ili spojene ako postoji kartica s identičnim imenom.\n ',
compositionsDescriptionMediaType:
'Naslijediti kartice i svojstva iz postojeće vrste medija. Nove kartice bit će\n dodane trenutnoj vrsti medija ili spojene ako postoji kartica s identičnim imenom.\n ',
compositionsDescriptionMemberType:
'Naslijediti kartice i svojstva iz postojeće vrste člana. Nove kartice bit će\n dodane trenutnoj vrsti člana ili spojene ako postoji kartica s identičnim imenom.\n ',
compositionInUse: 'Ova vrsta sadržaja se koristi u kompoziciji i stoga se ne može sam sastaviti.\n ',
compositionInUseMediaType: 'Ova vrsta medija se koristi u kompoziciji i stoga se ne može sam sastaviti.\n ',
compositionInUseMemberType: 'Ova vrsta člana se koristi u kompoziciji i stoga se ne može sam sastaviti.\n ',
noAvailableCompositions: 'Nema dostupnih vrsta sadržaja za upotrebu kao kompozicija.',
noAvailableCompositionsMediaType: 'Nema dostupnih vrsta medija za upotrebu kao kompozicija.',
noAvailableCompositionsMemberType: 'Nema dostupnih vrsta člana za upotrebu kao kompozicija.',
compositionRemoveWarning:
'Uklanjanje kompozicije će obrisati sve povezane podatke o svojstvu. Jednom kada spremite vrstu dokumenta, nema povratka.\n ',
availableEditors: 'Napravi novi',
@@ -1568,6 +1576,8 @@ export default {
tabHasNoSortOrder: 'kartica nema redoslijed sortiranja',
compositionUsageHeading: 'Gdje se koristi ovaj sastav?',
compositionUsageSpecification: 'Ovaj sastav se trenutno koristi u sastavu sljedećih\n vrsta sadržaja:\n ',
compositionUsageSpecificationMediaType: 'Ovaj sastav se trenutno koristi u sastavu sljedećih\n vrsta medija:\n ',
compositionUsageSpecificationMemberType: 'Ovaj sastav se trenutno koristi u sastavu sljedećih\n vrsta člana:\n ',
variantsHeading: 'Dozvoli varijacije',
cultureVariantHeading: 'Dozvolite varirati u zavisnosti od kulture',
segmentVariantHeading: 'Dozvoli segmentaciju',
@@ -1528,9 +1528,19 @@ export default {
chooseChildNode: 'Scegli nodo figlio',
compositionsDescription:
'Eredita schede e proprietà da un tipo di documento esistente. Le nuove schede verranno aggiunte al tipo di documento corrente o unite se esiste una scheda con un nome identico.',
compositionsDescriptionMediaType:
'Eredita schede e proprietà da un tipo di media esistente. Le nuove schede verranno aggiunte al tipo di media corrente o unite se esiste una scheda con un nome identico.',
compositionsDescriptionMemberType:
'Eredita schede e proprietà da un tipo di membro esistente. Le nuove schede verranno aggiunte al tipo di membro corrente o unite se esiste una scheda con un nome identico.',
compositionInUse:
'Questo tipo di contenuto è utlizzato in una composizione, e quindi non può essere composto da se stesso.',
'Questo tipo di contenuto è utilizzato in una composizione, e quindi non può essere composto da se stesso.',
compositionInUseMediaType:
'Questo tipo di media è utilizzato in una composizione, e quindi non può essere composto da se stesso.',
compositionInUseMemberType:
'Questo tipo di membro è utilizzato in una composizione, e quindi non può essere composto da se stesso.',
noAvailableCompositions: 'Non ci sono tipi di contenuto utilizzabili come composizione.',
noAvailableCompositionsMediaType: 'Non ci sono tipi di media utilizzabili come composizione.',
noAvailableCompositionsMemberType: 'Non ci sono tipi di membro utilizzabili come composizione.',
compositionRemoveWarning:
'Rimuovendo una composizione si elimineranno tutti i dati associati ad essa. Una volta salvato il tipo di documento non ci sarà nessun modo di recuperare i dati.',
availableEditors: 'Crea nuovo',
@@ -1568,6 +1578,8 @@ export default {
tabHasNoSortOrder: 'la scheda non ha un ordine',
compositionUsageHeading: 'Dove è usata questa composizione?',
compositionUsageSpecification: 'Questa composizione è usata nella composizione dei seguenti tipi di contenuto:',
compositionUsageSpecificationMediaType: 'Questa composizione è usata nella composizione dei seguenti tipi di media:',
compositionUsageSpecificationMemberType: 'Questa composizione è usata nella composizione dei seguenti tipi di membro:',
variantsHeading: 'Consenti variazioni',
cultureVariantHeading: 'Consenti variazioni in base alla lingua',
segmentVariantHeading: 'Consenti segmentazione',
@@ -888,8 +888,16 @@ export default {
chooseChildNode: '子ノードの選択',
compositionsDescription:
'既存ドキュメント タイプのタブとプロパティを継承。新しいタブを現在のドキュメント タイプに追加、または同じ名前のタブがある場合はマージされます。',
compositionsDescriptionMediaType:
'既存メディア タイプのタブとプロパティを継承。新しいタブを現在のメディア タイプに追加、または同じ名前のタブがある場合はマージされます。',
compositionsDescriptionMemberType:
'既存メンバー タイプのタブとプロパティを継承。新しいタブを現在のメンバー タイプに追加、または同じ名前のタブがある場合はマージされます。',
compositionInUse: 'このコンテンツ タイプが構成で使用されるため、自身を構成することはできません。',
compositionInUseMediaType: 'このメディア タイプが構成で使用されるため、自身を構成することはできません。',
compositionInUseMemberType: 'このメンバー タイプが構成で使用されるため、自身を構成することはできません。',
noAvailableCompositions: '構成に使用できるコンテンツ タイプはありません。',
noAvailableCompositionsMediaType: '構成に使用できるメディア タイプはありません。',
noAvailableCompositionsMemberType: '構成に使用できるメンバー タイプはありません。',
availableEditors: '使用可能なエディター',
reuse: '再利用',
editorSettings: 'エディター設定',
@@ -713,7 +713,7 @@ export default {
greeting5: 'Velkommen',
greeting6: 'Velkommen',
instruction: 'Logg på nedenfor',
signInWith: 'Logg på med',
signInWith: 'Logg på med {0}',
timeout: 'Din sesjon er utløpt',
bottomText:
'<p style="text-align:right;">&copy; 2001 - %0% <br /><a href="https://umbraco.com" style="text-decoration: none" target="_blank" rel="noopener">umbraco.com</a></p> ',
@@ -18,6 +18,7 @@ export default {
changeDataType: 'Datatype aanpassen',
copy: 'Kopiëren',
create: 'Nieuw',
createFor: (name: string) => (name ? `Item aanmaken voor ${name}` : 'Aanmaken'),
export: 'Export',
createPackage: 'Nieuwe package',
createGroup: 'Groep maken',
@@ -56,6 +57,7 @@ export default {
setGroup: 'Groep instellen',
sort: 'Sorteren',
translate: 'Vertalen',
trash: 'Verwijderen',
update: 'Bijwerken',
setPermissions: 'Rechten instellen',
unlock: 'Deblokkeer',
@@ -944,7 +946,7 @@ export default {
greeting5: 'Welkom',
greeting6: 'Welkom',
instruction: 'log hieronder in',
signInWith: 'Inloggen met',
signInWith: 'Inloggen met {0}',
timeout: 'Sessie is verlopen',
bottomText:
'<p style="text-align:right;">&copy; 2001 - %0% <br /><a href="https://umbraco.com" style="text-decoration: none" target="_blank" rel="noopener">umbraco.com</a></p>',
@@ -1455,9 +1457,19 @@ export default {
chooseChildNode: 'Kies onderliggende node',
compositionsDescription:
'Overgeërfde tabs en properties van een bestaand documenttype. Nieuwe tabs\n worden toegevoegd aan het huidige documenttype of samengevoegd als een tab met dezelfde naam al bestaat.\n ',
compositionsDescriptionMediaType:
'Overgeërfde tabs en properties van een bestaand mediatype. Nieuwe tabs\n worden toegevoegd aan het huidige mediatype of samengevoegd als een tab met dezelfde naam al bestaat.\n ',
compositionsDescriptionMemberType:
'Overgeërfde tabs en properties van een bestaand lidtype. Nieuwe tabs\n worden toegevoegd aan het huidige lidtype of samengevoegd als een tab met dezelfde naam al bestaat.\n ',
compositionInUse:
'Dit contenttype wordt gebruikt in een compositie en kan daarom niet zelf een\n compositie worden.\n ',
compositionInUseMediaType:
'Dit mediatype wordt gebruikt in een compositie en kan daarom niet zelf een\n compositie worden.\n ',
compositionInUseMemberType:
'Dit lidtype wordt gebruikt in een compositie en kan daarom niet zelf een\n compositie worden.\n ',
noAvailableCompositions: 'Er zijn geen contenttypen beschikbaar om als compositie te gebruiken.',
noAvailableCompositionsMediaType: 'Er zijn geen mediatypen beschikbaar om als compositie te gebruiken.',
noAvailableCompositionsMemberType: 'Er zijn geen lidtypen beschikbaar om als compositie te gebruiken.',
compositionRemoveWarning:
'Een compositie verwijderen zal alle bijbehorende eigenschapsdata ook\n verwijderen. Zodra je het documenttype hebt opgeslagen is er geen weg meer terug.\n ',
availableEditors: 'Beschikbare editors',
@@ -1495,6 +1507,10 @@ export default {
compositionUsageHeading: 'Waar wordt deze compositie gebruikt?',
compositionUsageSpecification:
'Deze samenstelling wordt momenteel gebruikt bij de samenstelling van de\n volgende inhoudstypen:\n ',
compositionUsageSpecificationMediaType:
'Deze samenstelling wordt momenteel gebruikt bij de samenstelling van de\n volgende mediatypen:\n ',
compositionUsageSpecificationMemberType:
'Deze samenstelling wordt momenteel gebruikt bij de samenstelling van de\n volgende lidtypen:\n ',
variantsHeading: 'Variaties toestaan',
cultureVariantHeading: 'Variëren per cultuur toestaan',
segmentVariantHeading: 'Segmentatie toestaan',
@@ -1074,8 +1074,16 @@ export default {
chooseChildNode: 'Wybierz węzeł dziecka',
compositionsDescription:
'Odziedzicz zakładki i właściwości z istniejącego typu dokumentu. Nowe zakładki będą dodane do bieżącego typu dokumentu lub złączone jeśli zakładka z identyczną nazwą już istnieje.',
compositionsDescriptionMediaType:
'Odziedzicz zakładki i właściwości z istniejącego typu mediów. Nowe zakładki będą dodane do bieżącego typu mediów lub złączone jeśli zakładka z identyczną nazwą już istnieje.',
compositionsDescriptionMemberType:
'Odziedzicz zakładki i właściwości z istniejącego typu członka. Nowe zakładki będą dodane do bieżącego typu członka lub złączone jeśli zakładka z identyczną nazwą już istnieje.',
compositionInUse: 'Ten typ zawartości jest używany w kompozycji, przez co sam nie może być złożony.',
compositionInUseMediaType: 'Ten typ mediów jest używany w kompozycji, przez co sam nie może być złożony.',
compositionInUseMemberType: 'Ten typ członka jest używany w kompozycji, przez co sam nie może być złożony.',
noAvailableCompositions: 'Brak możliwych typów zawartości do użycia jako kompozycja.',
noAvailableCompositionsMediaType: 'Brak możliwych typów mediów do użycia jako kompozycja.',
noAvailableCompositionsMemberType: 'Brak możliwych typów członka do użycia jako kompozycja.',
availableEditors: 'Dostępni edytorzy',
reuse: 'Użyj ponownie',
editorSettings: 'Ustawienia edytora',
@@ -1728,8 +1728,16 @@ export default {
chooseChildNode: 'Escolher nó filho',
compositionsDescription:
'Herde separadores e propriedades de um Tipo de Documento existente. Novos separadores serão adicionados ao Tipo de Documento atual ou fundidos se existir um separador com um nome idêntico.',
compositionsDescriptionMediaType:
'Herde separadores e propriedades de um Tipo de Multimédia existente. Novos separadores serão adicionados ao Tipo de Multimédia atual ou fundidos se existir um separador com um nome idêntico.',
compositionsDescriptionMemberType:
'Herde separadores e propriedades de um Tipo de Membro existente. Novos separadores serão adicionados ao Tipo de Membro atual ou fundidos se existir um separador com um nome idêntico.',
compositionInUse: 'Este Tipo de Conteúdo é usado numa composição e, portanto, não pode ser composto ele próprio.',
compositionInUseMediaType: 'Este Tipo de Multimédia é usado numa composição e, portanto, não pode ser composto ele próprio.',
compositionInUseMemberType: 'Este Tipo de Membro é usado numa composição e, portanto, não pode ser composto ele próprio.',
noAvailableCompositions: 'Não existem Tipos de Conteúdo disponíveis para usar como composição.',
noAvailableCompositionsMediaType: 'Não existem Tipos de Multimédia disponíveis para usar como composição.',
noAvailableCompositionsMemberType: 'Não existem Tipos de Membro disponíveis para usar como composição.',
compositionRemoveWarning:
'Remover uma composição eliminará todos os dados de propriedade associados. Depois de guardar o Tipo de Documento, não há como voltar atrás.',
availableEditors: 'Criar novo',
@@ -1766,6 +1774,8 @@ export default {
tabHasNoSortOrder: 'o separador não tem ordem',
compositionUsageHeading: 'Onde é usada esta composição?',
compositionUsageSpecification: 'Esta composição é atualmente usada na composição dos seguintes Tipos de Conteúdo:',
compositionUsageSpecificationMediaType: 'Esta composição é atualmente usada na composição dos seguintes Tipos de Multimédia:',
compositionUsageSpecificationMemberType: 'Esta composição é atualmente usada na composição dos seguintes Tipos de Membro:',
variantsHeading: 'Variação',
cultureVariantHeading: 'Permitir variar por cultura',
segmentVariantHeading: 'Permitir segmentação',
@@ -275,11 +275,23 @@ export default {
chooseChildNode: 'Выбрать дочерний узел',
compositionsDescription:
'Унаследовать вкладки и свойства из уже существующего типа документов. Вкладки будут либо добавлены в создаваемый тип, либо в случае совпадения названий вкладок будут добавлены наследуемые свойства.',
compositionsDescriptionMediaType:
'Унаследовать вкладки и свойства из уже существующего типа медиа. Вкладки будут либо добавлены в создаваемый тип, либо в случае совпадения названий вкладок будут добавлены наследуемые свойства.',
compositionsDescriptionMemberType:
'Унаследовать вкладки и свойства из уже существующего типа участников. Вкладки будут либо добавлены в создаваемый тип, либо в случае совпадения названий вкладок будут добавлены наследуемые свойства.',
compositionInUse:
'Этот тип документов уже участвует в композиции другого типа, поэтому сам не может быть композицией.',
compositionInUseMediaType:
'Этот тип медиа уже участвует в композиции другого типа, поэтому сам не может быть композицией.',
compositionInUseMemberType:
'Этот тип участников уже участвует в композиции другого типа, поэтому сам не может быть композицией.',
compositionUsageHeading: 'Где используется эта композиция?',
compositionUsageSpecification: 'Эта композиция сейчас используется при создании следующих типов документов:',
compositionUsageSpecificationMediaType: 'Эта композиция сейчас используется при создании следующих типов медиа:',
compositionUsageSpecificationMemberType: 'Эта композиция сейчас используется при создании следующих типов участников:',
noAvailableCompositions: 'В настоящее время нет типов документов, допустимых для построения композиции.',
noAvailableCompositionsMediaType: 'В настоящее время нет типов медиа, допустимых для построения композиции.',
noAvailableCompositionsMemberType: 'В настоящее время нет типов участников, допустимых для построения композиции.',
availableEditors: 'Доступные редакторы',
reuse: 'Переиспользовать',
editorSettings: 'Установки редактора',
@@ -697,7 +697,7 @@ export default {
greeting5: 'Välkommen',
greeting6: 'Välkommen',
instruction: 'Logga in nedan',
signInWith: 'Logga in med',
signInWith: 'Logga in med {0}',
timeout: 'Sessionen har nått sin maxgräns',
},
main: {
@@ -1376,8 +1376,16 @@ export default {
chooseChildNode: 'Alt düğümü seçin',
compositionsDescription:
'Mevcut bir belge türünden sekmeleri ve özellikleri devralın. Mevcut belge türüne yeni sekmeler eklenecek veya aynı ada sahip bir sekme varsa birleştirilecektir.',
compositionsDescriptionMediaType:
'Mevcut bir medya türünden sekmeleri ve özellikleri devralın. Mevcut medya türüne yeni sekmeler eklenecek veya aynı ada sahip bir sekme varsa birleştirilecektir.',
compositionsDescriptionMemberType:
'Mevcut bir üye türünden sekmeleri ve özellikleri devralın. Mevcut üye türüne yeni sekmeler eklenecek veya aynı ada sahip bir sekme varsa birleştirilecektir.',
compositionInUse: 'Bu içerik türü bir bestede kullanıldığından kendi başına oluşturulamaz.',
compositionInUseMediaType: 'Bu medya türü bir bestede kullanıldığından kendi başına oluşturulamaz.',
compositionInUseMemberType: 'Bu üye türü bir bestede kullanıldığından kendi başına oluşturulamaz.',
noAvailableCompositions: 'Beste olarak kullanılabilecek içerik türü yok.',
noAvailableCompositionsMediaType: 'Beste olarak kullanılabilecek medya türü yok.',
noAvailableCompositionsMemberType: 'Beste olarak kullanılabilecek üye türü yok.',
compositionRemoveWarning:
'Bir kompozisyonun kaldırılması, ilişkili tüm özellik verilerini silecektir. Belge türünü kaydettikten sonra geri dönüş yoktur.',
availableEditors: 'Yeni oluştur',
@@ -1412,6 +1420,8 @@ export default {
tabHasNoSortOrder: 'sekmesinde sıralama düzeni yok',
compositionUsageHeading: 'Bu beste nerede kullanılıyor?',
compositionUsageSpecification: 'Bu beste şu anda aşağıdaki içerik türlerinin oluşturulmasında kullanılmaktadır:',
compositionUsageSpecificationMediaType: 'Bu beste şu anda aşağıdaki medya türlerinin oluşturulmasında kullanılmaktadır:',
compositionUsageSpecificationMemberType: 'Bu beste şu anda aşağıdaki üye türlerinin oluşturulmasında kullanılmaktadır:',
cultureVariantHeading: 'Kültüre göre değişikliklere izin ver',
segmentVariantHeading: 'Segmentasyona izin ver',
cultureVariantLabel: 'Kültüre göre değişiklik yapın',
@@ -275,10 +275,20 @@ export default {
chooseChildNode: 'Вибрати дочірній вузол',
compositionsDescription:
'Успадкувати вкладки та властивості з існуючого типу документів. Вкладки будуть або додані до створюваного типу, або у разі збігу назв вкладок будуть додані успадковані властивості.',
compositionsDescriptionMediaType:
'Успадкувати вкладки та властивості з існуючого типу медіа. Вкладки будуть або додані до створюваного типу, або у разі збігу назв вкладок будуть додані успадковані властивості.',
compositionsDescriptionMemberType:
'Успадкувати вкладки та властивості з існуючого типу учасників. Вкладки будуть або додані до створюваного типу, або у разі збігу назв вкладок будуть додані успадковані властивості.',
compositionInUse: 'Цей тип документів вже бере участь у композиції іншого типу, тому сам може бути композицією.',
compositionInUseMediaType: 'Цей тип медіа вже бере участь у композиції іншого типу, тому сам може бути композицією.',
compositionInUseMemberType: 'Цей тип учасників вже бере участь у композиції іншого типу, тому сам може бути композицією.',
compositionUsageHeading: 'Де використовується ця композиція?',
compositionUsageSpecification: 'Ця композиція зараз використовується при створенні таких типів документів:',
compositionUsageSpecificationMediaType: 'Ця композиція зараз використовується при створенні таких типів медіа:',
compositionUsageSpecificationMemberType: 'Ця композиція зараз використовується при створенні таких типів учасників:',
noAvailableCompositions: 'Наразі немає типів документів, допустимих побудови композиції.',
noAvailableCompositionsMediaType: 'Наразі немає типів медіа, допустимих побудови композиції.',
noAvailableCompositionsMemberType: 'Наразі немає типів учасників, допустимих побудови композиції.',
availableEditors: 'Доступні редактори',
reuse: 'Перевикористати',
editorSettings: 'Налаштування редактора',

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