diff --git a/api/app_types/tcgdex.py b/api/app_types/tcgdex.py new file mode 100644 index 0000000..b0a600f --- /dev/null +++ b/api/app_types/tcgdex.py @@ -0,0 +1,112 @@ +"""TCGdex REST API v2 response shapes (https://tcgdex.dev/).""" + +from typing import Any, NotRequired, TypedDict + + +class TcgdexCardCount(TypedDict, total=False): + """Card counts inside a set or nested ``set`` on a card.""" + + total: int + official: int + firstEd: int + holo: int + normal: int + reverse: int + + +class TcgdexSetBrief(TypedDict, total=False): + """Row from ``GET /v2/{locale}/sets`` (may omit optional fields).""" + + id: str + name: str + logo: str + symbol: str + cardCount: TcgdexCardCount + + +class TcgdexAbbreviation(TypedDict, total=False): + """Official / internal product codes when present.""" + + official: str + + +class TcgdexSerieRef(TypedDict, total=False): + """Parent series reference on a set.""" + + id: str + name: str + + +class TcgdexSeriesBrief(TypedDict, total=False): + """Row from ``GET /v2/{locale}/series``.""" + + id: str + name: str + logo: str + + +class TcgdexSeriesDetail(TypedDict, total=False): + """Full series from ``GET /v2/{locale}/series/{seriesId}`` (includes nested sets).""" + + id: str + name: str + logo: str + releaseDate: str + firstSet: TcgdexSetBrief + lastSet: TcgdexSetBrief + sets: list[TcgdexSetBrief] + + +class TcgdexSetLegal(TypedDict, total=False): + """Play format flags.""" + + standard: bool + expanded: bool + + +class TcgdexCardInSetBrief(TypedDict, total=False): + """Card stub embedded in a full set payload.""" + + id: str + localId: str + name: str + image: str + + +class TcgdexSetDetail(TypedDict, total=False): + """Full set from ``GET /v2/{locale}/sets/{setId}`` (superset of TcgdexSetBrief).""" + + id: str + name: str + logo: str + symbol: str + cardCount: TcgdexCardCount + cards: list[TcgdexCardInSetBrief] + releaseDate: str + serie: TcgdexSerieRef + tcgOnline: str + abbreviation: TcgdexAbbreviation + legal: TcgdexSetLegal + + +class TcgdexSetNestedOnCard(TypedDict, total=False): + """``set`` object on a single-card response (subset of set fields).""" + + id: str + name: str + logo: str + symbol: str + cardCount: TcgdexCardCount + + +class TcgdexCardDetail(TypedDict, total=False): + """Single card from ``GET /v2/{locale}/cards/{cardId}``.""" + + id: str + localId: str + name: str + image: str + rarity: str + category: str + set: TcgdexSetNestedOnCard + pricing: dict[str, Any] diff --git a/api/main.py b/api/main.py index 9e3df55..4d88299 100644 --- a/api/main.py +++ b/api/main.py @@ -20,6 +20,7 @@ from routes import access_requests as access_requests_routes from routes import articles as articles_routes from routes import auth as auth_routes +from routes import catalog_route from routes import ebay_market_route from routes import ebay_route from routes import pricing_route @@ -104,6 +105,7 @@ def health() -> dict[str, str]: app.include_router(ebay_route.router) app.include_router(ebay_market_route.router) app.include_router(pricing_route.router) +app.include_router(catalog_route.router) app.include_router(stats_route.router) app.include_router(scan_routes.router) app.include_router(shipping_route.router) diff --git a/api/routes/catalog_route.py b/api/routes/catalog_route.py new file mode 100644 index 0000000..285e9c2 --- /dev/null +++ b/api/routes/catalog_route.py @@ -0,0 +1,341 @@ +""" +TCGdex-backed catalog routes (series / sets / cards) for stock pickers. + +Series group extensions (``GET …/series``). Image URL rules follow +https://tcgdex.dev/assets (quality + extension for cards; +``logo.{ext}`` / ``symbol.{ext}`` for sets). +""" + +from __future__ import annotations + +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from core.database import get_db +from core.deps import get_current_user +from models.margin_settings import MarginSettings +from models.user import User +from services.catalog_prefill_service import build_catalog_card_preview +from services.tcgdex_asset_url import ( + enrich_series_brief_row, + enrich_series_detail, + enrich_set_brief_row, + enrich_set_detail, +) +from services.tcgdex_client_service import SUPPORTED_LOCALES, TcgdexClientService + +router = APIRouter(prefix="/catalog", tags=["catalog"]) + + +def _merge_en_series_logos(rows: list[dict[str, Any]], en_rows: list[dict[str, Any]]) -> None: + """Fill missing ``logo`` on series rows using EN list (same ``id``).""" + logo_by_id: dict[str, str] = {} + for er in en_rows: + if not isinstance(er, dict): + continue + eid = er.get("id") + if not isinstance(eid, str): + continue + el = er.get("logo") + if isinstance(el, str) and el.strip(): + logo_by_id[eid] = el.strip() + for row in rows: + rid = row.get("id") + if isinstance(rid, str) and not row.get("logo") and rid in logo_by_id: + row["logo"] = logo_by_id[rid] + + +def _merge_en_set_briefs(rows: list[dict[str, Any]], en_rows: list[dict[str, Any]]) -> None: + """Fill missing ``logo`` / ``symbol`` on set briefs using EN rows (same ``id``).""" + logo_by_id: dict[str, str] = {} + sym_by_id: dict[str, str] = {} + for er in en_rows: + if not isinstance(er, dict): + continue + eid = er.get("id") + if not isinstance(eid, str): + continue + el = er.get("logo") + if isinstance(el, str) and el.strip(): + logo_by_id[eid] = el.strip() + sm = er.get("symbol") + if isinstance(sm, str) and sm.strip(): + sym_by_id[eid] = sm.strip() + for row in rows: + if not isinstance(row, dict): + continue + rid = row.get("id") + if not isinstance(rid, str): + continue + if not row.get("logo") and rid in logo_by_id: + row["logo"] = logo_by_id[rid] + if not row.get("symbol") and rid in sym_by_id: + row["symbol"] = sym_by_id[rid] + + +def _round_eur(value: float | None) -> float | None: + if value is None: + return None + return round(float(value), 2) + + +def _margin_percent(db: Session, user_id: int) -> int: + row = db.query(MarginSettings).filter(MarginSettings.user_id == user_id).first() + if row is not None: + return row.margin_percent + return 20 + + +@router.get("/sets") +def list_catalog_sets( + user: Annotated[User, Depends(get_current_user)], + locale: str = Query("en", min_length=2, max_length=8), + page: int = Query(1, ge=1), + per_page: int = Query(50, ge=1, le=100), + name: str | None = Query(None, max_length=120), +) -> dict[str, Any]: + """Paginated TCGdex set list with optional ``name`` filter.""" + loc = locale.strip().lower() + if loc not in SUPPORTED_LOCALES: + raise HTTPException( + status_code=400, + detail=f"Unsupported locale {locale!r}; use one of: {', '.join(sorted(SUPPORTED_LOCALES))}", + ) + client = TcgdexClientService() + name_filter = name.strip() if name else None + try: + rows = client.list_sets(loc, page=page, per_page=per_page, name_contains=name_filter) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + logo_by_id: dict[str, str] = {} + sym_by_id: dict[str, str] = {} + if loc != "en": + try: + en_rows = client.list_sets("en", page=page, per_page=per_page, name_contains=name_filter) + for er in en_rows: + if not isinstance(er, dict): + continue + eid = er.get("id") + if not isinstance(eid, str): + continue + el = er.get("logo") + if isinstance(el, str) and el.strip(): + logo_by_id[eid] = el.strip() + sm = er.get("symbol") + if isinstance(sm, str) and sm.strip(): + sym_by_id[eid] = sm.strip() + except RuntimeError: + pass + + enriched: list[dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + rid = row.get("id") + if isinstance(rid, str): + if not row.get("logo") and rid in logo_by_id: + row["logo"] = logo_by_id[rid] + if not row.get("symbol") and rid in sym_by_id: + row["symbol"] = sym_by_id[rid] + enrich_set_brief_row(row) + enriched.append(row) + + return {"locale": loc, "page": page, "per_page": per_page, "sets": enriched} + + +@router.get("/series") +def list_catalog_series( + user: Annotated[User, Depends(get_current_user)], + locale: str = Query("en", min_length=2, max_length=8), + name: str | None = Query(None, max_length=120), +) -> dict[str, Any]: + """TCGdex series list for grouping extensions; optional ``name`` substring filter (id or name).""" + loc = locale.strip().lower() + if loc not in SUPPORTED_LOCALES: + raise HTTPException( + status_code=400, + detail=f"Unsupported locale {locale!r}; use one of: {', '.join(sorted(SUPPORTED_LOCALES))}", + ) + client = TcgdexClientService() + try: + rows = client.list_series(loc) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + name_filter = name.strip().lower() if name else "" + if name_filter: + filtered: list[dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + nid = (row.get("id") or "").lower() + nname = (row.get("name") or "").lower() + if name_filter in nid or name_filter in nname: + filtered.append(row) + rows = filtered + + if loc != "en": + try: + en_rows = client.list_series("en") + _merge_en_series_logos(rows, en_rows) + except RuntimeError: + pass + + enriched: list[dict[str, Any]] = [] + for row in rows: + if isinstance(row, dict): + enrich_series_brief_row(row) + enriched.append(row) + + return {"locale": loc, "series": enriched} + + +@router.get("/series/{series_id}") +def get_catalog_series( + user: Annotated[User, Depends(get_current_user)], + series_id: str, + locale: str = Query("en", min_length=2, max_length=8), +) -> dict[str, Any]: + """One TCG series with nested set rows (logos merged from EN when needed).""" + loc = locale.strip().lower() + if loc not in SUPPORTED_LOCALES: + raise HTTPException( + status_code=400, + detail=f"Unsupported locale {locale!r}; use one of: {', '.join(sorted(SUPPORTED_LOCALES))}", + ) + client = TcgdexClientService() + sid = series_id.strip() + try: + detail = client.get_series(loc, sid) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + if not isinstance(detail, dict): + raise HTTPException(status_code=502, detail="Invalid TCGdex series payload") + + if loc != "en": + try: + en_detail = client.get_series("en", sid) + if isinstance(en_detail, dict): + if not detail.get("logo"): + el = en_detail.get("logo") + if isinstance(el, str) and el.strip(): + detail["logo"] = el.strip() + en_sets = en_detail.get("sets") + sets = detail.get("sets") + if isinstance(en_sets, list) and isinstance(sets, list): + en_list = [s for s in en_sets if isinstance(s, dict)] + loc_list = [s for s in sets if isinstance(s, dict)] + _merge_en_set_briefs(loc_list, en_list) + except RuntimeError: + pass + + enrich_series_detail(detail) + return {"locale": loc, "series": detail} + + +@router.get("/sets/{set_id}") +def get_catalog_set( + user: Annotated[User, Depends(get_current_user)], + set_id: str, + locale: str = Query("en", min_length=2, max_length=8), +) -> dict[str, Any]: + """Full TCGdex set payload including card stubs for grid UIs.""" + loc = locale.strip().lower() + if loc not in SUPPORTED_LOCALES: + raise HTTPException( + status_code=400, + detail=f"Unsupported locale {locale!r}; use one of: {', '.join(sorted(SUPPORTED_LOCALES))}", + ) + client = TcgdexClientService() + try: + detail = client.get_set(loc, set_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + if not isinstance(detail, dict): + raise HTTPException(status_code=502, detail="Invalid TCGdex set payload") + + if loc != "en": + try: + en_detail = client.get_set("en", set_id) + if isinstance(en_detail, dict): + if not detail.get("logo"): + el = en_detail.get("logo") + if isinstance(el, str) and el.strip(): + detail["logo"] = el.strip() + if not detail.get("symbol"): + es = en_detail.get("symbol") + if isinstance(es, str) and es.strip(): + detail["symbol"] = es.strip() + except RuntimeError: + pass + + enrich_set_detail(detail) + return {"locale": loc, "set": detail} + + +@router.get("/card-preview") +def get_catalog_card_preview( + db: Annotated[Session, Depends(get_db)], + user: Annotated[User, Depends(get_current_user)], + tcgdx_card_id: str = Query(..., min_length=3, max_length=120), + pokewallet_set_code: str | None = Query(None, max_length=32), + browse_locale: str | None = Query( + None, + min_length=2, + max_length=8, + description="Catalog UI locale for the suggested Pokémon name (en, fr, ja).", + ), +) -> dict[str, Any]: + """ + Resolve EN/FR/JA labels from TCGdex, map to PokéWallet codes, and attach pricing preview. + + ``browse_locale`` aligns ``display_pokemon_name`` with the catalogue language. + ``suggested_price`` uses the same margin percent as ``GET /pricing/lookup``. + """ + bl = browse_locale.strip().lower() if browse_locale else None + if bl and bl not in SUPPORTED_LOCALES: + raise HTTPException( + status_code=400, + detail=f"Unsupported browse_locale {browse_locale!r}; use one of: {', '.join(sorted(SUPPORTED_LOCALES))}", + ) + try: + body = build_catalog_card_preview( + tcgdx_card_id=tcgdx_card_id.strip(), + pokewallet_set_code=pokewallet_set_code.strip() if pokewallet_set_code else None, + browse_locale=bl, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + if body.get("error"): + raise HTTPException(status_code=422, detail=body["error"]) + + margin = _margin_percent(db, user.id) + pricing_block = body.get("pricing") + raw_avg = pricing_block.get("average_price_eur") if isinstance(pricing_block, dict) else None + avg = _round_eur(raw_avg if isinstance(raw_avg, (int, float)) else None) + + suggested: float | None = None + if avg is not None: + suggested = _round_eur(float(avg) * (1.0 + margin / 100.0)) + lp = body["listing_preview"] + if isinstance(lp, dict): + lp["suggested_price"] = suggested + + body["margin_percent_used"] = margin + return body diff --git a/api/services/catalog_prefill_service.py b/api/services/catalog_prefill_service.py new file mode 100644 index 0000000..787fcd2 --- /dev/null +++ b/api/services/catalog_prefill_service.py @@ -0,0 +1,155 @@ +"""Build listing fields from TCGdex card data + optional PokéWallet pricing.""" + +from __future__ import annotations + +from typing import Any + +from app_types.groq_vision import GroqVisionCardCollectorResult +from app_types.tcgdex import TcgdexCardDetail, TcgdexSetDetail +from services import pricing_service +from services.scan_service import build_title_and_description +from services.species_locale_names_service import fetch_species_locale_names +from services.tcgdex_client_service import ( + SUPPORTED_LOCALES, + TcgdexClientService, + infer_pokewallet_set_code, + normalize_card_number_for_pokewallet, + split_tcgdex_card_id, + tcgdx_image_url_high, +) + + +def _pick_display_pokemon_name( + browse_locale: str | None, + name_en: str, + name_fr: str, + name_ja: str | None, +) -> str: + """Prefer the catalog UI locale, then fall back so the article form matches the picker.""" + ja_s = (name_ja or "").strip() + pool = {"en": name_en.strip(), "fr": name_fr.strip(), "ja": ja_s} + raw = (browse_locale or "").strip().lower() + loc = raw if raw in SUPPORTED_LOCALES else "" + if not loc: + order: tuple[str, ...] = ("en", "fr", "ja") + elif loc == "ja": + order = ("ja", "fr", "en") + elif loc == "fr": + order = ("fr", "en", "ja") + else: + order = ("en", "fr", "ja") + for key in order: + v = pool.get(key, "") + if v: + return v + return name_en or name_fr or "Pokémon" + + +def build_catalog_card_preview( + *, + tcgdx_card_id: str, + pokewallet_set_code: str | None = None, + browse_locale: str | None = None, + tcgdex: TcgdexClientService | None = None, +) -> dict[str, Any]: + """ + Load EN/FR/JA card names from TCGdex, infer PokéWallet ``set_code`` + ``card_number``, + run pricing lookup, and build title/description compatible with ``ArticleForm``. + + ``browse_locale`` controls which localized Pokémon name is suggested first in + ``display_pokemon_name`` (``fr`` | ``en`` | ``ja``). + """ + client = tcgdex or TcgdexClientService() + set_id, local_raw = split_tcgdex_card_id(tcgdx_card_id) + set_detail = client.get_set("en", set_id) + resolved_code = (pokewallet_set_code or "").strip().upper() or infer_pokewallet_set_code(set_detail) + if not resolved_code: + return { + "error": "Could not infer PokéWallet set_code from TCGdex set; pass pokewallet_set_code explicitly.", + "tcgdx_card_id": tcgdx_card_id, + } + + card_en = client.get_card("en", tcgdx_card_id) + try: + card_fr = client.get_card("fr", tcgdx_card_id) + except (RuntimeError, ValueError): + card_fr = card_en + try: + card_ja = client.get_card("ja", tcgdx_card_id) + except (RuntimeError, ValueError): + card_ja = card_en + + name_en = (card_en.get("name") or "").strip() + name_fr = (card_fr.get("name") or "").strip() or name_en + number_pw = normalize_card_number_for_pokewallet(local_raw) + + species = fetch_species_locale_names(name_en) + name_tcgdex_ja = (card_ja.get("name") or "").strip() + name_ja = name_tcgdex_ja or ((species.japanese or "").strip() or None) + + nested_set = card_en.get("set") + set_name_en = "" + if isinstance(nested_set, dict): + raw_sn = nested_set.get("name") + if isinstance(raw_sn, str): + set_name_en = raw_sn.strip() + + rarity = "" + raw_r = card_en.get("rarity") + if isinstance(raw_r, str): + rarity = raw_r.strip() + + display_name = _pick_display_pokemon_name(browse_locale, name_en, name_fr, name_ja) + ocr: GroqVisionCardCollectorResult = { + "set_code": resolved_code, + "card_number": number_pw, + "pokemon_name": display_name, + "pokemon_name_english": name_en, + "pokemon_name_french": name_fr, + "set_name_english": set_name_en, + "rarity_english": rarity, + } + card_info: dict[str, Any] = { + "set_name": set_name_en, + "set_code": resolved_code, + "card_number": number_pw, + "rarity": rarity, + } + title, description = build_title_and_description(ocr, card_info) + if name_ja: + j = str(name_ja).strip() + if j and j != name_en and j != name_fr: + description = f"{description}\nNom (JPN) : {j}" + + pricing = pricing_service.fetch_card_prices(resolved_code, number_pw, name_en) + image_base = card_en.get("image") + image_url_high: str | None = None + if isinstance(image_base, str) and image_base.strip(): + image_url_high = tcgdx_image_url_high(image_base.strip()) + + return { + "tcgdx_card_id": tcgdx_card_id, + "display_pokemon_name": display_name, + "tcgdex": { + "names": {"en": name_en, "fr": name_fr, "ja": name_ja}, + "set_id": set_id, + "local_id": local_raw, + }, + "pokewallet": { + "set_code": resolved_code, + "card_number": number_pw, + }, + "listing_preview": { + "title": title, + "description": description, + "suggested_price": None, + }, + "pricing": { + "cardmarket_eur": pricing.get("cardmarket_eur"), + "tcgplayer_usd": pricing.get("tcgplayer_usd"), + "average_price_eur": pricing.get("average_price"), + "error": pricing.get("error"), + }, + "image_url_high": image_url_high, + "error": None, + } diff --git a/api/services/tcgdex_asset_url.py b/api/services/tcgdex_asset_url.py new file mode 100644 index 0000000..641cdbf --- /dev/null +++ b/api/services/tcgdex_asset_url.py @@ -0,0 +1,115 @@ +""" +Build final TCGdex CDN image URLs. + +API responses often omit file extensions on asset bases (e.g. ``…/136``, ``…/logo``). +Official rules: cards use ``{quality}.{extension}`` (``low`` / ``high`` + ``webp`` recommended); +logos and symbols use ``logo.{extension}`` and ``symbol.{extension}``. + +See: https://tcgdex.dev/assets +""" + +from __future__ import annotations + + +def _has_image_extension(url: str) -> bool: + lower = url.rsplit(".", 1)[-1].lower() + return lower in ("webp", "png", "jpg", "jpeg") + + +def tcgdx_asset_url_with_webp(url: str | None) -> str | None: + """ + Turn a base path like ``…/swsh3/logo`` or ``…/symbol`` into ``…/logo.webp`` / ``…/symbol.webp``. + + Matches TCGdex assets: logos and symbols are ``logo.{extension}`` and ``symbol.{extension}``. + """ + if url is None: + return None + raw = url.strip() + if not raw: + return None + if _has_image_extension(raw): + return raw + return f"{raw}.webp" + + +def normalize_set_logo_url(raw_logo: str | None) -> str | None: + """Return ``logo`` with explicit ``.webp`` when the API omits an extension, else ``None``.""" + if not isinstance(raw_logo, str) or not raw_logo.strip(): + return None + return tcgdx_asset_url_with_webp(raw_logo.strip()) + + +def card_image_low_webp(image_base: str | None) -> str | None: + """ + Card preview URL: ``{cardBase}/low.webp`` (``{quality}.{extension}`` per TCGdex assets doc). + """ + if image_base is None: + return None + base = image_base.strip().rstrip("/") + if not base: + return None + if base.endswith("/low.webp") or "/low." in base: + return tcgdx_asset_url_with_webp(base) or base + return f"{base}/low.webp" + + +def enrich_series_brief_row(row: dict[str, object]) -> None: + """Mutate a TCGdex series row from ``GET …/series``: normalize ``logo`` URL.""" + raw_logo = row.get("logo") + norm = normalize_set_logo_url(raw_logo if isinstance(raw_logo, str) else None) + if norm: + row["logo"] = norm + else: + row.pop("logo", None) + + +def enrich_series_detail(detail: dict[str, object]) -> None: + """Normalize series ``logo`` and run ``enrich_set_brief_row`` on each nested set.""" + raw_logo = detail.get("logo") + norm = normalize_set_logo_url(raw_logo if isinstance(raw_logo, str) else None) + if norm: + detail["logo"] = norm + else: + detail.pop("logo", None) + sets = detail.get("sets") + if not isinstance(sets, list): + return + for entry in sets: + if isinstance(entry, dict): + enrich_set_brief_row(entry) + + +def enrich_set_brief_row(row: dict[str, object]) -> None: + """Mutate a TCGdex set-brief dict in place: normalize ``logo`` / ``symbol`` URLs.""" + raw_logo = row.get("logo") + norm = normalize_set_logo_url(raw_logo if isinstance(raw_logo, str) else None) + if norm: + row["logo"] = norm + else: + row.pop("logo", None) + raw_sym = row.get("symbol") + if isinstance(raw_sym, str) and raw_sym.strip(): + row["symbol"] = tcgdx_asset_url_with_webp(raw_sym.strip()) or raw_sym.strip() + + +def enrich_set_detail(detail: dict[str, object]) -> None: + """Mutate a full set payload: ``logo`` / ``symbol`` and add ``image_low`` on each card stub.""" + raw_logo = detail.get("logo") + norm = normalize_set_logo_url(raw_logo if isinstance(raw_logo, str) else None) + if norm: + detail["logo"] = norm + else: + detail.pop("logo", None) + raw_sym = detail.get("symbol") + if isinstance(raw_sym, str) and raw_sym.strip(): + detail["symbol"] = tcgdx_asset_url_with_webp(raw_sym.strip()) or raw_sym.strip() + cards = detail.get("cards") + if not isinstance(cards, list): + return + for entry in cards: + if not isinstance(entry, dict): + continue + img = entry.get("image") + low = card_image_low_webp(img if isinstance(img, str) else None) + if low: + entry["image_low"] = low diff --git a/api/services/tcgdex_client_service.py b/api/services/tcgdex_client_service.py new file mode 100644 index 0000000..b578ef2 --- /dev/null +++ b/api/services/tcgdex_client_service.py @@ -0,0 +1,196 @@ +"""HTTP client for the TCGdex public REST API (https://api.tcgdex.net/v2).""" + +from __future__ import annotations + +import json +from typing import Any, cast +from urllib.parse import quote, urlencode + +import httpx + +from app_types.tcgdex import TcgdexCardDetail, TcgdexSeriesDetail, TcgdexSetBrief, TcgdexSetDetail + +DEFAULT_BASE_URL = "https://api.tcgdex.net/v2" +DEFAULT_TIMEOUT_SEC = 30.0 +DEFAULT_USER_AGENT = "GoupixDex/1.0 (+catalog)" + +# Locales we actively use in GoupixDex; others may work but are not validated here. +SUPPORTED_LOCALES = frozenset({"en", "fr", "ja"}) +SETS_PER_PAGE_MAX = 100 +SETS_PER_PAGE_DEFAULT = 50 + + +class TcgdexClientService: + """ + Read-only TCGdex client (no API key). + + Endpoints follow ``GET {base}/{locale}/sets`` and ``GET {base}/{locale}/cards/{id}``. + """ + + def __init__(self, base_url: str | None = None, timeout_sec: float = DEFAULT_TIMEOUT_SEC) -> None: + self._base = (base_url or DEFAULT_BASE_URL).rstrip("/") + self._timeout = timeout_sec + + def list_sets( + self, + locale: str, + page: int = 1, + per_page: int = SETS_PER_PAGE_DEFAULT, + name_contains: str | None = None, + ) -> list[TcgdexSetBrief]: + """ + List sets for a locale with optional server-side name filter. + + TCGdex returns a JSON array; pagination uses ``pagination:page`` and + ``pagination:itemsPerPage`` query keys. + """ + loc = _normalize_locale(locale) + params: dict[str, str] = { + "pagination:page": str(max(1, page)), + "pagination:itemsPerPage": str(max(1, min(SETS_PER_PAGE_MAX, per_page))), + } + raw_name = (name_contains or "").strip() + if raw_name: + params["name"] = raw_name + query = urlencode(params) + path = f"/{quote(loc, safe='')}/sets?{query}" + raw = self._fetch_json(path) + if not isinstance(raw, list): + return [] + out: list[TcgdexSetBrief] = [] + for row in raw: + if isinstance(row, dict): + out.append(cast(TcgdexSetBrief, row)) + return out + + def get_set(self, locale: str, set_id: str) -> TcgdexSetDetail: + """Return full set JSON including the ``cards`` array.""" + loc = _normalize_locale(locale) + sid = set_id.strip() + if not sid: + msg = "get_set: set_id must be non-empty" + raise ValueError(msg) + path = f"/{quote(loc, safe='')}/sets/{quote(sid, safe='')}" + raw = self._fetch_json(path) + if not isinstance(raw, dict): + msg = "TCGdex set response was not a JSON object" + raise RuntimeError(msg) + return cast(TcgdexSetDetail, raw) + + def list_series(self, locale: str) -> list[dict[str, Any]]: + """List TCG series for a locale (``GET /{locale}/series``).""" + loc = _normalize_locale(locale) + path = f"/{quote(loc, safe='')}/series" + raw = self._fetch_json(path) + if not isinstance(raw, list): + return [] + return [cast(dict[str, Any], row) for row in raw if isinstance(row, dict)] + + def get_series(self, locale: str, series_id: str) -> TcgdexSeriesDetail: + """Return full series JSON including the ``sets`` array.""" + loc = _normalize_locale(locale) + sid = series_id.strip() + if not sid: + msg = "get_series: series_id must be non-empty" + raise ValueError(msg) + path = f"/{quote(loc, safe='')}/series/{quote(sid, safe='')}" + raw = self._fetch_json(path) + if not isinstance(raw, dict): + msg = "TCGdex series response was not a JSON object" + raise RuntimeError(msg) + return cast(TcgdexSeriesDetail, raw) + + def get_card(self, locale: str, card_id: str) -> TcgdexCardDetail: + """Return a single card by TCGdex id (e.g. ``sv03.5-025``).""" + loc = _normalize_locale(locale) + cid = card_id.strip() + if not cid: + msg = "get_card: card_id must be non-empty" + raise ValueError(msg) + path = f"/{quote(loc, safe='')}/cards/{quote(cid, safe='')}" + raw = self._fetch_json(path) + if not isinstance(raw, dict): + msg = "TCGdex card response was not a JSON object" + raise RuntimeError(msg) + return cast(TcgdexCardDetail, raw) + + def _fetch_json(self, path: str) -> Any: + normalized = path if path.startswith("/") else f"/{path}" + url = f"{self._base}{normalized}" + headers = {"Accept": "application/json", "User-Agent": DEFAULT_USER_AGENT} + response = httpx.get(url, headers=headers, timeout=self._timeout) + body_text = response.text + if not response.is_success: + msg = f"TCGdex request failed ({response.status_code} {response.reason_phrase}): {body_text}" + raise RuntimeError(msg) + try: + return json.loads(body_text) + except json.JSONDecodeError as exc: + msg = "TCGdex response was not valid JSON" + raise RuntimeError(msg) from exc + + +def _normalize_locale(locale: str) -> str: + loc = locale.strip().lower() + if loc not in SUPPORTED_LOCALES: + msg = f"Unsupported TCGdex locale: {locale!r} (supported: {', '.join(sorted(SUPPORTED_LOCALES))})" + raise ValueError(msg) + return loc + + +def split_tcgdex_card_id(card_id: str) -> tuple[str, str]: + """ + Split ``{setId}-{localId}`` into set and local number. + + Uses rsplit so set ids containing hyphens (e.g. ``sv03.5-025``) still work. + """ + raw = card_id.strip() + if "-" not in raw: + msg = f"Invalid TCGdex card id (no hyphen): {card_id!r}" + raise ValueError(msg) + set_part, local = raw.rsplit("-", 1) + set_part = set_part.strip() + local = local.strip() + if not set_part or not local: + msg = f"Invalid TCGdex card id: {card_id!r}" + raise ValueError(msg) + return set_part, local + + +def infer_pokewallet_set_code(set_detail: TcgdexSetDetail) -> str | None: + """ + Best-effort Cardmarket / PokéWallet style set code from TCGdex set JSON. + + Prefer ``abbreviation.official`` (e.g. ``MEW``), then ``tcgOnline``, then uppercased ``id``. + """ + abbrev = set_detail.get("abbreviation") + if isinstance(abbrev, dict): + off = abbrev.get("official") + if isinstance(off, str) and off.strip(): + return off.strip().upper() + tcg_online = set_detail.get("tcgOnline") + if isinstance(tcg_online, str) and tcg_online.strip(): + return tcg_online.strip().upper() + sid = set_detail.get("id") + if isinstance(sid, str) and sid.strip(): + return sid.strip().upper() + return None + + +def normalize_card_number_for_pokewallet(local_id: str) -> str: + """Strip useless leading zeros when the local id is purely numeric.""" + s = local_id.strip() + if s.isdigit(): + stripped = s.lstrip("0") + return stripped if stripped else "0" + return s + + +def tcgdx_image_url_high(image_base: str) -> str: + """ + Full card art URL for ``high`` + ``webp`` (pattern ``{quality}.{extension}``). + + https://tcgdex.dev/assets + """ + base = image_base.rstrip("/") + return f"{base}/high.webp" diff --git a/web/app/components/articles/ArticleForm.vue b/web/app/components/articles/ArticleForm.vue index f85b2a2..6d5369a 100644 --- a/web/app/components/articles/ArticleForm.vue +++ b/web/app/components/articles/ArticleForm.vue @@ -144,21 +144,21 @@ watch( pokemonName.value = a.pokemon_name ?? '' setCode.value = a.set_code ?? '' cardNumber.value = a.card_number ?? '' - condition.value = - a.condition && conditionOptions.some((o) => o.value === a.condition) + condition.value + = a.condition && conditionOptions.some(o => o.value === a.condition) ? a.condition : 'Near Mint' purchasePrice.value = String(a.purchase_price) sellPrice.value = a.sell_price != null ? String(a.sell_price) : '' isGraded.value = Boolean(a.is_graded) - gradedGraderValueId.value = - a.graded_grader_value_id - && EBAY_PROFESSIONAL_GRADER_OPTIONS.some(o => o.value === a.graded_grader_value_id) + gradedGraderValueId.value + = a.graded_grader_value_id + && EBAY_PROFESSIONAL_GRADER_OPTIONS.some(o => o.value === a.graded_grader_value_id) ? a.graded_grader_value_id : '' - gradedGradeValueId.value = - a.graded_grade_value_id - && EBAY_GRADE_OPTIONS.some(o => o.value === a.graded_grade_value_id) + gradedGradeValueId.value + = a.graded_grade_value_id + && EBAY_GRADE_OPTIONS.some(o => o.value === a.graded_grade_value_id) ? a.graded_grade_value_id : '' gradedCertNumber.value = a.graded_cert_number ?? '' @@ -227,6 +227,53 @@ function applyScanPrefill(scan: { * of the fields (title, price, condition, …) and optionally a remote image URL * that we try to fetch as a File so it is bundled with the article creation. */ +/** + * Prefill from TCGdex catalog + server ``/catalog/card-preview`` (PokéWallet pricing). + */ +async function applyCatalogPrefill(p: { + display_pokemon_name?: string + listing_preview: { title: string, description: string, suggested_price: number | null } + pokewallet: { set_code: string, card_number: string } + tcgdex: { names: { en: string, fr: string, ja: string | null } } + image_url_high?: string | null +}) { + assignTitleFromExternal(p.listing_preview.title) + description.value = p.listing_preview.description + pokemonName.value = p.display_pokemon_name?.trim() + || p.tcgdex.names.ja?.trim() + || p.tcgdex.names.en + || p.tcgdex.names.fr + setCode.value = p.pokewallet.set_code + cardNumber.value = p.pokewallet.card_number + if (p.listing_preview.suggested_price != null) { + purchasePrice.value = String(p.listing_preview.suggested_price) + } + isGraded.value = false + gradedGraderValueId.value = '' + gradedGradeValueId.value = '' + gradedCertNumber.value = '' + importIsSold.value = false + importSoldAt.value = null + wardrobeVintedListed.value = false + wardrobeVintedPublishedAtIso.value = null + wardrobeImportSoldPrice.value = null + const img = p.image_url_high + if (img) { + try { + const blob = await blobFromVintedPhotoUrl(img) + if (blob) { + const ext = blob.type.includes('png') ? 'png' : 'webp' + const file = new File([blob], `tcgdex-${Date.now()}.${ext}`, { + type: blob.type || 'image/webp' + }) + addImageFiles([file]) + } + } catch { + /* optional image */ + } + } +} + async function applyEbayPrefill(p: { title?: string description?: string @@ -327,7 +374,8 @@ defineExpose({ addImageFiles, buildCreateFormData, applyWardrobeSlot, - applyEbayPrefill + applyEbayPrefill, + applyCatalogPrefill }) function buildCreateFormData(): FormData { diff --git a/web/app/composables/useCardCatalog.ts b/web/app/composables/useCardCatalog.ts new file mode 100644 index 0000000..90f0a5c --- /dev/null +++ b/web/app/composables/useCardCatalog.ts @@ -0,0 +1,148 @@ +/** TCGdex-backed catalog API (authenticated proxy through GoupixDex). */ + +export type CatalogLocale = 'en' | 'fr' | 'ja' + +export interface TcgdexSetBrief { + id: string + name: string + logo?: string + symbol?: string + cardCount?: { total?: number, official?: number } +} + +/** Row from ``GET /catalog/series`` (TCGdex ``/series``). */ +export interface TcgdexSeriesBrief { + id: string + name: string + logo?: string +} + +/** Full series with nested extensions from ``GET /catalog/series/:id``. */ +export interface TcgdexSeriesDetail extends TcgdexSeriesBrief { + releaseDate?: string + sets?: TcgdexSetBrief[] +} + +export interface CatalogSeriesListResponse { + locale: string + series: TcgdexSeriesBrief[] +} + +export interface CatalogSeriesDetailResponse { + locale: string + series: TcgdexSeriesDetail +} + +export interface TcgdexCardInSetBrief { + id: string + localId: string + name: string + image?: string + /** Ready-to-use thumbnail URL (``…/low.webp``), set by GoupixDex ``GET /catalog/sets/:id``. */ + image_low?: string +} + +export interface TcgdexSetDetail extends TcgdexSetBrief { + cards?: TcgdexCardInSetBrief[] + releaseDate?: string + serie?: { id?: string, name?: string } + abbreviation?: { official?: string } + tcgOnline?: string +} + +export interface CatalogSetsResponse { + locale: string + page: number + per_page: number + sets: TcgdexSetBrief[] +} + +export interface CatalogSetResponse { + locale: string + set: TcgdexSetDetail +} + +export interface CatalogCardPreviewResponse { + tcgdx_card_id: string + /** Pokémon name to show in the form (matches ``browse_locale`` when set). */ + display_pokemon_name?: string + tcgdex: { + names: { en: string, fr: string, ja: string | null } + set_id: string + local_id: string + } + pokewallet: { set_code: string, card_number: string } + listing_preview: { title: string, description: string, suggested_price: number | null } + pricing: { + cardmarket_eur: number | null + tcgplayer_usd: number | null + average_price_eur: number | null + error: string | null + } + image_url_high: string | null + margin_percent_used: number + error?: string | null +} + +export function useCardCatalog() { + const { $api } = useNuxtApp() + + async function listSeries(params: { locale: CatalogLocale, name?: string }) { + const { data } = await $api.get('/catalog/series', { + params: { + locale: params.locale, + name: params.name?.trim() || undefined + } + }) + return data + } + + async function getSeries(locale: CatalogLocale, seriesId: string) { + const { data } = await $api.get( + `/catalog/series/${encodeURIComponent(seriesId)}`, + { params: { locale } } + ) + return data + } + + async function listSets(params: { + locale: CatalogLocale + page?: number + perPage?: number + name?: string + }) { + const { data } = await $api.get('/catalog/sets', { + params: { + locale: params.locale, + page: params.page ?? 1, + per_page: params.perPage ?? 50, + name: params.name?.trim() || undefined + } + }) + return data + } + + async function getSet(locale: CatalogLocale, setId: string) { + const { data } = await $api.get(`/catalog/sets/${encodeURIComponent(setId)}`, { + params: { locale } + }) + return data + } + + async function previewCard( + tcgdxCardId: string, + pokewalletSetCode?: string | null, + browseLocale?: CatalogLocale | null + ) { + const { data } = await $api.get('/catalog/card-preview', { + params: { + tcgdx_card_id: tcgdxCardId, + pokewallet_set_code: pokewalletSetCode?.trim() || undefined, + browse_locale: browseLocale ?? undefined + } + }) + return data + } + + return { listSeries, getSeries, listSets, getSet, previewCard } +} diff --git a/web/app/layouts/default.vue b/web/app/layouts/default.vue index 2af11ab..1986139 100644 --- a/web/app/layouts/default.vue +++ b/web/app/layouts/default.vue @@ -12,8 +12,8 @@ if (import.meta.client && !me.value) { } /** - * Journal des publications : Vinted + eBay (SSE). Télécharger l'app : web uniquement. - * Le lien Utilisateurs n'apparaît que pour l'admin. + * Journal des publications : Vinted + eBay (SSE). Catalogue : extensions / cartes (création). + * Télécharger l'app : web uniquement. Le lien Utilisateurs n'apparaît que pour l'admin. */ const links = computed(() => { const items: NavigationMenuItem[] = [ @@ -29,6 +29,12 @@ const links = computed(() => { to: '/articles/stock', onSelect: () => { open.value = false } }, + { + label: 'Catalogue', + icon: 'i-lucide-library', + to: '/articles/catalog', + onSelect: () => { open.value = false } + }, { label: 'Articles', icon: 'i-lucide-store', @@ -48,7 +54,7 @@ const links = computed(() => { onSelect: () => { open.value = false } }, { - label: "Étiquettes d'envoi", + label: 'Étiquettes d\'envoi', icon: 'i-lucide-mailbox', to: '/shipping-labels', onSelect: () => { open.value = false } @@ -66,7 +72,7 @@ const links = computed(() => { if (!isDesktopApp.value) { items.push({ - label: "Télécharger l'app", + label: 'Télécharger l\'app', icon: 'i-lucide-download', to: '/downloads', onSelect: () => { open.value = false } diff --git a/web/app/pages/articles/catalog.vue b/web/app/pages/articles/catalog.vue new file mode 100644 index 0000000..182be59 --- /dev/null +++ b/web/app/pages/articles/catalog.vue @@ -0,0 +1,789 @@ + + + diff --git a/web/app/pages/articles/create-from-catalog.vue b/web/app/pages/articles/create-from-catalog.vue new file mode 100644 index 0000000..03fbd06 --- /dev/null +++ b/web/app/pages/articles/create-from-catalog.vue @@ -0,0 +1,12 @@ + + + diff --git a/web/app/pages/articles/create.vue b/web/app/pages/articles/create.vue index a5a1c29..bee9877 100644 --- a/web/app/pages/articles/create.vue +++ b/web/app/pages/articles/create.vue @@ -271,6 +271,15 @@ async function onSubmitCreate(fd: FormData) {

+ + Par catalogue + Création groupée + + Catalogue + Nouvel article