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:
+22
-2
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { exportFileName, type ExportFileNameInput } from "./filename";
|
||||
|
||||
const base: ExportFileNameInput = {
|
||||
number: "AP-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("AP-2026-001_Acme Ltd_2026-04-01.pdf");
|
||||
});
|
||||
|
||||
it("turns a slash in the invoice number into a dash", () => {
|
||||
expect(name({ number: "AP/2026-001" })).toBe("AP-2026-001_Acme Ltd_2026-04-01.pdf");
|
||||
});
|
||||
|
||||
it("adds -flat for the flattened export", () => {
|
||||
expect(name({ mode: "flattened" })).toBe("AP-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 AP-2026-001.pdf");
|
||||
expect(name({ pattern: "{number}" })).toBe("AP-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("AP-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("AP-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("AP-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("AP-2026-001_2026-04-01.pdf");
|
||||
expect(name({ client: "", date: "" })).toBe("AP-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(
|
||||
"AP-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("AP-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$/);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user