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:
Your NameandClaude Sonnet 5.5 committed 2026-10-15 11:57:09 +02:00
1 parent 0b5f8bfa8b
commit 313c41f867
16 files changed
+2706 -15

No files matched your search

+290
View File
@@ -0,0 +1,290 @@
'use strict';
// [[Wiki links]] on disk: find them, resolve them, rewrite them after a rename or move,
// and list backlinks. Lookup rules match src/lib/links.js.
const refPage = (ref) => String(ref || '').split('#')[0].replace(/\.md$/i, '').trim();
// Lookups over a page list, built once per list (a vault of thousands of pages is searched for every link).
const indexes = new WeakMap();
function indexOf(pages) {
let ix = indexes.get(pages);
if (ix && ix.size === pages.length) return ix;
const exact = new Map();
const byName = new Map();
for (const p of pages) {
const id = p.id.toLowerCase();
if (!exact.has(id)) exact.set(id, p.id);
const name = id.slice(id.lastIndexOf('/') + 1);
const list = byName.get(name);
if (list) list.push(p); else byName.set(name, [p]);
}
// by name: a note's title, or a canvas's file name ("Map.canvas"), so [[Map]] never means a canvas; shortest path wins
for (const list of byName.values()) if (list.length > 1) list.sort((a, b) => a.id.length - b.id.length);
ix = { size: pages.length, exact, byName };
indexes.set(pages, ix);
return ix;
}
function resolveLink(ref, pages) {
const want = refPage(ref).toLowerCase();
if (!want) return null;
const ix = indexOf(pages);
const hit = ix.exact.get(want);
if (hit) return hit;
const named = ix.byName.get(want);
return named ? named[0].id : null;
}
const normHeading = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
const linkHeading = (s) => String(s || '').replace(/[#|^\[\]]/g, ' ').replace(/\s+/g, ' ').trim();
const matchesHeading = (linkText, headingText) => {
const a = normHeading(linkText);
return !!a && (a === normHeading(headingText) || a === normHeading(linkHeading(headingText)));
};
// The heading a link points at ('' if none): the last #part, and #^blockid isn't a heading.
const headingOf = (ref) => {
const s = String(ref || '');
const i = s.lastIndexOf('#');
if (i < 0) return '';
const h = s.slice(i + 1).trim();
return h.startsWith('^') ? '' : h;
};
function refFor(id, pages) {
const title = id.slice(id.lastIndexOf('/') + 1);
const same = indexOf(pages).byName.get(title.toLowerCase()) || [];
return same.length <= 1 ? title : id;
}
// Blank out code (fenced and inline) so links inside it are left alone; offsets stay the same.
function maskCode(body) {
const blank = (s) => s.replace(/[^\n]/g, ' ');
let fence = false;
return body
.split('\n')
.map((line) => {
if (/^```/.test(line)) { fence = !fence; return blank(line); }
return fence ? blank(line) : line.replace(/`[^`\n]+`/g, blank);
})
.join('\n');
}
// [[Page]] links and ![[Page]] embeds (groups: 1 = "!" for an embed, 2 = target, 3 = |alias).
const LINK = /(!?)\[\[([^\[\]\n|]+)(\|[^\[\]\n]*)?\]\]/g;
// ![[photo.png]] and ![[slides.pdf]] embed files, not pages. (Same lists as src/lib/links.js.)
const FILE = /\.(png|jpe?g|gif|webp|avif|svg|bmp|pdf|mp3|mp4|wav|m4a|ogg|webm|mov|mkv|zip|docx?|xlsx?|pptx?|csv)$/i;
// [{ index, length, ref, alias, embed }] for every page link and page embed in a Markdown body.
function findLinks(body) {
const masked = maskCode(String(body || ''));
const out = [];
for (const m of masked.matchAll(LINK)) {
if (m[1] && FILE.test(m[2].trim())) continue;
out.push({ index: m.index, length: m[0].length, embed: !!m[1], ref: body.substr(m.index + m[1].length + 2, m[2].length), alias: m[3] || '' });
}
return out;
}
/* database properties
A database record's property values are front matter (page.props). Links written in them ([[Page]]) count as
links from the record, and a relation property (src/lib/database.js) points at sibling records by title. */
const NOT_PROPS = new Set(['icon', 'cover', 'tags', 'created', 'ink-database', 'kanban-plugin']);
const valuesOf = (v) => (Array.isArray(v) ? v : [v]).filter((x) => typeof x === 'string');
// The property values of a page that hold [[links]], one per line.
function propText(p) {
const out = [];
for (const [k, v] of Object.entries(p.props || {})) {
if (NOT_PROPS.has(k)) continue;
for (const x of valuesOf(v)) if (x.includes('[[')) out.push(x);
}
return out.join('\n');
}
// [{ key, name }] of the relation properties in database page `db` (read from the schema comment in its body).
function relationKeys(db) {
const m = String((db && db.body) || '').match(/^[ \t]*%%[ \t]*ink-database[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*%%[ \t]*$/m);
if (!m) return [];
try {
return (JSON.parse(m[1]).properties || []).filter((p) => p && p.type === 'relation' && p.key).map((p) => ({ key: p.key, name: p.name || p.key }));
} catch { return []; }
}
// [{ id, name }]: the sibling records that record `rec` names in its relation properties.
function relatedIds(rec, pages) {
if (!rec.parent || !rec.props) return [];
const db = pages.find((x) => x.id === rec.parent);
const keys = db ? relationKeys(db) : [];
if (!keys.length) return [];
const siblings = pages.filter((x) => x.parent === rec.parent && x.exists && x.id !== rec.id);
const out = [];
for (const { key, name } of keys) {
for (const title of valuesOf(rec.props[key])) {
const hit = siblings.find((x) => x.title.toLowerCase() === title.trim().toLowerCase());
if (hit) out.push({ id: hit.id, name });
}
}
return out;
}
// { key: newValue } for the property values whose [[links]] moved, or null.
function rewriteProps(props, before, after, moved) {
const patch = {};
for (const [k, v] of Object.entries(props || {})) {
if (NOT_PROPS.has(k)) continue;
const next = (x) => (typeof x === 'string' && x.includes('[[') ? rewriteLinks(x, before, after, moved) : null);
if (Array.isArray(v)) {
const out = v.map((x) => next(x));
if (out.some((x) => x !== null)) patch[k] = v.map((x, i) => (out[i] === null ? x : out[i]));
} else {
const n = next(v);
if (n !== null) patch[k] = n;
}
}
return Object.keys(patch).length ? patch : null;
}
// Rewrite links whose target moved. `before` is the page list before the change, `after` the list after,
// `moved(oldId)` returns the new id or null. Returns the new body, or null when nothing changed.
function rewriteLinks(body, before, after, moved) {
const links = findLinks(body);
let out = '', at = 0, changed = false;
for (const l of links) {
const was = resolveLink(l.ref, before);
const now = was && moved(was);
if (!now || now === was) continue;
const hash = l.ref.includes('#') ? l.ref.slice(l.ref.indexOf('#')) : '';
out += body.slice(at, l.index) + (l.embed ? '!' : '') + '[[' + refFor(now, after) + hash + l.alias + ']]';
at = l.index + l.length;
changed = true;
}
return changed ? out + body.slice(at) : null;
}
// A heading of page `pageId` was renamed from `from` to `to`: point the [[Page#from]] links and ![[Page#from]]
// embeds in `body` at the new name (page part and |alias stay). Returns { body, count }, or null if none matched.
// ([[#from]] with no page part points inside its own page, so it belongs to that page and is left alone.)
function renameHeadingRefs(body, pages, pageId, from, to) {
const safe = linkHeading(to);
if (!safe) return null;
let out = '', at = 0, count = 0;
for (const l of findLinks(body)) {
if (!refPage(l.ref) || resolveLink(l.ref, pages) !== pageId || !matchesHeading(headingOf(l.ref), from)) continue;
const ref = l.ref.slice(0, l.ref.lastIndexOf('#') + 1) + safe;
out += body.slice(at, l.index) + (l.embed ? '!' : '') + '[[' + ref + l.alias + ']]';
at = l.index + l.length;
count++;
}
return count ? { body: out + body.slice(at), count } : null;
}
// Pages that link to `id`, each with the line the first link sits on.
function backlinks(id, pages) {
const out = [];
for (const p of pages) {
if (!p.exists || p.id === id) continue;
const text = p.body + '\n' + propText(p); // links in property values count too
const hit = findLinks(text).find((l) => resolveLink(l.ref, pages) === id);
if (!hit) {
const rel = relatedIds(p, pages).find((r) => r.id === id);
if (rel) out.push({ id: p.id, title: p.title, icon: p.icon, snip: rel.name });
continue;
}
const start = text.lastIndexOf('\n', hit.index) + 1;
let end = text.indexOf('\n', hit.index);
if (end < 0) end = text.length;
const line = text.slice(start, end)
.replace(LINK, (_, _bang, ref, alias) => (alias ? alias.slice(1) : refPage(ref).split('/').pop()))
.replace(/^\s*(?:[-*+]\s+(?:\[[ xX]\]\s*)?|#{1,6}\s+|>\s?(?:\[!\w+\]\s*)?)/, '')
.replace(/\*\*|__|[*_`]/g, '')
.trim();
out.push({ id: p.id, title: p.title, icon: p.icon, snip: line.length > 160 ? line.slice(0, 159) + '…' : line });
}
return out.sort((a, b) => a.title.localeCompare(b.title));
}
/* unlinked mentions: pages that say a page's title in plain text without linking to it */
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Blank out what is not plain prose (code, [[links]], [text](url), bare URLs); offsets stay the same.
function maskProse(body) {
const blank = (s) => s.replace(/[^\n]/g, ' ');
return maskCode(body)
.replace(/!?\[\[[^\]\n]*\]\]/g, blank)
.replace(/!?\[[^\]\n]*\]\([^)\n]*\)/g, blank)
.replace(/\b(?:https?:\/\/|mailto:)\S+/g, blank);
}
const mentionRe = (title) => new RegExp('(?<![\\p{L}\\p{N}_])' + esc(title) + '(?![\\p{L}\\p{N}_])', 'iu');
const MIN_MENTION = 3;
function firstMention(body, title) {
if (String(title || '').trim().length < MIN_MENTION) return null;
const m = mentionRe(title.trim()).exec(maskProse(String(body || '')));
return m ? { index: m.index, length: m[0].length } : null;
}
// Pages that mention the title of `id` without linking to it, each with the line of the first mention.
function unlinked(id, pages) {
const me = pages.find((p) => p.id === id);
if (!me || !me.exists || /\.canvas$/i.test(id)) return [];
const out = [];
for (const p of pages) {
if (!p.exists || p.id === id || p.kind === 'canvas' || /\.canvas$/i.test(p.id)) continue;
if (p.props && p.props['kanban-plugin']) continue;
const hit = firstMention(p.body, me.title);
if (!hit) continue;
if (findLinks(p.body + '\n' + propText(p)).some((l) => resolveLink(l.ref, pages) === id)) continue;
const start = p.body.lastIndexOf('\n', hit.index) + 1;
let end = p.body.indexOf('\n', hit.index);
if (end < 0) end = p.body.length;
const line = p.body.slice(start, end)
.replace(/^\s*(?:[-*+]\s+(?:\[[ xX]\]\s*)?|#{1,6}\s+|>\s?(?:\[!\w+\]\s*)?)/, '')
.replace(/\*\*|__|[*_`]/g, '').trim();
out.push({ id: p.id, title: p.title, icon: p.icon, snip: line.length > 160 ? line.slice(0, 159) + '…' : line });
}
return out.sort((a, b) => a.title.localeCompare(b.title)).slice(0, 200);
}
// `body` with the first plain mention of page `id`'s title turned into a link; null if there is none.
function linkFirstMention(body, id, pages) {
const me = pages.find((p) => p.id === id);
if (!me) return null;
const hit = firstMention(body, me.title);
if (!hit) return null;
const said = body.substr(hit.index, hit.length);
const ref = refFor(id, pages);
const link = said === me.title.trim() && ref.toLowerCase() === said.toLowerCase() ? `[[${ref}]]` : `[[${ref}|${said}]]`;
return body.slice(0, hit.index) + link + body.slice(hit.index + hit.length);
}
// Everything the graph view draws: pages, links between them, and links to pages that don't exist yet.
// nodes: [{ id, title, icon, parent }] edges: [[from, to]] (each pair once) ghosts: [{ id: '?name', title }]
function graph(pages) {
const nodes = pages.filter((p) => p.exists).map((p) => ({ id: p.id, title: p.title, icon: p.icon || null, parent: p.parent || null, tags: p.tags || [] }));
const seen = new Set();
const edges = [];
const ghosts = new Map();
const add = (from, to) => {
const key = from + '\u0000' + to;
if (to === from || seen.has(key)) return;
seen.add(key);
edges.push([from, to]);
};
for (const p of pages) {
if (!p.exists) continue;
for (const l of findLinks(p.body + '\n' + propText(p))) {
let to = resolveLink(l.ref, pages);
if (!to) {
const name = refPage(l.ref);
if (!name) continue;
to = '?' + name.toLowerCase();
if (!ghosts.has(to)) ghosts.set(to, { id: to, title: name.split('/').pop() });
}
add(p.id, to);
}
for (const r of relatedIds(p, pages)) add(p.id, r.id);
}
return { nodes, edges, ghosts: [...ghosts.values()] };
}
module.exports = { unlinked, linkFirstMention, refPage, resolveLink, refFor, findLinks, rewriteLinks, rewriteProps, propText, relatedIds, renameHeadingRefs, backlinks, graph, headingOf, linkHeading, matchesHeading };