Skip to content

feat: duplicate content into another collection - #2368

Draft
MA2153 wants to merge 2 commits into
emdash-cms:mainfrom
MA2153:feat/cross-collection-duplication
Draft

feat: duplicate content into another collection#2368
MA2153 wants to merge 2 commits into
emdash-cms:mainfrom
MA2153:feat/cross-collection-duplication

Conversation

@MA2153

@MA2153 MA2153 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Important

Draft — not ready to merge. Two things gate it:

  1. Waiting on maintainer approval of Discussion #2367. Opening this as a draft so the design is reviewable against real code, not to jump the queue.
  2. Waiting on reference fields before reference support can be done properly — see Reference fields below.

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_COLUMN entries match — TEXT to TEXT, JSON to JSON. Writing JSON into a REAL column 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 pipeline handleContentCreate uses.

Today's same-collection duplicate skips validation, and that is safe: its source row already passed partial: false at create, so the copy is valid by construction. An arbitrary cross-collection mapping breaks that invariant. It can produce a row handleContentCreate would have rejected — an out-of-options select, a required field fed from a NULL source — and draft status is not a backstop: handleContentPublish runs no field validation, and a later edit runs partial: 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 failed with 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 options table:

name:  contentmap:{sourceSlug}:{targetSlug}
value: { "version": 1, "fields": { "body": "content", "excerpt": null } }

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 draft regardless of source status. Fresh slug generated in the target collection — uniqueness is UNIQUE(slug, locale) there, so no cross-collection conflict. locale preserved. New translation_group: a copy in another collection is a distinct thing, not a translation. author_id is 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:

Data Carried when Otherwise
Field columns mapped, compatible column type dropped, named in the dialog
SEO both collections have SEO enabled skipped; canonical always cleared
Bylines always — byline rows are global, the pivot is (collection, entry_id)
Taxonomy terms the taxonomy def's collections includes the target slug dropped, named in the dialog
Reference edges never — see below dropped, named with a count

Reference 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 reference field type is a plain TEXT column holding a target id (FIELD_TYPE_TO_COLUMN.reference = "TEXT", validated for target existence by validateContentData). Separately, _emdash_content_references holds edges that belong to relations — scoped to a collection pair via parent_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:

  • Reference fields map like any other TEXT field. Validation still checks that the target exists.
  • Reference edges are never carried. Re-parenting an outgoing edge would need a rule for which target-collection relation it lands on, and there isn't one to pick from yet. The dialog names the drop with a count rather than doing it silently.
  • Inbound edges never follow, which needs no code: the copy has a new 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, a source: "saved" | "derived" marker, per-target-field compatible sources, which taxonomy defs will and won't carry, and (when ids is supplied) inbound/outbound reference-edge counts from one batched query. ids is capped at the same 50 as the bulk route.

POST /_emdash/api/content/{collection}/{id}/duplicate gains 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-to takes { ids, targetCollection, mapping, saveMapping, trashSource }, ids capped at 50 (D1 binds 100 parameters), returning:

{ results: [{ id, status: "copied" | "copied_not_trashed" | "failed", targetId?, error? }] }

Authorization is content:create plus per-item read access to the source — checked with canActOnOwn(user, authorId, "content:edit_own", "content:edit_any"), the same substitution the existing per-item route makes because content:read is 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_trashed and 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 ids and can grow the check later without an API change.

Bulk reuses the existing ContentList selection infrastructure: BulkActionHandler returns 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):

  • derivation respects column-type compatibility and ignores incompatible same-slug pairs
  • a required target field left unmapped is rejected server-side, not just disabled in the UI
  • a mapping producing an out-of-options select value is rejected at insert and writes no row
  • a required target field mapped to a NULL source fails validation even though the mapping is complete
  • one item failing validation in a bulk run does not prevent the others from copying
  • copies land as draft with a fresh slug and a new translation_group
  • taxonomy pivot rows carry only when the def lists the target collection
  • reference edges are counted and reported; inbound edges stay on the original
  • SEO copies only when both collections have it enabled
  • a saved mapping round-trips through options and is preferred over derivation on the next call
  • bulk returns per-item results across a genuine partial failure
  • trashSource on an item the user cannot delete rejects that item without copying it

Also 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, no messages.po changes per the template note).

Two test-mock edits are included: middleware-prerender and middleware-security-headers build 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

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (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 format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: Cross-collection content duplication #2367linked, not yet approved. This draft is waiting on that approval.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 5 (Claude Code)

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

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-bot

changeset-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e7cd17c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
@emdash-cms/admin Minor
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke Major
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Scope check

This 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.

@pkg-pr-new

pkg-pr-new Bot commented Aug 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@2368

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@2368

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@2368

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@2368

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@2368

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@2368

emdash

npm i https://pkg.pr.new/emdash@2368

create-emdash

npm i https://pkg.pr.new/create-emdash@2368

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@2368

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@2368

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@2368

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@2368

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@2368

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@2368

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@2368

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@2368

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@2368

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@2368

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@2368

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@2368

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@2368

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@2368

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@2368

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@2368

commit: e7cd17c

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant