185 lines
6.0 KiB
Python
185 lines
6.0 KiB
Python
#!/usr/bin/env -S uv run --script
|
|
# /// script
|
|
# dependencies = ["pyyaml"]
|
|
# ///
|
|
|
|
"""
|
|
project.py — backend for /project skill.
|
|
Deterministic CRUD for project markdown files with YAML frontmatter.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent
|
|
PROJECTS_DIR = WORKSPACE / "projects"
|
|
|
|
# Valid statuses and priorities
|
|
STATUSES = {"active", "paused", "done"}
|
|
PRIORITIES = {"high", "medium", "low"}
|
|
|
|
|
|
def _slugify(name: str) -> str:
|
|
"""Kebab-case slug from first few words of name. Max 4 words."""
|
|
words = re.sub(r"[^a-zA-Z0-9\s]", "", name).lower().split()
|
|
words = words[:4]
|
|
return "-".join(words) if words else "project"
|
|
|
|
|
|
def _list_projects() -> list[dict]:
|
|
"""Parse frontmatter from all .md files in projects/."""
|
|
if not PROJECTS_DIR.exists():
|
|
return []
|
|
projects = []
|
|
for path in sorted(PROJECTS_DIR.glob("*.md")):
|
|
text = path.read_text(encoding="utf-8")
|
|
frontmatter, _ = _split_frontmatter(text)
|
|
if frontmatter:
|
|
meta = yaml.safe_load(frontmatter) or {}
|
|
meta["_file"] = path.name
|
|
projects.append(meta)
|
|
return projects
|
|
|
|
|
|
def _split_frontmatter(text: str) -> tuple[str | None, str]:
|
|
"""Split YAML frontmatter from body. Returns (frontmatter_yaml, body)."""
|
|
if not text.startswith("---\n"):
|
|
return None, text
|
|
end = text.find("\n---\n", 4)
|
|
if end == -1:
|
|
return None, text
|
|
return text[4:end], text[end + 5 :]
|
|
|
|
|
|
def _load_file(slug: str) -> tuple[Path, str, str | None, str]:
|
|
"""Load project file. Returns (path, full_text, frontmatter_yaml, body)."""
|
|
path = PROJECTS_DIR / f"{slug}.md"
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"Project '{slug}' not found ({path.name})")
|
|
text = path.read_text(encoding="utf-8")
|
|
fm, body = _split_frontmatter(text)
|
|
return path, text, fm, body
|
|
|
|
|
|
def _write_file(path: Path, frontmatter: dict, body: str) -> None:
|
|
"""Write project file with YAML frontmatter."""
|
|
fm_yaml = yaml.safe_dump(frontmatter, allow_unicode=True, sort_keys=False, default_flow_style=False)
|
|
path.write_text(f"---\n{fm_yaml}---\n{body}", encoding="utf-8")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Commands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def cmd_add(args: argparse.Namespace) -> int:
|
|
name = (args.name or "").strip()
|
|
if not name:
|
|
print(json.dumps({"error": "name must not be empty"}), file=sys.stderr)
|
|
return 1
|
|
|
|
slug = _slugify(name)
|
|
PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
path = PROJECTS_DIR / f"{slug}.md"
|
|
if path.exists():
|
|
print(json.dumps({"error": f"project '{slug}' already exists"}), file=sys.stderr)
|
|
return 1
|
|
|
|
priority = (args.priority or "medium").lower()
|
|
if priority not in PRIORITIES:
|
|
print(json.dumps({"error": f"invalid priority '{priority}' — use high/medium/low"}), file=sys.stderr)
|
|
return 1
|
|
|
|
frontmatter = {
|
|
"status": "active",
|
|
"priority": priority,
|
|
"created": date.today().isoformat(),
|
|
"slug": slug,
|
|
}
|
|
body = f"# {name}\n\n## Poznámky\n\n## Další krok\n\n"
|
|
_write_file(path, frontmatter, body)
|
|
print(json.dumps({"added": {"slug": slug, "name": name, "path": str(path.relative_to(WORKSPACE))}}, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
def cmd_list(_args: argparse.Namespace) -> int:
|
|
projects = _list_projects()
|
|
active = [p for p in projects if p.get("status") == "active"]
|
|
# Sort by priority: high > medium > low
|
|
priority_order = {"high": 0, "medium": 1, "low": 2}
|
|
active.sort(key=lambda p: priority_order.get(p.get("priority", "medium"), 1))
|
|
print(json.dumps({"projects": active}, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
def cmd_show(args: argparse.Namespace) -> int:
|
|
slug = (args.slug or "").strip()
|
|
try:
|
|
_path, text, _fm, _body = _load_file(slug)
|
|
except FileNotFoundError as exc:
|
|
print(json.dumps({"error": str(exc)}), file=sys.stderr)
|
|
return 1
|
|
print(text)
|
|
return 0
|
|
|
|
|
|
def cmd_status(args: argparse.Namespace) -> int:
|
|
slug = (args.slug or "").strip()
|
|
new_status = (args.status or "").strip().lower()
|
|
if new_status not in STATUSES:
|
|
print(json.dumps({"error": f"invalid status '{new_status}' — use active/paused/done"}), file=sys.stderr)
|
|
return 1
|
|
|
|
try:
|
|
path, text, fm_yaml, body = _load_file(slug)
|
|
except FileNotFoundError as exc:
|
|
print(json.dumps({"error": str(exc)}), file=sys.stderr)
|
|
return 1
|
|
|
|
if not fm_yaml:
|
|
print(json.dumps({"error": "no frontmatter found"}), file=sys.stderr)
|
|
return 1
|
|
|
|
frontmatter = yaml.safe_load(fm_yaml) or {}
|
|
frontmatter["status"] = new_status
|
|
_write_file(path, frontmatter, body)
|
|
print(json.dumps({"updated": {"slug": slug, "status": new_status}}, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Project file backend")
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
p_add = sub.add_parser("add", help="Create a new project")
|
|
p_add.add_argument("name", help="Project name")
|
|
p_add.add_argument("--priority", default="medium", help="Priority: high/medium/low")
|
|
|
|
sub.add_parser("list", help="List active projects")
|
|
|
|
p_show = sub.add_parser("show", help="Show full project file")
|
|
p_show.add_argument("slug", help="Project slug")
|
|
|
|
p_status = sub.add_parser("status", help="Change project status")
|
|
p_status.add_argument("slug", help="Project slug")
|
|
p_status.add_argument("status", help="New status: active/paused/done")
|
|
|
|
args = parser.parse_args()
|
|
dispatch = {"add": cmd_add, "list": cmd_list, "show": cmd_show, "status": cmd_status}
|
|
return dispatch[args.command](args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|