From 9f4b3f5c40b55bf1f5d4e69d07d04f990ed86cb5 Mon Sep 17 00:00:00 2001 From: david <58231312+davseby@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:29:48 +0300 Subject: [PATCH] replace markdown assistant with xml --- e2e/tests/search.test.ts | 7 +- server/auth-realtime/AGENTS.md | 3 +- server/auth-realtime/src/operations.test.ts | 1268 +++++---------- server/auth-realtime/src/operations.ts | 599 ++------ server/auth-realtime/src/routes.test.ts | 7 +- server/auth-realtime/src/routes.ts | 17 +- server/auth-realtime/src/schema/code-block.ts | 4 +- server/auth-realtime/src/schema/index.test.ts | 109 +- server/auth-realtime/src/schema/index.ts | 4 + server/auth-realtime/src/schema/metric.ts | 2 +- .../auth-realtime/src/schema/node-comment.ts | 20 + .../src/schema/parameter-list.ts | 12 +- .../src/schema/split-documentation.ts | 4 +- server/auth-realtime/src/validate.test.ts | 217 +++ server/auth-realtime/src/validate.ts | 113 ++ server/auth-realtime/src/ydocument.ts | 39 +- server/core/internal/apps/registry/client.go | 63 +- .../internal/apps/registry/client_test.go | 21 +- server/core/internal/assistant/AGENTS.md | 65 +- server/core/internal/assistant/block/block.go | 303 ---- .../internal/assistant/block/block_test.go | 130 -- .../core/internal/assistant/block/compact.go | 533 ------- .../internal/assistant/block/compact_test.go | 585 ------- .../core/internal/assistant/block/expand.go | 520 ------- .../internal/assistant/block/expand_test.go | 784 ---------- .../core/internal/assistant/block/markdown.go | 738 --------- .../internal/assistant/block/markdown_test.go | 574 ------- .../core/internal/assistant/block/metric.go | 356 ----- .../internal/assistant/block/metric_test.go | 465 ------ server/core/internal/assistant/block/types.go | 120 -- .../internal/assistant/block/types_test.go | 111 -- .../core/internal/assistant/block/validate.go | 851 ----------- .../internal/assistant/block/validate_test.go | 1003 ------------ server/core/internal/assistant/edit/client.go | 45 +- .../internal/assistant/edit/client_test.go | 76 +- server/core/internal/assistant/edit/error.go | 42 +- .../internal/assistant/edit/error_test.go | 54 +- .../core/internal/assistant/edit/operation.go | 290 ++-- .../internal/assistant/edit/operation_test.go | 125 +- .../core/internal/assistant/manager_test.go | 6 +- .../core/internal/assistant/markup/build.go | 540 +++++++ .../internal/assistant/markup/build_test.go | 398 +++++ .../internal/assistant/markup/describe.go | 100 ++ .../assistant/markup/describe_test.go | 53 + .../core/internal/assistant/markup/inline.go | 82 + .../internal/assistant/markup/inline_test.go | 77 + .../core/internal/assistant/markup/markup.go | 113 ++ .../core/internal/assistant/markup/metric.go | 215 +++ .../internal/assistant/markup/metric_test.go | 223 +++ .../core/internal/assistant/markup/parse.go | 366 +++++ .../internal/assistant/markup/parse_test.go | 174 +++ .../core/internal/assistant/markup/render.go | 297 ++++ .../internal/assistant/markup/render_test.go | 135 ++ .../assistant/markup/testdata/all_blocks.json | 632 ++++++++ .../assistant/markup/testdata/all_blocks.xml | 50 + .../markup/testdata/empty_parts.json | 131 ++ .../assistant/markup/testdata/empty_parts.xml | 14 + .../assistant/markup/testdata/scratch.json | 480 ++++++ .../assistant/markup/testdata/scratch.xml | 34 + .../assistant/markup/testdata/welcome.json | 682 +++++++++ .../assistant/markup/testdata/welcome.xml | 40 + server/core/internal/assistant/markup/text.go | 217 +++ .../internal/assistant/markup/text_test.go | 138 ++ .../core/internal/assistant/metrics_test.go | 22 +- .../assistant/middleware/compact_test.go | 8 +- .../assistant/middleware/observer_test.go | 12 +- server/core/internal/assistant/prompt.go | 131 +- server/core/internal/assistant/prompt_test.go | 57 +- .../internal/assistant/protocol/protocol.go | 4 +- .../core/internal/assistant/session_test.go | 12 +- server/core/internal/assistant/tools/block.go | 906 ++--------- .../internal/assistant/tools/block_test.go | 1358 ++++------------- .../core/internal/assistant/tools/confirm.go | 23 +- .../internal/assistant/tools/confirm_test.go | 55 +- .../internal/assistant/tools/datasource.go | 946 ++++-------- .../assistant/tools/datasource_test.go | 1000 ++++-------- .../core/internal/assistant/tools/document.go | 260 +--- .../internal/assistant/tools/document_test.go | 166 +- .../core/internal/assistant/tools/docwalk.go | 271 ---- .../internal/assistant/tools/docwalk_test.go | 356 ----- server/core/internal/assistant/tools/eino.go | 86 +- .../internal/assistant/tools/eino_test.go | 86 +- server/core/internal/assistant/tools/hook.go | 411 ++--- .../internal/assistant/tools/hook_test.go | 500 +++--- server/core/internal/assistant/tools/input.go | 553 +++---- .../internal/assistant/tools/input_test.go | 901 ++++------- .../core/internal/assistant/tools/schema.go | 315 ---- .../internal/assistant/tools/schema_test.go | 122 -- .../core/internal/assistant/tools/search.go | 80 +- .../internal/assistant/tools/search_test.go | 56 +- server/core/internal/assistant/tools/tag.go | 522 ++----- .../core/internal/assistant/tools/tag_test.go | 839 ++++------ .../tools/testdata/tool_schemas.golden | 2 +- server/core/internal/assistant/tools/tool.go | 352 +---- .../internal/assistant/tools/tool_test.go | 85 -- server/core/internal/assistant/tools/tools.go | 117 +- .../internal/assistant/tools/tools_test.go | 93 +- server/core/internal/assistant/tools/util.go | 65 +- .../internal/assistant/tools/util_test.go | 37 + server/core/internal/assistant/turn_test.go | 8 +- server/core/internal/buildinfo/buildinfo.go | 11 - .../core/internal/buildinfo/buildinfo_test.go | 17 - .../datasource/simulation/simulation.go | 7 +- .../datasource/simulation/simulation_test.go | 21 +- server/core/internal/document/attribute.go | 34 +- .../core/internal/document/attribute_keys.go | 21 +- .../core/internal/document/attribute_test.go | 44 +- server/core/internal/document/document.go | 20 - .../core/internal/document/history/history.go | 2 +- server/core/internal/document/hook/hook.go | 3 +- .../internal/document/hook/manager/write.go | 5 + .../document/hook/manager/write_test.go | 1 + server/core/internal/document/node.go | 116 +- server/core/internal/document/node_test.go | 176 +-- .../internal/server/internal/mcp/bridge.go | 27 +- .../server/internal/mcp/bridge_test.go | 57 +- .../internal/server/internal/mcp/http_test.go | 2 +- .../internal/server/internal/mcp/resources.go | 6 +- .../server/internal/mcp/resources_test.go | 2 +- .../internal/server/internal/mcp/server.go | 28 +- .../server/internal/mcp/server_test.go | 52 +- server/core/pkg/strutil/nanoid.go | 2 + server/core/pkg/strutil/nanoid_test.go | 5 + server/core/pkg/strutil/strutil.go | 30 - server/core/pkg/strutil/strutil_test.go | 57 - web/app/composables/useAIChat.nuxt.test.ts | 4 +- 126 files changed, 9528 insertions(+), 17515 deletions(-) create mode 100644 server/auth-realtime/src/schema/node-comment.ts create mode 100644 server/auth-realtime/src/validate.test.ts create mode 100644 server/auth-realtime/src/validate.ts delete mode 100644 server/core/internal/assistant/block/block.go delete mode 100644 server/core/internal/assistant/block/block_test.go delete mode 100644 server/core/internal/assistant/block/compact.go delete mode 100644 server/core/internal/assistant/block/compact_test.go delete mode 100644 server/core/internal/assistant/block/expand.go delete mode 100644 server/core/internal/assistant/block/expand_test.go delete mode 100644 server/core/internal/assistant/block/markdown.go delete mode 100644 server/core/internal/assistant/block/markdown_test.go delete mode 100644 server/core/internal/assistant/block/metric.go delete mode 100644 server/core/internal/assistant/block/metric_test.go delete mode 100644 server/core/internal/assistant/block/types.go delete mode 100644 server/core/internal/assistant/block/types_test.go delete mode 100644 server/core/internal/assistant/block/validate.go delete mode 100644 server/core/internal/assistant/block/validate_test.go create mode 100644 server/core/internal/assistant/markup/build.go create mode 100644 server/core/internal/assistant/markup/build_test.go create mode 100644 server/core/internal/assistant/markup/describe.go create mode 100644 server/core/internal/assistant/markup/describe_test.go create mode 100644 server/core/internal/assistant/markup/inline.go create mode 100644 server/core/internal/assistant/markup/inline_test.go create mode 100644 server/core/internal/assistant/markup/markup.go create mode 100644 server/core/internal/assistant/markup/metric.go create mode 100644 server/core/internal/assistant/markup/metric_test.go create mode 100644 server/core/internal/assistant/markup/parse.go create mode 100644 server/core/internal/assistant/markup/parse_test.go create mode 100644 server/core/internal/assistant/markup/render.go create mode 100644 server/core/internal/assistant/markup/render_test.go create mode 100644 server/core/internal/assistant/markup/testdata/all_blocks.json create mode 100644 server/core/internal/assistant/markup/testdata/all_blocks.xml create mode 100644 server/core/internal/assistant/markup/testdata/empty_parts.json create mode 100644 server/core/internal/assistant/markup/testdata/empty_parts.xml create mode 100644 server/core/internal/assistant/markup/testdata/scratch.json create mode 100644 server/core/internal/assistant/markup/testdata/scratch.xml create mode 100644 server/core/internal/assistant/markup/testdata/welcome.json create mode 100644 server/core/internal/assistant/markup/testdata/welcome.xml create mode 100644 server/core/internal/assistant/markup/text.go create mode 100644 server/core/internal/assistant/markup/text_test.go delete mode 100644 server/core/internal/assistant/tools/docwalk.go delete mode 100644 server/core/internal/assistant/tools/docwalk_test.go delete mode 100644 server/core/internal/assistant/tools/schema.go delete mode 100644 server/core/internal/assistant/tools/schema_test.go delete mode 100644 server/core/internal/assistant/tools/tool_test.go delete mode 100644 server/core/pkg/strutil/strutil.go delete mode 100644 server/core/pkg/strutil/strutil_test.go diff --git a/e2e/tests/search.test.ts b/e2e/tests/search.test.ts index 0c980261..447cae03 100644 --- a/e2e/tests/search.test.ts +++ b/e2e/tests/search.test.ts @@ -126,16 +126,13 @@ test.describe("search", () => { expect(branchId).toBeDefined() const inserted = await client.callTool({ - name: "insert_block", + name: "insert_blocks", arguments: { document_id: id, branch_id: branchId, position: "end", // a metric lives in a grid; the root refuses a bare one - block: { - type: "metric_grid", - items: [{ type: "metric", attrs: { title: title } }], - }, + content: `${JSON.stringify({ title })}`, }, }) expect(inserted.isError, JSON.stringify(inserted.content)).toBeFalsy() diff --git a/server/auth-realtime/AGENTS.md b/server/auth-realtime/AGENTS.md index b36aa85c..599d138c 100644 --- a/server/auth-realtime/AGENTS.md +++ b/server/auth-realtime/AGENTS.md @@ -37,7 +37,8 @@ organization and admin, created before listening), `logging.ts`, `headers.ts`, `auth.ts` (`createAuth` + better-auth callbacks), `hocuspocus.ts` (`createHocuspocus`, `createDocumentHooks`, `flushDocument`), `routes.ts` (`createRoutes` → Hono app), `operations.ts` -(pure: assistant edit ops on a Y.Doc), `ydocument.ts` (pure: replacing Y.Doc +(pure: assistant edit ops on a Y.Doc), `validate.ts` (pure: checks those ops +against the editor schema), `ydocument.ts` (pure: replacing Y.Doc content), `schema/` (ProseMirror schema mirroring web's tiptap extensions), `sentry.ts` and `bundle.ts` (docker entry: sentry + index in one bundle). diff --git a/server/auth-realtime/src/operations.test.ts b/server/auth-realtime/src/operations.test.ts index cd9da898..86c7cad0 100644 --- a/server/auth-realtime/src/operations.test.ts +++ b/server/auth-realtime/src/operations.test.ts @@ -1,14 +1,11 @@ import { describe, it } from "vitest" import * as Y from "yjs" -import { replaceYdocContent, transformer } from "./ydocument.js" +import { replaceYdocContent } from "./ydocument.js" import { applyOperations, - findByUid, - pmBlockToY, type InsertOp, type MoveOp, type Operation, - type PMInline, type PMNode, } from "./operations.js" @@ -46,43 +43,6 @@ function sampleBlocks(): PMNode[] { ] } -// a bullet list carrying one item, whose paragraph holds the text. The -// three uids let a test address the list, the item, or the paragraph. -function bulletList(listUID: string, itemUID: string, text: string): PMNode { - return { - type: "bulletList", - attrs: { uid: listUID }, - content: [ - { - type: "listItem", - attrs: { uid: itemUID }, - content: [textParagraph("text", text)], - }, - ], - } -} - -// a titled code block with its title row and its code, each with a uid -// a test can address. -function titledCode(title: string, code: string): PMNode { - return { - type: "titledCodeBlock", - attrs: { uid: "titled" }, - content: [ - { - type: "codeBlockTitle", - attrs: { uid: "title" }, - content: [{ type: "text", text: title }], - }, - { - type: "codeBlock", - attrs: { uid: "code", language: "go" }, - content: [{ type: "text", text: code }], - }, - ], - } -} - // builds a live document through the same path the Hocuspocus server // uses, so every block carries the uid the uid-addressed operations // look up. @@ -134,15 +94,28 @@ function topUids(doc: Y.Doc): (string | undefined)[] { ) } -// resolves a block for assertions, throwing rather than returning null -// so callers keep a non-nullable element. +// the block carrying uid anywhere in the content, if there is one. +function findBlock(doc: Y.Doc, uid: string): Y.XmlElement | undefined { + const [found] = doc + .getXmlFragment("content") + .createTreeWalker( + (node) => + node instanceof Y.XmlElement && + node.getAttribute("uid") === uid, + ) + + return found instanceof Y.XmlElement ? found : undefined +} + +// resolves a block for assertions, throwing rather than returning +// undefined so callers keep a non-nullable element. function blockByUid(doc: Y.Doc, uid: string): Y.XmlElement { - const found = findByUid(doc.getXmlFragment("content"), uid) + const found = findBlock(doc, uid) if (!found) { throw new Error(`test fixture has no block: ${uid}`) } - return found.element + return found } // getAttributes is declared as a string map, but the runtime keeps @@ -152,17 +125,6 @@ function attrsOf(el: Y.XmlElement): Record { return el.getAttributes() } -// Y.XmlText.toDelta is declared as returning any; narrowing it in one -// place keeps every call site free of unsafe values. -function inlineDelta(el: Y.XmlElement): unknown[] { - const child = el.get(0) - if (!(child instanceof Y.XmlText)) { - throw new Error("block carries no inline text") - } - - return child.toDelta() as unknown[] -} - // neither Y.XmlElement nor Y.Text declares a string-returning toString // in its type definitions, though both serialize properly at runtime. // Narrowing it once here keeps the assertions readable. @@ -170,20 +132,8 @@ function serialize(node: Y.XmlElement | Y.Text): string { return (node as unknown as { toString(): string }).toString() } -// a detached Y.XmlElement keeps its attributes and children in prelim -// state, invisible to the public getters, so assertions read it after -// attaching it to a throwaway host — which is what the operations do -// with it anyway. -function attached(el: Y.XmlElement): Y.XmlElement { - new Y.Doc().getXmlFragment("content").insert(0, [el]) - - return el -} - describe("applyOperations", () => { - it("keeps applying later operations after one of them fails", ({ - expect, - }) => { + it("applies nothing when one operation fails", ({ expect }) => { const doc = docWith(sampleBlocks()) const result = applyOperations(doc, [ @@ -198,16 +148,60 @@ describe("applyOperations", () => { }, ]) - expect(result.applied).toBe(2) - expect(result.errors).toEqual([ - { index: 1, message: "block_uid not found: ghost" }, + expect(result).toEqual({ + errors: [ + { + index: 1, + message: "block_uid not found: ghost", + }, + ], + }) + expect(topUids(doc)).toEqual(["intro", "callout", "head"]) + }) + + it("reports only the first failure of a batch", ({ expect }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { kind: "delete", block_uid: "ghost" }, + { kind: "delete", block_uid: "phantom" }, ]) + + expect(result).toEqual({ + errors: [ + { + index: 0, + message: "block_uid not found: ghost", + }, + ], + }) + }) + + it("applies a batch whose operations build on each other", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { + kind: "prepend", + block: textParagraph("first", "First"), + }, + { + kind: "insert", + position: "after", + reference_uid: "first", + block: textParagraph("second", "Second"), + }, + ]) + + expect(result).toEqual({ errors: [] }) expect(topUids(doc)).toEqual([ "first", + "second", "intro", "callout", "head", - "last", ]) }) @@ -236,7 +230,6 @@ describe("applyOperations", () => { const doc = docWith(sampleBlocks()) expect(applyOperations(doc, [])).toEqual({ - applied: 0, errors: [], }) expect(topUids(doc)).toEqual(["intro", "callout", "head"]) @@ -254,7 +247,6 @@ describe("applyOperations", () => { { kind: "transmogrify" } as unknown as Operation, ]) - expect(result.applied).toBe(0) expect(result.errors).toEqual([ { index: 0, @@ -265,6 +257,36 @@ describe("applyOperations", () => { }) describe("insert", () => { + it("refuses a block the parent does not take, leaving the document as it was", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { + kind: "insert", + position: "after", + reference_uid: "intro", + block: { + type: "metricBlock", + attrs: { uid: "metric" }, + }, + }, + ]) + + expect(result.errors[0]?.message).toContain( + "metricBlock is not allowed there in the document root", + ) + expect(result.errors[0]?.message).toContain( + "Choose a reference block in a container that takes it.", + ) + expect(topUids(doc)).toEqual([ + "intro", + "callout", + "head", + ]) + }) + const positionCases: { name: string input: InsertOp["position"] @@ -299,7 +321,7 @@ describe("applyOperations", () => { }, ]) - expect(result.applied).toBe(1) + expect(result.errors).toEqual([]) expect(topUids(doc)).toEqual(expected) }, ) @@ -357,7 +379,6 @@ describe("applyOperations", () => { } as unknown as Operation, ]) - expect(result.applied).toBe(0) expect(result.errors[0]?.message).toBe( 'insert position must be "before" or "after", got: beforeX', ) @@ -383,7 +404,6 @@ describe("applyOperations", () => { ]) expect(result).toEqual({ - applied: 0, errors: [ { index: 0, @@ -400,6 +420,31 @@ describe("applyOperations", () => { }) describe("append", () => { + it("refuses a block the document root does not take", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { + kind: "append", + block: { + type: "metricBlock", + attrs: { uid: "metric" }, + }, + }, + ]) + + expect(result.errors[0]?.message).toContain( + "Insert it before or after a block in a container that takes it.", + ) + expect(topUids(doc)).toEqual([ + "intro", + "callout", + "head", + ]) + }) + it("adds the block after a last block that is not a paragraph", ({ expect, }) => { @@ -555,15 +600,94 @@ describe("applyOperations", () => { }, ]) - expect(result.applied).toBe(1) + expect(result.errors).toEqual([]) expect(topUids(doc)).toEqual(["added"]) expect(serialize(blockByUid(doc, "added"))).toBe( 'Added', ) }) + it("keeps attribute values that are not strings", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + applyOperations(doc, [ + { + kind: "append", + block: { + type: "metricGrid", + attrs: { uid: "grid" }, + content: [ + { + type: "metricBlock", + attrs: { + uid: "metric", + queries: [ + { + query: "up", + }, + ], + }, + }, + ], + }, + }, + ]) + + // the simulation flag is the one metric attribute whose + // schema default is not null, so it is written out too. + expect(attrsOf(blockByUid(doc, "metric"))).toEqual({ + uid: "metric", + queries: [{ query: "up" }], + simulationActive: false, + }) + }) + + it("reports an error for a bare text node", ({ expect }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { + kind: "append", + block: { type: "text", text: "loose" }, + }, + ]) + + expect(result.errors).toEqual([ + { index: 0, message: "text is not a block" }, + ]) + expect(topUids(doc)).toEqual([ + "intro", + "callout", + "head", + ]) + }) }) describe("prepend", () => { + it("refuses a block the document root does not take", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { + kind: "prepend", + block: { + type: "metricBlock", + attrs: { uid: "metric" }, + }, + }, + ]) + + expect(result.errors).toHaveLength(1) + expect(topUids(doc)).toEqual([ + "intro", + "callout", + "head", + ]) + }) + it("adds the block at index 0", ({ expect }) => { const doc = docWith(sampleBlocks()) @@ -574,7 +698,7 @@ describe("applyOperations", () => { }, ]) - expect(result.applied).toBe(1) + expect(result.errors).toEqual([]) expect(topUids(doc)).toEqual([ "added", "intro", @@ -585,6 +709,42 @@ describe("applyOperations", () => { }) describe("replace", () => { + it("refuses a block its container does not take, leaving the old one", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { + kind: "replace", + block_uid: "nested", + block: { + type: "heading", + attrs: { + uid: "nested", + level: 2, + }, + content: [ + { + type: "text", + text: "Heading", + }, + ], + }, + }, + ]) + + expect(result.errors[0]?.message).toContain( + "heading is not allowed there in calloutBlock", + ) + expect(result.errors[0]?.message).not.toContain( + "Choose", + ) + expect(blockByUid(doc, "nested").nodeName).toBe( + "paragraph", + ) + }) + it("swaps the block in place, keeping its index", ({ expect, }) => { @@ -598,14 +758,9 @@ describe("applyOperations", () => { }, ]) - expect(result.applied).toBe(1) + expect(result.errors).toEqual([]) expect(topUids(doc)).toEqual(["intro", "fresh", "head"]) - expect( - findByUid( - doc.getXmlFragment("content"), - "nested", - ), - ).toBeNull() + expect(findBlock(doc, "nested")).toBeUndefined() }) it("swaps a nested block inside that block's own parent", ({ @@ -621,12 +776,9 @@ describe("applyOperations", () => { }, ]) - const found = findByUid( - doc.getXmlFragment("content"), - "fresh", + expect(blockByUid(doc, "callout").get(0)).toBe( + blockByUid(doc, "fresh"), ) - expect(found?.index).toBe(0) - expect(found?.parent).toBe(blockByUid(doc, "callout")) expect(topUids(doc)).toEqual([ "intro", "callout", @@ -648,7 +800,6 @@ describe("applyOperations", () => { ]) expect(result).toEqual({ - applied: 0, errors: [ { index: 0, @@ -664,551 +815,6 @@ describe("applyOperations", () => { }) }) - describe("update_text", () => { - it("swaps the inline content while keeping the block's type, attrs and uid", ({ - expect, - }) => { - const doc = docWith(sampleBlocks()) - - const result = applyOperations(doc, [ - { - kind: "update_text", - block_uid: "head", - content: [ - { - type: "text", - text: "Rewritten", - }, - ], - }, - ]) - - const head = blockByUid(doc, "head") - expect(result.applied).toBe(1) - expect(head.nodeName).toBe("heading") - expect(attrsOf(head)).toEqual({ uid: "head", level: 2 }) - expect(head.length).toBe(1) - expect(inlineDelta(head)).toEqual([ - { insert: "Rewritten" }, - ]) - }) - - it("turns marks into format attributes on the inline text", ({ - expect, - }) => { - const doc = docWith(sampleBlocks()) - - applyOperations(doc, [ - { - kind: "update_text", - block_uid: "intro", - content: [ - { - type: "text", - text: "plain ", - }, - { - type: "text", - text: "bold", - marks: [ - { - type: "bold", - }, - ], - }, - { - type: "text", - text: "link", - marks: [ - { - type: "link", - attrs: { - href: "https://oxynote.test", - }, - }, - ], - }, - ], - }, - ]) - - expect(inlineDelta(blockByUid(doc, "intro"))).toEqual([ - { insert: "plain " }, - { insert: "bold", attributes: { bold: {} } }, - { - insert: "link", - attributes: { - link: { - href: "https://oxynote.test", - }, - }, - }, - ]) - }) - - it("leaves the block without children when the content is empty", ({ - expect, - }) => { - const doc = docWith(sampleBlocks()) - - applyOperations(doc, [ - { - kind: "update_text", - block_uid: "intro", - content: [], - }, - ]) - - const intro = blockByUid(doc, "intro") - expect(intro.length).toBe(0) - expect(serialize(intro)).toBe( - '', - ) - }) - - // core leaves content out of the payload when it is empty. - it("leaves the block without children when the content is left out", ({ - expect, - }) => { - const doc = docWith(sampleBlocks()) - - const result = applyOperations(doc, [ - { - kind: "update_text", - block_uid: "intro", - } as unknown as Operation, - ]) - - expect(result.applied).toBe(1) - expect(blockByUid(doc, "intro").length).toBe(0) - }) - - it("leaves the block without children when no inline node carries text", ({ - expect, - }) => { - const doc = docWith(sampleBlocks()) - - applyOperations(doc, [ - { - kind: "update_text", - block_uid: "intro", - content: [ - { - type: "hardBreak", - } as unknown as PMInline, - { type: "text", text: "" }, - ], - }, - ]) - - expect(blockByUid(doc, "intro").length).toBe(0) - }) - - it("writes into a callout's only paragraph, keeping its uid", ({ - expect, - }) => { - const doc = docWith(sampleBlocks()) - - const result = applyOperations(doc, [ - { - kind: "update_text", - block_uid: "callout", - content: [ - { - type: "text", - text: "Rewritten", - }, - ], - }, - ]) - - expect(result.applied).toBe(1) - expect(blockByUid(doc, "callout").length).toBe(1) - expect(inlineDelta(blockByUid(doc, "nested"))).toEqual([ - { insert: "Rewritten" }, - ]) - }) - - it("refuses a callout holding more than one block, keeping them all", ({ - expect, - }) => { - const doc = docWith([ - { - type: "calloutBlock", - attrs: { - uid: "callout", - icon: "lucide:zap", - }, - content: [ - textParagraph( - "first", - "First line", - ), - textParagraph( - "second", - "Second line", - ), - ], - }, - ]) - - const result = applyOperations(doc, [ - { - kind: "update_text", - block_uid: "callout", - content: [ - { - type: "text", - text: "Rewritten", - }, - ], - }, - ]) - - expect(result.applied).toBe(0) - expect(result.errors[0]?.message).toContain( - "calloutBlock holding 2 blocks", - ) - expect(inlineDelta(blockByUid(doc, "first"))).toEqual([ - { insert: "First line" }, - ]) - expect(inlineDelta(blockByUid(doc, "second"))).toEqual([ - { insert: "Second line" }, - ]) - }) - - it("writes into a list item's paragraph, keeping the list nested under it", ({ - expect, - }) => { - const doc = docWith([ - { - type: "bulletList", - attrs: { uid: "list" }, - content: [ - { - type: "listItem", - attrs: { uid: "item" }, - content: [ - textParagraph( - "text", - "Item", - ), - bulletList( - "nested", - "nested-item", - "Nested", - ), - ], - }, - ], - }, - ]) - - const result = applyOperations(doc, [ - { - kind: "update_text", - block_uid: "item", - content: [ - { - type: "text", - text: "Rewritten", - }, - ], - }, - ]) - - expect(result.applied).toBe(1) - expect(blockByUid(doc, "item").length).toBe(2) - expect(blockByUid(doc, "nested").nodeName).toBe( - "bulletList", - ) - expect( - inlineDelta( - blockByUid(doc, "item").get( - 0, - ) as Y.XmlElement, - ), - ).toEqual([{ insert: "Rewritten" }]) - }) - - it.for([ - { - name: "a titled code block, into its code", - input: "titled", - expected: { code: "new_code", title: "GET /x" }, - }, - { - name: "a titled code block's title row", - input: "title", - expected: { code: "old", title: "POST /y" }, - }, - ])("writes $name", ({ input, expected }, { expect }) => { - const doc = docWith([titledCode("GET /x", "old")]) - - const result = applyOperations(doc, [ - { - kind: "update_text", - block_uid: input, - content: [ - { - type: "text", - text: - input === - "title" - ? expected.title - : expected.code, - }, - ], - }, - ]) - - expect(result.applied).toBe(1) - expect(inlineDelta(blockByUid(doc, "code"))).toEqual([ - { insert: expected.code }, - ]) - expect(inlineDelta(blockByUid(doc, "title"))).toEqual([ - { insert: expected.title }, - ]) - }) - - it("refuses a mark the block's schema does not allow, keeping its text", ({ - expect, - }) => { - const doc = docWith([titledCode("GET /x", "old")]) - - const result = applyOperations(doc, [ - { - kind: "update_text", - block_uid: "code", - content: [ - { type: "text", text: "my" }, - { - type: "text", - text: "var", - marks: [ - { - type: "underline", - }, - ], - }, - ], - }, - ]) - - expect(result.applied).toBe(0) - expect(result.errors[0]?.message).toBe( - "update_text cannot put the underline mark in codeBlock: write the text without it", - ) - expect(inlineDelta(blockByUid(doc, "code"))).toEqual([ - { insert: "old" }, - ]) - }) - - it("refuses a mark the schema does not know", ({ expect }) => { - const doc = docWith(sampleBlocks()) - - const result = applyOperations(doc, [ - { - kind: "update_text", - block_uid: "intro", - content: [ - { - type: "text", - text: "x", - marks: [ - { - type: "sparkle", - }, - ], - }, - ], - }, - ]) - - expect(result.applied).toBe(0) - expect(result.errors[0]?.message).toContain( - "cannot put the sparkle mark in paragraph", - ) - }) - - it("keeps a mark to its own run, leaving the text after it plain", ({ - expect, - }) => { - const doc = docWith(sampleBlocks()) - - applyOperations(doc, [ - { - kind: "update_text", - block_uid: "intro", - content: [ - { - type: "text", - text: "bold", - marks: [ - { - type: "bold", - }, - ], - }, - { type: "text", text: " and " }, - { - type: "text", - text: "italic", - marks: [ - { - type: "italic", - }, - ], - }, - { type: "text", text: " word" }, - ], - }, - ]) - - expect(inlineDelta(blockByUid(doc, "intro"))).toEqual([ - { insert: "bold", attributes: { bold: {} } }, - { insert: " and " }, - { - insert: "italic", - attributes: { italic: {} }, - }, - { insert: " word" }, - ]) - }) - - // core decodes a mark's attrs as an object and refuses the whole - // document otherwise, so every later persist would fail too. - it("writes marks the stored content carries as objects", ({ - expect, - }) => { - const doc = docWith(sampleBlocks()) - - applyOperations(doc, [ - { - kind: "update_text", - block_uid: "intro", - content: [ - { - type: "text", - text: "bold", - marks: [ - { - type: "bold", - }, - ], - }, - ], - }, - ]) - - const stored = transformer.fromYdoc(doc, "content") as { - content: { content?: { marks?: unknown[] }[] }[] - } - expect(stored.content[0]?.content?.[0]?.marks).toEqual([ - { type: "bold", attrs: {} }, - ]) - }) - - it("reports an error when a callout holds no paragraph to write in", ({ - expect, - }) => { - const doc = docWith([ - { - type: "calloutBlock", - attrs: { - uid: "callout", - icon: "lucide:zap", - }, - content: [ - bulletList( - "list", - "item", - "Item", - ), - ], - }, - ]) - - const result = applyOperations(doc, [ - { - kind: "update_text", - block_uid: "callout", - content: [ - { - type: "text", - text: "Rewritten", - }, - ], - }, - ]) - - expect(result.applied).toBe(0) - expect(result.errors[0]?.message).toContain( - "no paragraph to write in", - ) - }) - - it("refuses to overwrite a bullet list, which carries blocks rather than text", ({ - expect, - }) => { - const doc = docWith([ - bulletList("list", "item", "Item"), - ]) - - const result = applyOperations(doc, [ - { - kind: "update_text", - block_uid: "list", - content: [ - { - type: "text", - text: "Rewritten", - }, - ], - }, - ]) - - expect(result.applied).toBe(0) - expect(result.errors[0]?.message).toContain( - "carries blocks rather than text", - ) - // the item, and the uid any comment or hook hangs off, - // both survive the refusal. - expect(inlineDelta(blockByUid(doc, "text"))).toEqual([ - { insert: "Item" }, - ]) - }) - - it("reports an error when the block is absent", ({ - expect, - }) => { - const doc = docWith(sampleBlocks()) - - const result = applyOperations(doc, [ - { - kind: "update_text", - block_uid: "ghost", - content: [ - { - type: "text", - text: "Rewritten", - }, - ], - }, - ]) - - expect(result).toEqual({ - applied: 0, - errors: [ - { - index: 0, - message: "block_uid not found: ghost", - }, - ], - }) - }) - }) - describe("update_attrs", () => { it("sets the named attributes and keeps the untouched ones", ({ expect, @@ -1226,7 +832,7 @@ describe("applyOperations", () => { }, ]) - expect(result.applied).toBe(1) + expect(result.errors).toEqual([]) expect(attrsOf(blockByUid(doc, "callout"))).toEqual({ uid: "callout", icon: "lucide:bug", @@ -1251,12 +857,7 @@ describe("applyOperations", () => { uid: "head", level: 3, }) - expect( - findByUid( - doc.getXmlFragment("content"), - "stolen", - ), - ).toBeNull() + expect(findBlock(doc, "stolen")).toBeUndefined() }) it("reports an error when the block is absent", ({ @@ -1273,7 +874,6 @@ describe("applyOperations", () => { ]) expect(result).toEqual({ - applied: 0, errors: [ { index: 0, @@ -1282,136 +882,24 @@ describe("applyOperations", () => { ], }) }) - - it("writes a titled code block's title and language to its children", ({ - expect, - }) => { - const doc = docWith([titledCode("GET /x", "old")]) - - const result = applyOperations(doc, [ - { - kind: "update_attrs", - block_uid: "titled", - attrs: { - uid: "ignored", - title: "POST /y", - language: "python", - }, - }, - ]) - - expect(result.applied).toBe(1) - expect(attrsOf(blockByUid(doc, "titled"))).toEqual({ - uid: "titled", - }) - expect(inlineDelta(blockByUid(doc, "title"))).toEqual([ - { insert: "POST /y" }, - ]) - expect(attrsOf(blockByUid(doc, "code"))).toEqual({ - uid: "code", - language: "python", - }) - }) - - it.for([ - { name: "an empty title", input: "" }, - { name: "a title that is not a string", input: null }, - ])( - "leaves a titled code block's title row empty for $name", - ({ input }, { expect }) => { - const doc = docWith([ - titledCode("GET /x", "old"), - ]) - - applyOperations(doc, [ - { - kind: "update_attrs", - block_uid: "titled", - attrs: { title: input }, - }, - ]) - - expect(blockByUid(doc, "title").length).toBe(0) - expect( - attrsOf(blockByUid(doc, "code")), - ).toEqual({ - uid: "code", - language: "go", - }) - }, - ) - - it("refuses an attribute a titled code block does not take, writing none", ({ - expect, - }) => { - const doc = docWith([titledCode("GET /x", "old")]) - - const result = applyOperations(doc, [ - { - kind: "update_attrs", - block_uid: "titled", - attrs: { title: "POST /y", width: 3 }, - }, - ]) - - expect(result.applied).toBe(0) - expect(result.errors[0]?.message).toBe( - "titledCodeBlock takes only title and language, not width. Use replace_block to change anything else.", - ) - expect(inlineDelta(blockByUid(doc, "title"))).toEqual([ - { insert: "GET /x" }, - ]) - }) - - it("refuses a titled code block language that is not a string, writing none", ({ - expect, - }) => { - const doc = docWith([titledCode("GET /x", "old")]) - - const result = applyOperations(doc, [ - { - kind: "update_attrs", - block_uid: "titled", - attrs: { - title: "POST /y", - language: 1, - }, - }, - ]) - - expect(result.applied).toBe(0) - expect(result.errors[0]?.message).toBe( - "titledCodeBlock language must be a string or null", - ) - expect(inlineDelta(blockByUid(doc, "title"))).toEqual([ - { insert: "GET /x" }, - ]) - }) - - it("refuses a titled code block missing its title row", ({ - expect, - }) => { - const doc = docWith([]) - const broken = new Y.XmlElement("titledCodeBlock") - broken.setAttribute("uid", "titled") - doc.getXmlFragment("content").insert(0, [broken]) - - const result = applyOperations(doc, [ - { - kind: "update_attrs", - block_uid: "titled", - attrs: { language: "python" }, - }, - ]) - - expect(result.applied).toBe(0) - expect(result.errors[0]?.message).toBe( - "titledCodeBlock is missing its title or code. Use replace_block to rewrite it whole.", - ) - }) }) describe("delete", () => { + it("refuses to delete a container's last block", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { kind: "delete", block_uid: "nested" }, + ]) + + expect(result.errors[0]?.message).toContain( + "calloutBlock would end without", + ) + expect(blockByUid(doc, "callout").length).toBe(1) + }) + it("removes the block and everything nested inside it", ({ expect, }) => { @@ -1421,14 +909,9 @@ describe("applyOperations", () => { { kind: "delete", block_uid: "callout" }, ]) - expect(result.applied).toBe(1) + expect(result.errors).toEqual([]) expect(topUids(doc)).toEqual(["intro", "head"]) - expect( - findByUid( - doc.getXmlFragment("content"), - "nested", - ), - ).toBeNull() + expect(findBlock(doc, "nested")).toBeUndefined() }) it("reports an error when the block is absent", ({ @@ -1441,7 +924,6 @@ describe("applyOperations", () => { ]) expect(result).toEqual({ - applied: 0, errors: [ { index: 0, @@ -1489,7 +971,7 @@ describe("applyOperations", () => { }, ]) - expect(result.applied).toBe(1) + expect(result.errors).toEqual([]) expect(topUids(doc)).toEqual(expected) }, ) @@ -1508,7 +990,7 @@ describe("applyOperations", () => { }, ]) - expect(result.applied).toBe(1) + expect(result.errors).toEqual([]) expect(topUids(doc)).toEqual([ "head", "intro", @@ -1530,7 +1012,7 @@ describe("applyOperations", () => { }, ]) - expect(result.applied).toBe(1) + expect(result.errors).toEqual([]) expect(topUids(doc)).toEqual([ "intro", "head", @@ -1543,11 +1025,7 @@ describe("applyOperations", () => { icon: "lucide:zap", }) - const nested = findByUid( - doc.getXmlFragment("content"), - "nested", - ) - expect(nested?.parent).toBe(callout) + expect(blockByUid(doc, "nested").parent).toBe(callout) }) it("moves a block into a different parent next to a nested reference", ({ @@ -1555,6 +1033,31 @@ describe("applyOperations", () => { }) => { const doc = docWith(sampleBlocks()) + const result = applyOperations(doc, [ + { + kind: "move", + block_uid: "intro", + position: "after", + reference_uid: "nested", + }, + ]) + + expect(result.errors).toEqual([]) + expect(topUids(doc)).toEqual(["callout", "head"]) + + expect(blockByUid(doc, "intro").parent).toBe( + blockByUid(doc, "callout"), + ) + expect(attrsOf(blockByUid(doc, "intro"))).toEqual({ + uid: "intro", + }) + }) + + it("refuses a move into a container that does not take the block, leaving the document as it was", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + const result = applyOperations(doc, [ { kind: "move", @@ -1564,22 +1067,61 @@ describe("applyOperations", () => { }, ]) - expect(result.applied).toBe(1) - expect(topUids(doc)).toEqual(["intro", "callout"]) - - const head = findByUid( - doc.getXmlFragment("content"), - "head", + expect(result.errors[0]?.message).toContain( + "heading is not allowed there in calloutBlock", ) - expect(head?.parent).toBe(blockByUid(doc, "callout")) - expect(attrsOf(blockByUid(doc, "head"))).toEqual({ - uid: "head", - level: 2, - }) + expect(topUids(doc)).toEqual([ + "intro", + "callout", + "head", + ]) + expect(blockByUid(doc, "callout").length).toBe(1) }) it("moves a nested block out to the document root", ({ expect, + }) => { + const doc = docWith([ + textParagraph("intro", "Intro line"), + { + type: "calloutBlock", + attrs: { + uid: "callout", + icon: "lucide:zap", + }, + content: [ + textParagraph( + "nested", + "Nested line", + ), + textParagraph( + "second", + "Second line", + ), + ], + }, + ]) + + const result = applyOperations(doc, [ + { + kind: "move", + block_uid: "nested", + position: "before", + reference_uid: "intro", + }, + ]) + + expect(result.errors).toEqual([]) + expect(topUids(doc)).toEqual([ + "nested", + "intro", + "callout", + ]) + expect(blockByUid(doc, "callout").length).toBe(1) + }) + + it("refuses a move that would leave its container empty", ({ + expect, }) => { const doc = docWith(sampleBlocks()) @@ -1592,14 +1134,10 @@ describe("applyOperations", () => { }, ]) - expect(result.applied).toBe(1) - expect(topUids(doc)).toEqual([ - "nested", - "intro", - "callout", - "head", - ]) - expect(blockByUid(doc, "callout").length).toBe(0) + expect(result.errors[0]?.message).toContain( + "calloutBlock would end without", + ) + expect(blockByUid(doc, "callout").length).toBe(1) }) it("reports an error for a position that is neither before nor after", ({ @@ -1617,7 +1155,6 @@ describe("applyOperations", () => { ]) expect(result).toEqual({ - applied: 0, errors: [ { index: 0, @@ -1647,7 +1184,6 @@ describe("applyOperations", () => { ]) expect(result).toEqual({ - applied: 0, errors: [ { index: 0, @@ -1677,7 +1213,6 @@ describe("applyOperations", () => { ]) expect(result).toEqual({ - applied: 0, errors: [ { index: 0, @@ -1707,7 +1242,6 @@ describe("applyOperations", () => { ]) expect(result).toEqual({ - applied: 0, errors: [ { index: 0, @@ -1737,7 +1271,6 @@ describe("applyOperations", () => { ]) expect(result).toEqual({ - applied: 0, errors: [ { index: 0, @@ -1765,7 +1298,7 @@ describe("applyOperations", () => { const frag = doc.getXmlFragment("name") const para = frag.get(0) - expect(result.applied).toBe(1) + expect(result.errors).toEqual([]) expect(frag.length).toBe(1) expect(para).toBeInstanceOf(Y.XmlElement) @@ -1828,7 +1361,7 @@ describe("applyOperations", () => { { kind: "set_icon", icon: input }, ]) - expect(result.applied).toBe(1) + expect(result.errors).toEqual([]) expect(serialize(doc.getText("icon"))).toBe( expected, ) @@ -1836,108 +1369,3 @@ describe("applyOperations", () => { ) }) }) - -describe("findByUid", () => { - it("returns a top-level block with the content fragment as its parent", ({ - expect, - }) => { - const doc = docWith(sampleBlocks()) - const frag = doc.getXmlFragment("content") - - const found = findByUid(frag, "head") - - expect(found?.parent).toBe(frag) - expect(found?.index).toBe(2) - expect(found?.element.nodeName).toBe("heading") - }) - - it("returns a nested block with its enclosing element as parent", ({ - expect, - }) => { - const doc = docWith(sampleBlocks()) - const callout = blockByUid(doc, "callout") - - const found = findByUid(doc.getXmlFragment("content"), "nested") - - expect(found?.parent).toBe(callout) - expect(found?.index).toBe(0) - expect(found?.element).toBe(callout.get(0)) - }) - - it("returns null when no block carries the uid", ({ expect }) => { - const doc = docWith(sampleBlocks()) - - expect( - findByUid(doc.getXmlFragment("content"), "ghost"), - ).toBeNull() - }) - - it("skips children that are not elements", ({ expect }) => { - const doc = new Y.Doc() - const frag = doc.getXmlFragment("content") - const stray = new Y.XmlText() - frag.insert(0, [stray]) - stray.insert(0, "stray") - frag.insert(1, [paragraphWrappingElement("wrapper")]) - - const found = findByUid(frag, "wrapper-inner") - - expect(found?.index).toBe(0) - expect(found?.parent).toBe(frag.get(1)) - }) -}) - -describe("pmBlockToY", () => { - it("returns a detached element carrying the block's node name, attributes and children", ({ - expect, - }) => { - const el = pmBlockToY({ - type: "calloutBlock", - attrs: { uid: "callout", icon: "lucide:zap" }, - content: [textParagraph("nested", "Nested line")], - }) - - expect(el.nodeName).toBe("calloutBlock") - expect(el.doc).toBeNull() - expect(el.parent).toBeNull() - - const live = attached(el) - expect(attrsOf(live)).toEqual({ - uid: "callout", - icon: "lucide:zap", - }) - expect(serialize(live)).toBe( - '' + - 'Nested line' + - "", - ) - }) - - it("preserves attribute values that are not strings", ({ expect }) => { - const el = pmBlockToY({ - type: "metricBlock", - attrs: { - uid: "metric", - queries: [{ query: "up", legend: "uptime" }], - }, - }) - - // the simulation flag is the one metric attribute whose schema - // default is not null, so it is written out with the block - expect(attrsOf(attached(el))).toEqual({ - uid: "metric", - queries: [{ query: "up", legend: "uptime" }], - simulationActive: false, - }) - }) - - it("throws when the transformer yields no element for the block", ({ - expect, - }) => { - // a bare inline text node is not a block, so the transformer - // puts a Y.XmlText at the top of the fragment - expect(() => - pmBlockToY({ type: "text", text: "loose" }), - ).toThrow("pmBlockToY: transformer produced no XmlElement") - }) -}) diff --git a/server/auth-realtime/src/operations.ts b/server/auth-realtime/src/operations.ts index 4027ca66..53247cda 100644 --- a/server/auth-realtime/src/operations.ts +++ b/server/auth-realtime/src/operations.ts @@ -1,38 +1,43 @@ /** * Operation interpreter for the Go-driven edit RPC. * - * The Go assistant builds operations against the canonical block - * model, expands them into ProseMirror JSON, and posts them to the + * The Go assistant builds operations as ProseMirror JSON and posts + * them to the * /api/x/documents/:docId/branches/:branchId/operations route. This * module owns the Y.XmlFragment mutations that apply those - * operations to a live Y.Doc. Every applied batch runs inside a - * single `Y.Doc.transact` so subscribers see one update. - * - * The operations themselves are intentionally small (single-block - * atomic) because the AI fires them in parallel within one turn — - * see docs/assistant-rework.md §2.1 for the schema. + * operations to a live Y.Doc. A batch applies whole or not at all, + * inside a single `Y.Doc.transact` so subscribers see one update. */ import * as Y from "yjs" import { nanoid } from "nanoid" -import { schema, transformer, cloneXmlElement } from "./ydocument.js" +import { transformer, cloneXmlElement, cloneXmlFragment } from "./ydocument.js" +import { checkBlock, checkChildren } from "./validate.js" /** Canonical attribute name carried on every editor block. */ const UID_ATTR = "uid" +// what a refused insert or move should try instead. +const PLACE_ADVICE = "Choose a reference block in a container that takes it." + +// what a refused append or prepend should try instead. +const END_ADVICE = + "Insert it before or after a block in a container that takes it." + /** The sides an insert or move may name, as a widened list for validation. */ const INSERT_POSITIONS: string[] = ["before", "after"] /** * One batched operation against a document. Shapes mirror the Go - * canonical/op definitions; the Node side only validates the - * minimum required to apply each op. + * edit operations. An op that adds, replaces, moves or deletes a block + * is checked against the editor schema (validate.ts). applyOperations + * runs the batch on a trial copy first, so a refused op never reaches + * the document. */ export type Operation = | InsertOp | AppendOp | PrependOp | ReplaceOp - | UpdateTextOp | UpdateAttrsOp | DeleteOp | MoveOp @@ -69,26 +74,9 @@ export interface ReplaceOp { block: PMNode } -/** - * Replaces the inline content of a text-bearing block in place, - * preserving the block's type, attrs, and uid. A block holding its text - * one level down (a callout or blockquote of one paragraph, a list - * entry, a titled code block) is written through to that child; any - * other block-carrying target, and a mark the target does not allow, is - * refused rather than flattened. - */ -export interface UpdateTextOp { - kind: "update_text" - block_uid: string - /** ProseMirror inline content (text nodes with marks). */ - content: PMInline[] -} - /** * Sets/overrides the named attributes on an existing block. Other - * attributes are preserved. The uid attribute cannot be changed. A - * titled code block takes only title and language, written to its - * children. + * attributes are preserved. The uid attribute cannot be changed. */ export interface UpdateAttrsOp { kind: "update_attrs" @@ -128,22 +116,15 @@ export interface SetIconOp { icon: string } -/** A ProseMirror JSON node. Loose shape; transformer validates. */ +/** A ProseMirror JSON node. Loose shape; validate.ts checks it. */ export interface PMNode { type: string attrs?: Record - content?: (PMNode | PMInline)[] + content?: PMNode[] text?: string marks?: PMMark[] } -/** A ProseMirror inline text node fragment. */ -export interface PMInline { - type: "text" - text: string - marks?: PMMark[] -} - /** A ProseMirror mark (bold, italic, link, …). */ export interface PMMark { type: string @@ -160,39 +141,59 @@ export interface OperationError { /** Result of applying a batch of operations. */ export interface ApplyResult { - /** Number of operations that were applied successfully. */ - applied: number - /** Per-op errors, one entry per failed op (in input order). */ + /** The operation that failed, if any; a batch stops at the first. */ errors: OperationError[] } /** * Applies a batch of operations to the given Y.Doc inside a single - * transaction. Failures on individual operations are collected but - * do not abort the batch — applied state is committed for the ops - * that succeeded. Subscribers see one consolidated update. + * transaction, all of them or none. Subscribers see one consolidated + * update. */ export function applyOperations(doc: Y.Doc, ops: Operation[]): ApplyResult { - const result: ApplyResult = { applied: 0, errors: [] } + // a Yjs transaction cannot be rolled back, so the batch first runs + // on an exact copy that is then thrown away. Only a batch that + // applies whole reaches doc. + const trial = new Y.Doc() + cloneXmlFragment( + doc.getXmlFragment("content"), + trial.getXmlFragment("content"), + ) + + const error = runOperations(trial, ops) + if (error) { + return { errors: [error] } + } doc.transact(() => { - ops.forEach((op, index) => { - try { - applyOperation(doc, op) - result.applied++ - } catch (err) { - result.errors.push({ - index, - message: - err instanceof Error - ? err.message - : String(err), - }) - } - }) + runOperations(doc, ops) }) - return result + return { errors: [] } +} + +// runOperations applies each operation in turn and returns the first +// that fails. The ones after it build on blocks it never wrote, so +// their errors would only mislead. +function runOperations( + doc: Y.Doc, + ops: Operation[], +): OperationError | undefined { + for (const [index, op] of ops.entries()) { + try { + applyOperation(doc, op) + } catch (err) { + return { + index, + message: + err instanceof Error + ? err.message + : String(err), + } + } + } + + return undefined } /** Dispatches one operation to its concrete handler. */ @@ -210,9 +211,6 @@ function applyOperation(doc: Y.Doc, op: Operation): void { case "replace": opReplace(doc, op) return - case "update_text": - opUpdateText(doc, op) - return case "update_attrs": opUpdateAttrs(doc, op) return @@ -255,15 +253,11 @@ function opInsert(doc: Y.Doc, op: InsertOp): void { ) } - const found = findByUid(doc.getXmlFragment("content"), op.reference_uid) - if (!found) { - throw new Error(`reference_uid not found: ${op.reference_uid}`) - } - - const xml = pmBlockToY(op.block) + const found = blockByUid(doc, op.reference_uid, "reference_uid") const insertAt = op.position === "before" ? found.index : found.index + 1 - found.parent.insert(insertAt, [xml]) + + insertBlock(found.parent, insertAt, op.block, PLACE_ADVICE) } function opAppend(doc: Y.Doc, op: AppendOp): void { @@ -276,171 +270,37 @@ function opAppend(doc: Y.Doc, op: AppendOp): void { // stray empty block. Insert before it instead so the trailing // paragraph stays last. let insertAt = frag.length - if (insertAt > 0) { - const last = frag.get(insertAt - 1) - if (last instanceof Y.XmlElement && isEmptyParagraph(last)) { - insertAt -= 1 - } + const last = insertAt > 0 ? frag.get(insertAt - 1) : null + if ( + last instanceof Y.XmlElement && + last.nodeName === "paragraph" && + last + .toArray() + .every( + (child) => + child instanceof Y.XmlText && + child.length === 0, + ) + ) { + insertAt -= 1 } - frag.insert(insertAt, [pmBlockToY(op.block)]) + insertBlock(frag, insertAt, op.block, END_ADVICE) } function opPrepend(doc: Y.Doc, op: PrependOp): void { - const frag = doc.getXmlFragment("content") - frag.insert(0, [pmBlockToY(op.block)]) + insertBlock(doc.getXmlFragment("content"), 0, op.block, END_ADVICE) } function opReplace(doc: Y.Doc, op: ReplaceOp): void { - const found = findByUid(doc.getXmlFragment("content"), op.block_uid) - if (!found) { - throw new Error(`block_uid not found: ${op.block_uid}`) - } + const found = blockByUid(doc, op.block_uid, "block_uid") - const xml = pmBlockToY(op.block) found.parent.delete(found.index, 1) - found.parent.insert(found.index, [xml]) -} - -// the blocks whose children are inline content, so replacing them -// wholesale with a text run is the whole edit. -const TEXT_LEAF_NODES = new Set([ - "paragraph", - "heading", - "codeBlock", - "codeBlockTitle", - "mermaidBlock", -]) - -// the blocks that hold their text one level down, each with the child it -// lives in. Their schemas admit block children only, so writing a text -// run into them directly produces a node the editor cannot parse and -// the canonical model cannot read back. -const TEXT_HOLDER_NODES = new Map([ - ["calloutBlock", "paragraph"], - ["blockquote", "paragraph"], - ["listItem", "paragraph"], - ["taskItem", "paragraph"], - ["titledCodeBlock", "codeBlock"], -]) - -// the holders refused when they hold more than one block, since the -// edit would show only the first. A list entry is not one of them: the -// lists under its paragraph belong to it. -const SINGLE_TEXT_HOLDERS = new Set(["calloutBlock", "blockquote"]) - -function opUpdateText(doc: Y.Doc, op: UpdateTextOp): void { - const found = findByUid(doc.getXmlFragment("content"), op.block_uid) - if (!found) { - throw new Error(`block_uid not found: ${op.block_uid}`) - } - - const target = textTarget(found.element) - - checkMarks(target.nodeName, op.content) - - target.delete(0, target.length) - - const text = buildInlineText(op.content) - if (text) { - target.insert(0, [text]) - } -} - -// textTarget returns the element whose inline content update_text -// replaces: the block itself, or the child a holder keeps its text in. -function textTarget(el: Y.XmlElement): Y.XmlElement { - const nodeName = el.nodeName - - if (TEXT_LEAF_NODES.has(nodeName)) { - return el - } - - const holds = TEXT_HOLDER_NODES.get(nodeName) - - // the edit replaces everything under the target, so a block holding - // other blocks would lose them — and lose the uids comments and - // hooks are anchored to — while reporting success. Only the caller - // knows that was not meant, so refuse instead of guessing. - if (holds === undefined) { - throw new Error( - `update_text does not apply to ${nodeName}: it carries ` + - `blocks rather than text, and the edit would discard ` + - `them. Use replace_block to rewrite it whole, or ` + - `update_text on the block holding the text.`, - ) - } - - if (SINGLE_TEXT_HOLDERS.has(nodeName) && el.length > 1) { - throw new Error( - `update_text does not apply to ${nodeName} holding ` + - `${String(el.length)} blocks: its text is not one ` + - `paragraph. Use update_text on the paragraph you ` + - `mean, or replace_block to rewrite it whole.`, - ) - } - - const target = firstChild(el, holds) - if (!target) { - throw new Error( - `update_text found no ${holds} to write in ${nodeName}`, - ) - } - - return target -} - -// checkMarks refuses a mark the target's schema does not allow. The -// editor cannot load such a node, so the edit would be lost while -// reporting success. -function checkMarks(nodeName: string, content: PMInline[]): void { - const nodeType = schema.nodes[nodeName] - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- operations arrive as unvalidated JSON, so the declared type is a claim about the caller rather than a guarantee about the value - for (const node of content ?? []) { - for (const mark of node.marks ?? []) { - const markType = schema.marks[mark.type] - - if ( - !nodeType || - !markType || - !nodeType.allowsMarkType(markType) - ) { - throw new Error( - `update_text cannot put the ${mark.type} mark in ` + - `${nodeName}: write the text without it`, - ) - } - } - } -} - -// firstChild returns the element's first child of the named type. -function firstChild(el: Y.XmlElement, nodeName: string): Y.XmlElement | null { - for (let i = 0; i < el.length; i++) { - const child = el.get(i) - if ( - child instanceof Y.XmlElement && - child.nodeName === nodeName - ) { - return child - } - } - - return null + insertBlock(found.parent, found.index, op.block) } function opUpdateAttrs(doc: Y.Doc, op: UpdateAttrsOp): void { - const found = findByUid(doc.getXmlFragment("content"), op.block_uid) - if (!found) { - throw new Error(`block_uid not found: ${op.block_uid}`) - } - - if (found.element.nodeName === "titledCodeBlock") { - updateTitledCodeAttrs(found.element, op.attrs) - - return - } + const found = blockByUid(doc, op.block_uid, "block_uid") for (const [key, value] of Object.entries(op.attrs)) { if (key === UID_ATTR) { @@ -449,86 +309,18 @@ function opUpdateAttrs(doc: Y.Doc, op: UpdateAttrsOp): void { continue } - // Y.XmlElement.setAttribute is typed as accepting strings - // only, but the runtime stores arbitrary values, which is - // what the editor schema relies on for nested metric - // configuration. Cast through unknown to bypass the - // declaration's stricter type. + // the default attribute type is string, but attrs hold any JSON. ;( - found.element as unknown as { - setAttribute(key: string, value: unknown): void - } + found.element as Y.XmlElement> ).setAttribute(key, value) } } -// updateTitledCodeAttrs writes the title and language where the editor -// reads them: the title row's text and the code block's attribute. On -// the block itself, both would be ignored. -function updateTitledCodeAttrs( - el: Y.XmlElement, - attrs: Record, -): void { - const keys = Object.keys(attrs).filter((key) => key !== UID_ATTR) - - const other = keys.find((key) => key !== "title" && key !== "language") - if (other !== undefined) { - throw new Error( - `titledCodeBlock takes only title and language, not ` + - `${other}. Use replace_block to change anything else.`, - ) - } - - // the editor's highlighter fails on a language that is not a string, - // and the document stops rendering. - const language = attrs.language - if ( - keys.includes("language") && - language !== null && - typeof language !== "string" - ) { - throw new Error( - "titledCodeBlock language must be a string or null", - ) - } - - // both are looked up first, so a refusal changes nothing. - const title = firstChild(el, "codeBlockTitle") - const code = firstChild(el, "codeBlock") - if (!title || !code) { - throw new Error( - "titledCodeBlock is missing its title or code. Use " + - "replace_block to rewrite it whole.", - ) - } - - if (keys.includes("title")) { - title.delete(0, title.length) - - const text = typeof attrs.title === "string" ? attrs.title : "" - if (text) { - const run = new Y.XmlText() - run.insert(0, text) - title.insert(0, [run]) - } - } - - if (keys.includes("language")) { - ;( - code as unknown as { - setAttribute(key: string, value: unknown): void - } - ).setAttribute("language", language) - } -} - function opDelete(doc: Y.Doc, op: DeleteOp): void { - const found = findByUid(doc.getXmlFragment("content"), op.block_uid) - if (!found) { - throw new Error(`block_uid not found: ${op.block_uid}`) - } + const found = blockByUid(doc, op.block_uid, "block_uid") found.parent.delete(found.index, 1) + checkChildrenOf(found.parent) } function opMove(doc: Y.Doc, op: MoveOp): void { @@ -545,21 +337,12 @@ function opMove(doc: Y.Doc, op: MoveOp): void { ) } - const frag = doc.getXmlFragment("content") - - const found = findByUid(frag, op.block_uid) - if (!found) { - throw new Error(`block_uid not found: ${op.block_uid}`) - } - - const reference = findByUid(frag, op.reference_uid) - if (!reference) { - throw new Error(`reference_uid not found: ${op.reference_uid}`) - } + const found = blockByUid(doc, op.block_uid, "block_uid") + const reference = blockByUid(doc, op.reference_uid, "reference_uid") // a reference nested inside the moved block is destroyed by the // removal below, leaving the move nowhere to land. - if (isInside(reference.element, found.element)) { + if (Y.isParentOf(found.element, reference.element._item)) { throw new Error( `reference_uid is inside the moved block: ${op.reference_uid}`, ) @@ -573,7 +356,7 @@ function opMove(doc: Y.Doc, op: MoveOp): void { // removing the block shifts its later siblings down by one, so a // reference behind it in the same parent is re-indexed before the - // removal invalidates the index findByUid reported. + // removal invalidates the index blockByUid reported. let insertAt = reference.index if (reference.parent === found.parent && found.index < insertAt) { insertAt -= 1 @@ -585,6 +368,12 @@ function opMove(doc: Y.Doc, op: MoveOp): void { found.parent.delete(found.index, 1) reference.parent.insert(insertAt, [clone]) + + if (reference.parent !== found.parent) { + checkChildrenOf(found.parent) + } + + checkChildrenOf(reference.parent, PLACE_ADVICE) } function opSetName(doc: Y.Doc, op: SetNameOp): void { @@ -613,152 +402,68 @@ function opSetIcon(doc: Y.Doc, op: SetIconOp): void { /* ---------------- helpers ---------------- */ -/** - * Locates the first descendant XmlElement whose uid attribute - * matches the target, returning the element along with its - * immediate parent fragment and index in that parent. Used by - * operations that need to mutate a block in place or insert - * adjacent to it. - */ -export function findByUid( - fragment: Y.XmlFragment, - uid: string, -): { - parent: Y.XmlFragment | Y.XmlElement - index: number - element: Y.XmlElement -} | null { - for (let i = 0; i < fragment.length; i++) { - const child = fragment.get(i) - if (!(child instanceof Y.XmlElement)) { - continue - } - - if (child.getAttribute(UID_ATTR) === uid) { - return { parent: fragment, index: i, element: child } - } - - const inside = findByUid(child, uid) - if (inside) { - return inside - } - } - - return null -} - -/** - * Reports whether el sits anywhere inside ancestor's subtree. Used by - * opMove to refuse a reference the removal of the moved block would - * destroy. - */ -function isInside(el: Y.XmlElement, ancestor: Y.XmlElement): boolean { - let parent = el.parent - - while (parent !== null) { - if (parent === ancestor) { - return true - } - - parent = parent.parent - } - - return false -} - -/** - * Reports whether el is a paragraph node with no visible inline - * content. Used by opAppend to detect TipTap's trailing-paragraph - * affordance so the new block is inserted before it instead of - * after. - */ -function isEmptyParagraph(el: Y.XmlElement): boolean { - if (el.nodeName !== "paragraph") { - return false - } - - for (let i = 0; i < el.length; i++) { - const child = el.get(i) - if (child instanceof Y.XmlText && child.length > 0) { - return false - } - if (child instanceof Y.XmlElement) { - return false - } - } - - return true -} - -/** - * Converts a single ProseMirror block (as JSON) into a Y.XmlElement - * detached from any document so the caller can insert it into a - * live fragment. Uses the existing TiptapTransformer for the - * schema-aware conversion and clones the result so we don't leak - * the temporary Y.Doc. - */ -export function pmBlockToY(block: PMNode): Y.XmlElement { - const tempDoc = transformer.toYdoc( - { type: "doc", content: [block] }, - "content", - ) - - const frag = tempDoc.getXmlFragment("content") - const first = frag.get(0) - if (!(first instanceof Y.XmlElement)) { - throw new Error( - "pmBlockToY: transformer produced no XmlElement", +// blockByUid returns the block carrying uid, with its parent and its +// index there. field names the op field that held uid, for the error. +function blockByUid(doc: Y.Doc, uid: string, field: string) { + const [element] = doc + .getXmlFragment("content") + .createTreeWalker( + (node) => + node instanceof Y.XmlElement && + node.getAttribute(UID_ATTR) === uid, ) + + if (!(element instanceof Y.XmlElement)) { + throw new Error(`${field} not found: ${uid}`) } - return cloneXmlElement(first) + // the walker only yields nodes inside the fragment. + const parent = element.parent as Y.XmlFragment + return { parent, index: parent.toArray().indexOf(element), element } } -/** - * Builds a Y.XmlText carrying the supplied ProseMirror inline - * content (text nodes with optional marks). Returns null when the - * content is empty so callers can skip the insert. - */ -function buildInlineText(content: PMInline[]): Y.XmlText | null { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- operations arrive as unvalidated JSON, so the declared type is a claim about the caller rather than a guarantee about the value - if (!content || content.length === 0) { - return null +// insertBlock inserts block into parent at index, after checking the +// block and then the parent's children against the schema. advice is +// passed to checkChildren. +function insertBlock( + parent: Y.XmlFragment, + index: number, + block: PMNode, + advice = "", +): void { + checkBlock(block) + + const first = transformer + .toYdoc({ type: "doc", content: [block] }, "content") + .getXmlFragment("content") + .get(0) + + // a bare text node passes checkBlock but becomes a Y.XmlText. + if (!(first instanceof Y.XmlElement)) { + throw new Error(`${block.type} is not a block`) } - const text = new Y.XmlText() - let cursor = 0 - - for (const node of content) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- same as above: the node kind is whatever the JSON payload carried, not what the type says - if (node.type !== "text" || !node.text) { - continue - } - - const attrs = marksToAttrs(node.marks) - text.insert(cursor, node.text, attrs) - cursor += node.text.length - } - - if (cursor === 0) { - return null - } - - return text + // the element belongs to the transformer's own Y.Doc. + parent.insert(index, [cloneXmlElement(first)]) + checkChildrenOf(parent, advice) } -/** - * Translates a ProseMirror mark array into the format-attribute - * shape Y.XmlText expects: { [markType]: markAttrs }. Unmarked text - * gets an empty object rather than nothing, since Y.Text.insert without - * attributes carries over the formatting of the text before it. - */ -function marksToAttrs(marks: PMMark[] | undefined): Record { - const out: Record = {} - for (const mark of marks ?? []) { - // an object even without attrs, as y-prosemirror writes it. - // Core cannot decode `true`, so the document would not persist. - out[mark.type] = mark.attrs ?? {} - } - - return out +// checkChildrenOf refuses parent holding children the schema does not +// accept. advice is passed to checkChildren. +function checkChildrenOf( + parent: Y.XmlFragment | Y.XmlElement, + advice = "", +): void { + // the content fragment is the document root. + checkChildren( + parent instanceof Y.XmlElement ? parent.nodeName : "doc", + parent + .toArray() + .flatMap((child) => + child instanceof Y.XmlElement + ? [child.nodeName] + : [], + ), + advice, + ) } diff --git a/server/auth-realtime/src/routes.test.ts b/server/auth-realtime/src/routes.test.ts index 95ac7b14..e2e9657a 100644 --- a/server/auth-realtime/src/routes.test.ts +++ b/server/auth-realtime/src/routes.test.ts @@ -1554,10 +1554,7 @@ describe("createRoutes", () => { }) expect(res.status).toBe(200) - expect(await res.json()).toEqual({ - applied: 1, - errors: [], - }) + expect(await res.json()).toEqual({ errors: [] }) expect(openDirectConnection).toHaveBeenCalledWith( "doc1-b1", {}, @@ -1763,10 +1760,8 @@ describe("createRoutes", () => { expect(res.status).toBe(200) const body = (await res.json()) as { - applied: number errors: { index: number }[] } - expect(body.applied).toBe(0) expect(body.errors).toHaveLength(1) expect(body.errors[0]?.index).toBe(0) }) diff --git a/server/auth-realtime/src/routes.ts b/server/auth-realtime/src/routes.ts index 03433e5a..baff1e06 100644 --- a/server/auth-realtime/src/routes.ts +++ b/server/auth-realtime/src/routes.ts @@ -17,7 +17,11 @@ import { bootstrapAdmin, type DefaultAdmin } from "./bootstrap.js" import { toAxiosHeaders } from "./headers.js" import { bestEffort } from "./reporting.js" import { replaceYdocContent, systemOrigin } from "./ydocument.js" -import { applyOperations, type Operation } from "./operations.js" +import { + applyOperations, + type ApplyResult, + type Operation, +} from "./operations.js" // the two things the routes ask of better-auth: the request handler // mounted under /auth/**, and the local JWKS the MCP session endpoint @@ -769,16 +773,7 @@ export function createRoutes({ } try { - let result: { - applied: number - errors: { - index: number - message: string - }[] - } = { - applied: 0, - errors: [], - } + let result: ApplyResult = { errors: [] } await connection.transact((doc) => { result = applyOperations(doc, ops) diff --git a/server/auth-realtime/src/schema/code-block.ts b/server/auth-realtime/src/schema/code-block.ts index 2be87f28..3b39e3dd 100644 --- a/server/auth-realtime/src/schema/code-block.ts +++ b/server/auth-realtime/src/schema/code-block.ts @@ -11,7 +11,7 @@ export const CodeBlockTitle = Node.create({ name: "codeBlockTitle", content: "text*", marks: `${CommentMark.name} ${AddedMark.name} ${DeletedMark.name}`, - group: "block", + group: "codeBlockTitle", selectable: true, defining: true, isolating: true, @@ -93,7 +93,7 @@ export const CodeBlock = CodeBlockLowlight.extend({ export const TitledCodeBlock = Node.create({ name: "titledCodeBlock", - group: "block", + group: "titledCodeBlock", content: `${CodeBlockTitle.name} ${CodeBlock.name}`, defining: true, isolating: true, diff --git a/server/auth-realtime/src/schema/index.test.ts b/server/auth-realtime/src/schema/index.test.ts index c122e850..c670adc7 100644 --- a/server/auth-realtime/src/schema/index.test.ts +++ b/server/auth-realtime/src/schema/index.test.ts @@ -23,7 +23,7 @@ import { ImageBlock } from "./image.js" import { FileBlock } from "./file.js" import { MermaidBlock } from "./mermaid.js" import { MetricBlock, MetricGrid } from "./metric.js" -import { transformer } from "../ydocument.js" +import { schema, transformer } from "../ydocument.js" interface PMNode { type: string @@ -163,6 +163,31 @@ describe("getEditorExtensions", () => { expect(types.filter((t) => !names.has(t))).toEqual([]) }) + + // the editor hangs block comments off nodeCommentId, so a write + // through this schema has to carry it + it("keeps a block's comment through a round trip", ({ expect }) => { + const json = { + type: "doc", + content: [ + { + type: "paragraph", + attrs: { + uid: "p", + nodeCommentId: "c1", + }, + content: [{ type: "text", text: "hi" }], + }, + ], + } + + const back = transformer.fromYdoc( + transformer.toYdoc(json, "content"), + "content", + ) as { content: PMNode[] } + + expect(back.content[0]?.attrs?.nodeCommentId).toBe("c1") + }) }) describe("editor schema", () => { @@ -623,6 +648,88 @@ function attributesOf(node: { return specOf(node).addAttributes?.call({ options: {} }) ?? {} } +describe("editor schema placement", () => { + // the web editor gives every node that lives only inside a macro a + // group of its own, so the document root refuses it. Edits are + // checked against this schema, so it has to refuse the same. + it.for([ + { name: "metricBlock", input: "metricBlock", expected: false }, + { + name: "titledCodeBlock", + input: "titledCodeBlock", + expected: false, + }, + { + name: "codeBlockTitle", + input: "codeBlockTitle", + expected: false, + }, + { + name: "splitDocumentationLeftSide", + input: "splitDocumentationLeftSide", + expected: false, + }, + { + name: "splitDocumentationRightSide", + input: "splitDocumentationRightSide", + expected: false, + }, + { + name: "splitDocumentationParameterList", + input: "splitDocumentationParameterList", + expected: false, + }, + { + name: "splitDocumentationParameterListHeader", + input: "splitDocumentationParameterListHeader", + expected: false, + }, + { + name: "splitDocumentationParameterListItem", + input: "splitDocumentationParameterListItem", + expected: false, + }, + { + name: "splitDocumentationParameterListItemHeader", + input: "splitDocumentationParameterListItemHeader", + expected: false, + }, + { + name: "splitDocumentationParameterListItemHeaderTitle", + input: "splitDocumentationParameterListItemHeaderTitle", + expected: false, + }, + { + name: "splitDocumentationParameterListItemHeaderType", + input: "splitDocumentationParameterListItemHeaderType", + expected: false, + }, + { name: "paragraph", input: "paragraph", expected: true }, + { name: "metricGrid", input: "metricGrid", expected: true }, + { + name: "splitDocumentation", + input: "splitDocumentation", + expected: true, + }, + { name: "mermaidBlock", input: "mermaidBlock", expected: true }, + ])( + "decides whether $name sits at the document root", + ({ input, expected }, { expect }) => { + const type = schema.nodes[input] + + expect(type).toBeDefined() + expect( + Boolean( + type && + schema.topNodeType.contentMatch.matchType( + type, + ), + ), + ).toBe(expected) + }, + ) +}) + describe("schema html contract", () => { it.for([ { diff --git a/server/auth-realtime/src/schema/index.ts b/server/auth-realtime/src/schema/index.ts index 4c8dc4c0..ad015dc7 100644 --- a/server/auth-realtime/src/schema/index.ts +++ b/server/auth-realtime/src/schema/index.ts @@ -46,6 +46,7 @@ import { // Custom marks import { AddedMark, DeletedMark, CommentMark } from "./marks.js" +import { NodeComment } from "./node-comment.js" const contentExtensionsWithIDs: Extensions = [ Paragraph, @@ -124,6 +125,9 @@ export function getEditorExtensions(): Extensions { // returning any, which propagates to the arrow. generateID: (): string => nanoid(), }), + NodeComment.configure({ + types: contentExtensionsWithIDs.map((v) => v.name), + }), ] } diff --git a/server/auth-realtime/src/schema/metric.ts b/server/auth-realtime/src/schema/metric.ts index ec4ad6df..ff4153e9 100644 --- a/server/auth-realtime/src/schema/metric.ts +++ b/server/auth-realtime/src/schema/metric.ts @@ -23,7 +23,7 @@ export const MetricGrid = Node.create({ export const MetricBlock = Node.create({ name: "metricBlock", - group: "block", + group: "metricBlock", atom: true, defining: true, selectable: false, diff --git a/server/auth-realtime/src/schema/node-comment.ts b/server/auth-realtime/src/schema/node-comment.ts new file mode 100644 index 00000000..5d77c08a --- /dev/null +++ b/server/auth-realtime/src/schema/node-comment.ts @@ -0,0 +1,20 @@ +import { Extension } from "@tiptap/core" + +// the editor hangs block comments off this attribute. Without it here, +// every block this service writes would lose its comment. +export const NodeComment = Extension.create<{ types: string[] }>({ + name: "nodeComment", + addOptions() { + return { types: [] } + }, + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + nodeCommentId: { default: null }, + }, + }, + ] + }, +}) diff --git a/server/auth-realtime/src/schema/parameter-list.ts b/server/auth-realtime/src/schema/parameter-list.ts index 808ffd4d..6e89e626 100644 --- a/server/auth-realtime/src/schema/parameter-list.ts +++ b/server/auth-realtime/src/schema/parameter-list.ts @@ -4,7 +4,7 @@ import { CommentMark, AddedMark, DeletedMark } from "./marks.js" export const ParameterListItemHeaderType = Node.create({ name: "splitDocumentationParameterListItemHeaderType", - group: "block", + group: "splitDocumentationParameterListItemHeaderType", content: `text*`, marks: `${CommentMark.name} ${AddedMark.name} ${DeletedMark.name}`, defining: true, @@ -31,7 +31,7 @@ export const ParameterListItemHeaderType = Node.create({ export const ParameterListItemHeaderTitle = Node.create({ name: "splitDocumentationParameterListItemHeaderTitle", - group: "block", + group: "splitDocumentationParameterListItemHeaderTitle", content: `text*`, marks: `${CommentMark.name} ${AddedMark.name} ${DeletedMark.name}`, defining: true, @@ -58,7 +58,7 @@ export const ParameterListItemHeaderTitle = Node.create({ export const ParameterListItemHeader = Node.create({ name: "splitDocumentationParameterListItemHeader", - group: "block", + group: "splitDocumentationParameterListItemHeader", content: `${ParameterListItemHeaderTitle.name} ${ParameterListItemHeaderType.name}`, defining: true, isolating: true, @@ -84,7 +84,7 @@ export const ParameterListItemHeader = Node.create({ export const ParameterListItem = Node.create({ name: "splitDocumentationParameterListItem", - group: "block", + group: "splitDocumentationParameterListItem", content: `${ParameterListItemHeader.name} ${Paragraph.name}`, defining: true, isolating: true, @@ -111,7 +111,7 @@ export const ParameterListItem = Node.create({ export const ParameterListHeader = Node.create({ name: "splitDocumentationParameterListHeader", content: `text*`, - group: "block", + group: "splitDocumentationParameterListHeader", marks: `${CommentMark.name} ${AddedMark.name} ${DeletedMark.name}`, defining: true, isolating: true, @@ -137,7 +137,7 @@ export const ParameterListHeader = Node.create({ export const ParameterList = Node.create({ name: "splitDocumentationParameterList", - group: "block", + group: "splitDocumentationParameterList", content: `${ParameterListHeader.name} ${ParameterListItem.name}+`, defining: true, isolating: true, diff --git a/server/auth-realtime/src/schema/split-documentation.ts b/server/auth-realtime/src/schema/split-documentation.ts index 6ae58380..3ec4f326 100644 --- a/server/auth-realtime/src/schema/split-documentation.ts +++ b/server/auth-realtime/src/schema/split-documentation.ts @@ -20,7 +20,7 @@ const extraLeftSideContent = [ParameterList.name] export const SplitDocumentationLeftSide = Node.create({ name: "splitDocumentationLeftSide", - group: "block", + group: "splitDocumentationLeftSide", isolating: false, defining: true, selectable: false, @@ -45,7 +45,7 @@ export const SplitDocumentationLeftSide = Node.create({ export const SplitDocumentationRightSide = Node.create({ name: "splitDocumentationRightSide", - group: "block", + group: "splitDocumentationRightSide", isolating: false, defining: true, selectable: false, diff --git a/server/auth-realtime/src/validate.test.ts b/server/auth-realtime/src/validate.test.ts new file mode 100644 index 00000000..337c4a84 --- /dev/null +++ b/server/auth-realtime/src/validate.test.ts @@ -0,0 +1,217 @@ +import { describe, it } from "vitest" +import { checkBlock, checkChildren } from "./validate.js" +import type { PMNode } from "./operations.js" + +function paragraph(text: string): PMNode { + return { type: "paragraph", content: [{ type: "text", text }] } +} + +function titledCode(): PMNode { + return { + type: "titledCodeBlock", + content: [ + { + type: "codeBlockTitle", + content: [{ type: "text", text: "GET /x" }], + }, + { + type: "codeBlock", + content: [{ type: "text", text: "x" }], + }, + ], + } +} + +describe("checkBlock", () => { + it("accepts a split documentation block of both sides", ({ + expect, + }) => { + expect(() => { + checkBlock({ + type: "splitDocumentation", + content: [ + { + type: "splitDocumentationLeftSide", + content: [ + { + type: "heading", + attrs: { + level: 1, + }, + content: [ + { + type: "text", + text: "API", + }, + ], + }, + paragraph( + "Does a thing", + ), + ], + }, + { + type: "splitDocumentationRightSide", + content: [titledCode()], + }, + ], + }) + }).not.toThrow() + }) + + it("refuses an unknown block type", ({ expect }) => { + expect(() => { + checkBlock({ type: "wibble" }) + }).toThrow("wibble is not a known block type") + }) + + it("refuses a nested block its container does not take", ({ + expect, + }) => { + expect(() => { + checkBlock({ + type: "calloutBlock", + content: [paragraph("a"), titledCode()], + }) + }).toThrow( + "titledCodeBlock is not allowed there in calloutBlock", + ) + }) + + it("refuses a mark the text's node does not allow", ({ expect }) => { + expect(() => { + checkBlock({ + type: "codeBlock", + content: [ + { + type: "text", + text: "x", + marks: [{ type: "bold" }], + }, + ], + }) + }).toThrow( + "the bold mark is not allowed in codeBlock: write the text without it", + ) + }) + + it("accepts marks the text's node allows", ({ expect }) => { + expect(() => { + checkBlock({ + type: "paragraph", + content: [ + { + type: "text", + text: "x", + marks: [ + { type: "bold" }, + { type: "italic" }, + ], + }, + ], + }) + }).not.toThrow() + }) + + it("refuses a mark the schema does not know", ({ expect }) => { + expect(() => { + checkBlock({ + type: "paragraph", + content: [ + { + type: "text", + text: "x", + marks: [{ type: "sparkle" }], + }, + ], + }) + }).toThrow("the sparkle mark is not allowed in paragraph") + }) +}) + +describe("checkChildren", () => { + it("accepts children the parent takes in that order", ({ expect }) => { + expect(() => { + checkChildren("doc", [ + "paragraph", + "metricGrid", + "heading", + ]) + }).not.toThrow() + }) + + it("refuses a block at the document root that belongs in a container", ({ + expect, + }) => { + expect(() => { + checkChildren("doc", ["paragraph", "metricBlock"]) + }).toThrow( + /^metricBlock is not allowed there in the document root, which takes .+ or another block at that point\.$/, + ) + }) + + it("ends the refusal with the caller's advice", ({ expect }) => { + expect(() => { + checkChildren("doc", ["metricBlock"], "Try elsewhere.") + }).toThrow(/at that point\. Try elsewhere\.$/) + }) + + it("refuses a block out of the order its parent takes", ({ + expect, + }) => { + expect(() => { + checkChildren("splitDocumentationLeftSide", [ + "heading", + "paragraph", + "splitDocumentationParameterList", + "paragraph", + ]) + }).toThrow( + "paragraph is not allowed there in splitDocumentationLeftSide, which takes splitDocumentationParameterList at that point", + ) + }) + + it("refuses a parent left without the blocks it needs", ({ + expect, + }) => { + expect(() => { + checkChildren("splitDocumentationRightSide", []) + }).toThrow( + "splitDocumentationRightSide would end without titledCodeBlock, metricBlock, or mermaidBlock. Add one there first, or delete the block that holds it.", + ) + }) + + it("refuses emptying the document root without naming a holder", ({ + expect, + }) => { + expect(() => { + checkChildren("doc", []) + }).toThrow( + /^the document root would end without .+\. Add one there first\.$/, + ) + }) + + it("refuses a block after a parent's content is complete", ({ + expect, + }) => { + expect(() => { + checkChildren("titledCodeBlock", [ + "codeBlockTitle", + "codeBlock", + "codeBlock", + ]) + }).toThrow("which takes nothing more at that point") + }) + + it("refuses an unknown child type", ({ expect }) => { + expect(() => { + checkChildren("doc", ["wibble"]) + }).toThrow("wibble is not allowed there in the document root") + }) + + it("refuses an unknown parent type", ({ expect }) => { + expect(() => { + checkChildren("wibble", []) + }).toThrow("wibble is not a known block type") + }) +}) diff --git a/server/auth-realtime/src/validate.ts b/server/auth-realtime/src/validate.ts new file mode 100644 index 00000000..9d5b610e --- /dev/null +++ b/server/auth-realtime/src/validate.ts @@ -0,0 +1,113 @@ +// Checks edits against the editor's ProseMirror schema before they are +// applied. The schema is the one source of what may sit where, so core +// sends blocks without checking their placement, and a refusal here +// leaves the document untouched. +import { schema } from "./ydocument.js" +import type { PMNode } from "./operations.js" + +type ContentMatch = (typeof schema.topNodeType)["contentMatch"] + +// how many node types a message lists before it stops. +const LISTED_TYPES = 5 + +// checkBlock refuses a block whose own content the schema does not +// accept: an unknown node type, children of the wrong kind or order, or +// a mark its node does not allow. +export function checkBlock(block: PMNode): void { + const content = block.content ?? [] + + checkChildren( + block.type, + content.map((child) => child.type), + ) + + for (const child of content) { + if (child.type !== "text") { + checkBlock(child) + continue + } + + // the editor cannot load a mark its node does not allow, so the + // edit would be lost while reporting success. + for (const mark of child.marks ?? []) { + const markType = schema.marks[mark.type] + + if ( + !markType || + !schema.nodes[block.type]?.allowsMarkType( + markType, + ) + ) { + throw new Error( + `the ${mark.type} mark is not allowed in ` + + `${block.type}: write the text without it`, + ) + } + } + } +} + +// checkChildren refuses a parent holding the named children, in that +// order, when the schema does not accept them. parent is a node type +// name, "doc" for the document root. advice ends the message when a +// child does not fit, for a caller that knows how to place it instead. +export function checkChildren( + parent: string, + children: string[], + advice = "", +): void { + const parentType = schema.nodes[parent] + if (!parentType) { + throw new Error(`${parent} is not a known block type`) + } + + const isRoot = parent === schema.topNodeType.name + const place = isRoot ? "the document root" : parent + let match = parentType.contentMatch + + for (const child of children) { + const childType = schema.nodes[child] + const next = childType ? match.matchType(childType) : null + + if (!next) { + const message = + `${child} is not allowed there in ${place}, ` + + `which takes ${acceptedTypes(match)} at that point.` + + throw new Error( + advice ? `${message} ${advice}` : message, + ) + } + + match = next + } + + if (!match.validEnd) { + const next = isRoot + ? "Add one there first." + : "Add one there first, or delete the block that holds it." + + throw new Error( + `${place} would end without ${acceptedTypes(match)}. ${next}`, + ) + } +} + +// acceptedTypes lists the node types match takes next. +function acceptedTypes(match: ContentMatch): string { + const names: string[] = [] + + for (let i = 0; i < match.edgeCount; i++) { + names.push(match.edge(i).type.name) + } + + if (names.length === 0) { + return "nothing more" + } + + if (names.length > LISTED_TYPES) { + names.splice(LISTED_TYPES, Infinity, "another block") + } + + return new Intl.ListFormat("en", { type: "disjunction" }).format(names) +} diff --git a/server/auth-realtime/src/ydocument.ts b/server/auth-realtime/src/ydocument.ts index 7020f78f..58639627 100644 --- a/server/auth-realtime/src/ydocument.ts +++ b/server/auth-realtime/src/ydocument.ts @@ -1,5 +1,5 @@ import * as Y from "yjs" -import { TiptapTransformer } from "@hocuspocus/transformer" +import { ProsemirrorTransformer } from "@hocuspocus/transformer" import { getSchema } from "@tiptap/core" import { getEditorExtensions } from "./schema/index.js" @@ -9,13 +9,8 @@ export interface DocumentData { icon: string } -// Create a TiptapTransformer instance with the web editor schema -const extensions = getEditorExtensions() -const transformer = TiptapTransformer.extensions(extensions) - -// the same schema as a ProseMirror schema, for checks the transformer -// does not make. -const schema = getSchema(extensions) +const schema = getSchema(getEditorExtensions()) +const transformer = ProsemirrorTransformer.schema(schema) /** * Deep-clones a Y.XmlElement, preserving all attribute types (including arrays @@ -24,17 +19,6 @@ const schema = getSchema(extensions) */ export function cloneXmlElement(source: Y.XmlElement): Y.XmlElement { const el = new Y.XmlElement(source.nodeName) - // Y.XmlElement.setAttribute is declared as accepting `string`, - // but the runtime stores arbitrary values which we rely on for - // complex attributes (e.g. the queries array on metricBlock). - // Cast through unknown to bypass the type declaration without - // dropping non-string values. - const setAttr = ( - el as unknown as { - setAttribute(key: string, value: unknown): void - } - ).setAttribute.bind(el) - const attrs = source.getAttributes() for (const [key, value] of Object.entries(attrs)) { if (value === undefined) { @@ -43,8 +27,13 @@ export function cloneXmlElement(source: Y.XmlElement): Y.XmlElement { continue } - setAttr(key, value) + // the default attribute type is string, but attrs hold any JSON. + ;(el as Y.XmlElement>).setAttribute( + key, + value, + ) } + const children = source.toArray() if (children.length > 0) { el.insert( @@ -66,16 +55,18 @@ export function cloneXmlElement(source: Y.XmlElement): Y.XmlElement { return el } -function cloneXmlFragment(source: Y.XmlFragment, target: Y.XmlFragment): void { +// cloneXmlFragment replaces target's children with deep clones of +// source's. +export function cloneXmlFragment( + source: Y.XmlFragment, + target: Y.XmlFragment, +): void { target.delete(0, target.length) for (let i = 0; i < source.length; i++) { const item = source.get(i) if (item instanceof Y.XmlElement) { target.insert(i, [cloneXmlElement(item)]) } else if (item instanceof Y.XmlText) { - // NOCOV: both fragments this runs over come from the tiptap - // transformer, whose schema only ever puts block elements at - // the top level — bare text there is unreachable today. const newText = new Y.XmlText() newText.applyDelta(item.toDelta() as any[]) target.insert(i, [newText]) diff --git a/server/core/internal/apps/registry/client.go b/server/core/internal/apps/registry/client.go index def84a41..849f7ba4 100644 --- a/server/core/internal/apps/registry/client.go +++ b/server/core/internal/apps/registry/client.go @@ -8,7 +8,6 @@ import ( "net/http" "slices" - "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-containerregistry/pkg/v1/remote/transport" @@ -23,57 +22,14 @@ var ( ErrNotFound = errutil.New(http.StatusNotFound, "registry.not_found", "container image not found") ) -// digestOptions holds options for configuring the Digest function. -type digestOptions struct { - username string - password string - token string -} - -// DigestOption is an option for configuring the Digest function. -type DigestOption func(*digestOptions) - // Digest retrieves the digest of the specified container image. -func Digest( - ctx context.Context, - image string, - digOpts ...DigestOption, -) (string, error) { +func Digest(ctx context.Context, image string) (string, error) { ref, err := parseReference(image) if err != nil { return "", err } - remoteOpts := []remote.Option{ - remote.WithContext(ctx), - } - - baseOpts := &digestOptions{} - - for _, optFn := range digOpts { - optFn(baseOpts) - } - - if baseOpts.username != "" && baseOpts.password != "" { - remoteOpts = append(remoteOpts, - remote.WithAuth( - &authn.Basic{ - Username: baseOpts.username, - Password: baseOpts.password, - }, - ), - ) - } else if baseOpts.token != "" { - remoteOpts = append(remoteOpts, - remote.WithAuth( - &authn.Bearer{ - Token: baseOpts.token, - }, - ), - ) - } - - desc, err := remote.Head(ref, remoteOpts...) + desc, err := remote.Head(ref, remote.WithContext(ctx)) if err != nil { if terr, ok := errors.AsType[*transport.Error](err); ok { switch { @@ -103,21 +59,6 @@ func ValidateReference(image string) error { return err } -// WithBasicAuth sets the username and password for basic authentication. -func WithBasicAuth(username, password string) DigestOption { - return func(o *digestOptions) { - o.username = username - o.password = password - } -} - -// WithBearerToken sets the token for bearer authentication. -func WithBearerToken(token string) DigestOption { - return func(o *digestOptions) { - o.token = token - } -} - // parseReference parses an image reference, defaulting to Docker Hub and the // latest tag. func parseReference(image string) (name.Reference, error) { diff --git a/server/core/internal/apps/registry/client_test.go b/server/core/internal/apps/registry/client_test.go index 527f5b3d..9b7fda61 100644 --- a/server/core/internal/apps/registry/client_test.go +++ b/server/core/internal/apps/registry/client_test.go @@ -81,7 +81,6 @@ func Test_Digest(t *testing.T) { Authorization string Reference string Path string - Opts []DigestOption ExpectedErr string ErrContains string }{ @@ -98,28 +97,10 @@ func Test_Digest(t *testing.T) { Path: "/test/other:v1", ExpectedErr: ErrNotFound.Error(), }, - "Basic auth credentials are attached": { - Authorization: basicAuthorization, - Opts: []DigestOption{WithBasicAuth("user", "pass")}, - }, - "Bearer token is attached": { - Authorization: "Bearer tok-1", - Opts: []DigestOption{WithBearerToken("tok-1")}, - }, "Missing credentials map to ErrUnauthorized": { Authorization: basicAuthorization, ExpectedErr: ErrUnauthorized.Error(), }, - "Wrong credentials map to ErrUnauthorized": { - Authorization: basicAuthorization, - Opts: []DigestOption{WithBasicAuth("user", "wrong")}, - ExpectedErr: ErrUnauthorized.Error(), - }, - "Basic auth without password falls back to anonymous": { - Authorization: basicAuthorization, - Opts: []DigestOption{WithBasicAuth("user", "")}, - ExpectedErr: ErrUnauthorized.Error(), - }, } for tname, tc := range tests { @@ -138,7 +119,7 @@ func Test_Digest(t *testing.T) { reference = host + path } - digest, err := Digest(context.Background(), reference, tc.Opts...) + digest, err := Digest(context.Background(), reference) if tc.ErrContains != "" { require.Error(t, err) diff --git a/server/core/internal/assistant/AGENTS.md b/server/core/internal/assistant/AGENTS.md index cd1ad47f..2ece49a0 100644 --- a/server/core/internal/assistant/AGENTS.md +++ b/server/core/internal/assistant/AGENTS.md @@ -6,10 +6,10 @@ The assistant: system prompt and tools. Go standards live in ## Prompt -`prompt.go` assembles the system prompt from section constants. Block model, -edit etiquette and aesthetics ship to both the chat model and MCP clients; +`prompt.go` assembles the system prompt from section constants. Workflow, +content, editing and style ship to both the chat model and MCP clients; surface-specific rules (confirmation flow, persona) live in that surface's -section. Behaviour rules come first and are restated at the end. +section. `MCPInstructions` stays under 2 KB, the part Claude Code shows. Writing a rule: state the principle and its reason in one or two sentences, no edge-case lists or numbered steps; say what to do rather than what to @@ -26,22 +26,20 @@ adapter and `read_tool_output`. `tools.Set` is the only list of tools; adding one means its type plus a line there, and the MCP server picks it up for free. -- **Tools implement this package's `Tool`** (`Info`, `Traits`, `Title`, - `Summary`, `Execute`), never eino's interfaces; only `eino.go` imports - eino. Other surfaces use `Entry.Info` to describe and `Entry.Tool.Run` to - run. -- **A call reports what it changed** in `Result.Documents`; every write goes - through an `Input` method that records the document as it mutates (a - document delete records nothing, a hook delete records the branch, - tag-only writes record none). -- **Tag and hook writes notify**: tag tools end with - `Input.NotifyTagTreeChange` (`assign_tag`/`unassign_tag` also - `NotifyBranchTagsChange`). Hook writes go through `HookManager`, which - records history and announces the change, as for the HTTP handler; - `decodeHookSettings` switches on type into the processor's own struct. -- **A block write reports from the operation, never a re-read** (the - realtime service persists on a debounce): `sanitizeBlock` once, ship the - canonical uids, return `blockRows` of the expanded tree. +- **Tools implement this package's `Tool`** (`Info`, `Execute`), never + eino's interfaces; only `eino.go` imports eino. A read may add `Title` + for its status line; every write adds `Summary`, which is also its + status line. Other surfaces use `Entry.Info` to describe and + `Entry.Tool.Run` to run. +- **Writes notify on `input`**: the method that changes the tree or a tag + announces it, so a tool never does. Hook writes go through + `HookManager`, which records history and announces the change, as for + the HTTP handler; `decodeHookSettings` switches on type into the + processor's own struct. +- **Content is markup** (`markup/`): `get_document` renders XML with ids, + writes `markup.Build` it against the stored tree (a kept id keeps its + uid, unchanged content keeps the stored node) and report from the build, + never a re-read. - **Branches are addressed by id, always.** Every content tool requires `branch_id`; listings carry the ids; `FetchDocumentByBranchID` refuses a branch of another document; an unknown id is refused naming the @@ -50,32 +48,33 @@ for free. deletes or merges a branch. MCP resources are `oxynote://documents/{id}/branches/{branch_id}`. - **Every error names the next step**: what was wrong, then the tool or - argument that fixes it (`describeOpError` rewrites the realtime service's - errors; `errUnknownDocument`, `errUnknownBlock`, `errBranchProtected`, - `errUnknownParent` follow suit). + argument that fixes it (`edit.Result.Err` rewrites the realtime + service's errors; `ErrUnknownDocument`, `errUnknownBlock`, + `errBranchProtected`, `errUnknownParent` follow suit). - **`Decode` is the only way into arguments.** Every tool has a `Args` type with `Validate() error` (`errRequired(key)`); ids are `xid.ID`, timestamps `time.Time`, enums self-validating; `encoding/json/v2` reports the JSON path. An empty string is invalid, never absent. - `Title`/`Summary` fetch what they name and use the row's name; an - unresolvable target is an error. `Input` is built per call, scoped to the - session's (organisation, user), with `Fetch*`-prefixed reads; + unresolvable target is no title, or an error from `Summary`. `*input` + is built per call, scoped to the session's (organisation, user), with + `Fetch*`-prefixed reads; one call's steps share a branch fetch. `DescribeInput` is its read-only half. -- **A write owns its invariants** on the `Input` method that performs it +- **A write owns its invariants** on the `input` method that performs it (`MoveDocument` refuses a missing parent or a move under its own subtree), - not in `Execute`. Block writes go through `ApplyEdit`, so their per-type - rules sit in `sanitizeBlock` and `sanitizeBlockAttrs` (`block.go`). -- **`Traits()` says what a tool is**; the mixins - `plainSummary`/`plainTraits`/`plainTitle` cover the defaults. `Write` - gates behind confirmation and protects the result from context + not in `Execute`. Block writes go through `ApplyEdit`. What core cannot + check, placement and content order, is auth-realtime's, against the + editor schema (`validate.ts`). +- **`Info.Traits` says what a tool is**; the zero value is a plain read. + `Write` gates behind confirmation and protects the result from context middlewares (only writes have a `Summary`; `Test_New` checks). `Destructive` keeps it outside "approve all" (the four `delete_*`). `Overwrites` means content the caller did not name is replaced - (`update_block_text`, `replace_block`). `DataSource` reads an outbound + (`replace_blocks`). `DataSource` reads an outbound connection (own MCP scope, open-world). `Internal` keeps it off other surfaces (`read_tool_output`). The registry applies the confirmation gate - from traits; MCP serves the registry ungated and maps traits to - annotations. + from traits; MCP serves the registry ungated and maps traits to scopes + and annotations. Tool descriptions stand alone (an MCP client may never show the prompt): what it does and returns, when to use it and which sibling instead, the diff --git a/server/core/internal/assistant/block/block.go b/server/core/internal/assistant/block/block.go deleted file mode 100644 index 51e9f31e..00000000 --- a/server/core/internal/assistant/block/block.go +++ /dev/null @@ -1,303 +0,0 @@ -// Package block defines the AI-facing block model that maps to the -// ProseMirror document schema used by the editor. The AI authors and -// reads documents in this form; the package's Expand function converts -// a canonical Block into a document.Block tree (with uids assigned) -// that hocuspocus can apply to a Y.Doc, and the Compact function does -// the reverse for cheap AI reads. -// -// The canonical surface intentionally hides nested wrapper nodes that -// the TipTap schema requires (list item wrappers, the left/right -// sides of splitDocumentation, the four-deep nesting of parameter -// list items). Compound types expose their structural children as -// typed top-level fields on Block (Items, Left, Right, Params, …) -// rather than smuggling them through Attrs — Attrs is reserved for -// configuration like a heading's level or a callout's icon. -package block - -import ( - "slices" - - "github.com/oxynote/oxynote/server/core/internal/document" -) - -// Type is the canonical name of a block as the AI sees it. -// Canonical names use snake_case to match the rest of the AI tool -// surface; they are distinct from the camelCase node names used by -// the underlying ProseMirror schema. -type Type string - -// Canonical block types. The list mirrors what the editor renders; -// any TipTap node type not listed here is either a macro internal -// (e.g. splitDocumentationLeftSide) or an inline node not exposed -// at the block level (e.g. text nodes). -const ( - // BlockParagraph is a standard paragraph. Carries inline text. - BlockParagraph Type = "paragraph" - - // BlockHeading is a heading block. Carries inline text. Required - // attr "level" must be 1, 2 or 3. - BlockHeading Type = "heading" - - // BlockBlockquote is a quoted paragraph. Carries inline text. - BlockBlockquote Type = "blockquote" - - // BlockBulletList is an unordered list. Items hold a Block each - // (typically a paragraph). - BlockBulletList Type = "bullet_list" - - // BlockOrderedList is an ordered list. Items hold a Block each - // (typically a paragraph). - BlockOrderedList Type = "ordered_list" - - // BlockTaskList is a task/checklist. TaskItems hold a Block plus - // a Checked flag per row. - BlockTaskList Type = "task_list" - - // BlockCallout is a callout block. Either Text (shorthand for a - // single-paragraph callout) or Items (paragraphs and/or lists) - // carry the content. Optional attr "icon" (defaults to - // "lucide:text"). - BlockCallout Type = "callout" - - // BlockCode is a fenced code block. Text holds raw code (no - // markdown parsing). Optional attr "language". - BlockCode Type = "code" - - // BlockTitledCode is a titled code block used inside the right - // side of split_doc. Text holds raw code; attrs "title" (plain - // text, required) and "language" (optional) name the block. - BlockTitledCode Type = "titled_code" - - // BlockMermaid is a mermaid diagram block. Text holds raw - // diagram source (no markdown). - BlockMermaid Type = "mermaid" - - // BlockHorizontalRule is a horizontal divider. - BlockHorizontalRule Type = "horizontal_rule" - - // BlockImage is an image block. Required attr "src"; optional - // "alt", "title", "width". - BlockImage Type = "image" - - // BlockFile is a file attachment. Attrs "src", "name", - // "contentType" and "size" describe the uploaded file. It is only - // ever created by uploading in the editor: the AI reads and moves - // one but never writes it. - BlockFile Type = "file" - - // BlockFigma is an embedded Figma frame. Required attr "src"; - // optional "width", "height". - BlockFigma Type = "figma" - - // BlockMetric is a metric visualization. Attrs carry the chart's - // configuration — the data source, the queries, the window, the - // display settings — validated by metric.go; attrs that layer does - // not name are preserved verbatim. - BlockMetric Type = "metric" - - // BlockMetricGrid wraps one or more metric blocks at the top - // level of a document. Items hold metric blocks. - BlockMetricGrid Type = "metric_grid" - - // BlockSplitDoc is the split-documentation macro. Left carries - // the heading-led prose (heading + paragraphs/lists/callouts + - // optional param_lists); Right carries the illustrative blocks - // (titled_code, metric). Optional attr "inversed" flips the - // visual order. - BlockSplitDoc Type = "split_doc" - - // BlockParamList is the parameter-list macro. Header holds the - // section header; Params holds the rows. - BlockParamList Type = "split_doc_param_list" -) - -// Label turns the type into a friendlier label for the confirm UI. -// Unknown types fall through verbatim. -func (t Type) Label() string { - switch t { - case BlockParagraph: - return "a paragraph" - case BlockHeading: - return "a heading" - case BlockBlockquote: - return "a blockquote" - case BlockBulletList: - return "a bullet list" - case BlockOrderedList: - return "an ordered list" - case BlockTaskList: - return "a task list" - case BlockCallout: - return "a callout" - case BlockCode: - return "a code block" - case BlockTitledCode: - return "a titled code block" - case BlockMermaid: - return "a mermaid diagram" - case BlockHorizontalRule: - return "a divider" - case BlockImage: - return "an image" - case BlockFile: - return "a file attachment" - case BlockFigma: - return "a figma embed" - case BlockMetric: - return "a metric" - case BlockMetricGrid: - return "a metric grid" - case BlockSplitDoc: - return "a split documentation block" - case BlockParamList: - return "a parameter list" - } - - if t == "" { - return "a block" - } - - return "a " + string(t) + " block" -} - -// Block is the canonical representation of a single block as the AI -// reads and writes it. Not every field is meaningful for every block -// type: -// -// - Text — paragraph, heading, blockquote, callout (shorthand), -// code, titled_code (the code body), mermaid. -// - Attrs — per-type configuration: heading.level, image.src/alt, -// callout.icon, code.language, split_doc.inversed, -// titled_code.title/language, etc. -// - Items — symmetric compound containers: bullet_list, -// ordered_list, callout (when not using Text shorthand), -// metric_grid. -// - TaskItems — task_list. -// - Left, Right — split_doc. -// - Header, Params — param_list. -// -// Validate enforces these constraints before Expand runs. -type Block struct { - // Type is the canonical block kind. - Type Type `json:"type"` - - // UID is the block's stable identifier. Optional on input: when - // empty, Expand generates a fresh nanoid; when set, Expand - // preserves it so the AI can reference the same block across - // turns. Compact always populates UID from the underlying - // document.Block. - UID string `json:"uid,omitempty"` - - // Text is the inline content of text-bearing blocks, expressed - // in the canonical minimal-markdown subset (see markdown.go). - // For code, titled_code, and mermaid, Text is treated as raw - // (no markdown parsing). - Text string `json:"text,omitempty"` - - // Attrs holds per-type configuration attributes. Legal keys and - // value types are defined by each Type's Schema entry; Validate - // rejects unknown or wrong-typed attrs before Expand runs. - Attrs document.Attributes `json:"attrs,omitempty"` - - // Items holds what a container block holds: a list's entries, a - // blockquote's or callout's contents, a metric_grid's metrics. - Items []Block `json:"items,omitempty"` - - // Children holds the tail of a single list or task-list item. The - // editor's item wrapper is a paragraph followed by any number of - // further blocks — everything indented under the entry — and Children - // is that second half. - // - // It describes a different relationship than Items: Items is what a - // container holds, Children is what hangs under one of its entries. - // Both can appear on one block, when an entry's own content is a list. - // Every position other than a list item rejects Children. - Children []Block `json:"children,omitempty"` - - // TaskItems holds the rows of a task_list. Each row pairs a - // content Block with a Checked flag. - TaskItems []TaskItem `json:"task_items,omitempty"` - - // Left is the left-side content of a split_doc. The first block - // must be a heading; subsequent blocks may be paragraphs, lists, - // callouts, or param_lists. - Left []Block `json:"left,omitempty"` - - // Right is the right-side content of a split_doc. Each block - // must be a titled_code, a metric or a mermaid. - Right []Block `json:"right,omitempty"` - - // Header is the section header of a param_list. Plain text (no - // markdown). - Header string `json:"header,omitempty"` - - // Params is the ordered list of parameter rows in a param_list. - Params []ParamItem `json:"params,omitempty"` -} - -// CollectAttributeValues returns every distinct string value the named -// attribute takes in this block and everything under it, in the order -// they were found. -// -// A block's children hang off whichever typed field its kind uses, so a -// caller that has to see all of them — checking what a whole subtree -// refers to before it is written, say — would otherwise walk five -// fields itself. -func (b Block) CollectAttributeValues(name string) []string { - var out []string - - b.collectAttributeValues(name, &out) - - return out -} - -// collectAttributeValues appends this block's value, then those of -// everything it contains, skipping values already collected. -func (b Block) collectAttributeValues(name string, out *[]string) { - if a, ok := b.Attrs.Value(name); ok { - if v, isString := a.(string); isString && v != "" && !slices.Contains(*out, v) { - *out = append(*out, v) - } - } - - for _, group := range [][]Block{b.Items, b.Children, b.Left, b.Right} { - for _, child := range group { - child.collectAttributeValues(name, out) - } - } - - for _, item := range b.TaskItems { - item.Block.collectAttributeValues(name, out) - } -} - -// TaskItem is one row of a task_list, carrying an explicit checked -// state alongside a content Block (typically a paragraph). -type TaskItem struct { - // UID is the row's stable identifier. Same semantics as - // Block.UID. - UID string `json:"uid,omitempty"` - - // Checked is whether the task is marked done. - Checked bool `json:"checked,omitempty"` - - // Block is the row's content block. - Block Block `json:"block"` -} - -// ParamItem is one row in a parameter list. -type ParamItem struct { - // UID is the row's stable identifier. Same semantics as - // Block.UID. - UID string `json:"uid,omitempty"` - - // Name is the parameter name (e.g. "size_limit"). Plain text. - Name string `json:"name"` - - // Type is the parameter type (e.g. "number", "string?", "User"). - // Plain text — free-form per the language being described. - Type string `json:"type,omitempty"` - - // Description is the parameter description. Minimal-markdown. - Description string `json:"description,omitempty"` -} diff --git a/server/core/internal/assistant/block/block_test.go b/server/core/internal/assistant/block/block_test.go deleted file mode 100644 index 54b99462..00000000 --- a/server/core/internal/assistant/block/block_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package block - -import ( - "testing" - - "github.com/oxynote/oxynote/server/core/internal/document" - "github.com/stretchr/testify/assert" -) - -func Test_Block_CollectAttributeValues(t *testing.T) { - t.Parallel() - - metric := func(id string) Block { - return Block{ - Type: BlockMetric, - Attrs: document.Attributes{document.AttrDataSourceID: id}, - } - } - - cc := map[string]struct { - Input Block - Expected []string - }{ - "A block that does not carry the attribute": { - Input: Block{Type: BlockParagraph, Text: "hello"}, - }, - "A block carrying it unset": { - Input: Block{ - Type: BlockMetric, - Attrs: document.Attributes{document.AttrDataSourceID: nil}, - }, - }, - "A block carrying it empty": { - Input: metric(""), - }, - "A block carrying it": { - Input: metric("a"), - Expected: []string{"a"}, - }, - "A value that is not a string": { - Input: Block{ - Type: BlockMetric, - Attrs: document.Attributes{document.AttrDataSourceID: 1}, - }, - }, - "Items report each distinct value once": { - Input: Block{ - Type: BlockMetricGrid, - Items: []Block{metric("a"), metric("b"), metric("a")}, - }, - Expected: []string{"a", "b"}, - }, - "A split_doc reports both sides": { - Input: Block{ - Type: BlockSplitDoc, - Left: []Block{{Type: BlockHeading, Text: "x"}}, - Right: []Block{metric("a")}, - }, - Expected: []string{"a"}, - }, - "A list item reports what is nested under it": { - Input: Block{ - Type: BlockBulletList, - Items: []Block{{ - Type: BlockParagraph, - Text: "x", - Children: []Block{metric("a")}, - }}, - }, - Expected: []string{"a"}, - }, - "A task item reports its content block": { - Input: Block{ - Type: BlockTaskList, - TaskItems: []TaskItem{{ - Block: Block{ - Type: BlockParagraph, - Text: "x", - Children: []Block{metric("a")}, - }, - }}, - }, - Expected: []string{"a"}, - }, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - assert.Equal(t, c.Expected, c.Input.CollectAttributeValues(document.AttrDataSourceID)) - }) - } -} - -func Test_Type_Label(t *testing.T) { - cc := map[string]struct { - Kind Type - Result string - }{ - "Paragraph": {Kind: "paragraph", Result: "a paragraph"}, - "Heading": {Kind: "heading", Result: "a heading"}, - "Blockquote": {Kind: "blockquote", Result: "a blockquote"}, - "Bullet list": {Kind: "bullet_list", Result: "a bullet list"}, - "Ordered list": {Kind: "ordered_list", Result: "an ordered list"}, - "Task list": {Kind: "task_list", Result: "a task list"}, - "Callout": {Kind: "callout", Result: "a callout"}, - "Code": {Kind: "code", Result: "a code block"}, - "Titled code": {Kind: "titled_code", Result: "a titled code block"}, - "Mermaid": {Kind: "mermaid", Result: "a mermaid diagram"}, - "Horizontal rule": {Kind: "horizontal_rule", Result: "a divider"}, - "Image": {Kind: "image", Result: "an image"}, - "File": {Kind: "file", Result: "a file attachment"}, - "Figma": {Kind: "figma", Result: "a figma embed"}, - "Metric": {Kind: "metric", Result: "a metric"}, - "Metric grid": {Kind: "metric_grid", Result: "a metric grid"}, - "Split doc": {Kind: "split_doc", Result: "a split documentation block"}, - "Param list": {Kind: "split_doc_param_list", Result: "a parameter list"}, - "Empty": {Kind: "", Result: "a block"}, - "Unknown": {Kind: "wibble", Result: "a wibble block"}, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - assert.Equal(t, c.Result, c.Kind.Label()) - }) - } -} diff --git a/server/core/internal/assistant/block/compact.go b/server/core/internal/assistant/block/compact.go deleted file mode 100644 index 705286f1..00000000 --- a/server/core/internal/assistant/block/compact.go +++ /dev/null @@ -1,533 +0,0 @@ -package block - -import ( - "fmt" - - "github.com/oxynote/oxynote/server/core/internal/document" -) - -// Compact converts a document.Block (ProseMirror JSON) into the -// canonical Block representation surfaced to the AI. Wrapper nodes -// the AI never authors (listItem, splitDocumentationLeftSide, -// splitDocumentationParameterListItem, …) are folded into the -// enclosing macro's typed fields. A listItem or taskItem compacted on -// its own yields its entry paragraph, as its list would hold it. -// -// The returned Block preserves the source block's uid attribute so -// the AI can refer to it in subsequent edit operations. -func Compact(b document.Block) (Block, error) { - uid, _ := b.UID() - - switch b.Type { - case document.BlockNodeParagraph: - return compactParagraph(b, uid), nil - case document.BlockNodeHeading: - return compactHeading(b, uid), nil - case document.BlockNodeBlockquote: - return compactBlockquote(b, uid) - case document.BlockNodeBulletList: - return compactList(b, uid, BlockBulletList) - case document.BlockNodeOrderedList: - return compactList(b, uid, BlockOrderedList) - case document.BlockNodeTaskList: - return compactTaskList(b, uid) - case document.BlockNodeListItem, document.BlockNodeTaskItem: - return compactEntry(b) - case document.BlockNodeCalloutBlock: - return compactCallout(b, uid) - case document.BlockNodeCodeBlock: - return compactCode(b, uid), nil - case document.BlockNodeTitledCodeBlock: - return compactTitledCode(b, uid), nil - case document.BlockNodeMermaidBlock: - return compactMermaid(b, uid), nil - case document.BlockNodeHorizontalRule: - return Block{ - Type: BlockHorizontalRule, - UID: uid, - }, nil - case document.BlockNodeImageBlock: - return compactImage(b, uid), nil - case document.BlockNodeFileBlock: - return compactFile(b, uid), nil - case document.BlockNodeFigmaBlock: - return compactFigma(b, uid), nil - case document.BlockNodeMetricBlock: - return compactMetric(b, uid), nil - case document.BlockNodeMetricGrid: - return compactMetricGrid(b, uid) - case document.BlockNodeSplitDoc: - return compactSplitDoc(b, uid) - case document.BlockNodeParamList: - return compactParamList(b, uid) - default: - return Block{}, fmt.Errorf("unsupported ProseMirror type %q", b.Type) - } -} - -// compactMany compacts a slice of document.Blocks, short-circuiting -// on the first error. -func compactMany(blocks []document.Block) ([]Block, error) { - if len(blocks) == 0 { - return nil, nil - } - - out := make([]Block, 0, len(blocks)) - - for i, b := range blocks { - c, err := Compact(b) - if err != nil { - return nil, fmt.Errorf("compacting block %d (%s): %w", i, b.Type, err) - } - - out = append(out, c) - } - - return out, nil -} - -// compactParagraph compacts a paragraph node, emitting its inline -// content as minimal-markdown text. -func compactParagraph(b document.Block, uid string) Block { - return Block{ - Type: BlockParagraph, - UID: uid, - Text: emitInlineMarkdown(b.Content), - } -} - -// compactHeading compacts a heading node, carrying the level attr -// alongside its plain text. -func compactHeading(b document.Block, uid string) Block { - level := 1 - if a, ok := b.Attrs.Get(document.AttrLevel); ok { - level = a.Int() - } - - return Block{ - Type: BlockHeading, - UID: uid, - Text: flattenText(b.Content), - Attrs: document.Attributes{document.AttrLevel: level}, - } -} - -// compactBlockquote compacts a blockquote node. The canonical model -// treats blockquote as a single-paragraph wrapper: when the source -// has exactly one paragraph child, its text is promoted; otherwise -// Items carries the content so callers can still see it. -func compactBlockquote(b document.Block, uid string) (Block, error) { - if text, ok := singleParagraphText(b.Content); ok { - return Block{ - Type: BlockBlockquote, - UID: uid, - Text: text, - }, nil - } - - items, err := compactMany(b.Content) - if err != nil { - return Block{}, fmt.Errorf("blockquote: %w", err) - } - - return Block{ - Type: BlockBlockquote, - UID: uid, - Items: items, - }, nil -} - -// compactList compacts a bulletList or orderedList by unwrapping -// each listItem and compacting its single content child. -func compactList(b document.Block, uid string, kind Type) (Block, error) { - items := make([]Block, 0, len(b.Content)) - - for i, li := range b.Content { - if li.Type != document.BlockNodeListItem { - return Block{}, fmt.Errorf("%s child %d: expected listItem, got %s", kind, i, li.Type) - } - - inner, err := compactEntry(li) - if err != nil { - return Block{}, fmt.Errorf("%s item %d: %w", kind, i, err) - } - - items = append(items, inner) - } - - return Block{ - Type: kind, - UID: uid, - Items: items, - }, nil -} - -// compactEntry compacts a listItem or taskItem into the canonical list -// entry: its leading paragraph, with whatever follows that paragraph, -// typically a nested list, as the paragraph's children. Dropping the -// rest would delete it from the document on the way back through -// replace_block. -func compactEntry(item document.Block) (Block, error) { - if len(item.Content) == 0 { - return Block{Type: BlockParagraph}, nil - } - - inner, err := Compact(item.Content[0]) - if err != nil { - return Block{}, err - } - - inner.Children, err = compactMany(item.Content[1:]) - if err != nil { - return Block{}, err - } - - return inner, nil -} - -// compactTaskList compacts a taskList by unwrapping each taskItem -// into a canonical TaskItem. -func compactTaskList(b document.Block, uid string) (Block, error) { - items := make([]TaskItem, 0, len(b.Content)) - - for i, ti := range b.Content { - if ti.Type != document.BlockNodeTaskItem { - return Block{}, fmt.Errorf("task_list child %d: expected taskItem, got %s", i, ti.Type) - } - - itemUID, _ := ti.UID() - - var checked bool - if a, ok := ti.Attrs.Get(document.AttrChecked); ok { - checked = a.Bool() - } - - inner, err := compactEntry(ti) - if err != nil { - return Block{}, fmt.Errorf("task_list item %d: %w", i, err) - } - - items = append(items, TaskItem{ - UID: itemUID, - Checked: checked, - Block: inner, - }) - } - - return Block{ - Type: BlockTaskList, - UID: uid, - TaskItems: items, - }, nil -} - -// compactCallout compacts a calloutBlock. When the callout holds a -// single paragraph child, the canonical block uses the Text -// shorthand; otherwise Items carries the child blocks verbatim. -func compactCallout(b document.Block, uid string) (Block, error) { - attrs := document.Attributes{} - if a, ok := b.Attrs.Get(document.AttrIcon); ok && a.String() != "" { - attrs[document.AttrIcon] = a.String() - } - - if text, ok := singleParagraphText(b.Content); ok { - return Block{ - Type: BlockCallout, - UID: uid, - Text: text, - Attrs: attrsOrNil(attrs), - }, nil - } - - items, err := compactMany(b.Content) - if err != nil { - return Block{}, fmt.Errorf("callout items: %w", err) - } - - return Block{ - Type: BlockCallout, - UID: uid, - Items: items, - Attrs: attrsOrNil(attrs), - }, nil -} - -// compactCode compacts a codeBlock node, flattening its content -// into raw text (no markdown emission). -func compactCode(b document.Block, uid string) Block { - attrs := document.Attributes{} - if a, ok := b.Attrs.Get(document.AttrLanguage); ok && a.String() != "" { - attrs[document.AttrLanguage] = a.String() - } - - return Block{ - Type: BlockCode, - UID: uid, - Text: flattenText(b.Content), - Attrs: attrsOrNil(attrs), - } -} - -// compactTitledCode reads the title from the first child -// (codeBlockTitle) and the body from the second child (codeBlock). -// Defensive on malformed input: missing children produce empty -// title/body rather than an error. -func compactTitledCode(b document.Block, uid string) Block { - attrs := document.Attributes{} - - var ( - title string - body string - lang string - ) - - for _, child := range b.Content { - switch child.Type { - case document.BlockNodeCodeBlockTitle: - title = flattenText(child.Content) - case document.BlockNodeCodeBlock: - body = flattenText(child.Content) - - if a, ok := child.Attrs.Get(document.AttrLanguage); ok { - lang = a.String() - } - default: - } - } - - if title != "" { - attrs[document.AttrTitle] = title - } - - if lang != "" { - attrs[document.AttrLanguage] = lang - } - - return Block{ - Type: BlockTitledCode, - UID: uid, - Text: body, - Attrs: attrsOrNil(attrs), - } -} - -// compactMermaid compacts a mermaidBlock node, flattening its -// content into raw diagram source. -func compactMermaid(b document.Block, uid string) Block { - return Block{ - Type: BlockMermaid, - UID: uid, - Text: flattenText(b.Content), - } -} - -// compactImage compacts an imageBlock atom node, surfacing its -// src/alt/title/width attributes. -func compactImage(b document.Block, uid string) Block { - attrs := document.Attributes{} - - copyStringAttrs(attrs, b.Attrs, document.AttrSrc, document.AttrAlt, document.AttrTitle) - copyPositiveIntAttrs(attrs, b.Attrs, document.AttrWidth) - - return Block{ - Type: BlockImage, - UID: uid, - Attrs: attrsOrNil(attrs), - } -} - -// compactFile compacts a fileBlock atom node, surfacing its -// src/name/contentType/size attributes. -func compactFile(b document.Block, uid string) Block { - attrs := document.Attributes{} - - copyStringAttrs(attrs, b.Attrs, document.AttrSrc, document.AttrName, document.AttrContentType) - copyPositiveIntAttrs(attrs, b.Attrs, document.AttrSize) - - return Block{ - Type: BlockFile, - UID: uid, - Attrs: attrsOrNil(attrs), - } -} - -// compactFigma compacts a figmaBlock atom node, surfacing its -// src/width/height attributes. -func compactFigma(b document.Block, uid string) Block { - attrs := document.Attributes{} - - copyStringAttrs(attrs, b.Attrs, document.AttrSrc) - copyPositiveIntAttrs(attrs, b.Attrs, document.AttrWidth, document.AttrHeight) - - return Block{ - Type: BlockFigma, - UID: uid, - Attrs: attrsOrNil(attrs), - } -} - -// compactMetric keeps every attribute of the source metric block except -// its uid and its simulation flag. The configuration shape is opaque to -// the canonical layer, so it passes through as is. The flag is internal -// state that a reader never sees. -func compactMetric(b document.Block, uid string) Block { - attrs := b.Attrs.Copy() - delete(attrs, document.AttrUID) - delete(attrs, document.AttrSimulationActive) - - return Block{ - Type: BlockMetric, - UID: uid, - Attrs: attrsOrNil(attrs), - } -} - -// compactMetricGrid compacts a metricGrid node, carrying each child -// metric in Items. -func compactMetricGrid(b document.Block, uid string) (Block, error) { - items, err := compactMany(b.Content) - if err != nil { - return Block{}, fmt.Errorf("metric_grid items: %w", err) - } - - return Block{ - Type: BlockMetricGrid, - UID: uid, - Items: items, - }, nil -} - -// compactSplitDoc folds the splitDocumentationLeftSide and -// splitDocumentationRightSide wrapper nodes into the canonical -// block's Left/Right fields. -func compactSplitDoc(b document.Block, uid string) (Block, error) { - out := Block{Type: BlockSplitDoc, UID: uid} - - if a, ok := b.Attrs.Get(document.AttrInversed); ok && a.Bool() { - out.Attrs = document.Attributes{document.AttrInversed: true} - } - - for _, side := range b.Content { - switch side.Type { - case document.BlockNodeSplitDocLeft: - items, err := compactMany(side.Content) - if err != nil { - return Block{}, fmt.Errorf("split_doc left: %w", err) - } - - out.Left = items - case document.BlockNodeSplitDocRight: - items, err := compactMany(side.Content) - if err != nil { - return Block{}, fmt.Errorf("split_doc right: %w", err) - } - - out.Right = items - default: - } - } - - return out, nil -} - -// compactParamList unwraps the parameter-list tree, surfacing the -// header text and each item's name / type / description. -func compactParamList(b document.Block, uid string) (Block, error) { - out := Block{Type: BlockParamList, UID: uid} - - for _, child := range b.Content { - switch child.Type { - case document.BlockNodeParamListHeader: - out.Header = flattenText(child.Content) - case document.BlockNodeParamListItem: - out.Params = append(out.Params, compactParamListItem(child)) - default: - } - } - - return out, nil -} - -// compactParamListItem turns one splitDocumentationParameterListItem -// node into a canonical ParamItem. -func compactParamListItem(b document.Block) ParamItem { - uid, _ := b.UID() - item := ParamItem{UID: uid} - - for _, child := range b.Content { - switch child.Type { - case document.BlockNodeParamListItemHeader: - for _, sub := range child.Content { - switch sub.Type { - case document.BlockNodeParamListItemTitle: - item.Name = flattenText(sub.Content) - case document.BlockNodeParamListItemType: - item.Type = flattenText(sub.Content) - default: - } - } - case document.BlockNodeParagraph: - item.Description = emitInlineMarkdown(child.Content) - default: - } - } - - return item -} - -// singleParagraphText reports whether content is exactly one -// paragraph node and, when it is, returns that paragraph's inline -// content emitted as minimal-markdown text. -func singleParagraphText(content []document.Block) (string, bool) { - if len(content) == 1 && content[0].Type == document.BlockNodeParagraph { - return emitInlineMarkdown(content[0].Content), true - } - - return "", false -} - -// copyStringAttrs copies each named attribute from src into dst when -// it is present with a non-empty string value. -func copyStringAttrs(dst, src document.Attributes, keys ...string) { - for _, k := range keys { - if a, ok := src.Get(k); ok && a.String() != "" { - dst[k] = a.String() - } - } -} - -// copyPositiveIntAttrs copies each named attribute from src into dst -// when it is present with a positive integer value. -func copyPositiveIntAttrs(dst, src document.Attributes, keys ...string) { - for _, k := range keys { - if a, ok := src.Get(k); ok && a.Int() > 0 { - dst[k] = a.Int() - } - } -} - -// flattenText concatenates the text of inline text nodes ignoring -// marks. Used for code/mermaid/header bodies that carry raw -// non-markdown text. -func flattenText(content []document.Block) string { - var out []byte - - for _, n := range content { - if n.Type == document.BlockNodeText { - out = append(out, n.Text...) - } - } - - return string(out) -} - -// attrsOrNil returns m when it has at least one entry, nil -// otherwise. Compact output uses nil maps rather than empty ones so -// JSON-marshalled canonical blocks omit the field cleanly. -func attrsOrNil(m document.Attributes) document.Attributes { - if len(m) == 0 { - return nil - } - - return m -} diff --git a/server/core/internal/assistant/block/compact_test.go b/server/core/internal/assistant/block/compact_test.go deleted file mode 100644 index 464a2c2f..00000000 --- a/server/core/internal/assistant/block/compact_test.go +++ /dev/null @@ -1,585 +0,0 @@ -package block - -import ( - "testing" - - "github.com/oxynote/oxynote/server/core/internal/document" - "github.com/oxynote/oxynote/server/core/pkg/testutil" - "github.com/stretchr/testify/assert" - "go.uber.org/goleak" -) - -func TestMain(m *testing.M) { - goleak.VerifyTestMain(m) -} - -func Test_Compact(t *testing.T) { - cc := map[string]struct { - Input document.Block - Expected Block - Err error - }{ - "Paragraph emits markdown": { - Input: document.Block{ - Type: document.BlockNodeParagraph, - Attrs: map[string]any{"uid": "p1"}, - Content: []document.Block{ - {Type: "text", Text: "hi "}, - {Type: "text", Text: "bold", Marks: []document.Mark{{Type: "bold"}}}, - }, - }, - Expected: Block{Type: BlockParagraph, UID: "p1", Text: "hi **bold**"}, - }, - "Heading text is read plain": { - Input: document.Block{ - Type: document.BlockNodeHeading, - Attrs: map[string]any{"uid": "h1", "level": 2}, - Content: []document.Block{ - {Type: "text", Text: "my_var "}, - {Type: "text", Text: "noted", Marks: []document.Mark{{Type: "comment"}}}, - }, - }, - Expected: Block{Type: BlockHeading, UID: "h1", Text: "my_var noted", Attrs: map[string]any{"level": 2}}, - }, - "Callout with single paragraph uses Text shorthand": { - Input: document.Block{ - Type: document.BlockNodeCalloutBlock, - Attrs: map[string]any{"uid": "c1", "icon": "lucide:warning"}, - Content: []document.Block{ - {Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "watch out"}}}, - }, - }, - Expected: Block{ - Type: BlockCallout, - UID: "c1", - Text: "watch out", - Attrs: map[string]any{"icon": "lucide:warning"}, - }, - }, - "Split doc unwraps sides into Left and Right": { - Input: document.Block{ - Type: document.BlockNodeSplitDoc, - Attrs: map[string]any{"uid": "s1", "inversed": true}, - Content: []document.Block{ - { - Type: document.BlockNodeSplitDocLeft, - Content: []document.Block{ - {Type: document.BlockNodeHeading, Attrs: map[string]any{"uid": "h1", "level": 2}, Content: []document.Block{{Type: "text", Text: "API"}}}, - }, - }, - { - Type: document.BlockNodeSplitDocRight, - Content: []document.Block{}, - }, - }, - }, - Expected: Block{ - Type: BlockSplitDoc, - UID: "s1", - Attrs: map[string]any{"inversed": true}, - Left: []Block{ - {Type: BlockHeading, UID: "h1", Text: "API", Attrs: map[string]any{"level": 2}}, - }, - }, - }, - "Ordered list unwraps list items": { - Input: document.Block{ - Type: document.BlockNodeOrderedList, - Attrs: map[string]any{"uid": "ol1"}, - Content: []document.Block{ - { - Type: document.BlockNodeListItem, - Content: []document.Block{ - {Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "one"}}}, - }, - }, - }, - }, - Expected: Block{ - Type: BlockOrderedList, - UID: "ol1", - Items: []Block{{Type: BlockParagraph, Text: "one"}}, - }, - }, - "Empty list item becomes an empty paragraph": { - Input: document.Block{ - Type: document.BlockNodeBulletList, - Content: []document.Block{{Type: document.BlockNodeListItem}}, - }, - Expected: Block{ - Type: BlockBulletList, - Items: []Block{{Type: BlockParagraph}}, - }, - }, - // The item wrapper holds a paragraph plus whatever is nested under - // it; dropping the rest deletes it from the document on write-back. - "List item keeps its nested list": { - Input: document.Block{ - Type: document.BlockNodeBulletList, - Attrs: map[string]any{"uid": "l1"}, - Content: []document.Block{ - { - Type: document.BlockNodeListItem, - Content: []document.Block{ - { - Type: document.BlockNodeParagraph, - Attrs: map[string]any{"uid": "p1"}, - Content: []document.Block{{Type: "text", Text: "one"}}, - }, - { - Type: document.BlockNodeBulletList, - Attrs: map[string]any{"uid": "l2"}, - Content: []document.Block{ - { - Type: document.BlockNodeListItem, - Content: []document.Block{ - { - Type: document.BlockNodeParagraph, - Attrs: map[string]any{"uid": "p2"}, - Content: []document.Block{{Type: "text", Text: "nested"}}, - }, - }, - }, - }, - }, - }, - }, - }, - }, - Expected: Block{ - Type: BlockBulletList, - UID: "l1", - Items: []Block{{ - Type: BlockParagraph, - UID: "p1", - Text: "one", - Children: []Block{{ - Type: BlockBulletList, - UID: "l2", - Items: []Block{{Type: BlockParagraph, UID: "p2", Text: "nested"}}, - }}, - }}, - }, - }, - "Task item keeps its nested list": { - Input: document.Block{ - Type: document.BlockNodeTaskList, - Attrs: map[string]any{"uid": "t1"}, - Content: []document.Block{ - { - Type: document.BlockNodeTaskItem, - Attrs: map[string]any{"uid": "ti1", "checked": true}, - Content: []document.Block{ - { - Type: document.BlockNodeParagraph, - Attrs: map[string]any{"uid": "p1"}, - Content: []document.Block{{Type: "text", Text: "one"}}, - }, - { - Type: document.BlockNodeBulletList, - Attrs: map[string]any{"uid": "l2"}, - Content: []document.Block{ - { - Type: document.BlockNodeListItem, - Content: []document.Block{ - { - Type: document.BlockNodeParagraph, - Attrs: map[string]any{"uid": "p2"}, - Content: []document.Block{{Type: "text", Text: "nested"}}, - }, - }, - }, - }, - }, - }, - }, - }, - }, - Expected: Block{ - Type: BlockTaskList, - UID: "t1", - TaskItems: []TaskItem{{ - UID: "ti1", - Checked: true, - Block: Block{ - Type: BlockParagraph, - UID: "p1", - Text: "one", - Children: []Block{{ - Type: BlockBulletList, - UID: "l2", - Items: []Block{{Type: BlockParagraph, UID: "p2", Text: "nested"}}, - }}, - }, - }}, - }, - }, - "List with non-listItem child fails": { - Input: document.Block{ - Type: document.BlockNodeBulletList, - Content: []document.Block{{Type: document.BlockNodeParagraph}}, - }, - Err: assert.AnError, - }, - "List item with unsupported content fails": { - Input: document.Block{ - Type: document.BlockNodeBulletList, - Content: []document.Block{ - {Type: document.BlockNodeListItem, Content: []document.Block{{Type: "weirdNode"}}}, - }, - }, - Err: assert.AnError, - }, - "List item with unsupported nested child fails": { - Input: document.Block{ - Type: document.BlockNodeBulletList, - Content: []document.Block{ - { - Type: document.BlockNodeListItem, - Content: []document.Block{ - {Type: document.BlockNodeParagraph}, - {Type: "weirdNode"}, - }, - }, - }, - }, - Err: assert.AnError, - }, - "Blockquote with multiple paragraphs falls back to items": { - Input: document.Block{ - Type: document.BlockNodeBlockquote, - Attrs: map[string]any{"uid": "q1"}, - Content: []document.Block{ - {Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "a"}}}, - {Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "b"}}}, - }, - }, - Expected: Block{ - Type: BlockBlockquote, - UID: "q1", - Items: []Block{ - {Type: BlockParagraph, Text: "a"}, - {Type: BlockParagraph, Text: "b"}, - }, - }, - }, - "Blockquote with unsupported child fails": { - Input: document.Block{ - Type: document.BlockNodeBlockquote, - Content: []document.Block{{Type: "weirdNode"}}, - }, - Err: assert.AnError, - }, - "Task list with non-taskItem child fails": { - Input: document.Block{ - Type: document.BlockNodeTaskList, - Content: []document.Block{{Type: document.BlockNodeParagraph}}, - }, - Err: assert.AnError, - }, - "Task item without content becomes an empty paragraph": { - Input: document.Block{ - Type: document.BlockNodeTaskList, - Content: []document.Block{ - {Type: document.BlockNodeTaskItem, Attrs: map[string]any{"uid": "t1", "checked": true}}, - }, - }, - Expected: Block{ - Type: BlockTaskList, - TaskItems: []TaskItem{ - {UID: "t1", Checked: true, Block: Block{Type: BlockParagraph}}, - }, - }, - }, - "Task item with unsupported content fails": { - Input: document.Block{ - Type: document.BlockNodeTaskList, - Content: []document.Block{ - {Type: document.BlockNodeTaskItem, Content: []document.Block{{Type: "weirdNode"}}}, - }, - }, - Err: assert.AnError, - }, - "Task item with unsupported nested child fails": { - Input: document.Block{ - Type: document.BlockNodeTaskList, - Content: []document.Block{ - { - Type: document.BlockNodeTaskItem, - Content: []document.Block{ - {Type: document.BlockNodeParagraph}, - {Type: "weirdNode"}, - }, - }, - }, - }, - Err: assert.AnError, - }, - "Callout with multiple children falls back to items": { - Input: document.Block{ - Type: document.BlockNodeCalloutBlock, - Content: []document.Block{ - {Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "a"}}}, - {Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "b"}}}, - }, - }, - Expected: Block{ - Type: BlockCallout, - Items: []Block{ - {Type: BlockParagraph, Text: "a"}, - {Type: BlockParagraph, Text: "b"}, - }, - }, - }, - "Callout with unsupported child fails": { - Input: document.Block{ - Type: document.BlockNodeCalloutBlock, - Content: []document.Block{{Type: "weirdNode"}}, - }, - Err: assert.AnError, - }, - "Code without language omits attrs": { - Input: document.Block{ - Type: document.BlockNodeCodeBlock, - Content: []document.Block{{Type: "text", Text: "x := 1"}}, - }, - Expected: Block{Type: BlockCode, Text: "x := 1"}, - }, - "Titled code ignores unknown children": { - Input: document.Block{ - Type: document.BlockNodeTitledCodeBlock, - Content: []document.Block{ - {Type: document.BlockNodeCodeBlockTitle, Content: []document.Block{{Type: "text", Text: "ex.go"}}}, - {Type: document.BlockNodeCodeBlock, Attrs: map[string]any{"language": "go"}, Content: []document.Block{{Type: "text", Text: "x"}}}, - {Type: "weirdNode"}, - }, - }, - Expected: Block{ - Type: BlockTitledCode, - Text: "x", - Attrs: map[string]any{"title": "ex.go", "language": "go"}, - }, - }, - "Image carries title and width attrs": { - Input: document.Block{ - Type: document.BlockNodeImageBlock, - Attrs: map[string]any{"uid": "i1", "src": "http://x", "title": "shot", "width": 200}, - }, - Expected: Block{ - Type: BlockImage, - UID: "i1", - Attrs: map[string]any{"src": "http://x", "title": "shot", "width": 200}, - }, - }, - "File carries src, name, content type and size attrs": { - Input: document.Block{ - Type: document.BlockNodeFileBlock, - Attrs: map[string]any{ - "uid": "f1", "src": "http://x", "name": "notes.zip", "contentType": "application/zip", "size": 2048, "uploading": false, - }, - }, - Expected: Block{ - Type: BlockFile, - UID: "f1", - Attrs: map[string]any{"src": "http://x", "name": "notes.zip", "contentType": "application/zip", "size": 2048}, - }, - }, - "File without attrs": { - Input: document.Block{ - Type: document.BlockNodeFileBlock, - Attrs: map[string]any{"uid": "f1"}, - }, - Expected: Block{ - Type: BlockFile, - UID: "f1", - }, - }, - "Metric passes attrs through without uid": { - Input: document.Block{ - Type: document.BlockNodeMetricBlock, - Attrs: map[string]any{"uid": "m1", "query": "up", "unit": "%"}, - }, - Expected: Block{ - Type: BlockMetric, - UID: "m1", - Attrs: map[string]any{"query": "up", "unit": "%"}, - }, - }, - "Metric keeps its preset and hides its simulation flag": { - Input: document.Block{ - Type: document.BlockNodeMetricBlock, - Attrs: map[string]any{ - "uid": "m1", - "simulationPreset": "http_latency", - "simulationActive": true, - }, - }, - Expected: Block{ - Type: BlockMetric, - UID: "m1", - Attrs: map[string]any{"simulationPreset": "http_latency"}, - }, - }, - // a metric written before this layer knew the configuration: a - // legacy config blob, explicit nulls for unset fields, and an - // attribute it still does not name. Reading it must leave all - // of them alone, or typing the schema would break documents - // already in the system. - "Metric keeps a legacy config blob, nulls and unknown attrs": { - Input: document.Block{ - Type: document.BlockNodeMetricBlock, - Attrs: map[string]any{ - "uid": "m1", - "config": map[string]any{"type": "line_chart"}, - "visualizationType": nil, - "wibble": 42, - }, - }, - Expected: Block{ - Type: BlockMetric, - UID: "m1", - Attrs: map[string]any{ - "config": map[string]any{"type": "line_chart"}, - "visualizationType": nil, - "wibble": 42, - }, - }, - }, - "Metric grid wraps metric items": { - Input: document.Block{ - Type: document.BlockNodeMetricGrid, - Content: []document.Block{ - {Type: document.BlockNodeMetricBlock, Attrs: map[string]any{"uid": "m1", "query": "up"}}, - }, - }, - Expected: Block{ - Type: BlockMetricGrid, - Items: []Block{ - {Type: BlockMetric, UID: "m1", Attrs: map[string]any{"query": "up"}}, - }, - }, - }, - "Metric grid with unsupported child fails": { - Input: document.Block{ - Type: document.BlockNodeMetricGrid, - Content: []document.Block{{Type: "weirdNode"}}, - }, - Err: assert.AnError, - }, - "Split doc left failure propagates": { - Input: document.Block{ - Type: document.BlockNodeSplitDoc, - Content: []document.Block{ - {Type: document.BlockNodeSplitDocLeft, Content: []document.Block{{Type: "weirdNode"}}}, - }, - }, - Err: assert.AnError, - }, - "Split doc right failure propagates": { - Input: document.Block{ - Type: document.BlockNodeSplitDoc, - Content: []document.Block{ - {Type: document.BlockNodeSplitDocRight, Content: []document.Block{{Type: "weirdNode"}}}, - }, - }, - Err: assert.AnError, - }, - "Split doc ignores unknown side nodes": { - Input: document.Block{ - Type: document.BlockNodeSplitDoc, - Content: []document.Block{{Type: "weirdSide"}}, - }, - Expected: Block{Type: BlockSplitDoc}, - }, - "List item compacts to its entry paragraph with its nested blocks": { - Input: document.Block{ - Type: document.BlockNodeListItem, - Attrs: map[string]any{"uid": "li1"}, - Content: []document.Block{ - {Type: document.BlockNodeParagraph, Attrs: map[string]any{"uid": "p1"}, Content: []document.Block{{Type: "text", Text: "one"}}}, - { - Type: document.BlockNodeBulletList, - Attrs: map[string]any{"uid": "l2"}, - Content: []document.Block{ - { - Type: document.BlockNodeListItem, - Content: []document.Block{ - {Type: document.BlockNodeParagraph, Attrs: map[string]any{"uid": "p2"}, Content: []document.Block{{Type: "text", Text: "two"}}}, - }, - }, - }, - }, - }, - }, - Expected: Block{ - Type: BlockParagraph, - UID: "p1", - Text: "one", - Children: []Block{ - {Type: BlockBulletList, UID: "l2", Items: []Block{{Type: BlockParagraph, UID: "p2", Text: "two"}}}, - }, - }, - }, - "Task item compacts to its entry paragraph": { - Input: document.Block{ - Type: document.BlockNodeTaskItem, - Attrs: map[string]any{"uid": "t1", "checked": true}, - Content: []document.Block{ - {Type: document.BlockNodeParagraph, Attrs: map[string]any{"uid": "p1"}, Content: []document.Block{{Type: "text", Text: "done"}}}, - }, - }, - Expected: Block{Type: BlockParagraph, UID: "p1", Text: "done"}, - }, - "Empty list item compacts to an empty paragraph": { - Input: document.Block{Type: document.BlockNodeListItem}, - Expected: Block{Type: BlockParagraph}, - }, - "Unsupported node type fails": { - Input: document.Block{Type: "weirdNode"}, - Err: assert.AnError, - }, - "Param list ignores unknown children at every level": { - Input: document.Block{ - Type: document.BlockNodeParamList, - Content: []document.Block{ - {Type: document.BlockNodeParamListHeader, Content: []document.Block{{Type: "text", Text: "Body"}}}, - { - Type: document.BlockNodeParamListItem, - Content: []document.Block{ - { - Type: document.BlockNodeParamListItemHeader, - Content: []document.Block{ - {Type: document.BlockNodeParamListItemTitle, Content: []document.Block{{Type: "text", Text: "id"}}}, - {Type: "weirdSub"}, - }, - }, - {Type: "weirdChild"}, - }, - }, - {Type: "weirdRow"}, - }, - }, - Expected: Block{ - Type: BlockParamList, - Header: "Body", - Params: []ParamItem{{Name: "id"}}, - }, - }, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - got, err := Compact(c.Input) - - testutil.AssertEqualError(t, c.Err, err) - - if c.Err != nil { - return - } - - assert.Equal(t, c.Expected, got) - }) - } -} diff --git a/server/core/internal/assistant/block/expand.go b/server/core/internal/assistant/block/expand.go deleted file mode 100644 index b88bce33..00000000 --- a/server/core/internal/assistant/block/expand.go +++ /dev/null @@ -1,520 +0,0 @@ -package block - -import ( - "fmt" - - "github.com/oxynote/oxynote/server/core/internal/document" - "github.com/oxynote/oxynote/server/core/pkg/strutil" -) - -// Expand converts a canonical Block into a document.Block ready to -// be applied to a Y.Doc. Every node in the resulting tree carries a -// "uid" attribute: when the input supplies one (Block.UID, -// TaskItem.UID, ParamItem.UID, or an attr on a passed-through -// metric block), Expand preserves it; otherwise a fresh nanoid is -// generated. Macro types (split_doc, param_list) construct the -// nested TipTap wrapper nodes that the editor's schema requires. -// -// Expand assumes the input has already been validated by Validate. -// Calling Expand on an invalid block may produce a document.Block -// that hocuspocus rejects when applied. -func Expand(b Block) (document.Block, error) { - switch b.Type { - case BlockParagraph: - return expandParagraph(b), nil - case BlockHeading: - return expandHeading(b), nil - case BlockBlockquote: - return expandBlockquote(b) - case BlockBulletList: - return expandBulletOrOrderedList(b, document.BlockNodeBulletList) - case BlockOrderedList: - return expandBulletOrOrderedList(b, document.BlockNodeOrderedList) - case BlockTaskList: - return expandTaskList(b) - case BlockCallout: - return expandCallout(b) - case BlockCode: - return expandCode(b), nil - case BlockTitledCode: - return expandTitledCode(b), nil - case BlockMermaid: - return expandMermaid(b), nil - case BlockHorizontalRule: - return expandHorizontalRule(b), nil - case BlockImage: - return expandImage(b), nil - case BlockFile: - return document.Block{}, errFileNotAuthored - case BlockFigma: - return expandFigma(b), nil - case BlockMetric: - return expandMetric(b), nil - case BlockMetricGrid: - return expandMetricGrid(b) - case BlockSplitDoc: - return expandSplitDoc(b) - case BlockParamList: - return expandParamList(b) - } - - return document.Block{}, fmt.Errorf("unknown block type %q", b.Type) -} - -// expandMany expands a slice of canonical Blocks into a slice of -// document.Blocks, short-circuiting on the first error. -func expandMany(blocks []Block) ([]document.Block, error) { - if len(blocks) == 0 { - return nil, nil - } - - out := make([]document.Block, 0, len(blocks)) - - for i, b := range blocks { - expanded, err := Expand(b) - if err != nil { - return nil, fmt.Errorf("expanding block %d (%s): %w", i, b.Type, err) - } - - out = append(out, expanded) - } - - return out, nil -} - -// resolveUID returns the supplied uid when non-empty, otherwise a -// freshly generated nanoid. Centralised so the policy is consistent -// across every block kind. -func resolveUID(supplied string) string { - if supplied != "" { - return supplied - } - - return strutil.NanoID() -} - -// uidAttrs returns the standard {uid: …} attribute map. -func uidAttrs(uid string) document.Attributes { - return document.Attributes{document.AttrUID: uid} -} - -// expandParagraph builds a paragraph node from a canonical block, -// parsing Text as inline minimal-markdown. -func expandParagraph(b Block) document.Block { - return document.Block{ - Type: document.BlockNodeParagraph, - Attrs: uidAttrs(resolveUID(b.UID)), - Content: ParseInlineMarkdown(b.Text), - } -} - -// expandHeading builds a heading node, reading the required level -// attribute. Level defaults to 1 when missing; Validate should have -// already enforced 1-3. -func expandHeading(b Block) document.Block { - level := 1 - if a, ok := b.Attrs.Get(document.AttrLevel); ok { - level = a.Int() - } - - attrs := uidAttrs(resolveUID(b.UID)) - attrs[document.AttrLevel] = level - - return document.Block{ - Type: document.BlockNodeHeading, - Attrs: attrs, - Content: rawTextContent(b.Text), - } -} - -// expandBlockquote builds a blockquote node. Either Text (single-paragraph -// shorthand) or Items (any root-legal blocks) supplies the content, -// matching what Validate accepts and what Compact produces. -func expandBlockquote(b Block) (document.Block, error) { - children, err := textOrItemsContent(b) - if err != nil { - return document.Block{}, fmt.Errorf("blockquote items: %w", err) - } - - return document.Block{ - Type: document.BlockNodeBlockquote, - Attrs: uidAttrs(resolveUID(b.UID)), - Content: children, - }, nil -} - -// expandBulletOrOrderedList builds a bulletList or orderedList node. -// Each canonical Item is wrapped in a listItem; if the item's -// canonical type is paragraph, the paragraph node becomes the -// listItem's content; otherwise the item is expanded as-is and used -// directly. -func expandBulletOrOrderedList(b Block, pmType document.BlockNodeType) (document.Block, error) { - children := make([]document.Block, 0, len(b.Items)) - - for i, item := range b.Items { - expanded, err := Expand(item) - if err != nil { - return document.Block{}, fmt.Errorf("list item %d: %w", i, err) - } - - nested, err := expandMany(item.Children) - if err != nil { - return document.Block{}, fmt.Errorf("list item %d children: %w", i, err) - } - - content := append([]document.Block{expanded}, nested...) - - children = append(children, document.Block{ - Type: document.BlockNodeListItem, - Attrs: uidAttrs(strutil.NanoID()), - Content: content, - }) - } - - return document.Block{ - Type: pmType, - Attrs: uidAttrs(resolveUID(b.UID)), - Content: children, - }, nil -} - -// expandTaskList builds a taskList node. Each TaskItem becomes a -// taskItem node carrying a checked attr and wrapping the row's -// content block. -func expandTaskList(b Block) (document.Block, error) { - children := make([]document.Block, 0, len(b.TaskItems)) - - for i, item := range b.TaskItems { - expanded, err := Expand(item.Block) - if err != nil { - return document.Block{}, fmt.Errorf("task item %d: %w", i, err) - } - - nested, err := expandMany(item.Block.Children) - if err != nil { - return document.Block{}, fmt.Errorf("task item %d children: %w", i, err) - } - - content := append([]document.Block{expanded}, nested...) - - attrs := uidAttrs(resolveUID(item.UID)) - attrs[document.AttrChecked] = item.Checked - - children = append(children, document.Block{ - Type: document.BlockNodeTaskItem, - Attrs: attrs, - Content: content, - }) - } - - return document.Block{ - Type: document.BlockNodeTaskList, - Attrs: uidAttrs(resolveUID(b.UID)), - Content: children, - }, nil -} - -// expandCallout builds a calloutBlock. Either Text (single-paragraph -// shorthand) or Items (any combination of paragraphs and lists) -// supplies the content. Icon defaults to "lucide:text" matching the -// editor's default. -func expandCallout(b Block) (document.Block, error) { - attrs := uidAttrs(resolveUID(b.UID)) - - attrs[document.AttrIcon] = "lucide:text" - if a, ok := b.Attrs.Get(document.AttrIcon); ok { - attrs[document.AttrIcon] = a.String() - } - - children, err := textOrItemsContent(b) - if err != nil { - return document.Block{}, fmt.Errorf("callout items: %w", err) - } - - return document.Block{ - Type: document.BlockNodeCalloutBlock, - Attrs: attrs, - Content: children, - }, nil -} - -// textOrItemsContent builds the child slice of a block that accepts -// either the single-paragraph Text shorthand or explicit Items: -// present Items are expanded as-is, otherwise Text becomes one -// paragraph node. -func textOrItemsContent(b Block) ([]document.Block, error) { - if len(b.Items) > 0 { - return expandMany(b.Items) - } - - return []document.Block{{ - Type: document.BlockNodeParagraph, - Attrs: uidAttrs(strutil.NanoID()), - Content: ParseInlineMarkdown(b.Text), - }}, nil -} - -// expandCode builds a codeBlock. Text is treated as raw code and -// emitted as a single text node (no markdown parsing). -func expandCode(b Block) document.Block { - attrs := uidAttrs(resolveUID(b.UID)) - if a, ok := b.Attrs.Get(document.AttrLanguage); ok && a.String() != "" { - attrs[document.AttrLanguage] = a.String() - } - - return document.Block{ - Type: document.BlockNodeCodeBlock, - Attrs: attrs, - Content: rawTextContent(b.Text), - } -} - -// expandTitledCode builds a titledCodeBlock with its required -// codeBlockTitle + codeBlock children. Title is plain text; the code -// body is raw. -func expandTitledCode(b Block) document.Block { - var title, lang string - - if a, ok := b.Attrs.Get(document.AttrTitle); ok { - title = a.String() - } - - if a, ok := b.Attrs.Get(document.AttrLanguage); ok { - lang = a.String() - } - - codeAttrs := uidAttrs(strutil.NanoID()) - if lang != "" { - codeAttrs[document.AttrLanguage] = lang - } - - titleNode := document.Block{ - Type: document.BlockNodeCodeBlockTitle, - Attrs: uidAttrs(strutil.NanoID()), - Content: rawTextContent(title), - } - - codeNode := document.Block{ - Type: document.BlockNodeCodeBlock, - Attrs: codeAttrs, - Content: rawTextContent(b.Text), - } - - return document.Block{ - Type: document.BlockNodeTitledCodeBlock, - Attrs: uidAttrs(resolveUID(b.UID)), - Content: []document.Block{ - titleNode, - codeNode, - }, - } -} - -// expandMermaid builds a mermaidBlock. Text is treated as raw -// diagram source. -func expandMermaid(b Block) document.Block { - return document.Block{ - Type: document.BlockNodeMermaidBlock, - Attrs: uidAttrs(resolveUID(b.UID)), - Content: rawTextContent(b.Text), - } -} - -// expandHorizontalRule builds a horizontalRule atom node. -func expandHorizontalRule(b Block) document.Block { - return document.Block{ - Type: document.BlockNodeHorizontalRule, - Attrs: uidAttrs(resolveUID(b.UID)), - } -} - -// expandImage builds an imageBlock atom node with src/alt/title/ -// width attributes. -func expandImage(b Block) document.Block { - attrs := uidAttrs(resolveUID(b.UID)) - - copyStringAttrs(attrs, b.Attrs, document.AttrSrc, document.AttrAlt, document.AttrTitle) - copyPositiveIntAttrs(attrs, b.Attrs, document.AttrWidth) - - return document.Block{ - Type: document.BlockNodeImageBlock, - Attrs: attrs, - } -} - -// expandFigma builds a figmaBlock atom node. -func expandFigma(b Block) document.Block { - attrs := uidAttrs(resolveUID(b.UID)) - - copyStringAttrs(attrs, b.Attrs, document.AttrSrc) - copyPositiveIntAttrs(attrs, b.Attrs, document.AttrWidth, document.AttrHeight) - - return document.Block{ - Type: document.BlockNodeFigmaBlock, - Attrs: attrs, - } -} - -// expandMetric builds a metricBlock. All caller-supplied attrs are -// passed through unchanged (the metric configuration shape is -// opaque to the canonical layer); the uid is taken from Block.UID -// or freshly generated. A simulation flag the caller sent is dropped. -// A block with a preset simulates, and any other does not. -func expandMetric(b Block) document.Block { - attrs := b.Attrs.Copy() - attrs[document.AttrUID] = resolveUID(b.UID) - - delete(attrs, document.AttrSimulationActive) - - if _, ok := attrs.Value(document.AttrSimulationPreset); ok { - attrs[document.AttrSimulationActive] = true - } - - return document.Block{ - Type: document.BlockNodeMetricBlock, - Attrs: attrs, - } -} - -// expandMetricGrid builds a metricGrid wrapping each item (expected -// to be a metric). -func expandMetricGrid(b Block) (document.Block, error) { - children, err := expandMany(b.Items) - if err != nil { - return document.Block{}, fmt.Errorf("metric_grid items: %w", err) - } - - return document.Block{ - Type: document.BlockNodeMetricGrid, - Attrs: uidAttrs(resolveUID(b.UID)), - Content: children, - }, nil -} - -// expandSplitDoc builds the full splitDocumentation tree, wrapping -// Left and Right in their splitDocumentationLeftSide / -// splitDocumentationRightSide nodes. -func expandSplitDoc(b Block) (document.Block, error) { - left, err := expandMany(b.Left) - if err != nil { - return document.Block{}, fmt.Errorf("split_doc left: %w", err) - } - - right, err := expandMany(b.Right) - if err != nil { - return document.Block{}, fmt.Errorf("split_doc right: %w", err) - } - - leftNode := document.Block{ - Type: document.BlockNodeSplitDocLeft, - Attrs: uidAttrs(strutil.NanoID()), - Content: left, - } - - rightNode := document.Block{ - Type: document.BlockNodeSplitDocRight, - Attrs: uidAttrs(strutil.NanoID()), - Content: right, - } - - attrs := uidAttrs(resolveUID(b.UID)) - if a, ok := b.Attrs.Get(document.AttrInversed); ok && a.Bool() { - attrs[document.AttrInversed] = true - } - - return document.Block{ - Type: document.BlockNodeSplitDoc, - Attrs: attrs, - Content: []document.Block{ - leftNode, - rightNode, - }, - }, nil -} - -// expandParamList builds the full splitDocumentationParameterList -// tree: a header node followed by one item per Params entry. Each -// item nests an itemHeader (title + type) and a paragraph carrying -// the description. -func expandParamList(b Block) (document.Block, error) { - children := make([]document.Block, 0, 1+len(b.Params)) - - children = append(children, document.Block{ - Type: document.BlockNodeParamListHeader, - Attrs: uidAttrs(strutil.NanoID()), - Content: rawTextContent(b.Header), - }) - - for _, p := range b.Params { - titleNode := document.Block{ - Type: document.BlockNodeParamListItemTitle, - Attrs: uidAttrs(strutil.NanoID()), - Content: rawTextContent(p.Name), - } - - typeNode := document.Block{ - Type: document.BlockNodeParamListItemType, - Attrs: uidAttrs(strutil.NanoID()), - Content: rawTextContent(p.Type), - } - - headerNode := document.Block{ - Type: document.BlockNodeParamListItemHeader, - Attrs: uidAttrs(strutil.NanoID()), - Content: []document.Block{ - titleNode, - typeNode, - }, - } - - descNode := document.Block{ - Type: document.BlockNodeParagraph, - Attrs: uidAttrs(strutil.NanoID()), - Content: ParseInlineMarkdown(p.Description), - } - - children = append(children, document.Block{ - Type: document.BlockNodeParamListItem, - Attrs: uidAttrs(resolveUID(p.UID)), - Content: []document.Block{ - headerNode, - descNode, - }, - }) - } - - return document.Block{ - Type: document.BlockNodeParamList, - Attrs: uidAttrs(resolveUID(b.UID)), - Content: children, - }, nil -} - -// TextContent returns the inline content a block of type pm holds for -// text. A heading takes no formatting, and code, mermaid and a code -// block's title hold raw source, so their text is kept as written; every -// other type reads text as minimal markdown. -func TextContent(pm document.BlockNodeType, text string) []document.Block { - switch pm { - case document.BlockNodeHeading, - document.BlockNodeCodeBlock, - document.BlockNodeTitledCodeBlock, - document.BlockNodeCodeBlockTitle, - document.BlockNodeMermaidBlock: - return rawTextContent(text) - default: - return ParseInlineMarkdown(text) - } -} - -// rawTextContent returns the inline content slice for a node that -// stores raw text (code, mermaid, parameter title/type, code block -// title, paragraph-list header). Empty text returns nil so the -// resulting node has no content rather than an empty text node. -func rawTextContent(text string) []document.Block { - if text == "" { - return nil - } - - return []document.Block{{Type: document.BlockNodeText, Text: text}} -} diff --git a/server/core/internal/assistant/block/expand_test.go b/server/core/internal/assistant/block/expand_test.go deleted file mode 100644 index 511afefd..00000000 --- a/server/core/internal/assistant/block/expand_test.go +++ /dev/null @@ -1,784 +0,0 @@ -package block - -import ( - "testing" - - "github.com/oxynote/oxynote/server/core/internal/document" - "github.com/oxynote/oxynote/server/core/pkg/testutil" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// stripUIDsPM recursively removes uid attributes throughout a -// document.Block tree so structural equality assertions don't have -// to know which uids will be generated. It returns a transformed -// copy and leaves the input untouched. -func stripUIDsPM(b document.Block) document.Block { - out := document.Block{ - Type: b.Type, - Text: b.Text, - Marks: b.Marks, - } - - if b.Attrs != nil { - na := make(map[string]any, len(b.Attrs)) - for k, v := range b.Attrs { - if k == document.AttrUID { - continue - } - - na[k] = v - } - - if len(na) > 0 { - out.Attrs = na - } - } - - if len(b.Content) > 0 { - out.Content = make([]document.Block, len(b.Content)) - for i, c := range b.Content { - out.Content[i] = stripUIDsPM(c) - } - } - - return out -} - -// stripUIDsCanonical strips UID fields throughout a canonical Block -// tree (Block.UID, TaskItem.UID, ParamItem.UID) so round-trip -// assertions can ignore generated identifiers. It rewrites the -// nested Items/Children/TaskItems/Left/Right/Params slices in -// place, so the input tree is stripped as a side effect. -func stripUIDsCanonical(b Block) Block { - b.UID = "" - - for i, c := range b.Items { - b.Items[i] = stripUIDsCanonical(c) - } - - for i, c := range b.Children { - b.Children[i] = stripUIDsCanonical(c) - } - - for i, t := range b.TaskItems { - b.TaskItems[i] = TaskItem{ - Checked: t.Checked, - Block: stripUIDsCanonical(t.Block), - } - } - - for i, l := range b.Left { - b.Left[i] = stripUIDsCanonical(l) - } - - for i, r := range b.Right { - b.Right[i] = stripUIDsCanonical(r) - } - - for i, p := range b.Params { - p.UID = "" - b.Params[i] = p - } - - return b -} - -func Test_Expand(t *testing.T) { - cc := map[string]struct { - Input Block - Expected document.Block - Err error - - // RoundTrip asserts that compacting the expanded block returns the - // input instead of comparing against Expected. - RoundTrip bool - - // Check replaces the expected-block comparison for a case that - // asserts on something the comparison strips, such as the uid. - Check func(t *testing.T, got document.Block) - }{ - "Round trip: paragraph": {Input: Block{Type: BlockParagraph, Text: "hi **there**"}, RoundTrip: true}, - "Round trip: heading": {Input: Block{Type: BlockHeading, Text: "Title", Attrs: map[string]any{"level": 1}}, RoundTrip: true}, - "Round trip: blockquote": {Input: Block{Type: BlockBlockquote, Text: "quote"}, RoundTrip: true}, - "Round trip: blockquote with items": {RoundTrip: true, Input: Block{ - Type: BlockBlockquote, - Items: []Block{ - {Type: BlockParagraph, Text: "one"}, - {Type: BlockParagraph, Text: "two"}, - }, - }}, - "Round trip: bullet list with a nested list": {RoundTrip: true, Input: Block{ - Type: BlockBulletList, - Items: []Block{ - { - Type: BlockParagraph, - Text: "one", - Children: []Block{{ - Type: BlockBulletList, - Items: []Block{{Type: BlockParagraph, Text: "nested"}}, - }}, - }, - {Type: BlockParagraph, Text: "two"}, - }, - }}, - "Round trip: task list with a nested list": {RoundTrip: true, Input: Block{ - Type: BlockTaskList, - TaskItems: []TaskItem{{ - Checked: true, - Block: Block{ - Type: BlockParagraph, - Text: "done", - Children: []Block{{ - Type: BlockBulletList, - Items: []Block{{Type: BlockParagraph, Text: "nested"}}, - }}, - }, - }}, - }}, - "Round trip: bullet list": {RoundTrip: true, Input: Block{ - Type: BlockBulletList, - Items: []Block{{Type: BlockParagraph, Text: "one"}, {Type: BlockParagraph, Text: "two"}}, - }}, - "Round trip: task list": {RoundTrip: true, Input: Block{ - Type: BlockTaskList, - TaskItems: []TaskItem{ - {Checked: true, Block: Block{Type: BlockParagraph, Text: "done"}}, - }, - }}, - "Round trip: callout text shorthand": {RoundTrip: true, Input: Block{ - Type: BlockCallout, - Text: "warn", - Attrs: map[string]any{"icon": "lucide:warning"}, - }}, - "Round trip: code": {RoundTrip: true, Input: Block{ - Type: BlockCode, - Text: "x := 1", - Attrs: map[string]any{"language": "go"}, - }}, - "Round trip: titled code": {RoundTrip: true, Input: Block{ - Type: BlockTitledCode, - Text: "x := 1", - Attrs: map[string]any{"title": "ex.go", "language": "go"}, - }}, - "Round trip: mermaid": {Input: Block{Type: BlockMermaid, Text: "graph TD; A-->B;"}, RoundTrip: true}, - "Round trip: horizontal rule": {Input: Block{Type: BlockHorizontalRule}, RoundTrip: true}, - "Round trip: image": {RoundTrip: true, Input: Block{ - Type: BlockImage, - Attrs: map[string]any{"src": "http://x", "alt": "x", "width": 100}, - }}, - "Round trip: figma": {RoundTrip: true, Input: Block{ - Type: BlockFigma, - Attrs: map[string]any{"src": "http://figma", "width": 320, "height": 200}, - }}, - "Round trip: split doc": {RoundTrip: true, Input: Block{ - Type: BlockSplitDoc, - Attrs: map[string]any{"inversed": true}, - Left: []Block{ - {Type: BlockHeading, Text: "API", Attrs: map[string]any{"level": 1}}, - {Type: BlockParagraph, Text: "explain"}, - }, - Right: []Block{ - {Type: BlockTitledCode, Text: "ok", Attrs: map[string]any{"title": "example"}}, - }, - }}, - "Round trip: param list": {RoundTrip: true, Input: Block{ - Type: BlockParamList, - Header: "Body", - Params: []ParamItem{ - {Name: "id", Type: "string", Description: "the **user** id"}, - {Name: "limit", Type: "number?", Description: "page size"}, - }, - }}, - "An explicit uid is preserved": { - Input: Block{Type: BlockParagraph, UID: "preserve-me", Text: "x"}, - Check: func(t *testing.T, got document.Block) { - uid, ok := got.UID() - require.True(t, ok) - assert.Equal(t, "preserve-me", uid) - }, - }, - "A missing uid is generated": { - Input: Block{Type: BlockParagraph, Text: "x"}, - Check: func(t *testing.T, got document.Block) { - uid, ok := got.UID() - require.True(t, ok) - assert.NotEmpty(t, uid) - }, - }, - "Paragraph with bold mark": { - Input: Block{Type: BlockParagraph, Text: "say **hi**"}, - Expected: document.Block{ - Type: document.BlockNodeParagraph, - Content: []document.Block{ - {Type: "text", Text: "say "}, - {Type: "text", Text: "hi", Marks: []document.Mark{{Type: "bold"}}}, - }, - }, - }, - "Heading carries level attr": { - Input: Block{Type: BlockHeading, Text: "Title", Attrs: map[string]any{"level": 2}}, - Expected: document.Block{ - Type: document.BlockNodeHeading, - Attrs: map[string]any{"level": 2}, - Content: []document.Block{ - {Type: "text", Text: "Title"}, - }, - }, - }, - "Heading text is kept as written": { - Input: Block{Type: BlockHeading, Text: "The **my_var** flag", Attrs: map[string]any{"level": 2}}, - Expected: document.Block{ - Type: document.BlockNodeHeading, - Attrs: map[string]any{"level": 2}, - Content: []document.Block{ - {Type: "text", Text: "The **my_var** flag"}, - }, - }, - }, - "Blockquote wraps paragraph": { - Input: Block{Type: BlockBlockquote, Text: "quote"}, - Expected: document.Block{ - Type: document.BlockNodeBlockquote, - Content: []document.Block{ - { - Type: document.BlockNodeParagraph, - Content: []document.Block{{Type: "text", Text: "quote"}}, - }, - }, - }, - }, - "Blockquote wraps items": { - Input: Block{ - Type: BlockBlockquote, - Items: []Block{ - {Type: BlockParagraph, Text: "one"}, - {Type: BlockParagraph, Text: "two"}, - }, - }, - Expected: document.Block{ - Type: document.BlockNodeBlockquote, - Content: []document.Block{ - { - Type: document.BlockNodeParagraph, - Content: []document.Block{{Type: "text", Text: "one"}}, - }, - { - Type: document.BlockNodeParagraph, - Content: []document.Block{{Type: "text", Text: "two"}}, - }, - }, - }, - }, - "Bullet list wraps each item in listItem > paragraph": { - Input: Block{ - Type: BlockBulletList, - Items: []Block{ - {Type: BlockParagraph, Text: "first"}, - {Type: BlockParagraph, Text: "second"}, - }, - }, - Expected: document.Block{ - Type: document.BlockNodeBulletList, - Content: []document.Block{ - { - Type: document.BlockNodeListItem, - Content: []document.Block{ - {Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "first"}}}, - }, - }, - { - Type: document.BlockNodeListItem, - Content: []document.Block{ - {Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "second"}}}, - }, - }, - }, - }, - }, - "Task list carries checked flag on each task item": { - Input: Block{ - Type: BlockTaskList, - TaskItems: []TaskItem{ - {Checked: true, Block: Block{Type: BlockParagraph, Text: "done"}}, - {Checked: false, Block: Block{Type: BlockParagraph, Text: "todo"}}, - }, - }, - Expected: document.Block{ - Type: document.BlockNodeTaskList, - Content: []document.Block{ - { - Type: document.BlockNodeTaskItem, - Attrs: map[string]any{"checked": true}, - Content: []document.Block{ - {Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "done"}}}, - }, - }, - { - Type: document.BlockNodeTaskItem, - Attrs: map[string]any{"checked": false}, - Content: []document.Block{ - {Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "todo"}}}, - }, - }, - }, - }, - }, - "Callout text shorthand wraps a paragraph": { - Input: Block{Type: BlockCallout, Text: "warn", Attrs: map[string]any{"icon": "lucide:warning"}}, - Expected: document.Block{ - Type: document.BlockNodeCalloutBlock, - Attrs: map[string]any{"icon": "lucide:warning"}, - Content: []document.Block{ - {Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "warn"}}}, - }, - }, - }, - "Code is emitted raw without markdown parsing": { - Input: Block{Type: BlockCode, Text: "**not** bold", Attrs: map[string]any{"language": "go"}}, - Expected: document.Block{ - Type: document.BlockNodeCodeBlock, - Attrs: map[string]any{"language": "go"}, - Content: []document.Block{{Type: "text", Text: "**not** bold"}}, - }, - }, - "Titled code expands to title + code children": { - Input: Block{ - Type: BlockTitledCode, - Text: "x := 1", - Attrs: map[string]any{"title": "example.go", "language": "go"}, - }, - Expected: document.Block{ - Type: document.BlockNodeTitledCodeBlock, - Content: []document.Block{ - {Type: document.BlockNodeCodeBlockTitle, Content: []document.Block{{Type: "text", Text: "example.go"}}}, - {Type: document.BlockNodeCodeBlock, Attrs: map[string]any{"language": "go"}, Content: []document.Block{{Type: "text", Text: "x := 1"}}}, - }, - }, - }, - "Mermaid is raw text": { - Input: Block{Type: BlockMermaid, Text: "graph TD; A-->B;"}, - Expected: document.Block{ - Type: document.BlockNodeMermaidBlock, - Content: []document.Block{{Type: "text", Text: "graph TD; A-->B;"}}, - }, - }, - "Horizontal rule has no content": { - Input: Block{Type: BlockHorizontalRule}, - Expected: document.Block{Type: document.BlockNodeHorizontalRule}, - }, - "Image carries src and width attrs": { - Input: Block{Type: BlockImage, Attrs: map[string]any{"src": "http://x", "width": 200}}, - Expected: document.Block{ - Type: document.BlockNodeImageBlock, - Attrs: map[string]any{"src": "http://x", "width": 200}, - }, - }, - "Split doc builds left and right wrappers": { - Input: Block{ - Type: BlockSplitDoc, - Attrs: map[string]any{"inversed": true}, - Left: []Block{ - {Type: BlockHeading, Text: "API", Attrs: map[string]any{"level": 1}}, - {Type: BlockParagraph, Text: "intro"}, - }, - Right: []Block{ - {Type: BlockTitledCode, Text: "ok", Attrs: map[string]any{"title": "example"}}, - {Type: BlockMermaid, Text: "graph TD"}, - }, - }, - Expected: document.Block{ - Type: document.BlockNodeSplitDoc, - Attrs: map[string]any{"inversed": true}, - Content: []document.Block{ - { - Type: document.BlockNodeSplitDocLeft, - Content: []document.Block{ - {Type: document.BlockNodeHeading, Attrs: map[string]any{"level": 1}, Content: []document.Block{{Type: "text", Text: "API"}}}, - {Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "intro"}}}, - }, - }, - { - Type: document.BlockNodeSplitDocRight, - Content: []document.Block{ - { - Type: document.BlockNodeTitledCodeBlock, - Content: []document.Block{ - {Type: document.BlockNodeCodeBlockTitle, Content: []document.Block{{Type: "text", Text: "example"}}}, - {Type: document.BlockNodeCodeBlock, Content: []document.Block{{Type: "text", Text: "ok"}}}, - }, - }, - {Type: document.BlockNodeMermaidBlock, Content: []document.Block{{Type: "text", Text: "graph TD"}}}, - }, - }, - }, - }, - }, - "Param list builds header and full item nesting": { - Input: Block{ - Type: BlockParamList, - Header: "Body", - Params: []ParamItem{ - {Name: "id", Type: "string", Description: "the user id"}, - }, - }, - Expected: document.Block{ - Type: document.BlockNodeParamList, - Content: []document.Block{ - {Type: document.BlockNodeParamListHeader, Content: []document.Block{{Type: "text", Text: "Body"}}}, - { - Type: document.BlockNodeParamListItem, - Content: []document.Block{ - { - Type: document.BlockNodeParamListItemHeader, - Content: []document.Block{ - {Type: document.BlockNodeParamListItemTitle, Content: []document.Block{{Type: "text", Text: "id"}}}, - {Type: document.BlockNodeParamListItemType, Content: []document.Block{{Type: "text", Text: "string"}}}, - }, - }, - {Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "the user id"}}}, - }, - }, - }, - }, - }, - "Ordered list wraps items in listItem": { - Input: Block{ - Type: BlockOrderedList, - Items: []Block{{Type: BlockParagraph, Text: "one"}}, - }, - Expected: document.Block{ - Type: document.BlockNodeOrderedList, - Content: []document.Block{ - { - Type: document.BlockNodeListItem, - Content: []document.Block{ - {Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "one"}}}, - }, - }, - }, - }, - }, - "Heading without level defaults to 1": { - Input: Block{Type: BlockHeading, Text: "T"}, - Expected: document.Block{ - Type: document.BlockNodeHeading, - Attrs: map[string]any{"level": 1}, - Content: []document.Block{{Type: "text", Text: "T"}}, - }, - }, - "Callout without icon uses the default": { - Input: Block{Type: BlockCallout, Text: "note"}, - Expected: document.Block{ - Type: document.BlockNodeCalloutBlock, - Attrs: map[string]any{"icon": "lucide:text"}, - Content: []document.Block{ - {Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "note"}}}, - }, - }, - }, - "Callout with items expands each": { - Input: Block{ - Type: BlockCallout, - Attrs: map[string]any{"icon": "lucide:warning"}, - Items: []Block{ - {Type: BlockParagraph, Text: "a"}, - {Type: BlockParagraph, Text: "b"}, - }, - }, - Expected: document.Block{ - Type: document.BlockNodeCalloutBlock, - Attrs: map[string]any{"icon": "lucide:warning"}, - Content: []document.Block{ - {Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "a"}}}, - {Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "b"}}}, - }, - }, - }, - "Callout with invalid item fails": { - Input: Block{ - Type: BlockCallout, - Items: []Block{{Type: "not_a_type"}}, - }, - Err: assert.AnError, - }, - "Blockquote with invalid item fails": { - Input: Block{ - Type: BlockBlockquote, - Items: []Block{{Type: "not_a_type"}}, - }, - Err: assert.AnError, - }, - "Bullet list with invalid item fails": { - Input: Block{ - Type: BlockBulletList, - Items: []Block{{Type: "not_a_type"}}, - }, - Err: assert.AnError, - }, - "Bullet list with invalid nested child fails": { - Input: Block{ - Type: BlockBulletList, - Items: []Block{{ - Type: BlockParagraph, - Text: "one", - Children: []Block{{Type: "not_a_type"}}, - }}, - }, - Err: assert.AnError, - }, - "Task list with invalid item fails": { - Input: Block{ - Type: BlockTaskList, - TaskItems: []TaskItem{{Block: Block{Type: "not_a_type"}}}, - }, - Err: assert.AnError, - }, - "Task list with invalid nested child fails": { - Input: Block{ - Type: BlockTaskList, - TaskItems: []TaskItem{{ - Block: Block{ - Type: BlockParagraph, - Text: "one", - Children: []Block{{Type: "not_a_type"}}, - }, - }}, - }, - Err: assert.AnError, - }, - "Metric passes attrs through": { - Input: Block{Type: BlockMetric, Attrs: map[string]any{"query": "up"}}, - Expected: document.Block{ - Type: document.BlockNodeMetricBlock, - Attrs: map[string]any{"query": "up"}, - }, - }, - "Metric naming a preset simulates": { - Input: Block{ - Type: BlockMetric, - Attrs: map[string]any{"simulationPreset": "http_latency"}, - }, - Expected: document.Block{ - Type: document.BlockNodeMetricBlock, - Attrs: map[string]any{ - "simulationPreset": "http_latency", - "simulationActive": true, - }, - }, - }, - "Metric naming a preset simulates whatever flag was sent": { - Input: Block{ - Type: BlockMetric, - Attrs: map[string]any{ - "simulationPreset": "http_latency", - "simulationActive": false, - }, - }, - Expected: document.Block{ - Type: document.BlockNodeMetricBlock, - Attrs: map[string]any{ - "simulationPreset": "http_latency", - "simulationActive": true, - }, - }, - }, - "Metric naming no preset drops the flag it was sent": { - Input: Block{ - Type: BlockMetric, - Attrs: map[string]any{ - "simulationPreset": nil, - "simulationActive": true, - }, - }, - Expected: document.Block{ - Type: document.BlockNodeMetricBlock, - Attrs: map[string]any{"simulationPreset": nil}, - }, - }, - // the other half of the legacy round trip: what Compact read - // back out has to expand into exactly what it came from. - "Metric keeps a legacy config blob, nulls and unknown attrs": { - Input: Block{ - Type: BlockMetric, - UID: "m1", - Attrs: map[string]any{ - "config": map[string]any{"type": "line_chart"}, - "visualizationType": nil, - "wibble": 42, - }, - }, - // the harness strips uids, so what is asserted here is that - // every other attribute survived untouched; the uid's own - // preservation is covered by the cases that check it. - Expected: document.Block{ - Type: document.BlockNodeMetricBlock, - Attrs: map[string]any{ - "config": map[string]any{"type": "line_chart"}, - "visualizationType": nil, - "wibble": 42, - }, - }, - }, - "Metric grid wraps metric items": { - Input: Block{ - Type: BlockMetricGrid, - Items: []Block{{Type: BlockMetric, Attrs: map[string]any{"query": "up"}}}, - }, - Expected: document.Block{ - Type: document.BlockNodeMetricGrid, - Content: []document.Block{ - {Type: document.BlockNodeMetricBlock, Attrs: map[string]any{"query": "up"}}, - }, - }, - }, - "Metric grid with invalid item fails": { - Input: Block{ - Type: BlockMetricGrid, - Items: []Block{{Type: "not_a_type"}}, - }, - Err: assert.AnError, - }, - "Split doc with invalid left fails": { - Input: Block{ - Type: BlockSplitDoc, - Left: []Block{{Type: "not_a_type"}}, - }, - Err: assert.AnError, - }, - "Split doc with invalid right fails": { - Input: Block{ - Type: BlockSplitDoc, - Left: []Block{{Type: BlockHeading, Text: "T", Attrs: map[string]any{"level": 1}}}, - Right: []Block{{Type: "not_a_type"}}, - }, - Err: assert.AnError, - }, - "Split doc with empty sides yields empty wrappers": { - Input: Block{Type: BlockSplitDoc}, - Expected: document.Block{ - Type: document.BlockNodeSplitDoc, - Content: []document.Block{ - {Type: document.BlockNodeSplitDocLeft}, - {Type: document.BlockNodeSplitDocRight}, - }, - }, - }, - "Image carries title and alt attrs": { - Input: Block{Type: BlockImage, Attrs: map[string]any{"src": "http://x", "alt": "pic", "title": "shot"}}, - Expected: document.Block{ - Type: document.BlockNodeImageBlock, - Attrs: map[string]any{"src": "http://x", "alt": "pic", "title": "shot"}, - }, - }, - "Titled code without title has an empty title node": { - Input: Block{Type: BlockTitledCode, Text: "x", Attrs: map[string]any{"language": "go"}}, - Expected: document.Block{ - Type: document.BlockNodeTitledCodeBlock, - Content: []document.Block{ - {Type: document.BlockNodeCodeBlockTitle}, - {Type: document.BlockNodeCodeBlock, Attrs: map[string]any{"language": "go"}, Content: []document.Block{{Type: "text", Text: "x"}}}, - }, - }, - }, - "Unknown block type fails": { - Input: Block{Type: "not_a_type"}, - Err: assert.AnError, - }, - "File cannot be authored": { - Input: Block{Type: BlockFile, Attrs: map[string]any{"src": "http://x", "name": "notes.zip"}}, - Err: errFileNotAuthored, - }, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - got, err := Expand(c.Input) - - testutil.AssertEqualError(t, c.Err, err) - - if c.Err != nil { - return - } - - if c.RoundTrip { - require.NoError(t, Validate(c.Input), "input should validate") - - compacted, cerr := Compact(got) - require.NoError(t, cerr) - - assert.Equal(t, - stripUIDsCanonical(c.Input), - stripUIDsCanonical(compacted), - ) - - return - } - - if c.Check != nil { - c.Check(t, got) - - return - } - - assert.Equal(t, c.Expected, stripUIDsPM(got)) - }) - } -} - -func Test_TextContent(t *testing.T) { - t.Parallel() - - cc := map[string]struct { - Type document.BlockNodeType - Text string - Expected []document.Block - }{ - "Paragraph text is read as markdown": { - Type: document.BlockNodeParagraph, - Text: "say **hi**", - Expected: []document.Block{ - {Type: document.BlockNodeText, Text: "say "}, - {Type: document.BlockNodeText, Text: "hi", Marks: []document.Mark{{Type: "bold"}}}, - }, - }, - "Heading text is kept raw": { - Type: document.BlockNodeHeading, - Text: "The **my_var** flag", - Expected: []document.Block{{Type: document.BlockNodeText, Text: "The **my_var** flag"}}, - }, - "Code text is kept raw": { - Type: document.BlockNodeCodeBlock, - Text: `my_var = "a\b" **1**`, - Expected: []document.Block{{Type: document.BlockNodeText, Text: `my_var = "a\b" **1**`}}, - }, - "Titled code text is kept raw": { - Type: document.BlockNodeTitledCodeBlock, - Text: "a_b_c", - Expected: []document.Block{{Type: document.BlockNodeText, Text: "a_b_c"}}, - }, - "Code block title is kept raw": { - Type: document.BlockNodeCodeBlockTitle, - Text: "GET /v1/_internal", - Expected: []document.Block{{Type: document.BlockNodeText, Text: "GET /v1/_internal"}}, - }, - "Mermaid text is kept raw": { - Type: document.BlockNodeMermaidBlock, - Text: "graph TD; A_1-->B_2;", - Expected: []document.Block{{Type: document.BlockNodeText, Text: "graph TD; A_1-->B_2;"}}, - }, - "Empty raw text has no content": { - Type: document.BlockNodeCodeBlock, - }, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - assert.Equal(t, c.Expected, TextContent(c.Type, c.Text)) - }) - } -} diff --git a/server/core/internal/assistant/block/markdown.go b/server/core/internal/assistant/block/markdown.go deleted file mode 100644 index a687c900..00000000 --- a/server/core/internal/assistant/block/markdown.go +++ /dev/null @@ -1,738 +0,0 @@ -package block - -import ( - "slices" - "strings" - "unicode" - "unicode/utf8" - - "github.com/oxynote/oxynote/server/core/internal/document" -) - -// Inline mark types used in canonical-markdown output. -const ( - _markBold = "bold" - _markItalic = "italic" - _markUnderline = "underline" - _markStrike = "strike" - _markCode = "code" - _markLink = "link" -) - -// _escapeGrowHeadroom is the extra builder capacity reserved for the -// backslashes escaping inserts. -const _escapeGrowHeadroom = 4 - -// _attrHref is the attribute key carrying a link mark's target URL. -const _attrHref = "href" - -// closeMisses records, for one span, the first start from which an -// underscore close scan failed. A later scan only covers less of the -// span, so it is skipped, which keeps the parse from going cubic. Zero -// means no failure yet, since no scan starts at 0. -type closeMisses struct { - // plain is the miss for an underscore opened outside a word. - plain int - - // strict is the miss for one opened inside a word. - strict int -} - -// activeMark is one mark in the open-stack used during emit. -type activeMark struct { - // kind is the mark type ("bold", "italic", "link", …). - kind string - - // href is set when kind == _markLink. - href string -} - -// inlineAtom is a maximal run of text sharing the same ordered set -// of marks. The parser produces a stream of atoms which are then -// merged into ProseMirror text nodes. -type inlineAtom struct { - // text is the literal run. - text string - - // marks is the ordered set of marks active over text. The order - // matches the open order so emit can re-derive the same - // nesting. - marks []activeMark -} - -// ParseInlineMarkdown converts a minimal-markdown string into a slice -// of ProseMirror text nodes with marks. The supported syntax is: -// -// **bold** → mark bold -// *italic* → mark italic -// _underline_ → mark underline, unless both ends sit inside a word, -// so my_var_name is literal -// ~~strike~~ → mark strike -// `code` → mark code (literal; no inner parsing) -// [label](url) → mark link with href=url; label is recursively parsed -// \X → literal X (backslash escape) -// -// Unmatched delimiters are emitted as literal text. Newlines inside -// the input are preserved verbatim; the canonical block model treats -// multi-paragraph content as separate paragraph blocks, so a single -// block's text should not normally contain newlines. -// -// An empty input returns a nil slice (a valid representation of an -// empty paragraph). -func ParseInlineMarkdown(input string) []document.Block { - if input == "" { - return nil - } - - atoms := parseSpan(input, nil) - - return atomsToTextNodes(atoms) -} - -// emitInlineMarkdown is the inverse of ParseInlineMarkdown: it -// renders a slice of ProseMirror inline text nodes back into the -// canonical-markdown subset. Round-tripping parse(emit(x)) is -// idempotent at the level of (text, mark-set) atoms — text nodes -// with the same mark-set are merged on emit. -func emitInlineMarkdown(nodes []document.Block) string { - var ( - buf strings.Builder - active []activeMark - ) - - for _, n := range nodes { - if n.Type != document.BlockNodeText { - continue - } - - desired := extractMarks(n.Marks) - - // A code span cannot represent a literal backtick, so text - // carrying one falls back to escaped plain text without the - // code mark. - if strings.ContainsRune(n.Text, '`') { - desired = slices.DeleteFunc(desired, func(m activeMark) bool { - return m.kind == _markCode - }) - } - - // ProseMirror mark order is not semantic, so reorder the - // desired marks to share the longest possible prefix with - // the open stack: keeping delimiters open avoids ambiguous - // adjacent delimiter runs. - desired = reorderMarks(desired, active) - - // Close marks that are no longer desired, in reverse open - // order, until the active stack is a prefix of desired. - for len(active) > 0 { - if isMarkPrefix(active, desired) { - break - } - - buf.WriteString(closeDelim(active[len(active)-1])) - - active = active[:len(active)-1] - } - - // Open the marks present in desired but not yet active. We - // open them in the order they appear in desired so the - // nesting matches the parse output. - for _, m := range desired { - if !slices.Contains(active, m) { - buf.WriteString(openDelim(m)) - active = append(active, m) - } - } - - buf.WriteString(escapeMarkdown(n.Text, active)) - } - - // Close any marks still open at the end. - for _, v := range slices.Backward(active) { - buf.WriteString(closeDelim(v)) - } - - return buf.String() -} - -// parseSpan parses the entire input string with the given outer -// mark stack already active. It is called recursively for the -// contents of bold/italic/underline/strike spans and link labels. -func parseSpan(s string, outer []activeMark) []inlineAtom { //nolint:gocognit // this function is complex, however, it's well-structured - var out []inlineAtom - - cur := strings.Builder{} - flush := func() { - if cur.Len() == 0 { - return - } - - out = append(out, inlineAtom{ - text: cur.String(), - marks: append([]activeMark(nil), outer...), - }) - cur.Reset() - } - - var misses closeMisses - - i := 0 - for i < len(s) { - c := s[i] - - // Backslash escape: consume the next byte as literal. - if c == '\\' && i+1 < len(s) { - cur.WriteByte(s[i+1]) - i += 2 - - continue - } - - // Code span: literal until matching backtick. No parsing - // inside. - if c == '`' { - if end := strings.IndexByte(s[i+1:], '`'); end >= 0 { - flush() - - out = append(out, inlineAtom{ - text: s[i+1 : i+1+end], - marks: append(append([]activeMark(nil), outer...), activeMark{kind: _markCode}), - }) - i = i + 1 + end + 1 - - continue - } - } - - // Link: [label](url). The label is recursively parsed; the - // url is taken literally (no inline parsing inside). - if c == '[' { - if label, href, end, ok := scanLink(s, i); ok { - flush() - - linkMark := activeMark{kind: _markLink, href: href} - inner := parseSpan(label, append(append([]activeMark(nil), outer...), linkMark)) - out = append(out, inner...) - i = end - - continue - } - } - - // Two-character mark delimiters: ** and ~~. Check before - // the single-character ones so "**" is not consumed as two - // italics. If no close is found, consume both bytes as - // literal so we don't fall through to the single-char - // branch and treat ** as ***italic*italic*. - if i+1 < len(s) { //nolint:nestif // the branching is sequential and readable - pair := s[i : i+2] - - if pair == "**" || pair == "~~" { - mark := _markBold - if pair == "~~" { - mark = _markStrike - } - - if end := scanDelimClose(s, i+len(pair), pair, false); end >= 0 { - flush() - - inner := parseSpan(s[i+len(pair):end], append(append([]activeMark(nil), outer...), activeMark{kind: mark})) - out = append(out, inner...) - i = end + len(pair) - - continue - } - - cur.WriteString(pair) - - i += 2 - - continue - } - } - - // Single-character mark delimiters: * and _. - if c == '*' || c == '_' { - mark := _markItalic - if c == '_' { - mark = _markUnderline - } - - if end := scanSingleClose(s, i, &misses); end >= 0 { - flush() - - inner := parseSpan(s[i+1:end], append(append([]activeMark(nil), outer...), activeMark{kind: mark})) - out = append(out, inner...) - i = end + 1 - - continue - } - } - - cur.WriteByte(c) - - i++ - } - - flush() - - return out -} - -// scanLink tries to parse a markdown link starting at s[i] == '['. -// It returns the label (raw, still containing markdown), the href, -// the byte offset just after the closing ')', and ok=true on a -// successful match. On any malformed shape it returns ok=false and -// the caller treats the '[' as literal. -func scanLink(s string, i int) (label, href string, end int, ok bool) { - // Find the matching ']'. Allow escaped brackets inside the - // label and skip over backtick code spans so a `]` inside code - // doesn't end the label. - j := i + 1 - - for j < len(s) { - switch s[j] { - case '\\': - j += 2 - - continue - case '`': - if k := strings.IndexByte(s[j+1:], '`'); k >= 0 { - j = j + 1 + k + 1 - - continue - } - - return "", "", 0, false - case ']': - if j+1 < len(s) && s[j+1] == '(' { - hrefStart := j + len("](") - - end := scanHrefClose(s, hrefStart) - if end < 0 { - return "", "", 0, false - } - - return s[i+1 : j], unescapeLinkHref(s[hrefStart:end]), end + 1, true - } - - return "", "", 0, false - } - - j++ - } - - return "", "", 0, false -} - -// scanHrefClose returns the byte offset of the ')' closing a link -// href starting at start, or -1 when there is none. An escaped ')' -// belongs to the URL and does not close it. -func scanHrefClose(s string, start int) int { - for i := start; i < len(s); i++ { - if s[i] == '\\' && i+1 < len(s) { - i++ - - continue - } - - if s[i] == ')' { - return i - } - } - - return -1 -} - -// scanSingleClose returns where the * or _ span opening at byte i of s -// closes, or -1, reading and extending the span's misses. -func scanSingleClose(s string, i int, misses *closeMisses) int { - if s[i] == '*' { - return scanDelimClose(s, i+1, "*", false) - } - - // an underscore after a word character closes only before a non-word - // one, so my_var_name stays literal. - strict := wordBefore(s, i) - - miss := &misses.plain - if strict { - miss = &misses.strict - } - - if *miss > 0 && i+1 >= *miss { - return -1 - } - - end := scanDelimClose(s, i+1, "_", strict) - if end < 0 { - *miss = i + 1 - } - - return end -} - -// scanDelimClose returns the byte offset of the closing delimiter -// matching delim starting from byte position start, or -1 if no -// closing delimiter is found. It skips over escapes, code spans, -// and link labels so a delimiter inside those does not end the span. -// -// For two-character delimiters it also tracks lone occurrences of -// the delimiter character: when a match sits at the start of a -// three-plus run and a lone character is pending inside the span -// (as in "**a*b***" or "***ab***"), the run's first character -// closes the inner single-character span, so the real close starts -// one character later. -// -// strict skips a close followed by a word character. -func scanDelimClose(s string, start int, delim string, strict bool) int { - lone := 0 - i := start - - for i < len(s) { - c := s[i] - - if c == '\\' && i+1 < len(s) { - i += 2 - - continue - } - - if c == '`' { - if k := strings.IndexByte(s[i+1:], '`'); k >= 0 { - i = i + 1 + k + 1 - - continue - } - - i++ - - continue - } - - if c == '[' { - if _, _, end, ok := scanLink(s, i); ok { - i = end - - continue - } - } - - if strict && strings.HasPrefix(s[i:], delim) && wordAt(s, i+len(delim)) { - i += len(delim) - - continue - } - - if strings.HasPrefix(s[i:], delim) { - if len(delim) == 2 && lone%2 == 1 && i+len(delim) < len(s) && s[i+len(delim)] == delim[0] { - lone-- - i++ - - continue - } - - return i - } - - if len(delim) == 2 && c == delim[0] { - lone++ - } - - i++ - } - - return -1 -} - -// wordBefore reports whether the character ending at byte i of s is a -// letter or digit. -func wordBefore(s string, i int) bool { - r, _ := utf8.DecodeLastRuneInString(s[:i]) - - return unicode.IsLetter(r) || unicode.IsDigit(r) -} - -// wordAt reports whether the character starting at byte i of s is a -// letter or digit. -func wordAt(s string, i int) bool { - r, _ := utf8.DecodeRuneInString(s[i:]) - - return unicode.IsLetter(r) || unicode.IsDigit(r) -} - -// atomsToTextNodes merges adjacent atoms with identical mark sets -// and emits ProseMirror text nodes. -func atomsToTextNodes(atoms []inlineAtom) []document.Block { - if len(atoms) == 0 { - return nil - } - - out := make([]document.Block, 0, len(atoms)) - - var ( - buf strings.Builder - curMarks []activeMark - haveOpen bool - ) - - flush := func() { - if !haveOpen || buf.Len() == 0 { - buf.Reset() - - return - } - - out = append(out, document.Block{ - Type: document.BlockNodeText, - Text: buf.String(), - Marks: marksToDocument(curMarks), - }) - buf.Reset() - } - - for _, a := range atoms { - if a.text == "" { - continue - } - - if !haveOpen || !marksEqual(curMarks, a.marks) { - flush() - - curMarks = a.marks - haveOpen = true - } - - buf.WriteString(a.text) - } - - flush() - - return out -} - -// marksEqual reports whether two mark stacks describe the same set -// of active marks. Order matters because nested marks emit in open -// order. -func marksEqual(a, b []activeMark) bool { - if len(a) != len(b) { - return false - } - - for i := range a { - if a[i].kind != b[i].kind || a[i].href != b[i].href { - return false - } - } - - return true -} - -// marksToDocument converts the parser's internal mark stack into -// document.Mark values suitable for a ProseMirror text node. -func marksToDocument(marks []activeMark) []document.Mark { - if len(marks) == 0 { - return nil - } - - out := make([]document.Mark, 0, len(marks)) - - for _, m := range marks { - dm := document.Mark{Type: m.kind} - if m.kind == _markLink { - dm.Attrs = document.Attributes{_attrHref: m.href} - } - - out = append(out, dm) - } - - return out -} - -// extractMarks converts a document.Mark slice into the parser's -// internal mark stack. Marks of unknown types are dropped. -func extractMarks(marks []document.Mark) []activeMark { - if len(marks) == 0 { - return nil - } - - out := make([]activeMark, 0, len(marks)) - - for _, m := range marks { - switch m.Type { - case _markBold, _markItalic, _markUnderline, _markStrike, _markCode: - out = append(out, activeMark{kind: m.Type}) - case _markLink: - var href string - - if a, ok := m.Attrs.Get(_attrHref); ok { - href = a.String() - } - - out = append(out, activeMark{kind: _markLink, href: href}) - } - } - - return out -} - -// reorderMarks returns desired reordered so that its common prefix -// with active is as long as possible; the marks not shared with -// active keep their original relative order. -func reorderMarks(desired, active []activeMark) []activeMark { - if len(desired) == 0 || len(active) == 0 { - return desired - } - - out := make([]activeMark, 0, len(desired)) - rest := slices.Clone(desired) - - for _, a := range active { - i := slices.Index(rest, a) - if i < 0 { - break - } - - out = append(out, rest[i]) - rest = slices.Delete(rest, i, i+1) - } - - return append(out, rest...) -} - -// isMarkPrefix reports whether prefix is an order-preserving prefix -// of full. -func isMarkPrefix(prefix, full []activeMark) bool { - if len(prefix) > len(full) { - return false - } - - for i, p := range prefix { - if p.kind != full[i].kind || p.href != full[i].href { - return false - } - } - - return true -} - -// openDelim returns the markdown opening delimiter for a mark. -func openDelim(m activeMark) string { - switch m.kind { - case _markBold: - return "**" - case _markItalic: - return "*" - case _markUnderline: - return "_" - case _markStrike: - return "~~" - case _markCode: - return "`" - case _markLink: - return "[" - } - - return "" -} - -// closeDelim returns the markdown closing delimiter for a mark. -// Most marks use the same delimiter for open and close; link uses -// "](url)". -func closeDelim(m activeMark) string { - switch m.kind { - case _markBold: - return "**" - case _markItalic: - return "*" - case _markUnderline: - return "_" - case _markStrike: - return "~~" - case _markCode: - return "`" - case _markLink: - return "](" + escapeLinkHref(m.href) + ")" - } - - return "" -} - -// escapeLinkHref escapes the characters that would otherwise end the -// URL early when the emitted link is parsed back, so an href such as -// ".../Go_(programming_language)" survives the round trip. -func escapeLinkHref(href string) string { - if !strings.ContainsAny(href, "\\)") { - return href - } - - var b strings.Builder - - b.Grow(len(href) + _escapeGrowHeadroom) - - for i := range len(href) { - c := href[i] - if c == '\\' || c == ')' { - b.WriteByte('\\') - } - - b.WriteByte(c) - } - - return b.String() -} - -// unescapeLinkHref is the inverse of escapeLinkHref: it drops the -// backslash from every escaped pair, mirroring how parseSpan treats -// escapes in ordinary text. -func unescapeLinkHref(href string) string { - if !strings.Contains(href, "\\") { - return href - } - - var b strings.Builder - - b.Grow(len(href)) - - for i := 0; i < len(href); i++ { - if href[i] == '\\' && i+1 < len(href) { - i++ - } - - b.WriteByte(href[i]) - } - - return b.String() -} - -// escapeMarkdown returns text with markdown metacharacters escaped -// for emission. Inside a code span, no escaping is performed because -// code content is literal. -func escapeMarkdown(text string, active []activeMark) string { - for _, m := range active { - if m.kind == _markCode { - return text - } - } - - if !strings.ContainsAny(text, "\\*_~`[]") { - return text - } - - var b strings.Builder - - b.Grow(len(text) + _escapeGrowHeadroom) - - for i := range len(text) { - c := text[i] - switch c { - case '\\', '*', '_', '~', '`', '[', ']': - b.WriteByte('\\') - b.WriteByte(c) - default: - b.WriteByte(c) - } - } - - return b.String() -} diff --git a/server/core/internal/assistant/block/markdown_test.go b/server/core/internal/assistant/block/markdown_test.go deleted file mode 100644 index fc2e4cfd..00000000 --- a/server/core/internal/assistant/block/markdown_test.go +++ /dev/null @@ -1,574 +0,0 @@ -package block - -import ( - "strings" - "testing" - - "github.com/oxynote/oxynote/server/core/internal/document" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_ParseInlineMarkdown(t *testing.T) { - cc := map[string]struct { - Input string - Expected []document.Block - }{ - "Plain text": { - Input: "hello world", - Expected: []document.Block{{Type: "text", Text: "hello world"}}, - }, - "Empty string": { - Input: "", - Expected: nil, - }, - "Bold span": { - Input: "say **hi** there", - Expected: []document.Block{ - {Type: "text", Text: "say "}, - {Type: "text", Text: "hi", Marks: []document.Mark{{Type: "bold"}}}, - {Type: "text", Text: " there"}, - }, - }, - "Italic span": { - Input: "*ital* x", - Expected: []document.Block{ - {Type: "text", Text: "ital", Marks: []document.Mark{{Type: "italic"}}}, - {Type: "text", Text: " x"}, - }, - }, - "Underline span": { - Input: "_under_ x", - Expected: []document.Block{ - {Type: "text", Text: "under", Marks: []document.Mark{{Type: "underline"}}}, - {Type: "text", Text: " x"}, - }, - }, - "Underscores inside a word at both ends are literal": { - Input: "my_var_name and a_b_c", - Expected: []document.Block{{Type: "text", Text: "my_var_name and a_b_c"}}, - }, - "Underline span ending a word": { - Input: "foo_bar_", - Expected: []document.Block{ - {Type: "text", Text: "foo"}, - {Type: "text", Text: "bar", Marks: []document.Mark{{Type: "underline"}}}, - }, - }, - "Underline span starting a word": { - Input: "_foo_bar", - Expected: []document.Block{ - {Type: "text", Text: "foo", Marks: []document.Mark{{Type: "underline"}}}, - {Type: "text", Text: "bar"}, - }, - }, - "Underline span inside curly quotes": { - Input: "“_term_”", - Expected: []document.Block{ - {Type: "text", Text: "“"}, - {Type: "text", Text: "term", Marks: []document.Mark{{Type: "underline"}}}, - {Type: "text", Text: "”"}, - }, - }, - "Underline span next to punctuation": { - Input: "(_under_).", - Expected: []document.Block{ - {Type: "text", Text: "("}, - {Type: "text", Text: "under", Marks: []document.Mark{{Type: "underline"}}}, - {Type: "text", Text: ")."}, - }, - }, - "Underline span opened outside a word closes at the next underscore": { - Input: "_snake_case x", - Expected: []document.Block{ - {Type: "text", Text: "snake", Marks: []document.Mark{{Type: "underline"}}}, - {Type: "text", Text: "case x"}, - }, - }, - "Underline span opened inside a word skips a close inside one": { - Input: "my_var_name and_x_", - Expected: []document.Block{ - {Type: "text", Text: "my"}, - {Type: "text", Text: "var_name and_x", Marks: []document.Mark{{Type: "underline"}}}, - }, - }, - "Underscores inside a word of multi-byte letters are literal": { - Input: "é_x_é", - Expected: []document.Block{{Type: "text", Text: "é_x_é"}}, - }, - // each opener would rescan to the end without the failed scan - // being remembered. - "Unclosed underscores inside words are scanned once": { - Input: strings.Repeat("a_a ", 50000), - Expected: []document.Block{{Type: "text", Text: strings.Repeat("a_a ", 50000)}}, - }, - "Strike span": { - Input: "~~gone~~", - Expected: []document.Block{ - {Type: "text", Text: "gone", Marks: []document.Mark{{Type: "strike"}}}, - }, - }, - "Code span is literal": { - Input: "use `os.Exit(1)` then", - Expected: []document.Block{ - {Type: "text", Text: "use "}, - {Type: "text", Text: "os.Exit(1)", Marks: []document.Mark{{Type: "code"}}}, - {Type: "text", Text: " then"}, - }, - }, - "Code span does not parse inner markdown": { - Input: "`**not bold**`", - Expected: []document.Block{ - {Type: "text", Text: "**not bold**", Marks: []document.Mark{{Type: "code"}}}, - }, - }, - "Bold and underline nested": { - Input: "**bold _and_ under**", - Expected: []document.Block{ - {Type: "text", Text: "bold ", Marks: []document.Mark{{Type: "bold"}}}, - {Type: "text", Text: "and", Marks: []document.Mark{{Type: "bold"}, {Type: "underline"}}}, - {Type: "text", Text: " under", Marks: []document.Mark{{Type: "bold"}}}, - }, - }, - "Link with markdown label": { - Input: "[**oxy**](https://x)", - Expected: []document.Block{ - {Type: "text", Text: "oxy", Marks: []document.Mark{ - {Type: "link", Attrs: map[string]any{"href": "https://x"}}, - {Type: "bold"}, - }}, - }, - }, - "Plain link": { - Input: "see [docs](https://docs)", - Expected: []document.Block{ - {Type: "text", Text: "see "}, - {Type: "text", Text: "docs", Marks: []document.Mark{ - {Type: "link", Attrs: map[string]any{"href": "https://docs"}}, - }}, - }, - }, - "Escaped asterisks are literal": { - Input: `\*not italic\*`, - Expected: []document.Block{ - {Type: "text", Text: "*not italic*"}, - }, - }, - "Unmatched bold becomes literal": { - Input: "**unfinished", - Expected: []document.Block{ - {Type: "text", Text: "**unfinished"}, - }, - }, - "Unmatched bracket becomes literal": { - Input: "[label without", - Expected: []document.Block{ - {Type: "text", Text: "[label without"}, - }, - }, - "Empty bold span": { - Input: "x ** ** y", - Expected: []document.Block{ - {Type: "text", Text: "x "}, - {Type: "text", Text: " ", Marks: []document.Mark{{Type: "bold"}}}, - {Type: "text", Text: " y"}, - }, - }, - "Adjacent same-mark runs merge": { - Input: "**a****b**", - Expected: []document.Block{ - {Type: "text", Text: "ab", Marks: []document.Mark{{Type: "bold"}}}, - }, - }, - "Bold run extended by a bold italic tail": { - Input: "**a*b***", - Expected: []document.Block{ - {Type: "text", Text: "a", Marks: []document.Mark{{Type: "bold"}}}, - {Type: "text", Text: "b", Marks: []document.Mark{{Type: "bold"}, {Type: "italic"}}}, - }, - }, - "Bold wrapping italic via a triple run": { - Input: "***bold italic***", - Expected: []document.Block{ - {Type: "text", Text: "bold italic", Marks: []document.Mark{{Type: "bold"}, {Type: "italic"}}}, - }, - }, - "Backtick inside link label": { - Input: "[`x`](https://y)", - Expected: []document.Block{ - {Type: "text", Text: "x", Marks: []document.Mark{ - {Type: "link", Attrs: map[string]any{"href": "https://y"}}, - {Type: "code"}, - }}, - }, - }, - "Escaped bracket inside link label": { - Input: `[a\]b](https://u)`, - Expected: []document.Block{ - {Type: "text", Text: "a]b", Marks: []document.Mark{ - {Type: "link", Attrs: map[string]any{"href": "https://u"}}, - }}, - }, - }, - "Unclosed backtick in link label is literal": { - Input: "[`x](u)", - Expected: []document.Block{ - {Type: "text", Text: "[`x](u)"}, - }, - }, - "Bracket without url part is literal": { - Input: "[x]y", - Expected: []document.Block{ - {Type: "text", Text: "[x]y"}, - }, - }, - "Escaped paren stays inside the href": { - Input: `[Go](https://x/Go_\(lang\))`, - Expected: []document.Block{ - {Type: "text", Text: "Go", Marks: []document.Mark{ - {Type: "link", Attrs: map[string]any{"href": "https://x/Go_(lang)"}}, - }}, - }, - }, - "Link with missing closing paren is literal": { - Input: "[x](u", - Expected: []document.Block{ - {Type: "text", Text: "[x](u"}, - }, - }, - "Escape inside bold span": { - Input: `**a\*b**`, - Expected: []document.Block{ - {Type: "text", Text: "a*b", Marks: []document.Mark{{Type: "bold"}}}, - }, - }, - "Code span inside bold span": { - Input: "**`*`**", - Expected: []document.Block{ - {Type: "text", Text: "*", Marks: []document.Mark{{Type: "bold"}, {Type: "code"}}}, - }, - }, - "Unclosed backtick inside bold span is literal": { - Input: "**a`b**", - Expected: []document.Block{ - {Type: "text", Text: "a`b", Marks: []document.Mark{{Type: "bold"}}}, - }, - }, - "Link inside italic span": { - Input: "*a [l](https://u) b*", - Expected: []document.Block{ - {Type: "text", Text: "a ", Marks: []document.Mark{{Type: "italic"}}}, - {Type: "text", Text: "l", Marks: []document.Mark{ - {Type: "italic"}, - {Type: "link", Attrs: map[string]any{"href": "https://u"}}, - }}, - {Type: "text", Text: " b", Marks: []document.Mark{{Type: "italic"}}}, - }, - }, - "Empty link label and url yields nothing": { - Input: "[]()", - Expected: nil, - }, - "Empty code span is dropped": { - Input: "a``b", - Expected: []document.Block{ - {Type: "text", Text: "ab"}, - }, - }, - "Escape at end of input is literal": { - Input: `a\`, - Expected: []document.Block{ - {Type: "text", Text: `a\`}, - }, - }, - "Adjacent different marks stay separate": { - Input: "*a*_b_", - Expected: []document.Block{ - {Type: "text", Text: "a", Marks: []document.Mark{{Type: "italic"}}}, - {Type: "text", Text: "b", Marks: []document.Mark{{Type: "underline"}}}, - }, - }, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - got := ParseInlineMarkdown(c.Input) - assert.Equal(t, c.Expected, got) - }) - } -} - -func Test_emitInlineMarkdown(t *testing.T) { - cc := map[string]struct { - Input []document.Block - Expected string - - // Markdown asserts a round trip instead of an emitted string: - // parsing it, emitting the result and parsing again has to yield - // the same atoms. - Markdown string - }{ - "Round trip: plain text": {Markdown: "plain text"}, - "Round trip: bold": {Markdown: "**bold**"}, - "Round trip: bold wrapping italic": {Markdown: "***both***"}, - "Round trip: italic": {Markdown: "*italic*"}, - "Round trip: underline": {Markdown: "_underline_"}, - "Round trip: strike": {Markdown: "~~strike~~"}, - "Round trip: code": {Markdown: "`code`"}, - "Round trip: link": {Markdown: "[label](https://x)"}, - "Round trip: mixed marks": {Markdown: "mix **bold** and *italic* and `code`"}, - "Round trip: escaped literal": {Markdown: `escaped \* literal`}, - "Round trip: link inside bold": {Markdown: "**[link inside bold](https://y)**"}, - "Round trip: strike with code mark": {Markdown: "~~strike with `code` inside~~"}, - "Round trip: parenthesised href": {Markdown: `[Go](https://en.wikipedia.org/wiki/Go_\(programming_language\))`}, - "Round trip: bracket in label": {Markdown: `[label with \] bracket](https://x)`}, - "Plain text": { - Input: []document.Block{{Type: "text", Text: "hello"}}, - Expected: "hello", - }, - "Bold only": { - Input: []document.Block{ - {Type: "text", Text: "hi", Marks: []document.Mark{{Type: "bold"}}}, - }, - Expected: "**hi**", - }, - "Plain then bold": { - Input: []document.Block{ - {Type: "text", Text: "say "}, - {Type: "text", Text: "hi", Marks: []document.Mark{{Type: "bold"}}}, - }, - Expected: "say **hi**", - }, - "Special characters escape in plain text": { - Input: []document.Block{ - {Type: "text", Text: "use * carefully"}, - }, - Expected: `use \* carefully`, - }, - "Round trip: underline around a snake case word": {Markdown: "_snake_case_ and my\\_var"}, - "Underline touching a word survives a round trip": { - Input: []document.Block{ - {Type: "text", Text: "“foo"}, - {Type: "text", Text: "bar", Marks: []document.Mark{{Type: "underline"}}}, - {Type: "text", Text: "” my_var"}, - }, - Expected: "“foo_bar_” my\\_var", - }, - "Code text with a backtick falls back to escaped plain text": { - Input: []document.Block{ - {Type: "text", Text: "a`b", Marks: []document.Mark{{Type: "code"}}}, - }, - Expected: "a\\`b", - }, - "Code mark does not escape inside": { - Input: []document.Block{ - {Type: "text", Text: "**", Marks: []document.Mark{{Type: "code"}}}, - }, - Expected: "`**`", - }, - "Link emit": { - Input: []document.Block{ - {Type: "text", Text: "x", Marks: []document.Mark{ - {Type: "link", Attrs: map[string]any{"href": "https://y"}}, - }}, - }, - Expected: "[x](https://y)", - }, - "Link emit escapes a paren in the href": { - Input: []document.Block{ - {Type: "text", Text: "x", Marks: []document.Mark{ - {Type: "link", Attrs: map[string]any{"href": "https://y/a_(b)"}}, - }}, - }, - Expected: `[x](https://y/a_(b\))`, - }, - "Non-text nodes are skipped": { - Input: []document.Block{ - {Type: document.BlockNodeParagraph}, - {Type: "text", Text: "hi"}, - }, - Expected: "hi", - }, - "Incompatible mark order reorders to extend the open run": { - Input: []document.Block{ - {Type: "text", Text: "a", Marks: []document.Mark{{Type: "bold"}}}, - {Type: "text", Text: "b", Marks: []document.Mark{{Type: "italic"}, {Type: "bold"}}}, - }, - Expected: "**a*b***", - }, - "Nested mark closes when the next run drops it": { - Input: []document.Block{ - {Type: "text", Text: "a", Marks: []document.Mark{{Type: "bold"}, {Type: "italic"}}}, - {Type: "text", Text: "b", Marks: []document.Mark{{Type: "bold"}}}, - }, - Expected: "***a*b**", - }, - "Disjoint mark pair closes the unshared active mark": { - Input: []document.Block{ - {Type: "text", Text: "a", Marks: []document.Mark{{Type: "bold"}}}, - {Type: "text", Text: "b", Marks: []document.Mark{{Type: "italic"}, {Type: "underline"}}}, - }, - Expected: "**a***_b_*", - }, - "Disjoint marks close and reopen": { - Input: []document.Block{ - {Type: "text", Text: "a", Marks: []document.Mark{{Type: "bold"}}}, - {Type: "text", Text: "b", Marks: []document.Mark{{Type: "italic"}}}, - }, - Expected: "**a***b*", - }, - "Link href change closes and reopens the link": { - Input: []document.Block{ - {Type: "text", Text: "a", Marks: []document.Mark{ - {Type: "link", Attrs: map[string]any{"href": "https://one"}}, - }}, - {Type: "text", Text: "b", Marks: []document.Mark{ - {Type: "link", Attrs: map[string]any{"href": "https://two"}}, - }}, - }, - Expected: "[a](https://one)[b](https://two)", - }, - "Unknown mark types are dropped": { - Input: []document.Block{ - {Type: "text", Text: "hi", Marks: []document.Mark{{Type: "sparkle"}}}, - }, - Expected: "hi", - }, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - if c.Markdown != "" { - parsed := ParseInlineMarkdown(c.Markdown) - emitted := emitInlineMarkdown(parsed) - - require.Equal(t, parsed, ParseInlineMarkdown(emitted), - "parse(emit(parse(x))) must equal parse(x); emit produced %q", emitted, - ) - - return - } - - got := emitInlineMarkdown(c.Input) - assert.Equal(t, c.Expected, got) - }) - } -} - -func Test_openDelim(t *testing.T) { - cc := map[string]struct { - Mark activeMark - Result string - }{ - "Bold": {Mark: activeMark{kind: "bold"}, Result: "**"}, - "Italic": {Mark: activeMark{kind: "italic"}, Result: "*"}, - "Underline": {Mark: activeMark{kind: "underline"}, Result: "_"}, - "Strike": {Mark: activeMark{kind: "strike"}, Result: "~~"}, - "Code": {Mark: activeMark{kind: "code"}, Result: "`"}, - "Link": {Mark: activeMark{kind: "link", href: "https://x"}, Result: "["}, - "Unknown": {Mark: activeMark{kind: "sparkle"}, Result: ""}, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - assert.Equal(t, c.Result, openDelim(c.Mark)) - }) - } -} - -func Test_closeDelim(t *testing.T) { - cc := map[string]struct { - Mark activeMark - Result string - }{ - "Bold": {Mark: activeMark{kind: "bold"}, Result: "**"}, - "Italic": {Mark: activeMark{kind: "italic"}, Result: "*"}, - "Underline": {Mark: activeMark{kind: "underline"}, Result: "_"}, - "Strike": {Mark: activeMark{kind: "strike"}, Result: "~~"}, - "Code": {Mark: activeMark{kind: "code"}, Result: "`"}, - "Link": {Mark: activeMark{kind: "link", href: "https://x"}, Result: "](https://x)"}, - "Link with a parenthesis in the href": { - Mark: activeMark{kind: "link", href: "https://x/a_(b)"}, - Result: `](https://x/a_(b\))`, - }, - "Unknown": {Mark: activeMark{kind: "sparkle"}, Result: ""}, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - assert.Equal(t, c.Result, closeDelim(c.Mark)) - }) - } -} - -func Test_scanSingleClose(t *testing.T) { - t.Parallel() - - cc := map[string]struct { - Input string - Misses closeMisses - Expected int - Recorded closeMisses - }{ - "Asterisk closes at the next asterisk": { - Input: "*a*", - Expected: 2, - }, - "Underscore outside a word closes at the next underscore": { - Input: "_a_b", - Expected: 2, - }, - "Underscore inside a word skips a close inside one": { - Input: "a_b_c_", - Expected: 5, - }, - "Failed scan is recorded": { - Input: "a_b_c", - Expected: -1, - Recorded: closeMisses{strict: 2}, - }, - "Scan after a recorded failure is skipped": { - Input: "x a_b_", - Misses: closeMisses{strict: 1}, - Expected: -1, - Recorded: closeMisses{strict: 1}, - }, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - misses := c.Misses - - start := strings.IndexAny(c.Input, "*_") - assert.Equal(t, c.Expected, scanSingleClose(c.Input, start, &misses)) - assert.Equal(t, c.Recorded, misses) - }) - } -} - -func Test_wordBefore(t *testing.T) { - t.Parallel() - - assert.True(t, wordBefore("a_", 1)) - assert.True(t, wordBefore("é_", len("é"))) - assert.False(t, wordBefore("“_", len("“"))) - assert.False(t, wordBefore("_", 0)) -} - -func Test_wordAt(t *testing.T) { - t.Parallel() - - assert.True(t, wordAt("_7", 1)) - assert.False(t, wordAt("_”", 1)) - assert.False(t, wordAt("_", 1)) -} diff --git a/server/core/internal/assistant/block/metric.go b/server/core/internal/assistant/block/metric.go deleted file mode 100644 index 97d74635..00000000 --- a/server/core/internal/assistant/block/metric.go +++ /dev/null @@ -1,356 +0,0 @@ -package block - -import ( - "fmt" - "slices" - "strings" - - "github.com/oxynote/oxynote/server/core/internal/document" -) - -// Metric query-row field names. They name the fields inside the -// queries array a metric block carries, not the block's own attributes, -// so they stay with the validation that reads them. -const ( - // _metricQueryName is the display name of one query row. - _metricQueryName = "name" - - // _metricQueryQuery is the query text of one query row. - _metricQueryQuery = "query" - - // _metricQueryLegendFormat is the optional legend template of one - // query row. - _metricQueryLegendFormat = "legendFormat" -) - -// Metric enum values. They mirror the editor's enums in -// web/app/components/editor/blocks/metrics/utils.ts (TimeRangePreset, -// RefreshInterval, the Visualization*Unit enums, MetricBlockWidth, -// MetricSimulationPreset) and web/app/utils/api/data-source/generic-query.ts -// (GenericQueryChartType); a value added there has to be added here or the -// assistant cannot author it. -var ( - // _metricVisualizationTypes are the chart kinds; they equal the - // datasource processor's ChartType values, which the tools package - // pins with a test since this package must not import it. - _metricVisualizationTypes = []string{"line_chart", "bar_chart", "gauge_chart"} - - // _metricTimeRanges are the time-window presets. - _metricTimeRanges = []string{ - "last_5_minutes", "last_15_minutes", "last_30_minutes", - "last_1_hour", "last_3_hours", "last_6_hours", "last_12_hours", "last_24_hours", - "last_2_days", "last_7_days", "last_30_days", "last_90_days", - "last_6_months", "last_1_year", "last_2_years", "last_5_years", - "today", "yesterday", "today_so_far", - "this_week", "this_week_so_far", "this_month", "this_month_so_far", - "this_year", "this_year_so_far", - "previous_week", "previous_month", "previous_year", - } - - // _metricRefreshIntervals are the re-query periods. - _metricRefreshIntervals = []string{"5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"} - - // _metricUnitTypes are the value units: custom, time, data and percent. - _metricUnitTypes = []string{ - "custom", - "nanoseconds", "microseconds", "milliseconds", "seconds", "minutes", "hours", "days", - "bytes", "kilobytes", "megabytes", "gigabytes", "terabytes", - "bits", "kilobits", "megabits", "gigabits", "terabits", - "percent0to100", "percent0to1", - } - - // _metricWidths are the block widths inside a metric_grid. - _metricWidths = []string{"compact", "standard", "wide"} - - // _metricSimulationPresets are the generated series a block draws - // while the metric it documents has no real data to answer with. - _metricSimulationPresets = []string{ - "cpu_usage", "memory_usage", "disk_usage", - "http_requests", "http_latency", "error_rate", - } - - // _metricEnums maps each enum-valued attribute to its values, which - // is the table validateMetric checks against and MetricEnums - // publishes. - _metricEnums = map[string][]string{ - document.AttrVisualizationType: _metricVisualizationTypes, - document.AttrTimeRange: _metricTimeRanges, - document.AttrRefreshInterval: _metricRefreshIntervals, - document.AttrUnitType: _metricUnitTypes, - document.AttrWidth: _metricWidths, - document.AttrSimulationPreset: _metricSimulationPresets, - } -) - -// MetricEnums returns every enum-valued metric attribute with its -// allowed values, keyed by attribute name. The prompt pins itself to it -// so the model is told exactly the values Validate accepts, and nothing -// else. -func MetricEnums() map[string][]string { - out := make(map[string][]string, len(_metricEnums)) - - for k, v := range _metricEnums { - out[k] = slices.Clone(v) - } - - return out -} - -// DeriveSimulation sets the simulation flag on an attribute update. A -// flag the caller sent is dropped. A changed preset switches the -// simulation on, and a null preset switches it off. Any other update -// leaves the stored flag alone. -func DeriveSimulation(update map[string]any, stored document.Attributes) { - delete(update, document.AttrSimulationActive) - - preset, named := update[document.AttrSimulationPreset] - if !named || preset == stored[document.AttrSimulationPreset] { - return - } - - update[document.AttrSimulationActive] = preset != nil -} - -// KeepSimulation copies the stored simulation flag onto each metric in -// next that stored holds with the same uid and preset. A metric re-sent -// after its data arrived then does not start simulating again. It -// writes to next's attribute maps. -func KeepSimulation(next, stored document.Block) { - if next.Type != document.BlockNodeMetricBlock { - for _, child := range next.Content { - KeepSimulation(child, stored) - } - - return - } - - uid, ok := next.UID() - if !ok { - return - } - - old, found := stored.FindByUID(uid) - if !found || old.Type != document.BlockNodeMetricBlock { - return - } - - preset, ok := next.Attrs.Value(document.AttrSimulationPreset) - if !ok || preset != old.Attrs[document.AttrSimulationPreset] { - return - } - - delete(next.Attrs, document.AttrSimulationActive) - - if active, set := old.Attrs.Value(document.AttrSimulationActive); set { - next.Attrs[document.AttrSimulationActive] = active - } -} - -// validateMetric checks a metric block: no content, and every known -// attribute it does carry is well-formed. Only present, non-null values -// are checked — the editor stores null for "unset" and an older block -// may carry attributes this layer does not name — so a block read back -// and re-sent always passes. -func validateMetric(b Block, path string) error { - if err := validateContentless(b, path); err != nil { - return err - } - - return validateMetricAttrs(b.Attrs, path) -} - -// validateMetricAttrs checks a metric block's attributes: every enum -// holds one of its values, every string is a string, every number a -// number, and the queries and thresholds arrays are well-formed. -func validateMetricAttrs(attrs document.Attributes, path string) error { - for _, key := range []string{ - document.AttrVisualizationType, - document.AttrTimeRange, - document.AttrRefreshInterval, - document.AttrUnitType, - document.AttrWidth, - document.AttrSimulationPreset, - } { - if err := validateMetricEnum(attrs, key, path); err != nil { - return err - } - } - - for _, key := range []string{ - document.AttrTitle, - document.AttrDataSourceID, - document.AttrUnitCustom, - document.AttrBaseThresholdColor, - } { - if err := validateMetricString(attrs, key, path); err != nil { - return err - } - } - - for _, key := range []string{ - document.AttrDecimals, - document.AttrAxisBoundsMin, - document.AttrAxisBoundsMax, - } { - if err := validateMetricNumber(attrs, key, path); err != nil { - return err - } - } - - if err := validateMetricQueries(attrs, path); err != nil { - return err - } - - return validateMetricThresholds(attrs, path) -} - -// validateMetricEnum checks that the named attribute, when set, is one -// of its allowed values. -func validateMetricEnum(attrs document.Attributes, key, path string) error { - v, ok := attrs.Value(key) - if !ok { - return nil - } - - s, isString := v.(string) - if !isString || !slices.Contains(_metricEnums[key], s) { - return verr( - joinPath(path, "attrs."+key), - fmt.Sprintf("metric %s must be one of: %s", key, strings.Join(_metricEnums[key], ", ")), - ) - } - - return nil -} - -// validateMetricString checks that the named attribute, when set, is a -// string. -func validateMetricString(attrs document.Attributes, key, path string) error { - v, ok := attrs.Value(key) - if !ok { - return nil - } - - if _, isString := v.(string); !isString { - return verr(joinPath(path, "attrs."+key), fmt.Sprintf("metric %s must be a string", key)) - } - - return nil -} - -// validateMetricNumber checks that the named attribute, when set, is a -// number. -func validateMetricNumber(attrs document.Attributes, key, path string) error { - v, ok := attrs.Value(key) - if !ok { - return nil - } - - if !isNumber(v) { - return verr(joinPath(path, "attrs."+key), fmt.Sprintf("metric %s must be a number", key)) - } - - return nil -} - -// validateMetricQueries checks that queries, when set, is an array of -// objects each carrying a string name and query, and an optional string -// legendFormat. -func validateMetricQueries(attrs document.Attributes, path string) error { - v, ok := attrs.Value(document.AttrQueries) - if !ok { - return nil - } - - rows, err := objectRows(v) - if err != nil { - return verr(joinPath(path, "attrs."+document.AttrQueries), "metric queries must be an array of {name, query, legendFormat} objects") - } - - for i, row := range rows { - rowPath := joinPath(path, fmt.Sprintf("attrs.%s[%d]", document.AttrQueries, i)) - - for _, key := range []string{_metricQueryName, _metricQueryQuery} { - if _, isString := row[key].(string); !isString { - return verr(rowPath, fmt.Sprintf("metric query %s must be a string", key)) - } - } - - if lf, present := row.Value(_metricQueryLegendFormat); present { - if _, isString := lf.(string); !isString { - return verr(rowPath, "metric query legendFormat must be a string") - } - } - } - - return nil -} - -// validateMetricThresholds checks that thresholds, when set, is an array -// of objects whose value, when set, is a number and whose label and -// color, when set, are strings. -func validateMetricThresholds(attrs document.Attributes, path string) error { - v, ok := attrs.Value(document.AttrThresholds) - if !ok { - return nil - } - - rows, err := objectRows(v) - if err != nil { - return verr(joinPath(path, "attrs."+document.AttrThresholds), "metric thresholds must be an array of {value, label, color} objects") - } - - for i, row := range rows { - rowPath := joinPath(path, fmt.Sprintf("attrs.%s[%d]", document.AttrThresholds, i)) - - if val, present := row.Value("value"); present && !isNumber(val) { - return verr(rowPath, "metric threshold value must be a number") - } - - for _, key := range []string{"label", "color"} { - if s, present := row.Value(key); present { - if _, isString := s.(string); !isString { - return verr(rowPath, fmt.Sprintf("metric threshold %s must be a string", key)) - } - } - } - } - - return nil -} - -// objectRows reads an attribute value as a list of objects. -// -// The value reaches this either from the model's JSON or from the -// content column, and both decode an array of objects to []any of -// map[string]any, so that is the only shape there is to read. -func objectRows(v any) ([]document.Attributes, error) { - rows, ok := v.([]any) - if !ok { - return nil, fmt.Errorf("value is %T, not an array", v) - } - - out := make([]document.Attributes, 0, len(rows)) - - for _, r := range rows { - m, isObject := r.(map[string]any) - if !isObject { - return nil, fmt.Errorf("element is %T, not an object", r) - } - - out = append(out, m) - } - - return out, nil -} - -// isNumber reports whether v is one of the numeric types a decoded JSON -// payload or a hand-built attribute map can carry. -func isNumber(v any) bool { - switch v.(type) { - case float64, float32, int, int64, int32: - return true - default: - return false - } -} diff --git a/server/core/internal/assistant/block/metric_test.go b/server/core/internal/assistant/block/metric_test.go deleted file mode 100644 index d0cfa1f3..00000000 --- a/server/core/internal/assistant/block/metric_test.go +++ /dev/null @@ -1,465 +0,0 @@ -package block - -import ( - "testing" - - "github.com/oxynote/oxynote/server/core/internal/document" - "github.com/oxynote/oxynote/server/core/pkg/testutil" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_MetricEnums(t *testing.T) { - t.Parallel() - - got := MetricEnums() - - require.Len(t, got, 6) - assert.Equal(t, []string{"line_chart", "bar_chart", "gauge_chart"}, got[document.AttrVisualizationType]) - assert.Equal(t, []string{"compact", "standard", "wide"}, got[document.AttrWidth]) - assert.Len(t, got[document.AttrSimulationPreset], 6) - assert.Len(t, got[document.AttrTimeRange], 28) - assert.Len(t, got[document.AttrRefreshInterval], 10) - assert.Len(t, got[document.AttrUnitType], 20) - - // the copy protects the package's own table from a caller that - // sorts or appends to what it was handed. - got[document.AttrVisualizationType][0] = "wibble" - assert.Equal(t, "line_chart", MetricEnums()[document.AttrVisualizationType][0]) -} - -func Test_DeriveSimulation(t *testing.T) { - t.Parallel() - - cc := map[string]struct { - Update map[string]any - Stored document.Attributes - Expected map[string]any - }{ - "An update naming no preset leaves the flag alone": { - Update: map[string]any{"title": "Requests"}, - Stored: document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}, - Expected: map[string]any{"title": "Requests"}, - }, - "A flag the caller sent is dropped": { - Update: map[string]any{"title": "Requests", "simulationActive": true}, - Stored: document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}, - Expected: map[string]any{"title": "Requests"}, - }, - "The stored preset sent again leaves the flag alone": { - Update: map[string]any{"simulationPreset": "cpu_usage"}, - Stored: document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}, - Expected: map[string]any{"simulationPreset": "cpu_usage"}, - }, - "A first preset switches the simulation on": { - Update: map[string]any{"simulationPreset": "cpu_usage"}, - Stored: document.Attributes{"simulationPreset": nil}, - Expected: map[string]any{"simulationPreset": "cpu_usage", "simulationActive": true}, - }, - "Another preset switches the simulation on": { - Update: map[string]any{"simulationPreset": "error_rate"}, - Stored: document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}, - Expected: map[string]any{"simulationPreset": "error_rate", "simulationActive": true}, - }, - "A null preset switches the simulation off": { - Update: map[string]any{"simulationPreset": nil}, - Stored: document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}, - Expected: map[string]any{"simulationPreset": nil, "simulationActive": false}, - }, - "A null preset on a block that names none changes nothing": { - Update: map[string]any{"simulationPreset": nil}, - Stored: document.Attributes{}, - Expected: map[string]any{"simulationPreset": nil}, - }, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - DeriveSimulation(c.Update, c.Stored) - - assert.Equal(t, c.Expected, c.Update) - }) - } -} - -func Test_KeepSimulation(t *testing.T) { - t.Parallel() - - metric := func(uid string, attrs document.Attributes) document.Block { - attrs[document.AttrUID] = uid - - return document.Block{Type: document.BlockNodeMetricBlock, Attrs: attrs} - } - - grid := func(items ...document.Block) document.Block { - return document.Block{Type: document.BlockNodeMetricGrid, Content: items} - } - - cc := map[string]struct { - Next document.Block - Stored document.Block - Expected document.Block - }{ - "The same preset keeps a simulation that was switched off": { - Next: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}), - Stored: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}), - Expected: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}), - }, - "The same preset keeps a simulation that is running": { - Next: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}), - Stored: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}), - Expected: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}), - }, - "The same preset stored without a flag drops the derived one": { - Next: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}), - Stored: metric("m1", document.Attributes{"simulationPreset": "cpu_usage"}), - Expected: metric("m1", document.Attributes{"simulationPreset": "cpu_usage"}), - }, - "Another preset keeps what was derived": { - Next: metric("m1", document.Attributes{"simulationPreset": "error_rate", "simulationActive": true}), - Stored: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}), - Expected: metric("m1", document.Attributes{"simulationPreset": "error_rate", "simulationActive": true}), - }, - "A block naming no preset is left alone": { - Next: metric("m1", document.Attributes{"simulationPreset": nil}), - Stored: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}), - Expected: metric("m1", document.Attributes{"simulationPreset": nil}), - }, - "A block the stored tree does not hold is left alone": { - Next: metric("m2", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}), - Stored: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}), - Expected: metric("m2", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}), - }, - "A block without a uid is left alone": { - Next: document.Block{ - Type: document.BlockNodeMetricBlock, - Attrs: document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}, - }, - Stored: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}), - Expected: document.Block{ - Type: document.BlockNodeMetricBlock, - Attrs: document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}, - }, - }, - "A uid the stored tree gives to another kind of block is left alone": { - Next: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}), - Stored: document.Block{ - Type: document.BlockNodeParagraph, - Attrs: document.Attributes{document.AttrUID: "m1"}, - }, - Expected: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}), - }, - "A grid carries the flag of each metric it holds": { - Next: grid( - metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}), - metric("m2", document.Attributes{"simulationPreset": "error_rate", "simulationActive": true}), - ), - Stored: grid( - metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}), - metric("m2", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}), - ), - Expected: grid( - metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}), - metric("m2", document.Attributes{"simulationPreset": "error_rate", "simulationActive": true}), - ), - }, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - KeepSimulation(c.Next, c.Stored) - - assert.Equal(t, c.Expected, c.Next) - }) - } -} - -func Test_validateMetric(t *testing.T) { - t.Parallel() - - cc := map[string]struct { - Block Block - Attrs document.Attributes - Err error - ExpectedPath string - }{ - // a metric says everything in its attrs and carries no blocks of - // its own. - "Text is not accepted": { - Block: Block{Text: "x"}, - Err: assert.AnError, - }, - "Items are not accepted": { - Block: Block{Items: []Block{{Type: BlockParagraph, Text: "x"}}}, - Err: assert.AnError, - }, - "No attrs at all": {}, - "A fully configured block": { - Attrs: document.Attributes{ - document.AttrTitle: "Request rate", - document.AttrDataSourceID: "d1qbc8kv2vg000cnb6ag", - document.AttrVisualizationType: "line_chart", - document.AttrQueries: []any{map[string]any{"name": "Query 1", "query": "up", "legendFormat": "{{job}}"}}, - document.AttrTimeRange: "last_1_hour", - document.AttrRefreshInterval: "5m", - document.AttrThresholds: []any{map[string]any{"value": 90.0, "label": "warn", "color": "#f00"}}, - document.AttrBaseThresholdColor: "#0f0", - document.AttrDecimals: 2, - document.AttrUnitType: "percent0to100", - document.AttrUnitCustom: "rps", - document.AttrAxisBoundsMin: 0, - document.AttrAxisBoundsMax: 100.5, - document.AttrWidth: "wide", - document.AttrSimulationPreset: "cpu_usage", - }, - }, - "Nulls are as absent as missing keys": { - Attrs: document.Attributes{ - document.AttrVisualizationType: nil, - document.AttrTimeRange: nil, - document.AttrQueries: nil, - document.AttrThresholds: nil, - document.AttrDecimals: nil, - document.AttrAxisBoundsMin: nil, - }, - }, - "An unknown attr passes through": { - Attrs: document.Attributes{"config": map[string]any{"type": "line_chart"}, "wibble": 1}, - }, - "Unknown visualization type": { - Attrs: document.Attributes{document.AttrVisualizationType: "pie_chart"}, - Err: assert.AnError, - ExpectedPath: "attrs.visualizationType", - }, - "Non-string visualization type": { - Attrs: document.Attributes{document.AttrVisualizationType: 1}, - Err: assert.AnError, - ExpectedPath: "attrs.visualizationType", - }, - "Unknown time range": { - Attrs: document.Attributes{document.AttrTimeRange: "last_4_hours"}, - Err: assert.AnError, - ExpectedPath: "attrs.timeRange", - }, - "Unknown refresh interval": { - Attrs: document.Attributes{document.AttrRefreshInterval: "45s"}, - Err: assert.AnError, - ExpectedPath: "attrs.refreshInterval", - }, - "Unknown unit type": { - Attrs: document.Attributes{document.AttrUnitType: "furlongs"}, - Err: assert.AnError, - ExpectedPath: "attrs.unitType", - }, - "Unknown width": { - Attrs: document.Attributes{document.AttrWidth: "enormous"}, - Err: assert.AnError, - ExpectedPath: "attrs.width", - }, - "Unknown simulation preset": { - Attrs: document.Attributes{document.AttrSimulationPreset: "solar_flares"}, - Err: assert.AnError, - ExpectedPath: "attrs.simulationPreset", - }, - "Non-string title": { - Attrs: document.Attributes{document.AttrTitle: 1}, - Err: assert.AnError, - ExpectedPath: "attrs.title", - }, - "Non-string data source id": { - Attrs: document.Attributes{document.AttrDataSourceID: 1}, - Err: assert.AnError, - ExpectedPath: "attrs.dataSourceId", - }, - "Non-string unit custom": { - Attrs: document.Attributes{document.AttrUnitCustom: 1}, - Err: assert.AnError, - ExpectedPath: "attrs.unitCustom", - }, - "Non-string base threshold color": { - Attrs: document.Attributes{document.AttrBaseThresholdColor: 1}, - Err: assert.AnError, - ExpectedPath: "attrs.baseThresholdColor", - }, - "Non-string simulation preset": { - Attrs: document.Attributes{document.AttrSimulationPreset: 1}, - Err: assert.AnError, - ExpectedPath: "attrs.simulationPreset", - }, - "Non-numeric decimals": { - Attrs: document.Attributes{document.AttrDecimals: "two"}, - Err: assert.AnError, - ExpectedPath: "attrs.decimals", - }, - "Non-numeric axis bounds min": { - Attrs: document.Attributes{document.AttrAxisBoundsMin: "zero"}, - Err: assert.AnError, - ExpectedPath: "attrs.axisBoundsMin", - }, - "Non-numeric axis bounds max": { - Attrs: document.Attributes{document.AttrAxisBoundsMax: "ten"}, - Err: assert.AnError, - ExpectedPath: "attrs.axisBoundsMax", - }, - "Queries that are not an array": { - Attrs: document.Attributes{document.AttrQueries: "up"}, - Err: assert.AnError, - ExpectedPath: "attrs.queries", - }, - "A query row missing its query": { - Attrs: document.Attributes{document.AttrQueries: []any{map[string]any{"name": "Query 1"}}}, - Err: assert.AnError, - ExpectedPath: "attrs.queries[0]", - }, - "A query row with a non-string legend format": { - Attrs: document.Attributes{ - document.AttrQueries: []any{map[string]any{"name": "Query 1", "query": "up", "legendFormat": 1}}, - }, - Err: assert.AnError, - ExpectedPath: "attrs.queries[0]", - }, - "Thresholds that are not an array": { - Attrs: document.Attributes{document.AttrThresholds: 90}, - Err: assert.AnError, - ExpectedPath: "attrs.thresholds", - }, - "A threshold row with a non-numeric value": { - Attrs: document.Attributes{document.AttrThresholds: []any{map[string]any{"value": "90"}}}, - Err: assert.AnError, - ExpectedPath: "attrs.thresholds[0]", - }, - "A threshold row with a non-string color": { - Attrs: document.Attributes{document.AttrThresholds: []any{map[string]any{"color": 1}}}, - Err: assert.AnError, - ExpectedPath: "attrs.thresholds[0]", - }, - "A threshold row with nothing set at all": { - Attrs: document.Attributes{document.AttrThresholds: []any{map[string]any{}}}, - }, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - b := c.Block - b.Type = BlockMetric - b.Attrs = c.Attrs - - err := validateMetric(b, "") - - testutil.AssertEqualError(t, c.Err, err) - - if c.Err == nil { - return - } - - var ve *validationError - - require.ErrorAs(t, err, &ve, "expected validationError, got %T", err) - assert.Equal(t, c.ExpectedPath, ve.Path, "validationError path mismatch (full: %s)", ve.Error()) - }) - } -} - -func Test_validateMetricEnum(t *testing.T) { - t.Parallel() - - require.NoError(t, validateMetricEnum(document.Attributes{}, document.AttrTimeRange, "")) - require.NoError(t, validateMetricEnum(document.Attributes{document.AttrTimeRange: "today"}, document.AttrTimeRange, "")) - require.Error(t, validateMetricEnum(document.Attributes{document.AttrTimeRange: "tomorrow"}, document.AttrTimeRange, "")) -} - -func Test_validateMetricString(t *testing.T) { - t.Parallel() - - require.NoError(t, validateMetricString(document.Attributes{}, document.AttrTitle, "")) - require.NoError(t, validateMetricString(document.Attributes{document.AttrTitle: ""}, document.AttrTitle, "")) - require.Error(t, validateMetricString(document.Attributes{document.AttrTitle: 1.5}, document.AttrTitle, "")) -} - -func Test_validateMetricNumber(t *testing.T) { - t.Parallel() - - require.NoError(t, validateMetricNumber(document.Attributes{}, document.AttrDecimals, "")) - require.NoError(t, validateMetricNumber(document.Attributes{document.AttrDecimals: 0}, document.AttrDecimals, "")) - require.Error(t, validateMetricNumber(document.Attributes{document.AttrDecimals: true}, document.AttrDecimals, "")) -} - -func Test_validateMetricQueries(t *testing.T) { - t.Parallel() - - require.NoError(t, validateMetricQueries(document.Attributes{}, "")) - require.NoError(t, validateMetricQueries(document.Attributes{ - document.AttrQueries: []any{map[string]any{"name": "a", "query": "up"}}, - }, "")) - require.Error(t, validateMetricQueries(document.Attributes{ - document.AttrQueries: []any{map[string]any{"name": 1, "query": "up"}}, - }, "")) -} - -func Test_validateMetricThresholds(t *testing.T) { - t.Parallel() - - require.NoError(t, validateMetricThresholds(document.Attributes{}, "")) - require.NoError(t, validateMetricThresholds(document.Attributes{ - document.AttrThresholds: []any{map[string]any{"value": 1, "label": "x"}}, - }, "")) - require.Error(t, validateMetricThresholds(document.Attributes{ - document.AttrThresholds: []any{map[string]any{"label": 1}}, - }, "")) -} - -func Test_objectRows(t *testing.T) { - t.Parallel() - - cc := map[string]struct { - Input any - Expected []document.Attributes - Err error - }{ - "Decoded JSON array": { - Input: []any{map[string]any{"a": 1}, map[string]any{"b": 2}}, - Expected: []document.Attributes{{"a": 1}, {"b": 2}}, - }, - "An element that is not an object": { - Input: []any{[]any{1}}, - Err: assert.AnError, - }, - "Empty array": { - Input: []any{}, - Expected: []document.Attributes{}, - }, - "Not an array at all": { - Input: "a", - Err: assert.AnError, - }, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - got, err := objectRows(c.Input) - - testutil.AssertEqualError(t, c.Err, err) - assert.Equal(t, c.Expected, got) - }) - } -} - -func Test_isNumber(t *testing.T) { - t.Parallel() - - for _, v := range []any{1.5, float32(1.5), 1, int64(1), int32(1)} { - assert.True(t, isNumber(v), "%T should be a number", v) - } - - for _, v := range []any{"1", true, nil, []any{1}} { - assert.False(t, isNumber(v), "%T should not be a number", v) - } -} diff --git a/server/core/internal/assistant/block/types.go b/server/core/internal/assistant/block/types.go deleted file mode 100644 index 83c878df..00000000 --- a/server/core/internal/assistant/block/types.go +++ /dev/null @@ -1,120 +0,0 @@ -package block - -import "github.com/oxynote/oxynote/server/core/internal/document" - -// _canonicalTypes maps every ProseMirror node type the AI can read to its -// canonical name. Wrapper nodes the AI never authors — listItem, -// splitDocumentationLeftSide and the parameter-list nesting — are absent: -// they are folded into their macro's typed fields by Compact and have no -// canonical type of their own. -var _canonicalTypes = map[document.BlockNodeType]Type{ - document.BlockNodeParagraph: BlockParagraph, - document.BlockNodeHeading: BlockHeading, - document.BlockNodeBlockquote: BlockBlockquote, - document.BlockNodeBulletList: BlockBulletList, - document.BlockNodeOrderedList: BlockOrderedList, - document.BlockNodeTaskList: BlockTaskList, - document.BlockNodeCalloutBlock: BlockCallout, - document.BlockNodeCodeBlock: BlockCode, - document.BlockNodeTitledCodeBlock: BlockTitledCode, - document.BlockNodeMermaidBlock: BlockMermaid, - document.BlockNodeHorizontalRule: BlockHorizontalRule, - document.BlockNodeImageBlock: BlockImage, - document.BlockNodeFileBlock: BlockFile, - document.BlockNodeFigmaBlock: BlockFigma, - document.BlockNodeMetricBlock: BlockMetric, - document.BlockNodeMetricGrid: BlockMetricGrid, - document.BlockNodeSplitDoc: BlockSplitDoc, - document.BlockNodeParamList: BlockParamList, -} - -// _partNames names the ProseMirror nodes without a canonical type the -// way a message to the model refers to them: by the block they belong -// to. -var _partNames = map[document.BlockNodeType]string{ - document.BlockNodeDoc: "the document root", - document.BlockNodeListItem: "a list entry", - document.BlockNodeTaskItem: "a task list entry", - document.BlockNodeCodeBlockTitle: "a titled_code's title", - document.BlockNodeSplitDocLeft: "a split_doc's left side", - document.BlockNodeSplitDocRight: "a split_doc's right side", - document.BlockNodeParamListHeader: "a split_doc_param_list's header", - document.BlockNodeParamListItem: "a split_doc_param_list row", - document.BlockNodeParamListItemHeader: "a split_doc_param_list row", - document.BlockNodeParamListItemTitle: "a split_doc_param_list row's name", - document.BlockNodeParamListItemType: "a split_doc_param_list row's type", -} - -// _typeOrder lists every canonical type once, in the order the block -// model documents them. Callers publishing the set — a tool schema's -// enum — take it from here so the order is the same on every build, -// which is what keeps a provider's prompt cache warm across restarts. -var _typeOrder = []Type{ - BlockParagraph, - BlockHeading, - BlockBlockquote, - BlockBulletList, - BlockOrderedList, - BlockTaskList, - BlockCallout, - BlockCode, - BlockTitledCode, - BlockMermaid, - BlockHorizontalRule, - BlockImage, - BlockFile, - BlockFigma, - BlockMetric, - BlockMetricGrid, - BlockSplitDoc, - BlockParamList, -} - -// Types returns every canonical block type, in documentation order. -func Types() []string { - out := make([]string, 0, len(_typeOrder)) - - for _, t := range _typeOrder { - out = append(out, string(t)) - } - - return out -} - -// RootTypes returns the canonical block types that may sit directly -// under the document root, in documentation order. The rest reach the -// document inside a macro and are never placed on their own. -func RootTypes() []string { - out := make([]string, 0, len(_allowedAtRoot)) - - for _, t := range _typeOrder { - if _allowedAtRoot[t] { - out = append(out, string(t)) - } - } - - return out -} - -// CanonicalType returns the canonical name of a ProseMirror node type. The -// second return value reports whether the node type has one at all. -func CanonicalType(pm document.BlockNodeType) (Type, bool) { - t, ok := _canonicalTypes[pm] - - return t, ok -} - -// DescribeNode returns how a message to the model names a ProseMirror -// node type: by its canonical type, or by the block it is part of when -// it has no type of its own. An unknown node type is returned as it is. -func DescribeNode(pm document.BlockNodeType) string { - if t, ok := _canonicalTypes[pm]; ok { - return string(t) - } - - if name, ok := _partNames[pm]; ok { - return name - } - - return string(pm) -} diff --git a/server/core/internal/assistant/block/types_test.go b/server/core/internal/assistant/block/types_test.go deleted file mode 100644 index 1399dbbb..00000000 --- a/server/core/internal/assistant/block/types_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package block - -import ( - "testing" - - "github.com/oxynote/oxynote/server/core/internal/document" - "github.com/stretchr/testify/assert" -) - -func Test_CanonicalType(t *testing.T) { - cc := map[string]struct { - PM document.BlockNodeType - Result Type - Found bool - }{ - "Mapped node type": { - PM: document.BlockNodeParagraph, - Result: BlockParagraph, - Found: true, - }, - "Macro node type": { - PM: document.BlockNodeParamList, - Result: BlockParamList, - Found: true, - }, - "Wrapper node type the canonical model folds away": { - PM: document.BlockNodeListItem, - }, - "Unknown node type": { - PM: document.BlockNodeType("nonsense"), - }, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - res, ok := CanonicalType(c.PM) - assert.Equal(t, c.Found, ok) - assert.Equal(t, c.Result, res) - }) - } -} - -// every canonical type Expand knows how to build must be reachable from a -// ProseMirror type, or a block the AI writes could never be read back. -func Test_canonicalTypesCoverExpand(t *testing.T) { - t.Parallel() - - covered := make(map[Type]bool, len(_canonicalTypes)) - - for _, ct := range _canonicalTypes { - covered[ct] = true - } - - for _, tp := range []Type{ - BlockParagraph, - BlockHeading, - BlockBlockquote, - BlockBulletList, - BlockOrderedList, - BlockTaskList, - BlockCallout, - BlockCode, - BlockTitledCode, - BlockMermaid, - BlockHorizontalRule, - BlockImage, - BlockFigma, - BlockMetric, - BlockMetricGrid, - BlockSplitDoc, - BlockParamList, - } { - assert.True(t, covered[tp], "%s is not reachable from a ProseMirror type", tp) - } -} - -func Test_DescribeNode(t *testing.T) { - t.Parallel() - - cc := map[string]struct { - PM document.BlockNodeType - Result string - }{ - "Node type with a canonical type": { - PM: document.BlockNodeCalloutBlock, - Result: "callout", - }, - "Node type that is part of a block": { - PM: document.BlockNodeSplitDocLeft, - Result: "a split_doc's left side", - }, - "Document root": { - PM: document.BlockNodeDoc, - Result: "the document root", - }, - "Unknown node type": { - PM: document.BlockNodeType("nonsense"), - Result: "nonsense", - }, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - assert.Equal(t, c.Result, DescribeNode(c.PM)) - }) - } -} diff --git a/server/core/internal/assistant/block/validate.go b/server/core/internal/assistant/block/validate.go deleted file mode 100644 index e455db96..00000000 --- a/server/core/internal/assistant/block/validate.go +++ /dev/null @@ -1,851 +0,0 @@ -package block - -import ( - "errors" - "fmt" - "slices" - "strings" - - "github.com/oxynote/oxynote/server/core/internal/document" -) - -// MaxTextLength caps a block's text, in bytes. Markdown parsing is -// superlinear on some inputs, so the cap bounds what one write costs. -const MaxTextLength = 64 << 10 - -// errFileNotAuthored is returned by every write path handed a file -// block: one exists only by uploading in the editor, so the AI reads and -// moves it but never writes it. -var errFileNotAuthored = errors.New( - "file blocks are created by uploading in the editor and cannot be written by the assistant; " + - "move_block moves an existing one", -) - -// Allowed child-type sets, mirroring the editor's schema rules. -var ( - // _allowedBlockquoteItems mirrors TipTap's default Blockquote - // content (block+) — the same content expression as the document - // root — so anything root-legal may sit inside, and Compact can - // emit any of it back out of a stored blockquote. - _allowedBlockquoteItems = _allowedAtRoot - - // _allowedListItemContent is the set of block types that can be - // nested under a list or task-list entry — the "block*" half of - // the editor's "paragraph block*" content for ListItem and a - // nested TaskItem. The "paragraph" half is the entry itself, which - // validateListEntry checks; the two positions take different types - // and do not share a rule. - _allowedListItemContent = map[Type]bool{ - BlockParagraph: true, - BlockBulletList: true, - BlockOrderedList: true, - BlockTaskList: true, - } - - // _allowedCalloutItems mirrors CalloutBlock.content in - // web/app/components/editor/blocks/callout/index.ts. - _allowedCalloutItems = map[Type]bool{ - BlockParagraph: true, - BlockBulletList: true, - BlockOrderedList: true, - BlockTaskList: true, - } - - // _allowedSplitDocLeftBody is the set of types that may appear - // between the leading heading and any trailing param_lists on - // a split_doc left side. - _allowedSplitDocLeftBody = map[Type]bool{ - BlockParagraph: true, - BlockBulletList: true, - BlockOrderedList: true, - BlockTaskList: true, - BlockCallout: true, - } - - // _allowedSplitDocRight is the set of types that may appear on a - // split_doc right side. - _allowedSplitDocRight = map[Type]bool{ - BlockTitledCode: true, - BlockMetric: true, - BlockMermaid: true, - } - - // _allowedSplitDocLeft is what a block landing on an existing - // split_doc left side may be: the body set plus param_list. The - // leading heading is excluded — it is created with the macro and - // edited in place, never placed next to another block. - _allowedSplitDocLeft = map[Type]bool{ - BlockParagraph: true, - BlockBulletList: true, - BlockOrderedList: true, - BlockTaskList: true, - BlockCallout: true, - BlockParamList: true, - } - - // _allowedInContainer maps a ProseMirror container node to the - // canonical types it accepts as direct children. Containers absent - // from the map — lists, whose direct children are wrapper items, - // and macro internals — accept no canonical block at all. The - // document root is handled by ValidateAsRoot. - _allowedInContainer = map[document.BlockNodeType]map[Type]bool{ - document.BlockNodeBlockquote: _allowedBlockquoteItems, - document.BlockNodeListItem: _allowedListItemContent, - document.BlockNodeTaskItem: _allowedListItemContent, - document.BlockNodeCalloutBlock: _allowedCalloutItems, - document.BlockNodeSplitDocLeft: _allowedSplitDocLeft, - document.BlockNodeSplitDocRight: _allowedSplitDocRight, - document.BlockNodeMetricGrid: {BlockMetric: true}, - } - - // _allowedAtRoot is the set of canonical block types that may - // sit directly under the document root. Types not in this set - // use custom TipTap groups - // (splitDocumentationParameterList, metricBlock, - // titledCodeBlock) and only become valid inside their containing - // macro. - _allowedAtRoot = map[Type]bool{ - BlockParagraph: true, - BlockHeading: true, - BlockBlockquote: true, - BlockBulletList: true, - BlockOrderedList: true, - BlockTaskList: true, - BlockCallout: true, - BlockCode: true, - BlockMermaid: true, - BlockHorizontalRule: true, - BlockImage: true, - BlockFile: true, - BlockFigma: true, - BlockMetricGrid: true, - BlockSplitDoc: true, - } -) - -// validationError is the structured error returned by Validate. -// Path locates the offending block within a nested canonical tree -// using a slash-separated breadcrumb (e.g. "split_doc/left[0]", -// "param_list/params[2]"); Message describes the rule that failed. -type validationError struct { - // Path is a slash-separated breadcrumb identifying the offending - // node. Indices use bracketed integers (e.g. "items[3]"). - Path string - - // Message is a single-sentence description of the violated rule. - Message string -} - -// Error renders the validation error in the form -// "{path}: {message}". -func (e *validationError) Error() string { - if e.Path == "" { - return e.Message - } - - return e.Path + ": " + e.Message -} - -// Validate checks that a canonical Block satisfies every constraint -// Expand and the editor schema require. It is intended to be run on -// AI-supplied input before Expand. Validate stops at the first -// violation and returns a validationError describing it; callers -// can surface that message to the AI to recover precisely. -// -// A nil error means Expand will produce a structurally valid -// document.Block tree (subject to runtime constraints like the -// referenced uid existing, which only matter once the operation -// reaches hocuspocus). -func Validate(b Block) error { - return validateBlock(b, "") -} - -// ValidateAsRoot validates a block being inserted at the top level -// of a document. It runs Validate first and then rejects types -// whose TipTap group restricts them to a specific parent -// (titled_code, metric, param_list) — those types must reach the -// document tree via their macro container (split_doc, metric_grid) -// and never sit at the root. -func ValidateAsRoot(b Block) error { - if err := validateBlock(b, ""); err != nil { - return err - } - - return AllowedInContainer(document.BlockNodeDoc, b.Type) -} - -// ValidateAttrs checks the attribute rules for a block of the given -// type, asking nothing about its content. An attribute-only update -// names no content, so the content rules have nothing to say about it, -// while the attributes it does set answer to exactly the rules a whole -// block's would. Types carrying no attribute rules pass. -// -// Callers merge the update over the block's current attributes before -// calling: a rule reads the attributes the block will end up with, not -// the subset one call happened to name. -func ValidateAttrs(t Type, attrs document.Attributes) error { - switch t { - case BlockHeading: - return validateHeadingAttrs(attrs, "") - case BlockTitledCode: - return validateTitledCodeAttrs(attrs, "") - case BlockCode: - return validateLanguageAttr(attrs, "") - case BlockImage, BlockFigma: - return validateSrcAttrs(t, attrs, "") - case BlockFile: - return errFileNotAuthored - case BlockMetric: - return validateMetricAttrs(attrs, "") - case BlockParagraph, - BlockBlockquote, - BlockBulletList, - BlockOrderedList, - BlockTaskList, - BlockCallout, - BlockMermaid, - BlockHorizontalRule, - BlockMetricGrid, - BlockSplitDoc, - BlockParamList: - // these carry no attribute the canonical model constrains: - // callout's icon is a free string, and the rest have none. - return nil - } - - return nil -} - -// ValidateInContainer validates a block that is about to land among the -// direct children of the given ProseMirror node. It runs Validate first -// and then checks the container's child rules: the document root takes -// the root set, macro containers take their own sets, and a node whose -// direct children are wrapper items — a list, a macro internal — -// accepts no canonical block at all. -func ValidateInContainer(container document.BlockNodeType, b Block) error { - if err := validateBlock(b, ""); err != nil { - return err - } - - return AllowedInContainer(container, b.Type) -} - -// AllowedInContainer reports whether a block of type t may sit among -// the direct children of the given ProseMirror node, asking nothing -// about the block's content. A move carries a block that is already in -// the document, so its content is not in question — only where it is -// allowed to land. -func AllowedInContainer(container document.BlockNodeType, t Type) error { - if container == document.BlockNodeDoc { - if !_allowedAtRoot[t] { - return verr("", fmt.Sprintf( - "%s is not allowed at the document root; it must appear inside %s", - t, containerForType(t), - )) - } - - return nil - } - - allowed, ok := _allowedInContainer[container] - if !ok { - return verr("", "blocks cannot be placed directly inside "+DescribeNode(container)) - } - - if !allowed[t] { - return verr("", fmt.Sprintf( - "%s is not allowed inside %s, which holds only %s", - t, DescribeNode(container), listAllowed(allowed), - )) - } - - return nil -} - -// containerForType returns a human-readable description of the -// macro that must contain a non-root block type. Used to build -// helpful error messages for ValidateAsRoot. -func containerForType(t Type) string { - switch t { - case BlockTitledCode: - return "split_doc.right" - case BlockMetric: - return "metric_grid or split_doc.right" - case BlockParamList: - return "split_doc.left" - default: - return "its parent macro" - } -} - -// validateBlock dispatches on b.Type. Path is the breadcrumb to b -// from the validation root (passed in by recursive calls). -func validateBlock(b Block, path string) error { - if len(b.Text) > MaxTextLength { - return verr(joinPath(path, "text"), fmt.Sprintf("text is longer than %d bytes; split it across blocks", MaxTextLength)) - } - - switch b.Type { - case "": - return verr(path, "block type is required") - case BlockParagraph, BlockMermaid: - return validateTextBearing(b, path) - case BlockCode: - if err := validateTextBearing(b, path); err != nil { - return err - } - - return validateLanguageAttr(b.Attrs, path) - case BlockHeading: - return validateHeading(b, path) - case BlockBlockquote: - return validateBlockquote(b, path) - case BlockBulletList, BlockOrderedList: - return validateList(b, path) - case BlockTaskList: - return validateTaskList(b, path) - case BlockCallout: - return validateCallout(b, path) - case BlockTitledCode: - return validateTitledCode(b, path) - case BlockHorizontalRule: - return validateContentless(b, path) - case BlockMetric: - return validateMetric(b, path) - case BlockImage, BlockFigma: - return validateAtomWithSrc(b, path) - case BlockFile: - return verr(path, errFileNotAuthored.Error()) - case BlockMetricGrid: - return validateMetricGrid(b, path) - case BlockSplitDoc: - return validateSplitDoc(b, path) - case BlockParamList: - return validateParamList(b, path) - } - - return verr(path, fmt.Sprintf( - "unknown block type %q; the types are %s", - b.Type, strings.Join(Types(), ", "), - )) -} - -// validateTextBearing checks that the block carries only inline Text -// content and no compound fields. Used for paragraph, code and mermaid -// (heading, blockquote and titled_code layer their own rules on top of -// this). -func validateTextBearing(b Block, path string) error { - if err := mustNotHaveCompoundFields(b, path); err != nil { - return err - } - - if len(b.Items) != 0 { - return verr(path, fmt.Sprintf("%s does not accept items", b.Type)) - } - - return nil -} - -// validateHeading layers the level-attr rule on top of the -// text-bearing checks. -func validateHeading(b Block, path string) error { - if err := validateTextBearing(b, path); err != nil { - return err - } - - return validateHeadingAttrs(b.Attrs, path) -} - -// validateHeadingAttrs checks a heading's level. -func validateHeadingAttrs(attrs document.Attributes, path string) error { - if a, ok := attrs.Get(document.AttrLevel); !ok || a.Int() < 1 || a.Int() > 3 { - return verr(joinPath(path, "attrs.level"), "heading level must be 1, 2, or 3") - } - - return nil -} - -// validateBlockquote checks the text-or-items exclusivity rule and -// the allowed item types. Empty is legal: a blockquote holding one -// empty paragraph compacts to neither text nor items, and Expand -// turns the empty form back into that single paragraph. -func validateBlockquote(b Block, path string) error { - if err := mustNotHaveCompoundFields(b, path); err != nil { - return err - } - - if b.Text != "" && len(b.Items) > 0 { - return verr(path, "blockquote accepts either text or items, not both") - } - - if b.Text != "" { - return nil - } - - return validateItemsAllowed(b.Items, joinPath(path, "items"), _allowedBlockquoteItems) -} - -// validateList checks a bullet_list or ordered_list: no text, at -// least one item, items of allowed types. -func validateList(b Block, path string) error { - if err := mustNotHaveCompoundFields(b, path); err != nil { - return err - } - - if b.Text != "" { - return verr(path, fmt.Sprintf("%s does not accept text", b.Type)) - } - - if len(b.Items) == 0 { - return verr(path, fmt.Sprintf("%s requires at least one item", b.Type)) - } - - return validateListItems(b.Items, joinPath(path, "items")) -} - -// validateTaskList checks that rows live in task_items and each -// row's content block is an allowed list-item type. -func validateTaskList(b Block, path string) error { - if err := mustHaveNoCanonicalCompoundExcept(b, path, "task_items"); err != nil { - return err - } - - if b.Text != "" { - return verr(path, "task_list does not accept text") - } - - if len(b.TaskItems) == 0 { - return verr(path, "task_list requires at least one task_item") - } - - for i, ti := range b.TaskItems { - itemPath := joinPath(path, fmt.Sprintf("task_items[%d]", i)) - - children := ti.Block.Children - ti.Block.Children = nil - - if err := validateBlock(ti.Block, joinPath(itemPath, "block")); err != nil { - return err - } - - if err := validateListEntry(ti.Block.Type, joinPath(itemPath, "block")); err != nil { - return err - } - - if err := validateItemsAllowed( - children, - joinPath(itemPath, "block/children"), - _allowedListItemContent, - ); err != nil { - return err - } - } - - return nil -} - -// validateCallout checks the text-or-items exclusivity rule and -// the allowed item types. Empty is legal: a callout holding one -// empty paragraph compacts to neither text nor items, and Expand -// turns the empty form back into that single paragraph. -func validateCallout(b Block, path string) error { - if err := mustHaveNoCanonicalCompoundExcept(b, path, "items"); err != nil { - return err - } - - if b.Text != "" && len(b.Items) > 0 { - return verr(path, "callout accepts either text or items, not both") - } - - if b.Text != "" { - return nil - } - - return validateItemsAllowed(b.Items, joinPath(path, "items"), _allowedCalloutItems) -} - -// validateTitledCode checks that a titled_code block carries a -// non-empty title attr and no compound content. -func validateTitledCode(b Block, path string) error { - if err := validateTextBearing(b, path); err != nil { - return err - } - - return validateTitledCodeAttrs(b.Attrs, path) -} - -// validateTitledCodeAttrs checks that a titled_code carries a title -// and a language that is a string, if any. -func validateTitledCodeAttrs(attrs document.Attributes, path string) error { - if a, ok := attrs.Get(document.AttrTitle); !ok || strings.TrimSpace(a.String()) == "" { - return verr(joinPath(path, "attrs.title"), "titled_code requires a non-empty title") - } - - return validateLanguageAttr(attrs, path) -} - -// validateLanguageAttr checks that a code language, when set, is a -// string. The editor's highlighter fails on any other value, and the -// document with it stops rendering. -func validateLanguageAttr(attrs document.Attributes, path string) error { - v, ok := attrs.Value(document.AttrLanguage) - if !ok { - return nil - } - - if _, isString := v.(string); !isString { - return verr(joinPath(path, "attrs.language"), "code language must be a string") - } - - return nil -} - -// validateContentless checks that the block carries no content at all, -// leaving its attrs as the only thing it can say. Used for -// horizontal_rule and metric (image and figma layer the src rule on -// top of this). -func validateContentless(b Block, path string) error { - if err := mustNotHaveCompoundFields(b, path); err != nil { - return err - } - - if b.Text != "" || len(b.Items) != 0 { - return verr(path, fmt.Sprintf("%s does not accept content", b.Type)) - } - - return nil -} - -// validateAtomWithSrc checks that the block carries no content and -// points somewhere. Used for image and figma. -func validateAtomWithSrc(b Block, path string) error { - if err := validateContentless(b, path); err != nil { - return err - } - - return validateSrcAttrs(b.Type, b.Attrs, path) -} - -// validateSrcAttrs checks that an image or figma block carries a src. -func validateSrcAttrs(t Type, attrs document.Attributes, path string) error { - if a, ok := attrs.Get(document.AttrSrc); !ok || strings.TrimSpace(a.String()) == "" { - return verr(joinPath(path, "attrs.src"), fmt.Sprintf("%s requires a non-empty src", t)) - } - - return nil -} - -// validateMetricGrid checks that every item is a metric and there -// is at least one. -func validateMetricGrid(b Block, path string) error { - if err := mustHaveNoCanonicalCompoundExcept(b, path, "items"); err != nil { - return err - } - - if b.Text != "" { - return verr(path, "metric_grid does not accept text") - } - - if len(b.Items) == 0 { - return verr(path, "metric_grid requires at least one metric item") - } - - for i, item := range b.Items { - if item.Type != BlockMetric { - return verr( - joinPath(path, fmt.Sprintf("items[%d]", i)), - fmt.Sprintf("metric_grid items must be metric, got %s", item.Type), - ) - } - - if err := validateBlock(item, joinPath(path, fmt.Sprintf("items[%d]", i))); err != nil { - return err - } - } - - return nil -} - -// validateSplitDoc checks the split_doc shape: a level-1 heading -// leading the left side, body blocks before any param_lists, and -// titled_code/metric/mermaid blocks on the right. -func validateSplitDoc(b Block, path string) error { - if err := mustHaveNoCanonicalCompoundExcept(b, path, "left", "right"); err != nil { - return err - } - - if b.Text != "" { - return verr(path, "split_doc does not accept text") - } - - if len(b.Left) == 0 { - return verr(joinPath(path, "left"), "split_doc requires at least one left block") - } - - if len(b.Right) == 0 { - return verr(joinPath(path, "right"), "split_doc requires at least one right block") - } - - // First left block must be a heading at level 1; the rest may be - // paragraphs, lists, callouts, or param_lists in that order - // (heading first, then any number of body blocks, then any - // number of param_lists). - // - // Level 1 mirrors what the web editor's split_doc.createAndFill produces - // for a freshly inserted split_doc — the panel provides its own - // visual emphasis, so the heading inside is always level 1 - // regardless of the surrounding outline depth. - if b.Left[0].Type != BlockHeading { - return verr(joinPath(path, "left[0]"), "split_doc left must begin with a heading") - } - - if a, ok := b.Left[0].Attrs.Get(document.AttrLevel); !ok || a.Int() != 1 { - return verr( - joinPath(path, "left[0].attrs.level"), - "split_doc left heading must be level 1", - ) - } - - seenParamList := false - - for i, lb := range b.Left { - if err := validateBlock(lb, joinPath(path, fmt.Sprintf("left[%d]", i))); err != nil { - return err - } - - if i == 0 { - continue - } - - if lb.Type == BlockParamList { - seenParamList = true - - continue - } - - if seenParamList { - return verr( - joinPath(path, fmt.Sprintf("left[%d]", i)), - "non-param_list blocks must come before any param_list on the left side", - ) - } - - if !_allowedSplitDocLeftBody[lb.Type] { - return verr( - joinPath(path, fmt.Sprintf("left[%d]", i)), - fmt.Sprintf("split_doc left body must be one of %s, got %s", - listAllowed(_allowedSplitDocLeftBody), lb.Type), - ) - } - } - - for i, rb := range b.Right { - if err := validateBlock(rb, joinPath(path, fmt.Sprintf("right[%d]", i))); err != nil { - return err - } - - if !_allowedSplitDocRight[rb.Type] { - return verr( - joinPath(path, fmt.Sprintf("right[%d]", i)), - fmt.Sprintf("split_doc right must be one of %s, got %s", - listAllowed(_allowedSplitDocRight), rb.Type), - ) - } - } - - return nil -} - -// validateParamList checks the header and per-row name -// requirements of a param_list. -func validateParamList(b Block, path string) error { - if err := mustHaveNoCanonicalCompoundExcept(b, path, "header", "params"); err != nil { - return err - } - - if b.Text != "" { - return verr(path, "param_list uses header and params, not text") - } - - if strings.TrimSpace(b.Header) == "" { - return verr(joinPath(path, "header"), "param_list requires a non-empty header") - } - - if len(b.Params) == 0 { - return verr(joinPath(path, "params"), "param_list requires at least one row") - } - - for i, p := range b.Params { - if strings.TrimSpace(p.Name) == "" { - return verr( - joinPath(path, fmt.Sprintf("params[%d]/name", i)), - "param row requires a non-empty name", - ) - } - - if len(p.Description) > MaxTextLength { - return verr( - joinPath(path, fmt.Sprintf("params[%d]/description", i)), - fmt.Sprintf("param description is longer than %d bytes", MaxTextLength), - ) - } - } - - return nil -} - -// mustNotHaveCompoundFields rejects a block that sets any of the -// macro-only fields (TaskItems, Left, Right, Header, Params). Used -// by simple block types that should never see them. -func mustNotHaveCompoundFields(b Block, path string) error { - if len(b.Children) != 0 { - return verr(path, fmt.Sprintf( - "%s does not accept children; only a list or task-list item's content block carries them", - b.Type, - )) - } - - if len(b.TaskItems) != 0 { - return verr(path, fmt.Sprintf("%s does not accept task_items", b.Type)) - } - - if len(b.Left) != 0 || len(b.Right) != 0 { - return verr(path, fmt.Sprintf("%s does not accept left/right", b.Type)) - } - - if b.Header != "" || len(b.Params) != 0 { - return verr(path, fmt.Sprintf("%s does not accept header/params", b.Type)) - } - - return nil -} - -// mustHaveNoCanonicalCompoundExcept enforces that only the listed -// compound fields are populated. Each name is one of "items", -// "task_items", "left", "right", "header", "params". -func mustHaveNoCanonicalCompoundExcept(b Block, path string, allowed ...string) error { - allow := map[string]bool{} - for _, a := range allowed { - allow[a] = true - } - - if !allow["items"] && len(b.Items) != 0 { - return verr(path, fmt.Sprintf("%s does not accept items", b.Type)) - } - - if !allow["task_items"] && len(b.TaskItems) != 0 { - return verr(path, fmt.Sprintf("%s does not accept task_items", b.Type)) - } - - if !allow["left"] && len(b.Left) != 0 { - return verr(path, fmt.Sprintf("%s does not accept left", b.Type)) - } - - if !allow["right"] && len(b.Right) != 0 { - return verr(path, fmt.Sprintf("%s does not accept right", b.Type)) - } - - if !allow["header"] && b.Header != "" { - return verr(path, fmt.Sprintf("%s does not accept header", b.Type)) - } - - if !allow["params"] && len(b.Params) != 0 { - return verr(path, fmt.Sprintf("%s does not accept params", b.Type)) - } - - return nil -} - -// validateListItems checks the content blocks of a list or task list. -// An item's own children — a nested list, in practice — are validated -// separately, since every other position rejects them outright. -func validateListItems(items []Block, path string) error { - for i, item := range items { - itemPath := joinPath(path, fmt.Sprintf("[%d]", i)) - - children := item.Children - item.Children = nil - - if err := validateListEntry(item.Type, itemPath); err != nil { - return err - } - - if err := validateBlock(item, itemPath); err != nil { - return err - } - - if err := validateItemsAllowed(children, joinPath(itemPath, "children"), _allowedListItemContent); err != nil { - return err - } - } - - return nil -} - -// validateListEntry checks the block a list or task-list entry leads -// with. The editor's ListItem and nested TaskItem both hold -// "paragraph block*", so the entry is the paragraph and anything else -// belongs under it — expanding a list as the entry would produce an -// item with no leading paragraph, which the schema does not admit. -func validateListEntry(t Type, path string) error { - if t != BlockParagraph { - return verr(path, fmt.Sprintf( - "a list entry is a %s, got %s; nest other blocks under the entry with children", - BlockParagraph, t, - )) - } - - return nil -} - -// validateItemsAllowed validates a slice of items, requiring each -// to be of a type in allowed. -func validateItemsAllowed(items []Block, path string, allowed map[Type]bool) error { - for i, item := range items { - itemPath := joinPath(path, fmt.Sprintf("[%d]", i)) - if !allowed[item.Type] { - return verr(itemPath, fmt.Sprintf("type %s not allowed here; expected one of %s", - item.Type, listAllowed(allowed))) - } - - if err := validateBlock(item, itemPath); err != nil { - return err - } - } - - return nil -} - -// listAllowed returns a sorted comma-separated list of the keys in -// the allowed-set, for use in error messages. -func listAllowed(m map[Type]bool) string { - names := make([]string, 0, len(m)) - for k := range m { - names = append(names, string(k)) - } - - // Sort so error messages are deterministic for tests. - slices.Sort(names) - - return strings.Join(names, ", ") -} - -// verr is a tiny validationError constructor. -func verr(path, message string) error { - return &validationError{Path: path, Message: message} -} - -// joinPath concatenates a parent breadcrumb with a child segment. -// When the child starts with '[' (an index suffix) the segments -// join without a separator; otherwise a '/' separator is used. -func joinPath(parent, child string) string { - if parent == "" { - return child - } - - if strings.HasPrefix(child, "[") { - return parent + child - } - - return parent + "/" + child -} diff --git a/server/core/internal/assistant/block/validate_test.go b/server/core/internal/assistant/block/validate_test.go deleted file mode 100644 index 78b7bc72..00000000 --- a/server/core/internal/assistant/block/validate_test.go +++ /dev/null @@ -1,1003 +0,0 @@ -package block - -import ( - "strings" - "testing" - - "github.com/oxynote/oxynote/server/core/internal/document" - "github.com/oxynote/oxynote/server/core/pkg/testutil" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_Validate(t *testing.T) { - cc := map[string]struct { - Input Block - Err error - ExpectedPath string - }{ - "Missing type": { - Input: Block{Text: "x"}, - Err: assert.AnError, - }, - "Unknown type": { - Input: Block{Type: "wibble"}, - Err: assert.AnError, - }, - "Paragraph with task_items is rejected": { - Input: Block{Type: BlockParagraph, TaskItems: []TaskItem{{Block: Block{Type: BlockParagraph}}}}, - Err: assert.AnError, - ExpectedPath: "", - }, - "Text over the length cap": { - Input: Block{Type: BlockParagraph, Text: strings.Repeat("x", MaxTextLength+1)}, - Err: assert.AnError, - ExpectedPath: "text", - }, - "Text at the length cap": { - Input: Block{Type: BlockParagraph, Text: strings.Repeat("x", MaxTextLength)}, - }, - "Nested text over the length cap": { - Input: Block{Type: BlockBulletList, Items: []Block{ - {Type: BlockParagraph, Text: strings.Repeat("x", MaxTextLength+1)}, - }}, - Err: assert.AnError, - ExpectedPath: "items[0]/text", - }, - "Param description over the length cap": { - Input: Block{Type: BlockParamList, Header: "Body", Params: []ParamItem{ - {Name: "id", Description: strings.Repeat("x", MaxTextLength+1)}, - }}, - Err: assert.AnError, - ExpectedPath: "params[0]/description", - }, - "Heading without level": { - Input: Block{Type: BlockHeading, Text: "x"}, - Err: assert.AnError, - ExpectedPath: "attrs.level", - }, - "Heading with invalid level": { - Input: Block{Type: BlockHeading, Text: "x", Attrs: map[string]any{"level": 4}}, - Err: assert.AnError, - ExpectedPath: "attrs.level", - }, - "Heading with valid level passes": { - Input: Block{Type: BlockHeading, Text: "x", Attrs: map[string]any{"level": 2}}, - }, - "Bullet list requires items": { - Input: Block{Type: BlockBulletList}, - Err: assert.AnError, - }, - "Bullet list with non-paragraph item is rejected": { - Input: Block{ - Type: BlockBulletList, - Items: []Block{{Type: BlockCode, Text: "x"}}, - }, - Err: assert.AnError, - ExpectedPath: "items[0]", - }, - "Bullet list reports the index of the offending item": { - Input: Block{ - Type: BlockBulletList, - Items: []Block{ - {Type: BlockParagraph, Text: "a"}, - {Type: BlockParagraph, Text: "b"}, - {Type: BlockCode, Text: "x"}, - }, - }, - Err: assert.AnError, - ExpectedPath: "items[2]", - }, - "Task list requires task_items": { - Input: Block{Type: BlockTaskList}, - Err: assert.AnError, - }, - "Task list with empty content block is allowed only as paragraph": { - Input: Block{ - Type: BlockTaskList, - TaskItems: []TaskItem{ - {Block: Block{Type: BlockCode, Text: "x"}}, - }, - }, - Err: assert.AnError, - ExpectedPath: "task_items[0]/block", - }, - "Callout without content passes": { - Input: Block{Type: BlockCallout}, - }, - "Callout with both text and items is rejected": { - Input: Block{ - Type: BlockCallout, - Text: "hi", - Items: []Block{{Type: BlockParagraph, Text: "x"}}, - }, - Err: assert.AnError, - }, - "Titled code requires title": { - Input: Block{Type: BlockTitledCode, Text: "x"}, - Err: assert.AnError, - ExpectedPath: "attrs.title", - }, - "Image requires src": { - Input: Block{Type: BlockImage}, - Err: assert.AnError, - ExpectedPath: "attrs.src", - }, - "Figma requires src": { - Input: Block{Type: BlockFigma}, - Err: assert.AnError, - ExpectedPath: "attrs.src", - }, - "Metric grid requires metric items only": { - Input: Block{ - Type: BlockMetricGrid, - Items: []Block{{Type: BlockParagraph, Text: "x"}}, - }, - Err: assert.AnError, - ExpectedPath: "items[0]", - }, - "Split doc requires left starting with heading": { - Input: Block{ - Type: BlockSplitDoc, - Left: []Block{{Type: BlockParagraph, Text: "x"}}, - Right: []Block{{Type: BlockTitledCode, Text: "y", Attrs: map[string]any{"title": "ex"}}}, - }, - Err: assert.AnError, - ExpectedPath: "left[0]", - }, - "Split doc accepts a mermaid block on the right": { - Input: Block{ - Type: BlockSplitDoc, - Left: []Block{{Type: BlockHeading, Text: "T", Attrs: map[string]any{"level": 1}}}, - Right: []Block{{Type: BlockMermaid, Text: "graph TD"}}, - }, - }, - "Split doc requires right of titled_code, metric or mermaid": { - Input: Block{ - Type: BlockSplitDoc, - Left: []Block{{Type: BlockHeading, Text: "T", Attrs: map[string]any{"level": 1}}}, - Right: []Block{{Type: BlockParagraph, Text: "x"}}, - }, - Err: assert.AnError, - ExpectedPath: "right[0]", - }, - "Split doc heading must be level 1": { - Input: Block{ - Type: BlockSplitDoc, - Left: []Block{ - {Type: BlockHeading, Text: "API", Attrs: map[string]any{"level": 2}}, - }, - Right: []Block{ - {Type: BlockTitledCode, Text: "x", Attrs: map[string]any{"title": "ex"}}, - }, - }, - Err: assert.AnError, - ExpectedPath: "left[0].attrs.level", - }, - "Split doc with valid structure passes": { - Input: Block{ - Type: BlockSplitDoc, - Left: []Block{ - {Type: BlockHeading, Text: "API", Attrs: map[string]any{"level": 1}}, - {Type: BlockParagraph, Text: "explain"}, - }, - Right: []Block{ - {Type: BlockTitledCode, Text: "x", Attrs: map[string]any{"title": "ex"}}, - }, - }, - }, - "Param list requires header and params": { - Input: Block{Type: BlockParamList}, - Err: assert.AnError, - }, - "Param list with empty name is rejected": { - Input: Block{ - Type: BlockParamList, - Header: "Body", - Params: []ParamItem{{Name: " ", Type: "string"}}, - }, - Err: assert.AnError, - ExpectedPath: "params[0]/name", - }, - "Param list with valid rows passes": { - Input: Block{ - Type: BlockParamList, - Header: "Body", - Params: []ParamItem{{Name: "id", Type: "string", Description: "the id"}}, - }, - }, - "Paragraph with items is rejected": { - Input: Block{Type: BlockParagraph, Items: []Block{{Type: BlockParagraph}}}, - Err: assert.AnError, - }, - "Paragraph with left is rejected": { - Input: Block{Type: BlockParagraph, Left: []Block{{Type: BlockParagraph}}}, - Err: assert.AnError, - }, - "Paragraph with params is rejected": { - Input: Block{Type: BlockParagraph, Header: "H", Params: []ParamItem{{Name: "x"}}}, - Err: assert.AnError, - }, - "Heading with task_items is rejected": { - Input: Block{Type: BlockHeading, TaskItems: []TaskItem{{Block: Block{Type: BlockParagraph}}}}, - Err: assert.AnError, - }, - "Blockquote with both text and items is rejected": { - Input: Block{Type: BlockBlockquote, Text: "q", Items: []Block{{Type: BlockParagraph, Text: "x"}}}, - Err: assert.AnError, - }, - "Blockquote without content passes": { - Input: Block{Type: BlockBlockquote}, - }, - "Blockquote with task_items is rejected": { - Input: Block{Type: BlockBlockquote, TaskItems: []TaskItem{{Block: Block{Type: BlockParagraph}}}}, - Err: assert.AnError, - }, - "Blockquote with items passes": { - Input: Block{Type: BlockBlockquote, Items: []Block{{Type: BlockParagraph, Text: "x"}}}, - }, - "Blockquote with a root-legal item passes": { - Input: Block{Type: BlockBlockquote, Items: []Block{ - {Type: BlockHeading, Text: "T", Attrs: map[string]any{"level": 2}}, - {Type: BlockCode, Text: "x"}, - }}, - }, - "Blockquote with a macro-internal item is rejected": { - Input: Block{Type: BlockBlockquote, Items: []Block{{Type: BlockMetric}}}, - Err: assert.AnError, - ExpectedPath: "items[0]", - }, - "Blockquote with invalid nested item is rejected": { - Input: Block{Type: BlockBlockquote, Items: []Block{{Type: BlockBulletList}}}, - Err: assert.AnError, - ExpectedPath: "items[0]", - }, - "Bullet list with invalid nested item is rejected": { - Input: Block{ - Type: BlockBulletList, - Items: []Block{{Type: BlockBulletList}}, - }, - Err: assert.AnError, - ExpectedPath: "items[0]", - }, - "Bullet list whose entry is a well-formed list is rejected": { - Input: Block{ - Type: BlockBulletList, - Items: []Block{{ - Type: BlockBulletList, - Items: []Block{{Type: BlockParagraph, Text: "nested"}}, - }}, - }, - Err: assert.AnError, - ExpectedPath: "items[0]", - }, - "Task list whose entry is a well-formed list is rejected": { - Input: Block{ - Type: BlockTaskList, - TaskItems: []TaskItem{{ - Block: Block{ - Type: BlockBulletList, - Items: []Block{{Type: BlockParagraph, Text: "nested"}}, - }, - }}, - }, - Err: assert.AnError, - ExpectedPath: "task_items[0]/block", - }, - "List item children are accepted": { - Input: Block{ - Type: BlockBulletList, - Items: []Block{{ - Type: BlockParagraph, - Text: "one", - Children: []Block{{ - Type: BlockBulletList, - Items: []Block{{Type: BlockParagraph, Text: "nested"}}, - }}, - }}, - }, - }, - "List item children of a disallowed type are rejected": { - Input: Block{ - Type: BlockBulletList, - Items: []Block{{ - Type: BlockParagraph, - Text: "one", - Children: []Block{{Type: BlockCode, Text: "x"}}, - }}, - }, - Err: assert.AnError, - ExpectedPath: "items[0]/children[0]", - }, - "Task item children of a disallowed type are rejected": { - Input: Block{ - Type: BlockTaskList, - TaskItems: []TaskItem{{ - Block: Block{ - Type: BlockParagraph, - Text: "one", - Children: []Block{{Type: BlockCode, Text: "x"}}, - }, - }}, - }, - Err: assert.AnError, - ExpectedPath: "task_items[0]/block/children[0]", - }, - "Task item children are accepted": { - Input: Block{ - Type: BlockTaskList, - TaskItems: []TaskItem{{ - Block: Block{ - Type: BlockParagraph, - Text: "one", - Children: []Block{{ - Type: BlockBulletList, - Items: []Block{{Type: BlockParagraph, Text: "nested"}}, - }}, - }, - }}, - }, - }, - // only a list or task-list item's content block carries children; - // anywhere else they would be silently dropped by Expand. - "Children outside a list item are rejected": { - Input: Block{Type: BlockParagraph, Text: "x", Children: []Block{{Type: BlockParagraph}}}, - Err: assert.AnError, - }, - "Bullet list with text is rejected": { - Input: Block{Type: BlockBulletList, Text: "x", Items: []Block{{Type: BlockParagraph}}}, - Err: assert.AnError, - }, - "Bullet list with task_items is rejected": { - Input: Block{Type: BlockBulletList, TaskItems: []TaskItem{{Block: Block{Type: BlockParagraph}}}}, - Err: assert.AnError, - }, - "Ordered list without items is rejected": { - Input: Block{Type: BlockOrderedList}, - Err: assert.AnError, - }, - "Bullet list with paragraph items passes": { - Input: Block{Type: BlockBulletList, Items: []Block{{Type: BlockParagraph, Text: "x"}}}, - }, - "Task list with text is rejected": { - Input: Block{ - Type: BlockTaskList, - Text: "x", - TaskItems: []TaskItem{{Block: Block{Type: BlockParagraph}}}, - }, - Err: assert.AnError, - }, - "Task list with left is rejected": { - Input: Block{ - Type: BlockTaskList, - Left: []Block{{Type: BlockParagraph}}, - TaskItems: []TaskItem{{Block: Block{Type: BlockParagraph}}}, - }, - Err: assert.AnError, - }, - "Task list with invalid row block is rejected": { - Input: Block{ - Type: BlockTaskList, - TaskItems: []TaskItem{{Block: Block{Type: BlockHeading, Text: "x"}}}, - }, - Err: assert.AnError, - }, - "Task list with valid rows passes": { - Input: Block{ - Type: BlockTaskList, - TaskItems: []TaskItem{{Checked: true, Block: Block{Type: BlockParagraph, Text: "x"}}}, - }, - }, - "Callout with params is rejected": { - Input: Block{Type: BlockCallout, Text: "x", Params: []ParamItem{{Name: "p"}}}, - Err: assert.AnError, - }, - "Callout with task_items is rejected": { - Input: Block{Type: BlockCallout, Text: "x", TaskItems: []TaskItem{{Block: Block{Type: BlockParagraph}}}}, - Err: assert.AnError, - }, - "Metric grid with right is rejected": { - Input: Block{Type: BlockMetricGrid, Right: []Block{{Type: BlockMetric}}, Items: []Block{{Type: BlockMetric}}}, - Err: assert.AnError, - }, - "Callout with text passes": { - Input: Block{Type: BlockCallout, Text: "watch out"}, - }, - "Callout with items passes": { - Input: Block{Type: BlockCallout, Items: []Block{{Type: BlockParagraph, Text: "x"}}}, - }, - "Callout with disallowed item is rejected": { - Input: Block{Type: BlockCallout, Items: []Block{{Type: BlockMermaid, Text: "x"}}}, - Err: assert.AnError, - ExpectedPath: "items[0]", - }, - "Code with task_items is rejected": { - Input: Block{Type: BlockCode, TaskItems: []TaskItem{{Block: Block{Type: BlockParagraph}}}}, - Err: assert.AnError, - }, - "Code with items is rejected": { - Input: Block{Type: BlockCode, Items: []Block{{Type: BlockParagraph}}}, - Err: assert.AnError, - }, - "Code passes": { - Input: Block{Type: BlockCode, Text: "x := 1"}, - }, - "Code with a language that is not a string": { - Input: Block{Type: BlockCode, Text: "x := 1", Attrs: map[string]any{"language": 1}}, - Err: assert.AnError, - ExpectedPath: "attrs.language", - }, - "Titled code with task_items is rejected": { - Input: Block{Type: BlockTitledCode, TaskItems: []TaskItem{{Block: Block{Type: BlockParagraph}}}}, - Err: assert.AnError, - }, - "Titled code with items is rejected": { - Input: Block{Type: BlockTitledCode, Items: []Block{{Type: BlockParagraph}}}, - Err: assert.AnError, - }, - "Titled code with whitespace title is rejected": { - Input: Block{Type: BlockTitledCode, Text: "x", Attrs: map[string]any{"title": " "}}, - Err: assert.AnError, - ExpectedPath: "attrs.title", - }, - "Titled code with title passes": { - Input: Block{Type: BlockTitledCode, Text: "x", Attrs: map[string]any{"title": "ex.go"}}, - }, - "Mermaid with task_items is rejected": { - Input: Block{Type: BlockMermaid, TaskItems: []TaskItem{{Block: Block{Type: BlockParagraph}}}}, - Err: assert.AnError, - }, - "Mermaid with items is rejected": { - Input: Block{Type: BlockMermaid, Items: []Block{{Type: BlockParagraph}}}, - Err: assert.AnError, - }, - "Mermaid passes": { - Input: Block{Type: BlockMermaid, Text: "graph TD;"}, - }, - "Horizontal rule with task_items is rejected": { - Input: Block{Type: BlockHorizontalRule, TaskItems: []TaskItem{{Block: Block{Type: BlockParagraph}}}}, - Err: assert.AnError, - }, - "Horizontal rule with text is rejected": { - Input: Block{Type: BlockHorizontalRule, Text: "x"}, - Err: assert.AnError, - }, - "Horizontal rule passes": { - Input: Block{Type: BlockHorizontalRule}, - }, - "Image with task_items is rejected": { - Input: Block{Type: BlockImage, TaskItems: []TaskItem{{Block: Block{Type: BlockParagraph}}}}, - Err: assert.AnError, - }, - "Image with text is rejected": { - Input: Block{Type: BlockImage, Text: "x", Attrs: map[string]any{"src": "http://x"}}, - Err: assert.AnError, - }, - "Image with src passes": { - Input: Block{Type: BlockImage, Attrs: map[string]any{"src": "http://x"}}, - }, - "File is rejected as authoring": { - Input: Block{Type: BlockFile, Attrs: map[string]any{"src": "http://x", "name": "notes.zip"}}, - Err: assert.AnError, - }, - "Figma with task_items is rejected": { - Input: Block{Type: BlockFigma, TaskItems: []TaskItem{{Block: Block{Type: BlockParagraph}}}}, - Err: assert.AnError, - }, - "Figma with text is rejected": { - Input: Block{Type: BlockFigma, Text: "x", Attrs: map[string]any{"src": "http://f"}}, - Err: assert.AnError, - }, - "Figma with src passes": { - Input: Block{Type: BlockFigma, Attrs: map[string]any{"src": "http://f"}}, - }, - "Metric with task_items is rejected": { - Input: Block{Type: BlockMetric, TaskItems: []TaskItem{{Block: Block{Type: BlockParagraph}}}}, - Err: assert.AnError, - }, - "Metric with text is rejected": { - Input: Block{Type: BlockMetric, Text: "x"}, - Err: assert.AnError, - }, - "Metric passes": { - Input: Block{Type: BlockMetric, Attrs: map[string]any{"query": "up"}}, - }, - "Metric grid with header is rejected": { - Input: Block{Type: BlockMetricGrid, Header: "H", Items: []Block{{Type: BlockMetric}}}, - Err: assert.AnError, - }, - "Metric grid with text is rejected": { - Input: Block{Type: BlockMetricGrid, Text: "x", Items: []Block{{Type: BlockMetric}}}, - Err: assert.AnError, - }, - "Metric grid without items is rejected": { - Input: Block{Type: BlockMetricGrid}, - Err: assert.AnError, - }, - "Metric grid with invalid metric is rejected": { - Input: Block{Type: BlockMetricGrid, Items: []Block{{Type: BlockMetric, Text: "x"}}}, - Err: assert.AnError, - }, - "Metric grid with metrics passes": { - Input: Block{Type: BlockMetricGrid, Items: []Block{{Type: BlockMetric}}}, - }, - "Split doc with items is rejected": { - Input: Block{Type: BlockSplitDoc, Items: []Block{{Type: BlockParagraph}}}, - Err: assert.AnError, - }, - "Split doc with text is rejected": { - Input: Block{Type: BlockSplitDoc, Text: "x"}, - Err: assert.AnError, - }, - "Split doc without left is rejected": { - Input: Block{ - Type: BlockSplitDoc, - Right: []Block{{Type: BlockTitledCode, Text: "y", Attrs: map[string]any{"title": "ex"}}}, - }, - Err: assert.AnError, - ExpectedPath: "left", - }, - "Split doc without right is rejected": { - Input: Block{ - Type: BlockSplitDoc, - Left: []Block{{Type: BlockHeading, Text: "T", Attrs: map[string]any{"level": 1}}}, - }, - Err: assert.AnError, - ExpectedPath: "right", - }, - "Split doc with invalid left block is rejected": { - Input: Block{ - Type: BlockSplitDoc, - Left: []Block{ - {Type: BlockHeading, Text: "T", Attrs: map[string]any{"level": 1}}, - {Type: BlockBulletList}, - }, - Right: []Block{{Type: BlockTitledCode, Text: "y", Attrs: map[string]any{"title": "ex"}}}, - }, - Err: assert.AnError, - ExpectedPath: "left[1]", - }, - "Split doc body after param_list is rejected": { - Input: Block{ - Type: BlockSplitDoc, - Left: []Block{ - {Type: BlockHeading, Text: "T", Attrs: map[string]any{"level": 1}}, - {Type: BlockParamList, Header: "H", Params: []ParamItem{{Name: "x"}}}, - {Type: BlockParagraph, Text: "late"}, - }, - Right: []Block{{Type: BlockTitledCode, Text: "y", Attrs: map[string]any{"title": "ex"}}}, - }, - Err: assert.AnError, - ExpectedPath: "left[2]", - }, - "Split doc with disallowed left body is rejected": { - Input: Block{ - Type: BlockSplitDoc, - Left: []Block{ - {Type: BlockHeading, Text: "T", Attrs: map[string]any{"level": 1}}, - {Type: BlockCode, Text: "x"}, - }, - Right: []Block{{Type: BlockTitledCode, Text: "y", Attrs: map[string]any{"title": "ex"}}}, - }, - Err: assert.AnError, - ExpectedPath: "left[1]", - }, - "Split doc with invalid right block is rejected": { - Input: Block{ - Type: BlockSplitDoc, - Left: []Block{{Type: BlockHeading, Text: "T", Attrs: map[string]any{"level": 1}}}, - Right: []Block{{Type: BlockTitledCode, Text: "y"}}, - }, - Err: assert.AnError, - }, - "Split doc with trailing param_list passes": { - Input: Block{ - Type: BlockSplitDoc, - Left: []Block{ - {Type: BlockHeading, Text: "T", Attrs: map[string]any{"level": 1}}, - {Type: BlockParagraph, Text: "intro"}, - {Type: BlockParamList, Header: "H", Params: []ParamItem{{Name: "x"}}}, - }, - Right: []Block{{Type: BlockTitledCode, Text: "y", Attrs: map[string]any{"title": "ex"}}}, - }, - }, - "Param list with items is rejected": { - Input: Block{ - Type: BlockParamList, - Header: "H", - Items: []Block{{Type: BlockParagraph}}, - Params: []ParamItem{{Name: "x"}}, - }, - Err: assert.AnError, - }, - "Param list with text is rejected": { - Input: Block{ - Type: BlockParamList, - Text: "x", - Header: "H", - Params: []ParamItem{{Name: "x"}}, - }, - Err: assert.AnError, - }, - "Param list without params is rejected": { - Input: Block{Type: BlockParamList, Header: "H"}, - Err: assert.AnError, - ExpectedPath: "params", - }, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - err := Validate(c.Input) - - testutil.AssertEqualError(t, c.Err, err) - - if c.Err == nil { - return - } - - var ve *validationError - - require.ErrorAs(t, err, &ve, "expected validationError, got %T", err) - - if c.ExpectedPath != "" { - assert.Equal(t, c.ExpectedPath, ve.Path, "validationError path mismatch (full: %s)", ve.Error()) - } - }) - } -} - -func Test_ValidateAsRoot(t *testing.T) { - cc := map[string]struct { - Input Block - Err error - }{ - "Paragraph at root passes": { - Input: Block{Type: BlockParagraph, Text: "hi"}, - }, - "Heading at root passes": { - Input: Block{Type: BlockHeading, Text: "T", Attrs: map[string]any{"level": 1}}, - }, - "Metric grid at root passes": { - Input: Block{ - Type: BlockMetricGrid, - Items: []Block{{Type: BlockMetric}}, - }, - }, - "Split doc at root passes": { - Input: Block{ - Type: BlockSplitDoc, - Left: []Block{ - {Type: BlockHeading, Text: "API", Attrs: map[string]any{"level": 1}}, - }, - Right: []Block{ - {Type: BlockTitledCode, Text: "x", Attrs: map[string]any{"title": "ex"}}, - }, - }, - }, - "Titled code at root is rejected": { - Input: Block{ - Type: BlockTitledCode, - Text: "x", - Attrs: map[string]any{"title": "ex"}, - }, - Err: assert.AnError, - }, - "Metric at root is rejected": { - Input: Block{Type: BlockMetric}, - Err: assert.AnError, - }, - "Param list at root is rejected": { - Input: Block{ - Type: BlockParamList, - Header: "Body", - Params: []ParamItem{{Name: "x"}}, - }, - Err: assert.AnError, - }, - "Invalid block fails before the root check": { - Input: Block{Type: BlockHeading, Text: "x"}, - Err: assert.AnError, - }, - "File at root is rejected as authoring": { - Input: Block{Type: BlockFile, Attrs: map[string]any{"src": "http://x"}}, - Err: assert.AnError, - }, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - err := ValidateAsRoot(c.Input) - - testutil.AssertEqualError(t, c.Err, err) - - if c.Err == nil { - return - } - - var ve *validationError - - assert.ErrorAs(t, err, &ve, "expected validationError, got %T", err) - }) - } -} - -func Test_ValidateAttrs(t *testing.T) { - cc := map[string]struct { - Type Type - Attrs document.Attributes - Err error - }{ - "Heading with a level passes": { - Type: BlockHeading, - Attrs: document.Attributes{"level": 2}, - }, - "Heading without a level fails": { - Type: BlockHeading, - Err: assert.AnError, - }, - "Titled code with a title passes": { - Type: BlockTitledCode, - Attrs: document.Attributes{"title": "ex.go"}, - }, - "Titled code with a language that is not a string fails": { - Type: BlockTitledCode, - Attrs: document.Attributes{"title": "ex.go", "language": true}, - Err: assert.AnError, - }, - "Code with a string language passes": { - Type: BlockCode, - Attrs: document.Attributes{"language": "go"}, - }, - "Code with a null language passes": { - Type: BlockCode, - Attrs: document.Attributes{"language": nil}, - }, - "Code with a language that is not a string fails": { - Type: BlockCode, - Attrs: document.Attributes{"language": 1}, - Err: assert.AnError, - }, - "Image with src passes": { - Type: BlockImage, - Attrs: document.Attributes{"src": "http://x"}, - }, - "Figma without src fails": { - Type: BlockFigma, - Err: assert.AnError, - }, - "File is rejected as authoring": { - Type: BlockFile, - Attrs: document.Attributes{"name": "renamed.zip"}, - Err: errFileNotAuthored, - }, - "Metric with a valid width passes": { - Type: BlockMetric, - Attrs: document.Attributes{"width": "compact"}, - }, - "Paragraph carries no attribute rules": { - Type: BlockParagraph, - Attrs: document.Attributes{"anything": "goes"}, - }, - "Unknown type carries no attribute rules": { - Type: Type("wibble"), - }, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - err := ValidateAttrs(c.Type, c.Attrs) - - testutil.AssertEqualError(t, c.Err, err) - }) - } -} - -func Test_AllowedInContainer(t *testing.T) { - cc := map[string]struct { - Container document.BlockNodeType - Type Type - Err error - }{ - "Paragraph at the root": { - Container: document.BlockNodeDoc, - Type: BlockParagraph, - }, - // a file block is never written, but an existing one can be moved - // among the root blocks the way an image can. - "File at the root": { - Container: document.BlockNodeDoc, - Type: BlockFile, - }, - "Metric at the root": { - Container: document.BlockNodeDoc, - Type: BlockMetric, - Err: assert.AnError, - }, - "Metric in a metric grid": { - Container: document.BlockNodeMetricGrid, - Type: BlockMetric, - }, - "File in a metric grid": { - Container: document.BlockNodeMetricGrid, - Type: BlockFile, - Err: assert.AnError, - }, - "Unknown container": { - Container: document.BlockNodeType("wibble"), - Type: BlockParagraph, - Err: assert.AnError, - }, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - err := AllowedInContainer(c.Container, c.Type) - - testutil.AssertEqualError(t, c.Err, err) - }) - } -} - -func Test_ValidateInContainer(t *testing.T) { - titledCode := Block{ - Type: BlockTitledCode, - Text: "x", - Attrs: map[string]any{"title": "ex"}, - } - paragraph := Block{Type: BlockParagraph, Text: "hi"} - - cc := map[string]struct { - Container document.BlockNodeType - Input Block - Err error - }{ - "Root container delegates to the root check": { - Container: document.BlockNodeDoc, - Input: titledCode, - Err: assert.AnError, - }, - "Root container passes a root block": { - Container: document.BlockNodeDoc, - Input: paragraph, - }, - "Invalid block fails before the container check": { - Container: document.BlockNodeCalloutBlock, - Input: Block{Type: BlockHeading, Text: "x"}, - Err: assert.AnError, - }, - "Callout passes a paragraph": { - Container: document.BlockNodeCalloutBlock, - Input: paragraph, - }, - "Callout rejects a titled code": { - Container: document.BlockNodeCalloutBlock, - Input: titledCode, - Err: assert.AnError, - }, - "Callout rejects a nested callout": { - Container: document.BlockNodeCalloutBlock, - Input: Block{Type: BlockCallout, Text: "note"}, - Err: assert.AnError, - }, - "List item passes a paragraph": { - Container: document.BlockNodeListItem, - Input: paragraph, - }, - "Task item rejects a heading": { - Container: document.BlockNodeTaskItem, - Input: Block{Type: BlockHeading, Text: "T", Attrs: map[string]any{"level": 1}}, - Err: assert.AnError, - }, - "Blockquote passes a bullet list": { - Container: document.BlockNodeBlockquote, - Input: Block{ - Type: BlockBulletList, - Items: []Block{{Type: BlockParagraph, Text: "a"}}, - }, - }, - "Split doc right passes a titled code": { - Container: document.BlockNodeSplitDocRight, - Input: titledCode, - }, - "Split doc right passes a mermaid block": { - Container: document.BlockNodeSplitDocRight, - Input: Block{Type: BlockMermaid, Text: "graph TD"}, - }, - "Split doc right rejects a paragraph": { - Container: document.BlockNodeSplitDocRight, - Input: paragraph, - Err: assert.AnError, - }, - "Split doc left passes a param list": { - Container: document.BlockNodeSplitDocLeft, - Input: Block{ - Type: BlockParamList, - Header: "Body", - Params: []ParamItem{{Name: "x"}}, - }, - }, - "Split doc left rejects a heading": { - Container: document.BlockNodeSplitDocLeft, - Input: Block{Type: BlockHeading, Text: "T", Attrs: map[string]any{"level": 1}}, - Err: assert.AnError, - }, - "Metric grid passes a metric": { - Container: document.BlockNodeMetricGrid, - Input: Block{Type: BlockMetric}, - }, - "Metric grid rejects a paragraph": { - Container: document.BlockNodeMetricGrid, - Input: paragraph, - Err: assert.AnError, - }, - "Wrapper-only container rejects everything": { - Container: document.BlockNodeBulletList, - Input: paragraph, - Err: assert.AnError, - }, - "Macro internal rejects everything": { - Container: document.BlockNodeTitledCodeBlock, - Input: paragraph, - Err: assert.AnError, - }, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - err := ValidateInContainer(c.Container, c.Input) - - testutil.AssertEqualError(t, c.Err, err) - - if c.Err == nil { - return - } - - var ve *validationError - - assert.ErrorAs(t, err, &ve, "expected validationError, got %T", err) - }) - } -} - -func Test_validationError_Error(t *testing.T) { - t.Parallel() - - // with path - err := &validationError{Path: "items[0]", Message: "bad block"} - assert.Equal(t, "items[0]: bad block", err.Error()) - - // without path - err = &validationError{Message: "bad block"} - assert.Equal(t, "bad block", err.Error()) -} - -func Test_containerForType(t *testing.T) { - cc := map[string]struct { - Type Type - Result string - }{ - "Titled code": {Type: BlockTitledCode, Result: "split_doc.right"}, - "Metric": {Type: BlockMetric, Result: "metric_grid or split_doc.right"}, - "Param list": {Type: BlockParamList, Result: "split_doc.left"}, - "Fallback": {Type: BlockParagraph, Result: "its parent macro"}, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - assert.Equal(t, c.Result, containerForType(c.Type)) - }) - } -} diff --git a/server/core/internal/assistant/edit/client.go b/server/core/internal/assistant/edit/client.go index 100db81a..09cd9069 100644 --- a/server/core/internal/assistant/edit/client.go +++ b/server/core/internal/assistant/edit/client.go @@ -16,17 +16,11 @@ import ( // surfaced to callers. const _maxErrorPreviewBytes = 1024 -// Result is the outcome of an Apply call. Applied counts the -// operations Node committed to the Y.Doc; Errors lists per-op -// failures (e.g. uid not found, malformed block). A partial-success -// response has Applied < len(ops) and at least one entry in Errors. +// Result is the outcome of an Apply call. A batch applies whole or not +// at all: Errors is empty only when every operation landed. type Result struct { - // Applied is the number of operations the Node side committed - // to the live Y.Doc. - Applied int `json:"applied"` - - // Errors contains one entry per failed operation, in input - // order. An empty slice means every operation succeeded. + // Errors holds the operation that failed, if any. The batch stops + // at the first failure. Errors []OpError `json:"errors"` } @@ -57,7 +51,7 @@ func NewClient(httpClient *http.Client, baseURL string) *Client { } // Apply submits the given operations to the Node service for the -// (documentID, branchID) document and returns the per-op outcome. +// (documentID, branchID) document and returns the outcome. // // A system batch is one core originated rather than a person: its persist // is allowed onto a protected branch, where the writes an editor or the @@ -65,11 +59,11 @@ func NewClient(httpClient *http.Client, baseURL string) *Client { // pass true — nothing reachable from a prompt may. The userID is the // person an ordinary batch is made for, and its persist credits them. It // is empty for a system batch. -// Any transport-level failure (bad status, malformed response, -// canonical expansion error) is returned as an error and no -// operations are considered applied; failures of individual -// operations within an otherwise successful HTTP response are -// surfaced via Result.Errors. +// Any transport-level failure (bad status, malformed response) is +// returned as an error and no +// operations are considered applied; an operation the service +// refused within an otherwise successful HTTP response is surfaced +// via Result.Errors. func (c *Client) Apply( ctx context.Context, documentID, branchID xid.ID, @@ -81,22 +75,11 @@ func (c *Client) Apply( return Result{}, nil } - wires := make([]wireOp, 0, len(ops)) - - for i, op := range ops { - w, err := op() - if err != nil { - return Result{}, fmt.Errorf("operation %d: %w", i, err) - } - - wires = append(wires, w) - } - body, err := json.Marshal(struct { - Operations []wireOp `json:"operations"` - UserID string `json:"userId,omitempty"` - System bool `json:"system"` - }{Operations: wires, UserID: userID, System: system}) + Operations []Operation `json:"operations"` + UserID string `json:"userId,omitempty"` + System bool `json:"system"` + }{Operations: ops, UserID: userID, System: system}) if err != nil { return Result{}, fmt.Errorf("marshaling operations: %w", err) } diff --git a/server/core/internal/assistant/edit/client_test.go b/server/core/internal/assistant/edit/client_test.go index 0e62714e..3e0542a9 100644 --- a/server/core/internal/assistant/edit/client_test.go +++ b/server/core/internal/assistant/edit/client_test.go @@ -47,39 +47,30 @@ func Test_Client_Apply(t *testing.T) { } cc := map[string]struct { - Ops []Operation - UserID string - System bool - BaseURL string - CloseEarly bool - TruncateBody bool - StatusCode int - ResponseBody string - ExpectedApplied int - ExpectedErrors []OpError - ExpectedPath string - Err error + Ops []Operation + UserID string + System bool + BaseURL string + CloseEarly bool + TruncateBody bool + StatusCode int + ResponseBody string + ExpectedErrors []OpError + ExpectedPath string + Err error // NoRequest asserts Apply returned before any HTTP round trip. NoRequest bool }{ - "Operation failure skips the request": { - // an operation that cannot produce its wire form fails before - // the client builds a request at all. - Ops: []Operation{func() (wireOp, error) { return wireOp{}, assert.AnError }}, - Err: assert.AnError, - NoRequest: true, - }, - "Successful batch reports applied count": { + "Successful batch": { Ops: []Operation{ Append(paragraph()), Delete("uid-1"), }, - StatusCode: 200, - ResponseBody: `{"applied": 2, "errors": []}`, - ExpectedApplied: 2, - ExpectedErrors: []OpError{}, - ExpectedPath: path, + StatusCode: 200, + ResponseBody: `{"errors": []}`, + ExpectedErrors: []OpError{}, + ExpectedPath: path, }, // the mark is what a protected branch accepts a persist on, so // it has to reach the wire rather than stay a caller's intent. @@ -87,32 +78,29 @@ func Test_Client_Apply(t *testing.T) { Ops: []Operation{ Append(paragraph()), }, - System: true, - StatusCode: 200, - ResponseBody: `{"applied": 1, "errors": []}`, - ExpectedApplied: 1, - ExpectedErrors: []OpError{}, - ExpectedPath: path, + System: true, + StatusCode: 200, + ResponseBody: `{"errors": []}`, + ExpectedErrors: []OpError{}, + ExpectedPath: path, }, "An ordinary batch names its user": { Ops: []Operation{ Append(paragraph()), }, - UserID: "user-1", - StatusCode: 200, - ResponseBody: `{"applied": 1, "errors": []}`, - ExpectedApplied: 1, - ExpectedErrors: []OpError{}, - ExpectedPath: path, + UserID: "user-1", + StatusCode: 200, + ResponseBody: `{"errors": []}`, + ExpectedErrors: []OpError{}, + ExpectedPath: path, }, "Partial failure surfaces per-op errors": { Ops: []Operation{ Delete("missing"), Append(paragraph()), }, - StatusCode: 200, - ResponseBody: `{"applied": 1, "errors": [{"index": 0, "message": "block_uid not found: missing"}]}`, - ExpectedApplied: 1, + StatusCode: 200, + ResponseBody: `{"errors": [{"index": 0, "message": "block_uid not found: missing"}]}`, ExpectedErrors: []OpError{ {Index: 0, Message: "block_uid not found: missing"}, }, @@ -127,10 +115,9 @@ func Test_Client_Apply(t *testing.T) { Err: assert.AnError, }, "Empty ops returns empty result without HTTP call": { - Ops: nil, - StatusCode: 0, - ExpectedApplied: 0, - NoRequest: true, + Ops: nil, + StatusCode: 0, + NoRequest: true, }, "Invalid base URL fails before the request": { Ops: []Operation{Delete("x")}, @@ -213,7 +200,6 @@ func Test_Client_Apply(t *testing.T) { return } - assert.Equal(t, c.ExpectedApplied, res.Applied) assert.Equal(t, c.ExpectedErrors, res.Errors) if len(c.Ops) > 0 { diff --git a/server/core/internal/assistant/edit/error.go b/server/core/internal/assistant/edit/error.go index 8afbb094..176b363b 100644 --- a/server/core/internal/assistant/edit/error.go +++ b/server/core/internal/assistant/edit/error.go @@ -1,17 +1,18 @@ package edit import ( + "errors" "fmt" "regexp" "strings" - "github.com/oxynote/oxynote/server/core/internal/assistant/block" + "github.com/oxynote/oxynote/server/core/internal/assistant/markup" "github.com/oxynote/oxynote/server/core/internal/document" ) -// _nodeName matches a camel-cased word, the shape of the ProseMirror node -// types the realtime service names in its messages. -var _nodeName = regexp.MustCompile(`\b[a-z]+[A-Z][A-Za-z]*\b`) +// _nodeName matches a word that may be one of the ProseMirror node types +// the realtime service names in its messages. +var _nodeName = regexp.MustCompile(`\b[a-z][A-Za-z]*\b`) // OpError describes one operation's failure on the Node side. type OpError struct { @@ -25,9 +26,8 @@ type OpError struct { // describe rewrites the Node-side message into what the model can act // on: a uid it holds no block for points at get_document, a reference -// inside the moved block says what to pick instead, and an operation -// kind is named as the tool the model knows it by, and a node type by -// the canonical name the model knows. A message with no rewrite passes +// inside the moved block says what to pick instead, and a node type is +// named by the element the model knows. A message with no rewrite passes // through as it is. func (e OpError) describe() string { for _, prefix := range []string{"reference_uid not found: ", "block_uid not found: "} { @@ -40,22 +40,24 @@ func (e OpError) describe() string { return fmt.Sprintf("reference block %s sits inside the block being moved; choose a reference outside it", uid) } - msg := strings.ReplaceAll(e.Message, "update_text", "update_block_text") - - return _nodeName.ReplaceAllStringFunc(msg, func(w string) string { - return block.DescribeNode(document.BlockNodeType(w)) + return _nodeName.ReplaceAllStringFunc(e.Message, func(w string) string { + return markup.DescribeNode(document.BlockNodeType(w)) }) } -// JoinOpErrors renders per-operation failures as one message. The -// index is left out: a tool sends a single operation, so naming its -// position says nothing the reader can act on. -func JoinOpErrors(errs []OpError) string { - msgs := make([]string, 0, len(errs)) - - for _, e := range errs { - msgs = append(msgs, e.describe()) +// Err renders the failed operation as an error, or returns nil when +// every operation landed. When the batch held more than one operation, +// the message says nothing was applied and numbers the failure from 1, +// since the reader needs to know which one failed. +func (r Result) Err(ops int) error { + if len(r.Errors) == 0 { + return nil } - return strings.Join(msgs, "; ") + e := r.Errors[0] + if ops == 1 { + return errors.New(e.describe()) + } + + return fmt.Errorf("nothing was applied; operation %d: %s", e.Index+1, e.describe()) } diff --git a/server/core/internal/assistant/edit/error_test.go b/server/core/internal/assistant/edit/error_test.go index 84918602..35d89807 100644 --- a/server/core/internal/assistant/edit/error_test.go +++ b/server/core/internal/assistant/edit/error_test.go @@ -1,8 +1,10 @@ package edit import ( + "errors" "testing" + "github.com/oxynote/oxynote/server/core/pkg/testutil" "github.com/stretchr/testify/assert" ) @@ -25,13 +27,13 @@ func Test_OpError_describe(t *testing.T) { Msg: "reference_uid is inside the moved block: r1", Result: "reference block r1 sits inside the block being moved; choose a reference outside it", }, - "Operation kind named as its tool": { - Msg: "update_text does not apply to calloutBlock: use replace_block to rewrite it whole, or update_text on the block holding the text.", - Result: "update_block_text does not apply to callout: use replace_block to rewrite it whole, or update_block_text on the block holding the text.", + "Node types named by their elements": { + Msg: "paragraph is not allowed there in calloutBlock, which takes bulletList or orderedList at that point.", + Result: "

is not allowed there in , which takes