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:
matt 2025-08-26 20:00:07 -07:00
parent 625f6abb13
commit 8d1f6a8ac4
27 changed files with 1704 additions and 154 deletions

View file

@ -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")