Consolidate all Anki deck generators into python_pkg/anki_decks/

Move warsaw_bridges, warsaw_districts, warsaw_landmarks, warsaw_metro,
warsaw_osiedla, warsaw_streets, and car_brand_logos into the existing
python_pkg/anki_decks/ directory alongside the polish_* generators.

Also move preview_all.html into anki_decks/.

Update all import paths, filesystem references in geo_data.py,
docstrings, READMEs, test imports, and .gitignore accordingly.
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-02-07 15:32:23 +01:00
parent 2de7801b13
commit fe2c6628e2
73 changed files with 24982 additions and 0 deletions

View File

@ -0,0 +1 @@
"""Anki flashcard deck generators."""

View File

@ -0,0 +1 @@
"""Polish coastal features Anki generator."""

View File

@ -0,0 +1,333 @@
"""Anki flashcard generator for Polish coastal features.
Generates Anki-compatible flashcard decks with maps showing coastal features
(peninsulas, cliffs, beaches, etc.) highlighted on a Poland map.
"""
from __future__ import annotations
import argparse
import hashlib
from io import BytesIO
import multiprocessing as mp
from pathlib import Path
import random
import sys
import tempfile
from typing import TYPE_CHECKING
import genanki
import geopandas as gpd
import matplotlib as mpl
mpl.use("Agg") # Non-interactive backend for multiprocessing
import matplotlib.pyplot as plt
from shapely.geometry import LineString, MultiLineString, MultiPolygon, Polygon
sys.path.insert(0, str(Path(__file__).parent.parent))
from geo_data import get_poland_boundary, get_polish_coastal_features
if TYPE_CHECKING:
from collections.abc import Sequence
from matplotlib.figure import Figure
FEATURE_COLOR_POLYGON = "#D4AC0D" # Gold for polygon features
FEATURE_COLOR_LINE = "#D4AC0D" # Gold for line features
LINE_WIDTH = 4
def create_coastal_map(
feature_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
) -> Figure:
"""Create a map showing Poland with one coastal feature highlighted."""
fig, ax = plt.subplots(figsize=(10, 12))
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_alpha(0)
ax.patch.set_alpha(0)
# Plot Poland as a plain gray shape
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
# Plot the feature based on geometry type
geom = feature_gdf.iloc[0].geometry
if isinstance(geom, Polygon | MultiPolygon):
feature_gdf.plot(ax=ax, color=FEATURE_COLOR_POLYGON, alpha=0.9)
feature_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=3)
elif isinstance(geom, LineString | MultiLineString):
feature_gdf.plot(ax=ax, color=FEATURE_COLOR_LINE, linewidth=LINE_WIDTH)
# Set bounds to Poland
bounds = poland_boundary.total_bounds
ax.set_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3])
return fig
def generate_coastal_image_bytes(
feature_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
) -> bytes:
"""Generate a coastal feature map image as bytes."""
fig = create_coastal_map(feature_gdf, poland_boundary)
buf = BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.read()
# Global variables for multiprocessing (set via initializer)
_mp_poland_boundary: gpd.GeoDataFrame | None = None
def _init_worker(poland_geojson: str) -> None:
"""Initialize worker process with shared data."""
global _mp_poland_boundary # noqa: PLW0603
_mp_poland_boundary = gpd.read_file(poland_geojson)
def _render_single_feature(args: tuple[str, str]) -> tuple[str, bytes]:
"""Render a single feature image (worker function).
Args:
args: Tuple of (feature_name, feature_geojson_str).
Returns:
Tuple of (feature_name, image_bytes).
"""
feature_name, feature_geojson = args
feature_gdf = gpd.read_file(feature_geojson)
assert _mp_poland_boundary is not None # noqa: S101
image_data = generate_coastal_image_bytes(feature_gdf, _mp_poland_boundary)
return feature_name, image_data
def generate_anki_package(
features: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
deck_name: str = "Polish Coastal Features",
) -> genanki.Package:
"""Generate Anki package for Polish coastal features."""
model_id_hash = hashlib.md5( # noqa: S324
f"polish_coastal_features_{deck_name}".encode()
)
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """
.card {
font-family: Arial, sans-serif;
font-size: 24px;
text-align: center;
color: #333;
background-color: #fff;
}
.card.night_mode {
color: #eee;
background-color: #2f2f2f;
}
.map-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.map-container img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
}
.answer-text {
font-size: 32px;
font-weight: bold;
margin-top: 20px;
color: #2C3E50;
}
.card.night_mode .answer-text {
color: #ECF0F1;
}
.info-text {
font-size: 18px;
color: #7F8C8D;
margin-top: 10px;
}
.card.night_mode .info-text {
color: #BDC3C7;
}
"""
my_model = genanki.Model(
model_id,
"Polish Coastal Feature Model",
fields=[
{"name": "FeatureMap"},
{"name": "FeatureName"},
{"name": "FeatureType"},
],
templates=[
{
"name": "Card 1",
"qfmt": '<div class="map-container">{{FeatureMap}}</div>',
"afmt": '<div class="map-container">{{FeatureMap}}</div>'
'<hr id="answer">'
'<div class="answer-text">{{FeatureName}}</div>'
'<div class="info-text">{{FeatureType}}</div>',
},
],
css=card_css,
)
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
my_deck = genanki.Deck(deck_id, deck_name)
media_files = []
# Prepare data for parallel processing
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
poland_boundary.to_file(f.name, driver="GeoJSON")
poland_geojson_path = f.name
# Prepare work items: (feature_name, feature_geojson_str)
work_items: list[tuple[str, str]] = []
for _, row in features.iterrows():
feature_gdf = gpd.GeoDataFrame([row], crs=features.crs)
feature_geojson = feature_gdf.to_json()
work_items.append((row["name"], feature_geojson))
# Use multiprocessing for parallel rendering
num_workers = min(mp.cpu_count(), 8)
sys.stdout.write(
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
)
results: dict[str, bytes] = {}
with mp.Pool(
num_workers,
initializer=_init_worker,
initargs=(poland_geojson_path,),
) as pool:
for i, (feature_name, image_data) in enumerate(
pool.imap_unordered(_render_single_feature, work_items)
):
results[feature_name] = image_data
if (i + 1) % 10 == 0:
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
# Clean up temp file
Path(poland_geojson_path).unlink(missing_ok=True)
# Create notes from results
for _, row in features.iterrows():
feature_name = row["name"]
feature_type = row.get("type", "coastal feature")
image_data = results[feature_name]
filename = f"coastal_{feature_name.replace(' ', '_').replace('/', '_')}.png"
note = genanki.Note(
model=my_model,
fields=[f'<img src="{filename}">', feature_name, feature_type],
tags=["geography", "poland", "coastal", "baltic"],
)
my_deck.add_note(note)
temp_path = Path(f"/tmp/{filename}") # noqa: S108
temp_path.write_bytes(image_data)
media_files.append(str(temp_path))
package = genanki.Package(my_deck)
package.media_files = media_files
return package
def main(argv: Sequence[str] | None = None) -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate Anki flashcards for Polish coastal features.",
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path (default: polish_coastal_features.apkg)",
)
parser.add_argument(
"--deck-name",
"-d",
type=str,
default="Polish Coastal Features",
help="Name for the Anki deck",
)
parser.add_argument(
"--preview",
"-p",
type=str,
default=None,
help="Export preview images to specified directory",
)
parser.add_argument(
"--preview-count",
type=int,
default=5,
help="Number of preview images to export (default: 5)",
)
args = parser.parse_args(argv)
output_path = (
Path(args.output) if args.output else Path("polish_coastal_features.apkg")
)
try:
sys.stdout.write("Loading coastal features data...\n")
features = get_polish_coastal_features()
poland_boundary = get_poland_boundary()
num_features = len(features)
sys.stdout.write(f"Found {num_features} coastal features.\n")
sys.stdout.write("Generating flashcards...\n")
package = generate_anki_package(features, poland_boundary, args.deck_name)
package.write_to_file(str(output_path))
# Export preview images if requested
if args.preview:
preview_dir = Path(args.preview)
preview_dir.mkdir(parents=True, exist_ok=True)
preview_features = list(features.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_features)} preview images "
f"to {preview_dir}...\n"
)
for _, row in preview_features:
feature_name = row["name"]
feature_gdf = gpd.GeoDataFrame([row], crs=features.crs)
image_data = generate_coastal_image_bytes(feature_gdf, poland_boundary)
safe_name = feature_name.replace(" ", "_").replace("/", "_")
preview_path = preview_dir / f"{safe_name}.png"
preview_path.write_bytes(image_data)
sys.stdout.write(f" Saved: {preview_path.name}\n")
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"Coastal features: {num_features}\n")
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
if args.preview:
sys.stdout.write(f"Preview images: {args.preview}\n")
except (OSError, ValueError, RuntimeError) as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,6 @@
#!/bin/bash
# Run the Polish coastal features Anki generator
cd "$(dirname "$0")" || exit
python polish_coastal_features_anki.py --preview preview_images --preview-count 5 "$@"

View File

@ -0,0 +1 @@
"""Polish forests Anki generator."""

View File

@ -0,0 +1,322 @@
"""Anki flashcard generator for Polish forests (puszcze).
Generates Anki-compatible flashcard decks with maps showing large forests
highlighted on a Poland map.
"""
from __future__ import annotations
import argparse
import hashlib
from io import BytesIO
import multiprocessing as mp
from pathlib import Path
import random
import sys
import tempfile
from typing import TYPE_CHECKING
import genanki
import geopandas as gpd
import matplotlib as mpl
mpl.use("Agg") # Non-interactive backend for multiprocessing
import matplotlib.pyplot as plt
sys.path.insert(0, str(Path(__file__).parent.parent))
from geo_data import get_poland_boundary, get_polish_forests
if TYPE_CHECKING:
from collections.abc import Sequence
from matplotlib.figure import Figure
FOREST_COLOR = "#1D4E2B" # Dark green for forests
def create_forest_map(
forest_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
) -> Figure:
"""Create a map showing Poland with one forest highlighted."""
fig, ax = plt.subplots(figsize=(10, 12))
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_alpha(0)
ax.patch.set_alpha(0)
# Plot Poland as a plain gray shape
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
# Plot the forest
forest_gdf.plot(ax=ax, color=FOREST_COLOR, alpha=0.9)
forest_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=1.5)
# Set bounds to Poland
bounds = poland_boundary.total_bounds
ax.set_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3])
return fig
def generate_forest_image_bytes(
forest_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
) -> bytes:
"""Generate a forest map image as bytes."""
fig = create_forest_map(forest_gdf, poland_boundary)
buf = BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.read()
# Global variables for multiprocessing (set via initializer)
_mp_poland_boundary: gpd.GeoDataFrame | None = None
def _init_worker(poland_geojson: str) -> None:
"""Initialize worker process with shared data."""
global _mp_poland_boundary # noqa: PLW0603
_mp_poland_boundary = gpd.read_file(poland_geojson)
def _render_single_forest(args: tuple[str, str]) -> tuple[str, bytes]:
"""Render a single forest image (worker function).
Args:
args: Tuple of (forest_name, forest_geojson_str).
Returns:
Tuple of (forest_name, image_bytes).
"""
forest_name, forest_geojson = args
forest_gdf = gpd.read_file(forest_geojson)
assert _mp_poland_boundary is not None # noqa: S101
image_data = generate_forest_image_bytes(forest_gdf, _mp_poland_boundary)
return forest_name, image_data
def generate_anki_package(
forests: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
deck_name: str = "Polish Forests (Puszcze)",
) -> genanki.Package:
"""Generate Anki package for Polish forests."""
model_id_hash = hashlib.md5(f"polish_forests_{deck_name}".encode()) # noqa: S324
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """
.card {
font-family: Arial, sans-serif;
font-size: 24px;
text-align: center;
color: #333;
background-color: #fff;
}
.card.night_mode {
color: #eee;
background-color: #2f2f2f;
}
.map-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.map-container img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
}
.answer-text {
font-size: 32px;
font-weight: bold;
margin-top: 20px;
color: #2C3E50;
}
.card.night_mode .answer-text {
color: #ECF0F1;
}
.info-text {
font-size: 18px;
color: #7F8C8D;
margin-top: 10px;
}
.card.night_mode .info-text {
color: #BDC3C7;
}
"""
my_model = genanki.Model(
model_id,
"Polish Forest Model",
fields=[
{"name": "ForestMap"},
{"name": "ForestName"},
{"name": "Area"},
],
templates=[
{
"name": "Card 1",
"qfmt": '<div class="map-container">{{ForestMap}}</div>',
"afmt": '<div class="map-container">{{ForestMap}}</div>'
'<hr id="answer">'
'<div class="answer-text">{{ForestName}}</div>'
'<div class="info-text">{{Area}} km²</div>',
},
],
css=card_css,
)
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
my_deck = genanki.Deck(deck_id, deck_name)
media_files = []
# Prepare data for parallel processing
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
poland_boundary.to_file(f.name, driver="GeoJSON")
poland_geojson_path = f.name
# Prepare work items: (forest_name, forest_geojson_str)
work_items: list[tuple[str, str]] = []
for _, row in forests.iterrows():
forest_gdf = gpd.GeoDataFrame([row], crs=forests.crs)
forest_geojson = forest_gdf.to_json()
work_items.append((row["name"], forest_geojson))
# Use multiprocessing for parallel rendering
num_workers = min(mp.cpu_count(), 8)
sys.stdout.write(
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
)
results: dict[str, bytes] = {}
with mp.Pool(
num_workers,
initializer=_init_worker,
initargs=(poland_geojson_path,),
) as pool:
for i, (forest_name, image_data) in enumerate(
pool.imap_unordered(_render_single_forest, work_items)
):
results[forest_name] = image_data
if (i + 1) % 10 == 0:
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
# Clean up temp file
Path(poland_geojson_path).unlink(missing_ok=True)
# Create notes from results
for _, row in forests.iterrows():
forest_name = row["name"]
area_km2 = round(row["area_km2"], 1) if "area_km2" in row else 0
image_data = results[forest_name]
filename = f"forest_{forest_name.replace(' ', '_').replace('/', '_')}.png"
note = genanki.Note(
model=my_model,
fields=[f'<img src="{filename}">', forest_name, str(area_km2)],
tags=["geography", "poland", "forests", "puszcza", "nature"],
)
my_deck.add_note(note)
temp_path = Path(f"/tmp/{filename}") # noqa: S108
temp_path.write_bytes(image_data)
media_files.append(str(temp_path))
package = genanki.Package(my_deck)
package.media_files = media_files
return package
def main(argv: Sequence[str] | None = None) -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate Anki flashcards for Polish forests.",
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path (default: polish_forests.apkg)",
)
parser.add_argument(
"--deck-name",
"-d",
type=str,
default="Polish Forests (Puszcze)",
help="Name for the Anki deck",
)
parser.add_argument(
"--preview",
"-p",
type=str,
default=None,
help="Export preview images to specified directory",
)
parser.add_argument(
"--preview-count",
type=int,
default=5,
help="Number of preview images to export (default: 5)",
)
args = parser.parse_args(argv)
output_path = Path(args.output) if args.output else Path("polish_forests.apkg")
try:
sys.stdout.write("Loading forests data...\n")
forests = get_polish_forests()
poland_boundary = get_poland_boundary()
num_forests = len(forests)
sys.stdout.write(f"Found {num_forests} forests.\n")
sys.stdout.write("Generating flashcards...\n")
package = generate_anki_package(forests, poland_boundary, args.deck_name)
package.write_to_file(str(output_path))
# Export preview images if requested
if args.preview:
preview_dir = Path(args.preview)
preview_dir.mkdir(parents=True, exist_ok=True)
preview_forests = list(forests.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_forests)} preview images "
f"to {preview_dir}...\n"
)
for _, row in preview_forests:
forest_name = row["name"]
forest_gdf = gpd.GeoDataFrame([row], crs=forests.crs)
image_data = generate_forest_image_bytes(forest_gdf, poland_boundary)
safe_name = forest_name.replace(" ", "_").replace("/", "_")
preview_path = preview_dir / f"{safe_name}.png"
preview_path.write_bytes(image_data)
sys.stdout.write(f" Saved: {preview_path.name}\n")
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"Forests: {num_forests}\n")
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
if args.preview:
sys.stdout.write(f"Preview images: {args.preview}\n")
except (OSError, ValueError, RuntimeError) as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,6 @@
#!/bin/bash
# Run the Polish forests Anki generator
cd "$(dirname "$0")" || exit
python polish_forests_anki.py --preview preview_images --preview-count 5 "$@"

View File

@ -0,0 +1 @@
"""Polish gminy (municipalities) Anki flashcard generator."""

View File

@ -0,0 +1,404 @@
#!/usr/bin/env python3
"""Anki flashcard generator for Polish gminy (municipalities).
Generates Anki-compatible flashcard decks with maps showing individual
Polish municipalities highlighted on a country map.
Uses multiprocessing to parallelize image generation for ~4x speedup.
"""
from __future__ import annotations
import argparse
import hashlib
from io import BytesIO
import multiprocessing as mp
from pathlib import Path
import random
import sys
import tempfile
from typing import TYPE_CHECKING
import genanki
import geopandas as gpd
import matplotlib as mpl
mpl.use("Agg") # Non-interactive backend for multiprocessing
import matplotlib.pyplot as plt
sys.path.insert(0, str(Path(__file__).parent.parent))
from geo_data import get_poland_boundary, get_polish_gminy
if TYPE_CHECKING:
from collections.abc import Sequence
from matplotlib.figure import Figure
# 2500 colors for gminy (cycling through)
GMINA_COLORS = [
"#E74C3C",
"#3498DB",
"#2ECC71",
"#9B59B6",
"#F39C12",
"#1ABC9C",
"#E91E63",
"#00BCD4",
"#8BC34A",
"#FF5722",
"#673AB7",
"#FFEB3B",
"#795548",
"#607D8B",
"#CDDC39",
"#FF9800",
"#4CAF50",
"#03A9F4",
"#F44336",
"#009688",
"#3F51B5",
"#FFC107",
"#9E9E9E",
"#00E676",
"#FF4081",
"#448AFF",
"#69F0AE",
"#FFD740",
"#40C4FF",
"#B388FF",
"#EA80FC",
"#82B1FF",
"#A7FFEB",
"#FFFF8D",
"#FF80AB",
"#536DFE",
"#64FFDA",
"#FFE57F",
"#80D8FF",
"#B9F6CA",
"#CF6679",
"#BB86FC",
"#03DAC6",
"#018786",
"#6200EE",
"#3700B3",
"#B00020",
"#FF0266",
"#C51162",
"#AA00FF",
]
def create_gmina_map(
gmina_name: str,
gmina_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
color_map: dict[str, str],
) -> Figure:
"""Create a map showing Poland with one gmina highlighted."""
fig, ax = plt.subplots(figsize=(10, 12))
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_alpha(0)
ax.patch.set_alpha(0)
# Plot Poland as a plain gray shape
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
# Get pre-computed color
fill_color = color_map.get(gmina_name, GMINA_COLORS[0])
# Plot the highlighted gmina
gmina_gdf.plot(ax=ax, color=fill_color, alpha=0.9)
gmina_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=1.5)
# Set bounds to Poland
bounds = poland_boundary.total_bounds
ax.set_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3])
return fig
def generate_gmina_image_bytes(
gmina_name: str,
gmina_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
color_map: dict[str, str],
) -> bytes:
"""Generate a gmina map image as bytes."""
fig = create_gmina_map(gmina_name, gmina_gdf, poland_boundary, color_map)
buf = BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.read()
def _build_color_map(names: list[str]) -> dict[str, str]:
"""Pre-compute color mapping for all names.
Args:
names: List of all gmina names.
Returns:
Dictionary mapping name to color.
"""
sorted_names = sorted(names)
return {
name: GMINA_COLORS[i % len(GMINA_COLORS)] for i, name in enumerate(sorted_names)
}
# Global variables for multiprocessing (set via initializer)
_mp_poland_boundary: gpd.GeoDataFrame | None = None
_mp_color_map: dict[str, str] | None = None
def _init_worker(
poland_geojson: str,
color_map: dict[str, str],
) -> None:
"""Initialize worker process with shared data."""
global _mp_poland_boundary, _mp_color_map # noqa: PLW0603
_mp_poland_boundary = gpd.read_file(poland_geojson)
_mp_color_map = color_map
def _render_single_gmina(args: tuple[str, str]) -> tuple[str, bytes]:
"""Render a single gmina image (worker function).
Args:
args: Tuple of (gmina_name, gmina_geojson_str).
Returns:
Tuple of (gmina_name, image_bytes).
"""
gmina_name, gmina_geojson = args
gmina_gdf = gpd.read_file(gmina_geojson)
assert _mp_poland_boundary is not None # noqa: S101
assert _mp_color_map is not None # noqa: S101
image_data = generate_gmina_image_bytes(
gmina_name, gmina_gdf, _mp_poland_boundary, _mp_color_map
)
return gmina_name, image_data
def generate_anki_package(
gminy: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
deck_name: str = "Polish Gminy",
) -> genanki.Package:
"""Generate Anki package for Polish gminy."""
model_id_hash = hashlib.md5(f"polish_gminy_{deck_name}".encode()) # noqa: S324
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """
.card {
font-family: Arial, sans-serif;
font-size: 24px;
text-align: center;
color: #333;
background-color: #fff;
}
.card.night_mode {
color: #eee;
background-color: #2f2f2f;
}
.map-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.map-container img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
}
.answer-text {
font-size: 32px;
font-weight: bold;
margin-top: 20px;
color: #2C3E50;
}
.card.night_mode .answer-text {
color: #ECF0F1;
}
"""
my_model = genanki.Model(
model_id,
"Polish Gmina Model",
fields=[
{"name": "GminaMap"},
{"name": "GminaName"},
],
templates=[
{
"name": "Card 1",
"qfmt": '<div class="map-container">{{GminaMap}}</div>',
"afmt": '<div class="map-container">{{GminaMap}}</div>'
'<hr id="answer">'
'<div class="answer-text">{{GminaName}}</div>',
},
],
css=card_css,
)
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
my_deck = genanki.Deck(deck_id, deck_name)
media_files = []
# Pre-compute color mapping once (avoids O(n²) sorting)
color_map = _build_color_map(gminy["name"].tolist())
# Prepare data for parallel processing
# Serialize GeoDataFrames to GeoJSON strings for pickling
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
poland_boundary.to_file(f.name, driver="GeoJSON")
poland_geojson_path = f.name
# Prepare work items: (gmina_name, gmina_geojson_str)
work_items: list[tuple[str, str]] = []
for _, row in gminy.iterrows():
gmina_gdf = gpd.GeoDataFrame([row], crs=gminy.crs)
gmina_geojson = gmina_gdf.to_json()
work_items.append((row["name"], gmina_geojson))
# Use multiprocessing for parallel rendering
num_workers = min(mp.cpu_count(), 8)
sys.stdout.write(
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
)
results: dict[str, bytes] = {}
with mp.Pool(
num_workers,
initializer=_init_worker,
initargs=(poland_geojson_path, color_map),
) as pool:
for i, (gmina_name, image_data) in enumerate(
pool.imap_unordered(_render_single_gmina, work_items)
):
results[gmina_name] = image_data
if (i + 1) % 100 == 0:
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
# Clean up temp file
Path(poland_geojson_path).unlink(missing_ok=True)
# Create notes from results
for _, row in gminy.iterrows():
gmina_name = row["name"]
image_data = results[gmina_name]
filename = f"gmina_{gmina_name.replace(' ', '_').replace('/', '_')}.png"
note = genanki.Note(
model=my_model,
fields=[f'<img src="{filename}">', gmina_name],
tags=["geography", "poland", "gminy"],
)
my_deck.add_note(note)
temp_path = Path(f"/tmp/{filename}") # noqa: S108
temp_path.write_bytes(image_data)
media_files.append(str(temp_path))
package = genanki.Package(my_deck)
package.media_files = media_files
return package
def main(argv: Sequence[str] | None = None) -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate Anki flashcards for Polish gminy.",
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path (default: polish_gminy.apkg)",
)
parser.add_argument(
"--deck-name",
"-d",
type=str,
default="Polish Gminy",
help="Name for the Anki deck",
)
parser.add_argument(
"--preview",
"-p",
type=str,
default=None,
help="Export preview images to specified directory",
)
parser.add_argument(
"--preview-count",
type=int,
default=5,
help="Number of preview images to export (default: 5)",
)
args = parser.parse_args(argv)
output_path = Path(args.output) if args.output else Path("polish_gminy.apkg")
try:
sys.stdout.write("Loading gminy data...\n")
gminy = get_polish_gminy()
poland_boundary = get_poland_boundary()
num_gminy = len(gminy)
sys.stdout.write(f"Generating flashcards for {num_gminy} gminy...\n")
sys.stdout.write("This will take a while for ~2500 gminy...\n")
package = generate_anki_package(gminy, poland_boundary, args.deck_name)
package.write_to_file(str(output_path))
# Export preview images if requested
if args.preview:
preview_dir = Path(args.preview)
preview_dir.mkdir(parents=True, exist_ok=True)
preview_gminy = list(gminy.iterrows())[: args.preview_count]
# Pre-compute color mapping for previews
color_map = _build_color_map(gminy["name"].tolist())
sys.stdout.write(
f"Exporting {len(preview_gminy)} preview images "
f"to {preview_dir}...\n"
)
for _, row in preview_gminy:
gmina_name = row["name"]
gmina_gdf = gpd.GeoDataFrame([row], crs=gminy.crs)
image_data = generate_gmina_image_bytes(
gmina_name, gmina_gdf, poland_boundary, color_map
)
safe_name = gmina_name.replace(" ", "_").replace("/", "_")
preview_path = preview_dir / f"{safe_name}.png"
preview_path.write_bytes(image_data)
sys.stdout.write(f" Saved: {preview_path.name}\n")
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"Gminy: {num_gminy}\n")
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
if args.preview:
sys.stdout.write(f"Preview images: {args.preview}\n")
except (OSError, ValueError, RuntimeError) as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,36 @@
#!/bin/bash
# Script to generate Polish Gminy Anki deck
# WARNING: This will take a long time (~2500 gminy)
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_DIR="$SCRIPT_DIR/.venv"
PREVIEW_DIR="$SCRIPT_DIR/preview_images"
echo "=== Polish Gminy Anki Generator ==="
echo "WARNING: This may take a very long time (fetching ~2500 gminy)"
echo
if [ ! -d "$VENV_DIR" ]; then
echo "Creating virtual environment..."
python3 -m venv "$VENV_DIR"
fi
echo "Activating virtual environment..."
source "$VENV_DIR/bin/activate"
echo "Installing dependencies..."
pip install --quiet --upgrade pip
pip install --quiet matplotlib genanki geopandas requests shapely
cd "$SCRIPT_DIR"
# Create preview images directory
mkdir -p "$PREVIEW_DIR"
python -m polish_gminy_anki --output polish_gminy.apkg --preview "$PREVIEW_DIR" --preview-count 5
echo
echo "Done! The Anki deck is at: $SCRIPT_DIR/polish_gminy.apkg"
echo "Preview images are in: $PREVIEW_DIR"

View File

@ -0,0 +1 @@
"""Polish islands Anki generator."""

View File

@ -0,0 +1,410 @@
"""Anki flashcard generator for Polish islands.
Generates Anki-compatible flashcard decks with maps showing islands
highlighted on a Poland map.
"""
from __future__ import annotations
import argparse
import hashlib
from io import BytesIO
import multiprocessing as mp
from pathlib import Path
import random
import sys
import tempfile
from typing import TYPE_CHECKING
import genanki
import geopandas as gpd
import matplotlib as mpl
mpl.use("Agg") # Non-interactive backend for multiprocessing
import matplotlib.pyplot as plt
sys.path.insert(0, str(Path(__file__).parent.parent))
from geo_data import get_poland_boundary, get_polish_islands
if TYPE_CHECKING:
from collections.abc import Sequence
from matplotlib.figure import Figure
ISLAND_COLOR = "#E67E22" # Orange for islands
NEIGHBOR_COLOR = "#EAECEE" # Lighter gray for extended view
# Padding for zoom (in degrees)
ZOOM_PADDING_DEG = 0.2
def _island_extends_beyond(
island_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
) -> bool:
"""Check if island extends beyond Poland's boundaries."""
poland_bounds = poland_boundary.total_bounds # [minx, miny, maxx, maxy]
island_bounds = island_gdf.total_bounds
# Check if any part of island is outside Poland
extends_west = island_bounds[0] < poland_bounds[0]
extends_south = island_bounds[1] < poland_bounds[1]
extends_east = island_bounds[2] > poland_bounds[2]
extends_north = island_bounds[3] > poland_bounds[3]
return extends_west or extends_south or extends_east or extends_north
def create_island_map(
island_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
*,
zoom: bool,
) -> Figure:
"""Create a map showing Poland with one island highlighted.
Args:
island_gdf: GeoDataFrame with the island to highlight.
poland_boundary: GeoDataFrame with Poland's boundary.
zoom: If True, zoom to island area for better visibility.
"""
fig, ax = plt.subplots(figsize=(10, 12))
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_alpha(0)
ax.patch.set_alpha(0)
extends_beyond = _island_extends_beyond(island_gdf, poland_boundary)
if extends_beyond:
# Draw extended background if island goes beyond Poland
island_bounds = island_gdf.total_bounds
padding = 0.5
ax.fill(
[
island_bounds[0] - padding,
island_bounds[2] + padding,
island_bounds[2] + padding,
island_bounds[0] - padding,
],
[
island_bounds[1] - padding,
island_bounds[1] - padding,
island_bounds[3] + padding,
island_bounds[3] + padding,
],
color=NEIGHBOR_COLOR,
zorder=0,
)
# Plot Poland as a plain gray shape
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
# Plot the island with thinner lines
island_gdf.plot(ax=ax, color=ISLAND_COLOR, alpha=0.9)
island_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=1.5)
# Set bounds based on zoom mode and whether island extends beyond
if zoom:
# Zoom to island area with padding
island_bounds = island_gdf.total_bounds
ax.set_xlim(
island_bounds[0] - ZOOM_PADDING_DEG,
island_bounds[2] + ZOOM_PADDING_DEG,
)
ax.set_ylim(
island_bounds[1] - ZOOM_PADDING_DEG,
island_bounds[3] + ZOOM_PADDING_DEG,
)
elif extends_beyond:
# Include the full island in view
island_bounds = island_gdf.total_bounds
poland_bounds = poland_boundary.total_bounds
ax.set_xlim(
min(poland_bounds[0], island_bounds[0] - 0.1),
max(poland_bounds[2], island_bounds[2] + 0.1),
)
ax.set_ylim(
min(poland_bounds[1], island_bounds[1] - 0.1),
max(poland_bounds[3], island_bounds[3] + 0.1),
)
else:
# Normal Poland bounds
bounds = poland_boundary.total_bounds
ax.set_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3])
return fig
def generate_island_image_bytes(
island_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
*,
zoom: bool,
) -> bytes:
"""Generate an island map image as bytes."""
fig = create_island_map(island_gdf, poland_boundary, zoom=zoom)
buf = BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.read()
# Global variables for multiprocessing (set via initializer)
_mp_poland_boundary: gpd.GeoDataFrame | None = None
_mp_zoom_mode: str = "no-zoom"
def _init_worker(poland_geojson: str, zoom_mode: str) -> None:
"""Initialize worker process with shared data."""
global _mp_poland_boundary, _mp_zoom_mode # noqa: PLW0603
_mp_poland_boundary = gpd.read_file(poland_geojson)
_mp_zoom_mode = zoom_mode
def _render_single_island(args: tuple[str, str]) -> tuple[str, bytes]:
"""Render a single island image (worker function).
Args:
args: Tuple of (island_name, island_geojson_str).
Returns:
Tuple of (island_name, image_bytes).
"""
island_name, island_geojson = args
island_gdf = gpd.read_file(island_geojson)
assert _mp_poland_boundary is not None # noqa: S101
image_data = generate_island_image_bytes(
island_gdf, _mp_poland_boundary, zoom=(_mp_zoom_mode == "zoom")
)
return island_name, image_data
def generate_anki_package(
islands: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
deck_name: str = "Polish Islands",
*,
zoom: bool = True,
) -> genanki.Package:
"""Generate Anki package for Polish islands."""
model_id_hash = hashlib.md5(f"polish_islands_{deck_name}".encode()) # noqa: S324
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """
.card {
font-family: Arial, sans-serif;
font-size: 24px;
text-align: center;
color: #333;
background-color: #fff;
}
.card.night_mode {
color: #eee;
background-color: #2f2f2f;
}
.map-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.map-container img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
}
.answer-text {
font-size: 32px;
font-weight: bold;
margin-top: 20px;
color: #2C3E50;
}
.card.night_mode .answer-text {
color: #ECF0F1;
}
.info-text {
font-size: 18px;
color: #7F8C8D;
margin-top: 10px;
}
.card.night_mode .info-text {
color: #BDC3C7;
}
"""
my_model = genanki.Model(
model_id,
"Polish Island Model",
fields=[
{"name": "IslandMap"},
{"name": "IslandName"},
{"name": "Area"},
],
templates=[
{
"name": "Card 1",
"qfmt": '<div class="map-container">{{IslandMap}}</div>',
"afmt": '<div class="map-container">{{IslandMap}}</div>'
'<hr id="answer">'
'<div class="answer-text">{{IslandName}}</div>'
'<div class="info-text">{{Area}} km²</div>',
},
],
css=card_css,
)
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
my_deck = genanki.Deck(deck_id, deck_name)
media_files = []
# Prepare data for parallel processing
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
poland_boundary.to_file(f.name, driver="GeoJSON")
poland_geojson_path = f.name
# Prepare work items: (island_name, island_geojson_str)
work_items: list[tuple[str, str]] = []
for _, row in islands.iterrows():
island_gdf = gpd.GeoDataFrame([row], crs=islands.crs)
island_geojson = island_gdf.to_json()
work_items.append((row["name"], island_geojson))
# Use multiprocessing for parallel rendering
zoom_mode = "zoom" if zoom else "no-zoom"
num_workers = min(mp.cpu_count(), 8)
sys.stdout.write(
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
)
results: dict[str, bytes] = {}
with mp.Pool(
num_workers,
initializer=_init_worker,
initargs=(poland_geojson_path, zoom_mode),
) as pool:
for i, (island_name, image_data) in enumerate(
pool.imap_unordered(_render_single_island, work_items)
):
results[island_name] = image_data
if (i + 1) % 10 == 0:
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
# Clean up temp file
Path(poland_geojson_path).unlink(missing_ok=True)
# Create notes from results
for _, row in islands.iterrows():
island_name = row["name"]
area_km2 = round(row["area_km2"], 1) if "area_km2" in row else 0
image_data = results[island_name]
filename = f"island_{island_name.replace(' ', '_').replace('/', '_')}.png"
note = genanki.Note(
model=my_model,
fields=[f'<img src="{filename}">', island_name, str(area_km2)],
tags=["geography", "poland", "islands", "coastal"],
)
my_deck.add_note(note)
temp_path = Path(f"/tmp/{filename}") # noqa: S108
temp_path.write_bytes(image_data)
media_files.append(str(temp_path))
package = genanki.Package(my_deck)
package.media_files = media_files
return package
def main(argv: Sequence[str] | None = None) -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate Anki flashcards for Polish islands.",
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path (default: polish_islands.apkg)",
)
parser.add_argument(
"--deck-name",
"-d",
type=str,
default="Polish Islands",
help="Name for the Anki deck",
)
parser.add_argument(
"--preview",
"-p",
type=str,
default=None,
help="Export preview images to specified directory",
)
parser.add_argument(
"--preview-count",
type=int,
default=5,
help="Number of preview images to export (default: 5)",
)
args = parser.parse_args(argv)
output_path = Path(args.output) if args.output else Path("polish_islands.apkg")
try:
sys.stdout.write("Loading islands data...\n")
islands = get_polish_islands()
poland_boundary = get_poland_boundary()
num_islands = len(islands)
sys.stdout.write(f"Found {num_islands} islands.\n")
sys.stdout.write("Generating flashcards...\n")
package = generate_anki_package(islands, poland_boundary, args.deck_name)
package.write_to_file(str(output_path))
# Export preview images if requested
if args.preview:
preview_dir = Path(args.preview)
preview_dir.mkdir(parents=True, exist_ok=True)
preview_islands = list(islands.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_islands)} preview images "
f"to {preview_dir}...\n"
)
for _, row in preview_islands:
island_name = row["name"]
island_gdf = gpd.GeoDataFrame([row], crs=islands.crs)
image_data = generate_island_image_bytes(
island_gdf, poland_boundary, zoom=True
)
safe_name = island_name.replace(" ", "_").replace("/", "_")
preview_path = preview_dir / f"{safe_name}.png"
preview_path.write_bytes(image_data)
sys.stdout.write(f" Saved: {preview_path.name}\n")
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"Islands: {num_islands}\n")
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
if args.preview:
sys.stdout.write(f"Preview images: {args.preview}\n")
except (OSError, ValueError, RuntimeError) as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,6 @@
#!/bin/bash
# Run the Polish islands Anki generator
cd "$(dirname "$0")" || exit
python polish_islands_anki.py --preview preview_images --preview-count 5 "$@"

View File

@ -0,0 +1 @@
"""Polish lakes Anki deck generator."""

View File

@ -0,0 +1,362 @@
"""Anki flashcard generator for Polish lakes.
Generates Anki-compatible flashcard decks with ZOOMED maps showing lakes
highlighted for better visibility.
"""
from __future__ import annotations
import argparse
import hashlib
from io import BytesIO
import multiprocessing as mp
from pathlib import Path
import random
import sys
import tempfile
from typing import TYPE_CHECKING
import genanki
import geopandas as gpd
import matplotlib as mpl
mpl.use("Agg") # Non-interactive backend for multiprocessing
import matplotlib.pyplot as plt
sys.path.insert(0, str(Path(__file__).parent.parent))
from geo_data import get_poland_boundary, get_polish_lakes
if TYPE_CHECKING:
from collections.abc import Sequence
from matplotlib.figure import Figure
LAKE_COLOR = "#3498DB" # Blue for lakes
ZOOM_PADDING_DEG = 0.3 # Degrees of padding around lake for zoomed view
def create_lake_map(
lake_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
*,
zoom: bool = True,
) -> Figure:
"""Create a map showing Poland with one lake highlighted."""
fig, ax = plt.subplots(figsize=(10, 12))
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_alpha(0)
ax.patch.set_alpha(0)
# Plot Poland as a plain gray shape
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
# Plot the lake with thinner border
lake_gdf.plot(ax=ax, color=LAKE_COLOR, alpha=0.9)
lake_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=1.5)
if zoom:
# Zoom to lake area with padding
bounds = lake_gdf.total_bounds
min_x, min_y, max_x, max_y = bounds
# Add padding
ax.set_xlim(min_x - ZOOM_PADDING_DEG, max_x + ZOOM_PADDING_DEG)
ax.set_ylim(min_y - ZOOM_PADDING_DEG, max_y + ZOOM_PADDING_DEG)
else:
# Set bounds to Poland
bounds = poland_boundary.total_bounds
ax.set_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3])
return fig
def generate_lake_image_bytes(
lake_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
*,
zoom: bool = True,
) -> bytes:
"""Generate a lake map image as bytes."""
fig = create_lake_map(lake_gdf, poland_boundary, zoom=zoom)
buf = BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.read()
# Global variables for multiprocessing (set via initializer)
_mp_poland_boundary: gpd.GeoDataFrame | None = None
_mp_zoom: bool = True
def _init_worker(poland_geojson: str, zoom_mode: str) -> None:
"""Initialize worker process with shared data."""
global _mp_poland_boundary, _mp_zoom # noqa: PLW0603
_mp_poland_boundary = gpd.read_file(poland_geojson)
_mp_zoom = zoom_mode == "zoom"
def _render_single_lake(args: tuple[str, str]) -> tuple[str, bytes]:
"""Render a single lake image (worker function).
Args:
args: Tuple of (lake_name, lake_geojson_str).
Returns:
Tuple of (lake_name, image_bytes).
"""
lake_name, lake_geojson = args
lake_gdf = gpd.read_file(lake_geojson)
assert _mp_poland_boundary is not None # noqa: S101
image_data = generate_lake_image_bytes(lake_gdf, _mp_poland_boundary, zoom=_mp_zoom)
return lake_name, image_data
def generate_anki_package(
lakes: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
deck_name: str = "Polish Lakes",
*,
zoom: bool = True,
) -> genanki.Package:
"""Generate Anki package for Polish lakes."""
model_id_hash = hashlib.md5(f"polish_lakes_{deck_name}".encode()) # noqa: S324
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """
.card {
font-family: Arial, sans-serif;
font-size: 24px;
text-align: center;
color: #333;
background-color: #fff;
}
.card.night_mode {
color: #eee;
background-color: #2f2f2f;
}
.map-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.map-container img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
}
.answer-text {
font-size: 32px;
font-weight: bold;
margin-top: 20px;
color: #2C3E50;
}
.card.night_mode .answer-text {
color: #ECF0F1;
}
.info-text {
font-size: 18px;
color: #7F8C8D;
margin-top: 10px;
}
.card.night_mode .info-text {
color: #BDC3C7;
}
"""
my_model = genanki.Model(
model_id,
"Polish Lake Model",
fields=[
{"name": "LakeMap"},
{"name": "LakeName"},
{"name": "Area"},
],
templates=[
{
"name": "Card 1",
"qfmt": '<div class="map-container">{{LakeMap}}</div>',
"afmt": '<div class="map-container">{{LakeMap}}</div>'
'<hr id="answer">'
'<div class="answer-text">{{LakeName}}</div>'
'<div class="info-text">{{Area}} km²</div>',
},
],
css=card_css,
)
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
my_deck = genanki.Deck(deck_id, deck_name)
media_files = []
# Prepare data for parallel processing
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
poland_boundary.to_file(f.name, driver="GeoJSON")
poland_geojson_path = f.name
# Prepare work items: (lake_name, lake_geojson_str)
work_items: list[tuple[str, str]] = []
for _, row in lakes.iterrows():
lake_gdf = gpd.GeoDataFrame([row], crs=lakes.crs)
lake_geojson = lake_gdf.to_json()
work_items.append((row["name"], lake_geojson))
# Use multiprocessing for parallel rendering
num_workers = min(mp.cpu_count(), 8)
sys.stdout.write(
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
)
results: dict[str, bytes] = {}
with mp.Pool(
num_workers,
initializer=_init_worker,
initargs=(poland_geojson_path, "zoom" if zoom else "no-zoom"),
) as pool:
for i, (lake_name, image_data) in enumerate(
pool.imap_unordered(_render_single_lake, work_items)
):
results[lake_name] = image_data
if (i + 1) % 50 == 0:
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
# Clean up temp file
Path(poland_geojson_path).unlink(missing_ok=True)
# Create notes from results
for _, row in lakes.iterrows():
lake_name = row["name"]
area_km2 = round(row["area_km2"], 1) if "area_km2" in row else 0
image_data = results[lake_name]
filename = f"lake_{lake_name.replace(' ', '_').replace('/', '_')}.png"
note = genanki.Note(
model=my_model,
fields=[f'<img src="{filename}">', lake_name, str(area_km2)],
tags=["geography", "poland", "lakes", "water"],
)
my_deck.add_note(note)
temp_path = Path(f"/tmp/{filename}") # noqa: S108
temp_path.write_bytes(image_data)
media_files.append(str(temp_path))
package = genanki.Package(my_deck)
package.media_files = media_files
return package
def main(argv: Sequence[str] | None = None) -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate Anki flashcards for Polish lakes.",
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path (default: polish_lakes.apkg)",
)
parser.add_argument(
"--deck-name",
"-d",
type=str,
default="Polish Lakes",
help="Name for the Anki deck",
)
parser.add_argument(
"--preview",
"-p",
type=str,
default=None,
help="Export preview images to specified directory",
)
parser.add_argument(
"--preview-count",
type=int,
default=5,
help="Number of preview images to export (default: 5)",
)
parser.add_argument(
"--no-zoom",
action="store_true",
help="Disable zoom (show entire Poland instead of zoomed region)",
)
parser.add_argument(
"--limit",
"-l",
type=int,
default=None,
help="Limit number of lakes (for testing)",
)
args = parser.parse_args(argv)
output_path = Path(args.output) if args.output else Path("polish_lakes.apkg")
zoom = not args.no_zoom
try:
sys.stdout.write("Loading lakes data...\n")
lakes = get_polish_lakes()
poland_boundary = get_poland_boundary()
if args.limit:
lakes = lakes.head(args.limit)
sys.stdout.write(f"Limiting to {args.limit} lakes.\n")
num_lakes = len(lakes)
sys.stdout.write(f"Found {num_lakes} lakes.\n")
sys.stdout.write(f"Zoom mode: {'enabled' if zoom else 'disabled'}\n")
sys.stdout.write("Generating flashcards...\n")
package = generate_anki_package(
lakes, poland_boundary, args.deck_name, zoom=zoom
)
package.write_to_file(str(output_path))
# Export preview images if requested
if args.preview:
preview_dir = Path(args.preview)
preview_dir.mkdir(parents=True, exist_ok=True)
preview_lakes = list(lakes.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_lakes)} preview images "
f"to {preview_dir}...\n"
)
for _, row in preview_lakes:
lake_name = row["name"]
lake_gdf = gpd.GeoDataFrame([row], crs=lakes.crs)
image_data = generate_lake_image_bytes(
lake_gdf, poland_boundary, zoom=zoom
)
safe_name = lake_name.replace(" ", "_").replace("/", "_")
preview_path = preview_dir / f"{safe_name}.png"
preview_path.write_bytes(image_data)
sys.stdout.write(f" Saved: {preview_path.name}\n")
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"Lakes: {num_lakes}\n")
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
if args.preview:
sys.stdout.write(f"Preview images: {args.preview}\n")
except (OSError, ValueError, RuntimeError) as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,6 @@
#!/bin/bash
# Run the Polish lakes Anki generator
cd "$(dirname "$0")" || exit
python polish_lakes_anki.py --preview preview_images --preview-count 5 "$@"

View File

@ -0,0 +1 @@
"""Polish landscape parks Anki deck generator."""

View File

@ -0,0 +1,336 @@
"""Anki flashcard generator for Polish landscape parks.
Generates Anki-compatible flashcard decks with maps showing landscape parks
highlighted on a Poland map.
"""
from __future__ import annotations
import argparse
import hashlib
from io import BytesIO
import multiprocessing as mp
from pathlib import Path
import random
import sys
import tempfile
from typing import TYPE_CHECKING
import genanki
import geopandas as gpd
import matplotlib as mpl
mpl.use("Agg") # Non-interactive backend for multiprocessing
import matplotlib.pyplot as plt
sys.path.insert(0, str(Path(__file__).parent.parent))
from geo_data import get_poland_boundary, get_polish_landscape_parks
if TYPE_CHECKING:
from collections.abc import Sequence
from matplotlib.figure import Figure
PARK_COLOR = "#27AE60" # Green for landscape parks
def create_park_map(
park_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
) -> Figure:
"""Create a map showing Poland with one landscape park highlighted.
Clips park geometry to Poland boundary for clean edges.
"""
fig, ax = plt.subplots(figsize=(10, 12))
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_alpha(0)
ax.patch.set_alpha(0)
# Plot Poland as a plain gray shape
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
# Clip park geometry to Poland boundary for clean edges
boundary_union = poland_boundary.union_all()
clipped_gdf = park_gdf.copy()
clipped_gdf["geometry"] = park_gdf.geometry.intersection(boundary_union)
# Plot the landscape park with thinner lines
clipped_gdf.plot(ax=ax, color=PARK_COLOR, alpha=0.9)
clipped_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=1.5)
# Set bounds to Poland
bounds = poland_boundary.total_bounds
ax.set_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3])
return fig
def generate_park_image_bytes(
park_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
) -> bytes:
"""Generate a park map image as bytes."""
fig = create_park_map(park_gdf, poland_boundary)
buf = BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.read()
# Global variables for multiprocessing (set via initializer)
_mp_poland_boundary: gpd.GeoDataFrame | None = None
def _init_worker(poland_geojson: str) -> None:
"""Initialize worker process with shared data."""
global _mp_poland_boundary # noqa: PLW0603
_mp_poland_boundary = gpd.read_file(poland_geojson)
def _render_single_park(args: tuple[str, str]) -> tuple[str, bytes]:
"""Render a single park image (worker function).
Args:
args: Tuple of (park_name, park_geojson_str).
Returns:
Tuple of (park_name, image_bytes).
"""
park_name, park_geojson = args
park_gdf = gpd.read_file(park_geojson)
# Fix any geometry issues from serialization
park_gdf["geometry"] = park_gdf.geometry.make_valid()
assert _mp_poland_boundary is not None # noqa: S101
image_data = generate_park_image_bytes(park_gdf, _mp_poland_boundary)
return park_name, image_data
def generate_anki_package(
parks: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
deck_name: str = "Polish Landscape Parks",
) -> genanki.Package:
"""Generate Anki package for Polish landscape parks."""
model_id_hash = hashlib.md5( # noqa: S324
f"polish_landscape_parks_{deck_name}".encode()
)
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """
.card {
font-family: Arial, sans-serif;
font-size: 24px;
text-align: center;
color: #333;
background-color: #fff;
}
.card.night_mode {
color: #eee;
background-color: #2f2f2f;
}
.map-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.map-container img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
}
.answer-text {
font-size: 32px;
font-weight: bold;
margin-top: 20px;
color: #2C3E50;
}
.card.night_mode .answer-text {
color: #ECF0F1;
}
.info-text {
font-size: 18px;
color: #7F8C8D;
margin-top: 10px;
}
.card.night_mode .info-text {
color: #BDC3C7;
}
"""
my_model = genanki.Model(
model_id,
"Polish Landscape Park Model",
fields=[
{"name": "ParkMap"},
{"name": "ParkName"},
{"name": "Area"},
],
templates=[
{
"name": "Card 1",
"qfmt": '<div class="map-container">{{ParkMap}}</div>',
"afmt": '<div class="map-container">{{ParkMap}}</div>'
'<hr id="answer">'
'<div class="answer-text">{{ParkName}}</div>'
'<div class="info-text">{{Area}} km²</div>',
},
],
css=card_css,
)
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
my_deck = genanki.Deck(deck_id, deck_name)
media_files = []
# Prepare data for parallel processing
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
poland_boundary.to_file(f.name, driver="GeoJSON")
poland_geojson_path = f.name
# Prepare work items: (park_name, park_geojson_str)
work_items: list[tuple[str, str]] = []
for _, row in parks.iterrows():
park_gdf = gpd.GeoDataFrame([row], crs=parks.crs)
park_geojson = park_gdf.to_json()
work_items.append((row["name"], park_geojson))
# Use multiprocessing for parallel rendering
num_workers = min(mp.cpu_count(), 8)
sys.stdout.write(
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
)
results: dict[str, bytes] = {}
with mp.Pool(
num_workers,
initializer=_init_worker,
initargs=(poland_geojson_path,),
) as pool:
for i, (park_name, image_data) in enumerate(
pool.imap_unordered(_render_single_park, work_items)
):
results[park_name] = image_data
if (i + 1) % 25 == 0:
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
# Clean up temp file
Path(poland_geojson_path).unlink(missing_ok=True)
# Create notes from results
for _, row in parks.iterrows():
park_name = row["name"]
area_km2 = round(row["area_km2"], 1) if "area_km2" in row else 0
image_data = results[park_name]
filename = f"lpark_{park_name.replace(' ', '_').replace('/', '_')}.png"
note = genanki.Note(
model=my_model,
fields=[f'<img src="{filename}">', park_name, str(area_km2)],
tags=["geography", "poland", "landscape-parks", "nature"],
)
my_deck.add_note(note)
temp_path = Path(f"/tmp/{filename}") # noqa: S108
temp_path.write_bytes(image_data)
media_files.append(str(temp_path))
package = genanki.Package(my_deck)
package.media_files = media_files
return package
def main(argv: Sequence[str] | None = None) -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate Anki flashcards for Polish landscape parks.",
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path (default: polish_landscape_parks.apkg)",
)
parser.add_argument(
"--deck-name",
"-d",
type=str,
default="Polish Landscape Parks",
help="Name for the Anki deck",
)
parser.add_argument(
"--preview",
"-p",
type=str,
default=None,
help="Export preview images to specified directory",
)
parser.add_argument(
"--preview-count",
type=int,
default=5,
help="Number of preview images to export (default: 5)",
)
args = parser.parse_args(argv)
output_path = (
Path(args.output) if args.output else Path("polish_landscape_parks.apkg")
)
try:
sys.stdout.write("Loading landscape parks data...\n")
parks = get_polish_landscape_parks()
poland_boundary = get_poland_boundary()
num_parks = len(parks)
sys.stdout.write(f"Found {num_parks} landscape parks.\n")
sys.stdout.write("Generating flashcards...\n")
package = generate_anki_package(parks, poland_boundary, args.deck_name)
package.write_to_file(str(output_path))
# Export preview images if requested
if args.preview:
preview_dir = Path(args.preview)
preview_dir.mkdir(parents=True, exist_ok=True)
preview_parks = list(parks.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_parks)} preview images "
f"to {preview_dir}...\n"
)
for _, row in preview_parks:
park_name = row["name"]
park_gdf = gpd.GeoDataFrame([row], crs=parks.crs)
image_data = generate_park_image_bytes(park_gdf, poland_boundary)
safe_name = park_name.replace(" ", "_").replace("/", "_")
preview_path = preview_dir / f"{safe_name}.png"
preview_path.write_bytes(image_data)
sys.stdout.write(f" Saved: {preview_path.name}\n")
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"Landscape parks: {num_parks}\n")
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
if args.preview:
sys.stdout.write(f"Preview images: {args.preview}\n")
except (OSError, ValueError, RuntimeError) as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,6 @@
#!/bin/bash
# Run the Polish landscape parks Anki generator
cd "$(dirname "$0")" || exit
python polish_landscape_parks_anki.py --preview preview_images --preview-count 5 "$@"

View File

@ -0,0 +1,151 @@
# Polish License Plates Anki Generator
Generate Anki flashcards for learning Polish car license plate codes.
## Overview
This package generates Anki-compatible flashcard decks for all Polish vehicle registration plate codes. Each code is mapped to its corresponding location (city or powiat).
Polish license plates use a system where:
- First letter indicates the **voivodeship** (province)
- Following 1-2 letters indicate the specific **city** or **powiat** (county)
## Features
- **444 license plate codes** covering all Polish voivodeships, cities, and powiats
- **Bidirectional flashcards**:
- Code → Location (e.g., `WY``Warszawa Wola`)
- Location → Code (e.g., `Warszawa Wola``WY`)
- **888 total flashcards** for comprehensive learning
- Visual license plate styling in flashcards (yellow background, monospace font)
- Dark mode support
- Self-contained `.apkg` file - no manual setup required
## Data Source
License plate data is automatically extracted from Wikipedia's authoritative table:
- **Source**: [Vehicle registration plates of Poland](https://en.wikipedia.org/wiki/Vehicle_registration_plates_of_Poland)
- **Update**: Run `python -m python_pkg.anki_decks.polish_license_plates.fetch_license_plates` to refresh data
This ensures the codes are always based on the most current public information.
## Usage
### Generate Flashcards
```bash
# Generate with default settings
python -m python_pkg.anki_decks.polish_license_plates.polish_license_plates_anki
# Specify custom output file
python -m python_pkg.anki_decks.polish_license_plates.polish_license_plates_anki \
--output my_plates.apkg
# Use custom deck name
python -m python_pkg.anki_decks.polish_license_plates.polish_license_plates_anki \
--deck-name "My Polish Plates"
```
### Update License Plate Data
To fetch the latest data from Wikipedia:
```bash
# Use cached data if available (default)
python -m python_pkg.anki_decks.polish_license_plates.fetch_license_plates
# Force refresh from Wikipedia (ignore cache)
python -m python_pkg.anki_decks.polish_license_plates.fetch_license_plates --force
```
**Caching**: Downloaded Wikipedia data is cached for 7 days in `.wikipedia_cache/` to avoid unnecessary requests. Use `--force` to bypass the cache.
This will update `license_plate_data.py` with the current codes from Wikipedia.
**Requirements**: `pip install requests beautifulsoup4 lxml`
### Import into Anki
1. Open Anki
2. File → Import
3. Select the generated `.apkg` file
4. Click Import
## Examples
### License Plate Codes by Voivodeship
| Voivodeship | First Letter | Example Codes |
| ------------------- | ------------ | ------------------------------------------------ |
| Dolnośląskie | D | DA (Wrocław), DB (Wałbrzych), DJ (Jelenia Góra) |
| Kujawsko-Pomorskie | C | CB (Bydgoszcz), CT (Toruń), CG (Grudziądz) |
| Lubelskie | L | LL (Lublin), LC (Chełm), LZ (Zamość) |
| Lubuskie | F | FZ (Zielona Góra), FG (Gorzów Wielkopolski) |
| Łódzkie | E | ED (Łódź), EP (Piotrków Trybunalski) |
| Małopolskie | K | KR (Kraków), KT (Tarnów), KN (Nowy Sącz) |
| Mazowieckie | W | WA-WZ (Warsaw), WR (Radom), WS (Siedlce) |
| Opolskie | O | OP (Opole), OK (Kędzierzyn-Koźle) |
| Podkarpackie | R | RR (Rzeszów), RP (Przemyśl), RK (Krosno) |
| Podlaskie | B | BI (Białystok), BL (Łomża), BSU (Suwałki) |
| Pomorskie | G | GD (Gdańsk), GDY (Gdynia), GS (Słupsk) |
| Śląskie | S | SK (Katowice), SC (Chorzów), SB (Bielsko-Biała) |
| Świętokrzyskie | T | TK (Kielce), TSK (Skarżysko-Kamienna) |
| Warmińsko-Mazurskie | N | NO (Olsztyn), NE (Elbląg), NG (Giżycko) |
| Wielkopolskie | P | PO (Poznań), PKA (Kalisz), PIA (Piła) |
| Zachodniopomorskie | Z | ZS (Szczecin), ZKO (Koszalin), ZSW (Świnoujście) |
### Warsaw (Warszawa) Codes
Warsaw has an extensive range of codes (WA-WZ):
- WA: Warszawa (general)
- WB: Warszawa Bemowo
- WC: Ciechanów
- WD: Warszawa Praga Południe
- WE: Warszawa Praga Północ
- WH: Warszawa Mokotów
- WY: Warszawa Wola
- And many more...
## Data
The package includes 444 license plate codes covering:
- All 16 Polish voivodeships
- Major cities with powiat rights (e.g., Kraków, Gdańsk, Poznań)
- All powiats (counties) across Poland
## Testing
Run the test suite:
```bash
python -m pytest python_pkg/polish_license_plates/tests/ -v
```
All 17 tests validate:
- Data integrity (444 codes, no duplicates)
- Correct voivodeship prefixes
- Major cities present
- Anki package generation
- Bidirectional card templates
- CLI functionality
## Technical Details
- **Package format**: Anki `.apkg` (SQLite database)
- **Card model**: Bidirectional with two templates per note
- **Styling**: Custom CSS with license plate visual design
- **Tags**: `geography`, `poland`, `license-plates`, `transportation`
## Requirements
- Python 3.10+
- genanki (for Anki package generation)
## License
Part of the testsAndMisc repository.

View File

@ -0,0 +1,9 @@
"""Polish license plate Anki flashcard generator."""
from __future__ import annotations
__all__ = ["LICENSE_PLATE_CODES"]
from python_pkg.anki_decks.polish_license_plates.license_plate_data import (
LICENSE_PLATE_CODES,
)

View File

@ -0,0 +1,391 @@
#!/usr/bin/env python3
"""Fetch Polish license plate codes from Wikipedia.
This script scrapes the Wikipedia page "Vehicle registration plates of Poland"
to extract the official license plate codes and their corresponding locations.
The data is extracted from the wikitable on the page and saved to license_plate_data.py.
Caching:
Fetched Wikipedia HTML is cached to avoid unnecessary requests.
Cache location: .wikipedia_cache/license_plates.html
Cache expires after 7 days by default.
Usage:
python -m python_pkg.anki_decks.polish_license_plates.fetch_license_plates
# Force refresh (ignore cache)
python -m python_pkg.anki_decks.polish_license_plates.fetch_license_plates --force
Source:
https://en.wikipedia.org/wiki/Vehicle_registration_plates_of_Poland
Note:
This script requires internet access and the following packages:
- requests
- beautifulsoup4
- lxml
"""
from __future__ import annotations
import argparse
from pathlib import Path
import re
import sys
import time
try:
from bs4 import BeautifulSoup
import requests
except ImportError:
sys.stderr.write(
"Error: Required packages not installed.\n"
"Install with: pip install requests beautifulsoup4 lxml\n"
)
sys.exit(1)
# Constants
MIN_TABLE_COLUMNS = 2 # Minimum columns needed to extract code and location
MAX_CODE_LENGTH = 4 # Maximum length for a valid license plate code
CACHE_EXPIRY_DAYS = 7 # Cache expires after 7 days
USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36" # Updated to recent version
)
def get_cache_path() -> Path:
"""Get the path to the cache file.
Returns:
Path to the cache file.
"""
script_dir = Path(__file__).parent
cache_dir = script_dir / ".wikipedia_cache"
cache_dir.mkdir(exist_ok=True)
return cache_dir / "license_plates.html"
def is_cache_valid(cache_path: Path, max_age_days: int = CACHE_EXPIRY_DAYS) -> bool:
"""Check if the cache file exists and is not expired.
Args:
cache_path: Path to the cache file.
max_age_days: Maximum age in days before cache is considered expired.
Returns:
True if cache is valid, False otherwise.
"""
if not cache_path.exists():
return False
# Check age
file_age_seconds = time.time() - cache_path.stat().st_mtime
max_age_seconds = max_age_days * 24 * 60 * 60
return file_age_seconds < max_age_seconds
def fetch_wikipedia_html(*, force_refresh: bool = False) -> str:
"""Fetch Wikipedia HTML, using cache if available.
Args:
force_refresh: If True, ignore cache and fetch fresh data.
Returns:
HTML content of the Wikipedia page.
Raises:
RuntimeError: If the page cannot be fetched.
"""
cache_path = get_cache_path()
# Check if we can use cache
if not force_refresh and is_cache_valid(cache_path):
try:
sys.stdout.write(f"Using cached data from {cache_path}\n")
cache_age_hours = int((time.time() - cache_path.stat().st_mtime) / 3600)
sys.stdout.write(f"Cache age: {cache_age_hours} hours\n")
return cache_path.read_text(encoding="utf-8")
except OSError as e:
sys.stderr.write(f"Warning: Failed to read cache: {e}\n")
sys.stderr.write("Fetching fresh data from Wikipedia...\n")
# Fall through to fetch from Wikipedia
# Fetch from Wikipedia
url = "https://en.wikipedia.org/wiki/Vehicle_registration_plates_of_Poland"
headers = {"User-Agent": USER_AGENT}
if force_refresh:
sys.stdout.write("Force refresh: Ignoring cache\n")
sys.stdout.write(f"Fetching data from {url}...\n")
try:
response = requests.get(url, headers=headers, timeout=30)
response.raise_for_status()
except requests.RequestException as e:
msg = f"Failed to fetch Wikipedia page: {e}"
raise RuntimeError(msg) from e
# Cache the response
try:
cache_path.write_text(response.text, encoding="utf-8")
sys.stdout.write(f"Cached response to {cache_path}\n")
except OSError as e:
sys.stderr.write(f"Warning: Failed to write cache: {e}\n")
# Continue anyway - the data was fetched successfully
return response.text
def parse_license_plates_from_html(html_content: str) -> dict[str, str]:
"""Parse license plate codes from Wikipedia HTML.
Args:
html_content: HTML content of the Wikipedia page.
Returns:
Dictionary mapping license plate codes to their locations.
Raises:
RuntimeError: If no valid tables are found.
"""
soup = BeautifulSoup(html_content, "html.parser")
# Find all wikitables
tables = soup.find_all("table", {"class": "wikitable"})
if not tables:
msg = "No wikitable found on the page"
raise RuntimeError(msg)
sys.stdout.write(f"Found {len(tables)} tables on the page\n")
license_plates: dict[str, str] = {}
# Process each table
for table_idx, table in enumerate(tables):
rows = table.find_all("tr")
sys.stdout.write(f"Processing table {table_idx + 1} with {len(rows)} rows...\n")
for row in rows[1:]: # Skip header row
cells = row.find_all(["td", "th"])
if len(cells) >= MIN_TABLE_COLUMNS:
# Extract code and location
code_text = cells[0].get_text(strip=True)
location_text = cells[1].get_text(strip=True)
# Clean up the code (remove spaces, keep only letters)
code = re.sub(r"[^A-Z]", "", code_text.upper())
# Skip if code is invalid
if not code or len(code) > MAX_CODE_LENGTH:
continue
# Clean up location text (remove citations, extra spaces)
location = re.sub(r"\[[0-9]+\]", "", location_text)
location = " ".join(location.split())
if location:
license_plates[code] = location
sys.stdout.write(f"Extracted {len(license_plates)} license plate codes\n")
return license_plates
def fetch_wikipedia_license_plates(*, force_refresh: bool = False) -> dict[str, str]:
"""Fetch Polish license plate codes from Wikipedia.
Args:
force_refresh: If True, ignore cache and fetch fresh data.
Returns:
Dictionary mapping license plate codes to their locations.
Raises:
RuntimeError: If the page cannot be fetched or parsed.
"""
html_content = fetch_wikipedia_html(force_refresh=force_refresh)
return parse_license_plates_from_html(html_content)
def generate_license_plate_data_file(
license_plates: dict[str, str],
output_path: Path,
) -> None:
"""Generate license_plate_data.py file with the extracted data.
Args:
license_plates: Dictionary mapping codes to locations.
output_path: Path to the output file.
"""
# Group by first letter (voivodeship)
voivodeships: dict[str, list[tuple[str, str]]] = {}
for code, location in sorted(license_plates.items()):
first_letter = code[0]
if first_letter not in voivodeships:
voivodeships[first_letter] = []
voivodeships[first_letter].append((code, location))
# Voivodeship names
voivodeship_names = {
"B": "Podlaskie",
"C": "Kujawsko-Pomorskie",
"D": "Dolnośląskie",
"E": "Łódzkie",
"F": "Lubuskie",
"G": "Pomorskie",
"K": "Małopolskie",
"L": "Lubelskie",
"N": "Warmińsko-Mazurskie",
"O": "Opolskie",
"P": "Wielkopolskie",
"R": "Podkarpackie",
"S": "Śląskie",
"T": "Świętokrzyskie",
"W": "Mazowieckie",
"Z": "Zachodniopomorskie",
}
# Generate file content
content = '''"""Database of Polish car license plate registration codes.
This module contains a comprehensive mapping of Polish vehicle registration
plate codes to their corresponding locations (cities, powiats, voivodeships).
Polish license plates use a system where:
- First letter indicates the voivodeship (province)
- Following 1-2 letters indicate the specific city or powiat (county)
The database is organized by voivodeships in alphabetical order:
- B: Podlaskie
- C: Kujawsko-Pomorskie
- D: Dolnośląskie
- E: Łódzkie
- F: Lubuskie
- G: Pomorskie
- K: Małopolskie
- L: Lubelskie
- N: Warmińsko-Mazurskie
- O: Opolskie
- P: Wielkopolskie
- R: Podkarpackie
- S: Śląskie
- T: Świętokrzyskie
- W: Mazowieckie
- Z: Zachodniopomorskie
Data source:
Wikipedia - Vehicle registration plates of Poland
https://en.wikipedia.org/wiki/Vehicle_registration_plates_of_Poland
Auto-generated by:
python -m python_pkg.anki_decks.polish_license_plates.fetch_license_plates
Examples:
WA = Warszawa (Warsaw)
KR = Kraków
GD = Gdańsk
"""
from __future__ import annotations
LICENSE_PLATE_CODES: dict[str, str] = {
'''
# Add entries grouped by voivodeship
for letter in sorted(voivodeships.keys()):
voivodeship_name = voivodeship_names.get(letter, f"Voivodeship {letter}")
codes = voivodeships[letter]
content += f" # {letter} - {voivodeship_name} ({len(codes)} codes)\n"
for code, location in codes:
# Escape quotes in location
location_escaped = location.replace('"', '\\"')
content += f' "{code}": "{location_escaped}",\n'
content += "\n"
# Remove last comma and newline, then close the dict
content = content.rstrip(",\n") + "\n}\n"
# Write to file
output_path.write_text(content, encoding="utf-8")
sys.stdout.write(f"Generated {output_path}\n")
def main() -> int:
"""Main entry point.
Returns:
Exit code.
"""
parser = argparse.ArgumentParser(
description="Fetch Polish license plate codes from Wikipedia.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--force",
"-f",
action="store_true",
help="Force refresh: ignore cache and fetch fresh data from Wikipedia",
)
args = parser.parse_args()
try:
# Fetch data from Wikipedia
license_plates = fetch_wikipedia_license_plates(force_refresh=args.force)
# Determine output path
script_dir = Path(__file__).parent
output_path = script_dir / "license_plate_data.py"
# Generate the file
generate_license_plate_data_file(license_plates, output_path)
sys.stdout.write("\n")
sys.stdout.write("=" * 70 + "\n")
sys.stdout.write("LICENSE PLATE DATA UPDATE COMPLETE\n")
sys.stdout.write("=" * 70 + "\n")
sys.stdout.write(f"Total codes: {len(license_plates)}\n")
sys.stdout.write(f"Output file: {output_path}\n")
sys.stdout.write("\n")
sys.stdout.write("Data source: Wikipedia\n")
sys.stdout.write(
"URL: https://en.wikipedia.org/wiki/"
"Vehicle_registration_plates_of_Poland\n"
)
sys.stdout.write(f"Cache location: {get_cache_path()}\n")
sys.stdout.write(f"Cache expiry: {CACHE_EXPIRY_DAYS} days\n")
sys.stdout.write("\n")
sys.stdout.write("Next steps:\n")
sys.stdout.write(" 1. Review the generated file\n")
sys.stdout.write(
" 2. Run tests: "
"pytest python_pkg/anki_decks/"
"polish_license_plates/tests/\n"
)
sys.stdout.write(
" 3. Regenerate Anki package: "
"python -m python_pkg.anki_decks."
"polish_license_plates."
"polish_license_plates_anki\n"
)
except RuntimeError as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,489 @@
"""Database of Polish car license plate registration codes.
This module contains a comprehensive mapping of Polish vehicle registration
plate codes to their corresponding locations (cities, powiats, voivodeships).
Polish license plates use a system where:
- First letter indicates the voivodeship (province)
- Following 1-2 letters indicate the specific city or powiat (county)
The database is organized by voivodeships in alphabetical order:
- B: Podlaskie
- C: Kujawsko-Pomorskie
- D: Dolnośląskie
- E: Łódzkie
- F: Lubuskie
- G: Pomorskie
- K: Małopolskie
- L: Lubelskie
- N: Warmińsko-Mazurskie
- O: Opolskie
- P: Wielkopolskie
- R: Podkarpackie
- S: Śląskie
- T: Świętokrzyskie
- W: Mazowieckie
- Z: Zachodniopomorskie
Data source:
Wikipedia - Vehicle registration plates of Poland
https://en.wikipedia.org/wiki/Vehicle_registration_plates_of_Poland
Note:
This data can be automatically updated by running:
python -m python_pkg.anki_decks.polish_license_plates.fetch_license_plates
Examples:
WA = Warszawa (Warsaw)
KR = Kraków
GD = Gdańsk
"""
from __future__ import annotations
LICENSE_PLATE_CODES: dict[str, str] = {
"DA": "Wrocław Fabryczna",
"DB": "Wałbrzych",
"DC": "Wrocław Śródmieście",
"DD": "Dzierżoniów",
"DE": "Wrocław Psie Pole",
"DF": "Wrocław Krzyki",
"DG": "Głogów",
"DH": "Wrocław Stare Miasto",
"DJ": "Jelenia Góra",
"DK": "Kłodzko",
"DL": "Legnica",
"DLB": "Lubań",
"DLE": "Legnica powiat",
"DMI": "Milicz",
"DN": "Wrocław Nowy Dwór",
"DO": "Oława",
"DP": "Polkowice",
"DR": "Wrocław Krzyki",
"DS": "Świdnica",
"DSR": "Środa Śląska",
"DSW": "Świebodzice",
"DT": "Twardogóra",
"DTR": "Trzebnica",
"DW": "Wałbrzych powiat",
"DWL": "Wołów",
"DWR": "Wrocław",
"DZ": "Zgorzelec",
"DZA": "Ząbkowice Śląskie",
"DZG": "Zgorzelec powiat",
"CB": "Bydgoszcz",
"CBR": "Brodnica",
"CC": "Chełmno",
"CD": "Świecie",
"CE": "Inowrocław",
"CG": "Grudziądz",
"CH": "Chojnice",
"CI": "Inowrocław powiat",
"CL": "Lipno",
"CMG": "Mogilno",
"CN": "Nakło nad Notecią",
"CR": "Radziejów",
"CT": "Toruń",
"CTR": "Toruń powiat",
"CTU": "Tuchola",
"CW": "Włocławek",
"CWA": "Wąbrzeźno",
"CWL": "Włocławek powiat",
"CZ": "Żnin",
"LB": "Biała Podlaska",
"LBI": "Biłgoraj",
"LC": "Chełm",
"LCH": "Chełm powiat",
"LHR": "Hrubieszów",
"LI": "Janów Lubelski",
"LKR": "Kraśnik",
"LKS": "Krasnystaw",
"LL": "Lublin",
"LLE": "Łęczna",
"LLU": "Łuków",
"LM": "Biała Podlaska powiat",
"LOP": "Opole Lubelskie",
"LPA": "Parczew",
"LPU": "Puławy",
"LRA": "Radzyń Podlaski",
"LRY": "Ryki",
"LSI": "Świdnik",
"LT": "Tomaszów Lubelski",
"LU": "Lublin powiat",
"LWL": "Włodawa",
"LZ": "Zamość",
"LZA": "Zamość powiat",
"FG": "Gorzów Wielkopolski",
"FKR": "Krosno Odrzańskie",
"FMI": "Międzyrzecz",
"FNW": "Nowa Sól",
"FSD": "Strzelce-Drezdenko",
"FSL": "Słubice",
"FSU": "Sulęcin",
"FSW": "Świebodzin",
"FWS": "Wschowa",
"FZ": "Zielona Góra",
"FZG": "Zielona Góra powiat",
"FZI": "Żagań",
"FZY": "Żary",
"EA": "Bełchatów",
"EB": "Łódź Bałuty",
"EBE": "Bełchatów powiat",
"EBR": "Brzeziny",
"EC": "Łęczyca",
"ED": "Łódź Śródmieście",
"EE": "Łódź Górna",
"EG": "Głowno",
"EK": "Kutno",
"EKU": "Kutno powiat",
"EL": "Łask",
"ELA": "Łowicz",
"ELE": "Łęczyca powiat",
"ELW": "Łowicz powiat",
"EM": "Opoczno",
"EO": "Opoczno powiat",
"EP": "Piotrków Trybunalski",
"EPA": "Pajęczno",
"EPD": "Poddębice",
"EPI": "Piotrków Trybunalski powiat",
"ER": "Rawa Mazowiecka",
"ERA": "Radomsko",
"ERW": "Rawa Mazowiecka powiat",
"ES": "Sieradz",
"ESI": "Sieradz powiat",
"ESK": "Skierniewice",
"ESR": "Skierniewice powiat",
"ET": "Tomaszów Mazowiecki",
"EW": "Wieluń",
"EWI": "Wieluń powiat",
"EZ": "Zduńska Wola",
"EZD": "Zgierz",
"KA": "Kraków Krowodrza",
"KB": "Bochnia",
"KBC": "Brzesko",
"KC": "Chrzanów",
"KCH": "Chrzanów powiat",
"KD": "Kraków Nowa Huta",
"KDA": "Dąbrowa Tarnowska",
"KE": "Kraków Śródmieście",
"KG": "Gorlice",
"KH": "Kraków Podgórze",
"KI": "Miechów",
"KK": "Kraków Śródmieście",
"KL": "Limanowa",
"KLI": "Limanowa powiat",
"KM": "Myślenice",
"KN": "Nowy Sącz",
"KNS": "Nowy Sącz powiat",
"KNT": "Nowy Targ",
"KO": "Olkusz",
"KOL": "Olkusz powiat",
"KOS": "Oświęcim",
"KP": "Proszowice",
"KR": "Kraków",
"KRA": "Kraków powiat",
"KS": "Sucha Beskidzka",
"KT": "Tarnów",
"KTA": "Tarnów powiat",
"KTT": "Tatry",
"KW": "Wadowice",
"KWA": "Wadowice powiat",
"WA": "Warszawa",
"WB": "Warszawa Bemowo",
"WBR": "Białobrzegi",
"WC": "Ciechanów",
"WCI": "Ciechanów powiat",
"WD": "Warszawa Praga Południe",
"WE": "Warszawa Praga Północ",
"WF": "Garwolin",
"WG": "Grodzisk Mazowiecki",
"WGM": "Grójec",
"WGO": "Gostynin",
"WGR": "Garwolin powiat",
"WH": "Warszawa Mokotów",
"WI": "Pruszków",
"WJ": "Józefów",
"WK": "Kozienice",
"WL": "Legionowo",
"WLI": "Lipsko",
"WLS": "Łosice",
"WM": "Mińsk Mazowiecki",
"WMA": "Maków Mazowiecki",
"WML": "Mława",
"WN": "Warszawa Białołęka",
"WND": "Nowy Dwór Mazowiecki",
"WO": "Otwock",
"WOR": "Ostrołęka",
"WOS": "Ostrów Mazowiecka",
"WOT": "Otwock powiat",
"WP": "Piaseczno",
"WPI": "Płońsk",
"WPL": "Płock",
"WPN": "Przasnysz",
"WPR": "Przysucha",
"WPU": "Pułtusk",
"WPY": "Płońsk powiat",
"WPZ": "Przasnysz powiat",
"WR": "Radom",
"WRA": "Radom powiat",
"WS": "Siedlce",
"WSC": "Sokołów Podlaski",
"WSE": "Siedlce powiat",
"WSI": "Sierpc",
"WSK": "Sochaczew",
"WSZ": "Szydłowiec",
"WT": "Warszawa Wawer",
"WU": "Warszawa Ursus",
"WV": "Ostrołęka powiat",
"WW": "Warszawa Ochota",
"WWL": "Wołomin",
"WWY": "Wyszków",
"WX": "Warszawa Ursynów",
"WY": "Warszawa Wola",
"WZ": "Żyrardów",
"WZW": "Zwoleń",
"OA": "Brzeg",
"OB": "Namysłów",
"OGL": "Głubczyce",
"OK": "Kędzierzyn-Koźle",
"OKL": "Kluczbork",
"OKR": "Krapkowice",
"OL": "Nysa",
"ONA": "Namysłów powiat",
"ONY": "Nysa powiat",
"OP": "Opole",
"OO": "Opole powiat",
"OOL": "Olesno",
"OPO": "Prudnik",
"OST": "Strzelce Opolskie",
"RB": "Brzozów",
"RBI": "Biłgoraj",
"RC": "Rzeszów Centrum",
"RD": "Dębica",
"RDE": "Dębica powiat",
"RJ": "Jarosław",
"RJA": "Jarosław powiat",
"RJS": "Jasło",
"RK": "Krosno",
"RKL": "Kolbuszowa",
"RKR": "Krosno powiat",
"RL": "Leżajsk",
"RLE": "Lesko",
"RLS": "Lubaczów",
"RLU": "Łańcut",
"RM": "Mielec",
"RMI": "Mielec powiat",
"RN": "Nisko",
"RP": "Przemyśl",
"RPR": "Przemyśl powiat",
"RPZ": "Przeworsk",
"RR": "Rzeszów",
"RRS": "Ropczyce-Sędziszów",
"RRZ": "Rzeszów powiat",
"RSA": "Sanok",
"RSN": "Sanok powiat",
"RSR": "Stalowa Wola",
"RST": "Strzyżów",
"RTA": "Tarnobrzeg",
"RZ": "Rzeszów",
"BA": "Augustów",
"BBI": "Białystok",
"BC": "Hajnówka",
"BD": "Bielsk Podlaski",
"BE": "Wysokie Mazowieckie",
"BG": "Grajewo",
"BGR": "Grajewo powiat",
"BH": "Hajnówka powiat",
"BHA": "Hajnówka",
"BI": "Białystok",
"BIA": "Białystok powiat",
"BJ": "Kolno",
"BK": "Kolno powiat",
"BKL": "Kolno",
"BL": "Łomża",
"BLM": "Łomża powiat",
"BLS": "Łomża",
"BM": "Mońki",
"BMN": "Mońki powiat",
"BO": "Sokółka",
"BP": "Zambrów",
"BPI": "Piątnica",
"BR": "Siemiatycze",
"BS": "Sokółka powiat",
"BSE": "Sejny",
"BSI": "Siemiatycze powiat",
"BSK": "Sokółka",
"BSU": "Suwałki",
"BT": "Suwałki powiat",
"BWM": "Wysokie Mazowieckie powiat",
"BZA": "Zambrów powiat",
"GA": "Gdańsk",
"GB": "Bytów",
"GBY": "Bytów powiat",
"GC": "Chojnice",
"GCH": "Chojnice powiat",
"GCZ": "Człuchów",
"GD": "Gdańsk",
"GDA": "Gdańsk powiat",
"GDY": "Gdynia",
"GI": "Kościerzyna",
"GKA": "Kartuzy",
"GKS": "Kościerzyna powiat",
"GKW": "Kwidzyn",
"GL": "Lębork",
"GLE": "Lębork powiat",
"GMB": "Malbork",
"GND": "Nowy Dwór Gdański",
"GP": "Puck",
"GPU": "Puck powiat",
"GS": "Słupsk",
"GSL": "Słupsk powiat",
"GSP": "Starogard Gdański",
"GST": "Sztum",
"GT": "Tczew",
"GTB": "Tczew powiat",
"GW": "Wejherowo",
"GWE": "Wejherowo powiat",
"SA": "Sosnowiec",
"SB": "Bielsko-Biała",
"SBB": "Bielsko-Biała powiat",
"SBE": "Będzin",
"SBI": "Bieruń-Lędziny",
"SC": "Chorzów",
"SCH": "Cieszyn",
"SCI": "Cieszyn powiat",
"SD": "Dąbrowa Górnicza",
"SF": "Racibórz",
"SG": "Gliwice",
"SGI": "Gliwice powiat",
"SH": "Chorzów",
"SI": "Siemianowice Śląskie",
"SJ": "Jastrzębie-Zdrój",
"SJZ": "Jastrzębie-Zdrój",
"SK": "Katowice",
"SKA": "Katowice powiat",
"SKL": "Kłobuck",
"SKT": "Lubliniec",
"SL": "Rybnik",
"SLU": "Lubliniec powiat",
"SM": "Mysłowice",
"SMI": "Mikołów",
"SML": "Myszków",
"SN": "Nowy Targ",
"SO": "Sosnowiec powiat",
"SP": "Piekary Śląskie",
"SPI": "Pszczyna",
"SPS": "Pszczyna powiat",
"SR": "Rybnik powiat",
"SRC": "Racibórz powiat",
"SRY": "Rybnik",
"SS": "Świętochłowice",
"ST": "Tychy",
"STA": "Tarnowskie Góry",
"STG": "Tarnowskie Góry powiat",
"SW": "Wodzisław Śląski",
"SWD": "Wodzisław Śląski powiat",
"SY": "Ruda Śląska",
"SZ": "Zabrze",
"SZA": "Zawiercie",
"SZO": "Żory",
"SZY": "Żywiec",
"TB": "Busko-Zdrój",
"TBU": "Busko-Zdrój powiat",
"TJE": "Jędrzejów",
"TK": "Kielce",
"TKA": "Kazimierza Wielka",
"TKI": "Kielce powiat",
"TKN": "Końskie",
"TKO": "Końskie powiat",
"TOS": "Ostrołęka",
"TPI": "Pińczów",
"TSA": "Sandomierz",
"TSK": "Skarżysko-Kamienna",
"TST": "Starachowice",
"TWL": "Włoszczowa",
"NBA": "Bartoszyce",
"NBR": "Braniewo",
"NDZ": "Działdowo",
"NE": "Elbląg",
"NEL": "Elbląg powiat",
"NEB": "Ełk",
"NEK": "Ełk powiat",
"NG": "Giżycko",
"NGI": "Giżycko powiat",
"NGO": "Gołdap",
"NI": "Iława",
"NKE": "Kętrzyn",
"NL": "Lidzbark Warmiński",
"NMR": "Mrągowo",
"NNI": "Nidzica",
"NO": "Olsztyn",
"NOE": "Olecko",
"NOL": "Olsztyn powiat",
"NOS": "Ostróda",
"NPI": "Pisz",
"NSZ": "Szczytno",
"NW": "Węgorzewo",
"PCD": "Czarnków-Trzcianka",
"PCH": "Chodzież",
"PGN": "Gniezno",
"PGO": "Gostyń",
"PGR": "Grodzisk Wielkopolski",
"PIA": "Piła",
"PJ": "Jarocin",
"PJA": "Jarocin powiat",
"PK": "Kępno",
"PKA": "Kalisz",
"PKL": "Kalisz powiat",
"PKN": "Koło",
"PKO": "Konin",
"PKS": "Kościan",
"PL": "Leszno",
"PLE": "Leszno powiat",
"PMI": "Międzychód",
"PNT": "Nowy Tomyśl",
"PO": "Poznań",
"POB": "Oborniki",
"POL": "Ostrów Wielkopolski",
"POP": "Opole",
"POS": "Ostrzeszów",
"POT": "Ostrów Wielkopolski powiat",
"PP": "Pleszew",
"PPI": "Piła powiat",
"PPL": "Pleszew powiat",
"PRA": "Poznań powiat",
"PRS": "Rawicz",
"PSE": "Śrem",
"PSL": "Słupca",
"PSR": "Środa Wielkopolska",
"PSZ": "Szamotuły",
"PT": "Turek",
"PTU": "Turek powiat",
"PW": "Wągrowiec",
"PWA": "Wągrowiec powiat",
"PWL": "Wolsztyn",
"PWR": "Września",
"PZ": "Poznań",
"PZL": "Złotów",
"ZBI": "Białogard",
"ZCH": "Choszczno",
"ZG": "Gryfice",
"ZGR": "Gryfino",
"ZI": "Stargard",
"ZK": "Kołobrzeg",
"ZKA": "Kamień Pomorski",
"ZKL": "Kołobrzeg powiat",
"ZKO": "Koszalin",
"ZKS": "Koszalin powiat",
"ZL": "Łobez",
"ZM": "Myślibórz",
"ZPL": "Pyrzyce",
"ZPO": "Police",
"ZS": "Szczecin",
"ZSL": "Sławno",
"ZST": "Stargard powiat",
"ZSW": "Świnoujście",
"ZSZ": "Szczecinek",
"ZW": "Wałcz",
"ZZ": "Szczecin powiat",
}

View File

@ -0,0 +1,244 @@
"""Anki flashcard generator for Polish car license plates.
Generates Anki-compatible flashcard decks with bidirectional cards for Polish
vehicle registration plate codes and their corresponding locations.
Creates two types of cards:
1. Code Location (e.g., WY Warszawa Wola)
2. Location Code (e.g., Warszawa Wola WY)
Usage:
# Generate Anki cards for all Polish license plates
python -m python_pkg.anki_decks.polish_license_plates.polish_license_plates_anki
# Specify custom output file
python -m python_pkg.anki_decks.polish_license_plates.polish_license_plates_anki \
--output plates.apkg
Output:
Creates a self-contained .apkg file that can be directly imported into Anki.
"""
from __future__ import annotations
import argparse
import hashlib
from pathlib import Path
import random
import sys
from typing import TYPE_CHECKING
import genanki
from python_pkg.anki_decks.polish_license_plates.license_plate_data import (
LICENSE_PLATE_CODES,
)
if TYPE_CHECKING:
from collections.abc import Sequence
def generate_anki_package(
deck_name: str = "Polish License Plates",
) -> genanki.Package:
"""Generate Anki package (.apkg) for Polish license plates.
Creates two cards for each license plate code:
1. Code Location
2. Location Code
Args:
deck_name: Name for the Anki deck.
Returns:
genanki.Package object ready to be written to file.
"""
# Create unique model ID based on deck name
model_id_hash = hashlib.md5(
f"polish_license_plates_{deck_name}".encode(),
usedforsecurity=False,
)
model_id = int(model_id_hash.hexdigest()[:8], 16)
# Define the note model with centered styling and bidirectional templates
card_css = """
.card {
font-family: Arial, sans-serif;
font-size: 28px;
text-align: center;
color: #333;
background-color: #fff;
}
.card.night_mode {
color: #eee;
background-color: #2f2f2f;
}
.question {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
font-size: 48px;
font-weight: bold;
color: #2C3E50;
}
.card.night_mode .question {
color: #ECF0F1;
}
.answer {
font-size: 36px;
font-weight: bold;
margin-top: 20px;
color: #27AE60;
}
.card.night_mode .answer {
color: #2ECC71;
}
.plate-code {
font-family: 'Courier New', monospace;
background-color: #FFD700;
color: #000;
padding: 15px 30px;
border: 3px solid #000;
border-radius: 8px;
display: inline-block;
letter-spacing: 5px;
}
.card.night_mode .plate-code {
background-color: #FFA500;
}
"""
my_model = genanki.Model(
model_id,
"Polish License Plate Model",
fields=[
{"name": "Code"},
{"name": "Location"},
],
templates=[
{
"name": "Code → Location",
"qfmt": '<div class="question">'
'<span class="plate-code">{{Code}}</span>'
"</div>",
"afmt": '<div class="question">'
'<span class="plate-code">{{Code}}</span>'
"</div>"
'<hr id="answer">'
'<div class="answer">{{Location}}</div>',
},
{
"name": "Location → Code",
"qfmt": '<div class="question">{{Location}}</div>',
"afmt": '<div class="question">{{Location}}</div>'
'<hr id="answer">'
'<div class="answer">'
'<span class="plate-code">{{Code}}</span>'
"</div>",
},
],
css=card_css,
)
# Create unique deck ID
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
# Create the deck
my_deck = genanki.Deck(deck_id, deck_name)
# Generate notes for each license plate code
for code, location in sorted(LICENSE_PLATE_CODES.items()):
note = genanki.Note(
model=my_model,
fields=[code, location],
tags=["geography", "poland", "license-plates", "transportation"],
)
my_deck.add_note(note)
# Create package
return genanki.Package(my_deck)
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 Polish license plates.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path (default: polish_license_plates.apkg)",
)
parser.add_argument(
"--deck-name",
"-d",
type=str,
default="Polish License Plates",
help="Name for the Anki deck (default: 'Polish License Plates')",
)
args = parser.parse_args(argv)
# Determine output path
output_path = (
Path(args.output) if args.output else Path("polish_license_plates.apkg")
)
try:
num_codes = len(LICENSE_PLATE_CODES)
num_cards = num_codes * 2 # Two cards per code (bidirectional)
sys.stdout.write(
f"Generating flashcards for {num_codes} Polish license plate codes...\n"
)
sys.stdout.write(
"Each code will have 2 cards: Code → Location and Location → Code\n"
)
# Generate the package
package = generate_anki_package(args.deck_name)
# Write to file
package.write_to_file(str(output_path))
sys.stdout.write("\n")
sys.stdout.write("=" * 70 + "\n")
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
sys.stdout.write("=" * 70 + "\n")
sys.stdout.write(f"License plate codes: {num_codes}\n")
sys.stdout.write(f"Total flashcards: {num_cards} (bidirectional)\n")
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
sys.stdout.write("\n")
sys.stdout.write("Card types:\n")
sys.stdout.write(" 1. Code → Location (e.g., WY → Warszawa Wola)\n")
sys.stdout.write(" 2. Location → Code (e.g., Warszawa Wola → WY)\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("You can now learn Polish license plates both ways!\n")
except (OSError, ValueError) as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1 @@
"""Tests init file."""

View File

@ -0,0 +1,231 @@
"""Tests for the Polish license plates Anki generator."""
from __future__ import annotations
from pathlib import Path
import pytest
try:
from python_pkg.anki_decks.polish_license_plates.license_plate_data import (
LICENSE_PLATE_CODES,
)
from python_pkg.anki_decks.polish_license_plates.polish_license_plates_anki import (
generate_anki_package,
main,
)
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from python_pkg.anki_decks.polish_license_plates.license_plate_data import (
LICENSE_PLATE_CODES,
)
from python_pkg.anki_decks.polish_license_plates.polish_license_plates_anki import (
generate_anki_package,
main,
)
class TestLicensePlateData:
"""Tests for license plate data."""
def test_has_codes(self) -> None:
"""Test that we have license plate codes."""
assert len(LICENSE_PLATE_CODES) > 0
def test_all_codes_are_uppercase(self) -> None:
"""Test that all codes are uppercase strings."""
for code in LICENSE_PLATE_CODES:
assert isinstance(code, str)
assert code.isupper()
assert len(code) >= 2
def test_all_locations_are_strings(self) -> None:
"""Test that all locations are non-empty strings."""
for location in LICENSE_PLATE_CODES.values():
assert isinstance(location, str)
assert len(location) > 0
def test_no_duplicate_codes(self) -> None:
"""Test that all codes are unique."""
codes = list(LICENSE_PLATE_CODES.keys())
assert len(codes) == len(set(codes))
def test_warsaw_codes_present(self) -> None:
"""Test that Warsaw codes are in the database."""
warsaw_codes = [
"WA",
"WB",
"WC",
"WD",
"WE",
"WF",
"WG",
"WH",
"WI",
"WJ",
"WK",
"WL",
"WM",
"WN",
"WO",
"WP",
"WR",
"WS",
"WT",
"WU",
"WW",
"WX",
"WY",
"WZ",
]
for code in warsaw_codes:
assert code in LICENSE_PLATE_CODES
def test_major_cities_present(self) -> None:
"""Test that major Polish cities have codes."""
major_cities = {
"WA": "Warszawa",
"KR": "Kraków",
"GD": "Gdańsk",
"PO": "Poznań",
"WR": "Radom",
"BI": "Białystok",
}
for code, city_part in major_cities.items():
assert code in LICENSE_PLATE_CODES
assert city_part.lower() in LICENSE_PLATE_CODES[code].lower()
def test_voivodeship_prefixes_present(self) -> None:
"""Test that all 16 voivodeship prefixes are represented."""
voivodeship_prefixes = {
"B",
"C",
"D",
"E",
"F",
"G",
"K",
"L",
"N",
"O",
"P",
"R",
"S",
"T",
"W",
"Z",
}
found_prefixes = {code[0] for code in LICENSE_PLATE_CODES}
assert voivodeship_prefixes.issubset(found_prefixes)
class TestGenerateAnkiPackage:
"""Tests for generating Anki package."""
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
def test_generates_notes_for_all_codes(self) -> None:
"""Test that package contains notes for all license plate codes."""
package = generate_anki_package()
deck = package.decks[0]
# Each code generates one note with two card templates
assert len(deck.notes) == len(LICENSE_PLATE_CODES)
def test_custom_deck_name(self) -> None:
"""Test that custom deck name is used."""
package = generate_anki_package("Custom Deck")
deck = package.decks[0]
assert deck.name == "Custom Deck"
def test_notes_have_correct_fields(self) -> None:
"""Test that notes have Code and Location fields."""
package = generate_anki_package()
deck = package.decks[0]
note = deck.notes[0]
# Note should have 2 fields: Code and Location
assert len(note.fields) == 2
# Fields should be non-empty strings
assert len(note.fields[0]) > 0
assert len(note.fields[1]) > 0
def test_notes_have_tags(self) -> None:
"""Test that notes have appropriate tags."""
package = generate_anki_package()
deck = package.decks[0]
note = deck.notes[0]
assert "geography" in note.tags
assert "poland" in note.tags
assert "license-plates" in note.tags
def test_model_has_bidirectional_templates(self) -> None:
"""Test that the model has two card templates (bidirectional)."""
package = generate_anki_package()
deck = package.decks[0]
model = deck.notes[0].model
# Should have 2 templates: Code → Location and Location → Code
assert len(model.templates) == 2
class TestMain:
"""Tests for the main CLI function."""
def test_creates_output_file(self, tmp_path: Path) -> None:
"""Test that main creates the output file."""
output_file = tmp_path / "test_output.apkg"
result = main(
[
"--output",
str(output_file),
]
)
assert result == 0
assert output_file.exists()
def test_custom_deck_name(self, tmp_path: Path) -> None:
"""Test that custom deck name is used."""
output_file = tmp_path / "test_output.apkg"
result = main(
[
"--output",
str(output_file),
"--deck-name",
"Custom Deck",
]
)
assert result == 0
assert output_file.exists()
def test_default_output_path(self) -> None:
"""Test that default output path is used when not specified."""
# Clean up any existing file
default_path = Path("polish_license_plates.apkg")
if default_path.exists():
default_path.unlink()
result = main([])
assert result == 0
assert default_path.exists()
# Clean up
default_path.unlink()
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"])

View File

@ -0,0 +1 @@
"""Polish mountain peaks Anki deck generator."""

View File

@ -0,0 +1,378 @@
"""Anki flashcard generator for Polish mountain peaks.
Generates Anki-compatible flashcard decks with ZOOMED maps showing mountain peaks
highlighted on a regional map for better visibility.
"""
from __future__ import annotations
import argparse
import hashlib
from io import BytesIO
import multiprocessing as mp
from pathlib import Path
import random
import sys
import tempfile
from typing import TYPE_CHECKING
import genanki
import geopandas as gpd
import matplotlib as mpl
mpl.use("Agg") # Non-interactive backend for multiprocessing
import matplotlib.pyplot as plt
sys.path.insert(0, str(Path(__file__).parent.parent))
from geo_data import get_poland_boundary, get_polish_mountain_peaks
if TYPE_CHECKING:
from collections.abc import Sequence
from matplotlib.figure import Figure
MARKER_COLOR = "#E74C3C" # Red marker for peaks
ZOOM_PADDING_DEG = 0.5 # Degrees of padding around peak for zoomed view
def create_peak_map(
peak_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
*,
zoom: bool = True,
) -> Figure:
"""Create a map showing Poland with one peak highlighted.
Args:
peak_gdf: GeoDataFrame with the peak point.
poland_boundary: GeoDataFrame with Poland boundary.
zoom: If True, zoom to peak area; if False, show entire Poland.
"""
fig, ax = plt.subplots(figsize=(10, 12))
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_alpha(0)
ax.patch.set_alpha(0)
# Plot Poland as a plain gray shape
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
# Plot the peak as a marker
peak_gdf.plot(
ax=ax,
color=MARKER_COLOR,
markersize=400 if zoom else 200,
marker="^", # Triangle for mountain
edgecolor="#1A1A1A",
linewidth=2,
zorder=5,
)
if zoom:
# Zoom to peak area with padding
geom = peak_gdf.iloc[0].geometry
peak_x, peak_y = geom.x, geom.y
ax.set_xlim(peak_x - ZOOM_PADDING_DEG, peak_x + ZOOM_PADDING_DEG)
ax.set_ylim(peak_y - ZOOM_PADDING_DEG, peak_y + ZOOM_PADDING_DEG)
else:
# Set bounds to Poland
bounds = poland_boundary.total_bounds
ax.set_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3])
return fig
def generate_peak_image_bytes(
peak_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
*,
zoom: bool = True,
) -> bytes:
"""Generate a peak map image as bytes."""
fig = create_peak_map(peak_gdf, poland_boundary, zoom=zoom)
buf = BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.read()
# Global variables for multiprocessing (set via initializer)
_mp_poland_boundary: gpd.GeoDataFrame | None = None
_mp_zoom: bool = True
def _init_worker(poland_geojson: str, zoom_mode: str) -> None:
"""Initialize worker process with shared data."""
global _mp_poland_boundary, _mp_zoom # noqa: PLW0603
_mp_poland_boundary = gpd.read_file(poland_geojson)
_mp_zoom = zoom_mode == "zoom"
def _render_single_peak(args: tuple[str, str]) -> tuple[str, bytes]:
"""Render a single peak image (worker function).
Args:
args: Tuple of (peak_name, peak_geojson_str).
Returns:
Tuple of (peak_name, image_bytes).
"""
peak_name, peak_geojson = args
peak_gdf = gpd.read_file(peak_geojson)
assert _mp_poland_boundary is not None # noqa: S101
image_data = generate_peak_image_bytes(peak_gdf, _mp_poland_boundary, zoom=_mp_zoom)
return peak_name, image_data
def generate_anki_package(
peaks: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
deck_name: str = "Polish Mountain Peaks",
*,
zoom: bool = True,
) -> genanki.Package:
"""Generate Anki package for Polish mountain peaks."""
model_id_hash = hashlib.md5( # noqa: S324
f"polish_mountain_peaks_{deck_name}".encode()
)
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """
.card {
font-family: Arial, sans-serif;
font-size: 24px;
text-align: center;
color: #333;
background-color: #fff;
}
.card.night_mode {
color: #eee;
background-color: #2f2f2f;
}
.map-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.map-container img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
}
.answer-text {
font-size: 32px;
font-weight: bold;
margin-top: 20px;
color: #2C3E50;
}
.card.night_mode .answer-text {
color: #ECF0F1;
}
.info-text {
font-size: 18px;
color: #7F8C8D;
margin-top: 10px;
}
.card.night_mode .info-text {
color: #BDC3C7;
}
"""
my_model = genanki.Model(
model_id,
"Polish Mountain Peak Model",
fields=[
{"name": "PeakMap"},
{"name": "PeakName"},
{"name": "Elevation"},
],
templates=[
{
"name": "Card 1",
"qfmt": '<div class="map-container">{{PeakMap}}</div>',
"afmt": '<div class="map-container">{{PeakMap}}</div>'
'<hr id="answer">'
'<div class="answer-text">{{PeakName}}</div>'
'<div class="info-text">{{Elevation}} m n.p.m.</div>',
},
],
css=card_css,
)
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
my_deck = genanki.Deck(deck_id, deck_name)
media_files = []
# Prepare data for parallel processing
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
poland_boundary.to_file(f.name, driver="GeoJSON")
poland_geojson_path = f.name
# Prepare work items: (peak_name, peak_geojson_str)
work_items: list[tuple[str, str]] = []
for _, row in peaks.iterrows():
peak_gdf = gpd.GeoDataFrame([row], crs=peaks.crs)
peak_geojson = peak_gdf.to_json()
work_items.append((row["name"], peak_geojson))
# Use multiprocessing for parallel rendering
num_workers = min(mp.cpu_count(), 8)
sys.stdout.write(
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
)
results: dict[str, bytes] = {}
with mp.Pool(
num_workers,
initializer=_init_worker,
initargs=(poland_geojson_path, "zoom" if zoom else "no-zoom"),
) as pool:
for i, (peak_name, image_data) in enumerate(
pool.imap_unordered(_render_single_peak, work_items)
):
results[peak_name] = image_data
if (i + 1) % 50 == 0:
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
# Clean up temp file
Path(poland_geojson_path).unlink(missing_ok=True)
# Create notes from results
for _, row in peaks.iterrows():
peak_name = row["name"]
elevation = int(row["elevation"])
image_data = results[peak_name]
filename = f"peak_{peak_name.replace(' ', '_').replace('/', '_')}.png"
note = genanki.Note(
model=my_model,
fields=[f'<img src="{filename}">', peak_name, str(elevation)],
tags=["geography", "poland", "mountains", "peaks"],
)
my_deck.add_note(note)
temp_path = Path(f"/tmp/{filename}") # noqa: S108
temp_path.write_bytes(image_data)
media_files.append(str(temp_path))
package = genanki.Package(my_deck)
package.media_files = media_files
return package
def main(argv: Sequence[str] | None = None) -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate Anki flashcards for Polish mountain peaks.",
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path (default: polish_mountain_peaks.apkg)",
)
parser.add_argument(
"--deck-name",
"-d",
type=str,
default="Polish Mountain Peaks",
help="Name for the Anki deck",
)
parser.add_argument(
"--preview",
"-p",
type=str,
default=None,
help="Export preview images to specified directory",
)
parser.add_argument(
"--preview-count",
type=int,
default=5,
help="Number of preview images to export (default: 5)",
)
parser.add_argument(
"--no-zoom",
action="store_true",
help="Disable zoom (show entire Poland instead of zoomed region)",
)
parser.add_argument(
"--limit",
"-l",
type=int,
default=None,
help="Limit number of peaks (for testing)",
)
args = parser.parse_args(argv)
output_path = (
Path(args.output) if args.output else Path("polish_mountain_peaks.apkg")
)
zoom = not args.no_zoom
try:
sys.stdout.write("Loading mountain peaks data...\n")
peaks = get_polish_mountain_peaks()
poland_boundary = get_poland_boundary()
if args.limit:
peaks = peaks.head(args.limit)
sys.stdout.write(f"Limiting to {args.limit} peaks.\n")
num_peaks = len(peaks)
sys.stdout.write(f"Found {num_peaks} mountain peaks.\n")
sys.stdout.write(f"Zoom mode: {'enabled' if zoom else 'disabled'}\n")
sys.stdout.write("Generating flashcards...\n")
package = generate_anki_package(
peaks, poland_boundary, args.deck_name, zoom=zoom
)
package.write_to_file(str(output_path))
# Export preview images if requested
if args.preview:
preview_dir = Path(args.preview)
preview_dir.mkdir(parents=True, exist_ok=True)
preview_peaks = list(peaks.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_peaks)} preview images "
f"to {preview_dir}...\n"
)
for _, row in preview_peaks:
peak_name = row["name"]
peak_gdf = gpd.GeoDataFrame([row], crs=peaks.crs)
image_data = generate_peak_image_bytes(
peak_gdf, poland_boundary, zoom=zoom
)
safe_name = peak_name.replace(" ", "_").replace("/", "_")
preview_path = preview_dir / f"{safe_name}.png"
preview_path.write_bytes(image_data)
sys.stdout.write(f" Saved: {preview_path.name}\n")
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"Mountain peaks: {num_peaks}\n")
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
if args.preview:
sys.stdout.write(f"Preview images: {args.preview}\n")
except (OSError, ValueError, RuntimeError) as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,6 @@
#!/bin/bash
# Run the Polish mountain peaks Anki generator
cd "$(dirname "$0")" || exit
python polish_mountain_peaks_anki.py --preview preview_images --preview-count 5 "$@"

View File

@ -0,0 +1 @@
"""Polish mountain ranges Anki generator."""

View File

@ -0,0 +1,332 @@
"""Anki flashcard generator for Polish mountain ranges.
Generates Anki-compatible flashcard decks with maps showing mountain ranges
highlighted on a Poland map.
"""
from __future__ import annotations
import argparse
import hashlib
from io import BytesIO
import multiprocessing as mp
from pathlib import Path
import random
import sys
import tempfile
from typing import TYPE_CHECKING
import genanki
import geopandas as gpd
import matplotlib as mpl
mpl.use("Agg") # Non-interactive backend for multiprocessing
import matplotlib.pyplot as plt
sys.path.insert(0, str(Path(__file__).parent.parent))
from geo_data import get_poland_boundary, get_polish_mountain_ranges
if TYPE_CHECKING:
from collections.abc import Sequence
from matplotlib.figure import Figure
RANGE_COLOR = "#7B5A31" # Brown for mountain ranges
def create_range_map(
range_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
) -> Figure:
"""Create a map showing Poland with one mountain range highlighted."""
fig, ax = plt.subplots(figsize=(10, 12))
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_alpha(0)
ax.patch.set_alpha(0)
# Plot Poland as a plain gray shape
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
# Clip mountain range to Poland boundary
clipped_gdf = range_gdf.copy()
clipped_gdf["geometry"] = range_gdf.geometry.intersection(
poland_boundary.union_all()
)
# Plot the mountain range (clipped to Poland)
clipped_gdf.plot(ax=ax, color=RANGE_COLOR, alpha=0.9)
clipped_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=1.5)
# Set bounds to Poland
bounds = poland_boundary.total_bounds
ax.set_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3])
return fig
def generate_range_image_bytes(
range_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
) -> bytes:
"""Generate a range map image as bytes."""
fig = create_range_map(range_gdf, poland_boundary)
buf = BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.read()
# Global variables for multiprocessing (set via initializer)
_mp_poland_boundary: gpd.GeoDataFrame | None = None
def _init_worker(poland_geojson: str) -> None:
"""Initialize worker process with shared data."""
global _mp_poland_boundary # noqa: PLW0603
_mp_poland_boundary = gpd.read_file(poland_geojson)
def _render_single_range(args: tuple[str, str]) -> tuple[str, bytes]:
"""Render a single range image (worker function).
Args:
args: Tuple of (range_name, range_geojson_str).
Returns:
Tuple of (range_name, image_bytes).
"""
range_name, range_geojson = args
range_gdf = gpd.read_file(range_geojson)
assert _mp_poland_boundary is not None # noqa: S101
image_data = generate_range_image_bytes(range_gdf, _mp_poland_boundary)
return range_name, image_data
def generate_anki_package(
ranges: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
deck_name: str = "Polish Mountain Ranges",
) -> genanki.Package:
"""Generate Anki package for Polish mountain ranges."""
model_id_hash = hashlib.md5( # noqa: S324
f"polish_mountain_ranges_{deck_name}".encode()
)
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """
.card {
font-family: Arial, sans-serif;
font-size: 24px;
text-align: center;
color: #333;
background-color: #fff;
}
.card.night_mode {
color: #eee;
background-color: #2f2f2f;
}
.map-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.map-container img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
}
.answer-text {
font-size: 32px;
font-weight: bold;
margin-top: 20px;
color: #2C3E50;
}
.card.night_mode .answer-text {
color: #ECF0F1;
}
.info-text {
font-size: 18px;
color: #7F8C8D;
margin-top: 10px;
}
.card.night_mode .info-text {
color: #BDC3C7;
}
"""
my_model = genanki.Model(
model_id,
"Polish Mountain Range Model",
fields=[
{"name": "RangeMap"},
{"name": "RangeName"},
{"name": "Area"},
],
templates=[
{
"name": "Card 1",
"qfmt": '<div class="map-container">{{RangeMap}}</div>',
"afmt": '<div class="map-container">{{RangeMap}}</div>'
'<hr id="answer">'
'<div class="answer-text">{{RangeName}}</div>'
'<div class="info-text">{{Area}} km²</div>',
},
],
css=card_css,
)
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
my_deck = genanki.Deck(deck_id, deck_name)
media_files = []
# Prepare data for parallel processing
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
poland_boundary.to_file(f.name, driver="GeoJSON")
poland_geojson_path = f.name
# Prepare work items: (range_name, range_geojson_str)
work_items: list[tuple[str, str]] = []
for _, row in ranges.iterrows():
range_gdf = gpd.GeoDataFrame([row], crs=ranges.crs)
range_geojson = range_gdf.to_json()
work_items.append((row["name"], range_geojson))
# Use multiprocessing for parallel rendering
num_workers = min(mp.cpu_count(), 8)
sys.stdout.write(
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
)
results: dict[str, bytes] = {}
with mp.Pool(
num_workers,
initializer=_init_worker,
initargs=(poland_geojson_path,),
) as pool:
for i, (range_name, image_data) in enumerate(
pool.imap_unordered(_render_single_range, work_items)
):
results[range_name] = image_data
if (i + 1) % 10 == 0:
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
# Clean up temp file
Path(poland_geojson_path).unlink(missing_ok=True)
# Create notes from results
for _, row in ranges.iterrows():
range_name = row["name"]
area_km2 = round(row["area_km2"], 1) if "area_km2" in row else 0
image_data = results[range_name]
filename = f"range_{range_name.replace(' ', '_').replace('/', '_')}.png"
note = genanki.Note(
model=my_model,
fields=[f'<img src="{filename}">', range_name, str(area_km2)],
tags=["geography", "poland", "mountain-ranges", "mountains"],
)
my_deck.add_note(note)
temp_path = Path(f"/tmp/{filename}") # noqa: S108
temp_path.write_bytes(image_data)
media_files.append(str(temp_path))
package = genanki.Package(my_deck)
package.media_files = media_files
return package
def main(argv: Sequence[str] | None = None) -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate Anki flashcards for Polish mountain ranges.",
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path (default: polish_mountain_ranges.apkg)",
)
parser.add_argument(
"--deck-name",
"-d",
type=str,
default="Polish Mountain Ranges",
help="Name for the Anki deck",
)
parser.add_argument(
"--preview",
"-p",
type=str,
default=None,
help="Export preview images to specified directory",
)
parser.add_argument(
"--preview-count",
type=int,
default=5,
help="Number of preview images to export (default: 5)",
)
args = parser.parse_args(argv)
output_path = (
Path(args.output) if args.output else Path("polish_mountain_ranges.apkg")
)
try:
sys.stdout.write("Loading mountain ranges data...\n")
ranges = get_polish_mountain_ranges()
poland_boundary = get_poland_boundary()
num_ranges = len(ranges)
sys.stdout.write(f"Found {num_ranges} mountain ranges.\n")
sys.stdout.write("Generating flashcards...\n")
package = generate_anki_package(ranges, poland_boundary, args.deck_name)
package.write_to_file(str(output_path))
# Export preview images if requested
if args.preview:
preview_dir = Path(args.preview)
preview_dir.mkdir(parents=True, exist_ok=True)
preview_ranges = list(ranges.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_ranges)} preview images "
f"to {preview_dir}...\n"
)
for _, row in preview_ranges:
range_name = row["name"]
range_gdf = gpd.GeoDataFrame([row], crs=ranges.crs)
image_data = generate_range_image_bytes(range_gdf, poland_boundary)
safe_name = range_name.replace(" ", "_").replace("/", "_")
preview_path = preview_dir / f"{safe_name}.png"
preview_path.write_bytes(image_data)
sys.stdout.write(f" Saved: {preview_path.name}\n")
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"Mountain ranges: {num_ranges}\n")
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
if args.preview:
sys.stdout.write(f"Preview images: {args.preview}\n")
except (OSError, ValueError, RuntimeError) as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,6 @@
#!/bin/bash
# Run the Polish mountain ranges Anki generator
cd "$(dirname "$0")" || exit
python polish_mountain_ranges_anki.py --preview preview_images --preview-count 5 "$@"

View File

@ -0,0 +1 @@
"""Polish national parks Anki deck generator."""

View File

@ -0,0 +1,348 @@
"""Anki flashcard generator for Polish national parks.
Generates Anki-compatible flashcard decks with maps showing national parks
highlighted on a Poland map.
"""
from __future__ import annotations
import argparse
import hashlib
from io import BytesIO
import multiprocessing as mp
from pathlib import Path
import random
import sys
import tempfile
from typing import TYPE_CHECKING
import genanki
import geopandas as gpd
import matplotlib as mpl
mpl.use("Agg") # Non-interactive backend for multiprocessing
import matplotlib.pyplot as plt
sys.path.insert(0, str(Path(__file__).parent.parent))
from geo_data import get_poland_boundary, get_polish_national_parks
if TYPE_CHECKING:
from collections.abc import Sequence
from matplotlib.figure import Figure
PARK_COLOR = "#2ECC71" # Green for national parks
# Threshold for "small" parks that need an icon (in km²)
SMALL_PARK_THRESHOLD_KM2 = 100
def create_park_map(
park_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
) -> Figure:
"""Create a map showing Poland with one national park highlighted.
For small parks, also shows a tree marker at the centroid for visibility.
"""
fig, ax = plt.subplots(figsize=(10, 12))
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_alpha(0)
ax.patch.set_alpha(0)
# Plot Poland as a plain gray shape
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
# Plot the national park
park_gdf.plot(ax=ax, color=PARK_COLOR, alpha=0.9)
park_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=1.5)
# For small parks, add a tree marker at the centroid
area_km2 = park_gdf.iloc[0].get("area_km2", 0)
if area_km2 < SMALL_PARK_THRESHOLD_KM2:
centroid = park_gdf.iloc[0].geometry.centroid
# Use a tree-like marker (triangle pointing up)
ax.scatter(
[centroid.x],
[centroid.y],
s=600,
c="#006400",
marker="^",
edgecolor="#1A1A1A",
linewidth=2,
zorder=10,
)
# Set bounds to Poland
bounds = poland_boundary.total_bounds
ax.set_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3])
return fig
def generate_park_image_bytes(
park_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
) -> bytes:
"""Generate a park map image as bytes."""
fig = create_park_map(park_gdf, poland_boundary)
buf = BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.read()
# Global variables for multiprocessing (set via initializer)
_mp_poland_boundary: gpd.GeoDataFrame | None = None
def _init_worker(poland_geojson: str) -> None:
"""Initialize worker process with shared data."""
global _mp_poland_boundary # noqa: PLW0603
_mp_poland_boundary = gpd.read_file(poland_geojson)
def _render_single_park(args: tuple[str, str]) -> tuple[str, bytes]:
"""Render a single park image (worker function).
Args:
args: Tuple of (park_name, park_geojson_str).
Returns:
Tuple of (park_name, image_bytes).
"""
park_name, park_geojson = args
park_gdf = gpd.read_file(park_geojson)
assert _mp_poland_boundary is not None # noqa: S101
image_data = generate_park_image_bytes(park_gdf, _mp_poland_boundary)
return park_name, image_data
def generate_anki_package(
parks: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
deck_name: str = "Polish National Parks",
) -> genanki.Package:
"""Generate Anki package for Polish national parks."""
model_id_hash = hashlib.md5( # noqa: S324
f"polish_national_parks_{deck_name}".encode()
)
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """
.card {
font-family: Arial, sans-serif;
font-size: 24px;
text-align: center;
color: #333;
background-color: #fff;
}
.card.night_mode {
color: #eee;
background-color: #2f2f2f;
}
.map-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.map-container img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
}
.answer-text {
font-size: 32px;
font-weight: bold;
margin-top: 20px;
color: #2C3E50;
}
.card.night_mode .answer-text {
color: #ECF0F1;
}
.info-text {
font-size: 18px;
color: #7F8C8D;
margin-top: 10px;
}
.card.night_mode .info-text {
color: #BDC3C7;
}
"""
my_model = genanki.Model(
model_id,
"Polish National Park Model",
fields=[
{"name": "ParkMap"},
{"name": "ParkName"},
{"name": "Area"},
],
templates=[
{
"name": "Card 1",
"qfmt": '<div class="map-container">{{ParkMap}}</div>',
"afmt": '<div class="map-container">{{ParkMap}}</div>'
'<hr id="answer">'
'<div class="answer-text">{{ParkName}}</div>'
'<div class="info-text">{{Area}} km²</div>',
},
],
css=card_css,
)
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
my_deck = genanki.Deck(deck_id, deck_name)
media_files = []
# Prepare data for parallel processing
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
poland_boundary.to_file(f.name, driver="GeoJSON")
poland_geojson_path = f.name
# Prepare work items: (park_name, park_geojson_str)
work_items: list[tuple[str, str]] = []
for _, row in parks.iterrows():
park_gdf = gpd.GeoDataFrame([row], crs=parks.crs)
park_geojson = park_gdf.to_json()
work_items.append((row["name"], park_geojson))
# Use multiprocessing for parallel rendering
num_workers = min(mp.cpu_count(), 8)
sys.stdout.write(
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
)
results: dict[str, bytes] = {}
with mp.Pool(
num_workers,
initializer=_init_worker,
initargs=(poland_geojson_path,),
) as pool:
for i, (park_name, image_data) in enumerate(
pool.imap_unordered(_render_single_park, work_items)
):
results[park_name] = image_data
if (i + 1) % 10 == 0:
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
# Clean up temp file
Path(poland_geojson_path).unlink(missing_ok=True)
# Create notes from results
for _, row in parks.iterrows():
park_name = row["name"]
area_km2 = round(row["area_km2"], 1) if "area_km2" in row else 0
image_data = results[park_name]
filename = f"park_{park_name.replace(' ', '_').replace('/', '_')}.png"
note = genanki.Note(
model=my_model,
fields=[f'<img src="{filename}">', park_name, str(area_km2)],
tags=["geography", "poland", "national-parks", "nature"],
)
my_deck.add_note(note)
temp_path = Path(f"/tmp/{filename}") # noqa: S108
temp_path.write_bytes(image_data)
media_files.append(str(temp_path))
package = genanki.Package(my_deck)
package.media_files = media_files
return package
def main(argv: Sequence[str] | None = None) -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate Anki flashcards for Polish national parks.",
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path (default: polish_national_parks.apkg)",
)
parser.add_argument(
"--deck-name",
"-d",
type=str,
default="Polish National Parks",
help="Name for the Anki deck",
)
parser.add_argument(
"--preview",
"-p",
type=str,
default=None,
help="Export preview images to specified directory",
)
parser.add_argument(
"--preview-count",
type=int,
default=5,
help="Number of preview images to export (default: 5)",
)
args = parser.parse_args(argv)
output_path = (
Path(args.output) if args.output else Path("polish_national_parks.apkg")
)
try:
sys.stdout.write("Loading national parks data...\n")
parks = get_polish_national_parks()
poland_boundary = get_poland_boundary()
num_parks = len(parks)
sys.stdout.write(f"Found {num_parks} national parks.\n")
sys.stdout.write("Generating flashcards...\n")
package = generate_anki_package(parks, poland_boundary, args.deck_name)
package.write_to_file(str(output_path))
# Export preview images if requested
if args.preview:
preview_dir = Path(args.preview)
preview_dir.mkdir(parents=True, exist_ok=True)
preview_parks = list(parks.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_parks)} preview images "
f"to {preview_dir}...\n"
)
for _, row in preview_parks:
park_name = row["name"]
park_gdf = gpd.GeoDataFrame([row], crs=parks.crs)
image_data = generate_park_image_bytes(park_gdf, poland_boundary)
safe_name = park_name.replace(" ", "_").replace("/", "_")
preview_path = preview_dir / f"{safe_name}.png"
preview_path.write_bytes(image_data)
sys.stdout.write(f" Saved: {preview_path.name}\n")
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"National parks: {num_parks}\n")
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
if args.preview:
sys.stdout.write(f"Preview images: {args.preview}\n")
except (OSError, ValueError, RuntimeError) as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,6 @@
#!/bin/bash
# Run the Polish national parks Anki generator
cd "$(dirname "$0")" || exit
python polish_national_parks_anki.py --preview preview_images --preview-count 5 "$@"

View File

@ -0,0 +1 @@
"""Polish nature reserves Anki generator."""

View File

@ -0,0 +1,345 @@
"""Anki flashcard generator for Polish nature reserves.
Generates Anki-compatible flashcard decks with maps showing nature reserves
highlighted on a Poland map. Optimized for large datasets (~1500 reserves).
"""
from __future__ import annotations
import argparse
import hashlib
from io import BytesIO
import multiprocessing as mp
from pathlib import Path
import random
import sys
import tempfile
from typing import TYPE_CHECKING
import genanki
import geopandas as gpd
import matplotlib as mpl
mpl.use("Agg") # Non-interactive backend for multiprocessing
import matplotlib.pyplot as plt
sys.path.insert(0, str(Path(__file__).parent.parent))
from geo_data import get_poland_boundary, get_polish_nature_reserves
if TYPE_CHECKING:
from collections.abc import Sequence
from matplotlib.figure import Figure
RESERVE_COLOR = "#16A085" # Teal for nature reserves
def create_reserve_map(
reserve_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
) -> Figure:
"""Create a map showing Poland with one nature reserve highlighted."""
fig, ax = plt.subplots(figsize=(10, 12))
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_alpha(0)
ax.patch.set_alpha(0)
# Plot Poland as a plain gray shape
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
# Plot the nature reserve
reserve_gdf.plot(ax=ax, color=RESERVE_COLOR, alpha=0.9)
reserve_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=3)
# Set bounds to Poland
bounds = poland_boundary.total_bounds
ax.set_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3])
return fig
def generate_reserve_image_bytes(
reserve_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
) -> bytes:
"""Generate a reserve map image as bytes."""
fig = create_reserve_map(reserve_gdf, poland_boundary)
buf = BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.read()
# Global variables for multiprocessing (set via initializer)
_mp_poland_boundary: gpd.GeoDataFrame | None = None
def _init_worker(poland_geojson: str) -> None:
"""Initialize worker process with shared data."""
global _mp_poland_boundary # noqa: PLW0603
_mp_poland_boundary = gpd.read_file(poland_geojson)
def _render_single_reserve(args: tuple[str, str]) -> tuple[str, bytes]:
"""Render a single reserve image (worker function).
Args:
args: Tuple of (reserve_name, reserve_geojson_str).
Returns:
Tuple of (reserve_name, image_bytes).
"""
reserve_name, reserve_geojson = args
reserve_gdf = gpd.read_file(reserve_geojson)
assert _mp_poland_boundary is not None # noqa: S101
image_data = generate_reserve_image_bytes(reserve_gdf, _mp_poland_boundary)
return reserve_name, image_data
def generate_anki_package(
reserves: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
deck_name: str = "Polish Nature Reserves",
) -> genanki.Package:
"""Generate Anki package for Polish nature reserves."""
model_id_hash = hashlib.md5( # noqa: S324
f"polish_nature_reserves_{deck_name}".encode()
)
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """
.card {
font-family: Arial, sans-serif;
font-size: 24px;
text-align: center;
color: #333;
background-color: #fff;
}
.card.night_mode {
color: #eee;
background-color: #2f2f2f;
}
.map-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.map-container img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
}
.answer-text {
font-size: 28px;
font-weight: bold;
margin-top: 20px;
color: #2C3E50;
}
.card.night_mode .answer-text {
color: #ECF0F1;
}
.info-text {
font-size: 18px;
color: #7F8C8D;
margin-top: 10px;
}
.card.night_mode .info-text {
color: #BDC3C7;
}
"""
my_model = genanki.Model(
model_id,
"Polish Nature Reserve Model",
fields=[
{"name": "ReserveMap"},
{"name": "ReserveName"},
{"name": "Area"},
],
templates=[
{
"name": "Card 1",
"qfmt": '<div class="map-container">{{ReserveMap}}</div>',
"afmt": '<div class="map-container">{{ReserveMap}}</div>'
'<hr id="answer">'
'<div class="answer-text">{{ReserveName}}</div>'
'<div class="info-text">{{Area}} km²</div>',
},
],
css=card_css,
)
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
my_deck = genanki.Deck(deck_id, deck_name)
media_files = []
# Prepare data for parallel processing
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
poland_boundary.to_file(f.name, driver="GeoJSON")
poland_geojson_path = f.name
# Prepare work items: (reserve_name, reserve_geojson_str)
work_items: list[tuple[str, str]] = []
for _, row in reserves.iterrows():
reserve_gdf = gpd.GeoDataFrame([row], crs=reserves.crs)
reserve_geojson = reserve_gdf.to_json()
work_items.append((row["name"], reserve_geojson))
# Use multiprocessing for parallel rendering (more workers for large datasets)
num_workers = min(mp.cpu_count(), 8)
sys.stdout.write(
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
)
sys.stdout.write("(This may take a while due to the large number of reserves)\n")
results: dict[str, bytes] = {}
with mp.Pool(
num_workers,
initializer=_init_worker,
initargs=(poland_geojson_path,),
) as pool:
for i, (reserve_name, image_data) in enumerate(
pool.imap_unordered(_render_single_reserve, work_items)
):
results[reserve_name] = image_data
if (i + 1) % 100 == 0:
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
sys.stdout.write(f" Rendered {len(work_items)}/{len(work_items)}.\n")
# Clean up temp file
Path(poland_geojson_path).unlink(missing_ok=True)
# Create notes from results
for _, row in reserves.iterrows():
reserve_name = row["name"]
area_km2 = round(row["area_km2"], 2) if "area_km2" in row else 0
image_data = results[reserve_name]
# Use hash for unique filename since names may have special chars
name_hash = hashlib.md5(reserve_name.encode()).hexdigest()[:8] # noqa: S324
safe_name = reserve_name.replace(" ", "_").replace("/", "_")[:30]
filename = f"reserve_{safe_name}_{name_hash}.png"
note = genanki.Note(
model=my_model,
fields=[f'<img src="{filename}">', reserve_name, str(area_km2)],
tags=["geography", "poland", "nature-reserves", "protected-areas"],
)
my_deck.add_note(note)
temp_path = Path(f"/tmp/{filename}") # noqa: S108
temp_path.write_bytes(image_data)
media_files.append(str(temp_path))
package = genanki.Package(my_deck)
package.media_files = media_files
return package
def main(argv: Sequence[str] | None = None) -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate Anki flashcards for Polish nature reserves.",
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path (default: polish_nature_reserves.apkg)",
)
parser.add_argument(
"--deck-name",
"-d",
type=str,
default="Polish Nature Reserves",
help="Name for the Anki deck",
)
parser.add_argument(
"--preview",
"-p",
type=str,
default=None,
help="Export preview images to specified directory",
)
parser.add_argument(
"--preview-count",
type=int,
default=5,
help="Number of preview images to export (default: 5)",
)
parser.add_argument(
"--limit",
"-l",
type=int,
default=None,
help="Limit number of reserves (for testing, default: all)",
)
args = parser.parse_args(argv)
output_path = (
Path(args.output) if args.output else Path("polish_nature_reserves.apkg")
)
try:
sys.stdout.write("Loading nature reserves data...\n")
reserves = get_polish_nature_reserves()
poland_boundary = get_poland_boundary()
# Apply limit if specified
if args.limit:
reserves = reserves.head(args.limit)
sys.stdout.write(f"Limiting to {args.limit} reserves.\n")
num_reserves = len(reserves)
sys.stdout.write(f"Found {num_reserves} nature reserves.\n")
sys.stdout.write("Generating flashcards...\n")
package = generate_anki_package(reserves, poland_boundary, args.deck_name)
package.write_to_file(str(output_path))
# Export preview images if requested
if args.preview:
preview_dir = Path(args.preview)
preview_dir.mkdir(parents=True, exist_ok=True)
preview_reserves = list(reserves.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_reserves)} preview images "
f"to {preview_dir}...\n"
)
for _, row in preview_reserves:
reserve_name = row["name"]
reserve_gdf = gpd.GeoDataFrame([row], crs=reserves.crs)
image_data = generate_reserve_image_bytes(reserve_gdf, poland_boundary)
safe_name = reserve_name.replace(" ", "_").replace("/", "_")[:30]
preview_path = preview_dir / f"{safe_name}.png"
preview_path.write_bytes(image_data)
sys.stdout.write(f" Saved: {preview_path.name}\n")
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"Nature reserves: {num_reserves}\n")
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
if args.preview:
sys.stdout.write(f"Preview images: {args.preview}\n")
except (OSError, ValueError, RuntimeError) as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,7 @@
#!/bin/bash
# Run the Polish nature reserves Anki generator
cd "$(dirname "$0")" || exit
# Default runs all reserves - use --limit for testing
python polish_nature_reserves_anki.py --preview preview_images --preview-count 5 "$@"

View File

@ -0,0 +1 @@
"""Polish powiaty (counties) Anki flashcard generator."""

View File

@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""Anki flashcard generator for Polish powiaty (counties).
Generates Anki-compatible flashcard decks with maps showing individual
Polish counties highlighted on a country map.
"""
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
import genanki
import geopandas as gpd
import matplotlib.pyplot as plt
sys.path.insert(0, str(Path(__file__).parent.parent))
from geo_data import get_poland_boundary, get_polish_powiaty
if TYPE_CHECKING:
from collections.abc import Sequence
from matplotlib.figure import Figure
# 400 distinct colors for powiaty (cycling through)
POWIAT_COLORS = [
"#E74C3C",
"#3498DB",
"#2ECC71",
"#9B59B6",
"#F39C12",
"#1ABC9C",
"#E91E63",
"#00BCD4",
"#8BC34A",
"#FF5722",
"#673AB7",
"#FFEB3B",
"#795548",
"#607D8B",
"#CDDC39",
"#FF9800",
"#4CAF50",
"#03A9F4",
"#F44336",
"#009688",
"#3F51B5",
"#FFC107",
"#9E9E9E",
"#00E676",
"#FF4081",
"#448AFF",
"#69F0AE",
"#FFD740",
"#40C4FF",
"#B388FF",
"#EA80FC",
"#82B1FF",
"#A7FFEB",
"#FFFF8D",
"#FF80AB",
"#536DFE",
"#64FFDA",
"#FFE57F",
"#80D8FF",
"#B9F6CA",
"#CF6679",
"#BB86FC",
"#03DAC6",
"#018786",
"#6200EE",
"#3700B3",
"#B00020",
"#FF0266",
"#C51162",
"#AA00FF",
]
def create_powiat_map(
powiat_name: str,
powiat_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
all_powiaty: gpd.GeoDataFrame,
) -> Figure:
"""Create a map showing Poland with one powiat highlighted."""
fig, ax = plt.subplots(figsize=(10, 12))
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_alpha(0)
ax.patch.set_alpha(0)
# Plot Poland as a plain gray shape
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
# Assign color based on sorted names
sorted_names = sorted(all_powiaty["nazwa"].tolist())
color_idx = sorted_names.index(powiat_name) % len(POWIAT_COLORS)
fill_color = POWIAT_COLORS[color_idx]
# Plot the highlighted powiat
powiat_gdf.plot(ax=ax, color=fill_color, alpha=0.9)
powiat_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=3)
# Set bounds to Poland
bounds = poland_boundary.total_bounds
ax.set_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3])
return fig
def generate_powiat_image_bytes(
powiat_name: str,
powiat_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
all_powiaty: gpd.GeoDataFrame,
) -> bytes:
"""Generate a powiat map image as bytes."""
fig = create_powiat_map(powiat_name, powiat_gdf, poland_boundary, all_powiaty)
buf = BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.read()
def generate_anki_package(
powiaty: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
deck_name: str = "Polish Powiaty",
) -> genanki.Package:
"""Generate Anki package for Polish powiaty."""
model_id_hash = hashlib.md5(f"polish_powiaty_{deck_name}".encode()) # noqa: S324
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """
.card {
font-family: Arial, sans-serif;
font-size: 24px;
text-align: center;
color: #333;
background-color: #fff;
}
.card.night_mode {
color: #eee;
background-color: #2f2f2f;
}
.map-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.map-container img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
}
.answer-text {
font-size: 32px;
font-weight: bold;
margin-top: 20px;
color: #2C3E50;
}
.card.night_mode .answer-text {
color: #ECF0F1;
}
"""
my_model = genanki.Model(
model_id,
"Polish Powiat Model",
fields=[
{"name": "PowiatMap"},
{"name": "PowiatName"},
],
templates=[
{
"name": "Card 1",
"qfmt": '<div class="map-container">{{PowiatMap}}</div>',
"afmt": '<div class="map-container">{{PowiatMap}}</div>'
'<hr id="answer">'
'<div class="answer-text">{{PowiatName}}</div>',
},
],
css=card_css,
)
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
my_deck = genanki.Deck(deck_id, deck_name)
media_files = []
for _, row in powiaty.iterrows():
powiat_name = row["nazwa"]
powiat_gdf = gpd.GeoDataFrame([row], crs=powiaty.crs)
image_data = generate_powiat_image_bytes(
powiat_name, powiat_gdf, poland_boundary, powiaty
)
filename = f"powiat_{powiat_name.replace(' ', '_').replace('/', '_')}.png"
note = genanki.Note(
model=my_model,
fields=[f'<img src="{filename}">', powiat_name],
tags=["geography", "poland", "powiaty"],
)
my_deck.add_note(note)
temp_path = Path(f"/tmp/{filename}") # noqa: S108
temp_path.write_bytes(image_data)
media_files.append(str(temp_path))
package = genanki.Package(my_deck)
package.media_files = media_files
return package
def main(argv: Sequence[str] | None = None) -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate Anki flashcards for Polish powiaty.",
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path (default: polish_powiaty.apkg)",
)
parser.add_argument(
"--deck-name",
"-d",
type=str,
default="Polish Powiaty",
help="Name for the Anki deck",
)
parser.add_argument(
"--preview",
"-p",
type=str,
default=None,
help="Export preview images to specified directory",
)
parser.add_argument(
"--preview-count",
type=int,
default=5,
help="Number of preview images to export (default: 5)",
)
args = parser.parse_args(argv)
output_path = Path(args.output) if args.output else Path("polish_powiaty.apkg")
try:
sys.stdout.write("Loading powiaty data...\n")
powiaty = get_polish_powiaty()
poland_boundary = get_poland_boundary()
num_powiaty = len(powiaty)
sys.stdout.write(f"Generating flashcards for {num_powiaty} powiaty...\n")
package = generate_anki_package(powiaty, poland_boundary, args.deck_name)
package.write_to_file(str(output_path))
# Export preview images if requested
if args.preview:
preview_dir = Path(args.preview)
preview_dir.mkdir(parents=True, exist_ok=True)
preview_powiaty = list(powiaty.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_powiaty)} preview images "
f"to {preview_dir}...\n"
)
for _, row in preview_powiaty:
powiat_name = row["nazwa"]
powiat_gdf = gpd.GeoDataFrame([row], crs=powiaty.crs)
image_data = generate_powiat_image_bytes(
powiat_name, powiat_gdf, poland_boundary, powiaty
)
safe_name = powiat_name.replace(" ", "_").replace("/", "_")
preview_path = preview_dir / f"{safe_name}.png"
preview_path.write_bytes(image_data)
sys.stdout.write(f" Saved: {preview_path.name}\n")
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"Powiaty: {num_powiaty}\n")
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
if args.preview:
sys.stdout.write(f"Preview images: {args.preview}\n")
except (OSError, ValueError, RuntimeError) as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,34 @@
#!/bin/bash
# Script to generate Polish Powiaty Anki deck
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_DIR="$SCRIPT_DIR/.venv"
PREVIEW_DIR="$SCRIPT_DIR/preview_images"
echo "=== Polish Powiaty Anki Generator ==="
echo
if [ ! -d "$VENV_DIR" ]; then
echo "Creating virtual environment..."
python3 -m venv "$VENV_DIR"
fi
echo "Activating virtual environment..."
source "$VENV_DIR/bin/activate"
echo "Installing dependencies..."
pip install --quiet --upgrade pip
pip install --quiet matplotlib genanki geopandas
cd "$SCRIPT_DIR"
# Create preview images directory
mkdir -p "$PREVIEW_DIR"
python -m polish_powiaty_anki --output polish_powiaty.apkg --preview "$PREVIEW_DIR" --preview-count 5
echo
echo "Done! The Anki deck is at: $SCRIPT_DIR/polish_powiaty.apkg"
echo "Preview images are in: $PREVIEW_DIR"

View File

@ -0,0 +1 @@
"""Polish rivers Anki deck generator."""

View File

@ -0,0 +1,355 @@
"""Anki flashcard generator for Polish rivers.
Generates Anki-compatible flashcard decks with maps showing rivers
highlighted on a Poland map.
"""
from __future__ import annotations
import argparse
import hashlib
from io import BytesIO
import multiprocessing as mp
from pathlib import Path
import random
import sys
import tempfile
from typing import TYPE_CHECKING
import genanki
import geopandas as gpd
import matplotlib as mpl
mpl.use("Agg") # Non-interactive backend for multiprocessing
import matplotlib.pyplot as plt
sys.path.insert(0, str(Path(__file__).parent.parent))
from geo_data import get_poland_boundary, get_polish_rivers
if TYPE_CHECKING:
from collections.abc import Sequence
from matplotlib.figure import Figure
RIVER_COLOR = "#2980B9" # Dark blue for rivers
NEIGHBOR_COLOR = "#EAECEE" # Light gray for neighboring areas
def create_river_map(
river_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
) -> Figure:
"""Create a map showing Poland with one river highlighted.
Rivers that extend beyond Poland show an extended view.
"""
fig, ax = plt.subplots(figsize=(10, 12))
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_alpha(0)
ax.patch.set_alpha(0)
# Get Poland bounds
poland_bounds = poland_boundary.total_bounds
river_bounds = river_gdf.total_bounds
# Check if river extends beyond Poland
extends_beyond = (
river_bounds[0] < poland_bounds[0]
or river_bounds[1] < poland_bounds[1]
or river_bounds[2] > poland_bounds[2]
or river_bounds[3] > poland_bounds[3]
)
if extends_beyond:
# Calculate extended bounds with some padding
min_x = min(poland_bounds[0], river_bounds[0]) - 0.2
min_y = min(poland_bounds[1], river_bounds[1]) - 0.2
max_x = max(poland_bounds[2], river_bounds[2]) + 0.2
max_y = max(poland_bounds[3], river_bounds[3]) + 0.2
# Draw background for extended area (neighboring countries)
ax.fill(
[min_x, max_x, max_x, min_x, min_x],
[min_y, min_y, max_y, max_y, min_y],
color=NEIGHBOR_COLOR,
alpha=0.3,
)
# Plot Poland as a plain gray shape
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
# Plot the river
river_gdf.plot(ax=ax, color=RIVER_COLOR, linewidth=3, alpha=0.9)
if extends_beyond:
ax.set_xlim(min_x, max_x)
ax.set_ylim(min_y, max_y)
else:
# Set bounds to Poland
ax.set_xlim(poland_bounds[0], poland_bounds[2])
ax.set_ylim(poland_bounds[1], poland_bounds[3])
return fig
def generate_river_image_bytes(
river_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
) -> bytes:
"""Generate a river map image as bytes."""
fig = create_river_map(river_gdf, poland_boundary)
buf = BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.read()
# Global variables for multiprocessing (set via initializer)
_mp_poland_boundary: gpd.GeoDataFrame | None = None
def _init_worker(poland_geojson: str) -> None:
"""Initialize worker process with shared data."""
global _mp_poland_boundary # noqa: PLW0603
_mp_poland_boundary = gpd.read_file(poland_geojson)
def _render_single_river(args: tuple[str, str]) -> tuple[str, bytes]:
"""Render a single river image (worker function).
Args:
args: Tuple of (river_name, river_geojson_str).
Returns:
Tuple of (river_name, image_bytes).
"""
river_name, river_geojson = args
river_gdf = gpd.read_file(river_geojson)
assert _mp_poland_boundary is not None # noqa: S101
image_data = generate_river_image_bytes(river_gdf, _mp_poland_boundary)
return river_name, image_data
def generate_anki_package(
rivers: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
deck_name: str = "Polish Rivers",
) -> genanki.Package:
"""Generate Anki package for Polish rivers."""
model_id_hash = hashlib.md5(f"polish_rivers_{deck_name}".encode()) # noqa: S324
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """
.card {
font-family: Arial, sans-serif;
font-size: 24px;
text-align: center;
color: #333;
background-color: #fff;
}
.card.night_mode {
color: #eee;
background-color: #2f2f2f;
}
.map-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.map-container img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
}
.answer-text {
font-size: 32px;
font-weight: bold;
margin-top: 20px;
color: #2C3E50;
}
.card.night_mode .answer-text {
color: #ECF0F1;
}
.info-text {
font-size: 18px;
color: #7F8C8D;
margin-top: 10px;
}
.card.night_mode .info-text {
color: #BDC3C7;
}
"""
my_model = genanki.Model(
model_id,
"Polish River Model",
fields=[
{"name": "RiverMap"},
{"name": "RiverName"},
{"name": "Length"},
],
templates=[
{
"name": "Card 1",
"qfmt": '<div class="map-container">{{RiverMap}}</div>',
"afmt": '<div class="map-container">{{RiverMap}}</div>'
'<hr id="answer">'
'<div class="answer-text">{{RiverName}}</div>'
'<div class="info-text">~{{Length}} km w Polsce</div>',
},
],
css=card_css,
)
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
my_deck = genanki.Deck(deck_id, deck_name)
media_files = []
# Prepare data for parallel processing
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
poland_boundary.to_file(f.name, driver="GeoJSON")
poland_geojson_path = f.name
# Prepare work items: (river_name, river_geojson_str)
work_items: list[tuple[str, str]] = []
for _, row in rivers.iterrows():
river_gdf = gpd.GeoDataFrame([row], crs=rivers.crs)
river_geojson = river_gdf.to_json()
work_items.append((row["name"], river_geojson))
# Use multiprocessing for parallel rendering
num_workers = min(mp.cpu_count(), 8)
sys.stdout.write(
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
)
results: dict[str, bytes] = {}
with mp.Pool(
num_workers,
initializer=_init_worker,
initargs=(poland_geojson_path,),
) as pool:
for i, (river_name, image_data) in enumerate(
pool.imap_unordered(_render_single_river, work_items)
):
results[river_name] = image_data
if (i + 1) % 50 == 0:
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
# Clean up temp file
Path(poland_geojson_path).unlink(missing_ok=True)
# Create notes from results
for _, row in rivers.iterrows():
river_name = row["name"]
length_km = round(row["length_km"]) if "length_km" in row else 0
image_data = results[river_name]
filename = f"river_{river_name.replace(' ', '_').replace('/', '_')}.png"
note = genanki.Note(
model=my_model,
fields=[f'<img src="{filename}">', river_name, str(length_km)],
tags=["geography", "poland", "rivers", "water"],
)
my_deck.add_note(note)
temp_path = Path(f"/tmp/{filename}") # noqa: S108
temp_path.write_bytes(image_data)
media_files.append(str(temp_path))
package = genanki.Package(my_deck)
package.media_files = media_files
return package
def main(argv: Sequence[str] | None = None) -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate Anki flashcards for Polish rivers.",
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path (default: polish_rivers.apkg)",
)
parser.add_argument(
"--deck-name",
"-d",
type=str,
default="Polish Rivers",
help="Name for the Anki deck",
)
parser.add_argument(
"--preview",
"-p",
type=str,
default=None,
help="Export preview images to specified directory",
)
parser.add_argument(
"--preview-count",
type=int,
default=5,
help="Number of preview images to export (default: 5)",
)
args = parser.parse_args(argv)
output_path = Path(args.output) if args.output else Path("polish_rivers.apkg")
try:
sys.stdout.write("Loading rivers data...\n")
rivers = get_polish_rivers()
poland_boundary = get_poland_boundary()
num_rivers = len(rivers)
sys.stdout.write(f"Found {num_rivers} rivers.\n")
sys.stdout.write("Generating flashcards...\n")
package = generate_anki_package(rivers, poland_boundary, args.deck_name)
package.write_to_file(str(output_path))
# Export preview images if requested
if args.preview:
preview_dir = Path(args.preview)
preview_dir.mkdir(parents=True, exist_ok=True)
preview_rivers = list(rivers.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_rivers)} preview images "
f"to {preview_dir}...\n"
)
for _, row in preview_rivers:
river_name = row["name"]
river_gdf = gpd.GeoDataFrame([row], crs=rivers.crs)
image_data = generate_river_image_bytes(river_gdf, poland_boundary)
safe_name = river_name.replace(" ", "_").replace("/", "_")
preview_path = preview_dir / f"{safe_name}.png"
preview_path.write_bytes(image_data)
sys.stdout.write(f" Saved: {preview_path.name}\n")
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"Rivers: {num_rivers}\n")
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
if args.preview:
sys.stdout.write(f"Preview images: {args.preview}\n")
except (OSError, ValueError, RuntimeError) as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,6 @@
#!/bin/bash
# Run the Polish rivers Anki generator
cd "$(dirname "$0")" || exit
python polish_rivers_anki.py --preview preview_images --preview-count 5 "$@"

View File

@ -0,0 +1 @@
"""Polish UNESCO sites Anki generator."""

View File

@ -0,0 +1,365 @@
"""Anki flashcard generator for Polish UNESCO World Heritage Sites.
Generates Anki-compatible flashcard decks with maps showing UNESCO sites
highlighted on a Poland map.
"""
from __future__ import annotations
import argparse
import hashlib
from io import BytesIO
import multiprocessing as mp
from pathlib import Path
import random
import sys
import tempfile
from typing import TYPE_CHECKING
import genanki
import geopandas as gpd
import matplotlib as mpl
mpl.use("Agg") # Non-interactive backend for multiprocessing
import matplotlib.pyplot as plt
from shapely.geometry import Point
sys.path.insert(0, str(Path(__file__).parent.parent))
from geo_data import get_poland_boundary, get_polish_unesco_sites
if TYPE_CHECKING:
from collections.abc import Sequence
from matplotlib.figure import Figure
SITE_COLOR_POLYGON = "#9B59B6" # Purple for polygon sites
SITE_COLOR_POINT = "#9B59B6" # Purple for point markers
def create_unesco_map(
site_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
) -> Figure:
"""Create a map showing Poland with one UNESCO site highlighted.
Always shows a star marker at the centroid for consistency.
"""
fig, ax = plt.subplots(figsize=(10, 12))
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_alpha(0)
ax.patch.set_alpha(0)
# Plot Poland as a plain gray shape
poland_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
poland_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=1)
# Get centroid for star marker
geom = site_gdf.iloc[0].geometry
if isinstance(geom, Point):
x, y = geom.x, geom.y
else:
centroid = geom.centroid
x, y = centroid.x, centroid.y
# Always show a star marker for consistency
ax.scatter(
[x],
[y],
s=800,
c=SITE_COLOR_POINT,
marker="*",
edgecolor="#1A1A1A",
linewidth=2,
zorder=10,
)
# Set bounds to Poland
bounds = poland_boundary.total_bounds
ax.set_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3])
return fig
def generate_unesco_image_bytes(
site_gdf: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
) -> bytes:
"""Generate a UNESCO site map image as bytes."""
fig = create_unesco_map(site_gdf, poland_boundary)
buf = BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.read()
# Global variables for multiprocessing (set via initializer)
_mp_poland_boundary: gpd.GeoDataFrame | None = None
def _init_worker(poland_geojson: str) -> None:
"""Initialize worker process with shared data."""
global _mp_poland_boundary # noqa: PLW0603
_mp_poland_boundary = gpd.read_file(poland_geojson)
def _render_single_site(args: tuple[str, str]) -> tuple[str, bytes]:
"""Render a single site image (worker function).
Args:
args: Tuple of (site_name, site_geojson_str).
Returns:
Tuple of (site_name, image_bytes).
"""
site_name, site_geojson = args
site_gdf = gpd.read_file(site_geojson)
assert _mp_poland_boundary is not None # noqa: S101
image_data = generate_unesco_image_bytes(site_gdf, _mp_poland_boundary)
return site_name, image_data
def generate_anki_package(
sites: gpd.GeoDataFrame,
poland_boundary: gpd.GeoDataFrame,
deck_name: str = "Polish UNESCO World Heritage Sites",
) -> genanki.Package:
"""Generate Anki package for Polish UNESCO sites."""
model_id_hash = hashlib.md5( # noqa: S324
f"polish_unesco_sites_{deck_name}".encode()
)
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """
.card {
font-family: Arial, sans-serif;
font-size: 24px;
text-align: center;
color: #333;
background-color: #fff;
}
.card.night_mode {
color: #eee;
background-color: #2f2f2f;
}
.map-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.map-container img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
}
.answer-text {
font-size: 28px;
font-weight: bold;
margin-top: 20px;
color: #2C3E50;
}
.card.night_mode .answer-text {
color: #ECF0F1;
}
.info-text {
font-size: 18px;
color: #7F8C8D;
margin-top: 10px;
}
.card.night_mode .info-text {
color: #BDC3C7;
}
.year-badge {
display: inline-block;
background: #9B59B6;
color: white;
padding: 4px 12px;
border-radius: 15px;
font-size: 16px;
margin-top: 8px;
}
.card.night_mode .year-badge {
background: #8E44AD;
}
"""
my_model = genanki.Model(
model_id,
"Polish UNESCO Site Model",
fields=[
{"name": "SiteMap"},
{"name": "SiteName"},
{"name": "InscribedYear"},
{"name": "Category"},
],
templates=[
{
"name": "Card 1",
"qfmt": '<div class="map-container">{{SiteMap}}</div>',
"afmt": '<div class="map-container">{{SiteMap}}</div>'
'<hr id="answer">'
'<div class="answer-text">{{SiteName}}</div>'
'<div class="info-text">{{Category}}</div>'
'<div class="year-badge">Inscribed: {{InscribedYear}}</div>',
},
],
css=card_css,
)
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
my_deck = genanki.Deck(deck_id, deck_name)
media_files = []
# Prepare data for parallel processing
with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as f:
poland_boundary.to_file(f.name, driver="GeoJSON")
poland_geojson_path = f.name
# Prepare work items: (site_name, site_geojson_str)
work_items: list[tuple[str, str]] = []
for _, row in sites.iterrows():
site_gdf = gpd.GeoDataFrame([row], crs=sites.crs)
site_geojson = site_gdf.to_json()
work_items.append((row["name"], site_geojson))
# Use multiprocessing for parallel rendering
num_workers = min(mp.cpu_count(), 8)
sys.stdout.write(
f"Rendering {len(work_items)} images using {num_workers} workers...\n"
)
results: dict[str, bytes] = {}
with mp.Pool(
num_workers,
initializer=_init_worker,
initargs=(poland_geojson_path,),
) as pool:
for i, (site_name, image_data) in enumerate(
pool.imap_unordered(_render_single_site, work_items)
):
results[site_name] = image_data
if (i + 1) % 5 == 0:
sys.stdout.write(f" Rendered {i + 1}/{len(work_items)}...\n")
# Clean up temp file
Path(poland_geojson_path).unlink(missing_ok=True)
# Create notes from results
for _, row in sites.iterrows():
site_name = row["name"]
inscribed_year = row.get("inscribed_year", "Unknown")
category = row.get("category", "Cultural/Natural")
image_data = results[site_name]
filename = f"unesco_{site_name.replace(' ', '_').replace('/', '_')}.png"
note = genanki.Note(
model=my_model,
fields=[
f'<img src="{filename}">',
site_name,
str(inscribed_year),
category,
],
tags=["geography", "poland", "unesco", "heritage"],
)
my_deck.add_note(note)
temp_path = Path(f"/tmp/{filename}") # noqa: S108
temp_path.write_bytes(image_data)
media_files.append(str(temp_path))
package = genanki.Package(my_deck)
package.media_files = media_files
return package
def main(argv: Sequence[str] | None = None) -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate Anki flashcards for Polish UNESCO sites.",
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path (default: polish_unesco_sites.apkg)",
)
parser.add_argument(
"--deck-name",
"-d",
type=str,
default="Polish UNESCO World Heritage Sites",
help="Name for the Anki deck",
)
parser.add_argument(
"--preview",
"-p",
type=str,
default=None,
help="Export preview images to specified directory",
)
parser.add_argument(
"--preview-count",
type=int,
default=5,
help="Number of preview images to export (default: 5)",
)
args = parser.parse_args(argv)
output_path = Path(args.output) if args.output else Path("polish_unesco_sites.apkg")
try:
sys.stdout.write("Loading UNESCO sites data...\n")
sites = get_polish_unesco_sites()
poland_boundary = get_poland_boundary()
num_sites = len(sites)
sys.stdout.write(f"Found {num_sites} UNESCO World Heritage Sites.\n")
sys.stdout.write("Generating flashcards...\n")
package = generate_anki_package(sites, poland_boundary, args.deck_name)
package.write_to_file(str(output_path))
# Export preview images if requested
if args.preview:
preview_dir = Path(args.preview)
preview_dir.mkdir(parents=True, exist_ok=True)
preview_sites = list(sites.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_sites)} preview images "
f"to {preview_dir}...\n"
)
for _, row in preview_sites:
site_name = row["name"]
site_gdf = gpd.GeoDataFrame([row], crs=sites.crs)
image_data = generate_unesco_image_bytes(site_gdf, poland_boundary)
safe_name = site_name.replace(" ", "_").replace("/", "_")
preview_path = preview_dir / f"{safe_name}.png"
preview_path.write_bytes(image_data)
sys.stdout.write(f" Saved: {preview_path.name}\n")
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"UNESCO sites: {num_sites}\n")
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
if args.preview:
sys.stdout.write(f"Preview images: {args.preview}\n")
except (OSError, ValueError, RuntimeError) as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,6 @@
#!/bin/bash
# Run the Polish UNESCO sites Anki generator
cd "$(dirname "$0")" || exit
python polish_unesco_sites_anki.py --preview preview_images --preview-count 5 "$@"

View File

@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>All Preview Images</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; }
h1 { color: #333; }
h2 { color: #666; margin-top: 30px; border-bottom: 2px solid #ddd; padding-bottom: 10px; }
.gallery { display: flex; flex-wrap: wrap; gap: 20px; }
.card { background: white; padding: 15px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); }
.card img { max-width: 300px; max-height: 300px; object-fit: contain; }
.card p { margin-top: 10px; text-align: center; font-weight: bold; color: #333; }
</style>
</head>
<body>
<h1>🗺️ Anki Geography Cards - Preview Images</h1>
<h2>🛣️ Warsaw Streets (Top 5 Longest)</h2>
<div class="gallery">
<div class="card"><img src="warsaw_streets/preview_images/Wał_Miedzeszyński.png"><p>Wał Miedzeszyński (27.8km)</p></div>
<div class="card"><img src="warsaw_streets/preview_images/Puławska.png"><p>Puławska (25.3km)</p></div>
<div class="card"><img src="warsaw_streets/preview_images/Aleje_Jerozolimskie.png"><p>Aleje Jerozolimskie (22.2km)</p></div>
<div class="card"><img src="warsaw_streets/preview_images/Modlińska.png"><p>Modlińska (19.2km)</p></div>
<div class="card"><img src="warsaw_streets/preview_images/Patriotów.png"><p>Patriotów (15.9km)</p></div>
</div>
<h2>🚇 Warsaw Metro Stations</h2>
<div class="gallery">
<div class="card"><img src="warsaw_metro/preview_images/Ratusz-Arsenał.png"><p>Ratusz-Arsenał</p></div>
<div class="card"><img src="warsaw_metro/preview_images/Marymont.png"><p>Marymont</p></div>
<div class="card"><img src="warsaw_metro/preview_images/Stare_Bielany.png"><p>Stare Bielany</p></div>
<div class="card"><img src="warsaw_metro/preview_images/Wawrzyszew.png"><p>Wawrzyszew</p></div>
<div class="card"><img src="warsaw_metro/preview_images/Młociny.png"><p>Młociny</p></div>
</div>
<h2>🌉 Warsaw Bridges</h2>
<div class="gallery">
<div class="card"><img src="warsaw_bridges/preview_images/Most_Siekierkowski.png"><p>Most Siekierkowski</p></div>
<div class="card"><img src="warsaw_bridges/preview_images/Most_Świętokrzyski.png"><p>Most Świętokrzyski</p></div>
<div class="card"><img src="warsaw_bridges/preview_images/Most_Generała_Stefana_Grota-Roweckiego.png"><p>Most Grota-Roweckiego</p></div>
<div class="card"><img src="warsaw_bridges/preview_images/Most_Marii_Skłodowskiej-Curie.png"><p>Most M. Skłodowskiej-Curie</p></div>
<div class="card"><img src="warsaw_bridges/preview_images/Most_Anny_Jagiellonki.png"><p>Most Anny Jagiellonki</p></div>
</div>
<h2>🏛️ Warsaw Landmarks</h2>
<div class="gallery">
<div class="card"><img src="warsaw_landmarks/preview_images/Pomnik_Kościuszkowców.png"><p>Pomnik Kościuszkowców</p></div>
<div class="card"><img src="warsaw_landmarks/preview_images/Pomnik_Państwa_Podziemnego_i_AK.png"><p>Pomnik Państwa Podziemnego i AK</p></div>
<div class="card"><img src="warsaw_landmarks/preview_images/Muzeum_Etnograficzne.png"><p>Muzeum Etnograficzne</p></div>
<div class="card"><img src="warsaw_landmarks/preview_images/Żydowski_Instytut_Historyczny.png"><p>Żydowski Instytut Historyczny</p></div>
<div class="card"><img src="warsaw_landmarks/preview_images/Muzeum_Marii_Skłodowskiej-Curie.png"><p>Muzeum M. Skłodowskiej-Curie</p></div>
</div>
<h2>🏘️ Warsaw Osiedla</h2>
<div class="gallery">
<div class="card"><img src="warsaw_osiedla/preview_images/Grochów-Centrum.png"><p>Grochów-Centrum</p></div>
<div class="card"><img src="warsaw_osiedla/preview_images/Grochów-Kinowa.png"><p>Grochów-Kinowa</p></div>
<div class="card"><img src="warsaw_osiedla/preview_images/Grochów-Południowy.png"><p>Grochów-Południowy</p></div>
<div class="card"><img src="warsaw_osiedla/preview_images/Grochów-Północny.png"><p>Grochów-Północny</p></div>
<div class="card"><img src="warsaw_osiedla/preview_images/Przyczółek_Grochowski.png"><p>Przyczółek Grochowski</p></div>
</div>
<h2>🗺️ Polish Powiaty</h2>
<div class="gallery">
<div class="card"><img src="polish_powiaty/preview_images/powiat_ropczycko-sędziszowski.png"><p>Powiat Ropczycko-Sędziszowski</p></div>
<div class="card"><img src="polish_powiaty/preview_images/powiat_łosicki.png"><p>Powiat Łosicki</p></div>
<div class="card"><img src="polish_powiaty/preview_images/powiat_piaseczyński.png"><p>Powiat Piaseczyński</p></div>
<div class="card"><img src="polish_powiaty/preview_images/powiat_radomski.png"><p>Powiat Radomski</p></div>
<div class="card"><img src="polish_powiaty/preview_images/powiat_sierpecki.png"><p>Powiat Sierpecki</p></div>
</div>
<h2>🏙️ Warsaw Districts</h2>
<div class="gallery">
<div class="card"><img src="warsaw_districts/preview_images/Śródmieście.png"><p>Śródmieście</p></div>
<div class="card"><img src="warsaw_districts/preview_images/Mokotów.png"><p>Mokotów</p></div>
<div class="card"><img src="warsaw_districts/preview_images/Praga_Północ.png"><p>Praga Północ</p></div>
<div class="card"><img src="warsaw_districts/preview_images/Praga_Południe.png"><p>Praga Południe</p></div>
<div class="card"><img src="warsaw_districts/preview_images/Wola.png"><p>Wola</p></div>
</div>
</body>
</html>

View File

@ -0,0 +1 @@
"""Warsaw bridges Anki flashcard generator."""

View File

@ -0,0 +1,34 @@
#!/bin/bash
# Script to generate Warsaw Bridges Anki deck
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_DIR="$SCRIPT_DIR/.venv"
PREVIEW_DIR="$SCRIPT_DIR/preview_images"
echo "=== Warsaw Bridges Anki Generator ==="
echo
if [ ! -d "$VENV_DIR" ]; then
echo "Creating virtual environment..."
python3 -m venv "$VENV_DIR"
fi
echo "Activating virtual environment..."
source "$VENV_DIR/bin/activate"
echo "Installing dependencies..."
pip install --quiet --upgrade pip
pip install --quiet matplotlib genanki geopandas requests shapely
cd "$SCRIPT_DIR"
# Create preview images directory
mkdir -p "$PREVIEW_DIR"
python -m warsaw_bridges_anki --output warsaw_bridges.apkg --preview "$PREVIEW_DIR" --preview-count 5
echo
echo "Done! The Anki deck is at: $SCRIPT_DIR/warsaw_bridges.apkg"
echo "Preview images are in: $PREVIEW_DIR"

View File

@ -0,0 +1,318 @@
#!/usr/bin/env python3
"""Anki flashcard generator for Warsaw bridges over the Vistula.
Generates Anki-compatible flashcard decks with maps showing individual
Warsaw bridges highlighted on a city map.
"""
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
import genanki
import geopandas as gpd
import matplotlib.pyplot as plt
# Import shared data module
sys.path.insert(0, str(Path(__file__).parent.parent))
from geo_data import get_vistula_river, get_warsaw_bridges
if TYPE_CHECKING:
from collections.abc import Sequence
from matplotlib.figure import Figure
# Bridge color
BRIDGE_COLOR = "#E74C3C" # Red
RIVER_COLOR = "#3498DB" # Blue
def load_warsaw_boundary() -> gpd.GeoDataFrame:
"""Load Warsaw boundary from districts GeoJSON.
Returns:
GeoDataFrame with Warsaw boundary.
Raises:
FileNotFoundError: If boundary data file not found.
"""
districts_path = (
Path(__file__).parent.parent / "warsaw_districts" / "warszawa-dzielnice.geojson"
)
if districts_path.exists():
warsaw_gdf = gpd.read_file(districts_path)
warsaw_boundary = warsaw_gdf[warsaw_gdf["name"] == "Warszawa"]
if len(warsaw_boundary) == 0:
warsaw_boundary = gpd.GeoDataFrame(
geometry=[warsaw_gdf.union_all()], crs=warsaw_gdf.crs
)
return warsaw_boundary
msg = "Warsaw boundary data not found"
raise FileNotFoundError(msg)
def create_bridge_map(
bridge_gdf: gpd.GeoDataFrame,
warsaw_boundary: gpd.GeoDataFrame,
vistula: gpd.GeoDataFrame,
) -> Figure:
"""Create a map showing Warsaw with one bridge highlighted.
Args:
bridge_gdf: GeoDataFrame with the bridge to highlight.
warsaw_boundary: GeoDataFrame with Warsaw boundary.
vistula: GeoDataFrame with Vistula river geometry.
Returns:
Matplotlib figure with the map.
"""
fig, ax = plt.subplots(figsize=(10, 10))
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_alpha(0)
ax.patch.set_alpha(0)
# Plot Warsaw as a plain gray shape
warsaw_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
warsaw_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=2)
# Plot Vistula river
vistula.plot(ax=ax, color=RIVER_COLOR, linewidth=3, alpha=0.7)
# Plot the bridge
bridge_gdf.plot(ax=ax, color=BRIDGE_COLOR, linewidth=6, alpha=0.9)
# Set bounds to Warsaw
bounds = warsaw_boundary.total_bounds
ax.set_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3])
return fig
def generate_bridge_image_bytes(
bridge_gdf: gpd.GeoDataFrame,
warsaw_boundary: gpd.GeoDataFrame,
vistula: gpd.GeoDataFrame,
) -> bytes:
"""Generate a bridge map image as bytes.
Args:
bridge_gdf: GeoDataFrame with the bridge.
warsaw_boundary: GeoDataFrame with Warsaw boundary.
vistula: GeoDataFrame with Vistula river.
Returns:
PNG image bytes.
"""
fig = create_bridge_map(bridge_gdf, warsaw_boundary, vistula)
buf = BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.read()
def generate_anki_package(
bridges: gpd.GeoDataFrame,
warsaw_boundary: gpd.GeoDataFrame,
vistula: gpd.GeoDataFrame,
deck_name: str = "Warsaw Bridges",
) -> genanki.Package:
"""Generate Anki package for Warsaw bridges.
Args:
bridges: GeoDataFrame with all bridges.
warsaw_boundary: GeoDataFrame with Warsaw boundary.
vistula: GeoDataFrame with Vistula river.
deck_name: Name for the Anki deck.
Returns:
Generated Anki package.
"""
model_id_hash = hashlib.md5(f"warsaw_bridges_{deck_name}".encode()) # noqa: S324
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """
.card {
font-family: Arial, sans-serif;
font-size: 24px;
text-align: center;
color: #333;
background-color: #fff;
}
.card.night_mode {
color: #eee;
background-color: #2f2f2f;
}
.map-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.map-container img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
}
.answer-text {
font-size: 32px;
font-weight: bold;
margin-top: 20px;
color: #2C3E50;
}
.card.night_mode .answer-text {
color: #ECF0F1;
}
"""
my_model = genanki.Model(
model_id,
"Warsaw Bridge Model",
fields=[
{"name": "BridgeMap"},
{"name": "BridgeName"},
],
templates=[
{
"name": "Card 1",
"qfmt": '<div class="map-container">{{BridgeMap}}</div>',
"afmt": '<div class="map-container">{{BridgeMap}}</div>'
'<hr id="answer">'
'<div class="answer-text">{{BridgeName}}</div>',
},
],
css=card_css,
)
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
my_deck = genanki.Deck(deck_id, deck_name)
media_files = []
for _, row in bridges.iterrows():
bridge_name = row["name"]
bridge_gdf = gpd.GeoDataFrame([row], crs=bridges.crs)
image_data = generate_bridge_image_bytes(bridge_gdf, warsaw_boundary, vistula)
filename = f"bridge_{bridge_name.replace(' ', '_').replace('/', '_')}.png"
note = genanki.Note(
model=my_model,
fields=[f'<img src="{filename}">', bridge_name],
tags=["geography", "warsaw", "bridges"],
)
my_deck.add_note(note)
temp_path = Path(f"/tmp/{filename}") # noqa: S108
temp_path.write_bytes(image_data)
media_files.append(str(temp_path))
package = genanki.Package(my_deck)
package.media_files = media_files
return package
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 bridges.",
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path (default: warsaw_bridges.apkg)",
)
parser.add_argument(
"--deck-name",
"-d",
type=str,
default="Warsaw Bridges",
help="Name for the Anki deck",
)
parser.add_argument(
"--preview",
"-p",
type=str,
default=None,
help="Export preview images to specified directory",
)
parser.add_argument(
"--preview-count",
type=int,
default=5,
help="Number of preview images to export (default: 5)",
)
args = parser.parse_args(argv)
output_path = Path(args.output) if args.output else Path("warsaw_bridges.apkg")
try:
sys.stdout.write("Loading bridge data...\n")
bridges = get_warsaw_bridges()
vistula = get_vistula_river()
warsaw_boundary = load_warsaw_boundary()
num_bridges = len(bridges)
sys.stdout.write(f"Generating flashcards for {num_bridges} bridges...\n")
package = generate_anki_package(
bridges, warsaw_boundary, vistula, args.deck_name
)
package.write_to_file(str(output_path))
# Export preview images if requested
if args.preview:
preview_dir = Path(args.preview)
preview_dir.mkdir(parents=True, exist_ok=True)
preview_bridges = list(bridges.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_bridges)} preview images "
f"to {preview_dir}...\n"
)
for _, row in preview_bridges:
bridge_name = row["name"]
bridge_gdf = gpd.GeoDataFrame([row], crs=bridges.crs)
image_data = generate_bridge_image_bytes(
bridge_gdf, warsaw_boundary, vistula
)
safe_name = bridge_name.replace(" ", "_").replace("/", "_")
preview_path = preview_dir / f"{safe_name}.png"
preview_path.write_bytes(image_data)
sys.stdout.write(f" Saved: {preview_path.name}\n")
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"Bridges: {num_bridges}\n")
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
if args.preview:
sys.stdout.write(f"Preview images: {args.preview}\n")
except (OSError, ValueError, RuntimeError) as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,120 @@
# Warsaw Districts Anki Generator
Generate Anki flashcards for learning the 18 districts (dzielnice) of Warsaw, Poland.
## Features
- Generates flashcards for all 18 Warsaw districts
- **Uses real district boundaries from OpenStreetMap data**
- Front of card: Map showing the full city with only the target district's border highlighted in bold
- Back of card: District name in Polish
- Self-contained .apkg file with embedded images
- Compatible with AnkiWeb and AnkiDroid
## Data Source
District boundaries are sourced from [andilabs/warszawa-dzielnice-geojson](https://github.com/andilabs/warszawa-dzielnice-geojson), which provides accurate OpenStreetMap-based GeoJSON data for all Warsaw districts.
## Installation
Install dependencies using your preferred method:
### Using pyenv (recommended)
```bash
pyenv install 3.10 # or later
pyenv shell 3.10
pip install matplotlib genanki geopandas
```
### Using pipx
```bash
pipx install --python python3.10 matplotlib genanki geopandas
```
### Using system package manager (Arch Linux)
```bash
sudo pacman -S python-matplotlib python-geopandas
pip install genanki
```
### Using pip directly
```bash
pip install matplotlib genanki geopandas
```
## Usage
### Generate flashcards
```bash
# From the repository root
python -m python_pkg.anki_decks.warsaw_districts.warsaw_districts_anki
```
This creates:
- `warsaw_districts.apkg` - Self-contained Anki package with all images embedded
### Custom options
```bash
# Custom output file
python -m python_pkg.anki_decks.warsaw_districts.warsaw_districts_anki --output my_cards.apkg
# Custom deck name
python -m python_pkg.anki_decks.warsaw_districts.warsaw_districts_anki --deck-name "Warszawa - Dzielnice"
```
## Importing into Anki
1. Open Anki
2. File → 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
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
## 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.

View File

@ -0,0 +1,5 @@
"""Warsaw districts Anki flashcard generator."""
from __future__ import annotations
__all__ = ["generate_anki_deck", "main"]

View File

@ -0,0 +1,56 @@
#!/bin/bash
# Script to set up environment, install dependencies, and generate Warsaw Districts Anki deck
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_DIR="$SCRIPT_DIR/.venv"
PREVIEW_DIR="$SCRIPT_DIR/preview_images"
echo "=== Warsaw Districts Anki Generator ==="
echo
# Create virtual environment if it doesn't exist
if [ ! -d "$VENV_DIR" ]; then
echo "Creating virtual environment..."
python3 -m venv "$VENV_DIR"
fi
# Activate virtual environment
echo "Activating virtual environment..."
source "$VENV_DIR/bin/activate"
# Install dependencies
echo "Installing dependencies..."
pip install --quiet --upgrade pip
pip install --quiet matplotlib genanki geopandas
# Export preview images
echo
echo "Exporting preview images to $PREVIEW_DIR..."
mkdir -p "$PREVIEW_DIR"
cd "$SCRIPT_DIR"
python -c "
from warsaw_districts_anki import WARSAW_DISTRICTS, generate_district_image_bytes
from pathlib import Path
preview_dir = Path('$PREVIEW_DIR')
for district in WARSAW_DISTRICTS:
filename = district.replace(' ', '_').replace('-', '_') + '.png'
filepath = preview_dir / filename
filepath.write_bytes(generate_district_image_bytes(district))
print(f' Exported: {filename}')
"
echo
echo "Preview images exported! Check: $PREVIEW_DIR"
# Generate Anki deck
echo
echo "Generating Anki flashcards..."
python -m warsaw_districts_anki --output warsaw_districts.apkg
echo
echo "Done!"
echo " - Preview images: $PREVIEW_DIR"
echo " - Anki deck: $SCRIPT_DIR/warsaw_districts.apkg"

View File

@ -0,0 +1,5 @@
"""Tests init file."""
from __future__ import annotations
__all__: list[str] = []

View File

@ -0,0 +1,175 @@
"""Tests for the Warsaw districts Anki generator."""
from __future__ import annotations
from pathlib import Path
import matplotlib.pyplot as plt
import pytest
try:
from python_pkg.anki_decks.warsaw_districts.warsaw_districts_anki import (
WARSAW_DISTRICTS,
create_district_map,
generate_anki_package,
generate_district_image_bytes,
main,
)
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from python_pkg.anki_decks.warsaw_districts.warsaw_districts_anki import (
WARSAW_DISTRICTS,
create_district_map,
generate_anki_package,
generate_district_image_bytes,
main,
)
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_are_strings(self) -> None:
"""Test that all district entries are strings."""
for district in WARSAW_DISTRICTS:
assert isinstance(district, str)
assert len(district) > 0
def test_districts_are_unique(self) -> None:
"""Test that all district names are unique."""
assert len(WARSAW_DISTRICTS) == len(set(WARSAW_DISTRICTS))
def test_known_districts_present(self) -> None:
"""Test that all known Warsaw districts are in the list."""
district_set = set(WARSAW_DISTRICTS)
# Check all 18 districts
expected_districts = {
"Bemowo",
"Białołęka",
"Bielany",
"Mokotów",
"Ochota",
"Praga Południe", # Note: space, not hyphen
"Praga Północ", # Note: space, not hyphen
"Rembertów",
"Śródmieście",
"Targówek",
"Ursus",
"Ursynów",
"Wawer",
"Wesoła",
"Wilanów",
"Włochy",
"Wola",
"Żoliborz",
}
assert district_set == expected_districts
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
plt.close(fig)
def test_creates_figure_for_all_districts(self) -> None:
"""Test that we can create maps for all districts."""
for district in WARSAW_DISTRICTS:
fig = create_district_map(district)
assert fig is not None
plt.close(fig)
class TestGenerateDistrictImageBytes:
"""Tests for generating district image bytes."""
def test_generates_bytes(self) -> None:
"""Test that generate_district_image_bytes returns bytes."""
district = WARSAW_DISTRICTS[0]
image_bytes = generate_district_image_bytes(district)
assert isinstance(image_bytes, bytes)
assert len(image_bytes) > 0
def test_generates_for_all_districts(self) -> None:
"""Test that we can generate images for all districts."""
for district in WARSAW_DISTRICTS:
image_bytes = generate_district_image_bytes(district)
assert isinstance(image_bytes, bytes)
assert len(image_bytes) > 0
class TestGenerateAnkiPackage:
"""Tests for generating Anki package."""
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
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_custom_deck_name(self) -> None:
"""Test that custom deck name is used."""
package = generate_anki_package("Custom Deck")
deck = package.decks[0]
assert deck.name == "Custom Deck"
class TestMain:
"""Tests for the main CLI function."""
def test_creates_output_file(self, tmp_path: Path) -> None:
"""Test that main creates the output file."""
output_file = tmp_path / "test_output.apkg"
result = main(
[
"--output",
str(output_file),
]
)
assert result == 0
assert output_file.exists()
def test_custom_deck_name(self, tmp_path: Path) -> None:
"""Test that custom deck name is used."""
output_file = tmp_path / "test_output.apkg"
result = main(
[
"--output",
str(output_file),
"--deck-name",
"Custom Deck",
]
)
assert result == 0
assert output_file.exists()
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"])

View File

@ -0,0 +1,356 @@
#!/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 using real boundary data
from OpenStreetMap.
Usage:
# Generate Anki cards for all Warsaw districts
python -m python_pkg.anki_decks.warsaw_districts.warsaw_districts_anki
# Specify custom output file
python -m python_pkg.anki_decks.warsaw_districts.warsaw_districts_anki \
--output warsaw.apkg
Output:
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
import genanki
import geopandas as gpd
import matplotlib.pyplot as plt
if TYPE_CHECKING:
from collections.abc import Sequence
from matplotlib.figure import Figure
# Path to GeoJSON file with Warsaw district boundaries
GEOJSON_PATH = Path(__file__).parent / "warszawa-dzielnice.geojson"
def load_district_data() -> gpd.GeoDataFrame:
"""Load Warsaw district boundaries from GeoJSON.
Returns:
GeoDataFrame with district boundaries.
"""
if not GEOJSON_PATH.exists():
msg = f"GeoJSON file not found at {GEOJSON_PATH}"
raise FileNotFoundError(msg)
gdf = gpd.read_file(GEOJSON_PATH)
# Filter out the "Warszawa" entry (whole city) and keep only districts
return gdf[gdf["name"] != "Warszawa"].copy()
def get_district_names() -> list[str]:
"""Get list of all district names from GeoJSON data.
Returns:
Sorted list of district names.
"""
gdf = load_district_data()
return sorted(gdf["name"].tolist())
# Load district names from actual data
WARSAW_DISTRICTS = get_district_names()
# 18 unique distinct colors for all Warsaw districts
# Chosen to be visually distinct from each other in both light and dark modes
DISTRICT_COLORS = [
"#E74C3C", # Red
"#3498DB", # Blue
"#2ECC71", # Emerald green
"#9B59B6", # Purple
"#F39C12", # Orange
"#1ABC9C", # Turquoise
"#E91E63", # Pink
"#00BCD4", # Cyan
"#8BC34A", # Light green
"#FF5722", # Deep orange
"#673AB7", # Deep purple
"#FFEB3B", # Yellow
"#795548", # Brown
"#607D8B", # Blue grey
"#CDDC39", # Lime
"#FF9800", # Amber
"#4CAF50", # Green
"#03A9F4", # Light blue
]
def create_district_map(district_name: str) -> Figure:
"""Create a map showing Warsaw with one district highlighted.
The map shows Warsaw as a plain shape (no internal district borders)
with only the target district highlighted in color with a bold border.
This makes it harder to guess the district using contextual cues.
Args:
district_name: Name of the district to highlight.
Returns:
A matplotlib Figure object.
"""
# Load all district data
gdf = load_district_data()
# Create figure with transparent background
fig, ax = plt.subplots(figsize=(10, 10))
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_alpha(0)
ax.patch.set_alpha(0)
# Find the target district
target = gdf[gdf["name"] == district_name]
if len(target) == 0:
msg = f"District {district_name} not found in data"
raise ValueError(msg)
# Create unified Warsaw shape by dissolving all districts
warsaw_unified = gdf.union_all()
# Plot Warsaw as a plain gray shape (no internal borders)
warsaw_gdf = gpd.GeoDataFrame(geometry=[warsaw_unified], crs=gdf.crs)
warsaw_gdf.plot(ax=ax, color="#D5D8DC", alpha=0.6)
warsaw_gdf.boundary.plot(ax=ax, color="#2C3E50", linewidth=2)
# Assign colors to districts based on sorted names for consistency
sorted_names = sorted(gdf["name"].tolist())
color_map = {
name: DISTRICT_COLORS[i % len(DISTRICT_COLORS)]
for i, name in enumerate(sorted_names)
}
# Highlight only the target district with bright color and bold border
fill_color = color_map[district_name]
target.plot(ax=ax, color=fill_color, alpha=0.9)
target.boundary.plot(ax=ax, color="#1A1A1A", linewidth=4)
# Set tight layout
ax.set_xlim(gdf.total_bounds[0], gdf.total_bounds[2])
ax.set_ylim(gdf.total_bounds[1], gdf.total_bounds[3])
return fig
def generate_district_image_bytes(district_name: str) -> bytes:
"""Generate a district map image as bytes.
Args:
district_name: Name of the district to visualize.
Returns:
PNG image data as bytes.
"""
fig = create_district_map(district_name)
# Save to bytes buffer
buf = BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.read()
def generate_anki_package(
deck_name: str = "Warsaw Districts",
) -> genanki.Package:
"""Generate Anki package (.apkg) for Warsaw districts.
Args:
deck_name: Name for the Anki deck.
Returns:
genanki.Package object ready to be written to file.
"""
# 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)
# Define the note model (card template) with centered styling
card_css = """
.card {
font-family: Arial, sans-serif;
font-size: 24px;
text-align: center;
color: #333;
background-color: #fff;
}
.card.night_mode {
color: #eee;
background-color: #2f2f2f;
}
.map-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.map-container img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
}
.answer-text {
font-size: 32px;
font-weight: bold;
margin-top: 20px;
color: #2C3E50;
}
.card.night_mode .answer-text {
color: #ECF0F1;
}
"""
my_model = genanki.Model(
model_id,
"Warsaw District Model",
fields=[
{"name": "DistrictMap"},
{"name": "DistrictName"},
],
templates=[
{
"name": "Card 1",
"qfmt": '<div class="map-container">{{DistrictMap}}</div>',
"afmt": '<div class="map-container">{{DistrictMap}}</div>'
'<hr id="answer">'
'<div class="answer-text">{{DistrictName}}</div>',
},
],
css=card_css,
)
# 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_name in WARSAW_DISTRICTS:
# Generate image
image_data = generate_district_image_bytes(district_name)
# Create unique filename
filename = f"{district_name.replace(' ', '_').replace('-', '_')}.png"
# Create note
note = genanki.Note(
model=my_model,
fields=[
f'<img src="{filename}">',
district_name,
],
tags=["geography", "warsaw", "poland"],
)
my_deck.add_note(note)
# 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:
"""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.apkg)",
)
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 path
output_path = Path(args.output) if args.output else Path("warsaw_districts.apkg")
try:
num_districts = len(WARSAW_DISTRICTS)
sys.stdout.write(
f"Generating flashcards for {num_districts} Warsaw districts...\n"
)
sys.stdout.write("Using real district boundaries from OpenStreetMap data.\n")
# Generate the package
package = generate_anki_package(args.deck_name)
# Write to file
package.write_to_file(str(output_path))
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
if __name__ == "__main__":
sys.exit(main())

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
"""Warsaw landmarks Anki flashcard generator."""

View File

@ -0,0 +1,34 @@
#!/bin/bash
# Script to generate Warsaw Landmarks Anki deck
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_DIR="$SCRIPT_DIR/.venv"
PREVIEW_DIR="$SCRIPT_DIR/preview_images"
echo "=== Warsaw Landmarks Anki Generator ==="
echo
if [ ! -d "$VENV_DIR" ]; then
echo "Creating virtual environment..."
python3 -m venv "$VENV_DIR"
fi
echo "Activating virtual environment..."
source "$VENV_DIR/bin/activate"
echo "Installing dependencies..."
pip install --quiet --upgrade pip
pip install --quiet matplotlib genanki geopandas requests shapely
cd "$SCRIPT_DIR"
# Create preview images directory
mkdir -p "$PREVIEW_DIR"
python -m warsaw_landmarks_anki --output warsaw_landmarks.apkg --preview "$PREVIEW_DIR" --preview-count 5
echo
echo "Done! The Anki deck is at: $SCRIPT_DIR/warsaw_landmarks.apkg"
echo "Preview images are in: $PREVIEW_DIR"

View File

@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""Anki flashcard generator for Warsaw landmarks.
Generates Anki-compatible flashcard decks with maps showing individual
Warsaw landmarks (monuments, museums, parks, historic sites).
"""
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
sys.path.insert(0, str(Path(__file__).parent.parent))
import genanki
from geo_data import get_warsaw_landmarks
import geopandas as gpd
import matplotlib.pyplot as plt
if TYPE_CHECKING:
from collections.abc import Sequence
from matplotlib.figure import Figure
# Landmark marker color
LANDMARK_COLOR = "#9B59B6" # Purple
def load_warsaw_boundary() -> gpd.GeoDataFrame:
"""Load Warsaw boundary from districts GeoJSON."""
districts_path = (
Path(__file__).parent.parent / "warsaw_districts" / "warszawa-dzielnice.geojson"
)
if districts_path.exists():
warsaw_gdf = gpd.read_file(districts_path)
warsaw_boundary = warsaw_gdf[warsaw_gdf["name"] == "Warszawa"]
if len(warsaw_boundary) == 0:
warsaw_boundary = gpd.GeoDataFrame(
geometry=[warsaw_gdf.union_all()], crs=warsaw_gdf.crs
)
return warsaw_boundary
msg = "Warsaw boundary data not found"
raise FileNotFoundError(msg)
def create_landmark_map(
landmark_gdf: gpd.GeoDataFrame,
warsaw_boundary: gpd.GeoDataFrame,
) -> Figure:
"""Create a map showing Warsaw with one landmark highlighted."""
fig, ax = plt.subplots(figsize=(10, 10))
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_alpha(0)
ax.patch.set_alpha(0)
# Plot Warsaw as a plain gray shape
warsaw_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
warsaw_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=2)
# Plot the landmark as a star marker
landmark_gdf.plot(
ax=ax,
color=LANDMARK_COLOR,
markersize=400,
marker="*",
alpha=0.9,
edgecolor="#1A1A1A",
linewidth=1.5,
)
# Set bounds to Warsaw
bounds = warsaw_boundary.total_bounds
ax.set_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3])
return fig
def generate_landmark_image_bytes(
landmark_gdf: gpd.GeoDataFrame,
warsaw_boundary: gpd.GeoDataFrame,
) -> bytes:
"""Generate a landmark map image as bytes."""
fig = create_landmark_map(landmark_gdf, warsaw_boundary)
buf = BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.read()
def generate_anki_package(
landmarks: gpd.GeoDataFrame,
warsaw_boundary: gpd.GeoDataFrame,
deck_name: str = "Warsaw Landmarks",
) -> genanki.Package:
"""Generate Anki package for Warsaw landmarks."""
model_id_hash = hashlib.md5(f"warsaw_landmarks_{deck_name}".encode()) # noqa: S324
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """
.card {
font-family: Arial, sans-serif;
font-size: 24px;
text-align: center;
color: #333;
background-color: #fff;
}
.card.night_mode {
color: #eee;
background-color: #2f2f2f;
}
.map-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.map-container img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
}
.answer-text {
font-size: 32px;
font-weight: bold;
margin-top: 20px;
color: #2C3E50;
}
.card.night_mode .answer-text {
color: #ECF0F1;
}
"""
my_model = genanki.Model(
model_id,
"Warsaw Landmark Model",
fields=[
{"name": "LandmarkMap"},
{"name": "LandmarkName"},
],
templates=[
{
"name": "Card 1",
"qfmt": '<div class="map-container">{{LandmarkMap}}</div>',
"afmt": '<div class="map-container">{{LandmarkMap}}</div>'
'<hr id="answer">'
'<div class="answer-text">{{LandmarkName}}</div>',
},
],
css=card_css,
)
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
my_deck = genanki.Deck(deck_id, deck_name)
media_files = []
for _, row in landmarks.iterrows():
landmark_name = row["name"]
landmark_gdf = gpd.GeoDataFrame([row], crs=landmarks.crs)
image_data = generate_landmark_image_bytes(landmark_gdf, warsaw_boundary)
filename = f"landmark_{landmark_name.replace(' ', '_').replace('/', '_')}.png"
note = genanki.Note(
model=my_model,
fields=[f'<img src="{filename}">', landmark_name],
tags=["geography", "warsaw", "landmarks"],
)
my_deck.add_note(note)
temp_path = Path(f"/tmp/{filename}") # noqa: S108
temp_path.write_bytes(image_data)
media_files.append(str(temp_path))
package = genanki.Package(my_deck)
package.media_files = media_files
return package
def main(argv: Sequence[str] | None = None) -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate Anki flashcards for Warsaw landmarks.",
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path (default: warsaw_landmarks.apkg)",
)
parser.add_argument(
"--deck-name",
"-d",
type=str,
default="Warsaw Landmarks",
help="Name for the Anki deck",
)
parser.add_argument(
"--preview",
"-p",
type=str,
default=None,
help="Export preview images to specified directory",
)
parser.add_argument(
"--preview-count",
type=int,
default=5,
help="Number of preview images to export (default: 5)",
)
args = parser.parse_args(argv)
output_path = Path(args.output) if args.output else Path("warsaw_landmarks.apkg")
try:
sys.stdout.write("Loading landmark data...\n")
landmarks = get_warsaw_landmarks()
warsaw_boundary = load_warsaw_boundary()
num_landmarks = len(landmarks)
sys.stdout.write(f"Generating flashcards for {num_landmarks} landmarks...\n")
package = generate_anki_package(landmarks, warsaw_boundary, args.deck_name)
package.write_to_file(str(output_path))
# Export preview images if requested
if args.preview:
preview_dir = Path(args.preview)
preview_dir.mkdir(parents=True, exist_ok=True)
preview_landmarks = list(landmarks.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_landmarks)} preview images "
f"to {preview_dir}...\n"
)
for _, row in preview_landmarks:
landmark_name = row["name"]
landmark_gdf = gpd.GeoDataFrame([row], crs=landmarks.crs)
image_data = generate_landmark_image_bytes(
landmark_gdf, warsaw_boundary
)
safe_name = landmark_name.replace(" ", "_").replace("/", "_")
preview_path = preview_dir / f"{safe_name}.png"
preview_path.write_bytes(image_data)
sys.stdout.write(f" Saved: {preview_path.name}\n")
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"Landmarks: {num_landmarks}\n")
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
if args.preview:
sys.stdout.write(f"Preview images: {args.preview}\n")
except (OSError, ValueError, RuntimeError) as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1 @@
"""Warsaw metro stations Anki flashcard generator."""

View File

@ -0,0 +1,287 @@
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"name": "Ratusz-Arsena\u0142",
"line": "",
"osm_id": 35121250
},
"geometry": { "type": "Point", "coordinates": [21.0008823, 52.2452163] }
},
{
"type": "Feature",
"properties": { "name": "Marymont", "line": "", "osm_id": 291414851 },
"geometry": { "type": "Point", "coordinates": [20.9719399, 52.2715768] }
},
{
"type": "Feature",
"properties": {
"name": "Stare Bielany",
"line": "",
"osm_id": 307389114
},
"geometry": { "type": "Point", "coordinates": [20.9493511, 52.2818277] }
},
{
"type": "Feature",
"properties": { "name": "Wawrzyszew", "line": "", "osm_id": 307389115 },
"geometry": { "type": "Point", "coordinates": [20.939515, 52.2863474] }
},
{
"type": "Feature",
"properties": { "name": "M\u0142ociny", "line": "", "osm_id": 307389116 },
"geometry": { "type": "Point", "coordinates": [20.9298678, 52.2907703] }
},
{
"type": "Feature",
"properties": {
"name": "S\u0142odowiec",
"line": "",
"osm_id": 1958108834
},
"geometry": { "type": "Point", "coordinates": [20.9601259, 52.2768261] }
},
{
"type": "Feature",
"properties": {
"name": "Dworzec Wile\u0144ski",
"line": "",
"osm_id": 3390187815
},
"geometry": { "type": "Point", "coordinates": [21.0357972, 52.2537771] }
},
{
"type": "Feature",
"properties": {
"name": "Stadion Narodowy",
"line": "",
"osm_id": 3390197100
},
"geometry": { "type": "Point", "coordinates": [21.042847, 52.2468346] }
},
{
"type": "Feature",
"properties": {
"name": "Centrum Nauki Kopernik",
"line": "",
"osm_id": 3390230685
},
"geometry": { "type": "Point", "coordinates": [21.0317878, 52.2399148] }
},
{
"type": "Feature",
"properties": {
"name": "Nowy \u015awiat-Uniwersytet",
"line": "",
"osm_id": 3390237898
},
"geometry": { "type": "Point", "coordinates": [21.0168168, 52.2368196] }
},
{
"type": "Feature",
"properties": { "name": "Rondo ONZ", "line": "", "osm_id": 3390266492 },
"geometry": { "type": "Point", "coordinates": [20.9981024, 52.2330735] }
},
{
"type": "Feature",
"properties": {
"name": "Rondo Daszy\u0144skiego",
"line": "",
"osm_id": 3390267294
},
"geometry": { "type": "Point", "coordinates": [20.9828946, 52.2300827] }
},
{
"type": "Feature",
"properties": { "name": "Kabaty", "line": "", "osm_id": 3390283399 },
"geometry": { "type": "Point", "coordinates": [21.0650711, 52.1320765] }
},
{
"type": "Feature",
"properties": {
"name": "Stok\u0142osy",
"line": "",
"osm_id": 3390289961
},
"geometry": { "type": "Point", "coordinates": [21.0347233, 52.1560759] }
},
{
"type": "Feature",
"properties": { "name": "Natolin", "line": "", "osm_id": 3390290324 },
"geometry": { "type": "Point", "coordinates": [21.0564351, 52.1411007] }
},
{
"type": "Feature",
"properties": { "name": "Imielin", "line": "", "osm_id": 3390290598 },
"geometry": { "type": "Point", "coordinates": [21.0461062, 52.1493] }
},
{
"type": "Feature",
"properties": {
"name": "Ursyn\u00f3w",
"line": "",
"osm_id": 3390304927
},
"geometry": { "type": "Point", "coordinates": [21.0276283, 52.1620456] }
},
{
"type": "Feature",
"properties": {
"name": "S\u0142u\u017cew",
"line": "",
"osm_id": 3390306374
},
"geometry": { "type": "Point", "coordinates": [21.0262866, 52.1727624] }
},
{
"type": "Feature",
"properties": {
"name": "Rac\u0142awicka",
"line": "",
"osm_id": 3390327068
},
"geometry": { "type": "Point", "coordinates": [21.0122349, 52.1988637] }
},
{
"type": "Feature",
"properties": { "name": "Wierzbno", "line": "", "osm_id": 3390333313 },
"geometry": { "type": "Point", "coordinates": [21.0167966, 52.1898719] }
},
{
"type": "Feature",
"properties": { "name": "Wilanowska", "line": "", "osm_id": 3390336021 },
"geometry": { "type": "Point", "coordinates": [21.0231452, 52.1818168] }
},
{
"type": "Feature",
"properties": {
"name": "Pole Mokotowskie",
"line": "",
"osm_id": 3390342469
},
"geometry": { "type": "Point", "coordinates": [21.0079298, 52.2087775] }
},
{
"type": "Feature",
"properties": {
"name": "Politechnika",
"line": "",
"osm_id": 3390361095
},
"geometry": { "type": "Point", "coordinates": [21.0153031, 52.2186581] }
},
{
"type": "Feature",
"properties": { "name": "Centrum", "line": "", "osm_id": 3390365405 },
"geometry": { "type": "Point", "coordinates": [21.010186, 52.2310069] }
},
{
"type": "Feature",
"properties": {
"name": "Dworzec Gda\u0144ski",
"line": "",
"osm_id": 3416840991
},
"geometry": { "type": "Point", "coordinates": [20.9941857, 52.2580586] }
},
{
"type": "Feature",
"properties": {
"name": "Plac Wilsona",
"line": "",
"osm_id": 3615569910
},
"geometry": { "type": "Point", "coordinates": [20.9844973, 52.2692619] }
},
{
"type": "Feature",
"properties": { "name": "P\u0142ocka", "line": "", "osm_id": 4930657482 },
"geometry": { "type": "Point", "coordinates": [20.9663847, 52.2324542] }
},
{
"type": "Feature",
"properties": { "name": "Trocka", "line": "", "osm_id": 4930657487 },
"geometry": { "type": "Point", "coordinates": [21.0550586, 52.2751021] }
},
{
"type": "Feature",
"properties": {
"name": "\u015awi\u0119tokrzyska",
"line": "",
"osm_id": 5117464830
},
"geometry": { "type": "Point", "coordinates": [21.0078988, 52.2350954] }
},
{
"type": "Feature",
"properties": {
"name": "Ksi\u0119cia Janusza",
"line": "",
"osm_id": 5907821373
},
"geometry": { "type": "Point", "coordinates": [20.9443771, 52.2391822] }
},
{
"type": "Feature",
"properties": {
"name": "M\u0142yn\u00f3w",
"line": "",
"osm_id": 5907821374
},
"geometry": { "type": "Point", "coordinates": [20.9601048, 52.2376624] }
},
{
"type": "Feature",
"properties": { "name": "Szwedzka", "line": "", "osm_id": 6053348692 },
"geometry": { "type": "Point", "coordinates": [21.0455232, 52.2634709] }
},
{
"type": "Feature",
"properties": {
"name": "Targ\u00f3wek Mieszkaniowy",
"line": "",
"osm_id": 6564265270
},
"geometry": { "type": "Point", "coordinates": [21.0513658, 52.2692518] }
},
{
"type": "Feature",
"properties": { "name": "Bemowo", "line": "", "osm_id": 7362922676 },
"geometry": { "type": "Point", "coordinates": [20.9154991, 52.2392071] }
},
{
"type": "Feature",
"properties": {
"name": "Ulrych\u00f3w",
"line": "",
"osm_id": 7362922677
},
"geometry": { "type": "Point", "coordinates": [20.9298652, 52.2403314] }
},
{
"type": "Feature",
"properties": {
"name": "Br\u00f3dno",
"line": "",
"osm_id": 10058224345
},
"geometry": { "type": "Point", "coordinates": [21.0289387, 52.293585] }
},
{
"type": "Feature",
"properties": {
"name": "Kondratowicza",
"line": "",
"osm_id": 10058224348
},
"geometry": { "type": "Point", "coordinates": [21.0486889, 52.2920848] }
},
{
"type": "Feature",
"properties": { "name": "Zacisze", "line": "", "osm_id": 10058224349 },
"geometry": { "type": "Point", "coordinates": [21.062148, 52.2837496] }
}
]
}

View File

@ -0,0 +1,34 @@
#!/bin/bash
# Script to generate Warsaw Metro Anki deck
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_DIR="$SCRIPT_DIR/.venv"
PREVIEW_DIR="$SCRIPT_DIR/preview_images"
echo "=== Warsaw Metro Stations Anki Generator ==="
echo
if [ ! -d "$VENV_DIR" ]; then
echo "Creating virtual environment..."
python3 -m venv "$VENV_DIR"
fi
echo "Activating virtual environment..."
source "$VENV_DIR/bin/activate"
echo "Installing dependencies..."
pip install --quiet --upgrade pip
pip install --quiet matplotlib genanki geopandas requests shapely
cd "$SCRIPT_DIR"
# Create preview images directory
mkdir -p "$PREVIEW_DIR"
python -m warsaw_metro_anki --output warsaw_metro.apkg --preview "$PREVIEW_DIR" --preview-count 5
echo
echo "Done! The Anki deck is at: $SCRIPT_DIR/warsaw_metro.apkg"
echo "Preview images are in: $PREVIEW_DIR"

View File

@ -0,0 +1,295 @@
#!/usr/bin/env python3
"""Anki flashcard generator for Warsaw metro stations.
Generates Anki-compatible flashcard decks with maps showing individual
Warsaw metro stations highlighted on a city map.
"""
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
sys.path.insert(0, str(Path(__file__).parent.parent))
import genanki
from geo_data import get_warsaw_metro_stations
import geopandas as gpd
import matplotlib.pyplot as plt
if TYPE_CHECKING:
from collections.abc import Sequence
from matplotlib.figure import Figure
# Station marker color
STATION_COLOR = "#E74C3C"
def load_warsaw_boundary() -> gpd.GeoDataFrame:
"""Load Warsaw boundary from districts GeoJSON.
Returns:
GeoDataFrame with Warsaw boundary.
"""
districts_path = (
Path(__file__).parent.parent / "warsaw_districts" / "warszawa-dzielnice.geojson"
)
if districts_path.exists():
warsaw_gdf = gpd.read_file(districts_path)
warsaw_boundary = warsaw_gdf[warsaw_gdf["name"] == "Warszawa"]
if len(warsaw_boundary) == 0:
warsaw_boundary = gpd.GeoDataFrame(
geometry=[warsaw_gdf.union_all()], crs=warsaw_gdf.crs
)
return warsaw_boundary
msg = "Warsaw boundary data not found"
raise FileNotFoundError(msg)
def create_station_map(
station_gdf: gpd.GeoDataFrame,
warsaw_boundary: gpd.GeoDataFrame,
) -> Figure:
"""Create a map showing Warsaw with one metro station highlighted.
Args:
station_gdf: GeoDataFrame with the station point.
warsaw_boundary: GeoDataFrame with Warsaw boundary.
Returns:
A matplotlib Figure object.
"""
fig, ax = plt.subplots(figsize=(10, 10))
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_alpha(0)
ax.patch.set_alpha(0)
# Plot Warsaw as a plain gray shape
warsaw_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
warsaw_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=2)
# Plot the station as a large dot
station_gdf.plot(
ax=ax,
color=STATION_COLOR,
markersize=300,
marker="o",
alpha=0.9,
edgecolor="#1A1A1A",
linewidth=2,
)
# Set bounds to Warsaw
bounds = warsaw_boundary.total_bounds
ax.set_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3])
return fig
def generate_station_image_bytes(
station_gdf: gpd.GeoDataFrame,
warsaw_boundary: gpd.GeoDataFrame,
) -> bytes:
"""Generate a station map image as bytes."""
fig = create_station_map(station_gdf, warsaw_boundary)
buf = BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.read()
def generate_anki_package(
stations: gpd.GeoDataFrame,
warsaw_boundary: gpd.GeoDataFrame,
deck_name: str = "Warsaw Metro Stations",
) -> genanki.Package:
"""Generate Anki package for Warsaw metro stations."""
model_id_hash = hashlib.md5(f"warsaw_metro_{deck_name}".encode()) # noqa: S324
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """
.card {
font-family: Arial, sans-serif;
font-size: 24px;
text-align: center;
color: #333;
background-color: #fff;
}
.card.night_mode {
color: #eee;
background-color: #2f2f2f;
}
.map-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.map-container img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
}
.answer-text {
font-size: 32px;
font-weight: bold;
margin-top: 20px;
color: #2C3E50;
}
.line-info {
font-size: 24px;
margin-top: 10px;
color: #666;
}
.card.night_mode .answer-text {
color: #ECF0F1;
}
.card.night_mode .line-info {
color: #AAA;
}
"""
my_model = genanki.Model(
model_id,
"Warsaw Metro Model",
fields=[
{"name": "StationMap"},
{"name": "StationName"},
{"name": "Line"},
],
templates=[
{
"name": "Card 1",
"qfmt": '<div class="map-container">{{StationMap}}</div>',
"afmt": '<div class="map-container">{{StationMap}}</div>'
'<hr id="answer">'
'<div class="answer-text">{{StationName}}</div>'
'<div class="line-info">{{Line}}</div>',
},
],
css=card_css,
)
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
my_deck = genanki.Deck(deck_id, deck_name)
media_files = []
for _, row in stations.iterrows():
station_name = row["name"]
line = row.get("line", "")
station_gdf = gpd.GeoDataFrame([row], crs=stations.crs)
image_data = generate_station_image_bytes(station_gdf, warsaw_boundary)
filename = f"metro_{station_name.replace(' ', '_').replace('/', '_')}.png"
note = genanki.Note(
model=my_model,
fields=[f'<img src="{filename}">', station_name, line],
tags=["geography", "warsaw", "metro"],
)
my_deck.add_note(note)
temp_path = Path(f"/tmp/{filename}") # noqa: S108
temp_path.write_bytes(image_data)
media_files.append(str(temp_path))
package = genanki.Package(my_deck)
package.media_files = media_files
return package
def main(argv: Sequence[str] | None = None) -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate Anki flashcards for Warsaw metro stations.",
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path (default: warsaw_metro.apkg)",
)
parser.add_argument(
"--deck-name",
"-d",
type=str,
default="Warsaw Metro Stations",
help="Name for the Anki deck",
)
parser.add_argument(
"--preview",
"-p",
type=str,
default=None,
help="Export preview images to specified directory",
)
parser.add_argument(
"--preview-count",
type=int,
default=5,
help="Number of preview images to export (default: 5)",
)
args = parser.parse_args(argv)
output_path = Path(args.output) if args.output else Path("warsaw_metro.apkg")
try:
sys.stdout.write("Loading metro station data...\n")
stations = get_warsaw_metro_stations()
warsaw_boundary = load_warsaw_boundary()
num_stations = len(stations)
sys.stdout.write(
f"Generating flashcards for {num_stations} metro stations...\n"
)
package = generate_anki_package(stations, warsaw_boundary, args.deck_name)
package.write_to_file(str(output_path))
# Export preview images if requested
if args.preview:
preview_dir = Path(args.preview)
preview_dir.mkdir(parents=True, exist_ok=True)
preview_stations = list(stations.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_stations)} preview images "
f"to {preview_dir}...\n"
)
for _, row in preview_stations:
station_name = row["name"]
station_gdf = gpd.GeoDataFrame([row], crs=stations.crs)
image_data = generate_station_image_bytes(station_gdf, warsaw_boundary)
safe_name = station_name.replace(" ", "_").replace("/", "_")
preview_path = preview_dir / f"{safe_name}.png"
preview_path.write_bytes(image_data)
sys.stdout.write(f" Saved: {preview_path.name}\n")
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"Stations: {num_stations}\n")
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
if args.preview:
sys.stdout.write(f"Preview images: {args.preview}\n")
except (OSError, ValueError, RuntimeError) as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1 @@
"""Warsaw osiedla (neighborhoods) Anki flashcard generator."""

View File

@ -0,0 +1,34 @@
#!/bin/bash
# Script to generate Warsaw Osiedla Anki deck
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_DIR="$SCRIPT_DIR/.venv"
PREVIEW_DIR="$SCRIPT_DIR/preview_images"
echo "=== Warsaw Osiedla Anki Generator ==="
echo
if [ ! -d "$VENV_DIR" ]; then
echo "Creating virtual environment..."
python3 -m venv "$VENV_DIR"
fi
echo "Activating virtual environment..."
source "$VENV_DIR/bin/activate"
echo "Installing dependencies..."
pip install --quiet --upgrade pip
pip install --quiet matplotlib genanki geopandas requests shapely
cd "$SCRIPT_DIR"
# Create preview images directory
mkdir -p "$PREVIEW_DIR"
python -m warsaw_osiedla_anki --output warsaw_osiedla.apkg --preview "$PREVIEW_DIR" --preview-count 5
echo
echo "Done! The Anki deck is at: $SCRIPT_DIR/warsaw_osiedla.apkg"
echo "Preview images are in: $PREVIEW_DIR"

View File

@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""Anki flashcard generator for Warsaw osiedla (neighborhoods).
Generates Anki-compatible flashcard decks with maps showing individual
Warsaw neighborhoods highlighted on a city map.
"""
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
sys.path.insert(0, str(Path(__file__).parent.parent))
import genanki
from geo_data import get_warsaw_osiedla
import geopandas as gpd
import matplotlib.pyplot as plt
if TYPE_CHECKING:
from collections.abc import Sequence
from matplotlib.figure import Figure
# 50 unique colors for neighborhoods
OSIEDLE_COLORS = [
"#E74C3C",
"#3498DB",
"#2ECC71",
"#9B59B6",
"#F39C12",
"#1ABC9C",
"#E91E63",
"#00BCD4",
"#8BC34A",
"#FF5722",
"#673AB7",
"#FFEB3B",
"#795548",
"#607D8B",
"#CDDC39",
"#FF9800",
"#4CAF50",
"#03A9F4",
"#F44336",
"#009688",
"#3F51B5",
"#FFC107",
"#9E9E9E",
"#00E676",
"#FF4081",
"#448AFF",
"#69F0AE",
"#FFD740",
"#40C4FF",
"#B388FF",
"#EA80FC",
"#82B1FF",
"#A7FFEB",
"#FFFF8D",
"#FF80AB",
"#536DFE",
"#64FFDA",
"#FFE57F",
"#80D8FF",
"#B9F6CA",
"#CF6679",
"#BB86FC",
"#03DAC6",
"#018786",
"#6200EE",
"#3700B3",
"#B00020",
"#FF0266",
"#C51162",
"#AA00FF",
]
def load_warsaw_boundary() -> gpd.GeoDataFrame:
"""Load Warsaw boundary from districts GeoJSON."""
districts_path = (
Path(__file__).parent.parent / "warsaw_districts" / "warszawa-dzielnice.geojson"
)
if districts_path.exists():
warsaw_gdf = gpd.read_file(districts_path)
warsaw_boundary = warsaw_gdf[warsaw_gdf["name"] == "Warszawa"]
if len(warsaw_boundary) == 0:
warsaw_boundary = gpd.GeoDataFrame(
geometry=[warsaw_gdf.union_all()], crs=warsaw_gdf.crs
)
return warsaw_boundary
msg = "Warsaw boundary data not found"
raise FileNotFoundError(msg)
def create_osiedle_map(
osiedle_name: str,
osiedle_gdf: gpd.GeoDataFrame,
warsaw_boundary: gpd.GeoDataFrame,
all_osiedla: gpd.GeoDataFrame,
) -> Figure:
"""Create a map showing Warsaw with one osiedle highlighted."""
fig, ax = plt.subplots(figsize=(10, 10))
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_alpha(0)
ax.patch.set_alpha(0)
# Plot Warsaw as a plain gray shape
warsaw_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
warsaw_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=2)
# Assign color based on sorted names
sorted_names = sorted(all_osiedla["name"].tolist())
color_idx = sorted_names.index(osiedle_name) % len(OSIEDLE_COLORS)
fill_color = OSIEDLE_COLORS[color_idx]
# Plot the highlighted osiedle
osiedle_gdf.plot(ax=ax, color=fill_color, alpha=0.9)
osiedle_gdf.boundary.plot(ax=ax, color="#1A1A1A", linewidth=4)
# Set bounds to Warsaw
bounds = warsaw_boundary.total_bounds
ax.set_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3])
return fig
def generate_osiedle_image_bytes(
osiedle_name: str,
osiedle_gdf: gpd.GeoDataFrame,
warsaw_boundary: gpd.GeoDataFrame,
all_osiedla: gpd.GeoDataFrame,
) -> bytes:
"""Generate an osiedle map image as bytes."""
fig = create_osiedle_map(osiedle_name, osiedle_gdf, warsaw_boundary, all_osiedla)
buf = BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.read()
def generate_anki_package(
osiedla: gpd.GeoDataFrame,
warsaw_boundary: gpd.GeoDataFrame,
deck_name: str = "Warsaw Osiedla",
) -> genanki.Package:
"""Generate Anki package for Warsaw osiedla."""
model_id_hash = hashlib.md5(f"warsaw_osiedla_{deck_name}".encode()) # noqa: S324
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """
.card {
font-family: Arial, sans-serif;
font-size: 24px;
text-align: center;
color: #333;
background-color: #fff;
}
.card.night_mode {
color: #eee;
background-color: #2f2f2f;
}
.map-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.map-container img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
}
.answer-text {
font-size: 32px;
font-weight: bold;
margin-top: 20px;
color: #2C3E50;
}
.card.night_mode .answer-text {
color: #ECF0F1;
}
"""
my_model = genanki.Model(
model_id,
"Warsaw Osiedle Model",
fields=[
{"name": "OsiedleMap"},
{"name": "OsiedleName"},
],
templates=[
{
"name": "Card 1",
"qfmt": '<div class="map-container">{{OsiedleMap}}</div>',
"afmt": '<div class="map-container">{{OsiedleMap}}</div>'
'<hr id="answer">'
'<div class="answer-text">{{OsiedleName}}</div>',
},
],
css=card_css,
)
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
my_deck = genanki.Deck(deck_id, deck_name)
media_files = []
for _, row in osiedla.iterrows():
osiedle_name = row["name"]
osiedle_gdf = gpd.GeoDataFrame([row], crs=osiedla.crs)
image_data = generate_osiedle_image_bytes(
osiedle_name, osiedle_gdf, warsaw_boundary, osiedla
)
filename = f"osiedle_{osiedle_name.replace(' ', '_').replace('/', '_')}.png"
note = genanki.Note(
model=my_model,
fields=[f'<img src="{filename}">', osiedle_name],
tags=["geography", "warsaw", "osiedla"],
)
my_deck.add_note(note)
temp_path = Path(f"/tmp/{filename}") # noqa: S108
temp_path.write_bytes(image_data)
media_files.append(str(temp_path))
package = genanki.Package(my_deck)
package.media_files = media_files
return package
def main(argv: Sequence[str] | None = None) -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate Anki flashcards for Warsaw osiedla.",
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path (default: warsaw_osiedla.apkg)",
)
parser.add_argument(
"--deck-name",
"-d",
type=str,
default="Warsaw Osiedla",
help="Name for the Anki deck",
)
parser.add_argument(
"--preview",
"-p",
type=str,
default=None,
help="Export preview images to specified directory",
)
parser.add_argument(
"--preview-count",
type=int,
default=5,
help="Number of preview images to export (default: 5)",
)
args = parser.parse_args(argv)
output_path = Path(args.output) if args.output else Path("warsaw_osiedla.apkg")
try:
sys.stdout.write("Loading osiedla data...\n")
osiedla = get_warsaw_osiedla()
warsaw_boundary = load_warsaw_boundary()
num_osiedla = len(osiedla)
sys.stdout.write(f"Generating flashcards for {num_osiedla} osiedla...\n")
package = generate_anki_package(osiedla, warsaw_boundary, args.deck_name)
package.write_to_file(str(output_path))
# Export preview images if requested
if args.preview:
preview_dir = Path(args.preview)
preview_dir.mkdir(parents=True, exist_ok=True)
preview_osiedla = list(osiedla.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_osiedla)} preview images "
f"to {preview_dir}...\n"
)
for _, row in preview_osiedla:
osiedle_name = row["name"]
osiedle_gdf = gpd.GeoDataFrame([row], crs=osiedla.crs)
image_data = generate_osiedle_image_bytes(
osiedle_name, osiedle_gdf, warsaw_boundary, osiedla
)
safe_name = osiedle_name.replace(" ", "_").replace("/", "_")
preview_path = preview_dir / f"{safe_name}.png"
preview_path.write_bytes(image_data)
sys.stdout.write(f" Saved: {preview_path.name}\n")
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"Osiedla: {num_osiedla}\n")
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
if args.preview:
sys.stdout.write(f"Preview images: {args.preview}\n")
except (OSError, ValueError, RuntimeError) as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,37 @@
# Warsaw Streets Anki Generator
Generate Anki flashcards for learning major Warsaw streets.
## Features
- Generates flashcards for major Warsaw streets (primary, secondary, tertiary roads)
- Uses real street data from OpenStreetMap
- Front of card: Map showing Warsaw with the street highlighted
- Back of card: Street name in Polish
- Self-contained .apkg file with embedded images
## Data Source
Street data is fetched from OpenStreetMap via the Overpass API.
## Installation
```bash
pip install matplotlib genanki geopandas requests shapely
```
## Usage
```bash
# Generate flashcards (fetches data from OSM)
./run.sh
# Or run directly
python -m warsaw_streets_anki
```
## Notes
- Only includes named streets tagged as primary, secondary, or tertiary highways
- Streets are filtered to remove duplicates and very short segments
- The first run will download data from Overpass API (may take a minute)

View File

@ -0,0 +1 @@
"""Warsaw streets Anki flashcard generator."""

View File

@ -0,0 +1,34 @@
#!/bin/bash
# Script to generate Warsaw Streets Anki deck
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_DIR="$SCRIPT_DIR/.venv"
PREVIEW_DIR="$SCRIPT_DIR/preview_images"
echo "=== Warsaw Streets Anki Generator ==="
echo
if [ ! -d "$VENV_DIR" ]; then
echo "Creating virtual environment..."
python3 -m venv "$VENV_DIR"
fi
echo "Activating virtual environment..."
source "$VENV_DIR/bin/activate"
echo "Installing dependencies..."
pip install --quiet --upgrade pip
pip install --quiet matplotlib genanki geopandas requests shapely
cd "$SCRIPT_DIR"
# Create preview images directory
mkdir -p "$PREVIEW_DIR"
python -m warsaw_streets_anki --output warsaw_streets.apkg --preview "$PREVIEW_DIR" --preview-count 5
echo
echo "Done! The Anki deck is at: $SCRIPT_DIR/warsaw_streets.apkg"
echo "Preview images are in: $PREVIEW_DIR"

View File

@ -0,0 +1,356 @@
#!/usr/bin/env python3
"""Anki flashcard generator for Warsaw streets.
Generates Anki-compatible flashcard decks with maps showing individual
Warsaw streets highlighted on a city map.
Usage:
python -m python_pkg.anki_decks.warsaw_streets.warsaw_streets_anki
Output:
Creates a self-contained .apkg file that can be directly imported into Anki.
"""
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, Any
sys.path.insert(0, str(Path(__file__).parent.parent))
import genanki
from geo_data import get_warsaw_streets
import geopandas as gpd
import matplotlib.pyplot as plt
from shapely.geometry import MultiLineString
if TYPE_CHECKING:
from collections.abc import Sequence
from matplotlib.figure import Figure
# Minimum street length in meters to include
MIN_STREET_LENGTH = 500
def get_unique_streets(
gdf: gpd.GeoDataFrame,
) -> list[tuple[str, gpd.GeoDataFrame, float]]:
"""Group street segments by name and merge geometries.
Args:
gdf: GeoDataFrame with street segments.
Returns:
List of (name, GeoDataFrame, length_m) tuples, sorted by length (longest first).
"""
# Group by street name
streets: dict[str, list[Any]] = {}
for _, row in gdf.iterrows():
name = row["name"]
if name and name != "Unknown":
if name not in streets:
streets[name] = []
streets[name].append(row.geometry)
# Merge geometries and calculate length
result = []
for name, geometries in streets.items():
merged = geometries[0] if len(geometries) == 1 else MultiLineString(geometries)
# Create a GeoDataFrame for this street
street_gdf = gpd.GeoDataFrame(
[{"name": name, "geometry": merged}], crs="EPSG:4326"
)
# Calculate length in meters (approximate)
street_gdf_proj = street_gdf.to_crs("EPSG:2180") # Polish coordinate system
length = street_gdf_proj.geometry.length.iloc[0]
if length >= MIN_STREET_LENGTH:
result.append((name, street_gdf, length))
# Sort by length (longest first)
result.sort(key=lambda x: x[2], reverse=True)
return result
def load_street_data() -> (
tuple[list[tuple[str, gpd.GeoDataFrame, float]], gpd.GeoDataFrame]
):
"""Load Warsaw streets and boundary.
Returns:
Tuple of (streets list sorted by length, warsaw boundary GeoDataFrame).
"""
streets_gdf = get_warsaw_streets(min_length=MIN_STREET_LENGTH)
streets = get_unique_streets(streets_gdf)
# Load Warsaw districts for boundary (reuse from warsaw_districts)
districts_path = (
Path(__file__).parent.parent / "warsaw_districts" / "warszawa-dzielnice.geojson"
)
if districts_path.exists():
warsaw_gdf = gpd.read_file(districts_path)
# Get just Warsaw boundary
warsaw_boundary = warsaw_gdf[warsaw_gdf["name"] == "Warszawa"]
if len(warsaw_boundary) == 0:
# Dissolve all districts
warsaw_boundary = gpd.GeoDataFrame(
geometry=[warsaw_gdf.union_all()], crs=warsaw_gdf.crs
)
else:
msg = "Warsaw boundary data not found"
raise FileNotFoundError(msg)
return streets, warsaw_boundary
# Color for highlighted street
STREET_COLOR = "#E74C3C" # Red
def create_street_map(
street_gdf: gpd.GeoDataFrame,
warsaw_boundary: gpd.GeoDataFrame,
) -> Figure:
"""Create a map showing Warsaw with one street highlighted.
Args:
street_name: Name of the street.
street_gdf: GeoDataFrame with the street geometry.
warsaw_boundary: GeoDataFrame with Warsaw boundary.
Returns:
A matplotlib Figure object.
"""
fig, ax = plt.subplots(figsize=(10, 10))
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_alpha(0)
ax.patch.set_alpha(0)
# Plot Warsaw as a plain gray shape
warsaw_boundary.plot(ax=ax, color="#D5D8DC", alpha=0.6)
warsaw_boundary.boundary.plot(ax=ax, color="#2C3E50", linewidth=2)
# Plot the highlighted street
street_gdf.plot(ax=ax, color=STREET_COLOR, linewidth=4, alpha=0.9)
# Set bounds to Warsaw
bounds = warsaw_boundary.total_bounds
ax.set_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3])
return fig
def generate_street_image_bytes(
street_gdf: gpd.GeoDataFrame,
warsaw_boundary: gpd.GeoDataFrame,
) -> bytes:
"""Generate a street map image as bytes.
Args:
street_gdf: GeoDataFrame with the street geometry.
warsaw_boundary: GeoDataFrame with Warsaw boundary.
Returns:
PNG image data as bytes.
"""
fig = create_street_map(street_gdf, warsaw_boundary)
buf = BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
plt.close(fig)
buf.seek(0)
return buf.read()
def generate_anki_package(
streets: list[tuple[str, gpd.GeoDataFrame, float]],
warsaw_boundary: gpd.GeoDataFrame,
deck_name: str = "Warsaw Streets",
) -> genanki.Package:
"""Generate Anki package for Warsaw streets.
Args:
streets: List of (name, GeoDataFrame, length) tuples, sorted by length.
warsaw_boundary: GeoDataFrame with Warsaw boundary.
deck_name: Name for the Anki deck.
Returns:
genanki.Package object.
"""
model_id_hash = hashlib.md5(f"warsaw_streets_{deck_name}".encode()) # noqa: S324
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """
.card {
font-family: Arial, sans-serif;
font-size: 24px;
text-align: center;
color: #333;
background-color: #fff;
}
.card.night_mode {
color: #eee;
background-color: #2f2f2f;
}
.map-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.map-container img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
}
.answer-text {
font-size: 32px;
font-weight: bold;
margin-top: 20px;
color: #2C3E50;
}
.card.night_mode .answer-text {
color: #ECF0F1;
}
"""
my_model = genanki.Model(
model_id,
"Warsaw Street Model",
fields=[
{"name": "StreetMap"},
{"name": "StreetName"},
],
templates=[
{
"name": "Card 1",
"qfmt": '<div class="map-container">{{StreetMap}}</div>',
"afmt": '<div class="map-container">{{StreetMap}}</div>'
'<hr id="answer">'
'<div class="answer-text">{{StreetName}}</div>',
},
],
css=card_css,
)
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
my_deck = genanki.Deck(deck_id, deck_name)
media_files = []
# Streets are already sorted by length (longest first)
for street_name, street_gdf, _length in streets:
image_data = generate_street_image_bytes(street_gdf, warsaw_boundary)
filename = f"street_{street_name.replace(' ', '_').replace('/', '_')}.png"
note = genanki.Note(
model=my_model,
fields=[f'<img src="{filename}">', street_name],
tags=["geography", "warsaw", "streets"],
)
my_deck.add_note(note)
temp_path = Path(f"/tmp/{filename}") # noqa: S108
temp_path.write_bytes(image_data)
media_files.append(str(temp_path))
package = genanki.Package(my_deck)
package.media_files = media_files
return package
def main(argv: Sequence[str] | None = None) -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Generate Anki flashcards for Warsaw streets.",
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path (default: warsaw_streets.apkg)",
)
parser.add_argument(
"--deck-name",
"-d",
type=str,
default="Warsaw Streets",
help="Name for the Anki deck",
)
parser.add_argument(
"--min-length",
"-m",
type=int,
default=MIN_STREET_LENGTH,
help=f"Minimum street length in meters (default: {MIN_STREET_LENGTH})",
)
parser.add_argument(
"--preview",
"-p",
type=str,
default=None,
help="Export preview images to specified directory",
)
parser.add_argument(
"--preview-count",
type=int,
default=5,
help="Number of preview images to export (default: 5)",
)
args = parser.parse_args(argv)
output_path = Path(args.output) if args.output else Path("warsaw_streets.apkg")
try:
sys.stdout.write("Loading street data...\n")
streets, warsaw_boundary = load_street_data()
num_streets = len(streets)
sys.stdout.write(f"Generating flashcards for {num_streets} Warsaw streets...\n")
package = generate_anki_package(streets, warsaw_boundary, args.deck_name)
package.write_to_file(str(output_path))
# Export preview images if requested (top N longest streets)
if args.preview:
preview_dir = Path(args.preview)
preview_dir.mkdir(parents=True, exist_ok=True)
preview_streets = streets[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_streets)} preview images "
f"(longest streets) to {preview_dir}...\n"
)
for street_name, street_gdf, length_m in preview_streets:
image_data = generate_street_image_bytes(street_gdf, warsaw_boundary)
safe_name = street_name.replace(" ", "_").replace("/", "_")
preview_path = preview_dir / f"{safe_name}.png"
preview_path.write_bytes(image_data)
sys.stdout.write(f" Saved: {preview_path.name} ({length_m:.0f}m)\n")
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"Streets: {num_streets}\n")
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
if args.preview:
sys.stdout.write(f"Preview images: {args.preview}\n")
except (OSError, ValueError, RuntimeError) as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())