From 23b4228b585496c65f66f245123980e3570deda8 Mon Sep 17 00:00:00 2001 From: stephen Date: Mon, 29 Jun 2026 22:07:07 +1000 Subject: [PATCH] Change of anki template to get formating in anki to work --- anki_exporter.py | 140 +++++++---- database.py | 73 ++++-- spanish_trainer.db | Bin 217088 -> 217088 bytes tabs/sandbox_tab.py | 593 +++++++++++++++++++++----------------------- 4 files changed, 427 insertions(+), 379 deletions(-) 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 9ae2a39e54f0ea1a631ccf85e9d1c933de79f205..6cb9c3237cfcff88b333e7f68290ee18ffa8a670 100644 GIT binary patch delta 1047 zcmZuwO-vI(6yELbcDMVBK}4d)u?ZPH6EM-qE#oE&CI^AF3=^gM*Wz!s?>73G@RhTgKFRRv1fx#OiiVSow?I#2#n|BM7IF z3lhCU32>zMX!n^D2@pDpaxjZR?EvCY+r$n*-d~8&&Qe8t?9)G#ru8A7VOTJKbt}8j zz=9_I2P5iL(RgXh7-Pnu5z(tgV|fF3t)FV?<04IjcxFBeFQuS0YI=Eu(w*VGHTKPo zEA6~O%1k2~!!U<7ALk*02B;?1kU;&s+1Gdp&)G`F@)wZJ^|YzM=1?XYqp=uFg%M0*m* zBVELWHl_yGOkA}!M%=gz=!%haJR0ku&-LqI1_9dx5d5wM^2qfZ3}DJB5RjpziWeMr zG+dw9o6SN%zmfu`xhn|}{ALF7jtjg3pkz|;oKv3*tSlvdFlafLpo=6J?}0fRJD{tx zYj1s%n3snbSjAO5fg{0q8hMs$=LuE7i*?lE@io*Rp!1FeQ-v&aFa3hl4IS!UmoQYD zo=_2n5#>OI1H+D+YX><?RD6Oj8*6AM)?y?c~kob>vmx<>Yz7bCPER&rF^s zo-FQ5+}pY5bGLJ+b31bDaPx58}Bi`OjFpU*csWbv2A0Uz?RD9%%(cILBM+Q zUID4iR|WPnZhjE7fQ1Fn+a 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