diff --git a/apps/writer/package.json b/apps/writer/package.json index 9e413aa9..a5968b4d 100644 --- a/apps/writer/package.json +++ b/apps/writer/package.json @@ -6,6 +6,7 @@ "scripts": { "icons": "node scripts/write-pwa-icons.mjs", "build": "pnpm manifest && pnpm manifest:validate && vite build", + "build:lab": "VITE_MDBASE_ENV=lab pnpm build", "dev": "pnpm manifest && vite --host 127.0.0.1", "manifest": "node scripts/write-manifest.mjs", "manifest:validate": "mdbase-connect-dev validate-manifest public/.well-known/mdbase-app.json", @@ -32,13 +33,15 @@ "@lezer/highlight": "^1.2.3", "@lezer/markdown": "^1.4.3", "@mdbase-dev/connect": "0.1.0-beta.125", - "@mdbase-dev/ui": "0.1.0-beta.125", + "@mdbase-dev/connect-protocol": "file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.129-d765e675c3da.tgz", + "@mdbase-dev/sdk": "file:../../vendor/mdbase-dev-sdk-dev-9a033b25.tgz", + "@mdbase-dev/ui": "file:../../vendor/mdbase-dev-ui-dev-9553ad66.tgz", "@mdbase-writer/core": "workspace:*", "@myriaddreamin/typst-ts-renderer": "0.7.0", "@myriaddreamin/typst-ts-web-compiler": "0.7.0", "@myriaddreamin/typst.ts": "0.7.0", - "pandoc-wasm": "1.1.0", "@phosphor-icons/react": "^2.1.10", + "pandoc-wasm": "1.1.0", "react": "^19.2.8", "react-dom": "^19.2.8" }, @@ -46,9 +49,11 @@ "@callumalpass/mdbase": "0.3.0-rc.5", "@mdbase-dev/connect-dev": "0.1.0-beta.125", "@mdbase-dev/connect-testing": "0.1.0-beta.125", + "@testing-library/react": "16.3.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "jsdom": "29.1.1", "playwright": "1.61.1", "vite": "^8.3.1", "yaml": "^2.9.0" diff --git a/apps/writer/public/404.html b/apps/writer/public/404.html new file mode 100644 index 00000000..d28c4ecf --- /dev/null +++ b/apps/writer/public/404.html @@ -0,0 +1,53 @@ + + + + + + +Page not found · mdbase writer + + + + +
+

404

+

Page not found

+

There is nothing at this address.

+Open mdbase writer +
+ + diff --git a/apps/writer/scripts/browser-export-review-test.mjs b/apps/writer/scripts/browser-export-review-test.mjs new file mode 100644 index 00000000..2fa86d2b --- /dev/null +++ b/apps/writer/scripts/browser-export-review-test.mjs @@ -0,0 +1,67 @@ +// Loopback-only, real Pandoc conversion and browser downloads; no LAB credentials. +import assert from "node:assert/strict"; +import {mkdir} from "node:fs/promises"; +import {chromium} from "playwright"; +const base = process.argv[2] ?? "http://127.0.0.1:48332/"; +const origin = new URL(base).origin; +assert.ok(["127.0.0.1", "localhost", "[::1]"].includes(new URL(base).hostname)); +await mkdir("out", {recursive: true}); +const browser = await chromium.launch({executablePath: process.env.CHROME}); +const context = await browser.newContext({viewport: {width: 1280, height: 900}}); +await context.route("**/*", route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort()); +const page = await context.newPage(); +let downloads = 0; +page.on("download", () => downloads++); +try { + await page.goto(`${base}?demo`); + await page.locator(".manuscript-title").filter({hasText: "Patient Observation"}).click(); + await page.locator(".cm-content[contenteditable=true]").click(); + await page.keyboard.press("Control+a"); + await page.keyboard.insertText("# Unresolved citation test\n\n[test] Word export with [@bugfix3missing, p. 3].\n"); + await page.getByRole("button", {name: "Export formats", exact: true}).click(); + await page.getByRole("menuitem", {name: /Word/}).click(); + const review = page.getByRole("dialog", {name: "Review export problems", exact: true}); + await review.waitFor({timeout: 120000}); + assert.match(await review.innerText(), /bugfix3missing/); + assert.equal(downloads, 0); + await review.getByRole("button", {name: "Cancel and fix problems", exact: true}).click(); + await review.waitFor({state: "hidden"}); + assert.equal(downloads, 0); + console.log("PASS unresolved citation review and cancellation download nothing"); + + await page.getByRole("button", {name: "Export Word", exact: true}).click(); + await review.waitFor(); + // Listen BEFORE the click: the browser may emit download before click() resolves. + const [download] = await Promise.all([ + page.waitForEvent("download", {timeout: 20000}), + review.getByRole("button", {name: "Export anyway", exact: true}).click(), + ]); + await download.saveAs("out/unresolved-citation.docx"); + assert.equal(await download.failure(), null); + assert.match(download.suggestedFilename(), /\.docx$/); + await review.waitFor({state: "hidden"}); + await page.waitForTimeout(800); + assert.match(await page.getByRole("alert").innerText(), /Exported Word with/); + assert.equal(downloads, 1); + console.log("PASS consent downloads DOCX and leaves its warning visible after native close"); + + await page.evaluate(() => { + const original = URL.createObjectURL.bind(URL); + URL.createObjectURL = blob => { + if (blob.type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document") throw new Error("Controlled download refusal."); + return original(blob); + }; + }); + await page.getByRole("button", {name: "Export Word", exact: true}).click(); + await review.waitFor(); + await review.getByRole("button", {name: "Export anyway", exact: true}).click(); + await review.waitFor({state: "hidden"}); + await page.waitForTimeout(800); + assert.match(await page.getByRole("alert").innerText(), /The file could not be downloaded\. Controlled download refusal\./); + assert.equal(downloads, 1); + await page.getByRole("button", {name: "Retry export", exact: true}).waitFor(); + console.log("PASS failed download leaves an actionable error after native close"); +} catch (error) { + await page.screenshot({path: "out/export-review-failure.png"}); + throw error; +} finally { await context.close(); await browser.close(); } diff --git a/apps/writer/scripts/browser-find-panel-test.mjs b/apps/writer/scripts/browser-find-panel-test.mjs new file mode 100644 index 00000000..c90748cb --- /dev/null +++ b/apps/writer/scripts/browser-find-panel-test.mjs @@ -0,0 +1,57 @@ +// Isolated loopback demo and real CodeMirror/CSS; no LAB credentials. +import assert from "node:assert/strict"; +import {mkdir} from "node:fs/promises"; +import {chromium} from "playwright"; +const base = process.argv[2] ?? "http://127.0.0.1:48332/"; +const origin = new URL(base).origin; +assert.ok(["127.0.0.1", "localhost", "[::1]"].includes(new URL(base).hostname)); +await mkdir("out", {recursive: true}); +const browser = await chromium.launch({executablePath: process.env.CHROME}); +const context = await browser.newContext({viewport: {width: 1280, height: 900}}); +await context.route("**/*", route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort()); +const page = await context.newPage(); +try { + await page.goto(`${base}?demo`); + await page.locator(".manuscript-title").filter({hasText: "Patient Observation"}).click(); + for (const width of [1280, 390, 320]) { + await page.setViewportSize({width, height: width === 1280 ? 900 : 844}); + if (width < 900) await page.getByRole("navigation", {name: "View", exact: true}).getByRole("button", {name: /Write/}).click(); + const panel = page.locator(".cm-search"); + if (!await panel.isVisible()) { + await page.locator(".cm-content[contenteditable=true]").click(); + await page.keyboard.press("Control+f"); + } + await panel.waitFor(); + const geometry = await panel.locator("label").evaluateAll(labels => labels.map(label => { + const input = label.querySelector("input"); + const text = [...label.childNodes].find(node => node.nodeType === Node.TEXT_NODE && node.textContent.trim()); + const range = document.createRange(); + range.selectNodeContents(text); + const box = input.getBoundingClientRect(), caption = range.getBoundingClientRect(), target = label.getBoundingClientRect(); + return {text: label.innerText, width: box.width, gap: caption.left - box.right, center: Math.abs((caption.top + caption.bottom - box.top - box.bottom) / 2), targetHeight: target.height}; + })); + assert.equal(geometry.length, 3); + for (const option of geometry) { + assert.ok(option.width <= 24, `${width}px stretched checkbox: ${JSON.stringify(option)}`); + assert.ok(option.gap >= 0 && option.gap <= 12, `${width}px separated label: ${JSON.stringify(option)}`); + assert.ok(option.center <= 6, `${width}px separate rows: ${JSON.stringify(option)}`); + if (width < 900) assert.ok(option.targetHeight >= 44, `${width}px small touch label: ${JSON.stringify(option)}`); + } + for (const name of ["match case", "regexp", "by word"]) { + const checkbox = panel.getByRole("checkbox", {name, exact: true}); + const before = await checkbox.isChecked(); + await panel.locator("label").filter({hasText: name}).click(); + assert.equal(await checkbox.isChecked(), !before); + await checkbox.focus(); + await page.keyboard.press("Space"); + assert.equal(await checkbox.isChecked(), before); + } + const bounds = await panel.boundingBox(); + assert.ok(bounds.x >= 0 && bounds.x + bounds.width <= width + 1); + await page.screenshot({path: `out/find-panel-${width}.png`}); + console.log(`PASS ${width}px adjacent labels, label/keyboard toggles and panel fits`); + } +} catch (error) { + await page.screenshot({path: "out/find-panel-failure.png"}); + throw error; +} finally { await context.close(); await browser.close(); } diff --git a/apps/writer/scripts/browser-install-test.mjs b/apps/writer/scripts/browser-install-test.mjs new file mode 100644 index 00000000..bfd456d9 --- /dev/null +++ b/apps/writer/scripts/browser-install-test.mjs @@ -0,0 +1,51 @@ +// Isolated loopback demo, real Writer CSS and install invitation; no LAB credentials. +import assert from "node:assert/strict"; +import {chromium} from "playwright"; +const base = process.argv[2] ?? "http://127.0.0.1:48332/"; +const origin = new URL(base).origin; +assert.ok(["127.0.0.1", "localhost", "[::1]"].includes(new URL(base).hostname)); +const browser = await chromium.launch({executablePath: process.env.CHROME}); +const context = await browser.newContext({viewport: {width: 390, height: 844}}); +await context.route("**/*", route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort()); +const page = await context.newPage(); +try { + await page.goto(`${base}?demo`); + await page.locator(".manuscript-title").filter({hasText: "Patient Observation"}).click(); + await page.locator(".pane-tabs").waitFor(); + // A controlled browser invitation, never an actual installation. + await page.evaluate(() => { + const event = new Event("beforeinstallprompt", {cancelable: true}); + Object.assign(event, {prompt: async () => {}, userChoice: Promise.resolve({outcome: "dismissed"})}); + window.dispatchEvent(event); + }); + await page.locator(".pwa-install").waitFor({timeout: 35000}); + for (const [width, height] of [[390, 844], [320, 568], [844, 390]]) { + await page.setViewportSize({width, height}); + for (const name of ["Sidebar", "Write", "Preview"]) { + await page.getByRole("navigation", {name: "View", exact: true}).getByRole("button", {name: new RegExp(`^${name}`)}).click(); + const geometry = await page.evaluate(() => { + const banner = document.querySelector(".pwa-install"); + const prompt = banner.getBoundingClientRect(); + const nav = document.querySelector(".pane-tabs").getBoundingClientRect(); + const withPrompt = document.documentElement.scrollWidth; + banner.hidden = true; + const withoutPrompt = document.documentElement.scrollWidth; + banner.hidden = false; + return {gap: nav.top - prompt.bottom, top: prompt.top, fits: prompt.left >= 0 && prompt.right <= innerWidth, withPrompt, withoutPrompt}; + }); + assert.ok(geometry.gap >= 8, `${width}px ${name}: ${JSON.stringify(geometry)}`); + assert.ok(geometry.top >= 0); assert.equal(geometry.fits, true); + // A zoomed document preview may itself be wider; the invitation must add no overflow. + assert.equal(geometry.withPrompt, geometry.withoutPrompt, `${width}px ${name}: ${JSON.stringify(geometry)}`); + } + console.log(`PASS install invitation leaves all three mode controls reachable at ${width}x${height}`); + } + await page.setViewportSize({width: 1280, height: 900}); + assert.equal(await page.locator(".pwa-install").evaluate(e => getComputedStyle(e).bottom), "16px"); + await page.setViewportSize({width: 390, height: 844}); + await page.getByRole("button", {name: "Manuscripts", exact: true}).click(); + assert.equal(await page.locator(".pwa-install").evaluate(e => getComputedStyle(e).bottom), "16px"); + await page.getByRole("button", {name: "Not now", exact: true}).click(); + assert.equal(await page.locator(".pwa-install").count(), 0); + console.log("PASS desktop/home positioning and dismissal"); +} catch (error) {await page.screenshot({path: "out/install-prompt-failure.png"}); throw error;} finally {await context.close(); await browser.close();} diff --git a/apps/writer/scripts/browser-reliability-test.mjs b/apps/writer/scripts/browser-reliability-test.mjs index 1cf6d0d3..a659b556 100644 --- a/apps/writer/scripts/browser-reliability-test.mjs +++ b/apps/writer/scripts/browser-reliability-test.mjs @@ -229,6 +229,49 @@ try { assert.equal(await body(page, main), before); }); + for (const list of [false, true]) await scenario(`${list ? "list" : "paragraph"} Enter stays visible while typing and undo redo retain exact source`, async (page) => { + await open(page); + await page.evaluate((main) => window.writer.workspace.setBody(main, "# [test] typing\n\n"), main); + await page.locator(".cm-content").click(); await page.keyboard.press("ControlOrMeta+End"); + for (const text of [list ? "- One" : "One", "Two", "Three"]) { + await page.keyboard.type(text); await page.keyboard.press("Enter"); + } + if (list) await page.keyboard.press("Enter"); + await page.keyboard.type("After"); await page.keyboard.press("Enter"); + const expected = `# [test] typing\n\n${list ? "- One\n- Two\n- Three\n" : "One\nTwo\nThree"}\nAfter\n`; + assert.equal(await body(page, main), expected); + const lines = await page.locator(".cm-line").allTextContents(); + assert.ok(lines.includes(list ? "- Three" : "Three"), "the third line must not merge with After"); + assert.ok(lines.includes("After"), "the new line is visible, not an atomic hidden range"); + await page.waitForTimeout(600); // Start a separate typing history group. + await page.keyboard.type("Undo marker."); + assert.equal(await body(page, main), `${expected}Undo marker.`, "typing after Enter keeps the newline"); + await page.keyboard.press("ControlOrMeta+z"); assert.equal(await body(page, main), expected, "Undo restores the exact prior body"); + // A real shifted letter reports key="Z". Playwright's lowercase +Shift+z + // reports key="z", which CodeMirror correctly handles as the Undo binding. + await page.keyboard.press("ControlOrMeta+Shift+Z"); assert.equal(await body(page, main), `${expected}Undo marker.`, "Redo restores the exact typed body"); + assert.ok((await page.locator(".cm-line").allTextContents()).includes("Undo marker.")); + if (!await page.evaluate(() => /Mac/.test(navigator.platform))) { + await page.keyboard.press("Control+z"); assert.equal(await body(page, main), expected); + await page.keyboard.press("Control+y"); assert.equal(await body(page, main), `${expected}Undo marker.`); + } + // Reload starts with the caret elsewhere. Moving it away must never make + // a stored Enter vanish from the displayed lines (the old folding bug). + await page.keyboard.press("ControlOrMeta+Home"); + assert.ok((await page.locator(".cm-line").allTextContents()).includes("After")); + assert.ok((await page.locator(".cm-line").allTextContents()).includes(list ? "- Three" : "Three")); + assert.equal(await body(page, main), `${expected}Undo marker.`); + }); + + await scenario("explicit bullet after automatic continuation is not doubled", async (page) => { + await open(page); + await page.evaluate((path) => window.writer.workspace.setBody(path, "# [test] manual markers\n\n"), main); + await page.locator(".cm-content").click(); await page.keyboard.press("ControlOrMeta+End"); + await page.keyboard.type("- Staghorn corals"); await page.keyboard.press("Enter"); + await page.keyboard.type("- Brain corals"); + assert.equal(await body(page, main), "# [test] manual markers\n\n- Staghorn corals\n- Brain corals"); + }); + await scenario("chapter switching retains undo redo selection and scroll", async (page) => { await open(page); await painted(page); const chapter = "chapters/worms.md"; @@ -258,7 +301,7 @@ try { await page.locator(".cm-content").evaluate((el) => el.focus({ preventScroll: true })); await page.keyboard.press("ControlOrMeta+z"); assert.equal(await body(page, chapter), before); - await page.keyboard.press("ControlOrMeta+Shift+z"); + await page.keyboard.press("ControlOrMeta+Shift+Z"); assert.match(await body(page, chapter), /Retained history marker/); await page.keyboard.type(" Cursor kept."); assert.equal((await body(page, chapter)).endsWith("Retained history marker. Cursor kept."), true); diff --git a/apps/writer/scripts/browser-test.mjs b/apps/writer/scripts/browser-test.mjs index a1fa0b4d..5d3ed0bd 100644 --- a/apps/writer/scripts/browser-test.mjs +++ b/apps/writer/scripts/browser-test.mjs @@ -116,7 +116,8 @@ async function typeAtEndOfParagraph(needle, text) { await step("home lists the demo manuscripts", async () => { await page.goto(`${base}?demo`); await page.getByRole("heading", { name: "Manuscripts" }).waitFor(); - const titles = await page.locator(".manuscript-title").allTextContents(); + // Recovery drafts share the title class, but are not registered manuscripts. + const titles = await page.locator(".manuscript-row[data-path] .manuscript-title").allTextContents(); assert.deepEqual(titles, ["Patient Observation", "Small Agents, Large Effects: Darwin and the Science of Slow Change"]); }); @@ -226,8 +227,9 @@ await step("an embedded Reader annotation shows and typesets as its quotation, a await step("a toolbar over a selection formats it and finds a source for it", async () => { const before = await editorText(); const bar = page.locator(".cm-selection-bar"); - // Double-click "Charles" (hard-wrapped lines are joined), then take "Lyell" too. - const charles = await page.evaluate(() => { + // Scrolling the larger source-line view updates CodeMirror's viewport on a + // frame. Wait for its text nodes, then double-click Charles and take Lyell. + const charles = await page.waitForFunction(() => { const walker = document.createTreeWalker(document.querySelector(".cm-content"), NodeFilter.SHOW_TEXT); for (let node = walker.nextNode(); node; node = walker.nextNode()) { const at = node.textContent.indexOf("Charles Lyell, who"); @@ -240,8 +242,10 @@ await step("a toolbar over a selection formats it and finds a source for it", as } return null; }); - assert.ok(charles, "found “Charles Lyell, who” in the editor"); - await page.mouse.dblclick(charles.x, charles.y); + const position = await charles.jsonValue(); + await charles.dispose(); + assert.ok(position, "found “Charles Lyell, who” in the editor"); + await page.mouse.dblclick(position.x, position.y); await page.keyboard.press("ControlOrMeta+Shift+ArrowRight"); await bar.waitFor({ timeout: 5_000 }); await bar.getByRole("button", { name: "Bold" }).click(); diff --git a/apps/writer/scripts/manifest.test.mjs b/apps/writer/scripts/manifest.test.mjs index 7437ef64..cd5f9504 100644 --- a/apps/writer/scripts/manifest.test.mjs +++ b/apps/writer/scripts/manifest.test.mjs @@ -44,6 +44,13 @@ test("identity is optional: comments are unsigned without it", async () => { assert.deepEqual(manifest.requirements.people, { version: 1, optional: ["identity"] }); }); +test("image insertion declares add OPTIONAL without widening startup file access", async () => { + const manifest = await buildWriterManifest(); + assert.deepEqual(manifest.requirements.files, { + required: ["list", "read", "replace", "move"], optional: ["add"], scope: { kind: "collection" }, + }); +}); + // Published identities must not change. Add a new version/digest instead of updating this pin. // Pack digests are SHA-256 over the canonical (sorted-key) JSON of the whole pack manifest, // `upgrade_from` included; the beta.4 and beta.5 pins were confirmed with the mdbase CLI's `packs assess`. diff --git a/apps/writer/scripts/writer-manifest.mjs b/apps/writer/scripts/writer-manifest.mjs index 14f71ce9..095710ff 100644 --- a/apps/writer/scripts/writer-manifest.mjs +++ b/apps/writer/scripts/writer-manifest.mjs @@ -98,7 +98,11 @@ export async function buildWriterManifest({ origin = "https://writer.mdbase.dev" contract_version: 2, required: ["collection.read", "records.create", "records.edit"], }, - files: { required: ["list", "read"], scope: { kind: "collection" } }, + // Add is requested only at image insertion through SDK incremental consent. + // Native upload stays disabled until that public capability is available. + // Next accounts approve each record capability together with its file + // actions, so editing records also declares replace and move. + files: { required: ["list", "read", "replace", "move"], optional: ["add"], scope: { kind: "collection" } }, // The signed-in account, to sign comments with its person record. Optional: // without it, comments are unsigned. people: { version: 1, optional: ["identity"] }, diff --git a/apps/writer/src/App.tsx b/apps/writer/src/App.tsx index 4c777da1..23b84dd8 100644 --- a/apps/writer/src/App.tsx +++ b/apps/writer/src/App.tsx @@ -1,230 +1,29 @@ -// Top level: pick the backend (Connect, or the development demo), then show -// the manuscript list or an open manuscript. The open manuscript lives in the -// URL (`?manuscript=path`) so reloads and links land in the same place. -import { externalStore } from "@mdbase-dev/connect"; -import { Component, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; - -import { ConnectBackend } from "./backend/connect.js"; -import type { WriterBackend } from "./backend/types.js"; -import { createWriterSession, type WriterSession } from "./connect/session.js"; -import { appUrls } from "./apps.js"; +import { lazy, Suspense, useEffect, useMemo, useState } from "react"; import { AppSwitcher } from "@mdbase-dev/ui/app-switcher"; -import { FeedbackButton, useFeedback } from "@mdbase-dev/ui/feedback"; -import { useWriterFeedbackContext } from "./FeedbackRoot.js"; -import { ConnectGate } from "./ui/ConnectGate.js"; -import { CollectionPicker } from "./ui/CollectionPicker.js"; -import { Home } from "./ui/Home.js"; -import { localNamespace, recentManuscripts, recordOpened } from "./ui/recent.js"; -import { Dialog } from "@mdbase-dev/ui/dialog"; -import { zip } from "./export/zip.js"; +import { FeedbackButton } from "@mdbase-dev/ui/feedback"; import { loadThemePreference, saveThemePreference, type ThemePreference } from "@mdbase-dev/ui/theme"; -import { ThemeChoice, TopbarSlot } from "./ui/topbar.js"; import { ThemeSelect } from "@mdbase-dev/ui/theme-select"; -import { ConnectLayout, OpeningScreen } from "@mdbase-dev/ui/screens"; -import { WorkspaceView } from "./ui/WorkspaceView.js"; -import { ManuscriptWorkspace } from "./workspace/workspace.js"; - - -const params = new URL(location.href).searchParams; -const demoRequested = (import.meta.env.DEV || import.meta.env.VITE_WRITER_DEMO === "1") && params.has("demo"); +import { OpeningScreen } from "@mdbase-dev/ui/screens"; +import { appUrls } from "./apps.js"; +import { ThemeChoice, TopbarSlot } from "./ui/topbar.js"; +import { SdkWriterRoot, type SdkWriterRootProps } from "./ui/SdkWriterRoot.js"; -function useManuscriptParam(mayLeave: () => Promise): [string | null, (path: string | null, force?: boolean) => void] { - const [value, setValue] = useState(() => new URL(location.href).searchParams.get("manuscript")); - const guard = useRef(mayLeave); - guard.current = mayLeave; - const index = useRef(Number(history.state?.writerIndex ?? 0)); - const restoring = useRef(false); - const navigating = useRef(false); - useEffect(() => { - history.replaceState({ ...history.state, writerIndex: index.current }, ""); - const onPop = () => { - const nextIndex = Number(history.state?.writerIndex ?? 0); - if (restoring.current) { restoring.current = false; return; } - const nextPath = new URL(location.href).searchParams.get("manuscript"); - void guard.current().then((allowed) => { - if (allowed) { index.current = nextIndex; setValue(nextPath); } - else { - const delta = index.current - nextIndex; - if (delta) { restoring.current = true; history.go(delta); } - } - }); - }; - window.addEventListener("popstate", onPop); - return () => window.removeEventListener("popstate", onPop); - }, []); - const set = (path: string | null, force = false) => { - if (navigating.current) return; - navigating.current = true; - void (force ? Promise.resolve(true) : guard.current()).then((allowed) => { - if (!allowed) return; - const url = new URL(location.href); - if (path) url.searchParams.set("manuscript", path); - else url.searchParams.delete("manuscript"); - history.pushState({ ...history.state, writerIndex: ++index.current }, "", url); - setValue(path); - }).finally(() => { navigating.current = false; }); - }; - return [value, set]; +// Native mode never imports the legacy session/collection URL module. +// Production keeps its existing default; switching/bootstrap belongs to owner GO. +const LegacyApp = lazy(async () => ({ default: (await import("./LegacyApp.js")).LegacyApp })); +export function App(props: Partial = {}) { + return props.sdk ? : + }>; } - -export function App() { - const { reportError } = useFeedback(); +function NativeApp(props: SdkWriterRootProps) { const [theme, setTheme] = useState(() => loadThemePreference()); const [slot, setSlot] = useState(null); useEffect(() => saveThemePreference(theme), [theme]); const themeChoice = useMemo(() => ({ theme, setTheme }), [theme]); - return ( - - -
-
- -
- - -
- reportError({ code: "unknown_error" })}>{demoRequested ? : } -
-
-
- ); -} - -class ErrorBoundary extends Component<{ children: ReactNode; onError(): void }, { error: Error | null }> { - override state = { error: null as Error | null }; - static getDerivedStateFromError(error: Error) { - return { error }; - } - override componentDidCatch() { this.props.onError(); } - override render() { - if (!this.state.error) return this.props.children; - return ( -
-

Something went wrong

- -

{this.state.error.message}

-

Your saved text is in your collection. Unsent local drafts are backed up in this browser when storage is available. Download a copy before reloading.

- - -
- ); - } -} - -// Instances with a lifetime (sessions, backends, workspaces) are created in -// the effect that disposes them, so development double-mounting cannot leave -// the UI holding a disposed one. -function useOwned(create: () => T | null, dispose: (value: T) => void, deps: readonly unknown[]): T | null { - const [value, setValue] = useState(null); - useEffect(() => { - const created = create(); - setValue(created); - return () => { - if (created) dispose(created); - setValue(null); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- callers pass the real dependencies - }, deps); - return value; -} - -function ConnectRoot() { - const session = useOwned(() => { - const s = createWriterSession(); - void s.start({ timeoutMs: 30_000 }); - return s; - }, (s) => s.destroy(), []); - if (!session) return null; - return ; -} - -function ConnectedRoot({ session }: { session: WriterSession }) { - const store = useMemo(() => externalStore(session), [session]); - const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot); - const connection = snapshot.status === "ready" ? session.connection() : null; - const owned = useOwned( - () => (connection ? { connection, backend: new ConnectBackend(connection, () => session.authorize("selected", { presentation: "popup" })) } : null), - (value) => value.backend.dispose(), - [connection], - ); - const backend = owned?.connection === connection ? owned?.backend : null; - if (!backend || snapshot.status !== "ready") { - return ; - } - return - } />; -} - -function DemoRoot() { - const [backend, setBackend] = useState(null); - const [problem, setProblem] = useState(null); - useEffect(() => { - let live = true; - let owned: WriterBackend | undefined; - void import("./backend/demo.js").then(async ({ createDemoBackend }) => { - // StrictMode may have disposed this effect while the module was loading. - if (!live) return; - const b = import.meta.env.DEV && params.get("demo") === "large" - ? await (await import("./backend/large-demo.js")).createLargeDemoBackend() - : await createDemoBackend(); - if (live) { owned = b; setBackend(b); } - else b.dispose(); - }).catch((error: unknown) => { if (live) setProblem(error instanceof Error ? error.message : String(error)); }); - return () => { - live = false; - owned?.dispose(); - }; - }, []); - if (problem) return ; - if (!backend) return ; - return ; -} - -function downloadLocalDrafts(workspace: ManuscriptWorkspace): void { - const bytes = zip(workspace.localDrafts()); - const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type: "application/zip" })); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = "writer-local-drafts.zip"; - anchor.click(); - setTimeout(() => URL.revokeObjectURL(url), 30_000); -} - -function Manuscripts({ backend, collectionPicker }: { backend: WriterBackend; collectionPicker?: ReactNode }) { - const currentWorkspace = useRef(null); - const [navigation, setNavigation] = useState<{ message: string; finish(allowed: boolean): void } | null>(null); - const [path, setPath] = useManuscriptParam(async () => { - const workspace = currentWorkspace.current; - if (!workspace || !workspace.hasUnsavedChanges()) return true; - const saved = await workspace.retrySave(); - if (saved.ok) return true; - return new Promise((finish) => setNavigation({ message: saved.message, finish })); - }); - const workspace = useOwned(() => (path ? new ManuscriptWorkspace(backend, path) : null), (w) => void w.dispose(), [backend, path]); - useWriterFeedbackContext(path ? "manuscript" : "manuscripts", backend.collectionName); - currentWorkspace.current = workspace; - (window as unknown as { writer?: unknown }).writer = { backend, workspace }; - // When this manuscript was last opened here (read before the opening is recorded, below). - const since = useMemo(() => (path ? recentManuscripts(localNamespace(backend)).find((e) => e.path === path)?.at : undefined), [backend, path]); - useEffect(() => { - if (path) recordOpened(localNamespace(backend), path); - }, [backend, path]); - if (!path) return ; - if (!workspace) return null; - return <> - setPath(null, force)} /> - { navigation?.finish(false); setNavigation(null); }} title="Some changes are not saved"> -

{navigation?.message}

-

Download a local copy before leaving if saving cannot be completed.

-
- - - -
-
- ; + return +
+
+ +
+
; } diff --git a/apps/writer/src/LegacyApp.tsx b/apps/writer/src/LegacyApp.tsx new file mode 100644 index 00000000..0b370570 --- /dev/null +++ b/apps/writer/src/LegacyApp.tsx @@ -0,0 +1,141 @@ +// Top level: pick the backend (Connect, or the development demo), then show +// the manuscript list or an open manuscript. The open manuscript lives in the +// URL (`?manuscript=path`) so reloads and links land in the same place. +import { externalStore } from "@mdbase-dev/connect"; +import { Component, useEffect, useMemo, useState, useSyncExternalStore, type ReactNode } from "react"; + +import { ConnectBackend } from "./backend/connect.js"; +import type { WriterBackend } from "./backend/types.js"; +import { createWriterSession, type WriterSession } from "./connect/session.js"; +import { appUrls } from "./apps.js"; +import { AppSwitcher } from "@mdbase-dev/ui/app-switcher"; +import { FeedbackButton, useFeedback } from "@mdbase-dev/ui/feedback"; +import { Manuscripts } from "./ui/Manuscripts.js"; +import { downloadLocalDrafts } from "./ui/download-local-drafts.js"; +import { ConnectGate } from "./ui/ConnectGate.js"; +import { CollectionPicker } from "./ui/CollectionPicker.js"; + +import { loadThemePreference, saveThemePreference, type ThemePreference } from "@mdbase-dev/ui/theme"; +import { ThemeChoice, TopbarSlot } from "./ui/topbar.js"; +import { ThemeSelect } from "@mdbase-dev/ui/theme-select"; +import { ConnectLayout, OpeningScreen } from "@mdbase-dev/ui/screens"; + +import { ManuscriptWorkspace } from "./workspace/workspace.js"; + + +const params = new URL(location.href).searchParams; +const demoRequested = (import.meta.env.DEV || import.meta.env.VITE_WRITER_DEMO === "1") && params.has("demo"); + +export function LegacyApp() { + const { reportError } = useFeedback(); + const [theme, setTheme] = useState(() => loadThemePreference()); + const [slot, setSlot] = useState(null); + useEffect(() => saveThemePreference(theme), [theme]); + const themeChoice = useMemo(() => ({ theme, setTheme }), [theme]); + return ( + + +
+
+ +
+ + +
+ reportError({ code: "unknown_error" })}>{demoRequested ? : } +
+
+
+ ); +} + +class ErrorBoundary extends Component<{ children: ReactNode; onError(): void }, { error: Error | null }> { + override state = { error: null as Error | null }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + override componentDidCatch() { this.props.onError(); } + override render() { + if (!this.state.error) return this.props.children; + return ( +
+

Something went wrong

+ +

{this.state.error.message}

+

Your saved text is in your collection. Unsent local drafts are backed up in this browser when storage is available. Download a copy before reloading.

+ + +
+ ); + } +} + +// Instances with a lifetime (sessions, backends, workspaces) are created in +// the effect that disposes them, so development double-mounting cannot leave +// the UI holding a disposed one. +function useOwned(create: () => T | null, dispose: (value: T) => void, deps: readonly unknown[]): T | null { + const [value, setValue] = useState<{keys: readonly unknown[]; resource: T | null} | null>(null); + useEffect(() => { + const created = create(); const keys = [...deps]; let active = true; + queueMicrotask(() => {if (active) setValue({keys, resource: created});}); + return () => {active = false; if (created) dispose(created);}; + // eslint-disable-next-line react-hooks/exhaustive-deps -- callers pass the real dependencies + }, deps); + return value?.keys.length === deps.length && deps.every((dependency, index) => Object.is(dependency, value.keys[index])) ? value.resource : null; +} + +function ConnectRoot() { + const session = useOwned(() => { + const s = createWriterSession(); + void s.start({ timeoutMs: 30_000 }); + return s; + }, (s) => s.destroy(), []); + if (!session) return null; + return ; +} + +function ConnectedRoot({ session }: { session: WriterSession }) { + const store = useMemo(() => externalStore(session), [session]); + const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot); + const connection = snapshot.status === "ready" ? session.connection() : null; + const owned = useOwned( + () => (connection ? { connection, backend: new ConnectBackend(connection, () => session.authorize("selected", { presentation: "popup" })) } : null), + (value) => value.backend.dispose(), + [connection], + ); + const backend = owned?.connection === connection ? owned?.backend : null; + if (!backend || snapshot.status !== "ready") { + return ; + } + return + } />; +} + +function DemoRoot() { + const [backend, setBackend] = useState(null); + const [problem, setProblem] = useState(null); + useEffect(() => { + let live = true; + let owned: WriterBackend | undefined; + void import("./backend/demo.js").then(async ({ createDemoBackend }) => { + // StrictMode may have disposed this effect while the module was loading. + if (!live) return; + const b = import.meta.env.DEV && params.get("demo") === "large" + ? await (await import("./backend/large-demo.js")).createLargeDemoBackend() + : await createDemoBackend(); + if (live) { owned = b; setBackend(b); } + else b.dispose(); + }).catch((error: unknown) => { if (live) setProblem(error instanceof Error ? error.message : String(error)); }); + return () => { + live = false; + owned?.dispose(); + }; + }, []); + if (problem) return ; + if (!backend) return ; + return ; +} diff --git a/apps/writer/src/backend/bodies.ts b/apps/writer/src/backend/bodies.ts index eb360ac6..24d7a9fc 100644 --- a/apps/writer/src/backend/bodies.ts +++ b/apps/writer/src/backend/bodies.ts @@ -1,12 +1,13 @@ // Lazy hydration on top of classified metadata. No discovery or watch ownership. -import type { JsonObject, QueryRecord } from "@mdbase-dev/connect"; +import type { JsonObject } from "@mdbase-dev/connect"; import { commentFromRecord, type CommentRecord } from "@mdbase-writer/core/comments"; import { CollectionCache } from "./cache.js"; import type { CollectionStore, StoreDelta } from "./collection.js"; import { toContract } from "./comments.js"; import { ok, sourceAnnotation, type Result, type SourceAnnotation } from "./types.js"; -type ReadSelected = (metadata: ReadonlyMap) => Promise[]>>; +export interface WriterBodyRow { readonly path: string; readonly types: readonly string[]; readonly frontmatter?: JsonObject; readonly contractFields?: JsonObject; readonly body?: string } +type ReadSelected = (metadata: ReadonlyMap) => Promise>; export class CollectionBodies { private readonly comments = new Map(); private readonly sources = new Map>(); @@ -28,7 +29,7 @@ export class CollectionBodies { if (!loaded.ok) return loaded; return ok(loaded.value.flatMap((row) => { const entry = selected.get(row.path); - const a = entry && sourceAnnotation(row.path, toContract(row.frontmatter ?? {}, entry.fields), row.body ?? ""); + const a = entry && sourceAnnotation(row.path, row.contractFields ?? toContract(row.frontmatter ?? {}, entry.fields), row.body ?? ""); return a ? [a] : []; })); }); @@ -43,7 +44,7 @@ export class CollectionBodies { const entry = wanted.get(row.path); // Reset or a path update during a read must not cache an old body. if (!entry || entry !== this.store().comments.get(row.path)) continue; - const fields = Object.keys(entry.fields).length ? toContract(row.frontmatter ?? {}, entry.fields) : row.frontmatter ?? {}; + const fields = row.contractFields ?? (Object.keys(entry.fields).length ? toContract(row.frontmatter ?? {}, entry.fields) : row.frontmatter ?? {}); const comment = commentFromRecord(row.path, fields, row.body ?? ""); if (comment) this.comments.set(row.path, comment); } diff --git a/apps/writer/src/backend/collection.ts b/apps/writer/src/backend/collection.ts index 46d81215..a236a6fd 100644 --- a/apps/writer/src/backend/collection.ts +++ b/apps/writer/src/backend/collection.ts @@ -4,7 +4,7 @@ import { annotationSourceLink } from "@mdbase-writer/core/annotations"; import { commentFromRecord, linkPath, type CommentRecord } from "@mdbase-writer/core/comments"; import { PathIndex, pathIndex, resolveLinkTarget } from "@mdbase-writer/core/records"; import { personKey, toContract } from "./comments.js"; -import { fail, libraryEntry, ok, type CollectionDelta, type CollectionIndex, type LibraryEntry, type ManuscriptSummary } from "./types.js"; +import { fail, libraryEntry, ok, type CollectionDelta, type CollectionIndex, type LibraryEntry, type ManuscriptSummary, type UnavailableType } from "./types.js"; export const manuscriptContract = { id: "dev.mdbase.writer.manuscript", version: "1.0.0-beta.2" } as const; export const sourceContract = { id: "dev.mdbase.reader.source", version: "1.0.0-beta.1" } as const; @@ -19,6 +19,7 @@ const domains = { person: { contract: { id: "mdbase.person", version: "2.0.0" }, starter: "person", note: false }, } as const; export type Domain = keyof typeof domains; +export function domainContract(domain: Domain) { return domains[domain].contract; } /** The demo's starter definitions, derived from the same domain table. */ export function starterSchema(): CollectionSchema { return new CollectionSchema({ @@ -29,6 +30,22 @@ export function starterSchema(): CollectionSchema { })), }); } +/** Domain metadata, not a legacy connection or fabricated legacy catalog. */ +export interface WriterSchemaDescription { + readonly types: readonly { readonly name: string }[]; + readonly contracts: readonly { + readonly id: string; + readonly implementations: readonly { readonly typeName: string; readonly fields: Readonly> }[]; + }[]; + readonly configuration?: JsonObject; + /** Types left out of `contracts` because their definition has an error. */ + readonly unavailableTypes?: readonly (UnavailableType & { readonly domain: Domain })[]; +} +const DOMAIN_NAMES: Record = { manuscript: "Manuscripts", source: "Sources", annotation: "Annotations", comment: "Comments", person: "People" }; +/** Copy for a Writer type whose definition has an error: only that type is affected. */ +export function unavailableTypeMessage(name: string, domain: Domain): string { + return `Writer can't use the "${name}" type because its definition has an error. ${DOMAIN_NAMES[domain]} that use it are unavailable until the type is fixed; everything else still works.`; +} export interface Binding { readonly name: string; readonly fields: Readonly>; readonly typeKey: string } interface RoleBinding extends Binding { readonly active: boolean } export interface CollectionRow { @@ -36,6 +53,8 @@ export interface CollectionRow { readonly types: readonly string[]; readonly frontmatter?: JsonObject; readonly effectiveFrontmatter?: JsonObject; + /** Logical fields projected by the public SDK/Core, not a local field map. */ + readonly contractFields?: Partial>; /** Narrow query values are partial metadata, never a full record/frontmatter. */ readonly values?: JsonObject; readonly file?: { readonly mtime?: string }; @@ -48,6 +67,7 @@ export interface StoreDelta extends CollectionDelta { } interface Classified { note: boolean; + untyped: boolean; effective: boolean; source?: LibraryEntry; manuscript?: ManuscriptSummary; @@ -56,19 +76,19 @@ interface Classified { person?: string; } const equal = (a: unknown, b: unknown) => a === b || (a !== undefined && b !== undefined && JSON.stringify(a) === JSON.stringify(b)); -const fieldsOf = (fm: JsonObject, binding: Binding) => Object.keys(binding.fields).length ? toContract(fm, binding.fields) : fm; +const fieldsOf = (fm: JsonObject, binding: Binding, projected?: JsonObject) => projected ?? (Object.keys(binding.fields).length ? toContract(fm, binding.fields) : fm); /** One binding table serves discovery, writes, sessions and incremental classification. */ export class CollectionSchema { private readonly byType = new Map>(); private readonly roleCache = new Map>(); - constructor(readonly description: Pick) { + constructor(readonly description: WriterSchemaDescription | Pick, private readonly starterFallback = true) { for (const domain of Object.keys(domains) as Domain[]) { const rule = domains[domain]; const implementations = description.contracts.find((c) => c.id === rule.contract.id)?.implementations ?? []; const bindings = implementations.map((i) => ({ name: i.typeName, fields: i.fields, typeKey: this.typeKey })); // Recognise starter records even when their type isn't installed/described yet. - if (!bindings.some((b) => b.name === rule.starter)) bindings.push({ name: rule.starter, fields: {}, typeKey: this.typeKey }); + if (this.starterFallback && !bindings.some((b) => b.name === rule.starter)) bindings.push({ name: rule.starter, fields: {}, typeKey: this.typeKey }); const loaded = this.bindings(domain, domain === "manuscript"); const active = new Set(loaded.ok ? loaded.value.map((b) => b.name) : []); for (const binding of bindings) { @@ -89,12 +109,27 @@ export class CollectionSchema { const contract = this.description.contracts.find((c) => c.id === rule.contract.id); const implementations = contract?.implementations ?? []; if (implementations.length) return ok(implementations.map((i) => ({ name: i.typeName, fields: i.fields, typeKey: this.typeKey }))); + const unavailable = this.unavailableMessage(domain); + if (unavailable) return fail(unavailable); if (required || (contract && domain === "annotation")) return fail(`This collection has no type for ${domain}s. Set it up again from mdbase connect.`); - if (contract) return ok([]); + if (contract || !this.starterFallback) return ok([]); if (domain === "comment" || domain === "person") return ok([]); const starter = this.description.types.some((t) => t.name === rule.starter); return ok(starter ? [{ name: rule.starter, fields: {}, typeKey: this.typeKey }] : []); } + get unavailableTypes(): readonly UnavailableType[] { + return ("unavailableTypes" in this.description ? this.description.unavailableTypes ?? [] : []).map(({ name, message }) => ({ name, message })); + } + /** Set only when every type for the domain is unavailable. */ + unavailableMessage(domain: Domain): string | undefined { + const listed = "unavailableTypes" in this.description ? this.description.unavailableTypes ?? [] : []; + const contract = this.description.contracts.find((c) => c.id === domains[domain].contract.id); + if (contract?.implementations.length) return undefined; + return listed.find((t) => t.domain === domain)?.message; + } + isUnavailableType(type: string): boolean { + return "unavailableTypes" in this.description && !!this.description.unavailableTypes?.some((t) => t.name === type); + } roles(types: readonly string[]): ReadonlyMap { const key = types.join("\0"), cached = this.roleCache.get(key); if (cached) return cached; @@ -129,7 +164,7 @@ export class CollectionSchema { return binding?.fields ?? {}; } get setupStatus() { - const configured = (domain: Domain) => this.description.contracts.some((c) => c.id === domains[domain].contract.id) || this.description.types.some((t) => t.name === domains[domain].starter); + const configured = (domain: Domain) => this.description.contracts.some((c) => c.id === domains[domain].contract.id) || (this.starterFallback && this.description.types.some((t) => t.name === domains[domain].starter)); const comments = this.bindings("comment"); return { sources: configured("source"), annotations: configured("annotation"), comments: comments.ok && comments.value.length > 0 }; } @@ -153,7 +188,8 @@ export class CollectionStore { constructor(public schema: CollectionSchema) {} get index(): CollectionIndex { - return this.indexValue ??= { recordPaths: [...this.records.keys()], notePaths: [...this.records].filter(([, r]) => r.note).map(([p]) => p), filePaths: [...this.binaries] }; + return this.indexValue ??= { recordPaths: [...this.records.keys()], notePaths: [...this.records].filter(([, r]) => r.note).map(([p]) => p), + untypedPaths: [...this.records].filter(([, r]) => r.untyped).map(([p]) => p), unavailableTypes: this.schema.unavailableTypes, filePaths: [...this.binaries] }; } get library(): LibraryEntry[] { return this.libraryValue ??= [...this.sources.values()].sort((a, b) => a.path.localeCompare(b.path)); } get manuscripts(): ManuscriptSummary[] { return this.manuscriptValue ??= [...this.papers.values()].sort((a, b) => a.title.localeCompare(b.title) || a.path.localeCompare(b.path)); } @@ -165,20 +201,21 @@ export class CollectionStore { const effective = row.effectiveFrontmatter ?? fm; // Independent discovery queries can finish in any order. Persisted index rows // must not overwrite defaults already learned from effective domain queries. - const retainEffective = discovery && row.effectiveFrontmatter === undefined && previous?.effective; - const next: Classified = { note: /\.md$/i.test(row.path), effective: row.effectiveFrontmatter !== undefined || !!retainEffective }; + const retainEffective = discovery && row.effectiveFrontmatter === undefined && row.contractFields === undefined && previous?.effective; + // A record of an unusable Writer type is not someone's free writing. + const next: Classified = { note: /\.md$/i.test(row.path) && !row.types.some((type) => this.schema.isUnavailableType(type)), untyped: row.types.length === 0, effective: row.effectiveFrontmatter !== undefined || row.contractFields !== undefined || !!retainEffective }; for (const [domain, binding] of this.schema.roles(row.types)) { if (!domains[domain].note) next.note = false; if (!binding.active) continue; switch (domain) { case "source": { const written = row.values?.["file_mtime"] ?? row.file?.mtime; - const source = retainEffective && previous.source ? previous.source : libraryEntry(row.path, fieldsOf(effective, binding), typeof written === "string" ? written : undefined); + const source = retainEffective && previous.source ? previous.source : libraryEntry(row.path, fieldsOf(effective, binding, row.contractFields?.source), typeof written === "string" ? written : undefined); if (source) next.source = equal(previous?.source, source) ? previous!.source! : source; break; } case "manuscript": { - const fields = fieldsOf(effective, binding); + const fields = fieldsOf(effective, binding, row.contractFields?.manuscript); const modified = row.values?.["file_mtime"] ?? row.file?.mtime; const manuscript = retainEffective && previous.manuscript ? previous.manuscript : { path: row.path, title: typeof fields["title"] === "string" ? fields["title"] : row.path, @@ -190,12 +227,12 @@ export class CollectionStore { break; } case "annotation": { - const target = fm[binding.fields["source"] ?? "source"]; + const target = fieldsOf(fm, binding, row.contractFields?.annotation)["source"]; next.annotation = { path: row.path, type: binding.name, fields: binding.fields, source: typeof target === "string" ? annotationSourceLink(target) ?? "" : "" }; break; } case "comment": { - const comment = commentFromRecord(row.path, fieldsOf(fm, binding), ""); + const comment = commentFromRecord(row.path, fieldsOf(fm, binding, row.contractFields?.comment), ""); if (comment) { const metadata = { type: binding.name, fields: binding.fields, comment: { document: comment.document, ...(comment.inReplyTo ? { inReplyTo: comment.inReplyTo } : {}) } }; @@ -204,7 +241,7 @@ export class CollectionStore { break; } case "person": { - const name = fieldsOf(effective, binding)["name"]; + const name = fieldsOf(effective, binding, row.contractFields?.person)["name"]; if (typeof name === "string") next.person = name; break; } @@ -240,7 +277,7 @@ export class CollectionStore { for (const [path, row] of changes) { const old = this.records.get(path); const next = row && this.classify(row, old, discovery); - index ||= !!old !== !!next || old?.note !== next?.note; + index ||= !!old !== !!next || old?.note !== next?.note || old?.untyped !== next?.untyped; if (old?.source || next?.source) library ||= !equal(old?.source, next?.source); if (old?.manuscript || next?.manuscript) manuscripts ||= !equal(old?.manuscript, next?.manuscript); membership ||= !!old?.source !== !!next?.source; diff --git a/apps/writer/src/backend/comments.ts b/apps/writer/src/backend/comments.ts index 4a21c2aa..e4f3e7b7 100644 --- a/apps/writer/src/backend/comments.ts +++ b/apps/writer/src/backend/comments.ts @@ -12,6 +12,8 @@ export interface NewComment { readonly thread?: CommentRecord; /** A suggested edit: the text to put in place of the target's quote. */ readonly replacement?: string; + /** A stable name for this one comment, reused by its retries so it lands once. */ + readonly intent?: string; } export type CommentChange = diff --git a/apps/writer/src/backend/native-schema.ts b/apps/writer/src/backend/native-schema.ts new file mode 100644 index 00000000..3635eacc --- /dev/null +++ b/apps/writer/src/backend/native-schema.ts @@ -0,0 +1,88 @@ +import { toPlain, type wire } from "@mdbase-dev/sdk"; +import type { JsonObject } from "@mdbase-dev/connect"; +import manifest from "../generated/mdbase-app.json"; +import { annotationContract, CollectionSchema, domainContract, unavailableTypeMessage, type Domain, type WriterSchemaDescription } from "./collection.js"; +import type { UnavailableType } from "./types.js"; + +const DOMAINS: readonly Domain[] = ["manuscript", "source", "annotation", "comment", "person"]; +const domainOf = (id: string): Domain | undefined => DOMAINS.find(domain => domainContract(domain).id === id); +/** The definition path an issue is attached to, when it names one. */ +function issuePath(details: unknown): string | null { + const path = details instanceof Map ? (details as Map).get("path") : null; + return typeof path === "string" && path ? path : null; +} + +export type NativeJson = null | boolean | number | string | NativeJson[] | { [key: string]: NativeJson }; +/** Application JSON display values only. Native witnesses stay in SDK RecordViews. */ +export function nativeJson(value: unknown): NativeJson { + if (value === null || typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (value instanceof Date) return value.toISOString(); + if (Array.isArray(value)) return value.map(nativeJson); + if (typeof value === "object" && value !== null && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, nativeJson(item)])); + } + throw new Error("Writer requires JSON-compatible native fields."); +} +export function nativeWriteFields(fields: JsonObject): { [key: string]: NativeJson } { + return Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, nativeJson(value)])); +} +export function nativeObject(value: wire.Value): JsonObject { + const json = nativeJson(toPlain(value)); + if (json === null || typeof json !== "object" || Array.isArray(json)) throw new Error("Writer requires a native field map."); + return json; +} + +/** The wire reports contract digests as `sha256:` (as the manifest does). + * Compare in that canonical form so neither side's spelling can mismatch. */ +export function sameDigest(a: string | undefined, b: string | undefined): boolean { + const canonical = (digest: string) => digest.startsWith("sha256:") ? digest : `sha256:${digest}`; + return a !== undefined && b !== undefined && canonical(a) === canonical(b); +} + +export function manifestContractDigest(id: string, version: string): string | undefined { + return manifest.requirements.contracts.find(contract => contract.id === id && contract.version === version)?.digest; +} + +/** Read the original native catalog. Never invent legacy schema/type metadata or + * recognise a starter name without its actual native contract implementation. + * The SDK remains responsible for root readiness and configuration/provisioning. + */ +export function nativeWriterSchema(catalog: wire.DescribeResult): CollectionSchema { + // Aggregate readiness belongs to the SDK. An unrelated definition diagnostic + // must not hide usable Writer records; exact domain bindings remain checked. + const required = manifest.requirements.contracts; + for (const expected of required) { + const matching = catalog.contracts.filter(contract => contract.id === expected.id && contract.version === expected.version); + const actual = matching[0]; + if (matching.length !== 1 || !actual || actual.contractType !== "record" || !sameDigest(actual.digest, expected.digest)) { + throw new Error("This collection does not have Writer's exact native contracts."); + } + } + const supported = new Map([...required.map(contract => [contract.id, contract.version] as const), + [annotationContract.id, annotationContract.version], ["mdbase.person", "2.0.0"]]); + // An error-level issue on one of Writer's own type definitions makes only + // that type unavailable. Issues elsewhere never hide Writer's records. + const broken = new Set(catalog.issues.filter(issue => issue.severity === "error").map(issue => issuePath(issue.details)).filter(path => path !== null)); + const unavailableTypes: (UnavailableType & {domain: Domain})[] = []; + const contracts: WriterSchemaDescription["contracts"] = catalog.contracts + .filter(contract => supported.get(contract.id) === contract.version && contract.contractType === "record") + .map(contract => ({id: contract.id, implementations: contract.implementedBy.flatMap(name => { + const type = catalog.types.find(candidate => candidate.name === name); + const bindings = type?.implements.filter(binding => binding.contract === contract.id && binding.version === contract.version) ?? []; + const binding = bindings[0]; + if (!type || bindings.length !== 1 || !binding) throw new Error("Writer's native contract binding is incomplete."); + if (broken.has(type.path)) { + const domain = domainOf(contract.id); + if (domain && !unavailableTypes.some(entry => entry.name === type.name)) unavailableTypes.push({name: type.name, domain, message: unavailableTypeMessage(type.name, domain)}); + return []; + } + return [{typeName: type.name, fields: Object.fromEntries(binding.fields)}]; + })})); + const schema = new CollectionSchema({types: catalog.types.map(type => ({name: type.name})), contracts, + // A collection without settings reports null: that is no settings, not an error. + configuration: {settings: catalog.settings === null ? {} : nativeObject(catalog.settings)}, unavailableTypes}, false); + const manuscripts = schema.bindings("manuscript", true); + if (!manuscripts.ok && !schema.unavailableMessage("manuscript")) throw new Error(manuscripts.message); + return schema; +} diff --git a/apps/writer/src/backend/native.test.ts b/apps/writer/src/backend/native.test.ts new file mode 100644 index 00000000..158cf00b --- /dev/null +++ b/apps/writer/src/backend/native.test.ts @@ -0,0 +1,383 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { connect, MdbaseError, MdbasePeopleClient, type ChangesWatch, type MdbaseApplicationConnection, type MdbaseClient, type wire } from "@mdbase-dev/sdk"; +import { MemoryReplica, type MemoryContract } from "@mdbase-dev/sdk/testing"; +import manifest from "../generated/mdbase-app.json"; +import { NativeWriterBackend } from "./native.js"; +import { nativeObject, nativeWriterSchema } from "./native-schema.js"; + +// Native catalog/type metadata are explicit stand-ins. All record/file/session +// operations use actual public SDK over MemoryReplica, not native acceptance. +const clients: MdbaseClient[] = []; +const backends: NativeWriterBackend[] = []; +afterEach(() => {for (const backend of backends.splice(0)) backend.dispose(); for (const client of clients.splice(0)) client.close(); vi.restoreAllMocks();}); +function catalog(): wire.DescribeResult { + const definitions = manifest.requirements.contracts.map((contract, index) => ({contract, type: ["native-manuscript", "native-source", "native-comment"][index] ?? "unknown"})); + const mappings: Readonly>>> = { + "native-manuscript": {title: "/meta/heading", template: "layout", csl: "style"}, + "native-source": {title: "heading", csl: "citation"}, + "native-comment": {document: "document", motivation: "motivation", status: "/review/state", created_at: "created_at", created_by: "created_by", in_reply_to: "in_reply_to", target: "target", suggestion: "suggestion", resolved_at: "resolved_at", resolved_by: "resolved_by", modified_at: "/review/modified", deleted_at: "/review/deleted"}, + }; + return {specVersion: "0.3.0", settings: new Map([["explicit_type_keys", ["type"]]]), inclusion: {include: ["image"]}, issues: [], + contracts: definitions.map(({contract, type}) => ({...contract, path: `_contracts/${contract.id}.md`, contractType: "record", implementedBy: [type]})), + types: definitions.map(({contract, type}) => ({name: type, path: `_types/${type}.md`, implements: [{contract: contract.id, version: contract.version, fields: new Map(Object.entries(mappings[type] ?? {}))}]}))}; +} +async function fixture(beforeBackend?: (client: MdbaseClient) => void) { + const description = catalog(); + const contracts: MemoryContract[] = description.types.flatMap(type => (type.implements ?? []).map(implementation => { + const contract = description.contracts.find(candidate => candidate.id === implementation.contract && candidate.version === implementation.version); + if (!contract || !implementation.version) throw new Error("Missing exact fixture contract"); + return {id: contract.id, version: implementation.version, type: type.name, contractDigest: contract.digest, fields: Object.fromEntries(implementation.fields ?? new Map())}; + })); + const replica = new MemoryReplica({confirmDelayMs: 0, contracts}); + const manuscript = replica.seed({path: "manuscripts/original.md", types: ["native-manuscript"], frontmatter: {type: "native-manuscript", meta: new Map([["heading", "Original"]]), layout: "article", style: "apa"}, body: "Original manuscript\n"}); + replica.seed({path: "sources/paper.md", types: ["native-source"], frontmatter: {type: "native-source", heading: "Citation source", citation: new Map([["id", "paper"], ["type", "article-journal"], ["title", "Citation source"]])}, body: "Lazy source body\n"}); + replica.seed({path: "notes/chapter.md", frontmatter: {custom: "keep"}, body: "# A chapter\n"}); + const client = await connect({connector: replica.connector(), app: {name: "writer-native-test", version: "test"}, reconnect: false}); clients.push(client); + vi.spyOn(client, "describe").mockResolvedValue(description); + const metadata = vi.fn(async () => {throw new Error("Identity was not approved");}); + const current = vi.fn(() => true); + const connection: MdbaseApplicationConnection = {id: client.collection, displayName: "Writing", scope: {account: "fixture-account", installation: "fixture-installation", collection: client.collection}, generation: "fixture-generation", records: client, files: client.files, + people: new MdbasePeopleClient(client, {collection: client.collection, request: metadata, isCurrent: current}), isCurrent: current}; + const caller = new AbortController(); + beforeBackend?.(client); + const backend = new NativeWriterBackend(connection, {collectionName: "Writing", signal: caller.signal}); backends.push(backend); + return {replica, client, description, metadata, current, caller, backend, manuscript}; +} + +// Public watch lifecycle stand-ins only; no private SDK fault hooks. +function controlledWatch(initialState: ChangesWatch["state"] = "active", ready: Promise = Promise.resolve()) { + let state = initialState; + const listeners = new Set[0]>(); + const stop = vi.fn(() => {state = "closed";}); + const watch: ChangesWatch = Object.assign(stop, {ready, state, error: null, + subscribe: (listener: Parameters[0]) => {listeners.add(listener); return () => {listeners.delete(listener);};}}); + Object.defineProperty(watch, "state", {get: () => state}); + return {watch, stop, listeners, publish(next: ChangesWatch["state"]) {state = next; for (const listener of listeners) listener(next, null);}}; +} + +describe("native Writer domain backend", () => { + it("discovers mapped manuscripts/library/index without bodies or identity requests", async () => { + const h = await fixture(); const query = vi.spyOn(h.client, "query"); const get = vi.spyOn(h.client, "get"); + const projected = await h.client.queryContract({id: "dev.mdbase.writer.manuscript", version: "1.0.0-beta.2"}, {pageSize: 128}); + expect(projected.records[0]?.implementations[0]?.fields.get("title")).toBe("Original"); + const manuscripts = await h.backend.listManuscripts(); + expect(manuscripts).toEqual({ok: true, value: [{path: "manuscripts/original.md", title: "Original", template: "article", style: "apa"}]}); + const library = await h.backend.library(); + expect(library.ok && library.value[0]?.key).toBe("paper"); + const index = await h.backend.index(); + expect(index.ok && index.value.recordPaths).toContain("sources/paper.md"); + expect(index.ok && index.value.notePaths).not.toContain("sources/paper.md"); + expect(index.ok && index.value.notePaths).toContain("notes/chapter.md"); + expect(query.mock.calls.every(call => !call[1]?.body && !call[1]?.document)).toBe(true); + expect(get).not.toHaveBeenCalled(); expect(h.metadata).not.toHaveBeenCalled(); + expect("writeFile" in h.backend).toBe(false); + }); + it("unrelated invalid definitions do not hide Writer records or untyped notes", async () => { + const h = await fixture(); + h.description.issues.push({code: "type_invalid", severity: "error", message: "An unrelated custom type is invalid", details: new Map([["path", "_types/unrelated.md"]])}); + expect(await h.backend.listManuscripts()).toMatchObject({ok: true, value: [{path: "manuscripts/original.md"}]}); + expect(await h.backend.index()).toMatchObject({ok: true, value: {notePaths: expect.arrayContaining(["notes/chapter.md"])}}); + expect(await h.backend.readBody("notes/chapter.md")).toEqual({ok: true, value: "# A chapter\n"}); + const first = h.description.contracts[0]; if (!first) throw new Error("Missing original contract"); + first.digest = `sha256:${"f".repeat(64)}`; + expect(() => nativeWriterSchema(h.description)).toThrow("exact native contracts"); + }); + it("opens a catalog whose contract digests use the wire's sha256: form", async () => { + // UX-001: a fresh collection's describe reports `sha256:` digests, + // the same form as the manifest. Writer must not require bare hex. + const h = await fixture(); + expect(h.description.contracts.every(contract => contract.digest.startsWith("sha256:"))).toBe(true); + expect(await h.backend.listManuscripts()).toMatchObject({ok: true, value: [{path: "manuscripts/original.md"}]}); + expect((await h.backend.index()).ok).toBe(true); + expect((await h.backend.library()).ok).toBe(true); + }); + it("opens a collection whose catalog has no settings (null)", async () => { + // UX-001: a fresh collection without mdbase.yaml settings reports `settings: null`; + // Writer failed "Writer requires a native field map." and loaded nothing. + const h = await fixture(); + (h.description as {settings: unknown}).settings = null; + expect(await h.backend.listManuscripts()).toMatchObject({ok: true, value: [{path: "manuscripts/original.md"}]}); + expect((await h.backend.index()).ok).toBe(true); + }); + it("lists untyped records as untyped notes rather than dropping them", async () => { + const h = await fixture(); + const index = await h.backend.index(); + expect(index.ok && index.value.untypedPaths).toEqual(["notes/chapter.md"]); + expect(index.ok && index.value.notePaths).toContain("notes/chapter.md"); + }); + it("an error in one of Writer's own types makes only that type unavailable", async () => { + const h = await fixture(); + h.description.issues.push({code: "type_invalid", severity: "error", message: "Bad comment type", details: new Map([["path", "_types/native-comment.md"]])}); + expect(await h.backend.listManuscripts()).toMatchObject({ok: true, value: [{path: "manuscripts/original.md"}]}); + expect((await h.backend.library()).ok).toBe(true); + const index = await h.backend.index(); + expect(index).toMatchObject({ok: true, value: {notePaths: expect.arrayContaining(["notes/chapter.md"]), + unavailableTypes: [{name: "native-comment", message: expect.stringContaining("Comments")}]}}); + expect(await h.backend.comments()).toMatchObject({ok: false, message: expect.stringContaining("native-comment")}); + expect(await h.backend.createComment({document: "manuscripts/original.md", text: "Hi"})).toMatchObject({ok: false, message: expect.stringContaining("native-comment")}); + }); + it("an error in the manuscript type leaves notes and sources usable", async () => { + const h = await fixture(); + h.description.issues.push({code: "type_invalid", severity: "error", message: "Bad manuscript type", details: new Map([["path", "_types/native-manuscript.md"]])}); + expect(await h.backend.listManuscripts()).toMatchObject({ok: false, message: expect.stringContaining("native-manuscript")}); + expect(await h.backend.library()).toMatchObject({ok: true, value: [{key: "paper"}]}); + const index = await h.backend.index(); + expect(index.ok && index.value.notePaths).not.toContain("manuscripts/original.md"); + expect(index.ok && index.value.notePaths).toContain("notes/chapter.md"); + expect(await h.backend.readBody("notes/chapter.md")).toEqual({ok: true, value: "# A chapter\n"}); + }); + it("one record with an invalid projection does not block the collection", async () => { + const h = await fixture(); + const original = h.client.queryContract.bind(h.client); + vi.spyOn(h.client, "queryContract").mockImplementation(async (contract, options) => { + const result = await original(contract, options); + return {...result, records: result.records.map(record => ({...record, implementations: record.implementations.map(implementation => ({...implementation, issues: [{code: "field_invalid", severity: "error" as const, message: "bad"}]}))}))}; + }); + expect((await h.backend.index()).ok).toBe(true); + expect(await h.backend.listManuscripts()).toMatchObject({ok: true, value: [{path: "manuscripts/original.md"}]}); + }); + it("recreates an ended watch in the background with backoff and never duplicates it", async () => { + const initial = controlledWatch(), second = controlledWatch("starting"), third = controlledWatch("starting"); + const h = await fixture(client => {vi.spyOn(client, "watchChanges").mockReturnValueOnce(initial.watch).mockReturnValueOnce(second.watch).mockReturnValueOnce(third.watch);}); + const watch = vi.mocked(h.client.watchChanges); + expect((await h.backend.index()).ok).toBe(true); + const notified = vi.fn(); h.backend.onExternalChange(notified); + vi.useFakeTimers({toFake: ["setTimeout", "clearTimeout"]}); + try { + initial.publish("stale"); + expect(notified).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(499); expect(watch).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); expect(watch).toHaveBeenCalledTimes(2); + expect(initial.stop).toHaveBeenCalledOnce(); expect(initial.listeners.size).toBe(0); + second.publish("active"); + expect(notified).toHaveBeenCalledTimes(2); // Reconnected: reload what changed meanwhile. + second.publish("failed"); + expect(notified).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(500); expect(watch).toHaveBeenCalledTimes(3); + third.publish("failed"); // Never active: retry later, doubling the wait. + await vi.advanceTimersByTimeAsync(999); expect(watch).toHaveBeenCalledTimes(3); + // A read while a retry is pending recreates once and cancels the timer. + const read = h.backend.index(); await vi.advanceTimersByTimeAsync(0); + expect((await read).ok).toBe(true); + expect(watch).toHaveBeenCalledTimes(4); expect(third.stop).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(60_000); expect(watch).toHaveBeenCalledTimes(4); + h.backend.dispose(); + await vi.advanceTimersByTimeAsync(120_000); expect(watch).toHaveBeenCalledTimes(4); + } finally {vi.useRealTimers();} + }); + it.each(["stale", "failed"] as const)("recreates its own %s watch on discovery and fences its retired callbacks", async state => { + const initial = controlledWatch(); + const h = await fixture(client => {vi.spyOn(client, "watchChanges").mockReturnValueOnce(initial.watch);}); + const watch = vi.mocked(h.client.watchChanges); const close = vi.spyOn(h.client, "close"); + expect((await h.backend.index()).ok).toBe(true); + const notified = vi.fn(); h.backend.onExternalChange(notified); + initial.publish(state); + expect((await h.backend.index()).ok).toBe(true); + expect(watch).toHaveBeenCalledTimes(2); expect(initial.stop).toHaveBeenCalledOnce(); expect(initial.listeners.size).toBe(0); + const old = watch.mock.calls[0]?.[1]; if (!old) throw new Error("Missing original watch callback"); + const calls = notified.mock.calls.length; + old({changes: [], cursor: "retired-watch", reset: true}); + expect(notified).toHaveBeenCalledTimes(calls); + const current = watch.mock.results[1]?.value; + h.backend.dispose(); expect(current.state).toBe("closed"); expect(close).not.toHaveBeenCalled(); + }); + it("a replacement watch failure fails the pending read without an invalidation/retry loop", async () => { + const initial = controlledWatch(); let reject!: (error: Error) => void; + const replacement = controlledWatch("starting", new Promise((_, failed) => {reject = failed;})); + const h = await fixture(client => {vi.spyOn(client, "watchChanges").mockReturnValueOnce(initial.watch).mockReturnValueOnce(replacement.watch);}); + expect((await h.backend.index()).ok).toBe(true); + const notified = vi.fn(); h.backend.onExternalChange(notified); + initial.publish("failed"); + const read = h.backend.index(); + replacement.publish("failed"); reject(new Error("Replacement watch refused")); + expect(await read).toMatchObject({ok: false}); + expect(notified).toHaveBeenCalledOnce(); expect(h.client.watchChanges).toHaveBeenCalledTimes(2); + }); + it("refuses unsupported contract metadata without projecting raw fields locally", async () => { + const h = await fixture(); + const query = vi.spyOn(h.client, "queryContract").mockRejectedValue(new MdbaseError({code: "upgrade_required", recovery: "upgrade", message: "Contract queries unsupported", reason: "contract_query_unsupported"})); + expect(await h.backend.listManuscripts()).toMatchObject({ok: false, message: expect.stringContaining("upgrade_required")}); + expect(query).toHaveBeenCalledWith({id: "dev.mdbase.writer.manuscript", version: "1.0.0-beta.2"}, expect.objectContaining({pageSize: 128, signal: expect.any(AbortSignal)})); + }); + it("discovers all records across actual SDK pages rather than truncating the native index", async () => { + const h = await fixture(); + for (let index = 0; index < 501; index++) h.replica.seed({path: `notes/page-${index}.md`, body: `Note ${index}`}); + for (let index = 0; index < 129; index++) h.replica.seed({path: `manuscripts/page-${index}.md`, types: ["native-manuscript"], frontmatter: {meta: new Map([["heading", `Book ${index}`]])}}); + const query = vi.spyOn(h.client, "queryContract"); + const result = await h.backend.index(); + expect(result.ok && result.value.recordPaths).toHaveLength(633); + expect(result.ok && result.value.recordPaths).toContain("notes/page-500.md"); + const manuscripts = await h.backend.listManuscripts(); + expect(manuscripts.ok && manuscripts.value).toHaveLength(130); + expect(manuscripts.ok && manuscripts.value.some(book => book.path === "manuscripts/page-128.md" && book.title === "Book 128")).toBe(true); + expect(query.mock.calls.some(call => call[1]?.cursor !== undefined)).toBe(true); + }); + it.each(["changed snapshot", "repeated cursor"]) ("refuses unsafe metadata pagination: %s", async failure => { + const h = await fixture(); const actual = await h.client.query({}, {effective: true}); + vi.spyOn(h.client, "pages").mockImplementation(async function* () { + yield {...actual, records: [], complete: false, cursor: "original-cursor"}; + yield {...actual, records: [], complete: false, ...(failure === "changed snapshot" ? {asOf: actual.asOf + 1, cursor: "next-cursor"} : {cursor: "original-cursor"})}; + throw new Error("The repository must stop before requesting another unsafe page"); + }); + expect(await h.backend.index()).toMatchObject({ok: false, message: expect.stringContaining(failure === "changed snapshot" ? "snapshot changed" : "cursor repeated")}); + }); + it.each(["protected", "wrong identity"]) ("does not expose a lazy body from %s native readback", async failure => { + const h = await fixture(); await h.backend.index(); + const get = h.client.get.bind(h.client); + vi.spyOn(h.client, "get").mockImplementation(async (...args) => { + const record = await get(...args); + return failure === "protected" ? {...record, state: {...record.state, unresolved: 1}} : {...record, id: crypto.randomUUID()}; + }); + expect((await h.backend.readBody("manuscripts/original.md")).ok).toBe(false); + }); + it("refuses changed exact contract digest and never uses starter-name fallback", async () => { + const h = await fixture(); const first = h.description.contracts[0]; if (!first) throw new Error("Missing fixture contract"); + first.digest = `sha256:${"f".repeat(64)}`; + expect((await h.backend.listManuscripts()).ok).toBe(false); + first.digest = manifest.requirements.contracts[0]?.digest ?? ""; + const schema = nativeWriterSchema(h.description); + expect(schema.roles(["writer-manuscript"]).size).toBe(0); + expect(schema.roles(["native-manuscript"]).has("manuscript")).toBe(true); + }); + it("opens the same native record session, saves/readbacks, and never closes a peer lease/client", async () => { + const h = await fixture(); const close = vi.spyOn(h.client, "close"); + const peer = await h.client.records.open({path: "manuscripts/original.md"}, {autosave: false}); + const opened = await h.backend.records.open("manuscripts/original.md", {autosave: false}); if (!opened.ok) throw new Error("Native lease missing"); + expect(opened.value.session.getNativeSnapshot().record.id).toBe(h.manuscript.id); + opened.value.session.setBody("Changed through native lease\n"); + expect((await opened.value.session.flush()).ok).toBe(true); + expect((await h.client.get(h.manuscript.id, {body: true})).body).toBe("Changed through native lease\n"); + h.backend.dispose(); expect(close).not.toHaveBeenCalled(); + peer.session.setBody("Peer still active\n"); expect((await peer.session.flush()).ok).toBe(true); peer.release(); + }); + it("refuses unsupported contract projection without using raw comment fields as a fallback", async () => { + const h = await fixture(); + const row = h.replica.seed({path: "comments/unsupported.md", types: ["native-comment"], frontmatter: {type: "native-comment", document: "manuscripts/original.md", motivation: "commenting", status: "open", created_at: "2026-06-10T12:00:00Z"}, body: "Unconfirmed contract text\n"}); + const read = vi.spyOn(h.client, "getContract").mockRejectedValue(new MdbaseError({code: "upgrade_required", recovery: "upgrade", message: "Contract records unsupported", reason: "contract_records_unsupported"})); + expect(await h.backend.comments()).toMatchObject({ok: false, message: expect.stringContaining("upgrade_required")}); + expect(read).toHaveBeenCalledWith(row.id, {id: "mdbase.comment", version: "1.0.0", type: "native-comment"}, expect.any(AbortSignal)); + }); + it("creates a mapped manuscript and chapter only after confirmed current native readback", async () => { + const h = await fixture(); + const created = await h.backend.createManuscript({title: "Launch", template: "article", style: "apa"}); + expect(created).toEqual({ok: true, value: "manuscripts/launch.md"}); + const actual = await h.client.get({path: "manuscripts/launch.md"}, {body: true}); + expect(nativeObject(actual.frontmatter)["meta"]).toMatchObject({heading: "Launch"}); expect(actual.state.state).toBe("confirmed"); + const chapter = await h.backend.createRecord("notes/chapter.md", "# New chapter\n"); + expect(chapter).toEqual({ok: true, value: "notes/chapter-2.md"}); + }); + it("creates a manuscript when the confirmed receipt still carries the optimistic pending view", async () => { + // UX-001 (LAB): the confirmed receipt had no records, so write.records kept the + // submit-time pending view and Writer reported a failed create that had succeeded. + const h = await fixture(); const create = h.client.create.bind(h.client); + vi.spyOn(h.client, "create").mockImplementation(async (...args: Parameters) => { + const write = await create(...args); + const pending = write.records.map(record => ({...record, state: {state: "pending" as const, confirmedSeq: 0}})); + Object.defineProperty(write, "confirmed", {value: write.confirmed.then(receipt => { write.receipt = {...write.receipt, records: pending}; return receipt; })}); + return write; + }); + expect(await h.backend.createManuscript({title: "Pending view", template: "article", style: "apa"})).toEqual({ok: true, value: "manuscripts/pending-view.md"}); + }); + it("adopts a note with original UUID/body/custom metadata preserved by native CAS", async () => { + const h = await fixture(); + const original = await h.client.get({path: "notes/chapter.md"}, {body: true, document: true}); + const update = vi.spyOn(h.client, "update"); + expect(await h.backend.adoptManuscript(original.path)).toEqual({ok: true, value: original.path}); + const changed = await h.client.get(original.id, {body: true}); + expect(changed.id).toBe(original.id); expect(changed.body).toBe(original.body); + expect(changed.frontmatter.get("custom")).toBe("keep"); expect(changed.types).toContain("native-manuscript"); + expect(nativeObject(changed.frontmatter)["meta"]).toMatchObject({heading: "A chapter"}); + expect(update).toHaveBeenCalledTimes(1); + expect(update.mock.calls[0]?.[1]).toMatchObject({contract: {id: "dev.mdbase.writer.manuscript", version: "1.0.0-beta.2", type: "native-manuscript"}, promote: true, patch: {title: "A chapter"}, ifRevision: original.revision}); + }); + it("reads native files by original file UUID/digest and keeps upload unavailable", async () => { + const h = await fixture(); const bytes = new Uint8Array([1, 2, 3]); + const original = await h.client.files.upload("figures/image.png", bytes); await original.confirmed; + const read = await h.backend.readFile("figures/image.png"); + expect(read).toEqual({ok: true, value: bytes}); + expect("writeFile" in h.backend).toBe(false); + }); + it("creates/reads/resolves an unsigned native comment without inventing an account", async () => { + const h = await fixture(); + const created = await h.backend.createComment({document: "manuscripts/original.md", text: "Review this sentence"}); + if (!created.ok) throw new Error(created.message); + expect(created.value.createdBy).toBeUndefined(); + const listed = await h.backend.comments(["manuscripts/original.md"]); + expect(listed.ok && listed.value.map(comment => comment.path)).toContain(created.value.path); + const changed = await h.backend.changeComment(created.value, {kind: "resolve"}); + expect(changed.ok && changed.value.status).toBe("resolved"); + if (!changed.ok) throw new Error(changed.message); + const update = vi.spyOn(h.client, "update"); + const withdrawn = await h.backend.changeComment(changed.value, {kind: "withdraw"}); + expect(withdrawn.ok).toBe(true); + expect(update).toHaveBeenCalledTimes(1); + expect(update.mock.calls[0]?.[1]).toMatchObject({body: "", contract: {id: "mdbase.comment", version: "1.0.0", type: "native-comment"}}); + const raw = await h.client.get({path: changed.value.path}, {body: true}); + expect(raw.body).toBe(""); + expect(raw.frontmatter.get("review")).toBeInstanceOf(Map); + const review = nativeObject(raw.frontmatter)["review"]; + expect(review).toMatchObject({state: "resolved", deleted: expect.any(String), modified: expect.any(String)}); + expect(raw.frontmatter.has("deleted_at")).toBe(false); + }); + it.each(["untyped", "sdk-mid", "mismatched-sdk-mid"])("recovers a %s lost create reply by the original SDK MID without creating a duplicate", async mode => { + const h = await fixture(); + const original = h.client.create.bind(h.client); + const submitted: string[] = []; + vi.spyOn(h.client, "create").mockImplementationOnce(async (input, options) => { + if (!options?.mutationId) throw new Error("Missing original MID"); + submitted.push(options.mutationId); + const write = await original(input, options); await write.confirmed; + if (mode === "untyped") throw new Error("Lost original reply"); + throw new MdbaseError({code: "outcome_unknown", recovery: "resolve_outcome", message: "Lost original reply"}, + {mutationId: mode === "sdk-mid" ? write.mutationId : crypto.randomUUID()}); + }); + const lost = await h.backend.createRecord("notes/unknown.md", "Original intent\n"); + expect(lost.ok).toBe(false); + if (mode === "mismatched-sdk-mid") expect(lost).toMatchObject({message: "Writer lost the original mutation identity."}); + const receipt = vi.spyOn(h.client, "awaitReceipt"); + const recover = await h.backend.createRecord("notes/unknown.md", "Fresh retry must not submit\n"); + expect(recover).toMatchObject({ok: false, message: expect.stringContaining("recovered the original operation")}); + expect(receipt).toHaveBeenCalledWith(submitted[0], 5_000, expect.any(AbortSignal)); + expect(h.client.create).toHaveBeenCalledOnce(); + const queried = await h.client.query({}, {body: true}); + const records = queried.records.filter(record => record.path === "notes/unknown.md"); + expect(records).toHaveLength(1); expect(records[0]?.body).toBe("Original intent\n"); + }); + it("retries a lost create reply under the same intent into the one original record", async () => { + const h = await fixture(); + const original = h.client.create.bind(h.client); + const submitted: string[] = []; + vi.spyOn(h.client, "create").mockImplementation(async (input, options) => { + if (options?.mutationId !== undefined) return original(input, options); // the SDK's own MID-bound step + const write = await original(input, options); submitted.push(write.mutationId); + if (submitted.length > 1) return write; + await write.confirmed; throw new Error("Lost original reply"); + }); + const intent = "new-manuscript-1"; + expect((await h.backend.createManuscript({title: "Launch", template: "article", style: "apa", intent})).ok).toBe(false); + expect(await h.backend.createManuscript({title: "Launch", template: "article", style: "apa", intent})).toEqual({ok: true, value: "manuscripts/launch.md"}); + expect(submitted).toHaveLength(2); expect(submitted[1]).toBe(submitted[0]); + const queried = await h.client.query({}, {}); + expect(queried.records.filter(record => record.path.startsWith("manuscripts/launch"))).toHaveLength(1); + }); + it("retries a lost comment reply under the same intent into the one original comment", async () => { + const h = await fixture(); + const original = h.client.create.bind(h.client); + vi.spyOn(h.client, "create").mockImplementationOnce(async (input, options) => { + const write = await original(input, options); await write.confirmed; throw new Error("Lost original reply"); + }); + const input = {document: "manuscripts/original.md", text: "Once", intent: "new-comment-1"}; + expect((await h.backend.createComment(input)).ok).toBe(false); + const created = await h.backend.createComment(input); + if (!created.ok) throw new Error(created.message); + const listed = await h.backend.comments(["manuscripts/original.md"]); + expect(listed.ok && listed.value.filter(comment => comment.text === "Once").map(comment => comment.path)).toEqual([created.value.path]); + }); + it("fences late retired data and aborts only the repository watch/scope", async () => { + const h = await fixture(); const close = vi.spyOn(h.client, "close"); + const pending = h.backend.listManuscripts(); h.current.mockReturnValue(false); + expect((await pending).ok).toBe(false); + h.caller.abort(); expect((await h.backend.readBody("manuscripts/original.md")).ok).toBe(false); + expect(close).not.toHaveBeenCalled(); expect(h.client.link).toBe("open"); + }); +}); diff --git a/apps/writer/src/backend/native.ts b/apps/writer/src/backend/native.ts new file mode 100644 index 00000000..3ba75cbb --- /dev/null +++ b/apps/writer/src/backend/native.ts @@ -0,0 +1,530 @@ +// Writer's domain repository over one borrowed public SDK connection. No +// legacy backend facade, SDK runtime/setup engine, copied client or app journal. +import { isMdbaseError, type MdbaseApplicationConnection, type MdbaseClient, type ContractRecordView, type ContractSelector, type Write, type wire } from "@mdbase-dev/sdk"; +import type { JsonObject } from "@mdbase-dev/connect"; +import { commentFromRecord, type CommentRecord } from "@mdbase-writer/core/comments"; +import { CollectionBodies, type WriterBodyRow } from "./bodies.js"; +import { CollectionStore, annotationContract, commentContract, domainContract, manuscriptContract, type Binding, type CollectionRow, type CollectionSchema, type Domain } from "./collection.js"; +import { changeFields, commentPath, newCommentFields, NO_PEOPLE, peopleFromDirectory, type CommentChange, type NewComment, type People } from "./comments.js"; +import { manifestContractDigest, nativeObject, sameDigest, nativeWriteFields, nativeWriterSchema } from "./native-schema.js"; +import { NextWriterRecords } from "./next-records.js"; +import { fail, manuscriptBody, manuscriptSlug, numberedPath, ok, titleFromNote, type CollectionDelta, type CollectionIndex, type LibraryEntry, type ManuscriptSummary, type NewManuscript, type Result, type SourceAnnotation, type WriterBackend } from "./types.js"; + +export interface NativeWriterOptions { + readonly collectionName: string; + readonly signal: AbortSignal; + readonly records?: NextWriterRecords; + /** Reuses the SDK's original authorize gesture, never an app popup engine. */ + readonly authorize?: () => Promise; +} +interface NativeMetadata { + readonly schema: CollectionSchema; + readonly store: CollectionStore; + readonly identities: ReadonlyMap; + readonly files: ReadonlyMap; + readonly generation: number; +} +const RECONNECT_MIN_MS = 500, RECONNECT_MAX_MS = 30_000; +function unprotected(record: wire.RecordView): boolean { + return record.state.state === "confirmed" && !record.state.hold && !(record.state.unresolved ?? 0); +} + +export class NativeWriterBackend implements WriterBackend { + readonly kind = "connect"; + readonly collectionName: string; + readonly draftNamespace: string; + readonly records: NextWriterRecords; + // No writeFile: Writer's original declaration allows only files list/read. + // Upload remains unavailable unless a separately approved declaration changes. + private readonly client: MdbaseClient; + private readonly lifetime = new AbortController(); + private readonly signal: AbortSignal; + private readonly ownedRecords: boolean; + private watch: ReturnType; + private stopWatchState: () => void = () => {}; + private watchEpoch = 0; + /** One pending background reconnect at most; its delay doubles to a cap. */ + private reconnectTimer: ReturnType | undefined; + private reconnectDelay = RECONNECT_MIN_MS; + private readonly external = new Set<(paths: readonly string[]) => void>(); + private readonly collection = new Set<(delta: CollectionDelta) => void>(); + private readonly abort = () => this.dispose(); + private disposed = false; + private generation = 0; + private cached: NativeMetadata | undefined; + private metadataJob: Promise | undefined; + private peopleJob: Promise | undefined; + private writing = false; + /** This session's write whose reply was lost, so a repeated click can't duplicate it. + * Writes from earlier sessions are the SDK write ledger's to keep and settle. */ + private unresolvedMutation: string | undefined; + /** The SDK intent `unresolvedMutation` was submitted under, if any. */ + private unresolvedIntent: string | undefined; + /** Each user creation action's first submit (record ID, path, SDK intent), so its + * retry re-sends the same create. A rejected attempt renews the SDK intent. */ + private readonly creations = new Map(); + + constructor(private readonly connection: MdbaseApplicationConnection, private readonly options: NativeWriterOptions) { + this.client = connection.records; + if (connection.id !== this.client.collection || connection.files !== this.client.files) throw new Error("Writer requires its original SDK collection and files client."); + this.collectionName = options.collectionName; + this.draftNamespace = this.client.collection; + this.signal = AbortSignal.any([options.signal, this.lifetime.signal]); + this.ownedRecords = !options.records; + this.records = options.records ?? new NextWriterRecords(this.client, this.signal, () => connection.isCurrent()); + this.watch = this.startWatch(); + this.signal.addEventListener("abort", this.abort, {once: true}); + if (this.signal.aborted) this.dispose(); + } + get setupStatus() {return this.cached?.schema.setupStatus;} + private startWatch(reconnecting = false, background = false): ReturnType { + const epoch = ++this.watchEpoch; + // A failed replacement does not invalidate again (no recursive reload + // loop); it schedules one bounded, backed-off reconnect instead. + let notifyFailure = !reconnecting; + const current = () => !this.disposed && epoch === this.watchEpoch; + const watch = this.client.watchChanges(undefined, batch => { + if (current()) this.invalidate(batch.reset ? [] : batch.changes.map(change => change.path)); + }); + const ended = () => { + if (notifyFailure) this.invalidate([]); + notifyFailure = false; + this.scheduleReconnect(); + }; + this.stopWatchState = watch.subscribe(state => { + if (!current()) return; + if (state === "active") { + this.reconnectDelay = RECONNECT_MIN_MS; notifyFailure = true; + // Changes made while disconnected were never delivered: reload. + if (background) {background = false; this.invalidate([]);} + } + if (state === "stale" || state === "failed" || state === "closed") ended(); + }); + void watch.ready.catch(() => {if (current()) ended();}); + return watch; + } + private ended(watch: ReturnType): boolean { + return watch.state === "stale" || watch.state === "failed" || watch.state === "closed"; + } + /** Replace our own ended handle: fence its callbacks, retire it, start one. */ + private replaceWatch(background: boolean): void { + clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; + ++this.watchEpoch; + this.stopWatchState(); this.watch(); + this.watch = this.startWatch(true, background); + } + private scheduleReconnect(): void { + if (this.disposed || this.reconnectTimer !== undefined) return; + const delay = this.reconnectDelay; + this.reconnectDelay = Math.min(delay * 2, RECONNECT_MAX_MS); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined; + if (this.disposed || !this.connection.isCurrent() || !this.ended(this.watch)) return; + this.replaceWatch(true); + }, delay); + } + private discoveryWatch(): ReturnType { + if (this.ended(this.watch)) this.replaceWatch(false); + return this.watch; + } + private check(generation?: number): void { + this.signal.throwIfAborted(); + if (this.disposed || !this.connection.isCurrent()) throw new Error("Writer's collection has changed."); + if (generation !== undefined && generation !== this.generation) throw new Error("Writer's collection discovery has changed. Retry the read."); + } + private async safely(operation: () => Promise): Promise> { + try {this.check(); const value = await operation(); this.check(); return ok(value);} + catch (error) { + return fail(this.signal.aborted || this.disposed ? "Writer's collection view has closed." + : isMdbaseError(error) ? `The native collection operation could not complete (${error.code}).` + : error instanceof Error && error.message.startsWith("Writer") ? error.message : "Writer could not read or confirm this collection operation."); + } + } + private async safelyResult(operation: () => Promise>): Promise> { + const result = await this.safely(operation); + return result.ok ? result.value : result; + } + private invalidate(paths: readonly string[]): void { + if (this.disposed) return; + ++this.generation; this.cached = undefined; this.peopleJob = undefined; + for (const listener of this.external) listener(paths); + for (const listener of this.collection) listener({paths, reset: true}); + } + private metadata(): Promise { + this.check(); + if (this.cached && this.watch.state === "active") return Promise.resolve(this.cached); + this.cached = undefined; + if (this.metadataJob) return this.metadataJob; + const job = this.loadMetadata(); this.metadataJob = job; + void job.then(() => {if (this.metadataJob === job) this.metadataJob = undefined;}, () => {if (this.metadataJob === job) this.metadataJob = undefined;}); + return job; + } + private async loadMetadata(): Promise { + const watch = this.discoveryWatch(); + await watch.ready; this.check(); + const generation = this.generation; + if (watch !== this.watch || watch.state !== "active") throw new Error("Writer's collection watch needs reconnection."); + const catalog = await this.client.describe(this.signal); this.check(generation); + const schema = nativeWriterSchema(catalog); + const store = new CollectionStore(schema); + const rows = new Map(); + const identities = new Map(); + const ids = new Set(); + const recordCursors = new Set(); + let asOf: number | undefined; + let complete = false; + for await (const page of this.client.pages({limit: 500}, {effective: true}, this.signal)) { + this.check(generation); + if (asOf !== undefined && asOf !== page.asOf) throw new Error("Writer's native query snapshot changed."); + asOf = page.asOf; + if (page.cursor) { + if (recordCursors.has(page.cursor)) throw new Error("Writer's native record cursor repeated."); + recordCursors.add(page.cursor); + } + for (const record of page.records) { + if (identities.has(record.path) || ids.has(record.id)) throw new Error("Writer's native query repeated a record identity."); + identities.set(record.path, {id: record.id, revision: record.revision}); ids.add(record.id); + rows.set(record.id, {path: record.path, types: record.types, + ...(record.mtime !== undefined ? {file: {mtime: new Date(record.mtime).toISOString()}} : {})}); + } + complete = page.complete && !page.cursor; + } + if (!complete || asOf === undefined) throw new Error("Writer's native query did not return a complete snapshot."); + await this.projectMetadata(catalog, schema, rows, identities, asOf, generation); + store.upsert([...rows.values()], true); + const files = new Map(); + const fileIds = new Set(); + const cursors = new Set(); + let cursor: string | undefined; + do { + const page = await this.connection.files.listPage({limit: 500, ...(cursor ? {cursor} : {}), signal: this.signal}); + this.check(generation); + for (const file of page.files) { + if (files.has(file.path) || fileIds.has(file.id)) throw new Error("Writer's file listing repeated an identity."); + files.set(file.path, file); fileIds.add(file.id); + } + cursor = page.cursor; + if (cursor) { + if (cursors.has(cursor)) throw new Error("Writer's file cursor repeated."); + cursors.add(cursor); + } else if (!page.complete) throw new Error("Writer's file listing is incomplete."); + } while (cursor); + store.files([...files.keys()]); + this.check(generation); + if (watch !== this.watch || watch.state !== "active") throw new Error("Writer's collection watch needs reconnection."); + return this.cached = {schema, store, identities, files, generation}; + } + private async projectMetadata(catalog: wire.DescribeResult, schema: CollectionSchema, rows: Map, identities: ReadonlyMap, asOf: number, generation: number): Promise { + for (const domain of ["manuscript", "source", "annotation", "comment", "person"] as const) { + const selected = new Map([...rows].filter(([, row]) => schema.roles(row.types).get(domain)?.active)); + if (!selected.size) continue; + const contract = domainContract(domain); + const declared = catalog.contracts.find(candidate => candidate.id === contract.id && candidate.version === contract.version); + if (!declared) throw new Error("Writer's metadata lost its exact contract."); + const seen = new Set(); + const cursors = new Set(); + let cursor: string | undefined; + do { + const result = await this.client.queryContract(contract, {pageSize: 128, ...(cursor ? {cursor} : {}), signal: this.signal}); this.check(generation); + if (!sameDigest(result.contractDigest, declared.digest) || result.page.asOf !== asOf) throw new Error("Writer's contract metadata snapshot or digest changed."); + const witnesses = new Map(result.page.records.map(record => [record.id, record])); + if (witnesses.size !== result.page.records.length) throw new Error("Writer's contract query repeated a record identity."); + for (const projected of result.records) { + const row = selected.get(projected.id); + const witness = witnesses.get(projected.id); + const identity = row && identities.get(row.path); + if (!row || !witness || !identity || witness.path !== row.path || witness.revision !== identity.revision || seen.has(projected.id)) throw new Error("Writer's contract metadata identity changed or repeated."); + const binding = schema.roles(row.types).get(domain); + const implementations = projected.implementations.filter(candidate => candidate.typeName === binding?.name); + const implementation = implementations[0]; + if (implementations.length !== 1 || !implementation) throw new Error("Writer's contract metadata projection is ambiguous."); + seen.add(projected.id); + // One invalid record stays listed without its projected fields; + // it never blocks the rest of the collection. + if (implementation.issues.some(issue => issue.severity === "error")) continue; + rows.set(projected.id, {...row, contractFields: {...row.contractFields, [domain]: nativeObject(implementation.fields)}}); + } + cursor = result.page.cursor; + if (cursor) { + if (cursors.has(cursor)) throw new Error("Writer's contract metadata cursor repeated."); + cursors.add(cursor); + } else if (!result.page.complete) throw new Error("Writer's contract metadata query is incomplete."); + } while (cursor); + if (seen.size !== selected.size) throw new Error("Writer's contract metadata omitted an original record."); + } + } + manuscriptBindings(): Promise> {return this.safely(async () => this.bindingList((await this.metadata()).schema, "manuscript"));} + private bindingList(schema: CollectionSchema, domain: Domain): Binding[] { + const found = schema.bindings(domain, true); + if (!found.ok) throw new Error(found.message); + return found.value; + } + private async binding(domain: Domain): Promise { + const first = this.bindingList((await this.metadata()).schema, domain)[0]; + if (!first) throw new Error("Writer has no native type for this operation."); + return first; + } + listManuscripts(): Promise> {return this.safely(async () => this.available(await this.metadata(), "manuscript").store.manuscripts);} + /** Fails with plain copy when every Writer type for the domain is unusable. */ + private available(metadata: NativeMetadata, domain: Domain): NativeMetadata { + const message = metadata.schema.unavailableMessage(domain); + if (message) throw new Error(message); + return metadata; + } + index(): Promise> {return this.safely(async () => (await this.metadata()).store.index);} + library(): Promise> {return this.safely(async () => (await this.metadata()).store.library);} + annotationPaths(): Promise> {return this.safely(async () => [...(await this.metadata()).store.annotations.keys()]);} + annotationFields(path: string, types: readonly string[] = []): Readonly> { + return this.cached?.store.annotations.get(path)?.fields ?? this.cached?.schema.annotationFields(types) ?? {}; + } + private bodies(metadata: NativeMetadata): CollectionBodies { + return new CollectionBodies(() => metadata.store, selected => this.safely(async () => { + const rows: WriterBodyRow[] = []; + for (const [path, entry] of selected) { + const identity = metadata.identities.get(path); + if (!identity) throw new Error("Writer lost the original record identity."); + const record = await this.client.get(identity.id, {body: true}, this.signal); this.check(metadata.generation); + if (record.id !== identity.id || record.path !== path || record.revision !== identity.revision || !record.types.includes(entry.type) || !unprotected(record)) throw new Error("Writer's selected record changed or is protected."); + const contract = metadata.store.comments.has(path) ? commentContract : annotationContract; + const projected = await this.contractRecord(record, {...contract, type: entry.type}); this.check(metadata.generation); + rows.push({path: record.path, types: record.types, contractFields: nativeObject(projected.fields), body: record.body ?? ""}); + } + return rows; + })); + } + comments(scope?: readonly string[]): Promise> { + return this.safelyResult(async () => { + const metadata = this.available(await this.metadata(), "comment"); + const result = await this.bodies(metadata).commentsForScope(scope); this.check(metadata.generation); + return result; + }); + } + annotationsForSource(path: string): Promise> { + return this.safelyResult(async () => { + const metadata = await this.metadata(); + const result = await this.bodies(metadata).annotationsForSource(path); this.check(metadata.generation); + return result; + }); + } + readBody(path: string): Promise> { + return this.safely(async () => { + const metadata = await this.metadata(); const original = metadata.identities.get(path); + if (!original) throw new Error("Writer cannot find this record in its native index."); + const record = await this.client.get(original.id, {body: true}, this.signal); this.check(metadata.generation); + if (record.id !== original.id || record.path !== path || record.revision !== original.revision || !unprotected(record)) throw new Error("Writer's record changed or is protected."); + return record.body ?? ""; + }); + } + readFile(path: string): Promise> { + return this.safely(async () => { + const metadata = await this.metadata(); const original = metadata.files.get(path); + if (!original) throw new Error("Writer cannot find this file in its native index."); + const seen = await this.connection.files.get(original.id, this.signal); this.check(metadata.generation); + if (seen.id !== original.id || seen.path !== path || seen.digest !== original.digest || seen.hold || seen.state === "pending_upload" || seen.confirmedSeq <= 0) throw new Error("Writer's file changed or is protected."); + const bytes = await this.connection.files.download(seen, {signal: this.signal}); this.check(metadata.generation); + const current = await this.connection.files.get(original.id, this.signal); this.check(metadata.generation); + if (current.id !== original.id || current.digest !== seen.digest || current.path !== path || current.hold || current.state === "pending_upload" || current.confirmedSeq <= 0 || bytes.byteLength !== current.size) throw new Error("Writer's downloaded file changed or is protected."); + return bytes; + }); + } + people(options: {fresh?: boolean} = {}): Promise { + if (!this.peopleJob || options.fresh) this.peopleJob = this.loadPeople(); + return this.peopleJob; + } + private async loadPeople(): Promise { + try { + this.check(); + const directory = await this.connection.people.directory({members: "omit", signal: this.signal}); this.check(); + return peopleFromDirectory(directory); + } catch (error) { + // Names only, never a substitute account/author or guessed identity. + try {const names = (await this.metadata()).store.people; this.check(); return {names, signing: {kind: "unavailable", reason: "Writer could not check the account's person record."}};} + catch {return {...NO_PEOPLE, signing: isMdbaseError(error) && error.code === "forbidden" ? {kind: "not-approved"} : {kind: "unavailable", reason: "Writer could not check the account's person record."}};} + } + } + reviewIdentityAccess(): Promise> { + return this.safely(async () => {if (!this.options.authorize) throw new Error("Writer cannot request identity access here."); await this.options.authorize(); this.check(); this.peopleJob = undefined;}); + } + private async write(operation: () => Promise, intent?: string): Promise> { + if (this.writing) return fail("Writer is still confirming the original operation."); + this.writing = true; + try { + return await this.safely(async () => { + // A lost submit result never admits a fresh retry. Reconcile the same + // captured MID through SDK receipt/read APIs; this click does no write. + // A retry of the same creation re-sends its original intent, which lands once. + if (this.unresolvedMutation && (intent === undefined || this.sdkIntent(intent) !== this.unresolvedIntent)) { + const receipt = await this.client.awaitReceipt(this.unresolvedMutation, 5_000, this.signal); this.check(); + if (receipt.mutation !== this.unresolvedMutation) throw new Error("Writer lost the original receipt identity."); + if (receipt.state === "rejected") { + this.unresolvedMutation = this.unresolvedIntent = undefined; + throw new Error("Writer's original operation was rejected. Review it before starting another."); + } + await this.recovered(this.unresolvedMutation, receipt); + this.unresolvedMutation = this.unresolvedIntent = undefined; + throw new Error("Writer recovered the original operation. Review it before starting another."); + } + return operation(); + }); + } finally {this.writing = false;} + } + private async recovered(mutationId: string, receipt: wire.Receipt): Promise { + if (receipt.mutation !== mutationId || receipt.state !== "confirmed" || !receipt.records?.length) throw new Error("Writer must recover the original operation before starting another."); + for (const witnessed of receipt.records) { + const current = await this.client.get(witnessed.id, {document: true}, this.signal); this.check(); + if (!unprotected(current) || current.id !== witnessed.id || current.path !== witnessed.path + || (witnessed.state.state === "confirmed" && current.revision !== witnessed.revision)) throw new Error("Writer's original operation is protected or changed."); + } + } + private async availablePath(path: string): Promise { + for (let n = 1; n <= 20; n++) { + const target = numberedPath(path, n); + const found = await this.client.find({path: target}, undefined, this.signal); this.check(); + if (!found) return target; + } + throw new Error("Writer could not find an unused path."); + } + private async nativeWrite(start: (ids: {mutationId: string} | {intent: string}) => Promise, intent?: string): Promise { + // With an intent the SDK derives the MID; a retry of it keeps the one already captured. + const ids = intent === undefined ? {mutationId: crypto.randomUUID()} : {intent}; + const mutationId = "mutationId" in ids ? ids.mutationId : intent === this.unresolvedIntent ? this.unresolvedMutation : undefined; + this.unresolvedMutation = mutationId; this.unresolvedIntent = intent; + try { + const write = await start(ids); + if (mutationId !== undefined && write.mutationId !== mutationId) throw new Error("Writer lost the original mutation identity."); + this.unresolvedMutation = write.mutationId; this.check(); + return write; + } catch (error) { + if (isMdbaseError(error) && error.mutationId) { + if (mutationId !== undefined && error.mutationId !== mutationId) throw new Error("Writer lost the original mutation identity.", {cause: error}); + this.unresolvedMutation = error.mutationId; + } + // Retain the captured original even for an untyped lost transport reply. + // A receipt lookup, never a new submit, resolves the next user action. + throw error; + } + } + private async contractRecord(original: wire.RecordView, contract: ContractSelector): Promise { + const projected = await this.client.getContract(original.id, contract, this.signal); this.check(); + const expected = (await this.metadata()).schema.description.contracts.find(candidate => candidate.id === contract.id); + const digest = manifestContractDigest(contract.id, contract.version); + if (!expected || projected.id !== original.id || projected.path !== original.path || projected.revision !== original.revision + || projected.contract.id !== contract.id || projected.contract.version !== contract.version || projected.contract.type !== contract.type + || (digest !== undefined && !sameDigest(projected.contract.digest, digest))) throw new Error("Writer's contract record binding changed."); + const current = await this.client.get(original.id, {body: true, document: true}, this.signal); this.check(); + if (!unprotected(current) || current.id !== original.id || current.path !== original.path || current.revision !== original.revision) throw new Error("Writer's contract record changed or is protected."); + return projected; + } + private async confirmed(write: Write, id: string, contract?: ContractSelector): Promise { + await write.confirmed; this.check(); + if (write.receipt.mutation !== write.mutationId || write.receipt.state !== "confirmed") throw new Error("Writer lost the original confirmed receipt."); + // A confirmed receipt may carry no records, so `write.records` can still be the + // optimistic (pending) view from submit. It proves identity and path; the + // confirmed read below proves protection and the current revision. + const witnessed = write.records.find(record => record.id === id); + if (!witnessed) throw new Error("Writer's original receipt did not name the record."); + const current = await this.client.get(id, {body: true, document: true}, this.signal); this.check(); + if (!unprotected(current) || current.id !== id || current.path !== witnessed.path + || (witnessed.state.state === "confirmed" && current.revision !== witnessed.revision)) throw new Error("Writer's confirmed record changed or is protected."); + if (contract) await this.contractRecord(current, contract); + if (this.unresolvedMutation === write.mutationId) this.unresolvedMutation = this.unresolvedIntent = undefined; + return current; + } + private sdkIntent(intent: string): string { + const attempt = this.creations.get(intent)?.attempt ?? 0; + return attempt ? `${intent}/${attempt}` : intent; + } + private async create(path: string, body: string, frontmatter: JsonObject, contract?: ContractSelector, intent?: string): Promise { + const creation = intent === undefined ? {attempt: 0} : this.creations.get(intent) ?? {attempt: 0}; + const target = creation.target ?? await this.availablePath(path); this.check(); + const id = creation.id ?? crypto.randomUUID(); + if (intent !== undefined) this.creations.set(intent, {...creation, id, target}); + const sdkIntent = intent === undefined ? undefined : this.sdkIntent(intent); + // Exactly one submit. A taken path/unknown outcome is not a fresh retry. + const fields = nativeWriteFields(frontmatter); + const write = await this.nativeWrite(ids => this.client.create({id, path: target, body, frontmatter: fields, ...(contract ? {contract} : {})}, {...ids, signal: this.signal, include: {body: true, document: true}}), sdkIntent); + this.check(); + try {return await this.confirmed(write, id, contract);} + catch (error) { + // Nothing landed: the action's next retry is a fresh create under a renewed intent. + if (intent !== undefined && write.state === "rejected") { + this.creations.set(intent, {attempt: creation.attempt + 1}); + if (this.unresolvedMutation === write.mutationId) this.unresolvedMutation = this.unresolvedIntent = undefined; + } + throw error; + } + } + createManuscript(input: NewManuscript): Promise> { + return this.write(async () => { + const binding = await this.binding("manuscript"); + const library = input.starter ? (await this.metadata()).store.library : []; + const body = manuscriptBody(input.starter, library[0]?.key); + const frontmatter = {title: input.title, template: input.template, csl: input.style}; + const created = await this.create(`manuscripts/${manuscriptSlug(input.title)}.md`, body, frontmatter, {...manuscriptContract, type: binding.name}, input.intent); + if (created.body !== body) throw new Error("Writer's manuscript readback differs from the original operation."); + return created.path; + }, input.intent); + } + createRecord(path: string, body: string, intent?: string): Promise> { + return this.write(async () => {const created = await this.create(path, body, {}, undefined, intent); if (created.body !== body) throw new Error("Writer's record readback differs from the original operation."); return created.path;}, intent); + } + adoptManuscript(path: string): Promise> { + return this.write(async () => { + const binding = await this.binding("manuscript"); + const original = await this.client.get({path}, {body: true, document: true}, this.signal); this.check(); + if (!unprotected(original) || original.document === undefined) throw new Error("Writer's note is protected or lacks its full source."); + const contract = {...manuscriptContract, type: binding.name}; + const patch = {title: titleFromNote(path, original.body ?? "")}; + const write = await this.nativeWrite(ids => this.client.update(original, {contract, promote: true, patch, ifRevision: original.revision}, {...ids, signal: this.signal, include: {body: true, document: true}})); + const current = await this.confirmed(write, original.id, {...manuscriptContract, type: binding.name}); + if (current.body !== original.body || !current.types.includes(binding.name)) throw new Error("Writer's adopted note did not confirm the original content and type."); + return current.path; + }); + } + createComment(input: NewComment): Promise> { + return this.write(async () => { + const binding = await this.binding("comment"); const now = new Date(); + const fields = newCommentFields(input, now, (await this.people()).me?.link); this.check(); + const body = input.text.trim() ? `${input.text.trim()}\n` : ""; + const contract = {...commentContract, type: binding.name}; + const current = await this.create(commentPath(now), body, fields, contract, input.intent); + const projected = await this.contractRecord(current, contract); + const comment = commentFromRecord(current.path, nativeObject(projected.fields), current.body ?? ""); + if (!comment || current.body !== body) throw new Error("Writer's comment could not be confirmed from its native readback."); + return comment; + }, input.intent); + } + changeComment(comment: CommentRecord, change: CommentChange): Promise> { + return this.write(async () => { + const metadata = await this.metadata(); const identity = metadata.identities.get(comment.path); + if (!identity) throw new Error("Writer lost the original comment identity."); + const original = await this.client.get(identity.id, {body: true, document: true}, this.signal); this.check(); + if (!unprotected(original) || original.document === undefined || original.path !== comment.path) throw new Error("Writer's original comment is protected or has moved."); + const binding = this.bindingList(metadata.schema, "comment").find(candidate => original.types.includes(candidate.name)); + if (!binding) throw new Error("Writer's comment lost its native contract binding."); + const contract = {...commentContract, type: binding.name}; + const projected = await this.contractRecord(original, contract); + const currentComment = commentFromRecord(original.path, nativeObject(projected.fields), original.body ?? ""); + if (!currentComment || JSON.stringify(currentComment) !== JSON.stringify(comment)) throw new Error("Writer's comment changed before this operation."); + const {fields, body} = changeFields(currentComment, change, new Date(), (await this.people()).me?.link); this.check(); + const patch = nativeWriteFields(fields); + const write = await this.nativeWrite(ids => this.client.update(original, {contract, patch, ...(body !== undefined ? {body} : {}), ifRevision: original.revision}, {...ids, signal: this.signal, include: {body: true, document: true}})); + const current = await this.confirmed(write, original.id, contract); + const nextView = await this.contractRecord(current, contract); + const next = commentFromRecord(current.path, nativeObject(nextView.fields), current.body ?? ""); + if (!next) throw new Error("Writer's changed comment could not be confirmed from native readback."); + return next; + }); + } + onExternalChange(listener: (paths: readonly string[]) => void): () => void {this.external.add(listener); return () => {this.external.delete(listener);};} + onCollectionChange(listener: (delta: CollectionDelta) => void): () => void {this.collection.add(listener); return () => {this.collection.delete(listener);};} + flushChanges(): Promise> {return this.safely(async () => {await this.metadata();});} + reconcile(): Promise> {this.invalidate([]); return this.flushChanges();} + dispose(): void { + if (this.disposed) return; + this.disposed = true; ++this.watchEpoch; clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; + this.signal.removeEventListener("abort", this.abort); this.stopWatchState(); this.watch(); + this.lifetime.abort(); if (this.ownedRecords) this.records.dispose(); + this.external.clear(); this.collection.clear(); this.cached = undefined; + } +} diff --git a/apps/writer/src/backend/next-records.test.ts b/apps/writer/src/backend/next-records.test.ts new file mode 100644 index 00000000..d9623e80 --- /dev/null +++ b/apps/writer/src/backend/next-records.test.ts @@ -0,0 +1,163 @@ +import { connect, MdbaseError, type MdbaseClient } from "@mdbase-dev/sdk"; +import { MemoryReplica } from "@mdbase-dev/sdk/testing"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { NextWriterRecords, type NextWriterLease } from "./next-records.js"; + +const clients: MdbaseClient[] = []; +const leases: NextWriterLease[] = []; +afterEach(() => { + for (const lease of leases.splice(0)) lease.release(); + for (const client of clients.splice(0)) client.close(); + vi.restoreAllMocks(); +}); + +async function fixture(pending = false) { + const replica = new MemoryReplica({ confirmDelayMs: pending ? null : 0 }); + const record = replica.seed({ path: "writing/draft.md", frontmatter: { title: "Draft", custom: "preserve" }, body: "Original body\n" }); + const client = await connect({ connector: replica.connector(), app: { name: "writer-lease-test", version: "test" }, reconnect: false }); + clients.push(client); + const owner = new AbortController(); + // The public helper is frozen; a typed test-only forwarding port observes + // real SDK calls without modifying the native lease/session engine. + const nativeOpen = vi.fn(client.records.open.bind(client.records)); + return { replica, record, client, owner, nativeOpen, records: new NextWriterRecords({ records: { open: nativeOpen } }, owner.signal) }; +} + +async function open(f: Awaited>): Promise { + const result = await f.records.open(f.record.path, { autosave: false }); + if (!result.ok) throw new Error(result.problem.message); + leases.push(result.value); + return result.value; +} + +describe("NextWriterRecords SDK lease value translation (stand-in, not native/runtime)", () => { + it("opens an explicit native path and projects display JSON without replacing the native view", async () => { + const f = await fixture(); + const nativeOpen = f.nativeOpen; + const lease = await open(f); + expect(nativeOpen).toHaveBeenCalledWith({ path: f.record.path }, { autosave: false, signal: f.owner.signal }); + expect(lease.session.getSnapshot()).toMatchObject({ state: "saved", body: "Original body\n", dirty: false, frontmatter: { title: "Draft", custom: "preserve" } }); + const native = lease.session.getNativeSnapshot(); + expect(native.record.id).toBe(f.record.id); + expect(native.record.frontmatter).toBeInstanceOf(Map); + expect(native.record.document).toBeDefined(); + }); + + it("delegates body/explicit frontmatter patch and confirmed saved state to SDK flush", async () => { + const f = await fixture(); + const lease = await open(f); + const changed = vi.fn(); + const stop = lease.session.subscribe(changed); + lease.session.setBody("Changed body\n"); + lease.session.patchFrontmatter({ title: "Renamed", options: { style: "plain" } }); + expect(lease.session.getSnapshot()).toMatchObject({ state: "unsaved", body: "Changed body\n", dirty: true, frontmatter: { title: "Renamed", custom: "preserve", options: { style: "plain" } } }); + expect(await lease.session.flush({ timeoutMs: 1000 })).toEqual({ ok: true }); + expect(lease.session.getSnapshot()).toMatchObject({ state: "saved", body: "Changed body\n", dirty: false }); + expect(lease.session.getNativeSnapshot().lastReceipt?.state).toBe("confirmed"); + expect((await f.client.get(f.record.id, { body: true })).body).toBe("Changed body\n"); + expect(changed).toHaveBeenCalled(); + stop(); + }); + + it("keeps the SDK-owned shared session alive after one peer releases", async () => { + const f = await fixture(); + const first = await open(f); + const second = await open(f); + first.session.setBody("Shared peer draft\n"); + expect(second.session.getSnapshot().body).toBe("Shared peer draft\n"); + first.release(); + expect(await second.session.flush({ timeoutMs: 1000 })).toEqual({ ok: true }); + expect((await f.client.get(f.record.id, { body: true })).body).toBe("Shared peer draft\n"); + }); + + it("passes retained recovery identity to the native open helper without a factory shim", async () => { + const f = await fixture(); + const nativeLease = await f.client.records.open({ path: f.record.path }, { autosave: false }); + // Observe delegation only; a made-up recovery ref is not native proof. + const release = vi.fn(() => nativeLease.release()); + const nativeOpen = f.nativeOpen.mockResolvedValue({ session: nativeLease.session, release }); + const recovery = { mutationId: crypto.randomUUID(), recordId: f.record.id, baseRevision: nativeLease.session.getSnapshot().record.revision }; + const result = await f.records.open(f.record.path, { autosave: false, recovery }); + expect(nativeOpen).toHaveBeenCalledWith({ path: f.record.path }, { autosave: false, recovery, signal: f.owner.signal }); + expect(result.ok).toBe(true); + if (result.ok) { result.value.release(); } + expect(release).toHaveBeenCalledTimes(1); + }); + + it("translates real SDK open errors without changing their code/message", async () => { + const f = await fixture(); + const problem = { code: "not_found" as const, recovery: "fix_request" as const, message: "Missing native record" }; + f.nativeOpen.mockRejectedValue(new MdbaseError(problem)); + expect(await f.records.open("missing.md")).toEqual({ ok: false, problem }); + }); + + it("does not invoke native open after the owner has aborted", async () => { + const f = await fixture(); + f.owner.abort(new Error("Writer closed")); + const nativeOpen = f.nativeOpen; + expect(await f.records.open(f.record.path)).toEqual({ ok: false, problem: { code: "cancelled", message: "Writer closed" } }); + expect(nativeOpen).not.toHaveBeenCalled(); + }); + + it("releases a late-delivered lease after owner cancellation without closing the borrowed client", async () => { + const f = await fixture(); + const nativeLease = await f.client.records.open({ path: f.record.path }, { autosave: false }); + const release = vi.fn(() => nativeLease.release()); + f.nativeOpen.mockImplementation(() => { + f.owner.abort(new Error("Writer closed")); + return Promise.resolve({ session: nativeLease.session, release }); + }); + const close = vi.spyOn(f.client, "close"); + expect(await f.records.open(f.record.path)).toEqual({ ok: false, problem: { code: "cancelled", message: "Writer closed" } }); + expect(release).toHaveBeenCalledTimes(1); + expect(close).not.toHaveBeenCalled(); + }); + + it("fences a late successful flush result with the original owner signal", async () => { + const f = await fixture(); + const nativeLease = await f.client.records.open({ path: f.record.path }, { autosave: false }); + const nativeFlush = vi.fn(async () => { + f.owner.abort(new Error("Writer closed during delivery")); + return { ok: true as const, record: nativeLease.session.getSnapshot().record }; + }); + f.nativeOpen.mockResolvedValue({ + session: { ...nativeLease.session, flush: nativeFlush }, + release: () => nativeLease.release(), + }); + const lease = await open(f); + expect(await lease.session.flush({ timeoutMs: 100 })).toEqual({ ok: false, problem: { code: "cancelled", message: "Writer closed during delivery" } }); + expect(nativeFlush).toHaveBeenCalledWith({ timeoutMs: 100, signal: f.owner.signal }); + expect(lease.session.getNativeSnapshot().record.id).toBe(f.record.id); + }); + + it("validates the entire explicit JSON patch before changing the SDK draft", async () => { + const f = await fixture(); + const lease = await open(f); + for (const invalid of [NaN, Infinity, new Date(), 1n]) { + expect(() => lease.session.patchFrontmatter({ title: "Must not change", bad: invalid })).toThrow("plain JSON values"); + expect(lease.session.getSnapshot()).toMatchObject({ state: "saved", dirty: false, frontmatter: { title: "Draft" } }); + } + lease.session.patchFrontmatter({ custom: undefined }); + expect(lease.session.getNativeSnapshot().frontmatter.has("custom")).toBe(false); + expect((await f.client.pendingWrites())).toEqual([]); + }); + + it("keeps an unconfirmed write saved on this device with its mutation, then settles it without resubmitting", async () => { + const f = await fixture(true); + const lease = await open(f); + const update = vi.spyOn(f.client, "update"); + lease.session.setBody("Pending draft\n"); + // The receipt wait runs out before the flush deadline: kept on device, not recovery. + expect(await lease.session.flush({ timeoutMs: 25 })).toEqual({ ok: true }); + expect(lease.session.getNativeSnapshot()).toMatchObject({ state: "saved_on_device", recovery: null, body: "Pending draft\n" }); + const pending = await f.client.pendingWrites(); + expect(pending).toHaveLength(1); + const mid = pending[0]!.receipt.mutation; + expect(update).toHaveBeenCalledTimes(1); + f.replica.confirmAll(); + await vi.waitFor(() => expect(lease.session.getNativeSnapshot()).toMatchObject({ state: "saved", recovery: null })); + expect(update).toHaveBeenCalledTimes(1); + expect(lease.session.getNativeSnapshot().lastReceipt?.mutation).toBe(mid); + }); +}); diff --git a/apps/writer/src/backend/next-records.ts b/apps/writer/src/backend/next-records.ts new file mode 100644 index 00000000..f1c50eed --- /dev/null +++ b/apps/writer/src/backend/next-records.ts @@ -0,0 +1,130 @@ +import { isMdbaseError, toPlain, type MdbaseClient, type PlainValue, type RecordOpenOptions as NativeOpenOptions, type RecordSessionSnapshot as NativeSnapshot, type wire } from "@mdbase-dev/sdk"; +import type { JsonObject } from "@mdbase-dev/connect"; + +import type { RecordLease, RecordOpenOptions, RecordSession, RecordSessionSnapshot, SessionProblem, SessionRecord, WriterRecords } from "./types.js"; + +export interface NextWriterSession extends RecordSession { + /** Genuine SDK recovery/MID/receipt/write evidence. Never reconstruct it from + * the app's display-only JSON snapshot or manufacture a replacement intent. */ + getNativeSnapshot(): NativeSnapshot; +} + +export interface NextWriterLease extends RecordLease { + readonly session: NextWriterSession; +} + +export type NextWriterOpenOptions = RecordOpenOptions & Pick; + +/** Thin application value/Result translation over the public SDK lease. No + * app autosave, session factory, protocol, receipt/recovery or lease engine. */ +export class NextWriterRecords implements WriterRecords { + private readonly releases = new Set<() => void>(); + private disposed = false; + private readonly stop = () => this.dispose(); + constructor(private readonly client: Pick, private readonly signal: AbortSignal, private readonly isCurrent: () => boolean = () => true) { + signal.addEventListener("abort", this.stop, { once: true }); + } + + /** Release this UI's borrowed leases only; SDK owns sessions, recovery and client. */ + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.signal.removeEventListener("abort", this.stop); + for (const release of [...this.releases]) release(); + } + + async open(path: string, options: NextWriterOpenOptions = {}): Promise< + { readonly ok: true; readonly value: NextWriterLease } | + { readonly ok: false; readonly problem: { readonly code: string; readonly message?: string } } + > { + try { + this.signal.throwIfAborted(); + if (this.disposed || !this.isCurrent()) return { ok: false, problem: { code: "cancelled" } }; + const lease = await this.client.records.open({ path }, { ...options, signal: this.signal }); + if (this.signal.aborted || this.disposed || !this.isCurrent()) { + lease.release(); + this.signal.throwIfAborted(); + return { ok: false, problem: { code: "cancelled" } }; + } + const native = lease.session; + let released = false; + const release = () => { + if (released) return; + released = true; + this.releases.delete(release); + lease.release(); + }; + const check = () => { + this.signal.throwIfAborted(); + if (released || this.disposed || !this.isCurrent()) throw new DOMException("Writer view has closed", "AbortError"); + }; + this.releases.add(release); + return { ok: true, value: { + release, + session: { + getNativeSnapshot: () => native.getSnapshot(), + getSnapshot: () => snapshot(native.getSnapshot()), + subscribe: listener => native.subscribe(listener), + setBody: body => { check(); native.setBody(body); }, + patchFrontmatter: patch => { check(); native.patchFrontmatter(Object.fromEntries(Object.entries(patch).map(([key, value]) => [key, value === undefined ? undefined : plain(value)]))); }, + resolve: choice => { check(); native.resolve(choice); }, + flush: async flushOptions => { + try { + check(); + const result = await native.flush({ ...flushOptions, signal: this.signal }); + check(); + return result.ok ? { ok: true } : { ok: false, problem: result.problem }; + } catch (error) { + return { ok: false, problem: !this.signal.aborted && (released || this.disposed || !this.isCurrent()) ? { code: "cancelled" } : problem(error, this.signal) }; + } + }, + }, + } }; + } catch (error) { + return { ok: false, problem: problem(error, this.signal) }; + } + } +} + +function problem(error: unknown, signal: AbortSignal): SessionProblem { + return isMdbaseError(error) ? error.toProblem() : { + code: signal.aborted ? "cancelled" : "internal", + message: error instanceof Error ? error.message : "The native Writer record operation could not complete.", + }; +} + +// Copy the explicit JSON patch into the public SDK's mutable plain value type; +// preserve scalar values rather than serializing/round-tripping through JSON. +function plain(value: unknown): PlainValue { + if (value === null || typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (Array.isArray(value)) return value.map(plain); + if (typeof value === "object" && value !== null && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) { + return new Map(Object.entries(value).map(([key, item]) => [key, plain(item)])); + } + throw new TypeError("Writer frontmatter patches require plain JSON values."); +} + +function record(view: wire.RecordView): SessionRecord { + return { + path: view.path, + revision: view.revision, + types: view.types, + // Display/edit view only. Native session keeps the exact Map/tag values and + // genuine full record witness; writes only receive the explicit app patch. + frontmatter: toPlain(view.frontmatter) as JsonObject, + ...(view.body === undefined ? {} : { body: view.body }), + }; +} + +function snapshot(value: NativeSnapshot): RecordSessionSnapshot { + return { + state: value.state, + body: value.body, + frontmatter: toPlain(new Map(value.frontmatter)) as JsonObject, + record: record(value.record), + remote: value.remote ? record(value.remote) : null, + dirty: value.dirty, + problem: value.problem, + }; +} diff --git a/apps/writer/src/backend/types.ts b/apps/writer/src/backend/types.ts index e8382819..40fee039 100644 --- a/apps/writer/src/backend/types.ts +++ b/apps/writer/src/backend/types.ts @@ -1,6 +1,6 @@ // What the writer needs from a collection. Two implementations: Connect (real // collections) and a demo on the SDK's in-memory record authority. -import type { JsonObject, MdbaseRecords } from "@mdbase-dev/connect"; +import type { JsonObject } from "@mdbase-dev/connect"; import type { CslItem, StyleId, TemplateName } from "@mdbase-writer/core"; import type { SourceAnnotation } from "@mdbase-writer/core/annotations"; import type { CommentRecord } from "@mdbase-writer/core/comments"; @@ -56,6 +56,15 @@ export interface CollectionIndex { * manuscript: not comments, people, or Reader's sources and annotations. */ readonly notePaths: readonly string[]; + /** Records with no type at all: shown as notes with a calm "No type" label. */ + readonly untypedPaths?: readonly string[]; + /** Writer's types that can't be used because their definition has an error. */ + readonly unavailableTypes?: readonly UnavailableType[]; +} +export interface UnavailableType { + readonly name: string; + /** Plain copy naming the type and what is unavailable; the rest still works. */ + readonly message: string; } export interface CollectionDelta { @@ -80,6 +89,62 @@ export interface NewManuscript { readonly style: StyleId; /** An optional worked example, without invented library sources. */ readonly starter?: boolean; + /** A stable name for this one creation, reused by its retries so it lands once. */ + readonly intent?: string; +} + +// This is the workspace's application port, not a concrete SDK class. The +// current Connect sessions satisfy it unchanged; the new SDK adapter must keep +// receipt confirmation, drafts, conflict and exact-recovery behavior intact. +export type RecordSessionState = "saved" | "saved_on_device" | "unsaved" | "saving" | "conflict" | "recovery" | "error" | "deleted"; + +export interface SessionProblem { + readonly code: string; + readonly message?: string; +} + +export interface SessionRecord { + readonly path: string; + readonly revision: string; + readonly types: readonly string[]; + readonly frontmatter: JsonObject; + readonly body?: string; +} + +export interface RecordSessionSnapshot { + readonly state: RecordSessionState; + readonly body: string; + readonly frontmatter: JsonObject; + readonly record: SessionRecord; + readonly remote: SessionRecord | null; + readonly dirty: boolean; + readonly problem: SessionProblem | null; +} + +export type RecordResolution = { readonly keep: "mine" | "theirs" } | { readonly body: string }; + +export interface RecordSession { + getSnapshot(): RecordSessionSnapshot; + subscribe(listener: () => void): () => void; + setBody(body: string): void; + patchFrontmatter(patch: JsonObject): void; + resolve(choice: RecordResolution): void; + /** Flush until acknowledged, or preserve the draft with an actionable problem. */ + flush(options?: { timeoutMs?: number }): Promise<{ readonly ok: true } | { readonly ok: false; readonly problem: SessionProblem }>; +} + +export interface RecordLease { + readonly session: RecordSession; + release(): void; +} + +export interface RecordOpenOptions { + readonly autosave?: { readonly idleMs: number } | false; + readonly timeoutMs?: number; +} + +export interface WriterRecords { + open(path: string, options?: RecordOpenOptions): Promise<{ readonly ok: true; readonly value: RecordLease } | { readonly ok: false; readonly problem: SessionProblem }>; } export interface WriterBackend { @@ -89,13 +154,14 @@ export interface WriterBackend { /** Stable, non-secret collection identity for local draft isolation. */ readonly draftNamespace?: string; manuscriptBindings?(): Promise>; - readonly records: Pick, "open">; + readonly records: WriterRecords; listManuscripts(): Promise>; createManuscript(input: NewManuscript): Promise>; /** Marks an existing note as a manuscript (adds the manuscript type; keeps its other types). */ adoptManuscript(path: string): Promise>; - /** Creates a plain Markdown record at `path`, or at `path` numbered when that is taken; resolves to its path. */ - createRecord(path: string, body: string): Promise>; + /** Creates a plain Markdown record at `path`, or at `path` numbered when that is taken; resolves to its path. + * `intent` names this one creation; a retry with the same `intent` lands once. */ + createRecord(path: string, body: string, intent?: string): Promise>; index(): Promise>; library(): Promise>; /** Annotation identity discovery, without reading any bodies. */ diff --git a/apps/writer/src/connect/connect-popup.test.ts b/apps/writer/src/connect/connect-popup.test.ts new file mode 100644 index 00000000..a0b18e33 --- /dev/null +++ b/apps/writer/src/connect/connect-popup.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ConnectPopupBlocked, openConnectPopup } from "./connect-popup.js"; +import { createSignInPortalUi } from "./native-signin-ui.js"; + +afterEach(() => vi.unstubAllGlobals()); + +function handle() { + return { opener: {}, closed: false, location: { href: "" }, close: vi.fn() }; +} + +describe("UI-owned popup handles, not native authentication", () => { + it("uses a detached fallback tab if the popup throws", () => { + const tab = handle(); + const open = vi + .fn() + .mockImplementationOnce(() => { + throw new Error("blocked"); + }) + .mockReturnValue(tab); + vi.stubGlobal("window", { open }); + const portal = openConnectPopup(); + expect(open).toHaveBeenLastCalledWith("", "_blank"); + expect(tab.opener).toBeNull(); + portal.navigate("https://mdbase.test/approve"); + expect(tab.location.href).toBe("https://mdbase.test/approve"); + }); + it("reports an actually blocked popup and fallback", () => { + vi.stubGlobal("window", { open: () => null }); + expect(openConnectPopup).toThrow(ConnectPopupBlocked); + }); + it("closes idempotently and refuses late navigation of a disposed handle", () => { + const popup = handle(); + vi.stubGlobal("window", { open: () => popup }); + const portal = openConnectPopup(); + portal.close(); + portal.close(); + expect(popup.close).toHaveBeenCalledOnce(); + expect(() => portal.navigate("https://mdbase.test/late")).toThrow( + "Sign-in window closed.", + ); + }); + it("keeps the original handle after SDK handoff and reuses it for reopen", () => { + const popup = handle(); + const open = vi.fn(() => popup); + vi.stubGlobal("window", { open }); + const ui = createSignInPortalUi(); + ui.reserve(); + const original = ui.open(); + ui.reserve(); + expect(ui.open()).toBe(original); + expect(open).toHaveBeenCalledOnce(); + ui.cancel(); + ui.cancel(); + expect(popup.close).toHaveBeenCalledOnce(); + }); + it("reopens a manually closed popup without sharing a retired owner's name", () => { + const first = handle(); + const second = handle(); + const open = vi.fn().mockReturnValueOnce(first).mockReturnValue(second); + vi.stubGlobal("window", { open }); + const ui = createSignInPortalUi(); + ui.reserve(); + const original = ui.open(); + first.closed = true; + ui.reserve(); + expect(ui.open()).not.toBe(original); + createSignInPortalUi().reserve(); + expect(open.mock.calls[0]?.[1]).toBe(open.mock.calls[1]?.[1]); + expect(open.mock.calls[2]?.[1]).not.toBe(open.mock.calls[0]?.[1]); + }); + it("does not turn display cleanup failure into an approval error", () => { + const popup = handle(); + popup.close.mockImplementation(() => { + throw new Error("display cleanup"); + }); + vi.stubGlobal("window", { open: () => popup }); + const portal = openConnectPopup(); + expect(() => portal.close()).not.toThrow(); + portal.close(); + expect(popup.close).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/writer/src/connect/connect-popup.ts b/apps/writer/src/connect/connect-popup.ts new file mode 100644 index 00000000..2fbe49e7 --- /dev/null +++ b/apps/writer/src/connect/connect-popup.ts @@ -0,0 +1,61 @@ +import type { AppWebSignInPortal } from "@mdbase-dev/sdk/app-host"; + +export class ConnectPopupBlocked extends Error { + constructor() { + super("Your browser blocked the sign-in tab."); + } +} + +export interface ConnectPopup extends AppWebSignInPortal { + readonly closed: boolean; +} + +/** UI-only capability: callers pass only the SDK-validated Connect URI. + * Keep the production popup opener so its parent can close it across origins. + * The tab fallback detaches its opener before navigation. */ +export function openConnectPopup( + name = "mdbase-connect-authorization", +): ConnectPopup { + let popup: Window | null = null; + try { + popup = window.open("", name, "popup,width=620,height=760"); + } catch { + /* Try a tab. */ + } + if (!popup) { + try { + popup = window.open("", "_blank"); + // A literal noopener feature returns no usable blank handle. Detach + // immediately instead, before the SDK supplies the navigation URI. + if (popup) popup.opener = null; + } catch { + try { + popup?.close(); + } catch { + /* Display cleanup only. */ + } + popup = null; + } + } + if (!popup) throw new ConnectPopupBlocked(); + const original = popup; + let closed = false; + return { + get closed() { + return closed || original.closed; + }, + navigate(uri) { + if (closed || original.closed) throw new Error("Sign-in window closed."); + original.location.href = uri; + }, + close() { + if (closed) return; + closed = true; + try { + original.close(); + } catch { + /* Display cleanup does not undo approval. */ + } + }, + }; +} diff --git a/apps/writer/src/connect/native-signin-ui.test.ts b/apps/writer/src/connect/native-signin-ui.test.ts new file mode 100644 index 00000000..b850c00f --- /dev/null +++ b/apps/writer/src/connect/native-signin-ui.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AppWebSignInSnapshot } from "@mdbase-dev/sdk/app-host"; +import { + openSignInPortal, + SignInTabBlocked, + signInCopy, + signInPresentation, + unavailableCollection, +} from "./native-signin-ui"; + +function snapshot( + patch: Partial = {}, +): AppWebSignInSnapshot { + return { + status: "signed_out", + signIn: null, + collections: [], + selectedCollectionId: null, + problem: null, + timing: null, + ...patch, + }; +} +const paired: NonNullable = { + requestId: "request", + installationId: "installation", + deviceId: "device", + appId: "app", + kind: "app-runtime", + verificationUri: "https://mdbase.test/approve", + state: "paired", + accountId: "account", + accountEmail: "writer@example.test", + collectionIds: [], + createCollections: false, + requestedCreateCollections: false, +}; + +describe("native sign-in presentation, not an authentication engine", () => { + it("uses canonical signed-out wording", () => { + expect(signInPresentation(snapshot(), "TaskNotes Planner").message).toBe( + "Sign in with your mdbase account to sync TaskNotes Planner across your devices.", + ); + }); + it("renders SDK authorizing without introducing an app poller", () => { + const view = signInPresentation( + snapshot({ status: "authorizing" }), + "Planner", + ); + expect(view.message).toBe("Waiting for approval in the mdbase tab…"); + expect(view.waiting).toBe(true); + }); + it("shows email only from a paired SDK view", () => { + expect( + signInPresentation( + snapshot({ status: "unselected", signIn: paired }), + "Planner", + ).message, + ).toBe("Signed in as writer@example.test"); + expect( + signInPresentation( + snapshot({ + status: "authorizing", + signIn: { ...paired, state: "account_selected" }, + }), + "Planner", + ).identity, + ).toBeNull(); + }); + it("does not invent an email or use account IDs as labels", () => { + const view = signInPresentation( + snapshot({ + status: "unselected", + signIn: { ...paired, accountEmail: null }, + }), + "Planner", + ); + expect(view.identity).toBeNull(); + expect(view.message).toBe(signInCopy.unavailable); + expect(view.message).not.toContain(paired.accountId); + }); + it("uses actual selected collection metadata for opening", () => { + const view = signInPresentation( + snapshot({ + status: "opening", + signIn: paired, + selectedCollectionId: "selected", + collections: [ + { collectionId: "selected", displayName: "Research", role: "owner" }, + ], + }), + "Planner", + ); + expect(view.message).toBe("Signed in. Opening Research…"); + }); + it("refuses to invent a collection name from an identifier", () => { + const view = signInPresentation( + snapshot({ status: "opening", selectedCollectionId: "private-id" }), + "Planner", + ); + expect(view.message).not.toContain("private-id"); + }); + it("does not mistake a generic refusal for the user declining", () => { + expect( + signInPresentation( + snapshot({ status: "blocked", problem: "refused" }), + "Planner", + ).message, + ).toBe(signInCopy.interrupted); + }); + it("does not imply saved/sync or reminders capability from authorization", () => { + const view = signInPresentation( + snapshot({ status: "unselected", signIn: paired }), + "Planner", + ); + expect(JSON.stringify(view)).not.toMatch( + /saved on this device|reminders|Set up|Continue setup/, + ); + expect(view.unavailable).toBe(true); + }); + it("clears visible identity after SDK closure", () => { + const view = signInPresentation( + snapshot({ status: "closed", signIn: paired }), + "Planner", + ); + expect(view.paired).toBe(false); + expect(view.identity).toBeNull(); + }); + it("has a distinct blocked-tab action without rendering raw errors", () => { + const view = signInPresentation(snapshot(), "Planner", "tab_blocked"); + expect(view.message).toBe("Your browser blocked the sign-in tab."); + expect(view.action).toBe("Open sign-in"); + }); + it("opens the named production popup synchronously with its trusted opener", () => { + const opener = {}; + const popup = { + opener, + closed: false, + location: { href: "" }, + close: vi.fn(), + }; + vi.stubGlobal("window", { open: vi.fn(() => popup) }); + try { + const portal = openSignInPortal(); + expect(window.open).toHaveBeenCalledWith( + "", + "mdbase-connect-authorization", + "popup,width=620,height=760", + ); + expect(popup.opener).toBe(opener); + portal.navigate("https://mdbase.test/approve"); + expect(popup.location.href).toBe("https://mdbase.test/approve"); + portal.close(); + expect(popup.close).toHaveBeenCalledOnce(); + } finally { + vi.unstubAllGlobals(); + } + }); + it("distinguishes an actually blocked tab", () => { + vi.stubGlobal("window", { open: () => null }); + try { + expect(openSignInPortal).toThrow(SignInTabBlocked); + } finally { + vi.unstubAllGlobals(); + } + }); + it("the unavailable collection callback never manufactures a connection", async () => { + // Only signal is observed; fake host/scope/collection data is not consumed. + await expect( + unavailableCollection({ + signal: new AbortController().signal, + } as Parameters[0]), + ).rejects.toThrow(signInCopy.unavailable); + }); + it("preserves original cancellation before the unavailable boundary", async () => { + const controller = new AbortController(); + const reason = new Error("original cancelled"); + controller.abort(reason); + await expect( + unavailableCollection({ signal: controller.signal } as Parameters< + typeof unavailableCollection + >[0]), + ).rejects.toBe(reason); + }); +}); diff --git a/apps/writer/src/connect/native-signin-ui.ts b/apps/writer/src/connect/native-signin-ui.ts new file mode 100644 index 00000000..0d30a520 --- /dev/null +++ b/apps/writer/src/connect/native-signin-ui.ts @@ -0,0 +1,149 @@ +import { AppWebSignInSession } from "@mdbase-dev/sdk/app-host"; +import type { + AppWebSignInSessionOptions, + AppWebSignInSnapshot, +} from "@mdbase-dev/sdk/app-host"; +import { openConnectPopup, type ConnectPopup } from "./connect-popup.js"; +export { ConnectPopupBlocked as SignInTabBlocked } from "./connect-popup.js"; + +export type NativeSignInSession = Pick< + AppWebSignInSession, + | "getSnapshot" + | "subscribe" + | "start" + | "authorize" + | "renewExpiredPairing" + | "forget" + | "close" + | "setForeground" +>; + +export const signInCopy = Object.freeze({ + signIn: "Sign in", + waiting: "Waiting for approval in the mdbase tab…", + reopen: "Open the mdbase tab again", + tabBlocked: "Your browser blocked the sign-in tab.", + openSignIn: "Open sign-in", + interrupted: "Sign-in was interrupted. Please try again.", + signOut: "Sign out", + unavailable: "Opening collections isn't available in this build yet.", +}); + +/** UI-only public portal capability. The SDK supplies the validated URI. */ +export const openSignInPortal = openConnectPopup; + +// Popup names are UI ownership identities, never native IDs or namespaces. +let nextPortalKey = 0; + +/** Keep the owned handle after SDK handoff, including when it was closed by hand. */ +export function createSignInPortalUi() { + const name = `mdbase-writer-connect-${++nextPortalKey}`; + let active: ConnectPopup | null = null; + return { + reserve() { + if (!active || active.closed) active = openSignInPortal(name); + }, + open() { + if (!active || active.closed) active = openSignInPortal(name); + return active; + }, + cancel() { + const original = active; + active = null; + original?.close(); + }, + }; +} + +// React presentation identity only, never a native actor/record/mutation ID. +let nextViewKey = 0; + +export interface NativeSignInBinding { + session: NativeSignInSession; + portal: ReturnType; + /** UI-only React identity. */ + viewKey: number; +} + +/** Only a public SDK constructor and UI portal; no collection/SQL factory. */ +export function createNativeSignIn( + options: Omit< + AppWebSignInSessionOptions, + "openPortal" | "openCollection" | "autoSelect" + >, +): NativeSignInBinding { + options.signal.throwIfAborted(); + const portal = createSignInPortalUi(); + const session = new AppWebSignInSession({ + ...options, + openPortal: portal.open, + openCollection: unavailableCollection, + autoSelect: "never", + }); + return { session, portal, viewKey: ++nextViewKey }; +} + +/** Reserve in the user gesture BEFORE any asynchronous SDK renewal/start. */ +export async function authorizeNativeSignIn( + session: NativeSignInSession, + portal: ReturnType, +) { + portal.reserve(); + try { + if (session.getSnapshot().status === "not_started") await session.start(); + if (session.getSnapshot().problem === "expired") + await session.renewExpiredPairing(); + return await session.authorize(); + } catch (error) { + portal.cancel(); + throw error; + } +} + +/** Explicit pre-factory boundary, not a pretend collection or setup result. */ +export const unavailableCollection: AppWebSignInSessionOptions["openCollection"] = + async ({ signal }) => { + signal.throwIfAborted(); + throw new Error(signInCopy.unavailable); + }; + +export function signInPresentation( + snapshot: AppWebSignInSnapshot, + appName: string, + problem: "tab_blocked" | "interrupted" | null = null, +) { + const paired = + snapshot.status !== "closed" && snapshot.signIn?.state === "paired"; + const email = paired ? snapshot.signIn?.accountEmail : null; + const identity = email ? `Signed in as ${email}` : null; + const selected = snapshot.collections.find( + ({ collectionId }) => collectionId === snapshot.selectedCollectionId, + ); + const waiting = snapshot.status === "authorizing" && problem === null; + const interrupted = + problem === "interrupted" || snapshot.status === "blocked"; + const message = + problem === "tab_blocked" + ? signInCopy.tabBlocked + : interrupted + ? signInCopy.interrupted + : waiting + ? signInCopy.waiting + : snapshot.status === "opening" && selected + ? `Signed in. Opening ${selected.displayName}…` + : (identity ?? + (paired + ? signInCopy.unavailable + : `Sign in with your mdbase account to sync ${appName} across your devices.`)); + return { + message, + identity: identity === message ? null : identity, + waiting, + paired, + unavailable: paired && message !== signInCopy.unavailable, + error: problem !== null || snapshot.status === "blocked", + action: + problem === "tab_blocked" ? signInCopy.openSignIn : signInCopy.signIn, + disabled: ["starting", "opening", "closed"].includes(snapshot.status), + }; +} diff --git a/apps/writer/src/connect/sdk-application.test.ts b/apps/writer/src/connect/sdk-application.test.ts new file mode 100644 index 00000000..5e1ed78e --- /dev/null +++ b/apps/writer/src/connect/sdk-application.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +import { createWriterMdbase, deliverWriterGrantReturn, isWriterGrantReturn, writerMdbaseForBuild } from "./sdk-application.js"; +import manifest from "../generated/mdbase-app.json"; + +afterEach(() => {vi.restoreAllMocks(); vi.unstubAllGlobals();}); + +it("defaults only LAB builds to the SDK entry while production keeps the legacy entry", () => { + expect(writerMdbaseForBuild("production")).toBeUndefined(); + expect(writerMdbaseForBuild(undefined)).toBeUndefined(); + const entry = writerMdbaseForBuild("lab"); + expect(entry).toBeDefined(); + // Node/jsdom need not host a native Worker to prove the selected entry. + expect(entry?.getSnapshot().readiness.assessment).toBeNull(); + expect(entry?.connection("unopened")).toBeNull(); +}); + +it("constructs the actual live public entry without registration, Worker or popup IO", async () => { + const worker = vi.fn(() => {throw new Error("No test Worker allocation");}); + const channel = vi.fn(() => {throw new Error("No test channel allocation");}); + vi.stubGlobal("Worker", worker); vi.stubGlobal("MessageChannel", channel); + const fetch = vi.spyOn(globalThis, "fetch"); + const popup = vi.spyOn(window, "open"); + const original = JSON.stringify(manifest); + // Explicit fixture UUID, not a claimed CP registration or runtime acceptance. + const entry = createWriterMdbase({registeredApplicationId: "00000000-0000-4000-8000-000000000001", environment: "lab"}); + expect(entry.getSnapshot()).toMatchObject({status: "not_started", problem: null, + selection: {state: "none", requested: {collectionId: null, approved: false}, usable: null}}); + expect(entry.connection("unopened")).toBeNull(); + expect(JSON.stringify(manifest)).toBe(original); + expect(worker).not.toHaveBeenCalled(); expect(channel).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); expect(popup).not.toHaveBeenCalled(); + await entry.close(); +}); + +it("recognises the Private collection consent return at the redirect URI, in the SDK entry only", async () => { + const home = manifest.redirect_uris[0]!; + expect(isWriterGrantReturn("lab", `${home}?state=s1&code=c1`)).toBe(true); + expect(isWriterGrantReturn("lab", `${home}?state=s1&error=access_denied`)).toBe(true); + expect(isWriterGrantReturn("lab", `${home}?collection=x`)).toBe(false); + expect(isWriterGrantReturn("lab", `${new URL("manuscripts", home).href}?state=s1&code=c1`)).toBe(false); + // The legacy entry's own OAuth callback shares the root: never intercepted there. + expect(isWriterGrantReturn("production", `${home}?state=s1&code=c1`)).toBe(false); + + const posted: unknown[] = []; + class Channel { constructor(readonly name: string) {} postMessage(value: unknown) {posted.push([this.name, value]);} close() {} } + vi.stubGlobal("BroadcastChannel", Channel); + await deliverWriterGrantReturn(`${home}?state=s1&code=c1`); + expect(posted).toEqual([["mdbase.app-grant.v1", {type: "callback", url: `${home}?state=s1&code=c1`}]]); +}); diff --git a/apps/writer/src/connect/sdk-application.ts b/apps/writer/src/connect/sdk-application.ts new file mode 100644 index 00000000..d3d47956 --- /dev/null +++ b/apps/writer/src/connect/sdk-application.ts @@ -0,0 +1,35 @@ +import { createMdbase, type Mdbase, type MdbaseApplicationDeclaration } from "@mdbase-dev/sdk"; +import manifest from "../generated/mdbase-app.json"; + +/** App declaration only. The logical manifest ID is not a registration receipt; + * SDK registration resolves the genuine native binding. An explicit registered + * ID, when supplied, must match that binding. No app runtime/storage callbacks. */ +export function createWriterMdbase(input: { + registeredApplicationId?: string; + environment: MdbaseApplicationDeclaration["environment"]; +}): Mdbase { + return createMdbase({ id: input.registeredApplicationId ?? manifest.id, name: manifest.name, + origin: new URL(manifest.homepage).origin, environment: input.environment, manifest }, + {readiness: {typeIssues: "degrade"}}); +} + +/** LAB default only; production waits for its owner-provided trust asset. */ +export function writerMdbaseForBuild(environment: string | undefined): Mdbase | undefined { + return usesSdk(environment) ? createWriterMdbase({environment: "lab"}) : undefined; +} +const usesSdk = (environment: string | undefined): boolean => environment === "lab"; + +/** The SDK's Private-collection consent popup lands back on the manifest's + * redirect URI (the app root) with `state` and `code` or `error`. Only the SDK + * entry uses that popup; the legacy entry's own callback is left alone. */ +export function isWriterGrantReturn(environment: string | undefined, href: string): boolean { + if (!usesSdk(environment)) return false; + const url = new URL(href); + const redirect = manifest.redirect_uris.map((uri) => new URL(uri)).find((uri) => uri.origin === url.origin) ?? new URL("/", url.origin); + return url.pathname === redirect.pathname && url.searchParams.has("state") && (url.searchParams.has("code") || url.searchParams.has("error")); +} + +/** Hand the consent result to the waiting Writer tab; it closes this popup. */ +export async function deliverWriterGrantReturn(href: string): Promise { + (await import("@mdbase-dev/sdk/app-grant")).deliverAppGrantCallback(href); +} diff --git a/apps/writer/src/editor/Editor.recovery.test.tsx b/apps/writer/src/editor/Editor.recovery.test.tsx new file mode 100644 index 00000000..19489b85 --- /dev/null +++ b/apps/writer/src/editor/Editor.recovery.test.tsx @@ -0,0 +1,52 @@ +// @vitest-environment jsdom +import { act, cleanup, render } from "@testing-library/react"; +import { EditorView } from "@codemirror/view"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { Editor, type EditorProps, type RetainedEditor } from "./Editor.js"; +import { DEFAULT_LAYOUT } from "../ui/layout.js"; + +beforeEach(() => { + // Layout is exercised in the real-browser recovery regression. This test + // checks CodeMirror's editing facets without jsdom's absent text geometry. + vi.spyOn(window, "requestAnimationFrame").mockReturnValue(0); +}); +afterEach(() => { cleanup(); vi.restoreAllMocks(); }); + +it("advertises paused editing and becomes editable after recovery is reviewed", () => { + const props: EditorProps = { + states: new Map(), path: "main.md", text: "Collection text", + readOnly: true, livePreview: false, diagnostics: [], + completion: { library: [], labels: [], recordPaths: [] }, + insight: { library: new Map(), references: new Map(), labels: new Map() }, + onChange: vi.fn(), + }; + const rendered = render(); + const editor = () => { + const element = rendered.container.querySelector(".cm-editor"); + if (!element) throw new Error("Missing editor"); + return EditorView.findFromDOM(element)!; + }; + expect(editor().state.readOnly).toBe(true); + expect(editor().contentDOM.getAttribute("contenteditable")).toBe("false"); + expect(editor().contentDOM.getAttribute("aria-readonly")).toBe("true"); + rendered.rerender(); + expect(editor().state.readOnly).toBe(false); + expect(editor().contentDOM.getAttribute("contenteditable")).toBe("true"); + expect(editor().state.doc.toString()).toBe("Collection text"); +}); + +it("keeps every source line visible with workspace defaults, wherever the caret is", () => { + const text = "first\nsecond\n\nthird\nfourth"; + const props: EditorProps = { + ...DEFAULT_LAYOUT, path: "lines.md", text, readOnly: false, diagnostics: [], + completion: { library: [], labels: [], recordPaths: [] }, + insight: { library: new Map(), references: new Map(), labels: new Map() }, onChange: vi.fn(), + }; + const rendered = render(); + const view = EditorView.findFromDOM(rendered.container.querySelector(".cm-editor")!)!; + const lines = () => [...view.contentDOM.querySelectorAll(".cm-line")].map(el => el.textContent); + expect(lines()).toEqual(text.split("\n")); + act(() => view.dispatch({ selection: { anchor: text.length } })); + expect(lines()).toEqual(text.split("\n")); + expect(view.state.doc.toString()).toBe(text); +}); diff --git a/apps/writer/src/editor/Editor.tsx b/apps/writer/src/editor/Editor.tsx index cf27a4eb..f6e2d637 100644 --- a/apps/writer/src/editor/Editor.tsx +++ b/apps/writer/src/editor/Editor.tsx @@ -6,7 +6,7 @@ import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirro import { bracketMatching, indentOnInput } from "@codemirror/language"; import { lintGutter, setDiagnostics, type Diagnostic as CmDiagnostic } from "@codemirror/lint"; import { highlightSelectionMatches, search, searchKeymap } from "@codemirror/search"; -import { Annotation, Compartment, EditorSelection, EditorState, StateEffect, Transaction } from "@codemirror/state"; +import { Annotation, EditorSelection, EditorState, StateEffect, Transaction } from "@codemirror/state"; import { drawSelection, EditorView, highlightActiveLine, keymap, placeholder } from "@codemirror/view"; import { memo, useEffect, useLayoutEffect, useRef } from "react"; @@ -17,7 +17,6 @@ import { writerCompletions, type CompletionData } from "./completions.js"; import { fixesFor } from "./fixes.js"; import { formattingKeymap, makeLink, toggleBold, toggleCode, toggleItalic, type InlineFormat } from "./formatting.js"; import { collapseToEnd, selectionBar, type SelectionAction } from "./selection-bar.js"; -import { joinLines as joinLinesExtension } from "./join-lines.js"; import { writerInsight, type EditorInsight, type FollowTarget } from "./insight.js"; import { writerLanguage } from "./language.js"; import { livePreview as livePreviewExtension, refreshLivePreview } from "./live-preview.js"; @@ -56,8 +55,6 @@ export interface EditorProps { path: string; text: string; readOnly: boolean; - /** Draws a paragraph's soft line breaks as spaces, so hard-wrapped text flows. */ - joinLines?: boolean; /** Draws citations, figures, tables and marks as the manuscript reads, rather than as Markdown source. */ livePreview?: boolean; /** Images pasted or dropped into the text, to store in the collection as figures. */ @@ -88,7 +85,7 @@ export interface EditorProps { onSelectionAction?(action: SelectionAction, selected: string): void; } -export const Editor = memo(function Editor({ states, path, text, readOnly, joinLines = false, livePreview = true, onImageFiles, diagnostics, completion, inserts, insight, onChange, onReady, onCursor, onFollow, chapters, onFindSource, anchors, activeComment = null, onAnchor, anchorCard, onSelectionAction }: EditorProps) { +export const Editor = memo(function Editor({ states, path, text, readOnly, livePreview = true, onImageFiles, diagnostics, completion, inserts, insight, onChange, onReady, onCursor, onFollow, chapters, onFindSource, anchors, activeComment = null, onAnchor, anchorCard, onSelectionAction }: EditorProps) { const host = useRef(null); const view = useRef(null); // Texts this editor reported, newest last. The session echoes them back @@ -98,7 +95,6 @@ export const Editor = memo(function Editor({ states, path, text, readOnly, joinL // Only a real change to the problems is dispatched: redrawing lint marks // replaces the line's DOM, which would cancel a hover in progress. const shownDiagnostics = useRef(""); - const joining = useRef(new Compartment()); const preview = useRef | null>(null); const latest = useRef({ onChange, completion, inserts, insight, onCursor, onFollow, chapters, onFindSource, onAnchor, anchorCard, onSelectionAction, onImageFiles }); latest.current = { onChange, completion, inserts, insight, onCursor, onFollow, chapters, onFindSource, onAnchor, anchorCard, onSelectionAction, onImageFiles }; @@ -157,8 +153,8 @@ export const Editor = memo(function Editor({ states, path, text, readOnly, joinL latest.current.onSelectionAction?.(action, selected); }), EditorView.lineWrapping, - joining.current.of(joinLines ? joinLinesExtension() : []), EditorState.readOnly.of(readOnly), + EditorView.editable.of(!readOnly), placeholder("Write in Markdown. Cite with [@citekey]; type / to add a footnote, figure or table."), EditorView.contentAttributes.of({ "aria-label": `Markdown for ${path}`, spellcheck: "true", autocapitalize: "sentences" }), EditorView.updateListener.of((u) => { @@ -282,10 +278,6 @@ export const Editor = memo(function Editor({ states, path, text, readOnly, joinL v.dispatch({ changes: { from, to: endA, insert: text.slice(from, endB) }, annotations: [remote.of(true), Transaction.addToHistory.of(false)] }); }, [text]); - useEffect(() => { - view.current?.dispatch({ effects: joining.current.reconfigure(joinLines ? joinLinesExtension() : []) }); - }, [joinLines]); - useEffect(() => { if (view.current) preview.current?.set(view.current, livePreview); }, [livePreview]); diff --git a/apps/writer/src/editor/join-lines.test.ts b/apps/writer/src/editor/join-lines.test.ts deleted file mode 100644 index 7709814f..00000000 --- a/apps/writer/src/editor/join-lines.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { EditorState } from "@codemirror/state"; -import { describe, expect, it } from "vitest"; - -import { joinSoftBreaks, softBreaks } from "./join-lines.js"; -import { writerLanguage } from "./language.js"; - -/** The body with each soft break shown joined drawn as "⏎". */ -function joined(doc: string): string { - const state = EditorState.create({ doc, extensions: writerLanguage() }); - let out = ""; - let at = 0; - for (const { from, to } of softBreaks(state)) { - out += `${doc.slice(at, from)}⏎`; - at = to; - } - return out + doc.slice(at); -} - -describe("joining hard-wrapped lines", () => { - it("joins a paragraph's lines, not paragraphs", () => { - expect(joined("One line\nand the next.\n\nA new paragraph\ngoes on.")).toBe("One line⏎and the next.\n\nA new paragraph⏎goes on."); - }); - - it("keeps hard breaks, headings, lists, code and chapter embeds on their own lines", () => { - expect(joined("Two spaces \nkeep a break.\nA backslash\\\nkeeps one too.")).toBe("Two spaces \nkeep a break.⏎A backslash\\\nkeeps one too."); - expect(joined("# Heading\nText\n\n- one\n- two\n\n```\na\nb\n```")).toBe("# Heading\nText\n\n- one\n- two\n\n```\na\nb\n```"); - expect(joined("Before the chapter\n![[chapters/one]]")).toBe("Before the chapter\n![[chapters/one]]"); - }); - - it("joins a list item's and a footnote's continuation lines past their indent", () => { - expect(joined("- an item\n continued")).toBe("- an item⏎continued"); - expect(joined("[^a]: A note\n continued.")).toBe("[^a]: A note⏎continued."); - }); - - it("joins a field's soft breaks as spaces", () => { - expect(joinSoftBreaks("An abstract \n wrapped\nby hand.\n\nSecond \nline")).toBe("An abstract wrapped by hand.\n\nSecond \nline"); - }); -}); diff --git a/apps/writer/src/editor/join-lines.ts b/apps/writer/src/editor/join-lines.ts deleted file mode 100644 index bdc6bb2d..00000000 --- a/apps/writer/src/editor/join-lines.ts +++ /dev/null @@ -1,91 +0,0 @@ -// Hard-wrapped paragraphs shown as flowing text. A line break inside a -// paragraph or footnote reads as a space in Markdown, so it is drawn as one -// and the editor wraps the paragraph to its own width. The text keeps its -// breaks; only how they are shown changes. -import { syntaxTree } from "@codemirror/language"; -import { RangeSetBuilder, StateField, type EditorState, type Extension } from "@codemirror/state"; -import { Decoration, EditorView, type DecorationSet } from "@codemirror/view"; - -import { EMBED_LINE } from "../workspace/chapters.js"; - -// The break (and the next line's indent) is hidden, and the space is drawn -// after the line's last character by CSS (.cm-soft-break::after). A widget -// holding the space would be an atomic box the browser can wrap before, -// starting the next visual line with a space. -const hidden = Decoration.replace({}); -const spaceAfter = Decoration.mark({ class: "cm-soft-break" }); - -/** A line ending in two spaces or a backslash is a hard break, kept as a break. */ -const HARD_BREAK = /(?: {2,}|\\)$/; - -/** - * A field's text (the abstract) with its soft line breaks as spaces, as - * joinLines() shows a body. Markdown reads them as spaces, so saving this form - * after an edit changes nothing in the typeset text. - */ -export function joinSoftBreaks(text: string): string { - const lines = text.split("\n"); - let out = lines[0] ?? ""; - for (let i = 1; i < lines.length; i++) { - const before = lines[i - 1] ?? ""; - const line = lines[i] ?? ""; - const soft = before.trim() && line.trim() && !HARD_BREAK.test(before); - out = soft ? `${out.trimEnd()} ${line.trimStart()}` : `${out}\n${line}`; - } - return out; -} - -/** Where each soft line break in a paragraph or footnote runs to (the next line's text, past its indent). */ -export function softBreaks(state: EditorState): { from: number; to: number }[] { - const doc = state.doc; - const out: { from: number; to: number }[] = []; - syntaxTree(state).iterate({ - enter(node) { - if (node.name !== "Paragraph" && node.name !== "FootnoteDefinition") return; - const last = doc.lineAt(node.to).number; - for (let n = doc.lineAt(node.from).number; n < last; n++) { - const line = doc.line(n); - const next = doc.line(n + 1); - if (!line.text.trim() || !next.text.trim() || HARD_BREAK.test(line.text)) continue; - // A chapter embed is shown as a card on its own line. - if (EMBED_LINE.test(line.text) || EMBED_LINE.test(next.text)) continue; - out.push({ from: line.to, to: next.from + (/^[ \t>]*/.exec(next.text)?.[0].length ?? 0) }); - } - return false; - }, - }); - return out; -} - -interface Joins { - readonly spaces: DecorationSet; - readonly breaks: DecorationSet; -} - -function build(state: EditorState): Joins { - const spaces = new RangeSetBuilder(); - const breaks = new RangeSetBuilder(); - for (const { from, to } of softBreaks(state)) { - spaces.add(from - 1, from, spaceAfter); - breaks.add(from, to, hidden); - } - return { spaces: spaces.finish(), breaks: breaks.finish() }; -} - -/** Draws soft line breaks as spaces (see the file comment). */ -export function joinLines(): Extension { - const field = StateField.define({ - create: build, - update(value, tr) { - // The tree grows as the parser catches up, as well as on edits. - return tr.docChanged || syntaxTree(tr.state) !== syntaxTree(tr.startState) ? build(tr.state) : value; - }, - // Replacing across line breaks has to come from state, not a view plugin. - provide: (f) => [ - EditorView.decorations.from(f, (j) => j.spaces), - EditorView.decorations.from(f, (j) => j.breaks), - EditorView.atomicRanges.of((view) => view.state.field(f).breaks), - ], - }); - return field; -} diff --git a/apps/writer/src/editor/language.test.ts b/apps/writer/src/editor/language.test.ts new file mode 100644 index 00000000..1a4c5b2f --- /dev/null +++ b/apps/writer/src/editor/language.test.ts @@ -0,0 +1,49 @@ +import { insertNewlineAndIndent, history, undo, redo } from "@codemirror/commands"; +import { syntaxTree } from "@codemirror/language"; +import { EditorState, type StateCommand, type Transaction } from "@codemirror/state"; +import { keymap } from "@codemirror/view"; +import { describe, expect, it } from "vitest"; +import { writerLanguage } from "./language.js"; + +function editing(doc: string) { + let state = EditorState.create({ doc, selection: { anchor: doc.length }, extensions: [writerLanguage(), history()] }); + const target = { get state() { return state; }, dispatch(tr: Transaction) { state = tr.state; } }; + return { + get state() { return state; }, + text() { return state.doc.toString(); }, + enter() { + const bindings = state.facet(keymap).flat().filter(b => b.key === "Enter" && b.run); + if (!bindings.some(b => (b.run as StateCommand)(target))) insertNewlineAndIndent(target); + }, + type(text: string) { for (const ch of text) target.dispatch(state.update(state.replaceSelection(ch), { userEvent: "input.type" })); }, + undo() { return undo(target); }, redo() { return redo(target); }, + }; +} + +describe("Markdown list editing", () => { + for (const start of ["- one", "- one\n- two", "1. one\n2. two"]) { + it(`exits ${JSON.stringify(start)} into a real paragraph`, () => { + const e = editing(start); e.enter(); e.enter(); e.type("after"); + expect(e.text()).toBe(`${start}\n\nafter`); + expect(syntaxTree(e.state).topNode.lastChild?.name).toBe("Paragraph"); + }); + } + for (const [start, text, result] of [["- one", "- two", "- one\n- two"], ["- one", "* two", "- one\n* two"], ["1. one", "2. two", "1. one\n2. two"], ["- parent\n - child", "- next", "- parent\n - child\n - next"]]) { + it(`does not double a manually typed marker after ${JSON.stringify(start)}`, () => { + const e = editing(start!); e.enter(); e.type(text!); expect(e.text()).toBe(result); + }); + } + it("keeps automatic continuation when only item text is typed", () => { + const e = editing("- one"); e.enter(); e.type("two"); expect(e.text()).toBe("- one\n- two"); + }); + it("does not mistake a numeric item body for an explicit numbered marker", () => { + const e = editing("1. one"); e.enter(); e.type("2026 is a year"); expect(e.text()).toBe("1. one\n2. 2026 is a year"); + }); + it("preserves explicitly authored nested Markdown", () => { + const e = editing("- "); e.type("- nested"); expect(e.text()).toBe("- - nested"); + }); + it("keeps ordinary Enter as a literal newline and round-trips undo/redo", () => { + const e = editing("plain"); e.enter(); e.type("next"); const body=e.text(); expect(body).toBe("plain\nnext"); + expect(e.undo()).toBe(true); expect(e.redo()).toBe(true); expect(e.text()).toBe(body); + }); +}); diff --git a/apps/writer/src/editor/language.ts b/apps/writer/src/editor/language.ts index b04a39d9..9392577d 100644 --- a/apps/writer/src/editor/language.ts +++ b/apps/writer/src/editor/language.ts @@ -1,7 +1,9 @@ // CodeMirror language for the writer's Markdown dialect: the same lezer // extensions the translator parses with, plus highlighting for them. -import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; +import { deleteMarkupBackward, insertNewlineContinueMarkupCommand, markdown, markdownLanguage } from "@codemirror/lang-markdown"; import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import { Annotation, EditorState, Prec, StateField, type StateCommand } from "@codemirror/state"; +import { keymap } from "@codemirror/view"; import { styleTags, tags as t, Tag } from "@lezer/highlight"; import type { MarkdownConfig } from "@lezer/markdown"; import { writerMarkdown } from "@mdbase-writer/core/markdown"; @@ -47,6 +49,66 @@ const style = HighlightStyle.define([ { tag: writerTags.embed, color: "var(--connected)", fontFamily: "var(--mono)", fontSize: "0.9em" }, ]); +// Provenance for a fresh machine-inserted list marker. It lasts only while the +// next input could be an explicit marker; manually authored nested lists are +// never normalized. No body text or provenance is stored outside this editor. +interface AutoMarker { readonly from: number; readonly to: number; readonly text: string } +const generatedMarkup = Annotation.define(); +const autoMarker = StateField.define({ + create: () => null, + update(value, tr) { + if (tr.annotation(generatedMarkup)) { + const { main, ranges } = tr.state.selection; + const line = tr.state.doc.lineAt(main.head); + const match = /^([ \t]*)(?:[-*+]|\d+[.)])[ \t]+$/.exec(line.text); + return ranges.length === 1 && main.empty && main.head === line.to && match + ? { from: line.from + match[1]!.length, to: line.to, text: line.text.slice(match[1]!.length) } + : null; + } + if (!value || (tr.selection && !tr.docChanged)) return null; + if (!tr.docChanged) return value; + if (!tr.isUserEvent("input")) return null; + const from = tr.changes.mapPos(value.from, 1), to = tr.changes.mapPos(value.to, -1); + if (tr.newDoc.sliceString(from, to) !== value.text) return null; + const tail = tr.newDoc.sliceString(to, tr.newDoc.lineAt(to).to); + return /^(?:[-*+]?|\d+[.)]?)$/.test(tail) ? { ...value, from, to } : null; + }, +}); +const normalizeExplicitMarker = EditorState.transactionFilter.of(tr => { + const marker = tr.startState.field(autoMarker); + if (!marker || !tr.docChanged || !tr.isUserEvent("input")) return tr; + if (tr.startState.doc.sliceString(marker.from, marker.to) !== marker.text) throw new Error("Auto-list marker provenance disagrees with the document"); + const from = tr.changes.mapPos(marker.from, 1), to = tr.changes.mapPos(marker.to, -1); + if (tr.newDoc.sliceString(from, to) !== marker.text) return tr; // The user edited the generated marker itself. + const tail = tr.newDoc.sliceString(to, tr.newDoc.lineAt(to).to); + if (!/^(?:[-*+]|\d+[.)])[ \t]/.test(tail)) return tr; + // Remove only the generated marker. Keep every character explicitly typed, + // including the list's indentation and the user's chosen marker style. + return [tr, { changes: { from, to }, sequential: true }]; +}); +const continueMarkup = insertNewlineContinueMarkupCommand({ nonTightLists: false }); +const continueList: StateCommand = ({ state, dispatch }) => { + if (state.readOnly) return false; + const { main, ranges } = state.selection; + const line = state.doc.lineAt(main.head); + const emptyTopItem = ranges.length === 1 && main.empty && main.head === line.to && /^(?:[-*+]|\d+[.)])[ \t]*$/.test(line.text); + return continueMarkup({ state, dispatch(tr) { + if (!emptyTopItem) { + dispatch(state.update(tr, { annotations: generatedMarkup.of(true) })); + return; + } + const at = tr.newSelection.main.head; + if (tr.newDoc.lineAt(at).text !== "") throw new Error("Empty top-level list item did not exit its markup"); + // Removing '- ' alone leaves a lazy continuation of the previous item. + // A blank separator is necessary for the following text to be a paragraph. + dispatch(state.update(tr, { changes: { from: at, insert: "\n" }, selection: { anchor: at + 1 }, annotations: generatedMarkup.of(true), sequential: true })); + } }); +}; + export function writerLanguage() { - return [markdown({ base: markdownLanguage, extensions: [writerMarkdown, highlightNodes], addKeymap: true }), syntaxHighlighting(style)]; + return [ + markdown({ base: markdownLanguage, extensions: [writerMarkdown, highlightNodes], addKeymap: false }), + syntaxHighlighting(style), autoMarker, normalizeExplicitMarker, + Prec.high(keymap.of([{ key: "Enter", run: continueList }, { key: "Backspace", run: deleteMarkupBackward }])), + ]; } diff --git a/apps/writer/src/export/warnings.test.ts b/apps/writer/src/export/warnings.test.ts index 87b0628a..4e00e470 100644 --- a/apps/writer/src/export/warnings.test.ts +++ b/apps/writer/src/export/warnings.test.ts @@ -1,5 +1,16 @@ import { expect, it } from "vitest"; import { pandocWarnings } from "./warnings.js"; + +it("explains Word's missing-image fallback without hiding other warnings", () => { + const warning = "Could not fetch resource figures/example.png: replacing image with description"; + const guidance = "The image “figures/example.png” could not be loaded for Word. Its description will be included instead."; + expect(pandocWarnings([ + warning, + { verbosity: "WARNING", pretty: warning }, + { verbosity: "WARNING", pretty: "Unknown citation key" }, + { verbosity: "WARNING" }, + ])).toEqual([guidance, guidance, "Unknown citation key", "Word export reported a problem."]); +}); it("does not interrupt a clean export with informational resource messages", () => { expect(pandocWarnings([{ type: "LoadedResource", verbosity: "INFO", pretty: "Loaded style.csl" }, { verbosity: "DEBUG" }, { verbosity: "WARNING", pretty: "Image missing" }, "Citation missing"])).toEqual(["Image missing", "Citation missing"]); }); diff --git a/apps/writer/src/export/warnings.ts b/apps/writer/src/export/warnings.ts index 91f86cc7..a3576a96 100644 --- a/apps/writer/src/export/warnings.ts +++ b/apps/writer/src/export/warnings.ts @@ -1,10 +1,17 @@ // Pandoc returns informational resource-load messages alongside real warnings. export function pandocWarnings(messages: readonly unknown[]): string[] { return messages.flatMap((message) => { - if (typeof message === "string") return [message]; + if (typeof message === "string") return [exportWarningMessage(message)]; if (!message || typeof message !== "object") return []; const value = message as { verbosity?: string; pretty?: string; message?: string; type?: string }; if (value.verbosity === "INFO" || value.verbosity === "DEBUG") return []; - return [value.pretty ?? value.message ?? value.type ?? "Pandoc reported an export problem."]; + return [exportWarningMessage(value.pretty ?? value.message ?? value.type ?? "Word export reported a problem.")]; }); } + +function exportWarningMessage(message: string): string { + const missingImage = /^Could not fetch resource (.+): replacing image with description\.?$/.exec(message); + return missingImage + ? `The image “${missingImage[1]}” could not be loaded for Word. Its description will be included instead.` + : message; +} diff --git a/apps/writer/src/main.tsx b/apps/writer/src/main.tsx index dad45358..2d3f7001 100644 --- a/apps/writer/src/main.tsx +++ b/apps/writer/src/main.tsx @@ -14,6 +14,7 @@ import { createRoot } from "react-dom/client"; import { App } from "./App.js"; import { FeedbackRoot } from "./FeedbackRoot.js"; +import { deliverWriterGrantReturn, isWriterGrantReturn, writerMdbaseForBuild } from "./connect/sdk-application.js"; import { setupPwaInstall } from "./pwa-install.js"; import "./pwa-install.css"; @@ -21,10 +22,17 @@ import "./pwa-install.css"; const stopPwaInstall = setupPwaInstall("mdbase writer"); if (import.meta.hot) import.meta.hot.dispose(stopPwaInstall); +// The Private collection consent popup returns here: hand its result to the +// waiting Writer tab and close, without starting a second SDK owner. +const grantReturn = isWriterGrantReturn(import.meta.env.VITE_MDBASE_ENV, location.href); +if (grantReturn) void deliverWriterGrantReturn(location.href).catch(() => {}); +// One declaration-only singleton outside React/StrictMode. SDK owns its runtime. +const sdk = grantReturn ? undefined : writerMdbaseForBuild(import.meta.env.VITE_MDBASE_ENV); const root = document.getElementById("root"); if (!root) throw new Error("Missing #root"); createRoot(root).render( - + {grantReturn ?

You can close this window.

+ : }
, ); diff --git a/apps/writer/src/native-popup-lifetime.test.ts b/apps/writer/src/native-popup-lifetime.test.ts new file mode 100644 index 00000000..7c16c720 --- /dev/null +++ b/apps/writer/src/native-popup-lifetime.test.ts @@ -0,0 +1,6 @@ +import {readFileSync} from "node:fs"; +import {expect,it} from "vitest"; +it("Native callback leaves SAME popup lifetime to original SDK finish and custody ACK",()=>{ + const source=readFileSync(new URL("./main.tsx",import.meta.url),"utf8"); + expect(source).not.toMatch(/finally\(\(\)\s*=>\s*window\.close\(\)\)/); +}); diff --git a/apps/writer/src/pwa-install.css b/apps/writer/src/pwa-install.css index 1aa5142e..d4257e4a 100644 --- a/apps/writer/src/pwa-install.css +++ b/apps/writer/src/pwa-install.css @@ -14,6 +14,12 @@ box-shadow: 0 4px 24px #0002; font: 14px/1.5 system-ui, sans-serif; } +/* The optional invitation must not cover the phone's primary navigation. */ +@media (max-width: 900px) { + body:has(.workspace .pane-tabs) .pwa-install { + bottom: calc(var(--pane-tab-height, 54px) + 1px + env(safe-area-inset-bottom) + 16px); + } +} .pwa-install p { margin: 0 0 10px; font: inherit; color: inherit; } .pwa-install button { display: inline-block; diff --git a/apps/writer/src/styles.css b/apps/writer/src/styles.css index 895cb2d9..0662713f 100644 --- a/apps/writer/src/styles.css +++ b/apps/writer/src/styles.css @@ -21,6 +21,7 @@ --ease: cubic-bezier(0.16, 1, 0.3, 1); --scrollbar-thumb-current: var(--scrollbar-thumb-rest); --scrollbar-size: 2px; + --pane-tab-height: 54px; font-family: var(--sans); font-size: 14px; line-height: 1.5; @@ -77,6 +78,7 @@ label { display: grid; gap: 0.2rem; font-size: 13px; color: var(--ink-soft); } label > .mdbase-select { width: 100%; } .app { display: grid; grid-template-rows: auto 1fr; height: 100%; } +.grant-return { margin: 2rem auto; max-width: 32rem; padding: 0 1rem; text-align: center; color: var(--color-text-soft, inherit); } .topbar { display: flex; align-items: center; gap: 0.75rem; height: 48px; padding: 0 0.75rem 0 1rem; border-bottom: 1px solid var(--line); min-width: 0; } .topbar-slot { flex: 1; min-width: 0; display: flex; align-items: center; } /* The theme choice sits quietly in the bar but keeps its chevron, so it reads as a control. */ @@ -177,6 +179,11 @@ kbd { font-family: var(--mono); font-size: 11.5px; padding: 1px 5px; border: 1px .home-drafts li { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } .home-draft-text { display: grid; gap: 0.05rem; min-width: 0; } .home-drafts p { margin: 0; } +/* Untyped records and unusable types: calm, informational, never alerts. */ +.home-untyped { display: grid; gap: 0.5rem; margin-top: 1.25rem; } +.home-untyped h2 { margin: 0; } +.home-unavailable { display: grid; gap: 0.25rem; margin-top: 1rem; } +.home-unavailable p { margin: 0; } .home { width: 100%; max-width: 50rem; margin: 0 auto; padding: 2rem 16px; display: grid; gap: 2rem; align-content: start; overflow: auto; } .home-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } .home-header p { margin: 0; } @@ -554,9 +561,12 @@ blockquote.thread-quote::after { content: "”"; } .editor { min-height: 0; overflow: hidden; } .editor .cm-editor { height: 100%; font-size: 15px; background: var(--canvas); color: var(--ink); } +/* Search options are inline choices, not the full-width fields used in forms. */ +.editor .cm-search label { display: inline-flex; align-items: center; gap: 0.35rem; min-height: 28px; vertical-align: middle; white-space: nowrap; } +.editor .cm-search input[type="checkbox"] { width: auto; flex: none; margin: 0; } +@media (max-width: 900px) { .editor .cm-search label { min-height: 44px; } } .editor .cm-scroller { font-family: "Atkinson Hyperlegible", sans-serif; line-height: 1.65; } .editor .cm-content { max-width: 72ch; margin: 0 auto; padding: 1.5rem 1.25rem 40vh; caret-color: var(--ink); } -.editor .cm-soft-break::after { content: " "; } .editor .cm-activeLine { background: transparent; } .editor .cm-focused { outline: none; } .editor .cm-gutters { background: var(--canvas); border: 0; } @@ -688,7 +698,7 @@ fieldset.settings { margin: 0; padding: 0; border: 0; min-width: 0; } .bar .mdbase-button { padding: 0 0.55rem; min-height: 32px; } .workspace { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; grid-template-areas: "main" "tabs"; } .pane-tabs { grid-area: tabs; display: flex; border-top: 1px solid var(--line); background: var(--canvas); padding-bottom: env(safe-area-inset-bottom); } - .pane-tabs button { flex: 1; display: grid; justify-items: center; gap: 2px; height: 54px; padding: 6px 0 4px; border: 0; background: none; font: inherit; font-size: 11.5px; color: var(--muted); } + .pane-tabs button { flex: 1; display: grid; justify-items: center; gap: 2px; height: var(--pane-tab-height); padding: 6px 0 4px; border: 0; background: none; font: inherit; font-size: 11.5px; color: var(--muted); } .pane-tabs button .icon { width: 20px; height: 20px; } .pane-tabs button[aria-pressed="true"] { color: var(--ink); font-weight: 700; } .pane-tabs button[aria-pressed="true"] .icon { color: var(--app-writer); } diff --git a/apps/writer/src/ui/CompareDialog.test.tsx b/apps/writer/src/ui/CompareDialog.test.tsx new file mode 100644 index 00000000..abe4eff7 --- /dev/null +++ b/apps/writer/src/ui/CompareDialog.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { changedPassage, CompareDialog, type Comparison } from "./CompareDialog.js"; + +vi.mock("@mdbase-dev/ui/dialog", () => ({ + Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => open ?
{children}
: null, +})); +afterEach(() => { cleanup(); vi.restoreAllMocks(); }); + +it("highlights a late change and scrolls both text panes to it, not the page", () => { + const prefix = Array.from({ length: 100 }, (_, index) => `Unchanged line ${index}`).join("\n"); + const mine = `${prefix}\nLocal draft addition\nUnchanged suffix`; + const theirs = `${prefix}\nSaved collection line\nUnchanged suffix`; + expect(changedPassage(mine, theirs)).toEqual({ prefix, mine: "Local draft addition", theirs: "Saved collection line", suffix: "Unchanged suffix" }); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function(this: HTMLElement) { + return { top: this.tagName === "MARK" ? 1000 : 100 } as DOMRect; + }); + const rendered = render(); + expect([...rendered.container.querySelectorAll("mark")].map(mark => mark.textContent)).toEqual(["Local draft addition", "Saved collection line"]); + expect([...rendered.container.querySelectorAll(".compare-text")].map(text => text.scrollTop)).toEqual([868, 868]); + expect(window.scrollY).toBe(0); + expect(screen.getByRole("button", { name: "Restore local draft" })).toBeTruthy(); +}); + +it("keeps an edited merge across unrelated renders and resets it for a new local version", () => { + const comparison: Comparison = { path: "main.md", kind: "conflict", mine: "Local text", theirs: "Collection text", mineFields: {}, theirFields: {} }; + const callbacks = { onClose: vi.fn(), onResolve: vi.fn(), onDownload: vi.fn() }; + const rendered = render(); + const field = () => screen.getByRole("textbox", { name: /Merged Markdown/ }); + expect(field().value).toBe("Local text"); + fireEvent.change(field(), { target: { value: "Reviewed merged text" } }); + rendered.rerender(); + expect(field().value).toBe("Reviewed merged text"); + rendered.rerender(); + expect(field().value).toBe("New local version"); +}); diff --git a/apps/writer/src/ui/CompareDialog.tsx b/apps/writer/src/ui/CompareDialog.tsx index 1c35d3a4..83a3319f 100644 --- a/apps/writer/src/ui/CompareDialog.tsx +++ b/apps/writer/src/ui/CompareDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Dialog } from "@mdbase-dev/ui/dialog"; import type { JsonObject } from "@mdbase-dev/connect"; @@ -16,6 +16,14 @@ export function changedPassage(mine: string, theirs: string): { prefix: string; }; } +/** Reveal the first changed passage without scrolling the dialog or page. */ +export function revealDifferences(columns: HTMLElement): void { + for (const text of columns.querySelectorAll(".compare-text")) { + const mark = text.querySelector("mark"); + text.scrollTop = mark ? Math.max(0, text.scrollTop + mark.getBoundingClientRect().top - text.getBoundingClientRect().top - 32) : 0; + } +} + export interface Comparison { readonly path: string; readonly kind: "conflict" | "draft"; @@ -32,8 +40,12 @@ export function CompareDialog({ comparison, canRestore = true, onClose, onResolv onResolve(choice: "mine" | "theirs" | { body: string }): void; onDownload(): void; }) { - const [merged, setMerged] = useState(""); - useEffect(() => setMerged(comparison?.mine ?? ""), [comparison?.path, comparison?.mine]); + const [merge, setMerge] = useState({ path: comparison?.path, mine: comparison?.mine, text: comparison?.mine ?? "" }); + const changed = merge.path !== comparison?.path || merge.mine !== comparison?.mine; + if (changed) setMerge({ path: comparison?.path, mine: comparison?.mine, text: comparison?.mine ?? "" }); + const merged = changed ? comparison?.mine ?? "" : merge.text; + const columns = useRef(null); + useEffect(() => { if (columns.current) revealDifferences(columns.current); }, [comparison?.path, comparison?.mine, comparison?.theirs]); const diff = comparison ? changedPassage(comparison.mine, comparison.theirs) : null; const metadataChanged = comparison && JSON.stringify(comparison.mineFields) !== JSON.stringify(comparison.theirFields); return ( @@ -41,7 +53,7 @@ export function CompareDialog({ comparison, canRestore = true, onClose, onResolv {comparison && diff && <>

{comparison.path}

Nothing is replaced until you choose. Highlighted passages differ between the versions.

-
+
{([['Your local text', diff.mine], ['Collection version', diff.theirs]] as const).map(([label, change]) =>

{label}

{diff.prefix && `${diff.prefix}\n`}{comparison.mine !== comparison.theirs && {change || "(passage removed)"}}{diff.suffix && `\n${diff.suffix}`}
@@ -51,7 +63,7 @@ export function CompareDialog({ comparison, canRestore = true, onClose, onResolv
{JSON.stringify(comparison.mineFields, null, 2)}
{JSON.stringify(comparison.theirFields, null, 2)}
} {comparison.kind === "conflict" &&