loopable / web /src /customer-grid /importPlan.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
665e5ea verified
Raw
History Blame Contribute Delete
6.04 kB
// ---------------------------------------------------------------------------
// customer-grid / importPlan.ts
//
// ⭐⭐ WAVE-29 T76 β€” THE IMPORT PLAN: what a parsed spreadsheet would write, and what it cannot.
//
// Lifted OUT of `ImportDialog.tsx` and given no React and no stylesheet, for one reason: the
// dialog's headline feature had NEVER imported a row, and nothing could see it. The gate greps
// the .tsx for strings, and the node suite could only reach the pure `isImportTarget` predicate β€”
// so the one thing neither could do was RUN the plan builder against the payload the server
// actually sends. It runs here ([[gate-answers-the-wrong-question]]).
//
// β›” THE PAYLOAD IS COLUMN-MAJOR, AND THIS FILE IS THE ONLY PLACE THAT SHAPE IS READ.
// `routes_uploads.tabular` answers `{"values": {columnName: [cell, cell, …]}}` β€” its docstring
// says so at :4 and `:86` builds it β€” while the old reader declared `string[][]` and indexed
// `values[row][col]`. Every read was `undefined`, `values.length` was `undefined ?? 0`, the plan
// loop ran zero times, and the footer honestly reported "0 records" about a file full of them.
// The correct reader was already written one file away (`SelectFromFile.tsx:69`, by column NAME).
//
// ⚠ NAME-INDEXING IS SAFE BECAUSE THE SERVER DEDUPES ITS HEADERS. `routes_uploads._headers`
// renames a second "SKU" to "SKU (2)", so `columns[i]` is a unique key into `values` β€” a sheet
// with two identically-named columns maps two distinct pickers, never one merged list.
//
// ⚠ THE ROW COUNT IS THE SERVER'S OWN `rows`, never a column array's length: a mapping that
// names no column must still not invent a row count, and a short column is padded by `?? ""`
// rather than silently truncating the file.
// ---------------------------------------------------------------------------
import { coerceClipboardValue } from "./clipboard";
import { choiceOptions } from "./types";
import type { Field } from "./types";
/** The upload door's answer (`routes_uploads.tabular`). */
export interface ParsedUpload {
columns: string[];
/** Data rows READ (blank rows skipped, cap disclosed by `truncated`) β€” the loop's bound. */
rows: number;
/** β›” COLUMN-MAJOR: keyed by the deduped column NAME, one array of cells per column. */
values: Record<string, string[]>;
truncated?: boolean;
}
/** One refused cell, named the way the person holding the spreadsheet can find it. */
export interface ImportProblem {
/** 1-based, and +1 again for the header row β€” what the file's own row numbers say. */
row: number;
column: string;
value: string;
field: string;
}
export interface ImportPlan {
/** Field-keyed rows, ready for `POST /tables/{key}/rows/import`. */
rows: Record<string, string>[];
problems: ImportProblem[];
}
/** Column index (the picker's key) β†’ field key, or `""` for "don't import". */
export type ImportMapping = Record<number, string>;
const NONE = "";
/**
* THE ONE PLACE the wire shape is read. A column's cells, by the index the picker holds.
*
* ⚠ Keep this a named function rather than an inline expression: it is the single line a
* negative control can mutate to restore the row-major misreading, which is how the gate proves
* it can still see this defect ([[gate-negative-control]]).
*/
export function columnCells(parsed: ParsedUpload, index: number): readonly string[] {
const name = parsed.columns?.[index] ?? "";
return parsed.values?.[name] ?? [];
}
/**
* Coerce every mapped cell and collect the refusals. Runs over the WHOLE file, not the preview:
* the point of this step is that a bad value on row 900 is reported BEFORE anything is written,
* rather than discovered as a half-imported file.
*
* β›” THE REFUSAL SENTINEL IS `undefined`. `coerceClipboardValue` is typed `string | undefined`
* and every refusal path returns `undefined`; the old check tested `=== null`, which no path can
* produce β€” so a refused value was neither reported NOR skipped, it was written as the four-letter
* string `String(undefined)`. `""` is NOT a refusal: it is an explicit clear (a checkbox's
* "FALSE"), and it keeps its row.
*/
export function buildImportPlan(
parsed: ParsedUpload | null | undefined,
mapping: ImportMapping,
targets: Field[]
): ImportPlan {
const problems: ImportProblem[] = [];
const rows: Record<string, string>[] = [];
if (!parsed) return { rows, problems };
// Resolved ONCE per column rather than per cell: a 20,000-row file would otherwise re-scan the
// field list 20,000 times per mapped column.
const mapped = Object.entries(mapping)
.filter(([, key]) => key !== NONE)
.map(([indexText, key]) => {
const index = Number(indexText);
return {
key,
field: targets.find((f) => f.key === key),
column: parsed.columns?.[index] ?? `Column ${index + 1}`,
cells: columnCells(parsed, index),
choices: undefined as string[] | undefined,
};
})
.filter((m) => m.field !== undefined);
for (const m of mapped) m.choices = choiceOptions(m.field as Field);
const total = Number.isFinite(parsed.rows) ? Math.max(0, Math.trunc(parsed.rows)) : 0;
for (let r = 0; r < total; r += 1) {
const out: Record<string, string> = {};
// A row of only unmapped or blank cells is padding, not a record β€” importing it would add an
// empty record per trailing line of the sheet.
let blank = true;
for (const m of mapped) {
const raw = String(m.cells[r] ?? "");
if (raw.trim() === "") continue;
blank = false;
const value = coerceClipboardValue(m.field as Field, raw, m.choices);
if (value === undefined) {
problems.push({
row: r + 2, // +1 for the header row, +1 because humans count from 1
column: m.column,
value: raw.slice(0, 40),
field: (m.field as Field).label,
});
continue;
}
out[m.key] = value;
}
if (!blank) rows.push(out);
}
return { rows, problems };
}