Spaces:
Running
Running
| """ | |
| Retranslation GUI Module | |
| Force retranslation functionality for EPUB, text, and image files | |
| """ | |
| import os | |
| import sys | |
| import json | |
| import re | |
| from PySide6.QtWidgets import (QWidget, QDialog, QLabel, QFrame, QListWidget, | |
| QPushButton, QVBoxLayout, QHBoxLayout, QGridLayout, | |
| QMessageBox, QFileDialog, QTabWidget, QListWidgetItem, | |
| QScrollArea, QSizePolicy, QMenu) | |
| from PySide6.QtCore import Qt, Signal, QTimer, QPropertyAnimation, QEasingCurve, Property, QEventLoop, QUrl | |
| from PySide6.QtGui import QFont, QColor, QTransform, QIcon, QPixmap, QDesktopServices | |
| import xml.etree.ElementTree as ET | |
| import zipfile | |
| import shutil | |
| import traceback | |
| import subprocess | |
| # WindowManager and UIHelper removed - not needed in PySide6 | |
| # Qt handles window management and UI utilities automatically | |
| class AnimatedRefreshButton(QPushButton): | |
| """Custom QPushButton with rotation animation for refresh action using Halgakos.ico""" | |
| def __init__(self, text="Refresh", parent=None): | |
| super().__init__(text, parent) | |
| self._rotation = 0 | |
| self._animation = None | |
| self._original_text = text | |
| self._timer = None | |
| self._animation_step = 0 | |
| self._original_icon = None | |
| # Try to load Halgakos.ico | |
| try: | |
| # Get base directory | |
| base_dir = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) | |
| ico_path = os.path.join(base_dir, 'Halgakos.ico') | |
| if os.path.isfile(ico_path): | |
| self._original_icon = QIcon(ico_path) | |
| self.setIcon(self._original_icon) | |
| self.setIconSize(self.iconSize() * 1.2) # Make icon slightly larger | |
| except Exception as e: | |
| print(f"Could not load Halgakos.ico for refresh button: {e}") | |
| def get_rotation(self): | |
| return self._rotation | |
| def set_rotation(self, angle): | |
| self._rotation = angle | |
| self.update() # Trigger repaint | |
| # Define rotation as a Qt Property for animation | |
| rotation = Property(float, get_rotation, set_rotation) | |
| def start_animation(self): | |
| """Start the spinning animation""" | |
| if self._timer and self._timer.isActive(): | |
| return # Already animating | |
| # Update stylesheet to show active state | |
| current_style = self.styleSheet() | |
| if "background-color: #17a2b8" in current_style: | |
| self.setStyleSheet(current_style.replace( | |
| "background-color: #17a2b8", | |
| "background-color: #138496" | |
| )) | |
| # Start timer-based animation for icon rotation | |
| self._animation_step = 0 | |
| self._timer = QTimer(self) | |
| self._timer.timeout.connect(self._update_animation_frame) | |
| self._timer.start(50) # Update every 50ms for smooth rotation | |
| def _update_animation_frame(self): | |
| """Update animation frame by rotating the icon""" | |
| if self._original_icon: | |
| # Increment rotation angle (30 degrees per frame for smooth spinning) | |
| self._rotation = (self._rotation + 30) % 360 | |
| # Create a rotated version of the icon | |
| pixmap = self._original_icon.pixmap(self.iconSize()) | |
| transform = QTransform().rotate(self._rotation) | |
| rotated_pixmap = pixmap.transformed(transform, Qt.SmoothTransformation) | |
| # Set the rotated icon | |
| self.setIcon(QIcon(rotated_pixmap)) | |
| def stop_animation(self): | |
| """Stop the spinning animation""" | |
| if self._timer: | |
| self._timer.stop() | |
| self._timer = None | |
| self._rotation = 0 | |
| self._animation_step = 0 | |
| # Restore original icon (unrotated) | |
| if self._original_icon: | |
| self.setIcon(self._original_icon) | |
| # Restore original stylesheet | |
| current_style = self.styleSheet() | |
| if "background-color: #138496" in current_style: | |
| self.setStyleSheet(current_style.replace( | |
| "background-color: #138496", | |
| "background-color: #17a2b8" | |
| )) | |
| self.update() | |
| class RetranslationMixin: | |
| """Mixin class containing retranslation methods for TranslatorGUI""" | |
| def _ui_yield(self, ms=5): | |
| """Let the Qt event loop process pending events briefly.""" | |
| try: | |
| if getattr(self, '_suspend_yield', False): | |
| return | |
| from PySide6.QtWidgets import QApplication | |
| QApplication.processEvents(QEventLoop.AllEvents, ms) | |
| except Exception: | |
| pass | |
| def _clear_layout(self, layout): | |
| """Safely clear all items from a layout""" | |
| if layout is None: | |
| return | |
| while layout.count(): | |
| item = layout.takeAt(0) | |
| if item: | |
| widget = item.widget() | |
| if widget: | |
| widget.setParent(None) | |
| widget.deleteLater() | |
| elif item.layout(): | |
| self._clear_layout(item.layout()) | |
| def _get_dialog_size(self, width_ratio=0.5, height_ratio=0.5): | |
| """Calculate dialog size as a ratio of screen size (default 50% width, 50% height)""" | |
| try: | |
| from PySide6.QtWidgets import QApplication | |
| from PySide6.QtGui import QScreen | |
| # Get primary screen | |
| screen = QApplication.primaryScreen() | |
| if screen: | |
| geometry = screen.availableGeometry() | |
| width = int(geometry.width() * width_ratio) | |
| height = int(geometry.height() * height_ratio) | |
| return width, height | |
| except: | |
| pass | |
| # Fallback to reasonable defaults if screen info unavailable | |
| return int(1920 * width_ratio), int(1080 * height_ratio) | |
| def _show_message(self, msg_type, title, message, parent=None): | |
| """Show message using PySide6 QMessageBox with Halgakos icon""" | |
| try: | |
| # Create message box | |
| msg_box = QMessageBox(parent) | |
| msg_box.setWindowTitle(title) | |
| msg_box.setText(message) | |
| # Set icon based on message type | |
| if msg_type == 'info': | |
| msg_box.setIcon(QMessageBox.Information) | |
| elif msg_type == 'warning': | |
| msg_box.setIcon(QMessageBox.Warning) | |
| elif msg_type == 'error': | |
| msg_box.setIcon(QMessageBox.Critical) | |
| elif msg_type == 'question': | |
| msg_box.setIcon(QMessageBox.Question) | |
| msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No) | |
| # Try to set Halgakos window icon | |
| try: | |
| from PySide6.QtGui import QIcon | |
| if hasattr(self, 'base_dir'): | |
| base_dir = self.base_dir | |
| else: | |
| import sys | |
| base_dir = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) | |
| ico_path = os.path.join(base_dir, 'Halgakos.ico') | |
| if os.path.isfile(ico_path): | |
| msg_box.setWindowIcon(QIcon(ico_path)) | |
| except: | |
| pass | |
| # Show message box | |
| if msg_type == 'question': | |
| # Ensure dialog stays on top if it's a critical question | |
| msg_box.setWindowFlags(msg_box.windowFlags() | Qt.WindowStaysOnTopHint) | |
| return msg_box.exec() == QMessageBox.Yes | |
| else: | |
| msg_box.setWindowFlags(msg_box.windowFlags() | Qt.WindowStaysOnTopHint) | |
| msg_box.exec() | |
| return True | |
| except Exception as e: | |
| # Fallback to console if dialog fails | |
| print(f"{title}: {message}") | |
| if msg_type == 'question': | |
| return False | |
| return False | |
| def force_retranslation(self): | |
| """Force retranslation of specific chapters or images with improved display""" | |
| # Check for multiple file selection first | |
| if hasattr(self, 'selected_files') and len(self.selected_files) > 1: | |
| self._force_retranslation_multiple_files() | |
| return | |
| # Check if it's a folder selection (for images) | |
| if hasattr(self, 'selected_files') and len(self.selected_files) > 0: | |
| # Check if the first selected file is actually a folder | |
| first_item = self.selected_files[0] | |
| if os.path.isdir(first_item): | |
| self._force_retranslation_images_folder(first_item) | |
| return | |
| # Original logic for single files | |
| # Get input path from QLineEdit widget | |
| if hasattr(self.entry_epub, 'text'): | |
| # PySide6 QLineEdit widget | |
| input_path = self.entry_epub.text() | |
| else: | |
| input_path = "" | |
| if not input_path or not os.path.isfile(input_path): | |
| self._show_message('error', "Error", "Please select a valid EPUB, text file, or image folder first.") | |
| return | |
| # Check if it's an image file | |
| image_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp') | |
| if input_path.lower().endswith(image_extensions): | |
| # For single image, pass the image file path itself | |
| self._force_retranslation_images_folder(input_path) | |
| return | |
| # Check if dialog already exists for this file and is just hidden | |
| file_key = os.path.abspath(input_path) | |
| if hasattr(self, '_retranslation_dialog_cache') and file_key in self._retranslation_dialog_cache: | |
| # Reuse existing dialog - just show it and refresh data | |
| cached_data = self._retranslation_dialog_cache[file_key] | |
| if cached_data and cached_data.get('dialog'): | |
| # Recompute output directory (override path can change, or cache can be stale) | |
| epub_base = os.path.splitext(os.path.basename(input_path))[0] | |
| override_dir = (os.environ.get('OUTPUT_DIRECTORY') or os.environ.get('OUTPUT_DIR')) | |
| if not override_dir and hasattr(self, 'config'): | |
| try: | |
| override_dir = self.config.get('output_directory') | |
| except Exception: | |
| override_dir = None | |
| expected_output_dir = os.path.join(override_dir, epub_base) if override_dir else epub_base | |
| output_dir = cached_data.get('output_dir') | |
| progress_file = cached_data.get('progress_file') | |
| # If cache points at a different location than current override, force a rebuild. | |
| if output_dir and expected_output_dir and os.path.abspath(output_dir) != os.path.abspath(expected_output_dir): | |
| del self._retranslation_dialog_cache[file_key] | |
| else: | |
| # Check if output folder still exists before trying to refresh | |
| if not output_dir: | |
| output_dir = expected_output_dir | |
| cached_data['output_dir'] = output_dir | |
| cached_data['progress_file'] = os.path.join(output_dir, "translation_progress.json") | |
| progress_file = cached_data['progress_file'] | |
| if not os.path.exists(output_dir): | |
| # Output folder was deleted - show message and remove from cache | |
| self._show_message('info', "Info", "No translation output found for this file.") | |
| del self._retranslation_dialog_cache[file_key] | |
| return | |
| if not progress_file or not os.path.exists(progress_file): | |
| # Progress file was deleted - show message and remove from cache, | |
| # but DO NOT return. Fall through so we rebuild the dialog and | |
| # auto-discover completed chapters in a single click. | |
| self._show_message('info', "Info", "No progress tracking found. Existing translations will be auto-discovered.") | |
| del self._retranslation_dialog_cache[file_key] | |
| else: | |
| dialog = cached_data['dialog'] | |
| # Refresh the data before showing | |
| self._refresh_retranslation_data(cached_data) | |
| dialog.show() | |
| dialog.raise_() | |
| dialog.activateWindow() | |
| return | |
| # For EPUB/text files, use the shared logic | |
| # Get current toggle state if it exists, or default based on file type | |
| # Default to True for .txt, .pdf, .csv, and .json files, False for .epub | |
| show_special_extensions = ('.txt', '.pdf', '.csv', '.json') | |
| show_special = input_path.lower().endswith(show_special_extensions) | |
| if hasattr(self, '_retranslation_dialog_cache') and file_key in self._retranslation_dialog_cache: | |
| cached_data = self._retranslation_dialog_cache[file_key] | |
| if cached_data: | |
| show_special = cached_data.get('show_special_files_state', show_special) | |
| self._force_retranslation_epub_or_text(input_path, show_special_files_state=show_special) | |
| def _force_retranslation_epub_or_text(self, file_path, parent_dialog=None, tab_frame=None, show_special_files_state=False): | |
| """ | |
| Shared logic for force retranslation of EPUB/text files with OPF support | |
| Can be used standalone or embedded in a tab | |
| Args: | |
| file_path: Path to the EPUB/text file | |
| parent_dialog: If provided, won't create its own dialog | |
| tab_frame: If provided, will render into this frame instead of creating dialog | |
| show_special_files_state: Initial state for showing special files toggle | |
| Returns: | |
| dict: Contains all the UI elements and data for external access | |
| """ | |
| epub_base = os.path.splitext(os.path.basename(file_path))[0] | |
| # Check for output directory override | |
| override_dir = (os.environ.get('OUTPUT_DIRECTORY') or os.environ.get('OUTPUT_DIR')) | |
| if not override_dir and hasattr(self, 'config'): | |
| override_dir = self.config.get('output_directory') | |
| if override_dir: | |
| output_dir = os.path.join(override_dir, epub_base) | |
| else: | |
| output_dir = epub_base | |
| if not os.path.exists(output_dir): | |
| if not parent_dialog: | |
| self._show_message('info', "Info", "No translation output found for this file.") | |
| return None | |
| progress_file = os.path.join(output_dir, "translation_progress.json") | |
| if not os.path.exists(progress_file): | |
| # No progress file - create empty progress structure | |
| # This allows fuzzy matching to discover existing files | |
| print("⚠️ No progress file found - will attempt to discover existing translations") | |
| prog = { | |
| "chapters": {}, | |
| "chapter_chunks": {}, | |
| "version": "2.1" | |
| } | |
| else: | |
| with open(progress_file, 'r', encoding='utf-8') as f: | |
| prog = json.load(f) | |
| # Helper: auto-discover completed files when no OPF is available | |
| def _auto_discover_from_output_dir(output_dir, prog): | |
| updated = False | |
| try: | |
| files = [ | |
| f for f in os.listdir(output_dir) | |
| if os.path.isfile(os.path.join(output_dir, f)) | |
| # accept any extension except .epub | |
| and not f.lower().endswith("_translated.txt") | |
| and f != "translation_progress.json" | |
| and not f.lower().endswith(".epub") | |
| ] | |
| for fname in files: | |
| base = os.path.basename(fname) | |
| # Normalize by stripping response_ and all extensions | |
| if base.startswith("response_"): | |
| base = base[len("response_"):] | |
| while True: | |
| new_base, ext = os.path.splitext(base) | |
| if not ext: | |
| break | |
| base = new_base | |
| import re | |
| m = re.findall(r"(\d+)", base) | |
| chapter_num = int(m[-1]) if m else None | |
| key = str(chapter_num) if chapter_num is not None else f"special_{base}" | |
| actual_num = chapter_num if chapter_num is not None else 0 | |
| if key in prog.get("chapters", {}): | |
| continue | |
| prog.setdefault("chapters", {})[key] = { | |
| "actual_num": actual_num, | |
| "content_hash": "", | |
| "output_file": fname, | |
| "status": "completed", | |
| "last_updated": os.path.getmtime(os.path.join(output_dir, fname)), | |
| "auto_discovered": True, | |
| "original_basename": fname | |
| } | |
| updated = True | |
| except Exception as e: | |
| print(f"⚠️ Auto-discovery (no OPF) failed: {e}") | |
| return updated | |
| # Clean up missing files and merged children when opening the GUI | |
| # This handles the case where parent files were manually deleted | |
| from TransateKRtoEN import ProgressManager | |
| temp_progress = ProgressManager(os.path.dirname(progress_file)) | |
| temp_progress.prog = prog | |
| temp_progress.cleanup_missing_files(output_dir) | |
| prog = temp_progress.prog | |
| # Save the cleaned progress back to file | |
| with open(progress_file, 'w', encoding='utf-8') as f: | |
| json.dump(prog, f, ensure_ascii=False, indent=2) | |
| # ===================================================== | |
| # PARSE CONTENT.OPF FOR CHAPTER MANIFEST | |
| # ===================================================== | |
| # State variable for special files toggle (will be set later by checkbox) | |
| show_special_files = [show_special_files_state] # Use list to allow modification in nested function | |
| spine_chapters = [] | |
| opf_chapter_order = {} | |
| is_epub = file_path.lower().endswith('.epub') | |
| opf_parsed = False | |
| if is_epub and os.path.exists(file_path): | |
| try: | |
| import xml.etree.ElementTree as ET | |
| import zipfile | |
| with zipfile.ZipFile(file_path, 'r') as zf: | |
| # Find content.opf file | |
| opf_path = None | |
| opf_content = None | |
| # First try to find via container.xml | |
| try: | |
| container_content = zf.read('META-INF/container.xml') | |
| container_root = ET.fromstring(container_content) | |
| rootfile = container_root.find('.//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile') | |
| if rootfile is not None: | |
| opf_path = rootfile.get('full-path') | |
| except: | |
| pass | |
| # Fallback: search for content.opf | |
| if not opf_path: | |
| for name in zf.namelist(): | |
| if name.endswith('content.opf'): | |
| opf_path = name | |
| break | |
| if opf_path: | |
| opf_content = zf.read(opf_path) | |
| # Parse OPF | |
| root = ET.fromstring(opf_content) | |
| # Handle namespaces | |
| ns = {'opf': 'http://www.idpf.org/2007/opf'} | |
| if root.tag.startswith('{'): | |
| default_ns = root.tag[1:root.tag.index('}')] | |
| ns = {'opf': default_ns} | |
| # Get manifest - all chapter files | |
| manifest_chapters = {} | |
| for item in root.findall('.//opf:manifest/opf:item', ns): | |
| item_id = item.get('id') | |
| href = item.get('href') | |
| media_type = item.get('media-type', '') | |
| if item_id and href and ('html' in media_type.lower() or href.endswith(('.html', '.xhtml', '.htm'))): | |
| filename = os.path.basename(href) | |
| # Detect special files (files without numbers) | |
| import re | |
| # Check if filename contains any digits | |
| has_numbers = bool(re.search(r'\d', filename)) | |
| is_special = not has_numbers | |
| # Add all files - UI will handle filtering based on toggle | |
| manifest_chapters[item_id] = { | |
| 'filename': filename, | |
| 'href': href, | |
| 'media_type': media_type, | |
| 'is_special': is_special | |
| } | |
| # Get spine order - the reading order | |
| spine = root.find('.//opf:spine', ns) | |
| if spine is not None: | |
| for itemref in spine.findall('opf:itemref', ns): | |
| idref = itemref.get('idref') | |
| if idref and idref in manifest_chapters: | |
| chapter_info = manifest_chapters[idref] | |
| filename = chapter_info['filename'] | |
| is_special = chapter_info.get('is_special', False) | |
| # Extract chapter number from filename | |
| import re | |
| matches = re.findall(r'(\d+)', filename) | |
| if matches: | |
| file_chapter_num = int(matches[-1]) | |
| elif is_special: | |
| # Special files without numbers should be chapter 0 | |
| file_chapter_num = 0 | |
| else: | |
| file_chapter_num = len(spine_chapters) | |
| # Add all files - UI will handle filtering based on toggle | |
| spine_chapters.append({ | |
| 'id': idref, | |
| 'filename': filename, | |
| 'position': len(spine_chapters), | |
| 'file_chapter_num': file_chapter_num, | |
| 'status': 'unknown', # Will be updated | |
| 'output_file': None, # Will be updated | |
| 'is_special': is_special | |
| }) | |
| # Store the order for later use | |
| opf_chapter_order[filename] = len(spine_chapters) - 1 | |
| # Also store without extension for matching | |
| filename_noext = os.path.splitext(filename)[0] | |
| opf_chapter_order[filename_noext] = len(spine_chapters) - 1 | |
| opf_parsed = True | |
| except Exception as e: | |
| print(f"Warning: Could not parse OPF: {e}") | |
| # If no OPF/spine, fall back to auto-discovery from output_dir | |
| if not opf_parsed or len(spine_chapters) == 0: | |
| if _auto_discover_from_output_dir(output_dir, prog): | |
| try: | |
| with open(progress_file, 'w', encoding='utf-8') as f: | |
| json.dump(prog, f, ensure_ascii=False, indent=2) | |
| print("💾 Saved auto-discovered progress (no OPF available)") | |
| except Exception as e: | |
| print(f"⚠️ Failed to save auto-discovered progress: {e}") | |
| else: | |
| # OPF-AWARE AUTO-DISCOVERY: Use OPF filenames as original_basename | |
| # This ensures correct mapping between OPF entries and response files | |
| progress_updated = False | |
| for spine_ch in spine_chapters: | |
| opf_filename = spine_ch['filename'] # e.g., "0009_10_.xhtml" | |
| base_name = os.path.splitext(opf_filename)[0] # e.g., "0009_10_" | |
| # Look for corresponding response file on disk | |
| response_file = f"response_{base_name}.html" | |
| response_path = os.path.join(output_dir, response_file) | |
| if os.path.exists(response_path): | |
| # Check if we already have a progress entry with correct original_basename | |
| already_tracked = False | |
| for ch_info in prog.get("chapters", {}).values(): | |
| if ch_info.get("original_basename") == opf_filename: | |
| already_tracked = True | |
| break | |
| # Also check by output_file | |
| if ch_info.get("output_file") == response_file: | |
| # Update original_basename if missing or wrong | |
| if ch_info.get("original_basename") != opf_filename: | |
| ch_info["original_basename"] = opf_filename | |
| progress_updated = True | |
| already_tracked = True | |
| break | |
| if not already_tracked: | |
| # Create new progress entry with correct original_basename | |
| chapter_num = spine_ch['file_chapter_num'] | |
| key = str(chapter_num) if chapter_num else f"special_{base_name}" | |
| # Avoid duplicate keys | |
| if key not in prog.get("chapters", {}): | |
| prog.setdefault("chapters", {})[key] = { | |
| "actual_num": chapter_num, | |
| "content_hash": "", | |
| "output_file": response_file, | |
| "status": "completed", | |
| "last_updated": os.path.getmtime(response_path), | |
| "auto_discovered": True, | |
| "original_basename": opf_filename # CORRECT: OPF filename | |
| } | |
| progress_updated = True | |
| print(f"✅ OPF-aware discovery: {opf_filename} -> {response_file}") | |
| if progress_updated: | |
| try: | |
| with open(progress_file, 'w', encoding='utf-8') as f: | |
| json.dump(prog, f, ensure_ascii=False, indent=2) | |
| #print("💾 Saved OPF-aware auto-discovered progress") | |
| except Exception as e: | |
| print(f"⚠️ Failed to save progress: {e}") | |
| # ===================================================== | |
| # MATCH OPF CHAPTERS WITH TRANSLATION PROGRESS | |
| # ===================================================== | |
| # Helper: normalize filenames for OPF / progress matching | |
| # We intentionally strip a leading "response_" prefix so that | |
| # files like "chapter001.xhtml" and "response_chapter001.xhtml" | |
| # are treated as referring to the same logical entry. | |
| def _normalize_opf_match_name(name: str) -> str: | |
| if not name: | |
| return "" | |
| base = os.path.basename(name) | |
| # Remove response_ prefix | |
| if base.startswith("response_"): | |
| base = base[len("response_"):] | |
| # Remove all extensions so that .html, .xhtml, .htm, etc. all match | |
| # and double extensions like .html.xhtml collapse to the stem. | |
| while True: | |
| new_base, ext = os.path.splitext(base) | |
| if not ext: | |
| break | |
| base = new_base | |
| return base | |
| def _opf_names_equal(a: str, b: str) -> bool: | |
| return _normalize_opf_match_name(a) == _normalize_opf_match_name(b) | |
| # Build a map of original basenames to progress entries (normalized) | |
| basename_to_progress = {} | |
| for chapter_key, chapter_info in prog.get("chapters", {}).items(): | |
| original_basename = chapter_info.get("original_basename", "") | |
| if original_basename: | |
| norm_key = _normalize_opf_match_name(original_basename) | |
| if norm_key not in basename_to_progress: | |
| basename_to_progress[norm_key] = [] | |
| basename_to_progress[norm_key].append((chapter_key, chapter_info)) | |
| # Also build a map of response files (include both exact and normalized keys) | |
| response_file_to_progress = {} | |
| for chapter_key, chapter_info in prog.get("chapters", {}).items(): | |
| output_file = chapter_info.get("output_file", "") | |
| if output_file: | |
| # Exact key | |
| if output_file not in response_file_to_progress: | |
| response_file_to_progress[output_file] = [] | |
| response_file_to_progress[output_file].append((chapter_key, chapter_info)) | |
| # Normalized key (ignoring response_ prefix) | |
| norm_key = _normalize_opf_match_name(output_file) | |
| if norm_key != output_file: | |
| if norm_key not in response_file_to_progress: | |
| response_file_to_progress[norm_key] = [] | |
| response_file_to_progress[norm_key].append((chapter_key, chapter_info)) | |
| # Update spine chapters with translation status | |
| for idx, spine_ch in enumerate(spine_chapters): | |
| if idx % 80 == 0: | |
| self._ui_yield() | |
| filename = spine_ch['filename'] | |
| chapter_num = spine_ch['file_chapter_num'] | |
| is_special = spine_ch.get('is_special', False) | |
| # Find the actual response file that exists | |
| base_name = os.path.splitext(filename)[0] | |
| expected_response = None | |
| # Special files need to check what actually exists on disk | |
| if is_special: | |
| # Check for response_ prefix version | |
| response_with_prefix = f"response_{base_name}.html" | |
| retain = os.getenv('RETAIN_SOURCE_EXTENSION', '0') == '1' or self.config.get('retain_source_extension', False) | |
| if retain: | |
| expected_response = filename | |
| elif os.path.exists(os.path.join(output_dir, response_with_prefix)): | |
| expected_response = response_with_prefix | |
| else: | |
| # Fallback to original filename | |
| expected_response = filename | |
| else: | |
| # Use OPF filename directly to avoid mismatching | |
| retain = os.getenv('RETAIN_SOURCE_EXTENSION', '0') == '1' or self.config.get('retain_source_extension', False) | |
| if retain: | |
| expected_response = filename | |
| else: | |
| # Handle .htm.html -> .html conversion | |
| stripped_base_name = base_name | |
| if base_name.endswith('.htm'): | |
| stripped_base_name = base_name[:-4] # Remove .htm suffix | |
| expected_response = filename # Use exact OPF filename | |
| response_path = os.path.join(output_dir, expected_response) | |
| # Check various ways to find the translation progress info | |
| matched_info = None | |
| # Method 1: Check by original basename (ignoring response_ prefix) | |
| basename_key = _normalize_opf_match_name(filename) | |
| if basename_key in basename_to_progress: | |
| entries = basename_to_progress[basename_key] | |
| if entries: | |
| _, chapter_info = entries[0] | |
| # For in_progress/failed/qa_failed/pending, also verify actual_num matches | |
| status = chapter_info.get('status', '') | |
| if status in ['in_progress', 'failed', 'qa_failed', 'pending']: | |
| if chapter_info.get('actual_num') == chapter_num: | |
| matched_info = chapter_info | |
| else: | |
| matched_info = chapter_info | |
| # Method 2: Check by response file (with corrected extension) | |
| if not matched_info and expected_response in response_file_to_progress: | |
| entries = response_file_to_progress[expected_response] | |
| if entries: | |
| _, chapter_info = entries[0] | |
| # For in_progress/failed/qa_failed/pending, also verify actual_num matches | |
| status = chapter_info.get('status', '') | |
| if status in ['in_progress', 'failed', 'qa_failed', 'pending']: | |
| if chapter_info.get('actual_num') == chapter_num: | |
| matched_info = chapter_info | |
| else: | |
| matched_info = chapter_info | |
| # Method 3: Search through all progress entries for matching output file | |
| if not matched_info: | |
| for chapter_key, chapter_info in prog.get("chapters", {}).items(): | |
| out_file = chapter_info.get('output_file') | |
| if out_file == expected_response or _opf_names_equal(out_file, expected_response): | |
| # For in_progress/failed/qa_failed/pending, also verify actual_num matches | |
| status = chapter_info.get('status', '') | |
| if status in ['in_progress', 'failed', 'qa_failed', 'pending']: | |
| if chapter_info.get('actual_num') == chapter_num: | |
| matched_info = chapter_info | |
| break | |
| else: | |
| matched_info = chapter_info | |
| break | |
| # Method 4: CRUCIAL - Match by chapter number (actual_num vs file_chapter_num) | |
| # Also check composite keys for special files (e.g., "0_message", "0_TOC") | |
| if not matched_info: | |
| # First try simple chapter number key | |
| simple_key = str(chapter_num) | |
| if simple_key in prog.get("chapters", {}): | |
| chapter_info = prog["chapters"][simple_key] | |
| out_file = chapter_info.get('output_file') | |
| status = chapter_info.get('status', '') | |
| orig_base = chapter_info.get('original_basename', '') | |
| if orig_base: | |
| orig_base = os.path.basename(orig_base) | |
| # Merged chapters: check if parent exists AND original_basename matches | |
| if status == 'merged': | |
| parent_num = chapter_info.get('merged_parent_chapter') | |
| # For merged chapters, match by original_basename (not output_file) | |
| # because output_file points to parent's file, not this chapter's source file | |
| # Strip extension for comparison since orig_base may not have it | |
| filename_noext = os.path.splitext(filename)[0] | |
| if parent_num is not None and ( | |
| _opf_names_equal(orig_base, filename) | |
| or _opf_names_equal(orig_base, filename_noext) | |
| or not orig_base | |
| ): | |
| parent_key = str(parent_num) | |
| if parent_key in prog.get("chapters", {}): | |
| # Just verify parent exists, don't enforce 'completed' status | |
| # This ensures we show 'merged' even if parent is completed_empty or other states | |
| matched_info = chapter_info | |
| # In-progress/failed/pending chapters: require BOTH actual_num AND output_file | |
| # to match to avoid cross-matching files. | |
| elif status in ['in_progress', 'failed', 'pending']: | |
| if chapter_info.get('actual_num') == chapter_num and ( | |
| out_file == expected_response or _opf_names_equal(out_file, expected_response) | |
| ): | |
| matched_info = chapter_info | |
| # qa_failed chapters: match by chapter number only so they are always visible | |
| elif status == 'qa_failed': | |
| if chapter_info.get('actual_num') == chapter_num: | |
| matched_info = chapter_info | |
| # Normal match: output file matches expected (ignoring response_ prefix) | |
| elif out_file == expected_response or _opf_names_equal(out_file, expected_response): | |
| matched_info = chapter_info | |
| # If not found, check for composite key (chapter_num + filename) | |
| if not matched_info and is_special: | |
| # For special files, try composite key format: "{chapter_num}_{filename_without_extension}" | |
| base_name = os.path.splitext(filename)[0] | |
| # Remove "response_" prefix if present in the filename | |
| if base_name.startswith("response_"): | |
| base_name = base_name[9:] | |
| composite_key = f"{chapter_num}_{base_name}" | |
| if composite_key in prog.get("chapters", {}): | |
| matched_info = prog["chapters"][composite_key] | |
| # Fallback: iterate through all entries matching chapter number, | |
| # but only accept when it clearly refers to the same source file. | |
| # This prevents files like "000_information.xhtml" and "0153_0.xhtml" | |
| # (both parsed as chapter 0) from being conflated. | |
| if not matched_info: | |
| for chapter_key, chapter_info in prog.get("chapters", {}).items(): | |
| actual_num = chapter_info.get('actual_num') | |
| # Also check 'chapter_num' as fallback | |
| if actual_num is None: | |
| actual_num = chapter_info.get('chapter_num') | |
| if actual_num is not None and actual_num == chapter_num: | |
| orig_base = chapter_info.get('original_basename', '') | |
| if orig_base: | |
| orig_base = os.path.basename(orig_base) | |
| out_file = chapter_info.get('output_file') | |
| status = chapter_info.get('status', '') | |
| qa_issues = chapter_info.get('qa_issues_found', []) | |
| # Merged chapters: match by actual_num AND original_basename | |
| # For merged, output_file points to parent so we must match by source filename | |
| if status == 'merged': | |
| parent_num = chapter_info.get('merged_parent_chapter') | |
| # Match by original_basename (the source file), not output_file (parent's file) | |
| # Strip extension for comparison since orig_base may not have it | |
| filename_noext = os.path.splitext(filename)[0] | |
| if parent_num is not None and ( | |
| _opf_names_equal(orig_base, filename) | |
| or _opf_names_equal(orig_base, filename_noext) | |
| or not orig_base | |
| ): | |
| # Check if parent chapter exists | |
| parent_key = str(parent_num) | |
| if parent_key in prog.get("chapters", {}): | |
| # Just verify parent exists, don't enforce 'completed' status | |
| matched_info = chapter_info | |
| break | |
| # In-progress/failed/pending chapters: require BOTH actual_num AND output_file | |
| # to match to avoid cross-matching files. | |
| if status in ['in_progress', 'failed', 'pending']: | |
| if actual_num == chapter_num and ( | |
| out_file == expected_response or _opf_names_equal(out_file, expected_response) | |
| ): | |
| matched_info = chapter_info | |
| break | |
| # qa_failed chapters: match by chapter number only so they are always visible, | |
| # even when filenames don't line up perfectly. | |
| elif status == 'qa_failed': | |
| if actual_num == chapter_num: | |
| matched_info = chapter_info | |
| break | |
| # Only treat as a match for other statuses if the original basename matches | |
| # this filename, or, when original_basename is missing, the output_file matches | |
| # what we expect. | |
| if status not in ['in_progress', 'failed', 'qa_failed', 'pending']: | |
| if ( | |
| orig_base and _opf_names_equal(orig_base, filename) | |
| ) or ( | |
| not orig_base and out_file and ( | |
| out_file == expected_response or _opf_names_equal(out_file, expected_response) | |
| ) | |
| ): | |
| matched_info = chapter_info | |
| break | |
| # Determine if translation file exists | |
| file_exists = os.path.exists(response_path) | |
| # Set status and output file based on findings | |
| if matched_info: | |
| # We found progress tracking info - use its status | |
| status = matched_info.get('status', 'unknown') | |
| spine_ch['progress_key'] = matched_info.get('_key') | |
| # CRITICAL: For failed/in_progress/qa_failed/pending, ALWAYS use progress status | |
| # Never let file existence override these statuses | |
| if status in ['failed', 'in_progress', 'qa_failed', 'pending']: | |
| spine_ch['status'] = status | |
| spine_ch['output_file'] = matched_info.get('output_file') or expected_response | |
| spine_ch['progress_entry'] = matched_info | |
| # Skip all other logic - don't check file existence | |
| continue | |
| # For other statuses (completed, merged, etc.) | |
| spine_ch['status'] = status | |
| # For special files, always use the original filename (ignore what's in progress JSON) | |
| if is_special: | |
| spine_ch['output_file'] = expected_response | |
| else: | |
| spine_ch['output_file'] = matched_info.get('output_file', expected_response) | |
| spine_ch['progress_entry'] = matched_info | |
| # Handle null output_file | |
| if not spine_ch['output_file']: | |
| spine_ch['output_file'] = expected_response | |
| # Verify file actually exists for completed status | |
| if status == 'completed': | |
| output_path = os.path.join(output_dir, spine_ch['output_file']) | |
| if not os.path.exists(output_path): | |
| # If the expected_response file exists, prefer that and | |
| # transparently update the progress entry. | |
| if file_exists and expected_response: | |
| fixed_output_path = os.path.join(output_dir, expected_response) | |
| if os.path.exists(fixed_output_path): | |
| spine_ch['output_file'] = expected_response | |
| # If this spine chapter is tied to a concrete | |
| # progress entry, keep it consistent. | |
| if 'progress_entry' in spine_ch and spine_ch['progress_entry'] is not None: | |
| spine_ch['progress_entry']['output_file'] = expected_response | |
| # Also update the master prog dict so the | |
| # corrected value is written back later. | |
| for ch_key, ch_info in prog.get('chapters', {}).items(): | |
| if ch_info is spine_ch['progress_entry']: | |
| prog['chapters'][ch_key]['output_file'] = expected_response | |
| break | |
| else: | |
| # No matching file anywhere – mark as missing. | |
| spine_ch['status'] = 'not_translated' | |
| else: | |
| # Legacy behaviour: nothing on disk for this entry. | |
| spine_ch['status'] = 'not_translated' | |
| elif file_exists: | |
| # File exists but no progress tracking - mark as completed | |
| spine_ch['status'] = 'completed' | |
| spine_ch['output_file'] = expected_response | |
| else: | |
| # No file and no progress tracking - LAST RESORT: Try exact filename matching | |
| # This handles the case where progress file was deleted but files exist | |
| # Match by filename only (ignore response_ prefix and all extensions) | |
| def normalize_filename(fname): | |
| """Remove response_ prefix and all extensions for exact comparison""" | |
| base = os.path.basename(fname) | |
| # Remove response_ prefix | |
| if base.startswith('response_'): | |
| base = base[9:] | |
| # Remove all extensions (including double extensions like .html.xhtml) | |
| while True: | |
| new_base, ext = os.path.splitext(base) | |
| if not ext: | |
| break | |
| base = new_base | |
| return base | |
| # Normalize the OPF filename | |
| normalized_opf = normalize_filename(filename) | |
| # Search for exact matching file in output directory | |
| matched_file = None | |
| if os.path.exists(output_dir): | |
| try: | |
| for existing_file in os.listdir(output_dir): | |
| if os.path.isfile(os.path.join(output_dir, existing_file)): | |
| normalized_existing = normalize_filename(existing_file) | |
| # Exact match only - no fuzzy logic | |
| if normalized_existing == normalized_opf: | |
| matched_file = existing_file | |
| break | |
| except Exception as e: | |
| print(f"Warning: Error scanning output directory for match: {e}") | |
| if matched_file: | |
| # Found an exact matching file by normalized name - mark as completed | |
| spine_ch['status'] = 'completed' | |
| spine_ch['output_file'] = matched_file | |
| print(f"📁 Matched: {filename} -> {matched_file}") | |
| else: | |
| # No file and no progress tracking - not translated | |
| spine_ch['status'] = 'not_translated' | |
| spine_ch['output_file'] = expected_response | |
| # ===================================================== | |
| # SAVE AUTO-DISCOVERED FILES TO PROGRESS | |
| # ===================================================== | |
| # Check if we discovered any new completed files (exact matched by normalized filename) | |
| # and add them to the progress file | |
| progress_updated = False | |
| for spine_ch in spine_chapters: | |
| # Only add entries that were marked as completed but have no progress entry | |
| if spine_ch['status'] == 'completed' and 'progress_entry' not in spine_ch: | |
| chapter_num = spine_ch['file_chapter_num'] | |
| output_file = spine_ch['output_file'] | |
| filename = spine_ch['filename'] | |
| # Create a progress entry for this auto-discovered file | |
| chapter_key = str(chapter_num) | |
| # Check if key already exists (avoid duplicates) | |
| if chapter_key not in prog.get("chapters", {}): | |
| prog.setdefault("chapters", {})[chapter_key] = { | |
| "actual_num": chapter_num, | |
| "content_hash": "", # Unknown since we don't have the source | |
| "output_file": output_file, | |
| "status": "completed", | |
| "last_updated": os.path.getmtime(os.path.join(output_dir, output_file)), | |
| "auto_discovered": True, | |
| "original_basename": filename | |
| } | |
| progress_updated = True | |
| print(f"✅ Auto-discovered and tracked: {filename} -> {output_file}") | |
| # Save progress file if we added new entries | |
| if progress_updated: | |
| try: | |
| with open(progress_file, 'w', encoding='utf-8') as f: | |
| json.dump(prog, f, ensure_ascii=False, indent=2) | |
| print(f"💾 Saved {sum(1 for ch in spine_chapters if ch['status'] == 'completed' and 'progress_entry' not in ch)} auto-discovered files to progress file") | |
| except Exception as e: | |
| print(f"⚠️ Warning: Failed to save progress file: {e}") | |
| # ===================================================== | |
| # BUILD DISPLAY INFO | |
| # ===================================================== | |
| chapter_display_info = [] | |
| if spine_chapters: | |
| # Use OPF order | |
| for spine_ch in spine_chapters: | |
| display_info = { | |
| 'key': spine_ch.get('filename', ''), | |
| 'num': spine_ch['file_chapter_num'], | |
| 'info': spine_ch.get('progress_entry', {}), | |
| 'output_file': spine_ch['output_file'], | |
| 'status': spine_ch['status'], | |
| 'duplicate_count': 1, | |
| 'entries': [], | |
| 'opf_position': spine_ch['position'], | |
| 'original_filename': spine_ch['filename'], | |
| 'is_special': spine_ch.get('is_special', False) | |
| } | |
| chapter_display_info.append(display_info) | |
| else: | |
| # Fallback to original logic if no OPF | |
| files_to_entries = {} | |
| for chapter_key, chapter_info in prog.get("chapters", {}).items(): | |
| output_file = chapter_info.get("output_file", "") | |
| status = chapter_info.get("status", "") | |
| # Include chapters with output files OR transient statuses with null output file (legacy) | |
| # (composite keys like "0_TOC" should still be represented in the UI) | |
| if output_file or status in ["in_progress", "pending", "failed", "qa_failed"]: | |
| # For merged chapters, use a unique key (chapter_key) instead of output_file | |
| # This ensures merged chapters appear as separate entries in the list | |
| if status == "merged": | |
| file_key = f"_merged_{chapter_key}" | |
| elif output_file: | |
| file_key = output_file | |
| elif status == "in_progress": | |
| file_key = f"_in_progress_{chapter_key}" | |
| elif status == "pending": | |
| file_key = f"_pending_{chapter_key}" | |
| elif status == "qa_failed": | |
| file_key = f"_qa_failed_{chapter_key}" | |
| else: # failed | |
| file_key = f"_failed_{chapter_key}" | |
| if file_key not in files_to_entries: | |
| files_to_entries[file_key] = [] | |
| files_to_entries[file_key].append((chapter_key, chapter_info)) | |
| for output_file, entries in files_to_entries.items(): | |
| chapter_key, chapter_info = entries[0] | |
| # Get the actual output file (strip placeholder prefix if present) | |
| actual_output_file = output_file | |
| if ( | |
| output_file.startswith("_merged_") | |
| or output_file.startswith("_in_progress_") | |
| or output_file.startswith("_pending_") | |
| or output_file.startswith("_failed_") | |
| or output_file.startswith("_qa_failed_") | |
| ): | |
| # For merged/in_progress/pending/failed/qa_failed, get the actual output_file from chapter_info | |
| actual_output_file = chapter_info.get("output_file", "") | |
| if not actual_output_file: | |
| # Generate expected filename based on actual_num | |
| actual_num = chapter_info.get("actual_num") | |
| if actual_num is not None: | |
| # Use .txt extension for text files, .html for EPUB | |
| ext = ".txt" if file_path.endswith(".txt") else ".html" | |
| actual_output_file = f"response_section_{actual_num}{ext}" | |
| # Check if this is a special file (files without numbers) | |
| original_basename = chapter_info.get("original_basename", "") | |
| filename_to_check = original_basename if original_basename else actual_output_file | |
| # Check if filename contains any digits | |
| import re | |
| has_numbers = bool(re.search(r'\d', filename_to_check)) | |
| is_special = not has_numbers | |
| # Extract chapter number - prioritize stored values | |
| chapter_num = None | |
| if 'actual_num' in chapter_info and chapter_info['actual_num'] is not None: | |
| chapter_num = chapter_info['actual_num'] | |
| elif 'chapter_num' in chapter_info and chapter_info['chapter_num'] is not None: | |
| chapter_num = chapter_info['chapter_num'] | |
| # Fallback: extract from filename | |
| if chapter_num is None: | |
| import re | |
| matches = re.findall(r'(\d+)', actual_output_file) | |
| if matches: | |
| chapter_num = int(matches[-1]) | |
| else: | |
| chapter_num = 999999 | |
| status = chapter_info.get("status", "unknown") | |
| if status == "completed_empty": | |
| status = "completed" | |
| # Check file existence | |
| if status == "completed": | |
| output_path = os.path.join(output_dir, actual_output_file) | |
| if not os.path.exists(output_path): | |
| status = "file_missing" | |
| chapter_display_info.append({ | |
| 'key': chapter_key, | |
| 'num': chapter_num, | |
| 'info': chapter_info, | |
| 'output_file': actual_output_file, # Use actual output file, not placeholder | |
| 'status': status, | |
| 'duplicate_count': len(entries), | |
| 'entries': entries, | |
| 'is_special': is_special | |
| }) | |
| # Sort by chapter number | |
| chapter_display_info.sort(key=lambda x: x['num'] if x['num'] is not None else 999999) | |
| # ===================================================== | |
| # CREATE UI | |
| # ===================================================== | |
| # If no parent dialog or tab frame, create standalone dialog | |
| if not parent_dialog and not tab_frame: | |
| # Ensure QApplication exists for standalone PySide6 dialog | |
| from PySide6.QtWidgets import QApplication | |
| if not QApplication.instance(): | |
| # Create QApplication if it doesn't exist | |
| import sys | |
| QApplication(sys.argv) | |
| # Create standalone PySide6 dialog. | |
| # IMPORTANT: If created without a parent, it will NOT inherit the main window's | |
| # dark stylesheet and will fall back to the OS theme (white on some Win10 setups). | |
| parent_widget = self if isinstance(self, QWidget) else None | |
| dialog = QDialog(parent_widget) | |
| dialog.setWindowTitle("Progress Manager - OPF Based" if spine_chapters else "Progress Manager") | |
| # Keep above the translator window but allow interaction with it | |
| dialog.setWindowFlag(Qt.WindowStaysOnTopHint, True) | |
| dialog.setWindowModality(Qt.NonModal) | |
| # Use 38% width, 40% height for 1920x1080 | |
| width, height = self._get_dialog_size(0.38, 0.4) | |
| dialog.resize(width, height) | |
| # Inherit/copy the main window stylesheet when available (ensures consistent dark theme). | |
| try: | |
| if parent_widget is not None: | |
| ss = parent_widget.styleSheet() | |
| if ss: | |
| dialog.setStyleSheet(ss) | |
| except Exception: | |
| pass | |
| # Set icon | |
| try: | |
| from PySide6.QtGui import QIcon | |
| # Try to get base_dir from self (TranslatorGUI), fallback to calculating it | |
| if hasattr(self, 'base_dir'): | |
| base_dir = self.base_dir | |
| else: | |
| base_dir = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) | |
| ico_path = os.path.join(base_dir, 'Halgakos.ico') | |
| if os.path.isfile(ico_path): | |
| dialog.setWindowIcon(QIcon(ico_path)) | |
| except Exception as e: | |
| print(f"Failed to load icon: {e}") | |
| dialog_layout = QVBoxLayout(dialog) | |
| container = QWidget(dialog) | |
| container_layout = QVBoxLayout(container) | |
| dialog_layout.addWidget(container) | |
| else: | |
| container = tab_frame or parent_dialog | |
| if not hasattr(container, 'layout') or container.layout() is None: | |
| container_layout = QVBoxLayout(container) | |
| else: | |
| container_layout = container.layout() | |
| dialog = parent_dialog | |
| # Title and toggle row | |
| title_row = QWidget() | |
| title_layout = QHBoxLayout(title_row) | |
| title_layout.setContentsMargins(0, 0, 0, 0) | |
| title_text = "Chapters from content.opf (in reading order):" if spine_chapters else "Select chapters to retranslate:" | |
| title_label = QLabel(title_text) | |
| title_font = QFont('Arial', 12 if not tab_frame else 11) | |
| title_font.setBold(True) | |
| title_label.setFont(title_font) | |
| title_layout.addWidget(title_label) | |
| title_layout.addStretch() | |
| # Add toggle for showing special files | |
| from PySide6.QtWidgets import QCheckBox | |
| show_special_files_cb = QCheckBox("Show special files (cover, nav, toc)") | |
| show_special_files_cb.setChecked(show_special_files[0]) # Preserve the current state | |
| show_special_files_cb.setToolTip("When enabled, shows special files (files without chapter numbers like cover, nav, toc, info, message, etc.)") | |
| # Register this checkbox and checkmark with parent dialog for cross-tab syncing | |
| if parent_dialog and not hasattr(parent_dialog, '_all_toggle_checkboxes'): | |
| parent_dialog._all_toggle_checkboxes = [] | |
| parent_dialog._all_checkmark_labels = [] | |
| parent_dialog._tab_file_paths = {} # Map file_path to index | |
| if parent_dialog: | |
| # Store the index for this file | |
| file_key = os.path.abspath(file_path) | |
| if file_key not in parent_dialog._tab_file_paths: | |
| parent_dialog._tab_file_paths[file_key] = len(parent_dialog._all_toggle_checkboxes) | |
| parent_dialog._all_toggle_checkboxes.append(show_special_files_cb) | |
| else: | |
| # Replace the old checkbox at this index | |
| idx = parent_dialog._tab_file_paths[file_key] | |
| if idx < len(parent_dialog._all_toggle_checkboxes): | |
| parent_dialog._all_toggle_checkboxes[idx] = show_special_files_cb | |
| # Apply blue checkbox stylesheet (matching Other Settings dialog) | |
| show_special_files_cb.setStyleSheet(""" | |
| QCheckBox { | |
| color: white; | |
| spacing: 6px; | |
| } | |
| QCheckBox::indicator { | |
| width: 14px; | |
| height: 14px; | |
| border: 1px solid #5a9fd4; | |
| border-radius: 2px; | |
| background-color: #2d2d2d; | |
| } | |
| QCheckBox::indicator:checked { | |
| background-color: #5a9fd4; | |
| border-color: #5a9fd4; | |
| } | |
| QCheckBox::indicator:hover { | |
| border-color: #7bb3e0; | |
| } | |
| QCheckBox:disabled { | |
| color: #666666; | |
| } | |
| QCheckBox::indicator:disabled { | |
| background-color: #1a1a1a; | |
| border-color: #3a3a3a; | |
| } | |
| """) | |
| # Create checkmark overlay for the check symbol | |
| checkmark = QLabel("✓", show_special_files_cb) | |
| checkmark.setStyleSheet(""" | |
| QLabel { | |
| color: white; | |
| background: transparent; | |
| font-weight: bold; | |
| font-size: 11px; | |
| } | |
| """) | |
| checkmark.setAlignment(Qt.AlignCenter) | |
| checkmark.hide() | |
| checkmark.setAttribute(Qt.WA_TransparentForMouseEvents) | |
| def position_checkmark(): | |
| try: | |
| if checkmark: | |
| checkmark.setGeometry(2, 1, 14, 14) | |
| except RuntimeError: | |
| pass | |
| def update_checkmark(): | |
| try: | |
| if show_special_files_cb and checkmark: | |
| if show_special_files_cb.isChecked(): | |
| position_checkmark() | |
| checkmark.show() | |
| else: | |
| checkmark.hide() | |
| except RuntimeError: | |
| pass | |
| show_special_files_cb.stateChanged.connect(update_checkmark) | |
| def safe_init(): | |
| try: | |
| position_checkmark() | |
| update_checkmark() | |
| except RuntimeError: | |
| pass | |
| QTimer.singleShot(0, safe_init) | |
| # Register checkmark for cross-tab syncing | |
| if parent_dialog: | |
| file_key = os.path.abspath(file_path) | |
| if file_key in parent_dialog._tab_file_paths: | |
| idx = parent_dialog._tab_file_paths[file_key] | |
| # Append if new, replace if exists | |
| if idx >= len(parent_dialog._all_checkmark_labels): | |
| parent_dialog._all_checkmark_labels.append(checkmark) | |
| else: | |
| parent_dialog._all_checkmark_labels[idx] = checkmark | |
| title_layout.addWidget(show_special_files_cb) | |
| container_layout.addWidget(title_row) | |
| # Store reference to the listbox (will be created later) | |
| listbox_ref = [None] | |
| # Function to handle toggle change - will be defined after UI is created | |
| def on_toggle_special_files(state): | |
| """Filter the chapter list when the special files toggle is changed""" | |
| # Update the state variable | |
| show_special_files[0] = show_special_files_cb.isChecked() | |
| # Store the state persistently | |
| file_key = os.path.abspath(file_path) | |
| if not hasattr(self, '_retranslation_dialog_cache'): | |
| self._retranslation_dialog_cache = {} | |
| if file_key not in self._retranslation_dialog_cache: | |
| self._retranslation_dialog_cache[file_key] = {} | |
| self._retranslation_dialog_cache[file_key]['show_special_files_state'] = show_special_files[0] | |
| # For tabs in multi-file dialog, sync toggle state across tabs | |
| if tab_frame and parent_dialog: | |
| # Update cache for all files in the current selection | |
| if hasattr(parent_dialog, '_epub_files_in_dialog'): | |
| for f_path in parent_dialog._epub_files_in_dialog: | |
| f_key = os.path.abspath(f_path) | |
| if f_key not in self._retranslation_dialog_cache: | |
| self._retranslation_dialog_cache[f_key] = {} | |
| self._retranslation_dialog_cache[f_key]['show_special_files_state'] = show_special_files[0] | |
| # Sync ALL toggle checkboxes and checkmarks in ALL tabs | |
| if hasattr(parent_dialog, '_all_toggle_checkboxes'): | |
| for idx, other_checkbox in enumerate(parent_dialog._all_toggle_checkboxes): | |
| if other_checkbox is None or other_checkbox == show_special_files_cb: | |
| continue | |
| try: | |
| other_checkbox.isChecked() | |
| other_checkbox.blockSignals(True) | |
| other_checkbox.setChecked(show_special_files[0]) | |
| other_checkbox.blockSignals(False) | |
| if hasattr(parent_dialog, '_all_checkmark_labels') and idx < len(parent_dialog._all_checkmark_labels): | |
| other_checkmark = parent_dialog._all_checkmark_labels[idx] | |
| if other_checkmark is not None: | |
| try: | |
| other_checkmark.isVisible() | |
| if show_special_files[0]: | |
| other_checkmark.setGeometry(2, 1, 14, 14) | |
| other_checkmark.show() | |
| else: | |
| other_checkmark.hide() | |
| except RuntimeError: | |
| parent_dialog._all_checkmark_labels[idx] = None | |
| except (RuntimeError, AttributeError): | |
| parent_dialog._all_toggle_checkboxes[idx] = None | |
| # Filter list items instead of rebuilding entire UI | |
| if listbox_ref[0]: | |
| listbox = listbox_ref[0] | |
| for i in range(listbox.count()): | |
| item = listbox.item(i) | |
| if item: | |
| # Check if this item is marked as special | |
| item_data = item.data(Qt.UserRole) | |
| if item_data and isinstance(item_data, dict): | |
| is_special = item_data.get('is_special', False) | |
| # Show all items if toggle is on, hide special files if toggle is off | |
| item.setHidden(is_special and not show_special_files[0]) | |
| # Connect the checkbox to the handler | |
| show_special_files_cb.stateChanged.connect(on_toggle_special_files) | |
| # Statistics - always show for both OPF and non-OPF files | |
| stats_frame = QWidget() | |
| stats_layout = QHBoxLayout(stats_frame) | |
| stats_layout.setContentsMargins(0, 5, 0, 5) | |
| container_layout.addWidget(stats_frame) | |
| # Calculate stats from the appropriate source | |
| if spine_chapters: | |
| total_chapters = len(spine_chapters) | |
| completed = sum(1 for ch in spine_chapters if ch['status'] == 'completed') | |
| merged = sum(1 for ch in spine_chapters if ch['status'] == 'merged') | |
| in_progress = sum(1 for ch in spine_chapters if ch['status'] == 'in_progress') | |
| pending = sum(1 for ch in spine_chapters if ch['status'] == 'pending') | |
| missing = sum(1 for ch in spine_chapters if ch['status'] == 'not_translated') | |
| failed = sum(1 for ch in spine_chapters if ch['status'] in ['failed', 'qa_failed']) | |
| else: | |
| # For non-OPF files, calculate from chapter_display_info | |
| total_chapters = len(chapter_display_info) | |
| completed = sum(1 for ch in chapter_display_info if ch['status'] == 'completed') | |
| merged = sum(1 for ch in chapter_display_info if ch['status'] == 'merged') | |
| in_progress = sum(1 for ch in chapter_display_info if ch['status'] == 'in_progress') | |
| pending = sum(1 for ch in chapter_display_info if ch['status'] == 'pending') | |
| missing = sum(1 for ch in chapter_display_info if ch['status'] == 'not_translated') | |
| failed = sum(1 for ch in chapter_display_info if ch['status'] in ['failed', 'qa_failed']) | |
| # Create labels (outside the if/else so they always appear) | |
| stats_font = QFont('Arial', 10) | |
| lbl_total = QLabel(f"Total: {total_chapters} | ") | |
| lbl_total.setFont(stats_font) | |
| stats_layout.addWidget(lbl_total) | |
| lbl_completed = QLabel(f"✅ Completed: {completed} | ") | |
| lbl_completed.setFont(stats_font) | |
| lbl_completed.setStyleSheet("color: green;") | |
| lbl_completed.setCursor(Qt.PointingHandCursor) | |
| stats_layout.addWidget(lbl_completed) | |
| # Merged: chapters combined into parent request (always create, hide if 0) | |
| lbl_merged = QLabel(f"🔗 Merged: {merged} | ") | |
| lbl_merged.setFont(stats_font) | |
| lbl_merged.setStyleSheet("color: #17a2b8;") # Cyan/teal | |
| stats_layout.addWidget(lbl_merged) | |
| if merged == 0: | |
| lbl_merged.setVisible(False) | |
| # In Progress: currently being translated (always create, hide if 0) | |
| lbl_in_progress = QLabel(f"🔄 In Progress: {in_progress} | ") | |
| lbl_in_progress.setFont(stats_font) | |
| lbl_in_progress.setStyleSheet("color: orange;") | |
| lbl_in_progress.setCursor(Qt.PointingHandCursor) | |
| stats_layout.addWidget(lbl_in_progress) | |
| if in_progress == 0: | |
| lbl_in_progress.setVisible(False) | |
| # Pending: marked for retranslation (always create, hide if 0) | |
| lbl_pending = QLabel(f"❓ Pending: {pending} | ") | |
| lbl_pending.setFont(stats_font) | |
| lbl_pending.setStyleSheet("color: white;") | |
| lbl_pending.setCursor(Qt.PointingHandCursor) | |
| stats_layout.addWidget(lbl_pending) | |
| if pending == 0: | |
| lbl_pending.setVisible(False) | |
| # Not Translated: unique emoji/color (distinct from failures) | |
| lbl_missing = QLabel(f"⬜ Not Translated: {missing} | ") | |
| lbl_missing.setFont(stats_font) | |
| lbl_missing.setStyleSheet("color: #2b6cb0;") | |
| lbl_missing.setCursor(Qt.PointingHandCursor) | |
| stats_layout.addWidget(lbl_missing) | |
| # Match list status: failed/qa_failed use ❌ and red (clickable — jumps to next failure) | |
| lbl_failed = QLabel(f"❌ Failed: {failed} | ") | |
| lbl_failed.setFont(stats_font) | |
| lbl_failed.setStyleSheet("color: red;") | |
| lbl_failed.setCursor(Qt.PointingHandCursor) | |
| stats_layout.addWidget(lbl_failed) | |
| stats_layout.addStretch() | |
| # Main frame for listbox | |
| main_frame = QWidget() | |
| main_layout = QVBoxLayout(main_frame) | |
| main_layout.setContentsMargins(10 if not tab_frame else 5, 5, 10 if not tab_frame else 5, 5) | |
| container_layout.addWidget(main_frame) | |
| # Create listbox (QListWidget has built-in scrollbars) | |
| listbox = QListWidget() | |
| listbox.setSelectionMode(QListWidget.ExtendedSelection) | |
| listbox_font = QFont('Courier', 10) # Fixed-width font for better alignment | |
| listbox.setFont(listbox_font) | |
| listbox.setSpacing(0) | |
| listbox.setUniformItemSizes(True) | |
| listbox.setStyleSheet("QListWidget::item { padding: 1px 2px; margin: 0px; }") | |
| # Use 36% of screen width | |
| min_width, _ = self._get_dialog_size(0.36, 0) | |
| listbox.setMinimumWidth(min_width) | |
| main_layout.addWidget(listbox) | |
| # Store listbox reference for toggle handler | |
| listbox_ref[0] = listbox | |
| # Helper: cycle to next item matching given statuses | |
| def _make_cycle_handler(statuses): | |
| def _handler(_event=None): | |
| lb = listbox_ref[0] | |
| if not lb: | |
| return | |
| indices = [ | |
| i for i in range(lb.count()) | |
| if not lb.item(i).isHidden() | |
| and (lb.item(i).data(Qt.UserRole) or {}).get('info', {}).get('status') in statuses | |
| ] | |
| if not indices: | |
| return | |
| current = lb.currentRow() | |
| nxt = next((i for i in indices if i > current), indices[0]) | |
| lb.setCurrentRow(nxt) | |
| lb.scrollToItem(lb.item(nxt), QListWidget.PositionAtCenter) | |
| return _handler | |
| lbl_completed.mousePressEvent = _make_cycle_handler(('completed',)) | |
| lbl_in_progress.mousePressEvent = _make_cycle_handler(('in_progress',)) | |
| lbl_pending.mousePressEvent = _make_cycle_handler(('pending',)) | |
| lbl_missing.mousePressEvent = _make_cycle_handler(('not_translated',)) | |
| lbl_failed.mousePressEvent = _make_cycle_handler(('failed', 'qa_failed')) | |
| # Populate listbox with dynamic column widths | |
| status_icons = { | |
| 'completed': '✅', | |
| 'merged': '🔗', | |
| 'failed': '❌', | |
| 'qa_failed': '❌', | |
| 'in_progress': '🔄', | |
| 'pending': '❓', | |
| 'not_translated': '⬜', | |
| 'unknown': '❓' | |
| } | |
| status_labels = { | |
| 'completed': 'Completed', | |
| 'merged': 'Merged', | |
| 'failed': 'Failed', | |
| 'qa_failed': 'QA Failed', | |
| 'in_progress': 'In Progress', | |
| 'pending': 'Pending', | |
| 'not_translated': 'Not Translated', | |
| 'unknown': 'Unknown' | |
| } | |
| # Calculate maximum widths for dynamic column sizing | |
| max_original_len = 0 | |
| max_output_len = 0 | |
| for info in chapter_display_info: | |
| if 'opf_position' in info: | |
| original_file = info.get('original_filename', '') | |
| output_file = info['output_file'] | |
| max_original_len = max(max_original_len, len(original_file)) | |
| max_output_len = max(max_output_len, len(output_file)) | |
| # Set minimum widths to prevent too narrow columns | |
| max_original_len = max(max_original_len, 20) | |
| max_output_len = max(max_output_len, 25) | |
| for info in chapter_display_info: | |
| chapter_num = info['num'] | |
| status = info['status'] | |
| output_file = info['output_file'] | |
| icon = status_icons.get(status, '❓') | |
| status_label = status_labels.get(status, status) | |
| # Format display with OPF info if available | |
| if 'opf_position' in info: | |
| # OPF-based display with dynamic widths | |
| original_file = info.get('original_filename', '') | |
| opf_pos = info['opf_position'] + 1 # 1-based for display | |
| # Format: [OPF Position] Chapter Number | Status | Original File -> Response File | |
| if isinstance(chapter_num, float) and chapter_num.is_integer(): | |
| display = f"[{opf_pos:03d}] Ch.{int(chapter_num):03d} | {icon} {status_label:11s} | {original_file:<{max_original_len}} -> {output_file}" | |
| else: | |
| display = f"[{opf_pos:03d}] Ch.{chapter_num:03d} | {icon} {status_label:11s} | {original_file:<{max_original_len}} -> {output_file}" | |
| else: | |
| # Original format | |
| if isinstance(chapter_num, float) and chapter_num.is_integer(): | |
| display = f"Chapter {int(chapter_num):03d} | {icon} {status_label:11s} | {output_file}" | |
| elif isinstance(chapter_num, float): | |
| display = f"Chapter {chapter_num:06.1f} | {icon} {status_label:11s} | {output_file}" | |
| else: | |
| display = f"Chapter {chapter_num:03d} | {icon} {status_label:11s} | {output_file}" | |
| # Add QA issues if status is qa_failed | |
| if status == 'qa_failed': | |
| chapter_info = info.get('info', {}) | |
| qa_issues = chapter_info.get('qa_issues_found', []) | |
| if qa_issues: | |
| # Format issues for display (show first 2) | |
| issues_display = ', '.join(qa_issues[:2]) | |
| if len(qa_issues) > 2: | |
| issues_display += f' (+{len(qa_issues)-2} more)' | |
| display += f" | {issues_display}" | |
| # Add parent chapter info if status is merged | |
| if status == 'merged': | |
| chapter_info = info.get('info', {}) | |
| parent_chapter = chapter_info.get('merged_parent_chapter') | |
| if parent_chapter: | |
| display += f" | → Ch.{parent_chapter}" | |
| if info.get('duplicate_count', 1) > 1: | |
| display += f" | ({info['duplicate_count']} entries)" | |
| item = QListWidgetItem(display) | |
| # Color code based on status | |
| if status == 'completed': | |
| item.setForeground(QColor('green')) | |
| elif status == 'merged': | |
| item.setForeground(QColor('#17a2b8')) # Cyan/teal for merged | |
| elif status in ['failed', 'qa_failed']: | |
| item.setForeground(QColor('red')) | |
| elif status == 'not_translated': | |
| item.setForeground(QColor('#2b6cb0')) | |
| elif status == 'in_progress': | |
| item.setForeground(QColor('orange')) | |
| elif status == 'pending': | |
| item.setForeground(QColor('white')) # White for pending | |
| # Store metadata in item for filtering | |
| is_special = info.get('is_special', False) | |
| item.setData(Qt.UserRole, {'is_special': is_special, 'info': info}) | |
| # Add item to listbox first | |
| listbox.addItem(item) | |
| # Then hide special files if toggle is off (must be done after adding to listbox) | |
| if is_special and not show_special_files[0]: | |
| item.setHidden(True) | |
| # Selection count label | |
| selection_count_label = QLabel("Selected: 0") | |
| selection_font = QFont('Arial', 10 if not tab_frame else 9) | |
| selection_count_label.setFont(selection_font) | |
| container_layout.addWidget(selection_count_label) | |
| def update_selection_count(): | |
| count = len(listbox.selectedItems()) | |
| selection_count_label.setText(f"Selected: {count}") | |
| listbox.itemSelectionChanged.connect(update_selection_count) | |
| # Return data structure for external access | |
| result = { | |
| 'file_path': file_path, | |
| 'output_dir': output_dir, | |
| 'progress_file': progress_file, | |
| 'prog': prog, | |
| 'spine_chapters': spine_chapters, | |
| 'opf_chapter_order': opf_chapter_order, | |
| 'chapter_display_info': chapter_display_info, | |
| 'listbox': listbox, | |
| 'selection_count_label': selection_count_label, | |
| 'dialog': dialog, | |
| 'container': container, | |
| 'show_special_files_state': show_special_files[0], # Store current toggle state | |
| 'show_special_files_cb': show_special_files_cb # Store checkbox reference | |
| } | |
| # If standalone (no parent), add buttons and show dialog | |
| if not parent_dialog and not tab_frame: | |
| self._add_retranslation_buttons_opf(result) | |
| # Override close event to hide instead of destroy | |
| def closeEvent(event): | |
| event.ignore() # Ignore the close event | |
| dialog.hide() # Just hide the dialog | |
| dialog.closeEvent = closeEvent | |
| # Cache the dialog for reuse | |
| if not hasattr(self, '_retranslation_dialog_cache'): | |
| self._retranslation_dialog_cache = {} | |
| file_key = os.path.abspath(file_path) | |
| self._retranslation_dialog_cache[file_key] = result | |
| # Show the dialog (non-modal to allow interaction with other windows) | |
| dialog.show() | |
| elif not parent_dialog or tab_frame: | |
| # Embedded in tab - just add buttons | |
| self._add_retranslation_buttons_opf(result) | |
| return result | |
| def _add_retranslation_buttons_opf(self, data, button_frame=None): | |
| """Add the standard button set for retranslation dialogs with OPF support""" | |
| if not button_frame: | |
| button_frame = QWidget() | |
| button_layout = QGridLayout(button_frame) | |
| # Get container layout and add button frame | |
| container = data['container'] | |
| if hasattr(container, 'layout') and container.layout(): | |
| container.layout().addWidget(button_frame) | |
| else: | |
| button_layout = button_frame.layout() if button_frame.layout() else QGridLayout(button_frame) | |
| # Helper functions that work with the data dict | |
| def select_all(): | |
| data['listbox'].selectAll() | |
| data['selection_count_label'].setText(f"Selected: {data['listbox'].count()}") | |
| def clear_selection(): | |
| data['listbox'].clearSelection() | |
| data['selection_count_label'].setText("Selected: 0") | |
| def select_status(status_to_select): | |
| data['listbox'].clearSelection() | |
| for idx, info in enumerate(data['chapter_display_info']): | |
| if status_to_select == 'failed': | |
| if info['status'] in ['failed', 'qa_failed']: | |
| data['listbox'].item(idx).setSelected(True) | |
| elif status_to_select == 'qa_failed': | |
| if info['status'] == 'qa_failed': | |
| data['listbox'].item(idx).setSelected(True) | |
| else: | |
| if info['status'] == status_to_select: | |
| data['listbox'].item(idx).setSelected(True) | |
| count = len(data['listbox'].selectedItems()) | |
| data['selection_count_label'].setText(f"Selected: {count}") | |
| def _normalize_filename(name: str) -> str: | |
| if not name: | |
| return "" | |
| base = os.path.basename(name) | |
| if base.startswith("response_"): | |
| base = base[len("response_"):] | |
| while True: | |
| new_base, ext = os.path.splitext(base) | |
| if not ext: | |
| break | |
| base = new_base | |
| return base | |
| def _find_progress_entry(chapter_info, prog): | |
| """Strict: match only identical output_file string.""" | |
| target_out = chapter_info.get('output_file') | |
| if not target_out: | |
| return None | |
| for key, ch in prog.get("chapters", {}).items(): | |
| if ch.get('output_file') == target_out: | |
| return key, ch | |
| return None | |
| def remove_qa_failed_mark(): | |
| selected_items = data['listbox'].selectedItems() | |
| if not selected_items: | |
| QMessageBox.warning(data.get('dialog', self), "No Selection", "Please select at least one chapter.") | |
| return | |
| # Skip dedup here to avoid merging distinct chapters that share filenames | |
| selected_indices = [data['listbox'].row(item) for item in selected_items] | |
| selected_chapters = [data['chapter_display_info'][i] for i in selected_indices] | |
| failed_chapters = [ch for ch in selected_chapters if ch['status'] in ['qa_failed', 'failed']] | |
| if not failed_chapters: | |
| QMessageBox.warning(data.get('dialog', self), "No Failed Chapters", | |
| "None of the selected chapters have 'qa_failed' or 'failed' status.") | |
| return | |
| count = len(failed_chapters) | |
| reply = QMessageBox.question(data.get('dialog', self), "Confirm Remove Failed Mark", | |
| f"Remove failed mark from {count} chapters?", | |
| QMessageBox.Yes | QMessageBox.No) | |
| if reply != QMessageBox.Yes: | |
| return | |
| # Remove marks | |
| cleared_count = 0 | |
| progress_updated = False | |
| for info in failed_chapters: | |
| match = None | |
| progress_key = info.get('progress_key') | |
| if progress_key and progress_key in data['prog'].get("chapters", {}): | |
| match = (progress_key, data['prog']["chapters"][progress_key]) | |
| else: | |
| match = _find_progress_entry(info, data['prog']) | |
| # Normalize target output for multi-entry cleanup | |
| target_out = info.get('output_file') | |
| target_norm = _normalize_filename(target_out) | |
| if match: | |
| # Clear failed/qa_failed on ALL entries sharing this output file (normalized) | |
| fields_to_remove = ["qa_issues", "qa_timestamp", "qa_issues_found", "duplicate_confidence", "failure_reason", "error_message"] | |
| for key, entry in data['prog'].get("chapters", {}).items(): | |
| entry_out = entry.get('output_file') | |
| if not entry_out: | |
| continue | |
| if _normalize_filename(entry_out) == target_norm: | |
| if entry.get('status') in ['qa_failed', 'failed']: | |
| entry["status"] = "completed" | |
| for field in fields_to_remove: | |
| entry.pop(field, None) | |
| cleared_count += 1 | |
| progress_updated = True | |
| else: | |
| print(f"WARNING: Could not find chapter entry for {info.get('num')} ({info.get('output_file')})") | |
| # Save the updated progress | |
| if progress_updated: | |
| with open(data['progress_file'], 'w', encoding='utf-8') as f: | |
| json.dump(data['prog'], f, ensure_ascii=False, indent=2) | |
| # Auto-refresh the display | |
| self._refresh_retranslation_data(data) | |
| QMessageBox.information(data.get('dialog', self), "Success", f"Removed failed mark from {cleared_count} chapters.") | |
| def retranslate_selected(): | |
| selected_items = data['listbox'].selectedItems() | |
| if not selected_items: | |
| QMessageBox.warning(data.get('dialog', self), "No Selection", "Please select at least one chapter.") | |
| return | |
| # Do NOT dedup here; it can collapse distinct chapters sharing filenames | |
| selected_indices = [data['listbox'].row(item) for item in selected_items] | |
| selected_chapters = [data['chapter_display_info'][i] for i in selected_indices] | |
| # Count different types | |
| missing_count = sum(1 for ch in selected_chapters if ch['status'] == 'not_translated') | |
| existing_count = sum(1 for ch in selected_chapters if ch['status'] != 'not_translated') | |
| count = len(selected_chapters) | |
| if count > 10: | |
| if missing_count > 0 and existing_count > 0: | |
| confirm_msg = f"This will:\n• Mark {missing_count} missing chapters for translation\n• Delete and retranslate {existing_count} existing chapters\n\nTotal: {count} chapters\n\nContinue?" | |
| elif missing_count > 0: | |
| confirm_msg = f"This will mark {missing_count} missing chapters for translation.\n\nContinue?" | |
| else: | |
| confirm_msg = f"This will delete {existing_count} translated chapters and mark them for retranslation.\n\nContinue?" | |
| else: | |
| chapters = [f"Ch.{ch['num']}" for ch in selected_chapters] | |
| confirm_msg = f"This will process:\n\n{', '.join(chapters)}\n\n" | |
| if missing_count > 0: | |
| confirm_msg += f"• {missing_count} missing chapters will be marked for translation\n" | |
| if existing_count > 0: | |
| confirm_msg += f"• {existing_count} existing chapters will be deleted and retranslated\n" | |
| confirm_msg += "\nContinue?" | |
| reply = QMessageBox.question(data.get('dialog', self), "Confirm Retranslation", confirm_msg, | |
| QMessageBox.Yes | QMessageBox.No) | |
| if reply != QMessageBox.Yes: | |
| return | |
| # Process chapters - DELETE FILES AND UPDATE PROGRESS | |
| deleted_count = 0 | |
| marked_count = 0 | |
| status_reset_count = 0 | |
| merged_cleared_count = 0 | |
| progress_updated = False | |
| for ch_info in selected_chapters: | |
| output_file = ch_info['output_file'] | |
| actual_num = ch_info['num'] | |
| progress_key = ch_info.get('progress_key') | |
| if ch_info['status'] != 'not_translated': | |
| # Reset status to pending for ALL non-not_translated chapters, but only if we can match the exact progress entry | |
| match = None | |
| if progress_key and progress_key in data['prog']["chapters"]: | |
| match = (progress_key, data['prog']["chapters"][progress_key]) | |
| else: | |
| match = _find_progress_entry(ch_info, data['prog']) | |
| old_status = ch_info['status'] | |
| if match: | |
| # Delete existing file only after we know which entry to update | |
| if output_file: | |
| output_path = os.path.join(data['output_dir'], output_file) | |
| try: | |
| if os.path.exists(output_path): | |
| os.remove(output_path) | |
| deleted_count += 1 | |
| print(f"Deleted: {output_path}") | |
| except Exception as e: | |
| print(f"Failed to delete {output_path}: {e}") | |
| chapter_key, ch_entry = match | |
| target_output_file = ch_entry.get('output_file') or ch_info['output_file'] | |
| print(f"Resetting {old_status} status to pending for chapter {actual_num} (key: {chapter_key}, output file: {target_output_file})") | |
| ch_entry["status"] = "pending" | |
| ch_entry["failure_reason"] = "" | |
| ch_entry["error_message"] = "" | |
| progress_updated = True | |
| status_reset_count += 1 | |
| else: | |
| print(f"WARNING: Could not find exact progress entry for {output_file}; skipped deletion and status reset") | |
| # MERGED CHILDREN FIX: Clear any merged children of this chapter | |
| # ONLY clear children that still have "merged" status | |
| # If split-the-merge succeeded, children will have their own status (completed/qa_failed) | |
| # and should NOT be deleted when parent is retranslated | |
| for child_key, child_data in list(data['prog']["chapters"].items()): | |
| child_status = child_data.get("status") | |
| if child_status == "merged" and child_data.get("merged_parent_chapter") == actual_num: | |
| child_actual_num = child_data.get("actual_num") | |
| print(f"🔓 Clearing merged status for child chapter {child_actual_num} (parent {actual_num} being retranslated)") | |
| del data['prog']["chapters"][child_key] | |
| merged_cleared_count += 1 | |
| progress_updated = True | |
| else: | |
| # Just marking for translation (no file to delete) | |
| marked_count += 1 | |
| # Save the updated progress if we made changes | |
| if progress_updated: | |
| try: | |
| with open(data['progress_file'], 'w', encoding='utf-8') as f: | |
| json.dump(data['prog'], f, ensure_ascii=False, indent=2) | |
| print(f"Updated progress tracking file - reset {status_reset_count} chapter statuses to pending") | |
| except Exception as e: | |
| print(f"Failed to update progress file: {e}") | |
| # Auto-refresh the display to show updated status | |
| data['skip_cleanup'] = True # Disable cleanup for this dialog after retranslate to avoid deleting pending/failed | |
| self._refresh_retranslation_data(data) | |
| # Build success message | |
| success_parts = [] | |
| if deleted_count > 0: | |
| success_parts.append(f"Deleted {deleted_count} files") | |
| if marked_count > 0: | |
| success_parts.append(f"marked {marked_count} missing chapters for translation") | |
| if status_reset_count > 0: | |
| success_parts.append(f"reset {status_reset_count} chapter statuses to pending") | |
| if merged_cleared_count > 0: | |
| success_parts.append(f"cleared {merged_cleared_count} merged child chapters") | |
| if success_parts: | |
| success_msg = "Successfully " + ", ".join(success_parts) + "." | |
| if deleted_count > 0 or marked_count > 0 or merged_cleared_count > 0: | |
| total_to_translate = len(selected_indices) + merged_cleared_count | |
| success_msg += f"\n\nTotal {total_to_translate} chapters ready for translation." | |
| QMessageBox.information(data.get('dialog', self), "Success", success_msg) | |
| else: | |
| QMessageBox.information(data.get('dialog', self), "Info", "No changes made.") | |
| # Add buttons - First row | |
| btn_select_all = QPushButton("Select All") | |
| btn_select_all.setMinimumHeight(32) | |
| btn_select_all.setStyleSheet("QPushButton { background-color: #17a2b8; color: white; padding: 6px 16px; font-weight: bold; font-size: 10pt; }") | |
| btn_select_all.clicked.connect(select_all) | |
| button_layout.addWidget(btn_select_all, 0, 0) | |
| btn_clear = QPushButton("Clear") | |
| btn_clear.setMinimumHeight(32) | |
| btn_clear.setStyleSheet("QPushButton { background-color: #6c757d; color: white; padding: 6px 16px; font-weight: bold; font-size: 10pt; }") | |
| btn_clear.clicked.connect(clear_selection) | |
| button_layout.addWidget(btn_clear, 0, 1) | |
| btn_select_completed = QPushButton("Select Completed") | |
| btn_select_completed.setMinimumHeight(32) | |
| btn_select_completed.setStyleSheet("QPushButton { background-color: #28a745; color: white; padding: 6px 16px; font-weight: bold; font-size: 10pt; }") | |
| btn_select_completed.clicked.connect(lambda: select_status('completed')) | |
| button_layout.addWidget(btn_select_completed, 0, 2) | |
| btn_select_qa_failed = QPushButton("Select QA Failed") | |
| btn_select_qa_failed.setMinimumHeight(32) | |
| # Use red for QA Failed | |
| btn_select_qa_failed.setStyleSheet("QPushButton { background-color: #dc3545; color: white; padding: 6px 16px; font-weight: bold; font-size: 10pt; }") | |
| btn_select_qa_failed.clicked.connect(lambda: select_status('qa_failed')) | |
| button_layout.addWidget(btn_select_qa_failed, 0, 3) | |
| btn_select_failed = QPushButton("Select Failed") | |
| btn_select_failed.setMinimumHeight(32) | |
| # Use red for Failed / QA Failed | |
| btn_select_failed.setStyleSheet("QPushButton { background-color: #dc3545; color: white; padding: 6px 16px; font-weight: bold; font-size: 10pt; }") | |
| btn_select_failed.clicked.connect(lambda: select_status('failed')) | |
| button_layout.addWidget(btn_select_failed, 0, 4) | |
| # Second row | |
| btn_retranslate = QPushButton("Retranslate Selected") | |
| btn_retranslate.setMinimumHeight(32) | |
| btn_retranslate.setStyleSheet("QPushButton { background-color: #d39e00; color: white; padding: 6px 16px; font-weight: bold; font-size: 10pt; }") | |
| btn_retranslate.clicked.connect(retranslate_selected) | |
| button_layout.addWidget(btn_retranslate, 1, 0, 1, 2) | |
| btn_remove_qa = QPushButton("Remove QA Failed Mark") | |
| btn_remove_qa.setMinimumHeight(32) | |
| btn_remove_qa.setStyleSheet("QPushButton { background-color: #28a745; color: white; padding: 6px 16px; font-weight: bold; font-size: 10pt; }") | |
| btn_remove_qa.clicked.connect(remove_qa_failed_mark) | |
| button_layout.addWidget(btn_remove_qa, 1, 2, 1, 1) | |
| # Add animated refresh button | |
| btn_refresh = AnimatedRefreshButton(" Refresh") # Double space for icon padding | |
| btn_refresh.setMinimumHeight(32) | |
| btn_refresh.setStyleSheet( | |
| "QPushButton { " | |
| "background-color: #17a2b8; " | |
| "color: white; " | |
| "padding: 6px 16px; " | |
| "font-weight: bold; " | |
| "font-size: 10pt; " | |
| "}" | |
| ) | |
| # Create refresh handler with animation | |
| def animated_refresh(): | |
| import time | |
| btn_refresh.start_animation() | |
| btn_refresh.setEnabled(False) | |
| # Track start time for minimum animation duration | |
| start_time = time.time() | |
| min_animation_duration = 0.8 # 800ms minimum | |
| # A token to prevent older timers from firing after a newer refresh click | |
| refresh_token = time.time() | |
| data['_last_refresh_token'] = refresh_token | |
| def _rebuild_gui_from_refresh(): | |
| """Recreate the retranslation GUI if refresh appears to have failed to render.""" | |
| try: | |
| dlg = data.get('dialog') | |
| # Best-effort capture current toggle state | |
| show_special = data.get('show_special_files_state', False) | |
| cb = data.get('show_special_files_cb') | |
| if cb: | |
| try: | |
| show_special = cb.isChecked() | |
| except RuntimeError: | |
| pass | |
| # Multi-file dialog: destroy and recreate the whole multi-tab window | |
| if dlg and hasattr(dlg, '_tab_data'): | |
| selection = None | |
| if hasattr(self, '_multi_file_selection_key') and self._multi_file_selection_key: | |
| try: | |
| selection = list(self._multi_file_selection_key) | |
| except Exception: | |
| selection = None | |
| def do_multi_rebuild(): | |
| try: | |
| # Clear cached multi-file dialog so the recreate path is taken | |
| if hasattr(self, '_multi_file_retranslation_dialog'): | |
| self._multi_file_retranslation_dialog = None | |
| if hasattr(self, '_multi_file_selection_key'): | |
| self._multi_file_selection_key = None | |
| try: | |
| dlg.hide() | |
| except Exception: | |
| pass | |
| try: | |
| dlg.deleteLater() | |
| except Exception: | |
| pass | |
| if selection is not None: | |
| self.selected_files = selection | |
| self._force_retranslation_multiple_files() | |
| except Exception as e: | |
| print(f"Error during multi-file rebuild: {e}") | |
| QTimer.singleShot(0, do_multi_rebuild) | |
| return | |
| # Single-file dialog: remove cached entry and recreate | |
| file_path = data.get('file_path') | |
| if not file_path: | |
| return | |
| file_key = os.path.abspath(file_path) | |
| if hasattr(self, '_retranslation_dialog_cache') and file_key in self._retranslation_dialog_cache: | |
| try: | |
| del self._retranslation_dialog_cache[file_key] | |
| except Exception: | |
| pass | |
| old_dlg = dlg | |
| def do_single_rebuild(): | |
| try: | |
| if old_dlg: | |
| try: | |
| old_dlg.hide() | |
| except Exception: | |
| pass | |
| try: | |
| old_dlg.deleteLater() | |
| except Exception: | |
| pass | |
| self._force_retranslation_epub_or_text(file_path, show_special_files_state=show_special) | |
| except Exception as e: | |
| print(f"Error during rebuild: {e}") | |
| QTimer.singleShot(0, do_single_rebuild) | |
| except Exception as e: | |
| print(f"Error during rebuild: {e}") | |
| # Use QTimer to run refresh after animation starts | |
| def do_refresh(): | |
| try: | |
| # Check if this is part of a multi-tab dialog and refresh all tabs, otherwise just refresh current | |
| if data.get('dialog') and hasattr(data['dialog'], '_tab_data'): | |
| self._refresh_all_tabs(data['dialog']._tab_data) | |
| else: | |
| self._refresh_retranslation_data(data) | |
| # Schedule watchdog: if after 3 seconds there are still no visible entries, | |
| # but our data says there should be, rebuild the GUI. | |
| def watchdog_check(): | |
| try: | |
| if data.get('_last_refresh_token') != refresh_token: | |
| return # superseded by a newer refresh | |
| expected_total = len(data.get('chapter_display_info', []) or []) | |
| if expected_total <= 0: | |
| return | |
| listbox = data.get('listbox') | |
| if not listbox: | |
| _rebuild_gui_from_refresh() | |
| return | |
| try: | |
| count = listbox.count() | |
| except RuntimeError: | |
| _rebuild_gui_from_refresh() | |
| return | |
| visible = 0 | |
| try: | |
| for i in range(count): | |
| item = listbox.item(i) | |
| if item is not None and not item.isHidden(): | |
| visible += 1 | |
| except RuntimeError: | |
| _rebuild_gui_from_refresh() | |
| return | |
| if visible > 0: | |
| return | |
| # Don't rebuild if everything is hidden purely due to the special-files filter. | |
| try: | |
| show_special = data.get('show_special_files_state', False) | |
| cb = data.get('show_special_files_cb') | |
| if cb: | |
| show_special = cb.isChecked() | |
| if not show_special: | |
| infos = data.get('chapter_display_info', []) or [] | |
| if infos and all(bool(info.get('is_special', False)) for info in infos): | |
| return | |
| except Exception: | |
| pass | |
| _rebuild_gui_from_refresh() | |
| except Exception as e: | |
| print(f"Watchdog check error: {e}") | |
| QTimer.singleShot(3000, watchdog_check) | |
| # Calculate remaining time to meet minimum animation duration | |
| elapsed = time.time() - start_time | |
| remaining = max(0, min_animation_duration - elapsed) | |
| # Schedule animation stop after remaining time | |
| def finish_animation(): | |
| btn_refresh.stop_animation() | |
| btn_refresh.setEnabled(True) | |
| if remaining > 0: | |
| QTimer.singleShot(int(remaining * 1000), finish_animation) | |
| else: | |
| finish_animation() | |
| except Exception as e: | |
| print(f"Error during refresh: {e}") | |
| btn_refresh.stop_animation() | |
| btn_refresh.setEnabled(True) | |
| QTimer.singleShot(50, do_refresh) # Small delay to let animation start | |
| btn_refresh.clicked.connect(animated_refresh) | |
| button_layout.addWidget(btn_refresh, 1, 3, 1, 1) | |
| # Expose refresh handler for external triggers (e.g., Progress Manager reopen) | |
| data['refresh_func'] = animated_refresh | |
| if data.get('dialog'): | |
| setattr(data['dialog'], '_refresh_func', animated_refresh) | |
| # ==== Context menu on listbox ==== | |
| listbox = data['listbox'] | |
| listbox.setContextMenuPolicy(Qt.CustomContextMenu) | |
| def _open_file_for_item(item): | |
| info_meta = item.data(Qt.UserRole) or {} | |
| meta = info_meta.get('info', info_meta) or {} | |
| output_file = meta.get('output_file') | |
| if not output_file: | |
| self._show_message('error', "File Missing", "No output file recorded for this entry.", parent=data.get('dialog', self)) | |
| return | |
| path = os.path.join(data['output_dir'], output_file) | |
| if not os.path.exists(path): | |
| self._show_message('error', "File Missing", f"File not found:\n{path}", parent=data.get('dialog', self)) | |
| return | |
| try: | |
| QDesktopServices.openUrl(QUrl.fromLocalFile(path)) | |
| except Exception as e: | |
| self._show_message('error', "Open Failed", str(e), parent=data.get('dialog', self)) | |
| def show_context_menu(pos): | |
| item = listbox.itemAt(pos) | |
| if not item: | |
| return | |
| # Check for missing images in QA issues | |
| info_wrapper = item.data(Qt.UserRole) | |
| display_info = info_wrapper.get('info', {}) | |
| # The actual progress entry is nested inside 'info' key of display_info | |
| progress_entry = display_info.get('info', {}) | |
| # qa_issues is a boolean flag; the actual list is qa_issues_found | |
| qa_issues = progress_entry.get('qa_issues_found', []) | |
| if not isinstance(qa_issues, list): | |
| qa_issues = [] | |
| has_missing_images = any('missing_images' in str(issue) for issue in qa_issues) | |
| # Fallback: Check item text directly as it definitely contains the issue if visible | |
| if not has_missing_images and item and 'missing_images' in item.text(): | |
| has_missing_images = True | |
| print("DEBUG: Detected missing_images via list item text") | |
| # Determine file path for Notepad action | |
| _output_file = display_info.get('output_file') | |
| qa_file_path = os.path.join(data['output_dir'], _output_file) if _output_file else None | |
| menu = QMenu(listbox) | |
| # Remove extra left gutter reserved for icons to avoid empty space | |
| menu.setStyleSheet( | |
| "QMenu {" | |
| " padding: 4px;" | |
| " background-color: #2b2b2b;" | |
| " color: white;" | |
| " border: 1px solid #5a9fd4;" | |
| "} " | |
| "QMenu::icon { width: 0px; } " | |
| "QMenu::item {" | |
| " padding: 6px 12px;" | |
| " background-color: transparent;" | |
| "} " | |
| "QMenu::item:selected {" | |
| " background-color: #17a2b8;" | |
| " color: white;" | |
| "} " | |
| "QMenu::item:pressed {" | |
| " background-color: #138496;" | |
| "}" | |
| ) | |
| act_open = menu.addAction("📂 Open File") | |
| act_notepad_qa = None | |
| if qa_file_path: | |
| _label = "✏️ Edit File (find QA issue)" if qa_issues else "✏️ Edit File" | |
| act_notepad_qa = menu.addAction(_label) | |
| act_retranslate = menu.addAction("🔁 Retranslate Selected") | |
| act_insert_img = None | |
| if has_missing_images: | |
| act_insert_img = menu.addAction("🖼️ Insert Missing Image") | |
| act_remove_qa = menu.addAction("🧹 Remove QA Failed Mark") | |
| chosen = menu.exec(listbox.mapToGlobal(pos)) | |
| if chosen == act_open: | |
| _open_file_for_item(item) | |
| elif chosen == act_retranslate: | |
| retranslate_selected() | |
| elif act_insert_img and chosen == act_insert_img: | |
| # IN-PLACE RESTORATION LOGIC | |
| try: | |
| from bs4 import BeautifulSoup | |
| import zipfile | |
| import scan_html_folder # Use the helper module | |
| # Helper function for restoration (local version to ensure self-contained logic) | |
| def emergency_restore_images_local(text, original_html): | |
| if not original_html or not text: return text | |
| try: | |
| soup_orig = BeautifulSoup(original_html, 'html.parser') | |
| soup_text = BeautifulSoup(text, 'html.parser') | |
| orig_images = soup_orig.find_all('img') | |
| text_images = soup_text.find_all('img') | |
| if not orig_images or len(text_images) >= len(orig_images): | |
| return text | |
| present_srcs = set(img.get('src') for img in text_images if img.get('src')) | |
| missing_images = [] | |
| for img in orig_images: | |
| src = img.get('src') | |
| if src and src not in present_srcs: | |
| missing_images.append((src, img)) | |
| if not missing_images: return text | |
| source_str = str(original_html) | |
| text_str = str(text) | |
| text_chars = list(text_str) | |
| offset = 0 | |
| # Sort by position in source | |
| missing_images.sort(key=lambda x: source_str.find(x[0]) if x[0] in source_str else -1) | |
| for src, orig_img in missing_images: | |
| img_tag_str = str(orig_img) | |
| source_pos = source_str.find(img_tag_str) | |
| if source_pos == -1: source_pos = source_str.find(src) | |
| if source_pos != -1: | |
| relative_pos = source_pos / len(source_str) | |
| target_pos = int(len(text_str) * relative_pos) | |
| # Find paragraph break | |
| best_pos = target_pos | |
| min_dist = len(text_str) | |
| # Use simpler regex search | |
| for match in re.finditer(r'</p>|<br/?>|\n\n', text_str): | |
| end_pos = match.end() | |
| dist = abs(end_pos - target_pos) | |
| if dist < min_dist: | |
| min_dist = dist | |
| best_pos = end_pos | |
| insert_pos = best_pos + offset | |
| if insert_pos > len(text_chars): insert_pos = len(text_chars) | |
| insertion = f"\n<p>{img_tag_str}</p>\n" | |
| text_chars[insert_pos:insert_pos] = list(insertion) | |
| offset += len(insertion) | |
| return "".join(text_chars) | |
| except Exception as e: | |
| print(f"Restoration error: {e}") | |
| return text | |
| # 1. Get Source Content | |
| epub_path = data['file_path'] | |
| # Use filename matching locally since scan_html_folder helper isn't available | |
| original_filename = display_info.get('original_filename') | |
| source_html = None | |
| if original_filename: | |
| try: | |
| def normalize_name(n): | |
| base = os.path.basename(n) | |
| if base.startswith('response_'): | |
| base = base[9:] | |
| return os.path.splitext(base)[0].lower() | |
| target_base = normalize_name(original_filename) | |
| with zipfile.ZipFile(epub_path, 'r') as zf: | |
| for fname in zf.namelist(): | |
| if normalize_name(fname) == target_base: | |
| source_html = zf.read(fname).decode('utf-8', errors='ignore') | |
| break | |
| except Exception as ex: | |
| print(f"Extraction error: {ex}") | |
| if not source_html: | |
| self._show_message('error', "Error", "Could not extract source HTML for this chapter.") | |
| else: | |
| # 2. Get Translated Content | |
| output_file = display_info.get('output_file') | |
| output_path = os.path.join(data['output_dir'], output_file) | |
| if os.path.exists(output_path): | |
| with open(output_path, 'r', encoding='utf-8') as f: | |
| translated_html = f.read() | |
| # 3. Restore | |
| restored_html = emergency_restore_images_local(translated_html, source_html) | |
| if restored_html != translated_html: | |
| # 4. Save | |
| with open(output_path, 'w', encoding='utf-8') as f: | |
| f.write(restored_html) | |
| # 5. Update Progress (Clear QA flags) | |
| # Search for the entry by filename to ensure persistence | |
| # This avoids relying on internal _key injection if it's unreliable | |
| found_key = None | |
| target_out = display_info.get('output_file') | |
| if target_out: | |
| for k, v in data['prog'].get('chapters', {}).items(): | |
| if v.get('output_file') == target_out: | |
| found_key = k | |
| break | |
| if found_key: | |
| real_entry = data['prog']['chapters'][found_key] | |
| real_entry['status'] = 'completed' | |
| for key in ['qa_issues', 'qa_issues_found', 'qa_timestamp', 'failure_reason', 'error_message']: | |
| real_entry.pop(key, None) | |
| print(f"DEBUG: Updated progress entry {found_key} (matched by filename)") | |
| else: | |
| # Fallback: modify the object we have | |
| print(f"DEBUG: Could not find entry by filename '{target_out}', modifying object directly") | |
| progress_entry['status'] = 'completed' | |
| for key in ['qa_issues', 'qa_issues_found', 'qa_timestamp', 'failure_reason', 'error_message']: | |
| progress_entry.pop(key, None) | |
| # Save progress | |
| with open(data['progress_file'], 'w', encoding='utf-8') as f: | |
| json.dump(data['prog'], f, ensure_ascii=False, indent=2) | |
| # 6. Refresh | |
| self._refresh_retranslation_data(data) | |
| self._show_message('info', "Success", "Images restored and QA flags cleared.") | |
| else: | |
| self._show_message('info', "Info", "No missing images could be automatically restored.") | |
| else: | |
| self._show_message('error', "Error", "Output file not found.") | |
| except Exception as e: | |
| self._show_message('error', "Error", f"Failed to restore images: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| elif chosen == act_remove_qa: | |
| remove_qa_failed_mark() | |
| elif act_notepad_qa and chosen == act_notepad_qa: | |
| search_term = None | |
| _line_num = 1 | |
| if qa_issues: | |
| # Extract a meaningful search term from the QA issue strings | |
| # Try all common delimiter styles in order | |
| _QUOTE_PATTERNS = [ | |
| r"'([^']+)'", # single quotes: 'text' | |
| r'"([^"]+)"', # double quotes: "text" | |
| r"\u201c([^\u201d]+)\u201d", # curly double quotes: “text” | |
| r"\u2018([^\u2019]+)\u2019", # curly single quotes: ‘text’ | |
| r"\u300c([^\u300d]+)\u300d", # Japanese corner brackets: 「text」 | |
| r"\u300e([^\u300f]+)\u300f", # Japanese white corner brackets: 『text』 | |
| r"\uff62([^\uff63]+)\uff63", # Halfwidth corner brackets | |
| r"\[([^\]]+)\]", # square brackets: [text] | |
| r"\(([^)]+)\)", # parentheses: (text) | |
| ] | |
| for _issue in qa_issues: | |
| _s = str(_issue) | |
| for _pat in _QUOTE_PATTERNS: | |
| _m = re.search(_pat, _s) | |
| if _m and _m.group(1).strip(): | |
| search_term = _m.group(1) | |
| break | |
| if search_term: | |
| break | |
| # Fallback: scan file for any non-ASCII sequence | |
| if not search_term: | |
| try: | |
| with open(qa_file_path, 'r', encoding='utf-8', errors='ignore') as _f: | |
| _content = _f.read() | |
| _m = re.search(r'[^\x00-\x7f]{1,30}', _content) | |
| if _m: | |
| search_term = _m.group(0) | |
| except Exception: | |
| pass | |
| # Find line number of search term in file | |
| # Try progressively shorter prefixes in case the QA term is truncated | |
| if search_term and os.path.exists(qa_file_path): | |
| try: | |
| with open(qa_file_path, 'r', encoding='utf-8', errors='ignore') as _f: | |
| _lines = _f.readlines() | |
| # Strip surrounding quote/bracket chars so we search raw content | |
| _STRIP_QUOTES = '\'"「」『』“”‘’「」《》〈〉()' | |
| _bare = search_term.strip(_STRIP_QUOTES) | |
| _base = _bare if _bare else search_term | |
| # Build candidates: full bare term, then shrinking prefixes (min 1 char) | |
| _candidates = [_base[:_l] for _l in range(len(_base), 0, -1)] | |
| for _cand in _candidates: | |
| for _i, _ln in enumerate(_lines, 1): | |
| if _cand in _ln: | |
| _line_num = _i | |
| break | |
| if _line_num > 1: | |
| break | |
| except Exception: | |
| pass | |
| # Copy search term to clipboard | |
| if search_term: | |
| from PySide6.QtWidgets import QApplication | |
| QApplication.clipboard().setText(search_term) | |
| # Open file in best available editor, jumping to line if supported | |
| try: | |
| if sys.platform == 'win32': | |
| _npp_paths = [ | |
| r'C:\Program Files\Notepad++\notepad++.exe', | |
| r'C:\Program Files (x86)\Notepad++\notepad++.exe', | |
| ] | |
| _npp = next((p for p in _npp_paths if os.path.exists(p)), None) | |
| if _npp: | |
| subprocess.Popen([_npp, f'-n{_line_num}', qa_file_path]) | |
| else: | |
| subprocess.Popen(['notepad.exe', qa_file_path]) | |
| elif sys.platform == 'darwin': | |
| # Try TextEdit alternatives that support line jumping | |
| if shutil.which('code'): | |
| subprocess.Popen(['code', '--goto', f'{qa_file_path}:{_line_num}']) | |
| else: | |
| subprocess.Popen(['open', '-t', qa_file_path]) | |
| else: | |
| # Linux: try editors with line-jump support first | |
| if shutil.which('gedit'): | |
| subprocess.Popen(['gedit', f'+{_line_num}', qa_file_path]) | |
| elif shutil.which('kate'): | |
| subprocess.Popen(['kate', '-l', str(_line_num), qa_file_path]) | |
| elif shutil.which('code'): | |
| subprocess.Popen(['code', '--goto', f'{qa_file_path}:{_line_num}']) | |
| else: | |
| _linux_editors = ['mousepad', 'xed', 'pluma', 'nano', 'xdg-open'] | |
| _editor = next((e for e in _linux_editors if shutil.which(e)), 'xdg-open') | |
| subprocess.Popen([_editor, qa_file_path]) | |
| except Exception as _e: | |
| self._show_message('error', "Open Failed", f"Could not open editor:\n{_e}", | |
| parent=data.get('dialog', self)) | |
| listbox.customContextMenuRequested.connect(show_context_menu) | |
| btn_cancel = QPushButton("Cancel") | |
| btn_cancel.setMinimumHeight(32) | |
| btn_cancel.setStyleSheet("QPushButton { background-color: #6c757d; color: white; padding: 6px 16px; font-weight: bold; font-size: 10pt; }") | |
| btn_cancel.clicked.connect(lambda: data['dialog'].close() if data.get('dialog') else None) | |
| button_layout.addWidget(btn_cancel, 1, 4, 1, 1) | |
| # Automatically refresh once when dialog is opened | |
| animated_refresh() | |
| def _refresh_all_tabs(self, tab_data_list): | |
| """Refresh all tabs in a multi-file retranslation dialog""" | |
| try: | |
| print(f"🔄 Refreshing all {len(tab_data_list)} tabs...") | |
| refreshed_count = 0 | |
| skipped_count = 0 | |
| for idx, data in enumerate(tab_data_list): | |
| if data and data.get('type') != 'image_folder' and data.get('type') != 'individual_images': | |
| # Only refresh EPUB/text tabs | |
| try: | |
| # Check if widgets are still valid before attempting refresh | |
| if not self._is_data_valid(data): | |
| print(f"[DEBUG] Skipping tab {idx + 1}/{len(tab_data_list)} - widgets deleted") | |
| skipped_count += 1 | |
| continue | |
| print(f"[DEBUG] Refreshing tab {idx + 1}/{len(tab_data_list)}") | |
| self._refresh_retranslation_data(data) | |
| refreshed_count += 1 | |
| except RuntimeError as e: | |
| # Widget was deleted | |
| print(f"[WARN] Skipping tab {idx + 1} - widget deleted: {e}") | |
| skipped_count += 1 | |
| except Exception as e: | |
| print(f"[ERROR] Failed to refresh tab {idx + 1}: {e}") | |
| if skipped_count > 0: | |
| print(f"✅ Successfully refreshed {refreshed_count} tab(s), skipped {skipped_count} deleted tab(s)") | |
| else: | |
| print(f"✅ Successfully refreshed {refreshed_count} tab(s)") | |
| except Exception as e: | |
| print(f"❌ Failed to refresh all tabs: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| def _is_data_valid(self, data): | |
| """Check if the data structure has valid (non-deleted) widgets""" | |
| try: | |
| if not data: | |
| return False | |
| # Check if listbox exists and is still valid | |
| listbox = data.get('listbox') | |
| if not listbox: | |
| return False | |
| # Try to access a simple property to check if widget is still alive | |
| # This will raise RuntimeError if the C++ object was deleted | |
| listbox.count() | |
| return True | |
| except (RuntimeError, AttributeError): | |
| return False | |
| def _refresh_retranslation_data(self, data): | |
| """Refresh the retranslation dialog data by reloading progress and updating display""" | |
| try: | |
| # First check if widgets are still valid | |
| if not self._is_data_valid(data): | |
| print("⚠️ Cannot refresh - widgets have been deleted") | |
| return | |
| # If the output override directory changed while the dialog is open, | |
| # re-resolve output_dir/progress_file so we don't keep reading the old progress JSON. | |
| try: | |
| file_path = data.get('file_path') | |
| if file_path: | |
| epub_base = os.path.splitext(os.path.basename(file_path))[0] | |
| override_dir = (os.environ.get('OUTPUT_DIRECTORY') or os.environ.get('OUTPUT_DIR')) | |
| if not override_dir and hasattr(self, 'config'): | |
| try: | |
| override_dir = self.config.get('output_directory') | |
| except Exception: | |
| override_dir = None | |
| expected_output_dir = os.path.join(override_dir, epub_base) if override_dir else epub_base | |
| expected_progress_file = os.path.join(expected_output_dir, "translation_progress.json") | |
| # Update in-place if changed | |
| if expected_output_dir and data.get('output_dir') != expected_output_dir: | |
| data['output_dir'] = expected_output_dir | |
| if expected_progress_file and data.get('progress_file') != expected_progress_file: | |
| data['progress_file'] = expected_progress_file | |
| # Keep cache consistent too (if present) | |
| try: | |
| file_key = os.path.abspath(file_path) | |
| if hasattr(self, '_retranslation_dialog_cache') and file_key in self._retranslation_dialog_cache: | |
| cached = self._retranslation_dialog_cache[file_key] | |
| if isinstance(cached, dict): | |
| cached['output_dir'] = data.get('output_dir') | |
| cached['progress_file'] = data.get('progress_file') | |
| except Exception: | |
| pass | |
| except Exception as e: | |
| print(f"[WARN] Could not re-resolve output override on refresh: {e}") | |
| # Save current scroll position (and first visible row/offset) to restore after refresh | |
| saved_scroll = None | |
| updates_were_enabled = True | |
| signals_were_blocked = False | |
| self._suspend_yield = True | |
| first_visible_row = None | |
| first_visible_offset = 0 | |
| if 'listbox' in data and data['listbox']: | |
| try: | |
| from PySide6.QtCore import QPoint | |
| saved_scroll = data['listbox'].verticalScrollBar().value() | |
| updates_were_enabled = data['listbox'].updatesEnabled() | |
| signals_were_blocked = data['listbox'].signalsBlocked() | |
| idx = data['listbox'].indexAt(QPoint(0, 0)) | |
| if idx and idx.isValid(): | |
| first_visible_row = idx.row() | |
| rect = data['listbox'].visualItemRect(data['listbox'].item(first_visible_row)) | |
| first_visible_offset = -rect.top() | |
| data['listbox'].blockSignals(True) | |
| data['listbox'].setUpdatesEnabled(False) | |
| except Exception: | |
| saved_scroll = None | |
| # Save current selections to restore after refresh | |
| selected_indices = [] | |
| try: | |
| selected_indices = [data['listbox'].row(item) for item in data['listbox'].selectedItems()] | |
| except RuntimeError: | |
| print("⚠️ Could not save selection state - widget was deleted") | |
| return | |
| # Reload progress file - check if it exists first | |
| if not os.path.exists(data['progress_file']): | |
| print(f"⚠️ Progress file not found: {data['progress_file']}") | |
| # Recreate a minimal progress file and auto-discover completed files from output_dir | |
| prog = { | |
| "chapters": {}, | |
| "chapter_chunks": {}, | |
| "version": "2.1" | |
| } | |
| def _auto_discover_from_output_dir(output_dir, prog): | |
| updated = False | |
| try: | |
| files = [ | |
| f for f in os.listdir(output_dir) | |
| if os.path.isfile(os.path.join(output_dir, f)) | |
| # accept any extension except .epub | |
| and not f.lower().endswith("_translated.txt") | |
| and f != "translation_progress.json" | |
| and not f.lower().endswith(".epub") | |
| ] | |
| for fname in files: | |
| base = os.path.basename(fname) | |
| if base.startswith("response_"): | |
| base = base[len("response_"):] | |
| while True: | |
| new_base, ext = os.path.splitext(base) | |
| if not ext: | |
| break | |
| base = new_base | |
| import re | |
| m = re.findall(r"(\\d+)", base) | |
| chapter_num = int(m[-1]) if m else None | |
| key = str(chapter_num) if chapter_num is not None else f"special_{base}" | |
| actual_num = chapter_num if chapter_num is not None else 0 | |
| if key in prog.get("chapters", {}): | |
| continue | |
| prog.setdefault("chapters", {})[key] = { | |
| "actual_num": actual_num, | |
| "content_hash": "", | |
| "output_file": fname, | |
| "status": "completed", | |
| "last_updated": os.path.getmtime(os.path.join(output_dir, fname)), | |
| "auto_discovered": True, | |
| "original_basename": fname | |
| } | |
| updated = True | |
| except Exception as e: | |
| print(f"⚠️ Auto-discovery (refresh no OPF) failed: {e}") | |
| return updated | |
| if _auto_discover_from_output_dir(data['output_dir'], prog): | |
| print("💾 Recreated progress file via auto-discovery (refresh)") | |
| try: | |
| # Ensure the output directory exists (it may have been deleted) | |
| progress_dir = os.path.dirname(data['progress_file']) | |
| if progress_dir: | |
| os.makedirs(progress_dir, exist_ok=True) | |
| with open(data['progress_file'], 'w', encoding='utf-8') as f: | |
| json.dump(prog, f, ensure_ascii=False, indent=2) | |
| except Exception as e: | |
| QMessageBox.warning(data.get('dialog', self), "Progress File Error", | |
| f"Could not recreate progress file:\n{e}") | |
| return | |
| with open(data['progress_file'], 'r', encoding='utf-8') as f: | |
| data['prog'] = json.load(f) | |
| # Clean up missing files and merged children before display unless disabled | |
| if not data.get('skip_cleanup', False): | |
| from TransateKRtoEN import ProgressManager | |
| temp_progress = ProgressManager(os.path.dirname(data['progress_file'])) | |
| temp_progress.prog = data['prog'] | |
| temp_progress.cleanup_missing_files(data['output_dir']) | |
| data['prog'] = temp_progress.prog | |
| # Save the cleaned progress back to file | |
| with open(data['progress_file'], 'w', encoding='utf-8') as f: | |
| json.dump(data['prog'], f, ensure_ascii=False, indent=2) | |
| # Check if we're using OPF-based display or fallback | |
| if data.get('spine_chapters'): | |
| # OPF-based: Re-run full matching logic to update merged status correctly | |
| # We need to re-match spine chapters against the updated progress JSON | |
| self._rematch_spine_chapters(data) | |
| else: | |
| # Fallback mode: REBUILD chapter_display_info from scratch to pick up new entries | |
| # This is necessary for text files or EPUBs without OPF | |
| self._rebuild_chapter_display_info(data) | |
| # Note: chapter_display_info is already rebuilt/updated above | |
| # For OPF mode: _update_chapter_status_info updated existing entries | |
| # For fallback mode: _rebuild_chapter_display_info rebuilt from scratch | |
| # Update the listbox display | |
| self._update_listbox_display(data) | |
| # Update statistics if available | |
| self._update_statistics_display(data) | |
| # Ensure the special-files toggle is applied after every refresh. | |
| try: | |
| show_special = data.get('show_special_files_state', False) | |
| cb = data.get('show_special_files_cb') | |
| if cb: | |
| show_special = cb.isChecked() | |
| listbox = data.get('listbox') | |
| if listbox: | |
| for i in range(listbox.count()): | |
| item = listbox.item(i) | |
| if not item: | |
| continue | |
| meta = item.data(Qt.UserRole) or {} | |
| is_special = meta.get('is_special', False) | |
| item.setHidden(is_special and not show_special) | |
| data['show_special_files_state'] = show_special | |
| except Exception: | |
| pass | |
| # Restore scroll position and repaint immediately after rebuild | |
| if 'listbox' in data and data['listbox']: | |
| try: | |
| sb = data['listbox'].verticalScrollBar() | |
| if first_visible_row is not None and first_visible_row < data['listbox'].count(): | |
| item = data['listbox'].item(first_visible_row) | |
| data['listbox'].scrollToItem(item, data['listbox'].PositionAtTop) | |
| sb.setValue(sb.value() - first_visible_offset) | |
| elif saved_scroll is not None: | |
| target = min(saved_scroll, sb.maximum()) | |
| if sb.value() != target: | |
| sb.setValue(target) | |
| data['listbox'].setUpdatesEnabled(updates_were_enabled) | |
| data['listbox'].blockSignals(signals_were_blocked) | |
| data['listbox'].viewport().update() | |
| except Exception: | |
| try: | |
| data['listbox'].setUpdatesEnabled(updates_were_enabled) | |
| data['listbox'].blockSignals(signals_were_blocked) | |
| except Exception: | |
| pass | |
| self._suspend_yield = False | |
| # Restore selections | |
| try: | |
| if selected_indices: | |
| for idx in selected_indices: | |
| if idx < data['listbox'].count(): | |
| data['listbox'].item(idx).setSelected(True) | |
| # Update selection count | |
| if 'selection_count_label' in data and data['selection_count_label']: | |
| data['selection_count_label'].setText(f"Selected: {len(selected_indices)}") | |
| else: | |
| # Clear selections if there were none | |
| data['listbox'].clearSelection() | |
| if 'selection_count_label' in data and data['selection_count_label']: | |
| data['selection_count_label'].setText("Selected: 0") | |
| # Re-apply scroll AFTER selections (since selecting can auto-scroll) | |
| if saved_scroll is not None and 'listbox' in data and data['listbox']: | |
| from PySide6.QtCore import QTimer | |
| def _restore_scroll_again(): | |
| try: | |
| sb = data['listbox'].verticalScrollBar() | |
| target = min(saved_scroll, sb.maximum()) | |
| if sb.value() != target: | |
| sb.setValue(target) | |
| except Exception: | |
| pass | |
| QTimer.singleShot(0, _restore_scroll_again) | |
| except RuntimeError: | |
| print("⚠️ Could not restore selection state - widget was deleted during refresh") | |
| # print("✅ Retranslation data refreshed successfully") | |
| except RuntimeError as e: | |
| print(f"❌ Failed to refresh data - widget deleted: {e}") | |
| except FileNotFoundError as e: | |
| print(f"❌ Failed to refresh data - file not found: {e}") | |
| try: | |
| QMessageBox.information(data.get('dialog', self), "Output Folder Not Found", | |
| f"The output folder appears to have been deleted or moved.\n\n" | |
| f"File not found: {os.path.basename(str(e))}") | |
| except (RuntimeError, AttributeError): | |
| print(f"[WARN] Could not show error dialog - dialog was deleted") | |
| except Exception as e: | |
| print(f"❌ Failed to refresh data: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| try: | |
| # Show friendlier error message for common cases | |
| error_msg = str(e) | |
| if "No such file or directory" in error_msg or "cannot find the path" in error_msg: | |
| QMessageBox.information(data.get('dialog', self), "Output Folder Not Found", | |
| f"The output folder appears to have been deleted or moved.\n\n" | |
| f"Error: {error_msg}") | |
| else: | |
| QMessageBox.warning(data.get('dialog', self), "Refresh Failed", | |
| f"Failed to refresh data: {error_msg}") | |
| except (RuntimeError, AttributeError): | |
| # Dialog was also deleted, just print to console | |
| print(f"[WARN] Could not show error dialog - dialog was deleted") | |
| def _rematch_spine_chapters(self, data): | |
| """Re-run the full spine chapter matching logic against updated progress JSON""" | |
| prog = data['prog'] | |
| output_dir = data['output_dir'] | |
| spine_chapters = data['spine_chapters'] | |
| def _normalize_opf_match_name(name: str) -> str: | |
| if not name: | |
| return "" | |
| base = os.path.basename(name) | |
| if base.startswith("response_"): | |
| base = base[len("response_"):] | |
| while True: | |
| new_base, ext = os.path.splitext(base) | |
| if not ext: | |
| break | |
| base = new_base | |
| return base | |
| def _opf_names_equal(a: str, b: str) -> bool: | |
| return _normalize_opf_match_name(a) == _normalize_opf_match_name(b) | |
| # Build indexes once (O(n)) | |
| basename_to_progress = {} | |
| response_to_progress = {} | |
| actualnum_to_progress = {} | |
| composite_to_progress = {} | |
| chapters_dict = prog.get("chapters", {}) | |
| for ch in chapters_dict.values(): | |
| orig = ch.get("original_basename", "") | |
| out = ch.get("output_file", "") | |
| actual_num = ch.get("actual_num") | |
| if orig: | |
| basename_to_progress.setdefault(_normalize_opf_match_name(orig), []).append(ch) | |
| if out: | |
| response_to_progress.setdefault(out, []).append(ch) | |
| norm_out = _normalize_opf_match_name(out) | |
| if norm_out != out: | |
| response_to_progress.setdefault(norm_out, []).append(ch) | |
| if actual_num is not None: | |
| actualnum_to_progress.setdefault(actual_num, []).append(ch) | |
| fname_for_comp = orig or out | |
| if fname_for_comp and actual_num is not None: | |
| filename_noext = os.path.splitext(_normalize_opf_match_name(fname_for_comp))[0] | |
| composite_to_progress[f"{actual_num}_{filename_noext}"] = ch | |
| # Cache directory listing to avoid thousands of exists calls | |
| try: | |
| existing_files = {f for f in os.listdir(output_dir) if os.path.isfile(os.path.join(output_dir, f))} | |
| except Exception: | |
| existing_files = set() | |
| def file_exists_fast(fname: str) -> bool: | |
| return fname in existing_files | |
| for spine_ch in spine_chapters: | |
| filename = spine_ch['filename'] | |
| chapter_num = spine_ch['file_chapter_num'] | |
| is_special = spine_ch.get('is_special', False) | |
| base_name = os.path.splitext(filename)[0] | |
| retain = os.getenv('RETAIN_SOURCE_EXTENSION', '0') == '1' or self.config.get('retain_source_extension', False) | |
| if is_special: | |
| response_with_prefix = f"response_{base_name}.html" | |
| if retain: | |
| expected_response = filename | |
| elif file_exists_fast(response_with_prefix): | |
| expected_response = response_with_prefix | |
| else: | |
| expected_response = filename | |
| else: | |
| expected_response = filename if retain else filename | |
| matched_info = None | |
| basename_key = _normalize_opf_match_name(filename) | |
| # 1) original basename map | |
| lst = basename_to_progress.get(basename_key) | |
| if lst: | |
| for ch in lst: | |
| status = ch.get('status', '') | |
| if status in ['in_progress', 'failed', 'qa_failed', 'pending']: | |
| if ch.get('actual_num') == chapter_num: | |
| matched_info = ch | |
| break | |
| else: | |
| matched_info = ch | |
| break | |
| # 2) response map (choose highest severity, prefer matching chapter_num) | |
| if not matched_info: | |
| lookup_keys = [ | |
| expected_response, | |
| _normalize_opf_match_name(expected_response), | |
| f"response_{expected_response}" if not expected_response.startswith("response_") else expected_response, | |
| basename_key | |
| ] | |
| lst = None | |
| for k in lookup_keys: | |
| if k in response_to_progress: | |
| lst = response_to_progress[k] | |
| break | |
| if lst: | |
| has_qa = any(ch.get('status') == 'qa_failed' for ch in lst) | |
| if has_qa: | |
| lst = [ch for ch in lst if ch.get('status') != 'pending'] | |
| severity = {'qa_failed': 4, 'failed': 3, 'pending': 2, 'in_progress': 1, 'completed': 0} | |
| best = None | |
| best_score = -1 | |
| for ch in lst: | |
| status = ch.get('status', '') | |
| score = severity.get(status, -1) | |
| matches_num = ch.get('actual_num') == chapter_num | |
| if score > best_score or (score == best_score and matches_num): | |
| best = ch | |
| best_score = score | |
| # If exact chapter match and highest severity, keep going in case of even higher severity | |
| if best: | |
| matched_info = best | |
| # 3) composite key | |
| if not matched_info: | |
| filename_noext = base_name | |
| if filename_noext.startswith("response_"): | |
| filename_noext = filename_noext[len("response_"):] | |
| comp_key = f"{chapter_num}_{filename_noext}" | |
| matched_info = composite_to_progress.get(comp_key) | |
| # 4) actual_num map fallback (avoid mis-matching special files) | |
| if not matched_info and chapter_num in actualnum_to_progress: | |
| for ch in actualnum_to_progress[chapter_num]: | |
| status = ch.get('status', '') | |
| out_file = ch.get('output_file') | |
| orig_base = os.path.basename(ch.get('original_basename', '') or '') | |
| # If this spine entry is a special file (no digits), require filename match to avoid hijacking by other chapter 0 entries | |
| if is_special: | |
| fname_matches = ( | |
| (orig_base and _opf_names_equal(orig_base, filename)) or | |
| (out_file and (_opf_names_equal(out_file, expected_response) or out_file == expected_response)) | |
| ) | |
| if not fname_matches: | |
| continue | |
| if status == 'merged': | |
| if _opf_names_equal(orig_base, filename) or not orig_base: | |
| matched_info = ch | |
| break | |
| elif status in ['in_progress', 'failed', 'pending', 'qa_failed']: | |
| if out_file and (_opf_names_equal(out_file, expected_response) or out_file == expected_response): | |
| matched_info = ch | |
| break | |
| else: | |
| if (orig_base and _opf_names_equal(orig_base, filename)) or (out_file and (_opf_names_equal(out_file, expected_response) or out_file == expected_response)): | |
| matched_info = ch | |
| break | |
| file_exists = file_exists_fast(expected_response) | |
| if matched_info: | |
| status = matched_info.get('status', 'unknown') | |
| if status in ['failed', 'in_progress', 'qa_failed', 'pending']: | |
| spine_ch['status'] = status | |
| spine_ch['output_file'] = matched_info.get('output_file') or expected_response | |
| spine_ch['progress_entry'] = matched_info | |
| continue | |
| spine_ch['status'] = status | |
| spine_ch['output_file'] = expected_response if is_special else matched_info.get('output_file', expected_response) | |
| spine_ch['progress_entry'] = matched_info | |
| if not spine_ch['output_file']: | |
| spine_ch['output_file'] = expected_response | |
| if status == 'completed': | |
| output_file = spine_ch['output_file'] | |
| if not file_exists_fast(output_file): | |
| if file_exists and expected_response: | |
| spine_ch['output_file'] = expected_response | |
| matched_info['output_file'] = expected_response | |
| else: | |
| spine_ch['status'] = 'not_translated' | |
| elif file_exists: | |
| spine_ch['status'] = 'completed' | |
| spine_ch['output_file'] = expected_response | |
| else: | |
| norm_target = _normalize_opf_match_name(filename) | |
| matched_file = None | |
| for f in existing_files: | |
| if _normalize_opf_match_name(f) == norm_target: | |
| matched_file = f | |
| break | |
| if matched_file: | |
| spine_ch['status'] = 'completed' | |
| spine_ch['output_file'] = matched_file | |
| else: | |
| spine_ch['status'] = 'not_translated' | |
| spine_ch['output_file'] = expected_response | |
| # ===================================================== | |
| # SAVE AUTO-DISCOVERED FILES TO PROGRESS (refresh path) | |
| # ===================================================== | |
| progress_updated = False | |
| for spine_ch in spine_chapters: | |
| # Only add entries that were marked as completed but have no progress entry | |
| if spine_ch['status'] == 'completed' and 'progress_entry' not in spine_ch: | |
| chapter_num = spine_ch['file_chapter_num'] | |
| output_file = spine_ch['output_file'] | |
| filename = spine_ch['filename'] | |
| # Require normalized filename match between spine file and output file, and the file must exist | |
| norm_spine = _normalize_opf_match_name(filename) | |
| norm_out = _normalize_opf_match_name(output_file) | |
| file_exists = os.path.exists(os.path.join(output_dir, output_file)) | |
| if norm_spine != norm_out or not file_exists: | |
| continue | |
| # Create a progress entry for this auto-discovered file | |
| chapter_key = str(chapter_num) | |
| # Check if key already exists (avoid duplicates) | |
| if chapter_key not in prog.get("chapters", {}): | |
| prog.setdefault("chapters", {})[chapter_key] = { | |
| "actual_num": chapter_num, | |
| "content_hash": "", # Unknown since we don't have the source | |
| "output_file": output_file, | |
| "status": "completed", | |
| "last_updated": os.path.getmtime(os.path.join(output_dir, output_file)), | |
| "auto_discovered": True, | |
| "original_basename": filename | |
| } | |
| progress_updated = True | |
| print(f"✅ Auto-discovered and tracked (refresh): {filename} -> {output_file}") | |
| # Save progress file if we added new entries | |
| if progress_updated: | |
| try: | |
| with open(data['progress_file'], 'w', encoding='utf-8') as f: | |
| json.dump(prog, f, ensure_ascii=False, indent=2) | |
| print(f"💾 Saved {sum(1 for ch in spine_chapters if ch['status'] == 'completed' and 'progress_entry' not in ch)} auto-discovered files to progress file (refresh)") | |
| except Exception as e: | |
| print(f"⚠️ Warning: Failed to save progress file during refresh: {e}") | |
| # Rebuild chapter_display_info from updated spine_chapters | |
| chapter_display_info = [] | |
| for spine_ch in spine_chapters: | |
| display_info = { | |
| 'key': spine_ch.get('filename', ''), | |
| 'num': spine_ch['file_chapter_num'], | |
| 'info': spine_ch.get('progress_entry', {}), | |
| 'output_file': spine_ch['output_file'], | |
| 'status': spine_ch['status'], | |
| 'duplicate_count': 1, | |
| 'entries': [], | |
| 'opf_position': spine_ch['position'], | |
| 'original_filename': spine_ch['filename'], | |
| 'is_special': spine_ch.get('is_special', False), | |
| 'progress_key': spine_ch.get('progress_key') | |
| } | |
| chapter_display_info.append(display_info) | |
| data['chapter_display_info'] = chapter_display_info | |
| def _rebuild_chapter_display_info(self, data): | |
| """Rebuild chapter_display_info from scratch (for fallback mode without OPF)""" | |
| # This is the same logic as the initial build in _force_retranslation_epub_or_text | |
| # but extracted here so refresh can use it | |
| prog = data['prog'] | |
| output_dir = data['output_dir'] | |
| file_path = data.get('file_path', '') | |
| show_special = data.get('show_special_files_state', False) | |
| files_to_entries = {} | |
| for chapter_key, chapter_info in prog.get("chapters", {}).items(): | |
| output_file = chapter_info.get("output_file", "") | |
| status = chapter_info.get("status", "") | |
| # Include chapters with output files OR in_progress/failed/qa_failed with null output file (legacy) | |
| if output_file or status in ["in_progress", "failed", "qa_failed"]: | |
| # For merged chapters, use a unique key (chapter_key) instead of output_file | |
| # This ensures merged chapters appear as separate entries in the list | |
| if status == "merged": | |
| file_key = f"_merged_{chapter_key}" | |
| elif output_file: | |
| file_key = output_file | |
| elif status == "in_progress": | |
| file_key = f"_in_progress_{chapter_key}" | |
| elif status == "qa_failed": | |
| file_key = f"_qa_failed_{chapter_key}" | |
| else: # failed | |
| file_key = f"_failed_{chapter_key}" | |
| if file_key not in files_to_entries: | |
| files_to_entries[file_key] = [] | |
| files_to_entries[file_key].append((chapter_key, chapter_info)) | |
| chapter_display_info = [] | |
| for output_file, entries in files_to_entries.items(): | |
| chapter_key, chapter_info = entries[0] | |
| # Get the actual output file (strip placeholder prefix if present) | |
| actual_output_file = output_file | |
| if output_file.startswith("_merged_") or output_file.startswith("_in_progress_") or output_file.startswith("_failed_") or output_file.startswith("_qa_failed_"): | |
| # For merged/in_progress/failed/qa_failed, get the actual output_file from chapter_info | |
| actual_output_file = chapter_info.get("output_file", "") | |
| if not actual_output_file: | |
| # Generate expected filename based on actual_num | |
| actual_num = chapter_info.get("actual_num") | |
| if actual_num is not None: | |
| # Use .txt extension for text files, .html for EPUB | |
| ext = ".txt" if file_path.endswith(".txt") else ".html" | |
| actual_output_file = f"response_section_{actual_num}{ext}" | |
| # Check if this is a special file (files without numbers) | |
| original_basename = chapter_info.get("original_basename", "") | |
| filename_to_check = original_basename if original_basename else actual_output_file | |
| # Check if filename contains any digits | |
| import re | |
| has_numbers = bool(re.search(r'\d', filename_to_check)) | |
| is_special = not has_numbers | |
| # Don't skip special files here - let the display logic handle hiding them | |
| # This ensures chapter_display_info contains all items, and the listbox | |
| # will properly hide/show items based on the toggle state | |
| # Extract chapter number - prioritize stored values | |
| chapter_num = None | |
| if 'actual_num' in chapter_info and chapter_info['actual_num'] is not None: | |
| chapter_num = chapter_info['actual_num'] | |
| elif 'chapter_num' in chapter_info and chapter_info['chapter_num'] is not None: | |
| chapter_num = chapter_info['chapter_num'] | |
| # Fallback: extract from filename | |
| if chapter_num is None: | |
| import re | |
| matches = re.findall(r'(\d+)', actual_output_file) | |
| if matches: | |
| chapter_num = int(matches[-1]) | |
| else: | |
| chapter_num = 999999 | |
| status = chapter_info.get("status", "unknown") | |
| if status == "completed_empty": | |
| status = "completed" | |
| # Check file existence | |
| if status == "completed": | |
| output_path = os.path.join(output_dir, actual_output_file) | |
| if not os.path.exists(output_path): | |
| status = "not_translated" | |
| chapter_display_info.append({ | |
| 'key': chapter_key, | |
| 'num': chapter_num, | |
| 'info': chapter_info, | |
| 'output_file': actual_output_file, # Use actual output file, not placeholder | |
| 'status': status, | |
| 'duplicate_count': len(entries), | |
| 'entries': entries, | |
| 'is_special': is_special, | |
| 'progress_key': chapter_key | |
| }) | |
| # Sort by chapter number | |
| chapter_display_info.sort(key=lambda x: x['num'] if x['num'] is not None else 999999) | |
| # Update data with rebuilt list | |
| data['chapter_display_info'] = chapter_display_info | |
| def _update_chapter_status_info(self, data): | |
| """Update chapter status information after refresh""" | |
| # Re-check file existence and update status for each chapter | |
| for info in data['chapter_display_info']: | |
| output_file = info['output_file'] | |
| output_path = os.path.join(data['output_dir'], output_file) | |
| # Find matching progress entry | |
| matched_info = None | |
| # PRIORITY 1: Match by BOTH actual_num AND output_file | |
| # This prevents cross-matching between files with same chapter number but different filenames | |
| for chapter_key, chapter_info in data['prog'].get("chapters", {}).items(): | |
| actual_num = chapter_info.get('actual_num') or chapter_info.get('chapter_num') | |
| ch_output = chapter_info.get('output_file') | |
| # BOTH must match - no fallback | |
| if actual_num is not None and actual_num == info['num'] and ch_output == output_file: | |
| matched_info = chapter_info | |
| break | |
| # PRIORITY 2: Fall back to output_file matching if no actual_num match | |
| if not matched_info: | |
| # Prefer completed over failed/pending/in_progress; keep qa_failed highest | |
| severity = {'qa_failed': 5, 'completed': 4, 'failed': 3, 'pending': 2, 'in_progress': 1} | |
| best = None | |
| best_score = -1 | |
| for chapter_key, chapter_info in data['prog'].get("chapters", {}).items(): | |
| if chapter_info.get('output_file') == output_file: | |
| status = chapter_info.get('status', 'unknown') | |
| score = severity.get(status, -1) | |
| # Prefer higher severity; tie-breaker: matching actual_num if present | |
| matches_num = (chapter_info.get('actual_num') or chapter_info.get('chapter_num')) == info['num'] | |
| if score > best_score or (score == best_score and matches_num): | |
| best_score = score | |
| best = chapter_info | |
| if best: | |
| matched_info = best | |
| # Update status based on current state from progress file | |
| if matched_info: | |
| new_status = matched_info.get('status', 'unknown') | |
| # Handle completed_empty as completed for display | |
| if new_status == 'completed_empty': | |
| new_status = 'completed' | |
| # Verify file actually exists for completed status (but NOT for merged - merged chapters | |
| # don't have their own output files, they point to parent's file) | |
| if new_status == 'completed' and not os.path.exists(output_path): | |
| new_status = 'not_translated' | |
| info['status'] = new_status | |
| info['info'] = matched_info | |
| elif os.path.exists(output_path): | |
| # Before marking as completed based on file existence, check if this chapter | |
| # is actually marked as merged in the progress file (by actual_num lookup) | |
| # This handles the case where old output files exist from before merging was enabled | |
| is_merged_chapter = False | |
| for chapter_key, chapter_info in data['prog'].get("chapters", {}).items(): | |
| actual_num = chapter_info.get('actual_num') or chapter_info.get('chapter_num') | |
| if actual_num is not None and actual_num == info['num']: | |
| if chapter_info.get('status') == 'merged': | |
| is_merged_chapter = True | |
| info['status'] = 'merged' | |
| info['info'] = chapter_info | |
| break | |
| if not is_merged_chapter: | |
| info['status'] = 'completed' | |
| else: | |
| info['status'] = 'not_translated' | |
| def _update_listbox_display(self, data): | |
| """Update the listbox display with current chapter information""" | |
| # Add a check to ensure widgets are still valid before proceeding | |
| if not self._is_data_valid(data): | |
| print("⚠️ Cannot update listbox display - widgets have been deleted") | |
| return | |
| listbox = data['listbox'] | |
| # Clear existing items | |
| listbox.clear() | |
| # Status icons and labels | |
| status_icons = { | |
| 'completed': '✅', | |
| 'merged': '🔗', | |
| 'failed': '❌', | |
| 'qa_failed': '❌', | |
| 'in_progress': '🔄', | |
| 'not_translated': '⬜', | |
| 'unknown': '❓' | |
| } | |
| status_labels = { | |
| 'completed': 'Completed', | |
| 'merged': 'Merged', | |
| 'failed': 'Failed', | |
| 'qa_failed': 'QA Failed', | |
| 'in_progress': 'In Progress', | |
| 'not_translated': 'Not Translated', | |
| 'unknown': 'Unknown' | |
| } | |
| # Calculate maximum widths for dynamic column sizing | |
| max_original_len = 0 | |
| max_output_len = 0 | |
| for info in data['chapter_display_info']: | |
| if 'opf_position' in info: | |
| original_file = info.get('original_filename', '') | |
| output_file = info['output_file'] | |
| max_original_len = max(max_original_len, len(original_file)) | |
| max_output_len = max(max_output_len, len(output_file)) | |
| # Set minimum widths to prevent too narrow columns | |
| max_original_len = max(max_original_len, 20) | |
| max_output_len = max(max_output_len, 25) | |
| # Rebuild listbox items with updates/signals disabled to avoid flicker | |
| listbox.setUpdatesEnabled(False) | |
| listbox.blockSignals(True) | |
| count_existing = listbox.count() | |
| count_new = len(data['chapter_display_info']) | |
| def build_display(info, max_original_len, max_output_len): | |
| chapter_num = info['num'] | |
| status = info['status'] | |
| output_file = info['output_file'] | |
| icon = status_icons.get(status, '❓') | |
| status_label = status_labels.get(status, status) | |
| if 'opf_position' in info: | |
| original_file = info.get('original_filename', '') | |
| opf_pos = info['opf_position'] + 1 | |
| if isinstance(chapter_num, float): | |
| if chapter_num.is_integer(): | |
| display = f"[{opf_pos:03d}] Ch.{int(chapter_num):03d} | {icon} {status_label:11s} | {original_file:<{max_original_len}} -> {output_file}" | |
| else: | |
| display = f"[{opf_pos:03d}] Ch.{chapter_num:06.1f} | {icon} {status_label:11s} | {original_file:<{max_original_len}} -> {output_file}" | |
| else: | |
| display = f"[{opf_pos:03d}] Ch.{chapter_num:03d} | {icon} {status_label:11s} | {original_file:<{max_original_len}} -> {output_file}" | |
| else: | |
| if isinstance(chapter_num, float) and chapter_num.is_integer(): | |
| display = f"Chapter {int(chapter_num):03d} | {icon} {status_label:11s} | {output_file}" | |
| elif isinstance(chapter_num, float): | |
| display = f"Chapter {chapter_num:06.1f} | {icon} {status_label:11s} | {output_file}" | |
| else: | |
| display = f"Chapter {chapter_num:03d} | {icon} {status_label:11s} | {output_file}" | |
| if status == 'qa_failed': | |
| chapter_info = info.get('info', {}) | |
| qa_issues = chapter_info.get('qa_issues_found', []) | |
| if qa_issues: | |
| issues_display = ', '.join(qa_issues[:2]) | |
| if len(qa_issues) > 2: | |
| issues_display += f' (+{len(qa_issues)-2} more)' | |
| display += f" | {issues_display}" | |
| if status == 'merged': | |
| chapter_info = info.get('info', {}) | |
| parent_chapter = chapter_info.get('merged_parent_chapter') | |
| if parent_chapter: | |
| display += f" | → Ch.{parent_chapter}" | |
| if info.get('duplicate_count', 1) > 1: | |
| display += f" | ({info['duplicate_count']} entries)" | |
| return display | |
| def apply_item_visuals(item, status): | |
| from PySide6.QtGui import QColor | |
| if status == 'completed': | |
| item.setForeground(QColor('green')) | |
| elif status == 'merged': | |
| item.setForeground(QColor('#17a2b8')) | |
| elif status in ['failed', 'qa_failed']: | |
| item.setForeground(QColor('red')) | |
| elif status == 'not_translated': | |
| item.setForeground(QColor('#2b6cb0')) | |
| elif status == 'in_progress': | |
| item.setForeground(QColor('orange')) | |
| show_special_files = data.get('show_special_files_state', False) | |
| if 'show_special_files_cb' in data and data['show_special_files_cb']: | |
| try: | |
| show_special_files = data['show_special_files_cb'].isChecked() | |
| except RuntimeError: | |
| pass | |
| if count_existing == count_new: | |
| # Update in place to keep scroll stable | |
| for idx, info in enumerate(data['chapter_display_info']): | |
| if idx % 120 == 0: | |
| self._ui_yield() | |
| item = listbox.item(idx) | |
| if not item: | |
| continue | |
| item.setText(build_display(info, max_original_len, max_output_len)) | |
| apply_item_visuals(item, info['status']) | |
| is_special = info.get('is_special', False) | |
| item.setData(Qt.UserRole, {'is_special': is_special, 'info': info, 'progress_key': info.get('progress_key')}) | |
| item.setHidden(is_special and not show_special_files) | |
| else: | |
| # Recreate items | |
| listbox.clear() | |
| from PySide6.QtWidgets import QListWidgetItem | |
| from PySide6.QtCore import Qt | |
| for idx, info in enumerate(data['chapter_display_info']): | |
| if idx % 120 == 0: | |
| self._ui_yield() | |
| item = QListWidgetItem(build_display(info, max_original_len, max_output_len)) | |
| apply_item_visuals(item, info['status']) | |
| is_special = info.get('is_special', False) | |
| item.setData(Qt.UserRole, {'is_special': is_special, 'info': info, 'progress_key': info.get('progress_key')}) | |
| item.setHidden(is_special and not show_special_files) | |
| listbox.addItem(item) | |
| listbox.blockSignals(False) | |
| listbox.setUpdatesEnabled(True) | |
| def _update_statistics_display(self, data): | |
| """Update statistics display for both OPF and non-OPF files""" | |
| # Find statistics labels in the container | |
| container = data['container'] | |
| # Search for statistics labels by traversing the widget hierarchy | |
| def find_stats_labels(widget): | |
| labels = {} | |
| if hasattr(widget, 'children'): | |
| for child in widget.children(): | |
| if hasattr(child, 'text'): | |
| text = child.text() | |
| if text.startswith('Total:'): | |
| labels['total'] = child | |
| elif text.startswith('✅ Completed:'): | |
| labels['completed'] = child | |
| elif text.startswith('🔗 Merged:'): | |
| labels['merged'] = child | |
| elif text.startswith('🔄 In Progress:'): | |
| labels['in_progress'] = child | |
| elif text.startswith('❓ Pending:'): | |
| labels['pending'] = child | |
| elif text.startswith('⬜ Not Translated:'): | |
| labels['missing'] = child | |
| elif text.startswith('❌ Failed:'): | |
| labels['failed'] = child | |
| # Recursively search children | |
| labels.update(find_stats_labels(child)) | |
| return labels | |
| stats_labels = find_stats_labels(container) | |
| if stats_labels: | |
| # Recalculate statistics from chapter_display_info (works for both OPF and non-OPF) | |
| chapter_display_info = data.get('chapter_display_info', []) | |
| total_chapters = len(chapter_display_info) | |
| completed = sum(1 for info in chapter_display_info if info['status'] == 'completed') | |
| merged = sum(1 for info in chapter_display_info if info['status'] == 'merged') | |
| in_progress = sum(1 for info in chapter_display_info if info['status'] == 'in_progress') | |
| pending = sum(1 for info in chapter_display_info if info['status'] == 'pending') | |
| missing = sum(1 for info in chapter_display_info if info['status'] == 'not_translated') | |
| failed = sum(1 for info in chapter_display_info if info['status'] in ['failed', 'qa_failed']) | |
| # Update labels | |
| if 'total' in stats_labels: | |
| stats_labels['total'].setText(f"Total: {total_chapters} | ") | |
| if 'completed' in stats_labels: | |
| stats_labels['completed'].setText(f"✅ Completed: {completed} | ") | |
| if 'merged' in stats_labels: | |
| if merged > 0: | |
| stats_labels['merged'].setText(f"🔗 Merged: {merged} | ") | |
| stats_labels['merged'].setVisible(True) | |
| else: | |
| stats_labels['merged'].setVisible(False) | |
| if 'in_progress' in stats_labels: | |
| if in_progress > 0: | |
| stats_labels['in_progress'].setText(f"🔄 In Progress: {in_progress} | ") | |
| stats_labels['in_progress'].setVisible(True) | |
| else: | |
| stats_labels['in_progress'].setVisible(False) | |
| if 'pending' in stats_labels: | |
| if pending > 0: | |
| stats_labels['pending'].setText(f"❓ Pending: {pending} | ") | |
| stats_labels['pending'].setVisible(True) | |
| else: | |
| stats_labels['pending'].setVisible(False) | |
| if 'missing' in stats_labels: | |
| stats_labels['missing'].setText(f"⬜ Not Translated: {missing} | ") | |
| if 'failed' in stats_labels: | |
| stats_labels['failed'].setText(f"❌ Failed: {failed} | ") | |
| def _refresh_image_folder_data(self, data): | |
| """Refresh the image folder retranslation dialog data by rescanning files""" | |
| try: | |
| # Validate that widgets still exist | |
| if not self._is_data_valid(data): | |
| print("⚠️ Cannot refresh - widgets have been deleted") | |
| return | |
| # Save current selections to restore after refresh | |
| selected_indices = [] | |
| try: | |
| selected_indices = [data['listbox'].row(item) for item in data['listbox'].selectedItems()] | |
| except RuntimeError: | |
| print("⚠️ Could not save selection state - widget was deleted") | |
| return | |
| output_dir = data['output_dir'] | |
| progress_file = data['progress_file'] | |
| folder_path = data['folder_path'] | |
| # ALWAYS reload progress data from file to catch deletions | |
| progress_data = None | |
| html_files = [] | |
| has_progress_tracking = os.path.exists(progress_file) | |
| if has_progress_tracking: | |
| try: | |
| with open(progress_file, 'r', encoding='utf-8') as f: | |
| progress_data = json.load(f) | |
| print(f"🔄 Reloaded progress file from disk") | |
| # Extract files from progress data (primary source) | |
| # Check if this is the newer nested structure with 'images' key | |
| images_dict = progress_data.get('images', {}) | |
| if images_dict: | |
| # Newer structure: progress_data['images'][hash] = {entry} | |
| for key, value in images_dict.items(): | |
| if isinstance(value, dict) and 'output_file' in value: | |
| output_file = value['output_file'] | |
| # Handle both forward and backslashes in paths | |
| output_file = output_file.replace('\\', '/') | |
| if '/' in output_file: | |
| output_file = os.path.basename(output_file) | |
| # Only include if file actually exists on disk | |
| if output_file and output_file not in html_files: | |
| full_path = os.path.join(output_dir, output_file) | |
| if os.path.exists(full_path): | |
| html_files.append(output_file) | |
| else: | |
| print(f"⚠️ File in progress but not on disk: {output_file}") | |
| else: | |
| # Older structure: progress_data[hash] = {entry} | |
| for key, value in progress_data.items(): | |
| if isinstance(value, dict) and 'output_file' in value: | |
| output_file = value['output_file'] | |
| # Handle both forward and backslashes in paths | |
| output_file = output_file.replace('\\', '/') | |
| if '/' in output_file: | |
| output_file = os.path.basename(output_file) | |
| # Only include if file actually exists on disk | |
| if output_file and output_file not in html_files: | |
| full_path = os.path.join(output_dir, output_file) | |
| if os.path.exists(full_path): | |
| html_files.append(output_file) | |
| else: | |
| print(f"⚠️ File in progress but not on disk: {output_file}") | |
| except Exception as e: | |
| print(f"Failed to load progress file: {e}") | |
| has_progress_tracking = False | |
| # Also scan directory for any HTML files not in progress (fallback) | |
| if os.path.exists(output_dir): | |
| try: | |
| for file in os.listdir(output_dir): | |
| file_path = os.path.join(output_dir, file) | |
| if (os.path.isfile(file_path) and | |
| file.lower().endswith(('.html', '.xhtml', '.htm')) and | |
| file not in html_files): | |
| html_files.append(file) | |
| except Exception as e: | |
| print(f"Error scanning directory: {e}") | |
| # Rescan cover images | |
| image_files = [] | |
| images_dir = os.path.join(output_dir, "images") | |
| if os.path.exists(images_dir): | |
| try: | |
| for file in os.listdir(images_dir): | |
| if file.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp')): | |
| image_files.append(file) | |
| except Exception as e: | |
| print(f"Error scanning images directory: {e}") | |
| # Rebuild file_info list | |
| file_info = [] | |
| # Add translated files (both HTML and generated images) | |
| for html_file in sorted(set(html_files)): | |
| # Determine file type and extract info | |
| is_html = html_file.lower().endswith(('.html', '.xhtml', '.htm')) | |
| is_image = html_file.lower().endswith(('.png', '.jpg', '.jpeg', '.webp', '.gif')) | |
| if is_html: | |
| match = re.match(r'response_(\d+)_(.+)\.html', html_file) | |
| if match: | |
| index = match.group(1) | |
| base_name = match.group(2) | |
| elif is_image: | |
| # For generated images, just use the filename | |
| base_name = os.path.splitext(html_file)[0] | |
| # Find hash key if progress tracking exists | |
| hash_key = None | |
| if progress_data: | |
| # Check nested structure first | |
| images_dict = progress_data.get('images', {}) | |
| if images_dict: | |
| for key, value in images_dict.items(): | |
| if isinstance(value, dict) and 'output_file' in value: | |
| if html_file in value['output_file']: | |
| hash_key = key | |
| break | |
| else: | |
| # Check flat structure | |
| for key, value in progress_data.items(): | |
| if isinstance(value, dict) and 'output_file' in value: | |
| if html_file in value['output_file']: | |
| hash_key = key | |
| break | |
| file_info.append({ | |
| 'type': 'translated', | |
| 'file': html_file, | |
| 'path': os.path.join(output_dir, html_file), | |
| 'hash_key': hash_key, | |
| 'output_dir': output_dir | |
| }) | |
| # Add cover images | |
| for img_file in sorted(image_files): | |
| file_info.append({ | |
| 'type': 'cover', | |
| 'file': img_file, | |
| 'path': os.path.join(images_dir, img_file), | |
| 'hash_key': None, | |
| 'output_dir': output_dir | |
| }) | |
| # Update data dictionary with fresh data | |
| data['file_info'] = file_info | |
| data['progress_data'] = progress_data | |
| # IMPORTANT: Also update the original refresh_data dict so future operations use fresh data | |
| # This ensures delete operations after refresh work with current state | |
| if 'progress_data' in data: | |
| # Update the reference in the closure | |
| data['progress_data'] = progress_data | |
| # Clear and rebuild listbox | |
| listbox = data['listbox'] | |
| listbox.clear() | |
| # Add all tracked files to display | |
| for info in file_info: | |
| if info['type'] == 'translated': | |
| file_name = info['file'] | |
| # Check if it's an HTML file or a generated image | |
| is_html = file_name.lower().endswith(('.html', '.xhtml', '.htm')) | |
| is_image = file_name.lower().endswith(('.png', '.jpg', '.jpeg', '.webp', '.gif')) | |
| if is_html: | |
| match = re.match(r'response_(\d+)_(.+)\.html', file_name) | |
| if match: | |
| index = match.group(1) | |
| base_name = match.group(2) | |
| display = f"📄 Image {index} | {base_name} | ✅ Completed" | |
| else: | |
| display = f"📄 {file_name} | ✅ Completed" | |
| elif is_image: | |
| # Generated image file (e.g., Test1.png from imagen) | |
| base_name = os.path.splitext(file_name)[0] | |
| display = f"🖼️ {base_name} | ✅ Completed" | |
| else: | |
| display = f"📄 {file_name} | ✅ Completed" | |
| elif info['type'] == 'cover': | |
| display = f"🖼️ Cover | {info['file']} | ⏭️ Skipped (cover)" | |
| else: | |
| display = f"📄 {info['file']}" | |
| listbox.addItem(display) | |
| # Restore selections | |
| try: | |
| if selected_indices: | |
| for idx in selected_indices: | |
| if idx < listbox.count(): | |
| listbox.item(idx).setSelected(True) | |
| # Update selection count | |
| if 'selection_count_label' in data and data['selection_count_label']: | |
| data['selection_count_label'].setText(f"Selected: {len(selected_indices)}") | |
| else: | |
| listbox.clearSelection() | |
| if 'selection_count_label' in data and data['selection_count_label']: | |
| data['selection_count_label'].setText("Selected: 0") | |
| except RuntimeError: | |
| print("⚠️ Could not restore selection state - widget was deleted during refresh") | |
| print(f"✅ Image folder data refreshed: {len(html_files)} HTML files, {len(image_files)} cover images") | |
| except Exception as e: | |
| print(f"❌ Failed to refresh image folder data: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| def _force_retranslation_multiple_files(self): | |
| """Handle force retranslation when multiple files are selected - now uses shared logic""" | |
| try: | |
| print(f"[DEBUG] _force_retranslation_multiple_files called with {len(self.selected_files)} files") | |
| # First, check if all selected files are images from the same folder | |
| # This handles the case where folder selection results in individual file selections | |
| if len(self.selected_files) > 1: | |
| all_images = True | |
| parent_dirs = set() | |
| image_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp') | |
| for file_path in self.selected_files: | |
| if os.path.isfile(file_path) and file_path.lower().endswith(image_extensions): | |
| parent_dirs.add(os.path.dirname(file_path)) | |
| else: | |
| all_images = False | |
| break | |
| # If all files are images from the same directory, treat it as a folder selection | |
| if all_images and len(parent_dirs) == 1: | |
| folder_path = parent_dirs.pop() | |
| print(f"[DEBUG] Detected {len(self.selected_files)} images from same folder: {folder_path}") | |
| print(f"[DEBUG] Treating as folder selection") | |
| self._force_retranslation_images_folder(folder_path) | |
| return | |
| # Otherwise, continue with normal categorization | |
| epub_files = [] | |
| text_files = [] | |
| image_files = [] | |
| folders = [] | |
| image_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp') | |
| for file_path in self.selected_files: | |
| if os.path.isdir(file_path): | |
| folders.append(file_path) | |
| elif file_path.lower().endswith('.epub'): | |
| epub_files.append(file_path) | |
| elif file_path.lower().endswith('.txt'): | |
| text_files.append(file_path) | |
| elif file_path.lower().endswith(image_extensions): | |
| image_files.append(file_path) | |
| # Build summary | |
| summary_parts = [] | |
| if epub_files: | |
| summary_parts.append(f"{len(epub_files)} EPUB file(s)") | |
| if text_files: | |
| summary_parts.append(f"{len(text_files)} text file(s)") | |
| if image_files: | |
| summary_parts.append(f"{len(image_files)} image file(s)") | |
| if folders: | |
| summary_parts.append(f"{len(folders)} folder(s)") | |
| if not summary_parts: | |
| QMessageBox.information(self, "Info", "No valid files selected.") | |
| return | |
| # Create a unique key for the current selection | |
| selection_key = tuple(sorted(self.selected_files)) | |
| # Check if we already have a cached dialog for this exact selection | |
| if (hasattr(self, '_multi_file_retranslation_dialog') and | |
| self._multi_file_retranslation_dialog and | |
| hasattr(self, '_multi_file_selection_key') and | |
| self._multi_file_selection_key == selection_key): | |
| # Reuse existing dialog - refresh all tabs before showing | |
| cached_dialog = self._multi_file_retranslation_dialog | |
| if hasattr(cached_dialog, '_tab_data') and cached_dialog._tab_data: | |
| print(f"[DEBUG] Refreshing all {len(cached_dialog._tab_data)} tabs in cached dialog...") | |
| self._refresh_all_tabs(cached_dialog._tab_data) | |
| cached_dialog.show() | |
| cached_dialog.raise_() | |
| cached_dialog.activateWindow() | |
| return | |
| # If there's an existing dialog for a different selection, destroy it first | |
| if hasattr(self, '_multi_file_retranslation_dialog') and self._multi_file_retranslation_dialog: | |
| self._multi_file_retranslation_dialog.close() | |
| self._multi_file_retranslation_dialog.deleteLater() | |
| self._multi_file_retranslation_dialog = None | |
| # Create main dialog | |
| dialog = QDialog(self) | |
| dialog.setWindowTitle("Progress Manager - Multiple Files") | |
| dialog.setWindowFlag(Qt.WindowStaysOnTopHint, True) | |
| dialog.setWindowModality(Qt.NonModal) | |
| # Store the list of EPUBs in the dialog for cross-tab state updates | |
| dialog._epub_files_in_dialog = epub_files + text_files | |
| # Increased height from 18% to 25% for better visibility | |
| width, height = self._get_dialog_size(0.25, 0.45) | |
| dialog.resize(width, height) | |
| # Set icon | |
| try: | |
| from PySide6.QtGui import QIcon | |
| if hasattr(self, 'base_dir'): | |
| base_dir = self.base_dir | |
| else: | |
| base_dir = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) | |
| ico_path = os.path.join(base_dir, 'Halgakos.ico') | |
| if os.path.isfile(ico_path): | |
| dialog.setWindowIcon(QIcon(ico_path)) | |
| except Exception as e: | |
| print(f"Failed to load icon: {e}") | |
| dialog_layout = QVBoxLayout(dialog) | |
| # Summary label | |
| summary_label = QLabel(f"Selected: {', '.join(summary_parts)}") | |
| summary_font = QFont('Arial', 12) | |
| summary_font.setBold(True) | |
| summary_label.setFont(summary_font) | |
| dialog_layout.addWidget(summary_label) | |
| # Create tab widget with custom styling | |
| notebook = QTabWidget() | |
| notebook.setStyleSheet(""" | |
| QTabWidget::pane { | |
| border: 2px solid #5a9fd4; | |
| border-radius: 4px; | |
| background-color: #2d2d2d; | |
| } | |
| QTabBar::tab { | |
| background-color: #3a3a3a; | |
| color: white; | |
| padding: 8px 16px; | |
| margin-right: 2px; | |
| border: 1px solid #5a9fd4; | |
| border-bottom: none; | |
| border-top-left-radius: 4px; | |
| border-top-right-radius: 4px; | |
| font-weight: bold; | |
| font-size: 11pt; | |
| } | |
| QTabBar::tab:selected { | |
| background-color: #5a9fd4; | |
| color: white; | |
| } | |
| QTabBar::tab:hover { | |
| background-color: #4a8fc4; | |
| } | |
| """) | |
| dialog_layout.addWidget(notebook) | |
| # Track all tab data | |
| tab_data = [] | |
| tabs_created = False | |
| # Store tab_data reference on the dialog for cross-tab operations | |
| dialog._tab_data = tab_data | |
| # Get the global show_special state from the first file that has it cached | |
| # Default to True if any text files are present, False otherwise | |
| global_show_special = True if text_files else False | |
| for file_path in epub_files + text_files: | |
| file_key = os.path.abspath(file_path) | |
| if hasattr(self, '_retranslation_dialog_cache') and file_key in self._retranslation_dialog_cache: | |
| cached_data = self._retranslation_dialog_cache[file_key] | |
| if cached_data and 'show_special_files_state' in cached_data: | |
| global_show_special = cached_data['show_special_files_state'] | |
| break # Use the first one we find | |
| # Determine output directory override (matches single-file logic) | |
| override_dir = (os.environ.get('OUTPUT_DIRECTORY') or os.environ.get('OUTPUT_DIR')) | |
| if not override_dir and hasattr(self, 'config'): | |
| try: | |
| override_dir = self.config.get('output_directory') | |
| except Exception: | |
| override_dir = None | |
| # Create tabs for EPUB/text files using shared logic | |
| for file_path in epub_files + text_files: | |
| file_base = os.path.splitext(os.path.basename(file_path))[0] | |
| print(f"[DEBUG] Checking EPUB/text: {file_base}") | |
| # Quick check if output exists (respect override output directory) | |
| output_dir = os.path.join(override_dir, file_base) if override_dir else file_base | |
| if not os.path.exists(output_dir): | |
| print(f"[DEBUG] Skipping {file_base} - output folder doesn't exist: {output_dir}") | |
| continue | |
| print(f"[DEBUG] Creating tab for {file_base}") | |
| # Create tab | |
| tab_frame = QWidget() | |
| tab_layout = QVBoxLayout(tab_frame) | |
| tab_name = file_base[:20] + "..." if len(file_base) > 20 else file_base | |
| # Use shared logic to populate the tab with global state | |
| tab_result = self._force_retranslation_epub_or_text( | |
| file_path, | |
| parent_dialog=dialog, | |
| tab_frame=tab_frame, | |
| show_special_files_state=global_show_special | |
| ) | |
| # Only add the tab if content was successfully created | |
| if tab_result: | |
| notebook.addTab(tab_frame, tab_name) | |
| tab_data.append(tab_result) | |
| tabs_created = True | |
| print(f"[DEBUG] Successfully created tab for {file_base}") | |
| else: | |
| print(f"[DEBUG] Failed to create content for {file_base}") | |
| # Create tabs for image folders (keeping existing logic for now) | |
| for folder_path in folders: | |
| folder_result = self._create_image_folder_tab( | |
| folder_path, | |
| notebook, | |
| dialog | |
| ) | |
| if folder_result: | |
| tab_data.append(folder_result) | |
| tabs_created = True | |
| # If only individual image files selected and no tabs created yet | |
| if image_files and not tabs_created: | |
| # Create a single tab for all individual images | |
| image_tab_result = self._create_individual_images_tab( | |
| image_files, | |
| notebook, | |
| dialog | |
| ) | |
| if image_tab_result: | |
| tab_data.append(image_tab_result) | |
| tabs_created = True | |
| # If no tabs were created from folders, try scanning folders for individual images | |
| if not tabs_created and folders: | |
| # Scan folders for individual image files | |
| scanned_images = [] | |
| for folder_path in folders: | |
| if os.path.isdir(folder_path): | |
| try: | |
| for file in os.listdir(folder_path): | |
| file_path = os.path.join(folder_path, file) | |
| if os.path.isfile(file_path) and file.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp')): | |
| scanned_images.append(file_path) | |
| except: | |
| pass | |
| # If we found images, create a tab for them | |
| if scanned_images: | |
| image_tab_result = self._create_individual_images_tab( | |
| scanned_images, | |
| notebook, | |
| dialog | |
| ) | |
| if image_tab_result: | |
| tab_data.append(image_tab_result) | |
| tabs_created = True | |
| # If still no tabs were created, show error | |
| if not tabs_created: | |
| QMessageBox.information(self, "Info", | |
| "No translation output found for any of the selected files.\n\n" | |
| "Make sure the output folders exist in your script directory.") | |
| dialog.close() | |
| return | |
| # Add unified button bar that works across all tabs | |
| self._add_multi_file_buttons(dialog, notebook, tab_data) | |
| # Override close event to minimize instead of destroy | |
| def closeEvent(event): | |
| event.ignore() # Ignore the close event | |
| dialog.hide() # Just hide (minimize) the dialog | |
| dialog.closeEvent = closeEvent | |
| # Cache the dialog and selection key for reuse | |
| self._multi_file_retranslation_dialog = dialog | |
| self._multi_file_selection_key = selection_key | |
| # Refresh all tabs before showing the dialog | |
| if tab_data: | |
| print(f"[DEBUG] Refreshing all {len(tab_data)} tabs on dialog open...") | |
| self._refresh_all_tabs(tab_data) | |
| else: | |
| print(f"[WARN] No tab data to refresh on dialog open") | |
| # Show the dialog (non-modal to allow interaction with other windows) | |
| dialog.show() | |
| except Exception as e: | |
| print(f"[ERROR] _force_retranslation_multiple_files failed: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| QMessageBox.critical(self, "Error", f"Failed to open retranslation dialog:\n{str(e)}") | |
| def _add_multi_file_buttons(self, dialog, notebook, tab_data): | |
| """Placeholder for future multi-file button functionality""" | |
| # No buttons needed - dialog has standard close button | |
| pass | |
| def _create_individual_images_tab(self, image_files, notebook, parent_dialog): | |
| """Create a tab for individual image files""" | |
| # Create tab | |
| tab_frame = QWidget() | |
| tab_layout = QVBoxLayout(tab_frame) | |
| notebook.addTab(tab_frame, "Individual Images") | |
| # Instructions | |
| instruction_label = QLabel(f"Selected {len(image_files)} individual image(s):") | |
| instruction_font = QFont('Arial', 11) | |
| instruction_label.setFont(instruction_font) | |
| tab_layout.addWidget(instruction_label) | |
| # Listbox (QListWidget has built-in scrolling) | |
| listbox = QListWidget() | |
| listbox.setSelectionMode(QListWidget.ExtendedSelection) | |
| # Use 16% of screen width (half of original ~31% for 1920px screen) | |
| min_width, _ = self._get_dialog_size(0.16, 0) | |
| listbox.setMinimumWidth(min_width) | |
| tab_layout.addWidget(listbox) | |
| # File info | |
| file_info = [] | |
| script_dir = os.getcwd() | |
| # Check each image for translations | |
| for img_path in sorted(image_files): | |
| img_name = os.path.basename(img_path) | |
| base_name = os.path.splitext(img_name)[0] | |
| # Look for translations in various possible locations | |
| found_translations = [] | |
| # Check in script directory with base name | |
| possible_dirs = [ | |
| os.path.join(script_dir, base_name), | |
| os.path.join(script_dir, f"{base_name}_translated"), | |
| base_name, | |
| f"{base_name}_translated" | |
| ] | |
| for output_dir in possible_dirs: | |
| if os.path.exists(output_dir) and os.path.isdir(output_dir): | |
| # Look for HTML files | |
| for file in os.listdir(output_dir): | |
| if file.lower().endswith(('.html', '.xhtml', '.htm')) and base_name in file: | |
| found_translations.append((output_dir, file)) | |
| if found_translations: | |
| for output_dir, html_file in found_translations: | |
| display = f"📄 {img_name} → {html_file} | ✅ Translated" | |
| listbox.addItem(display) | |
| file_info.append({ | |
| 'type': 'translated', | |
| 'source_image': img_path, | |
| 'output_dir': output_dir, | |
| 'file': html_file, | |
| 'path': os.path.join(output_dir, html_file) | |
| }) | |
| else: | |
| display = f"🖼️ {img_name} | ❌ No translation found" | |
| listbox.addItem(display) | |
| # Selection count | |
| selection_count_label = QLabel("Selected: 0") | |
| selection_font = QFont('Arial', 9) | |
| selection_count_label.setFont(selection_font) | |
| tab_layout.addWidget(selection_count_label) | |
| def update_selection_count(): | |
| count = len(listbox.selectedItems()) | |
| selection_count_label.setText(f"Selected: {count}") | |
| listbox.itemSelectionChanged.connect(update_selection_count) | |
| # Right-click context menu to open translated/cover files | |
| def _open_file_for_row(row): | |
| if row < 0 or row >= len(file_info): | |
| return | |
| info = file_info[row] | |
| path = info.get('path') | |
| if not path or not os.path.exists(path): | |
| self._show_message('error', "File Missing", f"File not found:\n{path}", parent=parent_dialog) | |
| return | |
| try: | |
| QDesktopServices.openUrl(QUrl.fromLocalFile(path)) | |
| except Exception as e: | |
| self._show_message('error', "Open Failed", str(e), parent=parent_dialog) | |
| def _show_context_menu(pos): | |
| item = listbox.itemAt(pos) | |
| if not item: | |
| return | |
| row = listbox.row(item) | |
| menu = QMenu(listbox) | |
| menu.setStyleSheet( | |
| "QMenu {" | |
| " padding: 4px;" | |
| " background-color: #2b2b2b;" | |
| " color: white;" | |
| " border: 1px solid #5a9fd4;" | |
| "} " | |
| "QMenu::icon { width: 0px; } " | |
| "QMenu::item {" | |
| " padding: 6px 12px;" | |
| " background-color: transparent;" | |
| "} " | |
| "QMenu::item:selected {" | |
| " background-color: #17a2b8;" | |
| " color: white;" | |
| "} " | |
| "QMenu::item:pressed {" | |
| " background-color: #138496;" | |
| "}" | |
| ) | |
| act_open = menu.addAction("📂 Open File") | |
| chosen = menu.exec(listbox.mapToGlobal(pos)) | |
| if chosen == act_open: | |
| _open_file_for_row(row) | |
| listbox.setContextMenuPolicy(Qt.CustomContextMenu) | |
| listbox.customContextMenuRequested.connect(_show_context_menu) | |
| return { | |
| 'type': 'individual_images', | |
| 'listbox': listbox, | |
| 'file_info': file_info, | |
| 'selection_count_label': selection_count_label | |
| } | |
| def _create_image_folder_tab(self, folder_path, notebook, parent_dialog): | |
| """Create a tab for image folder retranslation""" | |
| folder_name = os.path.basename(folder_path) | |
| output_dir = f"{folder_name}_translated" | |
| if not os.path.exists(output_dir): | |
| return None | |
| # Create tab | |
| tab_frame = QWidget() | |
| tab_layout = QVBoxLayout(tab_frame) | |
| tab_name = "📁 " + (folder_name[:17] + "..." if len(folder_name) > 17 else folder_name) | |
| notebook.addTab(tab_frame, tab_name) | |
| # Instructions | |
| instruction_label = QLabel("Select images to retranslate:") | |
| instruction_font = QFont('Arial', 11) | |
| instruction_label.setFont(instruction_font) | |
| tab_layout.addWidget(instruction_label) | |
| # Listbox (QListWidget has built-in scrolling) | |
| listbox = QListWidget() | |
| listbox.setSelectionMode(QListWidget.ExtendedSelection) | |
| # Use 16% of screen width (half of original ~31% for 1920px screen) | |
| min_width, _ = self._get_dialog_size(0.16, 0) | |
| listbox.setMinimumWidth(min_width) | |
| tab_layout.addWidget(listbox) | |
| # Find files | |
| file_info = [] | |
| # Add HTML files (any .html/.xhtml/.htm, not just response_*) | |
| for file in os.listdir(output_dir): | |
| if file.lower().endswith(('.html', '.xhtml', '.htm')): | |
| match = re.match(r'^response_(\d+)_([^.]*).(?:html?|xhtml|htm)(?:\.xhtml)?$', file, re.IGNORECASE) | |
| if match: | |
| index = match.group(1) | |
| base_name = match.group(2) | |
| display = f"📄 Image {index} | {base_name} | ✅ Completed" | |
| else: | |
| display = f"📄 {file} | ✅ Completed" | |
| listbox.addItem(display) | |
| file_info.append({ | |
| 'type': 'translated', | |
| 'file': file, | |
| 'path': os.path.join(output_dir, file) | |
| }) | |
| # Add cover images | |
| images_dir = os.path.join(output_dir, "images") | |
| if os.path.exists(images_dir): | |
| for file in sorted(os.listdir(images_dir)): | |
| if file.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp')): | |
| display = f"🖼️ Cover | {file} | ⏭️ Skipped" | |
| listbox.addItem(display) | |
| file_info.append({ | |
| 'type': 'cover', | |
| 'file': file, | |
| 'path': os.path.join(images_dir, file) | |
| }) | |
| # Selection count | |
| selection_count_label = QLabel("Selected: 0") | |
| selection_font = QFont('Arial', 9) | |
| selection_count_label.setFont(selection_font) | |
| tab_layout.addWidget(selection_count_label) | |
| def update_selection_count(): | |
| count = len(listbox.selectedItems()) | |
| selection_count_label.setText(f"Selected: {count}") | |
| listbox.itemSelectionChanged.connect(update_selection_count) | |
| # Right-click context menu (Open File) | |
| def _open_file_for_row(row): | |
| if row < 0 or row >= len(file_info): | |
| return | |
| info = file_info[row] | |
| path = info.get('path') | |
| if not path or not os.path.exists(path): | |
| self._show_message('error', "File Missing", f"File not found:\n{path}", parent=parent_dialog) | |
| return | |
| try: | |
| QDesktopServices.openUrl(QUrl.fromLocalFile(path)) | |
| except Exception as e: | |
| self._show_message('error', "Open Failed", str(e), parent=parent_dialog) | |
| def _show_context_menu(pos): | |
| item = listbox.itemAt(pos) | |
| if not item: | |
| return | |
| row = listbox.row(item) | |
| menu = QMenu(listbox) | |
| menu.setStyleSheet( | |
| "QMenu {" | |
| " padding: 4px;" | |
| " background-color: #2b2b2b;" | |
| " color: white;" | |
| " border: 1px solid #5a9fd4;" | |
| "} " | |
| "QMenu::icon { width: 0px; } " | |
| "QMenu::item {" | |
| " padding: 6px 12px;" | |
| " background-color: transparent;" | |
| "} " | |
| "QMenu::item:selected {" | |
| " background-color: #17a2b8;" | |
| " color: white;" | |
| "} " | |
| "QMenu::item:pressed {" | |
| " background-color: #138496;" | |
| "}" | |
| ) | |
| act_open = menu.addAction("📂 Open File") | |
| chosen = menu.exec(listbox.mapToGlobal(pos)) | |
| if chosen == act_open: | |
| _open_file_for_row(row) | |
| listbox.setContextMenuPolicy(Qt.CustomContextMenu) | |
| listbox.customContextMenuRequested.connect(_show_context_menu) | |
| return { | |
| 'type': 'image_folder', | |
| 'folder_path': folder_path, | |
| 'output_dir': output_dir, | |
| 'listbox': listbox, | |
| 'file_info': file_info, | |
| 'selection_count_label': selection_count_label | |
| } | |
| def _force_retranslation_images_folder(self, folder_path): | |
| """Handle force retranslation for image folders""" | |
| # If folder_path is actually a file (single image), get its directory | |
| if os.path.isfile(folder_path): | |
| # Single image file - use basename without extension | |
| folder_name = os.path.splitext(os.path.basename(folder_path))[0] | |
| else: | |
| # Folder - use folder name as-is | |
| folder_name = os.path.basename(folder_path) | |
| # Check if we already have a cached dialog for this folder | |
| folder_key = os.path.abspath(folder_path) | |
| if hasattr(self, '_image_retranslation_dialog_cache') and folder_key in self._image_retranslation_dialog_cache: | |
| cached_dialog = self._image_retranslation_dialog_cache[folder_key] | |
| if cached_dialog: | |
| # Reuse existing dialog - just show it | |
| try: | |
| # Click stored refresh button or call stored refresh func on reuse | |
| if hasattr(cached_dialog, '_refresh_button') and cached_dialog._refresh_button: | |
| QTimer.singleShot(0, cached_dialog._refresh_button.click) | |
| elif hasattr(cached_dialog, '_refresh_func'): | |
| QTimer.singleShot(0, cached_dialog._refresh_func) | |
| except Exception: | |
| pass | |
| cached_dialog.show() | |
| cached_dialog.raise_() | |
| cached_dialog.activateWindow() | |
| return | |
| # Look for output folder in the SCRIPT'S directory, not relative to the selected folder | |
| script_dir = os.getcwd() # Current working directory where the script is running | |
| # Check multiple possible output folder patterns IN THE SCRIPT DIRECTORY | |
| possible_output_dirs = [ | |
| os.path.join(script_dir, folder_name), # Script dir + folder name (without extension) | |
| os.path.join(script_dir, f"{folder_name}_translated"), # Script dir + folder_translated | |
| folder_name, # Just the folder name in current directory | |
| f"{folder_name}_translated", # folder_translated in current directory | |
| ] | |
| # Check for output directory override | |
| override_dir = os.environ.get('OUTPUT_DIRECTORY') | |
| if not override_dir and hasattr(self, 'config'): | |
| override_dir = self.config.get('output_directory') | |
| if override_dir: | |
| # If override is set, check inside it for the folder name | |
| possible_output_dirs.insert(0, os.path.join(override_dir, folder_name)) | |
| possible_output_dirs.insert(1, os.path.join(override_dir, f"{folder_name}_translated")) | |
| output_dir = None | |
| for possible_dir in possible_output_dirs: | |
| print(f"Checking: {possible_dir}") | |
| if os.path.exists(possible_dir): | |
| # Check if it has translation_progress.json or HTML files | |
| if os.path.exists(os.path.join(possible_dir, "translation_progress.json")): | |
| output_dir = possible_dir | |
| print(f"Found output directory with progress tracker: {output_dir}") | |
| break | |
| # Check if it has any HTML files | |
| elif os.path.isdir(possible_dir): | |
| try: | |
| files = os.listdir(possible_dir) | |
| if any(f.lower().endswith(('.html', '.xhtml', '.htm')) for f in files): | |
| output_dir = possible_dir | |
| print(f"Found output directory with HTML files: {output_dir}") | |
| break | |
| except: | |
| pass | |
| if not output_dir: | |
| QMessageBox.information(self, "Info", | |
| f"No translation output found for '{folder_name}'.\n\n" | |
| f"Selected folder: {folder_path}\n" | |
| f"Script directory: {script_dir}\n\n" | |
| f"Checked locations:\n" + "\n".join(f"- {d}" for d in possible_output_dirs)) | |
| return | |
| print(f"Using output directory: {output_dir}") | |
| # Check for progress tracking file | |
| progress_file = os.path.join(output_dir, "translation_progress.json") | |
| has_progress_tracking = os.path.exists(progress_file) | |
| print(f"Progress tracking: {has_progress_tracking} at {progress_file}") | |
| # Find all HTML files in the output directory | |
| html_files = [] | |
| image_files = [] | |
| progress_data = None | |
| if has_progress_tracking: | |
| # Load progress data for image translations | |
| try: | |
| with open(progress_file, 'r', encoding='utf-8') as f: | |
| progress_data = json.load(f) | |
| print(f"Loaded progress data with {len(progress_data)} entries") | |
| # Extract files from progress data | |
| # The structure appears to use hash keys at the root level | |
| for key, value in progress_data.items(): | |
| if isinstance(value, dict) and 'output_file' in value: | |
| output_file = value['output_file'] | |
| # Handle both forward and backslashes in paths | |
| output_file = output_file.replace('\\', '/') | |
| if '/' in output_file: | |
| output_file = os.path.basename(output_file) | |
| html_files.append(output_file) | |
| print(f"Found tracked file: {output_file}") | |
| except Exception as e: | |
| print(f"Error loading progress file: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| has_progress_tracking = False | |
| # Also scan directory for any HTML files not in progress | |
| # Include all .html/.xhtml/.htm files plus generated image files | |
| try: | |
| for file in os.listdir(output_dir): | |
| file_path = os.path.join(output_dir, file) | |
| # Include HTML files (any name) | |
| if (os.path.isfile(file_path) and | |
| file.lower().endswith(('.html', '.xhtml', '.htm')) and | |
| file not in html_files): | |
| html_files.append(file) | |
| print(f"Found HTML file: {file}") | |
| # Also include generated image files (not in images/ subdirectory) | |
| elif (os.path.isfile(file_path) and | |
| file.lower().endswith(('.png', '.jpg', '.jpeg', '.webp', '.gif')) and | |
| file not in html_files): | |
| html_files.append(file) # Add to html_files for now, will be handled separately | |
| print(f"Found generated image file: {file}") | |
| except Exception as e: | |
| print(f"Error scanning directory: {e}") | |
| # Check for images subdirectory (cover images) | |
| images_dir = os.path.join(output_dir, "images") | |
| if os.path.exists(images_dir): | |
| try: | |
| for file in os.listdir(images_dir): | |
| if file.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp')): | |
| image_files.append(file) | |
| except Exception as e: | |
| print(f"Error scanning images directory: {e}") | |
| print(f"Total files found: {len(html_files)} HTML, {len(image_files)} images") | |
| if not html_files and not image_files: | |
| QMessageBox.information(self, "Info", | |
| f"No translated files found in: {output_dir}\n\n" | |
| f"Progress tracking: {'Yes' if has_progress_tracking else 'No'}") | |
| return | |
| # Create dialog | |
| dialog = QDialog(self) | |
| dialog.setWindowTitle("Progress Manager - Images") | |
| dialog.setWindowFlag(Qt.WindowStaysOnTopHint, True) | |
| dialog.setWindowModality(Qt.NonModal) | |
| # Decreased width to 18%, increased height to 25% for better vertical space | |
| width, height = self._get_dialog_size(0.18, 0.25) | |
| dialog.resize(width, height) | |
| # Set icon | |
| try: | |
| from PySide6.QtGui import QIcon | |
| if hasattr(self, 'base_dir'): | |
| base_dir = self.base_dir | |
| else: | |
| base_dir = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) | |
| ico_path = os.path.join(base_dir, 'Halgakos.ico') | |
| if os.path.isfile(ico_path): | |
| dialog.setWindowIcon(QIcon(ico_path)) | |
| except Exception as e: | |
| print(f"Failed to load icon: {e}") | |
| dialog_layout = QVBoxLayout(dialog) | |
| # Create listbox (QListWidget has built-in scrolling) | |
| listbox = QListWidget() | |
| listbox.setSelectionMode(QListWidget.ExtendedSelection) | |
| # Use 16% of screen width (half of original ~31% for 1920px screen) | |
| min_width, _ = self._get_dialog_size(0.16, 0) | |
| listbox.setMinimumWidth(min_width) | |
| dialog_layout.addWidget(listbox) | |
| # Keep track of file info | |
| file_info = [] | |
| # Add translated HTML files | |
| for html_file in sorted(set(html_files)): # Use set to avoid duplicates | |
| # Extract original image name from HTML filename | |
| # Expected format: response_001_imagename.html | |
| match = re.match(r'response_(\d+)_(.+)\.html', html_file) | |
| if match: | |
| index = match.group(1) | |
| base_name = match.group(2) | |
| display = f"📄 Image {index} | {base_name} | ✅ Completed" | |
| else: | |
| display = f"📄 {html_file} | ✅ Completed" | |
| listbox.addItem(display) | |
| # Find the hash key for this file if progress tracking exists | |
| hash_key = None | |
| if progress_data: | |
| for key, value in progress_data.items(): | |
| if isinstance(value, dict) and 'output_file' in value: | |
| if html_file in value['output_file']: | |
| hash_key = key | |
| break | |
| file_info.append({ | |
| 'type': 'translated', | |
| 'file': html_file, | |
| 'path': os.path.join(output_dir, html_file), | |
| 'hash_key': hash_key, | |
| 'output_dir': output_dir # Store for later use | |
| }) | |
| # Add cover images | |
| for img_file in sorted(image_files): | |
| display = f"🖼️ Cover | {img_file} | ⏭️ Skipped (cover)" | |
| listbox.addItem(display) | |
| file_info.append({ | |
| 'type': 'cover', | |
| 'file': img_file, | |
| 'path': os.path.join(images_dir, img_file), | |
| 'hash_key': None, | |
| 'output_dir': output_dir | |
| }) | |
| # Selection count label | |
| selection_count_label = QLabel("Selected: 0") | |
| selection_font = QFont('Arial', 10) | |
| selection_count_label.setFont(selection_font) | |
| dialog_layout.addWidget(selection_count_label) | |
| def update_selection_count(): | |
| count = len(listbox.selectedItems()) | |
| selection_count_label.setText(f"Selected: {count}") | |
| listbox.itemSelectionChanged.connect(update_selection_count) | |
| # ==== Context menu for image list ==== | |
| def _open_file_for_index(idx): | |
| info_list = refresh_data.get('file_info', file_info) | |
| if idx < 0 or idx >= len(info_list): | |
| return | |
| info = info_list[idx] | |
| path = info.get('path') | |
| if not path or not os.path.exists(path): | |
| self._show_message('error', "File Missing", f"File not found:\n{path}", parent=dialog) | |
| return | |
| try: | |
| QDesktopServices.openUrl(QUrl.fromLocalFile(path)) | |
| except Exception as e: | |
| self._show_message('error', "Open Failed", str(e), parent=dialog) | |
| def _show_context_menu(pos): | |
| item = listbox.itemAt(pos) | |
| if not item: | |
| return | |
| row = listbox.row(item) | |
| menu = QMenu(listbox) | |
| menu.setStyleSheet( | |
| "QMenu {" | |
| " padding: 4px;" | |
| " background-color: #2b2b2b;" | |
| " color: white;" | |
| " border: 1px solid #5a9fd4;" | |
| "} " | |
| "QMenu::icon { width: 0px; } " | |
| "QMenu::item {" | |
| " padding: 6px 12px;" | |
| " background-color: transparent;" | |
| "} " | |
| "QMenu::item:selected {" | |
| " background-color: #17a2b8;" | |
| " color: white;" | |
| "} " | |
| "QMenu::item:pressed {" | |
| " background-color: #138496;" | |
| "}" | |
| ) | |
| act_open = menu.addAction("📂 Open File") | |
| act_delete = menu.addAction("🔁 Delete / Retranslate") | |
| chosen = menu.exec(listbox.mapToGlobal(pos)) | |
| if chosen == act_open: | |
| _open_file_for_index(row) | |
| elif chosen == act_delete: | |
| retranslate_selected() | |
| listbox.setContextMenuPolicy(Qt.CustomContextMenu) | |
| listbox.customContextMenuRequested.connect(_show_context_menu) | |
| # Button frame | |
| button_frame = QWidget() | |
| button_layout = QGridLayout(button_frame) | |
| dialog_layout.addWidget(button_frame) | |
| def select_all(): | |
| listbox.selectAll() | |
| update_selection_count() | |
| def clear_selection(): | |
| listbox.clearSelection() | |
| update_selection_count() | |
| def select_translated(): | |
| listbox.clearSelection() | |
| for idx, info in enumerate(file_info): | |
| if info['type'] == 'translated': | |
| listbox.item(idx).setSelected(True) | |
| update_selection_count() | |
| def mark_as_skipped(): | |
| """Move selected images to the images folder to be skipped""" | |
| selected_items = listbox.selectedItems() | |
| if not selected_items: | |
| QMessageBox.warning(self, "No Selection", "Please select at least one image to mark as skipped.") | |
| return | |
| # Get all selected items | |
| selected_indices = [listbox.row(item) for item in selected_items] | |
| items_with_info = [(i, file_info[i]) for i in selected_indices] | |
| # Filter out items already in images folder (covers) | |
| items_to_move = [(i, item) for i, item in items_with_info if item['type'] != 'cover'] | |
| if not items_to_move: | |
| QMessageBox.information(self, "Info", "Selected items are already in the images folder (skipped).") | |
| return | |
| count = len(items_to_move) | |
| reply = QMessageBox.question(self, "Confirm Mark as Skipped", | |
| f"Move {count} translated image(s) to the images folder?\n\n" | |
| "This will:\n" | |
| "• Delete the translated HTML files\n" | |
| "• Copy source images to the images folder\n" | |
| "• Skip these images in future translations", | |
| QMessageBox.Yes | QMessageBox.No) | |
| if reply != QMessageBox.Yes: | |
| return | |
| # Create images directory if it doesn't exist | |
| images_dir = os.path.join(output_dir, "images") | |
| os.makedirs(images_dir, exist_ok=True) | |
| moved_count = 0 | |
| failed_count = 0 | |
| for idx, item in items_to_move: | |
| try: | |
| # Extract the original image name from the HTML filename | |
| # Expected format: response_001_imagename.html (also accept compound extensions) | |
| html_file = item['file'] | |
| match = re.match(r'^response_\d+_([^\.]*)\.(?:html?|xhtml|htm)(?:\.xhtml)?$', html_file, re.IGNORECASE) | |
| if match: | |
| base_name = match.group(1) | |
| # Try to find the original image with common extensions | |
| original_found = False | |
| # Look for the source image in multiple locations | |
| search_paths = [ | |
| folder_path, # Original folder path | |
| os.path.dirname(folder_path), # Parent of folder path | |
| os.getcwd(), # Script directory | |
| ] | |
| for ext in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']: | |
| for search_path in search_paths: | |
| if not search_path or not os.path.exists(search_path): | |
| continue | |
| # Check in the search path | |
| possible_source = os.path.join(search_path, base_name + ext) | |
| if os.path.exists(possible_source) and os.path.isfile(possible_source): | |
| # Copy to images folder | |
| dest_path = os.path.join(images_dir, base_name + ext) | |
| if not os.path.exists(dest_path): | |
| import shutil | |
| shutil.copy2(possible_source, dest_path) | |
| print(f"Copied {base_name + ext} from {possible_source} to images folder") | |
| original_found = True | |
| break | |
| if original_found: | |
| break | |
| if not original_found: | |
| print(f"Warning: Could not find original image for {html_file} in: {search_paths}") | |
| # Even if source not found, we can still delete the HTML and mark it | |
| # Delete the HTML translation file | |
| if os.path.exists(item['path']): | |
| os.remove(item['path']) | |
| print(f"Deleted translation: {item['path']}") | |
| # Remove from progress tracking if applicable | |
| if progress_data and item.get('hash_key'): | |
| hash_key = item['hash_key'] | |
| # Check nested structure first | |
| if 'images' in progress_data and hash_key in progress_data['images']: | |
| del progress_data['images'][hash_key] | |
| # Check flat structure | |
| elif hash_key in progress_data: | |
| del progress_data[hash_key] | |
| # Update the listbox display | |
| display = f"🖼️ Skipped | {base_name if match else item['file']} | ⏭️ Moved to images folder" | |
| listbox.item(idx).setText(display) | |
| # Update file_info | |
| file_info[idx] = { | |
| 'type': 'cover', # Treat as cover type since it's in images folder | |
| 'file': base_name + ext if match and original_found else item['file'], | |
| 'path': os.path.join(images_dir, base_name + ext if match and original_found else item['file']), | |
| 'hash_key': None, | |
| 'output_dir': output_dir | |
| } | |
| moved_count += 1 | |
| except Exception as e: | |
| print(f"Failed to process {item['file']}: {e}") | |
| failed_count += 1 | |
| # Save updated progress if modified | |
| if progress_data: | |
| try: | |
| with open(progress_file, 'w', encoding='utf-8') as f: | |
| json.dump(progress_data, f, ensure_ascii=False, indent=2) | |
| print(f"Updated progress tracking file") | |
| except Exception as e: | |
| print(f"Failed to update progress file: {e}") | |
| # Auto-refresh the display to show updated status | |
| if 'refresh_data' in locals(): | |
| self._refresh_image_folder_data(refresh_data) | |
| # Update selection count | |
| update_selection_count() | |
| # Show result | |
| if failed_count > 0: | |
| QMessageBox.warning(self, "Partial Success", | |
| f"Moved {moved_count} image(s) to be skipped.\n" | |
| f"Failed to process {failed_count} item(s).") | |
| else: | |
| QMessageBox.information(self, "Success", | |
| f"Moved {moved_count} image(s) to the images folder.\n" | |
| "They will be skipped in future translations.") | |
| def retranslate_selected(): | |
| selected_items = listbox.selectedItems() | |
| if not selected_items: | |
| QMessageBox.warning(self, "No Selection", "Please select at least one file.") | |
| return | |
| selected_indices = [listbox.row(item) for item in selected_items] | |
| # Count types | |
| translated_count = sum(1 for i in selected_indices if file_info[i]['type'] == 'translated') | |
| cover_count = sum(1 for i in selected_indices if file_info[i]['type'] == 'cover') | |
| # Build confirmation message | |
| msg_parts = [] | |
| if translated_count > 0: | |
| msg_parts.append(f"{translated_count} translated image(s)") | |
| if cover_count > 0: | |
| msg_parts.append(f"{cover_count} cover image(s)") | |
| confirm_msg = f"This will delete {' and '.join(msg_parts)}.\n\nContinue?" | |
| reply = QMessageBox.question(self, "Confirm Deletion", confirm_msg, | |
| QMessageBox.Yes | QMessageBox.No) | |
| if reply != QMessageBox.Yes: | |
| return | |
| # Delete selected files | |
| deleted_count = 0 | |
| for idx in selected_indices: | |
| info = file_info[idx] | |
| try: | |
| if os.path.exists(info['path']): | |
| os.remove(info['path']) | |
| deleted_count += 1 | |
| print(f"Deleted: {info['path']}") | |
| # Remove from progress tracking if applicable | |
| if progress_data and info.get('hash_key'): | |
| hash_key = info['hash_key'] | |
| # Check nested structure first | |
| if 'images' in progress_data and hash_key in progress_data['images']: | |
| del progress_data['images'][hash_key] | |
| print(f"Removed {hash_key} from progress_data['images']") | |
| # Check flat structure | |
| elif hash_key in progress_data: | |
| del progress_data[hash_key] | |
| print(f"Removed {hash_key} from progress_data") | |
| except Exception as e: | |
| print(f"Failed to delete {info['path']}: {e}") | |
| # ALWAYS save progress file after any deletions | |
| if deleted_count > 0 and progress_data: | |
| try: | |
| with open(progress_file, 'w', encoding='utf-8') as f: | |
| json.dump(progress_data, f, ensure_ascii=False, indent=2) | |
| print(f"Updated progress tracking file") | |
| except Exception as e: | |
| print(f"Failed to update progress file: {e}") | |
| # Auto-refresh the display to show updated status | |
| if 'refresh_data' in locals(): | |
| self._refresh_image_folder_data(refresh_data) | |
| QMessageBox.information(self, "Success", | |
| f"Deleted {deleted_count} file(s).\n\n" | |
| "They will be retranslated on the next run.") | |
| dialog.close() | |
| # Add buttons in grid layout (similar to EPUB/text retranslation) | |
| # Row 0: Selection buttons | |
| btn_select_all = QPushButton("Select All") | |
| btn_select_all.setStyleSheet("QPushButton { background-color: #17a2b8; color: white; padding: 5px 15px; font-weight: bold; }") | |
| btn_select_all.clicked.connect(select_all) | |
| button_layout.addWidget(btn_select_all, 0, 0) | |
| btn_clear_selection = QPushButton("Clear Selection") | |
| btn_clear_selection.setStyleSheet("QPushButton { background-color: #6c757d; color: white; padding: 5px 15px; font-weight: bold; }") | |
| btn_clear_selection.clicked.connect(clear_selection) | |
| button_layout.addWidget(btn_clear_selection, 0, 1) | |
| btn_select_translated = QPushButton("Select Translated") | |
| btn_select_translated.setStyleSheet("QPushButton { background-color: #28a745; color: white; padding: 5px 15px; font-weight: bold; }") | |
| btn_select_translated.clicked.connect(select_translated) | |
| button_layout.addWidget(btn_select_translated, 0, 2) | |
| btn_mark_skipped = QPushButton("Mark as Skipped") | |
| btn_mark_skipped.setStyleSheet("QPushButton { background-color: #e0a800; color: white; padding: 5px 15px; font-weight: bold; }") | |
| btn_mark_skipped.clicked.connect(mark_as_skipped) | |
| button_layout.addWidget(btn_mark_skipped, 0, 3) | |
| # Row 1: Action buttons | |
| btn_delete = QPushButton("Delete Selected") | |
| btn_delete.setStyleSheet("QPushButton { background-color: #dc3545; color: white; padding: 5px 15px; font-weight: bold; }") | |
| btn_delete.clicked.connect(retranslate_selected) | |
| button_layout.addWidget(btn_delete, 1, 0, 1, 1) | |
| # Add animated refresh button | |
| btn_refresh = AnimatedRefreshButton(" Refresh") # Double space for icon padding | |
| btn_refresh.setStyleSheet( | |
| "QPushButton { " | |
| "background-color: #17a2b8; " | |
| "color: white; " | |
| "padding: 5px 15px; " | |
| "font-weight: bold; " | |
| "}" | |
| ) | |
| # Create data dict for refresh function | |
| refresh_data = { | |
| 'type': 'image_folder', | |
| 'listbox': listbox, | |
| 'file_info': file_info, | |
| 'progress_file': progress_file, | |
| 'progress_data': progress_data, | |
| 'output_dir': output_dir, | |
| 'folder_path': folder_path, | |
| 'selection_count_label': selection_count_label, | |
| 'dialog': dialog | |
| } | |
| # Create refresh handler with animation | |
| def animated_refresh(): | |
| import time | |
| btn_refresh.start_animation() | |
| btn_refresh.setEnabled(False) | |
| # Track start time for minimum animation duration | |
| start_time = time.time() | |
| min_animation_duration = 0.8 # 800ms minimum | |
| # Use QTimer to run refresh after animation starts | |
| def do_refresh(): | |
| try: | |
| self._refresh_image_folder_data(refresh_data) | |
| # Calculate remaining time to meet minimum animation duration | |
| elapsed = time.time() - start_time | |
| remaining = max(0, min_animation_duration - elapsed) | |
| # Schedule animation stop after remaining time | |
| def finish_animation(): | |
| btn_refresh.stop_animation() | |
| btn_refresh.setEnabled(True) | |
| if remaining > 0: | |
| QTimer.singleShot(int(remaining * 1000), finish_animation) | |
| else: | |
| finish_animation() | |
| except Exception as e: | |
| print(f"Error during refresh: {e}") | |
| btn_refresh.stop_animation() | |
| btn_refresh.setEnabled(True) | |
| QTimer.singleShot(50, do_refresh) # Small delay to let animation start | |
| btn_refresh.clicked.connect(animated_refresh) | |
| button_layout.addWidget(btn_refresh, 1, 1, 1, 1) | |
| # Store for reuse-trigger | |
| dialog._refresh_button = btn_refresh | |
| btn_cancel = QPushButton("Cancel") | |
| btn_cancel.setStyleSheet("QPushButton { background-color: #6c757d; color: white; padding: 5px 15px; font-weight: bold; }") | |
| btn_cancel.clicked.connect(dialog.close) | |
| button_layout.addWidget(btn_cancel, 1, 2, 1, 2) | |
| # Override close event to hide instead of destroy | |
| def closeEvent(event): | |
| event.ignore() # Ignore the close event | |
| dialog.hide() # Just hide the dialog | |
| dialog.closeEvent = closeEvent | |
| # Cache the dialog for reuse | |
| if not hasattr(self, '_image_retranslation_dialog_cache'): | |
| self._image_retranslation_dialog_cache = {} | |
| folder_key = os.path.abspath(folder_path) | |
| self._image_retranslation_dialog_cache[folder_key] = dialog | |
| # Programmatically click the Refresh button once on open to ensure latest data (fires same slot) | |
| QTimer.singleShot(0, btn_refresh.click) | |
| # Show the dialog (non-modal to allow interaction with other windows) | |
| dialog.show() | |