378 lines
11 KiB
TypeScript
378 lines
11 KiB
TypeScript
// @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 [];
|
|
}
|
|
}
|