From a8af07c4e6470370a1995612683c07c2d8c74936 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 20 Sep 2026 17:11:07 +0000 Subject: [PATCH 1/2] feat(workspace): prove suggestion and document fidelity --- .../2026-09-20_131317-debate-workspace.md | 8 +- src/lib/workspace/document-conversion.ts | 530 ++++++++++++++++++ src/lib/workspace/review.ts | 323 +++++++++++ src/lib/workspace/schema.ts | 36 +- .../workspace/document-conversion.test.ts | 147 +++++ .../workspace/fixtures/structured-speech.ts | 152 +++++ src/test/workspace/review.test.ts | 288 ++++++++++ 7 files changed, 1477 insertions(+), 7 deletions(-) create mode 100644 src/lib/workspace/document-conversion.ts create mode 100644 src/lib/workspace/review.ts create mode 100644 src/test/workspace/document-conversion.test.ts create mode 100644 src/test/workspace/fixtures/structured-speech.ts create mode 100644 src/test/workspace/review.test.ts diff --git a/.hermes/plans/2026-09-20_131317-debate-workspace.md b/.hermes/plans/2026-09-20_131317-debate-workspace.md index 40d1c8713..cfab64de7 100644 --- a/.hermes/plans/2026-09-20_131317-debate-workspace.md +++ b/.hermes/plans/2026-09-20_131317-debate-workspace.md @@ -264,10 +264,10 @@ Each numbered slice is independently reviewable. Before implementation, write it **P0.2 Suggestions and import/export fidelity spike.** Proposed new paths: `src/test/workspace/review.test.ts`, `src/test/workspace/document-conversion.test.ts`, and small fixture files under `src/test/workspace/fixtures/` during implementation. -- [ ] Build one representative bilingual/hungarian speech fixture with headings, nested lists, links, a table, citation, and Unicode/IME edits. -- [ ] Test human suggested insert/delete/format, overlapping real edits, accepting one hunk, rejecting all, and undo/redo. Prove quote/comment anchors survive unrelated insertions and detach on deletion. -- [ ] Round-trip the fixture through supported Markdown, DOCX, and the PDF renderer. Compare structured semantics and extracted visible text, not binary file equality. -- [ ] Decide whether the open-source suggestion implementation is maintainable before committing the UI architecture. Record the tested ceiling and package choices in the implementation PR. +- [x] Build one representative bilingual/hungarian speech fixture with headings, nested lists, links, a table, citation, and Unicode/IME edits. +- [x] Test human suggested insert/delete/format, overlapping real edits, accepting one hunk, rejecting all, and undo/redo. Prove quote/comment anchors survive unrelated insertions and detach on deletion. +- [x] Round-trip the fixture through supported Markdown, DOCX, and the PDF renderer. Compare structured semantics and extracted visible text, not binary file equality. +- [x] Decide whether the open-source suggestion implementation is maintainable before committing the UI architecture. Record the tested ceiling and package choices in the implementation PR. **Exit:** Architecture is selected using runnable evidence. A failed gate stops dependent work, not the entire vision. No live rollout yet. diff --git a/src/lib/workspace/document-conversion.ts b/src/lib/workspace/document-conversion.ts new file mode 100644 index 000000000..cd838d4f8 --- /dev/null +++ b/src/lib/workspace/document-conversion.ts @@ -0,0 +1,530 @@ +import type { JSONContent } from "@tiptap/core"; +import { validateUrl } from "@/lib/validation/url"; +import liberationSansUrl from "pdfjs-dist/standard_fonts/LiberationSans-Regular.ttf?url"; +import { workspaceSchema } from "./schema"; + +function escapeMarkdown(text: string) { + return text.replace(/([\\`*_[\]()|])/g, "\\$1"); +} + +function unescapeMarkdown(text: string) { + return text.replace(/\\([\\`*_[\]()|])/g, "$1"); +} + +function citationLabel(attrs: Record) { + const title = String(attrs.title || "Citation"); + const locator = attrs.locator ? `, ${String(attrs.locator)}` : ""; + return `[${title}${locator}]`; +} + +function citationUri(attrs: Record) { + const query = new URLSearchParams(); + for (const key of ["title", "locator", "quote", "href"] as const) { + if (attrs[key]) query.set(key, String(attrs[key])); + } + return `debatelab-citation:${encodeURIComponent(String(attrs.sourceId || ""))}?${query}`; +} + +function inlineMarkdown(content: JSONContent[] = []) { + return content.map((node) => { + if (node.type === "citation") { + return `${citationLabel(node.attrs ?? {})}(${citationUri(node.attrs ?? {})})`; + } + if (node.type !== "text") return ""; + + let text = escapeMarkdown(node.text ?? ""); + for (const mark of node.marks ?? []) { + if (mark.type === "bold") text = `**${text}**`; + if (mark.type === "italic") text = `*${text}*`; + if (mark.type === "strike") text = `~~${text}~~`; + } + const link = node.marks?.find((mark) => mark.type === "link"); + return link?.attrs?.href + ? `[${text}](${String(link.attrs.href)})` + : text; + }).join(""); +} + +function serializeList(node: JSONContent, depth = 0): string { + const lines: string[] = []; + for (const item of node.content ?? []) { + const blocks = item.content ?? []; + const paragraph = blocks.find((block) => block.type === "paragraph"); + lines.push(`${" ".repeat(depth)}- ${inlineMarkdown(paragraph?.content)}`); + for (const child of blocks) { + if (child.type === "bulletList") lines.push(serializeList(child, depth + 1)); + } + } + return lines.join("\n"); +} + +function tableCellMarkdown(cell: JSONContent) { + return escapeMarkdown((cell.content ?? []).map((block) => inlineMarkdown(block.content)).join(" ")); +} + +function serializeTable(node: JSONContent) { + const rows = (node.content ?? []).map((row) => + `| ${(row.content ?? []).map(tableCellMarkdown).join(" | ")} |`, + ); + if (rows.length === 0) return ""; + const columns = node.content?.[0]?.content?.length ?? 1; + rows.splice(1, 0, `| ${Array.from({ length: columns }, () => "---").join(" | ")} |`); + return rows.join("\n"); +} + +export function toWorkspaceMarkdown(document: JSONContent): string { + workspaceSchema.nodeFromJSON(document).check(); + return (document.content ?? []).map((node) => { + if (node.type === "heading") { + return `${"#".repeat(Number(node.attrs?.level) || 1)} ${inlineMarkdown(node.content)}`; + } + if (node.type === "paragraph") return inlineMarkdown(node.content); + if (node.type === "bulletList") return serializeList(node); + if (node.type === "table") return serializeTable(node); + return ""; + }).filter(Boolean).join("\n\n"); +} + +function parseCitation(href: string): JSONContent | null { + if (!href.startsWith("debatelab-citation:")) return null; + const [source, query = ""] = href.slice("debatelab-citation:".length).split("?", 2); + let sourceId: string; + try { + sourceId = decodeURIComponent(source); + } catch { + return null; + } + if (!sourceId) return null; + const params = new URLSearchParams(query); + const citationHref = validateUrl(params.get("href") ?? undefined) ?? null; + return { + type: "citation", + attrs: { + sourceId, + title: params.get("title"), + locator: params.get("locator"), + quote: params.get("quote"), + href: citationHref, + }, + }; +} + +function parseMarkedText(text: string, extraMarks: JSONContent["marks"] = []): JSONContent[] { + const nodes: JSONContent[] = []; + const pattern = /(\*\*([^*]+)\*\*|\*([^*]+)\*|~~([^~]+)~~)/g; + let start = 0; + for (const match of text.matchAll(pattern)) { + if (match.index > start) { + nodes.push({ type: "text", text: unescapeMarkdown(text.slice(start, match.index)), marks: extraMarks }); + } + const mark = match[2] ? "bold" : match[3] ? "italic" : "strike"; + nodes.push({ + type: "text", + text: unescapeMarkdown(match[2] ?? match[3] ?? match[4]), + marks: [...extraMarks, { type: mark }], + }); + start = match.index + match[0].length; + } + if (start < text.length) { + nodes.push({ type: "text", text: unescapeMarkdown(text.slice(start)), marks: extraMarks }); + } + return nodes.map((node) => node.marks?.length ? node : { ...node, marks: undefined }); +} + +function parseInline(text: string): JSONContent[] { + const nodes: JSONContent[] = []; + const linkPattern = /\[([^\]]*)\]\(([^)]+)\)/g; + let start = 0; + for (const match of text.matchAll(linkPattern)) { + if (match.index > start) nodes.push(...parseMarkedText(text.slice(start, match.index))); + const citation = parseCitation(match[2]); + if (citation) { + nodes.push(citation); + } else { + const href = validateUrl(match[2]); + nodes.push(...parseMarkedText(match[1], href ? [{ type: "link", attrs: { href } }] : [])); + } + start = match.index + match[0].length; + } + if (start < text.length) nodes.push(...parseMarkedText(text.slice(start))); + return nodes; +} + +function splitTableRow(line: string) { + const cells: string[] = []; + let cell = ""; + let escaped = false; + for (const character of line.trim().replace(/^\||\|$/g, "")) { + if (character === "|" && !escaped) { + cells.push(cell.trim()); + cell = ""; + } else { + cell += character; + } + escaped = character === "\\" && !escaped; + if (character !== "\\") escaped = false; + } + cells.push(cell.trim()); + return cells; +} + +function parseTable(lines: string[], start: number) { + const rows: JSONContent[] = []; + let index = start; + let header = true; + while (index < lines.length && /^\s*\|/.test(lines[index])) { + if (!/^\s*\|(?:\s*:?-+:?\s*\|)+\s*$/.test(lines[index])) { + rows.push({ + type: "tableRow", + content: splitTableRow(lines[index]).map((cell) => ({ + type: header ? "tableHeader" : "tableCell", + content: [{ type: "paragraph", content: parseInline(unescapeMarkdown(cell)) }], + })), + }); + header = false; + } + index += 1; + } + return { node: { type: "table", content: rows } satisfies JSONContent, index }; +} + +function parseList(lines: string[], start: number, indent: number) { + const items: JSONContent[] = []; + let index = start; + while (index < lines.length) { + const match = /^(\s*)-\s+(.*)$/.exec(lines[index]); + if (!match || match[1].length < indent) break; + if (match[1].length > indent) { + const nested = parseList(lines, index, match[1].length); + items.at(-1)?.content?.push(nested.node); + index = nested.index; + continue; + } + items.push({ + type: "listItem", + content: [{ type: "paragraph", content: parseInline(match[2]) }], + }); + index += 1; + } + return { node: { type: "bulletList", content: items } satisfies JSONContent, index }; +} + +export function fromWorkspaceMarkdown(markdown: string): JSONContent { + const lines = markdown.replace(/\r\n/g, "\n").split("\n"); + const content: JSONContent[] = []; + let index = 0; + while (index < lines.length) { + const line = lines[index]; + if (!line.trim()) { + index += 1; + continue; + } + const heading = /^(#{1,6})\s+(.*)$/.exec(line); + if (heading) { + content.push({ + type: "heading", + attrs: { level: heading[1].length }, + content: parseInline(heading[2]), + }); + index += 1; + continue; + } + if (/^\s*-\s+/.test(line)) { + const list = parseList(lines, index, /^\s*/.exec(line)?.[0].length ?? 0); + content.push(list.node); + index = list.index; + continue; + } + if (/^\s*\|/.test(line) && /^\s*\|(?:\s*:?-+:?\s*\|)+\s*$/.test(lines[index + 1] ?? "")) { + const table = parseTable(lines, index); + content.push(table.node, { type: "paragraph" }); + index = table.index; + continue; + } + + content.push({ type: "paragraph", content: parseInline(line) }); + index += 1; + } + + const parsed = workspaceSchema.nodeFromJSON({ type: "doc", content }); + parsed.check(); + return parsed.toJSON(); +} + +function docxInline(content: JSONContent[] = [], docx: typeof import("docx")) { + return content.map((node) => { + if (node.type === "citation") { + return new docx.ExternalHyperlink({ + link: citationUri(node.attrs ?? {}), + children: [new docx.TextRun(citationLabel(node.attrs ?? {}))], + }); + } + + const options = { + text: node.text ?? "", + bold: node.marks?.some((mark) => mark.type === "bold"), + italics: node.marks?.some((mark) => mark.type === "italic"), + strike: node.marks?.some((mark) => mark.type === "strike"), + }; + const link = node.marks?.find((mark) => mark.type === "link")?.attrs?.href; + return link + ? new docx.ExternalHyperlink({ + link: String(link), + children: [new docx.TextRun(options)], + }) + : new docx.TextRun(options); + }); +} + +function docxList(node: JSONContent, docx: typeof import("docx"), depth = 0): import("docx").Paragraph[] { + const paragraphs: import("docx").Paragraph[] = []; + for (const item of node.content ?? []) { + const blocks = item.content ?? []; + const paragraph = blocks.find((block) => block.type === "paragraph"); + paragraphs.push(new docx.Paragraph({ + children: docxInline(paragraph?.content, docx), + bullet: { level: depth }, + })); + for (const child of blocks) { + if (child.type === "bulletList") paragraphs.push(...docxList(child, docx, depth + 1)); + } + } + return paragraphs; +} + +function docxTable(node: JSONContent, docx: typeof import("docx")) { + return new docx.Table({ + rows: (node.content ?? []).map((row, rowIndex) => new docx.TableRow({ + tableHeader: rowIndex === 0, + children: (row.content ?? []).map((cell) => new docx.TableCell({ + children: (cell.content ?? []).map((block) => new docx.Paragraph({ + children: docxInline(block.content, docx), + })), + })), + })), + }); +} + +export async function toWorkspaceDocx(document: JSONContent): Promise { + workspaceSchema.nodeFromJSON(document).check(); + const docx = await import("docx"); + const children = (document.content ?? []).flatMap((node) => { + if (node.type === "heading") { + const levels = [ + docx.HeadingLevel.HEADING_1, + docx.HeadingLevel.HEADING_2, + docx.HeadingLevel.HEADING_3, + docx.HeadingLevel.HEADING_4, + docx.HeadingLevel.HEADING_5, + docx.HeadingLevel.HEADING_6, + ]; + return [new docx.Paragraph({ + heading: levels[Math.max(0, Math.min(5, Number(node.attrs?.level) - 1))], + children: docxInline(node.content, docx), + })]; + } + if (node.type === "paragraph") { + return [new docx.Paragraph({ children: docxInline(node.content, docx) })]; + } + if (node.type === "bulletList") return docxList(node, docx); + if (node.type === "table") return [docxTable(node, docx)]; + return []; + }); + const file = new docx.Document({ sections: [{ children }] }); + return new Uint8Array(await docx.Packer.toBuffer(file)); +} + +function htmlInline(node: ChildNode, marks: JSONContent["marks"] = []): JSONContent[] { + if (node.nodeType === 3) { + return node.textContent ? [{ type: "text", text: node.textContent, marks }] : []; + } + if (!(node instanceof Element)) return []; + + const tag = node.tagName.toLowerCase(); + if (tag === "script" || tag === "style") return []; + if (tag === "a") { + const href = node.getAttribute("href") ?? ""; + const citation = parseCitation(href); + if (citation) return [citation]; + const safeHref = validateUrl(href); + const linkMarks = safeHref ? [...marks, { type: "link", attrs: { href: safeHref } }] : marks; + return Array.from(node.childNodes).flatMap((child) => htmlInline(child, linkMarks)); + } + + const mark = tag === "strong" || tag === "b" + ? "bold" + : tag === "em" || tag === "i" + ? "italic" + : tag === "s" || tag === "del" + ? "strike" + : null; + const nextMarks = mark ? [...marks, { type: mark }] : marks; + return Array.from(node.childNodes).flatMap((child) => htmlInline(child, nextMarks)); +} + +function htmlParagraph(element: Element): JSONContent { + return { + type: "paragraph", + content: Array.from(element.childNodes).flatMap((child) => htmlInline(child)), + }; +} + +function htmlList(element: Element): JSONContent { + return { + type: "bulletList", + content: Array.from(element.children) + .filter((child) => child.tagName.toLowerCase() === "li") + .map((item) => { + const content: JSONContent[] = []; + const paragraph = Array.from(item.children).find((child) => + child.tagName.toLowerCase() === "p", + ); + content.push(paragraph + ? htmlParagraph(paragraph) + : { + type: "paragraph", + content: Array.from(item.childNodes) + .filter((child) => !(child instanceof Element) || !["ul", "ol"].includes(child.tagName.toLowerCase())) + .flatMap((child) => htmlInline(child)), + }); + for (const child of Array.from(item.children)) { + if (["ul", "ol"].includes(child.tagName.toLowerCase())) content.push(htmlList(child)); + } + return { type: "listItem", content }; + }), + }; +} + +function htmlTable(element: Element): JSONContent { + const rows = Array.from(element.querySelectorAll("tr")).map((row, rowIndex) => ({ + type: "tableRow", + content: Array.from(row.children) + .filter((cell) => ["td", "th"].includes(cell.tagName.toLowerCase())) + .map((cell) => ({ + type: rowIndex === 0 || cell.tagName.toLowerCase() === "th" ? "tableHeader" : "tableCell", + content: [htmlParagraph(cell)], + })), + })); + return { type: "table", content: rows }; +} + +export function fromWorkspaceHtml(html: string): JSONContent { + const body = new DOMParser().parseFromString(html, "text/html").body; + const content: JSONContent[] = []; + for (const element of Array.from(body.children)) { + const tag = element.tagName.toLowerCase(); + if (/^h[1-6]$/.test(tag)) { + content.push({ + type: "heading", + attrs: { level: Number(tag[1]) }, + content: Array.from(element.childNodes).flatMap((child) => htmlInline(child)), + }); + } else if (tag === "p") { + content.push(htmlParagraph(element)); + } else if (tag === "ul" || tag === "ol") { + content.push(htmlList(element)); + } else if (tag === "table") { + content.push(htmlTable(element), { type: "paragraph" }); + } + } + const parsed = workspaceSchema.nodeFromJSON({ type: "doc", content }); + parsed.check(); + return parsed.toJSON(); +} + +export async function fromWorkspaceDocx(bytes: Uint8Array): Promise { + const mammoth = await import("mammoth"); + const arrayBuffer = new Uint8Array(bytes).buffer; + const input = { arrayBuffer, buffer: bytes }; + const result = await mammoth.convertToHtml(input); + return fromWorkspaceHtml(result.value); +} + +function visibleInlineText(content: JSONContent[] = []) { + return content.map((node) => + node.type === "citation" + ? citationLabel(node.attrs ?? {}) + : node.text ?? "", + ).join(""); +} + +function pdfLines(document: JSONContent) { + const lines: Array<{ text: string; fontSize: number; indent: number }> = []; + const addList = (node: JSONContent, depth = 0) => { + for (const item of node.content ?? []) { + const blocks = item.content ?? []; + const paragraph = blocks.find((block) => block.type === "paragraph"); + lines.push({ text: `• ${visibleInlineText(paragraph?.content)}`, fontSize: 11, indent: depth * 6 }); + for (const child of blocks) { + if (child.type === "bulletList") addList(child, depth + 1); + } + } + }; + + for (const node of document.content ?? []) { + if (node.type === "heading") { + lines.push({ + text: visibleInlineText(node.content), + fontSize: Math.max(12, 18 - Number(node.attrs?.level || 1) * 2), + indent: 0, + }); + } else if (node.type === "paragraph") { + const text = visibleInlineText(node.content); + if (text) lines.push({ text, fontSize: 11, indent: 0 }); + } else if (node.type === "bulletList") { + addList(node); + } else if (node.type === "table") { + for (const row of node.content ?? []) { + lines.push({ + text: (row.content ?? []).map((cell) => + (cell.content ?? []).map((block) => visibleInlineText(block.content)).join(" "), + ).join(" | "), + fontSize: 10, + indent: 0, + }); + } + } + } + return lines; +} + +function binaryString(bytes: Uint8Array) { + let value = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + value += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return value; +} + +export async function toWorkspacePdf( + document: JSONContent, + options: { fontData?: Uint8Array } = {}, +): Promise { + workspaceSchema.nodeFromJSON(document).check(); + const { jsPDF } = await import("jspdf"); + const fontData = options.fontData ?? new Uint8Array( + await (await fetch(liberationSansUrl)).arrayBuffer(), + ); + const pdf = new jsPDF({ unit: "mm", format: "a4" }); + pdf.addFileToVFS("LiberationSans-Regular.ttf", binaryString(fontData)); + pdf.addFont("LiberationSans-Regular.ttf", "LiberationSans", "normal"); + pdf.setFont("LiberationSans", "normal"); + + const margin = 20; + const width = pdf.internal.pageSize.getWidth() - margin * 2; + const pageBottom = pdf.internal.pageSize.getHeight() - margin; + let y = margin; + for (const line of pdfLines(document)) { + pdf.setFontSize(line.fontSize); + const wrapped = Array.from(pdf.splitTextToSize(line.text, width - line.indent), String); + const lineHeight = line.fontSize * 0.4; + if (y + wrapped.length * lineHeight > pageBottom) { + pdf.addPage(); + pdf.setFont("LiberationSans", "normal"); + y = margin; + } + pdf.text(wrapped, margin + line.indent, y); + y += wrapped.length * lineHeight + 3; + } + return new Uint8Array(pdf.output("arraybuffer")); +} diff --git a/src/lib/workspace/review.ts b/src/lib/workspace/review.ts new file mode 100644 index 000000000..ad01706bf --- /dev/null +++ b/src/lib/workspace/review.ts @@ -0,0 +1,323 @@ +import type { Editor, JSONContent } from "@tiptap/core"; +import { createMappablePosition } from "@tiptap/extension-collaboration"; +import { Fragment, Slice } from "@tiptap/pm/model"; +import { + relativePositionToAbsolutePosition, + ySyncPluginKey, +} from "@tiptap/y-tiptap"; +import * as Y from "yjs"; + +export type ProposalHunkStatus = "pending" | "accepted" | "rejected" | "stale"; + +type ProposalHunkInput = + | { id: string; kind: "insert"; at: number; content: JSONContent[] } + | { id: string; kind: "delete"; from: number; to: number } + | { + id: string; + kind: "format"; + from: number; + to: number; + content: JSONContent[]; + }; + +export interface ProposalHunk { + id: string; + kind: ProposalHunkInput["kind"]; + status: ProposalHunkStatus; + from: Uint8Array; + to: Uint8Array; + original: JSONContent[]; + replacement: JSONContent[]; + contextBefore: JSONContent[]; + contextAfter: JSONContent[]; +} + +export interface WorkspaceProposal { + id: string; + authorId: string; + documentEpoch: number; + baseStateVector: Uint8Array; + hunks: ProposalHunk[]; +} + +export interface QuoteAnchor { + id: string; + documentEpoch: number; + from: Uint8Array; + to: Uint8Array; + quote: string; +} + +function normalizedContent(editor: Editor, content: JSONContent[]) { + return Fragment.fromJSON(editor.schema, content).toJSON(); +} + +function rangeFor(input: ProposalHunkInput) { + return input.kind === "insert" + ? { from: input.at, to: input.at } + : { from: input.from, to: input.to }; +} + +function documentFor(editor: Editor): Y.Doc { + const state = ySyncPluginKey.getState(editor.state); + if (!state?.doc) throw new Error("Workspace editor is not bound to Yjs"); + return state.doc; +} + +function sameContent(left: JSONContent[], right: JSONContent[]) { + return JSON.stringify(left) === JSON.stringify(right); +} + +function resolveAnchors(editor: Editor, fromAnchor: Uint8Array, toAnchor: Uint8Array) { + const state = ySyncPluginKey.getState(editor.state); + if (!state?.doc || !state.type || !state.binding?.mapping) return null; + + const from = relativePositionToAbsolutePosition( + state.doc, + state.type, + Y.decodeRelativePosition(fromAnchor), + state.binding.mapping, + ); + const to = relativePositionToAbsolutePosition( + state.doc, + state.type, + Y.decodeRelativePosition(toAnchor), + state.binding.mapping, + ); + return from === null || to === null || from > to ? null : { from, to }; +} + +function resolvedRange(editor: Editor, hunk: ProposalHunk) { + const range = resolveAnchors(editor, hunk.from, hunk.to); + if (!range) return null; + const { from, to } = range; + + const documentEnd = editor.state.doc.content.size; + const original = editor.state.doc.slice(from, to).content.toJSON(); + const contextBefore = editor.state.doc + .slice(Math.max(0, from - 1), from) + .content.toJSON(); + const contextAfter = editor.state.doc + .slice(to, Math.min(documentEnd, to + 1)) + .content.toJSON(); + + return sameContent(original, hunk.original) && + sameContent(contextBefore, hunk.contextBefore) && + sameContent(contextAfter, hunk.contextAfter) + ? { from, to } + : null; +} + +function withStatuses( + proposal: WorkspaceProposal, + hunkIds: Set, + status: ProposalHunkStatus, +): WorkspaceProposal { + return { + ...proposal, + hunks: proposal.hunks.map((hunk) => + hunkIds.has(hunk.id) ? { ...hunk, status } : hunk, + ), + }; +} + +export class StaleProposalError extends Error { + constructor( + public readonly hunkIds: string[], + public readonly proposal: WorkspaceProposal, + ) { + super(`Stale proposal hunks: ${hunkIds.join(", ")}`); + this.name = "StaleProposalError"; + } +} + +export function createProposal({ + id, + authorId, + documentEpoch, + editor, + hunks, +}: { + id: string; + authorId: string; + documentEpoch: number; + editor: Editor; + hunks: ProposalHunkInput[]; +}): WorkspaceProposal { + const document = documentFor(editor); + const documentEnd = editor.state.doc.content.size; + + return { + id, + authorId, + documentEpoch, + baseStateVector: Y.encodeStateVector(document), + hunks: hunks.map((input) => { + const { from, to } = rangeFor(input); + if (from < 0 || to < from || to > documentEnd) { + throw new RangeError(`Invalid proposal range ${from}..${to}`); + } + + const replacement = + input.kind === "delete" ? [] : normalizedContent(editor, input.content); + + return { + id: input.id, + kind: input.kind, + status: "pending", + from: Y.encodeRelativePosition( + createMappablePosition(from, editor.state).yRelativePosition, + ), + to: Y.encodeRelativePosition( + createMappablePosition(to, editor.state).yRelativePosition, + ), + original: editor.state.doc.slice(from, to).content.toJSON(), + replacement, + contextBefore: editor.state.doc + .slice(Math.max(0, from - 1), from) + .content.toJSON(), + contextAfter: editor.state.doc + .slice(to, Math.min(documentEnd, to + 1)) + .content.toJSON(), + }; + }), + }; +} + +export function acceptProposalHunks({ + editor, + documentEpoch, + proposal, + hunkIds, +}: { + editor: Editor; + documentEpoch: number; + proposal: WorkspaceProposal; + hunkIds: string[]; +}): WorkspaceProposal { + const selectedIds = new Set(hunkIds); + if (selectedIds.size !== hunkIds.length) { + throw new Error("Duplicate proposal hunk ID"); + } + + const selected = proposal.hunks.filter((hunk) => selectedIds.has(hunk.id)); + if (selected.length !== selectedIds.size) throw new Error("Unknown proposal hunk ID"); + if (selected.some((hunk) => hunk.status !== "pending")) { + throw new Error("Only pending proposal hunks can be accepted"); + } + + const resolved = selected.map((hunk) => ({ hunk, range: resolvedRange(editor, hunk) })); + const stale = resolved + .filter(({ range }) => range === null) + .map(({ hunk }) => hunk.id); + if (documentEpoch !== proposal.documentEpoch) stale.push(...selected.map((hunk) => hunk.id)); + + const valid = resolved + .filter((item): item is { hunk: ProposalHunk; range: { from: number; to: number } } => + item.range !== null, + ) + .sort((left, right) => left.range.from - right.range.from); + for (let index = 1; index < valid.length; index += 1) { + const previous = valid[index - 1].range; + const current = valid[index].range; + if (current.from < previous.to || + (current.from === previous.from && current.to === previous.to)) { + stale.push(valid[index - 1].hunk.id, valid[index].hunk.id); + } + } + if (stale.length > 0) { + const staleIds = [...new Set(stale)]; + throw new StaleProposalError( + staleIds, + withStatuses(proposal, new Set(staleIds), "stale"), + ); + } + + const transaction = valid + .sort((left, right) => right.range.from - left.range.from) + .reduce( + (next, { hunk, range }) => + next.replace( + range.from, + range.to, + new Slice(Fragment.fromJSON(editor.schema, hunk.replacement), 0, 0), + ), + editor.state.tr, + ) + .setMeta("addToHistory", false); + editor.view.dispatch(transaction); + + return withStatuses(proposal, selectedIds, "accepted"); +} + +export function rejectProposalHunks({ + proposal, + hunkIds, +}: { + proposal: WorkspaceProposal; + hunkIds: string[]; +}): WorkspaceProposal { + const selectedIds = new Set(hunkIds); + if (selectedIds.size !== hunkIds.length) throw new Error("Duplicate proposal hunk ID"); + const selected = proposal.hunks.filter((hunk) => selectedIds.has(hunk.id)); + if (selected.length !== selectedIds.size) throw new Error("Unknown proposal hunk ID"); + if (selected.some((hunk) => hunk.status !== "pending")) { + throw new Error("Only pending proposal hunks can be rejected"); + } + return withStatuses(proposal, selectedIds, "rejected"); +} + +export function createQuoteAnchor({ + id, + documentEpoch, + editor, + from, + to, + quote, +}: { + id: string; + documentEpoch: number; + editor: Editor; + from: number; + to: number; + quote: string; +}): QuoteAnchor { + if (from < 0 || to <= from || to > editor.state.doc.content.size) { + throw new RangeError(`Invalid quote range ${from}..${to}`); + } + if (editor.state.doc.textBetween(from, to) !== quote) { + throw new Error("Quote does not match the anchored text"); + } + return { + id, + documentEpoch, + from: Y.encodeRelativePosition( + createMappablePosition(from, editor.state).yRelativePosition, + ), + to: Y.encodeRelativePosition( + createMappablePosition(to, editor.state).yRelativePosition, + ), + quote, + }; +} + +export function resolveQuoteAnchor({ + anchor, + documentEpoch, + editor, +}: { + anchor: QuoteAnchor; + documentEpoch: number; + editor: Editor; +}): + | { status: "attached"; from: number; to: number; quote: string } + | { status: "detached"; quote: string } { + if (anchor.documentEpoch !== documentEpoch) { + return { status: "detached", quote: anchor.quote }; + } + const range = resolveAnchors(editor, anchor.from, anchor.to); + if (!range || editor.state.doc.textBetween(range.from, range.to) !== anchor.quote) { + return { status: "detached", quote: anchor.quote }; + } + return { status: "attached", ...range, quote: anchor.quote }; +} diff --git a/src/lib/workspace/schema.ts b/src/lib/workspace/schema.ts index 0ff27a41b..78d5f3859 100644 --- a/src/lib/workspace/schema.ts +++ b/src/lib/workspace/schema.ts @@ -1,4 +1,4 @@ -import { getSchema } from "@tiptap/core"; +import { getSchema, Node } from "@tiptap/core"; import { TableKit } from "@tiptap/extension-table"; import StarterKit from "@tiptap/starter-kit"; import { yXmlFragmentToProsemirrorJSON } from "@tiptap/y-tiptap"; @@ -6,11 +6,41 @@ import type * as Y from "yjs"; export const WORKSPACE_CONTENT_FIELD = "content"; +export const InlineCitation = Node.create({ + name: "citation", + group: "inline", + inline: true, + atom: true, + selectable: false, + addAttributes() { + return { + sourceId: { default: null }, + title: { default: null }, + locator: { default: null }, + quote: { default: null }, + href: { default: null }, + }; + }, + parseHTML() { + return [{ tag: "span[data-citation-source-id]" }]; + }, + renderHTML({ node }) { + return [ + "span", + { "data-citation-source-id": node.attrs.sourceId }, + node.attrs.locator ? `[${node.attrs.locator}]` : "[citation]", + ]; + }, + renderText({ node }) { + return node.attrs.locator ? `[${node.attrs.locator}]` : "[citation]"; + }, +}); + export function workspaceSchemaExtensions() { - return [StarterKit.configure({ undoRedo: false }), TableKit]; + return [StarterKit.configure({ undoRedo: false }), TableKit, InlineCitation]; } -const workspaceSchema = getSchema(workspaceSchemaExtensions()); +export const workspaceSchema = getSchema(workspaceSchemaExtensions()); export function validateWorkspaceDocument(document: Y.Doc) { for (const name of document.share.keys()) { diff --git a/src/test/workspace/document-conversion.test.ts b/src/test/workspace/document-conversion.test.ts new file mode 100644 index 000000000..47648770a --- /dev/null +++ b/src/test/workspace/document-conversion.test.ts @@ -0,0 +1,147 @@ +import type { JSONContent } from "@tiptap/core"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs"; +import { describe, expect, it, vi } from "vitest"; +import { + fromWorkspaceDocx, + fromWorkspaceHtml, + fromWorkspaceMarkdown, + toWorkspaceDocx, + toWorkspaceMarkdown, + toWorkspacePdf, +} from "@/lib/workspace/document-conversion"; +import { + STRUCTURED_SPEECH, + STRUCTURED_SPEECH_TEXT, +} from "./fixtures/structured-speech"; + +const EXPECTED_SEMANTICS = { + headings: [{ level: 1, text: "A fair transition" }], + bulletListDepths: [0, 1], + links: [{ href: "https://example.com/evidence", text: "source" }], + table: [ + ["Metric", "Value"], + ["Access", "Equal"], + ], + citations: [ + { + sourceId: "source-1", + title: "Public evidence", + locator: "p. 7", + quote: "Equal access", + href: "https://example.com/evidence", + }, + ], +}; + +function nodeText(node: JSONContent): string { + if (node.type === "citation") return ""; + return node.text ?? node.content?.map(nodeText).join("") ?? ""; +} + +function documentSemantics(document: JSONContent) { + const headings: Array<{ level: number; text: string }> = []; + const bulletListDepths: number[] = []; + const links: Array<{ href: string; text: string }> = []; + const table: string[][] = []; + const citations: Array> = []; + + function visit(node: JSONContent, listDepth = 0) { + if (node.type === "heading") { + headings.push({ level: Number(node.attrs?.level), text: nodeText(node) }); + } + if (node.type === "bulletList") { + bulletListDepths.push(listDepth); + listDepth += 1; + } + if (node.type === "text") { + const link = node.marks?.find((mark) => mark.type === "link"); + if (link) links.push({ href: String(link.attrs?.href), text: node.text ?? "" }); + } + if (node.type === "table") { + for (const row of node.content ?? []) { + table.push((row.content ?? []).map(nodeText)); + } + } + if (node.type === "citation") citations.push(node.attrs ?? {}); + node.content?.forEach((child) => visit(child, listDepth)); + } + + visit(document); + return { headings, bulletListDepths, links, table, citations }; +} + +describe("workspace document conversion", () => { + it("round-trips supported semantics and multilingual text through Markdown", () => { + expect(documentSemantics(STRUCTURED_SPEECH)).toEqual(EXPECTED_SEMANTICS); + + const markdown = toWorkspaceMarkdown(STRUCTURED_SPEECH); + const roundTrip = fromWorkspaceMarkdown(markdown); + + expect(documentSemantics(roundTrip)).toEqual(EXPECTED_SEMANTICS); + const visibleText = nodeText(roundTrip); + for (const text of STRUCTURED_SPEECH_TEXT) { + expect(visibleText).toContain(text); + } + }); + + it("round-trips supported semantics and multilingual text through DOCX", async () => { + expect(toWorkspaceDocx).toBeTypeOf("function"); + expect(fromWorkspaceDocx).toBeTypeOf("function"); + + const docx = await toWorkspaceDocx(STRUCTURED_SPEECH); + expect(Array.from(docx.slice(0, 2))).toEqual([0x50, 0x4b]); + const roundTrip = await fromWorkspaceDocx(docx); + + expect(documentSemantics(roundTrip)).toEqual(EXPECTED_SEMANTICS); + const visibleText = nodeText(roundTrip); + for (const text of STRUCTURED_SPEECH_TEXT) { + expect(visibleText).toContain(text); + } + }); + + it("renders selectable multilingual PDF text with intact glyphs", async () => { + const fontData = await readFile(resolve( + process.cwd(), + "node_modules/pdfjs-dist/standard_fonts/LiberationSans-Regular.ttf", + )); + const bytes = await toWorkspacePdf(STRUCTURED_SPEECH, { fontData }); + const loadingTask = getDocument({ data: bytes, useWorkerFetch: false }); + const pdf = await loadingTask.promise; + const pages: string[] = []; + for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { + const page = await pdf.getPage(pageNumber); + const content = await page.getTextContent(); + pages.push(content.items.map((item) => "str" in item ? item.str : "").join(" ")); + page.cleanup(); + } + await loadingTask.destroy(); + + const extracted = pages.join(" ").replace(/\s+/g, " "); + for (const text of STRUCTURED_SPEECH_TEXT) { + expect(extracted).toContain(text); + } + expect(extracted).toContain("[Public evidence, p. 7]"); + }); + + it("keeps malformed citations, scripts, and unsafe links outside imported schema", () => { + const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const imported = fromWorkspaceHtml(` +

Safe heading

+

+ plain unsafe label + + broken citation label +

+ `); + const serialized = JSON.stringify(imported); + + expect(nodeText(imported)).toContain("plain unsafe label"); + expect(nodeText(imported)).toContain("broken citation label"); + expect(serialized).not.toContain("hidden payload"); + expect(serialized).not.toContain('"type":"link"'); + expect(serialized).not.toContain('"type":"citation"'); + warning.mockRestore(); + }); +}); diff --git a/src/test/workspace/fixtures/structured-speech.ts b/src/test/workspace/fixtures/structured-speech.ts new file mode 100644 index 000000000..cb13205f6 --- /dev/null +++ b/src/test/workspace/fixtures/structured-speech.ts @@ -0,0 +1,152 @@ +import type { JSONContent } from "@tiptap/core"; + +export const STRUCTURED_SPEECH_TEXT = [ + "A fair transition", + "English opening with café evidence", + "Español: la niñez y la acción pública", + "Magyar: árvíztűrő tükörfúrógép", + "First response", + "Nested reply", + "Metric", + "Value", + "Access", + "Equal", +] as const; + +export const STRUCTURED_SPEECH: JSONContent = { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[0] }], + }, + { + type: "paragraph", + content: [ + { type: "text", text: `${STRUCTURED_SPEECH_TEXT[1]} ` }, + { + type: "text", + marks: [ + { + type: "link", + attrs: { + href: "https://example.com/evidence", + target: "_blank", + rel: "noopener noreferrer nofollow", + class: null, + title: null, + }, + }, + ], + text: "source", + }, + { type: "text", text: ". " }, + { + type: "citation", + attrs: { + sourceId: "source-1", + title: "Public evidence", + locator: "p. 7", + quote: "Equal access", + href: "https://example.com/evidence", + }, + }, + ], + }, + { + type: "paragraph", + content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[2] }], + }, + { + type: "paragraph", + content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[3] }], + }, + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[4] }], + }, + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: STRUCTURED_SPEECH_TEXT[5] }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + { + type: "table", + content: [ + { + type: "tableRow", + content: [ + { + type: "tableHeader", + attrs: { colspan: 1, rowspan: 1, colwidth: null, align: null }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[6] }], + }, + ], + }, + { + type: "tableHeader", + attrs: { colspan: 1, rowspan: 1, colwidth: null, align: null }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[7] }], + }, + ], + }, + ], + }, + { + type: "tableRow", + content: [ + { + type: "tableCell", + attrs: { colspan: 1, rowspan: 1, colwidth: null, align: null }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[8] }], + }, + ], + }, + { + type: "tableCell", + attrs: { colspan: 1, rowspan: 1, colwidth: null, align: null }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[9] }], + }, + ], + }, + ], + }, + ], + }, + { type: "paragraph" }, + ], +}; diff --git a/src/test/workspace/review.test.ts b/src/test/workspace/review.test.ts new file mode 100644 index 000000000..9a97dd14a --- /dev/null +++ b/src/test/workspace/review.test.ts @@ -0,0 +1,288 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { yUndoPluginKey } from "@tiptap/y-tiptap"; +import * as Y from "yjs"; +import { createWorkspaceEditor } from "@/lib/workspace/editor-schema"; +import * as review from "@/lib/workspace/review"; +import { + STRUCTURED_SPEECH, + STRUCTURED_SPEECH_TEXT, +} from "./fixtures/structured-speech"; + +describe("workspace review model", () => { + const cleanup: Array<() => void> = []; + + afterEach(() => { + cleanup.splice(0).forEach((dispose) => dispose()); + }); + + function createEditor(document = new Y.Doc()) { + const editor = createWorkspaceEditor(document); + cleanup.push(() => editor.destroy()); + if (arguments.length === 0) cleanup.push(() => document.destroy()); + return editor; + } + + it("keeps the representative multilingual document structured", () => { + const editor = createEditor(); + + expect(editor.commands.setContent(STRUCTURED_SPEECH)).toBe(true); + + expect(editor.getJSON()).toEqual(STRUCTURED_SPEECH); + expect(editor.getJSON()).toMatchObject({ + content: expect.arrayContaining([ + expect.objectContaining({ type: "heading" }), + expect.objectContaining({ type: "bulletList" }), + expect.objectContaining({ type: "table" }), + ]), + }); + expect(JSON.stringify(editor.getJSON())).toContain('"type":"citation"'); + for (const text of STRUCTURED_SPEECH_TEXT) { + expect(editor.getText()).toContain(text); + } + }); + + it("keeps suggested insert, delete, and formatting outside accepted content", async () => { + const editor = createEditor(); + editor.commands.setContent("

Hello world

"); + const acceptedBefore = editor.getJSON(); + const composed = " tűrő".normalize("NFC"); + + const proposal = review.createProposal({ + id: "proposal-1", + authorId: "member-1", + documentEpoch: 1, + editor, + hunks: [ + { id: "insert", kind: "insert", at: 6, content: [{ type: "text", text: composed }] }, + { id: "delete", kind: "delete", from: 7, to: 12 }, + { + id: "format", + kind: "format", + from: 1, + to: 6, + content: [ + { type: "text", text: "Hello", marks: [{ type: "bold" }] }, + ], + }, + ], + }); + + expect(editor.getJSON()).toEqual(acceptedBefore); + expect(proposal.hunks.map((hunk) => [hunk.kind, hunk.status])).toEqual([ + ["insert", "pending"], + ["delete", "pending"], + ["format", "pending"], + ]); + expect(proposal.hunks[0].replacement).toEqual([ + { type: "text", text: composed }, + ]); + expect(proposal.hunks[2].replacement).toEqual([ + { type: "text", text: "Hello", marks: [{ type: "bold" }] }, + ]); + expect(proposal.hunks[1].original).toEqual([ + { type: "text", text: "world" }, + ]); + }); + + it("accepts one independent hunk and keeps teammate text outside local undo", async () => { + expect(review.acceptProposalHunks).toBeTypeOf("function"); + expect(review.rejectProposalHunks).toBeTypeOf("function"); + + const leftDocument = new Y.Doc(); + const rightDocument = new Y.Doc(); + const fromLeft = Symbol("from-left"); + const fromRight = Symbol("from-right"); + const syncLeft = (update: Uint8Array, origin: unknown) => { + if (origin !== fromRight) Y.applyUpdate(rightDocument, update, fromLeft); + }; + const syncRight = (update: Uint8Array, origin: unknown) => { + if (origin !== fromLeft) Y.applyUpdate(leftDocument, update, fromRight); + }; + leftDocument.on("update", syncLeft); + rightDocument.on("update", syncRight); + cleanup.push( + () => leftDocument.off("update", syncLeft), + () => rightDocument.off("update", syncRight), + () => leftDocument.destroy(), + () => rightDocument.destroy(), + ); + const left = createEditor(leftDocument); + const right = createEditor(rightDocument); + left.commands.setContent("

Alpha Beta Gamma

"); + expect(right.getJSON()).toEqual(left.getJSON()); + + const proposal = review.createProposal({ + id: "proposal-2", + authorId: "member-1", + documentEpoch: 1, + editor: left, + hunks: [ + { + id: "format-beta", + kind: "format", + from: 7, + to: 11, + content: [ + { type: "text", text: "Beta", marks: [{ type: "bold" }] }, + ], + }, + { id: "delete-gamma", kind: "delete", from: 12, to: 17 }, + ], + }); + + yUndoPluginKey.getState(left.state).undoManager.clear(); + yUndoPluginKey.getState(right.state).undoManager.clear(); + right.commands.insertContentAt(1, "Remote "); + left.commands.insertContentAt(left.state.doc.content.size - 1, " Local"); + + const accepted = review.acceptProposalHunks({ + editor: left, + documentEpoch: 1, + proposal, + hunkIds: ["format-beta"], + }); + const reviewed = review.rejectProposalHunks({ + proposal: accepted, + hunkIds: ["delete-gamma"], + }); + + expect(reviewed.hunks.map((hunk) => [hunk.id, hunk.status])).toEqual([ + ["format-beta", "accepted"], + ["delete-gamma", "rejected"], + ]); + expect(left.getText()).toBe("Remote Alpha Beta Gamma Local"); + expect(right.getJSON()).toEqual(left.getJSON()); + expect(left.getJSON()).toEqual( + expect.objectContaining({ + content: expect.arrayContaining([ + expect.objectContaining({ + content: expect.arrayContaining([ + expect.objectContaining({ + text: "Beta", + marks: [{ type: "bold" }], + }), + ]), + }), + ]), + }), + ); + + expect(left.commands.undo()).toBe(true); + expect(left.getText()).toBe("Remote Alpha Beta Gamma"); + expect(JSON.stringify(left.getJSON())).toContain('"type":"bold"'); + expect(left.commands.redo()).toBe(true); + expect(left.getText()).toBe("Remote Alpha Beta Gamma Local"); + expect(JSON.stringify(left.getJSON())).toContain('"type":"bold"'); + }); + + it("marks changed and overlapping exact targets stale without fuzzy replacement", async () => { + const changed = createEditor(); + changed.commands.setContent("

alpha beta alpha

"); + const changedProposal = review.createProposal({ + id: "proposal-3", + authorId: "member-1", + documentEpoch: 1, + editor: changed, + hunks: [ + { + id: "changed-target", + kind: "format", + from: 7, + to: 11, + content: [ + { type: "text", text: "beta", marks: [{ type: "bold" }] }, + ], + }, + ], + }); + changed.commands.insertContentAt({ from: 7, to: 11 }, "alpha"); + + let changedError: unknown; + try { + review.acceptProposalHunks({ + editor: changed, + documentEpoch: 1, + proposal: changedProposal, + hunkIds: ["changed-target"], + }); + } catch (error) { + changedError = error; + } + expect(changedError).toBeInstanceOf(review.StaleProposalError); + if (!(changedError instanceof review.StaleProposalError)) throw changedError; + expect(changedError.proposal.hunks[0].status).toBe("stale"); + expect(changed.getText()).toBe("alpha alpha alpha"); + expect(JSON.stringify(changed.getJSON())).not.toContain('"type":"bold"'); + + const overlapping = createEditor(); + overlapping.commands.setContent("

abcdefghij

"); + const overlappingProposal = review.createProposal({ + id: "proposal-4", + authorId: "member-1", + documentEpoch: 1, + editor: overlapping, + hunks: [ + { + id: "left-range", + kind: "format", + from: 2, + to: 6, + content: [{ type: "text", text: "bcde", marks: [{ type: "bold" }] }], + }, + { id: "right-range", kind: "delete", from: 4, to: 8 }, + ], + }); + + let overlapError: unknown; + try { + review.acceptProposalHunks({ + editor: overlapping, + documentEpoch: 1, + proposal: overlappingProposal, + hunkIds: ["left-range", "right-range"], + }); + } catch (error) { + overlapError = error; + } + expect(overlapError).toBeInstanceOf(review.StaleProposalError); + if (!(overlapError instanceof review.StaleProposalError)) throw overlapError; + expect(overlapError.proposal.hunks.map((hunk) => hunk.status)).toEqual([ + "stale", + "stale", + ]); + expect(overlapping.getText()).toBe("abcdefghij"); + }); + + it("keeps quote anchors through unrelated insertion and detaches on target deletion", async () => { + expect(review.createQuoteAnchor).toBeTypeOf("function"); + expect(review.resolveQuoteAnchor).toBeTypeOf("function"); + + const editor = createEditor(); + editor.commands.setContent("

Claim target context

"); + const anchor = review.createQuoteAnchor({ + id: "comment-1", + documentEpoch: 1, + editor, + from: 7, + to: 13, + quote: "target", + }); + + editor.commands.insertContentAt(1, "Unrelated "); + const moved = review.resolveQuoteAnchor({ + anchor, + documentEpoch: 1, + editor, + }); + expect(moved).toMatchObject({ status: "attached", quote: "target" }); + if (moved.status !== "attached") return; + expect(editor.state.doc.textBetween(moved.from, moved.to)).toBe("target"); + + editor.commands.deleteRange({ from: moved.from, to: moved.to }); + expect(review.resolveQuoteAnchor({ + anchor, + documentEpoch: 1, + editor, + })).toEqual({ status: "detached", quote: "target" }); + }); +}); From ed047b84fab08218279c0ec125e330f802486b9c Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 20 Sep 2026 17:35:31 +0000 Subject: [PATCH 2/2] fix(workspace): gate IME and concurrent proposals --- e2e/fixtures/workspace-review-ime.html | 102 +++++++++++++++++ e2e/workspace-review-ime.spec.ts | 114 +++++++++++++++++++ src/lib/workspace/review.ts | 10 ++ src/test/workspace/review.test.ts | 147 +++++++++++++++++++++---- 4 files changed, 353 insertions(+), 20 deletions(-) create mode 100644 e2e/fixtures/workspace-review-ime.html create mode 100644 e2e/workspace-review-ime.spec.ts diff --git a/e2e/fixtures/workspace-review-ime.html b/e2e/fixtures/workspace-review-ime.html new file mode 100644 index 000000000..c16d77f29 --- /dev/null +++ b/e2e/fixtures/workspace-review-ime.html @@ -0,0 +1,102 @@ + + + + + + Workspace IME review gate + + +
+ + + diff --git a/e2e/workspace-review-ime.spec.ts b/e2e/workspace-review-ime.spec.ts new file mode 100644 index 000000000..2caac968e --- /dev/null +++ b/e2e/workspace-review-ime.spec.ts @@ -0,0 +1,114 @@ +import { expect, test, type CDPSession, type Page } from "@playwright/test"; + +interface ImeSnapshot { + canonical: string; + canonicalUpdates: number; + suggestion: string; + editorComposing: boolean; + phase: "idle" | "composing" | "settled"; + events: Array<{ + type: string; + data: string | null; + inputType: string | null; + isComposing: boolean | null; + }>; + proposal: null | { + status: string; + replacement: Array<{ type: string; text?: string }>; + }; +} + +interface WorkspaceImeHarness { + snapshot(): ImeSnapshot; + capture(id: string): ImeSnapshot; + accept(): ImeSnapshot; + reject(): ImeSnapshot; + resetSuggestion(): ImeSnapshot; +} + +declare global { + interface Window { + workspaceIme: WorkspaceImeHarness; + } +} + +async function snapshot(page: Page) { + return page.evaluate(() => window.workspaceIme.snapshot()); +} + +async function compose( + page: Page, + cdp: CDPSession, + id: string, + steps: [string, ...string[]], + accepted: string, + acceptedUpdates: number, +) { + await page.locator(".ProseMirror").focus(); + for (const text of steps) { + await cdp.send("Input.imeSetComposition", { + text, + selectionStart: text.length, + selectionEnd: text.length, + }); + const during = await page.evaluate((proposalId) => + window.workspaceIme.capture(proposalId), id, + ); + expect(during.phase).toBe("composing"); + expect(during.proposal).toBeNull(); + expect(during.canonical).toBe(accepted); + expect(during.canonicalUpdates).toBe(acceptedUpdates); + } + await cdp.send("Input.insertText", { text: steps[steps.length - 1] }); + await expect.poll(async () => (await snapshot(page)).phase).toBe("settled"); + await expect.poll(async () => (await snapshot(page)).editorComposing).toBe(false); +} + +test("captures Spanish and Hungarian IME text only after composition settles", async ({ + page, + context, +}, testInfo) => { + test.skip(testInfo.project.name !== "desktop-chromium", "Chromium CDP provides native IME composition"); + + await page.goto("/e2e/fixtures/workspace-review-ime.html"); + await expect.poll(() => page.evaluate(() => Boolean(window.workspaceIme))).toBe(true); + const cdp = await context.newCDPSession(page); + + await compose(page, cdp, "spanish", ["n", "ni", "niñ", "niño"], "Accepted:", 0); + const spanishSettled = await page.evaluate(() => window.workspaceIme.capture("spanish")); + expect(spanishSettled.events.map((event) => event.type)).toEqual(expect.arrayContaining([ + "compositionstart", + "beforeinput", + "input", + "compositionend", + ])); + expect(spanishSettled.events.filter((event) => event.type === "beforeinput").every((event) => + event.inputType === "insertCompositionText" && event.isComposing, + )).toBe(true); + expect(spanishSettled.proposal).toEqual({ + status: "pending", + replacement: [{ type: "text", text: "niño" }], + }); + expect(spanishSettled.canonical).toBe("Accepted:"); + expect(spanishSettled.canonicalUpdates).toBe(0); + expect((await page.evaluate(() => window.workspaceIme.accept())).canonical).toBe("Accepted:niño"); + + await page.evaluate(() => window.workspaceIme.resetSuggestion()); + await compose( + page, + cdp, + "hungarian", + ["t", "tű", "tűr", "tűrő"], + "Accepted:niño", + 1, + ); + const hungarianSettled = await page.evaluate(() => window.workspaceIme.capture("hungarian")); + expect(hungarianSettled.proposal).toEqual({ + status: "pending", + replacement: [{ type: "text", text: "tűrő" }], + }); + expect(hungarianSettled.canonical).toBe("Accepted:niño"); + const rejected = await page.evaluate(() => window.workspaceIme.reject()); + expect(rejected.proposal?.status).toBe("rejected"); + expect(rejected.canonical).toBe("Accepted:niño"); +}); diff --git a/src/lib/workspace/review.ts b/src/lib/workspace/review.ts index ad01706bf..666c709f7 100644 --- a/src/lib/workspace/review.ts +++ b/src/lib/workspace/review.ts @@ -184,6 +184,16 @@ export function createProposal({ }; } +export function createProposalAfterComposition({ + compositionState, + proposal, +}: { + compositionState: "idle" | "composing" | "settled"; + proposal: Parameters[0]; +}): WorkspaceProposal | null { + return compositionState === "settled" ? createProposal(proposal) : null; +} + export function acceptProposalHunks({ editor, documentEpoch, diff --git a/src/test/workspace/review.test.ts b/src/test/workspace/review.test.ts index 9a97dd14a..a94b07e98 100644 --- a/src/test/workspace/review.test.ts +++ b/src/test/workspace/review.test.ts @@ -22,6 +22,33 @@ describe("workspace review model", () => { return editor; } + function createSyncedEditors() { + const leftDocument = new Y.Doc(); + const rightDocument = new Y.Doc(); + const fromLeft = Symbol("from-left"); + const fromRight = Symbol("from-right"); + const syncLeft = (update: Uint8Array, origin: unknown) => { + if (origin !== fromRight) Y.applyUpdate(rightDocument, update, fromLeft); + }; + const syncRight = (update: Uint8Array, origin: unknown) => { + if (origin !== fromLeft) Y.applyUpdate(leftDocument, update, fromRight); + }; + leftDocument.on("update", syncLeft); + rightDocument.on("update", syncRight); + cleanup.push( + () => leftDocument.off("update", syncLeft), + () => rightDocument.off("update", syncRight), + () => leftDocument.destroy(), + () => rightDocument.destroy(), + ); + return { + left: createEditor(leftDocument), + leftDocument, + right: createEditor(rightDocument), + rightDocument, + }; + } + it("keeps the representative multilingual document structured", () => { const editor = createEditor(); @@ -88,26 +115,7 @@ describe("workspace review model", () => { expect(review.acceptProposalHunks).toBeTypeOf("function"); expect(review.rejectProposalHunks).toBeTypeOf("function"); - const leftDocument = new Y.Doc(); - const rightDocument = new Y.Doc(); - const fromLeft = Symbol("from-left"); - const fromRight = Symbol("from-right"); - const syncLeft = (update: Uint8Array, origin: unknown) => { - if (origin !== fromRight) Y.applyUpdate(rightDocument, update, fromLeft); - }; - const syncRight = (update: Uint8Array, origin: unknown) => { - if (origin !== fromLeft) Y.applyUpdate(leftDocument, update, fromRight); - }; - leftDocument.on("update", syncLeft); - rightDocument.on("update", syncRight); - cleanup.push( - () => leftDocument.off("update", syncLeft), - () => rightDocument.off("update", syncRight), - () => leftDocument.destroy(), - () => rightDocument.destroy(), - ); - const left = createEditor(leftDocument); - const right = createEditor(rightDocument); + const { left, right } = createSyncedEditors(); left.commands.setContent("

Alpha Beta Gamma

"); expect(right.getJSON()).toEqual(left.getJSON()); @@ -175,6 +183,105 @@ describe("workspace review model", () => { expect(JSON.stringify(left.getJSON())).toContain('"type":"bold"'); }); + it("stales a remotely deleted and reinserted identical target", () => { + const { left, right, rightDocument } = createSyncedEditors(); + left.commands.setContent("

Alpha Beta Gamma

"); + const proposal = review.createProposal({ + id: "proposal-identity", + authorId: "member-1", + documentEpoch: 1, + editor: left, + hunks: [ + { + id: "format-beta", + kind: "format", + from: 7, + to: 11, + content: [ + { type: "text", text: "Beta", marks: [{ type: "bold" }] }, + ], + }, + ], + }); + + right.commands.insertContentAt(1, "Remote "); + const paragraph = rightDocument.getXmlFragment("content").get(0); + if (!(paragraph instanceof Y.XmlElement)) throw new Error("Expected paragraph element"); + const text = paragraph.get(0); + if (!(text instanceof Y.XmlText)) throw new Error("Expected paragraph text"); + rightDocument.transact(() => { + text.delete(13, 4); + text.insert(13, "Beta"); + }); + const beforeAcceptance = left.getJSON(); + + let stale: unknown; + try { + review.acceptProposalHunks({ + editor: left, + documentEpoch: 1, + proposal, + hunkIds: ["format-beta"], + }); + } catch (error) { + stale = error; + } + + expect(stale).toBeInstanceOf(review.StaleProposalError); + if (!(stale instanceof review.StaleProposalError)) throw stale; + expect(stale.proposal.hunks[0].status).toBe("stale"); + expect(left.getJSON()).toEqual(beforeAcceptance); + expect(right.getJSON()).toEqual(beforeAcceptance); + expect(left.getText()).toBe("Remote Alpha Beta Gamma"); + expect(JSON.stringify(left.getJSON())).not.toContain('"type":"bold"'); + }); + + it("stales a remotely overlapped target and preserves unrelated remote text", () => { + const { left, right } = createSyncedEditors(); + left.commands.setContent("

Alpha Beta Gamma

"); + const proposal = review.createProposal({ + id: "proposal-overlap", + authorId: "member-1", + documentEpoch: 1, + editor: left, + hunks: [ + { + id: "format-beta", + kind: "format", + from: 7, + to: 11, + content: [ + { type: "text", text: "Beta", marks: [{ type: "bold" }] }, + ], + }, + ], + }); + + right.commands.insertContentAt(1, "Remote "); + right.commands.insertContentAt(16, "X"); + const beforeAcceptance = left.getJSON(); + + let stale: unknown; + try { + review.acceptProposalHunks({ + editor: left, + documentEpoch: 1, + proposal, + hunkIds: ["format-beta"], + }); + } catch (error) { + stale = error; + } + + expect(stale).toBeInstanceOf(review.StaleProposalError); + if (!(stale instanceof review.StaleProposalError)) throw stale; + expect(stale.proposal.hunks[0].status).toBe("stale"); + expect(left.getJSON()).toEqual(beforeAcceptance); + expect(right.getJSON()).toEqual(beforeAcceptance); + expect(left.getText()).toBe("Remote Alpha BeXta Gamma"); + expect(JSON.stringify(left.getJSON())).not.toContain('"type":"bold"'); + }); + it("marks changed and overlapping exact targets stale without fuzzy replacement", async () => { const changed = createEditor(); changed.commands.setContent("

alpha beta alpha

");