mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-21 20:40:47 +02:00
Web/builder: Owned stability+enrichment+exports; prefer-owned toggle & bias; staged build show-skipped; UI polish; docs update
This commit is contained in:
parent
fd7fc01071
commit
625f6abb13
26 changed files with 1618 additions and 229 deletions
179
code/web/routes/owned.py
Normal file
179
code/web/routes/owned.py
Normal file
|
@ -0,0 +1,179 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request, UploadFile, File
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from ..app import templates
|
||||
from ..services import owned_store as store
|
||||
# Session helpers are not required for owned routes
|
||||
|
||||
|
||||
router = APIRouter(prefix="/owned")
|
||||
|
||||
|
||||
def _canon_color_code(seq: list[str] | tuple[str, ...]) -> str:
|
||||
"""Canonicalize a color identity sequence to a stable code (WUBRG order, no 'C' unless only color)."""
|
||||
order = {'W':0,'U':1,'B':2,'R':3,'G':4,'C':5}
|
||||
uniq: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for c in (seq or []):
|
||||
uc = (c or '').upper()
|
||||
if uc in order and uc not in seen:
|
||||
seen.add(uc)
|
||||
uniq.append(uc)
|
||||
uniq.sort(key=lambda x: order[x])
|
||||
code = ''.join([c for c in uniq if c != 'C'])
|
||||
return code or ('C' if 'C' in seen else '')
|
||||
|
||||
|
||||
def _color_combo_label(code: str) -> str:
|
||||
"""Return friendly label for a 2/3/4-color combo code; empty if unknown.
|
||||
|
||||
Uses standard names: Guilds, Shards/Wedges, and Nephilim-style for 4-color.
|
||||
"""
|
||||
two_map = {
|
||||
'WU':'Azorius','UB':'Dimir','BR':'Rakdos','RG':'Gruul','WG':'Selesnya',
|
||||
'WB':'Orzhov','UR':'Izzet','BG':'Golgari','WR':'Boros','UG':'Simic',
|
||||
}
|
||||
three_map = {
|
||||
'WUB':'Esper','UBR':'Grixis','BRG':'Jund','WRG':'Naya','WUG':'Bant',
|
||||
'WBR':'Mardu','WUR':'Jeskai','UBG':'Sultai','URG':'Temur','WBG':'Abzan',
|
||||
}
|
||||
four_map = {
|
||||
'WUBR': 'Yore-Tiller', # no G
|
||||
'WUBG': 'Witch-Maw', # no R
|
||||
'WURG': 'Ink-Treader', # no B
|
||||
'WBRG': 'Dune-Brood', # no U
|
||||
'UBRG': 'Glint-Eye', # no W
|
||||
}
|
||||
if len(code) == 2:
|
||||
return two_map.get(code, '')
|
||||
if len(code) == 3:
|
||||
return three_map.get(code, '')
|
||||
if len(code) == 4:
|
||||
return four_map.get(code, '')
|
||||
return ''
|
||||
|
||||
|
||||
def _build_color_combos(names_sorted: list[str], colors_by_name: dict[str, list[str]]) -> list[tuple[str, str]]:
|
||||
"""Compute present color combos and return [(code, display)], ordered by length then code."""
|
||||
combo_set: set[str] = set()
|
||||
for n in names_sorted:
|
||||
cols = (colors_by_name.get(n) or [])
|
||||
code = _canon_color_code(cols)
|
||||
if len(code) >= 2:
|
||||
combo_set.add(code)
|
||||
combos: list[tuple[str, str]] = []
|
||||
for code in sorted(combo_set, key=lambda s: (len(s), s)):
|
||||
label = _color_combo_label(code)
|
||||
display = f"{label} ({code})" if label else code
|
||||
combos.append((code, display))
|
||||
return combos
|
||||
|
||||
|
||||
def _build_owned_context(request: Request, notice: str | None = None, error: str | None = None) -> dict:
|
||||
"""Build the template context for the Owned Library page, including
|
||||
enrichment from csv_files and filter option lists.
|
||||
"""
|
||||
# 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()
|
||||
# Default sort by name (case-insensitive)
|
||||
names_sorted = sorted(names, key=lambda s: s.lower())
|
||||
# Build filter option sets
|
||||
all_types = sorted({type_by_name.get(n) for n in names_sorted if type_by_name.get(n)}, key=lambda s: s.lower())
|
||||
all_tags = sorted({t for n in names_sorted for t in (tags_by_name.get(n) or [])}, key=lambda s: s.lower())
|
||||
all_colors = ['W','U','B','R','G','C']
|
||||
# Build color combos displayed in the filter
|
||||
combos = _build_color_combos(names_sorted, colors_by_name)
|
||||
ctx = {
|
||||
"request": request,
|
||||
"names": names_sorted,
|
||||
"count": len(names_sorted),
|
||||
"tags_by_name": tags_by_name,
|
||||
"type_by_name": type_by_name,
|
||||
"colors_by_name": colors_by_name,
|
||||
"all_types": all_types,
|
||||
"all_tags": all_tags,
|
||||
"all_colors": all_colors,
|
||||
"color_combos": combos,
|
||||
}
|
||||
if notice:
|
||||
ctx["notice"] = notice
|
||||
if error:
|
||||
ctx["error"] = error
|
||||
return ctx
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def owned_index(request: Request) -> HTMLResponse:
|
||||
ctx = _build_owned_context(request)
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
|
||||
|
||||
@router.post("/upload", response_class=HTMLResponse)
|
||||
async def owned_upload(request: Request, file: UploadFile = File(...)) -> HTMLResponse:
|
||||
try:
|
||||
content = await file.read()
|
||||
fname = (file.filename or "").lower()
|
||||
if fname.endswith(".csv"):
|
||||
names = store.parse_csv_bytes(content)
|
||||
else:
|
||||
names = store.parse_txt_bytes(content)
|
||||
# Add and enrich immediately so the page doesn't need to parse CSVs
|
||||
added, total = store.add_and_enrich(names)
|
||||
notice = f"Added {added} new 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"Upload failed: {e}")
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
|
||||
|
||||
@router.post("/clear", response_class=HTMLResponse)
|
||||
async def owned_clear(request: Request) -> HTMLResponse:
|
||||
try:
|
||||
store.clear()
|
||||
ctx = _build_owned_context(request, notice="Library cleared.")
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
except Exception as e:
|
||||
ctx = _build_owned_context(request, error=f"Clear failed: {e}")
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
|
||||
|
||||
# Legacy /owned/use route removed; owned-only toggle now lives on the Builder Review step.
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def owned_export_txt() -> Response:
|
||||
"""Download the owned library as a simple TXT (one name per line)."""
|
||||
names, _, _, _ = store.get_enriched()
|
||||
# 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_cards.txt"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export.csv")
|
||||
async def owned_export_csv() -> Response:
|
||||
"""Download the owned library with enrichment as CSV (Name,Type,Colors,Tags)."""
|
||||
names, tags_by_name, type_by_name, colors_by_name = store.get_enriched()
|
||||
# Prepare CSV content
|
||||
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_cards.csv"},
|
||||
)
|
Loading…
Add table
Add a link
Reference in a new issue