Skip to the content.

TrueCopy

How this package works, and why. For getting it running locally see development.md; for what it does for an editor see the README. Paths below are relative to src/TrueCopy/ unless stated otherwise.

Umbraco’s built-in Copy duplicates a document and its descendants, but copies property values verbatim. Every Content Picker, Document Picker, Multi URL Picker and rich-text internal link in the copies therefore still points at the original pages. Copy a site section to build a new one and you get a set of pages that all link back into the old section, to be fixed by hand, page by page.

TrueCopy adds a True Copy… item to a document’s tree menu. It performs the copy Umbraco already performs, then walks the copies and repoints every internal link whose target was itself part of the copy.

Links pointing at pages outside the copy are left exactly as they are — they are still correct — but they are counted and listed in the summary the editor sees afterwards, because “this copy still links into the old tree” is the one thing they cannot see for themselves.

The built-in Copy is untouched. TrueCopy is a separate action; Umbraco’s own Copy behaves exactly as it did before, which corecopy in the verification below asserts on purpose.

Layout

TrueCopy/
  Composers/    DI registration, the notification handler, the rewriter collection, the OpenAPI document
  Controllers/  the backoffice API (one endpoint)
  Services/     orchestration, the per-operation key map, the notification handler
  Rewriting/    one ILinkRewriter per editor family - where all the risk lives
  ViewModels/   request/response models
  Resources/    .resx strings for the ProblemDetails the API returns (see Translating)
  Client/       TypeScript + Vite source for the entity action and the result modal
    src/lang/   the UI dictionaries, one file per language
  wwwroot/      Vite output, served at /App_Plugins/TrueCopy (generated, not committed)

How the original-to-copy map is built

ContentService.Copy raises one ContentCopiedNotification per copied node — the root and every descendant — and the scoped publisher dispatches them together, in a single call, once the copy’s scope has completed:

void Handle(IEnumerable<ContentCopiedNotification> notifications);

So by the time the copy call returns, TrueCopyKeyMapHandler has seen every (Original, Copy) pair. That collection is the map. It works for every entry point — the backoffice, the management API, or a direct IContentService.Copy call — because they all funnel into the same method.

Why not the relateDocumentOnCopy relations. Umbraco can record a relation per copied node, which would give the same mapping. But it only does so when the editor ticked “relate to original”, which would make TrueCopy’s correctness depend on an unrelated checkbox; and the relation table accumulates every copy ever made, so identifying this copy’s rows means guessing at a time window.

The handler is registered globally and therefore runs on every copy in the installation. Its first act is to ask TrueCopyOperationAccessor whether a TrueCopy operation is running and return if not. That one lookup is what keeps Umbraco’s own Copy completely unaffected.

The rewriters

bool CanRewrite(string propertyEditorAlias);
string? Rewrite(string value, RewriteContext context);   // null == unchanged

Returning null rather than the input is the safety property the package rests on. A value we did not need to change is never deserialised and re-serialised, so round-trip fidelity only has to hold for values we genuinely rewrote — and TrueCopy never saves a document it had no business touching.

LinkRewriterCollection is an Umbraco BuilderCollectionBase, so a site can Append its own rewriter for a custom editor.

Stored value shapes, each confirmed against real rows in this repo’s database rather than assumed:

Editor Stored as
Umbraco.ContentPicker umb://document/<guid-n>
Umbraco.MultiNodeTreePicker comma-separated UDIs, types mixed: umb://document/…,umb://media/…
Umbraco.MultiUrlPicker JSON array of {name,target,unique,type,udi,url,queryString,culture}; internal links carry udi, external ones url
Umbraco.RichText {"markup":"…","blocks":{contentData,settingsData,expose,layout}}
Umbraco.BlockList / BlockGrid / SingleBlock {layout,contentData,settingsData,expose}

Note that v18 still persists unique and type on Multi URL Picker entries (as nulls), so both the udi form and the older type+unique pairing are handled.

Everything works on the JSON tree, not on Umbraco’s models

BlockPropertyValue.Value is object?, so a nested block value round-trips through a typed model as a JsonElement and has to be reconstructed by hand — and any field TrueCopy does not model would be silently dropped on the way back out. Editing JsonNode in place changes only the GUIDs that matched and leaves every other byte alone, which is the behaviour you want from something rewriting other people’s content.

All three appear in real data and all three are handled, each rewritten back into the form it arrived in:

The match is anchored on the <a> tag rather than the token alone so the tag’s type attribute can be read. A {localLink:<guid>} with type="media" is a media link: its GUID is never in the copy map, so rewriting it is a no-op either way — but reporting it as a document link left pointing outside the copy would be a lie, and that report is the feature.

Block placeholders in the markup (<umb-rte-block data-content-key="…">) reference block content keys, not documents. Umbraco’s copy re-keys those consistently across layout, contentData and expose; rewriting one would detach a block from its own layout entry, so they are left alone.

Blocks recurse by editorAlias

Umbraco 18 persists editorAlias alongside each nested value, so each one says which editor it is and nothing has to be guessed. Where it is missing — values written by an older version — the block’s contentTypeKey is resolved through IContentTypeService instead, cached per operation. Guessing the editor from the value’s shape is how a rewriter starts corrupting data, so there is no third fallback: an unidentifiable value is skipped.

Why there is no reference pre-filter

DataValueReferenceFactoryCollection.GetAllReferences(properties, propertyEditors) looks like the ideal cheap test for “does this document reference anything in the map”. It is not usable here: it throws ArgumentException when a block property value has no resolved PropertyType, which is exactly the state a document loaded through IContentService.GetById is in. The rewriters are cheap enough (most start with a substring test) that the filter would not have earned its risk anyway.

API

POST /umbraco/truecopy/api/v1/copy, requiring Content section access. Browsable at /umbraco/openapi under the truecopy document.

{ "sourceId": "<guid>", "targetParentId": "<guid|null>", "includeDescendants": true, "relateToOriginal": false }

Section access alone is not enough — copying is a content write — so the request is authorized against the two specific documents, the same two permissions core’s own CopyDocumentController checks: Umb.Document.Duplicate on the source and Umb.Document.Create on the target.

ActionLetter, not ActionAlias. In Umbraco 18 those two fields read backwards from their names: ActionCopy.ActionLetter is "Umb.Document.Duplicate", the permission a user actually holds, while ActionCopy.ActionAlias is the legacy "copy". Authorizing on the alias fails every check silently, because nobody is ever granted a permission by that name.

Never declare a 401 or a 403 ProducesResponseType. Umbraco’s BackOfficeSecurityRequirementsTransformer adds both to every operation, and declaring either again throws An item with the same key has already been added while the document is generated — surfacing as a 500 on /umbraco/openapi/truecopy.json with nothing pointing at the controller.

The client

There are no dashboards in this package — one entityAction and one modal.

The destination picker is core’s own duplicate modal (UMB_DUPLICATE_DOCUMENT_MODAL, publicly exported from @umbraco-cms/backoffice/document), so True Copy looks and behaves exactly like the built-in Copy right up to the point where it fixes the links, and the allowed-parent filtering and tree pre-expansion are core’s code paths rather than a second implementation that could drift from them.

It needs no UmbModalRouteRegistrationController. The repo’s routed-modal rule applies to collection-based pickers — the user picker in ContentDashboard embeds umb-collection, whose router fires the navigationsuccess that makes the modal manager force-close router-less modals. This modal renders umb-tree, and core itself opens it with a plain umbOpenModal. Verified: it is still open five seconds after opening.

cd Client
npm install            # NOT --legacy-peer-deps
npm run build          # or: npm run watch
npm run generate-client   # regenerate src/api from the live OpenAPI doc (site must be running)

Bump version in Client/public/umbraco-package.json on every client change — it is the ?umb__rnd= cache-buster. Static web assets are baked at build time, so after npm run build you must rebuild and restart Cms before the new chunks are served. The running site locks TrueCopy.dll; stop it first.

Translating

Text lives in two places, because it is displayed in two different ways.

The UI — Client/src/lang/. en.ts is the source of truth; every other file is a copy of it with the values translated, registered by a type: "localization" manifest in lang/manifest.ts. Adding a language is those two edits and nothing else. English (en) is Umbraco’s default culture, so it is loaded whatever the editor’s language is and acts as the per-key fallback — never translate en.ts in place, and never give it a regional culture like en-us, which would not be loaded alongside another language and would break the fallback.

Two guards keep a translation honest, and both fail the build rather than the UI:

Counts are function entries ((count: number) => …) rather than an (s) suffix, so a language with different plural rules can express them. The number itself is rendered separately from the label, so a translation supplies only the noun.

The API’s ProblemDetails — Resources/TrueCopyResources.resx, plus one TrueCopyResources.<culture>.resx per language, which .NET builds into a satellite assembly. These cannot live in the client dictionary: they reach the editor through core’s generic ProblemDetails handling, so they are already prose by the time any of our TypeScript could see them. The controller picks the culture from the calling user’s IUser.Language, so an editor sees the failure in their own language regardless of the server’s. The {0} in FailedDetail is a raw ContentEditingOperationStatus name and is deliberately left untranslated — it is the diagnostic that makes a support report actionable.

Tests

dotnet test TrueCopy.Tests covers the rewriters, which carry all the logic that is easy to get quietly wrong. They are pure functions of (value, key map) — no Umbraco host, no container, no database — for the same reason AnomalyDetector is a static class in ErrorDashboard.

Each rewriter is exercised for a target inside the map, a target outside it (untouched and reported), a self-link, values it must ignore (media and member UDIs, external URLs), malformed input (returns null, never throws), and the guard that an unmatched value returns null so nothing is written.

Known limitations