feat: duplicate content into another collection - #2368
Draft
MA2153 wants to merge 2 commits into
Draft
Conversation
Extends the duplicate action so an entry can be copied into a different collection through a field mapping, one at a time or up to 50 at once. Two checks sit at different stages. Column-type compatibility gates what a mapping may pair, enforced server-side rather than only in the dialog. Field values run through validateContentData(partial: false) at insert — the same pipeline creates use. Same-collection duplicate can skip that because its source row already passed at create; an arbitrary mapping breaks the invariant and can assemble a row handleContentCreate would have rejected. Mapping completeness (every required target field has a source) is a separate, request-level check. Mappings are stored per collection pair in the existing options table, so there is no migration. Copies land as drafts with a fresh slug and a new translation_group — a copy in another collection is a distinct thing, not a translation — which is also why inbound reference edges keep pointing at the original by construction. Reference fields map like any other TEXT field. Entry-level reference edges belong to relations, which are scoped to a collection pair and not tied to a field, so they are dropped and named in the dialog with a count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: e7cd17c The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Contributor
Scope checkThis PR changes 2,086 lines across 22 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Contributor
The duplicate action always confirms first now, from a content row, from a
selection, or from the editor sidebar. Two duplicate icons sat in each row
before this — one for a plain copy and one for the cross-collection copy —
which is both confusing and one stray click away from creating content.
Merging the two surfaces means merging the two implementations, so
handleContentDuplicateMany accepts a target equal to the source and backs
every duplicate. `duplicate-to` was the name for a special case; now that
it is the only path it becomes `POST /content/{collection}/duplicate`. The
per-item route and the exported handleContentDuplicate stay for
compatibility, re-implemented as thin delegates so there is one behavior
rather than two that can drift.
Same-collection semantics are preserved deliberately. The copy still gets
a "(Copy)" title, since it shares a list with its original — that depends
on the collections matching, not on the mapping, so a copy that remaps
fields is suffixed too. Validation is skipped only for an identity mapping
within one collection, where the source row already passed at create: a row
that predates a newly required field must stay duplicable. A same-collection
copy that remaps can assemble a row create would have rejected, so it is
validated like any other mapping.
The dialog hides the mapping table, the saved-mapping option and the trash
option when the target is the source, where every field carries and there is
nothing to choose. From the editor it warns when the buffer is dirty, since
the copy is taken from the saved row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Important
Draft — not ready to merge. Two things gate it:
What does this PR do?
Extends the duplicate action so an entry can be copied into a different collection through a field mapping — one at a time, or up to 50 at once. Today that means copy-pasting field by field.
The check that keeps this small
Two checks sit at different stages, and keeping them distinct is what makes the feature small:
Column-type compatibility is enforced when the mapping is built. A mapping may only pair fields whose
FIELD_TYPE_TO_COLUMNentries match — TEXT to TEXT, JSON to JSON. Writing JSON into aREALcolumn is a storage error, not a content problem, so it is never offered. The dialog only shows compatible sources, and the server re-checks: a hand-rolled request can't sneak an incompatible pair through.Field values are validated when the copy is inserted. The assembled row runs through
validateContentData(db, targetCollection, mappedData, { partial: false })— the same pipelinehandleContentCreateuses.Today's same-collection duplicate skips validation, and that is safe: its source row already passed
partial: falseat create, so the copy is valid by construction. An arbitrary cross-collection mapping breaks that invariant. It can produce a rowhandleContentCreatewould have rejected — an out-of-optionsselect, a required field fed from a NULL source — and draft status is not a backstop:handleContentPublishruns no field validation, and a later edit runspartial: true, checking only the fields the editor happens to touch. An invalid copy would be publishable immediately and stay invalid indefinitely.Validation is per item: a failure returns
failedwith the message in that item's result, and nothing is written for it. The others still copy.Mapping completeness — every required target field has a source assigned — is a separate, earlier check about the mapping, not the values. It is enforced server-side, once per request, not only by disabling the dialog's confirm button. A required field mapped to a source that happens to be NULL satisfies completeness and is caught later by validation.
Saved mappings without a migration
A mapping is one blob per collection pair in the existing
optionstable:No new table, no migration. It upgrades to named presets later under the same key with a richer value. When no saved mapping exists, the server derives one by exact field-slug match, kept only where the column types agree.
What the copy is
Status
draftregardless of source status. Fresh slug generated in the target collection — uniqueness isUNIQUE(slug, locale)there, so no cross-collection conflict.localepreserved. Newtranslation_group: a copy in another collection is a distinct thing, not a translation.author_idis the acting user.published_at,scheduled_at,version, and both revision pointers start clean. No" (Copy)"title suffix — that exists to disambiguate two rows in one list, and a cross-collection copy lands in a different list.Everything beyond field columns is gated on the target actually supporting it:
(collection, entry_id)collectionsincludes the target slugReference fields
This is the part that isn't finished, and why the PR is a draft beyond the approval gate.
The spec this was built from assumes a mapped reference field re-parents its outgoing edges. That isn't expressible in the schema as it stands. The
referencefield type is a plain TEXT column holding a target id (FIELD_TYPE_TO_COLUMN.reference = "TEXT", validated for target existence byvalidateContentData). Separately,_emdash_content_referencesholds edges that belong to relations — scoped to a collection pair viaparent_collection/child_collection, and not tied to any field. The two are not the same feature, and the content handlers don't yet join them.So in this PR:
translation_group, so everything that pointed at the original keeps pointing at the original by construction. The dialog says so with a count.Once reference fields land properly — i.e. once a reference field maps 1:1 to a relation the way the design intends — the mapping table can offer reference→reference pairs and re-parent outgoing edges, with no change to the API shape. That is deliberately left for a follow-up rather than guessed at here.
API
Three additive routes.
GET /_emdash/api/content/{collection}/duplicate-mapping?target={slug}&ids={csv}— everything the dialog needs in one round trip: both field lists, the mapping, asource: "saved" | "derived"marker, per-target-field compatible sources, which taxonomy defs will and won't carry, and (whenidsis supplied) inbound/outbound reference-edge counts from one batched query.idsis capped at the same 50 as the bulk route.POST /_emdash/api/content/{collection}/{id}/duplicategains an optional body{ targetCollection, mapping, saveMapping, trashSource }. No body is today's same-collection behavior, unchanged — verified against a running instance, not just by reading.POST /_emdash/api/content/{collection}/duplicate-totakes{ ids, targetCollection, mapping, saveMapping, trashSource }, ids capped at 50 (D1 binds 100 parameters), returning:Authorization is
content:createplus per-item read access to the source — checked withcanActOnOwn(user, authorId, "content:edit_own", "content:edit_any"), the same substitution the existing per-item route makes becausecontent:readis flat. All three routes are in the OpenAPI document.Move source to trash
A per-run checkbox, default off, never persisted with the mapping. Permission is pre-checked per item with
requireOwnerPerm-equivalent logic before anything is copied, so an item can't end up copied with its source still in place because of a permission failure.D1 has no transactions, so copy-then-trash can split. If the trash step fails after a successful copy, that item reports
copied_not_trashedand the UI tells the user to trash it by hand — offering a retry would make a second copy. Those ids are excluded from the retry set the bulk selection keeps.Dialog
One screen: a target collection picker, then a mapping table with target fields as the rows. That orientation makes "every required field has a source" readable at a glance; unmapped required rows are marked and the confirm button stays disabled until they're filled. If a required target field has no column-type-compatible source at all, the pair is unmappable and the dialog says so outright rather than showing a dead button.
Below the table, a "Won't be copied" section names every drop explicitly: unmapped source fields, taxonomies the target isn't attached to, SEO when the target has it disabled, and the reference-edge counts.
The dialog checks the mapping, not the values — whether a given item's values survive validation is only known once the copy runs, and those failures surface per item. Pre-flighting them would mean reading every selected item's field values into the mapping endpoint to catch a case the per-item result already reports clearly; the endpoint already receives
idsand can grow the check later without an API change.Bulk reuses the existing
ContentListselection infrastructure:BulkActionHandlerreturns failed ids so those rows stay selected for retry. Cancelling the dialog keeps the whole selection.Test plan
11 integration tests through
describeEachDialect(mapping resolution is query-builder code, and regressions there tend to be dialect-specific):selectvalue is rejected at insert and writes no rowtranslation_groupoptionsand is preferred over derivation on the next calltrashSourceon an item the user cannot delete rejects that item without copying itAlso driven against a running instance (
demos/simple): single-item and bulk flows through the admin UI, the legacy no-body duplicate, the same-collection guard, and a saved mapping round-tripping into a later run. Tested in Arabic — the dialog mirrors correctly (logical Tailwind classes throughout, all strings Lingui-wrapped, nomessages.pochanges per the template note).Two test-mock edits are included:
middleware-prerenderandmiddleware-security-headersbuild a stand-in runtime object and fail until the new bindings exist on it. That is the mock going stale against a new binding, not a behavior change.Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change) — core is green (5184 passed). The admin browser-mode suite fails 3 suites locally with iframe/module-fetch errors; confirmed pre-existing by reproducing them with this branch stashed.pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain.AI-generated code disclosure
Screenshots
Verified visually in both directions; screenshots to be attached (LTR dialog with the mapping table and "Won't be copied" section, and the same dialog in Arabic showing the mirrored layout).
🤖 Generated with Claude Code