Change of anki template to get formating in anki to work

This commit is contained in:
stephen 2026-06-29 22:07:07 +10:00
parent aa614391b8
commit 45441bb159
4 changed files with 427 additions and 379 deletions

View file

@ -1,107 +1,141 @@
# anki_exporter.py # anki_exporter.py
import os import os
import tempfile import tempfile
import subprocess import asyncio
import shutil
import genanki 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): def compile_anki_package(records, output_path, deck_name):
""" """
Compiles database records into an .apkg package using native macOS TTS. Compiles database records into a bidirectional card payload package.
Uses 'Monica' for Spanish targets and the default premium system voice for English. 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 # Incrementing both forces a completely clean slate for both layout and deck container
model_id = 1684329011 model_id = 1684329014
deck_id = 1684329012 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( anki_model = genanki.Model(
model_id, model_id,
'Spanish Voice Trainer Model', 'Spanish Bidirectional Multi-Note HTML Model',
fields=[ fields=[
{'name': 'EnglishText'}, {'name': 'EnglishText'},
{'name': 'SpanishText'}, {'name': 'SpanishText'},
{'name': 'Notes'}, {'name': 'Notes'},
{'name': 'AnkiNotes'},
{'name': 'EnglishAudio'}, {'name': 'EnglishAudio'},
{'name': 'SpanishAudio'} {'name': 'SpanishAudio'}
], ],
templates=[ templates=[
{ {
'name': 'Card 1', 'name': 'Card 1: English ➔ Spanish',
'qfmt': '<div style="font-family: Arial; font-size: 20px; text-align: center; color: #34495E;">' 'qfmt': '<div style="font-family: Arial; font-size: 13px; font-weight: bold; color: #BDC3C7; text-align: center; letter-spacing: 1px;">TRANSLATE TO SPANISH:</div><br>'
'Translate to Spanish:<br><br><b>{{EnglishText}}</b></div><div style="display:none;">{{EnglishAudio}}</div>', '<div style="font-family: Arial; font-size: 20px; text-align: center; color: #34495E;"><b>{{EnglishText}}</b></div>'
'<div style="display:none;">{{EnglishAudio}}</div>',
'afmt': '{{FrontSide}}<hr id="answer">' 'afmt': '{{FrontSide}}<hr id="answer">'
'<div style="font-family: Arial; font-size: 28px; text-align: center; color: #2980B9; font-weight: bold;">' '<div style="font-family: Arial; font-size: 28px; text-align: center; color: #2980B9; font-weight: bold;">{{SpanishText}}</div><br>'
'{{SpanishText}}</div><br>' '<div style="font-family: Arial; font-size: 14px; text-align: center; color: #34495E;">{{Notes}}</div>'
'<div style="font-family: Arial; font-size: 14px; text-align: center; color: #7F8C8D; font-style: italic;">' '{{#AnkiNotes}}<div style="font-family: Arial; font-size: 13px; text-align: center; color: #8E44AD; border-top: 1px dashed #E5E7E9; padding-top: 6px; margin-top: 6px;"><b>Anki Meta:</b> {{AnkiNotes}}</div>{{/AnkiNotes}}<br>'
'{{Notes}}</div><br>'
'<div style="text-align: center;">{{SpanishAudio}}</div>', '<div style="text-align: center;">{{SpanishAudio}}</div>',
}, },
{
'name': 'Card 2: Spanish ➔ English',
'qfmt': '<div style="font-family: Arial; font-size: 13px; font-weight: bold; color: #E67E22; text-align: center; letter-spacing: 1px;">TRANSLATE TO ENGLISH:</div><br>'
'<div style="font-family: Arial; font-size: 26px; text-align: center; color: #2980B9; font-weight: bold;"><b>{{SpanishText}}</b></div>'
'<div style="display:none;">{{SpanishAudio}}</div>',
'afmt': '{{FrontSide}}<hr id="answer">'
'<div style="font-family: Arial; font-size: 22px; text-align: center; color: #2C3E50; font-weight: 500;">{{EnglishText}}</div><br>'
'<div style="font-family: Arial; font-size: 14px; text-align: center; color: #34495E;">{{Notes}}</div>'
'{{#AnkiNotes}}<div style="font-family: Arial; font-size: 13px; text-align: center; color: #8E44AD; border-top: 1px dashed #E5E7E9; padding-top: 6px; margin-top: 6px;"><b>Anki Meta:</b> {{AnkiNotes}}</div>{{/AnkiNotes}}<br>'
'<div style="text-align: center;">{{EnglishAudio}}</div>',
}
] ]
) )
deck = genanki.Deck(deck_id, deck_name) 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: with tempfile.TemporaryDirectory() as tmpdir:
for idx, record in enumerate(records): for idx, record in enumerate(records):
en_text = record["en_text"] en_text = record["en_text"]
es_text = record["es_text"] es_text = record["es_text"]
gender_flag = record.get("gender", "Female")
# 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"
# Extract and parse tags for native Anki tag support (split comma-separated text)
raw_tags = record.get("tags") or "" raw_tags = record.get("tags") or ""
note_tags = [t.strip().replace(" ", "_") for t in raw_tags.split(",") if t.strip()] 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 # Dynamic HTML Notes Mapping Builder
notes_parts = [] notes_html_parts = []
# if record.get('source_context'): if record.get('notes') and record['notes'].strip():
# notes_parts.append(f"Context: {record['source_context']}") notes_html_parts.append(f"<div style='margin-bottom: 4px;'>{record['notes'].strip()}</div>")
# #if record.get('notes'): if record.get('source_context') and record['source_context'].strip():
# #notes_parts.append(f"Notes: {record['notes']}") notes_html_parts.append(f"<div style='font-size: 12px; color: #95A5A6; font-style: italic;'>Context: {record['source_context'].strip()}</div>")
# if raw_tags:
# notes_parts.append(f"Tags: {raw_tags}")
notes_display_text = " | ".join(notes_parts) notes_html = "".join(notes_html_parts)
anki_notes_html = f"<div>{record['anki_notes'].strip()}</div>" if record.get('anki_notes') else ""
# Generate unique filenames for the media assets # Standard Unique Media Filenames
en_audio_filename = f"en_audio_{idx}.mp3" en_audio_filename = f"edge_en_{idx}_{model_id}.mp3"
es_audio_filename = f"es_audio_{idx}.mp3" es_audio_filename = f"edge_es_{idx}_{model_id}.mp3"
en_audio_path = os.path.join(tmpdir, en_audio_filename) en_audio_path = os.path.join(tmpdir, en_audio_filename)
es_audio_path = os.path.join(tmpdir, es_audio_filename) es_audio_path = os.path.join(tmpdir, es_audio_filename)
try: # English Audio Synthesis
# 1. Render English Audio using native macOS text-to-speech engine if loop.run_until_complete(generate_edge_audio(en_text, english_voice, en_audio_path, rate_string)):
subprocess.run( media_files_to_pack.append(en_audio_path)
["say", "-o", en_audio_path, "--data-format=Iface", en_text],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
media_files.append(en_audio_path)
en_audio_field = f"[sound:{en_audio_filename}]" en_audio_field = f"[sound:{en_audio_filename}]"
except Exception: else:
en_audio_field = "" en_audio_field = ""
try: # Spanish Audio Synthesis
# 2. Render Spanish Audio explicitly targeting the Monica voice profile if loop.run_until_complete(generate_edge_audio(es_text, spanish_voice, es_audio_path, rate_string)):
subprocess.run( media_files_to_pack.append(es_audio_path)
["say", "-v", "Monica", "-o", es_audio_path, "--data-format=Iface", es_text],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
media_files.append(es_audio_path)
es_audio_field = f"[sound:{es_audio_filename}]" es_audio_field = f"[sound:{es_audio_filename}]"
except Exception: else:
es_audio_field = "" es_audio_field = ""
# Build the card note stack, natively injecting the parsed Anki tags list
note = genanki.Note( note = genanki.Note(
model=anki_model, 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 tags=note_tags
) )
deck.add_note(note) 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 = genanki.Package(deck)
package.media_files = media_files package.media_files = media_files_to_pack
package.write_to_file(output_path) package.write_to_file(output_path)

View file

@ -18,7 +18,7 @@ def ensure_database_populated():
conn = get_connection() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
try: try:
# 1. Simplified unified translations table # 1. Unified translations table with dedicated anki_notes and gender tracks
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS translations ( CREATE TABLE IF NOT EXISTS translations (
translation_id INTEGER PRIMARY KEY AUTOINCREMENT, translation_id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -26,7 +26,9 @@ def ensure_database_populated():
en_text TEXT NOT NULL, en_text TEXT NOT NULL,
source_context TEXT, source_context TEXT,
tags 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(): 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. table-qualified column declarations for the engines.
""" """
conn = get_connection() conn = get_connection()
@ -90,7 +92,9 @@ def get_all_translations_explicit():
translations.en_text, translations.en_text,
translations.source_context, translations.source_context,
translations.tags, translations.tags,
translations.notes translations.notes,
translations.anki_notes,
translations.gender
FROM translations FROM translations
ORDER BY translations.translation_id ASC; ORDER BY translations.translation_id ASC;
""") """)
@ -110,7 +114,9 @@ def get_translation_by_id(translation_id):
translations.en_text, translations.en_text,
translations.source_context, translations.source_context,
translations.tags, translations.tags,
translations.notes translations.notes,
translations.anki_notes,
translations.gender
FROM translations FROM translations
WHERE translations.translation_id = ?; WHERE translations.translation_id = ?;
""", (translation_id,)) """, (translation_id,))
@ -119,21 +125,34 @@ def get_translation_by_id(translation_id):
finally: finally:
conn.close() conn.close()
def update_translation_record(translation_id, es_text, en_text, source_context, tags, notes): 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.""" """Saves sandbox interface edits directly back down into the table using named arguments."""
conn = get_connection() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
try: try:
# The SQL uses :key syntax instead of ?
cursor.execute(""" cursor.execute("""
UPDATE translations UPDATE translations
SET SET
es_text = ?, es_text = :es,
en_text = ?, en_text = :en,
source_context = ?, source_context = :ctx,
tags = ?, tags = :tags,
notes = ? notes = :notes,
WHERE translation_id = ?; gender = :gender,
""", (es_text, en_text, source_context, tags, notes, translation_id)) 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() conn.commit()
finally: finally:
conn.close() conn.close()
@ -151,15 +170,25 @@ def delete_translation_record(translation_id):
finally: finally:
conn.close() conn.close()
def insert_translation_record(es_text, en_text, source_context, tags, notes): def insert_translation_record(es_text, en_text, source_context, tags, notes, gender, anki_notes):
"""Inserts a completely fresh record into the translations table.""" """Inserts a new record using named arguments so positional order doesn't matter."""
conn = get_connection() conn = get_connection() # Corrected from get_db_connection
cursor = conn.cursor() cursor = conn.cursor()
try: try:
cursor.execute(""" cursor.execute("""
INSERT INTO translations (es_text, en_text, source_context, tags, notes) INSERT INTO translations (es_text, en_text, source_context, tags, notes, gender, anki_notes)
VALUES (?, ?, ?, ?, ?); VALUES (:es, :en, :ctx, :tags, :notes, :gender, :anki);
""", (es_text, en_text, source_context, tags, notes)) """, {
# 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() conn.commit()
finally: finally:
conn.close() conn.close()

Binary file not shown.

View file

@ -1,24 +1,58 @@
# tabs/sandbox_tab.py # tabs/sandbox_tab.py
import subprocess import subprocess
import tempfile
import os
import threading
import asyncio
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QLineEdit, QPushButton, QTableWidget, QTableWidgetItem, QLineEdit, QPushButton, QTableWidget, QTableWidgetItem, \
QHeaderView, QMessageBox, QFormLayout QHeaderView, QMessageBox, QFormLayout, QDialog, QTextEdit, QRadioButton, QButtonGroup
) )
from PyQt6.QtCore import pyqtSignal, pyqtSlot, Qt from PyQt6.QtCore import pyqtSignal, pyqtSlot, Qt
import edge_tts
import database 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): class SandboxTab(QWidget):
# Signal emitted whenever data is added, modified, or deleted
data_mutated = pyqtSignal() data_mutated = pyqtSignal()
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
# Internal tracking variable to distinguish edits vs new entries
self.selected_translation_id = None 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 = QVBoxLayout(self)
main_layout.setContentsMargins(30, 20, 30, 20) main_layout.setContentsMargins(30, 20, 30, 20)
main_layout.setSpacing(15) main_layout.setSpacing(15)
@ -29,361 +63,312 @@ class SandboxTab(QWidget):
form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight) form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
form_layout.setSpacing(10) form_layout.setSpacing(10)
# Input Form Fields
self.txt_english = QLineEdit() 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.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 = 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.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 = 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.setStyleSheet("padding: 6px; font-size: 14px;")
self.txt_context.textChanged.connect(self.handle_live_filter) self.txt_context.textChanged.connect(self.apply_live_grid_filter)
# NEW: Tags input field with live filter connection
self.txt_tags = QLineEdit() 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.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() # Modal Editor Row Buttons
self.txt_notes.setPlaceholderText("Grammar rules, formal vs informal nuances...") editor_buttons_layout = QHBoxLayout()
self.txt_notes.setStyleSheet("padding: 6px; font-size: 14px;") self.btn_edit_notes = QPushButton("📝 Edit Notes Block")
self.btn_edit_notes.clicked.connect(self.open_notes_editor)
# Mount fields onto Form Layout self.btn_edit_anki_notes = QPushButton("🗂️ Edit Anki Notes Block")
form_layout.addRow(QLabel("<b>English Text:</b>"), self.txt_english) self.btn_edit_anki_notes.clicked.connect(self.open_anki_notes_editor)
form_layout.addRow(QLabel("<b>Spanish Text:</b>"), self.txt_spanish)
editor_buttons_layout.addWidget(self.btn_edit_notes)
editor_buttons_layout.addWidget(self.btn_edit_anki_notes)
form_layout.addRow(QLabel("<b>English Phrase:</b>"), self.txt_english)
form_layout.addRow(QLabel("<b>Spanish Translation:</b>"), self.txt_spanish)
form_layout.addRow(QLabel("<b>Source Context:</b>"), self.txt_context) form_layout.addRow(QLabel("<b>Source Context:</b>"), self.txt_context)
form_layout.addRow(QLabel("<b>Tags:</b>"), self.txt_tags) form_layout.addRow(QLabel("<b>Tags:</b>"), self.txt_tags)
form_layout.addRow(QLabel("<b>Historical Notes:</b>"), self.txt_notes) form_layout.addRow(QLabel("<b>Extended Data Fields:</b>"), editor_buttons_layout)
main_layout.addWidget(form_container) main_layout.addWidget(form_container)
# --- SECTION 2: AUDIO PREVIEW ACTION ROW --- # --- INLINE AUDIO CONTROL + GENDER SELECTION PANEL ---
audio_layout = QHBoxLayout() audio_panel = QHBoxLayout()
audio_layout.setSpacing(15) audio_panel.setSpacing(15)
self.btn_play_en = QPushButton("🔊 Test English Voice") self.btn_test_en = QPushButton("🔊 Test English Voice")
self.btn_play_en.setCursor(Qt.CursorShape.PointingHandCursor) self.btn_test_en.clicked.connect(self.audition_english)
self.btn_play_en.setStyleSheet("""
QPushButton {
background-color: #E67E22;
color: white;
font-weight: bold;
font-size: 13px;
padding: 8px 16px;
border-radius: 4px;
}
QPushButton:hover { background-color: #D35400; }
""")
self.btn_play_en.clicked.connect(self.preview_english_audio)
self.btn_play_es = QPushButton("🔊 Test Mónica (Spanish)") self.btn_test_es = QPushButton("🔊 Test Spanish Voice")
self.btn_play_es.setCursor(Qt.CursorShape.PointingHandCursor) self.btn_test_es.clicked.connect(self.audition_spanish)
self.btn_play_es.setStyleSheet("""
QPushButton {
background-color: #9B59B6;
color: white;
font-weight: bold;
font-size: 13px;
padding: 8px 16px;
border-radius: 4px;
}
QPushButton:hover { background-color: #8E44AD; }
""")
self.btn_play_es.clicked.connect(self.preview_spanish_audio)
audio_layout.addWidget(self.btn_play_en) gender_label = QLabel("<b>Speaker Gender:</b>")
audio_layout.addWidget(self.btn_play_es) self.rb_female = QRadioButton("Female")
audio_layout.addStretch() 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 --- audio_panel.addWidget(self.btn_test_en)
control_layout = QHBoxLayout() audio_panel.addWidget(self.btn_test_es)
control_layout.setSpacing(15) 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") main_layout.addLayout(audio_panel)
self.btn_save.setCursor(Qt.CursorShape.PointingHandCursor)
self.btn_save.setStyleSheet("""
QPushButton {
background-color: #2980B9;
color: white;
font-weight: bold;
font-size: 14px;
padding: 10px 20px;
border-radius: 5px;
}
QPushButton:hover { background-color: #1F618D; }
""")
self.btn_save.clicked.connect(self.commit_translation_record)
self.btn_new_record = QPushButton("Create New Record") # --- ACTION CONTROL BAR ---
self.btn_new_record.setCursor(Qt.CursorShape.PointingHandCursor) actions_layout = QHBoxLayout()
self.btn_new_record.setStyleSheet(""" self.btn_save_record = QPushButton("📥 Save Transaction")
QPushButton { self.btn_save_record.clicked.connect(self.commit_form_entry)
background-color: #27AE60; self.btn_save_record.setStyleSheet("background-color: #27AE60; color: white; font-weight: bold; padding: 8px 16px;")
color: white;
font-weight: bold;
font-size: 14px;
padding: 10px 20px;
border-radius: 5px;
}
QPushButton:hover { background-color: #219653; }
""")
self.btn_new_record.clicked.connect(self.prepare_for_new_record)
self.btn_clear = QPushButton("Clear Fields") self.btn_clear_form = QPushButton("🧹 Reset Fields")
self.btn_clear.setCursor(Qt.CursorShape.PointingHandCursor) self.btn_clear_form.clicked.connect(self.clear_form_fields)
self.btn_clear.setStyleSheet("""
QPushButton {
background-color: #BDC3C7;
color: #34495E;
font-weight: bold;
font-size: 14px;
padding: 10px 20px;
border-radius: 5px;
}
QPushButton:hover { background-color: #95A5A6; }
""")
self.btn_clear.clicked.connect(self.clear_all_fields_manually)
control_layout.addWidget(self.btn_save) self.btn_delete_record = QPushButton("🗑️ Delete Selected")
control_layout.addWidget(self.btn_new_record) self.btn_delete_record.clicked.connect(self.remove_target_record)
control_layout.addWidget(self.btn_clear) self.btn_delete_record.setStyleSheet("background-color: #C0392B; color: white;")
control_layout.addStretch()
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 = QTableWidget()
self.table.setColumnCount(6) # Increased to 6 to display Tags column self.table.setColumnCount(6)
self.table.setHorizontalHeaderLabels(["ID", "English Phrase", "Spanish Translation", "Context", "Tags", "Notes"]) self.table.setHorizontalHeaderLabels(["ID", "English", "Spanish", "Context", "Tags", "Gender"])
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) 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 = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(2, 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) main_layout.addWidget(self.table)
# Master cache of unfiltered row records to enable instant filtering loops
self.all_cached_records = []
self.reload_table_display() self.reload_table_display()
@pyqtSlot() @pyqtSlot()
def preview_english_audio(self): def open_notes_editor(self):
"""Auditions current text state inside the English text box field.""" dlg = TextEditorDialog("Edit Grammar / Core Notes Block", self.current_notes_content, self)
text = self.txt_english.text().strip() if dlg.exec():
if text: self.current_notes_content = dlg.get_text()
subprocess.Popen(["say", text]) print(f"[DEBUG DIALOG CLOSE] Notes Block updated in memory: {self.current_notes_content}")
@pyqtSlot() @pyqtSlot()
def preview_spanish_audio(self): def open_anki_notes_editor(self):
"""Auditions current text state inside the Spanish text box field using Mónica.""" dlg = TextEditorDialog("Edit Anki Specialized Meta Field", self.current_anki_notes_content, self)
text = self.txt_spanish.text().strip() if dlg.exec():
if text: self.current_anki_notes_content = dlg.get_text()
subprocess.Popen(["say", "-v", "Monica", text]) print(f"[DEBUG DIALOG CLOSE] Anki Notes Block updated in memory: {self.current_anki_notes_content}")
@pyqtSlot() def clear_form_fields(self):
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_english.blockSignals(True)
self.txt_spanish.blockSignals(True) self.txt_spanish.blockSignals(True)
self.txt_context.blockSignals(True) self.txt_context.blockSignals(True)
self.txt_tags.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.selected_translation_id = None
self.table.blockSignals(True)
self.table.clearSelection()
self.table.blockSignals(False)
QMessageBox.information(self, "Status Shift", "Ready to insert a new record using the current field content.")
@pyqtSlot()
def commit_translation_record(self):
"""Saves current text blocks. Dynamically detects insert vs edit based on selections."""
en_text = self.txt_english.text().strip()
es_text = self.txt_spanish.text().strip()
context = self.txt_context.text().strip()
tags = self.txt_tags.text().strip()
notes = self.txt_notes.text().strip()
if not en_text or not es_text:
QMessageBox.warning(self, "Validation Alert", "Both English and Spanish base text blocks are required.")
return
try:
if self.selected_translation_id is not None:
# database.update_translation_record signature: (translation_id, es_text, en_text, source_context, tags, notes)
database.update_translation_record(
self.selected_translation_id, es_text, en_text, context, tags, notes
)
else:
# database.insert_translation_record signature: (es_text, en_text, source_context, tags, notes)
database.insert_translation_record(
es_text, en_text, context, tags, notes
)
# Clear pointer state values on successful writing commits
self.selected_translation_id = None
# Wipe inputs cleanly and sync UI layers
self.clear_input_fields()
self.reload_table_display()
self.data_mutated.emit()
except Exception as e:
QMessageBox.critical(self, "Database Commit Safeguard", f"Failed writing database operations:\n{str(e)}")
@pyqtSlot()
def clear_all_fields_manually(self):
"""Clears explicit states alongside visual row highlights simultaneously."""
self.selected_translation_id = None
self.table.blockSignals(True)
self.table.clearSelection()
self.table.blockSignals(False)
self.clear_input_fields()
self.reload_table_display()
def clear_input_fields(self):
"""Flushes transient text inside line editors without running filtering rules."""
self.txt_english.blockSignals(True)
self.txt_spanish.blockSignals(True)
self.txt_context.blockSignals(True)
self.txt_tags.blockSignals(True)
self.txt_notes.blockSignals(True)
self.txt_english.clear() self.txt_english.clear()
self.txt_spanish.clear() self.txt_spanish.clear()
self.txt_context.clear() self.txt_context.clear()
self.txt_tags.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.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_english.blockSignals(False)
self.txt_spanish.blockSignals(False) self.txt_spanish.blockSignals(False)
self.txt_context.blockSignals(False) self.txt_context.blockSignals(False)
self.txt_tags.blockSignals(False) self.txt_tags.blockSignals(False)
self.txt_notes.blockSignals(False)
def reload_table_display(self): def reload_table_display(self):
"""Refetches database rows and populates the master dashboard grid view.""" self.cached_records = database.get_all_translations_explicit()
self.table.blockSignals(True) 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) self.table.setRowCount(0)
visible_row_idx = 0
# Keep internal reference arrays synced cleanly for r in self.cached_records:
self.all_cached_records = database.get_all_translations_explicit() 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()
for idx, row in enumerate(self.all_cached_records): if match_en and match_es and match_ctx and match_tags:
self.table.insertRow(idx) 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
item_id = QTableWidgetItem(str(row["translation_id"])) def _async_edge_speech_worker(self, text, voice, rate_modifier):
item_en = QTableWidgetItem(row["en_text"]) """Background thread worker to download neural audio and play it without freezing the UI."""
item_es = QTableWidgetItem(row["es_text"]) async def stream_audio():
item_ctx = QTableWidgetItem(row["source_context"] or "") temp_file = os.path.join(tempfile.gettempdir(), "sandbox_audition.mp3")
item_tag = QTableWidgetItem(row["tags"] or "") try:
item_nts = QTableWidgetItem(row["notes"] or "") 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}")
item_id.setTextAlignment(Qt.AlignmentFlag.AlignCenter) asyncio.run(stream_audio())
self.table.setItem(idx, 0, item_id) @pyqtSlot()
self.table.setItem(idx, 1, item_en) def audition_english(self):
self.table.setItem(idx, 2, item_es) txt = self.txt_english.text().strip()
self.table.setItem(idx, 3, item_ctx) if not txt:
self.table.setItem(idx, 4, item_tag) return
self.table.setItem(idx, 5, item_nts)
self.table.blockSignals(False) 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"
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()