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