Glossarion / other_settings.py
Shirochi's picture
Upload 93 files
ec038f4 verified
"""Other Settings Dialog Methods for Glossarion
This module contains all the methods related to the "Other Settings" dialog.
These methods are dynamically injected into the TranslatorGUI class.
"""
# Standard library imports
import os
import json
import re
import sys
# PySide6 imports (fully migrated from Tkinter)
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QPushButton,
QTextEdit,
QGroupBox,
QMessageBox,
QScrollArea,
QWidget,
QGridLayout,
QFrame,
QCheckBox,
QComboBox,
QLineEdit,
)
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon, QPixmap
from PySide6.QtCore import QObject, Signal
class ConnTestBridge(QObject):
finished = Signal(list)
# Import dependencies for RotatableLabel
from PySide6.QtCore import Property, QPropertyAnimation, QEasingCurve
from PySide6.QtGui import QTransform
class RotatableLabel(QLabel):
def __init__(self, parent=None):
super().__init__(parent)
self._rotation = 0
self._original_pixmap = None
def set_rotation(self, angle):
self._rotation = angle
if self._original_pixmap:
transform = QTransform()
transform.rotate(angle)
rotated = self._original_pixmap.transformed(transform, Qt.SmoothTransformation)
self.setPixmap(rotated)
def get_rotation(self):
return self._rotation
rotation = Property(float, get_rotation, set_rotation)
def set_original_pixmap(self, pixmap):
self._original_pixmap = pixmap
self.setPixmap(pixmap)
# Local imports - these will be available through the TranslatorGUI instance
# WindowManager and UIHelper removed - not needed in PySide6
from translator_gui import CONFIG_FILE
from ai_hunter_enhanced import AIHunterConfigGUI
# Bring in backup management methods
from config_backup import (
_backup_config_file,
_restore_config_from_backup,
_create_manual_config_backup,
_open_backup_folder,
_manual_restore_config,
)
def _rename_output_files_for_retain(gui, retain: bool, output_dir: str = None):
"""Rename output files when the 'retain source extension' toggle changes.
When *retain* is True (toggle ON):
- Remove the ``response_`` prefix from each file.
- Restore the extension from content.opf (strip stacked extensions
like ``.html.html`` or ``.htm.xhtml`` first).
When *retain* is False (toggle OFF):
- Add the ``response_`` prefix if missing.
- Replace the file extension with ``.html`` (strip stacked
extensions first).
Does **nothing** if content.opf is not found in the output directory.
"""
import xml.etree.ElementTree as _ET
# Determine the output directory
if not output_dir or not os.path.isdir(output_dir):
# Try multiple sources to find the output directory
candidates = []
# 1. gui.output_dir (if set)
_od = getattr(gui, 'output_dir', None)
if _od:
candidates.append(_od)
# 2. Derive from selected EPUB + output override
override = os.environ.get('OUTPUT_DIRECTORY') or ''
if not override:
_cfg = getattr(gui, 'config', None)
if isinstance(_cfg, dict):
override = _cfg.get('output_directory', '') or ''
epub_path = os.environ.get('EPUB_PATH', '')
if not epub_path:
_sf = getattr(gui, 'selected_files', None)
if _sf:
for _f in _sf:
if str(_f).lower().endswith('.epub'):
epub_path = str(_f)
break
if epub_path:
base_name = os.path.splitext(os.path.basename(epub_path))[0]
if override:
candidates.append(os.path.join(override, base_name))
candidates.append(base_name) # relative to CWD
# 3. OUTPUT_DIR env (legacy fallback)
_envod = os.environ.get('OUTPUT_DIR', '')
if _envod:
candidates.append(_envod)
# Pick the first candidate that is an existing directory
output_dir = None
for c in candidates:
if c and os.path.isdir(c):
output_dir = c
break
if not output_dir or not os.path.isdir(output_dir):
return ('no_opf',)
opf_path = os.path.join(output_dir, 'content.opf')
if not os.path.exists(opf_path):
return ('no_opf',)
# Parse content.opf to build a set of original basenames + extensions
try:
tree = _ET.parse(opf_path)
root = tree.getroot()
ns_uri = ''
if root.tag.startswith('{'):
ns_uri = root.tag[1:root.tag.index('}')]
ns = {'opf': ns_uri} if ns_uri else {}
# manifest: collect HTML/XHTML items β†’ {basename_without_ext: original_ext}
opf_names = {} # core_name β†’ original extension (e.g. '.xhtml')
manifest_xpath = './/opf:manifest/opf:item' if ns else './/{http://www.idpf.org/2007/opf}manifest/{http://www.idpf.org/2007/opf}item'
for item in root.findall(manifest_xpath, ns if ns else None):
href = item.get('href', '')
media = item.get('media-type', '')
if not href:
continue
if 'html' not in media.lower() and not href.lower().endswith(('.html', '.xhtml', '.htm')):
continue
basename = os.path.basename(href)
# Split into core name and single extension
name, ext = os.path.splitext(basename)
if ext:
opf_names[name] = ext # e.g. 'chapter001' β†’ '.xhtml'
except Exception:
return ('no_opf',)
if not opf_names:
return ('no_opf',)
# Known HTML-like extensions to strip when peeling stacked extensions
_HTML_EXTS = {'.html', '.xhtml', '.htm', '.xml'}
def _strip_all_html_exts(filename: str):
"""Strip all trailing HTML-like extensions from *filename*.
Returns (core_name, list_of_stripped_exts).
Example: 'chapter001.html.html' β†’ ('chapter001', ['.html', '.html'])
"""
parts = []
while True:
name, ext = os.path.splitext(filename)
if ext.lower() in _HTML_EXTS:
parts.append(ext)
filename = name
else:
break
parts.reverse()
return filename, parts
renamed = 0
errors = []
for fname in os.listdir(output_dir):
fpath = os.path.join(output_dir, fname)
if not os.path.isfile(fpath):
continue
# Only consider HTML-like files
if not fname.lower().endswith(('.html', '.xhtml', '.htm')):
continue
if retain:
# --- Toggle ON: remove response_ prefix, restore opf extension ---
working = fname
if working.startswith('response_'):
working = working[len('response_'):]
core, _ = _strip_all_html_exts(working)
opf_ext = opf_names.get(core)
if opf_ext is None:
continue # not in content.opf β†’ skip
new_name = core + opf_ext
else:
# --- Toggle OFF: add response_ prefix, replace ext with .html ---
working = fname
if working.startswith('response_'):
# already has prefix β€” just fix extension
core, _ = _strip_all_html_exts(working[len('response_'):])
else:
core, _ = _strip_all_html_exts(working)
if core not in opf_names:
continue # not in content.opf β†’ skip
new_name = 'response_' + core + '.html'
if new_name == fname:
continue # nothing to change
new_path = os.path.join(output_dir, new_name)
if os.path.exists(new_path):
continue # target already exists β†’ skip to avoid overwrite
try:
os.rename(fpath, new_path)
renamed += 1
except Exception as e:
errors.append(f'{fname}: {e}')
# Update translation_progress.json so renamed files are still recognised
if renamed:
progress_path = os.path.join(output_dir, 'translation_progress.json')
if os.path.exists(progress_path):
try:
import json as _json
with open(progress_path, 'r', encoding='utf-8') as _pf:
prog = _json.load(_pf)
# Build a normalised→new_name map from the renames we just did
# (rebuild by scanning the current directory state)
_HTML_EXTS_L = {'.html', '.xhtml', '.htm', '.xml'}
def _norm_progress(fname):
if not fname:
return ''
base = os.path.basename(fname)
if base.startswith('response_'):
base = base[len('response_'):]
while True:
b, e = os.path.splitext(base)
if e.lower() in _HTML_EXTS_L:
base = b
else:
break
return base.lower()
# Collect current files keyed by normalised name
current_files = {}
for f in os.listdir(output_dir):
if f.lower().endswith(('.html', '.xhtml', '.htm')) and os.path.isfile(os.path.join(output_dir, f)):
current_files[_norm_progress(f)] = f
updated = 0
for _key, info in prog.get('chapters', {}).items():
old_out = info.get('output_file')
if not old_out:
continue
norm = _norm_progress(old_out)
new_out = current_files.get(norm)
if new_out and new_out != old_out:
info['output_file'] = new_out
updated += 1
if updated:
tmp = progress_path + '.tmp'
with open(tmp, 'w', encoding='utf-8') as _pf:
_json.dump(prog, _pf, ensure_ascii=False, indent=2)
if os.path.exists(progress_path):
os.remove(progress_path)
os.rename(tmp, progress_path)
except Exception:
pass # progress update is best-effort
# Log results
if hasattr(gui, 'append_log'):
if renamed:
mode = 'retain source names' if retain else 'response_ prefix'
gui.append_log(f'βœ… Renamed {renamed} file(s) to {mode}')
if errors:
for err in errors[:5]:
gui.append_log(f'⚠️ Rename error: {err}')
if renamed:
return ('renamed', renamed)
return ('no_files',)
def initialize_extraction_variables(gui_instance):
"""Initialize extraction-related variables early so profile switching works"""
# Initialize text_extraction_method_var if it doesn't exist
if not hasattr(gui_instance, 'text_extraction_method_var'):
# Check config for saved value, or use default
if gui_instance.config.get('extraction_mode') == 'enhanced':
gui_instance.text_extraction_method_var = 'enhanced'
else:
gui_instance.text_extraction_method_var = gui_instance.config.get('text_extraction_method', 'standard')
# Initialize file_filtering_level_var if it doesn't exist
if not hasattr(gui_instance, 'file_filtering_level_var'):
gui_instance.file_filtering_level_var = gui_instance.config.get('file_filtering_level', 'smart')
def setup_other_settings_methods(gui_instance):
"""Inject all other settings methods into the GUI instance"""
import types
import sys
# CRITICAL: Initialize extraction variables FIRST before any profile selection happens
initialize_extraction_variables(gui_instance)
# Get this module
current_module = sys.modules[__name__]
# List of all method names to bind
methods_to_bind = [
# Core profile methods (needed at GUI init)
'on_profile_select', 'save_profile', 'delete_profile', 'save_profiles',
'import_profiles', 'export_profiles',
# Other settings methods
'configure_rolling_summary_prompts', 'toggle_thinking_budget',
'toggle_gpt_reasoning_controls', 'toggle_anthropic_thinking_controls',
'open_other_settings',
'open_multi_api_key_manager', 'show_ai_hunter_settings',
'delete_translated_headers_file', 'delete_toc_txt_file', 'run_standalone_translate_headers', 'validate_epub_structure_gui',
'show_header_help_dialog',
'on_extraction_method_change', 'on_extraction_mode_change',
# Toggle methods
'toggle_extraction_workers', 'toggle_gemini_endpoint', 'toggle_ai_hunter',
'toggle_custom_endpoint_ui', 'toggle_more_endpoints',
'_toggle_multi_key_setting', '_toggle_http_tuning_controls',
'_toggle_anti_duplicate_controls', 'toggle_image_translation_section',
'toggle_anti_duplicate_section',
# Provider autocomplete methods
'_setup_provider_combobox_bindings', '_on_provider_combo_keyrelease',
'_commit_provider_autocomplete', '_scroll_provider_list_to_value',
'_validate_provider_selection',
# Section creation methods
'_create_context_management_section', '_create_response_handling_section',
'_create_prompt_management_section', '_create_processing_options_section',
'_create_image_translation_section', '_create_anti_duplicate_section',
'_create_custom_api_endpoints_section', '_create_debug_controls_section',
'_create_output_settings_section', '_create_danger_zone_section',
# Helper methods
'_create_multi_key_row', '_create_manual_config_backup', '_manual_restore_config',
'_open_backup_folder', '_backup_config_file', '_restore_config_from_backup',
'_check_azure_endpoint', '_update_azure_api_version_env',
'_reset_anti_duplicate_defaults', '_get_ai_hunter_status_text',
'create_ai_hunter_section', 'test_api_connections',
'_update_multi_key_status_label',
# Prompt configuration dialogs
'configure_translation_chunk_prompt', 'configure_image_chunk_prompt',
'configure_image_compression',
# Helper methods for styling
'_create_styled_checkbox', '_disable_combobox_mousewheel', '_disable_spinbox_mousewheel',
'_add_combobox_arrow'
]
# Bind each method to the GUI instance
for method_name in methods_to_bind:
if hasattr(current_module, method_name):
method = getattr(current_module, method_name)
if callable(method):
setattr(gui_instance, method_name, types.MethodType(method, gui_instance))
def _center_messagebox_buttons(msg_box):
"""Helper to center buttons in a QMessageBox"""
from PySide6.QtWidgets import QDialogButtonBox
button_box = msg_box.findChild(QDialogButtonBox)
if button_box:
button_box.setCenterButtons(True)
def _create_styled_checkbox(self, text):
"""Create a checkbox with proper checkmark using text overlay - from manga integration"""
from PySide6.QtWidgets import QCheckBox, QLabel
from PySide6.QtCore import Qt, QTimer
checkbox = QCheckBox(text)
# Don't set inline stylesheet - use the global stylesheet from container
# Create checkmark overlay
checkmark = QLabel("βœ“", checkbox)
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:
# Check if checkmark still exists and is valid
if checkmark and not checkmark.isHidden() or True: # Always try to set geometry
checkmark.setGeometry(2, 1, 14, 14)
except RuntimeError:
# Widget was already deleted
pass
def update_checkmark():
try:
# Check if both widgets still exist
if checkbox and checkmark:
if checkbox.isChecked():
position_checkmark()
checkmark.show()
else:
checkmark.hide()
except RuntimeError:
# Widget was already deleted
pass
checkbox.stateChanged.connect(update_checkmark)
# Use try-except to handle case where widgets are deleted before timer fires
def safe_init():
try:
position_checkmark()
update_checkmark()
except RuntimeError:
pass
QTimer.singleShot(0, safe_init)
return checkbox
def _disable_combobox_mousewheel(self, combobox):
"""Disable mousewheel scrolling on a combobox (PySide6)"""
combobox.wheelEvent = lambda event: None
def _disable_spinbox_mousewheel(self, spinbox):
"""Disable mousewheel scrolling on a spinbox (PySide6)"""
spinbox.wheelEvent = lambda event: None
def _add_combobox_arrow(self, combobox):
"""Add a unicode arrow overlay to a combobox"""
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QLabel
from PySide6.QtCore import Qt
arrow_label = QLabel("β–Ό", combobox)
arrow_label.setStyleSheet("""
QLabel {
color: white;
background: transparent;
font-size: 10pt;
border: none;
}
""")
arrow_label.setAlignment(Qt.AlignCenter)
arrow_label.setAttribute(Qt.WA_TransparentForMouseEvents)
def position_arrow():
try:
if arrow_label and combobox:
width = combobox.width()
height = combobox.height()
arrow_label.setGeometry(width - 20, (height - 16) // 2, 20, 16)
except RuntimeError:
pass
# Position arrow when combobox is resized
original_resize = combobox.resizeEvent
def new_resize(event):
original_resize(event)
position_arrow()
combobox.resizeEvent = new_resize
# Initial position
QTimer.singleShot(0, position_arrow)
class HeaderTranslationHelpDialog(QDialog):
"""Dialog to display detailed information about header translation functionality"""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Header Translation - Help")
self.setup_ui()
# Set icon if available
icon_path = os.path.join(os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.dirname(os.path.abspath(__file__)), "Halgakos.ico")
if os.path.exists(icon_path):
self.setWindowIcon(QIcon(icon_path))
def setup_ui(self):
"""Set up the dialog UI using ratios for sizing"""
# Use ratios for dialog size based on screen resolution
from PySide6.QtWidgets import QApplication
screen = QApplication.primaryScreen().availableGeometry()
width = int(screen.width() * 0.4) # 40% of screen width
height = int(screen.height() * 0.6) # 60% of screen height
self.resize(width, height)
# Center the dialog
self.move(
screen.x() + (screen.width() - width) // 2,
screen.y() + (screen.height() - height) // 2
)
layout = QVBoxLayout(self)
layout.setContentsMargins(
int(width * 0.03), # 3% of dialog width
int(height * 0.02), # 2% of dialog height
int(width * 0.03), # 3% of dialog width
int(height * 0.02) # 2% of dialog height
)
layout.setSpacing(int(height * 0.02)) # 2% of dialog height
# Title
title_label = QLabel("Chapter Header Translation - Detailed Guide")
title_label.setStyleSheet(f"""
QLabel {{
font-weight: bold;
font-size: {int(height * 0.025)}pt;
color: #6c7b7f;
padding-bottom: {int(height * 0.015)}px;
}}
""")
layout.addWidget(title_label)
# Scrollable content area
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
# Content widget
content_widget = QWidget()
content_layout = QVBoxLayout(content_widget)
content_layout.setContentsMargins(
int(width * 0.02), # 2% margins
int(height * 0.01),
int(width * 0.02),
int(height * 0.01)
)
content_layout.setSpacing(int(height * 0.015)) # 1.5% spacing
# Create sections with detailed explanations
sections = [
{
"title": "πŸ”„ Translation Modes",
"content": [
"β€’ OFF: Use existing headers from already translated chapters",
"β€’ ON: Extract all headers β†’ Translate in batch β†’ Update files"
]
},
{
"title": "βš™οΈ Options Explained",
"content": [
"β€’ Update headers in HTML files: Modifies the actual chapter files with translated headers",
"β€’ Save translations to .txt: Creates backup files with translation mappings",
"β€’ Headers per batch: Number of headers to translate simultaneously (affects API usage)"
]
},
{
"title": "🚫 Ignore Options",
"content": [
"β€’ Ignore header: Skip h1/h2/h3 tags (prevents re-translation of visible headers)",
"β€’ Use title: Include <title> tag in translation (translates document titles)"
]
},
{
"title": "⚠️ Fallback System",
"content": [
"β€’ Use Sorted Fallback: If OPF-based matching fails, use sorted index matching",
"β€’ WARNING: Less accurate - may mismatch chapters if file order differs from OPF spine",
"β€’ Only use if you're experiencing matching issues with standard mode"
]
},
{
"title": "πŸ“‚ Standalone Mode",
"content": [
"β€’ Uses content.opf-based exact mapping for precise chapter matching",
"β€’ Translates chapters with matching names (ignores 'response_' prefix and extensions)",
"β€’ The regular translation logic uses this logic as well"
]
},
{
"title": "πŸ—‘οΈ File Management",
"content": [
"β€’ Delete Header Files: Removes translated_headers.txt files for all selected EPUBs",
"β€’ Use this to reset translation state or clean up after testing",
"β€’ Safe operation - only removes translation cache files, not original content"
]
},
{
"title": "πŸ’‘ Best Practices",
"content": [
"β€’ Test with a small batch first to verify settings work correctly",
"β€’ Enable 'Save translations to .txt' for backup and debugging",
"β€’ Use 'Ignore header' if chapters already have translated visible titles",
"β€’ Keep 'Headers per batch' moderate to be within your output token limit"
]
}
]
font_size = max(9, int(height * 0.018)) # Scale font with dialog size, minimum 9pt
for section in sections:
# Section title
section_title = QLabel(section["title"])
section_title.setStyleSheet(f"""
QLabel {{
font-weight: bold;
font-size: {font_size + 1}pt;
color: #7f8c8d;
padding-top: {int(height * 0.01)}px;
padding-bottom: {int(height * 0.005)}px;
}}
""")
content_layout.addWidget(section_title)
# Section content
section_text = "\n".join(section["content"])
section_label = QLabel(section_text)
section_label.setStyleSheet(f"""
QLabel {{
font-size: {font_size}pt;
color: #95a5a6;
line-height: 1.4;
padding-left: {int(width * 0.02)}px;
padding-bottom: {int(height * 0.01)}px;
}}
""")
section_label.setWordWrap(True)
content_layout.addWidget(section_label)
scroll_area.setWidget(content_widget)
layout.addWidget(scroll_area)
# Close button
close_btn = QPushButton("Close")
close_btn.setFixedHeight(int(height * 0.05)) # 5% of dialog height
close_btn.clicked.connect(self.accept)
close_btn.setStyleSheet(f"""
QPushButton {{
background-color: #3498db;
color: white;
padding: {int(height * 0.01)}px {int(width * 0.03)}px;
border-radius: {int(height * 0.01)}px;
font-weight: bold;
font-size: {font_size}pt;
}}
QPushButton:hover {{
background-color: #2980b9;
}}
QPushButton:pressed {{
background-color: #21618c;
}}
""")
# Button layout
button_layout = QHBoxLayout()
button_layout.addStretch()
button_layout.addWidget(close_btn)
button_layout.addStretch()
layout.addLayout(button_layout)
def show_header_help_dialog(self):
"""Show the header translation help dialog"""
# Use the current GUI instance as parent, fallback to None if needed
parent = getattr(self, '_other_settings_dialog', None) or self
dialog = HeaderTranslationHelpDialog(parent)
dialog.exec()
def configure_rolling_summary_prompts(self):
"""Configure rolling summary prompts (PySide6)"""
from PySide6.QtGui import QIcon
# Create a non-modal dialog
from PySide6.QtWidgets import QApplication
dialog = QDialog(None)
dialog.setWindowTitle("Configure Memory System Prompts")
# Use screen ratios for sizing
screen = QApplication.primaryScreen().geometry()
width = int(screen.width() * 0.42) # 42% of screen width
height = int(screen.height() * 0.75) # 75% of screen height
dialog.resize(width, height)
# Set icon
try:
dialog.setWindowIcon(QIcon("halgakos.ico"))
except Exception:
pass
# Keep a reference so it isn't garbage-collected
self._rolling_summary_dialog = dialog
layout = QVBoxLayout(dialog)
# Title and description
title_lbl = QLabel("Memory System Configuration")
title_lbl.setStyleSheet("font-size: 16px; font-weight: bold;")
layout.addWidget(title_lbl)
desc_lbl = QLabel("Configure how the AI creates and maintains translation memory/context summaries.")
desc_lbl.setStyleSheet("color: gray;")
layout.addWidget(desc_lbl)
# System Prompt group
sys_group = QGroupBox("System Prompt (Role Definition)")
sys_v = QVBoxLayout(sys_group)
sys_help = QLabel("Defines the AI's role and behavior when creating summaries")
sys_help.setStyleSheet("color: #1f6feb;")
sys_v.addWidget(sys_help)
self.summary_system_text = QTextEdit()
self.summary_system_text.setAcceptRichText(False)
self.summary_system_text.setPlainText(getattr(self, 'rolling_summary_system_prompt', ''))
sys_v.addWidget(self.summary_system_text)
layout.addWidget(sys_group)
# User Prompt group
user_group = QGroupBox("User Prompt Template")
user_v = QVBoxLayout(user_group)
user_help = QLabel("Template for summary requests. Use {translations} for content placeholder")
user_help.setStyleSheet("color: #1f6feb;")
user_v.addWidget(user_help)
self.summary_user_text = QTextEdit()
self.summary_user_text.setAcceptRichText(False)
self.summary_user_text.setPlainText(getattr(self, 'rolling_summary_user_prompt', ''))
user_v.addWidget(self.summary_user_text)
layout.addWidget(user_group)
# Buttons row
btn_row = QHBoxLayout()
def _save_prompts():
self.rolling_summary_system_prompt = self.summary_system_text.toPlainText().strip()
self.rolling_summary_user_prompt = self.summary_user_text.toPlainText().strip()
self.config['rolling_summary_system_prompt'] = self.rolling_summary_system_prompt
self.config['rolling_summary_user_prompt'] = self.rolling_summary_user_prompt
os.environ['ROLLING_SUMMARY_SYSTEM_PROMPT'] = self.rolling_summary_system_prompt
os.environ['ROLLING_SUMMARY_USER_PROMPT'] = self.rolling_summary_user_prompt
QMessageBox.information(dialog, "Success", "Memory prompts saved!")
dialog.close()
def _reset_prompts():
res = QMessageBox.question(
dialog,
"Reset Prompts",
"Reset memory prompts to defaults?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if res == QMessageBox.Yes:
self.summary_system_text.setPlainText(getattr(self, 'default_rolling_summary_system_prompt', ''))
self.summary_user_text.setPlainText(getattr(self, 'default_rolling_summary_user_prompt', ''))
save_btn = QPushButton("Save")
save_btn.clicked.connect(_save_prompts)
btn_row.addWidget(save_btn)
reset_btn = QPushButton("Reset to Defaults")
reset_btn.clicked.connect(_reset_prompts)
btn_row.addWidget(reset_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(dialog.close)
btn_row.addWidget(cancel_btn)
layout.addLayout(btn_row)
# Show non-modally with smooth fade animation (no flash)
try:
from dialog_animations import show_dialog_with_fade
show_dialog_with_fade(dialog, duration=220)
except Exception:
dialog.show()
def toggle_thinking_budget(self):
"""Enable/disable thinking budget entry and labels based on checkbox state (PySide6 version)"""
try:
enabled = bool(self.enable_gemini_thinking_var)
if hasattr(self, 'thinking_budget_entry'):
self.thinking_budget_entry.setEnabled(enabled)
if hasattr(self, 'thinking_budget_label'):
self.thinking_budget_label.setEnabled(enabled)
color = "white" if enabled else "#808080"
self.thinking_budget_label.setStyleSheet(f"color: {color};")
if hasattr(self, 'thinking_tokens_label'):
self.thinking_tokens_label.setEnabled(enabled)
color = "white" if enabled else "#808080"
self.thinking_tokens_label.setStyleSheet(f"color: {color};")
if hasattr(self, 'thinking_level_combo'):
self.thinking_level_combo.setEnabled(enabled)
if hasattr(self, 'thinking_level_label'):
self.thinking_level_label.setEnabled(enabled)
color = "white" if enabled else "#808080"
self.thinking_level_label.setStyleSheet(f"color: {color};")
# Description label
if hasattr(self, 'gemini_desc_label'):
self.gemini_desc_label.setEnabled(enabled)
color = "gray" if enabled else "#606060"
self.gemini_desc_label.setStyleSheet(f"color: {color}; font-size: 10pt;")
except Exception:
pass
def toggle_anthropic_thinking_controls(self):
"""Enable/disable Anthropic thinking controls based on checkbox state"""
try:
enabled = bool(getattr(self, 'enable_anthropic_thinking_var', False))
if hasattr(self, 'anthropic_budget_label'):
self.anthropic_budget_label.setEnabled(enabled)
color = "white" if enabled else "#808080"
self.anthropic_budget_label.setStyleSheet(f"color: {color};")
if hasattr(self, 'anthropic_budget_entry'):
self.anthropic_budget_entry.setEnabled(enabled and not getattr(self, 'anthropic_force_adaptive_var', False))
if hasattr(self, 'anthropic_budget_tokens_label'):
self.anthropic_budget_tokens_label.setEnabled(enabled)
color = "white" if enabled else "#808080"
self.anthropic_budget_tokens_label.setStyleSheet(f"color: {color};")
if hasattr(self, 'anthropic_force_adaptive_cb'):
self.anthropic_force_adaptive_cb.setEnabled(enabled)
if hasattr(self, 'anthropic_effort_label'):
self.anthropic_effort_label.setEnabled(enabled)
color = "white" if enabled else "#808080"
self.anthropic_effort_label.setStyleSheet(f"color: {color};")
if hasattr(self, 'anthropic_effort_combo'):
self.anthropic_effort_combo.setEnabled(enabled)
if hasattr(self, 'anthropic_desc_label'):
self.anthropic_desc_label.setEnabled(enabled)
color = "gray" if enabled else "#606060"
self.anthropic_desc_label.setStyleSheet(f"color: {color}; font-size: 10pt;")
except Exception:
pass
def toggle_gpt_reasoning_controls(self):
"""Enable/disable GPT reasoning controls and labels based on toggle state (PySide6 version)"""
try:
enabled = bool(self.enable_gpt_thinking_var)
# Tokens entry and label
if hasattr(self, 'gpt_reasoning_tokens_entry'):
self.gpt_reasoning_tokens_entry.setEnabled(enabled)
if hasattr(self, 'gpt_reasoning_tokens_label'):
self.gpt_reasoning_tokens_label.setEnabled(enabled)
color = "white" if enabled else "#808080"
self.gpt_reasoning_tokens_label.setStyleSheet(f"color: {color};")
# Effort combo and label
if hasattr(self, 'gpt_effort_combo'):
self.gpt_effort_combo.setEnabled(enabled)
if hasattr(self, 'gpt_effort_label'):
self.gpt_effort_label.setEnabled(enabled)
color = "white" if enabled else "#808080"
self.gpt_effort_label.setStyleSheet(f"color: {color};")
# GPT tokens label
if hasattr(self, 'gpt_tokens_label'):
self.gpt_tokens_label.setEnabled(enabled)
color = "white" if enabled else "#808080"
self.gpt_tokens_label.setStyleSheet(f"color: {color};")
# Description label
if hasattr(self, 'gpt_desc_label'):
self.gpt_desc_label.setEnabled(enabled)
color = "gray" if enabled else "#606060"
self.gpt_desc_label.setStyleSheet(f"color: {color}; font-size: 10pt;")
except Exception:
pass
def open_other_settings(self):
"""Open the Other Settings dialog (PySide6)"""
from PySide6.QtGui import QIcon, QKeyEvent
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication
# If dialog already exists, just show and focus it to preserve exact state
try:
if hasattr(self, "_other_settings_dialog") and self._other_settings_dialog is not None:
# Show with fade animation
try:
from dialog_animations import show_dialog_with_fade
show_dialog_with_fade(self._other_settings_dialog, duration=220)
except Exception:
self._other_settings_dialog.show()
# Bring to front and focus
try:
self._other_settings_dialog.raise_()
self._other_settings_dialog.activateWindow()
except Exception:
pass
return
except Exception:
# If the old reference is invalid, recreate below
self._other_settings_dialog = None
# Create dialog with proper window attributes
# Pass self as parent so it stays in front of main GUI but allows other dialogs on top
dialog = QDialog(self)
dialog.setWindowTitle("Other Settings")
# Do not delete widgets on close; we'll hide instead to retain exact state
dialog.setAttribute(Qt.WA_DeleteOnClose, False)
# Set window flags
dialog.setWindowFlags(
Qt.WindowType.Window |
Qt.WindowType.WindowSystemMenuHint |
Qt.WindowType.WindowMinimizeButtonHint |
Qt.WindowType.WindowMaximizeButtonHint |
Qt.WindowType.WindowCloseButtonHint
)
# CRITICAL: Position dialog way off-screen during construction to prevent flash
# We'll move it to proper position before showing
dialog.move(-10000, -10000)
# CRITICAL: Remove size constraints that prevent maximize
dialog.setSizeGripEnabled(False)
# Set icon with absolute path
try:
import sys
base_dir = sys._MEIPASS if getattr(sys, 'frozen', False) else os.path.dirname(os.path.abspath(__file__))
icon_path = os.path.join(base_dir, 'Halgakos.ico')
dialog.setWindowIcon(QIcon(icon_path))
except Exception:
pass
# Set initial size based on screen ratio
try:
from PySide6.QtWidgets import QApplication
screen = QApplication.primaryScreen().availableGeometry()
# Use 60% of screen width and 80% of screen height
width = int(screen.width() * 0.48)
height = int(screen.height() * 0.95)
dialog.resize(width, height)
except Exception:
dialog.resize(950, 850) # Fallback
# Store original size for restoring after fullscreen
original_geometry = None
# Add F11 fullscreen toggle with stay on top for buttons visibility
def toggle_fullscreen():
nonlocal original_geometry
if dialog.isFullScreen():
dialog.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, False)
dialog.showNormal()
# Restore original size
if original_geometry:
dialog.setGeometry(original_geometry)
else:
# Save current geometry before fullscreen
original_geometry = dialog.geometry()
dialog.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
dialog.showFullScreen()
# Override key press event for F11
original_keyPressEvent = dialog.keyPressEvent
def custom_keyPressEvent(event: QKeyEvent):
if event.key() == Qt.Key_F11:
toggle_fullscreen()
else:
original_keyPressEvent(event)
dialog.keyPressEvent = custom_keyPressEvent
main_layout = QVBoxLayout(dialog)
main_layout.setContentsMargins(5, 5, 5, 5) # Set uniform margins
main_layout.setSpacing(8) # Set spacing between widgets
# Set up icon path
icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'Halgakos.ico')
# Apply global stylesheet for blue checkboxes (from manga integration)
# Back to regular string concatenation which was working before
checkbox_radio_style = """
QComboBox::down-arrow {
image: url(""" + icon_path.replace('\\', '/') + """);
width: 16px;
height: 16px;
}
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;
}
QRadioButton {
color: white;
spacing: 5px;
}
QRadioButton::indicator {
width: 13px;
height: 13px;
border: 2px solid #5a9fd4;
border-radius: 7px;
background-color: #2d2d2d;
}
QRadioButton::indicator:checked {
background-color: #5a9fd4;
border: 2px solid #5a9fd4;
}
QRadioButton::indicator:hover {
border-color: #7bb3e0;
}
QRadioButton:disabled {
color: #666666;
}
QRadioButton::indicator:disabled {
background-color: #1a1a1a;
border-color: #3a3a3a;
}
"""
# Scrollable area with a 2-column grid
scroll = QScrollArea()
scroll.setWidgetResizable(True)
container = QWidget()
container.setStyleSheet(checkbox_radio_style) # Apply global stylesheet
grid = QGridLayout(container)
grid.setColumnStretch(0, 1)
grid.setColumnStretch(1, 1)
grid.setHorizontalSpacing(6) # Compact horizontal spacing between columns
grid.setVerticalSpacing(2) # Minimal vertical spacing between sections
grid.setContentsMargins(4, 4, 4, 4) # Minimal grid margins
# Build sections (converted sections will populate the Qt layout)
self._create_context_management_section(container)
self._create_response_handling_section(container)
self._create_prompt_management_section(container)
self._create_processing_options_section(container)
self._create_image_translation_section(container)
self._create_anti_duplicate_section(container)
self._create_custom_api_endpoints_section(container)
# Add debug controls section at the bottom
self._create_debug_controls_section(container)
# Add Output Settings section (PDF generation + Image Compression)
self._create_output_settings_section(container)
# Add Danger Zone section
self._create_danger_zone_section(container)
scroll.setWidget(container)
scroll.setWidgetResizable(True)
main_layout.addWidget(scroll, 1)
# Buttons row (Save and Close) - always visible at bottom
btns = QHBoxLayout()
btns.setContentsMargins(5, 10, 5, 10) # Add padding around buttons
def _save_and_close():
try:
# Mirror legacy behavior: persist some toggles on close
if hasattr(self, 'retain_source_extension_var'):
try:
self.config['retain_source_extension'] = self.retain_source_extension_var
os.environ['RETAIN_SOURCE_EXTENSION'] = '1' if self.retain_source_extension_var else '0'
except Exception:
pass
self.save_config(show_message=False)
# CRITICAL: Reinitialize environment variables after saving
# This ensures TRANSLATE_SPECIAL_FILES and other settings take effect immediately
if hasattr(self, 'initialize_environment_variables'):
self.initialize_environment_variables()
self.append_log("βœ… Settings saved and environment variables updated")
except Exception as e:
self.append_log(f"⚠️ Error saving settings: {e}")
dialog.hide()
save_btn = QPushButton("πŸ’Ύ Save Settings")
save_btn.clicked.connect(_save_and_close)
save_btn.setMinimumHeight(35)
save_btn.setStyleSheet(
"QPushButton { "
" background-color: #28a745; "
" color: white; "
" padding: 8px 20px; "
" font-size: 11pt; "
" font-weight: bold; "
" border-radius: 4px; "
"} "
"QPushButton:hover { background-color: #218838; }"
)
btns.addWidget(save_btn)
close_btn = QPushButton("❌ Close")
close_btn.clicked.connect(dialog.close)
close_btn.setMinimumHeight(35)
close_btn.setStyleSheet(
"QPushButton { "
" background-color: #6c757d; "
" color: white; "
" padding: 8px 20px; "
" font-size: 11pt; "
" font-weight: bold; "
" border-radius: 4px; "
"} "
"QPushButton:hover { background-color: #5a6268; }"
)
btns.addWidget(close_btn)
main_layout.addLayout(btns)
# Intercept window close: hide instead of destroy to preserve state
def _handle_close(event):
try:
event.ignore()
dialog.hide()
except Exception:
# Best-effort: still hide on any error
try:
event.ignore()
except Exception:
pass
dialog.hide()
dialog.closeEvent = _handle_close
# Store reference for later closing
self._other_settings_dialog = dialog
# Move dialog to center of screen (was off-screen during construction)
try:
screen = QApplication.primaryScreen().availableGeometry()
dialog_x = screen.x() + (screen.width() - dialog.width()) // 2
dialog_y = screen.y() + (screen.height() - dialog.height()) // 2
dialog.move(dialog_x, dialog_y)
except Exception:
pass
# Show with smooth fade animation (no flash of generic window)
try:
from dialog_animations import show_dialog_with_fade
show_dialog_with_fade(dialog, duration=220)
except Exception:
dialog.show()
# Auto-fit width to content after showing
from PySide6.QtCore import QTimer
def adjust_width():
# Calculate width needed for both columns with spacing
needed_width = container.sizeHint().width() + 40 # Add margins
if needed_width > dialog.width():
dialog.resize(needed_width, dialog.height())
QTimer.singleShot(0, adjust_width)
def _create_output_settings_section(self, parent):
"""Create output settings section (PDF generation + Image Compression)"""
from PySide6.QtWidgets import QGroupBox, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox, QComboBox, QWidget, QFrame, QSpinBox
from PySide6.QtCore import Qt
grid = parent.layout() if hasattr(parent, 'layout') else None
section_box = QGroupBox("Output Settings")
section_box.setStyleSheet("""
QLabel:disabled { color: rgba(255,255,255,0.25); }
QSpinBox:disabled { color: rgba(255,255,255,0.25); background-color: rgba(255,255,255,0.05); }
QComboBox:disabled { color: rgba(255,255,255,0.25); background-color: rgba(255,255,255,0.05); }
QCheckBox:disabled { color: rgba(255,255,255,0.25); }
""")
section_v = QVBoxLayout(section_box)
section_v.setContentsMargins(8, 8, 8, 8)
section_v.setSpacing(4)
# ── PDF Settings ──────────────────────────────────────────
pdf_title = QLabel("PDF Settings")
pdf_title.setStyleSheet("font-weight: bold; font-size: 11pt;")
section_v.addWidget(pdf_title)
pdf_desc = QLabel("Generate a PDF from the output folder after EPUB conversion")
pdf_desc.setStyleSheet("color: gray; font-size: 10pt;")
section_v.addWidget(pdf_desc)
# Enable PDF Output
if not hasattr(self, 'enable_pdf_output_var'):
self.enable_pdf_output_var = self.config.get('enable_pdf_output', False)
pdf_enable_cb = self._create_styled_checkbox("Enable PDF Output")
pdf_enable_cb.setToolTip(
"Adds an extra step after EPUB conversion to create a PDF\n"
"from the output folder, retaining all images."
)
try:
pdf_enable_cb.setChecked(bool(self.enable_pdf_output_var))
except Exception:
pass
# Collect controls to enable/disable with the master toggle
pdf_controls = []
def _on_pdf_enable_toggle(checked):
try:
self.enable_pdf_output_var = bool(checked)
self.config['enable_pdf_output'] = self.enable_pdf_output_var
os.environ['ENABLE_PDF_OUTPUT'] = '1' if checked else '0'
for ctrl in pdf_controls:
ctrl.setEnabled(checked)
# Respect child checkbox states when re-enabling
if checked:
toc_on = toc_cb.isChecked()
for ctrl in toc_sub_controls:
ctrl.setEnabled(toc_on)
page_on = page_num_cb.isChecked()
for ctrl in page_num_sub_controls:
ctrl.setEnabled(page_on)
except Exception:
pass
pdf_enable_cb.toggled.connect(_on_pdf_enable_toggle)
section_v.addWidget(pdf_enable_cb)
# Generate Table of Contents
if not hasattr(self, 'pdf_generate_toc_var'):
self.pdf_generate_toc_var = self.config.get('pdf_generate_toc', False)
toc_cb = self._create_styled_checkbox("Generate Table of Contents")
toc_cb.setToolTip("Creates a table of contents page after the cover page in the PDF")
try:
toc_cb.setChecked(bool(self.pdf_generate_toc_var))
except Exception:
pass
# Sub-controls that depend on TOC being enabled
toc_sub_controls = []
def _on_toc_toggle(checked):
try:
self.pdf_generate_toc_var = bool(checked)
self.config['pdf_generate_toc'] = self.pdf_generate_toc_var
os.environ['PDF_GENERATE_TOC'] = '1' if checked else '0'
for ctrl in toc_sub_controls:
ctrl.setEnabled(checked)
except Exception:
pass
toc_cb.toggled.connect(_on_toc_toggle)
toc_cb.setContentsMargins(20, 0, 0, 0)
section_v.addWidget(toc_cb)
pdf_controls.append(toc_cb)
# Include Page Numbers in TOC
if not hasattr(self, 'pdf_toc_page_numbers_var'):
self.pdf_toc_page_numbers_var = self.config.get('pdf_toc_page_numbers', True)
toc_numbers_cb = self._create_styled_checkbox("Include Page Numbers in TOC")
toc_numbers_cb.setToolTip("Show page numbers next to each entry in the table of contents")
try:
toc_numbers_cb.setChecked(bool(self.pdf_toc_page_numbers_var))
except Exception:
pass
def _on_toc_numbers_toggle(checked):
try:
self.pdf_toc_page_numbers_var = bool(checked)
self.config['pdf_toc_page_numbers'] = self.pdf_toc_page_numbers_var
os.environ['PDF_TOC_PAGE_NUMBERS'] = '1' if checked else '0'
except Exception:
pass
toc_numbers_cb.toggled.connect(_on_toc_numbers_toggle)
toc_numbers_cb.setContentsMargins(40, 0, 0, 0)
section_v.addWidget(toc_numbers_cb)
pdf_controls.append(toc_numbers_cb)
toc_sub_controls.append(toc_numbers_cb)
# Separator
sep_pdf1 = QFrame()
sep_pdf1.setFrameShape(QFrame.HLine)
sep_pdf1.setFrameShadow(QFrame.Sunken)
section_v.addWidget(sep_pdf1)
# Page Numbers
if not hasattr(self, 'pdf_page_numbers_var'):
self.pdf_page_numbers_var = self.config.get('pdf_page_numbers', True)
page_num_cb = self._create_styled_checkbox("Page Numbers")
page_num_cb.setToolTip("Adds a semi-transparent page number footer to each page")
try:
page_num_cb.setChecked(bool(self.pdf_page_numbers_var))
except Exception:
pass
page_num_sub_controls = []
def _on_page_num_toggle(checked):
try:
self.pdf_page_numbers_var = bool(checked)
self.config['pdf_page_numbers'] = self.pdf_page_numbers_var
os.environ['PDF_PAGE_NUMBERS'] = '1' if checked else '0'
for ctrl in page_num_sub_controls:
ctrl.setEnabled(checked)
except Exception:
pass
page_num_cb.toggled.connect(_on_page_num_toggle)
section_v.addWidget(page_num_cb)
pdf_controls.append(page_num_cb)
# Alignment row
if not hasattr(self, 'pdf_page_number_alignment_var'):
self.pdf_page_number_alignment_var = self.config.get('pdf_page_number_alignment', 'center')
alignment_row = QWidget()
alignment_h = QHBoxLayout(alignment_row)
alignment_h.setContentsMargins(40, 2, 0, 0)
alignment_label = QLabel("Alignment:")
alignment_h.addWidget(alignment_label)
page_num_sub_controls.append(alignment_label)
alignment_combo = QComboBox()
alignment_combo.addItems(["left", "center", "right"])
alignment_combo.setFixedWidth(100)
alignment_combo.setStyleSheet("""
QComboBox::down-arrow {
image: none;
width: 12px;
height: 12px;
border: none;
}
""")
self._add_combobox_arrow(alignment_combo)
self._disable_combobox_mousewheel(alignment_combo)
try:
idx = alignment_combo.findText(self.pdf_page_number_alignment_var)
if idx >= 0:
alignment_combo.setCurrentIndex(idx)
except Exception:
pass
def _on_alignment_changed(text):
try:
self.pdf_page_number_alignment_var = text
self.config['pdf_page_number_alignment'] = text
os.environ['PDF_PAGE_NUMBER_ALIGNMENT'] = text
except Exception:
pass
alignment_combo.currentTextChanged.connect(_on_alignment_changed)
alignment_h.addWidget(alignment_combo)
page_num_sub_controls.append(alignment_combo)
alignment_h.addStretch()
section_v.addWidget(alignment_row)
pdf_controls.append(alignment_row)
page_num_desc = QLabel("Page numbers appear as semi-transparent footer text")
page_num_desc.setStyleSheet("color: gray; font-size: 10pt;")
page_num_desc.setContentsMargins(20, 0, 0, 5)
section_v.addWidget(page_num_desc)
# ── Quality Settings ──────────────────────────────────────
sep_quality = QFrame()
sep_quality.setFrameShape(QFrame.HLine)
sep_quality.setFrameShadow(QFrame.Sunken)
section_v.addWidget(sep_quality)
quality_title = QLabel("Quality Settings")
quality_title.setStyleSheet("font-weight: bold; font-size: 11pt;")
section_v.addWidget(quality_title)
# Enable Image Compression
if not hasattr(self, 'enable_image_compression_var'):
self.enable_image_compression_var = self.config.get('enable_image_compression', False)
compress_cb = self._create_styled_checkbox("Enable Image Compression")
compress_cb.setToolTip(
"Compresses images and converts them to .webp for smaller file size.\n"
"β€’ Applies during both EPUB and PDF generation"
)
try:
compress_cb.setChecked(bool(self.enable_image_compression_var))
except Exception:
pass
compress_sub_controls = []
def _on_compress_toggle(checked):
try:
self.enable_image_compression_var = bool(checked)
self.config['enable_image_compression'] = self.enable_image_compression_var
os.environ['ENABLE_IMAGE_COMPRESSION'] = '1' if checked else '0'
for ctrl in compress_sub_controls:
ctrl.setEnabled(checked)
except Exception:
pass
compress_cb.toggled.connect(_on_compress_toggle)
section_v.addWidget(compress_cb)
# Quality percentage row
quality_row = QWidget()
quality_h = QHBoxLayout(quality_row)
quality_h.setContentsMargins(20, 2, 0, 0)
quality_label = QLabel("Quality:")
quality_h.addWidget(quality_label)
compress_sub_controls.append(quality_label)
quality_spin = QSpinBox()
quality_spin.setMinimum(1)
quality_spin.setMaximum(100)
quality_spin.setValue(self.config.get('image_compression_quality', 80))
quality_spin.setSuffix("%")
quality_spin.setFixedWidth(80)
quality_spin.setToolTip("Compression quality (1-100). Lower = smaller file, higher = better quality")
def _on_quality_changed(value):
try:
self.config['image_compression_quality'] = value
os.environ['IMAGE_COMPRESSION_QUALITY'] = str(value)
except Exception:
pass
quality_spin.valueChanged.connect(_on_quality_changed)
self._disable_spinbox_mousewheel(quality_spin)
quality_h.addWidget(quality_spin)
compress_sub_controls.append(quality_spin)
quality_range_label = QLabel("1 – 100")
quality_range_label.setStyleSheet("color: rgba(255,255,255,0.3); font-size: 9pt;")
quality_h.addWidget(quality_range_label)
compress_sub_controls.append(quality_range_label)
quality_h.addStretch()
section_v.addWidget(quality_row)
# Exclude Cover Page Compression
exclude_cover_cb = self._create_styled_checkbox("Exclude Cover Page Compression")
exclude_cover_cb.setContentsMargins(20, 0, 0, 0)
exclude_cover_cb.setToolTip("Skip compression for the cover page image")
try:
exclude_cover_cb.setChecked(self.config.get('exclude_cover_compression', True))
except Exception:
exclude_cover_cb.setChecked(True)
def _on_exclude_cover_toggle(checked):
try:
self.config['exclude_cover_compression'] = bool(checked)
os.environ['EXCLUDE_COVER_COMPRESSION'] = '1' if checked else '0'
except Exception:
pass
exclude_cover_cb.toggled.connect(_on_exclude_cover_toggle)
section_v.addWidget(exclude_cover_cb)
compress_sub_controls.append(exclude_cover_cb)
# Exclude GIF Compression
exclude_gif_cb = self._create_styled_checkbox("Exclude .gif Compression")
exclude_gif_cb.setContentsMargins(20, 0, 0, 0)
exclude_gif_cb.setToolTip("Skip compression for GIF images entirely")
try:
exclude_gif_cb.setChecked(self.config.get('exclude_gif_compression', True))
except Exception:
exclude_gif_cb.setChecked(True)
def _on_exclude_gif_toggle(checked):
try:
self.config['exclude_gif_compression'] = bool(checked)
os.environ['EXCLUDE_GIF_COMPRESSION'] = '1' if checked else '0'
except Exception:
pass
exclude_gif_cb.toggled.connect(_on_exclude_gif_toggle)
section_v.addWidget(exclude_gif_cb)
compress_sub_controls.append(exclude_gif_cb)
# PDF Image Format
pdf_format_row = QWidget()
pdf_format_h = QHBoxLayout(pdf_format_row)
pdf_format_h.setContentsMargins(20, 2, 0, 0)
pdf_format_label = QLabel("PDF Image Format:")
pdf_format_h.addWidget(pdf_format_label)
compress_sub_controls.append(pdf_format_label)
pdf_format_combo = QComboBox()
pdf_format_combo.addItems(["JPEG", "PNG"])
pdf_format_combo.setFixedWidth(100)
pdf_format_combo.setToolTip(
"Image format used inside the PDF.\n"
"β€’ JPEG: Lossy, smaller files, respects quality setting, no transparency\n"
"β€’ PNG: Lossless, larger files, preserves transparency"
)
try:
current_pdf_fmt = self.config.get('pdf_image_format', 'jpeg').upper()
idx = pdf_format_combo.findText(current_pdf_fmt)
if idx >= 0:
pdf_format_combo.setCurrentIndex(idx)
except Exception:
pass
self._disable_combobox_mousewheel(pdf_format_combo)
# PNG optimize checkbox (sub-control of PDF format)
png_optimize_cb = self._create_styled_checkbox("Optimize PNG")
png_optimize_cb.setToolTip("Apply lossless optimization to reduce PNG file size")
try:
png_optimize_cb.setChecked(self.config.get('pdf_png_optimize', True))
except Exception:
png_optimize_cb.setChecked(True)
def _on_pdf_format_changed(text):
try:
fmt = text.lower()
self.config['pdf_image_format'] = fmt
os.environ['PDF_IMAGE_FORMAT'] = fmt
except Exception:
pass
pdf_format_combo.currentTextChanged.connect(_on_pdf_format_changed)
pdf_format_h.addWidget(pdf_format_combo)
compress_sub_controls.append(pdf_format_combo)
pdf_format_h.addStretch()
section_v.addWidget(pdf_format_row)
# PNG optimize row
png_optimize_cb.setContentsMargins(20, 0, 0, 0)
def _on_png_optimize_toggle(checked):
try:
self.config['pdf_png_optimize'] = bool(checked)
os.environ['PDF_PNG_OPTIMIZE'] = '1' if checked else '0'
except Exception:
pass
png_optimize_cb.toggled.connect(_on_png_optimize_toggle)
section_v.addWidget(png_optimize_cb)
compress_sub_controls.append(png_optimize_cb)
# PNG Compression Level (0-9)
png_level_row = QWidget()
png_level_h = QHBoxLayout(png_level_row)
png_level_h.setContentsMargins(20, 2, 0, 0)
png_level_label = QLabel("PNG Compression Level:")
png_level_h.addWidget(png_level_label)
compress_sub_controls.append(png_level_label)
png_level_spin = QSpinBox()
png_level_spin.setRange(0, 9)
png_level_spin.setFixedWidth(60)
png_level_spin.setToolTip(
"Zlib deflate compression level for PNG (0-9).\n"
"0 = no compression (fastest, largest)\n"
"6 = default balance\n"
"9 = max compression (slowest, smallest)\n"
"This is lossless β€” no quality loss at any level."
)
try:
png_level_spin.setValue(int(self.config.get('pdf_png_compress_level', 6)))
except Exception:
png_level_spin.setValue(6)
def _on_png_level_changed(val):
try:
self.config['pdf_png_compress_level'] = val
os.environ['PDF_PNG_COMPRESS_LEVEL'] = str(val)
except Exception:
pass
png_level_spin.valueChanged.connect(_on_png_level_changed)
self._disable_spinbox_mousewheel(png_level_spin)
png_level_h.addWidget(png_level_spin)
compress_sub_controls.append(png_level_spin)
png_level_range_label = QLabel("0 – 9")
png_level_range_label.setStyleSheet("color: rgba(255,255,255,0.3); font-size: 9pt;")
png_level_h.addWidget(png_level_range_label)
compress_sub_controls.append(png_level_range_label)
png_level_h.addStretch()
section_v.addWidget(png_level_row)
compress_desc = QLabel(
"EPUB always uses .webp. PDF uses the format selected above\n"
"(JPEG for quality control, PNG for transparency)."
)
compress_desc.setStyleSheet("color: gray; font-size: 10pt;")
compress_desc.setContentsMargins(20, 0, 0, 5)
section_v.addWidget(compress_desc)
# Set initial env vars for compression sub-settings
os.environ['IMAGE_COMPRESSION_QUALITY'] = str(quality_spin.value())
os.environ['EXCLUDE_COVER_COMPRESSION'] = '1' if exclude_cover_cb.isChecked() else '0'
os.environ['EXCLUDE_GIF_COMPRESSION'] = '1' if exclude_gif_cb.isChecked() else '0'
os.environ['PDF_IMAGE_FORMAT'] = self.config.get('pdf_image_format', 'jpeg')
os.environ['PDF_PNG_OPTIMIZE'] = '1' if png_optimize_cb.isChecked() else '0'
os.environ['PDF_PNG_COMPRESS_LEVEL'] = str(png_level_spin.value())
# Apply initial enabled state for compression sub-controls
initial_compress = compress_cb.isChecked()
for ctrl in compress_sub_controls:
ctrl.setEnabled(initial_compress)
# Apply initial enabled state for PDF sub-controls
initial_pdf = pdf_enable_cb.isChecked()
for ctrl in pdf_controls:
ctrl.setEnabled(initial_pdf)
initial_toc = toc_cb.isChecked() and initial_pdf
for ctrl in toc_sub_controls:
ctrl.setEnabled(initial_toc)
initial_page_num = page_num_cb.isChecked() and initial_pdf
for ctrl in page_num_sub_controls:
ctrl.setEnabled(initial_page_num)
# Place in grid spanning both columns
if isinstance(grid, QGridLayout):
row = grid.rowCount()
grid.addWidget(section_box, row, 0, 1, 2)
else:
if hasattr(parent, 'layout') and parent.layout():
parent.layout().addWidget(section_box)
else:
section_box.setParent(parent)
def _create_danger_zone_section(self, parent):
"""Create danger zone section (Reset to Defaults)"""
from PySide6.QtWidgets import QGroupBox, QVBoxLayout, QPushButton, QMessageBox, QLabel, QFrame
from PySide6.QtCore import Qt
# Check if parent is a grid layout (standard case) or just a widget
grid = parent.layout() if hasattr(parent, 'layout') else None
section_box = QGroupBox("Danger Zone")
section_box.setStyleSheet("""
QGroupBox {
border: 1px solid #dc3545;
border-radius: 5px;
margin-top: 10px;
font-weight: bold;
color: #ff6b6b;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 10px;
padding: 0 5px;
}
""")
section_v = QVBoxLayout(section_box)
section_v.setContentsMargins(15, 15, 15, 15)
section_v.setSpacing(10)
warning_lbl = QLabel("Reset all settings to default values. API keys and profiles will be preserved.")
warning_lbl.setStyleSheet("color: #ffa5a5; font-size: 10pt;")
warning_lbl.setWordWrap(True)
section_v.addWidget(warning_lbl)
def _reset_config_to_defaults():
try:
# Preservation logic
keys_to_preserve = {}
current_config = self.config
# 1. Main API Key
if 'api_key' in current_config:
keys_to_preserve['api_key'] = current_config['api_key']
# 2. Multi API Keys
if 'multi_api_keys' in current_config:
keys_to_preserve['multi_api_keys'] = current_config['multi_api_keys']
# 3. Fallback Keys
if 'fallback_keys' in current_config:
keys_to_preserve['fallback_keys'] = current_config['fallback_keys']
# 4. Replicate API Key
if 'replicate_api_key' in current_config:
keys_to_preserve['replicate_api_key'] = current_config['replicate_api_key']
# 5. Model Name
if 'model' in current_config:
keys_to_preserve['model'] = current_config['model']
# 6. Azure Computer Vision credentials
if 'azure_vision_key' in current_config:
keys_to_preserve['azure_vision_key'] = current_config['azure_vision_key']
if 'azure_vision_endpoint' in current_config:
keys_to_preserve['azure_vision_endpoint'] = current_config['azure_vision_endpoint']
# 7. Azure Document Intelligence credentials
if 'azure_document_intelligence_key' in current_config:
keys_to_preserve['azure_document_intelligence_key'] = current_config['azure_document_intelligence_key']
if 'azure_document_intelligence_endpoint' in current_config:
keys_to_preserve['azure_document_intelligence_endpoint'] = current_config['azure_document_intelligence_endpoint']
# 8. Google Vision credentials path
if 'google_vision_credentials' in current_config:
keys_to_preserve['google_vision_credentials'] = current_config['google_vision_credentials']
if 'google_cloud_credentials' in current_config:
keys_to_preserve['google_cloud_credentials'] = current_config['google_cloud_credentials']
# 9. Prompt Profiles
if 'prompt_profiles' in current_config:
keys_to_preserve['prompt_profiles'] = current_config['prompt_profiles']
if 'active_profile' in current_config:
keys_to_preserve['active_profile'] = current_config['active_profile']
# 10. Multi-Key and Fallback Key Toggle States
if 'use_multi_api_keys' in current_config:
keys_to_preserve['use_multi_api_keys'] = current_config['use_multi_api_keys']
if 'use_fallback_keys' in current_config:
keys_to_preserve['use_fallback_keys'] = current_config['use_fallback_keys']
# 11. QA Scanner Excluded Characters
if 'qa_scanner_settings' in current_config:
qa_settings = current_config['qa_scanner_settings']
if isinstance(qa_settings, dict) and 'excluded_characters' in qa_settings:
# Preserve only the excluded_characters field from QA settings
if 'qa_scanner_settings' not in keys_to_preserve:
keys_to_preserve['qa_scanner_settings'] = {}
keys_to_preserve['qa_scanner_settings']['excluded_characters'] = qa_settings['excluded_characters']
# Show warning dialog
msg = QMessageBox(getattr(self, '_other_settings_dialog', self))
msg.setWindowTitle("Reset to Defaults")
msg.setText("Are you sure you want to reset ALL settings to default values?")
msg.setInformativeText(
"This will restart the application.\n\n"
"The following will be PRESERVED:\n"
"β€’ Main API Key\n"
"β€’ Multi-API Keys\n"
"β€’ Fallback Keys\n"
"β€’ Replicate API Key\n"
"β€’ Azure Vision Key & Endpoint\n"
"β€’ Azure Document Intelligence Key & Endpoint\n"
"β€’ Google Vision Credentials Path\n"
"β€’ Selected Model\n"
"β€’ Prompt Profiles & Active Profile\n"
"β€’ Multi-Key & Fallback Key Mode Toggles\n"
"β€’ QA Scanner Excluded Characters\n\n"
"All other settings (history limits, custom endpoints, etc.) will be lost."
)
msg.setIcon(QMessageBox.Warning)
msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
msg.setDefaultButton(QMessageBox.No)
# Apply styling to buttons
msg.setStyleSheet("""
QMessageBox {
background-color: #2b2b2b;
color: white;
}
QLabel {
color: white;
}
QPushButton {
min-width: 80px;
padding: 5px 15px;
border-radius: 3px;
font-weight: bold;
}
QPushButton[text="&Yes"] {
background-color: #dc3545;
color: white;
border: 1px solid #c82333;
}
QPushButton[text="&Yes"]:hover {
background-color: #c82333;
}
QPushButton[text="&No"] {
background-color: #6c757d;
color: white;
border: 1px solid #5a6268;
}
QPushButton[text="&No"]:hover {
background-color: #5a6268;
}
""")
# Center buttons
_center_messagebox_buttons(msg)
if msg.exec() == QMessageBox.Yes:
# Delete config.json
config_path = os.path.join(os.getcwd(), CONFIG_FILE)
if os.path.exists(config_path):
try:
# Write temporary config with just the preserved keys
# This effectively resets everything else since the app will fill in defaults
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(keys_to_preserve, f, indent=2, ensure_ascii=False)
# Restart
import sys
import subprocess
# Close the dialog first
if hasattr(self, '_other_settings_dialog') and self._other_settings_dialog:
self._other_settings_dialog.close()
# Launch new instance
subprocess.Popen([sys.executable] + sys.argv)
# Exit current instance
sys.exit(0)
except Exception as e:
QMessageBox.critical(None, "Error", f"Failed to reset config: {e}")
except Exception as e:
print(f"Error resetting config: {e}")
import traceback
traceback.print_exc()
reset_btn = QPushButton("⚠️ Reset Settings to Defaults")
reset_btn.clicked.connect(_reset_config_to_defaults)
reset_btn.setMinimumHeight(35)
reset_btn.setStyleSheet(
"QPushButton { "
" background-color: #dc3545; "
" color: white; "
" font-weight: bold; "
" border-radius: 4px; "
" font-size: 11pt; "
"} "
"QPushButton:hover { background-color: #c82333; }"
)
section_v.addWidget(reset_btn)
# Place at the bottom, spanning both columns if in a grid
if isinstance(grid, QGridLayout):
# Find next available row (though we can just append to end)
row = grid.rowCount()
grid.addWidget(section_box, row, 0, 1, 2) # Span 2 columns
else:
# Fallback
if hasattr(parent, 'layout') and parent.layout():
parent.layout().addWidget(section_box)
else:
section_box.setParent(parent)
def _create_context_management_section(self, parent):
"""Create context management section (PySide6)"""
# Expect parent to have a QGridLayout
grid = parent.layout() if hasattr(parent, 'layout') else None
if not isinstance(grid, QGridLayout):
# If no grid yet, create one so we can place the section
grid = QGridLayout(parent)
parent.setLayout(grid)
grid.setColumnStretch(0, 1)
grid.setColumnStretch(1, 1)
section_box = QGroupBox("Context Management & Memory")
# No max width - let it expand in fullscreen
section_v = QVBoxLayout(section_box)
section_v.setContentsMargins(8, 8, 8, 8) # Compact margins
section_v.setSpacing(4) # Compact spacing between widgets
# Include previous source text toggle (controls whether source-side history is reused)
include_source_cb = self._create_styled_checkbox("Include previous source text in history/memory (Not Recommended)")
include_source_cb.setToolTip("Adds the raw source text to the summary request.\nThis is not recommended because the translated text is already included.\nEnabling this will negatively affect token effeciency.")
try:
include_source_cb.setChecked(bool(self.include_source_in_history_var))
except Exception:
pass
def _on_include_source_toggled(checked):
try:
self.include_source_in_history_var = bool(checked)
except Exception:
pass
include_source_cb.toggled.connect(_on_include_source_toggled)
section_v.addWidget(include_source_cb)
# Rolling summary toggle
rolling_cb = self._create_styled_checkbox("Use Rolling Summary (Memory)")
rolling_cb.setToolTip("Injects a summary request API call after every translation.\nIf batch translation is enabled, the summary request is performed on a per-batch basis.")
try:
rolling_cb.setChecked(bool(self.rolling_summary_var))
except Exception:
pass
# Warning label (kept visually distinct, but on the same row)
rolling_warn = QLabel("⚠ Do not use with contextual translation")
rolling_warn.setStyleSheet(
"color: #f59e0b; font-style: italic; font-size: 9pt;"
)
rolling_warn.setContentsMargins(0, 0, 0, 0)
# Store references to controls that should be enabled/disabled
rolling_controls = []
def _on_rolling_toggled(checked):
try:
self.rolling_summary_var = bool(checked)
# Enable/disable all rolling summary controls
for control in rolling_controls:
control.setEnabled(bool(checked))
except Exception:
pass
rolling_cb.toggled.connect(_on_rolling_toggled)
# Put checkbox + warning on the same line
rolling_row = QWidget()
rolling_row_l = QHBoxLayout(rolling_row)
rolling_row_l.setContentsMargins(0, 0, 0, 0)
rolling_row_l.setSpacing(8)
rolling_row_l.addWidget(rolling_cb)
rolling_row_l.addWidget(rolling_warn)
rolling_row_l.addStretch(1)
section_v.addWidget(rolling_row)
# Description
desc = QLabel("AI-powered memory system that maintains story context")
desc.setStyleSheet("color: gray; font-size: 9pt;")
desc.setContentsMargins(0, 0, 0, 8)
section_v.addWidget(desc)
# Settings container - 2 column grid layout
settings_w = QWidget()
settings_grid = QGridLayout(settings_w)
settings_grid.setContentsMargins(0, 5, 0, 5)
settings_grid.setHorizontalSpacing(0) # No spacing between label and input
settings_grid.setVerticalSpacing(6)
# Row 0: Role and Mode
role_lbl = QLabel("Role: ")
settings_grid.addWidget(role_lbl, 0, 0, alignment=Qt.AlignRight)
rolling_controls.append(role_lbl)
role_combo = QComboBox()
# Controls how the rolling-summary GENERATION request is built (summary API call).
# NOTE: The rolling summary is always injected into translation as an assistant message.
#
# user -> send user prompt only (configured summary user prompt + translated text)
# system -> send system prompt + user message containing ONLY the translated text
# both -> send both system + user (legacy/current behavior)
role_combo.addItems(["user", "system", "both"])
role_combo.setFixedWidth(90)
# Add custom styling with unicode arrow
role_combo.setStyleSheet("""
QComboBox::down-arrow {
image: none;
width: 12px;
height: 12px;
border: none;
}
""")
self._add_combobox_arrow(role_combo)
self._disable_combobox_mousewheel(role_combo)
try:
current_role = str(getattr(self, 'summary_role_var', 'system') or 'system').strip().lower()
idx = role_combo.findText(current_role)
if idx >= 0:
role_combo.setCurrentIndex(idx)
else:
role_combo.setCurrentIndex(0)
except Exception:
pass
def _on_role_changed(text):
try:
self.summary_role_var = str(text).strip().lower()
except Exception:
pass
role_combo.currentTextChanged.connect(_on_role_changed)
settings_grid.addWidget(role_combo, 0, 1, alignment=Qt.AlignLeft)
rolling_controls.append(role_combo)
mode_lbl = QLabel(" Mode: ")
settings_grid.addWidget(mode_lbl, 0, 2, alignment=Qt.AlignRight)
rolling_controls.append(mode_lbl)
mode_combo = QComboBox()
mode_combo.addItems(["append", "replace"])
mode_combo.setFixedWidth(100)
# Add custom styling with unicode arrow
mode_combo.setStyleSheet("""
QComboBox::down-arrow {
image: none;
width: 12px;
height: 12px;
border: none;
}
""")
self._add_combobox_arrow(mode_combo)
self._disable_combobox_mousewheel(mode_combo)
try:
mode_combo.setCurrentText(self.rolling_summary_mode_var)
except Exception:
pass
def _on_mode_changed(text):
try:
self.rolling_summary_mode_var = text
except Exception:
pass
mode_combo.currentTextChanged.connect(_on_mode_changed)
settings_grid.addWidget(mode_combo, 0, 3, alignment=Qt.AlignLeft)
rolling_controls.append(mode_combo)
# Add Max Tokens field to the right of Mode
max_tokens_lbl = QLabel(" Max tokens: ")
settings_grid.addWidget(max_tokens_lbl, 0, 4, alignment=Qt.AlignRight)
rolling_controls.append(max_tokens_lbl)
max_tokens_edit = QLineEdit()
max_tokens_edit.setFixedWidth(80)
try:
max_tokens_edit.setText(str(self.rolling_summary_max_tokens_var))
except Exception:
pass
def _on_max_tokens_changed(text):
try:
self.rolling_summary_max_tokens_var = text
except Exception:
pass
max_tokens_edit.textChanged.connect(_on_max_tokens_changed)
settings_grid.addWidget(max_tokens_edit, 0, 5, alignment=Qt.AlignLeft)
rolling_controls.append(max_tokens_edit)
# Row 1: Summarize last and Retain
summ_lbl = QLabel("Summarize last \n N exchanges: ")
settings_grid.addWidget(summ_lbl, 1, 0, alignment=Qt.AlignRight)
rolling_controls.append(summ_lbl)
exchanges_edit = QLineEdit()
exchanges_edit.setFixedWidth(70)
try:
exchanges_edit.setText(str(self.rolling_summary_exchanges_var))
except Exception:
pass
def _on_exchanges_changed(text):
try:
self.rolling_summary_exchanges_var = text
except Exception:
pass
exchanges_edit.textChanged.connect(_on_exchanges_changed)
settings_grid.addWidget(exchanges_edit, 1, 1, alignment=Qt.AlignLeft)
rolling_controls.append(exchanges_edit)
retain_lbl = QLabel(" Retain N entries: ")
settings_grid.addWidget(retain_lbl, 1, 2, alignment=Qt.AlignRight)
rolling_controls.append(retain_lbl)
retain_edit = QLineEdit()
retain_edit.setFixedWidth(70)
try:
retain_edit.setText(str(self.rolling_summary_max_entries_var))
except Exception:
pass
def _on_retain_changed(text):
try:
self.rolling_summary_max_entries_var = text
except Exception:
pass
retain_edit.textChanged.connect(_on_retain_changed)
settings_grid.addWidget(retain_edit, 1, 3, alignment=Qt.AlignLeft)
rolling_controls.append(retain_edit)
section_v.addWidget(settings_w)
# Configure prompts button
cfg_btn = QPushButton("βš™οΈ Configure Memory Prompts")
cfg_btn.setMinimumHeight(28)
cfg_btn.setStyleSheet(
"QPushButton { "
" background-color: #17a2b8; "
" color: white; "
" padding: 5px 12px; "
" font-size: 10pt; "
" font-weight: bold; "
" border-radius: 3px; "
"} "
"QPushButton:hover { background-color: #138496; } "
"QPushButton:disabled { "
" background-color: #6c757d; "
" color: #adb5bd; "
"}"
)
cfg_btn.clicked.connect(self.configure_rolling_summary_prompts)
section_v.addWidget(cfg_btn)
rolling_controls.append(cfg_btn)
# Set initial enabled state based on checkbox
initial_state = rolling_cb.isChecked()
for control in rolling_controls:
control.setEnabled(initial_state)
# Separator
sep1 = QFrame()
sep1.setFrameShape(QFrame.HLine)
sep1.setFrameShadow(QFrame.Sunken)
section_v.addWidget(sep1)
# Memory mode info
info = QLabel(
"πŸ’‘ Memory Mode:\nβ€’ Append: Keeps adding summaries (longer context)\nβ€’ Replace: Only keeps latest summary (concise)"
)
info.setStyleSheet("color: #666;")
section_v.addWidget(info)
# Separator
sep2 = QFrame()
sep2.setFrameShape(QFrame.HLine)
sep2.setFrameShadow(QFrame.Sunken)
section_v.addWidget(sep2)
# Application Updates
section_v.addWidget(QLabel("Application Updates:"))
updates_row = QWidget()
updates_h = QHBoxLayout(updates_row)
updates_h.setContentsMargins(0, 0, 0, 0)
btn_check_updates = QPushButton("πŸ”„ Check for Updates")
btn_check_updates.clicked.connect(lambda: self.check_for_updates_manual())
btn_check_updates.setStyleSheet(
"QPushButton { background-color: #17a2b8; color: white; padding: 5px 10px; border-radius: 3px; font-weight: bold; } "
"QPushButton:hover { background-color: #138496; }"
)
updates_h.addWidget(btn_check_updates)
auto_cb = self._create_styled_checkbox("Check on startup")
try:
auto_cb.setChecked(bool(self.auto_update_check_var))
except Exception:
pass
def _on_auto_update(checked):
try:
self.auto_update_check_var = bool(checked)
except Exception:
pass
auto_cb.toggled.connect(_on_auto_update)
updates_h.addSpacing(10)
updates_h.addWidget(auto_cb)
section_v.addWidget(updates_row)
updates_desc = QLabel("Check GitHub for new Glossarion releases\nand download updates")
updates_desc.setStyleSheet("color: gray;")
section_v.addWidget(updates_desc)
# Separator
sep3 = QFrame()
sep3.setFrameShape(QFrame.HLine)
sep3.setFrameShadow(QFrame.Sunken)
section_v.addWidget(sep3)
# Config Backup Management
section_v.addWidget(QLabel("Config Backup Management:"))
backup_row = QWidget()
backup_h = QHBoxLayout(backup_row)
backup_h.setContentsMargins(0, 0, 0, 0)
btn_backup = QPushButton("πŸ’Ύ Create Backup")
btn_backup.clicked.connect(lambda: self._create_manual_config_backup())
btn_backup.setStyleSheet(
"QPushButton { background-color: #28a745; color: white; padding: 5px 10px; border-radius: 3px; font-weight: bold; } "
"QPushButton:hover { background-color: #218838; }"
)
backup_h.addWidget(btn_backup)
btn_restore = QPushButton("β†Ά Restore Backup")
btn_restore.clicked.connect(lambda: self._manual_restore_config())
btn_restore.setStyleSheet(
"QPushButton { background-color: #ffc107; color: black; padding: 5px 10px; border-radius: 3px; font-weight: bold; } "
"QPushButton:hover { background-color: #e0a800; }"
)
backup_h.addWidget(btn_restore)
section_v.addWidget(backup_row)
backup_desc = QLabel("Automatic backups are created before each config save.")
backup_desc.setStyleSheet("color: gray;")
section_v.addWidget(backup_desc)
# Output Folder Override - NEW
section_v.addWidget(QLabel("Default Output Folder Override:"))
output_dir_row = QWidget()
output_dir_h = QHBoxLayout(output_dir_row)
output_dir_h.setContentsMargins(0, 0, 0, 0)
self.output_dir_entry = QLineEdit()
self.output_dir_entry.setPlaceholderText("Leave empty for default (relative to source file)")
# Load current value
current_output_dir = self.config.get('output_directory', os.environ.get('OUTPUT_DIRECTORY', ''))
self.output_dir_entry.setText(current_output_dir)
def _on_output_dir_changed(text):
try:
text = text.strip()
self.config['output_directory'] = text
if text:
os.environ['OUTPUT_DIRECTORY'] = text
else:
# Remove from env if empty to fallback to default behavior
if 'OUTPUT_DIRECTORY' in os.environ:
del os.environ['OUTPUT_DIRECTORY']
except Exception:
pass
self.output_dir_entry.textChanged.connect(_on_output_dir_changed)
output_dir_h.addWidget(self.output_dir_entry)
def _browse_output_dir():
from PySide6.QtWidgets import QFileDialog
dir_path = QFileDialog.getExistingDirectory(self, "Select Output Folder")
if dir_path:
self.output_dir_entry.setText(dir_path)
browse_btn = QPushButton("πŸ“‚")
browse_btn.setToolTip("Browse for folder")
browse_btn.clicked.connect(_browse_output_dir)
browse_btn.setFixedWidth(40)
output_dir_h.addWidget(browse_btn)
section_v.addWidget(output_dir_row)
# Halgakos icon at bottom (128x128 HiDPI)
_icon_label = QLabel()
_icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Halgakos.ico")
if os.path.exists(_icon_path):
from PySide6.QtGui import QPixmap
_pixmap = QPixmap(_icon_path)
if not _pixmap.isNull():
_scaled = _pixmap.scaled(128, 128, Qt.KeepAspectRatio, Qt.SmoothTransformation)
_icon_label.setPixmap(_scaled)
_icon_label.setAlignment(Qt.AlignCenter)
section_v.addWidget(_icon_label)
# Place the section at row 0, column 1 to match the original grid
try:
grid.addWidget(section_box, 0, 1)
except Exception:
# Fallback: just stack
section_box.setParent(parent)
def _create_response_handling_section(self, parent):
"""Create response handling section with AI Hunter additions (PySide6)"""
from PySide6.QtWidgets import QGroupBox, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox, QComboBox, QLineEdit, QPushButton, QWidget, QRadioButton, QButtonGroup
from PySide6.QtCore import Qt
section_box = QGroupBox("Response Handling & Retry Logic")
# No max width - let it expand in fullscreen
section_v = QVBoxLayout(section_box)
section_v.setContentsMargins(8, 8, 8, 8) # Compact margins
section_v.setSpacing(4) # Compact spacing between widgets
# Real-time Translation (Streaming)
streaming_title = QLabel("Real-time Translation (Streaming)")
streaming_title.setStyleSheet("font-weight: bold; font-size: 11pt;")
section_v.addWidget(streaming_title)
# Streaming toggle
if not hasattr(self, 'enable_streaming_var'):
self.enable_streaming_var = bool(self.config.get('enable_streaming', str(os.environ.get('ENABLE_STREAMING', '0')) == '1'))
self.enable_streaming_checkbox = self._create_styled_checkbox("Enable streaming responses (OpenAI-compatible)")
self.enable_streaming_checkbox.setToolTip(
"<qt><p style='white-space: normal; max-width: 32em; margin: 0;'>"
"Streams tokens as they are generated to reduce time-to-first-byte. "
"Some official providers (e.g., Google Gemini) may truncate streams without raising an errorβ€”disable streaming if you see incomplete outputs."
"</p></qt>"
)
try:
self.enable_streaming_checkbox.setChecked(bool(self.enable_streaming_var))
except Exception:
pass
# Allow streaming logs during batch mode
if not hasattr(self, 'allow_batch_stream_logs_var'):
self.allow_batch_stream_logs_var = bool(
self.config.get(
'allow_batch_stream_logs',
str(os.environ.get('ALLOW_BATCH_STREAM_LOGS', '0')) == '1'
)
)
# Ensure config mirrors the loaded state so it can be saved even if dialog isn't reopened
try:
self.config['allow_batch_stream_logs'] = bool(self.allow_batch_stream_logs_var)
except Exception:
pass
self.allow_batch_stream_logs_checkbox = self._create_styled_checkbox(
"Allow streaming logs during batch mode"
)
self.allow_batch_stream_logs_checkbox.setToolTip(
"<qt><p style='white-space: normal; max-width: 32em; margin: 0;'>"
"Show streaming token logs even while batch translation is running. "
"Default is off to reduce log noise.</p></qt>"
)
try:
self.allow_batch_stream_logs_checkbox.setChecked(bool(self.allow_batch_stream_logs_var))
except Exception:
pass
def _on_allow_batch_stream_logs_toggle(checked):
try:
self.allow_batch_stream_logs_var = bool(checked)
# Persist immediately to config so it survives session without requiring another dialog open
self.config['allow_batch_stream_logs'] = self.allow_batch_stream_logs_var
os.environ['ALLOW_BATCH_STREAM_LOGS'] = '1' if checked else '0'
except Exception:
pass
self.allow_batch_stream_logs_checkbox.toggled.connect(_on_allow_batch_stream_logs_toggle)
def _on_streaming_toggle(checked):
try:
self.enable_streaming_var = bool(checked)
# Keep environment in sync immediately
os.environ['ENABLE_STREAMING'] = '1' if checked else '0'
# Enable/disable logs toggle based on streaming state
self.allow_batch_stream_logs_checkbox.setEnabled(checked)
# Force style update to ensure visual state (color) updates immediately
self.allow_batch_stream_logs_checkbox.style().unpolish(self.allow_batch_stream_logs_checkbox)
self.allow_batch_stream_logs_checkbox.style().polish(self.allow_batch_stream_logs_checkbox)
except Exception:
pass
self.enable_streaming_checkbox.toggled.connect(_on_streaming_toggle)
# Initialize state (scheduled to run after widget construction)
from PySide6.QtCore import QTimer
QTimer.singleShot(0, lambda: _on_streaming_toggle(self.enable_streaming_checkbox.isChecked()))
section_v.addWidget(self.enable_streaming_checkbox)
streaming_warn = QLabel("⚠️ Enabling this may result in silent truncation")
streaming_warn.setStyleSheet("color: #f59e0b; font-size: 9pt;")
streaming_warn.setContentsMargins(20, 0, 0, 4)
section_v.addWidget(streaming_warn)
section_v.addWidget(self.allow_batch_stream_logs_checkbox)
# AuthGPT (ChatGPT Subscription) batch stream log toggle
section_v.addSpacing(6)
if not hasattr(self, 'allow_authgpt_batch_stream_logs_var'):
self.allow_authgpt_batch_stream_logs_var = bool(
self.config.get(
'allow_authgpt_batch_stream_logs',
str(os.environ.get('ALLOW_AUTHGPT_BATCH_STREAM_LOGS', '0')) == '1'
)
)
try:
self.config['allow_authgpt_batch_stream_logs'] = bool(self.allow_authgpt_batch_stream_logs_var)
except Exception:
pass
self.allow_authgpt_batch_stream_logs_checkbox = self._create_styled_checkbox(
"Allow ChatGPT Subscription batch mode stream log"
)
self.allow_authgpt_batch_stream_logs_checkbox.setToolTip(
"<qt><p style='white-space: normal; max-width: 32em; margin: 0;'>"
"ChatGPT Subscription models (authgpt/) always stream because the API requires it. "
"During batch translation this can flood the log. Enable this to see streaming "
"tokens in the log during batch mode. Off by default.</p></qt>"
)
try:
self.allow_authgpt_batch_stream_logs_checkbox.setChecked(bool(self.allow_authgpt_batch_stream_logs_var))
except Exception:
pass
def _on_allow_authgpt_batch_stream_logs_toggle(checked):
try:
self.allow_authgpt_batch_stream_logs_var = bool(checked)
self.config['allow_authgpt_batch_stream_logs'] = self.allow_authgpt_batch_stream_logs_var
os.environ['ALLOW_AUTHGPT_BATCH_STREAM_LOGS'] = '1' if checked else '0'
except Exception:
pass
self.allow_authgpt_batch_stream_logs_checkbox.toggled.connect(_on_allow_authgpt_batch_stream_logs_toggle)
section_v.addWidget(self.allow_authgpt_batch_stream_logs_checkbox)
authgpt_note = QLabel("πŸ” ChatGPT Subscription (authgpt/) always uses streaming β€” this controls batch log visibility")
authgpt_note.setStyleSheet("color: #6b7280; font-size: 9pt; font-style: italic;")
authgpt_note.setWordWrap(True)
authgpt_note.setContentsMargins(20, 0, 0, 4)
section_v.addWidget(authgpt_note)
# Separator
sep_stream = QFrame()
sep_stream.setFrameShape(QFrame.HLine)
sep_stream.setFrameShadow(QFrame.Sunken)
section_v.addWidget(sep_stream)
# GPT-5/OpenAI Reasoning Toggle
gpt5_title = QLabel("GPT-5 Thinking (OpenRouter/OpenAI-style)")
gpt5_title.setStyleSheet("font-weight: bold; font-size: 11pt;")
section_v.addWidget(gpt5_title)
gpt_row1 = QWidget()
gpt_h1 = QHBoxLayout(gpt_row1)
gpt_h1.setContentsMargins(20, 5, 0, 0)
gpt_enable_cb = self._create_styled_checkbox("Enable GPT / OR Thinking")
try:
gpt_enable_cb.setChecked(bool(self.enable_gpt_thinking_var))
except Exception:
pass
def _on_gpt_thinking_toggle(checked):
try:
self.enable_gpt_thinking_var = bool(checked)
self.toggle_gpt_reasoning_controls()
except Exception:
pass
gpt_enable_cb.toggled.connect(_on_gpt_thinking_toggle)
gpt_h1.addWidget(gpt_enable_cb)
gpt_h1.addSpacing(20)
self.gpt_effort_label = QLabel("Effort:")
gpt_h1.addWidget(self.gpt_effort_label)
self.gpt_effort_combo = QComboBox()
# GPT thinking effort now supports "none" (disable) and "xhigh" in addition to low/medium/high
self.gpt_effort_combo.addItems(["none", "low", "medium", "high", "xhigh"])
self.gpt_effort_combo.setFixedWidth(100)
# Add custom styling with unicode arrow
self.gpt_effort_combo.setStyleSheet("""
QComboBox::down-arrow {
image: none;
width: 12px;
height: 12px;
border: none;
}
""")
self._add_combobox_arrow(self.gpt_effort_combo)
self._disable_combobox_mousewheel(self.gpt_effort_combo)
try:
effort_val = self.gpt_effort_var
idx = self.gpt_effort_combo.findText(effort_val)
if idx >= 0:
self.gpt_effort_combo.setCurrentIndex(idx)
except Exception:
pass
def _on_effort_changed(text):
try:
self.gpt_effort_var = text
except Exception:
pass
self.gpt_effort_combo.currentTextChanged.connect(_on_effort_changed)
gpt_h1.addWidget(self.gpt_effort_combo)
gpt_h1.addStretch()
section_v.addWidget(gpt_row1)
# Second row for OpenRouter-specific token budget
gpt_row2 = QWidget()
gpt_h2 = QHBoxLayout(gpt_row2)
gpt_h2.setContentsMargins(40, 5, 0, 0)
self.gpt_reasoning_tokens_label = QLabel("OR Thinking Tokens:")
gpt_h2.addWidget(self.gpt_reasoning_tokens_label)
self.gpt_reasoning_tokens_entry = QLineEdit()
self.gpt_reasoning_tokens_entry.setFixedWidth(70)
try:
self.gpt_reasoning_tokens_entry.setText(str(self.gpt_reasoning_tokens_var))
except Exception:
pass
def _on_gpt_tokens_changed(text):
try:
self.gpt_reasoning_tokens_var = text
except Exception:
pass
self.gpt_reasoning_tokens_entry.textChanged.connect(_on_gpt_tokens_changed)
gpt_h2.addWidget(self.gpt_reasoning_tokens_entry)
self.gpt_tokens_label = QLabel("tokens")
gpt_h2.addWidget(self.gpt_tokens_label)
gpt_h2.addStretch()
section_v.addWidget(gpt_row2)
# Store reference to description label for enable/disable
self.gpt_desc_label = QLabel("Controls GPT-5 and OpenRouter reasoning.\nProvide Tokens to force a max token budget for other models,\n GPT-5 uses Effort (none/low/medium/high/xhigh).")
self.gpt_desc_label.setStyleSheet("color: gray; font-size: 10pt;")
self.gpt_desc_label.setContentsMargins(20, 0, 0, 10)
section_v.addWidget(self.gpt_desc_label)
# Initialize enabled state for GPT controls
self.toggle_gpt_reasoning_controls()
# Gemini Thinking Mode
gemini_title = QLabel("Gemini Thinking Mode")
gemini_title.setStyleSheet("font-weight: bold; font-size: 11pt;")
section_v.addWidget(gemini_title)
thinking_row_top = QWidget()
thinking_h_top = QHBoxLayout(thinking_row_top)
thinking_h_top.setContentsMargins(20, 5, 0, 0)
gemini_thinking_cb = self._create_styled_checkbox("Enable Gemini Thinking")
try:
gemini_thinking_cb.setChecked(bool(self.enable_gemini_thinking_var))
except Exception:
pass
def _on_gemini_thinking_toggle(checked):
try:
self.enable_gemini_thinking_var = bool(checked)
self.toggle_thinking_budget()
except Exception:
pass
gemini_thinking_cb.toggled.connect(_on_gemini_thinking_toggle)
thinking_h_top.addWidget(gemini_thinking_cb)
thinking_h_top.addSpacing(20)
self.thinking_budget_label = QLabel("Budget:")
thinking_h_top.addWidget(self.thinking_budget_label)
self.thinking_budget_entry = QLineEdit()
self.thinking_budget_entry.setFixedWidth(70)
try:
self.thinking_budget_entry.setText(str(self.thinking_budget_var))
except Exception:
pass
def _on_budget_changed(text):
try:
self.thinking_budget_var = text
except Exception:
pass
self.thinking_budget_entry.textChanged.connect(_on_budget_changed)
thinking_h_top.addWidget(self.thinking_budget_entry)
self.thinking_tokens_label = QLabel("tokens")
thinking_h_top.addWidget(self.thinking_tokens_label)
thinking_h_top.addStretch()
section_v.addWidget(thinking_row_top)
# Second row for Gemini 3 thinking level
thinking_row_level = QWidget()
thinking_h_level = QHBoxLayout(thinking_row_level)
thinking_h_level.setContentsMargins(40, 2, 0, 0)
self.thinking_level_label = QLabel("Level (Gemini 3):")
thinking_h_level.addWidget(self.thinking_level_label)
self.thinking_level_combo = QComboBox()
self.thinking_level_combo.addItems(["minimal", "low", "medium", "high"])
self.thinking_level_combo.setFixedWidth(110)
self.thinking_level_combo.setStyleSheet("""
QComboBox::down-arrow {
image: none;
width: 12px;
height: 12px;
border: none;
}
""")
self._add_combobox_arrow(self.thinking_level_combo)
self._disable_combobox_mousewheel(self.thinking_level_combo)
try:
level_val = self.thinking_level_var
idx = self.thinking_level_combo.findText(level_val)
if idx >= 0:
self.thinking_level_combo.setCurrentIndex(idx)
except Exception:
pass
def _on_level_changed(text):
try:
self.thinking_level_var = text
os.environ["GEMINI_THINKING_LEVEL"] = text
except Exception:
pass
self.thinking_level_combo.currentTextChanged.connect(_on_level_changed)
thinking_h_level.addWidget(self.thinking_level_combo)
thinking_h_level.addStretch()
section_v.addWidget(thinking_row_level)
# Enable thoughts toggle (disabled by default) β€” placed after level selector
if not hasattr(self, 'enable_thoughts_var'):
self.enable_thoughts_var = bool(
self.config.get(
'enable_thoughts',
str(os.environ.get('ENABLE_THOUGHTS', '0')) == '1'
)
)
thoughts_row = QWidget()
thoughts_h = QHBoxLayout(thoughts_row)
thoughts_h.setContentsMargins(20, 2, 0, 0)
enable_thoughts_cb = self._create_styled_checkbox("Enable thoughts (include model reasoning metadata)")
enable_thoughts_cb.setToolTip(
"<qt><p style='white-space: normal; max-width: 32em; margin: 0;'>"
"Adds model reasoning thoughts metadata to responses when supported. "
"</p></qt>"
)
try:
enable_thoughts_cb.setChecked(bool(self.enable_thoughts_var))
except Exception:
enable_thoughts_cb.setChecked(False)
def _on_enable_thoughts_toggle(checked):
try:
self.enable_thoughts_var = bool(checked)
self.config['enable_thoughts'] = self.enable_thoughts_var
os.environ['ENABLE_THOUGHTS'] = '1' if checked else '0'
except Exception:
pass
enable_thoughts_cb.toggled.connect(_on_enable_thoughts_toggle)
thoughts_h.addWidget(enable_thoughts_cb)
thoughts_h.addStretch()
section_v.addWidget(thoughts_row)
# Store reference to description label for enable/disable
self.gemini_desc_label = QLabel("Control Gemini thinking: budget (Gemini 2.5 and earlier) or level (Gemini 3).\nBudget: 0 = disabled, 512-24576 = limited, -1 = dynamic.\nLevel: minimal/low/medium/high β€” Gemini 3 Flash supports minimal\nGemini 3.0 Pro (gemini-3-pro-*) does not support medium; Gemini 3.1 Pro supports medium.")
self.gemini_desc_label.setStyleSheet("color: gray; font-size: 10pt;")
self.gemini_desc_label.setContentsMargins(20, 0, 0, 10)
section_v.addWidget(self.gemini_desc_label)
# Initialize enabled state for Gemini controls
self.toggle_thinking_budget()
# DeepSeek Thinking Mode
deepseek_title = QLabel("DeepSeek Thinking Mode")
deepseek_title.setStyleSheet("font-weight: bold; font-size: 11pt;")
section_v.addWidget(deepseek_title)
deepseek_row = QWidget()
deepseek_h = QHBoxLayout(deepseek_row)
deepseek_h.setContentsMargins(20, 5, 0, 0)
deepseek_cb = self._create_styled_checkbox("Enable DeepSeek Thinking (also controls chutes thinking header)")
try:
deepseek_cb.setChecked(bool(getattr(self, 'enable_deepseek_thinking_var', True)))
except Exception:
deepseek_cb.setChecked(True)
def _on_deepseek_thinking_toggle(checked):
try:
self.enable_deepseek_thinking_var = bool(checked)
os.environ['ENABLE_DEEPSEEK_THINKING'] = '1' if self.enable_deepseek_thinking_var else '0'
except Exception:
pass
deepseek_cb.toggled.connect(_on_deepseek_thinking_toggle)
deepseek_h.addWidget(deepseek_cb)
deepseek_h.addStretch()
section_v.addWidget(deepseek_row)
deepseek_desc = QLabel("Adds extra_body={thinking:{type:enabled}} for DeepSeek OpenAI-compatible requests.\nEnables reasoning_content when supported.")
deepseek_desc.setStyleSheet("color: gray; font-size: 10pt;")
deepseek_desc.setContentsMargins(20, 0, 0, 10)
section_v.addWidget(deepseek_desc)
# Anthropic Extended Thinking
anthropic_title = QLabel("Anthropic Extended Thinking")
anthropic_title.setStyleSheet("font-weight: bold; font-size: 11pt;")
section_v.addWidget(anthropic_title)
anthropic_row1 = QWidget()
anthropic_h1 = QHBoxLayout(anthropic_row1)
anthropic_h1.setContentsMargins(20, 5, 0, 0)
anthropic_enable_cb = self._create_styled_checkbox("Enable Anthropic Extended Thinking")
try:
anthropic_enable_cb.setChecked(bool(getattr(self, 'enable_anthropic_thinking_var', False)))
except Exception:
pass
def _on_anthropic_thinking_toggle(checked):
try:
self.enable_anthropic_thinking_var = bool(checked)
os.environ['ENABLE_ANTHROPIC_THINKING'] = '1' if checked else '0'
self.toggle_anthropic_thinking_controls()
except Exception:
pass
anthropic_enable_cb.toggled.connect(_on_anthropic_thinking_toggle)
anthropic_h1.addWidget(anthropic_enable_cb)
anthropic_h1.addSpacing(20)
self.anthropic_budget_label = QLabel("Budget:")
anthropic_h1.addWidget(self.anthropic_budget_label)
self.anthropic_budget_entry = QLineEdit()
self.anthropic_budget_entry.setFixedWidth(70)
try:
self.anthropic_budget_entry.setText(str(getattr(self, 'anthropic_thinking_budget_var', '10000')))
except Exception:
pass
def _on_anthropic_budget_changed(text):
try:
val = int(text)
max_out = int(getattr(self, 'max_output_tokens', 65536))
cap = max_out - 1
if val > cap:
self.anthropic_budget_entry.blockSignals(True)
self.anthropic_budget_entry.setText(str(cap))
self.anthropic_budget_entry.blockSignals(False)
self.anthropic_thinking_budget_var = str(cap)
else:
self.anthropic_thinking_budget_var = text
except (ValueError, TypeError):
self.anthropic_thinking_budget_var = text
except Exception:
pass
self.anthropic_budget_entry.textChanged.connect(_on_anthropic_budget_changed)
anthropic_h1.addWidget(self.anthropic_budget_entry)
self.anthropic_budget_tokens_label = QLabel("tokens")
anthropic_h1.addWidget(self.anthropic_budget_tokens_label)
anthropic_h1.addStretch()
section_v.addWidget(anthropic_row1)
# Second row: Force adaptive + Effort
anthropic_row2 = QWidget()
anthropic_h2 = QHBoxLayout(anthropic_row2)
anthropic_h2.setContentsMargins(40, 2, 0, 0)
self.anthropic_force_adaptive_cb = self._create_styled_checkbox("Force Adaptive Thinking")
self.anthropic_force_adaptive_cb.setToolTip(
"<qt><p style='white-space: normal; max-width: 32em; margin: 0;'>"
"Forces adaptive thinking on all Claude models. "
"Not all models support adaptive thinking β€” enabling this on unsupported models will cause API errors. "
"Opus 4.6 always uses adaptive automatically."
"</p></qt>"
)
try:
self.anthropic_force_adaptive_cb.setChecked(bool(getattr(self, 'anthropic_force_adaptive_var', False)))
except Exception:
pass
def _on_anthropic_force_adaptive_toggle(checked):
try:
self.anthropic_force_adaptive_var = bool(checked)
os.environ['ANTHROPIC_FORCE_ADAPTIVE'] = '1' if checked else '0'
# Disable budget entry when adaptive is forced (no budget_tokens needed)
if hasattr(self, 'anthropic_budget_entry'):
self.anthropic_budget_entry.setEnabled(not checked and bool(getattr(self, 'enable_anthropic_thinking_var', False)))
except Exception:
pass
self.anthropic_force_adaptive_cb.toggled.connect(_on_anthropic_force_adaptive_toggle)
anthropic_h2.addWidget(self.anthropic_force_adaptive_cb)
anthropic_h2.addSpacing(20)
self.anthropic_effort_label = QLabel("Effort:")
anthropic_h2.addWidget(self.anthropic_effort_label)
self.anthropic_effort_combo = QComboBox()
self.anthropic_effort_combo.addItems(["low", "medium", "high"])
self.anthropic_effort_combo.setFixedWidth(100)
self.anthropic_effort_combo.setStyleSheet("""
QComboBox::down-arrow {
image: none;
width: 12px;
height: 12px;
border: none;
}
""")
self._add_combobox_arrow(self.anthropic_effort_combo)
self._disable_combobox_mousewheel(self.anthropic_effort_combo)
try:
effort_val = getattr(self, 'anthropic_effort_var', 'medium')
idx = self.anthropic_effort_combo.findText(effort_val)
if idx >= 0:
self.anthropic_effort_combo.setCurrentIndex(idx)
except Exception:
pass
def _on_anthropic_effort_changed(text):
try:
self.anthropic_effort_var = text
os.environ['ANTHROPIC_EFFORT'] = text
except Exception:
pass
self.anthropic_effort_combo.currentTextChanged.connect(_on_anthropic_effort_changed)
anthropic_h2.addWidget(self.anthropic_effort_combo)
anthropic_h2.addStretch()
section_v.addWidget(anthropic_row2)
self.anthropic_desc_label = QLabel("Extended thinking for Claude models. Budget sets thinking token limit.\nAdaptive thinking uses effort level instead of fixed budget.\nOpus 4.6 always uses adaptive; Sonnet 4.6 optionally supports it.")
self.anthropic_desc_label.setStyleSheet("color: gray; font-size: 10pt;")
self.anthropic_desc_label.setContentsMargins(20, 0, 0, 10)
section_v.addWidget(self.anthropic_desc_label)
# Initialize enabled state for Anthropic controls
self.toggle_anthropic_thinking_controls()
# Parallel Extraction
parallel_title = QLabel("Parallel Extraction")
parallel_title.setStyleSheet("font-weight: bold; font-size: 11pt;")
section_v.addWidget(parallel_title)
extraction_row = QWidget()
extraction_h = QHBoxLayout(extraction_row)
extraction_h.setContentsMargins(20, 5, 0, 0)
parallel_cb = self._create_styled_checkbox("Enable Parallel Processing")
try:
parallel_cb.setChecked(bool(self.enable_parallel_extraction_var))
except Exception:
pass
def _on_parallel_toggle(checked):
try:
self.enable_parallel_extraction_var = bool(checked)
self.toggle_extraction_workers()
except Exception:
pass
parallel_cb.toggled.connect(_on_parallel_toggle)
extraction_h.addWidget(parallel_cb)
extraction_h.addSpacing(20)
self.workers_label = QLabel("Workers:")
extraction_h.addWidget(self.workers_label)
self.extraction_workers_entry = QLineEdit()
self.extraction_workers_entry.setFixedWidth(50)
try:
self.extraction_workers_entry.setText(str(self.extraction_workers_var))
except Exception:
pass
def _on_workers_changed(text):
try:
self.extraction_workers_var = text
except Exception:
pass
self.extraction_workers_entry.textChanged.connect(_on_workers_changed)
extraction_h.addWidget(self.extraction_workers_entry)
self.threads_label = QLabel("threads")
extraction_h.addWidget(self.threads_label)
extraction_h.addStretch()
section_v.addWidget(extraction_row)
# Store reference to description label for enable/disable
self.parallel_desc_label = QLabel("Speed up EPUB extraction using multiple threads.\nRecommended: 4-8 workers (set to 1 to disable)")
self.parallel_desc_label.setStyleSheet("color: gray; font-size: 10pt;")
self.parallel_desc_label.setContentsMargins(20, 0, 0, 10)
section_v.addWidget(self.parallel_desc_label)
# Initialize enabled state for Parallel Extraction controls
self.toggle_extraction_workers()
# GUI Yield Toggle
gui_yield_row = QWidget()
gui_yield_h = QHBoxLayout(gui_yield_row)
gui_yield_h.setContentsMargins(20, 5, 0, 0)
gui_yield_cb = self._create_styled_checkbox("Enable GUI Responsiveness Yield")
try:
gui_yield_cb.setChecked(bool(self.enable_gui_yield_var))
except Exception:
pass
def _on_gui_yield_toggle(checked):
try:
self.enable_gui_yield_var = bool(checked)
except Exception:
pass
gui_yield_cb.toggled.connect(_on_gui_yield_toggle)
gui_yield_h.addWidget(gui_yield_cb)
gui_yield_h.addStretch()
section_v.addWidget(gui_yield_row)
gui_yield_desc = QLabel("Adds small delays during extraction to keep GUI responsive.\n⚠️ Disable for maximum extraction speed (GUI may freeze temporarily)")
gui_yield_desc.setStyleSheet("color: gray; font-size: 10pt;")
gui_yield_desc.setContentsMargins(20, 5, 0, 10)
section_v.addWidget(gui_yield_desc)
# Separator
sep2 = QFrame()
sep2.setFrameShape(QFrame.HLine)
sep2.setFrameShadow(QFrame.Sunken)
section_v.addWidget(sep2)
# Multi API Key Management Section
multi_key_row = QWidget()
multi_key_h = QHBoxLayout(multi_key_row)
multi_key_h.setContentsMargins(0, 0, 0, 15)
# Create status labels and store references for dynamic updates
self.multi_key_status_label1 = QLabel("πŸ”‘ Multi-Key Mode:")
self.multi_key_status_label1.setStyleSheet("font-weight: bold; font-size: 11pt;")
multi_key_h.addWidget(self.multi_key_status_label1)
self.multi_key_status_label2 = QLabel()
multi_key_h.addWidget(self.multi_key_status_label2)
# Update status initially
self._update_multi_key_status_label()
multi_key_h.addStretch()
section_v.addWidget(multi_key_row)
multi_key_desc = QLabel("Manage multiple API keys with automatic rotation and rate limit handling")
multi_key_desc.setStyleSheet("color: gray; font-size: 10pt;")
multi_key_desc.setContentsMargins(20, 0, 0, 5)
section_v.addWidget(multi_key_desc)
# Multi API Key Manager button (moved below description)
btn_row = QWidget()
btn_row_h = QHBoxLayout(btn_row)
btn_row_h.setContentsMargins(20, 5, 0, 10)
btn_multi_key = QPushButton("βš™οΈ Configure API Keys")
btn_multi_key.setMinimumWidth(160)
btn_multi_key.setMaximumWidth(200)
btn_multi_key.setMinimumHeight(28)
btn_multi_key.setStyleSheet(
"QPushButton { "
" background-color: #17a2b8; "
" color: white; "
" padding: 5px 12px; "
" font-size: 10pt; "
" font-weight: bold; "
" border-radius: 3px; "
"} "
"QPushButton:hover { background-color: #138496; }"
)
btn_multi_key.clicked.connect(lambda: self.open_multi_api_key_manager())
btn_row_h.addWidget(btn_multi_key)
btn_row_h.addStretch()
section_v.addWidget(btn_row)
# Separator
sep5 = QFrame()
sep5.setFrameShape(QFrame.HLine)
sep5.setFrameShadow(QFrame.Sunken)
section_v.addWidget(sep5)
# Compression Factor
compression_title = QLabel("Translation Compression Factor")
compression_title.setStyleSheet("font-weight: bold; font-size: 11pt;")
section_v.addWidget(compression_title)
# Auto Compression Factor toggle
auto_compression_cb = self._create_styled_checkbox("Auto Compression Factor")
try:
auto_compression_cb.setChecked(bool(self.config.get('auto_compression_factor', True)))
except Exception:
auto_compression_cb.setChecked(True)
compression_w = QWidget()
compression_h = QHBoxLayout(compression_w)
compression_h.setContentsMargins(20, 5, 0, 0)
compression_h.addWidget(QLabel("CJK→English compression:"))
compression_edit = QLineEdit()
compression_edit.setFixedWidth(60)
try:
compression_edit.setText(str(self.compression_factor_var))
except Exception:
pass
def _update_compression_factor():
"""Update compression factor based on output token limit when auto is enabled"""
try:
if not auto_compression_cb.isChecked():
return
# Get current output token limit
output_tokens = int(getattr(self, 'max_output_tokens', 65536))
# Determine compression factor based on token limit
if output_tokens < 16379:
factor = 1.5
elif output_tokens < 32769:
factor = 2.0
elif output_tokens < 65536:
factor = 2.5
else: # 65536 or above
factor = 3.0
# Update the field and variable
compression_edit.setText(str(factor))
self.compression_factor_var = str(factor)
except Exception as e:
print(f"Error updating compression factor: {e}")
# Store the update function as an instance method so it can be called from main GUI
self._update_auto_compression_factor = _update_compression_factor
def _on_compression_changed(text):
try:
self.compression_factor_var = text
except Exception:
pass
def _on_auto_compression_toggle(checked):
try:
self.config['auto_compression_factor'] = bool(checked)
# Enable/disable manual editing
compression_edit.setEnabled(not checked)
# Update factor when enabling auto
if checked:
_update_compression_factor()
except Exception as e:
print(f"Error toggling auto compression: {e}")
auto_compression_cb.toggled.connect(_on_auto_compression_toggle)
section_v.addWidget(auto_compression_cb)
auto_compression_desc = QLabel("Automatically adjusts based on output token limit:\n<16379: 1.5 | <32769: 2.0 | <65536: 2.5 | β‰₯65536: 3.0")
auto_compression_desc.setStyleSheet("color: gray; font-size: 10pt;")
auto_compression_desc.setContentsMargins(20, 0, 0, 10)
section_v.addWidget(auto_compression_desc)
compression_edit.textChanged.connect(_on_compression_changed)
compression_h.addWidget(compression_edit)
compression_h.addWidget(QLabel("(1.0-5.0)"))
compression_h.addStretch()
section_v.addWidget(compression_w)
# Apply initial state
_on_auto_compression_toggle(auto_compression_cb.isChecked())
compression_desc = QLabel("Ratio for chunk sizing based on output limits")
compression_desc.setStyleSheet("color: gray; font-size: 10pt;")
compression_desc.setContentsMargins(20, 0, 0, 10)
section_v.addWidget(compression_desc)
# Separator
sep6 = QFrame()
sep6.setFrameShape(QFrame.HLine)
sep6.setFrameShadow(QFrame.Sunken)
section_v.addWidget(sep6)
# HTTP Timeouts & Connection Pooling
http_title = QLabel("HTTP Timeouts & Connection Pooling")
http_title.setStyleSheet("font-weight: bold; font-size: 11pt;")
section_v.addWidget(http_title)
http_main = QWidget()
http_main_v = QVBoxLayout(http_main)
http_main_v.setContentsMargins(20, 5, 0, 0)
# Master toggle to enable/disable all HTTP tuning fields
if not hasattr(self, 'enable_http_tuning_var'):
self.enable_http_tuning_var = self.config.get('enable_http_tuning', False)
self.http_tuning_checkbox = self._create_styled_checkbox("Enable HTTP timeout/pooling overrides")
try:
self.http_tuning_checkbox.setChecked(bool(self.enable_http_tuning_var))
except Exception:
pass
def _on_http_tuning_toggle(checked):
try:
self.enable_http_tuning_var = bool(checked)
if hasattr(self, '_toggle_http_tuning_controls'):
self._toggle_http_tuning_controls()
except Exception:
pass
self.http_tuning_checkbox.toggled.connect(_on_http_tuning_toggle)
http_main_v.addWidget(self.http_tuning_checkbox)
http_main_v.addSpacing(8)
# 2 column grid layout for more compact display
if not hasattr(self, 'connect_timeout_var'):
self.connect_timeout_var = str(self.config.get('connect_timeout', os.environ.get('CONNECT_TIMEOUT', '10')))
if not hasattr(self, 'read_timeout_var'):
self.read_timeout_var = str(self.config.get('read_timeout', os.environ.get('READ_TIMEOUT', os.environ.get('CHUNK_TIMEOUT', '1800'))))
if not hasattr(self, 'http_pool_connections_var'):
self.http_pool_connections_var = str(self.config.get('http_pool_connections', os.environ.get('HTTP_POOL_CONNECTIONS', '20')))
if not hasattr(self, 'http_pool_maxsize_var'):
self.http_pool_maxsize_var = str(self.config.get('http_pool_maxsize', os.environ.get('HTTP_POOL_MAXSIZE', '50')))
# Create grid for 2-column layout
http_grid = QWidget()
http_grid_layout = QGridLayout(http_grid)
http_grid_layout.setContentsMargins(0, 0, 0, 5)
http_grid_layout.setHorizontalSpacing(0) # No spacing between label and input
http_grid_layout.setVerticalSpacing(5)
# Row 0: Connect timeout and Read timeout
self.connect_timeout_label = QLabel("Connect timeout (s): ")
http_grid_layout.addWidget(self.connect_timeout_label, 0, 0, alignment=Qt.AlignRight)
self.connect_timeout_entry = QLineEdit()
self.connect_timeout_entry.setFixedWidth(70)
try:
self.connect_timeout_entry.setText(str(self.connect_timeout_var))
except Exception:
pass
def _on_connect_timeout_changed(text):
try:
self.connect_timeout_var = text
except Exception:
pass
self.connect_timeout_entry.textChanged.connect(_on_connect_timeout_changed)
http_grid_layout.addWidget(self.connect_timeout_entry, 0, 1, alignment=Qt.AlignLeft)
self.read_timeout_label = QLabel(" Read timeout (s): ")
http_grid_layout.addWidget(self.read_timeout_label, 0, 2, alignment=Qt.AlignRight)
self.read_timeout_entry = QLineEdit()
self.read_timeout_entry.setFixedWidth(70)
try:
self.read_timeout_entry.setText(str(self.read_timeout_var))
except Exception:
pass
def _on_read_timeout_changed(text):
try:
self.read_timeout_var = text
except Exception:
pass
self.read_timeout_entry.textChanged.connect(_on_read_timeout_changed)
http_grid_layout.addWidget(self.read_timeout_entry, 0, 3, alignment=Qt.AlignLeft)
# Row 1: Pool connections and Pool max size
self.http_pool_connections_label = QLabel("Pool connections: ")
http_grid_layout.addWidget(self.http_pool_connections_label, 1, 0, alignment=Qt.AlignRight)
self.http_pool_connections_entry = QLineEdit()
self.http_pool_connections_entry.setFixedWidth(70)
try:
self.http_pool_connections_entry.setText(str(self.http_pool_connections_var))
except Exception:
pass
def _on_pool_conn_changed(text):
try:
self.http_pool_connections_var = text
except Exception:
pass
self.http_pool_connections_entry.textChanged.connect(_on_pool_conn_changed)
http_grid_layout.addWidget(self.http_pool_connections_entry, 1, 1, alignment=Qt.AlignLeft)
self.http_pool_maxsize_label = QLabel(" Pool max size: ")
http_grid_layout.addWidget(self.http_pool_maxsize_label, 1, 2, alignment=Qt.AlignRight)
self.http_pool_maxsize_entry = QLineEdit()
self.http_pool_maxsize_entry.setFixedWidth(70)
try:
self.http_pool_maxsize_entry.setText(str(self.http_pool_maxsize_var))
except Exception:
pass
def _on_pool_maxsize_changed(text):
try:
self.http_pool_maxsize_var = text
except Exception:
pass
self.http_pool_maxsize_entry.textChanged.connect(_on_pool_maxsize_changed)
http_grid_layout.addWidget(self.http_pool_maxsize_entry, 1, 3, alignment=Qt.AlignLeft)
http_main_v.addWidget(http_grid)
# Optional toggle: ignore server Retry-After header
if not hasattr(self, 'ignore_retry_after_var'):
self.ignore_retry_after_var = bool(self.config.get('ignore_retry_after', str(os.environ.get('IGNORE_RETRY_AFTER', '0')) == '1'))
self.ignore_retry_after_checkbox = self._create_styled_checkbox("Ignore server Retry-After header (use local backoff)")
try:
self.ignore_retry_after_checkbox.setChecked(bool(self.ignore_retry_after_var))
except Exception:
pass
def _on_ignore_retry_after_toggle(checked):
try:
self.ignore_retry_after_var = bool(checked)
except Exception:
pass
self.ignore_retry_after_checkbox.toggled.connect(_on_ignore_retry_after_toggle)
http_main_v.addWidget(self.ignore_retry_after_checkbox)
section_v.addWidget(http_main)
# Apply initial enable/disable state
if hasattr(self, '_toggle_http_tuning_controls'):
self._toggle_http_tuning_controls()
http_desc = QLabel("Controls network behavior for connection establishment timeout, read timeout,\nHTTP connection pool sizes.")
http_desc.setStyleSheet("color: gray; font-size: 10pt;")
http_desc.setContentsMargins(20, 2, 0, 5)
section_v.addWidget(http_desc)
# Separator
sep8 = QFrame()
sep8.setFrameShape(QFrame.HLine)
sep8.setFrameShadow(QFrame.Sunken)
section_v.addWidget(sep8)
# Stop Logic Behavior section
stop_logic_title = QLabel("Stop Logic Behavior")
stop_logic_title.setStyleSheet("font-weight: bold; font-size: 11pt;")
section_v.addWidget(stop_logic_title)
# Graceful Stop toggle
if not hasattr(self, 'graceful_stop_var'):
self.graceful_stop_var = self.config.get('graceful_stop', True)
self.graceful_stop_checkbox = self._create_styled_checkbox("Graceful Stop (wait for in-flight API calls)")
self.graceful_stop_checkbox.setContentsMargins(20, 5, 0, 0)
try:
self.graceful_stop_checkbox.setChecked(bool(self.graceful_stop_var))
except Exception:
pass
def _on_graceful_stop_toggle(checked):
try:
self.graceful_stop_var = bool(checked)
except Exception:
pass
self.graceful_stop_checkbox.toggled.connect(_on_graceful_stop_toggle)
section_v.addWidget(self.graceful_stop_checkbox)
graceful_stop_desc = QLabel("When enabled, pressing Stop will wait for in-flight API calls to complete\ninstead of aborting them. Saves API costs since calls already made will finish.")
graceful_stop_desc.setStyleSheet("color: gray; font-size: 10pt;")
graceful_stop_desc.setContentsMargins(40, 2, 0, 5)
section_v.addWidget(graceful_stop_desc)
# Wait for Chunks toggle (child of graceful stop)
if not hasattr(self, 'wait_for_chunks_var'):
self.wait_for_chunks_var = self.config.get('wait_for_chunks', False)
self.wait_for_chunks_checkbox = self._create_styled_checkbox("Wait for all chunks to complete")
self.wait_for_chunks_checkbox.setContentsMargins(40, 0, 0, 0)
try:
self.wait_for_chunks_checkbox.setChecked(bool(self.wait_for_chunks_var))
except Exception:
pass
def _on_wait_for_chunks_toggle(checked):
try:
self.wait_for_chunks_var = bool(checked)
except Exception:
pass
self.wait_for_chunks_checkbox.toggled.connect(_on_wait_for_chunks_toggle)
section_v.addWidget(self.wait_for_chunks_checkbox)
wait_chunks_desc = QLabel("When enabled, graceful stop will wait for all chunks of a chapter to complete\nbefore stopping. Prevents partial chapter translations.")
wait_chunks_desc.setStyleSheet("color: gray; font-size: 10pt;")
wait_chunks_desc.setContentsMargins(60, 2, 0, 5)
section_v.addWidget(wait_chunks_desc)
# Enable/disable based on graceful stop state
def _update_wait_for_chunks_state(graceful_checked):
self.wait_for_chunks_checkbox.setEnabled(graceful_checked)
wait_chunks_desc.setEnabled(graceful_checked)
if hasattr(self, 'save_partial_results_checkbox'):
self.save_partial_results_checkbox.setEnabled(graceful_checked)
if 'save_partial_desc' in locals():
save_partial_desc.setEnabled(graceful_checked)
if graceful_checked:
self.wait_for_chunks_checkbox.setStyleSheet("none")
self.wait_for_chunks_checkbox.setStyleSheet("")
self.wait_for_chunks_checkbox.style().unpolish(self.wait_for_chunks_checkbox)
self.wait_for_chunks_checkbox.style().polish(self.wait_for_chunks_checkbox)
wait_chunks_desc.setStyleSheet("color: gray; font-size: 10pt;")
if hasattr(self, 'save_partial_results_checkbox'):
self.save_partial_results_checkbox.setStyleSheet("none")
self.save_partial_results_checkbox.setStyleSheet("")
self.save_partial_results_checkbox.style().unpolish(self.save_partial_results_checkbox)
self.save_partial_results_checkbox.style().polish(self.save_partial_results_checkbox)
if 'save_partial_desc' in locals():
save_partial_desc.setStyleSheet("color: gray; font-size: 10pt;")
else:
self.wait_for_chunks_checkbox.setStyleSheet("QCheckBox { color: #606060; }")
wait_chunks_desc.setStyleSheet("color: #606060; font-size: 10pt;")
if hasattr(self, 'save_partial_results_checkbox'):
self.save_partial_results_checkbox.setStyleSheet("QCheckBox { color: #606060; }")
if 'save_partial_desc' in locals():
save_partial_desc.setStyleSheet("color: #606060; font-size: 10pt;")
self.graceful_stop_checkbox.toggled.connect(_update_wait_for_chunks_state)
_update_wait_for_chunks_state(self.graceful_stop_checkbox.isChecked())
# Separator before API Request Retries
sep_stop_logic = QFrame()
sep_stop_logic.setFrameShape(QFrame.HLine)
sep_stop_logic.setFrameShadow(QFrame.Sunken)
section_v.addWidget(sep_stop_logic)
# Max Retries Configuration
retries_title = QLabel("API Request Retries")
retries_title.setStyleSheet("font-weight: bold; font-size: 11pt;")
section_v.addWidget(retries_title)
retries_w = QWidget()
retries_h = QHBoxLayout(retries_w)
retries_h.setContentsMargins(20, 5, 0, 0)
# Create MAX_RETRIES variable if it doesn't exist
if not hasattr(self, 'max_retries_var'):
self.max_retries_var = str(self.config.get('max_retries', os.environ.get('MAX_RETRIES', '7')))
retries_h.addWidget(QLabel("Maximum retry attempts:"))
max_retries_edit = QLineEdit()
max_retries_edit.setFixedWidth(40)
try:
max_retries_edit.setText(str(self.max_retries_var))
except Exception:
pass
def _on_max_retries_changed(text):
try:
self.max_retries_var = text
except Exception:
pass
max_retries_edit.textChanged.connect(_on_max_retries_changed)
retries_h.addWidget(max_retries_edit)
retries_h.addWidget(QLabel("(default: 7)"))
retries_h.addStretch()
section_v.addWidget(retries_w)
retries_desc = QLabel("Number of times to retry failed API requests before giving up.\nApplies to all API providers (OpenAI, Gemini, Anthropic, etc.)")
retries_desc.setStyleSheet("color: gray; font-size: 10pt;")
retries_desc.setContentsMargins(20, 2, 0, 10)
section_v.addWidget(retries_desc)
# Indefinite Rate Limit Retry toggle (default OFF)
indefinite_retry_cb = self._create_styled_checkbox("Indefinite Rate Limit Retry")
indefinite_retry_cb.setContentsMargins(20, 0, 0, 0)
try:
indefinite_retry_cb.setChecked(bool(self.indefinite_rate_limit_retry_var))
except Exception:
pass
def _on_indefinite_retry_toggle(checked):
try:
self.indefinite_rate_limit_retry_var = bool(checked)
except Exception:
pass
indefinite_retry_cb.toggled.connect(_on_indefinite_retry_toggle)
section_v.addWidget(indefinite_retry_cb)
indefinite_desc = QLabel("When enabled, rate limit errors (429) will retry indefinitely with exponential backoff.\nWhen disabled, rate limits count against the maximum retry attempts above.")
indefinite_desc.setStyleSheet("color: gray; font-size: 10pt;")
indefinite_desc.setContentsMargins(40, 2, 0, 5)
section_v.addWidget(indefinite_desc)
# Separator
sep_retry_1 = QFrame()
sep_retry_1.setFrameShape(QFrame.HLine)
sep_retry_1.setFrameShadow(QFrame.Sunken)
section_v.addWidget(sep_retry_1)
# Retry Truncated
if not hasattr(self, 'truncation_retry_attempts_var'):
self.truncation_retry_attempts_var = str(self.config.get('truncation_retry_attempts', '1'))
# Char-ratio truncation (silent truncation detector)
if not hasattr(self, 'char_ratio_truncation_var'):
self.char_ratio_truncation_var = bool(self.config.get('char_ratio_truncation_enabled', False))
if not hasattr(self, 'char_ratio_truncation_percent_var'):
self.char_ratio_truncation_percent_var = str(self.config.get('char_ratio_truncation_percent', '50'))
if not hasattr(self, 'char_ratio_truncation_attempts_var'):
self.char_ratio_truncation_attempts_var = str(self.config.get('char_ratio_truncation_attempts', '1'))
if not hasattr(self, 'char_ratio_min_output_chars_var'):
self.char_ratio_min_output_chars_var = str(self.config.get('char_ratio_min_output_chars', '100'))
retry_truncated_cb = self._create_styled_checkbox("Auto-retry Truncated Responses")
retry_frame_w = QWidget()
retry_frame_h = QHBoxLayout(retry_frame_w)
retry_frame_h.setContentsMargins(20, 5, 0, 5)
retry_tokens_label = QLabel("Token constraint:")
retry_frame_h.addWidget(retry_tokens_label)
retry_tokens_edit = QLineEdit()
retry_tokens_edit.setFixedWidth(80)
try:
retry_tokens_edit.setText(str(self.max_retry_tokens_var))
except Exception:
pass
def _on_retry_tokens_changed(text):
try:
self.max_retry_tokens_var = text
except Exception:
pass
retry_tokens_edit.textChanged.connect(_on_retry_tokens_changed)
retry_frame_h.addWidget(retry_tokens_edit)
retry_attempts_label = QLabel("Truncated attempts:")
retry_frame_h.addWidget(retry_attempts_label)
retry_attempts_edit = QLineEdit()
retry_attempts_edit.setFixedWidth(50)
try:
retry_attempts_edit.setText(str(self.truncation_retry_attempts_var))
except Exception:
retry_attempts_edit.setText("1")
def _on_retry_attempts_changed(text):
try:
self.truncation_retry_attempts_var = text
except Exception:
pass
retry_attempts_edit.textChanged.connect(_on_retry_attempts_changed)
retry_frame_h.addWidget(retry_attempts_edit)
retry_frame_h.addStretch()
retry_desc = QLabel("Retry when truncated. Acts as min/max constraint:\nbelow value = minimum, above value = maximum\nSet token constraint to -1 to use the global output token limit")
retry_desc.setContentsMargins(20, 0, 0, 10)
# Char-ratio truncation controls (silent truncation detector)
char_ratio_cb = self._create_styled_checkbox("Auto-retry Silent Truncation (Char-ratio)")
char_ratio_cb.setContentsMargins(20, 0, 0, 0)
char_ratio_cb.setToolTip(
"<qt><p style='white-space: normal; max-width: 32em; margin: 0;'>"
"Auto-retry logic for clear cases of truncation that the API does not report. "
"Detects silent truncation by comparing input vs output character counts. "
"Skipped when base64 images are present (they skew counts)."
"</p></qt>"
)
try:
char_ratio_cb.setChecked(bool(self.char_ratio_truncation_var))
except Exception:
char_ratio_cb.setChecked(True)
char_ratio_frame_w = QWidget()
char_ratio_frame_h = QHBoxLayout(char_ratio_frame_w)
char_ratio_frame_h.setContentsMargins(40, 5, 0, 0)
char_ratio_frame_w2 = QWidget()
char_ratio_frame_h2 = QHBoxLayout(char_ratio_frame_w2)
char_ratio_frame_h2.setContentsMargins(40, 0, 0, 5)
char_ratio_percent_label = QLabel("Trigger below (%):")
char_ratio_percent_label.setToolTip(
"Threshold ratio of Output/Input characters.\n"
"If output is less than this % of input length, it's considered truncated."
)
char_ratio_frame_h.addWidget(char_ratio_percent_label)
char_ratio_percent_edit = QLineEdit()
char_ratio_percent_edit.setFixedWidth(50)
try:
char_ratio_percent_edit.setText(str(self.char_ratio_truncation_percent_var))
except Exception:
char_ratio_percent_edit.setText("50")
def _on_char_ratio_percent_changed(text):
try:
self.char_ratio_truncation_percent_var = text
except Exception:
pass
char_ratio_percent_edit.textChanged.connect(_on_char_ratio_percent_changed)
char_ratio_frame_h.addWidget(char_ratio_percent_edit)
char_ratio_attempts_label = QLabel("Char-ratio retry attempts:")
char_ratio_frame_h.addWidget(char_ratio_attempts_label)
char_ratio_attempts_edit = QLineEdit()
char_ratio_attempts_edit.setFixedWidth(50)
try:
char_ratio_attempts_edit.setText(str(self.char_ratio_truncation_attempts_var))
except Exception:
char_ratio_attempts_edit.setText("1")
def _on_char_ratio_attempts_changed(text):
try:
self.char_ratio_truncation_attempts_var = text
except Exception:
pass
char_ratio_attempts_edit.textChanged.connect(_on_char_ratio_attempts_changed)
char_ratio_frame_h.addWidget(char_ratio_attempts_edit)
char_ratio_frame_h.addStretch()
char_ratio_min_chars_label = QLabel("Skip check if output is less than N Characters:")
char_ratio_min_chars_label.setToolTip(
"Safety threshold: if the output is extremely short (less than this value),\n"
"skip the ratio check to avoid false positives on short answers."
)
char_ratio_frame_h2.addWidget(char_ratio_min_chars_label)
char_ratio_min_chars_edit = QLineEdit()
char_ratio_min_chars_edit.setFixedWidth(60)
try:
char_ratio_min_chars_edit.setText(str(self.char_ratio_min_output_chars_var))
except Exception:
char_ratio_min_chars_edit.setText("100")
def _on_char_ratio_min_chars_changed(text):
try:
self.char_ratio_min_output_chars_var = text
except Exception:
pass
char_ratio_min_chars_edit.textChanged.connect(_on_char_ratio_min_chars_changed)
char_ratio_frame_h2.addWidget(char_ratio_min_chars_edit)
char_ratio_frame_h2.addStretch()
char_ratio_desc = QLabel(
"Auto-retry logic for clear cases of truncation that the API does not report.\n"
"Flags likely silent truncation when output is much shorter than input."
)
char_ratio_desc.setContentsMargins(40, 0, 0, 10)
def _on_char_ratio_toggle(checked):
try:
self.char_ratio_truncation_var = bool(checked)
main_enabled = retry_truncated_cb.isChecked()
effective = bool(checked) and bool(main_enabled)
# Enable/disable controls
char_ratio_percent_label.setEnabled(effective)
char_ratio_percent_edit.setEnabled(effective)
char_ratio_attempts_label.setEnabled(effective)
char_ratio_attempts_edit.setEnabled(effective)
char_ratio_min_chars_label.setEnabled(effective)
char_ratio_min_chars_edit.setEnabled(effective)
char_ratio_desc.setEnabled(effective)
# Update styles
if effective:
char_ratio_percent_label.setStyleSheet("color: white;")
char_ratio_attempts_label.setStyleSheet("color: white;")
char_ratio_min_chars_label.setStyleSheet("color: white;")
char_ratio_desc.setStyleSheet("color: gray; font-size: 10pt;")
char_ratio_percent_edit.setStyleSheet("color: white;")
char_ratio_attempts_edit.setStyleSheet("color: white;")
char_ratio_min_chars_edit.setStyleSheet("color: white;")
else:
char_ratio_percent_label.setStyleSheet("color: #606060;")
char_ratio_attempts_label.setStyleSheet("color: #606060;")
char_ratio_min_chars_label.setStyleSheet("color: #606060;")
char_ratio_desc.setStyleSheet("color: #606060; font-size: 10pt;")
char_ratio_percent_edit.setStyleSheet("color: #909090;")
char_ratio_attempts_edit.setStyleSheet("color: #909090;")
char_ratio_min_chars_edit.setStyleSheet("color: #909090;")
except Exception:
pass
char_ratio_cb.toggled.connect(_on_char_ratio_toggle)
def _on_retry_truncated_toggle(checked):
try:
self.retry_truncated_var = bool(checked)
# Update UI state
retry_tokens_edit.setEnabled(checked)
retry_tokens_label.setEnabled(checked)
retry_desc.setEnabled(checked)
retry_attempts_edit.setEnabled(checked)
retry_attempts_label.setEnabled(checked)
# Char-ratio truncation controls depend on the main retry toggle
char_ratio_cb.setEnabled(bool(checked))
# Update subordinate controls without changing the user's checkbox state
_on_char_ratio_toggle(char_ratio_cb.isChecked())
# Update styles
if checked:
retry_tokens_label.setStyleSheet("color: white;")
retry_desc.setStyleSheet("color: gray; font-size: 10pt;")
retry_tokens_edit.setStyleSheet("color: white;")
retry_attempts_label.setStyleSheet("color: white;")
retry_attempts_edit.setStyleSheet("color: white;")
else:
retry_tokens_label.setStyleSheet("color: #606060;")
retry_desc.setStyleSheet("color: #606060; font-size: 10pt;")
retry_tokens_edit.setStyleSheet("color: #909090;")
retry_attempts_label.setStyleSheet("color: #606060;")
retry_attempts_edit.setStyleSheet("color: #909090;")
except Exception:
pass
try:
retry_truncated_cb.setChecked(bool(self.retry_truncated_var))
except Exception:
pass
retry_truncated_cb.toggled.connect(_on_retry_truncated_toggle)
# Initialize UI state based on current value
_on_retry_truncated_toggle(retry_truncated_cb.isChecked())
section_v.addWidget(retry_truncated_cb)
section_v.addWidget(retry_frame_w)
section_v.addWidget(retry_desc)
section_v.addWidget(char_ratio_cb)
section_v.addWidget(char_ratio_frame_w)
section_v.addWidget(char_ratio_frame_w2)
section_v.addWidget(char_ratio_desc)
# Separator
sep4 = QFrame()
sep4.setFrameShape(QFrame.HLine)
sep4.setFrameShadow(QFrame.Sunken)
section_v.addWidget(sep4)
# Retry Slow
retry_slow_cb = self._create_styled_checkbox("Auto-retry Slow Processing (API Timeouts)")
retry_slow_cb.setContentsMargins(0, 15, 0, 0)
timeout_w = QWidget()
timeout_h = QHBoxLayout(timeout_w)
timeout_h.setContentsMargins(20, 5, 0, 0)
timeout_label_1 = QLabel("Timeout after (seconds):")
timeout_h.addWidget(timeout_label_1)
timeout_edit = QLineEdit()
timeout_edit.setFixedWidth(60)
try:
timeout_edit.setText(str(self.chunk_timeout_var))
except Exception:
pass
def _on_timeout_changed(text):
try:
self.chunk_timeout_var = text
except Exception:
pass
timeout_edit.textChanged.connect(_on_timeout_changed)
timeout_h.addWidget(timeout_edit)
timeout_attempts_label = QLabel("Timeout attempts:")
timeout_h.addWidget(timeout_attempts_label)
timeout_attempts_edit = QLineEdit()
timeout_attempts_edit.setFixedWidth(50)
if not hasattr(self, 'timeout_retry_attempts_var'):
self.timeout_retry_attempts_var = str(self.config.get('timeout_retry_attempts', '2'))
try:
timeout_attempts_edit.setText(str(self.timeout_retry_attempts_var))
except Exception:
timeout_attempts_edit.setText("2")
def _on_timeout_attempts_changed(text):
try:
self.timeout_retry_attempts_var = text
except Exception:
pass
timeout_attempts_edit.textChanged.connect(_on_timeout_attempts_changed)
timeout_h.addWidget(timeout_attempts_edit)
timeout_h.addStretch()
timeout_desc = QLabel("Adds API timeout logic to text/images chunks that take too long\nThis will also affect chapter extraction timeout")
timeout_desc.setContentsMargins(20, 0, 0, 5)
def _on_retry_slow_toggle(checked):
try:
self.retry_timeout_var = bool(checked)
# Update UI state
timeout_edit.setEnabled(bool(checked))
timeout_label_1.setEnabled(bool(checked))
timeout_attempts_label.setEnabled(bool(checked))
timeout_attempts_edit.setEnabled(bool(checked))
timeout_desc.setEnabled(bool(checked))
# Update styles
if checked:
timeout_label_1.setStyleSheet("color: white;")
timeout_attempts_label.setStyleSheet("color: white;")
timeout_attempts_edit.setStyleSheet("color: white;")
timeout_desc.setStyleSheet("color: gray; font-size: 10pt;")
else:
timeout_label_1.setStyleSheet("color: #606060;")
timeout_attempts_label.setStyleSheet("color: #606060;")
timeout_attempts_edit.setStyleSheet("color: #909090;")
timeout_desc.setStyleSheet("color: #606060; font-size: 10pt;")
except Exception:
pass
try:
retry_slow_cb.setChecked(bool(self.retry_timeout_var))
except Exception:
pass
retry_slow_cb.toggled.connect(_on_retry_slow_toggle)
# Apply initial styling
_on_retry_slow_toggle(retry_slow_cb.isChecked())
section_v.addWidget(retry_slow_cb)
section_v.addWidget(timeout_w)
section_v.addWidget(timeout_desc)
# Separator
sep7 = QFrame()
sep7.setFrameShape(QFrame.HLine)
sep7.setFrameShadow(QFrame.Sunken)
section_v.addWidget(sep7)
# Retry Duplicate
retry_duplicate_cb = self._create_styled_checkbox("Auto-retry Duplicate Content")
duplicate_w = QWidget()
duplicate_h = QHBoxLayout(duplicate_w)
duplicate_h.setContentsMargins(20, 5, 0, 0)
duplicate_label_1 = QLabel("Check last")
duplicate_h.addWidget(duplicate_label_1)
duplicate_edit = QLineEdit()
duplicate_edit.setFixedWidth(40)
try:
duplicate_edit.setText(str(self.duplicate_lookback_var))
except Exception:
pass
def _on_duplicate_lookback_changed(text):
try:
self.duplicate_lookback_var = text
except Exception:
pass
duplicate_edit.textChanged.connect(_on_duplicate_lookback_changed)
duplicate_h.addWidget(duplicate_edit)
duplicate_label_2 = QLabel("chapters")
duplicate_h.addWidget(duplicate_label_2)
duplicate_h.addStretch()
duplicate_desc = QLabel("Detects when AI returns same content\nfor different chapters")
duplicate_desc.setContentsMargins(20, 5, 0, 10)
def _on_retry_duplicate_toggle(checked):
try:
self.retry_duplicate_var = bool(checked)
update_detection_visibility()
# Update UI state
duplicate_edit.setEnabled(bool(checked))
duplicate_label_1.setEnabled(bool(checked))
duplicate_label_2.setEnabled(bool(checked))
duplicate_desc.setEnabled(bool(checked))
# Update styles
if checked:
duplicate_label_1.setStyleSheet("color: white;")
duplicate_label_2.setStyleSheet("color: white;")
duplicate_desc.setStyleSheet("color: gray; font-size: 10pt;")
duplicate_edit.setStyleSheet("")
else:
duplicate_label_1.setStyleSheet("color: #606060;")
duplicate_label_2.setStyleSheet("color: #606060;")
duplicate_desc.setStyleSheet("color: #606060; font-size: 10pt;")
except Exception:
pass
try:
retry_duplicate_cb.setChecked(bool(self.retry_duplicate_var))
except Exception:
pass
retry_duplicate_cb.toggled.connect(_on_retry_duplicate_toggle)
section_v.addWidget(retry_duplicate_cb)
section_v.addWidget(duplicate_w)
section_v.addWidget(duplicate_desc)
# Container for detection-related options (to show/hide based on toggle)
self.detection_options_container = QWidget()
detection_options_v = QVBoxLayout(self.detection_options_container)
detection_options_v.setContentsMargins(0, 0, 0, 0)
# Update thinking budget entry state based on initial toggle state
self.toggle_thinking_budget()
# Function to show/hide detection options based on auto-retry toggle
def update_detection_visibility():
try:
if self.retry_duplicate_var:
self.detection_options_container.setVisible(True)
else:
self.detection_options_container.setVisible(False)
except Exception:
pass
# Apply initial styling and visibility
_on_retry_duplicate_toggle(retry_duplicate_cb.isChecked())
# Detection Method subsection (now inside the container)
method_label = QLabel("Detection Method:")
method_label.setStyleSheet("font-weight: bold; font-size: 10pt;")
method_label.setContentsMargins(20, 10, 0, 5)
detection_options_v.addWidget(method_label)
methods = [
("basic", "Basic (Fast) - Original 85% threshold, 1000 chars"),
("ai-hunter", "AI Hunter - Multi-method semantic analysis"),
("cascading", "Cascading - Basic first, then AI Hunter")
]
# Container for AI Hunter config (will be shown/hidden based on selection)
self.ai_hunter_container = QWidget()
ai_hunter_v = QVBoxLayout(self.ai_hunter_container)
ai_hunter_v.setContentsMargins(0, 0, 0, 0)
# Function to update AI Hunter visibility based on detection mode
def update_ai_hunter_visibility(*args):
"""Update AI Hunter section visibility based on selection"""
# Clear existing widgets
while ai_hunter_v.count():
child = ai_hunter_v.takeAt(0)
if child.widget():
child.widget().deleteLater()
# Show AI Hunter config for both ai-hunter and cascading modes
try:
if self.duplicate_detection_mode_var in ['ai-hunter', 'cascading']:
self.create_ai_hunter_section(self.ai_hunter_container)
except Exception:
pass
# Update status if label exists
if hasattr(self, 'ai_hunter_status_label'):
try:
self.ai_hunter_status_label.setText(self._get_ai_hunter_status_text())
except Exception:
pass
# Create radio buttons (inside detection container)
detection_button_group = QButtonGroup(self.detection_options_container)
for value, text in methods:
rb = QRadioButton(text)
rb.setContentsMargins(40, 2, 0, 2)
try:
if self.duplicate_detection_mode_var == value:
rb.setChecked(True)
except Exception:
pass
def _make_rb_callback(val):
def _cb(checked):
if checked:
try:
self.duplicate_detection_mode_var = val
update_ai_hunter_visibility()
except Exception:
pass
return _cb
rb.toggled.connect(_make_rb_callback(value))
detection_button_group.addButton(rb)
detection_options_v.addWidget(rb)
# Pack the AI Hunter container
detection_options_v.addWidget(self.ai_hunter_container)
section_v.addWidget(self.detection_options_container)
# Initial visibility updates
update_detection_visibility()
update_ai_hunter_visibility()
# Separator
sep_preserve = QFrame()
sep_preserve.setFrameShape(QFrame.HLine)
sep_preserve.setFrameShadow(QFrame.Sunken)
section_v.addWidget(sep_preserve)
# Save interrupted chapters
if not hasattr(self, 'save_partial_results_var'):
self.save_partial_results_var = self.config.get('save_partial_results', False)
self.save_partial_results_checkbox = self._create_styled_checkbox("Save interrupted chapters")
self.save_partial_results_checkbox.setContentsMargins(20, 5, 0, 0)
self.save_partial_results_checkbox.setToolTip(
"When enabled, chapters interrupted by graceful stop are saved\n"
"and marked as QA failed (PARTIAL/TRUNCATED) instead of staying pending."
)
try:
self.save_partial_results_checkbox.setChecked(bool(self.save_partial_results_var))
except Exception:
pass
def _on_save_partial_results_toggle(checked):
try:
self.save_partial_results_var = bool(checked)
except Exception:
pass
self.save_partial_results_checkbox.toggled.connect(_on_save_partial_results_toggle)
section_v.addWidget(self.save_partial_results_checkbox)
save_partial_desc = QLabel(
"Saves partial output when a chapter is interrupted by graceful stop, and marks it QA failed."
)
save_partial_desc.setStyleSheet("color: gray; font-size: 10pt;")
save_partial_desc.setContentsMargins(20, 2, 0, 10)
section_v.addWidget(save_partial_desc)
# Save blocked/prohibited responses (separate toggle)
if not hasattr(self, 'save_prohibited_results_var'):
self.save_prohibited_results_var = self.config.get('save_prohibited_results', False)
self.save_prohibited_results_checkbox = self._create_styled_checkbox("Save blocked/prohibited responses")
self.save_prohibited_results_checkbox.setContentsMargins(20, 0, 0, 0)
self.save_prohibited_results_checkbox.setToolTip(
"When enabled, blocked/prohibited responses are saved (AI output or empty)\n"
"and marked as QA failed (PROHIBITED_CONTENT)."
)
try:
self.save_prohibited_results_checkbox.setChecked(bool(self.save_prohibited_results_var))
except Exception:
pass
def _on_save_prohibited_results_toggle(checked):
try:
self.save_prohibited_results_var = bool(checked)
except Exception:
pass
self.save_prohibited_results_checkbox.toggled.connect(_on_save_prohibited_results_toggle)
section_v.addWidget(self.save_prohibited_results_checkbox)
save_prohibited_desc = QLabel(
"Saves blocked/prohibited responses and marks the chapter QA failed."
)
save_prohibited_desc.setStyleSheet("color: gray; font-size: 10pt;")
save_prohibited_desc.setContentsMargins(20, 2, 0, 10)
section_v.addWidget(save_prohibited_desc)
# Preserve Original Text on Failure
preserve_cb = self._create_styled_checkbox("Preserve Original Text on Failure")
preserve_cb.setToolTip(
"When enabled, failed translation responses (timeouts, rate limits, extraction failures, etc.)\n"
"return the original source text inside a failure marker instead of an empty/blocked response."
)
try:
preserve_cb.setChecked(bool(self.preserve_original_text_var))
except Exception:
pass
def _on_preserve_toggle(checked):
try:
self.preserve_original_text_var = bool(checked)
except Exception:
pass
preserve_cb.toggled.connect(_on_preserve_toggle)
section_v.addWidget(preserve_cb)
preserve_desc = QLabel("Return original untranslated text when translation fails.\n⚠️ May mix source language into translated output")
preserve_desc.setStyleSheet("color: gray; font-size: 10pt;")
preserve_desc.setContentsMargins(20, 5, 0, 10)
section_v.addWidget(preserve_desc)
# Disable QA marker checks
disable_qa_markers_cb = self._create_styled_checkbox("Disable QA marker checks (error-keyword auto-fail)")
try:
disable_qa_markers_cb.setChecked(bool(self.disable_qa_marker_checks_var))
except Exception:
pass
def _on_disable_qa_markers_toggle(checked):
try:
self.disable_qa_marker_checks_var = bool(checked)
except Exception:
pass
disable_qa_markers_cb.toggled.connect(_on_disable_qa_markers_toggle)
# Row: disable toggle + threshold
qa_marker_row = QWidget()
qa_marker_h = QHBoxLayout(qa_marker_row)
qa_marker_h.setContentsMargins(0, 0, 0, 0)
qa_marker_h.addWidget(disable_qa_markers_cb)
qa_marker_h.addSpacing(12)
qa_marker_h.addWidget(QLabel("Marker length limit:"))
qa_marker_limit_entry = QLineEdit()
qa_marker_limit_entry.setFixedWidth(70)
try:
qa_marker_limit_entry.setText(str(self.qa_marker_length_limit_var))
except Exception:
qa_marker_limit_entry.setText("500")
def _on_qa_marker_limit_changed(text):
try:
self.qa_marker_length_limit_var = text
except Exception:
pass
qa_marker_limit_entry.textChanged.connect(_on_qa_marker_limit_changed)
qa_marker_h.addWidget(qa_marker_limit_entry)
qa_marker_h.addWidget(QLabel("chars"))
qa_marker_h.addStretch()
section_v.addWidget(qa_marker_row)
disable_qa_markers_desc = QLabel("When enabled, QA won't auto-fail based on error keywords (timeout, rate-limit, etc.).")
disable_qa_markers_desc.setStyleSheet("color: gray; font-size: 10pt;")
disable_qa_markers_desc.setContentsMargins(20, 5, 0, 10)
section_v.addWidget(disable_qa_markers_desc)
# Place the section at row 1, column 0 to match the original grid
try:
grid = parent.layout()
if grid:
grid.addWidget(section_box, 1, 0)
except Exception:
# Fallback: just stack
section_box.setParent(parent)
def open_multi_api_key_manager(self):
"""Open the multi API key manager dialog"""
from PySide6.QtWidgets import QMessageBox
# Import here to avoid circular imports
try:
from multi_api_key_manager import MultiAPIKeyDialog
# Use the static show_dialog method which handles PySide6 properly
# This blocks until the dialog is closed
MultiAPIKeyDialog.show_dialog(self.master, self)
# Refresh the settings display if in settings dialog
if hasattr(self, 'current_settings_dialog'):
try:
# Close and reopen settings to refresh (tkinter part)
self.current_settings_dialog.destroy()
self.show_settings() # or open_other_settings()
except Exception:
pass
except ImportError as e:
QMessageBox.critical(None, "Error", f"Failed to load Multi API Key Manager: {str(e)}")
except Exception as e:
QMessageBox.critical(None, "Error", f"Error opening Multi API Key Manager: {str(e)}")
import traceback
traceback.print_exc()
# DEPRECATED Tkinter function - not used in PySide6 version
# def _create_multi_key_row(self, parent):
# """Create a compact multi-key configuration row"""
# frame = tk.Frame(parent)
# frame.pack(fill=tk.X, pady=5)
#
# # Status indicator
# if self.config.get('use_multi_api_keys', False):
# keys = self.config.get('multi_api_keys', [])
# active = sum(1 for k in keys if k.get('enabled', True))
#
# # Checkbox to enable/disable
# tb.Checkbutton(frame, text="Multi API Key Mode",
# variable=self.use_multi_api_keys_var,
# bootstyle="round-toggle",
# command=self._toggle_multi_key_setting).pack(side=tk.LEFT)
#
# # Status
# tk.Label(frame, text=f"({active}/{len(keys)} active)",
# font=('TkDefaultFont', 10), fg='green').pack(side=tk.LEFT, padx=(5, 0))
# else:
# tb.Checkbutton(frame, text="Multi API Key Mode",
# variable=self.use_multi_api_keys_var,
# bootstyle="round-toggle",
# command=self._toggle_multi_key_setting).pack(side=tk.LEFT)
#
# # Configure button
# tb.Button(frame, text="Configure Keys...",
# command=self.open_multi_api_key_manager,
# bootstyle="primary-outline").pack(side=tk.LEFT, padx=(20, 0))
#
# return frame
def _update_multi_key_status_label(self):
"""Update the multi-key mode status label dynamically"""
try:
if not hasattr(self, 'multi_key_status_label2'):
return
if self.config.get('use_multi_api_keys', False):
multi_keys = self.config.get('multi_api_keys', [])
active_keys = sum(1 for k in multi_keys if k.get('enabled', True))
self.multi_key_status_label2.setText(f"ACTIVE ({active_keys}/{len(multi_keys)} keys)")
self.multi_key_status_label2.setStyleSheet("font-weight: bold; font-size: 11pt; color: green;")
else:
self.multi_key_status_label2.setText("DISABLED")
self.multi_key_status_label2.setStyleSheet("font-size: 11pt; color: gray;")
except Exception:
pass
def _toggle_multi_key_setting(self):
"""Toggle multi-key mode from settings dialog"""
self.config['use_multi_api_keys'] = self.use_multi_api_keys_var
# Don't save immediately, let the dialog's save button handle it
def toggle_image_translation_section(self):
"""Toggle visibility of image translation content with smooth fade"""
try:
if not hasattr(self, 'image_translation_content'):
return
enabled = bool(self.enable_image_translation_var)
# Import animation components
from PySide6.QtCore import QPropertyAnimation, QEasingCurve
from PySide6.QtWidgets import QGraphicsOpacityEffect
# Stop any existing animation
if hasattr(self, '_image_section_animation') and self._image_section_animation:
self._image_section_animation.stop()
# Ensure widget is visible for animation
self.image_translation_content.setVisible(True)
# Create or get opacity effect
if not hasattr(self.image_translation_content, '_opacity_effect'):
effect = QGraphicsOpacityEffect()
self.image_translation_content.setGraphicsEffect(effect)
self.image_translation_content._opacity_effect = effect
else:
effect = self.image_translation_content._opacity_effect
# Create opacity animation
animation = QPropertyAnimation(effect, b"opacity")
animation.setDuration(150) # Faster for no glitch
animation.setEasingCurve(QEasingCurve.Type.OutCubic)
if enabled:
# Fade in
animation.setStartValue(0.0)
animation.setEndValue(1.0)
else:
# Fade out
animation.setStartValue(1.0)
animation.setEndValue(0.0)
# Hide after fade out
animation.finished.connect(
lambda: self.image_translation_content.setVisible(False) if not enabled else None
)
self._image_section_animation = animation
animation.start()
except Exception:
# Fallback to simple show/hide if animation fails
try:
if hasattr(self, 'image_translation_content'):
enabled = bool(self.enable_image_translation_var)
self.image_translation_content.setVisible(enabled)
except Exception:
pass
def toggle_extraction_workers(self):
"""Enable/disable extraction workers entry and labels based on toggle (PySide6 version)"""
try:
enabled = bool(self.enable_parallel_extraction_var)
# Workers entry
if hasattr(self, 'extraction_workers_entry'):
self.extraction_workers_entry.setEnabled(enabled)
# Workers and threads labels
if hasattr(self, 'workers_label'):
self.workers_label.setEnabled(enabled)
color = "white" if enabled else "#808080"
self.workers_label.setStyleSheet(f"color: {color};")
if hasattr(self, 'threads_label'):
self.threads_label.setEnabled(enabled)
color = "white" if enabled else "#808080"
self.threads_label.setStyleSheet(f"color: {color};")
# Description label
if hasattr(self, 'parallel_desc_label'):
self.parallel_desc_label.setEnabled(enabled)
color = "gray" if enabled else "#606060"
self.parallel_desc_label.setStyleSheet(f"color: {color}; font-size: 10pt;")
if enabled:
# Set environment variable
os.environ["EXTRACTION_WORKERS"] = str(self.extraction_workers_var)
else:
# Set to 1 worker (sequential) when disabled
os.environ["EXTRACTION_WORKERS"] = "1"
# Ensure executor reflects current worker setting
try:
self._ensure_executor()
except Exception:
pass
except Exception:
pass
# DEPRECATED Tkinter function - not used in PySide6 version
# def _setup_provider_combobox_bindings(self):
# """Setup bindings for OpenRouter provider combobox with autocomplete"""
# try:
# # Bind to key release events for live filtering and autofill
# self.openrouter_provider_combo.bind('<KeyRelease>', self._on_provider_combo_keyrelease)
# # Commit best match on Enter
# self.openrouter_provider_combo.bind('<Return>', self._commit_provider_autocomplete)
# # Also bind to FocusOut to validate selection
# self.openrouter_provider_combo.bind('<FocusOut>', lambda e: self._validate_provider_selection())
# except Exception:
# pass # Silently fail if combo doesn't exist
# DEPRECATED Tkinter function - not used in PySide6 version
# def _on_provider_combo_keyrelease(self, event=None):
# """Provider combobox type-to-search with autocomplete (reuses model dropdown logic)"""
# try:
# combo = self.openrouter_provider_combo
# typed = combo.get()
# prev = getattr(self, '_provider_prev_text', '')
# keysym = (getattr(event, 'keysym', '') or '').lower()
#
# # Navigation/commit keys: don't interfere
# if keysym in {'up', 'down', 'left', 'right', 'return', 'escape', 'tab'}:
# return
#
# # Ensure we have the full source list
# source = getattr(self, '_provider_all_values', [])
# if not source:
# return
#
# # Compute match set
# first_match = None
# if typed:
# lowered = typed.lower()
# # Prefix matches first
# pref = [v for v in source if v.lower().startswith(lowered)]
# # Contains matches second
# cont = [v for v in source if lowered in v.lower() and v not in pref]
# if pref:
# first_match = pref[0]
# elif cont:
# first_match = cont[0]
#
# # Decide whether to autofill
# grew = len(typed) > len(prev) and typed.startswith(prev)
# is_deletion = keysym in {'backspace', 'delete'} or len(typed) < len(prev)
# try:
# at_end = combo.index(tk.INSERT) == len(typed)
# except Exception:
# at_end = True
# try:
# has_selection = combo.selection_present()
# except Exception:
# has_selection = False
#
# # Gentle autofill only when appending at the end
# do_autofill_text = first_match is not None and grew and at_end and not has_selection and not is_deletion
#
# if do_autofill_text:
# # Only complete if it's a true prefix match
# if first_match.lower().startswith(typed.lower()) and first_match != typed:
# combo.set(first_match)
# try:
# combo.icursor(len(typed))
# combo.selection_range(len(typed), len(first_match))
# except Exception:
# pass
#
# # If we have a match and the dropdown is open, scroll/highlight it
# if first_match:
# self._scroll_provider_list_to_value(first_match)
#
# # Remember current text for next event
# self._provider_prev_text = typed
# except Exception:
# pass # Silently handle errors
def _commit_provider_autocomplete(self, event=None):
"""On Enter, commit to the best matching provider"""
try:
combo = self.openrouter_provider_combo
typed = combo.get()
source = getattr(self, '_provider_all_values', [])
match = None
if typed:
lowered = typed.lower()
pref = [v for v in source if v.lower().startswith(lowered)]
cont = [v for v in source if lowered in v.lower()] if not pref else []
match = pref[0] if pref else (cont[0] if cont else None)
if match and match != typed:
combo.set(match)
# Move cursor to end and clear any selection
try:
combo.icursor('end')
try:
combo.selection_clear()
except Exception:
combo.selection_range(0, 0)
except Exception:
pass
# Update prev text
self._provider_prev_text = combo.get()
except Exception:
pass
return "break"
def _scroll_provider_list_to_value(self, value: str):
"""If the provider combobox dropdown is open, scroll to and highlight the given value"""
try:
values = getattr(self, '_provider_all_values', [])
if value not in values:
return
index = values.index(value)
# Resolve the internal popdown listbox for this combobox
popdown = self.openrouter_provider_combo.tk.eval(
f'ttk::combobox::PopdownWindow {self.openrouter_provider_combo._w}'
)
listbox = f'{popdown}.f.l'
tkobj = self.openrouter_provider_combo.tk
# Scroll and highlight the item
tkobj.call(listbox, 'see', index)
tkobj.call(listbox, 'selection', 'clear', 0, 'end')
tkobj.call(listbox, 'selection', 'set', index)
tkobj.call(listbox, 'activate', index)
except Exception:
pass # Dropdown may be closed or internals unavailable
def _validate_provider_selection(self):
"""Validate that the provider selection is from the list or default to Auto"""
try:
typed = self.openrouter_preferred_provider_var
source = getattr(self, '_provider_all_values', [])
if typed and typed not in source:
# Find closest match or default to Auto
lowered = typed.lower()
matches = [v for v in source if lowered in v.lower()]
if matches:
self.openrouter_preferred_provider_var = matches[0]
else:
self.openrouter_preferred_provider_var = 'Auto'
except Exception:
pass
def create_ai_hunter_section(self, parent_frame):
"""Create the AI Hunter configuration section (Qt version)"""
from PySide6.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout, QLabel, QPushButton
# Config row
config_w = QWidget()
config_h = QHBoxLayout(config_w)
config_h.setContentsMargins(20, 10, 0, 5)
# Status label
self.ai_hunter_status_label = QLabel(self._get_ai_hunter_status_text())
self.ai_hunter_status_label.setStyleSheet("font-size: 10pt;")
config_h.addWidget(self.ai_hunter_status_label)
config_h.addSpacing(10)
# Configure button
config_btn = QPushButton("βš™οΈ Configure AI Hunter")
config_btn.clicked.connect(lambda: self.show_ai_hunter_settings())
config_h.addWidget(config_btn)
config_h.addStretch()
# Add to parent layout (parent_frame should be a QVBoxLayout)
if hasattr(parent_frame, 'layout'):
# parent_frame is a QWidget, get its layout
layout = parent_frame.layout()
if layout:
layout.addWidget(config_w)
elif hasattr(parent_frame, 'addWidget'):
# parent_frame is a QLayout
parent_frame.addWidget(config_w)
# Info text
info_lbl = QLabel("AI Hunter uses multiple detection methods to identify duplicate content\nwith configurable thresholds and detection modes")
info_lbl.setStyleSheet("color: gray; font-size: 10pt;")
info_lbl.setContentsMargins(20, 0, 0, 10)
if hasattr(parent_frame, 'layout'):
# parent_frame is a QWidget, get its layout
layout = parent_frame.layout()
if layout:
layout.addWidget(info_lbl)
elif hasattr(parent_frame, 'addWidget'):
# parent_frame is a QLayout
parent_frame.addWidget(info_lbl)
def _get_ai_hunter_status_text(self):
"""Get status text for AI Hunter configuration"""
ai_config = self.config.get('ai_hunter_config', {})
# AI Hunter is shown when the detection mode is set to 'ai-hunter' or 'cascading'
if self.duplicate_detection_mode_var not in ['ai-hunter', 'cascading']:
return "AI Hunter: Not Selected"
if not ai_config.get('enabled', True):
return "AI Hunter: Disabled in Config"
mode_text = {
'single_method': 'Single Method',
'multi_method': 'Multi-Method',
'weighted_average': 'Weighted Average'
}
mode = mode_text.get(ai_config.get('detection_mode', 'multi_method'), 'Unknown')
thresholds = ai_config.get('thresholds', {})
if thresholds:
avg_threshold = sum(thresholds.values()) / len(thresholds)
else:
avg_threshold = 85
return f"AI Hunter: {mode} mode, Avg threshold: {int(avg_threshold)}%"
def show_ai_hunter_settings(self):
"""Open AI Hunter configuration window (PySide6)"""
try:
def on_config_saved():
# Save the entire configuration (without showing message box)
self.save_config(show_message=False)
# Update status label if it still exists
if hasattr(self, 'ai_hunter_status_label'):
try:
if not self.ai_hunter_status_label.isHidden():
self.ai_hunter_status_label.setText(self._get_ai_hunter_status_text())
except RuntimeError:
# Widget has been destroyed
pass
if hasattr(self, 'ai_hunter_enabled_var'):
self.ai_hunter_enabled_var = self.config.get('ai_hunter_config', {}).get('enabled', True)
# Sync main target language dropdown if it changed (two-way sync)
if 'output_language' in self.config and hasattr(self, 'update_target_language'):
# Call update_target_language with the new value
# This function handles the logic to update UI only if needed
self.update_target_language(self.config['output_language'])
# Store reference to prevent garbage collection
self._ai_hunter_gui = AIHunterConfigGUI(None, self.config, on_config_saved)
self._ai_hunter_gui.show_ai_hunter_config()
except Exception as e:
print(f"Error opening AI Hunter settings: {e}")
import traceback
traceback.print_exc()
from PySide6.QtWidgets import QMessageBox
from PySide6.QtGui import QIcon
msg_box = QMessageBox()
msg_box.setWindowTitle("Error")
msg_box.setText(f"Failed to open AI Hunter settings:\n{str(e)}")
msg_box.setIcon(QMessageBox.Critical)
try:
msg_box.setWindowIcon(QIcon("halgakos.ico"))
except Exception:
pass
_center_messagebox_buttons(msg_box)
msg_box.exec()
def configure_translation_chunk_prompt(self):
"""Configure the prompt template for translation chunks (PySide6)"""
from PySide6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTextEdit, QGroupBox, QWidget, QMessageBox
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon
dialog = QDialog(None)
dialog.setWindowTitle("Configure Chunk Prompt")
# Use screen ratios for sizing
from PySide6.QtWidgets import QApplication
screen = QApplication.primaryScreen().geometry()
width = int(screen.width() * 0.36) # 36% of screen width
height = int(screen.height() * 0.56) # 56% of screen height
dialog.resize(width, height)
# Set icon
try:
dialog.setWindowIcon(QIcon("halgakos.ico"))
except Exception:
pass
main_layout = QVBoxLayout(dialog)
main_layout.setContentsMargins(20, 20, 20, 20)
# Title
title = QLabel("Translation Chunk Prompt Template")
title.setStyleSheet("font-size: 14pt; font-weight: bold;")
main_layout.addWidget(title)
desc = QLabel("Configure how chunks are presented to the AI when chapters are split.")
desc.setStyleSheet("color: gray; font-size: 10pt;")
main_layout.addWidget(desc)
main_layout.addSpacing(10)
# Instructions
instructions_box = QGroupBox("Available Placeholders")
instructions_v = QVBoxLayout(instructions_box)
placeholders = [
("{chunk_idx}", "Current chunk number (1-based)"),
("{total_chunks}", "Total number of chunks"),
("{chunk_html}", "The actual HTML content to translate")
]
for placeholder, description in placeholders:
placeholder_lbl = QLabel(f"β€’ <b>{placeholder}:</b> {description}")
placeholder_lbl.setStyleSheet("font-family: Courier; font-size: 10pt;")
instructions_v.addWidget(placeholder_lbl)
main_layout.addWidget(instructions_box)
# Prompt input
prompt_box = QGroupBox("Chunk Prompt Template")
prompt_v = QVBoxLayout(prompt_box)
chunk_prompt_text = QTextEdit()
chunk_prompt_text.setAcceptRichText(False)
chunk_prompt_text.setPlainText(self.translation_chunk_prompt)
prompt_v.addWidget(chunk_prompt_text)
main_layout.addWidget(prompt_box)
# Example
example_box = QGroupBox("Example Output")
example_v = QVBoxLayout(example_box)
example_desc = QLabel("With chunk 2 of 5, the prompt would be:")
example_v.addWidget(example_desc)
example_label = QLabel()
example_label.setStyleSheet("color: #5a9fd4; font-family: Courier; font-size: 9pt; font-style: italic;")
example_label.setWordWrap(True)
example_v.addWidget(example_label)
def update_example():
try:
template = chunk_prompt_text.toPlainText()
example = template.replace('{chunk_idx}', '2').replace('{total_chunks}', '5').replace('{chunk_html}', '<p>Chapter content here...</p>')
display_text = example[:200] + "..." if len(example) > 200 else example
example_label.setText(display_text)
except Exception:
example_label.setText("[Invalid template]")
chunk_prompt_text.textChanged.connect(update_example)
update_example()
main_layout.addWidget(example_box)
# Buttons
button_layout = QHBoxLayout()
def save_chunk_prompt():
self.translation_chunk_prompt = chunk_prompt_text.toPlainText().strip()
self.config['translation_chunk_prompt'] = self.translation_chunk_prompt
QMessageBox.information(dialog, "Success", "Translation chunk prompt saved!")
dialog.close()
def reset_chunk_prompt():
result = QMessageBox.question(dialog, "Reset Prompt", "Reset to default chunk prompt?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if result == QMessageBox.Yes:
chunk_prompt_text.setPlainText(self.default_translation_chunk_prompt)
update_example()
save_btn = QPushButton("Save")
save_btn.clicked.connect(save_chunk_prompt)
button_layout.addWidget(save_btn)
reset_btn = QPushButton("Reset to Default")
reset_btn.clicked.connect(reset_chunk_prompt)
button_layout.addWidget(reset_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(dialog.close)
button_layout.addWidget(cancel_btn)
main_layout.addLayout(button_layout)
dialog.show()
def configure_image_chunk_prompt(self):
"""Configure the prompt template for image chunks (PySide6)"""
from PySide6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTextEdit, QGroupBox, QWidget, QMessageBox
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon
dialog = QDialog(None)
dialog.setWindowTitle("Configure Image Chunk Prompt")
# Use screen ratios for sizing
from PySide6.QtWidgets import QApplication
screen = QApplication.primaryScreen().geometry()
width = int(screen.width() * 0.36) # 36% of screen width
height = int(screen.height() * 0.56) # 56% of screen height
dialog.resize(width, height)
# Set icon
try:
dialog.setWindowIcon(QIcon("halgakos.ico"))
except Exception:
pass
main_layout = QVBoxLayout(dialog)
main_layout.setContentsMargins(20, 20, 20, 20)
# Title
title = QLabel("Image Chunk Context Template")
title.setStyleSheet("font-size: 14pt; font-weight: bold;")
main_layout.addWidget(title)
desc = QLabel("Configure the context provided when tall images are split into chunks.")
desc.setStyleSheet("color: gray; font-size: 10pt;")
main_layout.addWidget(desc)
main_layout.addSpacing(10)
# Instructions
instructions_box = QGroupBox("Available Placeholders")
instructions_v = QVBoxLayout(instructions_box)
placeholders = [
("{chunk_idx}", "Current chunk number (1-based)"),
("{total_chunks}", "Total number of chunks"),
("{context}", "Additional context (e.g., chapter info)")
]
for placeholder, description in placeholders:
placeholder_lbl = QLabel(f"β€’ <b>{placeholder}:</b> {description}")
placeholder_lbl.setStyleSheet("font-family: Courier; font-size: 10pt;")
instructions_v.addWidget(placeholder_lbl)
main_layout.addWidget(instructions_box)
# Prompt input
prompt_box = QGroupBox("Image Chunk Prompt Template")
prompt_v = QVBoxLayout(prompt_box)
image_chunk_prompt_text = QTextEdit()
image_chunk_prompt_text.setAcceptRichText(False)
image_chunk_prompt_text.setPlainText(self.image_chunk_prompt)
prompt_v.addWidget(image_chunk_prompt_text)
main_layout.addWidget(prompt_box)
# Example
example_box = QGroupBox("Example Output")
example_v = QVBoxLayout(example_box)
example_desc = QLabel("With chunk 3 of 7 and chapter context, the prompt would be:")
example_v.addWidget(example_desc)
example_label = QLabel()
example_label.setStyleSheet("color: #5a9fd4; font-family: Courier; font-size: 9pt; font-style: italic;")
example_label.setWordWrap(True)
example_v.addWidget(example_label)
def update_image_example():
try:
template = image_chunk_prompt_text.toPlainText()
example = template.replace('{chunk_idx}', '3').replace('{total_chunks}', '7').replace('{context}', 'Chapter 5: The Great Battle')
example_label.setText(example)
except Exception:
example_label.setText("[Invalid template]")
image_chunk_prompt_text.textChanged.connect(update_image_example)
update_image_example()
main_layout.addWidget(example_box)
# Buttons
button_layout = QHBoxLayout()
def save_image_chunk_prompt():
self.image_chunk_prompt = image_chunk_prompt_text.toPlainText().strip()
self.config['image_chunk_prompt'] = self.image_chunk_prompt
QMessageBox.information(dialog, "Success", "Image chunk prompt saved!")
dialog.close()
def reset_image_chunk_prompt():
result = QMessageBox.question(dialog, "Reset Prompt", "Reset to default image chunk prompt?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if result == QMessageBox.Yes:
image_chunk_prompt_text.setPlainText(self.default_image_chunk_prompt)
update_image_example()
save_btn = QPushButton("Save")
save_btn.clicked.connect(save_image_chunk_prompt)
button_layout.addWidget(save_btn)
reset_btn = QPushButton("Reset to Default")
reset_btn.clicked.connect(reset_image_chunk_prompt)
button_layout.addWidget(reset_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(dialog.close)
button_layout.addWidget(cancel_btn)
main_layout.addLayout(button_layout)
dialog.show()
def configure_image_compression(self):
"""Open the image compression configuration dialog (PySide6)"""
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QCheckBox, QLineEdit, QGroupBox, QRadioButton, QSlider,
QWidget, QScrollArea, QMessageBox, QFrame)
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon
dialog = QDialog(None)
dialog.setWindowTitle("Image Compression Settings")
# Use screen ratios for sizing
from PySide6.QtWidgets import QApplication
screen = QApplication.primaryScreen().geometry()
width = int(screen.width() * 0.34) # 34% of screen width
height = int(screen.height() * 0.65) # 65% of screen height
dialog.resize(width, height)
# Set icon
try:
dialog.setWindowIcon(QIcon("halgakos.ico"))
except Exception:
pass
# Apply global stylesheet for checkboxes and radio buttons
checkbox_radio_style = """
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;
}
QRadioButton {
color: white;
spacing: 5px;
}
QRadioButton::indicator {
width: 13px;
height: 13px;
border: 2px solid #5a9fd4;
border-radius: 7px;
background-color: #2d2d2d;
}
QRadioButton::indicator:checked {
background-color: #5a9fd4;
border: 2px solid #5a9fd4;
}
QRadioButton::indicator:hover {
border-color: #7bb3e0;
}
QRadioButton:disabled {
color: #666666;
}
QRadioButton::indicator:disabled {
background-color: #1a1a1a;
border-color: #3a3a3a;
}
"""
# Scrollable area
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll_widget = QWidget()
scroll_widget.setStyleSheet(checkbox_radio_style) # Apply stylesheet to scroll widget
main_layout = QVBoxLayout(scroll_widget)
main_layout.setContentsMargins(20, 20, 20, 20)
# Title
title = QLabel("πŸ—œοΈ Image Compression Settings")
title.setStyleSheet("font-size: 14pt; font-weight: bold;")
main_layout.addWidget(title)
main_layout.addSpacing(15)
# Enable compression toggle
enable_cb = self._create_styled_checkbox("Enable Image Compression")
enable_cb.setChecked(self.config.get('enable_image_compression', False))
main_layout.addWidget(enable_cb)
main_layout.addSpacing(20)
# Container for all compression options
compression_options = QWidget()
options_layout = QVBoxLayout(compression_options)
options_layout.setContentsMargins(0, 0, 0, 0)
# Auto Compression Section
auto_box = QGroupBox("Automatic Compression")
auto_v = QVBoxLayout(auto_box)
auto_compress_cb = self._create_styled_checkbox("Auto-compress to fit token limits")
auto_compress_cb.setChecked(self.config.get('auto_compress_enabled', True))
auto_v.addWidget(auto_compress_cb)
token_w = QWidget()
token_h = QHBoxLayout(token_w)
token_h.setContentsMargins(0, 10, 0, 0)
token_h.addWidget(QLabel("Target tokens per image:"))
target_tokens_edit = QLineEdit(str(self.config.get('target_image_tokens', 1000)))
target_tokens_edit.setFixedWidth(80)
token_h.addWidget(target_tokens_edit)
token_h.addWidget(QLabel("(Gemini uses ~258 tokens per image)"))
token_h.addStretch()
auto_v.addWidget(token_w)
options_layout.addWidget(auto_box)
# Format Selection
format_box = QGroupBox("Output Format")
format_v = QVBoxLayout(format_box)
format_group = QWidget()
format_buttons = []
formats = [
("Auto (Best quality/size ratio)", "auto"),
("WebP (Best compression)", "webp"),
("JPEG (Wide compatibility)", "jpeg"),
("PNG (Lossless)", "png")
]
current_format = self.config.get('image_compression_format', 'auto')
for text, value in formats:
rb = QRadioButton(text)
rb.setProperty("format_value", value)
if value == current_format:
rb.setChecked(True)
format_buttons.append(rb)
format_v.addWidget(rb)
options_layout.addWidget(format_box)
# Quality Settings
quality_box = QGroupBox("Quality Settings")
quality_v = QVBoxLayout(quality_box)
# WebP Quality
webp_w = QWidget()
webp_h = QHBoxLayout(webp_w)
webp_h.addWidget(QLabel("WebP Quality:"))
webp_slider = QSlider(Qt.Horizontal)
webp_slider.setMinimum(1)
webp_slider.setMaximum(100)
webp_slider.setValue(self.config.get('webp_quality', 85))
webp_slider.setFixedWidth(200)
webp_h.addWidget(webp_slider)
webp_label = QLabel(f"{webp_slider.value()}%")
webp_slider.valueChanged.connect(lambda v: webp_label.setText(f"{v}%"))
webp_h.addWidget(webp_label)
webp_h.addStretch()
quality_v.addWidget(webp_w)
# JPEG Quality
jpeg_w = QWidget()
jpeg_h = QHBoxLayout(jpeg_w)
jpeg_h.addWidget(QLabel("JPEG Quality:"))
jpeg_slider = QSlider(Qt.Horizontal)
jpeg_slider.setMinimum(1)
jpeg_slider.setMaximum(100)
jpeg_slider.setValue(self.config.get('jpeg_quality', 85))
jpeg_slider.setFixedWidth(200)
jpeg_h.addWidget(jpeg_slider)
jpeg_label = QLabel(f"{jpeg_slider.value()}%")
jpeg_slider.valueChanged.connect(lambda v: jpeg_label.setText(f"{v}%"))
jpeg_h.addWidget(jpeg_label)
jpeg_h.addStretch()
quality_v.addWidget(jpeg_w)
# PNG Compression
png_w = QWidget()
png_h = QHBoxLayout(png_w)
png_h.addWidget(QLabel("PNG Compression:"))
png_slider = QSlider(Qt.Horizontal)
png_slider.setMinimum(0)
png_slider.setMaximum(9)
png_slider.setValue(self.config.get('png_compression', 6))
png_slider.setFixedWidth(200)
png_h.addWidget(png_slider)
png_label = QLabel(f"Level {png_slider.value()}")
png_slider.valueChanged.connect(lambda v: png_label.setText(f"Level {v}"))
png_h.addWidget(png_label)
png_h.addStretch()
quality_v.addWidget(png_w)
options_layout.addWidget(quality_box)
# Resolution Limits
resolution_box = QGroupBox("Resolution Limits")
resolution_v = QVBoxLayout(resolution_box)
max_dim_w = QWidget()
max_dim_h = QHBoxLayout(max_dim_w)
max_dim_h.addWidget(QLabel("Max dimension (px):"))
max_dim_edit = QLineEdit(str(self.config.get('max_image_dimension', 2048)))
max_dim_edit.setFixedWidth(80)
max_dim_h.addWidget(max_dim_edit)
max_dim_h.addWidget(QLabel("(Images larger than this will be resized)"))
max_dim_h.addStretch()
resolution_v.addWidget(max_dim_w)
max_size_w = QWidget()
max_size_h = QHBoxLayout(max_size_w)
max_size_h.addWidget(QLabel("Max file size (MB):"))
max_size_edit = QLineEdit(str(self.config.get('max_image_size_mb', 10)))
max_size_edit.setFixedWidth(80)
max_size_h.addWidget(max_size_edit)
max_size_h.addWidget(QLabel("(Larger files will be compressed)"))
max_size_h.addStretch()
resolution_v.addWidget(max_size_w)
options_layout.addWidget(resolution_box)
# Advanced Options
advanced_box = QGroupBox("Advanced Options")
advanced_v = QVBoxLayout(advanced_box)
preserve_transparency_cb = self._create_styled_checkbox("Preserve transparency (PNG/WebP only)")
preserve_transparency_cb.setChecked(self.config.get('preserve_transparency', False))
advanced_v.addWidget(preserve_transparency_cb)
preserve_format_cb = self._create_styled_checkbox("Preserve original image format")
preserve_format_cb.setChecked(self.config.get('preserve_original_format', False))
advanced_v.addWidget(preserve_format_cb)
optimize_ocr_cb = self._create_styled_checkbox("Optimize for OCR (maintain text clarity)")
optimize_ocr_cb.setChecked(self.config.get('optimize_for_ocr', True))
advanced_v.addWidget(optimize_ocr_cb)
progressive_cb = self._create_styled_checkbox("Progressive encoding (JPEG)")
progressive_cb.setChecked(self.config.get('progressive_encoding', True))
advanced_v.addWidget(progressive_cb)
save_compressed_cb = self._create_styled_checkbox("Save compressed images to disk")
save_compressed_cb.setChecked(self.config.get('save_compressed_images', False))
advanced_v.addWidget(save_compressed_cb)
options_layout.addWidget(advanced_box)
# Info
info = QLabel("πŸ’‘ Tips:\nβ€’ WebP offers the best compression with good quality\nβ€’ Use 'Auto' format for intelligent format selection\nβ€’ Higher quality = larger file size\nβ€’ OCR optimization maintains text readability")
info.setStyleSheet("color: #666; font-size: 9pt;")
options_layout.addWidget(info)
main_layout.addWidget(compression_options)
# Toggle function
def toggle_options():
enabled = enable_cb.isChecked()
compression_options.setEnabled(enabled)
enable_cb.toggled.connect(toggle_options)
toggle_options()
scroll.setWidget(scroll_widget)
# Main dialog layout
dialog_layout = QVBoxLayout(dialog)
dialog_layout.addWidget(scroll)
# Buttons
button_layout = QHBoxLayout()
def save_compression_settings():
try:
# Validate
int(target_tokens_edit.text())
int(max_dim_edit.text())
float(max_size_edit.text())
except ValueError:
QMessageBox.critical(dialog, "Invalid Input", "Please enter valid numbers for numeric fields")
return
# Get selected format
selected_format = 'auto'
for rb in format_buttons:
if rb.isChecked():
selected_format = rb.property("format_value")
break
# Save all settings
self.config['enable_image_compression'] = enable_cb.isChecked()
self.config['auto_compress_enabled'] = auto_compress_cb.isChecked()
self.config['target_image_tokens'] = int(target_tokens_edit.text())
self.config['image_compression_format'] = selected_format
self.config['webp_quality'] = webp_slider.value()
self.config['jpeg_quality'] = jpeg_slider.value()
self.config['png_compression'] = png_slider.value()
self.config['max_image_dimension'] = int(max_dim_edit.text())
self.config['max_image_size_mb'] = float(max_size_edit.text())
self.config['preserve_transparency'] = preserve_transparency_cb.isChecked()
self.config['preserve_original_format'] = preserve_format_cb.isChecked()
self.config['optimize_for_ocr'] = optimize_ocr_cb.isChecked()
self.config['progressive_encoding'] = progressive_cb.isChecked()
self.config['save_compressed_images'] = save_compressed_cb.isChecked()
self.append_log("βœ… Image compression settings saved")
dialog.close()
save_btn = QPushButton("πŸ’Ύ Save Settings")
save_btn.clicked.connect(save_compression_settings)
save_btn.setMinimumHeight(35)
save_btn.setStyleSheet(
"QPushButton { "
" background-color: #28a745; "
" color: white; "
" padding: 8px 20px; "
" font-size: 11pt; "
" font-weight: bold; "
" border-radius: 4px; "
"} "
"QPushButton:hover { background-color: #218838; }"
)
button_layout.addWidget(save_btn)
cancel_btn = QPushButton("❌ Cancel")
cancel_btn.clicked.connect(dialog.close)
cancel_btn.setMinimumHeight(35)
cancel_btn.setStyleSheet(
"QPushButton { "
" background-color: #6c757d; "
" color: white; "
" padding: 8px 20px; "
" font-size: 11pt; "
" font-weight: bold; "
" border-radius: 4px; "
"} "
"QPushButton:hover { background-color: #5a6268; }"
)
button_layout.addWidget(cancel_btn)
dialog_layout.addLayout(button_layout)
dialog.show()
def toggle_ai_hunter(self):
"""Toggle AI Hunter enabled state"""
if 'ai_hunter_config' not in self.config:
self.config['ai_hunter_config'] = {}
self.config['ai_hunter_config']['enabled'] = self.ai_hunter_enabled_var
self.save_config()
# Note: ai_hunter_status_label is QLabel in PySide6, use setText instead of config
if hasattr(self.ai_hunter_status_label, 'setText'):
self.ai_hunter_status_label.setText(self._get_ai_hunter_status_text())
def _create_prompt_management_section(self, parent):
"""Create meta data section (formerly prompt management) - PySide6"""
from PySide6.QtWidgets import QGroupBox, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox, QPushButton, QWidget, QLineEdit
from PySide6.QtCore import Qt
section_box = QGroupBox("Meta Data")
# No max width - let it expand in fullscreen
section_v = QVBoxLayout(section_box)
section_v.setContentsMargins(8, 8, 8, 8) # Compact margins
section_v.setSpacing(4) # Compact spacing between widgets
# Title frame with checkbox and buttons
title_w = QWidget()
title_h = QHBoxLayout(title_w)
title_h.setContentsMargins(0, 0, 0, 0)
translate_title_cb = self._create_styled_checkbox("Translate Book Title")
try:
translate_title_cb.setChecked(bool(self.translate_book_title_var))
except Exception:
pass
# Toggle: include book title in glossary header/output
glossary_title_cb = self._create_styled_checkbox("Include book title at top of glossary (during generation)")
try:
# Default to False if not present in config
if not hasattr(self, 'include_book_title_glossary_var'):
self.include_book_title_glossary_var = False
glossary_title_cb.setChecked(bool(self.include_book_title_glossary_var))
except Exception:
glossary_title_cb.setChecked(False)
glossary_title_cb.setToolTip(
"Adds the book title row while generating the glossary (before deduplication).\n"
"Uses translated metadata if available; skipped if metadata is missing."
)
# Toggle: auto-inject book title before dedup
auto_inject_title_cb = self._create_styled_checkbox("Auto-inject book title (loaded glossaries only, bypasses dedup)")
try:
if not hasattr(self, 'auto_inject_book_title_var'):
self.auto_inject_book_title_var = self.config.get('auto_inject_book_title', False)
auto_inject_title_cb.setChecked(bool(self.auto_inject_book_title_var))
except Exception:
auto_inject_title_cb.setChecked(False)
auto_inject_title_cb.setToolTip(
"When loading an existing glossary file, inject the book title row after load\n"
"(not part of dedup). Use only if your saved glossary lacks the title."
)
def _update_glossary_title_state(checked):
"""Update enabled state and styling of glossary toggles"""
try:
glossary_title_cb.setEnabled(checked)
auto_inject_title_cb.setEnabled(checked)
if checked:
# Force white color for enabled state to ensure visibility
glossary_title_cb.setStyleSheet("QCheckBox { color: white; }")
auto_inject_title_cb.setStyleSheet("QCheckBox { color: white; }")
else:
# Disabled styling (grayed out) - do NOT change checked state
glossary_title_cb.setStyleSheet("QCheckBox { color: #666666; }")
auto_inject_title_cb.setStyleSheet("QCheckBox { color: #666666; }")
except Exception:
pass
def _on_translate_title_toggle(checked):
try:
self.translate_book_title_var = bool(checked)
_update_glossary_title_state(checked)
except Exception:
pass
translate_title_cb.setToolTip(
"Translate the book title and selected metadata fields\n"
"using your current model/profile."
)
translate_title_cb.toggled.connect(_on_translate_title_toggle)
title_h.addWidget(translate_title_cb)
title_h.addSpacing(10)
btn_configure_all = QPushButton("Configure All")
btn_configure_all.setFixedWidth(120)
btn_configure_all.clicked.connect(lambda: self.metadata_batch_ui.configure_translation_prompts())
title_h.addWidget(btn_configure_all)
title_h.addSpacing(5)
btn_custom_metadata = QPushButton("Custom Metadata")
btn_custom_metadata.setFixedWidth(150)
btn_custom_metadata.clicked.connect(lambda: self.metadata_batch_ui.configure_metadata_fields())
title_h.addWidget(btn_custom_metadata)
title_h.addStretch()
section_v.addWidget(title_w)
title_desc = QLabel("When enabled: Book titles and selected metadata will be translated")
title_desc.setStyleSheet("color: gray; font-size: 9pt;")
title_desc.setContentsMargins(20, 0, 0, 8)
section_v.addWidget(title_desc)
def _on_glossary_title_toggle(checked):
try:
self.include_book_title_glossary_var = bool(checked)
# Persist immediately in config so save_config captures it
if hasattr(self, 'config'):
self.config['include_book_title_glossary'] = bool(checked)
except Exception:
pass
glossary_title_cb.toggled.connect(_on_glossary_title_toggle)
section_v.addWidget(glossary_title_cb)
def _on_auto_inject_toggle(checked):
try:
self.auto_inject_book_title_var = bool(checked)
if hasattr(self, 'config'):
self.config['auto_inject_book_title'] = bool(checked)
except Exception:
pass
auto_inject_title_cb.toggled.connect(_on_auto_inject_toggle)
section_v.addWidget(auto_inject_title_cb)
# Initialize state based on current value
_update_glossary_title_state(translate_title_cb.isChecked())
# Separator
sep1 = QFrame()
sep1.setFrameShape(QFrame.HLine)
sep1.setFrameShadow(QFrame.Sunken)
section_v.addWidget(sep1)
# ── Table of Contents Translation ────────────────────────────
toc_struct_title = QLabel("Table of Contents Translation:")
toc_struct_title.setStyleSheet("font-weight: bold; font-size: 11pt;")
section_v.addWidget(toc_struct_title)
def _on_skip_dup_toc_toggle(checked):
try:
self.skip_duplicate_toc_translation_var = bool(checked)
self.config['skip_duplicate_toc_translation'] = self.skip_duplicate_toc_translation_var
os.environ['SKIP_DUPLICATE_TOC_TRANSLATION'] = '1' if checked else '0'
except Exception:
pass
# Use source toc.ncx
if not hasattr(self, 'use_toc_ncx_var'):
self.use_toc_ncx_var = self.config.get('use_toc_ncx', False)
use_toc_cb = self._create_styled_checkbox("Use toc.ncx")
use_toc_cb.setToolTip(
"Use the toc.ncx from the source EPUB to build the Table of Contents.\n"
"This can preserve the original TOC structure and anchor links."
)
try:
use_toc_cb.setChecked(bool(self.use_toc_ncx_var))
except Exception:
pass
# Translate source toc.ncx (single API call)
if not hasattr(self, 'translate_toc_ncx_var'):
self.translate_toc_ncx_var = self.config.get('translate_toc_ncx', False)
# TOC batch limit definitions (moved up for toggle logic)
toc_batch_label = QLabel("TOC entries per batch:")
toc_batch_entry = QLineEdit()
toc_batch_entry.setFixedWidth(60)
toc_batch_entry.setText(str(getattr(self, 'toc_ncx_per_batch_var', '-1')))
def _on_toc_batch_changed(text):
stripped = text.strip().lstrip('-')
if stripped.isdigit() or text.strip() in ('-', '-1', '0'):
self.toc_ncx_per_batch_var = text.strip()
toc_batch_entry.textChanged.connect(_on_toc_batch_changed)
translate_toc_cb = self._create_styled_checkbox("Translate toc.ncx")
translate_toc_cb.setToolTip(
"Translate ALL toc.ncx entries in ONE API call and save a TOC.txt cache file\n"
"in the same robust format as translated_headers.txt (Original/Translated blocks)."
)
try:
translate_toc_cb.setChecked(bool(self.translate_toc_ncx_var))
except Exception:
pass
# Skip duplicate translations (only when translating toc.ncx)
if not hasattr(self, 'skip_duplicate_toc_translation_var'):
self.skip_duplicate_toc_translation_var = self.config.get('skip_duplicate_toc_translation', False)
skip_dup_toc_cb = self._create_styled_checkbox("Skip duplicate translation")
skip_dup_toc_cb.setToolTip(
"When Translate toc.ncx is enabled, identical source labels are translated once and\n"
"duplicates reuse the first translation. Saves tokens and API time."
)
try:
skip_dup_toc_cb.setChecked(bool(self.skip_duplicate_toc_translation_var))
except Exception:
pass
# Enable/disable translate checkbox based on master
translate_toc_cb.setEnabled(use_toc_cb.isChecked())
skip_dup_toc_cb.setEnabled(translate_toc_cb.isChecked())
toc_batch_label.setEnabled(translate_toc_cb.isChecked())
toc_batch_entry.setEnabled(translate_toc_cb.isChecked())
def _on_use_toc_ncx_toggle(checked):
try:
self.use_toc_ncx_var = bool(checked)
self.config['use_toc_ncx'] = self.use_toc_ncx_var
os.environ['USE_TOC_NCX'] = '1' if checked else '0'
translate_toc_cb.setEnabled(bool(checked))
if checked:
enabled = translate_toc_cb.isChecked()
skip_dup_toc_cb.setEnabled(enabled)
toc_batch_label.setEnabled(enabled)
toc_batch_entry.setEnabled(enabled)
else:
skip_dup_toc_cb.setEnabled(False)
toc_batch_label.setEnabled(False)
toc_batch_entry.setEnabled(False)
# Deduplicate TOC and its child also depend on use_toc_ncx
dedup_toc_cb.setEnabled(bool(checked))
if checked:
dedup_toc_translated_cb.setEnabled(dedup_toc_cb.isChecked())
else:
dedup_toc_translated_cb.setEnabled(False)
except Exception:
pass
def _on_translate_toc_ncx_toggle(checked):
try:
if checked and not use_toc_cb.isChecked():
use_toc_cb.setChecked(True)
self.translate_toc_ncx_var = bool(checked)
self.config['translate_toc_ncx'] = self.translate_toc_ncx_var
os.environ['TRANSLATE_TOC_NCX'] = '1' if checked else '0'
skip_dup_toc_cb.setEnabled(bool(checked))
toc_batch_label.setEnabled(bool(checked))
toc_batch_entry.setEnabled(bool(checked))
except Exception:
pass
use_toc_cb.toggled.connect(_on_use_toc_ncx_toggle)
translate_toc_cb.toggled.connect(_on_translate_toc_ncx_toggle)
skip_dup_toc_cb.toggled.connect(_on_skip_dup_toc_toggle)
# ── 2-column layout ──────────────────────────────────────────
_toc_cols_w = QWidget()
_toc_cols_h = QHBoxLayout(_toc_cols_w)
_toc_cols_h.setContentsMargins(0, 0, 0, 0)
_toc_cols_h.setSpacing(8)
_toc_col_left = QWidget()
_toc_col_left_v = QVBoxLayout(_toc_col_left)
_toc_col_left_v.setContentsMargins(0, 0, 0, 0)
_toc_col_left_v.setSpacing(4)
_toc_col_right = QWidget()
_toc_col_right_v = QVBoxLayout(_toc_col_right)
_toc_col_right_v.setContentsMargins(0, 0, 0, 0)
_toc_col_right_v.setSpacing(4)
_toc_cols_h.addWidget(_toc_col_left)
_toc_cols_h.addWidget(_toc_col_right)
section_v.addWidget(_toc_cols_w)
# Left column: NCX toggles
_toc_col_left_v.addWidget(use_toc_cb)
_toc_col_left_v.addWidget(translate_toc_cb)
skip_dup_toc_cb.setContentsMargins(20, 0, 0, 0)
_toc_col_left_v.addWidget(skip_dup_toc_cb)
_toc_col_left_v.addStretch()
# Right column: Dedup + fallback
if not hasattr(self, 'deduplicate_toc_var'):
self.deduplicate_toc_var = self.config.get('deduplicate_toc', False)
dedup_toc_cb = self._create_styled_checkbox("Deduplicate TOC")
dedup_toc_cb.setToolTip(
"Skip duplicate TOC entries with the exact same title. Example: if 10 entries are named\n"
"\"Chapter 1: Peanuts\", only the first one is kept. No fuzzy matching."
)
try:
dedup_toc_cb.setChecked(bool(self.deduplicate_toc_var))
except Exception:
pass
def _on_dedup_toc_toggle(checked):
try:
self.deduplicate_toc_var = bool(checked)
self.config['deduplicate_toc'] = self.deduplicate_toc_var
os.environ['DEDUPLICATE_TOC'] = '1' if checked else '0'
except Exception:
pass
dedup_toc_cb.toggled.connect(_on_dedup_toc_toggle)
dedup_toc_cb.setEnabled(use_toc_cb.isChecked())
_toc_col_right_v.addWidget(dedup_toc_cb)
if not hasattr(self, 'deduplicate_toc_use_translated_var'):
self.deduplicate_toc_use_translated_var = self.config.get('deduplicate_toc_use_translated', False)
dedup_toc_translated_cb = self._create_styled_checkbox("Deduplicate using translated titles")
dedup_toc_translated_cb.setToolTip(
"When Deduplicate TOC is enabled, use translated titles for exact-match deduplication\n"
"instead of the raw source entries."
)
try:
dedup_toc_translated_cb.setChecked(bool(self.deduplicate_toc_use_translated_var))
except Exception:
pass
def _on_dedup_toc_translated_toggle(checked):
try:
self.deduplicate_toc_use_translated_var = bool(checked)
self.config['deduplicate_toc_use_translated'] = self.deduplicate_toc_use_translated_var
os.environ['DEDUPLICATE_TOC_USE_TRANSLATED'] = '1' if checked else '0'
except Exception:
pass
dedup_toc_translated_cb.toggled.connect(_on_dedup_toc_translated_toggle)
dedup_toc_translated_cb.setContentsMargins(20, 0, 0, 0)
dedup_toc_translated_cb.setEnabled(dedup_toc_cb.isChecked() and use_toc_cb.isChecked())
_toc_col_right_v.addWidget(dedup_toc_translated_cb)
def _sync_dedup_child(master_checked):
dedup_toc_translated_cb.setEnabled(bool(master_checked))
dedup_toc_cb.toggled.connect(_sync_dedup_child)
if not hasattr(self, 'use_p_tag_toc_fallback_var'):
self.use_p_tag_toc_fallback_var = self.config.get('use_p_tag_toc_fallback', False)
p_fallback_cb = self._create_styled_checkbox("Use TOC fallback titles")
p_fallback_cb.setToolTip(
"When enabled, the TOC may fall back to the first <p> tag and generic Chapter N titles\n"
"if no header tags are present. Disable to avoid any fallback titles."
)
try:
p_fallback_cb.setChecked(bool(self.use_p_tag_toc_fallback_var))
except Exception:
pass
def _on_p_tag_toc_fallback_toggle(checked):
try:
self.use_p_tag_toc_fallback_var = bool(checked)
self.config['use_p_tag_toc_fallback'] = self.use_p_tag_toc_fallback_var
os.environ['USE_P_TAG_TOC_FALLBACK'] = '1' if checked else '0'
except Exception:
pass
p_fallback_cb.toggled.connect(_on_p_tag_toc_fallback_toggle)
_toc_col_right_v.addWidget(p_fallback_cb)
_toc_col_right_v.addStretch()
# Delete TOC files button
delete_toc_btn = QPushButton("πŸ—‘οΈDelete TOC Files")
delete_toc_btn.setFixedWidth(210)
delete_toc_btn.clicked.connect(lambda: self.delete_toc_txt_file())
delete_toc_btn.setStyleSheet(
"QPushButton { background-color: #6c757d; color: white; padding: 5px 10px; border-radius: 3px; font-weight: bold; } "
"QPushButton:hover { background-color: #dc3545; } "
"QPushButton:disabled { background-color: #e0e0e0; color: #9e9e9e; }"
)
_toc_icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Halgakos.ico")
if os.path.exists(_toc_icon_path):
from PySide6.QtGui import QIcon as _QIcon
delete_toc_btn.setIcon(_QIcon(_toc_icon_path))
# Delete TOC files button + TOC batch limit (Horizontal layout)
_toc_btn_row = QWidget()
_toc_btn_h = QHBoxLayout(_toc_btn_row)
_toc_btn_h.setContentsMargins(0, 0, 0, 0)
_toc_btn_h.addWidget(delete_toc_btn)
_toc_btn_h.addSpacing(20)
_toc_btn_h.addWidget(toc_batch_label)
_toc_btn_h.addWidget(toc_batch_entry)
_toc_btn_h.addStretch()
section_v.addWidget(_toc_btn_row)
# Separator
sep_toc_struct = QFrame()
sep_toc_struct.setFrameShape(QFrame.HLine)
sep_toc_struct.setFrameShadow(QFrame.Sunken)
sep_toc_struct.setContentsMargins(0, 0, 0, 15)
section_v.addWidget(sep_toc_struct)
# Batch Header Translation Section
header_title = QLabel("Chapter Header Translation:")
header_title.setStyleSheet("font-weight: bold; font-size: 11pt;")
section_v.addWidget(header_title)
header_row1 = QWidget()
header_h1 = QHBoxLayout(header_row1)
header_h1.setContentsMargins(0, 0, 0, 0)
# Master toggle for batch header translation
batch_toggle_cb = self._create_styled_checkbox("Batch Translate Headers")
try:
batch_toggle_cb.setChecked(bool(self.batch_translate_headers_var))
except Exception:
pass
batch_toggle_cb.setToolTip(
"Translate chapter headers in batches instead of per file.\n"
"Uses the settings below for batching and output."
)
header_h1.addWidget(batch_toggle_cb)
header_h1.addSpacing(20)
headers_per_batch_label = QLabel("Headers per batch:")
header_h1.addWidget(headers_per_batch_label)
batch_entry = QLineEdit()
batch_entry.setFixedWidth(100)
try:
batch_entry.setText(str(self.headers_per_batch_var))
except Exception:
pass
def _on_headers_per_batch_changed(text):
try:
stripped = text.strip().lstrip('-')
if stripped.isdigit() or text.strip() in ('-', '-1', '0'):
self.headers_per_batch_var = text.strip()
except Exception:
pass
batch_entry.textChanged.connect(_on_headers_per_batch_changed)
header_h1.addWidget(batch_entry)
# Add help button next to batch entry
header_h1.addSpacing(5)
help_btn = QPushButton("ℹ️")
help_btn.setFixedSize(28, 28)
help_btn.clicked.connect(lambda: self.show_header_help_dialog())
help_btn.setStyleSheet(
"QPushButton { "
"background-color: transparent; "
"border: none; "
"font-size: 18px; "
"padding: 0px; "
"} "
"QPushButton:hover { "
"background-color: rgba(23, 162, 184, 0.2); "
"border-radius: 14px; "
"} "
"QPushButton:pressed { "
"background-color: rgba(23, 162, 184, 0.4); "
"border-radius: 14px; "
"}"
)
help_btn.setToolTip("Show detailed help for header translation options")
header_h1.addWidget(help_btn)
header_h1.addStretch()
section_v.addWidget(header_row1)
# Options for header translation
update_row = QWidget()
update_h = QHBoxLayout(update_row)
update_h.setContentsMargins(20, 0, 0, 0)
update_cb = self._create_styled_checkbox("Update headers in HTML files")
try:
update_cb.setChecked(bool(self.update_html_headers_var))
except Exception:
pass
def _on_update_html_toggle(checked):
try:
self.update_html_headers_var = bool(checked)
except Exception:
pass
update_cb.toggled.connect(_on_update_html_toggle)
update_cb.setToolTip(
"Write translated headers back into the HTML chapters.\n"
"Disable if you only want preview/exports."
)
update_h.addWidget(update_cb)
update_h.addSpacing(20)
save_cb = self._create_styled_checkbox("Save translations to .txt")
try:
save_cb.setChecked(bool(self.save_header_translations_var))
except Exception:
pass
def _on_save_translations_toggle(checked):
try:
self.save_header_translations_var = bool(checked)
except Exception:
pass
save_cb.toggled.connect(_on_save_translations_toggle)
save_cb.setToolTip(
"Export header translation mappings to translated_headers.txt\n"
"for auditing or reuse."
)
update_h.addWidget(save_cb)
update_h.addStretch()
section_v.addWidget(update_row)
# Additional ignore header options
ignore_row = QWidget()
ignore_h = QHBoxLayout(ignore_row)
ignore_h.setContentsMargins(20, 5, 0, 0)
ignore_header_cb = self._create_styled_checkbox("Ignore header")
try:
ignore_header_cb.setChecked(bool(self.ignore_header_var))
except Exception:
pass
def _on_ignore_header_toggle(checked):
try:
self.ignore_header_var = bool(checked)
except Exception:
pass
ignore_header_cb.toggled.connect(_on_ignore_header_toggle)
ignore_header_cb.setToolTip(
"Skip translating visible header tags (h1/h2/h3) inside chapters.\n"
"Useful if headers are already translated."
)
ignore_h.addWidget(ignore_header_cb)
ignore_h.addSpacing(15)
use_title_cb = self._create_styled_checkbox("Use title")
try:
use_title_cb.setChecked(bool(self.use_title_var))
except Exception:
pass
def _on_use_title_toggle(checked):
try:
self.use_title_var = bool(checked)
except Exception:
pass
use_title_cb.toggled.connect(_on_use_title_toggle)
use_title_cb.setToolTip(
"Include the title tag in translation.\n"
"Translates document titles in the HTML head."
)
ignore_h.addWidget(use_title_cb)
ignore_h.addStretch()
section_v.addWidget(ignore_row)
# Second ignore row for additional options
ignore_row2 = QWidget()
ignore_h2 = QHBoxLayout(ignore_row2)
ignore_h2.setContentsMargins(20, 5, 0, 0)
# Remove duplicate H1+P pairs
if not hasattr(self, 'remove_duplicate_h1_p_var'):
self.remove_duplicate_h1_p_var = self.config.get('remove_duplicate_h1_p', False)
remove_dup_cb = self._create_styled_checkbox("Remove duplicate H1+P pairs")
try:
remove_dup_cb.setChecked(bool(self.remove_duplicate_h1_p_var))
except Exception:
pass
def _on_remove_dup_toggle(checked):
try:
self.remove_duplicate_h1_p_var = bool(checked)
self.config['remove_duplicate_h1_p'] = bool(checked)
except Exception:
pass
remove_dup_cb.toggled.connect(_on_remove_dup_toggle)
remove_dup_cb.setToolTip(
"Remove paragraph tags that immediately follow H1 tags with identical text.\n"
"Useful for novels that repeat chapter titles."
)
ignore_h2.addWidget(remove_dup_cb)
ignore_h2.addSpacing(15)
# Add fallback option with warning icon
use_fallback_cb = self._create_styled_checkbox("⚠️ Use Sorted Fallback")
try:
use_fallback_cb.setChecked(bool(getattr(self, 'use_sorted_fallback_var', False)))
except Exception:
pass
def _on_use_fallback_toggle(checked):
try:
self.use_sorted_fallback_var = bool(checked)
except Exception:
pass
use_fallback_cb.toggled.connect(_on_use_fallback_toggle)
use_fallback_cb.setToolTip(
"If standalone OPF-based matching fails, fall back to sorted index matching.\n"
"⚠️ Less accurate - may mismatch chapters if file order differs from OPF spine."
)
ignore_h2.addWidget(use_fallback_cb)
ignore_h2.addStretch()
section_v.addWidget(ignore_row2)
# Buttons row (below ignore options)
buttons_row = QWidget()
buttons_h = QHBoxLayout(buttons_row)
buttons_h.setContentsMargins(20, 5, 0, 0)
translate_now_btn = QPushButton("Translate Headers Now")
translate_now_btn.setFixedWidth(210)
# Store reference for button transformation
self.translate_headers_btn = translate_now_btn
# Create a rotatable label for the icon
from PySide6.QtCore import Property, QPropertyAnimation, QEasingCurve
from PySide6.QtGui import QTransform
# Re-use the global RotatableLabel class
# (Removed local definition)
# Create icon with rotation support (HiDPI-aware, smaller than 36x36)
icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Halgakos.ico")
translate_icon_label = RotatableLabel()
if os.path.exists(icon_path):
try:
from PySide6.QtGui import QIcon, QPixmap
from PySide6.QtCore import QSize
try:
dpr = self.devicePixelRatioF()
except Exception:
dpr = 1.0
logical_px = 12 # smaller than toolbar icons
dev_px = int(logical_px * max(1.0, dpr))
icon = QIcon(icon_path)
pm = icon.pixmap(QSize(dev_px, dev_px))
if pm.isNull():
raw = QPixmap(icon_path)
img = raw.toImage().scaled(dev_px, dev_px, Qt.KeepAspectRatio, Qt.SmoothTransformation)
pm = QPixmap.fromImage(img)
try:
pm.setDevicePixelRatio(dpr)
except Exception:
pass
translate_icon_label.set_original_pixmap(pm)
except Exception:
pixmap = QPixmap(icon_path)
scaled_pixmap = pixmap.scaled(16, 16, Qt.KeepAspectRatio, Qt.SmoothTransformation)
translate_icon_label.set_original_pixmap(scaled_pixmap)
self.translate_headers_icon = translate_icon_label
# Create rotation animations
self.translate_icon_spin_animation = QPropertyAnimation(translate_icon_label, b"rotation")
self.translate_icon_spin_animation.setDuration(900) # 0.9 seconds per rotation
self.translate_icon_spin_animation.setStartValue(0)
self.translate_icon_spin_animation.setEndValue(360)
self.translate_icon_spin_animation.setLoopCount(-1) # Infinite loop
self.translate_icon_spin_animation.setEasingCurve(QEasingCurve.Linear)
# Create smooth stop animation
self.translate_icon_stop_animation = QPropertyAnimation(translate_icon_label, b"rotation")
self.translate_icon_stop_animation.setDuration(800) # Deceleration time
self.translate_icon_stop_animation.setEasingCurve(QEasingCurve.OutCubic)
def _on_translate_toggle():
from PySide6.QtWidgets import QMessageBox
from PySide6.QtGui import QIcon
# Check if currently running
is_running = getattr(self, '_headers_translation_running', False)
if is_running:
# Stop the translation
try:
# Set local stop flag
self._headers_stop_requested = True
# Set stop flags on BatchHeaderTranslator if it exists
if hasattr(self, '_batch_header_translator'):
self._batch_header_translator.set_stop_flag(True)
self.append_log("βœ… Stop signal sent to batch header translator")
# Set stop flags on unified_api_client (same as main translator GUI)
try:
import unified_api_client
if hasattr(unified_api_client, 'set_stop_flag'):
unified_api_client.set_stop_flag(True)
# If there's a global client instance, stop it too
if hasattr(unified_api_client, 'global_stop_flag'):
unified_api_client.global_stop_flag = True
# Set the _cancelled flag on the UnifiedClient class itself
if hasattr(unified_api_client, 'UnifiedClient'):
unified_api_client.UnifiedClient._global_cancelled = True
self.append_log("βœ… Stop signal sent to API client")
except Exception as e:
self.append_log(f"⚠️ Could not set API client stop flags: {e}")
# Also try to stop the API client instance if it exists
if hasattr(self, 'api_client') and self.api_client:
try:
if hasattr(self.api_client, 'set_stop_flag'):
self.api_client.set_stop_flag(True)
if hasattr(self.api_client, '_cancelled'):
self.api_client._cancelled = True
except Exception:
pass
# Update button to "Stopping..." state (gray)
translate_now_btn.setText("⏹ Stopping...")
translate_now_btn.setStyleSheet(
"QPushButton { background-color: #9e9e9e; color: white; padding: 5px 10px; border-radius: 3px; font-weight: bold; } "
"QPushButton:hover { background-color: #9e9e9e; } "
"QPushButton:disabled { background-color: #e0e0e0; color: #9e9e9e; }"
)
self.append_log("πŸ›‘ Stop requested β€” waiting for current operation to finish")
except Exception as e:
self.append_log(f"❌ Error stopping: {e}")
else:
# Start the translation
# Get icon
icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Halgakos.ico")
icon = QIcon(icon_path) if os.path.exists(icon_path) else QIcon()
# Show confirmation dialog
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Question)
msg_box.setWindowTitle("Translate Headers")
msg_box.setText("Start standalone header translation?")
msg_box.setInformativeText(
"This will translate chapter headers using content.opf-based exact matching."
)
msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
msg_box.setDefaultButton(QMessageBox.Yes)
msg_box.setWindowIcon(icon)
# Center the buttons
from PySide6.QtWidgets import QDialogButtonBox
button_box = msg_box.findChild(QDialogButtonBox)
if button_box:
button_box.setCenterButtons(True)
result = msg_box.exec()
if result == QMessageBox.Yes:
# Don't close the dialog - keep it open so user can see spinning button and stop if needed
# The button will transform to show stop state
# Run translation in background thread
self.run_standalone_translate_headers()
translate_now_btn.clicked.connect(_on_translate_toggle)
translate_now_btn.setStyleSheet(
"QPushButton { background-color: #6c757d; color: white; padding: 5px 10px; border-radius: 3px; font-weight: bold; } "
"QPushButton:hover { background-color: #28a745; } "
"QPushButton:disabled { background-color: #e0e0e0; color: #9e9e9e; }"
)
# Set icon from the rotatable label
if translate_icon_label._original_pixmap:
translate_now_btn.setIcon(QIcon(translate_icon_label._original_pixmap))
buttons_h.addWidget(translate_now_btn)
buttons_h.addSpacing(10)
delete_btn = QPushButton("πŸ—‘οΈDelete Header Files")
delete_btn.setFixedWidth(210)
delete_btn.clicked.connect(lambda: self.delete_translated_headers_file())
delete_btn.setStyleSheet(
"QPushButton { background-color: #6c757d; color: white; padding: 5px 10px; border-radius: 3px; font-weight: bold; } "
"QPushButton:hover { background-color: #dc3545; } "
"QPushButton:disabled { background-color: #e0e0e0; color: #9e9e9e; }"
)
# Set icon
icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Halgakos.ico")
if os.path.exists(icon_path):
delete_btn.setIcon(QIcon(icon_path))
buttons_h.addWidget(delete_btn)
buttons_h.addStretch()
section_v.addWidget(buttons_row)
# Description for the buttons
button_desc = QLabel(
"Standalone mode: Translates chapter headers using exact content.opf mapping."
)
button_desc.setStyleSheet("color: gray; font-size: 10pt;")
button_desc.setContentsMargins(20, 2, 0, 10)
section_v.addWidget(button_desc)
# Toggle function for enabling/disabling controls
def _toggle_header_controls(checked):
try:
enabled = bool(self.batch_translate_headers_var)
self.batch_translate_headers_var = bool(checked)
except Exception:
pass
headers_per_batch_label.setEnabled(checked)
batch_entry.setEnabled(checked)
update_cb.setEnabled(checked)
save_cb.setEnabled(checked)
ignore_header_cb.setEnabled(checked)
use_title_cb.setEnabled(checked)
use_fallback_cb.setEnabled(checked)
translate_now_btn.setEnabled(checked)
batch_toggle_cb.toggled.connect(_toggle_header_controls)
# Initialize disabled state
try:
_toggle_header_controls(bool(self.batch_translate_headers_var))
except Exception:
_toggle_header_controls(False)
# Separator
sep2 = QFrame()
sep2.setFrameShape(QFrame.HLine)
sep2.setFrameShadow(QFrame.Sunken)
section_v.addWidget(sep2)
# EPUB Utilities
epub_title = QLabel("EPUB Utilities:")
epub_title.setStyleSheet("font-weight: bold; font-size: 11pt;")
epub_title.setContentsMargins(0, 0, 0, 5)
section_v.addWidget(epub_title)
btn_validate = QPushButton("πŸ” Validate EPUB Structure")
btn_validate.setFixedWidth(250)
btn_validate.clicked.connect(lambda: self.validate_epub_structure_gui())
btn_validate.setStyleSheet(
"QPushButton { background-color: #6f42c1; color: white; padding: 5px 10px; border-radius: 3px; font-weight: bold; } "
"QPushButton:hover { background-color: #5a32a3; }"
)
section_v.addWidget(btn_validate)
validate_desc = QLabel("Check if all required EPUB files are present for compilation")
validate_desc.setStyleSheet("color: gray; font-size: 10pt;")
validate_desc.setContentsMargins(0, 0, 0, 5)
section_v.addWidget(validate_desc)
# NCX-only navigation toggle
ncx_cb = self._create_styled_checkbox("Use NCX-only Navigation (Compatibility Mode)")
try:
ncx_cb.setChecked(bool(self.force_ncx_only_var))
except Exception:
pass
def _on_ncx_toggle(checked):
try:
self.force_ncx_only_var = bool(checked)
self.config['force_ncx_only'] = bool(checked)
os.environ['FORCE_NCX_ONLY'] = '1' if checked else '0'
except Exception:
pass
ncx_cb.toggled.connect(_on_ncx_toggle)
ncx_cb.setContentsMargins(0, 5, 0, 5)
ncx_cb.setToolTip(
"Force NCX-only navigation in the output EPUB (no modern nav file).\n"
"Use for older readers that choke on EPUB3 navigation documents."
)
section_v.addWidget(ncx_cb)
# EPUB Layout Mode dropdown (Auto / EPUB2 / EPUB3)
if not hasattr(self, 'epub_layout_mode_var'):
# Backward compat: migrate old bool 'legacy_structure' β†’ string mode
# Only migrate if the NEW key doesn't exist yet in config
if 'epub_layout_mode' not in self.config:
old_legacy = self.config.get('legacy_structure', None)
if old_legacy is True:
self.epub_layout_mode_var = 'epub2'
else:
self.epub_layout_mode_var = 'auto'
self.config['epub_layout_mode'] = self.epub_layout_mode_var
# Remove old key so it never overrides again
self.config.pop('legacy_structure', None)
else:
self.epub_layout_mode_var = self.config.get('epub_layout_mode', 'auto')
layout_row = QWidget()
layout_h = QHBoxLayout(layout_row)
layout_h.setContentsMargins(0, 5, 0, 5)
layout_h.setSpacing(8)
layout_label = QLabel("EPUB Layout:")
layout_label.setStyleSheet("font-weight: bold;")
layout_h.addWidget(layout_label)
layout_combo = QComboBox()
layout_combo.addItems(["Auto", "EPUB2", "EPUB3"])
layout_combo.setFixedWidth(120)
layout_combo.wheelEvent = lambda event: None
layout_combo.setToolTip(
"Controls the internal folder structure of the compiled EPUB:\n\n"
"β€’ Auto β€” Detect the layout from the source EPUB and preserve it.\n"
" If the source uses OEBPS/Text/, the output will too.\n\n"
"β€’ EPUB2 β€” Force a legacy EPUB2-style structure (OEBPS/Text/).\n"
" Chapter files are placed under OEBPS/Text/ for compatibility\n"
" with older e-readers.\n\n"
"β€’ EPUB3 β€” Force a modern flat OEBPS/ structure.\n"
" Chapter files sit directly inside OEBPS/.\n\n"
"Default: Auto (falls back to EPUB3 if detection fails)."
)
# Set current selection from saved config
_mode_map = {'auto': 0, 'epub2': 1, 'epub3': 2}
try:
layout_combo.setCurrentIndex(_mode_map.get(self.epub_layout_mode_var, 0))
except Exception:
layout_combo.setCurrentIndex(0) # Default to Auto
def _on_layout_mode_changed(index):
try:
modes = ['auto', 'epub2', 'epub3']
mode = modes[index]
self.epub_layout_mode_var = mode
self.config['epub_layout_mode'] = mode
# Set env var for epub_converter.py
# 'auto' β†’ let the compiler detect, 'epub2' β†’ force legacy, 'epub3' β†’ force modern
os.environ['EPUB_LAYOUT_MODE'] = mode
# Backward compat: also set the old env var
os.environ['LEGACY_EPUB_STRUCTURE'] = '1' if mode == 'epub2' else '0'
except Exception:
pass
layout_combo.currentIndexChanged.connect(_on_layout_mode_changed)
layout_h.addWidget(layout_combo)
layout_h.addStretch()
section_v.addWidget(layout_row)
# CSS Attachment toggle
css_cb = self._create_styled_checkbox("Attach CSS to Chapters (May fix or cause styling issues)")
try:
css_cb.setChecked(bool(self.attach_css_to_chapters_var))
except Exception:
pass
css_cb.setToolTip(
"Reattach the original EPUB's CSS to each chapter when rebuilding.\n"
"If you click 'Load CSS…', the selected file overrides the original CSS."
)
# Ensure we have a variable to store the override CSS path
if not hasattr(self, 'epub_css_override_path_var'):
self.epub_css_override_path_var = self.config.get('epub_css_override_path', '')
def _on_css_toggle(checked):
try:
self.attach_css_to_chapters_var = bool(checked)
self.config['attach_css_to_chapters'] = bool(checked)
os.environ['ATTACH_CSS_TO_CHAPTERS'] = '1' if checked else '0'
except Exception:
pass
css_cb.toggled.connect(_on_css_toggle)
from PySide6.QtWidgets import QFileDialog, QHBoxLayout
css_row = QWidget()
css_row_h = QHBoxLayout(css_row)
css_row_h.setContentsMargins(0, 5, 0, 5)
css_row_h.setSpacing(8)
css_row_h.addWidget(css_cb)
load_css_btn = QPushButton("Load CSS…")
load_css_btn.setToolTip("Select a CSS file to use for all chapters (overrides original EPUB CSS)")
load_css_btn.setMinimumWidth(90)
load_css_btn.setStyleSheet(
"QPushButton { background-color: #17a2b8; color: white; padding: 4px 10px; "
"border-radius: 4px; font-weight: bold; } "
"QPushButton:hover { background-color: #138496; }"
)
css_row_h.addWidget(load_css_btn)
clear_css_btn = QPushButton("Clear")
clear_css_btn.setToolTip("Remove CSS override and use original EPUB CSS again")
clear_css_btn.setMinimumWidth(60)
clear_css_btn.setStyleSheet(
"QPushButton { background-color: #6c757d; color: white; padding: 4px 8px; "
"border-radius: 4px; font-weight: bold; } "
"QPushButton:hover { background-color: #5a6268; }"
)
css_row_h.addWidget(clear_css_btn)
import os as _os
css_status_label = QLabel()
css_status_label.setStyleSheet("color: #28a745; font-size: 11pt; font-weight: bold;")
css_status_label.hide()
css_row_h.addWidget(css_status_label)
css_path_label = QLabel()
css_path_label.setStyleSheet("color: gray; font-size: 9pt;")
if getattr(self, 'epub_css_override_path_var', ''):
css_path_label.setText(_os.path.basename(self.epub_css_override_path_var))
css_status_label.setText("βœ“")
css_status_label.show()
css_row_h.addWidget(css_path_label)
css_row_h.addStretch()
section_v.addWidget(css_row)
def _on_load_css_clicked():
try:
start_dir = _os.path.dirname(self.epub_css_override_path_var) if getattr(self, 'epub_css_override_path_var', '') else _os.getcwd()
file_name, _ = QFileDialog.getOpenFileName(parent, "Select CSS file", start_dir, "CSS Files (*.css);;All Files (*.*)")
if file_name:
self.epub_css_override_path_var = file_name
css_path_label.setText(_os.path.basename(file_name))
css_status_label.setText("βœ“")
css_status_label.show()
# If user explicitly loads CSS, ensure attachment is enabled
if not css_cb.isChecked():
css_cb.setChecked(True)
except Exception:
pass
def _on_clear_css_clicked():
try:
self.epub_css_override_path_var = ''
css_path_label.setText('')
css_status_label.hide()
except Exception:
pass
load_css_btn.clicked.connect(_on_load_css_clicked)
clear_css_btn.clicked.connect(_on_clear_css_clicked)
# HTML serialization method toggle
html_method_cb = self._create_styled_checkbox("Use HTML Method for EPUB (Better for preserving whitespaces)")
try:
html_method_cb.setChecked(bool(self.epub_use_html_method_var))
except Exception:
pass
def _on_html_method_toggle(checked):
try:
self.epub_use_html_method_var = bool(checked)
self.config['epub_use_html_method'] = bool(checked)
os.environ['EPUB_USE_HTML_METHOD'] = '1' if checked else '0'
except Exception:
pass
html_method_cb.toggled.connect(_on_html_method_toggle)
html_method_cb.setContentsMargins(0, 5, 0, 5)
html_method_cb.setToolTip(
"Serialize using HTML path instead of strict XHTML/XML.\n"
"Preserves whitespace/layout better; may be slightly slower."
)
section_v.addWidget(html_method_cb)
# Output file naming
retain_cb = self._create_styled_checkbox("Retain source extension (no 'response_' prefix)")
try:
retain_cb.setChecked(bool(self.retain_source_extension_var))
except Exception:
pass
def _on_retain_toggle(checked):
try:
self.retain_source_extension_var = bool(checked)
self.config['retain_source_extension'] = bool(checked)
os.environ['RETAIN_SOURCE_EXTENSION'] = '1' if checked else '0'
except Exception:
pass
retain_cb.toggled.connect(_on_retain_toggle)
retain_cb.setToolTip(
"Keep the original chapter filename/extension instead of adding 'response_' and replacing the extension with '.html'.\n"
"Requires content.opf in the output folder; does nothing otherwise."
)
rename_btn = QPushButton("Rename Files")
rename_btn.setToolTip("Rename existing output files to match the current naming convention.\nOnly available when no translation is running.")
rename_btn.setMinimumWidth(100)
rename_btn.setStyleSheet(
"QPushButton { background-color: #17a2b8; color: white; padding: 4px 10px; "
"border-radius: 4px; font-weight: bold; } "
"QPushButton:hover { background-color: #138496; } "
"QPushButton:disabled { background-color: #555; color: #999; }"
)
# Check if translation is running and disable accordingly
def _update_rename_btn_state():
try:
btn_text = self.run_button_text.text() if hasattr(self, 'run_button_text') else 'Run Translation'
rename_btn.setEnabled(btn_text == 'Run Translation')
except Exception:
rename_btn.setEnabled(True)
_update_rename_btn_state()
# Poll the run button state periodically
from PySide6.QtCore import QTimer
rename_timer = QTimer()
rename_timer.timeout.connect(_update_rename_btn_state)
rename_timer.start(1000)
rename_btn._keep_timer = rename_timer # prevent GC
def _on_rename_clicked():
import winsound
try:
result = _rename_output_files_for_retain(self, bool(retain_cb.isChecked()))
if result and result[0] == 'renamed':
rename_btn.setText(f"βœ… {result[1]} files renamed")
winsound.MessageBeep(winsound.MB_OK)
elif result and result[0] == 'no_opf':
rename_btn.setText("πŸ“ No content.opf found")
winsound.MessageBeep(winsound.MB_ICONEXCLAMATION)
else:
rename_btn.setText("πŸ“„ No files to rename")
winsound.MessageBeep(winsound.MB_ICONEXCLAMATION)
except Exception:
rename_btn.setText("⚠️ Rename failed")
try:
winsound.MessageBeep(winsound.MB_ICONHAND)
except Exception:
pass
# Revert button text after 3 seconds
QTimer.singleShot(3000, lambda: rename_btn.setText("Rename Files"))
rename_btn.clicked.connect(_on_rename_clicked)
from PySide6.QtWidgets import QHBoxLayout as _RetainHBox
retain_row = QWidget()
retain_row_h = _RetainHBox(retain_row)
retain_row_h.setContentsMargins(0, 5, 0, 5)
retain_row_h.setSpacing(8)
retain_row_h.addWidget(retain_cb)
retain_row_h.addWidget(rename_btn)
retain_row_h.addStretch()
section_v.addWidget(retain_row)
# Place the section at row 0, column 0 to match the original grid
try:
grid = parent.layout()
if grid:
grid.addWidget(section_box, 0, 0)
except Exception:
# Fallback: just stack
section_box.setParent(parent)
def _create_processing_options_section(self, parent):
"""Create processing options section - PySide6"""
from PySide6.QtWidgets import QGroupBox, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox, QPushButton, QWidget, QLineEdit, QComboBox, QRadioButton, QButtonGroup
from PySide6.QtCore import Qt
section_box = QGroupBox("Processing Options")
# No max width - let it expand in fullscreen
section_v = QVBoxLayout(section_box)
section_v.setContentsMargins(8, 8, 8, 8) # Compact margins
section_v.setSpacing(4) # Compact spacing between widgets
# Create two-column layout
columns_container = QWidget()
columns_h = QHBoxLayout(columns_container)
columns_h.setContentsMargins(0, 0, 0, 15)
columns_h.setSpacing(20)
# Left column - Checkboxes
left_column = QWidget()
left_v = QVBoxLayout(left_column)
left_v.setContentsMargins(0, 0, 0, 0)
# Emergency Paragraph Restoration
emergency_cb = self._create_styled_checkbox("Emergency Paragraph Restoration")
try:
emergency_cb.setChecked(bool(self.emergency_restore_var))
except Exception:
pass
def _on_emergency_toggle(checked):
try:
self.emergency_restore_var = bool(checked)
except Exception:
pass
emergency_cb.toggled.connect(_on_emergency_toggle)
emergency_cb.setContentsMargins(0, 2, 0, 0)
left_v.addWidget(emergency_cb)
emergency_desc = QLabel("Fixes AI responses that lose paragraph\nstructure (wall of text)")
emergency_desc.setStyleSheet("color: gray; font-size: 10pt;")
emergency_desc.setContentsMargins(20, 0, 0, 5)
left_v.addWidget(emergency_desc)
# Emergency Image Restoration (Add below Paragraph Restoration)
img_restore_cb = self._create_styled_checkbox("Emergency Image Restoration")
try:
# Default to False if not present (disabled by default)
if not hasattr(self, 'emergency_image_restore_var'):
self.emergency_image_restore_var = False
img_restore_cb.setChecked(bool(self.emergency_image_restore_var))
except Exception:
pass
def _on_img_restore_toggle(checked):
try:
self.emergency_image_restore_var = bool(checked)
except Exception:
pass
img_restore_cb.toggled.connect(_on_img_restore_toggle)
img_restore_cb.setContentsMargins(0, 2, 0, 0)
left_v.addWidget(img_restore_cb)
img_restore_desc = QLabel("Restores &lt;img&gt; tags if missing in translation<br>(Matches source images to output)")
img_restore_desc.setStyleSheet("color: gray; font-size: 10pt;")
img_restore_desc.setContentsMargins(20, 0, 0, 5)
img_restore_desc.setTextFormat(Qt.RichText)
left_v.addWidget(img_restore_desc)
# Enable Decimal Chapter Detection
decimal_cb = self._create_styled_checkbox("Enable Decimal Chapter Detection (EPUBs)")
try:
decimal_cb.setChecked(bool(self.enable_decimal_chapters_var))
except Exception:
pass
def _on_decimal_toggle(checked):
try:
self.enable_decimal_chapters_var = bool(checked)
except Exception:
pass
decimal_cb.toggled.connect(_on_decimal_toggle)
decimal_cb.setContentsMargins(0, 2, 0, 0)
left_v.addWidget(decimal_cb)
decimal_desc = QLabel("Detect chapters like 1.1, 1.2 in EPUB files\n(Text files always use decimal chapters when split)")
decimal_desc.setStyleSheet("color: gray; font-size: 10pt;")
decimal_desc.setContentsMargins(20, 0, 0, 5)
left_v.addWidget(decimal_desc)
# Fix Empty Attribute Tags (EPUB)
empty_attr_epub_cb = self._create_styled_checkbox("Fix Empty Attribute Tags (EPUB) - LLM Token Fix")
try:
# Default to config value (fallback False)
if not hasattr(self, 'fix_empty_attr_tags_epub_var'):
self.fix_empty_attr_tags_epub_var = self.config.get('fix_empty_attr_tags_epub', False)
empty_attr_epub_cb.setChecked(bool(self.fix_empty_attr_tags_epub_var))
except Exception:
pass
def _on_empty_attr_epub_toggle(checked):
try:
self.fix_empty_attr_tags_epub_var = bool(checked)
self.config['fix_empty_attr_tags_epub'] = self.fix_empty_attr_tags_epub_var
os.environ['FIX_EMPTY_ATTR_TAGS_EPUB'] = '1' if checked else '0'
except Exception:
pass
empty_attr_epub_cb.toggled.connect(_on_empty_attr_epub_toggle)
empty_attr_epub_cb.setContentsMargins(0, 2, 0, 0)
left_v.addWidget(empty_attr_epub_cb)
empty_attr_epub_desc = QLabel("Escapes tags like &lt;tag attr=\"\"&gt;&lt;/tag&gt; so they render as &lt;tag attr&gt;.")
empty_attr_epub_desc.setStyleSheet("color: gray; font-size: 10pt;")
empty_attr_epub_desc.setContentsMargins(20, 0, 0, 5)
empty_attr_epub_desc.setTextFormat(Qt.RichText)
left_v.addWidget(empty_attr_epub_desc)
# Fix Empty Attribute Tags (Extraction) - html2text-specific LLM token fix
empty_attr_extract_cb = self._create_styled_checkbox("Fix Empty Attribute Tags (Extraction) - LLM Token Fix")
try:
# Default to config value (fallback False)
if not hasattr(self, 'fix_empty_attr_tags_extract_var'):
self.fix_empty_attr_tags_extract_var = self.config.get('fix_empty_attr_tags_extract', False)
empty_attr_extract_cb.setChecked(bool(self.fix_empty_attr_tags_extract_var))
except Exception:
pass
def _on_empty_attr_extract_toggle(checked):
try:
self.fix_empty_attr_tags_extract_var = bool(checked)
self.config['fix_empty_attr_tags_extract'] = self.fix_empty_attr_tags_extract_var
os.environ['FIX_EMPTY_ATTR_TAGS_EXTRACT'] = '1' if checked else '0'
except Exception:
pass
empty_attr_extract_cb.toggled.connect(_on_empty_attr_extract_toggle)
empty_attr_extract_cb.setContentsMargins(0, 2, 0, 0)
empty_attr_extract_desc = QLabel("LLM Post-Process Fix: Preserves hallucinated tags like &lt;tag attr=\"\"&gt;&lt;/tag&gt;<br>by converting them to visible text &lt;tag attr&gt;")
empty_attr_extract_desc.setStyleSheet("color: gray; font-size: 8pt;")
empty_attr_extract_desc.setContentsMargins(20, 0, 0, 3)
empty_attr_extract_desc.setTextFormat(Qt.RichText)
# left_v.addWidget(empty_attr_extract_desc)
left_v.addStretch()
columns_h.addWidget(left_column)
# Right column - Button and Reinforce field
right_column = QWidget()
right_v = QVBoxLayout(right_column)
right_v.setContentsMargins(0, 0, 0, 0)
# Translation Chunk Prompt button (renamed)
btn_chunk_prompt = QPushButton("βš™οΈ Configure Chunk Prompt")
btn_chunk_prompt.setFixedWidth(180)
btn_chunk_prompt.clicked.connect(lambda: self.configure_translation_chunk_prompt())
right_v.addWidget(btn_chunk_prompt)
chunk_prompt_desc = QLabel("Split chapter context")
chunk_prompt_desc.setStyleSheet("color: gray; font-size: 10pt;")
chunk_prompt_desc.setContentsMargins(0, 0, 0, 15)
right_v.addWidget(chunk_prompt_desc)
# Message Reinforcement option
reinforce_w = QWidget()
reinforce_h = QHBoxLayout(reinforce_w)
reinforce_h.setContentsMargins(0, 0, 0, 0)
reinforce_h.addWidget(QLabel("Prompt Reinforcement:"))
reinforce_edit = QLineEdit()
reinforce_edit.setFixedWidth(60)
try:
reinforce_edit.setText(str(self.reinforcement_freq_var))
except Exception:
pass
def _on_reinforce_changed(text):
try:
self.reinforcement_freq_var = text
except Exception:
pass
reinforce_edit.textChanged.connect(_on_reinforce_changed)
reinforce_h.addWidget(reinforce_edit)
reinforce_h.addStretch()
right_v.addWidget(reinforce_w)
# Break Split Count option
break_split_w = QWidget()
break_split_h = QHBoxLayout(break_split_w)
break_split_h.setContentsMargins(0, 5, 0, 0)
break_split_h.addWidget(QLabel("Break Split Count:"))
break_split_edit = QLineEdit()
break_split_edit.setFixedWidth(60)
try:
break_split_edit.setText(str(self.break_split_count_var) if hasattr(self, 'break_split_count_var') and self.break_split_count_var else '')
except Exception:
pass
def _on_break_split_changed(text):
try:
self.break_split_count_var = text
except Exception:
pass
break_split_edit.textChanged.connect(_on_break_split_changed)
break_split_h.addWidget(break_split_edit)
break_split_h.addStretch()
right_v.addWidget(break_split_w)
break_split_desc = QLabel("Split chunks after N elements\n(Leave empty for token-only splitting)")
break_split_desc.setStyleSheet("color: gray; font-size: 9pt;")
break_split_desc.setContentsMargins(0, 0, 0, 5)
right_v.addWidget(break_split_desc)
right_v.addStretch()
columns_h.addWidget(right_column)
section_v.addWidget(columns_container)
# === CHAPTER EXTRACTION SETTINGS ===
extraction_box = QGroupBox("Chapter Extraction Settings")
extraction_v = QVBoxLayout(extraction_box)
# Initialize variables if not exists
if not hasattr(self, 'text_extraction_method_var'):
if self.config.get('extraction_mode') == 'enhanced':
self.text_extraction_method_var = 'enhanced'
self.file_filtering_level_var = self.config.get('enhanced_filtering', 'smart')
else:
self.text_extraction_method_var = 'standard'
self.file_filtering_level_var = self.config.get('extraction_mode', 'smart')
if not hasattr(self, 'enhanced_preserve_structure_var'):
self.enhanced_preserve_structure_var = self.config.get('enhanced_preserve_structure', True)
if not hasattr(self, 'enhanced_single_line_break_var'):
self.enhanced_single_line_break_var = self.config.get('enhanced_single_line_break', False)
if not hasattr(self, 'html2text_escape_snob_var'):
self.html2text_escape_snob_var = self.config.get('html2text_escape_snob', False)
if not hasattr(self, 'use_markdown2_converter_var'):
self.use_markdown2_converter_var = self.config.get('use_markdown2_converter', False)
# Text Extraction Method
method_title = QLabel("Text Extraction Method:")
method_title.setStyleSheet("font-weight: bold; font-size: 10pt;")
method_title.setContentsMargins(0, 0, 0, 5)
extraction_v.addWidget(method_title)
extraction_method_group = QButtonGroup(extraction_box)
# Standard extraction
standard_rb = QRadioButton("Standard (BeautifulSoup)")
standard_rb.setToolTip("It retains all html tags and is best for format preservation, but is very token inefficient.")
try:
if self.text_extraction_method_var == "standard":
standard_rb.setChecked(True)
except Exception:
pass
def _on_standard_selected(checked):
if checked:
try:
self.text_extraction_method_var = "standard"
self.on_extraction_method_change()
except Exception:
pass
standard_rb.toggled.connect(_on_standard_selected)
extraction_method_group.addButton(standard_rb)
standard_rb.setContentsMargins(0, 2, 0, 0)
extraction_v.addWidget(standard_rb)
standard_desc = QLabel("Traditional HTML parsing - Best for format preservation")
standard_desc.setStyleSheet("color: gray; font-size: 9pt;")
standard_desc.setContentsMargins(20, 0, 0, 5)
extraction_v.addWidget(standard_desc)
# BeautifulSoup options (shown when standard is selected)
self.bs_options_frame = QWidget()
bs_opts_v = QVBoxLayout(self.bs_options_frame)
bs_opts_v.setContentsMargins(20, 5, 0, 0)
# Fix Empty Attribute Tags (BeautifulSoup) - standard mode LLM token fix
empty_attr_bs_cb = self._create_styled_checkbox("Fix Empty Attribute Tags (BeautifulSoup) - LLM Token Fix")
try:
if not hasattr(self, 'fix_empty_attr_tags_bs_var'):
self.fix_empty_attr_tags_bs_var = self.config.get('fix_empty_attr_tags_bs', False)
empty_attr_bs_cb.setChecked(bool(self.fix_empty_attr_tags_bs_var))
except Exception:
pass
def _on_empty_attr_bs_toggle(checked):
try:
self.fix_empty_attr_tags_bs_var = bool(checked)
self.config['fix_empty_attr_tags_bs'] = self.fix_empty_attr_tags_bs_var
os.environ['FIX_EMPTY_ATTR_TAGS_BS'] = '1' if checked else '0'
except Exception:
pass
empty_attr_bs_cb.toggled.connect(_on_empty_attr_bs_toggle)
empty_attr_bs_cb.setContentsMargins(0, 2, 0, 0)
bs_opts_v.addWidget(empty_attr_bs_cb)
empty_attr_bs_desc = QLabel("Escapes hallucinated tags like &lt;tag attr=\"\"&gt; to visible text (Standard mode)")
empty_attr_bs_desc.setStyleSheet("color: gray; font-size: 8pt;")
empty_attr_bs_desc.setContentsMargins(20, 0, 0, 3)
empty_attr_bs_desc.setTextFormat(Qt.RichText)
bs_opts_v.addWidget(empty_attr_bs_desc)
extraction_v.addWidget(self.bs_options_frame)
# Set initial visibility based on current extraction method
_bs_visible = getattr(self, 'text_extraction_method_var', 'standard') == 'standard'
self.bs_options_frame.setVisible(_bs_visible)
# Enhanced extraction
enhanced_rb = QRadioButton("πŸš€ Enhanced (html2text)")
enhanced_rb.setToolTip("It strips out all html tags, and adds new html tags at the end.\nThis results in a cleaner output and is more token efficient, but sacrifices format preservation.")
try:
if self.text_extraction_method_var == "enhanced":
enhanced_rb.setChecked(True)
except Exception:
pass
def _on_enhanced_selected(checked):
if checked:
try:
self.text_extraction_method_var = "enhanced"
self.on_extraction_method_change()
except Exception:
pass
enhanced_rb.toggled.connect(_on_enhanced_selected)
extraction_method_group.addButton(enhanced_rb)
enhanced_rb.setContentsMargins(0, 2, 0, 0)
extraction_v.addWidget(enhanced_rb)
enhanced_desc = QLabel("Superior token effeciency, cleaner text extraction")
enhanced_desc.setStyleSheet("color: darkgreen; font-size: 9pt;")
enhanced_desc.setContentsMargins(20, 0, 0, 5)
extraction_v.addWidget(enhanced_desc)
# Enhanced options (shown when enhanced is selected)
self.enhanced_options_frame = QWidget()
enhanced_opts_v = QVBoxLayout(self.enhanced_options_frame)
enhanced_opts_v.setContentsMargins(20, 5, 0, 0)
preserve_cb = self._create_styled_checkbox("Preserve Markdown Structure")
preserve_cb.setToolTip(
"<qt><p style='white-space: normal; max-width: 32em; margin: 0;'>"
"Keeps markdown-style elements (headers, bold, lists) when using html2text so the model retains formatting cues."
"</p></qt>"
)
try:
preserve_cb.setChecked(bool(self.enhanced_preserve_structure_var))
except Exception:
pass
def _on_preserve_toggle(checked):
try:
self.enhanced_preserve_structure_var = bool(checked)
except Exception:
pass
preserve_cb.toggled.connect(_on_preserve_toggle)
preserve_cb.setContentsMargins(0, 2, 0, 0)
enhanced_opts_v.addWidget(preserve_cb)
preserve_desc = QLabel("Keep formatting (bold, headers, lists) for better AI context")
preserve_desc.setStyleSheet("color: gray; font-size: 8pt;")
preserve_desc.setContentsMargins(20, 0, 0, 3)
enhanced_opts_v.addWidget(preserve_desc)
# Single line break option
single_break_cb = self._create_styled_checkbox("Enable Single Line Break")
single_break_cb.setToolTip(
"<qt><p style='white-space: normal; max-width: 32em; margin: 0;'>"
"Use single newlines instead of double when converting with html2text. "
"Helps reduce blank-line spacing."
"</p></qt>"
)
try:
single_break_cb.setChecked(bool(self.enhanced_single_line_break_var))
except Exception:
pass
def _on_single_break_toggle(checked):
try:
self.enhanced_single_line_break_var = bool(checked)
self.config['enhanced_single_line_break'] = self.enhanced_single_line_break_var
os.environ['ENHANCED_SINGLE_LINE_BREAK'] = '1' if checked else '0'
except Exception:
pass
single_break_cb.toggled.connect(_on_single_break_toggle)
single_break_cb.setContentsMargins(0, 2, 0, 0)
enhanced_opts_v.addWidget(single_break_cb)
single_break_desc = QLabel("Use single newlines instead of double (helps reduce blank-line spacing)")
single_break_desc.setStyleSheet("color: gray; font-size: 8pt;")
single_break_desc.setContentsMargins(20, 0, 0, 3)
enhanced_opts_v.addWidget(single_break_desc)
# Escape snob option
escape_snob_cb = self._create_styled_checkbox("Escape Markdown specials (escape_snob)")
escape_snob_cb.setToolTip(
"<qt><p style='white-space: normal; max-width: 32em; margin: 0;'>"
"When on, html2text escapes Markdown specials like (), [], * "
"Enable to avoid accidental formatting; disable for plainer text output."
"</p></qt>"
)
try:
escape_snob_cb.setChecked(bool(self.html2text_escape_snob_var))
except Exception:
pass
def _on_escape_snob_toggle(checked):
try:
self.html2text_escape_snob_var = bool(checked)
self.config['html2text_escape_snob'] = self.html2text_escape_snob_var
os.environ['HTML2TEXT_ESCAPE_SNOB'] = '1' if checked else '0'
except Exception:
pass
escape_snob_cb.toggled.connect(_on_escape_snob_toggle)
escape_snob_cb.setContentsMargins(0, 2, 0, 0)
enhanced_opts_v.addWidget(escape_snob_cb)
escape_snob_desc = QLabel("When on, html2text escapes (), [], *, _ etc. Turn off for plainer text.")
escape_snob_desc.setStyleSheet("color: gray; font-size: 8pt;")
escape_snob_desc.setContentsMargins(20, 0, 0, 3)
enhanced_opts_v.addWidget(escape_snob_desc)
# Fix Empty Attribute Tags (Extraction) β€” html2text-specific LLM token fix
enhanced_opts_v.addWidget(empty_attr_extract_cb)
enhanced_opts_v.addWidget(empty_attr_extract_desc)
# Markdown2 converter option
markdown2_cb = self._create_styled_checkbox("Use markdown2 Converter (Legacy)")
try:
markdown2_cb.setChecked(bool(self.use_markdown2_converter_var))
except Exception:
pass
def _on_markdown2_toggle(checked):
try:
self.use_markdown2_converter_var = bool(checked)
except Exception:
pass
markdown2_cb.toggled.connect(_on_markdown2_toggle)
markdown2_cb.setContentsMargins(0, 2, 0, 0)
enhanced_opts_v.addWidget(markdown2_cb)
markdown2_cb.setToolTip(
"<qt><p style='white-space: normal; max-width: 32em; margin: 0;'>"
"When enabled, uses the markdown2 library for conversion.<br>"
"When disabled, falls back to the standard markdown library."
"</p></qt>"
)
markdown2_desc = QLabel("Use markdown2 library instead of markdown (may escape brackets)")
markdown2_desc.setStyleSheet("color: gray; font-size: 8pt;")
markdown2_desc.setContentsMargins(20, 0, 0, 3)
enhanced_opts_v.addWidget(markdown2_desc)
extraction_v.addWidget(self.enhanced_options_frame)
# Separator
sep_extract1 = QFrame()
sep_extract1.setFrameShape(QFrame.HLine)
sep_extract1.setFrameShadow(QFrame.Sunken)
extraction_v.addWidget(sep_extract1)
# File Filtering Level
filter_title = QLabel("File Filtering Level:")
filter_title.setStyleSheet("font-weight: bold; font-size: 10pt;")
filter_title.setContentsMargins(0, 0, 0, 5)
extraction_v.addWidget(filter_title)
filtering_group = QButtonGroup(extraction_box)
# Smart filtering
smart_rb = QRadioButton("Smart (Aggressive Filtering)")
try:
if self.file_filtering_level_var == "smart":
smart_rb.setChecked(True)
except Exception:
pass
def _on_smart_selected(checked):
if checked:
try:
self.file_filtering_level_var = "smart"
except Exception:
pass
smart_rb.toggled.connect(_on_smart_selected)
filtering_group.addButton(smart_rb)
smart_rb.setContentsMargins(0, 2, 0, 0)
extraction_v.addWidget(smart_rb)
smart_desc = QLabel("Skips navigation, TOC, copyright files\nBest for clean EPUBs with clear chapter structure")
smart_desc.setStyleSheet("color: gray; font-size: 9pt;")
smart_desc.setContentsMargins(20, 0, 0, 5)
extraction_v.addWidget(smart_desc)
# Comprehensive filtering
comprehensive_rb = QRadioButton("Comprehensive (Moderate Filtering)")
try:
if self.file_filtering_level_var == "comprehensive":
comprehensive_rb.setChecked(True)
except Exception:
pass
def _on_comprehensive_selected(checked):
if checked:
try:
self.file_filtering_level_var = "comprehensive"
except Exception:
pass
comprehensive_rb.toggled.connect(_on_comprehensive_selected)
filtering_group.addButton(comprehensive_rb)
comprehensive_rb.setContentsMargins(0, 2, 0, 0)
extraction_v.addWidget(comprehensive_rb)
comprehensive_desc = QLabel("Only skips obvious navigation files\nGood when Smart mode misses chapters")
comprehensive_desc.setStyleSheet("color: gray; font-size: 9pt;")
comprehensive_desc.setContentsMargins(20, 0, 0, 5)
extraction_v.addWidget(comprehensive_desc)
# Full extraction
full_rb = QRadioButton("Full (No Filtering)")
try:
if self.file_filtering_level_var == "full":
full_rb.setChecked(True)
except Exception:
pass
def _on_full_selected(checked):
if checked:
try:
self.file_filtering_level_var = "full"
except Exception:
pass
full_rb.toggled.connect(_on_full_selected)
filtering_group.addButton(full_rb)
full_rb.setContentsMargins(0, 2, 0, 0)
extraction_v.addWidget(full_rb)
full_desc = QLabel("Extracts ALL HTML/XHTML files\nUse when other modes skip important content")
full_desc.setStyleSheet("color: gray; font-size: 9pt;")
full_desc.setContentsMargins(20, 0, 0, 5)
extraction_v.addWidget(full_desc)
# Force BeautifulSoup for Traditional APIs
if not hasattr(self, 'force_bs_for_traditional_var'):
self.force_bs_for_traditional_var = self.config.get('force_bs_for_traditional', True)
force_bs_cb = self._create_styled_checkbox("Force BeautifulSoup for DeepL / Google Translate / Google Free")
try:
force_bs_cb.setChecked(bool(self.force_bs_for_traditional_var))
except Exception:
pass
def _on_force_bs_toggle(checked):
try:
self.force_bs_for_traditional_var = bool(checked)
except Exception:
pass
force_bs_cb.toggled.connect(_on_force_bs_toggle)
force_bs_cb.setContentsMargins(0, 0, 0, 5)
extraction_v.addWidget(force_bs_cb)
force_bs_desc = QLabel("Overrides HTML2Text.")
force_bs_desc.setStyleSheet("color: gray; font-size: 8pt;")
force_bs_desc.setContentsMargins(20, 0, 0, 5)
extraction_v.addWidget(force_bs_desc)
# Separator
sep_extract2 = QFrame()
sep_extract2.setFrameShape(QFrame.HLine)
sep_extract2.setFrameShadow(QFrame.Sunken)
extraction_v.addWidget(sep_extract2)
# Disable Section Merging (renamed from Chapter Merging)
if not hasattr(self, 'disable_chapter_merging_var'):
self.disable_chapter_merging_var = self.config.get('disable_chapter_merging', True)
disable_merging_cb = self._create_styled_checkbox("Disable Section Merging")
try:
disable_merging_cb.setChecked(bool(self.disable_chapter_merging_var))
except Exception:
pass
def _on_disable_merging_toggle(checked):
try:
self.disable_chapter_merging_var = bool(checked)
except Exception:
pass
disable_merging_cb.toggled.connect(_on_disable_merging_toggle)
disable_merging_cb.setContentsMargins(0, 2, 0, 0)
extraction_v.addWidget(disable_merging_cb)
disable_merging_desc = QLabel("Disable automatic merging of Section/Chapter pairs.\nEach file will be treated as a separate section.")
disable_merging_desc.setStyleSheet("color: gray; font-size: 9pt;")
disable_merging_desc.setContentsMargins(20, 0, 0, 5)
extraction_v.addWidget(disable_merging_desc)
# Request Merging (combine multiple chapters into single API request)
if not hasattr(self, 'request_merging_enabled_var'):
self.request_merging_enabled_var = self.config.get('request_merging_enabled', False)
if not hasattr(self, 'request_merge_count_var'):
self.request_merge_count_var = str(self.config.get('request_merge_count', 3))
request_merge_cb = self._create_styled_checkbox("Request Merging")
try:
request_merge_cb.setChecked(bool(self.request_merging_enabled_var))
except Exception:
pass
# Container for merge count setting
merge_count_widgets = []
def _on_request_merge_toggle(checked):
try:
self.request_merging_enabled_var = bool(checked)
# Enable/disable the merge count field
for widget in merge_count_widgets:
widget.setEnabled(checked)
except Exception:
pass
request_merge_cb.toggled.connect(_on_request_merge_toggle)
request_merge_cb.setContentsMargins(0, 2, 0, 0)
# extraction_v.addWidget(request_merge_cb) # Moved to custom layout below
# Create row for toggle and label
req_merge_row = QWidget()
req_merge_h = QHBoxLayout(req_merge_row)
req_merge_h.setContentsMargins(0, 2, 0, 0)
req_merge_h.addWidget(request_merge_cb)
# Add placeholder label that can be double-clicked to copy
placeholder_label = QLabel("{split_marker_instruction}")
# Define styles
base_style = """
QLabel {
color: #17a2b8;
font-family: Consolas, monospace;
padding: 2px 5px;
background-color: #2d2d2d;
border-radius: 3px;
border: 1px solid #4a5568;
}
QLabel:hover {
background-color: #3d3d3d;
border-color: #5a9fd4;
}
"""
pressed_style = """
QLabel {
color: #ffffff;
font-family: Consolas, monospace;
padding: 2px 5px;
background-color: #4a5568;
border-radius: 3px;
border: 1px solid #5a9fd4;
}
"""
copied_style = """
QLabel {
color: #28a745;
font-family: Consolas, monospace;
padding: 2px 5px;
background-color: #2d2d2d;
border-radius: 3px;
border: 1px solid #28a745;
font-weight: bold;
}
"""
placeholder_label.setStyleSheet(base_style)
placeholder_label.setToolTip("Click to copy.\nPaste this into your system prompt to inject instructions for preserving split markers.")
placeholder_label.setCursor(Qt.PointingHandCursor)
# Store state
placeholder_label._is_copied = False
def reset_label_state():
placeholder_label.setStyleSheet(base_style)
placeholder_label.setText("{split_marker_instruction}")
placeholder_label._is_copied = False
# Handle mouse events for interaction
def on_press(event):
if event.button() == Qt.LeftButton:
if not placeholder_label._is_copied:
placeholder_label.setStyleSheet(pressed_style)
def on_release(event):
if event.button() == Qt.LeftButton:
# Check if release is inside the widget
if placeholder_label.rect().contains(event.pos()):
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import QTimer
clipboard = QApplication.clipboard()
clipboard.setText("{split_marker_instruction}")
# Visual feedback
placeholder_label.setStyleSheet(copied_style)
placeholder_label.setText("Copied!")
placeholder_label._is_copied = True
# Reset after 1 second (standard feedback duration)
QTimer.singleShot(1000, reset_label_state)
else:
# Dragged out, cancel press
if not placeholder_label._is_copied:
placeholder_label.setStyleSheet(base_style)
placeholder_label.mousePressEvent = on_press
placeholder_label.mouseReleaseEvent = on_release
# Remove double click handler to prevent conflict/confusion
placeholder_label.mouseDoubleClickEvent = lambda e: None
req_merge_h.addWidget(placeholder_label)
req_merge_h.addStretch()
extraction_v.addWidget(req_merge_row)
# Row for merge count setting
merge_count_row = QWidget()
merge_count_h = QHBoxLayout(merge_count_row)
merge_count_h.setContentsMargins(20, 2, 0, 0)
merge_count_label = QLabel("Chapters per request:")
merge_count_h.addWidget(merge_count_label)
merge_count_widgets.append(merge_count_label)
merge_count_edit = QLineEdit()
merge_count_edit.setFixedWidth(50)
try:
merge_count_edit.setText(str(self.request_merge_count_var))
except Exception:
merge_count_edit.setText("3")
def _on_merge_count_changed(text):
try:
self.request_merge_count_var = text
except Exception:
pass
merge_count_edit.textChanged.connect(_on_merge_count_changed)
merge_count_h.addWidget(merge_count_edit)
merge_count_widgets.append(merge_count_edit)
default_merge_label = QLabel("(default: 3)")
merge_count_h.addWidget(default_merge_label)
merge_count_widgets.append(default_merge_label)
merge_count_h.addStretch()
extraction_v.addWidget(merge_count_row)
# Set initial enabled state
for widget in merge_count_widgets:
widget.setEnabled(bool(self.request_merging_enabled_var))
request_merge_desc = QLabel("Combine multiple chapters into a single translation request.\nReduces API overhead and may improve context consistency.\n⚠️ EPUB and PDF files only - does NOT work with .txt, .csv, .json, or .md files.")
request_merge_desc.setStyleSheet("color: gray; font-size: 9pt;")
request_merge_desc.setContentsMargins(20, 0, 0, 5)
extraction_v.addWidget(request_merge_desc)
# Split the Merge (split merged output back into individual files by headers)
if not hasattr(self, 'split_the_merge_var'):
self.split_the_merge_var = self.config.get('split_the_merge', True)
split_merge_cb = self._create_styled_checkbox("Split the Merge")
try:
split_merge_cb.setChecked(bool(self.split_the_merge_var))
except Exception:
pass
# Track widgets that depend on request merging being enabled
split_merge_widgets = [split_merge_cb]
def _on_split_merge_toggle(checked):
try:
self.split_the_merge_var = bool(checked)
except Exception:
pass
split_merge_cb.toggled.connect(_on_split_merge_toggle)
split_merge_cb.setContentsMargins(20, 2, 0, 0) # Indented to show it's a sub-option
extraction_v.addWidget(split_merge_cb)
split_merge_desc = QLabel("Split merged translation output back into separate files using invisible markers.\nEach chapter gets its own file named after the original content.opf entry.\nMarkers are automatically preserved during translation for reliable splitting.")
split_merge_desc.setStyleSheet("color: gray; font-size: 9pt;")
split_merge_desc.setContentsMargins(40, 0, 0, 5)
extraction_v.addWidget(split_merge_desc)
split_merge_widgets.append(split_merge_desc)
# Disable Fallback (mark as qa_failed if split fails)
if not hasattr(self, 'disable_merge_fallback_var'):
self.disable_merge_fallback_var = self.config.get('disable_merge_fallback', True)
disable_fallback_cb = self._create_styled_checkbox("Disable Fallback")
try:
disable_fallback_cb.setChecked(bool(self.disable_merge_fallback_var))
except Exception:
pass
def _on_disable_fallback_toggle(checked):
try:
self.disable_merge_fallback_var = bool(checked)
except Exception:
pass
disable_fallback_cb.toggled.connect(_on_disable_fallback_toggle)
disable_fallback_cb.setContentsMargins(40, 2, 0, 0) # Double indented to show it's a sub-sub-option
extraction_v.addWidget(disable_fallback_cb)
split_merge_widgets.append(disable_fallback_cb)
disable_fallback_desc = QLabel("Mark merged chapters as qa_failed if split fails (no fallback to merged file).\nUseful when you want to manually review failed splits.")
disable_fallback_desc.setStyleSheet("color: gray; font-size: 9pt;")
disable_fallback_desc.setContentsMargins(60, 0, 0, 5)
extraction_v.addWidget(disable_fallback_desc)
split_merge_widgets.append(disable_fallback_desc)
# Auto-retry Split Failures
if not hasattr(self, 'retry_split_failed_var'):
self.retry_split_failed_var = self.config.get('retry_split_failed', True)
if not hasattr(self, 'split_failed_retry_attempts_var'):
self.split_failed_retry_attempts_var = str(self.config.get('split_failed_retry_attempts', '1'))
retry_split_cb = self._create_styled_checkbox("Auto-retry Split Failures")
try:
retry_split_cb.setChecked(bool(self.retry_split_failed_var))
except Exception:
pass
retry_split_cb.setContentsMargins(60, 2, 0, 0)
split_merge_widgets.append(retry_split_cb)
retry_split_row = QWidget()
retry_split_h = QHBoxLayout(retry_split_row)
retry_split_h.setContentsMargins(80, 5, 0, 0)
retry_split_label = QLabel("Attempts:")
retry_split_h.addWidget(retry_split_label)
retry_split_edit = QLineEdit()
retry_split_edit.setFixedWidth(50)
try:
retry_split_edit.setText(str(self.split_failed_retry_attempts_var))
except Exception:
retry_split_edit.setText("1")
def _on_retry_split_changed(text):
try:
self.split_failed_retry_attempts_var = text
except Exception:
pass
retry_split_edit.textChanged.connect(_on_retry_split_changed)
retry_split_h.addWidget(retry_split_edit)
retry_split_h.addStretch()
split_merge_widgets.extend([retry_split_row, retry_split_label, retry_split_edit])
def _update_retry_split_state():
try:
enabled = retry_split_cb.isChecked() and request_merge_cb.isChecked()
retry_split_edit.setEnabled(enabled)
retry_split_label.setEnabled(enabled)
if enabled:
retry_split_label.setStyleSheet("color: white;")
retry_split_edit.setStyleSheet("color: white;")
else:
retry_split_label.setStyleSheet("color: #606060;")
retry_split_edit.setStyleSheet("color: #909090;")
except Exception:
pass
def _on_retry_split_toggle(checked):
try:
self.retry_split_failed_var = bool(checked)
_update_retry_split_state()
except Exception:
pass
retry_split_cb.toggled.connect(_on_retry_split_toggle)
extraction_v.addWidget(retry_split_cb)
extraction_v.addWidget(retry_split_row)
# Keep split-retry disabled if fallback is disabled
def _on_disable_fallback_toggle_enhanced(checked):
_on_disable_fallback_toggle(checked)
_update_retry_split_state()
try:
disable_fallback_cb.toggled.disconnect(_on_disable_fallback_toggle)
except Exception:
pass
disable_fallback_cb.toggled.connect(_on_disable_fallback_toggle_enhanced)
_update_retry_split_state()
# NOTE: Split markers are now ALWAYS enabled (hardcoded)
# They are required for split-the-merge to work, so no toggle is needed
# The toggle has been removed from the UI
# Set initial enabled state for split merge (depends on request merging)
for widget in split_merge_widgets:
widget.setEnabled(bool(self.request_merging_enabled_var))
# Update the request merge toggle to also control split merge widgets
original_on_request_merge_toggle = _on_request_merge_toggle
def _on_request_merge_toggle_with_split(checked):
original_on_request_merge_toggle(checked)
# Enable/disable split merge option (but don't change its checked state)
for widget in split_merge_widgets:
widget.setEnabled(checked)
# Dim/restore key labels/inputs for visual consistency
for lbl in (merge_count_label, default_merge_label,
retry_split_label, retry_split_edit):
try:
if hasattr(lbl, "style"):
lbl.style().unpolish(lbl)
lbl.style().polish(lbl)
lbl.setStyleSheet("color: white;" if checked else "color: #606060;")
except Exception:
pass
_update_retry_split_state()
request_merge_cb.toggled.disconnect(_on_request_merge_toggle)
request_merge_cb.toggled.connect(_on_request_merge_toggle_with_split)
# Initialize styling immediately (scheduled via QTimer to ensure widgets are ready)
from PySide6.QtCore import QTimer
QTimer.singleShot(0, lambda: _on_request_merge_toggle_with_split(request_merge_cb.isChecked()))
_update_retry_split_state()
section_v.addWidget(extraction_box)
# === REMAINING OPTIONS ===
# Disable Image Gallery
gallery_cb = self._create_styled_checkbox("Disable Image Gallery in EPUB")
try:
gallery_cb.setChecked(bool(self.disable_epub_gallery_var))
except Exception:
pass
def _on_gallery_toggle(checked):
try:
self.disable_epub_gallery_var = bool(checked)
self.config['disable_epub_gallery'] = bool(checked)
os.environ['DISABLE_EPUB_GALLERY'] = '1' if checked else '0'
except Exception:
pass
gallery_cb.toggled.connect(_on_gallery_toggle)
gallery_cb.setContentsMargins(0, 2, 0, 0)
section_v.addWidget(gallery_cb)
gallery_desc = QLabel("Skip creating image gallery page in EPUB")
gallery_desc.setStyleSheet("color: gray; font-size: 10pt;")
gallery_desc.setContentsMargins(20, 0, 0, 10)
section_v.addWidget(gallery_desc)
# Disable Automatic Cover Creation
cover_cb = self._create_styled_checkbox("Disable Automatic Cover Creation")
try:
cover_cb.setChecked(bool(self.disable_automatic_cover_creation_var))
except Exception:
pass
def _on_cover_toggle(checked):
try:
self.disable_automatic_cover_creation_var = bool(checked)
self.config['disable_automatic_cover_creation'] = bool(checked)
os.environ['DISABLE_AUTOMATIC_COVER_CREATION'] = '1' if checked else '0'
except Exception:
pass
cover_cb.toggled.connect(_on_cover_toggle)
cover_cb.setContentsMargins(0, 2, 0, 0)
section_v.addWidget(cover_cb)
cover_desc = QLabel("No auto-generated cover page is created.")
cover_desc.setStyleSheet("color: gray; font-size: 10pt;")
cover_desc.setContentsMargins(20, 0, 0, 10)
section_v.addWidget(cover_desc)
# Translate special files (cover, nav, toc, etc.)
translate_special_cb = self._create_styled_checkbox("Translate Special Files (Skip Override)")
try:
translate_special_cb.setChecked(bool(self.translate_special_files_var))
except Exception:
pass
def _on_translate_special_toggle(checked):
try:
old_value = self.translate_special_files_var
self.translate_special_files_var = bool(checked)
self.config['translate_special_files'] = bool(checked)
os.environ['TRANSLATE_SPECIAL_FILES'] = '1' if checked else '0'
# Show helpful message if value changed
if old_value != bool(checked):
if checked:
self.append_log("βœ… Special files override ENABLED - special files will be included in extraction")
self.append_log("πŸ”„ If you already extracted an EPUB, re-translate to apply this setting")
else:
self.append_log("❌ Special files override DISABLED - special files will be skipped (default behavior)")
except Exception:
pass
translate_special_cb.toggled.connect(_on_translate_special_toggle)
translate_special_cb.setContentsMargins(0, 2, 0, 0)
section_v.addWidget(translate_special_cb)
translate_special_desc = QLabel("Forces translation of special files (cover, nav, toc, message, etc.)\ninstead of skipping them during extraction and compilation.")
translate_special_desc.setStyleSheet("color: gray; font-size: 10pt;")
translate_special_desc.setContentsMargins(20, 0, 0, 10)
section_v.addWidget(translate_special_desc)
# === PDF OUTPUT SETTINGS ===
# Separator
pdf_sep = QFrame()
pdf_sep.setFrameShape(QFrame.HLine)
pdf_sep.setFrameShadow(QFrame.Sunken)
section_v.addWidget(pdf_sep)
# PDF Output Format section title
pdf_title = QLabel("PDF Output Settings")
pdf_title.setStyleSheet("font-weight: bold; font-size: 11pt;")
pdf_title.setContentsMargins(0, 5, 0, 5)
section_v.addWidget(pdf_title)
# Initialize PDF output format variable
if not hasattr(self, 'pdf_output_format_var'):
self.pdf_output_format_var = self.config.get('pdf_output_format', 'pdf')
# PDF Output Format toggle
pdf_format_row = QWidget()
pdf_format_h = QHBoxLayout(pdf_format_row)
pdf_format_h.setContentsMargins(20, 2, 0, 0)
pdf_format_label = QLabel("Output format:")
pdf_format_h.addWidget(pdf_format_label)
pdf_format_combo = QComboBox()
pdf_format_combo.addItems(["pdf", "epub"])
pdf_format_combo.setFixedWidth(100)
pdf_format_combo.setStyleSheet("""
QComboBox::down-arrow {
image: none;
width: 12px;
height: 12px;
border: none;
}
""")
self._add_combobox_arrow(pdf_format_combo)
self._disable_combobox_mousewheel(pdf_format_combo)
try:
format_val = self.pdf_output_format_var
idx = pdf_format_combo.findText(format_val)
if idx >= 0:
pdf_format_combo.setCurrentIndex(idx)
except Exception:
pass
def _on_pdf_format_changed(text):
try:
self.pdf_output_format_var = text
except Exception:
pass
pdf_format_combo.currentTextChanged.connect(_on_pdf_format_changed)
pdf_format_h.addWidget(pdf_format_combo)
pdf_format_h.addStretch()
section_v.addWidget(pdf_format_row)
pdf_format_desc = QLabel("Choose whether to output PDFs as .pdf or .epub files.\nPDF: Creates combined PDF with all translated pages\nEPUB: Compiles pages into EPUB format")
pdf_format_desc.setStyleSheet("color: gray; font-size: 10pt;")
pdf_format_desc.setContentsMargins(20, 0, 0, 10)
section_v.addWidget(pdf_format_desc)
# Initialize PDF render mode variable
if not hasattr(self, 'pdf_render_mode_var'):
self.pdf_render_mode_var = self.config.get('pdf_render_mode', 'xhtml')
# PDF Render Mode toggle
pdf_render_row = QWidget()
pdf_render_h = QHBoxLayout(pdf_render_row)
pdf_render_h.setContentsMargins(20, 2, 0, 0)
pdf_render_label = QLabel("Render mode:")
pdf_render_h.addWidget(pdf_render_label)
pdf_render_combo = QComboBox()
pdf_render_combo.addItems(["absolute", "semantic", "xhtml", "html", "image"])
pdf_render_combo.setFixedWidth(100)
pdf_render_combo.setStyleSheet("""
QComboBox::down-arrow {
image: none;
width: 12px;
height: 12px;
border: none;
}
""")
self._add_combobox_arrow(pdf_render_combo)
self._disable_combobox_mousewheel(pdf_render_combo)
try:
render_val = self.pdf_render_mode_var
idx = pdf_render_combo.findText(render_val)
if idx >= 0:
pdf_render_combo.setCurrentIndex(idx)
except Exception:
pass
def _on_pdf_render_changed(text):
try:
self.pdf_render_mode_var = text
except Exception:
pass
pdf_render_combo.currentTextChanged.connect(_on_pdf_render_changed)
pdf_render_h.addWidget(pdf_render_combo)
pdf_render_h.addStretch()
section_v.addWidget(pdf_render_row)
pdf_render_desc = QLabel(
"PDF extraction mode:\n"
"β€’ absolute: Fixed positioning (perfect layout, smaller payloads)\n"
"β€’ semantic: Semantic HTML (better text flow, larger payloads)\n"
"β€’ xhtml/html: MuPDF native rendering (1:1 layout)\n"
"β€’ image: Render each page as a raster image (no text extraction)"
)
pdf_render_desc.setStyleSheet("color: gray; font-size: 10pt;")
pdf_render_desc.setContentsMargins(20, 0, 0, 10)
section_v.addWidget(pdf_render_desc)
# Disable 0-based Chapter Detection
zero_detect_cb = self._create_styled_checkbox("Disable 0-based Chapter Detection")
try:
zero_detect_cb.setChecked(bool(self.disable_zero_detection_var))
except Exception:
pass
def _on_zero_detect_toggle(checked):
try:
self.disable_zero_detection_var = bool(checked)
except Exception:
pass
zero_detect_cb.toggled.connect(_on_zero_detect_toggle)
zero_detect_cb.setContentsMargins(0, 2, 0, 0)
section_v.addWidget(zero_detect_cb)
zero_detect_desc = QLabel("Always use chapter ranges as specified\n(don't force adjust to chapter 1)")
zero_detect_desc.setStyleSheet("color: gray; font-size: 10pt;")
zero_detect_desc.setContentsMargins(20, 0, 0, 10)
section_v.addWidget(zero_detect_desc)
# Use Header as Output Name
header_output_cb = self._create_styled_checkbox("Use Header as Output Name")
try:
header_output_cb.setChecked(bool(self.use_header_as_output_var))
except Exception:
pass
def _on_header_output_toggle(checked):
try:
self.use_header_as_output_var = bool(checked)
except Exception:
pass
header_output_cb.toggled.connect(_on_header_output_toggle)
header_output_cb.setContentsMargins(0, 2, 0, 0)
section_v.addWidget(header_output_cb)
header_output_desc = QLabel("Use chapter headers/titles as output filenames")
header_output_desc.setStyleSheet("color: gray; font-size: 10pt;")
header_output_desc.setContentsMargins(20, 0, 0, 10)
section_v.addWidget(header_output_desc)
# Separator
sep_opts1 = QFrame()
sep_opts1.setFrameShape(QFrame.HLine)
sep_opts1.setFrameShadow(QFrame.Sunken)
section_v.addWidget(sep_opts1)
# Chapter Number Offset
offset_w = QWidget()
offset_h = QHBoxLayout(offset_w)
offset_h.setContentsMargins(0, 5, 0, 0)
offset_h.addWidget(QLabel("Chapter Number Offset:"))
if not hasattr(self, 'chapter_number_offset_var'):
self.chapter_number_offset_var = str(self.config.get('chapter_number_offset', '0'))
offset_edit = QLineEdit()
offset_edit.setFixedWidth(60)
try:
offset_edit.setText(str(self.chapter_number_offset_var))
except Exception:
pass
def _on_offset_changed(text):
try:
self.chapter_number_offset_var = text
except Exception:
pass
offset_edit.textChanged.connect(_on_offset_changed)
offset_h.addWidget(offset_edit)
offset_h.addWidget(QLabel("(+/- adjustment)"))
offset_h.addStretch()
section_v.addWidget(offset_w)
offset_desc = QLabel("Adjust all chapter numbers by this amount.\nUseful for matching file numbers to actual chapters.")
offset_desc.setStyleSheet("color: gray; font-size: 10pt;")
offset_desc.setContentsMargins(20, 0, 0, 10)
section_v.addWidget(offset_desc)
# Separator
sep_opts2 = QFrame()
sep_opts2.setFrameShape(QFrame.HLine)
sep_opts2.setFrameShadow(QFrame.Sunken)
section_v.addWidget(sep_opts2)
# Post-Translation Scanning Phase
scan_w = QWidget()
scan_h = QHBoxLayout(scan_w)
scan_h.setContentsMargins(0, 10, 0, 0)
scan_cb = self._create_styled_checkbox("Enable post-translation Scanning phase")
try:
scan_cb.setChecked(bool(self.scan_phase_enabled_var))
except Exception:
pass
def _on_scan_toggle(checked):
try:
self.scan_phase_enabled_var = bool(checked)
except Exception:
pass
scan_cb.toggled.connect(_on_scan_toggle)
scan_h.addWidget(scan_cb)
scan_h.addSpacing(15)
scan_h.addWidget(QLabel("Mode:"))
scan_combo = QComboBox()
scan_combo.addItems(["quick-scan", "aggressive", "ai-hunter", "custom"])
scan_combo.setFixedWidth(120)
# Add custom styling with unicode arrow
scan_combo.setStyleSheet("""
QComboBox::down-arrow {
image: none;
width: 12px;
height: 12px;
border: none;
}
""")
self._add_combobox_arrow(scan_combo)
self._disable_combobox_mousewheel(scan_combo)
try:
mode_val = self.scan_phase_mode_var
idx = scan_combo.findText(mode_val)
if idx >= 0:
scan_combo.setCurrentIndex(idx)
except Exception:
pass
def _on_scan_mode_changed(text):
try:
self.scan_phase_mode_var = text
except Exception:
pass
scan_combo.currentTextChanged.connect(_on_scan_mode_changed)
scan_h.addWidget(scan_combo)
scan_h.addStretch()
section_v.addWidget(scan_w)
scan_desc = QLabel("Automatically run QA Scanner after translation completes")
scan_desc.setStyleSheet("color: gray; font-size: 10pt;")
scan_desc.setContentsMargins(20, 0, 0, 10)
section_v.addWidget(scan_desc)
# Batching Mode
batch_title = QLabel("Batching Mode")
batch_title.setStyleSheet("font-weight: bold; font-size: 11pt;")
batch_title.setContentsMargins(0, 8, 0, 4)
section_v.addWidget(batch_title)
from PySide6.QtWidgets import QButtonGroup, QRadioButton
batch_group = QButtonGroup(section_v)
batch_row = QWidget()
batch_row_h = QHBoxLayout(batch_row)
batch_row_h.setContentsMargins(0, 0, 0, 0)
conservative_rb = QRadioButton("Conservative batching")
direct_rb = QRadioButton("Direct batching")
aggressive_rb = QRadioButton("No batching")
batch_group.addButton(conservative_rb)
batch_group.addButton(direct_rb)
batch_group.addButton(aggressive_rb)
# Load current selection
current_mode = getattr(self, 'batch_mode_var', 'aggressive') or 'aggressive'
if current_mode == 'conservative':
conservative_rb.setChecked(True)
elif current_mode == 'direct':
direct_rb.setChecked(True)
else:
aggressive_rb.setChecked(True)
# Batch group size (multiplier) for conservative mode
batch_group_input = QLineEdit()
batch_group_input.setFixedWidth(50)
batch_group_input.setText(str(getattr(self, 'batch_group_size_var', '3')))
batch_group_input.setToolTip("Conservative mode multiplier applied to batch size")
def _set_mode(mode):
try:
self.batch_mode_var = mode
except Exception:
pass
conservative_rb.toggled.connect(lambda checked: _set_mode('conservative') if checked else None)
direct_rb.toggled.connect(lambda checked: _set_mode('direct') if checked else None)
aggressive_rb.toggled.connect(lambda checked: _set_mode('aggressive') if checked else None)
def _on_batch_group_change(text):
try:
self.batch_group_size_var = text
except Exception:
pass
batch_group_input.textChanged.connect(_on_batch_group_change)
batch_row_h.addWidget(conservative_rb)
batch_row_h.addSpacing(6)
batch_row_h.addWidget(batch_group_input)
batch_row_h.addWidget(QLabel("Γ— batch size"))
batch_row_h.addStretch()
section_v.addWidget(batch_row)
# Secondary row for other modes
mode_row = QWidget()
mode_row_h = QHBoxLayout(mode_row)
mode_row_h.setContentsMargins(0, 0, 0, 0)
mode_row_h.addWidget(direct_rb)
mode_row_h.addSpacing(12)
mode_row_h.addWidget(aggressive_rb)
mode_row_h.addStretch()
section_v.addWidget(mode_row)
batch_desc = QLabel("Direct: fixed batches of batch size.\nConservative: groups = batch size Γ— multiplier (set above).\nNo batching: keeps parallel slots full by opening a new request whenever one finishes.")
batch_desc.setStyleSheet("color: gray; font-size: 10pt;")
batch_desc.setContentsMargins(20, 0, 0, 10)
section_v.addWidget(batch_desc)
# Separator
sep_opts3 = QFrame()
sep_opts3.setFrameShape(QFrame.HLine)
sep_opts3.setFrameShadow(QFrame.Sunken)
section_v.addWidget(sep_opts3)
# API Safety Settings
safety_title = QLabel("API Safety Settings")
safety_title.setStyleSheet("font-weight: bold; font-size: 11pt;")
safety_title.setContentsMargins(0, 5, 0, 5)
section_v.addWidget(safety_title)
if not hasattr(self, 'disable_gemini_safety_var'):
self.disable_gemini_safety_var = self.config.get('disable_gemini_safety', True)
safety_cb = self._create_styled_checkbox("Disable API Safety Filters (Gemini, Groq, Fireworks, etc.)")
try:
safety_cb.setChecked(bool(self.disable_gemini_safety_var))
except Exception:
pass
def _on_safety_toggle(checked):
try:
self.disable_gemini_safety_var = bool(checked)
except Exception:
pass
safety_cb.toggled.connect(_on_safety_toggle)
safety_cb.setContentsMargins(0, 5, 0, 0)
section_v.addWidget(safety_cb)
safety_warning = QLabel("⚠️ Disables content safety filters for supported providers.\nGemini: Sets all harm categories to BLOCK_NONE.\nGroq/Fireworks: Disables moderation parameter.")
safety_warning.setStyleSheet("color: #ff6b6b; font-size: 9pt;")
safety_warning.setContentsMargins(20, 0, 0, 5)
section_v.addWidget(safety_warning)
safety_note = QLabel("Does NOT affect ElectronHub Gemini models (eh/gemini-*) or Together AI")
safety_note.setStyleSheet("color: gray; font-size: 8pt;")
safety_note.setContentsMargins(20, 0, 0, 8)
section_v.addWidget(safety_note)
# OpenRouter Transport Preference
if not hasattr(self, 'openrouter_http_only_var'):
self.openrouter_http_only_var = self.config.get('openrouter_use_http_only', False)
http_only_cb = self._create_styled_checkbox("Use HTTP-only for OpenRouter/NVIDIA (bypass SDK)")
try:
http_only_cb.setChecked(bool(self.openrouter_http_only_var))
except Exception:
pass
def _on_http_only_toggle(checked):
try:
self.openrouter_http_only_var = bool(checked)
except Exception:
pass
http_only_cb.toggled.connect(_on_http_only_toggle)
http_only_cb.setContentsMargins(0, 8, 0, 0)
section_v.addWidget(http_only_cb)
http_only_desc = QLabel("Requests to OpenRouter use direct HTTP POST with explicit headers")
http_only_desc.setStyleSheet("color: gray; font-size: 9pt;")
http_only_desc.setContentsMargins(20, 0, 0, 5)
section_v.addWidget(http_only_desc)
# OpenRouter Disable Compression
if not hasattr(self, 'openrouter_accept_identity_var'):
self.openrouter_accept_identity_var = self.config.get('openrouter_accept_identity', False)
accept_identity_cb = self._create_styled_checkbox("Disable compression for OpenRouter (Accept-Encoding)")
try:
accept_identity_cb.setChecked(bool(self.openrouter_accept_identity_var))
except Exception:
pass
def _on_accept_identity_toggle(checked):
try:
self.openrouter_accept_identity_var = bool(checked)
except Exception:
pass
accept_identity_cb.toggled.connect(_on_accept_identity_toggle)
accept_identity_cb.setContentsMargins(0, 4, 0, 0)
section_v.addWidget(accept_identity_cb)
accept_identity_desc = QLabel("Sends Accept-Encoding: identity to request uncompressed responses.\nUse if proxies/CDNs cause corrupted or non-JSON compressed bodies.")
accept_identity_desc.setStyleSheet("color: gray; font-size: 8pt;")
accept_identity_desc.setContentsMargins(20, 0, 0, 8)
section_v.addWidget(accept_identity_desc)
# OpenRouter: Provider preference
# Default to 'Auto' when missing or blank
if not hasattr(self, 'openrouter_preferred_provider_var'):
try:
v = self.config.get('openrouter_preferred_provider', 'Auto')
v = (v or '').strip() or 'Auto'
except Exception:
v = 'Auto'
self.openrouter_preferred_provider_var = v
# Keep config aligned so it won't come back blank next time
try:
self.config['openrouter_preferred_provider'] = v
except Exception:
pass
provider_w = QWidget()
provider_h = QHBoxLayout(provider_w)
provider_h.setContentsMargins(0, 4, 0, 0)
provider_h.addWidget(QLabel("Preferred OpenRouter Provider:"))
# Comprehensive list of OpenRouter providers (alphabetically sorted, with Auto first)
provider_options = [
'Auto', 'AI21', 'AionLabs', 'Alibaba Cloud Int.', 'Amazon Bedrock', 'Anthropic',
'AtlasCloud', 'Atoma', 'Avian.io', 'Azure', 'Baseten', 'Cerebras', 'Chutes',
'Cloudflare', 'Cohere', 'CrofAI', 'Crusoe', 'DeepInfra', 'DeepSeek', 'Enfer',
'Featherless', 'Fireworks', 'Friendli', 'GMICloud', 'Google AI Studio', 'Google Vertex',
'Groq', 'Hyperbolic', 'Inception', 'inference.net', 'Infermatic', 'Inflection',
'kluster.ai', 'Lambda', 'Lepton', 'Leschde', 'Liquid', 'Mancer (private)', 'Meta',
'Minimax', 'Mistral', 'Moonshot AI', 'Morph', 'nCompass', 'Nebius AI Studio',
'NextBit', 'Nineteen', 'NovitAI', 'NVIDIA', 'OpenAI', 'Open Inference', 'Parasail',
'Perplexity', 'Phala', 'Relace', 'SambaNova', 'SiliconFlow', 'Stealth', 'Switchpoint',
'Targon', 'Together', 'Ubicloud', 'Venice', 'Weights & Biases', 'xAI', 'Z.AI'
]
# Create combobox with autocomplete support (editable)
provider_combo = QComboBox()
provider_combo.setEditable(True)
provider_combo.addItems(provider_options)
provider_combo.setFixedWidth(160) # Reduced for more compact layout
# Add custom styling with unicode arrow
provider_combo.setStyleSheet("""
QComboBox::down-arrow {
image: none;
width: 12px;
height: 12px;
border: none;
}
""")
self._add_combobox_arrow(provider_combo)
self._disable_combobox_mousewheel(provider_combo)
try:
idx = provider_combo.findText(self.openrouter_preferred_provider_var)
if idx >= 0:
provider_combo.setCurrentIndex(idx)
else:
provider_combo.setCurrentText(self.openrouter_preferred_provider_var)
except Exception:
pass
def _on_provider_changed(text):
try:
self.openrouter_preferred_provider_var = text
except Exception:
pass
provider_combo.currentTextChanged.connect(_on_provider_changed)
provider_combo.lineEdit().textChanged.connect(_on_provider_changed)
provider_h.addWidget(provider_combo)
provider_h.addStretch()
# Store reference for potential autocomplete logic (Tkinter specific, may not be needed)
self.openrouter_provider_combo = provider_combo
self._provider_all_values = provider_options
self._provider_prev_text = self.openrouter_preferred_provider_var
section_v.addWidget(provider_w)
provider_desc = QLabel("Specify which upstream provider OpenRouter should prefer for your requests.\n'Auto' lets OpenRouter choose. Specific providers may have different availability.")
provider_desc.setStyleSheet("color: gray; font-size: 8pt;")
provider_desc.setContentsMargins(20, 0, 0, 8)
section_v.addWidget(provider_desc)
# Place the section at row 1, column 1 to match the original grid
try:
grid = parent.layout()
if grid:
grid.addWidget(section_box, 1, 1)
except Exception:
# Fallback: just stack
section_box.setParent(parent)
# Initial state - show/hide enhanced options
self.on_extraction_method_change()
def on_extraction_method_change(self):
"""Handle extraction method changes and show/hide Enhanced options"""
if hasattr(self, 'text_extraction_method_var') and hasattr(self, 'enhanced_options_frame'):
try:
# Qt version: use setVisible instead of pack/pack_forget
if self.text_extraction_method_var == 'enhanced':
self.enhanced_options_frame.setVisible(True)
else:
self.enhanced_options_frame.setVisible(False)
# Show/hide BS options frame (opposite of enhanced)
if hasattr(self, 'bs_options_frame'):
self.bs_options_frame.setVisible(self.text_extraction_method_var == 'standard')
except Exception:
# Fallback for any errors during transition
pass
def _enforce_image_output_dependency(self):
"""Enforce that image output mode is disabled when image translation is off,
unless using the special gemini-3-pro-image-preview model."""
try:
# Check model exception
model = str(getattr(self, 'model_var', '')).lower()
allow_without_translation = 'gemini-3-pro-image-preview' in model
# Check if image translation is enabled
image_translation_on = bool(getattr(self, 'enable_image_translation_var', False))
# Determine if image output should be allowed
allowed = image_translation_on or allow_without_translation
if not allowed:
# Force disable image output mode silently
self.enable_image_output_mode_var = False
except Exception:
pass
def _create_image_translation_section(self, parent):
"""Create image translation section (PySide6)"""
from PySide6.QtWidgets import QGroupBox, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox, QWidget, QLineEdit, QGridLayout
from PySide6.QtCore import Qt
section_box = QGroupBox("Image Translation & Vision API")
section_v = QVBoxLayout(section_box)
section_v.setContentsMargins(8, 8, 8, 8) # Compact margins
section_v.setSpacing(4) # Compact spacing between widgets
# Create horizontal container for two columns inside content container
columns_container = QWidget()
section_h = QHBoxLayout(columns_container)
section_h.setContentsMargins(0, 0, 0, 0)
section_h.setSpacing(10)
# Left column
left_column = QWidget()
left_v = QVBoxLayout(left_column)
left_v.setContentsMargins(0, 0, 20, 0)
# Enable Image Translation
enable_cb = self._create_styled_checkbox("Enable Image Translation")
try:
enable_cb.setChecked(bool(self.enable_image_translation_var))
except Exception:
pass
def _on_enable_image_toggle(checked):
try:
self.enable_image_translation_var = bool(checked)
self.toggle_image_translation_section()
# Enforce image output dependency when image translation changes
if hasattr(self, '_enforce_image_output_dependency'):
self._enforce_image_output_dependency()
except Exception:
pass
enable_cb.toggled.connect(_on_enable_image_toggle)
section_v.addWidget(enable_cb)
enable_desc = QLabel("Extracts and translates text from images using vision models")
enable_desc.setStyleSheet("color: gray; font-size: 10pt;")
enable_desc.setContentsMargins(0, 0, 0, 10)
section_v.addWidget(enable_desc)
# Create container for all content below the main checkbox
content_container = QWidget()
content_layout = QVBoxLayout(content_container)
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.setSpacing(4)
# Store reference for fade animation (everything below main checkbox)
self.image_translation_content = content_container
# Process Long Images
webnovel_cb = self._create_styled_checkbox("Process Long Images (Web Novel Style)")
try:
webnovel_cb.setChecked(bool(self.process_webnovel_images_var))
except Exception:
pass
def _on_webnovel_toggle(checked):
try:
self.process_webnovel_images_var = bool(checked)
except Exception:
pass
webnovel_cb.toggled.connect(_on_webnovel_toggle)
left_v.addWidget(webnovel_cb)
webnovel_desc = QLabel("Include tall images often used in web novels")
webnovel_desc.setStyleSheet("color: gray; font-size: 10pt;")
webnovel_desc.setContentsMargins(20, 0, 0, 10)
left_v.addWidget(webnovel_desc)
# Hide labels and remove OCR images
hide_cb = self._create_styled_checkbox("Hide labels and remove OCR images")
try:
hide_cb.setChecked(bool(self.hide_image_translation_label_var))
except Exception:
pass
def _on_hide_toggle(checked):
try:
self.hide_image_translation_label_var = bool(checked)
except Exception:
pass
hide_cb.toggled.connect(_on_hide_toggle)
left_v.addWidget(hide_cb)
hide_desc = QLabel("Clean mode: removes image and shows only translated text")
hide_desc.setStyleSheet("color: gray; font-size: 10pt;")
hide_desc.setContentsMargins(20, 0, 0, 10)
left_v.addWidget(hide_desc)
# Enable Image Output Mode
image_output_cb = self._create_styled_checkbox("Enable Image Output Mode")
try:
image_output_cb.setChecked(bool(self.enable_image_output_mode_var))
except Exception:
pass
def _on_image_output_toggle(checked):
try:
self.enable_image_output_mode_var = bool(checked)
# Save to config
self.config['enable_image_output_mode'] = bool(checked)
except Exception:
pass
image_output_cb.toggled.connect(_on_image_output_toggle)
left_v.addWidget(image_output_cb)
image_output_desc = QLabel("Request image output from vision models (e.g. gemini-3-pro-image-preview)")
image_output_desc.setStyleSheet("color: gray; font-size: 10pt;")
image_output_desc.setContentsMargins(20, 0, 0, 5)
left_v.addWidget(image_output_desc)
# Image Output Resolution dropdown
resolution_w = QWidget()
resolution_h = QHBoxLayout(resolution_w)
resolution_h.setContentsMargins(20, 0, 0, 0)
resolution_h.setSpacing(8)
resolution_h.addWidget(QLabel("Output Resolution:"))
resolution_combo = QComboBox()
resolution_combo.addItems(["1K", "2K", "4K"])
resolution_combo.setFixedWidth(80)
resolution_combo.setStyleSheet("""
QComboBox::down-arrow {
image: none;
width: 12px;
height: 12px;
border: none;
}
""")
self._add_combobox_arrow(resolution_combo)
self._disable_combobox_mousewheel(resolution_combo)
# Initialize variable if not exists
if not hasattr(self, 'image_output_resolution_var'):
self.image_output_resolution_var = self.config.get('image_output_resolution', '1K')
try:
idx = resolution_combo.findText(self.image_output_resolution_var)
if idx >= 0:
resolution_combo.setCurrentIndex(idx)
except Exception:
pass
def _on_resolution_changed(text):
try:
self.image_output_resolution_var = text
except Exception:
pass
resolution_combo.currentTextChanged.connect(_on_resolution_changed)
resolution_h.addWidget(resolution_combo)
resolution_h.addStretch()
left_v.addWidget(resolution_w)
resolution_desc = QLabel("Higher resolution = better quality but slower generation")
resolution_desc.setStyleSheet("color: gray; font-size: 10pt;")
resolution_desc.setContentsMargins(40, 0, 0, 10)
left_v.addWidget(resolution_desc)
left_v.addSpacing(10)
# Watermark Removal
watermark_cb = self._create_styled_checkbox("Enable Watermark Removal")
try:
watermark_cb.setChecked(bool(self.enable_watermark_removal_var))
except Exception:
pass
def _on_watermark_toggle(checked):
try:
self.enable_watermark_removal_var = bool(checked)
_toggle_watermark_options()
except Exception:
pass
watermark_cb.toggled.connect(_on_watermark_toggle)
left_v.addWidget(watermark_cb)
watermark_desc = QLabel("Advanced preprocessing to remove watermarks from images")
watermark_desc.setStyleSheet("color: gray; font-size: 10pt;")
watermark_desc.setContentsMargins(20, 0, 0, 10)
left_v.addWidget(watermark_desc)
# Save Cleaned Images
self.save_cleaned_checkbox = self._create_styled_checkbox("Save Cleaned Images")
try:
self.save_cleaned_checkbox.setChecked(bool(self.save_cleaned_images_var))
except Exception:
pass
def _on_save_cleaned_toggle(checked):
try:
self.save_cleaned_images_var = bool(checked)
except Exception:
pass
self.save_cleaned_checkbox.toggled.connect(_on_save_cleaned_toggle)
self.save_cleaned_checkbox.setContentsMargins(20, 0, 0, 0)
left_v.addWidget(self.save_cleaned_checkbox)
save_desc = QLabel("Keep watermark-removed images in translated_images/cleaned/")
save_desc.setStyleSheet("color: gray; font-size: 10pt;")
save_desc.setContentsMargins(40, 0, 0, 10)
left_v.addWidget(save_desc)
# Advanced Watermark Removal
self.advanced_watermark_checkbox = self._create_styled_checkbox("Advanced Watermark Removal")
try:
self.advanced_watermark_checkbox.setChecked(bool(self.advanced_watermark_removal_var))
except Exception:
pass
def _on_advanced_watermark_toggle(checked):
try:
self.advanced_watermark_removal_var = bool(checked)
except Exception:
pass
self.advanced_watermark_checkbox.toggled.connect(_on_advanced_watermark_toggle)
self.advanced_watermark_checkbox.setContentsMargins(20, 0, 0, 0)
left_v.addWidget(self.advanced_watermark_checkbox)
advanced_desc = QLabel("Use FFT-based pattern detection for stubborn watermarks")
advanced_desc.setStyleSheet("color: gray; font-size: 10pt;")
advanced_desc.setContentsMargins(40, 0, 0, 0)
left_v.addWidget(advanced_desc)
left_v.addStretch()
section_h.addWidget(left_column)
# Right column
right_column = QWidget()
right_v = QVBoxLayout(right_column)
right_v.setContentsMargins(0, 0, 0, 0)
# Settings grid
settings_w = QWidget()
settings_grid = QGridLayout(settings_w)
settings_grid.setContentsMargins(0, 0, 0, 0)
settings = [
("Min Image height (px):", self.webnovel_min_height_var, False),
("Max Images per chapter:", self.max_images_per_chapter_var, False),
("Chunk height:", self.image_chunk_height_var, False),
("Chunk overlap (%):", self.image_chunk_overlap_var, True)
]
for row, (label, var, has_tip) in enumerate(settings):
lbl = QLabel(label)
settings_grid.addWidget(lbl, row, 0, Qt.AlignLeft)
entry = QLineEdit()
entry.setFixedWidth(80)
try:
entry.setText(str(var))
except Exception:
pass
def _make_entry_callback(var_name):
def _cb(text):
try:
setattr(self, var_name, text)
except Exception:
pass
return _cb
# Extract variable name from var reference
var_name = None
for name in ['webnovel_min_height_var', 'max_images_per_chapter_var', 'image_chunk_height_var', 'image_chunk_overlap_var']:
if hasattr(self, name) and getattr(self, name) is var:
var_name = name
break
if var_name:
entry.textChanged.connect(_make_entry_callback(var_name))
settings_grid.addWidget(entry, row, 1, Qt.AlignLeft)
right_v.addWidget(settings_w)
right_v.addSpacing(15)
# Send tall image chunks in single API call
single_api_cb = self._create_styled_checkbox("Send tall image chunks in single API call (NOT RECOMMENDED)")
try:
single_api_cb.setChecked(bool(self.single_api_image_chunks_var))
except Exception:
pass
def _on_single_api_toggle(checked):
try:
self.single_api_image_chunks_var = bool(checked)
except Exception:
pass
single_api_cb.toggled.connect(_on_single_api_toggle)
right_v.addWidget(single_api_cb)
single_api_desc = QLabel("All image chunks sent to 1 API call (Most AI models don't like this)")
single_api_desc.setStyleSheet("color: gray; font-size: 10pt;")
single_api_desc.setContentsMargins(20, 0, 0, 10)
right_v.addWidget(single_api_desc)
models_info = QLabel("πŸ’‘ Supported models:\nβ€’ Gemini 1.5 Pro/Flash, 2.0 Flash\nβ€’ GPT-4V, GPT-4o, o4-mini")
models_info.setStyleSheet("color: #666; font-size: 10pt;")
models_info.setContentsMargins(0, 10, 0, 0)
right_v.addWidget(models_info)
# Configuration buttons section
right_v.addSpacing(20)
config_title = QLabel("Advanced Configuration:")
config_title.setStyleSheet("font-weight: bold; font-size: 10pt;")
right_v.addWidget(config_title)
# Image chunk prompt button
btn_image_chunk = QPushButton("βš™οΈ Configure Image Chunk Prompt")
btn_image_chunk.setFixedWidth(250)
btn_image_chunk.clicked.connect(lambda: self.configure_image_chunk_prompt())
right_v.addWidget(btn_image_chunk)
btn_image_chunk_desc = QLabel("Configure context for tall image chunks")
btn_image_chunk_desc.setStyleSheet("color: gray; font-size: 9pt;")
btn_image_chunk_desc.setContentsMargins(0, 0, 0, 5)
right_v.addWidget(btn_image_chunk_desc)
# Image compression button
btn_compression = QPushButton("πŸ—œοΈ Configure Image Compression")
btn_compression.setFixedWidth(250)
btn_compression.clicked.connect(lambda: self.configure_image_compression())
right_v.addWidget(btn_compression)
btn_compression_desc = QLabel("Optimize images for API token efficiency")
btn_compression_desc.setStyleSheet("color: gray; font-size: 9pt;")
btn_compression_desc.setContentsMargins(0, 0, 0, 5)
right_v.addWidget(btn_compression_desc)
right_v.addStretch()
section_h.addWidget(right_column)
# Add the columns container to the content container
content_layout.addWidget(columns_container)
# Add the content container (everything below main checkbox) to main section
section_v.addWidget(content_container)
# Dependency logic for watermark options
def _toggle_watermark_options():
try:
enabled = bool(self.enable_watermark_removal_var)
self.save_cleaned_checkbox.setEnabled(enabled)
self.advanced_watermark_checkbox.setEnabled(enabled)
if not enabled:
self.save_cleaned_images_var = False
self.advanced_watermark_removal_var = False
except Exception:
pass
# Call once to set initial state
_toggle_watermark_options()
# Initialize image translation section visibility
self.toggle_image_translation_section()
# Place the section at row 2, spanning both columns
try:
grid = parent.layout()
if grid:
grid.addWidget(section_box, 2, 0, 1, 2)
except Exception:
# Fallback: just stack
section_box.setParent(parent)
def on_extraction_mode_change(self):
"""Handle extraction mode changes and show/hide Enhanced options"""
try:
# Qt version: use setVisible instead of pack/pack_forget
if self.extraction_mode_var == 'enhanced':
if hasattr(self, 'enhanced_options_separator'):
self.enhanced_options_separator.setVisible(True)
if hasattr(self, 'enhanced_options_frame'):
self.enhanced_options_frame.setVisible(True)
else:
if hasattr(self, 'enhanced_options_separator'):
self.enhanced_options_separator.setVisible(False)
if hasattr(self, 'enhanced_options_frame'):
self.enhanced_options_frame.setVisible(False)
# Show BS toggle when not enhanced
if hasattr(self, '_bs_empty_attr_cb'):
_bs_vis = self.extraction_mode_var != 'enhanced'
self._bs_empty_attr_cb.setVisible(_bs_vis)
if hasattr(self, '_bs_empty_attr_desc'):
self._bs_empty_attr_desc.setVisible(_bs_vis)
except Exception:
# Fallback for any errors during transition
pass
def _create_anti_duplicate_section(self, parent):
"""Create comprehensive anti-duplicate parameter controls with tabs (PySide6)"""
from PySide6.QtWidgets import QGroupBox, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox, QWidget, QLineEdit, QTabWidget, QSlider, QPushButton
from PySide6.QtCore import Qt
section_box = QGroupBox("🎯 Anti-Duplicate Parameters")
section_v = QVBoxLayout(section_box)
# Description
desc_label = QLabel("Configure parameters to reduce duplicate translations across all AI providers.")
desc_label.setStyleSheet("color: gray; font-size: 9pt;")
desc_label.setWordWrap(True)
desc_label.setMaximumWidth(520)
desc_label.setContentsMargins(0, 0, 0, 10)
section_v.addWidget(desc_label)
# Enable/Disable toggle
self.enable_anti_duplicate_var = self.config.get('enable_anti_duplicate', False)
enable_cb = self._create_styled_checkbox("Enable Anti-Duplicate Parameters")
try:
enable_cb.setChecked(bool(self.enable_anti_duplicate_var))
except Exception:
pass
def _on_enable_anti_dup_toggle(checked):
try:
self.enable_anti_duplicate_var = bool(checked)
self._toggle_anti_duplicate_controls()
except Exception:
pass
enable_cb.toggled.connect(_on_enable_anti_dup_toggle)
enable_cb.setContentsMargins(0, 0, 0, 10)
section_v.addWidget(enable_cb)
# Create container for all content below the main checkbox
content_container = QWidget()
content_layout = QVBoxLayout(content_container)
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.setSpacing(4)
# Store reference for slide animation
self.anti_duplicate_content = content_container
# Create tab widget for organized parameters
self.anti_duplicate_notebook = QTabWidget()
# Enhanced tab styling
self.anti_duplicate_notebook.setStyleSheet("""
QTabWidget::pane {
border: 1px solid #555;
background-color: #2d2d2d;
border-top-left-radius: 0px;
border-top-right-radius: 4px;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
QTabWidget::tab-bar {
left: 5px;
}
QTabBar::tab {
background-color: #404040;
color: #cccccc;
padding: 8px 16px;
margin-right: 2px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
min-width: 80px;
font-weight: 500;
}
QTabBar::tab:selected {
background-color: #2d2d2d;
color: #ffffff;
border-bottom: 2px solid #0078d4;
font-weight: bold;
}
QTabBar::tab:hover:!selected {
background-color: #4a4a4a;
color: #ffffff;
}
QTabBar::tab:first {
margin-left: 0;
}
""")
content_layout.addWidget(self.anti_duplicate_notebook)
# Tab 1: Core Parameters
core_frame = QWidget()
core_v = QVBoxLayout(core_frame)
core_v.setContentsMargins(10, 10, 10, 10)
self.anti_duplicate_notebook.addTab(core_frame, "Core Parameters")
# Top-P (Nucleus Sampling)
def _create_slider_row(parent_layout, label_text, var_holder, var_name, min_val, max_val, decimals=2, is_int=False):
"""Helper to create a slider row with label and value display
var_holder: object that holds the variable (typically self)
var_name: string name of the attribute to set
"""
row_w = QWidget()
row_h = QHBoxLayout(row_w)
row_h.setContentsMargins(0, 5, 0, 5)
lbl = QLabel(label_text)
lbl.setFixedWidth(200)
row_h.addWidget(lbl)
slider = QSlider(Qt.Horizontal)
if is_int:
slider.setMinimum(int(min_val))
slider.setMaximum(int(max_val))
try:
current_val = getattr(var_holder, var_name, min_val)
slider.setValue(int(current_val))
except Exception:
pass
else:
# For double values, use int slider and scale
steps = int((max_val - min_val) / (0.01 if decimals == 2 else 0.1))
slider.setMinimum(0)
slider.setMaximum(steps)
try:
current = float(getattr(var_holder, var_name, min_val))
slider.setValue(int((current - min_val) / (max_val - min_val) * steps))
except Exception:
pass
slider.setFixedWidth(200)
# Disable mousewheel scrolling on slider
slider.wheelEvent = lambda event: None
row_h.addWidget(slider)
value_lbl = QLabel("")
value_lbl.setFixedWidth(80)
row_h.addWidget(value_lbl)
row_h.addStretch()
def _on_slider_change(value):
try:
if is_int:
actual_value = value
setattr(var_holder, var_name, actual_value)
if actual_value == 0:
value_lbl.setText("OFF")
else:
value_lbl.setText(f"{actual_value}")
else:
actual_value = min_val + (value / slider.maximum()) * (max_val - min_val)
setattr(var_holder, var_name, actual_value)
if decimals == 1:
value_lbl.setText(f"{actual_value:.1f}" if actual_value > 0 else "OFF")
else:
value_lbl.setText(f"{actual_value:.2f}")
except Exception:
pass
slider.valueChanged.connect(_on_slider_change)
# Trigger initial update
_on_slider_change(slider.value())
parent_layout.addWidget(row_w)
return slider, value_lbl
self.top_p_var = self.config.get('top_p', 1.0)
top_p_slider, self.top_p_value_label = _create_slider_row(core_v, "Top-P (Nucleus Sampling):", self, 'top_p_var', 0.1, 1.0, decimals=2)
# Top-K (Vocabulary Limit)
self.top_k_var = self.config.get('top_k', 0)
top_k_slider, self.top_k_value_label = _create_slider_row(core_v, "Top-K (Vocabulary Limit):", self, 'top_k_var', 0, 100, is_int=True)
# Frequency Penalty
self.frequency_penalty_var = self.config.get('frequency_penalty', 0.0)
freq_slider, self.freq_penalty_value_label = _create_slider_row(core_v, "Frequency Penalty:", self, 'frequency_penalty_var', 0.0, 2.0, decimals=2)
# Presence Penalty
self.presence_penalty_var = self.config.get('presence_penalty', 0.0)
pres_slider, self.pres_penalty_value_label = _create_slider_row(core_v, "Presence Penalty:", self, 'presence_penalty_var', 0.0, 2.0, decimals=2)
core_v.addStretch()
# Tab 2: Advanced Parameters
advanced_frame = QWidget()
advanced_v = QVBoxLayout(advanced_frame)
advanced_v.setContentsMargins(10, 10, 10, 10)
self.anti_duplicate_notebook.addTab(advanced_frame, "Advanced")
# Repetition Penalty
self.repetition_penalty_var = self.config.get('repetition_penalty', 1.0)
rep_slider, self.rep_penalty_value_label = _create_slider_row(advanced_v, "Repetition Penalty:", self, 'repetition_penalty_var', 1.0, 2.0, decimals=2)
# Candidate Count (Gemini)
self.candidate_count_var = self.config.get('candidate_count', 1)
candidate_slider, self.candidate_value_label = _create_slider_row(advanced_v, "Candidate Count (Gemini):", self, 'candidate_count_var', 1, 4, is_int=True)
advanced_v.addStretch()
# Tab 3: Stop Sequences
stop_frame = QWidget()
stop_v = QVBoxLayout(stop_frame)
stop_v.setContentsMargins(10, 10, 10, 10)
self.anti_duplicate_notebook.addTab(stop_frame, "Stop Sequences")
# Custom Stop Sequences
stop_row = QWidget()
stop_h = QHBoxLayout(stop_row)
stop_h.setContentsMargins(0, 5, 0, 5)
stop_h.addWidget(QLabel("Custom Stop Sequences:"))
self.custom_stop_sequences_var = self.config.get('custom_stop_sequences', '')
stop_entry = QLineEdit()
stop_entry.setFixedWidth(300)
try:
stop_entry.setText(str(self.custom_stop_sequences_var))
except Exception:
pass
def _on_stop_seq_changed(text):
try:
self.custom_stop_sequences_var = text
except Exception:
pass
stop_entry.textChanged.connect(_on_stop_seq_changed)
stop_h.addWidget(stop_entry)
stop_tip = QLabel("(comma-separated)")
stop_tip.setStyleSheet("color: gray; font-size: 8pt;")
stop_h.addWidget(stop_tip)
stop_h.addStretch()
stop_v.addWidget(stop_row)
stop_v.addStretch()
# Tab 4: Logit Bias (OpenAI)
bias_frame = QWidget()
bias_v = QVBoxLayout(bias_frame)
bias_v.setContentsMargins(10, 10, 10, 10)
self.anti_duplicate_notebook.addTab(bias_frame, "Logit Bias")
# Logit Bias Enable
self.logit_bias_enabled_var = self.config.get('logit_bias_enabled', False)
bias_cb = self._create_styled_checkbox("Enable Logit Bias (OpenAI only)")
try:
bias_cb.setChecked(bool(self.logit_bias_enabled_var))
except Exception:
pass
def _on_bias_enable_toggle(checked):
try:
self.logit_bias_enabled_var = bool(checked)
except Exception:
pass
bias_cb.toggled.connect(_on_bias_enable_toggle)
bias_cb.setContentsMargins(0, 0, 0, 5)
bias_v.addWidget(bias_cb)
# Logit Bias Strength
self.logit_bias_strength_var = self.config.get('logit_bias_strength', -0.5)
bias_slider, self.bias_strength_value_label = _create_slider_row(bias_v, "Bias Strength:", self, 'logit_bias_strength_var', -2.0, 2.0, decimals=1)
# Preset bias targets
preset_title = QLabel("Preset Bias Targets:")
preset_title.setStyleSheet("font-weight: bold; font-size: 9pt;")
preset_title.setContentsMargins(0, 10, 0, 5)
bias_v.addWidget(preset_title)
self.bias_common_words_var = self.config.get('bias_common_words', False)
common_cb = self._create_styled_checkbox("Bias against common words (the, and, said)")
try:
common_cb.setChecked(bool(self.bias_common_words_var))
except Exception:
pass
def _on_common_toggle(checked):
try:
self.bias_common_words_var = bool(checked)
except Exception:
pass
common_cb.toggled.connect(_on_common_toggle)
bias_v.addWidget(common_cb)
self.bias_repetitive_phrases_var = self.config.get('bias_repetitive_phrases', False)
phrases_cb = self._create_styled_checkbox("Bias against repetitive phrases")
try:
phrases_cb.setChecked(bool(self.bias_repetitive_phrases_var))
except Exception:
pass
def _on_phrases_toggle(checked):
try:
self.bias_repetitive_phrases_var = bool(checked)
except Exception:
pass
phrases_cb.toggled.connect(_on_phrases_toggle)
bias_v.addWidget(phrases_cb)
bias_v.addStretch()
# Provider compatibility info
compat_title = QLabel("Parameter Compatibility:")
compat_title.setStyleSheet("font-weight: bold; font-size: 9pt;")
compat_title.setContentsMargins(0, 15, 0, 0)
content_layout.addWidget(compat_title)
compat_text = QLabel("β€’ Core: Most providers β€’ Advanced: DeepSeek, Mistral, Groq β€’ Logit Bias: OpenAI only")
compat_text.setStyleSheet("color: gray; font-size: 8pt;")
compat_text.setContentsMargins(0, 5, 0, 0)
content_layout.addWidget(compat_text)
# Reset button
reset_row = QWidget()
reset_h = QHBoxLayout(reset_row)
reset_h.setContentsMargins(0, 10, 0, 0)
reset_btn = QPushButton("πŸ”„ Reset to Defaults")
reset_btn.setFixedWidth(180)
reset_btn.clicked.connect(lambda: self._reset_anti_duplicate_defaults())
reset_h.addWidget(reset_btn)
reset_desc = QLabel("Reset all anti-duplicate parameters to default values")
reset_desc.setStyleSheet("color: gray; font-size: 8pt;")
reset_h.addWidget(reset_desc)
reset_h.addStretch()
content_layout.addWidget(reset_row)
# Add content container to main section
section_v.addWidget(content_container)
# Store all tab frames for enable/disable
self.anti_duplicate_tabs = [core_frame, advanced_frame, stop_frame, bias_frame]
# Place the section at row 6, spanning both columns
try:
grid = parent.layout()
if grid:
grid.addWidget(section_box, 6, 0, 1, 2)
except Exception:
# Fallback: just stack
section_box.setParent(parent)
# Initialize anti-duplicate section visibility
self.toggle_anti_duplicate_section()
def toggle_anti_duplicate_section(self):
"""Toggle visibility of anti-duplicate content with smooth fade"""
try:
if not hasattr(self, 'anti_duplicate_content'):
return
enabled = bool(self.enable_anti_duplicate_var)
# Import animation components
from PySide6.QtCore import QPropertyAnimation, QEasingCurve
from PySide6.QtWidgets import QGraphicsOpacityEffect
# Stop any existing animation
if hasattr(self, '_anti_duplicate_animation') and self._anti_duplicate_animation:
self._anti_duplicate_animation.stop()
# Ensure widget is visible for animation
self.anti_duplicate_content.setVisible(True)
# Create or get opacity effect
if not hasattr(self.anti_duplicate_content, '_opacity_effect'):
effect = QGraphicsOpacityEffect()
self.anti_duplicate_content.setGraphicsEffect(effect)
self.anti_duplicate_content._opacity_effect = effect
else:
effect = self.anti_duplicate_content._opacity_effect
# Create opacity animation
animation = QPropertyAnimation(effect, b"opacity")
animation.setDuration(150) # Faster for no glitch
animation.setEasingCurve(QEasingCurve.Type.OutCubic)
if enabled:
# Fade in
animation.setStartValue(0.0)
animation.setEndValue(1.0)
else:
# Fade out
animation.setStartValue(1.0)
animation.setEndValue(0.0)
# Hide after fade out
animation.finished.connect(
lambda: self.anti_duplicate_content.setVisible(False) if not enabled else None
)
self._anti_duplicate_animation = animation
animation.start()
except Exception:
# Fallback to simple show/hide if animation fails
try:
if hasattr(self, 'anti_duplicate_content'):
enabled = bool(self.enable_anti_duplicate_var)
self.anti_duplicate_content.setVisible(enabled)
except Exception:
pass
def _toggle_anti_duplicate_controls(self):
"""Enable/disable anti-duplicate parameter controls (Qt version)"""
# Call the slide animation function
self.toggle_anti_duplicate_section()
def _toggle_http_tuning_controls(self):
"""Enable/disable the HTTP timeout/pooling controls as a group with proper styling (Qt version)"""
try:
enabled = bool(self.enable_http_tuning_var) if hasattr(self, 'enable_http_tuning_var') else False
except Exception:
enabled = False
# Entry fields
for attr in ['connect_timeout_entry', 'read_timeout_entry', 'http_pool_connections_entry', 'http_pool_maxsize_entry']:
widget = getattr(self, attr, None)
if widget is not None:
try:
widget.setEnabled(enabled)
except Exception:
pass
# Labels with proper disabled styling
label_attrs = [
'connect_timeout_label', 'read_timeout_label',
'http_pool_connections_label', 'http_pool_maxsize_label'
]
for attr in label_attrs:
widget = getattr(self, attr, None)
if widget is not None:
try:
widget.setEnabled(enabled)
# Apply proper disabled state styling
color = "white" if enabled else "#808080"
widget.setStyleSheet(f"color: {color};")
except Exception:
pass
# Retry-After checkbox
if hasattr(self, 'ignore_retry_after_checkbox') and self.ignore_retry_after_checkbox is not None:
try:
self.ignore_retry_after_checkbox.setEnabled(enabled)
except Exception:
pass
def _reset_anti_duplicate_defaults(self):
"""Reset all anti-duplicate parameters to their default values (Qt version)"""
from PySide6.QtWidgets import QMessageBox
# Ask for confirmation
reply = QMessageBox.question(
None,
"Reset Anti-Duplicate Parameters",
"Are you sure you want to reset all anti-duplicate parameters to their default values?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply != QMessageBox.Yes:
return
# Reset all variables to defaults
if hasattr(self, 'enable_anti_duplicate_var'):
self.enable_anti_duplicate_var = False
if hasattr(self, 'top_p_var'):
self.top_p_var = 1.0 # Default = no effect
if hasattr(self, 'top_k_var'):
self.top_k_var = 0 # Default = disabled
if hasattr(self, 'frequency_penalty_var'):
self.frequency_penalty_var = 0.0 # Default = no penalty
if hasattr(self, 'presence_penalty_var'):
self.presence_penalty_var = 0.0 # Default = no penalty
if hasattr(self, 'repetition_penalty_var'):
self.repetition_penalty_var = 1.0 # Default = no penalty
if hasattr(self, 'candidate_count_var'):
self.candidate_count_var = 1 # Default = single response
if hasattr(self, 'custom_stop_sequences_var'):
self.custom_stop_sequences_var = "" # Default = empty
if hasattr(self, 'logit_bias_enabled_var'):
self.logit_bias_enabled_var = False # Default = disabled
if hasattr(self, 'logit_bias_strength_var'):
self.logit_bias_strength_var = -0.5 # Default strength
if hasattr(self, 'bias_common_words_var'):
self.bias_common_words_var = False # Default = disabled
if hasattr(self, 'bias_repetitive_phrases_var'):
self.bias_repetitive_phrases_var = False # Default = disabled
# Update enable/disable state
self._toggle_anti_duplicate_controls()
# Show success message
from PySide6.QtWidgets import QMessageBox
QMessageBox.information(None, "Reset Complete", "All anti-duplicate parameters have been reset to their default values.")
# Log the reset
if hasattr(self, 'append_log'):
self.append_log("πŸ”„ Anti-duplicate parameters reset to defaults")
def _create_custom_api_endpoints_section(self, parent_frame):
"""Create the Custom API Endpoints section (PySide6)"""
from PySide6.QtWidgets import QGroupBox, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox, QWidget, QLineEdit, QPushButton, QComboBox
from PySide6.QtCore import Qt
section_box = QGroupBox("Custom API Endpoints")
section_v = QVBoxLayout(section_box)
section_v.setContentsMargins(8, 8, 8, 8) # Compact margins
section_v.setSpacing(4) # Compact spacing between widgets
# Checkbox to enable/disable custom endpoint
enable_cb = self._create_styled_checkbox("Enable Custom OpenAI Endpoint")
try:
enable_cb.setChecked(bool(self.use_custom_openai_endpoint_var))
except Exception:
pass
def _on_enable_custom_endpoint(checked):
try:
self.use_custom_openai_endpoint_var = bool(checked)
self.toggle_custom_endpoint_ui(user_interaction=True)
except Exception:
pass
enable_cb.toggled.connect(_on_enable_custom_endpoint)
section_v.addWidget(enable_cb)
# Main OpenAI Base URL
openai_row = QWidget()
openai_h = QHBoxLayout(openai_row)
openai_h.setContentsMargins(0, 5, 0, 5)
openai_h.addWidget(QLabel("Override API Endpoint:"))
self.openai_base_url_var = self.config.get('openai_base_url', '')
self.openai_base_url_entry = QLineEdit()
try:
self.openai_base_url_entry.setText(str(self.openai_base_url_var))
except Exception:
pass
def _on_openai_url_changed(text):
try:
self.openai_base_url_var = text
self._check_azure_endpoint()
except Exception:
pass
self.openai_base_url_entry.textChanged.connect(_on_openai_url_changed)
openai_h.addWidget(self.openai_base_url_entry)
self.openai_clear_button = QPushButton("Clear")
self.openai_clear_button.setFixedWidth(80)
def _clear_openai_url():
self.openai_base_url_var = ""
self.openai_base_url_entry.setText("")
self.openai_clear_button.clicked.connect(_clear_openai_url)
openai_h.addWidget(self.openai_clear_button)
section_v.addWidget(openai_row)
# Help text
help_lbl = QLabel("Enable checkbox to use custom endpoint. For Ollama: <a href='http://localhost:11434/v1'>http://localhost:11434/v1</a>")
help_lbl.setStyleSheet("color: gray; font-size: 8pt;")
help_lbl.setContentsMargins(0, 0, 0, 10)
help_lbl.setTextFormat(Qt.RichText)
help_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse)
help_lbl.setOpenExternalLinks(False)
help_lbl.setToolTip("Double-click the URL to copy it")
try:
from PySide6.QtGui import QGuiApplication
from PySide6.QtCore import QTimer, QEvent
orig_style = help_lbl.styleSheet()
orig_text = help_lbl.text()
def _copy_ollama_url(_link=None):
try:
QGuiApplication.clipboard().setText("http://localhost:11434/v1")
except Exception:
pass
def _flash_feedback():
try:
help_lbl.setStyleSheet(orig_style + " color: #00d084;")
help_lbl.setText("Copied: http://localhost:11434/v1")
QTimer.singleShot(900, lambda: (help_lbl.setStyleSheet(orig_style), help_lbl.setText(orig_text)))
except Exception:
pass
def _dbl_click(event):
_copy_ollama_url()
_flash_feedback()
help_lbl.mouseDoubleClickEvent = _dbl_click
except Exception:
pass
section_v.addWidget(help_lbl)
# Azure version frame (initially hidden)
self.azure_version_frame = QWidget()
azure_h = QHBoxLayout(self.azure_version_frame)
azure_h.setContentsMargins(0, 0, 0, 10)
azure_h.addWidget(QLabel("Azure API Version:"))
# Azure API version combo
try:
self.azure_api_version_var = self.config.get('azure_api_version', '2024-08-01-preview')
except Exception:
pass
versions = [
'2025-01-01-preview', '2024-12-01-preview', '2024-10-01-preview',
'2024-08-01-preview', '2024-06-01', '2024-05-01-preview',
'2024-04-01-preview', '2024-02-01', '2023-12-01-preview',
'2023-10-01-preview', '2023-05-15'
]
self.azure_version_combo = QComboBox()
self.azure_version_combo.addItems(versions)
self.azure_version_combo.setFixedWidth(200)
# Add custom styling with unicode arrow
self.azure_version_combo.setStyleSheet("""
QComboBox::down-arrow {
image: none;
width: 12px;
height: 12px;
border: none;
}
""")
self._add_combobox_arrow(self.azure_version_combo)
self._disable_combobox_mousewheel(self.azure_version_combo)
try:
idx = self.azure_version_combo.findText(self.azure_api_version_var)
if idx >= 0:
self.azure_version_combo.setCurrentIndex(idx)
except Exception:
pass
def _on_azure_version_changed(text):
try:
self.azure_api_version_var = text
self._update_azure_api_version_env()
except Exception:
pass
self.azure_version_combo.currentTextChanged.connect(_on_azure_version_changed)
azure_h.addWidget(self.azure_version_combo)
azure_h.addStretch()
self.azure_version_frame.setVisible(False) # Initially hidden
section_v.addWidget(self.azure_version_frame)
# Show More Fields button
self.show_more_endpoints = False
self.more_fields_button = QPushButton("β–Ό Show More Fields")
self.more_fields_button.setStyleSheet("text-align: left; border: none; color: #0dcaf0;")
self.more_fields_button.clicked.connect(lambda: self.toggle_more_endpoints())
section_v.addWidget(self.more_fields_button)
# Add spacing after Show More Fields button to prevent accidental clicks
section_v.addSpacing(15)
# Container for additional fields (initially hidden)
self.additional_endpoints_frame = QWidget()
additional_v = QVBoxLayout(self.additional_endpoints_frame)
additional_v.setContentsMargins(0, 0, 0, 0)
self.additional_endpoints_frame.setVisible(False) # Initially hidden
# Groq/Local Base URL
groq_row = QWidget()
groq_h = QHBoxLayout(groq_row)
groq_h.setContentsMargins(0, 5, 0, 5)
groq_h.addWidget(QLabel("Groq/Local Base URL:"))
self.groq_base_url_var = self.config.get('groq_base_url', '')
self.groq_base_url_entry = QLineEdit()
try:
self.groq_base_url_entry.setText(str(self.groq_base_url_var))
except Exception:
pass
def _on_groq_url_changed(text):
try:
self.groq_base_url_var = text
except Exception:
pass
self.groq_base_url_entry.textChanged.connect(_on_groq_url_changed)
groq_h.addWidget(self.groq_base_url_entry)
groq_clear_btn = QPushButton("Clear")
groq_clear_btn.setFixedWidth(80)
def _clear_groq_url():
self.groq_base_url_var = ""
self.groq_base_url_entry.setText("")
groq_clear_btn.clicked.connect(_clear_groq_url)
groq_h.addWidget(groq_clear_btn)
additional_v.addWidget(groq_row)
groq_help = QLabel("For vLLM: http://localhost:8000/v1 | For LM Studio: http://localhost:1234/v1")
groq_help.setStyleSheet("color: gray; font-size: 8pt;")
groq_help.setContentsMargins(0, 0, 0, 5)
additional_v.addWidget(groq_help)
# Fireworks Base URL
fireworks_row = QWidget()
fireworks_h = QHBoxLayout(fireworks_row)
fireworks_h.setContentsMargins(0, 5, 0, 5)
fireworks_h.addWidget(QLabel("Fireworks Base URL:"))
self.fireworks_base_url_var = self.config.get('fireworks_base_url', '')
self.fireworks_base_url_entry = QLineEdit()
try:
self.fireworks_base_url_entry.setText(str(self.fireworks_base_url_var))
except Exception:
pass
def _on_fireworks_url_changed(text):
try:
self.fireworks_base_url_var = text
except Exception:
pass
self.fireworks_base_url_entry.textChanged.connect(_on_fireworks_url_changed)
fireworks_h.addWidget(self.fireworks_base_url_entry)
fireworks_clear_btn = QPushButton("Clear")
fireworks_clear_btn.setFixedWidth(80)
def _clear_fireworks_url():
self.fireworks_base_url_var = ""
self.fireworks_base_url_entry.setText("")
fireworks_clear_btn.clicked.connect(_clear_fireworks_url)
fireworks_h.addWidget(fireworks_clear_btn)
additional_v.addWidget(fireworks_row)
# Info about multiple endpoints
info_lbl = QLabel("πŸ’‘ Advanced: Use multiple endpoints to run different local LLM servers simultaneously.\nβ€’ Use model prefix 'groq/' to route through Groq endpoint\nβ€’ Use model prefix 'fireworks/' to route through Fireworks endpoint\nβ€’ Most users only need the main OpenAI endpoint above")
info_lbl.setStyleSheet("color: #0dcaf0; font-size: 8pt;")
info_lbl.setWordWrap(True)
info_lbl.setContentsMargins(0, 10, 0, 10)
additional_v.addWidget(info_lbl)
# Gemini OpenAI-Compatible Endpoint
gemini_cb = self._create_styled_checkbox("Enable Gemini OpenAI-Compatible Endpoint")
try:
gemini_cb.setChecked(bool(self.use_gemini_openai_endpoint_var))
except Exception:
pass
def _on_gemini_endpoint_toggle(checked):
try:
self.use_gemini_openai_endpoint_var = bool(checked)
self.toggle_gemini_endpoint()
except Exception:
pass
gemini_cb.toggled.connect(_on_gemini_endpoint_toggle)
gemini_cb.setContentsMargins(0, 5, 0, 5)
additional_v.addWidget(gemini_cb)
# Gemini endpoint URL input
gemini_row = QWidget()
gemini_h = QHBoxLayout(gemini_row)
gemini_h.setContentsMargins(0, 5, 0, 5)
gemini_h.addWidget(QLabel("Gemini OpenAI Endpoint:"))
self.gemini_endpoint_entry = QLineEdit()
try:
self.gemini_endpoint_entry.setText(str(self.gemini_openai_endpoint_var))
except Exception:
pass
def _on_gemini_url_changed(text):
try:
self.gemini_openai_endpoint_var = text
except Exception:
pass
self.gemini_endpoint_entry.textChanged.connect(_on_gemini_url_changed)
gemini_h.addWidget(self.gemini_endpoint_entry)
self.gemini_clear_button = QPushButton("Clear")
self.gemini_clear_button.setFixedWidth(80)
def _clear_gemini_url():
self.gemini_openai_endpoint_var = ""
self.gemini_endpoint_entry.setText("")
self.gemini_clear_button.clicked.connect(_clear_gemini_url)
gemini_h.addWidget(self.gemini_clear_button)
additional_v.addWidget(gemini_row)
gemini_help = QLabel("For Gemini rate limit optimization with proxy services (e.g., OpenRouter, LiteLLM)")
gemini_help.setStyleSheet("color: gray; font-size: 8pt;")
gemini_help.setContentsMargins(0, 0, 0, 5)
additional_v.addWidget(gemini_help)
# Add the additional endpoints frame to the main section
section_v.addWidget(self.additional_endpoints_frame)
# Test Connection button
test_btn = QPushButton("Test Connection")
test_btn.clicked.connect(lambda: self.test_api_connections())
section_v.addWidget(test_btn)
# Place the section at row 7, spanning both columns
try:
grid = parent_frame.layout()
if grid:
grid.addWidget(section_box, 7, 0, 1, 2)
except Exception:
# Fallback: just stack
section_box.setParent(parent_frame)
# Set initial states
self.toggle_custom_endpoint_ui()
self.toggle_gemini_endpoint()
def _check_azure_endpoint(self, *args):
"""Check if endpoint is Azure and update UI (Qt version)"""
try:
if not self.use_custom_openai_endpoint_var:
if hasattr(self, 'azure_version_frame'):
self.azure_version_frame.setVisible(False)
return
url = self.openai_base_url_var
if '.azure.com' in url or '.cognitiveservices' in url:
if hasattr(self, 'api_key_label'):
try:
self.api_key_label.setText("Azure Key:")
except Exception:
pass
# Show Azure version frame
if hasattr(self, 'azure_version_frame'):
self.azure_version_frame.setVisible(True)
else:
if hasattr(self, 'api_key_label'):
try:
self.api_key_label.setText("OpenAI/Gemini/... API Key:")
except Exception:
pass
# Hide Azure version frame
if hasattr(self, 'azure_version_frame'):
self.azure_version_frame.setVisible(False)
except Exception:
pass
def _update_azure_api_version_env(self, *args):
"""Update the AZURE_API_VERSION environment variable when the setting changes"""
try:
api_version = self.azure_api_version_var
if api_version:
os.environ['AZURE_API_VERSION'] = api_version
#print(f"βœ… Updated Azure API Version in environment: {api_version}")
except Exception as e:
print(f"❌ Error updating Azure API Version environment variable: {e}")
def toggle_gemini_endpoint(self):
"""Enable/disable Gemini endpoint entry based on toggle (Qt version)"""
try:
enabled = bool(self.use_gemini_openai_endpoint_var)
if hasattr(self, 'gemini_endpoint_entry'):
self.gemini_endpoint_entry.setEnabled(enabled)
if hasattr(self, 'gemini_clear_button'):
self.gemini_clear_button.setEnabled(enabled)
except Exception:
pass
def toggle_custom_endpoint_ui(self, user_interaction=False):
"""Enable/disable the OpenAI base URL entry and detect Azure (Qt version)"""
try:
enabled = bool(self.use_custom_openai_endpoint_var)
if hasattr(self, 'openai_base_url_entry'):
self.openai_base_url_entry.setEnabled(enabled)
if hasattr(self, 'openai_clear_button'):
self.openai_clear_button.setEnabled(enabled)
if enabled:
# Check if it's Azure
url = self.openai_base_url_var
if '.azure.com' in url or '.cognitiveservices' in url:
if hasattr(self, 'api_key_label'):
try:
self.api_key_label.setText("Azure Key:")
except Exception:
pass
else:
if hasattr(self, 'api_key_label'):
try:
self.api_key_label.setText("OpenAI/Gemini/... API Key:")
except Exception:
pass
# Only print when user actually interacts with the toggle
if user_interaction:
print("βœ… Custom OpenAI endpoint enabled")
else:
if hasattr(self, 'api_key_label'):
try:
self.api_key_label.setText("OpenAI/Gemini/... API Key:")
except Exception:
pass
# Only print when user actually interacts with the toggle
if user_interaction:
print("❌ Custom OpenAI endpoint disabled - using default OpenAI API")
except Exception:
pass
def toggle_more_endpoints(self):
"""Toggle visibility of additional endpoint fields (Qt version)"""
try:
self.show_more_endpoints = not self.show_more_endpoints
if hasattr(self, 'additional_endpoints_frame'):
self.additional_endpoints_frame.setVisible(self.show_more_endpoints)
if hasattr(self, 'more_fields_button'):
if self.show_more_endpoints:
self.more_fields_button.setText("β–² Show Fewer Fields")
else:
self.more_fields_button.setText("β–Ό Show More Fields")
except Exception:
pass
def test_api_connections(self):
"""Test all configured API connections (Qt version)"""
import os
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QMessageBox, QPushButton
from PySide6.QtCore import Qt, QSize, QTimer, QObject, Signal
from PySide6.QtGui import QIcon, QPixmap
import threading
# Resolve app icon once for all dialogs
icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Halgakos.ico")
app_icon = QIcon(icon_path) if os.path.exists(icon_path) else QIcon("Halgakos.ico")
# Collect all configured endpoints
endpoints_to_test = []
# OpenAI endpoint - only test if checkbox is enabled
if self.use_custom_openai_endpoint_var:
openai_url = self.openai_base_url_var
if openai_url:
# Check if it's Azure
if '.azure.com' in openai_url or '.cognitiveservices' in openai_url:
# Azure endpoint
deployment = self.model_var if hasattr(self, 'model_var') else "gpt-35-turbo"
api_version = self.azure_api_version_var if hasattr(self, 'azure_api_version_var') else "2024-08-01-preview"
# Format Azure URL
if '/openai/deployments/' not in openai_url:
azure_url = f"{openai_url.rstrip('/')}/openai/deployments/{deployment}/chat/completions?api-version={api_version}"
else:
azure_url = openai_url
endpoints_to_test.append(("Azure OpenAI", azure_url, deployment, "azure"))
else:
# Regular custom endpoint
endpoints_to_test.append(("OpenAI (Custom)", openai_url, self.model_var if hasattr(self, 'model_var') else "gpt-3.5-turbo"))
else:
# Use default OpenAI endpoint if checkbox is on but no custom URL provided
endpoints_to_test.append(("OpenAI (Default)", "https://api.openai.com/v1", self.model_var if hasattr(self, 'model_var') else "gpt-3.5-turbo"))
# Groq endpoint
if hasattr(self, 'groq_base_url_var'):
groq_url = self.groq_base_url_var
if groq_url:
# For Groq, we need a groq-prefixed model
current_model = self.model_var if hasattr(self, 'model_var') else "llama-3-70b"
groq_model = current_model if current_model.startswith('groq/') else current_model.replace('groq/', '')
endpoints_to_test.append(("Groq/Local", groq_url, groq_model))
# Fireworks endpoint
if hasattr(self, 'fireworks_base_url_var'):
fireworks_url = self.fireworks_base_url_var
if fireworks_url:
# For Fireworks, we need the accounts/ prefix
current_model = self.model_var if hasattr(self, 'model_var') else "llama-v3-70b-instruct"
fw_model = current_model if current_model.startswith('accounts/') else f"accounts/fireworks/models/{current_model.replace('fireworks/', '')}"
endpoints_to_test.append(("Fireworks", fireworks_url, fw_model))
# Gemini OpenAI-Compatible endpoint
if hasattr(self, 'use_gemini_openai_endpoint_var') and self.use_gemini_openai_endpoint_var:
gemini_url = self.gemini_openai_endpoint_var
if gemini_url:
# Ensure the endpoint ends with /openai/ for compatibility
if not gemini_url.endswith('/openai/'):
if gemini_url.endswith('/'):
gemini_url = gemini_url + 'openai/'
else:
gemini_url = gemini_url + '/openai/'
# For Gemini OpenAI-compatible endpoints, use the current model or a suitable default
current_model = self.model_var if hasattr(self, 'model_var') else "gemini-2.0-flash-exp"
# Remove any 'gemini/' prefix for the OpenAI-compatible endpoint
gemini_model = current_model.replace('gemini/', '') if current_model.startswith('gemini/') else current_model
endpoints_to_test.append(("Gemini (OpenAI-Compatible)", gemini_url, gemini_model))
if not endpoints_to_test:
msg_box = QMessageBox()
msg_box.setWindowTitle("Info")
msg_box.setText("No custom endpoints configured.")
msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowIcon(app_icon)
msg_box.exec()
return
# Show immediate feedback
progress_dialog = QDialog(self.current_dialog if hasattr(self, 'current_dialog') else None)
progress_dialog.setWindowTitle("Testing Connections...")
progress_dialog.setModal(True) # modal, but we'll provide Cancel
# Use screen ratios for sizing
from PySide6.QtWidgets import QApplication
screen = QApplication.primaryScreen().geometry()
width = int(screen.width() * 0.13)
height = int(screen.height() * 0.17)
progress_dialog.setFixedSize(width, height)
# Set icon
try:
progress_dialog.setWindowIcon(app_icon)
except:
pass
# Center the dialog
if progress_dialog.parent():
parent_geo = progress_dialog.parent().geometry()
x = parent_geo.x() + (parent_geo.width() - 300) // 2
y = parent_geo.y() + (parent_geo.height() - 150) // 2
progress_dialog.move(x, y)
# Add progress message
layout = QVBoxLayout(progress_dialog)
# layout.addSpacing(10) # Removed spacing to reduce empty space
# Add app icon at 80x80 with HiDPI scaling (Animated)
icon_label = RotatableLabel()
# Set fixed size to 120x120 for container, icon itself is scaled to 90x90
icon_label.setFixedSize(120, 120)
dpr = QApplication.primaryScreen().devicePixelRatio() if QApplication.primaryScreen() else 1.0
pix = QPixmap(icon_path) if os.path.exists(icon_path) else app_icon.pixmap(QSize(90, 90))
if not pix.isNull():
scaled = pix.scaled(int(90 * dpr), int(90 * dpr), Qt.KeepAspectRatio, Qt.SmoothTransformation)
scaled.setDevicePixelRatio(dpr)
icon_label.set_original_pixmap(scaled)
icon_label.setAlignment(Qt.AlignCenter)
layout.addWidget(icon_label, 0, Qt.AlignCenter)
# Animate the icon
anim = QPropertyAnimation(icon_label, b"rotation", progress_dialog)
anim.setDuration(800)
anim.setStartValue(0)
anim.setEndValue(360)
anim.setLoopCount(-1)
anim.start()
else:
# Fallback if image loading fails
layout.addWidget(QLabel("Testing..."))
progress_label = QLabel("Testing API connections\nPlease wait")
progress_label.setAlignment(Qt.AlignCenter)
progress_label.setStyleSheet("font-size: 10pt;")
layout.addWidget(progress_label)
# Animate text dots
progress_dialog.dot_count = 0
def animate_dots():
try:
progress_dialog.dot_count = (progress_dialog.dot_count + 1) % 4
dots = "." * progress_dialog.dot_count
progress_label.setText(f"Testing API connections{dots}\nPlease wait{dots}")
except RuntimeError:
pass # Dialog deleted
text_timer = QTimer(progress_dialog)
text_timer.timeout.connect(animate_dots)
text_timer.start(500)
# Cancel button to stop background work
cancel_event = threading.Event()
cancel_btn = QPushButton("Cancel")
def _cancel():
cancel_event.set()
progress_dialog.close()
cancel_btn.clicked.connect(_cancel)
layout.addWidget(cancel_btn)
# Store cancel function in the dialog so it can be called if dialog is closed via X
progress_dialog._cancel_func = _cancel
def _on_close(event):
_cancel()
event.accept()
progress_dialog.closeEvent = _on_close
# Show dialog non-modally so it's visible
progress_dialog.show()
progress_dialog.repaint()
QApplication.processEvents() # ensure label paints before network calls
try:
# Ensure we have the openai module
import openai
except ImportError:
progress_dialog.close()
QMessageBox.critical(None, "Error", "OpenAI library not installed")
return
# Get API key from the main GUI
api_key = ''
if hasattr(self, 'api_key_entry'):
if hasattr(self.api_key_entry, 'text'): # PySide6 QLineEdit
api_key = self.api_key_entry.text()
elif hasattr(self.api_key_entry, 'get'): # Tkinter Entry
api_key = self.api_key_entry.get()
if not api_key:
api_key = self.config.get('api_key', '')
if not api_key:
api_key = "sk-dummy-key" # For local models
# Use module-level Bridge class
self.conn_test_bridge = ConnTestBridge()
def run_tests_background():
results = []
for endpoint_info in endpoints_to_test:
if cancel_event.is_set():
break
if len(endpoint_info) == 4 and endpoint_info[3] == "azure":
# Azure endpoint
name, base_url, model, endpoint_type = endpoint_info
try:
# Azure uses different headers
import requests
headers = {
"api-key": api_key,
"Content-Type": "application/json"
}
response = requests.post(
base_url,
headers=headers,
json={
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 5
},
timeout=5.0
)
if response.status_code == 200:
results.append(f"βœ… {name}: Connected successfully! (Deployment: {model})")
else:
results.append(f"❌ {name}: {response.status_code} - {response.text[:100]}")
except Exception as e:
error_msg = str(e)[:100]
results.append(f"❌ {name}: {error_msg}")
else:
# Regular OpenAI-compatible endpoint
name, base_url, model = endpoint_info[:3]
try:
# Quick endpoint reachability probe (low timeout)
try:
import httpx
probe_timeout = 3.0
probe_url = base_url.rstrip("/") # tolerate missing path
httpx.get(probe_url, timeout=probe_timeout)
except Exception as probe_err:
results.append(f"❌ {name}: Endpoint unreachable ({probe_err})")
continue
if cancel_event.is_set():
break
# Create client for this endpoint
test_client = openai.OpenAI(
api_key=api_key,
base_url=base_url,
timeout=5.0, # Keep model test short to avoid UI freeze
max_retries=0 # Fail fast on 404/connection errors
)
# Try a minimal completion
response = test_client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": "Hi"}],
max_tokens=5
)
results.append(f"βœ… {name}: Connected successfully! (Model: {model})")
except Exception as e:
error_msg = str(e)
# Simplify common error messages
if "timed out" in error_msg.lower():
error_msg = f"Connection timed out. The endpoint is running but the model '{model}' may be too slow to respond."
elif "404" in error_msg:
error_msg = "404 - Endpoint not found. Check URL and model name."
elif "401" in error_msg or "403" in error_msg:
error_msg = "Authentication failed. Check API key."
elif "model" in error_msg.lower() and "not found" in error_msg.lower():
error_msg = f"Model '{model}' not found at this endpoint."
results.append(f"❌ {name}: {error_msg}")
if not cancel_event.is_set():
self.conn_test_bridge.finished.emit(results)
threading.Thread(target=run_tests_background, daemon=True).start()
def finish_ui(results):
if cancel_event.is_set():
return
# Show results
result_message = "Connection Test Results:\n\n" + "\n\n".join(results)
# Close progress dialog
progress_dialog.close()
# Determine if all succeeded
all_success = all("βœ…" in r for r in results)
# Try to use other_settings_dialog as parent to ensure correct stacking
parent = getattr(self, '_other_settings_dialog', None)
if not parent or not parent.isVisible():
parent = progress_dialog.parent() if progress_dialog.parent() else None
msg_box = QMessageBox(parent)
msg_box.setWindowTitle("Success" if all_success else "Test Results")
msg_box.setText(result_message)
msg_box.setIcon(QMessageBox.Information if all_success else QMessageBox.Warning)
msg_box.setWindowIcon(app_icon)
msg_box.setStandardButtons(QMessageBox.Ok)
# Store in self to prevent garbage collection since we use show() (non-modal)
self._connection_result_dialog = msg_box
msg_box.setWindowModality(Qt.NonModal)
# Ensure it stays on top
msg_box.setWindowFlags(msg_box.windowFlags() | Qt.WindowStaysOnTopHint)
msg_box.show()
self.conn_test_bridge.finished.connect(finish_ui)
def run_standalone_translate_headers(self):
"""Run standalone header translation in a background thread"""
from PySide6.QtWidgets import QMessageBox
from PySide6.QtGui import QIcon
import traceback
import threading
# Get icon path
icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Halgakos.ico")
icon = QIcon(icon_path) if os.path.exists(icon_path) else QIcon()
try:
# Get API key and model from GUI fields
api_key = self.api_key_entry.text().strip() if hasattr(self, 'api_key_entry') else ""
model = self.model_var.strip() if hasattr(self, 'model_var') else ""
if not api_key:
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Critical)
msg_box.setWindowTitle("Error")
msg_box.setText("API key not configured.")
msg_box.setInformativeText("Please enter your API key in the main window before using this feature.")
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
return
if not model:
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Critical)
msg_box.setWindowTitle("Error")
msg_box.setText("Model not selected.")
msg_box.setInformativeText("Please select a model in the main window before using this feature.")
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
return
# Initialize stop flag and running state
self._headers_stop_requested = False
self._headers_translation_running = True
# Transform button to stop mode (red)
if hasattr(self, 'translate_headers_btn'):
self.translate_headers_btn.setText("⏹ Stop Headers")
self.translate_headers_btn.setStyleSheet(
"QPushButton { background-color: #dc3545; color: white; padding: 5px 10px; border-radius: 3px; font-weight: bold; } "
"QPushButton:hover { background-color: #c82333; } "
"QPushButton:disabled { background-color: #e0e0e0; color: #9e9e9e; }"
)
# Start spinning animation on icon
if hasattr(self, 'translate_icon_spin_animation') and hasattr(self, 'translate_headers_icon'):
try:
from PySide6.QtCore import QTimer, QPropertyAnimation
# Start the continuous spin animation
if self.translate_icon_spin_animation.state() != QPropertyAnimation.Running:
self.translate_icon_spin_animation.start()
# Update button icon periodically and monitor for completion
def update_and_monitor():
# Check if still running
if hasattr(self, '_headers_translation_running') and self._headers_translation_running:
# Update spinning icon
if hasattr(self, 'translate_headers_icon') and self.translate_headers_icon.pixmap():
self.translate_headers_btn.setIcon(QIcon(self.translate_headers_icon.pixmap()))
# Continue monitoring
QTimer.singleShot(50, update_and_monitor)
else:
# Translation finished - stop animation gracefully
if hasattr(self, 'translate_icon_spin_animation') and hasattr(self, 'translate_icon_stop_animation'):
if self.translate_icon_spin_animation.state() == QPropertyAnimation.Running:
# Stop the infinite spin
self.translate_icon_spin_animation.stop()
# Get current rotation and smoothly decelerate to 0
if hasattr(self, 'translate_headers_icon'):
current_rotation = self.translate_headers_icon.get_rotation()
current_rotation = current_rotation % 360
# Determine shortest path to 0
if current_rotation > 180:
target_rotation = 360
else:
target_rotation = 0
self.translate_icon_stop_animation.setStartValue(current_rotation)
self.translate_icon_stop_animation.setEndValue(target_rotation)
self.translate_icon_stop_animation.start()
# Wait for deceleration to finish before resetting button
def reset_button():
if hasattr(self, 'translate_headers_btn'):
self.translate_headers_btn.setText("Translate Headers Now")
self.translate_headers_btn.setStyleSheet(
"QPushButton { background-color: #6c757d; color: white; padding: 5px 10px; border-radius: 3px; font-weight: bold; } "
"QPushButton:hover { background-color: #28a745; } "
"QPushButton:disabled { background-color: #e0e0e0; color: #9e9e9e; }"
)
# Reset icon to static position
if hasattr(self, 'translate_headers_icon'):
self.translate_headers_icon.set_rotation(0)
if self.translate_headers_icon._original_pixmap:
self.translate_headers_btn.setIcon(QIcon(self.translate_headers_icon._original_pixmap))
# Delay reset to allow deceleration animation to finish
QTimer.singleShot(900, reset_button)
update_and_monitor()
except Exception as e:
pass
# Log that translation is starting
self.append_log("🌐 Starting standalone header translation in background...")
# Define the thread function
def translation_thread():
try:
# Use existing API client if available (it has multi-key support)
# Otherwise create a new one with proper config
original_client = getattr(self, 'api_client', None)
if original_client:
# Use existing client - it already has multi-key mode configured
self.append_log("βœ… Using existing API client (with multi-key support)")
api_client = original_client
else:
# Initialize new API client with current settings
# Multi-key mode is configured via environment variables (not constructor params)
from unified_api_client import UnifiedClient
import json
# Set environment variables for multi-key mode if configured
if hasattr(self, 'config'):
if self.config.get('use_multi_api_keys', False):
multi_keys = self.config.get('multi_api_keys', [])
os.environ['USE_MULTI_API_KEYS'] = '1'
os.environ['USE_MULTI_KEYS'] = '1'
os.environ['FORCE_KEY_ROTATION'] = '1' if self.config.get('force_key_rotation', True) else '0'
os.environ['ROTATION_FREQUENCY'] = str(self.config.get('rotation_frequency', 1))
# Avoid Windows env var length limit by keeping keys in memory
try:
from unified_api_client import UnifiedClient
UnifiedClient.set_in_memory_multi_keys(
multi_keys,
force_rotation=self.config.get('force_key_rotation', True),
rotation_frequency=self.config.get('rotation_frequency', 1),
)
except Exception:
pass
self.append_log(f"πŸ”‘ Multi-key mode enabled ({len(multi_keys)} keys)")
api_client = UnifiedClient(
model=model,
api_key=api_key
)
self.append_log("βœ… Created new API client")
# Set it temporarily
self.api_client = api_client
try:
# Import and run the translation GUI
from translate_headers_standalone import run_translate_headers_gui
run_translate_headers_gui(self)
# After translation completes, run EPUB converter to rebuild the EPUB
# with the updated HTML files
self.append_log("\nπŸ“¦ Rebuilding EPUB with translated headers...")
try:
from epub_converter import fallback_compile_epub
# Find the output directory for the current EPUB
epub_path = self.get_current_epub_path() if hasattr(self, 'get_current_epub_path') else None
if not epub_path and hasattr(self, 'selected_files') and self.selected_files:
# Get first EPUB from selection
epub_files = [f for f in self.selected_files if f.lower().endswith('.epub')]
if epub_files:
epub_path = epub_files[0]
if epub_path:
epub_base = os.path.splitext(os.path.basename(epub_path))[0]
current_dir = os.getcwd()
script_dir = os.path.dirname(os.path.abspath(__file__))
# Find output directory (same logic as header translation)
candidates = [
os.path.join(current_dir, epub_base),
os.path.join(script_dir, epub_base),
os.path.join(current_dir, 'src', epub_base),
]
# Add output directory override if configured
override_dir = os.environ.get('OUTPUT_DIRECTORY') or self.config.get('output_directory')
if override_dir:
candidates.insert(0, os.path.join(override_dir, epub_base))
output_dir = None
for candidate in candidates:
if os.path.isdir(candidate):
files = os.listdir(candidate)
html_files = [f for f in files if f.lower().endswith(('.html', '.xhtml', '.htm'))]
if html_files:
output_dir = candidate
break
if output_dir:
# Set EPUB_PATH env var for the converter
os.environ['EPUB_PATH'] = epub_path
self.append_log(f"πŸ“‚ Output directory: {output_dir}")
fallback_compile_epub(output_dir, log_callback=self.append_log)
self.append_log("βœ… EPUB rebuilt successfully with translated headers!")
else:
self.append_log("⚠️ Could not find output directory to rebuild EPUB")
else:
self.append_log("⚠️ No EPUB file selected - skipping EPUB rebuild")
except Exception as epub_error:
self.append_log(f"⚠️ Failed to rebuild EPUB: {epub_error}")
import traceback as tb
self.append_log(tb.format_exc())
finally:
# Restore original client
if original_client is not None:
self.api_client = original_client
elif hasattr(self, 'api_client'):
delattr(self, 'api_client')
except Exception as e:
error_msg = f"Failed to run standalone header translation: {e}\n\n{traceback.format_exc()}"
self.append_log(f"❌ {error_msg}")
finally:
# Reset button to initial state when thread completes
# Just set flags - the monitoring timer will handle UI updates
self._headers_translation_running = False
self._headers_stop_requested = True # Stop spinning animation
# Start the thread
thread = threading.Thread(target=translation_thread, daemon=True, name="HeaderTranslationThread")
self._headers_thread = thread # Store reference for stop button
thread.start()
except Exception as e:
error_msg = f"Failed to start standalone header translation: {e}\n\n{traceback.format_exc()}"
self.append_log(f"❌ {error_msg}")
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Critical)
msg_box.setWindowTitle("Error")
msg_box.setText(f"Failed to start standalone header translation: {e}")
msg_box.setDetailedText(traceback.format_exc())
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
def validate_epub_structure_gui(self):
"""GUI wrapper for EPUB structure validation"""
from PySide6.QtWidgets import QMessageBox
from PySide6.QtGui import QIcon
import os
# Get icon path
icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "halgakos.ico")
icon = QIcon(icon_path) if os.path.exists(icon_path) else QIcon()
input_path = ''
if hasattr(self, 'entry_epub'):
if hasattr(self.entry_epub, 'text'): # PySide6 QLineEdit
input_path = self.entry_epub.text()
elif hasattr(self.entry_epub, 'get'): # Tkinter Entry
input_path = self.entry_epub.get()
if not input_path:
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Critical)
msg_box.setWindowTitle("Error")
msg_box.setText("Please select a file first.")
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
return
if input_path.lower().endswith('.txt'):
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowTitle("Info")
msg_box.setText("Structure validation is only available for EPUB files.")
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
return
epub_base = os.path.splitext(os.path.basename(input_path))[0]
output_dir = epub_base
if not os.path.exists(output_dir):
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowTitle("Info")
msg_box.setText(f"No output directory found: {output_dir}")
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
return
self.append_log("πŸ” Validating EPUB structure...")
try:
from TransateKRtoEN import validate_epub_structure, check_epub_readiness
structure_ok = validate_epub_structure(output_dir)
readiness_ok = check_epub_readiness(output_dir)
if structure_ok and readiness_ok:
self.append_log("βœ… EPUB validation PASSED - Ready for compilation!")
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowTitle("Validation Passed")
msg_box.setText("βœ… All EPUB structure files are present!\n\n"
"Your translation is ready for EPUB compilation.")
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
elif structure_ok:
self.append_log("⚠️ EPUB structure OK, but some issues found")
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Warning)
msg_box.setWindowTitle("Validation Warning")
msg_box.setText("⚠️ EPUB structure is mostly OK, but some issues were found.\n\n"
"Check the log for details.")
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
else:
self.append_log("❌ EPUB validation FAILED - Missing critical files")
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Critical)
msg_box.setWindowTitle("Validation Failed")
msg_box.setText("❌ Missing critical EPUB files!\n\n"
"container.xml and/or OPF files are missing.\n"
"Try re-running the translation to extract them.")
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
except ImportError as e:
self.append_log(f"❌ Could not import validation functions: {e}")
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Critical)
msg_box.setWindowTitle("Error")
msg_box.setText("Validation functions not available.")
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
except Exception as e:
self.append_log(f"❌ Validation error: {e}")
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Critical)
msg_box.setWindowTitle("Error")
msg_box.setText(f"Validation failed: {e}")
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
def delete_translated_headers_file(self):
"""Delete the translated_headers.txt file from the output directory for all selected EPUBs"""
from PySide6.QtWidgets import QMessageBox
# Get icon path
icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Halgakos.ico")
icon = QIcon(icon_path) if os.path.exists(icon_path) else QIcon()
try:
# Get all selected EPUB files using the same logic as QA scanner
epub_files_to_process = []
# First check if current selection actually contains EPUBs
if hasattr(self, 'selected_files') and self.selected_files:
current_epub_files = [f for f in self.selected_files if f.lower().endswith('.epub')]
if current_epub_files:
epub_files_to_process = current_epub_files
self.append_log(f"πŸ“š Found {len(epub_files_to_process)} EPUB files in current selection")
# If no EPUBs in selection, try single EPUB methods
if not epub_files_to_process:
epub_path = self.get_current_epub_path()
if not epub_path:
entry_path = ''
if hasattr(self, 'entry_epub'):
if hasattr(self.entry_epub, 'text'): # PySide6 QLineEdit
entry_path = self.entry_epub.text().strip()
elif hasattr(self.entry_epub, 'get'): # Tkinter Entry
entry_path = self.entry_epub.get().strip()
if entry_path and entry_path != "No file selected" and os.path.exists(entry_path):
epub_path = entry_path
if epub_path:
epub_files_to_process = [epub_path]
if not epub_files_to_process:
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Critical)
msg_box.setWindowTitle("Error")
msg_box.setText("No EPUB file(s) selected. Please select EPUB file(s) first.")
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
return
# Process each EPUB file to find and delete translated_headers.txt
files_found = []
files_not_found = []
files_deleted = []
errors = []
current_dir = os.getcwd()
script_dir = os.path.dirname(os.path.abspath(__file__))
# First pass: scan for files
for epub_path in epub_files_to_process:
try:
epub_base = os.path.splitext(os.path.basename(epub_path))[0]
self.append_log(f"πŸ” Processing EPUB: {epub_base}")
# Check the most common locations in order of priority (same as QA scanner)
candidates = [
os.path.join(current_dir, epub_base), # current working directory
os.path.join(script_dir, epub_base), # src directory (where output typically goes)
os.path.join(current_dir, 'src', epub_base), # src subdirectory from current dir
]
# Add output directory override if configured (matches QA scanner behavior)
override_dir = os.environ.get('OUTPUT_DIRECTORY') or self.config.get('output_directory')
if override_dir:
candidates.insert(0, os.path.join(override_dir, epub_base))
self.append_log(f" πŸ” Checking override directory: {override_dir}")
output_dir = None
for candidate in candidates:
if os.path.isdir(candidate):
# Verify the folder actually contains HTML/XHTML files
try:
files = os.listdir(candidate)
html_files = [f for f in files if f.lower().endswith(('.html', '.xhtml', '.htm'))]
if html_files:
output_dir = candidate
break
except Exception:
continue
if not output_dir:
self.append_log(f" ⚠️ No output directory found for {epub_base}")
files_not_found.append((epub_base, "No output directory found"))
continue
# Look for translated_headers.txt in the output directory
headers_file = os.path.join(output_dir, "translated_headers.txt")
if os.path.exists(headers_file):
files_found.append((epub_base, headers_file))
self.append_log(f" βœ“ Found translated_headers.txt in {os.path.basename(output_dir)}")
else:
files_not_found.append((epub_base, "translated_headers.txt not found"))
self.append_log(f" ⚠️ No translated_headers.txt in {os.path.basename(output_dir)}")
except Exception as e:
epub_base = os.path.splitext(os.path.basename(epub_path))[0]
errors.append((epub_base, str(e)))
self.append_log(f" ❌ Error processing {epub_base}: {e}")
# Show summary and get user confirmation
if not files_found and not files_not_found and not errors:
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowTitle("No Files")
msg_box.setText("No EPUB files were processed.")
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
return
summary_text = f"Summary for {len(epub_files_to_process)} EPUB file(s):\n\n"
if files_found:
summary_text += f"βœ… Files to delete ({len(files_found)}):\n"
for epub_base, file_path in files_found:
summary_text += f" β€’ {epub_base}\n"
summary_text += "\n"
if files_not_found:
summary_text += f"⚠️ Files not found ({len(files_not_found)}):\n"
for epub_base, reason in files_not_found:
summary_text += f" β€’ {epub_base}: {reason}\n"
summary_text += "\n"
if errors:
summary_text += f"❌ Errors ({len(errors)}):\n"
for epub_base, error in errors:
summary_text += f" β€’ {epub_base}: {error}\n"
summary_text += "\n"
if files_found:
summary_text += "This will allow headers to be re-translated on the next run."
# Confirm deletion
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Question)
msg_box.setWindowTitle("Confirm Deletion")
msg_box.setText(summary_text)
msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
msg_box.setDefaultButton(QMessageBox.No)
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
result = msg_box.exec()
if result == QMessageBox.Yes:
# Delete the files
for epub_base, headers_file in files_found:
try:
os.remove(headers_file)
files_deleted.append(epub_base)
self.append_log(f"βœ… Deleted translated_headers.txt from {epub_base}")
except Exception as e:
errors.append((epub_base, f"Delete failed: {e}"))
self.append_log(f"❌ Failed to delete translated_headers.txt from {epub_base}: {e}")
# Show final results
if files_deleted:
success_msg = f"Successfully deleted {len(files_deleted)} file(s):\n"
success_msg += "\n".join([f"β€’ {epub_base}" for epub_base in files_deleted])
if errors:
success_msg += f"\n\nErrors: {len(errors)} file(s) failed to delete."
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowTitle("Success")
msg_box.setText(success_msg)
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
else:
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Critical)
msg_box.setWindowTitle("Error")
msg_box.setText("No files were successfully deleted.")
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
else:
# No files to delete
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowTitle("No Files to Delete")
msg_box.setText(summary_text)
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
except Exception as e:
self.append_log(f"❌ Error deleting translated_headers.txt: {e}")
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Critical)
msg_box.setWindowTitle("Error")
msg_box.setText(f"Failed to delete file: {e}")
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
def delete_toc_txt_file(self):
"""Delete the TOC.txt (toc.txt) file from the output directory for all selected EPUBs"""
from PySide6.QtWidgets import QMessageBox
# Get icon path
icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Halgakos.ico")
icon = QIcon(icon_path) if os.path.exists(icon_path) else QIcon()
try:
# Get all selected EPUB files using the same logic as QA scanner
epub_files_to_process = []
# First check if current selection actually contains EPUBs
if hasattr(self, 'selected_files') and self.selected_files:
current_epub_files = [f for f in self.selected_files if f.lower().endswith('.epub')]
if current_epub_files:
epub_files_to_process = current_epub_files
self.append_log(f"πŸ“š Found {len(epub_files_to_process)} EPUB files in current selection")
# If no EPUBs in selection, try single EPUB methods
if not epub_files_to_process:
epub_path = self.get_current_epub_path()
if not epub_path:
entry_path = ''
if hasattr(self, 'entry_epub'):
if hasattr(self.entry_epub, 'text'): # PySide6 QLineEdit
entry_path = self.entry_epub.text().strip()
elif hasattr(self.entry_epub, 'get'): # Tkinter Entry
entry_path = self.entry_epub.get().strip()
if entry_path and entry_path != "No file selected" and os.path.exists(entry_path):
epub_path = entry_path
if epub_path:
epub_files_to_process = [epub_path]
if not epub_files_to_process:
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Critical)
msg_box.setWindowTitle("Error")
msg_box.setText("No EPUB file(s) selected. Please select EPUB file(s) first.")
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
return
# Process each EPUB file to find and delete TOC.txt
files_found = []
files_not_found = []
files_deleted = []
errors = []
current_dir = os.getcwd()
script_dir = os.path.dirname(os.path.abspath(__file__))
# First pass: scan for files
for epub_path in epub_files_to_process:
try:
epub_base = os.path.splitext(os.path.basename(epub_path))[0]
self.append_log(f"πŸ” Processing EPUB: {epub_base}")
# Check the most common locations in order of priority (same as QA scanner)
candidates = [
os.path.join(current_dir, epub_base), # current working directory
os.path.join(script_dir, epub_base), # src directory (where output typically goes)
os.path.join(current_dir, 'src', epub_base), # src subdirectory from current dir
]
# Add output directory override if configured (matches QA scanner behavior)
override_dir = os.environ.get('OUTPUT_DIRECTORY') or self.config.get('output_directory')
if override_dir:
candidates.insert(0, os.path.join(override_dir, epub_base))
self.append_log(f" πŸ” Checking override directory: {override_dir}")
output_dir = None
for candidate in candidates:
if os.path.isdir(candidate):
# Verify the folder actually contains HTML/XHTML files
try:
files = os.listdir(candidate)
html_files = [f for f in files if f.lower().endswith(('.html', '.xhtml', '.htm'))]
if html_files:
output_dir = candidate
break
except Exception:
continue
if not output_dir:
self.append_log(f" ⚠️ No output directory found for {epub_base}")
files_not_found.append((epub_base, "No output directory found"))
continue
# Look for TOC.txt (case-insensitive)
toc_upper = os.path.join(output_dir, "TOC.txt")
toc_lower = os.path.join(output_dir, "toc.txt")
toc_file = toc_upper if os.path.exists(toc_upper) else (toc_lower if os.path.exists(toc_lower) else None)
if toc_file:
files_found.append((epub_base, toc_file))
self.append_log(f" βœ“ Found {os.path.basename(toc_file)} in {os.path.basename(output_dir)}")
else:
files_not_found.append((epub_base, "TOC.txt not found"))
self.append_log(f" ⚠️ No TOC.txt in {os.path.basename(output_dir)}")
except Exception as e:
epub_base = os.path.splitext(os.path.basename(epub_path))[0]
errors.append((epub_base, str(e)))
self.append_log(f" ❌ Error processing {epub_base}: {e}")
# Show summary and get user confirmation
if not files_found and not files_not_found and not errors:
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowTitle("No Files")
msg_box.setText("No EPUB files were processed.")
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
return
summary_text = f"Summary for {len(epub_files_to_process)} EPUB file(s):\n\n"
if files_found:
summary_text += f"βœ… Files to delete ({len(files_found)}):\n"
for epub_base, file_path in files_found:
summary_text += f" β€’ {epub_base}\n"
summary_text += "\n"
if files_not_found:
summary_text += f"⚠️ Files not found ({len(files_not_found)}):\n"
for epub_base, reason in files_not_found:
summary_text += f" β€’ {epub_base}: {reason}\n"
summary_text += "\n"
if errors:
summary_text += f"❌ Errors ({len(errors)}):\n"
for epub_base, error in errors:
summary_text += f" β€’ {epub_base}: {error}\n"
summary_text += "\n"
if files_found:
summary_text += "This will allow TOC entries to be re-translated on the next run."
# Confirm deletion
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Question)
msg_box.setWindowTitle("Confirm Deletion")
msg_box.setText(summary_text)
msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
msg_box.setDefaultButton(QMessageBox.No)
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
result = msg_box.exec()
if result == QMessageBox.Yes:
# Delete the files
for epub_base, toc_file in files_found:
try:
os.remove(toc_file)
files_deleted.append(epub_base)
self.append_log(f"βœ… Deleted {os.path.basename(toc_file)} from {epub_base}")
except Exception as e:
errors.append((epub_base, f"Delete failed: {e}"))
self.append_log(f"❌ Failed to delete TOC.txt from {epub_base}: {e}")
# Show final results
if files_deleted:
success_msg = f"Successfully deleted {len(files_deleted)} file(s):\n"
success_msg += "\n".join([f"β€’ {epub_base}" for epub_base in files_deleted])
if errors:
success_msg += f"\n\nErrors: {len(errors)} file(s) failed to delete."
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowTitle("Success")
msg_box.setText(success_msg)
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
else:
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Critical)
msg_box.setWindowTitle("Error")
msg_box.setText("No files were successfully deleted.")
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
else:
# No files to delete
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowTitle("No Files to Delete")
msg_box.setText(summary_text)
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
except Exception as e:
self.append_log(f"❌ Error deleting TOC.txt: {e}")
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Critical)
msg_box.setWindowTitle("Error")
msg_box.setText(f"Failed to delete file: {e}")
msg_box.setWindowIcon(icon)
_center_messagebox_buttons(msg_box)
msg_box.exec()
def on_profile_select(self, event=None):
"""Load the selected profile's prompt into the text area."""
# Get the current profile name from the combobox
name = self.profile_menu.currentText() if hasattr(self, 'profile_menu') else self.profile_var
# Skip if the name is empty or whitespace only
if not name or not name.strip():
return
# Only update if the profile actually exists in prompt_profiles
# This prevents switching to non-existent profiles while typing
if name in self.prompt_profiles:
# When switching profiles, revert any unsaved changes by loading from original content
if not hasattr(self, '_original_profile_content'):
self._original_profile_content = {}
# If this profile hasn't been saved yet, store its original content
if name not in self._original_profile_content:
self._original_profile_content[name] = self.prompt_profiles.get(name, "")
# Load the original (last saved) content, not the in-memory staged edits
prompt = self._original_profile_content.get(name, "")
current_text = self.prompt_text.toPlainText().strip()
if current_text != prompt.strip():
# PySide6: Clear and set QTextEdit content
self.prompt_text.clear()
self.prompt_text.setPlainText(prompt)
# Also revert the in-memory profile to original content
self.prompt_profiles[name] = prompt
# Update profile_var to match only when profile exists
self.profile_var = name
self.config['active_profile'] = name
# Set this as the active profile for autosave
self._active_profile_for_autosave = name
# AUTO-SWITCH EXTRACTION METHOD BASED ON PROFILE NAME
# Update both the variable AND the config so it persists
profile_lower = name.lower()
# Check if profile indicates an extraction mode
if 'beautifulsoup' in profile_lower:
# Switch to BeautifulSoup extraction mode
self.text_extraction_method_var = 'standard'
self.config['text_extraction_method'] = 'standard'
# Update radio button state if Other Settings dialog is open
if hasattr(self, 'standard_extraction_radio') and hasattr(self, 'enhanced_extraction_radio'):
self.standard_extraction_radio.setChecked(True)
elif 'html2text' in profile_lower:
# Switch to html2text extraction mode
self.text_extraction_method_var = 'enhanced'
self.config['text_extraction_method'] = 'enhanced'
# Update radio button state if Other Settings dialog is open
if hasattr(self, 'enhanced_extraction_radio') and hasattr(self, 'standard_extraction_radio'):
self.enhanced_extraction_radio.setChecked(True)
def save_profile(self):
"""Save current prompt under selected profile and persist."""
from PySide6.QtWidgets import QMessageBox
# Get name from combobox or profile_var
name = self.profile_menu.currentText().strip() if hasattr(self, 'profile_menu') else self.profile_var.strip()
if not name:
QMessageBox.critical(None, "Error", "Profile cannot be empty.")
return
# Check if we need to revert changes to the previous profile
# This happens if the user edited a profile but then changed the name to save as a new profile
if hasattr(self, '_active_profile_for_autosave') and self._active_profile_for_autosave and name != self._active_profile_for_autosave:
if hasattr(self, '_original_profile_content') and self._active_profile_for_autosave in self._original_profile_content:
# Revert the previous profile to its original content
original = self._original_profile_content[self._active_profile_for_autosave]
self.prompt_profiles[self._active_profile_for_autosave] = original
# Note: We don't update self.config here as it will be updated when save_profiles() is called
# PySide6: Get text from QTextEdit
content = self.prompt_text.toPlainText().strip()
self.prompt_profiles[name] = content
self.config['prompt_profiles'] = self.prompt_profiles
self.config['active_profile'] = name
# Update the original content to match the saved content
if not hasattr(self, '_original_profile_content'):
self._original_profile_content = {}
self._original_profile_content[name] = content
# Update combobox items only if the profile is new
current_items = [self.profile_menu.itemText(i) for i in range(self.profile_menu.count())]
if name not in current_items:
# Only rebuild if it's a new profile
self.profile_menu.addItem(name)
# Ensure the current selection is set to the saved profile
self.profile_menu.setCurrentText(name)
# Log the save
if hasattr(self, 'append_log'):
self.append_log(f"βœ… Profile '{name}' saved")
# Animate the save button to show confirmation
if hasattr(self, '_save_profile_btn'):
from PySide6.QtCore import QTimer
btn = self._save_profile_btn
original_text = btn.text()
# Play Windows notification sound
try:
import winsound
winsound.MessageBeep(winsound.MB_OK)
except:
pass
# Change to "Saved!" with green background
btn.setText("βœ“ Saved!")
btn.setStyleSheet(
"QPushButton { "
"background-color: #28a745; "
"color: white; "
"font-weight: bold; "
"border-radius: 3px; "
"} "
"QPushButton:hover { background-color: #28a745; }"
)
btn.setEnabled(False) # Disable button during animation
# Use a more robust approach with QTimer parent
timer = QTimer(self)
timer.setSingleShot(True)
def restore_button():
try:
btn.setText(original_text)
btn.setStyleSheet("") # Clear inline style
# Force Qt to recompute the style
btn.style().unpolish(btn)
btn.style().polish(btn)
btn.update()
btn.setEnabled(True)
except Exception as e:
# Fallback: restore without animation
btn.setText(original_text)
btn.setStyleSheet("")
btn.style().unpolish(btn)
btn.style().polish(btn)
btn.update()
btn.setEnabled(True)
timer.timeout.connect(restore_button)
timer.start(1000)
# Store timer reference to prevent garbage collection
self._save_profile_timer = timer
self.save_profiles()
def delete_profile(self):
"""Delete the selected profile."""
from PySide6.QtWidgets import QMessageBox
# Get name from combobox or profile_var
name = self.profile_menu.currentText() if hasattr(self, 'profile_menu') else self.profile_var
if name not in self.prompt_profiles:
QMessageBox.critical(None, "Error", f"Profile '{name}' not found.")
return
# Show delete confirmation with Halgakos icon
from PySide6.QtGui import QIcon
msg_box = QMessageBox()
msg_box.setWindowTitle("Delete")
msg_box.setText(f"Are you sure you want to delete language '{name}'?")
msg_box.setIcon(QMessageBox.Question)
msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
msg_box.setDefaultButton(QMessageBox.No)
try:
icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Halgakos.ico")
if os.path.exists(icon_path):
msg_box.setWindowIcon(QIcon(icon_path))
except:
pass
_center_messagebox_buttons(msg_box)
result = msg_box.exec()
if result == QMessageBox.Yes:
del self.prompt_profiles[name]
self.config['prompt_profiles'] = self.prompt_profiles
if self.prompt_profiles:
new = next(iter(self.prompt_profiles))
self.profile_var = new
# Update combobox
self.profile_menu.clear()
self.profile_menu.addItems(list(self.prompt_profiles.keys()))
self.profile_menu.setCurrentText(new)
self.on_profile_select()
else:
self.profile_var = ""
# Clear combobox and text
self.profile_menu.clear()
self.prompt_text.clear()
self.save_profiles()
def save_profiles(self):
"""Persist only the prompt profiles and active profile."""
from PySide6.QtWidgets import QMessageBox
try:
data = {}
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
data['prompt_profiles'] = self.prompt_profiles
# Get current profile from combobox or profile_var
data['active_profile'] = self.profile_menu.currentText() if hasattr(self, 'profile_menu') else self.profile_var
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e:
QMessageBox.critical(None, "Error", f"Failed to save profiles: {e}")
def import_profiles(self):
"""Import profiles from a JSON file, merging into existing ones."""
from PySide6.QtWidgets import QMessageBox, QFileDialog
path, _ = QFileDialog.getOpenFileName(
None,
"Import Profiles",
"",
"JSON files (*.json);;All files (*.*)"
)
if not path:
return
try:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
self.prompt_profiles.update(data)
self.config['prompt_profiles'] = self.prompt_profiles
# Update combobox
self.profile_menu.clear()
self.profile_menu.addItems(list(self.prompt_profiles.keys()))
QMessageBox.information(None, "Imported", f"Imported {len(data)} profiles.")
except Exception as e:
QMessageBox.critical(None, "Error", f"Failed to import profiles: {e}")
def export_profiles(self):
"""Export all profiles to a JSON file."""
from PySide6.QtWidgets import QMessageBox, QFileDialog
path, _ = QFileDialog.getSaveFileName(
None,
"Export Profiles",
"",
"JSON files (*.json);;All files (*.*)"
)
if not path:
return
# Add .json extension if not present
if not path.endswith('.json'):
path += '.json'
try:
with open(path, 'w', encoding='utf-8') as f:
json.dump(self.prompt_profiles, f, ensure_ascii=False, indent=2)
QMessageBox.information(None, "Exported", f"Profiles exported to {path}.")
except Exception as e:
QMessageBox.critical(None, "Error", f"Failed to export profiles: {e}")
def _create_debug_controls_section(self, parent_frame):
"""Create debug controls section at the bottom of Other Settings (PySide6)"""
from PySide6.QtWidgets import (
QGroupBox, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFrame
)
from PySide6.QtCore import Qt
try:
grid = parent_frame.layout()
current_row = grid.rowCount()
# Create a separator line above the debug section
separator = QFrame()
separator.setFrameShape(QFrame.HLine)
separator.setFrameShadow(QFrame.Sunken)
separator.setStyleSheet("QFrame { color: #404040; margin: 10px 0; }")
# Add separator spanning both columns
grid.addWidget(separator, current_row, 0, 1, 2)
current_row += 1
# Create debug controls group box
debug_group = QGroupBox("πŸ”§ Debug Controls")
debug_group.setStyleSheet("""
QGroupBox {
font-weight: bold;
font-size: 12pt;
color: #dc3545;
border: 2px solid #dc3545;
border-radius: 8px;
margin: 10px 5px;
padding-top: 35px;
}
QGroupBox::title {
subcontrol-origin: margin;
subcontrol-position: top left;
left: 15px;
top: 20px;
padding: 0 8px;
background-color: transparent;
color: #dc3545;
font-weight: bold;
}
""")
debug_layout = QVBoxLayout(debug_group)
debug_layout.setSpacing(10)
debug_layout.setContentsMargins(15, 15, 15, 15)
# Description label
desc_label = QLabel(
"Advanced debugging tools for troubleshooting environment variables and system configuration. "
"Only enable debug mode when investigating issues."
)
desc_label.setWordWrap(True)
desc_label.setStyleSheet("color: #888; font-size: 9pt; font-weight: normal; margin-bottom: 10px;")
debug_layout.addWidget(desc_label)
# Button container
button_layout = QHBoxLayout()
button_layout.setSpacing(15)
# Debug Mode Toggle Button
def _toggle_debug_mode():
try:
current_debug_state = getattr(self, 'config', {}).get('show_debug_buttons', False)
new_debug_state = not current_debug_state
# Update config
if not hasattr(self, 'config'):
self.config = {}
self.config['show_debug_buttons'] = new_debug_state
# Set environment variable for debug mode
os.environ['DEBUG_MODE'] = '1' if new_debug_state else '0'
# Save config
self.save_config(show_message=False)
# Update button appearance
if new_debug_state:
debug_toggle_btn.setText("πŸ” Debug Mode: ON")
debug_toggle_btn.setStyleSheet(
"QPushButton { "
" background-color: #dc3545; "
" color: white; "
" padding: 10px 20px; "
" font-size: 11pt; "
" font-weight: bold; "
" border-radius: 6px; "
" border: none; "
"} "
"QPushButton:hover { background-color: #c82333; }"
"QPushButton:pressed { background-color: #bd2130; }"
)
self.append_log("βœ… [DEBUG MODE] Debug mode ENABLED - enhanced debugging active")
self.append_log("πŸ”§ [DEBUG MODE] Debug features now available:")
self.append_log(" β€’ Enhanced environment variable debugging in save functions")
self.append_log(" β€’ Comprehensive variable verification")
self.append_log(" β€’ Detailed before/after tracking")
# Show debug action button
debug_action_btn.setVisible(True)
else:
debug_toggle_btn.setText("πŸ”’ Debug Mode: OFF")
debug_toggle_btn.setStyleSheet(
"QPushButton { "
" background-color: #6c757d; "
" color: white; "
" padding: 10px 20px; "
" font-size: 11pt; "
" font-weight: bold; "
" border-radius: 6px; "
" border: none; "
"} "
"QPushButton:hover { background-color: #5a6268; }"
"QPushButton:pressed { background-color: #545b62; }"
)
self.append_log("πŸ”’ [DEBUG MODE] Debug mode DISABLED - standard logging only")
# Hide debug action button
debug_action_btn.setVisible(False)
except Exception as e:
self.append_log(f"❌ [DEBUG MODE] Failed to toggle debug mode: {e}")
def _run_debug_check():
try:
self.append_log("πŸ” [DEBUG ACTION] Running comprehensive environment variable check...")
# First initialize if needed
init_success = self.initialize_environment_variables()
# Then debug
debug_success = self.debug_environment_variables(show_all=True)
if init_success and debug_success:
self.append_log("βœ… [DEBUG ACTION] Environment variables are properly configured")
else:
self.append_log("❌ [DEBUG ACTION] Environment variable issues detected - check log for details")
except Exception as e:
self.append_log(f"❌ [DEBUG ACTION] Debug check failed: {e}")
# Create buttons
current_debug_state = getattr(self, 'config', {}).get('show_debug_buttons', False)
debug_toggle_btn = QPushButton("πŸ” Debug Mode: ON" if current_debug_state else "πŸ”’ Debug Mode: OFF")
debug_toggle_btn.clicked.connect(_toggle_debug_mode)
debug_toggle_btn.setMinimumHeight(45)
debug_toggle_btn.setMinimumWidth(180)
if current_debug_state:
debug_toggle_btn.setStyleSheet(
"QPushButton { "
" background-color: #dc3545; "
" color: white; "
" padding: 10px 20px; "
" font-size: 11pt; "
" font-weight: bold; "
" border-radius: 6px; "
" border: none; "
"} "
"QPushButton:hover { background-color: #c82333; }"
"QPushButton:pressed { background-color: #bd2130; }"
)
else:
debug_toggle_btn.setStyleSheet(
"QPushButton { "
" background-color: #6c757d; "
" color: white; "
" padding: 10px 20px; "
" font-size: 11pt; "
" font-weight: bold; "
" border-radius: 6px; "
" border: none; "
"} "
"QPushButton:hover { background-color: #5a6268; }"
"QPushButton:pressed { background-color: #545b62; }"
)
button_layout.addWidget(debug_toggle_btn)
# Debug Action Button (only visible when debug mode is on)
debug_action_btn = QPushButton("πŸ” Check Environment Variables")
debug_action_btn.clicked.connect(_run_debug_check)
debug_action_btn.setMinimumHeight(45)
debug_action_btn.setMinimumWidth(220)
debug_action_btn.setStyleSheet(
"QPushButton { "
" background-color: #17a2b8; "
" color: white; "
" padding: 10px 20px; "
" font-size: 11pt; "
" font-weight: bold; "
" border-radius: 6px; "
" border: none; "
"} "
"QPushButton:hover { background-color: #138496; }"
"QPushButton:pressed { background-color: #117a8b; }"
)
# Only show action button if debug mode is currently enabled
debug_action_btn.setVisible(current_debug_state)
button_layout.addWidget(debug_action_btn)
# Add stretch to center buttons
button_layout.addStretch()
debug_layout.addLayout(button_layout)
# Store references for potential future use
self._debug_toggle_btn = debug_toggle_btn
self._debug_action_btn = debug_action_btn
# Add debug group to the grid, spanning both columns at the bottom
grid.addWidget(debug_group, current_row, 0, 1, 2)
except Exception as e:
print(f"Error creating debug controls section: {e}")