supervertaler 1.9.172__py3-none-any.whl → 1.9.174__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.

Potentially problematic release.


This version of supervertaler might be problematic. Click here for more details.

Supervertaler.py CHANGED
@@ -753,7 +753,83 @@ def get_wrapping_tag_pair(source_text: str, target_text: str) -> tuple:
753
753
  opening, closing = get_tag_pair(num)
754
754
  if opening not in target_tags or closing not in target_tags:
755
755
  return (opening, closing)
756
-
756
+
757
+ return (None, None)
758
+
759
+
760
+ def get_html_wrapping_tag_pair(source_text: str, target_text: str) -> tuple:
761
+ """
762
+ Get the next available HTML tag pair for wrapping selected text.
763
+
764
+ Finds paired HTML tags (opening + closing) from source that are not yet
765
+ complete in target. Supports common formatting tags: b, i, u, em, strong,
766
+ span, li, p, a, sub, sup, etc.
767
+
768
+ Args:
769
+ source_text: Source segment text with HTML tags
770
+ target_text: Current target text
771
+
772
+ Returns:
773
+ Tuple of (opening_tag, closing_tag) or (None, None) if no pairs available
774
+ """
775
+ import re
776
+
777
+ # Find all HTML tags in source
778
+ # Match: <tag>, <tag attr="...">, </tag>
779
+ tag_pattern = r'<(/?)([a-zA-Z][a-zA-Z0-9-]*)(?:\s+[^>]*)?>'
780
+ source_matches = re.findall(tag_pattern, source_text)
781
+ target_matches = re.findall(tag_pattern, target_text)
782
+
783
+ if not source_matches:
784
+ return (None, None)
785
+
786
+ # Build lists of opening and closing tags in source
787
+ source_opening = [] # [(full_tag, tag_name), ...]
788
+ source_closing = []
789
+
790
+ for match in re.finditer(tag_pattern, source_text):
791
+ is_closing = match.group(1) == '/'
792
+ tag_name = match.group(2).lower()
793
+ full_tag = match.group(0)
794
+
795
+ if is_closing:
796
+ source_closing.append((full_tag, tag_name))
797
+ else:
798
+ source_opening.append((full_tag, tag_name))
799
+
800
+ # Build sets of tag names already in target
801
+ target_opening_names = set()
802
+ target_closing_names = set()
803
+
804
+ for is_closing, tag_name in target_matches:
805
+ if is_closing == '/':
806
+ target_closing_names.add(tag_name.lower())
807
+ else:
808
+ target_opening_names.add(tag_name.lower())
809
+
810
+ # Find first tag pair where both opening and closing exist in source
811
+ # but at least one is missing from target
812
+ seen_tags = set()
813
+ for full_tag, tag_name in source_opening:
814
+ if tag_name in seen_tags:
815
+ continue
816
+ seen_tags.add(tag_name)
817
+
818
+ # Check if there's a matching closing tag in source
819
+ has_closing = any(name == tag_name for _, name in source_closing)
820
+ if not has_closing:
821
+ continue
822
+
823
+ # Check if pair is incomplete in target
824
+ opening_in_target = tag_name in target_opening_names
825
+ closing_in_target = tag_name in target_closing_names
826
+
827
+ if not opening_in_target or not closing_in_target:
828
+ # Find the actual opening and closing tags from source
829
+ opening_tag = full_tag
830
+ closing_tag = f"</{tag_name}>"
831
+ return (opening_tag, closing_tag)
832
+
757
833
  return (None, None)
758
834
 
759
835
 
@@ -1148,48 +1224,48 @@ class GridTextEditor(QTextEdit):
1148
1224
  def _insert_next_tag_or_wrap_selection(self):
1149
1225
  """
1150
1226
  Insert the next memoQ tag, HTML tag, or CafeTran pipe symbol from source, or wrap selection.
1151
-
1227
+
1152
1228
  Behavior:
1153
- - If text is selected: Wrap it with the next available tag pair [N}selection{N] or |selection|
1229
+ - If text is selected: Wrap it with the next available tag pair [N}selection{N] or <tag>selection</tag> or |selection|
1154
1230
  - If no selection: Insert the next unused tag/pipe from source at cursor position
1155
-
1231
+
1156
1232
  Supports:
1157
1233
  - memoQ tags: [1}, {1], [2}, {2], etc.
1158
1234
  - HTML/XML tags: <li>, </li>, <b>, </b>, <i>, </i>, etc.
1159
1235
  - CafeTran pipe symbols: |
1160
-
1236
+
1161
1237
  Shortcut: Ctrl+, (comma)
1162
1238
  """
1163
1239
  # Get the main window and current segment
1164
1240
  if not self.table_widget or self.current_row is None:
1165
1241
  return
1166
-
1242
+
1167
1243
  # Navigate up to find main window
1168
1244
  main_window = self.table_widget.parent()
1169
1245
  while main_window and not hasattr(main_window, 'current_project'):
1170
1246
  main_window = main_window.parent()
1171
-
1247
+
1172
1248
  if not main_window or not hasattr(main_window, 'current_project'):
1173
1249
  return
1174
-
1250
+
1175
1251
  if not main_window.current_project or self.current_row >= len(main_window.current_project.segments):
1176
1252
  return
1177
-
1253
+
1178
1254
  segment = main_window.current_project.segments[self.current_row]
1179
1255
  source_text = segment.source
1180
1256
  current_target = self.toPlainText()
1181
-
1257
+
1182
1258
  # Check what type of tags are in the source
1183
1259
  has_memoq_tags = bool(extract_memoq_tags(source_text))
1184
1260
  has_html_tags = bool(extract_html_tags(source_text))
1185
1261
  has_any_tags = has_memoq_tags or has_html_tags
1186
1262
  has_pipe_symbols = '|' in source_text
1187
-
1263
+
1188
1264
  # Check if there's a selection
1189
1265
  cursor = self.textCursor()
1190
1266
  if cursor.hasSelection():
1191
1267
  selected_text = cursor.selectedText()
1192
-
1268
+
1193
1269
  # Try memoQ tag pair first
1194
1270
  if has_memoq_tags:
1195
1271
  opening_tag, closing_tag = get_wrapping_tag_pair(source_text, current_target)
@@ -1199,7 +1275,17 @@ class GridTextEditor(QTextEdit):
1199
1275
  if hasattr(main_window, 'log'):
1200
1276
  main_window.log(f"🏷️ Wrapped selection with {opening_tag}...{closing_tag}")
1201
1277
  return
1202
-
1278
+
1279
+ # Try HTML tag pairs (e.g., <b>...</b>, <i>...</i>)
1280
+ if has_html_tags:
1281
+ opening_tag, closing_tag = get_html_wrapping_tag_pair(source_text, current_target)
1282
+ if opening_tag and closing_tag:
1283
+ wrapped_text = f"{opening_tag}{selected_text}{closing_tag}"
1284
+ cursor.insertText(wrapped_text)
1285
+ if hasattr(main_window, 'log'):
1286
+ main_window.log(f"🏷️ Wrapped selection with {opening_tag}...{closing_tag}")
1287
+ return
1288
+
1203
1289
  # Try CafeTran pipe symbols
1204
1290
  if has_pipe_symbols:
1205
1291
  pipes_needed = get_next_pipe_count_needed(source_text, current_target)
@@ -1210,12 +1296,12 @@ class GridTextEditor(QTextEdit):
1210
1296
  if hasattr(main_window, 'log'):
1211
1297
  main_window.log(f"🏷️ Wrapped selection with |...|")
1212
1298
  return
1213
-
1299
+
1214
1300
  if hasattr(main_window, 'log'):
1215
1301
  main_window.log("⚠️ No tag pairs available from source")
1216
1302
  else:
1217
1303
  # No selection - insert next unused tag or pipe at cursor
1218
-
1304
+
1219
1305
  # Try memoQ tags and HTML tags (find_next_unused_tag handles both)
1220
1306
  if has_any_tags:
1221
1307
  next_tag = find_next_unused_tag(source_text, current_target)
@@ -1224,7 +1310,7 @@ class GridTextEditor(QTextEdit):
1224
1310
  if hasattr(main_window, 'log'):
1225
1311
  main_window.log(f"🏷️ Inserted tag: {next_tag}")
1226
1312
  return
1227
-
1313
+
1228
1314
  # Try CafeTran pipe symbols
1229
1315
  if has_pipe_symbols:
1230
1316
  pipes_needed = get_next_pipe_count_needed(source_text, current_target)
@@ -1233,7 +1319,7 @@ class GridTextEditor(QTextEdit):
1233
1319
  if hasattr(main_window, 'log'):
1234
1320
  main_window.log(f"🏷️ Inserted pipe symbol (|)")
1235
1321
  return
1236
-
1322
+
1237
1323
  if hasattr(main_window, 'log'):
1238
1324
  main_window.log("✓ All tags from source already in target")
1239
1325
 
@@ -2080,16 +2166,27 @@ class ReadOnlyGridTextEditor(QTextEdit):
2080
2166
  # Use stored table reference and row number
2081
2167
  if self.table_ref and self.row >= 0:
2082
2168
  try:
2083
- self.table_ref.selectRow(self.row)
2084
- self.table_ref.setCurrentCell(self.row, 2)
2085
-
2086
- # CRITICAL: Manually trigger on_cell_selected since signals aren't firing
2087
- # Find the main window and call the method directly
2088
- main_window = self.table_ref.parent()
2089
- while main_window and not hasattr(main_window, 'on_cell_selected'):
2090
- main_window = main_window.parent()
2091
- if main_window and hasattr(main_window, 'on_cell_selected'):
2092
- main_window.on_cell_selected(self.row, 2, -1, -1)
2169
+ # Check for Shift or Ctrl modifier - let Qt handle native multi-selection
2170
+ modifiers = event.modifiers()
2171
+ is_shift = modifiers & Qt.KeyboardModifier.ShiftModifier
2172
+ is_ctrl = modifiers & Qt.KeyboardModifier.ControlModifier
2173
+
2174
+ if is_shift or is_ctrl:
2175
+ # For Shift+click (range) or Ctrl+click (toggle), just set current cell
2176
+ # but don't call selectRow() which would clear the selection
2177
+ self.table_ref.setCurrentCell(self.row, 2)
2178
+ else:
2179
+ # Normal click - select just this row
2180
+ self.table_ref.selectRow(self.row)
2181
+ self.table_ref.setCurrentCell(self.row, 2)
2182
+
2183
+ # CRITICAL: Manually trigger on_cell_selected since signals aren't firing
2184
+ # Find the main window and call the method directly
2185
+ main_window = self.table_ref.parent()
2186
+ while main_window and not hasattr(main_window, 'on_cell_selected'):
2187
+ main_window = main_window.parent()
2188
+ if main_window and hasattr(main_window, 'on_cell_selected'):
2189
+ main_window.on_cell_selected(self.row, 2, -1, -1)
2093
2190
  except Exception as e:
2094
2191
  print(f"Error triggering manual cell selection: {e}")
2095
2192
 
@@ -2166,20 +2263,32 @@ class ReadOnlyGridTextEditor(QTextEdit):
2166
2263
  """Select text when focused for easy copying and trigger row selection"""
2167
2264
  super().focusInEvent(event)
2168
2265
  # Don't auto-select - let user select manually
2169
-
2266
+
2170
2267
  # Use stored table reference and row number
2171
2268
  if self.table_ref and self.row >= 0:
2172
2269
  try:
2173
- self.table_ref.selectRow(self.row)
2174
- self.table_ref.setCurrentCell(self.row, 2)
2175
-
2176
- # CRITICAL: Manually trigger on_cell_selected since signals aren't firing
2177
- # Find the main window and call the method directly
2178
- main_window = self.table_ref.parent()
2179
- while main_window and not hasattr(main_window, 'on_cell_selected'):
2180
- main_window = main_window.parent()
2181
- if main_window and hasattr(main_window, 'on_cell_selected'):
2182
- main_window.on_cell_selected(self.row, 2, -1, -1)
2270
+ # Check for Shift or Ctrl modifier - let Qt handle native multi-selection
2271
+ from PyQt6.QtWidgets import QApplication
2272
+ modifiers = QApplication.keyboardModifiers()
2273
+ is_shift = modifiers & Qt.KeyboardModifier.ShiftModifier
2274
+ is_ctrl = modifiers & Qt.KeyboardModifier.ControlModifier
2275
+
2276
+ if is_shift or is_ctrl:
2277
+ # For Shift+click (range) or Ctrl+click (toggle), just set current cell
2278
+ # but don't call selectRow() which would clear the selection
2279
+ self.table_ref.setCurrentCell(self.row, 2)
2280
+ else:
2281
+ # Normal focus - select just this row
2282
+ self.table_ref.selectRow(self.row)
2283
+ self.table_ref.setCurrentCell(self.row, 2)
2284
+
2285
+ # CRITICAL: Manually trigger on_cell_selected since signals aren't firing
2286
+ # Find the main window and call the method directly
2287
+ main_window = self.table_ref.parent()
2288
+ while main_window and not hasattr(main_window, 'on_cell_selected'):
2289
+ main_window = main_window.parent()
2290
+ if main_window and hasattr(main_window, 'on_cell_selected'):
2291
+ main_window.on_cell_selected(self.row, 2, -1, -1)
2183
2292
  except Exception as e:
2184
2293
  print(f"Error triggering manual cell selection: {e}")
2185
2294
 
@@ -3026,19 +3135,30 @@ class EditableGridTextEditor(QTextEdit):
3026
3135
  super().mousePressEvent(event)
3027
3136
  # Auto-select the row when clicking in the target cell
3028
3137
  if self.table and self.row >= 0:
3029
- self.table.selectRow(self.row)
3030
- self.table.setCurrentCell(self.row, 3) # Column 3 is Target
3138
+ # Check for Shift or Ctrl modifier - let Qt handle native multi-selection
3139
+ modifiers = event.modifiers()
3140
+ is_shift = modifiers & Qt.KeyboardModifier.ShiftModifier
3141
+ is_ctrl = modifiers & Qt.KeyboardModifier.ControlModifier
3142
+
3143
+ if is_shift or is_ctrl:
3144
+ # For Shift+click (range) or Ctrl+click (toggle), just set current cell
3145
+ # but don't call selectRow() which would clear the selection
3146
+ self.table.setCurrentCell(self.row, 3) # Column 3 is Target
3147
+ else:
3148
+ # Normal click - select just this row
3149
+ self.table.selectRow(self.row)
3150
+ self.table.setCurrentCell(self.row, 3) # Column 3 is Target
3031
3151
 
3032
- # CRITICAL: Manually trigger on_cell_selected since signals aren't firing
3033
- # Find the main window and call the method directly
3034
- try:
3035
- main_window = self.table.parent()
3036
- while main_window and not hasattr(main_window, 'on_cell_selected'):
3037
- main_window = main_window.parent()
3038
- if main_window and hasattr(main_window, 'on_cell_selected'):
3039
- main_window.on_cell_selected(self.row, 3, -1, -1)
3040
- except Exception as e:
3041
- print(f"Error triggering manual cell selection: {e}")
3152
+ # CRITICAL: Manually trigger on_cell_selected since signals aren't firing
3153
+ # Find the main window and call the method directly
3154
+ try:
3155
+ main_window = self.table.parent()
3156
+ while main_window and not hasattr(main_window, 'on_cell_selected'):
3157
+ main_window = main_window.parent()
3158
+ if main_window and hasattr(main_window, 'on_cell_selected'):
3159
+ main_window.on_cell_selected(self.row, 3, -1, -1)
3160
+ except Exception as e:
3161
+ print(f"Error triggering manual cell selection: {e}")
3042
3162
 
3043
3163
  def mouseReleaseEvent(self, event):
3044
3164
  """Smart word selection - expand partial selections to full words
@@ -3108,19 +3228,31 @@ class EditableGridTextEditor(QTextEdit):
3108
3228
  self.show()
3109
3229
  # Auto-select the row when focusing the target cell
3110
3230
  if self.table and self.row >= 0:
3111
- self.table.selectRow(self.row)
3112
- self.table.setCurrentCell(self.row, 3) # Column 3 is Target
3113
-
3114
- # CRITICAL: Manually trigger on_cell_selected since signals aren't firing
3115
- # Find the main window and call the method directly
3116
- try:
3117
- main_window = self.table.parent()
3118
- while main_window and not hasattr(main_window, 'on_cell_selected'):
3119
- main_window = main_window.parent()
3120
- if main_window and hasattr(main_window, 'on_cell_selected'):
3121
- main_window.on_cell_selected(self.row, 3, -1, -1)
3122
- except Exception as e:
3123
- print(f"Error triggering manual cell selection: {e}")
3231
+ # Check for Shift or Ctrl modifier - let Qt handle native multi-selection
3232
+ from PyQt6.QtWidgets import QApplication
3233
+ modifiers = QApplication.keyboardModifiers()
3234
+ is_shift = modifiers & Qt.KeyboardModifier.ShiftModifier
3235
+ is_ctrl = modifiers & Qt.KeyboardModifier.ControlModifier
3236
+
3237
+ if is_shift or is_ctrl:
3238
+ # For Shift+click (range) or Ctrl+click (toggle), just set current cell
3239
+ # but don't call selectRow() which would clear the selection
3240
+ self.table.setCurrentCell(self.row, 3) # Column 3 is Target
3241
+ else:
3242
+ # Normal focus - select just this row
3243
+ self.table.selectRow(self.row)
3244
+ self.table.setCurrentCell(self.row, 3) # Column 3 is Target
3245
+
3246
+ # CRITICAL: Manually trigger on_cell_selected since signals aren't firing
3247
+ # Find the main window and call the method directly
3248
+ try:
3249
+ main_window = self.table.parent()
3250
+ while main_window and not hasattr(main_window, 'on_cell_selected'):
3251
+ main_window = main_window.parent()
3252
+ if main_window and hasattr(main_window, 'on_cell_selected'):
3253
+ main_window.on_cell_selected(self.row, 3, -1, -1)
3254
+ except Exception as e:
3255
+ print(f"Error triggering manual cell selection: {e}")
3124
3256
 
3125
3257
  def keyPressEvent(self, event):
3126
3258
  """Handle Tab and Ctrl+E keys to cycle between source and target cells"""
@@ -3423,7 +3555,7 @@ class EditableGridTextEditor(QTextEdit):
3423
3555
  cursor = self.textCursor()
3424
3556
  if cursor.hasSelection():
3425
3557
  selected_text = cursor.selectedText()
3426
-
3558
+
3427
3559
  # Try memoQ tag pair first
3428
3560
  if has_memoq_tags:
3429
3561
  opening_tag, closing_tag = get_wrapping_tag_pair(source_text, current_target)
@@ -3433,7 +3565,17 @@ class EditableGridTextEditor(QTextEdit):
3433
3565
  if hasattr(main_window, 'log'):
3434
3566
  main_window.log(f"🏷️ Wrapped selection with {opening_tag}...{closing_tag}")
3435
3567
  return
3436
-
3568
+
3569
+ # Try HTML tag pairs (e.g., <b>...</b>, <i>...</i>)
3570
+ if has_html_tags:
3571
+ opening_tag, closing_tag = get_html_wrapping_tag_pair(source_text, current_target)
3572
+ if opening_tag and closing_tag:
3573
+ wrapped_text = f"{opening_tag}{selected_text}{closing_tag}"
3574
+ cursor.insertText(wrapped_text)
3575
+ if hasattr(main_window, 'log'):
3576
+ main_window.log(f"🏷️ Wrapped selection with {opening_tag}...{closing_tag}")
3577
+ return
3578
+
3437
3579
  # Try CafeTran pipe symbols
3438
3580
  if has_pipe_symbols:
3439
3581
  pipes_needed = get_next_pipe_count_needed(source_text, current_target)
@@ -3444,12 +3586,12 @@ class EditableGridTextEditor(QTextEdit):
3444
3586
  if hasattr(main_window, 'log'):
3445
3587
  main_window.log(f"🏷️ Wrapped selection with |...|")
3446
3588
  return
3447
-
3589
+
3448
3590
  if hasattr(main_window, 'log'):
3449
3591
  main_window.log("⚠️ No tag pairs available from source")
3450
3592
  else:
3451
3593
  # No selection - insert next unused tag or pipe at cursor
3452
-
3594
+
3453
3595
  # Try memoQ tags and HTML tags (find_next_unused_tag handles both)
3454
3596
  if has_any_tags:
3455
3597
  next_tag = find_next_unused_tag(source_text, current_target)
@@ -3458,7 +3600,7 @@ class EditableGridTextEditor(QTextEdit):
3458
3600
  if hasattr(main_window, 'log'):
3459
3601
  main_window.log(f"🏷️ Inserted tag: {next_tag}")
3460
3602
  return
3461
-
3603
+
3462
3604
  # Try CafeTran pipe symbols
3463
3605
  if has_pipe_symbols:
3464
3606
  pipes_needed = get_next_pipe_count_needed(source_text, current_target)
@@ -3467,10 +3609,10 @@ class EditableGridTextEditor(QTextEdit):
3467
3609
  if hasattr(main_window, 'log'):
3468
3610
  main_window.log(f"🏷️ Inserted pipe symbol (|)")
3469
3611
  return
3470
-
3612
+
3471
3613
  if hasattr(main_window, 'log'):
3472
3614
  main_window.log("✓ All tags from source already in target")
3473
-
3615
+
3474
3616
  def _copy_source_to_target(self):
3475
3617
  """
3476
3618
  Copy source text to target cell.
@@ -5179,12 +5321,14 @@ class AdvancedFiltersDialog(QDialog):
5179
5321
 
5180
5322
  self.status_not_started = CheckmarkCheckBox("Not started")
5181
5323
  self.status_edited = CheckmarkCheckBox("Edited")
5324
+ self.status_pretranslated = CheckmarkCheckBox("Pre-translated")
5182
5325
  self.status_translated = CheckmarkCheckBox("Translated")
5183
5326
  self.status_confirmed = CheckmarkCheckBox("Confirmed")
5184
5327
  self.status_draft = CheckmarkCheckBox("Draft")
5185
-
5328
+
5186
5329
  status_layout.addWidget(self.status_not_started)
5187
5330
  status_layout.addWidget(self.status_edited)
5331
+ status_layout.addWidget(self.status_pretranslated)
5188
5332
  status_layout.addWidget(self.status_translated)
5189
5333
  status_layout.addWidget(self.status_confirmed)
5190
5334
  status_layout.addWidget(self.status_draft)
@@ -5257,6 +5401,7 @@ class AdvancedFiltersDialog(QDialog):
5257
5401
 
5258
5402
  self.status_not_started.setChecked(False)
5259
5403
  self.status_edited.setChecked(False)
5404
+ self.status_pretranslated.setChecked(False)
5260
5405
  self.status_translated.setChecked(False)
5261
5406
  self.status_confirmed.setChecked(False)
5262
5407
  self.status_draft.setChecked(False)
@@ -5283,6 +5428,8 @@ class AdvancedFiltersDialog(QDialog):
5283
5428
  row_status.append('not_started')
5284
5429
  if self.status_edited.isChecked():
5285
5430
  row_status.append('edited')
5431
+ if self.status_pretranslated.isChecked():
5432
+ row_status.append('pretranslated')
5286
5433
  if self.status_translated.isChecked():
5287
5434
  row_status.append('translated')
5288
5435
  if self.status_confirmed.isChecked():
@@ -5598,7 +5745,7 @@ class PreTranslationWorker(QThread):
5598
5745
  match = matches[0]
5599
5746
  match_pct = match.get('match_pct', 0)
5600
5747
  print(f"🔍 TM PRE-TRANSLATE: Best match pct: {match_pct}")
5601
- if match_pct >= 70: # Accept matches 70% and above
5748
+ if match_pct >= 75: # Accept matches 75% and above
5602
5749
  return match.get('target', '')
5603
5750
  return None
5604
5751
  except Exception as e:
@@ -7499,13 +7646,30 @@ class SupervertalerQt(QMainWindow):
7499
7646
 
7500
7647
  # Bulk Operations submenu
7501
7648
  bulk_menu = edit_menu.addMenu("Bulk &Operations")
7502
-
7649
+
7503
7650
  confirm_selected_action = QAction("✅ &Confirm Selected Segments", self)
7504
7651
  confirm_selected_action.setShortcut("Ctrl+Shift+Return")
7505
7652
  confirm_selected_action.setToolTip("Confirm all selected segments (Ctrl+Shift+Enter)")
7506
7653
  confirm_selected_action.triggered.connect(self.confirm_selected_segments_from_menu)
7507
7654
  bulk_menu.addAction(confirm_selected_action)
7508
-
7655
+
7656
+ # Change Status submenu
7657
+ status_submenu = bulk_menu.addMenu("🏷️ Change &Status")
7658
+ user_statuses = [
7659
+ ("not_started", "❌ &Not started"),
7660
+ ("pretranslated", "🤖 &Pre-translated"),
7661
+ ("translated", "✏️ &Translated"),
7662
+ ("confirmed", "✔ &Confirmed"),
7663
+ ("tr_confirmed", "🌟 T&R confirmed"),
7664
+ ("proofread", "🟪 Proo&fread"),
7665
+ ("approved", "⭐ &Approved"),
7666
+ ("rejected", "🚫 Re&jected"),
7667
+ ]
7668
+ for status_key, label in user_statuses:
7669
+ action = QAction(label, self)
7670
+ action.triggered.connect(lambda checked, s=status_key: self.change_status_selected(s, from_menu=True))
7671
+ status_submenu.addAction(action)
7672
+
7509
7673
  clear_translations_action = QAction("🗑️ &Clear Translations", self)
7510
7674
  clear_translations_action.setToolTip("Clear translations for selected segments")
7511
7675
  clear_translations_action.triggered.connect(self.clear_selected_translations_from_menu)
@@ -15408,7 +15572,57 @@ class SupervertalerQt(QMainWindow):
15408
15572
 
15409
15573
  model_group.setLayout(model_layout)
15410
15574
  layout.addWidget(model_group)
15411
-
15575
+
15576
+ # ========== SECTION 2b: Model Version Checker ==========
15577
+ version_check_group = QGroupBox("🔄 Model Version Checker")
15578
+ version_check_layout = QVBoxLayout()
15579
+
15580
+ version_check_info = QLabel(
15581
+ "Automatically check for new LLM models from OpenAI, Anthropic, and Google.\n"
15582
+ "Get notified when new models are available and easily add them to Supervertaler."
15583
+ )
15584
+ version_check_info.setWordWrap(True)
15585
+ version_check_layout.addWidget(version_check_info)
15586
+
15587
+ # Auto-check setting
15588
+ auto_check_models_cb = CheckmarkCheckBox("Enable automatic model checking (once per day on startup)")
15589
+ auto_check_models_cb.setChecked(general_settings.get('auto_check_models', True))
15590
+ auto_check_models_cb.setToolTip(
15591
+ "When enabled, Supervertaler will check for new models once per day when you start the application.\n"
15592
+ "You'll see a popup if new models are detected."
15593
+ )
15594
+ version_check_layout.addWidget(auto_check_models_cb)
15595
+
15596
+ # Manual check button
15597
+ manual_check_btn = QPushButton("🔍 Check for New Models Now")
15598
+ manual_check_btn.setToolTip("Manually check for new models from all providers")
15599
+ manual_check_btn.clicked.connect(lambda: self._check_for_new_models(force=True))
15600
+ version_check_layout.addWidget(manual_check_btn)
15601
+
15602
+ # Store reference for saving
15603
+ self.auto_check_models_cb = auto_check_models_cb
15604
+
15605
+ version_check_group.setLayout(version_check_layout)
15606
+ layout.addWidget(version_check_group)
15607
+
15608
+ # ========== SECTION 2c: API Keys ==========
15609
+ api_keys_group = QGroupBox("🔑 API Keys")
15610
+ api_keys_layout = QVBoxLayout()
15611
+
15612
+ api_keys_info = QLabel(
15613
+ f"Configure your API keys in:<br>"
15614
+ f"<code>{self.user_data_path / 'api_keys.txt'}</code>"
15615
+ )
15616
+ api_keys_info.setWordWrap(True)
15617
+ api_keys_layout.addWidget(api_keys_info)
15618
+
15619
+ open_keys_btn = QPushButton("📝 Open API Keys File")
15620
+ open_keys_btn.clicked.connect(lambda: self.open_api_keys_file())
15621
+ api_keys_layout.addWidget(open_keys_btn)
15622
+
15623
+ api_keys_group.setLayout(api_keys_layout)
15624
+ layout.addWidget(api_keys_group)
15625
+
15412
15626
  # ========== SECTION 3: Enable/Disable LLM Providers ==========
15413
15627
  provider_enable_group = QGroupBox("✅ Enable/Disable LLM Providers")
15414
15628
  provider_enable_layout = QVBoxLayout()
@@ -15688,58 +15902,7 @@ class SupervertalerQt(QMainWindow):
15688
15902
 
15689
15903
  behavior_group.setLayout(behavior_layout)
15690
15904
  layout.addWidget(behavior_group)
15691
-
15692
- # ========== SECTION 7: API Keys ==========
15693
- # ========== MODEL VERSION CHECKER ==========
15694
- version_check_group = QGroupBox("🔄 Model Version Checker")
15695
- version_check_layout = QVBoxLayout()
15696
-
15697
- version_check_info = QLabel(
15698
- "Automatically check for new LLM models from OpenAI, Anthropic, and Google.\n"
15699
- "Get notified when new models are available and easily add them to Supervertaler."
15700
- )
15701
- version_check_info.setWordWrap(True)
15702
- version_check_layout.addWidget(version_check_info)
15703
-
15704
- # Auto-check setting
15705
- auto_check_models_cb = CheckmarkCheckBox("Enable automatic model checking (once per day on startup)")
15706
- auto_check_models_cb.setChecked(general_settings.get('auto_check_models', True))
15707
- auto_check_models_cb.setToolTip(
15708
- "When enabled, Supervertaler will check for new models once per day when you start the application.\n"
15709
- "You'll see a popup if new models are detected."
15710
- )
15711
- version_check_layout.addWidget(auto_check_models_cb)
15712
-
15713
- # Manual check button
15714
- manual_check_btn = QPushButton("🔍 Check for New Models Now")
15715
- manual_check_btn.setToolTip("Manually check for new models from all providers")
15716
- manual_check_btn.clicked.connect(lambda: self._check_for_new_models(force=True))
15717
- version_check_layout.addWidget(manual_check_btn)
15718
-
15719
- # Store reference for saving
15720
- self.auto_check_models_cb = auto_check_models_cb
15721
-
15722
- version_check_group.setLayout(version_check_layout)
15723
- layout.addWidget(version_check_group)
15724
-
15725
- # ========== API KEYS ==========
15726
- api_keys_group = QGroupBox("🔑 API Keys")
15727
- api_keys_layout = QVBoxLayout()
15728
-
15729
- api_keys_info = QLabel(
15730
- f"Configure your API keys in:<br>"
15731
- f"<code>{self.user_data_path / 'api_keys.txt'}</code>"
15732
- )
15733
- api_keys_info.setWordWrap(True)
15734
- api_keys_layout.addWidget(api_keys_info)
15735
15905
 
15736
- open_keys_btn = QPushButton("📝 Open API Keys File")
15737
- open_keys_btn.clicked.connect(lambda: self.open_api_keys_file())
15738
- api_keys_layout.addWidget(open_keys_btn)
15739
-
15740
- api_keys_group.setLayout(api_keys_layout)
15741
- layout.addWidget(api_keys_group)
15742
-
15743
15906
  # ========== SAVE BUTTON ==========
15744
15907
  save_btn = QPushButton("💾 Save AI Settings")
15745
15908
  save_btn.setStyleSheet("font-weight: bold; padding: 8px; outline: none;")
@@ -19844,7 +20007,13 @@ class SupervertalerQt(QMainWindow):
19844
20007
  self.pagination_label = QLabel("Segments 1-50 of 0")
19845
20008
  self.pagination_label.setStyleSheet("color: #555;")
19846
20009
  pagination_layout.addWidget(self.pagination_label)
19847
-
20010
+
20011
+ # Tip label for Ctrl+, shortcut (subtle, helpful for new users)
20012
+ tip_label = QLabel("💡 Tip: Ctrl+, inserts the next tag from source")
20013
+ tip_label.setStyleSheet("color: #888; font-size: 9pt; margin-left: 20px;")
20014
+ tip_label.setToolTip("Select text first to wrap it with a tag pair (e.g., <b>selection</b>)")
20015
+ pagination_layout.addWidget(tip_label)
20016
+
19848
20017
  pagination_layout.addStretch()
19849
20018
 
19850
20019
  # Pagination controls (right side)
@@ -19972,6 +20141,7 @@ class SupervertalerQt(QMainWindow):
19972
20141
  from modules.statuses import get_status, STATUSES
19973
20142
  status_label = QLabel("Status:")
19974
20143
  tab_status_combo = QComboBox()
20144
+ tab_status_combo.setMinimumWidth(130) # Ensure full status text is visible
19975
20145
  for status_key in STATUSES.keys():
19976
20146
  definition = get_status(status_key)
19977
20147
  tab_status_combo.addItem(definition.label, status_key)
@@ -20870,6 +21040,7 @@ class SupervertalerQt(QMainWindow):
20870
21040
  from modules.statuses import STATUSES
20871
21041
  status_label = QLabel("Status:")
20872
21042
  tab_status_combo = QComboBox()
21043
+ tab_status_combo.setMinimumWidth(130) # Ensure full status text is visible
20873
21044
  for status_key in STATUSES.keys():
20874
21045
  definition = get_status(status_key)
20875
21046
  tab_status_combo.addItem(definition.label, status_key)
@@ -22319,27 +22490,31 @@ class SupervertalerQt(QMainWindow):
22319
22490
  def save_segment_to_activated_tms(self, source: str, target: str):
22320
22491
  """
22321
22492
  Save segment to all writable TMs for current project.
22322
-
22493
+
22323
22494
  Note: Uses get_writable_tm_ids() which checks the Write checkbox (read_only=0),
22324
22495
  NOT get_active_tm_ids() which checks the Read checkbox (is_active=1).
22325
-
22496
+
22497
+ Respects tm_save_mode setting:
22498
+ - 'latest': Overwrites existing entries with same source (keeps only newest translation)
22499
+ - 'all': Keeps all translations with different targets (default SQLite behavior)
22500
+
22326
22501
  Args:
22327
22502
  source: Source text
22328
22503
  target: Target text
22329
22504
  """
22330
22505
  if not self.current_project:
22331
22506
  return
22332
-
22507
+
22333
22508
  if not hasattr(self.current_project, 'source_lang') or not hasattr(self.current_project, 'target_lang'):
22334
22509
  return
22335
-
22510
+
22336
22511
  # Get WRITABLE TM IDs for this project (Write checkbox enabled)
22337
22512
  tm_ids = []
22338
-
22513
+
22339
22514
  if hasattr(self, 'tm_metadata_mgr') and self.tm_metadata_mgr:
22340
22515
  if hasattr(self, 'current_project') and self.current_project:
22341
22516
  project_id = self.current_project.id if hasattr(self.current_project, 'id') else None
22342
-
22517
+
22343
22518
  if project_id:
22344
22519
  # Use get_writable_tm_ids() to find TMs with Write enabled
22345
22520
  tm_ids = self.tm_metadata_mgr.get_writable_tm_ids(project_id)
@@ -22349,13 +22524,16 @@ class SupervertalerQt(QMainWindow):
22349
22524
  self.log(f"⚠️ Cannot save to TM: No current project loaded!")
22350
22525
  else:
22351
22526
  self.log(f"⚠️ Cannot save to TM: TM metadata manager not available!")
22352
-
22527
+
22353
22528
  # If no TMs have Write enabled, skip saving
22354
22529
  if not tm_ids:
22355
22530
  self.log("⚠️ No TMs with Write enabled - segment not saved to TM.")
22356
22531
  self.log(f" - To fix: Go to Resources > Translation Memories > TM List and enable the Write checkbox")
22357
22532
  return
22358
-
22533
+
22534
+ # Check TM save mode: 'latest' = overwrite, 'all' = keep all variants
22535
+ overwrite_mode = getattr(self, 'tm_save_mode', 'latest') == 'latest'
22536
+
22359
22537
  # Save to each writable TM
22360
22538
  saved_count = 0
22361
22539
  for tm_id in tm_ids:
@@ -22365,14 +22543,16 @@ class SupervertalerQt(QMainWindow):
22365
22543
  target=target,
22366
22544
  source_lang=self.current_project.source_lang,
22367
22545
  target_lang=self.current_project.target_lang,
22368
- tm_id=tm_id
22546
+ tm_id=tm_id,
22547
+ overwrite=overwrite_mode
22369
22548
  )
22370
22549
  saved_count += 1
22371
22550
  except Exception as e:
22372
22551
  self.log(f"⚠️ Could not save to TM '{tm_id}': {e}")
22373
-
22552
+
22374
22553
  if saved_count > 0:
22375
- msg = f"💾 Saved segment to {saved_count} TM(s)"
22554
+ mode_note = " (overwrite)" if overwrite_mode else ""
22555
+ msg = f"💾 Saved segment to {saved_count} TM(s){mode_note}"
22376
22556
  self._queue_tm_save_log(msg)
22377
22557
  # Invalidate cache so prefetched segments get fresh TM matches
22378
22558
  self.invalidate_translation_cache()
@@ -31476,25 +31656,43 @@ class SupervertalerQt(QMainWindow):
31476
31656
  def show_grid_context_menu(self, position):
31477
31657
  """Show context menu for grid view with bulk operations"""
31478
31658
  selected_segments = self.get_selected_segments_from_grid()
31479
-
31659
+
31480
31660
  if not selected_segments:
31481
31661
  return
31482
-
31662
+
31483
31663
  menu = QMenu(self)
31484
-
31664
+
31485
31665
  # Confirm selected segments action
31486
31666
  if len(selected_segments) >= 1:
31487
31667
  confirm_action = menu.addAction(f"✅ Confirm {len(selected_segments)} Segment(s)")
31488
31668
  confirm_action.setToolTip(f"Confirm {len(selected_segments)} selected segment(s)")
31489
31669
  confirm_action.triggered.connect(self.confirm_selected_segments)
31490
-
31670
+
31671
+ # Change Status submenu
31672
+ from modules.statuses import get_status
31673
+ status_menu = menu.addMenu(f"🏷️ Change Status ({len(selected_segments)})")
31674
+ # Common user-settable statuses (excluding TM-specific ones like pm, cm, tm_100, etc.)
31675
+ user_statuses = [
31676
+ ("not_started", "❌ Not started"),
31677
+ ("pretranslated", "🤖 Pre-translated"),
31678
+ ("translated", "✏️ Translated"),
31679
+ ("confirmed", "✔ Confirmed"),
31680
+ ("tr_confirmed", "🌟 TR confirmed"),
31681
+ ("proofread", "🟪 Proofread"),
31682
+ ("approved", "⭐ Approved"),
31683
+ ("rejected", "🚫 Rejected"),
31684
+ ]
31685
+ for status_key, label in user_statuses:
31686
+ action = status_menu.addAction(label)
31687
+ action.triggered.connect(lambda checked, s=status_key: self.change_status_selected(s))
31688
+
31491
31689
  # Clear translations action
31492
31690
  clear_action = menu.addAction("🗑️ Clear Translations")
31493
31691
  clear_action.setToolTip(f"Clear translations for {len(selected_segments)} selected segment(s)")
31494
31692
  clear_action.triggered.connect(lambda: self.clear_selected_translations(selected_segments, 'grid'))
31495
-
31693
+
31496
31694
  menu.addSeparator()
31497
-
31695
+
31498
31696
  # Clear proofreading notes (if any selected segment has proofreading notes)
31499
31697
  has_proofreading = any(seg.notes and "⚠️ PROOFREAD:" in seg.notes for seg in selected_segments)
31500
31698
  if has_proofreading:
@@ -31502,11 +31700,11 @@ class SupervertalerQt(QMainWindow):
31502
31700
  clear_proofread_action.setToolTip("Remove proofreading issues from selected segment(s)")
31503
31701
  clear_proofread_action.triggered.connect(lambda: self._clear_proofreading_from_selected(selected_segments))
31504
31702
  menu.addSeparator()
31505
-
31703
+
31506
31704
  # Select all action
31507
31705
  select_all_action = menu.addAction("📋 Select All (Ctrl+A)")
31508
31706
  select_all_action.triggered.connect(lambda: self.table.selectAll())
31509
-
31707
+
31510
31708
  menu.exec(self.table.viewport().mapToGlobal(position))
31511
31709
 
31512
31710
  def _clear_proofreading_from_selected(self, segments):
@@ -38331,17 +38529,75 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
38331
38529
  if not self.current_project:
38332
38530
  QMessageBox.information(self, "Not Available", "Please load a project first.")
38333
38531
  return
38334
-
38532
+
38335
38533
  if not hasattr(self, 'table') or not self.table:
38336
38534
  QMessageBox.information(self, "Not Available", "Grid view is not available.")
38337
38535
  return
38338
-
38536
+
38339
38537
  selected_segments = self.get_selected_segments_from_grid()
38340
38538
  if selected_segments:
38341
38539
  self.confirm_selected_segments()
38342
38540
  else:
38343
38541
  QMessageBox.information(self, "No Selection", "Please select one or more segments to confirm.")
38344
-
38542
+
38543
+ def change_status_selected(self, new_status: str, from_menu: bool = False):
38544
+ """Change status of all selected segments to the specified status.
38545
+
38546
+ Args:
38547
+ new_status: The status key to set (e.g., 'translated', 'pretranslated')
38548
+ from_menu: If True, show message boxes for errors (called from menu)
38549
+ """
38550
+ if not self.current_project:
38551
+ if from_menu:
38552
+ QMessageBox.information(self, "Not Available", "Please load a project first.")
38553
+ else:
38554
+ self.log("⚠️ No project loaded")
38555
+ return
38556
+
38557
+ if not hasattr(self, 'table') or not self.table:
38558
+ if from_menu:
38559
+ QMessageBox.information(self, "Not Available", "Grid view is not available.")
38560
+ return
38561
+
38562
+ selected_segments = self.get_selected_segments_from_grid()
38563
+
38564
+ if not selected_segments:
38565
+ if from_menu:
38566
+ QMessageBox.information(self, "No Selection", "Please select one or more segments to change.")
38567
+ else:
38568
+ self.log("⚠️ No segments selected")
38569
+ return
38570
+
38571
+ # Sync all target text from grid widgets first
38572
+ self._sync_grid_targets_to_segments(selected_segments)
38573
+
38574
+ # Get status definition for logging
38575
+ from modules.statuses import get_status
38576
+ status_def = get_status(new_status)
38577
+
38578
+ changed_count = 0
38579
+ for segment in selected_segments:
38580
+ # Skip if already has this status
38581
+ if segment.status == new_status:
38582
+ continue
38583
+
38584
+ segment.status = new_status
38585
+ changed_count += 1
38586
+
38587
+ # Update grid status icon
38588
+ row = self._find_row_for_segment(segment.id)
38589
+ if row >= 0:
38590
+ self.update_status_icon(row, new_status)
38591
+
38592
+ if changed_count > 0:
38593
+ self.project_modified = True
38594
+ self.update_window_title()
38595
+ self.log(f"✅ Changed {changed_count} segment(s) to '{status_def.label}'")
38596
+ # Update status bar progress stats
38597
+ self.update_progress_stats()
38598
+ else:
38599
+ self.log(f"ℹ️ All {len(selected_segments)} selected segment(s) already have status '{status_def.label}'")
38600
+
38345
38601
  def _sync_grid_targets_to_segments(self, segments):
38346
38602
  """Sync target text from grid widgets to segment objects.
38347
38603
 
@@ -41148,25 +41404,41 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
41148
41404
  self.log(f"📖 Pre-translate from TM: Using activated TMs: {tm_ids}")
41149
41405
 
41150
41406
  # Create progress dialog for TM pre-translation
41407
+ import time
41408
+ start_time = time.time()
41409
+ total_segments = len(segments_needing_translation)
41410
+
41151
41411
  progress = QProgressDialog(
41152
- f"Pre-translating {len(segments_needing_translation)} segments from TM...",
41153
- "Cancel", 0, len(segments_needing_translation), self
41412
+ f"Pre-translating {total_segments} segments from TM...",
41413
+ "Cancel", 0, total_segments, self
41154
41414
  )
41155
- progress.setWindowTitle("TM Pre-Translation")
41415
+ progress.setWindowTitle("🔍 TM Pre-Translation")
41156
41416
  progress.setWindowModality(Qt.WindowModality.WindowModal)
41157
41417
  progress.setMinimumDuration(0) # Show immediately
41418
+ progress.setMinimumWidth(450) # Wider dialog for more info
41158
41419
  progress.show()
41159
41420
  QApplication.processEvents()
41160
-
41421
+
41161
41422
  success_count = 0
41162
41423
  no_match_count = 0
41163
-
41424
+
41164
41425
  for idx, (row_index, segment) in enumerate(segments_needing_translation):
41165
41426
  if progress.wasCanceled():
41166
41427
  break
41167
-
41428
+
41168
41429
  progress.setValue(idx)
41169
- progress.setLabelText(f"Searching TM for segment {idx + 1}/{len(segments_needing_translation)}...")
41430
+
41431
+ # Build informative progress label
41432
+ elapsed = time.time() - start_time
41433
+ elapsed_str = f"{int(elapsed // 60)}:{int(elapsed % 60):02d}"
41434
+ source_preview = segment.source[:50] + "..." if len(segment.source) > 50 else segment.source
41435
+ label_text = (
41436
+ f"Searching TM for segment {idx + 1} of {total_segments}...\n\n"
41437
+ f"Current: \"{source_preview}\"\n"
41438
+ f"Matches found: {success_count} | Elapsed: {elapsed_str}\n\n"
41439
+ f"ℹ️ This may take a while for large documents."
41440
+ )
41441
+ progress.setLabelText(label_text)
41170
41442
  QApplication.processEvents()
41171
41443
 
41172
41444
  try:
@@ -41184,7 +41456,7 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
41184
41456
  else:
41185
41457
  no_match_count += 1
41186
41458
  else:
41187
- # Fuzzy matching enabled - get best match ≥70%
41459
+ # Fuzzy matching enabled - get best match ≥75%
41188
41460
  matches = self.tm_database.search_all(
41189
41461
  segment.source,
41190
41462
  tm_ids=tm_ids,
@@ -41194,7 +41466,7 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
41194
41466
  if matches and len(matches) > 0:
41195
41467
  best_match = matches[0]
41196
41468
  match_pct = best_match.get('match_pct', 0)
41197
- if match_pct >= 70:
41469
+ if match_pct >= 75:
41198
41470
  segment.target = best_match.get('target', '')
41199
41471
  segment.status = "Translated"
41200
41472
  success_count += 1
@@ -41562,9 +41834,9 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
41562
41834
  match_pct = match.get('match_pct', 0)
41563
41835
  tm_match = match.get('target', '')
41564
41836
 
41565
- if match_pct >= 70: # Accept matches 70% and above
41837
+ if match_pct >= 75: # Accept matches 75% and above
41566
41838
  segment.target = tm_match
41567
- segment.status = "translated" if match_pct == 100 else "pre-translated"
41839
+ segment.status = "pretranslated"
41568
41840
  translated_count += 1
41569
41841
 
41570
41842
  # Update grid immediately
@@ -41678,7 +41950,7 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
41678
41950
  if tm_match and len(tm_match) > 0:
41679
41951
  translation = tm_match[0]['target']
41680
41952
  segment.target = translation
41681
- segment.status = 'pre-translated'
41953
+ segment.status = 'pretranslated'
41682
41954
  translated_count += 1
41683
41955
  self.log(f" ✓ Segment {segment.id}: {segment.source[:40]}... → {translation[:40]}... (TM 100%)")
41684
41956
  else:
@@ -41725,7 +41997,7 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
41725
41997
 
41726
41998
  if translation and not translation.startswith('['): # Skip error messages
41727
41999
  segment.target = translation
41728
- segment.status = 'pre-translated'
42000
+ segment.status = 'pretranslated'
41729
42001
  translated_count += 1
41730
42002
  self.log(f" ✓ Segment {segment.id}: {segment.source[:40]}... → {translation[:40]}...")
41731
42003
  else:
@@ -17,12 +17,38 @@ import sqlite3
17
17
  import os
18
18
  import json
19
19
  import hashlib
20
+ import unicodedata
21
+ import re
20
22
  from datetime import datetime
21
23
  from typing import List, Dict, Optional, Tuple
22
24
  from pathlib import Path
23
25
  from difflib import SequenceMatcher
24
26
 
25
27
 
28
+ def _normalize_for_matching(text: str) -> str:
29
+ """Normalize text for exact matching.
30
+
31
+ Handles invisible differences that would cause exact match to fail:
32
+ - Unicode normalization (NFC)
33
+ - Multiple whitespace -> single space
34
+ - Leading/trailing whitespace
35
+ - Non-breaking spaces -> regular spaces
36
+ """
37
+ if not text:
38
+ return ""
39
+ # Unicode normalize (NFC form)
40
+ text = unicodedata.normalize('NFC', text)
41
+ # Convert non-breaking spaces and other whitespace to regular space
42
+ text = text.replace('\u00a0', ' ') # NBSP
43
+ text = text.replace('\u2007', ' ') # Figure space
44
+ text = text.replace('\u202f', ' ') # Narrow NBSP
45
+ # Collapse multiple whitespace to single space
46
+ text = re.sub(r'\s+', ' ', text)
47
+ # Strip leading/trailing whitespace
48
+ text = text.strip()
49
+ return text
50
+
51
+
26
52
  class DatabaseManager:
27
53
  """Manages SQLite database for translation resources"""
28
54
 
@@ -655,22 +681,46 @@ class DatabaseManager:
655
681
  # TRANSLATION MEMORY METHODS
656
682
  # ============================================
657
683
 
658
- def add_translation_unit(self, source: str, target: str, source_lang: str,
684
+ def add_translation_unit(self, source: str, target: str, source_lang: str,
659
685
  target_lang: str, tm_id: str = 'project',
660
686
  project_id: str = None, context_before: str = None,
661
- context_after: str = None, notes: str = None) -> int:
687
+ context_after: str = None, notes: str = None,
688
+ overwrite: bool = False) -> int:
662
689
  """
663
690
  Add translation unit to database
664
-
691
+
692
+ Args:
693
+ source: Source text
694
+ target: Target text
695
+ source_lang: Source language code
696
+ target_lang: Target language code
697
+ tm_id: TM identifier
698
+ project_id: Optional project ID
699
+ context_before: Optional context before
700
+ context_after: Optional context after
701
+ notes: Optional notes
702
+ overwrite: If True, delete existing entries with same source before inserting
703
+ (implements "Save only latest translation" mode)
704
+
665
705
  Returns: ID of inserted/updated entry
666
706
  """
667
- # Generate hash for fast exact matching
668
- source_hash = hashlib.md5(source.encode('utf-8')).hexdigest()
669
-
707
+ # Generate hash from NORMALIZED source for consistent exact matching
708
+ # This handles invisible differences like Unicode normalization, whitespace variations
709
+ normalized_source = _normalize_for_matching(source)
710
+ source_hash = hashlib.md5(normalized_source.encode('utf-8')).hexdigest()
711
+
670
712
  try:
713
+ # If overwrite mode, delete ALL existing entries with same source_hash and tm_id
714
+ # This ensures only the latest translation is kept
715
+ if overwrite:
716
+ self.cursor.execute("""
717
+ DELETE FROM translation_units
718
+ WHERE source_hash = ? AND tm_id = ?
719
+ """, (source_hash, tm_id))
720
+
671
721
  self.cursor.execute("""
672
- INSERT INTO translation_units
673
- (source_text, target_text, source_lang, target_lang, tm_id,
722
+ INSERT INTO translation_units
723
+ (source_text, target_text, source_lang, target_lang, tm_id,
674
724
  project_id, context_before, context_after, source_hash, notes)
675
725
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
676
726
  ON CONFLICT(source_hash, target_text, tm_id) DO UPDATE SET
@@ -678,42 +728,47 @@ class DatabaseManager:
678
728
  modified_date = CURRENT_TIMESTAMP
679
729
  """, (source, target, source_lang, target_lang, tm_id,
680
730
  project_id, context_before, context_after, source_hash, notes))
681
-
731
+
682
732
  self.connection.commit()
683
733
  return self.cursor.lastrowid
684
-
734
+
685
735
  except Exception as e:
686
736
  self.log(f"Error adding translation unit: {e}")
687
737
  return None
688
738
 
689
739
  def get_exact_match(self, source: str, tm_ids: List[str] = None,
690
- source_lang: str = None, target_lang: str = None,
740
+ source_lang: str = None, target_lang: str = None,
691
741
  bidirectional: bool = True) -> Optional[Dict]:
692
742
  """
693
743
  Get exact match from TM
694
-
744
+
695
745
  Args:
696
746
  source: Source text to match
697
747
  tm_ids: List of TM IDs to search (None = all)
698
748
  source_lang: Filter by source language (base code matching: 'en' matches 'en-US', 'en-GB', etc.)
699
749
  target_lang: Filter by target language (base code matching)
700
750
  bidirectional: If True, search both directions (nl→en AND en→nl)
701
-
751
+
702
752
  Returns: Dictionary with match data or None
703
753
  """
704
754
  from modules.tmx_generator import get_base_lang_code
705
-
755
+
756
+ # Try both normalized and non-normalized hashes for backward compatibility
757
+ # This handles invisible differences like Unicode normalization, whitespace variations
706
758
  source_hash = hashlib.md5(source.encode('utf-8')).hexdigest()
707
-
759
+ normalized_source = _normalize_for_matching(source)
760
+ normalized_hash = hashlib.md5(normalized_source.encode('utf-8')).hexdigest()
761
+
708
762
  # Get base language codes for comparison
709
763
  src_base = get_base_lang_code(source_lang) if source_lang else None
710
764
  tgt_base = get_base_lang_code(target_lang) if target_lang else None
711
-
765
+
766
+ # Search using both original hash and normalized hash
712
767
  query = """
713
- SELECT * FROM translation_units
714
- WHERE source_hash = ? AND source_text = ?
768
+ SELECT * FROM translation_units
769
+ WHERE (source_hash = ? OR source_hash = ?)
715
770
  """
716
- params = [source_hash, source]
771
+ params = [source_hash, normalized_hash]
717
772
 
718
773
  if tm_ids:
719
774
  placeholders = ','.join('?' * len(tm_ids))
@@ -123,8 +123,8 @@ class TMDatabase:
123
123
  if source_lang and target_lang:
124
124
  self.set_tm_languages(source_lang, target_lang)
125
125
 
126
- # Global fuzzy threshold (70% minimum similarity for fuzzy matches)
127
- self.fuzzy_threshold = 0.7
126
+ # Global fuzzy threshold (75% minimum similarity for fuzzy matches)
127
+ self.fuzzy_threshold = 0.75
128
128
 
129
129
  # TM metadata cache (populated from database as needed)
130
130
  # Note: Legacy 'project' and 'big_mama' TMs are no longer used.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: supervertaler
3
- Version: 1.9.172
3
+ Version: 1.9.174
4
4
  Summary: Professional AI-enhanced 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.172
74
+ # 🚀 Supervertaler v1.9.174
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/)
@@ -80,15 +80,24 @@ Dynamic: requires-python
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
82
 
83
- **Current Version:** v1.9.172 (January 28, 2026)
83
+ **Current Version:** v1.9.174 (January 28, 2026)
84
84
 
85
- ### FIXED in v1.9.172 - 🐛 Fresh Projects Start Clean
85
+ ### NEW in v1.9.174 - 🏷️ Batch Status Change, Ctrl+, Enhancements, TM Fixes
86
+
87
+ - **Batch Status Change**: Change status of multiple selected segments via right-click or Edit → Bulk Operations → Change Status.
88
+ - **Ctrl+, Tag Wrapping**: Now wraps selected text with HTML tag pairs (`<b>...</b>`) in addition to memoQ/CafeTran tags.
89
+ - **TM Overwrite Mode Fixed**: "Save only latest translation" now actually overwrites existing entries.
90
+ - **Shift+Click Multi-Select Fixed**: Grid range selection now works correctly.
91
+ - **Status Dropdown Width**: No more truncated text in status dropdown.
86
92
 
87
- - **Fresh Projects Start Clean**: Fixed bug where TMs and glossaries remained activated from previous sessions when loading/creating new projects. Now all resources are properly deactivated, giving you a clean slate.
93
+ ### IMPROVED in v1.9.173 - 🎯 Smarter TM Pre-Translation
88
94
 
89
- ### FIXED in v1.9.171 - 🐛 TM Target & Alt+0 Badge Regression
95
+ - **Smarter TM Exact Matching**: Exact matches now use text normalization, so matches are found even with whitespace/Unicode differences.
96
+ - **Improved Pre-Translation Dialog**: Shows current segment, match count, elapsed time, and patience message for large jobs.
97
+
98
+ ### FIXED in v1.9.172 - 🐛 Fresh Projects Start Clean
90
99
 
91
- - **TM Target & Alt+0 Badge Restored**: Fixed regression where the TM Target and its blue "0" badge (Alt+0 shortcut) were missing from the Match Panel.
100
+ - **Fresh Projects Start Clean**: Fixed bug where TMs and glossaries remained activated from previous sessions.
92
101
 
93
102
  ### NEW in v1.9.170 - 📝 Scratchpad Tab, Cache Defaults, TM Target Shortcut Badge
94
103
 
@@ -1,4 +1,4 @@
1
- Supervertaler.py,sha256=QN11SrXGKNdKhXFRwgkumm6IRI6Q9vg_S2yj2Qbd3K4,2286283
1
+ Supervertaler.py,sha256=P2n_d1jzUgaIOpQ0z_pTHqRQbifUYBPwAUkWDh_FkRY,2298190
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=juZlrW3UPkIkcnj0SREgOQkQROLf0fcu3ShZcKXMxsI,11361
@@ -6,7 +6,7 @@ modules/ai_file_viewer_dialog.py,sha256=lKKqUUlOEVgHmmu6aRxqH7P6ds-7dRLk4ltDyjCw
6
6
  modules/autofingers_engine.py,sha256=eJ7tBi7YJvTToe5hYTfnyGXB-qme_cHrOPZibaoR2Xw,17061
7
7
  modules/cafetran_docx_handler.py,sha256=_F7Jh0WPVaDnMhdxEsVSXuD1fN9r-S_V6i0gr86Pdfc,14076
8
8
  modules/config_manager.py,sha256=MkPY3xVFgFDkcwewLREg4BfyKueO0OJkT1cTLxehcjM,17894
9
- modules/database_manager.py,sha256=ZdsiuwF67lh-FPKPdalWsW9t6IieX_FM0fA2Bca1xSQ,80221
9
+ modules/database_manager.py,sha256=FecvNLJa_mw0PZld8-LozUSSgtAq6wDZ8Mhq_u2aiA0,82563
10
10
  modules/database_migrations.py,sha256=Y1onFsLDV_6vzJLOpNy3WCZDohBZ2jc4prM-g2_RwLE,14085
11
11
  modules/dejavurtf_handler.py,sha256=8NZPPYtHga40SZCypHjPoJPmZTvm9rD-eEUUab7mjtg,28156
12
12
  modules/document_analyzer.py,sha256=t1rVvqLaTcpQTEja228C7zZnh8dXshK4wA9t1E9aGVk,19524
@@ -69,7 +69,7 @@ modules/tmx_editor_qt.py,sha256=PxBIUw_06PHYTBHsd8hZzVJXW8T0A0ljfz1Wjjsa4yU,1170
69
69
  modules/tmx_generator.py,sha256=pNkxwdMLvSRMMru0lkB1gvViIpg9BQy1EVhRbwoef3k,9426
70
70
  modules/tracked_changes.py,sha256=S_BIEC6r7wVAwjG42aSy_RgH4KaMAC8GS5thEvqrYdE,39480
71
71
  modules/trados_docx_handler.py,sha256=VPRAQ73cUHs_SEj6x81z1PmSxfjnwPBp9P4fXeK3KpQ,16363
72
- modules/translation_memory.py,sha256=k0GtO6ANTqxI1XMcv3D5mdAoTgcWlDT5iVsYHizKNUM,28738
72
+ modules/translation_memory.py,sha256=13PDK4_kgYrWTACWBIBypOh2DvoxY9cRT8U6ulilbh4,28739
73
73
  modules/translation_results_panel.py,sha256=DmEe0pZRSfcZFg2cWeEREK7H9vrTcPkgeuMW54Pgrys,92505
74
74
  modules/translation_services.py,sha256=lyVpWuZK1wtVtYZMDMdLoq1DHBoSaeAnp-Yejb0TlVQ,10530
75
75
  modules/unified_prompt_library.py,sha256=lzbevgjUz_qCiYSf141BB0mmuaDhSsevWju_a7welu0,26008
@@ -77,9 +77,9 @@ modules/unified_prompt_manager_qt.py,sha256=fyF3_r0N8hnImT-CcWo1AuBOQ1Dn_ExeeUCk
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.172.dist-info/licenses/LICENSE,sha256=m28u-4qL5nXIWnJ6xlQVw__H30rWFtRK3pCOais2OuY,1092
81
- supervertaler-1.9.172.dist-info/METADATA,sha256=cDxLhwPym42J-CQfuc07BvtDUoj231w1Lyfz2_iIN6Y,48267
82
- supervertaler-1.9.172.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
83
- supervertaler-1.9.172.dist-info/entry_points.txt,sha256=NP4hiCvx-_30YYKqgr-jfJYQvHr1qTYBMfoVmKIXSM8,53
84
- supervertaler-1.9.172.dist-info/top_level.txt,sha256=9tUHBYUSfaE4S2E4W3eavJsDyYymkwLfeWAHHAPT6Dk,22
85
- supervertaler-1.9.172.dist-info/RECORD,,
80
+ supervertaler-1.9.174.dist-info/licenses/LICENSE,sha256=m28u-4qL5nXIWnJ6xlQVw__H30rWFtRK3pCOais2OuY,1092
81
+ supervertaler-1.9.174.dist-info/METADATA,sha256=MjFlkP5U4cAhkgRjfHo7MMcCSOrl3U5fXFEaViQ-iOU,48873
82
+ supervertaler-1.9.174.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
83
+ supervertaler-1.9.174.dist-info/entry_points.txt,sha256=NP4hiCvx-_30YYKqgr-jfJYQvHr1qTYBMfoVmKIXSM8,53
84
+ supervertaler-1.9.174.dist-info/top_level.txt,sha256=9tUHBYUSfaE4S2E4W3eavJsDyYymkwLfeWAHHAPT6Dk,22
85
+ supervertaler-1.9.174.dist-info/RECORD,,