diff --git a/.changeset/feat-cross-collection-duplication.md b/.changeset/feat-cross-collection-duplication.md new file mode 100644 index 0000000000..3ab38f934d --- /dev/null +++ b/.changeset/feat-cross-collection-duplication.md @@ -0,0 +1,8 @@ +--- +"@emdash-cms/admin": minor +"emdash": minor +--- + +Adds cross-collection duplication. Duplicate now always opens a dialog — from a content row, from a selection of up to 50 entries, or from the editor's sidebar — where you pick which collection the copy lands in. Choosing a different collection reveals a field mapping, which only pairs fields with matching column types, is validated before anything is written, and 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. + +Duplicating within a collection is unchanged apart from the confirmation step, and now carries the entry's taxonomy terms to the copy. diff --git a/e2e/tests/content-actions.spec.ts b/e2e/tests/content-actions.spec.ts index ec7ac41aa1..f300db9909 100644 --- a/e2e/tests/content-actions.spec.ts +++ b/e2e/tests/content-actions.spec.ts @@ -16,7 +16,7 @@ import { test, expect } from "../fixtures"; // ---------- regex patterns ---------- const SCHEDULE_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/schedule/; -const DUPLICATE_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/duplicate/; +const DUPLICATE_API_PATTERN = /\/api\/content\/posts\/duplicate$/; const DISCARD_DRAFT_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/discard-draft/; const RESTORE_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/restore/; const PERMANENT_DELETE_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/permanent/; @@ -240,6 +240,14 @@ test.describe("Duplicate content", () => { const row = page.locator("tr", { hasText: "Duplicate Source Post" }); await expect(row).toBeVisible({ timeout: 5000 }); + await row.getByRole("button", { name: "Duplicate Duplicate Source Post" }).click(); + + // The action always confirms first, so nothing is copied on the click alone. + const confirm = page + .getByRole("dialog") + .getByRole("button", { name: "Duplicate", exact: true }); + await expect(confirm).toBeEnabled({ timeout: 10000 }); + const duplicateResponse = page.waitForResponse( (res) => DUPLICATE_API_PATTERN.test(res.url()) && @@ -247,11 +255,10 @@ test.describe("Duplicate content", () => { (res.status() === 200 || res.status() === 201), { timeout: 10000 }, ); - - await row.getByRole("button", { name: "Duplicate Duplicate Source Post" }).click(); + await confirm.click(); const response = await duplicateResponse; const body = await response.json(); - duplicateId = body.data?.item?.id ?? body.data?.id; + duplicateId = body.data?.results?.[0]?.targetId; // Wait for the list to refresh await admin.waitForLoading(); @@ -269,6 +276,30 @@ test.describe("Duplicate content", () => { }); expect(getRes.ok).toBe(true); }); + + test("duplicate a post from the editor", async ({ admin, page }) => { + await admin.goToEditContent("posts", postId); + await admin.waitForLoading(); + + await page.getByRole("button", { name: "Duplicate…" }).click(); + + const confirm = page + .getByRole("dialog") + .getByRole("button", { name: "Duplicate", exact: true }); + await expect(confirm).toBeEnabled({ timeout: 10000 }); + + const duplicateResponse = page.waitForResponse( + (res) => DUPLICATE_API_PATTERN.test(res.url()) && res.request().method() === "POST", + { timeout: 10000 }, + ); + await confirm.click(); + const body = await (await duplicateResponse).json(); + duplicateId = body.data?.results?.[0]?.targetId; + expect(duplicateId).toBeTruthy(); + + // The editor follows the copy rather than leaving the original open. + await expect(page).toHaveURL(new RegExp(`/content/posts/${duplicateId}`), { timeout: 10000 }); + }); }); // ========================================================================== diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 8607665641..d66f9baaf9 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -178,6 +178,11 @@ export interface ContentEditorProps { onDelete?: () => void; /** Whether delete is in progress */ isDeleting?: boolean; + /** + * Open the duplicate flow for this entry. The copy is taken from the saved + * row, so the caller is told whether the editor has unsaved changes. + */ + onDuplicate?: (context: { unsavedChanges: boolean }) => void; /** i18n config — present when multiple locales are configured */ i18n?: { defaultLocale: string; locales: string[] }; /** Existing translations for this content item */ @@ -233,6 +238,7 @@ export function ContentEditor({ onQuickEditByline, onDelete, isDeleting, + onDuplicate, i18n, translations, onTranslate, @@ -405,6 +411,16 @@ export function ContentEditor({ [formData, slug, activeBylines], ); const isDirty = isNew || currentData !== lastSavedData; + + // The settings panel is memoized, so the handler it gets must be stable — + // dirtiness is read through a ref at click time instead of closed over. + const isDirtyRef = React.useRef(isDirty); + isDirtyRef.current = isDirty; + const handleDuplicate = React.useCallback( + () => onDuplicate?.({ unsavedChanges: isDirtyRef.current }), + [onDuplicate], + ); + const saveFeedbackActive = isSaveFeedbackActive ?? isSaving; const autosaveFeedbackActive = isAutosaveFeedbackActive ?? isAutosaving; const isContentOperationPending = Boolean(isSaving); @@ -865,6 +881,7 @@ export function ContentEditor({ onDiscardDraft={onDiscardDraft} onDelete={onDelete} isDeleting={isDeleting} + onDuplicate={onDuplicate ? handleDuplicate : undefined} currentUser={currentUser} users={users} onAuthorChange={onAuthorChange} diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx index 67e87bc7e4..a7fa95c4d9 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -74,6 +74,11 @@ export interface ContentListProps { isTrashedLoading?: boolean; onDelete?: (id: string) => void; onDuplicate?: (id: string) => void; + /** + * Duplicate the selected entries. Like the other bulk handlers it resolves + * with the ids that failed, so those rows stay selected. + */ + onBulkDuplicate?: BulkActionHandler; onRestore?: (id: string) => void; onPermanentDelete?: (id: string) => void; onLoadMore?: () => void; @@ -193,6 +198,7 @@ export function ContentList({ onBulkPublish, onBulkUnpublish, onBulkDelete, + onBulkDuplicate, }: ContentListProps) { const { t } = useLingui(); const [activeTab, setActiveTab] = React.useState("all"); @@ -202,7 +208,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 || onBulkDuplicate); // 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 +438,17 @@ export function ContentList({ {t`Set to draft`} )} + {onBulkDuplicate && ( + + )} {onBulkDelete && ( void; onDelete?: () => void; isDeleting?: boolean; + /** Opens the duplicate dialog for this entry. */ + onDuplicate?: () => void; currentUser?: CurrentUserInfo; users?: UserListItem[]; onAuthorChange?: (authorId: string | null) => void; @@ -364,6 +366,7 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ onDiscardDraft, onDelete, isDeleting, + onDuplicate, currentUser, users, onAuthorChange, @@ -697,50 +700,66 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ )} - {!isNew && onDelete && ( + {!isNew && (onDuplicate || onDelete) && (
- - ( - - )} - /> - - {t`Move to Trash?`} - - {t`This will move the item to trash. You can restore it later from the trash.`} - -
- ( - - )} - /> - ( - - )} - /> -
-
-
+ {onDuplicate && ( + + )} + {onDelete && ( + + ( + + )} + /> + + {t`Move to Trash?`} + + {t`This will move the item to trash. You can restore it later from the trash.`} + +
+ ( + + )} + /> + ( + + )} + /> +
+
+
+ )}
)} diff --git a/packages/admin/src/components/DuplicateDialog.tsx b/packages/admin/src/components/DuplicateDialog.tsx new file mode 100644 index 0000000000..17cf23ab67 --- /dev/null +++ b/packages/admin/src/components/DuplicateDialog.tsx @@ -0,0 +1,328 @@ +/** + * Duplicate dialog — the single confirmation step behind every duplicate + * action, so a copy is never one stray click away. + * + * The target defaults to the source collection, where the copy is a straight + * one and there is nothing to map. Choosing another collection reveals the + * mapping: target fields are the rows, so "every required field has a source" + * is readable at a glance. Everything the copy will drop is named here rather + * than left for the user to discover afterwards. + */ + +import { Button, Checkbox, Dialog, Loader, Select } from "@cloudflare/kumo"; +import { plural } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react/macro"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import * as React from "react"; + +import { + duplicateContentMany, + fetchDuplicateMapping, + type DuplicateFieldMapping, + type DuplicateResult, +} from "../lib/api"; +import { DialogError, getMutationError } from "./DialogError.js"; + +export interface DuplicateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Source collection slug. */ + collection: string; + /** Entries to copy. */ + ids: string[]; + /** Collections that can be targeted, including the source. */ + targets: Array<{ slug: string; label: string }>; + /** Warn that the copy is taken from the last saved version. */ + unsavedChanges?: boolean; + /** + * Called once the run settles, with the results in request order and the + * collection the copies landed in. + */ + onComplete: (results: DuplicateResult[], targetCollection: string) => void; +} + +export function DuplicateDialog({ + open, + onOpenChange, + collection, + ids, + targets, + unsavedChanges, + onComplete, +}: DuplicateDialogProps) { + const { t } = useLingui(); + const [target, setTarget] = React.useState(collection); + const [mapping, setMapping] = React.useState({}); + const [saveMapping, setSaveMapping] = React.useState(false); + const [trashSource, setTrashSource] = React.useState(false); + + // Each run starts from a clean slate: `trashSource` is deliberately never + // persisted, and the mapping is re-resolved for the chosen pair. + React.useEffect(() => { + if (!open) return; + setTarget(collection); + setMapping({}); + setSaveMapping(false); + setTrashSource(false); + }, [open, collection]); + + // A straight copy within one collection: every field carries, so there is + // no mapping to show and nothing to remember for the pair. + const sameCollection = target === collection; + + const { + data: resolved, + isLoading, + error: mappingError, + } = useQuery({ + queryKey: ["duplicate-mapping", collection, target, ids], + queryFn: () => fetchDuplicateMapping(collection, target, ids), + enabled: open && target !== "", + }); + + React.useEffect(() => { + if (resolved) setMapping(resolved.mapping); + }, [resolved]); + + const duplicateMutation = useMutation({ + mutationFn: () => + duplicateContentMany(collection, { + ids, + targetCollection: target, + // Omitting the mapping lets the server derive the identity one, + // which is what makes the copy a straight duplicate. + mapping: sameCollection ? undefined : mapping, + saveMapping: sameCollection ? false : saveMapping, + trashSource: sameCollection ? false : trashSource, + }), + onSuccess: (results) => { + onComplete(results, target); + onOpenChange(false); + }, + }); + + const targetFields = resolved?.targetCollection.fields ?? []; + const sourceFields = resolved?.sourceCollection.fields ?? []; + const sourceLabels = new Map(sourceFields.map((field) => [field.slug, field.label])); + + const missingRequired = sameCollection + ? [] + : targetFields.filter((field) => field.required && !mapping[field.slug]).map((f) => f.slug); + const unmappable = sameCollection ? [] : (resolved?.unmappableRequired ?? []); + const mappedSources = new Set(Object.values(mapping).filter((slug): slug is string => !!slug)); + const droppedSourceFields = sameCollection + ? [] + : sourceFields.filter((field) => !mappedSources.has(field.slug)); + const droppedTaxonomies = resolved?.taxonomies.dropped ?? []; + const inboundEdges = resolved?.referenceEdges?.inbound ?? 0; + const outboundEdges = resolved?.referenceEdges?.outbound ?? 0; + const seoDropped = resolved ? resolved.seo.sourceEnabled && !resolved.seo.targetEnabled : false; + + const canConfirm = + !!resolved && + unmappable.length === 0 && + missingRequired.length === 0 && + !duplicateMutation.isPending; + + return ( + + + {t`Duplicate`} + + {plural(ids.length, { + one: "The copy is created as a draft.", + other: "# copies are created as drafts.", + })} + + +
+
+ + +
+ + {unsavedChanges && ( +

{t`Unsaved changes aren't included — the copy is taken from the last saved version.`}

+ )} + + {isLoading && ( +
+ +
+ )} + + + + + {resolved && unmappable.length > 0 && ( + + )} + + {resolved && !sameCollection && unmappable.length === 0 && ( + <> +
+ + + + + + + + + {targetFields.map((field) => { + const value = mapping[field.slug] ?? ""; + const isMissing = field.required && !value; + return ( + + + + + ); + })} + +
{t`${resolved.targetCollection.label} field`}{t`Copied from`}
+ {field.label} + {field.required && ( + + )} + {isMissing && ( +

{t`Required — pick a source field`}

+ )} +
+ +
+
+ + )} + + {resolved && unmappable.length === 0 && ( +
+

{t`Won't be copied`}

+
    + {droppedSourceFields.length > 0 && ( +
  • {t`Fields: ${droppedSourceFields.map((f) => f.label).join(", ")}`}
  • + )} + {droppedTaxonomies.length > 0 && ( +
  • + {t`Taxonomies not attached to ${resolved.targetCollection.label}: ${droppedTaxonomies.map((tx) => tx.label).join(", ")}`} +
  • + )} + {seoDropped &&
  • {t`SEO metadata — the target collection has SEO disabled`}
  • } + {outboundEdges > 0 && ( +
  • + {plural(outboundEdges, { + one: "# reference this entry makes — the copy starts with no relations", + other: "# references these entries make — the copy starts with no relations", + })} +
  • + )} + {inboundEdges > 0 && ( +
  • + {plural(inboundEdges, { + one: "# item links to this — that link will keep pointing at the original", + other: + "# items link to these — those links will keep pointing at the originals", + })} +
  • + )} + {droppedSourceFields.length === 0 && + droppedTaxonomies.length === 0 && + !seoDropped && + outboundEdges === 0 && + inboundEdges === 0 &&
  • {t`Nothing — everything carries over.`}
  • } +
+
+ )} +
+ + {/* Outside the scroll area: the trash option changes what the run + does, so it must never be hidden below the fold. */} + {resolved && !sameCollection && unmappable.length === 0 && ( +
+ setSaveMapping(checked)} + label={t`Remember this mapping for ${resolved.targetCollection.label}`} + /> + setTrashSource(checked)} + label={plural(ids.length, { + one: "Move the original to trash after copying", + other: "Move the originals to trash after copying", + })} + /> +
+ )} + +
+ ( + + )} + /> + +
+
+
+ ); +} diff --git a/packages/admin/src/lib/api/content.ts b/packages/admin/src/lib/api/content.ts index 49ab6b2a5c..31785ab692 100644 --- a/packages/admin/src/lib/api/content.ts +++ b/packages/admin/src/lib/api/content.ts @@ -337,6 +337,86 @@ export async function duplicateContent(collection: string, id: string): Promise< return data.item; } +/** Target field slug -> source field slug, or null when left unmapped. */ +export type DuplicateFieldMapping = Record; + +export interface DuplicateMappingField { + slug: string; + label: string; + type: string; + columnType: string; + required: boolean; +} + +export interface DuplicateMappingTargetField extends DuplicateMappingField { + compatibleSources: string[]; +} + +export interface DuplicateMapping { + source: "saved" | "derived"; + sourceCollection: { slug: string; label: string; fields: DuplicateMappingField[] }; + targetCollection: { slug: string; label: string; fields: DuplicateMappingTargetField[] }; + mapping: DuplicateFieldMapping; + unmappableRequired: string[]; + seo: { sourceEnabled: boolean; targetEnabled: boolean }; + taxonomies: { + carried: Array<{ name: string; label: string }>; + dropped: Array<{ name: string; label: string }>; + }; + referenceEdges?: { inbound: number; outbound: number }; +} + +export interface DuplicateResult { + id: string; + status: "copied" | "copied_not_trashed" | "failed"; + targetId?: string; + error?: string; +} + +/** + * Resolve the field mapping for a cross-collection duplicate. `ids` opts into + * the reference-edge counts for those entries. + */ +export async function fetchDuplicateMapping( + collection: string, + targetCollection: string, + ids: string[] = [], +): Promise { + const params = new URLSearchParams({ target: targetCollection }); + if (ids.length > 0) params.set("ids", ids.join(",")); + const response = await apiFetch( + `${API_BASE}/content/${collection}/duplicate-mapping?${params.toString()}`, + ); + return parseApiResponse(response, "Failed to load duplicate mapping"); +} + +/** + * Copy entries into a collection, which may be the source collection itself. + * Resolves with one result per id — a failure for one entry doesn't stop the + * others. Omitting `mapping` uses the saved mapping for the pair, falling back + * to a slug match. + */ +export async function duplicateContentMany( + collection: string, + body: { + ids: string[]; + targetCollection: string; + mapping?: DuplicateFieldMapping; + saveMapping?: boolean; + trashSource?: boolean; + }, +): Promise { + const response = await apiFetch(`${API_BASE}/content/${collection}/duplicate`, { + method: "POST", + body: JSON.stringify(body), + }); + const data = await parseApiResponse<{ results: DuplicateResult[] }>( + response, + "Failed to duplicate content", + ); + return data.results; +} + /** * Schedule content for future publishing */ diff --git a/packages/admin/src/lib/api/index.ts b/packages/admin/src/lib/api/index.ts index 435cca08cc..282287ecd0 100644 --- a/packages/admin/src/lib/api/index.ts +++ b/packages/admin/src/lib/api/index.ts @@ -43,6 +43,13 @@ export { restoreContent, permanentDeleteContent, duplicateContent, + fetchDuplicateMapping, + duplicateContentMany, + type DuplicateFieldMapping, + type DuplicateMapping, + type DuplicateMappingField, + type DuplicateMappingTargetField, + type DuplicateResult, scheduleContent, unscheduleContent, getPreviewUrl, diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 18e7a82aba..e41ded93e1 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -34,6 +34,7 @@ import { ContentTypeEditor } from "./components/ContentTypeEditor"; import { ContentTypeList } from "./components/ContentTypeList"; import { Dashboard } from "./components/Dashboard"; import { DeviceAuthorizePage } from "./components/DeviceAuthorizePage"; +import { DuplicateDialog } from "./components/DuplicateDialog"; import { InviteAcceptPage } from "./components/InviteAcceptPage"; import { LoginPage } from "./components/LoginPage"; import { MarketplaceBrowse } from "./components/MarketplaceBrowse"; @@ -98,7 +99,7 @@ import { fetchTrashedContent, restoreContent, permanentDeleteContent, - duplicateContent, + type DuplicateResult, scheduleContent, unscheduleContent, publishContent, @@ -456,19 +457,65 @@ function ContentListPage() { }, }); - const duplicateMutation = useMutation({ - mutationFn: (id: string) => duplicateContent(collection, id), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["content", collection] }); - }, - onError: (mutationError) => { + // Every duplicate runs behind the dialog, so the promise ContentList awaits + // (to decide which rows stay selected) is settled by the dialog rather than + // by a request: cancelling keeps every row selected, completing keeps only + // the failures. + const [duplicateIds, setDuplicateIds] = React.useState([]); + const duplicateResolve = React.useRef<((failedIds: string[]) => void) | null>(null); + + const settleDuplicate = (failedIds: string[]) => { + duplicateResolve.current?.(failedIds); + duplicateResolve.current = null; + setDuplicateIds([]); + }; + + const handleDuplicate = (ids: string[]) => { + setDuplicateIds(ids); + return new Promise((resolve) => { + duplicateResolve.current = resolve; + }); + }; + + const handleDuplicateComplete = (results: DuplicateResult[]) => { + const copied = results.filter((r) => r.status !== "failed"); + const failed = results.filter((r) => r.status === "failed"); + const orphaned = results.filter((r) => r.status === "copied_not_trashed"); + + if (copied.length > 0) { + toastManager.add({ + title: plural(copied.length, { one: "Copied # item", other: "Copied # items" }), + type: "success", + }); + } + if (failed.length > 0) { toastManager.add({ title: t`Failed to duplicate`, - description: mutationError instanceof Error ? mutationError.message : t`An error occurred`, + description: + failed[0]?.error ?? + plural(failed.length, { + one: "# item could not be copied", + other: "# items could not be copied", + }), type: "error", }); - }, - }); + } + if (orphaned.length > 0) { + toastManager.add({ + title: t`Copied, but not trashed`, + description: plural(orphaned.length, { + one: "# original could not be moved to trash — trash it by hand. Retrying would create a second copy.", + other: + "# originals could not be moved to trash — trash them by hand. Retrying would create a second copy.", + }), + type: "error", + }); + } + + // `copied_not_trashed` is not retryable: a retry would copy again. + settleDuplicate(failed.map((r) => r.id)); + void queryClient.invalidateQueries({ queryKey: ["content"] }); + }; // Bulk actions run the existing per-entry endpoints through a // concurrency-limited queue (runBulkAction) — selection persists across @@ -581,40 +628,59 @@ function ContentListPage() { }); }; + const duplicateTargets = Object.entries(manifest.collections).map(([slug, config]) => ({ + slug, + label: config.label, + })); + return ( - deleteMutation.mutate(id)} - onRestore={(id) => restoreMutation.mutate(id)} - onPermanentDelete={(id) => permanentDeleteMutation.mutate(id)} - onDuplicate={(id) => duplicateMutation.mutate(id)} - i18n={i18n} - activeLocale={activeLocale} - onLocaleChange={handleLocaleChange} - urlPattern={collectionConfig.urlPattern} - sort={sort} - onSortChange={setSort} - total={total} - onSearchChange={setSearchTerm} - statusFilter={statusFilter} - onStatusFilterChange={setStatusFilter} - authors={authors} - authorFilter={authorFilter} - onAuthorFilterChange={setAuthorFilter} - dateFilter={dateFilter} - onDateFilterChange={setDateFilter} - onBulkPublish={(ids) => bulkPublishMutation.mutateAsync(ids).then((r) => r.failedIds)} - onBulkUnpublish={(ids) => bulkUnpublishMutation.mutateAsync(ids).then((r) => r.failedIds)} - onBulkDelete={(ids) => bulkDeleteMutation.mutateAsync(ids).then((r) => r.failedIds)} - /> + <> + deleteMutation.mutate(id)} + onRestore={(id) => restoreMutation.mutate(id)} + onPermanentDelete={(id) => permanentDeleteMutation.mutate(id)} + onDuplicate={(id) => void handleDuplicate([id])} + i18n={i18n} + activeLocale={activeLocale} + onLocaleChange={handleLocaleChange} + urlPattern={collectionConfig.urlPattern} + sort={sort} + onSortChange={setSort} + total={total} + onSearchChange={setSearchTerm} + statusFilter={statusFilter} + onStatusFilterChange={setStatusFilter} + authors={authors} + authorFilter={authorFilter} + onAuthorFilterChange={setAuthorFilter} + dateFilter={dateFilter} + onDateFilterChange={setDateFilter} + onBulkPublish={(ids) => bulkPublishMutation.mutateAsync(ids).then((r) => r.failedIds)} + onBulkUnpublish={(ids) => bulkUnpublishMutation.mutateAsync(ids).then((r) => r.failedIds)} + onBulkDelete={(ids) => bulkDeleteMutation.mutateAsync(ids).then((r) => r.failedIds)} + onBulkDuplicate={handleDuplicate} + /> + 0} + onOpenChange={(open) => { + // Cancelling keeps every row selected so the run can be retried. + if (!open) settleDuplicate(duplicateIds); + }} + collection={collection} + ids={duplicateIds} + targets={duplicateTargets} + onComplete={handleDuplicateComplete} + /> + ); } @@ -1238,6 +1304,36 @@ function ContentEditPage() { (locale: string) => translateMutation.mutate(locale), [translateMutation.mutate], ); + + // Non-null while the duplicate dialog is open, carrying the editor's dirty + // state — the copy is made from the saved row, not from the open form. + const [duplicateContext, setDuplicateContext] = React.useState<{ + unsavedChanges: boolean; + } | null>(null); + const handleDuplicate = React.useCallback( + (context: { unsavedChanges: boolean }) => setDuplicateContext(context), + [], + ); + + const handleDuplicateComplete = (results: DuplicateResult[], targetCollection: string) => { + const copy = results[0]; + if (!copy || copy.status === "failed") { + toastManager.add({ + title: t`Failed to duplicate`, + description: copy?.error ?? t`An error occurred`, + type: "error", + }); + return; + } + void queryClient.invalidateQueries({ queryKey: ["content"] }); + toastManager.add({ title: t`Duplicated`, type: "success" }); + if (copy.targetId) { + void navigate({ + to: "/content/$collection/$id", + params: { collection: targetCollection, id: copy.targetId }, + }); + } + }; const handleQuickCreateByline = React.useCallback( (input: { slug: string; displayName: string }) => createBylineMutation.mutateAsync(input), [createBylineMutation.mutateAsync], @@ -1262,49 +1358,68 @@ function ContentEditPage() { return ; } + const duplicateTargets = Object.entries(manifest.collections).map(([slug, config]) => ({ + slug, + label: config.label, + })); + return ( - 0} - onSave={handleSave} - onAutosave={handleAutosave} - isAutosaving={autosaveMutation.isPending} - isAutosaveFeedbackActive={ - autosaveMutation.isPending && autosaveMutation.variables?.targetId === id - } - autosaveCompletionToken={autosaveCompletion.entryId === id ? autosaveCompletion.token : 0} - onPublish={handlePublish} - onUnpublish={handleUnpublish} - onDiscardDraft={handleDiscardDraft} - onSchedule={handleSchedule} - onUnschedule={handleUnschedule} - isScheduling={scheduleMutation.isPending} - onPublishedAtChange={handlePublishedAtChange} - isUpdatingPublishedAt={publishedAtMutation.isPending} - onDelete={handleDelete} - isDeleting={deleteMutation.isPending} - supportsDrafts={collectionConfig.supports.includes("drafts")} - supportsRevisions={collectionConfig.supports.includes("revisions")} - supportsPreview={collectionConfig.supports.includes("preview")} - currentUser={currentUser} - users={usersData?.items} - onAuthorChange={handleAuthorChange} - i18n={i18n} - translations={translationsData?.translations} - onTranslate={handleTranslate} - pluginBlocks={pluginBlocks} - hasSeo={collectionConfig.hasSeo} - onSeoChange={handleSeoChange} - availableBylines={bylinesData?.items} - availableBylinesLoaded={bylinesLoaded} - onQuickCreateByline={handleQuickCreateByline} - onQuickEditByline={handleQuickEditByline} - manifest={manifest ?? null} - /> + <> + 0} + onSave={handleSave} + onAutosave={handleAutosave} + isAutosaving={autosaveMutation.isPending} + isAutosaveFeedbackActive={ + autosaveMutation.isPending && autosaveMutation.variables?.targetId === id + } + autosaveCompletionToken={autosaveCompletion.entryId === id ? autosaveCompletion.token : 0} + onPublish={handlePublish} + onUnpublish={handleUnpublish} + onDiscardDraft={handleDiscardDraft} + onSchedule={handleSchedule} + onUnschedule={handleUnschedule} + isScheduling={scheduleMutation.isPending} + onPublishedAtChange={handlePublishedAtChange} + isUpdatingPublishedAt={publishedAtMutation.isPending} + onDelete={handleDelete} + isDeleting={deleteMutation.isPending} + onDuplicate={handleDuplicate} + supportsDrafts={collectionConfig.supports.includes("drafts")} + supportsRevisions={collectionConfig.supports.includes("revisions")} + supportsPreview={collectionConfig.supports.includes("preview")} + currentUser={currentUser} + users={usersData?.items} + onAuthorChange={handleAuthorChange} + i18n={i18n} + translations={translationsData?.translations} + onTranslate={handleTranslate} + pluginBlocks={pluginBlocks} + hasSeo={collectionConfig.hasSeo} + onSeoChange={handleSeoChange} + availableBylines={bylinesData?.items} + availableBylinesLoaded={bylinesLoaded} + onQuickCreateByline={handleQuickCreateByline} + onQuickEditByline={handleQuickEditByline} + manifest={manifest ?? null} + /> + { + if (!open) setDuplicateContext(null); + }} + collection={collection} + ids={[id]} + targets={duplicateTargets} + unsavedChanges={duplicateContext?.unsavedChanges} + onComplete={handleDuplicateComplete} + /> + ); } diff --git a/packages/core/src/api/handlers/content-duplicate.ts b/packages/core/src/api/handlers/content-duplicate.ts new file mode 100644 index 0000000000..66e4e07232 --- /dev/null +++ b/packages/core/src/api/handlers/content-duplicate.ts @@ -0,0 +1,733 @@ +/** + * Content duplication through a field mapping. + * + * Backs every duplicate the admin performs. The target may be the source + * collection itself, where the mapping defaults to the identity and the copy + * behaves like a plain duplicate. Any copy staying in its own collection gets + * a `(Copy)` title, so it is distinguishable from the original in the list + * they now share. + * + * Two checks sit at different stages and stay distinct: + * + * - **Column type compatibility** is enforced when the mapping is built. A + * mapping may only pair fields whose `FIELD_TYPE_TO_COLUMN` entries match. + * Writing JSON into a REAL column is a storage error, so it is never + * offered — and re-checked here, not only in the UI. + * - **Field values** are validated when the copy is inserted, through the + * same `validateContentData(..., { partial: false })` pipeline creates use. + * A straight copy within one collection skips it because the source row + * already passed `partial: false`; any other mapping breaks that invariant + * and can assemble a row `handleContentCreate` would have rejected. + * + * Mapping completeness (every required target field has a source assigned) is + * a statement about the mapping, checked once for the whole request. A + * required field mapped to a NULL source satisfies it and fails validation + * later, per item. + */ + +import { canActOnOwn, type RoleLevel } from "@emdash-cms/auth"; +import type { Kysely, Selectable } from "kysely"; + +import { BylineRepository } from "../../database/repositories/byline.js"; +import { ContentRepository } from "../../database/repositories/content.js"; +import { OptionsRepository } from "../../database/repositories/options.js"; +import { SeoRepository } from "../../database/repositories/seo.js"; +import { TaxonomyRepository } from "../../database/repositories/taxonomy.js"; +import type { ContentItem } from "../../database/repositories/types.js"; +import { withTransaction } from "../../database/transaction.js"; +import type { Database, TaxonomyDefTable } from "../../database/types.js"; +import { validateIdentifier } from "../../database/validate.js"; +import { SchemaRegistry } from "../../schema/registry.js"; +import type { CollectionWithFields, ColumnType, Field, FieldType } from "../../schema/types.js"; +import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js"; +import { isMissingTableError } from "../../utils/db-errors.js"; +import { DUPLICATE_MAX_IDS } from "../schemas/content.js"; +import type { ApiResult } from "../types.js"; +import { validateMediaFields } from "./validate-media-fields.js"; +import { validateContentData } from "./validation.js"; + +/** Schema version of the saved mapping blob in `options`. */ +const MAPPING_VERSION = 1; + +/** Target field slug -> source field slug, or null when left unmapped. */ +export type DuplicateFieldMapping = Record; + +export interface DuplicateMappingSourceField { + slug: string; + label: string; + type: FieldType; + columnType: ColumnType; + required: boolean; +} + +export interface DuplicateMappingTargetField extends DuplicateMappingSourceField { + /** Source field slugs whose column type matches this target field. */ + compatibleSources: string[]; +} + +export interface DuplicateMappingTaxonomy { + name: string; + label: string; +} + +export interface DuplicateMappingResponse { + /** Whether `mapping` came from a saved blob or was derived by slug match. */ + source: "saved" | "derived"; + sourceCollection: { slug: string; label: string; fields: DuplicateMappingSourceField[] }; + targetCollection: { slug: string; label: string; fields: DuplicateMappingTargetField[] }; + mapping: DuplicateFieldMapping; + /** + * Required target fields with no column-type-compatible source at all. A + * non-empty list means the pair cannot be mapped, whatever the user picks. + */ + unmappableRequired: string[]; + seo: { sourceEnabled: boolean; targetEnabled: boolean }; + taxonomies: { carried: DuplicateMappingTaxonomy[]; dropped: DuplicateMappingTaxonomy[] }; + /** Reference-edge counts for the requested `ids`. Absent when no ids were given. */ + referenceEdges?: { inbound: number; outbound: number }; +} + +export type DuplicateItemStatus = "copied" | "copied_not_trashed" | "failed"; + +export interface DuplicateItemResult { + id: string; + status: DuplicateItemStatus; + targetId?: string; + error?: string; +} + +export interface DuplicateActor { + id: string; + role: RoleLevel; +} + +export interface DuplicateManyInput { + ids: string[]; + /** Defaults to the source collection, which makes the copy a straight one. */ + targetCollection?: string; + mapping?: DuplicateFieldMapping; + saveMapping?: boolean; + trashSource?: boolean; + /** + * Per-item read (and, with `trashSource`, delete) access is checked against + * this actor. Omit only where the caller has already authorized the request. + */ + actor?: DuplicateActor; + /** Author of the copies. Defaults to `actor`, then to the source's author. */ + authorId?: string; +} + +/** + * Option key for a collection pair's saved mapping. Both slugs are validated + * as identifiers first, so the key can never carry separator characters that + * would make two different pairs collide. + */ +function mappingOptionName(sourceCollection: string, targetCollection: string): string { + validateIdentifier(sourceCollection, "collection slug"); + validateIdentifier(targetCollection, "collection slug"); + return `contentmap:${sourceCollection}:${targetCollection}`; +} + +function toSourceField(field: Field): DuplicateMappingSourceField { + return { + slug: field.slug, + label: field.label, + type: field.type, + columnType: field.columnType, + required: field.required, + }; +} + +/** + * Restrict a mapping to pairs that actually exist and agree on column type. + * Applied to saved blobs and to client-supplied mappings alike — the dialog + * only offers compatible pairs, and this makes that a server guarantee. + */ +function sanitizeMapping( + raw: DuplicateFieldMapping, + sourceFields: Map, + targetFields: Map, +): DuplicateFieldMapping { + const clean: DuplicateFieldMapping = {}; + for (const [targetSlug, sourceSlug] of Object.entries(raw)) { + const target = targetFields.get(targetSlug); + if (!target) continue; + if (sourceSlug === null || sourceSlug === undefined) { + clean[targetSlug] = null; + continue; + } + const source = sourceFields.get(sourceSlug); + clean[targetSlug] = source && source.columnType === target.columnType ? sourceSlug : null; + } + return clean; +} + +/** + * Whether every target field is copied from the field of the same slug. Only + * such a mapping reproduces a row that already passed validation at create. + */ +function isIdentityMapping( + mapping: DuplicateFieldMapping, + targetFields: Map, +): boolean { + for (const slug of targetFields.keys()) { + if (mapping[slug] !== slug) return false; + } + return true; +} + +/** Exact field-slug match, kept only where the column types agree. */ +function deriveMapping( + sourceFields: Map, + targetFields: Map, +): DuplicateFieldMapping { + const mapping: DuplicateFieldMapping = {}; + for (const [slug, target] of targetFields) { + const source = sourceFields.get(slug); + mapping[slug] = source && source.columnType === target.columnType ? slug : null; + } + return mapping; +} + +/** Parse a stored mapping blob, ignoring anything that isn't the current shape. */ +function parseStoredMapping(value: unknown): DuplicateFieldMapping | null { + if (typeof value !== "object" || value === null) return null; + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- narrowed to a non-null object above; every read below is re-checked + const record = value as Record; + if (record.version !== MAPPING_VERSION) return null; + const fields = record.fields; + if (typeof fields !== "object" || fields === null) return null; + + const parsed: DuplicateFieldMapping = {}; + for (const [key, entry] of Object.entries(fields)) { + if (entry === null) parsed[key] = null; + else if (typeof entry === "string") parsed[key] = entry; + } + return parsed; +} + +async function loadCollections( + db: Kysely, + sourceCollection: string, + targetCollection: string, +): Promise< + | { ok: true; source: CollectionWithFields; target: CollectionWithFields } + | { ok: false; error: { code: string; message: string } } +> { + const registry = new SchemaRegistry(db); + const [source, target] = await Promise.all([ + registry.getCollectionWithFields(sourceCollection), + registry.getCollectionWithFields(targetCollection), + ]); + if (!source) { + return { + ok: false, + error: { + code: "COLLECTION_NOT_FOUND", + message: `Collection '${sourceCollection}' not found`, + }, + }; + } + if (!target) { + return { + ok: false, + error: { + code: "COLLECTION_NOT_FOUND", + message: `Collection '${targetCollection}' not found`, + }, + }; + } + return { ok: true, source, target }; +} + +function fieldMap(collection: CollectionWithFields): Map { + return new Map(collection.fields.map((field) => [field.slug, field])); +} + +/** + * Taxonomy definitions attached to `sourceCollection`, split by whether they + * also list `targetCollection`. Defs are row-per-locale; the lowest-locale row + * wins, mirroring how the taxonomy handlers resolve a def by name. + */ +async function splitTaxonomies( + db: Kysely, + sourceCollection: string, + targetCollection: string, +): Promise<{ carried: DuplicateMappingTaxonomy[]; dropped: DuplicateMappingTaxonomy[] }> { + const rows = await db + .selectFrom("_emdash_taxonomy_defs") + .select(["name", "label", "collections", "locale"]) + .orderBy("locale", "asc") + .execute(); + + const seen = new Set(); + const carried: DuplicateMappingTaxonomy[] = []; + const dropped: DuplicateMappingTaxonomy[] = []; + + for (const row of rows) { + if (seen.has(row.name)) continue; + seen.add(row.name); + const collections = parseDefCollections(row); + if (!collections.has(sourceCollection)) continue; + const entry = { name: row.name, label: row.label }; + if (collections.has(targetCollection)) carried.push(entry); + else dropped.push(entry); + } + + return { carried, dropped }; +} + +function parseDefCollections(def: Pick, "collections">): Set { + if (!def.collections) return new Set(); + try { + const parsed: unknown = JSON.parse(def.collections); + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((slug): slug is string => typeof slug === "string")); + } catch { + return new Set(); + } +} + +/** + * Count reference edges touching the given entries. Outbound edges are dropped + * by the copy (relations are collection-scoped); inbound edges keep pointing at + * the original by construction, since the copy gets a new `translation_group`. + */ +async function countReferenceEdges( + db: Kysely, + groups: string[], +): Promise<{ inbound: number; outbound: number }> { + if (groups.length === 0) return { inbound: 0, outbound: 0 }; + + let inbound = 0; + let outbound = 0; + for (const batch of chunks(groups, SQL_BATCH_SIZE)) { + const [inRows, outRows] = await Promise.all([ + db + .selectFrom("_emdash_content_references") + .select("id") + .where("child_group", "in", batch) + .execute(), + db + .selectFrom("_emdash_content_references") + .select("id") + .where("parent_group", "in", batch) + .execute(), + ]); + inbound += inRows.length; + outbound += outRows.length; + } + return { inbound, outbound }; +} + +/** + * Everything the duplicate dialog needs in one round trip: both field + * lists, the mapping (saved or derived), which taxonomies carry, and — when + * `ids` is supplied — the reference-edge counts for those entries. + */ +export async function handleDuplicateMappingGet( + db: Kysely, + sourceCollection: string, + targetCollection: string, + ids: string[] = [], +): Promise> { + try { + if (ids.length > DUPLICATE_MAX_IDS) { + return { + success: false, + error: { + code: "VALIDATION_ERROR", + message: `At most ${DUPLICATE_MAX_IDS} items may be mapped at once`, + }, + }; + } + + const collections = await loadCollections(db, sourceCollection, targetCollection); + if (!collections.ok) return { success: false, error: collections.error }; + + const sourceFields = fieldMap(collections.source); + const targetFields = fieldMap(collections.target); + + const options = new OptionsRepository(db); + const stored = parseStoredMapping( + await options.get(mappingOptionName(sourceCollection, targetCollection)), + ); + const mapping = stored + ? sanitizeMapping(stored, sourceFields, targetFields) + : deriveMapping(sourceFields, targetFields); + + const targetFieldSummaries: DuplicateMappingTargetField[] = collections.target.fields.map( + (field) => ({ + ...toSourceField(field), + compatibleSources: collections.source.fields + .filter((candidate) => candidate.columnType === field.columnType) + .map((candidate) => candidate.slug), + }), + ); + + const unmappableRequired = targetFieldSummaries + .filter((field) => field.required && field.compatibleSources.length === 0) + .map((field) => field.slug); + + const taxonomies = await splitTaxonomies(db, sourceCollection, targetCollection); + + const response: DuplicateMappingResponse = { + source: stored ? "saved" : "derived", + sourceCollection: { + slug: collections.source.slug, + label: collections.source.label, + fields: collections.source.fields.map(toSourceField), + }, + targetCollection: { + slug: collections.target.slug, + label: collections.target.label, + fields: targetFieldSummaries, + }, + mapping, + unmappableRequired, + seo: { + sourceEnabled: collections.source.hasSeo, + targetEnabled: collections.target.hasSeo, + }, + taxonomies, + }; + + if (ids.length > 0) { + const repo = new ContentRepository(db); + const items = await repo.findManyByIdOrSlug(sourceCollection, ids); + const groups = Array.from(items.values(), (item) => item.translationGroup).filter( + (group): group is string => typeof group === "string", + ); + response.referenceEdges = await countReferenceEdges(db, groups); + } + + return { success: true, data: response }; + } catch (error) { + if (isMissingTableError(error)) { + return { + success: false, + error: { code: "COLLECTION_NOT_FOUND", message: "Collection not found" }, + }; + } + console.error("Duplicate mapping error:", error); + return { + success: false, + error: { + code: "DUPLICATE_MAPPING_ERROR", + message: "Failed to resolve duplicate mapping", + }, + }; + } +} + +/** Apply `mapping` to one source item's data, dropping unmapped target fields. */ +function applyMapping(item: ContentItem, mapping: DuplicateFieldMapping): Record { + const data: Record = {}; + for (const [targetSlug, sourceSlug] of Object.entries(mapping)) { + if (sourceSlug === null) continue; + const value = item.data[sourceSlug]; + if (value === undefined) continue; + data[targetSlug] = value; + } + return data; +} + +function slugSourceFor(data: Record, item: ContentItem): string | null { + if (typeof data.title === "string" && data.title.length > 0) return data.title; + if (typeof data.name === "string" && data.name.length > 0) return data.name; + return item.slug; +} + +function errorMessage(error: unknown, fallback: string): string { + if (!(error instanceof Error)) return fallback; + const message = error.message.toLowerCase(); + if (message.includes("unique constraint failed") || message.includes("duplicate key")) { + return "Unique constraint violation in the target collection"; + } + return fallback; +} + +/** + * Copy entries into a collection, which defaults to the one they came from. + * + * Each item's copy (row, bylines, taxonomy terms, SEO) runs in one + * transaction. `trashSource` runs after that transaction commits: D1 has no + * transactions, so a copy that succeeds and a trash that fails reports + * `copied_not_trashed` — retrying the item would make a second copy. + */ +export async function handleContentDuplicateMany( + db: Kysely, + sourceCollection: string, + input: DuplicateManyInput, +): Promise> { + try { + const { trashSource = false, actor } = input; + const targetCollection = input.targetCollection ?? sourceCollection; + + const ids = [...new Set(input.ids)]; + if (ids.length === 0) { + return { + success: false, + error: { code: "VALIDATION_ERROR", message: "At least one item id is required" }, + }; + } + if (ids.length > DUPLICATE_MAX_IDS) { + return { + success: false, + error: { + code: "VALIDATION_ERROR", + message: `At most ${DUPLICATE_MAX_IDS} items may be duplicated at once`, + }, + }; + } + + const collections = await loadCollections(db, sourceCollection, targetCollection); + if (!collections.ok) return { success: false, error: collections.error }; + + const sourceFields = fieldMap(collections.source); + const targetFields = fieldMap(collections.target); + const options = new OptionsRepository(db); + + let mapping: DuplicateFieldMapping; + if (input.mapping) { + mapping = sanitizeMapping(input.mapping, sourceFields, targetFields); + } else { + const stored = parseStoredMapping( + await options.get(mappingOptionName(sourceCollection, targetCollection)), + ); + mapping = stored + ? sanitizeMapping(stored, sourceFields, targetFields) + : deriveMapping(sourceFields, targetFields); + } + + // Mapping completeness — about the mapping, not the values flowing + // through it. Checked once for the whole request so an incomplete + // mapping fails loudly instead of N times per item. + const missingRequired = collections.target.fields + .filter((field) => field.required && !mapping[field.slug]) + .map((field) => field.slug); + if (missingRequired.length > 0) { + return { + success: false, + error: { + code: "VALIDATION_ERROR", + message: `Required field(s) in '${targetCollection}' have no source assigned: ${missingRequired.join(", ")}`, + }, + }; + } + + const repo = new ContentRepository(db); + const resolved = await repo.findManyByIdOrSlug(sourceCollection, ids); + + const results: DuplicateItemResult[] = []; + const pending: Array<{ id: string; item: ContentItem }> = []; + + for (const id of ids) { + const item = resolved.get(id); + if (!item) { + results.push({ id, status: "failed", error: `Content item not found: ${id}` }); + continue; + } + // Copying requires read access to the source. `content:read` is flat + // (no own/any split), so ownership is expressed through the edit + // permissions — the same substitution the per-item route makes. + if ( + actor && + !canActOnOwn(actor, item.authorId ?? "", "content:edit_own", "content:edit_any") + ) { + results.push({ + id, + status: "failed", + error: "Insufficient permissions to read the source item", + }); + continue; + } + // Reject un-trashable items before anything is copied, so an item + // can't end up copied with its source still in place. + if ( + trashSource && + !canActOnOwn(actor, item.authorId ?? "", "content:delete_own", "content:delete_any") + ) { + results.push({ + id, + status: "failed", + error: "Insufficient permissions to trash the source item", + }); + continue; + } + pending.push({ id, item }); + } + + if (input.saveMapping) { + await options.set(mappingOptionName(sourceCollection, targetCollection), { + version: MAPPING_VERSION, + fields: mapping, + }); + } + + const carriedTaxonomies = new Set( + (await splitTaxonomies(db, sourceCollection, targetCollection)).carried.map((t) => t.name), + ); + const copySeo = collections.source.hasSeo && collections.target.hasSeo; + const straightCopy = + sourceCollection === targetCollection && isIdentityMapping(mapping, targetFields); + + for (const { id, item } of pending) { + const result = await copyItem(db, { + sourceCollection, + targetCollection, + item, + mapping, + carriedTaxonomies, + copySeo, + straightCopy, + authorId: input.authorId ?? actor?.id, + }); + if (!result.ok) { + results.push({ id, status: "failed", error: result.error }); + continue; + } + + if (!trashSource) { + results.push({ id, status: "copied", targetId: result.targetId }); + continue; + } + + try { + const trashed = await repo.delete(sourceCollection, item.id); + results.push({ + id, + status: trashed ? "copied" : "copied_not_trashed", + targetId: result.targetId, + }); + } catch (error) { + console.error("Duplicate-to trash error:", error); + results.push({ id, status: "copied_not_trashed", targetId: result.targetId }); + } + } + + // Preserve the caller's id order regardless of which items were skipped. + const byId = new Map(results.map((entry) => [entry.id, entry])); + return { + success: true, + data: { results: ids.map((id) => byId.get(id)).filter((entry) => entry !== undefined) }, + }; + } catch (error) { + console.error("Content duplicate error:", error); + return { + success: false, + error: { + code: "CONTENT_DUPLICATE_ERROR", + message: "Failed to duplicate content", + }, + }; + } +} + +/** + * Copy one entry. The copy is always a draft with a fresh slug, a new + * `translation_group` (a copy is a distinct thing, not a translation) and the + * acting user as author; revision pointers, schedule and publication + * timestamps start clean. + */ +async function copyItem( + db: Kysely, + args: { + sourceCollection: string; + targetCollection: string; + item: ContentItem; + mapping: DuplicateFieldMapping; + carriedTaxonomies: Set; + copySeo: boolean; + /** Identity mapping within one collection: reproduces an already-valid row. */ + straightCopy: boolean; + authorId?: string; + }, +): Promise<{ ok: true; targetId: string } | { ok: false; error: string }> { + const { sourceCollection, targetCollection, item, mapping, carriedTaxonomies, copySeo } = args; + const data = applyMapping(item, mapping); + + // A copy landing in the same list as its original needs a distinguishable + // title; across collections the original name carries as-is. + if (sourceCollection === targetCollection) { + if (typeof data.title === "string") data.title = `${data.title} (Copy)`; + else if (typeof data.name === "string") data.name = `${data.name} (Copy)`; + } + + if (!args.straightCopy) { + const validation = await validateContentData(db, targetCollection, data, { partial: false }); + if (!validation.ok) return { ok: false, error: validation.error.message }; + } + + const mimeCheck = await validateMediaFields(db, targetCollection, data); + if (!mimeCheck.success) { + return { ok: false, error: mimeCheck.error?.message ?? "Invalid media field value" }; + } + + try { + const targetId = await withTransaction(db, async (trx) => { + const repo = new ContentRepository(trx); + const slugSource = slugSourceFor(data, item); + const slug = slugSource + ? await repo.generateUniqueSlug(targetCollection, slugSource, item.locale ?? undefined) + : null; + + const created = await repo.create({ + type: targetCollection, + slug, + data, + status: "draft", + authorId: args.authorId || item.authorId || undefined, + locale: item.locale ?? undefined, + }); + + // Byline rows are global and the junction pivots on + // (collection, entry_id), so credits carry to any collection. + const bylineRepo = new BylineRepository(trx); + const credits = await bylineRepo.getContentBylines(sourceCollection, item.id); + if (credits.length > 0) { + await bylineRepo.setContentBylines( + targetCollection, + created.id, + credits.map((credit) => ({ + bylineId: credit.byline.id, + roleLabel: credit.roleLabel, + })), + ); + } + + if (carriedTaxonomies.size > 0) { + const taxRepo = new TaxonomyRepository(trx); + await taxRepo.copyEntryTermsAcross( + sourceCollection, + item.id, + targetCollection, + created.id, + carriedTaxonomies, + ); + } + + if (copySeo) { + const seoRepo = new SeoRepository(trx); + const seo = await seoRepo.get(sourceCollection, item.id); + if (seo.title !== null || seo.description !== null || seo.image !== null || seo.noIndex) { + await seoRepo.upsert(targetCollection, created.id, { + title: seo.title, + description: seo.description, + image: seo.image, + // The original's canonical pointed at the original. + canonical: null, + noIndex: seo.noIndex, + }); + } + } + + return created.id; + }); + + return { ok: true, targetId }; + } catch (error) { + console.error("Duplicate-to copy error:", error); + return { ok: false, error: errorMessage(error, "Failed to copy item") }; + } +} diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index 8a16b88fa4..5513f27b8c 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -38,6 +38,7 @@ import { invalidateTermCache } from "../../taxonomies/index.js"; import { isMissingColumnError, isMissingTableError } from "../../utils/db-errors.js"; import { encodeRev, validateRev } from "../rev.js"; import type { ApiResult, ContentListResponse, ContentResponse } from "../types.js"; +import { handleContentDuplicateMany } from "./content-duplicate.js"; import { validateMediaFields } from "./validate-media-fields.js"; /** @@ -1068,10 +1069,10 @@ export async function handleContentUpdate( } /** - * Duplicate content item. - * - * Only copies SEO data if the collection has SEO enabled. - * Always returns consistent `seo` shape for SEO-enabled collections. + * Duplicate one content item within its collection, returning the hydrated + * copy. A straight copy through `handleContentDuplicateMany`, whose per-item + * permission checks are skipped: every caller of this signature has already + * authorized the request against the source. */ export async function handleContentDuplicate( db: Kysely, @@ -1079,64 +1080,23 @@ export async function handleContentDuplicate( id: string, authorId?: string, ): Promise> { - try { - const hasSeo = await collectionHasSeo(db, collection); - - // Wrap duplicate + SEO copy in a transaction for atomicity - const duplicate = await withTransaction(db, async (trx) => { - const repo = new ContentRepository(trx); - const bylineRepo = new BylineRepository(trx); - const resolvedId = (await resolveId(repo, collection, id)) ?? id; - const dup = await repo.duplicate(collection, resolvedId, authorId); - - const existingBylines = await bylineRepo.getContentBylines(collection, resolvedId); - if (existingBylines.length > 0) { - await bylineRepo.setContentBylines( - collection, - dup.id, - existingBylines.map((entry) => ({ - bylineId: entry.byline.id, - roleLabel: entry.roleLabel, - })), - ); - } - - if (hasSeo) { - // Copy SEO data from the original (clears canonical) - const seoRepo = new SeoRepository(trx); - await seoRepo.copyForDuplicate(collection, resolvedId, dup.id); - // Always hydrate SEO for consistent response shape - dup.seo = await seoRepo.get(collection, dup.id); - } + const result = await handleContentDuplicateMany(db, collection, { ids: [id], authorId }); + if (!result.success) return { success: false, error: result.error }; - await hydrateBylines(trx, collection, dup); - - return dup; - }); - - return { - success: true, - data: { item: duplicate }, - }; - } catch (err) { - if (err instanceof EmDashValidationError) { - return { - success: false, - error: { - code: "NOT_FOUND", - message: err.message, - }, - }; - } - console.error("Content duplicate error:", err); + const copy = result.data.results[0]; + if (!copy || copy.status === "failed" || !copy.targetId) { return { success: false, error: { - code: "CONTENT_DUPLICATE_ERROR", - message: "Failed to duplicate content", + code: copy?.error ? "CONTENT_DUPLICATE_ERROR" : "NOT_FOUND", + message: copy?.error ?? `Content item not found: ${id}`, }, }; } + + const created = await handleContentGet(db, collection, copy.targetId); + if (!created.success) return { success: false, error: created.error }; + return { success: true, data: { item: created.data.item } }; } /** diff --git a/packages/core/src/api/handlers/index.ts b/packages/core/src/api/handlers/index.ts index 2d0fadf48c..c60e94103b 100644 --- a/packages/core/src/api/handlers/index.ts +++ b/packages/core/src/api/handlers/index.ts @@ -30,6 +30,18 @@ export { type TrashedContentItem, } from "./content.js"; +// Cross-collection duplication +export { + handleDuplicateMappingGet, + handleContentDuplicateMany, + type DuplicateFieldMapping, + type DuplicateMappingResponse, + type DuplicateActor, + type DuplicateManyInput, + type DuplicateItemResult, + type DuplicateItemStatus, +} from "./content-duplicate.js"; + // Dashboard stats export { handleDashboardStats, diff --git a/packages/core/src/api/openapi/document.ts b/packages/core/src/api/openapi/document.ts index ddf7e90696..825289d4b9 100644 --- a/packages/core/src/api/openapi/document.ts +++ b/packages/core/src/api/openapi/document.ts @@ -26,6 +26,9 @@ import { contentCompareResponseSchema, contentAuthorsResponseSchema, contentCreateBody, + contentDuplicateBody, + contentDuplicateManyBody, + contentDuplicateManyResponseSchema, contentItemSchema, contentListQuery, contentListResponseSchema, @@ -35,6 +38,8 @@ import { contentTrashQuery, contentTranslationsResponseSchema, contentUpdateBody, + duplicateMappingQuery, + duplicateMappingResponseSchema, trashedContentListResponseSchema, } from "../schemas/content.js"; import { @@ -427,6 +432,8 @@ const contentPaths = { post: { operationId: "duplicateContent", summary: "Duplicate a content item", + description: + "Without a body, copies the item within its own collection. With a body, copies it into `targetCollection` through a field mapping.", tags: ["Content"], requestParams: { path: z.object({ @@ -434,6 +441,9 @@ const contentPaths = { id: z.string().meta({ description: "Content ID or slug" }), }), }, + requestBody: { + content: { [JSON_CONTENT]: { schema: contentDuplicateBody } }, + }, responses: { "201": { description: "Duplicated content item", @@ -449,6 +459,60 @@ const contentPaths = { }, }, + "/_emdash/api/content/{collection}/duplicate-mapping": { + get: { + operationId: "getDuplicateMapping", + summary: "Resolve the field mapping for a cross-collection duplicate", + description: + "Returns both field lists, the saved or derived mapping, which taxonomies carry to the target, and — when `ids` is supplied — reference-edge counts for those entries.", + tags: ["Content"], + requestParams: { + path: z.object({ + collection: z.string().meta({ description: "Source collection slug" }), + }), + query: duplicateMappingQuery, + }, + responses: { + "200": { + description: "Resolved mapping", + content: { + [JSON_CONTENT]: { schema: successEnvelope(duplicateMappingResponseSchema) }, + }, + }, + ...authErrors, + ...standardErrors(404, 500), + }, + }, + }, + + "/_emdash/api/content/{collection}/duplicate": { + post: { + operationId: "duplicateContentMany", + summary: "Duplicate entries", + description: + "Copies up to 50 entries, into `targetCollection` through a field mapping when it differs from the source. Results are per item: one entry failing validation does not stop the others.", + tags: ["Content"], + requestParams: { + path: z.object({ + collection: z.string().meta({ description: "Source collection slug" }), + }), + }, + requestBody: { + content: { [JSON_CONTENT]: { schema: contentDuplicateManyBody } }, + }, + responses: { + "200": { + description: "Per-item copy results", + content: { + [JSON_CONTENT]: { schema: successEnvelope(contentDuplicateManyResponseSchema) }, + }, + }, + ...authErrors, + ...standardErrors(404, 500), + }, + }, + }, + "/_emdash/api/content/{collection}/{id}/restore": { post: { operationId: "restoreContent", diff --git a/packages/core/src/api/schemas/content.ts b/packages/core/src/api/schemas/content.ts index fafab339b2..913c8f027c 100644 --- a/packages/core/src/api/schemas/content.ts +++ b/packages/core/src/api/schemas/content.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { bylineSummarySchema, bylineCreditSchema, contentBylineInputSchema } from "./bylines.js"; -import { cursorPaginationQuery, httpUrl, localeCode } from "./common.js"; +import { cursorPaginationQuery, httpUrl, localeCode, slugPattern } from "./common.js"; // --------------------------------------------------------------------------- // Content: Input schemas @@ -130,6 +130,97 @@ export const contentTermsBody = z export const contentTrashQuery = cursorPaginationQuery; +/** Maximum entries one bulk duplicate request may carry (D1 binds 100 parameters). */ +export const DUPLICATE_MAX_IDS = 50; + +const collectionSlug = z.string().min(1).max(63).regex(slugPattern, "Invalid collection slug"); + +/** Target field slug -> source field slug, or null to leave the target field unmapped. */ +export const duplicateFieldMapping = z + .record(z.string(), z.string().nullable()) + .meta({ id: "DuplicateFieldMapping" }); + +export const duplicateMappingQuery = z + .object({ + target: collectionSlug, + ids: z + .string() + .optional() + .meta({ + description: `Comma-separated entry ids (max ${DUPLICATE_MAX_IDS}) to report reference-edge counts for.`, + }), + }) + .meta({ id: "DuplicateMappingQuery" }); + +/** Shared body fields for the per-item and bulk duplicate routes. */ +const duplicateFields = { + targetCollection: collectionSlug.optional().meta({ + description: "Defaults to the source collection, which makes the copy a straight one.", + }), + mapping: duplicateFieldMapping.optional().meta({ + description: "Omit to use the saved mapping for this collection pair, or a slug-match default.", + }), + saveMapping: z.boolean().optional(), + trashSource: z.boolean().optional().meta({ + description: "Soft-delete each source entry after its copy succeeds.", + }), +}; + +/** Optional body on the per-item duplicate route. Absent = straight copy. */ +export const contentDuplicateBody = z.object(duplicateFields).meta({ id: "ContentDuplicateBody" }); + +export const contentDuplicateManyBody = z + .object({ + ids: z.array(z.string().min(1)).min(1).max(DUPLICATE_MAX_IDS), + ...duplicateFields, + }) + .meta({ id: "ContentDuplicateManyBody" }); + +const duplicateMappingField = z.object({ + slug: z.string(), + label: z.string(), + type: z.string(), + columnType: z.string(), + required: z.boolean(), +}); + +export const duplicateMappingResponseSchema = z + .object({ + source: z.enum(["saved", "derived"]), + sourceCollection: z.object({ + slug: z.string(), + label: z.string(), + fields: z.array(duplicateMappingField), + }), + targetCollection: z.object({ + slug: z.string(), + label: z.string(), + fields: z.array(duplicateMappingField.extend({ compatibleSources: z.array(z.string()) })), + }), + mapping: duplicateFieldMapping, + unmappableRequired: z.array(z.string()), + seo: z.object({ sourceEnabled: z.boolean(), targetEnabled: z.boolean() }), + taxonomies: z.object({ + carried: z.array(z.object({ name: z.string(), label: z.string() })), + dropped: z.array(z.object({ name: z.string(), label: z.string() })), + }), + referenceEdges: z.object({ inbound: z.number().int(), outbound: z.number().int() }).optional(), + }) + .meta({ id: "DuplicateMappingResponse" }); + +export const contentDuplicateManyResponseSchema = z + .object({ + results: z.array( + z.object({ + id: z.string(), + status: z.enum(["copied", "copied_not_trashed", "failed"]), + targetId: z.string().optional(), + error: z.string().optional(), + }), + ), + }) + .meta({ id: "ContentDuplicateManyResponse" }); + // --------------------------------------------------------------------------- // Content: Response schemas // --------------------------------------------------------------------------- diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index 93f0efdfa3..183c431c62 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -148,6 +148,17 @@ export function injectCoreRoutes( entrypoint: resolveRoute("api/content/[collection]/[id]/duplicate.ts"), }); + // Bulk duplication + injectRoute({ + pattern: "/_emdash/api/content/[collection]/duplicate-mapping", + entrypoint: resolveRoute("api/content/[collection]/duplicate-mapping.ts"), + }); + + injectRoute({ + pattern: "/_emdash/api/content/[collection]/duplicate", + entrypoint: resolveRoute("api/content/[collection]/duplicate.ts"), + }); + // Publishing routes injectRoute({ pattern: "/_emdash/api/content/[collection]/[id]/publish", diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index 07337359c4..b069d4ab0c 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -755,8 +755,10 @@ export const onRequest = defineMiddleware(async (context, next) => { handleContentCountTrashed: runtime.handleContentCountTrashed.bind(runtime), handleContentGetIncludingTrashed: runtime.handleContentGetIncludingTrashed.bind(runtime), - // Duplicate handler + // Duplicate handlers handleContentDuplicate: runtime.handleContentDuplicate.bind(runtime), + handleDuplicateMappingGet: runtime.handleDuplicateMappingGet.bind(runtime), + handleContentDuplicateMany: runtime.handleContentDuplicateMany.bind(runtime), // Publishing & Scheduling handlers handleContentPublish: runtime.handleContentPublish.bind(runtime), diff --git a/packages/core/src/astro/routes/api/content/[collection]/[id]/duplicate.ts b/packages/core/src/astro/routes/api/content/[collection]/[id]/duplicate.ts index 41b71e3f9f..c312307a3a 100644 --- a/packages/core/src/astro/routes/api/content/[collection]/[id]/duplicate.ts +++ b/packages/core/src/astro/routes/api/content/[collection]/[id]/duplicate.ts @@ -2,16 +2,22 @@ * Duplicate content endpoint - injected by EmDash integration * * POST /_emdash/api/content/{collection}/{id}/duplicate - Create a copy + * + * Without a body this is a same-collection duplicate. With + * `{ targetCollection, ... }` the copy lands in another collection through a + * field mapping. */ import type { APIRoute } from "astro"; import { requirePerm, requireOwnerPerm } from "#api/authorize.js"; import { apiError, mapErrorStatus, unwrapResult } from "#api/error.js"; +import { parseOptionalBody } from "#api/parse.js"; +import { contentDuplicateBody } from "#api/schemas/content.js"; export const prerender = false; -export const POST: APIRoute = async ({ params, locals, cache }) => { +export const POST: APIRoute = async ({ params, request, locals, cache }) => { const { emdash, user } = locals; const collection = params.collection!; const id = params.id!; @@ -22,6 +28,9 @@ export const POST: APIRoute = async ({ params, locals, cache }) => { const denied = requirePerm(user, "content:create"); if (denied) return denied; + const body = await parseOptionalBody(request, contentDuplicateBody.optional(), undefined); + if (body instanceof Response) return body; + // Fetch item to check ownership — duplicating requires read access to the source const existing = await emdash.handleContentGet(collection, id); if (!existing.success) { @@ -50,6 +59,32 @@ export const POST: APIRoute = async ({ params, locals, cache }) => { if (readDenied) return readDenied; const resolvedId = typeof existingItem?.id === "string" ? existingItem.id : id; + + if (body) { + if (!emdash.handleContentDuplicateMany) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + const result = await emdash.handleContentDuplicateMany(collection, { + ...body, + ids: [resolvedId], + actor: user ? { id: user.id, role: user.role } : undefined, + }); + if (!result.success) return unwrapResult(result); + + const item = result.data?.results[0]; + if (!item || item.status === "failed") { + return apiError("DUPLICATE_FAILED", item?.error ?? "Failed to duplicate content", 400); + } + + if (cache?.enabled) { + const tags = [body.targetCollection ?? collection]; + if (body.trashSource) tags.push(collection); + await cache.invalidate({ tags }); + } + + return unwrapResult(result, 201); + } + const result = await emdash.handleContentDuplicate(collection, resolvedId, user?.id); if (!result.success) return unwrapResult(result); diff --git a/packages/core/src/astro/routes/api/content/[collection]/duplicate-mapping.ts b/packages/core/src/astro/routes/api/content/[collection]/duplicate-mapping.ts new file mode 100644 index 0000000000..c59c9aa1c3 --- /dev/null +++ b/packages/core/src/astro/routes/api/content/[collection]/duplicate-mapping.ts @@ -0,0 +1,39 @@ +/** + * Duplicate mapping endpoint - injected by EmDash integration + * + * GET /_emdash/api/content/{collection}/duplicate-mapping?target={slug}&ids={csv} + * Everything the duplicate dialog needs in one round trip. + */ + +import type { APIRoute } from "astro"; + +import { requirePerm } from "#api/authorize.js"; +import { apiError, unwrapResult } from "#api/error.js"; +import { parseQuery } from "#api/parse.js"; +import { duplicateMappingQuery } from "#api/schemas/content.js"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params, url, locals }) => { + const { emdash, user } = locals; + const collection = params.collection!; + + if (!emdash?.handleDuplicateMappingGet) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + const denied = requirePerm(user, "content:create"); + if (denied) return denied; + + const query = parseQuery(url, duplicateMappingQuery); + if (query instanceof Response) return query; + + const ids = query.ids + ? query.ids + .split(",") + .map((id) => id.trim()) + .filter((id) => id.length > 0) + : []; + + const result = await emdash.handleDuplicateMappingGet(collection, query.target, ids); + return unwrapResult(result); +}; diff --git a/packages/core/src/astro/routes/api/content/[collection]/duplicate.ts b/packages/core/src/astro/routes/api/content/[collection]/duplicate.ts new file mode 100644 index 0000000000..376e25a0f9 --- /dev/null +++ b/packages/core/src/astro/routes/api/content/[collection]/duplicate.ts @@ -0,0 +1,47 @@ +/** + * Bulk duplicate endpoint - injected by EmDash integration + * + * POST /_emdash/api/content/{collection}/duplicate - Copy entries, into another + * collection through a field mapping when `targetCollection` differs. Returns a + * per-item result; one item failing validation does not stop the others. + */ + +import type { APIRoute } from "astro"; + +import { requirePerm } from "#api/authorize.js"; +import { apiError, unwrapResult } from "#api/error.js"; +import { parseBody } from "#api/parse.js"; +import { contentDuplicateManyBody } from "#api/schemas/content.js"; + +export const prerender = false; + +export const POST: APIRoute = async ({ params, request, locals, cache }) => { + const { emdash, user } = locals; + const collection = params.collection!; + + if (!emdash?.handleContentDuplicateMany) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + const denied = requirePerm(user, "content:create"); + if (denied) return denied; + + const body = await parseBody(request, contentDuplicateManyBody); + if (body instanceof Response) return body; + + // Per-item read access (and, with trashSource, delete access) is checked + // against each entry's author inside the handler. + const result = await emdash.handleContentDuplicateMany(collection, { + ...body, + actor: user ? { id: user.id, role: user.role } : undefined, + }); + + if (!result.success) return unwrapResult(result); + + if (cache?.enabled) { + const tags = [body.targetCollection ?? collection]; + if (body.trashSource) tags.push(collection); + await cache.invalidate({ tags }); + } + + return unwrapResult(result); +}; diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index 3ed85d1e3c..1eb1f3ea0c 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -8,6 +8,11 @@ import type { Element } from "@emdash-cms/blocks"; import type { Kysely } from "kysely"; +import type { + DuplicateMappingResponse, + DuplicateManyInput, + DuplicateItemResult, +} from "../api/handlers/content-duplicate.js"; import type { RouteMeta } from "../plugins/routes.js"; // Re-export core types @@ -322,6 +327,18 @@ export interface EmDashHandlers { authorId?: string, ) => Promise; + // Cross-collection duplication + handleDuplicateMappingGet: ( + collection: string, + targetCollection: string, + ids?: string[], + ) => Promise>; + + handleContentDuplicateMany: ( + collection: string, + input: DuplicateManyInput, + ) => Promise>; + // Publishing & Scheduling handlers handleContentPublish: ( collection: string, diff --git a/packages/core/src/database/repositories/taxonomy.ts b/packages/core/src/database/repositories/taxonomy.ts index 2e22eff4b8..a413a87bbb 100644 --- a/packages/core/src/database/repositories/taxonomy.ts +++ b/packages/core/src/database/repositories/taxonomy.ts @@ -428,6 +428,48 @@ export class TaxonomyRepository { invalidateTaxonomyObjectCache(); } + /** + * Copy term assignments from an entry in one collection to an entry in + * another, keeping only the taxonomies named in `allowedNames`. A taxonomy + * def declares which collections it applies to, so a term the target isn't + * attached to must not follow the copy. + */ + async copyEntryTermsAcross( + sourceCollection: string, + sourceEntryId: string, + targetCollection: string, + targetEntryId: string, + allowedNames: ReadonlySet, + ): Promise { + if (allowedNames.size === 0) return; + + const rows = await this.db + .selectFrom("content_taxonomies") + .innerJoin("taxonomies", "taxonomies.translation_group", "content_taxonomies.taxonomy_id") + .select(["content_taxonomies.taxonomy_id as taxonomy_id"]) + .distinct() + .where("content_taxonomies.collection", "=", sourceCollection) + .where("content_taxonomies.entry_id", "=", sourceEntryId) + .where("taxonomies.name", "in", [...allowedNames]) + .execute(); + if (rows.length === 0) return; + + const denorm = await this.fetchEntryDenorm(targetCollection, targetEntryId); + await this.db + .insertInto("content_taxonomies") + .values( + rows.map((r) => ({ + collection: targetCollection, + entry_id: targetEntryId, + taxonomy_id: r.taxonomy_id, + ...denorm, + })), + ) + .onConflict((oc) => oc.doNothing()) + .execute(); + invalidateTaxonomyObjectCache(); + } + /** * Read the denormalized filter + sort columns from an entry's `ec_*` row so * they can be stamped onto new pivot rows (migration 051). A missing table or diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index c50f363211..28262503c9 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -158,6 +158,9 @@ import { handleContentUpdate, handleContentDelete, handleContentDuplicate, + handleDuplicateMappingGet, + handleContentDuplicateMany, + type DuplicateManyInput, handleContentRestore, handleContentPermanentDelete, handleContentListTrashed, @@ -3095,6 +3098,24 @@ export class EmDashRuntime { return result; } + async handleDuplicateMappingGet(collection: string, targetCollection: string, ids?: string[]) { + return handleDuplicateMappingGet(this.db, collection, targetCollection, ids); + } + + async handleContentDuplicateMany(collection: string, input: DuplicateManyInput) { + const result = await handleContentDuplicateMany(this.db, collection, input); + if (result.success && result.data) { + const copiedIds = result.data.results + .map((entry) => entry.targetId) + .filter((id): id is string => id !== undefined); + await this.refreshContentUsageAfterSuccessfulWrite( + input.targetCollection ?? collection, + copiedIds, + ); + } + return result; + } + // ========================================================================= // Publishing & Scheduling Handlers // ========================================================================= diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4392244aaa..10298adcf7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -54,6 +54,8 @@ export { handleContentUpdate, handleContentDelete, handleContentDuplicate, + handleDuplicateMappingGet, + handleContentDuplicateMany, handleContentRestore, handleContentPermanentDelete, handleContentListTrashed, @@ -80,6 +82,11 @@ export type { ListResponse, ContentListResponse, ContentResponse, + DuplicateFieldMapping, + DuplicateMappingResponse, + DuplicateManyInput, + DuplicateItemResult, + DuplicateItemStatus, MediaListResponse, MediaResponse, RevisionListResponse, diff --git a/packages/core/tests/integration/content/duplicate.test.ts b/packages/core/tests/integration/content/duplicate.test.ts new file mode 100644 index 0000000000..99e65e8467 --- /dev/null +++ b/packages/core/tests/integration/content/duplicate.test.ts @@ -0,0 +1,522 @@ +import { Role } from "@emdash-cms/auth"; +import { sql } from "kysely"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { + handleContentDuplicateMany, + handleDuplicateMappingGet, +} from "../../../src/api/handlers/content-duplicate.js"; +import { handleTaxonomyCreate } from "../../../src/api/handlers/taxonomies.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import { OptionsRepository } from "../../../src/database/repositories/options.js"; +import { RelationRepository } from "../../../src/database/repositories/relation.js"; +import { SeoRepository } from "../../../src/database/repositories/seo.js"; +import { TaxonomyRepository } from "../../../src/database/repositories/taxonomy.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { + describeEachDialect, + setupForDialectWithCollections, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +const EDITOR = { id: "editor-1", role: Role.EDITOR }; +const AUTHOR = { id: "author-1", role: Role.AUTHOR }; + +// setupForDialectWithCollections registers "post" and "page", each with a +// `title` (string/TEXT) and `content` (portableText/JSON) field. +describeEachDialect("content duplication", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialectWithCollections(dialect); + }); + afterEach(async () => { + await teardownForDialect(ctx); + }); + + function content() { + return new ContentRepository(ctx.db); + } + + async function countRows(collection: string): Promise { + const result = await sql<{ + c: number | bigint | string; + }>`SELECT COUNT(*) AS c FROM ${sql.ref(`ec_${collection}`)}`.execute(ctx.db); + return Number(result.rows[0]?.c ?? 0); + } + + it("derives a mapping by slug match and ignores incompatible same-slug pairs", async () => { + const registry = new SchemaRegistry(ctx.db); + // `page.summary` is JSON where `post.summary` is TEXT — same slug, no match. + await registry.createField("post", { slug: "summary", label: "Summary", type: "string" }); + await registry.createField("page", { slug: "summary", label: "Summary", type: "json" }); + + const result = await handleDuplicateMappingGet(ctx.db, "post", "page"); + expect(result.success).toBe(true); + if (!result.success) return; + + expect(result.data.source).toBe("derived"); + expect(result.data.mapping.title).toBe("title"); + expect(result.data.mapping.content).toBe("content"); + expect(result.data.mapping.summary).toBeNull(); + + const summary = result.data.targetCollection.fields.find((f) => f.slug === "summary"); + // The only JSON source field is `content`, so that's the one offered. + expect(summary?.compatibleSources).toEqual(["content"]); + }); + + it("rejects the request when a required target field has no source assigned", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createField("page", { + slug: "subtitle", + label: "Subtitle", + type: "string", + required: true, + }); + const post = await content().create({ type: "post", slug: "p", data: { title: "P" } }); + + const result = await handleContentDuplicateMany(ctx.db, "post", { + ids: [post.id], + targetCollection: "page", + mapping: { title: "title" }, + actor: EDITOR, + }); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe("VALIDATION_ERROR"); + expect(result.error?.message).toContain("subtitle"); + expect(await countRows("page")).toBe(0); + }); + + it("rejects a mapping that would write an out-of-options select value", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createField("post", { slug: "kind", label: "Kind", type: "string" }); + await registry.createField("page", { + slug: "kind", + label: "Kind", + type: "select", + validation: { options: ["guide", "reference"] }, + }); + const post = await content().create({ + type: "post", + slug: "p", + data: { title: "P", kind: "tutorial" }, + }); + + const result = await handleContentDuplicateMany(ctx.db, "post", { + ids: [post.id], + targetCollection: "page", + mapping: { title: "title", kind: "kind" }, + actor: EDITOR, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.results[0]?.status).toBe("failed"); + expect(await countRows("page")).toBe(0); + }); + + it("fails validation when a required target field is mapped to a NULL source", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createField("post", { slug: "subtitle", label: "Subtitle", type: "string" }); + await registry.createField("page", { + slug: "subtitle", + label: "Subtitle", + type: "string", + required: true, + }); + // The mapping is complete; the source value simply isn't set. + const post = await content().create({ type: "post", slug: "p", data: { title: "P" } }); + + const result = await handleContentDuplicateMany(ctx.db, "post", { + ids: [post.id], + targetCollection: "page", + mapping: { title: "title", subtitle: "subtitle" }, + actor: EDITOR, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.results[0]?.status).toBe("failed"); + expect(result.data.results[0]?.error).toContain("subtitle"); + expect(await countRows("page")).toBe(0); + }); + + it("copies as a draft with a fresh slug and a new translation group", async () => { + const post = await content().create({ + type: "post", + slug: "hello", + data: { title: "Hello" }, + status: "published", + }); + + const result = await handleContentDuplicateMany(ctx.db, "post", { + ids: [post.id], + targetCollection: "page", + actor: EDITOR, + }); + expect(result.success).toBe(true); + if (!result.success) return; + + const targetId = result.data.results[0]?.targetId; + expect(targetId).toBeDefined(); + const copy = await content().findById("page", targetId!); + expect(copy?.status).toBe("draft"); + expect(copy?.slug).toBe("hello"); + expect(copy?.data.title).toBe("Hello"); + expect(copy?.locale).toBe(post.locale); + expect(copy?.translationGroup).not.toBe(post.translationGroup); + expect(copy?.translationGroup).toBe(copy?.id); + expect(copy?.publishedAt).toBeNull(); + expect(copy?.authorId).toBe(EDITOR.id); + }); + + it("carries taxonomy terms only when the definition lists the target collection", async () => { + const shared = await handleTaxonomyCreate(ctx.db, { + name: "topic", + label: "Topics", + collections: ["post", "page"], + }); + const postOnly = await handleTaxonomyCreate(ctx.db, { + name: "series", + label: "Series", + collections: ["post"], + }); + expect(shared.success && postOnly.success).toBe(true); + + const taxRepo = new TaxonomyRepository(ctx.db); + const topic = await taxRepo.create({ name: "topic", slug: "astro", label: "Astro" }); + const series = await taxRepo.create({ name: "series", slug: "basics", label: "Basics" }); + + const post = await content().create({ type: "post", slug: "p", data: { title: "P" } }); + await taxRepo.attachToEntry("post", post.id, topic.id); + await taxRepo.attachToEntry("post", post.id, series.id); + + const result = await handleContentDuplicateMany(ctx.db, "post", { + ids: [post.id], + targetCollection: "page", + actor: EDITOR, + }); + expect(result.success).toBe(true); + if (!result.success) return; + + const targetId = result.data.results[0]?.targetId; + const carried = await taxRepo.getTermsForEntry("page", targetId!); + expect(carried.map((term) => term.name)).toEqual(["topic"]); + }); + + it("reports reference edges and leaves inbound edges on the original", async () => { + const relationRepo = new RelationRepository(ctx.db); + const relation = await relationRepo.create({ + name: "related", + parentCollection: "post", + childCollection: "post", + parentLabel: "Post", + childLabel: "Related post", + }); + + const post = await content().create({ type: "post", slug: "p", data: { title: "P" } }); + const other = await content().create({ type: "post", slug: "o", data: { title: "O" } }); + // other -> post, so `post` has one inbound edge and no outbound edges. + await relationRepo.setChildren(relation.translationGroup, other.translationGroup!, [ + post.translationGroup!, + ]); + + const mapping = await handleDuplicateMappingGet(ctx.db, "post", "page", [post.id]); + expect(mapping.success).toBe(true); + if (!mapping.success) return; + expect(mapping.data.referenceEdges).toEqual({ inbound: 1, outbound: 0 }); + + const result = await handleContentDuplicateMany(ctx.db, "post", { + ids: [post.id], + targetCollection: "page", + actor: EDITOR, + }); + expect(result.success).toBe(true); + if (!result.success) return; + + const edges = await ctx.db + .selectFrom("_emdash_content_references") + .select(["parent_group", "child_group"]) + .execute(); + expect(edges).toHaveLength(1); + expect(edges[0]?.child_group).toBe(post.translationGroup); + }); + + it("copies SEO only when both collections have it enabled", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "article", label: "Articles", hasSeo: true }); + await registry.createField("article", { slug: "title", label: "Title", type: "string" }); + + const seoRepo = new SeoRepository(ctx.db); + const post = await content().create({ type: "post", slug: "p", data: { title: "P" } }); + await seoRepo.upsert("post", post.id, { + title: "Meta", + description: "Desc", + canonical: "https://example.com/p", + }); + + // post has no SEO, so nothing carries even though article does. + const toArticle = await handleContentDuplicateMany(ctx.db, "post", { + ids: [post.id], + targetCollection: "article", + actor: EDITOR, + }); + expect(toArticle.success).toBe(true); + if (!toArticle.success) return; + const articleSeo = await seoRepo.get("article", toArticle.data.results[0]!.targetId!); + expect(articleSeo.title).toBeNull(); + + await registry.updateCollection("post", { hasSeo: true }); + const withSeo = await handleContentDuplicateMany(ctx.db, "post", { + ids: [post.id], + targetCollection: "article", + actor: EDITOR, + }); + expect(withSeo.success).toBe(true); + if (!withSeo.success) return; + const copiedSeo = await seoRepo.get("article", withSeo.data.results[0]!.targetId!); + expect(copiedSeo.title).toBe("Meta"); + expect(copiedSeo.description).toBe("Desc"); + // The canonical pointed at the original. + expect(copiedSeo.canonical).toBeNull(); + }); + + it("saves a mapping to options and prefers it over derivation", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createField("post", { slug: "summary", label: "Summary", type: "string" }); + await registry.createField("page", { slug: "subtitle", label: "Subtitle", type: "string" }); + + const post = await content().create({ + type: "post", + slug: "p", + data: { title: "P", summary: "S" }, + }); + + const saved = await handleContentDuplicateMany(ctx.db, "post", { + ids: [post.id], + targetCollection: "page", + mapping: { title: "title", subtitle: "summary" }, + saveMapping: true, + actor: EDITOR, + }); + expect(saved.success).toBe(true); + + const stored = await new OptionsRepository(ctx.db).get("contentmap:post:page"); + expect(stored).toEqual({ + version: 1, + fields: { title: "title", subtitle: "summary" }, + }); + + const mapping = await handleDuplicateMappingGet(ctx.db, "post", "page"); + expect(mapping.success).toBe(true); + if (!mapping.success) return; + expect(mapping.data.source).toBe("saved"); + expect(mapping.data.mapping.subtitle).toBe("summary"); + + // A later run without an explicit mapping uses the saved one. + const reused = await handleContentDuplicateMany(ctx.db, "post", { + ids: [post.id], + targetCollection: "page", + actor: EDITOR, + }); + expect(reused.success).toBe(true); + if (!reused.success) return; + const copy = await content().findById("page", reused.data.results[0]!.targetId!); + expect(copy?.data.subtitle).toBe("S"); + }); + + it("returns per-item results across a partial failure", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createField("post", { slug: "kind", label: "Kind", type: "string" }); + await registry.createField("page", { + slug: "kind", + label: "Kind", + type: "select", + validation: { options: ["guide"] }, + }); + + const good = await content().create({ + type: "post", + slug: "good", + data: { title: "Good", kind: "guide" }, + }); + const bad = await content().create({ + type: "post", + slug: "bad", + data: { title: "Bad", kind: "nope" }, + }); + + const result = await handleContentDuplicateMany(ctx.db, "post", { + ids: [bad.id, good.id], + targetCollection: "page", + actor: EDITOR, + }); + expect(result.success).toBe(true); + if (!result.success) return; + + expect(result.data.results.map((r) => [r.id, r.status])).toEqual([ + [bad.id, "failed"], + [good.id, "copied"], + ]); + expect(await countRows("page")).toBe(1); + }); + + it("rejects trashSource on an item the actor cannot delete, without copying it", async () => { + const mine = await content().create({ + type: "post", + slug: "mine", + data: { title: "Mine" }, + authorId: AUTHOR.id, + }); + const theirs = await content().create({ + type: "post", + slug: "theirs", + data: { title: "Theirs" }, + authorId: "someone-else", + }); + + const result = await handleContentDuplicateMany(ctx.db, "post", { + ids: [mine.id, theirs.id], + targetCollection: "page", + trashSource: true, + actor: AUTHOR, + }); + expect(result.success).toBe(true); + if (!result.success) return; + + expect(result.data.results.map((r) => [r.id, r.status])).toEqual([ + [mine.id, "copied"], + [theirs.id, "failed"], + ]); + expect(await countRows("page")).toBe(1); + expect(await content().findById("post", mine.id)).toBeNull(); + expect(await content().findById("post", theirs.id)).not.toBeNull(); + }); + + it("resolves an identity mapping when the target is the source collection", async () => { + await handleTaxonomyCreate(ctx.db, { + name: "series", + label: "Series", + collections: ["post"], + }); + + const result = await handleDuplicateMappingGet(ctx.db, "post", "post"); + expect(result.success).toBe(true); + if (!result.success) return; + + expect(result.data.mapping).toEqual({ title: "title", content: "content" }); + expect(result.data.unmappableRequired).toEqual([]); + expect(result.data.taxonomies.dropped).toEqual([]); + expect(result.data.taxonomies.carried.map((tx) => tx.name)).toEqual(["series"]); + }); + + it("duplicates within one collection as a draft copy carrying taxonomy terms", async () => { + await handleTaxonomyCreate(ctx.db, { + name: "series", + label: "Series", + collections: ["post"], + }); + const taxRepo = new TaxonomyRepository(ctx.db); + const series = await taxRepo.create({ name: "series", slug: "basics", label: "Basics" }); + + const post = await content().create({ + type: "post", + slug: "hello", + data: { title: "Hello" }, + status: "published", + }); + await taxRepo.attachToEntry("post", post.id, series.id); + + const result = await handleContentDuplicateMany(ctx.db, "post", { + ids: [post.id], + targetCollection: "post", + actor: EDITOR, + }); + expect(result.success).toBe(true); + if (!result.success) return; + + const targetId = result.data.results[0]?.targetId; + expect(targetId).toBeDefined(); + const copy = await content().findById("post", targetId!); + expect(copy?.status).toBe("draft"); + expect(copy?.data.title).toBe("Hello (Copy)"); + expect(copy?.slug).not.toBe(post.slug); + expect(copy?.translationGroup).not.toBe(post.translationGroup); + expect(await taxRepo.getTermsForEntry("post", targetId!)).toHaveLength(1); + }); + + it("copies a row that predates a newly required field", async () => { + const post = await content().create({ type: "post", slug: "old", data: { title: "Old" } }); + // The row was valid when it was written; the schema tightened afterwards. + await new SchemaRegistry(ctx.db).createField("post", { + slug: "subtitle", + label: "Subtitle", + type: "string", + required: true, + }); + + const result = await handleContentDuplicateMany(ctx.db, "post", { + ids: [post.id], + targetCollection: "post", + actor: EDITOR, + }); + expect(result.success).toBe(true); + if (!result.success) return; + + expect(result.data.results[0]?.status).toBe("copied"); + expect(await countRows("post")).toBe(2); + }); + + it("suffixes the title of a same-collection copy that drops a field", async () => { + const post = await content().create({ + type: "post", + slug: "p", + data: { title: "P", content: [{ _type: "block", children: [] }] }, + }); + + const result = await handleContentDuplicateMany(ctx.db, "post", { + ids: [post.id], + targetCollection: "post", + mapping: { title: "title", content: null }, + actor: EDITOR, + }); + expect(result.success).toBe(true); + if (!result.success) return; + + const targetId = result.data.results[0]?.targetId; + expect(targetId).toBeDefined(); + const copy = await content().findById("post", targetId!); + expect(copy?.data.title).toBe("P (Copy)"); + expect(copy?.data.content).toBeFalsy(); + }); + + it("validates a same-collection copy that remaps fields", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createField("post", { slug: "note", label: "Note", type: "string" }); + await registry.createField("post", { + slug: "kind", + label: "Kind", + type: "select", + validation: { options: ["guide", "reference"] }, + }); + const post = await content().create({ + type: "post", + slug: "p", + data: { title: "P", note: "tutorial" }, + }); + + const result = await handleContentDuplicateMany(ctx.db, "post", { + ids: [post.id], + targetCollection: "post", + mapping: { title: "title", content: "content", note: "note", kind: "note" }, + actor: EDITOR, + }); + expect(result.success).toBe(true); + if (!result.success) return; + + expect(result.data.results[0]?.status).toBe("failed"); + expect(await countRows("post")).toBe(1); + }); +}); diff --git a/packages/core/tests/unit/astro/middleware-prerender.test.ts b/packages/core/tests/unit/astro/middleware-prerender.test.ts index a118523dc7..418448b65f 100644 --- a/packages/core/tests/unit/astro/middleware-prerender.test.ts +++ b/packages/core/tests/unit/astro/middleware-prerender.test.ts @@ -48,6 +48,8 @@ const { handleContentCountTrashed: ok, handleContentGetIncludingTrashed: ok, handleContentDuplicate: ok, + handleDuplicateMappingGet: ok, + handleContentDuplicateMany: ok, handleContentPublish: ok, handleContentUnpublish: ok, handleContentSchedule: ok, diff --git a/packages/core/tests/unit/astro/middleware-security-headers.test.ts b/packages/core/tests/unit/astro/middleware-security-headers.test.ts index 2779a0d929..f69c1ece5b 100644 --- a/packages/core/tests/unit/astro/middleware-security-headers.test.ts +++ b/packages/core/tests/unit/astro/middleware-security-headers.test.ts @@ -40,6 +40,8 @@ const { MOCK_RUNTIME, mockGetPublicUrl } = vi.hoisted(() => { handleContentCountTrashed: ok, handleContentGetIncludingTrashed: ok, handleContentDuplicate: ok, + handleDuplicateMappingGet: ok, + handleContentDuplicateMany: ok, handleContentPublish: ok, handleContentUnpublish: ok, handleContentSchedule: ok,