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,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 };
|
||||
Reference in new issue
Block a user