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.
72 lines
2.4 KiB
TypeScript
72 lines
2.4 KiB
TypeScript
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`;
|
|
}
|