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