export apkg and loads in Anki

This commit is contained in:
stephen 2026-06-19 15:30:59 +10:00
parent 8936455285
commit f2abb7d9cb

140
main.py
View file

@ -1,6 +1,8 @@
# main.py
import sys
import os
import random
import hashlib
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout,
QHBoxLayout, QLabel, QPushButton, QLineEdit, QComboBox,
@ -10,6 +12,9 @@ from PyQt6.QtCore import Qt, QUrl
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
from PyQt6.QtGui import QFont
# Third-Party Anki Generation Tooling
import genanki
# Internal Project Module Imports
from database.connection import init_db, get_connection
from core.bulk_importer import BulkImporter
@ -41,7 +46,7 @@ class SpanishTrainerApp(QMainWindow):
self.init_phrase_sandbox_tab()
self.init_flashcard_reviewer_tab()
self.init_settings_tab() # Mount the new settings panel
self.init_settings_tab()
# 2. Populate table grids on initialization
self.refresh_crud_table()
@ -55,7 +60,6 @@ class SpanishTrainerApp(QMainWindow):
conn = get_connection()
cursor = conn.cursor()
# Ensure the settings table exists alongside legacy core tables
cursor.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);")
try:
@ -398,7 +402,6 @@ class SpanishTrainerApp(QMainWindow):
settings_frame.setFrameShape(QFrame.Shape.StyledPanel)
form_layout = QFormLayout(settings_frame)
# Anki Export Path Form Group
anki_layout = QHBoxLayout()
self.line_anki_dir = QLineEdit(self.anki_export_dir)
self.line_anki_dir.setReadOnly(True)
@ -407,7 +410,6 @@ class SpanishTrainerApp(QMainWindow):
anki_layout.addWidget(self.line_anki_dir)
anki_layout.addWidget(btn_browse_anki)
# Video Export Path Form Group
video_layout = QHBoxLayout()
self.line_video_dir = QLineEdit(self.video_export_dir)
self.line_video_dir.setReadOnly(True)
@ -426,7 +428,6 @@ class SpanishTrainerApp(QMainWindow):
self.tabs.addTab(tab, "⚙️ Settings")
def handle_browse_anki_directory(self):
"""Triggers a file browser directory selection for native Anki card compiles."""
directory = QFileDialog.getExistingDirectory(self, "Select Anki Export Folder", self.anki_export_dir)
if directory:
self.anki_export_dir = directory
@ -434,7 +435,6 @@ class SpanishTrainerApp(QMainWindow):
self.save_setting_to_db("anki_export_directory", directory)
def handle_browse_video_directory(self):
"""Triggers a file browser directory selection for video timeline outputs."""
directory = QFileDialog.getExistingDirectory(self, "Select Video Export Folder", self.video_export_dir)
if directory:
self.video_export_dir = directory
@ -665,7 +665,6 @@ class SpanishTrainerApp(QMainWindow):
QMessageBox.information(self, "Empty Pool", "No flashcards match your selected filter configurations.")
return
import random
target_id = random.choice(self.flashcard_ids_pool)
try:
@ -779,17 +778,130 @@ class SpanishTrainerApp(QMainWindow):
self.media_player.setPlaybackRate(rate)
# =====================================================================
# 📦 ARTIFACT EXPORT GATEWAYS (ANKI & DEPLOYMENT CODES)
# 📦 ARTIFACT EXPORT GATEWAYS (GENANKI LIVE ENGINE)
# =====================================================================
def handle_export_anki_deck(self):
"""Action handler loop for genanki package deployment modules."""
QMessageBox.information(
self, "Anki Export Engine",
f"Staging packaging manifest for active view subset!\n\n"
f"Target Directory: {self.anki_export_dir}\n"
f"Currently Filtered Scope count: {len(self.flashcard_ids_pool)} cards."
"""Compiles active subset into functional .apkg with bundled neural audio tracks."""
if not self.flashcard_ids_pool:
QMessageBox.warning(self, "Export Cancelled", "The current study stack is empty. Verify your search filters.")
return
# 1. Generate Stable Cryptographic Note Model ID
model_hash = hashlib.sha256(b"castilian_voice_trainer_model_v1").hexdigest()
model_id = int(model_hash[:13], 16)
# Standardized Castilian Note Template Structure Definition
spanish_note_model = genanki.Model(
model_id,
'Castilian Audio Flashcard Model',
fields=[
{'name': 'SpanishPhrase'},
{'name': 'EnglishTranslation'},
{'name': 'GrammarNotes'},
{'name': 'AudioTrack'}
],
templates=[
{
'name': 'Card 1: Auditory Identification',
'qfmt': '<div style="font-family: Arial; font-size: 24px; text-align: center; color: #2c3e50;">{{SpanishPhrase}}</div><br><div style="text-align: center;">{{AudioTrack}}</div>',
'afmt': '{{FrontSide}}<hr id="answer"><div style="font-family: Arial; font-size: 20px; text-align: center; color: #27ae60; font-weight: bold;">{{EnglishTranslation}}</div><br><div style="font-family: Arial; font-size: 14px; text-align: center; color: #7f8c8d; font-style: italic;">{{GrammarNotes}}</div>',
},
],
css='.card { font-family: arial; font-size: 20px; text-align: center; background-color: #f8f9fa; }'
)
# Determine dynamic manifest names based on runtime filter choices
context_txt = self.review_context_filter.text().strip()
tag_txt = self.review_tag_filter.text().strip()
if context_txt and tag_txt:
file_title = f"Spanish_Export_Context_{context_txt}_Tag_{tag_txt}.apkg"
elif context_txt:
file_title = f"Spanish_Export_Context_{context_txt}.apkg"
elif tag_txt:
file_title = f"Spanish_Export_Tag_{tag_txt}.apkg"
else:
file_title = "Spanish_Master_Deck.apkg"
# Clean filename characters for safety across platforms
file_title = "".join([c for c in file_title if c.isalnum() or c in (".", "_", "-")]).strip()
destination_path = os.path.join(self.anki_export_dir, file_title)
# Dictionary to store separate deck objects dynamically based on the DB assignments
decks_map = {}
media_files_manifest = []
missing_assets_count = 0
# 2. Fetch specific database rows matching the current runtime array pool
conn = get_connection()
cursor = conn.cursor()
placeholders = ",".join(["?"] * len(self.flashcard_ids_pool))
query = f"""
SELECT t.deck_name, p1.text, p2.text, t.notes, t.tags, p1.language
FROM translations t
JOIN phrases p1 ON t.source_phrase_id = p1.id
JOIN phrases p2 ON t.target_phrase_id = p2.id
WHERE p1.id IN ({placeholders})
"""
cursor.execute(query, self.flashcard_ids_pool)
records = cursor.fetchall()
conn.close()
# 3. Iterate over records and construct notes
for row in records:
db_deck_name = row[0].strip() if row[0] else "Castilian Spanish Master"
es_text = row[1].strip()
en_text = row[2].strip()
notes_text = row[3].strip() if row[3] else ""
tags_string = row[4].strip() if row[4] else ""
lang = row[5]
# Reconstruct local media naming signature
safe_audio_name = "".join([c for c in es_text if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower()
relative_audio_path = f"media/{safe_audio_name}_{lang}_female.mp3"
filename_only = f"{safe_audio_name}_{lang}_female.mp3"
# Check if target file exists. If missing, flag but don't crash
if os.path.exists(relative_audio_path):
media_files_manifest.append(relative_audio_path)
anki_audio_field = f"[sound:{filename_only}]"
else:
missing_assets_count += 1
anki_audio_field = "" # Fallback to empty if not generated yet
# Handle dynamic deck assignment structure
if db_deck_name not in decks_map:
deck_hash = hashlib.sha256(db_deck_name.encode('utf-8')).hexdigest()
deck_id = int(deck_hash[:13], 16)
decks_map[db_deck_name] = genanki.Deck(deck_id, db_deck_name)
# Split tags string by spaces into an array list for genanki
parsed_tags = [t for t in tags_string.replace(",", " ").split(" ") if t]
# Construct unique note entry template matching properties
flash_note = genanki.Note(
model=spanish_note_model,
fields=[es_text, en_text, notes_text, anki_audio_field],
tags=parsed_tags
)
decks_map[db_deck_name].add_note(flash_note)
# 4. Package all deck components into an .apkg container
try:
package = genanki.Package(list(decks_map.values()))
package.media_files = media_files_manifest
package.write_to_file(destination_path)
success_msg = f"✨ Packaged complete!\n\nFile Output: {file_title}\nDestination: {self.anki_export_dir}\nTotal Cards Built: {len(records)}\nDecks Created: {len(decks_map)}"
if missing_assets_count > 0:
success_msg += f"\n\n⚠️ Note: {missing_assets_count} cards were bundled without audio tracks because their voice files hadn't been generated in the review window yet."
QMessageBox.information(self, "Export Complete", success_msg)
except Exception as export_error:
QMessageBox.critical(self, "Export Failed", f"Genanki package compression pipeline failure:\n{export_error}")
def handle_export_video_assets(self):
"""Action handler loop for compiling video cards."""
QMessageBox.information(