From 593a6756f6c55405dcf3acc7bac6d2784ec0ae87 Mon Sep 17 00:00:00 2001 From: robviren Date: Sat, 13 Jun 2026 12:28:58 -0500 Subject: [PATCH] Filter and tokens update --- db.py | 24 +++-- main.py | 10 +- routes/cards.py | 6 +- routes/decks.py | 92 ++++++++++------- static/index.html | 103 +++++++++++++++---- worker.py | 257 ++++++++++++++++++++++++++++++---------------- 6 files changed, 338 insertions(+), 154 deletions(-) diff --git a/db.py b/db.py index c145203..1018cdb 100644 --- a/db.py +++ b/db.py @@ -12,6 +12,12 @@ def conn(): return c +def _add_column(c, table, col, decl): + cols = [r[1] for r in c.execute(f"PRAGMA table_info({table})").fetchall()] + if col not in cols: + c.execute(f"ALTER TABLE {table} ADD COLUMN {col} {decl}") + + def init(): DB_PATH.parent.mkdir(parents=True, exist_ok=True) with conn() as c: @@ -26,13 +32,15 @@ def init(): total INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS cards ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - deck_slug TEXT NOT NULL REFERENCES decks(slug) ON DELETE CASCADE, - name TEXT NOT NULL, - position INTEGER NOT NULL, - filename TEXT, - price_usd REAL DEFAULT 0, - fetch_status TEXT DEFAULT 'pending' + id INTEGER PRIMARY KEY AUTOINCREMENT, + deck_slug TEXT NOT NULL REFERENCES decks(slug) ON DELETE CASCADE, + name TEXT NOT NULL, + position INTEGER NOT NULL, + filename TEXT, + back_filename TEXT, + scry_url TEXT, + price_usd REAL DEFAULT 0, + fetch_status TEXT DEFAULT 'pending' ); CREATE TABLE IF NOT EXISTS logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -40,6 +48,8 @@ def init(): line TEXT NOT NULL ); """) + _add_column(c, "cards", "back_filename", "TEXT") + _add_column(c, "cards", "scry_url", "TEXT") def get_deck(slug: str): diff --git a/main.py b/main.py index ddee57e..d9ced66 100644 --- a/main.py +++ b/main.py @@ -82,6 +82,14 @@ app.include_router(cards_router, dependencies=[Depends(auth)]) app.include_router(public_router) # no auth — TTS app fetches card images directly +@app.get("/decks/{slug}/tts.json") +async def tts_json(slug: str): + path = OUTPUTS / slug / "deck.tts.json" + if not path.exists(): + raise HTTPException(404, "TTS file not found") + return FileResponse(path, media_type="application/json") + + @app.get("/") async def index(_=Depends(auth)): return FileResponse("static/index.html") @@ -92,4 +100,4 @@ async def card_back(): if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=PORT) + uvicorn.run(app, host="0.0.0.0", port=PORT) \ No newline at end of file diff --git a/routes/cards.py b/routes/cards.py index 3c5ec60..080c1bc 100644 --- a/routes/cards.py +++ b/routes/cards.py @@ -33,6 +33,9 @@ async def deck_cards(slug: str): "position": c["position"], "filename": c["filename"], "exists": bool(c["filename"] and (OUTPUTS / slug / c["filename"]).exists()), + "back_filename": c["back_filename"], + "back_exists": bool(c["back_filename"] and (OUTPUTS / slug / c["back_filename"]).exists()), + "scry_url": c["scry_url"], "fetch_status": c["fetch_status"], "price_usd": c["price_usd"], } @@ -94,6 +97,7 @@ async def search_cards(q: str = ""): "set_name": card.get("set_name", ""), "price": card.get("prices", {}).get("usd"), "image": img, + "scry_url": card.get("scryfall_uri", "").split("?")[0], }) return results @@ -106,4 +110,4 @@ async def serve_card(slug: str, filename: str): p = OUTPUTS / slug / filename if not p.exists(): raise HTTPException(404) - return FileResponse(p, media_type="image/png") + return FileResponse(p, media_type="image/png") \ No newline at end of file diff --git a/routes/decks.py b/routes/decks.py index ffca780..a290531 100644 --- a/routes/decks.py +++ b/routes/decks.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, HTTPException, Request import db import worker from config import OUTPUTS +from worker import is_scry_url router = APIRouter() @@ -20,6 +21,13 @@ def valid_slug(slug: str) -> str: return slug +def line_to_entry(line: str) -> dict: + line = line.strip() + if is_scry_url(line): + return {"name": "", "scry_url": line} + return {"name": line, "scry_url": None} + + @router.get("/decks") async def list_decks(): return [dict(d) for d in db.get_decks()] @@ -40,20 +48,28 @@ async def create_deck(request: Request): name = body.get("deck_name", "").strip() card_text = body.get("cards", "").strip() commander = body.get("commander", "").strip() - if not name or not card_text: - raise HTTPException(400, "deck_name and cards required") + is_tokens = bool(body.get("is_tokens")) + + if not name: + raise HTTPException(400, "deck_name required") + + entries = [line_to_entry(l) for l in card_text.splitlines() if l.strip()] + if not is_tokens and not entries: + raise HTTPException(400, "cards required") + + if is_tokens: + commander = "" + else: + if not commander: + commander = next((e["name"] for e in entries if e["name"]), "") + if commander: + idx = next((i for i, e in enumerate(entries) if e["name"] == commander), None) + if idx is not None: + entries.insert(0, entries.pop(idx)) + else: + entries.insert(0, {"name": commander, "scry_url": None}) slug = slugify(name) - card_names = [l.strip() for l in card_text.splitlines() if l.strip()] - if not card_names: - raise HTTPException(400, "no cards") - if not commander: - commander = card_names[0] - - # Commander always at position 0; remove any existing occurrence first - card_names = [n for n in card_names if n != commander] - card_names.insert(0, commander) - existing = db.get_deck(slug) if existing and existing["status"] in ("queued", "running"): raise HTTPException(409, "deck already processing") @@ -61,14 +77,14 @@ async def create_deck(request: Request): with db.conn() as c: c.execute( "INSERT OR REPLACE INTO decks (slug, deck_name, status, commander, price_usd, done, total) VALUES (?,?,?,?,0,0,?)", - (slug, name, "queued", commander, len(card_names)) + (slug, name, "queued", commander or None, len(entries)) ) c.execute("DELETE FROM cards WHERE deck_slug=?", (slug,)) c.execute("DELETE FROM logs WHERE deck_slug=?", (slug,)) - for i, card_name in enumerate(card_names): + for i, e in enumerate(entries): c.execute( - "INSERT INTO cards (deck_slug, name, position, fetch_status) VALUES (?,?,?,'pending')", - (slug, card_name, i + 1) + "INSERT INTO cards (deck_slug, name, position, scry_url, fetch_status) VALUES (?,?,?,?,'pending')", + (slug, e["name"], i + 1, e["scry_url"]) ) await worker.queue.put(slug) @@ -86,41 +102,45 @@ async def edit_deck(slug: str, request: Request): body = await request.json() remove_ids: list[int] = body.get("remove", []) - add_names: list[str] = [n.strip() for n in body.get("add", []) if n.strip()] + add_entries = [line_to_entry(n) for n in body.get("add", []) if n.strip()] new_name: str = body.get("deck_name", deck["deck_name"]).strip() or deck["deck_name"] - new_commander: str = body.get("commander", deck["commander"]).strip() or deck["commander"] + + raw_cmd = body.get("commander", None) + if raw_cmd is not None: + new_commander = raw_cmd.strip() or (deck["commander"] or None) + else: + new_commander = deck["commander"] with db.conn() as c: for cid in remove_ids: row = c.execute( - "SELECT filename FROM cards WHERE id=? AND deck_slug=?", (cid, slug) + "SELECT filename, back_filename FROM cards WHERE id=? AND deck_slug=?", (cid, slug) ).fetchone() - if row and row["filename"]: - p = OUTPUTS / slug / row["filename"] - if p.exists(): - p.unlink() + if row: + for fn in (row["filename"], row["back_filename"]): + if fn and (OUTPUTS / slug / fn).exists(): + (OUTPUTS / slug / fn).unlink() c.execute("DELETE FROM cards WHERE id=? AND deck_slug=?", (cid, slug)) max_pos = c.execute( "SELECT COALESCE(MAX(position), 0) FROM cards WHERE deck_slug=?", (slug,) ).fetchone()[0] - for i, card_name in enumerate(add_names): + for i, e in enumerate(add_entries): c.execute( - "INSERT INTO cards (deck_slug, name, position, fetch_status) VALUES (?,?,?,'pending')", - (slug, card_name, max_pos + i + 1) + "INSERT INTO cards (deck_slug, name, position, scry_url, fetch_status) VALUES (?,?,?,?,'pending')", + (slug, e["name"], max_pos + i + 1, e["scry_url"]) ) - # Ensure commander has a card row; if not, insert at position 0 so it sorts first - existing_commander = c.execute( - "SELECT id FROM cards WHERE deck_slug=? AND name=? LIMIT 1", - (slug, new_commander) - ).fetchone() - if not existing_commander: - c.execute( - "INSERT INTO cards (deck_slug, name, position, fetch_status) VALUES (?,?,0,'pending')", - (slug, new_commander) - ) + if new_commander: + row = c.execute( + "SELECT id FROM cards WHERE deck_slug=? AND name=? LIMIT 1", (slug, new_commander) + ).fetchone() + if not row: + c.execute( + "INSERT INTO cards (deck_slug, name, position, fetch_status) VALUES (?,?,0,'pending')", + (slug, new_commander) + ) c.execute( "UPDATE decks SET deck_name=?, commander=?, status='queued' WHERE slug=?", diff --git a/static/index.html b/static/index.html index 4112ccd..f9602af 100644 --- a/static/index.html +++ b/static/index.html @@ -65,6 +65,12 @@ textarea { resize: vertical; } .card-label { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.82); color: #fff; font-size: 9px; padding: 3px 5px; opacity: 0; transition: opacity 0.15s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .card-tile:hover .card-label { opacity: 1; } .commander-crown { position: absolute; top: 3px; left: 3px; font-size: 13px; line-height: 1; pointer-events: none; text-shadow: 0 1px 3px rgba(0,0,0,0.9); } +.token-toggle { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--muted); margin-bottom: 6px; cursor: pointer; user-select: none; } +.token-toggle input { width: auto; margin: 0; } +.dfc-badge { position: absolute; bottom: 3px; left: 3px; background: rgba(0,0,0,0.78); color: var(--blue); font-size: 10px; line-height: 1; padding: 3px 5px; border-radius: 2px; cursor: pointer; z-index: 2; } +.dfc-badge:hover { background: rgba(20,28,40,0.95); } +.card-price { position: absolute; bottom: 0; right: 0; background: rgba(0,0,0,0.75); color: var(--green); font-size: 9px; padding: 2px 4px; line-height: 1.4; pointer-events: none; } +.card-price.free { color: var(--muted); } .card-overlay-btn { position: absolute; display: none; border: none; cursor: pointer; font-size: 13px; line-height: 1; padding: 3px 5px; border-radius: 2px; } .btn-remove-card { top: 3px; right: 3px; background: rgba(180,0,0,0.8); color: #fff; } .btn-remove-card:hover { background: rgba(220,0,0,0.95); } @@ -126,8 +132,9 @@ textarea { resize: vertical; }
- - + +
+
@@ -149,7 +156,8 @@ textarea { resize: vertical; } - + +
Select a deck or submit a new one.
@@ -171,6 +179,23 @@ let activeDeck = null, sse = null, cardPollTimer = null; let deckData = {}, currentCards = []; let editMode = false, editRemovals = new Set(), editCommander = null; let searchTimer = null; +let sortOrder = "default"; // "default" | "cost-desc" | "cost-asc" + +function toggleSort() { + sortOrder = sortOrder === "default" ? "cost-desc" : sortOrder === "cost-desc" ? "cost-asc" : "default"; + const labels = { "default": "Sort: Default", "cost-desc": "Sort: $ High", "cost-asc": "Sort: $ Low" }; + document.getElementById("sortBtn").textContent = labels[sortOrder]; + if (activeDeck) renderCards(activeDeck, currentCards); +} + +function sortedCards(cards) { + if (sortOrder === "default") return cards; + const sorted = [...cards]; + sorted.sort((a, b) => sortOrder === "cost-desc" + ? (b.price_usd || 0) - (a.price_usd || 0) + : (a.price_usd || 0) - (b.price_usd || 0)); + return sorted; +} async function api(method, path, body) { const r = await fetch(path, { @@ -213,6 +238,8 @@ async function selectDeck(slug) { if (sse) { sse.close(); sse = null; } if (cardPollTimer) { clearInterval(cardPollTimer); cardPollTimer = null; } activeDeck = slug; + sortOrder = "default"; + document.getElementById("sortBtn").textContent = "Sort: Default"; renderList(); const d = deckData[slug]; if (!d) return; @@ -245,6 +272,7 @@ function updateHeader(d) { document.getElementById("editBtn").style.display = terminal && !editMode ? "" : "none"; document.getElementById("saveBtn").style.display = editMode ? "" : "none"; document.getElementById("cancelBtn").style.display = editMode ? "" : "none"; + document.getElementById("sortBtn").style.display = !editMode ? "" : "none"; } async function refreshCards(slug) { @@ -258,7 +286,8 @@ async function refreshCards(slug) { function renderCards(slug, cards) { const area = document.getElementById("cardArea"); - if (!cards.length) { area.innerHTML = '
No cards yet.
'; return; } + const displayCards = sortedCards(cards); + if (!displayCards.length) { area.innerHTML = '
No cards yet.
'; return; } let grid = area.querySelector(".card-grid"); if (!grid) { @@ -270,15 +299,18 @@ function renderCards(slug, cards) { grid.className = "card-grid" + (editMode ? " edit-mode" : ""); } - const commander = editCommander || (deckData[slug] && deckData[slug].commander) || ""; + const isBundle = !(deckData[slug] && deckData[slug].commander); + const commander = isBundle ? "" : (editCommander || deckData[slug].commander || ""); // Sync tiles const existing = Array.from(grid.querySelectorAll(".card-tile, .card-tile.missing")); - cards.forEach((card, i) => { + displayCards.forEach((card, i) => { let tile = existing[i]; - const isCommander = card.name === commander; + const isCommander = !isBundle && card.name === commander; const isRemoved = editRemovals.has(card.id); - const tileKey = card.filename + "|" + card.exists + "|" + isCommander + "|" + isRemoved + "|" + editMode; + const label = cardLabel(card); + const priceStr = card.price_usd > 0 ? `$${card.price_usd.toFixed(2)}` : null; + const tileKey = [card.filename, card.exists, card.back_exists, isCommander, isRemoved, editMode, card.fetch_status, label, (card.price_usd || 0)].join("|"); if (!tile) { tile = document.createElement("div"); grid.appendChild(tile); tile.dataset.key = ""; } @@ -289,22 +321,25 @@ function renderCards(slug, cards) { const imgUrl = `/cards/${slug}/${card.filename}`; tile.className = "card-tile" + (isCommander ? " is-commander" : "") + (isRemoved ? " marked-remove" : ""); tile.innerHTML = ` - ${esc(card.name)} - ${isCommander ? "♛ " : ""}${esc(card.name)} + ${esc(label)} + ${isCommander ? "♛ " : ""}${esc(label)} ${isCommander ? `` : ""} + ${card.back_exists ? `
` : ""} + ${priceStr ? `${priceStr}` : `$0`} - ${!isCommander ? `` : ""} + ${!isCommander && !isBundle ? `` : ""} `; } else { + const hint = card.fetch_status === "failed" ? " — failed" : " …"; tile.className = "card-tile missing" + (isRemoved ? " marked-remove" : ""); tile.innerHTML = ` - ${esc(card.name)} + ${esc(label)}${hint} `; } }); - while (grid.children.length > cards.length) grid.removeChild(grid.lastChild); + while (grid.children.length > displayCards.length) grid.removeChild(grid.lastChild); // Edit add section let addSection = area.querySelector(".edit-add-section"); @@ -319,7 +354,7 @@ function renderCards(slug, cards) {
Bulk Add (one per line)
- +
`; area.appendChild(addSection); @@ -369,7 +404,7 @@ async function saveEdit() { const deck = deckData[activeDeck]; const body = { deck_name: deck.deck_name, - commander: editCommander || deck.commander, + commander: deck.commander ? (editCommander || deck.commander) : null, remove: [...editRemovals], add: addNames, }; @@ -494,14 +529,16 @@ document.addEventListener("keydown", e => { if (e.key === "Escape") closeLightbo async function submitDeck() { const name = document.getElementById("deckName").value.trim(); const cards = document.getElementById("cardList").value.trim(); + const isTokens = document.getElementById("isTokens").checked; const commander = document.getElementById("deckCommander").value.trim(); const err = document.getElementById("submitError"); err.style.display = "none"; - if (!name || !cards) { err.textContent = "Need a name and card list."; err.style.display = ""; return; } + if (!name) { err.textContent = "Need a name."; err.style.display = ""; return; } + if (!isTokens && !cards) { err.textContent = "Need a card list (or check Token bundle)."; err.style.display = ""; return; } const btn = document.getElementById("submitBtn"); btn.disabled = true; try { - const r = await api("POST", "/decks", { deck_name: name, cards, commander }); + const r = await api("POST", "/decks", { deck_name: name, cards, commander, is_tokens: isTokens }); clearForm(); await loadDecks(); selectDeck(r.slug); @@ -511,8 +548,15 @@ async function submitDeck() { } finally { btn.disabled = false; } } +function onTokenToggle() { + const on = document.getElementById("isTokens").checked; + document.getElementById("commanderWrap").style.display = on ? "none" : ""; +} + function clearForm() { ["deckName","deckCommander","cardList"].forEach(id => document.getElementById(id).value = ""); + document.getElementById("isTokens").checked = false; + onTokenToggle(); document.getElementById("submitError").style.display = "none"; } @@ -539,14 +583,35 @@ async function deleteDeck() { } catch (e) { alert(e.message); } } -function downloadTTS() { if (activeDeck) window.location.href = `/decks/${activeDeck}/tts`; } +async function copyTTSUrl() { + if (!activeDeck) return; + const url = `${window.location.origin}/decks/${activeDeck}/tts.json`; + try { + await navigator.clipboard.writeText(url); + const btn = document.getElementById("dlBtn"); + const orig = btn.textContent; + btn.textContent = "Copied!"; + setTimeout(() => btn.textContent = orig, 1500); + } catch { + prompt("Copy this URL:", url); + } +} function esc(s) { return String(s).replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); } +function cardLabel(card) { + if (card.name) return card.name; + if (card.scry_url) { + const tail = (card.scry_url.split("/card/")[1] || "").replace(/\/$/, "").split("/"); + if (tail.length >= 2) return `${tail[0]} · ${tail[1]}`; + } + return "card"; +} + setInterval(loadDecks, 5000); loadDecks(); - + \ No newline at end of file diff --git a/worker.py b/worker.py index 13b9563..5f2db39 100644 --- a/worker.py +++ b/worker.py @@ -2,7 +2,7 @@ import asyncio import json import re import shutil -from urllib.parse import quote +from urllib.parse import quote, unquote, urlparse import httpx @@ -16,6 +16,19 @@ def safe_filename(name: str) -> str: return re.sub(r"[^a-z0-9_]", "", name.lower().replace(" ", "_")) +def is_scry_url(s: str) -> bool: + s = s.strip().lower() + return s.startswith("http") and "scryfall.com/card/" in s + + +def parse_scry_url(url: str) -> tuple[str, str] | None: + # https://scryfall.com/card/{set}/{number}[/{lang}]/{name-slug} + parts = [p for p in urlparse(url.strip()).path.split("/") if p] + if len(parts) >= 3 and parts[0] == "card": + return parts[1], unquote(parts[2]) + return None + + async def _scryfall_get(client: httpx.AsyncClient, url: str, slug: str) -> httpx.Response: for attempt in range(4): r = await client.get(url) @@ -28,48 +41,68 @@ async def _scryfall_get(client: httpx.AsyncClient, url: str, slug: str) -> httpx return r -async def fetch_card_data(client: httpx.AsyncClient, name: str, slug: str) -> tuple[bytes, float]: +async def _fetch_bytes(client: httpx.AsyncClient, url: str, slug: str) -> bytes: + r = await _scryfall_get(client, url, slug) + r.raise_for_status() + return r.content + + +async def resolve_card(client: httpx.AsyncClient, slug: str, name: str, scry_url: str | None) -> dict: + if scry_url: + parsed = parse_scry_url(scry_url) + if not parsed: + raise ValueError("unrecognized scryfall url") + setcode, num = parsed + url = f"https://api.scryfall.com/cards/{quote(setcode)}/{quote(num)}" + r = await _scryfall_get(client, url, slug) + r.raise_for_status() + return r.json() + for param in ("exact", "fuzzy"): url = f"https://api.scryfall.com/cards/named?{param}={quote(name.strip())}" r = await _scryfall_get(client, url, slug) if r.status_code == 200: - break + return r.json() if param == "fuzzy": r.raise_for_status() + raise ValueError("not found") - data = r.json() - price = float(data.get("prices", {}).get("usd") or 0) +def extract_images(data: dict) -> tuple[str, str | None]: + # Single image when the root carries image_uris (normal, split, flip, meld parts). + # Two distinct faces (transform / modal_dfc / double_faced_token / reversible) + # carry per-face image_uris and no root image_uris. if "image_uris" in data: - img_url = data["image_uris"]["normal"] - elif data.get("card_faces") and data["card_faces"][0].get("image_uris"): - img_url = data["card_faces"][0]["image_uris"]["normal"] - else: + return data["image_uris"]["normal"], None + faces = data.get("card_faces") or [] + front = faces[0]["image_uris"]["normal"] if faces and faces[0].get("image_uris") else None + back = faces[1]["image_uris"]["normal"] if len(faces) >= 2 and faces[1].get("image_uris") else None + if not front: raise ValueError("no image_uris in response") - - img_r = await _scryfall_get(client, img_url, slug) - img_r.raise_for_status() - return img_r.content, price + return front, back def build_tts(slug: str) -> dict: deck = db.get_deck(slug) cards = db.get_cards(slug) - commander_name = deck["commander"] + commander_name = (deck["commander"] or "").strip() - done_cards = [c for c in cards if c["fetch_status"] == "done" and c["filename"]] - if not done_cards: + done = [c for c in cards if c["fetch_status"] == "done" and c["filename"]] + if not done: raise ValueError("no successfully fetched cards") - commander = next((c for c in done_cards if c["name"] == commander_name), done_cards[0]) - rest = [c for c in done_cards if c["id"] != commander["id"]] + def card_url(fn: str) -> str: + return f"{BASE_URL}/cards/{slug}/{fn}" - def card_url(filename: str) -> str: - return f"{BASE_URL}/cards/{slug}/{filename}" - - def custom_entry(url: str) -> dict: + def entry(c) -> dict: + if c["back_filename"]: + return { + "FaceURL": card_url(c["filename"]), "BackURL": card_url(c["back_filename"]), + "NumWidth": 1, "NumHeight": 1, + "BackIsHidden": False, "UniqueBack": True, "Type": 0, + } return { - "FaceURL": url, "BackURL": MTG_BACK, + "FaceURL": card_url(c["filename"]), "BackURL": MTG_BACK, "NumWidth": 1, "NumHeight": 1, "BackIsHidden": True, "UniqueBack": False, "Type": 0, } @@ -85,40 +118,56 @@ def build_tts(slug: str) -> dict: "LuaScript": "", "LuaScriptState": "", "XmlUI": "", } - commander_obj = { - "Name": "Card", "Transform": transform(x=-5.0, rz=0.0), - "Nickname": commander["name"], **base, - "CardID": 100, - "CustomDeck": {"1": custom_entry(card_url(commander["filename"]))}, - } + k = 0 - deck_ids, custom_deck, contained = [], {}, [] - for i, card in enumerate(rest): - key = str(i + 2) - cid = (i + 2) * 100 - entry = custom_entry(card_url(card["filename"])) - custom_deck[key] = entry - deck_ids.append(cid) - contained.append({ - "Name": "Card", "Transform": transform(), "Nickname": card["name"], - **base, "CardID": cid, "CustomDeck": {key: entry}, + def next_k() -> int: + nonlocal k + k += 1 + return k + + def card_object(c, x=0.0, rz=180.0) -> dict: + n = next_k() + return { + "Name": "Card", "Transform": transform(x=x, rz=rz), + "Nickname": c["name"], **base, "CardID": n * 100, + "CustomDeck": {str(n): entry(c)}, + } + + commander = next((c for c in done if c["name"] == commander_name), None) if commander_name else None + rest = [c for c in done if commander is None or c["id"] != commander["id"]] + + objects = [] + if commander: + objects.append(card_object(commander, x=-5.0, rz=0.0)) + + if len(rest) >= 2: + deck_ids, custom_deck, contained = [], {}, [] + for c in rest: + n = next_k() + e = entry(c) + custom_deck[str(n)] = e + deck_ids.append(n * 100) + contained.append({ + "Name": "Card", "Transform": transform(), "Nickname": c["name"], + **base, "CardID": n * 100, "CustomDeck": {str(n): e}, + }) + objects.append({ + "Name": "Deck", "Transform": transform(), "Nickname": deck["deck_name"], + "Description": "", "Locked": False, "Grid": True, "Snap": True, + "Autoraise": True, "Sticky": True, "Tooltip": True, "GridProjection": False, + "HideWhenFaceDown": True, "Hands": False, "SidewaysCard": False, + "DeckIDs": deck_ids, "CustomDeck": custom_deck, "ContainedObjects": contained, + "LuaScript": "", "LuaScriptState": "", "XmlUI": "", }) - - deck_obj = { - "Name": "Deck", "Transform": transform(), "Nickname": deck["deck_name"], - "Description": "", "Locked": False, "Grid": True, "Snap": True, - "Autoraise": True, "Sticky": True, "Tooltip": True, "GridProjection": False, - "HideWhenFaceDown": True, "Hands": False, "SidewaysCard": False, - "DeckIDs": deck_ids, "CustomDeck": custom_deck, "ContainedObjects": contained, - "LuaScript": "", "LuaScriptState": "", "XmlUI": "", - } + elif rest: + objects.append(card_object(rest[0], rz=0.0)) return { "SaveName": deck["deck_name"], "Date": "", "VersionNumber": "", "GameMode": "Tabletop Simulator", "Gravity": 0.5, "PlayArea": 0.5, "Table": "", "Sky": "", "Note": "", "TabStates": {}, "LuaScript": "", "LuaScriptState": "", "XmlUI": "", - "ObjectStates": [commander_obj, deck_obj], + "ObjectStates": objects, } @@ -150,60 +199,88 @@ async def _process(client: httpx.AsyncClient, slug: str): for card in pending: name = card["name"] card_id = card["id"] - filename = f"{safe_filename(name)}_{card_id}.png" + scry_url = card["scry_url"] + base = safe_filename(name) if name else f"card_{card_id}" + filename = f"{base}_{card_id}.png" + back_filename = f"{base}_{card_id}_back.png" dest = deck_dir / filename - label = f"[{card['position']}] {name}" - - with db.conn() as c: - existing_row = c.execute( - "SELECT filename FROM cards WHERE deck_slug=? AND name=? AND fetch_status='done' AND id!=? LIMIT 1", - (slug, name, card_id) - ).fetchone() - existing_filename = existing_row["filename"] if existing_row else None + label = f"[{card['position']}] {name or scry_url}" if dest.exists(): - db.add_log(slug, f"{label} — SKIP") + bf = back_filename if (deck_dir / back_filename).exists() else None with db.conn() as c: - c.execute("UPDATE cards SET fetch_status='done', filename=? WHERE id=?", (filename, card_id)) - elif existing_filename: - src = deck_dir / existing_filename - if src.exists(): - shutil.copy(src, dest) + price_row = c.execute( + "SELECT price_usd FROM cards WHERE deck_slug=? AND name=? AND fetch_status='done' AND id!=? LIMIT 1", + (slug, name, card_id) + ).fetchone() + price = price_row["price_usd"] if price_row else None + if price is not None: + c.execute("UPDATE cards SET fetch_status='done', filename=?, back_filename=?, price_usd=? WHERE id=?", + (filename, bf, price, card_id)) + else: + c.execute("UPDATE cards SET fetch_status='done', filename=?, back_filename=? WHERE id=?", + (filename, bf, card_id)) + db.add_log(slug, f"{label} — SKIP") + db.recalc_done(slug) + continue + + # Name-based dedup only — URL adds are precise prints and must never be aliased + if not scry_url: + with db.conn() as c: + row = c.execute( + "SELECT filename, back_filename, price_usd FROM cards " + "WHERE deck_slug=? AND name=? AND fetch_status='done' AND id!=? LIMIT 1", + (slug, name, card_id) + ).fetchone() + if row and row["filename"] and (deck_dir / row["filename"]).exists(): + shutil.copy(deck_dir / row["filename"], dest) + bf = None + if row["back_filename"] and (deck_dir / row["back_filename"]).exists(): + shutil.copy(deck_dir / row["back_filename"], deck_dir / back_filename) + bf = back_filename with db.conn() as c: - price_row = c.execute( - "SELECT price_usd FROM cards WHERE deck_slug=? AND name=? AND fetch_status='done' LIMIT 1", - (slug, name) - ).fetchone() - price = price_row["price_usd"] if price_row else 0.0 - c.execute("UPDATE cards SET fetch_status='done', filename=?, price_usd=? WHERE id=?", - (filename, price, card_id)) + c.execute("UPDATE cards SET fetch_status='done', filename=?, back_filename=?, price_usd=? WHERE id=?", + (filename, bf, row["price_usd"], card_id)) db.add_log(slug, f"{label} — COPY") - else: - db.add_log(slug, f"{label} — FAIL: source missing") - with db.conn() as c: - c.execute("UPDATE cards SET fetch_status='failed' WHERE id=?", (card_id,)) - else: - try: - data, price = await fetch_card_data(client, name, slug) - dest.write_bytes(data) - with db.conn() as c: - c.execute("UPDATE cards SET fetch_status='done', filename=?, price_usd=? WHERE id=?", - (filename, price, card_id)) - db.add_log(slug, f"{label} — OK (${price:.2f})") - await asyncio.sleep(0.5) - except Exception as e: - with db.conn() as c: - c.execute("UPDATE cards SET fetch_status='failed' WHERE id=?", (card_id,)) - db.add_log(slug, f"{label} — FAIL: {e}") + db.recalc_done(slug) + continue + + try: + data = await resolve_card(client, slug, name, scry_url) + front_url, back_url = extract_images(data) + price = float(data.get("prices", {}).get("usd") or 0) + resolved_name = name or data.get("name", "") + + dest.write_bytes(await _fetch_bytes(client, front_url, slug)) + bf = None + if back_url: + (deck_dir / back_filename).write_bytes(await _fetch_bytes(client, back_url, slug)) + bf = back_filename + + with db.conn() as c: + c.execute("UPDATE cards SET fetch_status='done', name=?, filename=?, back_filename=?, price_usd=? WHERE id=?", + (resolved_name, filename, bf, price, card_id)) + db.add_log(slug, f"[{card['position']}] {resolved_name} — OK (${price:.2f}){' 2-sided' if back_url else ''}") + await asyncio.sleep(0.5) + except Exception as e: + with db.conn() as c: + c.execute("UPDATE cards SET fetch_status='failed' WHERE id=?", (card_id,)) + db.add_log(slug, f"{label} — FAIL: {e}") db.recalc_done(slug) + all_cards = db.get_cards(slug) + if not all_cards: + db.update_deck(slug, status="complete", price_usd=0) + db.add_log(slug, "Empty bundle — add cards to export.") + return + try: tts = build_tts(slug) (deck_dir / "deck.tts.json").write_text(json.dumps(tts, indent=2)) - total_price = sum(c["price_usd"] for c in db.get_cards(slug)) + total_price = sum(c["price_usd"] for c in all_cards) db.update_deck(slug, status="complete", price_usd=round(total_price, 2)) db.add_log(slug, f"Complete. Deck value: ${total_price:.2f}") except Exception as e: db.update_deck(slug, status="failed") - db.add_log(slug, f"TTS build failed: {e}") + db.add_log(slug, f"TTS build failed: {e}") \ No newline at end of file