import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import matter from 'gray-matter'; import { slugify } from './slug.mjs'; import { readSource } from './parse.mjs'; import { accession, isPromotion, normalizeStage } from '../../notes-src/stack/folio.mjs'; import { reservedSlugs } from '../../notes-src/stack/routes.mjs'; import { buildLinkIndex, transformBody } from './transform.mjs'; const KEY_ORDER = ['title', 'slug', 'number', 'source', 'stage', 'epistemic', 'description', 'tags', 'layout', 'planted', 'tended', 'hash', 'stages', 'links']; const toDateString = (v) => (v instanceof Date ? v.toISOString().slice(0, 10) : v); /** * A stored stage history with retired stage names mapped to current ones, keeping only the first * of any run of the same stage (so draft → in progress becomes a single draft). * @param {Array<{stage: string, date: string|Date}>} stages */ export function normalizeHistory(stages) { const out = []; for (const s of stages) { const stage = normalizeStage(s.stage); if (out.length && out[out.length - 1].stage === stage) continue; out.push({ stage, date: toDateString(s.date) }); } return out; } /** * Read srcDir/garden/*.md into a map keyed by slug. * @param {string} srcDir */ export function readGarden(srcDir) { const dir = path.join(srcDir, 'garden'); const out = new Map(); if (!fs.existsSync(dir)) return out; for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort()) { const parsed = matter(fs.readFileSync(path.join(dir, file), 'utf8')); const d = { ...parsed.data }; d.planted = toDateString(d.planted); d.tended = toDateString(d.tended); if (d.stage != null) d.stage = normalizeStage(d.stage); d.stages = normalizeHistory(d.stages || []); d.tags = d.tags || []; d.links = d.links || []; out.set(d.slug ?? path.basename(file, '.md'), { ...d, body: parsed.content.replace(/\n+$/, '') }); } return out; } /** * Read the number high-water mark. Only a missing file means 0; anything malformed is an error. * @param {string} srcDir * @returns {{ max: number, error?: string }} */ function readMax(srcDir) { let text; try { text = fs.readFileSync(path.join(srcDir, 'numbers.json'), 'utf8'); } catch (e) { if (e.code === 'ENOENT') return { max: 0 }; return { max: 0, error: `numbers.json: unreadable (${e.message}); fix or delete it deliberately` }; } try { const max = JSON.parse(text)?.max; if (!Number.isInteger(max) || max < 0) throw new Error(`"max" must be a non-negative integer, got ${JSON.stringify(max)}`); return { max }; } catch (e) { return { max: 0, error: `numbers.json: invalid (${e.message}); fix or delete it deliberately` }; } } const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0); /** * Compute everything a sync would do. Reads files, writes nothing. * @param {{ sourceDir: string, srcDir: string, today: string }} opts */ export function planSync({ sourceDir, srcDir, today }) { const { notes, errors: parseErrors } = readSource(sourceDir); const errors = [...parseErrors]; const warnings = []; const garden = readGarden(srcDir); const slugBySource = new Map([...garden.values()].map((g) => [g.source, g.slug])); // Numbers follow the source file, falling back to the slug when the file was renamed. const isNum = (v) => Number.isInteger(v) && v > 0; for (const g of garden.values()) { if (g.number !== undefined && !isNum(g.number)) { errors.push(`${g.slug}: invalid number ${JSON.stringify(g.number)} in garden frontmatter; fix it or remove the line deliberately`); } } const bySlugNumber = new Map(); for (const g of [...garden.values()].sort((x, y) => cmp(x.slug, y.slug))) { if (!isNum(g.number)) continue; if (bySlugNumber.has(g.number)) errors.push(`duplicate number ${g.number}: ${bySlugNumber.get(g.number)} and ${g.slug}`); else bySlugNumber.set(g.number, g.slug); } const numberBySource = new Map([...garden.values()].filter((g) => isNum(g.number)).map((g) => [g.source, g.number])); const published = notes.filter((n) => n.data.publish); const slugByFile = new Map(); const fileBySlug = new Map(); for (const n of published) { const slug = n.data.slug ? slugify(n.data.slug) : slugBySource.get(n.file) || slugify(n.title); if (!slug) { errors.push(`${n.file}: cannot derive a slug; add slug: to its frontmatter`); continue; } if (reservedSlugs.has(slug)) { errors.push(`${n.file}: slug "${slug}" is reserved; set a different slug:`); continue; } if (fileBySlug.has(slug)) { errors.push(`duplicate slug "${slug}": ${fileBySlug.get(slug)} and ${n.file}`); continue; } fileBySlug.set(slug, n.file); slugByFile.set(n.file, slug); } const index = buildLinkIndex(notes, slugByFile); const records = []; const images = []; const imageTo = new Map(); const report = { added: [], changed: [], promoted: [], demoted: [], removed: [], renames: [], numbered: [] }; for (const n of published) { const slug = slugByFile.get(n.file); if (!slug) continue; const t = transformBody(n.body, { slug, sourceDir, index }); for (const w of t.warnings) warnings.push(`${n.file}: ${w}`); for (const img of t.images) { const prev = imageTo.get(img.to); if (prev === undefined) { imageTo.set(img.to, img.from); images.push(img); } else if (prev !== img.from) { warnings.push(`${n.file}: image ${img.to} already used by a different file; keeping the first`); } } const hash = crypto.createHash('sha256').update(t.hashInput).digest('hex'); const old = garden.get(slug); const stage = n.data.stage; const tags = [...new Set([...n.data.tags, ...t.tags])].sort(); let planted = today; let tended = today; let stages = [{ stage, date: today }]; if (old) { planted = old.planted; tended = old.hash === hash ? old.tended : today; stages = [...old.stages]; if (old.hash !== hash) report.changed.push(slug); const last = stages[stages.length - 1]; if (!last || last.stage !== stage) { stages.push({ stage, date: today }); const move = { slug, from: last ? last.stage : old.stage, to: stage }; (isPromotion(move.from, stage) ? report.promoted : report.demoted).push(move); } } else { report.added.push(slug); } const rec = { title: n.title, slug, source: n.file, stage }; // A renamed source with a pinned slug still finds its number through the garden record. const num = numberBySource.get(n.file) ?? (isNum(old?.number) ? old.number : undefined); if (n.data.layout === 'note' && isNum(num)) rec.number = num; if (n.data.epistemic) rec.epistemic = n.data.epistemic; if (n.data.description) rec.description = n.data.description; Object.assign(rec, { tags, layout: n.data.layout, planted, tended, hash, stages, links: t.links, body: t.body }); records.push(rec); } const noteRecs = records.filter((r) => r.layout === 'note'); const stored = readMax(srcDir); if (stored.error) errors.push(stored.error); let max = Math.max(stored.max, 0, ...[...garden.values()].map((g) => (isNum(g.number) ? g.number : 0))); const unnumbered = noteRecs .filter((r) => r.number === undefined) .sort((a, b) => cmp(a.planted, b.planted) || cmp(a.source, b.source)); for (const r of unnumbered) { r.number = ++max; report.numbered.push({ slug: r.slug, number: r.number }); } for (const r of noteRecs) max = Math.max(max, r.number); const keep = new Set(records.map((r) => r.slug)); for (const slug of garden.keys()) if (!keep.has(slug)) report.removed.push(slug); for (const k of ['added', 'changed', 'removed']) report[k].sort(); for (const k of ['promoted', 'demoted']) report[k].sort((a, b) => a.slug.localeCompare(b.slug)); for (const from of report.removed) for (const to of report.added) report.renames.push({ from, to }); records.sort((a, b) => a.slug.localeCompare(b.slug)); return { errors, warnings, records, images, report, max }; } /** * Write the plan to srcDir. Owns only garden/*.md, media/ and numbers.json. * @param {ReturnType} plan * @param {string} srcDir */ export function applySync(plan, srcDir) { if (plan.errors.length) throw new Error(`cannot apply sync with errors:\n${plan.errors.join('\n')}`); const gardenDir = path.join(srcDir, 'garden'); fs.mkdirSync(gardenDir, { recursive: true }); const keep = new Set(plan.records.map((r) => `${r.slug}.md`)); for (const f of fs.readdirSync(gardenDir)) { if (f.endsWith('.md') && !keep.has(f)) fs.rmSync(path.join(gardenDir, f)); } for (const r of plan.records) { const { body, ...rest } = r; const data = {}; for (const k of KEY_ORDER) if (rest[k] !== undefined) data[k] = rest[k]; fs.writeFileSync(path.join(gardenDir, `${r.slug}.md`), matter.stringify(body, data)); } fs.writeFileSync(path.join(srcDir, 'numbers.json'), JSON.stringify({ max: plan.max }, null, 2) + '\n'); const mediaDir = path.join(srcDir, 'media'); fs.rmSync(mediaDir, { recursive: true, force: true }); for (const img of plan.images) { const dest = path.join(mediaDir, img.to); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(img.from, dest); } } /** @param {ReturnType} plan */ export function formatReport(plan) { const r = plan.report; const counts = [ [r.added.length, 'added'], [r.changed.length, 'changed'], [r.promoted.length, 'promoted'], [r.demoted.length, 'demoted'], [r.removed.length, 'removed'], ].filter(([n]) => n); const lines = [`Notes sync: ${counts.length ? counts.map(([n, l]) => `${n} ${l}`).join(', ') : 'no changes'}`]; const section = (title, items) => { if (items.length) lines.push('', `${title}:`, ...items.map((i) => ` ${i}`)); }; const move = (m) => `${m.slug}: ${m.from} -> ${m.to}`; section('Numbered', r.numbered.map((m) => `${m.slug}: ${accession(m.number)}`)); section('Added', r.added); section('Changed', r.changed); section('Promoted', r.promoted.map(move)); section('Demoted', r.demoted.map(move)); section('Removed', r.removed); section( 'Possible renames (pin slug: in NotePlan to keep URLs stable)', r.renames.map((m) => `${m.from} -> ${m.to}`), ); section('Warnings', plan.warnings); section('Errors', plan.errors); return lines.join('\n'); }