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