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
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': '<div style="font-family: Arial; font-size: 20px; text-align: center; color: #34495E;">'
'Translate to Spanish:<br><br><b>{{EnglishText}}</b></div><div style="display:none;">{{EnglishAudio}}</div>',
'name': 'Card 1: English ➔ Spanish',
'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>'
'<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">'
'<div style="font-family: Arial; font-size: 28px; text-align: center; color: #2980B9; font-weight: bold;">'
'{{SpanishText}}</div><br>'
'<div style="font-family: Arial; font-size: 14px; text-align: center; color: #7F8C8D; font-style: italic;">'
'{{Notes}}</div><br>'
'<div style="font-family: Arial; font-size: 28px; text-align: center; color: #2980B9; font-weight: bold;">{{SpanishText}}</div><br>'
'<div style="font-family: Arial; font-size: 14px; text-align: center; color: #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;">{{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)
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"<div style='margin-bottom: 4px;'>{record['notes'].strip()}</div>")
if record.get('source_context') and record['source_context'].strip():
notes_html_parts.append(f"<div style='font-size: 12px; color: #95A5A6; font-style: italic;'>Context: {record['source_context'].strip()}</div>")
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
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)

View file

@ -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()
conn.close()

Binary file not shown.

View file

@ -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("<b>English Text:</b>"), self.txt_english)
form_layout.addRow(QLabel("<b>Spanish Text:</b>"), 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("<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>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)
# --- 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("<b>Speaker Gender:</b>")
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)
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()