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
+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`;
}