# main.py import sys import os import random import hashlib import subprocess import json import asyncio import shutil from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QLineEdit, QComboBox, QTableWidget, QTableWidgetItem, QSlider, QFormLayout, QTextEdit, QFrame, QMessageBox, QFileDialog ) from PyQt6.QtCore import Qt, QUrl from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput from PyQt6.QtGui import QFont # Third-Party Tooling import genanki import edge_tts from PIL import Image, ImageDraw, ImageFont # Internal Project Module Imports from database.connection import init_db, get_connection from core.bulk_importer import BulkImporter from core.clean_glossary import GlossaryCleaner class SpanishTrainerApp(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Castilian Voice Trainer Pro") self.setMinimumSize(1200, 800) # 1. Initialize schema structures and check ingestion status self.ensure_database_populated() # Load system persistent settings from DB (including sleep-learning fields) self.load_system_settings() # Audio Player Architecture Setup self.media_player = QMediaPlayer() self.audio_output = QAudioOutput() self.media_player.setAudioOutput(self.audio_output) # Flashcard Core State Variables self.current_flashcard_id = None # Tracks the translation_id currently being reviewed self.current_card_is_flipped = False # False = Front, True = Back self.current_active_es_text = "" # Caches active Spanish string self.current_active_en_text = "" # Caches active English string self.flashcard_ids_pool = [] # Tracks currently filtered list of translation_ids self.current_sandbox_es_id = None self.current_sandbox_en_id = None # Central Main Window Tabs Interface self.tabs = QTabWidget() self.setCentralWidget(self.tabs) self.init_phrase_sandbox_tab() self.init_flashcard_reviewer_tab() self.init_settings_tab() # 2. Populate table grids on initialization self.refresh_crud_table() self.refresh_review_table() def ensure_database_populated(self): """Forces database configuration structure and triggers pipeline execution if empty.""" print("🗄️ Verification Pass: Running schema configuration scripts...") init_db() conn = get_connection() cursor = conn.cursor() # Ensure our settings table and key columns are structurally sound cursor.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);") # Seamlessly inject duration column into phrases if it doesn't exist cursor.execute("PRAGMA table_info(phrases);") columns = [row[1] for row in cursor.fetchall()] if "duration" not in columns: print("🔄 Modifying phrases schema to support floating-point duration tracking...") cursor.execute("ALTER TABLE phrases ADD COLUMN duration REAL;") conn.commit() try: cursor.execute("SELECT COUNT(*) FROM translations") count = cursor.fetchone()[0] print(f"📊 Current Translation Pairs found in database: {count}") except Exception as e: print(f"⚠️ Table check encountered an issue (likely empty tables): {e}") count = 0 finally: conn.close() if count == 0: print("🗄️ Database tables are empty. Triggering glossary reader pipeline...") pdf_file = "aula_int_plus_1_glos_en_alfa.pdf" if os.path.exists(pdf_file): importer = BulkImporter() importer.import_pdf_glossary(pdf_file, "Aula Internacional Plus 1") cleaner = GlossaryCleaner() cleaner.process_database_clean() print("✨ Ingestion pipeline processing sequence successfully completed.") else: print(f"❌ Error: Source document '{pdf_file}' is missing from the directory root.") def load_system_settings(self): """Loads persistent variables from the key-value settings table.""" self.anki_export_dir = os.getcwd() self.video_export_dir = os.getcwd() self.video_first_lang = "English First (en -> es)" self.video_repeats_count = "3" self.video_pause_duration = "4.0" conn = get_connection() cursor = conn.cursor() try: cursor.execute("SELECT key, value FROM settings") rows = cursor.fetchall() for row in rows: if row[0] == "anki_export_directory": self.anki_export_dir = row[1] elif row[0] == "video_export_directory": self.video_export_dir = row[1] elif row[0] == "video_first_language": self.video_first_lang = row[1] elif row[0] == "video_repeats_count": self.video_repeats_count = row[1] elif row[0] == "video_pause_duration": self.video_pause_duration = row[1] except Exception as e: print(f"⚠️ Failed to read application settings from database: {e}") finally: conn.close() def save_setting_to_db(self, key, value): """Updates or inserts a specific system runtime variable into the database.""" conn = get_connection() cursor = conn.cursor() try: cursor.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, value)) conn.commit() if key == "video_first_language": self.video_first_lang = value elif key == "video_repeats_count": self.video_repeats_count = value elif key == "video_pause_duration": self.video_pause_duration = value except Exception as e: print(f"❌ Critical: Failed to save setting '{key}': {e}") finally: conn.close() def get_or_generate_audio_duration(self, phrase_id, text_str, lang): """ Ensures a target audio track exists on disk, reads its run length via ffprobe, caches the duration field inside SQLite, and returns the float timing block. """ if not text_str.strip(): return 2.5 safe_name = "".join([c for c in text_str if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower() if not safe_name: safe_name = hashlib.sha256(text_str.encode('utf-8')).hexdigest()[:16] os.makedirs("media", exist_ok=True) target_file = f"media/{safe_name}_{lang}_female.mp3" if not os.path.exists(target_file): try: voice = "es-ES-ElviraNeural" if lang == "es" else "en-GB-SoniaNeural" communicate = edge_tts.Communicate(text_str, voice) asyncio.run(communicate.save(target_file)) except Exception as tts_err: print(f"❌ Core TTS System Exception: {tts_err}") return 2.5 if phrase_id: conn = get_connection() cursor = conn.cursor() cursor.execute("SELECT duration FROM phrases WHERE id = ?", (phrase_id,)) cached_row = cursor.fetchone() if cached_row and cached_row[0] is not None: conn.close() return float(cached_row[0]) try: cmd = [ 'ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_entries', 'format=duration', target_file ] result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) data = json.loads(result.stdout) duration = float(data['format']['duration']) if phrase_id: cursor.execute("UPDATE phrases SET duration = ? WHERE id = ?", (duration, phrase_id)) conn.commit() except Exception as e: print(f"⚠️ Track structure analysis warning for {target_file}: {e}") duration = 2.5 finally: if phrase_id and 'conn' in locals() and conn: conn.close() return duration # ===================================================================== # 🗄️ TAB 1: TRANSLATION-CENTRIC PHRASE SANDBOX (CRUD) # ===================================================================== def init_phrase_sandbox_tab(self): tab = QWidget() layout = QHBoxLayout(tab) left_panel = QVBoxLayout() filter_layout = QHBoxLayout() filter_layout.addWidget(QLabel("🔍 Text Filter:")) self.search_text_input = QLineEdit() self.search_text_input.setPlaceholderText("Search Spanish or English text blocks...") self.search_text_input.textChanged.connect(self.refresh_crud_table) filter_layout.addWidget(self.search_text_input) filter_layout.addWidget(QLabel("📂 Context:")) self.search_context_input = QLineEdit() self.search_context_input.setPlaceholderText("e.g. U2") self.search_context_input.setMaximumWidth(130) self.search_context_input.textChanged.connect(self.refresh_crud_table) filter_layout.addWidget(self.search_context_input) left_panel.addLayout(filter_layout) self.translation_table = QTableWidget() self.translation_table.setColumnCount(6) self.translation_table.setHorizontalHeaderLabels([ "TX ID", "Spanish Phrase", "English Translation", "Type", "Source Context", "Deck Assignment" ]) self.translation_table.itemSelectionChanged.connect(self.handle_table_row_select) left_panel.addWidget(self.translation_table) nav_layout = QHBoxLayout() self.btn_row_up = QPushButton("🔼 Previous Pair") self.btn_row_down = QPushButton("🔽 Next Pair") self.btn_row_up.clicked.connect(lambda: self.step_table_row(-1)) self.btn_row_down.clicked.connect(lambda: self.step_table_row(1)) nav_layout.addWidget(self.btn_row_up) nav_layout.addWidget(self.btn_row_down) left_panel.addLayout(nav_layout) right_panel = QVBoxLayout() form_frame = QFrame() form_frame.setFrameShape(QFrame.Shape.StyledPanel) form_layout = QFormLayout(form_frame) self.input_tx_id = QLineEdit() self.input_tx_id.setReadOnly(True) self.input_tx_id.setPlaceholderText("Auto-Increment ID") self.input_text_es = QTextEdit() self.input_text_es.setMaximumHeight(75) self.input_text_es.textChanged.connect(self.clear_id_if_new_entry) self.input_text_en = QTextEdit() self.input_text_en.setMaximumHeight(75) self.input_text_en.textChanged.connect(self.clear_id_if_new_entry) self.combo_type = QComboBox() self.combo_type.addItems(["phrase", "sentence", "noun", "verb", "adjective"]) self.input_context = QLineEdit() self.input_context.setPlaceholderText("e.g., U8_5A") self.input_tags = QLineEdit() self.input_tags.setPlaceholderText("e.g., irregular_er boots_verb") self.input_deck_tag = QLineEdit() self.input_deck_tag.setPlaceholderText("Anki Sub-deck Hierarchy") button_qss = """ QPushButton { background-color: #f0f0f0; border: 1px solid #c0c0c0; border-radius: 4px; font-size: 11px; font-weight: bold; color: #333333; } QPushButton:hover { background-color: #e0e0e0; border: 1px solid #a0a0a0; } QPushButton:pressed { background-color: #d0d0d0; } """ es_header_layout = QHBoxLayout() es_header_layout.setContentsMargins(0, 5, 0, 5) es_header_layout.addWidget(QLabel("🇪🇸 Castilian Spanish Text Element:")) self.btn_play_sandbox_es = QPushButton("Play 🔊") self.btn_play_sandbox_es.setFixedWidth(75) self.btn_play_sandbox_es.setFixedHeight(24) self.btn_play_sandbox_es.setStyleSheet(button_qss) self.btn_play_sandbox_es.clicked.connect(self.handle_sandbox_play_es) es_header_layout.addWidget(self.btn_play_sandbox_es) self.combo_speed_es = QComboBox() self.combo_speed_es.addItems(["0.50x", "0.75x", "1.00x", "1.25x", "1.50x"]) self.combo_speed_es.setCurrentText("1.00x") self.combo_speed_es.setFixedWidth(70) self.combo_speed_es.setFixedHeight(24) es_header_layout.addWidget(self.combo_speed_es) es_header_layout.addStretch() en_header_layout = QHBoxLayout() en_header_layout.setContentsMargins(0, 5, 0, 5) en_header_layout.addWidget(QLabel("🇬🇧 English Target Translation:")) self.btn_play_sandbox_en = QPushButton("Play 🔊") self.btn_play_sandbox_en.setFixedWidth(75) self.btn_play_sandbox_en.setFixedHeight(24) self.btn_play_sandbox_en.setStyleSheet(button_qss) self.btn_play_sandbox_en.clicked.connect(self.handle_sandbox_play_en) en_header_layout.addWidget(self.btn_play_sandbox_en) self.combo_speed_en = QComboBox() self.combo_speed_en.addItems(["0.50x", "0.75x", "1.00x", "1.25x", "1.50x"]) self.combo_speed_en.setCurrentText("1.00x") self.combo_speed_en.setFixedWidth(70) self.combo_speed_en.setFixedHeight(24) en_header_layout.addWidget(self.combo_speed_en) en_header_layout.addStretch() grammar_header_layout = QHBoxLayout() grammar_header_layout.setContentsMargins(0, 5, 0, 5) grammar_header_layout.addWidget(QLabel("📝 Grammar / Usage Note:")) grammar_header_layout.addStretch() self.input_grammar_note = QTextEdit() self.input_grammar_note.setMaximumHeight(75) self.input_grammar_note.setPlaceholderText("e.g., feminine variant...") form_layout.addRow("Translation Link ID:", self.input_tx_id) form_layout.addRow(es_header_layout) form_layout.addRow(self.input_text_es) form_layout.addRow(en_header_layout) form_layout.addRow(self.input_text_en) form_layout.addRow(grammar_header_layout) form_layout.addRow(self.input_grammar_note) form_layout.addRow("Classification Profile:", self.combo_type) form_layout.addRow("Source Context ID (Raw):", self.input_context) form_layout.addRow("Anki Note Tags:", self.input_tags) form_layout.addRow("Target Deck Scope:", self.input_deck_tag) crud_buttons = QHBoxLayout() self.btn_save = QPushButton("➕ Create Pair") self.btn_update = QPushButton("💾 Update Node") self.btn_delete = QPushButton("🗑️ Sever Link") self.btn_save.clicked.connect(self.crud_create_pair) self.btn_update.clicked.connect(self.crud_update_pair) self.btn_delete.clicked.connect(self.crud_delete_pair) crud_buttons.addWidget(self.btn_save) crud_buttons.addWidget(self.btn_update) crud_buttons.addWidget(self.btn_delete) right_panel.addWidget(QLabel("

Translation Node Management Matrix

")) right_panel.addWidget(form_frame) right_panel.addLayout(crud_buttons) right_panel.addStretch() layout.addLayout(left_panel, stretch=4) layout.addLayout(right_panel, stretch=3) self.tabs.addTab(tab, "🗄️ Phrase Sandbox (CRUD)") # ===================================================================== # 🃏 TAB 2: FLASHCARD STUDY MODULE # ===================================================================== def init_flashcard_reviewer_tab(self): tab = QWidget() layout = QHBoxLayout(tab) left_panel = QVBoxLayout() filter_layout = QHBoxLayout() filter_layout.addWidget(QLabel("📂 Context:")) self.review_context_filter = QLineEdit() self.review_context_filter.setPlaceholderText("Filter Context...") self.review_context_filter.textChanged.connect(self.refresh_review_table) filter_layout.addWidget(self.review_context_filter) filter_layout.addWidget(QLabel("🏷️ Tag:")) self.review_tag_filter = QLineEdit() self.review_tag_filter.setPlaceholderText("Filter Tag...") self.review_tag_filter.textChanged.connect(self.refresh_review_table) filter_layout.addWidget(self.review_tag_filter) left_panel.addLayout(filter_layout) self.review_table = QTableWidget() self.review_table.setColumnCount(4) self.review_table.setHorizontalHeaderLabels(["Tx ID", "Spanish Phrase", "Context", "Tags"]) self.review_table.itemSelectionChanged.connect(self.handle_review_table_select) left_panel.addWidget(self.review_table) layout.addLayout(left_panel, stretch=4) right_panel = QVBoxLayout() card_frame = QFrame() card_frame.setStyleSheet("background-color: #ffffff; border: 2px solid #bdc3c7; border-radius: 12px;") card_layout = QVBoxLayout(card_frame) card_frame.setMinimumHeight(280) self.lbl_card_text = QLabel("Select a row or click 'Next Card' to initiate...") self.lbl_card_text.setAlignment(Qt.AlignmentFlag.AlignCenter) self.lbl_card_text.setFont(QFont("Arial", 20, QFont.Weight.Bold)) self.lbl_card_text.setWordWrap(True) self.lbl_card_text.setStyleSheet("color: #2c3e50; border: none; padding: 20px;") self.lbl_card_meta = QLabel("") self.lbl_card_meta.setAlignment(Qt.AlignmentFlag.AlignCenter) self.lbl_card_meta.setFont(QFont("Arial", 11)) self.lbl_card_meta.setStyleSheet("color: #7f8c8d; border: none;") card_layout.addStretch() card_layout.addWidget(self.lbl_card_text) card_layout.addWidget(self.lbl_card_meta) card_layout.addStretch() right_panel.addWidget(card_frame, stretch=4) playback_layout = QHBoxLayout() playback_layout.addWidget(QLabel("🔊 Voice Speed:")) self.slider_review_speed = QSlider(Qt.Orientation.Horizontal) self.slider_review_speed.setMinimum(50) self.slider_review_speed.setMaximum(150) self.slider_review_speed.setValue(100) self.lbl_review_speed = QLabel("1.00x") self.slider_review_speed.valueChanged.connect(self.handle_live_speed_change) playback_layout.addWidget(self.slider_review_speed) playback_layout.addWidget(self.lbl_review_speed) right_panel.addLayout(playback_layout) action_buttons = QHBoxLayout() self.btn_play_voice = QPushButton("🗣️ Play Voice Track") self.btn_flip_card = QPushButton("👁️ Reveal Translation") self.btn_play_voice.clicked.connect(self.handle_play_voice) self.btn_flip_card.clicked.connect(self.handle_flip_card) action_buttons.addWidget(self.btn_play_voice) action_buttons.addWidget(self.btn_flip_card) right_panel.addLayout(action_buttons) right_panel.addSpacing(15) bottom_utility_layout = QHBoxLayout() self.btn_export_anki = QPushButton("📦 Export Anki Deck") self.btn_export_video = QPushButton("🎬 Export Video") self.btn_load_next = QPushButton("➡️ Next Card") self.btn_export_anki.clicked.connect(self.handle_export_anki_deck) self.btn_export_video.clicked.connect(self.handle_export_video_assets) self.btn_load_next.clicked.connect(self.handle_load_next_card) utility_qss = "QPushButton { font-weight: bold; background-color: #eaf2f8; padding: 6px; border-radius: 4px; }" self.btn_export_anki.setStyleSheet(utility_qss) self.btn_export_video.setStyleSheet(utility_qss) self.btn_load_next.setStyleSheet("QPushButton { font-weight: bold; background-color: #d5f5e3; padding: 6px; border-radius: 4px; }") bottom_utility_layout.addWidget(self.btn_export_anki) bottom_utility_layout.addWidget(self.btn_export_video) bottom_utility_layout.addStretch() bottom_utility_layout.addWidget(self.btn_load_next) right_panel.addLayout(bottom_utility_layout) layout.addLayout(right_panel, stretch=3) self.tabs.addTab(tab, "🃏 Flashcard Review") # ===================================================================== # ⚙️ TAB 3: SYSTEM HARDWARE & EXPORT SETTINGS # ===================================================================== def init_settings_tab(self): tab = QWidget() layout = QVBoxLayout(tab) settings_frame = QFrame() settings_frame.setFrameShape(QFrame.Shape.StyledPanel) form_layout = QFormLayout(settings_frame) anki_layout = QHBoxLayout() self.line_anki_dir = QLineEdit(self.anki_export_dir) self.line_anki_dir.setReadOnly(True) btn_browse_anki = QPushButton("Browse 📂") btn_browse_anki.clicked.connect(self.handle_browse_anki_directory) anki_layout.addWidget(self.line_anki_dir) anki_layout.addWidget(btn_browse_anki) video_layout = QHBoxLayout() self.line_video_dir = QLineEdit(self.video_export_dir) self.line_video_dir.setReadOnly(True) btn_browse_video = QPushButton("Browse 📂") btn_browse_video.clicked.connect(self.handle_browse_video_directory) video_layout.addWidget(self.line_video_dir) video_layout.addWidget(btn_browse_video) self.combo_first_lang = QComboBox() self.combo_first_lang.addItems(["English First (en -> es)", "Spanish First (es -> en)"]) self.combo_first_lang.setCurrentText(self.video_first_lang) self.combo_first_lang.currentTextChanged.connect(lambda v: self.save_setting_to_db("video_first_language", v)) self.spin_video_repeats = QLineEdit(self.video_repeats_count) self.spin_video_repeats.setFixedWidth(60) self.spin_video_repeats.textChanged.connect(lambda v: self.save_setting_to_db("video_repeats_count", v)) self.spin_pause_duration = QLineEdit(self.video_pause_duration) self.spin_pause_duration.setFixedWidth(60) self.spin_pause_duration.textChanged.connect(lambda v: self.save_setting_to_db("video_pause_duration", v)) form_layout.addRow("Anki Deck Export Destination:", anki_layout) form_layout.addRow("Video Assembly Output Target:", video_layout) form_layout.addRow("Introductory Anchor Audio Language:", self.combo_first_lang) form_layout.addRow("Target Translation Loop Multiplier (Repeats):", self.spin_video_repeats) form_layout.addRow("User Recall Repetition Frame Intermission (Seconds):", self.spin_pause_duration) self.btn_sync_cache = QPushButton("⚡ Populate Audio & Timings Cache") self.btn_sync_cache.setStyleSheet(""" QPushButton { font-weight: bold; background-color: #e67e22; color: white; padding: 10px; border-radius: 5px; font-size: 13px; } QPushButton:hover { background-color: #d35400; } """) self.btn_sync_cache.clicked.connect(self.handle_bulk_populate_audio_cache) layout.addWidget(QLabel("

Application Preferences & Workspace Routing

")) layout.addWidget(settings_frame) layout.addWidget(self.btn_sync_cache) layout.addStretch() self.tabs.addTab(tab, "⚙️ Settings") def handle_browse_anki_directory(self): directory = QFileDialog.getExistingDirectory(self, "Select Anki Export Folder", self.anki_export_dir) if directory: self.anki_export_dir = directory self.line_anki_dir.setText(directory) self.save_setting_to_db("anki_export_directory", directory) def handle_browse_video_directory(self): directory = QFileDialog.getExistingDirectory(self, "Select Video Export Folder", self.video_export_dir) if directory: self.video_export_dir = directory self.line_video_dir.setText(directory) self.save_setting_to_db("video_export_directory", directory) def handle_bulk_populate_audio_cache(self): conn = get_connection() cursor = conn.cursor() cursor.execute(""" SELECT p1.id, p1.text, p2.id, p2.text FROM translations t JOIN phrases p1 ON t.source_phrase_id = p1.id JOIN phrases p2 ON t.target_phrase_id = p2.id """) records = cursor.fetchall() conn.close() if not records: QMessageBox.information(self, "Cache Synchronizer", "No valid translation pairs exist inside the database to process.") return print(f"⚡ Processing structural cache updates for {len(records)} node linkages...") for row in records: es_id, es_text, en_id, en_text = row[0], row[1].strip(), row[2], row[3].strip() self.get_or_generate_audio_duration(en_id, en_text, "en") self.get_or_generate_audio_duration(es_id, es_text, "es") QMessageBox.information(self, "Cache Processing Complete", "All missing speech segments successfully written. Timings cached safely.") # ===================================================================== # 💡 FLASHCARD OPERATIONS LOGIC COUPLING # ===================================================================== def handle_live_speed_change(self): val = self.slider_review_speed.value() self.lbl_review_speed.setText(f"{val / 100:.2f}x") def load_flashcard_by_id(self, translation_id): """Loads a translation node into memory and targets local text widgets without notes clutter.""" conn = get_connection() cursor = conn.cursor() cursor.execute(""" SELECT t.translation_id, p1.text, p2.text, p1.source_context, t.tags, t.notes FROM translations t JOIN phrases p1 ON t.source_phrase_id = p1.id JOIN phrases p2 ON t.target_phrase_id = p2.id WHERE t.translation_id = ? """, (translation_id,)) record = cursor.fetchone() conn.close() if record: self.current_flashcard_id = record[0] self.current_active_es_text = str(record[1]) self.current_active_en_text = str(record[2]) self.current_card_is_flipped = False self.lbl_card_text.setText(self.current_active_en_text) self.btn_flip_card.setText("👁️ Reveal Translation") # --- t.notes (record[5]) has been intentionally omitted from this layout context --- meta_str = f"Link ID: {record[0]} | Context: {record[3] or 'N/A'}" if record[4]: meta_str += f" | Tags: {record[4]}" self.lbl_card_meta.setText(meta_str) self.handle_play_voice() def handle_flip_card(self): if not self.current_flashcard_id: return if not self.current_card_is_flipped: self.lbl_card_text.setText(self.current_active_es_text) self.btn_flip_card.setText("👁️ Return to Prompt") self.current_card_is_flipped = True else: self.lbl_card_text.setText(self.current_active_en_text) self.btn_flip_card.setText("👁️ Reveal Translation") self.current_card_is_flipped = False def handle_play_voice(self): if not self.current_flashcard_id: return text_target = self.current_active_es_text if self.current_card_is_flipped else self.current_active_en_text lang_target = "es" if self.current_card_is_flipped else "en" speed_target = self.lbl_review_speed.text() self.execute_playback(text_target, lang_target, speed_target) def handle_load_next_card(self): if not self.flashcard_ids_pool: QMessageBox.information(self, "Pool Empty", "No flashcards found in the matrix matching current criteria filters.") return next_tx_id = random.choice(self.flashcard_ids_pool) for row in range(self.review_table.rowCount()): if int(self.review_table.item(row, 0).text()) == next_tx_id: self.review_table.setCurrentCell(row, 0) break self.load_flashcard_by_id(next_tx_id) # ===================================================================== # 📦 GENANKI EXPORT ENGINE (RESTRUCTURED PURE TRANSLATION FLOW) # ===================================================================== def handle_export_anki_deck(self): """ Gathers selected records from the matching criteria pool view, builds a dual card template layout mapping English->Spanish (Card 1) and Spanish->English (Card 2) forward-reverse pairs cleanly. Explicitly maps 4 fields: EnglishText, EnglishAudio, SpanishText, SpanishAudio. Context and notes are fully removed. """ targets = self.flashcard_ids_pool if not targets: QMessageBox.warning(self, "Export Aborted", "The active flashcard pool filter is completely empty. Nothing to export.") return conn = get_connection() cursor = conn.cursor() placeholders = ",".join("?" for _ in targets) cursor.execute(f""" SELECT t.translation_id, p1.text, p2.text, p1.source_context, t.tags, t.notes, t.deck_name FROM translations t JOIN phrases p1 ON t.source_phrase_id = p1.id JOIN phrases p2 ON t.target_phrase_id = p2.id WHERE t.translation_id IN ({placeholders}) """, targets) records = cursor.fetchall() conn.close() if not records: QMessageBox.information(self, "Export Processing", "No structured database entries matched your criteria indices parameters.") return # Unique Identification Anchor Codes for Anki Database Integrity MODEL_ID = 1684321095 DECK_ID = 2026062011 # New Model structure aligning English Text/Audio with Spanish Text/Audio sequences spanish_model = genanki.Model( MODEL_ID, 'Castilian Learning Model (Pure Text & Audio Alignment)', fields=[ {'name': 'EnglishText'}, {'name': 'EnglishAudio'}, {'name': 'SpanishText'}, {'name': 'SpanishAudio'} ], templates=[ { 'name': 'Card 1: English -> Spanish', 'qfmt': ( '
🇬🇧 ENGLISH COMPREHENSION
' '
{{EnglishText}}
' '
{{EnglishAudio}}
' ), 'afmt': ( '{{FrontSide}}
' '
🇪🇸 SPANISH PRODUCTION
' '
{{SpanishText}}
' '
{{SpanishAudio}}
' ), }, { 'name': 'Card 2: Spanish -> English', 'qfmt': ( '
🇪🇸 SPANISH PRODUCTION
' '
{{SpanishText}}
' '
{{SpanishAudio}}
' ), 'afmt': ( '{{FrontSide}}
' '
🇬🇧 ENGLISH COMPREHENSION
' '
{{EnglishText}}
' '
{{EnglishAudio}}
' ), }, ], css='.card { font-family: arial; font-size: 20px; text-align: center; background-color: #fafafa; padding: 25px; border-radius: 8px; }' ) deck_name_fallback = records[0][6] if records[0][6] else "Castilian Voice Trainer" anki_deck = genanki.Deck(DECK_ID, f"Spanish::{deck_name_fallback}") media_files_bundle = [] print(f"📦 Assembling Anki audio package for {len(records)} notes...") for row in records: tx_id, es_text, en_text, context, tags, notes, deck_group = row es_clean = es_text.strip() en_clean = en_text.strip() # --- Spanish Media Track Setup --- safe_es_name = "".join([c for c in es_clean if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower() if not safe_es_name: safe_es_name = hashlib.sha256(es_clean.encode('utf-8')).hexdigest()[:16] audio_es_filename = f"{safe_es_name}_es_female.mp3" full_es_path = f"media/{audio_es_filename}" if not os.path.exists(full_es_path): self.get_or_generate_audio_duration(None, es_clean, "es") if os.path.exists(full_es_path): media_files_bundle.append(full_es_path) es_audio_tag = f"[sound:{audio_es_filename}]" else: es_audio_tag = "" # --- English Media Track Setup --- safe_en_name = "".join([c for c in en_clean if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower() if not safe_en_name: safe_en_name = hashlib.sha256(en_clean.encode('utf-8')).hexdigest()[:16] audio_en_filename = f"{safe_en_name}_en_female.mp3" full_en_path = f"media/{audio_en_filename}" if not os.path.exists(full_en_path): self.get_or_generate_audio_duration(None, en_clean, "en") if os.path.exists(full_en_path): media_files_bundle.append(full_en_path) en_audio_tag = f"[sound:{audio_en_filename}]" else: en_audio_tag = "" # Meta and Category Tag Construction tag_list = str(tags).split() if tags else [] if context: tag_list.append(str(context).replace(" ", "_").replace(".", "_")) # Populate note fields sequentially matching model specification: # EnglishText, EnglishAudio, SpanishText, SpanishAudio anki_note = genanki.Note( model=spanish_model, fields=[ en_text, en_audio_tag, es_text, es_audio_tag ], tags=tag_list ) anki_deck.add_note(anki_note) # Output compilation assembly export_output_path = os.path.join(self.anki_export_dir, f"{deck_name_fallback.replace('::', '_')}.apkg") package = genanki.Package(anki_deck) package.media_files = list(set(media_files_bundle)) # Excludes duplicate track instances package.write_to_file(export_output_path) print(f"✅ Success! Balanced text-audio cards exported cleanly: {export_output_path}") QMessageBox.information( self, "Anki Package Compiled", f"Successfully compiled {len(records)} balanced text-audio translation flashcard nodes.\n\nDestination:\n{export_output_path}" ) def handle_export_video_assets(self): pass # ===================================================================== # ➕ CRUD ENGINE ATOMIC OPERATIONS LOGIC # ===================================================================== def crud_create_pair(self): conn = get_connection() cursor = conn.cursor() cursor.execute(""" INSERT INTO phrases (text, language, word_type, source_context) VALUES (?, 'es', ?, ?) """, (self.input_text_es.toPlainText().strip(), self.combo_type.currentText(), self.input_context.text().strip())) es_id = cursor.lastrowid cursor.execute(""" INSERT INTO phrases (text, language, word_type, source_context) VALUES (?, 'en', ?, ?) """, (self.input_text_en.toPlainText().strip(), self.combo_type.currentText(), self.input_context.text().strip())) en_id = cursor.lastrowid cursor.execute(""" INSERT INTO translations (source_phrase_id, target_phrase_id, deck_name, notes, tags) VALUES (?, ?, ?, ?, ?) """, (es_id, en_id, self.input_deck_tag.text().strip() or "General", self.input_grammar_note.toPlainText().strip(), self.input_tags.text().strip())) tx_id = cursor.lastrowid conn.commit() conn.close() self.input_tx_id.setText(str(tx_id)) self.current_sandbox_es_id = es_id self.current_sandbox_en_id = en_id self.refresh_crud_table() self.refresh_review_table() QMessageBox.information(self, "Success", f"Isolated phrase pairs created and bound to Translation ID {tx_id}.") def crud_update_pair(self): tx_id_str = self.input_tx_id.text().strip() if not tx_id_str: QMessageBox.warning(self, "Update Target Missing", "No Translation Link ID found. Select an existing record node or create a fresh link pair first.") return if self.current_sandbox_es_id is None or self.current_sandbox_en_id is None: QMessageBox.warning(self, "Phrase Nodes Untracked", "Underlying unique identifiers for individual language components are missing. Reselect the row from the left panel matrix grid.") return conn = get_connection() cursor = conn.cursor() try: cursor.execute(""" UPDATE phrases SET text = ?, word_type = ?, source_context = ?, duration = NULL WHERE id = ? """, (self.input_text_es.toPlainText().strip(), self.combo_type.currentText(), self.input_context.text().strip(), self.current_sandbox_es_id)) cursor.execute(""" UPDATE phrases SET text = ?, word_type = ?, source_context = ?, duration = NULL WHERE id = ? """, (self.input_text_en.toPlainText().strip(), self.combo_type.currentText(), self.input_context.text().strip(), self.current_sandbox_en_id)) cursor.execute(""" UPDATE translations SET deck_name = ?, notes = ?, tags = ? WHERE translation_id = ? """, (self.input_deck_tag.text().strip() or "General", self.input_grammar_note.toPlainText().strip(), self.input_tags.text().strip(), int(tx_id_str))) conn.commit() except Exception as e: QMessageBox.critical(self, "Database Error", f"Failed to execute field modifications inside SQL engine: {e}") finally: conn.close() self.refresh_crud_table() self.refresh_review_table() QMessageBox.information(self, "Success", f"Node structural fields updated successfully. Translation Link ID {tx_id_str} remains active.") def crud_delete_pair(self): pass def execute_playback(self, text_str, lang, speed_text): txt = text_str.strip() if not txt: return self.get_or_generate_audio_duration(None, txt, lang) safe_name = "".join([c for c in txt if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower() if not safe_name: safe_name = hashlib.sha256(txt.encode('utf-8')).hexdigest()[:16] target_file = f"media/{safe_name}_{lang}_female.mp3" if os.path.exists(target_file): try: multiplier = float(speed_text.replace("x", "")) except ValueError: multiplier = 1.0 self.media_player.stop() self.media_player.setSource(QUrl.fromLocalFile(os.path.abspath(target_file))) self.media_player.setLoops(1) self.media_player.setPlaybackRate(multiplier) self.media_player.play() def handle_sandbox_play_es(self): self.execute_playback(self.input_text_es.toPlainText(), "es", self.combo_speed_es.currentText()) def handle_sandbox_play_en(self): self.execute_playback(self.input_text_en.toPlainText(), "en", self.combo_speed_en.currentText()) # ===================================================================== # ⚡ DATA MATRIX CONTROL & SYNCHRONIZATION VIEWS # ===================================================================== def refresh_crud_table(self): conn = get_connection() cursor = conn.cursor() text_filter = self.search_text_input.text().strip() context_filter = self.search_context_input.text().strip() query = """ SELECT t.translation_id, p1.text, p2.text, p1.word_type, p1.source_context, t.deck_name 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.language = 'es' AND p2.language = 'en' """ params = [] if text_filter: query += " AND (p1.text LIKE ? OR p2.text LIKE ?)" params.extend([f"%{text_filter}%", f"%{text_filter}%"]) if context_filter: query += " AND p1.source_context LIKE ?" params.append(f"%{context_filter}%") query += " ORDER BY t.translation_id ASC LIMIT 250" cursor.execute(query, params) rows = cursor.fetchall() conn.close() self.translation_table.setRowCount(0) for row_idx, row_data in enumerate(rows): self.translation_table.insertRow(row_idx) for col_idx in range(6): val = row_data[col_idx] self.translation_table.setItem(row_idx, col_idx, QTableWidgetItem(str(val if val is not None else ""))) def refresh_review_table(self): conn = get_connection() cursor = conn.cursor() context_filter = self.review_context_filter.text().strip() tag_filter = self.review_tag_filter.text().strip() query = """ SELECT t.translation_id, p1.text, p1.source_context, t.tags FROM translations t JOIN phrases p1 ON t.source_phrase_id = p1.id WHERE p1.language = 'es' """ params = [] if context_filter: query += " AND p1.source_context LIKE ?" params.append(f"%{context_filter}%") if tag_filter: query += " AND t.tags LIKE ?" params.append(f"%{tag_filter}%") query += " ORDER BY t.translation_id ASC" cursor.execute(query, params) rows = cursor.fetchall() conn.close() self.review_table.setRowCount(0) self.flashcard_ids_pool = [] for row_idx, row_data in enumerate(rows): self.review_table.insertRow(row_idx) self.flashcard_ids_pool.append(row_data[0]) for col_idx in range(4): val = row_data[col_idx] self.review_table.setItem(row_idx, col_idx, QTableWidgetItem(str(val if val is not None else ""))) def handle_table_row_select(self): selected_ranges = self.translation_table.selectedRanges() if not selected_ranges: return row = selected_ranges[0].topRow() tx_id_item = self.translation_table.item(row, 0) if not tx_id_item: return tx_id = tx_id_item.text() conn = get_connection() cursor = conn.cursor() cursor.execute(""" SELECT t.translation_id, p1.text, p2.text, p1.word_type, p1.source_context, t.deck_name, t.notes, t.tags, p1.id, p2.id FROM translations t JOIN phrases p1 ON t.source_phrase_id = p1.id JOIN phrases p2 ON t.target_phrase_id = p2.id WHERE t.translation_id = ? """, (tx_id,)) record = cursor.fetchone() conn.close() if record: self.input_tx_id.setText(str(record[0])) self.input_text_es.setPlainText(str(record[1])) self.input_text_en.setPlainText(str(record[2])) self.combo_type.setCurrentText(str(record[3]) if record[3] else "phrase") self.input_context.setText(str(record[4]) if record[4] else "") self.input_tags.setText(str(record[7]) if record[7] is not None else "") self.input_deck_tag.setText(str(record[5]) if record[5] else "General") self.input_grammar_note.setPlainText(str(record[6]) if record[6] is not None else "") self.current_sandbox_es_id = record[8] self.current_sandbox_en_id = record[9] def handle_review_table_select(self): selected_ranges = self.review_table.selectedRanges() if not selected_ranges: return row = selected_ranges[0].topRow() if row < len(self.flashcard_ids_pool): translation_id = self.flashcard_ids_pool[row] self.load_flashcard_by_id(translation_id) def step_table_row(self, direction): current_row = self.translation_table.currentRow() next_row = current_row + direction if 0 <= next_row < self.translation_table.rowCount(): self.translation_table.setCurrentCell(next_row, 0) def clear_id_if_new_entry(self): if self.input_tx_id.text() and not (self.input_text_es.hasFocus() or self.input_text_en.hasFocus()): pass if __name__ == "__main__": app = QApplication(sys.argv) app.setApplicationName("Spanish Voice Trainer") app.setOrganizationName("Oxnee Pty. Ltd.") window = SpanishTrainerApp() window.show() sys.exit(app.exec())