From 617aeaae0e84e4e46eb4c8817d0ee360769f6751 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 16 Mar 2026 08:54:50 -0500 Subject: [PATCH] Use zip archives for company export Co-Authored-By: Paperclip --- ui/src/lib/zip.test.ts | 53 +++++++++++++ ui/src/lib/zip.ts | 131 +++++++++++++++++++++++++++++++++ ui/src/pages/CompanyExport.tsx | 76 +++---------------- 3 files changed, 193 insertions(+), 67 deletions(-) create mode 100644 ui/src/lib/zip.test.ts create mode 100644 ui/src/lib/zip.ts diff --git a/ui/src/lib/zip.test.ts b/ui/src/lib/zip.test.ts new file mode 100644 index 00000000..6c36492d --- /dev/null +++ b/ui/src/lib/zip.test.ts @@ -0,0 +1,53 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import { createZipArchive } from "./zip"; + +function readUint16(bytes: Uint8Array, offset: number) { + return bytes[offset]! | (bytes[offset + 1]! << 8); +} + +function readUint32(bytes: Uint8Array, offset: number) { + return ( + bytes[offset]! | + (bytes[offset + 1]! << 8) | + (bytes[offset + 2]! << 16) | + (bytes[offset + 3]! << 24) + ) >>> 0; +} + +function readString(bytes: Uint8Array, offset: number, length: number) { + return new TextDecoder().decode(bytes.slice(offset, offset + length)); +} + +describe("createZipArchive", () => { + it("writes a zip archive with the export root path prefixed into each entry", () => { + const archive = createZipArchive( + { + "COMPANY.md": "# Company\n", + "agents/ceo/AGENTS.md": "# CEO\n", + }, + "paperclip-demo", + ); + + expect(readUint32(archive, 0)).toBe(0x04034b50); + + const firstNameLength = readUint16(archive, 26); + const firstBodyLength = readUint32(archive, 18); + expect(readString(archive, 30, firstNameLength)).toBe("paperclip-demo/agents/ceo/AGENTS.md"); + expect(readString(archive, 30 + firstNameLength, firstBodyLength)).toBe("# CEO\n"); + + const secondOffset = 30 + firstNameLength + firstBodyLength; + expect(readUint32(archive, secondOffset)).toBe(0x04034b50); + + const secondNameLength = readUint16(archive, secondOffset + 26); + const secondBodyLength = readUint32(archive, secondOffset + 18); + expect(readString(archive, secondOffset + 30, secondNameLength)).toBe("paperclip-demo/COMPANY.md"); + expect(readString(archive, secondOffset + 30 + secondNameLength, secondBodyLength)).toBe("# Company\n"); + + const endOffset = archive.length - 22; + expect(readUint32(archive, endOffset)).toBe(0x06054b50); + expect(readUint16(archive, endOffset + 8)).toBe(2); + expect(readUint16(archive, endOffset + 10)).toBe(2); + }); +}); diff --git a/ui/src/lib/zip.ts b/ui/src/lib/zip.ts new file mode 100644 index 00000000..7f677f65 --- /dev/null +++ b/ui/src/lib/zip.ts @@ -0,0 +1,131 @@ +const textEncoder = new TextEncoder(); + +const crcTable = new Uint32Array(256); +for (let i = 0; i < 256; i++) { + let crc = i; + for (let bit = 0; bit < 8; bit++) { + crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + } + crcTable[i] = crc >>> 0; +} + +function normalizeArchivePath(pathValue: string) { + return pathValue + .replace(/\\/g, "/") + .split("/") + .filter(Boolean) + .join("/"); +} + +function crc32(bytes: Uint8Array) { + let crc = 0xffffffff; + for (const byte of bytes) { + crc = (crc >>> 8) ^ crcTable[(crc ^ byte) & 0xff]!; + } + return (crc ^ 0xffffffff) >>> 0; +} + +function writeUint16(target: Uint8Array, offset: number, value: number) { + target[offset] = value & 0xff; + target[offset + 1] = (value >>> 8) & 0xff; +} + +function writeUint32(target: Uint8Array, offset: number, value: number) { + target[offset] = value & 0xff; + target[offset + 1] = (value >>> 8) & 0xff; + target[offset + 2] = (value >>> 16) & 0xff; + target[offset + 3] = (value >>> 24) & 0xff; +} + +function getDosDateTime(date: Date) { + const year = Math.min(Math.max(date.getFullYear(), 1980), 2107); + const month = date.getMonth() + 1; + const day = date.getDate(); + const hours = date.getHours(); + const minutes = date.getMinutes(); + const seconds = Math.floor(date.getSeconds() / 2); + + return { + time: (hours << 11) | (minutes << 5) | seconds, + date: ((year - 1980) << 9) | (month << 5) | day, + }; +} + +function concatChunks(chunks: Uint8Array[]) { + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const archive = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + archive.set(chunk, offset); + offset += chunk.length; + } + return archive; +} + +export function createZipArchive(files: Record, rootPath: string): Uint8Array { + const normalizedRoot = normalizeArchivePath(rootPath); + const localChunks: Uint8Array[] = []; + const centralChunks: Uint8Array[] = []; + const archiveDate = getDosDateTime(new Date()); + let localOffset = 0; + let entryCount = 0; + + for (const [relativePath, contents] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) { + const archivePath = normalizeArchivePath(`${normalizedRoot}/${relativePath}`); + const fileName = textEncoder.encode(archivePath); + const body = textEncoder.encode(contents); + const checksum = crc32(body); + + const localHeader = new Uint8Array(30 + fileName.length); + writeUint32(localHeader, 0, 0x04034b50); + writeUint16(localHeader, 4, 20); + writeUint16(localHeader, 6, 0x0800); + writeUint16(localHeader, 8, 0); + writeUint16(localHeader, 10, archiveDate.time); + writeUint16(localHeader, 12, archiveDate.date); + writeUint32(localHeader, 14, checksum); + writeUint32(localHeader, 18, body.length); + writeUint32(localHeader, 22, body.length); + writeUint16(localHeader, 26, fileName.length); + writeUint16(localHeader, 28, 0); + localHeader.set(fileName, 30); + + const centralHeader = new Uint8Array(46 + fileName.length); + writeUint32(centralHeader, 0, 0x02014b50); + writeUint16(centralHeader, 4, 20); + writeUint16(centralHeader, 6, 20); + writeUint16(centralHeader, 8, 0x0800); + writeUint16(centralHeader, 10, 0); + writeUint16(centralHeader, 12, archiveDate.time); + writeUint16(centralHeader, 14, archiveDate.date); + writeUint32(centralHeader, 16, checksum); + writeUint32(centralHeader, 20, body.length); + writeUint32(centralHeader, 24, body.length); + writeUint16(centralHeader, 28, fileName.length); + writeUint16(centralHeader, 30, 0); + writeUint16(centralHeader, 32, 0); + writeUint16(centralHeader, 34, 0); + writeUint16(centralHeader, 36, 0); + writeUint32(centralHeader, 38, 0); + writeUint32(centralHeader, 42, localOffset); + centralHeader.set(fileName, 46); + + localChunks.push(localHeader, body); + centralChunks.push(centralHeader); + localOffset += localHeader.length + body.length; + entryCount += 1; + } + + const centralDirectory = concatChunks(centralChunks); + const endOfCentralDirectory = new Uint8Array(22); + writeUint32(endOfCentralDirectory, 0, 0x06054b50); + writeUint16(endOfCentralDirectory, 4, 0); + writeUint16(endOfCentralDirectory, 6, 0); + writeUint16(endOfCentralDirectory, 8, entryCount); + writeUint16(endOfCentralDirectory, 10, entryCount); + writeUint32(endOfCentralDirectory, 12, centralDirectory.length); + writeUint32(endOfCentralDirectory, 16, localOffset); + writeUint16(endOfCentralDirectory, 20, 0); + + return concatChunks([...localChunks, centralDirectory, endOfCentralDirectory]); +} diff --git a/ui/src/pages/CompanyExport.tsx b/ui/src/pages/CompanyExport.tsx index 76f9d69b..555d5335 100644 --- a/ui/src/pages/CompanyExport.tsx +++ b/ui/src/pages/CompanyExport.tsx @@ -10,6 +10,7 @@ import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { MarkdownBody } from "../components/MarkdownBody"; import { cn } from "../lib/utils"; +import { createZipArchive } from "../lib/zip"; import { ChevronDown, ChevronRight, @@ -329,66 +330,7 @@ function paginateTaskNodes( return { nodes: result, totalTaskChildren, visibleTaskChildren }; } -// ── Tar helpers (reused from CompanySettings) ───────────────────────── - -function createTarArchive(files: Record, rootPath: string): Uint8Array { - const encoder = new TextEncoder(); - const chunks: Uint8Array[] = []; - for (const [relativePath, contents] of Object.entries(files)) { - const tarPath = `${rootPath}/${relativePath}`.replace(/\\/g, "/"); - const body = encoder.encode(contents); - chunks.push(buildTarHeader(tarPath, body.length)); - chunks.push(body); - const remainder = body.length % 512; - if (remainder > 0) chunks.push(new Uint8Array(512 - remainder)); - } - chunks.push(new Uint8Array(1024)); - const totalLength = chunks.reduce((sum, c) => sum + c.length, 0); - const archive = new Uint8Array(totalLength); - let offset = 0; - for (const chunk of chunks) { - archive.set(chunk, offset); - offset += chunk.length; - } - return archive; -} - -function buildTarHeader(pathname: string, size: number): Uint8Array { - const header = new Uint8Array(512); - writeTarString(header, 0, 100, pathname); - writeTarOctal(header, 100, 8, 0o644); - writeTarOctal(header, 108, 8, 0); - writeTarOctal(header, 116, 8, 0); - writeTarOctal(header, 124, 12, size); - writeTarOctal(header, 136, 12, Math.floor(Date.now() / 1000)); - for (let i = 148; i < 156; i++) header[i] = 32; - header[156] = "0".charCodeAt(0); - writeTarString(header, 257, 6, "ustar"); - writeTarString(header, 263, 2, "00"); - const checksum = header.reduce((sum, byte) => sum + byte, 0); - writeTarChecksum(header, checksum); - return header; -} - -function writeTarString(target: Uint8Array, offset: number, length: number, value: string) { - const encoded = new TextEncoder().encode(value); - target.set(encoded.slice(0, length), offset); -} - -function writeTarOctal(target: Uint8Array, offset: number, length: number, value: number) { - const stringValue = value.toString(8).padStart(length - 1, "0"); - writeTarString(target, offset, length - 1, stringValue); - target[offset + length - 1] = 0; -} - -function writeTarChecksum(target: Uint8Array, checksum: number) { - const stringValue = checksum.toString(8).padStart(6, "0"); - writeTarString(target, 148, 6, stringValue); - target[154] = 0; - target[155] = 32; -} - -function downloadTar( +function downloadZip( exported: CompanyPortabilityExportResult, selectedFiles: Set, effectiveFiles: Record, @@ -397,14 +339,14 @@ function downloadTar( for (const [path] of Object.entries(exported.files)) { if (selectedFiles.has(path)) filteredFiles[path] = effectiveFiles[path] ?? exported.files[path]; } - const tarBytes = createTarArchive(filteredFiles, exported.rootPath); - const tarBuffer = new ArrayBuffer(tarBytes.byteLength); - new Uint8Array(tarBuffer).set(tarBytes); - const blob = new Blob([tarBuffer], { type: "application/x-tar" }); + const zipBytes = createZipArchive(filteredFiles, exported.rootPath); + const zipBuffer = new ArrayBuffer(zipBytes.byteLength); + new Uint8Array(zipBuffer).set(zipBytes); + const blob = new Blob([zipBuffer], { type: "application/zip" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; - anchor.download = `${exported.rootPath}.tar`; + anchor.download = `${exported.rootPath}.zip`; document.body.appendChild(anchor); anchor.click(); anchor.remove(); @@ -893,11 +835,11 @@ export function CompanyExport() { function handleDownload() { if (!exportData) return; - downloadTar(exportData, checkedFiles, effectiveFiles); + downloadZip(exportData, checkedFiles, effectiveFiles); pushToast({ tone: "success", title: "Export downloaded", - body: `${selectedCount} file${selectedCount === 1 ? "" : "s"} exported as ${exportData.rootPath}.tar`, + body: `${selectedCount} file${selectedCount === 1 ? "" : "s"} exported as ${exportData.rootPath}.zip`, }); }