Filter and tokens update

This commit is contained in:
2026-06-13 12:28:58 -05:00
parent 1c4659c1ae
commit 593a6756f6
6 changed files with 338 additions and 154 deletions
+10
View File
@@ -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:
@@ -31,6 +37,8 @@ def init():
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'
);
@@ -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):
+8
View File
@@ -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")
+4
View File
@@ -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
+51 -31
View File
@@ -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,37 +102,41 @@ 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)
if new_commander:
row = c.execute(
"SELECT id FROM cards WHERE deck_slug=? AND name=? LIMIT 1", (slug, new_commander)
).fetchone()
if not existing_commander:
if not row:
c.execute(
"INSERT INTO cards (deck_slug, name, position, fetch_status) VALUES (?,?,0,'pending')",
(slug, new_commander)
+83 -18
View File
@@ -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; }
<div class="section">
<div class="section-label">New Deck</div>
<input id="deckName" placeholder="Deck name" />
<input id="deckCommander" placeholder="Commander (defaults to first card)" />
<textarea id="cardList" placeholder="Paste card list — one per line"></textarea>
<label class="token-toggle"><input type="checkbox" id="isTokens" onchange="onTokenToggle()" /> Token bundle (no commander)</label>
<div id="commanderWrap"><input id="deckCommander" placeholder="Commander (defaults to first card)" /></div>
<textarea id="cardList" placeholder="One per line — card name or Scryfall card URL"></textarea>
<div class="row">
<button class="btn btn-green" id="submitBtn" onclick="submitDeck()" style="flex:1">Submit</button>
<button class="btn" onclick="clearForm()">Clear</button>
@@ -149,7 +156,8 @@ textarea { resize: vertical; }
<button class="btn btn-gold" id="editBtn" onclick="enterEditMode()" style="display:none">Edit</button>
<button class="btn btn-green" id="saveBtn" onclick="saveEdit()" style="display:none">Save</button>
<button class="btn" id="cancelBtn" onclick="exitEditMode(true)" style="display:none">Cancel</button>
<button class="btn btn-blue" id="dlBtn" onclick="downloadTTS()" style="display:none">&#8595; TTS</button>
<button class="btn" id="sortBtn" onclick="toggleSort()" style="display:none">Sort: Default</button>
<button class="btn btn-blue" id="dlBtn" onclick="copyTTSUrl()" style="display:none">Copy URL</button>
<button class="btn btn-red" id="delBtn" onclick="deleteDeck()" style="display:none">Delete</button>
</div>
<div class="card-area" id="cardArea"><div class="placeholder">Select a deck or submit a new one.</div></div>
@@ -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 = '<div class="placeholder">No cards yet.</div>'; return; }
const displayCards = sortedCards(cards);
if (!displayCards.length) { area.innerHTML = '<div class="placeholder">No cards yet.</div>'; 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 = `
<img src="${imgUrl}" loading="lazy" alt="${esc(card.name)}" onclick="showLightbox('${imgUrl}')">
<span class="card-label">${isCommander ? "♛ " : ""}${esc(card.name)}</span>
<img src="${imgUrl}" loading="lazy" alt="${esc(label)}" onclick="showLightbox('${imgUrl}')">
<span class="card-label">${isCommander ? "♛ " : ""}${esc(label)}</span>
${isCommander ? `<span class="commander-crown">♛</span>` : ""}
${card.back_exists ? `<div class="dfc-badge" title="flip side" onclick="event.stopPropagation();showLightbox('/cards/${slug}/${esc(card.back_filename)}')">⇄</div>` : ""}
${priceStr ? `<span class="card-price">${priceStr}</span>` : `<span class="card-price free">$0</span>`}
<button class="card-overlay-btn btn-remove-card" onclick="toggleRemove(${card.id})">✕</button>
${!isCommander ? `<button class="card-overlay-btn btn-set-commander" onclick="setCommander('${esc(card.name)}')">♛</button>` : ""}
${!isCommander && !isBundle ? `<button class="card-overlay-btn btn-set-commander" onclick="setCommander('${esc(card.name).replace(/'/g,"\\'")}')">♛</button>` : ""}
`;
} else {
const hint = card.fetch_status === "failed" ? " — failed" : " …";
tile.className = "card-tile missing" + (isRemoved ? " marked-remove" : "");
tile.innerHTML = `
${esc(card.name)}
${esc(label)}${hint}
<button class="card-overlay-btn btn-remove-card" onclick="toggleRemove(${card.id})">✕</button>
`;
}
});
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) {
<div class="search-results" id="searchResults"></div>
</div>
<div class="section-label" style="margin-top:12px">Bulk Add (one per line)</div>
<textarea id="bulkAddArea" placeholder="Paste card names..."></textarea>
<textarea id="bulkAddArea" placeholder="Card names or Scryfall card URLs — one per line"></textarea>
<div class="row"><button class="btn btn-green" onclick="saveEdit()" style="margin-top:4px">Save Changes</button></div>
`;
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,12 +583,33 @@ 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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
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();
</script>
+151 -74
View File
@@ -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": url, "BackURL": MTG_BACK,
"FaceURL": card_url(c["filename"]), "BackURL": card_url(c["back_filename"]),
"NumWidth": 1, "NumHeight": 1,
"BackIsHidden": False, "UniqueBack": True, "Type": 0,
}
return {
"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
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)},
}
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},
})
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"]]
deck_obj = {
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": "",
}
})
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,46 +199,68 @@ 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")
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)
bf = back_filename if (deck_dir / back_filename).exists() else None
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)
"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 0.0
c.execute("UPDATE cards SET fetch_status='done', filename=?, price_usd=? WHERE id=?",
(filename, price, card_id))
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:
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:
db.recalc_done(slug)
continue
try:
data, price = await fetch_card_data(client, name, slug)
dest.write_bytes(data)
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', filename=?, price_usd=? WHERE id=?",
(filename, price, card_id))
db.add_log(slug, f"{label} — OK (${price:.2f})")
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:
@@ -198,10 +269,16 @@ async def _process(client: httpx.AsyncClient, slug: str):
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: