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:
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user