supervertaler 1.9.116__py3-none-any.whl → 1.9.130__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- Supervertaler.py +377 -102
- modules/shortcut_manager.py +22 -1
- modules/termview_widget.py +9 -2
- modules/unified_prompt_library.py +12 -9
- modules/unified_prompt_manager_qt.py +355 -112
- {supervertaler-1.9.116.dist-info → supervertaler-1.9.130.dist-info}/METADATA +37 -3
- {supervertaler-1.9.116.dist-info → supervertaler-1.9.130.dist-info}/RECORD +11 -11
- {supervertaler-1.9.116.dist-info → supervertaler-1.9.130.dist-info}/WHEEL +0 -0
- {supervertaler-1.9.116.dist-info → supervertaler-1.9.130.dist-info}/entry_points.txt +0 -0
- {supervertaler-1.9.116.dist-info → supervertaler-1.9.130.dist-info}/licenses/LICENSE +0 -0
- {supervertaler-1.9.116.dist-info → supervertaler-1.9.130.dist-info}/top_level.txt +0 -0
Supervertaler.py
CHANGED
|
@@ -3,7 +3,7 @@ Supervertaler
|
|
|
3
3
|
=============
|
|
4
4
|
The Ultimate Translation Workbench.
|
|
5
5
|
Modern PyQt6 interface with specialised modules to handle any problem.
|
|
6
|
-
Version: 1.9.
|
|
6
|
+
Version: 1.9.123 (QuickMenu now supports generic AI tasks)
|
|
7
7
|
Release Date: January 19, 2026
|
|
8
8
|
Framework: PyQt6
|
|
9
9
|
|
|
@@ -34,9 +34,9 @@ License: MIT
|
|
|
34
34
|
"""
|
|
35
35
|
|
|
36
36
|
# Version Information.
|
|
37
|
-
__version__ = "1.9.
|
|
37
|
+
__version__ = "1.9.130"
|
|
38
38
|
__phase__ = "0.9"
|
|
39
|
-
__release_date__ = "2026-01-
|
|
39
|
+
__release_date__ = "2026-01-19"
|
|
40
40
|
__edition__ = "Qt"
|
|
41
41
|
|
|
42
42
|
import sys
|
|
@@ -123,7 +123,7 @@ try:
|
|
|
123
123
|
QButtonGroup, QDialogButtonBox, QTabWidget, QGroupBox, QGridLayout, QCheckBox,
|
|
124
124
|
QProgressBar, QProgressDialog, QFormLayout, QTabBar, QPlainTextEdit, QAbstractItemDelegate,
|
|
125
125
|
QFrame, QListWidget, QListWidgetItem, QStackedWidget, QTreeWidget, QTreeWidgetItem,
|
|
126
|
-
QScrollArea, QSizePolicy, QSlider, QToolButton
|
|
126
|
+
QScrollArea, QSizePolicy, QSlider, QToolButton, QAbstractItemView
|
|
127
127
|
)
|
|
128
128
|
from PyQt6.QtCore import Qt, QSize, QTimer, pyqtSignal, QObject, QUrl
|
|
129
129
|
from PyQt6.QtGui import QFont, QAction, QKeySequence, QIcon, QTextOption, QColor, QDesktopServices, QTextCharFormat, QTextCursor, QBrush, QSyntaxHighlighter, QPalette, QTextBlockFormat
|
|
@@ -170,11 +170,11 @@ def cleanup_ahk_process():
|
|
|
170
170
|
try:
|
|
171
171
|
_ahk_process.terminate()
|
|
172
172
|
_ahk_process.wait(timeout=1)
|
|
173
|
-
print("[
|
|
173
|
+
print("[Hotkeys] AHK process terminated on exit")
|
|
174
174
|
except:
|
|
175
175
|
try:
|
|
176
176
|
_ahk_process.kill()
|
|
177
|
-
print("[
|
|
177
|
+
print("[Hotkeys] AHK process killed on exit")
|
|
178
178
|
except:
|
|
179
179
|
pass
|
|
180
180
|
|
|
@@ -910,6 +910,7 @@ class _CtrlReturnEventFilter(QObject):
|
|
|
910
910
|
|
|
911
911
|
return False
|
|
912
912
|
|
|
913
|
+
|
|
913
914
|
class GridTableEventFilter:
|
|
914
915
|
"""Mixin to pass keyboard shortcuts from editor to table"""
|
|
915
916
|
pass
|
|
@@ -2559,7 +2560,7 @@ class EditableGridTextEditor(QTextEdit):
|
|
|
2559
2560
|
menu.addSeparator()
|
|
2560
2561
|
|
|
2561
2562
|
# Add to dictionary action
|
|
2562
|
-
add_to_dict_action = QAction(f"📖 Add '{misspelled_word}' to Dictionary", self)
|
|
2563
|
+
add_to_dict_action = QAction(f"📖 Add '{misspelled_word}' to Dictionary (Alt+D)", self)
|
|
2563
2564
|
add_to_dict_action.triggered.connect(
|
|
2564
2565
|
lambda checked, w=misspelled_word: self._add_to_dictionary(w)
|
|
2565
2566
|
)
|
|
@@ -5592,6 +5593,11 @@ class SupervertalerQt(QMainWindow):
|
|
|
5592
5593
|
self.termview_shortcuts = []
|
|
5593
5594
|
self._termview_last_key = None # Track last key for double-tap detection
|
|
5594
5595
|
self._termview_last_time = 0 # Track timing for double-tap
|
|
5596
|
+
|
|
5597
|
+
# Double-tap Shift detection for context menu
|
|
5598
|
+
self._shift_last_press_time = 0
|
|
5599
|
+
self._shift_double_tap_threshold = 0.35 # 350ms window
|
|
5600
|
+
|
|
5595
5601
|
for i in range(0, 10): # 0-9
|
|
5596
5602
|
shortcut_id = f"termview_insert_{i}"
|
|
5597
5603
|
default_key = f"Alt+{i}"
|
|
@@ -5635,6 +5641,9 @@ class SupervertalerQt(QMainWindow):
|
|
|
5635
5641
|
self._ctrl_return_event_filter = _CtrlReturnEventFilter(self)
|
|
5636
5642
|
QApplication.instance().installEventFilter(self._ctrl_return_event_filter)
|
|
5637
5643
|
|
|
5644
|
+
# Note: Double-tap Shift for context menu is handled by AutoHotkey (double_shift_menu.ahk)
|
|
5645
|
+
# Qt's event system makes reliable double-tap detection difficult in Python
|
|
5646
|
+
|
|
5638
5647
|
# Ctrl+Shift+Enter - Always confirm all selected segments
|
|
5639
5648
|
create_shortcut("editor_confirm_selected", "Ctrl+Shift+Return", self.confirm_selected_segments)
|
|
5640
5649
|
|
|
@@ -5668,6 +5677,24 @@ class SupervertalerQt(QMainWindow):
|
|
|
5668
5677
|
|
|
5669
5678
|
# Ctrl+Shift+2 - Quick add term with Priority 2
|
|
5670
5679
|
create_shortcut("editor_quick_add_priority_2", "Ctrl+Shift+2", lambda: self._quick_add_term_with_priority(2))
|
|
5680
|
+
|
|
5681
|
+
# Alt+D - Add word at cursor to dictionary
|
|
5682
|
+
create_shortcut("editor_add_to_dictionary", "Alt+D", self.add_word_to_dictionary_shortcut)
|
|
5683
|
+
|
|
5684
|
+
# Ctrl+N - Focus Segment Note tab
|
|
5685
|
+
create_shortcut("editor_focus_notes", "Ctrl+N", self.focus_segment_notes)
|
|
5686
|
+
|
|
5687
|
+
def focus_segment_notes(self):
|
|
5688
|
+
"""Switch to Segment Note tab and focus the notes editor so user can start typing immediately"""
|
|
5689
|
+
if not hasattr(self, 'bottom_tabs'):
|
|
5690
|
+
return
|
|
5691
|
+
|
|
5692
|
+
# Switch to Segment note tab (index 1)
|
|
5693
|
+
self.bottom_tabs.setCurrentIndex(1)
|
|
5694
|
+
|
|
5695
|
+
# Focus the notes editor so user can start typing
|
|
5696
|
+
if hasattr(self, 'bottom_notes_edit'):
|
|
5697
|
+
self.bottom_notes_edit.setFocus()
|
|
5671
5698
|
|
|
5672
5699
|
def refresh_shortcut_enabled_states(self):
|
|
5673
5700
|
"""Refresh enabled/disabled states and key bindings of all global shortcuts from shortcut manager.
|
|
@@ -10836,6 +10863,38 @@ class SupervertalerQt(QMainWindow):
|
|
|
10836
10863
|
self._play_sound_effect('glossary_term_error')
|
|
10837
10864
|
self.statusBar().showMessage(f"Error adding term: {e}", 3000)
|
|
10838
10865
|
|
|
10866
|
+
def add_word_to_dictionary_shortcut(self):
|
|
10867
|
+
"""Add word at cursor position to custom dictionary (Alt+D shortcut)
|
|
10868
|
+
|
|
10869
|
+
Finds the misspelled word at the current cursor position and adds it to the
|
|
10870
|
+
custom dictionary. Works when focus is in a grid target cell.
|
|
10871
|
+
"""
|
|
10872
|
+
# Get currently focused widget
|
|
10873
|
+
focused_widget = QApplication.focusWidget()
|
|
10874
|
+
|
|
10875
|
+
# Check if we're in an editable grid cell
|
|
10876
|
+
if not isinstance(focused_widget, EditableGridTextEditor):
|
|
10877
|
+
self.statusBar().showMessage("Alt+D: Place cursor on a misspelled word in the target cell first", 3000)
|
|
10878
|
+
return
|
|
10879
|
+
|
|
10880
|
+
# Get cursor position
|
|
10881
|
+
cursor = focused_widget.textCursor()
|
|
10882
|
+
|
|
10883
|
+
# Try to find misspelled word at cursor
|
|
10884
|
+
word_info = focused_widget._get_misspelled_word_at_cursor(cursor)
|
|
10885
|
+
|
|
10886
|
+
if word_info[0] is None:
|
|
10887
|
+
# No misspelled word found at cursor
|
|
10888
|
+
self.statusBar().showMessage("No misspelled word at cursor position", 3000)
|
|
10889
|
+
return
|
|
10890
|
+
|
|
10891
|
+
word, start_pos, end_pos = word_info
|
|
10892
|
+
|
|
10893
|
+
# Add to dictionary
|
|
10894
|
+
focused_widget._add_to_dictionary(word)
|
|
10895
|
+
|
|
10896
|
+
# Status message already shown by _add_to_dictionary
|
|
10897
|
+
|
|
10839
10898
|
def add_text_to_non_translatables(self, text: str):
|
|
10840
10899
|
"""Add selected text to active non-translatable list(s)"""
|
|
10841
10900
|
if not text or not text.strip():
|
|
@@ -13956,6 +14015,45 @@ class SupervertalerQt(QMainWindow):
|
|
|
13956
14015
|
full_context_cb.stateChanged.connect(lambda: context_slider.setEnabled(full_context_cb.isChecked()))
|
|
13957
14016
|
update_context_label(context_window_size)
|
|
13958
14017
|
|
|
14018
|
+
prefs_layout.addSpacing(10)
|
|
14019
|
+
|
|
14020
|
+
# QuickMenu document context
|
|
14021
|
+
quickmenu_context_label = QLabel("<b>QuickMenu Document Context:</b>")
|
|
14022
|
+
prefs_layout.addWidget(quickmenu_context_label)
|
|
14023
|
+
|
|
14024
|
+
quickmenu_context_percent = general_prefs.get('quickmenu_context_percent', 50)
|
|
14025
|
+
quickmenu_context_layout = QHBoxLayout()
|
|
14026
|
+
quickmenu_context_layout.addWidget(QLabel(" Document context size:"))
|
|
14027
|
+
quickmenu_context_slider = QSlider(Qt.Orientation.Horizontal)
|
|
14028
|
+
quickmenu_context_slider.setMinimum(0)
|
|
14029
|
+
quickmenu_context_slider.setMaximum(100)
|
|
14030
|
+
quickmenu_context_slider.setValue(quickmenu_context_percent)
|
|
14031
|
+
quickmenu_context_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
|
|
14032
|
+
quickmenu_context_slider.setTickInterval(10)
|
|
14033
|
+
quickmenu_context_layout.addWidget(quickmenu_context_slider)
|
|
14034
|
+
quickmenu_context_value_label = QLabel(f"{quickmenu_context_percent}%")
|
|
14035
|
+
quickmenu_context_value_label.setMinimumWidth(60)
|
|
14036
|
+
quickmenu_context_layout.addWidget(quickmenu_context_value_label)
|
|
14037
|
+
quickmenu_context_layout.addStretch()
|
|
14038
|
+
prefs_layout.addLayout(quickmenu_context_layout)
|
|
14039
|
+
|
|
14040
|
+
quickmenu_context_info = QLabel(
|
|
14041
|
+
" ⓘ When using {{SOURCE+TARGET_CONTEXT}} or {{SOURCE_CONTEXT}} placeholders in QuickMenu prompts.\n"
|
|
14042
|
+
" 0% = disabled, 50% = half the document (default), 100% = entire document.\n"
|
|
14043
|
+
" Limit: maximum 100 segments for performance."
|
|
14044
|
+
)
|
|
14045
|
+
quickmenu_context_info.setStyleSheet("font-size: 9pt; color: #666; padding-left: 20px;")
|
|
14046
|
+
quickmenu_context_info.setWordWrap(True)
|
|
14047
|
+
prefs_layout.addWidget(quickmenu_context_info)
|
|
14048
|
+
|
|
14049
|
+
def update_quickmenu_context_label(value):
|
|
14050
|
+
if value == 0:
|
|
14051
|
+
quickmenu_context_value_label.setText("0% (disabled)")
|
|
14052
|
+
else:
|
|
14053
|
+
quickmenu_context_value_label.setText(f"{value}%")
|
|
14054
|
+
|
|
14055
|
+
quickmenu_context_slider.valueChanged.connect(update_quickmenu_context_label)
|
|
14056
|
+
|
|
13959
14057
|
prefs_layout.addSpacing(5)
|
|
13960
14058
|
|
|
13961
14059
|
# Check TM before API call
|
|
@@ -14100,7 +14198,8 @@ class SupervertalerQt(QMainWindow):
|
|
|
14100
14198
|
batch_size_spin, surrounding_spin, full_context_cb, context_slider,
|
|
14101
14199
|
check_tm_cb, auto_propagate_cb, delay_spin,
|
|
14102
14200
|
ollama_keepwarm_cb,
|
|
14103
|
-
llm_matching_cb, auto_markdown_cb, llm_spin
|
|
14201
|
+
llm_matching_cb, auto_markdown_cb, llm_spin,
|
|
14202
|
+
quickmenu_context_slider
|
|
14104
14203
|
))
|
|
14105
14204
|
layout.addWidget(save_btn)
|
|
14106
14205
|
|
|
@@ -17098,7 +17197,8 @@ class SupervertalerQt(QMainWindow):
|
|
|
17098
17197
|
batch_size_spin, surrounding_spin, full_context_cb, context_slider,
|
|
17099
17198
|
check_tm_cb, auto_propagate_cb, delay_spin,
|
|
17100
17199
|
ollama_keepwarm_cb,
|
|
17101
|
-
llm_matching_cb, auto_markdown_cb, llm_spin
|
|
17200
|
+
llm_matching_cb, auto_markdown_cb, llm_spin,
|
|
17201
|
+
quickmenu_context_slider):
|
|
17102
17202
|
"""Save all AI settings from the unified AI Settings tab"""
|
|
17103
17203
|
# Determine selected provider
|
|
17104
17204
|
if openai_radio.isChecked():
|
|
@@ -17157,6 +17257,7 @@ class SupervertalerQt(QMainWindow):
|
|
|
17157
17257
|
general_prefs['surrounding_segments'] = surrounding_spin.value()
|
|
17158
17258
|
general_prefs['use_full_context'] = full_context_cb.isChecked()
|
|
17159
17259
|
general_prefs['context_window_size'] = context_slider.value()
|
|
17260
|
+
general_prefs['quickmenu_context_percent'] = quickmenu_context_slider.value()
|
|
17160
17261
|
general_prefs['check_tm_before_api'] = check_tm_cb.isChecked()
|
|
17161
17262
|
general_prefs['auto_propagate_100'] = auto_propagate_cb.isChecked()
|
|
17162
17263
|
general_prefs['lookup_delay'] = delay_spin.value()
|
|
@@ -28206,6 +28307,20 @@ class SupervertalerQt(QMainWindow):
|
|
|
28206
28307
|
# Full processing for non-arrow-key navigation (click, etc.)
|
|
28207
28308
|
self._on_cell_selected_full(current_row, current_col, previous_row, previous_col)
|
|
28208
28309
|
|
|
28310
|
+
def _center_row_in_viewport(self, row: int):
|
|
28311
|
+
"""Center the given row vertically in the visible table viewport.
|
|
28312
|
+
|
|
28313
|
+
Uses Qt's built-in scrollTo() with PositionAtCenter hint.
|
|
28314
|
+
"""
|
|
28315
|
+
if row < 0 or row >= self.table.rowCount():
|
|
28316
|
+
return
|
|
28317
|
+
|
|
28318
|
+
# Get the model index for any cell in this row (use column 0)
|
|
28319
|
+
index = self.table.model().index(row, 0)
|
|
28320
|
+
if index.isValid():
|
|
28321
|
+
# Use Qt's built-in centering - PositionAtCenter puts the item in the center of the viewport
|
|
28322
|
+
self.table.scrollTo(index, QAbstractItemView.ScrollHint.PositionAtCenter)
|
|
28323
|
+
|
|
28209
28324
|
def _on_cell_selected_minimal(self, current_row, previous_row):
|
|
28210
28325
|
"""Minimal UI update for fast arrow key navigation - just highlight and scroll"""
|
|
28211
28326
|
try:
|
|
@@ -28227,7 +28342,7 @@ class SupervertalerQt(QMainWindow):
|
|
|
28227
28342
|
|
|
28228
28343
|
# Auto-center if enabled
|
|
28229
28344
|
if getattr(self, 'auto_center_active_segment', False) and not getattr(self, 'filtering_active', False):
|
|
28230
|
-
self.
|
|
28345
|
+
self._center_row_in_viewport(current_row)
|
|
28231
28346
|
except Exception as e:
|
|
28232
28347
|
if self.debug_mode_enabled:
|
|
28233
28348
|
self.log(f"Error in minimal cell selection: {e}")
|
|
@@ -28272,7 +28387,7 @@ class SupervertalerQt(QMainWindow):
|
|
|
28272
28387
|
current_id_item.setForeground(QColor("white"))
|
|
28273
28388
|
|
|
28274
28389
|
if getattr(self, 'auto_center_active_segment', False) and not getattr(self, 'filtering_active', False):
|
|
28275
|
-
self.
|
|
28390
|
+
self._center_row_in_viewport(current_row)
|
|
28276
28391
|
|
|
28277
28392
|
if not self.current_project or current_row < 0:
|
|
28278
28393
|
return
|
|
@@ -31042,11 +31157,18 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
|
|
|
31042
31157
|
normalized_source = clean_source_lower
|
|
31043
31158
|
for quote_char in '\"\'\u201C\u201D\u201E\u00AB\u00BB\u2018\u2019\u201A\u2039\u203A':
|
|
31044
31159
|
normalized_source = normalized_source.replace(quote_char, ' ')
|
|
31160
|
+
|
|
31161
|
+
# CRITICAL FIX: Strip trailing punctuation from glossary term before matching
|
|
31162
|
+
# This allows entries like "De huidige uitvinding...problemen." (with period)
|
|
31163
|
+
# to match source text "...problemen." where tokenization strips the period
|
|
31164
|
+
# Strip from both ends to handle quotes and trailing punctuation
|
|
31165
|
+
normalized_term = source_term.lower().rstrip(PUNCT_CHARS).lstrip(PUNCT_CHARS)
|
|
31166
|
+
|
|
31045
31167
|
# Check if term has punctuation - use different pattern
|
|
31046
|
-
if any(char in
|
|
31047
|
-
pattern = re.compile(r'(?<!\w)' + re.escape(
|
|
31168
|
+
if any(char in normalized_term for char in ['.', '%', ',', '-', '/']):
|
|
31169
|
+
pattern = re.compile(r'(?<!\w)' + re.escape(normalized_term) + r'(?!\w)')
|
|
31048
31170
|
else:
|
|
31049
|
-
pattern = re.compile(r"\b" + re.escape(
|
|
31171
|
+
pattern = re.compile(r"\b" + re.escape(normalized_term) + r"\b")
|
|
31050
31172
|
|
|
31051
31173
|
# Try matching on normalized (tag-stripped, quote-stripped) text first,
|
|
31052
31174
|
# then tag-stripped, then original with tags
|
|
@@ -31503,7 +31625,7 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
|
|
|
31503
31625
|
self.case_sensitive_cb.setChecked(op.case_sensitive)
|
|
31504
31626
|
|
|
31505
31627
|
def _fr_run_set_batch(self, fr_set: FindReplaceSet):
|
|
31506
|
-
"""Run all enabled operations in a F&R Set as a batch."""
|
|
31628
|
+
"""Run all enabled operations in a F&R Set as a batch (optimized for speed)."""
|
|
31507
31629
|
enabled_ops = [op for op in fr_set.operations if op.enabled and op.find_text]
|
|
31508
31630
|
|
|
31509
31631
|
if not enabled_ops:
|
|
@@ -31522,17 +31644,32 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
|
|
|
31522
31644
|
if reply != QMessageBox.StandardButton.Yes:
|
|
31523
31645
|
return
|
|
31524
31646
|
|
|
31525
|
-
#
|
|
31526
|
-
|
|
31527
|
-
for op in enabled_ops:
|
|
31528
|
-
count = self._execute_single_fr_operation(op)
|
|
31529
|
-
total_replaced += count
|
|
31530
|
-
self.log(f" '{op.find_text}' → '{op.replace_text}': {count} replacement(s)")
|
|
31647
|
+
# OPTIMIZATION: Disable UI updates during batch processing
|
|
31648
|
+
self.table.setUpdatesEnabled(False)
|
|
31531
31649
|
|
|
31532
|
-
|
|
31533
|
-
|
|
31534
|
-
|
|
31535
|
-
|
|
31650
|
+
try:
|
|
31651
|
+
# Run each operation
|
|
31652
|
+
total_replaced = 0
|
|
31653
|
+
for op in enabled_ops:
|
|
31654
|
+
count = self._execute_single_fr_operation(op)
|
|
31655
|
+
total_replaced += count
|
|
31656
|
+
self.log(f" '{op.find_text}' → '{op.replace_text}': {count} replacement(s)")
|
|
31657
|
+
|
|
31658
|
+
self.project_modified = True
|
|
31659
|
+
self.update_window_title()
|
|
31660
|
+
|
|
31661
|
+
finally:
|
|
31662
|
+
# OPTIMIZATION: Re-enable UI updates and refresh only target column cells
|
|
31663
|
+
self.table.setUpdatesEnabled(True)
|
|
31664
|
+
# Update all target cells in-place (batch operations can affect many segments)
|
|
31665
|
+
for row in range(len(self.current_project.segments)):
|
|
31666
|
+
segment = self.current_project.segments[row]
|
|
31667
|
+
target_widget = self.table.cellWidget(row, 3)
|
|
31668
|
+
if target_widget and hasattr(target_widget, 'setPlainText'):
|
|
31669
|
+
target_widget.blockSignals(True)
|
|
31670
|
+
target_widget.setPlainText(segment.target)
|
|
31671
|
+
target_widget.blockSignals(False)
|
|
31672
|
+
self.table.viewport().update()
|
|
31536
31673
|
|
|
31537
31674
|
QMessageBox.information(
|
|
31538
31675
|
self.find_replace_dialog,
|
|
@@ -31541,12 +31678,30 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
|
|
|
31541
31678
|
)
|
|
31542
31679
|
|
|
31543
31680
|
def _execute_single_fr_operation(self, op: FindReplaceOperation) -> int:
|
|
31544
|
-
"""Execute a single F&R operation on all segments. Returns replacement count."""
|
|
31681
|
+
"""Execute a single F&R operation on all segments (optimized). Returns replacement count."""
|
|
31545
31682
|
import re
|
|
31546
31683
|
count = 0
|
|
31547
31684
|
|
|
31685
|
+
# OPTIMIZATION: Pre-filter segments - only check segments that might contain the text
|
|
31686
|
+
# Quick case-insensitive check to skip segments that definitely don't match
|
|
31687
|
+
search_text_lower = op.find_text.lower() if not op.case_sensitive else None
|
|
31688
|
+
|
|
31548
31689
|
for segment in self.current_project.segments:
|
|
31549
31690
|
texts_to_check = []
|
|
31691
|
+
|
|
31692
|
+
# Pre-filter: skip segments that can't possibly match
|
|
31693
|
+
if not op.case_sensitive:
|
|
31694
|
+
# Quick check: does the segment contain the search text at all?
|
|
31695
|
+
skip_segment = True
|
|
31696
|
+
if op.search_in in ("source", "both") and self.allow_replace_in_source:
|
|
31697
|
+
if search_text_lower in segment.source.lower():
|
|
31698
|
+
skip_segment = False
|
|
31699
|
+
if op.search_in in ("target", "both"):
|
|
31700
|
+
if search_text_lower in segment.target.lower():
|
|
31701
|
+
skip_segment = False
|
|
31702
|
+
if skip_segment:
|
|
31703
|
+
continue
|
|
31704
|
+
|
|
31550
31705
|
if op.search_in in ("source", "both") and self.allow_replace_in_source:
|
|
31551
31706
|
texts_to_check.append(("source", segment.source))
|
|
31552
31707
|
if op.search_in in ("target", "both"):
|
|
@@ -31842,7 +31997,7 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
|
|
|
31842
31997
|
self.find_next_match()
|
|
31843
31998
|
|
|
31844
31999
|
def replace_all_matches(self):
|
|
31845
|
-
"""Replace all matches in target segments"""
|
|
32000
|
+
"""Replace all matches in target segments (optimized for speed)"""
|
|
31846
32001
|
find_text = self.find_input.text()
|
|
31847
32002
|
replace_text = self.replace_input.text()
|
|
31848
32003
|
|
|
@@ -31915,52 +32070,67 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
|
|
|
31915
32070
|
if reply != QMessageBox.StandardButton.Yes:
|
|
31916
32071
|
return
|
|
31917
32072
|
|
|
31918
|
-
#
|
|
31919
|
-
|
|
31920
|
-
replaced_count = 0
|
|
32073
|
+
# OPTIMIZATION: Disable UI updates during batch replacement
|
|
32074
|
+
self.table.setUpdatesEnabled(False)
|
|
31921
32075
|
|
|
31922
|
-
|
|
31923
|
-
|
|
31924
|
-
|
|
31925
|
-
|
|
31926
|
-
|
|
31927
|
-
old_text = segment.source
|
|
31928
|
-
else: # col == 3, Target
|
|
31929
|
-
old_text = segment.target
|
|
32076
|
+
try:
|
|
32077
|
+
# Perform replacements
|
|
32078
|
+
import re
|
|
32079
|
+
replaced_count = 0
|
|
32080
|
+
updated_rows = set() # Track which rows need UI updates
|
|
31930
32081
|
|
|
31931
|
-
|
|
31932
|
-
|
|
31933
|
-
|
|
31934
|
-
|
|
31935
|
-
if
|
|
31936
|
-
|
|
31937
|
-
else:
|
|
31938
|
-
|
|
31939
|
-
|
|
31940
|
-
|
|
31941
|
-
|
|
31942
|
-
|
|
31943
|
-
# Update the appropriate field
|
|
31944
|
-
if col == 2:
|
|
31945
|
-
segment.source = new_text
|
|
32082
|
+
for row, col in self.find_matches:
|
|
32083
|
+
segment = self.current_project.segments[row]
|
|
32084
|
+
|
|
32085
|
+
# Get the appropriate field
|
|
32086
|
+
if col == 2: # Source
|
|
32087
|
+
old_text = segment.source
|
|
32088
|
+
else: # col == 3, Target
|
|
32089
|
+
old_text = segment.target
|
|
32090
|
+
|
|
32091
|
+
# Perform replacement
|
|
32092
|
+
if match_mode == 2: # Entire segment
|
|
32093
|
+
new_text = replace_text
|
|
31946
32094
|
else:
|
|
31947
|
-
|
|
31948
|
-
|
|
31949
|
-
|
|
31950
|
-
|
|
31951
|
-
|
|
32095
|
+
if case_sensitive:
|
|
32096
|
+
new_text = old_text.replace(find_text, replace_text)
|
|
32097
|
+
else:
|
|
32098
|
+
pattern = re.escape(find_text)
|
|
32099
|
+
new_text = re.sub(pattern, replace_text, old_text, flags=re.IGNORECASE)
|
|
31952
32100
|
|
|
31953
|
-
|
|
31954
|
-
|
|
31955
|
-
|
|
31956
|
-
|
|
31957
|
-
|
|
31958
|
-
|
|
31959
|
-
|
|
31960
|
-
|
|
31961
|
-
|
|
31962
|
-
|
|
31963
|
-
|
|
32101
|
+
if new_text != old_text:
|
|
32102
|
+
replaced_count += 1
|
|
32103
|
+
updated_rows.add(row)
|
|
32104
|
+
|
|
32105
|
+
# Update the appropriate field
|
|
32106
|
+
if col == 2:
|
|
32107
|
+
segment.source = new_text
|
|
32108
|
+
else:
|
|
32109
|
+
old_target = segment.target
|
|
32110
|
+
old_status = segment.status
|
|
32111
|
+
segment.target = new_text
|
|
32112
|
+
# Record undo state for find/replace operation
|
|
32113
|
+
self.record_undo_state(segment.id, old_target, new_text, old_status, old_status)
|
|
32114
|
+
|
|
32115
|
+
# OPTIMIZATION: Update only the affected cell widget in-place
|
|
32116
|
+
cell_widget = self.table.cellWidget(row, col)
|
|
32117
|
+
if cell_widget and hasattr(cell_widget, 'setPlainText'):
|
|
32118
|
+
cell_widget.blockSignals(True)
|
|
32119
|
+
cell_widget.setPlainText(new_text)
|
|
32120
|
+
cell_widget.blockSignals(False)
|
|
32121
|
+
|
|
32122
|
+
self.project_modified = True
|
|
32123
|
+
self.update_window_title()
|
|
32124
|
+
|
|
32125
|
+
# Clear matches
|
|
32126
|
+
self.find_matches = []
|
|
32127
|
+
|
|
32128
|
+
finally:
|
|
32129
|
+
# OPTIMIZATION: Re-enable UI updates without full grid reload
|
|
32130
|
+
self.table.setUpdatesEnabled(True)
|
|
32131
|
+
# Trigger a repaint of updated rows only
|
|
32132
|
+
for row in updated_rows:
|
|
32133
|
+
self.table.viewport().update()
|
|
31964
32134
|
|
|
31965
32135
|
QMessageBox.information(self.find_replace_dialog, "Replace All", f"Replaced {replaced_count} occurrence(s).")
|
|
31966
32136
|
self.log(f"✓ Replaced {replaced_count} occurrence(s) of '{find_text}'")
|
|
@@ -37564,7 +37734,19 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
|
|
|
37564
37734
|
return text.strip()
|
|
37565
37735
|
|
|
37566
37736
|
def _quickmenu_build_custom_prompt(self, prompt_relative_path: str, source_text: str, source_lang: str, target_lang: str) -> str:
|
|
37567
|
-
"""Build a
|
|
37737
|
+
"""Build a prompt for QuickMenu using the selected prompt as instructions.
|
|
37738
|
+
|
|
37739
|
+
This is a GENERIC prompt builder (not translation-specific) that allows QuickMenu prompts
|
|
37740
|
+
to do anything: explain, define, search, translate, analyze, etc.
|
|
37741
|
+
|
|
37742
|
+
Supports placeholders:
|
|
37743
|
+
- {{SELECTION}} or {{SOURCE_TEXT}} - The selected text
|
|
37744
|
+
- {{SOURCE_LANGUAGE}} - Source language name
|
|
37745
|
+
- {{TARGET_LANGUAGE}} - Target language name
|
|
37746
|
+
- {{SOURCE+TARGET_CONTEXT}} - Project segments with both source and target (for proofreading)
|
|
37747
|
+
- {{SOURCE_CONTEXT}} - Project segments with source only (for translation questions)
|
|
37748
|
+
- {{TARGET_CONTEXT}} - Project segments with target only (for consistency/style analysis)
|
|
37749
|
+
"""
|
|
37568
37750
|
if not hasattr(self, 'prompt_manager_qt') or not self.prompt_manager_qt:
|
|
37569
37751
|
raise RuntimeError("Prompt manager not available")
|
|
37570
37752
|
|
|
@@ -37581,25 +37763,114 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
|
|
|
37581
37763
|
if not prompt_content:
|
|
37582
37764
|
raise RuntimeError("Prompt content is empty")
|
|
37583
37765
|
|
|
37584
|
-
|
|
37585
|
-
|
|
37586
|
-
|
|
37587
|
-
|
|
37588
|
-
|
|
37589
|
-
|
|
37590
|
-
|
|
37591
|
-
|
|
37592
|
-
|
|
37593
|
-
|
|
37766
|
+
# Build document context if requested (three variants)
|
|
37767
|
+
source_target_context = ""
|
|
37768
|
+
source_only_context = ""
|
|
37769
|
+
target_only_context = ""
|
|
37770
|
+
|
|
37771
|
+
has_source_target = "{{SOURCE+TARGET_CONTEXT}}" in prompt_content
|
|
37772
|
+
has_source_only = "{{SOURCE_CONTEXT}}" in prompt_content
|
|
37773
|
+
has_target_only = "{{TARGET_CONTEXT}}" in prompt_content
|
|
37774
|
+
|
|
37775
|
+
if (has_source_target or has_source_only or has_target_only) and hasattr(self, 'current_project') and self.current_project:
|
|
37776
|
+
if has_source_target:
|
|
37777
|
+
source_target_context = self._build_quickmenu_document_context(mode="both")
|
|
37778
|
+
self.log(f"🔍 QuickMenu: Built SOURCE+TARGET context ({len(source_target_context)} chars)")
|
|
37779
|
+
if has_source_only:
|
|
37780
|
+
source_only_context = self._build_quickmenu_document_context(mode="source")
|
|
37781
|
+
self.log(f"🔍 QuickMenu: Built SOURCE_ONLY context ({len(source_only_context)} chars)")
|
|
37782
|
+
if has_target_only:
|
|
37783
|
+
target_only_context = self._build_quickmenu_document_context(mode="target")
|
|
37784
|
+
self.log(f"🔍 QuickMenu: Built TARGET_ONLY context ({len(target_only_context)} chars)")
|
|
37785
|
+
else:
|
|
37786
|
+
if has_source_target:
|
|
37787
|
+
self.log("⚠️ QuickMenu: {{SOURCE+TARGET_CONTEXT}} requested but no project loaded")
|
|
37788
|
+
if has_source_only:
|
|
37789
|
+
self.log("⚠️ QuickMenu: {{SOURCE_CONTEXT}} requested but no project loaded")
|
|
37790
|
+
if has_target_only:
|
|
37791
|
+
self.log("⚠️ QuickMenu: {{TARGET_CONTEXT}} requested but no project loaded")
|
|
37792
|
+
|
|
37793
|
+
# Replace placeholders in the prompt content
|
|
37794
|
+
prompt_content = prompt_content.replace("{{SOURCE_LANGUAGE}}", source_lang)
|
|
37795
|
+
prompt_content = prompt_content.replace("{{TARGET_LANGUAGE}}", target_lang)
|
|
37796
|
+
prompt_content = prompt_content.replace("{{SOURCE_TEXT}}", source_text)
|
|
37797
|
+
prompt_content = prompt_content.replace("{{SELECTION}}", source_text) # Alternative placeholder
|
|
37798
|
+
prompt_content = prompt_content.replace("{{SOURCE+TARGET_CONTEXT}}", source_target_context)
|
|
37799
|
+
prompt_content = prompt_content.replace("{{SOURCE_CONTEXT}}", source_only_context)
|
|
37800
|
+
prompt_content = prompt_content.replace("{{TARGET_CONTEXT}}", target_only_context)
|
|
37801
|
+
|
|
37802
|
+
# Debug: Log the final prompt being sent
|
|
37803
|
+
self.log(f"📝 QuickMenu: Final prompt ({len(prompt_content)} chars):")
|
|
37804
|
+
self.log("─" * 80)
|
|
37805
|
+
self.log(prompt_content[:500] + ("..." if len(prompt_content) > 500 else ""))
|
|
37806
|
+
self.log("─" * 80)
|
|
37807
|
+
|
|
37808
|
+
# If the prompt doesn't contain the selection/text, append it
|
|
37809
|
+
if "{{SOURCE_TEXT}}" not in prompt_data.get('content', '') and "{{SELECTION}}" not in prompt_data.get('content', ''):
|
|
37810
|
+
prompt_content += f"\n\nText:\n{source_text}"
|
|
37811
|
+
|
|
37812
|
+
return prompt_content
|
|
37813
|
+
|
|
37814
|
+
def _build_quickmenu_document_context(self, mode: str = "both") -> str:
|
|
37815
|
+
"""Build document context for QuickMenu prompts.
|
|
37816
|
+
|
|
37817
|
+
Args:
|
|
37818
|
+
mode: One of:
|
|
37819
|
+
- "both": Include both source and target text (for proofreading)
|
|
37820
|
+
- "source": Include only source text (for translation/terminology questions)
|
|
37821
|
+
- "target": Include only target text (for consistency/style analysis)
|
|
37822
|
+
|
|
37823
|
+
Returns a formatted string with segments from the project for context.
|
|
37824
|
+
Uses the 'quickmenu_context_segments' setting (default: 50% of total segments).
|
|
37825
|
+
"""
|
|
37826
|
+
if not hasattr(self, 'current_project') or not self.current_project or not self.current_project.segments:
|
|
37827
|
+
return "(No project context available)"
|
|
37828
|
+
|
|
37594
37829
|
try:
|
|
37595
|
-
|
|
37596
|
-
|
|
37597
|
-
|
|
37598
|
-
|
|
37599
|
-
|
|
37600
|
-
|
|
37601
|
-
|
|
37602
|
-
|
|
37830
|
+
# Get settings
|
|
37831
|
+
general_prefs = self.load_general_settings()
|
|
37832
|
+
context_percent = general_prefs.get('quickmenu_context_percent', 50) # Default: 50%
|
|
37833
|
+
max_context_segments = general_prefs.get('quickmenu_context_max', 100) # Safety limit
|
|
37834
|
+
|
|
37835
|
+
# Calculate how many segments to include
|
|
37836
|
+
total_segments = len(self.current_project.segments)
|
|
37837
|
+
num_segments = min(
|
|
37838
|
+
int(total_segments * context_percent / 100),
|
|
37839
|
+
max_context_segments
|
|
37840
|
+
)
|
|
37841
|
+
|
|
37842
|
+
if num_segments == 0:
|
|
37843
|
+
return "(Document context disabled)"
|
|
37844
|
+
|
|
37845
|
+
# Get segments (from start of document)
|
|
37846
|
+
context_segments = self.current_project.segments[:num_segments]
|
|
37847
|
+
|
|
37848
|
+
# Format segments based on mode
|
|
37849
|
+
context_parts = []
|
|
37850
|
+
mode_labels = {"both": "SOURCE + TARGET", "source": "SOURCE ONLY", "target": "TARGET ONLY"}
|
|
37851
|
+
context_parts.append(f"=== DOCUMENT CONTEXT ({mode_labels.get(mode, 'UNKNOWN')}) ===")
|
|
37852
|
+
context_parts.append(f"(Showing {num_segments} of {total_segments} segments - {context_percent}%)")
|
|
37853
|
+
context_parts.append("")
|
|
37854
|
+
|
|
37855
|
+
for seg in context_segments:
|
|
37856
|
+
if mode == "both":
|
|
37857
|
+
# Source + Target
|
|
37858
|
+
context_parts.append(f"[{seg.id}] {seg.source}")
|
|
37859
|
+
if seg.target and seg.target.strip():
|
|
37860
|
+
context_parts.append(f" → {seg.target}")
|
|
37861
|
+
elif mode == "source":
|
|
37862
|
+
# Source only
|
|
37863
|
+
context_parts.append(f"[{seg.id}] {seg.source}")
|
|
37864
|
+
elif mode == "target":
|
|
37865
|
+
# Target only (skip empty targets)
|
|
37866
|
+
if seg.target and seg.target.strip():
|
|
37867
|
+
context_parts.append(f"[{seg.id}] {seg.target}")
|
|
37868
|
+
context_parts.append("") # Blank line between segments
|
|
37869
|
+
|
|
37870
|
+
return "\n".join(context_parts)
|
|
37871
|
+
|
|
37872
|
+
except Exception as e:
|
|
37873
|
+
return f"(Error building document context: {e})"
|
|
37603
37874
|
|
|
37604
37875
|
def _quickmenu_show_result_dialog(self, title: str, output_text: str, apply_callback=None):
|
|
37605
37876
|
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QDialogButtonBox, QPushButton, QApplication
|
|
@@ -37679,10 +37950,14 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
|
|
|
37679
37950
|
)
|
|
37680
37951
|
|
|
37681
37952
|
client = LLMClient(api_key=api_key, provider=provider, model=model)
|
|
37953
|
+
|
|
37954
|
+
# Use translate() with empty text and custom_prompt for generic AI completion
|
|
37955
|
+
# This allows QuickMenu prompts to do anything (explain, define, search, etc.)
|
|
37956
|
+
# not just translation. Same pattern as AI Assistant.
|
|
37682
37957
|
output_text = client.translate(
|
|
37683
|
-
text=
|
|
37684
|
-
source_lang=
|
|
37685
|
-
target_lang=
|
|
37958
|
+
text="", # Empty - we're using custom_prompt for everything
|
|
37959
|
+
source_lang="en", # Dummy values
|
|
37960
|
+
target_lang="en",
|
|
37686
37961
|
custom_prompt=custom_prompt
|
|
37687
37962
|
)
|
|
37688
37963
|
|
|
@@ -44268,7 +44543,7 @@ class SuperlookupTab(QWidget):
|
|
|
44268
44543
|
try:
|
|
44269
44544
|
# Use multiple methods to ensure cleanup
|
|
44270
44545
|
# Method 1: Kill by window title
|
|
44271
|
-
subprocess.run(['taskkill', '/F', '/FI', 'WINDOWTITLE eq
|
|
44546
|
+
subprocess.run(['taskkill', '/F', '/FI', 'WINDOWTITLE eq supervertaler_hotkeys.ahk*'],
|
|
44272
44547
|
capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW)
|
|
44273
44548
|
|
|
44274
44549
|
# Method 2: Kill AutoHotkey processes more aggressively
|
|
@@ -44280,7 +44555,7 @@ class SuperlookupTab(QWidget):
|
|
|
44280
44555
|
try:
|
|
44281
44556
|
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
|
|
44282
44557
|
try:
|
|
44283
|
-
if '
|
|
44558
|
+
if 'supervertaler_hotkeys' in ' '.join(proc.cmdline() or []):
|
|
44284
44559
|
proc.kill()
|
|
44285
44560
|
except:
|
|
44286
44561
|
pass
|
|
@@ -44293,8 +44568,8 @@ class SuperlookupTab(QWidget):
|
|
|
44293
44568
|
ahk_exe, source = self._find_autohotkey_executable()
|
|
44294
44569
|
|
|
44295
44570
|
if not ahk_exe:
|
|
44296
|
-
print("[
|
|
44297
|
-
print("[
|
|
44571
|
+
print("[Hotkeys] AutoHotkey not found.")
|
|
44572
|
+
print("[Hotkeys] Global hotkeys (Ctrl+Alt+L, Shift+Shift) will not be available.")
|
|
44298
44573
|
self.hotkey_registered = False
|
|
44299
44574
|
# Show setup dialog (deferred to avoid blocking startup) - unless user opted out
|
|
44300
44575
|
if self.main_window and hasattr(self.main_window, 'general_settings'):
|
|
@@ -44304,11 +44579,11 @@ class SuperlookupTab(QWidget):
|
|
|
44304
44579
|
QTimer.singleShot(2000, self._show_autohotkey_setup_dialog)
|
|
44305
44580
|
return
|
|
44306
44581
|
|
|
44307
|
-
print(f"[
|
|
44582
|
+
print(f"[Hotkeys] Found AutoHotkey at: {ahk_exe} (source: {source})")
|
|
44308
44583
|
|
|
44309
|
-
ahk_script = Path(__file__).parent / "
|
|
44310
|
-
print(f"[
|
|
44311
|
-
print(f"[
|
|
44584
|
+
ahk_script = Path(__file__).parent / "supervertaler_hotkeys.ahk"
|
|
44585
|
+
print(f"[Hotkeys] Looking for script at: {ahk_script}")
|
|
44586
|
+
print(f"[Hotkeys] Script exists: {ahk_script.exists()}")
|
|
44312
44587
|
|
|
44313
44588
|
if ahk_script.exists():
|
|
44314
44589
|
# Start AHK script in background (hidden)
|
|
@@ -44316,16 +44591,16 @@ class SuperlookupTab(QWidget):
|
|
|
44316
44591
|
creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
|
|
44317
44592
|
# Store in global variable for atexit cleanup
|
|
44318
44593
|
_ahk_process = self.ahk_process
|
|
44319
|
-
print(f"[
|
|
44594
|
+
print(f"[Hotkeys] AHK hotkeys registered (Ctrl+Alt+L, Shift+Shift)")
|
|
44320
44595
|
|
|
44321
44596
|
# Start file watcher
|
|
44322
44597
|
self.start_file_watcher()
|
|
44323
44598
|
self.hotkey_registered = True
|
|
44324
44599
|
else:
|
|
44325
|
-
print(f"[
|
|
44600
|
+
print(f"[Hotkeys] AHK script not found: {ahk_script}")
|
|
44326
44601
|
self.hotkey_registered = False
|
|
44327
44602
|
except Exception as e:
|
|
44328
|
-
print(f"[
|
|
44603
|
+
print(f"[Hotkeys] Could not start AHK hotkeys: {e}")
|
|
44329
44604
|
self.hotkey_registered = False
|
|
44330
44605
|
|
|
44331
44606
|
def start_file_watcher(self):
|