before redesign of the GUI
This commit is contained in:
parent
ae1c382c3c
commit
b30619939e
3 changed files with 530 additions and 134 deletions
|
|
@ -4,59 +4,93 @@ from database.connection import get_connection
|
||||||
|
|
||||||
class GlossaryCleaner:
|
class GlossaryCleaner:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Captures trailing markers: " m", " f", " m, pl", " f, pl", " pl"
|
|
||||||
self.gender_pattern = re.compile(r'\s+\b(m|f|m,\s*pl|f,\s*pl|pl)\b\s*$')
|
|
||||||
# Captures verb irregular brackets: " (zc)", " (ie)", etc.
|
# Captures verb irregular brackets: " (zc)", " (ie)", etc.
|
||||||
self.verb_pattern = re.compile(r'\s*\(([a-z\s]+)\)')
|
self.verb_pattern = re.compile(r'\s*\(([a-z\s]+)\)')
|
||||||
|
|
||||||
def extract_metadata(self, text: str) -> tuple[str, str, str]:
|
# Aggressive character-level match for broken trailing gender tags
|
||||||
"""
|
# Tracks variations like: " m", " f", "mpl", "fpl", "smpl", "osmpl" at the end of a string
|
||||||
Parses text to isolate the clean conversational string,
|
self.broken_gender_pattern = re.compile(r'[\s]*\b(m|f|pl)\b$|[\s\w]*(m|f|pl|mpl|fpl|smpl|osmpl)$', re.IGNORECASE)
|
||||||
the word classification type, and specific grammatical notes.
|
|
||||||
"""
|
def clean_and_expand_spanish(self, text: str) -> tuple[list[str], str, str]:
|
||||||
|
"""Parses and strips layout noise from Spanish strings, extracting rich metadata."""
|
||||||
word_type = "phrase"
|
word_type = "phrase"
|
||||||
grammar_note = None
|
grammar_note = None
|
||||||
clean_text = text.strip()
|
clean_text = text.strip()
|
||||||
|
|
||||||
# Check for verb present-tense irregular markers
|
# 1. Extract verb irregularities if present
|
||||||
verb_match = self.verb_pattern.search(clean_text)
|
verb_match = self.verb_pattern.search(clean_text)
|
||||||
if verb_match:
|
if verb_match:
|
||||||
word_type = "verb"
|
word_type = "verb"
|
||||||
grammar_note = verb_match.group(1) # e.g., 'zc', 'ie'
|
grammar_note = verb_match.group(1).strip()
|
||||||
clean_text = self.verb_pattern.sub('', clean_text).strip()
|
clean_text = self.verb_pattern.sub('', clean_text).strip()
|
||||||
return clean_text, word_type, grammar_note
|
|
||||||
|
|
||||||
# Check for noun gender indicators
|
# 2. Extract and remove sticky gender notations (e.g., "bañ osmpl" -> "baños")
|
||||||
gender_match = self.gender_pattern.search(clean_text)
|
# Check if text ends with common markers
|
||||||
if gender_match:
|
lower_text = clean_text.lower()
|
||||||
|
if lower_text.endswith(('mpl', 'fpl', 'smpl', 'osmpl')):
|
||||||
word_type = "noun"
|
word_type = "noun"
|
||||||
grammar_note = gender_match.group(1).strip() # e.g., 'm', 'f'
|
if 'f' in lower_text:
|
||||||
clean_text = self.gender_pattern.sub('', clean_text).strip()
|
grammar_note = "f, pl"
|
||||||
return clean_text, word_type, grammar_note
|
else:
|
||||||
|
grammar_note = "m, pl"
|
||||||
|
|
||||||
# If it's a single word without tags, check if it's likely an adjective/noun split
|
# Reconstruct the original word base before the layout break
|
||||||
if ' ' not in clean_text and '/' in clean_text:
|
# e.g., "bañ osmpl" -> remove "osmpl" and append "os" to restore "baños"
|
||||||
|
if lower_text.endswith('osmpl') and clean_text.lower().endswith('osmpl'):
|
||||||
|
clean_text = clean_text[:-5] + "os"
|
||||||
|
else:
|
||||||
|
# General strip of trailing garbage characters
|
||||||
|
clean_text = re.sub(r'\s*[a-zA-Z\s]*$', '', clean_text)
|
||||||
|
|
||||||
|
# Standard boundary check for clean tags (e.g., "años60 m")
|
||||||
|
elif re.search(r'\s+\b(m|f|pl|m,\s*pl|f,\s*pl)\b\s*$', clean_text):
|
||||||
|
word_type = "noun"
|
||||||
|
match = re.search(r'\b(m|f|pl|m,\s*pl|f,\s*pl)\b\s*$', clean_text)
|
||||||
|
if match:
|
||||||
|
grammar_note = match.group(1).strip()
|
||||||
|
clean_text = re.sub(r'\s+\b(m|f|pl|m,\s*pl|f,\s*pl)\b\s*$', '', clean_text).strip()
|
||||||
|
|
||||||
|
clean_text = clean_text.strip()
|
||||||
|
|
||||||
|
# 3. Expand dual-gender adjectives (e.g., "apasionado/a")
|
||||||
|
variants = []
|
||||||
|
if '/' in clean_text and ' ' not in clean_text:
|
||||||
word_type = "adjective"
|
word_type = "adjective"
|
||||||
|
base, suffix = clean_text.split('/', 1)
|
||||||
|
base = base.strip()
|
||||||
|
suffix = suffix.strip()
|
||||||
|
|
||||||
return clean_text, word_type, grammar_note
|
if suffix == 'a' and base.endswith('o'):
|
||||||
|
variants.append((base, word_type, "m"))
|
||||||
|
variants.append((base[:-1] + 'a', word_type, "f"))
|
||||||
|
elif suffix == 'ra' and base.endswith('r'):
|
||||||
|
variants.append((base, word_type, "m"))
|
||||||
|
variants.append((base + 'a', word_type, "f"))
|
||||||
|
else:
|
||||||
|
variants.append((clean_text, word_type, grammar_note))
|
||||||
|
else:
|
||||||
|
variants.append((clean_text, word_type, grammar_note))
|
||||||
|
|
||||||
def ensure_columns_exist(self, cursor):
|
return variants
|
||||||
"""Dynamically appends schema metadata columns to phrases table if missing."""
|
|
||||||
try:
|
def clean_english_text(self, text: str) -> str:
|
||||||
cursor.execute("ALTER TABLE phrases ADD COLUMN word_type TEXT")
|
"""Fixes layout spacing issues on English infinitive verbs."""
|
||||||
cursor.execute("ALTER TABLE phrases ADD COLUMN grammar_note TEXT")
|
cleaned = text.strip()
|
||||||
except Exception:
|
# Ensure duplicate internal spacing is compressed
|
||||||
# Columns already exist, skip safe alert safely
|
cleaned = re.sub(r'\s+', ' ', cleaned)
|
||||||
pass
|
|
||||||
|
# Intercept smashed English infinitives (e.g., "toappear" -> "to appear")
|
||||||
|
if cleaned.startswith("to") and len(cleaned) > 2 and not cleaned.startswith("to "):
|
||||||
|
cleaned = re.sub(r'^to([a-z])', r'to \1', cleaned)
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
|
||||||
def process_database_clean(self):
|
def process_database_clean(self):
|
||||||
|
"""Processes and normalizes raw table entries into pristine structures."""
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Ensure our rich metadata slots exist in SQLite
|
print("🧹 Extracting raw dataset for deep metadata extraction...")
|
||||||
self.ensure_columns_exist(cursor)
|
|
||||||
|
|
||||||
print("🧹 Extracting raw glossary dataset for metadata preservation...")
|
|
||||||
|
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT t.source_phrase_id, p1.text as es_text, p2.text as en_text,
|
SELECT t.source_phrase_id, p1.text as es_text, p2.text as en_text,
|
||||||
|
|
@ -73,9 +107,9 @@ class GlossaryCleaner:
|
||||||
conn.close()
|
conn.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"⚙️ Migrating {len(raw_rows)} rows into a structured format...")
|
print(f"⚙️ Migrating {len(raw_rows)} rows into corrected schemas...")
|
||||||
|
|
||||||
# Clear out current tables to run a fresh, structured reload
|
# Clear out previous passes completely
|
||||||
cursor.execute("DELETE FROM translations")
|
cursor.execute("DELETE FROM translations")
|
||||||
cursor.execute("DELETE FROM phrases")
|
cursor.execute("DELETE FROM phrases")
|
||||||
cursor.execute("DELETE FROM audio_tracks")
|
cursor.execute("DELETE FROM audio_tracks")
|
||||||
|
|
@ -83,44 +117,28 @@ class GlossaryCleaner:
|
||||||
inserted_count = 0
|
inserted_count = 0
|
||||||
|
|
||||||
for _, es_raw, en_raw, textbook, unit, context in raw_rows:
|
for _, es_raw, en_raw, textbook, unit, context in raw_rows:
|
||||||
# Isolate text from technical indicators
|
# Process Spanish layout text elements
|
||||||
clean_es, word_type, grammar_note = self.extract_metadata(es_raw)
|
es_variants = self.clean_and_expand_spanish(es_raw)
|
||||||
|
# Process and restore spaces to English text elements
|
||||||
|
en_clean = self.clean_english_text(en_raw)
|
||||||
|
|
||||||
# Handle dual-gender expansions like "apasionado/a"
|
for es_clean, w_type, g_note in es_variants:
|
||||||
variants = []
|
|
||||||
if '/' in clean_es:
|
|
||||||
base, suffix = clean_es.split('/', 1)
|
|
||||||
base = base.strip()
|
|
||||||
suffix = suffix.strip()
|
|
||||||
|
|
||||||
if suffix == 'a' and base.endswith('o'):
|
|
||||||
variants.append((base, "adjective", "m"))
|
|
||||||
variants.append((base[:-1] + 'a', "adjective", "f"))
|
|
||||||
elif suffix == 'ra' and base.endswith('r'):
|
|
||||||
variants.append((base, "adjective", "m"))
|
|
||||||
variants.append((base + 'a', "adjective", "f"))
|
|
||||||
else:
|
|
||||||
variants.append((clean_es, word_type, grammar_note))
|
|
||||||
else:
|
|
||||||
variants.append((clean_es, word_type, grammar_note))
|
|
||||||
|
|
||||||
for es_variant, w_type, g_note in variants:
|
|
||||||
try:
|
try:
|
||||||
# 1. Insert Spanish entry with structural metadata columns filled
|
# Insert pristine Spanish entry
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT INTO phrases (text, language, textbook, unit, source_context, word_type, grammar_note)
|
INSERT INTO phrases (text, language, textbook, unit, source_context, word_type, grammar_note)
|
||||||
VALUES (?, 'es', ?, ?, ?, ?, ?)
|
VALUES (?, 'es', ?, ?, ?, ?, ?)
|
||||||
""", (es_variant, textbook, unit, context, w_type, g_note))
|
""", (es_clean, textbook, unit, context, w_type, g_note))
|
||||||
es_id = cursor.lastrowid
|
es_id = cursor.lastrowid
|
||||||
|
|
||||||
# 2. Insert English entry
|
# Insert spaced English entry
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT INTO phrases (text, language, textbook, unit, source_context, word_type, grammar_note)
|
INSERT INTO phrases (text, language, textbook, unit, source_context, word_type, grammar_note)
|
||||||
VALUES (?, 'en', ?, ?, ?, ?, NULL)
|
VALUES (?, 'en', ?, ?, ?, ?, NULL)
|
||||||
""", (en_raw, textbook, unit, context, w_type))
|
""", (en_clean, textbook, unit, context, w_type))
|
||||||
en_id = cursor.lastrowid
|
en_id = cursor.lastrowid
|
||||||
|
|
||||||
# 3. Create Bidirectional Cross-References
|
# Re-map relations
|
||||||
cursor.execute("INSERT INTO translations (source_phrase_id, target_phrase_id) VALUES (?, ?)", (es_id, en_id))
|
cursor.execute("INSERT INTO translations (source_phrase_id, target_phrase_id) VALUES (?, ?)", (es_id, en_id))
|
||||||
cursor.execute("INSERT INTO translations (source_phrase_id, target_phrase_id) VALUES (?, ?)", (en_id, es_id))
|
cursor.execute("INSERT INTO translations (source_phrase_id, target_phrase_id) VALUES (?, ?)", (en_id, es_id))
|
||||||
|
|
||||||
|
|
@ -130,4 +148,4 @@ class GlossaryCleaner:
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
print(f"🎉 Metadata-aware normalization complete! Saved {inserted_count} structured phrases.")
|
print(f"🎉 Schema extraction pipeline completed! Saved {inserted_count} pristine phrase definitions.")
|
||||||
|
|
@ -1,59 +1,54 @@
|
||||||
# database/connection.py
|
# database/connection.py
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import os
|
|
||||||
|
|
||||||
DB_NAME = "spanish_trainer.db"
|
|
||||||
|
|
||||||
def get_connection():
|
def get_connection():
|
||||||
"""Returns a standard connection object to the SQLite database."""
|
return sqlite3.connect("spanish_trainer.db")
|
||||||
return sqlite3.connect(DB_NAME)
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
"""
|
conn = get_connection()
|
||||||
Initializes the SQLite database tables if they do not exist.
|
cursor = conn.cursor()
|
||||||
This safely runs on every boot without wiping your existing data.
|
|
||||||
"""
|
|
||||||
print(f"🗄️ Checking database status for '{DB_NAME}'...")
|
|
||||||
|
|
||||||
# The SQL schema we designed for your glossary, cross-references, and tracks
|
# 1. Main Phrases Table
|
||||||
schema = """
|
cursor.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS phrases (
|
CREATE TABLE IF NOT EXISTS phrases (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
text TEXT NOT NULL,
|
text TEXT NOT NULL,
|
||||||
language TEXT NOT NULL,
|
language TEXT NOT NULL,
|
||||||
textbook TEXT DEFAULT NULL,
|
textbook TEXT,
|
||||||
unit INTEGER DEFAULT NULL,
|
unit INTEGER,
|
||||||
source_context TEXT DEFAULT NULL,
|
source_context TEXT,
|
||||||
|
word_type TEXT,
|
||||||
|
grammar_note TEXT,
|
||||||
|
voice_gender TEXT DEFAULT 'female',
|
||||||
|
base_speed REAL DEFAULT 1.0,
|
||||||
|
deck_name TEXT DEFAULT 'General',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 2. Translations Cross-Reference Table
|
||||||
|
cursor.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS translations (
|
CREATE TABLE IF NOT EXISTS translations (
|
||||||
source_phrase_id INTEGER,
|
source_phrase_id INTEGER,
|
||||||
target_phrase_id INTEGER,
|
target_phrase_id INTEGER,
|
||||||
PRIMARY KEY (source_phrase_id, target_phrase_id),
|
PRIMARY KEY (source_phrase_id, target_phrase_id),
|
||||||
FOREIGN KEY (source_phrase_id) REFERENCES phrases(id) ON DELETE CASCADE,
|
FOREIGN KEY (source_phrase_id) REFERENCES phrases(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (target_phrase_id) REFERENCES phrases(id) ON DELETE CASCADE
|
FOREIGN KEY (target_phrase_id) REFERENCES phrases(id) ON DELETE CASCADE
|
||||||
);
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 3. Audio Tracks Metadata Table (Required by the glossary cleaner)
|
||||||
|
cursor.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS audio_tracks (
|
CREATE TABLE IF NOT EXISTS audio_tracks (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
phrase_id INTEGER NOT NULL,
|
phrase_id INTEGER,
|
||||||
voice_gender TEXT NOT NULL,
|
|
||||||
voice_name TEXT NOT NULL,
|
|
||||||
file_path TEXT NOT NULL,
|
file_path TEXT NOT NULL,
|
||||||
is_reference INTEGER DEFAULT 1,
|
sample_rate INTEGER,
|
||||||
|
duration REAL,
|
||||||
FOREIGN KEY (phrase_id) REFERENCES phrases(id) ON DELETE CASCADE
|
FOREIGN KEY (phrase_id) REFERENCES phrases(id) ON DELETE CASCADE
|
||||||
);
|
)
|
||||||
"""
|
""")
|
||||||
|
|
||||||
conn = get_connection()
|
|
||||||
try:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
# executescript allows running multiple CREATE TABLE statements at once
|
|
||||||
cursor.executescript(schema)
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
print("✅ Database tables verified and initialized successfully.")
|
|
||||||
except sqlite3.Error as e:
|
|
||||||
print(f"❌ Database initialization failed: {e}")
|
|
||||||
finally:
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
print("✅ Rich metadata database schema initialized successfully.")
|
||||||
|
|
|
||||||
403
main.py
403
main.py
|
|
@ -1,27 +1,410 @@
|
||||||
# main.py
|
# main.py
|
||||||
import asyncio
|
import sys
|
||||||
from database.connection import init_db
|
import os
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout,
|
||||||
|
QHBoxLayout, QLabel, QPushButton, QLineEdit, QComboBox,
|
||||||
|
QTableWidget, QTableWidgetItem, QSlider, QFormLayout, QTextEdit, QFrame, QMessageBox
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import Qt, QUrl
|
||||||
|
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
|
||||||
|
from PyQt6.QtGui import QFont
|
||||||
|
from database.connection import init_db, get_connection
|
||||||
from core.bulk_importer import BulkImporter
|
from core.bulk_importer import BulkImporter
|
||||||
from core.clean_glossary import GlossaryCleaner
|
from core.clean_glossary import GlossaryCleaner
|
||||||
|
|
||||||
async def rebuild_pipeline():
|
class SpanishTrainerApp(QMainWindow):
|
||||||
print("🚀 Initiating Clean Reconstruction Pipeline...")
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle("Castilian Voice Trainer Pro")
|
||||||
|
self.setMinimumSize(1000, 650)
|
||||||
|
|
||||||
# 1. This will automatically recreate the blank .db file and all tables
|
# 1. Initialize and Seed Database if Empty
|
||||||
|
self.ensure_database_populated()
|
||||||
|
|
||||||
|
# 2. Initialize PyQt Multimedia Audio Engine Components
|
||||||
|
self.media_player = QMediaPlayer()
|
||||||
|
self.audio_output = QAudioOutput()
|
||||||
|
self.media_player.setAudioOutput(self.audio_output)
|
||||||
|
|
||||||
|
self.current_flashcard_id = None
|
||||||
|
|
||||||
|
# 3. Build UI Components
|
||||||
|
self.tabs = QTabWidget()
|
||||||
|
self.setCentralWidget(self.tabs)
|
||||||
|
|
||||||
|
self.init_phrase_sandbox_tab()
|
||||||
|
self.init_flashcard_reviewer_tab()
|
||||||
|
|
||||||
|
# 4. Initial Load of Database Data into Grid View
|
||||||
|
self.refresh_crud_table()
|
||||||
|
|
||||||
|
def ensure_database_populated(self):
|
||||||
|
"""Verifies if the database exists and has records; seeds it from the PDF if empty."""
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
# 2. Run the layout-aware bulk import from the PDF layout text
|
conn = get_connection()
|
||||||
importer = BulkImporter()
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM phrases")
|
||||||
|
count = cursor.fetchone()[0]
|
||||||
|
except Exception:
|
||||||
|
count = 0
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if count == 0:
|
||||||
|
print("🗄️ Database appears empty. Running the structural reconstruction pipeline...")
|
||||||
pdf_file = "aula_int_plus_1_glos_en_alfa.pdf"
|
pdf_file = "aula_int_plus_1_glos_en_alfa.pdf"
|
||||||
textbook = "Aula Internacional Plus 1"
|
textbook = "Aula Internacional Plus 1"
|
||||||
|
|
||||||
|
if os.path.exists(pdf_file):
|
||||||
|
importer = BulkImporter()
|
||||||
importer.import_pdf_glossary(pdf_file, textbook)
|
importer.import_pdf_glossary(pdf_file, textbook)
|
||||||
|
|
||||||
# 3. Immediately run the metadata preservation and text cleaning pass
|
|
||||||
cleaner = GlossaryCleaner()
|
cleaner = GlossaryCleaner()
|
||||||
cleaner.process_database_clean()
|
cleaner.process_database_clean()
|
||||||
|
print("✨ Database successfully seeded with metadata-parsed entries.")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Warning: Could not find '{pdf_file}' to automatically seed records.")
|
||||||
|
|
||||||
print("\n✨ Database completely rebuilt with pristine structured data!")
|
# =====================================================================
|
||||||
|
# 🗄️ TAB 1: PHRASE SANDBOX (CRUD Panel)
|
||||||
|
# =====================================================================
|
||||||
|
def init_phrase_sandbox_tab(self):
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QHBoxLayout(tab)
|
||||||
|
|
||||||
|
left_panel = QVBoxLayout()
|
||||||
|
search_layout = QHBoxLayout()
|
||||||
|
search_layout.addWidget(QLabel("🔍 Filter text:"))
|
||||||
|
self.search_input = QLineEdit()
|
||||||
|
self.search_input.textChanged.connect(self.refresh_crud_table)
|
||||||
|
search_layout.addWidget(self.search_input)
|
||||||
|
left_panel.addLayout(search_layout)
|
||||||
|
|
||||||
|
self.phrase_table = QTableWidget()
|
||||||
|
self.phrase_table.setColumnCount(6)
|
||||||
|
self.phrase_table.setHorizontalHeaderLabels(["ID", "Text", "Lang", "Type", "Voice Gender", "Deck Tag"])
|
||||||
|
self.phrase_table.itemSelectionChanged.connect(self.handle_table_row_select)
|
||||||
|
left_panel.addWidget(self.phrase_table)
|
||||||
|
|
||||||
|
right_panel = QVBoxLayout()
|
||||||
|
form_frame = QFrame()
|
||||||
|
form_frame.setFrameShape(QFrame.Shape.StyledPanel)
|
||||||
|
form_layout = QFormLayout(form_frame)
|
||||||
|
|
||||||
|
self.input_id = QLineEdit()
|
||||||
|
self.input_id.setReadOnly(True)
|
||||||
|
self.input_id.setPlaceholderText("Auto-assigned ID")
|
||||||
|
|
||||||
|
self.input_text = QTextEdit()
|
||||||
|
self.input_text.setMaximumHeight(60)
|
||||||
|
|
||||||
|
self.combo_lang = QComboBox()
|
||||||
|
self.combo_lang.addItems(["es", "en"])
|
||||||
|
|
||||||
|
self.combo_type = QComboBox()
|
||||||
|
self.combo_type.addItems(["sentence", "phrase", "noun", "verb", "adjective"])
|
||||||
|
|
||||||
|
self.combo_voice_gender = QComboBox()
|
||||||
|
self.combo_voice_gender.addItems(["female", "male"])
|
||||||
|
|
||||||
|
self.slider_base_speed = QSlider(Qt.Orientation.Horizontal)
|
||||||
|
self.slider_base_speed.setMinimum(50)
|
||||||
|
self.slider_base_speed.setMaximum(150)
|
||||||
|
self.slider_base_speed.setValue(100)
|
||||||
|
self.lbl_base_speed = QLabel("1.00x")
|
||||||
|
self.slider_base_speed.valueChanged.connect(lambda v: self.lbl_base_speed.setText(f"{v/100:.2f}x"))
|
||||||
|
|
||||||
|
speed_box = QHBoxLayout()
|
||||||
|
speed_box.addWidget(self.slider_base_speed)
|
||||||
|
speed_box.addWidget(self.lbl_base_speed)
|
||||||
|
|
||||||
|
self.input_deck_tag = QLineEdit()
|
||||||
|
self.input_deck_tag.setPlaceholderText("e.g., Anki_Unit_1")
|
||||||
|
|
||||||
|
form_layout.addRow("Phrase ID Resource:", self.input_id)
|
||||||
|
form_layout.addRow(QLabel("<b>Target Conversational Text:</b>"))
|
||||||
|
form_layout.addRow(self.input_text)
|
||||||
|
form_layout.addRow("Language Accent:", self.combo_lang)
|
||||||
|
form_layout.addRow("Grammar Type Classification:", self.combo_type)
|
||||||
|
form_layout.addRow("<b>Preferred Voice Gender:</b>", self.combo_voice_gender)
|
||||||
|
form_layout.addRow("<b>Default Playback Speed:</b>", speed_box)
|
||||||
|
form_layout.addRow("Deck / Group Identifier Tag:", self.input_deck_tag)
|
||||||
|
|
||||||
|
crud_buttons = QHBoxLayout()
|
||||||
|
self.btn_save = QPushButton("➕ Save New")
|
||||||
|
self.btn_update = QPushButton("💾 Update Entry")
|
||||||
|
self.btn_delete = QPushButton("🗑️ Delete")
|
||||||
|
|
||||||
|
self.btn_save.clicked.connect(self.crud_create)
|
||||||
|
self.btn_update.clicked.connect(self.crud_update)
|
||||||
|
self.btn_delete.clicked.connect(self.crud_delete)
|
||||||
|
|
||||||
|
crud_buttons.addWidget(self.btn_save)
|
||||||
|
crud_buttons.addWidget(self.btn_update)
|
||||||
|
crud_buttons.addWidget(self.btn_delete)
|
||||||
|
|
||||||
|
right_panel.addWidget(QLabel("<h3>Configure Phrase & Voice Variables</h3>"))
|
||||||
|
right_panel.addWidget(form_frame)
|
||||||
|
right_panel.addLayout(crud_buttons)
|
||||||
|
right_panel.addStretch()
|
||||||
|
|
||||||
|
layout.addLayout(left_panel, stretch=3)
|
||||||
|
layout.addLayout(right_panel, stretch=2)
|
||||||
|
|
||||||
|
self.tabs.addTab(tab, "🗄️ Phrase Sandbox (CRUD)")
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 🃏 TAB 2: FLASHCARD STUDY PLAYER
|
||||||
|
# =====================================================================
|
||||||
|
def init_flashcard_reviewer_tab(self):
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
|
||||||
|
card_frame = QFrame()
|
||||||
|
card_frame.setStyleSheet("background-color: #ffffff; border: 2px solid #bdc3c7; border-radius: 12px;")
|
||||||
|
card_layout = QVBoxLayout(card_frame)
|
||||||
|
|
||||||
|
self.lbl_card_text = QLabel("Click 'Load Next' to study sentences...")
|
||||||
|
self.lbl_card_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.lbl_card_text.setFont(QFont("Arial", 22, 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()
|
||||||
|
|
||||||
|
playback_layout = QHBoxLayout()
|
||||||
|
playback_layout.addWidget(QLabel("🔊 Fine-Tune Study 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.0x (Normal)")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
action_buttons = QHBoxLayout()
|
||||||
|
self.btn_play_voice = QPushButton("🗣️ Play Voice Track")
|
||||||
|
self.btn_flip_card = QPushButton("👁️ Reveal Translation")
|
||||||
|
self.btn_load_next = QPushButton("➡️ Next Card")
|
||||||
|
|
||||||
|
self.btn_play_voice.clicked.connect(self.handle_play_voice)
|
||||||
|
self.btn_flip_card.clicked.connect(self.handle_flip_card)
|
||||||
|
self.btn_load_next.clicked.connect(self.handle_load_next_card)
|
||||||
|
|
||||||
|
action_buttons.addWidget(self.btn_play_voice)
|
||||||
|
action_buttons.addWidget(self.btn_flip_card)
|
||||||
|
action_buttons.addStretch()
|
||||||
|
action_buttons.addWidget(self.btn_load_next)
|
||||||
|
|
||||||
|
layout.addWidget(card_frame, stretch=1)
|
||||||
|
layout.addLayout(playback_layout)
|
||||||
|
layout.addLayout(action_buttons)
|
||||||
|
|
||||||
|
self.tabs.addTab(tab, "🃏 Flashcard Study")
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# ⚡ ENGINE BUSINESS LOGIC OPERATIONS & DATABASE MAPPINGS
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
def refresh_crud_table(self):
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
filter_text = self.search_input.text()
|
||||||
|
if filter_text:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, text, language, word_type, voice_gender, deck_name
|
||||||
|
FROM phrases WHERE text LIKE ? ORDER BY id DESC LIMIT 100
|
||||||
|
""", (f"%{filter_text}%",))
|
||||||
|
else:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, text, language, word_type, voice_gender, deck_name
|
||||||
|
FROM phrases ORDER BY id DESC LIMIT 100
|
||||||
|
""")
|
||||||
|
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
self.phrase_table.setRowCount(0)
|
||||||
|
for row_idx, row_data in enumerate(rows):
|
||||||
|
self.phrase_table.insertRow(row_idx)
|
||||||
|
for col_idx, value in enumerate(row_data):
|
||||||
|
self.phrase_table.setItem(row_idx, col_idx, QTableWidgetItem(str(value if value is not None else "")))
|
||||||
|
|
||||||
|
def handle_table_row_select(self):
|
||||||
|
selected_ranges = self.phrase_table.selectedRanges()
|
||||||
|
if not selected_ranges:
|
||||||
|
return
|
||||||
|
row = selected_ranges[0].topRow()
|
||||||
|
item = self.phrase_table.item(row, 0)
|
||||||
|
if not item:
|
||||||
|
return
|
||||||
|
phrase_id = item.text()
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT id, text, language, word_type, voice_gender, base_speed, deck_name FROM phrases WHERE id = ?", (phrase_id,))
|
||||||
|
record = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if record:
|
||||||
|
self.input_id.setText(str(record[0]))
|
||||||
|
self.input_text.setPlainText(str(record[1]))
|
||||||
|
self.combo_lang.setCurrentText(str(record[2]))
|
||||||
|
self.combo_type.setCurrentText(str(record[3]) if record[3] else "sentence")
|
||||||
|
self.combo_voice_gender.setCurrentText(str(record[4]) if record[4] else "female")
|
||||||
|
|
||||||
|
speed_val = int((record[5] if record[5] else 1.0) * 100)
|
||||||
|
self.slider_base_speed.setValue(speed_val)
|
||||||
|
self.input_deck_tag.setText(str(record[6]) if record[6] else "General")
|
||||||
|
|
||||||
|
def crud_create(self):
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO phrases (text, language, word_type, voice_gender, base_speed, deck_name)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
self.input_text.toPlainText().strip(),
|
||||||
|
self.combo_lang.currentText(),
|
||||||
|
self.combo_type.currentText(),
|
||||||
|
self.combo_voice_gender.currentText(),
|
||||||
|
self.slider_base_speed.value() / 100.0, # Fixed typo: changed from .setValue() to .value()
|
||||||
|
self.input_deck_tag.text().strip() or "General"
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
self.refresh_crud_table()
|
||||||
|
QMessageBox.information(self, "Success", "Phrase generated into database index store successfully.")
|
||||||
|
|
||||||
|
def crud_update(self):
|
||||||
|
pid = self.input_id.text()
|
||||||
|
if not pid:
|
||||||
|
return
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE phrases SET text=?, language=?, word_type=?, voice_gender=?, base_speed=?, deck_name=?
|
||||||
|
WHERE id=?
|
||||||
|
""", (
|
||||||
|
self.input_text.toPlainText().strip(),
|
||||||
|
self.combo_lang.currentText(),
|
||||||
|
self.combo_type.currentText(),
|
||||||
|
self.combo_voice_gender.currentText(),
|
||||||
|
self.slider_base_speed.value() / 100.0,
|
||||||
|
self.input_deck_tag.text().strip() or "General",
|
||||||
|
pid
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
self.refresh_crud_table()
|
||||||
|
QMessageBox.information(self, "Success", "Database record fields updated cleanly.")
|
||||||
|
|
||||||
|
def crud_delete(self):
|
||||||
|
pid = self.input_id.text()
|
||||||
|
if not pid:
|
||||||
|
return
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM phrases WHERE id=?", (pid,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
self.input_id.clear()
|
||||||
|
self.input_text.clear()
|
||||||
|
self.refresh_crud_table()
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 🔊 AUDIO PLAYER SYSTEM
|
||||||
|
# =====================================================================
|
||||||
|
def handle_load_next_card(self):
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT id, text, language, word_type, voice_gender, base_speed FROM phrases ORDER BY RANDOM() LIMIT 1")
|
||||||
|
record = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if record:
|
||||||
|
self.current_flashcard_id = record[0]
|
||||||
|
self.lbl_card_text.setText(record[1])
|
||||||
|
|
||||||
|
lang_lbl = "Spanish Accent" if record[2] == "es" else "English Accent"
|
||||||
|
gender_lbl = str(record[4]).capitalize() if record[4] else "Female"
|
||||||
|
self.lbl_card_meta.setText(f"Classification: {record[3]} • Configured Voice: {lang_lbl} ({gender_lbl})")
|
||||||
|
|
||||||
|
card_saved_speed = int((record[5] if record[5] else 1.0) * 100)
|
||||||
|
self.slider_review_speed.setValue(card_saved_speed)
|
||||||
|
|
||||||
|
def handle_play_voice(self):
|
||||||
|
if not self.current_flashcard_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT text, language, voice_gender FROM phrases WHERE id = ?", (self.current_flashcard_id,))
|
||||||
|
phrase_row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not phrase_row:
|
||||||
|
return
|
||||||
|
|
||||||
|
text_str, lang_code, voice_gender = phrase_row
|
||||||
|
|
||||||
|
safe_filename = "".join([c for c in text_str if c.isalnum() or c in (" ", "_")]).rstrip().replace(" ", "_").lower()
|
||||||
|
target_audio_file = f"media/{safe_filename}_{lang_code}_{voice_gender}.mp3"
|
||||||
|
|
||||||
|
os.makedirs("media", exist_ok=True)
|
||||||
|
|
||||||
|
if not os.path.exists(target_audio_file):
|
||||||
|
QMessageBox.warning(self, "Audio Track Missing",
|
||||||
|
f"Audio track asset file not found in directory:\n'{target_audio_file}'\n\nRun the background Edge-TTS batch generator script next to download this track automatically.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.media_player.setSource(QUrl.fromLocalFile(os.path.abspath(target_audio_file)))
|
||||||
|
|
||||||
|
current_rate = self.slider_review_speed.value() / 100.0
|
||||||
|
self.media_player.setPlaybackRate(current_rate)
|
||||||
|
|
||||||
|
self.media_player.play()
|
||||||
|
|
||||||
|
def handle_live_speed_change(self, value):
|
||||||
|
rate = value / 100.0
|
||||||
|
self.lbl_review_speed.setText(f"{rate:.2f}x")
|
||||||
|
if self.media_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
|
||||||
|
self.media_player.setPlaybackRate(rate)
|
||||||
|
|
||||||
|
def handle_flip_card(self):
|
||||||
|
if not self.current_flashcard_id:
|
||||||
|
return
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT p2.text FROM translations t
|
||||||
|
JOIN phrases p2 ON t.target_phrase_id = p2.id
|
||||||
|
WHERE t.source_phrase_id = ?
|
||||||
|
""", (self.current_flashcard_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
current_es = self.lbl_card_text.text().split("\n\n👉")[0]
|
||||||
|
self.lbl_card_text.setText(f"{current_es}\n\n👉 [ {row[0]} ]")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(rebuild_pipeline())
|
app = QApplication(sys.argv)
|
||||||
|
window = SpanishTrainerApp()
|
||||||
|
window.show()
|
||||||
|
sys.exit(app.exec())
|
||||||
Loading…
Reference in a new issue