Compare commits
5 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89e93dd467 | |||
| b5567bf498 | |||
| b61d72740b | |||
| 7203f0001c | |||
| e2a1991a59 |
19 changed files with 1209 additions and 2194 deletions
92
anki_exporter.py
Normal file
92
anki_exporter.py
Normal file
|
|
@ -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': '<div style="font-family: Arial; font-size: 20px; text-align: center; color: #34495E;">'
|
||||||
|
'Translate to Spanish:<br><br><b>{{EnglishText}}</b></div><div style="display:none;">{{EnglishAudio}}</div>',
|
||||||
|
'afmt': '{{FrontSide}}<hr id="answer">'
|
||||||
|
'<div style="font-family: Arial; font-size: 28px; text-align: center; color: #2980B9; font-weight: bold;">'
|
||||||
|
'{{SpanishText}}</div><br>'
|
||||||
|
'<div style="font-family: Arial; font-size: 14px; text-align: center; color: #7F8C8D; font-style: italic;">'
|
||||||
|
'{{Notes}}</div><br>'
|
||||||
|
'<div style="text-align: center;">{{SpanishAudio}}</div>',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
165
database.py
Normal file
165
database.py
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
def insert_translation_record(es_text, en_text, source_context, tags, notes):
|
||||||
|
"""Inserts a completely fresh record into the translations table."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO translations (es_text, en_text, source_context, tags, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?);
|
||||||
|
""", (es_text, en_text, source_context, tags, notes))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
1133
doc/Notes.md
1133
doc/Notes.md
File diff suppressed because it is too large
Load diff
BIN
doc/Notes.pdf
BIN
doc/Notes.pdf
Binary file not shown.
BIN
doc/images/image-02.png
Normal file
BIN
doc/images/image-02.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
BIN
doc/images/image-03.png
Normal file
BIN
doc/images/image-03.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
114
migrate_database.py
Normal file
114
migrate_database.py
Normal file
|
|
@ -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()
|
||||||
Binary file not shown.
BIN
spanish_trainer_legacy.db
Normal file
BIN
spanish_trainer_legacy.db
Normal file
Binary file not shown.
5
tabs/__init__.py
Normal file
5
tabs/__init__.py
Normal file
|
|
@ -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
|
||||||
201
tabs/review_tab.py
Normal file
201
tabs/review_tab.py
Normal file
|
|
@ -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()
|
||||||
389
tabs/sandbox_tab.py
Normal file
389
tabs/sandbox_tab.py
Normal file
|
|
@ -0,0 +1,389 @@
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# Internal tracking variable to distinguish edits vs new entries
|
||||||
|
self.selected_translation_id = None
|
||||||
|
|
||||||
|
# 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_english.textChanged.connect(self.handle_live_filter)
|
||||||
|
|
||||||
|
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_spanish.textChanged.connect(self.handle_live_filter)
|
||||||
|
|
||||||
|
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_context.textChanged.connect(self.handle_live_filter)
|
||||||
|
|
||||||
|
# NEW: Tags input field with live filter connection
|
||||||
|
self.txt_tags = QLineEdit()
|
||||||
|
self.txt_tags.setPlaceholderText("e.g., verb, greeting, subjunctive, travel...")
|
||||||
|
self.txt_tags.setStyleSheet("padding: 6px; font-size: 14px;")
|
||||||
|
self.txt_tags.textChanged.connect(self.handle_live_filter)
|
||||||
|
|
||||||
|
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("<b>English Text:</b>"), self.txt_english)
|
||||||
|
form_layout.addRow(QLabel("<b>Spanish Text:</b>"), self.txt_spanish)
|
||||||
|
form_layout.addRow(QLabel("<b>Source Context:</b>"), self.txt_context)
|
||||||
|
form_layout.addRow(QLabel("<b>Tags:</b>"), self.txt_tags)
|
||||||
|
form_layout.addRow(QLabel("<b>Historical Notes:</b>"), 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()
|
||||||
|
control_layout.setSpacing(15)
|
||||||
|
|
||||||
|
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_new_record = QPushButton("Create New Record")
|
||||||
|
self.btn_new_record.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
self.btn_new_record.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background-color: #27AE60;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
QPushButton:hover { background-color: #219653; }
|
||||||
|
""")
|
||||||
|
self.btn_new_record.clicked.connect(self.prepare_for_new_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_all_fields_manually)
|
||||||
|
|
||||||
|
control_layout.addWidget(self.btn_save)
|
||||||
|
control_layout.addWidget(self.btn_new_record)
|
||||||
|
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(6) # Increased to 6 to display Tags column
|
||||||
|
self.table.setHorizontalHeaderLabels(["ID", "English Phrase", "Spanish Translation", "Context", "Tags", "Notes"])
|
||||||
|
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||||
|
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||||
|
|
||||||
|
# Wire up row selection change signals to auto-populate form
|
||||||
|
self.table.itemSelectionChanged.connect(self.handle_row_selection)
|
||||||
|
|
||||||
|
# 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) # Tags Header
|
||||||
|
header.setSectionResizeMode(5, QHeaderView.ResizeMode.Interactive) # Notes Header
|
||||||
|
|
||||||
|
main_layout.addWidget(self.table)
|
||||||
|
|
||||||
|
# Master cache of unfiltered row records to enable instant filtering loops
|
||||||
|
self.all_cached_records = []
|
||||||
|
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 handle_live_filter(self):
|
||||||
|
"""Filters the visible entries based on English, Spanish, Context, and Tags criteria."""
|
||||||
|
# Temporary block signals to prevent selection loops from fighting text changes
|
||||||
|
self.table.blockSignals(True)
|
||||||
|
|
||||||
|
filter_en = self.txt_english.text().lower().strip()
|
||||||
|
filter_es = self.txt_spanish.text().lower().strip()
|
||||||
|
filter_ctx = self.txt_context.text().lower().strip()
|
||||||
|
filter_tag = self.txt_tags.text().lower().strip() # Capture tag text query
|
||||||
|
|
||||||
|
self.table.setRowCount(0)
|
||||||
|
visible_row_index = 0
|
||||||
|
|
||||||
|
for row in self.all_cached_records:
|
||||||
|
val_en = (row["en_text"] or "").lower()
|
||||||
|
val_es = (row["es_text"] or "").lower()
|
||||||
|
val_ctx = (row["source_context"] or "").lower()
|
||||||
|
val_tag = (row["tags"] or "").lower() # Extract tags criteria
|
||||||
|
|
||||||
|
# Look for explicit matching conditions across all 4 entry variables
|
||||||
|
if (filter_en in val_en) and (filter_es in val_es) and (filter_ctx in val_ctx) and (filter_tag in val_tag):
|
||||||
|
self.table.insertRow(visible_row_index)
|
||||||
|
|
||||||
|
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_tag = QTableWidgetItem(row["tags"] or "")
|
||||||
|
item_nts = QTableWidgetItem(row["notes"] or "")
|
||||||
|
|
||||||
|
item_id.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
|
self.table.setItem(visible_row_index, 0, item_id)
|
||||||
|
self.table.setItem(visible_row_index, 1, item_en)
|
||||||
|
self.table.setItem(visible_row_index, 2, item_es)
|
||||||
|
self.table.setItem(visible_row_index, 3, item_ctx)
|
||||||
|
self.table.setItem(visible_row_index, 4, item_tag)
|
||||||
|
self.table.setItem(visible_row_index, 5, item_nts)
|
||||||
|
|
||||||
|
# If we have an active editing ID, highlight that specific row during re-renders
|
||||||
|
if self.selected_translation_id == row["translation_id"]:
|
||||||
|
self.table.selectRow(visible_row_index)
|
||||||
|
|
||||||
|
visible_row_index += 1
|
||||||
|
|
||||||
|
self.table.blockSignals(False)
|
||||||
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def handle_row_selection(self):
|
||||||
|
"""Populates the input forms when a user clicks a row in the table view."""
|
||||||
|
selected_ranges = self.table.selectedRanges()
|
||||||
|
if not selected_ranges:
|
||||||
|
return
|
||||||
|
|
||||||
|
row_idx = selected_ranges[0].topRow()
|
||||||
|
id_item = self.table.item(row_idx, 0)
|
||||||
|
if not id_item:
|
||||||
|
return
|
||||||
|
|
||||||
|
target_id = int(id_item.text())
|
||||||
|
|
||||||
|
# Locate item match within memory cache store elements
|
||||||
|
record = next((r for r in self.all_cached_records if r["translation_id"] == target_id), None)
|
||||||
|
if record:
|
||||||
|
# Block line edit text tracking temporarily so populating fields doesn't trigger filter loops
|
||||||
|
self.txt_english.blockSignals(True)
|
||||||
|
self.txt_spanish.blockSignals(True)
|
||||||
|
self.txt_context.blockSignals(True)
|
||||||
|
self.txt_tags.blockSignals(True)
|
||||||
|
self.txt_notes.blockSignals(True)
|
||||||
|
|
||||||
|
self.selected_translation_id = record["translation_id"]
|
||||||
|
self.txt_english.setText(record["en_text"])
|
||||||
|
self.txt_spanish.setText(record["es_text"])
|
||||||
|
self.txt_context.setText(record["source_context"] or "")
|
||||||
|
self.txt_tags.setText(record["tags"] or "")
|
||||||
|
self.txt_notes.setText(record["notes"] or "")
|
||||||
|
|
||||||
|
self.txt_english.blockSignals(False)
|
||||||
|
self.txt_spanish.blockSignals(False)
|
||||||
|
self.txt_context.blockSignals(False)
|
||||||
|
self.txt_tags.blockSignals(False)
|
||||||
|
self.txt_notes.blockSignals(False)
|
||||||
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def prepare_for_new_record(self):
|
||||||
|
"""Clears selection state so next click on 'Save' inserts fresh rows without scrubbing fields."""
|
||||||
|
self.selected_translation_id = None
|
||||||
|
self.table.blockSignals(True)
|
||||||
|
self.table.clearSelection()
|
||||||
|
self.table.blockSignals(False)
|
||||||
|
QMessageBox.information(self, "Status Shift", "Ready to insert a new record using the current field content.")
|
||||||
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def commit_translation_record(self):
|
||||||
|
"""Saves current text blocks. Dynamically detects insert vs edit based on selections."""
|
||||||
|
en_text = self.txt_english.text().strip()
|
||||||
|
es_text = self.txt_spanish.text().strip()
|
||||||
|
context = self.txt_context.text().strip()
|
||||||
|
tags = self.txt_tags.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
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.selected_translation_id is not None:
|
||||||
|
# database.update_translation_record signature: (translation_id, es_text, en_text, source_context, tags, notes)
|
||||||
|
database.update_translation_record(
|
||||||
|
self.selected_translation_id, es_text, en_text, context, tags, notes
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# database.insert_translation_record signature: (es_text, en_text, source_context, tags, notes)
|
||||||
|
database.insert_translation_record(
|
||||||
|
es_text, en_text, context, tags, notes
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clear pointer state values on successful writing commits
|
||||||
|
self.selected_translation_id = None
|
||||||
|
|
||||||
|
# Wipe inputs cleanly and sync UI layers
|
||||||
|
self.clear_input_fields()
|
||||||
|
self.reload_table_display()
|
||||||
|
self.data_mutated.emit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Database Commit Safeguard", f"Failed writing database operations:\n{str(e)}")
|
||||||
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def clear_all_fields_manually(self):
|
||||||
|
"""Clears explicit states alongside visual row highlights simultaneously."""
|
||||||
|
self.selected_translation_id = None
|
||||||
|
self.table.blockSignals(True)
|
||||||
|
self.table.clearSelection()
|
||||||
|
self.table.blockSignals(False)
|
||||||
|
self.clear_input_fields()
|
||||||
|
self.reload_table_display()
|
||||||
|
|
||||||
|
def clear_input_fields(self):
|
||||||
|
"""Flushes transient text inside line editors without running filtering rules."""
|
||||||
|
self.txt_english.blockSignals(True)
|
||||||
|
self.txt_spanish.blockSignals(True)
|
||||||
|
self.txt_context.blockSignals(True)
|
||||||
|
self.txt_tags.blockSignals(True)
|
||||||
|
self.txt_notes.blockSignals(True)
|
||||||
|
|
||||||
|
self.txt_english.clear()
|
||||||
|
self.txt_spanish.clear()
|
||||||
|
self.txt_context.clear()
|
||||||
|
self.txt_tags.clear()
|
||||||
|
self.txt_notes.clear()
|
||||||
|
|
||||||
|
self.txt_english.blockSignals(False)
|
||||||
|
self.txt_spanish.blockSignals(False)
|
||||||
|
self.txt_context.blockSignals(False)
|
||||||
|
self.txt_tags.blockSignals(False)
|
||||||
|
self.txt_notes.blockSignals(False)
|
||||||
|
|
||||||
|
def reload_table_display(self):
|
||||||
|
"""Refetches database rows and populates the master dashboard grid view."""
|
||||||
|
self.table.blockSignals(True)
|
||||||
|
self.table.setRowCount(0)
|
||||||
|
|
||||||
|
# Keep internal reference arrays synced cleanly
|
||||||
|
self.all_cached_records = database.get_all_translations_explicit()
|
||||||
|
|
||||||
|
for idx, row in enumerate(self.all_cached_records):
|
||||||
|
self.table.insertRow(idx)
|
||||||
|
|
||||||
|
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_tag = QTableWidgetItem(row["tags"] or "")
|
||||||
|
item_nts = QTableWidgetItem(row["notes"] or "")
|
||||||
|
|
||||||
|
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_tag)
|
||||||
|
self.table.setItem(idx, 5, item_nts)
|
||||||
|
|
||||||
|
self.table.blockSignals(False)
|
||||||
145
tabs/settings_tab.py
Normal file
145
tabs/settings_tab.py
Normal file
|
|
@ -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)
|
||||||
0
video_generator.py
Normal file
0
video_generator.py
Normal file
Loading…
Reference in a new issue