Glossarion / Retranslation_GUI.py
Shirochi's picture
Upload 93 files
ec038f4 verified
"""
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()