| // --------------------------------------------------------------------------- | |
| // 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 }; | |
| } | |