161 lines
5.0 KiB
Python
161 lines
5.0 KiB
Python
"""
|
|
Archidekt collection importer.
|
|
|
|
Supports two export formats:
|
|
- CSV (Collection -> Export -> CSV)
|
|
- JSON (Collection -> Export -> JSON)
|
|
|
|
CSV columns (current Archidekt format, as of 2024):
|
|
Quantity, Foil Quantity, Card Name, Set Code, Collector Number, ...
|
|
|
|
JSON format (array of card objects):
|
|
[{"quantity": 1, "foilQuantity": 0, "card": {"name": "...", "set": {"code": "..."}, "collectorNumber": "..."}}]
|
|
"""
|
|
import csv
|
|
import io
|
|
import json
|
|
from typing import Union
|
|
|
|
import chardet
|
|
|
|
from app.services.imports.models import RawCardRow
|
|
|
|
SOURCE = "archidekt"
|
|
|
|
# CSV column name aliases — Archidekt has changed these over time
|
|
_NAME_COLS = {"card name", "name", "cardname"}
|
|
_QTY_COLS = {"quantity", "qty", "count", "amount"}
|
|
_FOIL_COLS = {"foil quantity", "foil qty", "foilqty", "foilcount", "foils"}
|
|
_SET_COLS = {"set code", "set", "setcode", "edition"}
|
|
_COLLECTOR_COLS = {"collector number", "collector #", "collectornumber", "number"}
|
|
|
|
|
|
def parse(raw_bytes: bytes) -> list[RawCardRow]:
|
|
"""
|
|
Auto-detect CSV vs JSON and dispatch to the appropriate parser.
|
|
Raises ValueError with a human-readable message on unrecognised format.
|
|
"""
|
|
# Detect encoding
|
|
detected = chardet.detect(raw_bytes)
|
|
encoding = detected.get("encoding") or "utf-8"
|
|
text = raw_bytes.decode(encoding, errors="replace").strip()
|
|
|
|
if text.startswith("[") or text.startswith("{"):
|
|
return _parse_json(text)
|
|
else:
|
|
return _parse_csv(text)
|
|
|
|
|
|
def _parse_csv(text: str) -> list[RawCardRow]:
|
|
reader = csv.DictReader(io.StringIO(text))
|
|
if not reader.fieldnames:
|
|
raise ValueError("Archidekt CSV appears to be empty or has no header row")
|
|
|
|
# Normalise header names
|
|
headers = {h.strip().lower(): h for h in reader.fieldnames if h}
|
|
|
|
name_col = _find_col(headers, _NAME_COLS, "Card Name")
|
|
qty_col = _find_col(headers, _QTY_COLS, "Quantity")
|
|
foil_col = _find_col(headers, _FOIL_COLS, None, required=False)
|
|
set_col = _find_col(headers, _SET_COLS, None, required=False)
|
|
collect_col = _find_col(headers, _COLLECTOR_COLS, None, required=False)
|
|
|
|
rows: list[RawCardRow] = []
|
|
for i, row in enumerate(reader, start=2): # start=2 because row 1 is header
|
|
name = row.get(name_col, "").strip()
|
|
if not name:
|
|
continue
|
|
|
|
try:
|
|
qty = int(float(row.get(qty_col, "1") or "1"))
|
|
except (ValueError, TypeError):
|
|
qty = 1
|
|
|
|
try:
|
|
foil_qty = int(float(row.get(foil_col, "0") or "0")) if foil_col else 0
|
|
except (ValueError, TypeError):
|
|
foil_qty = 0
|
|
|
|
rows.append(RawCardRow(
|
|
card_name=name,
|
|
set_code=(row.get(set_col, "") or "").strip().lower() if set_col else "",
|
|
collector_number=(row.get(collect_col, "") or "").strip() if collect_col else "",
|
|
quantity=qty,
|
|
foil_quantity=foil_qty,
|
|
import_source=SOURCE,
|
|
raw=dict(row),
|
|
))
|
|
|
|
if not rows:
|
|
raise ValueError("Archidekt CSV contained no card rows")
|
|
|
|
return rows
|
|
|
|
|
|
def _parse_json(text: str) -> list[RawCardRow]:
|
|
try:
|
|
data = json.loads(text)
|
|
except json.JSONDecodeError as e:
|
|
raise ValueError(f"Archidekt JSON is malformed: {e}")
|
|
|
|
# Handle both bare array and {"cards": [...]} wrapper
|
|
if isinstance(data, dict):
|
|
data = data.get("cards") or data.get("collection") or []
|
|
if not isinstance(data, list):
|
|
raise ValueError("Archidekt JSON must be an array of card objects")
|
|
|
|
rows: list[RawCardRow] = []
|
|
for item in data:
|
|
card = item.get("card") or item # nested or flat
|
|
name = (
|
|
card.get("name")
|
|
or card.get("cardName")
|
|
or card.get("card_name")
|
|
or ""
|
|
).strip()
|
|
if not name:
|
|
continue
|
|
|
|
qty = int(item.get("quantity") or item.get("qty") or 1)
|
|
foil_qty = int(item.get("foilQuantity") or item.get("foil_quantity") or 0)
|
|
|
|
set_obj = card.get("set") or {}
|
|
set_code = (
|
|
set_obj.get("code") if isinstance(set_obj, dict) else str(set_obj)
|
|
).lower().strip()
|
|
|
|
collector = str(card.get("collectorNumber") or card.get("collector_number") or "").strip()
|
|
|
|
rows.append(RawCardRow(
|
|
card_name=name,
|
|
set_code=set_code,
|
|
collector_number=collector,
|
|
quantity=qty,
|
|
foil_quantity=foil_qty,
|
|
import_source=SOURCE,
|
|
raw=item,
|
|
))
|
|
|
|
if not rows:
|
|
raise ValueError("Archidekt JSON contained no card entries")
|
|
|
|
return rows
|
|
|
|
|
|
def _find_col(
|
|
headers: dict[str, str],
|
|
aliases: set[str],
|
|
fallback: str | None,
|
|
required: bool = True,
|
|
) -> str | None:
|
|
for alias in aliases:
|
|
if alias in headers:
|
|
return headers[alias]
|
|
if fallback and fallback.lower() in headers:
|
|
return headers[fallback.lower()]
|
|
if required:
|
|
raise ValueError(
|
|
f"Could not find required column. Expected one of: {sorted(aliases)}"
|
|
)
|
|
return None
|