Add PDF archive store, raw-body IPC and export file helpers

The searchable PDF can be archived content-addressed under the local data
dir with its sha256 and layout fingerprint; a different PDF for an archived
invoice is refused and reads verify the hash. Export writes go through a
raw-body command that remembers the last folder; reveal-in-folder and open
fall back to xdg-open / explorer. Adds a Windows-safe export file name
builder. Migration M4 adds the archive columns and last export dir.
This commit is contained in:
2026-10-04 05:54:26 +05:30
parent 9e6df5ca8e
commit dff03ea210
17 changed files with 891 additions and 36 deletions
+22 -2
View File
@@ -10,6 +10,13 @@ import type {
Settings,
} from "./types";
export interface ArchiveStatus {
archived: boolean;
sha256?: string;
fingerprint?: string;
archivedAt?: string;
}
export const api = {
getSettings: () => invoke<Settings>("get_settings"),
saveSettings: (settings: Settings) => invoke<Settings>("save_settings", { settings }),
@@ -47,6 +54,19 @@ export const api = {
invoke<string>("save_asset_bytes", { kind, fileName, dataBase64 }),
readAssetDataUri: (path: string) => invoke<string>("read_asset_data_uri", { path }),
removeAsset: (path: string) => invoke<void>("remove_asset", { path }),
saveBinaryFile: (path: string, dataBase64: string) =>
invoke<void>("save_binary_file", { path, dataBase64 }),
// Raw-body commands: the payload is the bytes themselves (no base64); metadata goes in headers.
// Header values must be visible ASCII, hence the encodeURIComponent on paths.
archivePdf: (invoiceId: number, bytes: Uint8Array, fingerprint: string) =>
invoke<string>("archive_pdf", bytes, {
headers: { "x-invoice-id": String(invoiceId), "x-fingerprint": fingerprint },
}),
readArchive: async (invoiceId: number): Promise<Uint8Array> =>
new Uint8Array(await invoke<ArrayBuffer>("read_archive", { invoiceId })),
archiveStatus: (invoiceId: number) => invoke<ArchiveStatus>("archive_status", { invoiceId }),
writeExportFile: (path: string, bytes: Uint8Array) =>
invoke<string>("write_export_file", bytes, { headers: { "x-path": encodeURIComponent(path) } }),
getLastExportDir: () => invoke<string>("get_last_export_dir"),
revealInFolder: (path: string) => invoke<void>("reveal_in_folder", { path }),
openFile: (path: string) => invoke<void>("open_file", { path }),
};
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import { exportFileName, type ExportFileNameInput } from "./filename";
const base: ExportFileNameInput = {
number: "INV-2026-001",
client: "Acme Ltd",
date: "2026-04-01",
mode: "searchable",
};
const name = (over: Partial<ExportFileNameInput> = {}) => exportFileName({ ...base, ...over });
describe("exportFileName", () => {
it("uses number_client_date by default", () => {
expect(name()).toBe("INV-2026-001_Acme Ltd_2026-04-01.pdf");
});
it("turns a slash in the invoice number into a dash", () => {
expect(name({ number: "INV/2026-001" })).toBe("INV-2026-001_Acme Ltd_2026-04-01.pdf");
});
it("adds -flat for the flattened export", () => {
expect(name({ mode: "flattened" })).toBe("INV-2026-001_Acme Ltd_2026-04-01-flat.pdf");
});
it("honours a custom pattern", () => {
expect(name({ pattern: "{date} {client} {number}" })).toBe("2026-04-01 Acme Ltd INV-2026-001.pdf");
expect(name({ pattern: "{number}" })).toBe("INV-2026-001.pdf");
});
it("falls back to the default pattern for a blank pattern", () => {
expect(name({ pattern: " " })).toBe(name());
});
it("replaces every Windows-forbidden character", () => {
expect(name({ client: 'A<B>C:D"E/F\\G|H?I*J' })).toBe("INV-2026-001_A-B-C-D-E-F-G-H-I-J_2026-04-01.pdf");
});
it("replaces control characters", () => {
expect(name({ client: "A\u0000B\tC\nD\u007fE" })).toBe("INV-2026-001_A-B-C-D-E_2026-04-01.pdf");
});
it("collapses runs of separators and spaces", () => {
expect(name({ client: "A///B C___D" })).toBe("INV-2026-001_A-B C_D_2026-04-01.pdf");
});
it("trims dots and spaces from the ends", () => {
expect(name({ pattern: " {client}. . " })).toBe("Acme Ltd.pdf");
expect(name({ pattern: "{client}", client: "Acme Ltd. " })).toBe("Acme Ltd.pdf");
expect(name({ pattern: "{client}", client: "...hidden" })).toBe("hidden.pdf");
});
it("drops the separators left behind by empty parts", () => {
expect(name({ client: "" })).toBe("INV-2026-001_2026-04-01.pdf");
expect(name({ client: "", date: "" })).toBe("INV-2026-001.pdf");
});
it("prefixes reserved device names", () => {
expect(name({ pattern: "{client}", client: "CON" })).toBe("_CON.pdf");
expect(name({ pattern: "{client}", client: "nul" })).toBe("_nul.pdf");
expect(name({ pattern: "{client}", client: "Aux" })).toBe("_Aux.pdf");
expect(name({ pattern: "{client}", client: "PRN" })).toBe("_PRN.pdf");
});
it("prefixes COM1-9 and LPT1-9 but not COM10 or lookalikes", () => {
expect(name({ pattern: "{client}", client: "COM1" })).toBe("_COM1.pdf");
expect(name({ pattern: "{client}", client: "com9" })).toBe("_com9.pdf");
expect(name({ pattern: "{client}", client: "LPT3" })).toBe("_LPT3.pdf");
expect(name({ pattern: "{client}", client: "COM10" })).toBe("COM10.pdf");
expect(name({ pattern: "{client}", client: "CONSOLE" })).toBe("CONSOLE.pdf");
});
it("catches a reserved name that has an extension-like tail", () => {
expect(name({ pattern: "{client}", client: "CON.txt" })).toBe("_CON.txt.pdf");
});
it("keeps Indic client names intact", () => {
expect(name({ client: "சென்னை ஸ்டூடியோ" })).toBe(
"INV-2026-001_சென்னை ஸ்டூடியோ_2026-04-01.pdf",
);
expect(name({ client: "ਭਾਰਤ/ਕੰਪਨੀ" })).toContain("ਭਾਰਤ-ਕੰਪਨੀ");
});
it("normalises Unicode to NFC", () => {
const decomposed = "Café";
expect(name({ pattern: "{client}", client: decomposed })).toBe("Café.pdf");
});
it("caps the stem at 120 characters and cuts the client first", () => {
const out = name({ client: "x".repeat(300) });
const stem = out.slice(0, -".pdf".length);
expect(stem.length).toBeLessThanOrEqual(120);
expect(out.startsWith("INV-2026-001_x")).toBe(true);
expect(out.endsWith("_2026-04-01.pdf")).toBe(true);
});
it("keeps the number whole even when it alone is long", () => {
const number = "N".repeat(110);
const out = name({ number, client: "y".repeat(200) });
expect(out.startsWith(number)).toBe(true);
expect(out.length).toBeLessThanOrEqual(120 + ".pdf".length);
});
it("hard-caps an absurdly long number", () => {
const out = name({ number: "9".repeat(500) });
expect(out.length).toBeLessThanOrEqual(120 + ".pdf".length);
});
it("does not split a surrogate pair when truncating", () => {
const out = name({ pattern: "{client}", client: "\u{1F600}".repeat(100) });
const stem = out.slice(0, -".pdf".length);
expect(stem.length).toBeLessThanOrEqual(120);
expect([...stem].every((ch) => ch === "\u{1F600}")).toBe(true);
});
it("adds -flat after truncation", () => {
const out = name({ client: "z".repeat(300), mode: "flattened" });
expect(out.endsWith("_2026-04-01-flat.pdf")).toBe(true);
});
it("uses a non-empty fallback", () => {
expect(name({ number: "", client: "", date: "" })).toBe("invoice.pdf");
expect(name({ pattern: "...", number: "x" })).toBe("invoice.pdf");
expect(name({ number: "///", client: "***", date: "|" })).toBe("invoice.pdf");
expect(name({ number: "", client: "", date: "", mode: "flattened" })).toBe("invoice-flat.pdf");
});
it("the result never ends in a dot or space", () => {
const out = name({ client: "Trailing dot.", date: "" });
expect(out).not.toMatch(/[. ]\.pdf$/);
});
});
+71
View File
@@ -0,0 +1,71 @@
export type ExportMode = "searchable" | "flattened";
export interface ExportFileNameInput {
/** Tokens: {number}, {client}, {date}. */
pattern?: string;
number: string;
client: string;
date: string;
mode: ExportMode;
}
const DEFAULT_PATTERN = "{number}_{client}_{date}";
const MAX_STEM = 120;
const FALLBACK = "invoice";
// Characters Windows forbids in file names, plus control characters.
// eslint-disable-next-line no-control-regex
const FORBIDDEN = /[<>:"/\\|?*\u0000-\u001f\u007f]/g;
// The device name matters even with an extension ("CON.txt" is still the console).
const RESERVED = /^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])$/i;
const EDGE_JUNK = /^[.\s_-]+|[.\s_-]+$/g;
function collapse(value: string): string {
return value
.replace(FORBIDDEN, "-")
.replace(/\s+/g, " ")
.replace(/-{2,}/g, "-")
.replace(/_{2,}/g, "_")
.replace(/ ?([-_]) ?/g, "$1");
}
function cleanPart(value: string): string {
return collapse(value.normalize("NFC")).replace(EDGE_JUNK, "");
}
/** Cut to at most `max` UTF-16 units without splitting a surrogate pair. */
function truncate(value: string, max: number): string {
let out = "";
for (const ch of value) {
if (out.length + ch.length > max) break;
out += ch;
}
return out;
}
function fill(pattern: string, parts: { number: string; client: string; date: string }): string {
return pattern.replace(/\{(number|client|date)\}/g, (_, key: keyof typeof parts) => parts[key]);
}
/** A file name that is valid on Windows, macOS and Linux for an exported invoice PDF. */
export function exportFileName(input: ExportFileNameInput): string {
const pattern = input.pattern?.trim() ? input.pattern : DEFAULT_PATTERN;
const parts = {
number: cleanPart(input.number),
client: cleanPart(input.client),
date: cleanPart(input.date),
};
let stem = fill(pattern, parts);
if (stem.length > MAX_STEM) {
// The invoice number is what identifies the file, so the client name gives way first.
const overflow = stem.length - MAX_STEM;
const client = truncate(parts.client, Math.max(0, parts.client.length - overflow));
stem = fill(pattern, { ...parts, client });
}
stem = cleanPart(truncate(cleanPart(stem), MAX_STEM));
if (!stem) stem = FALLBACK;
if (RESERVED.test(stem.split(".")[0])) stem = `_${stem}`;
return `${stem}${input.mode === "flattened" ? "-flat" : ""}.pdf`;
}
+1 -8
View File
@@ -10,13 +10,6 @@ import { imageSizeFromDataUri } from "./imageSize";
import { getRenderClient, nextRenderJobId } from "./renderClient";
import type { BankAccount, Invoice, Settings } from "./types";
function bytesToBase64(bytes: Uint8Array): string {
let bin = "";
const step = 0x8000;
for (let i = 0; i < bytes.length; i += step) bin += String.fromCharCode(...bytes.subarray(i, i + step));
return btoa(bin);
}
/** A logo with no readable size is fitted as if it filled Classic's 170 x 54 box. */
const UNKNOWN_LOGO_SIZE = { width: 170, height: 54 };
@@ -82,7 +75,7 @@ export async function savePdfBytes(bytes: Uint8Array, fileName: string): Promise
filters: [{ name: "PDF document", extensions: ["pdf"] }],
});
if (!path) return null;
await api.saveBinaryFile(path, bytesToBase64(bytes));
await api.writeExportFile(path, bytes);
return path;
}
+5
View File
@@ -25,6 +25,8 @@ export interface Settings {
signatoryDesignation: string;
/** The user's default page setup (RenderPrefsV1 JSON); empty means the built-in defaults. */
renderPrefs: string;
/** Folder of the last export; kept by the backend (saving settings does not change it). */
lastExportDir?: string;
}
export interface BankAccount {
@@ -136,6 +138,9 @@ export interface Invoice {
archivedPdfSha256: string | null;
/** RenderPrefsV1 JSON frozen at issue; empty when none was stored. */
renderPrefs: string;
/** Layout fingerprint of the archived PDF; null when nothing is archived. */
archivedFingerprint?: string | null;
archivedAt?: string | null;
items: InvoiceItem[];
}