/** * Journal append action (DRAFT-001 v0.3.2). * * Templates are the shared contract: this module reads * `templates/journal.md` to seed a missing journal, then appends a new * dated entry under the `## Log` section (creating it if absent). * * Pure functions: callers get back `{ targetPath, newContents }`. The * pane handles the actual disk write so it can show a preview diff * first. */ import * as fs from "node:fs"; import * as path from "node:path"; import { parseFrontmatter, todayIso } from "../frontmatter"; const TEMPLATE_REL = "templates/journal.md"; export interface JournalPlan { targetPath: string; /** Relative path for display. */ relTargetPath: string; /** Final file contents on disk after the append. */ newContents: string; /** Original contents at plan time (empty string if the file doesn't exist). */ originalContents: string; /** True when the file did not exist and is being created from template. */ isNew: boolean; } /** * Build a plan to append `entryText` to the journal for the focused file. * Caller is the cockpit, which knows the package root path. * * @param focusedAbsPath Absolute path of the file the user has focused. * @param entryText The user-supplied entry body (one line for now). * @param sddPackageRoot Absolute path to `packages/sdd` (so we can find * the journal template). */ export function planJournalAppend( focusedAbsPath: string, entryText: string, sddPackageRoot: string, now: Date = new Date(), ): { ok: true; plan: JournalPlan } | { ok: false; reason: string } { const trimmed = entryText.trim(); if (trimmed.length === 0) { return { ok: false, reason: "empty entry" }; } const dir = path.dirname(focusedAbsPath); const targetPath = path.join(dir, "journal.md"); let originalContents = ""; let isNew = false; if (fs.existsSync(targetPath)) { try { originalContents = fs.readFileSync(targetPath, "utf8"); } catch (e) { return { ok: false, reason: `cannot read existing journal: ${(e as Error).message}` }; } } else { isNew = true; const seeded = seedFromTemplate(focusedAbsPath, sddPackageRoot); if (!seeded.ok) return seeded; originalContents = seeded.contents; } const date = todayIso(now); const newContents = appendLogEntry(originalContents, date, trimmed); return { ok: true, plan: { targetPath, relTargetPath: relativeFromCwd(targetPath), newContents, originalContents: isNew ? "" : originalContents, isNew, }, }; } function relativeFromCwd(abs: string): string { const rel = path.relative(process.cwd(), abs); return rel.length > 0 ? rel : abs; } function seedFromTemplate( focusedAbsPath: string, sddPackageRoot: string, ): { ok: true; contents: string } | { ok: false; reason: string } { const templatePath = path.join(sddPackageRoot, TEMPLATE_REL); let raw: string; try { raw = fs.readFileSync(templatePath, "utf8"); } catch (e) { return { ok: false, reason: `cannot read journal template: ${(e as Error).message}` }; } // Extract ticket id + slug from focused file's frontmatter / folder name. const { ticketId, slug } = ticketInfoFor(focusedAbsPath); // The template uses `` and `` placeholders in the H1. const filled = raw .replace(//g, ticketId) .replace(//g, slug); // Prepend a minimal frontmatter block so the cockpit can recognise the // journal's ticket. Skip if the template already has one. if (filled.startsWith("---")) return { ok: true, contents: filled }; const fm = `---\nid: ${ticketId}\n---\n\n`; return { ok: true, contents: fm + filled }; } function ticketInfoFor(focusedAbsPath: string): { ticketId: string; slug: string } { // Try frontmatter id from the focused file first. try { const content = fs.readFileSync(focusedAbsPath, "utf8"); const fm = parseFrontmatter(content); const fmId = fm?.fields.get("id"); if (fmId) { const slug = slugFromFolder(focusedAbsPath, fmId); return { ticketId: fmId, slug }; } } catch { // ignore } // Fallback: parse from folder name (DRAFT-001-something-or-other). const dir = path.basename(path.dirname(focusedAbsPath)); const match = /^([A-Z]+-\d+)-(.+)$/.exec(dir); if (match) return { ticketId: match[1]!, slug: match[2]! }; return { ticketId: "UNKNOWN", slug: dir }; } function slugFromFolder(focusedAbsPath: string, ticketId: string): string { const dir = path.basename(path.dirname(focusedAbsPath)); // Strip the ticket prefix when present so the slug isn't redundant. const prefix = `${ticketId}-`; if (dir.startsWith(prefix)) return dir.slice(prefix.length); return dir; } /** * Append a `### — cockpit` entry under `## Log`. If the journal * doesn't have a `## Log` heading, create one at the end of the file * before appending. Entries are appended (oldest above, newest below), * matching the template's `` hint. */ function appendLogEntry(original: string, date: string, body: string): string { const entry = `### ${date} — cockpit\n\n- ${body}\n`; const logIdx = original.search(/^##\s+Log\s*$/m); if (logIdx === -1) { const sep = original.endsWith("\n") ? "" : "\n"; return `${original}${sep}\n## Log\n\n${entry}`; } // Insert at the end of the Log section: find the next `## ` heading after // the Log header, or EOF. const afterLogHeader = logIdx + original.slice(logIdx).indexOf("\n") + 1; const rest = original.slice(afterLogHeader); const nextH2 = rest.search(/^##\s+/m); const insertPoint = nextH2 === -1 ? original.length : afterLogHeader + nextH2; const before = original.slice(0, insertPoint); const after = original.slice(insertPoint); // Make sure there's a blank line before our entry and one after. const padBefore = before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n"; const padAfter = after.startsWith("\n") ? "" : "\n"; return before + padBefore + entry + padAfter + after; }