obsidian_canvas_patch
Add, update, or remove semantic nodes and edges in an Obsidian canvas, with optional automatic relayout.
Instructions
Patch a canvas by adding/updating/removing semantic nodes and edges, with optional relayout.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| vault | No | Optional configured vault name. Defaults to the server default vault. | |
| path | Yes | Vault-relative path. Absolute paths and traversal are rejected. | |
| addNodes | No | ||
| addEdges | No | ||
| updateNodes | No | ||
| removeNodes | No | ||
| removeEdges | No | ||
| relayout | No | ||
| direction | No | TB |
Implementation Reference
- src/tools.ts:1114-1145 (registration)Registration of the 'obsidian_canvas_patch' tool with its Zod schema and handler that reads the canvas file, calls patchCanvas(), and writes the result back.
tool( "obsidian_canvas_patch", "Patch a canvas by adding/updating/removing semantic nodes and edges, with optional relayout.", { vault: vaultArg, path: pathArg, addNodes: z.array(z.object({ id: z.string().optional(), label: z.string(), type: z.enum(["text", "file", "link", "group"]).optional(), text: z.string().optional(), file: z.string().optional(), url: z.string().optional(), color: z.string().optional(), width: z.number().optional(), height: z.number().optional(), })).optional(), addEdges: z.array(z.object({ id: z.string().optional(), from: z.string(), to: z.string(), label: z.string().optional(), color: z.string().optional() })).optional(), updateNodes: z.array(z.object({ match: z.string(), label: z.string().optional(), text: z.string().optional(), file: z.string().optional(), url: z.string().optional(), color: z.string().optional() })).optional(), removeNodes: z.array(z.string()).optional(), removeEdges: z.array(z.string()).optional(), relayout: z.boolean().optional().default(true), direction: z.enum(["TB", "BT", "LR", "RL"]).optional().default("TB"), }, async (args) => { const canvasPath = args.path.endsWith(".canvas") ? args.path : `${args.path}.canvas`; const read = await vaults.readText(canvasPath, args.vault); const patched = patchCanvas(JSON.parse(read.text) as CanvasFile, args); await vaults.writeText(read.path, `${JSON.stringify(patched.canvas, null, 2)}\n`, args.vault, { overwrite: true }); return { path: read.path, changes: patched.changes, summary: { nodes: patched.canvas.nodes.length, edges: patched.canvas.edges.length } }; }, ); - src/canvas.ts:112-182 (handler)The patchCanvas() function that adds/updates/removes nodes and edges, then optionally relayouts the canvas. Called by the tool handler in tools.ts.
export function patchCanvas( canvas: CanvasFile, patch: { addNodes?: CanvasNodeInput[]; addEdges?: CanvasEdgeInput[]; updateNodes?: Array<{ match: string; label?: string; text?: string; file?: string; url?: string; color?: string }>; removeNodes?: string[]; removeEdges?: string[]; relayout?: boolean; direction?: "TB" | "BT" | "LR" | "RL"; }, ): { canvas: CanvasFile; changes: string[] } { const changes: string[] = []; const next: CanvasFile = { nodes: canvas.nodes.map((node) => ({ ...node })), edges: canvas.edges.map((edge) => ({ ...edge })), }; const labels = labelMap(next.nodes); const removeNodeIds = new Set((patch.removeNodes ?? []).map((match) => findNode(next.nodes, match)?.id).filter((id): id is string => Boolean(id))); if (removeNodeIds.size > 0) { next.nodes = next.nodes.filter((node) => !removeNodeIds.has(node.id)); next.edges = next.edges.filter((edge) => !removeNodeIds.has(edge.fromNode) && !removeNodeIds.has(edge.toNode)); changes.push(`removed ${removeNodeIds.size} nodes`); } for (const update of patch.updateNodes ?? []) { const node = findNode(next.nodes, update.match); if (!node) continue; if (update.label !== undefined) node.label = update.label; if (update.text !== undefined) node.text = update.text; if (update.file !== undefined) node.file = update.file; if (update.url !== undefined) node.url = update.url; if (update.color !== undefined) node.color = update.color; changes.push(`updated ${node.id}`); } const idMap = new Map<string, string>(next.nodes.flatMap((node) => [[node.id, node.id], [nodeLabel(node), node.id]])); for (const node of patch.addNodes ?? []) { const id = node.id ?? makeId(node.label, next.nodes.length); idMap.set(node.label, id); next.nodes.push({ id, type: node.type ?? (node.file ? "file" : node.url ? "link" : "text"), x: 0, y: 0, width: node.width ?? 260, height: node.height ?? estimateHeight(node.text ?? node.label, 90), label: node.label, text: node.text ?? node.label, file: node.file, url: node.url, color: node.color, }); changes.push(`added ${id}`); } const removeEdgeIds = new Set(patch.removeEdges ?? []); next.edges = next.edges.filter((edge) => !removeEdgeIds.has(edge.id)); if (removeEdgeIds.size > 0) changes.push(`removed ${removeEdgeIds.size} edges`); for (const edge of patch.addEdges ?? []) { next.edges.push({ id: edge.id ?? `edge-${next.edges.length + 1}`, fromNode: resolveId(idMap, edge.from), toNode: resolveId(idMap, edge.to), label: edge.label, color: edge.color, }); changes.push(`added edge ${edge.from} -> ${edge.to}`); } return { canvas: patch.relayout === false ? next : relayoutCanvas(next, { direction: patch.direction ?? "TB" }), changes, }; } - src/tools.ts:1118-1137 (schema)Zod schema for obsidian_canvas_patch: accepts addNodes, addEdges, updateNodes, removeNodes, removeEdges, relayout, and direction parameters.
vault: vaultArg, path: pathArg, addNodes: z.array(z.object({ id: z.string().optional(), label: z.string(), type: z.enum(["text", "file", "link", "group"]).optional(), text: z.string().optional(), file: z.string().optional(), url: z.string().optional(), color: z.string().optional(), width: z.number().optional(), height: z.number().optional(), })).optional(), addEdges: z.array(z.object({ id: z.string().optional(), from: z.string(), to: z.string(), label: z.string().optional(), color: z.string().optional() })).optional(), updateNodes: z.array(z.object({ match: z.string(), label: z.string().optional(), text: z.string().optional(), file: z.string().optional(), url: z.string().optional(), color: z.string().optional() })).optional(), removeNodes: z.array(z.string()).optional(), removeEdges: z.array(z.string()).optional(), relayout: z.boolean().optional().default(true), direction: z.enum(["TB", "BT", "LR", "RL"]).optional().default("TB"), }, - src/canvas.ts:184-207 (helper)relayoutCanvas() helper function used by patchCanvas to apply dagre auto-layout after patching nodes/edges.
export function relayoutCanvas( canvas: CanvasFile, options: { direction?: "TB" | "BT" | "LR" | "RL" } = {}, ): CanvasFile { const graph = new dagre.graphlib.Graph(); graph.setGraph({ rankdir: options.direction ?? "TB", nodesep: 70, ranksep: 100, marginx: 20, marginy: 20 }); graph.setDefaultEdgeLabel(() => ({})); for (const node of canvas.nodes) graph.setNode(node.id, { width: node.width, height: node.height }); for (const edge of canvas.edges) graph.setEdge(edge.fromNode, edge.toNode); dagre.layout(graph); const nodes = canvas.nodes.map((node) => { const positioned = graph.node(node.id); return positioned ? { ...node, x: Math.round(positioned.x - node.width / 2), y: Math.round(positioned.y - node.height / 2) } : node; }); const nodeById = new Map(nodes.map((node) => [node.id, node])); const edges = canvas.edges.map((edge) => { const from = nodeById.get(edge.fromNode); const to = nodeById.get(edge.toNode); return from && to ? { ...edge, ...edgeSides(from, to) } : edge; }); return { nodes, edges }; } - src/canvas.ts:218-222 (helper)findNode() helper used by patchCanvas to locate nodes by id or label for update/removal operations.
function findNode(nodes: CanvasNode[], match: string): CanvasNode | undefined { const lower = match.toLowerCase(); return nodes.find((node) => node.id === match || nodeLabel(node).toLowerCase() === lower) ?? nodes.find((node) => nodeLabel(node).toLowerCase().includes(lower)); }