From 394c6e3b030a56c935306c8de4b916249ae618c1 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 12 Jun 2026 20:04:18 -0400 Subject: [PATCH] fix: Duplicate paths in export ZIP (#12674) --- server/queues/tasks/ExportDocumentTreeTask.ts | 78 +++++++++++-------- .../tasks/ExportMarkdownZipTask.test.ts | 61 +++++++++++++++ 2 files changed, 108 insertions(+), 31 deletions(-) create mode 100644 server/queues/tasks/ExportMarkdownZipTask.test.ts diff --git a/server/queues/tasks/ExportDocumentTreeTask.ts b/server/queues/tasks/ExportDocumentTreeTask.ts index 59ecd782ef..4c09cdfda3 100644 --- a/server/queues/tasks/ExportDocumentTreeTask.ts +++ b/server/queues/tasks/ExportDocumentTreeTask.ts @@ -150,26 +150,12 @@ export default abstract class ExportDocumentTreeTask extends ExportTask { includeAttachments = true ) { const pathMap = this.createPathMap(collections, format); - Logger.debug( - "task", - `Start adding ${Object.values(pathMap).length} documents to archive` - ); - - for (const path of pathMap) { - const documentId = path[0].replace("/doc/", ""); - const pathInZip = path[1]; - - await this.processDocument({ - zip, - pathInZip, - documentId, - includeAttachments, - format, - pathMap, - }); - } - - Logger.debug("task", "Completed adding documents to archive"); + await this.addDocumentsToArchive({ + zip, + pathMap, + format, + includeAttachments, + }); return await ZipHelper.toTmpFile(zip); } @@ -200,28 +186,58 @@ export default abstract class ExportDocumentTreeTask extends ExportTask { format ); - Logger.debug( - "task", - `Start adding ${Object.values(pathMap).length} documents to archive` - ); + await this.addDocumentsToArchive({ + zip, + pathMap, + format, + includeAttachments: true, + }); - for (const entry of pathMap) { - const documentId = entry[0].replace("/doc/", ""); - const pathInZip = entry[1]; + return await ZipHelper.toTmpFile(zip); + } + + /** + * Processes each unique document in the path map and adds it to the zip. + * + * @param zip The yazl ZipFile to add files to + * @param pathMap Map of document urls to their path in the zip + * @param format The format to export in + * @param includeAttachments Whether to include attachments in the export + */ + private async addDocumentsToArchive({ + zip, + pathMap, + format, + includeAttachments, + }: { + zip: ZipFile; + pathMap: Map; + format: FileOperationFormat; + includeAttachments: boolean; + }) { + const processedPaths = new Set(); + + Logger.debug("task", `Start adding documents to archive`); + + for (const [url, pathInZip] of pathMap) { + // A document may be keyed by multiple urls in the path map, only + // process each file in the zip once. + if (processedPaths.has(pathInZip)) { + continue; + } + processedPaths.add(pathInZip); await this.processDocument({ zip, pathInZip, - documentId, - includeAttachments: true, + documentId: url.replace("/doc/", ""), + includeAttachments, format, pathMap, }); } Logger.debug("task", "Completed adding documents to archive"); - - return await ZipHelper.toTmpFile(zip); } /** diff --git a/server/queues/tasks/ExportMarkdownZipTask.test.ts b/server/queues/tasks/ExportMarkdownZipTask.test.ts new file mode 100644 index 0000000000..6a5a7704a0 --- /dev/null +++ b/server/queues/tasks/ExportMarkdownZipTask.test.ts @@ -0,0 +1,61 @@ +import fs from "fs-extra"; +import ZipHelper from "@server/utils/ZipHelper"; +import { + buildCollection, + buildDocument, + buildFileOperation, + buildTeam, + buildUser, +} from "@server/test/factories"; +import ExportMarkdownZipTask from "./ExportMarkdownZipTask"; + +describe("ExportMarkdownZipTask", () => { + it("should not duplicate documents in the zip file", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const collection = await buildCollection({ + teamId: team.id, + createdById: user.id, + }); + const documents = await Promise.all([ + buildDocument({ + teamId: team.id, + userId: user.id, + collectionId: collection.id, + title: "Test1", + }), + buildDocument({ + teamId: team.id, + userId: user.id, + collectionId: collection.id, + title: "Test2", + }), + ]); + for (const document of documents) { + await collection.addDocumentToStructure(document); + } + const fileOperation = await buildFileOperation({ + teamId: team.id, + userId: user.id, + }); + + const task = new ExportMarkdownZipTask(); + const filePath = await task.exportCollections([collection], fileOperation); + + try { + const fileNames: string[] = []; + await ZipHelper.walk(filePath, (entry) => { + if (!entry.isDirectory) { + fileNames.push(entry.fileName); + } + }); + + expect(fileNames.sort()).toEqual([ + `${collection.name}/Test1.md`, + `${collection.name}/Test2.md`, + ]); + } finally { + await fs.remove(filePath); + } + }); +});