mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +01:00
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
This commit is contained in:
parent
8fa040a05a
commit
0f73a85a4e
43 changed files with 4515 additions and 105 deletions
179
code/web/routes/configs.py
Normal file
179
code/web/routes/configs.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request, Form, UploadFile, File
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pathlib import Path
|
||||
import os
|
||||
import json
|
||||
from ..app import templates
|
||||
from ..services import orchestrator as orch
|
||||
from deck_builder import builder_constants as bc
|
||||
|
||||
|
||||
router = APIRouter(prefix="/configs")
|
||||
|
||||
|
||||
def _config_dir() -> Path:
|
||||
# Prefer explicit env var if provided, else default to ./config
|
||||
p = os.getenv("DECK_CONFIG")
|
||||
if p:
|
||||
# If env points to a file, use its parent dir; else treat as dir
|
||||
pp = Path(p)
|
||||
return (pp.parent if pp.suffix else pp).resolve()
|
||||
return (Path.cwd() / "config").resolve()
|
||||
|
||||
|
||||
def _list_configs() -> list[dict]:
|
||||
d = _config_dir()
|
||||
try:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
items: list[dict] = []
|
||||
for p in sorted(d.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True):
|
||||
meta = {"name": p.name, "path": str(p), "mtime": p.stat().st_mtime}
|
||||
try:
|
||||
with p.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
meta["commander"] = data.get("commander")
|
||||
tags = [t for t in [data.get("primary_tag"), data.get("secondary_tag"), data.get("tertiary_tag")] if t]
|
||||
meta["tags"] = tags
|
||||
meta["bracket_level"] = data.get("bracket_level")
|
||||
except Exception:
|
||||
pass
|
||||
items.append(meta)
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def configs_index(request: Request) -> HTMLResponse:
|
||||
items = _list_configs()
|
||||
# Load example deck.json from the config directory, if present
|
||||
example_json = None
|
||||
example_name = "deck.json"
|
||||
try:
|
||||
example_path = _config_dir() / example_name
|
||||
if example_path.exists() and example_path.is_file():
|
||||
example_json = example_path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
example_json = None
|
||||
return templates.TemplateResponse(
|
||||
"configs/index.html",
|
||||
{"request": request, "items": items, "example_json": example_json, "example_name": example_name},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/view", response_class=HTMLResponse)
|
||||
async def configs_view(request: Request, name: str) -> HTMLResponse:
|
||||
base = _config_dir()
|
||||
p = (base / name).resolve()
|
||||
# Safety: ensure the resolved path is within config dir
|
||||
try:
|
||||
if base not in p.parents and p != base:
|
||||
raise ValueError("Access denied")
|
||||
except Exception:
|
||||
pass
|
||||
if not (p.exists() and p.is_file() and p.suffix.lower() == ".json"):
|
||||
return templates.TemplateResponse(
|
||||
"configs/index.html",
|
||||
{"request": request, "items": _list_configs(), "error": "Config not found."},
|
||||
)
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
return templates.TemplateResponse(
|
||||
"configs/index.html",
|
||||
{"request": request, "items": _list_configs(), "error": f"Failed to read JSON: {e}"},
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
"configs/view.html",
|
||||
{"request": request, "path": str(p), "name": p.name, "data": data},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/run", response_class=HTMLResponse)
|
||||
async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse:
|
||||
base = _config_dir()
|
||||
p = (base / name).resolve()
|
||||
try:
|
||||
if base not in p.parents and p != base:
|
||||
raise ValueError("Access denied")
|
||||
except Exception:
|
||||
pass
|
||||
if not (p.exists() and p.is_file() and p.suffix.lower() == ".json"):
|
||||
return templates.TemplateResponse(
|
||||
"configs/index.html",
|
||||
{"request": request, "items": _list_configs(), "error": "Config not found."},
|
||||
)
|
||||
try:
|
||||
cfg = json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
return templates.TemplateResponse(
|
||||
"configs/index.html",
|
||||
{"request": request, "items": _list_configs(), "error": f"Failed to read JSON: {e}"},
|
||||
)
|
||||
|
||||
commander = cfg.get("commander", "")
|
||||
tags = [t for t in [cfg.get("primary_tag"), cfg.get("secondary_tag"), cfg.get("tertiary_tag")] if t]
|
||||
bracket = int(cfg.get("bracket_level") or 0)
|
||||
ideals = cfg.get("ideal_counts", {}) or {}
|
||||
|
||||
# Run build headlessly with orchestrator
|
||||
res = orch.run_build(commander=commander, tags=tags, bracket=bracket, ideals=ideals)
|
||||
if not res.get("ok"):
|
||||
return templates.TemplateResponse(
|
||||
"configs/run_result.html",
|
||||
{
|
||||
"request": request,
|
||||
"ok": False,
|
||||
"error": res.get("error") or "Build failed",
|
||||
"log": res.get("log", ""),
|
||||
"cfg_name": p.name,
|
||||
"commander": commander,
|
||||
},
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
"configs/run_result.html",
|
||||
{
|
||||
"request": request,
|
||||
"ok": True,
|
||||
"log": res.get("log", ""),
|
||||
"csv_path": res.get("csv_path"),
|
||||
"txt_path": res.get("txt_path"),
|
||||
"summary": res.get("summary"),
|
||||
"cfg_name": p.name,
|
||||
"commander": commander,
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload", response_class=HTMLResponse)
|
||||
async def configs_upload(request: Request, file: UploadFile = File(...)) -> HTMLResponse:
|
||||
# Optional helper: allow uploading a JSON config
|
||||
try:
|
||||
content = await file.read()
|
||||
data = json.loads(content.decode("utf-8"))
|
||||
# Minimal validation
|
||||
if not data.get("commander"):
|
||||
raise ValueError("Missing 'commander'")
|
||||
except Exception as e:
|
||||
return templates.TemplateResponse(
|
||||
"configs/index.html",
|
||||
{"request": request, "items": _list_configs(), "error": f"Invalid JSON: {e}"},
|
||||
)
|
||||
# Save to config dir with original filename (or unique)
|
||||
d = _config_dir()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
fname = file.filename or "config.json"
|
||||
out = d / fname
|
||||
i = 1
|
||||
while out.exists():
|
||||
stem = out.stem
|
||||
out = d / f"{stem}_{i}.json"
|
||||
i += 1
|
||||
out.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
return templates.TemplateResponse(
|
||||
"configs/index.html",
|
||||
{"request": request, "items": _list_configs(), "notice": f"Uploaded {out.name}"},
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue