Ctrl K
ComunidadGuías…

server/commands/documentDuplicator.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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
  ) {
    // Structural fields only – content is re-fetched per document in
    // `duplicateItem`, so a large tree isn't held in memory at once.
    const childDocuments = await original.findChildDocuments(
      {
        archivedAt: original.archivedAt
          ? {
              [Op.ne]: null,
            }
          : {
              [Op.eq]: null,
            },
      },
      {
        ...ctx,
        attributes: { exclude: ["state", "content", "text"] },
      }
    );

    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 original =
      item.original.dataValues.content !== undefined
        ? item.original
        : await Document.findByPk(item.original.id, {
            transaction: ctx.state.transaction,
            rejectOnEmpty: true,
          });

    const duplicated = await documentCreator(ctx, {
      id: item.id,
      urlId: item.urlId,
      parentDocumentId: options.parentDocumentId,
      publish: options.publish,
      collectionId: collection?.id,
      icon: original.icon,
      color: original.color,
      fullWidth: original.fullWidth,
      preferences: original.preferences,
      title: item.title,
      content: ProsemirrorHelper.replaceDocumentReferences(
        ProsemirrorHelper.removeMarks(DocumentHelper.toProsemirror(original), [
          "comment",
        ]),
        references
      ),
      sourceMetadata: {
        ...original.sourceMetadata,
        originalDocumentId: 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;
}