You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Duplicating a post into articles today means copy-pasting field by field. This proposes extending the existing duplicate action to target another collection, with a saved field mapping per collection pair, no new tables, and no migration.
The core tradeoff
Two checks sit at different stages, and keeping them distinct is what makes this 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.
Field values are validated when the copy is inserted. The assembled target row runs through validateContentData(db, targetCollection, mappedData, { partial: false }) — the same pipeline handleContentCreate uses — before anything is written. Required fields, select options, type shape, and reference-target existence all apply.
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 for that. 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 validation message in the per-item results array, and nothing is written for that item.
Mapping completeness is a separate, earlier check: every required field in the target must have a source assigned before the copy runs, enforced server-side and not only in the UI. That is a statement about the mapping, not about the values flowing through it — a required target 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 stored in the existing options key-value table:
One blob per collection pair, overwritten each run when the user opts to save. No new table, no migration, and it upgrades to named presets later under the same key with a richer value if anyone ever needs them.
When no saved mapping exists, the server derives one: exact field-slug match, kept only where the column types agree. Everything else starts unmapped for the user to fill in.
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; revision history is not carried.
Unlike same-collection duplicate, the title does not get a " (Copy)" suffix. That suffix exists to disambiguate two rows in one list; 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
source reference field mapped to a target reference field
dropped, named in the dialog
Unmapped target fields take their column default. Unmapped source fields are dropped.
Reference fields have no ec_* column — edges live only in _emdash_content_references, keyed on translation_group via parent_group/child_group. A mapped reference field copies outgoing edges by inserting rows with parent_group set to the copy's new group. Inbound edges never follow: because the copy has a new translation_group, everything that pointed at the original keeps pointing at the original, by construction. The dialog says so with a count.
Media needs no special handling — image/file columns hold a media id, media rows are shared, and the usage index is derived from content rows.
Move source to trash
A per-run checkbox in the dialog, default off, never persisted with the mapping. It soft-deletes the original after a successful copy.
Permission is pre-checked per item with requireOwnerPerm(user, item.authorId, "content:delete_own", "content:delete_any"). Items failing that check are rejected before anything is copied, so each item's outcome stays binary.
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 retry would make a second copy.
API
Three additive routes.
GET /_emdash/api/content/{collection}/duplicate-mapping?target={slug}&ids={csv} returns everything the dialog needs in one round trip: source and target field lists, the mapping, a source: "saved" | "derived" marker, and which taxonomy defs will and won't carry. When ids is supplied it also returns an inbound-edge count for those items from a single WHERE child_group IN (...) 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.
POST /_emdash/api/content/{collection}/duplicate-to takes { ids, targetCollection, mapping, saveMapping, trashSource } with ids capped at 50 (D1 binds 100 parameters), returning:
Authorization is content:create for the target plus read access to the source, and the per-item delete check when trashSource is set.
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 are 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, unmapped reference fields, and N items link to this — those links will keep pointing at the original.
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 in the result. 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; if it turns out editors hit it often, the mapping endpoint already receives ids and can grow the check without an API change.
Bulk reuses the existing ContentList selection infrastructure. BulkActionHandler = (ids) => Promise<string[]> already returns failed ids so rows stay selected for retry; copied_not_trashed is excluded from that retry set and surfaced separately.
All strings Lingui-wrapped, logical Tailwind classes only, tested in Arabic before it's called done.
Testing
Integration tests through describeEachDialect, since mapping resolution is query-builder code:
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
outgoing edges re-parent to the new group; 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
Scope
No migration. Mappings live in options, everything else in existing pivots. The per-item route stays backwards compatible with an optional body.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Cross-collection content duplication
Duplicating a post into
articlestoday means copy-pasting field by field. This proposes extending the existing duplicate action to target another collection, with a saved field mapping per collection pair, no new tables, and no migration.The core tradeoff
Two checks sit at different stages, and keeping them distinct is what makes this 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.Field values are validated when the copy is inserted. The assembled target row runs through
validateContentData(db, targetCollection, mappedData, { partial: false })— the same pipelinehandleContentCreateuses — before anything is written. Required fields, select options, type shape, and reference-target existence all apply.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 for that.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 validation message in the per-item results array, and nothing is written for that item.Mapping completeness is a separate, earlier check: every required field in the target must have a source assigned before the copy runs, enforced server-side and not only in the UI. That is a statement about the mapping, not about the values flowing through it — a required target 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 stored in the existing
optionskey-value table:One blob per collection pair, overwritten each run when the user opts to save. No new table, no migration, and it upgrades to named presets later under the same key with a richer value if anyone ever needs them.
When no saved mapping exists, the server derives one: exact field-slug match, kept only where the column types agree. Everything else starts unmapped for the user to fill in.
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; revision history is not carried.Unlike same-collection duplicate, the title does not get a
" (Copy)"suffix. That suffix exists to disambiguate two rows in one list; 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 slugUnmapped target fields take their column default. Unmapped source fields are dropped.
Reference fields have no
ec_*column — edges live only in_emdash_content_references, keyed ontranslation_groupviaparent_group/child_group. A mapped reference field copies outgoing edges by inserting rows withparent_groupset to the copy's new group. Inbound edges never follow: because the copy has a newtranslation_group, everything that pointed at the original keeps pointing at the original, by construction. The dialog says so with a count.Media needs no special handling —
image/filecolumns hold a media id, media rows are shared, and the usage index is derived from content rows.Move source to trash
A per-run checkbox in the dialog, default off, never persisted with the mapping. It soft-deletes the original after a successful copy.
Permission is pre-checked per item with
requireOwnerPerm(user, item.authorId, "content:delete_own", "content:delete_any"). Items failing that check are rejected before anything is copied, so each item's outcome stays binary.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 retry would make a second copy.API
Three additive routes.
GET /_emdash/api/content/{collection}/duplicate-mapping?target={slug}&ids={csv}returns everything the dialog needs in one round trip: source and target field lists, the mapping, asource: "saved" | "derived"marker, and which taxonomy defs will and won't carry. Whenidsis supplied it also returns an inbound-edge count for those items from a singleWHERE child_group IN (...)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.POST /_emdash/api/content/{collection}/duplicate-totakes{ ids, targetCollection, mapping, saveMapping, trashSource }with ids capped at 50 (D1 binds 100 parameters), returning:Authorization is
content:createfor the target plus read access to the source, and the per-item delete check whentrashSourceis set.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 are 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, unmapped reference fields, and
N items link to this — those links will keep pointing at the original.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 in the result. 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; if it turns out editors hit it often, the mapping endpoint already receives
idsand can grow the check without an API change.Bulk reuses the existing
ContentListselection infrastructure.BulkActionHandler = (ids) => Promise<string[]>already returns failed ids so rows stay selected for retry;copied_not_trashedis excluded from that retry set and surfaced separately.All strings Lingui-wrapped, logical Tailwind classes only, tested in Arabic before it's called done.
Testing
Integration tests through
describeEachDialect, since mapping resolution is query-builder code: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 itScope
No migration. Mappings live in
options, everything else in existing pivots. The per-item route stays backwards compatible with an optional body.All reactions