File size: 11,777 Bytes
f5ffed6
5351cc8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ce7669
 
 
 
 
 
 
 
 
f5ffed6
 
 
 
 
5351cc8
 
 
 
 
 
f5ffed6
 
 
 
4748aae
 
 
 
 
 
5351cc8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d80995
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5351cc8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da5297e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5351cc8
 
da5297e
 
 
 
 
 
 
5351cc8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da5297e
 
 
 
 
 
5351cc8
 
da5297e
 
 
5351cc8
 
 
 
 
 
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
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
import { choiceOptions, isDerivedLink, isMachineWritten, ratingMax } from "./types";
import type { Field } from "./types";

function choiceMatch(raw: string, allowed: string[]): string | undefined {
  const wanted = raw.trim().toLowerCase();
  return allowed.find((value) => value.trim().toLowerCase() === wanted);
}

function numericValue(raw: string): string | undefined {
  const clean = raw.trim().replace(/[$,%\s]/g, "");
  if (clean === "") return "";
  const value = Number(clean);
  return Number.isFinite(value) ? String(value) : undefined;
}

/**
 * Convert one clipboard cell into the overlay store's string contract. `undefined` means the
 * paste is invalid and the caller must reject the whole operation; an empty string is a valid
 * clear. Choice fields always return their canonical declared spelling.
 */
export function coerceClipboardValue(
  field: Field,
  raw: string,
  allowedChoices: string[] = choiceOptions(field)
): string | undefined {
  // ⭐ OWNER ITEM 4 (2026-08-06) β€” THE PASTE DOOR, which the type switch below structurally
  // cannot cover. This module's own design note is that a paste never opens an editor, so a cell
  // that is read-only by NATURE and absent from the switch is uneditable by keyboard and
  // overwritable by Ctrl+V. Every case below is a TYPE; `isMachineWritten` is not type-based β€” an
  // automation-filled column is an ordinary `text` field wearing a tag β€” so the Instagram preset
  // columns fell straight through it. `grid_events` refuses the write either way, but a
  // multi-cell paste would paint optimistically across them and then bounce, which reads to the
  // person doing it as the app losing their data rather than as a wall.
  if (isMachineWritten(field)) return undefined;
  // ⭐ 2026-08-07 β€” the DERIVED half of `link`, refused for the same reason and by the same
  // shape: it is read-only per FIELD, not per type, so the switch below structurally cannot see
  // it. The server rewrites the cell on the next refresh pass, so a pasted value would paint,
  // survive a moment, and vanish β€” the failure this whole function exists to prevent.
  if (isDerivedLink(field)) return undefined;
  const value = raw.replace(/\r/g, "");
  const trimmed = value.trim();
  switch (field.type) {
    case "formula":
    case "created_time":
    case "status":
    // ⭐ 2026-08-07 β€” a ROLLUP cell is an aggregate the host computed. Same argument as
    // `automation` below: a paste would be overwritten by the next refresh with nothing on any
    // surface saying so.
    case "rollup":
    // C5-AUTOFIELD (wave 18) β€” an automation cell is what the last RUN wrote, so a paste into
    // one is refused HERE and not only by the renderer. `READONLY_CELL_TYPES` decides what
    // gets an editor; a paste never opens one, so a type that is read-only by nature and
    // absent from this list would be uneditable by keyboard and overwritable by Ctrl+V β€”
    // and the next run would silently replace whatever landed.
    case "automation":
      return undefined;
    case "checkbox": {
      if (trimmed === "") return "";
      const key = trimmed.toLowerCase();
      if (["1", "true", "yes", "y", "on", "checked", "x"].includes(key)) return "1";
      if (["0", "false", "no", "n", "off", "unchecked"].includes(key)) return "";
      return undefined;
    }
    case "int":
    case "currency":
    case "pct":
      return numericValue(value);
    case "rating": {
      if (trimmed === "") return "";
      const n = Number(trimmed);
      return Number.isInteger(n) && n >= 1 && n <= ratingMax(field)
        ? String(n)
        : undefined;
    }
    case "select":
    case "user":
      return trimmed === "" ? "" : choiceMatch(trimmed, allowedChoices);
    case "multiselect": {
      if (trimmed === "") return "";
      const out: string[] = [];
      const seen = new Set<string>();
      for (const part of value.split(/[,\n]/)) {
        if (!part.trim()) continue;
        const match = choiceMatch(part, allowedChoices);
        if (!match) return undefined;
        const key = match.toLowerCase();
        if (!seen.has(key)) {
          seen.add(key);
          out.push(match);
        }
      }
      return out.length ? out.join(",") : "";
    }
    /**
     * ⭐ WAVE-26 ITEM 3 β€” a pasted date is NORMALISED to the stored contract, not stored as
     * whatever text happened to parse.
     *
     * It used to `return trimmed` for anything `Date.parse` accepted, which meant pasting a
     * DISPLAY string put a display string in the cell: `8/5/2026` from Excel, or β€” after item 3
     * changed the rendering β€” `Aug 5, 2026` copied out of this very grid. The cell then LOOKED
     * right (an unparseable date renders verbatim) while the stored value had stopped being a
     * date: it sorts as text, no date filter matches it, and the JSON export carries the browser
     * locale's spelling of somebody's calendar.
     *
     * ⚠ AN ALREADY-CANONICAL VALUE IS LEFT ALONE, and that guard is the load-bearing half.
     * `new Date("2026-08-05")` is UTC midnight, so re-deriving `YYYY-MM-DD` from its LOCAL parts
     * would move it to the 4th for every viewer west of Greenwich β€” a normaliser that corrupts
     * exactly the values that needed no normalising. Anything already starting with an ISO day
     * (bare, or carrying a time) passes through untouched.
     *
     * A display string, by contrast, was written in the reader's own clock, so its LOCAL parts
     * are the day they meant β€” never `toISOString()`, which would shift it back the other way.
     */
    case "date": {
      if (trimmed === "") return "";
      if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) return trimmed;
      const parsed = new Date(trimmed);
      if (Number.isNaN(parsed.getTime())) return undefined;
      const pad = (n: number) => String(n).padStart(2, "0");
      return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())}`;
    }
    default:
      return value;
  }
}

/** The product deliberately supports vertical field paste, never a cross-field matrix. */
export function isSingleColumnClipboard(values: readonly (readonly string[])[]): boolean {
  return values.length > 0 && values.every((row) => row.length === 1);
}

export interface GridCopyProvenance {
  fieldKey: string | null;
  /** The native copy event follows the grid key event; this short arm identifies that one event. */
  armedUntil: number;
}

export const EMPTY_GRID_COPY_PROVENANCE: GridCopyProvenance = {
  fieldKey: null,
  armedUntil: 0,
};

export function markGridCopy(
  fieldKey: string | null,
  now: number
): GridCopyProvenance {
  return { fieldKey, armedUntil: now + 1_000 };
}

/**
 * Preserve provenance for the native copy event belonging to the grid shortcut. Any later
 * document copy is external to the grid and clears it, so stale source metadata cannot block
 * an unrelated paste.
 */
export function observeCopyEvent(
  provenance: GridCopyProvenance,
  now: number
): GridCopyProvenance {
  return now <= provenance.armedUntil
    ? { ...provenance, armedUntil: 0 }
    : EMPTY_GRID_COPY_PROVENANCE;
}

export interface FieldPastePatch {
  pid: number;
  value: string;
}

/**
 * The selected RECTANGLE, in glide's own coordinates (`GridSelection.current.range`).
 * `width`/`height` are counts of columns/rows, so a single clicked cell is 1Γ—1.
 */
export interface PasteSelection {
  x: number;
  y: number;
  width: number;
  height: number;
}

/**
 * Wave-15 item 3 (R8) β€” HOW MANY ROWS a paste writes.
 *
 * The defect this replaces: the target rows were walked down from the anchor for exactly
 * `values.length` rows and THE SELECTION WAS NEVER CONSULTED. So copying one cell, selecting
 * fifty rows and pasting wrote ONE β€” the fifty-row selection was painted, obeyed by nothing, and
 * the user had no way to tell the difference between "it filled" and "it filled the first row"
 * without scrolling.
 *
 * R8: the clipboard TILES TO FILL the selection. A pattern of K repeats top-to-bottom until N
 * rows are covered (`planFieldPaste` takes `values[i % K]`), and N need not be a multiple of K β€”
 * Excel refuses that, Airtable tiles, and the owner asked for tiling. A clipboard LARGER than
 * the selection is not truncated either: it pastes from the anchor downward, past the selection
 * end, because the rows the user copied are the thing they chose and silently dropping the tail
 * would be the same class of quiet lie as the bug above.
 *
 * ⚠ `target` IS the selection origin whenever there is a selection β€” glide's `onPasteInternal`
 * computes `target = [current.range.x, current.range.y]` (data-editor.tsx:3361), MEASURED, not
 * assumed. The equality guards below are therefore normally trivially true; they earn their
 * place by covering glide's OTHER two target branches (a whole-column or whole-row selection
 * with no `current`), where `range` is undefined and the honest answer is today's behaviour.
 */
export function pasteRowCount(
  clipboardRows: number,
  selection: PasteSelection | undefined,
  target: { col: number; row: number }
): number {
  if (clipboardRows <= 0) return 0;
  // No rectangle to fill: paste the clipboard as it comes, from the anchor down.
  if (!selection) return clipboardRows;
  // Same-field VERTICAL paste only, exactly as today (`isSingleColumnClipboard` is the other
  // half). A selection spanning columns describes a matrix this product deliberately refuses,
  // so filling it would invent a policy nobody asked for.
  if (selection.width !== 1) return clipboardRows;
  // The paste has to be landing IN the selection for the selection to mean anything.
  if (selection.x !== target.col || selection.y !== target.row) return clipboardRows;
  return Math.max(clipboardRows, selection.height);
}

/**
 * The whole paste policy as a pure, atomic plan. CustomerGrid supplies the actual target row
 * pids (null for a group/header row) β€” `pasteRowCount` of them β€” then applies the returned
 * patches only when every value passed. `null` means apply nothing.
 *
 * ⚠ ONE PATCH PER TARGET ROW, not per clipboard row (wave-15 R8). The clipboard is a PATTERN
 * that repeats: row i takes `values[i % values.length]`. With one copied cell and fifty targets
 * that is fifty identical patches; with a two-row pattern and seven targets it is
 * A B A B A B A.
 */
export function planFieldPaste({
  field,
  sourceFieldKey,
  editable,
  values,
  targetPids,
  allowedChoices,
}: {
  field: Field;
  sourceFieldKey: string | null;
  editable: boolean;
  values: readonly (readonly string[])[];
  targetPids: readonly (number | null)[];
  allowedChoices?: string[];
}): FieldPastePatch[] | null {
  if (!editable || !isSingleColumnClipboard(values)) return null;
  if (sourceFieldKey && sourceFieldKey !== field.key) return null;
  // ⚠ This was `!==` until wave 15, and the change is deliberate rather than a loosening.
  // FEWER targets than clipboard rows still refuses β€” that would drop copied rows on the floor
  // silently. MORE is now legal, because that IS the tile-to-fill case. The null test is
  // untouched and is what keeps the whole operation ATOMIC: a selection running past the last
  // record, or over a group header, refuses everything rather than half-writing.
  if (targetPids.length < values.length || targetPids.some((pid) => pid == null))
    return null;
  const patches: FieldPastePatch[] = [];
  for (let index = 0; index < targetPids.length; index += 1) {
    const value = coerceClipboardValue(
      field, values[index % values.length][0], allowedChoices);
    const pid = targetPids[index];
    if (value === undefined || pid == null) return null;
    patches.push({ pid, value });
  }
  return patches;
}