"""EuropaLex .apkg Export — creates a proper Anki package via genanki.
Two-phase approach:
Phase 1: Build CSV + media folder (matching working_anki_example/cards.csv format)
Phase 2: Package with genanki into .apkg file
Produces: .local/models/output/export/{scenario_slug}_{CEFR}_{LANG}.apkg
"""
import csv
import os
import re
import random
import shutil
from pathlib import Path
import genanki
# ISO 639-1 language abbreviation mapping — mirrors csv_export.py exactly
_LANGUAGE_ABBREVS: dict[str, str] = {
"Latvian": "LV",
"Spanish": "ES",
"French": "FR",
"German": "DE",
"Polish": "PL",
"Italian": "IT",
"Portuguese": "PT",
"Finnish": "FI",
}
# Project root for resolving relative paths — mirrors csv_export.py
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
def _sanitize_folder_name(scenario: str) -> str:
"""Convert scenario text to a filesystem-safe folder name slug.
Args:
scenario: Free-form scenario/topic string from the user.
Returns:
Sanitized slug suitable for use as a directory name.
"""
slug = scenario.strip().lower()
slug = re.sub(r'[^a-z0-9\s_]', '', slug) # remove special chars
slug = re.sub(r'\s+', '_', slug) # spaces → underscores
slug = re.sub(r'_+', '_', slug) # collapse multiple underscores
return slug.strip('_')
def _get_language_abbrev(language: str) -> str:
"""Return the ISO 639-1 abbreviation for a language name.
Args:
language: Language name (e.g., 'Latvian', 'Spanish').
Returns:
Two-letter ISO 639-1 code.
Raises:
ValueError: If the language is not in the mapping.
"""
if language not in _LANGUAGE_ABBREVS:
raise ValueError(
f"Unknown language '{language}'. "
f"Supported: {', '.join(sorted(_LANGUAGE_ABBREVS.keys()))}"
)
return _LANGUAGE_ABBREVS[language]
def _copy_media_file(
src_path: str | None,
dest_dir: Path,
filename_prefix: str,
card_index: int,
ext: str,
) -> str | None:
"""Copy a media file to the export media directory and return its filename, or None.
Args:
src_path: Source file path or None.
dest_dir: Destination media directory (collection.media/).
filename_prefix: Filename prefix ({scenario}_{CEFR}_{LANG}).
card_index: Zero-based card index for the filename suffix.
ext: File extension including dot (e.g., '.wav', '.png').
Returns:
Bare media filename or None if skipped.
"""
if not src_path or not Path(src_path).exists():
return None
media_filename = f"{filename_prefix}_{card_index}{ext}"
shutil.copy2(src_path, str(dest_dir / media_filename))
return media_filename
def _build_front_html(
translation: str,
audio_path: str | None,
image_path: str | None,
export_dir: Path,
base_name: str,
card_index: int,
) -> tuple[str, str]:
"""Build the HTML/markup strings for the card front fields.
Returns both Image markup and Audio markup as a tuple.
Args:
translation: Target-language text (will be HTML-escaped).
audio_path: Path to TTS .wav file or None.
image_path: Path to illustration .png file or None.
export_dir: Export directory containing collection.media/.
base_name: Filename prefix ({scenario}_{CEFR}_{LANG}).
card_index: Zero-based card index.
Returns:
Tuple of (image_markup, audio_markup) strings.
image_markup: '' or empty string
audio_markup: '[sound:{filename}]' or empty string
"""
media_dir = export_dir / "collection.media"
media_dir.mkdir(parents=True, exist_ok=True)
image_markup = ""
if image_path:
fname = _copy_media_file(image_path, media_dir, base_name, card_index, ".png")
if fname:
image_markup = f'
'
audio_markup = ""
if audio_path:
fname = _copy_media_file(audio_path, media_dir, base_name, card_index, ".wav")
if fname:
audio_markup = f"[sound:collection.media/{fname}]"
return image_markup, audio_markup
# Card template and CSS from create_anki_deck.py reference script
FRONT_TEMPLATE = """
img_fn = ""
if img_raw:
match = re.search(r'src=["\']collection\.media/([^"\']+)["\']', img_raw)
if match:
img_fn = match.group(1)
# Extract bare filename from audio markup: [sound:collection.media/foo.wav]
aud_fn = ""
if aud_raw:
match = re.search(r'\[sound:collection\.media/([^\]]+)\]', aud_raw)
if match:
aud_fn = match.group(1)
# Build image field with bare filename
img_field = ""
if img_fn:
img_path = os.path.join(media_dir, img_fn)
if os.path.exists(img_path):
img_field = f'