From a8dc1835eb030452c7bdb7e065b1dd6f344979c7 Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 16 Oct 2025 19:02:33 -0700 Subject: [PATCH 1/2] feat(card-browser): advanced filters, keyboard shortcuts, and responsive design --- CHANGELOG.md | 23 +- README.md | 14 + RELEASE_NOTES_TEMPLATE.md | 16 +- code/scripts/generate_theme_catalog.py | 30 +- code/web/app.py | 9 + code/web/routes/card_browser.py | 1122 +++++++++++++++++ code/web/static/styles.css | 336 +++++ code/web/templates/base.html | 1 + .../templates/browse/cards/_card_grid.html | 22 + .../templates/browse/cards/_card_tile.html | 67 + code/web/templates/browse/cards/index.html | 958 ++++++++++++++ code/web/templates/home.html | 1 + 12 files changed, 2591 insertions(+), 8 deletions(-) create mode 100644 code/web/routes/card_browser.py create mode 100644 code/web/templates/browse/cards/_card_grid.html create mode 100644 code/web/templates/browse/cards/_card_tile.html create mode 100644 code/web/templates/browse/cards/index.html diff --git a/CHANGELOG.md b/CHANGELOG.md index ef19564..08e5d53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,30 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Summary -_No unreleased changes yet._ +Card browser with advanced filters, keyboard shortcuts, and responsive design. ### Added -_No unreleased changes yet._ +- **Card Browser**: Browse 26,427 Magic cards with powerful filtering at `/browse/cards` + - Fuzzy autocomplete for card names and themes with typo tolerance + - Multi-theme filtering (up to 5 themes with AND logic) + - Color identity, card type, rarity, CMC range, power/toughness filters + - Six sorting options: Name A-Z/Z-A, CMC Low/High, Power High, EDHREC Popular + - Cursor-based pagination with infinite scroll + - Shareable filter URLs for saving and sharing searches +- **Keyboard Shortcuts**: Efficient navigation without mouse + - `Enter`: Add first autocomplete match to theme filters + - `Shift+Enter`: Apply all active filters from any input field + - `Esc` (double-tap): Clear all filters globally (500ms window) + - Desktop-only keyboard shortcuts help button with tooltip + - Auto-focus theme input after adding theme (desktop only) +- **Responsive Design**: Mobile-optimized card browser with touch-friendly controls + - Adaptive grid layout (1-4 columns based on screen width) + - Theme chips with remove buttons + - Graceful 5-theme limit (input disabled, no intrusive alerts) + - Desktop-only UI elements hidden on mobile with media queries ### Changed -_No unreleased changes yet._ +- **Theme Catalog**: Improved generation to include more themes and filter out ultra-rare entries ### Fixed _No unreleased changes yet._ diff --git a/README.md b/README.md index e12e294..e567ec2 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ A web-first Commander/EDH deckbuilder with a shared core for CLI, headless, and - [Initial Setup](#initial-setup) - [Owned Library](#owned-library) - [Browse Commanders](#browse-commanders) + - [Browse Cards](#browse-cards) - [Browse Themes](#browse-themes) - [Finished Decks](#finished-decks) - [Random Build](#random-build) @@ -164,6 +165,19 @@ Explore the curated commander catalog. - Refresh via Initial Setup or the commander catalog script above. - MDFC merges and compatibility snapshots are handled automatically; use `--compat-snapshot` on the refresh script to emit an unmerged snapshot. +### Browse Cards +Search and filter all 26,427 Magic cards. +- **Filtering**: Search by name, themes (up to 5), color identity, type, rarity, CMC range, power/toughness +- **Sorting**: Name A-Z/Z-A, CMC Low/High, Power High, EDHREC Popular +- **Keyboard Shortcuts**: + - `Enter`: Add first autocomplete match to theme filters + - `Shift+Enter`: Apply all active filters + - `Esc` (double-tap): Clear all filters + - `?` button (desktop): Show keyboard shortcuts reference +- **Responsive Design**: Mobile-optimized with adaptive grid and touch controls +- **Shareable URLs**: Filter state persists in URL for saving and sharing searches +- Powered by `card_files/all_cards.parquet` with theme tag index for fast lookups + ### Browse Themes Investigate theme synergies and diagnostics. - `ENABLE_THEMES=1` keeps the tile visible (default). diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 27d6e5b..3341cc9 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -1,13 +1,23 @@ # MTG Python Deckbuilder ${VERSION} ### Summary -_No unreleased changes yet._ +Card browser with advanced filters, keyboard shortcuts, and responsive design. ### Added -_No unreleased changes yet._ +- **Card Browser**: Browse 26,427 Magic cards with powerful filtering + - Multi-theme filtering (up to 5 themes), color identity, type, rarity, CMC, power/toughness + - Six sorting options including EDHREC popularity + - Infinite scroll with cursor-based pagination + - Shareable filter URLs +- **Keyboard Shortcuts**: Efficient navigation + - `Enter`: Add first autocomplete match + - `Shift+Enter`: Apply filters + - `Esc` (double-tap): Clear all filters + - Desktop-only help button with keyboard shortcuts reference +- **Responsive Design**: Mobile-optimized with adaptive grid layout and touch-friendly controls ### Changed -_No unreleased changes yet._ +- **Theme Catalog**: Improved generation to include more themes and filter out ultra-rare entries ### Fixed _No unreleased changes yet._ diff --git a/code/scripts/generate_theme_catalog.py b/code/scripts/generate_theme_catalog.py index e5c7e77..c3698d7 100644 --- a/code/scripts/generate_theme_catalog.py +++ b/code/scripts/generate_theme_catalog.py @@ -218,6 +218,7 @@ def build_theme_catalog( cards_filename: str = "cards.csv", logs_directory: Optional[Path] = None, use_parquet: bool = True, + min_card_count: int = 3, ) -> CatalogBuildResult: """Build theme catalog from card data. @@ -229,6 +230,8 @@ def build_theme_catalog( cards_filename: Name of cards CSV file logs_directory: Optional directory to copy output to use_parquet: If True, try to use all_cards.parquet first (default: True) + min_card_count: Minimum number of cards required to include theme (default: 3) + use_parquet: If True, try to use all_cards.parquet first (default: True) Returns: CatalogBuildResult with generated rows and metadata @@ -251,11 +254,16 @@ def build_theme_catalog( commander_parquet, theme_variants=theme_variants ) - # CSV method doesn't load non-commander cards, so we don't either - card_counts = Counter() + # Load all card counts from all_cards.parquet to include all themes + all_cards_parquet = parquet_dir / "all_cards.parquet" + card_counts = _load_theme_counts_from_parquet( + all_cards_parquet, theme_variants=theme_variants + ) used_parquet = True print("✓ Loaded theme data from parquet files") + print(f" - Commanders: {len(commander_counts)} themes") + print(f" - All cards: {len(card_counts)} themes") except Exception as e: print(f"⚠ Failed to load from parquet: {e}") @@ -285,12 +293,19 @@ def build_theme_catalog( version_hash = _compute_version_hash(display_names) rows: List[CatalogRow] = [] + filtered_count = 0 for key, display in zip(keys, display_names): if not display: continue card_count = int(card_counts.get(key, 0)) commander_count = int(commander_counts.get(key, 0)) source_count = card_count + commander_count + + # Filter out themes below minimum threshold + if source_count < min_card_count: + filtered_count += 1 + continue + rows.append( CatalogRow( theme=display, @@ -330,6 +345,9 @@ def build_theme_catalog( row.version, ]) + if filtered_count > 0: + print(f" Filtered {filtered_count} themes with <{min_card_count} cards") + if logs_directory is not None: logs_directory = logs_directory.resolve() logs_directory.mkdir(parents=True, exist_ok=True) @@ -376,6 +394,13 @@ def main(argv: Optional[Sequence[str]] = None) -> CatalogBuildResult: default=None, help="Optional directory to mirror the generated catalog for diffing (e.g., logs/generated)", ) + parser.add_argument( + "--min-cards", + dest="min_cards", + type=int, + default=3, + help="Minimum number of cards required to include theme (default: 3)", + ) args = parser.parse_args(argv) csv_dir = _resolve_csv_directory(str(args.csv_dir) if args.csv_dir else None) @@ -383,6 +408,7 @@ def main(argv: Optional[Sequence[str]] = None) -> CatalogBuildResult: csv_directory=csv_dir, output_path=args.output, logs_directory=args.logs_dir, + min_card_count=args.min_cards, ) print( f"Generated {len(result.rows)} themes -> {result.output_path} (version={result.version})", diff --git a/code/web/app.py b/code/web/app.py index 767eb36..3b524a2 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -62,6 +62,13 @@ async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue maybe_build_index() except Exception: pass + # Warm card browser theme catalog (fast CSV read) and theme index (slower card parsing) + try: + from .routes.card_browser import get_theme_catalog, get_theme_index # type: ignore + get_theme_catalog() # Fast: just reads CSV + get_theme_index() # Slower: parses cards for theme-to-card mapping + except Exception: + pass yield # (no shutdown tasks currently) @@ -2206,6 +2213,7 @@ from .routes import commanders as commanders_routes # noqa: E402 from .routes import partner_suggestions as partner_suggestions_routes # noqa: E402 from .routes import telemetry as telemetry_routes # noqa: E402 from .routes import cards as cards_routes # noqa: E402 +from .routes import card_browser as card_browser_routes # noqa: E402 app.include_router(build_routes.router) app.include_router(config_routes.router) app.include_router(decks_routes.router) @@ -2216,6 +2224,7 @@ app.include_router(commanders_routes.router) app.include_router(partner_suggestions_routes.router) app.include_router(telemetry_routes.router) app.include_router(cards_routes.router) +app.include_router(card_browser_routes.router) # Warm validation cache early to reduce first-call latency in tests and dev try: diff --git a/code/web/routes/card_browser.py b/code/web/routes/card_browser.py new file mode 100644 index 0000000..d256b6f --- /dev/null +++ b/code/web/routes/card_browser.py @@ -0,0 +1,1122 @@ +""" +Card browser web UI routes (HTML views with HTMX). + +Provides paginated card browsing with filters, search, and cursor-based pagination. +Complements the existing API routes in cards.py for tag-based card queries. +""" + +from __future__ import annotations + +import logging +from difflib import SequenceMatcher + +import pandas as pd +from fastapi import APIRouter, Request, Query +from fastapi.responses import HTMLResponse +from ..app import templates + +# Import existing services +try: + from code.services.all_cards_loader import AllCardsLoader + from code.deck_builder.builder_utils import parse_theme_tags +except ImportError: + from services.all_cards_loader import AllCardsLoader + from deck_builder.builder_utils import parse_theme_tags + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/cards", tags=["card-browser"]) + +# Cached loader instance and theme index +_loader: AllCardsLoader | None = None +_theme_index: dict[str, set[int]] | None = None # theme_lower -> set of card indices +_theme_catalog: list[str] | None = None # cached list of all theme names from catalog + + +def get_loader() -> AllCardsLoader: + """Get cached AllCardsLoader instance.""" + global _loader + if _loader is None: + _loader = AllCardsLoader() + return _loader + + +def get_theme_catalog() -> list[str]: + """ + Get cached list of all theme names from theme_catalog.csv. + + Reads from the catalog CSV which includes all themes from all_cards.parquet + (not just commander themes). Much faster than parsing themes from 26k+ cards. + Used for autocomplete suggestions. + + Returns ~900+ themes (as of latest generation). + """ + global _theme_catalog + if _theme_catalog is None: + import csv + from pathlib import Path + import os + + print("Loading theme catalog...", flush=True) + + # Try multiple possible paths (local dev vs Docker) + possible_paths = [ + Path(__file__).parent.parent.parent / "config" / "themes" / "theme_catalog.csv", # Local dev + Path("/app/config/themes/theme_catalog.csv"), # Docker + Path(os.environ.get("CONFIG_DIR", "/app/config")) / "themes" / "theme_catalog.csv", # Env var + ] + + themes = [] + loaded = False + + for catalog_path in possible_paths: + print(f"Checking path: {catalog_path} (exists: {catalog_path.exists()})", flush=True) + if catalog_path.exists(): + try: + with open(catalog_path, 'r', encoding='utf-8') as f: + # Skip comment lines starting with # + lines = [line for line in f if not line.strip().startswith('#')] + + # Parse CSV from non-comment lines + from io import StringIO + csv_content = StringIO(''.join(lines)) + reader = csv.DictReader(csv_content) + + for row in reader: + if 'theme' in row and row['theme']: + themes.append(row['theme']) + + _theme_catalog = themes + print(f"Loaded {len(themes)} themes from catalog: {catalog_path}", flush=True) + logger.info(f"Loaded {len(themes)} themes from catalog: {catalog_path}") + loaded = True + break + except Exception as e: + print(f"❌ Failed to load from {catalog_path}: {e}", flush=True) # Debug log + logger.warning(f"Failed to load theme catalog from {catalog_path}: {e}") + + if not loaded: + print("⚠️ No catalog found, falling back to parsing cards", flush=True) # Debug log + logger.warning("Failed to load theme catalog from all paths, falling back to parsing cards") + # Fallback: extract from theme index + theme_index = get_theme_index() + _theme_catalog = [theme.title() for theme in theme_index.keys()] + + return _theme_catalog + + +def get_theme_index() -> dict[str, set[int]]: + """ + Get cached theme-to-card-index mapping for fast lookups. + + Returns dict mapping lowercase theme names to sets of card indices. + Built once on first access and reused for all subsequent theme queries. + """ + global _theme_index + if _theme_index is None: + logger.info("Building theme index for fast lookups...") + _theme_index = {} + loader = get_loader() + df = loader.load() + + for idx, row in enumerate(df.itertuples()): + themes = parse_theme_tags(row.themeTags if hasattr(row, 'themeTags') else '') + for theme in themes: + theme_lower = theme.lower() + if theme_lower not in _theme_index: + _theme_index[theme_lower] = set() + _theme_index[theme_lower].add(idx) + + logger.info(f"Theme index built with {len(_theme_index)} unique themes") + + return _theme_index + + +@router.get("/", response_class=HTMLResponse) +async def card_browser_index( + request: Request, + search: str = Query("", description="Card name search query"), + themes: list[str] = Query([], description="Theme tag filters (AND logic)"), + color: str = Query("", description="Color identity filter"), + card_type: str = Query("", description="Card type filter"), + rarity: str = Query("", description="Rarity filter"), + sort: str = Query("name_asc", description="Sort order"), + cmc_min: int = Query(None, description="Minimum CMC filter", ge=0, le=16), + cmc_max: int = Query(None, description="Maximum CMC filter", ge=0, le=16), + power_min: int = Query(None, description="Minimum power filter", ge=0, le=99), + power_max: int = Query(None, description="Maximum power filter", ge=0, le=99), + tough_min: int = Query(None, description="Minimum toughness filter", ge=0, le=99), + tough_max: int = Query(None, description="Maximum toughness filter", ge=0, le=99), +): + """ + Main card browser page. + + Displays initial grid of cards with filters and search bar. + Uses HTMX for dynamic updates (pagination, filtering, search). + """ + try: + loader = get_loader() + df = loader.load() + + # Apply filters + filtered_df = df.copy() + + if search: + # Prioritize exact matches first, then word-count matches, then fuzzy + query_lower = search.lower().strip() + query_words = set(query_lower.split()) + + # 1. Check for exact match (case-insensitive) + # For double-faced cards, check both full name and name before " //" + exact_matches = [] + word_count_matches = [] + fuzzy_candidates = [] + fuzzy_indices = [] + + for idx, card_name in enumerate(filtered_df['name']): + card_lower = card_name.lower() + # For double-faced cards, get the front face name + front_name = card_lower.split(' // ')[0].strip() if ' // ' in card_lower else card_lower + + # Exact match (full name or front face) + if card_lower == query_lower or front_name == query_lower: + exact_matches.append(idx) + # Word count match (same number of words + high similarity) + elif len(query_lower.split()) == len(front_name.split()) and ( + query_lower in card_lower or any(word in card_lower for word in query_words) + ): + word_count_matches.append((idx, card_name)) + # Fuzzy candidate + elif query_lower in card_lower or any(word in card_lower for word in query_words): + fuzzy_candidates.append(card_name) + fuzzy_indices.append(idx) + + # Build final match list + final_matches = [] + + # If we have exact matches, ONLY return those (don't add fuzzy results) + if exact_matches: + final_matches = exact_matches + else: + # 2. Add word-count matches with fuzzy scoring + if word_count_matches: + scored_wc = [(idx, _fuzzy_card_name_score(search, name), name) + for idx, name in word_count_matches] + scored_wc.sort(key=lambda x: -x[1]) # Sort by score desc + final_matches.extend([idx for idx, score, name in scored_wc if score >= 0.3]) + + # 3. Add fuzzy matches + if fuzzy_candidates: + scored_fuzzy = [(fuzzy_indices[i], _fuzzy_card_name_score(search, name), name) + for i, name in enumerate(fuzzy_candidates)] + scored_fuzzy.sort(key=lambda x: -x[1]) # Sort by score desc + final_matches.extend([idx for idx, score, name in scored_fuzzy if score >= 0.3]) + + # Apply matches + if final_matches: + # Remove duplicates while preserving order + seen = set() + unique_matches = [] + for idx in final_matches: + if idx not in seen: + seen.add(idx) + unique_matches.append(idx) + filtered_df = filtered_df.iloc[unique_matches] + else: + filtered_df = filtered_df.iloc[0:0] + + # Multi-select theme filtering (AND logic: card must have ALL selected themes) + if themes: + theme_index = get_theme_index() + + # For each theme, get matching card indices + all_theme_matches = [] + for theme in themes: + theme_lower = theme.lower().strip() + + # Try exact match first (instant lookup) + if theme_lower in theme_index: + # Direct index lookup - O(1) instead of O(n) + matching_indices = theme_index[theme_lower] + all_theme_matches.append(matching_indices) + else: + # Fuzzy match: check all themes in index for similarity + matching_indices = set() + for indexed_theme, card_indices in theme_index.items(): + if _fuzzy_theme_match_score(theme, indexed_theme) >= 0.5: + matching_indices.update(card_indices) + all_theme_matches.append(matching_indices) + + # Apply AND logic: card must be in ALL theme match sets + if all_theme_matches: + # Start with first theme's matches + intersection = all_theme_matches[0] + # Intersect with all other theme matches + for theme_matches in all_theme_matches[1:]: + intersection = intersection & theme_matches + + # Intersect with current filtered_df indices + current_indices = set(filtered_df.index) + valid_indices = intersection & current_indices + if valid_indices: + filtered_df = filtered_df.loc[list(valid_indices)] + else: + filtered_df = filtered_df.iloc[0:0] + + if color: + filtered_df = filtered_df[ + filtered_df['colorIdentity'] == color + ] + + if card_type: + filtered_df = filtered_df[ + filtered_df['type'].str.contains(card_type, case=False, na=False) + ] + + if rarity and 'rarity' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['rarity'].str.lower() == rarity.lower() + ] + + # CMC range filter + if cmc_min is not None and 'manaValue' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['manaValue'] >= cmc_min + ] + + if cmc_max is not None and 'manaValue' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['manaValue'] <= cmc_max + ] + + # Power range filter (only applies to cards with power values) + if power_min is not None and 'power' in filtered_df.columns: + # Filter: either no power (NaN) OR power >= min + filtered_df = filtered_df[ + filtered_df['power'].isna() | (filtered_df['power'] >= str(power_min)) + ] + + if power_max is not None and 'power' in filtered_df.columns: + # Filter: either no power (NaN) OR power <= max + filtered_df = filtered_df[ + filtered_df['power'].isna() | (filtered_df['power'] <= str(power_max)) + ] + + # Toughness range filter (only applies to cards with toughness values) + if tough_min is not None and 'toughness' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['toughness'].isna() | (filtered_df['toughness'] >= str(tough_min)) + ] + + if tough_max is not None and 'toughness' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['toughness'].isna() | (filtered_df['toughness'] <= str(tough_max)) + ] + + # Apply sorting + if sort == "name_desc": + # Name Z-A + filtered_df['_sort_key'] = filtered_df['name'].str.replace('"', '', regex=False).str.replace("'", '', regex=False) + filtered_df['_sort_key'] = filtered_df['_sort_key'].apply( + lambda x: x.replace('_', ' ') if x.startswith('_') else x + ) + filtered_df = filtered_df.sort_values('_sort_key', key=lambda col: col.str.lower(), ascending=False) + filtered_df = filtered_df.drop('_sort_key', axis=1) + elif sort == "cmc_asc": + # CMC Low-High, then name + filtered_df = filtered_df.sort_values(['manaValue', 'name'], ascending=[True, True]) + elif sort == "cmc_desc": + # CMC High-Low, then name + filtered_df = filtered_df.sort_values(['manaValue', 'name'], ascending=[False, True]) + elif sort == "power_desc": + # Power High-Low (creatures first, then non-creatures) + # Convert power to numeric, NaN becomes -1 for sorting + filtered_df['_power_sort'] = pd.to_numeric(filtered_df['power'], errors='coerce').fillna(-1) + filtered_df = filtered_df.sort_values(['_power_sort', 'name'], ascending=[False, True]) + filtered_df = filtered_df.drop('_power_sort', axis=1) + elif sort == "edhrec_asc": + # EDHREC rank (low number = popular) + if 'edhrecRank' in filtered_df.columns: + # NaN goes to end (high value) + filtered_df['_edhrec_sort'] = filtered_df['edhrecRank'].fillna(999999) + filtered_df = filtered_df.sort_values(['_edhrec_sort', 'name'], ascending=[True, True]) + filtered_df = filtered_df.drop('_edhrec_sort', axis=1) + else: + # Fallback to name sort + filtered_df = filtered_df.sort_values('name') + else: + # Default: Name A-Z (name_asc) + filtered_df['_sort_key'] = filtered_df['name'].str.replace('"', '', regex=False).str.replace("'", '', regex=False) + filtered_df['_sort_key'] = filtered_df['_sort_key'].apply( + lambda x: x.replace('_', ' ') if x.startswith('_') else x + ) + filtered_df = filtered_df.sort_values('_sort_key', key=lambda col: col.str.lower()) + filtered_df = filtered_df.drop('_sort_key', axis=1) + + total_cards = len(filtered_df) + + # Get first page (20 cards) + per_page = 20 + cards_page = filtered_df.head(per_page) + + # Convert to list of dicts + cards_list = cards_page.to_dict('records') + + # Parse theme tags and color identity for each card + for card in cards_list: + card['themeTags_parsed'] = parse_theme_tags(card.get('themeTags', '')) + # Parse colorIdentity which can be: + # - "Colorless" -> [] (but mark as colorless) + # - "W" -> ['W'] + # - "B, R, U" -> ['B', 'R', 'U'] + # - "['W', 'U']" -> ['W', 'U'] + # - empty/None -> [] + raw_color = card.get('colorIdentity', '') + is_colorless = False + if raw_color and isinstance(raw_color, str): + if raw_color.lower() == 'colorless': + card['colorIdentity'] = [] + is_colorless = True + elif raw_color.startswith('['): + # Parse list-like strings e.g. "['W', 'U']" + card['colorIdentity'] = parse_theme_tags(raw_color) + elif ', ' in raw_color: + # Parse comma-separated e.g. "B, R, U" + card['colorIdentity'] = [c.strip() for c in raw_color.split(',')] + else: + # Single color e.g. "W" + card['colorIdentity'] = [raw_color.strip()] + elif not raw_color: + card['colorIdentity'] = [] + card['is_colorless'] = is_colorless + # TODO: Add owned card checking when integrated + card['is_owned'] = False + + # Get unique values for filters + # Build structured color identity list with proper names + unique_color_ids = df['colorIdentity'].dropna().unique().tolist() + + # Define color identity groups with proper names + color_groups = { + 'Colorless': ['Colorless'], + 'Mono-Color': ['W', 'U', 'B', 'R', 'G'], + 'Two-Color': [ + ('W, U', 'Azorius'), + ('U, B', 'Dimir'), + ('B, R', 'Rakdos'), + ('R, G', 'Gruul'), + ('G, W', 'Selesnya'), + ('W, B', 'Orzhov'), + ('U, R', 'Izzet'), + ('B, G', 'Golgari'), + ('R, W', 'Boros'), + ('G, U', 'Simic'), + ], + 'Three-Color': [ + ('B, G, U', 'Sultai'), + ('G, U, W', 'Bant'), + ('B, U, W', 'Esper'), + ('B, R, U', 'Grixis'), + ('B, G, R', 'Jund'), + ('G, R, W', 'Naya'), + ('B, G, W', 'Abzan'), + ('R, U, W', 'Jeskai'), + ('B, R, W', 'Mardu'), + ('G, R, U', 'Temur'), + ], + 'Four-Color': [ + ('B, G, R, U', 'Non-White'), + ('B, G, R, W', 'Non-Blue'), + ('B, G, U, W', 'Non-Red'), + ('B, R, U, W', 'Non-Green'), + ('G, R, U, W', 'Non-Black'), + ], + 'Five-Color': ['B, G, R, U, W'], + } + + # Flatten and filter to only include combinations present in data + all_colors = [] + for group_name, entries in color_groups.items(): + group_colors = [] + for entry in entries: + if isinstance(entry, tuple): + color_id, display_name = entry + if color_id in unique_color_ids: + group_colors.append((color_id, display_name)) + else: + color_id = entry + if color_id in unique_color_ids: + group_colors.append((color_id, color_id)) + if group_colors: + all_colors.append((group_name, group_colors)) + + all_types = sorted( + set( + df['type'].dropna().str.extract(r'([A-Za-z]+)', expand=False).dropna().unique().tolist() + ) + )[:20] # Limit to top 20 types + + all_rarities = [] + if 'rarity' in df.columns: + all_rarities = sorted(df['rarity'].dropna().unique().tolist()) + + # Calculate pagination info + per_page = 20 + total_filtered = len(filtered_df) + total_pages = (total_filtered + per_page - 1) // per_page # Ceiling division + current_page = 1 # Always page 1 on initial load (cursor-based makes exact page tricky) + + # Determine if there's a next page + has_next = total_cards > per_page + last_card_name = cards_list[-1]['name'] if cards_list else "" + + return templates.TemplateResponse( + "browse/cards/index.html", + { + "request": request, + "cards": cards_list, + "total_cards": len(df), # Original unfiltered count + "filtered_count": total_filtered, # After filters applied + "has_next": has_next, + "last_card": last_card_name, + "search": search, + "themes": themes, + "color": color, + "card_type": card_type, + "rarity": rarity, + "sort": sort, + "cmc_min": cmc_min, + "cmc_max": cmc_max, + "power_min": power_min, + "power_max": power_max, + "tough_min": tough_min, + "tough_max": tough_max, + "all_colors": all_colors, + "all_types": all_types, + "all_rarities": all_rarities, + "per_page": per_page, + "current_page": current_page, + "total_pages": total_pages, + }, + ) + + except FileNotFoundError as e: + logger.error(f"Card data not found: {e}") + return templates.TemplateResponse( + "browse/cards/index.html", + { + "request": request, + "cards": [], + "total_cards": 0, + "has_next": False, + "last_card": "", + "search": "", + "color": "", + "card_type": "", + "rarity": "", + "all_colors": [], + "all_types": [], + "all_rarities": [], + "per_page": 20, + "error": "Card data not available. Please run setup to generate all_cards.parquet.", + }, + ) + except Exception as e: + logger.error(f"Error loading card browser: {e}", exc_info=True) + return templates.TemplateResponse( + "browse/cards/index.html", + { + "request": request, + "cards": [], + "total_cards": 0, + "has_next": False, + "last_card": "", + "search": "", + "color": "", + "card_type": "", + "rarity": "", + "all_colors": [], + "all_types": [], + "all_rarities": [], + "per_page": 20, + "error": f"Error loading cards: {str(e)}", + }, + ) + + +@router.get("/grid", response_class=HTMLResponse) +async def card_browser_grid( + request: Request, + cursor: str = Query("", description="Last card name from previous page"), + search: str = Query("", description="Card name search query"), + themes: list[str] = Query([], description="Theme tag filters (AND logic)"), + color: str = Query("", description="Color identity filter"), + card_type: str = Query("", description="Card type filter"), + rarity: str = Query("", description="Rarity filter"), + sort: str = Query("name_asc", description="Sort order"), + cmc_min: int = Query(None, description="Minimum CMC filter", ge=0, le=16), + cmc_max: int = Query(None, description="Maximum CMC filter", ge=0, le=16), + power_min: int = Query(None, description="Minimum power filter", ge=0, le=99), + power_max: int = Query(None, description="Maximum power filter", ge=0, le=99), + tough_min: int = Query(None, description="Minimum toughness filter", ge=0, le=99), + tough_max: int = Query(None, description="Maximum toughness filter", ge=0, le=99), +): + """ + HTMX endpoint for paginated card grid. + + Returns only the grid partial HTML for seamless pagination. + Uses cursor-based pagination (last_card_name) for performance. + """ + try: + loader = get_loader() + df = loader.load() + + # Apply filters + filtered_df = df.copy() + + if search: + # Prioritize exact matches first, then word-count matches, then fuzzy + query_lower = search.lower().strip() + query_words = set(query_lower.split()) + + # 1. Check for exact match (case-insensitive) + # For double-faced cards, check both full name and name before " //" + exact_matches = [] + word_count_matches = [] + fuzzy_candidates = [] + fuzzy_indices = [] + + for idx, card_name in enumerate(filtered_df['name']): + card_lower = card_name.lower() + # For double-faced cards, get the front face name + front_name = card_lower.split(' // ')[0].strip() if ' // ' in card_lower else card_lower + + # Exact match (full name or front face) + if card_lower == query_lower or front_name == query_lower: + exact_matches.append(idx) + # Word count match (same number of words + high similarity) + elif len(query_lower.split()) == len(front_name.split()) and ( + query_lower in card_lower or any(word in card_lower for word in query_words) + ): + word_count_matches.append((idx, card_name)) + # Fuzzy candidate + elif query_lower in card_lower or any(word in card_lower for word in query_words): + fuzzy_candidates.append(card_name) + fuzzy_indices.append(idx) + + # Build final match list + final_matches = [] + + # If we have exact matches, ONLY return those (don't add fuzzy results) + if exact_matches: + final_matches = exact_matches + else: + # 2. Add word-count matches with fuzzy scoring + if word_count_matches: + scored_wc = [(idx, _fuzzy_card_name_score(search, name), name) + for idx, name in word_count_matches] + scored_wc.sort(key=lambda x: -x[1]) # Sort by score desc + final_matches.extend([idx for idx, score, name in scored_wc if score >= 0.3]) + + # 3. Add fuzzy matches + if fuzzy_candidates: + scored_fuzzy = [(fuzzy_indices[i], _fuzzy_card_name_score(search, name), name) + for i, name in enumerate(fuzzy_candidates)] + scored_fuzzy.sort(key=lambda x: -x[1]) # Sort by score desc + final_matches.extend([idx for idx, score, name in scored_fuzzy if score >= 0.3]) + + # Apply matches + if final_matches: + # Remove duplicates while preserving order + seen = set() + unique_matches = [] + for idx in final_matches: + if idx not in seen: + seen.add(idx) + unique_matches.append(idx) + filtered_df = filtered_df.iloc[unique_matches] + else: + filtered_df = filtered_df.iloc[0:0] + + # Multi-select theme filtering (AND logic: card must have ALL selected themes) + if themes: + theme_index = get_theme_index() + + # For each theme, get matching card indices + all_theme_matches = [] + for theme in themes: + theme_lower = theme.lower().strip() + + # Try exact match first (instant lookup) + if theme_lower in theme_index: + # Direct index lookup - O(1) instead of O(n) + matching_indices = theme_index[theme_lower] + all_theme_matches.append(matching_indices) + else: + # Fuzzy match: check all themes in index for similarity + matching_indices = set() + for indexed_theme, card_indices in theme_index.items(): + if _fuzzy_theme_match_score(theme, indexed_theme) >= 0.5: + matching_indices.update(card_indices) + all_theme_matches.append(matching_indices) + + # Apply AND logic: card must be in ALL theme match sets + if all_theme_matches: + # Start with first theme's matches + intersection = all_theme_matches[0] + # Intersect with all other theme matches + for theme_matches in all_theme_matches[1:]: + intersection = intersection & theme_matches + + # Intersect with current filtered_df indices + current_indices = set(filtered_df.index) + valid_indices = intersection & current_indices + if valid_indices: + filtered_df = filtered_df.loc[list(valid_indices)] + else: + filtered_df = filtered_df.iloc[0:0] + + if color: + filtered_df = filtered_df[ + filtered_df['colorIdentity'] == color + ] + + if card_type: + filtered_df = filtered_df[ + filtered_df['type'].str.contains(card_type, case=False, na=False) + ] + + if rarity and 'rarity' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['rarity'].str.lower() == rarity.lower() + ] + + # CMC range filter (grid endpoint) + if cmc_min is not None and 'manaValue' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['manaValue'] >= cmc_min + ] + + if cmc_max is not None and 'manaValue' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['manaValue'] <= cmc_max + ] + + # Power range filter (grid endpoint) + if power_min is not None and 'power' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['power'].isna() | (filtered_df['power'] >= str(power_min)) + ] + + if power_max is not None and 'power' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['power'].isna() | (filtered_df['power'] <= str(power_max)) + ] + + # Toughness range filter (grid endpoint) + if tough_min is not None and 'toughness' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['toughness'].isna() | (filtered_df['toughness'] >= str(tough_min)) + ] + + if tough_max is not None and 'toughness' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['toughness'].isna() | (filtered_df['toughness'] <= str(tough_max)) + ] + + # Apply sorting (same logic as main endpoint) + if sort == "name_desc": + filtered_df['_sort_key'] = filtered_df['name'].str.replace('"', '', regex=False).str.replace("'", '', regex=False) + filtered_df['_sort_key'] = filtered_df['_sort_key'].apply( + lambda x: x.replace('_', ' ') if x.startswith('_') else x + ) + filtered_df = filtered_df.sort_values('_sort_key', key=lambda col: col.str.lower(), ascending=False) + filtered_df = filtered_df.drop('_sort_key', axis=1) + elif sort == "cmc_asc": + filtered_df = filtered_df.sort_values(['manaValue', 'name'], ascending=[True, True]) + elif sort == "cmc_desc": + filtered_df = filtered_df.sort_values(['manaValue', 'name'], ascending=[False, True]) + elif sort == "power_desc": + filtered_df['_power_sort'] = pd.to_numeric(filtered_df['power'], errors='coerce').fillna(-1) + filtered_df = filtered_df.sort_values(['_power_sort', 'name'], ascending=[False, True]) + filtered_df = filtered_df.drop('_power_sort', axis=1) + elif sort == "edhrec_asc": + if 'edhrecRank' in filtered_df.columns: + filtered_df['_edhrec_sort'] = filtered_df['edhrecRank'].fillna(999999) + filtered_df = filtered_df.sort_values(['_edhrec_sort', 'name'], ascending=[True, True]) + filtered_df = filtered_df.drop('_edhrec_sort', axis=1) + else: + filtered_df = filtered_df.sort_values('name') + else: + # Default: Name A-Z + filtered_df['_sort_key'] = filtered_df['name'].str.replace('"', '', regex=False).str.replace("'", '', regex=False) + filtered_df['_sort_key'] = filtered_df['_sort_key'].apply( + lambda x: x.replace('_', ' ') if x.startswith('_') else x + ) + filtered_df = filtered_df.sort_values('_sort_key', key=lambda col: col.str.lower()) + filtered_df = filtered_df.drop('_sort_key', axis=1) + + # Cursor-based pagination + if cursor: + filtered_df = filtered_df[filtered_df['name'] > cursor] + + per_page = 20 + cards_page = filtered_df.head(per_page) + cards_list = cards_page.to_dict('records') + + # Parse theme tags and color identity + for card in cards_list: + card['themeTags_parsed'] = parse_theme_tags(card.get('themeTags', '')) + # Parse colorIdentity which can be: + # - "Colorless" -> [] (but mark as colorless) + # - "W" -> ['W'] + # - "B, R, U" -> ['B', 'R', 'U'] + # - "['W', 'U']" -> ['W', 'U'] + # - empty/None -> [] + raw_color = card.get('colorIdentity', '') + is_colorless = False + if raw_color and isinstance(raw_color, str): + if raw_color.lower() == 'colorless': + card['colorIdentity'] = [] + is_colorless = True + elif raw_color.startswith('['): + # Parse list-like strings e.g. "['W', 'U']" + card['colorIdentity'] = parse_theme_tags(raw_color) + elif ', ' in raw_color: + # Parse comma-separated e.g. "B, R, U" + card['colorIdentity'] = [c.strip() for c in raw_color.split(',')] + else: + # Single color e.g. "W" + card['colorIdentity'] = [raw_color.strip()] + elif not raw_color: + card['colorIdentity'] = [] + card['is_colorless'] = is_colorless + card['is_owned'] = False # TODO: Add owned card checking + + has_next = len(filtered_df) > per_page + last_card_name = cards_list[-1]['name'] if cards_list else "" + + return templates.TemplateResponse( + "browse/cards/_card_grid.html", + { + "request": request, + "cards": cards_list, + "has_next": has_next, + "last_card": last_card_name, + "search": search, + "themes": themes, + "color": color, + "card_type": card_type, + "rarity": rarity, + "sort": sort, + "cmc_min": cmc_min, + "cmc_max": cmc_max, + "power_min": power_min, + "power_max": power_max, + "tough_min": tough_min, + "tough_max": tough_max, + }, + ) + + except Exception as e: + logger.error(f"Error loading card grid: {e}", exc_info=True) + return HTMLResponse( + f'
Error loading cards: {str(e)}
', + status_code=500, + ) + + +def _fuzzy_theme_match_score(query: str, theme: str) -> float: + """ + Calculate fuzzy match score between query and theme name. + Handles typos in the middle of words. + + Returns score from 0.0 to 1.0, higher is better match. + """ + query_lower = query.lower() + theme_lower = theme.lower() + + # Use sequence matcher for proper fuzzy matching (handles typos) + base_score = SequenceMatcher(None, query_lower, theme_lower).ratio() + + # Bonus for substring match + substring_bonus = 0.0 + if theme_lower.startswith(query_lower): + substring_bonus = 0.3 # Strong bonus for prefix + elif query_lower in theme_lower: + substring_bonus = 0.2 # Moderate bonus for substring + + # Word overlap bonus (for multi-word themes) + query_words = set(query_lower.split()) + theme_words = set(theme_lower.split()) + word_overlap = 0.0 + if query_words and theme_words: + overlap_ratio = len(query_words & theme_words) / len(query_words) + word_overlap = overlap_ratio * 0.2 + + # Combine scores + return min(1.0, base_score + substring_bonus + word_overlap) + + +@router.get("/search", response_class=HTMLResponse) +async def card_browser_search( + request: Request, + q: str = Query("", description="Search query"), +): + """ + Live search autocomplete endpoint. + + Returns matching card names for autocomplete suggestions. + """ + try: + if not q or len(q) < 2: + return HTMLResponse("") + + loader = get_loader() + df = loader.load() + + # Search by card name (case-insensitive) + matches = df[df['name'].str.contains(q, case=False, na=False)] + matches = matches.sort_values('name').head(10) + + card_names = matches['name'].tolist() + + # Return as simple HTML list + html = "" + + return HTMLResponse(html) + + except Exception as e: + logger.error(f"Error in card search: {e}", exc_info=True) + return HTMLResponse("") + + +def _normalize_search_text(value: str | None) -> str: + """Normalize search text for fuzzy matching (lowercase, alphanumeric only).""" + if not value: + return "" + # Keep letters, numbers, spaces; convert to lowercase + import re + tokens = re.findall(r"[a-z0-9]+", value.lower()) + return " ".join(tokens) if tokens else "" + + +def _fuzzy_card_name_score(query: str, card_name: str) -> float: + """ + Calculate fuzzy match score between query and card name. + + Uses multiple scoring methods similar to commanders.py: + - Base sequence matching + - Partial ratio (substring matching) + - Token matching + - Word count matching bonus + - Substring bonuses + + Returns score from 0.0 to 1.0, higher is better match. + """ + normalized_query = _normalize_search_text(query) + normalized_card = _normalize_search_text(card_name) + + if not normalized_query or not normalized_card: + return 0.0 + + # Base sequence matching + base_score = SequenceMatcher(None, normalized_query, normalized_card).ratio() + + # Partial ratio - best matching substring + query_len = len(normalized_query) + if query_len <= len(normalized_card): + best_partial = 0.0 + for i in range(len(normalized_card) - query_len + 1): + substr = normalized_card[i:i + query_len] + ratio = SequenceMatcher(None, normalized_query, substr).ratio() + if ratio > best_partial: + best_partial = ratio + else: + best_partial = base_score + + # Token matching + query_tokens = normalized_query.split() + card_tokens = normalized_card.split() + + if query_tokens and card_tokens: + # Average token score + token_scores = [] + for q_token in query_tokens: + best_token_match = max( + (SequenceMatcher(None, q_token, c_token).ratio() for c_token in card_tokens), + default=0.0 + ) + token_scores.append(best_token_match) + token_avg = sum(token_scores) / len(token_scores) if token_scores else 0.0 + + # Word count bonus: prioritize same number of words + # "peer parker" (2 words) should match "peter parker" (2 words) over "peter parker amazing" (3 words) + word_count_bonus = 0.0 + if len(query_tokens) == len(card_tokens): + word_count_bonus = 0.15 # Significant bonus for same word count + else: + token_avg = 0.0 + word_count_bonus = 0.0 + + # Substring bonuses + substring_bonus = 0.0 + if normalized_card.startswith(normalized_query): + substring_bonus = 1.0 + elif normalized_query in normalized_card: + substring_bonus = 0.9 + elif query_tokens and all(token in card_tokens for token in query_tokens): + substring_bonus = 0.85 + + # Combine scores with word count bonus + base_result = max(base_score, best_partial, token_avg, substring_bonus) + return min(1.0, base_result + word_count_bonus) # Cap at 1.0 + + + +@router.get("/search-autocomplete", response_class=HTMLResponse) +async def card_search_autocomplete( + request: Request, + q: str = Query(..., min_length=2, description="Card name search query"), + limit: int = Query(10, ge=1, le=50), +) -> HTMLResponse: + """ + HTMX endpoint for card name autocomplete with fuzzy matching. + + Similar to commanders theme autocomplete, returns HTML suggestions + with keyboard navigation support. + """ + try: + loader = get_loader() + df = loader.load() + + # Quick filter: prioritize exact match, then word count match, then fuzzy + query_lower = q.lower() + query_words = set(query_lower.split()) + query_word_count = len(query_lower.split()) + + # Fast categorization + exact_matches = [] + word_count_candidates = [] + fuzzy_candidates = [] + + for card_name in df['name'].unique(): + card_lower = card_name.lower() + + # Exact match + if card_lower == query_lower: + exact_matches.append(card_name) + # Same word count with substring/word overlap + elif len(card_lower.split()) == query_word_count and ( + query_lower in card_lower or any(word in card_lower for word in query_words) + ): + word_count_candidates.append(card_name) + # Fuzzy candidate + elif query_lower in card_lower or any(word in card_lower for word in query_words): + fuzzy_candidates.append(card_name) + + # Build final scored list + scored_cards: list[tuple[float, str, int]] = [] # (score, name, priority) + + # 1. Exact matches (priority 0 = highest) + for card_name in exact_matches[:limit]: # Take top N exact matches + scored_cards.append((1.0, card_name, 0)) + + # 2. Word count matches (priority 1) + if len(scored_cards) < limit and word_count_candidates: + # Limit word count candidates before fuzzy scoring + if len(word_count_candidates) > 200: + word_count_candidates.sort(key=lambda n: (not n.lower().startswith(query_lower), len(n), n.lower())) + word_count_candidates = word_count_candidates[:200] + + for card_name in word_count_candidates: + score = _fuzzy_card_name_score(q, card_name) + if score >= 0.3: + scored_cards.append((score, card_name, 1)) + + # 3. Fuzzy matches (priority 2) + if len(scored_cards) < limit and fuzzy_candidates: + # Limit fuzzy candidates before scoring + if len(fuzzy_candidates) > 200: + fuzzy_candidates.sort(key=lambda n: (not n.lower().startswith(query_lower), len(n), n.lower())) + fuzzy_candidates = fuzzy_candidates[:200] + + for card_name in fuzzy_candidates: + score = _fuzzy_card_name_score(q, card_name) + if score >= 0.3: + scored_cards.append((score, card_name, 2)) + + # Sort by priority first, then score desc, then name asc + scored_cards.sort(key=lambda x: (x[2], -x[0], x[1].lower())) + + # Take top matches + top_matches = scored_cards[:limit] + + # Generate HTML suggestions with ARIA attributes + html_parts = [] + for score, card_name, priority in top_matches: + # Escape HTML special characters + safe_name = card_name.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"') + html_parts.append( + f'
' + f'{safe_name}
' + ) + + html = "\n".join(html_parts) if html_parts else '
No matching cards
' + + return HTMLResponse(content=html) + + except Exception as e: + logger.error(f"Error in card autocomplete: {e}", exc_info=True) + return HTMLResponse(content=f'
Error: {str(e)}
') + + +@router.get("/theme-autocomplete", response_class=HTMLResponse) +async def card_theme_autocomplete( + request: Request, + q: str = Query(..., min_length=2, description="Theme search query"), + limit: int = Query(10, ge=1, le=20), +) -> HTMLResponse: + """ + HTMX endpoint for theme tag autocomplete with fuzzy matching. + + Uses theme catalog for instant lookups (no card parsing required). + """ + try: + # Use cached theme catalog (loaded from CSV, not parsed from cards) + all_themes = get_theme_catalog() + + # Fuzzy match themes using helper function + scored_themes: list[tuple[float, str]] = [] + + # Only check against theme names from catalog (~575 themes) + for theme in all_themes: + score = _fuzzy_theme_match_score(q, theme) + # Only include if score is reasonable (0.5+ = 50%+ match) + if score >= 0.5: + scored_themes.append((score, theme)) + + # Sort by score (desc), then alphabetically + scored_themes.sort(key=lambda x: (-x[0], x[1].lower())) + top_matches = scored_themes[:limit] + + # Generate HTML suggestions + html_parts = [] + for score, theme in top_matches: + safe_theme = theme.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"') + html_parts.append( + f'
' + f'{safe_theme}
' + ) + + html = "\n".join(html_parts) if html_parts else '
No matching themes
' + + return HTMLResponse(content=html) + + except Exception as e: + logger.error(f"Error in theme autocomplete: {e}", exc_info=True) + return HTMLResponse(content=f'
Error: {str(e)}
') + diff --git a/code/web/static/styles.css b/code/web/static/styles.css index 4c610c3..5bee967 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -727,3 +727,339 @@ img.lqip.loaded { filter: blur(0); opacity: 1; } border: 1px solid var(--border); border-radius: 4px; } + +/* ======================================== + Card Browser Styles + ======================================== */ + +/* Card browser container */ +.card-browser-container { + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* Filter panel */ +.card-browser-filters { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; +} + +.filter-section { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.filter-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.filter-row label { + font-weight: 600; + min-width: 80px; + color: var(--text); + font-size: 0.95rem; +} + +.filter-row select, +.filter-row input[type="text"], +.filter-row input[type="search"] { + flex: 1; + min-width: 150px; + max-width: 300px; +} + +/* Search bar styling */ +.card-search-wrapper { + position: relative; + flex: 1; + max-width: 100%; +} + +.card-search-wrapper input[type="search"] { + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 1rem; +} + +/* Results count and info bar */ +.card-browser-info { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.5rem 0; +} + +.results-count { + font-size: 0.95rem; + color: var(--muted); +} + +.page-indicator { + font-size: 0.95rem; + color: var(--text); + font-weight: 600; +} + +/* Card browser grid */ +.card-browser-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 240px)); + gap: 0.5rem; + padding: 0.5rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + min-height: 480px; + justify-content: start; +} + +/* Individual card tile in browser */ +.card-browser-tile { + break-inside: avoid; + display: flex; + flex-direction: column; + background: var(--card-bg, #1a1d24); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; + cursor: pointer; +} + +.card-browser-tile:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + border-color: color-mix(in srgb, var(--border) 50%, var(--ring) 50%); +} + +.card-browser-tile-image { + position: relative; + width: 100%; + aspect-ratio: 488/680; + overflow: hidden; + background: #0a0b0e; +} + +.card-browser-tile-image img { + width: 100%; + height: 100%; + object-fit: contain; + transition: transform 0.3s ease; +} + +.card-browser-tile:hover .card-browser-tile-image img { + transform: scale(1.05); +} + +.card-browser-tile-info { + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.card-browser-tile-name { + font-weight: 600; + font-size: 0.95rem; + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.3; +} + +.card-browser-tile-type { + font-size: 0.85rem; + color: var(--muted); + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.3; +} + +.card-browser-tile-stats { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.85rem; +} + +.card-browser-tile-tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.25rem; +} + +.card-browser-tile-tags .tag { + font-size: 0.7rem; + padding: 0.15rem 0.4rem; + background: rgba(148, 163, 184, 0.15); + color: var(--muted); + border-radius: 3px; + white-space: nowrap; +} + +/* Pagination controls */ +.card-browser-pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + padding: 1rem 0; + flex-wrap: wrap; +} + +.card-browser-pagination .btn { + min-width: 120px; +} + +.card-browser-pagination .page-info { + font-size: 0.95rem; + color: var(--text); + padding: 0 1rem; +} + +/* No results message */ +.no-results { + text-align: center; + padding: 3rem 1rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; +} + +.no-results-title { + font-size: 1.25rem; + font-weight: 600; + color: var(--text); + margin-bottom: 0.5rem; +} + +.no-results-message { + color: var(--muted); + margin-bottom: 1rem; + line-height: 1.5; +} + +.no-results-filters { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: center; + margin-bottom: 1rem; +} + +.no-results-filter-tag { + padding: 0.25rem 0.75rem; + background: rgba(148, 163, 184, 0.15); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.9rem; + color: var(--text); +} + +/* Loading indicator */ +.card-browser-loading { + text-align: center; + padding: 2rem; + color: var(--muted); +} + +/* Responsive adjustments */ +/* Large tablets and below - reduce to ~180px cards */ +@media (max-width: 1024px) { + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(200px, 200px)); + } +} + +/* Tablets - reduce to ~160px cards */ +@media (max-width: 768px) { + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(180px, 180px)); + gap: 0.5rem; + padding: 0.5rem; + } + + .filter-row { + flex-direction: column; + align-items: stretch; + } + + .filter-row label { + min-width: auto; + } + + .filter-row select, + .filter-row input { + max-width: 100%; + } + + .card-browser-info { + flex-direction: column; + align-items: flex-start; + } +} + +/* Small tablets/large phones - reduce to ~140px cards */ +@media (max-width: 600px) { + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 160px)); + gap: 0.5rem; + } +} + +/* Phones - 2 column layout with flexible width */ +@media (max-width: 480px) { + .card-browser-grid { + grid-template-columns: repeat(2, 1fr); + gap: 0.375rem; + } + + .card-browser-tile-name { + font-size: 0.85rem; + } + + .card-browser-tile-type { + font-size: 0.75rem; + } + + .card-browser-tile-info { + padding: 0.5rem; + } +} + +/* Theme chips for multi-select */ +.theme-chip { + display: inline-flex; + align-items: center; + background: var(--primary-bg); + color: var(--primary-fg); + padding: 0.25rem 0.75rem; + border-radius: 1rem; + font-size: 0.9rem; + border: 1px solid var(--border-color); +} + +.theme-chip button { + margin-left: 0.5rem; + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 0; + font-weight: bold; + font-size: 1.2rem; + line-height: 1; +} + +.theme-chip button:hover { + color: var(--error-color); +} diff --git a/code/web/templates/base.html b/code/web/templates/base.html index b8a0d88..f0c014d 100644 --- a/code/web/templates/base.html +++ b/code/web/templates/base.html @@ -85,6 +85,7 @@ Build from JSON {% if show_setup %}Setup/Tag{% endif %} Owned Library + All Cards {% if show_commanders %}Commanders{% endif %} Finished Decks Themes diff --git a/code/web/templates/browse/cards/_card_grid.html b/code/web/templates/browse/cards/_card_grid.html new file mode 100644 index 0000000..1720da4 --- /dev/null +++ b/code/web/templates/browse/cards/_card_grid.html @@ -0,0 +1,22 @@ +{# HTMX-loadable card grid for pagination #} +{% for card in cards %} +{% include "browse/cards/_card_tile.html" %} +{% endfor %} + +{# Pagination button (uses out-of-band swap to replace itself) #} +
+{% if has_next %} + + + Loading... + +{% endif %} +
diff --git a/code/web/templates/browse/cards/_card_tile.html b/code/web/templates/browse/cards/_card_tile.html new file mode 100644 index 0000000..cbd9cbe --- /dev/null +++ b/code/web/templates/browse/cards/_card_tile.html @@ -0,0 +1,67 @@ +{# Single card tile for grid display #} +
+ {# Card image #} +
+ {{ card.name }} + {# Fallback for missing images #} +
+ {{ card.name }} +
+ + {# Owned indicator #} + {% if card.is_owned %} +
+ ✓ Owned +
+ {% endif %} +
+ + {# Card info #} +
+ {# Card name #} +
+ {{ card.name }} +
+ + {# Type line #} + {% if card.type %} +
+ {{ card.type }} +
+ {% endif %} + + {# Mana cost and color identity #} +
+ {% if card.manaValue is defined and card.manaValue is not none %} + CMC: {{ card.manaValue }} + {% endif %} + + {% if card.is_colorless %} +
+ +
+ {% elif card.colorIdentity %} +
+ {% for color in card.colorIdentity %} + + {% endfor %} +
+ {% endif %} +
+ + {# Theme tags (show all tags, not truncated) #} + {% if card.themeTags_parsed and card.themeTags_parsed|length > 0 %} +
+ {% for tag in card.themeTags_parsed %} + {{ tag }} + {% endfor %} +
+ {% endif %} +
+
diff --git a/code/web/templates/browse/cards/index.html b/code/web/templates/browse/cards/index.html new file mode 100644 index 0000000..f891bf5 --- /dev/null +++ b/code/web/templates/browse/cards/index.html @@ -0,0 +1,958 @@ +{% extends "base.html" %} +{% block content %} + + +
+

Card Browser

+

Browse all {{ total_cards }} cards with filters and search.

+ + {# Error message #} + {% if error %} +
+ {{ error }} +
+ {% endif %} + + {# Filters Panel #} +
+ {# Keyboard shortcuts help button (desktop only) #} + + + {# Shortcuts help tooltip #} + + + {# Search bar #} +
+
+
+ +
+ +
+
+ {% if search %} + + {% endif %} + +
+
+
+ + {# Filter controls #} +
+
+ {# Multi-select theme filter #} + +
+ {# Selected themes as chips #} +
+ {% if themes %} + {% for t in themes %} + + {{ t }} + + + {% endfor %} + {% endif %} +
+ + {# Autocomplete input #} +
+ +
+
+
+
+ +
+ {# Color filter #} + {% if all_colors %} + + + {% endif %} + + {# Type filter #} + {% if all_types %} + + + {% endif %} + + {# Rarity filter #} + {% if all_rarities %} + + + {% endif %} + + {# Sort dropdown #} + + + + + +
+ + {# Advanced filters row #} +
+ {# CMC range filter #} + +
+ + + +
+ + {# Power range filter #} + +
+ + + +
+ + {# Toughness range filter #} + +
+ + + +
+
+
+
+ + {# Results info bar with page indicator #} +
+ + {% if filtered_count is defined and filtered_count != total_cards %} + Showing {{ cards|length }} of {{ filtered_count }} filtered cards ({{ total_cards }} total) + {% else %} + Showing {{ cards|length }} of {{ total_cards }} cards + {% endif %} + {% if search %} matching "{{ search }}"{% endif %} + +
+ + {# Card grid container or no results message #} + {% if cards and cards|length %} +
800 %}data-virtualize="1"{% endif %}> +
+ {% for card in cards %} + {% include "browse/cards/_card_tile.html" %} + {% endfor %} +
+
+ + {# Pagination controls #} + {% if has_next %} +
+ + + Loading... + +
+ {% endif %} + {% else %} + {# No results message with helpful info #} +
+
No cards found
+
+ {% if search or color or card_type or rarity or theme or cmc_min or cmc_max %} + No cards match your current filters. + {% if search %}Try a different search term{% endif %}{% if search and (color or card_type or rarity or theme or cmc_min or cmc_max) %} or {% endif %}{% if color or card_type or rarity or theme or cmc_min or cmc_max %}adjust your filters{% endif %}. + {% else %} + Unable to load cards. Please try refreshing the page. + {% endif %} +
+ + {% if search or color or card_type or rarity or theme or cmc_min or cmc_max %} +
+ Active filters: + {% if search %} + Search: "{{ search }}" + {% endif %} + {% if theme %} + Theme: {{ theme }} + {% endif %} + {% if color %} + Color: {{ color }} + {% endif %} + {% if card_type %} + Type: {{ card_type }} + {% endif %} + {% if rarity %} + Rarity: {{ rarity|title }} + {% endif %} + {% if cmc_min or cmc_max %} + CMC: {% if cmc_min %}{{ cmc_min }}{% else %}0{% endif %}–{% if cmc_max %}{{ cmc_max }}{% else %}16+{% endif %} + {% endif %} +
+

Clear All Filters

+ {% endif %} +
+ {% endif %} +
+ + +{% endblock %} \ No newline at end of file diff --git a/code/web/templates/home.html b/code/web/templates/home.html index b4434bd..2f6c5fd 100644 --- a/code/web/templates/home.html +++ b/code/web/templates/home.html @@ -6,6 +6,7 @@ Run a JSON Config {% if show_setup %}Initial Setup{% endif %} Owned Library + Browse All Cards {% if show_commanders %}Browse Commanders{% endif %} Finished Decks Browse Themes From c2960c808ed7b5772e284f58c9956347d83a464f Mon Sep 17 00:00:00 2001 From: matt Date: Fri, 17 Oct 2025 16:17:36 -0700 Subject: [PATCH 2/2] Add card browser with similar cards and performance optimizations --- .env.example | 5 + .github/workflows/build-similarity-cache.yml | 171 + .github/workflows/dockerhub-publish.yml | 30 + CHANGELOG.md | 50 +- DOCKER.md | 2 + README.md | 16 +- RELEASE_NOTES_TEMPLATE.md | 41 +- .../scripts/build_similarity_cache_parquet.py | 445 +++ code/settings.py | 23 +- code/web/app.py | 11 + code/web/routes/card_browser.py | 153 +- code/web/routes/setup.py | 6 +- code/web/services/card_similarity.py | 483 +++ code/web/services/similarity_cache.py | 386 ++ code/web/static/styles.css | 136 + .../templates/browse/cards/_card_tile.html | 12 +- .../browse/cards/_similar_cards.html | 250 ++ code/web/templates/browse/cards/detail.html | 273 ++ code/web/templates/browse/cards/index.html | 2 +- code/web/templates/error.html | 88 + code/web/templates/setup/index.html | 136 + config/themes/theme_list.json | 3495 ++++++++++------- docker-compose.yml | 6 + dockerhub-docker-compose.yml | 6 + entrypoint.sh | 7 + 25 files changed, 4841 insertions(+), 1392 deletions(-) create mode 100644 .github/workflows/build-similarity-cache.yml create mode 100644 code/scripts/build_similarity_cache_parquet.py create mode 100644 code/web/services/card_similarity.py create mode 100644 code/web/services/similarity_cache.py create mode 100644 code/web/templates/browse/cards/_similar_cards.html create mode 100644 code/web/templates/browse/cards/detail.html create mode 100644 code/web/templates/error.html diff --git a/.env.example b/.env.example index 6e72a30..a7d347e 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,11 @@ WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1" ALLOW_MUST_HAVES=1 # dockerhub: ALLOW_MUST_HAVES="1" SHOW_MUST_HAVE_BUTTONS=0 # dockerhub: SHOW_MUST_HAVE_BUTTONS="0" (set to 1 to surface must include/exclude buttons) WEB_THEME_PICKER_DIAGNOSTICS=0 # 1=enable uncapped synergies, diagnostics fields & /themes/metrics (dev only) +# ENABLE_CARD_DETAILS=0 # 1=show Card Details button in card browser (experimental feature) +# ENABLE_CARD_SIMILARITIES=0 # 1=enable similarity/synergy features (requires ENABLE_CARD_DETAILS=1 and manual cache build) +# SIMILARITY_CACHE_PATH= # Override similarity cache location (default: card_files/similarity_cache.json) +# SIMILARITY_CACHE_MAX_AGE_DAYS=7 # Days before showing cache refresh prompt (default: 7) +# SIMILARITY_CACHE_DOWNLOAD=1 # 1=download pre-built cache from GitHub (saves 15-20 min), 0=always build locally ############################ # Partner / Background Mechanics diff --git a/.github/workflows/build-similarity-cache.yml b/.github/workflows/build-similarity-cache.yml new file mode 100644 index 0000000..f66cd8c --- /dev/null +++ b/.github/workflows/build-similarity-cache.yml @@ -0,0 +1,171 @@ +name: Build Similarity Cache + +# Manual trigger + weekly schedule + callable from other workflows +on: + workflow_dispatch: + inputs: + force_rebuild: + description: 'Force rebuild even if cache exists' + required: false + type: boolean + default: true + workflow_call: # Allow this workflow to be called by other workflows + schedule: + # Run every Sunday at 2 AM UTC + - cron: '0 2 * * 0' + +jobs: + build-cache: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Check if cache needs rebuild + id: check_cache + run: | + FORCE="${{ github.event.inputs.force_rebuild }}" + if [ "$FORCE" = "true" ] || [ ! -f "card_files/similarity_cache.parquet" ]; then + echo "needs_build=true" >> $GITHUB_OUTPUT + echo "Cache doesn't exist or force rebuild requested" + else + # Check cache age via metadata JSON + CACHE_AGE_DAYS=$(python -c " + import json + from datetime import datetime + from pathlib import Path + + metadata_path = Path('card_files/similarity_cache_metadata.json') + if metadata_path.exists(): + with open(metadata_path) as f: + data = json.load(f) + build_date = data.get('build_date') + if build_date: + age = (datetime.now() - datetime.fromisoformat(build_date)).days + print(age) + else: + print(999) + else: + print(999) + " || echo "999") + + if [ "$CACHE_AGE_DAYS" -gt 7 ]; then + echo "needs_build=true" >> $GITHUB_OUTPUT + echo "Cache is $CACHE_AGE_DAYS days old, rebuilding" + else + echo "needs_build=false" >> $GITHUB_OUTPUT + echo "Cache is only $CACHE_AGE_DAYS days old, skipping" + fi + fi + + - name: Run initial setup + if: steps.check_cache.outputs.needs_build == 'true' + run: | + python -c "from code.file_setup.setup import initial_setup; initial_setup()" + + - name: Run tagging (serial - more reliable in CI) + if: steps.check_cache.outputs.needs_build == 'true' + run: | + python -c "from code.tagging.tagger import run_tagging; run_tagging(parallel=False)" + + - name: Build all_cards.parquet (needed for similarity cache, but not committed) + if: steps.check_cache.outputs.needs_build == 'true' + run: | + python -c "from code.web.services.card_loader import CardCatalogLoader; loader = CardCatalogLoader(); df = loader.load(); print(f'Created all_cards.parquet with {len(df):,} cards')" + + - name: Build similarity cache (Parquet) + if: steps.check_cache.outputs.needs_build == 'true' + run: | + python -m code.scripts.build_similarity_cache_parquet --parallel --checkpoint-interval 1000 --force + + - name: Verify cache was created + if: steps.check_cache.outputs.needs_build == 'true' + run: | + if [ ! -f "card_files/similarity_cache.parquet" ]; then + echo "ERROR: Cache Parquet file was not created" + exit 1 + fi + if [ ! -f "card_files/similarity_cache_metadata.json" ]; then + echo "ERROR: Cache metadata file was not created" + exit 1 + fi + + # Check cache validity + python -c " + import json + from pathlib import Path + from code.web.services.similarity_cache import get_cache + + cache = get_cache() + stats = cache.get_stats() + + if stats['total_cards'] < 20000: + raise ValueError(f\"Cache only has {stats['total_cards']} cards, expected ~30k\") + + print(f\"✓ Cache is valid with {stats['total_cards']:,} cards, {stats['total_entries']:,} entries\") + print(f\" File size: {stats['file_size_mb']:.2f} MB\") + " + + - name: Get cache metadata for commit message + if: steps.check_cache.outputs.needs_build == 'true' + id: cache_meta + run: | + METADATA=$(python -c " + import json + from pathlib import Path + from code.web.services.similarity_cache import get_cache + + cache = get_cache() + stats = cache.get_stats() + metadata = cache._metadata or {} + + build_date = metadata.get('build_date', 'unknown') + print(f\"{stats['total_cards']} cards, {stats['total_entries']} entries, {stats['file_size_mb']:.1f}MB, built {build_date}\") + ") + echo "metadata=$METADATA" >> $GITHUB_OUTPUT + + - name: Commit and push cache + if: steps.check_cache.outputs.needs_build == 'true' + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + + # Switch to or create dedicated cache branch + git checkout -b similarity-cache-data || git checkout similarity-cache-data + + # Add only the similarity cache files (not all_cards.parquet) + git add card_files/similarity_cache.parquet + git add card_files/similarity_cache_metadata.json + + # Check if there are changes to commit + if git diff --staged --quiet; then + echo "No changes to commit" + else + git commit -m "chore: update similarity cache [${{ steps.cache_meta.outputs.metadata }}]" + git push origin similarity-cache-data --force + fi + + - name: Summary + if: always() + run: | + if [ "${{ steps.check_cache.outputs.needs_build }}" = "true" ]; then + echo "✓ Similarity cache built and committed" + echo " Metadata: ${{ steps.cache_meta.outputs.metadata }}" + else + echo "⊘ Cache is recent, no rebuild needed" + fi diff --git a/.github/workflows/dockerhub-publish.yml b/.github/workflows/dockerhub-publish.yml index ec5eff6..4efa2e9 100644 --- a/.github/workflows/dockerhub-publish.yml +++ b/.github/workflows/dockerhub-publish.yml @@ -7,9 +7,15 @@ on: workflow_dispatch: jobs: + build-cache: + name: Build similarity cache + uses: ./.github/workflows/build-similarity-cache.yml + secrets: inherit + prepare: name: Prepare metadata runs-on: ubuntu-latest + needs: build-cache permissions: contents: read outputs: @@ -63,6 +69,18 @@ jobs: - name: Checkout uses: actions/checkout@v5.0.0 + - name: Download similarity cache from branch + run: | + # Download cache files from similarity-cache-data branch + mkdir -p card_files + wget -q https://raw.githubusercontent.com/${{ github.repository }}/similarity-cache-data/card_files/similarity_cache.parquet -O card_files/similarity_cache.parquet || echo "Cache not found, will build without it" + wget -q https://raw.githubusercontent.com/${{ github.repository }}/similarity-cache-data/card_files/similarity_cache_metadata.json -O card_files/similarity_cache_metadata.json || echo "Metadata not found" + + if [ -f card_files/similarity_cache.parquet ]; then + echo "✓ Downloaded similarity cache" + ls -lh card_files/similarity_cache.parquet + fi + - name: Compute amd64 tag id: arch_tag shell: bash @@ -120,6 +138,18 @@ jobs: - name: Checkout uses: actions/checkout@v5.0.0 + - name: Download similarity cache from branch + run: | + # Download cache files from similarity-cache-data branch + mkdir -p card_files + wget -q https://raw.githubusercontent.com/${{ github.repository }}/similarity-cache-data/card_files/similarity_cache.parquet -O card_files/similarity_cache.parquet || echo "Cache not found, will build without it" + wget -q https://raw.githubusercontent.com/${{ github.repository }}/similarity-cache-data/card_files/similarity_cache_metadata.json -O card_files/similarity_cache_metadata.json || echo "Metadata not found" + + if [ -f card_files/similarity_cache.parquet ]; then + echo "✓ Downloaded similarity cache" + ls -lh card_files/similarity_cache.parquet + fi + - name: Compute arm64 tag id: arch_tag shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 08e5d53..6d277bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,33 +9,41 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Summary -Card browser with advanced filters, keyboard shortcuts, and responsive design. +New card browser for exploring 29,839 Magic cards with advanced filters, similar card recommendations, and performance optimizations. ### Added -- **Card Browser**: Browse 26,427 Magic cards with powerful filtering at `/browse/cards` - - Fuzzy autocomplete for card names and themes with typo tolerance - - Multi-theme filtering (up to 5 themes with AND logic) - - Color identity, card type, rarity, CMC range, power/toughness filters - - Six sorting options: Name A-Z/Z-A, CMC Low/High, Power High, EDHREC Popular - - Cursor-based pagination with infinite scroll - - Shareable filter URLs for saving and sharing searches -- **Keyboard Shortcuts**: Efficient navigation without mouse - - `Enter`: Add first autocomplete match to theme filters - - `Shift+Enter`: Apply all active filters from any input field - - `Esc` (double-tap): Clear all filters globally (500ms window) - - Desktop-only keyboard shortcuts help button with tooltip - - Auto-focus theme input after adding theme (desktop only) -- **Responsive Design**: Mobile-optimized card browser with touch-friendly controls - - Adaptive grid layout (1-4 columns based on screen width) - - Theme chips with remove buttons - - Graceful 5-theme limit (input disabled, no intrusive alerts) - - Desktop-only UI elements hidden on mobile with media queries +- **Card Browser**: Browse and search all Magic cards at `/browse/cards` + - Smart autocomplete for card names and themes with typo tolerance + - Multi-theme filtering (up to 5 themes) + - Color, type, rarity, CMC, power/toughness filters + - Multiple sorting options including EDHREC popularity + - Infinite scroll with shareable filter URLs +- **Card Detail Pages**: Individual card pages with similar card suggestions + - Full card stats, oracle text, and theme tags + - Similar cards based on theme overlap + - Color-coded similarity scores + - Card preview on hover + - Enable with `ENABLE_CARD_DETAILS=1` environment variable +- **Similarity Cache**: Pre-computed card similarities for fast page loads + - Build cache with parallel processing script + - Automatically used when available + - Control with `SIMILARITY_CACHE_ENABLED` environment variable +- **Keyboard Shortcuts**: Quick navigation in card browser + - `Enter` to add autocomplete matches + - `Shift+Enter` to apply filters + - Double `Esc` to clear all filters ### Changed -- **Theme Catalog**: Improved generation to include more themes and filter out ultra-rare entries +- **Card Database**: Expanded to 29,839 cards (updated from 26,427) +- **Theme Catalog**: Improved coverage with better filtering + +### Removed +- **Unused Scripts**: Removed `regenerate_parquet.py` (functionality now in web UI setup) ### Fixed -_No unreleased changes yet._ +- **Card Browser UI**: Improved styling consistency and card image loading +- **Infinite Scroll**: Fixed cards appearing multiple times when loading more results +- **Sorting**: Sort order now persists correctly when scrolling through all pages ## [2.8.1] - 2025-10-16 ### Summary diff --git a/DOCKER.md b/DOCKER.md index 9ce253d..6a5ba07 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -256,6 +256,8 @@ See `.env.example` for the full catalog. Common knobs: | `THEME` | `dark` | Initial UI theme (`system`, `light`, or `dark`). | | `WEB_STAGE_ORDER` | `new` | Build stage execution order: `new` (creatures→spells→lands) or `legacy` (lands→creatures→spells). | | `WEB_IDEALS_UI` | `slider` | Ideal counts interface: `slider` (range inputs with live validation) or `input` (text boxes with placeholders). | +| `ENABLE_CARD_DETAILS` | `0` | Show card detail pages with similar card recommendations at `/cards/`. | +| `SIMILARITY_CACHE_ENABLED` | `1` | Use pre-computed similarity cache for fast card detail pages. | ### Random build controls diff --git a/README.md b/README.md index e567ec2..3966697 100644 --- a/README.md +++ b/README.md @@ -166,17 +166,13 @@ Explore the curated commander catalog. - MDFC merges and compatibility snapshots are handled automatically; use `--compat-snapshot` on the refresh script to emit an unmerged snapshot. ### Browse Cards -Search and filter all 26,427 Magic cards. -- **Filtering**: Search by name, themes (up to 5), color identity, type, rarity, CMC range, power/toughness +Search and explore all 29,839 Magic cards. +- **Search & Filters**: Smart autocomplete for card names and themes, multi-theme filtering (up to 5), color identity, type, rarity, CMC range, power/toughness - **Sorting**: Name A-Z/Z-A, CMC Low/High, Power High, EDHREC Popular -- **Keyboard Shortcuts**: - - `Enter`: Add first autocomplete match to theme filters - - `Shift+Enter`: Apply all active filters - - `Esc` (double-tap): Clear all filters - - `?` button (desktop): Show keyboard shortcuts reference -- **Responsive Design**: Mobile-optimized with adaptive grid and touch controls -- **Shareable URLs**: Filter state persists in URL for saving and sharing searches -- Powered by `card_files/all_cards.parquet` with theme tag index for fast lookups +- **Card Details** (optional): Enable with `ENABLE_CARD_DETAILS=1` for individual card pages with similar card recommendations +- **Keyboard Shortcuts**: `Enter` to add matches, `Shift+Enter` to apply filters, double `Esc` to clear all +- **Shareable URLs**: Filter state persists in URL for easy sharing +- Fast lookups powered by pre-built card index and optional similarity cache (`SIMILARITY_CACHE_ENABLED=1`) ### Browse Themes Investigate theme synergies and diagnostics. diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 3341cc9..2042211 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -1,23 +1,36 @@ # MTG Python Deckbuilder ${VERSION} ### Summary -Card browser with advanced filters, keyboard shortcuts, and responsive design. +New card browser for exploring and discovering cards with advanced filters, similar card recommendations, and fast performance. ### Added -- **Card Browser**: Browse 26,427 Magic cards with powerful filtering - - Multi-theme filtering (up to 5 themes), color identity, type, rarity, CMC, power/toughness - - Six sorting options including EDHREC popularity - - Infinite scroll with cursor-based pagination - - Shareable filter URLs -- **Keyboard Shortcuts**: Efficient navigation - - `Enter`: Add first autocomplete match - - `Shift+Enter`: Apply filters - - `Esc` (double-tap): Clear all filters - - Desktop-only help button with keyboard shortcuts reference -- **Responsive Design**: Mobile-optimized with adaptive grid layout and touch-friendly controls +- **Card Browser**: Browse and search all 29,839 Magic cards at `/browse/cards` + - Smart autocomplete with typo tolerance for card names and themes + - Multi-theme filtering (up to 5 themes) + - Color, type, rarity, CMC, power/toughness filters + - Multiple sorting options including EDHREC popularity + - Infinite scroll with shareable URLs +- **Card Detail Pages**: Individual card pages with similar card suggestions + - Enable with `ENABLE_CARD_DETAILS=1` environment variable + - Full card stats, oracle text, and theme tags + - Similar cards based on theme overlap with color-coded scores + - Card preview on hover +- **Similarity Cache**: Pre-computed card similarities for instant page loads + - Build cache with `python -m code.scripts.build_similarity_cache_parquet --parallel` + - Control with `SIMILARITY_CACHE_ENABLED` environment variable +- **Keyboard Shortcuts**: Quick navigation + - `Enter` to add autocomplete matches + - `Shift+Enter` to apply filters + - Double `Esc` to clear all filters ### Changed -- **Theme Catalog**: Improved generation to include more themes and filter out ultra-rare entries +- **Card Database**: Expanded to 29,839 cards (from 26,427) +- **Theme Catalog**: Improved coverage and filtering + +### Removed +- **Unused Scripts**: Removed redundant `regenerate_parquet.py` ### Fixed -_No unreleased changes yet._ +- **Card Browser**: Improved UI consistency and image loading +- **Infinite Scroll**: No more duplicate cards when loading more +- **Sorting**: Sort order now persists correctly across pages diff --git a/code/scripts/build_similarity_cache_parquet.py b/code/scripts/build_similarity_cache_parquet.py new file mode 100644 index 0000000..1edf924 --- /dev/null +++ b/code/scripts/build_similarity_cache_parquet.py @@ -0,0 +1,445 @@ +""" +Build similarity cache for all cards in the database using Parquet format. + +Pre-computes and stores similarity calculations for ~29k cards to improve +card detail page performance from 2-6s down to <500ms. + +NOTE: This script assumes card data and tagging are already complete. +Run setup and tagging separately before building the cache. + +Usage: + python -m code.scripts.build_similarity_cache_parquet [--parallel] [--checkpoint-interval 100] + +Options: + --parallel Enable parallel processing (faster but uses more CPU) + --checkpoint-interval Save cache every N cards (default: 100) + --force Rebuild cache even if it exists + --dry-run Calculate without saving (for testing) + --workers N Number of parallel workers (default: auto-detect) +""" + +import argparse +import logging +import sys +import time +import pandas as pd +from concurrent.futures import ProcessPoolExecutor, as_completed +from datetime import datetime +from pathlib import Path + +# Add project root to path +project_root = Path(__file__).parents[2] +sys.path.insert(0, str(project_root)) + +from code.web.services.card_similarity import CardSimilarity +from code.web.services.similarity_cache import SimilarityCache, get_cache + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + +# Shared data for worker processes (passed during initialization, not reloaded per worker) +_shared_cards_df = None +_shared_theme_frequencies = None +_shared_cleaned_tags = None +_worker_similarity = None + + +def _init_worker(cards_df_pickled: bytes, theme_frequencies: dict, cleaned_tags: dict): + """ + Initialize worker process with shared data. + Called once when each worker process starts. + + Args: + cards_df_pickled: Pickled DataFrame of all cards + theme_frequencies: Pre-computed theme frequency dict + cleaned_tags: Pre-computed cleaned tags cache + """ + import pickle + import logging + + global _shared_cards_df, _shared_theme_frequencies, _shared_cleaned_tags, _worker_similarity + + # Unpickle shared data once per worker + _shared_cards_df = pickle.loads(cards_df_pickled) + _shared_theme_frequencies = theme_frequencies + _shared_cleaned_tags = cleaned_tags + + # Create worker-level CardSimilarity instance with shared data + _worker_similarity = CardSimilarity(cards_df=_shared_cards_df) + + # Override pre-computed data to avoid recomputation + _worker_similarity.theme_frequencies = _shared_theme_frequencies + _worker_similarity.cleaned_tags_cache = _shared_cleaned_tags + + # Suppress verbose logging in workers + logging.getLogger("card_similarity").setLevel(logging.WARNING) + + +def calculate_similarity_for_card(args: tuple) -> tuple[str, list[dict], bool]: + """ + Calculate similarity for a single card (worker function for parallel processing). + + Args: + args: Tuple of (card_name, threshold, min_results, limit) + + Returns: + Tuple of (card_name, similar_cards, success) + """ + card_name, threshold, min_results, limit = args + + try: + # Use the global worker-level CardSimilarity instance + global _worker_similarity + if _worker_similarity is None: + # Fallback if initializer wasn't called (shouldn't happen) + _worker_similarity = CardSimilarity() + + # Calculate without using cache (we're building it) + similar_cards = _worker_similarity.find_similar( + card_name=card_name, + threshold=threshold, + min_results=min_results, + limit=limit, + adaptive=True, + use_cache=False, + ) + + return card_name, similar_cards, True + + except Exception as e: + logger.error(f"Failed to calculate similarity for '{card_name}': {e}") + return card_name, [], False + + +def _add_results_to_cache(cache_df: pd.DataFrame, card_name: str, similar_cards: list[dict]) -> pd.DataFrame: + """ + Add similarity results for a card to the cache DataFrame. + + Args: + cache_df: Existing cache DataFrame + card_name: Name of the card + similar_cards: List of similar cards with scores + + Returns: + Updated DataFrame + """ + # Build new rows + new_rows = [] + for rank, card in enumerate(similar_cards): + new_rows.append({ + "card_name": card_name, + "similar_name": card["name"], + "similarity": card["similarity"], + "edhrecRank": card.get("edhrecRank", float("inf")), + "rank": rank, + }) + + if new_rows: + new_df = pd.DataFrame(new_rows) + cache_df = pd.concat([cache_df, new_df], ignore_index=True) + + return cache_df + + +def build_cache( + parallel: bool = False, + workers: int | None = None, + checkpoint_interval: int = 100, + force: bool = False, + dry_run: bool = False, +) -> None: + """ + Build similarity cache for all cards. + + NOTE: Assumes card data (cards.csv, all_cards.parquet) and tagged data already exist. + Run setup and tagging separately before building cache. + + Args: + parallel: Enable parallel processing + workers: Number of parallel workers (None = auto-detect) + checkpoint_interval: Save cache every N cards + force: Rebuild even if cache exists + dry_run: Calculate without saving + """ + logger.info("=" * 80) + logger.info("Similarity Cache Builder (Parquet Edition)") + logger.info("=" * 80) + logger.info("") + + # Initialize cache + cache = get_cache() + + # Quick check for complete cache - if metadata says build is done, exit + if not force and cache.cache_path.exists() and not dry_run: + metadata = cache._metadata or {} + is_complete = metadata.get("build_complete", False) + + if is_complete: + stats = cache.get_stats() + logger.info(f"Cache already complete with {stats['total_cards']:,} cards") + logger.info("Use --force to rebuild") + return + else: + stats = cache.get_stats() + logger.info(f"Resuming incomplete cache with {stats['total_cards']:,} cards") + + if dry_run: + logger.info("DRY RUN MODE - No changes will be saved") + logger.info("") + + # Initialize similarity engine + logger.info("Initializing similarity engine...") + similarity = CardSimilarity() + total_cards = len(similarity.cards_df) + logger.info(f"Loaded {total_cards:,} cards") + logger.info("") + + # Filter out low-value lands (single-sided with <3 tags) + df = similarity.cards_df + df["is_land"] = df["type"].str.contains("Land", case=False, na=False) + df["is_multifaced"] = df["layout"].str.lower().isin(["modal_dfc", "transform", "reversible_card", "double_faced_token"]) + df["tag_count"] = df["themeTags"].apply(lambda x: len(x.split("|")) if pd.notna(x) and x else 0) + + # Keep cards that are either: + # 1. Not lands, OR + # 2. Multi-faced lands, OR + # 3. Single-sided lands with >= 3 tags + keep_mask = (~df["is_land"]) | (df["is_multifaced"]) | (df["is_land"] & (df["tag_count"] >= 3)) + + card_names = df[keep_mask]["name"].tolist() + skipped_lands = (~keep_mask & df["is_land"]).sum() + + logger.info(f"Filtered out {skipped_lands} low-value lands (single-sided with <3 tags)") + logger.info(f"Processing {len(card_names):,} cards ({len(card_names)/total_cards*100:.1f}% of total)") + logger.info("") + + # Configuration for similarity calculation + threshold = 0.8 + min_results = 3 + limit = 20 # Cache up to 20 similar cards per card for variety + + # Initialize cache data structure - try to load existing for resume + existing_cache_df = cache.load_cache() + already_processed = set() + + if len(existing_cache_df) > 0 and not dry_run: + # Resume from checkpoint - keep existing data + cache_df = existing_cache_df + already_processed = set(existing_cache_df["card_name"].unique()) + logger.info(f"Resuming from checkpoint with {len(already_processed):,} cards already processed") + + # Setup metadata + metadata = cache._metadata or cache._empty_metadata() + else: + # Start fresh + cache_df = cache._empty_cache_df() + metadata = cache._empty_metadata() + metadata["build_date"] = datetime.now().isoformat() + metadata["threshold"] = threshold + metadata["min_results"] = min_results + + # Track stats + start_time = time.time() + processed = len(already_processed) # Start count from checkpoint + failed = 0 + checkpoint_count = 0 + + try: + if parallel: + # Parallel processing - use available CPU cores + import os + import pickle + + if workers is not None: + max_workers = max(1, workers) # User-specified, minimum 1 + logger.info(f"Using {max_workers} worker processes (user-specified)") + else: + cpu_count = os.cpu_count() or 4 + # Use CPU count - 1 to leave one core for system, minimum 4 + max_workers = max(4, cpu_count - 1) + logger.info(f"Detected {cpu_count} CPUs, using {max_workers} worker processes") + + # Prepare shared data (pickle DataFrame once, share with all workers) + logger.info("Preparing shared data for workers...") + cards_df_pickled = pickle.dumps(similarity.cards_df) + theme_frequencies = similarity.theme_frequencies.copy() + cleaned_tags = similarity.cleaned_tags_cache.copy() + logger.info(f"Shared data prepared: {len(cards_df_pickled):,} bytes (DataFrame), " + f"{len(theme_frequencies)} themes, {len(cleaned_tags)} cleaned tag sets") + + # Prepare arguments for cards not yet processed + cards_to_process = [name for name in card_names if name not in already_processed] + logger.info(f"Cards to process: {len(cards_to_process):,} (skipping {len(already_processed):,} already done)") + + card_args = [(name, threshold, min_results, limit) for name in cards_to_process] + + with ProcessPoolExecutor( + max_workers=max_workers, + initializer=_init_worker, + initargs=(cards_df_pickled, theme_frequencies, cleaned_tags) + ) as executor: + # Submit all tasks + future_to_card = { + executor.submit(calculate_similarity_for_card, args): args[0] + for args in card_args + } + + # Process results as they complete + for future in as_completed(future_to_card): + card_name, similar_cards, success = future.result() + + if success: + cache_df = _add_results_to_cache(cache_df, card_name, similar_cards) + processed += 1 + else: + failed += 1 + + # Progress reporting + total_to_process = len(card_names) + if processed % 100 == 0: + elapsed = time.time() - start_time + # Calculate rate based on cards processed THIS session + cards_this_session = processed - len(already_processed) + rate = cards_this_session / elapsed if elapsed > 0 else 0 + cards_remaining = total_to_process - processed + eta = cards_remaining / rate if rate > 0 else 0 + logger.info( + f"Progress: {processed}/{total_to_process} " + f"({processed/total_to_process*100:.1f}%) - " + f"Rate: {rate:.1f} cards/sec - " + f"ETA: {eta/60:.1f} min" + ) + + # Checkpoint save + if not dry_run and processed % checkpoint_interval == 0: + checkpoint_count += 1 + cache.save_cache(cache_df, metadata) + logger.info(f"Checkpoint {checkpoint_count}: Saved cache with {processed:,} cards") + + else: + # Serial processing - skip already processed cards + cards_to_process = [name for name in card_names if name not in already_processed] + logger.info(f"Cards to process: {len(cards_to_process):,} (skipping {len(already_processed):,} already done)") + + for i, card_name in enumerate(cards_to_process, start=1): + try: + similar_cards = similarity.find_similar( + card_name=card_name, + threshold=threshold, + min_results=min_results, + limit=limit, + adaptive=True, + use_cache=False, + ) + + cache_df = _add_results_to_cache(cache_df, card_name, similar_cards) + processed += 1 + + except Exception as e: + logger.error(f"Failed to process '{card_name}': {e}") + failed += 1 + + # Progress reporting + if i % 100 == 0: + elapsed = time.time() - start_time + rate = i / elapsed if elapsed > 0 else 0 + cards_remaining = len(card_names) - i + eta = cards_remaining / rate if rate > 0 else 0 + logger.info( + f"Progress: {i}/{len(card_names)} " + f"({i/len(card_names)*100:.1f}%) - " + f"Rate: {rate:.1f} cards/sec - " + f"ETA: {eta/60:.1f} min" + ) + + # Checkpoint save + if not dry_run and i % checkpoint_interval == 0: + checkpoint_count += 1 + cache.save_cache(cache_df, metadata) + logger.info(f"Checkpoint {checkpoint_count}: Saved cache with {i:,} cards") + + # Final save + if not dry_run: + metadata["last_updated"] = datetime.now().isoformat() + metadata["build_complete"] = True + cache.save_cache(cache_df, metadata) + + # Summary + elapsed = time.time() - start_time + logger.info("") + logger.info("=" * 80) + logger.info("Build Complete") + logger.info("=" * 80) + logger.info(f"Total time: {elapsed/60:.2f} minutes") + logger.info(f"Cards processed: {processed:,}") + logger.info(f"Failed: {failed}") + logger.info(f"Checkpoints saved: {checkpoint_count}") + + if processed > 0: + logger.info(f"Average rate: {processed/elapsed:.2f} cards/sec") + + if not dry_run: + stats = cache.get_stats() + logger.info(f"Cache file size: {stats.get('file_size_mb', 0):.2f} MB") + logger.info(f"Cache location: {cache.cache_path}") + + except KeyboardInterrupt: + logger.warning("\nBuild interrupted by user") + + # Save partial cache + if not dry_run and len(cache_df) > 0: + metadata["last_updated"] = datetime.now().isoformat() + cache.save_cache(cache_df, metadata) + logger.info(f"Saved partial cache with {processed:,} cards") + + +def main(): + """CLI entry point.""" + parser = argparse.ArgumentParser( + description="Build similarity cache for all cards (Parquet format)" + ) + parser.add_argument( + "--parallel", + action="store_true", + help="Enable parallel processing", + ) + parser.add_argument( + "--workers", + type=int, + default=None, + help="Number of parallel workers (default: auto-detect)", + ) + parser.add_argument( + "--checkpoint-interval", + type=int, + default=100, + help="Save cache every N cards (default: 100)", + ) + parser.add_argument( + "--force", + action="store_true", + help="Rebuild cache even if it exists", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Calculate without saving (for testing)", + ) + + args = parser.parse_args() + + build_cache( + parallel=args.parallel, + workers=args.workers, + checkpoint_interval=args.checkpoint_interval, + force=args.force, + dry_run=args.dry_run, + ) + + +if __name__ == "__main__": + main() diff --git a/code/settings.py b/code/settings.py index 02a0201..98cfab5 100644 --- a/code/settings.py +++ b/code/settings.py @@ -124,4 +124,25 @@ TAG_PROTECTION_GRANTS = os.getenv('TAG_PROTECTION_GRANTS', '1').lower() not in ( TAG_METADATA_SPLIT = os.getenv('TAG_METADATA_SPLIT', '1').lower() not in ('0', 'false', 'off', 'disabled') # M5: Enable protection scope filtering in deck builder (completed - Phase 1-3, in progress Phase 4+) -TAG_PROTECTION_SCOPE = os.getenv('TAG_PROTECTION_SCOPE', '1').lower() not in ('0', 'false', 'off', 'disabled') \ No newline at end of file +TAG_PROTECTION_SCOPE = os.getenv('TAG_PROTECTION_SCOPE', '1').lower() not in ('0', 'false', 'off', 'disabled') + +# ---------------------------------------------------------------------------------- +# CARD BROWSER FEATURE FLAGS +# ---------------------------------------------------------------------------------- + +# Enable card detail pages (default: OFF) +# Set to '1' or 'true' to enable card detail pages in card browser +ENABLE_CARD_DETAILS = os.getenv('ENABLE_CARD_DETAILS', '0').lower() not in ('0', 'false', 'off', 'disabled') + +# Enable similarity/synergy features (default: OFF) +# Requires ENABLE_CARD_DETAILS=1 and manual cache build via Setup/Tag page +# Shows similar cards based on theme tag overlap using containment scoring +ENABLE_CARD_SIMILARITIES = os.getenv('ENABLE_CARD_SIMILARITIES', '0').lower() not in ('0', 'false', 'off', 'disabled') + +# Similarity cache configuration +SIMILARITY_CACHE_PATH = os.getenv('SIMILARITY_CACHE_PATH', 'card_files/similarity_cache.json') +SIMILARITY_CACHE_MAX_AGE_DAYS = int(os.getenv('SIMILARITY_CACHE_MAX_AGE_DAYS', '7')) + +# Allow downloading pre-built cache from GitHub (saves 15-20 min build time) +# Set to '0' to always build locally (useful for custom seeds or offline environments) +SIMILARITY_CACHE_DOWNLOAD = os.getenv('SIMILARITY_CACHE_DOWNLOAD', '1').lower() not in ('0', 'false', 'off', 'disabled') \ No newline at end of file diff --git a/code/web/app.py b/code/web/app.py index 3b524a2..437be4b 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -69,6 +69,14 @@ async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue get_theme_index() # Slower: parses cards for theme-to-card mapping except Exception: pass + # Warm CardSimilarity singleton (if card details enabled) - runs after theme index loads cards + try: + from code.settings import ENABLE_CARD_DETAILS + if ENABLE_CARD_DETAILS: + from .routes.card_browser import get_similarity # type: ignore + get_similarity() # Pre-initialize singleton (one-time cost: ~2-3s) + except Exception: + pass yield # (no shutdown tasks currently) @@ -2202,6 +2210,7 @@ async def setup_status(): except Exception: return JSONResponse({"running": False, "phase": "error"}) + # Routers from .routes import build as build_routes # noqa: E402 from .routes import configs as config_routes # noqa: E402 @@ -2233,6 +2242,8 @@ except Exception: pass ## (Additional startup warmers consolidated into lifespan handler) +## Note: CardSimilarity uses lazy initialization pattern like AllCardsLoader +## First card detail page loads in ~200ms (singleton init), subsequent in ~60ms # --- Exception handling --- def _wants_html(request: Request) -> bool: diff --git a/code/web/routes/card_browser.py b/code/web/routes/card_browser.py index d256b6f..f5f5656 100644 --- a/code/web/routes/card_browser.py +++ b/code/web/routes/card_browser.py @@ -9,6 +9,7 @@ from __future__ import annotations import logging from difflib import SequenceMatcher +from typing import TYPE_CHECKING import pandas as pd from fastapi import APIRouter, Request, Query @@ -19,9 +20,14 @@ from ..app import templates try: from code.services.all_cards_loader import AllCardsLoader from code.deck_builder.builder_utils import parse_theme_tags + from code.settings import ENABLE_CARD_DETAILS except ImportError: from services.all_cards_loader import AllCardsLoader from deck_builder.builder_utils import parse_theme_tags + from settings import ENABLE_CARD_DETAILS + +if TYPE_CHECKING: + from code.web.services.card_similarity import CardSimilarity logger = logging.getLogger(__name__) @@ -31,6 +37,7 @@ router = APIRouter(prefix="/cards", tags=["card-browser"]) _loader: AllCardsLoader | None = None _theme_index: dict[str, set[int]] | None = None # theme_lower -> set of card indices _theme_catalog: list[str] | None = None # cached list of all theme names from catalog +_similarity: "CardSimilarity | None" = None # cached CardSimilarity instance def get_loader() -> AllCardsLoader: @@ -41,6 +48,28 @@ def get_loader() -> AllCardsLoader: return _loader +def get_similarity() -> "CardSimilarity": + """ + Get cached CardSimilarity instance. + + CardSimilarity initialization is expensive (pre-computes tags for 29k cards, + loads cache with 277k entries). Cache it globally to avoid re-initialization + on every card detail page load. + + Returns: + Cached CardSimilarity instance + """ + global _similarity + if _similarity is None: + from code.web.services.card_similarity import CardSimilarity + loader = get_loader() + df = loader.load() + logger.info("Initializing CardSimilarity singleton (one-time cost)...") + _similarity = CardSimilarity(df) + logger.info("CardSimilarity singleton ready") + return _similarity + + def get_theme_catalog() -> list[str]: """ Get cached list of all theme names from theme_catalog.csv. @@ -497,6 +526,7 @@ async def card_browser_index( "per_page": per_page, "current_page": current_page, "total_pages": total_pages, + "enable_card_details": ENABLE_CARD_DETAILS, }, ) @@ -519,6 +549,7 @@ async def card_browser_index( "all_rarities": [], "per_page": 20, "error": "Card data not available. Please run setup to generate all_cards.parquet.", + "enable_card_details": ENABLE_CARD_DETAILS, }, ) except Exception as e: @@ -540,6 +571,7 @@ async def card_browser_index( "all_rarities": [], "per_page": 20, "error": f"Error loading cards: {str(e)}", + "enable_card_details": ENABLE_CARD_DETAILS, }, ) @@ -757,8 +789,19 @@ async def card_browser_grid( filtered_df = filtered_df.drop('_sort_key', axis=1) # Cursor-based pagination + # Cursor is the card name - skip all cards until we find it, then take next batch if cursor: - filtered_df = filtered_df[filtered_df['name'] > cursor] + try: + # Find the position of the cursor card in the sorted dataframe + cursor_position = filtered_df[filtered_df['name'] == cursor].index + if len(cursor_position) > 0: + # Get the iloc position (row number, not index label) + cursor_iloc = filtered_df.index.get_loc(cursor_position[0]) + # Skip past the cursor card (take everything after it) + filtered_df = filtered_df.iloc[cursor_iloc + 1:] + except (KeyError, IndexError): + # Cursor card not found - might have been filtered out, just proceed + pass per_page = 20 cards_page = filtered_df.head(per_page) @@ -815,6 +858,7 @@ async def card_browser_grid( "power_max": power_max, "tough_min": tough_min, "tough_max": tough_max, + "enable_card_details": ENABLE_CARD_DETAILS, }, ) @@ -1120,3 +1164,110 @@ async def card_theme_autocomplete( logger.error(f"Error in theme autocomplete: {e}", exc_info=True) return HTMLResponse(content=f'
Error: {str(e)}
') + +@router.get("/{card_name}", response_class=HTMLResponse) +async def card_detail(request: Request, card_name: str): + """ + Display detailed information about a single card with similar cards. + + Args: + card_name: URL-encoded card name + + Returns: + HTML page with card details and similar cards section + """ + try: + from urllib.parse import unquote + + # Decode URL-encoded card name + card_name = unquote(card_name) + + # Load card data + loader = get_loader() + df = loader.load() + + # Find the card + card_row = df[df['name'] == card_name] + + if card_row.empty: + # Card not found - return 404 page + return templates.TemplateResponse( + "error.html", + { + "request": request, + "error_code": 404, + "error_message": f"Card not found: {card_name}", + "back_link": "/cards", + "back_text": "Back to Card Browser" + }, + status_code=404 + ) + + # Get card data as dict + card = card_row.iloc[0].to_dict() + + # Parse theme tags using helper function + card['themeTags_parsed'] = parse_theme_tags(card.get('themeTags', '')) + + # Calculate similar cards using cached singleton + similarity = get_similarity() + similar_cards = similarity.find_similar( + card_name, + threshold=0.8, # Start at 80% + limit=5, # Show 3-5 cards + min_results=3, # Target minimum 3 + adaptive=True # Enable adaptive thresholds (80% → 60%) + ) + + # Enrich similar cards with full data + for similar in similar_cards: + similar_row = df[df['name'] == similar['name']] + if not similar_row.empty: + similar_data = similar_row.iloc[0].to_dict() + + # Parse theme tags before updating (so we have the list, not string) + theme_tags_parsed = parse_theme_tags(similar_data.get('themeTags', '')) + + similar.update(similar_data) + + # Set the parsed tags list (not the string version from df) + similar['themeTags'] = theme_tags_parsed + + # Log card detail page access + if similar_cards: + threshold_pct = similar_cards[0].get('threshold_used', 0) * 100 + logger.info( + f"Card detail page for '{card_name}': found {len(similar_cards)} similar cards " + f"(threshold: {threshold_pct:.0f}%)" + ) + else: + logger.info(f"Card detail page for '{card_name}': no similar cards found") + + # Get main card's theme tags for overlap highlighting + main_card_tags = card.get('themeTags_parsed', []) + + return templates.TemplateResponse( + "browse/cards/detail.html", + { + "request": request, + "card": card, + "similar_cards": similar_cards, + "main_card_tags": main_card_tags, + } + ) + + except Exception as e: + logger.error(f"Error loading card detail for '{card_name}': {e}", exc_info=True) + return templates.TemplateResponse( + "error.html", + { + "request": request, + "error_code": 500, + "error_message": f"Error loading card details: {str(e)}", + "back_link": "/cards", + "back_text": "Back to Card Browser" + }, + status_code=500 + ) + + diff --git a/code/web/routes/setup.py b/code/web/routes/setup.py index 345e277..ad492f5 100644 --- a/code/web/routes/setup.py +++ b/code/web/routes/setup.py @@ -157,4 +157,8 @@ async def rebuild_cards(): @router.get("/", response_class=HTMLResponse) async def setup_index(request: Request) -> HTMLResponse: - return templates.TemplateResponse("setup/index.html", {"request": request}) + import code.settings as settings + return templates.TemplateResponse("setup/index.html", { + "request": request, + "similarity_enabled": settings.ENABLE_CARD_SIMILARITIES + }) diff --git a/code/web/services/card_similarity.py b/code/web/services/card_similarity.py new file mode 100644 index 0000000..39f1dbe --- /dev/null +++ b/code/web/services/card_similarity.py @@ -0,0 +1,483 @@ +""" +Card similarity service using Jaccard index on theme tags. + +Provides similarity scoring between cards based on theme tag overlap. +Used for "Similar Cards" feature in card browser. + +Supports persistent caching for improved performance (2-6s → <500ms). + +Uses "signature tags" approach: compares top 5 most frequent tags instead +of all tags, significantly improving performance and quality. +""" + +import ast +import logging +import random +from pathlib import Path +from typing import Optional + +import pandas as pd + +from code.web.services.similarity_cache import SimilarityCache, get_cache + +logger = logging.getLogger(__name__) + + +class CardSimilarity: + """Calculate card similarity using theme tag overlap (Jaccard index) with caching.""" + + def __init__(self, cards_df: Optional[pd.DataFrame] = None, cache: Optional[SimilarityCache] = None): + """ + Initialize similarity calculator. + + Args: + cards_df: DataFrame with card data. If None, loads from all_cards.parquet + cache: SimilarityCache instance. If None, uses global singleton + """ + if cards_df is None: + # Load from default location + parquet_path = Path(__file__).parents[3] / "card_files" / "all_cards.parquet" + logger.info(f"Loading cards from {parquet_path}") + self.cards_df = pd.read_parquet(parquet_path) + else: + self.cards_df = cards_df + + # Initialize cache + self.cache = cache if cache is not None else get_cache() + + # Load theme frequencies from catalog + self.theme_frequencies = self._load_theme_frequencies() + + # Pre-compute cleaned tags (with exclusions) for all cards (one-time cost, huge speedup) + # This removes "Historics Matter" and "Legends Matter" from all cards + self.cleaned_tags_cache = self._precompute_cleaned_tags() + + # Pre-compute card metadata (EDHREC rank) for fast lookups + self._card_metadata = self._precompute_card_metadata() + + # Inverted index (tag -> set of card names) - built lazily on first use + self._tag_to_cards_index = None + + logger.info( + f"Initialized CardSimilarity with {len(self.cards_df)} cards " + f"and {len(self.theme_frequencies)} theme frequencies " + f"(cache: {'enabled' if self.cache.enabled else 'disabled'})" + ) + + def _load_theme_frequencies(self) -> dict[str, int]: + """ + Load theme frequencies from theme_catalog.csv. + + Returns: + Dict mapping theme name to card_count (higher = more common) + """ + catalog_path = Path(__file__).parents[3] / "config" / "themes" / "theme_catalog.csv" + + try: + # Read CSV, skipping comment line + df = pd.read_csv(catalog_path, comment="#") + + # Create dict mapping theme -> card_count + # Higher card_count = more common/frequent theme + frequencies = dict(zip(df["theme"], df["card_count"])) + + logger.info(f"Loaded {len(frequencies)} theme frequencies from catalog") + return frequencies + + except Exception as e: + logger.warning(f"Failed to load theme frequencies: {e}, using empty dict") + return {} + + def _precompute_cleaned_tags(self) -> dict[str, set[str]]: + """ + Pre-compute cleaned tags for all cards. + + Removes overly common tags like "Historics Matter" and "Legends Matter" + that don't provide meaningful similarity. This is done once during + initialization to avoid recalculating for every comparison. + + Returns: + Dict mapping card name -> cleaned tags (full set minus exclusions) + """ + logger.info("Pre-computing cleaned tags for all cards...") + excluded_tags = {"Historics Matter", "Legends Matter"} + cleaned = {} + + for _, row in self.cards_df.iterrows(): + card_name = row["name"] + tags = self.parse_theme_tags(row["themeTags"]) + + if tags: + # Remove excluded tags + cleaned_tags = tags - excluded_tags + if cleaned_tags: # Only store if card has tags after exclusion + cleaned[card_name] = cleaned_tags + + logger.info(f"Pre-computed {len(cleaned)} card tag sets") + return cleaned + + def _precompute_card_metadata(self) -> dict[str, dict]: + """ + Pre-compute card metadata (EDHREC rank, etc.) for fast lookups. + + Returns: + Dict mapping card name -> metadata dict + """ + logger.info("Pre-computing card metadata...") + metadata = {} + + for _, row in self.cards_df.iterrows(): + card_name = row["name"] + edhrec_rank = row.get("edhrecRank") + # Convert to float, use inf for NaN/None + edhrec_rank = float(edhrec_rank) if pd.notna(edhrec_rank) else float('inf') + + metadata[card_name] = { + "edhrecRank": edhrec_rank, + } + + logger.info(f"Pre-computed metadata for {len(metadata)} cards") + return metadata + + def _build_tag_index(self) -> None: + """ + Build inverted index: tag -> set of card names that have this tag. + + This allows fast candidate filtering - instead of checking all 29k cards, + we only check cards that share at least one tag with the target. + + Performance impact: Reduces 29k comparisons to typically 100-2000 comparisons. + """ + logger.info("Building inverted tag index...") + index = {} + + for card_name, tags in self.cleaned_tags_cache.items(): + for tag in tags: + if tag not in index: + index[tag] = set() + index[tag].add(card_name) + + self._tag_to_cards_index = index + + # Log statistics + avg_cards_per_tag = sum(len(cards) for cards in index.values()) / len(index) if index else 0 + logger.info( + f"Built tag index: {len(index)} unique tags, " + f"avg {avg_cards_per_tag:.1f} cards per tag" + ) + + def get_signature_tags( + self, + card_tags: set[str], + top_n: int = 5, + random_n: Optional[int] = None, + seed: Optional[int] = None, + ) -> set[str]: + """ + Get signature tags for similarity comparison. + + Takes the most frequent (popular) tags PLUS random tags for diversity. + This balances defining characteristics with discovery of niche synergies. + + Excludes overly common tags like "Historics Matter" and "Legends Matter" + that appear on most legendary cards and don't provide meaningful similarity. + + Args: + card_tags: Full set of card theme tags + top_n: Number of most frequent tags to use (default 5) + random_n: Number of random tags to add. If None, auto-scales: + - 6-10 tags: 1 random + - 11-15 tags: 2 random + - 16+ tags: 3 random + seed: Random seed for reproducibility (default: None) + + Returns: + Set of signature tags (top_n most frequent + random_n random) + """ + # Exclude overly common tags that don't provide meaningful similarity + excluded_tags = {"Historics Matter", "Legends Matter"} + card_tags = card_tags - excluded_tags + + if len(card_tags) <= top_n: + return card_tags # Use all if card has few tags + + # Auto-scale random_n based on total tag count if not specified + if random_n is None: + tag_count = len(card_tags) + if tag_count >= 16: + random_n = 3 + elif tag_count >= 11: + random_n = 2 + elif tag_count >= 6: + random_n = 1 + else: + random_n = 0 # Very few tags, no random needed + + # Sort tags by frequency (higher card_count = more common = higher priority) + sorted_tags = sorted( + card_tags, + key=lambda t: -self.theme_frequencies.get(t, 0), # Negate for descending order + ) + + # Take top N most frequent tags + signature = set(sorted_tags[:top_n]) + + # Add random tags from remaining tags + remaining_tags = card_tags - signature + if remaining_tags and random_n > 0: + if seed is not None: + random.seed(seed) + + # Sample min(random_n, len(remaining_tags)) to avoid errors + sample_size = min(random_n, len(remaining_tags)) + random_tags = set(random.sample(list(remaining_tags), sample_size)) + + signature = signature | random_tags + + return signature + + @staticmethod + def parse_theme_tags(tags: str | list) -> set[str]: + """ + Parse theme tags from string or list format. + + Args: + tags: Theme tags as string representation of list or actual list + + Returns: + Set of theme tag strings + """ + if pd.isna(tags) or not tags: + return set() + + if isinstance(tags, list): + return set(tags) + + if isinstance(tags, str): + # Handle string representation of list: "['tag1', 'tag2']" + try: + parsed = ast.literal_eval(tags) + if isinstance(parsed, list): + return set(parsed) + return set() + except (ValueError, SyntaxError): + # If parsing fails, return empty set + logger.warning(f"Failed to parse theme tags: {tags[:100]}") + return set() + + return set() + + @staticmethod + def calculate_similarity(tags_a: set[str], tags_b: set[str]) -> float: + """ + Calculate Jaccard similarity between two sets of theme tags. + + Jaccard index = intersection / union + + Args: + tags_a: First set of theme tags + tags_b: Second set of theme tags + + Returns: + Similarity score from 0.0 (no overlap) to 1.0 (identical) + """ + if not tags_a or not tags_b: + return 0.0 + + intersection = len(tags_a & tags_b) + union = len(tags_a | tags_b) + + if union == 0: + return 0.0 + + return intersection / union + + def get_card_tags(self, card_name: str) -> Optional[set[str]]: + """ + Get theme tags for a specific card. + + Args: + card_name: Name of the card + + Returns: + Set of theme tags, or None if card not found + """ + card_row = self.cards_df[self.cards_df["name"] == card_name] + + if card_row.empty: + return None + + tags = card_row.iloc[0]["themeTags"] + return self.parse_theme_tags(tags) + + def find_similar( + self, + card_name: str, + threshold: float = 0.8, + limit: int = 10, + min_results: int = 3, + adaptive: bool = True, + use_cache: bool = True, + ) -> list[dict]: + """ + Find cards with similar theme tags. + + Uses adaptive threshold scaling to ensure minimum number of results. + Tries 80% → 60% thresholds until min_results is met (skips 70% for performance). + + Checks cache first for pre-computed results, falls back to real-time calculation. + + Args: + card_name: Name of the target card + threshold: Starting similarity threshold (0.0-1.0), default 0.8 (80%) + limit: Maximum number of results, default 10 + min_results: Minimum desired results for adaptive scaling, default 3 + adaptive: Enable adaptive threshold scaling, default True + use_cache: Check cache first before calculating, default True + + Returns: + List of dicts with keys: name, similarity, themeTags, edhrecRank, threshold_used + Sorted by similarity descending, then by EDHREC rank ascending (more popular first) + Returns empty list if card not found or has no tags + """ + # Check cache first + if use_cache and self.cache.enabled: + cached_results = self.cache.get_similar(card_name, limit=limit, randomize=True) + if cached_results is not None: + logger.info(f"Cache HIT for '{card_name}' ({len(cached_results)} results, randomized)") + return cached_results + else: + logger.info(f"Cache MISS for '{card_name}', calculating...") + + # Get target card tags + target_tags = self.get_card_tags(card_name) + + if target_tags is None: + logger.warning(f"Card not found: {card_name}") + return [] + + if not target_tags: + logger.info(f"Card has no theme tags: {card_name}") + return [] + + # Get signature tags for TARGET card only (top 5 most frequent + 1-3 random) + # This focuses the search on the target's defining characteristics + # with some diversity from random tags + + # Use card name hash as seed for reproducible randomness per card + card_seed = hash(card_name) % (2**31) + target_signature = self.get_signature_tags( + target_tags, + top_n=5, + seed=card_seed + ) + + logger.debug( + f"Target '{card_name}': {len(target_tags)} tags → " + f"{len(target_signature)} signature tags" + ) + + # Try adaptive thresholds if enabled + thresholds_to_try = [threshold] + if adaptive: + # Build list of thresholds to try: 80% → 60% → 50% (skip 70% for performance) + thresholds_to_try = [] + if threshold >= 0.8: + thresholds_to_try.append(0.8) + if threshold >= 0.6: + thresholds_to_try.append(0.6) + if threshold >= 0.5: + thresholds_to_try.append(0.5) + + # Remove duplicates and sort descending + thresholds_to_try = sorted(set(thresholds_to_try), reverse=True) + + results = [] + threshold_used = threshold + + for current_threshold in thresholds_to_try: + # Use inverted index for fast candidate filtering + # Instead of checking all 29k cards, only check cards that share at least one signature tag + results = [] + + # Build inverted index on first use (lazily) + if self._tag_to_cards_index is None: + self._build_tag_index() + + # Get candidate cards that share at least one signature tag + # This drastically reduces the number of cards we need to check + candidate_cards = set() + for tag in target_signature: + if tag in self._tag_to_cards_index: + candidate_cards.update(self._tag_to_cards_index[tag]) + + # Remove the target card itself + candidate_cards.discard(card_name) + + if not candidate_cards: + continue # No candidates at all, try lower threshold + + # Now calculate scores only for candidates (vectorized where possible) + # Pre-filter candidates by checking if they meet minimum overlap requirement + min_overlap = int(len(target_signature) * current_threshold) + + for candidate_name in candidate_cards: + candidate_tags = self.cleaned_tags_cache.get(candidate_name) + + if not candidate_tags: + continue + + # Fast overlap check using set intersection + overlap = target_signature & candidate_tags + overlap_count = len(overlap) + + # Quick filter: skip if overlap too small + if overlap_count < min_overlap: + continue + + # Calculate exact containment score + containment_score = overlap_count / len(target_signature) + + if containment_score >= current_threshold: + # Get EDHREC rank efficiently from card metadata + edhrec_rank = self._card_metadata.get(candidate_name, {}).get('edhrecRank', float('inf')) + + results.append({ + "name": candidate_name, + "similarity": containment_score, + "themeTags": list(candidate_tags), + "edhrecRank": edhrec_rank, + }) + + # Sort by similarity descending, then by EDHREC rank ascending (lower is better) + # Unranked cards (inf) will appear last + results.sort(key=lambda x: (-x["similarity"], x["edhrecRank"])) + + # Check if we have enough results + if len(results) >= min_results or not adaptive: + threshold_used = current_threshold + break + + # Log that we're trying a lower threshold + logger.info( + f"Found {len(results)} results at {current_threshold:.0%} " + f"for '{card_name}', trying lower threshold..." + ) + + # Add threshold_used to results + for result in results: + result["threshold_used"] = threshold_used + + logger.info( + f"Found {len(results)} similar cards for '{card_name}' " + f"at {threshold_used:.0%} threshold" + ) + + final_results = results[:limit] + + # Cache the results for future lookups + if use_cache and self.cache.enabled and final_results: + self.cache.set_similar(card_name, final_results) + logger.debug(f"Cached {len(final_results)} results for '{card_name}'") + + return final_results diff --git a/code/web/services/similarity_cache.py b/code/web/services/similarity_cache.py new file mode 100644 index 0000000..ff4c3aa --- /dev/null +++ b/code/web/services/similarity_cache.py @@ -0,0 +1,386 @@ +""" +Similarity cache manager for card similarity calculations. + +Provides persistent caching of pre-computed card similarity scores to improve +card detail page load times from 2-6s down to <500ms. + +Cache format: Parquet file with columnar structure: +- card_name: str (source card) +- similar_name: str (similar card name) +- similarity: float (similarity score) +- edhrecRank: float (EDHREC rank of similar card) +- rank: int (ranking position, 0-19 for top 20) + +Metadata stored in separate JSON sidecar file. + +Benefits vs JSON: +- 5-10x faster load times +- 50-70% smaller file size +- Better compression for large datasets +- Consistent with other card data storage +""" + +import json +import logging +import os +import pandas as pd +import pyarrow as pa +import pyarrow.parquet as pq +from datetime import datetime +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +# Default cache settings +CACHE_VERSION = "2.0" # Bumped for Parquet format +DEFAULT_CACHE_PATH = Path(__file__).parents[3] / "card_files" / "similarity_cache.parquet" +DEFAULT_METADATA_PATH = Path(__file__).parents[3] / "card_files" / "similarity_cache_metadata.json" + + +class SimilarityCache: + """Manages persistent cache for card similarity calculations using Parquet.""" + + def __init__(self, cache_path: Optional[Path] = None, enabled: bool = True): + """ + Initialize similarity cache manager. + + Args: + cache_path: Path to cache file. If None, uses DEFAULT_CACHE_PATH + enabled: Whether cache is enabled (can be disabled via env var) + """ + self.cache_path = cache_path or DEFAULT_CACHE_PATH + self.metadata_path = self.cache_path.with_name( + self.cache_path.stem + "_metadata.json" + ) + self.enabled = enabled and os.getenv("SIMILARITY_CACHE_ENABLED", "1") == "1" + self._cache_df: Optional[pd.DataFrame] = None + self._metadata: Optional[dict] = None + + # Ensure cache directory exists + self.cache_path.parent.mkdir(parents=True, exist_ok=True) + + if self.enabled: + logger.info(f"SimilarityCache initialized at {self.cache_path}") + else: + logger.info("SimilarityCache disabled") + + def load_cache(self) -> pd.DataFrame: + """ + Load cache from disk. + + Returns: + DataFrame with columns: card_name, similar_name, similarity, edhrecRank, rank + Returns empty DataFrame if file doesn't exist or loading fails + """ + if not self.enabled: + return self._empty_cache_df() + + if self._cache_df is not None: + return self._cache_df + + if not self.cache_path.exists(): + logger.info("Cache file not found, returning empty cache") + self._cache_df = self._empty_cache_df() + return self._cache_df + + try: + # Load Parquet file + self._cache_df = pq.read_table(self.cache_path).to_pandas() + + # Load metadata + if self.metadata_path.exists(): + with open(self.metadata_path, "r", encoding="utf-8") as f: + self._metadata = json.load(f) + else: + self._metadata = self._empty_metadata() + + # Validate cache structure + if not self._validate_cache(self._cache_df): + logger.warning("Cache validation failed, returning empty cache") + self._cache_df = self._empty_cache_df() + return self._cache_df + + total_cards = len(self._cache_df["card_name"].unique()) if len(self._cache_df) > 0 else 0 + logger.info( + f"Loaded similarity cache v{self._metadata.get('version', 'unknown')} with {total_cards:,} cards ({len(self._cache_df):,} entries)" + ) + + return self._cache_df + + except Exception as e: + logger.error(f"Failed to load cache: {e}") + self._cache_df = self._empty_cache_df() + return self._cache_df + + def save_cache(self, cache_df: pd.DataFrame, metadata: Optional[dict] = None) -> bool: + """ + Save cache to disk. + + Args: + cache_df: DataFrame with similarity data + metadata: Optional metadata dict. If None, uses current metadata with updates. + + Returns: + True if save successful, False otherwise + """ + if not self.enabled: + logger.debug("Cache disabled, skipping save") + return False + + try: + # Ensure directory exists + self.cache_path.parent.mkdir(parents=True, exist_ok=True) + + # Update metadata + if metadata is None: + metadata = self._metadata or self._empty_metadata() + + total_cards = len(cache_df["card_name"].unique()) if len(cache_df) > 0 else 0 + metadata["total_cards"] = total_cards + metadata["last_updated"] = datetime.now().isoformat() + metadata["total_entries"] = len(cache_df) + + # Write Parquet file (with compression) + temp_cache = self.cache_path.with_suffix(".tmp") + pq.write_table( + pa.table(cache_df), + temp_cache, + compression="snappy", + version="2.6", + ) + temp_cache.replace(self.cache_path) + + # Write metadata file + temp_meta = self.metadata_path.with_suffix(".tmp") + with open(temp_meta, "w", encoding="utf-8") as f: + json.dump(metadata, f, indent=2, ensure_ascii=False) + temp_meta.replace(self.metadata_path) + + self._cache_df = cache_df + self._metadata = metadata + + logger.info(f"Saved similarity cache with {total_cards:,} cards ({len(cache_df):,} entries)") + + return True + + except Exception as e: + logger.error(f"Failed to save cache: {e}") + return False + + def get_similar(self, card_name: str, limit: int = 5, randomize: bool = True) -> Optional[list[dict]]: + """ + Get cached similar cards for a given card. + + Args: + card_name: Name of the card to look up + limit: Maximum number of results to return + randomize: If True, randomly sample from cached results; if False, return top by rank + + Returns: + List of similar cards with similarity scores, or None if not in cache + """ + if not self.enabled: + return None + + cache_df = self.load_cache() + + if len(cache_df) == 0: + return None + + # Filter to this card + card_data = cache_df[cache_df["card_name"] == card_name] + + if len(card_data) == 0: + return None + + # Randomly sample if requested and we have more results than limit + if randomize and len(card_data) > limit: + card_data = card_data.sample(n=limit, random_state=None) + else: + # Sort by rank and take top N + card_data = card_data.sort_values("rank").head(limit) + + # Convert to list of dicts + results = [] + for _, row in card_data.iterrows(): + results.append({ + "name": row["similar_name"], + "similarity": row["similarity"], + "edhrecRank": row["edhrecRank"], + }) + + return results + + def set_similar(self, card_name: str, similar_cards: list[dict]) -> bool: + """ + Cache similar cards for a given card. + + Args: + card_name: Name of the card + similar_cards: List of similar cards with similarity scores + + Returns: + True if successful, False otherwise + """ + if not self.enabled: + return False + + cache_df = self.load_cache() + + # Remove existing entries for this card + cache_df = cache_df[cache_df["card_name"] != card_name] + + # Add new entries + new_rows = [] + for rank, card in enumerate(similar_cards): + new_rows.append({ + "card_name": card_name, + "similar_name": card["name"], + "similarity": card["similarity"], + "edhrecRank": card.get("edhrecRank", float("inf")), + "rank": rank, + }) + + if new_rows: + new_df = pd.DataFrame(new_rows) + cache_df = pd.concat([cache_df, new_df], ignore_index=True) + + return self.save_cache(cache_df) + + def invalidate(self, card_name: Optional[str] = None) -> bool: + """ + Invalidate cache entries. + + Args: + card_name: If provided, invalidate only this card. If None, clear entire cache. + + Returns: + True if successful, False otherwise + """ + if not self.enabled: + return False + + if card_name is None: + # Clear entire cache + logger.info("Clearing entire similarity cache") + self._cache_df = self._empty_cache_df() + self._metadata = self._empty_metadata() + return self.save_cache(self._cache_df, self._metadata) + + # Clear specific card + cache_df = self.load_cache() + + initial_len = len(cache_df) + cache_df = cache_df[cache_df["card_name"] != card_name] + + if len(cache_df) < initial_len: + logger.info(f"Invalidated cache for card: {card_name}") + return self.save_cache(cache_df) + + return False + + def get_stats(self) -> dict: + """ + Get cache statistics. + + Returns: + Dictionary with cache stats (version, total_cards, build_date, file_size, etc.) + """ + if not self.enabled: + return {"enabled": False} + + cache_df = self.load_cache() + metadata = self._metadata or self._empty_metadata() + + stats = { + "enabled": True, + "version": metadata.get("version", "unknown"), + "total_cards": len(cache_df["card_name"].unique()) if len(cache_df) > 0 else 0, + "total_entries": len(cache_df), + "build_date": metadata.get("build_date"), + "last_updated": metadata.get("last_updated"), + "file_exists": self.cache_path.exists(), + "file_path": str(self.cache_path), + "format": "parquet", + } + + if self.cache_path.exists(): + stats["file_size_mb"] = round( + self.cache_path.stat().st_size / (1024 * 1024), 2 + ) + + return stats + + @staticmethod + def _empty_cache_df() -> pd.DataFrame: + """ + Create empty cache DataFrame. + + Returns: + Empty DataFrame with correct schema + """ + return pd.DataFrame(columns=["card_name", "similar_name", "similarity", "edhrecRank", "rank"]) + + @staticmethod + def _empty_metadata() -> dict: + """ + Create empty metadata structure. + + Returns: + Empty metadata dictionary + """ + return { + "version": CACHE_VERSION, + "total_cards": 0, + "total_entries": 0, + "build_date": None, + "last_updated": None, + "threshold": 0.6, + "min_results": 3, + } + + @staticmethod + def _validate_cache(cache_df: pd.DataFrame) -> bool: + """ + Validate cache DataFrame structure. + + Args: + cache_df: DataFrame to validate + + Returns: + True if valid, False otherwise + """ + if not isinstance(cache_df, pd.DataFrame): + return False + + # Check required columns + required_cols = {"card_name", "similar_name", "similarity", "edhrecRank", "rank"} + if not required_cols.issubset(cache_df.columns): + logger.warning(f"Cache missing required columns. Expected: {required_cols}, Got: {set(cache_df.columns)}") + return False + + return True + + +# Singleton instance for global access +_cache_instance: Optional[SimilarityCache] = None + + +def get_cache() -> SimilarityCache: + """ + Get singleton cache instance. + + Returns: + Global SimilarityCache instance + """ + global _cache_instance + + if _cache_instance is None: + # Check environment variables for custom path + cache_path_str = os.getenv("SIMILARITY_CACHE_PATH") + cache_path = Path(cache_path_str) if cache_path_str else None + + _cache_instance = SimilarityCache(cache_path=cache_path) + + return _cache_instance diff --git a/code/web/static/styles.css b/code/web/static/styles.css index 5bee967..02cc051 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -906,6 +906,90 @@ img.lqip.loaded { filter: blur(0); opacity: 1; } white-space: nowrap; } +/* Card Details button on tiles */ +.card-details-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.5rem 0.75rem; + background: var(--primary); + color: white; + text-decoration: none; + border-radius: 6px; + font-weight: 500; + font-size: 0.85rem; + transition: all 0.2s; + margin-top: 0.5rem; + border: none; + cursor: pointer; +} + +.card-details-btn:hover { + background: var(--primary-hover); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4); +} + +.card-details-btn svg { + flex-shrink: 0; +} + +/* Card Preview Modal */ +.preview-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.85); + z-index: 9999; + align-items: center; + justify-content: center; +} + +.preview-modal.active { + display: flex; +} + +.preview-content { + position: relative; + max-width: 90%; + max-height: 90%; +} + +.preview-content img { + max-width: 100%; + max-height: 90vh; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +.preview-close { + position: absolute; + top: -40px; + right: 0; + background: rgba(255, 255, 255, 0.9); + color: #000; + border: none; + border-radius: 50%; + width: 36px; + height: 36px; + font-size: 24px; + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.preview-close:hover { + background: #fff; + transform: scale(1.1); +} + /* Pagination controls */ .card-browser-pagination { display: flex; @@ -1063,3 +1147,55 @@ img.lqip.loaded { filter: blur(0); opacity: 1; } .theme-chip button:hover { color: var(--error-color); } + +/* Card Detail Page Styles */ +.card-tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; + margin-bottom: 1rem; +} + +.card-tag { + background: var(--ring); + color: white; + padding: 0.35rem 0.75rem; + border-radius: 16px; + font-size: 0.85rem; + font-weight: 500; +} + +.back-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: var(--panel); + color: var(--text); + text-decoration: none; + border-radius: 8px; + border: 1px solid var(--border); + font-weight: 500; + transition: all 0.2s; + margin-bottom: 2rem; +} + +.back-button:hover { + background: var(--ring); + color: white; + border-color: var(--ring); +} + +/* Card Detail Page - Main Card Image */ +.card-image-large { + flex: 0 0 auto; + max-width: 360px !important; + width: 100%; +} + +.card-image-large img { + width: 100%; + height: auto; + border-radius: 12px; +} diff --git a/code/web/templates/browse/cards/_card_tile.html b/code/web/templates/browse/cards/_card_tile.html index cbd9cbe..f3911c0 100644 --- a/code/web/templates/browse/cards/_card_tile.html +++ b/code/web/templates/browse/cards/_card_tile.html @@ -1,6 +1,6 @@ {# Single card tile for grid display #}
- {# Card image #} + {# Card image (uses hover system for preview) #}
+ {# Card Details button (only show if feature enabled) #} + {% if enable_card_details %} + + Card Details + + + + + {% endif %} + {# Theme tags (show all tags, not truncated) #} {% if card.themeTags_parsed and card.themeTags_parsed|length > 0 %}
diff --git a/code/web/templates/browse/cards/_similar_cards.html b/code/web/templates/browse/cards/_similar_cards.html new file mode 100644 index 0000000..514fd17 --- /dev/null +++ b/code/web/templates/browse/cards/_similar_cards.html @@ -0,0 +1,250 @@ + + +
+
+

Similar Cards

+
+ + {% if similar_cards and similar_cards|length > 0 %} +
+ {% for card in similar_cards %} +
+ +
+ {{ card.name }} + {# Fallback for missing images #} +
+ {{ card.name }} +
+
+ + +
+
{{ card.name }}
+ + + {% if card.themeTags and card.themeTags|length > 0 %} + {% set main_card_tags = main_card_tags|default([]) %} + {% set matching_tags = [] %} + {% for tag in card.themeTags %} + {% if tag in main_card_tags %} + {% set _ = matching_tags.append(tag) %} + {% endif %} + {% endfor %} + {% if matching_tags|length > 0 %} +
+ ✓ {{ matching_tags|length }} matching theme{{ 's' if matching_tags|length > 1 else '' }} +
+ {% endif %} + {% endif %} + + + {% if card.edhrecRank %} +
+ EDHREC Rank: #{{ card.edhrecRank }} +
+ {% endif %} + + + {% if card.themeTags and card.themeTags|length > 0 %} +
+ {% set main_card_tags = main_card_tags|default([]) %} + {% for tag in card.themeTags %} + {% set is_overlap = tag in main_card_tags %} + + {{ tag }} + + {% endfor %} +
+ {% endif %} +
+ + + + Card Details + + + + +
+ {% endfor %} +
+ {% else %} +
+
🔍
+
No similar cards found
+

+ This card has unique theme tags or no cards share similar characteristics. +

+
+ {% endif %} +
diff --git a/code/web/templates/browse/cards/detail.html b/code/web/templates/browse/cards/detail.html new file mode 100644 index 0000000..2f7d128 --- /dev/null +++ b/code/web/templates/browse/cards/detail.html @@ -0,0 +1,273 @@ +{% extends "base.html" %} + +{% block title %}{{ card.name }} - Card Details{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+ + + + + + Back to Card Browser + + + +
+ +
+ {{ card.name }} + {# Fallback for missing images #} +
+ {{ card.name }} +
+
+ + +
+

{{ card.name }}

+ +
{{ card.type }}
+ + + {% if card.colors %} +
+ {% for color in card.colors %} + {{ color }} + {% endfor %} +
+ {% endif %} + + +
+ {% if card.manaValue is not none %} +
+ Mana Value + {{ card.manaValue }} +
+ {% endif %} + + {% if card.power is not none and card.power != 'NaN' and card.power|string != 'nan' %} +
+ Power / Toughness + {{ card.power }} / {{ card.toughness }} +
+ {% endif %} + + {% if card.edhrecRank %} +
+ EDHREC Rank + #{{ card.edhrecRank }} +
+ {% endif %} + + {% if card.rarity %} +
+ Rarity + {{ card.rarity | capitalize }} +
+ {% endif %} +
+ + + {% if card.text %} +
{{ card.text | replace('\\n', '\n') }}
+ {% endif %} + + + {% if card.themeTags_parsed and card.themeTags_parsed|length > 0 %} +
+ {% for tag in card.themeTags_parsed %} + {{ tag }} + {% endfor %} +
+ {% endif %} +
+
+ + +
+ {% include "browse/cards/_similar_cards.html" %} +
+
+{% endblock %} diff --git a/code/web/templates/browse/cards/index.html b/code/web/templates/browse/cards/index.html index f891bf5..6c98a11 100644 --- a/code/web/templates/browse/cards/index.html +++ b/code/web/templates/browse/cards/index.html @@ -345,7 +345,7 @@
+ + {% if similarity_enabled %} +
+ Similarity Cache Status +
+
Status:
+
Checking…
+ + +
+
+
+ + + (~15-20 min local, instant if cached on GitHub) +
+ {% endif %}