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