import { spawn } from "child_process"; import { readFile, writeFile, mkdir, rm } from "fs/promises"; import { join } from "path"; import { randomUUID } from "crypto"; import { uploadFile, downloadFile } from "./storage"; const BOOKMARK_INJECTOR_PROJECT = process.env.BOOKMARK_INJECTOR_PROJECT || join(__dirname, "../../tools/docx-bookmark-injector/docx-bookmark-injector.csproj"); export interface BookmarkSidecar { bookmarks: Record< string, { paragraph_id: string; native_para_id: string | null; container: "body" | "table_cell"; table_index?: number; row_index?: number; cell_index?: number; text_preview: string; } >; } export async function injectBookmarks( inputDocxPath: string, workDir: string, ): Promise<{ outputDocxPath: string; sidecar: BookmarkSidecar }> { const outputDocxPath = join(workDir, `bookmarked_${randomUUID().replace(/-/g, "")}.docx`); const sidecarPath = join(workDir, `bookmark_sidecar_${randomUUID().replace(/-/g, "")}.json`); const command = [ "dotnet", "run", "--project", BOOKMARK_INJECTOR_PROJECT, "--", "--input", inputDocxPath, "--output", outputDocxPath, "--sidecar", sidecarPath, ]; const result = await runCommand(command); if (result.exitCode !== 0) { throw new Error(`Bookmark injector failed: ${result.stderr || result.stdout}`); } const sidecarRaw = await readFile(sidecarPath, "utf-8"); const sidecar: BookmarkSidecar = JSON.parse(sidecarRaw); return { outputDocxPath, sidecar }; } export async function storeBookmarkSidecar( userId: string, documentId: string, sidecar: BookmarkSidecar, ): Promise { const storagePath = `documents/${userId}/${documentId}/bookmark_sidecar.json`; const bytes = Buffer.from(JSON.stringify(sidecar), "utf-8"); await uploadFile( storagePath, bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer, "application/json", ); return storagePath; } export async function loadBookmarkSidecar( userId: string, documentId: string, ): Promise { const storagePath = `documents/${userId}/${documentId}/bookmark_sidecar.json`; const bytes = await downloadFile(storagePath); if (!bytes) return null; return JSON.parse(Buffer.from(bytes).toString("utf-8")) as BookmarkSidecar; } function runCommand( command: string[], ): Promise<{ exitCode: number; stdout: string; stderr: string }> { return new Promise((resolve) => { const proc = spawn(command[0], command.slice(1)); let stdout = ""; let stderr = ""; proc.stdout.on("data", (d: Buffer) => (stdout += d.toString())); proc.stderr.on("data", (d: Buffer) => (stderr += d.toString())); proc.on("close", (code) => resolve({ exitCode: code ?? 1, stdout, stderr }), ); setTimeout(() => { proc.kill("SIGKILL"); resolve({ exitCode: 1, stdout, stderr: stderr + "\n[TIMEOUT] Process killed after 120 seconds" }); }, 120_000); }); }