import { isValidDate, type Reading } from '@wellformed/shared'; /** * Reading values the way they were written, not the way SQL spells them. * * Shared between the importer and the tightening dry run on purpose: a file * has to mean the same thing whether it is being imported today or tightened * next month, so there is exactly one answer to "is `1.234,50` a number" and * both ask it here. */ /** * Which mark separates the decimals, and which end of a slashed date the day is * on, guessed from the column as a whole. * * `1.234` is a thousand in Amsterdam and one-and-a-bit in Chicago; `03/04/2026` * is April in one and March in the other. Neither is decidable from a single * value, and both are decidable from a column often enough to be worth trying — * one `31/12/2025` settles a thousand ambiguous rows above it. Where the column * does not settle it, this says so and the operator does. */ export function inferReading(texts: string[]): { reading: Required; ambiguousDates: boolean } { let commaVotes = 0; let dotVotes = 0; let dayVotes = 0; let monthVotes = 0; let slashed = 0; for (const raw of texts) { const s = raw.trim(); if (!s) continue; // `\s` already covers the non-breaking space a spreadsheet groups with. const stripped = s.replace(/[\s$€£¥₹()-]/g, ''); const lastDot = stripped.lastIndexOf('.'); const lastComma = stripped.lastIndexOf(','); if (lastDot >= 0 && lastComma >= 0) { // Both marks present: the rightmost one is the decimal, always. if (lastComma > lastDot) commaVotes++; else dotVotes++; } else if (lastComma >= 0 && /^\d+,\d{1,2}$/.test(stripped)) { // One comma with one or two digits after it is money, not a thousand. commaVotes++; } else if (lastDot >= 0 && /^\d+\.\d{1,2}$/.test(stripped) && !/^\d{1,3}\.\d{3}$/.test(stripped)) { dotVotes++; } const parts = /^(\d{1,2})[-/.](\d{1,2})[-/.](\d{2}|\d{4})$/.exec(s); if (parts) { slashed++; const first = Number(parts[1]); const second = Number(parts[2]); if (first > 12 && second <= 12) dayVotes++; else if (second > 12 && first <= 12) monthVotes++; } } return { reading: { // A tie goes to the dot, which is what every other number in this product // is already written with. decimal: commaVotes > dotVotes ? ',' : '.', dayFirst: monthVotes > dayVotes ? false : true, }, // Dates in the file, and nothing in the file that decides which way round. ambiguousDates: slashed > 0 && dayVotes === 0 && monthVotes === 0, }; } /** Read a number out of however it was written. Null when it is not one. */ export function readNumber(text: string, decimal: '.' | ','): number | null { let s = text.trim(); if (!s) return null; // `(1,200.00)` is how a ledger export writes a negative. let negative = false; if (/^\(.*\)$/.test(s)) { negative = true; s = s.slice(1, -1); } s = s.replace(/[\s$€£¥₹]/g, ''); if (s.startsWith('-')) { negative = !negative; s = s.slice(1); } else if (s.startsWith('+')) { s = s.slice(1); } /** * The other mark is only allowed where a thousands separator is allowed — * every three digits — so reading the column the wrong way round fails * loudly instead of turning `1.234,50` into 1.2345 and saying nothing. A * silent wrong number is the one outcome this whole module exists to avoid. */ const plain = decimal === ',' ? /^\d+(,\d+)?$/ : /^\d+(\.\d+)?$/; const grouped = decimal === ',' ? /^\d{1,3}(\.\d{3})+(,\d+)?$/ : /^\d{1,3}(,\d{3})+(\.\d+)?$/; if (!plain.test(s) && !grouped.test(s)) return null; s = decimal === ',' ? s.replace(/\./g, '').replace(',', '.') : s.replace(/,/g, ''); const n = Number(s); if (!Number.isFinite(n)) return null; return negative ? -n : n; } /** Read a date out of however it was written. Null when it is not one. */ export function readDate(text: string, dayFirst: boolean): string | null { // An exported timestamp is a date with a midnight nobody meant. const s = text.trim().replace(/[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?Z?$/, ''); const iso = /^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/.exec(s); if (iso) return build(Number(iso[1]), Number(iso[2]), Number(iso[3])); const parts = /^(\d{1,2})[-/.](\d{1,2})[-/.](\d{2}|\d{4})$/.exec(s); if (parts) { const a = Number(parts[1]); const b = Number(parts[2]); /** * One reading for the whole column, applied to every value in it. * * Letting a `22/04` quietly read day-first inside a month-first column * would convert every row and leave two different calendars in one column, * which is the silent wrong number this module exists to avoid. So a value * the chosen reading cannot take is reported as one that will not convert — * and since the reading was guessed from the unambiguous values in the * first place, that only happens when somebody overrules the guess, which * is exactly when they should hear about it. */ return build(year(parts[3]!), dayFirst ? b : a, dayFirst ? a : b); } return null; function build(y: number, m: number, d: number): string | null { const value = `${String(y).padStart(4, '0')}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`; return isValidDate(value) ? value : null; } /** Two digits: the same window every spreadsheet uses. */ function year(raw: string): number { const n = Number(raw); if (raw.length === 4) return n; return n < 70 ? 2000 + n : 1900 + n; } }