supervertaler 1.9.109__py3-none-any.whl → 1.9.131__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,8 +3,8 @@ 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.104 (Packaging + docs)
7
- Release Date: January 14, 2026
6
+ Version: 1.9.123 (QuickMenu now supports generic AI tasks)
7
+ Release Date: January 19, 2026
8
8
  Framework: PyQt6
9
9
 
10
10
  This is the modern edition of Supervertaler using PyQt6 framework.
@@ -34,9 +34,9 @@ License: MIT
34
34
  """
35
35
 
36
36
  # Version Information.
37
- __version__ = "1.9.109"
37
+ __version__ = "1.9.131"
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
@@ -123,10 +123,10 @@ try:
123
123
  QButtonGroup, QDialogButtonBox, QTabWidget, QGroupBox, QGridLayout, QCheckBox,
124
124
  QProgressBar, QProgressDialog, QFormLayout, QTabBar, QPlainTextEdit, QAbstractItemDelegate,
125
125
  QFrame, QListWidget, QListWidgetItem, QStackedWidget, QTreeWidget, QTreeWidgetItem,
126
- QScrollArea, QSizePolicy, QSlider, QToolButton
126
+ QScrollArea, QSizePolicy, QSlider, QToolButton, QAbstractItemView
127
127
  )
128
128
  from PyQt6.QtCore import Qt, QSize, QTimer, pyqtSignal, QObject, QUrl
129
- from PyQt6.QtGui import QFont, QAction, QKeySequence, QIcon, QTextOption, QColor, QDesktopServices, QTextCharFormat, QTextCursor, QBrush, QSyntaxHighlighter, QPalette, QTextBlockFormat
129
+ from PyQt6.QtGui import QFont, QAction, QKeySequence, QIcon, QTextOption, QColor, QDesktopServices, QTextCharFormat, QTextCursor, QBrush, QSyntaxHighlighter, QPalette, QTextBlockFormat, QCursor
130
130
  from PyQt6.QtWidgets import QStyleOptionViewItem, QStyle
131
131
  from PyQt6.QtCore import QRectF
132
132
  from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
@@ -170,11 +170,11 @@ def cleanup_ahk_process():
170
170
  try:
171
171
  _ahk_process.terminate()
172
172
  _ahk_process.wait(timeout=1)
173
- print("[Superlookup] AHK process terminated on exit")
173
+ print("[Hotkeys] AHK process terminated on exit")
174
174
  except:
175
175
  try:
176
176
  _ahk_process.kill()
177
- print("[Superlookup] AHK process killed on exit")
177
+ print("[Hotkeys] AHK process killed on exit")
178
178
  except:
179
179
  pass
180
180
 
@@ -910,6 +910,7 @@ class _CtrlReturnEventFilter(QObject):
910
910
 
911
911
  return False
912
912
 
913
+
913
914
  class GridTableEventFilter:
914
915
  """Mixin to pass keyboard shortcuts from editor to table"""
915
916
  pass
@@ -2559,7 +2560,7 @@ class EditableGridTextEditor(QTextEdit):
2559
2560
  menu.addSeparator()
2560
2561
 
2561
2562
  # Add to dictionary action
2562
- add_to_dict_action = QAction(f"📖 Add '{misspelled_word}' to Dictionary", self)
2563
+ add_to_dict_action = QAction(f"📖 Add '{misspelled_word}' to Dictionary (Alt+D)", self)
2563
2564
  add_to_dict_action.triggered.connect(
2564
2565
  lambda checked, w=misspelled_word: self._add_to_dictionary(w)
2565
2566
  )
@@ -5322,7 +5323,7 @@ class SupervertalerQt(QMainWindow):
5322
5323
  dialog.exec()
5323
5324
 
5324
5325
  # Navigate to Settings → Features tab
5325
- self.main_tabs.setCurrentIndex(3) # Settings tab
5326
+ self.main_tabs.setCurrentIndex(4) # Settings tab
5326
5327
  if hasattr(self, 'settings_tabs'):
5327
5328
  # Find the Features tab index
5328
5329
  for i in range(self.settings_tabs.count()):
@@ -5592,6 +5593,11 @@ class SupervertalerQt(QMainWindow):
5592
5593
  self.termview_shortcuts = []
5593
5594
  self._termview_last_key = None # Track last key for double-tap detection
5594
5595
  self._termview_last_time = 0 # Track timing for double-tap
5596
+
5597
+ # Double-tap Shift detection for context menu
5598
+ self._shift_last_press_time = 0
5599
+ self._shift_double_tap_threshold = 0.35 # 350ms window
5600
+
5595
5601
  for i in range(0, 10): # 0-9
5596
5602
  shortcut_id = f"termview_insert_{i}"
5597
5603
  default_key = f"Alt+{i}"
@@ -5635,6 +5641,9 @@ class SupervertalerQt(QMainWindow):
5635
5641
  self._ctrl_return_event_filter = _CtrlReturnEventFilter(self)
5636
5642
  QApplication.instance().installEventFilter(self._ctrl_return_event_filter)
5637
5643
 
5644
+ # Note: Double-tap Shift for context menu is handled by AutoHotkey (double_shift_menu.ahk)
5645
+ # Qt's event system makes reliable double-tap detection difficult in Python
5646
+
5638
5647
  # Ctrl+Shift+Enter - Always confirm all selected segments
5639
5648
  create_shortcut("editor_confirm_selected", "Ctrl+Shift+Return", self.confirm_selected_segments)
5640
5649
 
@@ -5668,6 +5677,84 @@ class SupervertalerQt(QMainWindow):
5668
5677
 
5669
5678
  # Ctrl+Shift+2 - Quick add term with Priority 2
5670
5679
  create_shortcut("editor_quick_add_priority_2", "Ctrl+Shift+2", lambda: self._quick_add_term_with_priority(2))
5680
+
5681
+ # Alt+D - Add word at cursor to dictionary
5682
+ create_shortcut("editor_add_to_dictionary", "Alt+D", self.add_word_to_dictionary_shortcut)
5683
+
5684
+ # Ctrl+N - Focus Segment Note tab
5685
+ create_shortcut("editor_focus_notes", "Ctrl+N", self.focus_segment_notes)
5686
+
5687
+ # Alt+K - Open QuickMenu directly
5688
+ create_shortcut("editor_open_quickmenu", "Alt+K", self.open_quickmenu)
5689
+
5690
+ def focus_segment_notes(self):
5691
+ """Switch to Segment Note tab and focus the notes editor so user can start typing immediately"""
5692
+ if not hasattr(self, 'bottom_tabs'):
5693
+ return
5694
+
5695
+ # Switch to Segment note tab (index 1)
5696
+ self.bottom_tabs.setCurrentIndex(1)
5697
+
5698
+ # Focus the notes editor so user can start typing
5699
+ if hasattr(self, 'bottom_notes_edit'):
5700
+ self.bottom_notes_edit.setFocus()
5701
+
5702
+ def open_quickmenu(self):
5703
+ """Open QuickMenu popup at current cursor position for quick AI prompt selection.
5704
+
5705
+ User can navigate with arrow keys and press Enter to select a prompt.
5706
+ """
5707
+ try:
5708
+ # Get QuickMenu items from prompt library
5709
+ quickmenu_items = []
5710
+ if hasattr(self, 'prompt_manager_qt') and self.prompt_manager_qt:
5711
+ lib = getattr(self.prompt_manager_qt, 'library', None)
5712
+ if lib and hasattr(lib, 'get_quickmenu_grid_prompts'):
5713
+ quickmenu_items = lib.get_quickmenu_grid_prompts() or []
5714
+
5715
+ if not quickmenu_items:
5716
+ self.log("⚠️ No QuickMenu prompts available. Add prompts with 'Show in Supervertaler QuickMenu' enabled.")
5717
+ return
5718
+
5719
+ # Find the currently focused widget (source or target cell)
5720
+ focus_widget = QApplication.focusWidget()
5721
+
5722
+ # Build the menu
5723
+ menu = QMenu(self)
5724
+ menu.setTitle("⚡ QuickMenu")
5725
+
5726
+ for rel_path, label in sorted(quickmenu_items, key=lambda x: (x[1] or x[0]).lower()):
5727
+ prompt_menu = menu.addMenu(label or rel_path)
5728
+
5729
+ run_show = QAction("▶ Run (show response)…", self)
5730
+ run_show.triggered.connect(
5731
+ lambda checked=False, p=rel_path, w=focus_widget: self.run_grid_quickmenu_prompt(p, origin_widget=w, behavior="show")
5732
+ )
5733
+ prompt_menu.addAction(run_show)
5734
+
5735
+ run_replace = QAction("↺ Run and replace target selection", self)
5736
+ run_replace.triggered.connect(
5737
+ lambda checked=False, p=rel_path, w=focus_widget: self.run_grid_quickmenu_prompt(p, origin_widget=w, behavior="replace")
5738
+ )
5739
+ prompt_menu.addAction(run_replace)
5740
+
5741
+ # Show menu at cursor position (or center of focused widget)
5742
+ if focus_widget:
5743
+ # Get cursor rectangle if it's a text editor
5744
+ if hasattr(focus_widget, 'cursorRect'):
5745
+ cursor_rect = focus_widget.cursorRect()
5746
+ pos = focus_widget.mapToGlobal(cursor_rect.bottomLeft())
5747
+ else:
5748
+ # Fallback to center of widget
5749
+ pos = focus_widget.mapToGlobal(focus_widget.rect().center())
5750
+ else:
5751
+ # Fallback to mouse cursor position
5752
+ pos = QCursor.pos()
5753
+
5754
+ menu.exec(pos)
5755
+
5756
+ except Exception as e:
5757
+ self.log(f"❌ Error opening QuickMenu: {e}")
5671
5758
 
5672
5759
  def refresh_shortcut_enabled_states(self):
5673
5760
  """Refresh enabled/disabled states and key bindings of all global shortcuts from shortcut manager.
@@ -6085,8 +6172,8 @@ class SupervertalerQt(QMainWindow):
6085
6172
  export_txt_action.triggered.connect(self.export_simple_txt)
6086
6173
  export_menu.addAction(export_txt_action)
6087
6174
 
6088
- export_ai_action = QAction("🤖 &AI-Readable Format (TXT)...", self)
6089
- export_ai_action.triggered.connect(self.export_for_ai)
6175
+ export_ai_action = QAction("📄 &AI-Readable Markdown (.md)...", self)
6176
+ export_ai_action.triggered.connect(self.export_bilingual_table_markdown)
6090
6177
  export_ai_action.setToolTip("Export segments in [SEGMENT] format for AI translation/review")
6091
6178
  export_menu.addAction(export_ai_action)
6092
6179
 
@@ -6307,12 +6394,16 @@ class SupervertalerQt(QMainWindow):
6307
6394
  go_resources_action.triggered.connect(lambda: self.main_tabs.setCurrentIndex(1) if hasattr(self, 'main_tabs') else None)
6308
6395
  nav_menu.addAction(go_resources_action)
6309
6396
 
6397
+ go_prompt_manager_action = QAction("🤖 &Prompt Manager", self)
6398
+ go_prompt_manager_action.triggered.connect(lambda: self.main_tabs.setCurrentIndex(2) if hasattr(self, 'main_tabs') else None)
6399
+ nav_menu.addAction(go_prompt_manager_action)
6400
+
6310
6401
  go_tools_action = QAction("🛠️ &Tools", self)
6311
- go_tools_action.triggered.connect(lambda: self.main_tabs.setCurrentIndex(2) if hasattr(self, 'main_tabs') else None)
6402
+ go_tools_action.triggered.connect(lambda: self.main_tabs.setCurrentIndex(3) if hasattr(self, 'main_tabs') else None)
6312
6403
  nav_menu.addAction(go_tools_action)
6313
6404
 
6314
6405
  go_settings_action = QAction("⚙️ &Settings", self)
6315
- go_settings_action.triggered.connect(lambda: self.main_tabs.setCurrentIndex(3) if hasattr(self, 'main_tabs') else None)
6406
+ go_settings_action.triggered.connect(lambda: self.main_tabs.setCurrentIndex(4) if hasattr(self, 'main_tabs') else None)
6316
6407
  nav_menu.addAction(go_settings_action)
6317
6408
 
6318
6409
  view_menu.addSeparator()
@@ -6415,11 +6506,53 @@ class SupervertalerQt(QMainWindow):
6415
6506
  # Tools Menu
6416
6507
  tools_menu = menubar.addMenu("&Tools")
6417
6508
 
6418
- autofingers_action = QAction("✋ &AutoFingers - CAT Tool Automation...", self)
6509
+ # Tools in same order as Tools tab
6510
+ autofingers_action = QAction("✋ &AutoFingers...", self)
6419
6511
  autofingers_action.setShortcut("Ctrl+Shift+A")
6420
6512
  autofingers_action.triggered.connect(self.show_autofingers)
6421
6513
  tools_menu.addAction(autofingers_action)
6422
6514
 
6515
+ superconverter_action = QAction("🔄 Super&converter...", self)
6516
+ superconverter_action.triggered.connect(lambda: self._navigate_to_tool("Superconverter"))
6517
+ tools_menu.addAction(superconverter_action)
6518
+
6519
+ pdf_rescue_action = QAction("📄 &PDF Rescue...", self)
6520
+ pdf_rescue_action.triggered.connect(lambda: self._navigate_to_tool("PDF Rescue"))
6521
+ tools_menu.addAction(pdf_rescue_action)
6522
+
6523
+ superbench_action = QAction("📊 Super&bench...", self)
6524
+ superbench_action.triggered.connect(lambda: self._navigate_to_tool("Superbench"))
6525
+ tools_menu.addAction(superbench_action)
6526
+
6527
+ superbrowser_action = QAction("🌐 Super&browser...", self)
6528
+ superbrowser_action.triggered.connect(lambda: self._navigate_to_tool("Superbrowser"))
6529
+ tools_menu.addAction(superbrowser_action)
6530
+
6531
+ supercleaner_action = QAction("🧹 Supercleaner...", self)
6532
+ supercleaner_action.triggered.connect(lambda: self._navigate_to_tool("Supercleaner"))
6533
+ tools_menu.addAction(supercleaner_action)
6534
+
6535
+ superlookup_action = QAction("🔍 Super&lookup...", self)
6536
+ superlookup_action.setShortcut("Ctrl+K")
6537
+ superlookup_action.triggered.connect(self._go_to_superlookup)
6538
+ tools_menu.addAction(superlookup_action)
6539
+
6540
+ supervoice_action = QAction("🎤 Super&voice...", self)
6541
+ supervoice_action.triggered.connect(lambda: self._navigate_to_tool("Supervoice"))
6542
+ tools_menu.addAction(supervoice_action)
6543
+
6544
+ encoding_action = QAction("🔧 &Text Encoding Repair...", self)
6545
+ encoding_action.triggered.connect(lambda: self._navigate_to_tool("Text Encoding Repair"))
6546
+ tools_menu.addAction(encoding_action)
6547
+
6548
+ tmx_editor_action = QAction("✏️ T&MX Editor...", self)
6549
+ tmx_editor_action.triggered.connect(lambda: self._navigate_to_tool("TMX Editor"))
6550
+ tools_menu.addAction(tmx_editor_action)
6551
+
6552
+ tracked_changes_action = QAction("🔄 Tracked &Changes...", self)
6553
+ tracked_changes_action.triggered.connect(lambda: self._navigate_to_tool("Tracked Changes"))
6554
+ tools_menu.addAction(tracked_changes_action)
6555
+
6423
6556
  tools_menu.addSeparator()
6424
6557
 
6425
6558
  image_extractor_action = QAction("🖼️ &Image Extractor (Superimage)...", self)
@@ -6758,6 +6891,9 @@ class SupervertalerQt(QMainWindow):
6758
6891
  settings_tab = self.create_settings_tab()
6759
6892
  self.main_tabs.addTab(settings_tab, "⚙️ Settings")
6760
6893
 
6894
+ # Set startup tab to Grid (index 0)
6895
+ self.main_tabs.setCurrentIndex(0)
6896
+
6761
6897
  main_layout.addWidget(self.main_tabs)
6762
6898
 
6763
6899
  # Connect tab changes to handle view refreshes
@@ -7282,6 +7418,143 @@ class SupervertalerQt(QMainWindow):
7282
7418
 
7283
7419
  return prompt_widget
7284
7420
 
7421
+
7422
+ def create_superconverter_tab(self) -> QWidget:
7423
+ """Create the Superconverter tab - Format conversion tools"""
7424
+ container = QWidget()
7425
+ main_layout = QVBoxLayout(container)
7426
+ main_layout.setContentsMargins(10, 10, 10, 10)
7427
+ main_layout.setSpacing(10)
7428
+
7429
+ # Header
7430
+ header = QLabel("🔄 Superconverter")
7431
+ header.setStyleSheet("font-size: 16pt; font-weight: bold; color: #1976D2;")
7432
+ main_layout.addWidget(header)
7433
+
7434
+ # Description
7435
+ description = QLabel(
7436
+ "Convert translation data between different formats - perfect for AI workflows, CAT tool exchanges, and data processing."
7437
+ )
7438
+ description.setWordWrap(True)
7439
+ description.setStyleSheet("color: #666; padding: 5px; background-color: #E3F2FD; border-radius: 3px;")
7440
+ main_layout.addWidget(description)
7441
+
7442
+ main_layout.addSpacing(5)
7443
+
7444
+ # Create tabbed interface for different conversion types
7445
+ tabs = QTabWidget()
7446
+ tabs.setStyleSheet("QTabBar::tab { outline: 0; } QTabBar::tab:focus { outline: none; }")
7447
+
7448
+ # Tab 1: Bilingual Export (current project)
7449
+ bilingual_tab = QWidget()
7450
+ bilingual_layout = QVBoxLayout(bilingual_tab)
7451
+ bilingual_layout.setContentsMargins(15, 15, 15, 15)
7452
+
7453
+ bilingual_info = QLabel(
7454
+ "<b>Export Current Project</b><br><br>"
7455
+ "Export your translation project as a Markdown table - perfect for AI chat interfaces like ChatGPT and Claude.<br><br>"
7456
+ "The table format renders beautifully and makes it easy for AI to understand and process your segments."
7457
+ )
7458
+ bilingual_info.setWordWrap(True)
7459
+ bilingual_info.setTextFormat(Qt.TextFormat.RichText)
7460
+ bilingual_layout.addWidget(bilingual_info)
7461
+
7462
+ bilingual_layout.addSpacing(15)
7463
+
7464
+ bilingual_btn = QPushButton("📄 Export as Markdown Table")
7465
+ bilingual_btn.setMinimumHeight(40)
7466
+ bilingual_btn.setStyleSheet(
7467
+ "QPushButton { background-color: #2196F3; color: white; font-weight: bold; "
7468
+ "border: none; border-radius: 5px; padding: 10px; outline: none; }"
7469
+ "QPushButton:hover { background-color: #1976D2; }"
7470
+ )
7471
+ bilingual_btn.clicked.connect(self.export_bilingual_table_markdown)
7472
+ bilingual_layout.addWidget(bilingual_btn)
7473
+
7474
+ bilingual_layout.addStretch()
7475
+ tabs.addTab(bilingual_tab, "📊 Bilingual Table")
7476
+
7477
+ # Tab 2: Document Converter (monolingual docs to Markdown)
7478
+ doc_tab = QWidget()
7479
+ doc_layout = QVBoxLayout(doc_tab)
7480
+ doc_layout.setContentsMargins(15, 15, 15, 15)
7481
+
7482
+ doc_info = QLabel(
7483
+ "<b>Convert Documents to Markdown</b><br><br>"
7484
+ "Convert DOCX or TXT documents to Markdown format, preserving structure (headings, lists, paragraphs).<br><br>"
7485
+ "Perfect for preparing documents for AI processing or publishing on web platforms."
7486
+ )
7487
+ doc_info.setWordWrap(True)
7488
+ doc_info.setTextFormat(Qt.TextFormat.RichText)
7489
+ doc_layout.addWidget(doc_info)
7490
+
7491
+ doc_layout.addSpacing(15)
7492
+
7493
+ # Single file button
7494
+ single_doc_btn = QPushButton("📄 Convert Single Document")
7495
+ single_doc_btn.setMinimumHeight(40)
7496
+ single_doc_btn.setStyleSheet(
7497
+ "QPushButton { background-color: #4CAF50; color: white; font-weight: bold; "
7498
+ "border: none; border-radius: 5px; padding: 10px; outline: none; }"
7499
+ "QPushButton:hover { background-color: #388E3C; }"
7500
+ )
7501
+ single_doc_btn.clicked.connect(self.convert_document_to_markdown)
7502
+ doc_layout.addWidget(single_doc_btn)
7503
+
7504
+ doc_layout.addSpacing(10)
7505
+
7506
+ # Batch conversion button
7507
+ batch_doc_btn = QPushButton("📁 Batch Convert Multiple Documents")
7508
+ batch_doc_btn.setMinimumHeight(40)
7509
+ batch_doc_btn.setStyleSheet(
7510
+ "QPushButton { background-color: #FF9800; color: white; font-weight: bold; "
7511
+ "border: none; border-radius: 5px; padding: 10px; outline: none; }"
7512
+ "QPushButton:hover { background-color: #F57C00; }"
7513
+ )
7514
+ batch_doc_btn.clicked.connect(self.batch_convert_documents_to_markdown)
7515
+ doc_layout.addWidget(batch_doc_btn)
7516
+
7517
+ doc_layout.addStretch()
7518
+ tabs.addTab(doc_tab, "📝 Document → Markdown")
7519
+
7520
+ # Tab 3: TMX Tools (placeholder for future)
7521
+ tmx_tab = QWidget()
7522
+ tmx_layout = QVBoxLayout(tmx_tab)
7523
+ tmx_layout.setContentsMargins(15, 15, 15, 15)
7524
+
7525
+ tmx_info = QLabel(
7526
+ "<b>TMX Conversion Tools</b><br><br>"
7527
+ "Convert Translation Memory eXchange (TMX) files to and from tab-delimited format.<br><br>"
7528
+ "<i>Coming soon...</i>"
7529
+ )
7530
+ tmx_info.setWordWrap(True)
7531
+ tmx_info.setTextFormat(Qt.TextFormat.RichText)
7532
+ tmx_info.setStyleSheet("color: #888;")
7533
+ tmx_layout.addWidget(tmx_info)
7534
+
7535
+ tmx_layout.addSpacing(15)
7536
+
7537
+ tmx_to_tsv_btn = QPushButton("TMX → Tab-delimited")
7538
+ tmx_to_tsv_btn.setMinimumHeight(40)
7539
+ tmx_to_tsv_btn.setEnabled(False)
7540
+ tmx_to_tsv_btn.setStyleSheet("QPushButton:disabled { color: #999; background-color: #E0E0E0; }")
7541
+ tmx_layout.addWidget(tmx_to_tsv_btn)
7542
+
7543
+ tmx_layout.addSpacing(10)
7544
+
7545
+ tsv_to_tmx_btn = QPushButton("Tab-delimited → TMX")
7546
+ tsv_to_tmx_btn.setMinimumHeight(40)
7547
+ tsv_to_tmx_btn.setEnabled(False)
7548
+ tsv_to_tmx_btn.setStyleSheet("QPushButton:disabled { color: #999; background-color: #E0E0E0; }")
7549
+ tmx_layout.addWidget(tsv_to_tmx_btn)
7550
+
7551
+ tmx_layout.addStretch()
7552
+ tabs.addTab(tmx_tab, "🔄 TMX Tools")
7553
+
7554
+ main_layout.addWidget(tabs)
7555
+
7556
+ return container
7557
+
7285
7558
  def create_supercleaner_tab(self) -> QWidget:
7286
7559
  """Create the Supercleaner tab - Clean DOCX documents"""
7287
7560
  from modules.supercleaner_ui import SupercleanerUI
@@ -8412,6 +8685,10 @@ class SupervertalerQt(QMainWindow):
8412
8685
  autofingers_tab = AutoFingersWidget(self)
8413
8686
  modules_tabs.addTab(autofingers_tab, "✋ AutoFingers")
8414
8687
 
8688
+ # Superconverter - Format conversion tools
8689
+ superconverter_tab = self.create_superconverter_tab()
8690
+ modules_tabs.addTab(superconverter_tab, "🔄 Superconverter")
8691
+
8415
8692
  pdf_tab = self.create_pdf_rescue_tab()
8416
8693
  modules_tabs.addTab(pdf_tab, "📄 PDF Rescue")
8417
8694
 
@@ -9734,8 +10011,12 @@ class SupervertalerQt(QMainWindow):
9734
10011
  elif status in ('draft', 'needs_review'):
9735
10012
  run.font.color.rgb = RGBColor(200, 100, 0) # Orange
9736
10013
 
9737
- # Notes column - empty for user to fill
9738
- cells[4].text = ''
10014
+ # Notes column - populate with segment notes if available
10015
+ notes_text = seg.notes if hasattr(seg, 'notes') else ''
10016
+ cells[4].text = notes_text
10017
+ for para in cells[4].paragraphs:
10018
+ for run in para.runs:
10019
+ run.font.size = Pt(8)
9739
10020
 
9740
10021
  # Add footer with branding
9741
10022
  footer_para = doc.add_paragraph()
@@ -10642,6 +10923,38 @@ class SupervertalerQt(QMainWindow):
10642
10923
  self._play_sound_effect('glossary_term_error')
10643
10924
  self.statusBar().showMessage(f"Error adding term: {e}", 3000)
10644
10925
 
10926
+ def add_word_to_dictionary_shortcut(self):
10927
+ """Add word at cursor position to custom dictionary (Alt+D shortcut)
10928
+
10929
+ Finds the misspelled word at the current cursor position and adds it to the
10930
+ custom dictionary. Works when focus is in a grid target cell.
10931
+ """
10932
+ # Get currently focused widget
10933
+ focused_widget = QApplication.focusWidget()
10934
+
10935
+ # Check if we're in an editable grid cell
10936
+ if not isinstance(focused_widget, EditableGridTextEditor):
10937
+ self.statusBar().showMessage("Alt+D: Place cursor on a misspelled word in the target cell first", 3000)
10938
+ return
10939
+
10940
+ # Get cursor position
10941
+ cursor = focused_widget.textCursor()
10942
+
10943
+ # Try to find misspelled word at cursor
10944
+ word_info = focused_widget._get_misspelled_word_at_cursor(cursor)
10945
+
10946
+ if word_info[0] is None:
10947
+ # No misspelled word found at cursor
10948
+ self.statusBar().showMessage("No misspelled word at cursor position", 3000)
10949
+ return
10950
+
10951
+ word, start_pos, end_pos = word_info
10952
+
10953
+ # Add to dictionary
10954
+ focused_widget._add_to_dictionary(word)
10955
+
10956
+ # Status message already shown by _add_to_dictionary
10957
+
10645
10958
  def add_text_to_non_translatables(self, text: str):
10646
10959
  """Add selected text to active non-translatable list(s)"""
10647
10960
  if not text or not text.strip():
@@ -13240,7 +13553,9 @@ class SupervertalerQt(QMainWindow):
13240
13553
 
13241
13554
  # ===== TAB 2: AI Settings (LLM, Ollama) =====
13242
13555
  ai_tab = self._create_ai_settings_tab()
13243
- settings_tabs.addTab(scroll_area_wrapper(ai_tab), "🤖 AI Settings")
13556
+ ai_scroll = scroll_area_wrapper(ai_tab)
13557
+ settings_tabs.addTab(ai_scroll, "🤖 AI Settings")
13558
+ self.ai_settings_scroll = ai_scroll # Store reference for scrolling to API keys
13244
13559
 
13245
13560
  # ===== TAB 3: Language Pair Settings =====
13246
13561
  lang_tab = self._create_language_pair_tab()
@@ -13760,6 +14075,45 @@ class SupervertalerQt(QMainWindow):
13760
14075
  full_context_cb.stateChanged.connect(lambda: context_slider.setEnabled(full_context_cb.isChecked()))
13761
14076
  update_context_label(context_window_size)
13762
14077
 
14078
+ prefs_layout.addSpacing(10)
14079
+
14080
+ # QuickMenu document context
14081
+ quickmenu_context_label = QLabel("<b>QuickMenu Document Context:</b>")
14082
+ prefs_layout.addWidget(quickmenu_context_label)
14083
+
14084
+ quickmenu_context_percent = general_prefs.get('quickmenu_context_percent', 50)
14085
+ quickmenu_context_layout = QHBoxLayout()
14086
+ quickmenu_context_layout.addWidget(QLabel(" Document context size:"))
14087
+ quickmenu_context_slider = QSlider(Qt.Orientation.Horizontal)
14088
+ quickmenu_context_slider.setMinimum(0)
14089
+ quickmenu_context_slider.setMaximum(100)
14090
+ quickmenu_context_slider.setValue(quickmenu_context_percent)
14091
+ quickmenu_context_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
14092
+ quickmenu_context_slider.setTickInterval(10)
14093
+ quickmenu_context_layout.addWidget(quickmenu_context_slider)
14094
+ quickmenu_context_value_label = QLabel(f"{quickmenu_context_percent}%")
14095
+ quickmenu_context_value_label.setMinimumWidth(60)
14096
+ quickmenu_context_layout.addWidget(quickmenu_context_value_label)
14097
+ quickmenu_context_layout.addStretch()
14098
+ prefs_layout.addLayout(quickmenu_context_layout)
14099
+
14100
+ quickmenu_context_info = QLabel(
14101
+ " ⓘ When using {{SOURCE+TARGET_CONTEXT}} or {{SOURCE_CONTEXT}} placeholders in QuickMenu prompts.\n"
14102
+ " 0% = disabled, 50% = half the document (default), 100% = entire document.\n"
14103
+ " Limit: maximum 100 segments for performance."
14104
+ )
14105
+ quickmenu_context_info.setStyleSheet("font-size: 9pt; color: #666; padding-left: 20px;")
14106
+ quickmenu_context_info.setWordWrap(True)
14107
+ prefs_layout.addWidget(quickmenu_context_info)
14108
+
14109
+ def update_quickmenu_context_label(value):
14110
+ if value == 0:
14111
+ quickmenu_context_value_label.setText("0% (disabled)")
14112
+ else:
14113
+ quickmenu_context_value_label.setText(f"{value}%")
14114
+
14115
+ quickmenu_context_slider.valueChanged.connect(update_quickmenu_context_label)
14116
+
13763
14117
  prefs_layout.addSpacing(5)
13764
14118
 
13765
14119
  # Check TM before API call
@@ -13904,7 +14258,8 @@ class SupervertalerQt(QMainWindow):
13904
14258
  batch_size_spin, surrounding_spin, full_context_cb, context_slider,
13905
14259
  check_tm_cb, auto_propagate_cb, delay_spin,
13906
14260
  ollama_keepwarm_cb,
13907
- llm_matching_cb, auto_markdown_cb, llm_spin
14261
+ llm_matching_cb, auto_markdown_cb, llm_spin,
14262
+ quickmenu_context_slider
13908
14263
  ))
13909
14264
  layout.addWidget(save_btn)
13910
14265
 
@@ -16902,7 +17257,8 @@ class SupervertalerQt(QMainWindow):
16902
17257
  batch_size_spin, surrounding_spin, full_context_cb, context_slider,
16903
17258
  check_tm_cb, auto_propagate_cb, delay_spin,
16904
17259
  ollama_keepwarm_cb,
16905
- llm_matching_cb, auto_markdown_cb, llm_spin):
17260
+ llm_matching_cb, auto_markdown_cb, llm_spin,
17261
+ quickmenu_context_slider):
16906
17262
  """Save all AI settings from the unified AI Settings tab"""
16907
17263
  # Determine selected provider
16908
17264
  if openai_radio.isChecked():
@@ -16961,6 +17317,7 @@ class SupervertalerQt(QMainWindow):
16961
17317
  general_prefs['surrounding_segments'] = surrounding_spin.value()
16962
17318
  general_prefs['use_full_context'] = full_context_cb.isChecked()
16963
17319
  general_prefs['context_window_size'] = context_slider.value()
17320
+ general_prefs['quickmenu_context_percent'] = quickmenu_context_slider.value()
16964
17321
  general_prefs['check_tm_before_api'] = check_tm_cb.isChecked()
16965
17322
  general_prefs['auto_propagate_100'] = auto_propagate_cb.isChecked()
16966
17323
  general_prefs['lookup_delay'] = delay_spin.value()
@@ -18502,7 +18859,7 @@ class SupervertalerQt(QMainWindow):
18502
18859
  header.setStretchLastSection(False) # Don't auto-stretch last section (we use Stretch mode for Source/Target)
18503
18860
 
18504
18861
  # Set initial column widths - give Source and Target equal space
18505
- self.table.setColumnWidth(0, 55) # ID - fits 4-digit segment numbers
18862
+ self.table.setColumnWidth(0, 40) # ID - compact, fits up to 3 digits comfortably
18506
18863
  self.table.setColumnWidth(1, 40) # Type - narrower
18507
18864
  self.table.setColumnWidth(2, 400) # Source
18508
18865
  self.table.setColumnWidth(3, 400) # Target
@@ -20922,6 +21279,9 @@ class SupervertalerQt(QMainWindow):
20922
21279
  # Initialize TM for this project
20923
21280
  self.initialize_tm_database()
20924
21281
 
21282
+ # Deactivate all resources for new project (user explicitly activates what they need)
21283
+ self._deactivate_all_resources_for_new_project()
21284
+
20925
21285
  # Auto-resize rows for better initial display
20926
21286
  self.auto_resize_rows()
20927
21287
 
@@ -21670,6 +22030,493 @@ class SupervertalerQt(QMainWindow):
21670
22030
  "Export Error",
21671
22031
  f"Failed to export AI format:\n\n{str(e)}"
21672
22032
  )
22033
+
22034
+ def export_bilingual_table_markdown(self):
22035
+ """Export current project segments as Markdown table (for AI systems)"""
22036
+ try:
22037
+ if not self.current_project or not self.current_project.segments:
22038
+ QMessageBox.warning(self, "No Project", "Please open a project with segments first")
22039
+ return
22040
+
22041
+ segments = list(self.current_project.segments)
22042
+
22043
+ # Get language names
22044
+ source_lang = self.current_project.source_lang or "Source"
22045
+ target_lang = self.current_project.target_lang or "Target"
22046
+
22047
+ # Check translation status
22048
+ translated_count = sum(1 for seg in segments if seg.target and seg.target.strip())
22049
+ total_count = len(segments)
22050
+
22051
+ # Show export options dialog
22052
+ dialog = QDialog(self)
22053
+ dialog.setWindowTitle("Export as Markdown Table")
22054
+ dialog.setMinimumWidth(500)
22055
+
22056
+ layout = QVBoxLayout(dialog)
22057
+
22058
+ # Info label
22059
+ info_label = QLabel(
22060
+ "Export segments as a <b>Markdown table</b> optimized for AI systems.\n"
22061
+ "The table format renders beautifully in ChatGPT, Claude, and all AI chat interfaces."
22062
+ )
22063
+ info_label.setWordWrap(True)
22064
+ info_label.setTextFormat(Qt.TextFormat.RichText)
22065
+ layout.addWidget(info_label)
22066
+
22067
+ layout.addSpacing(10)
22068
+
22069
+ # Status info
22070
+ status_label = QLabel(
22071
+ f"📊 <b>{translated_count} of {total_count}</b> segments have translations."
22072
+ if translated_count > 0
22073
+ else f"📊 <b>{total_count}</b> segments (none translated yet)."
22074
+ )
22075
+ status_label.setTextFormat(Qt.TextFormat.RichText)
22076
+ layout.addWidget(status_label)
22077
+
22078
+ layout.addSpacing(10)
22079
+
22080
+ # Content options
22081
+ content_group = QGroupBox("Content Options")
22082
+ content_layout = QVBoxLayout(content_group)
22083
+
22084
+ include_both_radio = CheckmarkRadioButton(f"Include both {source_lang} and {target_lang} (bilingual table)")
22085
+ include_both_radio.setChecked(True)
22086
+ source_only_radio = CheckmarkRadioButton(f"{source_lang} only (for AI translation)")
22087
+ target_only_radio = CheckmarkRadioButton(f"{target_lang} only (translated segments)")
22088
+
22089
+ content_layout.addWidget(include_both_radio)
22090
+ content_layout.addWidget(source_only_radio)
22091
+ content_layout.addWidget(target_only_radio)
22092
+ layout.addWidget(content_group)
22093
+
22094
+ # Filter options
22095
+ filter_group = QGroupBox("Segment Filter")
22096
+ filter_layout = QVBoxLayout(filter_group)
22097
+
22098
+ all_segments_radio = CheckmarkRadioButton("All segments")
22099
+ all_segments_radio.setChecked(True)
22100
+ untranslated_radio = CheckmarkRadioButton("Untranslated segments only (empty target)")
22101
+ translated_radio = CheckmarkRadioButton("Translated segments only (has target)")
22102
+
22103
+ filter_layout.addWidget(all_segments_radio)
22104
+ filter_layout.addWidget(untranslated_radio)
22105
+ filter_layout.addWidget(translated_radio)
22106
+ layout.addWidget(filter_group)
22107
+
22108
+ layout.addSpacing(10)
22109
+
22110
+ # Preview
22111
+ preview_group = QGroupBox("Preview")
22112
+ preview_layout = QVBoxLayout(preview_group)
22113
+ preview_text = QTextEdit()
22114
+ preview_text.setReadOnly(True)
22115
+ preview_text.setMaximumHeight(150)
22116
+ preview_text.setStyleSheet("font-family: Consolas, monospace; font-size: 10px;")
22117
+ preview_layout.addWidget(preview_text)
22118
+ layout.addWidget(preview_group)
22119
+
22120
+ def update_preview():
22121
+ # Show preview with sample data
22122
+ if include_both_radio.isChecked():
22123
+ preview = f"# Translation Project: {self.current_project.name or 'Untitled'}\n\n"
22124
+ preview += f"| Segment | {source_lang} | {target_lang} |\n"
22125
+ preview += "|---------|" + "-" * (len(source_lang) + 2) + "|" + "-" * (len(target_lang) + 2) + "|\n"
22126
+ preview += "| 1 | Source text example... | Target text example... |\n"
22127
+ preview += "| 2 | Another segment... | Translation here... |"
22128
+ elif source_only_radio.isChecked():
22129
+ preview = f"# Translation Project: {self.current_project.name or 'Untitled'}\n\n"
22130
+ preview += f"| Segment | {source_lang} |\n"
22131
+ preview += "|---------|" + "-" * (len(source_lang) + 2) + "|\n"
22132
+ preview += "| 1 | Source text example... |\n"
22133
+ preview += "| 2 | Another segment... |"
22134
+ else:
22135
+ preview = f"# Translation Project: {self.current_project.name or 'Untitled'}\n\n"
22136
+ preview += f"| Segment | {target_lang} |\n"
22137
+ preview += "|---------|" + "-" * (len(target_lang) + 2) + "|\n"
22138
+ preview += "| 1 | Target text example... |\n"
22139
+ preview += "| 2 | Translation here... |"
22140
+
22141
+ preview_text.setPlainText(preview)
22142
+
22143
+ # Connect signals for live preview
22144
+ include_both_radio.toggled.connect(update_preview)
22145
+ source_only_radio.toggled.connect(update_preview)
22146
+ target_only_radio.toggled.connect(update_preview)
22147
+
22148
+ update_preview() # Initial preview
22149
+
22150
+ layout.addSpacing(10)
22151
+
22152
+ # Buttons
22153
+ button_layout = QHBoxLayout()
22154
+ ok_btn = QPushButton("Export")
22155
+ ok_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold; border: none; outline: none;")
22156
+ ok_btn.clicked.connect(dialog.accept)
22157
+ cancel_btn = QPushButton("Cancel")
22158
+ cancel_btn.clicked.connect(dialog.reject)
22159
+
22160
+ button_layout.addStretch()
22161
+ button_layout.addWidget(cancel_btn)
22162
+ button_layout.addWidget(ok_btn)
22163
+ layout.addLayout(button_layout)
22164
+
22165
+ if dialog.exec() != QDialog.DialogCode.Accepted:
22166
+ return
22167
+
22168
+ # Determine content mode
22169
+ if source_only_radio.isChecked():
22170
+ content_mode = "source_only"
22171
+ elif target_only_radio.isChecked():
22172
+ content_mode = "target_only"
22173
+ else:
22174
+ content_mode = "bilingual"
22175
+
22176
+ # Filter segments
22177
+ if untranslated_radio.isChecked():
22178
+ filtered_segments = [s for s in segments if not (s.target and s.target.strip())]
22179
+ elif translated_radio.isChecked():
22180
+ filtered_segments = [s for s in segments if s.target and s.target.strip()]
22181
+ else:
22182
+ filtered_segments = segments
22183
+
22184
+ if not filtered_segments:
22185
+ QMessageBox.warning(
22186
+ self, "No Segments",
22187
+ "No segments match the selected filter criteria."
22188
+ )
22189
+ return
22190
+
22191
+ # Get save path
22192
+ default_name = ""
22193
+ if self.current_project.name:
22194
+ default_name = self.current_project.name.replace(" ", "_") + "_table.md"
22195
+
22196
+ file_path, _ = QFileDialog.getSaveFileName(
22197
+ self,
22198
+ "Export Markdown Table",
22199
+ default_name,
22200
+ "Markdown Files (*.md);;All Files (*.*)"
22201
+ )
22202
+
22203
+ if not file_path:
22204
+ return
22205
+
22206
+ # Ensure .md extension
22207
+ if not file_path.lower().endswith('.md'):
22208
+ file_path += '.md'
22209
+
22210
+ # Build Markdown table
22211
+ output_lines = []
22212
+ output_lines.append(f"# Translation Project: {self.current_project.name or 'Untitled'}")
22213
+ output_lines.append("")
22214
+ output_lines.append(f"**Languages:** {source_lang} → {target_lang}")
22215
+ output_lines.append(f"**Segments:** {len(filtered_segments)}")
22216
+ output_lines.append(f"**Exported:** {datetime.now().strftime('%Y-%m-%d %H:%M')}")
22217
+ output_lines.append("")
22218
+
22219
+ # Table header
22220
+ if content_mode == "bilingual":
22221
+ output_lines.append(f"| Segment | {source_lang} | {target_lang} |")
22222
+ output_lines.append("|---------|" + "-" * (len(source_lang) + 2) + "|" + "-" * (len(target_lang) + 2) + "|")
22223
+ elif content_mode == "source_only":
22224
+ output_lines.append(f"| Segment | {source_lang} |")
22225
+ output_lines.append("|---------|" + "-" * (len(source_lang) + 2) + "|")
22226
+ else: # target_only
22227
+ output_lines.append(f"| Segment | {target_lang} |")
22228
+ output_lines.append("|---------|" + "-" * (len(target_lang) + 2) + "|")
22229
+
22230
+ # Table rows
22231
+ for i, seg in enumerate(filtered_segments, 1):
22232
+ # Escape pipe characters in text for Markdown tables
22233
+ source_text = seg.source.replace("|", "\\|").replace("\n", " ")
22234
+ target_text = (seg.target or "").replace("|", "\\|").replace("\n", " ")
22235
+
22236
+ if content_mode == "bilingual":
22237
+ output_lines.append(f"| {i} | {source_text} | {target_text} |")
22238
+ elif content_mode == "source_only":
22239
+ output_lines.append(f"| {i} | {source_text} |")
22240
+ else: # target_only
22241
+ output_lines.append(f"| {i} | {target_text} |")
22242
+
22243
+ # Write file
22244
+ with open(file_path, 'w', encoding='utf-8', newline='\n') as f:
22245
+ f.write('\n'.join(output_lines))
22246
+
22247
+ self.log(f"✓ Exported {len(filtered_segments)} segments as Markdown table to: {os.path.basename(file_path)}")
22248
+
22249
+ QMessageBox.information(
22250
+ self,
22251
+ "Export Complete",
22252
+ f"Successfully exported {len(filtered_segments)} segments as Markdown table to:\n\n{os.path.basename(file_path)}\n\n"
22253
+ f"This format renders beautifully in ChatGPT, Claude, and other AI chat interfaces!"
22254
+ )
22255
+
22256
+ except Exception as e:
22257
+ self.log(f"✗ Markdown table export failed: {str(e)}")
22258
+ QMessageBox.critical(
22259
+ self,
22260
+ "Export Error",
22261
+ f"Failed to export Markdown table:\n\n{str(e)}"
22262
+ )
22263
+
22264
+ def convert_document_to_markdown(self):
22265
+ """Convert a single DOCX or TXT document to Markdown format, preserving structure"""
22266
+ try:
22267
+ # Select input file
22268
+ file_path, _ = QFileDialog.getOpenFileName(
22269
+ self,
22270
+ "Select Document to Convert",
22271
+ "",
22272
+ "Documents (*.docx *.txt);;All Files (*.*)"
22273
+ )
22274
+
22275
+ if not file_path:
22276
+ return
22277
+
22278
+ # Determine output path
22279
+ base_name = os.path.splitext(file_path)[0]
22280
+ output_path, _ = QFileDialog.getSaveFileName(
22281
+ self,
22282
+ "Save Markdown File",
22283
+ base_name + ".md",
22284
+ "Markdown Files (*.md);;All Files (*.*)"
22285
+ )
22286
+
22287
+ if not output_path:
22288
+ return
22289
+
22290
+ # Ensure .md extension
22291
+ if not output_path.lower().endswith('.md'):
22292
+ output_path += '.md'
22293
+
22294
+ # Convert based on file type
22295
+ if file_path.lower().endswith('.docx'):
22296
+ self._convert_docx_to_markdown(file_path, output_path)
22297
+ else: # TXT
22298
+ self._convert_txt_to_markdown(file_path, output_path)
22299
+
22300
+ self.log(f"✓ Converted {os.path.basename(file_path)} → {os.path.basename(output_path)}")
22301
+
22302
+ QMessageBox.information(
22303
+ self,
22304
+ "Conversion Complete",
22305
+ f"Successfully converted document to Markdown:\n\n{os.path.basename(output_path)}"
22306
+ )
22307
+
22308
+ except Exception as e:
22309
+ self.log(f"✗ Document conversion failed: {str(e)}")
22310
+ QMessageBox.critical(
22311
+ self,
22312
+ "Conversion Error",
22313
+ f"Failed to convert document:\n\n{str(e)}"
22314
+ )
22315
+
22316
+ def batch_convert_documents_to_markdown(self):
22317
+ """Batch convert multiple documents to Markdown format"""
22318
+ try:
22319
+ # Select multiple files
22320
+ file_paths, _ = QFileDialog.getOpenFileNames(
22321
+ self,
22322
+ "Select Documents to Convert",
22323
+ "",
22324
+ "Documents (*.docx *.txt);;All Files (*.*)"
22325
+ )
22326
+
22327
+ if not file_paths:
22328
+ return
22329
+
22330
+ # Select output folder
22331
+ output_folder = QFileDialog.getExistingDirectory(
22332
+ self,
22333
+ "Select Output Folder for Markdown Files",
22334
+ "",
22335
+ QFileDialog.Option.ShowDirsOnly
22336
+ )
22337
+
22338
+ if not output_folder:
22339
+ return
22340
+
22341
+ # Progress dialog
22342
+ progress = QProgressDialog(
22343
+ "Converting documents to Markdown...",
22344
+ "Cancel",
22345
+ 0,
22346
+ len(file_paths),
22347
+ self
22348
+ )
22349
+ progress.setWindowModality(Qt.WindowModality.WindowModal)
22350
+ progress.setMinimumDuration(0)
22351
+
22352
+ converted_count = 0
22353
+ failed_count = 0
22354
+
22355
+ for i, file_path in enumerate(file_paths):
22356
+ if progress.wasCanceled():
22357
+ break
22358
+
22359
+ progress.setValue(i)
22360
+ progress.setLabelText(f"Converting: {os.path.basename(file_path)}")
22361
+ QApplication.processEvents()
22362
+
22363
+ try:
22364
+ # Determine output path
22365
+ base_name = os.path.splitext(os.path.basename(file_path))[0]
22366
+ output_path = os.path.join(output_folder, base_name + ".md")
22367
+
22368
+ # Convert based on file type
22369
+ if file_path.lower().endswith('.docx'):
22370
+ self._convert_docx_to_markdown(file_path, output_path)
22371
+ else: # TXT
22372
+ self._convert_txt_to_markdown(file_path, output_path)
22373
+
22374
+ converted_count += 1
22375
+ self.log(f"✓ Converted: {os.path.basename(file_path)}")
22376
+
22377
+ except Exception as e:
22378
+ failed_count += 1
22379
+ self.log(f"✗ Failed: {os.path.basename(file_path)} - {str(e)}")
22380
+
22381
+ progress.setValue(len(file_paths))
22382
+
22383
+ # Show summary
22384
+ summary = f"Batch conversion complete!\n\n"
22385
+ summary += f"✓ Successfully converted: {converted_count} files\n"
22386
+ if failed_count > 0:
22387
+ summary += f"✗ Failed: {failed_count} files\n"
22388
+ summary += f"\nOutput folder: {output_folder}"
22389
+
22390
+ QMessageBox.information(
22391
+ self,
22392
+ "Batch Conversion Complete",
22393
+ summary
22394
+ )
22395
+
22396
+ except Exception as e:
22397
+ self.log(f"✗ Batch conversion failed: {str(e)}")
22398
+ QMessageBox.critical(
22399
+ self,
22400
+ "Conversion Error",
22401
+ f"Failed to perform batch conversion:\n\n{str(e)}"
22402
+ )
22403
+
22404
+ def _convert_docx_to_markdown(self, docx_path: str, output_path: str):
22405
+ """Convert DOCX document to Markdown, preserving structure"""
22406
+ from docx import Document
22407
+
22408
+ doc = Document(docx_path)
22409
+ markdown_lines = []
22410
+
22411
+ # Add document title
22412
+ doc_name = os.path.splitext(os.path.basename(docx_path))[0]
22413
+ markdown_lines.append(f"# {doc_name}")
22414
+ markdown_lines.append("")
22415
+
22416
+ for para in doc.paragraphs:
22417
+ if not para.text.strip():
22418
+ markdown_lines.append("")
22419
+ continue
22420
+
22421
+ # Check paragraph style
22422
+ style = para.style.name.lower()
22423
+ text = para.text.strip()
22424
+
22425
+ # Detect ALL CAPS headings (common in patent documents)
22426
+ words = text.split()
22427
+ is_all_caps = (
22428
+ len(words) >= 2 and
22429
+ all(word.isupper() or not word.isalpha() for word in words) and
22430
+ len(text) <= 100 # Reasonable heading length
22431
+ )
22432
+
22433
+ if 'heading 1' in style or style == 'title':
22434
+ markdown_lines.append(f"# {text}")
22435
+ elif 'heading 2' in style:
22436
+ markdown_lines.append(f"## {text}")
22437
+ elif 'heading 3' in style:
22438
+ markdown_lines.append(f"### {text}")
22439
+ elif 'heading 4' in style:
22440
+ markdown_lines.append(f"#### {text}")
22441
+ elif 'heading 5' in style:
22442
+ markdown_lines.append(f"##### {text}")
22443
+ elif 'heading 6' in style:
22444
+ markdown_lines.append(f"###### {text}")
22445
+ elif is_all_caps:
22446
+ # ALL CAPS line without heading style - treat as heading
22447
+ markdown_lines.append(f"## {text.title()}")
22448
+ elif 'list bullet' in style or 'bullet' in style:
22449
+ markdown_lines.append(f"- {text}")
22450
+ elif 'list number' in style or 'numbering' in style:
22451
+ markdown_lines.append(f"1. {text}")
22452
+ elif 'quote' in style or 'quotation' in style:
22453
+ markdown_lines.append(f"> {text}")
22454
+ else:
22455
+ # Regular paragraph - preserve bold/italic if present
22456
+ # Simple formatting detection (runs with bold/italic)
22457
+ has_bold = any(run.bold for run in para.runs if run.text.strip())
22458
+ has_italic = any(run.italic for run in para.runs if run.text.strip())
22459
+
22460
+ if has_bold and has_italic:
22461
+ markdown_lines.append(f"***{text}***")
22462
+ elif has_bold:
22463
+ markdown_lines.append(f"**{text}**")
22464
+ elif has_italic:
22465
+ markdown_lines.append(f"*{text}*")
22466
+ else:
22467
+ markdown_lines.append(text)
22468
+
22469
+ markdown_lines.append("")
22470
+
22471
+ # Write output
22472
+ with open(output_path, 'w', encoding='utf-8', newline='\n') as f:
22473
+ f.write('\n'.join(markdown_lines))
22474
+
22475
+ def _convert_txt_to_markdown(self, txt_path: str, output_path: str):
22476
+ """Convert TXT document to Markdown with minimal formatting"""
22477
+ with open(txt_path, 'r', encoding='utf-8') as f:
22478
+ lines = f.readlines()
22479
+
22480
+ markdown_lines = []
22481
+
22482
+ # Add document title
22483
+ doc_name = os.path.splitext(os.path.basename(txt_path))[0]
22484
+ markdown_lines.append(f"# {doc_name}")
22485
+ markdown_lines.append("")
22486
+
22487
+ # Process lines - detect simple patterns
22488
+ for line in lines:
22489
+ stripped = line.strip()
22490
+
22491
+ if not stripped:
22492
+ markdown_lines.append("")
22493
+ continue
22494
+
22495
+ # Detect ALL CAPS headings (common in plain text documents)
22496
+ # Must be at least 3 words long and all uppercase
22497
+ words = stripped.split()
22498
+ is_all_caps = (
22499
+ len(words) >= 2 and
22500
+ all(word.isupper() or not word.isalpha() for word in words) and
22501
+ len(stripped) <= 100 # Reasonable heading length
22502
+ )
22503
+
22504
+ if is_all_caps:
22505
+ # Convert to title case and make it a heading
22506
+ markdown_lines.append(f"## {stripped.title()}")
22507
+ # Detect simple bullet points
22508
+ elif stripped.startswith('- ') or stripped.startswith('* '):
22509
+ markdown_lines.append(stripped)
22510
+ # Detect numbered lists
22511
+ elif len(stripped) > 2 and stripped[0].isdigit() and stripped[1] in '.):':
22512
+ markdown_lines.append(stripped)
22513
+ # Regular text
22514
+ else:
22515
+ markdown_lines.append(stripped)
22516
+
22517
+ # Write output
22518
+ with open(output_path, 'w', encoding='utf-8', newline='\n') as f:
22519
+ f.write('\n'.join(markdown_lines))
21673
22520
 
21674
22521
  # ========================================================================
21675
22522
  # MULTI-FILE FOLDER IMPORT
@@ -22912,6 +23759,9 @@ class SupervertalerQt(QMainWindow):
22912
23759
  self.load_segments_to_grid()
22913
23760
  self.initialize_tm_database()
22914
23761
 
23762
+ # Deactivate all resources for new project (user explicitly activates what they need)
23763
+ self._deactivate_all_resources_for_new_project()
23764
+
22915
23765
  # Auto-resize rows for better initial display
22916
23766
  self.auto_resize_rows()
22917
23767
 
@@ -23406,6 +24256,9 @@ class SupervertalerQt(QMainWindow):
23406
24256
  self.load_segments_to_grid()
23407
24257
  self.initialize_tm_database()
23408
24258
 
24259
+ # Deactivate all resources for new project (user explicitly activates what they need)
24260
+ self._deactivate_all_resources_for_new_project()
24261
+
23409
24262
  # Auto-resize rows for better initial display
23410
24263
  self.auto_resize_rows()
23411
24264
 
@@ -23672,6 +24525,9 @@ class SupervertalerQt(QMainWindow):
23672
24525
  self.load_segments_to_grid()
23673
24526
  self.initialize_tm_database()
23674
24527
 
24528
+ # Deactivate all resources for new project (user explicitly activates what they need)
24529
+ self._deactivate_all_resources_for_new_project()
24530
+
23675
24531
  # Auto-resize rows for better initial display
23676
24532
  self.auto_resize_rows()
23677
24533
 
@@ -23881,6 +24737,9 @@ class SupervertalerQt(QMainWindow):
23881
24737
  self.load_segments_to_grid()
23882
24738
  self.initialize_tm_database()
23883
24739
 
24740
+ # Deactivate all resources for new project (user explicitly activates what they need)
24741
+ self._deactivate_all_resources_for_new_project()
24742
+
23884
24743
  # Auto-resize rows for better initial display
23885
24744
  self.auto_resize_rows()
23886
24745
 
@@ -27508,6 +28367,20 @@ class SupervertalerQt(QMainWindow):
27508
28367
  # Full processing for non-arrow-key navigation (click, etc.)
27509
28368
  self._on_cell_selected_full(current_row, current_col, previous_row, previous_col)
27510
28369
 
28370
+ def _center_row_in_viewport(self, row: int):
28371
+ """Center the given row vertically in the visible table viewport.
28372
+
28373
+ Uses Qt's built-in scrollTo() with PositionAtCenter hint.
28374
+ """
28375
+ if row < 0 or row >= self.table.rowCount():
28376
+ return
28377
+
28378
+ # Get the model index for any cell in this row (use column 0)
28379
+ index = self.table.model().index(row, 0)
28380
+ if index.isValid():
28381
+ # Use Qt's built-in centering - PositionAtCenter puts the item in the center of the viewport
28382
+ self.table.scrollTo(index, QAbstractItemView.ScrollHint.PositionAtCenter)
28383
+
27511
28384
  def _on_cell_selected_minimal(self, current_row, previous_row):
27512
28385
  """Minimal UI update for fast arrow key navigation - just highlight and scroll"""
27513
28386
  try:
@@ -27529,7 +28402,7 @@ class SupervertalerQt(QMainWindow):
27529
28402
 
27530
28403
  # Auto-center if enabled
27531
28404
  if getattr(self, 'auto_center_active_segment', False) and not getattr(self, 'filtering_active', False):
27532
- self.table.scrollToItem(current_id_item, QTableWidget.ScrollHint.PositionAtCenter)
28405
+ self._center_row_in_viewport(current_row)
27533
28406
  except Exception as e:
27534
28407
  if self.debug_mode_enabled:
27535
28408
  self.log(f"Error in minimal cell selection: {e}")
@@ -27574,7 +28447,7 @@ class SupervertalerQt(QMainWindow):
27574
28447
  current_id_item.setForeground(QColor("white"))
27575
28448
 
27576
28449
  if getattr(self, 'auto_center_active_segment', False) and not getattr(self, 'filtering_active', False):
27577
- self.table.scrollToItem(current_id_item, QTableWidget.ScrollHint.PositionAtCenter)
28450
+ self._center_row_in_viewport(current_row)
27578
28451
 
27579
28452
  if not self.current_project or current_row < 0:
27580
28453
  return
@@ -29800,6 +30673,35 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
29800
30673
  except Exception as e:
29801
30674
  self.log(f"⚠ Could not initialize spellcheck: {e}")
29802
30675
 
30676
+ def _deactivate_all_resources_for_new_project(self):
30677
+ """Deactivate all TMs, termbases, and NT lists for a freshly imported project.
30678
+ This ensures new projects start with clean slate - user explicitly activates what they need."""
30679
+ if not self.current_project or not hasattr(self.current_project, 'id'):
30680
+ return
30681
+
30682
+ project_id = self.current_project.id
30683
+ if not project_id:
30684
+ return
30685
+
30686
+ # Deactivate all TMs for this project
30687
+ if hasattr(self, 'tm_metadata_mgr') and self.tm_metadata_mgr:
30688
+ all_tms = self.tm_metadata_mgr.get_all_tms()
30689
+ for tm in all_tms:
30690
+ self.tm_metadata_mgr.deactivate_tm(tm['id'], project_id)
30691
+
30692
+ # Deactivate all termbases for this project
30693
+ if hasattr(self, 'termbase_mgr') and self.termbase_mgr:
30694
+ all_termbases = self.termbase_mgr.get_all_termbases()
30695
+ for tb in all_termbases:
30696
+ self.termbase_mgr.deactivate_termbase(tb['id'], project_id)
30697
+
30698
+ # Deactivate all NT lists
30699
+ if hasattr(self, 'nt_manager') and self.nt_manager:
30700
+ for list_name in list(self.nt_manager.lists.keys()):
30701
+ self.nt_manager.set_list_active(list_name, False)
30702
+
30703
+ self.log("📋 New project: All TMs, glossaries, and NT lists deactivated (start clean)")
30704
+
29803
30705
  def search_and_display_tm_matches(self, source_text: str):
29804
30706
  """Search TM and Termbases and display matches with visual diff for fuzzy matches"""
29805
30707
  self.log(f"🚨 search_and_display_tm_matches called with source_text: '{source_text[:50]}...'")
@@ -30315,11 +31217,18 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
30315
31217
  normalized_source = clean_source_lower
30316
31218
  for quote_char in '\"\'\u201C\u201D\u201E\u00AB\u00BB\u2018\u2019\u201A\u2039\u203A':
30317
31219
  normalized_source = normalized_source.replace(quote_char, ' ')
31220
+
31221
+ # CRITICAL FIX: Strip trailing punctuation from glossary term before matching
31222
+ # This allows entries like "De huidige uitvinding...problemen." (with period)
31223
+ # to match source text "...problemen." where tokenization strips the period
31224
+ # Strip from both ends to handle quotes and trailing punctuation
31225
+ normalized_term = source_term.lower().rstrip(PUNCT_CHARS).lstrip(PUNCT_CHARS)
31226
+
30318
31227
  # Check if term has punctuation - use different pattern
30319
- if any(char in source_term for char in ['.', '%', ',', '-', '/']):
30320
- pattern = re.compile(r'(?<!\w)' + re.escape(source_term.lower()) + r'(?!\w)')
31228
+ if any(char in normalized_term for char in ['.', '%', ',', '-', '/']):
31229
+ pattern = re.compile(r'(?<!\w)' + re.escape(normalized_term) + r'(?!\w)')
30321
31230
  else:
30322
- pattern = re.compile(r"\b" + re.escape(source_term.lower()) + r"\b")
31231
+ pattern = re.compile(r"\b" + re.escape(normalized_term) + r"\b")
30323
31232
 
30324
31233
  # Try matching on normalized (tag-stripped, quote-stripped) text first,
30325
31234
  # then tag-stripped, then original with tags
@@ -30776,7 +31685,7 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
30776
31685
  self.case_sensitive_cb.setChecked(op.case_sensitive)
30777
31686
 
30778
31687
  def _fr_run_set_batch(self, fr_set: FindReplaceSet):
30779
- """Run all enabled operations in a F&R Set as a batch."""
31688
+ """Run all enabled operations in a F&R Set as a batch (optimized for speed)."""
30780
31689
  enabled_ops = [op for op in fr_set.operations if op.enabled and op.find_text]
30781
31690
 
30782
31691
  if not enabled_ops:
@@ -30795,17 +31704,32 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
30795
31704
  if reply != QMessageBox.StandardButton.Yes:
30796
31705
  return
30797
31706
 
30798
- # Run each operation
30799
- total_replaced = 0
30800
- for op in enabled_ops:
30801
- count = self._execute_single_fr_operation(op)
30802
- total_replaced += count
30803
- self.log(f" '{op.find_text}' → '{op.replace_text}': {count} replacement(s)")
31707
+ # OPTIMIZATION: Disable UI updates during batch processing
31708
+ self.table.setUpdatesEnabled(False)
30804
31709
 
30805
- # Refresh grid
30806
- self.load_segments_to_grid()
30807
- self.project_modified = True
30808
- self.update_window_title()
31710
+ try:
31711
+ # Run each operation
31712
+ total_replaced = 0
31713
+ for op in enabled_ops:
31714
+ count = self._execute_single_fr_operation(op)
31715
+ total_replaced += count
31716
+ self.log(f" '{op.find_text}' → '{op.replace_text}': {count} replacement(s)")
31717
+
31718
+ self.project_modified = True
31719
+ self.update_window_title()
31720
+
31721
+ finally:
31722
+ # OPTIMIZATION: Re-enable UI updates and refresh only target column cells
31723
+ self.table.setUpdatesEnabled(True)
31724
+ # Update all target cells in-place (batch operations can affect many segments)
31725
+ for row in range(len(self.current_project.segments)):
31726
+ segment = self.current_project.segments[row]
31727
+ target_widget = self.table.cellWidget(row, 3)
31728
+ if target_widget and hasattr(target_widget, 'setPlainText'):
31729
+ target_widget.blockSignals(True)
31730
+ target_widget.setPlainText(segment.target)
31731
+ target_widget.blockSignals(False)
31732
+ self.table.viewport().update()
30809
31733
 
30810
31734
  QMessageBox.information(
30811
31735
  self.find_replace_dialog,
@@ -30814,12 +31738,30 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
30814
31738
  )
30815
31739
 
30816
31740
  def _execute_single_fr_operation(self, op: FindReplaceOperation) -> int:
30817
- """Execute a single F&R operation on all segments. Returns replacement count."""
31741
+ """Execute a single F&R operation on all segments (optimized). Returns replacement count."""
30818
31742
  import re
30819
31743
  count = 0
30820
31744
 
31745
+ # OPTIMIZATION: Pre-filter segments - only check segments that might contain the text
31746
+ # Quick case-insensitive check to skip segments that definitely don't match
31747
+ search_text_lower = op.find_text.lower() if not op.case_sensitive else None
31748
+
30821
31749
  for segment in self.current_project.segments:
30822
31750
  texts_to_check = []
31751
+
31752
+ # Pre-filter: skip segments that can't possibly match
31753
+ if not op.case_sensitive:
31754
+ # Quick check: does the segment contain the search text at all?
31755
+ skip_segment = True
31756
+ if op.search_in in ("source", "both") and self.allow_replace_in_source:
31757
+ if search_text_lower in segment.source.lower():
31758
+ skip_segment = False
31759
+ if op.search_in in ("target", "both"):
31760
+ if search_text_lower in segment.target.lower():
31761
+ skip_segment = False
31762
+ if skip_segment:
31763
+ continue
31764
+
30823
31765
  if op.search_in in ("source", "both") and self.allow_replace_in_source:
30824
31766
  texts_to_check.append(("source", segment.source))
30825
31767
  if op.search_in in ("target", "both"):
@@ -31115,7 +32057,7 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
31115
32057
  self.find_next_match()
31116
32058
 
31117
32059
  def replace_all_matches(self):
31118
- """Replace all matches in target segments"""
32060
+ """Replace all matches in target segments (optimized for speed)"""
31119
32061
  find_text = self.find_input.text()
31120
32062
  replace_text = self.replace_input.text()
31121
32063
 
@@ -31188,52 +32130,67 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
31188
32130
  if reply != QMessageBox.StandardButton.Yes:
31189
32131
  return
31190
32132
 
31191
- # Perform replacements
31192
- import re
31193
- replaced_count = 0
32133
+ # OPTIMIZATION: Disable UI updates during batch replacement
32134
+ self.table.setUpdatesEnabled(False)
31194
32135
 
31195
- for row, col in self.find_matches:
31196
- segment = self.current_project.segments[row]
31197
-
31198
- # Get the appropriate field
31199
- if col == 2: # Source
31200
- old_text = segment.source
31201
- else: # col == 3, Target
31202
- old_text = segment.target
32136
+ try:
32137
+ # Perform replacements
32138
+ import re
32139
+ replaced_count = 0
32140
+ updated_rows = set() # Track which rows need UI updates
31203
32141
 
31204
- # Perform replacement
31205
- if match_mode == 2: # Entire segment
31206
- new_text = replace_text
31207
- else:
31208
- if case_sensitive:
31209
- new_text = old_text.replace(find_text, replace_text)
31210
- else:
31211
- pattern = re.escape(find_text)
31212
- new_text = re.sub(pattern, replace_text, old_text, flags=re.IGNORECASE)
31213
-
31214
- if new_text != old_text:
31215
- replaced_count += 1
31216
- # Update the appropriate field
31217
- if col == 2:
31218
- segment.source = new_text
32142
+ for row, col in self.find_matches:
32143
+ segment = self.current_project.segments[row]
32144
+
32145
+ # Get the appropriate field
32146
+ if col == 2: # Source
32147
+ old_text = segment.source
32148
+ else: # col == 3, Target
32149
+ old_text = segment.target
32150
+
32151
+ # Perform replacement
32152
+ if match_mode == 2: # Entire segment
32153
+ new_text = replace_text
31219
32154
  else:
31220
- old_target = segment.target
31221
- old_status = segment.status
31222
- segment.target = new_text
31223
- # Record undo state for find/replace operation
31224
- self.record_undo_state(segment.id, old_target, new_text, old_status, old_status)
32155
+ if case_sensitive:
32156
+ new_text = old_text.replace(find_text, replace_text)
32157
+ else:
32158
+ pattern = re.escape(find_text)
32159
+ new_text = re.sub(pattern, replace_text, old_text, flags=re.IGNORECASE)
31225
32160
 
31226
- # Update table
31227
- item = self.table.item(row, col)
31228
- if item:
31229
- item.setText(new_text)
31230
-
31231
- self.project_modified = True
31232
- self.update_window_title()
31233
-
31234
- # Clear matches and reload
31235
- self.find_matches = []
31236
- self.load_segments_to_grid()
32161
+ if new_text != old_text:
32162
+ replaced_count += 1
32163
+ updated_rows.add(row)
32164
+
32165
+ # Update the appropriate field
32166
+ if col == 2:
32167
+ segment.source = new_text
32168
+ else:
32169
+ old_target = segment.target
32170
+ old_status = segment.status
32171
+ segment.target = new_text
32172
+ # Record undo state for find/replace operation
32173
+ self.record_undo_state(segment.id, old_target, new_text, old_status, old_status)
32174
+
32175
+ # OPTIMIZATION: Update only the affected cell widget in-place
32176
+ cell_widget = self.table.cellWidget(row, col)
32177
+ if cell_widget and hasattr(cell_widget, 'setPlainText'):
32178
+ cell_widget.blockSignals(True)
32179
+ cell_widget.setPlainText(new_text)
32180
+ cell_widget.blockSignals(False)
32181
+
32182
+ self.project_modified = True
32183
+ self.update_window_title()
32184
+
32185
+ # Clear matches
32186
+ self.find_matches = []
32187
+
32188
+ finally:
32189
+ # OPTIMIZATION: Re-enable UI updates without full grid reload
32190
+ self.table.setUpdatesEnabled(True)
32191
+ # Trigger a repaint of updated rows only
32192
+ for row in updated_rows:
32193
+ self.table.viewport().update()
31237
32194
 
31238
32195
  QMessageBox.information(self.find_replace_dialog, "Replace All", f"Replaced {replaced_count} occurrence(s).")
31239
32196
  self.log(f"✓ Replaced {replaced_count} occurrence(s) of '{find_text}'")
@@ -31999,15 +32956,17 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
31999
32956
 
32000
32957
  try:
32001
32958
  visible_count = 0
32002
- row_count = self.table.rowCount()
32003
32959
  segments = self.current_project.segments
32960
+ total_segments = len(segments)
32004
32961
 
32005
32962
  # Pre-compute lowercase filter texts
32006
32963
  source_filter_lower = source_filter_text.lower() if source_filter_text else None
32007
32964
  target_filter_lower = target_filter_text.lower() if target_filter_text else None
32008
32965
 
32009
- for row in range(row_count):
32010
- if row >= len(segments):
32966
+ # IMPORTANT: Always search through ALL segments, not just visible rows
32967
+ # Pagination state should not affect which segments we search
32968
+ for row in range(total_segments):
32969
+ if row >= total_segments:
32011
32970
  break
32012
32971
 
32013
32972
  segment = segments[row]
@@ -35221,11 +36180,30 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
35221
36180
  except Exception as e:
35222
36181
  QMessageBox.warning(self, "Error", f"Could not open api_keys.txt: {str(e)}")
35223
36182
 
35224
- def _go_to_settings_tab(self):
35225
- """Navigate to Settings tab (from menu)"""
36183
+ def _go_to_settings_tab(self, subtab_name: str = None):
36184
+ """Navigate to Settings tab (from menu), optionally to a specific sub-tab
36185
+
36186
+ Args:
36187
+ subtab_name: Name of the sub-tab to navigate to (e.g., "AI Settings")
36188
+ """
35226
36189
  if hasattr(self, 'main_tabs'):
35227
- # Main tabs: Grid=0, Project resources=1, Tools=2, Settings=3
35228
- self.main_tabs.setCurrentIndex(3)
36190
+ # Main tabs: Grid=0, Resources=1, Prompt Manager=2, Tools=3, Settings=4
36191
+ self.main_tabs.setCurrentIndex(4)
36192
+
36193
+ # Navigate to specific sub-tab if requested
36194
+ if subtab_name and hasattr(self, 'settings_tabs'):
36195
+ for i in range(self.settings_tabs.count()):
36196
+ if subtab_name.lower() in self.settings_tabs.tabText(i).lower():
36197
+ self.settings_tabs.setCurrentIndex(i)
36198
+
36199
+ # If navigating to AI Settings, scroll to bottom (API keys section)
36200
+ if "ai settings" in subtab_name.lower() and hasattr(self, 'ai_settings_scroll'):
36201
+ # Use QTimer to ensure the tab is fully rendered before scrolling
36202
+ from PyQt6.QtCore import QTimer
36203
+ QTimer.singleShot(100, lambda: self.ai_settings_scroll.verticalScrollBar().setValue(
36204
+ self.ai_settings_scroll.verticalScrollBar().maximum()
36205
+ ))
36206
+ break
35229
36207
 
35230
36208
  def _go_to_superlookup(self):
35231
36209
  """Navigate to Superlookup in Tools tab"""
@@ -35240,6 +36218,18 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
35240
36218
  self.modules_tabs.setCurrentIndex(i)
35241
36219
  break
35242
36220
 
36221
+ def _navigate_to_tool(self, tool_name: str):
36222
+ """Navigate to a specific tool in the Tools tab"""
36223
+ if hasattr(self, 'main_tabs'):
36224
+ # Main tabs: Grid=0, Resources=1, Prompt Manager=2, Tools=3, Settings=4
36225
+ self.main_tabs.setCurrentIndex(3) # Switch to Tools tab
36226
+ # Then switch to the specific tool sub-tab
36227
+ if hasattr(self, 'modules_tabs'):
36228
+ for i in range(self.modules_tabs.count()):
36229
+ if tool_name in self.modules_tabs.tabText(i):
36230
+ self.modules_tabs.setCurrentIndex(i)
36231
+ break
36232
+
35243
36233
  def open_api_keys_file(self):
35244
36234
  """Open API keys file in system text editor"""
35245
36235
  api_keys_file = self.user_data_path / "api_keys.txt"
@@ -36445,7 +37435,7 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
36445
37435
  QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
36446
37436
  )
36447
37437
  if reply == QMessageBox.StandardButton.Yes:
36448
- self._go_to_settings_tab()
37438
+ self._go_to_settings_tab("AI Settings")
36449
37439
  return
36450
37440
 
36451
37441
  # Check if API key exists for selected provider
@@ -36804,7 +37794,19 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
36804
37794
  return text.strip()
36805
37795
 
36806
37796
  def _quickmenu_build_custom_prompt(self, prompt_relative_path: str, source_text: str, source_lang: str, target_lang: str) -> str:
36807
- """Build a complete translation prompt using the chosen QuickMenu prompt as PRIMARY instructions."""
37797
+ """Build a prompt for QuickMenu using the selected prompt as instructions.
37798
+
37799
+ This is a GENERIC prompt builder (not translation-specific) that allows QuickMenu prompts
37800
+ to do anything: explain, define, search, translate, analyze, etc.
37801
+
37802
+ Supports placeholders:
37803
+ - {{SELECTION}} or {{SOURCE_TEXT}} - The selected text
37804
+ - {{SOURCE_LANGUAGE}} - Source language name
37805
+ - {{TARGET_LANGUAGE}} - Target language name
37806
+ - {{SOURCE+TARGET_CONTEXT}} - Project segments with both source and target (for proofreading)
37807
+ - {{SOURCE_CONTEXT}} - Project segments with source only (for translation questions)
37808
+ - {{TARGET_CONTEXT}} - Project segments with target only (for consistency/style analysis)
37809
+ """
36808
37810
  if not hasattr(self, 'prompt_manager_qt') or not self.prompt_manager_qt:
36809
37811
  raise RuntimeError("Prompt manager not available")
36810
37812
 
@@ -36821,25 +37823,114 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
36821
37823
  if not prompt_content:
36822
37824
  raise RuntimeError("Prompt content is empty")
36823
37825
 
36824
- mode = getattr(pm, 'current_mode', None) or "single"
36825
- system_template = pm.get_system_template(mode)
36826
-
36827
- system_template = system_template.replace("{{SOURCE_LANGUAGE}}", source_lang)
36828
- system_template = system_template.replace("{{TARGET_LANGUAGE}}", target_lang)
36829
- system_template = system_template.replace("{{SOURCE_TEXT}}", source_text)
36830
-
36831
- library_prompts = "\n\n# PRIMARY INSTRUCTIONS\n\n" + prompt_content
36832
-
36833
- # Keep any globally attached prompts as additional instructions
37826
+ # Build document context if requested (three variants)
37827
+ source_target_context = ""
37828
+ source_only_context = ""
37829
+ target_only_context = ""
37830
+
37831
+ has_source_target = "{{SOURCE+TARGET_CONTEXT}}" in prompt_content
37832
+ has_source_only = "{{SOURCE_CONTEXT}}" in prompt_content
37833
+ has_target_only = "{{TARGET_CONTEXT}}" in prompt_content
37834
+
37835
+ if (has_source_target or has_source_only or has_target_only) and hasattr(self, 'current_project') and self.current_project:
37836
+ if has_source_target:
37837
+ source_target_context = self._build_quickmenu_document_context(mode="both")
37838
+ self.log(f"🔍 QuickMenu: Built SOURCE+TARGET context ({len(source_target_context)} chars)")
37839
+ if has_source_only:
37840
+ source_only_context = self._build_quickmenu_document_context(mode="source")
37841
+ self.log(f"🔍 QuickMenu: Built SOURCE_ONLY context ({len(source_only_context)} chars)")
37842
+ if has_target_only:
37843
+ target_only_context = self._build_quickmenu_document_context(mode="target")
37844
+ self.log(f"🔍 QuickMenu: Built TARGET_ONLY context ({len(target_only_context)} chars)")
37845
+ else:
37846
+ if has_source_target:
37847
+ self.log("⚠️ QuickMenu: {{SOURCE+TARGET_CONTEXT}} requested but no project loaded")
37848
+ if has_source_only:
37849
+ self.log("⚠️ QuickMenu: {{SOURCE_CONTEXT}} requested but no project loaded")
37850
+ if has_target_only:
37851
+ self.log("⚠️ QuickMenu: {{TARGET_CONTEXT}} requested but no project loaded")
37852
+
37853
+ # Replace placeholders in the prompt content
37854
+ prompt_content = prompt_content.replace("{{SOURCE_LANGUAGE}}", source_lang)
37855
+ prompt_content = prompt_content.replace("{{TARGET_LANGUAGE}}", target_lang)
37856
+ prompt_content = prompt_content.replace("{{SOURCE_TEXT}}", source_text)
37857
+ prompt_content = prompt_content.replace("{{SELECTION}}", source_text) # Alternative placeholder
37858
+ prompt_content = prompt_content.replace("{{SOURCE+TARGET_CONTEXT}}", source_target_context)
37859
+ prompt_content = prompt_content.replace("{{SOURCE_CONTEXT}}", source_only_context)
37860
+ prompt_content = prompt_content.replace("{{TARGET_CONTEXT}}", target_only_context)
37861
+
37862
+ # Debug: Log the final prompt being sent
37863
+ self.log(f"📝 QuickMenu: Final prompt ({len(prompt_content)} chars):")
37864
+ self.log("─" * 80)
37865
+ self.log(prompt_content[:500] + ("..." if len(prompt_content) > 500 else ""))
37866
+ self.log("─" * 80)
37867
+
37868
+ # If the prompt doesn't contain the selection/text, append it
37869
+ if "{{SOURCE_TEXT}}" not in prompt_data.get('content', '') and "{{SELECTION}}" not in prompt_data.get('content', ''):
37870
+ prompt_content += f"\n\nText:\n{source_text}"
37871
+
37872
+ return prompt_content
37873
+
37874
+ def _build_quickmenu_document_context(self, mode: str = "both") -> str:
37875
+ """Build document context for QuickMenu prompts.
37876
+
37877
+ Args:
37878
+ mode: One of:
37879
+ - "both": Include both source and target text (for proofreading)
37880
+ - "source": Include only source text (for translation/terminology questions)
37881
+ - "target": Include only target text (for consistency/style analysis)
37882
+
37883
+ Returns a formatted string with segments from the project for context.
37884
+ Uses the 'quickmenu_context_segments' setting (default: 50% of total segments).
37885
+ """
37886
+ if not hasattr(self, 'current_project') or not self.current_project or not self.current_project.segments:
37887
+ return "(No project context available)"
37888
+
36834
37889
  try:
36835
- for attached_content in lib.attached_prompts:
36836
- library_prompts += "\n\n# ADDITIONAL INSTRUCTIONS\n\n" + (attached_content or "")
36837
- except Exception:
36838
- pass
36839
-
36840
- final_prompt = system_template + library_prompts
36841
- final_prompt += "\n\n**YOUR TRANSLATION (provide ONLY the translated text, no numbering or labels):**\n"
36842
- return final_prompt
37890
+ # Get settings
37891
+ general_prefs = self.load_general_settings()
37892
+ context_percent = general_prefs.get('quickmenu_context_percent', 50) # Default: 50%
37893
+ max_context_segments = general_prefs.get('quickmenu_context_max', 100) # Safety limit
37894
+
37895
+ # Calculate how many segments to include
37896
+ total_segments = len(self.current_project.segments)
37897
+ num_segments = min(
37898
+ int(total_segments * context_percent / 100),
37899
+ max_context_segments
37900
+ )
37901
+
37902
+ if num_segments == 0:
37903
+ return "(Document context disabled)"
37904
+
37905
+ # Get segments (from start of document)
37906
+ context_segments = self.current_project.segments[:num_segments]
37907
+
37908
+ # Format segments based on mode
37909
+ context_parts = []
37910
+ mode_labels = {"both": "SOURCE + TARGET", "source": "SOURCE ONLY", "target": "TARGET ONLY"}
37911
+ context_parts.append(f"=== DOCUMENT CONTEXT ({mode_labels.get(mode, 'UNKNOWN')}) ===")
37912
+ context_parts.append(f"(Showing {num_segments} of {total_segments} segments - {context_percent}%)")
37913
+ context_parts.append("")
37914
+
37915
+ for seg in context_segments:
37916
+ if mode == "both":
37917
+ # Source + Target
37918
+ context_parts.append(f"[{seg.id}] {seg.source}")
37919
+ if seg.target and seg.target.strip():
37920
+ context_parts.append(f" → {seg.target}")
37921
+ elif mode == "source":
37922
+ # Source only
37923
+ context_parts.append(f"[{seg.id}] {seg.source}")
37924
+ elif mode == "target":
37925
+ # Target only (skip empty targets)
37926
+ if seg.target and seg.target.strip():
37927
+ context_parts.append(f"[{seg.id}] {seg.target}")
37928
+ context_parts.append("") # Blank line between segments
37929
+
37930
+ return "\n".join(context_parts)
37931
+
37932
+ except Exception as e:
37933
+ return f"(Error building document context: {e})"
36843
37934
 
36844
37935
  def _quickmenu_show_result_dialog(self, title: str, output_text: str, apply_callback=None):
36845
37936
  from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QDialogButtonBox, QPushButton, QApplication
@@ -36919,10 +38010,14 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
36919
38010
  )
36920
38011
 
36921
38012
  client = LLMClient(api_key=api_key, provider=provider, model=model)
38013
+
38014
+ # Use translate() with empty text and custom_prompt for generic AI completion
38015
+ # This allows QuickMenu prompts to do anything (explain, define, search, etc.)
38016
+ # not just translation. Same pattern as AI Assistant.
36922
38017
  output_text = client.translate(
36923
- text=input_text,
36924
- source_lang=source_lang,
36925
- target_lang=target_lang,
38018
+ text="", # Empty - we're using custom_prompt for everything
38019
+ source_lang="en", # Dummy values
38020
+ target_lang="en",
36926
38021
  custom_prompt=custom_prompt
36927
38022
  )
36928
38023
 
@@ -38672,11 +39767,26 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
38672
39767
  return f"[MyMemory error: {str(e)}]"
38673
39768
 
38674
39769
  def load_api_keys(self) -> Dict[str, str]:
38675
- """Load API keys from user_data folder"""
39770
+ """Load API keys with dev-first priority
39771
+
39772
+ Priority order:
39773
+ 1. user_data_private/api_keys.txt (dev mode, gitignored)
39774
+ 2. user_data/api_keys.txt (user mode, shipped with app)
39775
+ """
38676
39776
  api_keys = {}
38677
- api_keys_file = self.user_data_path / "api_keys.txt"
38678
39777
 
38679
- if not api_keys_file.exists():
39778
+ # Priority 1: Dev mode (gitignored, never shipped)
39779
+ dev_file = Path("user_data_private") / "api_keys.txt"
39780
+
39781
+ # Priority 2: User mode (ships with app)
39782
+ user_file = self.user_data_path / "api_keys.txt"
39783
+
39784
+ # Check dev first, then user
39785
+ if dev_file.exists():
39786
+ api_keys_file = dev_file
39787
+ elif user_file.exists():
39788
+ api_keys_file = user_file
39789
+ else:
38680
39790
  return api_keys
38681
39791
 
38682
39792
  try:
@@ -38806,9 +39916,9 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
38806
39916
  def show_autofingers(self):
38807
39917
  """Show AutoFingers by switching to the AutoFingers tab"""
38808
39918
  # Find the AutoFingers tab index and activate it
38809
- # AutoFingers is in Tools tab (main_tabs index 2)
39919
+ # AutoFingers is in Tools tab (main_tabs index 3)
38810
39920
  if hasattr(self, 'main_tabs'):
38811
- self.main_tabs.setCurrentIndex(2) # Switch to Tools tab
39921
+ self.main_tabs.setCurrentIndex(3) # Switch to Tools tab
38812
39922
  # Then switch to AutoFingers sub-tab
38813
39923
  if hasattr(self, 'modules_tabs'):
38814
39924
  for i in range(self.modules_tabs.count()):
@@ -43493,7 +44603,7 @@ class SuperlookupTab(QWidget):
43493
44603
  try:
43494
44604
  # Use multiple methods to ensure cleanup
43495
44605
  # Method 1: Kill by window title
43496
- subprocess.run(['taskkill', '/F', '/FI', 'WINDOWTITLE eq superlookup_hotkey.ahk*'],
44606
+ subprocess.run(['taskkill', '/F', '/FI', 'WINDOWTITLE eq supervertaler_hotkeys.ahk*'],
43497
44607
  capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW)
43498
44608
 
43499
44609
  # Method 2: Kill AutoHotkey processes more aggressively
@@ -43505,7 +44615,7 @@ class SuperlookupTab(QWidget):
43505
44615
  try:
43506
44616
  for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
43507
44617
  try:
43508
- if 'superlookup_hotkey' in ' '.join(proc.cmdline() or []):
44618
+ if 'supervertaler_hotkeys' in ' '.join(proc.cmdline() or []):
43509
44619
  proc.kill()
43510
44620
  except:
43511
44621
  pass
@@ -43518,8 +44628,8 @@ class SuperlookupTab(QWidget):
43518
44628
  ahk_exe, source = self._find_autohotkey_executable()
43519
44629
 
43520
44630
  if not ahk_exe:
43521
- print("[Superlookup] AutoHotkey not found.")
43522
- print("[Superlookup] Global hotkey (Ctrl+Alt+L) will not be available.")
44631
+ print("[Hotkeys] AutoHotkey not found.")
44632
+ print("[Hotkeys] Global hotkeys (Ctrl+Alt+L, Shift+Shift) will not be available.")
43523
44633
  self.hotkey_registered = False
43524
44634
  # Show setup dialog (deferred to avoid blocking startup) - unless user opted out
43525
44635
  if self.main_window and hasattr(self.main_window, 'general_settings'):
@@ -43529,11 +44639,11 @@ class SuperlookupTab(QWidget):
43529
44639
  QTimer.singleShot(2000, self._show_autohotkey_setup_dialog)
43530
44640
  return
43531
44641
 
43532
- print(f"[Superlookup] Found AutoHotkey at: {ahk_exe} (source: {source})")
44642
+ print(f"[Hotkeys] Found AutoHotkey at: {ahk_exe} (source: {source})")
43533
44643
 
43534
- ahk_script = Path(__file__).parent / "superlookup_hotkey.ahk"
43535
- print(f"[Superlookup] Looking for script at: {ahk_script}")
43536
- print(f"[Superlookup] Script exists: {ahk_script.exists()}")
44644
+ ahk_script = Path(__file__).parent / "supervertaler_hotkeys.ahk"
44645
+ print(f"[Hotkeys] Looking for script at: {ahk_script}")
44646
+ print(f"[Hotkeys] Script exists: {ahk_script.exists()}")
43537
44647
 
43538
44648
  if ahk_script.exists():
43539
44649
  # Start AHK script in background (hidden)
@@ -43541,16 +44651,16 @@ class SuperlookupTab(QWidget):
43541
44651
  creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
43542
44652
  # Store in global variable for atexit cleanup
43543
44653
  _ahk_process = self.ahk_process
43544
- print(f"[Superlookup] AHK hotkey registered: Ctrl+Alt+L")
44654
+ print(f"[Hotkeys] AHK hotkeys registered (Ctrl+Alt+L, Shift+Shift)")
43545
44655
 
43546
44656
  # Start file watcher
43547
44657
  self.start_file_watcher()
43548
44658
  self.hotkey_registered = True
43549
44659
  else:
43550
- print(f"[Superlookup] AHK script not found: {ahk_script}")
44660
+ print(f"[Hotkeys] AHK script not found: {ahk_script}")
43551
44661
  self.hotkey_registered = False
43552
44662
  except Exception as e:
43553
- print(f"[Superlookup] Could not start AHK hotkey: {e}")
44663
+ print(f"[Hotkeys] Could not start AHK hotkeys: {e}")
43554
44664
  self.hotkey_registered = False
43555
44665
 
43556
44666
  def start_file_watcher(self):
@@ -43633,11 +44743,11 @@ class SuperlookupTab(QWidget):
43633
44743
  print(f"[Superlookup] Main window type: {type(main_window).__name__}")
43634
44744
  print(f"[Superlookup] Has main_tabs: {hasattr(main_window, 'main_tabs')}")
43635
44745
 
43636
- # Switch to Tools tab (main_tabs index 2)
43637
- # Tab structure: Grid=0, Project resources=1, Tools=2, Settings=3
44746
+ # Switch to Tools tab (main_tabs index 3)
44747
+ # Tab structure: Grid=0, Resources=1, Prompt Manager=2, Tools=3, Settings=4
43638
44748
  if hasattr(main_window, 'main_tabs'):
43639
44749
  print(f"[Superlookup] Current main_tab index: {main_window.main_tabs.currentIndex()}")
43640
- main_window.main_tabs.setCurrentIndex(2) # Tools tab is at index 2
44750
+ main_window.main_tabs.setCurrentIndex(3) # Tools tab
43641
44751
  print(f"[Superlookup] Switched to Tools tab (index 2)")
43642
44752
  QApplication.processEvents() # Force GUI update
43643
44753
  else: