'use strict'; // The vault is a plain folder of Markdown files. // "Projects.md" -> page "Projects" // "Projects/Note app roadmap.md" -> child page of "Projects" // A folder without a matching .md file still shows up as an (empty) page. // Page ids are paths relative to the vault, without ".md", always with "/". // Canvases ("Ideas/Map.canvas", see canvas.cjs) are pages too; their id keeps the ".canvas", as links to them do. const fs = require('node:fs'); const fsp = fs.promises; const path = require('node:path'); const { parseFrontMatter, serializeFrontMatter } = require('./frontmatter.cjs'); const { isCanvasId, canvasText, EMPTY_CANVAS } = require('./canvas.cjs'); const ILLEGAL = /[\\/:*?"<>|\u0000-\u001f]/g; const SKIP_DIRS = new Set(['node_modules']); function cleanTitle(t) { const s = String(t || '').replace(ILLEGAL, '-').replace(/\s+/g, ' ').trim().replace(/^\.+/, '').slice(0, 120).trim(); return s || 'Untitled'; } const today = (d = new Date()) => { const z = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`; }; const parentOf = (id) => (id.includes('/') ? id.slice(0, id.lastIndexOf('/')) : null); const PAGE_KEYS = new Set(['icon', 'cover', 'tags', 'created']); const titleOf = (id) => id.slice(id.lastIndexOf('/') + 1).replace(/\.canvas$/i, ''); const extOf = (id) => (isCanvasId(id) ? '.canvas' : ''); const noCanvasParent = (parent) => { if (parent && isCanvasId(parent)) throw new Error('A canvas cannot hold pages.'); }; const asArray = (v) => (Array.isArray(v) ? v : typeof v === 'string' && v ? [v] : []).map(String); async function exists(p) { try { await fsp.access(p); return true; } catch { return false; } } // Version history limits (Settings): at most one version per `gap` of editing, the newest `keep` per page. const limits = { gap: 5 * 60 * 1000, keep: 100 }; function setLimits({ historyKeep, historyGapMin } = {}) { if (Number.isFinite(historyKeep) && historyKeep >= 1) limits.keep = Math.floor(historyKeep); if (Number.isFinite(historyGapMin) && historyGapMin >= 1) limits.gap = Math.floor(historyGapMin) * 60 * 1000; } const isLocalPath = (src) => !!src && !/^[a-z][a-z0-9+.-]*:/i.test(src) && !src.startsWith('/') && !src.startsWith('\\'); const cleanFileName = (name) => { const ext = (path.extname(String(name)).toLowerCase().match(/^\.[a-z0-9]{1,5}$/) || ['.png'])[0]; const base = path.basename(String(name), path.extname(String(name))).replace(ILLEGAL, '-').replace(/\s+/g, ' ').trim().replace(/^\.+/, '').slice(0, 80); return (base || 'image') + ext; }; async function freeFile(dir, name) { const ext = path.extname(name), base = name.slice(0, name.length - ext.length); for (let n = 1; n < 1000; n++) { const f = n === 1 ? name : `${base} ${n}${ext}`; if (!(await exists(path.join(dir, f)))) return f; } throw new Error('Could not find a free file name.'); } class Vault { constructor(root, { trash } = {}) { this.root = path.resolve(root); this.trash = trash; this.recent = new Map(); // id -> time of our own write, to ignore watcher echoes this.cache = new Map(); // fullPath -> { mtime, page } } #inside(p) { if (p !== this.root && !p.startsWith(this.root + path.sep)) throw new Error('That path is outside the notes folder.'); return p; } abs(id) { return this.#inside(path.resolve(this.root, isCanvasId(id) ? id : id + '.md')); } dir(id) { return this.#inside(path.resolve(this.root, id || '.')); } #touch(id) { this.recent.set(id, Date.now()); } isRecent(id) { const t = this.recent.get(id); return t !== undefined && Date.now() - t < 1500; } async list() { const pages = new Map(); const walk = async (dir, parent) => { let entries; try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; } for (const e of entries) { if (e.name.startsWith('.') || !e.isFile() || !/\.(md|canvas)$/i.test(e.name)) continue; const canvas = /\.canvas$/i.test(e.name); const title = canvas ? e.name.slice(0, -7) : e.name.slice(0, -3); const id = parent ? `${parent}/${canvas ? e.name : title}` : canvas ? e.name : title; const full = path.join(dir, e.name); try { const st = await fsp.stat(full); const cached = this.cache.get(full); let page; if (cached && cached.mtime === st.mtimeMs) { page = cached.page; // update ID and parent in case it moved/renamed but mtime stayed the same page.id = id; page.parent = parent || null; page.title = title; } else { const raw = await fsp.readFile(full, 'utf8'); // A canvas's "body" is its text and the notes on it as [[links]], for search, backlinks and the graph. const { meta, body } = canvas ? { meta: {}, body: canvasText(raw) } : parseFrontMatter(raw); // Front matter beyond the page's own keys: a database record's property values (see src/lib/database.js). const props = {}; for (const k of Object.keys(meta)) if (!PAGE_KEYS.has(k)) props[k] = meta[k]; page = { ...(canvas ? { kind: 'canvas' } : {}), props, ...(meta['ink-database'] ? { db: true } : {}), id, parent: parent || null, title, exists: true, mtime: st.mtimeMs, icon: meta.icon || null, cover: meta.cover || null, tags: asArray(meta.tags), created: meta.created || today(st.birthtime && st.birthtime.getTime() ? st.birthtime : st.mtime), body }; this.cache.set(full, { mtime: st.mtimeMs, page }); } pages.set(id, page); } catch { /* unreadable file: skip */ } } for (const e of entries) { if (e.name.startsWith('.') || !e.isDirectory() || SKIP_DIRS.has(e.name)) continue; const id = parent ? `${parent}/${e.name}` : e.name; if (!pages.has(id)) { pages.set(id, { id, parent: parent || null, title: e.name, exists: false, mtime: 0, icon: null, cover: null, tags: [], created: null, body: '' }); } await walk(path.join(dir, e.name), id); } }; await walk(this.root, ''); const active = new Set([...pages.values()].filter(p => p.exists).map(p => this.abs(p.id))); for (const k of this.cache.keys()) { if (!active.has(k)) this.cache.delete(k); } return [...pages.values()]; } async read(id) { const file = this.abs(id); try { const [raw, st] = await Promise.all([fsp.readFile(file, 'utf8'), fsp.stat(file)]); const { meta, body } = isCanvasId(id) ? { meta: {}, body: raw } : parseFrontMatter(raw); if (meta.tags !== undefined) meta.tags = asArray(meta.tags); return { id, title: titleOf(id), meta, body, exists: true, mtime: st.mtimeMs }; } catch (err) { if (err.code !== 'ENOENT') throw err; return { id, title: titleOf(id), meta: {}, body: '', exists: false, mtime: 0 }; } } async write(id, meta, body) { const file = this.abs(id); await fsp.mkdir(path.dirname(file), { recursive: true }); const text = isCanvasId(id) ? String(body || '') : serializeFrontMatter(meta) + String(body || ''); const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.tmp`); this.#touch(id); await fsp.writeFile(tmp, text, 'utf8'); await fsp.rename(tmp, file); this.#touch(id); await this.snapshot(id, text); return (await fsp.stat(file)).mtimeMs; } /* version history .notes/history//