diff --git a/anki_exporter.py b/anki_exporter.py index e69de29..91efb07 100644 --- a/anki_exporter.py +++ b/anki_exporter.py @@ -0,0 +1,92 @@ +# anki_exporter.py +import os +import tempfile +import subprocess +import shutil +import genanki + +def compile_anki_package(records, output_path, deck_name): + """ + Compiles database records into an .apkg package using native macOS TTS. + Uses 'Monica' for Spanish targets and the default premium system voice for English. + """ + # Create a unique random Model ID and Deck ID for genanki + model_id = 1684329011 + deck_id = 1684329012 + + # Define the Anki Card Layout structure with audio fields + anki_model = genanki.Model( + model_id, + 'Spanish Voice Trainer Model', + fields=[ + {'name': 'EnglishText'}, + {'name': 'SpanishText'}, + {'name': 'Notes'}, + {'name': 'EnglishAudio'}, + {'name': 'SpanishAudio'} + ], + templates=[ + { + 'name': 'Card 1', + 'qfmt': '
' + 'Translate to Spanish:

{{EnglishText}}
{{EnglishAudio}}
', + 'afmt': '{{FrontSide}}
' + '
' + '{{SpanishText}}

' + '
' + '{{Notes}}

' + '
{{SpanishAudio}}
', + }, + ] + ) + + deck = genanki.Deck(deck_id, deck_name) + media_files = [] + + # Process all records inside a secure temporary directory workspace + with tempfile.TemporaryDirectory() as tmpdir: + for idx, record in enumerate(records): + en_text = record["en_text"] + es_text = record["es_text"] + notes = f"Context: {record['source_context'] or ''} | {record['notes'] or ''}".strip(" | ") + + # Generate unique filenames for the media assets + en_audio_filename = f"en_audio_{idx}.mp3" + es_audio_filename = f"es_audio_{idx}.mp3" + + en_audio_path = os.path.join(tmpdir, en_audio_filename) + es_audio_path = os.path.join(tmpdir, es_audio_filename) + + try: + # 1. Render English Audio using native macOS text-to-speech engine + subprocess.run( + ["say", "-o", en_audio_path, "--data-format=Iface", en_text], + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + media_files.append(en_audio_path) + en_audio_field = f"[sound:{en_audio_filename}]" + except Exception: + en_audio_field = "" + + try: + # 2. Render Spanish Audio explicitly targeting the Monica voice profile + subprocess.run( + ["say", "-v", "Monica", "-o", es_audio_path, "--data-format=Iface", es_text], + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + media_files.append(es_audio_path) + es_audio_field = f"[sound:{es_audio_filename}]" + except Exception: + es_audio_field = "" + + # Build the card note stack + note = genanki.Note( + model=anki_model, + fields=[en_text, es_text, notes, en_audio_field, es_audio_field] + ) + deck.add_note(note) + + # Build package collection mapping archive pipelines + package = genanki.Package(deck) + package.media_files = media_files + package.write_to_file(output_path) \ No newline at end of file diff --git a/database.py b/database.py index e69de29..1718b67 100644 --- a/database.py +++ b/database.py @@ -0,0 +1,152 @@ +# database.py +import sqlite3 +import os + +DB_NAME = "spanish_trainer.db" + +def get_connection(): + """Returns a connection to the SQLite database with row factory enabled.""" + conn = sqlite3.connect(DB_NAME) + conn.row_factory = sqlite3.Row + return conn + +def ensure_database_populated(): + """ + Creates empty tables using the unified schema if running + in a fresh environment without a database file. + """ + conn = get_connection() + cursor = conn.cursor() + try: + # 1. Simplified unified translations table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS translations ( + translation_id INTEGER PRIMARY KEY AUTOINCREMENT, + es_text TEXT NOT NULL, + en_text TEXT NOT NULL, + source_context TEXT, + tags TEXT, + notes TEXT + ); + """) + + # 2. Key-value configuration table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + ); + """) + conn.commit() + finally: + conn.close() + +# ========================================== +# SETTINGS CRUD FUNCTIONS +# ========================================== + +def load_all_settings(): + """Fetches all system configuration properties into a flat Python dictionary.""" + conn = get_connection() + cursor = conn.cursor() + settings_dict = {} + try: + cursor.execute("SELECT settings.key, settings.value FROM settings;") + for row in cursor.fetchall(): + settings_dict[row["key"]] = row["value"] + finally: + conn.close() + return settings_dict + +def save_setting_to_db(key, value): + """Inserts or replaces an application configuration entry.""" + conn = get_connection() + cursor = conn.cursor() + try: + cursor.execute(""" + INSERT OR REPLACE INTO settings (key, value) + VALUES (?, ?); + """, (key, value)) + conn.commit() + finally: + conn.close() + +# ========================================== +# TRANSLATIONS CRUD FUNCTIONS +# ========================================== + +def get_all_translations_explicit(): + """ + Retrieves all 784+ records using completely explicit, + table-qualified column declarations for the engines. + """ + conn = get_connection() + cursor = conn.cursor() + try: + cursor.execute(""" + SELECT + translations.translation_id, + translations.es_text, + translations.en_text, + translations.source_context, + translations.tags, + translations.notes + FROM translations + ORDER BY translations.translation_id ASC; + """) + return [dict(row) for row in cursor.fetchall()] + finally: + conn.close() + +def get_translation_by_id(translation_id): + """Loads a single unified record row for specific inspection or editing.""" + conn = get_connection() + cursor = conn.cursor() + try: + cursor.execute(""" + SELECT + translations.translation_id, + translations.es_text, + translations.en_text, + translations.source_context, + translations.tags, + translations.notes + FROM translations + WHERE translations.translation_id = ?; + """, (translation_id,)) + row = cursor.fetchone() + return dict(row) if row else None + finally: + conn.close() + +def update_translation_record(translation_id, es_text, en_text, source_context, tags, notes): + """Saves sandbox interface edits directly back down into the table.""" + conn = get_connection() + cursor = conn.cursor() + try: + cursor.execute(""" + UPDATE translations + SET + es_text = ?, + en_text = ?, + source_context = ?, + tags = ?, + notes = ? + WHERE translation_id = ?; + """, (es_text, en_text, source_context, tags, notes, translation_id)) + conn.commit() + finally: + conn.close() + +def delete_translation_record(translation_id): + """Permanently drops a phrase card row from the data index.""" + conn = get_connection() + cursor = conn.cursor() + try: + cursor.execute(""" + DELETE FROM translations + WHERE translations.translation_id = ?; + """, (translation_id,)) + conn.commit() + finally: + conn.close() \ No newline at end of file diff --git a/database/__init__.py b/database_legacy/__init__.py similarity index 100% rename from database/__init__.py rename to database_legacy/__init__.py diff --git a/database/connection.py b/database_legacy/connection.py similarity index 100% rename from database/connection.py rename to database_legacy/connection.py diff --git a/database/trainer_backup_20260618_220043.db b/database_legacy/trainer_backup_20260618_220043.db similarity index 100% rename from database/trainer_backup_20260618_220043.db rename to database_legacy/trainer_backup_20260618_220043.db diff --git a/database/trainer_backup_20260618_224811.db b/database_legacy/trainer_backup_20260618_224811.db similarity index 100% rename from database/trainer_backup_20260618_224811.db rename to database_legacy/trainer_backup_20260618_224811.db diff --git a/main.py b/main.py index 401a433..a473be3 100644 --- a/main.py +++ b/main.py @@ -1,1106 +1,129 @@ # 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 +from PyQt6.QtWidgets import QMainWindow, QTabWidget, QApplication, QMessageBox, QVBoxLayout, QWidget +from PyQt6.QtCore import pyqtSlot -# Third-Party Tooling -import genanki -import edge_tts -from PIL import Image, ImageDraw, ImageFont +# Import our unified data layer +import database -# Internal Project Module Imports -from database.connection import init_db, get_connection -from core.bulk_importer import BulkImporter -from core.clean_glossary import GlossaryCleaner +# Import the UI components from our sub-package +from tabs.sandbox_tab import SandboxTab +from tabs.review_tab import ReviewTab +from tabs.settings_tab import SettingsTab -class SpanishTrainerApp(QMainWindow): +# Import our background engine runners +import anki_exporter +import video_generator + +class MainWindow(QMainWindow): def __init__(self): super().__init__() - self.setWindowTitle("Castilian Voice Trainer Pro") - self.setMinimumSize(1200, 800) + self.setWindowTitle("Castilian Spanish Voice Trainer") + self.setMinimumSize(900, 650) - # 1. Initialize schema structures and check ingestion status - self.ensure_database_populated() + # 1. Ensure database structure exists on startup + database.ensure_database_populated() - # Load system persistent settings from DB (including sleep-learning fields) - self.load_system_settings() + # 2. In-memory cache for application configurations + self.app_settings = {} + self.refresh_settings_cache() - # Audio Player Architecture Setup - self.media_player = QMediaPlayer() - self.audio_output = QAudioOutput() - self.media_player.setAudioOutput(self.audio_output) + # 3. Setup central tabbed layout container + self.central_widget = QWidget() + self.setCentralWidget(self.central_widget) + self.main_layout = QVBoxLayout(self.central_widget) - # 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.tab_widget = QTabWidget() + self.main_layout.addWidget(self.tab_widget) - self.current_sandbox_es_id = None - self.current_sandbox_en_id = None + # 4. Instantiate isolated tab modules + self.sandbox_tab = SandboxTab() + self.review_tab = ReviewTab() + self.settings_tab = SettingsTab() - # Central Main Window Tabs Interface - self.tabs = QTabWidget() - self.setCentralWidget(self.tabs) + # 5. Mount tabs with Database Sandbox on the far left (Index 0) + self.tab_widget.addTab(self.sandbox_tab, "Database Sandbox") + self.tab_widget.addTab(self.review_tab, "Flashcard Review") + self.tab_widget.addTab(self.settings_tab, "Configuration Settings") - 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() + # 6. Wire up the cross-module communication channels (Signals) + self.wire_application_signals() - 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 refresh_settings_cache(self): + """Loads all database settings into an easy-to-read dictionary.""" + self.app_settings = database.load_all_settings() - 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" - self.default_deck_name = "Trainer" # Unified structural configuration fallback + def wire_application_signals(self): + """Connects tab user-actions to execution managers inside main.py.""" + # Settings Tab Signal Actions + self.settings_tab.settings_changed.connect(self.handle_settings_update) + self.settings_tab.export_anki_requested.connect(self.execute_anki_export) + self.settings_tab.generate_video_requested.connect(self.execute_video_generation) - 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] - elif row[0] == "default_deck_name": - self.default_deck_name = row[1] - except Exception as e: - print(f"⚠️ Failed to read application settings from database: {e}") - finally: - conn.close() + # Sandbox / Review Tab synchronization signals + self.sandbox_tab.data_mutated.connect(self.refresh_ui_views) - 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 - elif key == "default_deck_name": - self.default_deck_name = value - except Exception as e: - print(f"❌ Critical: Failed to save setting '{key}': {e}") - finally: - conn.close() + @pyqtSlot(str, str) + def handle_settings_update(self, key, value): + """Saves a modified setting value directly down to the database and updates memory cache.""" + database.save_setting_to_db(key, value) + self.refresh_settings_cache() - 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) + @pyqtSlot(str) + def execute_anki_export(self, full_deck_name): + """Bridges data coordinates out of SQLite to trigger the text-to-speech anki file compiler.""" + self.refresh_settings_cache() + output_dir = self.app_settings.get("anki_export_directory", os.path.expanduser("~")) + output_file = os.path.join(output_dir, f"{full_deck_name.replace('::', '_')}.apkg") - left_panel = QVBoxLayout() + # Pull all active translation records out via explicit SQL matching + records = database.get_all_translations_explicit() - 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("Overrides System Default Workspace Deck") - - 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 (Optional):", 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) - - # New Workspace Target Settings Directive Entry - self.line_default_deck = QLineEdit(self.default_deck_name) - self.line_default_deck.setPlaceholderText("e.g., Spanish::Aula_Plus_1") - self.line_default_deck.textChanged.connect(lambda v: self.save_setting_to_db("default_deck_name", v.strip())) - - 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("Default Target Deck String:", self.line_default_deck) - 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") - - 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: + QMessageBox.warning(self, "Export Failed", "The database contains no translation records to export.") 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. - """ - # Architectural Fallback: If pool is empty, run an internal update pass first to grab current matrix records - if not self.flashcard_ids_pool: - self.refresh_review_table() - - 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; }' - ) - - # --- RESTRUCTURED STRINGS SAFEGUARD BLOCK --- - # Safeguard checks to normalize index 6 data entry and prevent generation string crashes - raw_deck_entry = records[0][6] - if raw_deck_entry and str(raw_deck_entry).strip(): - deck_name_fallback = str(raw_deck_entry).strip() - else: - deck_name_fallback = self.default_deck_name if self.default_deck_name else "Trainer" - - # Explicitly ensure the Anki namespace structural hierarchy sequence is intact - full_deck_string = f"Spanish::{deck_name_fallback}" if "Spanish::" not in deck_name_fallback else deck_name_fallback - - anki_deck = genanki.Deck(DECK_ID, full_deck_string) - media_files_bundle = [] - - print(f"πŸ“¦ Assembling Anki audio package for {len(records)} notes under deck: {full_deck_string}...") - - 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(".", "_")) - - 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 file name normalization optimization to sweep unmapped character sets - safe_file_name = deck_name_fallback.replace('::', '_').replace('/', '_').strip() - export_output_path = os.path.join(self.anki_export_dir, f"{safe_file_name}.apkg") - try: - package = genanki.Package(anki_deck) - package.media_files = list(set(media_files_bundle)) - 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}" - ) - except Exception as export_err: - print(f"❌ Genanki Write Failure Exception Error: {export_err}") - QMessageBox.critical( - self, - "Export Failure Error", - f"The packaging sub-engine failed to write file to disk:\n{export_err}" - ) - - 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 - - # Pulls from self.input_deck_tag if typed, cleanly defaults to global persistent variable self.default_deck_name - target_deck = self.input_deck_tag.text().strip() or self.default_deck_name - - cursor.execute(""" - INSERT INTO translations (source_phrase_id, target_phrase_id, deck_name, notes, tags) VALUES (?, ?, ?, ?, ?) - """, (es_id, en_id, target_deck, 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)) - - # Updated field configuration using system preferences cache fallback assignment - target_deck = self.input_deck_tag.text().strip() or self.default_deck_name - - cursor.execute(""" - UPDATE translations - SET deck_name = ?, notes = ?, tags = ? - WHERE translation_id = ? - """, (target_deck, self.input_grammar_note.toPlainText().strip(), self.input_tags.text().strip(), int(tx_id_str))) - - conn.commit() + anki_exporter.compile_anki_package(records, output_file, full_deck_name) + QMessageBox.information(self, "Export Success", f"Successfully compiled light-weight TTS deck to:\n{output_file}") except Exception as e: - QMessageBox.critical(self, "Database Error", f"Failed to execute field modifications inside SQL engine: {e}") - finally: - conn.close() + QMessageBox.critical(self, "Export Error", f"Failed to parse or write Anki package archive:\n{str(e)}") - 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) + @pyqtSlot() + def execute_video_generation(self): + """Coordinates data streams to invoke the independent video compilation generator.""" + self.refresh_settings_cache() + records = database.get_all_translations_explicit() - 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: + if not records: + QMessageBox.warning(self, "Generation Failed", "No phrases available to construct a training video playlist loops.") return - tx_id = tx_id_item.text() + # UI updates can block execution loops, so we notify before processing + self.statusBar().showMessage("Generating training video assets. Please wait...") + QApplication.processEvents() - 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 "") - 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] + try: + video_generator.generate_training_video(records, self.app_settings) + QMessageBox.information(self, "Success", "MP4 Video compilation completed successfully.") + self.statusBar().showMessage("Video compilation completed.", 5000) + except Exception as e: + QMessageBox.critical(self, "Video Error", f"An error occurred during frame rendering sequences:\n{str(e)}") + self.statusBar().clearMessage() - 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 + @pyqtSlot() + def refresh_ui_views(self): + """Instructs distinct active tabs to reload data windows after database changes occur.""" + self.sandbox_tab.reload_table_display() + self.review_tab.reload_review_pool() if __name__ == "__main__": app = QApplication(sys.argv) - window = SpanishTrainerApp() + + # Modern clean styling choices for desktop applications + app.setStyle('Fusion') + + window = MainWindow() window.show() sys.exit(app.exec()) \ No newline at end of file diff --git a/migrate_database.py b/migrate_database.py new file mode 100644 index 0000000..13df5ba --- /dev/null +++ b/migrate_database.py @@ -0,0 +1,114 @@ +# migrate_database.py +import sqlite3 +import os + +OLD_DB = "spanish_trainer_legacy.db" # Your existing database renamed +NEW_DB = "spanish_trainer.db" # The fresh, simplified target database + +def migrate(): + if not os.path.exists(OLD_DB): + print(f"❌ Error: Could not find legacy database file named '{OLD_DB}'") + print("Please rename your active database file to match before running this script.") + return + + print("πŸš€ Initializing schema transformation...") + + # Connect to both databases + conn_old = sqlite3.connect(OLD_DB) + conn_old.row_factory = sqlite3.Row + cursor_old = conn_old.cursor() + + conn_new = sqlite3.connect(NEW_DB) + cursor_new = conn_new.cursor() + + # 1. Provision the clean, simplified new tables + cursor_new.execute(""" + CREATE TABLE IF NOT EXISTS translations ( + translation_id INTEGER PRIMARY KEY AUTOINCREMENT, + es_text TEXT NOT NULL, + en_text TEXT NOT NULL, + source_context TEXT, + tags TEXT, + notes TEXT + ); + """) + + cursor_new.execute(""" + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + ); + """) + + # 2. Extract and pair data using explicit, table-qualified SQL queries + print("πŸ“¦ Extracting and consolidating relational text rows...") + migration_query = """ + SELECT + t.translation_id, + p1.text AS spanish_phrase, + p2.text AS english_translation, + p1.source_context AS textbook_unit, + t.tags AS metadata_tags, + t.notes AS historical_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 p1.language = 'es' + AND p2.language = 'en' + ORDER BY t.translation_id ASC; + """ + + try: + cursor_old.execute(migration_query) + legacy_records = cursor_old.fetchall() + except sqlite3.OperationalError as e: + print(f"❌ Legacy structure lookup failed: {e}") + print("Verify your old table structures match the schema before running.") + conn_old.close() + conn_new.close() + return + + # 3. Insert records into the new simplified table structure + inserted_count = 0 + for row in legacy_records: + cursor_new.execute(""" + INSERT INTO translations ( + translation_id, + es_text, + en_text, + source_context, + tags, + notes + ) VALUES (?, ?, ?, ?, ?, ?); + """, ( + row["translation_id"], + row["spanish_phrase"], + row["english_translation"], + row["textbook_unit"], + row["metadata_tags"], + row["historical_notes"] + )) + inserted_count += 1 + + # 4. Copy existing system configuration keys over safely + try: + cursor_old.execute("SELECT key, value FROM settings;") + settings_records = cursor_old.fetchall() + for setting in settings_records: + cursor_new.execute(""" + INSERT OR REPLACE INTO settings (key, value) + VALUES (?, ?); + """, (setting["key"], setting["value"])) + except sqlite3.OperationalError: + print("⚠️ Warning: No legacy settings table found or could not read it. Skipping settings copy.") + + # Commit changes and clean up connections + conn_new.commit() + conn_old.close() + conn_new.close() + + print(f"✨ Migration complete! Successfully converted {inserted_count} text rows.") + print(f"πŸ’Ύ Fresh database engine ready at: {NEW_DB}") + +if __name__ == "__main__": + migrate() \ No newline at end of file diff --git a/spanish_trainer.db b/spanish_trainer.db index 42db890..71e66d5 100644 Binary files a/spanish_trainer.db and b/spanish_trainer.db differ diff --git a/spanish_trainer_legacy.db b/spanish_trainer_legacy.db new file mode 100644 index 0000000..42db890 Binary files /dev/null and b/spanish_trainer_legacy.db differ diff --git a/tabs/__init__.py b/tabs/__init__.py index e69de29..551983a 100644 --- a/tabs/__init__.py +++ b/tabs/__init__.py @@ -0,0 +1,5 @@ +# tabs/__init__.py +# Leave this file empty, or just expose the tabs like this: +from .sandbox_tab import SandboxTab +from .review_tab import ReviewTab +from .settings_tab import SettingsTab \ No newline at end of file diff --git a/tabs/review_tab.py b/tabs/review_tab.py index e69de29..e189a83 100644 --- a/tabs/review_tab.py +++ b/tabs/review_tab.py @@ -0,0 +1,201 @@ +# tabs/review_tab.py +import random +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QFrame, QStackedWidget +) +from PyQt6.QtCore import Qt, pyqtSlot +import database + +class ReviewTab(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + # Core State Variables + self.review_pool = [] + self.current_index = -1 + + # Primary Layout + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(30, 20, 30, 20) + main_layout.setSpacing(20) + + # Header Status Tracker + self.lbl_status = QLabel("Session Status: No active cards loaded.") + self.lbl_status.setStyleSheet("font-size: 13px; font-weight: bold; color: #7F8C8D; letter-spacing: 0.5px;") + main_layout.addWidget(self.lbl_status) + + # --- THE CARD CANVAS AREA --- + self.card_frame = QFrame() + self.card_frame.setStyleSheet(""" + QFrame { + background-color: #FAFAFA; + border: 2px solid #E5E7E9; + border-radius: 8px; + } + """) + card_layout = QVBoxLayout(self.card_frame) + card_layout.setContentsMargins(40, 40, 40, 40) + + # Stacked display interface separating Question and Answer card views + self.card_stack = QStackedWidget() + + # View A: Card Front Layout (Prompt and Source Language Text) + self.view_front = QWidget() + front_layout = QVBoxLayout(self.view_front) + front_prompt = QLabel("TRANSLATE THIS TO SPANISH:") + front_prompt.setStyleSheet("font-size: 12px; font-weight: bold; color: #BDC3C7; letter-spacing: 1px;") + front_prompt.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.lbl_english = QLabel("English Text Layer") + self.lbl_english.setStyleSheet("font-size: 26px; color: #34495E; font-weight: 500; margin-top: 20px;") + self.lbl_english.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.lbl_english.setWordWrap(True) + + front_layout.addWidget(front_prompt) + front_layout.addWidget(self.lbl_english) + front_layout.addStretch() + + # View B: Card Back Layout (Revealed target text alongside historical context notes) + self.view_back = QWidget() + back_layout = QVBoxLayout(self.view_back) + + self.lbl_spanish = QLabel("Spanish Translated Phrase") + self.lbl_spanish.setStyleSheet("font-size: 34px; font-weight: bold; color: #2980B9; margin-bottom: 10px;") + self.lbl_spanish.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.lbl_spanish.setWordWrap(True) + + self.lbl_notes = QLabel("Context/Historical reference notes go here...") + self.lbl_notes.setStyleSheet(""" + QLabel { + font-size: 15px; + font-style: italic; + color: #7F8C8D; + background-color: #EAEDED; + padding: 12px; + border-radius: 4px; + margin-top: 20px; + } + """) + self.lbl_notes.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.lbl_notes.setWordWrap(True) + + back_layout.addWidget(self.lbl_spanish) + back_layout.addWidget(self.lbl_notes) + back_layout.addStretch() + + # Mount the structural views into the execution layer index + self.card_stack.addWidget(self.view_front) + self.card_stack.addWidget(self.view_back) + card_layout.addWidget(self.card_stack) + + main_layout.addWidget(self.card_frame) + + # --- THE INTERACTIVE BOTTOM BAR CONTROL PIPELINE --- + self.control_stack = QStackedWidget() + + # Panel A: Contains exclusively the single full-width layout Reveal button + self.panel_reveal = QWidget() + reveal_layout = QHBoxLayout(self.panel_reveal) + reveal_layout.setContentsMargins(0, 0, 0, 0) + self.btn_reveal = QPushButton("Reveal Answer Verification") + self.btn_reveal.setStyleSheet(""" + QPushButton { background-color: #34495E; color: white; font-weight: bold; font-size: 15px; padding: 12px; border-radius: 5px; } + QPushButton:hover { background-color: #2C3E50; } + """) + self.btn_reveal.clicked.connect(self.reveal_card_answer) + reveal_layout.addWidget(self.btn_reveal) + + # Panel B: Contains the standard sequential navigation tools (Pass / Fail iteration indicators) + self.panel_navigation = QWidget() + nav_layout = QHBoxLayout(self.panel_navigation) + nav_layout.setContentsMargins(0, 0, 0, 0) + nav_layout.setSpacing(15) + + self.btn_next = QPushButton("Next Phrase βž”") + self.btn_next.setStyleSheet(""" + QPushButton { background-color: #27AE60; color: white; font-weight: bold; font-size: 15px; padding: 12px; border-radius: 5px; } + QPushButton:hover { background-color: #2ECC71; } + """) + self.btn_next.clicked.connect(self.advance_review_index) + nav_layout.addWidget(self.btn_next) + + self.control_stack.addWidget(self.panel_reveal) + self.control_stack.addWidget(self.panel_navigation) + + main_layout.addWidget(self.control_stack) + + # Load up your initial study loop deck pool array elements + self.reload_review_pool() + + @pyqtSlot() + def reload_review_pool(self): + """Fetches consolidated text entries from the database module and scrambles their indexing.""" + raw_records = database.get_all_translations_explicit() + + # Filter down records to ensure they possess safe core text parameters + self.review_pool = [r for r in raw_records if r["es_text"] and r["en_text"]] + + # Randomize review sequencing to prevent memory bias based on insertion order + random.shuffle(self.review_pool) + + if self.review_pool: + self.current_index = 0 + self.display_current_card_front() + else: + self.current_index = -1 + self.lbl_status.setText("Session Status: No usable records found inside spanish_trainer.db") + self.lbl_english.setText("The database appears to be empty.") + self.control_stack.setEnabled(False) + + def display_current_card_front(self): + """Configures the UI canvas parameters to display the prompt front.""" + if not (0 <= self.current_index < len(self.review_pool)): + return + + record = self.review_pool[self.current_index] + self.lbl_status.setText(f"Review Cycle Running: Phrase {self.current_index + 1} of {len(self.review_pool)}") + + # Render prompt text labels cleanly + self.lbl_english.setText(record["en_text"]) + + # Reset visual stack view states back to original baselines + self.card_stack.setCurrentIndex(0) # Switch to front canvas text display + self.control_stack.setCurrentIndex(0) # Toggle control element button bar back to 'Reveal' layout + + @pyqtSlot() + def reveal_card_answer(self): + """Displays translation targets on the card back.""" + if not (0 <= self.current_index < len(self.review_pool)): + return + + record = self.review_pool[self.current_index] + self.lbl_spanish.setText(record["es_text"]) + + # Show contextual notes or structural flags cleanly if populated + if record["notes"] or record["source_context"]: + context_string = record["source_context"] if record["source_context"] else "" + notes_string = f" | {record['notes']}" if record["notes"] else "" + self.lbl_notes.setText(f"Context: {context_string}{notes_string}") + self.lbl_notes.setVisible(True) + else: + self.lbl_notes.setVisible(False) + + # Toggle component visualization states + self.card_stack.setCurrentIndex(1) # Swap card panel over to the back answer layout + self.control_stack.setCurrentIndex(1) # Swap button layouts over to display 'Next Phrase' action bars + + @pyqtSlot() + def advance_review_index(self): + """Increments index counters to display a new flashcard container.""" + if not self.review_pool: + return + + self.current_index += 1 + + # Loop review cards continuously if the session index boundary thresholds overflow + if self.current_index >= len(self.review_pool): + self.current_index = 0 + random.shuffle(self.review_pool) # Re-scramble deck upon completing the pass loop + + self.display_current_card_front() \ No newline at end of file diff --git a/tabs/sandbox_tab.py b/tabs/sandbox_tab.py index e69de29..b2b19db 100644 --- a/tabs/sandbox_tab.py +++ b/tabs/sandbox_tab.py @@ -0,0 +1,219 @@ +# tabs/sandbox_tab.py +import subprocess +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QTableWidget, QTableWidgetItem, + QHeaderView, QMessageBox, QFormLayout +) +from PyQt6.QtCore import pyqtSignal, pyqtSlot, Qt +import database + +class SandboxTab(QWidget): + # Signal emitted whenever data is added, modified, or deleted + data_mutated = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + + # Main layout structure + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(30, 20, 30, 20) + main_layout.setSpacing(15) + + # --- SECTION 1: FORM INPUT CRADLE --- + form_container = QWidget() + form_layout = QFormLayout(form_container) + form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight) + form_layout.setSpacing(10) + + # Input Form Fields + self.txt_english = QLineEdit() + self.txt_english.setPlaceholderText("Enter English phrase or word...") + self.txt_english.setStyleSheet("padding: 6px; font-size: 14px;") + + self.txt_spanish = QLineEdit() + self.txt_spanish.setPlaceholderText("Introduce la frase en espaΓ±ol...") + self.txt_spanish.setStyleSheet("padding: 6px; font-size: 14px;") + + self.txt_context = QLineEdit() + self.txt_context.setPlaceholderText("e.g., Camino 2027, CafΓ©, Market conversation...") + self.txt_context.setStyleSheet("padding: 6px; font-size: 14px;") + + self.txt_notes = QLineEdit() + self.txt_notes.setPlaceholderText("Grammar rules, formal vs informal nuances...") + self.txt_notes.setStyleSheet("padding: 6px; font-size: 14px;") + + # Mount fields onto Form Layout + form_layout.addRow(QLabel("English Text:"), self.txt_english) + form_layout.addRow(QLabel("Spanish Text:"), self.txt_spanish) + form_layout.addRow(QLabel("Source Context:"), self.txt_context) + form_layout.addRow(QLabel("Historical Notes:"), self.txt_notes) + + main_layout.addWidget(form_container) + + # --- SECTION 2: AUDIO PREVIEW ACTION ROW --- + audio_layout = QHBoxLayout() + audio_layout.setSpacing(15) + + self.btn_play_en = QPushButton("πŸ”Š Test English Voice") + self.btn_play_en.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_play_en.setStyleSheet(""" + QPushButton { + background-color: #E67E22; + color: white; + font-weight: bold; + font-size: 13px; + padding: 8px 16px; + border-radius: 4px; + } + QPushButton:hover { background-color: #D35400; } + """) + self.btn_play_en.clicked.connect(self.preview_english_audio) + + self.btn_play_es = QPushButton("πŸ”Š Test MΓ³nica (Spanish)") + self.btn_play_es.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_play_es.setStyleSheet(""" + QPushButton { + background-color: #9B59B6; + color: white; + font-weight: bold; + font-size: 13px; + padding: 8px 16px; + border-radius: 4px; + } + QPushButton:hover { background-color: #8E44AD; } + """) + self.btn_play_es.clicked.connect(self.preview_spanish_audio) + + audio_layout.addWidget(self.btn_play_en) + audio_layout.addWidget(self.btn_play_es) + audio_layout.addStretch() + + main_layout.addLayout(audio_layout) + + # --- SECTION 3: DATA COMMIT CONTROL BAR --- + control_layout = QHBoxLayout() + + self.btn_save = QPushButton("Save Translation Record") + self.btn_save.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_save.setStyleSheet(""" + QPushButton { + background-color: #2980B9; + color: white; + font-weight: bold; + font-size: 14px; + padding: 10px 20px; + border-radius: 5px; + } + QPushButton:hover { background-color: #1F618D; } + """) + self.btn_save.clicked.connect(self.commit_translation_record) + + self.btn_clear = QPushButton("Clear Fields") + self.btn_clear.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_clear.setStyleSheet(""" + QPushButton { + background-color: #BDC3C7; + color: #34495E; + font-weight: bold; + font-size: 14px; + padding: 10px 20px; + border-radius: 5px; + } + QPushButton:hover { background-color: #95A5A6; } + """) + self.btn_clear.clicked.connect(self.clear_input_fields) + + control_layout.addWidget(self.btn_save) + control_layout.addWidget(self.btn_clear) + control_layout.addStretch() + + main_layout.addLayout(control_layout) + + # --- SECTION 4: DATALIST DISPLAY REGION --- + self.table = QTableWidget() + self.table.setColumnCount(5) + self.table.setHorizontalHeaderLabels(["ID", "English Phrase", "Spanish Translation", "Context", "Notes"]) + self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + + # Tweak display headers to scale nicely + header = self.table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive) + header.setSectionResizeMode(4, QHeaderView.ResizeMode.Interactive) + + main_layout.addWidget(self.table) + + # Populate live view from storage layout tracking engines + self.reload_table_display() + + @pyqtSlot() + def preview_english_audio(self): + """Auditions current text state inside the English text box field.""" + text = self.txt_english.text().strip() + if text: + subprocess.Popen(["say", text]) + + @pyqtSlot() + def preview_spanish_audio(self): + """Auditions current text state inside the Spanish text box field using MΓ³nica.""" + text = self.txt_spanish.text().strip() + if text: + subprocess.Popen(["say", "-v", "Monica", text]) + + @pyqtSlot() + def commit_translation_record(self): + """Extracts text metrics out of input wrappers and saves down to database engine storage.""" + en_text = self.txt_english.text().strip() + es_text = self.txt_spanish.text().strip() + context = self.txt_context.text().strip() + notes = self.txt_notes.text().strip() + + if not en_text or not es_text: + QMessageBox.warning(self, "Validation Alert", "Both English and Spanish base text blocks are required.") + return + + # Call explicit writing handlers down to SQLite database layer + database.insert_translation_explicit(en_text, es_text, context, notes) + + # Wipe structural field items cleanly on completions loop + self.clear_input_fields() + + # Sync state out across the rest of the app window elements + self.reload_table_display() + self.data_mutated.emit() + + @pyqtSlot() + def clear_input_fields(self): + """Flushes transient cache items out of form line elements.""" + self.txt_english.clear() + self.txt_spanish.clear() + self.txt_context.clear() + self.txt_notes.clear() + + def reload_table_display(self): + """Refetches database rows and populates the master dashboard grid view.""" + self.table.setRowCount(0) + records = database.get_all_translations_explicit() + + for idx, row in enumerate(records): + self.table.insertRow(idx) + + # Form clean mapping cell entities + item_id = QTableWidgetItem(str(row["translation_id"])) + item_en = QTableWidgetItem(row["en_text"]) + item_es = QTableWidgetItem(row["es_text"]) + item_ctx = QTableWidgetItem(row["source_context"] or "") + item_nts = QTableWidgetItem(row["notes"] or "") + + # Align center the ID key indices + item_id.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + + self.table.setItem(idx, 0, item_id) + self.table.setItem(idx, 1, item_en) + self.table.setItem(idx, 2, item_es) + self.table.setItem(idx, 3, item_ctx) + self.table.setItem(idx, 4, item_nts) \ No newline at end of file diff --git a/tabs/setting_tab.py b/tabs/setting_tab.py deleted file mode 100644 index e69de29..0000000 diff --git a/tabs/settings_tab.py b/tabs/settings_tab.py new file mode 100644 index 0000000..40eec84 --- /dev/null +++ b/tabs/settings_tab.py @@ -0,0 +1,145 @@ +# tabs/settings_tab.py +import os +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QFileDialog, QGroupBox, QFormLayout, + QMessageBox, QFrame +) +from PyQt6.QtCore import pyqtSignal, pyqtSlot +import database + +class SettingsTab(QWidget): + # Signals to communicate up to the centralized main.py loop coordinator + settings_changed = pyqtSignal(str, str) # Emits: (key, value) + export_anki_requested = pyqtSignal(str) # Emits: (full_deck_name) + generate_video_requested = pyqtSignal() # Emits: trigger + + def __init__(self, parent=None): + super().__init__(parent) + + main_layout = QVBoxLayout(self) + main_layout.setSpacing(15) + + # --- SECTION 1: GLOBAL ANKI PACKAGING CONFIGURATIONS --- + anki_group = QGroupBox("Anki Compilation Settings") + anki_form = QFormLayout(anki_group) + anki_form.setSpacing(10) + + self.txt_root_deck = QLineEdit() + self.txt_root_deck.setPlaceholderText("e.g., Spanish::CAE_Course") + self.txt_root_deck.textChanged.connect(lambda text: self.update_setting("anki_root_deck_name", text.strip())) + + self.txt_sub_deck = QLineEdit() + self.txt_sub_deck.setPlaceholderText("e.g., Vocabulary::Unit_1") + self.txt_sub_deck.textChanged.connect(lambda text: self.update_setting("anki_sub_deck_name", text.strip())) + + # Export Destination Directory Picker + dir_picker_layout = QHBoxLayout() + self.txt_export_dir = QLineEdit() + self.txt_export_dir.setReadOnly(True) + self.txt_export_dir.setStyleSheet("background-color: #F8F9F9; color: #34495E;") + + btn_browse = QPushButton("Browse...") + btn_browse.clicked.connect(self.browse_export_directory) + dir_picker_layout.addWidget(self.txt_export_dir) + dir_picker_layout.addWidget(btn_browse) + + anki_form.addRow("Root Deck Name:", self.txt_root_deck) + anki_form.addRow("Sub-Deck Namespace Hierarchy:", self.txt_sub_deck) + anki_form.addRow("Export Target Directory:", dir_picker_layout) + + main_layout.addWidget(anki_group) + + # --- SECTION 2: AUDIO ENGINE & COMPILATION OVERRIDES --- + engine_group = QGroupBox("Voice Synthesis & Training Configuration") + engine_form = QFormLayout(engine_group) + + self.txt_tts_voice = QLineEdit() + self.txt_tts_voice.setPlaceholderText("Apple_Monica") + self.txt_tts_voice.textChanged.connect(lambda text: self.update_setting("tts_preferred_voice", text.strip())) + + self.txt_tts_speed = QLineEdit() + self.txt_tts_speed.setPlaceholderText("1.15") + self.txt_tts_speed.textChanged.connect(lambda text: self.update_setting("tts_playback_speed", text.strip())) + + engine_form.addRow("Fallback System Voice Name:", self.txt_tts_voice) + engine_form.addRow("Target Speech Playback Multiplier:", self.txt_tts_speed) + + main_layout.addWidget(engine_group) + + # Decorative divider line + divider = QFrame() + divider.setFrameShape(QFrame.Shape.HLine) + divider.setFrameShadow(QFrame.Shadow.Sunken) + main_layout.addWidget(divider) + + # --- SECTION 3: SYSTEM ACTION EXECUTION BAR --- + actions_group = QGroupBox("Execution Pipelines") + actions_layout = QHBoxLayout(actions_group) + actions_layout.setSpacing(20) + + self.btn_export_anki = QPushButton("πŸš€ Compile Lightweight Anki APKG") + self.btn_export_anki.setStyleSheet(""" + QPushButton { background-color: #2980B9; color: white; font-weight: bold; font-size: 13px; padding: 10px; border-radius: 4px; } + QPushButton:hover { background-color: #3498DB; } + """) + self.btn_export_anki.clicked.connect(self.dispatch_anki_export) + + self.btn_gen_video = QPushButton("🎬 Generate MP4 Loop Playlists") + self.btn_gen_video.setStyleSheet(""" + QPushButton { background-color: #8E44AD; color: white; font-weight: bold; font-size: 13px; padding: 10px; border-radius: 4px; } + QPushButton:hover { background-color: #9B59B6; } + """) + self.btn_gen_video.clicked.connect(self.generate_video_requested.emit) + + actions_layout.addWidget(self.btn_export_anki) + actions_layout.addWidget(self.btn_gen_video) + + main_layout.addWidget(actions_group) + main_layout.addStretch() # Push everything neatly to the top + + # Load settings from database onto inputs on initialization + self.populate_fields_from_db_state() + + def populate_fields_from_db_state(self): + """Fetches stored parameters on view load initialization.""" + # Block signals briefly so loading state doesn't trigger write-back loops + self.blockSignals(True) + + stored_settings = database.load_all_settings() + + self.txt_root_deck.setText(stored_settings.get("anki_root_deck_name", "Spanish")) + self.txt_sub_deck.setText(stored_settings.get("anki_sub_deck_name", "")) + self.txt_export_dir.setText(stored_settings.get("anki_export_directory", os.path.expanduser("~"))) + self.txt_tts_voice.setText(stored_settings.get("tts_preferred_voice", "Apple_Monica")) + self.txt_tts_speed.setText(stored_settings.get("tts_playback_speed", "1.15")) + + self.blockSignals(False) + + def update_setting(self, key, value): + """Internal helper to communicate state mutations instantly upward.""" + self.settings_changed.emit(key, value) + + def browse_export_directory(self): + """Invokes a native macOS directory finder path browser window.""" + current_dir = self.txt_export_dir.text() or os.path.expanduser("~") + selected_directory = QFileDialog.getExistingDirectory( + self, "Select Anki Export Target Location", current_dir + ) + + if selected_directory: + self.txt_export_dir.setText(selected_directory) + self.update_setting("anki_export_directory", selected_directory) + + def dispatch_anki_export(self): + """Constructs and validates the structured deck names namespace before signaling main.py.""" + root = self.txt_root_deck.text().strip() + sub = self.txt_sub_deck.text().strip() + + if not root: + QMessageBox.warning(self, "Invalid Parameters", "A root deck namespace destination must be provided.") + return + + # Combine hierarchy into standard Anki format: 'Root::SubDeck' + full_deck_name = f"{root}::{sub}" if sub else root + self.export_anki_requested.emit(full_deck_name) \ No newline at end of file