2026-06-24 05:37:22 +00:00
|
|
|
# anki_exporter.py
|
|
|
|
|
import os
|
|
|
|
|
import tempfile
|
2026-06-29 12:07:07 +00:00
|
|
|
import asyncio
|
2026-08-21 12:24:50 +00:00
|
|
|
import time
|
2026-06-24 05:37:22 +00:00
|
|
|
import genanki
|
2026-06-29 12:07:07 +00:00
|
|
|
import edge_tts
|
|
|
|
|
import database
|
2026-07-22 00:26:25 +00:00
|
|
|
from tts_utils import parse_text_for_edgetts, get_configured_tts_rate
|
2026-06-29 12:07:07 +00:00
|
|
|
|
|
|
|
|
async def generate_edge_audio(text, voice, output_path, rate_modifier="+0%"):
|
|
|
|
|
"""Asynchronously streams data packages via the Microsoft Edge API pipeline."""
|
|
|
|
|
try:
|
|
|
|
|
communicate = edge_tts.Communicate(text=text, voice=voice, rate=rate_modifier)
|
|
|
|
|
await communicate.save(output_path)
|
|
|
|
|
return True
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Edge-TTS synthesis anomaly: {e}")
|
|
|
|
|
return False
|
2026-06-24 05:37:22 +00:00
|
|
|
|
|
|
|
|
def compile_anki_package(records, output_path, deck_name):
|
|
|
|
|
"""
|
2026-06-29 12:07:07 +00:00
|
|
|
Compiles database records into a bidirectional card payload package.
|
|
|
|
|
Resolves voice models dynamically by gender selection parameters and applies
|
|
|
|
|
global speed coefficient rates from the active configurations.
|
2026-08-28 00:27:21 +00:00
|
|
|
Injects sort_order as field 0 for structured card sequencing.
|
2026-06-24 05:37:22 +00:00
|
|
|
"""
|
2026-08-21 12:24:50 +00:00
|
|
|
# Generate deterministic positive 32-bit integers from deck name and model name
|
|
|
|
|
# to avoid collisions across different decks while keeping imports stable
|
|
|
|
|
deck_id = abs(hash(deck_name)) % (2**31)
|
|
|
|
|
model_id = abs(hash("Spanish Bidirectional Multi-Note HTML Model")) % (2**31)
|
|
|
|
|
|
|
|
|
|
# Unique timestamp prefix for media files to prevent overwriting prior exports in Anki
|
|
|
|
|
run_prefix = int(time.time())
|
2026-06-29 12:07:07 +00:00
|
|
|
|
|
|
|
|
# Global Configuration Pace Resolver Mapping
|
|
|
|
|
settings = database.load_all_settings() or {}
|
2026-07-22 00:26:25 +00:00
|
|
|
rate_string = get_configured_tts_rate(settings)
|
2026-06-24 05:37:22 +00:00
|
|
|
|
|
|
|
|
anki_model = genanki.Model(
|
|
|
|
|
model_id,
|
2026-06-29 12:07:07 +00:00
|
|
|
'Spanish Bidirectional Multi-Note HTML Model',
|
2026-06-24 05:37:22 +00:00
|
|
|
fields=[
|
2026-08-28 00:27:21 +00:00
|
|
|
{'name': 'SortOrder'}, # Field 0: Anki primary sort field
|
2026-06-24 05:37:22 +00:00
|
|
|
{'name': 'EnglishText'},
|
|
|
|
|
{'name': 'SpanishText'},
|
2026-06-29 12:07:07 +00:00
|
|
|
{'name': 'AnkiNotes'},
|
2026-06-24 05:37:22 +00:00
|
|
|
{'name': 'EnglishAudio'},
|
|
|
|
|
{'name': 'SpanishAudio'}
|
|
|
|
|
],
|
|
|
|
|
templates=[
|
|
|
|
|
{
|
2026-06-29 12:07:07 +00:00
|
|
|
'name': 'Card 1: English ➔ Spanish',
|
|
|
|
|
'qfmt': '<div style="font-family: Arial; font-size: 13px; font-weight: bold; color: #BDC3C7; text-align: center; letter-spacing: 1px;">TRANSLATE TO SPANISH:</div><br>'
|
|
|
|
|
'<div style="font-family: Arial; font-size: 20px; text-align: center; color: #34495E;"><b>{{EnglishText}}</b></div>'
|
|
|
|
|
'<div style="display:none;">{{EnglishAudio}}</div>',
|
2026-06-24 05:37:22 +00:00
|
|
|
'afmt': '{{FrontSide}}<hr id="answer">'
|
2026-06-29 12:07:07 +00:00
|
|
|
'<div style="font-family: Arial; font-size: 28px; text-align: center; color: #2980B9; font-weight: bold;">{{SpanishText}}</div><br>'
|
2026-06-30 07:57:41 +00:00
|
|
|
'{{#AnkiNotes}}<div style="font-family: Arial; font-size: 13px; text-align: center; color: #8E44AD; border-top: 1px dashed #E5E7E9; padding-top: 6px; margin-top: 6px;"><b>Anki Meta:</b> {{{AnkiNotes}}}</div>{{/AnkiNotes}}<br>'
|
2026-06-24 05:37:22 +00:00
|
|
|
'<div style="text-align: center;">{{SpanishAudio}}</div>',
|
|
|
|
|
},
|
2026-06-29 12:07:07 +00:00
|
|
|
{
|
|
|
|
|
'name': 'Card 2: Spanish ➔ English',
|
|
|
|
|
'qfmt': '<div style="font-family: Arial; font-size: 13px; font-weight: bold; color: #E67E22; text-align: center; letter-spacing: 1px;">TRANSLATE TO ENGLISH:</div><br>'
|
|
|
|
|
'<div style="font-family: Arial; font-size: 26px; text-align: center; color: #2980B9; font-weight: bold;"><b>{{SpanishText}}</b></div>'
|
|
|
|
|
'<div style="display:none;">{{SpanishAudio}}</div>',
|
|
|
|
|
'afmt': '{{FrontSide}}<hr id="answer">'
|
|
|
|
|
'<div style="font-family: Arial; font-size: 22px; text-align: center; color: #2C3E50; font-weight: 500;">{{EnglishText}}</div><br>'
|
2026-06-30 07:57:41 +00:00
|
|
|
'{{#AnkiNotes}}<div style="font-family: Arial; font-size: 13px; text-align: center; color: #8E44AD; border-top: 1px dashed #E5E7E9; padding-top: 6px; margin-top: 6px;"><b>Anki Meta:</b> {{{AnkiNotes}}}</div>{{/AnkiNotes}}<br>'
|
2026-06-29 12:07:07 +00:00
|
|
|
'<div style="text-align: center;">{{EnglishAudio}}</div>',
|
|
|
|
|
}
|
2026-06-24 05:37:22 +00:00
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
deck = genanki.Deck(deck_id, deck_name)
|
2026-06-29 12:07:07 +00:00
|
|
|
media_files_to_pack = []
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
except RuntimeError:
|
|
|
|
|
loop = asyncio.new_event_loop()
|
|
|
|
|
asyncio.set_event_loop(loop)
|
2026-06-24 05:37:22 +00:00
|
|
|
|
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
|
|
|
for idx, record in enumerate(records):
|
2026-07-22 00:26:25 +00:00
|
|
|
en_raw = record["en_text"]
|
|
|
|
|
es_raw = record["es_text"]
|
2026-06-29 12:07:07 +00:00
|
|
|
gender_flag = record.get("gender", "Female")
|
2026-06-26 06:20:45 +00:00
|
|
|
|
2026-08-28 00:27:21 +00:00
|
|
|
# Read sort_order from database record (fallback to loop index if unset)
|
|
|
|
|
raw_order = record.get("sort_order")
|
|
|
|
|
order_val = int(raw_order) if raw_order is not None else (idx + 1)
|
|
|
|
|
sort_order_str = f"{order_val:04d}" # Zero-padded integer string for exact sorting
|
|
|
|
|
|
2026-07-22 00:26:25 +00:00
|
|
|
# Sanitize text payloads for TTS engine
|
|
|
|
|
en_tts_text = parse_text_for_edgetts(en_raw)
|
|
|
|
|
es_tts_text = parse_text_for_edgetts(es_raw)
|
|
|
|
|
|
2026-06-29 12:07:07 +00:00
|
|
|
# Map native neural voice files matching gender settings
|
|
|
|
|
spanish_voice = "es-ES-AlvaroNeural" if gender_flag == "Male" else "es-ES-ElviraNeural"
|
|
|
|
|
english_voice = "en-US-EmmaNeural"
|
|
|
|
|
|
2026-06-26 06:20:45 +00:00
|
|
|
raw_tags = record.get("tags") or ""
|
|
|
|
|
note_tags = [t.strip().replace(" ", "_") for t in raw_tags.split(",") if t.strip()]
|
|
|
|
|
|
2026-07-01 23:46:20 +00:00
|
|
|
# Safely isolate the raw text/HTML data string down to Anki notes field
|
2026-06-30 07:57:41 +00:00
|
|
|
anki_notes_html = record['anki_notes'].strip() if record.get('anki_notes') else ""
|
2026-06-24 05:37:22 +00:00
|
|
|
|
2026-08-21 12:24:50 +00:00
|
|
|
# Unique Media Filenames combining execution timestamp and index
|
|
|
|
|
en_audio_filename = f"edge_en_{run_prefix}_{idx}.mp3"
|
|
|
|
|
es_audio_filename = f"edge_es_{run_prefix}_{idx}.mp3"
|
2026-06-24 05:37:22 +00:00
|
|
|
|
|
|
|
|
en_audio_path = os.path.join(tmpdir, en_audio_filename)
|
|
|
|
|
es_audio_path = os.path.join(tmpdir, es_audio_filename)
|
|
|
|
|
|
2026-06-29 12:07:07 +00:00
|
|
|
# English Audio Synthesis
|
2026-07-22 00:26:25 +00:00
|
|
|
if en_tts_text.strip() and loop.run_until_complete(generate_edge_audio(en_tts_text, english_voice, en_audio_path, rate_string)):
|
2026-06-29 12:07:07 +00:00
|
|
|
media_files_to_pack.append(en_audio_path)
|
2026-06-24 05:37:22 +00:00
|
|
|
en_audio_field = f"[sound:{en_audio_filename}]"
|
2026-06-29 12:07:07 +00:00
|
|
|
else:
|
2026-06-24 05:37:22 +00:00
|
|
|
en_audio_field = ""
|
|
|
|
|
|
2026-06-29 12:07:07 +00:00
|
|
|
# Spanish Audio Synthesis
|
2026-07-22 00:26:25 +00:00
|
|
|
if es_tts_text.strip() and loop.run_until_complete(generate_edge_audio(es_tts_text, spanish_voice, es_audio_path, rate_string)):
|
2026-06-29 12:07:07 +00:00
|
|
|
media_files_to_pack.append(es_audio_path)
|
2026-06-24 05:37:22 +00:00
|
|
|
es_audio_field = f"[sound:{es_audio_filename}]"
|
2026-06-29 12:07:07 +00:00
|
|
|
else:
|
2026-06-24 05:37:22 +00:00
|
|
|
es_audio_field = ""
|
|
|
|
|
|
2026-07-01 23:46:20 +00:00
|
|
|
# Clean sequential matching fields array matching schema mapping above
|
2026-06-24 05:37:22 +00:00
|
|
|
note = genanki.Note(
|
|
|
|
|
model=anki_model,
|
2026-08-28 00:27:21 +00:00
|
|
|
fields=[sort_order_str, en_raw, es_raw, anki_notes_html, en_audio_field, es_audio_field],
|
2026-06-26 06:20:45 +00:00
|
|
|
tags=note_tags
|
2026-06-24 05:37:22 +00:00
|
|
|
)
|
|
|
|
|
deck.add_note(note)
|
|
|
|
|
|
2026-06-29 12:07:07 +00:00
|
|
|
# Build Package while media assets are guaranteed contextually active inside tmpdir
|
2026-06-24 05:37:22 +00:00
|
|
|
package = genanki.Package(deck)
|
2026-06-29 12:07:07 +00:00
|
|
|
package.media_files = media_files_to_pack
|
|
|
|
|
package.write_to_file(output_path)
|