Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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<Reading>; 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;
}
}