Creating a program to allow me to better manage my Magic cards and even make decks from it.
This commit is contained in:
195
.gitignore
vendored
Normal file
195
.gitignore
vendored
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
#uv.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
#poetry.toml
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
|
# Abstra
|
||||||
|
# Abstra is an AI-powered process automation framework.
|
||||||
|
# Ignore directories containing user credentials, local state, and settings.
|
||||||
|
# Learn more at https://abstra.io/docs
|
||||||
|
.abstra/
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||||
|
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||||
|
# you could uncomment the following to ignore the entire vscode folder
|
||||||
|
# .vscode/
|
||||||
|
|
||||||
|
# Ruff stuff:
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# PyPI configuration file
|
||||||
|
.pypirc
|
||||||
|
|
||||||
|
# Cursor
|
||||||
|
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
||||||
|
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
||||||
|
# refer to https://docs.cursor.com/context/ignore-files
|
||||||
|
.cursorignore
|
||||||
|
.cursorindexingignore
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 [fullname]
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
597
collection_manager.py
Normal file
597
collection_manager.py
Normal file
@ -0,0 +1,597 @@
|
|||||||
|
# main.py
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
import requests
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk, messagebox, simpledialog
|
||||||
|
from PIL import Image, ImageTk
|
||||||
|
|
||||||
|
from mtg_api import init_cache_db, get_card_by_name, search_cards
|
||||||
|
from deck_manager import save_deck as dm_save_deck, load_deck, list_saved_decks
|
||||||
|
from collection_manager import load_collection, save_collection, list_collection
|
||||||
|
from models import Deck, Card
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Helper to detect lands
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def is_land(card: Card) -> bool:
|
||||||
|
return "Land" in card.type_line
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Main Application Window
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class MTGDeckBuilder(tk.Tk):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.title("MTG Deck Builder")
|
||||||
|
self.geometry("1000x650")
|
||||||
|
|
||||||
|
# Ensure necessary folders/files exist
|
||||||
|
init_cache_db()
|
||||||
|
_ = load_collection() # ensure collection file/folder
|
||||||
|
|
||||||
|
# Currently loaded Deck (or None)
|
||||||
|
self.current_deck: Deck | None = None
|
||||||
|
|
||||||
|
# In-memory cache: card_name → Card object (so we don't re-fetch repeatedly)
|
||||||
|
self.card_cache: dict[str, Card] = {}
|
||||||
|
|
||||||
|
# Hold references to PhotoImage objects so they don’t get garbage-collected
|
||||||
|
self.preview_photo: ImageTk.PhotoImage | None = None
|
||||||
|
self.color_icon_images: dict[str, ImageTk.PhotoImage] = {}
|
||||||
|
|
||||||
|
# Build the UI
|
||||||
|
self._load_color_icons()
|
||||||
|
self._build_widgets()
|
||||||
|
self._layout_widgets()
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Pre-load color icon images from local `assets/icons`
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def _load_color_icons(self):
|
||||||
|
"""
|
||||||
|
Expects five PNG files in assets/icons: W.png, U.png, B.png, R.png, G.png
|
||||||
|
"""
|
||||||
|
icon_folder = os.path.join("assets", "icons")
|
||||||
|
for symbol in ["W", "U", "B", "R", "G"]:
|
||||||
|
path = os.path.join(icon_folder, f"{symbol}.png")
|
||||||
|
if os.path.isfile(path):
|
||||||
|
img = Image.open(path).resize((32, 32), Image.LANCZOS)
|
||||||
|
self.color_icon_images[symbol] = ImageTk.PhotoImage(img)
|
||||||
|
else:
|
||||||
|
self.color_icon_images[symbol] = None
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Create all widgets
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def _build_widgets(self):
|
||||||
|
# -- Deck Controls -------------------------------------------------------
|
||||||
|
self.deck_frame = ttk.LabelFrame(self, text="Deck Controls", padding=10)
|
||||||
|
self.new_deck_btn = ttk.Button(self.deck_frame, text="New Deck", command=self.new_deck)
|
||||||
|
self.load_deck_btn = ttk.Button(self.deck_frame, text="Load Deck", command=self.load_deck)
|
||||||
|
self.save_deck_btn = ttk.Button(self.deck_frame, text="Save Deck", command=self.save_deck)
|
||||||
|
self.smart_build_btn = ttk.Button(self.deck_frame, text="Smart Build Deck", command=self.smart_build_deck)
|
||||||
|
self.deck_name_label = ttk.Label(self.deck_frame, text="(no deck loaded)")
|
||||||
|
|
||||||
|
# -- A container for the two main panels (Search vs. Deck) -------------
|
||||||
|
self.content_frame = ttk.Frame(self)
|
||||||
|
|
||||||
|
# -- Search / Add Cards -----------------------------------------------
|
||||||
|
self.search_frame = ttk.LabelFrame(self.content_frame, text="Search / Add Cards", padding=10)
|
||||||
|
self.search_entry = ttk.Entry(self.search_frame, width=40)
|
||||||
|
self.search_btn = ttk.Button(self.search_frame, text="Search", command=self.perform_search)
|
||||||
|
|
||||||
|
self.results_list = tk.Listbox(self.search_frame, height=12, width=50)
|
||||||
|
self.result_scroll = ttk.Scrollbar(self.search_frame, orient="vertical", command=self.results_list.yview)
|
||||||
|
self.results_list.configure(yscrollcommand=self.result_scroll.set)
|
||||||
|
self.results_list.bind("<<ListboxSelect>>", self.on_result_select)
|
||||||
|
|
||||||
|
self.add_qty_label = ttk.Label(self.search_frame, text="Quantity:")
|
||||||
|
self.add_qty_spin = ttk.Spinbox(self.search_frame, from_=1, to=20, width=5)
|
||||||
|
self.add_to_deck_btn = ttk.Button(self.search_frame, text="Add to Deck", command=self.add_card_to_deck)
|
||||||
|
self.add_to_coll_btn = ttk.Button(self.search_frame, text="Add to Collection", command=self.add_card_to_collection)
|
||||||
|
|
||||||
|
# -- Card Preview (image + color icons) --------------------------------
|
||||||
|
self.preview_frame = ttk.LabelFrame(self.search_frame, text="Card Preview", padding=10)
|
||||||
|
self.card_image_label = ttk.Label(self.preview_frame)
|
||||||
|
self.color_icons_frame = ttk.Frame(self.preview_frame)
|
||||||
|
|
||||||
|
# -- Deck Contents -----------------------------------------------------
|
||||||
|
self.deck_view_frame = ttk.LabelFrame(self.content_frame, text="Deck Contents", padding=10)
|
||||||
|
self.deck_list = tk.Listbox(self.deck_view_frame, height=20, width=40)
|
||||||
|
self.deck_scroll = ttk.Scrollbar(self.deck_view_frame, orient="vertical", command=self.deck_list.yview)
|
||||||
|
self.deck_list.configure(yscrollcommand=self.deck_scroll.set)
|
||||||
|
self.deck_list.bind("<<ListboxSelect>>", self.on_deck_select)
|
||||||
|
|
||||||
|
self.remove_card_btn = ttk.Button(self.deck_view_frame, text="Remove Selected", command=self.remove_selected)
|
||||||
|
|
||||||
|
# -- Collection Controls (view your collection) ------------------------
|
||||||
|
self.coll_frame = ttk.LabelFrame(self, text="Collection Controls", padding=10)
|
||||||
|
self.view_coll_btn = ttk.Button(self.coll_frame, text="View Collection", command=self.view_collection)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Arrange everything in the proper geometry (pack/grid)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def _layout_widgets(self):
|
||||||
|
# 1) Top: Deck controls (packed)
|
||||||
|
self.deck_frame.pack(fill="x", padx=10, pady=5)
|
||||||
|
self.new_deck_btn.grid(row=0, column=0, padx=5, pady=5)
|
||||||
|
self.load_deck_btn.grid(row=0, column=1, padx=5, pady=5)
|
||||||
|
self.save_deck_btn.grid(row=0, column=2, padx=5, pady=5)
|
||||||
|
self.smart_build_btn.grid(row=0, column=3, padx=5, pady=5)
|
||||||
|
self.deck_name_label.grid(row=0, column=4, padx=10, pady=5, sticky="w")
|
||||||
|
|
||||||
|
# 2) Just below: Collection controls (packed)
|
||||||
|
self.coll_frame.pack(fill="x", padx=10, pady=(0,5))
|
||||||
|
self.view_coll_btn.pack(padx=5, pady=5, anchor="w")
|
||||||
|
|
||||||
|
# 3) Middle: content_frame (packed)
|
||||||
|
self.content_frame.pack(fill="both", expand=True, padx=10, pady=5)
|
||||||
|
self.content_frame.columnconfigure(0, weight=1)
|
||||||
|
self.content_frame.columnconfigure(1, weight=0)
|
||||||
|
self.content_frame.rowconfigure(0, weight=1)
|
||||||
|
|
||||||
|
# --- Left panel: Search / Add / Collection (gridded inside content_frame) ---
|
||||||
|
self.search_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 10))
|
||||||
|
self.search_frame.columnconfigure(0, weight=1)
|
||||||
|
self.search_frame.rowconfigure(1, weight=1)
|
||||||
|
|
||||||
|
self.search_entry.grid(row=0, column=0, padx=5, pady=5, sticky="w")
|
||||||
|
self.search_btn.grid(row=0, column=1, padx=5, pady=5, sticky="w")
|
||||||
|
|
||||||
|
self.results_list.grid(row=1, column=0, columnspan=3, padx=(5, 0), pady=5, sticky="nsew")
|
||||||
|
self.result_scroll.grid(row=1, column=3, sticky="ns", pady=5)
|
||||||
|
|
||||||
|
self.add_qty_label.grid(row=2, column=0, padx=5, pady=5, sticky="w")
|
||||||
|
self.add_qty_spin.grid(row=2, column=1, padx=5, pady=5, sticky="w")
|
||||||
|
self.add_to_deck_btn.grid(row=2, column=2, padx=5, pady=5, sticky="w")
|
||||||
|
self.add_to_coll_btn.grid(row=2, column=3, padx=5, pady=5, sticky="w")
|
||||||
|
|
||||||
|
# Preview frame sits below everything in the search_frame
|
||||||
|
self.preview_frame.grid(row=3, column=0, columnspan=4, padx=5, pady=10, sticky="nsew")
|
||||||
|
self.preview_frame.columnconfigure(0, weight=1)
|
||||||
|
self.preview_frame.rowconfigure(0, weight=1)
|
||||||
|
|
||||||
|
self.card_image_label.grid(row=0, column=0, padx=5, pady=5)
|
||||||
|
self.color_icons_frame.grid(row=1, column=0, padx=5, pady=5)
|
||||||
|
|
||||||
|
# --- Right panel: Deck Contents (gridded inside content_frame) ---
|
||||||
|
self.deck_view_frame.grid(row=0, column=1, sticky="nsew")
|
||||||
|
self.deck_view_frame.columnconfigure(0, weight=1)
|
||||||
|
self.deck_view_frame.rowconfigure(0, weight=1)
|
||||||
|
|
||||||
|
self.deck_list.grid(row=0, column=0, padx=(5, 0), pady=5, sticky="nsew")
|
||||||
|
self.deck_scroll.grid(row=0, column=1, sticky="ns", pady=5)
|
||||||
|
self.remove_card_btn.grid(row=1, column=0, padx=5, pady=5, sticky="w")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Create a brand-new Deck
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def new_deck(self):
|
||||||
|
name = simpledialog.askstring("New Deck", "Enter deck name:", parent=self)
|
||||||
|
if not name:
|
||||||
|
return
|
||||||
|
self.current_deck = Deck(name=name)
|
||||||
|
self.deck_name_label.config(text=f"Deck: {name} (0 cards)")
|
||||||
|
self._refresh_deck_list()
|
||||||
|
self._clear_preview()
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Load a saved Deck from disk
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def load_deck(self):
|
||||||
|
choices = list_saved_decks()
|
||||||
|
if not choices:
|
||||||
|
messagebox.showinfo("Load Deck", "No saved decks found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
name = simpledialog.askstring(
|
||||||
|
"Load Deck",
|
||||||
|
f"Available: {', '.join(choices)}\nEnter deck name:",
|
||||||
|
parent=self
|
||||||
|
)
|
||||||
|
if not name:
|
||||||
|
return
|
||||||
|
deck = load_deck(name)
|
||||||
|
if deck:
|
||||||
|
self.current_deck = deck
|
||||||
|
self.deck_name_label.config(text=f"Deck: {deck.name} ({deck.total_cards()} cards)")
|
||||||
|
self._refresh_deck_list()
|
||||||
|
self._clear_preview()
|
||||||
|
else:
|
||||||
|
messagebox.showerror("Error", f"Deck '{name}' not found.")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Save current Deck to disk
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def save_deck(self):
|
||||||
|
if not self.current_deck:
|
||||||
|
messagebox.showwarning("Save Deck", "No deck loaded.")
|
||||||
|
return
|
||||||
|
dm_save_deck(self.current_deck)
|
||||||
|
messagebox.showinfo("Save Deck", f"Deck '{self.current_deck.name}' saved.")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Smart-build a 60-card deck based on user’s color choice & archetype
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def smart_build_deck(self):
|
||||||
|
# 1) Ask for colors
|
||||||
|
color_input = simpledialog.askstring(
|
||||||
|
"Smart Build: Colors",
|
||||||
|
"Enter 1–3 colors (e.g. R G) separated by spaces:",
|
||||||
|
parent=self
|
||||||
|
)
|
||||||
|
if not color_input:
|
||||||
|
return
|
||||||
|
colors = [c.strip().upper() for c in color_input.split() if c.strip().upper() in {"W","U","B","R","G"}]
|
||||||
|
if not 1 <= len(colors) <= 3:
|
||||||
|
messagebox.showerror("Invalid Colors", "You must pick 1–3 of W, U, B, R, G.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2) Ask for archetype
|
||||||
|
archetype = simpledialog.askstring(
|
||||||
|
"Smart Build: Archetype",
|
||||||
|
"Enter archetype (Aggro, Control, Midrange):",
|
||||||
|
parent=self
|
||||||
|
)
|
||||||
|
if not archetype:
|
||||||
|
return
|
||||||
|
archetype = archetype.strip().lower()
|
||||||
|
if archetype not in {"aggro", "control", "midrange"}:
|
||||||
|
messagebox.showerror("Invalid Archetype", "Must be 'Aggro', 'Control', or 'Midrange'.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3) Build a name for the new deck
|
||||||
|
deck_name = f"{archetype.capitalize()} {'/'.join(colors)} Auto"
|
||||||
|
deck = Deck(name=deck_name)
|
||||||
|
|
||||||
|
# 4) Determine land count & creature/spell counts
|
||||||
|
# We’ll do a 24-land / 24-spells / 12-utility split.
|
||||||
|
land_count = 24
|
||||||
|
spells_count = 36 # creatures+noncreatures
|
||||||
|
# For simplicity, creatures vs. noncreature split:
|
||||||
|
if archetype == "aggro":
|
||||||
|
creature_target = 24
|
||||||
|
noncreature_target = 12
|
||||||
|
elif archetype == "midrange":
|
||||||
|
creature_target = 18
|
||||||
|
noncreature_target = 18
|
||||||
|
else: # control
|
||||||
|
creature_target = 12
|
||||||
|
noncreature_target = 24
|
||||||
|
|
||||||
|
# 5) Fetch lands: basic lands for each color, split evenly.
|
||||||
|
per_color = land_count // len(colors)
|
||||||
|
extra = land_count % len(colors)
|
||||||
|
for idx, col in enumerate(colors):
|
||||||
|
qty = per_color + (1 if idx < extra else 0)
|
||||||
|
# Basic Land names in MTG: “Plains”, “Island”, “Swamp”, “Mountain”, “Forest”
|
||||||
|
map_basic = {"W": "Plains", "U": "Island", "B": "Swamp", "R": "Mountain", "G": "Forest"}
|
||||||
|
land_name = map_basic[col]
|
||||||
|
deck.add_card(land_name, qty)
|
||||||
|
|
||||||
|
# 6) Fetch creatures using Scryfall search
|
||||||
|
# Example query: “c:R type:creature cmc<=3” for aggro; adjust for other archetypes.
|
||||||
|
creature_query = f"c:{''.join(colors)} type:creature"
|
||||||
|
if archetype == "aggro":
|
||||||
|
creature_query += " cmc<=3"
|
||||||
|
elif archetype == "midrange":
|
||||||
|
creature_query += " cmc<=4"
|
||||||
|
else: # control
|
||||||
|
creature_query += " cmc<=5"
|
||||||
|
|
||||||
|
creatures = search_cards(creature_query)
|
||||||
|
creatures = [c for c in creatures if set(c.colors).issubset(set(colors))]
|
||||||
|
# Take up to creature_target distinct names
|
||||||
|
used = set()
|
||||||
|
added = 0
|
||||||
|
for c in creatures:
|
||||||
|
if added >= creature_target:
|
||||||
|
break
|
||||||
|
if c.name not in used:
|
||||||
|
deck.add_card(c.name, 1)
|
||||||
|
used.add(c.name)
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
# 7) Fetch noncreature spells: instants & sorceries
|
||||||
|
noncre_query = f"c:{''.join(colors)} (type:instant or type:sorcery)"
|
||||||
|
if archetype == "aggro":
|
||||||
|
noncre_query += " cmc<=3"
|
||||||
|
elif archetype == "midrange":
|
||||||
|
noncre_query += " cmc<=4"
|
||||||
|
else: # control
|
||||||
|
noncre_query += " cmc>=3"
|
||||||
|
|
||||||
|
noncre = search_cards(noncre_query)
|
||||||
|
noncre = [c for c in noncre if set(c.colors).issubset(set(colors))]
|
||||||
|
added_non = 0
|
||||||
|
for c in noncre:
|
||||||
|
if added_non >= noncreature_target:
|
||||||
|
break
|
||||||
|
if c.name not in used:
|
||||||
|
deck.add_card(c.name, 1)
|
||||||
|
used.add(c.name)
|
||||||
|
added_non += 1
|
||||||
|
|
||||||
|
# 8) If we still need cards (e.g., not enough results), pad with any multi-color or colorless
|
||||||
|
total_cards = sum(deck.cards.values())
|
||||||
|
if total_cards < 60:
|
||||||
|
fill_needed = 60 - total_cards
|
||||||
|
filler = search_cards("type:creature cmc<=3") # cheap catch-all
|
||||||
|
for c in filler:
|
||||||
|
if c.name not in used:
|
||||||
|
deck.add_card(c.name, 1)
|
||||||
|
used.add(c.name)
|
||||||
|
fill_needed -= 1
|
||||||
|
if fill_needed == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 9) Assign to current_deck and refresh UI
|
||||||
|
self.current_deck = deck
|
||||||
|
self.deck_name_label.config(text=f"Deck: {deck.name} ({deck.total_cards()} cards)")
|
||||||
|
self._refresh_deck_list()
|
||||||
|
self._clear_preview()
|
||||||
|
messagebox.showinfo("Smart Build Complete", f"Created deck '{deck.name}' with {deck.total_cards()} cards.")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# View your existing Collection in a pop-up window
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def view_collection(self):
|
||||||
|
coll = load_collection()
|
||||||
|
if not coll:
|
||||||
|
messagebox.showinfo("Collection", "Your collection is empty.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build a Toplevel window
|
||||||
|
top = tk.Toplevel(self)
|
||||||
|
top.title("Your Collection")
|
||||||
|
top.geometry("400x500")
|
||||||
|
|
||||||
|
lbl = ttk.Label(top, text="Card Name – Quantity", font=("TkDefaultFont", 12, "bold"))
|
||||||
|
lbl.pack(pady=(10,5))
|
||||||
|
|
||||||
|
listbox = tk.Listbox(top, width=50, height=20)
|
||||||
|
listbox.pack(fill="both", expand=True, padx=10, pady=5)
|
||||||
|
|
||||||
|
# Sort alphabetically
|
||||||
|
for name, qty in sorted(coll.items(), key=lambda x: x[0].lower()):
|
||||||
|
listbox.insert(tk.END, f"{qty}× {name}")
|
||||||
|
|
||||||
|
def remove_selected():
|
||||||
|
sel = listbox.curselection()
|
||||||
|
if not sel:
|
||||||
|
return
|
||||||
|
idx = sel[0]
|
||||||
|
entry = listbox.get(idx)
|
||||||
|
qty_str, name_part = entry.split("×", 1)
|
||||||
|
card_name = name_part.strip()
|
||||||
|
# Remove entirely (or prompt for qty) — here, we remove all copies
|
||||||
|
del coll[card_name]
|
||||||
|
save_collection(coll)
|
||||||
|
listbox.delete(idx)
|
||||||
|
|
||||||
|
btn = ttk.Button(top, text="Remove Selected Card", command=remove_selected)
|
||||||
|
btn.pack(pady=(5,10))
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Perform a Scryfall search and populate the listbox
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def perform_search(self):
|
||||||
|
query = self.search_entry.get().strip()
|
||||||
|
if not query:
|
||||||
|
return
|
||||||
|
self.results_list.delete(0, tk.END)
|
||||||
|
results = search_cards(query)
|
||||||
|
if not results:
|
||||||
|
self.results_list.insert(tk.END, "(no results)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Cache Card objects
|
||||||
|
for card in results:
|
||||||
|
self.card_cache[card.name] = card
|
||||||
|
|
||||||
|
for card in results:
|
||||||
|
display = f"{card.name} • {card.mana_cost or ''} • {card.type_line} [{card.rarity}]"
|
||||||
|
self.results_list.insert(tk.END, display)
|
||||||
|
|
||||||
|
self._clear_preview()
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# When a search result is selected, show color icons + preview image
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def on_result_select(self, event):
|
||||||
|
sel = self.results_list.curselection()
|
||||||
|
if not sel:
|
||||||
|
return
|
||||||
|
index = sel[0]
|
||||||
|
display = self.results_list.get(index)
|
||||||
|
card_name = display.split(" • ")[0].strip()
|
||||||
|
|
||||||
|
card = self.card_cache.get(card_name) or get_card_by_name(card_name)
|
||||||
|
if not card:
|
||||||
|
return
|
||||||
|
self.card_cache[card.name] = card
|
||||||
|
self._show_preview(card)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Show a given Card’s image + its color icons
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def _show_preview(self, card: Card):
|
||||||
|
# 1) Display color icons
|
||||||
|
for widget in self.color_icons_frame.winfo_children():
|
||||||
|
widget.destroy()
|
||||||
|
|
||||||
|
x = 0
|
||||||
|
for symbol in card.colors:
|
||||||
|
icon_img = self.color_icon_images.get(symbol)
|
||||||
|
if icon_img:
|
||||||
|
lbl = ttk.Label(self.color_icons_frame, image=icon_img)
|
||||||
|
lbl.image = icon_img
|
||||||
|
lbl.grid(row=0, column=x, padx=2)
|
||||||
|
x += 1
|
||||||
|
|
||||||
|
# 2) Fetch & display card image
|
||||||
|
if card.image_url:
|
||||||
|
try:
|
||||||
|
response = requests.get(card.image_url, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
img_data = response.content
|
||||||
|
image = Image.open(io.BytesIO(img_data))
|
||||||
|
image.thumbnail((250, 350), Image.LANCZOS)
|
||||||
|
photo = ImageTk.PhotoImage(image)
|
||||||
|
self.preview_photo = photo
|
||||||
|
self.card_image_label.config(image=photo, text="")
|
||||||
|
except Exception:
|
||||||
|
self.card_image_label.config(text="Could not load image", image="")
|
||||||
|
self.preview_photo = None
|
||||||
|
else:
|
||||||
|
self.card_image_label.config(text="No image available", image="")
|
||||||
|
self.preview_photo = None
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Clear preview panel (both icons & image)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def _clear_preview(self):
|
||||||
|
self.card_image_label.config(image="", text="")
|
||||||
|
for widget in self.color_icons_frame.winfo_children():
|
||||||
|
widget.destroy()
|
||||||
|
self.preview_photo = None
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Add selected card (with qty) to the current Deck
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def add_card_to_deck(self):
|
||||||
|
if not self.current_deck:
|
||||||
|
messagebox.showwarning("Add Card", "Create or load a deck first.")
|
||||||
|
return
|
||||||
|
sel = self.results_list.curselection()
|
||||||
|
if not sel:
|
||||||
|
messagebox.showwarning("Add Card", "Select a card from search results.")
|
||||||
|
return
|
||||||
|
index = sel[0]
|
||||||
|
display = self.results_list.get(index)
|
||||||
|
card_name = display.split(" • ")[0].strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
qty = int(self.add_qty_spin.get())
|
||||||
|
except ValueError:
|
||||||
|
messagebox.showerror("Invalid Quantity", "Enter a valid number.")
|
||||||
|
return
|
||||||
|
|
||||||
|
card = self.card_cache.get(card_name) or get_card_by_name(card_name)
|
||||||
|
if not card:
|
||||||
|
messagebox.showerror("Error", f"Card '{card_name}' not found.")
|
||||||
|
return
|
||||||
|
self.card_cache[card.name] = card
|
||||||
|
|
||||||
|
self.current_deck.add_card(card.name, qty)
|
||||||
|
total = self.current_deck.total_cards()
|
||||||
|
self.deck_name_label.config(text=f"Deck: {self.current_deck.name} ({total} cards)")
|
||||||
|
self._refresh_deck_list()
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Add selected card (with qty) to your Collection (no deck required)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def add_card_to_collection(self):
|
||||||
|
coll = load_collection()
|
||||||
|
sel = self.results_list.curselection()
|
||||||
|
if not sel:
|
||||||
|
messagebox.showwarning("Add to Collection", "Select a card first.")
|
||||||
|
return
|
||||||
|
index = sel[0]
|
||||||
|
display = self.results_list.get(index)
|
||||||
|
card_name = display.split(" • ")[0].strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
qty = int(self.add_qty_spin.get())
|
||||||
|
except ValueError:
|
||||||
|
messagebox.showerror("Invalid Quantity", "Enter a valid number.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Increment in collection
|
||||||
|
coll[card_name] = coll.get(card_name, 0) + qty
|
||||||
|
save_collection(coll)
|
||||||
|
messagebox.showinfo("Collection", f"Added {qty}× '{card_name}' to your collection.")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# When a deck entry is selected, preview its image + colors
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def on_deck_select(self, event):
|
||||||
|
sel = self.deck_list.curselection()
|
||||||
|
if not sel or not self.current_deck:
|
||||||
|
return
|
||||||
|
index = sel[0]
|
||||||
|
entry = self.deck_list.get(index)
|
||||||
|
# Format: "Qty× CardName ⚠?" or "Qty× CardName"
|
||||||
|
parts = entry.split("×", 1)
|
||||||
|
if len(parts) != 2:
|
||||||
|
return
|
||||||
|
_, rest = parts
|
||||||
|
card_name = rest.strip()
|
||||||
|
if card_name.endswith("⚠"):
|
||||||
|
card_name = card_name[:-1].strip()
|
||||||
|
|
||||||
|
card = self.card_cache.get(card_name) or get_card_by_name(card_name)
|
||||||
|
if not card:
|
||||||
|
return
|
||||||
|
self.card_cache[card.name] = card
|
||||||
|
self._show_preview(card)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Remove the selected card (all copies) from the deck
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def remove_selected(self):
|
||||||
|
if not self.current_deck:
|
||||||
|
return
|
||||||
|
sel = self.deck_list.curselection()
|
||||||
|
if not sel:
|
||||||
|
return
|
||||||
|
index = sel[0]
|
||||||
|
entry = self.deck_list.get(index)
|
||||||
|
parts = entry.split("×", 1)
|
||||||
|
if len(parts) != 2:
|
||||||
|
return
|
||||||
|
qty_str, rest = parts
|
||||||
|
try:
|
||||||
|
qty = int(qty_str.strip())
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
card_name = rest.strip()
|
||||||
|
if card_name.endswith("⚠"):
|
||||||
|
card_name = card_name[:-1].strip()
|
||||||
|
|
||||||
|
self.current_deck.remove_card(card_name, qty)
|
||||||
|
total = self.current_deck.total_cards()
|
||||||
|
self.deck_name_label.config(text=f"Deck: {self.current_deck.name} ({total} cards)")
|
||||||
|
self._refresh_deck_list()
|
||||||
|
self._clear_preview()
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Repopulate deck_list, marking non-land duplicates with “⚠”
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def _refresh_deck_list(self):
|
||||||
|
self.deck_list.delete(0, tk.END)
|
||||||
|
if not self.current_deck:
|
||||||
|
return
|
||||||
|
for name, qty in self.current_deck.cards.items():
|
||||||
|
card = self.card_cache.get(name) or get_card_by_name(name)
|
||||||
|
if card:
|
||||||
|
self.card_cache[card.name] = card
|
||||||
|
flag = ""
|
||||||
|
if qty > 1 and not is_land(card):
|
||||||
|
flag = " ⚠"
|
||||||
|
display = f"{qty}× {card.name}{flag}"
|
||||||
|
else:
|
||||||
|
display = f"{qty}× {name}"
|
||||||
|
self.deck_list.insert(tk.END, display)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Launch the app
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Warn if color icons are missing
|
||||||
|
missing = [s for s in ["W","U","B","R","G"] if not os.path.isfile(os.path.join("assets","icons",f"{s}.png"))]
|
||||||
|
if missing:
|
||||||
|
print(f"Warning: Missing color icon(s) for {missing} in assets/icons/. Cards will still load.")
|
||||||
|
app = MTGDeckBuilder()
|
||||||
|
app.mainloop()
|
BIN
data/cards_cache.sqlite
Normal file
BIN
data/cards_cache.sqlite
Normal file
Binary file not shown.
25
deck_manager.py
Normal file
25
deck_manager.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# deck_manager.py
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from models import Deck
|
||||||
|
|
||||||
|
DECKS_DIR = "data/decks"
|
||||||
|
|
||||||
|
def save_deck(deck: Deck):
|
||||||
|
os.makedirs(DECKS_DIR, exist_ok=True)
|
||||||
|
filepath = os.path.join(DECKS_DIR, f"{deck.name}.json")
|
||||||
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(deck.to_dict(), f, indent=2)
|
||||||
|
|
||||||
|
def load_deck(name: str) -> Deck | None:
|
||||||
|
filepath = os.path.join(DECKS_DIR, f"{name}.json")
|
||||||
|
if not os.path.isfile(filepath):
|
||||||
|
return None
|
||||||
|
with open(filepath, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return Deck.from_dict(data)
|
||||||
|
|
||||||
|
def list_saved_decks() -> list[str]:
|
||||||
|
if not os.path.isdir(DECKS_DIR):
|
||||||
|
return []
|
||||||
|
return [fname[:-5] for fname in os.listdir(DECKS_DIR) if fname.endswith(".json")]
|
408
main.py
Normal file
408
main.py
Normal file
@ -0,0 +1,408 @@
|
|||||||
|
# main.py
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
import requests
|
||||||
|
import sqlite3
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk, messagebox, simpledialog
|
||||||
|
from PIL import Image, ImageTk
|
||||||
|
|
||||||
|
from mtg_api import init_cache_db, get_card_by_name, search_cards
|
||||||
|
from deck_manager import save_deck as dm_save_deck, load_deck, list_saved_decks
|
||||||
|
from models import Deck, Card
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Helper to detect lands
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def is_land(card: Card) -> bool:
|
||||||
|
return "Land" in card.type_line
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Main Application Window
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class MTGDeckBuilder(tk.Tk):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.title("MTG Deck Builder")
|
||||||
|
self.geometry("1000x600")
|
||||||
|
|
||||||
|
# Ensure cache DB exists
|
||||||
|
init_cache_db()
|
||||||
|
|
||||||
|
# Currently loaded Deck
|
||||||
|
self.current_deck: Deck | None = None
|
||||||
|
|
||||||
|
# Local in‐memory cache: card_name → Card object
|
||||||
|
self.card_cache: dict[str, Card] = {}
|
||||||
|
|
||||||
|
# Keep references to PhotoImage so they do not get garbage‐collected
|
||||||
|
self.preview_photo: ImageTk.PhotoImage | None = None
|
||||||
|
self.color_icon_images: dict[str, ImageTk.PhotoImage] = {}
|
||||||
|
|
||||||
|
# Build UI components
|
||||||
|
self._load_color_icons()
|
||||||
|
self._build_widgets()
|
||||||
|
self._layout_widgets()
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Pre‐load color icon images from local `assets/icons`
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def _load_color_icons(self):
|
||||||
|
"""
|
||||||
|
Expects five PNG files in assets/icons: W.png, U.png, B.png, R.png, G.png
|
||||||
|
"""
|
||||||
|
icon_folder = os.path.join("assets", "icons")
|
||||||
|
for symbol in ["W", "U", "B", "R", "G"]:
|
||||||
|
path = os.path.join(icon_folder, f"{symbol}.png")
|
||||||
|
if os.path.isfile(path):
|
||||||
|
img = Image.open(path).resize((32, 32), Image.LANCZOS)
|
||||||
|
self.color_icon_images[symbol] = ImageTk.PhotoImage(img)
|
||||||
|
else:
|
||||||
|
self.color_icon_images[symbol] = None # missing icon silently
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Create all widgets
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def _build_widgets(self):
|
||||||
|
# -- Deck Controls -------------------------------------------------------
|
||||||
|
self.deck_frame = ttk.LabelFrame(self, text="Deck Controls", padding=10)
|
||||||
|
self.new_deck_btn = ttk.Button(self.deck_frame, text="New Deck", command=self.new_deck)
|
||||||
|
self.load_deck_btn = ttk.Button(self.deck_frame, text="Load Deck", command=self.load_deck)
|
||||||
|
self.save_deck_btn = ttk.Button(self.deck_frame, text="Save Deck", command=self.save_deck)
|
||||||
|
self.deck_name_label = ttk.Label(self.deck_frame, text="(no deck loaded)")
|
||||||
|
|
||||||
|
# -- A container for the two main panels (Search and Deck) -------------
|
||||||
|
self.content_frame = ttk.Frame(self)
|
||||||
|
|
||||||
|
# -- Search / Add Cards -----------------------------------------------
|
||||||
|
self.search_frame = ttk.LabelFrame(self.content_frame, text="Search / Add Cards", padding=10)
|
||||||
|
self.search_entry = ttk.Entry(self.search_frame, width=40)
|
||||||
|
self.search_btn = ttk.Button(self.search_frame, text="Search", command=self.perform_search)
|
||||||
|
|
||||||
|
self.results_list = tk.Listbox(self.search_frame, height=12, width=50)
|
||||||
|
self.result_scroll = ttk.Scrollbar(self.search_frame, orient="vertical", command=self.results_list.yview)
|
||||||
|
self.results_list.configure(yscrollcommand=self.result_scroll.set)
|
||||||
|
self.results_list.bind("<<ListboxSelect>>", self.on_result_select)
|
||||||
|
|
||||||
|
self.add_qty_label = ttk.Label(self.search_frame, text="Quantity:")
|
||||||
|
self.add_qty_spin = ttk.Spinbox(self.search_frame, from_=1, to=20, width=5)
|
||||||
|
self.add_card_btn = ttk.Button(self.search_frame, text="Add to Deck", command=self.add_card_to_deck)
|
||||||
|
|
||||||
|
# -- Card Preview (image + color icons) --------------------------------
|
||||||
|
self.preview_frame = ttk.LabelFrame(self.search_frame, text="Card Preview", padding=10)
|
||||||
|
# Label to show the card image
|
||||||
|
self.card_image_label = ttk.Label(self.preview_frame)
|
||||||
|
# Frame to hold 0–5 color icons horizontally
|
||||||
|
self.color_icons_frame = ttk.Frame(self.preview_frame)
|
||||||
|
|
||||||
|
# -- Deck Contents -----------------------------------------------------
|
||||||
|
self.deck_view_frame = ttk.LabelFrame(self.content_frame, text="Deck Contents", padding=10)
|
||||||
|
self.deck_list = tk.Listbox(self.deck_view_frame, height=20, width=40)
|
||||||
|
self.deck_scroll = ttk.Scrollbar(self.deck_view_frame, orient="vertical", command=self.deck_list.yview)
|
||||||
|
self.deck_list.configure(yscrollcommand=self.deck_scroll.set)
|
||||||
|
self.deck_list.bind("<<ListboxSelect>>", self.on_deck_select)
|
||||||
|
|
||||||
|
self.remove_card_btn = ttk.Button(self.deck_view_frame, text="Remove Selected", command=self.remove_selected)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Arrange everything in the proper geometry
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def _layout_widgets(self):
|
||||||
|
# 1) Top ─ Deck controls (packed)
|
||||||
|
self.deck_frame.pack(fill="x", padx=10, pady=5)
|
||||||
|
self.new_deck_btn.grid(row=0, column=0, padx=5, pady=5)
|
||||||
|
self.load_deck_btn.grid(row=0, column=1, padx=5, pady=5)
|
||||||
|
self.save_deck_btn.grid(row=0, column=2, padx=5, pady=5)
|
||||||
|
self.deck_name_label.grid(row=0, column=3, padx=10, pady=5, sticky="w")
|
||||||
|
|
||||||
|
# 2) Middle ─ content_frame (packed)
|
||||||
|
self.content_frame.pack(fill="both", expand=True, padx=10, pady=5)
|
||||||
|
self.content_frame.columnconfigure(0, weight=1)
|
||||||
|
self.content_frame.columnconfigure(1, weight=0)
|
||||||
|
self.content_frame.rowconfigure(0, weight=1)
|
||||||
|
|
||||||
|
# --- Left panel: Search / Add Cards (gridded inside content_frame) ---
|
||||||
|
self.search_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 10))
|
||||||
|
self.search_frame.columnconfigure(0, weight=1)
|
||||||
|
self.search_frame.rowconfigure(1, weight=1)
|
||||||
|
|
||||||
|
self.search_entry.grid(row=0, column=0, padx=5, pady=5, sticky="w")
|
||||||
|
self.search_btn.grid(row=0, column=1, padx=5, pady=5, sticky="w")
|
||||||
|
|
||||||
|
self.results_list.grid(row=1, column=0, columnspan=2, padx=(5, 0), pady=5, sticky="nsew")
|
||||||
|
self.result_scroll.grid(row=1, column=2, sticky="ns", pady=5)
|
||||||
|
|
||||||
|
self.add_qty_label.grid(row=2, column=0, padx=5, pady=5, sticky="w")
|
||||||
|
self.add_qty_spin.grid(row=2, column=1, padx=5, pady=5, sticky="w")
|
||||||
|
self.add_card_btn.grid(row=2, column=2, padx=5, pady=5, sticky="w")
|
||||||
|
|
||||||
|
# Preview frame sits below everything in the search_frame
|
||||||
|
self.preview_frame.grid(row=3, column=0, columnspan=3, padx=5, pady=10, sticky="nsew")
|
||||||
|
self.preview_frame.columnconfigure(0, weight=1)
|
||||||
|
self.preview_frame.rowconfigure(0, weight=1)
|
||||||
|
|
||||||
|
self.card_image_label.grid(row=0, column=0, padx=5, pady=5)
|
||||||
|
self.color_icons_frame.grid(row=1, column=0, padx=5, pady=5)
|
||||||
|
|
||||||
|
# --- Right panel: Deck Contents (gridded inside content_frame) ---
|
||||||
|
self.deck_view_frame.grid(row=0, column=1, sticky="nsew")
|
||||||
|
self.deck_view_frame.columnconfigure(0, weight=1)
|
||||||
|
self.deck_view_frame.rowconfigure(0, weight=1)
|
||||||
|
|
||||||
|
self.deck_list.grid(row=0, column=0, padx=(5, 0), pady=5, sticky="nsew")
|
||||||
|
self.deck_scroll.grid(row=0, column=1, sticky="ns", pady=5)
|
||||||
|
self.remove_card_btn.grid(row=1, column=0, padx=5, pady=5, sticky="w")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Create a brand‐new Deck
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def new_deck(self):
|
||||||
|
name = simpledialog.askstring("New Deck", "Enter deck name:", parent=self)
|
||||||
|
if not name:
|
||||||
|
return
|
||||||
|
self.current_deck = Deck(name=name)
|
||||||
|
self.deck_name_label.config(text=f"Deck: {name} (0 cards)")
|
||||||
|
self._refresh_deck_list()
|
||||||
|
self._clear_preview()
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Load a saved Deck from disk
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def load_deck(self):
|
||||||
|
choices = list_saved_decks()
|
||||||
|
if not choices:
|
||||||
|
messagebox.showinfo("Load Deck", "No saved decks found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
name = simpledialog.askstring(
|
||||||
|
"Load Deck",
|
||||||
|
f"Available: {', '.join(choices)}\nEnter deck name:",
|
||||||
|
parent=self
|
||||||
|
)
|
||||||
|
if not name:
|
||||||
|
return
|
||||||
|
deck = load_deck(name)
|
||||||
|
if deck:
|
||||||
|
self.current_deck = deck
|
||||||
|
self.deck_name_label.config(text=f"Deck: {deck.name} ({deck.total_cards()} cards)")
|
||||||
|
self._refresh_deck_list()
|
||||||
|
self._clear_preview()
|
||||||
|
else:
|
||||||
|
messagebox.showerror("Error", f"Deck '{name}' not found.")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Save current Deck to disk
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def save_deck(self):
|
||||||
|
if not self.current_deck:
|
||||||
|
messagebox.showwarning("Save Deck", "No deck loaded.")
|
||||||
|
return
|
||||||
|
dm_save_deck(self.current_deck)
|
||||||
|
messagebox.showinfo("Save Deck", f"Deck '{self.current_deck.name}' saved.")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Perform a Scryfall search and populate the listbox
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def perform_search(self):
|
||||||
|
query = self.search_entry.get().strip()
|
||||||
|
if not query:
|
||||||
|
return
|
||||||
|
self.results_list.delete(0, tk.END)
|
||||||
|
results = search_cards(query)
|
||||||
|
if not results:
|
||||||
|
self.results_list.insert(tk.END, "(no results)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Cache Card objects in memory
|
||||||
|
for card in results:
|
||||||
|
self.card_cache[card.name] = card
|
||||||
|
|
||||||
|
for card in results:
|
||||||
|
display = f"{card.name} • {card.mana_cost or ''} • {card.type_line} [{card.rarity}]"
|
||||||
|
self.results_list.insert(tk.END, display)
|
||||||
|
|
||||||
|
# Clear any old preview
|
||||||
|
self._clear_preview()
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# When a search result is selected, show color icons + preview image
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def on_result_select(self, event):
|
||||||
|
sel = self.results_list.curselection()
|
||||||
|
if not sel:
|
||||||
|
return
|
||||||
|
index = sel[0]
|
||||||
|
display = self.results_list.get(index)
|
||||||
|
# Extract card name (everything before first " • ")
|
||||||
|
card_name = display.split(" • ")[0].strip()
|
||||||
|
|
||||||
|
card = self.card_cache.get(card_name) or get_card_by_name(card_name)
|
||||||
|
if not card:
|
||||||
|
return
|
||||||
|
self.card_cache[card.name] = card
|
||||||
|
self._show_preview(card)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Show a given Card’s image + its color icons
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def _show_preview(self, card: Card):
|
||||||
|
# 1) Display color icons
|
||||||
|
for widget in self.color_icons_frame.winfo_children():
|
||||||
|
widget.destroy()
|
||||||
|
|
||||||
|
x = 0
|
||||||
|
for symbol in card.colors:
|
||||||
|
icon_img = self.color_icon_images.get(symbol)
|
||||||
|
if icon_img:
|
||||||
|
lbl = ttk.Label(self.color_icons_frame, image=icon_img)
|
||||||
|
lbl.image = icon_img
|
||||||
|
lbl.grid(row=0, column=x, padx=2)
|
||||||
|
x += 1
|
||||||
|
|
||||||
|
# 2) Fetch & display card image
|
||||||
|
if card.image_url:
|
||||||
|
try:
|
||||||
|
response = requests.get(card.image_url, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
img_data = response.content
|
||||||
|
image = Image.open(io.BytesIO(img_data))
|
||||||
|
# Resize to fit in ~250×350 area, preserving aspect ratio
|
||||||
|
image.thumbnail((250, 350), Image.LANCZOS)
|
||||||
|
photo = ImageTk.PhotoImage(image)
|
||||||
|
self.preview_photo = photo
|
||||||
|
self.card_image_label.config(image=photo, text="")
|
||||||
|
except Exception:
|
||||||
|
self.card_image_label.config(text="Could not load image", image="")
|
||||||
|
self.preview_photo = None
|
||||||
|
else:
|
||||||
|
self.card_image_label.config(text="No image available", image="")
|
||||||
|
self.preview_photo = None
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Clear preview panel (both icons & image)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def _clear_preview(self):
|
||||||
|
self.card_image_label.config(image="", text="")
|
||||||
|
for widget in self.color_icons_frame.winfo_children():
|
||||||
|
widget.destroy()
|
||||||
|
self.preview_photo = None
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Add the currently highlighted search‐result card into the deck
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def add_card_to_deck(self):
|
||||||
|
if not self.current_deck:
|
||||||
|
messagebox.showwarning("Add Card", "Create or load a deck first.")
|
||||||
|
return
|
||||||
|
sel = self.results_list.curselection()
|
||||||
|
if not sel:
|
||||||
|
messagebox.showwarning("Add Card", "Select a card from search results.")
|
||||||
|
return
|
||||||
|
index = sel[0]
|
||||||
|
display = self.results_list.get(index)
|
||||||
|
card_name = display.split(" • ")[0].strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
qty = int(self.add_qty_spin.get())
|
||||||
|
except ValueError:
|
||||||
|
messagebox.showerror("Invalid Quantity", "Enter a valid number.")
|
||||||
|
return
|
||||||
|
|
||||||
|
card = self.card_cache.get(card_name) or get_card_by_name(card_name)
|
||||||
|
if not card:
|
||||||
|
messagebox.showerror("Error", f"Card '{card_name}' not found.")
|
||||||
|
return
|
||||||
|
self.card_cache[card.name] = card
|
||||||
|
|
||||||
|
self.current_deck.add_card(card.name, qty)
|
||||||
|
total = self.current_deck.total_cards()
|
||||||
|
self.deck_name_label.config(text=f"Deck: {self.current_deck.name} ({total} cards)")
|
||||||
|
self._refresh_deck_list()
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# When a deck entry is selected, also preview its image + colors
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def on_deck_select(self, event):
|
||||||
|
sel = self.deck_list.curselection()
|
||||||
|
if not sel or not self.current_deck:
|
||||||
|
return
|
||||||
|
index = sel[0]
|
||||||
|
entry = self.deck_list.get(index)
|
||||||
|
# Format is "Qty× CardName [⚠]" or "Qty× CardName"
|
||||||
|
parts = entry.split("×", 1)
|
||||||
|
if len(parts) != 2:
|
||||||
|
return
|
||||||
|
_, rest = parts
|
||||||
|
card_name = rest.strip()
|
||||||
|
if card_name.endswith("⚠"):
|
||||||
|
card_name = card_name[:-1].strip()
|
||||||
|
|
||||||
|
card = self.card_cache.get(card_name) or get_card_by_name(card_name)
|
||||||
|
if not card:
|
||||||
|
return
|
||||||
|
self.card_cache[card.name] = card
|
||||||
|
self._show_preview(card)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Remove the selected card (all copies) from the deck
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def remove_selected(self):
|
||||||
|
if not self.current_deck:
|
||||||
|
return
|
||||||
|
sel = self.deck_list.curselection()
|
||||||
|
if not sel:
|
||||||
|
return
|
||||||
|
index = sel[0]
|
||||||
|
entry = self.deck_list.get(index)
|
||||||
|
parts = entry.split("×", 1)
|
||||||
|
if len(parts) != 2:
|
||||||
|
return
|
||||||
|
qty_str, rest = parts
|
||||||
|
try:
|
||||||
|
qty = int(qty_str.strip())
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
card_name = rest.strip()
|
||||||
|
if card_name.endswith("⚠"):
|
||||||
|
card_name = card_name[:-1].strip()
|
||||||
|
|
||||||
|
# Remove that many copies (which usually removes it entirely)
|
||||||
|
self.current_deck.remove_card(card_name, qty)
|
||||||
|
total = self.current_deck.total_cards()
|
||||||
|
self.deck_name_label.config(text=f"Deck: {self.current_deck.name} ({total} cards)")
|
||||||
|
self._refresh_deck_list()
|
||||||
|
self._clear_preview()
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Repopulate deck_list, showing "⚠" next to any non‐land with qty > 1
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def _refresh_deck_list(self):
|
||||||
|
self.deck_list.delete(0, tk.END)
|
||||||
|
if not self.current_deck:
|
||||||
|
return
|
||||||
|
for name, qty in self.current_deck.cards.items():
|
||||||
|
card = self.card_cache.get(name) or get_card_by_name(name)
|
||||||
|
if card:
|
||||||
|
self.card_cache[card.name] = card
|
||||||
|
flag = ""
|
||||||
|
if qty > 1 and not is_land(card):
|
||||||
|
flag = " ⚠"
|
||||||
|
display = f"{qty}× {card.name}{flag}"
|
||||||
|
else:
|
||||||
|
display = f"{qty}× {name}"
|
||||||
|
self.deck_list.insert(tk.END, display)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Launch the app
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Warn if icons folder is missing any color PNG
|
||||||
|
missing = []
|
||||||
|
for sym in ["W", "U", "B", "R", "G"]:
|
||||||
|
if not os.path.isfile(os.path.join("assets", "icons", f"{sym}.png")):
|
||||||
|
missing.append(sym)
|
||||||
|
if missing:
|
||||||
|
print(f"Warning: Missing color icon(s) for {missing} in assets/icons/.")
|
||||||
|
print("The rest of the GUI will still load, but those icons won't appear.")
|
||||||
|
app = MTGDeckBuilder()
|
||||||
|
app.mainloop()
|
60
models.py
Normal file
60
models.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# models.py
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Card:
|
||||||
|
"""Represents an MTG card (subset of Scryfall’s data), including color identity."""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
mana_cost: Optional[str]
|
||||||
|
type_line: str
|
||||||
|
oracle_text: Optional[str]
|
||||||
|
set_name: str
|
||||||
|
rarity: str
|
||||||
|
image_url: Optional[str]
|
||||||
|
colors: List[str] # e.g. ["R"], ["W","U"], or [] for colorless
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_scryfall_json(cls, data: dict) -> "Card":
|
||||||
|
return cls(
|
||||||
|
id=data["id"],
|
||||||
|
name=data["name"],
|
||||||
|
mana_cost=data.get("mana_cost"),
|
||||||
|
type_line=data["type_line"],
|
||||||
|
oracle_text=data.get("oracle_text"),
|
||||||
|
set_name=data["set_name"],
|
||||||
|
rarity=data["rarity"],
|
||||||
|
image_url=data.get("image_uris", {}).get("normal"),
|
||||||
|
colors=data.get("colors", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Deck:
|
||||||
|
"""Keeps track of cards and quantities in a deck."""
|
||||||
|
name: str
|
||||||
|
cards: dict[str, int] = field(default_factory=dict)
|
||||||
|
# Example: {"Lightning Bolt": 4, "Island": 24, ...}
|
||||||
|
|
||||||
|
def add_card(self, card_name: str, qty: int = 1):
|
||||||
|
self.cards[card_name] = self.cards.get(card_name, 0) + qty
|
||||||
|
|
||||||
|
def remove_card(self, card_name: str, qty: int = 1):
|
||||||
|
if card_name in self.cards:
|
||||||
|
new_qty = self.cards[card_name] - qty
|
||||||
|
if new_qty > 0:
|
||||||
|
self.cards[card_name] = new_qty
|
||||||
|
else:
|
||||||
|
del self.cards[card_name]
|
||||||
|
|
||||||
|
def total_cards(self) -> int:
|
||||||
|
return sum(self.cards.values())
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {"name": self.name, "cards": self.cards}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "Deck":
|
||||||
|
deck = cls(name=data["name"])
|
||||||
|
deck.cards = data["cards"]
|
||||||
|
return deck
|
79
mtg_api.py
Normal file
79
mtg_api.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# mtg_api.py
|
||||||
|
import requests
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
from models import Card
|
||||||
|
|
||||||
|
SCRYFALL_SEARCH_URL = "https://api.scryfall.com/cards/search"
|
||||||
|
SCRYFALL_CARD_URL = "https://api.scryfall.com/cards/named"
|
||||||
|
|
||||||
|
CACHE_DB_PATH = os.path.join("data", "cards_cache.sqlite")
|
||||||
|
|
||||||
|
def init_cache_db():
|
||||||
|
"""Create local SQLite DB (if not exists) with a simple table for cards."""
|
||||||
|
os.makedirs(os.path.dirname(CACHE_DB_PATH), exist_ok=True)
|
||||||
|
conn = sqlite3.connect(CACHE_DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS cards (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT UNIQUE,
|
||||||
|
json_data TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_card_by_name(name: str, use_cache: bool = True) -> Card | None:
|
||||||
|
"""
|
||||||
|
1. If use_cache is True, check SQLite for card JSON.
|
||||||
|
2. If not found, call Scryfall named endpoint, store JSON in cache, then return Card.
|
||||||
|
"""
|
||||||
|
if use_cache:
|
||||||
|
conn = sqlite3.connect(CACHE_DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("SELECT json_data FROM cards WHERE name = ?", (name.lower(),))
|
||||||
|
row = c.fetchone()
|
||||||
|
if row:
|
||||||
|
import json
|
||||||
|
data = json.loads(row[0])
|
||||||
|
conn.close()
|
||||||
|
return Card.from_scryfall_json(data)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Not in cache (or no cache). Fetch from Scryfall by exact name.
|
||||||
|
params = {"exact": name}
|
||||||
|
res = requests.get(SCRYFALL_CARD_URL, params=params)
|
||||||
|
if res.status_code != 200:
|
||||||
|
return None # card not found or API error
|
||||||
|
data = res.json()
|
||||||
|
|
||||||
|
# Insert into cache
|
||||||
|
if use_cache:
|
||||||
|
conn = sqlite3.connect(CACHE_DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
import json
|
||||||
|
c.execute(
|
||||||
|
"INSERT OR IGNORE INTO cards (id, name, json_data) VALUES (?, ?, ?)",
|
||||||
|
(data["id"], data["name"].lower(), json.dumps(data))
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return Card.from_scryfall_json(data)
|
||||||
|
|
||||||
|
def search_cards(query: str, use_cache: bool = False) -> list[Card]:
|
||||||
|
"""
|
||||||
|
Use Scryfall’s search endpoint. Returns up to 175 cards by default.
|
||||||
|
query examples:
|
||||||
|
- "name:Lightning Bolt"
|
||||||
|
- "type:creature cmc<=2"
|
||||||
|
- "c:red c:creature" (red creatures)
|
||||||
|
"""
|
||||||
|
params = {"q": query, "unique": "cards", "order": "name", "dir": "asc"}
|
||||||
|
res = requests.get(SCRYFALL_SEARCH_URL, params=params)
|
||||||
|
if res.status_code != 200:
|
||||||
|
return []
|
||||||
|
data = res.json()
|
||||||
|
card_list = [Card.from_scryfall_json(card) for card in data.get("data", [])]
|
||||||
|
return card_list
|
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
requests>=2.28
|
||||||
|
SQLAlchemy>=2.0 # if you’re still using SQLAlchemy for caching; otherwise use stdlib sqlite3
|
||||||
|
Pillow>=9.0
|
Reference in New Issue
Block a user