mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
* 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>
254 lines
7.3 KiB
TypeScript
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;
|
|
}
|