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>
454 lines
13 KiB
TypeScript
454 lines
13 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { MentionType } from "@shared/types";
|
|
import { createContext } from "@server/context";
|
|
import env from "@server/env";
|
|
import { sequelize } from "@server/storage/database";
|
|
import {
|
|
buildCollection,
|
|
buildDocument,
|
|
buildUser,
|
|
} from "@server/test/factories";
|
|
import { withAPIContext } from "@server/test/support";
|
|
import { generateUrlId } from "@server/utils/url";
|
|
import documentDuplicator from "./documentDuplicator";
|
|
|
|
describe("documentDuplicator", () => {
|
|
it("should duplicate existing document", async () => {
|
|
const user = await buildUser();
|
|
const original = await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
});
|
|
|
|
const response = await withAPIContext(user, (ctx) =>
|
|
documentDuplicator(ctx, {
|
|
document: original,
|
|
collection: original.collection,
|
|
})
|
|
);
|
|
|
|
expect(response).toHaveLength(1);
|
|
expect(response[0].title).toEqual(original.title);
|
|
expect(response[0].text).toEqual(original.text);
|
|
expect(response[0].icon).toEqual(original.icon);
|
|
expect(response[0].color).toEqual(original.color);
|
|
expect(response[0].publishedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("should duplicate document with title override", async () => {
|
|
const user = await buildUser();
|
|
const original = await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
icon: "👋",
|
|
});
|
|
|
|
const response = await withAPIContext(user, (ctx) =>
|
|
documentDuplicator(ctx, {
|
|
document: original,
|
|
collection: original.collection,
|
|
title: "New title",
|
|
})
|
|
);
|
|
|
|
expect(response).toHaveLength(1);
|
|
expect(response[0].title).toEqual("New title");
|
|
expect(response[0].text).toEqual(original.text);
|
|
expect(response[0].icon).toEqual(original.icon);
|
|
expect(response[0].color).toEqual(original.color);
|
|
expect(response[0].publishedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("should duplicate child documents, in the correct order with recursive=true", async () => {
|
|
const user = await buildUser();
|
|
const collection = await buildCollection({
|
|
teamId: user.teamId,
|
|
userId: user.id,
|
|
});
|
|
|
|
const original = await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
icon: "👋",
|
|
title: "doc 1",
|
|
collectionId: collection.id,
|
|
});
|
|
|
|
const child1 = await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
parentDocumentId: original.id,
|
|
title: "doc 1.1",
|
|
});
|
|
|
|
const child2 = await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
parentDocumentId: original.id,
|
|
title: "doc 1.2",
|
|
});
|
|
|
|
const child3 = await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
parentDocumentId: original.id,
|
|
title: "doc 1.3",
|
|
});
|
|
|
|
await collection.addDocumentToStructure(original);
|
|
await collection.addDocumentToStructure(child1);
|
|
await collection.addDocumentToStructure(child2);
|
|
await collection.addDocumentToStructure(child3);
|
|
|
|
await withAPIContext(user, (ctx) =>
|
|
documentDuplicator(ctx, {
|
|
title: "duplicate",
|
|
document: original,
|
|
collection: original.collection,
|
|
recursive: true,
|
|
})
|
|
);
|
|
|
|
await collection.reload();
|
|
const duplicate = collection.documentStructure![0];
|
|
const childTitles = duplicate.children!.map((child) => child.title);
|
|
|
|
expect(duplicate.title).toEqual("duplicate");
|
|
expect(childTitles.length).toBe(3);
|
|
expect(childTitles[0]).toBe(child1.title);
|
|
expect(childTitles[1]).toBe(child2.title);
|
|
expect(childTitles[2]).toBe(child3.title);
|
|
});
|
|
|
|
it("should duplicate existing document as draft", async () => {
|
|
const user = await buildUser();
|
|
const original = await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
});
|
|
|
|
const response = await withAPIContext(user, (ctx) =>
|
|
documentDuplicator(ctx, {
|
|
document: original,
|
|
collection: original.collection,
|
|
publish: false,
|
|
})
|
|
);
|
|
|
|
expect(response).toHaveLength(1);
|
|
expect(response[0].title).toEqual(original.title);
|
|
expect(response[0].text).toEqual(original.text);
|
|
expect(response[0].icon).toEqual(original.icon);
|
|
expect(response[0].color).toEqual(original.color);
|
|
expect(response[0].publishedAt).toBeNull();
|
|
});
|
|
|
|
it("should set originalDocumentId in sourceMetadata when duplicating", async () => {
|
|
const user = await buildUser();
|
|
const original = await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
sourceMetadata: { fileName: "test.md", externalId: "ext123" },
|
|
});
|
|
|
|
const response = await sequelize.transaction((transaction) =>
|
|
documentDuplicator(createContext({ user, transaction }), {
|
|
document: original,
|
|
collection: original.collection,
|
|
})
|
|
);
|
|
|
|
expect(response).toHaveLength(1);
|
|
expect(response[0].sourceMetadata).toEqual({
|
|
fileName: "test.md",
|
|
externalId: "ext123",
|
|
originalDocumentId: original.id,
|
|
});
|
|
});
|
|
|
|
it("should set originalDocumentId for child documents when duplicating recursively", async () => {
|
|
const user = await buildUser();
|
|
const original = await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
});
|
|
|
|
const childDocument = await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
parentDocumentId: original.id,
|
|
collection: original.collection,
|
|
sourceMetadata: { fileName: "child.md" },
|
|
});
|
|
|
|
const response = await sequelize.transaction((transaction) =>
|
|
documentDuplicator(createContext({ user, transaction }), {
|
|
document: original,
|
|
collection: original.collection,
|
|
recursive: true,
|
|
})
|
|
);
|
|
|
|
expect(response).toHaveLength(2);
|
|
|
|
// Check parent document
|
|
const duplicatedParent = response.find((doc) => !doc.parentDocumentId);
|
|
expect(duplicatedParent?.sourceMetadata?.originalDocumentId).toEqual(
|
|
original.id
|
|
);
|
|
|
|
// Check child document
|
|
const duplicatedChild = response.find((doc) => doc.parentDocumentId);
|
|
expect(duplicatedChild?.sourceMetadata?.originalDocumentId).toEqual(
|
|
childDocument.id
|
|
);
|
|
expect(duplicatedChild?.sourceMetadata?.fileName).toEqual("child.md");
|
|
});
|
|
|
|
it("should remap links between documents in the duplicated tree", async () => {
|
|
const user = await buildUser();
|
|
const collection = await buildCollection({
|
|
teamId: user.teamId,
|
|
userId: user.id,
|
|
});
|
|
const original = await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
collectionId: collection.id,
|
|
title: "parent",
|
|
});
|
|
const child2 = await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
collectionId: collection.id,
|
|
parentDocumentId: original.id,
|
|
title: "child 2",
|
|
});
|
|
const child1 = await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
collectionId: collection.id,
|
|
parentDocumentId: original.id,
|
|
title: "child 1",
|
|
text: `See [child 2](${child2.path}) and the [parent](/doc/${original.id}).`,
|
|
});
|
|
|
|
const response = await withAPIContext(user, (ctx) =>
|
|
documentDuplicator(ctx, {
|
|
document: original,
|
|
collection,
|
|
recursive: true,
|
|
})
|
|
);
|
|
|
|
const duplicatedParent = response.find(
|
|
(doc) => doc.sourceMetadata?.originalDocumentId === original.id
|
|
);
|
|
const duplicatedChild1 = response.find(
|
|
(doc) => doc.sourceMetadata?.originalDocumentId === child1.id
|
|
);
|
|
const duplicatedChild2 = response.find(
|
|
(doc) => doc.sourceMetadata?.originalDocumentId === child2.id
|
|
);
|
|
|
|
expect(duplicatedChild1!.text).toContain(duplicatedChild2!.path);
|
|
expect(duplicatedChild1!.text).toContain(duplicatedParent!.path);
|
|
expect(duplicatedChild1!.text).not.toContain(child2.urlId);
|
|
expect(duplicatedChild1!.text).not.toContain(original.id);
|
|
});
|
|
|
|
it("should remap mentions of documents in the duplicated tree", async () => {
|
|
const user = await buildUser();
|
|
const collection = await buildCollection({
|
|
teamId: user.teamId,
|
|
userId: user.id,
|
|
});
|
|
const original = await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
collectionId: collection.id,
|
|
title: "parent",
|
|
});
|
|
const child2 = await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
collectionId: collection.id,
|
|
parentDocumentId: original.id,
|
|
title: "child 2",
|
|
});
|
|
const child1 = await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
collectionId: collection.id,
|
|
parentDocumentId: original.id,
|
|
title: "child 1",
|
|
content: {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{
|
|
type: "mention",
|
|
attrs: {
|
|
type: MentionType.Document,
|
|
modelId: child2.id,
|
|
label: "child 2",
|
|
actorId: user.id,
|
|
id: randomUUID(),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const response = await withAPIContext(user, (ctx) =>
|
|
documentDuplicator(ctx, {
|
|
document: original,
|
|
collection,
|
|
recursive: true,
|
|
})
|
|
);
|
|
|
|
const duplicatedChild1 = response.find(
|
|
(doc) => doc.sourceMetadata?.originalDocumentId === child1.id
|
|
);
|
|
const duplicatedChild2 = response.find(
|
|
(doc) => doc.sourceMetadata?.originalDocumentId === child2.id
|
|
);
|
|
const mention = duplicatedChild1!.content!.content![0].content![0];
|
|
|
|
expect(mention.attrs!.modelId).toEqual(duplicatedChild2!.id);
|
|
});
|
|
|
|
it("should not remap links to documents outside of the duplicated tree", async () => {
|
|
const user = await buildUser();
|
|
const collection = await buildCollection({
|
|
teamId: user.teamId,
|
|
userId: user.id,
|
|
});
|
|
const other = await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
collectionId: collection.id,
|
|
title: "other",
|
|
});
|
|
const original = await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
collectionId: collection.id,
|
|
title: "parent",
|
|
text: `See [other](${other.path}) and [elsewhere](https://example.com/doc/${other.urlId}).`,
|
|
});
|
|
|
|
const response = await withAPIContext(user, (ctx) =>
|
|
documentDuplicator(ctx, {
|
|
document: original,
|
|
collection,
|
|
recursive: true,
|
|
})
|
|
);
|
|
|
|
expect(response[0].text).toContain(other.path);
|
|
expect(response[0].text).toContain(
|
|
`https://example.com/doc/${other.urlId}`
|
|
);
|
|
});
|
|
|
|
it("should remap self links when duplicating a single document", async () => {
|
|
const user = await buildUser();
|
|
const collection = await buildCollection({
|
|
teamId: user.teamId,
|
|
userId: user.id,
|
|
});
|
|
const urlId = generateUrlId();
|
|
const original = await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
collectionId: collection.id,
|
|
title: "parent",
|
|
urlId,
|
|
text: [
|
|
`Relative [top](/doc/parent-${urlId}).`,
|
|
`Qualified [top](${env.URL}/doc/parent-${urlId}).`,
|
|
`Plain <${env.URL}/doc/parent-${urlId}>`,
|
|
].join("\n\n"),
|
|
});
|
|
|
|
const response = await withAPIContext(user, (ctx) =>
|
|
documentDuplicator(ctx, {
|
|
document: original,
|
|
collection,
|
|
title: "parent (copy)",
|
|
})
|
|
);
|
|
|
|
expect(response[0].text).toContain(`(${response[0].path})`);
|
|
expect(response[0].text).toContain(`(${env.URL}${response[0].path})`);
|
|
expect(response[0].text).toContain(`<${env.URL}${response[0].path}>`);
|
|
expect(response[0].text).not.toContain(original.urlId);
|
|
});
|
|
|
|
it("should copy fullWidth property when duplicating document", async () => {
|
|
const user = await buildUser();
|
|
const original = await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
fullWidth: true,
|
|
});
|
|
|
|
const response = await withAPIContext(user, (ctx) =>
|
|
documentDuplicator(ctx, {
|
|
document: original,
|
|
collection: original.collection,
|
|
})
|
|
);
|
|
|
|
expect(response).toHaveLength(1);
|
|
expect(response[0].fullWidth).toBe(true);
|
|
});
|
|
|
|
it("should copy fullWidth property to child documents when duplicating recursively", async () => {
|
|
const user = await buildUser();
|
|
const collection = await buildCollection({
|
|
teamId: user.teamId,
|
|
userId: user.id,
|
|
});
|
|
|
|
const original = await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
fullWidth: true,
|
|
collectionId: collection.id,
|
|
});
|
|
|
|
await buildDocument({
|
|
userId: user.id,
|
|
teamId: user.teamId,
|
|
parentDocumentId: original.id,
|
|
fullWidth: true,
|
|
collectionId: collection.id,
|
|
});
|
|
|
|
const response = await withAPIContext(user, (ctx) =>
|
|
documentDuplicator(ctx, {
|
|
document: original,
|
|
collection: original.collection,
|
|
recursive: true,
|
|
})
|
|
);
|
|
|
|
expect(response).toHaveLength(2);
|
|
|
|
// Check parent document
|
|
const duplicatedParent = response.find((doc) => !doc.parentDocumentId);
|
|
expect(duplicatedParent?.fullWidth).toBe(true);
|
|
|
|
// Check child document
|
|
const duplicatedChild = response.find((doc) => doc.parentDocumentId);
|
|
expect(duplicatedChild?.fullWidth).toBe(true);
|
|
});
|
|
});
|