Files
outline/server/commands/documentDuplicator.ts
T
b2b41dd3ca fix: Remap internal links when duplicating a document tree (#13270)
* fix: Remap internal links when duplicating a document tree

Duplicating a document tree now assigns the identifiers of every duplicate
before any content is written, so links and mentions between the documents
being duplicated point at the copies rather than the originals.

* fix: Remap fully qualified links when duplicating a document tree

Links written as a full url to this installation are now remapped alongside
relative ones, and stay fully qualified. Where a link is displayed as its own
url the text is updated to match.

Also trims surrounding whitespace in sanitizeUrl, which otherwise failed
validation and prepended a second scheme to an already qualified url.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: Remap links across trees when duplicating a collection

The collection duplication task duplicated each root document separately, so
only links within a single tree were remapped. It now duplicates the whole
collection as one unit, with identifiers assigned across every tree before any
content is written.

* fix: Match document links by identifier rather than host when duplicating

An installation can be reached through more than one host, so comparing a
link's host against the configured url left fully qualified links to a
document in the duplicated set unmapped. The document a link identifies now
decides whether it is replaced, and the host it was written with is kept.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:44:06 -04:00

254 lines
7.3 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { Op } from "sequelize";
import { Collection, Document } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import type { DocumentReference } from "@server/models/helpers/ProsemirrorHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import type { APIContext } from "@server/types";
import { generateUrlId } from "@server/utils/url";
import documentCreator from "./documentCreator";
type Props = {
/** The document to duplicate */
document: Document;
/** The collection to add the duplicated document to */
collection?: Collection | null;
/** Override of the parent document to add the duplicate to */
parentDocumentId?: string;
/** Override of the duplicated document title */
title?: string;
/** Override of the duplicated document publish state */
publish?: boolean;
/** Whether to duplicate child documents */
recursive?: boolean;
};
type ManyProps = {
/** The documents to duplicate, in the order they should be created */
documents: Document[];
/** The collection to add the duplicated documents to */
collection?: Collection | null;
/** Override of the duplicated documents publish state */
publish?: boolean;
/** Whether to duplicate child documents */
recursive?: boolean;
};
/** A document to duplicate, and where its duplicate should be placed. */
type Root = {
/** The document to duplicate */
document: Document;
/** Override of the duplicated document title */
title?: string;
/** Override of the parent document to add the duplicate to */
parentDocumentId?: string;
};
/** A document to duplicate, with the identifiers assigned to its duplicate. */
type DuplicateItem = {
/** The document being duplicated */
original: Document;
/** The id assigned to the duplicate */
id: string;
/** The url identifier assigned to the duplicate */
urlId: string;
/** The title of the duplicate */
title: string;
/** The children to duplicate under the duplicate */
children: DuplicateItem[];
};
/**
* Duplicates a document, optionally including its child documents. Links
* between the duplicated documents are remapped to point at the copies.
*
* @param ctx the API context containing the acting user and transaction.
* @param props the document to duplicate and optional overrides.
* @returns the duplicated documents.
*/
export default async function documentDuplicator(
ctx: APIContext,
{ document, collection, parentDocumentId, title, publish, recursive }: Props
): Promise<Document[]> {
return duplicateRoots(ctx, {
roots: [{ document, title, parentDocumentId }],
collection,
publish,
recursive,
});
}
/**
* Duplicates several documents as one unit, so that links between them are
* remapped to the copies no matter which of the documents they cross between.
*
* @param ctx the API context containing the acting user and transaction.
* @param props the documents to duplicate and optional overrides.
* @returns the duplicated documents.
*/
export async function documentsDuplicator(
ctx: APIContext,
{ documents, collection, publish, recursive }: ManyProps
): Promise<Document[]> {
return duplicateRoots(ctx, {
roots: documents.map((document) => ({ document })),
collection,
publish,
recursive,
});
}
async function duplicateRoots(
ctx: APIContext,
{
roots,
collection,
publish,
recursive,
}: {
roots: Root[];
collection?: Collection | null;
publish?: boolean;
recursive?: boolean;
}
): Promise<Document[]> {
const newDocuments: Document[] = [];
const references = new Map<string, DocumentReference>();
const originalCollections = new Map<string, Collection | null>();
async function originalCollectionFor(original: Document) {
const collectionId = original.collectionId;
if (!collectionId) {
return null;
}
if (!originalCollections.has(collectionId)) {
originalCollections.set(
collectionId,
await Collection.findByPk(collectionId, {
attributes: {
include: ["documentStructure"],
},
})
);
}
return originalCollections.get(collectionId) ?? null;
}
async function buildItem(
original: Document,
originalCollection: Collection | null,
titleOverride?: string
): Promise<DuplicateItem> {
const id = randomUUID();
const urlId = generateUrlId();
const itemTitle = titleOverride ?? original.title;
const reference = {
id,
path: Document.getPath({ title: itemTitle, urlId }),
};
references.set(original.id, reference);
references.set(original.urlId, reference);
return {
original,
id,
urlId,
title: itemTitle,
children: recursive
? await buildChildItems(original, originalCollection)
: [],
};
}
async function buildChildItems(
original: Document,
originalCollection: Collection | null
) {
const childDocuments = await original.findChildDocuments(
{
archivedAt: original.archivedAt
? {
[Op.ne]: null,
}
: {
[Op.eq]: null,
},
},
ctx
);
const sorted = DocumentHelper.sortDocumentsByStructure(
childDocuments,
originalCollection?.getDocumentTree(original.id)?.children ?? []
).reverse(); // we have to reverse since the child documents will be added in reverse order
const items: DuplicateItem[] = [];
for (const childDocument of sorted) {
items.push(await buildItem(childDocument, originalCollection));
}
return items;
}
async function duplicateItem(
item: DuplicateItem,
options: { parentDocumentId?: string; publish: boolean }
) {
const duplicated = await documentCreator(ctx, {
id: item.id,
urlId: item.urlId,
parentDocumentId: options.parentDocumentId,
publish: options.publish,
collectionId: collection?.id,
icon: item.original.icon,
color: item.original.color,
fullWidth: item.original.fullWidth,
title: item.title,
content: ProsemirrorHelper.replaceDocumentReferences(
ProsemirrorHelper.removeMarks(
DocumentHelper.toProsemirror(item.original),
["comment"]
),
references
),
sourceMetadata: {
...item.original.sourceMetadata,
originalDocumentId: item.original.id,
},
});
duplicated.collection = collection ?? null;
newDocuments.push(duplicated);
for (const child of item.children) {
await duplicateItem(child, {
...options,
parentDocumentId: duplicated.id,
});
}
}
// The identifiers of every duplicate are assigned before any content is
// written so that links between the documents being duplicated can be
// remapped to the copies, leaving the duplicate self-contained.
const items: DuplicateItem[] = [];
for (const root of roots) {
items.push(
await buildItem(
root.document,
await originalCollectionFor(root.document),
root.title
)
);
}
for (const [index, item] of items.entries()) {
await duplicateItem(item, {
parentDocumentId: roots[index].parentDocumentId,
publish: publish ?? !!roots[index].document.publishedAt,
});
}
return newDocuments;
}