Glossarion / GlossaryManager_GUI.py
Shirochi's picture
Upload 93 files
ec038f4 verified
"""
Glossary Manager GUI Module
Comprehensive glossary management for automatic and manual glossary extraction
"""
import os
import sys
import json
from PySide6.QtWidgets import (QDialog, QWidget, QLabel, QLineEdit, QPushButton,
QCheckBox, QRadioButton, QTextEdit, QListWidget,
QTreeWidget, QTreeWidgetItem, QScrollArea, QTabWidget, QTabBar,
QVBoxLayout, QHBoxLayout, QGridLayout, QFrame,
QGroupBox, QSpinBox, QSlider, QMessageBox, QFileDialog,
QSizePolicy, QAbstractItemView, QButtonGroup, QApplication,
QComboBox, QMenu, QInputDialog)
from PySide6.QtCore import Qt, Signal, Slot, QTimer
from PySide6.QtGui import QFont, QColor, QIcon, QKeySequence, QShortcut, QBrush
# WindowManager and UIHelper removed - not needed in PySide6
# Qt handles window management and UI utilities automatically
class GlossaryManagerMixin:
"""Mixin class containing glossary management methods for TranslatorGUI"""
@staticmethod
def _disable_slider_mousewheel(slider):
"""Disable mousewheel scrolling on a slider to prevent accidental changes"""
slider.wheelEvent = lambda event: None
@staticmethod
def _disable_spinbox_mousewheel(spinbox):
"""Disable mousewheel scrolling on a spinbox to prevent accidental changes"""
spinbox.wheelEvent = lambda event: None
@staticmethod
def _disable_tabwidget_mousewheel(tabwidget):
"""Disable mousewheel scrolling on a tab widget to prevent accidental tab switching"""
tabwidget.wheelEvent = lambda event: None
@staticmethod
def _disable_combobox_mousewheel(combobox):
"""Disable mousewheel scrolling on a combobox"""
combobox.wheelEvent = lambda event: None
@staticmethod
def _add_combobox_arrow(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)
def _create_styled_checkbox(self, text):
"""Create a checkbox; styling is handled by the dialog's global stylesheet."""
from PySide6.QtWidgets import QCheckBox
return QCheckBox(text)
def glossary_manager(self):
"""Open comprehensive glossary management dialog"""
# Create standalone PySide6 dialog (no Tkinter parent)
# Note: self.master is a Tkinter window, so we use None as parent for PySide6
dialog = QDialog(None)
dialog.setWindowTitle("Glossary Settings")
dialog.setFont(QFont("Segoe UI", 10))
# Make non-modal but stay on top
dialog.setModal(False)
dialog.setWindowFlags(dialog.windowFlags() | Qt.WindowStaysOnTopHint)
# Use screen ratios instead of fixed pixels
self._screen = QApplication.primaryScreen().geometry()
min_width = int(self._screen.width() * 0.6) # 50% of screen width
min_height = int(self._screen.height() * 0.9) # 90% of screen height (leaves room for taskbar)
dialog.setMinimumSize(min_width, min_height)
# Set window icon
try:
icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'Halgakos.ico')
if os.path.exists(icon_path):
dialog.setWindowIcon(QIcon(icon_path))
except Exception as e:
print(f"Could not load window icon: {e}")
# Store dialog reference for use in nested functions
self.dialog = dialog
# Apply simplified dark mode stylesheet
global_stylesheet = """
/* Global dark mode styling */
QDialog {
background-color: #2d2d2d;
color: white;
}
QGroupBox {
color: white;
border: 1px solid #555;
margin: 5px;
padding-top: 10px;
}
QGroupBox::title {
color: white;
left: 10px;
padding: 0 5px;
}
QLabel {
color: white;
background-color: transparent;
border: none;
}
/* Checkbox styling */
QCheckBox {
color: white;
spacing: 6px;
}
QCheckBox::indicator {
width: 14px;
height: 14px;
border: 1px solid #5a9fd4;
border-radius: 2px;
background-color: transparent;
}
QCheckBox::indicator:checked {
background-color: #5a9fd4;
border-color: #5a9fd4;
}
QCheckBox::indicator:hover {
border-color: #7bb3e0;
}
QCheckBox:disabled {
color: #666666;
}
QCheckBox::indicator:disabled {
background-color: transparent;
border-color: #3a3a3a;
}
/* Radio button styling */
QRadioButton {
color: white;
spacing: 5px;
}
QRadioButton::indicator {
width: 13px;
height: 13px;
border: 2px solid #5a9fd4;
border-radius: 7px;
background-color: transparent;
}
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: transparent;
border-color: #3a3a3a;
}
/* Input fields styling */
QLineEdit, QTextEdit {
background-color: transparent;
color: white;
border: 1px solid #4a5568;
border-radius: 3px;
padding: 4px;
}
QLineEdit:focus, QTextEdit:focus {
border-color: #5a9fd4;
}
QLineEdit:disabled, QTextEdit:disabled {
background-color: #1a1a1a;
color: #666666;
border: 1px solid #3a3a3a;
}
/* Slider styling */
QSlider {
background-color: transparent;
}
QSlider::groove:horizontal, QSlider::groove:vertical {
background: transparent;
}
QSlider::add-page:horizontal, QSlider::sub-page:horizontal,
QSlider::add-page:vertical, QSlider::sub-page:vertical {
background: transparent;
}
QSlider::handle:horizontal, QSlider::handle:vertical {
background: #5a9fd4;
border: 2px solid #3b4f5e;
width: 14px;
height: 14px;
border-radius: 7px;
}
/* ComboBox styling */
QComboBox {
background-color: transparent;
color: white;
border: 1px solid #4a5568;
border-radius: 3px;
padding: 4px;
}
QComboBox:disabled {
background-color: #1a1a1a;
color: #666666;
border: 1px solid #3a3a3a;
}
QComboBox::drop-down {
border: none;
}
QComboBox QAbstractItemView {
background-color: #2d2d2d;
color: white;
selection-background-color: #5a9fd4;
}
/* SpinBox styling */
QSpinBox, QDoubleSpinBox {
background-color: transparent;
color: white;
border: 1px solid #4a5568;
border-radius: 3px;
padding: 4px;
}
QSpinBox:disabled, QDoubleSpinBox:disabled {
background-color: #1a1a1a;
color: #666666;
border: 1px solid #3a3a3a;
}
/* Slider styling */
QSlider::groove:horizontal {
background: transparent;
height: 6px;
border-radius: 3px;
border: 1px solid #4a5568;
}
QSlider::groove:vertical {
background: transparent;
width: 6px;
border-radius: 3px;
border: 1px solid #4a5568;
}
QSlider::handle:horizontal {
background: #5a9fd4;
border: 2px solid #3b4f5e;
width: 14px;
height: 14px;
margin: -5px 0; /* center over 6px groove with 2px border */
border-radius: 7px;
}
QSlider::handle:vertical {
background: #5a9fd4;
border: 2px solid #3b4f5e;
width: 14px;
height: 14px;
margin: 0 -5px; /* center for vertical groove */
border-radius: 7px;
}
QSlider::handle:horizontal:hover, QSlider::handle:vertical:hover {
background: #7bb3e0;
}
/* GroupBox styling */
QGroupBox {
color: white;
border: 1px solid #4a5568;
border-radius: 5px;
margin-top: 10px;
padding-top: 10px;
background-color: #252525;
}
QGroupBox::title {
subcontrol-origin: margin;
subcontrol-position: top left;
padding: 2px 5px;
color: #5a9fd4;
}
/* Label styling */
QLabel {
color: white;
}
QLabel:disabled {
color: #666666;
}
/* ListWidget styling */
QListWidget {
background-color: #2d2d2d;
color: white;
border: 1px solid #4a5568;
border-radius: 3px;
}
QListWidget {
background-color: transparent;
color: white;
border: 1px solid #4a5568;
border-radius: 3px;
}
QListWidget::item:selected {
background-color: #5a9fd4;
}
QListWidget::item:hover {
background-color: #3a3a3a;
}
/* TreeWidget styling */
QTreeWidget {
background-color: transparent;
color: white;
border: 1px solid #4a5568;
border-radius: 3px;
alternate-background-color: transparent;
}
QTreeWidget::item:selected {
background-color: #5a9fd4;
}
QTreeWidget::item:hover {
background-color: #3a3a3a;
}
QHeaderView::section {
background-color: #252525;
color: white;
border: 1px solid #4a5568;
padding: 4px;
}
/* TabWidget styling */
QTabWidget::pane {
border: 1px solid #4a5568;
background-color: #1e1e1e;
}
/* ScrollArea styling */
QScrollArea, QScrollArea > QWidget, QScrollArea > QWidget > QWidget {
background: transparent;
}
QTabBar::tab {
background-color: #252525;
color: white;
border: 1px solid #4a5568;
padding: 8px 16px;
margin-right: 2px;
font-weight: bold;
}
QTabBar::tab:selected {
background-color: #5a9fd4;
border-bottom: 2px solid #5a9fd4;
font-weight: bold;
}
QTabBar::tab:hover {
background-color: #3a3a3a;
}
/* ScrollBar styling */
QScrollBar:vertical {
background: transparent;
width: 12px;
margin: 0px;
}
QScrollBar::handle:vertical {
background: #4a5568;
min-height: 20px;
border-radius: 6px;
}
QScrollBar::handle:vertical:hover {
background: #5a9fd4;
}
QScrollBar:horizontal {
background: transparent;
height: 12px;
margin: 0px;
}
QScrollBar::handle:horizontal {
background: #4a5568;
min-width: 20px;
border-radius: 6px;
}
QScrollBar::handle:horizontal:hover {
background: #5a9fd4;
}
"""
dialog.setStyleSheet(global_stylesheet)
# Main layout
main_layout = QVBoxLayout(dialog)
# Create scroll area
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
# Scrollable widget and layout
scrollable_widget = QWidget()
scrollable_layout = QVBoxLayout(scrollable_widget)
scroll_area.setWidget(scrollable_widget)
main_layout.addWidget(scroll_area)
# Create notebook for tabs
notebook = QTabWidget()
# Prevent wheel from switching tabs, but keep wheel events working inside tab contents
class NoWheelTabBar(QTabBar):
def wheelEvent(self, event):
event.ignore()
notebook.setTabBar(NoWheelTabBar())
scrollable_layout.addWidget(notebook)
# Create and add tabs
tabs = [
("Manual Glossary Extraction", self._setup_manual_glossary_tab),
("Automatic Glossary Generation", self._setup_auto_glossary_tab),
("Glossary Editor", self._setup_glossary_editor_tab)
]
for tab_name, setup_method in tabs:
tab_widget = QWidget()
notebook.addTab(tab_widget, tab_name)
setup_method(tab_widget)
# Dialog Controls
control_frame = QWidget()
control_layout = QHBoxLayout(control_frame)
main_layout.addWidget(control_frame)
def save_glossary_settings():
try:
# Update prompts from text widgets to instance variables
self.update_glossary_prompts()
# Check if any types are enabled before saving
# Note: save_config will update enabled status from checkboxes automatically
enabled_types = []
if hasattr(self, 'type_enabled_checks') and hasattr(self, 'custom_entry_types'):
# Check from UI checkboxes
for type_name, checkbox in self.type_enabled_checks.items():
if checkbox.isChecked():
enabled_types.append(type_name)
elif hasattr(self, 'custom_entry_types'):
# Fallback: check from custom_entry_types dict
enabled_types = [t for t, cfg in self.custom_entry_types.items() if cfg.get('enabled', True)]
# Only show warning if we have custom_entry_types and none are enabled
if hasattr(self, 'custom_entry_types') and not enabled_types:
QMessageBox.warning(dialog, "Warning", "No entry types selected! The glossary extraction will not find any entries.")
# CRITICAL: Update the main GUI's instance variables to match checkbox states
# These vars are checked at runtime in _get_environment_variables() and translate_image()
# Without updating them here, changes won't take effect until GUI restart
checkbox_to_var_mapping = [
('append_glossary_checkbox', 'append_glossary_var'),
('enable_auto_glossary_checkbox', 'enable_auto_glossary_var'),
('add_additional_glossary_checkbox', 'add_additional_glossary_var'),
('compress_glossary_checkbox', 'compress_glossary_prompt_var'),
('enable_gender_nuance_checkbox', 'enable_gender_nuance_var'),
('include_gender_context_checkbox', 'include_gender_context_var'),
('include_description_checkbox', 'include_description_var'),
('glossary_history_rolling_checkbox', 'glossary_history_rolling_var'),
('strip_honorifics_checkbox', 'strip_honorifics_var'),
('disable_honorifics_checkbox', 'disable_honorifics_var'),
('use_legacy_csv_checkbox', 'use_legacy_csv_var'),
('glossary_auto_compression_checkbox', 'glossary_auto_compression_var'),
('glossary_json_output_checkbox', 'glossary_output_legacy_json_var'),
('include_all_characters_checkbox', 'glossary_include_all_characters_var'),
]
# Handle inverted logic for disable_smart_filtering_checkbox
if hasattr(self, 'disable_smart_filtering_checkbox'):
# Checkbox is "disable" so checked=True means use_smart_filter=False
use_smart_filter = not self.disable_smart_filtering_checkbox.isChecked()
self.config['glossary_use_smart_filter'] = use_smart_filter
setattr(self, 'glossary_use_smart_filter_var', use_smart_filter)
# IMPORTANT: When smart filter is disabled, also disable frequency checking
# This ensures ALL AI-generated entries are kept, not just the pre-filtered text
# Without this, entries get filtered out during post-processing even though full text was sent
skip_frequency = not use_smart_filter # If smart filter disabled, skip frequency checks
self.config['glossary_skip_frequency_check'] = skip_frequency
setattr(self, 'glossary_skip_frequency_check_var', skip_frequency)
for checkbox_name, var_name in checkbox_to_var_mapping:
if hasattr(self, checkbox_name):
setattr(self, var_name, getattr(self, checkbox_name).isChecked())
# Update glossary request merging checkbox
if hasattr(self, 'glossary_request_merging_checkbox'):
glossary_request_merging = self.glossary_request_merging_checkbox.isChecked()
self.config['glossary_request_merging_enabled'] = glossary_request_merging
setattr(self, 'glossary_request_merging_enabled_var', glossary_request_merging)
# Chapter split toggle (manual glossary)
if hasattr(self, 'glossary_enable_chapter_split_checkbox'):
split_enabled = self.glossary_enable_chapter_split_checkbox.isChecked()
self.config['glossary_enable_chapter_split'] = split_enabled
setattr(self, 'glossary_enable_chapter_split_var', split_enabled)
# Update text field variables from Targeted Extraction Settings
text_field_to_var_mapping = [
('glossary_min_frequency_entry', 'glossary_min_frequency', 'glossary_min_frequency_var'),
('glossary_max_names_entry', 'glossary_max_names', 'glossary_max_names_var'),
('glossary_max_titles_entry', 'glossary_max_titles', 'glossary_max_titles_var'),
('glossary_context_window_entry', 'glossary_context_window', 'glossary_context_window_var'),
('glossary_max_text_size_entry', 'glossary_max_text_size', 'glossary_max_text_size_var'),
('glossary_max_sentences_entry', 'glossary_max_sentences', 'glossary_max_sentences_var'),
('glossary_chapter_split_threshold_entry', 'glossary_chapter_split_threshold', 'glossary_chapter_split_threshold_var'),
('glossary_request_merge_count_entry', 'glossary_request_merge_count', 'glossary_request_merge_count_var'),
]
for field_name, config_key, var_name in text_field_to_var_mapping:
if hasattr(self, field_name):
try:
value = int(getattr(self, field_name).text())
self.config[config_key] = value
# Also update the instance variable (used by _get_environment_variables)
setattr(self, var_name, str(value))
except ValueError:
pass # Keep existing value if invalid
# Update glossary-specific float fields (compression factor)
if hasattr(self, 'glossary_compression_factor_entry'):
try:
value = float(self.glossary_compression_factor_entry.text())
self.config['glossary_compression_factor'] = value
setattr(self, 'glossary_compression_factor_var', str(value))
except ValueError:
pass
# Update glossary max output tokens
if hasattr(self, 'glossary_output_token_limit_entry'):
try:
value = int(self.glossary_output_token_limit_entry.text())
self.config['glossary_max_output_tokens'] = value
setattr(self, 'glossary_max_output_tokens_var', str(value))
except ValueError:
pass
# Update glossary anti-duplicate parameters
if hasattr(self, 'glossary_enable_anti_duplicate_var'):
self.config['glossary_enable_anti_duplicate'] = bool(self.glossary_enable_anti_duplicate_var)
if hasattr(self, 'glossary_top_p_var'):
self.config['glossary_top_p'] = float(self.glossary_top_p_var)
if hasattr(self, 'glossary_top_k_var'):
self.config['glossary_top_k'] = int(self.glossary_top_k_var)
if hasattr(self, 'glossary_frequency_penalty_var'):
self.config['glossary_frequency_penalty'] = float(self.glossary_frequency_penalty_var)
if hasattr(self, 'glossary_presence_penalty_var'):
self.config['glossary_presence_penalty'] = float(self.glossary_presence_penalty_var)
if hasattr(self, 'glossary_repetition_penalty_var'):
self.config['glossary_repetition_penalty'] = float(self.glossary_repetition_penalty_var)
if hasattr(self, 'glossary_candidate_count_var'):
self.config['glossary_candidate_count'] = int(self.glossary_candidate_count_var)
if hasattr(self, 'glossary_custom_stop_sequences_var'):
self.config['glossary_custom_stop_sequences'] = str(self.glossary_custom_stop_sequences_var)
if hasattr(self, 'glossary_logit_bias_enabled_var'):
self.config['glossary_logit_bias_enabled'] = bool(self.glossary_logit_bias_enabled_var)
if hasattr(self, 'glossary_logit_bias_strength_var'):
self.config['glossary_logit_bias_strength'] = float(self.glossary_logit_bias_strength_var)
if hasattr(self, 'glossary_bias_common_words_var'):
self.config['glossary_bias_common_words'] = bool(self.glossary_bias_common_words_var)
if hasattr(self, 'glossary_bias_repetitive_phrases_var'):
self.config['glossary_bias_repetitive_phrases'] = bool(self.glossary_bias_repetitive_phrases_var)
# Update target language from combo box (check both auto and manual)
if hasattr(self, 'glossary_target_language_combo'):
self.config['glossary_target_language'] = self.glossary_target_language_combo.currentText()
elif hasattr(self, 'manual_target_language_combo'):
self.config['glossary_target_language'] = self.manual_target_language_combo.currentText()
# Update duplicate detection algorithm from combo box
if hasattr(self, 'duplicate_algo_combo'):
algo_index = self.duplicate_algo_combo.currentIndex()
algo_map = {0: 'auto', 1: 'strict', 2: 'balanced', 3: 'aggressive', 4: 'basic'}
self.glossary_duplicate_algorithm_var = algo_map.get(algo_index, 'auto')
self.config['glossary_duplicate_algorithm'] = self.glossary_duplicate_algorithm_var
# Partial ratio weight (substring matcher)
if hasattr(self, 'partial_ratio_slider'):
self.partial_ratio_weight = self.partial_ratio_slider.value() / 100.0
self.config['glossary_partial_ratio_weight'] = self.partial_ratio_weight
self.glossary_partial_ratio_weight_var = self.partial_ratio_weight
# Save auto compression state
if hasattr(self, 'glossary_auto_compression_checkbox'):
self.config['glossary_auto_compression'] = self.glossary_auto_compression_checkbox.isChecked()
# Call main save_config - it will:
# 1. Update custom_entry_types from checkboxes
# 2. Read from all UI widgets and instance variables
# 3. Write everything to config.json
# 4. Set environment variables
self.save_config(show_message=False)
# CRITICAL: Reload config.json from disk, then reinitialize ALL environment variables
# This ensures environment variables are fully synced with the saved config
try:
import json
from api_key_encryption import decrypt_config
with open('config.json', 'r', encoding='utf-8') as f:
self.config = json.load(f)
self.config = decrypt_config(self.config)
# Now call save_config to set ALL environment variables from the reloaded config
self.save_config(show_message=False)
except Exception as e:
self.append_log(f"⚠️ Failed to reload config: {e}")
self.append_log("✅ Glossary settings saved successfully")
self.append_log("✅ Environment variables reinitialized")
dialog.accept()
except Exception as e:
import traceback
error_msg = f"Failed to save settings: {e}\n{traceback.format_exc()}"
QMessageBox.critical(dialog, "Error", f"Failed to save settings: {e}")
self.append_log(f"❌ Failed to save glossary settings: {e}")
print(error_msg)
# Add buttons
save_button = QPushButton("Save All Settings")
save_button.clicked.connect(save_glossary_settings)
save_button.setStyleSheet("background-color: #28a745; color: white; font-weight: bold; padding: 8px;")
control_layout.addWidget(save_button)
cancel_button = QPushButton("Cancel")
cancel_button.clicked.connect(dialog.reject)
cancel_button.setStyleSheet("background-color: #6c757d; color: white; padding: 8px;")
control_layout.addWidget(cancel_button)
# Show dialog with fade animation
try:
from dialog_animations import show_dialog_with_fade
show_dialog_with_fade(dialog, duration=250)
except Exception:
dialog.show()
def _setup_manual_glossary_tab(self, parent):
"""Setup manual glossary tab - simplified for new format"""
# Create main layout for parent
manual_layout = QVBoxLayout(parent)
manual_layout.setContentsMargins(10, 10, 10, 10)
# Type filtering section with custom types
type_filter_frame = QGroupBox("Entry Type Configuration")
type_filter_layout = QVBoxLayout(type_filter_frame)
manual_layout.addWidget(type_filter_frame)
# Always reload custom entry types from config to ensure latest saved state
self.custom_entry_types = self.config.get('custom_entry_types', {
'character': {'enabled': True, 'has_gender': True},
'terms': {'enabled': True, 'has_gender': False}
})
# Normalize legacy key "term" -> "terms"
if 'term' in self.custom_entry_types and 'terms' not in self.custom_entry_types:
self.custom_entry_types['terms'] = self.custom_entry_types.pop('term')
# If both exist, prefer "terms" and drop legacy duplicate
if 'term' in self.custom_entry_types and 'terms' in self.custom_entry_types:
self.custom_entry_types.pop('term', None)
# Main container with grid for better control
type_main_grid = QGridLayout()
type_filter_layout.addLayout(type_main_grid)
# Left side - type list with checkboxes
type_list_widget = QWidget()
type_list_layout = QVBoxLayout(type_list_widget)
type_list_layout.setContentsMargins(0, 0, 15, 0)
type_main_grid.addWidget(type_list_widget, 0, 0)
type_main_grid.setColumnStretch(0, 3)
type_main_grid.setColumnStretch(1, 2)
label = QLabel("Active Entry Types:")
# label.setStyleSheet("font-weight: bold;")
type_list_layout.addWidget(label)
# Scrollable frame for type checkboxes
type_scroll_area = QScrollArea()
type_scroll_area.setWidgetResizable(True)
# Use screen ratio: ~16% of screen height
scroll_height = int(self._screen.height() * 0.16)
type_scroll_area.setMinimumHeight(scroll_height)
type_scroll_area.setMaximumHeight(scroll_height)
type_list_layout.addWidget(type_scroll_area)
self.type_checkbox_widget = QWidget()
self.type_checkbox_layout = QVBoxLayout(self.type_checkbox_widget)
self.type_checkbox_layout.setContentsMargins(0, 0, 0, 0)
type_scroll_area.setWidget(self.type_checkbox_widget)
# Store checkbox variables
self.type_enabled_checkboxes = {}
def update_type_checkboxes():
"""Rebuild the checkbox list"""
# Clear existing checkboxes
while self.type_checkbox_layout.count():
item = self.type_checkbox_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
# Sort types: built-in first, then custom alphabetically
sorted_types = sorted(self.custom_entry_types.items(),
key=lambda x: (x[0] not in ['character', 'terms'], x[0]))
# Create checkboxes for each type
for type_name, type_config in sorted_types:
row_widget = QWidget()
row_layout = QHBoxLayout(row_widget)
row_layout.setContentsMargins(0, 2, 0, 2)
row_layout.setSpacing(6)
# Checkbox
cb = self._create_styled_checkbox(type_name)
cb.setChecked(type_config.get('enabled', True))
self.type_enabled_checkboxes[type_name] = cb
row_layout.addWidget(cb)
# Add gender indicator for types that support it
if type_config.get('has_gender', False):
label = QLabel("(has gender field)")
# label.setStyleSheet("color: gray; font-size: 9pt;")
row_layout.addWidget(label)
# Delete button for custom types (place right after the label/text)
if type_name not in ['character', 'terms']:
delete_btn = QPushButton("×")
delete_btn.setFixedWidth(24)
delete_btn.setStyleSheet("""
QPushButton {
background-color: #dc3545; /* red */
color: white;
font-weight: bold;
border: 1px solid #a71d2a;
border-radius: 4px;
padding: 0px 6px;
min-height: 18px;
}
QPushButton:hover { background-color: #c82333; }
QPushButton:pressed { background-color: #bd2130; }
""")
delete_btn.clicked.connect(lambda checked, t=type_name: remove_type(t))
row_layout.addWidget(delete_btn)
# Push any remaining content to the far right
row_layout.addStretch()
self.type_checkbox_layout.addWidget(row_widget)
self.type_checkbox_layout.addStretch()
# Right side - controls for adding custom types
type_control_widget = QWidget()
type_control_layout = QVBoxLayout(type_control_widget)
type_control_layout.setContentsMargins(0, 0, 0, 0)
type_main_grid.addWidget(type_control_widget, 0, 1)
label = QLabel("Add Custom Type:")
# label.setStyleSheet("font-weight: bold;")
type_control_layout.addWidget(label)
# Entry for new type field
QLabel("Type Field:").setParent(type_control_widget)
type_control_layout.addWidget(QLabel("Type Field:"))
new_type_entry = QLineEdit()
type_control_layout.addWidget(new_type_entry)
# Checkbox for gender field
has_gender_checkbox = self._create_styled_checkbox("Include gender field")
type_control_layout.addWidget(has_gender_checkbox)
def add_custom_type():
type_name = new_type_entry.text().strip().lower()
if not type_name:
QMessageBox.warning(parent, "Invalid Input", "Please enter a type name")
return
if type_name in self.custom_entry_types:
QMessageBox.warning(parent, "Duplicate Type", f"Type '{type_name}' already exists")
return
# Add the new type
self.custom_entry_types[type_name] = {
'enabled': True,
'has_gender': has_gender_checkbox.isChecked()
}
# Clear inputs
new_type_entry.clear()
has_gender_checkbox.setChecked(False)
# Update display
update_type_checkboxes()
self.append_log(f"✅ Added custom type: {type_name}")
def remove_type(type_name):
if type_name in ['character', 'term']:
QMessageBox.warning(parent, "Cannot Remove", "Built-in types cannot be removed")
return
reply = QMessageBox.question(parent, "Confirm Removal", f"Remove type '{type_name}'?",
QMessageBox.Yes | QMessageBox.No)
if reply == QMessageBox.Yes:
del self.custom_entry_types[type_name]
if type_name in self.type_enabled_checkboxes:
del self.type_enabled_checkboxes[type_name]
update_type_checkboxes()
self.append_log(f"🗑️ Removed custom type: {type_name}")
add_type_button = QPushButton("Add Type")
add_type_button.clicked.connect(add_custom_type)
# add_type_button.setStyleSheet("background-color: #28a745; color: white; padding: 5px;")
type_control_layout.addWidget(add_type_button)
type_control_layout.addStretch()
# Initialize checkboxes
update_type_checkboxes()
# Custom fields section
custom_frame = QGroupBox("Custom Fields (Additional Columns)")
custom_frame_layout = QVBoxLayout(custom_frame)
manual_layout.addWidget(custom_frame)
QLabel("Additional fields to extract (will be added as extra columns):").setParent(custom_frame)
custom_frame_layout.addWidget(QLabel("Additional fields to extract (will be added as extra columns):"))
self.custom_fields_listbox = QListWidget()
# Use screen ratio: ~10% of screen height
listbox_height = int(self._screen.height() * 0.10)
self.custom_fields_listbox.setMaximumHeight(listbox_height)
custom_frame_layout.addWidget(self.custom_fields_listbox)
# Initialize custom_glossary_fields if not exists
if not hasattr(self, 'custom_glossary_fields'):
self.custom_glossary_fields = self.config.get('custom_glossary_fields', [])
# Add "description" as default field if list is empty and user hasn't manually removed it
description_removed_flag = self.config.get('custom_field_description_removed', False)
if not self.custom_glossary_fields and not description_removed_flag:
self.custom_glossary_fields = ['description']
# Save to config so it persists
self.config['custom_glossary_fields'] = self.custom_glossary_fields
self.save_config(show_message=False)
for field in self.custom_glossary_fields:
self.custom_fields_listbox.addItem(field)
custom_controls_widget = QWidget()
custom_controls_layout = QHBoxLayout(custom_controls_widget)
custom_controls_layout.setContentsMargins(0, 5, 0, 0)
custom_frame_layout.addWidget(custom_controls_widget)
self.custom_field_entry = QLineEdit()
self.custom_field_entry.setPlaceholderText("Enter field name...")
custom_controls_layout.addWidget(self.custom_field_entry)
def add_custom_field():
field = self.custom_field_entry.text().strip()
if field and field not in self.custom_glossary_fields:
self.custom_glossary_fields.append(field)
self.custom_fields_listbox.addItem(field)
self.custom_field_entry.clear()
# If user manually adds "description" back, clear the removal flag
if field.lower() == 'description':
self.config['custom_field_description_removed'] = False
self.save_config(show_message=False)
def remove_custom_field():
current_row = self.custom_fields_listbox.currentRow()
if current_row >= 0:
item = self.custom_fields_listbox.item(current_row)
field = item.text()
self.custom_glossary_fields.remove(field)
self.custom_fields_listbox.takeItem(current_row)
# If user manually removes "description", set flag to prevent re-adding
if field.lower() == 'description':
self.config['custom_field_description_removed'] = True
self.save_config(show_message=False)
# Use screen ratio for button widths: ~8% of screen width
button_width = int(self._screen.width() * 0.08)
add_field_btn = QPushButton("Add")
add_field_btn.setFixedWidth(button_width)
add_field_btn.clicked.connect(add_custom_field)
custom_controls_layout.addWidget(add_field_btn)
remove_field_btn = QPushButton("Remove")
remove_field_btn.setFixedWidth(button_width)
remove_field_btn.clicked.connect(remove_custom_field)
custom_controls_layout.addWidget(remove_field_btn)
# Duplicate Detection Settings
duplicate_frame = QGroupBox("Duplicate Detection")
duplicate_frame_layout = QVBoxLayout(duplicate_frame)
manual_layout.addWidget(duplicate_frame)
# Algorithm selection dropdown
algo_label = QLabel("Detection Algorithm:")
duplicate_frame_layout.addWidget(algo_label)
algo_widget = QWidget()
algo_layout = QHBoxLayout(algo_widget)
algo_layout.setContentsMargins(0, 0, 0, 5)
duplicate_frame_layout.addWidget(algo_widget)
# Add icon before dropdown (HiDPI-aware 36x36 like Extract Glossary)
algo_icon_label = QLabel()
algo_icon_label.setStyleSheet("background-color: transparent;")
try:
icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'Halgakos.ico')
if os.path.exists(icon_path):
from PySide6.QtGui import QIcon, QPixmap
from PySide6.QtCore import QSize
icon = QIcon(icon_path)
try:
dpr = self.devicePixelRatioF()
except Exception:
dpr = 1.0
logical_px = 16
dev_px = int(logical_px * max(1.0, dpr))
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
algo_icon_label.setPixmap(pm)
algo_icon_label.setFixedSize(36, 36)
algo_icon_label.setAlignment(Qt.AlignCenter)
else:
algo_icon_label.setText("🎯")
algo_icon_label.setStyleSheet("font-size: 18pt;")
except Exception:
algo_icon_label.setText("🎯")
algo_icon_label.setStyleSheet("font-size: 18pt;")
algo_layout.addWidget(algo_icon_label)
self.duplicate_algo_combo = QComboBox()
self.duplicate_algo_combo.addItems([
"Auto - Uses all algorithms",
"Strict - High precision, minimal merging",
"Balanced - Token + Partial matching",
"Aggressive - Maximum duplicate detection",
"Basic Only - Simple Levenshtein distance"
])
# Prevent accidental changes via mouse wheel
self._disable_combobox_mousewheel(self.duplicate_algo_combo)
# Load saved setting or default to Auto
saved_algo = self.config.get('glossary_duplicate_algorithm', 'auto')
algo_index_map = {
'auto': 0,
'strict': 1,
'balanced': 2,
'aggressive': 3,
'basic': 4
}
self.duplicate_algo_combo.setCurrentIndex(algo_index_map.get(saved_algo, 0))
# Try to set custom dropdown icon using Halgakos.ico
try:
icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'Halgakos.ico')
if os.path.exists(icon_path):
# Create a small icon for the dropdown arrow
icon = QIcon(icon_path)
# Set the icon for each item (optional, makes it appear next to text)
# self.duplicate_algo_combo.setItemIcon(0, icon)
# Custom stylesheet for this combo box with icon-based dropdown
combo_style = """
QComboBox {
padding-right: 28px;
}
QComboBox::drop-down {
subcontrol-origin: padding;
subcontrol-position: top right;
width: 24px;
border-left: 1px solid #4a5568;
}
QComboBox::down-arrow {
width: 16px;
height: 16px;
image: url(""" + icon_path.replace('\\', '/') + """);
}
QComboBox::down-arrow:on {
top: 1px;
}
"""
self.duplicate_algo_combo.setStyleSheet(combo_style)
except Exception as e:
# If icon fails, just use default styling
pass
algo_layout.addWidget(self.duplicate_algo_combo)
# Info button
algo_info_btn = QPushButton("ℹ️ Info")
algo_info_btn.setFixedWidth(60)
def show_algorithm_info():
msg_box = QMessageBox(parent)
msg_box.setWindowTitle("Algorithm Information")
msg_box.setIcon(QMessageBox.Information)
msg_box.setText(
"<b>Auto</b>: Uses all available algorithms (RapidFuzz, Jaro-Winkler, Token matching) and takes the best score.<br><br>"
"<b>Strict</b>: Only matches very similar names (95%+ similarity). Keeps more entries, minimal merging. Good if you want to review duplicates manually.<br><br>"
"<b>Balanced</b>: Uses token-based and partial matching. Handles word order (‘Park Ji-sung’ = ‘Ji-sung Park’) and substrings. Good middle ground.<br><br>"
"<b>Aggressive</b>: Lower threshold (80%) with all algorithms. Catches romanization variants (‘Catherine’ = ‘Katherine’). May over-merge similar names.<br><br>"
"<b>Basic Only</b>: Simple Levenshtein distance. Faster but less accurate. May miss variants like ‘Kim Sang-hyun’ vs ‘Kim Sanghyun’."
)
# Set size using screen ratios: 40% width, 50% height
screen_width = self._screen.width()
screen_height = self._screen.height()
msg_box.setMinimumWidth(int(screen_width * 0.40))
msg_box.setMinimumHeight(int(screen_height * 0.50))
msg_box.exec()
algo_info_btn.clicked.connect(show_algorithm_info)
algo_layout.addWidget(algo_info_btn)
algo_layout.addStretch()
# Update description when algorithm changes
def update_algo_description(index):
descriptions = [
"🎯 Auto mode uses multiple algorithms for best accuracy",
"🔒 Strict mode: High precision, keeps more entries",
"⚖️ Balanced mode: Handles word order and substrings",
"🔥 Aggressive mode: Maximum duplicate detection (may over-merge)",
"📄 Basic mode: Simple matching (faster, less accurate)"
]
algo_desc.setText(descriptions[index])
algo_desc = QLabel()
algo_desc.setStyleSheet("color: gray; font-size: 9pt; margin-bottom: 15px;")
duplicate_frame_layout.addWidget(algo_desc)
# Set initial description based on saved algorithm
update_algo_description(self.duplicate_algo_combo.currentIndex())
self.duplicate_algo_combo.currentIndexChanged.connect(update_algo_description)
# Partial ratio strength slider (controls substring matching weight)
partial_label = QLabel("Partial ratio strength (substring matcher):")
duplicate_frame_layout.addWidget(partial_label)
partial_desc = QLabel("0 disables substring matching; higher values increase partial-ratio influence.")
duplicate_frame_layout.addWidget(partial_desc)
partial_slider_row = QWidget()
partial_slider_layout = QHBoxLayout(partial_slider_row)
partial_slider_layout.setContentsMargins(0, 5, 0, 0)
duplicate_frame_layout.addWidget(partial_slider_row)
self.partial_ratio_weight = float(self.config.get('glossary_partial_ratio_weight', 0.45))
self.partial_ratio_slider = QSlider(Qt.Horizontal)
self.partial_ratio_slider.setMinimum(0)
self.partial_ratio_slider.setMaximum(100)
self.partial_ratio_slider.setValue(int(self.partial_ratio_weight * 100))
self._disable_slider_mousewheel(self.partial_ratio_slider)
partial_slider_layout.addWidget(self.partial_ratio_slider)
self.partial_ratio_value_label = QLabel(f"{self.partial_ratio_weight:.2f}")
partial_slider_layout.addWidget(self.partial_ratio_value_label)
self.partial_ratio_desc_label = QLabel("")
duplicate_frame_layout.addWidget(self.partial_ratio_desc_label)
def update_partial_ratio_label(value):
weight = value / 100.0
self.partial_ratio_weight = weight
self.partial_ratio_value_label.setText(f"{weight:.2f}")
if weight == 0:
desc = "Disabled (no substring matching)"
elif weight <= 0.30:
desc = "Very light substring influence"
elif weight <= 0.60:
desc = "Moderate substring influence"
else:
desc = "Strong substring influence (may over-merge)"
self.partial_ratio_desc_label.setText(desc)
self.partial_ratio_slider.valueChanged.connect(update_partial_ratio_label)
update_partial_ratio_label(self.partial_ratio_slider.value())
# Honorifics filter toggle
if not hasattr(self, 'disable_honorifics_checkbox'):
self.disable_honorifics_checkbox = self._create_styled_checkbox("Disable honorifics filtering")
self.disable_honorifics_checkbox.setChecked(self.config.get('glossary_disable_honorifics_filter', False))
duplicate_frame_layout.addWidget(self.disable_honorifics_checkbox)
honorifics_label = QLabel("When enabled, honorifics (님, さん, 先生, etc.) will NOT be removed from raw names")
# honorifics_label.setStyleSheet("color: gray; font-size: 9pt; margin-left: 20px;")
duplicate_frame_layout.addWidget(honorifics_label)
# Fuzzy matching slider
fuzzy_label = QLabel("Fuzzy Matching Threshold:")
# fuzzy_label.setStyleSheet("font-weight: bold; margin-top: 10px;")
duplicate_frame_layout.addWidget(fuzzy_label)
desc_label = QLabel("Controls how similar names must be to be considered duplicates")
# desc_label.setStyleSheet("color: gray; font-size: 9pt;")
duplicate_frame_layout.addWidget(desc_label)
# Slider widget
slider_widget = QWidget()
slider_layout = QHBoxLayout(slider_widget)
slider_layout.setContentsMargins(0, 5, 0, 0)
duplicate_frame_layout.addWidget(slider_widget)
# Always reload fuzzy threshold value from config
self.fuzzy_threshold_value = self.config.get('glossary_fuzzy_threshold', 0.90)
# Slider (store as self.manual_fuzzy_slider for syncing)
self.manual_fuzzy_slider = QSlider(Qt.Horizontal)
self.manual_fuzzy_slider.setMinimum(50) # 0.5 * 100
self.manual_fuzzy_slider.setMaximum(100) # 1.0 * 100
self.manual_fuzzy_slider.setValue(int(self.fuzzy_threshold_value * 100))
# Use screen ratio: ~30% of screen width
slider_width = int(self._screen.width() * 0.30)
self.manual_fuzzy_slider.setMinimumWidth(slider_width)
self._disable_slider_mousewheel(self.manual_fuzzy_slider) # Disable mouse wheel
slider_layout.addWidget(self.manual_fuzzy_slider)
# Value label
self.manual_fuzzy_value_label = QLabel(f"{self.fuzzy_threshold_value:.2f}")
slider_layout.addWidget(self.manual_fuzzy_value_label)
# Description label
self.manual_fuzzy_desc_label = QLabel("")
# self.manual_fuzzy_desc_label.setStyleSheet("color: white; font-size: 9pt; margin-top: 5px;")
duplicate_frame_layout.addWidget(self.manual_fuzzy_desc_label)
# Token-efficient format toggle
format_frame = QGroupBox("Output Format")
format_frame_layout = QVBoxLayout(format_frame)
manual_layout.addWidget(format_frame)
# Initialize variable if not exists
if not hasattr(self, 'use_legacy_csv_checkbox'):
self.use_legacy_csv_checkbox = self._create_styled_checkbox("Use legacy CSV format")
self.use_legacy_csv_checkbox.setChecked(self.config.get('glossary_use_legacy_csv', False))
format_frame_layout.addWidget(self.use_legacy_csv_checkbox)
label1 = QLabel("When disabled (default): Uses token-efficient format with sections (=== CHARACTERS ===)")
# label1.setStyleSheet("color: gray; font-size: 9pt; margin-left: 20px;")
format_frame_layout.addWidget(label1)
label2 = QLabel("When enabled: Uses traditional CSV format with repeated type columns")
# label2.setStyleSheet("color: gray; font-size: 9pt; margin-left: 20px;")
format_frame_layout.addWidget(label2)
# Legacy JSON Output Toggle
if not hasattr(self, 'glossary_json_output_checkbox'):
self.glossary_json_output_checkbox = self._create_styled_checkbox("Output legacy JSON format")
# Default to False as requested
self.glossary_json_output_checkbox.setChecked(self.config.get('glossary_output_legacy_json', False))
format_frame_layout.addWidget(self.glossary_json_output_checkbox)
label3 = QLabel("When enabled: Outputs a .json file containing the glossary structure")
format_frame_layout.addWidget(label3)
# Update label when slider moves
def update_manual_fuzzy_label(value):
float_value = value / 100.0
self.fuzzy_threshold_value = float_value
self.manual_fuzzy_value_label.setText(f"{float_value:.2f}")
# Show description
if float_value >= 0.95:
desc = "Exact match only (strict)"
elif float_value >= 0.85:
desc = "Very similar names (recommended)"
elif float_value >= 0.75:
desc = "Moderately similar names"
elif float_value >= 0.65:
desc = "Loosely similar names"
else:
desc = "Very loose matching (may over-merge)"
self.manual_fuzzy_desc_label.setText(desc)
# Sync with auto glossary slider and labels if they exist
if hasattr(self, 'fuzzy_threshold_slider'):
self.fuzzy_threshold_slider.blockSignals(True)
self.fuzzy_threshold_slider.setValue(value)
self.fuzzy_threshold_slider.blockSignals(False)
# Update auto labels directly without triggering signals
if hasattr(self, 'auto_fuzzy_value_label') and hasattr(self, 'auto_fuzzy_desc_label'):
self.auto_fuzzy_value_label.setText(f"{float_value:.2f}")
self.auto_fuzzy_desc_label.setText(desc)
# Store update function for cross-tab syncing
self.update_manual_fuzzy_label_func = update_manual_fuzzy_label
# Connect slider to update function
self.manual_fuzzy_slider.valueChanged.connect(update_manual_fuzzy_label)
# Initialize description
update_manual_fuzzy_label(self.manual_fuzzy_slider.value())
# Target language dropdown (above prompt)
language_frame = QGroupBox("Target Language")
language_frame_layout = QVBoxLayout(language_frame)
manual_layout.addWidget(language_frame)
# Create language dropdown
if not hasattr(self, 'manual_target_language_combo'):
self.manual_target_language_combo = QComboBox()
self.manual_target_language_combo.setMaximumWidth(200)
self.manual_target_language_combo.setEditable(True)
languages = [
"English", "Spanish", "French", "German", "Italian", "Portuguese",
"Russian", "Arabic", "Hindi", "Chinese (Simplified)",
"Chinese (Traditional)", "Japanese", "Korean", "Turkish"
]
self.manual_target_language_combo.addItems(languages)
# Lock mousewheel scrolling on target language dropdown
self._disable_combobox_mousewheel(self.manual_target_language_combo)
# Use icon in dropdown arrow like auto glossary dropdown
try:
icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'Halgakos.ico')
if os.path.exists(icon_path):
combo_style = """
QComboBox {
padding-right: 28px;
}
QComboBox::drop-down {
subcontrol-origin: padding;
subcontrol-position: top right;
width: 24px;
border-left: 1px solid #4a5568;
}
QComboBox::down-arrow {
width: 16px;
height: 16px;
image: url(""" + icon_path.replace('\\', '/') + """);
}
QComboBox::down-arrow:on {
top: 1px;
}
"""
self.manual_target_language_combo.setStyleSheet(combo_style)
except Exception:
pass
saved_language = self.config.get('glossary_target_language', self.config.get('output_language', 'English'))
index = self.manual_target_language_combo.findText(saved_language)
if index >= 0:
self.manual_target_language_combo.setCurrentIndex(index)
else:
self.manual_target_language_combo.setCurrentText(saved_language)
# Sync with auto glossary language dropdown and main GUI
def sync_manual_to_auto(text):
# Sync with auto glossary dropdown
if hasattr(self, 'glossary_target_language_combo'):
auto_index = self.glossary_target_language_combo.findText(text)
if auto_index >= 0:
self.glossary_target_language_combo.blockSignals(True)
self.glossary_target_language_combo.setCurrentIndex(auto_index)
self.glossary_target_language_combo.blockSignals(False)
# Sync with main GUI dropdown
if hasattr(self, 'target_lang_combo'):
main_index = self.target_lang_combo.findText(text)
if main_index >= 0:
self.target_lang_combo.blockSignals(True)
self.target_lang_combo.setCurrentIndex(main_index)
self.target_lang_combo.blockSignals(False)
else:
self.target_lang_combo.blockSignals(True)
self.target_lang_combo.setCurrentText(text)
self.target_lang_combo.blockSignals(False)
# Update configs
self.config['glossary_target_language'] = text
self.config['output_language'] = text
os.environ['OUTPUT_LANGUAGE'] = text
self.manual_target_language_combo.currentTextChanged.connect(sync_manual_to_auto)
language_frame_layout.addWidget(self.manual_target_language_combo)
lang_desc = QLabel("Language for translated glossary entries (synced with Extraction Settings)")
language_frame_layout.addWidget(lang_desc)
# Prompt section
prompt_frame = QGroupBox("Extraction Prompt")
prompt_frame_layout = QVBoxLayout(prompt_frame)
manual_layout.addWidget(prompt_frame)
label1 = QLabel("Placeholders will be replaced with actual values during extraction.")
prompt_frame_layout.addWidget(label1)
# Copyable placeholder helper (matches auto glossary tab style)
placeholders_line = QLineEdit("Available placeholders: {fields}, {language}, {entries}")
placeholders_line.setReadOnly(True)
placeholders_line.setFrame(False)
placeholders_line.setStyleSheet("color: #5a9fd4; font-size: 9pt;")
placeholders_line.setCursorPosition(0)
placeholders_line.setToolTip("{fields} -> columns/entry types list\n{language} -> target language\n{entries} -> enabled custom entry types (comma list with ampersand)")
prompt_frame_layout.addWidget(placeholders_line)
self.manual_prompt_text = QTextEdit()
self.manual_prompt_text.setContextMenuPolicy(Qt.DefaultContextMenu) # keep copy/paste
# Use screen ratio: ~25% of screen height
prompt_height = int(self._screen.height() * 0.25)
self.manual_prompt_text.setMinimumHeight(prompt_height)
self.manual_prompt_text.setLineWrapMode(QTextEdit.WidgetWidth)
prompt_frame_layout.addWidget(self.manual_prompt_text)
# If the user clears the prompt and leaves the field, restore the default.
# (Avoids persisting an empty template and doesn't interfere with copy/paste while typing.)
_orig_focus_out = self.manual_prompt_text.focusOutEvent
def _manual_prompt_focus_out(event):
try:
if not self.manual_prompt_text.toPlainText().strip():
default_manual = getattr(self, 'default_manual_glossary_prompt', None)
if default_manual:
self.manual_prompt_text.setPlainText(default_manual)
except Exception:
pass
return _orig_focus_out(event)
self.manual_prompt_text.focusOutEvent = _manual_prompt_focus_out
# Always reload prompt from config to ensure fresh state
# Treat empty strings as missing so users always get a usable default.
default_manual_prompt = """You are a novel glossary extraction assistant.
You must strictly return ONLY CSV format with these columns and entry types in this exact order provided:
{fields}
For character entries, determine gender from context, leave empty if context is insufficient.
For non-character entries, leave gender empty.
The description column is mandatory and must be detailed
Critical Requirement: The translated name and description column must be in {language}.
For example:
character,ᫀ이히리ᄐ 나애,Dihirit Ade,female,The enigmatic guild leader of the Shadow Lotus who operates from the concealed backrooms of the capital, manipulating city politics through commerce and wielding dual daggers with lethal precision
character,ᫀ뢔사난,Kim Sang-hyu,male,A master swordsman from the Northern Sect known for his icy demeanor and unparalleled skill with the Frost Blade technique which he uses to defend the border fortress
CRITICAL EXTRACTION RULES:
- Extract All {entries}
- Do NOT extract sentences, dialogue, actions, questions, or statements as glossary entries
- REJECT entries that contain verbs or end with punctuation (?, !, .)
- REJECT entries starting with: "Me", "How", "What", "Why", "I", "He", "She", "They", "That's", "So", "Therefore", "Still", "But", "Protagonist". (The description column is excluded from this restriction)
- Do NOT output any entries that are rejected by the above rules; skip them entirely
- If unsure whether something is a proper noun/name, skip it
- The description column must contain detailed context/explanation
- You must include absolutely all characters found in the provided text in your glossary generation. Do not skip any character."""
# Keep a copy for later (e.g., when saving and the field was cleared)
self.default_manual_glossary_prompt = default_manual_prompt
manual_prompt_from_config = self.config.get('manual_glossary_prompt3', default_manual_prompt)
if not manual_prompt_from_config or not manual_prompt_from_config.strip():
self.manual_glossary_prompt = default_manual_prompt
else:
self.manual_glossary_prompt = manual_prompt_from_config
self.manual_prompt_text.setPlainText(self.manual_glossary_prompt)
prompt_controls_widget = QWidget()
prompt_controls_layout = QHBoxLayout(prompt_controls_widget)
prompt_controls_layout.setContentsMargins(0, 10, 0, 0)
manual_layout.addWidget(prompt_controls_widget)
def reset_manual_prompt():
reply = QMessageBox.question(parent, "Reset Prompt", "Reset manual glossary prompt to default?",
QMessageBox.Yes | QMessageBox.No)
if reply == QMessageBox.Yes:
self.manual_prompt_text.setPlainText(getattr(self, 'default_manual_glossary_prompt', self.manual_prompt_text.toPlainText()))
reset_btn = QPushButton("Reset to Default")
reset_btn.clicked.connect(reset_manual_prompt)
reset_btn.setStyleSheet("""
QPushButton {
background-color: #b8860b; /* dark yellow */
color: black;
padding: 5px;
border: 1px solid #8a6a08;
border-radius: 4px;
}
QPushButton:hover { background-color: #9a6d07; }
QPushButton:pressed { background-color: #8a6106; }
""")
prompt_controls_layout.addWidget(reset_btn)
prompt_controls_layout.addStretch()
# Settings
settings_frame = QGroupBox("Extraction Settings")
settings_frame_layout = QVBoxLayout(settings_frame)
manual_layout.addWidget(settings_frame)
settings_grid = QGridLayout()
settings_grid.setContentsMargins(2, 4, 6, 6)
settings_grid.setHorizontalSpacing(8)
settings_grid.setVerticalSpacing(6)
settings_frame_layout.addLayout(settings_grid)
# Compact label+field pair helper for manual Extraction Settings
def _m_pair(label_text, field_widget, label_width=120, tooltip=None):
cont = QWidget()
h = QHBoxLayout(cont)
h.setContentsMargins(0, 0, 0, 0)
h.setSpacing(6)
lbl = QLabel(label_text)
lbl.setFixedWidth(label_width)
lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
if tooltip:
lbl.setToolTip(tooltip)
h.addWidget(lbl)
h.addWidget(field_widget)
h.addStretch()
return cont
# Add icon to third column
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, QIcon
icon_label = QLabel()
icon_label.setMinimumSize(180, 180)
icon = QIcon(icon_path)
pixmap = icon.pixmap(140, 140) # Smaller icon with padding
icon_label.setPixmap(pixmap)
icon_label.setAlignment(Qt.AlignCenter)
settings_grid.addWidget(icon_label, 0, 2, 4, 1) # Span 4 rows
# Row 0: Temperature and Context Limit
self.manual_temp_entry = QLineEdit(str(self.config.get('manual_glossary_temperature', 0.1)))
self.manual_temp_entry.setFixedWidth(80)
settings_grid.addWidget(_m_pair(
"Temperature:", self.manual_temp_entry,
tooltip="AI creativity for manual extraction.\nLower = more deterministic (recommended 0.1–0.3)."
), 0, 0)
self.manual_context_entry = QLineEdit(str(self.config.get('manual_context_limit', 2)))
self.manual_context_entry.setFixedWidth(80)
settings_grid.addWidget(_m_pair(
"Context Limit:", self.manual_context_entry,
tooltip="This controls how many chapters to include with contextual translation"
), 0, 1)
# Row 1: Compression Factor and Rolling window checkbox
self.glossary_compression_factor_entry = QLineEdit(str(self.config.get('glossary_compression_factor', 1.0)))
self.glossary_compression_factor_entry.setFixedWidth(60)
# Auto Compression Factor toggle
self.glossary_auto_compression_checkbox = self._create_styled_checkbox("Auto")
self.glossary_auto_compression_checkbox.setChecked(self.config.get('glossary_auto_compression', True))
# Container for compression factor controls
comp_cont = QWidget()
comp_layout = QHBoxLayout(comp_cont)
comp_layout.setContentsMargins(0, 0, 0, 0)
comp_layout.setSpacing(8)
comp_layout.addWidget(self.glossary_compression_factor_entry)
comp_layout.addWidget(self.glossary_auto_compression_checkbox)
comp_layout.addStretch()
settings_grid.addWidget(_m_pair(
"Compression Factor:", comp_cont,
tooltip="How much to compress text before sending to the model.\nAuto adjusts based on token limit; manual overrides fixed value."
), 1, 0)
if not hasattr(self, 'glossary_history_rolling_checkbox'):
self.glossary_history_rolling_checkbox = self._create_styled_checkbox("Keep recent context instead of reset")
self.glossary_history_rolling_checkbox.setChecked(self.config.get('glossary_history_rolling', False))
self.glossary_history_rolling_checkbox.setToolTip(
"Keep recent glossary context between chunks instead of resetting.\nHelps continuity; may increase token use."
)
settings_grid.addWidget(self.glossary_history_rolling_checkbox, 1, 1)
# Row 2: Output Token Limit and Request Merging checkbox
# Default changed to -1 (auto/inherit) instead of 65536
self.glossary_output_token_limit_entry = QLineEdit(str(self.config.get('glossary_max_output_tokens', -1)))
self.glossary_output_token_limit_entry.setFixedWidth(80)
self.glossary_output_token_limit_entry.setToolTip("-1 = Use main translation Output Token Limit")
# Container for token limit to add "tokens" label
token_cont = QWidget()
token_layout = QHBoxLayout(token_cont)
token_layout.setContentsMargins(0, 0, 0, 0)
token_layout.setSpacing(6)
token_layout.addWidget(self.glossary_output_token_limit_entry)
# Add a small helper label to show actual value if -1
self.token_limit_helper = QLabel("")
self.token_limit_helper.setStyleSheet("color: gray; font-size: 8pt;")
token_layout.addWidget(self.token_limit_helper)
token_layout.addStretch()
settings_grid.addWidget(_m_pair(
"Output Token Limit:", token_cont,
tooltip="Max tokens allowed in AI responses for glossary extraction.\n-1 = inherit main translation limit."
), 2, 0)
# Label tooltip
token_cont.parentWidget().layout().itemAt(0).widget().setToolTip("Maximum tokens allowed in AI responses. -1 inherits main translation output limit.")
if not hasattr(self, 'glossary_request_merging_checkbox'):
self.glossary_request_merging_checkbox = self._create_styled_checkbox("Glossary Request Merging")
self.glossary_request_merging_checkbox.setChecked(self.config.get('glossary_request_merging_enabled', False))
self.glossary_request_merging_checkbox.setToolTip(
"Merge multiple small glossary requests before sending.\nReduces API calls; controlled by Merge Count below."
)
settings_grid.addWidget(self.glossary_request_merging_checkbox, 2, 1)
# Row 3: Chapter split toggle (output-limit safe chunking)
if not hasattr(self, 'glossary_enable_chapter_split_checkbox'):
self.glossary_enable_chapter_split_checkbox = self._create_styled_checkbox("Enable chapter splitting")
self.glossary_enable_chapter_split_checkbox.setToolTip("When enabled, large glossary chapters are auto-split using the output token limit and compression factor to avoid oversized requests.")
self.glossary_enable_chapter_split_checkbox.setChecked(self.config.get('glossary_enable_chapter_split', False))
self.glossary_enable_chapter_split_checkbox.setToolTip(
"Automatically split large chapters using token/output limits\nso each glossary request stays under size caps."
)
settings_grid.addWidget(self.glossary_enable_chapter_split_checkbox, 3, 0)
# Logic for Auto Compression Factor
def _update_glossary_compression():
try:
# Update helper label for token limit
try:
limit_val = int(self.glossary_output_token_limit_entry.text())
except ValueError:
limit_val = 65536
# Resolve -1 to actual max_output_tokens
if limit_val == -1:
actual_limit = getattr(self, 'max_output_tokens', 65536)
self.token_limit_helper.setText(f"(Auto: {actual_limit})")
else:
actual_limit = limit_val
self.token_limit_helper.setText("")
if not self.glossary_auto_compression_checkbox.isChecked():
self.glossary_compression_factor_entry.setEnabled(True)
return
self.glossary_compression_factor_entry.setEnabled(False)
# Logic: 1.0 | 1.2 | 1.4 | 1.5
if actual_limit < 16379:
factor = 1.0
elif actual_limit < 32769:
factor = 1.2
elif actual_limit < 65536:
factor = 1.4
else:
factor = 1.5
self.glossary_compression_factor_entry.setText(str(factor))
except Exception as e:
print(f"Error updating glossary compression: {e}")
# Connect signals
self.glossary_auto_compression_checkbox.toggled.connect(_update_glossary_compression)
self.glossary_output_token_limit_entry.textChanged.connect(lambda: _update_glossary_compression())
# Initial update
_update_glossary_compression()
# Row 3: Empty and Merge Count
self.glossary_request_merge_count_entry = QLineEdit(str(self.config.get('glossary_request_merge_count', 10)))
self.glossary_request_merge_count_entry.setFixedWidth(80)
settings_grid.addWidget(_m_pair(
"Merge Count:", self.glossary_request_merge_count_entry,
tooltip="When request merging is on, combine this many chunks\nbefore one glossary API call."
), 3, 1)
# Shortcut button: Anti Duplicate Parameters (glossary-specific)
anti_dup_btn = QPushButton("Anti Duplicate Parameters")
anti_dup_btn.setStyleSheet("""
QPushButton {
background-color: #2b6cb0;
color: white;
padding: 5px 10px;
border: 1px solid #1e4e8c;
border-radius: 4px;
font-size: 9pt;
font-weight: bold;
}
QPushButton:hover { background-color: #255f9a; }
QPushButton:pressed { background-color: #1f5286; }
""")
anti_dup_btn.setToolTip("Configure anti-duplicate parameters for glossary generation only.")
anti_dup_btn.clicked.connect(lambda: self._open_glossary_anti_duplicate_dialog(parent))
manual_layout.addWidget(anti_dup_btn)
def update_glossary_prompts(self):
"""Update glossary prompts from text widgets if they exist"""
try:
debug_enabled = getattr(self, 'config', {}).get('show_debug_buttons', False)
if hasattr(self, 'manual_prompt_text'):
manual_text = self.manual_prompt_text.toPlainText()
self.manual_glossary_prompt = manual_text.strip()
# If the prompt was cleared, restore the default so we never persist an empty template.
if not self.manual_glossary_prompt:
default_manual = getattr(self, 'default_manual_glossary_prompt', None)
if default_manual:
self.manual_glossary_prompt = default_manual.strip()
# Update the UI to reflect the restored default
try:
self.manual_prompt_text.blockSignals(True)
self.manual_prompt_text.setPlainText(default_manual)
finally:
self.manual_prompt_text.blockSignals(False)
# Save to config (using new key)
self.config['manual_glossary_prompt3'] = self.manual_glossary_prompt
if debug_enabled:
print(f"🔍 [UPDATE] manual_glossary_prompt3: {len(self.manual_glossary_prompt)} chars")
if hasattr(self, 'auto_prompt_text'):
self.unified_auto_glosary_prompt3 = self.auto_prompt_text.toPlainText().strip()
if debug_enabled:
print(f"🔍 [UPDATE] unified_auto_glosary_prompt3: {len(self.unified_auto_glosary_prompt3)} chars")
if hasattr(self, 'append_prompt_text'):
old_value = getattr(self, 'append_glossary_prompt', '<NOT SET>')
self.append_glossary_prompt = self.append_prompt_text.toPlainText().strip()
self.config['append_glossary_prompt'] = self.append_glossary_prompt
if debug_enabled:
print(f"🔍 [UPDATE] append_glossary_prompt: OLD='{old_value[:50]}...' NEW='{self.append_glossary_prompt[:50]}...' ({len(self.append_glossary_prompt)} chars)")
else:
# Always print this one since it's the problematic field
#print(f"Updated append_glossary_prompt from UI: '{self.append_glossary_prompt[:80]}...' ({len(self.append_glossary_prompt)} chars)")
pass
except Exception as e:
print(f"❌ Error updating glossary prompts: {e}")
import traceback
traceback.print_exc()
def _load_additional_glossary(self):
"""Load an additional glossary file (CSV/TXT/JSON/PDF) to append to auto-generated glossary"""
from PySide6.QtWidgets import QFileDialog, QMessageBox
# Open file dialog to select glossary file
file_path, _ = QFileDialog.getOpenFileName(
None,
"Select Additional Glossary File",
"",
"Glossary Files (*.csv *.txt *.json *.pdf *.md);;CSV Files (*.csv);;Text Files (*.txt);;JSON Files (*.json);;PDF Files (*.pdf);;Markdown Files (*.md);;All Files (*.*)"
)
if not file_path:
return # User cancelled
# Validate file exists
if not os.path.exists(file_path):
QMessageBox.warning(None, "File Not Found", f"Selected file does not exist:\n{file_path}")
return
# Load and validate the file content
try:
file_ext = os.path.splitext(file_path)[1].lower()
content_preview = ""
if file_ext == '.csv':
# Read CSV and validate format
with open(file_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
if lines:
content_preview = f"CSV file with {len(lines)} lines\nFirst line: {lines[0][:100]}"
elif file_ext == '.txt':
# Read text file
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
content_preview = f"Text file ({len(content)} chars)\nFirst 100 chars: {content[:100]}"
elif file_ext == '.md':
# Read markdown file (treat same as text)
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
content_preview = f"Markdown file ({len(content)} chars)\nFirst 100 chars: {content[:100]}"
elif file_ext == '.json':
# Read and validate JSON
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
content_preview = f"JSON file with {len(data)} entries" if isinstance(data, (list, dict)) else "JSON file"
elif file_ext == '.pdf':
# Just validate PDF exists (parsing will happen during glossary generation)
file_size = os.path.getsize(file_path)
content_preview = f"PDF file ({file_size} bytes)"
else:
QMessageBox.warning(None, "Unsupported Format", f"Unsupported file format: {file_ext}\nSupported formats: .csv, .txt, .json, .pdf, .md")
return
# Save to config
self.config['additional_glossary_path'] = file_path
self.config['add_additional_glossary'] = True # Auto-enable the checkbox
self.save_config()
# Update checkbox if it exists
if hasattr(self, 'add_additional_glossary_checkbox'):
self.add_additional_glossary_checkbox.setChecked(True)
# Update label if it exists
if hasattr(self, 'additional_glossary_label'):
self.additional_glossary_label.setText(f"(Current: {os.path.basename(file_path)})")
# Show success message with icon
msg_box = QMessageBox(None)
msg_box.setWindowTitle("Additional Glossary Loaded")
msg_box.setIcon(QMessageBox.Information)
# Use the actual file extension in the message
target_filename = f"glossary_extension{file_ext}"
msg_box.setText(f"Successfully loaded additional glossary:\n\n{os.path.basename(file_path)}\n\n{content_preview}\n\nThis will be copied as '{target_filename}' alongside the main glossary and sent to the API.")
# Set window icon
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 Exception:
pass # If icon fails to load, continue without it
msg_box.exec()
except Exception as e:
QMessageBox.critical(
None,
"Error Loading File",
f"Failed to load additional glossary:\n\n{str(e)}"
)
import traceback
traceback.print_exc()
def _setup_auto_glossary_tab(self, parent):
"""Setup automatic glossary tab with fully configurable prompts"""
# Create main layout for parent
auto_layout = QVBoxLayout(parent)
auto_layout.setContentsMargins(10, 10, 10, 10)
# Master toggle
master_toggle_widget = QWidget()
master_toggle_layout = QHBoxLayout(master_toggle_widget)
master_toggle_layout.setContentsMargins(0, 0, 0, 15)
auto_layout.addWidget(master_toggle_widget)
if not hasattr(self, 'enable_auto_glossary_checkbox'):
self.enable_auto_glossary_checkbox = self._create_styled_checkbox("Enable Automatic Glossary Generation")
self.enable_auto_glossary_checkbox.setChecked(self.config.get('enable_auto_glossary', False))
self.enable_auto_glossary_checkbox.setToolTip(
"Run glossary extraction during translation.\n"
"Finds names/terms per chunk and keeps them consistent."
)
master_toggle_layout.addWidget(self.enable_auto_glossary_checkbox)
label = QLabel("(Automatic extraction and translation of character names/Terms)")
# label.setStyleSheet("color: gray; font-size: 9pt;")
master_toggle_layout.addWidget(label)
master_toggle_layout.addStretch()
# Append glossary toggle
append_widget = QWidget()
append_layout = QHBoxLayout(append_widget)
append_layout.setContentsMargins(0, 0, 0, 15)
auto_layout.addWidget(append_widget)
if not hasattr(self, 'append_glossary_checkbox'):
self.append_glossary_checkbox = self._create_styled_checkbox("Append Glossary to System Prompt")
self.append_glossary_checkbox.setChecked(self.config.get('append_glossary', False))
self.append_glossary_checkbox.setToolTip(
"Send the current glossary to the model with every request.\n"
"Improves consistency across chapters."
)
append_layout.addWidget(self.append_glossary_checkbox)
label2 = QLabel("(Applies to ALL glossaries - manual and automatic)")
# label2.setStyleSheet("color: white; font-size: 10pt; font-style: italic;")
append_layout.addWidget(label2)
append_layout.addStretch()
# Add additional glossary toggle (below append glossary)
additional_glossary_widget = QWidget()
additional_glossary_layout = QHBoxLayout(additional_glossary_widget)
additional_glossary_layout.setContentsMargins(0, 0, 0, 15)
auto_layout.addWidget(additional_glossary_widget)
if not hasattr(self, 'add_additional_glossary_checkbox'):
self.add_additional_glossary_checkbox = self._create_styled_checkbox("Add Additional Glossary")
self.add_additional_glossary_checkbox.setChecked(self.config.get('add_additional_glossary', False))
self.add_additional_glossary_checkbox.setToolTip(
"Always include an external glossary file (CSV/JSON/TXT/PDF/MD)\n"
"alongside the generated glossary when calling the API."
)
additional_glossary_layout.addWidget(self.add_additional_glossary_checkbox)
# Load additional glossary button
load_additional_btn = QPushButton("Load Additional Glossary")
load_additional_btn.setStyleSheet("""
QPushButton {
background-color: #5a9fd4;
color: white;
padding: 5px 15px;
border-radius: 3px;
font-weight: bold;
}
QPushButton:hover { background-color: #7ab8e8; }
QPushButton:pressed { background-color: #4a8fc4; }
QPushButton:disabled { background-color: #cccccc; color: #666666; }
""")
load_additional_btn.clicked.connect(self._load_additional_glossary)
additional_glossary_layout.addWidget(load_additional_btn)
# Show current additional glossary path if exists
additional_glossary_path = self.config.get('additional_glossary_path', '')
if additional_glossary_path:
label_additional = QLabel(f"(Current: {os.path.basename(additional_glossary_path)})")
else:
label_additional = QLabel("(Sends additional glossary file alongside main glossary to API)")
additional_glossary_layout.addWidget(label_additional)
self.additional_glossary_label = label_additional # Store reference to update later
additional_glossary_layout.addStretch()
# Compress glossary toggle
compress_widget = QWidget()
compress_layout = QHBoxLayout(compress_widget)
compress_layout.setContentsMargins(0, 0, 0, 15)
auto_layout.addWidget(compress_widget)
if not hasattr(self, 'compress_glossary_checkbox'):
self.compress_glossary_checkbox = self._create_styled_checkbox("Compress Glossary Prompt")
self.compress_glossary_checkbox.setChecked(self.config.get('compress_glossary_prompt', True))
self.compress_glossary_checkbox.setToolTip(
"Only send glossary entries that appear in the current source text.\n"
"Saves tokens and cost; recommended ON."
)
compress_layout.addWidget(self.compress_glossary_checkbox)
label3 = QLabel("(Excludes glossary entries that don't appear in source text before sending to API)")
# label3.setStyleSheet("color: white; font-size: 10pt; font-style: italic;")
compress_layout.addWidget(label3)
compress_layout.addStretch()
# Include all characters toggle (Dynamic Max Limit)
# MOVED to row 3 next to max sentences entry for better context
# include_chars_widget = QWidget()
# include_chars_layout = QHBoxLayout(include_chars_widget)
# include_chars_layout.setContentsMargins(0, 0, 0, 15)
# auto_layout.addWidget(include_chars_widget)
# if not hasattr(self, 'include_all_characters_checkbox'):
# self.include_all_characters_checkbox = self._create_styled_checkbox("Dynamic Limit: Include All Characters")
# self.include_all_characters_checkbox.setChecked(self.config.get('glossary_include_all_characters', False))
# self.include_all_characters_checkbox.setToolTip("Dynamically increases the Max Sentences limit to ensure every detected character name is included in the context, even if it exceeds the fixed limit.")
# include_chars_layout.addWidget(self.include_all_characters_checkbox)
# label_chars = QLabel("(Dynamically increases sentence limit to cover ALL character names)")
# include_chars_layout.addWidget(label_chars)
# include_chars_layout.addStretch()
# Include gender context toggle (below compress glossary)
gender_context_widget = QWidget()
gender_context_layout = QHBoxLayout(gender_context_widget)
gender_context_layout.setContentsMargins(0, 0, 0, 15)
auto_layout.addWidget(gender_context_widget)
if not hasattr(self, 'include_gender_context_checkbox'):
self.include_gender_context_checkbox = self._create_styled_checkbox("Include Gender Context (More Expensive)")
self.include_gender_context_checkbox.setChecked(self.config.get('include_gender_context', False))
self.include_gender_context_checkbox.setToolTip(
"Expand snippets with surrounding sentences to infer gender.\n"
"Higher cost; required to enable gender nuance/description options."
)
gender_context_layout.addWidget(self.include_gender_context_checkbox)
label4 = QLabel("(Expands text snippets to include surrounding sentences for better gender detection)")
gender_context_layout.addWidget(label4)
gender_context_layout.addStretch()
# Gender nuance analysis toggle (depends on gender context)
gender_nuance_widget = QWidget()
gender_nuance_layout = QHBoxLayout(gender_nuance_widget)
gender_nuance_layout.setContentsMargins(0, 0, 0, 15)
auto_layout.addWidget(gender_nuance_widget)
if not hasattr(self, 'enable_gender_nuance_checkbox'):
self.enable_gender_nuance_checkbox = self._create_styled_checkbox("Enable Gender Nuance Analysis")
self.enable_gender_nuance_checkbox.setChecked(self.config.get('enable_gender_nuance', True))
self.enable_gender_nuance_checkbox.setToolTip(
"Adds a pronoun/honorific-aware scoring pass to prioritize\n"
"sentences that reveal gender. Slightly higher CPU/time cost."
)
gender_nuance_layout.addWidget(self.enable_gender_nuance_checkbox)
gender_nuance_label = QLabel("(Prioritizes pronoun/honorific cues to improve gender assignment; adds a scoring pass)")
gender_nuance_layout.addWidget(gender_nuance_label)
gender_nuance_layout.addStretch()
# Include description column toggle (below gender context)
description_widget = QWidget()
description_layout = QHBoxLayout(description_widget)
description_layout.setContentsMargins(0, 0, 0, 15)
auto_layout.addWidget(description_widget)
if not hasattr(self, 'include_description_checkbox'):
self.include_description_checkbox = self._create_styled_checkbox("Include Description Column")
self.include_description_checkbox.setChecked(self.config.get('include_description', False))
self.include_description_checkbox.setToolTip(
"Add a description/context field to each glossary entry.\n"
"Only available when gender context is enabled."
)
description_layout.addWidget(self.include_description_checkbox)
label5 = QLabel("(Adds a description/context field for each glossary entry)")
description_layout.addWidget(label5)
description_layout.addStretch()
# Function to update description checkbox state based on gender context
def update_description_state():
gender_enabled = self.include_gender_context_checkbox.isChecked()
self.include_description_checkbox.setEnabled(gender_enabled)
label5.setEnabled(gender_enabled)
self.enable_gender_nuance_checkbox.setEnabled(gender_enabled)
gender_nuance_label.setEnabled(gender_enabled)
# Connect gender context checkbox to update description state
self.include_gender_context_checkbox.stateChanged.connect(update_description_state)
# Initialize state
update_description_state()
# Disable smart filtering toggle (below description)
disable_filtering_widget = QWidget()
disable_filtering_layout = QHBoxLayout(disable_filtering_widget)
disable_filtering_layout.setContentsMargins(0, 0, 0, 15)
auto_layout.addWidget(disable_filtering_widget)
if not hasattr(self, 'disable_smart_filtering_checkbox'):
self.disable_smart_filtering_checkbox = self._create_styled_checkbox("Disable Smart Filtering (Send Full Text)")
# Invert the logic: checkbox is "disable" so checked=True means use_smart_filter=False
self.disable_smart_filtering_checkbox.setChecked(not self.config.get('glossary_use_smart_filter', True))
self.disable_smart_filtering_checkbox.setToolTip(
"Bypass all text filtering and send the entire novel to the extractor.\n"
"Extremely expensive; use only for debugging edge cases."
)
disable_filtering_layout.addWidget(self.disable_smart_filtering_checkbox)
label6 = QLabel("(Disables all text filtering and sends the entire novel to the API - very expensive!)")
disable_filtering_layout.addWidget(label6)
disable_filtering_layout.addStretch()
# Custom append prompt section
append_prompt_frame = QGroupBox("Glossary Append Format")
append_prompt_layout = QVBoxLayout(append_prompt_frame)
append_prompt_layout.setContentsMargins(10, 10, 10, 10) # Tighter margins
append_prompt_layout.setSpacing(5) # Reduced spacing
auto_layout.addWidget(append_prompt_frame)
self.append_prompt_text = QTextEdit()
self.append_prompt_text.setFixedHeight(60)
self.append_prompt_text.setLineWrapMode(QTextEdit.WidgetWidth)
append_prompt_layout.addWidget(self.append_prompt_text)
# Always reload append prompt from config to ensure fresh state
# Treat empty string as missing to ensure users get the default
default_append_prompt = "- Follow this reference glossary for consistent translation (Do not output any raw entries):\n"
append_prompt_from_config = self.config.get('append_glossary_prompt', default_append_prompt)
if not append_prompt_from_config or not append_prompt_from_config.strip():
self.append_glossary_prompt = default_append_prompt
else:
self.append_glossary_prompt = append_prompt_from_config
self.append_prompt_text.setPlainText(self.append_glossary_prompt)
append_prompt_controls_widget = QWidget()
append_prompt_controls_layout = QHBoxLayout(append_prompt_controls_widget)
append_prompt_controls_layout.setContentsMargins(0, 5, 0, 0)
append_prompt_layout.addWidget(append_prompt_controls_widget)
def reset_append_prompt():
reply = QMessageBox.question(parent, "Reset Prompt", "Reset to default glossary append format?",
QMessageBox.Yes | QMessageBox.No)
if reply == QMessageBox.Yes:
self.append_prompt_text.setPlainText("- Follow this reference glossary for consistent translation (Do not output any raw entries):\n")
reset_append_btn = QPushButton("Reset to Default")
reset_append_btn.clicked.connect(reset_append_prompt)
reset_append_btn.setStyleSheet("""
QPushButton {
background-color: #b8860b;
color: black;
padding: 5px;
border: 1px solid #8a6a08;
border-radius: 4px;
font-weight: bold;
}
QPushButton:hover { background-color: #9a6d07; }
QPushButton:pressed { background-color: #8a6106; }
""")
append_prompt_controls_layout.addWidget(reset_append_btn)
append_prompt_controls_layout.addStretch()
# Create notebook for tabs
notebook = QTabWidget()
# Prevent wheel from switching tabs, but allow wheel scrolling inside sections
class NoWheelTabBar(QTabBar):
def wheelEvent(self, event):
event.ignore()
notebook.setTabBar(NoWheelTabBar())
auto_layout.addWidget(notebook)
# Add stretch to eliminate the massive empty space
auto_layout.addStretch(1)
# Tab 1: Extraction Settings
extraction_tab = QWidget()
extraction_tab_layout = QVBoxLayout(extraction_tab)
extraction_tab_layout.setContentsMargins(10, 10, 10, 10)
notebook.addTab(extraction_tab, "Extraction Settings")
# Extraction settings
settings_label_frame = QGroupBox("Targeted Extraction Settings")
settings_label_layout = QVBoxLayout(settings_label_frame)
settings_label_layout.setContentsMargins(6, 6, 6, 6)
extraction_tab_layout.addWidget(settings_label_frame)
extraction_grid = QGridLayout()
# Tighten spacing between labels and controls inside Targeted Extraction Settings
extraction_grid.setContentsMargins(2, 4, 6, 6)
extraction_grid.setHorizontalSpacing(8)
extraction_grid.setVerticalSpacing(6)
# Set column stretch factors to minimize gap between left and right columns
extraction_grid.setColumnStretch(0, 0)
extraction_grid.setColumnStretch(1, 1)
extraction_grid.setColumnStretch(2, 0)
extraction_grid.setColumnStretch(3, 1)
settings_label_layout.addLayout(extraction_grid)
# Initialize entry widgets with config values
if not hasattr(self, 'glossary_min_frequency_entry'):
self.glossary_min_frequency_entry = QLineEdit()
self.glossary_min_frequency_entry.setFixedWidth(80)
self.glossary_min_frequency_entry.setText(str(self.config.get('glossary_min_frequency', 2)))
if not hasattr(self, 'glossary_max_names_entry'):
self.glossary_max_names_entry = QLineEdit()
self.glossary_max_names_entry.setFixedWidth(80)
self.glossary_max_names_entry.setText(str(self.config.get('glossary_max_names', 100)))
if not hasattr(self, 'glossary_max_titles_entry'):
self.glossary_max_titles_entry = QLineEdit()
self.glossary_max_titles_entry.setFixedWidth(80)
self.glossary_max_titles_entry.setText(str(self.config.get('glossary_max_titles', 50)))
if not hasattr(self, 'glossary_context_window_entry'):
self.glossary_context_window_entry = QLineEdit()
self.glossary_context_window_entry.setFixedWidth(80)
self.glossary_context_window_entry.setText(str(self.config.get('glossary_context_window', 2)))
if not hasattr(self, 'glossary_max_text_size_entry'):
self.glossary_max_text_size_entry = QLineEdit()
self.glossary_max_text_size_entry.setFixedWidth(80)
self.glossary_max_text_size_entry.setText(str(self.config.get('glossary_max_text_size', 0)))
if not hasattr(self, 'glossary_chapter_split_threshold_entry'):
self.glossary_chapter_split_threshold_entry = QLineEdit()
self.glossary_chapter_split_threshold_entry.setFixedWidth(80)
self.glossary_chapter_split_threshold_entry.setText(str(self.config.get('glossary_chapter_split_threshold', 0)))
if not hasattr(self, 'glossary_max_sentences_entry'):
self.glossary_max_sentences_entry = QLineEdit()
self.glossary_max_sentences_entry.setFixedWidth(80)
self.glossary_max_sentences_entry.setText(str(self.config.get('glossary_max_sentences', 200)))
# Helper: compact label+field pair in one cell
def _pair(label_text, field_widget, label_width=180, tooltip=None):
cont = QWidget()
h = QHBoxLayout(cont)
h.setContentsMargins(0, 0, 0, 0)
h.setSpacing(6)
lbl = QLabel(label_text)
lbl.setFixedWidth(label_width)
lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
if tooltip:
lbl.setToolTip(tooltip)
h.addWidget(lbl)
h.addWidget(field_widget)
h.addStretch()
return cont
# Row 1 (left/right pairs)
extraction_grid.addWidget(_pair(
"Min frequency:", self.glossary_min_frequency_entry,
tooltip="Times a name must appear before keeping it.\nLower = more terms; affects dynamic sentence limit."
), 0, 0, 1, 2)
extraction_grid.addWidget(_pair(
"Max names:", self.glossary_max_names_entry,
tooltip="Prompt suggestion for how many character names to keep per chunk."
), 0, 2, 1, 2)
# Row 2
extraction_grid.addWidget(_pair(
"Max titles:", self.glossary_max_titles_entry,
tooltip="Prompt suggestion for how many titles/terms to keep per chunk."
), 1, 0, 1, 2)
extraction_grid.addWidget(_pair(
"Context window size:", self.glossary_context_window_entry,
tooltip="Sentences before/after a hit used for gender detection (default: 2)."
), 1, 2, 1, 2)
# Row 3 - Max text size and target language
extraction_grid.addWidget(_pair(
"Max text size:", self.glossary_max_text_size_entry,
tooltip="Characters to analyze (0 = entire text, 50000 = first 50k chars)."
), 2, 0, 1, 2)
# Target language dropdown
if not hasattr(self, 'glossary_target_language_combo'):
self.glossary_target_language_combo = QComboBox()
self.glossary_target_language_combo.setMaximumWidth(120)
self.glossary_target_language_combo.setEditable(True)
languages = [
"English", "Spanish", "French", "German", "Italian", "Portuguese",
"Russian", "Arabic", "Hindi", "Chinese (Simplified)",
"Chinese (Traditional)", "Japanese", "Korean", "Turkish"
]
self.glossary_target_language_combo.addItems(languages)
# Lock mousewheel scrolling on target language dropdown
self._disable_combobox_mousewheel(self.glossary_target_language_combo)
# Use icon in dropdown arrow like duplicate algorithm dropdown
try:
icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'Halgakos.ico')
if os.path.exists(icon_path):
combo_style = """
QComboBox {
padding-right: 28px;
}
QComboBox::drop-down {
subcontrol-origin: padding;
subcontrol-position: top right;
width: 24px;
border-left: 1px solid #4a5568;
}
QComboBox::down-arrow {
width: 16px;
height: 16px;
image: url(""" + icon_path.replace('\\', '/') + """);
}
QComboBox::down-arrow:on {
top: 1px;
}
"""
self.glossary_target_language_combo.setStyleSheet(combo_style)
except Exception:
pass
saved_language = self.config.get('glossary_target_language', self.config.get('output_language', 'English'))
index = self.glossary_target_language_combo.findText(saved_language)
if index >= 0:
self.glossary_target_language_combo.setCurrentIndex(index)
else:
self.glossary_target_language_combo.setCurrentText(saved_language)
# Sync with manual glossary language dropdown and main GUI
def sync_auto_to_manual(text):
# Sync with manual glossary dropdown
if hasattr(self, 'manual_target_language_combo'):
manual_index = self.manual_target_language_combo.findText(text)
if manual_index >= 0:
self.manual_target_language_combo.blockSignals(True)
self.manual_target_language_combo.setCurrentIndex(manual_index)
self.manual_target_language_combo.blockSignals(False)
# Sync with main GUI dropdown
if hasattr(self, 'target_lang_combo'):
main_index = self.target_lang_combo.findText(text)
if main_index >= 0:
self.target_lang_combo.blockSignals(True)
self.target_lang_combo.setCurrentIndex(main_index)
self.target_lang_combo.blockSignals(False)
else:
self.target_lang_combo.blockSignals(True)
self.target_lang_combo.setCurrentText(text)
self.target_lang_combo.blockSignals(False)
# Update configs
self.config['glossary_target_language'] = text
self.config['output_language'] = text
os.environ['OUTPUT_LANGUAGE'] = text
self.glossary_target_language_combo.currentTextChanged.connect(sync_auto_to_manual)
# Add tooltip explaining prompt placeholder + sync behavior
target_lang_label = QLabel("Target language:")
target_lang_label.setToolTip("Replaces {language} placeholder in AI prompts; synced across all target language dropdowns.")
target_lang_pair = _pair("", self.glossary_target_language_combo)
# swap label inside helper
target_lang_pair.layout().itemAt(0).widget().setText("Target language:")
target_lang_pair.layout().itemAt(0).widget().setToolTip("Replaces {language} placeholder in AI prompts; synced across all target language dropdowns.")
extraction_grid.addWidget(target_lang_pair, 2, 2, 1, 2)
# Row 4 - Max sentences and chapter split threshold
# Max sentences for glossary (with inline hint)
ms_cont = QWidget()
ms_layout = QHBoxLayout(ms_cont)
ms_layout.setContentsMargins(0, 0, 0, 0)
ms_layout.setSpacing(6)
ms_label = QLabel("Max sentences:")
ms_label.setFixedWidth(180)
ms_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
ms_label.setToolTip(
"Maximum sentences sent to AI.\nDynamic Limit adds +1 per detected character before dedup."
)
ms_layout.addWidget(ms_label)
ms_layout.addWidget(self.glossary_max_sentences_entry)
# Include All Characters toggle (dynamic limit)
if not hasattr(self, 'include_all_characters_checkbox'):
self.include_all_characters_checkbox = self._create_styled_checkbox("Dynamic Limit Expansion")
self.include_all_characters_checkbox.setToolTip(
"Dynamic Limit Expansion:\n"
"- Adds one sentence per detected character on top of 'Max sentences'.\n"
"- After selection, duplicate sentences are removed, so both the bonus and the base cap can shrink.\n"
"- Example: requesting 200 + 700 bonus may dedupe down to ~120 total."
)
self.include_all_characters_checkbox.setChecked(self.config.get('glossary_include_all_characters', False))
ms_layout.addWidget(self.include_all_characters_checkbox)
hint = QLabel("(Limit for AI processing)")
hint.setStyleSheet("color: gray;")
ms_layout.addWidget(hint)
ms_layout.addStretch()
extraction_grid.addWidget(ms_cont, 3, 0, 1, 2)
extraction_grid.addWidget(_pair(
"Chapter split threshold:", self.glossary_chapter_split_threshold_entry,
tooltip="Split large texts into chunks (0 = token-based; 100000 = split at 100k chars)."
), 3, 2, 1, 2)
# Row 5 - Filter mode
filter_label = QLabel("Filter mode:")
filter_label.setToolTip(
"Choose which names/terms to keep:\n"
"- All names & terms\n"
"- Names with honorifics only\n"
"- Names without honorifics & terms"
)
extraction_grid.addWidget(filter_label, 4, 0)
filter_widget = QWidget()
filter_layout = QHBoxLayout(filter_widget)
filter_layout.setContentsMargins(0, 0, 0, 0)
filter_layout.setSpacing(8)
extraction_grid.addWidget(filter_widget, 4, 1, 1, 3)
if not hasattr(self, 'glossary_filter_mode_buttons'):
self.glossary_filter_mode_buttons = {}
filter_mode_value = self.config.get('glossary_filter_mode', 'all')
radio1 = QRadioButton("All names & terms")
radio1.setChecked(self.config.get('glossary_filter_mode', 'all') == 'all')
self.glossary_filter_mode_buttons['all'] = radio1
filter_layout.addWidget(radio1)
radio2 = QRadioButton("Names with honorifics only")
radio2.setChecked(self.config.get('glossary_filter_mode', 'all') == 'only_with_honorifics')
self.glossary_filter_mode_buttons['only_with_honorifics'] = radio2
filter_layout.addWidget(radio2)
radio3 = QRadioButton("Names without honorifics & terms")
radio3.setChecked(self.config.get('glossary_filter_mode', 'all') == 'only_without_honorifics')
self.glossary_filter_mode_buttons['only_without_honorifics'] = radio3
filter_layout.addWidget(radio3)
filter_layout.addStretch()
# Row 6 - Strip honorifics
strip_label = QLabel("Strip honorifics:")
strip_label.setToolTip("Remove suffixes from extracted names (e.g., '-nim', '-san').")
extraction_grid.addWidget(strip_label, 5, 0)
if not hasattr(self, 'strip_honorifics_checkbox'):
self.strip_honorifics_checkbox = self._create_styled_checkbox("Remove honorifics from extracted names")
self.strip_honorifics_checkbox.setToolTip("Remove suffixes from extracted names (e.g., '-nim', '-san').")
# Always reload from config
self.strip_honorifics_checkbox.setChecked(self.config.get('strip_honorifics', True))
extraction_grid.addWidget(self.strip_honorifics_checkbox, 5, 1, 1, 3)
# Row 7 - Fuzzy matching threshold (reuse existing value)
fuzzy_label = QLabel("Fuzzy threshold:")
fuzzy_label.setToolTip("Similarity needed to merge duplicates.\n0.90 = very similar (recommended); 1.0 = exact match.")
extraction_grid.addWidget(fuzzy_label, 6, 0)
auto_fuzzy_widget = QWidget()
auto_fuzzy_layout = QHBoxLayout(auto_fuzzy_widget)
auto_fuzzy_layout.setContentsMargins(0, 0, 0, 0)
auto_fuzzy_layout.setSpacing(8)
extraction_grid.addWidget(auto_fuzzy_widget, 6, 1, 1, 3)
# Always reload fuzzy threshold value from config
try:
self.fuzzy_threshold_value = float(self.config.get('glossary_fuzzy_threshold', 0.90))
except Exception:
self.fuzzy_threshold_value = 0.90
# Create slider and expose on self for save handler
self.fuzzy_threshold_slider = QSlider(Qt.Horizontal)
self.fuzzy_threshold_slider.setMinimum(50)
self.fuzzy_threshold_slider.setMaximum(100)
self.fuzzy_threshold_slider.setValue(int(self.fuzzy_threshold_value * 100))
self.fuzzy_threshold_slider.setMinimumWidth(250)
self._disable_slider_mousewheel(self.fuzzy_threshold_slider) # Disable mouse wheel
auto_fuzzy_layout.addWidget(self.fuzzy_threshold_slider)
self.auto_fuzzy_value_label = QLabel(f"{self.fuzzy_threshold_value:.2f}")
auto_fuzzy_layout.addWidget(self.auto_fuzzy_value_label)
self.auto_fuzzy_desc_label = QLabel("")
self.auto_fuzzy_desc_label.setStyleSheet("color: gray; font-size: 9pt;")
auto_fuzzy_layout.addWidget(self.auto_fuzzy_desc_label)
auto_fuzzy_layout.addStretch()
# Update function for auto fuzzy slider
def update_auto_fuzzy_label(value):
float_value = value / 100.0
self.fuzzy_threshold_value = float_value
self.auto_fuzzy_value_label.setText(f"{float_value:.2f}")
if float_value >= 0.95:
desc = "Exact match only (strict)"
elif float_value >= 0.85:
desc = "Very similar names (recommended)"
elif float_value >= 0.75:
desc = "Moderately similar names"
elif float_value >= 0.65:
desc = "Loosely similar names"
else:
desc = "Very loose matching (may over-merge)"
self.auto_fuzzy_desc_label.setText(desc)
# Sync with manual glossary slider and labels if they exist
if hasattr(self, 'manual_fuzzy_slider'):
self.manual_fuzzy_slider.blockSignals(True)
self.manual_fuzzy_slider.setValue(value)
self.manual_fuzzy_slider.blockSignals(False)
# Update manual labels directly without triggering signals
if hasattr(self, 'manual_fuzzy_value_label') and hasattr(self, 'manual_fuzzy_desc_label'):
self.manual_fuzzy_value_label.setText(f"{float_value:.2f}")
self.manual_fuzzy_desc_label.setText(desc)
# Store update function for cross-tab syncing
self.update_auto_fuzzy_label_func = update_auto_fuzzy_label
self.fuzzy_threshold_slider.valueChanged.connect(update_auto_fuzzy_label)
update_auto_fuzzy_label(self.fuzzy_threshold_slider.value())
# Row 8 - Reset to Defaults button
reset_extraction_btn = QPushButton("Reset to Defaults")
reset_extraction_btn.setStyleSheet("""
QPushButton {
background-color: #b8860b;
color: black;
padding: 5px 10px;
border: 1px solid #8a6a08;
border-radius: 4px;
font-size: 9pt;
font-weight: bold;
}
QPushButton:hover { background-color: #9a6d07; }
QPushButton:pressed { background-color: #8a6106; }
""")
def reset_extraction_settings():
reply = QMessageBox.question(parent, "Reset Settings",
"Reset all extraction settings to defaults?",
QMessageBox.Yes | QMessageBox.No)
if reply == QMessageBox.Yes:
# Reset all fields to defaults
self.glossary_min_frequency_entry.setText("2")
self.glossary_max_names_entry.setText("100")
self.glossary_max_titles_entry.setText("50")
self.glossary_context_window_entry.setText("2")
self.glossary_max_text_size_entry.setText("0")
self.glossary_chapter_split_threshold_entry.setText("0")
self.glossary_max_sentences_entry.setText("200")
self.glossary_target_language_combo.setCurrentText("English")
if hasattr(self, 'glossary_enable_chapter_split_checkbox'):
self.glossary_enable_chapter_split_checkbox.setChecked(False)
# Reset filter mode to 'all'
if 'all' in self.glossary_filter_mode_buttons:
self.glossary_filter_mode_buttons['all'].setChecked(True)
# Reset strip honorifics to True
if hasattr(self, 'strip_honorifics_checkbox'):
self.strip_honorifics_checkbox.setChecked(True)
# Reset fuzzy threshold to 0.90
if hasattr(self, 'fuzzy_threshold_slider'):
self.fuzzy_threshold_slider.setValue(90)
reset_extraction_btn.clicked.connect(reset_extraction_settings)
extraction_grid.addWidget(reset_extraction_btn, 7, 0, 1, 2)
# Row 8 - Anti Duplicate Parameters button (glossary-specific)
anti_dup_btn = QPushButton("Anti Duplicate Parameters")
anti_dup_btn.setStyleSheet("""
QPushButton {
background-color: #2b6cb0;
color: white;
padding: 5px 10px;
border: 1px solid #1e4e8c;
border-radius: 4px;
font-size: 9pt;
font-weight: bold;
}
QPushButton:hover { background-color: #255f9a; }
QPushButton:pressed { background-color: #1f5286; }
""")
anti_dup_btn.setToolTip("Configure anti-duplicate parameters for glossary generation only.")
anti_dup_btn.clicked.connect(lambda: self._open_glossary_anti_duplicate_dialog(parent))
extraction_grid.addWidget(anti_dup_btn, 7, 2, 1, 2)
# Help text
help_widget = QWidget()
help_layout = QVBoxLayout(help_widget)
help_layout.setContentsMargins(10, 10, 0, 0)
# Make the Settings Guide taller by default, but add a scrollbar if it still clips
help_scroll = QScrollArea()
help_scroll.setWidgetResizable(True)
help_scroll.setFrameShape(QFrame.NoFrame)
help_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
help_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
help_scroll.setWidget(help_widget)
try:
guide_h = int(self._screen.height() * 0.30)
guide_h = max(200, min(320, guide_h))
help_scroll.setMinimumHeight(guide_h)
except Exception:
help_scroll.setMinimumHeight(240)
help_scroll.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding)
extraction_tab_layout.addWidget(help_scroll)
help_title = QLabel("💡 Settings Guide:")
help_title.setStyleSheet("font-size: 12pt; font-weight: bold;")
help_layout.addWidget(help_title)
help_texts = [
"• Min frequency: How many times a name must appear (lower = more terms). Affects dynamic sentence limit.",
"• Max names/titles: AI Prompt placeholder only (limits are suggestions)",
"• Context window size: Number of sentences before/after for gender detection (default: 2)",
"• Max text size: Characters to analyze (0 = entire text, 50000 = first 50k chars)",
"• Chapter split: Split large texts into chunks (0 = Token based splitting, 100000 = split at 100k chars)",
"• Max sentences: Maximum sentences to send to AI. With Dynamic Limit on, base + character bonus is applied then deduped; duplicates can shrink both caps.",
"• Dynamic Limit Expansion: Adds one sentence per detected character before the cap; post-selection dedup drops overlapping sentences so final total may be lower than requested.",
"• Filter mode:",
" - All names & terms: Extract character names (with/without honorifics) + titles/terms",
" - Names with honorifics only: ONLY character names with honorifics (no titles/terms)",
" - Names without honorifics & terms: Character names without honorifics + titles/terms",
"• Strip honorifics: Remove suffixes from extracted names (e.g., '김' instead of '김님')",
"• Fuzzy threshold: How similar terms must be to match (0.9 = 90% match, 1.0 = exact match)"
]
help_wrap_width = int(self._screen.width() * 0.55) # keep help text from forcing huge width
for txt in help_texts:
label = QLabel(txt)
label.setWordWrap(True)
label.setMaximumWidth(help_wrap_width)
label.setStyleSheet("color: gray; font-size: 10pt; margin-left: 20px;")
help_layout.addWidget(label)
# Tab 2: Glossary Prompt (unified system + extraction)
glossary_prompt_tab = QWidget()
glossary_prompt_tab_layout = QVBoxLayout(glossary_prompt_tab)
glossary_prompt_tab_layout.setContentsMargins(10, 10, 10, 10)
notebook.addTab(glossary_prompt_tab, "Glossary Prompt")
# Unified glossary prompt section
glossary_prompt_frame = QGroupBox("Glossary Extraction Prompt")
glossary_prompt_frame_layout = QVBoxLayout(glossary_prompt_frame)
glossary_prompt_tab_layout.addWidget(glossary_prompt_frame)
desc_label = QLabel("This prompt guides the AI to extract character names, terms, and titles from the text:")
glossary_prompt_frame_layout.addWidget(desc_label)
placeholder_line = QLineEdit("Available placeholders: {language}, {min_frequency}, {max_names}, {max_titles}, {marker}")
placeholder_line.setReadOnly(True)
placeholder_line.setFrame(False)
placeholder_line.setCursorPosition(0)
placeholder_line.setStyleSheet("color: #5a9fd4; font-size: 9pt;")
placeholder_line.setToolTip("{language} -> target language\n{min_frequency} -> minimum term frequency\n{max_names} -> max character names\n{max_titles} -> max titles\n{marker} -> context window marker count")
glossary_prompt_frame_layout.addWidget(placeholder_line)
self.auto_prompt_text = QTextEdit()
self.auto_prompt_text.setMinimumHeight(250)
self.auto_prompt_text.setLineWrapMode(QTextEdit.WidgetWidth)
glossary_prompt_frame_layout.addWidget(self.auto_prompt_text)
# Default unified prompt (combines system + extraction instructions)
default_unified_prompt = """You are a novel glossary extraction assistant.
You must strictly return ONLY CSV format with 2-4 columns in this exact order: type,raw_name,translated_name,gender,description.
For character entries, determine gender from context, leave empty if context is insufficient.
For non-character entries, leave gender empty.
The description column is optional and can contain brief context (role, location, significance).
Critical Requirement: The translated name and description column must be in {language}.
For example:
character,ᫀ이히리ᄐ 나애,Dihirit Ade,female,The enigmatic guild leader of the Shadow Lotus who operates from the concealed backrooms of the capital, manipulating city politics through commerce and wielding dual daggers with lethal precision
character,ᫀ뢔사난,Kim Sang-hyu,male,A master swordsman from the Northern Sect known for his icy demeanor and unparalleled skill with the Frost Blade technique which he uses to defend the border fortress
CRITICAL EXTRACTION RULES:
- Extract All Character names, Terms, Location names, Ability/Skill names, Item names, Organization names, and Titles/Ranks
- Do NOT extract sentences, dialogue, actions, questions, or statements as glossary entries
- REJECT entries that contain verbs or end with punctuation (?, !, .)
- REJECT entries starting with: "Me", "How", "What", "Why", "I", "He", "She", "They", "That's", "So", "Therefore", "Still", "But", "Protagonist". (The description column is excluded from this restriction)
- Do NOT output any entries that are rejected by the above rules; skip them entirely
- If unsure whether something is a proper noun/name, skip it
- The description column must contain detailed context/explanation
- Create at least one glossary entry for EVERY context marker window (lines ending with "=== CONTEXT N END ==="); treat each marker boundary as a required extraction point.
- You must create {marker} glossary entries (one or more per window; do not invent placeholders).
- You must include absolutely all characters found in the provided text in your glossary generation. Do not skip any character."""
# Load from config or use default
# Note: Ignoring old '...prompt2' key to force update to new prompt
# Also treat empty strings as missing to ensure users get the new default
self.unified_auto_glosary_prompt3 = self.config.get('unified_auto_glosary_prompt3', default_unified_prompt)
if not self.unified_auto_glosary_prompt3 or not self.unified_auto_glosary_prompt3.strip():
self.unified_auto_glosary_prompt3 = default_unified_prompt
self.auto_prompt_text.setPlainText(self.unified_auto_glosary_prompt3)
glossary_prompt_controls_widget = QWidget()
glossary_prompt_controls_layout = QHBoxLayout(glossary_prompt_controls_widget)
glossary_prompt_controls_layout.setContentsMargins(0, 0, 0, 0)
glossary_prompt_tab_layout.addWidget(glossary_prompt_controls_widget)
def reset_glossary_prompt():
reply = QMessageBox.question(parent, "Reset Prompt", "Reset glossary prompt to default?",
QMessageBox.Yes | QMessageBox.No)
if reply == QMessageBox.Yes:
self.auto_prompt_text.setPlainText(default_unified_prompt)
reset_glossary_btn = QPushButton("Reset to Default")
reset_glossary_btn.clicked.connect(reset_glossary_prompt)
reset_glossary_btn.setStyleSheet("""
QPushButton {
background-color: #b8860b;
color: black;
padding: 5px;
border: 1px solid #8a6a08;
border-radius: 4px;
font-weight: bold;
}
QPushButton:hover { background-color: #9a6d07; }
QPushButton:pressed { background-color: #8a6106; }
""")
glossary_prompt_controls_layout.addWidget(reset_glossary_btn)
glossary_prompt_controls_layout.addStretch()
# Format Instructions removed - now hardcoded to just append {text_sample}
# Update states function with proper error handling - converted to use signals
def update_auto_glossary_state(checked=None):
enabled = self.enable_auto_glossary_checkbox.isChecked()
# Enable/disable the entire Targeted Extraction Settings group box
settings_label_frame.setEnabled(enabled)
# Enable/disable all extraction grid widgets (for thorough coverage)
for i in range(extraction_grid.count()):
item = extraction_grid.itemAt(i)
if item:
widget = item.widget()
if widget:
widget.setEnabled(enabled)
# Also enable/disable all children within compound widgets
for child in widget.findChildren(QWidget):
child.setEnabled(enabled)
# Enable/disable text widgets
self.auto_prompt_text.setEnabled(enabled)
def update_append_prompt_state(checked=None):
enabled = self.append_glossary_checkbox.isChecked()
self.append_prompt_text.setEnabled(enabled)
# Initialize states
update_auto_glossary_state()
update_append_prompt_state()
# Connect signals
self.enable_auto_glossary_checkbox.stateChanged.connect(update_auto_glossary_state)
self.append_glossary_checkbox.stateChanged.connect(update_append_prompt_state)
def _open_glossary_anti_duplicate_dialog(self, parent):
"""Open glossary-specific anti-duplicate parameters dialog."""
from PySide6.QtWidgets import QDialog, QVBoxLayout, QPushButton, QHBoxLayout, QLabel, QWidget
from PySide6.QtCore import Qt
if hasattr(self, 'glossary_anti_duplicate_dialog') and self.glossary_anti_duplicate_dialog:
try:
self.glossary_anti_duplicate_dialog.showNormal()
self.glossary_anti_duplicate_dialog.raise_()
self.glossary_anti_duplicate_dialog.activateWindow()
return
except Exception:
pass
dialog = QDialog(parent)
dialog.setWindowTitle("Glossary Anti-Duplicate Parameters")
dialog.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
# Use screen ratios for sizing (smaller than settings dialog)
screen = QApplication.primaryScreen().geometry()
width = int(screen.width() * 0.36)
height = int(screen.height() * 0.50)
dialog.setMinimumSize(width, height)
dialog.setAttribute(Qt.WA_DeleteOnClose, False)
main_layout = QVBoxLayout(dialog)
main_layout.setContentsMargins(12, 12, 12, 12)
# Title
title_label = QLabel("Glossary Anti-Duplicate Parameters")
title_label.setStyleSheet("font-size: 16pt; font-weight: bold;")
main_layout.addWidget(title_label)
# Anti-duplicate section (same layout as Other Settings)
section_box = self._create_glossary_anti_duplicate_section(dialog)
main_layout.addWidget(section_box)
# Ensure enabled/disabled state matches toggle
self._toggle_glossary_anti_duplicate_controls()
# Close button
controls = QWidget()
controls_layout = QHBoxLayout(controls)
controls_layout.addStretch()
close_btn = QPushButton("Close")
close_btn.clicked.connect(lambda: self._persist_glossary_anti_duplicate_settings(minimize_dialog=dialog))
controls_layout.addWidget(close_btn)
main_layout.addWidget(controls)
# Store and override close behavior to minimize instead of destroying
self.glossary_anti_duplicate_dialog = dialog
def _close_on_close(event):
try:
self._persist_glossary_anti_duplicate_settings(minimize_dialog=dialog)
event.accept()
except Exception:
event.accept()
dialog.closeEvent = _close_on_close
dialog.show()
def _persist_glossary_anti_duplicate_settings(self, minimize_dialog=None):
"""Persist glossary anti-duplicate settings to config and optionally minimize dialog."""
try:
if hasattr(self, 'glossary_enable_anti_duplicate_var'):
self.config['glossary_enable_anti_duplicate'] = bool(self.glossary_enable_anti_duplicate_var)
if hasattr(self, 'glossary_top_p_var'):
self.config['glossary_top_p'] = float(self.glossary_top_p_var)
if hasattr(self, 'glossary_top_k_var'):
self.config['glossary_top_k'] = int(self.glossary_top_k_var)
if hasattr(self, 'glossary_frequency_penalty_var'):
self.config['glossary_frequency_penalty'] = float(self.glossary_frequency_penalty_var)
if hasattr(self, 'glossary_presence_penalty_var'):
self.config['glossary_presence_penalty'] = float(self.glossary_presence_penalty_var)
if hasattr(self, 'glossary_repetition_penalty_var'):
self.config['glossary_repetition_penalty'] = float(self.glossary_repetition_penalty_var)
if hasattr(self, 'glossary_candidate_count_var'):
self.config['glossary_candidate_count'] = int(self.glossary_candidate_count_var)
if hasattr(self, 'glossary_custom_stop_sequences_var'):
self.config['glossary_custom_stop_sequences'] = str(self.glossary_custom_stop_sequences_var)
if hasattr(self, 'glossary_logit_bias_enabled_var'):
self.config['glossary_logit_bias_enabled'] = bool(self.glossary_logit_bias_enabled_var)
if hasattr(self, 'glossary_logit_bias_strength_var'):
self.config['glossary_logit_bias_strength'] = float(self.glossary_logit_bias_strength_var)
if hasattr(self, 'glossary_bias_common_words_var'):
self.config['glossary_bias_common_words'] = bool(self.glossary_bias_common_words_var)
if hasattr(self, 'glossary_bias_repetitive_phrases_var'):
self.config['glossary_bias_repetitive_phrases'] = bool(self.glossary_bias_repetitive_phrases_var)
self.save_config(show_message=False)
except Exception:
pass
if minimize_dialog is not None:
try:
minimize_dialog.close()
except Exception:
pass
def _create_glossary_anti_duplicate_section(self, parent):
"""Create glossary-specific anti-duplicate parameter controls with tabs (PySide6)."""
from PySide6.QtWidgets import QGroupBox, QVBoxLayout, QHBoxLayout, QLabel, 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 glossary outputs 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.glossary_enable_anti_duplicate_var = self.config.get('glossary_enable_anti_duplicate', False)
enable_cb = self._create_styled_checkbox("Enable Anti-Duplicate Parameters")
self.glossary_enable_anti_duplicate_checkbox = enable_cb
try:
enable_cb.setChecked(bool(self.glossary_enable_anti_duplicate_var))
except Exception:
pass
def _on_enable_anti_dup_toggle(checked):
try:
self.glossary_enable_anti_duplicate_var = bool(checked)
self._toggle_glossary_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.glossary_anti_duplicate_content = content_container
# Create tab widget for organized parameters
self.glossary_anti_duplicate_notebook = QTabWidget()
# Enhanced tab styling
self.glossary_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.glossary_anti_duplicate_notebook)
# Tab 1: Core Parameters
core_frame = QWidget()
core_v = QVBoxLayout(core_frame)
core_v.setContentsMargins(10, 10, 10, 10)
self.glossary_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.glossary_top_p_var = self.config.get('glossary_top_p', 1.0)
top_p_slider, self.glossary_top_p_value_label = _create_slider_row(core_v, "Top-P (Nucleus Sampling):", self, 'glossary_top_p_var', 0.1, 1.0, decimals=2)
# Top-K (Vocabulary Limit)
self.glossary_top_k_var = self.config.get('glossary_top_k', 0)
top_k_slider, self.glossary_top_k_value_label = _create_slider_row(core_v, "Top-K (Vocabulary Limit):", self, 'glossary_top_k_var', 0, 100, is_int=True)
# Frequency Penalty
self.glossary_frequency_penalty_var = self.config.get('glossary_frequency_penalty', 0.0)
freq_slider, self.glossary_freq_penalty_value_label = _create_slider_row(core_v, "Frequency Penalty:", self, 'glossary_frequency_penalty_var', 0.0, 2.0, decimals=2)
# Presence Penalty
self.glossary_presence_penalty_var = self.config.get('glossary_presence_penalty', 0.0)
pres_slider, self.glossary_pres_penalty_value_label = _create_slider_row(core_v, "Presence Penalty:", self, 'glossary_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.glossary_anti_duplicate_notebook.addTab(advanced_frame, "Advanced")
# Repetition Penalty
self.glossary_repetition_penalty_var = self.config.get('glossary_repetition_penalty', 1.0)
rep_slider, self.glossary_rep_penalty_value_label = _create_slider_row(advanced_v, "Repetition Penalty:", self, 'glossary_repetition_penalty_var', 1.0, 2.0, decimals=2)
# Candidate Count (Gemini)
self.glossary_candidate_count_var = self.config.get('glossary_candidate_count', 1)
candidate_slider, self.glossary_candidate_value_label = _create_slider_row(advanced_v, "Candidate Count (Gemini):", self, 'glossary_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.glossary_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.glossary_custom_stop_sequences_var = self.config.get('glossary_custom_stop_sequences', '')
stop_entry = QLineEdit()
stop_entry.setFixedWidth(300)
try:
stop_entry.setText(str(self.glossary_custom_stop_sequences_var))
except Exception:
pass
def _on_stop_seq_changed(text):
try:
self.glossary_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.glossary_anti_duplicate_notebook.addTab(bias_frame, "Logit Bias")
# Logit Bias Enable
self.glossary_logit_bias_enabled_var = self.config.get('glossary_logit_bias_enabled', False)
bias_cb = self._create_styled_checkbox("Enable Logit Bias (OpenAI only)")
try:
bias_cb.setChecked(bool(self.glossary_logit_bias_enabled_var))
except Exception:
pass
def _on_bias_enable_toggle(checked):
try:
self.glossary_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.glossary_logit_bias_strength_var = self.config.get('glossary_logit_bias_strength', -0.5)
bias_slider, self.glossary_bias_strength_value_label = _create_slider_row(bias_v, "Bias Strength:", self, 'glossary_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.glossary_bias_common_words_var = self.config.get('glossary_bias_common_words', False)
common_cb = self._create_styled_checkbox("Bias against common words (the, and, said)")
try:
common_cb.setChecked(bool(self.glossary_bias_common_words_var))
except Exception:
pass
def _on_common_toggle(checked):
try:
self.glossary_bias_common_words_var = bool(checked)
except Exception:
pass
common_cb.toggled.connect(_on_common_toggle)
bias_v.addWidget(common_cb)
self.glossary_bias_repetitive_phrases_var = self.config.get('glossary_bias_repetitive_phrases', False)
phrases_cb = self._create_styled_checkbox("Bias against repetitive phrases")
try:
phrases_cb.setChecked(bool(self.glossary_bias_repetitive_phrases_var))
except Exception:
pass
def _on_phrases_toggle(checked):
try:
self.glossary_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_glossary_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.glossary_anti_duplicate_tabs = [core_frame, advanced_frame, stop_frame, bias_frame]
# Initialize anti-duplicate section visibility
self.toggle_glossary_anti_duplicate_section()
return section_box
def toggle_glossary_anti_duplicate_section(self):
"""Enable/disable glossary anti-duplicate content (no hide)."""
try:
if not hasattr(self, 'glossary_anti_duplicate_content'):
return
if hasattr(self, 'glossary_enable_anti_duplicate_checkbox'):
enabled = bool(self.glossary_enable_anti_duplicate_checkbox.isChecked())
else:
enabled = bool(self.glossary_enable_anti_duplicate_var)
self.glossary_anti_duplicate_content.setVisible(True)
self.glossary_anti_duplicate_content.setEnabled(enabled)
except Exception:
try:
if hasattr(self, 'glossary_anti_duplicate_content'):
enabled = bool(self.glossary_enable_anti_duplicate_var)
self.glossary_anti_duplicate_content.setVisible(True)
self.glossary_anti_duplicate_content.setEnabled(enabled)
except Exception:
pass
def _toggle_glossary_anti_duplicate_controls(self):
"""Enable/disable glossary anti-duplicate parameter controls."""
self.toggle_glossary_anti_duplicate_section()
def _reset_glossary_anti_duplicate_defaults(self):
"""Reset all glossary anti-duplicate parameters to their default values."""
from PySide6.QtWidgets import QMessageBox
parent = None
try:
parent = getattr(self, 'glossary_anti_duplicate_dialog', None)
except Exception:
parent = None
reply = QMessageBox.question(
parent,
"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
if hasattr(self, 'glossary_enable_anti_duplicate_var'):
self.glossary_enable_anti_duplicate_var = False
if hasattr(self, 'glossary_top_p_var'):
self.glossary_top_p_var = 1.0
if hasattr(self, 'glossary_top_k_var'):
self.glossary_top_k_var = 0
if hasattr(self, 'glossary_frequency_penalty_var'):
self.glossary_frequency_penalty_var = 0.0
if hasattr(self, 'glossary_presence_penalty_var'):
self.glossary_presence_penalty_var = 0.0
if hasattr(self, 'glossary_repetition_penalty_var'):
self.glossary_repetition_penalty_var = 1.0
if hasattr(self, 'glossary_candidate_count_var'):
self.glossary_candidate_count_var = 1
if hasattr(self, 'glossary_custom_stop_sequences_var'):
self.glossary_custom_stop_sequences_var = ""
if hasattr(self, 'glossary_logit_bias_enabled_var'):
self.glossary_logit_bias_enabled_var = False
if hasattr(self, 'glossary_logit_bias_strength_var'):
self.glossary_logit_bias_strength_var = -0.5
if hasattr(self, 'glossary_bias_common_words_var'):
self.glossary_bias_common_words_var = False
if hasattr(self, 'glossary_bias_repetitive_phrases_var'):
self.glossary_bias_repetitive_phrases_var = False
self._toggle_glossary_anti_duplicate_controls()
QMessageBox.information(parent, "Reset Complete", "All anti-duplicate parameters have been reset to their default values.")
if hasattr(self, 'append_log'):
self.append_log("🔄 Glossary anti-duplicate parameters reset to defaults")
def _setup_glossary_editor_tab(self, parent):
"""Set up the glossary editor/trimmer tab"""
# Create main layout
editor_layout = QVBoxLayout(parent)
editor_layout.setContentsMargins(10, 10, 10, 10)
# Toggle: update HTML files when translated names change
html_toggle_widget = QWidget()
html_toggle_layout = QHBoxLayout(html_toggle_widget)
html_toggle_layout.setContentsMargins(0, 0, 0, 4)
self.update_html_on_save_checkbox = self._create_styled_checkbox("Update all HTML files on save")
self.update_html_on_save_checkbox.setChecked(self.config.get('update_html_on_save', True))
self.update_html_on_save_checkbox.setToolTip(
"When enabled, saving will also replace updated translated names across all .html files."
)
def _persist_update_html(state):
self.config['update_html_on_save'] = bool(state)
try:
self.save_config(show_message=False)
except Exception:
pass
self.update_html_on_save_checkbox.stateChanged.connect(_persist_update_html)
html_toggle_layout.addWidget(self.update_html_on_save_checkbox)
html_toggle_layout.addStretch()
# We'll place this above the save buttons inside the toolbar later
file_widget = QWidget()
file_layout = QHBoxLayout(file_widget)
file_layout.setContentsMargins(0, 0, 0, 10)
editor_layout.addWidget(file_widget)
file_layout.addWidget(QLabel("Glossary File:"))
self.editor_file_entry = QLineEdit()
self.editor_file_entry.setReadOnly(True)
file_layout.addWidget(self.editor_file_entry)
stats_widget = QWidget()
stats_layout = QHBoxLayout(stats_widget)
stats_layout.setContentsMargins(0, 0, 0, 5)
editor_layout.addWidget(stats_widget)
self.stats_label = QLabel("No glossary loaded")
self.stats_label.setStyleSheet("font-size: 10pt; font-style: italic;")
stats_layout.addWidget(self.stats_label)
stats_layout.addStretch()
content_frame = QGroupBox("Glossary Entries")
content_frame_layout = QVBoxLayout(content_frame)
editor_layout.addWidget(content_frame)
# Create tree widget
self.glossary_tree = QTreeWidget()
self.glossary_tree.setColumnCount(1)
self.glossary_tree.setHeaderLabels(["#"])
self.glossary_tree.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.glossary_tree.setContextMenuPolicy(Qt.CustomContextMenu)
content_frame_layout.addWidget(self.glossary_tree)
self.glossary_tree.itemDoubleClicked.connect(lambda item, col: self._on_tree_double_click(item, col))
self.glossary_tree.customContextMenuRequested.connect(lambda pos: None) # will be rebound after helpers are defined
self.current_glossary_data = None
self.current_glossary_format = None
self.glossary_column_fields = []
self._original_translated_map = {}
# Row highlight helpers
orange_brush = QBrush(QColor("#f97316"))
default_brush = QBrush()
def mark_row_updated(item, updated):
for c in range(item.columnCount()):
item.setBackground(c, orange_brush if updated else default_brush)
def get_baseline_translated(item, col_key):
if col_key not in ['translated_name', 'translated']:
return None
if self.current_glossary_format in ['list', 'token_csv']:
try:
idx = int(item.text(0)) - 1
except Exception:
return None
return self._original_translated_map.get(idx, '')
elif self.current_glossary_format == 'dict':
key = item.data(0, Qt.UserRole)
return self._original_translated_map.get(key, '')
return None
def update_row_highlight(item, col_key, new_val):
baseline = get_baseline_translated(item, col_key)
if baseline is None:
return
mark_row_updated(item, new_val != baseline)
# Editor functions
def load_glossary_for_editing():
path = self.editor_file_entry.text()
if not path or not os.path.exists(path):
QMessageBox.critical(parent, "Error", "Please select a valid glossary file")
return
try:
# Helpers for token-efficient format (sectioned, bullet-style CSV text)
def parse_token_efficient_glossary(lines):
entries = []
sections = []
current_section = None
gender_keywords = {'male', 'female', 'unknown'}
header_columns = ['translated_name', 'raw_name', 'gender', 'description']
# Default extra columns: pattern manager + custom fields (used if header omits them)
default_extra_columns = []
try:
import PatternManager as _pm
pf = getattr(_pm, 'PATTERN_ADDITIONAL_FIELDS', [])
if isinstance(pf, (list, tuple)):
default_extra_columns.extend(pf)
except Exception:
pass
default_extra_columns.extend(self.config.get('custom_glossary_fields', []))
extra_columns = list(default_extra_columns)
# Map section -> type (from custom entry types only, plus simple plurals)
custom_types = getattr(self, 'custom_entry_types', {}) or {
'character': {'enabled': True, 'has_gender': True},
'terms': {'enabled': True, 'has_gender': False},
}
type_map = {}
for t in custom_types.keys():
type_map[t.lower()] = t
# naive plural
if not t.lower().endswith('s'):
type_map[f"{t.lower()}s"] = t
for raw_line in lines:
line = raw_line.strip()
if not line:
continue
if line.lower().startswith('glossary columns:'):
# Parse header columns
cols_text = line.split(':', 1)[1]
header_columns = [c.strip() for c in cols_text.split(',') if c.strip()]
if len(header_columns) < 4:
header_columns = ['translated_name', 'raw_name', 'gender', 'description']
extra_columns = header_columns[4:] or list(default_extra_columns)
continue
if line.startswith('===') and line.endswith('==='):
section_name = line.strip('=').strip()
current_section = section_name
sections.append(section_name)
continue
if not line.startswith('* '):
continue
# Pattern: * translated (raw) [gender]: description
import re
m = re.match(r'^\*\s+(.*?)\s*(?:\((.*?)\))?\s*(?:\[(.*?)\])?\s*(?::\s*(.*))?$', line)
if not m:
continue
translated = (m.group(1) or '').strip()
raw_name = (m.group(2) or '').strip()
bracket = (m.group(3) or '').strip()
desc = (m.group(4) or '').strip()
# Split out extra column values encoded as " | key: val"
extra_values = {}
if desc and ' | ' in desc:
parts = desc.split(' | ')
desc = parts[0].strip()
for part in parts[1:]:
if ':' in part:
k, v = part.split(':', 1)
extra_values[k.strip()] = v.strip()
gender = ''
if bracket:
if bracket.lower() in gender_keywords:
gender = bracket
else:
# treat bracket content as description fragment
desc = f"{bracket}: {desc}".strip(': ').strip() if desc else bracket
entry = {
'type': type_map.get((current_section or 'terms').lower(), 'terms'),
'raw_name': raw_name,
'translated_name': translated,
'gender': gender,
}
if desc:
entry['description'] = desc
if current_section:
entry['_section'] = current_section
# Apply any extra columns from header
for col in extra_columns:
if col in extra_values:
entry[col] = extra_values[col]
entries.append(entry)
return entries, sections
# Prepare accumulator for field discovery
all_fields = set()
# Try CSV first
if path.endswith('.csv'):
# Peek to detect token-efficient format
with open(path, 'r', encoding='utf-8') as f:
lines = f.readlines()
token_style = False
for l in lines:
lstrip = l.lstrip()
if lstrip.startswith('===') or lstrip.startswith('* '):
token_style = True
break
if not token_style and lines and lines[0].lower().startswith('glossary columns:'):
token_style = True
if token_style:
entries, sections = parse_token_efficient_glossary(lines)
self.current_glossary_data = entries
self.current_glossary_format = 'token_csv'
self.current_glossary_sections = sections
for e in entries:
all_fields.update(e.keys())
else:
import csv
entries = []
with open(path, 'r', encoding='utf-8') as f:
reader = csv.reader(f)
for row in reader:
if len(row) >= 3:
entry = {
'type': row[0],
'raw_name': row[1],
'translated_name': row[2]
}
if row[0] == 'character' and len(row) > 3:
entry['gender'] = row[3]
# include any extra columns
if len(row) > 4:
entry['description'] = row[4]
entries.append(entry)
self.current_glossary_data = entries
self.current_glossary_format = 'list'
for e in entries:
all_fields.update(e.keys())
else:
# JSON format
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
entries = []
if isinstance(data, dict):
if 'entries' in data:
self.current_glossary_data = data
self.current_glossary_format = 'dict'
for original, translated in data['entries'].items():
entry = {'original': original, 'translated': translated}
entries.append(entry)
all_fields.update(entry.keys())
else:
self.current_glossary_data = {'entries': data}
self.current_glossary_format = 'dict'
for original, translated in data.items():
entry = {'original': original, 'translated': translated}
entries.append(entry)
all_fields.update(entry.keys())
elif isinstance(data, list):
self.current_glossary_data = data
self.current_glossary_format = 'list'
for item in data:
all_fields.update(item.keys())
entries.append(item)
# Set up columns based on new format
if self.current_glossary_format in ['list', 'token_csv'] and entries and 'type' in entries[0]:
# New simple format
column_fields = []
# Show section if present
if any('_section' in e for e in entries):
column_fields.append('_section')
column_fields.extend(['type', 'raw_name', 'translated_name', 'gender'])
# Include description/custom fields
for entry in entries:
for field in entry.keys():
if field.startswith('_'):
continue
if field not in column_fields:
column_fields.append(field)
# Check for any custom fields
for entry in entries:
for field in entry.keys():
if field.startswith('_'):
continue
if field not in column_fields:
column_fields.append(field)
else:
# Old format compatibility
standard_fields = ['original_name', 'name', 'original', 'translated', 'gender',
'title', 'group_affiliation', 'traits', 'how_they_refer_to_others',
'locations']
column_fields = []
for field in standard_fields:
if field in all_fields:
column_fields.append(field)
custom_fields = sorted(all_fields - set(standard_fields))
column_fields.extend(custom_fields)
self.glossary_tree.clear()
self.glossary_column_fields = list(column_fields)
self.glossary_tree.setColumnCount(len(column_fields) + 1) # +1 for index column
headers = ['#'] + [field.replace('_', ' ').title() for field in column_fields]
self.glossary_tree.setHeaderLabels(headers)
self.glossary_tree.setColumnWidth(0, 80)
for idx, field in enumerate(column_fields, start=1):
if field in ['raw_name', 'translated_name', 'original_name', 'name', 'original', 'translated']:
width = 150
elif field in ['traits', 'locations', 'how_they_refer_to_others']:
width = 200
else:
width = 100
self.glossary_tree.setColumnWidth(idx, width)
for idx, entry in enumerate(entries):
values = [str(idx + 1)]
for field in column_fields:
value = entry.get(field, '')
if isinstance(value, list):
value = ', '.join(str(v) for v in value)
elif isinstance(value, dict):
value = ', '.join(f"{k}: {v}" for k, v in value.items())
elif value is None:
value = ''
values.append(str(value))
item = QTreeWidgetItem(values)
if self.current_glossary_format == 'dict':
item.setData(0, Qt.UserRole, entry.get('original', ''))
else:
item.setData(0, Qt.UserRole, idx)
self.glossary_tree.addTopLevelItem(item)
# Update stats
stats = []
stats.append(f"Total entries: {len(entries)}")
if self.current_glossary_format in ['list', 'token_csv'] and entries and 'type' in entries[0]:
# New format stats
characters = sum(1 for e in entries if e.get('type') == 'character')
terms = sum(1 for e in entries if e.get('type') == 'terms')
stats.append(f"Characters: {characters}, Terms: {terms}")
elif self.current_glossary_format == 'list':
# Old format stats
chars = sum(1 for e in entries if 'original_name' in e or 'name' in e)
locs = sum(1 for e in entries if 'locations' in e and e['locations'])
stats.append(f"Characters: {chars}, Locations: {locs}")
self.stats_label.setText(" | ".join(stats))
self.append_log(f"✅ Loaded {len(entries)} entries from glossary")
self._last_find_text = ""
self._last_find_pos = -1
# Capture baseline translated values for change tracking
if self.current_glossary_format in ['list', 'token_csv']:
self._original_translated_map = {
idx: entry.get('translated_name', '') for idx, entry in enumerate(self.current_glossary_data)
}
elif self.current_glossary_format == 'dict':
self._original_translated_map = dict(self.current_glossary_data.get('entries', {}))
else:
self._original_translated_map = {}
# Clear any existing highlights
for i in range(self.glossary_tree.topLevelItemCount()):
mark_row_updated(self.glossary_tree.topLevelItem(i), False)
except Exception as e:
QMessageBox.critical(parent, "Error", f"Failed to load glossary: {e}")
self.append_log(f"❌ Failed to load glossary: {e}")
def browse_glossary():
start_dir = ""
try:
override_dir = os.environ.get("OUTPUT_DIRECTORY") or self.config.get("output_directory", "")
if override_dir:
start_dir = os.path.abspath(override_dir)
except Exception:
start_dir = ""
path, _ = QFileDialog.getOpenFileName(
parent,
"Select glossary file",
start_dir,
"Glossary files (*.json *.csv);;JSON files (*.json);;CSV files (*.csv)"
)
if path:
self.editor_file_entry.setText(path)
load_glossary_for_editing()
# Common save helper
def save_current_glossary():
path = self.editor_file_entry.text()
if not path or not self.current_glossary_data:
return False
try:
if path.endswith('.csv'):
if getattr(self, 'current_glossary_format', '') == 'token_csv':
def save_token_csv(entries, path_out):
sections = getattr(self, 'current_glossary_sections', []) or []
if not sections:
sections = ['CHARACTERS', 'TERMS', 'TITLES', 'ORGANIZATIONS', 'LOCATIONS', 'ITEMS', 'ABILITYS']
grouped = {sec: [] for sec in sections}
default_map = {'character': 'CHARACTERS', 'terms': 'TERMS'}
for entry in entries:
sec = entry.get('_section')
if not sec:
sec = default_map.get(entry.get('type', 'terms'), 'TITLES')
if sec not in grouped:
grouped[sec] = []
sections.append(sec)
grouped[sec].append(entry)
# Build header columns: standard + pattern-manager fields + custom/additional fields
standard_cols = ['translated_name', 'raw_name', 'gender', 'description']
pattern_fields = []
try:
import PatternManager as _pm
pf = getattr(_pm, 'PATTERN_ADDITIONAL_FIELDS', [])
if isinstance(pf, (list, tuple)):
pattern_fields = list(pf)
except Exception:
pattern_fields = []
custom_fields = self.config.get('custom_glossary_fields', [])
# include any fields present in data that are not internal/standard
data_fields = []
for e in entries:
for k in e.keys():
if k.startswith('_') or k in ['type'] + standard_cols:
continue
if k not in custom_fields and k not in pattern_fields and k not in data_fields:
data_fields.append(k)
header_cols = standard_cols + pattern_fields + custom_fields + data_fields
lines = [f"Glossary Columns: {', '.join(header_cols)}", ""]
for sec in sections:
sec_entries = grouped.get(sec, [])
if not sec_entries:
continue
lines.append(f"=== {sec} ===")
for e in sec_entries:
translated = e.get('translated_name', '')
raw_name = e.get('raw_name', '')
gender = e.get('gender', '')
desc = e.get('description', '')
line = f"* {translated}"
if raw_name:
line += f" ({raw_name})"
if gender:
line += f" [{gender}]"
extra_tail = []
for col in header_cols[4:]:
val = e.get(col, '')
if val:
extra_tail.append(f"{col}: {val}")
if desc:
line += f": {desc}"
if extra_tail:
tail_str = " | ".join(extra_tail)
line += f" | {tail_str}" if desc else f": {tail_str}"
lines.append(line)
lines.append("")
with open(path_out, 'w', encoding='utf-8', newline='') as f:
f.write("\n".join(lines).rstrip() + "\n")
save_token_csv(self.current_glossary_data, path)
else:
import csv
standard_fields = ['type', 'raw_name', 'translated_name', 'gender']
extra_fields = []
for entry in self.current_glossary_data:
for k in entry.keys():
if k.startswith('_') or k in standard_fields:
continue
if k not in extra_fields:
extra_fields.append(k)
with open(path, 'w', encoding='utf-8', newline='') as f:
writer = csv.writer(f)
for entry in self.current_glossary_data:
row = [
entry.get('type', ''),
entry.get('raw_name', ''),
entry.get('translated_name', ''),
entry.get('gender', '')
]
for field in extra_fields:
row.append(entry.get(field, ''))
writer.writerow(row)
else:
with open(path, 'w', encoding='utf-8') as f:
json.dump(self.current_glossary_data, f, ensure_ascii=False, indent=2)
return True
except Exception as e:
QMessageBox.critical(parent, "Error", f"Failed to save: {e}")
return False
def clean_empty_fields():
if not self.current_glossary_data:
QMessageBox.critical(parent, "Error", "No glossary loaded")
return
if self.current_glossary_format in ['list', 'token_csv']:
# Check if there are any empty fields
empty_fields_found = False
fields_cleaned = {}
# Count empty fields first
for entry in self.current_glossary_data:
for field in list(entry.keys()):
value = entry[field]
if value is None or value == "" or (isinstance(value, list) and len(value) == 0) or (isinstance(value, dict) and len(value) == 0):
empty_fields_found = True
fields_cleaned[field] = fields_cleaned.get(field, 0) + 1
# If no empty fields found, show message and return
if not empty_fields_found:
QMessageBox.information(parent, "Info", "No empty fields found in glossary")
return
# Only create backup if there are fields to clean
if not self.create_glossary_backup("before_clean"):
return
# Now actually clean the fields
total_cleaned = 0
for entry in self.current_glossary_data:
for field in list(entry.keys()):
value = entry[field]
if value is None or value == "" or (isinstance(value, list) and len(value) == 0) or (isinstance(value, dict) and len(value) == 0):
entry.pop(field)
total_cleaned += 1
if save_current_glossary():
load_glossary_for_editing()
# Provide detailed feedback
msg = f"Cleaned {total_cleaned} empty fields\n\n"
msg += "Fields cleaned:\n"
for field, count in sorted(fields_cleaned.items(), key=lambda x: x[1], reverse=True):
msg += f"• {field}: {count} entries\n"
QMessageBox.information(parent, "Success", msg)
def delete_selected_entries():
selected = self.glossary_tree.selectedItems()
if not selected:
QMessageBox.warning(parent, "No Selection", "Please select entries to delete")
return
count = len(selected)
reply = QMessageBox.question(parent, "Confirm Delete", f"Delete {count} selected entries?",
QMessageBox.Yes | QMessageBox.No)
if reply == QMessageBox.Yes:
# automatic backup
if not self.create_glossary_backup(f"before_delete_{count}"):
return
indices_to_delete = []
for item in selected:
idx = int(item.text(0)) - 1 # First column is index
indices_to_delete.append(idx)
indices_to_delete.sort(reverse=True)
if self.current_glossary_format in ['list', 'token_csv']:
for idx in indices_to_delete:
if 0 <= idx < len(self.current_glossary_data):
del self.current_glossary_data[idx]
elif self.current_glossary_format == 'dict':
entries_list = list(self.current_glossary_data.get('entries', {}).items())
for idx in indices_to_delete:
if 0 <= idx < len(entries_list):
key = entries_list[idx][0]
self.current_glossary_data['entries'].pop(key, None)
if save_current_glossary():
load_glossary_for_editing()
QMessageBox.information(parent, "Success", f"Deleted {len(indices_to_delete)} entries")
def remove_duplicates():
if not self.current_glossary_data:
QMessageBox.critical(parent, "Error", "No glossary loaded")
return
if self.current_glossary_format in ['list', 'token_csv']:
# Import the skip function from the updated script
try:
from extract_glossary_from_epub import skip_duplicate_entries, remove_honorifics
# Set environment variable for honorifics toggle
os.environ['GLOSSARY_DISABLE_HONORIFICS_FILTER'] = '1' if self.config.get('glossary_disable_honorifics_filter', False) else '0'
original_count = len(self.current_glossary_data)
self.current_glossary_data = skip_duplicate_entries(self.current_glossary_data)
duplicates_removed = original_count - len(self.current_glossary_data)
if duplicates_removed > 0:
if self.config.get('glossary_auto_backup', False):
self.create_glossary_backup(f"before_remove_{duplicates_removed}_dupes")
if save_current_glossary():
load_glossary_for_editing()
QMessageBox.information(parent, "Success", f"Removed {duplicates_removed} duplicate entries")
self.append_log(f"🗑️ Removed {duplicates_removed} duplicates based on raw_name")
else:
QMessageBox.information(parent, "Info", "No duplicates found")
except ImportError:
# Fallback implementation
seen_raw_names = set()
unique_entries = []
duplicates = 0
for entry in self.current_glossary_data:
raw_name = entry.get('raw_name', '').lower().strip()
if raw_name and raw_name not in seen_raw_names:
seen_raw_names.add(raw_name)
unique_entries.append(entry)
elif raw_name:
duplicates += 1
if duplicates > 0:
self.current_glossary_data = unique_entries
if save_current_glossary():
load_glossary_for_editing()
QMessageBox.information(parent, "Success", f"Removed {duplicates} duplicate entries")
else:
QMessageBox.information(parent, "Info", "No duplicates found")
# dialog function for configuring duplicate detection mode
def duplicate_detection_settings():
"""Show info about duplicate detection (simplified for new format)"""
QMessageBox.information(
parent,
"Duplicate Detection",
"Duplicate detection is based on the raw_name field.\n\n"
"• Entries with identical raw_name values are considered duplicates\n"
"• The first occurrence is kept, later ones are removed\n"
"• Honorifics filtering can be toggled in the Manual Glossary tab\n\n"
"When honorifics filtering is enabled, names are compared after removing honorifics."
)
def backup_settings_dialog():
"""Show dialog for configuring automatic backup settings"""
# Create dialog
backup_dialog = QDialog(parent)
backup_dialog.setWindowTitle("Automatic Backup Settings")
# Use screen ratios for sizing
screen = QApplication.primaryScreen().geometry()
width = int(screen.width() * 0.26) # 26% of screen width
height = int(screen.height() * 0.39) # 39% of screen height
backup_dialog.setMinimumSize(width, height)
# Main layout
main_layout = QVBoxLayout(backup_dialog)
main_layout.setContentsMargins(20, 20, 20, 20)
# Title
title_label = QLabel("Automatic Backup Settings")
title_label.setStyleSheet("font-size: 22pt; font-weight: bold;")
main_layout.addWidget(title_label)
main_layout.addSpacing(20)
# Backup toggle
backup_checkbox = self._create_styled_checkbox("Enable automatic backups before modifications")
backup_checkbox.setChecked(self.config.get('glossary_auto_backup', True))
main_layout.addWidget(backup_checkbox)
main_layout.addSpacing(5)
# Settings frame (indented)
settings_widget = QWidget()
settings_layout = QVBoxLayout(settings_widget)
settings_layout.setContentsMargins(20, 10, 0, 0)
main_layout.addWidget(settings_widget)
# Max backups setting
max_backups_widget = QWidget()
max_backups_layout = QHBoxLayout(max_backups_widget)
max_backups_layout.setContentsMargins(0, 5, 0, 5)
settings_layout.addWidget(max_backups_widget)
max_backups_layout.addWidget(QLabel("Maximum backups to keep:"))
max_backups_spinbox = QSpinBox()
max_backups_spinbox.setRange(0, 999)
max_backups_spinbox.setValue(self.config.get('glossary_max_backups', 50))
max_backups_spinbox.setFixedWidth(80)
self._disable_spinbox_mousewheel(max_backups_spinbox) # Disable mouse wheel
max_backups_layout.addWidget(max_backups_spinbox)
unlimited_label = QLabel("(0 = unlimited)")
unlimited_label.setStyleSheet("color: gray; font-size: 9pt;")
max_backups_layout.addWidget(unlimited_label)
max_backups_layout.addStretch()
# Backup naming pattern info
settings_layout.addSpacing(15)
pattern_label = QLabel("Backup naming pattern:")
pattern_label.setStyleSheet("font-weight: bold;")
settings_layout.addWidget(pattern_label)
pattern_text = QLabel("[original_name]_[operation]_[YYYYMMDD_HHMMSS].json")
pattern_text.setStyleSheet("color: #666; font-style: italic; font-size: 9pt; margin-left: 10px;")
settings_layout.addWidget(pattern_text)
# Example
example_text = "Example: my_glossary_before_delete_5_20240115_143052.json"
example_label = QLabel(example_text)
example_label.setStyleSheet("color: gray; font-size: 8pt; margin-left: 10px; margin-top: 2px;")
settings_layout.addWidget(example_label)
# Separator
main_layout.addSpacing(20)
separator = QFrame()
separator.setFrameShape(QFrame.HLine)
separator.setFrameShadow(QFrame.Sunken)
main_layout.addWidget(separator)
main_layout.addSpacing(15)
# Backup location info
location_label = QLabel("📁 Backup Location:")
location_label.setStyleSheet("font-weight: bold;")
main_layout.addWidget(location_label)
if self.editor_file_entry.text():
glossary_dir = os.path.dirname(self.editor_file_entry.text())
backup_path = "Backups"
full_path = os.path.join(glossary_dir, "Backups")
path_label = QLabel(f"{backup_path}/")
path_label.setStyleSheet("color: #7bb3e0; font-size: 9pt; margin-left: 10px;")
main_layout.addWidget(path_label)
# Check if backup folder exists and show count
if os.path.exists(full_path):
backup_count = len([f for f in os.listdir(full_path) if f.endswith('.json')])
count_label = QLabel(f"Currently contains {backup_count} backup(s)")
count_label.setStyleSheet("color: gray; font-size: 8pt; margin-left: 10px;")
main_layout.addWidget(count_label)
else:
backup_label = QLabel("Backups")
backup_label.setStyleSheet("color: gray; font-size: 9pt; margin-left: 10px;")
main_layout.addWidget(backup_label)
def toggle_settings_state(checked):
max_backups_spinbox.setEnabled(backup_checkbox.isChecked())
backup_checkbox.stateChanged.connect(toggle_settings_state)
toggle_settings_state(backup_checkbox.isChecked()) # Set initial state
# Buttons
main_layout.addSpacing(25)
button_widget = QWidget()
button_layout = QHBoxLayout(button_widget)
button_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(button_widget)
button_layout.addStretch()
def save_settings():
# Save backup settings
self.config['glossary_auto_backup'] = backup_checkbox.isChecked()
self.config['glossary_max_backups'] = max_backups_spinbox.value()
# Save to config file
CONFIG_FILE = os.path.join(os.path.dirname(__file__), 'config.json')
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(self.config, f, ensure_ascii=False, indent=2)
status = "enabled" if backup_checkbox.isChecked() else "disabled"
if backup_checkbox.isChecked():
limit = max_backups_spinbox.value()
limit_text = "unlimited" if limit == 0 else f"max {limit}"
msg = f"Automatic backups {status} ({limit_text})"
else:
msg = f"Automatic backups {status}"
QMessageBox.information(backup_dialog, "Success", msg)
backup_dialog.accept()
def create_manual_backup():
"""Create a manual backup right now"""
if not self.current_glossary_data:
QMessageBox.critical(backup_dialog, "Error", "No glossary loaded")
return
if self.create_glossary_backup("manual"):
QMessageBox.information(backup_dialog, "Success", "Manual backup created successfully!")
save_btn = QPushButton("Save Settings")
save_btn.setFixedWidth(120)
save_btn.clicked.connect(save_settings)
save_btn.setStyleSheet("background-color: #28a745; color: white; padding: 8px;")
button_layout.addWidget(save_btn)
backup_now_btn = QPushButton("Backup Now")
backup_now_btn.setFixedWidth(120)
backup_now_btn.clicked.connect(create_manual_backup)
backup_now_btn.setStyleSheet("background-color: #17a2b8; color: white; padding: 8px;")
button_layout.addWidget(backup_now_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.setFixedWidth(120)
cancel_btn.clicked.connect(backup_dialog.reject)
cancel_btn.setStyleSheet("background-color: #6c757d; color: white; padding: 8px;")
button_layout.addWidget(cancel_btn)
button_layout.addStretch()
# Show dialog with fade animation
try:
from dialog_animations import exec_dialog_with_fade
exec_dialog_with_fade(backup_dialog, duration=250)
except Exception:
backup_dialog.exec()
def smart_trim_dialog():
if not self.current_glossary_data:
QMessageBox.critical(parent, "Error", "No glossary loaded")
return
# Create dialog
trim_dialog = QDialog(parent)
trim_dialog.setWindowTitle("Smart Trim Glossary")
# Use screen ratios for sizing
screen = QApplication.primaryScreen().geometry()
width = int(screen.width() * 0.31) # 31% of screen width
height = int(screen.height() * 0.49) # 49% of screen height
trim_dialog.setMinimumSize(width, height)
main_layout = QVBoxLayout(trim_dialog)
main_layout.setContentsMargins(20, 20, 20, 20)
# Title and description
title = QLabel("Smart Glossary Trimming")
title.setStyleSheet("font-size: 14pt; font-weight: bold;")
main_layout.addWidget(title)
desc = QLabel("Limit the number of entries in your glossary")
desc.setStyleSheet("color: gray; font-size: 10pt;")
desc.setWordWrap(True)
main_layout.addWidget(desc)
main_layout.addSpacing(15)
# Display current glossary stats
stats_group = QGroupBox("Current Glossary Statistics")
stats_layout = QVBoxLayout(stats_group)
main_layout.addWidget(stats_group)
entry_count = len(self.current_glossary_data) if self.current_glossary_format in ['list', 'token_csv'] else len(self.current_glossary_data.get('entries', {}))
stats_layout.addWidget(QLabel(f"Total entries: {entry_count}"))
# For new format, show type breakdown
if self.current_glossary_format in ['list', 'token_csv'] and self.current_glossary_data and 'type' in self.current_glossary_data[0]:
characters = sum(1 for e in self.current_glossary_data if e.get('type') == 'character')
terms = sum(1 for e in self.current_glossary_data if e.get('type') == 'terms')
stats_layout.addWidget(QLabel(f"Characters: {characters}, Terms: {terms}"))
main_layout.addSpacing(15)
# Entry limit section
limit_group = QGroupBox("Entry Limit")
limit_layout = QVBoxLayout(limit_group)
main_layout.addWidget(limit_group)
limit_desc = QLabel("Keep only the first N entries to reduce glossary size")
limit_desc.setStyleSheet("color: gray; font-size: 9pt;")
limit_desc.setWordWrap(True)
limit_layout.addWidget(limit_desc)
top_widget = QWidget()
top_layout = QHBoxLayout(top_widget)
top_layout.setContentsMargins(0, 5, 0, 0)
limit_layout.addWidget(top_widget)
top_layout.addWidget(QLabel("Keep first"))
top_entry = QLineEdit(str(min(100, entry_count)))
top_entry.setFixedWidth(80)
top_layout.addWidget(top_entry)
top_layout.addWidget(QLabel(f"entries (out of {entry_count})"))
top_layout.addStretch()
main_layout.addSpacing(15)
# Preview section
preview_group = QGroupBox("Preview")
preview_layout = QVBoxLayout(preview_group)
main_layout.addWidget(preview_group)
preview_label = QLabel("Click 'Preview Changes' to see the effect")
preview_label.setStyleSheet("color: gray; font-size: 10pt;")
preview_layout.addWidget(preview_label)
def preview_changes():
try:
top_n = int(top_entry.text())
entries_to_remove = max(0, entry_count - top_n)
preview_text = f"Preview of changes:\n"
preview_text += f"• Entries: {entry_count}{top_n} ({entries_to_remove} removed)\n"
preview_label.setText(preview_text)
preview_label.setStyleSheet("color: #7bb3e0; font-size: 10pt;")
except ValueError:
preview_label.setText("Please enter a valid number")
preview_label.setStyleSheet("color: red; font-size: 10pt;")
preview_btn = QPushButton("Preview Changes")
preview_btn.clicked.connect(preview_changes)
preview_btn.setStyleSheet("background-color: #17a2b8; color: white; padding: 5px;")
preview_layout.addWidget(preview_btn)
main_layout.addSpacing(10)
# Action buttons
button_widget = QWidget()
button_layout = QHBoxLayout(button_widget)
main_layout.addWidget(button_widget)
def apply_smart_trim():
try:
top_n = int(top_entry.text())
# Calculate how many entries will be removed
entries_to_remove = len(self.current_glossary_data) - top_n
if entries_to_remove > 0:
if not self.create_glossary_backup(f"before_trim_{entries_to_remove}"):
return
if self.current_glossary_format in ['list', 'token_csv']:
# Keep only top N entries
if top_n < len(self.current_glossary_data):
self.current_glossary_data = self.current_glossary_data[:top_n]
elif self.current_glossary_format == 'dict':
# For dict format, only support entry limit
entries = list(self.current_glossary_data['entries'].items())
if top_n < len(entries):
self.current_glossary_data['entries'] = dict(entries[:top_n])
if save_current_glossary():
load_glossary_for_editing()
QMessageBox.information(trim_dialog, "Success", f"Trimmed glossary to {top_n} entries")
trim_dialog.accept()
except ValueError:
QMessageBox.critical(trim_dialog, "Error", "Please enter valid numbers")
button_layout.addStretch()
apply_btn = QPushButton("Apply Trim")
apply_btn.setFixedWidth(120)
apply_btn.clicked.connect(apply_smart_trim)
apply_btn.setStyleSheet("background-color: #28a745; color: white; padding: 8px;")
button_layout.addWidget(apply_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.setFixedWidth(120)
cancel_btn.clicked.connect(trim_dialog.reject)
cancel_btn.setStyleSheet("background-color: #6c757d; color: white; padding: 8px;")
button_layout.addWidget(cancel_btn)
button_layout.addStretch()
# Info section at bottom
main_layout.addSpacing(20)
tip_label = QLabel("💡 Tip: Entries are kept in their original order")
tip_label.setStyleSheet("color: #666; font-size: 9pt; font-style: italic;")
main_layout.addWidget(tip_label)
# Show dialog with fade animation
try:
from dialog_animations import exec_dialog_with_fade
exec_dialog_with_fade(trim_dialog, duration=250)
except Exception:
trim_dialog.exec()
def filter_entries_dialog():
if not self.current_glossary_data:
QMessageBox.critical(self.dialog, "Error", "No glossary loaded")
return
# Create dialog with scroll area
filter_dialog = QDialog(self.dialog)
filter_dialog.setWindowTitle("Filter Entries")
filter_dialog.setMinimumWidth(600)
main_layout = QVBoxLayout(filter_dialog)
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
main_layout.addWidget(scroll_area)
main_frame = QWidget()
content_layout = QVBoxLayout(main_frame)
scroll_area.setWidget(main_frame)
# Title and description
title_label = QLabel("Filter Glossary Entries")
title_label.setStyleSheet("font-size: 14pt; font-weight: bold;")
content_layout.addWidget(title_label)
content_layout.addSpacing(5)
desc_label = QLabel("Filter entries by type or content")
desc_label.setStyleSheet("color: gray; font-size: 10pt;")
desc_label.setWordWrap(True)
content_layout.addWidget(desc_label)
content_layout.addSpacing(15)
# Current stats
entry_count = len(self.current_glossary_data) if self.current_glossary_format in ['list', 'token_csv'] else len(self.current_glossary_data.get('entries', {}))
stats_group = QGroupBox("Current Status")
stats_layout = QVBoxLayout(stats_group)
stats_label = QLabel(f"Total entries: {entry_count}")
stats_label.setStyleSheet("font-size: 10pt;")
stats_layout.addWidget(stats_label)
content_layout.addWidget(stats_group)
content_layout.addSpacing(15)
# Check if new format
is_new_format = (self.current_glossary_format in ['list', 'token_csv'] and
self.current_glossary_data and
'type' in self.current_glossary_data[0])
# Filter conditions
conditions_group = QGroupBox("Filter Conditions")
conditions_layout = QVBoxLayout(conditions_group)
content_layout.addWidget(conditions_group)
content_layout.addSpacing(15)
# Type filter for new format
type_checks = {}
if is_new_format:
type_group = QGroupBox("Entry Type")
type_layout = QVBoxLayout(type_group)
conditions_layout.addWidget(type_group)
conditions_layout.addSpacing(10)
char_check = self._create_styled_checkbox("Keep characters")
char_check.setChecked(True)
type_checks['character'] = char_check
type_layout.addWidget(char_check)
term_check = self._create_styled_checkbox("Keep terms/locations")
term_check.setChecked(True)
type_checks['term'] = term_check
type_layout.addWidget(term_check)
# Text content filter
text_filter_group = QGroupBox("Text Content Filter")
text_filter_layout = QVBoxLayout(text_filter_group)
conditions_layout.addWidget(text_filter_group)
conditions_layout.addSpacing(10)
text_hint_label = QLabel("Keep entries containing text (case-insensitive):")
text_hint_label.setStyleSheet("color: gray; font-size: 9pt;")
text_filter_layout.addWidget(text_hint_label)
text_filter_layout.addSpacing(5)
search_entry = QLineEdit()
text_filter_layout.addWidget(search_entry)
# Gender filter for new format
gender_value = "all"
gender_buttons = {}
if is_new_format:
gender_group = QGroupBox("Gender Filter (Characters Only)")
gender_layout = QVBoxLayout(gender_group)
conditions_layout.addWidget(gender_group)
conditions_layout.addSpacing(10)
gender_button_group = QButtonGroup(filter_dialog)
all_radio = QRadioButton("All genders")
all_radio.setChecked(True)
gender_buttons['all'] = all_radio
gender_button_group.addButton(all_radio, 0)
gender_layout.addWidget(all_radio)
male_radio = QRadioButton("Male only")
gender_buttons['Male'] = male_radio
gender_button_group.addButton(male_radio, 1)
gender_layout.addWidget(male_radio)
female_radio = QRadioButton("Female only")
gender_buttons['Female'] = female_radio
gender_button_group.addButton(female_radio, 2)
gender_layout.addWidget(female_radio)
unknown_radio = QRadioButton("Unknown only")
gender_buttons['Unknown'] = unknown_radio
gender_button_group.addButton(unknown_radio, 3)
gender_layout.addWidget(unknown_radio)
def update_gender_value():
nonlocal gender_value
for val, btn in gender_buttons.items():
if btn.isChecked():
gender_value = val
break
gender_button_group.buttonClicked.connect(update_gender_value)
# Preview section
preview_group = QGroupBox("Preview")
preview_layout = QVBoxLayout(preview_group)
content_layout.addWidget(preview_group)
content_layout.addSpacing(15)
preview_label = QLabel("Click 'Preview Filter' to see how many entries match")
preview_label.setStyleSheet("color: gray; font-size: 10pt;")
preview_layout.addWidget(preview_label)
def check_entry_matches(entry):
"""Check if an entry matches the filter conditions"""
# Type filter
if is_new_format and entry.get('type'):
type_check = type_checks.get(entry['type'])
if type_check and not type_check.isChecked():
return False
# Text filter
search_text = search_entry.text().strip().lower()
if search_text:
# Search in all text fields
entry_text = ' '.join(str(v) for v in entry.values() if isinstance(v, str)).lower()
if search_text not in entry_text:
return False
# Gender filter
if is_new_format and gender_value != "all":
if entry.get('type') == 'character' and entry.get('gender') != gender_value:
return False
return True
def preview_filter():
"""Preview the filter results"""
nonlocal gender_value
# Update gender value first
for val, btn in gender_buttons.items():
if btn.isChecked():
gender_value = val
break
matching = 0
if self.current_glossary_format in ['list', 'token_csv']:
for entry in self.current_glossary_data:
if check_entry_matches(entry):
matching += 1
else:
for key, entry in self.current_glossary_data.get('entries', {}).items():
if check_entry_matches(entry):
matching += 1
removed = entry_count - matching
preview_label.setText(f"Filter matches: {matching} entries ({removed} will be removed)")
preview_label.setStyleSheet(f"color: {'#5a9fd4' if matching > 0 else 'red'}; font-size: 10pt; font-style: italic;")
preview_btn = QPushButton("Preview Filter")
preview_btn.clicked.connect(preview_filter)
preview_btn.setStyleSheet("background-color: #0dcaf0; color: white; padding: 8px;")
preview_layout.addWidget(preview_btn)
# Action buttons
content_layout.addSpacing(10)
button_layout = QHBoxLayout()
content_layout.addLayout(button_layout)
content_layout.addSpacing(20)
def apply_filter():
nonlocal gender_value
# Update gender value first
for val, btn in gender_buttons.items():
if btn.isChecked():
gender_value = val
break
if self.current_glossary_format in ['list', 'token_csv']:
filtered = []
for entry in self.current_glossary_data:
if check_entry_matches(entry):
filtered.append(entry)
removed = len(self.current_glossary_data) - len(filtered)
if removed > 0:
if not self.create_glossary_backup(f"before_filter_remove_{removed}"):
return
self.current_glossary_data[:] = filtered
if save_current_glossary():
load_glossary_for_editing()
QMessageBox.information(filter_dialog, "Success",
f"Filter applied!\n\nKept: {len(filtered)} entries\nRemoved: {removed} entries")
filter_dialog.accept()
button_layout.addStretch()
apply_btn = QPushButton("Apply Filter")
apply_btn.setFixedWidth(120)
apply_btn.clicked.connect(apply_filter)
apply_btn.setStyleSheet("background-color: #198754; color: white; padding: 8px;")
button_layout.addWidget(apply_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.setFixedWidth(120)
cancel_btn.clicked.connect(filter_dialog.reject)
cancel_btn.setStyleSheet("background-color: #6c757d; color: white; padding: 8px;")
button_layout.addWidget(cancel_btn)
button_layout.addStretch()
# Show dialog with fade animation
try:
from dialog_animations import exec_dialog_with_fade
exec_dialog_with_fade(filter_dialog, duration=250)
except Exception:
filter_dialog.exec()
def export_selection():
selected = self.glossary_tree.selectedItems()
if not selected:
QMessageBox.warning(self.dialog, "Warning", "No entries selected")
return
path, _ = QFileDialog.getSaveFileName(
self.dialog,
"Export Selected Entries",
"",
"JSON files (*.json);;CSV files (*.csv)"
)
if not path:
return
try:
if self.current_glossary_format == 'list':
exported = []
for item in selected:
idx = int(item.text(0)) - 1
if 0 <= idx < len(self.current_glossary_data):
exported.append(self.current_glossary_data[idx])
if path.endswith('.csv'):
# Export as CSV
import csv
with open(path, 'w', encoding='utf-8', newline='') as f:
writer = csv.writer(f)
for entry in exported:
if entry.get('type') == 'character':
writer.writerow([entry.get('type', ''), entry.get('raw_name', ''),
entry.get('translated_name', ''), entry.get('gender', '')])
else:
writer.writerow([entry.get('type', ''), entry.get('raw_name', ''),
entry.get('translated_name', ''), ''])
else:
# Export as JSON
with open(path, 'w', encoding='utf-8') as f:
json.dump(exported, f, ensure_ascii=False, indent=2)
else:
exported = {}
entries_list = list(self.current_glossary_data.get('entries', {}).items())
for item in selected:
idx = int(item.text(0)) - 1
if 0 <= idx < len(entries_list):
key, value = entries_list[idx]
exported[key] = value
with open(path, 'w', encoding='utf-8') as f:
json.dump(exported, f, ensure_ascii=False, indent=2)
QMessageBox.information(self.dialog, "Success", f"Exported {len(selected)} entries to {os.path.basename(path)}")
except Exception as e:
QMessageBox.critical(self.dialog, "Error", f"Failed to export: {e}")
def collect_translated_changes():
"""Return list of (old, new) translated-name changes since last baseline."""
changes = []
if self.current_glossary_format in ['list', 'token_csv']:
for idx, entry in enumerate(self.current_glossary_data or []):
old = self._original_translated_map.get(idx, entry.get('translated_name', ''))
new = entry.get('translated_name', '')
if old != new:
changes.append((old, new))
elif self.current_glossary_format == 'dict':
entries = (self.current_glossary_data or {}).get('entries', {})
for key, new in entries.items():
old = self._original_translated_map.get(key, new)
if old != new:
changes.append((old, new))
return changes
def update_html_files(changes):
"""Replace old translated names with new ones across .html files."""
if not changes:
return 0, 0
# Derive target directory ONLY from the loaded glossary file path
# The glossary is typically at <book_output>/glossary.csv or <book_output>/Glossary/glossary.csv
glossary_path = self.editor_file_entry.text()
if not glossary_path or not os.path.exists(glossary_path):
self.append_log("⚠️ Cannot update HTML files: no glossary file loaded.")
return 0, 0
glossary_dir = os.path.dirname(glossary_path)
# If glossary is in a "Glossary" subfolder, go up one level to get book output dir
if os.path.basename(glossary_dir).lower() == 'glossary':
book_output_dir = os.path.dirname(glossary_dir)
else:
book_output_dir = glossary_dir
if not os.path.isdir(book_output_dir):
self.append_log(f"⚠️ Cannot update HTML files: directory not found: {book_output_dir}")
return 0, 0
files_updated = 0
total_replacements = 0
# Excluded files: .csv, .json, metadata files (except glossary files)
excluded_extensions = {'.csv', '.json'}
allowed_names = {'glossary.csv', 'glossary.json'}
excluded_names = {'metadata.json', 'metadata.opf', 'metadata.xml', 'content.opf', 'toc.ncx'}
# Only process files directly in book_output_dir (no subfolders)
for name in os.listdir(book_output_dir):
path = os.path.join(book_output_dir, name)
if not os.path.isfile(path):
continue
lower_name = name.lower()
# Skip excluded extensions (unless it's an allowed glossary file)
if lower_name not in allowed_names:
if any(lower_name.endswith(ext) for ext in excluded_extensions):
continue
if lower_name in excluded_names:
continue
try:
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
new_content = content
for old, new in changes:
if old and new and old in new_content:
new_content = new_content.replace(old, new)
if new_content != content:
with open(path, 'w', encoding='utf-8') as f:
f.write(new_content)
files_updated += 1
replaced_here = 0
for old, new in changes:
replaced_here += content.count(old)
total_replacements += replaced_here
self.append_log(f"Updated file: {path} ({replaced_here} replacements)")
except Exception as e:
self.append_log(f"⚠️ Failed to update {path}: {e}")
return files_updated, total_replacements
def save_edited_glossary():
changes = collect_translated_changes()
if self.update_html_on_save_checkbox.isChecked() and changes:
example_lines = "<br>".join(f"{old or '&lt;empty&gt;'} -> {new or '&lt;empty&gt;'}" for old, new in changes[:5])
msg = QMessageBox(self.dialog)
msg.setIcon(QMessageBox.Warning)
msg.setWindowTitle("Update HTML files")
msg.setText(f"{len(changes)} entries have had their translated name field updated.\nNow all HTML files will be updated to reflect the change.")
msg.setInformativeText(example_lines)
msg.setTextFormat(Qt.RichText)
msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
msg.setDefaultButton(QMessageBox.Yes)
if msg.exec() != QMessageBox.Yes:
return
if save_current_glossary():
files_updated = 0
if self.update_html_on_save_checkbox.isChecked() and changes:
files_updated, total_repl = update_html_files(changes)
self.append_log(f"Updated {files_updated} files with translated-name changes ({total_repl} replacements).")
# Reset baseline and highlights
if self.current_glossary_format in ['list', 'token_csv']:
self._original_translated_map = {
idx: entry.get('translated_name', '') for idx, entry in enumerate(self.current_glossary_data)
}
elif self.current_glossary_format == 'dict':
self._original_translated_map = dict((self.current_glossary_data or {}).get('entries', {}))
for i in range(self.glossary_tree.topLevelItemCount()):
mark_row_updated(self.glossary_tree.topLevelItem(i), False)
QMessageBox.information(self.dialog, "Success", "Glossary saved successfully")
self.append_log(f"✅ Saved glossary to: {self.editor_file_entry.text()}")
def save_as_glossary():
if not self.current_glossary_data:
QMessageBox.critical(self.dialog, "Error", "No glossary loaded")
return
path, _ = QFileDialog.getSaveFileName(
self.dialog,
"Save Glossary As",
"",
"JSON files (*.json);;CSV files (*.csv)"
)
if not path:
return
try:
if path.endswith('.csv'):
# Save as CSV
import csv
with open(path, 'w', encoding='utf-8', newline='') as f:
writer = csv.writer(f)
if self.current_glossary_format == 'list':
for entry in self.current_glossary_data:
if entry.get('type') == 'character':
writer.writerow([entry.get('type', ''), entry.get('raw_name', ''),
entry.get('translated_name', ''), entry.get('gender', '')])
else:
writer.writerow([entry.get('type', ''), entry.get('raw_name', ''),
entry.get('translated_name', ''), ''])
else:
# Save as JSON
with open(path, 'w', encoding='utf-8') as f:
json.dump(self.current_glossary_data, f, ensure_ascii=False, indent=2)
self.editor_file_entry.setText(path)
QMessageBox.information(self.dialog, "Success", f"Glossary saved to {os.path.basename(path)}")
self.append_log(f"✅ Saved glossary as: {path}")
except Exception as e:
QMessageBox.critical(self.dialog, "Error", f"Failed to save: {e}")
# Automatically load the currently auto-loaded glossary (CSV preferred) when opening the tab
def auto_select_current_glossary():
try:
auto_path = getattr(self, 'auto_loaded_glossary_path', None)
manual_path = getattr(self, 'manual_glossary_path', None)
# Prefer the auto-loaded glossary if it exists and is a CSV
if auto_path and os.path.exists(auto_path):
self.editor_file_entry.setText(auto_path)
load_glossary_for_editing()
return
# Fallback to any currently loaded manual glossary
if manual_path and os.path.exists(manual_path):
self.editor_file_entry.setText(manual_path)
load_glossary_for_editing()
return
# SIMPLE FALLBACK: derive from current input file's output folder
try:
epub_path = None
if hasattr(self, 'get_current_epub_path'):
epub_path = self.get_current_epub_path()
if not epub_path and hasattr(self, 'file_path'):
epub_path = getattr(self, 'file_path', None)
if epub_path and os.path.exists(epub_path):
base = os.path.splitext(os.path.basename(epub_path))[0]
out_dir = os.path.join(os.getcwd(), base)
candidates = [
# CSV (highest priority)
os.path.join(out_dir, "glossary.csv"),
os.path.join(out_dir, "Glossary", "glossary.csv"),
# JSON
os.path.join(out_dir, "glossary.json"),
os.path.join(out_dir, "Glossary", "glossary.json"),
# TXT / MD (lowest priority after CSV/JSON)
os.path.join(out_dir, "glossary.txt"),
os.path.join(out_dir, "Glossary", "glossary.txt"),
os.path.join(out_dir, "glossary.md"),
os.path.join(out_dir, "Glossary", "glossary.md"),
]
for cand in candidates:
if os.path.exists(cand):
self.editor_file_entry.setText(cand)
load_glossary_for_editing()
return
except Exception:
pass
except Exception:
pass
auto_select_current_glossary()
# Quick toolbar above the entry list
toolbar_widget = QWidget()
toolbar_layout = QHBoxLayout(toolbar_widget)
toolbar_layout.setContentsMargins(0, 0, 0, 4)
# Place the HTML update toggle above the save controls
toolbar_layout.addWidget(html_toggle_widget)
for text, handler, color in [
("Save", save_edited_glossary, "#15803d"),
("Save As...", save_as_glossary, "#0f766e"),
("Delete Selected", delete_selected_entries, "#991b1b")
]:
btn = QPushButton(text)
btn.setFixedWidth(115)
btn.clicked.connect(handler)
btn.setStyleSheet(f"background-color: {color}; color: white; padding: 6px; font-weight: bold;")
toolbar_layout.addWidget(btn)
toolbar_layout.addStretch()
content_frame_layout.insertWidget(0, toolbar_widget)
def show_tree_context_menu(pos):
menu = QMenu(self.glossary_tree)
menu.addAction("Save Changes", save_edited_glossary)
menu.addAction("Save As...", save_as_glossary)
menu.addSeparator()
# Edit selected entry using existing inline editor
current_item = self.glossary_tree.itemAt(pos)
current_col = self.glossary_tree.columnAt(pos.x())
if current_item and current_col > 0:
menu.addAction("Edit", lambda: self._on_tree_double_click(current_item, current_col))
else:
# fallback to current selection
item = self.glossary_tree.currentItem()
col = self.glossary_tree.currentColumn()
if item and col > 0:
menu.addAction("Edit", lambda: self._on_tree_double_click(item, col))
menu.addAction("Delete Selected", delete_selected_entries)
menu.addAction("Reload", load_glossary_for_editing)
menu.exec(self.glossary_tree.viewport().mapToGlobal(pos))
try:
self.glossary_tree.customContextMenuRequested.disconnect()
except Exception:
pass
self.glossary_tree.customContextMenuRequested.connect(show_tree_context_menu)
def find_in_tree():
import re
dialog = QDialog(self.dialog)
dialog.setWindowTitle("Find / Replace")
dialog_layout = QVBoxLayout(dialog)
grid = QGridLayout()
dialog_layout.addLayout(grid)
find_edit = QLineEdit(getattr(self, "_last_find_text", ""))
replace_edit = QLineEdit(getattr(self, "_last_replace_text", ""))
grid.addWidget(QLabel("Find:"), 0, 0)
grid.addWidget(find_edit, 0, 1)
grid.addWidget(QLabel("Replace with:"), 1, 0)
grid.addWidget(replace_edit, 1, 1)
status_label = QLabel("")
status_label.setStyleSheet("color: #9ca3af;")
dialog_layout.addWidget(status_label)
def find_next():
text = find_edit.text()
if not text:
return
total = self.glossary_tree.topLevelItemCount()
if total == 0:
return
if text != getattr(self, "_last_find_text", ""):
self._last_find_pos = -1
text_lower = text.lower()
start = (getattr(self, "_last_find_pos", -1) + 1) % total
for offset in range(total):
idx = (start + offset) % total
item = self.glossary_tree.topLevelItem(idx)
cols = [item.text(c) for c in range(item.columnCount())]
if any(text_lower in c.lower() for c in cols):
self.glossary_tree.setCurrentItem(item)
self.glossary_tree.scrollToItem(item)
self._last_find_text = text
self._last_find_pos = idx
status_label.setText(f"Found at row {idx + 1}")
return
status_label.setText("No matches found.")
def replace_in_item(item):
"""Replace occurrences in a single tree item and keep data in sync."""
text = find_edit.text()
repl = replace_edit.text()
if not text or item is None:
return 0
pattern = re.compile(re.escape(text), re.IGNORECASE)
replacements = 0
for col_idx in range(1, item.columnCount()):
before = item.text(col_idx)
after, count = pattern.subn(repl, before)
if count == 0:
continue
item.setText(col_idx, after)
col_key = self.glossary_column_fields[col_idx - 1] if self.glossary_column_fields else None
if self.current_glossary_format in ['list', 'token_csv']:
try:
row_idx = int(item.text(0)) - 1
except Exception:
row_idx = -1
if 0 <= row_idx < len(self.current_glossary_data) and col_key:
entry = self.current_glossary_data[row_idx]
if col_key == '_section':
entry['_section'] = after
elif after:
entry[col_key] = after
else:
entry.pop(col_key, None)
elif self.current_glossary_format == 'dict':
key = item.data(0, Qt.UserRole)
entries = self.current_glossary_data.get('entries', {})
if col_key == 'original':
value = entries.pop(key, None)
new_key = after if after else key
entries[new_key] = value
item.setData(0, Qt.UserRole, new_key)
elif col_key == 'translated' and key in entries:
entries[key] = after
replacements += count
update_row_highlight(item, col_key, after)
return replacements
def replace_current():
count = replace_in_item(self.glossary_tree.currentItem())
status_label.setText(f"Replaced {count} occurrence(s) in current row" if count else "No matches in current row.")
def replace_all():
total_items = self.glossary_tree.topLevelItemCount()
if total_items == 0 or not find_edit.text():
return
total_repl = 0
for idx in range(total_items):
item = self.glossary_tree.topLevelItem(idx)
total_repl += replace_in_item(item)
self._last_find_text = find_edit.text()
self._last_replace_text = replace_edit.text()
status_label.setText(f"Replaced {total_repl} occurrence(s) across all entries.")
# Fallback: if nothing replaced in glossary, offer to update files directly
if total_repl == 0:
old = find_edit.text()
new = replace_edit.text()
prompt = QMessageBox(self.dialog)
prompt.setIcon(QMessageBox.Question)
prompt.setWindowTitle("No glossary match")
prompt.setText("No entry found in the glossary. Update HTML/TXT files directly?")
prompt.setInformativeText(f"{old} -> {new}")
prompt.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
prompt.setDefaultButton(QMessageBox.Yes)
if prompt.exec() == QMessageBox.Yes:
files_updated, total_file_repl = update_html_files([(old, new)])
self.append_log(f"Updated {files_updated} files directly from Find/Replace fallback ({total_file_repl} replacements).")
status_label.setText(f"Updated {files_updated} files directly ({total_file_repl} replacements).")
buttons = QHBoxLayout()
dialog_layout.addLayout(buttons)
find_btn = QPushButton("Find Next")
find_btn.clicked.connect(find_next)
buttons.addWidget(find_btn)
replace_btn = QPushButton("Replace")
replace_btn.clicked.connect(replace_current)
buttons.addWidget(replace_btn)
replace_all_btn = QPushButton("Replace All")
replace_all_btn.clicked.connect(replace_all)
buttons.addWidget(replace_all_btn)
buttons.addStretch()
close_btn = QPushButton("Close")
close_btn.clicked.connect(dialog.accept)
buttons.addWidget(close_btn)
find_edit.returnPressed.connect(find_next)
dialog.exec()
# Buttons
browse_btn = QPushButton("Browse")
browse_btn.setFixedWidth(135)
browse_btn.clicked.connect(browse_glossary)
browse_btn.setStyleSheet("background-color: #495057; color: white; padding: 8px; font-weight: bold;")
file_layout.addWidget(browse_btn)
# Advanced editing toggle and button grid placed above the tree
advanced_toggle_widget = QWidget()
advanced_toggle_layout = QHBoxLayout(advanced_toggle_widget)
advanced_toggle_layout.setContentsMargins(0, 4, 0, 4)
advanced_toggle_layout.addStretch()
advanced_checkbox = self._create_styled_checkbox("Advanced editing")
advanced_checkbox.setChecked(False)
advanced_toggle_layout.addWidget(advanced_checkbox)
content_frame_layout.insertWidget(0, advanced_toggle_widget)
advanced_tools_widget = QWidget()
advanced_tools_layout = QGridLayout(advanced_tools_widget)
advanced_tools_layout.setContentsMargins(0, 0, 0, 4)
advanced_tools_layout.setHorizontalSpacing(10)
advanced_tools_layout.setVerticalSpacing(8)
def add_adv_btn(row, col, text, handler, color, width=150):
btn = QPushButton(text)
btn.setFixedWidth(width)
btn.clicked.connect(handler)
btn.setStyleSheet(f"background-color: {color}; color: white; padding: 8px; font-weight: bold;")
advanced_tools_layout.addWidget(btn, row, col)
add_adv_btn(0, 0, "Reload", load_glossary_for_editing, "#0891b2")
add_adv_btn(0, 1, "Clean Empty Fields", clean_empty_fields, "#b45309")
add_adv_btn(0, 2, "Remove Duplicates", remove_duplicates, "#b45309")
add_adv_btn(0, 3, "Backup Settings", backup_settings_dialog, "#15803d")
add_adv_btn(1, 0, "Trim Entries", smart_trim_dialog, "#1e40af")
add_adv_btn(1, 1, "Filter Entries", filter_entries_dialog, "#1e40af")
add_adv_btn(1, 2, "Convert Format", lambda: self.convert_glossary_format(load_glossary_for_editing), "#0891b2")
add_adv_btn(1, 3, "Export Selection", export_selection, "#4b5563")
add_adv_btn(1, 4, "About Format", duplicate_detection_settings, "#0891b2")
advanced_tools_widget.setVisible(False)
content_frame_layout.insertWidget(2, advanced_tools_widget)
def toggle_advanced(state):
advanced_tools_widget.setVisible(bool(state))
advanced_checkbox.stateChanged.connect(toggle_advanced)
# Keyboard shortcuts
QShortcut(QKeySequence.Save, self.dialog, activated=save_edited_glossary)
QShortcut(QKeySequence.Delete, self.dialog, activated=delete_selected_entries)
QShortcut(QKeySequence.Find, self.dialog, activated=find_in_tree)
def _on_tree_double_click(self, item, column_idx):
"""Handle double-click on treeview item for inline editing"""
if not item or column_idx <= 0:
return
if not self.glossary_column_fields or column_idx - 1 >= len(self.glossary_column_fields):
return
col_key = self.glossary_column_fields[column_idx - 1]
current_value = item.text(column_idx)
edit_dialog = QDialog(self.dialog)
edit_dialog.setWindowTitle(f"Edit {col_key.replace('_', ' ').title()}")
edit_dialog.setMinimumWidth(400)
edit_dialog.setMinimumHeight(150)
dialog_layout = QVBoxLayout(edit_dialog)
dialog_layout.setContentsMargins(20, 20, 20, 20)
label = QLabel(f"Edit {col_key.replace('_', ' ').title()}:")
dialog_layout.addWidget(label)
entry = QLineEdit(current_value)
dialog_layout.addWidget(entry)
dialog_layout.addSpacing(5)
entry.setFocus()
entry.selectAll()
def save_edit():
new_value = entry.text()
item.setText(column_idx, new_value)
row_idx = int(item.text(0)) - 1
if self.current_glossary_format in ['list', 'token_csv']:
if 0 <= row_idx < len(self.current_glossary_data):
data_entry = self.current_glossary_data[row_idx]
if new_value:
data_entry[col_key] = new_value
else:
data_entry.pop(col_key, None)
elif self.current_glossary_format == 'dict':
key = item.data(0, Qt.UserRole)
entries = self.current_glossary_data.get('entries', {})
if key in entries:
if col_key == 'original':
value = entries.pop(key)
new_key = new_value or key
entries[new_key] = value
item.setData(0, Qt.UserRole, new_key)
elif col_key == 'translated':
entries[key] = new_value
# Local highlight update to mark changed translated fields
if col_key in ['translated_name', 'translated']:
try:
orange = getattr(self, "_highlight_brush_orange", None)
default = getattr(self, "_highlight_brush_default", None)
if orange is None:
orange = QBrush(QColor("#f97316"))
default = QBrush()
self._highlight_brush_orange = orange
self._highlight_brush_default = default
baseline = None
if self.current_glossary_format in ['list', 'token_csv']:
baseline = self._original_translated_map.get(row_idx, '')
elif self.current_glossary_format == 'dict':
baseline = self._original_translated_map.get(item.data(0, Qt.UserRole), '')
if baseline is not None:
brush = orange if new_value != baseline else default
for c in range(item.columnCount()):
item.setBackground(c, brush)
except Exception:
pass
edit_dialog.accept()
dialog_layout.addSpacing(10)
button_layout = QHBoxLayout()
dialog_layout.addLayout(button_layout)
save_btn = QPushButton("Save")
save_btn.setFixedWidth(80)
save_btn.clicked.connect(save_edit)
save_btn.setStyleSheet("background-color: #198754; color: white; padding: 8px;")
button_layout.addWidget(save_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.setFixedWidth(80)
cancel_btn.clicked.connect(edit_dialog.reject)
cancel_btn.setStyleSheet("background-color: #6c757d; color: white; padding: 8px;")
button_layout.addWidget(cancel_btn)
entry.returnPressed.connect(save_edit)
try:
from dialog_animations import exec_dialog_with_fade
exec_dialog_with_fade(edit_dialog, duration=250)
except Exception:
edit_dialog.exec()
def convert_glossary_format(self, reload_callback):
"""Export glossary to CSV format"""
if not self.current_glossary_data:
QMessageBox.critical(self.dialog, "Error", "No glossary loaded")
return
# Create backup before conversion
if not self.create_glossary_backup("before_export"):
return
# Get current file path
current_path = self.editor_file_entry.text()
if current_path:
default_csv_path = current_path.replace('.json', '.csv')
else:
override_dir = os.environ.get("OUTPUT_DIRECTORY") or self.config.get("output_directory", "")
default_csv_path = os.path.join(os.path.abspath(override_dir) if override_dir else os.getcwd(), "glossary.csv")
# Ask user for CSV save location
csv_path, _ = QFileDialog.getSaveFileName(
self.dialog,
"Export Glossary to CSV",
default_csv_path,
"CSV files (*.csv);;All files (*.*)"
)
if not csv_path:
return
try:
import csv
# Get custom types for gender info
custom_types = self.config.get('custom_entry_types', {
'character': {'enabled': True, 'has_gender': True},
'terms': {'enabled': True, 'has_gender': False}
})
# Get custom fields
custom_fields = self.config.get('custom_glossary_fields', [])
with open(csv_path, 'w', encoding='utf-8', newline='') as f:
writer = csv.writer(f)
# Build header row
header = ['type', 'raw_name', 'translated_name', 'gender']
if custom_fields:
header.extend(custom_fields)
# Write header row
writer.writerow(header)
# Process based on format
if isinstance(self.current_glossary_data, list) and self.current_glossary_data:
if 'type' in self.current_glossary_data[0]:
# New format - direct export
for entry in self.current_glossary_data:
entry_type = entry.get('type', 'terms')
type_config = custom_types.get(entry_type, {})
row = [
entry_type,
entry.get('raw_name', ''),
entry.get('translated_name', '')
]
# Add gender
if type_config.get('has_gender', False):
row.append(entry.get('gender', ''))
else:
row.append('')
# Add custom field values
for field in custom_fields:
row.append(entry.get(field, ''))
writer.writerow(row)
else:
# Old format - convert then export
for entry in self.current_glossary_data:
# Determine type
is_location = False
if 'locations' in entry and entry['locations']:
is_location = True
elif 'title' in entry and any(term in str(entry.get('title', '')).lower()
for term in ['location', 'place', 'city', 'region']):
is_location = True
entry_type = 'terms' if is_location else 'character'
type_config = custom_types.get(entry_type, {})
row = [
entry_type,
entry.get('original_name', entry.get('original', '')),
entry.get('name', entry.get('translated', ''))
]
# Add gender
if type_config.get('has_gender', False):
row.append(entry.get('gender', 'Unknown'))
else:
row.append('')
# Add empty custom fields
for field in custom_fields:
row.append('')
writer.writerow(row)
QMessageBox.information(self.dialog, "Success", f"Glossary exported to CSV:\n{csv_path}")
self.append_log(f"✅ Exported glossary to: {csv_path}")
except Exception as e:
QMessageBox.critical(self.dialog, "Export Error", f"Failed to export CSV: {e}")
self.append_log(f"❌ CSV export failed: {e}")