diff --git a/routes/shared.py b/routes/shared.py index 2ff3094..e2f4760 100644 --- a/routes/shared.py +++ b/routes/shared.py @@ -140,257 +140,12 @@ from models.responses import ( ) -# ══════════════════════════════════════════════════════════════ -# Collection Database -# ══════════════════════════════════════════════════════════════ - -COLLECTION_DB_PATH = Path(__file__).parent.parent / "collection.db" -_db_local = threading.local() - - -def _get_db_conn() -> sqlite3.Connection: - if not hasattr(_db_local, "conn") or _db_local.conn is None: - conn = sqlite3.connect(str(COLLECTION_DB_PATH), check_same_thread=False) - conn.row_factory = sqlite3.Row - _db_local.conn = conn - return _db_local.conn - - -def init_collection_db(): - conn = sqlite3.connect(str(COLLECTION_DB_PATH), check_same_thread=False) - conn.row_factory = sqlite3.Row - cur = conn.cursor() - cur.executescript(""" - CREATE TABLE IF NOT EXISTS card_records ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - type_line TEXT DEFAULT '', - subtypes TEXT DEFAULT '', - is_legendary INTEGER DEFAULT 0, - is_basic INTEGER DEFAULT 0, - color_identity TEXT DEFAULT '', - cmc REAL DEFAULT 0, - oracle_text TEXT DEFAULT '', - keywords TEXT DEFAULT '', - tcg_price REAL DEFAULT 0, - salt_score REAL DEFAULT 0, - is_game_changer INTEGER DEFAULT 0, - category TEXT DEFAULT '', - scryfall_id TEXT DEFAULT '', - tcgplayer_id TEXT DEFAULT '' - ); - - CREATE TABLE IF NOT EXISTS collection_entries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - type_line TEXT DEFAULT '', - subtypes TEXT DEFAULT '', - is_legendary INTEGER DEFAULT 0, - is_basic INTEGER DEFAULT 0, - color_identity TEXT DEFAULT '', - cmc REAL DEFAULT 0, - mana_cost TEXT DEFAULT '', - oracle_text TEXT DEFAULT '', - keywords TEXT DEFAULT '', - power TEXT DEFAULT '', - toughness TEXT DEFAULT '', - rarity TEXT DEFAULT '', - set_name TEXT DEFAULT '', - edhrec_rank INTEGER DEFAULT 0, - tcg_price REAL DEFAULT 0, - salt_score REAL DEFAULT 0, - is_game_changer INTEGER DEFAULT 0, - category TEXT DEFAULT '', - scryfall_id TEXT DEFAULT '', - tcgplayer_id TEXT DEFAULT '', - quantity INTEGER DEFAULT 1, - finish TEXT DEFAULT 'NORMAL', - condition TEXT DEFAULT '', - language TEXT DEFAULT '', - notes TEXT DEFAULT '', - tags TEXT DEFAULT '', - set_code TEXT DEFAULT '', - collector_number TEXT DEFAULT '', - created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')) - ); - - CREATE INDEX IF NOT EXISTS idx_ce_name ON collection_entries(name); - CREATE INDEX IF NOT EXISTS idx_ce_color_identity ON collection_entries(color_identity); - CREATE INDEX IF NOT EXISTS idx_ce_type_line ON collection_entries(type_line); - CREATE INDEX IF NOT EXISTS idx_ce_is_legendary ON collection_entries(is_legendary); - CREATE INDEX IF NOT EXISTS idx_ce_cmc ON collection_entries(cmc); - CREATE INDEX IF NOT EXISTS idx_ce_category ON collection_entries(category); - CREATE INDEX IF NOT EXISTS idx_ce_set_code ON collection_entries(set_code); - CREATE INDEX IF NOT EXISTS idx_ce_collector_number ON collection_entries(collector_number); - CREATE INDEX IF NOT EXISTS idx_ce_finish ON collection_entries(finish); - CREATE INDEX IF NOT EXISTS idx_cr_name ON card_records(name); - - CREATE TABLE IF NOT EXISTS decks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - commander_scryfall_id TEXT DEFAULT '', - commander_name TEXT DEFAULT '', - color_identity TEXT DEFAULT '[]', - strategy_tag TEXT DEFAULT '', - created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')) - ); - - CREATE TABLE IF NOT EXISTS deck_cards ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - deck_id INTEGER NOT NULL, - scryfall_id TEXT NOT NULL, - card_name TEXT DEFAULT '', - quantity INTEGER DEFAULT 1, - is_commander INTEGER DEFAULT 0, - role_tag TEXT DEFAULT '', - FOREIGN KEY (deck_id) REFERENCES decks(id) ON DELETE CASCADE - ); - - CREATE INDEX IF NOT EXISTS idx_dc_deck_id ON deck_cards(deck_id); - CREATE INDEX IF NOT EXISTS idx_dc_scryfall_id ON deck_cards(scryfall_id); - CREATE INDEX IF NOT EXISTS idx_dk_commander ON decks(commander_scryfall_id); - """) - conn.commit() - _migrate_columns = [ - ("mana_cost", "TEXT DEFAULT ''"), - ("power", "TEXT DEFAULT ''"), - ("toughness", "TEXT DEFAULT ''"), - ("rarity", "TEXT DEFAULT ''"), - ("set_name", "TEXT DEFAULT ''"), - ("edhrec_rank", "INTEGER DEFAULT 0"), - ] - existing_cols = {row[1] for row in conn.execute("PRAGMA table_info(collection_entries)").fetchall()} - for col_name, col_def in _migrate_columns: - if col_name not in existing_cols: - conn.execute(f"ALTER TABLE collection_entries ADD COLUMN {col_name} {col_def}") - log_collect.info(f" Migration: added column '{col_name}' to collection_entries") - conn.commit() - conn.close() - log_collect.info(f" Collection DB: {COLLECTION_DB_PATH}") - - -# ══════════════════════════════════════════════════════════════ -# Collection Utility Helpers -# ══════════════════════════════════════════════════════════════ - -_JSON_FIELDS = ("category", "color_identity", "subtypes", "keywords") -VALID_SORT_FIELDS = {"name", "cmc", "tcg_price", "salt_score", "category", "color_identity", "quantity", "type_line", "finish", "rarity", "set_code", "power", "toughness", "edhrec_rank"} - - -def _row_to_dict(row) -> dict: - if row is None: - return None - d = dict(row) - for f in _JSON_FIELDS: - if f in d and isinstance(d[f], str): - try: - d[f] = json.loads(d[f]) - except (json.JSONDecodeError, TypeError): - d[f] = [] - return d - - -def _snake_to_camel(s: str) -> str: - parts = s.split("_") - return parts[0] + "".join(p.capitalize() for p in parts[1:]) - - -def _add_image_url(card: dict) -> dict: - scryfall_id = card.get("scryfall_id", "") - if scryfall_id: - card["imageUrl"] = f"https://api.scryfall.com/cards/{scryfall_id}?format=image&version=normal" - else: - card["imageUrl"] = None - for key in list(card.keys()): - if "_" in key: - camel = _snake_to_camel(key) - if camel not in card: - card[camel] = card[key] - return card - - -def _build_collection_filters( - q=None, colors=None, types=None, isLegendary=None, isBasic=None, - isGameChanger=None, highSalt=None, finish=None, cmcMin=None, cmcMax=None, - priceMin=None, priceMax=None, category=None, rarity=None, setCode=None, - powerMin=None, powerMax=None, toughMin=None, toughMax=None, keyword=None, - edhrecMin=None, edhrecMax=None, qtyMin=None, qtyMax=None, -) -> tuple: - conditions = [] - params = [] - if q: - like_q = f"%{q}%" - conditions.append("(name LIKE ? OR type_line LIKE ? OR oracle_text LIKE ?)") - params.extend([like_q, like_q, like_q]) - if colors: - for color in [c.strip().upper() for c in colors.split(",") if c.strip()]: - conditions.append("color_identity LIKE ?") - params.append(f'%"{color}"%') - if types: - tc = [] - for t in [t.strip() for t in types.split(",") if t.strip()]: - tc.append("type_line LIKE ?") - params.append(f"%{t}%") - if tc: - conditions.append(f"({' OR '.join(tc)})") - if isLegendary is not None: - conditions.append("is_legendary = ?"); params.append(1 if isLegendary else 0) - if isBasic is not None: - conditions.append("is_basic = ?"); params.append(1 if isBasic else 0) - if isGameChanger is not None: - conditions.append("is_game_changer = ?"); params.append(1 if isGameChanger else 0) - if highSalt: - conditions.append("salt_score > 2.0") - if finish: - conditions.append("finish = ?"); params.append(finish.upper()) - if cmcMin is not None: - conditions.append("cmc >= ?"); params.append(cmcMin) - if cmcMax is not None: - conditions.append("cmc <= ?"); params.append(cmcMax) - if priceMin is not None: - conditions.append("tcg_price >= ?"); params.append(priceMin) - if priceMax is not None: - conditions.append("tcg_price <= ?"); params.append(priceMax) - if category: - cc = [] - for c in [c.strip() for c in category.split(",") if c.strip()]: - cc.append("category LIKE ?"); params.append(f'%"{c}"%') - if cc: - conditions.append(f"({' OR '.join(cc)})") - if rarity: - rl = [r.strip().lower() for r in rarity.split(",") if r.strip()] - if rl: - conditions.append(f"LOWER(rarity) IN ({','.join(['?']*len(rl))})"); params.extend(rl) - if setCode: - sl = [s.strip().upper() for s in setCode.split(",") if s.strip()] - if sl: - conditions.append(f"UPPER(set_code) IN ({','.join(['?']*len(sl))})"); params.extend(sl) - if powerMin is not None: - conditions.append("CAST(power AS REAL) >= ?"); params.append(float(powerMin)) - if powerMax is not None: - conditions.append("CAST(power AS REAL) <= ?"); params.append(float(powerMax)) - if toughMin is not None: - conditions.append("CAST(toughness AS REAL) >= ?"); params.append(float(toughMin)) - if toughMax is not None: - conditions.append("CAST(toughness AS REAL) <= ?"); params.append(float(toughMax)) - if keyword: - for kw in [k.strip() for k in keyword.split(",") if k.strip()]: - conditions.append("keywords LIKE ?"); params.append(f"%{kw}%") - if edhrecMin is not None: - conditions.append("edhrec_rank >= ?"); params.append(edhrecMin) - if edhrecMax is not None: - conditions.append("edhrec_rank <= ?"); params.append(edhrecMax) - if qtyMin is not None: - conditions.append("quantity >= ?"); params.append(qtyMin) - if qtyMax is not None: - conditions.append("quantity <= ?"); params.append(qtyMax) - where_str = ("WHERE " + " AND ".join(conditions)) if conditions else "" - return where_str, params - - -# ══════════════════════════════════════════════════════════════ +# Collection Database (imported from services/database.py) +from services.database import ( + COLLECTION_DB_PATH, _get_db_conn, init_collection_db, + _JSON_FIELDS, VALID_SORT_FIELDS, + _row_to_dict, _snake_to_camel, _add_image_url, _build_collection_filters, +) # Card Role Detection # ══════════════════════════════════════════════════════════════ diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..f346b8a --- /dev/null +++ b/services/__init__.py @@ -0,0 +1 @@ +"""Services for Commander AI Lab.""" diff --git a/services/database.py b/services/database.py new file mode 100644 index 0000000..70b0852 --- /dev/null +++ b/services/database.py @@ -0,0 +1,267 @@ +"""SQLite database management and collection utility helpers.""" +from __future__ import annotations +import io +import json +import logging +import re +import sqlite3 +import threading +from pathlib import Path +from typing import Optional + +log_collect = logging.getLogger("commander_ai_lab.collection") + + +# ══════════════════════════════════════════════════════════════ +# Collection Database +# ══════════════════════════════════════════════════════════════ +COLLECTION_DB_PATH = Path(__file__).parent.parent / "collection.db" +_db_local = threading.local() + + +def _get_db_conn() -> sqlite3.Connection: + if not hasattr(_db_local, "conn") or _db_local.conn is None: + conn = sqlite3.connect(str(COLLECTION_DB_PATH), check_same_thread=False) + conn.row_factory = sqlite3.Row + _db_local.conn = conn + return _db_local.conn + + +def init_collection_db(): + conn = sqlite3.connect(str(COLLECTION_DB_PATH), check_same_thread=False) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.executescript(""" + CREATE TABLE IF NOT EXISTS card_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type_line TEXT DEFAULT '', + subtypes TEXT DEFAULT '', + is_legendary INTEGER DEFAULT 0, + is_basic INTEGER DEFAULT 0, + color_identity TEXT DEFAULT '', + cmc REAL DEFAULT 0, + oracle_text TEXT DEFAULT '', + keywords TEXT DEFAULT '', + tcg_price REAL DEFAULT 0, + salt_score REAL DEFAULT 0, + is_game_changer INTEGER DEFAULT 0, + category TEXT DEFAULT '', + scryfall_id TEXT DEFAULT '', + tcgplayer_id TEXT DEFAULT '' + ); + + CREATE TABLE IF NOT EXISTS collection_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type_line TEXT DEFAULT '', + subtypes TEXT DEFAULT '', + is_legendary INTEGER DEFAULT 0, + is_basic INTEGER DEFAULT 0, + color_identity TEXT DEFAULT '', + cmc REAL DEFAULT 0, + mana_cost TEXT DEFAULT '', + oracle_text TEXT DEFAULT '', + keywords TEXT DEFAULT '', + power TEXT DEFAULT '', + toughness TEXT DEFAULT '', + rarity TEXT DEFAULT '', + set_name TEXT DEFAULT '', + edhrec_rank INTEGER DEFAULT 0, + tcg_price REAL DEFAULT 0, + salt_score REAL DEFAULT 0, + is_game_changer INTEGER DEFAULT 0, + category TEXT DEFAULT '', + scryfall_id TEXT DEFAULT '', + tcgplayer_id TEXT DEFAULT '', + quantity INTEGER DEFAULT 1, + finish TEXT DEFAULT 'NORMAL', + condition TEXT DEFAULT '', + language TEXT DEFAULT '', + notes TEXT DEFAULT '', + tags TEXT DEFAULT '', + set_code TEXT DEFAULT '', + collector_number TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_ce_name ON collection_entries(name); + CREATE INDEX IF NOT EXISTS idx_ce_color_identity ON collection_entries(color_identity); + CREATE INDEX IF NOT EXISTS idx_ce_type_line ON collection_entries(type_line); + CREATE INDEX IF NOT EXISTS idx_ce_is_legendary ON collection_entries(is_legendary); + CREATE INDEX IF NOT EXISTS idx_ce_cmc ON collection_entries(cmc); + CREATE INDEX IF NOT EXISTS idx_ce_category ON collection_entries(category); + CREATE INDEX IF NOT EXISTS idx_ce_set_code ON collection_entries(set_code); + CREATE INDEX IF NOT EXISTS idx_ce_collector_number ON collection_entries(collector_number); + CREATE INDEX IF NOT EXISTS idx_ce_finish ON collection_entries(finish); + CREATE INDEX IF NOT EXISTS idx_cr_name ON card_records(name); + + CREATE TABLE IF NOT EXISTS decks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + commander_scryfall_id TEXT DEFAULT '', + commander_name TEXT DEFAULT '', + color_identity TEXT DEFAULT '[]', + strategy_tag TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS deck_cards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + deck_id INTEGER NOT NULL, + scryfall_id TEXT NOT NULL, + card_name TEXT DEFAULT '', + quantity INTEGER DEFAULT 1, + is_commander INTEGER DEFAULT 0, + role_tag TEXT DEFAULT '', + FOREIGN KEY (deck_id) REFERENCES decks(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_dc_deck_id ON deck_cards(deck_id); + CREATE INDEX IF NOT EXISTS idx_dc_scryfall_id ON deck_cards(scryfall_id); + CREATE INDEX IF NOT EXISTS idx_dk_commander ON decks(commander_scryfall_id); + """) + conn.commit() + _migrate_columns = [ + ("mana_cost", "TEXT DEFAULT ''"), + ("power", "TEXT DEFAULT ''"), + ("toughness", "TEXT DEFAULT ''"), + ("rarity", "TEXT DEFAULT ''"), + ("set_name", "TEXT DEFAULT ''"), + ("edhrec_rank", "INTEGER DEFAULT 0"), + ] + existing_cols = {row[1] for row in conn.execute("PRAGMA table_info(collection_entries)").fetchall()} + for col_name, col_def in _migrate_columns: + if col_name not in existing_cols: + conn.execute(f"ALTER TABLE collection_entries ADD COLUMN {col_name} {col_def}") + log_collect.info(f" Migration: added column '{col_name}' to collection_entries") + conn.commit() + conn.close() + log_collect.info(f" Collection DB: {COLLECTION_DB_PATH}") + + +# ══════════════════════════════════════════════════════════════ +# Collection Utility Helpers +# ══════════════════════════════════════════════════════════════ +_JSON_FIELDS = ("category", "color_identity", "subtypes", "keywords") + +VALID_SORT_FIELDS = { + "name", "cmc", "tcg_price", "salt_score", "category", "color_identity", + "quantity", "type_line", "finish", "rarity", "set_code", "power", + "toughness", "edhrec_rank", +} + + + + +def _row_to_dict(row) -> dict: + if row is None: + return None + d = dict(row) + for f in _JSON_FIELDS: + if f in d and isinstance(d[f], str): + try: + d[f] = json.loads(d[f]) + except (json.JSONDecodeError, TypeError): + d[f] = [] + return d + + + def _snake_to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.capitalize() for p in parts[1:]) + + + def _add_image_url(card: dict) -> dict: + scryfall_id = card.get("scryfall_id", "") + if scryfall_id: + card["imageUrl"] = f"https://api.scryfall.com/cards/{scryfall_id}?format=image&version=normal" + else: + card["imageUrl"] = None + for key in list(card.keys()): + if "_" in key: + camel = _snake_to_camel(key) + if camel not in card: + card[camel] = card[key] + return card + + + def _build_collection_filters( + q=None, colors=None, types=None, isLegendary=None, isBasic=None, + isGameChanger=None, highSalt=None, finish=None, cmcMin=None, cmcMax=None, + priceMin=None, priceMax=None, category=None, rarity=None, setCode=None, + powerMin=None, powerMax=None, toughMin=None, toughMax=None, keyword=None, + edhrecMin=None, edhrecMax=None, qtyMin=None, qtyMax=None, +) -> tuple: + conditions = [] + params = [] + if q: + like_q = f"%{q}%" + conditions.append("(name LIKE ? OR type_line LIKE ? OR oracle_text LIKE ?)") + params.extend([like_q, like_q, like_q]) + if colors: + for color in [c.strip().upper() for c in colors.split(",") if c.strip()]: + conditions.append("color_identity LIKE ?") + params.append(f"%{color}%") + if types: + tc = [] + for t in [t.strip() for t in types.split(",") if t.strip()]: + tc.append("type_line LIKE ?") + params.append(f"%{t}%") + if tc: + conditions.append(f"({' OR '.join(tc)})") + if isLegendary is not None: + conditions.append("is_legendary = ?"); params.append(1 if isLegendary else 0) + if isBasic is not None: + conditions.append("is_basic = ?"); params.append(1 if isBasic else 0) + if isGameChanger is not None: + conditions.append("is_game_changer = ?"); params.append(1 if isGameChanger else 0) + if highSalt: + conditions.append("salt_score > 2.0") + if finish: + conditions.append("finish = ?"); params.append(finish.upper()) + if cmcMin is not None: + conditions.append("cmc >= ?"); params.append(cmcMin) + if cmcMax is not None: + conditions.append("cmc <= ?"); params.append(cmcMax) + if priceMin is not None: + conditions.append("tcg_price >= ?"); params.append(priceMin) + if priceMax is not None: + conditions.append("tcg_price <= ?"); params.append(priceMax) + if category: + cc = [] + for c in [c.strip() for c in category.split(",") if c.strip()]: + cc.append("category LIKE ?"); params.append(f"%{c}%") + if cc: + conditions.append(f"({' OR '.join(cc)})") + if rarity: + rl = [r.strip().lower() for r in rarity.split(",") if r.strip()] + if rl: + conditions.append(f"LOWER(rarity) IN ({','.join(['?']*len(rl))})"); params.extend(rl) + if setCode: + sl = [s.strip().upper() for s in setCode.split(",") if s.strip()] + if sl: + conditions.append(f"UPPER(set_code) IN ({','.join(['?']*len(sl))})"); params.extend(sl) + if powerMin is not None: + conditions.append("CAST(power AS REAL) >= ?"); params.append(float(powerMin)) + if powerMax is not None: + conditions.append("CAST(power AS REAL) <= ?"); params.append(float(powerMax)) + if toughMin is not None: + conditions.append("CAST(toughness AS REAL) >= ?"); params.append(float(toughMin)) + if toughMax is not None: + conditions.append("CAST(toughness AS REAL) <= ?"); params.append(float(toughMax)) + if keyword: + for kw in [k.strip() for k in keyword.split(",") if k.strip()]: + conditions.append("keywords LIKE ?"); params.append(f"%{kw}%") + if edhrecMin is not None: + conditions.append("edhrec_rank >= ?"); params.append(edhrecMin) + if edhrecMax is not None: + conditions.append("edhrec_rank <= ?"); params.append(edhrecMax) + if qtyMin is not None: + conditions.append("quantity >= ?"); params.append(qtyMin) + if qtyMax is not None: + conditions.append("quantity <= ?"); params.append(qtyMax) + where_str = ("WHERE " + " AND ".join(conditions)) if conditions else "" + return where_str, params \ No newline at end of file