Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import type {
ColumnType,
ImportColumnOverride,
ImportFormat,
ImportGroupOverride,
ImportNotes,
Reading,
} from '@wellformed/shared';
import { slugifyColumnName } from '@wellformed/shared';
import { badRequest } from '../errors.js';
import { inferReading, readDate, readNumber } from './reading.js';
import { readWorkbook, WorkbookError } from './workbook.js';
/**
* Getting a spreadsheet in.
*
* This is the highest-leverage thing in the product's onboarding and also the
* easiest place to be quietly wrong, so the rules are explicit:
*
* - Whatever they have is what they bring. XLSX and ODS are unpacked here as
* readily as CSV — see `workbook.ts` — because "export it as CSV first" asks
* someone to do work before they are allowed to find out whether this is any
* good, and plenty of them will simply not bother.
* - Whichever format it arrived in, it becomes a grid of strings and takes the
* same path from there. One header-finder, one type inference, one set of
* tests; a workbook that had its own would drift from the CSV one by the
* second bug fix.
* - The delimiter is **detected**, not assumed. Half of Europe exports CSV with
* semicolons, and a file that imports as one giant column reads as "this
* product is broken" rather than "wrong delimiter".
* - Quoting follows RFC 4180: quotes protect delimiters and newlines, and a
* doubled quote inside a quoted field is one quote.
* - Types are inferred from **every** value in the column, not the first few. A
* column that is numeric for 900 rows and then says "n/a" is a text column,
* and finding that out on row 901 is how imports lose data.
* - A header becomes the column's *label*, word for word — "Prijs per stuk"
* stays "Prijs per stuk". The SQL identifier is derived from it, the same
* derivation the column dialog shows, so no header is ever illegal.
*
* Real exports are not tidy. They open with a title and a "generated on" line,
* they breathe with blank rows, and they end their rows with the separators of
* columns that were deleted in Excel years ago. None of that is the operator's
* mistake to correct by hand in a text editor, so the parser skips past it —
* and counts what it skipped, because a silent drop is indistinguishable from a
* bug when the numbers come out wrong later.
*/
export const MAX_IMPORT_ROWS = 5_000;
export const MAX_IMPORT_COLUMNS = 60;
/** The identifier rules' own ceiling, which a rewritten header has to fit inside. */
const MAX_COLUMN_NAME = 48;
/** The catalogue's label ceiling, which a header has to be cut to fit. */
const MAX_COLUMN_LABEL = 60;
const DELIMITERS = [',', ';', '\t', '|'] as const;
/** Lines a delimiter is judged on. Enough to see a pattern, not the whole file. */
const SNIFF_LINES = 40;
/**
* The delimiter that cuts the file into the widest consistent grid.
*
* Judged on a sample of lines rather than on the first one, because the first
* line is so often a title: "Verkooprapport Q3" contains no semicolon at all
* and would vote for a comma out of a file that has none either.
*
* The winner is the delimiter that most lines *agree* about — a real separator
* produces the same field count on line after line, while a stray comma inside
* prose produces a different count each time.
*/
export function detectDelimiter(text: string): string {
const lines = text.split('\n').slice(0, SNIFF_LINES).filter((l) => l.trim() !== '');
let best = ',';
let bestScore = 0;
for (const candidate of DELIMITERS) {
// Counted through the parser, so a comma inside "Smith, Jane" does not vote.
const counts = lines.map((line) => parseLine(line, candidate).length);
const width = modeOf(counts.filter((n) => n > 1));
if (width < 2) continue;
// Width first, agreement as the tiebreak: a file of "a;b,c" is two columns
// of semicolon before it is anything else.
const agreeing = counts.filter((n) => n === width).length;
const score = width * 1000 + agreeing;
if (score > bestScore) {
best = candidate;
bestScore = score;
}
}
return best;
}
/** The most common value, ties going to the largest. */
function modeOf(values: number[]): number {
const tally = new Map<number, number>();
for (const v of values) tally.set(v, (tally.get(v) ?? 0) + 1);
let best = 0;
let bestSeen = 0;
for (const [value, seen] of tally) {
if (seen > bestSeen || (seen === bestSeen && value > best)) {
best = value;
bestSeen = seen;
}
}
return best;
}
function parseLine(line: string, delimiter: string): string[] {
return parseCsv(line, delimiter)[0] ?? [];
}
/**
* Parse a whole document into rows of raw strings.
*
* Blank rows are kept here and thrown away by `parseImport`, so that deciding
* what counts as junk — and reporting how much of it there was — happens in one
* place rather than being split between the two.
*/
export function parseCsv(text: string, delimiter: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = '';
let quoted = false;
let started = false;
// A byte-order mark on the first header would become part of the first name.
const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
const endField = () => {
row.push(field);
field = '';
started = false;
};
const endRow = () => {
endField();
rows.push(row);
row = [];
};
for (let i = 0; i < body.length; i++) {
const char = body[i]!;
if (quoted) {
if (char === '"') {
if (body[i + 1] === '"') {
field += '"';
i++;
} else {
quoted = false;
}
} else {
field += char;
}
continue;
}
if (char === '"' && !started) {
quoted = true;
started = true;
continue;
}
if (char === delimiter) {
endField();
continue;
}
if (char === '\r') continue;
if (char === '\n') {
endRow();
continue;
}
field += char;
started = true;
}
if (field !== '' || row.length > 0) endRow();
return rows;
}
export interface ImportColumn {
/** The identifier the column gets — or the existing column's, when appending. */
name: string;
/** What the column will be called, taken from the file unless overridden. */
label: string;
/** What the file called it, so a correction can say what it corrected. */
header: string;
/** The group heading this column sits under, or null outside any group. */
group: string | null;
type: ColumnType;
/** Values that will not read as the type — only ever non-zero under an override. */
unreadable: number;
/** The first value that would not read, so the count points at something. */
problem: string | null;
}
export interface ParsedImport {
columns: ImportColumn[];
/** One record per data row, keyed by column name, already coerced. */
rows: Record<string, string | number | boolean | null>[];
format: ImportFormat;
/** What cut the text into columns, and `null` when a workbook did. */
delimiter: string | null;
/** The sheet the rows came from, and `null` for delimited text. */
sheet: string | null;
/** Every sheet in the workbook, in tab order, to choose another from. */
sheets: string[];
/** Rows dropped for being past the cap, so the caller can say so. */
skipped: number;
/** What was thrown away as junk, so the preview can own up to it. */
ignored: ImportNotes;
/** The row the column names came from, guessed unless it was given. */
headerRow: number;
/**
* The row whose headings were folded in front of the column names — the
* file's merged group cells, "Q1" over Omzet and Kosten — or null when no
* row was used that way.
*/
groupRow: number | null;
/**
* A row that could serve as the group row but is not being used. Reported
* so the choice can be shown and taken rather than existing only inside
* the guesswork.
*/
groupCandidate: number | null;
/**
* The group headings in use, in the order the file shelves them, each with
* the number of columns under it. What the group editor edits: a
* correction addresses one of these by position and renames every column
* in its run.
*/
groups: { label: string; count: number }[];
/** Trailing rows left out for being totals rather than data. */
footerRows: number;
/** How many the parser would leave out on its own, in use or not. */
footerCandidate: number;
/** The rows the footer guess points at, rendered for the preview. */
footerText: string[];
/** The opening rows, one line each, to choose another header from. */
firstRows: string[];
}
export interface ImportOptions {
/** Overrules the guess when the operator picked a row in the preview. */
headerRow?: number;
/**
* The row whose headings group the columns. Undefined lets the parser
* decide — it folds the row above the header in when the header repeats
* its names, which is what grouped columns look like. `null` says no row
* does, whatever the parser thinks.
*/
groupRow?: number | null;
/** Which sheet of a workbook to read; the first with anything in it by default. */
sheet?: string;
/**
* How many trailing rows are totals rather than data. Undefined lets the
* parser decide; 0 keeps every row, whatever it thinks.
*/
footerRows?: number;
/** Corrections to the guessed column labels and types, from the preview. */
columns?: ImportColumnOverride[];
/**
* Corrections to the group headings, addressed by their position in the
* preview's `groups`. One correction renames every column in the group;
* corrected to nothing, the group stops prefixing its columns at all.
*/
groups?: ImportGroupOverride[];
/**
* The target table's columns, when importing into one that exists. A header
* is matched against them — by label first, since the label is what an
* export would print, then by identifier for the files that carry those —
* and a match takes the existing column's name and type: the column already
* *is* a date, and the only question left is whether the values read as one.
*/
tableColumns?: { name: string; label: string; type: ColumnType }[];
}
/**
* What arrived: pasted text, or the bytes of a file.
*
* A file is not decoded to text before it gets here, because half the formats
* worth accepting are not text at all — and the ones that are still need their
* encoding worked out rather than assumed.
*/
export type ImportSource = string | { bytes: Buffer; filename?: string };
/** How far into the file the header may be chosen from. */
const HEADER_CHOICES = 15;
/**
* The words a tick is written with. Exported because tightening a text column
* into a checkbox afterwards has to accept exactly what importing it would
* have, or the same file would mean two different things.
*/
export const TRUE_WORDS = new Set(['true', 'yes', 'y', 'x', '1', 'j', 'ja', 'waar']);
export const FALSE_WORDS = new Set(['false', 'no', 'n', '0', 'nee', 'onwaar']);
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
/**
* A number, and deliberately not a code that happens to be written in digits.
*
* A leading zero is the giveaway: `0101010.02` is a ledger reference, `007` is
* an account number, and importing either as a number silently drops the zeros
* that made it unique. Nobody writes a quantity that way.
*/
const NUMERIC = /^-?(0|[1-9]\d*)(\.\d+)?$/;
/**
* The identifier a label becomes, stepped past what this import already took.
*
* The same derivation the column dialog shows — see `slugifyColumnName` — so a
* column made by import and a column made by hand from the same words carry
* the same name. Uniqueness is stepped with `_2` exactly as the server steps
* it, because two headers reading "Total" is a fact about the file, not an
* error to hand back.
*/
export function columnNameFor(label: string, taken: Set<string>): string {
const candidate = slugifyColumnName(label);
let unique = candidate;
for (let n = 2; taken.has(unique); n++) {
unique = `${candidate.slice(0, MAX_COLUMN_NAME - 4)}_${n}`;
}
taken.add(unique);
return unique;
}
/** The existing column a header speaks of, when importing into a table. */
function matchColumn(
columns: { name: string; label: string; type: ColumnType }[],
label: string,
): { name: string; label: string; type: ColumnType } | undefined {
const folded = label.trim().toLowerCase();
return (
// The label is what an export prints, so it is the first thing to try…
columns.find((c) => c.label.trim().toLowerCase() === folded) ??
// …and the identifier answers for files that carry the SQL names.
columns.find((c) => c.name === label.trim()) ??
columns.find((c) => c.name === slugifyColumnName(label))
);
}
/** A number written with money on it, which is what a currency column holds. */
const CURRENCY_MARK = /[€$£¥₹]/;
/**
* What one column is, guessed through the same readers the tightening uses —
* so `12-3-2021` and `€ 1.234,50` land as the date and the amount they are,
* not as text to be repaired later. The reading (decimal comma or dot, day or
* month first) is inferred from the column as a whole, exactly as a tightening
* would infer it; a guess is only made when **every** value fits it, so an
* inferred type can never refuse a row.
*
* The leading-zero rule survives from the stricter days: `007` is an account
* number and `0101010.02` is a ledger reference, and reading either as a
* number silently drops the zeros that made it unique.
*/
export function inferColumn(values: string[]): { type: ColumnType; reading: Required<Reading> } {
const present = values.map((v) => v.trim()).filter((v) => v !== '');
const { reading } = inferReading(values);
if (present.length === 0) return { type: 'text', reading };
// A mark column before a number column: a column of nothing but 1s (or x,
// ja, yes) with gaps is somebody ticking rows, and reading it as the
// number one loses the gesture. The gaps are what settle it — a column of
// 1s with every row filled is a quantity and falls through to the number
// branch — and a column carrying both a yes-word and a no-word is a
// checkbox however full it is.
const words = present.map((v) => v.toLowerCase());
if (words.every((v) => TRUE_WORDS.has(v) || FALSE_WORDS.has(v))) {
const gaps = present.length < values.length;
const both = words.some((v) => TRUE_WORDS.has(v)) && words.some((v) => FALSE_WORDS.has(v));
if (gaps || both) return { type: 'checkbox', reading };
}
const codeLike = present.some((v) => /^0\d/.test(v.replace(/^[\s€$£¥₹(+-]+/, '')));
if (!codeLike && present.every((v) => readNumber(v, reading.decimal) !== null)) {
return { type: present.some((v) => CURRENCY_MARK.test(v)) ? 'currency' : 'number', reading };
}
if (present.every((v) => readDate(v, reading.dayFirst) !== null)) return { type: 'date', reading };
const lowered = present.map((v) => v.toLowerCase());
if (lowered.every((v) => TRUE_WORDS.has(v) || FALSE_WORDS.has(v))) {
return { type: 'checkbox', reading };
}
return { type: 'text', reading };
}
/** One cell under the column's type, or the news that it will not go. */
function readValue(
raw: string,
type: ColumnType,
reading: Required<Reading>,
): { ok: boolean; value: string | number | boolean | null } {
const value = raw.trim();
if (value === '') return { ok: true, value: null };
if (type === 'number' || type === 'currency') {
const n = readNumber(value, reading.decimal);
return n === null ? { ok: false, value: null } : { ok: true, value: n };
}
if (type === 'date') {
const d = readDate(value, reading.dayFirst);
return d === null ? { ok: false, value: null } : { ok: true, value: d };
}
if (type === 'checkbox') {
const word = value.toLowerCase();
if (TRUE_WORDS.has(word)) return { ok: true, value: true };
if (FALSE_WORDS.has(word)) return { ok: true, value: false };
return { ok: false, value: null };
}
return { ok: true, value };
}
/** How many cells in a row hold anything at all. */
function filled(row: string[]): number {
return row.reduce((n, cell) => (cell.trim() === '' ? n : n + 1), 0);
}
/** A row of labels: nothing in it reads as a number or a date. */
function looksLikeLabels(row: string[]): boolean {
const cells = row.map((c) => c.trim()).filter((c) => c !== '');
return cells.length > 0 && cells.every((c) => !NUMERIC.test(c) && !ISO_DATE.test(c));
}
/** A row carrying at least one value no one would write as a column name. */
function hasTypedValue(row: string[]): boolean {
return row.some((cell) => {
const value = cell.trim();
return value !== '' && (NUMERIC.test(value) || ISO_DATE.test(value));
});
}
/**
* The words a summary row announces itself with — "Totaal", "Subtotal",
* "Grand total", "Gemiddelde". The whole cell must be the word (a colon
* allowed), because a row is only *announced* as arithmetic when the label
* stands alone: a product called "Total Repair Kit" is a row of data, and a
* cell that says more than the word is saying something else.
*/
const SUMMARY_WORD =
/^(?:(?:(?:sub|eind|grand)[ -]?)?tota(?:a)?l(?:en|s)?|som|sum|gemiddelde?|average):?$/i;
function looksLikeSummary(row: string[]): boolean {
return row.some((cell) => SUMMARY_WORD.test(cell.trim()));
}
/**
* A row that ignores the word columns while carrying numbers is arithmetic
* about the table, however full it happens to be. The word columns are the
* table's spine — filled in (nearly) every row, never with a bare number:
* the reference codes, the descriptions. A row of per-column counts leaves
* every one of them empty, or worse, puts a number there; a real record
* cannot do that, because filling those columns with words is what made
* them word columns. One real word in one of them acquits the row outright.
*/
function looksLikeArithmetic(rows: string[][], row: string[], width: number): boolean {
let wordColumns = 0;
let offended = 0;
let numbers = 0;
for (let index = 0; index < width; index++) {
const value = (row[index] ?? '').trim();
if (value !== '' && NUMERIC.test(value)) numbers++;
const rest = rows.map((r) => (r[index] ?? '').trim());
const present = rest.filter((v) => v !== '');
const wordy =
present.length >= Math.max(2, Math.ceil(rest.length * 0.9)) &&
present.every((v) => !NUMERIC.test(v));
if (!wordy) continue;
wordColumns++;
if (value === '' || NUMERIC.test(value)) offended++;
else return false;
}
return wordColumns >= 2 && offended === wordColumns && numbers >= 2;
}
/** Columns whose values, taken together, read as something better than text. */
function typedColumns(rows: string[][], width: number): number {
let typed = 0;
for (let index = 0; index < width; index++) {
const values = rows.map((row) => row[index] ?? '');
if (values.every((value) => value.trim() === '')) continue;
if (inferColumn(values).type !== 'text') typed++;
}
return typed;
}
/**
* How many rows at the bottom are arithmetic about the table rather than rows
* of it.
*
* A totals row poisons twice: it lands as a row nobody meant to store, and
* one word of it under a column of numbers makes the whole column text — see
* the every-value rule in the inference. So the bottom rows are read for two
* signals, either sufficient: they say so ("Totaal", "Subtotal", "Gemiddelde"
* — most do), or they are sparser than the table and provably the only reason
* a column reads as text. A short last row that breaks nothing — somebody's
* half-filled final record — matches neither and stays data.
*
* A guess, and treated like the header guess: reported, shown in the preview
* with the rows it points at, and the operator's to overrule either way.
*/
function trailingSummaryRows(data: string[][], width: number): number {
if (data.length < 2) return 0;
const modal = modeOf(data.map(filled).filter((n) => n > 0));
let count = 0;
while (count < 3 && count < data.length - 1) {
const row = data[data.length - 1 - count]!;
const above = data.slice(0, data.length - 1 - count);
if (looksLikeSummary(row)) {
count++;
continue;
}
// A grand total is not always sparser than the table — a row of
// per-column counts can be its fullest line. Ignoring the word columns
// gives it away either way.
if (looksLikeArithmetic(above, row, width)) {
count++;
continue;
}
if (
filled(row) < modal &&
typedColumns(above, width) > typedColumns(data.slice(0, data.length - count), width)
) {
count++;
continue;
}
break;
}
return count;
}
/**
* Which row is the header.
*
* An export that opens with "Verkooprapport Q3 2026", a generated-on stamp and
* a blank line has three rows above the one that names the columns, and taking
* the first of them leaves a one-column table called `Verkooprapport_Q3_2026`.
*
* Found in two moves. First the **body**: most rows in a CSV agree on how many
* cells they fill, and the first row to reach that number is where the table
* starts. Titles and stamps fill one or two cells and lose.
*
* Reaching it, not matching it: a header is often the *fullest* row in the
* file. The RGS ledger scheme names 29 columns and then leaves two thirds of
* them empty on every line, so a header that had to equal the body's width
* would be passed over in favour of the first ordinary row.
*
* Then one careful step back up, because a header is not always as wide as its
* data — `,Name,Amount` over `0,First,10` is what half the world's tooling
* writes, and its header fills fewer cells than every row beneath it. The step
* is only taken when the row above reads as *labels* — no numbers, no dates —
* and it either covers more columns than the body does or sits on top of values
* that are plainly typed. A generated-on stamp fails on its date and stays
* preamble; a title of one word fails on width.
*
* A guess is acceptable here only because it is a *visible* one: the preview
* shows the names it landed on before a single row is written.
*/
function findHeaderRow(grid: string[][]): number {
const width = modeOf(grid.map(filled).filter((n) => n > 1));
if (width < 2) return 0;
const start = grid.findIndex((row) => filled(row) >= width);
if (start <= 0) return Math.max(0, start);
const above = grid[start - 1]!;
if (filled(above) < 2 || !looksLikeLabels(above)) return start;
return filled(above) > width || hasTypedValue(grid[start]!) ? start - 1 : start;
}
/**
* The row above the header, when it is shaped like group headings.
*
* A spreadsheet with grouped columns writes the group name once, merged over
* the columns it covers — "Q1" over Omzet and Kosten — and an export turns
* the merge into one filled cell with blanks trailing it. So the shape to
* look for is a row of labels directly above the header with at least two of
* them, covering no more cells than the header itself. A report title fails
* on being one cell; a generated-on stamp fails on its date.
*/
function groupRowCandidate(grid: string[][], headerIndex: number): number | null {
if (headerIndex === 0) return null;
const above = grid[headerIndex - 1]!;
const header = grid[headerIndex]!;
if (!looksLikeLabels(above)) return null;
const cells = filled(above);
if (cells < 2 || cells > filled(header)) return null;
return headerIndex - 1;
}
/**
* Each column's group heading, read the way the merge meant it: a group
* covers every column from its own until the next one starts.
*/
function carryGroups(row: string[], width: number): (string | null)[] {
const carried: (string | null)[] = [];
let current: string | null = null;
for (let index = 0; index < width; index++) {
const cell = (row[index] ?? '').trim();
if (cell) current = cell;
carried.push(current);
}
return carried;
}
interface ReadGrid {
grid: string[][];
format: ImportFormat;
delimiter: string | null;
sheet: string | null;
sheets: string[];
}
/**
* Whatever arrived, as rows of raw strings.
*
* The one place that knows there is more than one kind of file. Everything
* after it works on the grid and cannot tell where the grid came from.
*/
function readGrid(source: ImportSource, wanted: string | undefined): ReadGrid {
if (typeof source !== 'string' && isWorkbook(source.bytes)) {
let book;
try {
book = readWorkbook(source.bytes);
} catch (error) {
throw badRequest(error instanceof WorkbookError ? error.message : 'That file could not be read.');
}
const sheets = book.sheets.map((s) => s.name);
const named = wanted === undefined ? undefined : book.sheets.find((s) => s.name === wanted);
if (wanted !== undefined && !named) {
throw badRequest(`That workbook has no sheet called "${wanted}".`);
}
// Workbooks open on a cover sheet, a recap or a page of notes often
// enough that taking the first one flatly would import prose. Prose is
// short and tables are long, so the data is on a sheet with lots of
// filled rows — but between two long sheets (the table, and a diff of
// the table against its last edition) tab order is the better judge, so
// the guess is the *first* sheet in reach of the longest rather than the
// longest itself. The preview lets it be overruled the same way the
// header row can be.
const rowsOf = (sheet: { grid: string[][] }) => sheet.grid.filter((row) => filled(row) > 0).length;
const longest = Math.max(...book.sheets.map(rowsOf));
const chosen = named ?? book.sheets.find((s) => rowsOf(s) * 2 >= longest) ?? book.sheets[0]!;
if (chosen.grid.length === 0) throw badRequest(`Sheet "${chosen.name}" is empty.`);
return { grid: chosen.grid, format: book.format, delimiter: null, sheet: chosen.name, sheets };
}
const text = typeof source === 'string' ? source : decodeText(source.bytes);
if (!text.trim()) throw badRequest('That file is empty.');
const delimiter = detectDelimiter(text);
return { grid: parseCsv(text, delimiter), format: 'delimited', delimiter, sheet: null, sheets: [] };
}
/** Whether these bytes are a container rather than text — a ZIP, or an old `.xls`. */
function isWorkbook(bytes: Buffer): boolean {
if (bytes.length < 8) return false;
const signature = bytes.readUInt32BE(0);
return signature === 0x504b0304 || signature === 0xd0cf11e0;
}
/**
* A text file's bytes as characters.
*
* Guessed, because nothing in a CSV says what it is encoded in and the guess
* has to be made regardless — the only choice is whether to make it carefully
* or to assume UTF-8 and hand somebody a table full of `ë`.
*
* Excel's "Unicode Text" export is UTF-16, and announces itself with a byte
* order mark. Everything else is UTF-8 until proven otherwise: a decode that
* comes back carrying replacement characters was not UTF-8, and on a Windows
* export the answer is almost always the Latin-1 superset Excel writes.
*/
export function decodeText(bytes: Buffer): string {
if (bytes.length >= 2) {
if (bytes[0] === 0xff && bytes[1] === 0xfe) return bytes.subarray(2).toString('utf16le');
if (bytes[0] === 0xfe && bytes[1] === 0xff) return bytes.subarray(2).swap16().toString('utf16le');
}
const utf8 = bytes.toString('utf8');
if (!utf8.includes('�')) return utf8;
try {
return new TextDecoder('windows-1252').decode(bytes);
} catch {
return bytes.toString('latin1');
}
}
/**
* Parse a spreadsheet into columns and rows, ready to be written.
*
* Nothing is written here: this is the step whose output the client shows as a
* preview, so that a mangled delimiter, a misread type or a header found in the
* wrong place is caught before it becomes a table.
*
* `options.headerRow` is how that catching ends: the guess is only ever an
* opening bid, and an operator who can see it is wrong can name the right row
* instead of going back to reshape the file by hand.
*/
export function parseImport(source: ImportSource, options: ImportOptions = {}): ParsedImport {
const { grid, format, delimiter, sheet, sheets } = readGrid(source, options.sheet);
const chosen = options.headerRow;
const headerIndex = chosen === undefined ? findHeaderRow(grid) : chosen;
const header = grid[headerIndex];
if (!header || filled(header) === 0) {
throw badRequest(
chosen === undefined
? 'That file has no header row.'
: `Row ${chosen + 1} is empty, so it cannot name the columns.`,
);
}
// Blank rows are not only a trailing-newline problem: exports space their
// sections out, and a row of nothing but separators looks full to a parser
// and empty to a person.
const under = grid.slice(headerIndex + 1);
const data = under.filter((row) => filled(row) > 0);
// The totals rows at the bottom, filtered the way the title rows at the top
// are — and like the header, a visible and overrulable guess.
const fullWidth = data.reduce((n, row) => Math.max(n, row.length), header.length);
const footerCandidate = trailingSummaryRows(data, fullWidth);
const footerRows =
options.footerRows === undefined
? footerCandidate
: Math.max(0, Math.min(options.footerRows, Math.max(0, data.length - 1)));
// The rows the guess points at, rendered for the preview — whether they are
// being dropped or only offered.
const footerText = data
.slice(data.length - Math.min(Math.max(footerRows, footerCandidate), 3))
.map((row) => row.map((cell) => cell.trim()).filter(Boolean).join(' · '));
const bodyRows = footerRows > 0 ? data.slice(0, data.length - footerRows) : data;
// Rows wider than the header are counted in, so a value can never be dropped
// for sitting under a header cell that Excel forgot to write.
const width = bodyRows.reduce((n, row) => Math.max(n, row.length), header.length);
const kept: number[] = [];
let emptyColumns = 0;
for (let index = 0; index < width; index++) {
const named = (header[index] ?? '').trim() !== '';
const used = bodyRows.some((row) => (row[index] ?? '').trim() !== '');
// Trailing separators from long-deleted columns: no name, and nothing under
// it in any row. A column with a name but no values is somebody's intent.
if (!named && !used) emptyColumns++;
else kept.push(index);
}
if (kept.length === 0) throw badRequest('That file has no columns with anything in them.');
if (kept.length > MAX_IMPORT_COLUMNS) {
throw badRequest(
`That file has ${kept.length} columns; ${MAX_IMPORT_COLUMNS} is the most one import can create.`,
);
}
const skipped = Math.max(0, bodyRows.length - MAX_IMPORT_ROWS);
const body = bodyRows.slice(0, MAX_IMPORT_ROWS);
/*
* Grouped columns. A file that shelves its columns under merged headings —
* "Q1" over Omzet and Kosten, "Q2" over the same two words again — names
* its columns uniquely per group and repeats them across groups. The group
* row is folded in front of the names, so those columns land as "Q1 Omzet"
* and "Q2 Omzet" rather than as an Omzet and an Omzet 2 that no longer say
* which quarter is which.
*
* Folded on its own only when the header actually repeats a name — that
* repetition is what grouped columns look like, and without it the row
* above is more likely a stray line than a shelf. Either reading can be
* overruled: the candidate is reported even when unused, and `null` turns
* the fold off however sure the parser is.
*/
const groupCandidate = groupRowCandidate(grid, headerIndex);
const headerNames = kept.map((index) => (header[index] ?? '').trim().toLowerCase()).filter(Boolean);
const headerRepeats = new Set(headerNames).size < headerNames.length;
let groupRow: number | null;
if (options.groupRow === undefined) {
groupRow = headerRepeats ? groupCandidate : null;
} else if (options.groupRow === null) {
groupRow = null;
} else {
const row = grid[options.groupRow];
if (options.groupRow >= headerIndex || !row || filled(row) === 0) {
throw badRequest(`Row ${options.groupRow + 1} cannot group the columns: it must be a filled row above the header.`);
}
groupRow = options.groupRow;
}
const carried = groupRow !== null ? carryGroups(grid[groupRow]!, width) : null;
/*
* The groups as things of their own, so they can be edited as one word
* instead of once per column. Each run of columns under one heading is one
* group, listed in the order the file shelves them; a correction addresses
* a group by that position and lands on every column in the run — and a
* correction emptied to nothing takes the heading off its run entirely,
* which is how one messy merged cell is cleaned in one stroke. A stale
* correction pointing past the list is ignored, exactly as a stale column
* correction is: previews regenerate, and yesterday's fix must not wedge
* today's reading.
*/
const runOf: (number | null)[] = [];
const groupRuns: { label: string; count: number }[] = [];
let previousValue: string | null = null;
for (const index of kept) {
const value = carried?.[index] ?? null;
if (value === null) {
runOf.push(null);
} else if (value === previousValue) {
groupRuns[groupRuns.length - 1]!.count++;
runOf.push(groupRuns.length - 1);
} else {
groupRuns.push({ label: value, count: 1 });
runOf.push(groupRuns.length - 1);
}
previousValue = value;
}
const groupEdits = new Map((options.groups ?? []).map((o) => [o.index, o.label.trim()]));
const effectiveGroups = groupRuns.map((run, ordinal) => {
const edited = groupEdits.get(ordinal);
return {
label: (edited === undefined ? run.label : edited).slice(0, MAX_COLUMN_LABEL),
count: run.count,
};
});
// The operator's corrections, addressed by position in the preview. A
// correction is a label like any other — there is nothing illegal to type,
// because the identifier is derived from whatever the words turn out to be.
//
// Duplicates that survive the grouping are stepped, not refused: the second
// remaining "Omzet" becomes "Omzet 2" exactly as the identifier under it
// becomes omzet_2, with the original kept beside it so the preview can say
// what happened. Headers are also cut to the label cap for the same
// reason: whatever the file says, the preview must be able to show a
// column that the catalogue will actually accept.
const overrides = new Map((options.columns ?? []).map((o) => [o.index, o]));
const groupFor = (position: number): string | null => {
const ordinal = runOf[position];
if (ordinal === null || ordinal === undefined) return null;
return effectiveGroups[ordinal]!.label || null;
};
const seen = new Set<string>();
const labels = kept.map((index, position) => {
const wanted = overrides.get(position)?.label?.trim();
const named = (header[index] ?? '').trim();
const group = groupFor(position);
const worded = group && named ? `${group} ${named}` : named || group || '';
const base = (wanted || worded || `Column ${position + 1}`).slice(0, MAX_COLUMN_LABEL);
let unique = base;
for (let n = 2; seen.has(unique); n++) {
const suffix = ` ${n}`;
unique = `${base.slice(0, MAX_COLUMN_LABEL - suffix.length)}${suffix}`;
}
seen.add(unique);
return unique;
});
// Appending matches each header to a column the table already has; creating
// derives fresh identifiers. Matched names claim theirs first, so a header
// that matched nothing can never derive its way onto one that did.
const matched = labels.map((label) => (options.tableColumns ? matchColumn(options.tableColumns, label) : undefined));
const taken = new Set(matched.flatMap((hit) => (hit ? [hit.name] : [])));
for (const column of options.tableColumns ?? []) taken.add(column.name);
const names = labels.map((label, position) => matched[position]?.name ?? columnNameFor(label, taken));
const columns: ImportColumn[] = [];
const readings: Required<Reading>[] = [];
kept.forEach((source, position) => {
const inferred = inferColumn(body.map((row) => row[source] ?? ''));
readings.push(inferred.reading);
// A select or link column takes the text as written — `readValue` falls
// back to text for any type it has no reader for, and the row gate knows
// the options and the target where this parser does not.
const type = matched[position]?.type ?? overrides.get(position)?.type ?? inferred.type;
columns.push({
name: names[position]!,
label: labels[position]!,
header: header[source]?.trim() ?? '',
group: groupFor(position),
type,
unreadable: 0,
problem: null,
});
});
const rows = body.map((row) => {
const record: Record<string, string | number | boolean | null> = {};
columns.forEach((column, index) => {
const read = readValue(row[kept[index]!] ?? '', column.type, readings[index]!);
if (!read.ok) {
column.unreadable++;
column.problem ??= (row[kept[index]!] ?? '').trim();
}
record[column.name] = read.value;
});
return record;
});
return {
columns,
rows,
format,
delimiter,
sheet,
sheets,
skipped,
headerRow: headerIndex,
groupRow,
groupCandidate,
groups: effectiveGroups,
footerRows,
footerCandidate,
footerText,
// Shown as they were read, so the row that names the columns can be picked
// out by eye rather than by counting lines in a text editor.
firstRows: grid
.slice(0, Math.max(HEADER_CHOICES, headerIndex + 1))
.map((row) => row.map((cell) => cell.trim()).filter(Boolean).join(' · ')),
ignored: {
// A group row in use is not junk that was walked past.
preamble: headerIndex - (groupRow !== null && groupRow < headerIndex ? 1 : 0),
blankRows: under.length - data.length,
emptyColumns,
footer: footerRows,
},
};
}