supervertaler 1.9.116__py3-none-any.whl → 1.9.128__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 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.116 (Fix all tab navigation + startup tab)
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.116"
37
+ __version__ = "1.9.128"
38
38
  __phase__ = "0.9"
39
- __release_date__ = "2026-01-18"
39
+ __release_date__ = "2026-01-19"
40
40
  __edition__ = "Qt"
41
41
 
42
42
  import sys
@@ -2559,7 +2559,7 @@ class EditableGridTextEditor(QTextEdit):
2559
2559
  menu.addSeparator()
2560
2560
 
2561
2561
  # Add to dictionary action
2562
- add_to_dict_action = QAction(f"📖 Add '{misspelled_word}' to Dictionary", self)
2562
+ add_to_dict_action = QAction(f"📖 Add '{misspelled_word}' to Dictionary (Alt+D)", self)
2563
2563
  add_to_dict_action.triggered.connect(
2564
2564
  lambda checked, w=misspelled_word: self._add_to_dictionary(w)
2565
2565
  )
@@ -5668,6 +5668,24 @@ class SupervertalerQt(QMainWindow):
5668
5668
 
5669
5669
  # Ctrl+Shift+2 - Quick add term with Priority 2
5670
5670
  create_shortcut("editor_quick_add_priority_2", "Ctrl+Shift+2", lambda: self._quick_add_term_with_priority(2))
5671
+
5672
+ # Alt+D - Add word at cursor to dictionary
5673
+ create_shortcut("editor_add_to_dictionary", "Alt+D", self.add_word_to_dictionary_shortcut)
5674
+
5675
+ # Ctrl+N - Focus Segment Note tab
5676
+ create_shortcut("editor_focus_notes", "Ctrl+N", self.focus_segment_notes)
5677
+
5678
+ def focus_segment_notes(self):
5679
+ """Switch to Segment Note tab and focus the notes editor so user can start typing immediately"""
5680
+ if not hasattr(self, 'bottom_tabs'):
5681
+ return
5682
+
5683
+ # Switch to Segment note tab (index 1)
5684
+ self.bottom_tabs.setCurrentIndex(1)
5685
+
5686
+ # Focus the notes editor so user can start typing
5687
+ if hasattr(self, 'bottom_notes_edit'):
5688
+ self.bottom_notes_edit.setFocus()
5671
5689
 
5672
5690
  def refresh_shortcut_enabled_states(self):
5673
5691
  """Refresh enabled/disabled states and key bindings of all global shortcuts from shortcut manager.
@@ -10836,6 +10854,38 @@ class SupervertalerQt(QMainWindow):
10836
10854
  self._play_sound_effect('glossary_term_error')
10837
10855
  self.statusBar().showMessage(f"Error adding term: {e}", 3000)
10838
10856
 
10857
+ def add_word_to_dictionary_shortcut(self):
10858
+ """Add word at cursor position to custom dictionary (Alt+D shortcut)
10859
+
10860
+ Finds the misspelled word at the current cursor position and adds it to the
10861
+ custom dictionary. Works when focus is in a grid target cell.
10862
+ """
10863
+ # Get currently focused widget
10864
+ focused_widget = QApplication.focusWidget()
10865
+
10866
+ # Check if we're in an editable grid cell
10867
+ if not isinstance(focused_widget, EditableGridTextEditor):
10868
+ self.statusBar().showMessage("Alt+D: Place cursor on a misspelled word in the target cell first", 3000)
10869
+ return
10870
+
10871
+ # Get cursor position
10872
+ cursor = focused_widget.textCursor()
10873
+
10874
+ # Try to find misspelled word at cursor
10875
+ word_info = focused_widget._get_misspelled_word_at_cursor(cursor)
10876
+
10877
+ if word_info[0] is None:
10878
+ # No misspelled word found at cursor
10879
+ self.statusBar().showMessage("No misspelled word at cursor position", 3000)
10880
+ return
10881
+
10882
+ word, start_pos, end_pos = word_info
10883
+
10884
+ # Add to dictionary
10885
+ focused_widget._add_to_dictionary(word)
10886
+
10887
+ # Status message already shown by _add_to_dictionary
10888
+
10839
10889
  def add_text_to_non_translatables(self, text: str):
10840
10890
  """Add selected text to active non-translatable list(s)"""
10841
10891
  if not text or not text.strip():
@@ -13956,6 +14006,45 @@ class SupervertalerQt(QMainWindow):
13956
14006
  full_context_cb.stateChanged.connect(lambda: context_slider.setEnabled(full_context_cb.isChecked()))
13957
14007
  update_context_label(context_window_size)
13958
14008
 
14009
+ prefs_layout.addSpacing(10)
14010
+
14011
+ # QuickMenu document context
14012
+ quickmenu_context_label = QLabel("<b>QuickMenu Document Context:</b>")
14013
+ prefs_layout.addWidget(quickmenu_context_label)
14014
+
14015
+ quickmenu_context_percent = general_prefs.get('quickmenu_context_percent', 50)
14016
+ quickmenu_context_layout = QHBoxLayout()
14017
+ quickmenu_context_layout.addWidget(QLabel(" Document context size:"))
14018
+ quickmenu_context_slider = QSlider(Qt.Orientation.Horizontal)
14019
+ quickmenu_context_slider.setMinimum(0)
14020
+ quickmenu_context_slider.setMaximum(100)
14021
+ quickmenu_context_slider.setValue(quickmenu_context_percent)
14022
+ quickmenu_context_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
14023
+ quickmenu_context_slider.setTickInterval(10)
14024
+ quickmenu_context_layout.addWidget(quickmenu_context_slider)
14025
+ quickmenu_context_value_label = QLabel(f"{quickmenu_context_percent}%")
14026
+ quickmenu_context_value_label.setMinimumWidth(60)
14027
+ quickmenu_context_layout.addWidget(quickmenu_context_value_label)
14028
+ quickmenu_context_layout.addStretch()
14029
+ prefs_layout.addLayout(quickmenu_context_layout)
14030
+
14031
+ quickmenu_context_info = QLabel(
14032
+ " ⓘ When using {{DOCUMENT_CONTEXT}} placeholder in QuickMenu prompts.\n"
14033
+ " 0% = disabled, 50% = half the document (default), 100% = entire document.\n"
14034
+ " Limit: maximum 100 segments for performance."
14035
+ )
14036
+ quickmenu_context_info.setStyleSheet("font-size: 9pt; color: #666; padding-left: 20px;")
14037
+ quickmenu_context_info.setWordWrap(True)
14038
+ prefs_layout.addWidget(quickmenu_context_info)
14039
+
14040
+ def update_quickmenu_context_label(value):
14041
+ if value == 0:
14042
+ quickmenu_context_value_label.setText("0% (disabled)")
14043
+ else:
14044
+ quickmenu_context_value_label.setText(f"{value}%")
14045
+
14046
+ quickmenu_context_slider.valueChanged.connect(update_quickmenu_context_label)
14047
+
13959
14048
  prefs_layout.addSpacing(5)
13960
14049
 
13961
14050
  # Check TM before API call
@@ -14100,7 +14189,8 @@ class SupervertalerQt(QMainWindow):
14100
14189
  batch_size_spin, surrounding_spin, full_context_cb, context_slider,
14101
14190
  check_tm_cb, auto_propagate_cb, delay_spin,
14102
14191
  ollama_keepwarm_cb,
14103
- llm_matching_cb, auto_markdown_cb, llm_spin
14192
+ llm_matching_cb, auto_markdown_cb, llm_spin,
14193
+ quickmenu_context_slider
14104
14194
  ))
14105
14195
  layout.addWidget(save_btn)
14106
14196
 
@@ -17098,7 +17188,8 @@ class SupervertalerQt(QMainWindow):
17098
17188
  batch_size_spin, surrounding_spin, full_context_cb, context_slider,
17099
17189
  check_tm_cb, auto_propagate_cb, delay_spin,
17100
17190
  ollama_keepwarm_cb,
17101
- llm_matching_cb, auto_markdown_cb, llm_spin):
17191
+ llm_matching_cb, auto_markdown_cb, llm_spin,
17192
+ quickmenu_context_slider):
17102
17193
  """Save all AI settings from the unified AI Settings tab"""
17103
17194
  # Determine selected provider
17104
17195
  if openai_radio.isChecked():
@@ -17157,6 +17248,7 @@ class SupervertalerQt(QMainWindow):
17157
17248
  general_prefs['surrounding_segments'] = surrounding_spin.value()
17158
17249
  general_prefs['use_full_context'] = full_context_cb.isChecked()
17159
17250
  general_prefs['context_window_size'] = context_slider.value()
17251
+ general_prefs['quickmenu_context_percent'] = quickmenu_context_slider.value()
17160
17252
  general_prefs['check_tm_before_api'] = check_tm_cb.isChecked()
17161
17253
  general_prefs['auto_propagate_100'] = auto_propagate_cb.isChecked()
17162
17254
  general_prefs['lookup_delay'] = delay_spin.value()
@@ -31042,11 +31134,18 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
31042
31134
  normalized_source = clean_source_lower
31043
31135
  for quote_char in '\"\'\u201C\u201D\u201E\u00AB\u00BB\u2018\u2019\u201A\u2039\u203A':
31044
31136
  normalized_source = normalized_source.replace(quote_char, ' ')
31137
+
31138
+ # CRITICAL FIX: Strip trailing punctuation from glossary term before matching
31139
+ # This allows entries like "De huidige uitvinding...problemen." (with period)
31140
+ # to match source text "...problemen." where tokenization strips the period
31141
+ # Strip from both ends to handle quotes and trailing punctuation
31142
+ normalized_term = source_term.lower().rstrip(PUNCT_CHARS).lstrip(PUNCT_CHARS)
31143
+
31045
31144
  # Check if term has punctuation - use different pattern
31046
- if any(char in source_term for char in ['.', '%', ',', '-', '/']):
31047
- pattern = re.compile(r'(?<!\w)' + re.escape(source_term.lower()) + r'(?!\w)')
31145
+ if any(char in normalized_term for char in ['.', '%', ',', '-', '/']):
31146
+ pattern = re.compile(r'(?<!\w)' + re.escape(normalized_term) + r'(?!\w)')
31048
31147
  else:
31049
- pattern = re.compile(r"\b" + re.escape(source_term.lower()) + r"\b")
31148
+ pattern = re.compile(r"\b" + re.escape(normalized_term) + r"\b")
31050
31149
 
31051
31150
  # Try matching on normalized (tag-stripped, quote-stripped) text first,
31052
31151
  # then tag-stripped, then original with tags
@@ -31503,7 +31602,7 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
31503
31602
  self.case_sensitive_cb.setChecked(op.case_sensitive)
31504
31603
 
31505
31604
  def _fr_run_set_batch(self, fr_set: FindReplaceSet):
31506
- """Run all enabled operations in a F&R Set as a batch."""
31605
+ """Run all enabled operations in a F&R Set as a batch (optimized for speed)."""
31507
31606
  enabled_ops = [op for op in fr_set.operations if op.enabled and op.find_text]
31508
31607
 
31509
31608
  if not enabled_ops:
@@ -31522,17 +31621,32 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
31522
31621
  if reply != QMessageBox.StandardButton.Yes:
31523
31622
  return
31524
31623
 
31525
- # Run each operation
31526
- total_replaced = 0
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)")
31624
+ # OPTIMIZATION: Disable UI updates during batch processing
31625
+ self.table.setUpdatesEnabled(False)
31531
31626
 
31532
- # Refresh grid
31533
- self.load_segments_to_grid()
31534
- self.project_modified = True
31535
- self.update_window_title()
31627
+ try:
31628
+ # Run each operation
31629
+ total_replaced = 0
31630
+ for op in enabled_ops:
31631
+ count = self._execute_single_fr_operation(op)
31632
+ total_replaced += count
31633
+ self.log(f" '{op.find_text}' → '{op.replace_text}': {count} replacement(s)")
31634
+
31635
+ self.project_modified = True
31636
+ self.update_window_title()
31637
+
31638
+ finally:
31639
+ # OPTIMIZATION: Re-enable UI updates and refresh only target column cells
31640
+ self.table.setUpdatesEnabled(True)
31641
+ # Update all target cells in-place (batch operations can affect many segments)
31642
+ for row in range(len(self.current_project.segments)):
31643
+ segment = self.current_project.segments[row]
31644
+ target_widget = self.table.cellWidget(row, 3)
31645
+ if target_widget and hasattr(target_widget, 'setPlainText'):
31646
+ target_widget.blockSignals(True)
31647
+ target_widget.setPlainText(segment.target)
31648
+ target_widget.blockSignals(False)
31649
+ self.table.viewport().update()
31536
31650
 
31537
31651
  QMessageBox.information(
31538
31652
  self.find_replace_dialog,
@@ -31541,12 +31655,30 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
31541
31655
  )
31542
31656
 
31543
31657
  def _execute_single_fr_operation(self, op: FindReplaceOperation) -> int:
31544
- """Execute a single F&R operation on all segments. Returns replacement count."""
31658
+ """Execute a single F&R operation on all segments (optimized). Returns replacement count."""
31545
31659
  import re
31546
31660
  count = 0
31547
31661
 
31662
+ # OPTIMIZATION: Pre-filter segments - only check segments that might contain the text
31663
+ # Quick case-insensitive check to skip segments that definitely don't match
31664
+ search_text_lower = op.find_text.lower() if not op.case_sensitive else None
31665
+
31548
31666
  for segment in self.current_project.segments:
31549
31667
  texts_to_check = []
31668
+
31669
+ # Pre-filter: skip segments that can't possibly match
31670
+ if not op.case_sensitive:
31671
+ # Quick check: does the segment contain the search text at all?
31672
+ skip_segment = True
31673
+ if op.search_in in ("source", "both") and self.allow_replace_in_source:
31674
+ if search_text_lower in segment.source.lower():
31675
+ skip_segment = False
31676
+ if op.search_in in ("target", "both"):
31677
+ if search_text_lower in segment.target.lower():
31678
+ skip_segment = False
31679
+ if skip_segment:
31680
+ continue
31681
+
31550
31682
  if op.search_in in ("source", "both") and self.allow_replace_in_source:
31551
31683
  texts_to_check.append(("source", segment.source))
31552
31684
  if op.search_in in ("target", "both"):
@@ -31842,7 +31974,7 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
31842
31974
  self.find_next_match()
31843
31975
 
31844
31976
  def replace_all_matches(self):
31845
- """Replace all matches in target segments"""
31977
+ """Replace all matches in target segments (optimized for speed)"""
31846
31978
  find_text = self.find_input.text()
31847
31979
  replace_text = self.replace_input.text()
31848
31980
 
@@ -31915,52 +32047,67 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
31915
32047
  if reply != QMessageBox.StandardButton.Yes:
31916
32048
  return
31917
32049
 
31918
- # Perform replacements
31919
- import re
31920
- replaced_count = 0
32050
+ # OPTIMIZATION: Disable UI updates during batch replacement
32051
+ self.table.setUpdatesEnabled(False)
31921
32052
 
31922
- for row, col in self.find_matches:
31923
- segment = self.current_project.segments[row]
31924
-
31925
- # Get the appropriate field
31926
- if col == 2: # Source
31927
- old_text = segment.source
31928
- else: # col == 3, Target
31929
- old_text = segment.target
32053
+ try:
32054
+ # Perform replacements
32055
+ import re
32056
+ replaced_count = 0
32057
+ updated_rows = set() # Track which rows need UI updates
31930
32058
 
31931
- # Perform replacement
31932
- if match_mode == 2: # Entire segment
31933
- new_text = replace_text
31934
- else:
31935
- if case_sensitive:
31936
- new_text = old_text.replace(find_text, replace_text)
31937
- else:
31938
- pattern = re.escape(find_text)
31939
- new_text = re.sub(pattern, replace_text, old_text, flags=re.IGNORECASE)
31940
-
31941
- if new_text != old_text:
31942
- replaced_count += 1
31943
- # Update the appropriate field
31944
- if col == 2:
31945
- segment.source = new_text
32059
+ for row, col in self.find_matches:
32060
+ segment = self.current_project.segments[row]
32061
+
32062
+ # Get the appropriate field
32063
+ if col == 2: # Source
32064
+ old_text = segment.source
32065
+ else: # col == 3, Target
32066
+ old_text = segment.target
32067
+
32068
+ # Perform replacement
32069
+ if match_mode == 2: # Entire segment
32070
+ new_text = replace_text
31946
32071
  else:
31947
- old_target = segment.target
31948
- old_status = segment.status
31949
- segment.target = new_text
31950
- # Record undo state for find/replace operation
31951
- self.record_undo_state(segment.id, old_target, new_text, old_status, old_status)
32072
+ if case_sensitive:
32073
+ new_text = old_text.replace(find_text, replace_text)
32074
+ else:
32075
+ pattern = re.escape(find_text)
32076
+ new_text = re.sub(pattern, replace_text, old_text, flags=re.IGNORECASE)
31952
32077
 
31953
- # Update table
31954
- item = self.table.item(row, col)
31955
- if item:
31956
- item.setText(new_text)
31957
-
31958
- self.project_modified = True
31959
- self.update_window_title()
31960
-
31961
- # Clear matches and reload
31962
- self.find_matches = []
31963
- self.load_segments_to_grid()
32078
+ if new_text != old_text:
32079
+ replaced_count += 1
32080
+ updated_rows.add(row)
32081
+
32082
+ # Update the appropriate field
32083
+ if col == 2:
32084
+ segment.source = new_text
32085
+ else:
32086
+ old_target = segment.target
32087
+ old_status = segment.status
32088
+ segment.target = new_text
32089
+ # Record undo state for find/replace operation
32090
+ self.record_undo_state(segment.id, old_target, new_text, old_status, old_status)
32091
+
32092
+ # OPTIMIZATION: Update only the affected cell widget in-place
32093
+ cell_widget = self.table.cellWidget(row, col)
32094
+ if cell_widget and hasattr(cell_widget, 'setPlainText'):
32095
+ cell_widget.blockSignals(True)
32096
+ cell_widget.setPlainText(new_text)
32097
+ cell_widget.blockSignals(False)
32098
+
32099
+ self.project_modified = True
32100
+ self.update_window_title()
32101
+
32102
+ # Clear matches
32103
+ self.find_matches = []
32104
+
32105
+ finally:
32106
+ # OPTIMIZATION: Re-enable UI updates without full grid reload
32107
+ self.table.setUpdatesEnabled(True)
32108
+ # Trigger a repaint of updated rows only
32109
+ for row in updated_rows:
32110
+ self.table.viewport().update()
31964
32111
 
31965
32112
  QMessageBox.information(self.find_replace_dialog, "Replace All", f"Replaced {replaced_count} occurrence(s).")
31966
32113
  self.log(f"✓ Replaced {replaced_count} occurrence(s) of '{find_text}'")
@@ -37564,7 +37711,17 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
37564
37711
  return text.strip()
37565
37712
 
37566
37713
  def _quickmenu_build_custom_prompt(self, prompt_relative_path: str, source_text: str, source_lang: str, target_lang: str) -> str:
37567
- """Build a complete translation prompt using the chosen QuickMenu prompt as PRIMARY instructions."""
37714
+ """Build a prompt for QuickMenu using the selected prompt as instructions.
37715
+
37716
+ This is a GENERIC prompt builder (not translation-specific) that allows QuickMenu prompts
37717
+ to do anything: explain, define, search, translate, analyze, etc.
37718
+
37719
+ Supports placeholders:
37720
+ - {{SELECTION}} or {{SOURCE_TEXT}} - The selected text
37721
+ - {{SOURCE_LANGUAGE}} - Source language name
37722
+ - {{TARGET_LANGUAGE}} - Target language name
37723
+ - {{DOCUMENT_CONTEXT}} - Surrounding segments from the project for context
37724
+ """
37568
37725
  if not hasattr(self, 'prompt_manager_qt') or not self.prompt_manager_qt:
37569
37726
  raise RuntimeError("Prompt manager not available")
37570
37727
 
@@ -37581,25 +37738,70 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
37581
37738
  if not prompt_content:
37582
37739
  raise RuntimeError("Prompt content is empty")
37583
37740
 
37584
- mode = getattr(pm, 'current_mode', None) or "single"
37585
- system_template = pm.get_system_template(mode)
37586
-
37587
- system_template = system_template.replace("{{SOURCE_LANGUAGE}}", source_lang)
37588
- system_template = system_template.replace("{{TARGET_LANGUAGE}}", target_lang)
37589
- system_template = system_template.replace("{{SOURCE_TEXT}}", source_text)
37741
+ # Build document context if requested
37742
+ document_context = ""
37743
+ if "{{DOCUMENT_CONTEXT}}" in prompt_content and hasattr(self, 'current_project') and self.current_project:
37744
+ document_context = self._build_quickmenu_document_context()
37590
37745
 
37591
- library_prompts = "\n\n# PRIMARY INSTRUCTIONS\n\n" + prompt_content
37592
-
37593
- # Keep any globally attached prompts as additional instructions
37746
+ # Replace placeholders in the prompt content
37747
+ prompt_content = prompt_content.replace("{{SOURCE_LANGUAGE}}", source_lang)
37748
+ prompt_content = prompt_content.replace("{{TARGET_LANGUAGE}}", target_lang)
37749
+ prompt_content = prompt_content.replace("{{SOURCE_TEXT}}", source_text)
37750
+ prompt_content = prompt_content.replace("{{SELECTION}}", source_text) # Alternative placeholder
37751
+ prompt_content = prompt_content.replace("{{DOCUMENT_CONTEXT}}", document_context)
37752
+
37753
+ # If the prompt doesn't contain the selection/text, append it
37754
+ if "{{SOURCE_TEXT}}" not in prompt_data.get('content', '') and "{{SELECTION}}" not in prompt_data.get('content', ''):
37755
+ prompt_content += f"\n\nText:\n{source_text}"
37756
+
37757
+ return prompt_content
37758
+
37759
+ def _build_quickmenu_document_context(self) -> str:
37760
+ """Build document context for QuickMenu prompts.
37761
+
37762
+ Returns a formatted string with segments from the project for context.
37763
+ Uses the 'quickmenu_context_segments' setting (default: 50% of total segments).
37764
+ """
37765
+ if not hasattr(self, 'current_project') or not self.current_project or not self.current_project.segments:
37766
+ return "(No project context available)"
37767
+
37594
37768
  try:
37595
- for attached_content in lib.attached_prompts:
37596
- library_prompts += "\n\n# ADDITIONAL INSTRUCTIONS\n\n" + (attached_content or "")
37597
- except Exception:
37598
- pass
37599
-
37600
- final_prompt = system_template + library_prompts
37601
- final_prompt += "\n\n**YOUR TRANSLATION (provide ONLY the translated text, no numbering or labels):**\n"
37602
- return final_prompt
37769
+ # Get settings
37770
+ general_prefs = self.load_general_settings_from_file()
37771
+ context_percent = general_prefs.get('quickmenu_context_percent', 50) # Default: 50%
37772
+ max_context_segments = general_prefs.get('quickmenu_context_max', 100) # Safety limit
37773
+
37774
+ # Calculate how many segments to include
37775
+ total_segments = len(self.current_project.segments)
37776
+ num_segments = min(
37777
+ int(total_segments * context_percent / 100),
37778
+ max_context_segments
37779
+ )
37780
+
37781
+ if num_segments == 0:
37782
+ return "(Document context disabled)"
37783
+
37784
+ # Get segments (from start of document)
37785
+ context_segments = self.current_project.segments[:num_segments]
37786
+
37787
+ # Format segments
37788
+ context_parts = []
37789
+ context_parts.append(f"=== DOCUMENT CONTEXT ===")
37790
+ context_parts.append(f"(Showing {num_segments} of {total_segments} segments - {context_percent}%)")
37791
+ context_parts.append("")
37792
+
37793
+ for seg in context_segments:
37794
+ # Source text
37795
+ context_parts.append(f"[{seg.id}] {seg.source}")
37796
+ # Target text if available
37797
+ if seg.target and seg.target.strip():
37798
+ context_parts.append(f" → {seg.target}")
37799
+ context_parts.append("") # Blank line between segments
37800
+
37801
+ return "\n".join(context_parts)
37802
+
37803
+ except Exception as e:
37804
+ return f"(Error building document context: {e})"
37603
37805
 
37604
37806
  def _quickmenu_show_result_dialog(self, title: str, output_text: str, apply_callback=None):
37605
37807
  from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QDialogButtonBox, QPushButton, QApplication
@@ -37679,10 +37881,14 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
37679
37881
  )
37680
37882
 
37681
37883
  client = LLMClient(api_key=api_key, provider=provider, model=model)
37884
+
37885
+ # Use translate() with empty text and custom_prompt for generic AI completion
37886
+ # This allows QuickMenu prompts to do anything (explain, define, search, etc.)
37887
+ # not just translation. Same pattern as AI Assistant.
37682
37888
  output_text = client.translate(
37683
- text=input_text,
37684
- source_lang=source_lang,
37685
- target_lang=target_lang,
37889
+ text="", # Empty - we're using custom_prompt for everything
37890
+ source_lang="en", # Dummy values
37891
+ target_lang="en",
37686
37892
  custom_prompt=custom_prompt
37687
37893
  )
37688
37894
 
@@ -18,9 +18,15 @@ class ShortcutManager:
18
18
  "file_new": {
19
19
  "category": "File",
20
20
  "description": "New Project",
21
- "default": "Ctrl+N",
21
+ "default": "",
22
22
  "action": "new_project"
23
23
  },
24
+ "editor_focus_notes": {
25
+ "category": "Edit",
26
+ "description": "Focus Segment Note Tab (Ctrl+N)",
27
+ "default": "Ctrl+N",
28
+ "action": "focus_segment_notes"
29
+ },
24
30
  "file_open": {
25
31
  "category": "File",
26
32
  "description": "Open Project",
@@ -542,6 +548,13 @@ class ShortcutManager:
542
548
  "action": "copy_source_to_target",
543
549
  "context": "grid_editor"
544
550
  },
551
+ "editor_add_to_dictionary": {
552
+ "category": "Editor",
553
+ "description": "Add word at cursor to custom dictionary",
554
+ "default": "Alt+D",
555
+ "action": "add_word_to_dictionary",
556
+ "context": "grid_editor"
557
+ },
545
558
 
546
559
  # Filter Operations
547
560
  "filter_selected_text": {
@@ -941,13 +941,20 @@ class TermviewWidget(QWidget):
941
941
  for quote_char in '\"\'\u201C\u201D\u201E\u00AB\u00BB\u2018\u2019\u201A\u2039\u203A':
942
942
  normalized_text = normalized_text.replace(quote_char, ' ')
943
943
 
944
+ # CRITICAL FIX v1.9.118: Strip punctuation from glossary term before matching
945
+ # This allows entries like "...problemen." (with period) to match source text
946
+ # where tokenization strips the period during word splitting
947
+ # Comprehensive set of quote and punctuation characters to strip
948
+ PUNCT_CHARS = '.,;:!?\"\'\u201C\u201D\u201E\u00AB\u00BB\u2018\u2019\u201A\u2039\u203A'
949
+ normalized_term = source_lower.rstrip(PUNCT_CHARS).lstrip(PUNCT_CHARS)
950
+
944
951
  # Use word boundaries to match complete words/phrases only
945
952
  if ' ' in source_term:
946
953
  # Multi-word term - must exist as exact phrase
947
- pattern = r'\b' + re.escape(source_lower) + r'\b'
954
+ pattern = r'\b' + re.escape(normalized_term) + r'\b'
948
955
  else:
949
956
  # Single word
950
- pattern = r'\b' + re.escape(source_lower) + r'\b'
957
+ pattern = r'\b' + re.escape(normalized_term) + r'\b'
951
958
 
952
959
  # Try matching on normalized text first, then original
953
960
  if not re.search(pattern, normalized_text) and not re.search(pattern, text_lower):
@@ -171,11 +171,14 @@ class UnifiedPromptLibrary:
171
171
  # Backward compatibility: quick_run is the legacy field; internally we
172
172
  # treat it as the "QuickMenu (future app menu)" flag.
173
173
  prompt_data.setdefault('quick_run', False)
174
- prompt_data['quickmenu_quickmenu'] = bool(
175
- prompt_data.get('quickmenu_quickmenu', prompt_data.get('quick_run', False))
174
+ # Support legacy quickmenu_quickmenu field (rename to sv_quickmenu)
175
+ if 'quickmenu_quickmenu' in prompt_data:
176
+ prompt_data['sv_quickmenu'] = prompt_data['quickmenu_quickmenu']
177
+ prompt_data['sv_quickmenu'] = bool(
178
+ prompt_data.get('sv_quickmenu', prompt_data.get('quick_run', False))
176
179
  )
177
180
  # Keep legacy field in sync so older code/versions still behave.
178
- prompt_data['quick_run'] = bool(prompt_data['quickmenu_quickmenu'])
181
+ prompt_data['quick_run'] = bool(prompt_data['sv_quickmenu'])
179
182
 
180
183
  # New QuickMenu fields
181
184
  prompt_data.setdefault('quickmenu_grid', False)
@@ -270,7 +273,7 @@ class UnifiedPromptLibrary:
270
273
  'name', 'description', 'domain', 'version', 'task_type',
271
274
  'favorite',
272
275
  # QuickMenu
273
- 'quickmenu_label', 'quickmenu_grid', 'quickmenu_quickmenu',
276
+ 'quickmenu_label', 'quickmenu_grid', 'sv_quickmenu',
274
277
  # Legacy (kept for backward compatibility)
275
278
  'quick_run',
276
279
  'folder', 'tags',
@@ -309,8 +312,8 @@ class UnifiedPromptLibrary:
309
312
  prompt_data['_relative_path'] = relative_path
310
313
 
311
314
  # Keep legacy field in sync
312
- if 'quickmenu_quickmenu' in prompt_data:
313
- prompt_data['quick_run'] = bool(prompt_data.get('quickmenu_quickmenu', False))
315
+ if 'sv_quickmenu' in prompt_data:
316
+ prompt_data['quick_run'] = bool(prompt_data.get('sv_quickmenu', False))
314
317
  self.prompts[relative_path] = prompt_data
315
318
 
316
319
  self.log(f"✓ Saved prompt: {prompt_data.get('name', relative_path)}")
@@ -456,8 +459,8 @@ class UnifiedPromptLibrary:
456
459
  return False
457
460
 
458
461
  prompt_data = self.prompts[relative_path]
459
- new_value = not bool(prompt_data.get('quickmenu_quickmenu', prompt_data.get('quick_run', False)))
460
- prompt_data['quickmenu_quickmenu'] = new_value
462
+ new_value = not bool(prompt_data.get('sv_quickmenu', prompt_data.get('quick_run', False)))
463
+ prompt_data['sv_quickmenu'] = new_value
461
464
  prompt_data['quick_run'] = new_value # keep legacy in sync
462
465
  prompt_data['modified'] = datetime.now().strftime("%Y-%m-%d")
463
466
 
@@ -493,7 +496,7 @@ class UnifiedPromptLibrary:
493
496
  """Update cached QuickMenu (future app menu) list (legacy name: quick_run)."""
494
497
  self._quick_run = []
495
498
  for path, data in self.prompts.items():
496
- is_enabled = bool(data.get('quickmenu_quickmenu', data.get('quick_run', False)))
499
+ is_enabled = bool(data.get('sv_quickmenu', data.get('quick_run', False)))
497
500
  if not is_enabled:
498
501
  continue
499
502
  label = (data.get('quickmenu_label') or data.get('name') or Path(path).stem).strip()
@@ -18,7 +18,7 @@ from PyQt6.QtWidgets import (
18
18
  QTextEdit, QPlainTextEdit, QSplitter, QGroupBox, QMessageBox, QFileDialog,
19
19
  QInputDialog, QLineEdit, QFrame, QMenu, QCheckBox, QSizePolicy, QScrollArea, QTabWidget,
20
20
  QListWidget, QListWidgetItem, QStyledItemDelegate, QStyleOptionViewItem, QApplication, QDialog,
21
- QAbstractItemView
21
+ QAbstractItemView, QTableWidget, QTableWidgetItem, QHeaderView
22
22
  )
23
23
  from PyQt6.QtCore import Qt, QSettings, pyqtSignal, QThread, QSize, QRect, QRectF
24
24
  from PyQt6.QtGui import QFont, QColor, QAction, QIcon, QPainter, QPen, QBrush, QPainterPath, QLinearGradient
@@ -31,6 +31,89 @@ from modules.ai_file_viewer_dialog import FileViewerDialog, FileRemoveConfirmDia
31
31
  from modules.ai_actions import AIActionSystem
32
32
 
33
33
 
34
+ class CheckmarkCheckBox(QCheckBox):
35
+ """Custom checkbox with green background and white checkmark when checked"""
36
+
37
+ def __init__(self, text="", parent=None):
38
+ super().__init__(text, parent)
39
+ self.setCheckable(True)
40
+ self.setEnabled(True)
41
+ self.setStyleSheet("""
42
+ QCheckBox {
43
+ font-size: 9pt;
44
+ spacing: 6px;
45
+ }
46
+ QCheckBox::indicator {
47
+ width: 16px;
48
+ height: 16px;
49
+ border: 2px solid #999;
50
+ border-radius: 3px;
51
+ background-color: white;
52
+ }
53
+ QCheckBox::indicator:checked {
54
+ background-color: #4CAF50;
55
+ border-color: #4CAF50;
56
+ }
57
+ QCheckBox::indicator:hover {
58
+ border-color: #666;
59
+ }
60
+ QCheckBox::indicator:checked:hover {
61
+ background-color: #45a049;
62
+ border-color: #45a049;
63
+ }
64
+ """)
65
+
66
+ def paintEvent(self, event):
67
+ """Override paint event to draw white checkmark when checked"""
68
+ super().paintEvent(event)
69
+
70
+ if self.isChecked():
71
+ # Get the indicator rectangle using QStyle
72
+ from PyQt6.QtWidgets import QStyleOptionButton
73
+ from PyQt6.QtCore import QPointF
74
+
75
+ opt = QStyleOptionButton()
76
+ self.initStyleOption(opt)
77
+ indicator_rect = self.style().subElementRect(
78
+ self.style().SubElement.SE_CheckBoxIndicator,
79
+ opt,
80
+ self
81
+ )
82
+
83
+ if indicator_rect.isValid():
84
+ # Draw white checkmark
85
+ painter = QPainter(self)
86
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
87
+ pen_width = max(2.0, min(indicator_rect.width(), indicator_rect.height()) * 0.12)
88
+ painter.setPen(QPen(QColor(255, 255, 255), pen_width, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin))
89
+ painter.setBrush(QColor(255, 255, 255))
90
+
91
+ # Draw checkmark (✓ shape) - coordinates relative to indicator
92
+ x = indicator_rect.x()
93
+ y = indicator_rect.y()
94
+ w = indicator_rect.width()
95
+ h = indicator_rect.height()
96
+
97
+ # Add padding (15% on all sides)
98
+ padding = min(w, h) * 0.15
99
+ x += padding
100
+ y += padding
101
+ w -= padding * 2
102
+ h -= padding * 2
103
+
104
+ # Checkmark path
105
+ check_x1 = x + w * 0.10
106
+ check_y1 = y + h * 0.50
107
+ check_x2 = x + w * 0.35
108
+ check_y2 = y + h * 0.70
109
+ check_x3 = x + w * 0.90
110
+ check_y3 = y + h * 0.25
111
+
112
+ # Draw two lines forming the checkmark
113
+ painter.drawLine(QPointF(check_x2, check_y2), QPointF(check_x3, check_y3))
114
+ painter.drawLine(QPointF(check_x1, check_y1), QPointF(check_x2, check_y2))
115
+
116
+
34
117
  class PromptLibraryTreeWidget(QTreeWidget):
35
118
  """Tree widget that supports drag-and-drop moves for prompt files."""
36
119
 
@@ -563,6 +646,10 @@ class UnifiedPromptManagerQt:
563
646
  assistant_tab = self._create_ai_assistant_tab()
564
647
  self.sub_tabs.addTab(assistant_tab, "✨ AI Assistant")
565
648
 
649
+ # Tab 3: Placeholders Reference
650
+ placeholders_tab = self._create_placeholders_tab()
651
+ self.sub_tabs.addTab(placeholders_tab, "📝 Placeholders")
652
+
566
653
  # Connect tab change signal to update context
567
654
  self.sub_tabs.currentChanged.connect(self._on_tab_changed)
568
655
 
@@ -764,6 +851,143 @@ class UnifiedPromptManagerQt:
764
851
 
765
852
  return tab
766
853
 
854
+ def _create_placeholders_tab(self) -> QWidget:
855
+ """Create the Placeholders Reference sub-tab"""
856
+ tab = QWidget()
857
+ layout = QVBoxLayout(tab)
858
+ layout.setContentsMargins(10, 10, 10, 10)
859
+ layout.setSpacing(5)
860
+
861
+ # Header (matches standard tool style: Superbench, AutoFingers, TMX Editor)
862
+ header = QLabel("📝 Available Placeholders")
863
+ header.setStyleSheet("font-size: 16pt; font-weight: bold; color: #1976D2;")
864
+ layout.addWidget(header, 0)
865
+
866
+ # Description box (matches standard tool style)
867
+ description = QLabel(
868
+ "Use these placeholders in your prompts. They will be replaced with actual values when the prompt runs."
869
+ )
870
+ description.setWordWrap(True)
871
+ description.setStyleSheet("color: #666; padding: 5px; background-color: #E3F2FD; border-radius: 3px;")
872
+ layout.addWidget(description, 0)
873
+
874
+ # Horizontal splitter for table and tips
875
+ splitter = QSplitter(Qt.Orientation.Horizontal)
876
+ splitter.setHandleWidth(3)
877
+
878
+ # Left: Table with placeholders
879
+ table = QTableWidget()
880
+ table.setColumnCount(3)
881
+ table.setHorizontalHeaderLabels(["Placeholder", "Description", "Example"])
882
+ table.horizontalHeader().setStretchLastSection(True)
883
+ table.verticalHeader().setVisible(False)
884
+ table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
885
+ table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
886
+ table.setAlternatingRowColors(True)
887
+
888
+ # Placeholder data
889
+ placeholders = [
890
+ (
891
+ "{{SELECTION}}",
892
+ "Currently selected text in the grid (source or target cell)",
893
+ "If you select 'translation memory' in the grid, this will contain that text"
894
+ ),
895
+ (
896
+ "{{SOURCE_TEXT}}",
897
+ "Full text of the current source segment",
898
+ "The complete source sentence/paragraph from the active segment"
899
+ ),
900
+ (
901
+ "{{SOURCE_LANGUAGE}}",
902
+ "Project's source language",
903
+ "Dutch, English, German, French, etc."
904
+ ),
905
+ (
906
+ "{{TARGET_LANGUAGE}}",
907
+ "Project's target language",
908
+ "English, Spanish, Portuguese, etc."
909
+ ),
910
+ (
911
+ "{{DOCUMENT_CONTEXT}}",
912
+ "Formatted list of project segments (configurable % in Settings → AI Settings)",
913
+ "[1] Source text\\n → Target text\\n\\n[2] Source text\\n → Target text\\n\\n..."
914
+ )
915
+ ]
916
+
917
+ table.setRowCount(len(placeholders))
918
+ for row, (placeholder, description, example) in enumerate(placeholders):
919
+ # Placeholder column (monospace, bold)
920
+ item_placeholder = QTableWidgetItem(placeholder)
921
+ item_placeholder.setFont(QFont("Courier New", 10, QFont.Weight.Bold))
922
+ table.setItem(row, 0, item_placeholder)
923
+
924
+ # Description column
925
+ item_desc = QTableWidgetItem(description)
926
+ item_desc.setToolTip(description)
927
+ table.setItem(row, 1, item_desc)
928
+
929
+ # Example column (monospace, italic)
930
+ item_example = QTableWidgetItem(example)
931
+ item_example.setFont(QFont("Courier New", 9))
932
+ item_example.setToolTip(example)
933
+ table.setItem(row, 2, item_example)
934
+
935
+ # Set column widths
936
+ table.setColumnWidth(0, 200)
937
+ table.setColumnWidth(1, 300)
938
+ header = table.horizontalHeader()
939
+ header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
940
+ header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive)
941
+ header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
942
+
943
+ # Adjust row heights for readability
944
+ for row in range(table.rowCount()):
945
+ table.setRowHeight(row, 60)
946
+
947
+ splitter.addWidget(table)
948
+
949
+ # Right: Usage tips panel
950
+ tips_panel = QWidget()
951
+ tips_layout = QVBoxLayout(tips_panel)
952
+ tips_layout.setContentsMargins(10, 0, 0, 0)
953
+
954
+ tips_header = QLabel("💡 Usage Tips")
955
+ tips_header.setStyleSheet("font-weight: bold; font-size: 11pt; color: #2196F3; margin-bottom: 8px;")
956
+ tips_layout.addWidget(tips_header)
957
+
958
+ tips_intro = QLabel(
959
+ "Use these placeholders in your prompts. They will be replaced with actual values when the prompt runs."
960
+ )
961
+ tips_intro.setWordWrap(True)
962
+ tips_intro.setStyleSheet("color: #666; margin-bottom: 15px; font-style: italic;")
963
+ tips_layout.addWidget(tips_intro)
964
+
965
+ tips_text = QLabel(
966
+ "• Placeholders are case-sensitive (use UPPERCASE)\n\n"
967
+ "• Surround placeholders with double curly braces: {{ }}\n\n"
968
+ "• You can combine multiple placeholders in one prompt\n\n"
969
+ "• Use {{DOCUMENT_CONTEXT}} for context-aware translations\n\n"
970
+ "• Configure {{DOCUMENT_CONTEXT}} percentage in Settings → AI Settings"
971
+ )
972
+ tips_text.setWordWrap(True)
973
+ tips_text.setStyleSheet("color: #666; line-height: 1.6;")
974
+ tips_layout.addWidget(tips_text)
975
+
976
+ tips_layout.addStretch()
977
+
978
+ tips_panel.setMinimumWidth(280)
979
+ tips_panel.setMaximumWidth(400)
980
+ splitter.addWidget(tips_panel)
981
+
982
+ # Set splitter proportions (75% table, 25% tips)
983
+ splitter.setSizes([750, 250])
984
+ splitter.setStretchFactor(0, 1) # Table expands
985
+ splitter.setStretchFactor(1, 0) # Tips panel fixed-ish
986
+
987
+ layout.addWidget(splitter, 1) # 1 = stretch to fill all available space
988
+
989
+ return tab
990
+
767
991
  def _create_context_sidebar(self) -> QWidget:
768
992
  """Create context sidebar showing available resources"""
769
993
  panel = QWidget()
@@ -1470,10 +1694,10 @@ class UnifiedPromptManagerQt:
1470
1694
  self.editor_quickmenu_label_input.setPlaceholderText("Label shown in QuickMenu")
1471
1695
  quickmenu_layout.addWidget(self.editor_quickmenu_label_input, 2)
1472
1696
 
1473
- self.editor_quickmenu_in_grid_cb = QCheckBox("Show in Grid right-click QuickMenu")
1697
+ self.editor_quickmenu_in_grid_cb = CheckmarkCheckBox("Show in Grid right-click QuickMenu")
1474
1698
  quickmenu_layout.addWidget(self.editor_quickmenu_in_grid_cb, 2)
1475
1699
 
1476
- self.editor_quickmenu_in_quickmenu_cb = QCheckBox("Show in QuickMenu")
1700
+ self.editor_quickmenu_in_quickmenu_cb = CheckmarkCheckBox("Show in Supervertaler QuickMenu")
1477
1701
  quickmenu_layout.addWidget(self.editor_quickmenu_in_quickmenu_cb, 1)
1478
1702
 
1479
1703
  layout.addLayout(quickmenu_layout)
@@ -1950,7 +2174,7 @@ class UnifiedPromptManagerQt:
1950
2174
  indicators = []
1951
2175
  if prompt_data.get('favorite'):
1952
2176
  indicators.append("⭐")
1953
- if prompt_data.get('quickmenu_quickmenu', prompt_data.get('quick_run', False)):
2177
+ if prompt_data.get('sv_quickmenu', prompt_data.get('quick_run', False)):
1954
2178
  indicators.append("⚡")
1955
2179
  if prompt_data.get('quickmenu_grid', False):
1956
2180
  indicators.append("🖱️")
@@ -2018,7 +2242,7 @@ class UnifiedPromptManagerQt:
2018
2242
  action_fav.triggered.connect(lambda: self._toggle_favorite(path))
2019
2243
 
2020
2244
  # Toggle QuickMenu (legacy: quick_run)
2021
- if prompt_data.get('quickmenu_quickmenu', prompt_data.get('quick_run', False)):
2245
+ if prompt_data.get('sv_quickmenu', prompt_data.get('quick_run', False)):
2022
2246
  action_qr = menu.addAction("⚡ Remove from QuickMenu")
2023
2247
  else:
2024
2248
  action_qr = menu.addAction("⚡ Add to QuickMenu")
@@ -2070,7 +2294,7 @@ class UnifiedPromptManagerQt:
2070
2294
  if hasattr(self, 'editor_quickmenu_in_grid_cb'):
2071
2295
  self.editor_quickmenu_in_grid_cb.setChecked(bool(prompt_data.get('quickmenu_grid', False)))
2072
2296
  if hasattr(self, 'editor_quickmenu_in_quickmenu_cb'):
2073
- self.editor_quickmenu_in_quickmenu_cb.setChecked(bool(prompt_data.get('quickmenu_quickmenu', prompt_data.get('quick_run', False))))
2297
+ self.editor_quickmenu_in_quickmenu_cb.setChecked(bool(prompt_data.get('sv_quickmenu', prompt_data.get('quick_run', False))))
2074
2298
  self.editor_content.setPlainText(prompt_data.get('content', ''))
2075
2299
 
2076
2300
  # Store current path for saving
@@ -2083,119 +2307,127 @@ class UnifiedPromptManagerQt:
2083
2307
 
2084
2308
  def _save_current_prompt(self):
2085
2309
  """Save currently edited prompt"""
2086
- name = self.editor_name_input.text().strip()
2087
- description = self.editor_desc_input.text().strip()
2088
- content = self.editor_content.toPlainText().strip()
2089
-
2090
- # Name field now represents the complete filename with extension
2091
- # No stripping needed - user sees and edits the full filename
2092
-
2093
- quickmenu_label = ''
2094
- quickmenu_grid = False
2095
- quickmenu_quickmenu = False
2096
- if hasattr(self, 'editor_quickmenu_label_input'):
2097
- quickmenu_label = self.editor_quickmenu_label_input.text().strip()
2098
- if hasattr(self, 'editor_quickmenu_in_grid_cb'):
2099
- quickmenu_grid = bool(self.editor_quickmenu_in_grid_cb.isChecked())
2100
- if hasattr(self, 'editor_quickmenu_in_quickmenu_cb'):
2101
- quickmenu_quickmenu = bool(self.editor_quickmenu_in_quickmenu_cb.isChecked())
2102
-
2103
- if not name or not content:
2104
- QMessageBox.warning(self.main_widget, "Error", "Name and content are required")
2105
- return
2106
-
2107
- # Check if this is a new prompt or editing existing
2108
- if hasattr(self, 'editor_current_path') and self.editor_current_path:
2109
- path = self.editor_current_path
2110
-
2111
- # Handle external prompts (save back to external file)
2112
- if path.startswith("[EXTERNAL] "):
2113
- external_file_path = path[11:] # Remove "[EXTERNAL] " prefix
2114
- self._save_external_prompt(external_file_path, name, description, content)
2115
- return
2310
+ try:
2311
+ name = self.editor_name_input.text().strip()
2312
+ description = self.editor_desc_input.text().strip()
2313
+ content = self.editor_content.toPlainText().strip()
2116
2314
 
2117
- # Editing existing library prompt
2118
- if path not in self.library.prompts:
2119
- QMessageBox.warning(self.main_widget, "Error", "Prompt no longer exists")
2315
+ # Name field now represents the complete filename with extension
2316
+ # No stripping needed - user sees and edits the full filename
2317
+
2318
+ quickmenu_label = ''
2319
+ quickmenu_grid = False
2320
+ sv_quickmenu = False
2321
+ if hasattr(self, 'editor_quickmenu_label_input'):
2322
+ quickmenu_label = self.editor_quickmenu_label_input.text().strip()
2323
+ if hasattr(self, 'editor_quickmenu_in_grid_cb'):
2324
+ quickmenu_grid = bool(self.editor_quickmenu_in_grid_cb.isChecked())
2325
+ if hasattr(self, 'editor_quickmenu_in_quickmenu_cb'):
2326
+ sv_quickmenu = bool(self.editor_quickmenu_in_quickmenu_cb.isChecked())
2327
+
2328
+ if not name or not content:
2329
+ QMessageBox.warning(self.main_widget, "Error", "Name and content are required")
2120
2330
  return
2121
2331
 
2122
- prompt_data = self.library.prompts[path].copy()
2123
- old_filename = Path(path).name
2124
-
2125
- # Extract name without extension for metadata
2126
- name_without_ext = Path(name).stem
2127
-
2128
- prompt_data['name'] = name_without_ext
2129
- prompt_data['description'] = description
2130
- prompt_data['content'] = content
2131
- prompt_data['quickmenu_label'] = quickmenu_label or name_without_ext
2132
- prompt_data['quickmenu_grid'] = quickmenu_grid
2133
- prompt_data['quickmenu_quickmenu'] = quickmenu_quickmenu
2134
- # Keep legacy field in sync
2135
- prompt_data['quick_run'] = quickmenu_quickmenu
2136
-
2137
- # Check if filename changed - need to rename file
2138
- if old_filename != name:
2139
- from pathlib import Path
2140
- old_path = Path(path)
2141
- folder = str(old_path.parent) if old_path.parent != Path('.') else ''
2142
- new_path = f"{folder}/{name}" if folder else name
2332
+ # Check if this is a new prompt or editing existing
2333
+ if hasattr(self, 'editor_current_path') and self.editor_current_path:
2334
+ path = self.editor_current_path
2143
2335
 
2144
- # Delete old file and save to new location
2145
- if self.library.delete_prompt(path):
2146
- if self.library.save_prompt(new_path, prompt_data):
2147
- self.library.load_all_prompts()
2148
- self._refresh_tree()
2149
- self._select_and_reveal_prompt(new_path)
2150
- self.editor_current_path = new_path # Update to new path
2151
- QMessageBox.information(self.main_widget, "Saved", f"Prompt renamed to '{name}' successfully!")
2152
- self.log_message(f" Renamed prompt: {old_filename} → {name}")
2336
+ # Handle external prompts (save back to external file)
2337
+ if path.startswith("[EXTERNAL] "):
2338
+ external_file_path = path[11:] # Remove "[EXTERNAL] " prefix
2339
+ self._save_external_prompt(external_file_path, name, description, content)
2340
+ return
2341
+
2342
+ # Editing existing library prompt
2343
+ if path not in self.library.prompts:
2344
+ QMessageBox.warning(self.main_widget, "Error", "Prompt no longer exists")
2345
+ return
2346
+
2347
+ prompt_data = self.library.prompts[path].copy()
2348
+ old_filename = Path(path).name
2349
+
2350
+ # Extract name without extension for metadata
2351
+ name_without_ext = Path(name).stem
2352
+
2353
+ prompt_data['name'] = name_without_ext
2354
+ prompt_data['description'] = description
2355
+ prompt_data['content'] = content
2356
+ prompt_data['quickmenu_label'] = quickmenu_label or name_without_ext
2357
+ prompt_data['quickmenu_grid'] = quickmenu_grid
2358
+ prompt_data['sv_quickmenu'] = sv_quickmenu
2359
+ # Keep legacy field in sync
2360
+ prompt_data['quick_run'] = sv_quickmenu
2361
+
2362
+ # Check if filename changed - need to rename file
2363
+ if old_filename != name:
2364
+ old_path = Path(path)
2365
+ folder = str(old_path.parent) if old_path.parent != Path('.') else ''
2366
+ new_path = f"{folder}/{name}" if folder else name
2367
+
2368
+ # Delete old file and save to new location
2369
+ if self.library.delete_prompt(path):
2370
+ if self.library.save_prompt(new_path, prompt_data):
2371
+ self.library.load_all_prompts()
2372
+ self._refresh_tree()
2373
+ self._select_and_reveal_prompt(new_path)
2374
+ self.editor_current_path = new_path # Update to new path
2375
+ QMessageBox.information(self.main_widget, "Saved", f"Prompt renamed to '{name}' successfully!")
2376
+ self.log_message(f"✓ Renamed prompt: {old_filename} → {name}")
2377
+ else:
2378
+ QMessageBox.warning(self.main_widget, "Error", "Failed to rename prompt")
2153
2379
  else:
2154
- QMessageBox.warning(self.main_widget, "Error", "Failed to rename prompt")
2380
+ QMessageBox.warning(self.main_widget, "Error", "Failed to delete old prompt file")
2155
2381
  else:
2156
- QMessageBox.warning(self.main_widget, "Error", "Failed to delete old prompt file")
2382
+ # Name unchanged, just update in place
2383
+ if self.library.save_prompt(path, prompt_data):
2384
+ QMessageBox.information(self.main_widget, "Saved", "Prompt updated successfully!")
2385
+ self._refresh_tree()
2386
+ else:
2387
+ QMessageBox.warning(self.main_widget, "Error", "Failed to save prompt")
2157
2388
  else:
2158
- # Name unchanged, just update in place
2159
- if self.library.save_prompt(path, prompt_data):
2160
- QMessageBox.information(self.main_widget, "Saved", "Prompt updated successfully!")
2389
+ # Creating new prompt
2390
+ folder = getattr(self, 'editor_target_folder', 'Project Prompts')
2391
+
2392
+ # Create new prompt data
2393
+ from datetime import datetime
2394
+ prompt_data = {
2395
+ 'name': name,
2396
+ 'description': description,
2397
+ 'content': content,
2398
+ 'domain': '',
2399
+ 'version': '1.0',
2400
+ 'task_type': 'Translation',
2401
+ 'favorite': False,
2402
+ # QuickMenu
2403
+ 'quickmenu_label': quickmenu_label or name,
2404
+ 'quickmenu_grid': quickmenu_grid,
2405
+ 'sv_quickmenu': sv_quickmenu,
2406
+ # Legacy
2407
+ 'quick_run': sv_quickmenu,
2408
+ 'folder': folder,
2409
+ 'tags': [],
2410
+ 'created': datetime.now().strftime('%Y-%m-%d'),
2411
+ 'modified': datetime.now().strftime('%Y-%m-%d')
2412
+ }
2413
+
2414
+ # Create the prompt file (save_prompt creates new file if it doesn't exist)
2415
+ relative_path = f"{folder}/{name}.svprompt"
2416
+ if self.library.save_prompt(relative_path, prompt_data):
2417
+ QMessageBox.information(self.main_widget, "Created", f"Prompt '{name}' created successfully!")
2418
+ self.library.load_all_prompts() # Reload to get new prompt in memory
2161
2419
  self._refresh_tree()
2420
+ self.editor_current_path = relative_path # Now editing this prompt
2162
2421
  else:
2163
- QMessageBox.warning(self.main_widget, "Error", "Failed to save prompt")
2164
- else:
2165
- # Creating new prompt
2166
- folder = getattr(self, 'editor_target_folder', 'Project Prompts')
2167
-
2168
- # Create new prompt data
2169
- from datetime import datetime
2170
- prompt_data = {
2171
- 'name': name,
2172
- 'description': description,
2173
- 'content': content,
2174
- 'domain': '',
2175
- 'version': '1.0',
2176
- 'task_type': 'Translation',
2177
- 'favorite': False,
2178
- # QuickMenu
2179
- 'quickmenu_label': quickmenu_label or name,
2180
- 'quickmenu_grid': quickmenu_grid,
2181
- 'quickmenu_quickmenu': quickmenu_quickmenu,
2182
- # Legacy
2183
- 'quick_run': quickmenu_quickmenu,
2184
- 'folder': folder,
2185
- 'tags': [],
2186
- 'created': datetime.now().strftime('%Y-%m-%d'),
2187
- 'modified': datetime.now().strftime('%Y-%m-%d')
2188
- }
2189
-
2190
- # Create the prompt file (save_prompt creates new file if it doesn't exist)
2191
- relative_path = f"{folder}/{name}.svprompt"
2192
- if self.library.save_prompt(relative_path, prompt_data):
2193
- QMessageBox.information(self.main_widget, "Created", f"Prompt '{name}' created successfully!")
2194
- self.library.load_all_prompts() # Reload to get new prompt in memory
2195
- self._refresh_tree()
2196
- self.editor_current_path = relative_path # Now editing this prompt
2197
- else:
2198
- QMessageBox.warning(self.main_widget, "Error", "Failed to create prompt")
2422
+ QMessageBox.warning(self.main_widget, "Error", "Failed to create prompt")
2423
+
2424
+ except Exception as e:
2425
+ import traceback
2426
+ error_msg = f"Prompt save error: {str(e)}\n{traceback.format_exc()}"
2427
+ print(f"[ERROR] {error_msg}")
2428
+ self.log_message(f"❌ Prompt save error: {str(e)}")
2429
+ QMessageBox.critical(self.main_widget, "Save Error", f"Failed to save prompt:\n\n{str(e)}")
2430
+ return
2199
2431
 
2200
2432
  def _save_external_prompt(self, file_path: str, name: str, description: str, content: str):
2201
2433
  """Save changes to an external prompt file"""
@@ -2421,7 +2653,7 @@ class UnifiedPromptManagerQt:
2421
2653
  'favorite': False,
2422
2654
  'quickmenu_label': name_without_ext,
2423
2655
  'quickmenu_grid': False,
2424
- 'quickmenu_quickmenu': False,
2656
+ 'sv_quickmenu': False,
2425
2657
  'quick_run': False,
2426
2658
  'folder': folder_path,
2427
2659
  'tags': [],
@@ -2437,6 +2669,7 @@ class UnifiedPromptManagerQt:
2437
2669
  self._refresh_tree()
2438
2670
  self._select_and_reveal_prompt(relative_path)
2439
2671
  self._load_prompt_in_editor(relative_path)
2672
+ self.btn_save_prompt.setEnabled(True) # Ensure Save button is enabled for new prompt
2440
2673
  self.log_message(f"✓ Created new prompt '{name}' in folder: {folder_path}")
2441
2674
  else:
2442
2675
  QMessageBox.warning(self.main_widget, "Error", "Failed to create prompt")
@@ -2469,7 +2702,7 @@ class UnifiedPromptManagerQt:
2469
2702
  src_data['favorite'] = False
2470
2703
  src_data['quick_run'] = False
2471
2704
  src_data['quickmenu_grid'] = False
2472
- src_data['quickmenu_quickmenu'] = False
2705
+ src_data['sv_quickmenu'] = False
2473
2706
  src_data['folder'] = folder
2474
2707
  src_data['created'] = datetime.now().strftime('%Y-%m-%d')
2475
2708
  src_data['modified'] = datetime.now().strftime('%Y-%m-%d')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: supervertaler
3
- Version: 1.9.116
3
+ Version: 1.9.128
4
4
  Summary: Professional AI-powered translation workbench with multi-LLM support, glossary system, TM, spellcheck, voice commands, and PyQt6 interface. Batteries included (core).
5
5
  Home-page: https://supervertaler.com
6
6
  Author: Michael Beijer
@@ -71,7 +71,7 @@ Dynamic: home-page
71
71
  Dynamic: license-file
72
72
  Dynamic: requires-python
73
73
 
74
- # 🚀 Supervertaler v1.9.116
74
+ # 🚀 Supervertaler v1.9.125
75
75
 
76
76
  [![PyPI version](https://badge.fury.io/py/supervertaler.svg)](https://pypi.org/project/Supervertaler/)
77
77
  [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
@@ -79,7 +79,37 @@ Dynamic: requires-python
79
79
 
80
80
  AI-enhanced CAT tool with multi-LLM support (GPT-4, Claude, Gemini, Ollama), innovative Superlookup concordance system offering access to multiple terminology sources (TMs, glossaries, web resources, etc.), and seamless CAT tool integration (memoQ, Trados, CafeTran, Phrase).
81
81
 
82
- **Current Version:** v1.9.116 (January 19, 2026)
82
+ **Current Version:** v1.9.125 (January 19, 2026)
83
+
84
+ ### FIXED in v1.9.125 - 🐛 Prompt Save Crash
85
+
86
+ **Critical Fix:** Wrapped prompt save logic in comprehensive error handling to prevent silent crashes. Now shows detailed error messages if save fails instead of crashing the app.
87
+
88
+ ### ADDED in v1.9.124 - 📄 QuickMenu Document Context
89
+
90
+ **Context-Aware AI Suggestions:** QuickMenu prompts can now access the full document context! Use the new `{{DOCUMENT_CONTEXT}}` placeholder to give the AI access to your project's source segments. Configure what percentage of segments to include (0-100%, default 50%) in Settings → AI Settings. Perfect for: "Suggest the best translation of '{{SELECTION}}' within the context of this project."
91
+
92
+ ### FIXED in v1.9.123 - 🤖 QuickMenu Generic AI Support
93
+
94
+ **QuickMenu Now Works for Any Task:** Fixed bug where QuickMenu prompts were forced into translation mode. Now supports any AI task: explain terms, define concepts, suggest multiple options, analyze tone, etc.
95
+
96
+ ### ENHANCED in v1.9.122 - ⌨️ Ctrl+N for Quick Notes
97
+
98
+ **Faster Note-Taking:** Ctrl+N now focuses the Segment Note tab and places your cursor in the notes field, ready to type. Perfect for quick proofreading notes, context reminders, or translation decisions.
99
+
100
+ ### FIXED in v1.9.121 - 🐛 Find & Replace Performance
101
+
102
+ **Critical Fix:** v1.9.120 accidentally made Find & Replace slower (37+ seconds). v1.9.121 actually fixes it by updating cells in-place instead of recreating all widgets. Now near-instant!
103
+
104
+ ### OPTIMIZED in v1.9.120 - ⚡ Find & Replace Speed (BUGGY - USE v1.9.121)
105
+
106
+ ### ADDED in v1.9.119 - ⌨️ Alt+D Dictionary Shortcut
107
+
108
+ **Quick Dictionary Addition:** Press Alt+D to instantly add misspelled words to your custom dictionary without using the right-click menu.
109
+
110
+ ### FIXED in v1.9.117 - 🐛 Glossary Punctuation Matching
111
+
112
+ **Critical Fix:** Glossary entries with trailing punctuation (periods, quotes, etc.) now match correctly! Previously, "sentence." in glossary wouldn't match "sentence." in source text due to tokenization stripping punctuation from source but not from glossary entries.
83
113
 
84
114
  ### FIXED in v1.9.116 - 🐛 Tab Navigation & Startup
85
115
 
@@ -1,4 +1,4 @@
1
- Supervertaler.py,sha256=5fxmIoIe8FTV9XFVmQ9l-BsZ9lkBwis0WZNjX0VZINw,2126515
1
+ Supervertaler.py,sha256=SZo1QhKtxynjmTAgmUXA5js44gwWsIEZidWgrccrXp0,2137119
2
2
  modules/__init__.py,sha256=G58XleS-EJ2sX4Kehm-3N2m618_W2Es0Kg8CW_eBG7g,327
3
3
  modules/ai_actions.py,sha256=i5MJcM-7Y6CAvKUwxmxrVHeoZAVtAP7aRDdWM5KLkO0,33877
4
4
  modules/ai_attachment_manager.py,sha256=mA5ISI22qN9mH3DQFF4gOTciDyBt5xVR7sHTkgkTIlw,11361
@@ -41,7 +41,7 @@ modules/quick_access_sidebar.py,sha256=RPn5ssvYXlitNMWFZN9Yxv7So8u_z5RGNpHN6N-SF
41
41
  modules/ribbon_widget.py,sha256=QNGKxmit_oM5C5nJViadYYEzeRlIdIsla8Bzu_RNGO0,21990
42
42
  modules/sdlppx_handler.py,sha256=o6Rj_T0B94toiYlvDDwMYLSz4q6kANgegFaDK5i3yhs,33538
43
43
  modules/setup_wizard.py,sha256=1prK5GPrUU4U4CqdT3G1RA3iy8FG1Z4jgPmPPZXOOEA,13115
44
- modules/shortcut_manager.py,sha256=-QyDA3GjmEaytXSPEZVRaD1i0iglihRP-0su-Rrfjjw,30827
44
+ modules/shortcut_manager.py,sha256=h8a9-ENKpXGvdTHFcemOH3GRYQVqGRQnHnQAP7xgqnY,31305
45
45
  modules/simple_segmenter.py,sha256=-V7L-tjajW1M3DADxvcYEgBu0VLJvmRQl6VB9OshiuM,4480
46
46
  modules/spellcheck_manager.py,sha256=jwduHJ66pOKv1MtzSAltxpP8LPgz11FvF6j8h7BiRZY,27592
47
47
  modules/statuses.py,sha256=t6TCA9pNZHDw3SbKTxT73uKezJhwWk9gFLr0NOgEufs,6911
@@ -59,7 +59,7 @@ modules/term_extractor.py,sha256=qPvKNCVXFTGEGwXNvvC0cfCmdb5c3WhzE38EOgKdKUI,112
59
59
  modules/termbase_entry_editor.py,sha256=iWO9CgLjMomGAqBXDsGAX7TFJvDOp2s_taS4gBL1rZY,35818
60
60
  modules/termbase_import_export.py,sha256=16IAY04IS_rgt0GH5UOUzUI5NoqAli4JMfMquxmFBm0,23552
61
61
  modules/termbase_manager.py,sha256=-PlGF6fIA7KYCteoQ8FZ_0SQZNRRBFAtLimHPbmhQ6w,44544
62
- modules/termview_widget.py,sha256=qaVeXPZinCzfq9QeLWWoTvu2gmsM0lwu_AQTUco-j9s,52158
62
+ modules/termview_widget.py,sha256=KNzgQ7dEFW5ANcbCsUPHJ268MYwBmV5MQG-9GQJoobY,52745
63
63
  modules/theme_manager.py,sha256=EOI_5pM2bXAadw08bbl92TLN-w28lbw4Zi1E8vQ-kM0,16694
64
64
  modules/tm_editor_dialog.py,sha256=AzGwq4QW641uFJdF8DljLTRRp4FLoYX3Pe4rlTjQWNg,3517
65
65
  modules/tm_manager_qt.py,sha256=h2bvXkRuboHf_RRz9-5FX35GVRlpXgRDWeXyj1QWtPs,54406
@@ -72,14 +72,14 @@ modules/trados_docx_handler.py,sha256=VPRAQ73cUHs_SEj6x81z1PmSxfjnwPBp9P4fXeK3Kp
72
72
  modules/translation_memory.py,sha256=U-deNPybG2PKeEt2LSTZv0Ziu5VwxkH2BsuCCXbpalc,26453
73
73
  modules/translation_results_panel.py,sha256=DmEe0pZRSfcZFg2cWeEREK7H9vrTcPkgeuMW54Pgrys,92505
74
74
  modules/translation_services.py,sha256=lyVpWuZK1wtVtYZMDMdLoq1DHBoSaeAnp-Yejb0TlVQ,10530
75
- modules/unified_prompt_library.py,sha256=f9C0oTmOCCL5yzDrTpPPPWk_67uwFnH1-wOGyqaYIBA,25862
76
- modules/unified_prompt_manager_qt.py,sha256=x6MwpLb2hZcjhKKqkVvDNsJb5I8o2vML443hHADmQ08,160675
75
+ modules/unified_prompt_library.py,sha256=L3do_T-7NHnC5CxJ1TDq63iVM60XLkvovDJ9g4cJmZQ,26016
76
+ modules/unified_prompt_manager_qt.py,sha256=2EvOEryM699xJHIYJrqRSPUqpypnVJOErc-W0PfjaaQ,170988
77
77
  modules/voice_commands.py,sha256=iBb-gjWxRMLhFH7-InSRjYJz1EIDBNA2Pog8V7TtJaY,38516
78
78
  modules/voice_dictation.py,sha256=QmitXfkG-vRt5hIQATjphHdhXfqmwhzcQcbXB6aRzIg,16386
79
79
  modules/voice_dictation_lite.py,sha256=jorY0BmWE-8VczbtGrWwt1zbnOctMoSlWOsQrcufBcc,9423
80
- supervertaler-1.9.116.dist-info/licenses/LICENSE,sha256=m28u-4qL5nXIWnJ6xlQVw__H30rWFtRK3pCOais2OuY,1092
81
- supervertaler-1.9.116.dist-info/METADATA,sha256=ct3FGXY8i9w3atJ-rlfeo4-WiO1lgBbTie9H7UQSt6c,40009
82
- supervertaler-1.9.116.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
83
- supervertaler-1.9.116.dist-info/entry_points.txt,sha256=NP4hiCvx-_30YYKqgr-jfJYQvHr1qTYBMfoVmKIXSM8,53
84
- supervertaler-1.9.116.dist-info/top_level.txt,sha256=9tUHBYUSfaE4S2E4W3eavJsDyYymkwLfeWAHHAPT6Dk,22
85
- supervertaler-1.9.116.dist-info/RECORD,,
80
+ supervertaler-1.9.128.dist-info/licenses/LICENSE,sha256=m28u-4qL5nXIWnJ6xlQVw__H30rWFtRK3pCOais2OuY,1092
81
+ supervertaler-1.9.128.dist-info/METADATA,sha256=TeUD7EbvGmLtukvAIep4czAC7P8YNm4wLoGH244ug8U,42109
82
+ supervertaler-1.9.128.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
83
+ supervertaler-1.9.128.dist-info/entry_points.txt,sha256=NP4hiCvx-_30YYKqgr-jfJYQvHr1qTYBMfoVmKIXSM8,53
84
+ supervertaler-1.9.128.dist-info/top_level.txt,sha256=9tUHBYUSfaE4S2E4W3eavJsDyYymkwLfeWAHHAPT6Dk,22
85
+ supervertaler-1.9.128.dist-info/RECORD,,