Moxfield import
This commit is contained in:
+1
-1
@@ -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/
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", "")
|
||||||
|
|||||||
Reference in New Issue
Block a user