diff --git a/anki_exporter.py b/anki_exporter.py
index 4d8a61d..0869644 100644
--- a/anki_exporter.py
+++ b/anki_exporter.py
@@ -1,107 +1,141 @@
# anki_exporter.py
import os
import tempfile
-import subprocess
-import shutil
+import asyncio
import genanki
+import edge_tts
+import shutil
+import database
+
+async def generate_edge_audio(text, voice, output_path, rate_modifier="+0%"):
+ """Asynchronously streams data packages via the Microsoft Edge API pipeline."""
+ try:
+ communicate = edge_tts.Communicate(text=text, voice=voice, rate=rate_modifier)
+ await communicate.save(output_path)
+ return True
+ except Exception as e:
+ print(f"Edge-TTS synthesis anomaly: {e}")
+ return False
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.
+ Compiles database records into a bidirectional card payload package.
+ Resolves voice models dynamically by gender selection parameters and applies
+ global speed coefficient rates from the active configurations.
"""
- # Create a unique random Model ID and Deck ID for genanki
- model_id = 1684329011
- deck_id = 1684329012
+ # Incrementing both forces a completely clean slate for both layout and deck container
+ model_id = 1684329014
+ deck_id = 1684329014
+
+ # Global Configuration Pace Resolver Mapping
+ settings = database.load_all_settings() or {}
+ config_speed = settings.get("tts_playback_speed", "1.0")
+
+ # Transform numeric string floats (e.g., 1.2) into Edge-TTS percentage strings (e.g., +20%)
+ try:
+ pct = int((float(config_speed) - 1.0) * 100)
+ rate_string = f"{'+' if pct >= 0 else ''}{pct}%"
+ except Exception:
+ rate_string = "+0%"
- # Define the Anki Card Layout structure with audio fields
anki_model = genanki.Model(
model_id,
- 'Spanish Voice Trainer Model',
+ 'Spanish Bidirectional Multi-Note HTML Model',
fields=[
{'name': 'EnglishText'},
{'name': 'SpanishText'},
{'name': 'Notes'},
+ {'name': 'AnkiNotes'},
{'name': 'EnglishAudio'},
{'name': 'SpanishAudio'}
],
templates=[
{
- 'name': 'Card 1',
- 'qfmt': '
'
- 'Translate to Spanish:
{{EnglishText}}
{{EnglishAudio}}
',
+ 'name': 'Card 1: English ➔ Spanish',
+ 'qfmt': 'TRANSLATE TO SPANISH:
'
+ '{{EnglishText}}
'
+ '{{EnglishAudio}}
',
'afmt': '{{FrontSide}}
'
- ''
- '{{SpanishText}}
'
- ''
- '{{Notes}}
'
+ '{{SpanishText}}
'
+ '{{Notes}}
'
+ '{{#AnkiNotes}}Anki Meta: {{AnkiNotes}}
{{/AnkiNotes}}
'
'{{SpanishAudio}}
',
},
+ {
+ 'name': 'Card 2: Spanish ➔ English',
+ 'qfmt': 'TRANSLATE TO ENGLISH:
'
+ '{{SpanishText}}
'
+ '{{SpanishAudio}}
',
+ 'afmt': '{{FrontSide}}
'
+ '{{EnglishText}}
'
+ '{{Notes}}
'
+ '{{#AnkiNotes}}Anki Meta: {{AnkiNotes}}
{{/AnkiNotes}}
'
+ '{{EnglishAudio}}
',
+ }
]
)
deck = genanki.Deck(deck_id, deck_name)
- media_files = []
+ media_files_to_pack = []
+
+ try:
+ loop = asyncio.get_running_loop()
+ except RuntimeError:
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
- # 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"]
+ gender_flag = record.get("gender", "Female")
- # Extract and parse tags for native Anki tag support (split comma-separated text)
+ # Map native neural voice files matching gender settings
+ spanish_voice = "es-ES-AlvaroNeural" if gender_flag == "Male" else "es-ES-ElviraNeural"
+ english_voice = "en-US-EmmaNeural"
+
raw_tags = record.get("tags") or ""
note_tags = [t.strip().replace(" ", "_") for t in raw_tags.split(",") if t.strip()]
- # Build text for visual notes area, appending tags if they exist
- notes_parts = []
- # if record.get('source_context'):
- # notes_parts.append(f"Context: {record['source_context']}")
- # #if record.get('notes'):
- # #notes_parts.append(f"Notes: {record['notes']}")
- # if raw_tags:
- # notes_parts.append(f"Tags: {raw_tags}")
-
- notes_display_text = " | ".join(notes_parts)
+ # Dynamic HTML Notes Mapping Builder
+ notes_html_parts = []
+ if record.get('notes') and record['notes'].strip():
+ notes_html_parts.append(f"{record['notes'].strip()}
")
+ if record.get('source_context') and record['source_context'].strip():
+ notes_html_parts.append(f"Context: {record['source_context'].strip()}
")
+
+ notes_html = "".join(notes_html_parts)
+ anki_notes_html = f"{record['anki_notes'].strip()}
" if record.get('anki_notes') else ""
- # Generate unique filenames for the media assets
- en_audio_filename = f"en_audio_{idx}.mp3"
- es_audio_filename = f"es_audio_{idx}.mp3"
+ # Standard Unique Media Filenames
+ en_audio_filename = f"edge_en_{idx}_{model_id}.mp3"
+ es_audio_filename = f"edge_es_{idx}_{model_id}.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)
+ # English Audio Synthesis
+ if loop.run_until_complete(generate_edge_audio(en_text, english_voice, en_audio_path, rate_string)):
+ media_files_to_pack.append(en_audio_path)
en_audio_field = f"[sound:{en_audio_filename}]"
- except Exception:
+ else:
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)
+ # Spanish Audio Synthesis
+ if loop.run_until_complete(generate_edge_audio(es_text, spanish_voice, es_audio_path, rate_string)):
+ media_files_to_pack.append(es_audio_path)
es_audio_field = f"[sound:{es_audio_filename}]"
- except Exception:
+ else:
es_audio_field = ""
- # Build the card note stack, natively injecting the parsed Anki tags list
note = genanki.Note(
model=anki_model,
- fields=[en_text, es_text, notes_display_text, en_audio_field, es_audio_field],
+ fields=[en_text, es_text, notes_html, anki_notes_html, en_audio_field, es_audio_field],
tags=note_tags
)
deck.add_note(note)
- # Build package collection mapping archive pipelines
+ # Build Package while media assets are guaranteed contextually active inside tmpdir
package = genanki.Package(deck)
- package.media_files = media_files
- package.write_to_file(output_path)
+ package.media_files = media_files_to_pack
+ package.write_to_file(output_path)
\ No newline at end of file
diff --git a/database.py b/database.py
index 301cf61..01cf016 100644
--- a/database.py
+++ b/database.py
@@ -18,7 +18,7 @@ def ensure_database_populated():
conn = get_connection()
cursor = conn.cursor()
try:
- # 1. Simplified unified translations table
+ # 1. Unified translations table with dedicated anki_notes and gender tracks
cursor.execute("""
CREATE TABLE IF NOT EXISTS translations (
translation_id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -26,7 +26,9 @@ def ensure_database_populated():
en_text TEXT NOT NULL,
source_context TEXT,
tags TEXT,
- notes TEXT
+ notes TEXT,
+ anki_notes TEXT DEFAULT '',
+ gender TEXT DEFAULT 'Female'
);
""")
@@ -77,7 +79,7 @@ def save_setting_to_db(key, value):
def get_all_translations_explicit():
"""
- Retrieves all 784+ records using completely explicit,
+ Retrieves all records using completely explicit,
table-qualified column declarations for the engines.
"""
conn = get_connection()
@@ -90,7 +92,9 @@ def get_all_translations_explicit():
translations.en_text,
translations.source_context,
translations.tags,
- translations.notes
+ translations.notes,
+ translations.anki_notes,
+ translations.gender
FROM translations
ORDER BY translations.translation_id ASC;
""")
@@ -110,7 +114,9 @@ def get_translation_by_id(translation_id):
translations.en_text,
translations.source_context,
translations.tags,
- translations.notes
+ translations.notes,
+ translations.anki_notes,
+ translations.gender
FROM translations
WHERE translations.translation_id = ?;
""", (translation_id,))
@@ -119,21 +125,34 @@ def get_translation_by_id(translation_id):
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."""
+def update_translation_record(translation_id, es_text, en_text, source_context, tags, notes, gender, anki_notes):
+ """Saves sandbox interface edits directly back down into the table using named arguments."""
conn = get_connection()
cursor = conn.cursor()
try:
+ # The SQL uses :key syntax instead of ?
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))
+ es_text = :es,
+ en_text = :en,
+ source_context = :ctx,
+ tags = :tags,
+ notes = :notes,
+ gender = :gender,
+ anki_notes = :anki
+ WHERE translation_id = :id;
+ """, {
+ # The order inside this dictionary does not matter at all!
+ "id": translation_id,
+ "es": es_text,
+ "en": en_text,
+ "ctx": source_context,
+ "tags": tags,
+ "notes": notes,
+ "gender": gender,
+ "anki": anki_notes
+ })
conn.commit()
finally:
conn.close()
@@ -150,16 +169,26 @@ def delete_translation_record(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()
+
+def insert_translation_record(es_text, en_text, source_context, tags, notes, gender, anki_notes):
+ """Inserts a new record using named arguments so positional order doesn't matter."""
+ conn = get_connection() # Corrected from get_db_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))
+ INSERT INTO translations (es_text, en_text, source_context, tags, notes, gender, anki_notes)
+ VALUES (:es, :en, :ctx, :tags, :notes, :gender, :anki);
+ """, {
+ # SQLite maps these keys directly to the tokens above by name
+ "en": en_text,
+ "es": es_text,
+ "ctx": source_context,
+ "tags": tags,
+ "notes": notes,
+ "anki": anki_notes,
+ "gender": gender
+ })
conn.commit()
finally:
- conn.close()
\ No newline at end of file
+ conn.close()
+
diff --git a/spanish_trainer.db b/spanish_trainer.db
index 9ae2a39..6cb9c32 100644
Binary files a/spanish_trainer.db and b/spanish_trainer.db differ
diff --git a/tabs/sandbox_tab.py b/tabs/sandbox_tab.py
index 3978202..f2d620d 100644
--- a/tabs/sandbox_tab.py
+++ b/tabs/sandbox_tab.py
@@ -1,24 +1,58 @@
# tabs/sandbox_tab.py
import subprocess
+import tempfile
+import os
+import threading
+import asyncio
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
- QLineEdit, QPushButton, QTableWidget, QTableWidgetItem,
- QHeaderView, QMessageBox, QFormLayout
+ QLineEdit, QPushButton, QTableWidget, QTableWidgetItem, \
+ QHeaderView, QMessageBox, QFormLayout, QDialog, QTextEdit, QRadioButton, QButtonGroup
)
from PyQt6.QtCore import pyqtSignal, pyqtSlot, Qt
+import edge_tts
import database
+class TextEditorDialog(QDialog):
+ """A pop-up modal containing a large text field workspace for copy-pasting extra text blocks."""
+ def __init__(self, title, initial_text="", parent=None):
+ super().__init__(parent)
+ self.setWindowTitle(title)
+ self.resize(500, 350)
+ layout = QVBoxLayout(self)
+
+ self.editor = QTextEdit()
+ self.editor.setPlainText(initial_text)
+ self.editor.setStyleSheet("font-family: Arial; font-size: 14px; padding: 5px;")
+ layout.addWidget(self.editor)
+
+ btn_layout = QHBoxLayout()
+ self.btn_save = QPushButton("Save / Apply")
+ self.btn_save.clicked.connect(self.accept)
+ self.btn_cancel = QPushButton("Cancel")
+ self.btn_cancel.clicked.connect(self.reject)
+
+ btn_layout.addStretch()
+ btn_layout.addWidget(self.btn_cancel)
+ btn_layout.addWidget(self.btn_save)
+ layout.addLayout(btn_layout)
+
+ def get_text(self):
+ return self.editor.toPlainText().strip()
+
+
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
+ # Local item memory caching for instant search lookups
+ self.cached_records = []
+ self.current_notes_content = ""
+ self.current_anki_notes_content = ""
+
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(30, 20, 30, 20)
main_layout.setSpacing(15)
@@ -29,361 +63,312 @@ class SandboxTab(QWidget):
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.setPlaceholderText("Enter English phrase (filters grid real-time)...")
self.txt_english.setStyleSheet("padding: 6px; font-size: 14px;")
- self.txt_english.textChanged.connect(self.handle_live_filter)
+ self.txt_english.textChanged.connect(self.apply_live_grid_filter)
self.txt_spanish = QLineEdit()
- self.txt_spanish.setPlaceholderText("Introduce la frase en español...")
+ self.txt_spanish.setPlaceholderText("Enter Spanish phrase (filters grid real-time)...")
self.txt_spanish.setStyleSheet("padding: 6px; font-size: 14px;")
- self.txt_spanish.textChanged.connect(self.handle_live_filter)
+ self.txt_spanish.textChanged.connect(self.apply_live_grid_filter)
self.txt_context = QLineEdit()
- self.txt_context.setPlaceholderText("e.g., Camino 2027, Café, Market conversation...")
+ self.txt_context.setPlaceholderText("Context e.g., Camino 2027 (filters grid real-time)...")
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_context.textChanged.connect(self.apply_live_grid_filter)
+
self.txt_tags = QLineEdit()
- self.txt_tags.setPlaceholderText("e.g., verb, greeting, subjunctive, travel...")
+ self.txt_tags.setPlaceholderText("Comma separated tags (filters grid real-time)...")
self.txt_tags.setStyleSheet("padding: 6px; font-size: 14px;")
- self.txt_tags.textChanged.connect(self.handle_live_filter)
+ self.txt_tags.textChanged.connect(self.apply_live_grid_filter)
- self.txt_notes = QLineEdit()
- self.txt_notes.setPlaceholderText("Grammar rules, formal vs informal nuances...")
- self.txt_notes.setStyleSheet("padding: 6px; font-size: 14px;")
+ # Modal Editor Row Buttons
+ editor_buttons_layout = QHBoxLayout()
+ self.btn_edit_notes = QPushButton("📝 Edit Notes Block")
+ self.btn_edit_notes.clicked.connect(self.open_notes_editor)
- # Mount fields onto Form Layout
- form_layout.addRow(QLabel("English Text:"), self.txt_english)
- form_layout.addRow(QLabel("Spanish Text:"), self.txt_spanish)
+ self.btn_edit_anki_notes = QPushButton("🗂️ Edit Anki Notes Block")
+ self.btn_edit_anki_notes.clicked.connect(self.open_anki_notes_editor)
+
+ editor_buttons_layout.addWidget(self.btn_edit_notes)
+ editor_buttons_layout.addWidget(self.btn_edit_anki_notes)
+
+ form_layout.addRow(QLabel("English Phrase:"), self.txt_english)
+ form_layout.addRow(QLabel("Spanish Translation:"), self.txt_spanish)
form_layout.addRow(QLabel("Source Context:"), self.txt_context)
form_layout.addRow(QLabel("Tags:"), self.txt_tags)
- form_layout.addRow(QLabel("Historical Notes:"), self.txt_notes)
+ form_layout.addRow(QLabel("Extended Data Fields:"), editor_buttons_layout)
main_layout.addWidget(form_container)
- # --- SECTION 2: AUDIO PREVIEW ACTION ROW ---
- audio_layout = QHBoxLayout()
- audio_layout.setSpacing(15)
+ # --- INLINE AUDIO CONTROL + GENDER SELECTION PANEL ---
+ audio_panel = QHBoxLayout()
+ audio_panel.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_test_en = QPushButton("🔊 Test English Voice")
+ self.btn_test_en.clicked.connect(self.audition_english)
- 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)
+ self.btn_test_es = QPushButton("🔊 Test Spanish Voice")
+ self.btn_test_es.clicked.connect(self.audition_spanish)
- audio_layout.addWidget(self.btn_play_en)
- audio_layout.addWidget(self.btn_play_es)
- audio_layout.addStretch()
+ gender_label = QLabel("Speaker Gender:")
+ self.rb_female = QRadioButton("Female")
+ self.rb_male = QRadioButton("Male")
+ self.rb_female.setChecked(True)
- main_layout.addLayout(audio_layout)
+ self.gender_group = QButtonGroup(self)
+ self.gender_group.addButton(self.rb_female)
+ self.gender_group.addButton(self.rb_male)
- # --- SECTION 3: DATA COMMIT CONTROL BAR ---
- control_layout = QHBoxLayout()
- control_layout.setSpacing(15)
+ audio_panel.addWidget(self.btn_test_en)
+ audio_panel.addWidget(self.btn_test_es)
+ audio_panel.addSpacing(20)
+ audio_panel.addWidget(gender_label)
+ audio_panel.addWidget(self.rb_female)
+ audio_panel.addWidget(self.rb_male)
+ audio_panel.addStretch()
- 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)
+ main_layout.addLayout(audio_panel)
- 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)
+ # --- ACTION CONTROL BAR ---
+ actions_layout = QHBoxLayout()
+ self.btn_save_record = QPushButton("📥 Save Transaction")
+ self.btn_save_record.clicked.connect(self.commit_form_entry)
+ self.btn_save_record.setStyleSheet("background-color: #27AE60; color: white; font-weight: bold; padding: 8px 16px;")
- 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)
+ self.btn_clear_form = QPushButton("🧹 Reset Fields")
+ self.btn_clear_form.clicked.connect(self.clear_form_fields)
- control_layout.addWidget(self.btn_save)
- control_layout.addWidget(self.btn_new_record)
- control_layout.addWidget(self.btn_clear)
- control_layout.addStretch()
+ self.btn_delete_record = QPushButton("🗑️ Delete Selected")
+ self.btn_delete_record.clicked.connect(self.remove_target_record)
+ self.btn_delete_record.setStyleSheet("background-color: #C0392B; color: white;")
- main_layout.addLayout(control_layout)
+ actions_layout.addWidget(self.btn_save_record)
+ actions_layout.addWidget(self.btn_clear_form)
+ actions_layout.addWidget(self.btn_delete_record)
+ actions_layout.addStretch()
+ main_layout.addLayout(actions_layout)
- # --- SECTION 4: DATALIST DISPLAY REGION ---
+ # --- VIEWPORT GRID TABLE ---
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.setColumnCount(6)
+ self.table.setHorizontalHeaderLabels(["ID", "English", "Spanish", "Context", "Tags", "Gender"])
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
+ self.table.cellClicked.connect(self.populate_form_from_grid)
- # 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])
+ def open_notes_editor(self):
+ dlg = TextEditorDialog("Edit Grammar / Core Notes Block", self.current_notes_content, self)
+ if dlg.exec():
+ self.current_notes_content = dlg.get_text()
+ print(f"[DEBUG DIALOG CLOSE] Notes Block updated in memory: {self.current_notes_content}")
+
@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])
+ def open_anki_notes_editor(self):
+ dlg = TextEditorDialog("Edit Anki Specialized Meta Field", self.current_anki_notes_content, self)
+ if dlg.exec():
+ self.current_anki_notes_content = dlg.get_text()
+ print(f"[DEBUG DIALOG CLOSE] Anki Notes Block updated in memory: {self.current_anki_notes_content}")
- @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."""
+ def clear_form_fields(self):
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 = None
self.txt_english.clear()
self.txt_spanish.clear()
self.txt_context.clear()
self.txt_tags.clear()
- self.txt_notes.clear()
-
+ self.current_notes_content = ""
+ self.current_anki_notes_content = ""
+ self.rb_female.setChecked(True)
+
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)
+
+ self.apply_live_grid_filter()
+
+ @pyqtSlot()
+ def commit_form_entry(self):
+ en_t = self.txt_english.text().strip()
+ es_t = self.txt_spanish.text().strip()
+ ctx_t = self.txt_context.text().strip()
+ tag_t = self.txt_tags.text().strip()
+ gender_t = "Male" if self.rb_male.isChecked() else "Female"
+
+ if not en_t or not es_t:
+ QMessageBox.warning(self, "Validation Alert", "English and Spanish phrase properties cannot remain blank.")
+ return
+
+ print(f"\n[DEBUG DATABASE WRITE]")
+ print(f" ID Selected: {self.selected_translation_id}")
+ print(f" English: {en_t}")
+ print(f" Spanish: {es_t}")
+ print(f" Context: {ctx_t}")
+ print(f" Tags: {tag_t}")
+ print(f" Notes: {self.current_notes_content}")
+ print(f" Anki Notes: {self.current_anki_notes_content}")
+ print(f" Gender: {gender_t}\n")
+
+
+ # Fixed argument sequence to match Beekeeper schema (gender position 7, anki_notes position 8)
+ if self.selected_translation_id is None:
+ database.insert_translation_record(es_t, en_t, ctx_t, tag_t, self.current_notes_content, gender_t, self.current_anki_notes_content)
+ else:
+ database.update_translation_record(self.selected_translation_id, es_t, en_t, ctx_t, tag_t, self.current_notes_content, gender_t, self.current_anki_notes_content)
+
+ self.clear_form_fields()
+ self.reload_table_display()
+ self.data_mutated.emit()
+
+ @pyqtSlot()
+ def remove_target_record(self):
+ if self.selected_translation_id is None:
+ QMessageBox.warning(self, "Selection Missing", "Please select a row from the grid viewport before attempting deletion.")
+ return
+
+ confirm = QMessageBox.question(
+ self,
+ "Confirm Deletion",
+ "Are you sure you want to permanently delete this translation record?",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
+ )
+
+ if confirm == QMessageBox.StandardButton.Yes:
+ database.delete_translation_record(self.selected_translation_id)
+ self.clear_form_fields()
+ self.reload_table_display()
+ self.data_mutated.emit()
+
+ def populate_form_from_grid(self, row, col):
+ self.selected_translation_id = int(self.table.item(row, 0).text())
+ record = database.get_translation_by_id(self.selected_translation_id)
+
+ if record:
+ self.txt_english.blockSignals(True)
+ self.txt_spanish.blockSignals(True)
+ self.txt_context.blockSignals(True)
+ self.txt_tags.blockSignals(True)
+
+ self.txt_english.setText(record["en_text"])
+ self.txt_spanish.setText(record["es_text"])
+ self.txt_context.setText(record.get("source_context", ""))
+ self.txt_tags.setText(record.get("tags", ""))
+ self.current_notes_content = record.get("notes", "")
+ self.current_anki_notes_content = record.get("anki_notes", "")
+
+ if record.get("gender") == "Male":
+ self.rb_male.setChecked(True)
+ else:
+ self.rb_female.setChecked(True)
+
+ self.txt_english.blockSignals(False)
+ self.txt_spanish.blockSignals(False)
+ self.txt_context.blockSignals(False)
+ self.txt_tags.blockSignals(False)
def reload_table_display(self):
- """Refetches database rows and populates the master dashboard grid view."""
- self.table.blockSignals(True)
+ self.cached_records = database.get_all_translations_explicit()
+ self.apply_live_grid_filter()
+
+ @pyqtSlot()
+ def apply_live_grid_filter(self):
+ 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_tags = self.txt_tags.text().lower().strip()
+
self.table.setRowCount(0)
+ visible_row_idx = 0
+
+ for r in self.cached_records:
+ match_en = filter_en in (r.get("en_text") or "").lower()
+ match_es = filter_es in (r.get("es_text") or "").lower()
+ match_ctx = filter_ctx in (r.get("source_context") or "").lower()
+ match_tags = filter_tags in (r.get("tags") or "").lower()
+
+ if match_en and match_es and match_ctx and match_tags:
+ self.table.insertRow(visible_row_idx)
+ self.table.setItem(visible_row_idx, 0, QTableWidgetItem(str(r["translation_id"])))
+ self.table.setItem(visible_row_idx, 1, QTableWidgetItem(r["en_text"]))
+ self.table.setItem(visible_row_idx, 2, QTableWidgetItem(r["es_text"]))
+ self.table.setItem(visible_row_idx, 3, QTableWidgetItem(r.get("source_context", "")))
+ self.table.setItem(visible_row_idx, 4, QTableWidgetItem(r.get("tags", "")))
+ self.table.setItem(visible_row_idx, 5, QTableWidgetItem(r.get("gender", "Female")))
+ visible_row_idx += 1
+
+ def _async_edge_speech_worker(self, text, voice, rate_modifier):
+ """Background thread worker to download neural audio and play it without freezing the UI."""
+ async def stream_audio():
+ temp_file = os.path.join(tempfile.gettempdir(), "sandbox_audition.mp3")
+ try:
+ communicate = edge_tts.Communicate(text=text, voice=voice, rate=rate_modifier)
+ await communicate.save(temp_file)
+ if os.path.exists(temp_file):
+ subprocess.run(["afplay", temp_file])
+ except Exception as e:
+ print(f"Sandbox Audition Error: {e}")
+
+ asyncio.run(stream_audio())
+
+ @pyqtSlot()
+ def audition_english(self):
+ txt = self.txt_english.text().strip()
+ if not txt:
+ return
+
+ settings = database.load_all_settings() or {}
+ # Fixed: Changed lookup from "playback_speed_multiplier" to "tts_playback_speed"
+ config_speed = settings.get("tts_playback_speed", "1.0")
+ try:
+ pct = int((float(config_speed) - 1.0) * 100)
+ rate_string = f"{'+' if pct >= 0 else ''}{pct}%"
+ except Exception:
+ rate_string = "+0%"
+
+ threading.Thread(
+ target=self._async_edge_speech_worker,
+ args=(txt, "en-US-EmmaNeural", rate_string),
+ daemon=True
+ ).start()
+
+ @pyqtSlot()
+ def audition_spanish(self):
+ txt = self.txt_spanish.text().strip()
+ if not txt:
+ return
+
+ # Dynamically switch between neural voices depending on the active form state radio selection
+ voice = "es-ES-AlvaroNeural" if self.rb_male.isChecked() else "es-ES-ElviraNeural"
- # 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)
\ No newline at end of file
+ settings = database.load_all_settings() or {}
+ config_speed = settings.get("tts_playback_speed", "1.0")
+ try:
+ pct = int((float(config_speed) - 1.0) * 100)
+ print(f"[DEBUG Sandbox Speech] config_speed (Raw String): {config_speed}")
+ print(f"[DEBUG Sandbox Speech] Calculated pct (Integer): {pct}")
+ rate_string = f"{'+' if pct >= 0 else ''}{pct}%"
+ except Exception:
+ print(f"[DEBUG Sandbox Speech] Failed to parse speed calculation")
+ rate_string = "+0%"
+
+ threading.Thread(
+ target=self._async_edge_speech_worker,
+ args=(txt, voice, rate_string),
+ daemon=True
+ ).start()
\ No newline at end of file