// @generated — DO NOT EDIT. Source: packages/shared/storage.ts /** * Plan Storage Utility * * Saves plans and annotations to ~/.plannotator/plans/ * Cross-platform: works on Windows, macOS, and Linux. * * Runtime-agnostic: uses only node:fs, node:path, node:os. */ import { homedir } from "os"; import { join, resolve, sep } from "path"; import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync, existsSync } from "fs"; import { sanitizeTag } from "./project"; import { resolveUserPath } from "./resolve-file"; /** * Get the plan storage directory, creating it if needed. * Cross-platform: uses os.homedir() for Windows/macOS/Linux compatibility. * @param customPath Optional custom path. Supports ~ for home directory. */ export function getPlanDir(customPath?: string | null): string { let planDir: string; if (customPath?.trim()) { planDir = resolveUserPath(customPath); } else { planDir = join(homedir(), ".plannotator", "plans"); } mkdirSync(planDir, { recursive: true }); return planDir; } /** * Extract the first heading from markdown content. */ function extractFirstHeading(markdown: string): string | null { const match = markdown.match(/^#\s+(.+)$/m); if (!match) return null; return match[1].trim(); } /** * Generate a slug from plan content. * Format: {sanitized-heading}-YYYY-MM-DD */ export function generateSlug(plan: string): string { const date = new Date().toISOString().split("T")[0]; // YYYY-MM-DD const heading = extractFirstHeading(plan); const slug = heading ? sanitizeTag(heading) : null; return slug ? `${slug}-${date}` : `plan-${date}`; } /** * Save the plan markdown to disk. * Returns the full path to the saved file. */ export function savePlan(slug: string, content: string, customPath?: string | null): string { const planDir = getPlanDir(customPath); const filePath = join(planDir, `${slug}.md`); writeFileSync(filePath, content, "utf-8"); return filePath; } /** * Save annotations to disk. * Returns the full path to the saved file. */ export function saveAnnotations(slug: string, annotationsContent: string, customPath?: string | null): string { const planDir = getPlanDir(customPath); const filePath = join(planDir, `${slug}.annotations.md`); writeFileSync(filePath, annotationsContent, "utf-8"); return filePath; } /** * Save the final snapshot on approve/deny. * Combines plan and annotations into a single file with status suffix. * Returns the full path to the saved file. */ export function saveFinalSnapshot( slug: string, status: "approved" | "denied", plan: string, annotations: string, customPath?: string | null ): string { const planDir = getPlanDir(customPath); const filePath = join(planDir, `${slug}-${status}.md`); // Combine plan with annotations appended let content = plan; if (annotations && annotations !== "No changes detected.") { content += "\n\n---\n\n" + annotations; } writeFileSync(filePath, content, "utf-8"); return filePath; } // --- Plan Archive --- export interface ArchivedPlan { filename: string; title: string; date: string; timestamp: string; // ISO string from file mtime status: "approved" | "denied" | "unknown"; size: number; } /** * Parse an archive filename into metadata. * Handles both old (DATE-heading-status.md) and new (heading-DATE-status.md) formats. */ export function parseArchiveFilename(filename: string): ArchivedPlan | null { // Skip non-decision files if (filename.endsWith(".annotations.md") || filename.endsWith(".diff.md")) return null; const base = filename.replace(/\.md$/, ""); // Extract status suffix let status: ArchivedPlan["status"] = "unknown"; let slug = base; if (base.endsWith("-approved")) { status = "approved"; slug = base.slice(0, -"-approved".length); } else if (base.endsWith("-denied")) { status = "denied"; slug = base.slice(0, -"-denied".length); } else { // Skip plain files (no decision status) return null; } // Extract date (YYYY-MM-DD) — could be anywhere in the slug const dateMatch = slug.match(/(\d{4}-\d{2}-\d{2})/); const date = dateMatch ? dateMatch[1] : ""; // Title: remove date, convert hyphens to spaces, trim const title = slug .replace(/\d{4}-\d{2}-\d{2}/, "") .replace(/^-+|-+$/g, "") .replace(/-+/g, " ") .trim() || "Untitled Plan"; return { filename, title, date, timestamp: "", status, size: 0 }; } /** * List all archived plans (approved/denied decision snapshots). * Returns plans sorted by date descending. */ export function listArchivedPlans(customPath?: string | null): ArchivedPlan[] { const planDir = getPlanDir(customPath); try { const entries = readdirSync(planDir); const plans: ArchivedPlan[] = []; for (const entry of entries) { if (!entry.endsWith(".md")) continue; const parsed = parseArchiveFilename(entry); if (!parsed) continue; try { const stat = statSync(join(planDir, entry)); parsed.size = stat.size; parsed.timestamp = stat.mtime.toISOString(); } catch { /* keep defaults */ } plans.push(parsed); } return plans.sort((a, b) => b.date.localeCompare(a.date) || b.timestamp.localeCompare(a.timestamp)); } catch { return []; } } /** * Read an archived plan file by filename. * Returns null if the file doesn't exist or on read error. */ export function readArchivedPlan(filename: string, customPath?: string | null): string | null { const planDir = getPlanDir(customPath); const filePath = resolve(planDir, filename); // Guard against path traversal (resolve + trailing separator, matching reference-handlers.ts) if (!filePath.startsWith(planDir + sep)) return null; try { return readFileSync(filePath, "utf-8"); } catch { return null; } } // --- Version History --- /** * Get the history directory for a project/slug combination, creating it if needed. * History is always stored in ~/.plannotator/history/{project}/{slug}/. * Not affected by the customPath setting (that only affects decision saves). */ export function getHistoryDir(project: string, slug: string): string { const historyDir = join(homedir(), ".plannotator", "history", project, slug); mkdirSync(historyDir, { recursive: true }); return historyDir; } /** * Determine the next version number by scanning existing files. * Returns 1 if no versions exist, otherwise max + 1. */ function getNextVersionNumber(historyDir: string): number { try { const entries = readdirSync(historyDir); let max = 0; for (const entry of entries) { const match = entry.match(/^(\d+)\.md$/); if (match) { const num = parseInt(match[1], 10); if (num > max) max = num; } } return max + 1; } catch { return 1; } } /** * Save a plan version to the history directory. * Deduplication: if the latest version has identical content, skip saving. * Returns the version number, file path, and whether a new file was created. */ export function saveToHistory( project: string, slug: string, plan: string ): { version: number; path: string; isNew: boolean } { const historyDir = getHistoryDir(project, slug); const nextVersion = getNextVersionNumber(historyDir); // Deduplicate: check if latest version has identical content if (nextVersion > 1) { const latestPath = join(historyDir, `${String(nextVersion - 1).padStart(3, "0")}.md`); try { const existing = readFileSync(latestPath, "utf-8"); if (existing === plan) { return { version: nextVersion - 1, path: latestPath, isNew: false }; } } catch { // File read failed, proceed with saving } } const fileName = `${String(nextVersion).padStart(3, "0")}.md`; const filePath = join(historyDir, fileName); writeFileSync(filePath, plan, "utf-8"); return { version: nextVersion, path: filePath, isNew: true }; } /** * Read a specific version's content from history. * Returns null if the version doesn't exist or on read error. */ export function getPlanVersion( project: string, slug: string, version: number ): string | null { const historyDir = join(homedir(), ".plannotator", "history", project, slug); const fileName = `${String(version).padStart(3, "0")}.md`; const filePath = join(historyDir, fileName); try { return readFileSync(filePath, "utf-8"); } catch { return null; } } /** * Get the file path for a specific version in history. * Returns null if the version file doesn't exist. */ export function getPlanVersionPath( project: string, slug: string, version: number ): string | null { const historyDir = join(homedir(), ".plannotator", "history", project, slug); const fileName = `${String(version).padStart(3, "0")}.md`; const filePath = join(historyDir, fileName); return existsSync(filePath) ? filePath : null; } /** * Get the number of versions stored for a project/slug. * Returns 0 if the directory doesn't exist. */ export function getVersionCount(project: string, slug: string): number { const historyDir = join(homedir(), ".plannotator", "history", project, slug); try { const entries = readdirSync(historyDir); return entries.filter((e) => /^\d+\.md$/.test(e)).length; } catch { return 0; } } /** * List all versions for a project/slug with metadata. * Returns versions sorted ascending by version number. */ export function listVersions( project: string, slug: string ): Array<{ version: number; timestamp: string }> { const historyDir = join(homedir(), ".plannotator", "history", project, slug); try { const entries = readdirSync(historyDir); const versions: Array<{ version: number; timestamp: string }> = []; for (const entry of entries) { const match = entry.match(/^(\d+)\.md$/); if (match) { const version = parseInt(match[1], 10); const filePath = join(historyDir, entry); try { const stat = statSync(filePath); versions.push({ version, timestamp: stat.mtime.toISOString() }); } catch { versions.push({ version, timestamp: "" }); } } } return versions.sort((a, b) => a.version - b.version); } catch { return []; } } /** * List all plan slugs stored for a project. * Returns slugs sorted by most recently modified first. */ export function listProjectPlans( project: string ): Array<{ slug: string; versions: number; lastModified: string }> { const projectDir = join(homedir(), ".plannotator", "history", project); try { const entries = readdirSync(projectDir, { withFileTypes: true }); const plans: Array<{ slug: string; versions: number; lastModified: string }> = []; for (const entry of entries) { if (!entry.isDirectory()) continue; const slugDir = join(projectDir, entry.name); const files = readdirSync(slugDir).filter((f) => /^\d+\.md$/.test(f)); if (files.length === 0) continue; // Find most recent file modification time let latest = 0; for (const file of files) { try { const mtime = statSync(join(slugDir, file)).mtime.getTime(); if (mtime > latest) latest = mtime; } catch { /* skip */ } } plans.push({ slug: entry.name, versions: files.length, lastModified: latest ? new Date(latest).toISOString() : "", }); } return plans.sort((a, b) => b.lastModified.localeCompare(a.lastModified)); } catch { return []; } }