diff --git a/python_pkg/warsaw_districts/README.md b/python_pkg/warsaw_districts/README.md index 79d118e..2e79f33 100644 --- a/python_pkg/warsaw_districts/README.md +++ b/python_pkg/warsaw_districts/README.md @@ -7,13 +7,34 @@ Generate Anki flashcards for learning the 18 districts (dzielnice) of Warsaw, Po - Generates flashcards for all 18 Warsaw districts - Front of card: Map showing only the district in question with its borders highlighted - Back of card: District name in Polish -- Anki-compatible output format (semicolon-separated) +- Self-contained .apkg file with embedded images - Compatible with AnkiWeb and AnkiDroid ## Installation +Install dependencies using your preferred method: + +### Using pyenv (recommended) ```bash -pip install matplotlib +pyenv install 3.10 # or later +pyenv shell 3.10 +pip install matplotlib genanki +``` + +### Using pipx +```bash +pipx install --python python3.10 matplotlib genanki +``` + +### Using system package manager (Arch Linux) +```bash +sudo pacman -S python-matplotlib +pip install genanki +``` + +### Using pip directly +```bash +pip install matplotlib genanki ``` ## Usage @@ -26,32 +47,26 @@ python -m python_pkg.warsaw_districts.warsaw_districts_anki ``` This creates: -- `warsaw_districts_anki.txt` - Anki import file -- `warsaw_districts_images/` - Directory with 18 PNG map images +- `warsaw_districts.apkg` - Self-contained Anki package with all images embedded ### Custom options ```bash -# Custom output file and image directory -python -m python_pkg.warsaw_districts.warsaw_districts_anki \ - --output my_cards.txt \ - --image-dir my_maps +# Custom output file +python -m python_pkg.warsaw_districts.warsaw_districts_anki --output my_cards.apkg # Custom deck name -python -m python_pkg.warsaw_districts.warsaw_districts_anki \ - --deck-name "Warszawa - Dzielnice" +python -m python_pkg.warsaw_districts.warsaw_districts_anki --deck-name "Warszawa - Dzielnice" ``` ## Importing into Anki 1. Open Anki 2. File → Import -3. Select the generated `warsaw_districts_anki.txt` file -4. Copy all images from `warsaw_districts_images/` to your Anki profile's `collection.media` folder - - On Linux: `~/.local/share/Anki2/[Profile]/collection.media/` - - On Windows: `%APPDATA%\Anki2\[Profile]\collection.media\` - - On macOS: `~/Library/Application Support/Anki2/[Profile]/collection.media/` -5. Click Import +3. Select the generated `warsaw_districts.apkg` file +4. Click Import + +That's it! All images are already embedded in the .apkg file. ## Warsaw Districts @@ -76,22 +91,6 @@ The generator includes all 18 official districts of Warsaw: 17. Wola 18. Żoliborz -## Output Format - -The generated file uses Anki's standard import format: - -``` -#separator:semicolon -#html:true -#deck:Warsaw Districts -#tags:geography warsaw poland -#columns:Front;Back - -;Bemowo -;Białołęka -... -``` - ## Development ### Running tests diff --git a/python_pkg/warsaw_districts/tests/test_warsaw_districts_anki.py b/python_pkg/warsaw_districts/tests/test_warsaw_districts_anki.py index 9a276d5..33a3739 100644 --- a/python_pkg/warsaw_districts/tests/test_warsaw_districts_anki.py +++ b/python_pkg/warsaw_districts/tests/test_warsaw_districts_anki.py @@ -11,9 +11,9 @@ try: from python_pkg.warsaw_districts.warsaw_districts_anki import ( WARSAW_DISTRICTS, create_district_map, - generate_anki_deck, + generate_anki_package, + generate_district_image_bytes, main, - save_district_image, ) except ImportError: import sys @@ -22,9 +22,9 @@ except ImportError: from python_pkg.warsaw_districts.warsaw_districts_anki import ( WARSAW_DISTRICTS, create_district_map, - generate_anki_deck, + generate_anki_package, + generate_district_image_bytes, main, - save_district_image, ) @@ -54,13 +54,30 @@ class TestDistricts: assert len(names) == len(set(names)) def test_known_districts_present(self) -> None: - """Test that known Warsaw districts are in the list.""" + """Test that all known Warsaw districts are in the list.""" district_names = {d.name for d in WARSAW_DISTRICTS} - # Check a few well-known districts - assert "Śródmieście" in district_names - assert "Mokotów" in district_names - assert "Praga-Północ" in district_names - assert "Żoliborz" in district_names + # Check all 18 districts + expected_districts = { + "Bemowo", + "Białołęka", + "Bielany", + "Mokotów", + "Ochota", + "Praga-Południe", + "Praga-Północ", + "Rembertów", + "Śródmieście", + "Targówek", + "Ursus", + "Ursynów", + "Wawer", + "Wesoła", + "Wilanów", + "Włochy", + "Wola", + "Żoliborz", + } + assert district_names == expected_districts class TestCreateDistrictMap: @@ -82,68 +99,44 @@ class TestCreateDistrictMap: plt.close(fig) -class TestSaveDistrictImage: - """Tests for saving district images.""" +class TestGenerateDistrictImageBytes: + """Tests for generating district image bytes.""" - def test_saves_image_file(self, tmp_path: Path) -> None: - """Test that save_district_image creates a file.""" + def test_generates_bytes(self) -> None: + """Test that generate_district_image_bytes returns bytes.""" district = WARSAW_DISTRICTS[0] - image_path = save_district_image(district, tmp_path) + image_bytes = generate_district_image_bytes(district) + assert isinstance(image_bytes, bytes) + assert len(image_bytes) > 0 - assert image_path.exists() - assert image_path.suffix == ".png" - assert image_path.parent == tmp_path - - def test_saves_all_district_images(self, tmp_path: Path) -> None: - """Test that we can save images for all districts.""" + def test_generates_for_all_districts(self) -> None: + """Test that we can generate images for all districts.""" for district in WARSAW_DISTRICTS: - image_path = save_district_image(district, tmp_path) - assert image_path.exists() + image_bytes = generate_district_image_bytes(district) + assert isinstance(image_bytes, bytes) + assert len(image_bytes) > 0 -class TestGenerateAnkiDeck: - """Tests for generating Anki deck content.""" +class TestGenerateAnkiPackage: + """Tests for generating Anki package.""" - def test_generates_valid_header(self, tmp_path: Path) -> None: - """Test that output contains valid Anki headers.""" - result = generate_anki_deck(tmp_path, "Test Deck") + def test_generates_package(self) -> None: + """Test that output is a genanki Package.""" + package = generate_anki_package("Test Deck") + assert package is not None + assert len(package.decks) == 1 - assert "#separator:semicolon" in result - assert "#deck:Test Deck" in result - assert "#html:true" in result + def test_generates_notes_for_all_districts(self) -> None: + """Test that package contains cards for all 18 districts.""" + package = generate_anki_package() + deck = package.decks[0] + assert len(deck.notes) == len(WARSAW_DISTRICTS) - def test_generates_flashcard_for_all_districts(self, tmp_path: Path) -> None: - """Test that output contains cards for all 18 districts.""" - result = generate_anki_deck(tmp_path) - - # Check that all district names appear in the output - for district in WARSAW_DISTRICTS: - assert district.name in result - - def test_generates_images_for_all_districts(self, tmp_path: Path) -> None: - """Test that images are generated for all districts.""" - generate_anki_deck(tmp_path) - - # Check that all image files were created - image_files = list(tmp_path.glob("*.png")) - assert len(image_files) == 18 - - def test_output_format(self, tmp_path: Path) -> None: - """Test that output has correct semicolon-separated format.""" - result = generate_anki_deck(tmp_path) - - lines = result.split("\n") - # Skip header lines and empty lines - data_lines = [line for line in lines if line and not line.startswith("#")] - - # Each data line should have exactly 2 fields (front;back) - for line in data_lines: - fields = line.split(";") - assert len(fields) == 2 - # Front should contain None: """Test that main creates the output file.""" - output_file = tmp_path / "test_output.txt" - image_dir = tmp_path / "images" + output_file = tmp_path / "test_output.apkg" result = main( [ "--output", str(output_file), - "--image-dir", - str(image_dir), ] ) assert result == 0 assert output_file.exists() - assert image_dir.exists() - - def test_creates_images(self, tmp_path: Path) -> None: - """Test that main creates image files.""" - output_file = tmp_path / "test_output.txt" - image_dir = tmp_path / "images" - - main( - [ - "--output", - str(output_file), - "--image-dir", - str(image_dir), - ] - ) - - image_files = list(image_dir.glob("*.png")) - assert len(image_files) == 18 def test_custom_deck_name(self, tmp_path: Path) -> None: """Test that custom deck name is used.""" - output_file = tmp_path / "test_output.txt" - image_dir = tmp_path / "images" + output_file = tmp_path / "test_output.apkg" - main( + result = main( [ "--output", str(output_file), - "--image-dir", - str(image_dir), "--deck-name", "Custom Deck", ] ) - content = output_file.read_text() - assert "#deck:Custom Deck" in content + assert result == 0 + assert output_file.exists() def test_help_flag(self) -> None: """Test that --help works.""" diff --git a/python_pkg/warsaw_districts/warsaw_districts_anki.py b/python_pkg/warsaw_districts/warsaw_districts_anki.py index 2b0a120..08b3a37 100755 --- a/python_pkg/warsaw_districts/warsaw_districts_anki.py +++ b/python_pkg/warsaw_districts/warsaw_districts_anki.py @@ -9,23 +9,24 @@ Usage: python -m python_pkg.warsaw_districts.warsaw_districts_anki # Specify custom output file - python -m python_pkg.warsaw_districts.warsaw_districts_anki --output warsaw.txt - - # Specify custom output directory for images - python -m python_pkg.warsaw_districts.warsaw_districts_anki --image-dir ./maps + python -m python_pkg.warsaw_districts.warsaw_districts_anki --output warsaw.apkg Output: - Creates a semicolon-separated text file that can be imported into Anki. - Format: ;district_name_in_polish + Creates a self-contained .apkg file that can be directly imported into Anki. + The file includes all images embedded, so no manual file copying is needed. """ from __future__ import annotations import argparse +import hashlib +from io import BytesIO from pathlib import Path +import random import sys from typing import TYPE_CHECKING, NamedTuple +import genanki import matplotlib.patches as mpatches import matplotlib.pyplot as plt @@ -134,67 +135,99 @@ def create_district_map(district: District, *, highlight_only: bool = True) -> F return fig -def save_district_image(district: District, output_dir: Path) -> Path: - """Save a district map image to a file. +def generate_district_image_bytes(district: District) -> bytes: + """Generate a district map image as bytes. Args: district: The district to visualize. - output_dir: Directory to save the image. Returns: - Path to the saved image file. + PNG image data as bytes. """ - output_dir.mkdir(parents=True, exist_ok=True) fig = create_district_map(district) - # Create filename from district name (sanitized) - filename = f"{district.name.replace('-', '_').replace(' ', '_')}.png" - output_path = output_dir / filename - - fig.savefig(output_path, format="png", bbox_inches="tight", dpi=150) + # Save to bytes buffer + buf = BytesIO() + fig.savefig(buf, format="png", bbox_inches="tight", dpi=150) plt.close(fig) + buf.seek(0) - return output_path + return buf.read() -def generate_anki_deck( - output_dir: Path, +def generate_anki_package( deck_name: str = "Warsaw Districts", -) -> str: - """Generate Anki-compatible deck content for Warsaw districts. +) -> genanki.Package: + """Generate Anki package (.apkg) for Warsaw districts. Args: - output_dir: Directory where images will be saved. deck_name: Name for the Anki deck. Returns: - Semicolon-separated content ready for Anki import. + genanki.Package object ready to be written to file. """ - lines: list[str] = [] + # Create a unique model ID based on deck name + model_id_hash = hashlib.md5( # noqa: S324 + f"warsaw_districts_{deck_name}".encode() + ) + model_id = int(model_id_hash.hexdigest()[:8], 16) - # Add Anki headers - lines.append("#separator:semicolon") - lines.append("#html:true") - lines.append(f"#deck:{deck_name}") - lines.append("#tags:geography warsaw poland") - lines.append("#columns:Front;Back") - lines.append("") # Empty line before data + # Define the note model (card template) + my_model = genanki.Model( + model_id, + "Warsaw District Model", + fields=[ + {"name": "DistrictMap"}, + {"name": "DistrictName"}, + ], + templates=[ + { + "name": "Card 1", + "qfmt": "{{DistrictMap}}", + "afmt": '{{FrontSide}}
{{DistrictName}}', + }, + ], + ) - # Generate cards for each district + # Create a unique deck ID based on deck name + deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311 + + # Create the deck + my_deck = genanki.Deck(deck_id, deck_name) + + # Store media files + media_files = [] + + # Generate notes for each district for district in WARSAW_DISTRICTS: - # Save the image - image_path = save_district_image(district, output_dir) + # Generate image + image_data = generate_district_image_bytes(district) - # Create the front side: reference to image - # Anki expects the image filename to be in the media collection - front = f'' + # Create unique filename + filename = f"{district.name.replace('-', '_').replace(' ', '_')}.png" - # Back side: district name in Polish - back = district.name + # Create note + note = genanki.Note( + model=my_model, + fields=[ + f'', + district.name, + ], + tags=["geography", "warsaw", "poland"], + ) - lines.append(f"{front};{back}") + my_deck.add_note(note) - return "\n".join(lines) + # Save image data to temporary file for packaging + temp_path = Path(f"/tmp/{filename}") # noqa: S108 + temp_path.write_bytes(image_data) + media_files.append(str(temp_path)) + + # Create package + package = genanki.Package(my_deck) + package.media_files = media_files + + return package def main(argv: Sequence[str] | None = None) -> int: @@ -217,14 +250,7 @@ def main(argv: Sequence[str] | None = None) -> int: "-o", type=str, default=None, - help="Output file path (default: warsaw_districts_anki.txt)", - ) - parser.add_argument( - "--image-dir", - "-i", - type=str, - default=None, - help="Directory for district images (default: warsaw_districts_images)", + help="Output file path (default: warsaw_districts.apkg)", ) parser.add_argument( "--deck-name", @@ -236,44 +262,39 @@ def main(argv: Sequence[str] | None = None) -> int: args = parser.parse_args(argv) - # Determine output paths - if args.output: - output_path = Path(args.output) - else: - output_path = Path("warsaw_districts_anki.txt") - - if args.image_dir: - image_dir = Path(args.image_dir) - else: - image_dir = Path("warsaw_districts_images") + # Determine output path + output_path = ( + Path(args.output) if args.output else Path("warsaw_districts.apkg") + ) try: - print(f"Generating flashcards for {len(WARSAW_DISTRICTS)} Warsaw districts...") # noqa: T201 + num_districts = len(WARSAW_DISTRICTS) + sys.stdout.write( + f"Generating flashcards for {num_districts} Warsaw districts...\n" + ) - # Generate the deck content - anki_content = generate_anki_deck(image_dir, args.deck_name) + # Generate the package + package = generate_anki_package(args.deck_name) - # Write output file - output_path.write_text(anki_content, encoding="utf-8") + # Write to file + package.write_to_file(str(output_path)) - print() # noqa: T201 - print("=" * 60) # noqa: T201 - print("FLASHCARD GENERATION COMPLETE") # noqa: T201 - print("=" * 60) # noqa: T201 - print(f"Districts: {len(WARSAW_DISTRICTS)}") # noqa: T201 - print(f"Images directory: {image_dir.absolute()}") # noqa: T201 - print(f"Output file: {output_path.absolute()}") # noqa: T201 - print() # noqa: T201 - print("To import into Anki:") # noqa: T201 - print(" 1. Open Anki") # noqa: T201 - print(" 2. File -> Import") # noqa: T201 - print(f" 3. Select: {output_path.absolute()}") # noqa: T201 - img_dir = image_dir.absolute() - print(f" 4. Ensure images from {img_dir} are in Anki's media folder") # noqa: T201 - print(" or copy them to your Anki profile's collection.media folder") # noqa: T201 - print(" 5. Click Import") # noqa: T201 - except Exception as e: # noqa: BLE001 - print(f"Error: {e}", file=sys.stderr) # noqa: T201 + sys.stdout.write("\n") + sys.stdout.write("=" * 60 + "\n") + sys.stdout.write("FLASHCARD GENERATION COMPLETE\n") + sys.stdout.write("=" * 60 + "\n") + sys.stdout.write(f"Districts: {num_districts}\n") + sys.stdout.write(f"Output file: {output_path.absolute()}\n") + sys.stdout.write("\n") + sys.stdout.write("To import into Anki:\n") + sys.stdout.write(" 1. Open Anki\n") + sys.stdout.write(" 2. File -> Import\n") + sys.stdout.write(f" 3. Select: {output_path.absolute()}\n") + sys.stdout.write(" 4. Click Import\n") + sys.stdout.write("\n") + sys.stdout.write("All images are embedded in the .apkg file!\n") + except (OSError, ValueError) as e: + sys.stderr.write(f"Error: {e}\n") return 1 else: return 0 diff --git a/requirements.txt b/requirements.txt index 4cde208..e878622 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ beautifulsoup4>=4.0 berserk>=0.13 bottle>=0.12 +genanki>=0.13 lxml>=5.0 # Optional dependencies for specific scripts (needed for full pylint analysis)