diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..faf1868 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,3 @@ +#!/bin/sh +python ruff_hook.py || exit 1 +python -m pytest tests/ --ignore=tests/test_downloader.py -m "not network" -q || exit 1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 40e47a7..fdbd0bf 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,4 +28,4 @@ jobs: pip install Pillow reportlab gdown requests pytest - name: Run tests - run: pytest tests/ -v --tb=short + run: pytest tests/ --ignore=tests/test_downloader.py -m "not network" -v --tb=short diff --git a/.gitignore b/.gitignore index d59fe1a..2edf3e7 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,12 @@ config.json # Generated by build_exe.py — contains the XOR-obfuscated API key; never commit src/_bundled_key.py +# Generated by build_exe.py — contains build-time flags; never commit +gui/_build_flags.py + +# Runtime output folder created next to the .exe (or project root in dev) +MPCFillToPDF/ + # OS .DS_Store Thumbs.db @@ -59,5 +65,6 @@ desktop.ini *.suo *.user -# Claude Code local settings (machine-specific) +# Claude Code settings (machine-specific, not shared) +.claude/settings.json .claude/settings.local.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 53870f8..df98224 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,16 +1,17 @@ repos: - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.13 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format - - repo: local hooks: + - id: ruff-autofix + name: ruff (fix + format + re-stage) + language: python + additional_dependencies: [ruff] + entry: python ruff_hook.py + pass_filenames: false + always_run: true + - id: pytest name: pytest - entry: python -m pytest tests/ --ignore=tests/test_downloader.py -q + entry: python -m pytest tests/ --ignore=tests/test_downloader.py -m "not network" -q language: system pass_filenames: false always_run: true diff --git a/.specs-fire/state.yaml b/.specs-fire/state.yaml new file mode 100644 index 0000000..468370b --- /dev/null +++ b/.specs-fire/state.yaml @@ -0,0 +1,52 @@ +active_intent: + id: "INTENT-001" + title: "Importar deck desde URL de deckbuilder en la tab de Magic" + status: completed + last_updated: "2026-06-21" + +work_items: + - id: "WI-001" + title: "src/deck_importer.py — fetch deck list con set+número por carta" + status: completed + files: + - path: "src/deck_importer.py" + action: created + + - id: "WI-002" + title: "src/scryfall.py — descarga imágenes por set+número con soporte MDFC" + status: completed + files: + - path: "src/scryfall.py" + action: created + + - id: "WI-003" + title: "src/pipeline.py — run_deck_url()" + status: completed + files: + - path: "src/pipeline.py" + action: modified + + - id: "WI-004" + title: "gui/xml_tab.py — sección 'Importar desde URL' con sideboard checkbox" + status: completed + files: + - path: "gui/xml_tab.py" + action: modified + + - id: "WI-005" + title: "gui/main.py — AppState + worker para run_deck_url" + status: completed + files: + - path: "gui/main.py" + action: modified + + - id: "WI-006" + title: "tests — test_deck_importer.py + test_scryfall.py" + status: completed + files: + - path: "tests/test_deck_importer.py" + action: created + - path: "tests/test_scryfall.py" + action: created + +last_updated: "2026-06-21" diff --git a/.specsmd/fire/memory-bank.yaml b/.specsmd/fire/memory-bank.yaml new file mode 100644 index 0000000..21e0625 --- /dev/null +++ b/.specsmd/fire/memory-bank.yaml @@ -0,0 +1,30 @@ +project: MPCFillToPDF +description: > + App Python/Tkinter que convierte proyectos de mazos de cartas + (Magic, One Piece, Riftbound, Lorcana) en PDFs para impresión en copistería. +tech_stack: + language: Python 3.10+ + gui: Tkinter + pdf: reportlab + image: Pillow + download: requests + gdown +games_supported: + - Magic (XML MPCFill) + - One Piece (scraping: onepiece.gg, egmanevents, cardkaizoku) + - Riftbound (scraping) + - Lorcana (scraping) +existing_features: + - Descarga paralela de imágenes (Google Drive + scraping) + - Recorte de sangrado MPC + - Generación PDF duplex (3x3 grid, A4) + - Checkpoint / recuperación parcial tras error + - Validación XML + - ETA / velocidad de descarga + - Notificación del sistema al terminar + - Retry de descargas fallidas + - Preview de cartas locales + - Tiempos de fase (timing log) + - Split de PDF por tamaño (máx 480 MB) + - Modo solo-frontales + - Fusión de XMLs + - Importar deck desde URL (Moxfield, Archidekt) con imágenes Scryfall por set+número, soporte MDFC diff --git a/CLAUDE.md b/CLAUDE.md index 56314c1..7dc2937 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,11 +55,12 @@ MPCFillToPDF/ │ └── main.py # CLI: batch-processes xml/*.xml into out/ ├── gui/ │ ├── main.py # Tkinter GUI entry point -│ └── paths.py # Resolves out/ and workdir/ next to the .exe when frozen +│ └── paths.py # Resolves runtime dirs next to the .exe when frozen ├── build_exe.py # PyInstaller build script (produces dist/MPCFillToPDF.exe) ├── xml/ # Drop .xml inputs here (CLI mode) -├── out/ # Generated PDFs (gitignored) -├── workdir/ # Cached downloads + intermediate images (gitignored) +├── MPCFillToPDF/ # Created next to the .exe (or project root in dev); gitignored +│ ├── archivos generados/ # Generated PDFs +│ └── procesamiento/ # Download cache, intermediate images, gui.log ├── examples/ │ ├── example.xml # Reference MPCFill project file │ ├── example.pdf # Target PDF output (reference) @@ -76,11 +77,13 @@ MPCFillToPDF/ - The user picks XML(s) via a file dialog, optionally toggles "Conservar caché", and clicks **Generar PDF(s)**. - The pipeline runs in a worker thread; UI updates via a `queue.Queue` drained from the Tk loop. - When done, the output folder opens automatically (`os.startfile` on Windows). -- `gui/paths.py` resolves `out/` and `workdir/` next to `sys.executable` when frozen by PyInstaller, otherwise next to the project root. +- `gui/paths.py` resolves `MPCFillToPDF/archivos generados/` and `MPCFillToPDF/procesamiento/` next to `sys.executable` when frozen by PyInstaller, otherwise next to the project root. ### Packaging (V2 → .exe) - `python build_exe.py` runs PyInstaller with `--onefile --windowed`, bundling `src/assets/` as data. -- Output: `dist/MPCFillToPDF.exe`. The .exe is portable — drop it anywhere and it creates `out/` and `workdir/` next to itself on first run. +- `python build_exe.py --debug-logging` habilita `gui.log` en el exe resultante (por defecto desactivado). +- Output: `dist/MPCFillToPDF.exe`. The .exe is portable — drop it anywhere and it creates `MPCFillToPDF/archivos generados/` and `MPCFillToPDF/procesamiento/` next to itself on first run. +- `gui/_build_flags.py` is generated by `build_exe.py` at build time and deleted afterwards; it is gitignored and never committed. ### Size-based splitting - Cap: each output PDF stays under 500 MB on disk (decimal MB). `MAX_PDF_BYTES` in `pdf_generator.py` is set to 480 MB so the projected estimate has a safety margin. diff --git a/README.md b/README.md index f6fb22a..db40247 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,52 @@ Convierte un archivo de proyecto de [MPCFill](https://mpcfill.com/) (XML) en un El XML de MPCFill referencia imágenes alojadas en Google Drive. Esta herramienta las descarga, les quita el sangrado de MPC, las recoloca con un sangrado en espejo de 1 mm y monta el PDF con líneas de corte y marcas de impresora. +También soporta mazos de otros juegos de cartas descargándolos directamente desde webs especializadas (ver [Webs soportadas](#webs-soportadas-para-diferentes-tcg)). + +--- + +## Webs soportadas para diferentes TCG + +### One Piece Card Game + +| Web | URL de ejemplo | +|-----|---------------| +| [onepiece.gg](https://onepiece.gg) | `https://onepiece.gg/decks/nombre-del-mazo` | +| [deckbuilder.egmanevents.com](https://deckbuilder.egmanevents.com) | `https://deckbuilder.egmanevents.com/?deck=CARTA:X,...` o `https://deckbuilder.egmanevents.com/d/CODIGO` | +| [deckbuilder.cardkaizoku.com](https://deckbuilder.cardkaizoku.com) | `https://deckbuilder.cardkaizoku.com/?deck=2xOP01-001\|3xOP01-002\|...` | + +Pega la URL del mazo en la pestaña **One Piece** de la interfaz gráfica y pulsa **Añadir**. + +--- + +### Riftbound TCG + +| Web | URL de ejemplo | +|-----|---------------| +| [riftbound.gg](https://riftbound.gg) | `https://riftbound.gg/decks/nombre-del-mazo/` | +| [piltoverarchive.com](https://piltoverarchive.com) | `https://piltoverarchive.com/decks/view/` | +| [riftmana.com](https://riftmana.com) | `https://riftmana.com/decks/nombre-del-mazo` | +| [riftbinder.com](https://riftbinder.com) | `https://riftbinder.com/decks/` | +| [riftdex.com](https://riftdex.com) | `https://riftdex.com/deck/` | + +Pega la URL del mazo en la pestaña **Riftbound** de la interfaz gráfica y pulsa **Añadir**. + +> **Nota:** Solo se pueden descargar mazos **públicos**. Si el mazo no está disponible (privado o eliminado), aparecerá un mensaje de error. + +--- + +### Lorcana + +| Web | URL de ejemplo | +|-----|---------------| +| [lorcana.gg](https://lorcana.gg) | `https://lorcana.gg/decks/nombre-del-mazo/` | +| [inkdecks.com](https://inkdecks.com) | `https://inkdecks.com/lorcana-metagame/deck-nombre-ID` | +| [dreamborn.ink](https://dreamborn.ink) | `https://dreamborn.ink/es/decks/ID` | + +Pega la URL del mazo en la pestaña **Lorcana** de la interfaz gráfica y pulsa **Añadir**. + +> **Nota:** dreamborn.ink requiere que **Google Chrome** esté instalado, ya que la web usa protección anti-bots que solo un navegador real puede superar. El programa abre Chrome minimizado, carga la página y lo cierra automáticamente. + --- ## Clave de API de Google Drive (recomendado) @@ -82,59 +128,7 @@ Instala `Pillow`, `reportlab` y `gdown`. ## Uso -Hay dos formas: por línea de comandos (CLI) o con la interfaz gráfica (GUI). - -### A) Línea de comandos (CLI) - -1. Coloca tus archivos `.xml` de MPCFill en la carpeta `xml/` (en la raíz del proyecto). Puedes poner uno o varios. -2. Ejecuta: - ``` - python -m cli.main - ``` -3. Los PDFs aparecen en una carpeta nueva por ejecución dentro de `out/`, con el nombre `DD_MM_YYYY_HH-MM-SS`. Cada PDF se nombra como el XML de origen: - - `xml/mazo.xml` → `out/22_05_2026_14-30-12/out_mazo.pdf` - - Si un PDF supera 500 MB se parte en `out_mazo_1.pdf`, `out_mazo_2.pdf`, … (el corte siempre cae tras una página de reversos para que cada parte siga siendo imprimible a doble cara). - -#### Opciones de la CLI - -| Opción | Por defecto | Para qué sirve | -|----------------|-------------|----------------| -| `--xml-dir` | `xml` | Carpeta donde leer los `.xml`. | -| `--out-dir` | `out` | Carpeta donde escribir los PDFs. | -| `--workdir` | `workdir` | Carpeta para imágenes descargadas (`raw/`) e intermedias (`bled/`). | -| `--test` | desactivado | **No** borra `workdir/raw` ni `workdir/bled` al terminar; útil para iterar sin volver a descargar y recortar. | -| `--yes` / `-y` | desactivado | Continuar sin pedir confirmación si alguna baraja no es múltiplo de 9. Útil para scripts. | - -Ejemplos: -``` -python -m cli.main -python -m cli.main --test -python -m cli.main --xml-dir mis_xmls --out-dir resultado -python -m cli.main -y # sin prompts -``` - -#### Ejemplo de ejecución - -``` -Encontrados 2 XML(s) en 'xml'. -Carpeta de salida: out\22_05_2026_14-30-12 - - mazo_a.xml: 95 cartas (4 hueco(s) en blanco) - - mazo_b.xml: 4 cartas (5 hueco(s) en blanco) - -Se fusionarán las siguientes barajas para evitar huecos en blanco: - • mazo_a_mazo_b_union.pdf ← mazo_a.xml, mazo_b.xml (99 cartas) - -Procesando: mazo_a_mazo_b_union (fusión) -Descargando: [##############################] 89/89 ( 12.4s) -Recortando : [##############################] 89/89 ( 6.1s) -Generando : [##############################] 11/11 ( 88.2s) - -> out\22_05_2026_14-30-12\out_mazo_a_mazo_b_union.pdf (417.5 MB) - -Resumen de fusiones escrito en: out\22_05_2026_14-30-12\resumen.txt -Tiempo total: 106.7s -``` - -### B) Interfaz gráfica (GUI) +### Interfaz gráfica (GUI) Lanza la ventana: ``` @@ -146,7 +140,7 @@ Aparece una ventana con: - **Seleccionar XMLs…** abre el explorador para elegir uno o varios `.xml` (Ctrl+click para varios). - **Lista** con los archivos en cola y botones para *Quitar selección* / *Vaciar*. - **Conservar caché**: si está marcado, no borra `workdir/raw` y `workdir/bled` al terminar (acelera futuras regeneraciones del mismo XML). **Por defecto desactivado.** -- **Generar PDF(s)**: arranca el proceso. Solo se activa cuando hay al menos un XML en la lista. +- **Generar PDF(s)**: arranca el proceso. Solo se activa cuando hay al menos un XML en la lista o un mazo añadido desde una web. - **Estado + barra de progreso** se actualizan durante la generación. Antes de generar: @@ -188,10 +182,8 @@ El sistema lo gestiona así: ``` MPCFillToPDF/ -├── xml/ ← .xml de MPCFill (modo CLI) ├── out/ ← una subcarpeta por ejecución (DD_MM_YYYY_HH-MM-SS) con los PDFs y, si hay fusión, resumen.txt ├── workdir/ ← caché temporal: raw/ (descargas) y bled/ (recortes) -├── cli/main.py ← entrada CLI ├── gui/ │ ├── main.py ← entrada GUI (Tkinter) │ └── paths.py ← resolver out/ y workdir/ junto al .exe @@ -202,7 +194,10 @@ MPCFillToPDF/ │ ├── pdf_generator.py ← maquetación + crop marks + barra de calibración │ ├── pipeline.py ← orquestador (run, run_merged) │ ├── precheck.py ← conteo, planificación de fusiones, manifiesto +│ ├── op_scraper.py ← descarga mazos de One Piece desde webs especializadas │ └── assets/ ← imágenes embebidas (color_bar.png, corner_mark.png) +├── resources/ +│ └── backs/op/ ← reversos para cartas de One Piece (default.png, lider.png) └── build_exe.py ← script de empaquetado PyInstaller ``` diff --git a/build_exe.py b/build_exe.py index c832043..ef504ed 100644 --- a/build_exe.py +++ b/build_exe.py @@ -2,10 +2,12 @@ Run: pip install pyinstaller - python build_exe.py + python build_exe.py # standard build, no debug logging + python build_exe.py --debug-logging # enables gui.log in the built exe Produces `dist/MPCFillToPDF.exe`. The .exe is portable: drop it in any -folder and it will create `out/` and `workdir/` next to itself. +folder and it will create `MPCFillToPDF/archivos generados/` and +`MPCFillToPDF/procesamiento/` next to itself. API key embedding ----------------- @@ -16,6 +18,12 @@ `src/_bundled_key.py` is listed in .gitignore and deleted after the build. +Debug logging +------------- +Pass `--debug-logging` to bake `gui/_build_flags.py` with `DEBUG_LOGGING = True`. +Without the flag the file is written with `False` and no `gui.log` is created. +`gui/_build_flags.py` is listed in .gitignore and deleted after the build. + SmartScreen / antivirus false positives --------------------------------------- This build embeds Windows version metadata via `version_file.txt`, which @@ -36,6 +44,7 @@ After that, run `python build_exe.py` as usual. """ +import argparse import json import os import shutil @@ -47,8 +56,11 @@ APP_NAME = "MPCFillToPDF" ENTRY = ROOT / "gui" / "main.py" ASSETS = ROOT / "src" / "assets" +ICONS = ROOT / "icons" +RESOURCES = ROOT / "resources" VERSION_FILE = ROOT / "version_file.txt" BUNDLED_KEY_PATH = ROOT / "src" / "_bundled_key.py" +BUILD_FLAGS_PATH = ROOT / "gui" / "_build_flags.py" def _embed_api_key() -> bool: @@ -92,7 +104,7 @@ def _embed_api_key() -> bool: " return bytes(a ^ b for a, b in zip(_E, _M)).decode('utf-8')\n", encoding="utf-8", ) - print(f"API key embedded (XOR-obfuscated) → {BUNDLED_KEY_PATH.name}") + print(f"API key embedded (XOR-obfuscated) -> {BUNDLED_KEY_PATH.name}") return True @@ -104,24 +116,61 @@ def _remove_bundled_key() -> None: pass +def _write_build_flags(debug_logging: bool) -> None: + BUILD_FLAGS_PATH.write_text( + f"# Auto-generated by build_exe.py — do not commit.\nDEBUG_LOGGING = {debug_logging}\n", + encoding="utf-8", + ) + print(f"Build flags written (DEBUG_LOGGING={debug_logging}) -> {BUILD_FLAGS_PATH.name}") + + +def _remove_build_flags() -> None: + try: + BUILD_FLAGS_PATH.unlink() + print(f"Cleaned up {BUILD_FLAGS_PATH.name}") + except FileNotFoundError: + pass + + +def _check_paths() -> None: + missing = [p for p in (ENTRY, VERSION_FILE, ASSETS, ICONS, RESOURCES) if not p.exists()] + if missing: + for p in missing: + print(f"ERROR: ruta requerida no encontrada: {p}", file=sys.stderr) + sys.exit(1) + + def main() -> None: + parser = argparse.ArgumentParser(description="Build MPCFillToPDF.exe") + parser.add_argument( + "--debug-logging", + action="store_true", + help="Enable gui.log file logging in the built exe (default: off)", + ) + cli_args = parser.parse_args() + if shutil.which("pyinstaller") is None: print("pyinstaller not found. Install it with: pip install pyinstaller", file=sys.stderr) sys.exit(1) + _check_paths() _embed_api_key() + _write_build_flags(cli_args.debug_logging) + sep = ";" if sys.platform == "win32" else ":" args = [ "pyinstaller", "--noconfirm", "--clean", "--onefile", "--windowed", + "--log-level=WARN", "--name", APP_NAME, f"--version-file={VERSION_FILE}", - f"--add-data={ASSETS}{';' if sys.platform == 'win32' else ':'}src/assets", - # Make sure these packages are bundled even when discovered indirectly + f"--add-data={ASSETS}{sep}src/assets", + f"--add-data={ICONS}{sep}icons", + f"--add-data={RESOURCES}{sep}resources", "--hidden-import=PIL.Image", "--hidden-import=reportlab.pdfgen", "--hidden-import=gdown", @@ -133,6 +182,7 @@ def main() -> None: subprocess.run(args, check=True, cwd=ROOT) finally: _remove_bundled_key() + _remove_build_flags() print(f"\nBuilt: {ROOT / 'dist' / (APP_NAME + '.exe')}") diff --git a/cli/__init__.py b/cli/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/cli/main.py b/cli/main.py index 16f3ff2..933e0b7 100644 --- a/cli/main.py +++ b/cli/main.py @@ -20,7 +20,6 @@ analyze, check_drive_access, collect_drive_ids, - format_merge_info, format_warning, plan, write_manifest, @@ -269,11 +268,6 @@ def main() -> None: plan_ = plan(reports, local_count=len(local_fronts)) if reports else None if plan_ is not None: - merge_info = format_merge_info(plan_) - if merge_info: - print() - print(merge_info) - warning = format_warning(plan_) if warning and not args.yes: print() diff --git a/gui/locals_tab.py b/gui/locals_tab.py new file mode 100644 index 0000000..e08f515 --- /dev/null +++ b/gui/locals_tab.py @@ -0,0 +1,473 @@ +"""LocalsTabMixin — local image panel methods for the App class.""" + +from __future__ import annotations + +import tkinter as tk +from pathlib import Path +from tkinter import filedialog, ttk + +from gui.widgets import FRONT_NAME_WIDTH, IMAGE_FILETYPES, WINDND_AVAILABLE, ImageTooltip, ellipsize +from src.constants import SUPPORTED_IMAGE_EXTS + + +class LocalsTabMixin: + """Methods for the local images pane (backs + fronts with per-card back assignment).""" + + def _build_locals_pane(self, parent: ttk.Frame) -> None: + local_frame = ttk.LabelFrame(parent, text="Imágenes locales (opcional)") + local_frame.grid(row=0, column=1, sticky="nsew", padx=(4, 0)) + self._locals_drop_frame = local_frame + local_frame.columnconfigure(0, weight=1) + local_frame.rowconfigure(1, weight=1, uniform="locals") + local_frame.rowconfigure(3, weight=2, uniform="locals") + + backs_hdr = ttk.Frame(local_frame) + backs_hdr.grid(row=0, column=0, sticky="ew", padx=6, pady=(6, 2)) + ttk.Label(backs_hdr, text="Traseras (numeradas 1, 2, …):").pack(side=tk.LEFT) + self._back_crop_all = tk.BooleanVar(value=False) + ttk.Checkbutton( + backs_hdr, + text="Recortar todas", + variable=self._back_crop_all, + command=self._on_back_crop_all, + ).pack(side=tk.RIGHT, padx=(8, 0)) + + backs_block = ttk.Frame(local_frame) + backs_block.grid(row=1, column=0, sticky="nsew", padx=6) + backs_block.columnconfigure(0, weight=1) + backs_block.rowconfigure(0, weight=1) + + self.backs_canvas, self.backs_inner, self._backs_window = self._build_scrollable_rows( + backs_block + ) + self.backs_canvas.bind("", lambda _e: self._bind_mousewheel(self.backs_canvas, True)) + self.backs_canvas.bind( + "", lambda _e: self._bind_mousewheel(self.backs_canvas, False) + ) + + dnd_hint = " o arrastra aquí" if WINDND_AVAILABLE else "" + self._backs_empty_label = ttk.Label( + self.backs_inner, + text=f"(sin traseras — usa «Seleccionar imágenes…»{dnd_hint})", + foreground="#777", + padding=(8, 10), + ) + self._backs_empty_label.pack(anchor="w") + + backs_btn_row = ttk.Frame(backs_block) + backs_btn_row.grid(row=1, column=0, sticky="ew", pady=(2, 6)) + ttk.Button( + backs_btn_row, text="Seleccionar imágenes…", command=self._pick_local_backs + ).pack(side=tk.LEFT) + ttk.Button(backs_btn_row, text="Vaciar", command=self._clear_local_backs).pack( + side=tk.LEFT, padx=6 + ) + + ttk.Separator(local_frame, orient=tk.HORIZONTAL).grid( + row=2, column=0, sticky="ew", padx=6, pady=(4, 4) + ) + + fronts_block = ttk.Frame(local_frame) + fronts_block.grid(row=3, column=0, sticky="nsew", padx=6, pady=(0, 6)) + fronts_block.columnconfigure(0, weight=1) + fronts_block.rowconfigure(1, weight=1) + + fronts_hdr = ttk.Frame(fronts_block) + fronts_hdr.grid(row=0, column=0, sticky="ew", pady=(2, 4)) + self._fronts_header_var = tk.StringVar(value="Frontales (asignar trasera por carta):") + ttk.Label(fronts_hdr, textvariable=self._fronts_header_var).pack(side=tk.LEFT) + self._front_crop_all = tk.BooleanVar(value=False) + ttk.Checkbutton( + fronts_hdr, + text="Recortar todas", + variable=self._front_crop_all, + command=self._on_front_crop_all, + ).pack(side=tk.RIGHT, padx=(8, 0)) + + fronts_holder = ttk.Frame(fronts_block) + fronts_holder.grid(row=1, column=0, sticky="nsew") + fronts_holder.columnconfigure(0, weight=1) + fronts_holder.rowconfigure(0, weight=1) + + self.fronts_canvas, self.fronts_inner, self._fronts_window = self._build_scrollable_rows( + fronts_holder + ) + self.fronts_canvas.bind( + "", lambda _e: self._bind_mousewheel(self.fronts_canvas, True) + ) + self.fronts_canvas.bind( + "", lambda _e: self._bind_mousewheel(self.fronts_canvas, False) + ) + + dnd_hint = " o arrastra aquí" if WINDND_AVAILABLE else "" + self._fronts_empty_label = ttk.Label( + self.fronts_inner, + text=f"(sin frontales — usa «Seleccionar imágenes…»{dnd_hint})", + foreground="#777", + padding=(8, 10), + ) + self._fronts_empty_label.pack(anchor="w") + + fronts_btn_row = ttk.Frame(fronts_block) + fronts_btn_row.grid(row=2, column=0, sticky="ew", pady=(4, 0)) + ttk.Button( + fronts_btn_row, text="Seleccionar imágenes…", command=self._pick_local_fronts + ).pack(side=tk.LEFT) + ttk.Button(fronts_btn_row, text="Vaciar", command=self._clear_local_fronts).pack( + side=tk.LEFT, padx=6 + ) + + def _pick_local_backs(self) -> None: + paths = filedialog.askopenfilenames( + title="Selecciona imágenes locales (traseras)", + filetypes=IMAGE_FILETYPES, + ) + was_empty = not self.state.local_backs + added = False + for p in paths: + pp = Path(p) + if pp not in self.state.local_backs: + self.state.local_backs.append(pp) + self.state.local_back_crop.append(False) + added = True + if added: + if was_empty and self.state.local_backs: + first = self.state.local_backs[0] + for i, assigned in enumerate(self.state.front_back_paths): + if assigned is None: + self.state.front_back_paths[i] = first + self._refresh_back_rows() + self._refresh_front_rows() + self._refresh_generate_state() + + def _remove_back(self, idx: int) -> None: + if not (0 <= idx < len(self.state.local_backs)): + return + removed_path = self.state.local_backs[idx] + del self.state.local_backs[idx] + del self.state.local_back_crop[idx] + for i, assigned in enumerate(self.state.front_back_paths): + if assigned == removed_path: + self.state.front_back_paths[i] = None + self._refresh_back_rows() + self._refresh_front_rows() + self._refresh_generate_state() + + def _clear_local_backs(self) -> None: + if not self.state.local_backs: + return + self.state.local_backs.clear() + self.state.local_back_crop.clear() + self.state.front_back_paths = [None] * len(self.state.front_back_paths) + self._refresh_back_rows() + self._refresh_front_rows() + self._refresh_generate_state() + + def _on_back_crop_change(self, idx: int, var: tk.BooleanVar) -> None: + if 0 <= idx < len(self.state.local_back_crop): + self.state.local_back_crop[idx] = bool(var.get()) + + def _refresh_back_rows(self) -> None: + for row in self._back_rows: + row["frame"].destroy() + self._back_rows.clear() + + if not self.state.local_backs: + self._backs_empty_label.pack(anchor="w") + return + self._backs_empty_label.pack_forget() + + for i, back_path in enumerate(self.state.local_backs): + row = ttk.Frame(self.backs_inner) + row.pack(fill=tk.X, pady=1, padx=2) + + ttk.Label(row, text=f"{i + 1:>3}.", width=4, anchor="e").pack(side=tk.LEFT) + name_lbl = ttk.Label( + row, + text=ellipsize(back_path.name, FRONT_NAME_WIDTH), + width=FRONT_NAME_WIDTH + 1, + anchor="w", + ) + name_lbl.pack(side=tk.LEFT, padx=(4, 8)) + ImageTooltip(name_lbl, back_path) + + crop_var = tk.BooleanVar(value=self.state.local_back_crop[i]) + ttk.Checkbutton( + row, + text="Recortar bordes extra", + variable=crop_var, + command=lambda idx=i, v=crop_var: self._on_back_crop_change(idx, v), + ).pack(side=tk.LEFT, padx=(4, 6)) + + ttk.Button( + row, + text="✕", + width=2, + command=lambda idx=i: self._remove_back(idx), + ).pack(side=tk.RIGHT) + ttk.Button( + row, + text="▼", + width=2, + command=lambda idx=i: self._move_back_down(idx), + ).pack(side=tk.RIGHT, padx=(0, 1)) + ttk.Button( + row, + text="▲", + width=2, + command=lambda idx=i: self._move_back_up(idx), + ).pack(side=tk.RIGHT, padx=(0, 1)) + + self._back_rows.append({"frame": row, "crop_var": crop_var}) + + self.backs_inner.update_idletasks() + self.backs_canvas.configure(scrollregion=self.backs_canvas.bbox("all")) + + def _pick_local_fronts(self) -> None: + paths = filedialog.askopenfilenames( + title="Selecciona imágenes locales (frontales)", + filetypes=IMAGE_FILETYPES, + ) + default_back = self.state.local_backs[0] if self.state.local_backs else None + added = False + for p in paths: + pp = Path(p) + if pp not in self.state.local_fronts: + self.state.local_fronts.append(pp) + self.state.front_back_paths.append(default_back) + self.state.local_front_crop.append(False) + added = True + if added: + self._refresh_front_rows() + self._refresh_generate_state() + + def _clear_local_fronts(self) -> None: + if not self.state.local_fronts: + return + self.state.local_fronts.clear() + self.state.front_back_paths.clear() + self.state.local_front_crop.clear() + self._refresh_front_rows() + self._refresh_generate_state() + + def _remove_front(self, idx: int) -> None: + if 0 <= idx < len(self.state.local_fronts): + del self.state.local_fronts[idx] + del self.state.front_back_paths[idx] + del self.state.local_front_crop[idx] + self._refresh_front_rows() + self._refresh_generate_state() + + def _on_front_back_change(self, idx: int, var: tk.StringVar) -> None: + """Combobox callback: maps the displayed choice back to a Path or None.""" + choice = var.get() + try: + n = int(choice) + except (TypeError, ValueError): + self.state.front_back_paths[idx] = None + return + if 1 <= n <= len(self.state.local_backs): + self.state.front_back_paths[idx] = self.state.local_backs[n - 1] + + def _on_front_crop_change(self, idx: int, var: tk.BooleanVar) -> None: + if 0 <= idx < len(self.state.local_front_crop): + self.state.local_front_crop[idx] = bool(var.get()) + + def _on_front_crop_all(self) -> None: + val = bool(self._front_crop_all.get()) + for i in range(len(self.state.local_front_crop)): + self.state.local_front_crop[i] = val + self._refresh_front_rows() + + def _on_back_crop_all(self) -> None: + val = bool(self._back_crop_all.get()) + for i in range(len(self.state.local_back_crop)): + self.state.local_back_crop[i] = val + self._refresh_back_rows() + + def _refresh_front_rows(self) -> None: + for row in self._front_rows: + row["frame"].destroy() + self._front_rows.clear() + + if not self.state.local_fronts: + self._fronts_empty_label.pack(anchor="w") + self._fronts_header_var.set("Frontales (asignar trasera por carta):") + return + self._fronts_empty_label.pack_forget() + + numbered = [str(i) for i in range(1, len(self.state.local_backs) + 1)] + combo_values = ["—", *numbered] + backs_present = bool(numbered) + + for i, front_path in enumerate(self.state.local_fronts): + row = ttk.Frame(self.fronts_inner) + row.pack(fill=tk.X, pady=1, padx=2) + + ttk.Label(row, text=f"{i + 1:>3}.", width=4, anchor="e").pack(side=tk.LEFT) + front_name_lbl = ttk.Label( + row, + text=ellipsize(front_path.name, FRONT_NAME_WIDTH), + width=FRONT_NAME_WIDTH + 1, + anchor="w", + ) + front_name_lbl.pack(side=tk.LEFT, padx=(4, 8)) + ImageTooltip(front_name_lbl, front_path) + + ttk.Label(row, text="Trasera:").pack(side=tk.LEFT) + + assigned = self.state.front_back_paths[i] + if assigned is not None and assigned not in self.state.local_backs: + assigned = None + self.state.front_back_paths[i] = None + var = tk.StringVar() + if assigned is None: + var.set("—") + else: + var.set(str(self.state.local_backs.index(assigned) + 1)) + combo = ttk.Combobox( + row, + values=combo_values, + textvariable=var, + state="readonly" if backs_present else "disabled", + width=4, + ) + combo.bind( + "<>", + lambda _e, idx=i, v=var: self._on_front_back_change(idx, v), + ) + combo.pack(side=tk.LEFT, padx=(4, 6)) + + crop_var = tk.BooleanVar(value=self.state.local_front_crop[i]) + ttk.Checkbutton( + row, + text="Recortar bordes extra", + variable=crop_var, + command=lambda idx=i, v=crop_var: self._on_front_crop_change(idx, v), + ).pack(side=tk.LEFT, padx=(4, 6)) + + ttk.Button( + row, + text="✕", + width=2, + command=lambda idx=i: self._remove_front(idx), + ).pack(side=tk.RIGHT) + ttk.Button( + row, + text="▼", + width=2, + command=lambda idx=i: self._move_front_down(idx), + ).pack(side=tk.RIGHT, padx=(0, 1)) + ttk.Button( + row, + text="▲", + width=2, + command=lambda idx=i: self._move_front_up(idx), + ).pack(side=tk.RIGHT, padx=(0, 1)) + + self._front_rows.append( + {"frame": row, "var": var, "combo": combo, "crop_var": crop_var}, + ) + + self.fronts_inner.update_idletasks() + self.fronts_canvas.configure(scrollregion=self.fronts_canvas.bbox("all")) + + total = len(self.state.local_fronts) + self._fronts_header_var.set( + f"Frontales (asignar trasera por carta): Actualmente: {total} cartas" + ) + + def _move_back_up(self, idx: int) -> None: + if idx > 0: + self.state.local_backs[idx], self.state.local_backs[idx - 1] = ( + self.state.local_backs[idx - 1], + self.state.local_backs[idx], + ) + self.state.local_back_crop[idx], self.state.local_back_crop[idx - 1] = ( + self.state.local_back_crop[idx - 1], + self.state.local_back_crop[idx], + ) + self._refresh_back_rows() + self._refresh_front_rows() + + def _move_back_down(self, idx: int) -> None: + if idx < len(self.state.local_backs) - 1: + self.state.local_backs[idx], self.state.local_backs[idx + 1] = ( + self.state.local_backs[idx + 1], + self.state.local_backs[idx], + ) + self.state.local_back_crop[idx], self.state.local_back_crop[idx + 1] = ( + self.state.local_back_crop[idx + 1], + self.state.local_back_crop[idx], + ) + self._refresh_back_rows() + self._refresh_front_rows() + + def _move_front_up(self, idx: int) -> None: + if idx > 0: + self.state.local_fronts[idx], self.state.local_fronts[idx - 1] = ( + self.state.local_fronts[idx - 1], + self.state.local_fronts[idx], + ) + self.state.front_back_paths[idx], self.state.front_back_paths[idx - 1] = ( + self.state.front_back_paths[idx - 1], + self.state.front_back_paths[idx], + ) + self.state.local_front_crop[idx], self.state.local_front_crop[idx - 1] = ( + self.state.local_front_crop[idx - 1], + self.state.local_front_crop[idx], + ) + self._refresh_front_rows() + + def _move_front_down(self, idx: int) -> None: + if idx < len(self.state.local_fronts) - 1: + self.state.local_fronts[idx], self.state.local_fronts[idx + 1] = ( + self.state.local_fronts[idx + 1], + self.state.local_fronts[idx], + ) + self.state.front_back_paths[idx], self.state.front_back_paths[idx + 1] = ( + self.state.front_back_paths[idx + 1], + self.state.front_back_paths[idx], + ) + self.state.local_front_crop[idx], self.state.local_front_crop[idx + 1] = ( + self.state.local_front_crop[idx + 1], + self.state.local_front_crop[idx], + ) + self._refresh_front_rows() + + def _on_drop_backs(self, files) -> None: + paths = self._decode_drop(files) + was_empty = not self.state.local_backs + added = False + for pp in paths: + if pp.suffix.lower() not in SUPPORTED_IMAGE_EXTS: + continue + if pp not in self.state.local_backs: + self.state.local_backs.append(pp) + self.state.local_back_crop.append(False) + added = True + if added: + if was_empty and self.state.local_backs: + first = self.state.local_backs[0] + for i, assigned in enumerate(self.state.front_back_paths): + if assigned is None: + self.state.front_back_paths[i] = first + self._refresh_back_rows() + self._refresh_front_rows() + self._refresh_generate_state() + + def _on_drop_fronts(self, files) -> None: + paths = self._decode_drop(files) + default_back = self.state.local_backs[0] if self.state.local_backs else None + added = False + for pp in paths: + if pp.suffix.lower() not in SUPPORTED_IMAGE_EXTS: + continue + if pp not in self.state.local_fronts: + self.state.local_fronts.append(pp) + self.state.front_back_paths.append(default_back) + self.state.local_front_crop.append(False) + added = True + if added: + self._refresh_front_rows() + self._refresh_generate_state() diff --git a/gui/lorcana_tab.py b/gui/lorcana_tab.py new file mode 100644 index 0000000..5abafb9 --- /dev/null +++ b/gui/lorcana_tab.py @@ -0,0 +1,226 @@ +"""LorcanaTabMixin — Lorcana tab methods for the App class.""" + +from __future__ import annotations + +import threading +import tkinter as tk +from tkinter import messagebox, ttk + +from gui.widgets import APP_TITLE, attach_context_menu, ellipsize +from src.lorcana_scraper import scrape_deck as lorcana_scrape_deck + + +class LorcanaTabMixin: + """Methods for the Lorcana scraper tab.""" + + def _build_lorcana_tab(self, parent: ttk.Frame) -> None: + parent.columnconfigure(0, weight=1) + parent.rowconfigure(1, weight=1) + + url_row = ttk.Frame(parent) + url_row.grid(row=0, column=0, sticky="ew", padx=6, pady=(10, 4)) + url_row.columnconfigure(1, weight=1) + ttk.Label( + url_row, + text="Webs aceptadas: lorcana.gg, inkdecks.com", + foreground="#999", + font=("Segoe UI", 8), + wraplength=450, + justify="left", + ).grid(row=0, column=0, columnspan=3, sticky="w", pady=(0, 4)) + ttk.Label(url_row, text="URL del mazo:").grid(row=1, column=0, sticky="w", padx=(0, 6)) + self._lorcana_url_var = tk.StringVar() + self._lorcana_url_entry = ttk.Entry(url_row, textvariable=self._lorcana_url_var) + self._lorcana_url_entry.grid(row=1, column=1, sticky="ew") + self._lorcana_url_entry.bind("", lambda _e: self._lorcana_load_deck()) + attach_context_menu(self._lorcana_url_entry) + self._lorcana_load_btn = ttk.Button( + url_row, text="Añadir", width=7, command=self._lorcana_load_deck + ) + self._lorcana_load_btn.grid(row=1, column=2, padx=(6, 0)) + + self._lorcana_status_var = tk.StringVar(value="") + ttk.Label( + url_row, textvariable=self._lorcana_status_var, foreground="#555", anchor="w" + ).grid(row=2, column=0, columnspan=3, sticky="ew", pady=(2, 0)) + + lorcana_list_frame = ttk.Frame(parent) + lorcana_list_frame.grid(row=1, column=0, sticky="nsew", padx=6, pady=(0, 2)) + lorcana_list_frame.columnconfigure(0, weight=1) + lorcana_list_frame.rowconfigure(0, weight=1) + + self._lorcana_canvas, self._lorcana_inner, _ = self._build_scrollable_rows( + lorcana_list_frame + ) + self._lorcana_canvas.bind( + "", lambda _e: self._bind_mousewheel(self._lorcana_canvas, True) + ) + self._lorcana_canvas.bind( + "", lambda _e: self._bind_mousewheel(self._lorcana_canvas, False) + ) + + self._lorcana_empty_label = ttk.Label( + self._lorcana_inner, + text="(introduce una URL de lorcana.gg o inkdecks.com)", + foreground="#777", + padding=(8, 10), + ) + self._lorcana_empty_label.pack(anchor="w") + + lorcana_btn_row = ttk.Frame(parent) + lorcana_btn_row.grid(row=2, column=0, sticky="ew", padx=6, pady=(2, 6)) + ttk.Button(lorcana_btn_row, text="Vaciar todo", command=self._lorcana_clear).pack( + side=tk.LEFT + ) + + def _lorcana_load_deck(self) -> None: + url = self._lorcana_url_var.get().strip() + if not url: + messagebox.showwarning(APP_TITLE, "Introduce una URL de mazo de Lorcana.") + return + + self._lorcana_load_btn.state(["disabled"]) + self._lorcana_status_var.set("Cargando mazo…") + + def _fetch(): + try: + deck = lorcana_scrape_deck(url) + self.events.put(("lorcana_deck_loaded", deck)) + except Exception as e: + self.events.put(("lorcana_deck_error", str(e))) + + threading.Thread(target=_fetch, daemon=True).start() + + def _lorcana_refresh_rows(self) -> None: + for row in self._lorcana_deck_rows: + row["outer"].destroy() + self._lorcana_deck_rows.clear() + + if not self._lorcana_decks: + self._lorcana_empty_label.pack(anchor="w") + return + self._lorcana_empty_label.pack_forget() + + source_labels = { + "lorcana_gg": "lorcana.gg", + "inkdecks": "inkdecks.com", + } + + for idx, deck in enumerate(self._lorcana_decks): + outer = ttk.Frame(self._lorcana_inner, relief="groove", borderwidth=1) + outer.pack(fill=tk.X, pady=3, padx=2) + outer.columnconfigure(0, weight=1) + + summary = ttk.Frame(outer) + summary.pack(fill=tk.X, padx=6, pady=4) + summary.columnconfigure(1, weight=1) + + ttk.Label( + summary, + text=ellipsize(deck.name, 28), + font=("Segoe UI", 9, "bold"), + anchor="w", + ).grid(row=0, column=0, sticky="w", padx=(0, 12)) + + source_txt = source_labels.get(deck.source, deck.source) + ttk.Label( + summary, + text=source_txt, + foreground="#555", + anchor="w", + font=("Segoe UI", 8), + ).grid(row=0, column=1, sticky="w") + + ttk.Label( + summary, + text=f"{deck.total_slots} cartas", + foreground="#888", + anchor="e", + ).grid(row=0, column=2, sticky="e", padx=(8, 6)) + + expanded_var = tk.BooleanVar(value=False) + toggle_btn = ttk.Button( + summary, + text="Detalles ▼", + width=10, + command=lambda i=idx: self._lorcana_toggle_details(i), + ) + toggle_btn.grid(row=0, column=3, padx=(0, 4)) + + ttk.Button( + summary, + text="✕", + width=2, + command=lambda i=idx: self._lorcana_remove_deck(i), + ).grid(row=0, column=4) + + detail = ttk.Frame(outer) + + for card in deck.cards: + row_f = ttk.Frame(detail) + row_f.pack(fill=tk.X, pady=0, padx=(12, 4)) + ttk.Label( + row_f, + text=f"x{card.quantity}", + foreground="#444", + font=("Segoe UI", 8), + width=4, + anchor="e", + ).pack(side=tk.LEFT, padx=(0, 6)) + ttk.Label( + row_f, + text=ellipsize(card.name, 30), + anchor="w", + width=31, + ).pack(side=tk.LEFT) + ttk.Label( + row_f, + text=card.card_id, + foreground="#888", + font=("Segoe UI", 8), + anchor="w", + width=10, + ).pack(side=tk.LEFT, padx=(4, 0)) + + self._lorcana_deck_rows.append( + { + "outer": outer, + "detail": detail, + "toggle_btn": toggle_btn, + "expanded": expanded_var, + "deck": deck, + } + ) + + self._lorcana_inner.update_idletasks() + self._lorcana_canvas.configure(scrollregion=self._lorcana_canvas.bbox("all")) + + def _lorcana_toggle_details(self, idx: int) -> None: + if idx >= len(self._lorcana_deck_rows): + return + row = self._lorcana_deck_rows[idx] + expanded = row["expanded"] + if expanded.get(): + row["detail"].pack_forget() + row["toggle_btn"].configure(text="Detalles ▼") + expanded.set(False) + else: + row["detail"].pack(fill=tk.X, padx=0, pady=(0, 4)) + row["toggle_btn"].configure(text="Detalles ▲") + expanded.set(True) + self._lorcana_inner.update_idletasks() + self._lorcana_canvas.configure(scrollregion=self._lorcana_canvas.bbox("all")) + + def _lorcana_remove_deck(self, idx: int) -> None: + if 0 <= idx < len(self._lorcana_decks): + del self._lorcana_decks[idx] + self._lorcana_refresh_rows() + self._refresh_generate_state() + + def _lorcana_clear(self) -> None: + self._lorcana_decks.clear() + self._lorcana_url_var.set("") + self._lorcana_status_var.set("") + self._lorcana_load_btn.state(["!disabled"]) + self._lorcana_refresh_rows() + self._refresh_generate_state() diff --git a/gui/main.py b/gui/main.py index d04becd..dafabdf 100644 --- a/gui/main.py +++ b/gui/main.py @@ -1,7 +1,6 @@ """MPCFillToPDF GUI — pick XML(s) and optional local images, run the pipeline, open the output folder.""" -import io import logging import math import os @@ -12,7 +11,6 @@ import time import tkinter as tk import traceback -from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field from datetime import datetime from pathlib import Path @@ -25,336 +23,76 @@ except ImportError: _WINDND_AVAILABLE = False -from PIL import Image, ImageTk - +from gui.locals_tab import LocalsTabMixin +from gui.lorcana_tab import LorcanaTabMixin +from gui.op_tab import OPTabMixin from gui.paths import output_dir, work_dir +from gui.rb_tab import RBTabMixin +from gui.widgets import ( + APP_TITLE, + STAGE_LABELS, + attach_context_menu, + ellipsize, + load_tab_icon, + notify, +) +from gui.xml_tab import XmlTabMixin from src.cancellation import Cancelled -from src.constants import CARDS_PER_PAGE, SUPPORTED_IMAGE_EXTS, Stage +from src.constants import CARDS_PER_PAGE, Stage +from src.deck_importer import DeckCard from src.downloader import ( DownloadPartialError, DownloadPermissionError, DownloadRateLimitError, DownloadTimeoutError, ) -from src.parser import CardOrder, parse +from src.lorcana_scraper import LocanaDeck, get_lorcana_back +from src.lorcana_scraper import download_images as lorcana_download +from src.lorcana_scraper import expand_deck as lorcana_expand +from src.op_scraper import OPDeck, get_op_backs +from src.op_scraper import download_images as op_download +from src.op_scraper import expand_deck as op_expand +from src.parser import CardOrder from src.pipeline import run_locals_only, run_plan from src.precheck import ( analyze, check_drive_access, collect_drive_ids, - format_merge_info, format_warning, plan, write_manifest, ) -from src.validator import ValidationWarning, validate +from src.rb_scraper import RBDeck, get_rb_backs +from src.rb_scraper import download_images as rb_download +from src.rb_scraper import expand_deck as rb_expand +from src.scraper_utils import resources_dir +from src.scryfall import download_deck_images as scryfall_download +from src.validator import ValidationWarning _log = logging.getLogger(__name__) -def _notify(title: str, message: str) -> None: - """Show a system notification (best-effort; silently ignored if plyer is missing).""" - try: - from plyer import notification as _n - - _n.notify(title=title, message=message, app_name=APP_TITLE, timeout=8) - except Exception: - pass - - -APP_TITLE = "MPCFillToPDF" -STAGE_LABELS = { - Stage.VERIFY: "Verificando XML", - Stage.DOWNLOAD: "Descargando", - Stage.CROP: "Procesando imágenes", - Stage.PDF: "Generando PDF, Páginas", -} - -IMAGE_FILETYPES = [ - ("Imágenes", "*.jpg *.jpeg *.png *.webp *.bmp *.tif *.tiff"), - ("Todos", "*.*"), -] - -FRONT_NAME_WIDTH = 28 # chars shown before ellipsizing a front filename - - -def _ellipsize(name: str, width: int) -> str: - if len(name) <= width: - return name - return name[: max(0, width - 1)] + "…" - - -_PB_DOWNLOAD_COLOR = "#0078d4" # Windows blue -_PB_CROP_COLOR = "#2e7d32" # dark green - - -class _XmlPb(tk.Canvas): - """Canvas progress bar with centered text overlay — works on all themes.""" - - _W = 130 - _H = 18 - _TROUGH = "#dde5f0" - _BORDER = "#9aafc7" - _TEXT = "#f0f0f0" - - def __init__(self, parent, **kw): - super().__init__( - parent, - width=self._W, - height=self._H, - bg=self._TROUGH, - highlightthickness=1, - highlightbackground=self._BORDER, - **kw, - ) - self._bar = self.create_rectangle(0, 0, 0, self._H, fill=_PB_DOWNLOAD_COLOR, outline="") - self._lbl = self.create_text( - self._W // 2, - self._H // 2, - text="", - fill=self._TEXT, - font=("Segoe UI", 8), - ) - - def set_progress(self, pct: float, text: str = "", color: str | None = None) -> None: - if color is not None: - self.itemconfigure(self._bar, fill=color) - filled = int(self._W * max(0.0, min(100.0, pct)) / 100) - self.coords(self._bar, 0, 0, filled, self._H) - self.itemconfigure(self._lbl, text=text) - - -class _ImageTooltip: - """Floating image preview that appears when the mouse hovers over a widget.""" - - _DELAY_MS = 350 - _MAX_W = 240 - _MAX_H = 336 - - def __init__(self, widget: tk.Widget, image_path: Path) -> None: - self._widget = widget - self._path = image_path - self._after_id: str | None = None - self._tip: tk.Toplevel | None = None - self._photo = None - widget.bind("", self._schedule, add="+") - widget.bind("", self._hide, add="+") - widget.bind("", self._on_motion, add="+") - widget.bind("", lambda _e: self._hide(), add="+") - - def _schedule(self, event) -> None: - self._hide() - self._after_id = self._widget.after( - self._DELAY_MS, - lambda: self._show(event.x_root, event.y_root), - ) - - def _on_motion(self, event) -> None: - if self._tip and self._tip.winfo_exists(): - self._move(event.x_root, event.y_root) - - def _show(self, x_root: int, y_root: int) -> None: - if not self._widget.winfo_exists(): - return - try: - img = Image.open(self._path).convert("RGB") - except Exception: - return - img.thumbnail((self._MAX_W, self._MAX_H), Image.LANCZOS) - self._photo = ImageTk.PhotoImage(img) - parent = self._widget.winfo_toplevel() - self._tip = tk.Toplevel(parent) - self._tip.overrideredirect(True) - self._tip.attributes("-topmost", True) - border = tk.Frame(self._tip, bg="#444", padx=2, pady=2) - border.pack() - tk.Label(border, image=self._photo, bg="#444").pack() - self._tip.update_idletasks() - self._move(x_root, y_root) - - def _move(self, x_root: int, y_root: int) -> None: - if not (self._tip and self._tip.winfo_exists()): - return - tw = self._tip.winfo_width() - th = self._tip.winfo_height() - sw = self._tip.winfo_screenwidth() - sh = self._tip.winfo_screenheight() - x = x_root + 18 - y = y_root + 18 - if x + tw > sw: - x = x_root - tw - 8 - if y + th > sh: - y = y_root - th - 8 - self._tip.geometry(f"+{max(0, x)}+{max(0, y)}") - - def _hide(self, _event=None) -> None: - if self._after_id is not None: - try: - self._widget.after_cancel(self._after_id) - except Exception: - pass - self._after_id = None - if self._tip and self._tip.winfo_exists(): - self._tip.destroy() - self._tip = None - - -class PreviewWindow(tk.Toplevel): - _THUMB_W = 80 - _THUMB_H = 112 - _COLS = 4 - _THUMB_URL = "https://drive.google.com/thumbnail?id={}&sz=w80" - _SPINNER = ["◐", "◓", "◑", "◒"] - - def __init__(self, parent: tk.Misc, xml_path: Path, order: CardOrder) -> None: - super().__init__(parent) - self.title(f"Vista previa — {xml_path.name}") - self.geometry("700x520") - self.resizable(True, True) - - self._cancel = threading.Event() - self._pending: queue.Queue = queue.Queue() - self._photo_refs: list = [] - self._spinner_frame: int = 0 - self._loading_labels: list[tk.Label] = [] - self._loading_set: set[int] = set() - - placeholder = Image.new("RGB", (self._THUMB_W, self._THUMB_H), (210, 210, 210)) - self._placeholder_photo = ImageTk.PhotoImage(placeholder) - - frame = ttk.Frame(self) - frame.pack(fill=tk.BOTH, expand=True, padx=8, pady=8) - frame.columnconfigure(0, weight=1) - frame.rowconfigure(0, weight=1) - - canvas = tk.Canvas(frame, highlightthickness=0) - sb = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=canvas.yview) - canvas.configure(yscrollcommand=sb.set) - canvas.grid(row=0, column=0, sticky="nsew") - sb.grid(row=0, column=1, sticky="ns") - inner = ttk.Frame(canvas) - wid = canvas.create_window((0, 0), window=inner, anchor="nw") - inner.bind("", lambda _e: canvas.configure(scrollregion=canvas.bbox("all"))) - canvas.bind("", lambda e: canvas.itemconfigure(wid, width=e.width)) - - def _scroll(event): - canvas.yview_scroll(int(-event.delta / 120), "units") - - self.bind("", _scroll) - canvas.bind("", _scroll) - inner.bind("", _scroll) - - self._img_labels: list[tk.Label] = [] - for idx, card in enumerate(order.fronts): - r, c = divmod(idx, self._COLS) - cell = ttk.Frame(inner, relief=tk.RIDGE, borderwidth=1) - cell.grid(row=r, column=c, padx=4, pady=4) - cell.bind("", _scroll) - - lbl_img = tk.Label(cell, image=self._placeholder_photo, bg="#d2d2d2") - lbl_img.pack() - lbl_img.bind("", _scroll) - self._img_labels.append(lbl_img) - - loading_lbl = tk.Label( - cell, - text=self._SPINNER[0], - font=("Segoe UI", 9), - fg="#888", - ) - loading_lbl.pack() - loading_lbl.bind("", _scroll) - self._loading_labels.append(loading_lbl) - self._loading_set.add(idx) - - name = _ellipsize(card.name, 12) if card.name else "(sin nombre)" - name_lbl = tk.Label(cell, text=name, font=("Segoe UI", 7), wraplength=self._THUMB_W) - name_lbl.pack() - name_lbl.bind("", _scroll) - count = len(card.slots) - if count > 1: - count_lbl = tk.Label( - cell, text=f"x{count}", font=("Segoe UI", 7, "bold"), fg="#555" - ) - count_lbl.pack() - count_lbl.bind("", _scroll) - - self.protocol("WM_DELETE_WINDOW", self._on_close) - self.after(80, self._drain) - self.after(200, self._tick_spinner) - - self._executor = ThreadPoolExecutor(max_workers=4) - threading.Thread(target=self._load_all, args=(order,), daemon=True).start() - - def _tick_spinner(self) -> None: - if self._cancel.is_set() or not self._loading_set: - return - self._spinner_frame = (self._spinner_frame + 1) % len(self._SPINNER) - ch = self._SPINNER[self._spinner_frame] - for idx in self._loading_set: - if idx < len(self._loading_labels): - self._loading_labels[idx].configure(text=ch) - self.after(200, self._tick_spinner) - - def _fetch(self, drive_id: str) -> bytes: - import requests as _req - - resp = _req.get(self._THUMB_URL.format(drive_id), timeout=(5, 15)) - resp.raise_for_status() - return resp.content - - def _load_all(self, order: CardOrder) -> None: - futs = { - self._executor.submit(self._fetch, card.drive_id): idx - for idx, card in enumerate(order.fronts) - } - for fut in as_completed(futs): - if self._cancel.is_set(): - break - idx = futs[fut] - try: - self._pending.put((idx, fut.result())) - except Exception: - self._pending.put((idx, None)) - - def _drain(self) -> None: - if self._cancel.is_set(): - return - try: - while True: - idx, data = self._pending.get_nowait() - self._apply(idx, data) - except queue.Empty: - pass - finally: - if not self._cancel.is_set(): - self.after(80, self._drain) - - def _apply(self, idx: int, data: bytes | None) -> None: - if idx >= len(self._img_labels): - return - self._loading_set.discard(idx) - if idx < len(self._loading_labels): - self._loading_labels[idx].pack_forget() - if data is None: - return +@dataclass +class MtgUrlDeck: + url: str + cards: list[DeckCard] + include_side: bool = False + name: str = "" + + @property + def display_name(self) -> str: + if self.name: + return self.name try: - img = Image.open(io.BytesIO(data)).convert("RGB") - img.thumbnail((self._THUMB_W, self._THUMB_H), Image.LANCZOS) - padded = Image.new("RGB", (self._THUMB_W, self._THUMB_H), (210, 210, 210)) - ox = (self._THUMB_W - img.width) // 2 - oy = (self._THUMB_H - img.height) // 2 - padded.paste(img, (ox, oy)) - photo = ImageTk.PhotoImage(padded) - self._photo_refs.append(photo) - self._img_labels[idx].configure(image=photo) - except Exception: - pass + platform = self.url.split("/")[2].replace("www.", "") + slug = self.url.rstrip("/").split("/")[-1] + return f"{platform}/{slug}" + except IndexError: + return self.url[:40] - def _on_close(self) -> None: - self._cancel.set() - self._executor.shutdown(wait=False) - self.destroy() + @property + def active_count(self) -> int: + return sum(c.quantity for c in self.cards if c.zone == "main" or self.include_side) @dataclass @@ -365,9 +103,10 @@ class AppState: front_back_paths: list[Path | None] = field(default_factory=list) local_front_crop: list[bool] = field(default_factory=list) local_back_crop: list[bool] = field(default_factory=list) + mtg_url_decks: list[MtgUrlDeck] = field(default_factory=list) -class App: +class App(XmlTabMixin, OPTabMixin, RBTabMixin, LorcanaTabMixin, LocalsTabMixin): def __init__(self, root: tk.Tk) -> None: self.root = root root.title(APP_TITLE) @@ -379,7 +118,7 @@ def __init__(self, root: tk.Tk) -> None: self._xml_card_counts: dict[Path, int] = {} self._xml_orders: dict[Path, CardOrder] = {} self._xml_validations: dict[Path, list[ValidationWarning]] = {} - # Tk widgets per row (parallel to local_fronts / local_backs). + self._mtg_deck_rows: list[dict] = [] self._front_rows: list[dict] = [] self._back_rows: list[dict] = [] @@ -391,21 +130,24 @@ def __init__(self, root: tk.Tk) -> None: self._dl_speed_str: str = "" self._custom_output_dir: Path | None = None + self._op_decks: list[OPDeck] = [] + self._op_deck_rows: list[dict] = [] + + self._rb_decks: list[RBDeck] = [] + self._rb_deck_rows: list[dict] = [] + + self._lorcana_decks: list[LocanaDeck] = [] + self._lorcana_deck_rows: list[dict] = [] + self._build_ui() self.root.after(80, self._drain_events) self.root.after(200, self._setup_dnd) - # ------------------------------------------------------------------ - # Layout - # ------------------------------------------------------------------ def _build_ui(self) -> None: pad = {"padx": 10, "pady": 6} - # Contenedor principal que ocupa toda la ventana frm = ttk.Frame(self.root) frm.pack(fill=tk.BOTH, expand=True, **pad) - # 1. SECCIÓN INFERIOR (Controles y Progreso) - # La empaquetamos primero con side=tk.BOTTOM para que "reserve" su espacio bottom_controls = ttk.Frame(frm) bottom_controls.pack(side=tk.BOTTOM, fill=tk.X, pady=(10, 0)) @@ -463,15 +205,11 @@ def _build_ui(self) -> None: anchor=tk.W, ).pack(fill=tk.X) - # Pre-flight summary (updates whenever XMLs / locals change) self._preflight_frame = ttk.LabelFrame(bottom_controls, text="Resumen") - # Initially hidden; _update_preflight shows it when there's content. self._preflight_inner = ttk.Frame(self._preflight_frame) self._preflight_inner.pack(fill=tk.X, padx=6, pady=4) self._preflight_labels: list[ttk.Label] = [] - # 2. SECCIÓN SUPERIOR (Paneles de archivos) - # Usamos un frame intermedio que ocupará el RESTO del espacio top = ttk.Frame(frm) top.pack(side=tk.TOP, fill=tk.BOTH, expand=True) top.columnconfigure(0, weight=1, uniform="cols") @@ -482,148 +220,49 @@ def _build_ui(self) -> None: self._build_locals_pane(top) def _build_xml_pane(self, parent: ttk.Frame) -> None: - xml_frame = ttk.LabelFrame(parent, text="Archivos XML") - xml_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 4)) - self._xml_drop_frame = xml_frame - xml_frame.columnconfigure(0, weight=1) - xml_frame.rowconfigure(0, weight=1) - - xml_list_frame = ttk.Frame(xml_frame) - xml_list_frame.grid(row=0, column=0, sticky="nsew", padx=6, pady=(6, 2)) - xml_list_frame.columnconfigure(0, weight=1) - xml_list_frame.rowconfigure(0, weight=1) - - self.xml_canvas, self.xml_inner, self._xml_window = self._build_scrollable_rows( - xml_list_frame - ) - self.xml_canvas.bind("", lambda _e: self._bind_mousewheel(self.xml_canvas, True)) - self.xml_canvas.bind("", lambda _e: self._bind_mousewheel(self.xml_canvas, False)) - - dnd_hint = " o arrastra aquí" if _WINDND_AVAILABLE else "" - self._xml_empty_label = ttk.Label( - self.xml_inner, - text=f"(sin XMLs — usa «Seleccionar XMLs…»{dnd_hint})", - foreground="#777", - padding=(8, 10), - ) - self._xml_empty_label.pack(anchor="w") - - xml_btn_row = ttk.Frame(xml_frame) - xml_btn_row.grid(row=1, column=0, sticky="ew", padx=6, pady=(0, 6)) - ttk.Button(xml_btn_row, text="Seleccionar XMLs…", command=self._pick_xmls).pack( - side=tk.LEFT - ) - ttk.Button(xml_btn_row, text="Vaciar", command=self._clear_xmls).pack(side=tk.LEFT, padx=6) - - def _build_locals_pane(self, parent: ttk.Frame) -> None: - local_frame = ttk.LabelFrame(parent, text="Imágenes locales (opcional)") - local_frame.grid(row=0, column=1, sticky="nsew", padx=(4, 0)) - self._locals_drop_frame = local_frame - local_frame.columnconfigure(0, weight=1) - local_frame.rowconfigure(1, weight=1, uniform="locals") # backs - local_frame.rowconfigure(3, weight=2, uniform="locals") # fronts - - # --- Backs (top) ---------------------------------------------- - backs_hdr = ttk.Frame(local_frame) - backs_hdr.grid(row=0, column=0, sticky="ew", padx=6, pady=(6, 2)) - ttk.Label(backs_hdr, text="Traseras (numeradas 1, 2, …):").pack(side=tk.LEFT) - self._back_crop_all = tk.BooleanVar(value=False) - ttk.Checkbutton( - backs_hdr, - text="Recortar todas", - variable=self._back_crop_all, - command=self._on_back_crop_all, - ).pack(side=tk.RIGHT, padx=(8, 0)) - - backs_block = ttk.Frame(local_frame) - backs_block.grid(row=1, column=0, sticky="nsew", padx=6) - backs_block.columnconfigure(0, weight=1) - backs_block.rowconfigure(0, weight=1) - - self.backs_canvas, self.backs_inner, self._backs_window = self._build_scrollable_rows( - backs_block - ) - self.backs_canvas.bind("", lambda _e: self._bind_mousewheel(self.backs_canvas, True)) - self.backs_canvas.bind( - "", lambda _e: self._bind_mousewheel(self.backs_canvas, False) - ) - - dnd_hint = " o arrastra aquí" if _WINDND_AVAILABLE else "" - self._backs_empty_label = ttk.Label( - self.backs_inner, - text=f"(sin traseras — usa «Seleccionar imágenes…»{dnd_hint})", - foreground="#777", - padding=(8, 10), - ) - self._backs_empty_label.pack(anchor="w") - - backs_btn_row = ttk.Frame(backs_block) - backs_btn_row.grid(row=1, column=0, sticky="ew", pady=(2, 6)) - ttk.Button( - backs_btn_row, text="Seleccionar imágenes…", command=self._pick_local_backs - ).pack(side=tk.LEFT) - ttk.Button(backs_btn_row, text="Vaciar", command=self._clear_local_backs).pack( - side=tk.LEFT, padx=6 - ) - - ttk.Separator(local_frame, orient=tk.HORIZONTAL).grid( - row=2, column=0, sticky="ew", padx=6, pady=(4, 4) - ) - - # --- Fronts (bottom, scrollable rows with per-front back combo) ----- - fronts_block = ttk.Frame(local_frame) - fronts_block.grid(row=3, column=0, sticky="nsew", padx=6, pady=(0, 6)) - fronts_block.columnconfigure(0, weight=1) - fronts_block.rowconfigure(1, weight=1) - - fronts_hdr = ttk.Frame(fronts_block) - fronts_hdr.grid(row=0, column=0, sticky="ew", pady=(2, 4)) - self._fronts_header_var = tk.StringVar(value="Frontales (asignar trasera por carta):") - ttk.Label(fronts_hdr, textvariable=self._fronts_header_var).pack(side=tk.LEFT) - self._front_crop_all = tk.BooleanVar(value=False) - ttk.Checkbutton( - fronts_hdr, - text="Recortar todas", - variable=self._front_crop_all, - command=self._on_front_crop_all, - ).pack(side=tk.RIGHT, padx=(8, 0)) - - fronts_holder = ttk.Frame(fronts_block) - fronts_holder.grid(row=1, column=0, sticky="nsew") - fronts_holder.columnconfigure(0, weight=1) - fronts_holder.rowconfigure(0, weight=1) - - self.fronts_canvas, self.fronts_inner, self._fronts_window = self._build_scrollable_rows( - fronts_holder - ) - self.fronts_canvas.bind( - "", lambda _e: self._bind_mousewheel(self.fronts_canvas, True) - ) - self.fronts_canvas.bind( - "", lambda _e: self._bind_mousewheel(self.fronts_canvas, False) - ) - - dnd_hint = " o arrastra aquí" if _WINDND_AVAILABLE else "" - self._fronts_empty_label = ttk.Label( - self.fronts_inner, - text=f"(sin frontales — usa «Seleccionar imágenes…»{dnd_hint})", - foreground="#777", - padding=(8, 10), - ) - self._fronts_empty_label.pack(anchor="w") - - fronts_btn_row = ttk.Frame(fronts_block) - fronts_btn_row.grid(row=2, column=0, sticky="ew", pady=(4, 0)) - ttk.Button( - fronts_btn_row, text="Seleccionar imágenes…", command=self._pick_local_fronts - ).pack(side=tk.LEFT) - ttk.Button(fronts_btn_row, text="Vaciar", command=self._clear_local_fronts).pack( - side=tk.LEFT, padx=6 - ) + notebook = ttk.Notebook(parent) + notebook.grid(row=0, column=0, sticky="nsew", padx=(0, 4)) + self._game_notebook = notebook + + self._mtg_icon_img = load_tab_icon("mtg_icon") + self._op_icon_img = load_tab_icon("op_icon") + self._rb_icon_img = load_tab_icon("riftbound_icon") + self._lorcana_icon_img = load_tab_icon("lorcana_icon") + + magic_frame = ttk.Frame(notebook) + magic_kw: dict = {"text": " Magic"} + if self._mtg_icon_img: + magic_kw["image"] = self._mtg_icon_img + magic_kw["compound"] = "left" + notebook.add(magic_frame, **magic_kw) + self._build_magic_tab(magic_frame) + + op_frame = ttk.Frame(notebook) + op_kw: dict = {"text": " One Piece"} + if self._op_icon_img: + op_kw["image"] = self._op_icon_img + op_kw["compound"] = "left" + notebook.add(op_frame, **op_kw) + self._build_onepiece_tab(op_frame) + + rb_frame = ttk.Frame(notebook) + rb_kw: dict = {"text": " Riftbound"} + if self._rb_icon_img: + rb_kw["image"] = self._rb_icon_img + rb_kw["compound"] = "left" + notebook.add(rb_frame, **rb_kw) + self._build_riftbound_tab(rb_frame) + + lorcana_frame = ttk.Frame(notebook) + lorcana_kw: dict = {"text": " Lorcana"} + if self._lorcana_icon_img: + lorcana_kw["image"] = self._lorcana_icon_img + lorcana_kw["compound"] = "left" + notebook.add(lorcana_frame, **lorcana_kw) + self._build_lorcana_tab(lorcana_frame) def _build_scrollable_rows(self, parent: ttk.Frame): - """Create a Canvas + inner Frame pair for a vertically scrolling list - of per-row widgets. Returns (canvas, inner_frame, window_id).""" + """Create a Canvas + inner Frame for a vertically scrolling list of rows.""" holder = ttk.Frame(parent, relief=tk.SUNKEN, borderwidth=1) holder.grid(row=0, column=0, sticky="nsew") holder.columnconfigure(0, weight=1) @@ -645,546 +284,6 @@ def _build_scrollable_rows(self, parent: ttk.Frame): ) return canvas, inner, window_id - # ------------------------------------------------------------------ - # XML pickers - # ------------------------------------------------------------------ - def _pick_xmls(self) -> None: - paths = filedialog.askopenfilenames( - title="Selecciona archivos XML de MPCFill", - filetypes=[("Archivos XML", "*.xml"), ("Todos", "*.*")], - ) - added = 0 - for p in paths: - pp = Path(p) - if pp not in self.state.xml_paths: - self.state.xml_paths.append(pp) - added += 1 - if pp not in self._xml_card_counts: - try: - rpts = analyze([pp]) - if rpts: - self._xml_card_counts[pp] = rpts[0].cards - except Exception: - pass - if pp not in self._xml_orders: - try: - self._xml_orders[pp] = parse(pp) - except Exception: - pass - if pp not in self._xml_validations: - try: - self._xml_validations[pp] = validate(pp) - except Exception: - self._xml_validations[pp] = [] - if added: - self._refresh_xml_rows() - self.status_var.set(f"{len(self.state.xml_paths)} XML(s) en cola.") - self._refresh_generate_state() - - def _remove_xml(self, idx: int) -> None: - if 0 <= idx < len(self.state.xml_paths): - p = self.state.xml_paths[idx] - self._xml_card_counts.pop(p, None) - self._xml_orders.pop(p, None) - self._xml_validations.pop(p, None) - del self.state.xml_paths[idx] - self._refresh_xml_rows() - self._refresh_generate_state() - - def _clear_xmls(self) -> None: - self.state.xml_paths.clear() - self._xml_card_counts.clear() - self._xml_orders.clear() - self._xml_validations.clear() - self._refresh_xml_rows() - self._refresh_generate_state() - - def _refresh_xml_rows(self) -> None: - for row in self._xml_rows: - row["frame"].destroy() - self._xml_rows.clear() - - if not self.state.xml_paths: - self._xml_empty_label.pack(anchor="w") - return - self._xml_empty_label.pack_forget() - - for i, xml_path in enumerate(self.state.xml_paths): - frame = ttk.Frame(self.xml_inner) - frame.pack(fill=tk.X, pady=1, padx=2) - frame.columnconfigure(0, weight=1) - - ttk.Label( - frame, - text=_ellipsize(xml_path.name, 32), - anchor="w", - ).grid(row=0, column=0, sticky="ew", padx=(4, 8)) - - card_count = self._xml_card_counts.get(xml_path) - cards_text = f"{card_count} cartas" if card_count is not None else "" - ttk.Label(frame, text=cards_text, foreground="#555", width=10, anchor="e").grid( - row=0, - column=1, - padx=(0, 8), - ) - - pb = _XmlPb(frame) - pb.grid(row=0, column=2, padx=(0, 4)) - pb.grid_remove() - - count_var = tk.StringVar(value="") - count_lbl = ttk.Label(frame, textvariable=count_var, width=9, anchor="e") - count_lbl.grid(row=0, column=3, padx=(0, 4)) - count_lbl.grid_remove() - - ttk.Button( - frame, - text="▲", - width=2, - command=lambda idx=i: self._move_xml_up(idx), - ).grid(row=0, column=4, padx=(0, 1)) - ttk.Button( - frame, - text="▼", - width=2, - command=lambda idx=i: self._move_xml_down(idx), - ).grid(row=0, column=5, padx=(0, 1)) - ttk.Button( - frame, - text="✕", - width=2, - command=lambda idx=i: self._remove_xml(idx), - ).grid(row=0, column=6, padx=(0, 1)) - ttk.Button( - frame, - text="Ver…", - width=4, - command=lambda p=xml_path: self._show_preview(p), - ).grid(row=0, column=7, padx=(0, 2)) - - xml_warnings = self._xml_validations.get(xml_path, []) - warn_btn = ttk.Button( - frame, - text="⚠", - width=2, - command=lambda p=xml_path: self._show_xml_warnings(p), - ) - warn_btn.grid(row=0, column=8, padx=(0, 2)) - if not xml_warnings: - warn_btn.grid_remove() - - self._xml_rows.append( - { - "frame": frame, - "pb": pb, - "count_var": count_var, - "count_lbl": count_lbl, - "warn_btn": warn_btn, - } - ) - - self.xml_inner.update_idletasks() - self.xml_canvas.configure(scrollregion=self.xml_canvas.bbox("all")) - - def _show_xml_download_progress(self, xml_name: str, done: int, total: int) -> None: - for xml_path, row in zip(self.state.xml_paths, self._xml_rows): - if xml_path.name == xml_name: - pct = (done / total * 100.0) if total else 100.0 - row["pb"].set_progress(pct, "Descargando", color=_PB_DOWNLOAD_COLOR) - row["count_var"].set(f"{done}/{total}") - row["pb"].grid() - row["count_lbl"].grid() - break - - def _show_xml_crop_progress(self, xml_name: str, done: int, total: int) -> None: - for xml_path, row in zip(self.state.xml_paths, self._xml_rows): - if xml_path.name == xml_name: - pct = (done / total * 100.0) if total else 100.0 - row["pb"].set_progress(pct, "Recortando", color=_PB_CROP_COLOR) - row["count_var"].set(f"{done}/{total}") - row["pb"].grid() - row["count_lbl"].grid() - break - - def _reset_xml_download_progress(self) -> None: - for row in self._xml_rows: - row["pb"].set_progress(0, "") - row["count_var"].set("") - row["pb"].grid_remove() - row["count_lbl"].grid_remove() - - def _show_preview(self, xml_path: Path) -> None: - order = self._xml_orders.get(xml_path) - if order is None: - messagebox.showinfo(APP_TITLE, "No hay datos de cartas para esta XML.") - return - PreviewWindow(self.root, xml_path, order) - - def _show_xml_warnings(self, xml_path: Path) -> None: - warnings = self._xml_validations.get(xml_path, []) - if not warnings: - return - msg = "\n".join(f"• {w.message}" for w in warnings) - messagebox.showwarning( - APP_TITLE, - f"Advertencias en {xml_path.name}:\n\n{msg}", - ) - - # ------------------------------------------------------------------ - # Local back pickers - # ------------------------------------------------------------------ - def _pick_local_backs(self) -> None: - paths = filedialog.askopenfilenames( - title="Selecciona imágenes locales (traseras)", - filetypes=IMAGE_FILETYPES, - ) - was_empty = not self.state.local_backs - added = False - for p in paths: - pp = Path(p) - if pp not in self.state.local_backs: - self.state.local_backs.append(pp) - self.state.local_back_crop.append(False) - added = True - if added: - # Going from 0 → ≥1 backs: auto-assign the new first back to any - # fronts that don't already have an explicit pick. - if was_empty and self.state.local_backs: - first = self.state.local_backs[0] - for i, assigned in enumerate(self.state.front_back_paths): - if assigned is None: - self.state.front_back_paths[i] = first - self._refresh_back_rows() - self._refresh_front_rows() - self._refresh_generate_state() - - def _remove_back(self, idx: int) -> None: - if not (0 <= idx < len(self.state.local_backs)): - return - removed_path = self.state.local_backs[idx] - del self.state.local_backs[idx] - del self.state.local_back_crop[idx] - for i, assigned in enumerate(self.state.front_back_paths): - if assigned == removed_path: - self.state.front_back_paths[i] = None - self._refresh_back_rows() - self._refresh_front_rows() - self._refresh_generate_state() - - def _clear_local_backs(self) -> None: - if not self.state.local_backs: - return - self.state.local_backs.clear() - self.state.local_back_crop.clear() - # All explicit assignments are now invalid → fall back to default. - self.state.front_back_paths = [None] * len(self.state.front_back_paths) - self._refresh_back_rows() - self._refresh_front_rows() - self._refresh_generate_state() - - def _on_back_crop_change(self, idx: int, var: tk.BooleanVar) -> None: - if 0 <= idx < len(self.state.local_back_crop): - self.state.local_back_crop[idx] = bool(var.get()) - - def _refresh_back_rows(self) -> None: - for row in self._back_rows: - row["frame"].destroy() - self._back_rows.clear() - - if not self.state.local_backs: - self._backs_empty_label.pack(anchor="w") - return - self._backs_empty_label.pack_forget() - - for i, back_path in enumerate(self.state.local_backs): - row = ttk.Frame(self.backs_inner) - row.pack(fill=tk.X, pady=1, padx=2) - - ttk.Label(row, text=f"{i + 1:>3}.", width=4, anchor="e").pack(side=tk.LEFT) - name_lbl = ttk.Label( - row, - text=_ellipsize(back_path.name, FRONT_NAME_WIDTH), - width=FRONT_NAME_WIDTH + 1, - anchor="w", - ) - name_lbl.pack(side=tk.LEFT, padx=(4, 8)) - _ImageTooltip(name_lbl, back_path) - - crop_var = tk.BooleanVar(value=self.state.local_back_crop[i]) - ttk.Checkbutton( - row, - text="Recortar bordes extra", - variable=crop_var, - command=lambda idx=i, v=crop_var: self._on_back_crop_change(idx, v), - ).pack(side=tk.LEFT, padx=(4, 6)) - - ttk.Button( - row, - text="✕", - width=2, - command=lambda idx=i: self._remove_back(idx), - ).pack(side=tk.RIGHT) - ttk.Button( - row, - text="▼", - width=2, - command=lambda idx=i: self._move_back_down(idx), - ).pack(side=tk.RIGHT, padx=(0, 1)) - ttk.Button( - row, - text="▲", - width=2, - command=lambda idx=i: self._move_back_up(idx), - ).pack(side=tk.RIGHT, padx=(0, 1)) - - self._back_rows.append({"frame": row, "crop_var": crop_var}) - - self.backs_inner.update_idletasks() - self.backs_canvas.configure(scrollregion=self.backs_canvas.bbox("all")) - - # ------------------------------------------------------------------ - # Local front pickers + per-row widgets - # ------------------------------------------------------------------ - def _pick_local_fronts(self) -> None: - paths = filedialog.askopenfilenames( - title="Selecciona imágenes locales (frontales)", - filetypes=IMAGE_FILETYPES, - ) - default_back = self.state.local_backs[0] if self.state.local_backs else None - added = False - for p in paths: - pp = Path(p) - if pp not in self.state.local_fronts: - self.state.local_fronts.append(pp) - self.state.front_back_paths.append(default_back) - self.state.local_front_crop.append(False) - added = True - if added: - self._refresh_front_rows() - self._refresh_generate_state() - - def _clear_local_fronts(self) -> None: - if not self.state.local_fronts: - return - self.state.local_fronts.clear() - self.state.front_back_paths.clear() - self.state.local_front_crop.clear() - self._refresh_front_rows() - self._refresh_generate_state() - - def _remove_front(self, idx: int) -> None: - if 0 <= idx < len(self.state.local_fronts): - del self.state.local_fronts[idx] - del self.state.front_back_paths[idx] - del self.state.local_front_crop[idx] - self._refresh_front_rows() - self._refresh_generate_state() - - def _on_front_back_change(self, idx: int, var: tk.StringVar) -> None: - """Combobox callback: maps the displayed choice back to a Path or None.""" - choice = var.get() - try: - n = int(choice) - except (TypeError, ValueError): - self.state.front_back_paths[idx] = None # "—" = no explicit choice - return - if 1 <= n <= len(self.state.local_backs): - self.state.front_back_paths[idx] = self.state.local_backs[n - 1] - - def _on_front_crop_change(self, idx: int, var: tk.BooleanVar) -> None: - if 0 <= idx < len(self.state.local_front_crop): - self.state.local_front_crop[idx] = bool(var.get()) - - def _on_front_crop_all(self) -> None: - val = bool(self._front_crop_all.get()) - for i in range(len(self.state.local_front_crop)): - self.state.local_front_crop[i] = val - self._refresh_front_rows() - - def _on_back_crop_all(self) -> None: - val = bool(self._back_crop_all.get()) - for i in range(len(self.state.local_back_crop)): - self.state.local_back_crop[i] = val - self._refresh_back_rows() - - def _refresh_front_rows(self) -> None: - """Tear down and rebuild the per-front rows so indices/combos stay in - sync with `self.state.local_fronts` and `self.state.local_backs`.""" - for row in self._front_rows: - row["frame"].destroy() - self._front_rows.clear() - - if not self.state.local_fronts: - self._fronts_empty_label.pack(anchor="w") - self._fronts_header_var.set("Frontales (asignar trasera por carta):") - return - self._fronts_empty_label.pack_forget() - - numbered = [str(i) for i in range(1, len(self.state.local_backs) + 1)] - combo_values = ["—", *numbered] - backs_present = bool(numbered) - - for i, front_path in enumerate(self.state.local_fronts): - row = ttk.Frame(self.fronts_inner) - row.pack(fill=tk.X, pady=1, padx=2) - - ttk.Label(row, text=f"{i + 1:>3}.", width=4, anchor="e").pack(side=tk.LEFT) - front_name_lbl = ttk.Label( - row, - text=_ellipsize(front_path.name, FRONT_NAME_WIDTH), - width=FRONT_NAME_WIDTH + 1, - anchor="w", - ) - front_name_lbl.pack(side=tk.LEFT, padx=(4, 8)) - _ImageTooltip(front_name_lbl, front_path) - - ttk.Label(row, text="Trasera:").pack(side=tk.LEFT) - - # If a previously picked back is no longer in the list, drop the - # assignment so the combo shows "—" (use XML cardback fallback). - assigned = self.state.front_back_paths[i] - if assigned is not None and assigned not in self.state.local_backs: - assigned = None - self.state.front_back_paths[i] = None - var = tk.StringVar() - if assigned is None: - var.set("—") - else: - var.set(str(self.state.local_backs.index(assigned) + 1)) - combo = ttk.Combobox( - row, - values=combo_values, - textvariable=var, - state="readonly" if backs_present else "disabled", - width=4, - ) - combo.bind( - "<>", - lambda _e, idx=i, v=var: self._on_front_back_change(idx, v), - ) - combo.pack(side=tk.LEFT, padx=(4, 6)) - - crop_var = tk.BooleanVar(value=self.state.local_front_crop[i]) - ttk.Checkbutton( - row, - text="Recortar bordes extra", - variable=crop_var, - command=lambda idx=i, v=crop_var: self._on_front_crop_change(idx, v), - ).pack(side=tk.LEFT, padx=(4, 6)) - - ttk.Button( - row, - text="✕", - width=2, - command=lambda idx=i: self._remove_front(idx), - ).pack(side=tk.RIGHT) - ttk.Button( - row, - text="▼", - width=2, - command=lambda idx=i: self._move_front_down(idx), - ).pack(side=tk.RIGHT, padx=(0, 1)) - ttk.Button( - row, - text="▲", - width=2, - command=lambda idx=i: self._move_front_up(idx), - ).pack(side=tk.RIGHT, padx=(0, 1)) - - self._front_rows.append( - {"frame": row, "var": var, "combo": combo, "crop_var": crop_var}, - ) - - # Keep scrollregion fresh after layout settles. - self.fronts_inner.update_idletasks() - self.fronts_canvas.configure(scrollregion=self.fronts_canvas.bbox("all")) - - total = len(self.state.local_fronts) - self._fronts_header_var.set( - f"Frontales (asignar trasera por carta): Actualmente: {total} cartas" - ) - - # ------------------------------------------------------------------ - # ------------------------------------------------------------------ - # Reorder helpers - # ------------------------------------------------------------------ - def _move_xml_up(self, idx: int) -> None: - if idx > 0: - self.state.xml_paths[idx], self.state.xml_paths[idx - 1] = ( - self.state.xml_paths[idx - 1], - self.state.xml_paths[idx], - ) - self._refresh_xml_rows() - - def _move_xml_down(self, idx: int) -> None: - if idx < len(self.state.xml_paths) - 1: - self.state.xml_paths[idx], self.state.xml_paths[idx + 1] = ( - self.state.xml_paths[idx + 1], - self.state.xml_paths[idx], - ) - self._refresh_xml_rows() - - def _move_back_up(self, idx: int) -> None: - if idx > 0: - self.state.local_backs[idx], self.state.local_backs[idx - 1] = ( - self.state.local_backs[idx - 1], - self.state.local_backs[idx], - ) - self.state.local_back_crop[idx], self.state.local_back_crop[idx - 1] = ( - self.state.local_back_crop[idx - 1], - self.state.local_back_crop[idx], - ) - self._refresh_back_rows() - self._refresh_front_rows() - - def _move_back_down(self, idx: int) -> None: - if idx < len(self.state.local_backs) - 1: - self.state.local_backs[idx], self.state.local_backs[idx + 1] = ( - self.state.local_backs[idx + 1], - self.state.local_backs[idx], - ) - self.state.local_back_crop[idx], self.state.local_back_crop[idx + 1] = ( - self.state.local_back_crop[idx + 1], - self.state.local_back_crop[idx], - ) - self._refresh_back_rows() - self._refresh_front_rows() - - def _move_front_up(self, idx: int) -> None: - if idx > 0: - self.state.local_fronts[idx], self.state.local_fronts[idx - 1] = ( - self.state.local_fronts[idx - 1], - self.state.local_fronts[idx], - ) - self.state.front_back_paths[idx], self.state.front_back_paths[idx - 1] = ( - self.state.front_back_paths[idx - 1], - self.state.front_back_paths[idx], - ) - self.state.local_front_crop[idx], self.state.local_front_crop[idx - 1] = ( - self.state.local_front_crop[idx - 1], - self.state.local_front_crop[idx], - ) - self._refresh_front_rows() - - def _move_front_down(self, idx: int) -> None: - if idx < len(self.state.local_fronts) - 1: - self.state.local_fronts[idx], self.state.local_fronts[idx + 1] = ( - self.state.local_fronts[idx + 1], - self.state.local_fronts[idx], - ) - self.state.front_back_paths[idx], self.state.front_back_paths[idx + 1] = ( - self.state.front_back_paths[idx + 1], - self.state.front_back_paths[idx], - ) - self.state.local_front_crop[idx], self.state.local_front_crop[idx + 1] = ( - self.state.local_front_crop[idx + 1], - self.state.local_front_crop[idx], - ) - self._refresh_front_rows() - - # ------------------------------------------------------------------ - # Drag-and-drop - # ------------------------------------------------------------------ @staticmethod def _decode_drop(files) -> list[Path]: result = [] @@ -1197,74 +296,6 @@ def _decode_drop(files) -> list[Path]: result.append(Path(str(f))) return result - def _on_drop_xmls(self, files) -> None: - paths = self._decode_drop(files) - added = 0 - for pp in paths: - if pp.suffix.lower() != ".xml": - continue - if pp not in self.state.xml_paths: - self.state.xml_paths.append(pp) - added += 1 - if pp not in self._xml_card_counts: - try: - rpts = analyze([pp]) - if rpts: - self._xml_card_counts[pp] = rpts[0].cards - except Exception: - pass - if pp not in self._xml_orders: - try: - self._xml_orders[pp] = parse(pp) - except Exception: - pass - if pp not in self._xml_validations: - try: - self._xml_validations[pp] = validate(pp) - except Exception: - self._xml_validations[pp] = [] - if added: - self._refresh_xml_rows() - self.status_var.set(f"{len(self.state.xml_paths)} XML(s) en cola.") - self._refresh_generate_state() - - def _on_drop_backs(self, files) -> None: - paths = self._decode_drop(files) - was_empty = not self.state.local_backs - added = False - for pp in paths: - if pp.suffix.lower() not in SUPPORTED_IMAGE_EXTS: - continue - if pp not in self.state.local_backs: - self.state.local_backs.append(pp) - self.state.local_back_crop.append(False) - added = True - if added: - if was_empty and self.state.local_backs: - first = self.state.local_backs[0] - for i, assigned in enumerate(self.state.front_back_paths): - if assigned is None: - self.state.front_back_paths[i] = first - self._refresh_back_rows() - self._refresh_front_rows() - self._refresh_generate_state() - - def _on_drop_fronts(self, files) -> None: - paths = self._decode_drop(files) - default_back = self.state.local_backs[0] if self.state.local_backs else None - added = False - for pp in paths: - if pp.suffix.lower() not in SUPPORTED_IMAGE_EXTS: - continue - if pp not in self.state.local_fronts: - self.state.local_fronts.append(pp) - self.state.front_back_paths.append(default_back) - self.state.local_front_crop.append(False) - added = True - if added: - self._refresh_front_rows() - self._refresh_generate_state() - def _setup_dnd(self) -> None: if not _WINDND_AVAILABLE: return @@ -1278,8 +309,6 @@ def _setup_dnd(self) -> None: except Exception: pass - # Mousewheel scroll over the active row list - # ------------------------------------------------------------------ def _bind_mousewheel(self, canvas: tk.Canvas, on: bool) -> None: if on: self._active_scroll_canvas = canvas @@ -1303,9 +332,6 @@ def _on_mousewheel(self, event) -> None: else: canvas.yview_scroll(int(-event.delta / 120), "units") - # ------------------------------------------------------------------ - # Run controls - # ------------------------------------------------------------------ def _pick_output_dir(self) -> None: chosen = filedialog.askdirectory( title="Selecciona la carpeta de salida para los PDFs", @@ -1321,17 +347,22 @@ def _effective_output_dir(self) -> Path: return d def _update_preflight(self) -> None: - """Refresh the pre-flight summary panel from current XML + local state.""" for widget in self._preflight_labels: widget.destroy() self._preflight_labels.clear() - if not self.state.xml_paths and not self.state.local_fronts: + if ( + not self.state.xml_paths + and not self.state.local_fronts + and not self._op_decks + and not self._rb_decks + and not self._lorcana_decks + and not self.state.mtg_url_decks + ): self._preflight_frame.pack_forget() return def _row(parts: list[tuple[str, str]]) -> None: - """Create one line: list of (text, style) where style is 'normal', 'bold', or 'warn'.""" frame = ttk.Frame(self._preflight_inner) frame.pack(fill=tk.X) self._preflight_labels.append(frame) @@ -1361,6 +392,50 @@ def _row(parts: list[tuple[str, str]]) -> None: ] ) + for deck in self._op_decks: + n = deck.total_slots + xml_total += n + _row( + [ + (f"• One Piece – {ellipsize(deck.name, 22)}: ", "normal"), + (f"{n}", "bold"), + (" cartas", "normal"), + ] + ) + + for deck in self._rb_decks: + n = deck.total_slots + xml_total += n + _row( + [ + (f"• Riftbound – {ellipsize(deck.name, 22)}: ", "normal"), + (f"{n}", "bold"), + (" cartas", "normal"), + ] + ) + + for deck in self._lorcana_decks: + n = deck.total_slots + xml_total += n + _row( + [ + (f"• Lorcana – {ellipsize(deck.name, 22)}: ", "normal"), + (f"{n}", "bold"), + (" cartas", "normal"), + ] + ) + + for deck in self.state.mtg_url_decks: + n = deck.active_count + xml_total += n + _row( + [ + (f"• Magic – {ellipsize(deck.display_name, 28)}: ", "normal"), + (f"{n}", "bold"), + (" cartas", "normal"), + ] + ) + local_count = len(self.state.local_fronts) if local_count: _row( @@ -1402,12 +477,14 @@ def _row(parts: list[tuple[str, str]]) -> None: self._preflight_frame.pack(fill=tk.X, pady=(8, 0)) def _refresh_generate_state(self) -> None: - ready = False - if self.state.xml_paths: - ready = True - elif self.state.local_fronts and self.state.local_backs: - # locals-only requires at least one back (acts as the cardback) - ready = True + ready = ( + bool(self.state.xml_paths) + or (self.state.local_fronts and self.state.local_backs) + or self._op_decks + or self._rb_decks + or self._lorcana_decks + or bool(self.state.mtg_url_decks) + ) if ready and not self.running: self.soriano_btn.state(["!disabled"]) self.fronts_only_btn.state(["!disabled"]) @@ -1417,21 +494,12 @@ def _refresh_generate_state(self) -> None: self._update_preflight() def _resolve_extra_backs(self) -> list[Path | None]: - """One entry per local front: the explicit Path the user chose, or - `None` to fall back to the XML cardback (or `local_cardback` in - locals-only mode).""" - result: list[Path | None] = [] - for i, _ in enumerate(self.state.local_fronts): - assigned = ( - self.state.front_back_paths[i] if i < len(self.state.front_back_paths) else None - ) - result.append(assigned) - return result + return [ + self.state.front_back_paths[i] if i < len(self.state.front_back_paths) else None + for i in range(len(self.state.local_fronts)) + ] def _build_crop_map(self) -> dict[Path, bool]: - """Per-image crop setting for every local image. Same path appearing - as both a front and a back gets the front's setting last (overrides - the back's), which is fine — they map to the same on-disk file.""" m: dict[Path, bool] = {} for p, c in zip(self.state.local_backs, self.state.local_back_crop): m[p] = c @@ -1443,11 +511,22 @@ def _start(self, fronts_only: bool = False) -> None: if self.running: return - if not self.state.xml_paths and not self.state.local_fronts: - messagebox.showerror(APP_TITLE, "Selecciona al menos un XML o imágenes locales.") + if ( + not self.state.xml_paths + and not self.state.local_fronts + and not self._op_decks + and not self._rb_decks + and not self._lorcana_decks + and not self.state.mtg_url_decks + ): + messagebox.showerror( + APP_TITLE, + "Selecciona al menos un XML, imágenes locales, o un mazo de " + "One Piece, Riftbound, Lorcana o desde una URL.", + ) return - if not self.state.xml_paths and not self.state.local_backs: + if not self.state.xml_paths and self.state.local_fronts and not self.state.local_backs: messagebox.showerror( APP_TITLE, "Sin XMLs se necesita al menos un back local " @@ -1458,7 +537,6 @@ def _start(self, fronts_only: bool = False) -> None: reports = [] plan_ = None if self.state.xml_paths: - # Show a single confirmation if any XML has validation warnings. all_xml_warnings = [ (p, ws) for p in self.state.xml_paths if (ws := self._xml_validations.get(p)) ] @@ -1480,26 +558,43 @@ def _start(self, fronts_only: bool = False) -> None: try: reports = analyze(self.state.xml_paths) except Exception as e: - messagebox.showerror(APP_TITLE, f"No se pudo analizar el XML:\n{e}") + self._show_error_dialog(f"No se pudo analizar el XML:\n{e}") return plan_ = plan(reports, local_count=len(self.state.local_fronts)) - merge_info = format_merge_info(plan_) - if merge_info: - messagebox.showinfo(APP_TITLE, merge_info) - - warning = format_warning(plan_) - if warning: - if not messagebox.askyesno( - APP_TITLE, - warning + "\n\n¿Continuar de todos modos?", - icon=messagebox.WARNING, - ): - return + if format_warning(plan_): + combined_total = ( + sum(r.cards for r in reports) + + len(self.state.local_fronts) + + sum(d.total_slots for d in self._op_decks) + + sum(d.total_slots for d in self._rb_decks) + + sum(d.total_slots for d in self._lorcana_decks) + + sum(d.active_count for d in self.state.mtg_url_decks) + ) + combined_rem = combined_total % CARDS_PER_PAGE + if combined_rem != 0: + combined_blanks = CARDS_PER_PAGE - combined_rem + s = "hueco" if combined_blanks == 1 else "huecos" + combined_warning = ( + f"Aviso: {combined_total} carta(s) en total no es múltiplo de 9.\n" + f"La última página tendrá {combined_blanks} {s} en blanco " + "(la imprenta cobra la página entera aunque no esté llena)." + ) + if not messagebox.askyesno( + APP_TITLE, + combined_warning + "\n\n¿Continuar de todos modos?", + icon=messagebox.WARNING, + ): + return else: - # Locals-only: warn if total fronts is not a multiple of 9 - total = len(self.state.local_fronts) + total = ( + len(self.state.local_fronts) + + sum(d.total_slots for d in self._op_decks) + + sum(d.total_slots for d in self._rb_decks) + + sum(d.total_slots for d in self._lorcana_decks) + + sum(d.active_count for d in self.state.mtg_url_decks) + ) rem = total % CARDS_PER_PAGE if rem: blanks = CARDS_PER_PAGE - rem @@ -1551,7 +646,6 @@ def _work(self, plan_, reports, fronts_only: bool = False) -> None: out = self._effective_output_dir() wd = work_dir() - # Checkpoint: notify if cached crops exist from a previous failed run bled_dir = wd / "bled" if bled_dir.exists(): cached = [f for f in bled_dir.iterdir() if f.is_file()] @@ -1564,7 +658,6 @@ def _work(self, plan_, reports, fronts_only: bool = False) -> None: extra_backs = self._resolve_extra_backs() crop_map = self._build_crop_map() - # Phase timing tracking _run_start = time.time() _phase_first: dict[str, float] = {} _phase_done: dict[str, float] = {} @@ -1576,8 +669,204 @@ def _track(stage: str, done: int, total: int) -> None: if done == total and total > 0: _phase_done[stage] = now + op_fronts: list[Path] = [] + op_backs_resolved: list[Path] = [] + op_crop_extra: dict[Path, bool] = {} + op_standard_back: Path | None = None + + if self._op_decks: + op_raw_dir = wd / "op_raw" + op_label = " + ".join(d.name for d in self._op_decks) + op_total_unique = sum(len({c.card_id for c in d.cards}) for d in self._op_decks) + self.events.put( + ("progress", "download", 0, op_total_unique, f"One Piece – {op_label}") + ) + image_map_op: dict[str, Path] = {} + done_dl_op = 0 + for deck in self._op_decks: + _off_op = done_dl_op + + def _op_prog(done, total, _o=_off_op, _t=op_total_unique, _lbl=op_label): + _track("download", _o + done, _t) + self.events.put( + ("progress", "download", _o + done, _t, f"One Piece – {_lbl}") + ) + + part = op_download( + deck, op_raw_dir, cancel_event=self.cancel_event, progress_cb=_op_prog + ) + image_map_op.update(part) + done_dl_op += len({c.card_id for c in deck.cards}) + if self.cancel_event.is_set(): + self.events.put(("cancelled", run_dir)) + return + op_standard_back, op_leader_back_res = get_op_backs() + leader_backs_op: dict[str, Path] = {} + for deck in self._op_decks: + if deck.leader and deck.leader.card_id not in leader_backs_op: + leader_backs_op[deck.leader.card_id] = op_leader_back_res + for deck in self._op_decks: + lb = leader_backs_op.get(deck.leader.card_id) if deck.leader else None + fronts_op, backs_op = op_expand(deck, image_map_op, lb, op_standard_back) + op_fronts.extend(fronts_op) + op_backs_resolved.extend(op_standard_back if b is None else b for b in backs_op) + op_all_back_paths = {op_standard_back} | set(leader_backs_op.values()) + op_crop_extra = {p: False for p in set(op_fronts) | op_all_back_paths} + + rb_fronts: list[Path] = [] + rb_backs_resolved: list[Path] = [] + rb_crop_extra: dict[Path, bool] = {} + rb_default_back: Path | None = None + + if self._rb_decks: + rb_raw_dir = wd / "rb_raw" + rb_label = " + ".join(d.name for d in self._rb_decks) + rb_total_unique = sum(len({c.variant_id for c in d.cards}) for d in self._rb_decks) + self.events.put( + ("progress", "download", 0, rb_total_unique, f"Riftbound – {rb_label}") + ) + image_map_rb: dict[str, Path] = {} + done_dl_rb = 0 + for deck in self._rb_decks: + _off_rb = done_dl_rb + + def _rb_prog(done, total, _o=_off_rb, _t=rb_total_unique, _lbl=rb_label): + _track("download", _o + done, _t) + self.events.put( + ("progress", "download", _o + done, _t, f"Riftbound – {_lbl}") + ) + + part = rb_download( + deck, rb_raw_dir, cancel_event=self.cancel_event, progress_cb=_rb_prog + ) + image_map_rb.update(part) + done_dl_rb += len({c.variant_id for c in deck.cards}) + if self.cancel_event.is_set(): + self.events.put(("cancelled", run_dir)) + return + rb_backs_map = get_rb_backs() + rb_default_back = rb_backs_map.get("maindeck") or next(iter(rb_backs_map.values())) + rb_all_back_paths = set(rb_backs_map.values()) + print_runes_flags = [row["print_runes_var"].get() for row in self._rb_deck_rows] + for idx, deck in enumerate(self._rb_decks): + include_runes = print_runes_flags[idx] if idx < len(print_runes_flags) else True + fronts_rb, backs_rb = rb_expand( + deck, image_map_rb, rb_backs_map, include_runes=include_runes + ) + rb_fronts.extend(fronts_rb) + rb_backs_resolved.extend(rb_default_back if b is None else b for b in backs_rb) + rb_crop_extra = {p: False for p in set(rb_fronts) | rb_all_back_paths} + + lorcana_fronts: list[Path] = [] + lorcana_backs_resolved: list[Path | None] = [] + lorcana_crop_extra: dict[Path, bool] = {} + lorcana_default_back: Path | None = None + + if self._lorcana_decks: + lorcana_raw_dir = wd / "lorcana_raw" + lorcana_label = " + ".join(d.name for d in self._lorcana_decks) + lorcana_total_unique = sum( + len({c.card_id for c in d.cards}) for d in self._lorcana_decks + ) + self.events.put( + ( + "progress", + "download", + 0, + lorcana_total_unique, + f"Lorcana – {lorcana_label}", + ) + ) + image_map_lorcana: dict[str, Path] = {} + done_dl_lorcana = 0 + for deck in self._lorcana_decks: + _off_lorcana = done_dl_lorcana + + def _lorcana_prog( + done, total, _o=_off_lorcana, _t=lorcana_total_unique, _lbl=lorcana_label + ): + _track("download", _o + done, _t) + self.events.put( + ("progress", "download", _o + done, _t, f"Lorcana – {_lbl}") + ) + + part = lorcana_download( + deck, + lorcana_raw_dir, + cancel_event=self.cancel_event, + progress_cb=_lorcana_prog, + ) + image_map_lorcana.update(part) + done_dl_lorcana += len({c.card_id for c in deck.cards}) + if self.cancel_event.is_set(): + self.events.put(("cancelled", run_dir)) + return + lorcana_back = get_lorcana_back() + lorcana_default_back = lorcana_back + for deck in self._lorcana_decks: + fronts_lorcana, backs_lorcana = lorcana_expand(deck, image_map_lorcana) + lorcana_fronts.extend(fronts_lorcana) + lorcana_backs_resolved.extend( + lorcana_back if b is None else b for b in backs_lorcana + ) + lorcana_crop_extra = {p: False for p in set(lorcana_fronts) | {lorcana_back}} + + mtg_fronts: list[Path] = [] + mtg_backs_resolved: list[Path] = [] + mtg_crop_extra: dict[Path, bool] = {} + mtg_default_back: Path | None = None + + if self.state.mtg_url_decks: + mtg_default_back = resources_dir() / "backs" / "mtg" / "back.jpg" + scryfall_dir = wd / "scryfall" + mtg_cards_all: list = [] + for _deck in self.state.mtg_url_decks: + mtg_cards_all.extend( + c for c in _deck.cards if c.zone == "main" or _deck.include_side + ) + mtg_label = f"Magic – {len(self.state.mtg_url_decks)} mazo(s)" + self.events.put(("progress", "download", 0, len(mtg_cards_all), mtg_label)) + + def _mtg_prog(done: int, total: int) -> None: + _track("download", done, total) + self.events.put(("progress", "download", done, total, mtg_label)) + + dl_results = scryfall_download( + mtg_cards_all, scryfall_dir, _mtg_prog, cancel_event=self.cancel_event + ) + + if self.cancel_event.is_set(): + self.events.put(("cancelled", run_dir)) + return + + all_mtg_back_paths: set[Path] = {mtg_default_back} + for card, front_path, back_path in dl_results: + resolved_back = back_path if back_path is not None else mtg_default_back + all_mtg_back_paths.add(resolved_back) + for _ in range(card.quantity): + mtg_fronts.append(front_path) + mtg_backs_resolved.append(resolved_back) + mtg_crop_extra = {p: False for p in set(mtg_fronts) | all_mtg_back_paths} + + all_extra_fronts = ( + list(self.state.local_fronts) + op_fronts + rb_fronts + lorcana_fronts + mtg_fronts + ) + all_extra_backs: list[Path | None] = ( + list(extra_backs) + + op_backs_resolved + + rb_backs_resolved + + lorcana_backs_resolved + + mtg_backs_resolved + ) + all_crop_map = { + **crop_map, + **op_crop_extra, + **rb_crop_extra, + **lorcana_crop_extra, + **mtg_crop_extra, + } + if plan_ is not None: - # --- Verify Drive access before downloading --------------- xml_paths_flat = [Path(p) for job in plan_.jobs for p in job.xml_paths] all_ids = collect_drive_ids(xml_paths_flat) raw_dir = wd / "raw" @@ -1608,7 +897,6 @@ def _verify_cb(done, total): self.events.put(("cancelled", run_dir)) return - # label shown during download/crop (global phases) _pdf_label = [""] def cb(stage, done, total): @@ -1637,9 +925,9 @@ def on_speed_update(speed_mbps: float, eta_sec: float) -> None: wd, cb, cancel_event=self.cancel_event, - extra_fronts=list(self.state.local_fronts) or None, - extra_backs=list(extra_backs) or None, - local_crop_map=dict(crop_map) or None, + extra_fronts=all_extra_fronts or None, + extra_backs=all_extra_backs or None, + local_crop_map=all_crop_map or None, on_job_pdf_start=on_job_pdf_start, on_xml_download_progress=on_xml_download_progress, on_xml_crop_progress=on_xml_crop_progress, @@ -1649,24 +937,48 @@ def on_speed_update(speed_mbps: float, eta_sec: float) -> None: generated.extend(pdfs) manifest = write_manifest(plan_, reports, run_dir) else: - # Locals-only run. Back #1 acts as the default cardback. - base = "locales" - self.events.put(("file", 1, 1, f"{base} (solo imágenes locales)")) + if not all_extra_fronts: + raise ValueError("No hay cartas para generar.") + if self.state.local_fronts and self.state.local_backs: + default_back: Path = self.state.local_backs[0] + elif op_standard_back is not None: + default_back = op_standard_back + elif rb_default_back is not None: + default_back = rb_default_back + elif lorcana_default_back is not None: + default_back = lorcana_default_back + elif mtg_default_back is not None: + default_back = mtg_default_back + else: + raise ValueError("No se encontró reverso por defecto.") + parts = [ + p + for p in [ + "locales" if self.state.local_fronts else None, + "One Piece" if op_fronts else None, + "Riftbound" if rb_fronts else None, + "Lorcana" if lorcana_fronts else None, + "magic_url" if mtg_fronts else None, + ] + if p + ] + base = "_".join(parts) if parts else "combinado" + self.events.put(("file", 1, 1, base)) def cb(stage, done, total, _label=base): _track(stage, done, total) self.events.put(("progress", stage, done, total, _label)) pdfs = run_locals_only( - list(self.state.local_fronts), - self.state.local_backs[0], + all_extra_fronts, + default_back, run_dir, base, wd, cb, cancel_event=self.cancel_event, - extra_backs=list(extra_backs), - local_crop_map=dict(crop_map), + extra_backs=all_extra_backs, + local_crop_map=all_crop_map, fronts_only=fronts_only, ) generated.extend(pdfs) @@ -1675,7 +987,6 @@ def cb(stage, done, total, _label=base): if not self.keep_cache.get(): self._cleanup_workdir(wd) - # Build timing summary def _fmt_dur(sec: float) -> str: return f"{int(sec) // 60}m {int(sec) % 60}s" if sec >= 60 else f"{sec:.0f}s" @@ -1719,7 +1030,7 @@ def _fmt_dur(sec: float) -> str: @staticmethod def _cleanup_workdir(wd: Path) -> None: - for sub in ("raw", "bled"): + for sub in ("raw", "bled", "op_raw", "rb_raw", "lorcana_raw", "scryfall"): target = wd / sub if target.exists(): shutil.rmtree(target, ignore_errors=True) @@ -1788,7 +1099,7 @@ def _handle(self, ev: tuple) -> None: total_failed = len(perm_errors) + len(timeout_errors) self.status_var.set(f"Error: {total_failed} imagen(es) no se pudieron descargar.") self._finish_running() - messagebox.showerror(APP_TITLE, "\n\n".join(parts)) + self._show_error_dialog("\n\n".join(parts)) elif kind == "done": _, pdfs, manifest, run_dir, timing_str = ev self.progress["value"] = 100 @@ -1798,7 +1109,7 @@ def _handle(self, ev: tuple) -> None: self.timing_var.set(f"Tiempos — {timing_str}") _log.info("Phase timings: %s", timing_str) self._finish_running() - _notify(APP_TITLE, f"¡Listo! {len(pdfs)} PDF(s) generados correctamente.") + notify(APP_TITLE, f"¡Listo! {len(pdfs)} PDF(s) generados correctamente.") self._open_output_folder(run_dir) elif kind == "xml_download_progress": _, xml_name, done, total = ev @@ -1834,12 +1145,11 @@ def _handle(self, ev: tuple) -> None: self._cleanup_run_dir(run_dir) self.status_var.set("Error: demasiadas descargas en poco tiempo.") self._finish_running() - messagebox.showerror( - APP_TITLE, + self._show_error_dialog( "Se están intentando descargar demasiadas imágenes en poco tiempo, " "espera un rato y vuelve a intentar.\n\n" "Por favor selecciona «Guardar en el PC las imágenes entre ejecuciones» " - "así evitamos volver a descargarlas cada vez.", + "así evitamos volver a descargarlas cada vez." ) elif kind == "permission_error": _, card_name, xml_name, position, run_dir = ev @@ -1859,7 +1169,7 @@ def _handle(self, ev: tuple) -> None: ) self.status_var.set("Error de descarga.") self._finish_running() - messagebox.showerror(APP_TITLE, "\n\n".join(parts)) + self._show_error_dialog("\n\n".join(parts)) elif kind == "timeout_error": _, card_name, xml_name, position, run_dir = ev if run_dir is not None: @@ -1878,14 +1188,121 @@ def _handle(self, ev: tuple) -> None: ) self.status_var.set("Error de descarga (tiempo agotado).") self._finish_running() - messagebox.showerror(APP_TITLE, "\n\n".join(parts)) + self._show_error_dialog("\n\n".join(parts)) + elif kind == "op_deck_loaded": + _, deck = ev + if not any(d.slug == deck.slug for d in self._op_decks): + self._op_decks.append(deck) + self._op_refresh_rows() + self._refresh_generate_state() + self._op_url_var.set("") + self._op_status_var.set(f"Añadido: {deck.name}") + self._op_load_btn.state(["!disabled"]) + elif kind == "op_deck_error": + _, msg = ev + self._op_status_var.set("Error al cargar el mazo.") + self._op_load_btn.state(["!disabled"]) + self._show_error_dialog(msg) + elif kind == "rb_deck_loaded": + _, deck = ev + if not any(d.deck_id == deck.deck_id for d in self._rb_decks): + self._rb_decks.append(deck) + self._rb_refresh_rows() + self._refresh_generate_state() + self._rb_url_var.set("") + self._rb_status_var.set(f"Añadido: {deck.name}") + self._rb_load_btn.state(["!disabled"]) + elif kind == "rb_deck_error": + _, msg = ev + self._rb_status_var.set("Error al cargar el mazo.") + self._rb_load_btn.state(["!disabled"]) + self._show_error_dialog(msg) + elif kind == "lorcana_deck_loaded": + _, deck = ev + if not any(d.deck_id == deck.deck_id for d in self._lorcana_decks): + self._lorcana_decks.append(deck) + self._lorcana_refresh_rows() + self._refresh_generate_state() + self._lorcana_url_var.set("") + self._lorcana_status_var.set(f"Añadido: {deck.name}") + self._lorcana_load_btn.state(["!disabled"]) + elif kind == "lorcana_deck_error": + _, msg = ev + self._lorcana_status_var.set("Error al cargar el mazo.") + self._lorcana_load_btn.state(["!disabled"]) + self._show_error_dialog(msg) + elif kind == "mtg_url_loaded": + _, url, cards, include_side, deck_name = ev + self.state.mtg_url_decks.append(MtgUrlDeck(url, cards, include_side, name=deck_name)) + dlg = getattr(self, "_mtg_url_dialog", None) + if dlg and dlg.winfo_exists(): + dlg.destroy() + self._mtg_url_dialog = None + self._refresh_xml_rows() + self._refresh_generate_state() + elif kind == "mtg_url_error": + _, msg = ev + status_var = getattr(self, "_mtg_url_status_var", None) + import_btn = getattr(self, "_mtg_import_btn", None) + if status_var: + status_var.set(f"✗ {msg}") + if import_btn: + try: + import_btn.state(["!disabled"]) + except Exception: + pass elif kind == "error": _, msg, run_dir = ev if run_dir is not None: self._cleanup_run_dir(run_dir) self.status_var.set("Error durante la generación. Las imágenes en caché se conservan.") self._finish_running() - messagebox.showerror(APP_TITLE, msg) + self._show_error_dialog(msg) + + def _show_error_dialog(self, message: str) -> None: + dlg = tk.Toplevel(self.root) + dlg.title(f"{APP_TITLE} — Error") + dlg.geometry("600x340") + dlg.resizable(True, True) + dlg.grab_set() + + ttk.Label(dlg, text="Error", font=("Segoe UI", 11, "bold"), foreground="#cc0000").pack( + anchor="w", padx=12, pady=(10, 4) + ) + + frame = ttk.Frame(dlg) + frame.pack(fill=tk.BOTH, expand=True, padx=12, pady=(0, 6)) + frame.columnconfigure(0, weight=1) + frame.rowconfigure(0, weight=1) + + txt = tk.Text( + frame, + wrap=tk.WORD, + relief=tk.SUNKEN, + borderwidth=1, + font=("Consolas", 9), + state=tk.NORMAL, + ) + sb = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=txt.yview) + txt.configure(yscrollcommand=sb.set) + txt.grid(row=0, column=0, sticky="nsew") + sb.grid(row=0, column=1, sticky="ns") + txt.insert("1.0", message) + txt.configure(state=tk.DISABLED) + attach_context_menu(txt) + + btn_row = ttk.Frame(dlg) + btn_row.pack(fill=tk.X, padx=12, pady=(0, 10)) + + def _copy(): + dlg.clipboard_clear() + dlg.clipboard_append(message) + copy_btn.configure(text="✓ Copiado") + dlg.after(2000, lambda: copy_btn.configure(text="Copiar al portapapeles")) + + copy_btn = ttk.Button(btn_row, text="Copiar al portapapeles", command=_copy) + copy_btn.pack(side=tk.LEFT) + ttk.Button(btn_row, text="Cerrar", command=dlg.destroy).pack(side=tk.RIGHT) def _finish_running(self) -> None: self.running = False @@ -1896,7 +1313,7 @@ def _finish_running(self) -> None: def _open_output_folder(self, path: Path) -> None: try: - os.startfile(str(path)) # Windows + os.startfile(str(path)) except AttributeError: import subprocess @@ -1904,14 +1321,25 @@ def _open_output_folder(self, path: Path) -> None: subprocess.Popen([opener, str(path)]) -def main() -> None: - _wd = work_dir() - _wd.mkdir(parents=True, exist_ok=True) +def _setup_logging() -> None: + if getattr(sys, "frozen", False): + try: + from gui._build_flags import DEBUG_LOGGING + except ImportError: + DEBUG_LOGGING = False + if not DEBUG_LOGGING: + return + wd = work_dir() + wd.mkdir(parents=True, exist_ok=True) logging.basicConfig( level=logging.DEBUG, format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", - handlers=[logging.FileHandler(_wd / "gui.log", encoding="utf-8")], + handlers=[logging.FileHandler(wd / "gui.log", encoding="utf-8")], ) + + +def main() -> None: + _setup_logging() root = tk.Tk() try: ttk.Style().theme_use("vista") diff --git a/gui/op_tab.py b/gui/op_tab.py new file mode 100644 index 0000000..b975db3 --- /dev/null +++ b/gui/op_tab.py @@ -0,0 +1,354 @@ +"""OPTabMixin — One Piece tab methods for the App class.""" + +from __future__ import annotations + +import threading +import time +import tkinter as tk +import traceback +from datetime import datetime +from pathlib import Path +from tkinter import messagebox, ttk + +from gui.paths import work_dir +from gui.widgets import APP_TITLE, attach_context_menu, ellipsize +from src.op_scraper import download_images as op_download +from src.op_scraper import expand_deck as op_expand +from src.op_scraper import get_op_backs +from src.op_scraper import scrape_deck as op_scrape_deck +from src.pipeline import run_locals_only + + +class OPTabMixin: + """Methods for the One Piece scraper tab.""" + + def _build_onepiece_tab(self, parent: ttk.Frame) -> None: + parent.columnconfigure(0, weight=1) + parent.rowconfigure(1, weight=1) + + url_row = ttk.Frame(parent) + url_row.grid(row=0, column=0, sticky="ew", padx=6, pady=(10, 4)) + url_row.columnconfigure(1, weight=1) + ttk.Label( + url_row, + text="Webs aceptadas: onepiece.gg, egmanevents.com, cardkaizoku.com", + foreground="#999", + font=("Segoe UI", 8), + wraplength=450, + justify="left", + ).grid(row=0, column=0, columnspan=3, sticky="w", pady=(0, 4)) + ttk.Label(url_row, text="URL del mazo:").grid(row=1, column=0, sticky="w", padx=(0, 6)) + self._op_url_var = tk.StringVar() + self._op_url_entry = ttk.Entry(url_row, textvariable=self._op_url_var) + self._op_url_entry.grid(row=1, column=1, sticky="ew") + self._op_url_entry.bind("", lambda _e: self._op_load_deck()) + attach_context_menu(self._op_url_entry) + self._op_load_btn = ttk.Button(url_row, text="Añadir", width=7, command=self._op_load_deck) + self._op_load_btn.grid(row=1, column=2, padx=(6, 0)) + + self._op_status_var = tk.StringVar(value="") + ttk.Label(url_row, textvariable=self._op_status_var, foreground="#555", anchor="w").grid( + row=2, column=0, columnspan=3, sticky="ew", pady=(2, 0) + ) + + op_list_frame = ttk.Frame(parent) + op_list_frame.grid(row=1, column=0, sticky="nsew", padx=6, pady=(0, 2)) + op_list_frame.columnconfigure(0, weight=1) + op_list_frame.rowconfigure(0, weight=1) + + self._op_canvas, self._op_inner, _ = self._build_scrollable_rows(op_list_frame) + self._op_canvas.bind("", lambda _e: self._bind_mousewheel(self._op_canvas, True)) + self._op_canvas.bind("", lambda _e: self._bind_mousewheel(self._op_canvas, False)) + + self._op_empty_label = ttk.Label( + self._op_inner, + text="(introduce una URL de mazo y haz clic en «Añadir»)", + foreground="#777", + padding=(8, 10), + ) + self._op_empty_label.pack(anchor="w") + + op_btn_row = ttk.Frame(parent) + op_btn_row.grid(row=2, column=0, sticky="ew", padx=6, pady=(2, 6)) + ttk.Button(op_btn_row, text="Vaciar todo", command=self._op_clear).pack(side=tk.LEFT) + + def _op_load_deck(self) -> None: + url = self._op_url_var.get().strip() + if not url: + messagebox.showwarning(APP_TITLE, "Introduce una URL de mazo de One Piece.") + return + + self._op_load_btn.state(["disabled"]) + self._op_status_var.set("Cargando mazo…") + + def _fetch(): + try: + deck = op_scrape_deck(url) + self.events.put(("op_deck_loaded", deck)) + except Exception as e: + self.events.put(("op_deck_error", str(e))) + + threading.Thread(target=_fetch, daemon=True).start() + + def _op_refresh_rows(self) -> None: + for row in self._op_deck_rows: + row["outer"].destroy() + self._op_deck_rows.clear() + + if not self._op_decks: + self._op_empty_label.pack(anchor="w") + return + self._op_empty_label.pack_forget() + + for idx, deck in enumerate(self._op_decks): + leader = deck.leader + + outer = ttk.Frame(self._op_inner, relief="groove", borderwidth=1) + outer.pack(fill=tk.X, pady=3, padx=2) + outer.columnconfigure(0, weight=1) + + summary = ttk.Frame(outer) + summary.pack(fill=tk.X, padx=6, pady=4) + summary.columnconfigure(1, weight=1) + + ttk.Label( + summary, text=ellipsize(deck.name, 22), font=("Segoe UI", 9, "bold"), anchor="w" + ).grid(row=0, column=0, sticky="w", padx=(0, 12)) + + if leader: + color_txt = " / ".join(leader.colors) + leader_txt = f"Líder: {ellipsize(leader.name, 18)} ({color_txt})" + else: + leader_txt = "(sin líder)" + ttk.Label(summary, text=leader_txt, foreground="#555", anchor="w").grid( + row=0, column=1, sticky="w" + ) + + ttk.Label( + summary, text=f"{deck.total_slots} cartas", foreground="#888", anchor="e" + ).grid(row=0, column=2, sticky="e", padx=(8, 6)) + + expanded_var = tk.BooleanVar(value=False) + toggle_btn = ttk.Button( + summary, + text="Detalles ▼", + width=10, + command=lambda i=idx: self._op_toggle_details(i), + ) + toggle_btn.grid(row=0, column=3, padx=(0, 4)) + + ttk.Button( + summary, + text="✕", + width=2, + command=lambda i=idx: self._op_remove_deck(i), + ).grid(row=0, column=4) + + detail = ttk.Frame(outer) + + for card in deck.cards: + row_f = ttk.Frame(detail) + row_f.pack(fill=tk.X, pady=0, padx=(12, 4)) + + if card.is_leader: + badge_text, badge_fg = "LÍDER", "#1565C0" + badge_font = ("Segoe UI", 8, "bold") + else: + badge_text, badge_fg = f"x{card.quantity}", "#444" + badge_font = ("Segoe UI", 8) + ttk.Label( + row_f, + text=badge_text, + foreground=badge_fg, + font=badge_font, + width=6, + anchor="e", + ).pack(side=tk.LEFT, padx=(0, 6)) + ttk.Label(row_f, text=ellipsize(card.name, 24), anchor="w", width=25).pack( + side=tk.LEFT + ) + ttk.Label( + row_f, + text=card.card_id, + foreground="#888", + font=("Segoe UI", 8), + anchor="w", + width=10, + ).pack(side=tk.LEFT, padx=(4, 0)) + if card.colors: + ttk.Label( + row_f, text=" / ".join(card.colors), foreground="#555", font=("Segoe UI", 8) + ).pack(side=tk.LEFT, padx=(6, 0)) + + self._op_deck_rows.append( + { + "outer": outer, + "detail": detail, + "toggle_btn": toggle_btn, + "expanded": expanded_var, + "deck": deck, + } + ) + + self._op_inner.update_idletasks() + self._op_canvas.configure(scrollregion=self._op_canvas.bbox("all")) + + def _op_toggle_details(self, idx: int) -> None: + if idx >= len(self._op_deck_rows): + return + row = self._op_deck_rows[idx] + expanded = row["expanded"] + if expanded.get(): + row["detail"].pack_forget() + row["toggle_btn"].configure(text="Detalles ▼") + expanded.set(False) + else: + row["detail"].pack(fill=tk.X, padx=0, pady=(0, 4)) + row["toggle_btn"].configure(text="Detalles ▲") + expanded.set(True) + self._op_inner.update_idletasks() + self._op_canvas.configure(scrollregion=self._op_canvas.bbox("all")) + + def _op_remove_deck(self, idx: int) -> None: + if 0 <= idx < len(self._op_decks): + del self._op_decks[idx] + self._op_refresh_rows() + self._refresh_generate_state() + + def _op_clear(self) -> None: + self._op_decks.clear() + self._op_url_var.set("") + self._op_status_var.set("") + self._op_load_btn.state(["!disabled"]) + self._op_refresh_rows() + self._refresh_generate_state() + + def _start_op(self, fronts_only: bool = False) -> None: + self.running = True + self.cancel_event.clear() + self._dl_speed_str = "" + self.timing_var.set("") + self.soriano_btn.state(["disabled"]) + self.fronts_only_btn.state(["disabled"]) + self.stop_btn.state(["!disabled"]) + self.stop_btn.pack(fill=tk.X, pady=(4, 0), after=self.fronts_only_btn) + self.progress["value"] = 0 + self.status_var.set("Preparando One Piece…") + self.worker = threading.Thread( + target=self._work_op, + args=(fronts_only,), + daemon=True, + ) + self.worker.start() + + def _work_op(self, fronts_only: bool = False) -> None: + run_dir = None + try: + decks = self._op_decks + if not decks: + raise ValueError("No hay mazos de One Piece cargados.") + + out = self._effective_output_dir() + wd = work_dir() + run_dir = out / datetime.now().strftime("%d_%m_%Y_%H-%M-%S") + run_dir.mkdir(parents=True, exist_ok=True) + + _run_start = time.time() + label = " + ".join(d.name for d in decks) + + op_raw_dir = wd / "op_raw" + total_unique = sum(len({c.card_id for c in d.cards}) for d in decks) + self.events.put(("progress", "download", 0, total_unique, label)) + + image_map: dict[str, Path] = {} + done_dl_offset = 0 + + for deck in decks: + _offset = done_dl_offset + + def _dl_progress(done, total, _off=_offset): + self.events.put(("progress", "download", _off + done, total_unique, label)) + + partial = op_download( + deck, + op_raw_dir, + cancel_event=self.cancel_event, + progress_cb=_dl_progress, + ) + image_map.update(partial) + done_dl_offset += len({c.card_id for c in deck.cards}) + + if self.cancel_event.is_set(): + self.events.put(("cancelled", run_dir)) + return + + standard_back, leader_back_res = get_op_backs() + + leader_backs: dict[str, Path] = {} + for deck in decks: + leader = deck.leader + if leader and leader.card_id not in leader_backs: + leader_backs[leader.card_id] = leader_back_res + + all_fronts: list[Path] = [] + all_backs: list[Path | None] = [] + for deck in decks: + leader = deck.leader + lb = leader_backs.get(leader.card_id) if leader else None + fronts, backs = op_expand(deck, image_map, lb, standard_back) + all_fronts.extend(fronts) + all_backs.extend(backs) + + if not all_fronts: + raise ValueError("No se pudieron expandir las cartas.") + + all_back_paths = {standard_back} | set(leader_backs.values()) + crop_map = {p: False for p in set(all_fronts) | all_back_paths} + + base_name = "_".join(d.slug for d in decks)[:60] + self.events.put(("file", 1, 1, label)) + + _phase_first: dict[str, float] = {} + _phase_done: dict[str, float] = {} + + def cb(stage, done, total): + now = time.time() + if stage not in _phase_first: + _phase_first[stage] = now + if done == total and total > 0: + _phase_done[stage] = now + self.events.put(("progress", stage, done, total, label)) + + pdfs = run_locals_only( + all_fronts, + standard_back, + run_dir, + base_name, + wd, + cb, + cancel_event=self.cancel_event, + extra_backs=all_backs, + local_crop_map=crop_map, + fronts_only=fronts_only, + ) + + def _fmt_dur(sec: float) -> str: + return f"{int(sec) // 60}m {int(sec) % 60}s" if sec >= 60 else f"{sec:.0f}s" + + timing_parts = [] + for stage in ("download", "crop", "pdf"): + if stage in _phase_first and stage in _phase_done: + dur = _phase_done[stage] - _phase_first[stage] + lbl = {"download": "Descarga", "crop": "Recorte", "pdf": "PDF"}.get( + stage, stage + ) + timing_parts.append(f"{lbl}: {_fmt_dur(dur)}") + total_dur = time.time() - _run_start + timing_str = " ".join(timing_parts) + if timing_str: + timing_str += f" Total: {_fmt_dur(total_dur)}" + + self.events.put(("done", pdfs, None, run_dir, timing_str)) + + except Exception as e: + self.events.put(("error", f"{e}\n\n{traceback.format_exc()}", run_dir)) diff --git a/gui/paths.py b/gui/paths.py index 647e38a..5a0446c 100644 --- a/gui/paths.py +++ b/gui/paths.py @@ -1,10 +1,13 @@ """Resolve runtime directories that must live next to the executable. When frozen by PyInstaller (--onefile), `sys.executable` is the .exe path, -and bundled data is unpacked under `sys._MEIPASS`. We want `out/` and -`workdir/` to be persistent folders next to the .exe — never inside the -temp extraction dir — so the user finds their PDFs and cache after the -.exe exits. +and bundled data is unpacked under `sys._MEIPASS`. We want the user-facing +folders to be persistent siblings of the .exe — never inside the temp +extraction dir — so the user finds their PDFs and cache after the .exe exits. + +All output lives under a single "MPCFillToPDF/" root folder next to the .exe: + MPCFillToPDF/archivos generados/ ← PDFs + MPCFillToPDF/procesamiento/ ← download cache, logs """ import sys @@ -18,13 +21,13 @@ def app_base_dir() -> Path: return Path(__file__).resolve().parent.parent +def _mpc_dir() -> Path: + return app_base_dir() / "MPCFillToPDF" + + def output_dir() -> Path: - p = app_base_dir() / "out" - p.mkdir(parents=True, exist_ok=True) - return p + return _mpc_dir() / "archivos generados" def work_dir() -> Path: - p = app_base_dir() / "workdir" - p.mkdir(parents=True, exist_ok=True) - return p + return _mpc_dir() / "procesamiento" diff --git a/gui/rb_tab.py b/gui/rb_tab.py new file mode 100644 index 0000000..ee96b85 --- /dev/null +++ b/gui/rb_tab.py @@ -0,0 +1,379 @@ +"""RBTabMixin — Riftbound tab methods for the App class.""" + +from __future__ import annotations + +import threading +import time +import tkinter as tk +import traceback +from datetime import datetime +from pathlib import Path +from tkinter import messagebox, ttk + +from gui.paths import work_dir +from gui.widgets import APP_TITLE, attach_context_menu, ellipsize +from src.pipeline import run_locals_only +from src.rb_scraper import download_images as rb_download +from src.rb_scraper import expand_deck as rb_expand +from src.rb_scraper import get_rb_backs +from src.rb_scraper import scrape_deck as rb_scrape_deck + +_SECTION_LABELS = { + "legend": "Leyenda", + "champion": "Campeón", + "battlefield": "Campo de batalla", + "rune": "Runa", + "maindeck": "Mazo principal", + "sideboard": "Sideboard", +} + + +class RBTabMixin: + """Methods for the Riftbound scraper tab.""" + + def _build_riftbound_tab(self, parent: ttk.Frame) -> None: + parent.columnconfigure(0, weight=1) + parent.rowconfigure(1, weight=1) + + url_row = ttk.Frame(parent) + url_row.grid(row=0, column=0, sticky="ew", padx=6, pady=(10, 4)) + url_row.columnconfigure(1, weight=1) + ttk.Label( + url_row, + text="Webs aceptadas: piltoverarchive.com, riftbound.gg, riftmana.com, riftbinder.com, riftdex.com", + foreground="#999", + font=("Segoe UI", 8), + wraplength=450, + justify="left", + ).grid(row=0, column=0, columnspan=3, sticky="w", pady=(0, 4)) + ttk.Label(url_row, text="URL del mazo:").grid(row=1, column=0, sticky="w", padx=(0, 6)) + self._rb_url_var = tk.StringVar() + self._rb_url_entry = ttk.Entry(url_row, textvariable=self._rb_url_var) + self._rb_url_entry.grid(row=1, column=1, sticky="ew") + self._rb_url_entry.bind("", lambda _e: self._rb_load_deck()) + attach_context_menu(self._rb_url_entry) + self._rb_load_btn = ttk.Button(url_row, text="Añadir", width=7, command=self._rb_load_deck) + self._rb_load_btn.grid(row=1, column=2, padx=(6, 0)) + + self._rb_status_var = tk.StringVar(value="") + ttk.Label(url_row, textvariable=self._rb_status_var, foreground="#555", anchor="w").grid( + row=2, column=0, columnspan=3, sticky="ew", pady=(2, 0) + ) + + rb_list_frame = ttk.Frame(parent) + rb_list_frame.grid(row=1, column=0, sticky="nsew", padx=6, pady=(0, 2)) + rb_list_frame.columnconfigure(0, weight=1) + rb_list_frame.rowconfigure(0, weight=1) + + self._rb_canvas, self._rb_inner, _ = self._build_scrollable_rows(rb_list_frame) + self._rb_canvas.bind("", lambda _e: self._bind_mousewheel(self._rb_canvas, True)) + self._rb_canvas.bind("", lambda _e: self._bind_mousewheel(self._rb_canvas, False)) + + self._rb_empty_label = ttk.Label( + self._rb_inner, + text="(introduce una URL de piltoverarchive.com, riftbound.gg, riftmana.com, riftbinder.com o riftdex.com)", + foreground="#777", + padding=(8, 10), + ) + self._rb_empty_label.pack(anchor="w") + + rb_btn_row = ttk.Frame(parent) + rb_btn_row.grid(row=2, column=0, sticky="ew", padx=6, pady=(2, 6)) + ttk.Button(rb_btn_row, text="Vaciar todo", command=self._rb_clear).pack(side=tk.LEFT) + + def _rb_load_deck(self) -> None: + url = self._rb_url_var.get().strip() + if not url: + messagebox.showwarning(APP_TITLE, "Introduce una URL de mazo de Riftbound.") + return + + self._rb_load_btn.state(["disabled"]) + self._rb_status_var.set("Cargando mazo…") + + def _fetch(): + try: + deck = rb_scrape_deck(url) + self.events.put(("rb_deck_loaded", deck)) + except Exception as e: + self.events.put(("rb_deck_error", str(e))) + + threading.Thread(target=_fetch, daemon=True).start() + + def _rb_refresh_rows(self) -> None: + for row in self._rb_deck_rows: + row["outer"].destroy() + self._rb_deck_rows.clear() + + if not self._rb_decks: + self._rb_empty_label.pack(anchor="w") + return + self._rb_empty_label.pack_forget() + + for idx, deck in enumerate(self._rb_decks): + outer = ttk.Frame(self._rb_inner, relief="groove", borderwidth=1) + outer.pack(fill=tk.X, pady=3, padx=2) + outer.columnconfigure(0, weight=1) + + summary = ttk.Frame(outer) + summary.pack(fill=tk.X, padx=6, pady=4) + summary.columnconfigure(1, weight=1) + + ttk.Label( + summary, text=ellipsize(deck.name, 28), font=("Segoe UI", 9, "bold"), anchor="w" + ).grid(row=0, column=0, sticky="w", padx=(0, 12)) + + by_sec = deck.by_section() + sec_parts = [ + f"{_SECTION_LABELS.get(s, s)}: {sum(c.quantity for c in cards)}" + for s, cards in by_sec.items() + if cards + ] + ttk.Label( + summary, + text=" | ".join(sec_parts), + foreground="#555", + anchor="w", + font=("Segoe UI", 8), + ).grid(row=0, column=1, sticky="w") + + ttk.Label( + summary, text=f"{deck.total_slots} cartas", foreground="#888", anchor="e" + ).grid(row=0, column=2, sticky="e", padx=(8, 6)) + + print_runes_var = tk.BooleanVar(value=True) + has_runes = bool(by_sec.get("rune")) + runes_cb = ttk.Checkbutton( + summary, + text="imprimir runas", + variable=print_runes_var, + ) + if not has_runes: + runes_cb.state(["disabled"]) + runes_cb.grid(row=0, column=3, padx=(4, 8)) + + expanded_var = tk.BooleanVar(value=False) + toggle_btn = ttk.Button( + summary, + text="Detalles ▼", + width=10, + command=lambda i=idx: self._rb_toggle_details(i), + ) + toggle_btn.grid(row=0, column=4, padx=(0, 4)) + + ttk.Button( + summary, + text="✕", + width=2, + command=lambda i=idx: self._rb_remove_deck(i), + ).grid(row=0, column=5) + + detail = ttk.Frame(outer) + + for section, cards in by_sec.items(): + if not cards: + continue + sec_lbl = _SECTION_LABELS.get(section, section) + ttk.Label( + detail, + text=f"── {sec_lbl} ──", + foreground="#888", + font=("Segoe UI", 8, "italic"), + padding=(12, 2, 0, 0), + ).pack(anchor="w") + for card in cards: + row_f = ttk.Frame(detail) + row_f.pack(fill=tk.X, pady=0, padx=(12, 4)) + ttk.Label( + row_f, + text=f"x{card.quantity}", + foreground="#444", + font=("Segoe UI", 8), + width=4, + anchor="e", + ).pack(side=tk.LEFT, padx=(0, 6)) + ttk.Label(row_f, text=ellipsize(card.name, 26), anchor="w", width=27).pack( + side=tk.LEFT + ) + ttk.Label( + row_f, + text=card.card_type, + foreground="#888", + font=("Segoe UI", 8), + anchor="w", + width=12, + ).pack(side=tk.LEFT, padx=(4, 0)) + + self._rb_deck_rows.append( + { + "outer": outer, + "detail": detail, + "toggle_btn": toggle_btn, + "expanded": expanded_var, + "deck": deck, + "print_runes_var": print_runes_var, + } + ) + + self._rb_inner.update_idletasks() + self._rb_canvas.configure(scrollregion=self._rb_canvas.bbox("all")) + + def _rb_toggle_details(self, idx: int) -> None: + if idx >= len(self._rb_deck_rows): + return + row = self._rb_deck_rows[idx] + expanded = row["expanded"] + if expanded.get(): + row["detail"].pack_forget() + row["toggle_btn"].configure(text="Detalles ▼") + expanded.set(False) + else: + row["detail"].pack(fill=tk.X, padx=0, pady=(0, 4)) + row["toggle_btn"].configure(text="Detalles ▲") + expanded.set(True) + self._rb_inner.update_idletasks() + self._rb_canvas.configure(scrollregion=self._rb_canvas.bbox("all")) + + def _rb_remove_deck(self, idx: int) -> None: + if 0 <= idx < len(self._rb_decks): + del self._rb_decks[idx] + self._rb_refresh_rows() + self._refresh_generate_state() + + def _rb_clear(self) -> None: + self._rb_decks.clear() + self._rb_url_var.set("") + self._rb_status_var.set("") + self._rb_load_btn.state(["!disabled"]) + self._rb_refresh_rows() + self._refresh_generate_state() + + def _start_rb(self, fronts_only: bool = False) -> None: + print_runes_flags = [row["print_runes_var"].get() for row in self._rb_deck_rows] + self.running = True + self.cancel_event.clear() + self._dl_speed_str = "" + self.timing_var.set("") + self.soriano_btn.state(["disabled"]) + self.fronts_only_btn.state(["disabled"]) + self.stop_btn.state(["!disabled"]) + self.stop_btn.pack(fill=tk.X, pady=(4, 0), after=self.fronts_only_btn) + self.progress["value"] = 0 + self.status_var.set("Preparando Riftbound…") + self.worker = threading.Thread( + target=self._work_rb, + args=(fronts_only, print_runes_flags), + daemon=True, + ) + self.worker.start() + + def _work_rb( + self, fronts_only: bool = False, print_runes_flags: list[bool] | None = None + ) -> None: + run_dir = None + try: + decks = self._rb_decks + if not decks: + raise ValueError("No hay mazos de Riftbound cargados.") + + out = self._effective_output_dir() + wd = work_dir() + run_dir = out / datetime.now().strftime("%d_%m_%Y_%H-%M-%S") + run_dir.mkdir(parents=True, exist_ok=True) + + _run_start = time.time() + label = " + ".join(d.name for d in decks) + + rb_raw_dir = wd / "rb_raw" + total_unique = sum(len({c.variant_id for c in d.cards}) for d in decks) + self.events.put(("progress", "download", 0, total_unique, label)) + + image_map: dict[str, Path] = {} + done_dl_offset = 0 + + for deck in decks: + _offset = done_dl_offset + + def _dl_progress(done, total, _off=_offset): + self.events.put(("progress", "download", _off + done, total_unique, label)) + + partial = rb_download( + deck, + rb_raw_dir, + cancel_event=self.cancel_event, + progress_cb=_dl_progress, + ) + image_map.update(partial) + done_dl_offset += len({c.variant_id for c in deck.cards}) + + if self.cancel_event.is_set(): + self.events.put(("cancelled", run_dir)) + return + + backs = get_rb_backs() + + all_fronts: list[Path] = [] + all_backs: list[Path | None] = [] + for idx, deck in enumerate(decks): + include_runes = ( + print_runes_flags[idx] + if print_runes_flags and idx < len(print_runes_flags) + else True + ) + fronts, per_backs = rb_expand(deck, image_map, backs, include_runes=include_runes) + all_fronts.extend(fronts) + all_backs.extend(per_backs) + + if not all_fronts: + raise ValueError("No se pudieron expandir las cartas.") + + default_back = backs.get("maindeck") or next(iter(backs.values())) + all_back_paths = set(backs.values()) + crop_map = {p: False for p in set(all_fronts) | all_back_paths} + + base_name = "_".join(d.deck_id[:8] for d in decks)[:60] + self.events.put(("file", 1, 1, label)) + + _phase_first: dict[str, float] = {} + _phase_done: dict[str, float] = {} + + def cb(stage, done, total): + now = time.time() + if stage not in _phase_first: + _phase_first[stage] = now + if done == total and total > 0: + _phase_done[stage] = now + self.events.put(("progress", stage, done, total, label)) + + pdfs = run_locals_only( + all_fronts, + default_back, + run_dir, + base_name, + wd, + cb, + cancel_event=self.cancel_event, + extra_backs=all_backs, + local_crop_map=crop_map, + fronts_only=fronts_only, + ) + + def _fmt_dur(sec: float) -> str: + return f"{int(sec) // 60}m {int(sec) % 60}s" if sec >= 60 else f"{sec:.0f}s" + + timing_parts = [] + for stage in ("download", "crop", "pdf"): + if stage in _phase_first and stage in _phase_done: + dur = _phase_done[stage] - _phase_first[stage] + lbl = {"download": "Descarga", "crop": "Recorte", "pdf": "PDF"}.get( + stage, stage + ) + timing_parts.append(f"{lbl}: {_fmt_dur(dur)}") + total_dur = time.time() - _run_start + timing_str = " ".join(timing_parts) + if timing_str: + timing_str += f" Total: {_fmt_dur(total_dur)}" + + self.events.put(("done", pdfs, None, run_dir, timing_str)) + + except Exception as e: + self.events.put(("error", f"{e}\n\n{traceback.format_exc()}", run_dir)) diff --git a/gui/widgets.py b/gui/widgets.py new file mode 100644 index 0000000..e85a338 --- /dev/null +++ b/gui/widgets.py @@ -0,0 +1,354 @@ +"""Standalone GUI helper widgets, constants, and utility functions.""" + +from __future__ import annotations + +import io +import queue +import sys +import threading +import tkinter as tk +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path +from tkinter import ttk + +try: + import windnd as _windnd # noqa: F401 + + WINDND_AVAILABLE = True +except ImportError: + WINDND_AVAILABLE = False + +from PIL import Image, ImageTk + +from src.constants import Stage +from src.parser import CardOrder + +APP_TITLE = "MPCFillToPDF" +STAGE_LABELS = { + Stage.VERIFY: "Verificando XML", + Stage.DOWNLOAD: "Descargando", + Stage.CROP: "Procesando imágenes", + Stage.PDF: "Generando PDF, Páginas", +} +IMAGE_FILETYPES = [ + ("Imágenes", "*.jpg *.jpeg *.png *.webp *.bmp *.tif *.tiff"), + ("Todos", "*.*"), +] +FRONT_NAME_WIDTH = 28 + +_PB_DOWNLOAD_COLOR = "#0078d4" +_PB_CROP_COLOR = "#2e7d32" + + +def ellipsize(name: str, width: int) -> str: + if len(name) <= width: + return name + return name[: max(0, width - 1)] + "…" + + +def notify(title: str, message: str) -> None: + """Show a system notification (best-effort; silently ignored if plyer is missing).""" + try: + from plyer import notification as _n + + _n.notify(title=title, message=message, app_name=APP_TITLE, timeout=8) + except Exception: + pass + + +def attach_context_menu(widget: tk.Widget) -> None: + """Attach a right-click context menu (cut/copy/paste/select all) to an Entry or Text widget.""" + menu = tk.Menu(widget, tearoff=0) + menu.add_command(label="Cortar", command=lambda: widget.event_generate("<>")) + menu.add_command(label="Copiar", command=lambda: widget.event_generate("<>")) + menu.add_command(label="Pegar", command=lambda: widget.event_generate("<>")) + menu.add_separator() + menu.add_command( + label="Seleccionar todo", command=lambda: widget.event_generate("<>") + ) + widget.bind("", lambda e: menu.tk_popup(e.x_root, e.y_root)) + + +def load_tab_icon(name: str, size: tuple[int, int] = (20, 20)) -> ImageTk.PhotoImage | None: + if getattr(sys, "frozen", False): + icons_dir = Path(getattr(sys, "_MEIPASS", "")) / "icons" + else: + icons_dir = Path(__file__).resolve().parent.parent / "icons" + path = icons_dir / f"{name}.png" + if not path.exists(): + return None + try: + img = Image.open(path).convert("RGBA") + img = img.resize(size, Image.LANCZOS) + return ImageTk.PhotoImage(img) + except Exception: + return None + + +class XmlPb(tk.Canvas): + """Canvas progress bar with centered text overlay — works on all themes.""" + + _W = 130 + _H = 18 + _TROUGH = "#dde5f0" + _BORDER = "#9aafc7" + _TEXT = "#f0f0f0" + + def __init__(self, parent, **kw): + super().__init__( + parent, + width=self._W, + height=self._H, + bg=self._TROUGH, + highlightthickness=1, + highlightbackground=self._BORDER, + **kw, + ) + self._bar = self.create_rectangle(0, 0, 0, self._H, fill=_PB_DOWNLOAD_COLOR, outline="") + self._lbl = self.create_text( + self._W // 2, + self._H // 2, + text="", + fill=self._TEXT, + font=("Segoe UI", 8), + ) + + def set_progress(self, pct: float, text: str = "", color: str | None = None) -> None: + if color is not None: + self.itemconfigure(self._bar, fill=color) + filled = int(self._W * max(0.0, min(100.0, pct)) / 100) + self.coords(self._bar, 0, 0, filled, self._H) + self.itemconfigure(self._lbl, text=text) + + +class ImageTooltip: + """Floating image preview that appears when the mouse hovers over a widget.""" + + _DELAY_MS = 350 + _MAX_W = 240 + _MAX_H = 336 + + def __init__(self, widget: tk.Widget, image_path: Path) -> None: + self._widget = widget + self._path = image_path + self._after_id: str | None = None + self._tip: tk.Toplevel | None = None + self._photo = None + widget.bind("", self._schedule, add="+") + widget.bind("", self._hide, add="+") + widget.bind("", self._on_motion, add="+") + widget.bind("", lambda _e: self._hide(), add="+") + + def _schedule(self, event) -> None: + self._hide() + self._after_id = self._widget.after( + self._DELAY_MS, + lambda: self._show(event.x_root, event.y_root), + ) + + def _on_motion(self, event) -> None: + if self._tip and self._tip.winfo_exists(): + self._move(event.x_root, event.y_root) + + def _show(self, x_root: int, y_root: int) -> None: + if not self._widget.winfo_exists(): + return + try: + img = Image.open(self._path).convert("RGB") + except Exception: + return + img.thumbnail((self._MAX_W, self._MAX_H), Image.LANCZOS) + self._photo = ImageTk.PhotoImage(img) + parent = self._widget.winfo_toplevel() + self._tip = tk.Toplevel(parent) + self._tip.overrideredirect(True) + self._tip.attributes("-topmost", True) + border = tk.Frame(self._tip, bg="#444", padx=2, pady=2) + border.pack() + tk.Label(border, image=self._photo, bg="#444").pack() + self._tip.update_idletasks() + self._move(x_root, y_root) + + def _move(self, x_root: int, y_root: int) -> None: + if not (self._tip and self._tip.winfo_exists()): + return + tw = self._tip.winfo_width() + th = self._tip.winfo_height() + sw = self._tip.winfo_screenwidth() + sh = self._tip.winfo_screenheight() + x = x_root + 18 + y = y_root + 18 + if x + tw > sw: + x = x_root - tw - 8 + if y + th > sh: + y = y_root - th - 8 + self._tip.geometry(f"+{max(0, x)}+{max(0, y)}") + + def _hide(self, _event=None) -> None: + if self._after_id is not None: + try: + self._widget.after_cancel(self._after_id) + except Exception: + pass + self._after_id = None + if self._tip and self._tip.winfo_exists(): + self._tip.destroy() + self._tip = None + + +class PreviewWindow(tk.Toplevel): + _THUMB_W = 80 + _THUMB_H = 112 + _COLS = 4 + _THUMB_URL = "https://drive.google.com/thumbnail?id={}&sz=w80" + _SPINNER = ["◐", "◓", "◑", "◒"] + + def __init__(self, parent: tk.Misc, xml_path: Path, order: CardOrder) -> None: + super().__init__(parent) + self.title(f"Vista previa — {xml_path.name}") + self.geometry("700x520") + self.resizable(True, True) + + self._cancel = threading.Event() + self._pending: queue.Queue = queue.Queue() + self._photo_refs: list = [] + self._spinner_frame: int = 0 + self._loading_labels: list[tk.Label] = [] + self._loading_set: set[int] = set() + + placeholder = Image.new("RGB", (self._THUMB_W, self._THUMB_H), (210, 210, 210)) + self._placeholder_photo = ImageTk.PhotoImage(placeholder) + + frame = ttk.Frame(self) + frame.pack(fill=tk.BOTH, expand=True, padx=8, pady=8) + frame.columnconfigure(0, weight=1) + frame.rowconfigure(0, weight=1) + + canvas = tk.Canvas(frame, highlightthickness=0) + sb = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=canvas.yview) + canvas.configure(yscrollcommand=sb.set) + canvas.grid(row=0, column=0, sticky="nsew") + sb.grid(row=0, column=1, sticky="ns") + inner = ttk.Frame(canvas) + wid = canvas.create_window((0, 0), window=inner, anchor="nw") + inner.bind("", lambda _e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.bind("", lambda e: canvas.itemconfigure(wid, width=e.width)) + + def _scroll(event): + canvas.yview_scroll(int(-event.delta / 120), "units") + + self.bind("", _scroll) + canvas.bind("", _scroll) + inner.bind("", _scroll) + + self._img_labels: list[tk.Label] = [] + for idx, card in enumerate(order.fronts): + r, c = divmod(idx, self._COLS) + cell = ttk.Frame(inner, relief=tk.RIDGE, borderwidth=1) + cell.grid(row=r, column=c, padx=4, pady=4) + cell.bind("", _scroll) + + lbl_img = tk.Label(cell, image=self._placeholder_photo, bg="#d2d2d2") + lbl_img.pack() + lbl_img.bind("", _scroll) + self._img_labels.append(lbl_img) + + loading_lbl = tk.Label( + cell, + text=self._SPINNER[0], + font=("Segoe UI", 9), + fg="#888", + ) + loading_lbl.pack() + loading_lbl.bind("", _scroll) + self._loading_labels.append(loading_lbl) + self._loading_set.add(idx) + + name = ellipsize(card.name, 12) if card.name else "(sin nombre)" + name_lbl = tk.Label(cell, text=name, font=("Segoe UI", 7), wraplength=self._THUMB_W) + name_lbl.pack() + name_lbl.bind("", _scroll) + count = len(card.slots) + if count > 1: + count_lbl = tk.Label( + cell, text=f"x{count}", font=("Segoe UI", 7, "bold"), fg="#555" + ) + count_lbl.pack() + count_lbl.bind("", _scroll) + + self.protocol("WM_DELETE_WINDOW", self._on_close) + self.after(80, self._drain) + self.after(200, self._tick_spinner) + + self._executor = ThreadPoolExecutor(max_workers=4) + threading.Thread(target=self._load_all, args=(order,), daemon=True).start() + + def _tick_spinner(self) -> None: + if self._cancel.is_set() or not self._loading_set: + return + self._spinner_frame = (self._spinner_frame + 1) % len(self._SPINNER) + ch = self._SPINNER[self._spinner_frame] + for idx in self._loading_set: + if idx < len(self._loading_labels): + self._loading_labels[idx].configure(text=ch) + self.after(200, self._tick_spinner) + + def _fetch(self, drive_id: str) -> bytes: + import requests as _req + + resp = _req.get(self._THUMB_URL.format(drive_id), timeout=(5, 15)) + resp.raise_for_status() + return resp.content + + def _load_all(self, order: CardOrder) -> None: + futs = { + self._executor.submit(self._fetch, card.drive_id): idx + for idx, card in enumerate(order.fronts) + } + for fut in as_completed(futs): + if self._cancel.is_set(): + break + idx = futs[fut] + try: + self._pending.put((idx, fut.result())) + except Exception: + self._pending.put((idx, None)) + + def _drain(self) -> None: + if self._cancel.is_set(): + return + try: + while True: + idx, data = self._pending.get_nowait() + self._apply(idx, data) + except queue.Empty: + pass + finally: + if not self._cancel.is_set(): + self.after(80, self._drain) + + def _apply(self, idx: int, data: bytes | None) -> None: + if idx >= len(self._img_labels): + return + self._loading_set.discard(idx) + if idx < len(self._loading_labels): + self._loading_labels[idx].pack_forget() + if data is None: + return + try: + img = Image.open(io.BytesIO(data)).convert("RGB") + img.thumbnail((self._THUMB_W, self._THUMB_H), Image.LANCZOS) + padded = Image.new("RGB", (self._THUMB_W, self._THUMB_H), (210, 210, 210)) + ox = (self._THUMB_W - img.width) // 2 + oy = (self._THUMB_H - img.height) // 2 + padded.paste(img, (ox, oy)) + photo = ImageTk.PhotoImage(padded) + self._photo_refs.append(photo) + self._img_labels[idx].configure(image=photo) + except Exception: + pass + + def _on_close(self) -> None: + self._cancel.set() + self._executor.shutdown(wait=False) + self.destroy() diff --git a/gui/xml_tab.py b/gui/xml_tab.py new file mode 100644 index 0000000..c4e4900 --- /dev/null +++ b/gui/xml_tab.py @@ -0,0 +1,391 @@ +"""XmlTabMixin — Magic/XML tab methods for the App class.""" + +from __future__ import annotations + +import threading +import tkinter as tk +from pathlib import Path +from tkinter import filedialog, messagebox, ttk + +from gui.widgets import ( + _PB_CROP_COLOR, + _PB_DOWNLOAD_COLOR, + APP_TITLE, + WINDND_AVAILABLE, + PreviewWindow, + XmlPb, + attach_context_menu, + ellipsize, +) +from src.deck_importer import DeckImportError, fetch_deck +from src.parser import parse +from src.precheck import analyze +from src.validator import validate + + +class XmlTabMixin: + """Methods for the Magic/XML tab and XML file management.""" + + def _build_magic_tab(self, parent: ttk.Frame) -> None: + self._xml_drop_frame = parent + parent.columnconfigure(0, weight=1) + parent.rowconfigure(0, weight=1) + + xml_list_frame = ttk.Frame(parent) + xml_list_frame.grid(row=0, column=0, sticky="nsew", padx=6, pady=(6, 2)) + xml_list_frame.columnconfigure(0, weight=1) + xml_list_frame.rowconfigure(0, weight=1) + + self.xml_canvas, self.xml_inner, self._xml_window = self._build_scrollable_rows( + xml_list_frame + ) + self.xml_canvas.bind("", lambda _e: self._bind_mousewheel(self.xml_canvas, True)) + self.xml_canvas.bind("", lambda _e: self._bind_mousewheel(self.xml_canvas, False)) + + dnd_hint = " o arrastra aquí" if WINDND_AVAILABLE else "" + self._xml_empty_label = ttk.Label( + self.xml_inner, + text=f"(sin XMLs — usa «Seleccionar XMLs…»{dnd_hint})", + foreground="#777", + padding=(8, 10), + ) + self._xml_empty_label.pack(anchor="w") + + xml_btn_row = ttk.Frame(parent) + xml_btn_row.grid(row=1, column=0, sticky="ew", padx=6, pady=(0, 6)) + ttk.Button(xml_btn_row, text="Seleccionar XMLs…", command=self._pick_xmls).pack( + side=tk.LEFT + ) + ttk.Button(xml_btn_row, text="Añadir desde URL", command=self._open_url_dialog).pack( + side=tk.LEFT, padx=(6, 0) + ) + ttk.Button(xml_btn_row, text="Vaciar", command=self._clear_xmls).pack(side=tk.LEFT, padx=6) + + def _pick_xmls(self) -> None: + paths = filedialog.askopenfilenames( + title="Selecciona archivos XML de MPCFill", + filetypes=[("Archivos XML", "*.xml"), ("Todos", "*.*")], + ) + added = 0 + for p in paths: + pp = Path(p) + if pp not in self.state.xml_paths: + self.state.xml_paths.append(pp) + added += 1 + if pp not in self._xml_card_counts: + try: + rpts = analyze([pp]) + if rpts: + self._xml_card_counts[pp] = rpts[0].cards + except Exception: + pass + if pp not in self._xml_orders: + try: + self._xml_orders[pp] = parse(pp) + except Exception: + pass + if pp not in self._xml_validations: + try: + self._xml_validations[pp] = validate(pp) + except Exception: + self._xml_validations[pp] = [] + if added: + self._refresh_xml_rows() + self.status_var.set(f"{len(self.state.xml_paths)} XML(s) en cola.") + self._refresh_generate_state() + + def _on_drop_xmls(self, files) -> None: + paths = self._decode_drop(files) + added = 0 + for pp in paths: + if pp.suffix.lower() != ".xml": + continue + if pp not in self.state.xml_paths: + self.state.xml_paths.append(pp) + added += 1 + if pp not in self._xml_card_counts: + try: + rpts = analyze([pp]) + if rpts: + self._xml_card_counts[pp] = rpts[0].cards + except Exception: + pass + if pp not in self._xml_orders: + try: + self._xml_orders[pp] = parse(pp) + except Exception: + pass + if pp not in self._xml_validations: + try: + self._xml_validations[pp] = validate(pp) + except Exception: + self._xml_validations[pp] = [] + if added: + self._refresh_xml_rows() + self.status_var.set(f"{len(self.state.xml_paths)} XML(s) en cola.") + self._refresh_generate_state() + + def _remove_xml(self, idx: int) -> None: + if 0 <= idx < len(self.state.xml_paths): + p = self.state.xml_paths[idx] + self._xml_card_counts.pop(p, None) + self._xml_orders.pop(p, None) + self._xml_validations.pop(p, None) + del self.state.xml_paths[idx] + self._refresh_xml_rows() + self._refresh_generate_state() + + def _clear_xmls(self) -> None: + self.state.xml_paths.clear() + self._xml_card_counts.clear() + self._xml_orders.clear() + self._xml_validations.clear() + self.state.mtg_url_decks.clear() + self._refresh_xml_rows() + self._refresh_generate_state() + + def _refresh_xml_rows(self) -> None: + for row in self._xml_rows: + row["frame"].destroy() + self._xml_rows.clear() + for row in self._mtg_deck_rows: + row["frame"].destroy() + self._mtg_deck_rows.clear() + + if not self.state.xml_paths and not self.state.mtg_url_decks: + self._xml_empty_label.pack(anchor="w") + return + self._xml_empty_label.pack_forget() + + for i, xml_path in enumerate(self.state.xml_paths): + frame = ttk.Frame(self.xml_inner) + frame.pack(fill=tk.X, pady=1, padx=2) + frame.columnconfigure(0, weight=1, uniform="half") + frame.columnconfigure(2, weight=1, uniform="half") + + ttk.Label( + frame, + text=ellipsize(xml_path.name, 32), + anchor="w", + ).grid(row=0, column=0, sticky="ew", padx=(4, 0)) + + ttk.Label(frame, text=" - ").grid(row=0, column=1) + + right = ttk.Frame(frame) + right.grid(row=0, column=2, sticky="ew") + + card_count = self._xml_card_counts.get(xml_path) + cards_text = f"{card_count} cartas" if card_count is not None else "" + ttk.Label(right, text=cards_text, foreground="#555", anchor="w").grid( + row=0, column=0, sticky="w", padx=(0, 6) + ) + + pb = XmlPb(right) + pb.grid(row=0, column=1, padx=(0, 4)) + pb.grid_remove() + + count_var = tk.StringVar(value="") + count_lbl = ttk.Label(right, textvariable=count_var, width=9, anchor="w") + count_lbl.grid(row=0, column=2, padx=(0, 4)) + count_lbl.grid_remove() + + ttk.Button( + right, + text="Ver cartas", + command=lambda p=xml_path: self._show_preview(p), + ).grid(row=0, column=4, padx=(0, 2)) + ttk.Button( + right, + text="✕", + width=2, + command=lambda idx=i: self._remove_xml(idx), + ).grid(row=0, column=3, padx=(0, 1)) + + xml_warnings = self._xml_validations.get(xml_path, []) + warn_btn = ttk.Button( + right, + text="⚠", + width=2, + command=lambda p=xml_path: self._show_xml_warnings(p), + ) + warn_btn.grid(row=0, column=5, padx=(0, 2)) + if not xml_warnings: + warn_btn.grid_remove() + + self._xml_rows.append( + { + "frame": frame, + "pb": pb, + "count_var": count_var, + "count_lbl": count_lbl, + "warn_btn": warn_btn, + } + ) + + for i, deck in enumerate(self.state.mtg_url_decks): + frame = ttk.Frame(self.xml_inner) + frame.pack(fill=tk.X, pady=1, padx=2) + frame.columnconfigure(0, weight=1, uniform="half") + frame.columnconfigure(2, weight=1, uniform="half") + + ttk.Label( + frame, + text=ellipsize(deck.display_name, 32), + anchor="w", + ).grid(row=0, column=0, sticky="ew", padx=(4, 0)) + + ttk.Label(frame, text=" - ").grid(row=0, column=1) + + right = ttk.Frame(frame) + right.grid(row=0, column=2, sticky="ew") + + main_count = sum(c.quantity for c in deck.cards if c.zone == "main") + side_count = sum(c.quantity for c in deck.cards if c.zone == "side") + count_text = f"{main_count} cartas" + (f" +{side_count} side" if side_count else "") + ttk.Label(right, text=count_text, foreground="#555", anchor="w").grid( + row=0, column=0, sticky="w", padx=(0, 6) + ) + + col = 1 + if side_count: + side_var = tk.BooleanVar(value=deck.include_side) + + def _toggle_side(idx=i, var=side_var): + self.state.mtg_url_decks[idx].include_side = var.get() + self._refresh_generate_state() + + ttk.Checkbutton( + right, text="Incluir sideboard", variable=side_var, command=_toggle_side + ).grid(row=0, column=col, padx=(0, 4)) + col += 1 + + ttk.Button( + right, + text="✕", + width=2, + command=lambda idx=i: self._remove_mtg_deck(idx), + ).grid(row=0, column=col, padx=(0, 2)) + + self._mtg_deck_rows.append({"frame": frame}) + + self.xml_inner.update_idletasks() + self.xml_canvas.configure(scrollregion=self.xml_canvas.bbox("all")) + + def _show_xml_download_progress(self, xml_name: str, done: int, total: int) -> None: + for xml_path, row in zip(self.state.xml_paths, self._xml_rows): + if xml_path.name == xml_name: + pct = (done / total * 100.0) if total else 100.0 + row["pb"].set_progress(pct, "Descargando", color=_PB_DOWNLOAD_COLOR) + row["count_var"].set(f"{done}/{total}") + row["pb"].grid() + row["count_lbl"].grid() + break + + def _show_xml_crop_progress(self, xml_name: str, done: int, total: int) -> None: + for xml_path, row in zip(self.state.xml_paths, self._xml_rows): + if xml_path.name == xml_name: + pct = (done / total * 100.0) if total else 100.0 + row["pb"].set_progress(pct, "Recortando", color=_PB_CROP_COLOR) + row["count_var"].set(f"{done}/{total}") + row["pb"].grid() + row["count_lbl"].grid() + break + + def _reset_xml_download_progress(self) -> None: + for row in self._xml_rows: + row["pb"].set_progress(0, "") + row["count_var"].set("") + row["pb"].grid_remove() + row["count_lbl"].grid_remove() + + def _show_preview(self, xml_path: Path) -> None: + order = self._xml_orders.get(xml_path) + if order is None: + messagebox.showinfo(APP_TITLE, "No hay datos de cartas para esta XML.") + return + PreviewWindow(self.root, xml_path, order) + + def _show_xml_warnings(self, xml_path: Path) -> None: + warnings = self._xml_validations.get(xml_path, []) + if not warnings: + return + msg = "\n".join(f"• {w.message}" for w in warnings) + messagebox.showwarning( + APP_TITLE, + f"Advertencias en {xml_path.name}:\n\n{msg}", + ) + + def _remove_mtg_deck(self, idx: int) -> None: + if 0 <= idx < len(self.state.mtg_url_decks): + del self.state.mtg_url_decks[idx] + self._refresh_xml_rows() + self._refresh_generate_state() + + def _open_url_dialog(self) -> None: + dlg = tk.Toplevel(self.root) + dlg.title("Añadir mazo desde URL") + dlg.geometry("480x190") + dlg.resizable(False, False) + dlg.grab_set() + dlg.columnconfigure(0, weight=1) + self._mtg_url_dialog = dlg + + ttk.Label( + dlg, + text="Ten en cuenta que al usar esta opción puede que las imágenes tengan menos calidad que las de MPCFill", + foreground="#b86000", + font=("Segoe UI", 8), + wraplength=440, + ).pack(anchor="w", padx=10, pady=(10, 2)) + + ttk.Label( + dlg, + text="Webs aceptadas: moxfield.com, archidekt.com, deckstats.net, tappedout.net, manabox.app", + foreground="#999", + font=("Segoe UI", 8), + wraplength=440, + justify="left", + ).pack(anchor="w", padx=10, pady=(0, 2)) + + url_frame = ttk.Frame(dlg) + url_frame.pack(fill=tk.X, padx=10) + url_frame.columnconfigure(1, weight=1) + ttk.Label(url_frame, text="URL:").grid(row=0, column=0, sticky="w", padx=(0, 6)) + url_var = tk.StringVar() + url_entry = ttk.Entry(url_frame, textvariable=url_var) + url_entry.grid(row=0, column=1, sticky="ew") + attach_context_menu(url_entry) + url_entry.focus_set() + + status_var = tk.StringVar(value="") + self._mtg_url_status_var = status_var + + status_lbl = ttk.Label(dlg, textvariable=status_var, foreground="#555") + status_lbl.pack(anchor="w", padx=10, pady=(6, 0)) + + btn_row = ttk.Frame(dlg) + btn_row.pack(fill=tk.X, padx=10, pady=(6, 10)) + + def _do_import(): + url = url_var.get().strip() + if not url: + return + import_btn.state(["disabled"]) + status_var.set("Cargando…") + + def _run(): + try: + result = fetch_deck(url) + self.events.put(("mtg_url_loaded", url, result.cards, False, result.name)) + except DeckImportError as exc: + self.events.put(("mtg_url_error", str(exc))) + except Exception as exc: + self.events.put(("mtg_url_error", f"Error inesperado: {exc}")) + + threading.Thread(target=_run, daemon=True).start() + + import_btn = ttk.Button(btn_row, text="Importar", command=_do_import) + import_btn.pack(side=tk.RIGHT, padx=(6, 0)) + ttk.Button(btn_row, text="Cancelar", command=dlg.destroy).pack(side=tk.RIGHT) + url_entry.bind("", lambda _e: _do_import()) + self._mtg_import_btn = import_btn diff --git a/icons/lorcana_icon.png b/icons/lorcana_icon.png new file mode 100644 index 0000000..1f73a55 Binary files /dev/null and b/icons/lorcana_icon.png differ diff --git a/icons/mtg_icon.png b/icons/mtg_icon.png new file mode 100644 index 0000000..e3767eb Binary files /dev/null and b/icons/mtg_icon.png differ diff --git a/icons/op_icon.png b/icons/op_icon.png new file mode 100644 index 0000000..cf8a0c5 Binary files /dev/null and b/icons/op_icon.png differ diff --git a/icons/riftbound_icon.png b/icons/riftbound_icon.png new file mode 100644 index 0000000..ca1afa1 Binary files /dev/null and b/icons/riftbound_icon.png differ diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..bf270a7 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +markers = + network: tests that make real HTTP requests (deselect with -m "not network") diff --git a/resources/backs/lorcana/back.png b/resources/backs/lorcana/back.png new file mode 100644 index 0000000..e9c5824 Binary files /dev/null and b/resources/backs/lorcana/back.png differ diff --git a/resources/backs/mtg/back.jpg b/resources/backs/mtg/back.jpg new file mode 100644 index 0000000..2bc4114 Binary files /dev/null and b/resources/backs/mtg/back.jpg differ diff --git a/resources/backs/op/default.png b/resources/backs/op/default.png new file mode 100644 index 0000000..cae14d4 Binary files /dev/null and b/resources/backs/op/default.png differ diff --git a/resources/backs/op/lider.png b/resources/backs/op/lider.png new file mode 100644 index 0000000..324d72d Binary files /dev/null and b/resources/backs/op/lider.png differ diff --git a/resources/backs/riftbound/black.png b/resources/backs/riftbound/black.png new file mode 100644 index 0000000..5507c1b Binary files /dev/null and b/resources/backs/riftbound/black.png differ diff --git a/resources/backs/riftbound/blue.png b/resources/backs/riftbound/blue.png new file mode 100644 index 0000000..bedb4f7 Binary files /dev/null and b/resources/backs/riftbound/blue.png differ diff --git a/resources/backs/riftbound/white.png b/resources/backs/riftbound/white.png new file mode 100644 index 0000000..c30c239 Binary files /dev/null and b/resources/backs/riftbound/white.png differ diff --git a/ruff_hook.py b/ruff_hook.py new file mode 100644 index 0000000..6677ac1 --- /dev/null +++ b/ruff_hook.py @@ -0,0 +1,13 @@ +import subprocess +import sys + + +def _run(cmd: list[str]) -> subprocess.CompletedProcess: + return subprocess.run(cmd, check=False) + + +_run([sys.executable, "-m", "ruff", "check", "--fix", "."]) +_run([sys.executable, "-m", "ruff", "format", "."]) +_run(["git", "add", "-u"]) +result = _run([sys.executable, "-m", "ruff", "check", "."]) +sys.exit(result.returncode) diff --git a/src/constants.py b/src/constants.py index f936b9a..27a8837 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from enum import Enum COLS = 3 @@ -12,3 +13,9 @@ class Stage(str, Enum): DOWNLOAD = "download" CROP = "crop" PDF = "pdf" + + +ProgressCallback = Callable[[int, int], None] | None +StageCallback = Callable[[str, int, int], None] | None +SpeedCallback = Callable[[float, float], None] | None +ImageDoneCallback = Callable[[str], None] | None diff --git a/src/deck_importer.py b/src/deck_importer.py new file mode 100644 index 0000000..77ccdd7 --- /dev/null +++ b/src/deck_importer.py @@ -0,0 +1,283 @@ +"""Deck list fetcher for Moxfield, Archidekt, Deckstats, TappedOut, and Manabox.""" + +from __future__ import annotations + +import html as _html +import json as _json +import re +from dataclasses import dataclass + +import requests + +_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36" + ) +} +_TIMEOUT = 15 + +_MOXFIELD_RE = re.compile(r"moxfield\.com/decks/([A-Za-z0-9_-]+)") +_ARCHIDEKT_RE = re.compile(r"archidekt\.com/decks/(\d+)") +_DECKSTATS_RE = re.compile(r"deckstats\.net/decks/(\d+)/(\d+)") +_TAPPEDOUT_RE = re.compile(r"tappedout\.net/mtg-decks/([^/?#]+)") +_MANABOX_RE = re.compile(r"manabox\.app/decks/([A-Za-z0-9_-]+)") + +_MOXFIELD_API = "https://api2.moxfield.com/v3/decks/all/{deck_id}" +_ARCHIDEKT_API = "https://archidekt.com/api/decks/{deck_id}/" +_DECKSTATS_API = ( + "https://deckstats.net/api.php" + "?action=get_deck&id_type=saved&owner_id={owner_id}&id={deck_id}&response_type=json" +) +_TAPPEDOUT_API = "https://tappedout.net/mtg-decks/{slug}/?fmt=txt" + +_ARCHIDEKT_SIDE_CATEGORIES = frozenset({"Sideboard"}) +_ARCHIDEKT_SKIP_CATEGORIES = frozenset({"Maybeboard"}) +_MANABOX_SIDE_CATS = frozenset({1}) # boardCategory 1 = sideboard + +_TAPPEDOUT_MAIN_RE = re.compile(r"^(\d+)\s+(.+)$") +_TAPPEDOUT_SIDE_RE = re.compile(r"^SB:\s*(\d+)\s+(.+)$") + + +class DeckImportError(Exception): + def __init__(self, message: str, platform: str = "") -> None: + self.platform = platform + super().__init__(message) + + +@dataclass +class DeckCard: + name: str + set_code: str + collector_number: str + quantity: int + zone: str # "main" | "side" + + +@dataclass +class FetchedDeck: + name: str + cards: list[DeckCard] + + +def fetch_deck(url: str) -> FetchedDeck: + """Return the deck name and card list for the deck at url.""" + m = _MOXFIELD_RE.search(url) + if m: + return _fetch_moxfield(m.group(1)) + m = _ARCHIDEKT_RE.search(url) + if m: + return _fetch_archidekt(m.group(1)) + m = _DECKSTATS_RE.search(url) + if m: + return _fetch_deckstats(m.group(1), m.group(2)) + m = _TAPPEDOUT_RE.search(url) + if m: + return _fetch_tappedout(m.group(1)) + m = _MANABOX_RE.search(url) + if m: + return _fetch_manabox(m.group(1)) + raise DeckImportError( + "URL no reconocida. Webs soportadas: moxfield.com, archidekt.com, " + "deckstats.net, tappedout.net, manabox.app" + ) + + +def _fetch_moxfield(deck_id: str) -> FetchedDeck: + url = _MOXFIELD_API.format(deck_id=deck_id) + try: + resp = requests.get(url, headers=_HEADERS, timeout=_TIMEOUT) + resp.raise_for_status() + data = resp.json() + except requests.RequestException as exc: + raise DeckImportError(f"Error al acceder a Moxfield: {exc}", "moxfield") from exc + + deck_name: str = data.get("name", "") + cards: list[DeckCard] = [] + boards = data.get("boards", {}) + for board_name, zone in ( + ("mainboard", "main"), + ("commanders", "main"), + ("companions", "main"), + ("sideboard", "side"), + ): + board = boards.get(board_name, {}) + for entry in board.get("cards", {}).values(): + card = entry.get("card", {}) + set_code = str(card.get("set", "")).lower() + collector_number = str(card.get("cn", "")) + name = card.get("name", "") + qty = int(entry.get("quantity", 1)) + if not set_code or not collector_number: + continue + cards.append(DeckCard(name, set_code, collector_number, qty, zone)) + return FetchedDeck(name=deck_name, cards=cards) + + +def _fetch_archidekt(deck_id: str) -> FetchedDeck: + url = _ARCHIDEKT_API.format(deck_id=deck_id) + try: + resp = requests.get(url, headers=_HEADERS, timeout=_TIMEOUT) + resp.raise_for_status() + data = resp.json() + except requests.RequestException as exc: + raise DeckImportError(f"Error al acceder a Archidekt: {exc}", "archidekt") from exc + + deck_name: str = data.get("name", "") + cards: list[DeckCard] = [] + for entry in data.get("cards", []): + categories = entry.get("categories", []) + if any(c in _ARCHIDEKT_SKIP_CATEGORIES for c in categories): + continue + card_data = entry.get("card", {}) + edition = card_data.get("edition", {}) + set_code = str(edition.get("editioncode", "")).lower() + collector_number = str(card_data.get("collectorNumber", "")) + name = card_data.get("oracleCard", {}).get("name", "") + qty = int(entry.get("quantity", 1)) + zone = "side" if any(c in _ARCHIDEKT_SIDE_CATEGORIES for c in categories) else "main" + if not set_code or not collector_number: + continue + cards.append(DeckCard(name, set_code, collector_number, qty, zone)) + return FetchedDeck(name=deck_name, cards=cards) + + +def _fetch_deckstats(owner_id: str, deck_id: str) -> FetchedDeck: + url = _DECKSTATS_API.format(owner_id=owner_id, deck_id=deck_id) + try: + resp = requests.get(url, headers=_HEADERS, timeout=_TIMEOUT) + resp.raise_for_status() + data = resp.json() + except requests.RequestException as exc: + raise DeckImportError(f"Error al acceder a Deckstats: {exc}", "deckstats") from exc + + deck_name: str = data.get("name", "") + cards: list[DeckCard] = [] + for section in data.get("sections", []): + for entry in section.get("cards", []): + name = entry.get("name", "") + qty = int(entry.get("amount", 1)) + if not name: + continue + cards.append(DeckCard(name, "", "", qty, "main")) + for entry in data.get("sideboard", []): + name = entry.get("name", "") + qty = int(entry.get("amount", 1)) + if not name: + continue + cards.append(DeckCard(name, "", "", qty, "side")) + return FetchedDeck(name=deck_name, cards=cards) + + +def _fetch_tappedout(slug: str) -> FetchedDeck: + url = _TAPPEDOUT_API.format(slug=slug) + try: + resp = requests.get(url, headers=_HEADERS, timeout=_TIMEOUT) + resp.raise_for_status() + except requests.RequestException as exc: + raise DeckImportError(f"Error al acceder a TappedOut: {exc}", "tappedout") from exc + + deck_name = re.sub(r"\s+", " ", slug.replace("-", " ")).strip().title() + cards: list[DeckCard] = [] + for line in resp.text.splitlines(): + line = line.strip() + if not line or line.startswith("//"): + continue + m = _TAPPEDOUT_SIDE_RE.match(line) + if m: + cards.append(DeckCard(m.group(2).strip(), "", "", int(m.group(1)), "side")) + continue + m = _TAPPEDOUT_MAIN_RE.match(line) + if m: + cards.append(DeckCard(m.group(2).strip(), "", "", int(m.group(1)), "main")) + + if not cards: + raise DeckImportError("No se encontraron cartas en el mazo de TappedOut", "tappedout") + return FetchedDeck(name=deck_name, cards=cards) + + +def _astro_val(wrapped: object) -> object: + if isinstance(wrapped, list) and len(wrapped) == 2 and isinstance(wrapped[0], int): + signal_type, content = wrapped[0], wrapped[1] + if signal_type == 0: + return content + if signal_type == 1: # Astro type 1 = array; each element is also [type, value] + return [_astro_val(item) for item in content] + return wrapped + + +def _astro_find(data: object, key: str) -> object: + """Search recursively for key in Astro dehydrated props; returns unwrapped value or None.""" + if isinstance(data, dict): + if key in data: + return _astro_val(data[key]) + for v in data.values(): + result = _astro_find(_astro_val(v), key) + if result is not None: + return result + elif isinstance(data, list): + for item in data: + result = _astro_find(item, key) + if result is not None: + return result + return None + + +def _manabox_extract_props(raw: str) -> str: + """Return the HTML-encoded value of the props attribute containing "cards".""" + marker = ""cards":" + pos = raw.find(marker) + if pos == -1: + return "" + attr_pos = raw.rfind('props="', 0, pos) + if attr_pos == -1: + return "" + i = attr_pos + 7 + end = i + while end < len(raw): + if raw[end] == '"': + break + if raw[end : end + 6] == """: + end += 6 + continue + end += 1 + return raw[i:end] + + +def _fetch_manabox(deck_id: str) -> FetchedDeck: + url = f"https://manabox.app/decks/{deck_id}" + try: + resp = requests.get(url, headers=_HEADERS, timeout=_TIMEOUT) + resp.raise_for_status() + except requests.RequestException as exc: + raise DeckImportError(f"Error al acceder a Manabox: {exc}", "manabox") from exc + + props_encoded = _manabox_extract_props(resp.text) + if not props_encoded: + raise DeckImportError("No se encontraron datos del mazo en Manabox", "manabox") + + try: + props = _json.loads(_html.unescape(props_encoded)) + except _json.JSONDecodeError as exc: + raise DeckImportError(f"Error al parsear datos de Manabox: {exc}", "manabox") from exc + + raw_cards = _astro_find(props, "cards") + if not isinstance(raw_cards, list): + raise DeckImportError("Formato de mazos inesperado en Manabox", "manabox") + + deck_name = str(_astro_find(props, "name") or deck_id) + cards: list[DeckCard] = [] + for entry in raw_cards: + if not isinstance(entry, dict): + continue + name = str(_astro_val(entry.get("name", ""))) + set_code = str(_astro_val(entry.get("setId", ""))).lower() + collector_number = str(_astro_val(entry.get("collectorNumber", ""))) + quantity = int(_astro_val(entry.get("quantity", 1))) # type: ignore[arg-type] + board_cat = int(_astro_val(entry.get("boardCategory", 0))) # type: ignore[arg-type] + zone = "side" if board_cat in _MANABOX_SIDE_CATS else "main" + if not name: + continue + cards.append(DeckCard(name, set_code, collector_number, quantity, zone)) + + return FetchedDeck(name=deck_name, cards=cards) diff --git a/src/downloader.py b/src/downloader.py index 10580fe..b6cbb11 100644 --- a/src/downloader.py +++ b/src/downloader.py @@ -12,6 +12,7 @@ from src.cancellation import Cancelled from src.config import get_drive_api_key +from src.constants import ImageDoneCallback, ProgressCallback, SpeedCallback _log = logging.getLogger(__name__) @@ -194,6 +195,28 @@ def download_image(drive_id: str, dest_dir: Path, filename: str) -> Path: raise DownloadTimeoutError(drive_id, filename) except Exception as exc: _safe_unlink(tmp_path) + if _is_permission_error(exc) and _DRIVE_API_KEY: + # Drive API v3 with an API key only works for "Public on the web" files. + # Files shared as "Anyone with the link" return 403 via the API but are + # downloadable via gdown (which follows Google's web redirect flow). + # Fall back to gdown before declaring a permission failure. + _log.info("API 403 for %s — retrying via gdown fallback", filename) + try: + gdown.download(_gdown_url(drive_id), str(tmp_path), quiet=True) + tmp_path.replace(output_path) + _log.debug("Downloaded via gdown fallback: %s", output_path.name) + return output_path + except Exception as gdown_exc: + _safe_unlink(tmp_path) + if _is_permission_error(gdown_exc): + _log.error( + "Permission denied (gdown also failed): %s (%s)", filename, drive_id + ) + raise DownloadPermissionError(drive_id, filename) from gdown_exc + if isinstance(gdown_exc, requests.exceptions.Timeout | TimeoutError): + _log.error("Timeout via gdown fallback: %s (%s)", filename, drive_id) + raise DownloadTimeoutError(drive_id, filename) from gdown_exc + raise gdown_exc if _is_permission_error(exc): _log.error("Permission denied: %s (%s)", filename, drive_id) raise DownloadPermissionError(drive_id, filename) from exc @@ -219,10 +242,10 @@ def download_image(drive_id: str, dest_dir: Path, filename: str) -> Path: def download_all( id_name_pairs: list[tuple[str, str]], dest_dir: str | Path, - progress_callback=None, + progress_callback: ProgressCallback = None, cancel_event: Event | None = None, - on_image_done=None, - on_speed_update=None, + on_image_done: ImageDoneCallback = None, + on_speed_update: SpeedCallback = None, ) -> dict[str, Path]: """Download multiple images in parallel. diff --git a/src/lorcana_scraper.py b/src/lorcana_scraper.py new file mode 100644 index 0000000..bd6b4e1 --- /dev/null +++ b/src/lorcana_scraper.py @@ -0,0 +1,415 @@ +"""Lorcana deck scraper — supports lorcana.gg, inkdecks.com""" + +from __future__ import annotations + +import re +import threading +import urllib.parse +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field +from pathlib import Path + +import requests + +from src.cancellation import Cancelled +from src.constants import ProgressCallback +from src.scraper_utils import generate_fallback_back +from src.scraper_utils import resources_dir as _resources_dir + +_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36" + ), + "Accept": "application/json", +} + +# ── lorcana.gg (dotgg) ─────────────────────────────────────────────────────── +_DOTGG_DECK_API = "https://api.dotgg.gg/cgfw/getdeck?game=lorcana&slug={slug}" +_DOTGG_CARDS_API = "https://api.dotgg.gg/cgfw/getcards?game=lorcana&mode=indexed" +_DOTGG_IMAGE_URL = "https://static.dotgg.gg/lorcana/cards/{card_id}.webp" + +# ── inkdecks.com ───────────────────────────────────────────────────────────── +_INKDECKS_API = "https://inkdecks.com/api/lorcana/decks/{deck_id}" + +# dotgg card DB cache +_dotgg_lock = threading.Lock() +_dotgg_cache: dict[str, dict] | None = None +_dotgg_name_cache: dict[str, str] | None = None + +# Fallback back image singleton (generated once per process if back.png is missing) +_fallback_lock = threading.Lock() +_fallback_back_path: Path | None = None + + +# ── Data model ──────────────────────────────────────────────────────────────── + + +@dataclass +class LorcanaCard: + card_id: str + name: str + quantity: int + image_url: str + + +@dataclass +class LocanaDeck: + deck_id: str + name: str + cards: list[LorcanaCard] = field(default_factory=list) + source: str = "lorcana_gg" + + @property + def total_slots(self) -> int: + return sum(c.quantity for c in self.cards) + + +# ── Resources ───────────────────────────────────────────────────────────────── + + +def get_lorcana_back() -> Path: + """Return the Lorcana card back image path, generating a fallback if missing.""" + back = _resources_dir() / "backs" / "lorcana" / "back.png" + if back.exists(): + return back + return _generate_fallback_back() + + +def _generate_fallback_back() -> Path: + global _fallback_back_path + with _fallback_lock: + if _fallback_back_path is not None and _fallback_back_path.exists(): + return _fallback_back_path + import tempfile + + tmp = Path(tempfile.mkdtemp()) + path = generate_fallback_back(tmp / "back.png", "#0a1f44", "#c8a84b") + _fallback_back_path = path + return path + + +# ── Shared card DB helpers ──────────────────────────────────────────────────── + + +def _load_dotgg_card_db() -> tuple[dict[str, dict], dict[str, str]]: + """Fetch the dotgg lorcana cards DB once. Returns ({id: card}, {name.lower(): id}).""" + global _dotgg_cache, _dotgg_name_cache + with _dotgg_lock: + if _dotgg_cache is not None: + return _dotgg_cache, _dotgg_name_cache # type: ignore[return-value] + r = requests.get(_DOTGG_CARDS_API, headers=_HEADERS, timeout=30) + r.raise_for_status() + try: + raw = r.json() + names = raw["names"] + rows = raw["data"] + except (ValueError, KeyError) as exc: + raise ValueError( + f"La base de datos de cartas de dotgg.gg devolvió un formato inesperado: {exc}\n" + "Puede ser un problema temporal. Vuelve a intentarlo." + ) from exc + id_to_card: dict[str, dict] = {} + name_to_id: dict[str, str] = {} + for row in rows: + card = dict(zip(names, row)) + cid = card.get("id", "") + if cid: + id_to_card[cid] = card + name_to_id[card.get("name", "").lower()] = cid + _dotgg_cache = id_to_card + _dotgg_name_cache = name_to_id + return id_to_card, name_to_id + + +def _image_url_for_id(card_id: str) -> str: + return _DOTGG_IMAGE_URL.format(card_id=card_id) + + +def _image_url_for_name(name: str) -> str: + """Look up image URL by card name. Returns empty string if not found.""" + try: + _, name_to_id = _load_dotgg_card_db() + cid = name_to_id.get(name.lower(), "") + if cid: + return _image_url_for_id(cid) + except Exception: + pass + return "" + + +# ── URL routing ─────────────────────────────────────────────────────────────── + + +def scrape_deck(url: str) -> LocanaDeck: + """Detect the source site from the URL and dispatch to the right scraper.""" + host = urllib.parse.urlparse(url).netloc.lower() + if "lorcana.gg" in host: + return _scrape_lorcana_gg(url) + if "inkdecks.com" in host: + return _scrape_inkdecks(url) + raise ValueError(f"URL no reconocida: {url}\nWebs soportadas: lorcana.gg, inkdecks.com") + + +# ── lorcana.gg (dotgg) scraper ─────────────────────────────────────────────── + + +def _scrape_lorcana_gg(url: str) -> LocanaDeck: + m = re.search(r"/decks/([^/?#]+)", url) + if not m: + raise ValueError( + f"No se pudo extraer el slug de la URL de lorcana.gg: {url}\n" + "Formato esperado: https://lorcana.gg/decks//" + ) + slug = m.group(1).strip("/") + + r = requests.get(_DOTGG_DECK_API.format(slug=slug), headers=_HEADERS, timeout=20) + r.raise_for_status() + body = r.text.strip() + if not body: + raise ValueError( + f"El mazo '{slug}' no está disponible en lorcana.gg.\n" + "Puede ser privado, haber sido eliminado, o no existir.\n" + "Asegúrate de que la URL sea correcta y el mazo sea público." + ) + try: + deck_data = r.json() + except ValueError as exc: + raise ValueError( + f"La respuesta de lorcana.gg para '{slug}' no es JSON válido.\n" + "La API puede haber cambiado temporalmente. Vuelve a intentarlo." + ) from exc + + deck_entries = deck_data.get("deck", {}) + if not deck_entries: + raise ValueError( + f"El mazo '{slug}' de lorcana.gg no contiene cartas.\n" + "Puede haber sido eliminado o estar vacío." + ) + + id_to_card, _ = _load_dotgg_card_db() + + cards: list[LorcanaCard] = [] + for card_id, qty in deck_entries.items(): + meta = id_to_card.get(card_id, {}) + try: + qty_int = int(qty) + except (TypeError, ValueError) as exc: + raise ValueError( + f"Cantidad de carta inválida en lorcana.gg para '{card_id}': {qty!r}" + ) from exc + cards.append( + LorcanaCard( + card_id=card_id, + name=meta.get("name", card_id), + quantity=qty_int, + image_url=_image_url_for_id(card_id), + ) + ) + + return LocanaDeck( + deck_id=slug, + name=deck_data.get("humanname", slug), + cards=cards, + source="lorcana_gg", + ) + + +# ── inkdecks.com scraper ────────────────────────────────────────────────────── + + +def _scrape_inkdecks(url: str) -> LocanaDeck: + # URL format: /lorcana-metagame/deck-ARCHETYPE-ID or /lorcana-decks/SLUG-ID + m = re.search(r"-(\d+)(?:[/?#]|$)", url) + if not m: + raise ValueError( + f"No se pudo extraer el ID del mazo de la URL de inkdecks.com: {url}\n" + "Formato esperado: https://inkdecks.com/lorcana-metagame/deck-...-" + ) + deck_id = m.group(1) + + # Try JSON API first + api_url = _INKDECKS_API.format(deck_id=deck_id) + try: + r = requests.get(api_url, headers=_HEADERS, timeout=20) + if r.status_code == 200: + return _parse_inkdecks_api(r.json(), deck_id) + except Exception: + pass + + # Fall back to HTML page scraping (__NEXT_DATA__ JSON embedded in the page) + html_headers = {**_HEADERS, "Accept": "text/html,application/xhtml+xml,*/*"} + try: + r = requests.get(url, headers=html_headers, timeout=20) + r.raise_for_status() + except requests.HTTPError as exc: + status = exc.response.status_code if exc.response is not None else "?" + raise ValueError( + f"No se pudo acceder al mazo {deck_id} en inkdecks.com (HTTP {status}).\n" + "El mazo puede no existir o el sitio estar temporalmente no disponible." + ) from exc + return _parse_inkdecks_html(r.text, deck_id) + + +def _parse_inkdecks_api(data: dict, deck_id: str) -> LocanaDeck: + """Parse inkdecks.com JSON API response.""" + name = data.get("name") or data.get("title") or data.get("deck_name") or f"Deck {deck_id}" + raw_cards = data.get("cards") or data.get("decklist") or [] + cards = _parse_inkdecks_cards(raw_cards) + if not cards: + raise ValueError( + f"No se encontraron cartas en el mazo {deck_id} de inkdecks.com.\n" + "La respuesta de la API puede haber cambiado." + ) + return LocanaDeck(deck_id=deck_id, name=name, cards=cards, source="inkdecks") + + +def _parse_inkdecks_cards(raw_cards: list | dict) -> list[LorcanaCard]: + """Convert inkdecks card list to LorcanaCards, resolving image URLs via dotgg.""" + cards: list[LorcanaCard] = [] + if isinstance(raw_cards, dict): + # {card_id: quantity} map + for cid, qty in raw_cards.items(): + image_url = _image_url_for_id(cid) + cards.append(LorcanaCard(card_id=cid, name=cid, quantity=int(qty), image_url=image_url)) + elif isinstance(raw_cards, list): + for item in raw_cards: + cid = item.get("card_id") or item.get("id") or item.get("cardId") or "" + for _key in ("quantity", "count", "qty"): + if _key in item and item[_key] is not None: + qty = int(item[_key]) + break + else: + qty = 1 + name = item.get("name") or item.get("card_name") or cid + image_url = item.get("image") or item.get("image_url") or "" + if not image_url: + if cid: + image_url = _image_url_for_id(cid) + elif name: + image_url = _image_url_for_name(name) + if cid or name: + cards.append( + LorcanaCard(card_id=cid or name, name=name, quantity=qty, image_url=image_url) + ) + return cards + + +_INKDECKS_BASE = "https://inkdecks.com" + +# Matches +_INKDECKS_CARD_ROW = re.compile( + r'class="card-list-item"[^>]*' + r'data-card-type="[^"]*"[^>]*' + r'data-quantity="(\d+)"[^>]*' + r'data-image-src="(/img/cards/lorcana/[^"]+)"', +) +# Card name: Main Name -\n subtitle inside an anchor +_INKDECKS_CARD_NAME = re.compile( + r'href="/cards/details-[^"]+">\s*(?:\s*(.*?)\s*\s*)?(.*?)\s*', + re.DOTALL, +) + + +def _parse_inkdecks_html(html: str, deck_id: str) -> LocanaDeck: + """Extract deck data from server-rendered inkdecks.com HTML (data-* attributes on card rows).""" + # Deck name from first

or JSON-LD Article headline + name: str = f"Deck {deck_id}" + h1 = re.search(r"]*>(.*?)

", html, re.DOTALL) + if h1: + name = re.sub(r"<[^>]+>", "", h1.group(1)).strip() or name + + card_rows = _INKDECKS_CARD_ROW.findall(html) + if not card_rows: + raise ValueError( + f"No se encontró el mazo {deck_id} en la página de inkdecks.com.\n" + "Es posible que el sitio haya cambiado su estructura o el mazo no exista." + ) + + # Pair each row with the card name anchor that follows it in the HTML + card_name_matches = list(_INKDECKS_CARD_NAME.finditer(html)) + cards: list[LorcanaCard] = [] + for i, (qty_str, img_path) in enumerate(card_rows): + qty = int(qty_str) + image_url = _INKDECKS_BASE + img_path + card_id = img_path.lstrip("/") + + card_name = card_id # fallback + if i < len(card_name_matches): + main = (card_name_matches[i].group(1) or "").strip().rstrip(" -").strip() + subtitle = re.sub(r"\s+", " ", (card_name_matches[i].group(2) or "").strip()) + card_name = ( + f"{main} - {subtitle}" if main and subtitle else (main or subtitle or card_id) + ) + + cards.append( + LorcanaCard(card_id=card_id, name=card_name, quantity=qty, image_url=image_url) + ) + + return LocanaDeck(deck_id=deck_id, name=name, cards=cards, source="inkdecks") + + +# ── Image download ──────────────────────────────────────────────────────────── + + +def download_images( + deck: LocanaDeck, + dest_dir: Path, + cancel_event: threading.Event | None = None, + progress_cb: ProgressCallback = None, +) -> dict[str, Path]: + """Download one image per unique card_id. Returns {card_id: local_path}.""" + dest_dir.mkdir(parents=True, exist_ok=True) + unique: dict[str, LorcanaCard] = {c.card_id: c for c in deck.cards} + done = 0 + + def _fetch(card: LorcanaCard) -> tuple[str, Path]: + if not card.image_url: + raise ValueError(f"Sin URL de imagen para la carta '{card.name}'") + ext = card.image_url.rsplit(".", 1)[-1].split("?")[0] or "webp" + safe_name = re.sub(r"[^\w\-.]", "_", card.card_id) + path = dest_dir / f"{safe_name}.{ext}" + if path.exists(): + return card.card_id, path + r = requests.get(card.image_url, headers=_HEADERS, timeout=20) + r.raise_for_status() + path.write_bytes(r.content) + return card.card_id, path + + image_map: dict[str, Path] = {} + with ThreadPoolExecutor(max_workers=5) as ex: + futs = {ex.submit(_fetch, c): c for c in unique.values()} + for fut in as_completed(futs): + if cancel_event and cancel_event.is_set(): + raise Cancelled() + card_id, path = fut.result() + image_map[card_id] = path + done += 1 + if progress_cb: + progress_cb(done, len(unique)) + + return image_map + + +# ── Deck expansion ──────────────────────────────────────────────────────────── + + +def expand_deck( + deck: LocanaDeck, + image_map: dict[str, Path], +) -> tuple[list[Path], list[Path | None]]: + """Expand each card by its quantity. + + Returns (fronts, per_slot_backs) where None means use the pipeline default back. + All Lorcana cards share the same back, so per_slot_backs is all-None. + """ + fronts: list[Path] = [] + backs: list[Path | None] = [] + for card in deck.cards: + img = image_map.get(card.card_id) + if img is None: + continue + for _ in range(card.quantity): + fronts.append(img) + backs.append(None) + return fronts, backs diff --git a/src/op_scraper.py b/src/op_scraper.py new file mode 100644 index 0000000..b042b72 --- /dev/null +++ b/src/op_scraper.py @@ -0,0 +1,406 @@ +"""One Piece Card Game deck scraper — supports onepiece.gg, deckbuilder.egmanevents.com, deckbuilder.cardkaizoku.com.""" + +from __future__ import annotations + +import re +import threading +import urllib.parse +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from pathlib import Path + +import requests + +from src.cancellation import Cancelled +from src.constants import ProgressCallback +from src.scraper_utils import generate_fallback_back +from src.scraper_utils import resources_dir as _resources_dir + +_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36" + ) +} + +# ── dotgg (onepiece.gg) ──────────────────────────────────────────────────── +_DOTGG_DECK_API = "https://api.dotgg.gg/cgfw/getdeck?game=onepiece&slug={slug}" +_DOTGG_CARDS_API = "https://api.dotgg.gg/cgfw/getcards?game=onepiece&mode=indexed" +_DOTGG_IMAGE_URL = "https://static.dotgg.gg/onepiece/card/{card_id}.webp" + +# ── egmanevents ──────────────────────────────────────────────────────────── +_EGMAN_CARDS_API = "https://deckbuilder.egmanevents.com/api/cards/optcg" +_EGMAN_IMAGE_URL = "https://deckbuilder.egmanevents.com/api/images/optcg/{card_id}.png" +_EGMAN_SUPABASE = "https://resgvirjzcpamfumrygh.supabase.co" +_EGMAN_SUPA_KEY = "sb_publishable_bdDgor6ifmOvryEuZKWniw_RBzb3vuh" + +# ── cardkaizoku ──────────────────────────────────────────────────────────── +_KAIZOKU_CDN = "https://cdn.cardkaizoku.com" +_KAIZOKU_REFERER = "https://deckbuilder.cardkaizoku.com/" + + +# ── Image URL callables keyed by source ─────────────────────────────────── +def _kaizoku_img(card_id: str) -> str: + prefix = card_id.split("-")[0] + return f"{_KAIZOKU_CDN}/cards_en/{prefix}/{card_id}.png" + + +_IMAGE_URL_FUNC_BY_SOURCE = { + "dotgg": lambda cid: _DOTGG_IMAGE_URL.format(card_id=cid), + "egman": lambda cid: _EGMAN_IMAGE_URL.format(card_id=cid), + "kaizoku": _kaizoku_img, +} +_IMAGE_EXT_BY_SOURCE: dict[str, str] = { + "dotgg": "webp", + "egman": "png", + "kaizoku": "png", +} +_IMAGE_EXTRA_HEADERS_BY_SOURCE: dict[str, dict] = { + "kaizoku": {"Referer": _KAIZOKU_REFERER}, +} + +# ── Resources ───────────────────────────────────────────────────────────── +_STANDARD_BACK_BG = "#0A1628" +_STANDARD_BACK_BORDER = "#B0B8C8" + + +def get_op_backs() -> tuple[Path, Path]: + """Return (default_back, leader_back) from resources/backs/op/. + Falls back to generating simple colored images if the files are missing. + """ + op_dir = _resources_dir() / "backs" / "op" + default = op_dir / "default.png" + leader = op_dir / "lider.png" + if default.exists() and leader.exists(): + return default, leader + return _generate_fallback_backs() + + +def _generate_fallback_backs() -> tuple[Path, Path]: + import tempfile + + tmp = Path(tempfile.mkdtemp()) + return ( + generate_fallback_back(tmp / "default.png", _STANDARD_BACK_BG, _STANDARD_BACK_BORDER), + generate_fallback_back(tmp / "lider.png", "#8B0000", "#CCCCCC"), + ) + + +# ── Data model ──────────────────────────────────────────────────────────── + + +@dataclass +class OPCard: + card_id: str + name: str + quantity: int + is_leader: bool + colors: list[str] + + +@dataclass +class OPDeck: + name: str + slug: str + cards: list[OPCard] + source: str = "dotgg" # "dotgg" | "egman" | "kaizoku" + + @property + def leader(self) -> OPCard | None: + return next((c for c in self.cards if c.is_leader), None) + + @property + def total_slots(self) -> int: + return sum(c.quantity for c in self.cards) + + +# ── URL routing ─────────────────────────────────────────────────────────── + + +def scrape_deck(url: str) -> OPDeck: + """Detect the source site from the URL and dispatch to the right scraper.""" + host = urllib.parse.urlparse(url).netloc.lower() + if "onepiece.gg" in host: + return _scrape_dotgg(url) + if "egmanevents.com" in host: + return _scrape_egman(url) + if "cardkaizoku.com" in host: + return _scrape_kaizoku(url) + raise ValueError( + f"URL no reconocida: {url}\n" + "Webs soportadas: onepiece.gg, deckbuilder.egmanevents.com, deckbuilder.cardkaizoku.com" + ) + + +# ── dotgg / onepiece.gg ────────────────────────────────────────────────── + + +def _scrape_dotgg(url: str) -> OPDeck: + m = re.search(r"/decks/([^/?#]+)", url) + if not m: + raise ValueError(f"No se pudo extraer el slug de la URL: {url}") + slug = m.group(1).strip("/") + + r = requests.get(_DOTGG_DECK_API.format(slug=slug), headers=_HEADERS, timeout=15) + r.raise_for_status() + deck_data = r.json() + + r2 = requests.get(_DOTGG_CARDS_API, headers=_HEADERS, timeout=30) + r2.raise_for_status() + raw = r2.json() + names = raw["names"] + cards_db: dict[str, dict] = {row[0]: dict(zip(names, row)) for row in raw["data"]} + + cards: list[OPCard] = [] + for card_id, qty_str in deck_data["deck"].items(): + meta = cards_db.get(card_id, {}) + is_leader = meta.get("cardType", "").upper() == "LEADER" + color_str = meta.get("Color", "") + colors = [c.strip() for c in color_str.split("/") if c.strip()] + cards.append( + OPCard( + card_id=card_id, + name=meta.get("name", card_id), + quantity=int(qty_str), + is_leader=is_leader, + colors=colors, + ) + ) + + return OPDeck( + name=deck_data.get("humanname", slug), + slug=slug, + cards=cards, + source="dotgg", + ) + + +# ── egmanevents ────────────────────────────────────────────────────────── + + +def _egman_cards_db() -> dict[str, dict]: + r = requests.get(_EGMAN_CARDS_API, headers=_HEADERS, timeout=20) + r.raise_for_status() + return {c["card_code"]: c for c in r.json()} + + +def _egman_build_deck( + deck_map: dict[str, int], + cards_db: dict[str, dict], + slug: str, + source: str = "egman", +) -> OPDeck: + cards: list[OPCard] = [] + for card_id, qty in deck_map.items(): + meta = cards_db.get(card_id, {}) + is_leader = meta.get("category", "").lower() == "leader" + raw_colors = meta.get("color", []) + colors = ( + raw_colors if isinstance(raw_colors, list) else ([raw_colors] if raw_colors else []) + ) + cards.append( + OPCard( + card_id=card_id, + name=meta.get("name", card_id), + quantity=qty, + is_leader=is_leader, + colors=colors, + ) + ) + + leader = next((c for c in cards if c.is_leader), None) + name = f"{leader.name} Deck" if leader else slug + return OPDeck(name=name, slug=f"{source}_{slug}", cards=cards, source=source) + + +def _scrape_egman(url: str) -> OPDeck: + parsed = urllib.parse.urlparse(url) + qs = urllib.parse.parse_qs(parsed.query) + + if "deck" in qs: + # Direct format: ?deck=CARD:COUNT,CARD:COUNT,... + deck_str = qs["deck"][0] + deck_map: dict[str, int] = {} + for part in deck_str.split(","): + part = part.strip() + if ":" in part: + card_id, count = part.rsplit(":", 1) + try: + deck_map[card_id.strip()] = int(count) + except ValueError: + pass + if not deck_map: + raise ValueError("No se encontraron cartas en el parámetro ?deck= de la URL.") + slug = "direct" + + elif parsed.path.startswith("/d/"): + # Short URL: /d/CODE → Supabase RPC + short_code = parsed.path[3:].strip("/") + if not short_code: + raise ValueError("Código de mazo vacío en la URL /d/...") + deck_map, slug = _egman_load_short_code(short_code) + + else: + raise ValueError( + "URL de egmanevents no reconocida.\n" + "Formatos válidos:\n" + " • https://deckbuilder.egmanevents.com/?deck=CARTA:X,...\n" + " • https://deckbuilder.egmanevents.com/d/CODIGO" + ) + + cards_db = _egman_cards_db() + return _egman_build_deck(deck_map, cards_db, slug) + + +def _egman_load_short_code(code: str) -> tuple[dict[str, int], str]: + """Resolve /d/CODE via Supabase RPC. Returns (deck_map, slug).""" + rpc_url = f"{_EGMAN_SUPABASE}/rest/v1/rpc/get_deck_by_short_code" + headers = { + **_HEADERS, + "apikey": _EGMAN_SUPA_KEY, + "Authorization": f"Bearer {_EGMAN_SUPA_KEY}", + "Content-Type": "application/json", + } + r = requests.post(rpc_url, json={"p_code": code}, headers=headers, timeout=15) + r.raise_for_status() + data = r.json() + + if not data: + raise ValueError(f"No se encontró ningún mazo con el código: {code}") + + row = data[0] if isinstance(data, list) else data + deck_data = row.get("deck_data") or row.get("deckData") or {} + + # deck_data puede ser dict {card_id: count} o lista [{card_code, count}, ...] + deck_map: dict[str, int] = {} + if isinstance(deck_data, dict): + deck_map = {k: int(v) for k, v in deck_data.items()} + elif isinstance(deck_data, list): + for entry in deck_data: + cid = entry.get("card_code") or entry.get("cardCode") or entry.get("id", "") + cnt = int(entry.get("count", entry.get("quantity", 1))) + if cid: + deck_map[cid] = cnt + + if not deck_map: + raise ValueError(f"El mazo con código {code} está vacío o tiene un formato desconocido.") + + slug = row.get("short_code", code) + return deck_map, slug + + +# ── cardkaizoku ────────────────────────────────────────────────────────── + + +def _scrape_kaizoku(url: str) -> OPDeck: + """Parse ?deck={N}x{CARD_ID}|{N}x{CARD_ID}|... from cardkaizoku.com.""" + qs = urllib.parse.parse_qs(urllib.parse.urlparse(url).query) + deck_str = qs.get("deck", [""])[0] + if not deck_str: + raise ValueError( + "No se encontró el parámetro ?deck= en la URL de cardkaizoku.\n" + "Formato esperado: https://deckbuilder.cardkaizoku.com/?deck=2xOP01-001|3xOP01-002|..." + ) + + deck_map: dict[str, int] = {} + for part in deck_str.split("|"): + part = part.strip() + m = re.match(r"(\d+)x(.+)", part) + if m: + count = int(m.group(1)) + card_id = m.group(2).strip().upper() + deck_map[card_id] = deck_map.get(card_id, 0) + count + + if not deck_map: + raise ValueError( + "No se encontraron cartas en el parámetro ?deck= de la URL de cardkaizoku." + ) + + cards_db = _egman_cards_db() + return _egman_build_deck(deck_map, cards_db, "direct", source="kaizoku") + + +# ── Image download ───────────────────────────────────────────────────────── + + +def download_images( + deck: OPDeck, + dest_dir: Path, + cancel_event: threading.Event | None = None, + progress_cb: ProgressCallback = None, +) -> dict[str, Path]: + """Download one image per unique card. Returns {card_id: local_path}.""" + dest_dir.mkdir(parents=True, exist_ok=True) + url_func = _IMAGE_URL_FUNC_BY_SOURCE.get( + deck.source, + lambda cid: _DOTGG_IMAGE_URL.format(card_id=cid), + ) + ext = _IMAGE_EXT_BY_SOURCE.get(deck.source, "webp") + extra_headers = _IMAGE_EXTRA_HEADERS_BY_SOURCE.get(deck.source, {}) + req_headers = {**_HEADERS, **extra_headers} + done = 0 + + def _fetch(card: OPCard) -> tuple[str, Path]: + path = dest_dir / f"{card.card_id}.{ext}" + if path.exists(): + return card.card_id, path + url = url_func(card.card_id) + try: + r = requests.get(url, headers=req_headers, timeout=20) + r.raise_for_status() + path.write_bytes(r.content) + return card.card_id, path + except requests.exceptions.HTTPError as exc: + if exc.response is None or exc.response.status_code != 404: + raise + # Primary source returned 404 — fall back to kaizoku CDN (covers all sets) + fb_path = dest_dir / f"{card.card_id}.png" + if fb_path.exists(): + return card.card_id, fb_path + fb_r = requests.get( + _kaizoku_img(card.card_id), + headers={**_HEADERS, "Referer": _KAIZOKU_REFERER}, + timeout=20, + ) + fb_r.raise_for_status() + fb_path.write_bytes(fb_r.content) + return card.card_id, fb_path + + image_map: dict[str, Path] = {} + with ThreadPoolExecutor(max_workers=5) as ex: + futs = {ex.submit(_fetch, c): c for c in deck.cards} + for fut in as_completed(futs): + if cancel_event and cancel_event.is_set(): + raise Cancelled() + card_id, path = fut.result() + image_map[card_id] = path + done += 1 + if progress_cb: + progress_cb(done, len(deck.cards)) + + return image_map + + +# ── Deck expansion ──────────────────────────────────────────────────────── + + +def expand_deck( + deck: OPDeck, + image_map: dict[str, Path], + leader_back: Path | None, + standard_back: Path, +) -> tuple[list[Path], list[Path | None]]: + """Expand each card by its quantity. + Returns (fronts, per_slot_backs) where None means use the standard back. + """ + fronts: list[Path] = [] + backs: list[Path | None] = [] + for card in deck.cards: + img_path = image_map.get(card.card_id) + if img_path is None: + continue + slot_back: Path | None = leader_back if (card.is_leader and leader_back) else None + for _ in range(card.quantity): + fronts.append(img_path) + backs.append(slot_back) + return fronts, backs diff --git a/src/pipeline.py b/src/pipeline.py index 055cd23..8ce033b 100644 --- a/src/pipeline.py +++ b/src/pipeline.py @@ -6,8 +6,9 @@ from threading import Event from src.cancellation import Cancelled -from src.constants import Stage +from src.constants import Stage, StageCallback from src.cropper import process_for_pdf +from src.deck_importer import fetch_deck from src.downloader import ( DownloadPartialError, DownloadPermissionError, @@ -16,6 +17,8 @@ ) from src.parser import CardOrder, parse from src.pdf_generator import generate +from src.scraper_utils import resources_dir +from src.scryfall import ScryfallError, download_deck_images CROP_THREADS = 5 _log = logging.getLogger(__name__) @@ -51,7 +54,7 @@ def _build_crop_tasks( def _run_crop_parallel( tasks: list[tuple[str, Path, Path, bool]], cancel_event: Event | None, - on_done=None, # (drive_id: str, done: int, total: int) → None + on_done: StageCallback = None, ) -> dict[str, Path]: """Crop images in parallel using CROP_THREADS workers.""" total = len(tasks) @@ -82,7 +85,7 @@ def run( xml_path: str | Path, output_dir: str | Path, work_dir: str | Path = "workdir", - progress_callback=None, + progress_callback: StageCallback = None, cancel_event: Event | None = None, extra_fronts: list[str | Path] | None = None, extra_backs: list[str | Path | None] | None = None, @@ -119,7 +122,7 @@ def run_merged( output_dir: str | Path, base_name: str, work_dir: str | Path = "workdir", - progress_callback=None, + progress_callback: StageCallback = None, cancel_event: Event | None = None, extra_fronts: list[str | Path] | None = None, extra_backs: list[str | Path | None] | None = None, @@ -154,7 +157,7 @@ def run_locals_only( output_dir: str | Path, base_name: str, work_dir: str | Path = "workdir", - progress_callback=None, + progress_callback: StageCallback = None, cancel_event: Event | None = None, extra_backs: list[str | Path | None] | None = None, local_crop_map: dict[Path, bool] | None = None, @@ -254,7 +257,7 @@ def _run_xmls( base_name: str, output_dir: str | Path, work_dir: str | Path, - progress_callback=None, + progress_callback: StageCallback = None, cancel_event: Event | None = None, extra_fronts: list[str | Path] | None = None, extra_backs: list[str | Path | None] | None = None, @@ -443,14 +446,14 @@ def run_plan( jobs: list, output_dir: str | Path, work_dir: str | Path = "workdir", - progress_callback=None, + progress_callback: StageCallback = None, cancel_event: Event | None = None, extra_fronts: list[str | Path] | None = None, extra_backs: list[str | Path | None] | None = None, local_crop_map: dict[Path, bool] | None = None, - on_job_pdf_start=None, - on_xml_download_progress=None, - on_xml_crop_progress=None, + on_job_pdf_start: StageCallback = None, + on_xml_download_progress: StageCallback = None, + on_xml_crop_progress: StageCallback = None, fronts_only: bool = False, on_speed_update=None, ) -> list[Path]: @@ -614,3 +617,73 @@ def _on_crop_plan(drive_id: str, done: int, total: int) -> None: all_outputs.extend(outputs) return all_outputs + + +def run_deck_url( + url: str, + output_dir: str | Path, + work_dir: str | Path, + base_name: str, + progress_callback: StageCallback = None, + cancel_event: Event | None = None, + include_sideboard: bool = False, + fronts_only: bool = False, +) -> list[Path]: + """Fetch a deck from Moxfield/Archidekt, download images from Scryfall, generate PDF(s). + + MDFCs use card_faces[0] as front and card_faces[1] as back. + Normal cards use resources/backs/mtg/back.jpg as their back. + No MPC bleed crop is applied (Scryfall images have no bleed). + """ + output_dir = Path(output_dir) + work_dir = Path(work_dir) + scryfall_dir = work_dir / "scryfall" + + fetched = fetch_deck(url) + + cards = [ + c for c in fetched.cards if c.zone == "main" or (include_sideboard and c.zone == "side") + ] + if not cards: + raise ValueError("El mazo importado no contiene cartas.") + + def _dl_progress(done: int, total: int) -> None: + if progress_callback: + progress_callback(Stage.DOWNLOAD, done, total) + + if progress_callback: + progress_callback(Stage.DOWNLOAD, 0, len(cards)) + + try: + dl_results = download_deck_images(cards, scryfall_dir, _dl_progress, cancel_event) + except ScryfallError as exc: + raise ValueError(f"Error al descargar imágenes de Scryfall: {exc}") from exc + + _check_cancel(cancel_event) + + mtg_back = resources_dir() / "backs" / "mtg" / "back.jpg" + + extra_fronts: list[Path] = [] + extra_backs: list[Path | None] = [] + local_crop_map: dict[Path, bool] = {} + + for card, front_path, back_path in dl_results: + local_crop_map[front_path] = False + if back_path: + local_crop_map[back_path] = False + for _ in range(card.quantity): + extra_fronts.append(front_path) + extra_backs.append(back_path) + + return run_locals_only( + extra_fronts=extra_fronts, + local_cardback=mtg_back, + output_dir=output_dir, + base_name=base_name, + work_dir=work_dir, + progress_callback=progress_callback, + cancel_event=cancel_event, + extra_backs=extra_backs, + local_crop_map=local_crop_map, + fronts_only=fronts_only, + ) diff --git a/src/rb_scraper.py b/src/rb_scraper.py new file mode 100644 index 0000000..80882cc --- /dev/null +++ b/src/rb_scraper.py @@ -0,0 +1,675 @@ +"""Riftbound deck scraper — piltoverarchive.com, riftmana.com, riftbinder.com, riftdex.com, riftbound.gg""" + +from __future__ import annotations + +import json +import re +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field +from pathlib import Path +from urllib.parse import quote + +import requests + +from src.cancellation import Cancelled +from src.constants import ProgressCallback +from src.scraper_utils import generate_fallback_back +from src.scraper_utils import resources_dir as _resources_dir + +_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36" + ), + "Accept": "application/json", + "Referer": "https://piltoverarchive.com/", +} + +_TRPC_BASE = "https://piltoverarchive.com/api/trpc" + +# ── riftmana.com ────────────────────────────────────────────────────────────── +_RM_BASE = "https://riftmana.com" +_RM_API = "https://riftmana.com/wp-json/riftmana/v2/decks/{uuid}" + +# ── riftbinder.com (Firestore) ──────────────────────────────────────────────── +_RB_FS_BASE = ( + "https://firestore.googleapis.com/v1" + "/projects/riftbinder-dc881/databases/(default)/documents/decks/{id}" +) +_RB_IMG_CDN = "https://cdn.piltoverarchive.com/cards/{code}.webp" + +# ── riftbound.gg (dotgg) ───────────────────────────────────────────────────── +_RBGG_DECK_API = "https://api.dotgg.gg/cgfw/getdeck?game=riftbound&slug={slug}" +_RBGG_CARDS_API = "https://api.dotgg.gg/cgfw/getcards?game=riftbound&mode=indexed" +_RBGG_IMG_URL = "https://static.dotgg.gg/riftbound/cards/{code}.webp" + +# ── riftdex.com (Supabase) ──────────────────────────────────────────────────── +_RDX_SUPA_URL = "https://duiehcongdospcckoydy.supabase.co" +_RDX_SUPA_KEY = ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ".eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImR1aWVoY29uZ2Rvc3BjY2tveWR5Iiw" + "icm9sZSI6ImFub24iLCJpYXQiOjE3NTE5NzIwNTMsImV4cCI6MjA2NzU0ODA1M30" + ".e-XlfBWPLkUIzU6DeNpx7dNJrTM05v9IedSiV6zt7_c" +) + + +# ── Sections ────────────────────────────────────────────────────────────────── +# Defines which PDF back each section uses. +SECTION_ORDER = ["legend", "champion", "battlefield", "rune", "maindeck", "sideboard"] + +# Sections that share the maindeck back +_MAINDECK_SECTIONS = {"champion", "maindeck", "sideboard"} + + +# ── Data model ──────────────────────────────────────────────────────────────── + + +@dataclass +class RBCard: + card_id: str + variant_id: str + name: str + card_type: str # "Legend", "Unit", "Rune", "Battlefield", "Spell", … + card_super: str | None # "Champion", "Basic", None + quantity: int + image_url: str + section: str # "legend" | "champion" | "battlefield" | "rune" | "maindeck" | "sideboard" + + +@dataclass +class RBDeck: + deck_id: str + name: str + cards: list[RBCard] = field(default_factory=list) + + @property + def total_slots(self) -> int: + return sum(c.quantity for c in self.cards) + + def by_section(self) -> dict[str, list[RBCard]]: + result: dict[str, list[RBCard]] = {s: [] for s in SECTION_ORDER} + for c in self.cards: + result.setdefault(c.section, []).append(c) + return result + + +# ── Resources ───────────────────────────────────────────────────────────────── + + +def get_rb_backs() -> dict[str, Path]: + """Return {section: back_image_path} for each Riftbound card section. + + Mapping: + black.png → legend, battlefield + blue.png → champion, maindeck, sideboard (Units, Spells, Gears) + white.png → rune + Falls back to generated placeholder images when files are missing. + """ + rb_dir = _resources_dir() / "backs" / "riftbound" + black = rb_dir / "black.png" + blue = rb_dir / "blue.png" + white = rb_dir / "white.png" + + def _get(path: Path, fallback_key: str) -> Path: + return path if path.exists() else _generate_fallback_back(fallback_key) + + return { + "legend": _get(black, "legend"), + "battlefield": _get(black, "battlefield"), + "rune": _get(white, "rune"), + "maindeck": _get(blue, "maindeck"), + } + + +_FALLBACK_COLORS: dict[str, tuple[str, str]] = { + "legend": ("#111111", "#888888"), + "battlefield": ("#111111", "#888888"), + "rune": ("#eeeeee", "#444444"), + "maindeck": ("#0d0d1a", "#5588cc"), +} + + +def _generate_fallback_back(section: str) -> Path: + import tempfile + + tmp = Path(tempfile.mkdtemp()) + bg, border = _FALLBACK_COLORS.get(section, ("#111111", "#888888")) + return generate_fallback_back(tmp / f"{section}.png", bg, border) + + +# ── Shared helpers ──────────────────────────────────────────────────────────── + + +def _type_to_section(card_type: str, card_super: str | None = None) -> str: + """Map Riftbound card type + super to a deck section name.""" + t = (card_type or "").lower() + s = (card_super or "").lower() + if t == "legend": + return "legend" + if t == "battlefield": + return "battlefield" + if t == "rune": + return "rune" + if t == "champion" or s == "champion": + return "champion" + return "maindeck" + + +# ── URL routing ─────────────────────────────────────────────────────────────── + + +def scrape_deck(url: str) -> RBDeck: + """Detect the source site from the URL and dispatch to the right scraper.""" + import urllib.parse + + host = urllib.parse.urlparse(url).netloc.lower() + if "piltoverarchive.com" in host: + m = re.search(r"/decks/view/([0-9a-f-]{36})", url, re.IGNORECASE) + if not m: + raise ValueError( + f"URL no reconocida: {url}\n" + "Formato esperado: https://piltoverarchive.com/decks/view/" + ) + return _fetch_deck(m.group(1)) + if "riftmana.com" in host: + return _scrape_riftmana(url) + if "riftbinder.com" in host: + return _scrape_riftbinder(url) + if "riftdex.com" in host: + return _scrape_riftdex(url) + if "riftbound.gg" in host: + return _scrape_riftbound_gg(url) + raise ValueError( + f"URL no reconocida: {url}\n" + "Webs soportadas: piltoverarchive.com, riftmana.com, " + "riftbinder.com, riftdex.com, riftbound.gg" + ) + + +# ── riftbound.gg scraper (dotgg) ───────────────────────────────────────────── + + +def _scrape_riftbound_gg(url: str) -> RBDeck: + m = re.search(r"/decks/([^/?#]+)", url) + if not m: + raise ValueError( + f"No se pudo extraer el slug de la URL de riftbound.gg: {url}\n" + "Formato esperado: https://riftbound.gg/decks//" + ) + slug = m.group(1).strip("/") + + r = requests.get(_RBGG_DECK_API.format(slug=slug), headers=_HEADERS, timeout=20) + r.raise_for_status() + body = r.text.strip() + if not body: + raise ValueError( + f"El mazo '{slug}' no está disponible en riftbound.gg.\n" + "Puede ser privado, haber sido eliminado, o no existir.\n" + "Asegúrate de que la URL sea correcta y el mazo sea público." + ) + deck_data = r.json() + + r2 = requests.get(_RBGG_CARDS_API, headers=_HEADERS, timeout=30) + r2.raise_for_status() + raw = r2.json() + names = raw["names"] + cards_db: dict[str, dict] = {row[0]: dict(zip(names, row)) for row in raw["data"]} + + cards: list[RBCard] = [] + + for code, qty_str in deck_data.get("deck", {}).items(): + meta = cards_db.get(code, {}) + section = _type_to_section(meta.get("type", ""), meta.get("supertype")) + cards.append( + RBCard( + card_id=code, + variant_id=code, + name=meta.get("name", code), + card_type=meta.get("type", ""), + card_super=meta.get("supertype"), + quantity=int(qty_str), + image_url=_RBGG_IMG_URL.format(code=code), + section=section, + ) + ) + + # boards[1] = sideboard (boards[0] == deck) + boards = deck_data.get("boards", []) + if len(boards) > 1: + for code, qty_str in boards[1].items(): + meta = cards_db.get(code, {}) + cards.append( + RBCard( + card_id=code, + variant_id=f"{code}_sb", + name=meta.get("name", code), + card_type=meta.get("type", ""), + card_super=meta.get("supertype"), + quantity=int(qty_str), + image_url=_RBGG_IMG_URL.format(code=code), + section="sideboard", + ) + ) + + return RBDeck( + deck_id=slug, + name=deck_data.get("humanname", slug), + cards=cards, + ) + + +# ── riftmana.com scraper ────────────────────────────────────────────────────── + + +def _scrape_riftmana(url: str) -> RBDeck: + html_headers = {**_HEADERS, "Accept": "text/html"} + r = requests.get(url, headers=html_headers, timeout=20) + r.raise_for_status() + + m = re.search(r'data-deck-uuid=["\']([0-9a-f-]{36})["\']', r.text) + if not m: + raise ValueError(f"No se encontró el UUID del mazo en la página de riftmana.com: {url}") + uuid = m.group(1) + + api_r = requests.get( + _RM_API.format(uuid=uuid), + headers=_HEADERS, + timeout=20, + ) + api_r.raise_for_status() + data = api_r.json()["data"]["deck"] + + cards: list[RBCard] = [] + for item in data.get("cards", []): + section = _type_to_section(item.get("type", ""), item.get("super")) + code = item["code"].upper() + cards.append( + RBCard( + card_id=code, + variant_id=code, + name=item.get("name", code), + card_type=item.get("type", ""), + card_super=item.get("super"), + quantity=int(item.get("quantity", 1)), + image_url=item.get("image", ""), + section=section, + ) + ) + for item in data.get("sideboard", []): + code = item["code"].upper() + cards.append( + RBCard( + card_id=code, + variant_id=f"{code}_sb", + name=item.get("name", code), + card_type=item.get("type", ""), + card_super=item.get("super"), + quantity=int(item.get("quantity", 1)), + image_url=item.get("image", ""), + section="sideboard", + ) + ) + + return RBDeck(deck_id=uuid, name=data.get("name", uuid), cards=cards) + + +# ── riftbinder.com scraper (Firestore) ──────────────────────────────────────── + + +def _fs_str(field: dict) -> str: + """Extract a string value from a Firestore field object.""" + return field.get("stringValue") or field.get("integerValue") or "" + + +def _fs_arr(field: dict) -> list[dict]: + """Extract array values from a Firestore arrayValue field.""" + return field.get("arrayValue", {}).get("values", []) + + +def _scrape_riftbinder(url: str) -> RBDeck: + m = re.search(r"/decks/([A-Za-z0-9_-]+)", url) + if not m: + raise ValueError(f"No se pudo extraer el ID del mazo de la URL de riftbinder.com: {url}") + doc_id = m.group(1) + + r = requests.get( + _RB_FS_BASE.format(id=doc_id), + headers=_HEADERS, + timeout=20, + ) + r.raise_for_status() + fields = r.json().get("fields", {}) + + name = _fs_str(fields.get("name", {})) or doc_id + cards: list[RBCard] = [] + + def _add(code: str, qty: int, section: str) -> None: + code = code.upper() + cards.append( + RBCard( + card_id=code, + variant_id=f"{code}_{section}", + name=code, + card_type=section.capitalize(), + card_super=None, + quantity=qty, + image_url=_RB_IMG_CDN.format(code=code), + section=section, + ) + ) + + legend_id = _fs_str(fields.get("legendId", {})) + if legend_id: + _add(legend_id, 1, "legend") + + for v in _fs_arr(fields.get("battlefields", {})): + code = v.get("stringValue", "") + if code: + _add(code, 1, "battlefield") + + for v in _fs_arr(fields.get("runes", {})): + f = v.get("mapValue", {}).get("fields", {}) + code = _fs_str(f.get("runeId", {})) + if code: + _add(code, 1, "rune") + + for section_key, section_name in [("mainDeck", "maindeck"), ("sideboard", "sideboard")]: + for v in _fs_arr(fields.get(section_key, {})): + f = v.get("mapValue", {}).get("fields", {}) + code = _fs_str(f.get("cardId", {})) + qty = int(_fs_str(f.get("quantity", {})) or 1) + if code: + _add(code, qty, section_name) + + return RBDeck(deck_id=doc_id, name=name, cards=cards) + + +# ── riftdex.com scraper (Supabase) ──────────────────────────────────────────── + +_RDX_HEADERS = { + **_HEADERS, + "apikey": _RDX_SUPA_KEY, + "Authorization": f"Bearer {_RDX_SUPA_KEY}", +} + + +def _scrape_riftdex(url: str) -> RBDeck: + m = re.search(r"/deck/([0-9a-f-]{36})", url, re.IGNORECASE) + if not m: + raise ValueError( + f"No se pudo extraer el UUID del mazo de la URL de riftdex.com: {url}\n" + "Formato esperado: https://riftdex.com/deck/" + ) + deck_id = m.group(1) + + r = requests.get( + f"{_RDX_SUPA_URL}/rest/v1/decklists?id=eq.{deck_id}&select=*", + headers=_RDX_HEADERS, + timeout=20, + ) + r.raise_for_status() + rows = r.json() + if not rows: + raise ValueError(f"No se encontró el mazo con ID {deck_id} en riftdex.com") + deck_data = rows[0] + + # deck_data["cards"] = [{"count": N, "cardId": ""}] + slot_list: list[dict] = deck_data.get("cards", []) + if not slot_list: + raise ValueError(f"El mazo {deck_id} de riftdex.com no tiene cartas") + + # Batch-resolve all card UUIDs in one Supabase query + card_uuids = list({s["cardId"] for s in slot_list}) + chunk_size = 200 + card_db: dict[str, dict] = {} + for i in range(0, len(card_uuids), chunk_size): + chunk = card_uuids[i : i + chunk_size] + ids_param = "(" + ",".join(chunk) + ")" + rc = requests.get( + f"{_RDX_SUPA_URL}/rest/v1/cards" + f"?id=in.{ids_param}" + f"&select=id,card_name,card_number,type,image_url,super", + headers=_RDX_HEADERS, + timeout=20, + ) + rc.raise_for_status() + for row in rc.json(): + card_db[row["id"]] = row + + cards: list[RBCard] = [] + for slot in slot_list: + cid = slot["cardId"] + qty = int(slot.get("count", 1)) + meta = card_db.get(cid, {}) + card_number = meta.get("card_number", cid) + section = _type_to_section(meta.get("type", ""), meta.get("super")) + cards.append( + RBCard( + card_id=card_number, + variant_id=cid, + name=meta.get("card_name", card_number), + card_type=meta.get("type", ""), + card_super=meta.get("super"), + quantity=qty, + image_url=meta.get("image_url", ""), + section=section, + ) + ) + + return RBDeck(deck_id=deck_id, name=deck_data.get("name", deck_id), cards=cards) + + +# ── API fetch ───────────────────────────────────────────────────────────────── + + +def _trpc_get(proc: str, payload: dict) -> dict: + inp = quote(json.dumps({"json": payload})) + url = f"{_TRPC_BASE}/{proc}?input={inp}" + r = requests.get(url, headers=_HEADERS, timeout=20) + r.raise_for_status() + data = r.json() + return data["result"]["data"]["json"] + + +def _resolve_image(item: dict) -> str: + """Find the imageUrl for the selected variantId in the card's variants list.""" + variant_id = item.get("variantId") + variants = item.get("card", {}).get("cardVariants", []) + for v in variants: + if v["id"] == variant_id: + return v["imageUrl"] + # Fallback: use first variant if the selected one is not found + if variants: + return variants[0]["imageUrl"] + return "" + + +def _fetch_deck(deck_id: str) -> RBDeck: + raw = _trpc_get("decks.getById", {"id": deck_id}) + if raw is None: + raise ValueError( + f"No se encontró el mazo con ID {deck_id} en piltoverarchive.com.\n" + "Puede ser privado, haber sido eliminado, o el ID no ser válido." + ) + + cards: list[RBCard] = [] + + # ── Legend (always 1 copy, imageUrl directly on the object) ────────────── + leg = raw.get("legend") + if leg: + cards.append( + RBCard( + card_id=leg["cardId"], + variant_id=leg["id"], + name=leg["card"]["name"], + card_type="Legend", + card_super=None, + quantity=1, + image_url=leg["imageUrl"], + section="legend", + ) + ) + + # ── Champions ───────────────────────────────────────────────────────────── + for item in raw.get("champions", []): + cards.append( + RBCard( + card_id=item["cardId"], + variant_id=item["variantId"], + name=item["card"]["name"], + card_type=item["card"]["type"], + card_super=item["card"].get("super"), + quantity=item["quantity"], + image_url=_resolve_image(item), + section="champion", + ) + ) + + # ── Battlefields ────────────────────────────────────────────────────────── + for item in raw.get("battlefields", []): + cards.append( + RBCard( + card_id=item["cardId"], + variant_id=item["variantId"], + name=item["card"]["name"], + card_type=item["card"]["type"], + card_super=item["card"].get("super"), + quantity=item["quantity"], + image_url=_resolve_image(item), + section="battlefield", + ) + ) + + # ── Runes ───────────────────────────────────────────────────────────────── + for item in raw.get("runes", []): + cards.append( + RBCard( + card_id=item["cardId"], + variant_id=item["variantId"], + name=item["card"]["name"], + card_type=item["card"]["type"], + card_super=item["card"].get("super"), + quantity=item["quantity"], + image_url=_resolve_image(item), + section="rune", + ) + ) + + # ── Maindeck ────────────────────────────────────────────────────────────── + for item in raw.get("maindeck", []): + cards.append( + RBCard( + card_id=item["cardId"], + variant_id=item["variantId"], + name=item["card"]["name"], + card_type=item["card"]["type"], + card_super=item["card"].get("super"), + quantity=item["quantity"], + image_url=_resolve_image(item), + section="maindeck", + ) + ) + + # ── Sideboard ───────────────────────────────────────────────────────────── + for item in raw.get("sideboard", []): + cards.append( + RBCard( + card_id=item["cardId"], + variant_id=item["variantId"], + name=item["card"]["name"], + card_type=item["card"]["type"], + card_super=item["card"].get("super"), + quantity=item["quantity"], + image_url=_resolve_image(item), + section="sideboard", + ) + ) + + return RBDeck(deck_id=deck_id, name=raw.get("name", deck_id), cards=cards) + + +# ── Image download ──────────────────────────────────────────────────────────── + + +def download_images( + deck: RBDeck, + dest_dir: Path, + cancel_event: threading.Event | None = None, + progress_cb: ProgressCallback = None, +) -> dict[str, Path]: + """Download one image per unique (card_id, variant_id) pair. + Returns {variant_id: local_path}. + """ + dest_dir.mkdir(parents=True, exist_ok=True) + seen: dict[str, Path] = {} + done = 0 + unique = {c.variant_id: c for c in deck.cards} + + def _fetch(card: RBCard) -> tuple[str, Path]: + ext = card.image_url.rsplit(".", 1)[-1].split("?")[0] or "webp" + safe_name = re.sub(r"[^\w\-.]", "_", card.variant_id) + path = dest_dir / f"{safe_name}.{ext}" + if path.exists(): + return card.variant_id, path + r = requests.get(card.image_url, headers=_HEADERS, timeout=20) + r.raise_for_status() + path.write_bytes(r.content) + return card.variant_id, path + + with ThreadPoolExecutor(max_workers=5) as ex: + futs = {ex.submit(_fetch, c): c for c in unique.values()} + for fut in as_completed(futs): + if cancel_event and cancel_event.is_set(): + raise Cancelled() + vid, path = fut.result() + seen[vid] = path + done += 1 + if progress_cb: + progress_cb(done, len(unique)) + + return seen + + +# ── Deck expansion ──────────────────────────────────────────────────────────── + + +def expand_deck( + deck: RBDeck, + image_map: dict[str, Path], + backs: dict[str, Path], + include_runes: bool = True, +) -> tuple[list[Path], list[Path | None]]: + """Expand cards by quantity in section order. + + Returns (fronts, per_slot_backs). + Sections: legend → legend back, battlefield → battlefield back, + rune → rune back, champion/maindeck/sideboard → maindeck back. + None back means use the PDF default cardback. + """ + fronts: list[Path] = [] + per_backs: list[Path | None] = [] + + def _back_for(section: str) -> Path | None: + if section == "legend": + return backs.get("legend") + if section == "battlefield": + return backs.get("battlefield") + if section == "rune": + return backs.get("rune") + # champion, maindeck, sideboard + return backs.get("maindeck") + + for section in SECTION_ORDER: + if section == "rune" and not include_runes: + continue + for card in deck.by_section()[section]: + img = image_map.get(card.variant_id) + if img is None: + continue + back = _back_for(section) + for _ in range(card.quantity): + fronts.append(img) + per_backs.append(back) + + return fronts, per_backs diff --git a/src/scraper_utils.py b/src/scraper_utils.py new file mode 100644 index 0000000..612642c --- /dev/null +++ b/src/scraper_utils.py @@ -0,0 +1,32 @@ +"""Shared utilities for deck scrapers.""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +def resources_dir() -> Path: + """Return the project resources directory, handling frozen (.exe) and dev environments.""" + if getattr(sys, "frozen", False): + return Path(sys._MEIPASS) / "resources" + return Path(__file__).resolve().parent.parent / "resources" + + +def generate_fallback_back( + path: Path, + bg: str, + border: str, + size: tuple[int, int] = (480, 670), +) -> Path: + """Draw a plain colored card-back rectangle and save it to path. Returns path.""" + from PIL import Image, ImageDraw + + W, H = size + img = Image.new("RGB", (W, H), bg) + draw = ImageDraw.Draw(img) + bw = max(6, W // 25) + draw.rectangle([0, 0, W - 1, H - 1], outline=border, width=bw) + path.parent.mkdir(parents=True, exist_ok=True) + img.save(path, "PNG") + return path diff --git a/src/scryfall.py b/src/scryfall.py new file mode 100644 index 0000000..616c844 --- /dev/null +++ b/src/scryfall.py @@ -0,0 +1,206 @@ +"""Scryfall image downloader — fetches card images by set code and collector number.""" + +from __future__ import annotations + +import logging +import re +import threading +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from pathlib import Path +from threading import Event + +import requests + +from src.cancellation import Cancelled +from src.constants import ProgressCallback +from src.deck_importer import DeckCard + +_log = logging.getLogger(__name__) + +_SCRYFALL_API = "https://api.scryfall.com/cards/{set_code}/{number}" +_SCRYFALL_NAMED_API = "https://api.scryfall.com/cards/named" +_HEADERS = { + "User-Agent": "MPCFillToPDF/2.0", + "Accept": "application/json", +} +_TIMEOUT = 15 +_RATE_DELAY = 0.1 # Scryfall policy: max 10 req/s + +SCRYFALL_THREADS = 5 + +_rate_lock = threading.Lock() +_last_request_time: float = 0.0 + +_cache: dict[tuple[str, str], ScryfallCard] = {} +_cache_lock = threading.Lock() + +_name_cache: dict[str, tuple[str, str]] = {} +_name_cache_lock = threading.Lock() + + +class ScryfallError(Exception): + pass + + +@dataclass +class ScryfallCard: + front_url: str + back_url: str | None + + +def _throttled_get(url: str, params: dict | None = None) -> requests.Response: + global _last_request_time + for attempt in range(3): + with _rate_lock: + now = time.monotonic() + wait = _RATE_DELAY - (now - _last_request_time) + if wait > 0: + time.sleep(wait) + _last_request_time = time.monotonic() + resp = requests.get(url, headers=_HEADERS, timeout=_TIMEOUT, params=params) + if resp.status_code != 429: + return resp + retry_after = float(resp.headers.get("Retry-After", 2**attempt)) + _log.warning("Scryfall 429, reintentando en %.1fs (intento %d/3)", retry_after, attempt + 1) + time.sleep(retry_after) + return resp + + +def fetch_card(set_code: str, collector_number: str) -> ScryfallCard: + """Return front/back image URLs for the given printing from Scryfall.""" + key = (set_code.lower(), collector_number) + with _cache_lock: + if key in _cache: + return _cache[key] + + url = _SCRYFALL_API.format(set_code=set_code.lower(), number=collector_number) + try: + resp = _throttled_get(url) + resp.raise_for_status() + data = resp.json() + except requests.HTTPError as exc: + raise ScryfallError( + f"Carta no encontrada en Scryfall: {set_code}/{collector_number} ({exc})" + ) from exc + except requests.RequestException as exc: + raise ScryfallError( + f"Error de conexión con Scryfall ({set_code}/{collector_number}): {exc}" + ) from exc + + if "image_uris" in data: + result = ScryfallCard(data["image_uris"]["large"], None) + elif "card_faces" in data: + faces = data["card_faces"] + front = faces[0].get("image_uris", {}).get("large", "") + back = faces[1].get("image_uris", {}).get("large") if len(faces) > 1 else None + if not front: + raise ScryfallError( + f"No se encontró imagen de frente para {set_code}/{collector_number}" + ) + result = ScryfallCard(front, back) + else: + raise ScryfallError( + f"Formato de respuesta inesperado de Scryfall para {set_code}/{collector_number}" + ) + + with _cache_lock: + _cache[key] = result + return result + + +def fetch_card_by_name(name: str) -> tuple[str, str]: + """Return (set_code, collector_number) for the given card name using Scryfall exact search.""" + key = name.lower() + with _name_cache_lock: + if key in _name_cache: + return _name_cache[key] + + try: + resp = _throttled_get(_SCRYFALL_NAMED_API, params={"exact": name}) + resp.raise_for_status() + data = resp.json() + except requests.HTTPError as exc: + raise ScryfallError(f"Carta no encontrada en Scryfall: {name} ({exc})") from exc + except requests.RequestException as exc: + raise ScryfallError(f"Error de conexión con Scryfall ({name}): {exc}") from exc + + result = (str(data.get("set", "")).lower(), str(data.get("collector_number", ""))) + with _name_cache_lock: + _name_cache[key] = result + return result + + +def _ext_from_url(url: str) -> str: + m = re.search(r"\.(jpg|jpeg|png|webp)", url, re.IGNORECASE) + return f".{m.group(1).lower()}" if m else ".jpg" + + +def download_card_images(card: DeckCard, dest_dir: Path) -> tuple[Path, Path | None]: + """Download front and (for MDFCs) back images. Returns (front_path, back_path|None).""" + dest_dir.mkdir(parents=True, exist_ok=True) + set_code = card.set_code + collector_number = card.collector_number + if not set_code or not collector_number: + set_code, collector_number = fetch_card_by_name(card.name) + scryfall = fetch_card(set_code, collector_number) + slug = f"{set_code.lower()}_{collector_number}" + + front_ext = _ext_from_url(scryfall.front_url) + front_path = dest_dir / f"{slug}{front_ext}" + if not front_path.exists(): + _log.info("Descargando imagen: %s", slug) + resp = _throttled_get(scryfall.front_url) + resp.raise_for_status() + front_path.write_bytes(resp.content) + + back_path: Path | None = None + if scryfall.back_url: + back_ext = _ext_from_url(scryfall.back_url) + back_path = dest_dir / f"{slug}_back{back_ext}" + if not back_path.exists(): + _log.info("Descargando reverso MDFC: %s", slug) + resp = _throttled_get(scryfall.back_url) + resp.raise_for_status() + back_path.write_bytes(resp.content) + + return front_path, back_path + + +def download_deck_images( + cards: list[DeckCard], + dest_dir: Path, + progress_cb: ProgressCallback = None, + cancel_event: Event | None = None, +) -> list[tuple[DeckCard, Path, Path | None]]: + """Download images for all unique cards in parallel. + + Returns an ordered list of (card, front_path, back_path|None) matching the input order. + """ + total = len(cards) + done = 0 + id_to_result: dict[int, tuple[DeckCard, Path, Path | None]] = {} + + with ThreadPoolExecutor(max_workers=SCRYFALL_THREADS) as executor: + futures = { + executor.submit(download_card_images, c, dest_dir): (i, c) for i, c in enumerate(cards) + } + try: + for future in as_completed(futures): + if cancel_event is not None and cancel_event.is_set(): + for f in futures: + f.cancel() + raise Cancelled() + idx, card = futures[future] + front_path, back_path = future.result() + id_to_result[idx] = (card, front_path, back_path) + done += 1 + if progress_cb: + progress_cb(done, total) + except Exception: + for f in futures: + f.cancel() + raise + + return [id_to_result[i] for i in range(len(cards))] diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..f9767b2 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,71 @@ +"""Tests for src/config.py — Google Drive API key resolution cascade.""" + +from __future__ import annotations + +import json +import sys +import types +from unittest.mock import patch + +from src.config import get_drive_api_key + + +class TestGetDriveApiKey: + def test_returns_none_when_no_config_and_no_bundled(self, tmp_path): + with patch("src.config._PROJECT_ROOT", tmp_path): + assert get_drive_api_key() is None + + def test_reads_key_from_config_json(self, tmp_path): + (tmp_path / "config.json").write_text( + json.dumps({"google_drive_api_key": "AIzaTestKey123"}), encoding="utf-8" + ) + with patch("src.config._PROJECT_ROOT", tmp_path): + assert get_drive_api_key() == "AIzaTestKey123" + + def test_ignores_placeholder_key(self, tmp_path): + (tmp_path / "config.json").write_text( + json.dumps({"google_drive_api_key": "YOUR_API_KEY_HERE"}), encoding="utf-8" + ) + with patch("src.config._PROJECT_ROOT", tmp_path): + assert get_drive_api_key() is None + + def test_ignores_empty_key(self, tmp_path): + (tmp_path / "config.json").write_text( + json.dumps({"google_drive_api_key": ""}), encoding="utf-8" + ) + with patch("src.config._PROJECT_ROOT", tmp_path): + assert get_drive_api_key() is None + + def test_missing_key_field_in_json(self, tmp_path): + (tmp_path / "config.json").write_text( + json.dumps({"other_field": "value"}), encoding="utf-8" + ) + with patch("src.config._PROJECT_ROOT", tmp_path): + assert get_drive_api_key() is None + + def test_bundled_key_takes_priority_over_config_json(self, tmp_path): + (tmp_path / "config.json").write_text( + json.dumps({"google_drive_api_key": "from_config_json"}), encoding="utf-8" + ) + fake_module = types.ModuleType("src._bundled_key") + fake_module._get_key = lambda: "bundled_value" + with patch.dict(sys.modules, {"src._bundled_key": fake_module}): + with patch("src.config._PROJECT_ROOT", tmp_path): + assert get_drive_api_key() == "bundled_value" + + def test_empty_bundled_key_falls_through_to_config_json(self, tmp_path): + (tmp_path / "config.json").write_text( + json.dumps({"google_drive_api_key": "from_config"}), encoding="utf-8" + ) + fake_module = types.ModuleType("src._bundled_key") + fake_module._get_key = lambda: "" + with patch.dict(sys.modules, {"src._bundled_key": fake_module}): + with patch("src.config._PROJECT_ROOT", tmp_path): + assert get_drive_api_key() == "from_config" + + def test_strips_whitespace_from_config_key(self, tmp_path): + (tmp_path / "config.json").write_text( + json.dumps({"google_drive_api_key": " AIzaSpaced "}), encoding="utf-8" + ) + with patch("src.config._PROJECT_ROOT", tmp_path): + assert get_drive_api_key() == "AIzaSpaced" diff --git a/tests/test_cropper.py b/tests/test_cropper.py index feed78a..7321428 100644 --- a/tests/test_cropper.py +++ b/tests/test_cropper.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from PIL import Image +from PIL import Image, ImageOps from src.cropper import ( _BLEED_X, @@ -12,6 +12,7 @@ BLEED_MM, CARD_H_MM, CARD_W_MM, + _add_mirror_bleed, crop_image, process_for_pdf, ) @@ -140,3 +141,64 @@ def test_crop_image_invalid_raises(tmp_path): out = tmp_path / "out.jpg" with pytest.raises(RuntimeError, match="abrir"): crop_image(bad, out) + + +# ─── _add_mirror_bleed ────────────────────────────────────────────────────── + + +def _gradient_image(w: int, h: int) -> Image.Image: + img = Image.new("RGB", (w, h)) + for y in range(h): + for x in range(w): + img.putpixel((x, y), (x * 13 % 251 + 1, y * 17 % 251 + 1, (x + y) * 7 % 251 + 1)) + return img + + +class TestAddMirrorBleed: + def test_output_size(self): + img = _gradient_image(10, 12) + out = _add_mirror_bleed(img, bx=2, by=3) + assert out.size == (14, 18) + + def test_center_matches_original(self): + img = _gradient_image(10, 12) + bx, by = 2, 3 + out = _add_mirror_bleed(img, bx=bx, by=by) + center = out.crop((bx, by, bx + 10, by + 12)) + assert list(center.getdata()) == list(img.getdata()) + + def test_left_bleed_is_mirror_of_left_strip(self): + img = _gradient_image(10, 12) + bx, by = 2, 3 + out = _add_mirror_bleed(img, bx=bx, by=by) + expected = ImageOps.mirror(img.crop((0, 0, bx, 12))) + actual = out.crop((0, by, bx, by + 12)) + assert list(actual.getdata()) == list(expected.getdata()) + + def test_right_bleed_is_mirror_of_right_strip(self): + img = _gradient_image(10, 12) + bx, by = 2, 3 + w, h = 10, 12 + nw = w + 2 * bx + out = _add_mirror_bleed(img, bx=bx, by=by) + expected = ImageOps.mirror(img.crop((w - bx, 0, w, h))) + actual = out.crop((nw - bx, by, nw, by + h)) + assert list(actual.getdata()) == list(expected.getdata()) + + def test_top_bleed_is_flip_of_top_strip(self): + img = _gradient_image(10, 12) + bx, by = 2, 3 + out = _add_mirror_bleed(img, bx=bx, by=by) + expected = ImageOps.flip(img.crop((0, 0, 10, by))) + actual = out.crop((bx, 0, bx + 10, by)) + assert list(actual.getdata()) == list(expected.getdata()) + + def test_bottom_bleed_is_flip_of_bottom_strip(self): + img = _gradient_image(10, 12) + bx, by = 2, 3 + w, h = 10, 12 + nh = h + 2 * by + out = _add_mirror_bleed(img, bx=bx, by=by) + expected = ImageOps.flip(img.crop((0, h - by, w, h))) + actual = out.crop((bx, nh - by, bx + w, nh)) + assert list(actual.getdata()) == list(expected.getdata()) diff --git a/tests/test_deck_importer.py b/tests/test_deck_importer.py new file mode 100644 index 0000000..c360beb --- /dev/null +++ b/tests/test_deck_importer.py @@ -0,0 +1,436 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from src.deck_importer import ( + DeckCard, + DeckImportError, + FetchedDeck, + _astro_find, + _astro_val, + _manabox_extract_props, + fetch_deck, +) + +_MOXFIELD_FIXTURE = { + "name": "Test Deck", + "boards": { + "mainboard": { + "cards": { + "a1": { + "quantity": 4, + "card": {"name": "Lightning Bolt", "set": "ltr", "cn": "152"}, + }, + "a2": { + "quantity": 1, + "card": {"name": "Forest", "set": "m21", "cn": "295"}, + }, + } + }, + "commanders": {"cards": {}}, + "companions": {"cards": {}}, + "sideboard": { + "cards": { + "b1": { + "quantity": 2, + "card": {"name": "Naturalize", "set": "m21", "cn": "196"}, + } + } + }, + }, +} + +_MOXFIELD_FIXTURE_WITH_COMPANION = { + "name": "Companion Deck", + "boards": { + "mainboard": {"cards": {}}, + "commanders": {"cards": {}}, + "companions": { + "cards": { + "c1": { + "quantity": 1, + "card": {"name": "Lurrus of the Dream-Den", "set": "iko", "cn": "226"}, + } + } + }, + "sideboard": {"cards": {}}, + }, +} + +_ARCHIDEKT_FIXTURE = { + "name": "Archidekt Test", + "cards": [ + { + "quantity": 4, + "categories": ["Mainboard"], + "card": { + "oracleCard": {"name": "Counterspell"}, + "edition": {"editioncode": "ice"}, + "collectorNumber": "57", + }, + }, + { + "quantity": 2, + "categories": ["Sideboard"], + "card": { + "oracleCard": {"name": "Negate"}, + "edition": {"editioncode": "m21"}, + "collectorNumber": "60", + }, + }, + { + "quantity": 1, + "categories": ["Maybeboard"], + "card": { + "oracleCard": {"name": "Skipped"}, + "edition": {"editioncode": "m21"}, + "collectorNumber": "1", + }, + }, + ], +} + + +class TestMoxfieldFetch: + def _mock_resp(self, data): + resp = MagicMock() + resp.json.return_value = data + resp.raise_for_status.return_value = None + return resp + + def test_extracts_mainboard_cards(self): + with patch( + "src.deck_importer.requests.get", return_value=self._mock_resp(_MOXFIELD_FIXTURE) + ): + result = fetch_deck("https://www.moxfield.com/decks/ABC123") + main = [c for c in result.cards if c.zone == "main"] + assert len(main) == 2 + bolt = next(c for c in main if c.name == "Lightning Bolt") + assert bolt.set_code == "ltr" + assert bolt.collector_number == "152" + assert bolt.quantity == 4 + + def test_extracts_sideboard_cards(self): + with patch( + "src.deck_importer.requests.get", return_value=self._mock_resp(_MOXFIELD_FIXTURE) + ): + result = fetch_deck("https://www.moxfield.com/decks/ABC123") + side = [c for c in result.cards if c.zone == "side"] + assert len(side) == 1 + assert side[0].name == "Naturalize" + assert side[0].quantity == 2 + + def test_extracts_deck_name(self): + with patch( + "src.deck_importer.requests.get", return_value=self._mock_resp(_MOXFIELD_FIXTURE) + ): + result = fetch_deck("https://www.moxfield.com/decks/ABC123") + assert result.name == "Test Deck" + + def test_extracts_companion_as_main(self): + with patch( + "src.deck_importer.requests.get", + return_value=self._mock_resp(_MOXFIELD_FIXTURE_WITH_COMPANION), + ): + result = fetch_deck("https://www.moxfield.com/decks/ABC123") + assert len(result.cards) == 1 + assert result.cards[0].name == "Lurrus of the Dream-Den" + assert result.cards[0].zone == "main" + assert result.cards[0].collector_number == "226" + + def test_raises_on_http_error(self): + import requests as req + + with patch("src.deck_importer.requests.get", side_effect=req.RequestException("404")): + with pytest.raises(DeckImportError, match="Moxfield"): + fetch_deck("https://www.moxfield.com/decks/BAD") + + +class TestArchidektFetch: + def _mock_resp(self, data): + resp = MagicMock() + resp.json.return_value = data + resp.raise_for_status.return_value = None + return resp + + def test_extracts_mainboard_cards(self): + with patch( + "src.deck_importer.requests.get", return_value=self._mock_resp(_ARCHIDEKT_FIXTURE) + ): + result = fetch_deck("https://archidekt.com/decks/12345/my-deck") + main = [c for c in result.cards if c.zone == "main"] + assert len(main) == 1 + assert main[0].name == "Counterspell" + assert main[0].set_code == "ice" + assert main[0].collector_number == "57" + assert main[0].quantity == 4 + + def test_extracts_sideboard_cards(self): + with patch( + "src.deck_importer.requests.get", return_value=self._mock_resp(_ARCHIDEKT_FIXTURE) + ): + result = fetch_deck("https://archidekt.com/decks/12345/my-deck") + side = [c for c in result.cards if c.zone == "side"] + assert len(side) == 1 + assert side[0].name == "Negate" + + def test_extracts_deck_name(self): + with patch( + "src.deck_importer.requests.get", return_value=self._mock_resp(_ARCHIDEKT_FIXTURE) + ): + result = fetch_deck("https://archidekt.com/decks/12345/my-deck") + assert result.name == "Archidekt Test" + + def test_skips_maybeboard(self): + with patch( + "src.deck_importer.requests.get", return_value=self._mock_resp(_ARCHIDEKT_FIXTURE) + ): + result = fetch_deck("https://archidekt.com/decks/12345/my-deck") + names = [c.name for c in result.cards] + assert "Skipped" not in names + + +class TestUrlDetection: + def test_invalid_url_raises(self): + with pytest.raises(DeckImportError, match="no reconocida"): + fetch_deck("https://deckstats.net/decks/123") + + def test_moxfield_url_detected(self): + with patch("src.deck_importer._fetch_moxfield", return_value=FetchedDeck("", [])) as mock: + fetch_deck("https://www.moxfield.com/decks/XYZ-abc") + mock.assert_called_once_with("XYZ-abc") + + def test_archidekt_url_detected(self): + with patch("src.deck_importer._fetch_archidekt", return_value=FetchedDeck("", [])) as mock: + fetch_deck("https://archidekt.com/decks/99999/name") + mock.assert_called_once_with("99999") + + def test_deckstats_url_detected(self): + with patch("src.deck_importer._fetch_deckstats", return_value=FetchedDeck("", [])) as mock: + fetch_deck("https://deckstats.net/decks/12345/67890-burn") + mock.assert_called_once_with("12345", "67890") + + def test_tappedout_url_detected(self): + with patch("src.deck_importer._fetch_tappedout", return_value=FetchedDeck("", [])) as mock: + fetch_deck("https://tappedout.net/mtg-decks/my-deck/") + mock.assert_called_once_with("my-deck") + + def test_manabox_url_detected(self): + with patch("src.deck_importer._fetch_manabox", return_value=FetchedDeck("", [])) as mock: + fetch_deck("https://manabox.app/decks/abc123") + mock.assert_called_once_with("abc123") + + +_DECKSTATS_FIXTURE = { + "name": "Deckstats Test", + "sections": [ + { + "name": "Creatures", + "cards": [ + {"amount": 4, "name": "Goblin Guide"}, + {"amount": 2, "name": "Monastery Swiftspear"}, + ], + }, + { + "name": "Spells", + "cards": [ + {"amount": 4, "name": "Lightning Bolt"}, + ], + }, + ], + "sideboard": [ + {"amount": 2, "name": "Eidolon of the Great Revel"}, + ], +} + +_TAPPEDOUT_TEXT = """\ +4 Lightning Bolt +2 Goblin Guide +// sideboard +SB: 2 Pyroclasm +SB: 1 Smash to Smithereens +""" + +_MANABOX_PROPS_JSON = ( + '{"deck":[0,{"name":[0,"Manabox Test"],' + '"cards":[1,[[0,{"name":[0,"Whirlpool Warrior"],"setId":[0,"apc"],' + '"collectorNumber":[0,"36"],"quantity":[0,2],"boardCategory":[0,3]}],' + '[0,{"name":[0,"Negate"],"setId":[0,"m21"],' + '"collectorNumber":[0,"60"],"quantity":[0,1],"boardCategory":[0,1]}]]]}]}' +) +_MANABOX_HTML = ( + "" + '' + "" +) + + +class TestDeckstatsFetch: + def _mock_resp(self, data): + resp = MagicMock() + resp.json.return_value = data + resp.raise_for_status.return_value = None + return resp + + def test_extracts_mainboard_from_sections(self): + with patch( + "src.deck_importer.requests.get", return_value=self._mock_resp(_DECKSTATS_FIXTURE) + ): + result = fetch_deck("https://deckstats.net/decks/12345/67890-burn") + main = [c for c in result.cards if c.zone == "main"] + assert len(main) == 3 + bolt = next(c for c in main if c.name == "Lightning Bolt") + assert bolt.quantity == 4 + assert bolt.set_code == "" + + def test_extracts_sideboard(self): + with patch( + "src.deck_importer.requests.get", return_value=self._mock_resp(_DECKSTATS_FIXTURE) + ): + result = fetch_deck("https://deckstats.net/decks/12345/67890-burn") + side = [c for c in result.cards if c.zone == "side"] + assert len(side) == 1 + assert side[0].name == "Eidolon of the Great Revel" + + def test_extracts_deck_name(self): + with patch( + "src.deck_importer.requests.get", return_value=self._mock_resp(_DECKSTATS_FIXTURE) + ): + result = fetch_deck("https://deckstats.net/decks/12345/67890-burn") + assert result.name == "Deckstats Test" + + def test_raises_on_http_error(self): + import requests as req + + with patch("src.deck_importer.requests.get", side_effect=req.RequestException("timeout")): + with pytest.raises(DeckImportError, match="Deckstats"): + fetch_deck("https://deckstats.net/decks/12345/67890-burn") + + +class TestTappedoutFetch: + def _mock_resp(self, text): + resp = MagicMock() + resp.text = text + resp.raise_for_status.return_value = None + return resp + + def test_extracts_main_cards(self): + with patch("src.deck_importer.requests.get", return_value=self._mock_resp(_TAPPEDOUT_TEXT)): + result = fetch_deck("https://tappedout.net/mtg-decks/burn-deck/") + main = [c for c in result.cards if c.zone == "main"] + assert len(main) == 2 + bolt = next(c for c in main if c.name == "Lightning Bolt") + assert bolt.quantity == 4 + assert bolt.set_code == "" + + def test_extracts_sideboard_lines(self): + with patch("src.deck_importer.requests.get", return_value=self._mock_resp(_TAPPEDOUT_TEXT)): + result = fetch_deck("https://tappedout.net/mtg-decks/burn-deck/") + side = [c for c in result.cards if c.zone == "side"] + assert len(side) == 2 + assert any(c.name == "Pyroclasm" for c in side) + assert any(c.name == "Smash to Smithereens" for c in side) + + def test_deck_name_from_slug(self): + with patch("src.deck_importer.requests.get", return_value=self._mock_resp(_TAPPEDOUT_TEXT)): + result = fetch_deck("https://tappedout.net/mtg-decks/burn-deck/") + assert "Burn" in result.name + + def test_skips_comment_lines(self): + with patch("src.deck_importer.requests.get", return_value=self._mock_resp(_TAPPEDOUT_TEXT)): + result = fetch_deck("https://tappedout.net/mtg-decks/burn-deck/") + assert all(c.name != "sideboard" for c in result.cards) + + def test_raises_on_empty_response(self): + with patch("src.deck_importer.requests.get", return_value=self._mock_resp("")): + with pytest.raises(DeckImportError): + fetch_deck("https://tappedout.net/mtg-decks/empty-deck/") + + +class TestManaboxFetch: + def _mock_resp(self, text): + resp = MagicMock() + resp.text = text + resp.raise_for_status.return_value = None + return resp + + def test_extracts_main_cards(self): + with patch("src.deck_importer.requests.get", return_value=self._mock_resp(_MANABOX_HTML)): + result = fetch_deck("https://manabox.app/decks/abc123") + main = [c for c in result.cards if c.zone == "main"] + assert len(main) == 1 + assert main[0].name == "Whirlpool Warrior" + assert main[0].set_code == "apc" + assert main[0].collector_number == "36" + assert main[0].quantity == 2 + + def test_extracts_sideboard_cards(self): + with patch("src.deck_importer.requests.get", return_value=self._mock_resp(_MANABOX_HTML)): + result = fetch_deck("https://manabox.app/decks/abc123") + side = [c for c in result.cards if c.zone == "side"] + assert len(side) == 1 + assert side[0].name == "Negate" + assert side[0].set_code == "m21" + + def test_extracts_deck_name(self): + with patch("src.deck_importer.requests.get", return_value=self._mock_resp(_MANABOX_HTML)): + result = fetch_deck("https://manabox.app/decks/abc123") + assert result.name == "Manabox Test" + + def test_raises_on_missing_data(self): + with patch( + "src.deck_importer.requests.get", return_value=self._mock_resp("no data") + ): + with pytest.raises(DeckImportError): + fetch_deck("https://manabox.app/decks/abc123") + + +class TestManaboxHelpers: + def test_astro_val_unwraps_type0(self): + assert _astro_val([0, "hello"]) == "hello" + assert _astro_val([0, 42]) == 42 + assert _astro_val("plain") == "plain" + + def test_astro_val_unwraps_type1_array(self): + result = _astro_val([1, [[0, "a"], [0, "b"]]]) + assert result == ["a", "b"] + + def test_astro_val_type1_nested_dicts(self): + result = _astro_val([1, [[0, {"x": [0, 1]}], [0, {"x": [0, 2]}]]]) + assert result == [{"x": [0, 1]}, {"x": [0, 2]}] + + def test_astro_find_nested(self): + data = {"a": [0, {"b": [0, {"cards": [0, [1, 2, 3]]}]}]} + result = _astro_find(data, "cards") + assert result == [1, 2, 3] + + def test_astro_find_not_found(self): + assert _astro_find({"x": [0, 1]}, "missing") is None + + def test_manabox_extract_props_returns_encoded_value(self): + html = '' + result = _manabox_extract_props(html) + assert result == "{"cards":[0,[]]}" + + def test_manabox_extract_props_missing_returns_empty(self): + assert _manabox_extract_props("no data") == "" + + +class TestSideboardFiltering: + def test_main_and_side_zones_returned(self): + with patch( + "src.deck_importer.requests.get", + return_value=MagicMock(json=lambda: _MOXFIELD_FIXTURE, raise_for_status=lambda: None), + ): + result = fetch_deck("https://www.moxfield.com/decks/X") + zones = {c.zone for c in result.cards} + assert "main" in zones + assert "side" in zones + + def test_deck_card_dataclass_fields(self): + card = DeckCard("Test", "m21", "1", 3, "main") + assert card.name == "Test" + assert card.set_code == "m21" + assert card.collector_number == "1" + assert card.quantity == 3 + assert card.zone == "main" diff --git a/tests/test_lorcana_scraper.py b/tests/test_lorcana_scraper.py new file mode 100644 index 0000000..1ce8ca1 --- /dev/null +++ b/tests/test_lorcana_scraper.py @@ -0,0 +1,798 @@ +"""Tests for src/lorcana_scraper.py. + +Unit tests (no network) cover URL routing, data model, expand_deck logic, +and helper functions. Integration tests (marked @pytest.mark.network) hit the +live websites to detect API or format changes. + +Run only unit tests: + pytest tests/test_lorcana_scraper.py -m "not network" + +Run everything including live checks: + pytest tests/test_lorcana_scraper.py +""" + +from __future__ import annotations + +import threading +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from src.cancellation import Cancelled +from src.lorcana_scraper import ( + LocanaDeck, + LorcanaCard, + _parse_inkdecks_api, + _parse_inkdecks_cards, + _parse_inkdecks_html, + _scrape_inkdecks, + download_images, + expand_deck, + get_lorcana_back, + scrape_deck, +) + +# --------------------------------------------------------------------------- +# Reference URLs — kept here so network-test failures pinpoint the site +# --------------------------------------------------------------------------- + +URL_LORCANA_GG = "https://lorcana.gg/decks/robin-hood-copy-eissv/" +URL_INKDECKS = "https://inkdecks.com/lorcana-metagame/deck-sapphire-amethyst-515323" + + +# --------------------------------------------------------------------------- +# Unit tests — URL routing +# --------------------------------------------------------------------------- + + +class TestScrapedeckRouting: + def test_unknown_domain_raises(self): + with pytest.raises(ValueError, match="URL no reconocida"): + scrape_deck("https://example.com/deck/123") + + def test_lorcana_gg_routes(self): + with patch("src.lorcana_scraper._scrape_lorcana_gg") as mock: + mock.return_value = MagicMock(spec=LocanaDeck) + scrape_deck(URL_LORCANA_GG) + mock.assert_called_once_with(URL_LORCANA_GG) + + def test_inkdecks_routes(self): + with patch("src.lorcana_scraper._scrape_inkdecks") as mock: + mock.return_value = MagicMock(spec=LocanaDeck) + scrape_deck(URL_INKDECKS) + mock.assert_called_once_with(URL_INKDECKS) + + def test_error_message_lists_supported_sites(self): + with pytest.raises(ValueError, match="lorcana.gg"): + scrape_deck("https://unknown-site.com/deck/abc") + + +# --------------------------------------------------------------------------- +# Unit tests — LocanaDeck model +# --------------------------------------------------------------------------- + + +class TestLocanaDeckModel: + def _card(self, qty: int, cid: str = "001-001") -> LorcanaCard: + return LorcanaCard( + card_id=cid, + name="Test Card", + quantity=qty, + image_url="https://example.com/img.webp", + ) + + def test_total_slots_empty(self): + deck = LocanaDeck(deck_id="x", name="X") + assert deck.total_slots == 0 + + def test_total_slots_sums_quantities(self): + deck = LocanaDeck( + deck_id="x", + name="X", + cards=[self._card(4, "001-001"), self._card(3, "001-002"), self._card(1, "001-003")], + ) + assert deck.total_slots == 8 + + def test_source_default(self): + deck = LocanaDeck(deck_id="x", name="X") + assert deck.source == "lorcana_gg" + + def test_source_custom(self): + deck = LocanaDeck(deck_id="x", name="X", source="inkdecks") + assert deck.source == "inkdecks" + + +# --------------------------------------------------------------------------- +# Unit tests — expand_deck +# --------------------------------------------------------------------------- + + +class TestExpandDeck: + def _card(self, qty: int, cid: str) -> LorcanaCard: + return LorcanaCard( + card_id=cid, + name="Card", + quantity=qty, + image_url="https://example.com/img.webp", + ) + + def test_expands_quantity(self, tmp_path): + deck = LocanaDeck( + deck_id="x", + name="X", + cards=[self._card(4, "001-001")], + ) + img = tmp_path / "001-001.webp" + img.write_bytes(b"img") + fronts, backs = expand_deck(deck, {"001-001": img}) + assert len(fronts) == 4 + assert all(f == img for f in fronts) + + def test_backs_are_none(self, tmp_path): + """None means use the pipeline default back — Lorcana has a single back.""" + deck = LocanaDeck( + deck_id="x", + name="X", + cards=[self._card(3, "001-001")], + ) + img = tmp_path / "001-001.webp" + img.write_bytes(b"img") + _, backs = expand_deck(deck, {"001-001": img}) + assert all(b is None for b in backs) + assert len(backs) == 3 + + def test_skips_missing_image(self): + deck = LocanaDeck( + deck_id="x", + name="X", + cards=[self._card(2, "MISSING-001")], + ) + fronts, backs = expand_deck(deck, {}) + assert fronts == [] + assert backs == [] + + def test_multiple_cards_expand_in_order(self, tmp_path): + deck = LocanaDeck( + deck_id="x", + name="X", + cards=[self._card(2, "001-001"), self._card(1, "001-002")], + ) + img1 = tmp_path / "001-001.webp" + img1.write_bytes(b"img1") + img2 = tmp_path / "001-002.webp" + img2.write_bytes(b"img2") + fronts, _ = expand_deck(deck, {"001-001": img1, "001-002": img2}) + assert fronts == [img1, img1, img2] + + +# --------------------------------------------------------------------------- +# Unit tests — inkdecks card list parsing +# --------------------------------------------------------------------------- + + +class TestParseInkdecksCards: + def test_dict_format(self): + raw = {"001-001": 4, "001-002": 3} + cards = _parse_inkdecks_cards(raw) + assert len(cards) == 2 + qtys = {c.card_id: c.quantity for c in cards} + assert qtys["001-001"] == 4 + assert qtys["001-002"] == 3 + + def test_list_format_with_card_id_and_quantity(self): + raw = [ + {"card_id": "001-001", "quantity": 2}, + {"card_id": "001-002", "quantity": 1}, + ] + cards = _parse_inkdecks_cards(raw) + assert len(cards) == 2 + assert cards[0].quantity == 2 + + def test_list_format_with_id_and_count(self): + raw = [{"id": "001-005", "count": 3, "name": "Ariel"}] + cards = _parse_inkdecks_cards(raw) + assert cards[0].card_id == "001-005" + assert cards[0].quantity == 3 + assert cards[0].name == "Ariel" + + def test_empty_list(self): + assert _parse_inkdecks_cards([]) == [] + + def test_empty_dict(self): + assert _parse_inkdecks_cards({}) == [] + + +# --------------------------------------------------------------------------- +# Unit tests — download_images cancellation +# --------------------------------------------------------------------------- + + +class TestDownloadImagesCancellation: + def test_raises_cancelled_when_event_set(self, tmp_path): + from src.lorcana_scraper import download_images + + card = LorcanaCard( + card_id="001-001", + name="Ariel", + quantity=1, + image_url="https://example.com/img.webp", + ) + deck = LocanaDeck(deck_id="x", name="X", cards=[card]) + cancel = threading.Event() + cancel.set() + + mock_resp = MagicMock() + mock_resp.content = b"fake" + with patch("src.lorcana_scraper.requests.get", return_value=mock_resp): + with pytest.raises(Cancelled): + download_images(deck, tmp_path, cancel_event=cancel) + + +# --------------------------------------------------------------------------- +# Unit tests — lorcana.gg dotgg scraper (mocked HTTP) +# --------------------------------------------------------------------------- + + +class TestScrapeLorcanaGg: + def _mock_deck_response(self) -> dict: + return { + "humanname": "Robin Hood Deck", + "slug": "robin-hood-copy-eissv", + "deck": { + "001-173": 4, + "001-197": 3, + }, + } + + def _mock_cards_response(self) -> dict: + return { + "names": ["id", "name", "type", "color"], + "data": [ + ["001-173", "Robin Hood", "Character", "amber"], + ["001-197", "Ariel", "Character", "amber"], + ], + } + + def test_parses_deck_name(self): + from src.lorcana_scraper import _scrape_lorcana_gg + + deck_resp = MagicMock() + deck_resp.text = '{"humanname":"Robin Hood Deck","slug":"test","deck":{"001-173":4}}' + deck_resp.json.return_value = self._mock_deck_response() + + cards_resp = MagicMock() + cards_resp.json.return_value = self._mock_cards_response() + + with patch("src.lorcana_scraper.requests.get", side_effect=[deck_resp, cards_resp]): + with patch("src.lorcana_scraper._dotgg_cache", None): + with patch("src.lorcana_scraper._dotgg_name_cache", None): + deck = _scrape_lorcana_gg("https://lorcana.gg/decks/robin-hood-copy-eissv/") + + assert deck.name == "Robin Hood Deck" + + def test_parses_card_quantities(self): + from src.lorcana_scraper import _scrape_lorcana_gg + + deck_resp = MagicMock() + deck_resp.text = '{"humanname":"X","slug":"x","deck":{"001-173":4,"001-197":3}}' + deck_resp.json.return_value = self._mock_deck_response() + + cards_resp = MagicMock() + cards_resp.json.return_value = self._mock_cards_response() + + with patch("src.lorcana_scraper.requests.get", side_effect=[deck_resp, cards_resp]): + with patch("src.lorcana_scraper._dotgg_cache", None): + with patch("src.lorcana_scraper._dotgg_name_cache", None): + deck = _scrape_lorcana_gg("https://lorcana.gg/decks/robin-hood-copy-eissv/") + + total = sum(c.quantity for c in deck.cards) + assert total == 7 + + def test_bad_url_raises(self): + from src.lorcana_scraper import _scrape_lorcana_gg + + with pytest.raises(ValueError, match="slug"): + _scrape_lorcana_gg("https://lorcana.gg/") + + def test_empty_response_raises(self): + from src.lorcana_scraper import _scrape_lorcana_gg + + resp = MagicMock() + resp.text = "" + + with patch("src.lorcana_scraper.requests.get", return_value=resp): + with pytest.raises(ValueError, match="disponible"): + _scrape_lorcana_gg("https://lorcana.gg/decks/nonexistent/") + + def test_image_url_format(self): + from src.lorcana_scraper import _scrape_lorcana_gg + + deck_resp = MagicMock() + deck_resp.text = '{"humanname":"X","slug":"x","deck":{"001-173":1}}' + deck_resp.json.return_value = { + "humanname": "X", + "slug": "x", + "deck": {"001-173": 1}, + } + cards_resp = MagicMock() + cards_resp.json.return_value = self._mock_cards_response() + + with patch("src.lorcana_scraper.requests.get", side_effect=[deck_resp, cards_resp]): + with patch("src.lorcana_scraper._dotgg_cache", None): + with patch("src.lorcana_scraper._dotgg_name_cache", None): + deck = _scrape_lorcana_gg("https://lorcana.gg/decks/test/") + + assert "static.dotgg.gg/lorcana/cards/001-173.webp" in deck.cards[0].image_url + + def test_source_is_lorcana_gg(self): + from src.lorcana_scraper import _scrape_lorcana_gg + + deck_resp = MagicMock() + deck_resp.text = '{"humanname":"X","slug":"x","deck":{"001-173":1}}' + deck_resp.json.return_value = { + "humanname": "X", + "slug": "x", + "deck": {"001-173": 1}, + } + cards_resp = MagicMock() + cards_resp.json.return_value = self._mock_cards_response() + + with patch("src.lorcana_scraper.requests.get", side_effect=[deck_resp, cards_resp]): + with patch("src.lorcana_scraper._dotgg_cache", None): + with patch("src.lorcana_scraper._dotgg_name_cache", None): + deck = _scrape_lorcana_gg("https://lorcana.gg/decks/test/") + + assert deck.source == "lorcana_gg" + + def test_empty_deck_raises(self): + from src.lorcana_scraper import _scrape_lorcana_gg + + deck_resp = MagicMock() + deck_resp.text = '{"humanname":"X","deck":{}}' + deck_resp.json.return_value = {"humanname": "X", "deck": {}} + + with patch("src.lorcana_scraper.requests.get", return_value=deck_resp): + with patch("src.lorcana_scraper._load_dotgg_card_db", return_value=({}, {})): + with pytest.raises(ValueError, match="cartas"): + _scrape_lorcana_gg("https://lorcana.gg/decks/empty-deck/") + + def test_invalid_qty_raises(self): + from src.lorcana_scraper import _scrape_lorcana_gg + + deck_resp = MagicMock() + deck_resp.text = '{"humanname":"X","deck":{"001-001":"4x"}}' + deck_resp.json.return_value = {"humanname": "X", "deck": {"001-001": "4x"}} + + with patch("src.lorcana_scraper.requests.get", return_value=deck_resp): + with patch( + "src.lorcana_scraper._load_dotgg_card_db", + return_value=({"001-001": {"name": "Ariel"}}, {}), + ): + with pytest.raises(ValueError, match="Cantidad"): + _scrape_lorcana_gg("https://lorcana.gg/decks/bad-qty/") + + +# --------------------------------------------------------------------------- +# Unit tests — inkdecks.com scraper (mocked HTTP) +# --------------------------------------------------------------------------- + + +def _inkdecks_html(cards: list[tuple[int, str, str, str]], deck_name: str = "Test Deck") -> str: + """Build a minimal inkdecks.com server-rendered HTML page. + + cards: list of (quantity, img_path, main_name, subtitle) + """ + rows = "" + for qty, img_path, main_name, subtitle in cards: + slug = main_name.lower().replace(" ", "-") + rows += ( + f'' + f'{main_name} - {subtitle}' + ) + return f"

{deck_name}

{rows}
" + + +class TestScrapeInkdecks: + def _api_json(self) -> dict: + return { + "name": "Sapphire Amethyst", + "cards": [ + {"card_id": "001-001", "quantity": 4}, + {"card_id": "001-002", "quantity": 2}, + ], + } + + def test_parses_deck_from_api(self): + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = self._api_json() + + with patch("src.lorcana_scraper.requests.get", return_value=resp): + deck = _scrape_inkdecks( + "https://inkdecks.com/lorcana-metagame/deck-sapphire-amethyst-515323" + ) + + assert deck.name == "Sapphire Amethyst" + assert deck.total_slots == 6 + assert deck.source == "inkdecks" + assert deck.deck_id == "515323" + + def test_falls_back_to_html_when_api_fails(self): + api_resp = MagicMock() + api_resp.status_code = 404 + + html_resp = MagicMock() + html_resp.status_code = 200 + html_resp.text = _inkdecks_html( + [(1, "/img/cards/lorcana/SET/001-003_1468x2048.webp", "Ariel", "On Human Legs")], + deck_name="HTML Deck", + ) + + with patch("src.lorcana_scraper.requests.get", side_effect=[api_resp, html_resp]): + deck = _scrape_inkdecks( + "https://inkdecks.com/lorcana-metagame/deck-sapphire-amethyst-515323" + ) + + assert deck.name == "HTML Deck" + assert deck.total_slots == 1 + + def test_bad_url_raises(self): + with pytest.raises(ValueError, match="ID"): + _scrape_inkdecks("https://inkdecks.com/lorcana-metagame/no-numbers-here") + + def test_falls_back_to_html_when_api_raises_exception(self): + """Network exception during API call should silently fall back to HTML.""" + html_resp = MagicMock() + html_resp.status_code = 200 + html_resp.text = _inkdecks_html( + [ + (1, "/img/cards/lorcana/SET/001-001_1468x2048.webp", "Ariel", "Mermaid"), + (1, "/img/cards/lorcana/SET/001-002_1468x2048.webp", "Elsa", "Queen"), + ], + deck_name="Exception Fallback Deck", + ) + + with patch( + "src.lorcana_scraper.requests.get", + side_effect=[Exception("connection refused"), html_resp], + ): + deck = _scrape_inkdecks("https://inkdecks.com/lorcana-metagame/deck-test-515323") + + assert deck.name == "Exception Fallback Deck" + assert deck.total_slots == 2 + + def test_html_http_error_raises_friendly_error(self): + """When both API and HTML page fail, raise a Spanish ValueError.""" + api_resp = MagicMock() + api_resp.status_code = 404 + + mock_http_error = requests.HTTPError("503 Service Unavailable") + mock_http_error.response = MagicMock() + mock_http_error.response.status_code = 503 + html_resp = MagicMock() + html_resp.raise_for_status.side_effect = mock_http_error + + with patch("src.lorcana_scraper.requests.get", side_effect=[api_resp, html_resp]): + with pytest.raises(ValueError, match="inkdecks"): + _scrape_inkdecks("https://inkdecks.com/lorcana-metagame/deck-test-515323") + + def test_quantity_zero_treated_as_zero(self): + """A card with explicit quantity=0 must not be silently upgraded to 1.""" + raw = [{"card_id": "001-001", "quantity": 0}] + cards = _parse_inkdecks_cards(raw) + assert cards[0].quantity == 0 + + def test_quantity_key_precedence(self): + """'quantity' wins over 'count' when both are present.""" + raw = [{"card_id": "001-001", "quantity": 3, "count": 99}] + cards = _parse_inkdecks_cards(raw) + assert cards[0].quantity == 3 + + def test_missing_quantity_defaults_to_one(self): + raw = [{"card_id": "001-001"}] + cards = _parse_inkdecks_cards(raw) + assert cards[0].quantity == 1 + + +# --------------------------------------------------------------------------- +# Unit tests — _parse_inkdecks_api +# --------------------------------------------------------------------------- + + +class TestParseInkdecksApi: + def test_empty_cards_raises(self): + with pytest.raises(ValueError, match="cartas"): + _parse_inkdecks_api({"name": "X", "cards": []}, "42") + + def test_empty_decklist_raises(self): + with pytest.raises(ValueError, match="cartas"): + _parse_inkdecks_api({"name": "X", "decklist": []}, "42") + + def test_no_cards_key_raises(self): + with pytest.raises(ValueError, match="cartas"): + _parse_inkdecks_api({"name": "X"}, "42") + + +# --------------------------------------------------------------------------- +# Unit tests — get_lorcana_back fallback +# --------------------------------------------------------------------------- + + +class TestGetLorcanaBack: + def test_returns_existing_file(self, tmp_path): + back_dir = tmp_path / "backs" / "lorcana" + back_dir.mkdir(parents=True) + back_file = back_dir / "back.png" + back_file.write_bytes(b"png") + with patch("src.lorcana_scraper._resources_dir", return_value=tmp_path): + result = get_lorcana_back() + assert result == back_file + + def test_generates_fallback_when_missing(self, tmp_path): + import src.lorcana_scraper as mod + + original = mod._fallback_back_path + mod._fallback_back_path = None + try: + with patch("src.lorcana_scraper._resources_dir", return_value=tmp_path): + result = get_lorcana_back() + assert result.exists() + assert result.suffix == ".png" + finally: + mod._fallback_back_path = original + + def test_fallback_returns_same_path_on_repeated_calls(self, tmp_path): + import src.lorcana_scraper as mod + + original = mod._fallback_back_path + mod._fallback_back_path = None + try: + with patch("src.lorcana_scraper._resources_dir", return_value=tmp_path): + first = get_lorcana_back() + second = get_lorcana_back() + # Singleton — both calls return identical Path (no new temp dir created) + assert first == second + finally: + mod._fallback_back_path = original + + +# --------------------------------------------------------------------------- +# Unit tests — _parse_inkdecks_html +# --------------------------------------------------------------------------- + + +class TestParseInkdecksHtml: + def test_parses_name_and_cards(self): + html = _inkdecks_html( + [(3, "/img/cards/lorcana/SET/001-001_1468x2048.webp", "Ariel", "Mermaid")], + deck_name="Test Deck", + ) + deck = _parse_inkdecks_html(html, "99") + assert deck.name == "Test Deck" + assert deck.total_slots == 3 + + def test_missing_cards_raises(self): + with pytest.raises(ValueError, match="inkdecks"): + _parse_inkdecks_html("

Empty

", "42") + + def test_empty_cards_raises(self): + with pytest.raises(ValueError, match="inkdecks"): + _parse_inkdecks_html("

Empty

", "42") + + def test_card_name_parsed_correctly(self): + html = _inkdecks_html( + [(2, "/img/cards/lorcana/SET/001-001_1468x2048.webp", "Fauna", "Good-Natured Fairy")], + ) + deck = _parse_inkdecks_html(html, "1") + assert deck.cards[0].name == "Fauna - Good-Natured Fairy" + + def test_image_url_is_absolute(self): + html = _inkdecks_html( + [(1, "/img/cards/lorcana/WLD/140-204-en-12_1468x2048.webp", "X", "Y")], + ) + deck = _parse_inkdecks_html(html, "1") + assert deck.cards[0].image_url.startswith("https://inkdecks.com") + + +# --------------------------------------------------------------------------- +# Unit tests — download_images success path +# --------------------------------------------------------------------------- + + +class TestDownloadImagesSuccess: + def test_downloads_and_returns_map(self, tmp_path): + card = LorcanaCard( + card_id="001-001", + name="Ariel", + quantity=2, + image_url="https://example.com/001-001.webp", + ) + deck = LocanaDeck(deck_id="x", name="X", cards=[card]) + + mock_resp = MagicMock() + mock_resp.content = b"fake_image_bytes" + + with patch("src.lorcana_scraper.requests.get", return_value=mock_resp): + image_map = download_images(deck, tmp_path) + + assert "001-001" in image_map + written = image_map["001-001"] + assert written.exists() + assert written.read_bytes() == b"fake_image_bytes" + + def test_deduplicates_same_card_id(self, tmp_path): + """Two cards with the same card_id (different qty) should download only once.""" + cards = [ + LorcanaCard("001-001", "Ariel", 4, "https://example.com/001-001.webp"), + LorcanaCard("001-001", "Ariel", 2, "https://example.com/001-001.webp"), + ] + deck = LocanaDeck(deck_id="x", name="X", cards=cards) + + mock_resp = MagicMock() + mock_resp.content = b"img" + + with patch("src.lorcana_scraper.requests.get", return_value=mock_resp) as mock_get: + download_images(deck, tmp_path) + + # Only one HTTP request despite two card entries + assert mock_get.call_count == 1 + + def test_skips_cached_file(self, tmp_path): + card = LorcanaCard("001-001", "Ariel", 1, "https://example.com/001-001.webp") + deck = LocanaDeck(deck_id="x", name="X", cards=[card]) + cached = tmp_path / "001-001.webp" + cached.write_bytes(b"cached") + + with patch("src.lorcana_scraper.requests.get") as mock_get: + image_map = download_images(deck, tmp_path) + + mock_get.assert_not_called() + assert image_map["001-001"] == cached + + def test_calls_progress_callback(self, tmp_path): + cards = [ + LorcanaCard("001-001", "A", 1, "https://example.com/001-001.webp"), + LorcanaCard("001-002", "B", 1, "https://example.com/001-002.webp"), + ] + deck = LocanaDeck(deck_id="x", name="X", cards=cards) + + mock_resp = MagicMock() + mock_resp.content = b"img" + + progress_calls: list[tuple[int, int]] = [] + with patch("src.lorcana_scraper.requests.get", return_value=mock_resp): + download_images(deck, tmp_path, progress_cb=lambda d, t: progress_calls.append((d, t))) + + assert len(progress_calls) == 2 + assert progress_calls[-1] == (2, 2) + + def test_http_error_propagates(self, tmp_path): + card = LorcanaCard("001-001", "Ariel", 1, "https://example.com/001-001.webp") + deck = LocanaDeck(deck_id="x", name="X", cards=[card]) + + mock_resp = MagicMock() + mock_resp.raise_for_status.side_effect = requests.HTTPError("404 Not Found") + + with patch("src.lorcana_scraper.requests.get", return_value=mock_resp): + with pytest.raises(requests.HTTPError): + download_images(deck, tmp_path) + + +# --------------------------------------------------------------------------- +# Unit tests — _scrape_lorcana_gg non-JSON response +# --------------------------------------------------------------------------- + + +class TestScrapeLorcanaGgEdgeCases: + def test_non_json_response_raises_friendly_error(self): + from src.lorcana_scraper import _scrape_lorcana_gg + + resp = MagicMock() + resp.text = "error page" + resp.json.side_effect = ValueError("not JSON") + + with patch("src.lorcana_scraper.requests.get", return_value=resp): + with pytest.raises(ValueError, match="JSON válido"): + _scrape_lorcana_gg("https://lorcana.gg/decks/test-slug/") + + +# --------------------------------------------------------------------------- +# Unit tests — _load_dotgg_card_db malformed response +# --------------------------------------------------------------------------- + + +class TestLoadDotggCardDb: + def _reset_cache(self): + import src.lorcana_scraper as mod + + mod._dotgg_cache = None + mod._dotgg_name_cache = None + + def _restore_cache(self, original, original_name): + import src.lorcana_scraper as mod + + mod._dotgg_cache = original + mod._dotgg_name_cache = original_name + + def test_malformed_response_missing_names_raises(self): + import src.lorcana_scraper as mod + from src.lorcana_scraper import _load_dotgg_card_db + + orig, orig_name = mod._dotgg_cache, mod._dotgg_name_cache + self._reset_cache() + try: + resp = MagicMock() + resp.json.return_value = {"unexpected_key": "no names or data here"} + with patch("src.lorcana_scraper.requests.get", return_value=resp): + with pytest.raises(ValueError, match="formato"): + _load_dotgg_card_db() + finally: + self._restore_cache(orig, orig_name) + + def test_malformed_response_non_json_raises(self): + import src.lorcana_scraper as mod + from src.lorcana_scraper import _load_dotgg_card_db + + orig, orig_name = mod._dotgg_cache, mod._dotgg_name_cache + self._reset_cache() + try: + resp = MagicMock() + resp.json.side_effect = ValueError("not json") + with patch("src.lorcana_scraper.requests.get", return_value=resp): + with pytest.raises(ValueError, match="formato"): + _load_dotgg_card_db() + finally: + self._restore_cache(orig, orig_name) + + def test_valid_response_builds_dicts(self): + import src.lorcana_scraper as mod + from src.lorcana_scraper import _load_dotgg_card_db + + orig, orig_name = mod._dotgg_cache, mod._dotgg_name_cache + self._reset_cache() + try: + resp = MagicMock() + resp.json.return_value = { + "names": ["id", "name"], + "data": [["001-001", "Ariel"], ["001-002", "Elsa"]], + } + with patch("src.lorcana_scraper.requests.get", return_value=resp): + id_to_card, name_to_id = _load_dotgg_card_db() + + assert "001-001" in id_to_card + assert name_to_id["ariel"] == "001-001" + assert name_to_id["elsa"] == "001-002" + finally: + self._restore_cache(orig, orig_name) + + +# --------------------------------------------------------------------------- +# Integration tests — live network calls +# --------------------------------------------------------------------------- + + +@pytest.mark.network +class TestLiveScrapers: + """Smoke-tests against the real websites. + + Detect breaking changes in a site's API or URL format. + Skipped in CI unless the 'network' marker is explicitly included. + """ + + def _assert_valid_deck(self, deck: LocanaDeck, min_cards: int = 1) -> None: + assert isinstance(deck, LocanaDeck) + assert deck.name, "Deck has no name" + assert deck.total_slots >= min_cards, f"Deck has fewer than {min_cards} cards" + assert all(c.image_url for c in deck.cards), "Some cards have no image URL" + + def test_lorcana_gg(self): + deck = scrape_deck(URL_LORCANA_GG) + self._assert_valid_deck(deck, min_cards=10) + assert deck.source == "lorcana_gg" + + def test_inkdecks(self): + deck = scrape_deck(URL_INKDECKS) + self._assert_valid_deck(deck, min_cards=10) + assert deck.source == "inkdecks" diff --git a/tests/test_op_scraper.py b/tests/test_op_scraper.py new file mode 100644 index 0000000..f1b8ac9 --- /dev/null +++ b/tests/test_op_scraper.py @@ -0,0 +1,532 @@ +"""Tests for src/op_scraper.py. + +Unit tests (no network) cover URL routing, URL parsing and image-URL generation. +Integration tests (marked @pytest.mark.network) hit the live websites to detect +API or format changes that would break the scrapers. + +Run only unit tests: + pytest tests/test_op_scraper.py -m "not network" + +Run everything including live checks: + pytest tests/test_op_scraper.py +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from src.op_scraper import ( + OPCard, + OPDeck, + _egman_load_short_code, + _kaizoku_img, + _scrape_dotgg, + _scrape_egman, + _scrape_kaizoku, + download_images, + expand_deck, + get_op_backs, + scrape_deck, +) + +# --------------------------------------------------------------------------- +# Reference deck URLs provided by the user — kept here so failures in network +# tests pinpoint exactly which site broke. +# --------------------------------------------------------------------------- + +URL_ONEPIECE_GG = "https://onepiece.gg/decks/god-ussop/" +URL_EGMANEVENTS = ( + "https://deckbuilder.egmanevents.com/?deck=" + "EB01-001:1,EB01-002:1,EB01-003:2,EB01-004:2,EB01-005:1,EB01-007:1,EB01-008:1" +) +URL_CARDKAIZOKU = ( + "https://deckbuilder.cardkaizoku.com/?deck=" + "2xEB01-004%7C1xEB01-008%7C2xEB01-003%7C1xEB01-002%7C1xEB01-007%7C1xEB01-005%7C1xEB01-001" +) + + +# --------------------------------------------------------------------------- +# Unit tests — URL routing +# --------------------------------------------------------------------------- + + +class TestScrapedeckRouting: + def test_unknown_domain_raises(self): + with pytest.raises(ValueError, match="URL no reconocida"): + scrape_deck("https://example.com/deck/123") + + def test_onepiece_gg_routes_to_dotgg(self): + with patch("src.op_scraper._scrape_dotgg") as mock: + mock.return_value = MagicMock(spec=OPDeck) + scrape_deck(URL_ONEPIECE_GG) + mock.assert_called_once_with(URL_ONEPIECE_GG) + + def test_egmanevents_routes_to_egman(self): + with patch("src.op_scraper._scrape_egman") as mock: + mock.return_value = MagicMock(spec=OPDeck) + scrape_deck(URL_EGMANEVENTS) + mock.assert_called_once_with(URL_EGMANEVENTS) + + def test_cardkaizoku_routes_to_kaizoku(self): + with patch("src.op_scraper._scrape_kaizoku") as mock: + mock.return_value = MagicMock(spec=OPDeck) + scrape_deck(URL_CARDKAIZOKU) + mock.assert_called_once_with(URL_CARDKAIZOKU) + + +# --------------------------------------------------------------------------- +# Unit tests — kaizoku URL parsing +# --------------------------------------------------------------------------- + + +class TestScrapeKaizoku: + def _mock_cards_db(self): + return { + "EB01-001": {"name": "Kouzuki Oden", "category": "Leader", "color": ["Red", "Green"]}, + "EB01-002": {"name": "Izo", "category": "Character", "color": ["Red"]}, + "EB01-004": {"name": "Koza", "category": "Character", "color": ["Red"]}, + } + + def test_parses_pipe_separated_format(self): + with patch("src.op_scraper._egman_cards_db", return_value=self._mock_cards_db()): + deck = _scrape_kaizoku( + "https://deckbuilder.cardkaizoku.com/?deck=1xEB01-001|2xEB01-002|3xEB01-004" + ) + assert deck.source == "kaizoku" + quantities = {c.card_id: c.quantity for c in deck.cards} + assert quantities["EB01-001"] == 1 + assert quantities["EB01-002"] == 2 + assert quantities["EB01-004"] == 3 + + def test_parses_url_encoded_pipes(self): + with patch("src.op_scraper._egman_cards_db", return_value=self._mock_cards_db()): + deck = _scrape_kaizoku( + "https://deckbuilder.cardkaizoku.com/?deck=1xEB01-001%7C2xEB01-002" + ) + assert len(deck.cards) == 2 + + def test_detects_leader(self): + with patch("src.op_scraper._egman_cards_db", return_value=self._mock_cards_db()): + deck = _scrape_kaizoku( + "https://deckbuilder.cardkaizoku.com/?deck=1xEB01-001|2xEB01-002" + ) + assert deck.leader is not None + assert deck.leader.card_id == "EB01-001" + + def test_deck_name_uses_leader(self): + with patch("src.op_scraper._egman_cards_db", return_value=self._mock_cards_db()): + deck = _scrape_kaizoku( + "https://deckbuilder.cardkaizoku.com/?deck=1xEB01-001|2xEB01-002" + ) + assert "Kouzuki Oden" in deck.name + + def test_missing_deck_param_raises(self): + with pytest.raises(ValueError, match="deck="): + _scrape_kaizoku("https://deckbuilder.cardkaizoku.com/") + + def test_empty_deck_raises(self): + with pytest.raises(ValueError): + _scrape_kaizoku("https://deckbuilder.cardkaizoku.com/?deck=") + + +# --------------------------------------------------------------------------- +# Unit tests — image URL generation +# --------------------------------------------------------------------------- + + +class TestImageUrls: + def test_kaizoku_image_url_uses_prefix(self): + assert _kaizoku_img("EB01-004") == "https://cdn.cardkaizoku.com/cards_en/EB01/EB01-004.png" + + def test_kaizoku_image_url_op_prefix(self): + assert _kaizoku_img("OP03-114") == "https://cdn.cardkaizoku.com/cards_en/OP03/OP03-114.png" + + def test_kaizoku_image_url_st_prefix(self): + assert _kaizoku_img("ST01-001") == "https://cdn.cardkaizoku.com/cards_en/ST01/ST01-001.png" + + +# --------------------------------------------------------------------------- +# Unit tests — OPDeck / OPCard model +# --------------------------------------------------------------------------- + + +class TestOPDeckModel: + def _make_deck(self, cards): + return OPDeck(name="Test", slug="test", cards=cards, source="dotgg") + + def test_leader_returns_none_when_no_leader(self): + deck = self._make_deck( + [ + OPCard("OP01-002", "A", 4, False, ["Red"]), + ] + ) + assert deck.leader is None + + def test_leader_returns_leader_card(self): + leader = OPCard("OP01-001", "Leader", 1, True, ["Red"]) + deck = self._make_deck([leader, OPCard("OP01-002", "A", 4, False, ["Red"])]) + assert deck.leader is leader + + def test_total_slots_sums_quantities(self): + deck = self._make_deck( + [ + OPCard("A", "A", 4, False, []), + OPCard("B", "B", 3, False, []), + OPCard("C", "C", 1, True, []), + ] + ) + assert deck.total_slots == 8 + + +# --------------------------------------------------------------------------- +# Unit tests — _scrape_dotgg parsing +# --------------------------------------------------------------------------- + + +class TestScrapeDotgg: + def _deck_resp(self): + return {"humanname": "Test Deck", "deck": {"OP01-001": "1", "OP01-002": "4"}} + + def _cards_resp(self): + return { + "names": ["id", "name", "cardType", "Color"], + "data": [ + ["OP01-001", "Leader Card", "LEADER", "Red"], + ["OP01-002", "Normal Card", "Character", "Red"], + ], + } + + def _two_mocks(self): + r1 = MagicMock() + r1.json.return_value = self._deck_resp() + r1.text = "nonempty" + r2 = MagicMock() + r2.json.return_value = self._cards_resp() + return r1, r2 + + def test_parses_deck_name_and_quantities(self): + r1, r2 = self._two_mocks() + with patch("src.op_scraper.requests.get", side_effect=[r1, r2]): + deck = _scrape_dotgg("https://onepiece.gg/decks/test-deck/") + assert deck.name == "Test Deck" + assert deck.slug == "test-deck" + assert deck.source == "dotgg" + qtys = {c.card_id: c.quantity for c in deck.cards} + assert qtys["OP01-001"] == 1 + assert qtys["OP01-002"] == 4 + + def test_detects_leader(self): + r1, r2 = self._two_mocks() + with patch("src.op_scraper.requests.get", side_effect=[r1, r2]): + deck = _scrape_dotgg("https://onepiece.gg/decks/test-deck/") + assert deck.leader is not None + assert deck.leader.card_id == "OP01-001" + assert deck.leader.is_leader is True + + def test_parses_colors(self): + r1, r2 = self._two_mocks() + with patch("src.op_scraper.requests.get", side_effect=[r1, r2]): + deck = _scrape_dotgg("https://onepiece.gg/decks/test-deck/") + leader = deck.leader + assert "Red" in leader.colors + + def test_bad_url_raises(self): + with pytest.raises(ValueError, match="slug"): + _scrape_dotgg("https://onepiece.gg/") + + +# --------------------------------------------------------------------------- +# Unit tests — _scrape_egman parsing +# --------------------------------------------------------------------------- + + +class TestScrapeEgman: + def _cards_db(self): + return { + "EB01-001": {"name": "Kouzuki Oden", "category": "Leader", "color": ["Red", "Green"]}, + "EB01-002": {"name": "Izo", "category": "Character", "color": ["Red"]}, + } + + def test_parses_query_string_format(self): + with patch("src.op_scraper._egman_cards_db", return_value=self._cards_db()): + deck = _scrape_egman("https://deckbuilder.egmanevents.com/?deck=EB01-001:1,EB01-002:4") + qtys = {c.card_id: c.quantity for c in deck.cards} + assert qtys["EB01-001"] == 1 + assert qtys["EB01-002"] == 4 + + def test_query_string_detects_leader(self): + with patch("src.op_scraper._egman_cards_db", return_value=self._cards_db()): + deck = _scrape_egman("https://deckbuilder.egmanevents.com/?deck=EB01-001:1,EB01-002:4") + assert deck.leader is not None + assert deck.leader.card_id == "EB01-001" + + def test_empty_deck_param_raises(self): + with pytest.raises(ValueError, match="deck"): + _scrape_egman("https://deckbuilder.egmanevents.com/?deck=") + + def test_short_code_format_calls_load_short_code(self): + with patch("src.op_scraper._egman_load_short_code") as mock_load: + mock_load.return_value = ({"EB01-001": 1}, "test-code") + with patch("src.op_scraper._egman_cards_db", return_value=self._cards_db()): + _scrape_egman("https://deckbuilder.egmanevents.com/d/TEST123") + mock_load.assert_called_once_with("TEST123") + + def test_unrecognized_url_raises(self): + with pytest.raises(ValueError, match="no reconocida"): + _scrape_egman("https://deckbuilder.egmanevents.com/other/path") + + def test_load_short_code_posts_to_supabase(self): + mock_r = MagicMock() + mock_r.json.return_value = [{"deck_data": {"EB01-001": 2}, "short_code": "abc"}] + with patch("src.op_scraper.requests.post", return_value=mock_r) as mock_post: + deck_map, slug = _egman_load_short_code("abc") + mock_post.assert_called_once() + call_url = mock_post.call_args[0][0] + assert "get_deck_by_short_code" in call_url + assert mock_post.call_args[1]["json"] == {"p_code": "abc"} + assert deck_map == {"EB01-001": 2} + assert slug == "abc" + + def test_load_short_code_not_found_raises(self): + mock_r = MagicMock() + mock_r.json.return_value = [] + with patch("src.op_scraper.requests.post", return_value=mock_r): + with pytest.raises(ValueError, match="código"): + _egman_load_short_code("missing") + + def test_load_short_code_list_format(self): + mock_r = MagicMock() + mock_r.json.return_value = [ + { + "deck_data": [ + {"card_code": "EB01-001", "count": 3}, + {"card_code": "EB01-002", "count": 1}, + ], + "short_code": "xyz", + } + ] + with patch("src.op_scraper.requests.post", return_value=mock_r): + deck_map, slug = _egman_load_short_code("xyz") + assert deck_map == {"EB01-001": 3, "EB01-002": 1} + assert slug == "xyz" + + +# --------------------------------------------------------------------------- +# Unit tests — download_images 404 fallback +# --------------------------------------------------------------------------- + + +class TestDownloadImagesFallback: + def _single_card_deck(self, source="dotgg"): + return OPDeck( + name="Test", + slug="test", + cards=[OPCard("OP01-001", "Card", 1, False, [])], + source=source, + ) + + def test_uses_primary_url_on_success(self, tmp_path): + deck = self._single_card_deck() + mock_r = MagicMock() + mock_r.content = b"img_data" + with patch("src.op_scraper.requests.get", return_value=mock_r): + result = download_images(deck, tmp_path) + assert "OP01-001" in result + assert result["OP01-001"].read_bytes() == b"img_data" + + def test_falls_back_to_kaizoku_on_404(self, tmp_path): + from requests.exceptions import HTTPError + + deck = self._single_card_deck(source="dotgg") + + err_resp = MagicMock() + err_resp.status_code = 404 + primary_err = HTTPError(response=err_resp) + + primary_mock = MagicMock() + primary_mock.raise_for_status.side_effect = primary_err + + fallback_mock = MagicMock() + fallback_mock.content = b"fallback_bytes" + + with patch("src.op_scraper.requests.get", side_effect=[primary_mock, fallback_mock]): + result = download_images(deck, tmp_path) + + assert "OP01-001" in result + assert result["OP01-001"].read_bytes() == b"fallback_bytes" + assert result["OP01-001"].suffix == ".png" + + def test_non_404_error_propagates(self, tmp_path): + from requests.exceptions import HTTPError + + deck = self._single_card_deck() + + err_resp = MagicMock() + err_resp.status_code = 500 + primary_err = HTTPError(response=err_resp) + + primary_mock = MagicMock() + primary_mock.raise_for_status.side_effect = primary_err + + with patch("src.op_scraper.requests.get", return_value=primary_mock): + with pytest.raises(HTTPError): + download_images(deck, tmp_path) + + +# --------------------------------------------------------------------------- +# Integration tests — live network calls +# --------------------------------------------------------------------------- + + +@pytest.mark.network +class TestLiveScrapers: + """Smoke-tests against the real websites. + + These tests detect breaking changes in a site's API or URL format. + They are skipped in CI unless the 'network' marker is explicitly included. + Each test asserts the minimum contract: a valid deck with a leader and cards. + """ + + def _assert_valid_deck(self, deck: OPDeck, expected_source: str) -> None: + assert isinstance(deck, OPDeck) + assert deck.source == expected_source + assert deck.total_slots > 0, "Deck has no cards" + assert deck.leader is not None, "No leader card found" + assert len(deck.leader.colors) > 0, "Leader has no colors" + + def test_onepiece_gg_god_ussop(self): + deck = scrape_deck(URL_ONEPIECE_GG) + self._assert_valid_deck(deck, "dotgg") + + def test_egmanevents_eb01_deck(self): + deck = scrape_deck(URL_EGMANEVENTS) + self._assert_valid_deck(deck, "egman") + leader = deck.leader + assert leader.card_id == "EB01-001" + assert leader.name == "Kouzuki Oden" + + def test_cardkaizoku_eb01_deck(self): + deck = scrape_deck(URL_CARDKAIZOKU) + self._assert_valid_deck(deck, "kaizoku") + leader = deck.leader + assert leader.card_id == "EB01-001" + assert leader.name == "Kouzuki Oden" + assert deck.total_slots == 9 + + +# --------------------------------------------------------------------------- +# Unit tests — expand_deck +# --------------------------------------------------------------------------- + + +class TestExpandDeck: + def _make_deck(self, cards: list[OPCard]) -> OPDeck: + return OPDeck(name="Test", slug="test", cards=cards, source="dotgg") + + def test_expands_by_quantity(self, tmp_path): + img = tmp_path / "card.jpg" + img.touch() + standard = tmp_path / "std.jpg" + standard.touch() + deck = self._make_deck([OPCard("OP01-001", "Card", 3, False, [])]) + fronts, backs = expand_deck(deck, {"OP01-001": img}, None, standard) + assert len(fronts) == 3 + assert len(backs) == 3 + + def test_leader_gets_leader_back(self, tmp_path): + front = tmp_path / "front.jpg" + front.touch() + leader_back = tmp_path / "leader.jpg" + leader_back.touch() + standard = tmp_path / "std.jpg" + standard.touch() + deck = self._make_deck([OPCard("OP01-001", "Leader", 1, True, [])]) + _, backs = expand_deck(deck, {"OP01-001": front}, leader_back, standard) + assert backs[0] == leader_back + + def test_non_leader_gets_none_back(self, tmp_path): + front = tmp_path / "front.jpg" + front.touch() + leader_back = tmp_path / "leader.jpg" + leader_back.touch() + standard = tmp_path / "std.jpg" + standard.touch() + deck = self._make_deck([OPCard("OP01-002", "Normal", 2, False, [])]) + _, backs = expand_deck(deck, {"OP01-002": front}, leader_back, standard) + assert all(b is None for b in backs) + + def test_leader_back_none_returns_none_for_leader(self, tmp_path): + front = tmp_path / "front.jpg" + front.touch() + standard = tmp_path / "std.jpg" + standard.touch() + deck = self._make_deck([OPCard("OP01-001", "Leader", 1, True, [])]) + _, backs = expand_deck(deck, {"OP01-001": front}, None, standard) + assert backs[0] is None + + def test_card_missing_from_image_map_skipped(self, tmp_path): + standard = tmp_path / "std.jpg" + standard.touch() + deck = self._make_deck([OPCard("OP01-999", "Missing", 3, False, [])]) + fronts, backs = expand_deck(deck, {}, None, standard) + assert fronts == [] + assert backs == [] + + def test_mixed_leader_and_non_leader(self, tmp_path): + f1 = tmp_path / "f1.jpg" + f1.touch() + f2 = tmp_path / "f2.jpg" + f2.touch() + leader_back = tmp_path / "leader.jpg" + leader_back.touch() + standard = tmp_path / "std.jpg" + standard.touch() + cards = [ + OPCard("OP01-001", "Leader", 1, True, []), + OPCard("OP01-002", "Normal", 2, False, []), + ] + deck = self._make_deck(cards) + image_map = {"OP01-001": f1, "OP01-002": f2} + fronts, backs = expand_deck(deck, image_map, leader_back, standard) + assert len(fronts) == 3 + assert backs[0] == leader_back + assert backs[1] is None + assert backs[2] is None + + +# --------------------------------------------------------------------------- +# Unit tests — get_op_backs +# --------------------------------------------------------------------------- + + +class TestGetOpBacks: + def test_returns_real_files_when_both_present(self, tmp_path): + op_dir = tmp_path / "backs" / "op" + op_dir.mkdir(parents=True) + default = op_dir / "default.png" + leader = op_dir / "lider.png" + default.write_bytes(b"fake") + leader.write_bytes(b"fake") + with patch("src.op_scraper._resources_dir", return_value=tmp_path): + d, lider = get_op_backs() + assert d == default + assert lider == leader + + def test_falls_back_to_generated_when_missing(self, tmp_path): + with patch("src.op_scraper._resources_dir", return_value=tmp_path): + d, lider = get_op_backs() + assert d.exists() + assert lider.exists() + assert d.suffix == ".png" + assert lider.suffix == ".png" + + def test_falls_back_when_only_default_missing(self, tmp_path): + op_dir = tmp_path / "backs" / "op" + op_dir.mkdir(parents=True) + (op_dir / "lider.png").write_bytes(b"fake") + with patch("src.op_scraper._resources_dir", return_value=tmp_path): + d, lider = get_op_backs() + assert d.exists() + assert lider.exists() diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 8864aa0..7b07512 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -11,10 +11,22 @@ import pytest from src.cancellation import Cancelled +from src.deck_importer import DeckCard, FetchedDeck from src.downloader import DownloadPermissionError -from src.pipeline import _local_synthetic_id, run_locals_only, run_plan +from src.parser import parse +from src.pipeline import ( + _build_crop_tasks, + _build_slot_maps, + _local_synthetic_id, + run, + run_deck_url, + run_locals_only, + run_merged, + run_plan, +) from src.precheck import analyze from src.precheck import plan as make_plan +from src.scryfall import ScryfallError from tests.conftest import make_rgb_image, make_xml # ─── helpers ──────────────────────────────────────────────────────────────── @@ -320,3 +332,373 @@ def test_local_synthetic_id_starts_with_local(tmp_path): p = tmp_path / "image.jpg" p.write_bytes(b"x") assert _local_synthetic_id(p).startswith("local_") + + +# ─── _build_slot_maps ──────────────────────────────────────────────────────── + + +class TestBuildSlotMaps: + def test_single_xml_assigns_slots_from_zero(self, tmp_path): + xml = make_xml( + tmp_path / "deck.xml", + fronts=[ + {"id": "F001", "name": "Card1", "slots": "0"}, + {"id": "F002", "name": "Card2", "slots": "1"}, + ], + cardback_id="CB001", + quantity=2, + ) + order = parse(xml) + front_map, back_map, id_name, _, _, next_slot = _build_slot_maps([xml], [order], 0) + assert next_slot == 2 + assert front_map[0] == "F001" + assert front_map[1] == "F002" + assert back_map[0] == "CB001" + assert back_map[1] == "CB001" + + def test_two_xmls_get_consecutive_slots(self, tmp_path): + xml1 = make_xml( + tmp_path / "a.xml", + fronts=[{"id": "F001", "name": "A", "slots": "0"}], + cardback_id="CB001", + quantity=1, + ) + xml2 = make_xml( + tmp_path / "b.xml", + fronts=[{"id": "F002", "name": "B", "slots": "0"}], + cardback_id="CB002", + quantity=1, + ) + front_map, back_map, _, _, _, next_slot = _build_slot_maps( + [xml1, xml2], [parse(xml1), parse(xml2)], 0 + ) + assert next_slot == 2 + assert front_map[0] == "F001" + assert front_map[1] == "F002" + assert back_map[0] == "CB001" + assert back_map[1] == "CB002" + + def test_respects_starting_next_slot(self, tmp_path): + xml = make_xml( + tmp_path / "deck.xml", + fronts=[{"id": "F001", "name": "A", "slots": "0"}], + cardback_id="CB001", + quantity=1, + ) + front_map, _, _, _, _, next_slot = _build_slot_maps([xml], [parse(xml)], 5) + assert 5 in front_map + assert front_map[5] == "F001" + assert next_slot == 6 + + def test_id_name_map_includes_all_ids(self, tmp_path): + xml = make_xml( + tmp_path / "deck.xml", + fronts=[{"id": "F001", "name": "TestCard", "slots": "0"}], + cardback_id="CB001", + quantity=1, + ) + _, _, id_name, _, _, _ = _build_slot_maps([xml], [parse(xml)], 0) + assert "F001" in id_name + assert "CB001" in id_name + + def test_empty_xml_list_returns_empty_maps(self): + front_map, back_map, _, _, _, next_slot = _build_slot_maps([], [], 0) + assert front_map == {} + assert back_map == {} + assert next_slot == 0 + + +# ─── run / run_merged ──────────────────────────────────────────────────────── + + +class TestRun: + def test_produces_pdf_for_single_xml(self, tmp_path): + xml = _one_card_xml(tmp_path) + with patch("src.pipeline.download_all", side_effect=_fake_download_all(tmp_path / "raw")): + results = run(xml, tmp_path / "out", tmp_path / "work") + assert len(results) == 1 + assert results[0].exists() + assert results[0].suffix == ".pdf" + + def test_output_named_after_xml_stem(self, tmp_path): + xml = make_xml( + tmp_path / "my_cards.xml", + fronts=[{"id": "F1", "name": "C", "slots": "0"}], + cardback_id="CB", + quantity=1, + ) + with patch("src.pipeline.download_all", side_effect=_fake_download_all(tmp_path / "raw")): + results = run(xml, tmp_path / "out", tmp_path / "work") + assert "my_cards" in results[0].stem + + +class TestRunMerged: + def test_merged_combines_slots_from_two_xmls(self, tmp_path): + xml1 = make_xml( + tmp_path / "deck1.xml", + fronts=[{"id": f"F1_{i}", "name": f"A{i}", "slots": str(i)} for i in range(9)], + cardback_id="CB1", + ) + xml2 = make_xml( + tmp_path / "deck2.xml", + fronts=[{"id": f"F2_{i}", "name": f"B{i}", "slots": str(i)} for i in range(9)], + cardback_id="CB2", + ) + with patch("src.pipeline.download_all", side_effect=_fake_download_all(tmp_path / "raw")): + results = run_merged( + [xml1, xml2], + tmp_path / "out", + "merged", + tmp_path / "work", + ) + assert len(results) == 1 + assert results[0].exists() + assert "merged" in results[0].stem + + def test_merged_pdf_is_larger_than_single(self, tmp_path): + xml1 = make_xml( + tmp_path / "a.xml", + fronts=[{"id": "F1", "name": "A", "slots": "0"}], + cardback_id="CB1", + quantity=1, + ) + xml2 = make_xml( + tmp_path / "b.xml", + fronts=[{"id": "F2", "name": "B", "slots": "0"}], + cardback_id="CB2", + quantity=1, + ) + with patch("src.pipeline.download_all", side_effect=_fake_download_all(tmp_path / "raw")): + single = run(xml1, tmp_path / "out_s", tmp_path / "work_s") + merged = run_merged([xml1, xml2], tmp_path / "out_m", "merged", tmp_path / "work_m") + assert merged[0].stat().st_size > single[0].stat().st_size + + +# ─── _build_crop_tasks ─────────────────────────────────────────────────────── + + +class TestBuildCropTasks: + def test_remote_id_crop_true(self, tmp_path): + raw = tmp_path / "ABC123.jpg" + raw.touch() + tasks = _build_crop_tasks({"ABC123": raw}, tmp_path / "bled", {}, {}) + assert len(tasks) == 1 + did, _, bled_p, crop = tasks[0] + assert did == "ABC123" + assert crop is True + assert bled_p.name == "ABC123.jpg" + + def test_local_id_with_crop_true(self, tmp_path): + local = tmp_path / "local.jpg" + local.touch() + lid = "local_abc" + tasks = _build_crop_tasks({lid: local}, tmp_path / "bled", {lid: local}, {local: True}) + _, _, bled_p, crop = tasks[0] + assert crop is True + assert "_nocrop" not in bled_p.name + + def test_local_id_with_crop_false(self, tmp_path): + local = tmp_path / "local.jpg" + local.touch() + lid = "local_abc" + tasks = _build_crop_tasks({lid: local}, tmp_path / "bled", {lid: local}, {local: False}) + _, _, bled_p, crop = tasks[0] + assert crop is False + assert "_nocrop" in bled_p.name + + def test_local_id_missing_from_crop_map_defaults_to_no_crop(self, tmp_path): + local = tmp_path / "local.jpg" + local.touch() + lid = "local_abc" + tasks = _build_crop_tasks({lid: local}, tmp_path / "bled", {lid: local}, {}) + _, _, bled_p, crop = tasks[0] + assert crop is False + assert "_nocrop" in bled_p.name + + def test_empty_input_returns_empty(self): + tasks = _build_crop_tasks({}, Path("bled"), {}, {}) + assert tasks == [] + + def test_bled_path_placed_in_bled_dir(self, tmp_path): + raw = tmp_path / "X.jpg" + raw.touch() + bled_dir = tmp_path / "bled" + tasks = _build_crop_tasks({"X": raw}, bled_dir, {}, {}) + _, _, bled_p, _ = tasks[0] + assert bled_p.parent == bled_dir + + +# ─── run_deck_url ──────────────────────────────────────────────────────────── + + +class TestRunDeckUrl: + def _make_resources(self, tmp_path: Path) -> Path: + res = tmp_path / "resources" + mtg_back = res / "backs" / "mtg" / "back.jpg" + mtg_back.parent.mkdir(parents=True, exist_ok=True) + make_rgb_image(mtg_back) + return res + + def _simple_deck(self) -> FetchedDeck: + return FetchedDeck( + name="Test Deck", + cards=[DeckCard("Lightning Bolt", "lea", "1", 2, "main")], + ) + + def test_produces_pdf(self, tmp_path): + res = self._make_resources(tmp_path) + front = make_rgb_image(tmp_path / "front.jpg") + deck = self._simple_deck() + dl_result = [(deck.cards[0], front, None)] + + with ( + patch("src.pipeline.fetch_deck", return_value=deck), + patch("src.pipeline.download_deck_images", return_value=dl_result), + patch("src.pipeline.resources_dir", return_value=res), + ): + results = run_deck_url( + "https://moxfield.com/decks/abc", + tmp_path / "out", + tmp_path / "work", + "test_deck", + ) + assert len(results) == 1 + assert results[0].exists() + assert results[0].stat().st_size > 0 + + def test_sideboard_excluded_by_default(self, tmp_path): + res = self._make_resources(tmp_path) + front = make_rgb_image(tmp_path / "front.jpg") + deck = FetchedDeck( + name="Test", + cards=[ + DeckCard("Main Card", "lea", "1", 1, "main"), + DeckCard("Side Card", "lea", "2", 2, "side"), + ], + ) + dl_result = [(deck.cards[0], front, None)] + + with ( + patch("src.pipeline.fetch_deck", return_value=deck), + patch("src.pipeline.download_deck_images", return_value=dl_result) as mock_dl, + patch("src.pipeline.resources_dir", return_value=res), + ): + run_deck_url("https://moxfield.com/decks/abc", tmp_path / "out", tmp_path / "work", "t") + passed = mock_dl.call_args[0][0] + assert all(c.zone == "main" for c in passed) + + def test_sideboard_included_when_flag_set(self, tmp_path): + res = self._make_resources(tmp_path) + front1 = make_rgb_image(tmp_path / "f1.jpg") + front2 = make_rgb_image(tmp_path / "f2.jpg") + deck = FetchedDeck( + name="Test", + cards=[ + DeckCard("Main Card", "lea", "1", 1, "main"), + DeckCard("Side Card", "lea", "2", 1, "side"), + ], + ) + dl_result = [(deck.cards[0], front1, None), (deck.cards[1], front2, None)] + + with ( + patch("src.pipeline.fetch_deck", return_value=deck), + patch("src.pipeline.download_deck_images", return_value=dl_result) as mock_dl, + patch("src.pipeline.resources_dir", return_value=res), + ): + run_deck_url( + "https://moxfield.com/decks/abc", + tmp_path / "out", + tmp_path / "work", + "t", + include_sideboard=True, + ) + passed = mock_dl.call_args[0][0] + assert len(passed) == 2 + + def test_empty_deck_after_filter_raises(self, tmp_path): + res = self._make_resources(tmp_path) + deck = FetchedDeck( + name="Test", + cards=[DeckCard("Side Card", "lea", "2", 1, "side")], + ) + with ( + patch("src.pipeline.fetch_deck", return_value=deck), + patch("src.pipeline.resources_dir", return_value=res), + ): + with pytest.raises(ValueError, match="cartas"): + run_deck_url( + "https://moxfield.com/decks/abc", tmp_path / "out", tmp_path / "work", "t" + ) + + def test_scryfall_error_converts_to_value_error(self, tmp_path): + res = self._make_resources(tmp_path) + deck = self._simple_deck() + with ( + patch("src.pipeline.fetch_deck", return_value=deck), + patch("src.pipeline.download_deck_images", side_effect=ScryfallError("rate limit")), + patch("src.pipeline.resources_dir", return_value=res), + ): + with pytest.raises(ValueError, match="Scryfall"): + run_deck_url( + "https://moxfield.com/decks/abc", tmp_path / "out", tmp_path / "work", "t" + ) + + def test_cancellation_after_download_raises(self, tmp_path): + res = self._make_resources(tmp_path) + front = make_rgb_image(tmp_path / "front.jpg") + deck = self._simple_deck() + cancel = threading.Event() + cancel.set() + with ( + patch("src.pipeline.fetch_deck", return_value=deck), + patch("src.pipeline.download_deck_images", return_value=[(deck.cards[0], front, None)]), + patch("src.pipeline.resources_dir", return_value=res), + ): + with pytest.raises(Cancelled): + run_deck_url( + "https://moxfield.com/decks/abc", + tmp_path / "out", + tmp_path / "work", + "t", + cancel_event=cancel, + ) + + def test_mdfc_back_path_used(self, tmp_path): + res = self._make_resources(tmp_path) + front = make_rgb_image(tmp_path / "front.jpg") + back_face = make_rgb_image(tmp_path / "back_face.jpg", color=(80, 180, 80)) + deck = FetchedDeck( + name="Test", + cards=[DeckCard("Delver of Secrets", "isd", "51", 1, "main")], + ) + dl_result = [(deck.cards[0], front, back_face)] + with ( + patch("src.pipeline.fetch_deck", return_value=deck), + patch("src.pipeline.download_deck_images", return_value=dl_result), + patch("src.pipeline.resources_dir", return_value=res), + ): + results = run_deck_url( + "https://moxfield.com/decks/abc", tmp_path / "out", tmp_path / "work", "t" + ) + assert results[0].exists() + + def test_progress_callback_fires_download_zero_first(self, tmp_path): + res = self._make_resources(tmp_path) + front = make_rgb_image(tmp_path / "front.jpg") + deck = self._simple_deck() + events = [] + with ( + patch("src.pipeline.fetch_deck", return_value=deck), + patch("src.pipeline.download_deck_images", return_value=[(deck.cards[0], front, None)]), + patch("src.pipeline.resources_dir", return_value=res), + ): + run_deck_url( + "https://moxfield.com/decks/abc", + tmp_path / "out", + tmp_path / "work", + "t", + progress_callback=lambda s, d, t: events.append((s, d, t)), + ) + dl_events = [(s, d, t) for s, d, t in events if s == "download"] + assert dl_events, "No download events fired" + assert dl_events[0][1] == 0 diff --git a/tests/test_rb_scraper.py b/tests/test_rb_scraper.py new file mode 100644 index 0000000..37dfb89 --- /dev/null +++ b/tests/test_rb_scraper.py @@ -0,0 +1,845 @@ +"""Tests for src/rb_scraper.py. + +Unit tests (no network) cover URL routing, section mapping, expand_deck logic, +and helper functions. Integration tests (marked @pytest.mark.network) hit the +live websites to detect API or format changes. + +Run only unit tests: + pytest tests/test_rb_scraper.py -m "not network" + +Run everything including live checks: + pytest tests/test_rb_scraper.py +""" + +from __future__ import annotations + +import threading +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from src.cancellation import Cancelled +from src.rb_scraper import ( + SECTION_ORDER, + RBCard, + RBDeck, + _fetch_deck, + _fs_arr, + _fs_str, + _resolve_image, + _scrape_riftbinder, + _scrape_riftbound_gg, + _scrape_riftdex, + _scrape_riftmana, + _trpc_get, + _type_to_section, + download_images, + expand_deck, + get_rb_backs, + scrape_deck, +) + +# --------------------------------------------------------------------------- +# Reference deck URLs — kept here so network-test failures pinpoint the site +# --------------------------------------------------------------------------- + +URL_PILTOVER = "https://piltoverarchive.com/decks/view/00000000-0000-0000-0000-000000000001" +URL_RIFTMANA = "https://riftmana.com/deck/some-deck" +URL_RIFTBINDER = "https://riftbinder.com/decks/abc123" +URL_RIFTDEX = "https://riftdex.com/deck/00000000-0000-0000-0000-000000000001" +URL_RIFTBOUNDGG = "https://riftbound.gg/decks/some-slug/" + + +# --------------------------------------------------------------------------- +# Unit tests — URL routing +# --------------------------------------------------------------------------- + + +class TestScrapedeckRouting: + def test_unknown_domain_raises(self): + with pytest.raises(ValueError, match="URL no reconocida"): + scrape_deck("https://example.com/deck/123") + + def test_piltoverarchive_routes_to_fetch_deck(self): + with patch("src.rb_scraper._fetch_deck") as mock: + mock.return_value = MagicMock(spec=RBDeck) + scrape_deck( + "https://piltoverarchive.com/decks/view/00000000-0000-0000-0000-000000000001" + ) + mock.assert_called_once_with("00000000-0000-0000-0000-000000000001") + + def test_piltoverarchive_bad_url_raises(self): + with pytest.raises(ValueError, match="UUID"): + scrape_deck("https://piltoverarchive.com/decks/") + + def test_riftmana_routes(self): + with patch("src.rb_scraper._scrape_riftmana") as mock: + mock.return_value = MagicMock(spec=RBDeck) + scrape_deck(URL_RIFTMANA) + mock.assert_called_once_with(URL_RIFTMANA) + + def test_riftbinder_routes(self): + with patch("src.rb_scraper._scrape_riftbinder") as mock: + mock.return_value = MagicMock(spec=RBDeck) + scrape_deck(URL_RIFTBINDER) + mock.assert_called_once_with(URL_RIFTBINDER) + + def test_riftdex_routes(self): + with patch("src.rb_scraper._scrape_riftdex") as mock: + mock.return_value = MagicMock(spec=RBDeck) + scrape_deck(URL_RIFTDEX) + mock.assert_called_once_with(URL_RIFTDEX) + + def test_riftbound_gg_routes(self): + with patch("src.rb_scraper._scrape_riftbound_gg") as mock: + mock.return_value = MagicMock(spec=RBDeck) + scrape_deck(URL_RIFTBOUNDGG) + mock.assert_called_once_with(URL_RIFTBOUNDGG) + + +# --------------------------------------------------------------------------- +# Unit tests — _type_to_section +# --------------------------------------------------------------------------- + + +class TestTypeToSection: + def test_legend(self): + assert _type_to_section("Legend") == "legend" + + def test_battlefield(self): + assert _type_to_section("Battlefield") == "battlefield" + + def test_rune(self): + assert _type_to_section("Rune") == "rune" + + def test_champion_by_type(self): + assert _type_to_section("Champion") == "champion" + + def test_champion_by_super(self): + assert _type_to_section("Unit", "Champion") == "champion" + + def test_unit_is_maindeck(self): + assert _type_to_section("Unit") == "maindeck" + + def test_spell_is_maindeck(self): + assert _type_to_section("Spell") == "maindeck" + + def test_empty_is_maindeck(self): + assert _type_to_section("") == "maindeck" + + def test_case_insensitive(self): + assert _type_to_section("LEGEND") == "legend" + assert _type_to_section("battlefield") == "battlefield" + + +# --------------------------------------------------------------------------- +# Unit tests — Firestore helpers (_fs_str, _fs_arr) +# --------------------------------------------------------------------------- + + +class TestFirestoreHelpers: + def test_fs_str_string_value(self): + assert _fs_str({"stringValue": "hello"}) == "hello" + + def test_fs_str_integer_value(self): + assert _fs_str({"integerValue": "42"}) == "42" + + def test_fs_str_empty_dict(self): + assert _fs_str({}) == "" + + def test_fs_arr_returns_values(self): + field = {"arrayValue": {"values": [{"stringValue": "a"}, {"stringValue": "b"}]}} + assert _fs_arr(field) == [{"stringValue": "a"}, {"stringValue": "b"}] + + def test_fs_arr_missing_returns_empty(self): + assert _fs_arr({}) == [] + + def test_fs_arr_empty_array(self): + assert _fs_arr({"arrayValue": {}}) == [] + + +# --------------------------------------------------------------------------- +# Unit tests — _scrape_riftbinder (Firestore mock) +# --------------------------------------------------------------------------- + + +class TestScrapeRiftbinder: + def _firestore_response(self) -> dict: + return { + "fields": { + "name": {"stringValue": "Test Deck"}, + "legendId": {"stringValue": "RB-LEGEND-001"}, + "battlefields": {"arrayValue": {"values": [{"stringValue": "RB-BF-001"}]}}, + "runes": { + "arrayValue": { + "values": [ + {"mapValue": {"fields": {"runeId": {"stringValue": "RB-RUNE-001"}}}} + ] + } + }, + "mainDeck": { + "arrayValue": { + "values": [ + { + "mapValue": { + "fields": { + "cardId": {"stringValue": "RB-UNIT-001"}, + "quantity": {"integerValue": "3"}, + } + } + } + ] + } + }, + "sideboard": { + "arrayValue": { + "values": [ + { + "mapValue": { + "fields": { + "cardId": {"stringValue": "RB-UNIT-002"}, + "quantity": {"integerValue": "1"}, + } + } + } + ] + } + }, + } + } + + def test_parses_name(self): + mock_resp = MagicMock() + mock_resp.json.return_value = self._firestore_response() + with patch("src.rb_scraper.requests.get", return_value=mock_resp): + deck = _scrape_riftbinder("https://riftbinder.com/decks/abc123") + assert deck.name == "Test Deck" + + def test_parses_legend(self): + mock_resp = MagicMock() + mock_resp.json.return_value = self._firestore_response() + with patch("src.rb_scraper.requests.get", return_value=mock_resp): + deck = _scrape_riftbinder("https://riftbinder.com/decks/abc123") + legend_cards = [c for c in deck.cards if c.section == "legend"] + assert len(legend_cards) == 1 + assert legend_cards[0].card_id == "RB-LEGEND-001" + + def test_parses_battlefield(self): + mock_resp = MagicMock() + mock_resp.json.return_value = self._firestore_response() + with patch("src.rb_scraper.requests.get", return_value=mock_resp): + deck = _scrape_riftbinder("https://riftbinder.com/decks/abc123") + bf = [c for c in deck.cards if c.section == "battlefield"] + assert len(bf) == 1 + assert bf[0].card_id == "RB-BF-001" + + def test_parses_maindeck_quantity(self): + mock_resp = MagicMock() + mock_resp.json.return_value = self._firestore_response() + with patch("src.rb_scraper.requests.get", return_value=mock_resp): + deck = _scrape_riftbinder("https://riftbinder.com/decks/abc123") + main = [c for c in deck.cards if c.section == "maindeck"] + assert len(main) == 1 + assert main[0].quantity == 3 + + def test_parses_sideboard(self): + mock_resp = MagicMock() + mock_resp.json.return_value = self._firestore_response() + with patch("src.rb_scraper.requests.get", return_value=mock_resp): + deck = _scrape_riftbinder("https://riftbinder.com/decks/abc123") + sb = [c for c in deck.cards if c.section == "sideboard"] + assert len(sb) == 1 + + def test_bad_url_raises(self): + with pytest.raises(ValueError, match="ID"): + _scrape_riftbinder("https://riftbinder.com/decks/") + + +# --------------------------------------------------------------------------- +# Unit tests — RBDeck model +# --------------------------------------------------------------------------- + + +class TestRBDeckModel: + def _make_deck(self, cards: list[RBCard]) -> RBDeck: + return RBDeck(deck_id="test-id", name="Test", cards=cards) + + def _card(self, section: str, qty: int = 1, variant_id: str | None = None) -> RBCard: + cid = f"RB-{section.upper()}-001" + return RBCard( + card_id=cid, + variant_id=variant_id or cid, + name="Card", + card_type=section.capitalize(), + card_super=None, + quantity=qty, + image_url="https://example.com/img.webp", + section=section, + ) + + def test_total_slots_sums_quantities(self): + deck = self._make_deck( + [ + self._card("legend", 1), + self._card("maindeck", 4), + self._card("rune", 2), + ] + ) + assert deck.total_slots == 7 + + def test_by_section_groups_correctly(self): + legend = self._card("legend") + main = self._card("maindeck", 3) + deck = self._make_deck([legend, main]) + grouped = deck.by_section() + assert grouped["legend"] == [legend] + assert grouped["maindeck"] == [main] + assert grouped["battlefield"] == [] + + def test_by_section_contains_all_section_keys(self): + deck = self._make_deck([]) + grouped = deck.by_section() + assert set(grouped.keys()) == set(SECTION_ORDER) + + +# --------------------------------------------------------------------------- +# Unit tests — expand_deck +# --------------------------------------------------------------------------- + + +class TestExpandDeck: + def _back_map(self, tmp_path: Path) -> dict[str, Path]: + backs = {} + for section in ("legend", "battlefield", "rune", "maindeck"): + p = tmp_path / f"{section}.png" + p.write_bytes(b"fake") + backs[section] = p + return backs + + def _card(self, section: str, qty: int, variant_id: str) -> RBCard: + return RBCard( + card_id=variant_id, + variant_id=variant_id, + name="Card", + card_type=section.capitalize(), + card_super=None, + quantity=qty, + image_url="https://example.com/img.webp", + section=section, + ) + + def test_expands_quantity(self, tmp_path): + deck = RBDeck( + deck_id="x", + name="X", + cards=[ + self._card("maindeck", 3, "UNIT-001"), + ], + ) + img = tmp_path / "UNIT-001.webp" + img.write_bytes(b"img") + backs = self._back_map(tmp_path) + fronts, per_backs = expand_deck(deck, {"UNIT-001": img}, backs) + assert len(fronts) == 3 + assert all(f == img for f in fronts) + + def test_legend_uses_legend_back(self, tmp_path): + deck = RBDeck( + deck_id="x", + name="X", + cards=[ + self._card("legend", 1, "LEG-001"), + ], + ) + img = tmp_path / "LEG-001.webp" + img.write_bytes(b"img") + backs = self._back_map(tmp_path) + _, per_backs = expand_deck(deck, {"LEG-001": img}, backs) + assert per_backs[0] == backs["legend"] + + def test_rune_uses_rune_back(self, tmp_path): + deck = RBDeck( + deck_id="x", + name="X", + cards=[ + self._card("rune", 1, "RUNE-001"), + ], + ) + img = tmp_path / "RUNE-001.webp" + img.write_bytes(b"img") + backs = self._back_map(tmp_path) + _, per_backs = expand_deck(deck, {"RUNE-001": img}, backs) + assert per_backs[0] == backs["rune"] + + def test_champion_uses_maindeck_back(self, tmp_path): + deck = RBDeck( + deck_id="x", + name="X", + cards=[ + self._card("champion", 1, "CHAMP-001"), + ], + ) + img = tmp_path / "CHAMP-001.webp" + img.write_bytes(b"img") + backs = self._back_map(tmp_path) + _, per_backs = expand_deck(deck, {"CHAMP-001": img}, backs) + assert per_backs[0] == backs["maindeck"] + + def test_sideboard_uses_maindeck_back(self, tmp_path): + deck = RBDeck( + deck_id="x", + name="X", + cards=[ + self._card("sideboard", 2, "SB-001"), + ], + ) + img = tmp_path / "SB-001.webp" + img.write_bytes(b"img") + backs = self._back_map(tmp_path) + _, per_backs = expand_deck(deck, {"SB-001": img}, backs) + assert all(b == backs["maindeck"] for b in per_backs) + + def test_skips_missing_image(self, tmp_path): + deck = RBDeck( + deck_id="x", + name="X", + cards=[ + self._card("maindeck", 2, "MISSING-001"), + ], + ) + backs = self._back_map(tmp_path) + fronts, per_backs = expand_deck(deck, {}, backs) + assert fronts == [] + assert per_backs == [] + + def test_include_runes_false_skips_rune_section(self, tmp_path): + deck = RBDeck( + deck_id="x", + name="X", + cards=[ + self._card("rune", 1, "RUNE-001"), + self._card("maindeck", 1, "UNIT-001"), + ], + ) + rune_img = tmp_path / "RUNE-001.webp" + rune_img.write_bytes(b"img") + unit_img = tmp_path / "UNIT-001.webp" + unit_img.write_bytes(b"img") + backs = self._back_map(tmp_path) + image_map = {"RUNE-001": rune_img, "UNIT-001": unit_img} + fronts, _ = expand_deck(deck, image_map, backs, include_runes=False) + assert len(fronts) == 1 + assert fronts[0] == unit_img + + def test_section_order_is_preserved(self, tmp_path): + """Cards appear in SECTION_ORDER regardless of insertion order.""" + deck = RBDeck( + deck_id="x", + name="X", + cards=[ + self._card("maindeck", 1, "UNIT-001"), + self._card("legend", 1, "LEG-001"), + ], + ) + unit_img = tmp_path / "UNIT-001.webp" + unit_img.write_bytes(b"img") + leg_img = tmp_path / "LEG-001.webp" + leg_img.write_bytes(b"img") + backs = self._back_map(tmp_path) + fronts, _ = expand_deck(deck, {"UNIT-001": unit_img, "LEG-001": leg_img}, backs) + # Legend should appear first (legend precedes maindeck in SECTION_ORDER) + assert fronts[0] == leg_img + assert fronts[1] == unit_img + + +# --------------------------------------------------------------------------- +# Unit tests — download_images cancellation +# --------------------------------------------------------------------------- + + +class TestDownloadImagesCancellation: + def test_raises_cancelled_when_event_set(self, tmp_path): + from src.rb_scraper import download_images + + card = RBCard( + card_id="RB-001", + variant_id="RB-001", + name="Card", + card_type="Unit", + card_super=None, + quantity=1, + image_url="https://example.com/img.webp", + section="maindeck", + ) + deck = RBDeck(deck_id="x", name="X", cards=[card]) + cancel = threading.Event() + cancel.set() + + mock_resp = MagicMock() + mock_resp.content = b"fake" + with patch("src.rb_scraper.requests.get", return_value=mock_resp): + with pytest.raises(Cancelled): + download_images(deck, tmp_path, cancel_event=cancel) + + +# --------------------------------------------------------------------------- +# Unit tests — _trpc_get +# --------------------------------------------------------------------------- + + +class TestTrpcGet: + def test_builds_url_and_parses_result(self): + mock_r = MagicMock() + mock_r.json.return_value = {"result": {"data": {"json": {"key": "value"}}}} + with patch("src.rb_scraper.requests.get", return_value=mock_r) as mock_get: + result = _trpc_get("decks.getById", {"id": "abc"}) + assert result == {"key": "value"} + call_url = mock_get.call_args[0][0] + assert "decks.getById" in call_url + assert "piltoverarchive.com" in call_url + + def test_passes_encoded_payload(self): + mock_r = MagicMock() + mock_r.json.return_value = {"result": {"data": {"json": {}}}} + with patch("src.rb_scraper.requests.get", return_value=mock_r) as mock_get: + _trpc_get("proc", {"id": "xyz"}) + call_url = mock_get.call_args[0][0] + assert "input=" in call_url + assert "xyz" in call_url + + +# --------------------------------------------------------------------------- +# Unit tests — _resolve_image +# --------------------------------------------------------------------------- + + +class TestResolveImage: + def test_returns_matching_variant_url(self): + item = { + "variantId": "v002", + "card": { + "cardVariants": [ + {"id": "v001", "imageUrl": "url1"}, + {"id": "v002", "imageUrl": "url2"}, + ] + }, + } + assert _resolve_image(item) == "url2" + + def test_falls_back_to_first_variant_when_preferred_missing(self): + item = { + "variantId": "v999", + "card": {"cardVariants": [{"id": "v001", "imageUrl": "url1"}]}, + } + assert _resolve_image(item) == "url1" + + def test_returns_empty_when_no_variants(self): + item = {"variantId": "v001", "card": {"cardVariants": []}} + assert _resolve_image(item) == "" + + +# --------------------------------------------------------------------------- +# Unit tests — _fetch_deck (piltoverarchive) +# --------------------------------------------------------------------------- + + +class TestFetchDeck: + def _raw(self): + return { + "name": "PA Deck", + "legend": { + "cardId": "L001", + "id": "v-leg", + "imageUrl": "https://example.com/L001.webp", + "card": {"name": "The Legend", "type": "Legend"}, + }, + "champions": [], + "battlefields": [], + "runes": [], + "maindeck": [ + { + "cardId": "C001", + "variantId": "v001", + "quantity": 4, + "card": { + "name": "A Unit", + "type": "Unit", + "super": None, + "cardVariants": [ + {"id": "v001", "imageUrl": "https://example.com/C001.webp"} + ], + }, + } + ], + "sideboard": [], + } + + def test_parses_legend_and_maindeck(self): + with patch("src.rb_scraper._trpc_get", return_value=self._raw()): + deck = _fetch_deck("test-id") + assert deck.name == "PA Deck" + sections = {c.section for c in deck.cards} + assert "legend" in sections + assert "maindeck" in sections + qtys = {c.card_id: c.quantity for c in deck.cards} + assert qtys["C001"] == 4 + + def test_legend_gets_image_url_directly(self): + with patch("src.rb_scraper._trpc_get", return_value=self._raw()): + deck = _fetch_deck("test-id") + legend = next(c for c in deck.cards if c.section == "legend") + assert legend.image_url == "https://example.com/L001.webp" + + def test_not_found_raises(self): + with patch("src.rb_scraper._trpc_get", return_value=None): + with pytest.raises(ValueError, match="No se encontró"): + _fetch_deck("missing-id") + + +# --------------------------------------------------------------------------- +# Unit tests — _scrape_riftbound_gg +# --------------------------------------------------------------------------- + + +class TestScrapeRiftboundGg: + def _deck_json(self): + return { + "humanname": "RBgg Deck", + "deck": {"CARD-001": "4", "CARD-002": "1"}, + "boards": [], + } + + def _cards_json(self): + return { + "names": ["id", "name", "type", "supertype"], + "data": [ + ["CARD-001", "Unit Card", "Unit", None], + ["CARD-002", "Legend Card", "Legend", None], + ], + } + + def test_parses_deck_and_cards(self): + r1 = MagicMock() + r1.text = "nonempty" + r1.json.return_value = self._deck_json() + r2 = MagicMock() + r2.json.return_value = self._cards_json() + with patch("src.rb_scraper.requests.get", side_effect=[r1, r2]): + deck = _scrape_riftbound_gg("https://riftbound.gg/decks/my-deck/") + assert deck.name == "RBgg Deck" + qtys = {c.card_id: c.quantity for c in deck.cards} + assert qtys["CARD-001"] == 4 + assert qtys["CARD-002"] == 1 + + def test_assigns_section_from_type(self): + r1 = MagicMock() + r1.text = "nonempty" + r1.json.return_value = self._deck_json() + r2 = MagicMock() + r2.json.return_value = self._cards_json() + with patch("src.rb_scraper.requests.get", side_effect=[r1, r2]): + deck = _scrape_riftbound_gg("https://riftbound.gg/decks/my-deck/") + sections = {c.card_id: c.section for c in deck.cards} + assert sections["CARD-002"] == "legend" + assert sections["CARD-001"] == "maindeck" + + def test_empty_body_raises(self): + r1 = MagicMock() + r1.text = " " + with patch("src.rb_scraper.requests.get", return_value=r1): + with pytest.raises(ValueError, match="privado"): + _scrape_riftbound_gg("https://riftbound.gg/decks/my-deck/") + + def test_bad_url_raises(self): + with pytest.raises(ValueError, match="slug"): + _scrape_riftbound_gg("https://riftbound.gg/") + + +# --------------------------------------------------------------------------- +# Unit tests — _scrape_riftmana +# --------------------------------------------------------------------------- + + +class TestScrapeRiftmana: + def test_extracts_uuid_and_fetches_api(self): + uuid = "aaaabbbb-1111-2222-3333-ccccddddeeee" + html_r = MagicMock() + html_r.text = f'
' + api_r = MagicMock() + api_r.json.return_value = { + "data": { + "deck": { + "name": "RM Deck", + "cards": [ + { + "code": "card-001", + "name": "C1", + "type": "Unit", + "super": None, + "quantity": 3, + "image": "https://example.com/c1.webp", + } + ], + "sideboard": [], + } + } + } + with patch("src.rb_scraper.requests.get", side_effect=[html_r, api_r]): + deck = _scrape_riftmana("https://riftmana.com/decks/my-deck") + assert deck.name == "RM Deck" + assert deck.deck_id == uuid + assert len(deck.cards) == 1 + assert deck.cards[0].card_id == "CARD-001" + assert deck.cards[0].quantity == 3 + + def test_uuid_not_found_raises(self): + html_r = MagicMock() + html_r.text = "no uuid here" + with patch("src.rb_scraper.requests.get", return_value=html_r): + with pytest.raises(ValueError, match="UUID"): + _scrape_riftmana("https://riftmana.com/decks/my-deck") + + +# --------------------------------------------------------------------------- +# Unit tests — _scrape_riftdex +# --------------------------------------------------------------------------- + + +class TestScrapeRiftdex: + DECK_UUID = "00000000-1111-2222-3333-444455556666" + CARD_UUID = "aaaabbbb-0000-0000-0000-111122223333" + + def test_parses_deck_with_card_lookup(self): + deck_r = MagicMock() + deck_r.json.return_value = [ + {"name": "RD Deck", "cards": [{"cardId": self.CARD_UUID, "count": 2}]} + ] + cards_r = MagicMock() + cards_r.json.return_value = [ + { + "id": self.CARD_UUID, + "card_name": "My Card", + "card_number": "RD-001", + "type": "Unit", + "super": None, + "image_url": "https://example.com/rd001.webp", + } + ] + with patch("src.rb_scraper.requests.get", side_effect=[deck_r, cards_r]): + deck = _scrape_riftdex(f"https://riftdex.com/deck/{self.DECK_UUID}") + assert deck.name == "RD Deck" + assert deck.cards[0].card_id == "RD-001" + assert deck.cards[0].quantity == 2 + + def test_not_found_raises(self): + r = MagicMock() + r.json.return_value = [] + with patch("src.rb_scraper.requests.get", return_value=r): + with pytest.raises(ValueError, match="No se encontró"): + _scrape_riftdex(f"https://riftdex.com/deck/{self.DECK_UUID}") + + def test_bad_url_raises(self): + with pytest.raises(ValueError, match="UUID"): + _scrape_riftdex("https://riftdex.com/deck/not-a-uuid") + + +# --------------------------------------------------------------------------- +# Unit tests — download_images (rb_scraper) +# --------------------------------------------------------------------------- + + +class TestRBDownloadImages: + def _make_card(self, variant_id: str = "v001") -> RBCard: + return RBCard( + card_id="C001", + variant_id=variant_id, + name="Card", + card_type="Unit", + card_super=None, + quantity=2, + image_url=f"https://example.com/{variant_id}.webp", + section="maindeck", + ) + + def test_downloads_and_saves_image(self, tmp_path): + card = self._make_card() + deck = RBDeck(deck_id="d1", name="Test", cards=[card]) + mock_r = MagicMock() + mock_r.content = b"image_bytes" + with patch("src.rb_scraper.requests.get", return_value=mock_r): + result = download_images(deck, tmp_path) + assert "v001" in result + assert result["v001"].read_bytes() == b"image_bytes" + + def test_deduplicates_by_variant_id(self, tmp_path): + card = self._make_card("v001") + deck = RBDeck(deck_id="d1", name="Test", cards=[card, card]) + mock_r = MagicMock() + mock_r.content = b"bytes" + call_count = 0 + + def _track(*a, **kw): + nonlocal call_count + call_count += 1 + return mock_r + + with patch("src.rb_scraper.requests.get", side_effect=_track): + result = download_images(deck, tmp_path) + assert call_count == 1 + assert "v001" in result + + def test_uses_cached_file(self, tmp_path): + card = self._make_card() + deck = RBDeck(deck_id="d1", name="Test", cards=[card]) + cached = tmp_path / "v001.webp" + cached.write_bytes(b"cached") + with patch("src.rb_scraper.requests.get") as mock_get: + result = download_images(deck, tmp_path) + mock_get.assert_not_called() + assert result["v001"] == cached + + +# --------------------------------------------------------------------------- +# Unit tests — get_rb_backs fallback +# --------------------------------------------------------------------------- + + +class TestGetRbBacksFallback: + def test_generates_fallback_when_files_missing(self, tmp_path): + with patch("src.rb_scraper._resources_dir", return_value=tmp_path / "missing"): + backs = get_rb_backs() + for section in ("legend", "battlefield", "rune", "maindeck"): + assert section in backs + assert backs[section].exists() + + +# --------------------------------------------------------------------------- +# Integration tests — live network calls +# --------------------------------------------------------------------------- + + +@pytest.mark.network +class TestLiveScrapers: + """Smoke-tests against the real websites. + + Detect breaking changes in a site's API or URL format. + Skipped in CI unless the 'network' marker is explicitly included. + """ + + URL_PILTOVER = "https://piltoverarchive.com/decks/view/6e82e7e5-3de3-41d2-8aee-c30fc0bbe4d6" + URL_RIFTBOUND_GG = "https://riftbound.gg/decks/test-deck/" + + def _assert_valid_deck(self, deck: RBDeck) -> None: + assert isinstance(deck, RBDeck) + assert deck.total_slots > 0, "Deck has no cards" + legend = [c for c in deck.cards if c.section == "legend"] + assert len(legend) == 1, "Deck has no legend" + + def test_piltoverarchive(self): + deck = scrape_deck(self.URL_PILTOVER) + self._assert_valid_deck(deck) + + def test_riftbound_gg(self): + deck = scrape_deck(self.URL_RIFTBOUND_GG) + self._assert_valid_deck(deck) diff --git a/tests/test_scraper_utils.py b/tests/test_scraper_utils.py new file mode 100644 index 0000000..848b5af --- /dev/null +++ b/tests/test_scraper_utils.py @@ -0,0 +1,50 @@ +"""Tests for src/scraper_utils.py.""" + +from PIL import Image + +from src.scraper_utils import generate_fallback_back + + +class TestGenerateFallbackBack: + def test_creates_file_at_path(self, tmp_path): + path = tmp_path / "back.png" + generate_fallback_back(path, "#0A1628", "#B0B8C8") + assert path.exists() + + def test_returns_the_path(self, tmp_path): + path = tmp_path / "back.png" + result = generate_fallback_back(path, "#0A1628", "#B0B8C8") + assert result == path + + def test_default_size(self, tmp_path): + path = tmp_path / "back.png" + generate_fallback_back(path, "#000000", "#FFFFFF") + img = Image.open(path) + assert img.size == (480, 670) + + def test_custom_size(self, tmp_path): + path = tmp_path / "back.png" + generate_fallback_back(path, "#000000", "#FFFFFF", size=(100, 140)) + img = Image.open(path) + assert img.size == (100, 140) + + def test_creates_parent_directories(self, tmp_path): + path = tmp_path / "nested" / "deep" / "back.png" + generate_fallback_back(path, "#123456", "#ABCDEF") + assert path.exists() + + def test_image_is_rgb(self, tmp_path): + path = tmp_path / "back.png" + generate_fallback_back(path, "#FF0000", "#00FF00") + img = Image.open(path) + assert img.mode == "RGB" + + def test_background_color_applied(self, tmp_path): + path = tmp_path / "back.png" + generate_fallback_back(path, "#FF0000", "#000000", size=(50, 70)) + img = Image.open(path) + cx, cy = img.size[0] // 2, img.size[1] // 2 + r, g, b = img.getpixel((cx, cy)) + assert r > 200 + assert g < 50 + assert b < 50 diff --git a/tests/test_scryfall.py b/tests/test_scryfall.py new file mode 100644 index 0000000..1054dfe --- /dev/null +++ b/tests/test_scryfall.py @@ -0,0 +1,273 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from src.deck_importer import DeckCard +from src.scryfall import ( + ScryfallCard, + ScryfallError, + download_deck_images, + fetch_card, + fetch_card_by_name, +) + +_NORMAL_CARD_JSON = { + "image_uris": { + "large": "https://cards.scryfall.io/large/front/0/0/abc.jpg", + } +} + +_MDFC_CARD_JSON = { + "card_faces": [ + {"image_uris": {"large": "https://cards.scryfall.io/large/front/1/1/face0.jpg"}}, + {"image_uris": {"large": "https://cards.scryfall.io/large/back/1/1/face1.jpg"}}, + ] +} + + +def _mock_resp(json_data, status=200): + resp = MagicMock() + resp.json.return_value = json_data + resp.raise_for_status.return_value = None + resp.content = b"IMAGEDATA" + resp.status_code = status + return resp + + +class TestFetchCardNormal: + def setup_method(self): + import src.scryfall as sf + + sf._cache.clear() + + def test_returns_front_url_and_no_back(self): + with patch("src.scryfall._throttled_get", return_value=_mock_resp(_NORMAL_CARD_JSON)): + card = fetch_card("ltr", "152") + assert card.front_url == "https://cards.scryfall.io/large/front/0/0/abc.jpg" + assert card.back_url is None + + def test_result_is_scryfall_card_instance(self): + with patch("src.scryfall._throttled_get", return_value=_mock_resp(_NORMAL_CARD_JSON)): + card = fetch_card("m21", "1") + assert isinstance(card, ScryfallCard) + + def test_raises_scryfall_error_on_http_error(self): + import requests as req + + err = req.HTTPError(response=MagicMock(status_code=404)) + with patch("src.scryfall._throttled_get", side_effect=err): + with pytest.raises(ScryfallError, match="no encontrada"): + fetch_card("bad", "0") + + +class TestFetchCardMDFC: + def setup_method(self): + import src.scryfall as sf + + sf._cache.clear() + + def test_returns_both_face_urls(self): + with patch("src.scryfall._throttled_get", return_value=_mock_resp(_MDFC_CARD_JSON)): + card = fetch_card("khm", "200") + assert "face0" in card.front_url + assert card.back_url is not None + assert "face1" in card.back_url + + def test_front_is_card_faces_zero(self): + with patch("src.scryfall._throttled_get", return_value=_mock_resp(_MDFC_CARD_JSON)): + card = fetch_card("khm", "200") + assert card.front_url == _MDFC_CARD_JSON["card_faces"][0]["image_uris"]["large"] + assert card.back_url == _MDFC_CARD_JSON["card_faces"][1]["image_uris"]["large"] + + +class TestFetchCardCaching: + def setup_method(self): + import src.scryfall as sf + + sf._cache.clear() + + def test_second_call_does_not_hit_network(self): + with patch( + "src.scryfall._throttled_get", return_value=_mock_resp(_NORMAL_CARD_JSON) + ) as mock_get: + fetch_card("ltr", "10") + fetch_card("ltr", "10") + assert mock_get.call_count == 1 + + def test_cache_keyed_by_set_and_number(self): + with patch( + "src.scryfall._throttled_get", return_value=_mock_resp(_NORMAL_CARD_JSON) + ) as mock_get: + fetch_card("ltr", "10") + fetch_card("m21", "10") + assert mock_get.call_count == 2 + + +class TestDownloadCardImages: + def setup_method(self): + import src.scryfall as sf + + sf._cache.clear() + + def test_file_cache_prevents_redownload(self, tmp_path): + existing = tmp_path / "ltr_10.jpg" + existing.write_bytes(b"cached") + card = DeckCard("Bolt", "ltr", "10", 1, "main") + with ( + patch( + "src.scryfall.fetch_card", + return_value=ScryfallCard("https://example.com/ltr_10.jpg", None), + ), + patch("src.scryfall._throttled_get") as mock_get, + ): + from src.scryfall import download_card_images + + front, back = download_card_images(card, tmp_path) + mock_get.assert_not_called() + assert front == existing + assert back is None + + def test_downloads_front_when_missing(self, tmp_path): + card = DeckCard("Bolt", "ltr", "152", 1, "main") + with ( + patch( + "src.scryfall.fetch_card", + return_value=ScryfallCard("https://example.com/img.jpg", None), + ), + patch("src.scryfall._throttled_get", return_value=_mock_resp(_NORMAL_CARD_JSON)), + ): + from src.scryfall import download_card_images + + front, back = download_card_images(card, tmp_path) + assert front.exists() + assert front.read_bytes() == b"IMAGEDATA" + assert back is None + + def test_downloads_mdfc_back(self, tmp_path): + card = DeckCard("Fable", "mid", "141", 1, "main") + with ( + patch( + "src.scryfall.fetch_card", + return_value=ScryfallCard( + "https://example.com/front.jpg", + "https://example.com/back.jpg", + ), + ), + patch("src.scryfall._throttled_get", return_value=_mock_resp(_MDFC_CARD_JSON)), + ): + from src.scryfall import download_card_images + + front, back = download_card_images(card, tmp_path) + assert back is not None + assert back.exists() + assert "_back" in back.name + + +class TestDownloadDeckImages: + def setup_method(self): + import src.scryfall as sf + + sf._cache.clear() + + def test_returns_ordered_results(self, tmp_path): + cards = [ + DeckCard("A", "m21", "1", 1, "main"), + DeckCard("B", "m21", "2", 1, "main"), + DeckCard("C", "m21", "3", 1, "main"), + ] + side_effects = [ + (tmp_path / "m21_1.jpg", None), + (tmp_path / "m21_2.jpg", None), + (tmp_path / "m21_3.jpg", None), + ] + for p, _ in side_effects: + p.write_bytes(b"x") + + with patch("src.scryfall.download_card_images", side_effect=side_effects): + results = download_deck_images(cards, tmp_path) + + assert len(results) == 3 + names = [c.name for c, _, _ in results] + assert names == ["A", "B", "C"] + + +class TestFetchCardByName: + def setup_method(self): + import src.scryfall as sf + + sf._name_cache.clear() + + def test_returns_set_and_collector_number(self): + data = {"set": "LTR", "collector_number": "152"} + with patch("src.scryfall._throttled_get", return_value=_mock_resp(data)): + set_code, cn = fetch_card_by_name("Lightning Bolt") + assert set_code == "ltr" + assert cn == "152" + + def test_result_cached_on_second_call(self): + data = {"set": "M21", "collector_number": "295"} + with patch("src.scryfall._throttled_get", return_value=_mock_resp(data)) as mock_get: + fetch_card_by_name("Forest") + fetch_card_by_name("Forest") + assert mock_get.call_count == 1 + + def test_cache_case_insensitive(self): + data = {"set": "m21", "collector_number": "295"} + with patch("src.scryfall._throttled_get", return_value=_mock_resp(data)) as mock_get: + fetch_card_by_name("Forest") + fetch_card_by_name("FOREST") + assert mock_get.call_count == 1 + + def test_raises_scryfall_error_on_http_error(self): + import requests as req + + err = req.HTTPError(response=MagicMock(status_code=404)) + with patch("src.scryfall._throttled_get", side_effect=err): + with pytest.raises(ScryfallError, match="no encontrada"): + fetch_card_by_name("Nonexistent Card XYZZY") + + def test_set_code_lowercased(self): + data = {"set": "LTR", "collector_number": "1"} + with patch("src.scryfall._throttled_get", return_value=_mock_resp(data)): + set_code, _ = fetch_card_by_name("Gandalf") + assert set_code == set_code.lower() + + +class TestDownloadCardImagesNameResolution: + def setup_method(self): + import src.scryfall as sf + + sf._cache.clear() + sf._name_cache.clear() + + def test_resolves_name_only_card(self, tmp_path): + card = DeckCard("Lightning Bolt", "", "", 1, "main") + with ( + patch("src.scryfall.fetch_card_by_name", return_value=("ltr", "152")) as mock_name, + patch( + "src.scryfall.fetch_card", + return_value=ScryfallCard("https://example.com/img.jpg", None), + ), + patch("src.scryfall._throttled_get", return_value=_mock_resp(_NORMAL_CARD_JSON)), + ): + from src.scryfall import download_card_images + + front, back = download_card_images(card, tmp_path) + mock_name.assert_called_once_with("Lightning Bolt") + assert front.exists() + assert back is None + + def test_skips_name_resolution_when_set_and_number_present(self, tmp_path): + card = DeckCard("Lightning Bolt", "ltr", "152", 1, "main") + with ( + patch("src.scryfall.fetch_card_by_name") as mock_name, + patch( + "src.scryfall.fetch_card", + return_value=ScryfallCard("https://example.com/img.jpg", None), + ), + patch("src.scryfall._throttled_get", return_value=_mock_resp(_NORMAL_CARD_JSON)), + ): + from src.scryfall import download_card_images + + download_card_images(card, tmp_path) + mock_name.assert_not_called()