From bed5be99978f041843ff5ff881ed3d26e3d57d83 Mon Sep 17 00:00:00 2001 From: stephen Date: Fri, 19 Jun 2026 15:30:59 +1000 Subject: [PATCH] export apkg and loads in Anki --- main.py | 140 ++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 126 insertions(+), 14 deletions(-) diff --git a/main.py b/main.py index f6aaea1..18b9bf3 100644 --- a/main.py +++ b/main.py @@ -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': '
{{SpanishPhrase}}

{{AudioTrack}}
', + 'afmt': '{{FrontSide}}
{{EnglishTranslation}}

{{GrammarNotes}}
', + }, + ], + 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(