File size: 7,018 Bytes
ef68ae0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
// ---------------------------------------------------------------------------
// customer-grid / fileSelect.ts — WAVE 21 item 11 (ruling R10, contract C5):
// "Select from file…", the PURE half.
//
// The owner's ask is small to describe and easy to get quietly wrong: paste or
// upload a list of names, and have the grid tick those records. Every way it
// fails is silent —
//
//   · matching over the ROWS GLIDE HAS PAINTED rather than the rows the view
//     matched. `displayRows` is a capped slice (`DISPLAY_PAGE`); a list of 900
//     names would report ~200 selected and hundreds "not found", all of which
//     exist. This module never sees the slice.
//   · reporting a count without the misses. "812 selected" out of a 900-row
//     file is a number nobody can act on; the 88 that did not match are the
//     whole reason someone runs this ([[no-unverifiable-aggregates]]).
//   · conflating "not in this view" with "not in this table". A filtered view
//     turns half a correct file into apparent garbage, and the user goes
//     looking for a data problem that is a filter.
//
// So this returns THREE buckets, not a count, and the dialog renders what it is
// given. React-free so `verify_grid_ux.py` can run it under node.
// ---------------------------------------------------------------------------

/** The one comparison rule (R10): trim, then casefold. Nothing else — no
 *  punctuation stripping, no number parsing, no fuzzy anything. A match a user
 *  cannot predict is worse than a miss they can see and fix. */
export function normalizeKey(value: unknown): string {
  if (value === null || value === undefined) return "";
  return String(value).trim().toLowerCase();
}

export interface PastedList {
  /** The candidate values, in the order pasted, blanks dropped. */
  values: string[];
  /** How many lines were dropped for being empty. Reported, never hidden. */
  blanks: number;
  /**
   * The paste carried MORE THAN ONE COLUMN (tab-separated) and only the first
   * is being matched. Disclosed in the dialog — a range copied out of Excel is
   * the common case, and silently reading one column of it is the kind of
   * helpfulness that becomes a support question.
   */
  extraColumns: boolean;
}

/**
 * Read a pasted list.
 *
 * ⚠ SPLIT ON NEWLINES AND TABS ONLY — never commas. "Acme, Inc." is a company
 * name, and a comma split would turn one customer into two values, one of which
 * can never match anything. A CSV file goes through the UPLOAD path, where the
 * server parses it properly (C5).
 */
export function parsePastedList(text: string): PastedList {
  const lines = String(text ?? "").split(/\r?\n/);
  const values: string[] = [];
  let blanks = 0;
  let extraColumns = false;
  for (const line of lines) {
    const cells = line.split("\t");
    if (cells.length > 1 && cells.slice(1).some((c) => c.trim() !== "")) extraColumns = true;
    const first = (cells[0] ?? "").trim();
    if (!first) {
      // A trailing newline is not a blank the user needs told about; a gap in
      // the middle of a pasted column is. Both are counted the same way here
      // and the dialog only mentions the total when it is non-zero.
      if (line.trim() === "" && cells.length === 1) blanks += 1;
      else blanks += 1;
      continue;
    }
    values.push(first);
  }
  // The trailing newline every "copy a column" produces would otherwise report
  // one phantom blank on every paste.
  if (blanks > 0 && text.endsWith("\n")) blanks -= 1;
  return { values, blanks, extraColumns };
}

/** The minimum a row must look like for this module. */
export interface MatchRow {
  pid: number;
  [key: string]: unknown;
}

export interface MatchResult {
  /** Records to tick, de-duplicated, in the order the file named them. */
  pids: number[];
  /** File values that matched at least one row IN THE VIEW. */
  matched: string[];
  /**
   * File values that name a record in this TABLE that the current view does not
   * show. A filter, not a data problem — and the difference is the single most
   * useful thing this result carries.
   */
  outsideView: string[];
  /** File values with no record anywhere in this table. */
  missing: string[];
  /** File values that appeared more than once in the file. */
  duplicates: number;
  /**
   * Values matching MORE THAN ONE record. All of them are selected (dropping
   * the ambiguity would be a silent cap), and the count is disclosed so
   * "40 values, 47 selected" is explained rather than mysterious.
   */
  ambiguous: number;
}

/**
 * Match `values` against `columnKey` over the rows the view matched, with the
 * whole table as the second opinion.
 *
 * ⚠ `inView` MUST be the full matched set (`visibleRows`), never the painted
 * slice (`displayRows`). Passing the slice compiles, runs, and lies.
 */
export function matchRowsByValue(
  values: string[],
  columnKey: string,
  inView: MatchRow[],
  wholeTable: MatchRow[]
): MatchResult {
  const index = new Map<string, number[]>();
  for (const row of inView) {
    const k = normalizeKey(row[columnKey]);
    if (!k) continue;
    const at = index.get(k);
    if (at) at.push(row.pid);
    else index.set(k, [row.pid]);
  }
  const elsewhere = new Set<string>();
  for (const row of wholeTable) {
    const k = normalizeKey(row[columnKey]);
    if (k && !index.has(k)) elsewhere.add(k);
  }

  const pids: number[] = [];
  const seenPid = new Set<number>();
  const matched: string[] = [];
  const outsideView: string[] = [];
  const missing: string[] = [];
  const seenValue = new Set<string>();
  let duplicates = 0;
  let ambiguous = 0;

  for (const raw of values) {
    const k = normalizeKey(raw);
    if (!k) continue;
    if (seenValue.has(k)) {
      duplicates += 1;
      continue;
    }
    seenValue.add(k);
    const hits = index.get(k);
    if (hits) {
      matched.push(raw);
      if (hits.length > 1) ambiguous += 1;
      for (const pid of hits)
        if (!seenPid.has(pid)) {
          seenPid.add(pid);
          pids.push(pid);
        }
    } else if (elsewhere.has(k)) {
      outsideView.push(raw);
    } else {
      missing.push(raw);
    }
  }
  return { pids, matched, outsideView, missing, duplicates, ambiguous };
}

/**
 * The result, in one sentence — R10's "N selected · M not found", with the two
 * facts that ruling could not have anticipated folded in only when they are
 * non-zero (a sentence that always recites five clauses is one nobody reads).
 */
export function matchSummary(r: MatchResult): string {
  const parts = [`${r.pids.length.toLocaleString()} selected`];
  if (r.outsideView.length)
    parts.push(`${r.outsideView.length.toLocaleString()} not in this view`);
  if (r.missing.length) parts.push(`${r.missing.length.toLocaleString()} not found`);
  if (r.duplicates) parts.push(`${r.duplicates.toLocaleString()} repeated in the file`);
  if (r.ambiguous)
    parts.push(`${r.ambiguous.toLocaleString()} matched more than one record`);
  return parts.join(" · ");
}