# tabs/review_tab.py import random import os import re import subprocess import tempfile import threading import asyncio from datetime import datetime from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QTableWidget, QTableWidgetItem, QHeaderView, QFormLayout, QMessageBox, QFrame, QStackedWidget ) from PyQt6.QtCore import Qt, pyqtSlot import edge_tts import database import anki_exporter from tts_utils import parse_text_for_edgetts, get_configured_tts_rate 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("Source Context:"), self.txt_review_context) top_layout.addRow(QLabel("Tags:"), 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() def _async_edge_speech_worker(self, text, voice, rate_modifier): """Background thread worker to render neural speech with terminal debug logging.""" async def stream_audio(): temp_file = os.path.join(tempfile.gettempdir(), "review_card_audio.mp3") # Clean up old file if present if os.path.exists(temp_file): try: os.remove(temp_file) except Exception: pass try: print(f"[TTS Debug] Generating TTS -> Voice: {voice} | Rate: {rate_modifier} | Text: '{text}'") communicate = edge_tts.Communicate(text=text, voice=voice, rate=rate_modifier) await communicate.save(temp_file) if os.path.exists(temp_file) and os.path.getsize(temp_file) > 0: print(f"[TTS Debug] Audio ready ({os.path.getsize(temp_file)} bytes). Playing via afplay...") result = subprocess.run(["afplay", temp_file], capture_output=True, text=True) if result.returncode != 0: print(f"[TTS Debug] afplay failed: {result.stderr}") else: print("[TTS Debug] Playback finished successfully.") else: print("[TTS Debug] Error: Audio file was not created or is 0 bytes.") except Exception as e: print(f"[TTS Debug] Exception during speech synthesis: {e}") # Explicitly set up and run a clean event loop for this thread try: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) loop.run_until_complete(stream_audio()) loop.close() except Exception as e: print(f"[TTS Debug] Event loop error: {e}") @pyqtSlot() def play_card_audio(self): """Auditions neural edge-tts voice based on active flashcard side.""" if not (0 <= self.current_index < len(self.filtered_review_pool)): return record = self.filtered_review_pool[self.current_index] settings = database.load_all_settings() or {} rate_string = get_configured_tts_rate(settings) if not self.is_flipped: raw_text = record.get("en_text", "") spoken_text = parse_text_for_edgetts(raw_text) voice = "en-US-EmmaNeural" else: raw_text = record.get("es_text", "") spoken_text = parse_text_for_edgetts(raw_text) is_male = (record.get("gender") == "Male") voice = "es-ES-AlvaroNeural" if is_male else "es-ES-ElviraNeural" # Respect flags or empty entries if not spoken_text.strip(): print("[TTS Debug] Skipped: Parsed text is empty or muted via sound-off tag.") return threading.Thread( target=self._async_edge_speech_worker, args=(spoken_text, voice, rate_string), daemon=True ).start() @pyqtSlot() def generate_deck_action(self): """Generates a specialized .apkg Anki deck matching active filter parameters, prepending yyyy-mm-dd-hhmm timestamp and matching Sub-Deck-Namespace-Hierarchy without brackets/parentheses.""" 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 database configurations settings = database.load_all_settings() or {} # Extract configurations targeting exact database schema names found in settings target_dir = settings.get("anki_export_directory") 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()) if sub_deck_hierarchy and str(sub_deck_hierarchy).strip(): deck_tree_parts.append(str(sub_deck_hierarchy).strip()) else: if not deck_tree_parts: deck_tree_parts.append("DefaultDeck") # Join parts using Anki double-colon syntax (::) for internal Anki hierarchy full_deck_namespace = "::".join(deck_tree_parts) # --- Format Timestamp and Clean Filename --- # Format: YYYY-MM-DD-HHMM timestamp = datetime.now().strftime("%Y-%m-%d-%H%M") # Explicitly strip out parentheses/brackets before regex normalization clean_namespace = full_deck_namespace.replace('(', '').replace(')', '').replace('[', '').replace(']', '') clean_namespace = re.sub(r'[^a-zA-Z0-9]', '-', clean_namespace) clean_namespace = re.sub(r'-+', '-', clean_namespace).strip('-') # Complete output filename pattern: yyyy-mm-dd-hhmm-Sub-Deck-Namespace-Hierarchy.apkg filename = f"{timestamp}-{clean_namespace}.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!\n\n" f"Deck Hierarchy: {full_deck_namespace}\n" f"File Name: {filename}\n" f"Path: {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." )