Compare commits
6 Commits
593a6756f6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b2e99a588 | |||
| b2a0b59cbe | |||
| f18c0c6564 | |||
| f22df0e1d4 | |||
| e208e3f6a8 | |||
| 8ee646e3f9 |
+4
-2
@@ -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/
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
Reference in New Issue
Block a user