Compare commits

..

15 commits
main ... v32.0

19 changed files with 1412 additions and 2192 deletions

132
anki_exporter.py Normal file
View file

@ -0,0 +1,132 @@
# anki_exporter.py
import os
import tempfile
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 a bidirectional card payload package.
Resolves voice models dynamically by gender selection parameters and applies
global speed coefficient rates from the active configurations.
"""
# Incremented IDs to force a fresh schema mapping without legacy 'Notes' fields
model_id = 1684329060
deck_id = 1684329060
# 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%"
anki_model = genanki.Model(
model_id,
'Spanish Bidirectional Multi-Note HTML Model',
fields=[
{'name': 'EnglishText'},
{'name': 'SpanishText'},
{'name': 'AnkiNotes'},
{'name': 'EnglishAudio'},
{'name': 'SpanishAudio'}
],
templates=[
{
'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>'
'{{#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>'
'{{#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_to_pack = []
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
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")
# 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()]
# Safely isolate the raw text/HTML data string down to Anki notes field
anki_notes_html = record['anki_notes'].strip() if record.get('anki_notes') else ""
# 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)
# 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}]"
else:
en_audio_field = ""
# 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}]"
else:
es_audio_field = ""
# Clean sequential matching fields array matching schema mapping above
note = genanki.Note(
model=anki_model,
fields=[en_text, es_text, anki_notes_html, en_audio_field, es_audio_field],
tags=note_tags
)
deck.add_note(note)
# Build Package while media assets are guaranteed contextually active inside tmpdir
package = genanki.Package(deck)
package.media_files = media_files_to_pack
package.write_to_file(output_path)

194
database.py Normal file
View file

@ -0,0 +1,194 @@
# database.py
import sqlite3
import os
DB_NAME = "spanish_trainer.db"
def get_connection():
"""Returns a connection to the SQLite database with row factory enabled."""
conn = sqlite3.connect(DB_NAME)
conn.row_factory = sqlite3.Row
return conn
def ensure_database_populated():
"""
Creates empty tables using the unified schema if running
in a fresh environment without a database file.
"""
conn = get_connection()
cursor = conn.cursor()
try:
# 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,
es_text TEXT NOT NULL,
en_text TEXT NOT NULL,
source_context TEXT,
tags TEXT,
notes TEXT,
anki_notes TEXT DEFAULT '',
gender TEXT DEFAULT 'Female'
);
""")
# 2. Key-value configuration table
cursor.execute("""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);
""")
conn.commit()
finally:
conn.close()
# ==========================================
# SETTINGS CRUD FUNCTIONS
# ==========================================
def load_all_settings():
"""Fetches all system configuration properties into a flat Python dictionary."""
conn = get_connection()
cursor = conn.cursor()
settings_dict = {}
try:
cursor.execute("SELECT settings.key, settings.value FROM settings;")
for row in cursor.fetchall():
settings_dict[row["key"]] = row["value"]
finally:
conn.close()
return settings_dict
def save_setting_to_db(key, value):
"""Inserts or replaces an application configuration entry."""
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute("""
INSERT OR REPLACE INTO settings (key, value)
VALUES (?, ?);
""", (key, value))
conn.commit()
finally:
conn.close()
# ==========================================
# TRANSLATIONS CRUD FUNCTIONS
# ==========================================
def get_all_translations_explicit():
"""
Retrieves all records using completely explicit,
table-qualified column declarations for the engines.
"""
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute("""
SELECT
translations.translation_id,
translations.es_text,
translations.en_text,
translations.source_context,
translations.tags,
translations.notes,
translations.anki_notes,
translations.gender
FROM translations
ORDER BY translations.translation_id ASC;
""")
return [dict(row) for row in cursor.fetchall()]
finally:
conn.close()
def get_translation_by_id(translation_id):
"""Loads a single unified record row for specific inspection or editing."""
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute("""
SELECT
translations.translation_id,
translations.es_text,
translations.en_text,
translations.source_context,
translations.tags,
translations.notes,
translations.anki_notes,
translations.gender
FROM translations
WHERE translations.translation_id = ?;
""", (translation_id,))
row = cursor.fetchone()
return dict(row) if row else None
finally:
conn.close()
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 = :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()
def delete_translation_record(translation_id):
"""Permanently drops a phrase card row from the data index."""
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute("""
DELETE FROM translations
WHERE translations.translation_id = ?;
""", (translation_id,))
conn.commit()
finally:
conn.close()
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, 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()

File diff suppressed because it is too large Load diff

Binary file not shown.

BIN
doc/images/image-02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
doc/images/image-03.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

1159
main.py

File diff suppressed because it is too large Load diff

114
migrate_database.py Normal file
View file

@ -0,0 +1,114 @@
# migrate_database.py
import sqlite3
import os
OLD_DB = "spanish_trainer_legacy.db" # Your existing database renamed
NEW_DB = "spanish_trainer.db" # The fresh, simplified target database
def migrate():
if not os.path.exists(OLD_DB):
print(f"❌ Error: Could not find legacy database file named '{OLD_DB}'")
print("Please rename your active database file to match before running this script.")
return
print("🚀 Initializing schema transformation...")
# Connect to both databases
conn_old = sqlite3.connect(OLD_DB)
conn_old.row_factory = sqlite3.Row
cursor_old = conn_old.cursor()
conn_new = sqlite3.connect(NEW_DB)
cursor_new = conn_new.cursor()
# 1. Provision the clean, simplified new tables
cursor_new.execute("""
CREATE TABLE IF NOT EXISTS translations (
translation_id INTEGER PRIMARY KEY AUTOINCREMENT,
es_text TEXT NOT NULL,
en_text TEXT NOT NULL,
source_context TEXT,
tags TEXT,
notes TEXT
);
""")
cursor_new.execute("""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);
""")
# 2. Extract and pair data using explicit, table-qualified SQL queries
print("📦 Extracting and consolidating relational text rows...")
migration_query = """
SELECT
t.translation_id,
p1.text AS spanish_phrase,
p2.text AS english_translation,
p1.source_context AS textbook_unit,
t.tags AS metadata_tags,
t.notes AS historical_notes
FROM translations t
JOIN phrases p1 ON t.source_phrase_id = p1.id
JOIN phrases p2 ON t.target_phrase_id = p2.id
WHERE p1.language = 'es'
AND p2.language = 'en'
ORDER BY t.translation_id ASC;
"""
try:
cursor_old.execute(migration_query)
legacy_records = cursor_old.fetchall()
except sqlite3.OperationalError as e:
print(f"❌ Legacy structure lookup failed: {e}")
print("Verify your old table structures match the schema before running.")
conn_old.close()
conn_new.close()
return
# 3. Insert records into the new simplified table structure
inserted_count = 0
for row in legacy_records:
cursor_new.execute("""
INSERT INTO translations (
translation_id,
es_text,
en_text,
source_context,
tags,
notes
) VALUES (?, ?, ?, ?, ?, ?);
""", (
row["translation_id"],
row["spanish_phrase"],
row["english_translation"],
row["textbook_unit"],
row["metadata_tags"],
row["historical_notes"]
))
inserted_count += 1
# 4. Copy existing system configuration keys over safely
try:
cursor_old.execute("SELECT key, value FROM settings;")
settings_records = cursor_old.fetchall()
for setting in settings_records:
cursor_new.execute("""
INSERT OR REPLACE INTO settings (key, value)
VALUES (?, ?);
""", (setting["key"], setting["value"]))
except sqlite3.OperationalError:
print("⚠️ Warning: No legacy settings table found or could not read it. Skipping settings copy.")
# Commit changes and clean up connections
conn_new.commit()
conn_old.close()
conn_new.close()
print(f"✨ Migration complete! Successfully converted {inserted_count} text rows.")
print(f"💾 Fresh database engine ready at: {NEW_DB}")
if __name__ == "__main__":
migrate()

Binary file not shown.

BIN
spanish_trainer_legacy.db Normal file

Binary file not shown.

5
tabs/__init__.py Normal file
View file

@ -0,0 +1,5 @@
# tabs/__init__.py
# Leave this file empty, or just expose the tabs like this:
from .sandbox_tab import SandboxTab
from .review_tab import ReviewTab
from .settings_tab import SettingsTab

348
tabs/review_tab.py Normal file
View file

@ -0,0 +1,348 @@
# tabs/review_tab.py
import random
import os
import subprocess
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QLineEdit, QPushButton, QTableWidget, QTableWidgetItem,
QHeaderView, QFormLayout, QMessageBox, QFrame, QStackedWidget
)
from PyQt6.QtCore import Qt, pyqtSlot
import database
import anki_exporter
class ReviewTab(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
# Core Review State Tracking
self.all_cached_records = []
self.filtered_review_pool = []
self.current_index = -1
self.is_flipped = False # Track front vs back state of the active flashcard
# Primary Main Layout
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(30, 20, 30, 20)
main_layout.setSpacing(15)
# --- SECTION 1: TOP REGION (Source Context & Tags Filters) ---
top_container = QWidget()
top_layout = QFormLayout(top_container)
top_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
top_layout.setSpacing(10)
self.txt_review_context = QLineEdit()
self.txt_review_context.setPlaceholderText("Filter deck by context (e.g., Camino 2027)...")
self.txt_review_context.setStyleSheet("padding: 6px; font-size: 14px;")
self.txt_review_context.textChanged.connect(self.handle_live_filter)
self.txt_review_tags = QLineEdit()
self.txt_review_tags.setPlaceholderText("Filter deck by tags (e.g., verb, greeting)...")
self.txt_review_tags.setStyleSheet("padding: 6px; font-size: 14px;")
self.txt_review_tags.textChanged.connect(self.handle_live_filter)
top_layout.addRow(QLabel("<b>Source Context:</b>"), self.txt_review_context)
top_layout.addRow(QLabel("<b>Tags:</b>"), self.txt_review_tags)
main_layout.addWidget(top_container)
# --- SECTION 2: MIDDLE REGION (Split Screen Workspace) ---
split_layout = QHBoxLayout()
split_layout.setSpacing(20)
# Left Half: Live Translation Grid View Table
self.table = QTableWidget()
self.table.setColumnCount(2)
self.table.setHorizontalHeaderLabels(["English Phrase", "Spanish Translation"])
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
split_layout.addWidget(self.table, stretch=1)
# Right Half: Live Interactive Flashcard Review Panel container
card_container = QWidget()
card_vbox = QVBoxLayout(card_container)
card_vbox.setContentsMargins(0, 0, 0, 0)
card_vbox.setSpacing(12)
# The Card Visual Canvas Frame
self.card_frame = QFrame()
self.card_frame.setStyleSheet("""
QFrame {
background-color: #FAFAFA;
border: 2px solid #E5E7E9;
border-radius: 8px;
}
""")
card_frame_layout = QVBoxLayout(self.card_frame)
card_frame_layout.setContentsMargins(25, 25, 25, 25)
self.card_stack = QStackedWidget()
# Card Front View (English Prompt)
self.view_front = QWidget()
front_layout = QVBoxLayout(self.view_front)
front_prompt = QLabel("TRANSLATE TO SPANISH:")
front_prompt.setStyleSheet("font-size: 11px; font-weight: bold; color: #BDC3C7; letter-spacing: 1px;")
front_prompt.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.lbl_english = QLabel("No cards matching active filters.")
self.lbl_english.setStyleSheet("font-size: 20px; color: #34495E; font-weight: 500; margin-top: 15px;")
self.lbl_english.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.lbl_english.setWordWrap(True)
front_layout.addWidget(front_prompt)
front_layout.addWidget(self.lbl_english)
front_layout.addStretch()
# Card Back View (Spanish Answer Only)
self.view_back = QWidget()
back_layout = QVBoxLayout(self.view_back)
self.lbl_spanish = QLabel("Spanish Answer Text")
self.lbl_spanish.setStyleSheet("font-size: 24px; font-weight: bold; color: #2980B9; margin-top: 20px;")
self.lbl_spanish.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.lbl_spanish.setWordWrap(True)
back_layout.addWidget(self.lbl_spanish)
back_layout.addStretch()
self.card_stack.addWidget(self.view_front)
self.card_stack.addWidget(self.view_back)
card_frame_layout.addWidget(self.card_stack)
card_vbox.addWidget(self.card_frame, stretch=1)
# Buttons Row beneath the Flashcard
card_buttons_layout = QHBoxLayout()
card_buttons_layout.setSpacing(10)
self.btn_play_audio = QPushButton("🔊 Play Voice")
self.btn_play_audio.setCursor(Qt.CursorShape.PointingHandCursor)
self.btn_play_audio.setStyleSheet("""
QPushButton { background-color: #E67E22; color: white; font-weight: bold; font-size: 13px; padding: 10px; border-radius: 4px; }
QPushButton:hover { background-color: #D35400; }
""")
self.btn_play_audio.clicked.connect(self.play_card_audio)
self.btn_flip_next = QPushButton("Flip Card")
self.btn_flip_next.setCursor(Qt.CursorShape.PointingHandCursor)
self.btn_flip_next.setStyleSheet("""
QPushButton { background-color: #34495E; color: white; font-weight: bold; font-size: 13px; padding: 10px; border-radius: 4px; }
QPushButton:hover { background-color: #2C3E50; }
""")
self.btn_flip_next.clicked.connect(self.handle_card_interaction)
card_buttons_layout.addWidget(self.btn_play_audio, stretch=1)
card_buttons_layout.addWidget(self.btn_flip_next, stretch=2)
card_vbox.addLayout(card_buttons_layout)
split_layout.addWidget(card_container, stretch=1)
main_layout.addLayout(split_layout)
# --- SECTION 3: BOTTOM REGION (Action Control Panel) ---
bottom_layout = QHBoxLayout()
bottom_layout.setSpacing(15)
self.btn_generate_deck = QPushButton("🗂️ Generate Deck")
self.btn_generate_deck.setCursor(Qt.CursorShape.PointingHandCursor)
self.btn_generate_deck.setStyleSheet("""
QPushButton { background-color: #27AE60; color: white; font-weight: bold; font-size: 14px; padding: 10px 22px; border-radius: 5px; }
QPushButton:hover { background-color: #219653; }
""")
self.btn_generate_deck.clicked.connect(self.generate_deck_action)
self.btn_generate_video = QPushButton("🎬 Generate Video")
self.btn_generate_video.setCursor(Qt.CursorShape.PointingHandCursor)
self.btn_generate_video.setStyleSheet("""
QPushButton { background-color: #2980B9; color: white; font-weight: bold; font-size: 14px; padding: 10px 22px; border-radius: 5px; }
QPushButton:hover { background-color: #1F618D; }
""")
self.btn_generate_video.clicked.connect(self.generate_video_action)
bottom_layout.addWidget(self.btn_generate_deck)
bottom_layout.addWidget(self.btn_generate_video)
bottom_layout.addStretch()
main_layout.addLayout(bottom_layout)
# Populate initial states from backend
self.reload_review_pool()
@pyqtSlot()
def reload_review_pool(self):
"""Fetches consolidated text entries from the database and initializes cache."""
try:
self.all_cached_records = database.get_all_translations_explicit()
self.handle_live_filter()
except Exception as e:
print(f"Error initializing flashcard review workspace: {e}")
@pyqtSlot()
def handle_live_filter(self):
"""Filters grid contents and generates a randomized matching queue for the card engine."""
self.table.blockSignals(True)
self.table.setRowCount(0)
filter_ctx = self.txt_review_context.text().lower().strip()
filter_tag = self.txt_review_tags.text().lower().strip()
self.filtered_review_pool = []
visible_row_index = 0
for row in self.all_cached_records:
val_ctx = (row["source_context"] or "").lower()
val_tag = (row["tags"] or "").lower()
if (filter_ctx in val_ctx) and (filter_tag in val_tag):
self.filtered_review_pool.append(row)
self.table.insertRow(visible_row_index)
self.table.setItem(visible_row_index, 0, QTableWidgetItem(row["en_text"]))
self.table.setItem(visible_row_index, 1, QTableWidgetItem(row["es_text"]))
visible_row_index += 1
self.table.blockSignals(False)
# Reshuffle the active localized queue stack and reset card state tracking pointer
random.shuffle(self.filtered_review_pool)
self.current_index = 0 if self.filtered_review_pool else -1
self.is_flipped = False
self.display_current_card()
def display_current_card(self):
"""Pushes current pool row data configurations to layout containers."""
if not (0 <= self.current_index < len(self.filtered_review_pool)):
self.lbl_english.setText("No phrases match current active criteria filters.")
self.lbl_spanish.setText("")
self.card_stack.setCurrentIndex(0)
self.btn_flip_next.setText("Flip Card")
self.btn_flip_next.setEnabled(False)
self.btn_play_audio.setEnabled(False)
return
self.btn_flip_next.setEnabled(True)
self.btn_play_audio.setEnabled(True)
record = self.filtered_review_pool[self.current_index]
# Setup front and back text labels
self.lbl_english.setText(record["en_text"])
self.lbl_spanish.setText(record["es_text"])
# Sync visual widget indexing configurations
if not self.is_flipped:
self.card_stack.setCurrentIndex(0)
self.btn_flip_next.setText("Flip Card")
else:
self.card_stack.setCurrentIndex(1)
self.btn_flip_next.setText("Next Card ➔")
@pyqtSlot()
def handle_card_interaction(self):
"""State machine cycling through card flipped values or increments indices sequential steps."""
if not self.filtered_review_pool:
return
if not self.is_flipped:
# Transition State: Front -> Back
self.is_flipped = True
self.display_current_card()
else:
# Transition State: Advance to next index item row
self.current_index += 1
if self.current_index >= len(self.filtered_review_pool):
self.current_index = 0
random.shuffle(self.filtered_review_pool) # Rescramble on completion pass loops
self.is_flipped = False
self.display_current_card()
@pyqtSlot()
def play_card_audio(self):
"""Auditions native voice files based on active visual canvas sides."""
if not (0 <= self.current_index < len(self.filtered_review_pool)):
return
record = self.filtered_review_pool[self.current_index]
if not self.is_flipped:
# Play English text utilizing standard native subsystem default output
if record["en_text"]:
subprocess.Popen(["say", record["en_text"]])
else:
# Play target translation explicitly targeting the Castilian voice profile Mónica
if record["es_text"]:
subprocess.Popen(["say", "-v", "Monica", record["es_text"]])
@pyqtSlot()
def generate_deck_action(self):
"""Generates a specialized lightweight .apkg Anki deck matching active filter parameters,
respecting exact user database configuration keys for target folders and naming chains."""
if not self.filtered_review_pool:
QMessageBox.warning(self, "Export Aborted", "The current matching review deck queue is empty. Cannot compile an empty deck.")
return
try:
# Load active settings dictionary directly from your database configurations
settings = database.load_all_settings()
# print("--- CURRENT DATABASE SETTINGS ---")
# for key, value in settings.items():
# print(f"{key}: {value}")
# print("---------------------------------")
# Extract configurations targeting exact database schema names found in settings
target_dir = settings.get("anki_export_directory")
#root_deck_name = settings.get("default_deck_name")
root_deck_name = settings.get("anki_root_deck_name")
sub_deck_hierarchy = settings.get("anki_sub_deck_name")
# Fallback handling to verify directories exist safely
if not target_dir or not os.path.isdir(str(target_dir)):
target_dir = os.path.expanduser("~/Desktop")
else:
target_dir = str(target_dir)
# --- Compile Full Namespace Tree Path ---
deck_tree_parts = []
if root_deck_name and str(root_deck_name).strip():
deck_tree_parts.append(str(root_deck_name).strip())
else:
deck_tree_parts.append("DefaultDeck") # Baseline structural root name fallback
if sub_deck_hierarchy and str(sub_deck_hierarchy).strip():
deck_tree_parts.append(str(sub_deck_hierarchy).strip())
## deck_tree_parts.append("Filtered Review Session")
# Join parts using Anki double-colon syntax (::)
full_deck_namespace = "::".join(deck_tree_parts)
# Establish absolute output filename file path anchor
filename = "Spanish_Filtered_Review.apkg"
file_path = os.path.join(target_dir, filename)
# Execute actual compilation algorithm pipeline mapping filtered records cleanly
anki_exporter.compile_anki_package(self.filtered_review_pool, file_path, full_deck_namespace)
QMessageBox.information(
self,
"Export Complete",
f"Successfully exported Anki package to your configured target directory!\n\n"
f"<b>Full Namespace Tree:</b> {full_deck_namespace}\n"
f"<b>Destination Path:</b> {file_path}"
)
except Exception as e:
QMessageBox.critical(self, "Compiler Fault Safeguard", f"An exception occurred building your deck container package:\n{str(e)}")
@pyqtSlot()
def generate_video_action(self):
"""Placeholder function execution trigger for media compiler production automation."""
QMessageBox.information(
self,
"Media Generator Active",
f"Initiating background video asset production using the {len(self.filtered_review_pool)} visible phrases."
)

374
tabs/sandbox_tab.py Normal file
View file

@ -0,0 +1,374 @@
# 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()

145
tabs/settings_tab.py Normal file
View file

@ -0,0 +1,145 @@
# tabs/settings_tab.py
import os
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QFileDialog, QGroupBox, QFormLayout,
QMessageBox, QFrame
)
from PyQt6.QtCore import pyqtSignal, pyqtSlot
import database
class SettingsTab(QWidget):
# Signals to communicate up to the centralized main.py loop coordinator
settings_changed = pyqtSignal(str, str) # Emits: (key, value)
export_anki_requested = pyqtSignal(str) # Emits: (full_deck_name)
generate_video_requested = pyqtSignal() # Emits: trigger
def __init__(self, parent=None):
super().__init__(parent)
main_layout = QVBoxLayout(self)
main_layout.setSpacing(15)
# --- SECTION 1: GLOBAL ANKI PACKAGING CONFIGURATIONS ---
anki_group = QGroupBox("Anki Compilation Settings")
anki_form = QFormLayout(anki_group)
anki_form.setSpacing(10)
self.txt_root_deck = QLineEdit()
self.txt_root_deck.setPlaceholderText("e.g., Spanish::CAE_Course")
self.txt_root_deck.textChanged.connect(lambda text: self.update_setting("anki_root_deck_name", text.strip()))
self.txt_sub_deck = QLineEdit()
self.txt_sub_deck.setPlaceholderText("e.g., Vocabulary::Unit_1")
self.txt_sub_deck.textChanged.connect(lambda text: self.update_setting("anki_sub_deck_name", text.strip()))
# Export Destination Directory Picker
dir_picker_layout = QHBoxLayout()
self.txt_export_dir = QLineEdit()
self.txt_export_dir.setReadOnly(True)
self.txt_export_dir.setStyleSheet("background-color: #F8F9F9; color: #34495E;")
btn_browse = QPushButton("Browse...")
btn_browse.clicked.connect(self.browse_export_directory)
dir_picker_layout.addWidget(self.txt_export_dir)
dir_picker_layout.addWidget(btn_browse)
anki_form.addRow("Root Deck Name:", self.txt_root_deck)
anki_form.addRow("Sub-Deck Namespace Hierarchy:", self.txt_sub_deck)
anki_form.addRow("Export Target Directory:", dir_picker_layout)
main_layout.addWidget(anki_group)
# --- SECTION 2: AUDIO ENGINE & COMPILATION OVERRIDES ---
engine_group = QGroupBox("Voice Synthesis & Training Configuration")
engine_form = QFormLayout(engine_group)
self.txt_tts_voice = QLineEdit()
self.txt_tts_voice.setPlaceholderText("Apple_Monica")
self.txt_tts_voice.textChanged.connect(lambda text: self.update_setting("tts_preferred_voice", text.strip()))
self.txt_tts_speed = QLineEdit()
self.txt_tts_speed.setPlaceholderText("1.15")
self.txt_tts_speed.textChanged.connect(lambda text: self.update_setting("tts_playback_speed", text.strip()))
engine_form.addRow("Fallback System Voice Name:", self.txt_tts_voice)
engine_form.addRow("Target Speech Playback Multiplier:", self.txt_tts_speed)
main_layout.addWidget(engine_group)
# Decorative divider line
divider = QFrame()
divider.setFrameShape(QFrame.Shape.HLine)
divider.setFrameShadow(QFrame.Shadow.Sunken)
main_layout.addWidget(divider)
# --- SECTION 3: SYSTEM ACTION EXECUTION BAR ---
actions_group = QGroupBox("Execution Pipelines")
actions_layout = QHBoxLayout(actions_group)
actions_layout.setSpacing(20)
self.btn_export_anki = QPushButton("🚀 Compile Lightweight Anki APKG")
self.btn_export_anki.setStyleSheet("""
QPushButton { background-color: #2980B9; color: white; font-weight: bold; font-size: 13px; padding: 10px; border-radius: 4px; }
QPushButton:hover { background-color: #3498DB; }
""")
self.btn_export_anki.clicked.connect(self.dispatch_anki_export)
self.btn_gen_video = QPushButton("🎬 Generate MP4 Loop Playlists")
self.btn_gen_video.setStyleSheet("""
QPushButton { background-color: #8E44AD; color: white; font-weight: bold; font-size: 13px; padding: 10px; border-radius: 4px; }
QPushButton:hover { background-color: #9B59B6; }
""")
self.btn_gen_video.clicked.connect(self.generate_video_requested.emit)
actions_layout.addWidget(self.btn_export_anki)
actions_layout.addWidget(self.btn_gen_video)
main_layout.addWidget(actions_group)
main_layout.addStretch() # Push everything neatly to the top
# Load settings from database onto inputs on initialization
self.populate_fields_from_db_state()
def populate_fields_from_db_state(self):
"""Fetches stored parameters on view load initialization."""
# Block signals briefly so loading state doesn't trigger write-back loops
self.blockSignals(True)
stored_settings = database.load_all_settings()
self.txt_root_deck.setText(stored_settings.get("anki_root_deck_name", "Spanish"))
self.txt_sub_deck.setText(stored_settings.get("anki_sub_deck_name", ""))
self.txt_export_dir.setText(stored_settings.get("anki_export_directory", os.path.expanduser("~")))
self.txt_tts_voice.setText(stored_settings.get("tts_preferred_voice", "Apple_Monica"))
self.txt_tts_speed.setText(stored_settings.get("tts_playback_speed", "1.15"))
self.blockSignals(False)
def update_setting(self, key, value):
"""Internal helper to communicate state mutations instantly upward."""
self.settings_changed.emit(key, value)
def browse_export_directory(self):
"""Invokes a native macOS directory finder path browser window."""
current_dir = self.txt_export_dir.text() or os.path.expanduser("~")
selected_directory = QFileDialog.getExistingDirectory(
self, "Select Anki Export Target Location", current_dir
)
if selected_directory:
self.txt_export_dir.setText(selected_directory)
self.update_setting("anki_export_directory", selected_directory)
def dispatch_anki_export(self):
"""Constructs and validates the structured deck names namespace before signaling main.py."""
root = self.txt_root_deck.text().strip()
sub = self.txt_sub_deck.text().strip()
if not root:
QMessageBox.warning(self, "Invalid Parameters", "A root deck namespace destination must be provided.")
return
# Combine hierarchy into standard Anki format: 'Root::SubDeck'
full_deck_name = f"{root}::{sub}" if sub else root
self.export_anki_requested.emit(full_deck_name)

0
video_generator.py Normal file
View file