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