'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 " .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 };