diff --git a/apps/web/src/lib/workbook-clipboard.ts b/apps/web/src/lib/workbook-clipboard.ts new file mode 100644 index 00000000..7ff2d79e --- /dev/null +++ b/apps/web/src/lib/workbook-clipboard.ts @@ -0,0 +1,34 @@ +/** Spreadsheet TSV keeps embedded tabs/newlines inside quoted cells. */ +export function serializeClipboardTable(rows: readonly (readonly string[])[]): string { + return rows.map(row => row.map(value => /[\t\r\n"]/.test(value) + ? `"${value.replace(/"/g, '""')}"` : value).join("\t")).join("\n") +} + +export function parseClipboardTable(text: string): string[][] { + if (!text) return [] + const rows: string[][] = [] + let row: string[] = [], cell = "", quoted = false, closed = false + for (let index = 0; index < text.length; index++) { + const char = text[index] + if (quoted) { + if (char !== '"') cell += char + else if (text[index + 1] === '"') { cell += '"'; index++ } + else { quoted = false; closed = true } + continue + } + if (char === "\t" || char === "\n" || char === "\r") { + row.push(cell); cell = ""; closed = false + if (char !== "\t") { + rows.push(row); row = [] + if (char === "\r" && text[index + 1] === "\n") index++ + } + } else if (closed) { + throw new Error("Clipboard contains text after a quoted cell") + } else if (char === '"' && cell === "") quoted = true + else cell += char + } + if (quoted) throw new Error("Clipboard contains an unclosed quoted cell") + // A final record separator is conventional in spreadsheet clipboard text. + if (row.length || cell || !/[\r\n]$/.test(text)) rows.push([...row, cell]) + return rows +} diff --git a/apps/web/src/pages/workbook-editor.tsx b/apps/web/src/pages/workbook-editor.tsx index 6498b4eb..3257347f 100644 --- a/apps/web/src/pages/workbook-editor.tsx +++ b/apps/web/src/pages/workbook-editor.tsx @@ -13,6 +13,7 @@ import { flexRender, type ColumnDef, type CellContext, type SortingState, } from "@tanstack/react-table" import { useVirtualizer } from "@tanstack/react-virtual" +import { parseClipboardTable, serializeClipboardTable } from "@/lib/workbook-clipboard" import { workbookColumnWindow } from "@/lib/workbook-column-window" import { loadedCellProgress, isExecutableColumn } from "@/lib/workbook-progress" import { @@ -1241,11 +1242,11 @@ export default function WorkbookEditorPage() { } if (shortcut && event.key.toLowerCase() === "c") { event.preventDefault() - const text = Array.from({ length: range.bottom - range.top + 1 }, (_, rowOffset) => + const text = serializeClipboardTable(Array.from({ length: range.bottom - range.top + 1 }, (_, rowOffset) => Array.from({ length: range.right - range.left + 1 }, (_, columnOffset) => String(gridCellValue(range.top + rowOffset, range.left + columnOffset)) - ).join("\t") - ).join("\n") + ) + )) navigator.clipboard.writeText(text) .then(() => toast.success("Copied selection", { duration: 1200 })) .catch(() => toast.error("Clipboard access was denied")) @@ -1322,11 +1323,13 @@ export default function WorkbookEditorPage() { const handleGridPaste = useCallback((event: React.ClipboardEvent, startRow: number, startColumn: number) => { const target = event.target as HTMLElement if (target.matches("input, textarea, [contenteditable=true]")) return - const matrix = event.clipboardData.getData("text/plain") - .replace(/\r\n/g, "\n") - .replace(/\n$/, "") - .split("\n") - .map(line => line.split("\t")) + let matrix: string[][] + try { matrix = parseClipboardTable(event.clipboardData.getData("text/plain")) } + catch (error) { + event.preventDefault() + toast.error(error instanceof Error ? error.message : "Clipboard could not be parsed") + return + } if (!matrix.length || !matrix[0].length) return const visibleColumns = table.getVisibleLeafColumns() diff --git a/apps/web/tests/workbook-clipboard.test.ts b/apps/web/tests/workbook-clipboard.test.ts new file mode 100644 index 00000000..81e91763 --- /dev/null +++ b/apps/web/tests/workbook-clipboard.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" +import { parseClipboardTable, serializeClipboardTable } from "../src/lib/workbook-clipboard" + +describe("spreadsheet clipboard cells", () => { + test("round trips notes containing record separators and quotes", () => { + const rows = [['first\nsecond', 'tab\there', 'say "hello"'], ['\r\n', '', 'plain']] + expect(parseClipboardTable(serializeClipboardTable(rows))).toEqual(rows) + }) + test("reads Excel TSV with quoted multiline cells and trailing record separator", () => { + expect(parseClipboardTable('"first\r\nsecond"\t"say ""hello"""\r\n')).toEqual([ + ['first\r\nsecond', 'say "hello"'], + ]) + }) + test("keeps ordinary paste, blank columns and interior blank rows", () => { + expect(parseClipboardTable('Acme\t\r\n\r\nBeta\tvalue\r\n')).toEqual([ + ['Acme', ''], [''], ['Beta', 'value'], + ]) + expect(parseClipboardTable('')).toEqual([]) + expect(parseClipboardTable('a "quote"')).toEqual([['a "quote"']]) + }) + test("refuses malformed quoted cells before any mutations", () => { + expect(() => parseClipboardTable('"unfinished\nnext')).toThrow('unclosed') + expect(() => parseClipboardTable('"closed"extra')).toThrow('after a quoted cell') + }) +}) diff --git a/tests/fixtures/workbook_clipboard_server.ts b/tests/fixtures/workbook_clipboard_server.ts new file mode 100644 index 00000000..19914dcc --- /dev/null +++ b/tests/fixtures/workbook_clipboard_server.ts @@ -0,0 +1,37 @@ +import { join } from "node:path"; +const wt = process.argv[2]; +const entry = join(wt, `apps/web/.clipboard-native-${process.pid}.tsx`); +await Bun.write(entry, `import React from 'react'; +import {createRoot} from 'react-dom/client'; +import {QueryClient,QueryClientProvider} from '@tanstack/react-query'; +import {MemoryRouter,Routes,Route} from 'react-router-dom'; +import Editor from './src/pages/workbook-editor'; +import {QuickLookProvider} from './src/components/quick-look/quick-look'; +const root=createRoot(document.getElementById('root')); +root.render(}/>); +window.unmount=()=>root.unmount();`); +let result; +try {result=await Bun.build({entrypoints:[entry],target:"browser"});} finally {await Bun.file(entry).delete();} +if(!result.success)throw new Error(String(result.logs)); +const bundle=await result.outputs.find(output=>output.type.includes("javascript"))!.text(); +const columns=[{id:"notes",name:"Notes",type:"input",width:240},{id:"company",name:"Company",type:"input",width:240}]; +const rows=[1,2,3].map(id=>({row_id:id,lead_id:null,data:{notes:id===1?'first\nsecond':'Keep notes '+id,company:'Keep company '+id},lead:{},enrichments:{}})); +const updates:unknown[]=[]; +const server=Bun.serve({hostname:"127.0.0.1",port:0,fetch(req,srv){ + const path=new URL(req.url).pathname; + if(path==="/bundle.js")return new Response(bundle,{headers:{"Content-Type":"application/javascript"}}); + if(path.endsWith('/ws')){if(srv.upgrade(req))return;return new Response('upgrade failed',{status:400});} + if(path==="/received")return Response.json(updates); + if(path==="/api/workbooks/native/rows" && req.method==="PATCH")return req.json().then(body=>{updates.push(body);return Response.json({status:"updated",updated_rows:body.updates.length,reactive_columns:[]});}); + if(path==="/api/workbooks/native")return Response.json({workbook:{id:"native",name:"Clipboard",status:"draft",source_type:"manual",source_config:{},filter_criteria:{},columns_config:columns,total_rows:3,completed_rows:0,sync_to_leads:false},rows,total_rows:3,query_total_rows:3,page:1,page_size:1000,has_more:false}); + if(path.endsWith('/views'))return Response.json({views:[],total:0}); + if(path.endsWith('/connector-runs'))return Response.json({runs:[],next_cursor:null,has_more:false}); + if(path.endsWith('/runs'))return Response.json({runs:[],total:0}); + if(path.endsWith('/providers'))return Response.json({providers:[]}); + if(path.endsWith('/cost'))return Response.json({budget_spent_usd:0,budget_max_usd:0}); + if(path.endsWith('/estimate'))return Response.json({best_usd:0,worst_usd:0,rows:3}); + if(path.includes('/api/'))return Response.json({presets:[]}); + return new Response('
',{headers:{"Content-Type":"text/html"}}); +},websocket:{message(){}}}); +console.log(JSON.stringify({port:server.port})); +process.on("SIGTERM",()=>{server.stop(true);process.exit(0);}); diff --git a/tests/test_workbook_clipboard_browser.py b/tests/test_workbook_clipboard_browser.py new file mode 100644 index 00000000..9bd0aa31 --- /dev/null +++ b/tests/test_workbook_clipboard_browser.py @@ -0,0 +1,74 @@ +"""Opt-in real WorkbookEditor clipboard handlers against an owned API fixture. + +Supply OPENGTM_BUN and OPENGTM_CHROMIUM; this test downloads no dependencies. +Clipboard writes are captured inside the page, without touching the host clipboard. +""" +import json +import os +import select +import subprocess +from pathlib import Path + +import pytest + + +@pytest.mark.skipif(not (os.getenv("OPENGTM_BUN") and os.getenv("OPENGTM_CHROMIUM")), + reason="native browser/Bun executables must be explicitly supplied") +def test_workbook_clipboard_preserves_cell_boundaries(): + from playwright.sync_api import sync_playwright + + root = Path(__file__).resolve().parents[1] + server = subprocess.Popen( + [os.environ["OPENGTM_BUN"], str(root / "tests/fixtures/workbook_clipboard_server.ts"), str(root)], + stdout=subprocess.PIPE, text=True, + ) + try: + if not select.select([server.stdout], [], [], 30)[0]: + raise TimeoutError("fixture startup deadline") + line = server.stdout.readline() + if not line: + raise RuntimeError("fixture exited before startup") + origin = "http://127.0.0.1:" + str(json.loads(line)["port"]) + with sync_playwright() as p: + browser = p.chromium.launch(executable_path=os.environ["OPENGTM_CHROMIUM"], headless=True) + try: + page = browser.new_page() + page.route("**/*", lambda route: route.continue_() if route.request.url.startswith(origin) else route.abort()) + errors = [] + page.on("pageerror", lambda error: errors.append(str(error))) + page.goto(origin) + cell = page.locator('[data-grid-row="0"][data-grid-column="2"]') + cell.wait_for() + page.evaluate('Object.defineProperty(navigator.clipboard,"writeText",{value:async text=>window.copied=text})') + cell.focus() + page.keyboard.press("Meta+c") + page.wait_for_function("window.copied !== undefined") + copied = page.evaluate("copied") + cases = [ + ('"first\nsecond"\tAcme', [{"row_id": 1, "fields": {"notes": "first\nsecond", "company": "Acme"}}]), + ('"tab\there"\t"say ""hello"""', [{"row_id": 1, "fields": {"notes": "tab\there", "company": 'say "hello"'}}]), + ('plain\t\r\nnext\tcompany\r\n', [ + {"row_id": 1, "fields": {"notes": "plain", "company": ""}}, + {"row_id": 2, "fields": {"notes": "next", "company": "company"}}, + ]), + ] + observed = [] + for index, (text, expected) in enumerate(cases): + cell.evaluate('''(element,text)=>{const data=new DataTransfer();data.setData("text/plain",text); + element.dispatchEvent(new ClipboardEvent("paste",{clipboardData:data,bubbles:true,cancelable:true}));}''', text) + page.wait_for_function("count=>fetch('/received').then(r=>r.json()).then(items=>items.length>=count)", arg=index + 1) + observed.append(page.request.get(origin + "/received").json()[index]["updates"]) + print(json.dumps({"copied": copied, "updates": observed, "errors": errors}, indent=2)) + assert copied == '"first\nsecond"' + assert observed == [expected for _, expected in cases] + assert not errors + page.evaluate("unmount()") + finally: + browser.close() + finally: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + server.wait(timeout=5)