/** * Self-numbering text columns. * * A pattern like `INV-{yyyy}-{nnn}` fills the column whenever a row arrives * with the cell empty. The counter is never stored anywhere: the next number * is read off the rows already in the column, among those that match the rest * of the pattern as it renders today. That is also what makes the numbering * controllable — put `{yyyy}` (or `{q}`, or `{mm}`) in the pattern and the * count starts over when the year turns, because nothing older matches any * more. Typing a value by hand always wins; the pattern only speaks for cells * left empty. */ export const SEQUENCE_MAX_LENGTH = 60; /** The tokens a pattern may carry; everything else in it is literal text. */ const TOKEN = /\{(yyyy|yy|mm|q|n{1,6})\}/g; type Part = | { kind: 'literal'; text: string } | { kind: 'date'; token: 'yyyy' | 'yy' | 'mm' | 'q' } | { kind: 'counter'; width: number }; function parts(pattern: string): Part[] { const out: Part[] = []; let last = 0; for (const m of pattern.matchAll(TOKEN)) { if (m.index! > last) out.push({ kind: 'literal', text: pattern.slice(last, m.index) }); const token = m[1]!; if (token.startsWith('n')) out.push({ kind: 'counter', width: token.length }); else out.push({ kind: 'date', token: token as 'yyyy' | 'yy' | 'mm' | 'q' }); last = m.index! + m[0].length; } if (last < pattern.length) out.push({ kind: 'literal', text: pattern.slice(last) }); return out; } /** Why a pattern cannot be a numbering, or null when it can. */ export function validateSequence(pattern: string): string | null { if (!pattern.trim()) return 'A numbering pattern cannot be empty.'; if (pattern.length > SEQUENCE_MAX_LENGTH) { return `Numbering patterns are limited to ${SEQUENCE_MAX_LENGTH} characters.`; } // A brace that is not a known token is a typo, not a literal — accepting it // would number nothing and say nothing. const leftover = pattern.replace(TOKEN, '').match(/\{[^}]*\}|[{}]/); if (leftover) { return `"${leftover[0]}" is not a token. The counter is {n} ({nnn} to pad); {yyyy} {yy} {mm} {q} are today's date.`; } const counters = parts(pattern).filter((p) => p.kind === 'counter'); if (counters.length === 0) { return 'The pattern needs a counter: {n}, or {nnn} to pad it to three digits.'; } if (counters.length > 1) { return 'One counter only — a second {n} would have to hold the same number.'; } return null; } function renderDate(token: 'yyyy' | 'yy' | 'mm' | 'q', date: Date): string { switch (token) { case 'yyyy': return String(date.getFullYear()); case 'yy': return String(date.getFullYear() % 100).padStart(2, '0'); case 'mm': return String(date.getMonth() + 1).padStart(2, '0'); case 'q': return String(Math.floor(date.getMonth() / 3) + 1); } } /** The pattern with its counter at `n` — what the cell would actually hold. */ export function renderSequence(pattern: string, n: number, date: Date): string { return parts(pattern) .map((p) => { if (p.kind === 'literal') return p.text; if (p.kind === 'date') return renderDate(p.token, date); // Padding is a floor, not a ceiling: {nnn} writes 001 and, when the // column outgrows it, 1000 — a counter must never wrap back to 000. return String(n).padStart(p.width, '0'); }) .join(''); } /** * The next value the pattern would write, given what the column holds now. * * Only values matching the pattern as rendered for `date` take part — that is * the whole scoping mechanism. Hand-typed values that do not match are simply * outside the numbering, and the highest matching counter plus one keeps the * sequence monotonic even when rows in the middle have been deleted. */ export function nextSequenceValue( pattern: string, existing: readonly string[], date: Date, ): string { const regex = new RegExp( '^' + parts(pattern) .map((p) => { if (p.kind === 'literal') return p.text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); if (p.kind === 'date') return renderDate(p.token, date).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return '(\\d+)'; }) .join('') + '$', ); let highest = 0; for (const value of existing) { const m = regex.exec(value); if (m) highest = Math.max(highest, parseInt(m[1]!, 10)); } return renderSequence(pattern, highest + 1, date); }