diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c568718..597c399 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot Instructions for scout-merit-badges-anki +# Copilot Instructions for scout-anki ## Repository Overview -This is a Python CLI tool that generates Anki flashcard decks (.apkg files) for learning Scouting America merit badges by image. The tool processes local archive files (.zip, .tar.gz) containing merit badge data and images, maps badges to their corresponding images using sophisticated pattern matching, and creates structured Anki decks with stable IDs to prevent duplicates on reimport. +This is a Python CLI tool that generates Anki flashcard decks (.apkg files) for Scouting content. Supports learning Scouting America merit badges and Cub Scout adventures by image. The tool processes local archive files (.tar.gz) containing badge/adventure data and images, maps content to images using direct `image_filename` field mapping, and creates structured Anki decks with stable IDs to prevent duplicates on reimport. **Repository Stats:** - Language: Python 3.11+ (currently using 3.12) @@ -59,7 +59,13 @@ make cov-report uv run pytest --cov-report=html && open htmlcov/index.html # Run CLI tool for testing -uv run scout-merit-badges-anki --help +uv run scout-anki --help + +# Build merit badge deck +uv run scout-anki build merit-badges extracted/ + +# Build cub adventure deck +uv run scout-anki build cub-adventures extracted/ ``` ### Pre-commit Hooks (MANDATORY) @@ -79,7 +85,7 @@ uv run pre-commit run --all-files ### Validation Commands ```bash # Test CLI functionality (dry run - safe to run) -uv run scout-merit-badges-anki build --dry-run --quiet *.zip *.tar.gz +uv run scout-anki build --dry-run --quiet *.zip *.tar.gz # Download and test with latest release make fetch-and-build @@ -115,7 +121,7 @@ make clean # Removes build artifacts, .venv, __pycache__, *.apkg files, coverag tests/ ├── test_cli_simple.py # Basic CLI tests (4 tests) ├── test_cli_comprehensive.py # CLI functionality with mocking (2 tests) -└── test_scout_merit_badges_anki.py # Directory processing integration (2 tests) +└── test_scout_anki.py # Directory processing integration (2 tests) ``` ### Coverage by Module @@ -152,7 +158,7 @@ When adding new functionality: ## Project Architecture -### Core Modules (`scout_merit_badges_anki/`) +### Core Modules (`scout_anki/`) - `cli.py` - Click-based command line interface with build command - `archive.py` - Archive processing (ZIP/TAR.GZ) and file extraction - `mapping.py` - Sophisticated image-to-badge mapping logic with pattern matching @@ -213,7 +219,7 @@ Uses deterministic hashing in `schema.py` to generate stable Anki model/deck IDs ```bash # 1. Clone repository git clone -cd scout-merit-badges-anki +cd scout-anki # 2. Set up environment (REQUIRED) uv sync @@ -243,7 +249,7 @@ git commit -m "Your commit message" ### Testing Changes ```bash # Test CLI functionality without creating files -uv run scout-merit-badges-anki build --dry-run *.zip *.tar.gz +uv run scout-anki build --dry-run *.zip *.tar.gz # Download and test with latest release make fetch-and-build @@ -295,11 +301,11 @@ make test ## File Structure Reference ``` -scout-merit-badges-anki/ +scout-anki/ ├── .github/ │ ├── copilot-instructions.md # This file - development guidelines │ └── workflows/ # CI/CD pipelines -├── scout_merit_badges_anki/ # Main package +├── scout_anki/ # Main package │ ├── __init__.py # Package metadata │ ├── __main__.py # Entry point for python -m │ ├── cli.py # Command line interface diff --git a/.gitignore b/.gitignore index 26044a4..2d476f7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ venv .eggs .pytest_cache +.ruff_cache *.egg-info .DS_Store diff --git a/Makefile b/Makefile index d4a8d25..872a37b 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,22 @@ -.PHONY: fmt lint test test-cov test-no-cov cov-report build-deck clean setup-pre-commit check-all help fetch-releases extract-archives fetch-and-build +.PHONY: fmt lint test test-cov test-no-cov cov-report build-merit-badges build-cub-adventures build-all clean setup-pre-commit check-all help fetch-releases extract-archives fetch-and-build-merit-badges fetch-and-build-cub-adventures help: @echo "Available commands:" - @echo " fetch-and-build - Fetch, extract, and build deck in one command" - @echo " fetch-releases - Fetch scout-archive releases using gh CLI" - @echo " extract-archives - Extract downloaded archives to extracted/ directory" - @echo " build-deck - Build Anki deck from extracted directory" - @echo " setup-pre-commit - Install pre-commit hooks" - @echo " check-all - Run all pre-commit hooks on all files" - @echo " fmt - Format code with ruff" - @echo " lint - Lint code with ruff" - @echo " test - Run tests with coverage reporting (80% target)" - @echo " test-no-cov - Run tests without coverage reporting" - @echo " cov-report - Generate and open HTML coverage report" - @echo " clean - Clean up temporary files" + @echo " fetch-and-build-merit-badges - Fetch, extract, and build merit badge deck" + @echo " fetch-and-build-cub-adventures - Fetch, extract, and build cub adventure deck" + @echo " fetch-releases - Fetch scout-archive releases using gh CLI" + @echo " extract-archives - Extract downloaded archives to extracted/ directory" + @echo " build-merit-badges - Build merit badge Anki deck from extracted directory" + @echo " build-cub-adventures - Build cub adventure Anki deck from extracted directory" + @echo " build-all - Build both merit badge and cub adventure decks" + @echo " setup-pre-commit - Install pre-commit hooks" + @echo " check-all - Run all pre-commit hooks on all files" + @echo " fmt - Format code with ruff" + @echo " lint - Lint code with ruff" + @echo " test - Run tests with coverage reporting (80% target)" + @echo " test-no-cov - Run tests without coverage reporting" + @echo " cov-report - Generate and open HTML coverage report" + @echo " clean - Clean up temporary files" setup-pre-commit: uv run pre-commit install @@ -46,10 +49,17 @@ extract-archives: mkdir -p extracted/ for file in *.tar.gz; do [ -f "$$file" ] && tar -xzf "$$file" -C extracted/ || true; done -build-deck: - uv run scout-merit-badges-anki build extracted/ +build-merit-badges: + uv run scout-anki build merit-badges extracted/ -fetch-and-build: fetch-releases extract-archives build-deck +build-cub-adventures: + uv run scout-anki build cub-adventures extracted/ + +build-all: build-merit-badges build-cub-adventures + +fetch-and-build-merit-badges: fetch-releases extract-archives build-merit-badges + +fetch-and-build-cub-adventures: fetch-releases extract-archives build-cub-adventures clean: rm -rf build/ diff --git a/README.md b/README.md index 7f9709c..5cfb97f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# scout-merit-badges-anki +# scout-anki -Generates an Anki deck for learning Scouting America merit badges by sight. The front of the card is an image of the merit badge. The back of the card is the name, description, and an indicator if it is required for the Eagle rank. +Anki deck builder for Scouting content. Generates Anki decks for learning Scouting America merit badges and Cub Scout adventures by sight. The front of the card is an image of the merit badge or adventure loop. The back of the card is the name, description, and additional details. ## Usage @@ -9,38 +9,51 @@ Generates an Anki deck for learning Scouting America merit badges by sight. The First, download and extract the scout-archive release files: ```bash -# Download and extract latest release archives -make fetch-and-build +# Download and extract latest release archives for merit badges +make fetch-and-build-merit-badges + +# Download and extract latest release archives for cub adventures +make fetch-and-build-cub-adventures + +# Build both deck types +make fetch-releases extract-archives build-all ``` -This downloads archives, extracts them to `extracted/` directory, and creates `merit_badges_image_trainer.apkg` that can be imported into Anki. +This downloads archives, extracts them to `extracted/` directory, and creates `.apkg` files that can be imported into Anki. ### Manual Usage ```bash # Download archives -gh release download --repo dasevilla/scout-archive --pattern "*.zip" --pattern "*.tar.gz" +gh release download --repo dasevilla/scout-archive --pattern "*.tar.gz" # Extract archives mkdir -p extracted/ -for file in *.zip; do unzip -q "$file" -d extracted/; done for file in *.tar.gz; do tar -xzf "$file" -C extracted/; done -# Generate Anki deck from extracted directory -scout-merit-badges-anki build extracted/ +# Generate merit badge Anki deck from extracted directory +scout-anki build merit-badges extracted/ + +# Generate cub adventure Anki deck from extracted directory +scout-anki build cub-adventures extracted/ ``` ### Advanced Usage ```bash -# Custom output file -scout-merit-badges-anki build extracted/ --out my_badges.apkg +# Merit badges with custom output file +scout-anki build merit-badges extracted/ --out my_badges.apkg + +# Cub adventures with custom output file +scout-anki build cub-adventures extracted/ --out my_adventures.apkg # Dry run to preview without creating file -scout-merit-badges-anki build extracted/ --dry-run +scout-anki build merit-badges extracted/ --dry-run +scout-anki build cub-adventures extracted/ --dry-run # Custom deck and model names -scout-merit-badges-anki build extracted/ --deck-name "My Badges" --model-name "Badge Quiz" +scout-anki build merit-badges extracted/ --deck-name "My Badges" --model-name "Badge Quiz" +scout-anki build cub-adventures extracted/ --deck-name "My Adventures" --model-name "Adventure Quiz" ``` ### Command Reference @@ -48,48 +61,53 @@ scout-merit-badges-anki build extracted/ --deck-name "My Badges" --model-name "B #### `build` - Generate Anki deck ```bash -scout-merit-badges-anki build [DIRECTORY] [OPTIONS] +scout-anki build DECK_TYPE DIRECTORY [OPTIONS] ``` **Arguments:** +- `DECK_TYPE` - Type of deck to build: `merit-badges` or `cub-adventures` - `DIRECTORY` - Directory containing extracted badge data and images **Options:** -- `--out PATH` - Output file path (default: `merit_badges_image_trainer.apkg`) -- `--deck-name TEXT` - Anki deck name (default: `Merit Badges Image Trainer`) -- `--model-name TEXT` - Anki model name (default: `Merit Badge Image → Text`) +- `--out PATH` - Output file path (auto-generated based on deck type if not specified) +- `--deck-name TEXT` - Anki deck name (auto-generated based on deck type if not specified) +- `--model-name TEXT` - Anki model name (auto-generated based on deck type if not specified) - `--dry-run` - Preview without creating .apkg file - `-q, --quiet` - Only show errors - `-v, --verbose` - Increase verbosity ## How It Works -1. **Reads local archive files** (.zip, .tar.gz) containing badge data and images -2. **Extracts badge data** from JSON files using flexible schema normalization -3. **Maps badges to images** using explicit filenames or smart pattern matching +1. **Reads local archive files** (.tar.gz) containing Scouting data and images +2. **Extracts content data** from JSON files using flexible schema normalization +3. **Maps content to images** using direct `image_filename` field mapping 4. **Creates Anki deck** with stable IDs to prevent duplicates on reimport 5. **Bundles media files** into a complete .apkg package ### Image Mapping Strategy -The tool uses a sophisticated strategy to match badges with images: +The tool uses direct field mapping for reliable image association: -1. **Explicit mapping**: If JSON specifies an image filename, match by basename -2. **Pattern matching**: Look for `-merit-badge.*` format -3. **Exact slug match**: Match badge slug directly to image filename -4. **Shortest path preference**: When multiple candidates exist, choose the shortest filename +1. **Direct mapping**: Uses the `image_filename` field from JSON data +2. **100% success rate**: All badges and adventures now have explicit image filenames +3. **No pattern matching needed**: Simplified from complex inference logic ### Card Format +**Merit Badges:** - **Front**: Merit badge image (centered, 85% width) -- **Back**: Badge name and description +- **Back**: Badge name, description, and Eagle required indicator + +**Cub Adventures:** +- **Front**: Adventure loop image (centered, 85% width) +- **Back**: Adventure name, rank, type, and description ## Development To contribute to this tool, first checkout the code. Then set up the development environment: ```bash -cd scout-merit-badges-anki +cd scout-anki uv sync ``` @@ -107,22 +125,6 @@ To run the tests: make test ``` -### Development Commands - -```bash -make setup-pre-commit # Install pre-commit hooks (REQUIRED) -make fmt # Format code with ruff and fix linting issues -make lint # Lint code with ruff (check only, no fixes) -make check-all # Run all pre-commit hooks on all files -make test # Run tests (8 focused tests) -make test-no-cov # Run tests without coverage (faster for development) -make fetch-releases # Download scout-archive releases using gh CLI -make extract-archives # Extract downloaded archives to extracted/ directory -make build-deck # Build Anki deck from extracted directory -make fetch-and-build # Fetch, extract, and build deck in one command -make clean # Clean temporary files -``` - ## Data Source -This tool works with releases from the [scout-archive](https://github.com/dasevilla/scout-archive) repository, which contains merit badge data and images updated roughly weekly. +This tool is built using data from the [scout-archive](https://github.com/dasevilla/scout-archive) repository, which is updated roughly weekly. diff --git a/pyproject.toml b/pyproject.toml index bc7f154..924660d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] -name = "scout-merit-badges-anki" +name = "scout-anki" version = "0.1.0" -description = "Generates an Anki deck for learning Scouting America merit badges by image" +description = "Anki deck builder for Scouting content" readme = "README.md" authors = [{name = "Devin Sevilla"}] license = "Apache-2.0" @@ -13,13 +13,13 @@ dependencies = [ ] [project.urls] -Homepage = "https://github.com/dasevilla/scout-merit-badges-anki" -Changelog = "https://github.com/dasevilla/scout-merit-badges-anki/releases" -Issues = "https://github.com/dasevilla/scout-merit-badges-anki/issues" -CI = "https://github.com/dasevilla/scout-merit-badges-anki/actions" +Homepage = "https://github.com/dasevilla/scout-anki" +Changelog = "https://github.com/dasevilla/scout-anki/releases" +Issues = "https://github.com/dasevilla/scout-anki/issues" +CI = "https://github.com/dasevilla/scout-anki/actions" [project.scripts] -scout-merit-badges-anki = "scout_merit_badges_anki.cli:cli" +scout-anki = "scout_anki.cli:cli" [build-system] requires = ["hatchling"] @@ -58,7 +58,7 @@ minversion = "7.0" addopts = [ "--strict-markers", "--strict-config", - "--cov=scout_merit_badges_anki", + "--cov=scout_anki", "--cov-report=term-missing", "--cov-report=html:htmlcov", "--cov-report=xml", @@ -70,7 +70,7 @@ markers = [ ] [tool.coverage.run] -source = ["scout_merit_badges_anki"] +source = ["scout_anki"] omit = [ "*/tests/*", "*/test_*", diff --git a/scout_merit_badges_anki/__init__.py b/scout_anki/__init__.py similarity index 100% rename from scout_merit_badges_anki/__init__.py rename to scout_anki/__init__.py diff --git a/scout_merit_badges_anki/__main__.py b/scout_anki/__main__.py similarity index 100% rename from scout_merit_badges_anki/__main__.py rename to scout_anki/__main__.py diff --git a/scout_anki/cli.py b/scout_anki/cli.py new file mode 100644 index 0000000..79b1171 --- /dev/null +++ b/scout_anki/cli.py @@ -0,0 +1,69 @@ +"""Command line interface for scout-anki.""" + +import sys + +import click + +from .cub_adventures.processor import AdventureProcessor +from .errors import NoBadgesFoundError, NoImagesFoundError +from .log import setup_logging +from .merit_badges.processor import MeritBadgeProcessor + + +@click.group() +def cli(): + """Scout Archive to Anki deck tools.""" + pass + + +@cli.command() +@click.argument("deck_type", type=click.Choice(["merit-badges", "cub-adventures"])) +@click.argument("directory_path", type=click.Path(exists=True, file_okay=False, readable=True)) +@click.option( + "--out", + type=click.Path(writable=True), + help="Output file path (default: auto-generated based on deck type)", +) +@click.option("--deck-name", help="Anki deck name (default: auto-generated based on deck type)") +@click.option("--model-name", help="Anki model name (default: auto-generated based on deck type)") +@click.option("--dry-run", is_flag=True, default=False, help="Run without writing .apkg file") +@click.option("-q", "--quiet", is_flag=True, help="Only show errors") +@click.option("-v", "--verbose", count=True, help="Increase verbosity") +def build( + deck_type, + directory_path, + out, + deck_name, + model_name, + dry_run, + quiet, + verbose, +): + """Build Anki deck from extracted directory.""" + + # Setup logging + logger = setup_logging(quiet=quiet, verbose=verbose) + + try: + # Create appropriate processor + if deck_type == "merit-badges": + processor = MeritBadgeProcessor() + elif deck_type == "cub-adventures": + processor = AdventureProcessor() + + # Build deck using processor + processor.build_deck(directory_path, out, deck_name, model_name, dry_run) + + except NoImagesFoundError as e: + logger.error(str(e)) + sys.exit(3) + except NoBadgesFoundError as e: + logger.error(str(e)) + sys.exit(4) + except Exception as e: + logger.error(f"Unexpected error: {e}") + if verbose: + import traceback + + traceback.print_exc() + sys.exit(1) diff --git a/scout_anki/cub_adventures/__init__.py b/scout_anki/cub_adventures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scout_anki/cub_adventures/processor.py b/scout_anki/cub_adventures/processor.py new file mode 100644 index 0000000..62bd96e --- /dev/null +++ b/scout_anki/cub_adventures/processor.py @@ -0,0 +1,106 @@ +"""Adventure processor.""" + +from pathlib import Path + +import click +import genanki + +from .. import deck +from ..image_utils import map_content_by_image_filename +from ..log import get_logger +from ..processor import DeckProcessor +from .schema import Adventure, process_adventure_directory + + +class AdventureProcessor(DeckProcessor): + """Processor for Cub Scout adventure decks.""" + + def __init__(self): + super().__init__("adventures") + + def get_defaults(self) -> dict[str, str]: + """Get default values for adventures.""" + return { + "out": "cub_scout_adventure_image_trainer.apkg", + "deck_name": "Cub Scout Adventure Image Trainer", + "model_name": "Cub Scout Adventure Quiz", + } + + def process_directory(self, directory_path: str) -> tuple[list[Adventure], dict[str, Path]]: + """Process directory for adventures.""" + return process_adventure_directory(directory_path) + + def map_content_to_images( + self, content: list[Adventure], images: dict[str, Path] + ) -> tuple[list[tuple[Adventure, str]], list[Adventure]]: + """Map adventures to images.""" + logger = get_logger() + mapped_adventures, unmapped_adventures = map_content_by_image_filename(content, images) + + logger.info( + f"Mapped {len(mapped_adventures)} adventures to images, " + f"{len(unmapped_adventures)} adventures without images" + ) + return mapped_adventures, unmapped_adventures + + def create_mapping_summary( + self, + content: list[Adventure], + images: dict[str, Path], + mapped: list[tuple[Adventure, str]], + unmapped: list[Adventure], + ) -> dict[str, int | list[str]]: + """Create mapping summary for adventures.""" + used_images = {image_name for _, image_name in mapped} + unused_images = set(images.keys()) - used_images + + missing_image_details = [] + for adventure in unmapped: + expected = getattr(adventure, "image_filename", f"{adventure.slug}.jpg") + missing_image_details.append( + { + "adventure_name": f"{adventure.name} ({adventure.rank})", + "expected_image": expected, + } + ) + + return { + "total_adventures": len(content), + "total_images": len(images), + "mapped_adventures": len(mapped), + "unmapped_adventures": len(unmapped), + "unused_images": len(unused_images), + "missing_image_details": missing_image_details, + } + + def print_summary(self, summary: dict[str, int | list[str]], dry_run: bool) -> None: + """Print adventure summary.""" + click.echo("\n" + "=" * 60) + click.echo("BUILD SUMMARY") + click.echo("=" * 60) + + click.echo(f"Total adventures in JSON: {summary['total_adventures']}") + click.echo(f"Total images available: {summary['total_images']}") + click.echo(f"Adventures mapped to images: {summary['mapped_adventures']}") + click.echo(f"Adventures without images: {summary['unmapped_adventures']}") + click.echo(f"Unused images: {summary['unused_images']}") + + if summary["missing_image_details"]: + click.echo(f"\nMissing images ({len(summary['missing_image_details'])}):") + for item in summary["missing_image_details"]: + click.echo(f" • {item['adventure_name']} → {item['expected_image']}") + + if dry_run: + click.echo(f"\n[DRY RUN] Would create deck with {summary['mapped_adventures']} notes") + + click.echo("=" * 60) + + def create_deck( + self, + deck_name: str, + model_name: str, + mapped_content: list[tuple[Adventure, str]], + images: dict[str, Path], + ) -> tuple[genanki.Deck, list[str]]: + """Create adventure deck.""" + return deck.create_adventure_deck(deck_name, model_name, mapped_content, images) diff --git a/scout_anki/cub_adventures/schema.py b/scout_anki/cub_adventures/schema.py new file mode 100644 index 0000000..519d36e --- /dev/null +++ b/scout_anki/cub_adventures/schema.py @@ -0,0 +1,84 @@ +"""Cub Scout adventure processing.""" + +import json +from dataclasses import dataclass +from pathlib import Path + +from ..image_utils import discover_images +from ..log import get_logger + + +@dataclass +class Adventure: + """Cub Scout adventure data structure.""" + + name: str + rank: str + type: str # Required/Elective + overview: str + image_filename: str | None = None + + @property + def slug(self) -> str: + """Generate slug for image matching.""" + return self.name.lower().replace(" ", "-").replace("'", "") + + @property + def stable_id(self) -> int: + """Generate stable ID for Anki.""" + return abs(hash(f"adventure:{self.rank}:{self.name}")) % (2**31) + + +def normalize_adventure_data(data: dict) -> Adventure: + """Convert JSON adventure data to Adventure object.""" + return Adventure( + name=data.get("adventure_name", ""), + rank=data.get("rank_name", ""), + type=data.get("adventure_type", ""), + overview=data.get("adventure_overview", ""), + image_filename=data.get("image_filename"), + ) + + +def process_adventure_directory(directory_path: str) -> tuple[list[Adventure], dict[str, Path]]: + """Process directory for Cub Scout adventures and images.""" + logger = get_logger() + directory = Path(directory_path) + + adventures = [] + available_images = {} + + # Find all adventure JSON files in rank directories + rank_dirs = ["lion", "tiger", "wolf", "bear", "webelos", "arrow-of-light"] + + for rank_dir in rank_dirs: + rank_path = directory / rank_dir + if not rank_path.exists(): + continue + + logger.debug(f"Processing rank directory: {rank_path}") + + # Process JSON files + for json_file in rank_path.glob("*.json"): + if json_file.name.startswith("bobcat"): + continue # Skip bobcat files + + try: + with open(json_file, encoding="utf-8") as f: + data = json.load(f) + adventure = normalize_adventure_data(data) + if adventure.name: # Only add if we have a name + adventures.append(adventure) + logger.debug(f"Added adventure: {adventure.name} ({adventure.rank})") + except (json.JSONDecodeError, KeyError) as e: + logger.warning(f"Failed to parse {json_file}: {e}") + + # Process images + images_dir = rank_path / "images" + if images_dir.exists(): + rank_images = discover_images(images_dir) + available_images.update(rank_images) + logger.debug(f"Found {len(rank_images)} images in {images_dir}") + + logger.info(f"Found {len(adventures)} adventures and {len(available_images)} images") + return adventures, available_images diff --git a/scout_merit_badges_anki/deck.py b/scout_anki/deck.py similarity index 57% rename from scout_merit_badges_anki/deck.py rename to scout_anki/deck.py index 19d1e2c..1904e75 100644 --- a/scout_merit_badges_anki/deck.py +++ b/scout_anki/deck.py @@ -6,8 +6,10 @@ import genanki +from .cub_adventures.schema import Adventure from .log import get_logger -from .schema import Badge, slug, stable_id +from .merit_badges.schema import MeritBadge +from .schema import slug, stable_id def create_merit_badge_model(model_name: str) -> genanki.Model: @@ -96,7 +98,9 @@ def create_merit_badge_model(model_name: str) -> genanki.Model: ) -def create_merit_badge_note(badge: Badge, image_name: str, model: genanki.Model) -> genanki.Note: +def create_merit_badge_note( + badge: MeritBadge, image_name: str, model: genanki.Model +) -> genanki.Note: """Create an Anki note for a merit badge. Args: @@ -126,7 +130,7 @@ def create_merit_badge_note(badge: Badge, image_name: str, model: genanki.Model) def create_merit_badge_deck( deck_name: str, model_name: str, - mapped_badges: list[tuple[Badge, str]], + mapped_badges: list[tuple[MeritBadge, str]], available_images: dict[str, Path], ) -> tuple[genanki.Deck, list[str]]: """Create an Anki deck with merit badge notes. @@ -214,3 +218,127 @@ def cleanup_temp_files(media_files: list[str]) -> None: os.rmdir(temp_dir) except OSError: pass # Directory not empty or other error, ignore + + +def create_adventure_model(model_name: str) -> genanki.Model: + """Create the Anki model for adventure cards.""" + model_id = stable_id(model_name) + + fields = [ + {"name": "Image"}, + {"name": "Name"}, + {"name": "Rank"}, + {"name": "Type"}, + {"name": "Overview"}, + ] + + templates = [ + { + "name": "Adventure Card", + "qfmt": """ +
+ {{Image}} +
+ """, + "afmt": """ +
+ {{Image}} +
+
+ {{Name}} +
+
+ {{Rank}} • {{Type}} +
+
+ {{Overview}} +
+
+ """, + } + ] + + css = """ + .card { + font-family: Arial, sans-serif; + font-size: 16px; + text-align: center; + color: black; + background-color: white; + } + img { + max-width: 85%; + height: auto; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + } + """ + + return genanki.Model( + model_id=model_id, + name=model_name, + fields=fields, + templates=templates, + css=css, + ) + + +def create_adventure_deck( + deck_name: str, + model_name: str, + mapped_adventures: list[tuple[Adventure, str]], + available_images: dict[str, Path], +) -> tuple[genanki.Deck, list[str]]: + """Create Anki deck for Cub Scout adventures.""" + logger = get_logger() + + # Create model and deck + model = create_adventure_model(model_name) + deck_id = stable_id(deck_name) + deck = genanki.Deck(deck_id=deck_id, name=deck_name) + + # Create temporary directory for media files + temp_dir = tempfile.mkdtemp() + media_files = [] + + for adventure, image_name in mapped_adventures: + # Copy image to temp directory + source_path = available_images[image_name] + temp_image_path = os.path.join(temp_dir, image_name) + + try: + import shutil + + shutil.copy2(source_path, temp_image_path) + media_files.append(temp_image_path) + except Exception as e: + logger.warning(f"Failed to copy image {image_name}: {e}") + continue + + # Create note + note_id = adventure.stable_id + + # Truncate overview if too long + overview = adventure.overview + if len(overview) > 500: + overview = overview[:497] + "..." + + fields = [ + f'', + adventure.name, + adventure.rank, + adventure.type, + overview, + ] + + note = genanki.Note( + model=model, + fields=fields, + guid=str(note_id), + ) + + deck.add_note(note) + + logger.info(f"Created deck '{deck_name}' with {len(deck.notes)} notes") + return deck, media_files diff --git a/scout_merit_badges_anki/errors.py b/scout_anki/errors.py similarity index 79% rename from scout_merit_badges_anki/errors.py rename to scout_anki/errors.py index a0132de..68f7e27 100644 --- a/scout_merit_badges_anki/errors.py +++ b/scout_anki/errors.py @@ -13,6 +13,12 @@ class NoBadgesFoundError(ScoutAnkiError): pass +class NoImagesFoundError(ScoutAnkiError): + """No images found in directory.""" + + pass + + class ValidationError(ScoutAnkiError): """Validation failed with missing images.""" diff --git a/scout_anki/image_utils.py b/scout_anki/image_utils.py new file mode 100644 index 0000000..782cc3b --- /dev/null +++ b/scout_anki/image_utils.py @@ -0,0 +1,42 @@ +"""Shared image processing utilities.""" + +from pathlib import Path +from typing import Protocol + +IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"} + + +class ContentItem(Protocol): + """Protocol for content items that can be mapped to images.""" + + image_filename: str | None + + +def discover_images(directory: Path) -> dict[str, Path]: + """Discover all image files in directory and subdirectories.""" + available_images = {} + for img_file in directory.glob("**/*"): + if img_file.is_file() and img_file.suffix.lower() in IMAGE_EXTENSIONS: + available_images[img_file.name] = img_file + return available_images + + +def map_content_by_image_filename( + content: list[ContentItem], images: dict[str, Path] +) -> tuple[list[tuple[ContentItem, str]], list[ContentItem]]: + """Map content to images using image_filename field.""" + mapped = [] + unmapped = [] + + for item in content: + image_name = None + if hasattr(item, "image_filename") and item.image_filename: + if item.image_filename in images: + image_name = item.image_filename + + if image_name: + mapped.append((item, image_name)) + else: + unmapped.append(item) + + return mapped, unmapped diff --git a/scout_merit_badges_anki/log.py b/scout_anki/log.py similarity index 93% rename from scout_merit_badges_anki/log.py rename to scout_anki/log.py index 63f385c..741d95d 100644 --- a/scout_merit_badges_anki/log.py +++ b/scout_anki/log.py @@ -34,7 +34,7 @@ def setup_logging(quiet: bool = False, verbose: int = 0) -> logging.Logger: Returns: Configured logger """ - logger = logging.getLogger("scout_merit_badges_anki") + logger = logging.getLogger("scout_anki") # Clear any existing handlers logger.handlers.clear() @@ -69,4 +69,4 @@ def setup_logging(quiet: bool = False, verbose: int = 0) -> logging.Logger: def get_logger() -> logging.Logger: """Get the configured logger.""" - return logging.getLogger("scout_merit_badges_anki") + return logging.getLogger("scout_anki") diff --git a/scout_anki/merit_badges/__init__.py b/scout_anki/merit_badges/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scout_anki/merit_badges/processor.py b/scout_anki/merit_badges/processor.py new file mode 100644 index 0000000..589b448 --- /dev/null +++ b/scout_anki/merit_badges/processor.py @@ -0,0 +1,123 @@ +"""Merit badge processor.""" + +import json +from pathlib import Path + +import click +import genanki + +from .. import deck +from ..image_utils import discover_images, map_content_by_image_filename +from ..log import get_logger +from ..processor import DeckProcessor +from .schema import MeritBadge, normalize_badge_data + + +class MeritBadgeProcessor(DeckProcessor): + """Processor for merit badge decks.""" + + def __init__(self): + super().__init__("badges") + + def get_defaults(self) -> dict[str, str]: + """Get default values for merit badges.""" + return { + "out": "merit_badges.apkg", + "deck_name": "Merit Badges", + "model_name": "Merit Badge Quiz", + } + + def process_directory(self, directory_path: str) -> tuple[list[MeritBadge], dict[str, Path]]: + """Process directory to find badges and images.""" + directory = Path(directory_path) + + # Find and process JSON files + all_badge_data = [] + for json_file in directory.glob("**/*.json"): + with open(json_file, encoding="utf-8") as f: + data = json.load(f) + + # Handle both single objects and arrays + if isinstance(data, list): + all_badge_data.extend(data) + else: + all_badge_data.append(data) + + # Normalize all badge data + badges = normalize_badge_data(all_badge_data) + + # Find image files + available_images = discover_images(directory) + + return badges, available_images + + def map_content_to_images( + self, content: list[MeritBadge], images: dict[str, Path] + ) -> tuple[list[tuple[MeritBadge, str]], list[MeritBadge]]: + """Map badges to images.""" + logger = get_logger() + mapped_badges, unmapped_badges = map_content_by_image_filename(content, images) + + logger.info( + f"Mapped {len(mapped_badges)} badges to images, " + f"{len(unmapped_badges)} badges without images" + ) + return mapped_badges, unmapped_badges + + def create_mapping_summary( + self, + content: list[MeritBadge], + images: dict[str, Path], + mapped: list[tuple[MeritBadge, str]], + unmapped: list[MeritBadge], + ) -> dict[str, int | list[str]]: + """Create mapping summary for badges.""" + mapped_image_names = {img_name for _, img_name in mapped} + unused_images = set(images.keys()) - mapped_image_names + + # Create missing image details + missing_images = [] + for badge in unmapped: + expected = getattr(badge, "image_filename", "unknown") + missing_images.append({"badge_name": badge.name, "expected_image": expected}) + + return { + "total_badges": len(content), + "total_images": len(images), + "mapped_badges": len(mapped), + "unmapped_badges": len(unmapped), + "unused_images": len(unused_images), + "missing_image_details": missing_images, + } + + def print_summary(self, summary: dict[str, int | list[str]], dry_run: bool) -> None: + """Print merit badge summary.""" + click.echo("\n" + "=" * 60) + click.echo("BUILD SUMMARY") + click.echo("=" * 60) + + click.echo(f"Total badges in JSON: {summary['total_badges']}") + click.echo(f"Total images available: {summary['total_images']}") + click.echo(f"Badges mapped to images: {summary['mapped_badges']}") + click.echo(f"Badges without images: {summary['unmapped_badges']}") + click.echo(f"Unused images: {summary['unused_images']}") + + if summary["missing_image_details"]: + click.echo(f"\nMissing images ({len(summary['missing_image_details'])}):") + for item in summary["missing_image_details"]: + click.echo(f" • {item['badge_name']} → {item['expected_image']}") + + if dry_run: + click.echo(f"\n[DRY RUN] Would create deck with {summary['mapped_badges']} notes") + + click.echo("=" * 60) + + def create_deck( + self, + deck_name: str, + model_name: str, + mapped_content: list[tuple[MeritBadge, str]], + images: dict[str, Path], + ) -> tuple[genanki.Deck, list[str]]: + """Create merit badge deck.""" + return deck.create_merit_badge_deck(deck_name, model_name, mapped_content, images) diff --git a/scout_merit_badges_anki/schema.py b/scout_anki/merit_badges/schema.py similarity index 50% rename from scout_merit_badges_anki/schema.py rename to scout_anki/merit_badges/schema.py index 52b66eb..fce0d80 100644 --- a/scout_merit_badges_anki/schema.py +++ b/scout_anki/merit_badges/schema.py @@ -1,80 +1,31 @@ -"""Badge model and JSON schema normalization.""" +"""Merit badge model and JSON schema normalization.""" -import hashlib -import re from dataclasses import dataclass from typing import Any @dataclass -class Badge: +class MeritBadge: """Merit badge model.""" name: str description: str image: str | None = None + image_filename: str | None = None source: str | None = None eagle_required: bool = False -def stable_id(seed: str) -> int: - """Generate a stable ID from a seed string using SHA1 hash. - - Args: - seed: String to hash - - Returns: - Integer ID derived from first 10 hex chars of SHA1 hash - - Examples: - >>> stable_id("test") - 2711781484 - >>> stable_id("Merit Badges") - 1234567890 # Deterministic output - """ - return int(hashlib.sha1(seed.encode(), usedforsecurity=False).hexdigest()[:10], 16) - - -def slug(s: str) -> str: - """Convert string to slug format. - - Args: - s: String to convert - - Returns: - Lowercase string with spaces as dashes, only alphanumeric and hyphens - - Raises: - ValueError: If input string is empty after processing - """ - if not isinstance(s, str): - raise TypeError(f"Expected string, got {type(s)}") - - # Convert to lowercase and replace spaces with dashes - s = s.lower().replace(" ", "-") - # Remove non-alphanumeric characters except hyphens - s = re.sub(r"[^a-z0-9-]", "", s) - # Remove multiple consecutive dashes - s = re.sub(r"-+", "-", s) - # Remove leading/trailing dashes - result = s.strip("-") - - if not result: - raise ValueError("Input string produces empty slug") - - return result - - -def normalize_badge_data(data: Any) -> list[Badge]: - """Normalize JSON data into Badge objects. +def normalize_badge_data(data: Any) -> list[MeritBadge]: + """Normalize JSON data into MeritBadge objects. Args: data: Raw JSON data (list or dict) Returns: - List of normalized Badge objects + List of normalized MeritBadge objects """ - badges: list[Badge] = [] + badges: list[MeritBadge] = [] # Extract badge list from various JSON structures if isinstance(data, list): @@ -126,37 +77,22 @@ def normalize_badge_data(data: Any) -> list[Badge]: image = str(item[img_key]).strip() break + # Extract image filename + image_filename = None + if item.get("image_filename"): + image_filename = str(item["image_filename"]).strip() + # Extract eagle required status eagle_required = bool(item.get("is_eagle_required", False)) - badge = Badge( + badge = MeritBadge( name=name, description=description, image=image, + image_filename=image_filename, source="JSON", eagle_required=eagle_required, ) badges.append(badge) return badges - - -def merge_badge_lists(badge_lists: list[list[Badge]]) -> list[Badge]: - """Merge multiple badge lists, removing duplicates by name. - - Args: - badge_lists: List of badge lists to merge - - Returns: - Merged list with duplicates removed (first occurrence kept) - """ - merged = [] - seen_names = set() - - for badge_list in badge_lists: - for badge in badge_list: - if badge.name not in seen_names: - merged.append(badge) - seen_names.add(badge.name) - - return merged diff --git a/scout_anki/processor.py b/scout_anki/processor.py new file mode 100644 index 0000000..7e36dd1 --- /dev/null +++ b/scout_anki/processor.py @@ -0,0 +1,133 @@ +"""Shared processing logic for different deck types.""" + +import sys +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Protocol + +import genanki + +from . import deck +from .errors import NoBadgesFoundError, NoImagesFoundError +from .log import get_logger + + +class ContentItem(Protocol): + """Protocol for content items (badges, adventures, etc.).""" + + name: str + image_filename: str | None + + +class DeckProcessor(ABC): + """Base class for deck processors.""" + + def __init__(self, deck_type: str): + self.deck_type = deck_type + self.logger = get_logger() + + @abstractmethod + def get_defaults(self) -> dict[str, str]: + """Get default values for output, deck name, and model name.""" + pass + + @abstractmethod + def process_directory(self, directory_path: str) -> tuple[list[ContentItem], dict[str, Path]]: + """Process directory and return content and images.""" + pass + + @abstractmethod + def map_content_to_images( + self, content: list[ContentItem], images: dict[str, Path] + ) -> tuple[list[tuple[ContentItem, str]], list[ContentItem]]: + """Map content to images.""" + pass + + @abstractmethod + def create_mapping_summary( + self, + content: list[ContentItem], + images: dict[str, Path], + mapped: list[tuple[ContentItem, str]], + unmapped: list[ContentItem], + ) -> dict[str, int | list[str]]: + """Create mapping summary.""" + pass + + @abstractmethod + def print_summary(self, summary: dict[str, int | list[str]], dry_run: bool) -> None: + """Print build summary.""" + pass + + @abstractmethod + def create_deck( + self, + deck_name: str, + model_name: str, + mapped_content: list[tuple[ContentItem, str]], + images: dict[str, Path], + ) -> tuple[genanki.Deck, list[str]]: + """Create Anki deck.""" + pass + + def build_deck( + self, + directory_path: str, + out: str | None = None, + deck_name: str | None = None, + model_name: str | None = None, + dry_run: bool = False, + ) -> None: + """Build deck with shared logic.""" + # Set defaults + defaults = self.get_defaults() + if not out: + out = defaults["out"] + if not deck_name: + deck_name = defaults["deck_name"] + if not model_name: + model_name = defaults["model_name"] + + # Process directory + self.logger.info(f"Processing directory: {directory_path}") + content, available_images = self.process_directory(directory_path) + + if not content: + raise NoBadgesFoundError(f"No {self.deck_type} found in directory") + + if not available_images: + raise NoImagesFoundError("No images found in directory") + + # Map content to images + self.logger.info(f"Mapping {self.deck_type} to images...") + mapped_content, unmapped_content = self.map_content_to_images(content, available_images) + + # Create mapping summary + summary = self.create_mapping_summary( + content, available_images, mapped_content, unmapped_content + ) + + # Print summary + self.print_summary(summary, dry_run) + + if not mapped_content: + self.logger.error(f"No {self.deck_type} could be mapped to images") + sys.exit(4) + + # Build deck (unless dry run) + if not dry_run: + self.logger.info("Building Anki deck...") + anki_deck, media_files = self.create_deck( + deck_name, model_name, mapped_content, available_images + ) + + # Write package + self.logger.info(f"Writing package to {out}") + deck.write_anki_package(anki_deck, media_files, out) + + # Cleanup temp files + deck.cleanup_temp_files(media_files) + + self.logger.info(f"Successfully created {out}") + else: + self.logger.info("Dry run complete - no .apkg file written") diff --git a/scout_anki/schema.py b/scout_anki/schema.py new file mode 100644 index 0000000..b8031d6 --- /dev/null +++ b/scout_anki/schema.py @@ -0,0 +1,52 @@ +"""Generic schema utilities.""" + +import hashlib +import re + + +def stable_id(seed: str) -> int: + """Generate a stable ID from a seed string using SHA1 hash. + + Args: + seed: String to hash + + Returns: + Integer ID derived from first 10 hex chars of SHA1 hash + + Examples: + >>> stable_id("test") + 2711781484 + >>> stable_id("Merit Badges") + 1234567890 # Deterministic output + """ + return int(hashlib.sha1(seed.encode(), usedforsecurity=False).hexdigest()[:10], 16) + + +def slug(s: str) -> str: + """Convert string to slug format. + + Args: + s: String to convert + + Returns: + Lowercase string with spaces as dashes, only alphanumeric and hyphens + + Raises: + ValueError: If input string is empty after processing + """ + if not isinstance(s, str): + raise TypeError(f"Expected string, got {type(s)}") + + # Convert to lowercase and replace spaces with dashes + s = s.lower().replace(" ", "-") + # Remove non-alphanumeric characters except hyphens + s = re.sub(r"[^a-z0-9-]", "", s) + # Remove multiple consecutive dashes + s = re.sub(r"-+", "-", s) + # Remove leading/trailing dashes + result = s.strip("-") + + if not result: + raise ValueError("Input string produces empty slug") + + return result diff --git a/scout_merit_badges_anki/cli.py b/scout_merit_badges_anki/cli.py deleted file mode 100644 index 4722d07..0000000 --- a/scout_merit_badges_anki/cli.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Command line interface for scout-merit-badges-anki.""" - -import sys - -import click - -from . import deck, directory, mapping -from .errors import ( - NoBadgesFoundError, -) -from .log import setup_logging - - -@click.group() -def cli(): - """Scout Archive to Anki deck tools.""" - pass - - -@cli.command() -@click.argument("directory_path", type=click.Path(exists=True, file_okay=False, readable=True)) -@click.option( - "--out", - type=click.Path(writable=True), - default="merit_badges_image_trainer.apkg", - help="Output file path", -) -@click.option("--deck-name", default="Merit Badges Visual Trainer", help="Anki deck name") -@click.option("--model-name", default="Merit Badge Image → Text", help="Anki model name") -@click.option("--dry-run", is_flag=True, default=False, help="Run without writing .apkg file") -@click.option("-q", "--quiet", is_flag=True, help="Only show errors") -@click.option("-v", "--verbose", count=True, help="Increase verbosity") -def build( - directory_path, - out, - deck_name, - model_name, - dry_run, - quiet, - verbose, -): - """Build Anki deck from extracted directory.""" - - # Setup logging - logger = setup_logging(quiet=quiet, verbose=verbose) - - try: - # Process directory - logger.info(f"Processing directory: {directory_path}") - badges, available_images = directory.process_directory(directory_path) - - if not badges: - raise NoBadgesFoundError("No badges found in directory") - - if not available_images: - raise ValueError("No images found in directory") - - # Map badges to images - logger.info("Mapping badges to images...") - mapped_badges, unmapped_badges = mapping.map_badges_to_images(badges, available_images) - - # Create mapping summary - summary = mapping.create_mapping_summary( - badges, available_images, mapped_badges, unmapped_badges - ) - - # Print summary - print_build_summary(summary, dry_run) - - if not mapped_badges: - logger.error("No badges could be mapped to images") - sys.exit(4) - - # Build deck (unless dry run) - if not dry_run: - logger.info("Building Anki deck...") - anki_deck, media_files = deck.create_merit_badge_deck( - deck_name=deck_name, - model_name=model_name, - mapped_badges=mapped_badges, - available_images=available_images, - ) - - # Write package - logger.info(f"Writing package to {out}") - deck.write_anki_package(anki_deck, media_files, out) - - # Cleanup temp files - deck.cleanup_temp_files(media_files) - - logger.info(f"Successfully created {out}") - else: - logger.info("Dry run complete - no .apkg file written") - - except ValueError as e: - if "No images found" in str(e): - logger.error(str(e)) - sys.exit(3) - except NoBadgesFoundError as e: - logger.error(str(e)) - sys.exit(4) - except Exception as e: - logger.error(f"Unexpected error: {e}") - if verbose: - import traceback - - traceback.print_exc() - sys.exit(1) - - -def print_build_summary(summary: dict, dry_run: bool = False) -> None: - """Print build summary table.""" - click.echo("\n" + "=" * 60) - click.echo("BUILD SUMMARY") - click.echo("=" * 60) - - click.echo(f"Total badges in JSON: {summary['total_badges']}") - click.echo(f"Total images available: {summary['total_images']}") - click.echo(f"Badges mapped to images: {summary['mapped_badges']}") - click.echo(f"Badges without images: {summary['unmapped_badges']}") - click.echo(f"Unused images: {summary['unused_images']}") - - if summary["missing_image_details"]: - click.echo(f"\nMissing images ({len(summary['missing_image_details'])}):") - for item in summary["missing_image_details"]: - click.echo(f" • {item['badge_name']} → {item['expected_image']}") - - if dry_run: - click.echo(f"\n[DRY RUN] Would create deck with {summary['mapped_badges']} notes") - - click.echo("=" * 60) diff --git a/scout_merit_badges_anki/config.py b/scout_merit_badges_anki/config.py deleted file mode 100644 index b25bc7b..0000000 --- a/scout_merit_badges_anki/config.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Configuration management for scout-merit-badges-anki.""" - -from dataclasses import dataclass - - -@dataclass -class Config: - """Application configuration.""" - - # Anki settings - default_deck_name: str = "Merit Badges Image Trainer" - default_model_name: str = "Merit Badge Image → Text" - default_output_file: str = "merit_badges_image_trainer.apkg" - - # Image settings - max_image_width: str = "85%" - image_style: str = "max-width: 85%; height: auto;" - - # Logging settings - log_format_simple: str = "%(levelname)s: %(message)s" - log_format_verbose: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - - -# Global config instance -config = Config() diff --git a/scout_merit_badges_anki/directory.py b/scout_merit_badges_anki/directory.py deleted file mode 100644 index 90fe108..0000000 --- a/scout_merit_badges_anki/directory.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Directory processing for scout-merit-badges-anki.""" - -import json -from pathlib import Path - -from . import schema - - -def process_directory(directory_path): - """Process extracted directory to find badges and images. - - Args: - directory_path: Path to directory containing extracted files - - Returns: - tuple: (badges, available_images) where: - - badges: List of normalized badge objects - - available_images: Dict mapping image names to file paths - """ - directory = Path(directory_path) - - # Find and process JSON files - all_badge_data = [] - for json_file in directory.glob("**/*.json"): - with open(json_file, encoding="utf-8") as f: - data = json.load(f) - - # Handle both single objects and arrays - if isinstance(data, list): - all_badge_data.extend(data) - else: - all_badge_data.append(data) - - # Normalize all badge data - badges = schema.normalize_badge_data(all_badge_data) - - # Find image files - available_images = {} - image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"} - - for img_file in directory.glob("**/*"): - if img_file.is_file() and img_file.suffix.lower() in image_extensions: - available_images[img_file.name] = img_file - - return badges, available_images diff --git a/scout_merit_badges_anki/mapping.py b/scout_merit_badges_anki/mapping.py deleted file mode 100644 index c43db48..0000000 --- a/scout_merit_badges_anki/mapping.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Image mapping and selection logic.""" - -import os -from functools import lru_cache -from pathlib import Path -from typing import Any - -from .log import get_logger -from .schema import Badge, slug - - -@lru_cache(maxsize=128) -def _cached_slug(name: str) -> str: - """Cached version of slug generation for performance.""" - return slug(name) - - -def find_image_for_badge(badge: Badge, available_images: dict[str, bytes]) -> str | None: - """Find the best matching image for a badge. - - Args: - badge: Badge to find image for - available_images: Dict mapping image basename to content - - Returns: - Image basename if found, None otherwise - """ - logger = get_logger() - - # If badge specifies an image, try to match by basename - if badge.image: - # Try exact match first - if badge.image in available_images: - logger.debug(f"Exact image match for {badge.name}: {badge.image}") - return badge.image - - # Try matching by basename - badge_basename = os.path.basename(badge.image) - if badge_basename in available_images: - logger.debug(f"Basename image match for {badge.name}: {badge_basename}") - return badge_basename - - # Infer image from badge name using slug rules - badge_slug = _cached_slug(badge.name) - - # Look for pattern: -merit-badge.* or --merit-badge.* - merit_badge_pattern = f"{badge_slug}-merit-badge" - candidates = [] - - for image_name in available_images.keys(): - image_stem = Path(image_name).stem.lower() - - # Check if it matches the merit badge pattern - if image_stem == merit_badge_pattern: - candidates.append(image_name) - # Check for versioned pattern: --merit-badge - elif image_stem.startswith(f"{badge_slug}-") and image_stem.endswith("-merit-badge"): - # Extract the middle part to see if it's a version number - middle_part = image_stem[len(f"{badge_slug}-") : -len("-merit-badge")] - if middle_part.isdigit(): - candidates.append(image_name) - # Also check for exact slug match - elif image_stem == badge_slug: - candidates.append(image_name) - - if candidates: - # If multiple candidates, choose the one with shortest basename - best_candidate = min(candidates, key=len) - logger.debug(f"Inferred image for {badge.name}: {best_candidate}") - return best_candidate - - logger.debug(f"No image found for badge: {badge.name}") - return None - - -def map_badges_to_images( - badges: list[Badge], available_images: dict[str, bytes] -) -> tuple[list[tuple[Badge, str]], list[Badge]]: - """Map badges to their corresponding images. - - Args: - badges: List of badges to map - available_images: Dict mapping image basename to content - - Returns: - Tuple of (successful mappings, badges without images) - """ - logger = get_logger() - - mapped_badges = [] - unmapped_badges = [] - - for badge in badges: - image_name = find_image_for_badge(badge, available_images) - - if image_name: - mapped_badges.append((badge, image_name)) - else: - unmapped_badges.append(badge) - - logger.info( - f"Mapped {len(mapped_badges)} badges to images, " - f"{len(unmapped_badges)} badges without images" - ) - - return mapped_badges, unmapped_badges - - -def get_unused_images( - badges: list[Badge], available_images: dict[str, bytes], mapped_images: set[str] -) -> list[str]: - """Get list of images that weren't used by any badge. - - Args: - badges: List of all badges - available_images: Dict of all available images - mapped_images: Set of image names that were used - - Returns: - List of unused image names - """ - all_images = set(available_images.keys()) - unused = all_images - mapped_images - return sorted(unused) - - -def generate_expected_image_name(badge: Badge) -> str: - """Generate the expected image filename for a badge. - - Args: - badge: Badge to generate filename for - - Returns: - Expected image filename - """ - if badge.image: - return os.path.basename(badge.image) - - badge_slug = slug(badge.name) - return f"{badge_slug}-merit-badge.jpg" - - -def create_mapping_summary( - badges: list[Badge], - available_images: dict[str, bytes], - mapped_badges: list[tuple[Badge, str]], - unmapped_badges: list[Badge], -) -> dict[str, Any]: - """Create a summary of the mapping process. - - Args: - badges: All badges - available_images: All available images - mapped_badges: Successfully mapped badges - unmapped_badges: Badges without images - - Returns: - Summary dict with counts and details - """ - mapped_image_names = {img_name for _, img_name in mapped_badges} - unused_images = get_unused_images(badges, available_images, mapped_image_names) - - # Create missing image details - missing_images = [] - for badge in unmapped_badges: - expected = generate_expected_image_name(badge) - missing_images.append({"badge_name": badge.name, "expected_image": expected}) - - return { - "total_badges": len(badges), - "total_images": len(available_images), - "mapped_badges": len(mapped_badges), - "unmapped_badges": len(unmapped_badges), - "unused_images": len(unused_images), - "missing_image_details": missing_images, - "unused_image_names": unused_images, - } diff --git a/spec.md b/spec.md deleted file mode 100644 index 8699aea..0000000 --- a/spec.md +++ /dev/null @@ -1,308 +0,0 @@ -# Overview - -Build a Python CLI tool, implemented with `click`, that generates an Anki `.apkg` deck of merit badges from the **latest** or a **given** GitHub release in the `scout-archive` repository. Cards show the badge image on the front, and on the back show the badge name and a brief description. Images in releases follow the pattern `american-business-merit-badge.jpg`. Source data is updated roughly weekly. - -# Success Criteria - -- Given a public GitHub release containing badge JSON and images, the command `scoutanki build` outputs a valid `.apkg` that can be imported into Anki with one note per badge, media bundled. -- Stable model and deck IDs are derived from deterministic hashes so repeated runs do not create duplicate models. -- If a tag is omitted, the latest release is used. If a tag is provided, that specific release is used. If a token is provided, the tool respects it for higher rate limits. -- The tool reports counts of parsed badges, attached images, skipped items, and the output path. Non zero exit on fatal errors. - -# User Stories - -- As a Scout leader, I run `scoutanki build` weekly to regenerate the deck from the newest release, then import into Anki for personal study or to share. -- As a contributor, I run `scoutanki validate --tag ` to confirm a given release has the expected JSON and images before publishing. - -# CLI Design - -## Commands - -1. `scoutanki build` - - Purpose, fetch release assets, parse JSON, map images, build `.apkg`. - - Options, - - `--repo TEXT`, default `dasevilla/scout-archive`. - - `--tag TEXT`, optional release tag, for example `archive-YYYY-MM-DD-HHMM`. - - `--token TEXT`, optional GitHub token, may also read `GITHUB_TOKEN`. - - `--out PATH`, default `merit_badges_image_trainer.apkg`. - - `--deck-name TEXT`, default `Merit Badges, Image Trainer`. - - `--model-name TEXT`, default `Merit Badge Image → Text`. - - `--reverse/--no-reverse`, add a Name to Image card, default disabled. - - `--include-eagle-tags/--no-include-eagle-tags`, if JSON has `is_eagle_required`, tag notes with `eagle`, default enabled. - - `--dry-run`, run everything except writing `.apkg`, print a summary table. - - `-q, --quiet` and `-v, --verbose` logging controls. - -2. `scoutanki validate` - - Purpose, inspect a release, print counts, list missing images, and spot JSON issues without building the deck. - - Same options for `--repo`, `--tag`, `--token`. - - Exit code non zero on validation errors. - -3. `scoutanki list-releases` - - Purpose, list recent release tags and publish times to help the user choose. - - Options, `--repo`, `--token`, `--limit INTEGER` default 10. - -## Example usage - -```bash -scoutanki build # latest release -scoutanki build --tag archive-2025-08-25-1426 -scoutanki build --reverse --out badges.apkg -scoutanki validate --tag archive-2025-09-01-1415 -scoutanki list-releases --limit 5 -``` - -# Architecture - -``` -src/ - scoutanki/ - __init__.py - cli.py # click entrypoints - github.py # GitHub API calls for releases and asset downloads - archive.py # read zip, tar.gz, single files, iterate members - schema.py # Badge model and normalization - mapping.py # image selection by name pattern and explicit JSON - deck.py # genanki model, deck, and package creation - log.py # logger setup - errors.py # custom exception types -``` - -- `Badge` model fields, `name` (str), `description` (str), `image` (str, optional), `is_eagle_required` (bool, optional), `source` (str, optional). -- Deterministic IDs, `stable_id(seed: str) -> int` using `sha1(seed)[:10]` as hex to int. -- HTTP logic in `github.py` supports anonymous and token authenticated requests, sets a clear UA, retries on transient status codes. - -# Data Assumptions and Normalization - -## Release assets - -- Assets may be zip, tar.gz, tar, or loose `.json` and image files. -- Badge images include names like `*-merit-badge.jpg`. -- JSON can be a list or a dict with a list under a key such as `badges`, `items`, `data`, or `meritBadges`. - -## JSON schema normalization - -Accept the following keys, case sensitive as typical JSON, - -- Name, `name` or `title` or `badge`. -- Description, `overview` or `description` or `blurb` or `summary`. -- Image filename, `image` or `img` or `icon`. -- Eagle flag, `is_eagle_required` boolean. - -Normalization rules, - -- Trim whitespace of strings, drop records missing `name`. -- Keep the first occurrence for duplicates by name. - -# Image Mapping Strategy - -- If the JSON specifies an image filename, match by basename against the assets. -- Otherwise infer from `name` using slug rules, lower case, non alphanumeric replaced with dashes, then prefer `-merit-badge.*`. -- If multiple candidates match, choose the shortest basename. -- If no image is found, skip that badge and report it in the summary. - -# Deck and Note Construction - -- Model, fields, `[Image, Name, Description]`. -- Front template, `` centered and constrained to 85 percent width. -- Back template, show FrontSide, then Name and Description. -- Optional reverse card, query `{{Name}}` on front, show image and description on back. -- Tags, include `eagle` when `is_eagle_required is true` if `--include-eagle-tags`. -- GUID, `genanki.guid_for(f"{slug(name)}|{image_basename}")`. -- Deck ID and Model ID derived from `stable_id(f"{repo}:{deck_name}")` and `stable_id(f"{repo}:{model_name}")`. - -# Error Handling and Exit Codes - -- Non zero exit when, - - GitHub release not found, 2. - - No JSON or no images found in assets, 3. - - Parsing JSON yields zero badges, 4. - - Network error unrecoverable, 5. -- Validation prints a compact table of badge names with missing images and returns 6 if any are missing. - -# Logging - -- Default is info level, verbose prints HTTP endpoints, counts per phase, and timing, quiet prints only final summary and errors. -- Colorized output is fine, avoid heavy dependencies. - -# Implementation Details - -## GitHub API - -- Endpoints, - - Latest release, `GET /repos/{owner}/{repo}/releases/latest`. - - Specific tag, `GET /repos/{owner}/{repo}/releases/tags/{tag}`. - - Asset download, use `browser_download_url`. -- Headers, `Accept: application/vnd.github+json`, optional `Authorization: Bearer `. -- Retries, two short retries on `429, 502, 503, 504`. - -## Archives - -- Support `.zip`, `.tar.gz`, `.tgz`, `.tar`. -- Iterate members without extracting to disk, keep file path strings for name matching. -- Only write image files that are used into the working directory so they can be bundled in `genanki.Package.media_files`. - -## Deterministic hashing - -- `stable_id(seed)`, `int(sha1(seed.encode()).hexdigest()[:10], 16)`. -- `slug(s)`, lower case, spaces to dashes, remove non alphanumeric and hyphen. - -# click Skeleton - -```python -import click -from . import deck, github, archive, mapping, schema - -@click.group() -def cli(): - """Scout Archive to Anki deck tools.""" - -@cli.command() -@click.option("--repo", default="dasevilla/scout-archive") -@click.option("--tag") -@click.option("--token", envvar="GITHUB_TOKEN") -@click.option("--out", type=click.Path(writable=True), default="merit_badges_image_trainer.apkg") -@click.option("--deck-name", default="Merit Badges, Image Trainer") -@click.option("--model-name", default="Merit Badge Image → Text") -@click.option("--reverse/--no-reverse", default=False) -@click.option("--include-eagle-tags/--no-include-eagle-tags", default=True) -@click.option("--dry-run", is_flag=True, default=False) -@click.option("-q", "--quiet", is_flag=True) -@click.option("-v", "--verbose", count=True) -def build(repo, tag, token, out, deck_name, model_name, reverse, include_eagle_tags, dry_run, quiet, verbose): - # 1) fetch release json - # 2) download assets - # 3) gather JSON and images - # 4) normalize badges - # 5) map images - # 6) build deck, optionally add reverse template - # 7) write .apkg unless dry run - ... - -@cli.command("validate") -@click.option("--repo", default="dasevilla/scout-archive") -@click.option("--tag") -@click.option("--token", envvar="GITHUB_TOKEN") -def validate_cmd(repo, tag, token): - # fetch, download, parse, print findings, return non zero on problems - ... - -@cli.command("list-releases") -@click.option("--repo", default="dasevilla/scout-archive") -@click.option("--token", envvar="GITHUB_TOKEN") -@click.option("--limit", default=10) -def list_releases(repo, token, limit): - ... -``` - -# Packaging and Tooling - -## pyproject.toml - -```toml -[project] -name = "scoutanki" -version = "0.1.0" -description = "Build Anki decks from scout-archive releases" -requires-python = ">=3.11" -dependencies = [ - "click>=8.1", - "requests>=2.32", - "genanki>=0.13.1", -] - -[project.scripts] -scoutanki = "scoutanki.cli:cli" - -[tool.ruff] -line-length = 100 -target-version = "py311" - -[tool.ruff.lint] -select = ["E","F","I","UP","B","RUF"] - -[tool.ruff.format] -quote-style = "double" -indent-style = "space" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" -``` - -## pre-commit - -```yaml -repos: - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.9 - hooks: - - id: ruff - args: ["--fix"] - - id: ruff-format -``` - -## Makefile - -```make -.PHONY: venv fmt lint test run - -venv: - uv venv && . .venv/bin/activate && uv pip install -e . - -fmt: - ruff format - -lint: - ruff check - -test: - pytest -q - -run: - scoutanki build -``` - -# Edge Cases and Rules - -- If multiple JSON files exist, merge their badge lists then de duplicate by `name`. -- If images with the same basename exist in different folders, prefer the shortest path. -- If description is empty, still create the card using just Name. -- If there are zero usable badges after parsing, exit with a clear message and non zero code. -- If a user supplies `--reverse`, add an additional template to the model that produces a second card per note. - -# Validation Output - -- Counts, total badges in JSON, images available, notes created, skipped. -- Missing images list, badge names and expected inferred image basenames. -- Unknown image files, present in assets but not referenced by any badge after mapping, optional. - -# Test Plan - -- Unit tests for, - - JSON normalization, assorted schema shapes, including empty fields. - - Slug and image selection, including the `*-merit-badge` pattern. - - Stable IDs remain stable across runs. - - Archive readers for `.zip` and `.tar.gz`. -- Integration tests, - - Use a small synthetic release archive with three badges and images to assert note count and media bundling. - - Dry run path produces summary without `.apkg` file. -- Smoke test, - - Hit the real GitHub release, guarded by an env var and skipped in CI by default. - -# Performance and Limits - -- Badges count is low, memory footprint is tiny. Stream assets in memory, avoid writing everything to disk. Only write images that are included. -- GitHub anonymous rate limit is low, recommend passing a token if frequent runs are expected. - -# Security and Privacy - -- The tool only reads public release assets. If a token is provided, do not log it. Redact tokens from any error messages. - -# Handover Notes for the Implementing LLM - -- Follow the module boundaries exactly to keep the code clear. -- Keep the JSON normalization logic in one place so schema changes are easy. -- Prefer small focused functions with type hints. -- Write concise docstrings and keep logging human readable. -- Start by implementing `list-releases`, then `validate`, then `build`. -- After implementation, generate a minimal `--help` output and confirm all options are documented. diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..e53936a --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,96 @@ +"""Tests for CLI functionality.""" + +import tempfile +from unittest.mock import Mock, patch + +from click.testing import CliRunner + +from scout_anki.cli import build, cli + + +class TestCLI: + """Test CLI commands.""" + + def test_cli_help(self): + """Test main CLI help.""" + runner = CliRunner() + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "Scout Archive to Anki deck tools" in result.output + + def test_build_help(self): + """Test build command help.""" + runner = CliRunner() + result = runner.invoke(build, ["--help"]) + assert result.exit_code == 0 + assert "Build Anki deck from extracted directory" in result.output + assert "merit-badges" in result.output + assert "cub-adventures" in result.output + + def test_build_missing_directory(self): + """Test build command with missing directory.""" + runner = CliRunner() + result = runner.invoke(build, ["merit-badges", "nonexistent"]) + assert result.exit_code != 0 + + def test_build_dry_run_missing_directory(self): + """Test build command dry run with missing directory.""" + runner = CliRunner() + result = runner.invoke(build, ["merit-badges", "nonexistent", "--dry-run"]) + assert result.exit_code != 0 + + +def test_build_merit_badges_success(): + """Test successful merit badge build.""" + with tempfile.TemporaryDirectory() as temp_dir: + with patch( + "scout_anki.merit_badges.processor.MeritBadgeProcessor.process_directory" + ) as mock_process: + with patch( + "scout_anki.merit_badges.processor.MeritBadgeProcessor.map_content_to_images" + ) as mock_map: + with patch("scout_anki.deck.create_merit_badge_deck") as mock_deck: + # Setup mocks + mock_process.return_value = (["badge"], {"test.jpg": "path"}) + mock_map.return_value = ([("badge", "test.jpg")], []) + mock_deck.return_value = (Mock(), []) + + runner = CliRunner() + result = runner.invoke(build, ["merit-badges", temp_dir, "--dry-run"]) + + assert result.exit_code == 0 + + +def test_build_cub_adventures_success(): + """Test successful cub adventure build.""" + with tempfile.TemporaryDirectory() as temp_dir: + with patch( + "scout_anki.cub_adventures.processor.AdventureProcessor.process_directory" + ) as mock_process: + with patch( + "scout_anki.cub_adventures.processor.AdventureProcessor.map_content_to_images" + ) as mock_map: + with patch("scout_anki.deck.create_adventure_deck") as mock_deck: + # Setup mocks + mock_process.return_value = (["adventure"], {"test.jpg": "path"}) + mock_map.return_value = ([("adventure", "test.jpg")], []) + mock_deck.return_value = (Mock(), []) + + runner = CliRunner() + result = runner.invoke(build, ["cub-adventures", temp_dir, "--dry-run"]) + + assert result.exit_code == 0 + + +def test_build_no_content_found(): + """Test build command when no content is found.""" + with tempfile.TemporaryDirectory() as temp_dir: + with patch( + "scout_anki.merit_badges.processor.MeritBadgeProcessor.process_directory" + ) as mock_process: + mock_process.return_value = ([], {}) + + runner = CliRunner() + result = runner.invoke(build, ["merit-badges", temp_dir]) + + assert result.exit_code == 4 # NoBadgesFoundError exit code diff --git a/tests/test_cli_comprehensive.py b/tests/test_cli_comprehensive.py deleted file mode 100644 index 93996b3..0000000 --- a/tests/test_cli_comprehensive.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Basic CLI tests.""" - -import tempfile -from pathlib import Path -from unittest.mock import Mock, patch - -from click.testing import CliRunner - -from scout_merit_badges_anki.cli import build - - -def test_build_with_valid_directory(): - """Test build command with valid directory.""" - with tempfile.TemporaryDirectory() as temp_dir: - with patch("scout_merit_badges_anki.directory.process_directory") as mock_process: - with patch("scout_merit_badges_anki.mapping.map_badges_to_images") as mock_map: - with patch("scout_merit_badges_anki.deck.create_merit_badge_deck") as mock_deck: - # Setup mocks - mock_process.return_value = (["badge"], {"test.jpg": Path("test.jpg")}) - mock_map.return_value = ([("badge", "test.jpg")], []) - mock_deck.return_value = (Mock(), []) - - runner = CliRunner() - result = runner.invoke(build, [temp_dir, "--dry-run"]) - - assert result.exit_code == 0 - - -def test_build_no_badges_found(): - """Test build command when no badges are found.""" - with tempfile.TemporaryDirectory() as temp_dir: - with patch("scout_merit_badges_anki.directory.process_directory") as mock_process: - mock_process.return_value = ([], {}) - - runner = CliRunner() - result = runner.invoke(build, [temp_dir]) - - assert result.exit_code == 4 # NoBadgesFoundError exit code diff --git a/tests/test_cli_simple.py b/tests/test_cli_simple.py deleted file mode 100644 index 54272ed..0000000 --- a/tests/test_cli_simple.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Simple CLI tests.""" - -from click.testing import CliRunner - -from scout_merit_badges_anki.cli import build, cli - - -class TestCLIBasic: - """Basic CLI functionality tests.""" - - def test_cli_help(self): - """Test CLI help command.""" - runner = CliRunner() - result = runner.invoke(cli, ["--help"]) - assert result.exit_code == 0 - assert "Scout Archive to Anki deck tools" in result.output - - def test_build_help(self): - """Test build command help.""" - runner = CliRunner() - result = runner.invoke(build, ["--help"]) - assert result.exit_code == 0 - assert "Build Anki deck from extracted directory" in result.output - - def test_build_missing_directory(self): - """Test build command with missing directory.""" - runner = CliRunner() - result = runner.invoke(build, ["nonexistent"]) - assert result.exit_code != 0 - - def test_build_dry_run_missing_directory(self): - """Test build command dry run with missing directory.""" - runner = CliRunner() - result = runner.invoke(build, ["nonexistent", "--dry-run"]) - assert result.exit_code != 0 diff --git a/tests/test_cub_adventures.py b/tests/test_cub_adventures.py new file mode 100644 index 0000000..9a71ece --- /dev/null +++ b/tests/test_cub_adventures.py @@ -0,0 +1,89 @@ +"""Tests for cub adventure functionality.""" + +import json +import tempfile +from pathlib import Path + +from scout_anki.cub_adventures.processor import AdventureProcessor + + +def test_cub_adventure_directory_processing(): + """Test processing directory with cub adventure data.""" + with tempfile.TemporaryDirectory() as temp_dir: + test_dir = Path(temp_dir) + + # Create rank directory structure + tiger_dir = test_dir / "tiger" + tiger_dir.mkdir() + images_dir = tiger_dir / "images" + images_dir.mkdir() + + # Create test adventure data with expected field names + adventure_data = { + "adventure_name": "Backyard Jungle", + "rank_name": "Tiger", + "adventure_type": "Adventure", + "adventure_overview": "Explore nature", + "image_filename": "jungle.png", + } + + # Write JSON file in rank directory + (tiger_dir / "backyard-jungle.json").write_text(json.dumps(adventure_data)) + + # Create image file in images subdirectory + (images_dir / "jungle.png").write_bytes(b"fake jungle image") + + # Process directory + processor = AdventureProcessor() + adventures, images = processor.process_directory(str(test_dir)) + + assert len(adventures) == 1 + assert len(images) == 1 + assert adventures[0].name == "Backyard Jungle" + assert "jungle.png" in images + + +def test_cub_adventure_empty_directory(): + """Test processing empty directory.""" + with tempfile.TemporaryDirectory() as temp_dir: + processor = AdventureProcessor() + adventures, images = processor.process_directory(temp_dir) + + assert len(adventures) == 0 + assert len(images) == 0 + + +def test_cub_adventure_defaults(): + """Test cub adventure processor defaults.""" + processor = AdventureProcessor() + defaults = processor.get_defaults() + + assert defaults["out"] == "cub_scout_adventure_image_trainer.apkg" + assert defaults["deck_name"] == "Cub Scout Adventure Image Trainer" + assert defaults["model_name"] == "Cub Scout Adventure Quiz" + + +def test_cub_adventure_mapping(): + """Test cub adventure to image mapping.""" + processor = AdventureProcessor() + + # Mock adventure data + from scout_anki.cub_adventures.schema import Adventure + + adventures = [ + Adventure( + name="Backyard Jungle", + rank="Tiger", + type="Adventure", + overview="Test", + image_filename="jungle.png", + ) + ] + images = {"jungle.png": Path("jungle.png")} + + mapped, unmapped = processor.map_content_to_images(adventures, images) + + assert len(mapped) == 1 + assert len(unmapped) == 0 + assert mapped[0][0].name == "Backyard Jungle" + assert mapped[0][1] == "jungle.png" diff --git a/tests/test_merit_badges.py b/tests/test_merit_badges.py new file mode 100644 index 0000000..ef01c8f --- /dev/null +++ b/tests/test_merit_badges.py @@ -0,0 +1,77 @@ +"""Tests for merit badge functionality.""" + +import json +import tempfile +from pathlib import Path + +from scout_anki.merit_badges.processor import MeritBadgeProcessor + + +def test_merit_badge_directory_processing(): + """Test processing directory with merit badge data.""" + with tempfile.TemporaryDirectory() as temp_dir: + test_dir = Path(temp_dir) + + # Create test badge data + badge_data = [ + { + "name": "Camping", + "description": "Learn outdoor skills", + "image_filename": "camping.png", + }, + {"name": "Hiking", "description": "Trail adventures", "image_filename": "hiking.jpg"}, + ] + + # Write JSON file + (test_dir / "badges.json").write_text(json.dumps(badge_data)) + + # Create image files + (test_dir / "camping.png").write_bytes(b"fake camping image") + (test_dir / "hiking.jpg").write_bytes(b"fake hiking image") + + # Process directory + processor = MeritBadgeProcessor() + badges, images = processor.process_directory(str(test_dir)) + + assert len(badges) == 2 + assert len(images) == 2 + assert badges[0].name == "Camping" + assert "camping.png" in images + + +def test_merit_badge_empty_directory(): + """Test processing empty directory.""" + with tempfile.TemporaryDirectory() as temp_dir: + processor = MeritBadgeProcessor() + badges, images = processor.process_directory(temp_dir) + + assert len(badges) == 0 + assert len(images) == 0 + + +def test_merit_badge_defaults(): + """Test merit badge processor defaults.""" + processor = MeritBadgeProcessor() + defaults = processor.get_defaults() + + assert defaults["out"] == "merit_badges.apkg" + assert defaults["deck_name"] == "Merit Badges" + assert defaults["model_name"] == "Merit Badge Quiz" + + +def test_merit_badge_mapping(): + """Test merit badge to image mapping.""" + processor = MeritBadgeProcessor() + + # Mock badge data + from scout_anki.merit_badges.schema import MeritBadge + + badges = [MeritBadge(name="Camping", description="Test", image_filename="camping.png")] + images = {"camping.png": Path("camping.png")} + + mapped, unmapped = processor.map_content_to_images(badges, images) + + assert len(mapped) == 1 + assert len(unmapped) == 0 + assert mapped[0][0].name == "Camping" + assert mapped[0][1] == "camping.png" diff --git a/tests/test_scout_merit_badges_anki.py b/tests/test_scout_merit_badges_anki.py deleted file mode 100644 index 1ca5758..0000000 --- a/tests/test_scout_merit_badges_anki.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Basic integration tests.""" - -import json -import tempfile -from pathlib import Path - -from scout_merit_badges_anki import directory - - -def test_directory_processing(): - """Test processing a directory with badges and images.""" - with tempfile.TemporaryDirectory() as temp_dir: - test_dir = Path(temp_dir) - - # Create test JSON file - badges_data = [ - {"name": "Archery", "description": "Learn archery skills"}, - {"name": "Camping", "description": "Learn camping skills"}, - ] - json_file = test_dir / "badges.json" - json_file.write_text(json.dumps(badges_data)) - - # Create test image files - (test_dir / "archery.jpg").write_bytes(b"fake archery image") - (test_dir / "camping.png").write_bytes(b"fake camping image") - - # Process directory - badges, images = directory.process_directory(test_dir) - - assert len(badges) == 2 - assert len(images) == 2 - assert "archery.jpg" in images - assert "camping.png" in images - - -def test_empty_directory(): - """Test processing empty directory.""" - with tempfile.TemporaryDirectory() as temp_dir: - badges, images = directory.process_directory(temp_dir) - - assert len(badges) == 0 - assert len(images) == 0 diff --git a/uv.lock b/uv.lock index a30a1fe..56fc607 100644 --- a/uv.lock +++ b/uv.lock @@ -371,7 +371,7 @@ wheels = [ ] [[package]] -name = "scout-merit-badges-anki" +name = "scout-anki" version = "0.1.0" source = { editable = "." } dependencies = [