Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/web/src/lib/workbook-clipboard.ts
Original file line number Diff line number Diff line change
@@ -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
}
19 changes: 11 additions & 8 deletions apps/web/src/pages/workbook-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -1322,11 +1323,13 @@ export default function WorkbookEditorPage() {
const handleGridPaste = useCallback((event: React.ClipboardEvent<HTMLTableCellElement>, 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()
Expand Down
25 changes: 25 additions & 0 deletions apps/web/tests/workbook-clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
37 changes: 37 additions & 0 deletions tests/fixtures/workbook_clipboard_server.ts
Original file line number Diff line number Diff line change
@@ -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(<QueryClientProvider client={new QueryClient({defaultOptions:{queries:{retry:false}}})}><QuickLookProvider><MemoryRouter initialEntries={['/workbooks/native']}><Routes><Route path="/workbooks/:id" element={<Editor/>}/></Routes></MemoryRouter></QuickLookProvider></QueryClientProvider>);
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('<style>body{margin:0}table{display:table}td{width:240px;height:40px}#root{height:800px}[class*=overflow]{height:600px}</style><div id="root"></div><script type="module" src="/bundle.js"></script>',{headers:{"Content-Type":"text/html"}});
},websocket:{message(){}}});
console.log(JSON.stringify({port:server.port}));
process.on("SIGTERM",()=>{server.stop(true);process.exit(0);});
74 changes: 74 additions & 0 deletions tests/test_workbook_clipboard_browser.py
Original file line number Diff line number Diff line change
@@ -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)
Loading