File size: 6,042 Bytes
665e5ea | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | // ---------------------------------------------------------------------------
// 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 };
}
|