Compare commits

...

6 Commits

Author SHA1 Message Date
robviren 8b2e99a588 Copy Deck 2026-07-23 20:28:38 -05:00
robviren b2a0b59cbe Moxfield import fix 2026-07-23 19:24:01 -05:00
robviren f18c0c6564 Moxfield import 2026-07-10 20:54:39 -05:00
robviren f22df0e1d4 Actually fixing it now, stupid build files 2026-06-29 21:34:40 -05:00
robviren e208e3f6a8 Token upload fix 2026-06-29 21:25:35 -05:00
robviren 8ee646e3f9 Token upload 2026-06-29 21:22:42 -05:00
9 changed files with 589 additions and 64 deletions
+4 -2
View File
@@ -9,9 +9,11 @@ RUN pip install --no-cache-dir \
fastapi \
uvicorn \
httpx \
pyyaml
pyyaml \
pillow \
python-multipart
COPY main.py worker.py db.py config.py ./
COPY main.py worker.py db.py config.py moxfield.py ./
COPY routes/ routes/
COPY static/ static/
+17 -7
View File
@@ -23,13 +23,16 @@ def init():
with conn() as c:
c.executescript("""
CREATE TABLE IF NOT EXISTS decks (
slug TEXT PRIMARY KEY,
deck_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
commander TEXT,
price_usd REAL DEFAULT 0,
done INTEGER DEFAULT 0,
total INTEGER DEFAULT 0
slug TEXT PRIMARY KEY,
deck_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
commander TEXT,
price_usd REAL DEFAULT 0,
done INTEGER DEFAULT 0,
total INTEGER DEFAULT 0,
source TEXT NOT NULL DEFAULT 'manual',
moxfield_id TEXT,
include_tokens INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -39,6 +42,8 @@ def init():
filename TEXT,
back_filename TEXT,
scry_url TEXT,
scry_id TEXT,
is_token INTEGER DEFAULT 0,
price_usd REAL DEFAULT 0,
fetch_status TEXT DEFAULT 'pending'
);
@@ -50,6 +55,11 @@ def init():
""")
_add_column(c, "cards", "back_filename", "TEXT")
_add_column(c, "cards", "scry_url", "TEXT")
_add_column(c, "cards", "scry_id", "TEXT")
_add_column(c, "cards", "is_token", "INTEGER DEFAULT 0")
_add_column(c, "decks", "source", "TEXT NOT NULL DEFAULT 'manual'")
_add_column(c, "decks", "moxfield_id", "TEXT")
_add_column(c, "decks", "include_tokens", "INTEGER DEFAULT 0")
def get_deck(slug: str):
+112
View File
@@ -0,0 +1,112 @@
import asyncio
import re
from urllib.parse import urlparse
import httpx
API_BASE = "https://api2.moxfield.com/v3/decks/all"
# A plain "python-httpx/…" UA gets blocked by Moxfield's edge; a normal
# browser UA does not. There's no server-rendered deck data in the page
# HTML to fall back to (it's a client-rendered SPA), so this API call is
# the only viable path — verified by hand against api2.moxfield.com.
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
),
"Accept": "application/json",
}
# Boards that represent physical cards belonging to the deck. Sideboard and
# maybeboard are staging areas, not part of the actual decklist.
DECK_BOARDS = [
"mainboard", "commanders", "companions", "signatureSpells",
"attractions", "contraptions", "planes", "schemes", "stickers",
]
_ID_RE = re.compile(r"^[A-Za-z0-9_-]{6,}$")
def parse_deck_id(text: str) -> str:
text = text.strip()
if not text:
raise ValueError("empty Moxfield URL")
if "moxfield.com" not in text.lower() and _ID_RE.match(text):
return text
parts = [p for p in urlparse(text).path.split("/") if p]
if len(parts) >= 2 and parts[0] == "decks":
return parts[1]
raise ValueError("could not parse a deck id from that Moxfield URL")
async def fetch_deck(client: httpx.AsyncClient, deck_id: str) -> dict:
url = f"{API_BASE}/{deck_id}"
r = None
for attempt in range(4):
r = await client.get(url, headers=HEADERS)
if r.status_code == 200:
return r.json()
if r.status_code == 404:
raise ValueError("deck not found on Moxfield (private, deleted, or wrong id?)")
if r.status_code == 403:
raise ValueError("Moxfield blocked the request (403) — try again in a bit")
if r.status_code in (429, 500, 502, 503, 504):
wait = float(r.headers.get("Retry-After", 2 ** attempt))
await asyncio.sleep(wait)
continue
r.raise_for_status()
r.raise_for_status()
raise ValueError("failed to fetch deck from Moxfield")
def _is_token(card: dict) -> bool:
return card.get("layout") == "token" or (card.get("type_line") or "").startswith("Token")
def extract(deck_json: dict, include_tokens: bool) -> dict:
"""Flatten a Moxfield deck payload into (name, commanders, entries).
Each entry carries a scryfall_id when Moxfield has one on file, which lets
the fetch pipeline pull the exact printing (and both faces of a DFC/MDFC)
straight from Scryfall's /cards/{id} instead of a fuzzy name search.
"""
boards = deck_json.get("boards", {})
commanders: list[str] = []
entries: list[dict] = []
for board_name in DECK_BOARDS:
board = boards.get(board_name) or {}
for row in board.get("cards", {}).values():
card = row.get("card") or {}
name = card.get("name") or ""
scry_id = card.get("scryfall_id")
qty = max(1, int(row.get("quantity") or 1))
if board_name == "commanders" and name:
commanders.append(name)
for _ in range(qty):
entries.append({"name": name, "scry_id": scry_id, "is_token": False})
if include_tokens:
seen_ids = {e["scry_id"] for e in entries if e["scry_id"]}
token_cards: dict[str, dict] = {}
for row in (boards.get("tokens") or {}).get("cards", {}).values():
c = row.get("card") or {}
if c.get("scryfall_id"):
token_cards[c["scryfall_id"]] = c
for c in (deck_json.get("tokenMappings") or {}).values():
if c.get("scryfall_id") and _is_token(c):
token_cards[c["scryfall_id"]] = c
for scry_id, c in token_cards.items():
if scry_id in seen_ids:
continue
entries.append({"name": c.get("name") or "", "scry_id": scry_id, "is_token": True})
return {
"name": deck_json.get("name") or "Moxfield Deck",
"commanders": commanders,
"entries": entries,
}
+2
View File
@@ -7,6 +7,8 @@ requires-python = ">=3.14"
dependencies = [
"fastapi>=0.136.1",
"httpx>=0.28.1",
"pillow>=12.2.0",
"python-multipart>=0.0.32",
"pyyaml>=6.0.3",
"uvicorn[standard]>=0.46.0",
]
+47 -1
View File
@@ -1,13 +1,16 @@
import asyncio
import io
import re
from pathlib import Path
from urllib.parse import quote
import httpx
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from PIL import Image
import db
from worker import safe_filename
from config import OUTPUTS
router = APIRouter()
@@ -36,6 +39,7 @@ async def deck_cards(slug: str):
"back_filename": c["back_filename"],
"back_exists": bool(c["back_filename"] and (OUTPUTS / slug / c["back_filename"]).exists()),
"scry_url": c["scry_url"],
"is_token": bool(c["is_token"]),
"fetch_status": c["fetch_status"],
"price_usd": c["price_usd"],
}
@@ -43,6 +47,48 @@ async def deck_cards(slug: str):
]
@router.post("/decks/{slug}/upload-image", status_code=201)
async def upload_image(slug: str, name: str = Form(...), file: UploadFile = File(...)):
slug = valid_slug(slug)
deck = db.get_deck(slug)
if not deck:
raise HTTPException(404)
if deck["source"] == "moxfield":
raise HTTPException(409, "Moxfield-linked decks are updated by syncing, not manual edits")
name = name.strip()
if not name:
raise HTTPException(400, "name required")
raw = await file.read()
try:
img = Image.open(io.BytesIO(raw))
img.load()
except Exception:
raise HTTPException(400, "unreadable image")
if img.mode != "RGB":
img = img.convert("RGB")
deck_dir = OUTPUTS / slug
deck_dir.mkdir(exist_ok=True)
with db.conn() as c:
max_pos = c.execute(
"SELECT COALESCE(MAX(position), 0) FROM cards WHERE deck_slug=?", (slug,)
).fetchone()[0]
cur = c.execute(
"INSERT INTO cards (deck_slug, name, position, fetch_status, price_usd) VALUES (?,?,?,?,0)",
(slug, name, max_pos + 1, "done")
)
card_id = cur.lastrowid
filename = f"{safe_filename(name)}_{card_id}.png"
img.save(deck_dir / filename, "PNG")
c.execute("UPDATE cards SET filename=? WHERE id=?", (filename, card_id))
c.execute("INSERT INTO logs (deck_slug, line) VALUES (?,?)", (slug, f"[{max_pos + 1}] {name} — UPLOADED"))
db.recalc_done(slug)
return {"id": card_id, "filename": filename}
@router.get("/decks/{slug}/stream")
async def stream(slug: str):
slug = valid_slug(slug)
+135
View File
@@ -1,9 +1,11 @@
import re
import shutil
import httpx
from fastapi import APIRouter, HTTPException, Request
import db
import moxfield
import worker
from config import OUTPUTS
from worker import is_scry_url
@@ -42,6 +44,135 @@ async def get_deck(slug: str):
return {**dict(d), "log": db.get_logs(slug)}
@router.post("/decks/moxfield", status_code=201)
async def create_deck_from_moxfield(request: Request):
body = await request.json()
url = (body.get("url") or "").strip()
include_tokens = bool(body.get("include_tokens"))
if not url:
raise HTTPException(400, "Moxfield URL required")
try:
deck_id = moxfield.parse_deck_id(url)
except ValueError as e:
raise HTTPException(400, str(e))
async with httpx.AsyncClient(timeout=20) as client:
try:
data = await moxfield.fetch_deck(client, deck_id)
except ValueError as e:
raise HTTPException(502, str(e))
parsed = moxfield.extract(data, include_tokens)
if not parsed["entries"]:
raise HTTPException(400, "Moxfield deck has no cards")
name = parsed["name"]
slug = slugify(name)
existing = db.get_deck(slug)
if existing and existing["status"] in ("queued", "running"):
raise HTTPException(409, "deck already processing")
if existing and existing["source"] != "moxfield":
raise HTTPException(409, "a manually-created deck with this name already exists — rename or delete it first")
commander_field = "\n".join(parsed["commanders"]) or None
with db.conn() as c:
c.execute(
"INSERT OR REPLACE INTO decks "
"(slug, deck_name, status, commander, price_usd, done, total, source, moxfield_id, include_tokens) "
"VALUES (?,?,?,?,0,0,?,?,?,?)",
(slug, name, "queued", commander_field, len(parsed["entries"]), "moxfield", deck_id, int(include_tokens))
)
c.execute("DELETE FROM cards WHERE deck_slug=?", (slug,))
c.execute("DELETE FROM logs WHERE deck_slug=?", (slug,))
for i, e in enumerate(parsed["entries"]):
c.execute(
"INSERT INTO cards (deck_slug, name, position, scry_id, is_token, fetch_status) VALUES (?,?,?,?,?,'pending')",
(slug, e["name"], i + 1, e["scry_id"], int(e["is_token"]))
)
c.execute("INSERT INTO logs (deck_slug, line) VALUES (?,?)", (slug, f"Imported from Moxfield ({deck_id})."))
await worker.queue.put(slug)
return {"slug": slug}
@router.post("/decks/{slug}/moxfield-sync")
async def sync_moxfield_deck(slug: str):
slug = valid_slug(slug)
deck = db.get_deck(slug)
if not deck:
raise HTTPException(404)
if deck["source"] != "moxfield" or not deck["moxfield_id"]:
raise HTTPException(400, "not a Moxfield-linked deck")
if deck["status"] in ("queued", "running"):
raise HTTPException(409, "deck is processing")
async with httpx.AsyncClient(timeout=20) as client:
try:
data = await moxfield.fetch_deck(client, deck["moxfield_id"])
except ValueError as e:
raise HTTPException(502, str(e))
parsed = moxfield.extract(data, bool(deck["include_tokens"]))
if not parsed["entries"]:
raise HTTPException(400, "Moxfield deck has no cards")
commander_field = "\n".join(parsed["commanders"]) or None
with db.conn() as c:
existing_rows = c.execute(
"SELECT id, scry_id, filename, back_filename FROM cards WHERE deck_slug=?", (slug,)
).fetchall()
by_scry: dict[str, list] = {}
remove_ids = [row["id"] for row in existing_rows if not row["scry_id"]]
for row in existing_rows:
if row["scry_id"]:
by_scry.setdefault(row["scry_id"], []).append(row)
to_insert = []
for e in parsed["entries"]:
pool = by_scry.get(e["scry_id"]) if e["scry_id"] else None
if pool:
pool.pop(0)
else:
to_insert.append(e)
remove_ids += [row["id"] for rows in by_scry.values() for row in rows]
for rid in remove_ids:
row = c.execute("SELECT filename, back_filename FROM cards WHERE id=?", (rid,)).fetchone()
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=?", (rid,))
max_pos = c.execute(
"SELECT COALESCE(MAX(position), 0) FROM cards WHERE deck_slug=?", (slug,)
).fetchone()[0]
for i, e in enumerate(to_insert):
c.execute(
"INSERT INTO cards (deck_slug, name, position, scry_id, is_token, fetch_status) VALUES (?,?,?,?,?,'pending')",
(slug, e["name"], max_pos + i + 1, e["scry_id"], int(e["is_token"]))
)
c.execute(
"UPDATE decks SET deck_name=?, commander=?, status='queued' WHERE slug=?",
(parsed["name"], commander_field, slug)
)
added, removed = len(to_insert), len(remove_ids)
c.execute(
"INSERT INTO logs (deck_slug, line) VALUES (?,?)",
(slug, f"Synced from Moxfield — +{added} / -{removed}.")
)
db.recalc_done(slug)
await worker.queue.put(slug)
return {"slug": slug, "added": added, "removed": removed}
@router.post("/decks", status_code=201)
async def create_deck(request: Request):
body = await request.json()
@@ -73,6 +204,8 @@ async def create_deck(request: Request):
existing = db.get_deck(slug)
if existing and existing["status"] in ("queued", "running"):
raise HTTPException(409, "deck already processing")
if existing and existing["source"] == "moxfield":
raise HTTPException(409, "a Moxfield-linked deck with this name already exists — sync or delete it first")
with db.conn() as c:
c.execute(
@@ -97,6 +230,8 @@ async def edit_deck(slug: str, request: Request):
deck = db.get_deck(slug)
if not deck:
raise HTTPException(404)
if deck["source"] == "moxfield":
raise HTTPException(409, "Moxfield-linked decks are updated by syncing, not manual edits")
if deck["status"] in ("queued", "running"):
raise HTTPException(409, "deck is processing")
+143 -16
View File
@@ -44,6 +44,10 @@ textarea { resize: vertical; }
.badge-running { color: var(--yellow); background: #1e1a0e; }
.badge-queued { color: var(--blue); background: #101622; }
.badge-failed { color: var(--red); background: #1e1010; }
.badge-source { color: var(--blue); background: #101622; border: 1px solid #1e2e3a; }
.mode-toggle .btn.active { color: var(--green); border-color: #1e3a1e; background: #141e14; }
.token-badge { position: absolute; top: 3px; right: 3px; font-size: 9px; letter-spacing: 0.5px; color: var(--blue); background: rgba(0,0,0,0.78); padding: 2px 5px; border-radius: 2px; pointer-events: none; }
.edit-mode .token-badge { display: none; }
/* Main panel */
.panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
@@ -131,10 +135,20 @@ textarea { resize: vertical; }
<div class="sidebar">
<div class="section">
<div class="section-label">New Deck</div>
<input id="deckName" placeholder="Deck name" />
<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 mode-toggle" style="margin-top:0">
<button class="btn active" id="modeManualBtn" onclick="setCreateMode('manual')" style="flex:1">Manual</button>
<button class="btn" id="modeMoxBtn" onclick="setCreateMode('moxfield')" style="flex:1">Moxfield</button>
</div>
<div id="manualCreateFields" style="margin-top:7px">
<input id="deckName" placeholder="Deck name" />
<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>
<div id="moxfieldCreateFields" style="display:none; margin-top:7px">
<input id="moxUrl" placeholder="Moxfield deck URL or ID" />
<label class="token-toggle"><input type="checkbox" id="moxIncludeTokens" /> Pull tokens too (for TTS)</label>
</div>
<div class="row">
<button class="btn btn-green" id="submitBtn" onclick="submitDeck()" style="flex:1">Submit</button>
<button class="btn" onclick="clearForm()">Clear</button>
@@ -151,13 +165,16 @@ textarea { resize: vertical; }
<div class="progress-bar"><div class="progress-fill" id="progressFill" style="width:0"></div></div>
<div class="deck-header" id="deckHeader" style="display:none">
<span class="deck-header-title" id="headerTitle"></span>
<span class="badge badge-source" id="sourceBadge" style="display:none">MOX</span>
<span class="deck-header-meta" id="headerMeta"></span>
<span class="price-tag" id="priceTag"></span>
<button class="btn btn-gold" id="editBtn" onclick="enterEditMode()" style="display:none">Edit</button>
<button class="btn btn-gold" id="syncBtn" onclick="syncMoxfieldDeck()" style="display:none">Sync</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" 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-blue" id="copyListBtn" onclick="copyMoxfieldList()" style="display:none">Copy List</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>
@@ -261,20 +278,39 @@ async function selectDeck(slug) {
function updateHeader(d) {
document.getElementById("headerTitle").textContent = d.deck_name;
document.getElementById("sourceBadge").style.display = d.source === "moxfield" ? "" : "none";
const pct = d.total > 0 ? d.done / d.total * 100 : (d.status === "complete" ? 100 : 0);
document.getElementById("progressFill").style.width = pct + "%";
document.getElementById("headerMeta").textContent = d.total > 0 ? `${d.done}/${d.total}` : "";
document.getElementById("priceTag").textContent = d.price_usd > 0 ? `$${d.price_usd.toFixed(2)}` : "";
const complete = d.status === "complete";
const terminal = complete || d.status === "failed";
const isMox = d.source === "moxfield";
document.getElementById("dlBtn").style.display = complete && !editMode ? "" : "none";
document.getElementById("copyListBtn").style.display = !editMode ? "" : "none";
document.getElementById("delBtn").style.display = terminal && !editMode ? "" : "none";
document.getElementById("editBtn").style.display = terminal && !editMode ? "" : "none";
document.getElementById("editBtn").style.display = terminal && !editMode && !isMox ? "" : "none";
document.getElementById("syncBtn").style.display = terminal && !editMode && isMox ? "" : "none";
document.getElementById("saveBtn").style.display = editMode ? "" : "none";
document.getElementById("cancelBtn").style.display = editMode ? "" : "none";
document.getElementById("sortBtn").style.display = !editMode ? "" : "none";
}
async function syncMoxfieldDeck() {
if (!activeDeck) return;
const btn = document.getElementById("syncBtn");
btn.disabled = true;
try {
await api("POST", `/decks/${activeDeck}/moxfield-sync`);
await loadDecks();
await selectDeck(activeDeck);
} catch (e) {
alert(e.message);
} finally {
btn.disabled = false;
}
}
async function refreshCards(slug) {
if (activeDeck !== slug) return;
try {
@@ -310,7 +346,7 @@ function renderCards(slug, cards) {
const isRemoved = editRemovals.has(card.id);
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("|");
const tileKey = [card.filename, card.exists, card.back_exists, isCommander, isRemoved, editMode, card.fetch_status, label, (card.price_usd || 0), card.is_token].join("|");
if (!tile) { tile = document.createElement("div"); grid.appendChild(tile); tile.dataset.key = ""; }
@@ -324,6 +360,7 @@ function renderCards(slug, cards) {
<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.is_token ? `<span class="token-badge">TOKEN</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>
@@ -348,7 +385,12 @@ function renderCards(slug, cards) {
addSection = document.createElement("div");
addSection.className = "edit-add-section";
addSection.innerHTML = `
<div class="section-label">Search to Add</div>
<div class="section-label">Upload Image (token / custom card)</div>
<input id="uploadName" placeholder="Card name..." autocomplete="off" />
<input id="uploadFile" type="file" accept="image/*" />
<div class="row"><button class="btn btn-blue" onclick="uploadImage()" style="margin-top:4px">Upload</button></div>
<div id="uploadError" class="error-text" style="display:none"></div>
<div class="section-label" style="margin-top:12px">Search to Add</div>
<div class="search-box">
<input id="searchInput" placeholder="Card name..." oninput="onSearchInput(this.value)" autocomplete="off" />
<div class="search-results" id="searchResults"></div>
@@ -421,6 +463,36 @@ async function saveEdit() {
}
}
async function uploadImage() {
if (!activeDeck) return;
const nameInp = document.getElementById("uploadName");
const fileInp = document.getElementById("uploadFile");
const errEl = document.getElementById("uploadError");
errEl.style.display = "none";
const name = nameInp.value.trim();
const file = fileInp.files[0];
if (!name || !file) {
errEl.textContent = "Name and file required.";
errEl.style.display = "block";
return;
}
const fd = new FormData();
fd.append("name", name);
fd.append("file", file);
try {
const res = await fetch(`/decks/${activeDeck}/upload-image`, { method: "POST", body: fd });
if (!res.ok) throw new Error(await res.text().catch(() => res.statusText));
nameInp.value = "";
fileInp.value = "";
await loadDecks();
await selectDeck(activeDeck);
enterEditMode();
} catch (e) {
errEl.textContent = e.message;
errEl.style.display = "block";
}
}
function onSearchInput(val) {
clearTimeout(searchTimer);
const res = document.getElementById("searchResults");
@@ -526,19 +598,40 @@ function showLightbox(url) {
function closeLightbox() { document.getElementById("lightbox").classList.remove("open"); }
document.addEventListener("keydown", e => { if (e.key === "Escape") closeLightbox(); });
let createMode = "manual";
function setCreateMode(mode) {
createMode = mode;
document.getElementById("manualCreateFields").style.display = mode === "manual" ? "" : "none";
document.getElementById("moxfieldCreateFields").style.display = mode === "moxfield" ? "" : "none";
document.getElementById("modeManualBtn").classList.toggle("active", mode === "manual");
document.getElementById("modeMoxBtn").classList.toggle("active", mode === "moxfield");
document.getElementById("submitError").style.display = "none";
}
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) { 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;
let r;
try {
const r = await api("POST", "/decks", { deck_name: name, cards, commander, is_tokens: isTokens });
if (createMode === "moxfield") {
const url = document.getElementById("moxUrl").value.trim();
const includeTokens = document.getElementById("moxIncludeTokens").checked;
if (!url) { err.textContent = "Need a Moxfield deck URL."; err.style.display = ""; return; }
btn.disabled = true;
r = await api("POST", "/decks/moxfield", { url, include_tokens: includeTokens });
} else {
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();
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; }
btn.disabled = true;
r = await api("POST", "/decks", { deck_name: name, cards, commander, is_tokens: isTokens });
}
clearForm();
await loadDecks();
selectDeck(r.slug);
@@ -554,8 +647,9 @@ function onTokenToggle() {
}
function clearForm() {
["deckName","deckCommander","cardList"].forEach(id => document.getElementById(id).value = "");
["deckName","deckCommander","cardList","moxUrl"].forEach(id => document.getElementById(id).value = "");
document.getElementById("isTokens").checked = false;
document.getElementById("moxIncludeTokens").checked = false;
onTokenToggle();
document.getElementById("submitError").style.display = "none";
}
@@ -597,6 +691,39 @@ async function copyTTSUrl() {
}
}
function buildMoxfieldText() {
const deck = deckData[activeDeck];
if (!deck) return "";
const counts = new Map();
currentCards.forEach(c => {
if (c.is_token) return;
counts.set(c.name, (counts.get(c.name) || 0) + 1);
});
const commanders = (deck.commander || "").split("\n").map(s => s.trim()).filter(Boolean);
const lines = commanders.map(name => {
const left = (counts.get(name) || 1) - 1;
if (left > 0) counts.set(name, left); else counts.delete(name);
return `1 ${name}`;
});
if (commanders.length) lines.push("");
for (const [name, qty] of counts) lines.push(`${qty} ${name}`);
return lines.join("\n");
}
async function copyMoxfieldList() {
if (!activeDeck) return;
const text = buildMoxfieldText();
const btn = document.getElementById("copyListBtn");
try {
await navigator.clipboard.writeText(text);
const orig = btn.textContent;
btn.textContent = "Copied!";
setTimeout(() => btn.textContent = orig, 1500);
} catch {
prompt("Copy this list:", text);
}
}
function esc(s) {
return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
Generated
+46
View File
@@ -39,6 +39,8 @@ source = { virtual = "." }
dependencies = [
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pillow" },
{ name = "python-multipart" },
{ name = "pyyaml" },
{ name = "uvicorn", extra = ["standard"] },
]
@@ -47,6 +49,8 @@ dependencies = [
requires-dist = [
{ name = "fastapi", specifier = ">=0.136.1" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pillow", specifier = ">=12.2.0" },
{ name = "python-multipart", specifier = ">=0.0.32" },
{ name = "pyyaml", specifier = ">=6.0.3" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.46.0" },
]
@@ -158,6 +162,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
]
[[package]]
name = "pillow"
version = "12.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
@@ -223,6 +260,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
+83 -38
View File
@@ -47,7 +47,22 @@ async def _fetch_bytes(client: httpx.AsyncClient, url: str, slug: str) -> bytes:
return r.content
async def resolve_card(client: httpx.AsyncClient, slug: str, name: str, scry_url: str | None) -> dict:
async def resolve_card(
client: httpx.AsyncClient, slug: str, name: str, scry_url: str | None, scry_id: str | None = None
) -> dict:
if scry_id:
url = f"https://api.scryfall.com/cards/{quote(scry_id)}"
r = await _scryfall_get(client, url, slug)
if r.status_code == 200:
return r.json()
if r.status_code != 404 or not name:
r.raise_for_status()
# Moxfield's scryfall_id can go stale (Scryfall periodically deletes/merges
# card ids when it corrects duplicate prints, e.g. basic lands and
# guildgates with many near-identical art variations). Fall back to a
# name lookup rather than failing the whole card.
db.add_log(slug, f" scryfall_id {scry_id} is stale (404) — falling back to name lookup")
if scry_url:
parsed = parse_scry_url(scry_url)
if not parsed:
@@ -85,7 +100,7 @@ def extract_images(data: dict) -> tuple[str, str | None]:
def build_tts(slug: str) -> dict:
deck = db.get_deck(slug)
cards = db.get_cards(slug)
commander_name = (deck["commander"] or "").strip()
commander_names = [n for n in (deck["commander"] or "").splitlines() if n.strip()]
done = [c for c in cards if c["fetch_status"] == "done" and c["filename"]]
if not done:
@@ -133,34 +148,53 @@ def build_tts(slug: str) -> dict:
"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"]]
def build_pile(pile_cards: list, nickname: str, x: float) -> dict | None:
if len(pile_cards) >= 2:
deck_ids, custom_deck, contained = [], {}, []
for c in pile_cards:
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},
})
return {
"Name": "Deck", "Transform": transform(x=x), "Nickname": nickname,
"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": "",
}
if pile_cards:
return card_object(pile_cards[0], x=x, rz=0.0)
return None
token_cards = [c for c in done if c["is_token"]]
playable = [c for c in done if not c["is_token"]]
commanders = []
used_ids = set()
for cname in commander_names:
match = next((c for c in playable if c["name"] == cname and c["id"] not in used_ids), None)
if match:
commanders.append(match)
used_ids.add(match["id"])
rest = [c for c in playable if c["id"] not in used_ids]
objects = []
if commander:
objects.append(card_object(commander, x=-5.0, rz=0.0))
for i, cmd in enumerate(commanders):
objects.append(card_object(cmd, x=-5.0 - i * 2.5, 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))
main_pile = build_pile(rest, deck["deck_name"], x=0.0)
if main_pile:
objects.append(main_pile)
token_pile = build_pile(token_cards, f"{deck['deck_name']} — Tokens", x=6.0)
if token_pile:
objects.append(token_pile)
return {
"SaveName": deck["deck_name"], "Date": "", "VersionNumber": "",
@@ -200,19 +234,31 @@ async def _process(client: httpx.AsyncClient, slug: str):
name = card["name"]
card_id = card["id"]
scry_url = card["scry_url"]
scry_id = card["scry_id"]
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 or scry_url}"
label = f"[{card['position']}] {name or scry_url or scry_id}"
# Dedup key: an exact scryfall id is the most precise match (safe to
# alias across cards even without a name yet); a scry_url is a precise
# print and must never be aliased; otherwise fall back to name.
dedup_col, dedup_val = (None, None)
if scry_id:
dedup_col, dedup_val = "scry_id", scry_id
elif not scry_url:
dedup_col, dedup_val = "name", name
if dest.exists():
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' AND id!=? LIMIT 1",
(slug, name, card_id)
).fetchone()
price_row = None
if dedup_col:
price_row = c.execute(
f"SELECT price_usd FROM cards WHERE deck_slug=? AND {dedup_col}=? AND fetch_status='done' AND id!=? LIMIT 1",
(slug, dedup_val, 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=?",
@@ -224,13 +270,12 @@ async def _process(client: httpx.AsyncClient, slug: str):
db.recalc_done(slug)
continue
# Name-based dedup only — URL adds are precise prints and must never be aliased
if not scry_url:
if dedup_col:
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)
f"SELECT filename, back_filename, price_usd FROM cards "
f"WHERE deck_slug=? AND {dedup_col}=? AND fetch_status='done' AND id!=? LIMIT 1",
(slug, dedup_val, card_id)
).fetchone()
if row and row["filename"] and (deck_dir / row["filename"]).exists():
shutil.copy(deck_dir / row["filename"], dest)
@@ -246,7 +291,7 @@ async def _process(client: httpx.AsyncClient, slug: str):
continue
try:
data = await resolve_card(client, slug, name, scry_url)
data = await resolve_card(client, slug, name, scry_url, scry_id)
front_url, back_url = extract_images(data)
price = float(data.get("prices", {}).get("usd") or 0)
resolved_name = name or data.get("name", "")