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'
Browse all {{ total_cards }} cards with filters and search.
+ + {# Error message #} + {% if error %} +