"""Theme storage: flat JSON files under themes/.""" from __future__ import annotations import json import re from dataclasses import dataclass from pathlib import Path THEMES_DIR = Path(__file__).resolve().parent.parent / "themes" _SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") @dataclass class Theme: slug: str name: str words: list[str] class ThemeError(ValueError): """Raised for invalid theme input.""" def slugify(name: str) -> str: """Lowercase, replace non-alphanumeric runs with single hyphen, trim.""" s = name.lower().strip() s = re.sub(r"[^a-z0-9]+", "-", s) s = s.strip("-") return s def is_valid_slug(slug: str) -> bool: return bool(_SLUG_RE.match(slug)) def _path(slug: str) -> Path: if not is_valid_slug(slug): raise ThemeError(f"invalid slug: {slug!r}") return THEMES_DIR / f"{slug}.json" def list_themes() -> list[Theme]: THEMES_DIR.mkdir(parents=True, exist_ok=True) out: list[Theme] = [] for path in sorted(THEMES_DIR.glob("*.json")): slug = path.stem if not is_valid_slug(slug): continue try: data = json.loads(path.read_text(encoding="utf-8")) out.append(Theme(slug=slug, name=data.get("name", slug), words=list(data.get("words", [])))) except (json.JSONDecodeError, OSError): continue return out def load_theme(slug: str) -> Theme: path = _path(slug) if not path.exists(): raise ThemeError(f"theme not found: {slug!r}") data = json.loads(path.read_text(encoding="utf-8")) return Theme(slug=slug, name=data.get("name", slug), words=list(data.get("words", []))) def save_theme(slug: str, name: str, words: list[str]) -> Theme: if not is_valid_slug(slug): raise ThemeError( "slug must be lowercase letters, digits, and hyphens only" ) name = name.strip() if not name: raise ThemeError("theme name is required") cleaned = [w.strip() for w in words if w.strip()] if not cleaned: raise ThemeError("theme must contain at least one word") THEMES_DIR.mkdir(parents=True, exist_ok=True) path = THEMES_DIR / f"{slug}.json" payload = {"name": name, "words": cleaned} path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") return Theme(slug=slug, name=name, words=cleaned) def delete_theme(slug: str) -> None: path = _path(slug) if path.exists(): path.unlink() def parse_words_textarea(blob: str) -> list[str]: """One word per line; ignore blank lines and surrounding whitespace.""" return [line.strip() for line in blob.splitlines() if line.strip()]