""" 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