From 9011a1ce6299a541e0a7da15efcf532f085fb7ee Mon Sep 17 00:00:00 2001 From: tianma-if Date: Tue, 13 Oct 2026 13:25:16 +0800 Subject: [PATCH] feat(plugins): add guarded editing for structured notes --- apps/api/src/mcp-diagram-tools.ts | 432 +---------------- apps/api/src/mcp-poster-tools.ts | 246 +--------- .../src/mcp-structured-operations.test.mjs | 61 +++ apps/api/src/mcp-table-tools.ts | 249 +--------- apps/api/src/poster-content-layout.ts | 82 +--- apps/web/src/lib/desktop-repository.test.mjs | 24 +- apps/web/src/lib/desktop-repository.ts | 24 +- apps/web/src/lib/local-memo-guard.ts | 15 + .../plugins/infographic-operations.test.mjs | 86 ++++ .../lib/plugins/infographic-syntax-adapter.ts | 13 + apps/web/src/lib/plugins/plugin-host.test.mjs | 234 ++++++++++ apps/web/src/lib/plugins/plugin-host.ts | 181 +++++++- .../src/lib/plugins/structured-note-api.ts | 56 +++ apps/web/src/lib/repository-events.ts | 4 +- apps/web/src/lib/repository.test.mjs | 37 ++ apps/web/src/lib/repository.ts | 10 +- crates/desktop-sidecar/src/memo.rs | 43 +- crates/desktop-sidecar/src/rpc.rs | 6 + docs/plugin-development.md | 63 +++ docs/plugin-development.zh-CN.md | 63 +++ package.json | 3 +- packages/plugin-api/README.md | 16 + packages/plugin-api/dist/index.d.ts | 90 +++- packages/plugin-api/dist/index.js | 2 +- .../plugin-api/dist/structured-notes.d.ts | 358 +++++++++++++++ packages/plugin-api/src/index.ts | 25 +- packages/plugin-api/src/structured-notes.ts | 119 +++++ packages/shared/src/desktop-rpc.ts | 3 + packages/shared/src/diagram-operations.ts | 434 ++++++++++++++++++ packages/shared/src/index.ts | 7 + packages/shared/src/infographic-operations.ts | 122 +++++ packages/shared/src/poster-content-layout.ts | 77 ++++ packages/shared/src/poster-operations.ts | 238 ++++++++++ packages/shared/src/structured-note-error.ts | 11 + packages/shared/src/table-operations.ts | 274 +++++++++++ tests/plugin-structured-sync.integration.mjs | 288 ++++++++++++ 36 files changed, 3002 insertions(+), 994 deletions(-) create mode 100644 apps/api/src/mcp-structured-operations.test.mjs create mode 100644 apps/web/src/lib/local-memo-guard.ts create mode 100644 apps/web/src/lib/plugins/infographic-operations.test.mjs create mode 100644 apps/web/src/lib/plugins/infographic-syntax-adapter.ts create mode 100644 apps/web/src/lib/plugins/structured-note-api.ts create mode 100644 packages/plugin-api/dist/structured-notes.d.ts create mode 100644 packages/plugin-api/src/structured-notes.ts create mode 100644 packages/shared/src/diagram-operations.ts create mode 100644 packages/shared/src/infographic-operations.ts create mode 100644 packages/shared/src/poster-content-layout.ts create mode 100644 packages/shared/src/poster-operations.ts create mode 100644 packages/shared/src/structured-note-error.ts create mode 100644 packages/shared/src/table-operations.ts create mode 100644 tests/plugin-structured-sync.integration.mjs diff --git a/apps/api/src/mcp-diagram-tools.ts b/apps/api/src/mcp-diagram-tools.ts index 5fbdfea3..f2515239 100644 --- a/apps/api/src/mcp-diagram-tools.ts +++ b/apps/api/src/mcp-diagram-tools.ts @@ -1,431 +1,21 @@ import { - ARCHITECTURE_RESOURCE_ICONS, - docToText, - markdownToDoc, - parseDiagramDocument, - serializeDiagramDocument, - stripDiagramDocumentMarker, - type DiagramDocument, - type DiagramNodeShape, - type MemoDetail, + applyDiagramOperations as apply, + parseDiagramMemoIr as parse, + StructuredNoteError, } from "@edgeever/shared"; -import type { DiagramIr, DiagramIrNodeType } from "@edgeever/shared/diagram-layout"; import { AppError } from "./app-error"; -import { createId } from "./entity-utils"; -import { getRequiredString } from "./mcp-json-rpc"; -const DIAGRAM_IR_NODE_TYPES = new Set([ - "topic", "process", "decision", "start", "end", "terminator", "client", "frontend", "service", "database", "storage", - "queue", "security", "external", "boundary", -]); -const DIAGRAM_EDGE_KINDS = new Set(["dependency", "request", "async", "data"]); -const DIAGRAM_MEMO_KEYS = new Set(["notebookId", "title", "kind", "theme", "structure", "layout", "tags", "nodes", "edges"]); -const DIAGRAM_NODE_KEYS = new Set(["id", "label", "type", "parentId", "resourceIcon"]); -const DIAGRAM_EDGE_KEYS = new Set(["source", "target", "label", "type", "bidirectional"]); +export { diagramSemanticGraph, memoWithoutDiagramPayload } from "@edgeever/shared"; -const isPlainRecord = (value: unknown): value is Record => - Boolean(value) && typeof value === "object" && !Array.isArray(value); - -const assertAllowedKeys = (value: Record, allowed: Set, path: string) => { - const unexpected = Object.keys(value).find((key) => !allowed.has(key)); - if (unexpected) throw new AppError("invalid_params", `${path}.${unexpected} is not supported`, 400); +const translate = (error: unknown): never => { + if (error instanceof StructuredNoteError) throw new AppError(error.code, error.message, error.status); + throw error; }; -const parseDiagramNode = (value: unknown, index: number) => { - const path = `nodes.${index}`; - if (!isPlainRecord(value)) throw new AppError("invalid_params", `${path} must be an object`, 400); - assertAllowedKeys(value, DIAGRAM_NODE_KEYS, path); - const id = getRequiredString(value.id, `${path}.id`); - if (id.length > 100) throw new AppError("invalid_params", `${path}.id must be at most 100 characters`, 400); - if (typeof value.label !== "string" || value.label.length > 500) { - throw new AppError("invalid_params", `${path}.label must be a string with at most 500 characters`, 400); - } - if (value.type !== undefined && ( - typeof value.type !== "string" || !DIAGRAM_IR_NODE_TYPES.has(value.type as DiagramIrNodeType) - )) { - throw new AppError("invalid_params", `${path}.type is not supported`, 400); - } - if (value.parentId !== undefined && (typeof value.parentId !== "string" || !value.parentId.trim() || value.parentId.length > 100)) { - throw new AppError("invalid_params", `${path}.parentId must be a non-empty string with at most 100 characters`, 400); - } - if (value.resourceIcon !== undefined && ( - typeof value.resourceIcon !== "string" - || !ARCHITECTURE_RESOURCE_ICONS.includes(value.resourceIcon as typeof ARCHITECTURE_RESOURCE_ICONS[number]) - )) { - throw new AppError("invalid_params", `${path}.resourceIcon is not supported`, 400); - } - return { ...value, id } as DiagramIr["nodes"][number]; +export const parseDiagramMemoIr: typeof parse = (...args) => { + try { return parse(...args); } catch (error) { return translate(error); } }; -const parseDiagramEdge = (value: unknown, index: number) => { - const path = `edges.${index}`; - if (!isPlainRecord(value)) throw new AppError("invalid_params", `${path} must be an object`, 400); - assertAllowedKeys(value, DIAGRAM_EDGE_KEYS, path); - const source = getRequiredString(value.source, `${path}.source`); - const target = getRequiredString(value.target, `${path}.target`); - if ([source, target].some((item) => item.length > 100)) { - throw new AppError("invalid_params", `${path} node IDs must be at most 100 characters`, 400); - } - if (value.label !== undefined && (typeof value.label !== "string" || value.label.length > 500)) { - throw new AppError("invalid_params", `${path}.label must be a string with at most 500 characters`, 400); - } - if (value.type !== undefined && (typeof value.type !== "string" || !DIAGRAM_EDGE_KINDS.has(value.type))) { - throw new AppError("invalid_params", `${path}.type is not supported`, 400); - } - if (value.bidirectional !== undefined && typeof value.bidirectional !== "boolean") { - throw new AppError("invalid_params", `${path}.bidirectional must be a boolean`, 400); - } - return { ...value, source, target } as NonNullable[number]; -}; - -export const parseDiagramMemoIr = (args: Record): DiagramIr => { - assertAllowedKeys(args, DIAGRAM_MEMO_KEYS, "arguments"); - const kind = getRequiredString(args.kind, "kind"); - if (!(["mind-map", "flowchart", "architecture"] as const).includes(kind as DiagramIr["kind"])) { - throw new AppError("invalid_params", "kind must be mind-map, flowchart, or architecture", 400); - } - if (!Array.isArray(args.nodes) || args.nodes.length < 1 || args.nodes.length > 200) { - throw new AppError("invalid_params", "nodes must include between 1 and 200 items", 400); - } - if (args.edges !== undefined && (!Array.isArray(args.edges) || args.edges.length > 400)) { - throw new AppError("invalid_params", "edges must be an array with at most 400 items", 400); - } - if (args.theme !== undefined && !["plain", "brand", "sun", "wa", "island", "rose", "mint", "cosmos", "tea", "naive", "macaron", "ocean", "ink", "classic", "paper"].includes(String(args.theme))) { - throw new AppError("invalid_params", "theme is not a supported diagram color scheme", 400); - } - if (args.structure !== undefined && !["map", "line", "capsule", "box", "circle", "ellipse", "hexagon", "logic", "tree", "brace", "org", "timeline", "fishbone"].includes(String(args.structure))) { - throw new AppError("invalid_params", "structure is not a supported mind-map shape", 400); - } - if (args.title !== undefined && (typeof args.title !== "string" || args.title.length > 160)) { - throw new AppError("invalid_params", "title must be a string with at most 160 characters", 400); - } - if (args.tags !== undefined && ( - !Array.isArray(args.tags) || args.tags.length > 100 || args.tags.some((tag) => typeof tag !== "string") - )) { - throw new AppError("invalid_params", "tags must be an array of at most 100 strings", 400); - } - if (args.layout !== undefined && ( - !isPlainRecord(args.layout) - || Object.keys(args.layout).some((key) => key !== "direction") - || (args.layout.direction !== undefined && !["left-to-right", "top-to-bottom"].includes(String(args.layout.direction))) - )) { - throw new AppError("invalid_params", "layout may only specify direction as left-to-right or top-to-bottom", 400); - } - - const ir: DiagramIr = { - kind: kind as DiagramIr["kind"], - ...(args.theme === undefined ? {} : { theme: args.theme as DiagramIr["theme"] }), - ...(args.structure === undefined ? {} : { structure: args.structure as DiagramIr["structure"] }), - ...(args.layout === undefined ? {} : { layout: args.layout as DiagramIr["layout"] }), - nodes: args.nodes.map(parseDiagramNode), - edges: (args.edges as unknown[] | undefined)?.map(parseDiagramEdge), - }; - const nodeIds = new Set(ir.nodes.map((node) => node.id)); - if (nodeIds.size !== ir.nodes.length) throw new AppError("invalid_params", "node IDs must be unique", 400); - for (const node of ir.nodes) { - const allowedTypes = ir.kind === "mind-map" - ? new Set([undefined, "topic"]) - : ir.kind === "flowchart" - ? new Set([undefined, "process", "decision", "start", "end", "terminator"]) - : new Set([undefined, "client", "frontend", "service", "database", "storage", "queue", "security", "external", "boundary"]); - if (!allowedTypes.has(node.type)) { - throw new AppError("invalid_params", `${node.type} is not a valid node type for ${ir.kind}`, 400); - } - if (ir.kind === "flowchart" && node.parentId) { - throw new AppError("invalid_params", "flowchart nodes cannot use parentId", 400); - } - if (ir.kind !== "architecture" && node.resourceIcon) { - throw new AppError("invalid_params", "resourceIcon is only available for architecture nodes", 400); - } - if (node.parentId && !nodeIds.has(node.parentId)) { - throw new AppError("invalid_params", `${node.id}.parentId must reference an existing node`, 400); - } - if (ir.kind === "architecture" && node.parentId && ir.nodes.find((candidate) => candidate.id === node.parentId)?.type !== "boundary") { - throw new AppError("invalid_params", `${node.id}.parentId must reference an architecture boundary`, 400); - } - } - for (const edge of ir.edges ?? []) { - if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) { - throw new AppError("invalid_params", "every edge endpoint must reference an existing node", 400); - } - if (ir.kind === "architecture" && ir.nodes.some((node) => (node.id === edge.source || node.id === edge.target) && node.type === "boundary")) { - throw new AppError("invalid_params", "architecture boundaries cannot be edge endpoints", 400); - } - } - const parentByNodeId = new Map(ir.nodes.flatMap((node) => node.parentId ? [[node.id, node.parentId]] : [])); - for (const node of ir.nodes) { - const ancestors = new Set(); - let currentId: string | undefined = node.id; - while (currentId) { - if (ancestors.has(currentId)) { - throw new AppError("invalid_params", "diagram parent relationships must not contain cycles", 400); - } - ancestors.add(currentId); - currentId = parentByNodeId.get(currentId); - } - } - return ir; -}; - -const diagramNodeType = (shape: DiagramNodeShape): DiagramIrNodeType => shape; - -export const diagramSemanticGraph = (document: DiagramDocument, includeLayout = false) => ({ - kind: document.kind, - ...(document.theme ? { theme: document.theme } : {}), - ...(document.structure ? { structure: document.structure } : {}), - nodes: document.nodes.map((node) => ({ - id: node.id, - label: node.label, - type: diagramNodeType(node.shape), - ...(node.parentId ? { parentId: node.parentId } : {}), - ...(node.resourceIcon ? { resourceIcon: node.resourceIcon } : {}), - ...(includeLayout ? { layout: { x: node.x, y: node.y, width: node.width, height: node.height } } : {}), - })), - edges: document.edges.map((edge) => ({ - id: edge.id, - source: edge.source, - target: edge.target, - ...(edge.label ? { label: edge.label } : {}), - ...(edge.kind ? { type: edge.kind } : {}), - ...(edge.bidirectional !== undefined ? { bidirectional: edge.bidirectional } : {}), - })), -}); - -export const memoWithoutDiagramPayload = (memo: MemoDetail) => { - const contentMarkdown = stripDiagramDocumentMarker(memo.contentMarkdown); - const contentJson = markdownToDoc(contentMarkdown); - return { ...memo, contentMarkdown, contentJson, contentText: docToText(contentJson) }; -}; - -type MutableDiagramEdge = ReturnType["edges"][number]; - -export const applyDiagramOperations = async ( - args: Record, - current: DiagramDocument, -) => { - assertAllowedKeys(args, new Set(["memoId", "expectedRevision", "operations", "dryRun", "reflow"]), "arguments"); - if (!Array.isArray(args.operations) || args.operations.length < 1 || args.operations.length > 100) { - throw new AppError("invalid_params", "operations must include between 1 and 100 items", 400); - } - if (args.reflow !== undefined && args.reflow !== "preserve" && args.reflow !== "all") { - throw new AppError("invalid_params", "reflow must be preserve or all", 400); - } - - let nodes: DiagramIr["nodes"] = current.nodes.map((node) => ({ - id: node.id, - label: node.label, - type: diagramNodeType(node.shape), - ...(node.parentId ? { parentId: node.parentId } : {}), - ...(node.resourceIcon ? { resourceIcon: node.resourceIcon } : {}), - })); - let edges: MutableDiagramEdge[] = diagramSemanticGraph(current).edges; - const resizedNodeIds = new Set(); - const repositionedNodeIds = new Set(); - const affectedBoundaryIds = new Set(); - const counts = { addedNodes: 0, updatedNodes: 0, removedNodes: 0, addedEdges: 0, updatedEdges: 0, removedEdges: 0 }; - - const requireOperation = (value: unknown, index: number) => { - if (!isPlainRecord(value) || typeof value.op !== "string") { - throw new AppError("invalid_params", `operations.${index} must be an object with an op`, 400); - } - return value; - }; - const findNodeIndex = (nodeId: string) => { - const index = nodes.findIndex((node) => node.id === nodeId); - if (index < 0) throw new AppError("invalid_params", `diagram node ${nodeId} does not exist`, 400); - return index; - }; - const findEdgeIndex = (edgeId: string) => { - const index = edges.findIndex((edge) => edge.id === edgeId); - if (index < 0) throw new AppError("invalid_params", `diagram edge ${edgeId} does not exist`, 400); - return index; - }; - - for (let operationIndex = 0; operationIndex < args.operations.length; operationIndex += 1) { - const operation = requireOperation(args.operations[operationIndex], operationIndex); - const path = `operations.${operationIndex}`; - if (operation.op === "add_node") { - assertAllowedKeys(operation, new Set(["op", "node"]), path); - const node = parseDiagramNode(operation.node, operationIndex); - if (nodes.some((candidate) => candidate.id === node.id)) { - throw new AppError("invalid_params", `diagram node ${node.id} already exists`, 400); - } - nodes.push(node); - if (node.parentId) affectedBoundaryIds.add(node.parentId); - counts.addedNodes += 1; - continue; - } - if (operation.op === "update_node") { - assertAllowedKeys(operation, new Set(["op", "nodeId", "changes"]), path); - const nodeId = getRequiredString(operation.nodeId, `${path}.nodeId`); - if (!isPlainRecord(operation.changes) || Object.keys(operation.changes).length === 0) { - throw new AppError("invalid_params", `${path}.changes must be a non-empty object`, 400); - } - assertAllowedKeys(operation.changes, new Set(["label", "type", "parentId", "resourceIcon"]), `${path}.changes`); - const index = findNodeIndex(nodeId); - const previous = nodes[index]; - const candidate: Record = { ...previous, ...operation.changes, id: nodeId }; - if (operation.changes.parentId === null || operation.changes.parentId === "") delete candidate.parentId; - if (operation.changes.resourceIcon === null || operation.changes.resourceIcon === "") delete candidate.resourceIcon; - const next = parseDiagramNode(candidate, operationIndex); - nodes[index] = next; - if (operation.changes.label !== undefined || operation.changes.type !== undefined) resizedNodeIds.add(nodeId); - if (operation.changes.parentId !== undefined && previous.parentId !== next.parentId) { - repositionedNodeIds.add(nodeId); - if (previous.parentId) affectedBoundaryIds.add(previous.parentId); - if (next.parentId) affectedBoundaryIds.add(next.parentId); - } - counts.updatedNodes += 1; - continue; - } - if (operation.op === "remove_node") { - assertAllowedKeys(operation, new Set(["op", "nodeId", "cascade"]), path); - const nodeId = getRequiredString(operation.nodeId, `${path}.nodeId`); - findNodeIndex(nodeId); - const removalIds = new Set([nodeId]); - let changed = true; - while (changed) { - changed = false; - for (const node of nodes) { - if (node.parentId && removalIds.has(node.parentId) && !removalIds.has(node.id)) { - if (operation.cascade !== true) { - throw new AppError("invalid_params", `${nodeId} contains child nodes; set cascade to true to remove them`, 400); - } - removalIds.add(node.id); - changed = true; - } - } - } - for (const node of nodes) if (removalIds.has(node.id) && node.parentId) affectedBoundaryIds.add(node.parentId); - const priorEdgeCount = edges.length; - nodes = nodes.filter((node) => !removalIds.has(node.id)); - edges = edges.filter((edge) => !removalIds.has(edge.source) && !removalIds.has(edge.target)); - counts.removedNodes += removalIds.size; - counts.removedEdges += priorEdgeCount - edges.length; - continue; - } - if (operation.op === "add_edge") { - assertAllowedKeys(operation, new Set(["op", "edge"]), path); - if (!isPlainRecord(operation.edge)) throw new AppError("invalid_params", `${path}.edge must be an object`, 400); - assertAllowedKeys(operation.edge, new Set([...DIAGRAM_EDGE_KEYS, "id"]), `${path}.edge`); - const { id: requestedId, ...edgeInput } = operation.edge; - const parsed = parseDiagramEdge(edgeInput, operationIndex); - const id = requestedId === undefined ? createId("edge") : getRequiredString(requestedId, `${path}.edge.id`); - if (id.length > 100 || edges.some((edge) => edge.id === id)) { - throw new AppError("invalid_params", `diagram edge ID ${id} is invalid or already exists`, 400); - } - edges.push({ id, ...parsed }); - counts.addedEdges += 1; - continue; - } - if (operation.op === "update_edge") { - assertAllowedKeys(operation, new Set(["op", "edgeId", "changes"]), path); - const edgeId = getRequiredString(operation.edgeId, `${path}.edgeId`); - if (!isPlainRecord(operation.changes) || Object.keys(operation.changes).length === 0) { - throw new AppError("invalid_params", `${path}.changes must be a non-empty object`, 400); - } - assertAllowedKeys(operation.changes, DIAGRAM_EDGE_KEYS, `${path}.changes`); - const index = findEdgeIndex(edgeId); - const candidate: Record = { ...edges[index], ...operation.changes }; - delete candidate.id; - if (operation.changes.label === null || operation.changes.label === "") delete candidate.label; - if (operation.changes.type === null || operation.changes.type === "") delete candidate.type; - if (operation.changes.bidirectional === null) delete candidate.bidirectional; - edges[index] = { id: edgeId, ...parseDiagramEdge(candidate, operationIndex) }; - counts.updatedEdges += 1; - continue; - } - if (operation.op === "remove_edge") { - assertAllowedKeys(operation, new Set(["op", "edgeId"]), path); - const edgeId = getRequiredString(operation.edgeId, `${path}.edgeId`); - edges.splice(findEdgeIndex(edgeId), 1); - counts.removedEdges += 1; - continue; - } - throw new AppError("invalid_params", `${path}.op is not supported`, 400); - } - - if (nodes.length < 1) throw new AppError("invalid_params", "a diagram must contain at least one node", 400); - if (current.kind === "mind-map") { - const priorEdgeCount = edges.length; - edges = edges.filter((edge) => !repositionedNodeIds.has(edge.target) - || nodes.find((node) => node.id === edge.target)?.parentId === edge.source); - counts.removedEdges += priorEdgeCount - edges.length; - for (const node of nodes) { - if (!node.parentId || edges.some((edge) => edge.source === node.parentId && edge.target === node.id)) continue; - edges.push({ id: createId("edge"), source: node.parentId, target: node.id }); - counts.addedEdges += 1; - } - } - const validatedIr = parseDiagramMemoIr({ - kind: current.kind, - ...(current.theme ? { theme: current.theme } : {}), - ...(current.structure ? { structure: current.structure } : {}), - nodes, - edges: edges.map(({ id: _id, ...edge }) => edge), - }); - const { compileDiagramIr } = await import("@edgeever/shared/diagram-layout"); - const next = compileDiagramIr(validatedIr); - next.edges = edges.map((edge) => ({ - id: edge.id, - source: edge.source, - target: edge.target, - ...(edge.label ? { label: edge.label } : {}), - ...(edge.type ? { kind: edge.type } : {}), - ...(edge.bidirectional !== undefined ? { bidirectional: edge.bidirectional } : {}), - })); - - if (args.reflow !== "all") { - const currentById = new Map(current.nodes.map((node) => [node.id, node])); - for (const node of next.nodes) { - const previous = currentById.get(node.id); - if (!previous) continue; - const boundaryNeedsLayout = node.shape === "boundary" && affectedBoundaryIds.has(node.id); - if (repositionedNodeIds.has(node.id) || boundaryNeedsLayout) continue; - node.x = previous.x; - node.y = previous.y; - if (!resizedNodeIds.has(node.id)) { - node.width = previous.width; - node.height = previous.height; - } - } - if (current.kind === "architecture" && affectedBoundaryIds.size > 0) { - const nodeById = new Map(next.nodes.map((node) => [node.id, node])); - for (const boundaryId of [...affectedBoundaryIds]) { - let parentId = nodeById.get(boundaryId)?.parentId; - while (parentId) { - affectedBoundaryIds.add(parentId); - parentId = nodeById.get(parentId)?.parentId; - } - } - const depth = (nodeId: string) => { - let result = 0; - let node = nodeById.get(nodeId); - while (node?.parentId) { - result += 1; - node = nodeById.get(node.parentId); - } - return result; - }; - for (const boundaryId of [...affectedBoundaryIds].sort((left, right) => depth(right) - depth(left))) { - const boundary = nodeById.get(boundaryId); - const children = next.nodes.filter((node) => node.parentId === boundaryId); - if (!boundary || boundary.shape !== "boundary" || children.length === 0) continue; - const left = Math.min(...children.map((node) => node.x)) - 36; - const top = Math.min(...children.map((node) => node.y)) - 56; - const right = Math.max(...children.map((node) => node.x + node.width)) + 36; - const bottom = Math.max(...children.map((node) => node.y + node.height)) + 36; - boundary.x = left; - boundary.y = top; - boundary.width = Math.max(260, right - left); - boundary.height = Math.max(180, bottom - top); - } - } - } - - const contentMarkdown = serializeDiagramDocument(next); - if (!parseDiagramDocument(contentMarkdown)) { - throw new AppError("invalid_params", "The diagram operations produced an invalid document", 400); - } - return { document: next, contentMarkdown, changes: counts }; +export const applyDiagramOperations: typeof apply = async (...args) => { + try { return await apply(...args); } catch (error) { return translate(error); } }; diff --git a/apps/api/src/mcp-poster-tools.ts b/apps/api/src/mcp-poster-tools.ts index f9255d08..3e7eb109 100644 --- a/apps/api/src/mcp-poster-tools.ts +++ b/apps/api/src/mcp-poster-tools.ts @@ -1,235 +1,13 @@ -import { z } from "zod"; -import { createPosterDocument, POSTER_FORMATS, POSTER_TEMPLATES, PosterDocumentSchema, PosterGradientSchema, PosterElementSchema, PosterPaintGradientSchema, resizePosterDocument, PLATFORMS, POSTER_FONTS, POSTER_DESIGN_TEMPLATES, type PosterDocument } from "@edgeever/shared"; +import { compilePosterMemo as compile, applyPosterEdits as apply, StructuredNoteError } from "@edgeever/shared"; import { AppError } from "./app-error"; -import { layoutPosterContent } from "./poster-content-layout"; - -const creation = z.object({ - notebookId: z.string().min(1), - title: z.string().min(1).max(160), - heading: z.string().trim().min(1).max(80), - subtitle: z.string().max(240).default(""), - template: z.enum(POSTER_TEMPLATES).default("editorial"), - format: z.enum(["portrait", "square", "wechat", "video", "story"]).default("portrait"), - badge: z.string().trim().max(24).default(""), - footer: z.string().trim().max(100).default(""), - layout: z.enum(["auto", "grid", "list"]).default("auto"), - sections: z.array(z.object({ heading: z.string().trim().min(1).max(32), body: z.string().trim().min(1).max(120) }).strict()).min(2).max(6).optional(), - tags: z.array(z.string()).max(100).optional(), -}).strict(); - -// Agents supply copy, not the encoded document or hand-written canvas geometry. -export function compilePosterMemo(input: unknown) { - const parsed = creation.safeParse(input); - if (!parsed.success) throw new AppError("invalid_params", "Invalid poster input. Supply notebookId, title and a short heading; use create_poster_memo, never encoded Markdown.", 400); - const args = parsed.data; - const format = POSTER_FORMATS.find(format => format.id === args.format)!; - const document = createPosterDocument(args.heading, args.subtitle, args.template, format.width, format.height); - document.templateId = args.template; - document.copy = { title: args.heading, subtitle: args.subtitle, badge: "" }; - if (args.sections) { - layoutPosterContent(document, { ...args, sections: args.sections }); - return { args, document: PosterDocumentSchema.parse(document) }; - } - if (args.badge || args.footer || args.layout !== "auto") throw new AppError("invalid_params", "Structured layout, badge and footer require sections. Omit them for a headline-only poster.", 400); - for (const element of document.elements) { - if (element.type !== "text") continue; - element.role = element.id === "heading" ? "title" : "subtitle"; - element.sourceText = element.text; - // Reserve separate title/body regions and fit conservative CJK line widths. - // Explicit newlines count too, so long copy does not overlap the other block. - const height = document.height * (element.role === "title" ? 0.38 : 0.22); - const lines = () => element.text.split("\n").reduce((count, line) => count + Math.max(1, Math.ceil(Array.from(line).length * element.fontSize / element.width)), 0); - while (element.fontSize > 16 && lines() * element.fontSize * 1.4 > height) element.fontSize--; - if (lines() * element.fontSize * 1.4 > height) throw new AppError("invalid_params", "Poster copy does not fit. Shorten the text or remove extra line breaks.", 400); - element.height = height; - element.lineHeight = 1.4; - } - return { args, document: PosterDocumentSchema.parse(document) }; -} - -export const posterPatchSchema = z.object({ - name: z.string().max(120).optional(), - role: z.enum(["title", "subtitle", "badge", "decor"]).optional(), - visible: z.boolean().optional(), - flipX: z.boolean().optional(), - flipY: z.boolean().optional(), - stroke: z.string().max(80).regex(/^(?:#[0-9a-f]{3,8}|rgba?\([\d.,\s]+\)|transparent)$/i).optional(), - strokeWidth: z.number().min(0).max(1024).optional(), - shadow: z.object({ color: z.string().max(80).regex(/^(?:#[0-9a-f]{3,8}|rgba?\([\d.,\s]+\)|transparent)$/i), blur: z.number().min(0).max(300), offsetX: z.number().min(-300).max(300), offsetY: z.number().min(-300).max(300) }).strict().nullable().optional(), - gradient: PosterPaintGradientSchema.nullable().optional(), - fontFamily: z.string().min(1).max(100).regex(/^[\w\s\-\u3400-\u9fff]+$/).optional(), - fontStyle: z.enum(["normal", "italic"]).optional(), - underline: z.boolean().optional(), - lineHeight: z.number().min(0.5).max(3).optional(), - charSpacing: z.number().min(-300).max(1000).optional(), - badge: z.object({ background: z.string().regex(/^#[0-9a-f]{6}$/i), padX: z.number().min(0).max(3), padY: z.number().min(0).max(3), radius: z.number().min(0).max(1) }).strict().nullable().optional(), - radius: z.number().min(0).max(4096).optional(), - resourceId: z.string().min(1).max(160).regex(/^[\w-]+$/).optional(), - crop: z.object({ x: z.number().min(0).max(1), y: z.number().min(0).max(1), width: z.number().min(0.01).max(1), height: z.number().min(0.01).max(1) }).strict().refine(crop => crop.x + crop.width <= 1 && crop.y + crop.height <= 1).nullable().optional(), - path: z.string().min(1).max(100000).regex(/^[MmLlHhVvCcSsQqTtAaZz\d\s,.eE+\-]+$/).optional(), - text: z.string().max(8000).optional(), - fill: z.string().regex(/^#[0-9a-f]{6}$/i).optional(), - fontSize: z.number().finite().min(4).max(8192).optional(), - fontWeight: z.enum(["normal", "bold"]).optional(), - textAlign: z.enum(["left", "center", "right"]).optional(), - x: z.number().finite().min(-8192).max(8192).optional(), - y: z.number().finite().min(-8192).max(8192).optional(), - width: z.number().finite().min(1).max(8192).optional(), - height: z.number().finite().min(1).max(8192).optional(), - rotation: z.number().finite().min(-360).max(360).optional(), - opacity: z.number().finite().min(0).max(1).optional(), -}).strict().refine(value => Object.keys(value).length > 0); -const patch = posterPatchSchema; -const typographyKeys = ["text", "fontSize", "fontFamily", "fontWeight", "fontStyle", "underline", "textAlign", "lineHeight", "charSpacing", "badge"]; -function fail(message: string): never { throw new AppError("invalid_params", message, 400); } -const ids = z.array(z.string().min(1)).min(1).max(100).refine(ids => new Set(ids).size === ids.length); -const operation = z.discriminatedUnion("op", [ - z.object({ op: z.literal("add"), element: z.union([PosterElementSchema.options[0].strict(), PosterElementSchema.options[1].strict(), PosterElementSchema.options[2].strict(), PosterElementSchema.options[3].strict()]) }).strict(), - z.object({ op: z.literal("remove"), ids }).strict(), - z.object({ op: z.literal("duplicate"), ids, dx: z.number().min(-8192).max(8192).default(24), dy: z.number().min(-8192).max(8192).default(24) }).strict(), - z.object({ op: z.literal("reorder"), ids, position: z.enum(["front", "back", "forward", "backward"]) }).strict(), - z.object({ op: z.literal("group"), ids }).strict(), - z.object({ op: z.literal("ungroup"), ids }).strict(), - z.object({ op: z.literal("align"), ids, alignment: z.enum(["left", "center", "right", "top", "middle", "bottom"]) }).strict(), - z.object({ op: z.literal("lock"), ids, locked: z.boolean() }).strict(), - z.object({ op: z.literal("apply_template"), template: z.enum(POSTER_TEMPLATES), heading: z.string().min(1).max(80).optional(), subtitle: z.string().max(240).optional() }).strict(), -]); - -const request = z.object({ - memoId: z.string().min(1), - expectedRevision: z.number().int().nonnegative(), - dryRun: z.boolean().optional(), - background: z.string().regex(/^#[0-9a-f]{6}$/i).optional(), - backgroundGradient: PosterGradientSchema.nullable().optional(), - canvas: z.object({ width: z.number().int().min(320).max(4096), height: z.number().int().min(320).max(4096), scaleElements: z.boolean().default(true) }).strict().optional(), - platform: z.string().min(1).max(80).optional(), - operations: z.array(operation).min(1).max(100).optional(), - edits: z.array(z.object({ id: z.string().min(1), patch }).strict()).max(100).optional(), -}).strict().refine(value => Boolean(value.edits?.length || value.background || value.backgroundGradient !== undefined || value.canvas || value.platform || value.operations?.length)); - -export function applyPosterEdits(input: unknown, current: PosterDocument) { - const parsed = request.safeParse(input); - if (!parsed.success) fail("Invalid poster edits. Read get_poster_capabilities for supported fields and operations."); - const args = parsed.data; - if (args.canvas && args.platform) fail("Choose canvas or platform, not both."); - const document = structuredClone(PosterDocumentSchema.parse(current)); - const selected = (wanted: string[], allowLocked = false) => wanted.map(id => { - const element = document.elements.find(element => element.id === id); - if (!element || (element.locked && !allowLocked)) return fail("Poster element is missing or locked"); - return element; - }); - const edits = args.edits ?? []; - if (new Set(edits.map(edit => edit.id)).size !== edits.length) fail("Duplicate poster element IDs"); - for (const edit of edits) { - const [element] = selected([edit.id]); - if (element.type !== "text" && typographyKeys.some(key => key in edit.patch)) fail("Typography edits require a text element"); - if (element.type === "image" && (edit.patch.fill !== undefined || edit.patch.gradient !== undefined)) fail("Image elements do not have a fill"); - if (element.type !== "image" && (edit.patch.resourceId !== undefined || edit.patch.crop !== undefined)) fail("Image edits require an image element"); - if (element.type !== "rect" && element.type !== "ellipse" && edit.patch.radius !== undefined) fail("Radius edits require a shape"); - if (element.type !== "path" && edit.patch.path !== undefined) fail("Path edits require a path element"); - const changes = { ...edit.patch } as Record; - for (const key of ["shadow", "gradient", "crop", "badge"]) if (changes[key] === null) changes[key] = undefined; - Object.assign(element, changes, edit.patch.text !== undefined ? { sourceText: edit.patch.text } : {}); - } - for (const op of args.operations ?? []) { - if (op.op === "add") { - if (document.elements.some(element => element.id === op.element.id)) fail("Duplicate poster element ID"); - if (op.element.type === "image" && op.element.crop && (op.element.crop.x + op.element.crop.width > 1 || op.element.crop.y + op.element.crop.height > 1)) fail("Image crop exceeds the image"); - document.elements.push(structuredClone(op.element)); - continue; - } - if (op.op === "apply_template") { - if (document.elements.some(element => element.locked)) fail("Cannot replace a template with locked elements"); - const titleElement = document.elements.find(element => element.type === "text" && (element.role === "title" || element.id === "heading")); - const title = op.heading ?? document.copy?.title ?? (titleElement?.type === "text" ? titleElement.sourceText ?? titleElement.text : "Poster"); - const subtitleElement = document.elements.find(element => element.type === "text" && (element.role === "subtitle" || element.id === "subtitle")); - const subtitle = op.subtitle ?? document.copy?.subtitle ?? (subtitleElement?.type === "text" ? subtitleElement.sourceText ?? subtitleElement.text : ""); - const built = compilePosterMemo({ notebookId: "template", title: "Poster", heading: title, subtitle, template: op.template }); - const resized = resizePosterDocument(built.document, document.width, document.height); - Object.assign(document, { elements: resized.elements, copy: resized.copy, templateId: op.template, background: resized.background, backgroundGradient: undefined }); - continue; - } - const elements = selected(op.ids, op.op === "lock"); - if (op.op === "lock") { elements.forEach(element => { element.locked = op.locked; }); continue; } - if (op.op === "remove") document.elements = document.elements.filter(element => !op.ids.includes(element.id)); - if (op.op === "duplicate") { - const groups = new Map(); - document.elements.push(...elements.map(element => { - if (element.groupId && !groups.has(element.groupId)) groups.set(element.groupId, crypto.randomUUID()); - return { ...structuredClone(element), id: crypto.randomUUID(), x: element.x + op.dx, y: element.y + op.dy, groupId: element.groupId ? groups.get(element.groupId) : undefined }; - })); - } - if (op.op === "group") { - const groupId = crypto.randomUUID(); - elements.forEach(element => { element.groupId = groupId; }); - } - if (op.op === "ungroup") elements.forEach(element => { element.groupId = undefined; }); - if (op.op === "align") { - if (elements.some(element => element.rotation !== 0)) fail("Align requires unrotated elements; set rotation=0 first"); - for (const element of elements) { - if (op.alignment === "left") element.x = 0; - if (op.alignment === "center") element.x = (document.width - element.width) / 2; - if (op.alignment === "right") element.x = document.width - element.width; - if (op.alignment === "top") element.y = 0; - if (op.alignment === "middle") element.y = (document.height - element.height) / 2; - if (op.alignment === "bottom") element.y = document.height - element.height; - } - } - if (op.op === "reorder") { - const picked = document.elements.filter(element => op.ids.includes(element.id)); - const rest = document.elements.filter(element => !op.ids.includes(element.id)); - if (op.position === "front") document.elements = [...rest, ...picked]; - else if (op.position === "back") document.elements = [...picked, ...rest]; - else { - const direction = op.position === "forward" ? 1 : -1; - const order = direction === 1 ? [...document.elements].reverse() : [...document.elements]; - for (const element of order.filter(element => op.ids.includes(element.id))) { - const index = document.elements.indexOf(element), target = index + direction; - if (target >= 0 && target < document.elements.length && !op.ids.includes(document.elements[target].id)) { - [document.elements[index], document.elements[target]] = [document.elements[target], document.elements[index]]; - } - } - } - } - } - const platform = args.platform ? PLATFORMS.find(platform => platform.id === args.platform) : undefined; - if (args.platform && !platform) fail("Unknown platform. Read get_poster_capabilities."); - const size = args.canvas ?? (platform ? { width: platform.width, height: platform.height, scaleElements: true } : undefined); - if (size && (size.width !== document.width || size.height !== document.height)) { - if (document.elements.some(element => element.locked) && size.scaleElements) fail("Cannot scale locked elements"); - Object.assign(document, size.scaleElements ? resizePosterDocument(document, size.width, size.height) : { width: size.width, height: size.height }); - document.platformId = platform?.id; - } - if (platform) document.platformId = platform.id; - if (args.background !== undefined) document.background = args.background; - if (args.backgroundGradient !== undefined) document.backgroundGradient = args.backgroundGradient ?? undefined; - const copyTouched = edits.some(edit => "text" in edit.patch || "role" in edit.patch) || args.operations?.some(op => ["remove", "add", "apply_template"].includes(op.op)); - if (copyTouched) { - document.copy = { title: "", subtitle: "", badge: "" }; - for (const role of ["title", "subtitle", "badge"] as const) { - document.copy[role] = [...new Set(document.elements.filter(element => element.type === "text" && (element.role === role || (role === "title" && element.id === "heading") || (role === "subtitle" && element.id === "subtitle"))) - .map(element => element.type === "text" ? element.sourceText ?? element.text : ""))].join("\n"); - } - } - const safe = PosterDocumentSchema.safeParse(document); - if (!safe.success) fail("Poster edits exceed document limits"); - const changed = JSON.stringify(safe.data) !== JSON.stringify(PosterDocumentSchema.parse(current)); - if (changed) safe.data.previewResourceId = undefined; - return { document: safe.data, changed }; -} - -export function posterCapabilities() { - return { - templates: POSTER_TEMPLATES.map(id => ({ id, sourceBuilder: "server", imageRenderer: "client" })), - browserDesignTemplates: POSTER_DESIGN_TEMPLATES.filter(template => template.available).map(template => ({ ...template, renderer: "browser", instruction: "Use request_poster_render with templateId and expectedRevision. This ID is not a server apply_template ID." })), - contentLayouts: { layouts: ["auto", "grid", "list"], sections: { min: 2, max: 6, headingMaxLength: 32, bodyMaxLength: 120 }, instruction: "For explanatory posters, plan 3–6 concise sections before creating. Use grid for categories/comparison, list for steps/ranked points. badge and footer are optional. Auto adapts columns to the aspect ratio. Headline-only posters may omit sections." }, - formats: POSTER_FORMATS, - platforms: PLATFORMS, - fonts: POSTER_FONTS.map(({ family, zh, en }) => ({ family, zh, en })), - operations: ["add", "remove", "duplicate", "reorder", "group", "ungroup", "align", "lock", "apply_template"], - editableFields: Object.keys(patch.shape), - limits: { maxElements: 500, maxOperations: 100, canvasMin: 320, canvasMax: 4096 }, - rendering: { image: "browser", preview: "client_render_queue", export: ["source", "saved_preview"], instruction: "Request fresh PNG rendering or full design templates with request_poster_render. An online desktop/Web workspace handles the job; no poster editor mount is needed. Query get_poster_render_job, then get_poster_preview. Never call a pending or failed job rendered." }, - instruction: "Read get_poster before editing. Edits run first, then operations in order, then canvas/platform resize. Array order is back to front. Unlock in an earlier call before field edits; ordered operations can explicitly unlock before a later operation. apply_template replaces all elements; use only when requested. Image resource IDs must belong to this poster. dryRun does not save changes.", - }; -} +export { posterPatchSchema, posterCapabilities } from "@edgeever/shared"; +const translate = (error: unknown): never => { + if (error instanceof StructuredNoteError) throw new AppError(error.code, error.message, error.status); + throw error; +}; +export const compilePosterMemo: typeof compile = (...args) => { + try { return compile(...args); } catch (error) { return translate(error); } +}; +export const applyPosterEdits: typeof apply = (...args) => { + try { return apply(...args); } catch (error) { return translate(error); } +}; diff --git a/apps/api/src/mcp-structured-operations.test.mjs b/apps/api/src/mcp-structured-operations.test.mjs new file mode 100644 index 00000000..c8710996 --- /dev/null +++ b/apps/api/src/mcp-structured-operations.test.mjs @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { AppError } from "./app-error"; +import { applyDiagramOperations, parseDiagramMemoIr } from "./mcp-diagram-tools"; +import { applyTableSchemaOperations, tableCellPatch } from "./mcp-table-tools"; + +const diagram = () => ({ schemaVersion: 1, kind: "mind-map", theme: "marine", structure: "tree", nodes: [ + { id: "root", label: "Root", shape: "topic", x: 10, y: 20, width: 100, height: 40 }, + { id: "child", label: "Child", shape: "topic", parentId: "root", x: 210, y: 20, width: 100, height: 40 }, +], edges: [{ id: "edge", source: "root", target: "child" }] }); +const table = () => ({ schemaVersion: 1, fields: [{ id: "name", name: "Name", type: "text" }], records: [{ id: "row", cells: { name: "Keep" } }], view: { filters: [], sort: null } }); + +describe("MCP adapters for shared structured operations", () => { + test("keeps existing diagrams editable with current catalog themes and preserves visual identity", async () => { + const current = diagram(); + const mutation = await applyDiagramOperations({ operations: [ + { op: "add_node", node: { id: "second", label: "Second", parentId: "root" } }, + { op: "update_node", nodeId: "child", changes: { label: "Edited" } }, + { op: "update_edge", edgeId: "edge", changes: { label: "Connection" } }, + ] }, current); + expect(mutation.document).toMatchObject({ theme: "marine", structure: "tree" }); + expect(mutation.document.nodes.find(node => node.id === "root")).toMatchObject({ x: 10, y: 20, width: 100, height: 40 }); + expect(mutation.document.edges.find(edge => edge.id === "edge").label).toBe("Connection"); + expect(current).toEqual(diagram()); + }); + + test("retains MCP AppError semantics and validates hierarchy cycles and cascades", async () => { + expect(() => parseDiagramMemoIr({ kind: "architecture", nodes: [{ id: "a", label: "A", parentId: "a", type: "boundary" }] })).toThrow(AppError); + await expect(applyDiagramOperations({ operations: [{ op: "remove_node", nodeId: "root" }] }, diagram())).rejects.toMatchObject({ code: "invalid_params", status: 400 }); + const current = diagram(); + const result = await applyDiagramOperations({ operations: [ + { op: "add_node", node: { id: "replacement", label: "Replacement" } }, + { op: "remove_node", nodeId: "root", cascade: true }, + ] }, current); + expect(result.document.nodes.map(node => node.id)).toEqual(["replacement"]); + expect(result.document.edges).toHaveLength(0); + }); + + test("shares schema validation without mutating the old table", () => { + const current = table(); + const result = applyTableSchemaOperations(current, [ + { op: "add_field", field: { name: "Status", type: "select", options: ["Open", "Done"] } }, + { op: "update_field", fieldId: "name", changes: { name: "Title" } }, + ]); + expect(result.fields.map(field => field.name)).toEqual(["Title", "Status"]); + expect(current).toEqual(table()); + expect(() => applyTableSchemaOperations(current, [{ op: "remove_field", fieldId: "name" }])).toThrow(AppError); + expect(() => applyTableSchemaOperations(current, [{ op: "add_field", field: { name: "name", type: "text" } }])).toThrow(AppError); + }); + + test("uses the database ownership check for attachment cells and rejects invalid cell types", async () => { + const current = table(); + current.fields.push({ id: "attachment", name: "File", type: "attachment" }); + const bindings = []; + const db = { prepare: () => ({ bind: (...args) => { bindings.push(args); return { first: async () => ({ id: "res", filename: "file.pdf", mime_type: "application/pdf", byte_size: 12 }) }; } }) }; + const cells = await tableCellPatch(db, "memo", current, { attachment: ["res"] }, false); + expect(bindings).toEqual([["res", "memo"]]); + expect(cells.attachment).toEqual([{ resourceId: "res", filename: "file.pdf", mimeType: "application/pdf", byteSize: 12 }]); + await expect(tableCellPatch(db, "memo", current, { name: 12 }, false)).rejects.toBeInstanceOf(AppError); + await expect(tableCellPatch(db, "memo", current, { attachment: ["res", "res"] }, false)).rejects.toMatchObject({ code: "invalid_params" }); + }); +}); diff --git a/apps/api/src/mcp-table-tools.ts b/apps/api/src/mcp-table-tools.ts index faaf00ea..ce2c120a 100644 --- a/apps/api/src/mcp-table-tools.ts +++ b/apps/api/src/mcp-table-tools.ts @@ -1,24 +1,7 @@ -import { - addTableField, - createTableId, - docToText, - listTableAttachmentResourceIds, - markdownToDoc, - parseTableDocument, - removeTableField, - serializeTableDocument, - stripTableDocumentMarker, - TABLE_ATTACHMENT_LIMIT, - TABLE_FIELD_LIMIT, - TABLE_FIELD_TYPES, - updateTableField, - type MemoDetail, - type TableDocument, - type TableField, - type TableFieldType, +import { validateTableCells, tableFieldInput as fieldInput, ensureUniqueTableFieldName as unique, applyTableSchemaOperations as schema, StructuredNoteError, + docToText, markdownToDoc, stripTableDocumentMarker, type MemoDetail, type TableDocument, } from "@edgeever/shared"; import { AppError } from "./app-error"; -import { getRequiredString } from "./mcp-json-rpc"; import type { DatabaseAdapter } from "./storage-contract"; export const memoWithoutTablePayload = (memo: MemoDetail) => { @@ -34,219 +17,21 @@ export const requiredTableRevision = (value: unknown) => { return value as number; }; -export const tableCellPatch = async ( - db: DatabaseAdapter, - memoId: string, - document: TableDocument, - value: unknown, - allowEmpty: boolean, -) => { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new AppError("invalid_params", "cells must be an object keyed by field ID", 400); - } - const cells = value as Record; - const normalized: Record = {}; - if (!allowEmpty && Object.keys(cells).length === 0) { - throw new AppError("invalid_params", "cells must include at least one field", 400); - } - const fields = new Map(document.fields.map((field) => [field.id, field])); - for (const [fieldId, cell] of Object.entries(cells)) { - const field = fields.get(fieldId); - if (!field) throw new AppError("invalid_params", `Unknown table field: ${fieldId}`, 400); - if (field.type === "attachment") { - if (!Array.isArray(cell) || cell.length > TABLE_ATTACHMENT_LIMIT || cell.some((id) => typeof id !== "string")) { - throw new AppError("invalid_params", `${fieldId} requires an array of up to ${TABLE_ATTACHMENT_LIMIT} resource IDs`, 400); - } - if (new Set(cell).size !== cell.length) { - throw new AppError("invalid_params", `${fieldId} contains duplicate resource IDs`, 400); - } - const attachments = []; - for (const resourceId of cell) { - const resource = await db.prepare( - `SELECT id, filename, mime_type, byte_size FROM resources - WHERE id = ? AND memo_id = ? AND kind = 'attachment' AND is_deleted = 0`, - ).bind(resourceId, memoId).first<{ id: string; filename: string | null; mime_type: string | null; byte_size: number }>(); - if (!resource) throw new AppError("invalid_params", `Attachment resource not found in this memo: ${resourceId}`, 400); - attachments.push({ - resourceId: resource.id, - filename: resource.filename ?? "attachment", - mimeType: resource.mime_type ?? "application/octet-stream", - byteSize: resource.byte_size, - }); - } - normalized[fieldId] = attachments; - continue; - } - if (field.type === "checkbox") { - if (typeof cell !== "boolean") throw new AppError("invalid_params", `${fieldId} requires a boolean`, 400); - } else if (field.type === "number") { - if (cell !== null && (typeof cell !== "number" || !Number.isFinite(cell))) { - throw new AppError("invalid_params", `${fieldId} requires a finite number or null`, 400); - } - } else if (cell !== null && (typeof cell !== "string" || cell.length > 2000)) { - throw new AppError("invalid_params", `${fieldId} requires a string of at most 2000 characters or null`, 400); - } - if (field.type === "select" && cell && !field.options?.includes(cell as string)) { - throw new AppError("invalid_params", `${fieldId} requires an existing option`, 400); - } - if (field.type === "date" && cell && !/^\d{4}-\d{2}-\d{2}$/.test(cell as string)) { - throw new AppError("invalid_params", `${fieldId} requires YYYY-MM-DD`, 400); - } - if (field.type === "url" && cell && !/^https?:\/\//i.test(cell as string)) { - throw new AppError("invalid_params", `${fieldId} requires an http:// or https:// URL`, 400); - } - normalized[fieldId] = cell; - } - return normalized; +export const tableCellPatch = async (db: DatabaseAdapter, memoId: string, document: TableDocument, value: unknown, allowEmpty: boolean) => { + try { + return await validateTableCells(async resourceId => { + const resource = await db.prepare(`SELECT id, filename, mime_type, byte_size FROM resources WHERE id = ? AND memo_id = ? AND kind = 'attachment' AND is_deleted = 0`) + .bind(resourceId, memoId).first<{ id: string; filename: string | null; mime_type: string | null; byte_size: number }>(); + return resource ? { resourceId: resource.id, filename: resource.filename ?? "attachment", mimeType: resource.mime_type ?? "application/octet-stream", byteSize: resource.byte_size } : null; + }, document, value, allowEmpty); + } catch (error) { return translate(error); } }; +export const tableFieldInput: typeof fieldInput = (...args) => { try { return fieldInput(...args); } catch (error) { return translate(error); } }; +export const ensureUniqueTableFieldName: typeof unique = (...args) => { try { return unique(...args); } catch (error) { return translate(error); } }; +export const applyTableSchemaOperations: typeof schema = (...args) => { try { return schema(...args); } catch (error) { return translate(error); } }; +export { tableSchemaImpact } from "@edgeever/shared"; -const tableObject = (value: unknown, label: string): Record => { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new AppError("invalid_params", `${label} must be an object`, 400); - } - return value as Record; -}; - -const tableFieldName = (value: unknown) => { - const name = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : ""; - if (!name || name.length > 80) { - throw new AppError("invalid_params", "Field name must have 1–80 characters", 400); - } - return name; -}; - -const tableFieldType = (value: unknown): TableFieldType => { - if (typeof value !== "string" || !TABLE_FIELD_TYPES.includes(value as TableFieldType)) { - throw new AppError("invalid_params", "Unsupported table field type", 400); - } - return value as TableFieldType; -}; - -const tableFieldOptions = (value: unknown) => { - if (!Array.isArray(value) || value.length < 1 || value.length > 40) { - throw new AppError("invalid_params", "Select fields require 1–40 options", 400); - } - const options = value.map((option) => tableFieldName(option)); - if (new Set(options.map((option) => option.toLocaleLowerCase())).size !== options.length) { - throw new AppError("invalid_params", "Select options must be unique", 400); - } - return options; -}; - -export const tableFieldInput = (value: unknown): TableField => { - const input = tableObject(value, "field"); - if (Object.keys(input).some((key) => !["name", "type", "options"].includes(key))) { - throw new AppError("invalid_params", "field must include only name, type, or options", 400); - } - const type = tableFieldType(input.type); - if (type !== "select" && input.options !== undefined) { - throw new AppError("invalid_params", "Only select fields can have options", 400); - } - return { - id: createTableId("fld"), - name: tableFieldName(input.name), - type, - ...(type === "select" ? { options: tableFieldOptions(input.options) } : {}), - }; -}; - -export const ensureUniqueTableFieldName = (document: TableDocument, name: string, exceptId?: string) => { - if (document.fields.some((field) => field.id !== exceptId && field.name.toLocaleLowerCase() === name.toLocaleLowerCase())) { - throw new AppError("invalid_params", `Duplicate table field name: ${name}`, 400); - } -}; - -const tableFieldPatch = (current: TableField, value: unknown): Partial> => { - const changes = tableObject(value, "changes"); - if (!Object.keys(changes).length || Object.keys(changes).some((key) => !["name", "type", "options"].includes(key))) { - throw new AppError("invalid_params", "changes must include only name, type, or options", 400); - } - const type = changes.type === undefined ? current.type : tableFieldType(changes.type); - if (type !== "select" && changes.options !== undefined) { - throw new AppError("invalid_params", "Only select fields can have options", 400); - } - if (type === "select" && current.type !== "select" && changes.options === undefined) { - throw new AppError("invalid_params", "Changing to select requires options", 400); - } - return { - ...(changes.name === undefined ? {} : { name: tableFieldName(changes.name) }), - ...(changes.type === undefined ? {} : { type }), - ...(type === "select" && changes.options !== undefined ? { options: tableFieldOptions(changes.options) } : {}), - }; -}; - -const validTableValueForField = (field: TableField, value: unknown) => { - if (value === null || value === "") return true; - if (field.type === "select") return typeof value === "string" && Boolean(field.options?.includes(value)); - if (field.type === "date") return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value); - if (field.type === "url") return typeof value === "string" && /^https?:\/\//i.test(value); - return true; -}; - -export const applyTableSchemaOperations = (document: TableDocument, value: unknown) => { - if (!Array.isArray(value) || value.length < 1 || value.length > 40) { - throw new AppError("invalid_params", "operations must include 1–40 changes", 400); - } - let next = document; - for (const valueItem of value) { - const item = tableObject(valueItem, "operation"); - if (item.op === "add_field") { - if (next.fields.length >= TABLE_FIELD_LIMIT) throw new AppError("table_field_limit", "Table field limit reached", 409); - const field = tableFieldInput(item.field); - ensureUniqueTableFieldName(next, field.name); - next = addTableField(next, field); - } else if (item.op === "update_field") { - const fieldId = getRequiredString(item.fieldId, "fieldId"); - const current = next.fields.find((field) => field.id === fieldId); - if (!current) throw new AppError("not_found", `Table field not found: ${fieldId}`, 404); - const patch = tableFieldPatch(current, item.changes); - if (patch.name !== undefined) ensureUniqueTableFieldName(next, patch.name, fieldId); - next = updateTableField(next, fieldId, patch); - const updated = next.fields.find((field) => field.id === fieldId)!; - if (updated.type !== "select" && updated.options !== undefined) { - next = { ...next, fields: next.fields.map((field) => field.id === fieldId ? { id: field.id, name: field.name, type: field.type } : field) }; - } - const finalField = next.fields.find((field) => field.id === fieldId)!; - if ((patch.type !== undefined || patch.options !== undefined) && ["select", "date", "url"].includes(finalField.type)) { - next = { - ...next, - records: next.records.map((record) => validTableValueForField(finalField, record.cells[fieldId]) - ? record - : { ...record, cells: { ...record.cells, [fieldId]: "" } }), - }; - } - } else if (item.op === "remove_field") { - const fieldId = getRequiredString(item.fieldId, "fieldId"); - if (!next.fields.some((field) => field.id === fieldId)) { - throw new AppError("not_found", `Table field not found: ${fieldId}`, 404); - } - if (next.fields.length === 1) throw new AppError("invalid_params", "A table must keep at least one field", 400); - next = removeTableField(next, fieldId); - } else { - throw new AppError("invalid_params", "Unknown table schema operation", 400); - } - } - if (!parseTableDocument(serializeTableDocument(next))) { - throw new AppError("invalid_params", "Schema changes produced an invalid table", 400); - } - return next; -}; - -export const tableSchemaImpact = (before: TableDocument, after: TableDocument) => { - const afterRecords = new Map(after.records.map((record) => [record.id, record])); - let changedCellCount = 0; - for (const record of before.records) { - const nextRecord = afterRecords.get(record.id); - for (const field of before.fields) { - const oldValue = record.cells[field.id]; - const newValue = nextRecord?.cells[field.id]; - const hasValue = Array.isArray(oldValue) ? oldValue.length > 0 : oldValue !== null && oldValue !== ""; - if (hasValue && JSON.stringify(oldValue) !== JSON.stringify(newValue)) changedCellCount += 1; - } - } - const retainedResources = listTableAttachmentResourceIds(after); - return { - changedCellCount, - removedAttachmentCount: [...listTableAttachmentResourceIds(before)].filter((id) => !retainedResources.has(id)).length, - }; +const translate = (error: unknown): never => { + if (error instanceof StructuredNoteError) throw new AppError(error.code, error.message, error.status); + throw error; }; diff --git a/apps/api/src/poster-content-layout.ts b/apps/api/src/poster-content-layout.ts index c16ef0c8..a2571fe0 100644 --- a/apps/api/src/poster-content-layout.ts +++ b/apps/api/src/poster-content-layout.ts @@ -1,77 +1,9 @@ -import type { PosterDocument, PosterText } from "@edgeever/shared"; +import { layoutPosterContent as layout, StructuredNoteError } from "@edgeever/shared"; import { AppError } from "./app-error"; - -export type PosterSection = { heading: string; body: string }; -export type PosterContentPlan = { - heading: string; - subtitle: string; - badge: string; - footer: string; - layout: "auto" | "grid" | "list"; - sections: PosterSection[]; -}; - -// Compile semantic content into ordinary v1 elements: every block remains editable -// and works with the existing editor, resource renderer and Markdown fallback. -export function layoutPosterContent(document: PosterDocument, plan: PosterContentPlan) { - const { width: w, height: h } = document; - const unit = Math.min(w, h); - const margin = unit * 0.065; - const gap = unit * 0.025; - const inner = w - margin * 2; - const ink = "#07130b"; - const dark = document.background === ink; - const accent = dark ? "#baf264" : "#16a06e"; - document.elements = []; - const rect = (id: string, x: number, y: number, width: number, height: number, fill: string, groupId?: string) => { - document.elements.push({ id, name: id, type: "rect", role: "decor", x, y, width, height, fill, rotation: 0, opacity: 1, ...(groupId ? { groupId } : {}) }); - }; - const text = (id: string, content: string, x: number, y: number, width: number, height: number, size: number, fill: string, bold = false, role: PosterText["role"] = "decor", groupId?: string) => { - if (!content) return; - const lines = (fontSize: number) => content.split("\n").reduce((sum, line) => sum + Math.max(1, Math.ceil(Array.from(line).length * fontSize / width)), 0); - let fontSize = Math.max(14, Math.floor(size)); - while (fontSize > 14 && lines(fontSize) * fontSize * 1.3 > height) fontSize--; - if (lines(fontSize) * fontSize * 1.3 > height) { - throw new AppError("invalid_params", "Poster content does not fit. Shorten section copy or use fewer sections/a taller format.", 400); - } - document.elements.push({ id, name: id, type: "text", role, text: content, sourceText: content, x, y, width, height, fontSize, fill, fontFamily: "sans-serif", fontWeight: bold ? "bold" : "normal", textAlign: "left", lineHeight: 1.3, rotation: 0, opacity: 1, ...(groupId ? { groupId } : {}) }); - }; - const headerEnd = h * 0.32; - rect("content-header", 0, 0, w, headerEnd, ink); - rect("accent", margin, h * 0.045, unit * 0.09, unit * 0.008, accent); - text("badge", plan.badge, margin + unit * 0.12, h * 0.032, inner - unit * 0.12, h * 0.04, unit * 0.022, accent, true, "badge"); - text("heading", plan.heading, margin, h * 0.09, inner, h * 0.13, Math.min(w * 0.075, h * 0.065), "#ffffff", true, "title"); - text("subtitle", plan.subtitle, margin, h * 0.24, inner, h * 0.065, unit * 0.026, "#ffffff", false, "subtitle"); - - // Wide canvases need columns; portrait step-by-step copy benefits from rows. - const useGrid = plan.layout === "grid" || (plan.layout === "auto" && w / h >= 0.7); - const columns = useGrid ? Math.min(plan.sections.length, w / h >= 1.5 ? 3 : 2) : 1; - const rows = Math.ceil(plan.sections.length / columns); - const top = headerEnd + gap; - const bottom = plan.footer ? h * 0.88 : h - margin; - const cellHeight = (bottom - top - gap * (rows - 1)) / rows; - const cellWidth = (inner - gap * (columns - 1)) / columns; - plan.sections.forEach((section, index) => { - const row = Math.floor(index / columns); - const col = index % columns; - const span = index === plan.sections.length - 1 && col < columns - 1; - const x = margin + col * (cellWidth + gap); - const y = top + row * (cellHeight + gap); - const width = span ? inner - col * (cellWidth + gap) : cellWidth; - const pad = Math.min(unit * 0.027, cellHeight * 0.09); - const group = `section-${index + 1}`; - rect(`${group}-card`, x, y, width, cellHeight, dark ? "#14281e" : "#ffffff", group); - rect(`${group}-accent`, x, y, unit * 0.005, cellHeight, accent, group); - const labelHeight = Math.max(19, Math.min(unit * 0.024, cellHeight * 0.13)); - const headingHeight = cellHeight * 0.26; - text(`${group}-number`, String(index + 1).padStart(2, "0"), x + pad, y + pad, width - pad * 2, labelHeight, labelHeight / 1.3, accent, true, "decor", group); - text(`${group}-heading`, section.heading, x + pad, y + pad + labelHeight + pad / 2, width - pad * 2, headingHeight, Math.min(unit * 0.033, headingHeight / 1.3), dark ? "#ffffff" : ink, true, "decor", group); - const bodyY = y + pad * 2 + labelHeight + headingHeight; - text(`${group}-body`, section.body, x + pad, bodyY, width - pad * 2, y + cellHeight - pad - bodyY, unit * 0.024, dark ? "#d5e8dc" : "#35483c", false, "decor", group); - }); - if (plan.footer) { - rect("footer-rule", margin, h * 0.915, inner, Math.max(1, unit * 0.0015), accent); - text("footer", plan.footer, margin, h * 0.935, inner, h * 0.045, unit * 0.02, dark ? "#d5e8dc" : "#35483c"); +export type { PosterSection, PosterContentPlan } from "@edgeever/shared"; +export const layoutPosterContent: typeof layout = (...args) => { + try { return layout(...args); } catch (error) { + if (error instanceof StructuredNoteError) throw new AppError(error.code, error.message, error.status); + throw error; } - document.copy = { title: plan.heading, subtitle: plan.subtitle, badge: plan.badge }; -} +}; diff --git a/apps/web/src/lib/desktop-repository.test.mjs b/apps/web/src/lib/desktop-repository.test.mjs index 8fdbb087..0826c04e 100644 --- a/apps/web/src/lib/desktop-repository.test.mjs +++ b/apps/web/src/lib/desktop-repository.test.mjs @@ -49,7 +49,7 @@ globalThis.window = { summary: { totalCount: 2, totalBytes: 2, imageCount: 2, attachmentCount: 0 }, }; } - if (method === "memo.update") { + if (method === "memo.update" || method === "memo.updateGuarded") { return { memo: { ...params, id: params.memoId, contentHash: "next-hash" } }; } return { memos: [], totalCount: 0, nextCursor: null }; @@ -181,3 +181,25 @@ describe("desktop repository memo reads", () => { expect(result.memo.contentMarkdown).toContain("edgeever-resource://resource/res_wechat"); }); }); + +describe("guarded desktop plugin saves", () => { + test("uses a dedicated RPC so older sidecars cannot silently ignore the snapshot guard", async () => { + const memo = { id: "guarded", revision: 2, contentHash: "base", title: "Title", tags: ["keep"], contentMarkdown: "Original", contentJson: { type: "doc", content: [] } }; + await createDesktopRepository().updateMemo(memo, { title: "Title", contentMarkdown: "Edited", contentJson: memo.contentJson, tags: memo.tags }, { guardLocalSnapshot: true }); + expect(lastRequest.method).toBe("memo.updateGuarded"); + expect(lastRequest.params.expectedLocalSnapshot).toMatchObject({ revision: 2, contentHash: "base", contentMarkdown: "Original", title: "Title", tags: ["keep"] }); + }); + + test("reports conflicts and refuses unsupported old hosts without an unguarded fallback", async () => { + const request = window.edgeeverDesktop.sidecarRequest; + const memo = { id: "guarded", revision: 2, contentHash: "base", title: "Title", tags: [], contentMarkdown: "Original", contentJson: { type: "doc", content: [] } }; + try { + for (const [message, code] of [["NOTE_CONFLICT: Changed locally", "NOTE_CONFLICT"], ["Unknown sidecar method: memo.updateGuarded", "HOST_UPGRADE_REQUIRED"]]) { + const calls = []; + window.edgeeverDesktop.sidecarRequest = async method => { calls.push(method); throw new Error(message); }; + await expect(createDesktopRepository().updateMemo(memo, { title: "Title", contentJson: memo.contentJson, tags: [] }, { guardLocalSnapshot: true })).rejects.toMatchObject({ code }); + expect(calls).toEqual(["memo.updateGuarded"]); + } + } finally { window.edgeeverDesktop.sidecarRequest = request; } + }); +}); diff --git a/apps/web/src/lib/desktop-repository.ts b/apps/web/src/lib/desktop-repository.ts index 84c7d7c9..db2c8830 100644 --- a/apps/web/src/lib/desktop-repository.ts +++ b/apps/web/src/lib/desktop-repository.ts @@ -1,3 +1,4 @@ +import { memoLocalSnapshot } from "./local-memo-guard"; import type { DesktopMemoCreateParams, DesktopMemoListParams, @@ -297,7 +298,7 @@ export const createDesktopRepository = (): EdgeEverRepository => ({ }); }, - updateMemo: async (memo: MemoDetail, input) => { + updateMemo: async (memo: MemoDetail, input, options) => { const needsStagedRepair = contentReferencesStagedResourceUrl(input.contentJson) || contentReferencesStagedResourceUrl(input.contentMarkdown); const aliasedInput = needsStagedRepair @@ -332,7 +333,26 @@ export const createDesktopRepository = (): EdgeEverRepository => ({ contentText: docToText(portableContentJson), tags: input.tags, }; - const result = await request("memo.update", rpcParams); + let result; + if (options?.guardLocalSnapshot) { + const snapshot = memoLocalSnapshot(memo); + try { + result = await request("memo.updateGuarded", { + ...rpcParams, + expectedLocalSnapshot: { ...snapshot, + contentJson: mapTiptapResourceUrls(snapshot.contentJson, toApiResourceUrl), + contentMarkdown: mapMarkdownResourceUrls(snapshot.contentMarkdown, toApiResourceUrl) ?? snapshot.contentMarkdown, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("NOTE_CONFLICT:")) throw Object.assign(new Error(message), { code: "NOTE_CONFLICT" }); + if (message.includes("Unknown sidecar method: memo.updateGuarded")) throw Object.assign(new Error("Upgrade the desktop host before using guarded plugin note edits."), { code: "HOST_UPGRADE_REQUIRED" }); + throw error; + } + } else { + result = await request("memo.update", rpcParams); + } notifySyncQueueDeferred(); return { memo: await repairDisplayedMemo(result.memo), queued: true as const }; }, diff --git a/apps/web/src/lib/local-memo-guard.ts b/apps/web/src/lib/local-memo-guard.ts new file mode 100644 index 00000000..465bb2b1 --- /dev/null +++ b/apps/web/src/lib/local-memo-guard.ts @@ -0,0 +1,15 @@ +import type { MemoDetail } from "@edgeever/shared"; + +/** Synchronous comparison so it can run inside an IndexedDB write transaction. */ +const canonical = (value: unknown) => JSON.stringify(value, (_key, item) => + item && typeof item === "object" && !Array.isArray(item) + ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item); +export const memoLocalSnapshot = (memo: MemoDetail) => ({ + revision: memo.revision, contentHash: memo.contentHash, title: memo.title, + tags: memo.tags, contentMarkdown: memo.contentMarkdown, contentJson: memo.contentJson, +}); +export const assertLocalMemoSnapshot = (current: MemoDetail | null, expected: MemoDetail, hasConflict: boolean) => { + if (!current || current.isDeleted || hasConflict || canonical(memoLocalSnapshot(current)) !== canonical(memoLocalSnapshot(expected))) { + throw Object.assign(new Error("The note changed locally or has an unresolved sync conflict. Reload or resolve it before editing from a plugin."), { code: "NOTE_CONFLICT" }); + } +}; diff --git a/apps/web/src/lib/plugins/infographic-operations.test.mjs b/apps/web/src/lib/plugins/infographic-operations.test.mjs new file mode 100644 index 00000000..9c7610d7 --- /dev/null +++ b/apps/web/src/lib/plugins/infographic-operations.test.mjs @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { applyInfographicOperations, StructuredNoteError } from "@edgeever/shared"; +import { infographicSyntaxAdapter as adapter } from "./infographic-syntax-adapter"; + +const document = () => ({ schemaVersion: 1, syntax: `infographic list-grid-simple +width 900 +height 600 +data + title Original + desc Preserve this description + lists + - label First + desc First detail + icon mdi/star + - label Second + desc Second detail +theme dark + colorBg #123456 + palette #123456 #abcdef +design + item simple +`, history: [{ id: "turn", prompt: "Create", createdAt: "2026-01-01", kind: "generated", resultTitle: "Original" }] }); + +describe("lossless infographic detail editing with the installed AntV parser", () => { + test("changes nested data while preserving template, styles, icons and history", () => { + const current = document(); + const result = applyInfographicOperations(current, [{ op: "set_data", path: ["lists", 0, "label"], value: "Edited" }], adapter); + expect(adapter.parse(result.document.syntax).errors).toHaveLength(0); + expect(adapter.parse(result.document.syntax).options.data.lists).toEqual([ + { label: "Edited", desc: "First detail", icon: "mdi/star" }, { label: "Second", desc: "Second detail" }, + ]); + expect(result.document.syntax).toContain("theme dark\n colorBg #123456\n palette #123456 #abcdef"); + expect(result.document.syntax).toContain("width 900\nheight 600"); + expect(result.document.syntax).toContain("design\n item simple"); + expect(result.document.history).toEqual(current.history); + expect(current).toEqual(document()); + }); + + test("inserts and removes items in an atomic batch", () => { + const result = applyInfographicOperations(document(), [ + { op: "insert_data", path: ["lists"], index: 1, value: { label: "New", desc: "Added" } }, + { op: "remove_data", path: ["lists", 0] }, + { op: "remove_data", path: ["desc"] }, + ], adapter); + const data = adapter.parse(result.document.syntax).options.data; + expect(data.lists.map(item => item.label)).toEqual(["New", "Second"]); + expect(data.desc).toBeUndefined(); + }); + + test("changes themes and templates only when explicitly requested and preserves overrides", () => { + const result = applyInfographicOperations(document(), [ + { op: "set_theme", theme: "light" }, + { op: "set_template", template: "list-column-done-list" }, + ], adapter); + expect(adapter.parse(result.document.syntax).options).toMatchObject({ template: "list-column-done-list", theme: "light", themeConfig: { colorBg: "#123456", palette: ["#123456", "#abcdef"] } }); + expect(result.document.syntax).toContain(" icon mdi/star"); + }); + + test("rejects invalid paths, prototypes, nonfinite numbers, unknown templates and partial batches", () => { + for (const op of [ + { op: "set_data", path: ["lists", 9, "label"], value: "Wrong" }, + { op: "set_data", path: ["__proto__", "polluted"], value: true }, + { op: "set_data", path: ["title"], value: NaN }, + { op: "insert_data", path: ["lists"], index: 9, value: { label: "Wrong" } }, + { op: "set_template", template: "not-a-template" }, + { op: "set_theme", theme: "not-a-theme" }, + ]) { + const current = document(); + expect(() => applyInfographicOperations(current, [{ op: "set_data", path: ["title"], value: "Must not apply" }, op], adapter)).toThrow(StructuredNoteError); + expect(current).toEqual(document()); + } + expect({}.polluted).toBeUndefined(); + }); + + test("rejects changes the syntax builder cannot represent instead of silently dropping them", () => { + for (const value of [null, "First\nSecond", [{ custom: "no identifying field" }]]) { + expect(() => applyInfographicOperations(document(), [{ op: "set_data", path: ["title"], value }], adapter)).toThrow(); + } + }); + + test("leaves unchanged syntax byte-for-byte and validates unsupported originals", () => { + const current = document(); + expect(applyInfographicOperations(current, [{ op: "set_data", path: ["title"], value: "Original" }], adapter)).toEqual({ document: current, changed: false }); + expect(() => applyInfographicOperations({ schemaVersion: 1, syntax: "invalid syntax" }, [{ op: "set_theme", theme: "dark" }], adapter)).toThrow(); + }); +}); diff --git a/apps/web/src/lib/plugins/infographic-syntax-adapter.ts b/apps/web/src/lib/plugins/infographic-syntax-adapter.ts new file mode 100644 index 00000000..18fa3668 --- /dev/null +++ b/apps/web/src/lib/plugins/infographic-syntax-adapter.ts @@ -0,0 +1,13 @@ +import { getTemplate, getTheme, getTemplates, getThemes, parseSyntax } from "@antv/infographic"; +import type { InfographicSyntaxAdapter } from "@edgeever/shared"; + +export const infographicSyntaxAdapter: InfographicSyntaxAdapter = { + parse: syntax => { + const parsed = parseSyntax(syntax); + return { options: parsed.options as ReturnType["options"], errors: parsed.errors, warnings: parsed.warnings }; + }, + hasTemplate: template => Boolean(getTemplate(template)), + hasTheme: theme => Boolean(getTheme(theme)), +}; + +export const infographicCapabilities = () => ({ templates: getTemplates(), themes: getThemes(), maxOperations: 100 }); diff --git a/apps/web/src/lib/plugins/plugin-host.test.mjs b/apps/web/src/lib/plugins/plugin-host.test.mjs index 51edb997..c44398fd 100644 --- a/apps/web/src/lib/plugins/plugin-host.test.mjs +++ b/apps/web/src/lib/plugins/plugin-host.test.mjs @@ -56,6 +56,7 @@ afterAll(() => { }); const repository = { + getMemo: async () => ({ memo: { id: "note-1", contentMarkdown: "hello" } }), listMemos: async () => ({ memos: [], totalCount: 0, nextCursor: null }), }; @@ -713,3 +714,236 @@ describe("EdgeEverPluginHost", () => { await host.dispose(); }); }); + +const structured = await import("@edgeever/shared"); +describe("structured plugin note APIs", () => { + const setup = (contentMarkdown, contentJson = structured.markdownToDoc(contentMarkdown)) => { + let memo = { id: "note-1", notebookId: "book", title: "Original", excerpt: "", tags: ["keep"], isPinned: false, + createdAt: "2026-01-01", updatedAt: "2026-01-01", revision: 4, contentHash: "cloud-hash", contentMarkdown, contentJson, contentText: "" }; + const saves = []; + const resources = []; + let onResourceRead = () => {}; + const host = new EdgeEverPluginHost({ scope: "structured", repository: { + getMemo: async () => ({ memo: structuredClone(memo) }), + listResources: async () => { onResourceRead(); return { resources }; }, + updateMemo: async (current, input) => { + saves.push(input); + // Simulate an offline Web write: cloud revision/hash have not advanced yet. + memo = { ...memo, ...input }; + return { memo: structuredClone(memo) }; + }, + }}); + const context = host.createContext({ id: "org.test.structured", permissions: [] }, []); + return { host, context, saves, resources, setResourceRead: (callback) => { onResourceRead = callback; }, getMemo: () => memo, change: (patch) => { memo = { ...memo, ...patch }; } }; + }; + const input = result => ({ expectedRevision: result.note.revision, expectedContentHash: result.note.contentHash, expectedEditToken: result.editToken }); + const diagramMarkdown = () => structured.serializeDiagramDocument({ schemaVersion: 1, kind: "mind-map", theme: "slate", structure: "tree", + nodes: [{ id: "root", label: "Root", shape: "topic", x: 71, y: 22, width: 120, height: 40 }], edges: [] }); + const tableMarkdown = () => structured.serializeTableDocument({ schemaVersion: 1, + fields: [{ id: "name", name: "Name", type: "text" }, { id: "file", name: "File", type: "attachment" }], + records: [{ id: "row", cells: { name: "Original", file: [] } }], view: { filters: [], sort: null } }); + + test("exposes full detached content and preserves special notes on metadata updates", async () => { + const originalDoc = { type: "doc", content: [{ type: "paragraph", attrs: { custom: "preserve" } }] }; + const f = setup(diagramMarkdown(), originalDoc); + const read = await f.context.notes.get("note-1"); + expect(read.kind).toBe("mind-map"); + read.contentJson.content[0].attrs.custom = "changed"; + expect(f.getMemo().contentJson).toEqual(originalDoc); + await f.context.notes.update("note-1", { title: "Renamed", tags: ["new"] }); + expect(f.saves[0].contentJson).toEqual(originalDoc); + expect(f.saves[0].contentMarkdown).toBe(diagramMarkdown()); + await expect(f.context.notes.update("note-1", { contentMarkdown: "oops" })).rejects.toMatchObject({ code: "STRUCTURED_NOTE_REQUIRED" }); + await expect(f.context.notes.editMarkdown("note-1", { expectedRevision: 4, expectedContentHash: "cloud-hash", edits: [{ from: 0, to: 0, insert: "oops" }] })).rejects.toMatchObject({ code: "STRUCTURED_NOTE_REQUIRED" }); + expect(f.saves).toHaveLength(1); + }); + + test("previews and edits diagrams while preserving styles and rejecting stale unsynced snapshots", async () => { + const f = setup(diagramMarkdown()); + const snapshot = await f.context.diagrams.get("note-1", { includeLayout: true }); + const edit = { ...input(snapshot), operations: [{ op: "update_node", nodeId: "root", changes: { label: "Edited" } }] }; + const preview = await f.context.diagrams.edit("note-1", { ...edit, dryRun: true }); + expect(preview.diagram.nodes[0].label).toBe("Edited"); + expect(preview.note.contentMarkdown).toBe(snapshot.note.contentMarkdown); + expect(f.saves).toHaveLength(0); + const result = await f.context.diagrams.edit("note-1", edit); + expect(result.diagram).toMatchObject({ theme: "slate", structure: "tree" }); + expect(result.diagram.nodes[0].layout).toMatchObject({ x: 71, y: 22 }); + await expect(f.context.diagrams.edit("note-1", edit)).rejects.toMatchObject({ code: "NOTE_CONFLICT" }); + expect(f.saves).toHaveLength(1); + }); + + test("rejects invalid diagram batches without a partial write", async () => { + const f = setup(diagramMarkdown()); + const read = await f.context.diagrams.get("note-1"); + await expect(f.context.diagrams.edit("note-1", { ...input(read), operations: [ + { op: "update_node", nodeId: "root", changes: { label: "Must not save" } }, + { op: "add_edge", edge: { source: "root", target: "missing" } }, + ] })).rejects.toMatchObject({ code: "INVALID_STRUCTURED_EDIT" }); + expect(f.saves).toHaveLength(0); + expect((await f.context.diagrams.get("note-1")).diagram.nodes[0].label).toBe("Root"); + }); + + test("applies table records and cells as one batch and rejects bad values atomically", async () => { + const f = setup(tableMarkdown()); + let read = await f.context.tables.get("note-1"); + await expect(f.context.tables.edit("note-1", { ...input(read), operations: [ + { op: "update_record", recordId: "row", cells: { name: "Must not save" } }, + { op: "update_record", recordId: "row", cells: { missing: "oops" } }, + ] })).rejects.toMatchObject({ code: "INVALID_STRUCTURED_EDIT" }); + expect(f.saves).toHaveLength(0); + const result = await f.context.tables.edit("note-1", { ...input(read), operations: [ + { op: "update_record", recordId: "row", cells: { name: "Edited" } }, + { op: "add_record", recordId: "row2", cells: { name: "New" } }, + ] }); + expect(result.table.records.map(row => row.cells.name)).toEqual(["Edited", "New"]); + expect(f.saves).toHaveLength(1); + await expect(f.context.tables.edit("note-1", { ...input(read), operations: [{ op: "remove_record", recordId: "row" }] })).rejects.toMatchObject({ code: "NOTE_CONFLICT" }); + read = await f.context.tables.get("note-1"); + await f.context.tables.edit("note-1", { ...input(read), operations: [{ op: "remove_record", recordId: "row2" }] }); + expect(f.getMemo().tags).toEqual(["keep"]); + }); + + test("previews destructive schema changes and requires explicit data conversion", async () => { + const f = setup(tableMarkdown()); + const read = await f.context.tables.get("note-1"); + const edit = { ...input(read), operations: [{ op: "update_field", fieldId: "name", changes: { type: "number" } }] }; + const preview = await f.context.tables.edit("note-1", { ...edit, dryRun: true }); + expect(preview.changedCellCount).toBe(1); + expect(f.saves).toHaveLength(0); + await expect(f.context.tables.edit("note-1", edit)).rejects.toMatchObject({ code: "TABLE_DATA_CHANGES_REQUIRED" }); + await f.context.tables.edit("note-1", { ...edit, allowDataChanges: true }); + expect(f.saves).toHaveLength(1); + }); + + test("validates attachment ownership and resolves metadata through the repository", async () => { + const f = setup(tableMarkdown()); + const read = await f.context.tables.get("note-1"); + const edit = { ...input(read), operations: [{ op: "update_record", recordId: "row", cells: { file: ["attachment"] } }] }; + f.resources.push({ id: "attachment", memoId: "other-note", kind: "attachment", filename: "a.pdf", mimeType: "application/pdf", byteSize: 12 }); + await expect(f.context.tables.edit("note-1", edit)).rejects.toMatchObject({ code: "INVALID_STRUCTURED_EDIT" }); + f.resources[0].memoId = "note-1"; + const result = await f.context.tables.edit("note-1", edit); + expect(result.table.records[0].cells.file).toEqual([{ resourceId: "attachment", filename: "a.pdf", mimeType: "application/pdf", byteSize: 12 }]); + }); + + test("rejects dirty editors, wrong note types, and concurrent writes using the same snapshot", async () => { + const f = setup(tableMarkdown()); + const read = await f.context.tables.get("note-1"); + const edit = { ...input(read), operations: [{ op: "update_record", recordId: "row", cells: { name: "Edited" } }] }; + f.host.setEditorAdapter({ getDocument: () => ({ noteId: "note-1", contentMarkdown: "draft", hasUnsavedChanges: true }) }); + await expect(f.context.tables.edit("note-1", edit)).rejects.toMatchObject({ code: "NOTE_CONFLICT" }); + await expect(f.context.editor.insertAtCursor("oops")).rejects.toMatchObject({ code: "STRUCTURED_NOTE_REQUIRED" }); + await expect(f.context.editor.replaceSelection("oops")).rejects.toMatchObject({ code: "STRUCTURED_NOTE_REQUIRED" }); + await expect(f.context.editor.editMarkdown([{ from: 0, to: 0, insert: "oops" }])).rejects.toMatchObject({ code: "STRUCTURED_NOTE_REQUIRED" }); + f.host.setEditorAdapter(null); + await expect(f.context.diagrams.get("note-1")).rejects.toMatchObject({ code: "NOTE_TYPE_MISMATCH" }); + const results = await Promise.allSettled([f.context.tables.edit("note-1", edit), f.context.tables.edit("note-1", edit)]); + expect(results.map(result => result.status)).toEqual(["fulfilled", "rejected"]); + expect(f.saves).toHaveLength(1); + }); + + test("rechecks the saved baseline after asynchronous attachment validation", async () => { + const f = setup(tableMarkdown()); + const read = await f.context.tables.get("note-1"); + f.resources.push({ id: "attachment", memoId: "note-1", kind: "attachment", filename: "file", mimeType: "text/plain", byteSize: 1 }); + f.setResourceRead(() => f.change({ title: "Changed elsewhere" })); + await expect(f.context.tables.edit("note-1", { ...input(read), operations: [ + { op: "update_record", recordId: "row", cells: { file: ["attachment"] } }, + ] })).rejects.toMatchObject({ code: "NOTE_CONFLICT" }); + expect(f.saves).toHaveLength(0); + expect(f.getMemo().title).toBe("Changed elsewhere"); + }); + + test("rejects missing snapshot tokens and invalid typed cells", async () => { + const f = setup(tableMarkdown()); + const read = await f.context.tables.get("note-1"); + await expect(f.context.tables.edit("note-1", { expectedRevision: 4, expectedContentHash: "cloud-hash", operations: [] })).rejects.toMatchObject({ code: "INVALID_STRUCTURED_EDIT" }); + await expect(f.context.tables.edit("note-1", { ...input(read), operations: [ + { op: "add_field", field: { name: "Count", type: "number" } }, + { op: "update_record", recordId: "row", cells: { name: 42 } }, + ] })).rejects.toMatchObject({ code: "INVALID_STRUCTURED_EDIT" }); + expect(f.saves).toHaveLength(0); + }); + + test("previews poster element changes, preserves locked elements and rejects stale snapshots", async () => { + const poster = structured.createPosterDocument("Original", "Keep subtitle"); + poster.elements.find(element => element.id === "subtitle").locked = true; + poster.previewResourceId = "old-preview"; + const f = setup(structured.serializePosterDocument(poster)); + const read = await f.context.posters.get("note-1"); + expect((await f.context.posters.capabilities()).editableFields).toContain("text"); + const edit = { ...input(read), edits: [{ id: "heading", patch: { text: "Edited", fill: "#16A06E" } }] }; + const preview = await f.context.posters.edit("note-1", { ...edit, dryRun: true }); + expect(preview.poster.elements.find(element => element.id === "heading").text).toBe("Edited"); + expect(preview.poster.previewResourceId).toBeUndefined(); + expect(f.saves).toHaveLength(0); + const result = await f.context.posters.edit("note-1", edit); + expect(result.poster.elements.find(element => element.id === "subtitle")).toEqual(poster.elements.find(element => element.id === "subtitle")); + expect(f.getMemo().tags).toEqual(["keep"]); + await expect(f.context.posters.edit("note-1", edit)).rejects.toMatchObject({ code: "NOTE_CONFLICT" }); + const fresh = await f.context.posters.get("note-1"); + await expect(f.context.posters.edit("note-1", { ...input(fresh), operations: [{ op: "remove", ids: ["subtitle"] }] })).rejects.toMatchObject({ code: "INVALID_STRUCTURED_EDIT" }); + await expect(f.context.posters.edit("note-1", { ...input(fresh), operations: [{ op: "apply_template", template: "bold" }] })).rejects.toMatchObject({ code: "INVALID_STRUCTURED_EDIT" }); + expect(f.saves).toHaveLength(1); + }); + + test("rejects poster partial batches and foreign image resources", async () => { + const f = setup(structured.serializePosterDocument(structured.createPosterDocument("Original"))); + const read = await f.context.posters.get("note-1"); + const image = { id: "image", name: "Image", type: "image", resourceId: "resource", x: 0, y: 0, width: 100, height: 100, rotation: 0, opacity: 1 }; + const edit = { ...input(read), operations: [{ op: "add", element: image }] }; + f.resources.push({ id: "resource", memoId: "other-note", kind: "image" }); + await expect(f.context.posters.edit("note-1", edit)).rejects.toMatchObject({ code: "INVALID_STRUCTURED_EDIT" }); + expect(f.saves).toHaveLength(0); + await expect(f.context.posters.edit("note-1", { ...input(read), edits: [{ id: "heading", patch: { text: "Must not save" } }], operations: [{ op: "remove", ids: ["missing"] }] })).rejects.toMatchObject({ code: "INVALID_STRUCTURED_EDIT" }); + expect(f.saves).toHaveLength(0); + f.resources[0].memoId = "note-1"; + const result = await f.context.posters.edit("note-1", edit); + expect(result.poster.elements.find(element => element.id === "image")).toEqual(image); + }); + + test("reads and edits infographic data without losing styles or conversation history", async () => { + const infographic = { schemaVersion: 1, syntax: "infographic list-grid-simple\ndata\n title Original\n lists\n - label First\n desc Keep\ntheme dark\n colorBg #123456", history: [ + { id: "history", prompt: "Create", createdAt: "2026-01-01", kind: "generated", resultTitle: "Original" }, + ] }; + const f = setup(structured.serializeInfographicDocument(infographic)); + const read = await f.context.infographics.get("note-1"); + expect((await f.context.infographics.capabilities()).templates).toContain("list-grid-simple"); + expect((await f.context.infographics.capabilities()).themes).toContain("dark"); + expect(read).toMatchObject({ template: "list-grid-simple", theme: "dark", data: { title: "Original", lists: [{ label: "First", desc: "Keep" }] } }); + const edit = { ...input(read), operations: [{ op: "set_data", path: ["lists", 0, "label"], value: "Edited" }] }; + const preview = await f.context.infographics.edit("note-1", { ...edit, dryRun: true }); + expect(preview.infographic.syntax).toContain("label Edited"); + expect(f.saves).toHaveLength(0); + const result = await f.context.infographics.edit("note-1", edit); + expect(result.infographic.history).toEqual(infographic.history); + expect(result.infographic.syntax).toContain("theme dark\n colorBg #123456"); + expect(f.getMemo().title).toBe("Original"); + await expect(f.context.infographics.edit("note-1", edit)).rejects.toMatchObject({ code: "NOTE_CONFLICT" }); + const fresh = await f.context.infographics.get("note-1"); + expect(fresh.data.lists[0].desc).toBe("Keep"); + expect(f.saves).toHaveLength(1); + }); + + test("rejects wrong poster/infographic types, dirty editors and invalid infographic batches", async () => { + const f = setup(structured.serializeInfographicDocument({ schemaVersion: 1, syntax: "infographic list-grid-simple\ndata\n lists\n - label Original" })); + const read = await f.context.infographics.get("note-1"); + await expect(f.context.posters.get("note-1")).rejects.toMatchObject({ code: "NOTE_TYPE_MISMATCH" }); + await expect(f.context.infographics.edit("note-1", { ...input(read), operations: [ + { op: "set_data", path: ["lists", 0, "label"], value: "Must not save" }, + { op: "remove_data", path: ["lists", 8] }, + ] })).rejects.toMatchObject({ code: "INVALID_STRUCTURED_EDIT" }); + f.host.setEditorAdapter({ getDocument: () => ({ noteId: "note-1", contentMarkdown: "draft", hasUnsavedChanges: true }) }); + await expect(f.context.infographics.edit("note-1", { ...input(read), operations: [{ op: "set_theme", theme: "dark" }] })).rejects.toMatchObject({ code: "NOTE_CONFLICT" }); + expect(f.saves).toHaveLength(0); + }); + + test("protects poster and infographic markers, including malformed payloads", async () => { + for (const kind of ["poster", "infographic", "diagram", "table"]) { + const f = setup(`Body\n`); + await expect(f.context.notes.update("note-1", { contentMarkdown: "oops" })).rejects.toMatchObject({ code: "STRUCTURED_NOTE_REQUIRED" }); + expect(f.saves).toHaveLength(0); + } + }); +}); diff --git a/apps/web/src/lib/plugins/plugin-host.ts b/apps/web/src/lib/plugins/plugin-host.ts index 5e7692b4..1da4efbd 100644 --- a/apps/web/src/lib/plugins/plugin-host.ts +++ b/apps/web/src/lib/plugins/plugin-host.ts @@ -34,7 +34,13 @@ import { type ThemeTokenName, type ThemeTokens, } from "@edgeever/plugin-api"; -import { markdownToDoc } from "@edgeever/shared"; +import { markdownToDoc, type MemoDetail } from "@edgeever/shared"; +import { + structuredEditToken, pluginNoteKind, assertPluginMarkdownNote, assertStructuredRevision, pluginDiagram, pluginTable, + translateStructuredError, applyDiagramOperations, applyTableOperations, diagramSemanticGraph, + serializeTableDocument, tableFallbackMarkdown, structuredApiError, + posterCapabilities, pluginPoster, pluginInfographic, applyPosterEdits, applyInfographicOperations, serializePosterDocument, serializeInfographicDocument, +} from "./structured-note-api"; import { api } from "@/lib/api"; import type { EdgeEverRepository } from "@/lib/repository"; import { WebPluginSecretStore, type PluginSecretStorage } from "@/lib/plugins/plugin-secret-store"; @@ -218,6 +224,7 @@ interface ActivePlugin { } const toPluginNoteSummary = (note: Awaited>["memos"][number]): PluginNoteSummary => ({ + kind: pluginNoteKind(note), id: note.id, notebookId: note.notebookId, title: note.title, @@ -230,6 +237,7 @@ const toPluginNoteSummary = (note: Awaited>["memo"]): PluginNote => ({ ...toPluginNoteSummary(note), + contentJson: structuredClone(note.contentJson), revision: note.revision, contentMarkdown: note.contentMarkdown, contentText: note.contentText, @@ -1063,11 +1071,52 @@ export class EdgeEverPluginHost { this.refreshSnapshot(); } + private readonly noteWrites = new Map>(); + private async withNoteWrite(noteId: string, write: () => Promise): Promise { + const previous = this.noteWrites.get(noteId) ?? Promise.resolve(); + const pending = previous.catch(() => {}).then(write); + this.noteWrites.set(noteId, pending); + try { + return await pending; + } finally { + if (this.noteWrites.get(noteId) === pending) this.noteWrites.delete(noteId); + } + } + private createContext(manifest: PluginManifest, disposers: Array<() => void>): PluginContext { const storagePrefix = `${STORAGE_PREFIX}:${this.scope}:${manifest.id}:`; const secretNamespace = `${this.scope}:${manifest.id}`; const lifetime = new AbortController(); disposers.push(() => lifetime.abort()); + const assertSaved = (noteId: string) => { + const active = this.editorAdapter?.getDocument(); + if (active?.noteId === noteId && active.hasUnsavedChanges) throw structuredApiError("NOTE_CONFLICT", "Save the active editor's changes before editing from a plugin."); + }; + const assertEditorMarkdown = async () => { + const adapter = this.editorAdapter; + const document = adapter?.getDocument(); + if (!document) throw new Error("No note editor is currently active."); + assertPluginMarkdownNote({ contentMarkdown: document.contentMarkdown }); + if (document.noteId) assertPluginMarkdownNote((await this.repository.getMemo(document.noteId)).memo); + const latest = this.editorAdapter?.getDocument(); + if (adapter !== this.editorAdapter || latest?.noteId !== document.noteId || latest?.contentMarkdown !== document.contentMarkdown) { + throw structuredApiError("NOTE_CONFLICT", "The active editor changed while the plugin was preparing an edit."); + } + }; + const persistStructured = async (current: MemoDetail, contentMarkdown: string, contentJson = markdownToDoc(contentMarkdown)) => { + assertSaved(current.id); + const latest = (await this.repository.getMemo(current.id)).memo; + if (await structuredEditToken(latest) !== await structuredEditToken(current)) throw structuredApiError("NOTE_CONFLICT", "The note changed while the batch was being validated. Read it again."); + assertSaved(current.id); + if (contentMarkdown === current.contentMarkdown) return current; + const updated = (await this.repository.updateMemo(current, { + expectedRevision: current.revision, expectedContentHash: current.contentHash, + editSessionId: `plugin:${manifest.id}`, title: current.title ?? "", tags: current.tags, + contentMarkdown, contentJson, + }, { guardLocalSnapshot: true })).memo; + await this.onWorkspaceChanged?.(); + return updated; + }; return { pluginId: manifest.id, ai: { @@ -1138,7 +1187,7 @@ export class EdgeEverPluginHost { assertPermission(manifest, "notes:read"); return toPluginNote((await this.repository.getMemo(noteId)).memo); }, - editMarkdown: async (noteId, input) => { + editMarkdown: async (noteId, input) => this.withNoteWrite(noteId, async () => { assertPermission(manifest, "notes:read"); assertPermission(manifest, "notes:write"); if ( @@ -1154,11 +1203,13 @@ export class EdgeEverPluginHost { if (current.revision !== input.expectedRevision || current.contentHash !== input.expectedContentHash) { throw createPluginApiError("NOTE_CONFLICT", "The note changed after the plugin read it. Reload the note before editing."); } + assertPluginMarkdownNote(current); const activeDocument = this.editorAdapter?.getDocument(); if (activeDocument?.noteId === noteId && activeDocument.hasUnsavedChanges) { throw createPluginApiError("NOTE_CONFLICT", "The note has unsaved editor changes. Save them before editing from a plugin."); } const contentMarkdown = applyPluginMarkdownEdits(current.contentMarkdown, input.edits); + assertPluginMarkdownNote({ contentMarkdown }); if (contentMarkdown === current.contentMarkdown) return toPluginNote(current); const updated = (await this.repository.updateMemo(current, { expectedRevision: input.expectedRevision, @@ -1168,35 +1219,41 @@ export class EdgeEverPluginHost { contentJson: markdownToDoc(contentMarkdown), contentMarkdown, tags: current.tags, - })).memo; + }, { guardLocalSnapshot: true })).memo; const note = toPluginNote(updated); await this.onWorkspaceChanged?.(); return note; - }, + }), create: async (input) => { assertPermission(manifest, "notes:write"); + assertPluginMarkdownNote({ contentMarkdown: input.contentMarkdown }); const note = toPluginNote((await this.repository.createMemo(input)).memo); await this.onWorkspaceChanged?.(); return note; }, - update: async (noteId, input) => { + update: async (noteId, input) => this.withNoteWrite(noteId, async () => { assertPermission(manifest, "notes:write"); assertPermission(manifest, "notes:read"); const current = (await this.repository.getMemo(noteId)).memo; + assertSaved(noteId); + if (input.contentMarkdown !== undefined) { + assertPluginMarkdownNote(current); + assertPluginMarkdownNote({ contentMarkdown: input.contentMarkdown }); + } const contentMarkdown = input.contentMarkdown ?? current.contentMarkdown; const updated = (await this.repository.updateMemo(current, { expectedRevision: current.revision, expectedContentHash: current.contentHash, editSessionId: `plugin:${manifest.id}`, title: input.title ?? current.title ?? "", - contentJson: markdownToDoc(contentMarkdown), + contentJson: input.contentMarkdown === undefined ? current.contentJson : markdownToDoc(contentMarkdown), contentMarkdown, tags: input.tags ?? current.tags, - })).memo; + }, { guardLocalSnapshot: true })).memo; const note = toPluginNote(updated); await this.onWorkspaceChanged?.(); return note; - }, + }), delete: async (noteId, options) => { assertPermission(manifest, "notes:delete"); await this.repository.deleteMemo(noteId, Boolean(options?.permanent)); @@ -1235,6 +1292,107 @@ export class EdgeEverPluginHost { }, }, }, + diagrams: { + get: async (noteId, options) => { + assertPermission(manifest, "notes:read"); + const current = (await this.repository.getMemo(noteId)).memo; + return { note: toPluginNote(current), diagram: diagramSemanticGraph(pluginDiagram(current), options?.includeLayout === true), editToken: await structuredEditToken(current) }; + }, + edit: async (noteId, input) => this.withNoteWrite(noteId, async () => { + assertPermission(manifest, "notes:read"); assertPermission(manifest, "notes:write"); + const current = (await this.repository.getMemo(noteId)).memo; + await assertStructuredRevision(current, input); assertSaved(noteId); + try { + const mutation = await applyDiagramOperations({ operations: input.operations, reflow: input.reflow }, pluginDiagram(current)); + const note = input.dryRun === true ? current : await persistStructured(current, mutation.contentMarkdown); + return { note: toPluginNote(note), diagram: diagramSemanticGraph(mutation.document, true), editToken: await structuredEditToken(note), dryRun: input.dryRun === true }; + } catch (error) { return translateStructuredError(error); } + }), + }, + tables: { + get: async (noteId) => { + assertPermission(manifest, "notes:read"); + const current = (await this.repository.getMemo(noteId)).memo; + return { note: toPluginNote(current), table: pluginTable(current), editToken: await structuredEditToken(current) }; + }, + edit: async (noteId, input) => this.withNoteWrite(noteId, async () => { + assertPermission(manifest, "notes:read"); assertPermission(manifest, "notes:write"); + const current = (await this.repository.getMemo(noteId)).memo; + await assertStructuredRevision(current, input); assertSaved(noteId); + if (input.allowDataChanges !== undefined && typeof input.allowDataChanges !== "boolean") throw structuredApiError("INVALID_STRUCTURED_EDIT", "allowDataChanges must be a boolean."); + try { + // Resolve attachments once per batch through the host repository. + let resourcesPromise: ReturnType | undefined; + const mutation = await applyTableOperations(pluginTable(current), input.operations, async resourceId => { + const resources = (await (resourcesPromise ??= this.repository.listResources())).resources; + const resource = resources.find(item => item.id === resourceId && item.memoId === noteId && item.kind === "attachment" && !item.memoDeleted); + return resource ? { resourceId: resource.id, filename: resource.filename ?? "attachment", mimeType: resource.mimeType ?? "application/octet-stream", byteSize: resource.byteSize } : null; + }); + if (!input.dryRun && mutation.changedCellCount > 0 && input.allowDataChanges !== true) throw structuredApiError("TABLE_DATA_CHANGES_REQUIRED", "Schema changes affect populated cells. Preview with dryRun, then set allowDataChanges=true."); + const note = input.dryRun === true ? current : await persistStructured(current, serializeTableDocument(mutation.document), markdownToDoc(tableFallbackMarkdown(mutation.document))); + return { note: toPluginNote(note), table: mutation.document, editToken: await structuredEditToken(note), dryRun: input.dryRun === true, changedCellCount: mutation.changedCellCount, removedAttachmentCount: mutation.removedAttachmentCount }; + } catch (error) { return translateStructuredError(error); } + }), + }, + posters: { + capabilities: async () => { + assertPermission(manifest, "notes:read"); + const capabilities = posterCapabilities(); + return { templates: capabilities.templates.map(template => template.id), platforms: capabilities.platforms.map(({ id, width, height }) => ({ id, width, height })), fonts: capabilities.fonts.map(font => font.family), editableFields: capabilities.editableFields, limits: capabilities.limits }; + }, + get: async noteId => { + assertPermission(manifest, "notes:read"); + const current = (await this.repository.getMemo(noteId)).memo; + return { note: toPluginNote(current), poster: pluginPoster(current), editToken: await structuredEditToken(current) }; + }, + edit: async (noteId, input) => this.withNoteWrite(noteId, async () => { + assertPermission(manifest, "notes:read"); + assertPermission(manifest, "notes:write"); + const current = (await this.repository.getMemo(noteId)).memo; + await assertStructuredRevision(current, input); + assertSaved(noteId); + try { + const { expectedContentHash: _hash, expectedEditToken: _token, ...edits } = input; + const mutation = applyPosterEdits({ ...edits, memoId: noteId }, pluginPoster(current)); + const images = mutation.document.elements.filter(element => element.type === "image"); + if (images.length) { + const owned = new Set((await this.repository.listResources()).resources + .filter(resource => resource.memoId === noteId && resource.kind === "image" && !resource.memoDeleted).map(resource => resource.id)); + if (images.some(image => !owned.has(image.resourceId))) throw structuredApiError("INVALID_STRUCTURED_EDIT", "Poster images must be active image resources belonging to this note."); + } + const note = input.dryRun === true || !mutation.changed ? current : await persistStructured(current, serializePosterDocument(mutation.document)); + return { note: toPluginNote(note), poster: mutation.document, editToken: await structuredEditToken(note), dryRun: input.dryRun === true, changed: mutation.changed }; + } catch (error) { return translateStructuredError(error); } + }), + }, + infographics: { + capabilities: async () => { + assertPermission(manifest, "notes:read"); + const { infographicCapabilities } = await import("./infographic-syntax-adapter"); + return infographicCapabilities(); + }, + get: async noteId => { + assertPermission(manifest, "notes:read"); + const current = (await this.repository.getMemo(noteId)).memo; + const infographic = pluginInfographic(current); + const { infographicSyntaxAdapter } = await import("./infographic-syntax-adapter"); + const parsed = infographicSyntaxAdapter.parse(infographic.syntax); + return { note: toPluginNote(current), infographic, data: (parsed.options.data ?? {}) as Record, template: parsed.options.template ?? "", theme: parsed.options.theme, editToken: await structuredEditToken(current) }; + }, + edit: async (noteId, input) => this.withNoteWrite(noteId, async () => { + assertPermission(manifest, "notes:read"); + assertPermission(manifest, "notes:write"); + const current = (await this.repository.getMemo(noteId)).memo; + await assertStructuredRevision(current, input); + assertSaved(noteId); + try { + const { infographicSyntaxAdapter } = await import("./infographic-syntax-adapter"); + const mutation = applyInfographicOperations(pluginInfographic(current), input.operations, infographicSyntaxAdapter); + const note = input.dryRun === true || !mutation.changed ? current : await persistStructured(current, serializeInfographicDocument(mutation.document)); + return { note: toPluginNote(note), infographic: mutation.document, editToken: await structuredEditToken(note), dryRun: input.dryRun === true, changed: mutation.changed }; + } catch (error) { return translateStructuredError(error); } + }), + }, notebooks: { list: async () => { assertPermission(manifest, "metadata:read"); @@ -1412,16 +1570,19 @@ export class EdgeEverPluginHost { return this.editorAdapter?.getDocument() ?? null; }, editMarkdown: async (edits) => { + await assertEditorMarkdown(); assertPermission(manifest, "editor:read"); assertPermission(manifest, "editor:write"); if (!this.editorAdapter) throw new Error("No note editor is currently active."); const current = this.editorAdapter.getDocument(); if (!current) throw new Error("No note editor is currently active."); const contentMarkdown = applyPluginMarkdownEdits(current.contentMarkdown, edits); + assertPluginMarkdownNote({ contentMarkdown }); if (contentMarkdown !== current.contentMarkdown) this.editorAdapter.replaceDocument(contentMarkdown); return { ...current, contentMarkdown, hasUnsavedChanges: contentMarkdown !== current.contentMarkdown || current.hasUnsavedChanges }; }, insertEmbed: async (input) => { + await assertEditorMarkdown(); assertPermission(manifest, "editor:write"); assertPermission(manifest, "ui:embeds"); if (!this.editorAdapter) throw new Error("No note editor is currently active."); @@ -1465,13 +1626,17 @@ export class EdgeEverPluginHost { }, }, replaceSelection: async (contentMarkdown) => { + await assertEditorMarkdown(); assertPermission(manifest, "editor:write"); if (!this.editorAdapter) throw new Error("No note editor is currently active."); + assertPluginMarkdownNote({ contentMarkdown }); this.editorAdapter.replaceSelection(contentMarkdown); }, insertAtCursor: async (contentMarkdown) => { + await assertEditorMarkdown(); assertPermission(manifest, "editor:write"); if (!this.editorAdapter) throw new Error("No note editor is currently active."); + assertPluginMarkdownNote({ contentMarkdown }); this.editorAdapter.insertAtCursor(contentMarkdown); }, }, diff --git a/apps/web/src/lib/plugins/structured-note-api.ts b/apps/web/src/lib/plugins/structured-note-api.ts new file mode 100644 index 00000000..b44dc6e7 --- /dev/null +++ b/apps/web/src/lib/plugins/structured-note-api.ts @@ -0,0 +1,56 @@ +import { sha256Hex } from "./github-plugin-distribution"; +import { + applyDiagramOperations, applyTableOperations, diagramSemanticGraph, + hasDiagramDocumentMarker, hasTableDocumentMarker, hasPosterDocumentMarker, hasInfographicDocumentMarker, + posterCapabilities, parsePosterDocument, parseInfographicDocument, applyPosterEdits, applyInfographicOperations, serializePosterDocument, serializeInfographicDocument, + parseDiagramDocument, parseTableDocument, serializeTableDocument, tableFallbackMarkdown, markdownToDoc, + StructuredNoteError, type MemoDetail, +} from "@edgeever/shared"; +import type { PluginApiErrorCode, PluginNoteKind, PluginStructuredEditInput } from "@edgeever/plugin-api"; + +export const pluginNoteKind = (note: { diagramKind?: string | null; structuredTable?: boolean; poster?: boolean; infographic?: boolean; videoNote?: boolean; contentMarkdown?: string }): PluginNoteKind => { + const markdown = note.contentMarkdown; + const marker = markdown?.match(/