374 lines
No EOL
16 KiB
Python
374 lines
No EOL
16 KiB
Python
# 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, 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):
|
|
data_mutated = pyqtSignal()
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.selected_translation_id = None
|
|
|
|
# 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)
|
|
|
|
# --- SECTION 1: FORM INPUT CRADLE ---
|
|
form_container = QWidget()
|
|
form_layout = QFormLayout(form_container)
|
|
form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
|
form_layout.setSpacing(10)
|
|
|
|
self.txt_english = QLineEdit()
|
|
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.apply_live_grid_filter)
|
|
|
|
self.txt_spanish = QLineEdit()
|
|
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.apply_live_grid_filter)
|
|
|
|
self.txt_context = QLineEdit()
|
|
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.apply_live_grid_filter)
|
|
|
|
self.txt_tags = QLineEdit()
|
|
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.apply_live_grid_filter)
|
|
|
|
# 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)
|
|
|
|
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>Extended Data Fields:</b>"), editor_buttons_layout)
|
|
|
|
main_layout.addWidget(form_container)
|
|
|
|
# --- INLINE AUDIO CONTROL + GENDER SELECTION PANEL ---
|
|
audio_panel = QHBoxLayout()
|
|
audio_panel.setSpacing(15)
|
|
|
|
self.btn_test_en = QPushButton("🔊 Test English Voice")
|
|
self.btn_test_en.clicked.connect(self.audition_english)
|
|
|
|
self.btn_test_es = QPushButton("🔊 Test Spanish Voice")
|
|
self.btn_test_es.clicked.connect(self.audition_spanish)
|
|
|
|
gender_label = QLabel("<b>Speaker Gender:</b>")
|
|
self.rb_female = QRadioButton("Female")
|
|
self.rb_male = QRadioButton("Male")
|
|
self.rb_female.setChecked(True)
|
|
|
|
self.gender_group = QButtonGroup(self)
|
|
self.gender_group.addButton(self.rb_female)
|
|
self.gender_group.addButton(self.rb_male)
|
|
|
|
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()
|
|
|
|
main_layout.addLayout(audio_panel)
|
|
|
|
# --- 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_form = QPushButton("🧹 Reset Fields")
|
|
self.btn_clear_form.clicked.connect(self.clear_form_fields)
|
|
|
|
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;")
|
|
|
|
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)
|
|
|
|
# --- VIEWPORT GRID TABLE ---
|
|
self.table = QTableWidget()
|
|
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)
|
|
|
|
header = self.table.horizontalHeader()
|
|
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
|
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
|
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
|
|
|
main_layout.addWidget(self.table)
|
|
self.reload_table_display()
|
|
|
|
@pyqtSlot()
|
|
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 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}")
|
|
|
|
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.selected_translation_id = None
|
|
self.txt_english.clear()
|
|
self.txt_spanish.clear()
|
|
self.txt_context.clear()
|
|
self.txt_tags.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_spanish.blockSignals(False)
|
|
self.txt_context.blockSignals(False)
|
|
self.txt_tags.blockSignals(False)
|
|
|
|
def reload_table_display(self):
|
|
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"
|
|
|
|
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() |