Change of anki template to get formating in anki to work
This commit is contained in:
parent
aa614391b8
commit
23b4228b58
4 changed files with 427 additions and 379 deletions
136
anki_exporter.py
136
anki_exporter.py
|
|
@ -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)
|
||||||
69
database.py
69
database.py
|
|
@ -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.
|
|
@ -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_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."""
|
|
||||||
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 = None
|
||||||
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_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)
|
|
||||||
|
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):
|
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()
|
||||||
Loading…
Reference in a new issue