Initial commit
This commit is contained in:
96
app/themes.py
Normal file
96
app/themes.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""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()]
|
||||
Reference in New Issue
Block a user