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