From b537a2f84491bab7a7b86a908e008fc51b217f3c Mon Sep 17 00:00:00 2001 From: Dani Date: Tue, 3 Jun 2025 17:00:24 -0400 Subject: [PATCH] Creating a program to allow me to better manage my Magic cards and even make decks from it. --- .gitignore | 195 +++++++++++++ LICENSE | 21 ++ README.md | 0 collection_manager.py | 597 ++++++++++++++++++++++++++++++++++++++++ data/cards_cache.sqlite | Bin 0 -> 16384 bytes deck_manager.py | 25 ++ main.py | 408 +++++++++++++++++++++++++++ models.py | 60 ++++ mtg_api.py | 79 ++++++ requirements.txt | 3 + 10 files changed, 1388 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 collection_manager.py create mode 100644 data/cards_cache.sqlite create mode 100644 deck_manager.py create mode 100644 main.py create mode 100644 models.py create mode 100644 mtg_api.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2cd3f63 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fb5f6af --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/collection_manager.py b/collection_manager.py new file mode 100644 index 0000000..0dc382c --- /dev/null +++ b/collection_manager.py @@ -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("<>", 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("<>", 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() diff --git a/data/cards_cache.sqlite b/data/cards_cache.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..f4e6e63f2e376951fa2cb08aa5d728c61bd9f51d GIT binary patch literal 16384 zcmeI#F-yZh6u|Mjrl=H3vSq!|fC%a=7$rlsij67SDV!!kq)o7i;NsA)((mKf$v44Bs zw?BksJHmGCcYC?1lzjvcKmY**5I_I{1Q0*~0R;X}U}4K`*LCIMtsi`c*Q%8jQxl`sq_y 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")] diff --git a/main.py b/main.py new file mode 100644 index 0000000..f04087a --- /dev/null +++ b/main.py @@ -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("<>", 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("<>", 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() diff --git a/models.py b/models.py new file mode 100644 index 0000000..a5b0b47 --- /dev/null +++ b/models.py @@ -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 diff --git a/mtg_api.py b/mtg_api.py new file mode 100644 index 0000000..14a956b --- /dev/null +++ b/mtg_api.py @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..978222f --- /dev/null +++ b/requirements.txt @@ -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