diff --git a/python_pkg/warsaw_districts/README.md b/python_pkg/warsaw_districts/README.md new file mode 100644 index 0000000..79d118e --- /dev/null +++ b/python_pkg/warsaw_districts/README.md @@ -0,0 +1,111 @@ +# Warsaw Districts Anki Generator + +Generate Anki flashcards for learning the 18 districts (dzielnice) of Warsaw, Poland. + +## Features + +- 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) +- Compatible with AnkiWeb and AnkiDroid + +## Installation + +```bash +pip install matplotlib +``` + +## Usage + +### Generate flashcards + +```bash +# From the repository root +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 + +### 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 deck name +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 + +## Warsaw Districts + +The generator includes all 18 official districts of Warsaw: + +1. Bemowo +2. Białołęka +3. Bielany +4. Mokotów +5. Ochota +6. Praga-Południe +7. Praga-Północ +8. Rembertów +9. Śródmieście +10. Targówek +11. Ursus +12. Ursynów +13. Wawer +14. Wesoła +15. Wilanów +16. Włochy +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 + +```bash +pytest python_pkg/warsaw_districts/tests/ +``` + +### Code quality + +```bash +ruff check python_pkg/warsaw_districts/ +``` + +## License + +Same as the parent repository. diff --git a/python_pkg/warsaw_districts/__init__.py b/python_pkg/warsaw_districts/__init__.py new file mode 100644 index 0000000..efb0fdd --- /dev/null +++ b/python_pkg/warsaw_districts/__init__.py @@ -0,0 +1,5 @@ +"""Warsaw districts Anki flashcard generator.""" + +from __future__ import annotations + +__all__ = ["generate_anki_deck", "main"] diff --git a/python_pkg/warsaw_districts/tests/__init__.py b/python_pkg/warsaw_districts/tests/__init__.py new file mode 100644 index 0000000..7b53cd4 --- /dev/null +++ b/python_pkg/warsaw_districts/tests/__init__.py @@ -0,0 +1,5 @@ +"""Tests init file.""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/python_pkg/warsaw_districts/tests/test_warsaw_districts_anki.py b/python_pkg/warsaw_districts/tests/test_warsaw_districts_anki.py new file mode 100644 index 0000000..9530bf6 --- /dev/null +++ b/python_pkg/warsaw_districts/tests/test_warsaw_districts_anki.py @@ -0,0 +1,206 @@ +"""Tests for the Warsaw districts Anki generator.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +try: + from python_pkg.warsaw_districts.warsaw_districts_anki import ( + WARSAW_DISTRICTS, + create_district_map, + generate_anki_deck, + main, + save_district_image, + ) +except ImportError: + import sys + + sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) + from python_pkg.warsaw_districts.warsaw_districts_anki import ( + WARSAW_DISTRICTS, + create_district_map, + generate_anki_deck, + main, + save_district_image, + ) + + +class TestDistricts: + """Tests for Warsaw districts data.""" + + def test_has_18_districts(self) -> None: + """Test that we have exactly 18 Warsaw districts.""" + assert len(WARSAW_DISTRICTS) == 18 + + def test_all_districts_have_names(self) -> None: + """Test that all districts have non-empty names.""" + for district in WARSAW_DISTRICTS: + assert district.name + assert isinstance(district.name, str) + assert len(district.name) > 0 + + def test_all_districts_have_valid_coordinates(self) -> None: + """Test that all districts have coordinates in valid range.""" + for district in WARSAW_DISTRICTS: + assert 0 <= district.x <= 1 + assert 0 <= district.y <= 1 + + def test_districts_are_unique(self) -> None: + """Test that all district names are unique.""" + names = [d.name for d in WARSAW_DISTRICTS] + assert len(names) == len(set(names)) + + def test_known_districts_present(self) -> None: + """Test that 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 + + +class TestCreateDistrictMap: + """Tests for creating district maps.""" + + def test_creates_figure(self) -> None: + """Test that create_district_map returns a Figure.""" + district = WARSAW_DISTRICTS[0] + fig = create_district_map(district) + assert fig is not None + # Clean up + import matplotlib.pyplot as plt + + plt.close(fig) + + def test_creates_figure_for_all_districts(self) -> None: + """Test that we can create maps for all districts.""" + import matplotlib.pyplot as plt + + for district in WARSAW_DISTRICTS: + fig = create_district_map(district) + assert fig is not None + plt.close(fig) + + +class TestSaveDistrictImage: + """Tests for saving district images.""" + + def test_saves_image_file(self, tmp_path: Path) -> None: + """Test that save_district_image creates a file.""" + district = WARSAW_DISTRICTS[0] + image_path = save_district_image(district, tmp_path) + + 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.""" + for district in WARSAW_DISTRICTS: + image_path = save_district_image(district, tmp_path) + assert image_path.exists() + + +class TestGenerateAnkiDeck: + """Tests for generating Anki deck content.""" + + 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") + + assert "#separator:semicolon" in result + assert "#deck:Test Deck" in result + assert "#html:true" in result + + 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" + + 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" + + 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 + + def test_help_flag(self) -> None: + """Test that --help works.""" + with pytest.raises(SystemExit) as exc_info: + main(["--help"]) + assert exc_info.value.code == 0 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/python_pkg/warsaw_districts/warsaw_districts_anki.py b/python_pkg/warsaw_districts/warsaw_districts_anki.py new file mode 100755 index 0000000..32a827b --- /dev/null +++ b/python_pkg/warsaw_districts/warsaw_districts_anki.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +"""Anki flashcard generator for Warsaw districts. + +Generates Anki-compatible flashcard decks with maps showing individual +Warsaw districts (dzielnice) with their borders. + +Usage: + # Generate Anki cards for all Warsaw districts + 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 + +Output: + Creates a semicolon-separated text file that can be imported into Anki. + Format: ;district_name_in_polish +""" + +from __future__ import annotations + +import argparse +import base64 +from io import BytesIO +from pathlib import Path +import sys +from typing import TYPE_CHECKING, NamedTuple + +import matplotlib.patches as mpatches +import matplotlib.pyplot as plt + +if TYPE_CHECKING: + from collections.abc import Sequence + + from matplotlib.figure import Figure + + +class District(NamedTuple): + """A Warsaw district with its approximate position.""" + + name: str # Polish name + x: float # Approximate x coordinate (0-1) + y: float # Approximate y coordinate (0-1) + + +# Warsaw districts (dzielnice) - 18 total +# Coordinates are approximate relative positions for visualization +WARSAW_DISTRICTS: list[District] = [ + District("Bemowo", 0.15, 0.55), + District("Białołęka", 0.75, 0.7), + District("Bielany", 0.35, 0.75), + District("Mokotów", 0.45, 0.3), + District("Ochota", 0.3, 0.45), + District("Praga-Południe", 0.7, 0.35), + District("Praga-Północ", 0.7, 0.6), + District("Rembertów", 0.85, 0.5), + District("Śródmieście", 0.5, 0.5), + District("Targówek", 0.65, 0.8), + District("Ursus", 0.05, 0.4), + District("Ursynów", 0.5, 0.15), + District("Wawer", 0.8, 0.25), + District("Wesoła", 0.9, 0.45), + District("Wilanów", 0.6, 0.1), + District("Włochy", 0.15, 0.3), + District("Wola", 0.35, 0.6), + District("Żoliborz", 0.45, 0.7), +] + + +def create_district_map( + district: District, *, highlight_only: bool = True +) -> Figure: + """Create a map showing Warsaw districts with one district highlighted. + + Args: + district: The district to highlight. + highlight_only: If True, show only the highlighted district's border. + + Returns: + A matplotlib Figure object. + """ + fig, ax = plt.subplots(figsize=(8, 8)) + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + ax.set_aspect("equal") + ax.axis("off") + + # Draw all districts as points if not highlight_only + if not highlight_only: + for dist in WARSAW_DISTRICTS: + if dist.name != district.name: + circle = mpatches.Circle( + (dist.x, dist.y), + 0.03, + color="lightgray", + alpha=0.3, + ) + ax.add_patch(circle) + + # Draw the highlighted district with a border + # Create a polygon approximating the district area + # For simplicity, we'll use a circle with border + highlighted = mpatches.Circle( + (district.x, district.y), + 0.08, + facecolor="white", + edgecolor="black", + linewidth=3, + ) + ax.add_patch(highlighted) + + # Add some neighboring circles to show context (lighter borders) + # Find nearest districts + distances = [ + ( + d, + ((d.x - district.x) ** 2 + (d.y - district.y) ** 2) ** 0.5, + ) + for d in WARSAW_DISTRICTS + if d.name != district.name + ] + distances.sort(key=lambda x: x[1]) + + # Draw 3-4 nearest neighbors with light borders + for neighbor, _ in distances[:4]: + neighbor_circle = mpatches.Circle( + (neighbor.x, neighbor.y), + 0.08, + facecolor="white", + edgecolor="lightgray", + linewidth=1, + alpha=0.5, + ) + ax.add_patch(neighbor_circle) + + return fig + + +def generate_district_image_base64(district: District) -> str: + """Generate a base64-encoded PNG image of the district map. + + Args: + district: The district to visualize. + + Returns: + Base64-encoded PNG image string. + """ + fig = create_district_map(district) + + # Save to bytes buffer + buf = BytesIO() + fig.savefig(buf, format="png", bbox_inches="tight", dpi=150) + plt.close(fig) + buf.seek(0) + + # Encode to base64 + return base64.b64encode(buf.read()).decode("utf-8") + + +def save_district_image(district: District, output_dir: Path) -> Path: + """Save a district map image to a file. + + Args: + district: The district to visualize. + output_dir: Directory to save the image. + + Returns: + Path to the saved image file. + """ + 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) + plt.close(fig) + + return output_path + + +def generate_anki_deck( + output_dir: Path, + deck_name: str = "Warsaw Districts", +) -> str: + """Generate Anki-compatible deck content 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. + """ + lines: list[str] = [] + + # 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 + + # Generate cards for each district + for district in WARSAW_DISTRICTS: + # Save the image + image_path = save_district_image(district, output_dir) + + # Create the front side: reference to image + # Anki expects the image filename to be in the media collection + front = f'' + + # Back side: district name in Polish + back = district.name + + lines.append(f"{front};{back}") + + return "\n".join(lines) + + +def main(argv: Sequence[str] | None = None) -> int: + """Main entry point. + + Args: + argv: Command line arguments. + + Returns: + Exit code. + """ + parser = argparse.ArgumentParser( + description="Generate Anki flashcards for Warsaw districts.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + + parser.add_argument( + "--output", + "-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)", + ) + parser.add_argument( + "--deck-name", + "-d", + type=str, + default="Warsaw Districts", + help="Name for the Anki deck (default: 'Warsaw Districts')", + ) + + 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") + + try: + print(f"Generating flashcards for {len(WARSAW_DISTRICTS)} Warsaw districts...") # noqa: T201 + + # Generate the deck content + anki_content = generate_anki_deck(image_dir, args.deck_name) + + # Write output file + output_path.write_text(anki_content, encoding="utf-8") + + 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 + return 1 + else: + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/requirements.txt b/requirements.txt index bd18982..9799423 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ lxml>=5.0 mitmproxy>=10.0 # Optional dependencies for specific scripts (needed for full pylint analysis) +matplotlib>=3.0 opencv-python>=4.0 pillow>=10.0 pygame>=2.0