mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-21 20:40:47 +02:00
feat(web,docs): visual summaries (curve, pips/sources incl. 'C', non‑land sources), tooltip copy, favicon; diagnostics (/healthz, request‑id, global handlers); fetches excluded, basics CSV fallback, list highlight polish; README/DOCKER/release-notes/CHANGELOG updated
This commit is contained in:
parent
625f6abb13
commit
8d1f6a8ac4
27 changed files with 1704 additions and 154 deletions
|
@ -76,6 +76,8 @@ def _build_owned_context(request: Request, notice: str | None = None, error: str
|
|||
"""
|
||||
# Read enriched data from the store (fast path; avoids per-request CSV parsing)
|
||||
names, tags_by_name, type_by_name, colors_by_name = store.get_enriched()
|
||||
added_at_map = store.get_added_at_map()
|
||||
user_tags_map = store.get_user_tags_map()
|
||||
# Default sort by name (case-insensitive)
|
||||
names_sorted = sorted(names, key=lambda s: s.lower())
|
||||
# Build filter option sets
|
||||
|
@ -95,6 +97,8 @@ def _build_owned_context(request: Request, notice: str | None = None, error: str
|
|||
"all_tags": all_tags,
|
||||
"all_colors": all_colors,
|
||||
"color_combos": combos,
|
||||
"added_at_map": added_at_map,
|
||||
"user_tags_map": user_tags_map,
|
||||
}
|
||||
if notice:
|
||||
ctx["notice"] = notice
|
||||
|
@ -139,6 +143,85 @@ async def owned_clear(request: Request) -> HTMLResponse:
|
|||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
|
||||
|
||||
@router.post("/remove", response_class=HTMLResponse)
|
||||
async def owned_remove(request: Request) -> HTMLResponse:
|
||||
"""Remove a set of names provided as JSON or form data under 'names'."""
|
||||
try:
|
||||
names: list[str] = []
|
||||
# Try JSON first
|
||||
try:
|
||||
payload = await request.json()
|
||||
if isinstance(payload, dict) and isinstance(payload.get("names"), list):
|
||||
names = [str(x) for x in payload.get("names")]
|
||||
elif isinstance(payload, list):
|
||||
names = [str(x) for x in payload]
|
||||
except Exception:
|
||||
# Fallback to form field 'names' as comma-separated
|
||||
form = await request.form()
|
||||
raw = form.get("names") or ""
|
||||
if raw:
|
||||
names = [s.strip() for s in str(raw).split(',') if s.strip()]
|
||||
removed, total = store.remove_names(names)
|
||||
notice = f"Removed {removed} name(s). Total: {total}."
|
||||
ctx = _build_owned_context(request, notice=notice)
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
except Exception as e:
|
||||
ctx = _build_owned_context(request, error=f"Remove failed: {e}")
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
|
||||
|
||||
@router.post("/tag/add", response_class=HTMLResponse)
|
||||
async def owned_tag_add(request: Request) -> HTMLResponse:
|
||||
try:
|
||||
names: list[str] = []
|
||||
tag: str = ""
|
||||
try:
|
||||
payload = await request.json()
|
||||
if isinstance(payload, dict):
|
||||
if isinstance(payload.get("names"), list):
|
||||
names = [str(x) for x in payload.get("names")]
|
||||
tag = str(payload.get("tag") or "").strip()
|
||||
except Exception:
|
||||
form = await request.form()
|
||||
raw = form.get("names") or ""
|
||||
if raw:
|
||||
names = [s.strip() for s in str(raw).split(',') if s.strip()]
|
||||
tag = str(form.get("tag") or "").strip()
|
||||
updated = store.add_user_tag(names, tag)
|
||||
notice = f"Added tag '{tag}' to {updated} name(s)."
|
||||
ctx = _build_owned_context(request, notice=notice)
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
except Exception as e:
|
||||
ctx = _build_owned_context(request, error=f"Tag add failed: {e}")
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
|
||||
|
||||
@router.post("/tag/remove", response_class=HTMLResponse)
|
||||
async def owned_tag_remove(request: Request) -> HTMLResponse:
|
||||
try:
|
||||
names: list[str] = []
|
||||
tag: str = ""
|
||||
try:
|
||||
payload = await request.json()
|
||||
if isinstance(payload, dict):
|
||||
if isinstance(payload.get("names"), list):
|
||||
names = [str(x) for x in payload.get("names")]
|
||||
tag = str(payload.get("tag") or "").strip()
|
||||
except Exception:
|
||||
form = await request.form()
|
||||
raw = form.get("names") or ""
|
||||
if raw:
|
||||
names = [s.strip() for s in str(raw).split(',') if s.strip()]
|
||||
tag = str(form.get("tag") or "").strip()
|
||||
updated = store.remove_user_tag(names, tag)
|
||||
notice = f"Removed tag '{tag}' from {updated} name(s)."
|
||||
ctx = _build_owned_context(request, notice=notice)
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
except Exception as e:
|
||||
ctx = _build_owned_context(request, error=f"Tag remove failed: {e}")
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
|
||||
|
||||
# Legacy /owned/use route removed; owned-only toggle now lives on the Builder Review step.
|
||||
|
||||
|
||||
|
@ -177,3 +260,69 @@ async def owned_export_csv() -> Response:
|
|||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": "attachment; filename=owned_cards.csv"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/export-visible")
|
||||
async def owned_export_visible_txt(request: Request) -> Response:
|
||||
"""Download the provided names (visible subset) as TXT."""
|
||||
try:
|
||||
names: list[str] = []
|
||||
try:
|
||||
payload = await request.json()
|
||||
if isinstance(payload, dict) and isinstance(payload.get("names"), list):
|
||||
names = [str(x) for x in payload.get("names")]
|
||||
elif isinstance(payload, list):
|
||||
names = [str(x) for x in payload]
|
||||
except Exception:
|
||||
form = await request.form()
|
||||
raw = form.get("names") or ""
|
||||
if raw:
|
||||
names = [s.strip() for s in str(raw).split(',') if s.strip()]
|
||||
# Stable case-insensitive sort
|
||||
lines = "\n".join(sorted((names or []), key=lambda s: s.lower()))
|
||||
return Response(
|
||||
content=lines + ("\n" if lines else ""),
|
||||
media_type="text/plain; charset=utf-8",
|
||||
headers={"Content-Disposition": "attachment; filename=owned_visible.txt"},
|
||||
)
|
||||
except Exception:
|
||||
# On error return empty file
|
||||
return Response(content="", media_type="text/plain; charset=utf-8")
|
||||
|
||||
|
||||
@router.post("/export-visible.csv")
|
||||
async def owned_export_visible_csv(request: Request) -> Response:
|
||||
"""Download the provided names (visible subset) with enrichment as CSV."""
|
||||
try:
|
||||
names: list[str] = []
|
||||
try:
|
||||
payload = await request.json()
|
||||
if isinstance(payload, dict) and isinstance(payload.get("names"), list):
|
||||
names = [str(x) for x in payload.get("names")]
|
||||
elif isinstance(payload, list):
|
||||
names = [str(x) for x in payload]
|
||||
except Exception:
|
||||
form = await request.form()
|
||||
raw = form.get("names") or ""
|
||||
if raw:
|
||||
names = [s.strip() for s in str(raw).split(',') if s.strip()]
|
||||
# Build CSV using current enrichment
|
||||
all_names, tags_by_name, type_by_name, colors_by_name = store.get_enriched()
|
||||
import csv
|
||||
from io import StringIO
|
||||
buf = StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["Name", "Type", "Colors", "Tags"])
|
||||
for n in sorted((names or []), key=lambda s: s.lower()):
|
||||
tline = type_by_name.get(n, "")
|
||||
cols = ''.join(colors_by_name.get(n, []) or [])
|
||||
tags = '|'.join(tags_by_name.get(n, []) or [])
|
||||
writer.writerow([n, tline, cols, tags])
|
||||
content = buf.getvalue()
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": "attachment; filename=owned_visible.csv"},
|
||||
)
|
||||
except Exception:
|
||||
return Response(content="", media_type="text/csv; charset=utf-8")
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue