From 06a3f2bf9706ef0e0142bf87c60d6484ab6f34eb Mon Sep 17 00:00:00 2001 From: Research Assistant Date: Thu, 14 May 2026 17:47:43 +0800 Subject: [PATCH] feat: add project-log CLI command with JSONL write + auto-render --- paperforge/cli.py | 14 +++ paperforge/commands/project_log.py | 183 +++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 paperforge/commands/project_log.py diff --git a/paperforge/cli.py b/paperforge/cli.py index 44d74d31..e289d445 100644 --- a/paperforge/cli.py +++ b/paperforge/cli.py @@ -310,6 +310,15 @@ def build_parser() -> argparse.ArgumentParser: p_rl.add_argument("--lookup", help="Look up all reading notes for a paper key") p_rl.add_argument("--json", action="store_true", help="Output as JSON") + p_pl = sub.add_parser("project-log", help="Record or render project work logs") + p_pl.add_argument("--write", action="store_true", help="Write a new project log entry") + p_pl.add_argument("--payload", help="JSON payload for the entry") + p_pl.add_argument("--project", help="Project name (required for write/list/render)") + p_pl.add_argument("--list", action="store_true", help="List all entries for a project") + p_pl.add_argument("--render", action="store_true", help="Render project-log.md") + p_pl.add_argument("--limit", type=int, default=50, help="Max entries to list") + p_pl.add_argument("--json", action="store_true", help="Output as PFResult JSON") + p_search = sub.add_parser("search", help="Full-text search across the library") p_search.add_argument("query", help="Search query (supports FTS5 syntax)") p_search.add_argument("--json", action="store_true", help="Output as JSON") @@ -568,6 +577,11 @@ def main(argv: list[str] | None = None) -> int: return run(args) + if args.command == "project-log": + from paperforge.commands.project_log import run + + return run(args) + if args.command == "search": from paperforge.commands.search import run diff --git a/paperforge/commands/project_log.py b/paperforge/commands/project_log.py new file mode 100644 index 00000000..5f26d0ae --- /dev/null +++ b/paperforge/commands/project_log.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import argparse +import json +import sys +from collections import Counter +from pathlib import Path + +from paperforge import __version__ as PF_VERSION +from paperforge.config import paperforge_paths +from paperforge.core.errors import ErrorCode +from paperforge.core.result import PFError, PFResult +from paperforge.memory.permanent import ( + append_project_entry, + get_project_entries, + read_all_project_entries, +) + + +def _render_project_log_md(vault: Path, project: str) -> None: + """Render project-log.md from JSONL.""" + entries = get_project_entries(vault, project) + if not entries: + return + + lines = [f"# Project Log — {project}", ""] + lines.append("> Auto-generated from project-log.jsonl. Do not edit manually.") + lines.append("") + + for entry in sorted(entries, key=lambda x: x.get("created_at", ""), reverse=True): + lines.append(f"## {entry.get('date', '')} — {entry.get('title', '(untitled)')}") + lines.append(f"**Type:** {entry.get('type', '')}") + lines.append("") + + if entry.get("decisions"): + lines.append("### Core Decisions") + for d in entry["decisions"]: + lines.append(f"- {d}") + lines.append("") + + if entry.get("detours"): + lines.append("### Detours & Corrections") + for dt in entry["detours"]: + if isinstance(dt, dict): + lines.append(f"- **Wrong:** {dt.get('wrong', '')}") + lines.append(f" **Correction:** {dt.get('correction', '')}") + lines.append(f" **Resolution:** {dt.get('resolution', '')}") + else: + lines.append(f"- {dt}") + lines.append("") + + if entry.get("reusable"): + lines.append("### Reusable Methods") + for r in entry["reusable"]: + lines.append(f"- {r}") + lines.append("") + + if entry.get("todos"): + lines.append("### Todos") + for t in entry["todos"]: + done = "x" if t.get("done", False) else " " + lines.append(f"- [{done}] {t.get('content', '')}") + lines.append("") + + if entry.get("tags"): + lines.append(f"**Tags:** {', '.join(entry['tags'])}") + + lines.append("---") + lines.append("") + + paths = paperforge_paths(vault) + resource_dir = paths.get("resources") + if resource_dir: + output_dir = resource_dir / "Projects" / project + else: + output_dir = vault / "Projects" / project + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / "project-log.md" + output_path.write_text("\n".join(lines), encoding="utf-8") + + +def run(args: argparse.Namespace) -> int: + vault = args.vault_path + + if getattr(args, "write", False): + project = getattr(args, "project", "") + payload_str = getattr(args, "payload", "") + + if not project: + result = PFResult(ok=False, command="project-log", version=PF_VERSION, + error=PFError(code=ErrorCode.VALIDATION_ERROR, message="--project is required for --write")) + if getattr(args, "json", False): + print(result.to_json()) + else: + print(f"Error: {result.error.message}", file=sys.stderr) + return 1 + + if not payload_str: + result = PFResult(ok=False, command="project-log", version=PF_VERSION, + error=PFError(code=ErrorCode.VALIDATION_ERROR, message="--payload is required for --write")) + if getattr(args, "json", False): + print(result.to_json()) + else: + print(f"Error: {result.error.message}", file=sys.stderr) + return 1 + + try: + entry = json.loads(payload_str) + entry["project"] = project + result_data = append_project_entry(vault, entry) + + _render_project_log_md(vault, project) + + result = PFResult(ok=True, command="project-log", version=PF_VERSION, data=result_data) + except json.JSONDecodeError as e: + result = PFResult(ok=False, command="project-log", version=PF_VERSION, + error=PFError(code=ErrorCode.VALIDATION_ERROR, message=f"Invalid JSON: {e}")) + + if getattr(args, "json", False): + print(result.to_json()) + else: + print("Written." if result.ok else f"Error: {result.error.message}") + return 0 if result.ok else 1 + + if getattr(args, "list", False): + project = getattr(args, "project", "") + if not project: + result = PFResult(ok=False, command="project-log", version=PF_VERSION, + error=PFError(code=ErrorCode.VALIDATION_ERROR, message="--project is required for --list")) + if getattr(args, "json", False): + print(result.to_json()) + else: + print(f"Error: {result.error.message}", file=sys.stderr) + return 1 + + entries = get_project_entries(vault, project) + data = {"project": project, "entries": entries[:getattr(args, "limit", 50)], "count": len(entries)} + result = PFResult(ok=True, command="project-log", version=PF_VERSION, data=data) + + if getattr(args, "json", False): + print(result.to_json()) + else: + print(f"{len(entries)} entries for project '{project}'") + for e in entries[:5]: + print(f" [{e.get('date', '')}] {e.get('type', '')}: {e.get('title', '')}") + return 0 + + if getattr(args, "render", False): + project = getattr(args, "project", "") + if not project: + result = PFResult(ok=False, command="project-log", version=PF_VERSION, + error=PFError(code=ErrorCode.VALIDATION_ERROR, message="--project is required for --render")) + if getattr(args, "json", False): + print(result.to_json()) + else: + print(f"Error: {result.error.message}", file=sys.stderr) + return 1 + + _render_project_log_md(vault, project) + result = PFResult(ok=True, command="project-log", version=PF_VERSION, + data={"rendered": True, "project": project}) + if getattr(args, "json", False): + print(result.to_json()) + else: + print(f"Rendered project-log.md for '{project}'") + return 0 + + # Default: show all projects with entry counts + all_entries = read_all_project_entries(vault) + project_counts = Counter(e["project"] for e in all_entries if e.get("project")) + + result = PFResult(ok=True, command="project-log", version=PF_VERSION, + data={"projects": dict(project_counts)}) + if getattr(args, "json", False): + print(result.to_json()) + else: + if project_counts: + print("Projects with log entries:") + for proj, cnt in project_counts.most_common(): + print(f" {proj}: {cnt} entries") + else: + print("No project log entries found.") + return 0