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>
135 lines
5.0 KiB
JavaScript
135 lines
5.0 KiB
JavaScript
'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 };
|