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,134 @@
|
||||
'use strict';
|
||||
// Backups: the notes folder as one .zip, written without any dependency (deflate from zlib, a small zip writer).
|
||||
// Version history and the trash are left out (they are large and can be rebuilt or are meant to be gone),
|
||||
// and so is the search index.
|
||||
const fs = require('node:fs');
|
||||
const fsp = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const zlib = require('node:zlib');
|
||||
|
||||
const SKIP = ['.notes/history', '.notes/trash', '.notes/index.sqlite', '.notes/index.sqlite-wal', '.notes/index.sqlite-shm'];
|
||||
|
||||
let table = null;
|
||||
function crc32(buf) {
|
||||
if (!table) {
|
||||
table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
}
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) c = table[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function dosTime(d) {
|
||||
const year = Math.max(1980, d.getFullYear());
|
||||
return {
|
||||
time: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1),
|
||||
date: ((year - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()
|
||||
};
|
||||
}
|
||||
|
||||
async function* walk(root, rel = '') {
|
||||
let entries;
|
||||
try { entries = await fsp.readdir(path.join(root, rel), { withFileTypes: true }); } catch { return; }
|
||||
for (const e of entries) {
|
||||
const r = rel ? `${rel}/${e.name}` : e.name;
|
||||
if (SKIP.includes(r)) continue;
|
||||
if (e.isDirectory()) yield* walk(root, r);
|
||||
else if (e.isFile()) yield r;
|
||||
}
|
||||
}
|
||||
|
||||
// Zip `root` into `out`. Returns the number of files.
|
||||
async function zipFolder(root, out) {
|
||||
await fsp.mkdir(path.dirname(out), { recursive: true });
|
||||
const tmp = out + '.part';
|
||||
const fd = await fsp.open(tmp, 'w');
|
||||
const central = [];
|
||||
let offset = 0, count = 0;
|
||||
try {
|
||||
for await (const rel of walk(root)) {
|
||||
const file = path.join(root, rel);
|
||||
let data, st;
|
||||
try { data = await fsp.readFile(file); st = await fsp.stat(file); } catch { continue; } // changed while we ran
|
||||
const packed = zlib.deflateRawSync(data);
|
||||
const useDeflate = packed.length < data.length;
|
||||
const body = useDeflate ? packed : data;
|
||||
const name = Buffer.from(rel, 'utf8');
|
||||
const { time, date } = dosTime(st.mtime);
|
||||
const crc = crc32(data);
|
||||
const head = Buffer.alloc(30);
|
||||
head.writeUInt32LE(0x04034b50, 0);
|
||||
head.writeUInt16LE(20, 4);
|
||||
head.writeUInt16LE(0x0800, 6); // UTF-8 names
|
||||
head.writeUInt16LE(useDeflate ? 8 : 0, 8);
|
||||
head.writeUInt16LE(time, 10);
|
||||
head.writeUInt16LE(date, 12);
|
||||
head.writeUInt32LE(crc, 14);
|
||||
head.writeUInt32LE(body.length, 18);
|
||||
head.writeUInt32LE(data.length, 22);
|
||||
head.writeUInt16LE(name.length, 26);
|
||||
await fd.write(head);
|
||||
await fd.write(name);
|
||||
await fd.write(body);
|
||||
central.push({ name, crc, size: data.length, packed: body.length, method: useDeflate ? 8 : 0, time, date, offset });
|
||||
offset += 30 + name.length + body.length;
|
||||
count++;
|
||||
}
|
||||
let size = 0;
|
||||
for (const c of central) {
|
||||
const h = Buffer.alloc(46);
|
||||
h.writeUInt32LE(0x02014b50, 0);
|
||||
h.writeUInt16LE(20, 4);
|
||||
h.writeUInt16LE(20, 6);
|
||||
h.writeUInt16LE(0x0800, 8);
|
||||
h.writeUInt16LE(c.method, 10);
|
||||
h.writeUInt16LE(c.time, 12);
|
||||
h.writeUInt16LE(c.date, 14);
|
||||
h.writeUInt32LE(c.crc, 16);
|
||||
h.writeUInt32LE(c.packed, 20);
|
||||
h.writeUInt32LE(c.size, 24);
|
||||
h.writeUInt16LE(c.name.length, 28);
|
||||
h.writeUInt32LE(c.offset, 42);
|
||||
await fd.write(h);
|
||||
await fd.write(c.name);
|
||||
size += 46 + c.name.length;
|
||||
}
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(count, 8);
|
||||
end.writeUInt16LE(count, 10);
|
||||
end.writeUInt32LE(size, 12);
|
||||
end.writeUInt32LE(offset, 16);
|
||||
await fd.write(end);
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
await fsp.rename(tmp, out);
|
||||
return count;
|
||||
}
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
function stamp(d = new Date()) {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
// Back up the folder into `dir` as "<folder name> <date time>.zip", then keep only the newest `keep` of this folder's.
|
||||
async function backup(root, dir, keep) {
|
||||
const base = path.basename(root);
|
||||
const out = path.join(dir, `${base} ${stamp()}.zip`);
|
||||
const count = await zipFolder(root, out);
|
||||
const mine = (await fsp.readdir(dir)).filter((f) => f.startsWith(base + ' ') && /\d{4}-\d{2}-\d{2} \d{4}\.zip$/.test(f)).sort().reverse();
|
||||
for (const old of mine.slice(Math.max(1, keep))) await fsp.unlink(path.join(dir, old)).catch(() => {});
|
||||
return { file: out, count };
|
||||
}
|
||||
|
||||
const EVERY = { daily: 20 * 3600e3, weekly: 6.5 * 24 * 3600e3 };
|
||||
// Whether a backup is due: every = 'off' | 'daily' | 'weekly'; last = time of the previous one (ms) or 0.
|
||||
const due = (every, last, now = Date.now()) => !!EVERY[every] && now - (last || 0) >= EVERY[every];
|
||||
|
||||
module.exports = { zipFolder, backup, due, crc32, stamp };
|
||||
Reference in new issue
Block a user