200 lines
7.5 KiB
Python
Executable File
200 lines
7.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""odysseus-theme — shell wrapper for the theme system.
|
|
|
|
Built-in preset definitions live in `static/js/theme.js` (the `THEMES`
|
|
export). Each user's chosen preset + custom-color overrides persist
|
|
in `data/user_prefs.json` under that user's key, so we can read/write
|
|
themes from the shell without needing the browser.
|
|
|
|
odysseus-theme list # built-in preset names
|
|
odysseus-theme users # users with a saved theme
|
|
odysseus-theme get --user alice@example.com # one user's theme blob
|
|
odysseus-theme set --user alice@example.com --preset chatgpt
|
|
odysseus-theme export --user alice@example.com --pretty > theme.json
|
|
|
|
`set --preset NAME` clears any custom-color overrides — pass
|
|
`--keep-colors` to preserve them (useful when switching presets but
|
|
keeping your manual tweaks).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
import sys
|
|
import os, sys
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "_lib"))
|
|
from cli import quiet_logs, emit, fail, common_parser, run, REPO_ROOT as _REPO_ROOT
|
|
quiet_logs()
|
|
|
|
import argparse, json, logging, os, shutil, subprocess, sys, tempfile
|
|
from pathlib import Path
|
|
|
|
_USER_PREFS_PATH = _REPO_ROOT / "data" / "user_prefs.json"
|
|
_THEME_JS = _REPO_ROOT / "static" / "js" / "theme.js"
|
|
|
|
|
|
def _load_prefs() -> dict:
|
|
if not _USER_PREFS_PATH.exists():
|
|
return {"_users": {}}
|
|
try:
|
|
data = json.loads(_USER_PREFS_PATH.read_text())
|
|
except json.JSONDecodeError as e:
|
|
fail(f"user_prefs.json is corrupt: {e}")
|
|
if not isinstance(data, dict):
|
|
fail("user_prefs.json is corrupt: expected an object")
|
|
users = data.setdefault("_users", {})
|
|
if not isinstance(users, dict):
|
|
fail("user_prefs.json is corrupt: _users must be an object")
|
|
return data
|
|
|
|
|
|
def _save_prefs(data: dict) -> None:
|
|
_USER_PREFS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
# Backup so a bad write doesn't nuke everyone's theme.
|
|
if _USER_PREFS_PATH.exists():
|
|
try:
|
|
(_USER_PREFS_PATH.with_suffix(_USER_PREFS_PATH.suffix + ".bak")).write_bytes(
|
|
_USER_PREFS_PATH.read_bytes()
|
|
)
|
|
except Exception:
|
|
pass
|
|
tmp = _USER_PREFS_PATH.with_suffix(_USER_PREFS_PATH.suffix + ".tmp")
|
|
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
|
tmp.replace(_USER_PREFS_PATH)
|
|
|
|
|
|
def _builtin_preset_names() -> list[str]:
|
|
"""Read the THEMES export from theme.js. Uses node so we don't
|
|
have to write a JS parser. Falls back to a regex scrape if node
|
|
isn't available."""
|
|
if not _THEME_JS.exists():
|
|
return []
|
|
if shutil.which("node"):
|
|
script = (
|
|
f"const m = await import('{_THEME_JS.as_posix()}'); "
|
|
"console.log(JSON.stringify(Object.keys(m.THEMES || {})));"
|
|
)
|
|
try:
|
|
out = subprocess.run(
|
|
["node", "--input-type=module", "-e", script],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
if out.returncode == 0:
|
|
lines = [l for l in out.stdout.splitlines() if l.strip()]
|
|
if lines:
|
|
return json.loads(lines[-1])
|
|
except Exception:
|
|
pass
|
|
# Fallback: regex-scrape from the file. Less precise than node,
|
|
# but works without a JS runtime. We only match keys at depth 1
|
|
# (exactly 2 leading spaces) so nested `advanced: { userBubbleBg: ... }`
|
|
# entries don't leak into the preset list.
|
|
import re
|
|
src = _THEME_JS.read_text()
|
|
m = re.search(r"export\s+const\s+THEMES\s*=\s*\{(.*?)\n\};", src, re.DOTALL)
|
|
if not m:
|
|
return []
|
|
body = m.group(1)
|
|
return re.findall(r"^ ([a-zA-Z_][\w]*)\s*:", body, re.MULTILINE)
|
|
|
|
|
|
# ─── list ────────────────────────────────────────────────────────────
|
|
|
|
def cmd_list(args):
|
|
names = _builtin_preset_names()
|
|
emit({"presets": names, "count": len(names)}, args)
|
|
|
|
|
|
# ─── users ───────────────────────────────────────────────────────────
|
|
|
|
def cmd_users(args):
|
|
users = _load_prefs().get("_users", {})
|
|
out = []
|
|
for username, prefs in users.items():
|
|
theme = (prefs or {}).get("theme") or {}
|
|
out.append({
|
|
"user": username,
|
|
"preset": theme.get("name") or "",
|
|
"has_custom_colors": bool(theme.get("colors")),
|
|
"font": theme.get("font") or "",
|
|
"density": theme.get("density") or "",
|
|
})
|
|
out.sort(key=lambda r: r["user"])
|
|
emit(out, args)
|
|
|
|
|
|
# ─── get ─────────────────────────────────────────────────────────────
|
|
|
|
def cmd_get(args):
|
|
users = _load_prefs().get("_users", {})
|
|
prefs = users.get(args.user)
|
|
if prefs is None:
|
|
fail(f"no prefs for user {args.user!r}")
|
|
theme = (prefs or {}).get("theme") or {}
|
|
if not theme:
|
|
emit({"user": args.user, "theme": None}, args)
|
|
return
|
|
emit({"user": args.user, "theme": theme}, args)
|
|
|
|
|
|
# ─── set ─────────────────────────────────────────────────────────────
|
|
|
|
def cmd_set(args):
|
|
valid = _builtin_preset_names()
|
|
if valid and args.preset not in valid:
|
|
fail(
|
|
f"unknown preset {args.preset!r}. Available: {', '.join(valid) or '(none discovered)'}"
|
|
)
|
|
data = _load_prefs()
|
|
users = data.setdefault("_users", {})
|
|
user_prefs = users.setdefault(args.user, {})
|
|
theme = dict(user_prefs.get("theme") or {})
|
|
theme["name"] = args.preset
|
|
if not args.keep_colors:
|
|
theme.pop("colors", None)
|
|
user_prefs["theme"] = theme
|
|
_save_prefs(data)
|
|
emit({"ok": True, "user": args.user, "preset": args.preset, "kept_colors": args.keep_colors}, args)
|
|
|
|
|
|
# ─── export ──────────────────────────────────────────────────────────
|
|
|
|
def cmd_export(args):
|
|
users = _load_prefs().get("_users", {})
|
|
prefs = users.get(args.user)
|
|
if prefs is None:
|
|
fail(f"no prefs for user {args.user!r}")
|
|
emit(prefs.get("theme") or {}, args)
|
|
|
|
|
|
def _build_parser():
|
|
common = argparse.ArgumentParser(add_help=False)
|
|
common.add_argument("--pretty", action="store_true")
|
|
p = argparse.ArgumentParser(prog="odysseus-theme", parents=[common])
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
|
|
pl = sub.add_parser("list", parents=[common])
|
|
pl.set_defaults(func=cmd_list)
|
|
|
|
pu = sub.add_parser("users", parents=[common])
|
|
pu.set_defaults(func=cmd_users)
|
|
|
|
pg = sub.add_parser("get", parents=[common])
|
|
pg.add_argument("--user", required=True)
|
|
pg.set_defaults(func=cmd_get)
|
|
|
|
ps = sub.add_parser("set", parents=[common])
|
|
ps.add_argument("--user", required=True)
|
|
ps.add_argument("--preset", required=True)
|
|
ps.add_argument("--keep-colors", action="store_true",
|
|
help="don't clear custom color overrides")
|
|
ps.set_defaults(func=cmd_set)
|
|
|
|
pe = sub.add_parser("export", parents=[common])
|
|
pe.add_argument("--user", required=True)
|
|
pe.set_defaults(func=cmd_export)
|
|
|
|
return p
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(run(_build_parser()))
|