Use zip archives for company export
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
53
ui/src/lib/zip.test.ts
Normal file
53
ui/src/lib/zip.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
131
ui/src/lib/zip.ts
Normal file
131
ui/src/lib/zip.ts
Normal file
@@ -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<string, string>, 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]);
|
||||
}
|
||||
@@ -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<string, string>, 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<string>,
|
||||
effectiveFiles: Record<string, string>,
|
||||
@@ -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`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user