Moxfield import

This commit is contained in:
2026-07-10 20:54:39 -05:00
parent f22df0e1d4
commit f18c0c6564
7 changed files with 416 additions and 61 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ RUN pip install --no-cache-dir \
pillow \ pillow \
python-multipart 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 routes/ routes/
COPY static/ static/ COPY static/ static/
+17 -7
View File
@@ -23,13 +23,16 @@ def init():
with conn() as c: with conn() as c:
c.executescript(""" c.executescript("""
CREATE TABLE IF NOT EXISTS decks ( CREATE TABLE IF NOT EXISTS decks (
slug TEXT PRIMARY KEY, slug TEXT PRIMARY KEY,
deck_name TEXT NOT NULL, deck_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued', status TEXT NOT NULL DEFAULT 'queued',
commander TEXT, commander TEXT,
price_usd REAL DEFAULT 0, price_usd REAL DEFAULT 0,
done INTEGER DEFAULT 0, done INTEGER DEFAULT 0,
total 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 ( CREATE TABLE IF NOT EXISTS cards (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -39,6 +42,8 @@ def init():
filename TEXT, filename TEXT,
back_filename TEXT, back_filename TEXT,
scry_url TEXT, scry_url TEXT,
scry_id TEXT,
is_token INTEGER DEFAULT 0,
price_usd REAL DEFAULT 0, price_usd REAL DEFAULT 0,
fetch_status TEXT DEFAULT 'pending' fetch_status TEXT DEFAULT 'pending'
); );
@@ -50,6 +55,11 @@ def init():
""") """)
_add_column(c, "cards", "back_filename", "TEXT") _add_column(c, "cards", "back_filename", "TEXT")
_add_column(c, "cards", "scry_url", "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): 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,
}
+3
View File
@@ -39,6 +39,7 @@ async def deck_cards(slug: str):
"back_filename": c["back_filename"], "back_filename": c["back_filename"],
"back_exists": bool(c["back_filename"] and (OUTPUTS / slug / c["back_filename"]).exists()), "back_exists": bool(c["back_filename"] and (OUTPUTS / slug / c["back_filename"]).exists()),
"scry_url": c["scry_url"], "scry_url": c["scry_url"],
"is_token": bool(c["is_token"]),
"fetch_status": c["fetch_status"], "fetch_status": c["fetch_status"],
"price_usd": c["price_usd"], "price_usd": c["price_usd"],
} }
@@ -52,6 +53,8 @@ async def upload_image(slug: str, name: str = Form(...), file: UploadFile = File
deck = db.get_deck(slug) deck = db.get_deck(slug)
if not deck: if not deck:
raise HTTPException(404) raise HTTPException(404)
if deck["source"] == "moxfield":
raise HTTPException(409, "Moxfield-linked decks are updated by syncing, not manual edits")
name = name.strip() name = name.strip()
if not name: if not name:
raise HTTPException(400, "name required") raise HTTPException(400, "name required")
+135
View File
@@ -1,9 +1,11 @@
import re import re
import shutil import shutil
import httpx
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
import db import db
import moxfield
import worker import worker
from config import OUTPUTS from config import OUTPUTS
from worker import is_scry_url from worker import is_scry_url
@@ -42,6 +44,135 @@ async def get_deck(slug: str):
return {**dict(d), "log": db.get_logs(slug)} 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) @router.post("/decks", status_code=201)
async def create_deck(request: Request): async def create_deck(request: Request):
body = await request.json() body = await request.json()
@@ -73,6 +204,8 @@ async def create_deck(request: Request):
existing = db.get_deck(slug) existing = db.get_deck(slug)
if existing and existing["status"] in ("queued", "running"): if existing and existing["status"] in ("queued", "running"):
raise HTTPException(409, "deck already processing") 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: with db.conn() as c:
c.execute( c.execute(
@@ -97,6 +230,8 @@ async def edit_deck(slug: str, request: Request):
deck = db.get_deck(slug) deck = db.get_deck(slug)
if not deck: if not deck:
raise HTTPException(404) 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"): if deck["status"] in ("queued", "running"):
raise HTTPException(409, "deck is processing") raise HTTPException(409, "deck is processing")
+72 -15
View File
@@ -44,6 +44,10 @@ textarea { resize: vertical; }
.badge-running { color: var(--yellow); background: #1e1a0e; } .badge-running { color: var(--yellow); background: #1e1a0e; }
.badge-queued { color: var(--blue); background: #101622; } .badge-queued { color: var(--blue); background: #101622; }
.badge-failed { color: var(--red); background: #1e1010; } .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 */ /* Main panel */
.panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; } .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="sidebar">
<div class="section"> <div class="section">
<div class="section-label">New Deck</div> <div class="section-label">New Deck</div>
<input id="deckName" placeholder="Deck name" /> <div class="row mode-toggle" style="margin-top:0">
<label class="token-toggle"><input type="checkbox" id="isTokens" onchange="onTokenToggle()" /> Token bundle (no commander)</label> <button class="btn active" id="modeManualBtn" onclick="setCreateMode('manual')" style="flex:1">Manual</button>
<div id="commanderWrap"><input id="deckCommander" placeholder="Commander (defaults to first card)" /></div> <button class="btn" id="modeMoxBtn" onclick="setCreateMode('moxfield')" style="flex:1">Moxfield</button>
<textarea id="cardList" placeholder="One per line — card name or Scryfall card URL"></textarea> </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"> <div class="row">
<button class="btn btn-green" id="submitBtn" onclick="submitDeck()" style="flex:1">Submit</button> <button class="btn btn-green" id="submitBtn" onclick="submitDeck()" style="flex:1">Submit</button>
<button class="btn" onclick="clearForm()">Clear</button> <button class="btn" onclick="clearForm()">Clear</button>
@@ -151,9 +165,11 @@ textarea { resize: vertical; }
<div class="progress-bar"><div class="progress-fill" id="progressFill" style="width:0"></div></div> <div class="progress-bar"><div class="progress-fill" id="progressFill" style="width:0"></div></div>
<div class="deck-header" id="deckHeader" style="display:none"> <div class="deck-header" id="deckHeader" style="display:none">
<span class="deck-header-title" id="headerTitle"></span> <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="deck-header-meta" id="headerMeta"></span>
<span class="price-tag" id="priceTag"></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="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 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="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" id="sortBtn" onclick="toggleSort()" style="display:none">Sort: Default</button>
@@ -261,20 +277,38 @@ async function selectDeck(slug) {
function updateHeader(d) { function updateHeader(d) {
document.getElementById("headerTitle").textContent = d.deck_name; 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); const pct = d.total > 0 ? d.done / d.total * 100 : (d.status === "complete" ? 100 : 0);
document.getElementById("progressFill").style.width = pct + "%"; document.getElementById("progressFill").style.width = pct + "%";
document.getElementById("headerMeta").textContent = d.total > 0 ? `${d.done}/${d.total}` : ""; document.getElementById("headerMeta").textContent = d.total > 0 ? `${d.done}/${d.total}` : "";
document.getElementById("priceTag").textContent = d.price_usd > 0 ? `$${d.price_usd.toFixed(2)}` : ""; document.getElementById("priceTag").textContent = d.price_usd > 0 ? `$${d.price_usd.toFixed(2)}` : "";
const complete = d.status === "complete"; const complete = d.status === "complete";
const terminal = complete || d.status === "failed"; const terminal = complete || d.status === "failed";
const isMox = d.source === "moxfield";
document.getElementById("dlBtn").style.display = complete && !editMode ? "" : "none"; document.getElementById("dlBtn").style.display = complete && !editMode ? "" : "none";
document.getElementById("delBtn").style.display = terminal && !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("saveBtn").style.display = editMode ? "" : "none";
document.getElementById("cancelBtn").style.display = editMode ? "" : "none"; document.getElementById("cancelBtn").style.display = editMode ? "" : "none";
document.getElementById("sortBtn").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) { async function refreshCards(slug) {
if (activeDeck !== slug) return; if (activeDeck !== slug) return;
try { try {
@@ -310,7 +344,7 @@ function renderCards(slug, cards) {
const isRemoved = editRemovals.has(card.id); const isRemoved = editRemovals.has(card.id);
const label = cardLabel(card); const label = cardLabel(card);
const priceStr = card.price_usd > 0 ? `$${card.price_usd.toFixed(2)}` : null; 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 = ""; } if (!tile) { tile = document.createElement("div"); grid.appendChild(tile); tile.dataset.key = ""; }
@@ -324,6 +358,7 @@ function renderCards(slug, cards) {
<img src="${imgUrl}" loading="lazy" alt="${esc(label)}" onclick="showLightbox('${imgUrl}')"> <img src="${imgUrl}" loading="lazy" alt="${esc(label)}" onclick="showLightbox('${imgUrl}')">
<span class="card-label">${isCommander ? "♛ " : ""}${esc(label)}</span> <span class="card-label">${isCommander ? "♛ " : ""}${esc(label)}</span>
${isCommander ? `<span class="commander-crown">♛</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>` : ""} ${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>`} ${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> <button class="card-overlay-btn btn-remove-card" onclick="toggleRemove(${card.id})">✕</button>
@@ -561,19 +596,40 @@ function showLightbox(url) {
function closeLightbox() { document.getElementById("lightbox").classList.remove("open"); } function closeLightbox() { document.getElementById("lightbox").classList.remove("open"); }
document.addEventListener("keydown", e => { if (e.key === "Escape") closeLightbox(); }); 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() { 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"); const err = document.getElementById("submitError");
err.style.display = "none"; 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"); const btn = document.getElementById("submitBtn");
btn.disabled = true;
let r;
try { 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(); clearForm();
await loadDecks(); await loadDecks();
selectDeck(r.slug); selectDeck(r.slug);
@@ -589,8 +645,9 @@ function onTokenToggle() {
} }
function clearForm() { 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("isTokens").checked = false;
document.getElementById("moxIncludeTokens").checked = false;
onTokenToggle(); onTokenToggle();
document.getElementById("submitError").style.display = "none"; document.getElementById("submitError").style.display = "none";
} }
+76 -38
View File
@@ -47,7 +47,15 @@ async def _fetch_bytes(client: httpx.AsyncClient, url: str, slug: str) -> bytes:
return r.content 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)
r.raise_for_status()
return r.json()
if scry_url: if scry_url:
parsed = parse_scry_url(scry_url) parsed = parse_scry_url(scry_url)
if not parsed: if not parsed:
@@ -85,7 +93,7 @@ def extract_images(data: dict) -> tuple[str, str | None]:
def build_tts(slug: str) -> dict: def build_tts(slug: str) -> dict:
deck = db.get_deck(slug) deck = db.get_deck(slug)
cards = db.get_cards(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"]] done = [c for c in cards if c["fetch_status"] == "done" and c["filename"]]
if not done: if not done:
@@ -133,34 +141,53 @@ def build_tts(slug: str) -> dict:
"CustomDeck": {str(n): entry(c)}, "CustomDeck": {str(n): entry(c)},
} }
commander = next((c for c in done if c["name"] == commander_name), None) if commander_name else None def build_pile(pile_cards: list, nickname: str, x: float) -> dict | None:
rest = [c for c in done if commander is None or c["id"] != commander["id"]] 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 = [] objects = []
if commander: for i, cmd in enumerate(commanders):
objects.append(card_object(commander, x=-5.0, rz=0.0)) objects.append(card_object(cmd, x=-5.0 - i * 2.5, rz=0.0))
if len(rest) >= 2: main_pile = build_pile(rest, deck["deck_name"], x=0.0)
deck_ids, custom_deck, contained = [], {}, [] if main_pile:
for c in rest: objects.append(main_pile)
n = next_k()
e = entry(c) token_pile = build_pile(token_cards, f"{deck['deck_name']} — Tokens", x=6.0)
custom_deck[str(n)] = e if token_pile:
deck_ids.append(n * 100) objects.append(token_pile)
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 { return {
"SaveName": deck["deck_name"], "Date": "", "VersionNumber": "", "SaveName": deck["deck_name"], "Date": "", "VersionNumber": "",
@@ -200,19 +227,31 @@ async def _process(client: httpx.AsyncClient, slug: str):
name = card["name"] name = card["name"]
card_id = card["id"] card_id = card["id"]
scry_url = card["scry_url"] scry_url = card["scry_url"]
scry_id = card["scry_id"]
base = safe_filename(name) if name else f"card_{card_id}" base = safe_filename(name) if name else f"card_{card_id}"
filename = f"{base}_{card_id}.png" filename = f"{base}_{card_id}.png"
back_filename = f"{base}_{card_id}_back.png" back_filename = f"{base}_{card_id}_back.png"
dest = deck_dir / filename 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(): if dest.exists():
bf = back_filename if (deck_dir / back_filename).exists() else None bf = back_filename if (deck_dir / back_filename).exists() else None
with db.conn() as c: with db.conn() as c:
price_row = c.execute( price_row = None
"SELECT price_usd FROM cards WHERE deck_slug=? AND name=? AND fetch_status='done' AND id!=? LIMIT 1", if dedup_col:
(slug, name, card_id) price_row = c.execute(
).fetchone() 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 price = price_row["price_usd"] if price_row else None
if price is not None: if price is not None:
c.execute("UPDATE cards SET fetch_status='done', filename=?, back_filename=?, price_usd=? WHERE id=?", c.execute("UPDATE cards SET fetch_status='done', filename=?, back_filename=?, price_usd=? WHERE id=?",
@@ -224,13 +263,12 @@ async def _process(client: httpx.AsyncClient, slug: str):
db.recalc_done(slug) db.recalc_done(slug)
continue continue
# Name-based dedup only — URL adds are precise prints and must never be aliased if dedup_col:
if not scry_url:
with db.conn() as c: with db.conn() as c:
row = c.execute( row = c.execute(
"SELECT filename, back_filename, price_usd FROM cards " f"SELECT filename, back_filename, price_usd FROM cards "
"WHERE deck_slug=? AND name=? AND fetch_status='done' AND id!=? LIMIT 1", f"WHERE deck_slug=? AND {dedup_col}=? AND fetch_status='done' AND id!=? LIMIT 1",
(slug, name, card_id) (slug, dedup_val, card_id)
).fetchone() ).fetchone()
if row and row["filename"] and (deck_dir / row["filename"]).exists(): if row and row["filename"] and (deck_dir / row["filename"]).exists():
shutil.copy(deck_dir / row["filename"], dest) shutil.copy(deck_dir / row["filename"], dest)
@@ -246,7 +284,7 @@ async def _process(client: httpx.AsyncClient, slug: str):
continue continue
try: 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) front_url, back_url = extract_images(data)
price = float(data.get("prices", {}).get("usd") or 0) price = float(data.get("prices", {}).get("usd") or 0)
resolved_name = name or data.get("name", "") resolved_name = name or data.get("name", "")