'use strict'; // Canvas files (".canvas") on disk, in the JSON Canvas format Obsidian uses (https://jsoncanvas.org): // { "nodes": [{ id, type: text|file|link|group, x, y, width, height, text|file|url|label, color? }], // "edges": [{ id, fromNode, fromSide?, toNode, toSide?, label?, color? }] } // Written the way Obsidian writes it (one node per line, tabs), so files changed by both apps diff cleanly. // The page-side twin is src/lib/canvas.js; keep parse/serialize in step. const { rewriteLinks, renameHeadingRefs, resolveLink, matchesHeading, linkHeading } = require('./links.cjs'); const isCanvasId = (id) => /\.canvas$/i.test(String(id || '')); // { nodes, edges, extra } from the file's text. Empty text is an empty canvas; anything unreadable throws. function parseCanvas(text) { const s = String(text || '').trim(); if (!s) return { nodes: [], edges: [], extra: {} }; const data = JSON.parse(s); if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('This is not a canvas file.'); const { nodes, edges, ...extra } = data; return { nodes: Array.isArray(nodes) ? nodes : [], edges: Array.isArray(edges) ? edges : [], extra }; } function serializeCanvas({ nodes = [], edges = [], extra = {} }) { const list = (arr) => (arr.length ? '[\n' + arr.map((o) => '\t\t' + JSON.stringify(o)).join(',\n') + '\n\t]' : '[]'); const parts = [`\t"nodes":${list(nodes)}`, `\t"edges":${list(edges)}`]; for (const [k, v] of Object.entries(extra)) parts.push(`\t${JSON.stringify(k)}:${JSON.stringify(v)}`); return '{\n' + parts.join(',\n') + '\n}'; } const EMPTY_CANVAS = serializeCanvas({}); // A note's file node points at "Folder/Note.md"; its page id has no extension. Other files keep theirs. const fileToId = (file) => String(file || '').replace(/\.md$/i, ''); const isNoteFile = (file) => /\.md$/i.test(String(file || '')); const isCanvasFile = (file) => isCanvasId(file); // The canvas as Markdown-ish text: what search indexes, and where backlinks and the graph find its links // (a note or canvas on it counts as a [[link]]). function canvasText(text) { let c; try { c = parseCanvas(text); } catch { return ''; } const out = []; for (const n of c.nodes) { if (n.type === 'text' && n.text) out.push(String(n.text)); else if (n.type === 'group' && n.label) out.push(String(n.label)); else if (n.type === 'link' && n.url) out.push(String(n.url)); else if (n.type === 'file' && (isNoteFile(n.file) || isCanvasFile(n.file))) out.push(`[[${fileToId(n.file)}${n.subpath || ''}]]`); } for (const e of c.edges) if (e.label) out.push(String(e.label)); return out.join('\n\n'); } // Pages moved or renamed (see main.cjs relink): point file nodes and [[links]] in text nodes at the new places. // Returns the new file text, or null when nothing changed. function rewriteCanvas(text, before, after, moved) { let c; try { c = parseCanvas(text); } catch { return null; } let changed = false; for (const n of c.nodes) { if (n.type === 'file' && n.file) { const id = fileToId(n.file); const now = moved(id); if (now && now !== id) { n.file = isNoteFile(n.file) ? now + '.md' : now; changed = true; } } else if (n.type === 'text' && n.text) { const t = rewriteLinks(n.text, before, after, moved); if (t !== null) { n.text = t; changed = true; } } } return changed ? serializeCanvas(c) : null; } // A heading of page `pageId` was renamed: notes shown at that heading ("subpath") and [[Page#Heading]] links in // text nodes follow. Returns { body, count } or null. function renameHeadingInCanvas(text, pages, pageId, from, to) { let c; try { c = parseCanvas(text); } catch { return null; } const safe = linkHeading(to); if (!safe) return null; let count = 0; for (const n of c.nodes) { if (n.type === 'file' && isNoteFile(n.file) && n.subpath && resolveLink(fileToId(n.file), pages) === pageId && matchesHeading(String(n.subpath).replace(/^#/, ''), from)) { n.subpath = '#' + safe; count++; } else if (n.type === 'text' && n.text) { const r = renameHeadingRefs(n.text, pages, pageId, from, to); if (r) { n.text = r.body; count += r.count; } } } return count ? { body: serializeCanvas(c), count } : null; } module.exports = { isCanvasId, parseCanvas, serializeCanvas, EMPTY_CANVAS, canvasText, rewriteCanvas, renameHeadingInCanvas, fileToId };