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
+76 -38
View File
@@ -47,7 +47,15 @@ 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)
r.raise_for_status()
return r.json()
if scry_url:
parsed = parse_scry_url(scry_url)
if not parsed:
@@ -85,7 +93,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 +141,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 +227,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 +263,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 +284,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", "")