mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
feat(editorial): Phase D synergy commander enrichment, augmentation, lint & docs\n\nAdds Phase D editorial tooling: synergy-based commander selection with 3/2/1 pattern, duplicate filtering, annotated synergy_commanders, promotion to minimum examples, and augmentation heuristics (e.g. Counters Matter/Proliferate injection). Includes new scripts (generate_theme_editorial_suggestions, lint, validate, catalog build/apply), updates orchestrator & web routes, expands CI workflow, and documents usage & non-determinism policies. Updates lint rules, type definitions, and docker configs.
This commit is contained in:
parent
16261bbf09
commit
f2a76d2ffc
35 changed files with 2818 additions and 509 deletions
|
@ -78,7 +78,7 @@ ENABLE_THEMES = _as_bool(os.getenv("ENABLE_THEMES"), False)
|
|||
ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False)
|
||||
ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
|
||||
ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), False)
|
||||
RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), False)
|
||||
RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), False) # initial snapshot (legacy)
|
||||
RANDOM_UI = _as_bool(os.getenv("RANDOM_UI"), False)
|
||||
def _as_int(val: str | None, default: int) -> int:
|
||||
try:
|
||||
|
@ -200,11 +200,17 @@ async def status_sys():
|
|||
except Exception:
|
||||
return {"version": "unknown", "uptime_seconds": 0, "flags": {}}
|
||||
|
||||
def random_modes_enabled() -> bool:
|
||||
"""Dynamic check so tests that set env after import still work.
|
||||
|
||||
Keeps legacy global for template snapshot while allowing runtime override."""
|
||||
return _as_bool(os.getenv("RANDOM_MODES"), bool(RANDOM_MODES))
|
||||
|
||||
# --- Random Modes API ---
|
||||
@app.post("/api/random_build")
|
||||
async def api_random_build(request: Request):
|
||||
# Gate behind feature flag
|
||||
if not RANDOM_MODES:
|
||||
if not random_modes_enabled():
|
||||
raise HTTPException(status_code=404, detail="Random Modes disabled")
|
||||
try:
|
||||
body = {}
|
||||
|
@ -253,7 +259,7 @@ async def api_random_build(request: Request):
|
|||
@app.post("/api/random_full_build")
|
||||
async def api_random_full_build(request: Request):
|
||||
# Gate behind feature flag
|
||||
if not RANDOM_MODES:
|
||||
if not random_modes_enabled():
|
||||
raise HTTPException(status_code=404, detail="Random Modes disabled")
|
||||
try:
|
||||
body = {}
|
||||
|
@ -324,7 +330,7 @@ async def api_random_full_build(request: Request):
|
|||
@app.post("/api/random_reroll")
|
||||
async def api_random_reroll(request: Request):
|
||||
# Gate behind feature flag
|
||||
if not RANDOM_MODES:
|
||||
if not random_modes_enabled():
|
||||
raise HTTPException(status_code=404, detail="Random Modes disabled")
|
||||
try:
|
||||
body = {}
|
||||
|
@ -532,11 +538,13 @@ from .routes import configs as config_routes # noqa: E402
|
|||
from .routes import decks as decks_routes # noqa: E402
|
||||
from .routes import setup as setup_routes # noqa: E402
|
||||
from .routes import owned as owned_routes # noqa: E402
|
||||
from .routes import themes as themes_routes # noqa: E402
|
||||
app.include_router(build_routes.router)
|
||||
app.include_router(config_routes.router)
|
||||
app.include_router(decks_routes.router)
|
||||
app.include_router(setup_routes.router)
|
||||
app.include_router(owned_routes.router)
|
||||
app.include_router(themes_routes.router)
|
||||
|
||||
# Warm validation cache early to reduce first-call latency in tests and dev
|
||||
try:
|
||||
|
|
|
@ -1355,7 +1355,7 @@ async def build_combos_panel(request: Request) -> HTMLResponse:
|
|||
weights = {
|
||||
"treasure": 3.0, "tokens": 2.8, "landfall": 2.6, "card draw": 2.5, "ramp": 2.3,
|
||||
"engine": 2.2, "value": 2.1, "artifacts": 2.0, "enchantress": 2.0, "spellslinger": 1.9,
|
||||
"counters": 1.8, "equipment": 1.7, "tribal": 1.6, "lifegain": 1.5, "mill": 1.4,
|
||||
"counters": 1.8, "equipment matters": 1.7, "tribal": 1.6, "lifegain": 1.5, "mill": 1.4,
|
||||
"damage": 1.3, "stax": 1.2
|
||||
}
|
||||
syn_sugs: list[dict] = []
|
||||
|
|
|
@ -14,11 +14,19 @@ router = APIRouter(prefix="/setup")
|
|||
|
||||
|
||||
def _kickoff_setup_async(force: bool = False):
|
||||
"""Start setup/tagging in a background thread.
|
||||
|
||||
Previously we passed a no-op output function, which hid downstream steps (e.g., theme export).
|
||||
Using print provides visibility in container logs and helps diagnose export issues.
|
||||
"""
|
||||
def runner():
|
||||
try:
|
||||
_ensure_setup_ready(lambda _m: None, force=force) # type: ignore[arg-type]
|
||||
except Exception:
|
||||
pass
|
||||
_ensure_setup_ready(print, force=force) # type: ignore[arg-type]
|
||||
except Exception as e: # pragma: no cover - background best effort
|
||||
try:
|
||||
print(f"Setup thread failed: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
t = threading.Thread(target=runner, daemon=True)
|
||||
t.start()
|
||||
|
||||
|
|
126
code/web/routes/themes.py
Normal file
126
code/web/routes/themes.py
Normal file
|
@ -0,0 +1,126 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime as _dt
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import BackgroundTasks
|
||||
from ..services.orchestrator import _ensure_setup_ready # type: ignore
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(prefix="/themes", tags=["themes"]) # /themes/status
|
||||
|
||||
THEME_LIST_PATH = Path("config/themes/theme_list.json")
|
||||
CATALOG_DIR = Path("config/themes/catalog")
|
||||
STATUS_PATH = Path("csv_files/.setup_status.json")
|
||||
TAG_FLAG_PATH = Path("csv_files/.tagging_complete.json")
|
||||
|
||||
|
||||
def _iso(ts: float | int | None) -> Optional[str]:
|
||||
if ts is None or ts <= 0:
|
||||
return None
|
||||
try:
|
||||
return _dt.fromtimestamp(ts).isoformat(timespec="seconds")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _load_status() -> Dict[str, Any]:
|
||||
try:
|
||||
if STATUS_PATH.exists():
|
||||
return json.loads(STATUS_PATH.read_text(encoding="utf-8") or "{}") or {}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _load_tag_flag_time() -> Optional[float]:
|
||||
try:
|
||||
if TAG_FLAG_PATH.exists():
|
||||
data = json.loads(TAG_FLAG_PATH.read_text(encoding="utf-8") or "{}") or {}
|
||||
t = data.get("tagged_at")
|
||||
if isinstance(t, str) and t.strip():
|
||||
try:
|
||||
return _dt.fromisoformat(t.strip()).timestamp()
|
||||
except Exception:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def theme_status():
|
||||
"""Return current theme export status for the UI.
|
||||
|
||||
Provides counts, mtimes, and freshness vs. tagging flag.
|
||||
"""
|
||||
try:
|
||||
status = _load_status()
|
||||
theme_list_exists = THEME_LIST_PATH.exists()
|
||||
theme_list_mtime_s = THEME_LIST_PATH.stat().st_mtime if theme_list_exists else None
|
||||
theme_count: Optional[int] = None
|
||||
parse_error: Optional[str] = None
|
||||
if theme_list_exists:
|
||||
try:
|
||||
raw = json.loads(THEME_LIST_PATH.read_text(encoding="utf-8") or "{}") or {}
|
||||
if isinstance(raw, dict):
|
||||
themes = raw.get("themes")
|
||||
if isinstance(themes, list):
|
||||
theme_count = len(themes)
|
||||
except Exception as e: # pragma: no cover
|
||||
parse_error = f"parse_error: {e}" # keep short
|
||||
yaml_catalog_exists = CATALOG_DIR.exists() and CATALOG_DIR.is_dir()
|
||||
yaml_file_count = 0
|
||||
if yaml_catalog_exists:
|
||||
try:
|
||||
yaml_file_count = len([p for p in CATALOG_DIR.iterdir() if p.suffix == ".yml"]) # type: ignore[arg-type]
|
||||
except Exception:
|
||||
yaml_file_count = -1
|
||||
tagged_time = _load_tag_flag_time()
|
||||
stale = False
|
||||
if tagged_time and theme_list_mtime_s:
|
||||
# Stale if tagging flag is newer by > 1 second
|
||||
stale = tagged_time > (theme_list_mtime_s + 1)
|
||||
# Also stale if we expect a catalog (after any tagging) but have suspiciously few YAMLs (< 100)
|
||||
if yaml_catalog_exists and yaml_file_count >= 0 and yaml_file_count < 100:
|
||||
stale = True
|
||||
last_export_at = status.get("themes_last_export_at") or _iso(theme_list_mtime_s) or None
|
||||
resp = {
|
||||
"ok": True,
|
||||
"theme_list_exists": theme_list_exists,
|
||||
"theme_list_mtime": _iso(theme_list_mtime_s),
|
||||
"theme_count": theme_count,
|
||||
"yaml_catalog_exists": yaml_catalog_exists,
|
||||
"yaml_file_count": yaml_file_count,
|
||||
"stale": stale,
|
||||
"last_export_at": last_export_at,
|
||||
"last_export_fast_path": status.get("themes_last_export_fast_path"),
|
||||
"phase": status.get("phase"),
|
||||
"running": status.get("running"),
|
||||
}
|
||||
if parse_error:
|
||||
resp["parse_error"] = parse_error
|
||||
return JSONResponse(resp)
|
||||
except Exception as e: # pragma: no cover
|
||||
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def theme_refresh(background: BackgroundTasks):
|
||||
"""Force a theme export refresh without re-tagging if not needed.
|
||||
|
||||
Runs setup readiness with force=False (fast-path export fallback will run). Returns immediately.
|
||||
"""
|
||||
try:
|
||||
def _runner():
|
||||
try:
|
||||
_ensure_setup_ready(lambda _m: None, force=False) # export fallback triggers
|
||||
except Exception:
|
||||
pass
|
||||
background.add_task(_runner)
|
||||
return JSONResponse({"ok": True, "started": True})
|
||||
except Exception as e: # pragma: no cover
|
||||
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|
|
@ -732,6 +732,8 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
|
|||
Mirrors the CLI behavior used in build_deck_full: if csv_files/cards.csv is
|
||||
missing, too old, or the tagging flag is absent, run initial setup and tagging.
|
||||
"""
|
||||
# Track whether a theme catalog export actually executed during this invocation
|
||||
theme_export_performed = False
|
||||
def _write_status(payload: dict) -> None:
|
||||
try:
|
||||
os.makedirs('csv_files', exist_ok=True)
|
||||
|
@ -754,6 +756,138 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
def _refresh_theme_catalog(out_func, *, force: bool, fast_path: bool = False) -> None:
|
||||
"""Generate or refresh theme JSON + per-theme YAML exports.
|
||||
|
||||
force: when True pass --force to YAML exporter (used right after tagging).
|
||||
fast_path: when True indicates we are refreshing without a new tagging run.
|
||||
"""
|
||||
try: # Broad defensive guard: never let theme export kill setup flow
|
||||
phase_label = 'themes-fast' if fast_path else 'themes'
|
||||
# Start with an in-progress percent below 100 so UI knows additional work remains
|
||||
_write_status({"running": True, "phase": phase_label, "message": "Generating theme catalog...", "percent": 95})
|
||||
# Mark that we *attempted* an export; even if it fails we won't silently skip fallback repeat
|
||||
nonlocal theme_export_performed
|
||||
theme_export_performed = True
|
||||
from subprocess import run as _run
|
||||
# Resolve absolute script paths to avoid cwd-dependent failures inside container
|
||||
script_base = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'scripts'))
|
||||
extract_script = os.path.join(script_base, 'extract_themes.py')
|
||||
export_script = os.path.join(script_base, 'export_themes_to_yaml.py')
|
||||
build_script = os.path.join(script_base, 'build_theme_catalog.py')
|
||||
catalog_mode = os.environ.get('THEME_CATALOG_MODE', '').strip().lower()
|
||||
# Default to merge mode if build script exists unless explicitly set to 'legacy'
|
||||
use_merge = False
|
||||
if os.path.exists(build_script):
|
||||
if catalog_mode in {'merge', 'build', 'phaseb', ''} and catalog_mode != 'legacy':
|
||||
use_merge = True
|
||||
import sys as _sys
|
||||
def _emit(msg: str):
|
||||
try:
|
||||
if out_func:
|
||||
out_func(msg)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
print(msg)
|
||||
except Exception:
|
||||
pass
|
||||
if use_merge:
|
||||
_emit("Attempting Phase B merged theme catalog build (build_theme_catalog.py)...")
|
||||
try:
|
||||
_run([_sys.executable, build_script], check=True)
|
||||
_emit("Merged theme catalog build complete.")
|
||||
# Ensure per-theme YAML files are also updated so editorial workflows remain intact.
|
||||
if os.path.exists(export_script):
|
||||
# Optional fast-path skip: if enabled via env AND we are on fast_path AND not force.
|
||||
# Default behavior now: ALWAYS force export so YAML stays aligned with merged JSON output.
|
||||
fast_skip = False
|
||||
try:
|
||||
fast_skip = fast_path and not force and os.getenv('THEME_YAML_FAST_SKIP', '0').strip() not in {'', '0', 'false', 'False', 'no', 'NO'}
|
||||
except Exception:
|
||||
fast_skip = False
|
||||
if fast_skip:
|
||||
_emit("Per-theme YAML export skipped (fast path)")
|
||||
else:
|
||||
exp_args = [_sys.executable, export_script, '--force'] # unconditional force now
|
||||
try:
|
||||
_run(exp_args, check=True)
|
||||
if fast_path:
|
||||
_emit("Per-theme YAML export (Phase A) completed post-merge (forced fast path).")
|
||||
else:
|
||||
_emit("Per-theme YAML export (Phase A) completed post-merge (forced).")
|
||||
except Exception as yerr:
|
||||
_emit(f"YAML export after merge failed: {yerr}")
|
||||
except Exception as merge_err:
|
||||
_emit(f"Merge build failed ({merge_err}); falling back to legacy extract/export.")
|
||||
use_merge = False
|
||||
if not use_merge:
|
||||
if not os.path.exists(extract_script):
|
||||
raise FileNotFoundError(f"extract script missing: {extract_script}")
|
||||
if not os.path.exists(export_script):
|
||||
raise FileNotFoundError(f"export script missing: {export_script}")
|
||||
_emit("Refreshing theme catalog ({} path)...".format('fast' if fast_path else 'post-tagging'))
|
||||
_run([_sys.executable, extract_script], check=True)
|
||||
args = [_sys.executable, export_script]
|
||||
if force:
|
||||
args.append('--force')
|
||||
_run(args, check=True)
|
||||
_emit("Theme catalog (JSON + YAML) refreshed{}.".format(" (fast path)" if fast_path else ""))
|
||||
# Mark progress complete
|
||||
_write_status({"running": True, "phase": phase_label, "message": "Theme catalog refreshed", "percent": 99})
|
||||
# Append status file enrichment with last export metrics
|
||||
try:
|
||||
status_path = os.path.join('csv_files', '.setup_status.json')
|
||||
if os.path.exists(status_path):
|
||||
with open(status_path, 'r', encoding='utf-8') as _rf:
|
||||
st = json.load(_rf) or {}
|
||||
else:
|
||||
st = {}
|
||||
st.update({
|
||||
'themes_last_export_at': _dt.now().isoformat(timespec='seconds'),
|
||||
'themes_last_export_fast_path': bool(fast_path),
|
||||
# Populate provenance if available (Phase B/C)
|
||||
})
|
||||
try:
|
||||
theme_json_path = os.path.join('config', 'themes', 'theme_list.json')
|
||||
if os.path.exists(theme_json_path):
|
||||
with open(theme_json_path, 'r', encoding='utf-8') as _tf:
|
||||
_td = json.load(_tf) or {}
|
||||
prov = _td.get('provenance') or {}
|
||||
if isinstance(prov, dict):
|
||||
for k, v in prov.items():
|
||||
st[f'theme_provenance_{k}'] = v
|
||||
except Exception:
|
||||
pass
|
||||
# Write back
|
||||
with open(status_path, 'w', encoding='utf-8') as _wf:
|
||||
json.dump(st, _wf)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _e: # pragma: no cover - non-critical diagnostics only
|
||||
try:
|
||||
out_func(f"Theme catalog refresh failed: {_e}")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
print(f"Theme catalog refresh failed: {_e}")
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
# Mark phase back to done if we were otherwise complete
|
||||
status_path = os.path.join('csv_files', '.setup_status.json')
|
||||
if os.path.exists(status_path):
|
||||
with open(status_path, 'r', encoding='utf-8') as _rf:
|
||||
st = json.load(_rf) or {}
|
||||
# Only flip phase if previous run finished
|
||||
if st.get('phase') in {'themes','themes-fast'}:
|
||||
st['phase'] = 'done'
|
||||
with open(status_path, 'w', encoding='utf-8') as _wf:
|
||||
json.dump(st, _wf)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
cards_path = os.path.join('csv_files', 'cards.csv')
|
||||
flag_path = os.path.join('csv_files', '.tagging_complete.json')
|
||||
|
@ -910,7 +1044,9 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
|
|||
duration_s = int(max(0.0, (finished_dt - start_dt).total_seconds()))
|
||||
except Exception:
|
||||
duration_s = None
|
||||
payload = {"running": False, "phase": "done", "message": "Setup complete", "color": None, "percent": 100, "finished_at": finished}
|
||||
# Generate / refresh theme catalog (JSON + per-theme YAML) BEFORE marking done so UI sees progress
|
||||
_refresh_theme_catalog(out, force=True, fast_path=False)
|
||||
payload = {"running": False, "phase": "done", "message": "Setup complete", "color": None, "percent": 100, "finished_at": finished, "themes_exported": True}
|
||||
if duration_s is not None:
|
||||
payload["duration_seconds"] = duration_s
|
||||
_write_status(payload)
|
||||
|
@ -919,6 +1055,116 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
|
|||
except Exception:
|
||||
# Non-fatal; downstream loads will still attempt and surface errors in logs
|
||||
_write_status({"running": False, "phase": "error", "message": "Setup check failed"})
|
||||
# Fast-path theme catalog refresh: if setup/tagging were already current (no refresh_needed executed)
|
||||
# ensure theme artifacts exist and are fresh relative to the tagging flag. This runs outside the
|
||||
# main try so that a failure here never blocks normal builds.
|
||||
try: # noqa: E722 - defensive broad except acceptable for non-critical refresh
|
||||
# Only attempt if we did NOT just perform a refresh (refresh_needed False) and auto-setup enabled
|
||||
# We detect refresh_needed by checking presence of the status flag percent=100 and phase done.
|
||||
status_path = os.path.join('csv_files', '.setup_status.json')
|
||||
tag_flag = os.path.join('csv_files', '.tagging_complete.json')
|
||||
auto_setup_enabled = _is_truthy_env('WEB_AUTO_SETUP', '1')
|
||||
if not auto_setup_enabled:
|
||||
return
|
||||
refresh_recent = False
|
||||
try:
|
||||
if os.path.exists(status_path):
|
||||
with open(status_path, 'r', encoding='utf-8') as _rf:
|
||||
st = json.load(_rf) or {}
|
||||
# If status percent just hit 100 moments ago (< 10s), we can skip fast-path work
|
||||
if st.get('percent') == 100 and st.get('phase') == 'done':
|
||||
# If finished very recently we assume the main export already ran
|
||||
fin = st.get('finished_at') or st.get('updated')
|
||||
if isinstance(fin, str) and fin.strip():
|
||||
try:
|
||||
ts = _dt.fromisoformat(fin.strip())
|
||||
if (time.time() - ts.timestamp()) < 10:
|
||||
refresh_recent = True
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
if refresh_recent:
|
||||
return
|
||||
|
||||
theme_json = os.path.join('config', 'themes', 'theme_list.json')
|
||||
catalog_dir = os.path.join('config', 'themes', 'catalog')
|
||||
need_theme_refresh = False
|
||||
# Helper to parse ISO timestamp
|
||||
def _parse_iso(ts: str | None):
|
||||
if not ts:
|
||||
return None
|
||||
try:
|
||||
return _dt.fromisoformat(ts.strip()).timestamp()
|
||||
except Exception:
|
||||
return None
|
||||
tag_ts = None
|
||||
try:
|
||||
if os.path.exists(tag_flag):
|
||||
with open(tag_flag, 'r', encoding='utf-8') as f:
|
||||
tag_ts = (json.load(f) or {}).get('tagged_at')
|
||||
except Exception:
|
||||
tag_ts = None
|
||||
tag_time = _parse_iso(tag_ts)
|
||||
theme_mtime = os.path.getmtime(theme_json) if os.path.exists(theme_json) else 0
|
||||
# Determine newest YAML or build script mtime to detect editorial changes
|
||||
newest_yaml_mtime = 0
|
||||
try:
|
||||
if os.path.isdir(catalog_dir):
|
||||
for fn in os.listdir(catalog_dir):
|
||||
if fn.endswith('.yml'):
|
||||
pth = os.path.join(catalog_dir, fn)
|
||||
try:
|
||||
mt = os.path.getmtime(pth)
|
||||
if mt > newest_yaml_mtime:
|
||||
newest_yaml_mtime = mt
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
newest_yaml_mtime = 0
|
||||
build_script_path = os.path.join('code', 'scripts', 'build_theme_catalog.py')
|
||||
build_script_mtime = 0
|
||||
try:
|
||||
if os.path.exists(build_script_path):
|
||||
build_script_mtime = os.path.getmtime(build_script_path)
|
||||
except Exception:
|
||||
build_script_mtime = 0
|
||||
# Conditions triggering refresh:
|
||||
# 1. theme_list.json missing
|
||||
# 2. catalog dir missing or unusually small (< 100 files) – indicates first run or failure
|
||||
# 3. tagging flag newer than theme_list.json (themes stale relative to data)
|
||||
if not os.path.exists(theme_json):
|
||||
need_theme_refresh = True
|
||||
elif not os.path.isdir(catalog_dir):
|
||||
need_theme_refresh = True
|
||||
else:
|
||||
try:
|
||||
yml_count = len([p for p in os.listdir(catalog_dir) if p.endswith('.yml')])
|
||||
if yml_count < 100: # heuristic threshold (we expect ~700+)
|
||||
need_theme_refresh = True
|
||||
except Exception:
|
||||
need_theme_refresh = True
|
||||
# Trigger refresh if tagging newer
|
||||
if not need_theme_refresh and tag_time and tag_time > (theme_mtime + 1):
|
||||
need_theme_refresh = True
|
||||
# Trigger refresh if any catalog YAML newer than theme_list.json (editorial edits)
|
||||
if not need_theme_refresh and newest_yaml_mtime and newest_yaml_mtime > (theme_mtime + 1):
|
||||
need_theme_refresh = True
|
||||
# Trigger refresh if build script updated (logic changes)
|
||||
if not need_theme_refresh and build_script_mtime and build_script_mtime > (theme_mtime + 1):
|
||||
need_theme_refresh = True
|
||||
if need_theme_refresh:
|
||||
_refresh_theme_catalog(out, force=False, fast_path=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Unconditional fallback: if (for any reason) no theme export ran above, perform a fast-path export now.
|
||||
# This guarantees that clicking Run Setup/Tagging always leaves themes current even when tagging wasn't needed.
|
||||
try:
|
||||
if not theme_export_performed:
|
||||
_refresh_theme_catalog(out, force=False, fast_path=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, int], tag_mode: str | None = None, *, use_owned_only: bool | None = None, prefer_owned: bool | None = None, owned_names: List[str] | None = None, prefer_combos: bool | None = None, combo_target_count: int | None = None, combo_balance: str | None = None) -> Dict[str, Any]:
|
||||
|
|
|
@ -173,24 +173,32 @@
|
|||
return statusEl;
|
||||
}
|
||||
function renderSetupStatus(data){
|
||||
var el = ensureStatusEl(); if (!el) return;
|
||||
if (data && data.running) {
|
||||
var msg = (data.message || 'Preparing data...');
|
||||
el.innerHTML = '<strong>Setup/Tagging:</strong> ' + msg + ' <a href="/setup/running" style="margin-left:.5rem;">View progress</a>';
|
||||
el.classList.add('busy');
|
||||
} else if (data && data.phase === 'done') {
|
||||
// Don't show "Setup complete" message to avoid UI stuttering
|
||||
// Just clear any existing content and remove busy state
|
||||
el.innerHTML = '';
|
||||
el.classList.remove('busy');
|
||||
} else if (data && data.phase === 'error') {
|
||||
el.innerHTML = '<span class="error">Setup error.</span>';
|
||||
setTimeout(function(){ el.innerHTML = ''; el.classList.remove('busy'); }, 5000);
|
||||
} else {
|
||||
if (!el.innerHTML.trim()) el.innerHTML = '';
|
||||
el.classList.remove('busy');
|
||||
}
|
||||
var el = ensureStatusEl(); if (!el) return;
|
||||
if (data && data.running) {
|
||||
var msg = (data.message || 'Preparing data...');
|
||||
var pct = (typeof data.percent === 'number') ? data.percent : null;
|
||||
// Suppress banner if we're effectively finished (>=99%) or message is purely theme catalog refreshed
|
||||
var suppress = false;
|
||||
if (pct !== null && pct >= 99) suppress = true;
|
||||
var lm = (msg || '').toLowerCase();
|
||||
if (lm.indexOf('theme catalog refreshed') >= 0) suppress = true;
|
||||
if (suppress) {
|
||||
if (el.innerHTML) { el.innerHTML=''; el.classList.remove('busy'); }
|
||||
return;
|
||||
}
|
||||
el.innerHTML = '<strong>Setup/Tagging:</strong> ' + msg + ' <a href="/setup/running" style="margin-left:.5rem;">View progress</a>';
|
||||
el.classList.add('busy');
|
||||
} else if (data && data.phase === 'done') {
|
||||
el.innerHTML = '';
|
||||
el.classList.remove('busy');
|
||||
} else if (data && data.phase === 'error') {
|
||||
el.innerHTML = '<span class="error">Setup error.</span>';
|
||||
setTimeout(function(){ el.innerHTML = ''; el.classList.remove('busy'); }, 5000);
|
||||
} else {
|
||||
if (!el.innerHTML.trim()) el.innerHTML = '';
|
||||
el.classList.remove('busy');
|
||||
}
|
||||
}
|
||||
function pollStatus(){
|
||||
try {
|
||||
fetch('/status/setup', { cache: 'no-store' })
|
||||
|
|
|
@ -9,5 +9,29 @@
|
|||
<a class="action-button" href="/decks">Finished Decks</a>
|
||||
{% if show_logs %}<a class="action-button" href="/logs">View Logs</a>{% endif %}
|
||||
</div>
|
||||
<div id="themes-quick" style="margin-top:1rem; font-size:.85rem; color:var(--text-muted);">
|
||||
<span id="themes-quick-status">Themes: …</span>
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
(function(){
|
||||
function upd(data){
|
||||
var el = document.getElementById('themes-quick-status');
|
||||
if(!el) return;
|
||||
if(!data || !data.ok){ el.textContent='Themes: unavailable'; return; }
|
||||
var badge = '';
|
||||
if(data.phase === 'themes' || data.phase === 'themes-fast') badge=' (refreshing)';
|
||||
else if(data.stale) badge=' (stale)';
|
||||
el.textContent = 'Themes: ' + (data.theme_count != null ? data.theme_count : '?') + badge;
|
||||
}
|
||||
function poll(){
|
||||
fetch('/themes/status', { cache:'no-store' })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(upd)
|
||||
.catch(function(){});
|
||||
}
|
||||
poll();
|
||||
setInterval(poll, 7000);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
@ -24,18 +24,37 @@
|
|||
|
||||
<div style="margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap;">
|
||||
<form id="frm-start-setup" action="/setup/start" method="post" onsubmit="event.preventDefault(); startSetup();">
|
||||
<button type="submit" id="btn-start-setup">Run Setup/Tagging</button>
|
||||
<button type="submit" id="btn-start-setup" class="action-btn">Run Setup/Tagging</button>
|
||||
<label class="muted" style="margin-left:.75rem; font-size:.9rem;">
|
||||
<input type="checkbox" id="chk-force" checked /> Force run
|
||||
</label>
|
||||
</form>
|
||||
<form method="get" action="/setup/running?start=1&force=1">
|
||||
<button type="submit">Open Progress Page</button>
|
||||
<button type="submit" class="action-btn">Open Progress Page</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<details style="margin-top:1.25rem;" open>
|
||||
<summary>Theme Catalog Status</summary>
|
||||
<div id="themes-status" style="margin-top:.5rem; padding:1rem; border:1px solid var(--border); background:#0f1115; border-radius:8px;">
|
||||
<div class="muted">Status:</div>
|
||||
<div id="themes-status-line" style="margin-top:.25rem;">Checking…</div>
|
||||
<div class="muted" id="themes-meta-line" style="margin-top:.25rem; display:none;"></div>
|
||||
<div class="muted" id="themes-stale-line" style="margin-top:.25rem; display:none; color:#f87171;"></div>
|
||||
</div>
|
||||
</details>
|
||||
<div style="margin-top:.75rem;">
|
||||
<button type="button" id="btn-refresh-themes" class="action-btn" onclick="refreshThemes()">Refresh Themes Only</button>
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
(function(){
|
||||
// Minimal styling helper to unify button widths
|
||||
try {
|
||||
var style = document.createElement('style');
|
||||
style.textContent = '.action-btn{min-width:180px;}';
|
||||
document.head.appendChild(style);
|
||||
} catch(e){}
|
||||
function update(data){
|
||||
var line = document.getElementById('setup-status-line');
|
||||
var colorEl = document.getElementById('setup-color-line');
|
||||
|
@ -126,7 +145,47 @@
|
|||
.then(function(r){ return r.json(); })
|
||||
.then(update)
|
||||
.catch(function(){});
|
||||
pollThemes();
|
||||
}
|
||||
function pollThemes(){
|
||||
fetch('/themes/status', { cache: 'no-store' })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(updateThemes)
|
||||
.catch(function(){});
|
||||
}
|
||||
function updateThemes(data){
|
||||
var line = document.getElementById('themes-status-line');
|
||||
var meta = document.getElementById('themes-meta-line');
|
||||
var staleEl = document.getElementById('themes-stale-line');
|
||||
var btn = document.getElementById('btn-refresh-themes');
|
||||
if(!line) return;
|
||||
if(!data || !data.ok){ line.textContent = 'Unavailable'; return; }
|
||||
var parts = [];
|
||||
if (typeof data.theme_count === 'number') parts.push('Themes: '+data.theme_count);
|
||||
if (typeof data.yaml_file_count === 'number') parts.push('YAML: '+data.yaml_file_count);
|
||||
if (data.last_export_at) parts.push('Last Export: '+data.last_export_at);
|
||||
line.textContent = (data.theme_list_exists ? 'Ready' : 'Not generated');
|
||||
if(parts.length){ meta.style.display=''; meta.textContent = parts.join(' • '); } else { meta.style.display='none'; }
|
||||
if(data.stale){ staleEl.style.display=''; staleEl.textContent='Stale: needs refresh'; }
|
||||
else { staleEl.style.display='none'; }
|
||||
// Disable refresh while a theme export phase is active (in orchestrator phases 'themes' / 'themes-fast')
|
||||
try {
|
||||
if(btn){
|
||||
if(data.phase === 'themes' || data.phase === 'themes-fast'){
|
||||
btn.disabled = true; btn.textContent='Refreshing…';
|
||||
} else {
|
||||
if(!data.running){ btn.disabled = false; btn.textContent='Refresh Themes Only'; }
|
||||
}
|
||||
}
|
||||
} catch(e){}
|
||||
}
|
||||
window.refreshThemes = function(){
|
||||
var btn = document.getElementById('btn-refresh-themes');
|
||||
if(btn) btn.disabled = true;
|
||||
fetch('/themes/refresh', { method:'POST' })
|
||||
.then(function(){ setTimeout(function(){ pollThemes(); if(btn) btn.disabled=false; }, 1200); })
|
||||
.catch(function(){ if(btn) btn.disabled=false; });
|
||||
};
|
||||
function rapidPoll(times, delay){
|
||||
var i = 0;
|
||||
function tick(){
|
||||
|
@ -157,6 +216,7 @@
|
|||
};
|
||||
setInterval(poll, 3000);
|
||||
poll();
|
||||
pollThemes();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue