// --------------------------------------------------------------------------- // 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; 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[]; problems: ImportProblem[]; } /** Column index (the picker's key) → field key, or `""` for "don't import". */ export type ImportMapping = Record; 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[] = []; 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 = {}; // 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 }; }