feat(electron): main process, vault, search index, links, import, capture
Vault on plain .md files with FTS5 index, link rewriting, canvas files, history/trash/backup, link previews, image fetch, tasks and unlinked mentions, Notion/Obsidian importer, quick-capture window and shortcut. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0b5f8bfa8b
commit
313c41f867
16 files changed
+2706
-15
No files matched your search
@@ -0,0 +1,91 @@
|
||||
'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 };
|
||||
Reference in new issue
Block a user