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

+166
View File
@@ -0,0 +1,166 @@
'use strict';
// What a web link card shows: the page's title and description, read from the start of its HTML.
// Runs in the main process (no CORS there). Only public http(s) addresses are fetched: a canvas file that
// someone else made must not be able to make this app read an address on your own network.
const dns = require('node:dns/promises');
const net = require('node:net');
const MAX_BYTES = 512 * 1024; // of HTML read; the tags we want are at the top
const TIMEOUT = 8000;
const MAX_REDIRECTS = 5;
// Replaceable for tests: { fetch, lookup }
const hooks = { fetch: null, lookup: null };
// Addresses that are not on the public internet (this machine, the local network, link-local, multicast, …).
const PRIVATE = new net.BlockList();
for (const [ip, bits] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
['192.0.0.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['224.0.0.0', 3]]) PRIVATE.addSubnet(ip, bits, 'ipv4');
for (const [ip, bits] of [['::', 128], ['::1', 128], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8]]) PRIVATE.addSubnet(ip, bits, 'ipv6');
function privateAddress(ip) {
const v = net.isIP(ip);
if (!v) return true;
const x = ip.toLowerCase();
if (v === 6) {
// IPv4 inside IPv6 (::ffff:a.b.c.d, which URL parsing turns into ::ffff:aabb:ccdd, or ::a.b.c.d): judge the IPv4 part
const dotted = x.match(/^::(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/);
const hex = x.match(/^::(?:ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
let v4 = dotted ? dotted[1] : null;
if (hex) {
const hi = parseInt(hex[1], 16), lo = parseInt(hex[2], 16);
v4 = `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
}
if (v4 && PRIVATE.check(v4, 'ipv4')) return true;
}
return PRIVATE.check(x, v === 4 ? 'ipv4' : 'ipv6');
}
// The URL if it may be fetched, else null.
async function allowed(raw, lookup) {
let u;
try { u = new URL(raw); } catch { return null; }
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
if (u.username || u.password) return null;
const host = u.hostname.replace(/^\[|\]$/g, '');
if (!host) return null;
if (net.isIP(host)) return privateAddress(host) ? null : u;
if (/(^|\.)localhost$/i.test(host)) return null;
try {
const addrs = await (lookup || ((h) => dns.lookup(h, { all: true })))(host);
if (!addrs.length || addrs.some((a) => privateAddress(a.address))) return null;
} catch { return null; }
return u;
}
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ndash: '–', mdash: '—', hellip: '…', rsquo: '’', lsquo: '‘', rdquo: '”', ldquo: '“', copy: '©' };
const decode = (s) =>
s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
if (e[0] === '#') {
const n = e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
try { return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : m; } catch { return m; }
}
return ENTITIES[e.toLowerCase()] ?? m;
});
const tidy = (s, max) => {
const t = decode(String(s || '')).replace(/\s+/g, ' ').trim();
return t.length > max ? t.slice(0, max - 1).trimEnd() + '…' : t;
};
// { title, description, site } from a page's HTML (empty strings where it says nothing)
function parsePreview(html) {
const head = String(html).slice(0, MAX_BYTES);
const meta = {};
for (const tag of head.match(/<meta\b[^>]*>/gi) || []) {
const at = {};
for (const m of tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g)) at[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? '';
const key = (at.property || at.name || '').toLowerCase();
if (key && at.content !== undefined && !(key in meta)) meta[key] = at.content;
}
const t = head.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
return {
title: tidy(meta['og:title'] || meta['twitter:title'] || (t ? t[1] : ''), 200),
description: tidy(meta['og:description'] || meta['twitter:description'] || meta.description, 400),
site: tidy(meta['og:site_name'], 80)
};
}
function charsetOf(contentType, bytes) {
const h = /charset\s*=\s*["']?([\w-]+)/i.exec(contentType || '');
if (h) return h[1];
const m = /<meta[^>]+charset\s*=\s*["']?([\w-]+)/i.exec(Buffer.from(bytes.subarray(0, 4096)).toString('latin1'));
return m ? m[1] : 'utf-8';
}
async function readLimited(res, limit = MAX_BYTES) {
const chunks = [];
let size = 0;
const reader = res.body.getReader();
try {
while (size < limit) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
size += value.length;
}
} finally {
reader.cancel().catch(() => {});
}
return Buffer.concat(chunks.map((c) => Buffer.from(c))).subarray(0, limit);
}
// { title, description, site } for a web address, or null when there is nothing to show.
async function fetchPreview(url, { fetch: doFetch, lookup } = {}) {
const get = doFetch || hooks.fetch || globalThis.fetch;
const look = lookup || hooks.lookup;
const signal = AbortSignal.timeout(TIMEOUT);
let u = await allowed(url, look);
for (let hop = 0; u && hop <= MAX_REDIRECTS; hop++) {
let res;
try {
res = await get(u.href, {
redirect: 'manual',
signal,
headers: { 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36', accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.1', 'accept-language': 'en;q=0.8' }
});
} catch { return null; }
if (res.status >= 300 && res.status < 400) {
const loc = res.headers.get('location');
u = loc ? await allowed(new URL(loc, u).href, look) : null; // every hop is checked again
continue;
}
if (!res.ok || !/html/i.test(res.headers.get('content-type') || '') || !res.body) return null;
const bytes = await readLimited(res);
let text;
try { text = new TextDecoder(charsetOf(res.headers.get('content-type'), bytes)).decode(bytes); } catch { text = bytes.toString('utf8'); }
const p = parsePreview(text);
return p.title || p.description ? p : null;
}
return null;
}
// { html, url } for a whole web page (up to 3 MB), under the same rules as previews; null if it can't be had.
async function fetchHtml(url, { fetch: doFetch, lookup } = {}) {
const get = doFetch || hooks.fetch || globalThis.fetch;
const look = lookup || hooks.lookup;
const signal = AbortSignal.timeout(15000);
let u = await allowed(url, look);
for (let hop = 0; u && hop <= MAX_REDIRECTS; hop++) {
let res;
try {
res = await get(u.href, { redirect: 'manual', signal, headers: { 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36', accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.1' } });
} catch { return null; }
if (res.status >= 300 && res.status < 400) {
const loc = res.headers.get('location');
u = loc ? await allowed(new URL(loc, u).href, look) : null;
continue;
}
if (!res.ok || !/html/i.test(res.headers.get('content-type') || '') || !res.body) return null;
const bytes = await readLimited(res, 3 * 1024 * 1024);
let text;
try { text = new TextDecoder(charsetOf(res.headers.get('content-type'), bytes)).decode(bytes); } catch { text = bytes.toString('utf8'); }
return { html: text, url: u.href };
}
return null;
}
module.exports = { fetchHtml, fetchPreview, parsePreview, allowed, hooks };