From 922e09125273177478a25144a35024a4ff628fe5 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:31:55 +0300 Subject: [PATCH 1/2] feat: duplicate content into another collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../feat-cross-collection-duplication.md | 6 + packages/admin/src/components/ContentList.tsx | 34 +- .../src/components/DuplicateToDialog.tsx | 312 ++++++++ packages/admin/src/lib/api/content.ts | 78 ++ packages/admin/src/lib/api/index.ts | 7 + packages/admin/src/router.tsx | 146 +++- .../src/api/handlers/content-duplicate-to.ts | 711 ++++++++++++++++++ packages/core/src/api/handlers/index.ts | 12 + packages/core/src/api/openapi/document.ts | 64 ++ packages/core/src/api/schemas/content.ts | 93 ++- packages/core/src/astro/integration/routes.ts | 11 + packages/core/src/astro/middleware.ts | 4 +- .../content/[collection]/[id]/duplicate.ts | 37 +- .../content/[collection]/duplicate-mapping.ts | 39 + .../api/content/[collection]/duplicate-to.ts | 47 ++ packages/core/src/astro/types.ts | 17 + .../src/database/repositories/taxonomy.ts | 42 ++ packages/core/src/emdash-runtime.ts | 18 + packages/core/src/index.ts | 7 + .../integration/content/duplicate-to.test.ts | 397 ++++++++++ .../unit/astro/middleware-prerender.test.ts | 2 + .../astro/middleware-security-headers.test.ts | 2 + 22 files changed, 2049 insertions(+), 37 deletions(-) create mode 100644 .changeset/feat-cross-collection-duplication.md create mode 100644 packages/admin/src/components/DuplicateToDialog.tsx create mode 100644 packages/core/src/api/handlers/content-duplicate-to.ts create mode 100644 packages/core/src/astro/routes/api/content/[collection]/duplicate-mapping.ts create mode 100644 packages/core/src/astro/routes/api/content/[collection]/duplicate-to.ts create mode 100644 packages/core/tests/integration/content/duplicate-to.test.ts diff --git a/.changeset/feat-cross-collection-duplication.md b/.changeset/feat-cross-collection-duplication.md new file mode 100644 index 0000000000..376f32d0bf --- /dev/null +++ b/.changeset/feat-cross-collection-duplication.md @@ -0,0 +1,6 @@ +--- +"@emdash-cms/admin": minor +"emdash": minor +--- + +Adds cross-collection duplication: the duplicate action can now copy entries into a different collection through a field mapping, one at a time or up to 50 at once. The mapping only pairs fields with matching column types, every copy is validated before it is written, and the mapping can be remembered per collection pair. The dialog names everything the copy will drop — unmapped fields, taxonomies the target isn't attached to, SEO, and links pointing at the original. diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx index 67e87bc7e4..330793a3f9 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -18,6 +18,7 @@ import { ArrowCounterClockwise, ArrowSquareOut, Copy, + CopySimple, MagnifyingGlass, CaretUp, CaretDown, @@ -74,6 +75,12 @@ export interface ContentListProps { isTrashedLoading?: boolean; onDelete?: (id: string) => void; onDuplicate?: (id: string) => void; + /** + * Open the cross-collection duplicate flow for these entries. Rendered as + * a row action and as a toolbar button. Like the other bulk handlers it + * resolves with the ids that failed, so those rows stay selected. + */ + onDuplicateTo?: BulkActionHandler; onRestore?: (id: string) => void; onPermanentDelete?: (id: string) => void; onLoadMore?: () => void; @@ -168,6 +175,7 @@ export function ContentList({ isTrashedLoading, onDelete, onDuplicate, + onDuplicateTo, onRestore, onPermanentDelete, onLoadMore, @@ -202,7 +210,7 @@ export function ContentList({ // Bulk selection is opt-in: the checkbox column + toolbar only render when // the parent wired at least one bulk handler. - const bulkEnabled = !!(onBulkPublish || onBulkUnpublish || onBulkDelete); + const bulkEnabled = !!(onBulkPublish || onBulkUnpublish || onBulkDelete || onDuplicateTo); // Server-side search mode: the caller refetches based on the (debounced) // query, so `items`/`total` already reflect the filter and we must not @@ -432,6 +440,17 @@ export function ContentList({ {t`Set to draft`} )} + {onDuplicateTo && ( + + )} {onBulkDelete && ( void; onDuplicate?: (id: string) => void; + onDuplicateTo?: (ids: string[]) => Promise; showLocale?: boolean; urlPattern?: string; selectable?: boolean; @@ -965,6 +986,7 @@ function ContentListItem({ collection, onDelete, onDuplicate, + onDuplicateTo, showLocale, urlPattern, selectable, @@ -1041,6 +1063,16 @@ function ContentListItem({ >