supervertaler 1.9.175__py3-none-any.whl → 1.9.177b0__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
@@ -34,7 +34,7 @@ License: MIT
34
34
  """
35
35
 
36
36
  # Version Information.
37
- __version__ = "1.9.175"
37
+ __version__ = "1.9.176-beta"
38
38
  __phase__ = "0.9"
39
39
  __release_date__ = "2026-01-28"
40
40
  __edition__ = "Qt"
@@ -6435,13 +6435,10 @@ class SupervertalerQt(QMainWindow):
6435
6435
  from PyQt6.QtCore import QTimer
6436
6436
  QTimer.singleShot(2000, lambda: self._check_for_new_models(force=False)) # 2 second delay
6437
6437
 
6438
- # First-run check - show data location dialog, then Features tab
6439
- if self._needs_data_location_dialog:
6438
+ # First-run check - show unified setup wizard
6439
+ if self._needs_data_location_dialog or not general_settings.get('first_run_completed', False):
6440
6440
  from PyQt6.QtCore import QTimer
6441
- QTimer.singleShot(300, self._show_data_location_dialog)
6442
- elif not general_settings.get('first_run_completed', False):
6443
- from PyQt6.QtCore import QTimer
6444
- QTimer.singleShot(500, self._show_first_run_welcome)
6441
+ QTimer.singleShot(300, lambda: self._show_setup_wizard(is_first_run=True))
6445
6442
 
6446
6443
  def _show_data_location_dialog(self):
6447
6444
  """Show dialog to let user choose their data folder location on first run."""
@@ -6672,7 +6669,273 @@ class SupervertalerQt(QMainWindow):
6672
6669
  self.log("✅ First-run welcome shown (will show again next time)")
6673
6670
  except Exception as e:
6674
6671
  self.log(f"⚠️ First-run welcome error: {e}")
6675
-
6672
+
6673
+ def _show_setup_wizard(self, is_first_run: bool = False):
6674
+ """
6675
+ Show unified setup wizard that combines data folder selection and features intro.
6676
+
6677
+ Args:
6678
+ is_first_run: If True, this is an automatic first-run trigger. If False, user
6679
+ manually invoked from menu (skip data folder if already configured).
6680
+ """
6681
+ try:
6682
+ from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
6683
+ QPushButton, QLineEdit, QFileDialog, QStackedWidget,
6684
+ QWidget, QFrame, QCheckBox)
6685
+ from PyQt6.QtCore import Qt
6686
+
6687
+ dialog = QDialog(self)
6688
+ dialog.setWindowTitle("Supervertaler Setup Wizard")
6689
+ dialog.setMinimumWidth(600)
6690
+ dialog.setMinimumHeight(450)
6691
+ dialog.setModal(True)
6692
+
6693
+ main_layout = QVBoxLayout(dialog)
6694
+ main_layout.setSpacing(15)
6695
+ main_layout.setContentsMargins(20, 20, 20, 20)
6696
+
6697
+ # Stacked widget for wizard pages
6698
+ stacked = QStackedWidget()
6699
+
6700
+ # Determine if we need to show data folder page
6701
+ show_data_folder_page = is_first_run and self._needs_data_location_dialog
6702
+
6703
+ # ==================== PAGE 1: Data Folder Selection ====================
6704
+ page1 = QWidget()
6705
+ page1_layout = QVBoxLayout(page1)
6706
+ page1_layout.setSpacing(15)
6707
+
6708
+ # Step indicator
6709
+ step1_indicator = QLabel("<span style='color: #888;'>Step 1 of 2</span>")
6710
+ page1_layout.addWidget(step1_indicator)
6711
+
6712
+ # Title
6713
+ page1_title = QLabel("<h2>📁 Choose Your Data Folder</h2>")
6714
+ page1_layout.addWidget(page1_title)
6715
+
6716
+ # Explanation
6717
+ page1_msg = QLabel(
6718
+ "Supervertaler stores your data in a folder of your choice:<br><br>"
6719
+ "• <b>API keys</b> – Your LLM provider credentials<br>"
6720
+ "• <b>Translation memories</b> – Reusable translation pairs<br>"
6721
+ "• <b>Glossaries</b> – Terminology databases<br>"
6722
+ "• <b>Prompts</b> – Custom AI prompts<br>"
6723
+ "• <b>Settings</b> – Application configuration<br><br>"
6724
+ "Choose a location that's easy to find and backup."
6725
+ )
6726
+ page1_msg.setWordWrap(True)
6727
+ page1_layout.addWidget(page1_msg)
6728
+
6729
+ # Path input with browse button
6730
+ path_layout = QHBoxLayout()
6731
+ path_edit = QLineEdit()
6732
+ default_path = get_default_user_data_path()
6733
+ path_edit.setText(str(default_path))
6734
+ path_edit.setMinimumWidth(350)
6735
+ path_layout.addWidget(path_edit)
6736
+
6737
+ browse_btn = QPushButton("Browse...")
6738
+ def browse_folder():
6739
+ folder = QFileDialog.getExistingDirectory(
6740
+ dialog,
6741
+ "Choose Data Folder",
6742
+ str(Path.home())
6743
+ )
6744
+ if folder:
6745
+ folder_path = Path(folder)
6746
+ if folder_path.name != "Supervertaler":
6747
+ folder_path = folder_path / "Supervertaler"
6748
+ path_edit.setText(str(folder_path))
6749
+
6750
+ browse_btn.clicked.connect(browse_folder)
6751
+ path_layout.addWidget(browse_btn)
6752
+ page1_layout.addLayout(path_layout)
6753
+
6754
+ # Tip
6755
+ page1_tip = QLabel(
6756
+ "💡 <b>Tip:</b> The default location is in your home folder, "
6757
+ "making it easy to find and backup."
6758
+ )
6759
+ page1_tip.setWordWrap(True)
6760
+ page1_tip.setStyleSheet("color: #666;")
6761
+ page1_layout.addWidget(page1_tip)
6762
+
6763
+ page1_layout.addStretch()
6764
+ stacked.addWidget(page1)
6765
+
6766
+ # ==================== PAGE 2: Features Introduction ====================
6767
+ page2 = QWidget()
6768
+ page2_layout = QVBoxLayout(page2)
6769
+ page2_layout.setSpacing(15)
6770
+
6771
+ # Step indicator
6772
+ step2_label = "Step 2 of 2" if show_data_folder_page else "Setup"
6773
+ step2_indicator = QLabel(f"<span style='color: #888;'>{step2_label}</span>")
6774
+ page2_layout.addWidget(step2_indicator)
6775
+
6776
+ # Data folder info (shown when skipping page 1)
6777
+ if not show_data_folder_page:
6778
+ from PyQt6.QtGui import QDesktopServices
6779
+ from PyQt6.QtCore import QUrl
6780
+
6781
+ data_folder_path = str(self.user_data_path)
6782
+ data_folder_info = QLabel(
6783
+ f"<b>📁 Data Folder:</b> <a href='file:///{data_folder_path}' "
6784
+ f"style='color: #3b82f6;'>{data_folder_path}</a><br>"
6785
+ "<span style='color: #666; font-size: 0.9em;'>"
6786
+ "Your settings, TMs, glossaries and prompts are stored here. "
6787
+ "Change in Settings → General.</span>"
6788
+ )
6789
+ data_folder_info.setWordWrap(True)
6790
+ data_folder_info.setTextFormat(Qt.TextFormat.RichText)
6791
+ data_folder_info.setOpenExternalLinks(False) # Handle clicks ourselves
6792
+ data_folder_info.linkActivated.connect(
6793
+ lambda url: QDesktopServices.openUrl(QUrl.fromLocalFile(data_folder_path))
6794
+ )
6795
+ data_folder_info.setStyleSheet(
6796
+ "background: #f0f4ff; padding: 12px; border-radius: 6px; "
6797
+ "border-left: 4px solid #3b82f6; margin-bottom: 10px;"
6798
+ )
6799
+ page2_layout.addWidget(data_folder_info)
6800
+
6801
+ # Title
6802
+ page2_title = QLabel("<h2>✨ Modular Features</h2>")
6803
+ page2_layout.addWidget(page2_title)
6804
+
6805
+ # Message
6806
+ page2_msg = QLabel(
6807
+ "Supervertaler uses a <b>modular architecture</b> – you can install "
6808
+ "only the features you need.<br><br>"
6809
+ "<b>Core features</b> (always available):<br>"
6810
+ "• AI translation with OpenAI, Claude, Gemini, Ollama<br>"
6811
+ "• Translation Memory and Glossaries<br>"
6812
+ "• XLIFF, SDLXLIFF, memoQ support<br>"
6813
+ "• Basic spellchecking<br><br>"
6814
+ "<b>Optional features</b> (install via pip):<br>"
6815
+ "• <code>openai-whisper</code> – Local voice dictation (no API needed)<br><br>"
6816
+ "You can view and manage features in <b>Settings → Features</b>."
6817
+ )
6818
+ page2_msg.setWordWrap(True)
6819
+ page2_msg.setTextFormat(Qt.TextFormat.RichText)
6820
+ page2_layout.addWidget(page2_msg)
6821
+
6822
+ # Checkbox
6823
+ dont_show_checkbox = CheckmarkCheckBox("Don't show this wizard on startup")
6824
+ dont_show_checkbox.setChecked(True)
6825
+ page2_layout.addWidget(dont_show_checkbox)
6826
+
6827
+ # Open Features tab checkbox
6828
+ open_features_checkbox = CheckmarkCheckBox("Open Features tab after closing")
6829
+ open_features_checkbox.setChecked(True)
6830
+ page2_layout.addWidget(open_features_checkbox)
6831
+
6832
+ page2_layout.addStretch()
6833
+ stacked.addWidget(page2)
6834
+
6835
+ main_layout.addWidget(stacked)
6836
+
6837
+ # ==================== Navigation Buttons ====================
6838
+ nav_layout = QHBoxLayout()
6839
+
6840
+ back_btn = QPushButton("← Back")
6841
+ back_btn.setVisible(False) # Hidden on first page
6842
+
6843
+ next_btn = QPushButton("Next →")
6844
+ finish_btn = QPushButton("Finish")
6845
+ finish_btn.setVisible(False)
6846
+ finish_btn.setDefault(True)
6847
+
6848
+ # Use Default button (only on page 1)
6849
+ default_btn = QPushButton("Use Default")
6850
+ default_btn.clicked.connect(lambda: path_edit.setText(str(default_path)))
6851
+
6852
+ nav_layout.addWidget(default_btn)
6853
+ nav_layout.addStretch()
6854
+ nav_layout.addWidget(back_btn)
6855
+ nav_layout.addWidget(next_btn)
6856
+ nav_layout.addWidget(finish_btn)
6857
+
6858
+ main_layout.addLayout(nav_layout)
6859
+
6860
+ # Track chosen path for later
6861
+ chosen_path_holder = [None]
6862
+
6863
+ def go_to_page(page_index):
6864
+ stacked.setCurrentIndex(page_index)
6865
+ if page_index == 0:
6866
+ back_btn.setVisible(False)
6867
+ next_btn.setVisible(True)
6868
+ finish_btn.setVisible(False)
6869
+ default_btn.setVisible(True)
6870
+ else:
6871
+ back_btn.setVisible(show_data_folder_page)
6872
+ next_btn.setVisible(False)
6873
+ finish_btn.setVisible(True)
6874
+ default_btn.setVisible(False)
6875
+
6876
+ def on_next():
6877
+ # Save the data folder choice
6878
+ chosen_path = Path(path_edit.text())
6879
+ chosen_path_holder[0] = chosen_path
6880
+
6881
+ # Create the folder and save config
6882
+ chosen_path.mkdir(parents=True, exist_ok=True)
6883
+ save_user_data_path(chosen_path)
6884
+
6885
+ # Update our path if different
6886
+ if chosen_path != self.user_data_path:
6887
+ self.user_data_path = chosen_path
6888
+ self._reinitialize_with_new_data_path()
6889
+ else:
6890
+ if hasattr(self, 'db_manager') and self.db_manager and not self.db_manager.connection:
6891
+ self.db_manager.connect()
6892
+
6893
+ self.log(f"📁 Data folder set to: {chosen_path}")
6894
+ go_to_page(1)
6895
+
6896
+ def on_back():
6897
+ go_to_page(0)
6898
+
6899
+ def on_finish():
6900
+ # Save first_run preference
6901
+ if dont_show_checkbox.isChecked():
6902
+ settings = self.load_general_settings()
6903
+ settings['first_run_completed'] = True
6904
+ self.save_general_settings(settings)
6905
+ self.log("✅ Setup wizard completed (won't show again on startup)")
6906
+ else:
6907
+ self.log("✅ Setup wizard shown (will show again next time)")
6908
+
6909
+ dialog.accept()
6910
+
6911
+ # Navigate to Features tab if checkbox is checked
6912
+ if open_features_checkbox.isChecked():
6913
+ self.main_tabs.setCurrentIndex(4) # Settings tab
6914
+ if hasattr(self, 'settings_tabs'):
6915
+ for i in range(self.settings_tabs.count()):
6916
+ if "Features" in self.settings_tabs.tabText(i):
6917
+ self.settings_tabs.setCurrentIndex(i)
6918
+ break
6919
+
6920
+ back_btn.clicked.connect(on_back)
6921
+ next_btn.clicked.connect(on_next)
6922
+ finish_btn.clicked.connect(on_finish)
6923
+
6924
+ # Start on appropriate page
6925
+ if show_data_folder_page:
6926
+ go_to_page(0)
6927
+ else:
6928
+ # Skip to features page if data folder already configured
6929
+ go_to_page(1)
6930
+ step2_indicator.setText("<span style='color: #888;'>Supervertaler Setup</span>")
6931
+
6932
+ dialog.exec()
6933
+
6934
+ except Exception as e:
6935
+ self.log(f"⚠️ Setup wizard error: {e}")
6936
+ import traceback
6937
+ traceback.print_exc()
6938
+
6676
6939
  def _check_for_new_models(self, force: bool = False):
6677
6940
  """
6678
6941
  Check for new LLM models from providers
@@ -7936,6 +8199,11 @@ class SupervertalerQt(QMainWindow):
7936
8199
  superdocs_action.triggered.connect(lambda: self._open_url("https://supervertaler.gitbook.io/superdocs/"))
7937
8200
  help_menu.addAction(superdocs_action)
7938
8201
 
8202
+ setup_wizard_action = QAction("🚀 Setup Wizard...", self)
8203
+ setup_wizard_action.setToolTip("Run the initial setup wizard (data folder location, features overview)")
8204
+ setup_wizard_action.triggered.connect(lambda: self._show_setup_wizard(is_first_run=False))
8205
+ help_menu.addAction(setup_wizard_action)
8206
+
7939
8207
  help_menu.addSeparator()
7940
8208
 
7941
8209
  shortcuts_action = QAction("⌨️ Keyboard Shortcuts", self)
@@ -22051,20 +22319,24 @@ class SupervertalerQt(QMainWindow):
22051
22319
  """
22052
22320
  Search termbases using a provided cursor (thread-safe for background threads).
22053
22321
  This method allows background workers to query the database without SQLite threading errors.
22054
-
22322
+
22323
+ Implements BIDIRECTIONAL matching: searches both source_term and target_term columns.
22324
+ When a match is found on target_term, source and target are swapped in the result.
22325
+ This matches memoQ/Trados behavior where a NL→EN termbase also works for EN→NL projects.
22326
+
22055
22327
  Args:
22056
22328
  source_text: The source text to search for
22057
22329
  cursor: A database cursor from a thread-local connection
22058
22330
  source_lang: Source language code
22059
22331
  target_lang: Target language code
22060
22332
  project_id: Current project ID (required to filter by activated termbases)
22061
-
22333
+
22062
22334
  Returns:
22063
22335
  Dictionary of {term: translation} matches
22064
22336
  """
22065
22337
  if not source_text or not cursor:
22066
22338
  return {}
22067
-
22339
+
22068
22340
  try:
22069
22341
  # Convert language names to codes (match interactive search logic)
22070
22342
  source_lang_code = self._convert_language_to_code(source_lang) if source_lang else None
@@ -22084,20 +22356,26 @@ class SupervertalerQt(QMainWindow):
22084
22356
  try:
22085
22357
  # JOIN termbases AND termbase_activation to filter by activated termbases
22086
22358
  # This matches the logic in database_manager.py search_termbases()
22359
+ # BIDIRECTIONAL: Search both source_term (forward) and target_term (reverse)
22360
+ # Using UNION to combine both directions
22087
22361
  query = """
22088
- SELECT
22089
- t.id, t.source_term, t.target_term, t.termbase_id, t.priority,
22090
- t.domain, t.notes, t.project, t.client, t.forbidden,
22091
- tb.is_project_termbase, tb.name as termbase_name,
22092
- COALESCE(ta.priority, tb.ranking) as ranking
22093
- FROM termbase_terms t
22094
- LEFT JOIN termbases tb ON CAST(t.termbase_id AS INTEGER) = tb.id
22095
- LEFT JOIN termbase_activation ta ON ta.termbase_id = tb.id AND ta.project_id = ? AND ta.is_active = 1
22096
- WHERE LOWER(t.source_term) LIKE ?
22097
- AND (ta.is_active = 1 OR tb.is_project_termbase = 1)
22362
+ SELECT * FROM (
22363
+ -- Forward match: search source_term
22364
+ SELECT
22365
+ t.id, t.source_term, t.target_term, t.termbase_id, t.priority,
22366
+ t.domain, t.notes, t.project, t.client, t.forbidden,
22367
+ tb.is_project_termbase, tb.name as termbase_name,
22368
+ COALESCE(ta.priority, tb.ranking) as ranking,
22369
+ 'source' as match_direction
22370
+ FROM termbase_terms t
22371
+ LEFT JOIN termbases tb ON CAST(t.termbase_id AS INTEGER) = tb.id
22372
+ LEFT JOIN termbase_activation ta ON ta.termbase_id = tb.id AND ta.project_id = ? AND ta.is_active = 1
22373
+ WHERE LOWER(t.source_term) LIKE ?
22374
+ AND (ta.is_active = 1 OR tb.is_project_termbase = 1)
22098
22375
  """
22099
22376
  params = [project_id if project_id else 0, f"%{clean_word.lower()}%"]
22100
22377
 
22378
+ # Language filters for forward query
22101
22379
  if source_lang_code:
22102
22380
  query += " AND (t.source_lang = ? OR (t.source_lang IS NULL AND tb.source_lang = ?) OR (t.source_lang IS NULL AND tb.source_lang IS NULL))"
22103
22381
  params.extend([source_lang_code, source_lang_code])
@@ -22105,15 +22383,45 @@ class SupervertalerQt(QMainWindow):
22105
22383
  query += " AND (t.target_lang = ? OR (t.target_lang IS NULL AND tb.target_lang = ?) OR (t.target_lang IS NULL AND tb.target_lang IS NULL))"
22106
22384
  params.extend([target_lang_code, target_lang_code])
22107
22385
 
22108
- # Limit raw hits per word to keep batch worker light
22109
- query += " LIMIT 15"
22386
+ # Reverse match: search target_term, swap source/target in output
22387
+ query += """
22388
+ UNION ALL
22389
+ -- Reverse match: search target_term, swap columns
22390
+ SELECT
22391
+ t.id, t.target_term as source_term, t.source_term as target_term,
22392
+ t.termbase_id, t.priority,
22393
+ t.domain, t.notes, t.project, t.client, t.forbidden,
22394
+ tb.is_project_termbase, tb.name as termbase_name,
22395
+ COALESCE(ta.priority, tb.ranking) as ranking,
22396
+ 'target' as match_direction
22397
+ FROM termbase_terms t
22398
+ LEFT JOIN termbases tb ON CAST(t.termbase_id AS INTEGER) = tb.id
22399
+ LEFT JOIN termbase_activation ta ON ta.termbase_id = tb.id AND ta.project_id = ? AND ta.is_active = 1
22400
+ WHERE LOWER(t.target_term) LIKE ?
22401
+ AND (ta.is_active = 1 OR tb.is_project_termbase = 1)
22402
+ """
22403
+ params.extend([project_id if project_id else 0, f"%{clean_word.lower()}%"])
22404
+
22405
+ # Language filters for reverse query (swapped)
22406
+ if source_lang_code:
22407
+ # For reverse: source_lang filters target_lang column
22408
+ query += " AND (t.target_lang = ? OR (t.target_lang IS NULL AND tb.target_lang = ?) OR (t.target_lang IS NULL AND tb.target_lang IS NULL))"
22409
+ params.extend([source_lang_code, source_lang_code])
22410
+ if target_lang_code:
22411
+ # For reverse: target_lang filters source_lang column
22412
+ query += " AND (t.source_lang = ? OR (t.source_lang IS NULL AND tb.source_lang = ?) OR (t.source_lang IS NULL AND tb.source_lang IS NULL))"
22413
+ params.extend([target_lang_code, target_lang_code])
22414
+
22415
+ # Close UNION and limit
22416
+ query += ") combined LIMIT 30"
22110
22417
  cursor.execute(query, params)
22111
22418
  results = cursor.fetchall()
22112
22419
 
22113
22420
  for row in results:
22114
- # Uniform access
22421
+ # Uniform access (columns are already swapped for reverse matches)
22115
22422
  source_term = row[1] if isinstance(row, tuple) else row['source_term']
22116
22423
  target_term = row[2] if isinstance(row, tuple) else row['target_term']
22424
+ match_direction = row[13] if isinstance(row, tuple) else row.get('match_direction', 'source')
22117
22425
  if not source_term or not target_term:
22118
22426
  continue
22119
22427
 
@@ -22137,8 +22445,10 @@ class SupervertalerQt(QMainWindow):
22137
22445
  existing = matches.get(source_term.strip())
22138
22446
  # Deduplicate: keep numerically lowest ranking (highest priority)
22139
22447
  # For project termbases, ranking is None so they always win
22448
+ # Also prefer 'source' matches over 'target' matches when equal
22140
22449
  if existing:
22141
22450
  existing_ranking = existing.get('ranking', None)
22451
+ existing_direction = existing.get('match_direction', 'source')
22142
22452
  if is_project_tb:
22143
22453
  # Project termbase always wins
22144
22454
  pass
@@ -22147,8 +22457,12 @@ class SupervertalerQt(QMainWindow):
22147
22457
  continue
22148
22458
  elif existing_ranking is not None and ranking is not None:
22149
22459
  # Both have rankings, keep lower (higher priority)
22150
- if existing_ranking <= ranking:
22460
+ if existing_ranking < ranking:
22151
22461
  continue
22462
+ elif existing_ranking == ranking:
22463
+ # Same ranking: prefer source match over target match
22464
+ if existing_direction == 'source' and match_direction == 'target':
22465
+ continue
22152
22466
 
22153
22467
  matches[source_term.strip()] = {
22154
22468
  'translation': target_term.strip(),
@@ -22163,15 +22477,18 @@ class SupervertalerQt(QMainWindow):
22163
22477
  'forbidden': forbidden or False,
22164
22478
  'is_project_termbase': bool(is_project_tb),
22165
22479
  'termbase_name': termbase_name or '',
22166
- 'target_synonyms': [] # Will be populated below
22480
+ 'target_synonyms': [], # Will be populated below
22481
+ 'match_direction': match_direction # Track if this was a reverse match
22167
22482
  }
22168
-
22483
+
22169
22484
  # Fetch synonyms for this term
22485
+ # For reverse matches, fetch 'source' synonyms since they become targets
22170
22486
  try:
22487
+ synonym_lang = 'source' if match_direction == 'target' else 'target'
22171
22488
  cursor.execute("""
22172
- SELECT synonym_text FROM termbase_synonyms
22173
- WHERE term_id = ? AND language = 'target' AND forbidden = 0
22174
- """, (term_id,))
22489
+ SELECT synonym_text FROM termbase_synonyms
22490
+ WHERE term_id = ? AND language = ? AND forbidden = 0
22491
+ """, (term_id, synonym_lang))
22175
22492
  synonym_rows = cursor.fetchall()
22176
22493
  for syn_row in synonym_rows:
22177
22494
  synonym = syn_row[0] if isinstance(syn_row, tuple) else syn_row['synonym_text']
@@ -1477,120 +1477,225 @@ class DatabaseManager:
1477
1477
  # TODO: Implement in Phase 3
1478
1478
  pass
1479
1479
 
1480
- def search_termbases(self, search_term: str, source_lang: str = None,
1480
+ def search_termbases(self, search_term: str, source_lang: str = None,
1481
1481
  target_lang: str = None, project_id: str = None,
1482
- min_length: int = 0) -> List[Dict]:
1482
+ min_length: int = 0, bidirectional: bool = True) -> List[Dict]:
1483
1483
  """
1484
- Search termbases for matching source terms
1485
-
1484
+ Search termbases for matching terms (bidirectional by default)
1485
+
1486
1486
  Args:
1487
- search_term: Source term to search for
1487
+ search_term: Term to search for
1488
1488
  source_lang: Filter by source language (optional)
1489
1489
  target_lang: Filter by target language (optional)
1490
1490
  project_id: Filter by project (optional)
1491
1491
  min_length: Minimum term length to return
1492
-
1492
+ bidirectional: If True, also search target_term and swap results (default True)
1493
+
1493
1494
  Returns:
1494
1495
  List of termbase hits, sorted by priority (lower = higher priority)
1496
+ Each result includes 'match_direction' ('source' or 'target') indicating
1497
+ which column matched. For 'target' matches, source_term and target_term
1498
+ are swapped so results are always oriented correctly for the current project.
1495
1499
  """
1496
1500
  # Build query with filters - include termbase name and ranking via JOIN
1497
1501
  # Note: termbase_id is stored as TEXT in termbase_terms but INTEGER in termbases
1498
1502
  # Use CAST to ensure proper comparison
1499
1503
  # IMPORTANT: Join with termbase_activation to get the ACTUAL priority for this project
1500
1504
  # CRITICAL FIX: Also match when search_term starts with the glossary term
1501
- # This handles cases like searching for "ca." when glossary has "ca."
1505
+ # This handles cases like searching for "ca." when glossary has "ca."
1502
1506
  # AND searching for "ca" when glossary has "ca."
1503
1507
  # We also strip trailing punctuation from glossary terms for comparison
1504
- query = """
1505
- SELECT
1506
- t.id, t.source_term, t.target_term, t.termbase_id, t.priority,
1508
+
1509
+ # Build matching conditions for a given column
1510
+ def build_match_conditions(column: str) -> str:
1511
+ return f"""(
1512
+ LOWER(t.{column}) = LOWER(?) OR
1513
+ LOWER(t.{column}) LIKE LOWER(?) OR
1514
+ LOWER(t.{column}) LIKE LOWER(?) OR
1515
+ LOWER(t.{column}) LIKE LOWER(?) OR
1516
+ LOWER(RTRIM(t.{column}, '.!?,;:')) = LOWER(?) OR
1517
+ LOWER(?) LIKE LOWER(t.{column}) || '%' OR
1518
+ LOWER(?) = LOWER(RTRIM(t.{column}, '.!?,;:'))
1519
+ )"""
1520
+
1521
+ # Build match params for one direction
1522
+ def build_match_params() -> list:
1523
+ return [
1524
+ search_term,
1525
+ f"{search_term} %",
1526
+ f"% {search_term}",
1527
+ f"% {search_term} %",
1528
+ search_term, # For RTRIM comparison
1529
+ search_term, # For reverse LIKE
1530
+ search_term # For reverse RTRIM comparison
1531
+ ]
1532
+
1533
+ # Matching patterns:
1534
+ # 1. Exact match: column = search_term
1535
+ # 2. Glossary term starts with search: column LIKE "search_term %"
1536
+ # 3. Glossary term ends with search: column LIKE "% search_term"
1537
+ # 4. Glossary term contains search: column LIKE "% search_term %"
1538
+ # 5. Glossary term (stripped) = search_term: RTRIM(column) = search_term (handles "ca." = "ca")
1539
+ # 6. Search starts with glossary term: search_term LIKE column || '%'
1540
+ # 7. Search = glossary term stripped: search_term = RTRIM(column)
1541
+
1542
+ # Base SELECT for forward matches (source_term matches)
1543
+ base_select_forward = """
1544
+ SELECT
1545
+ t.id, t.source_term, t.target_term, t.termbase_id, t.priority,
1507
1546
  t.forbidden, t.source_lang, t.target_lang, t.definition, t.domain,
1508
1547
  t.notes, t.project, t.client,
1509
1548
  tb.name as termbase_name,
1510
1549
  tb.source_lang as termbase_source_lang,
1511
1550
  tb.target_lang as termbase_target_lang,
1512
1551
  tb.is_project_termbase,
1513
- COALESCE(ta.priority, tb.ranking) as ranking
1552
+ COALESCE(ta.priority, tb.ranking) as ranking,
1553
+ 'source' as match_direction
1514
1554
  FROM termbase_terms t
1515
1555
  LEFT JOIN termbases tb ON CAST(t.termbase_id AS INTEGER) = tb.id
1516
1556
  LEFT JOIN termbase_activation ta ON ta.termbase_id = tb.id AND ta.project_id = ? AND ta.is_active = 1
1517
- WHERE (
1518
- LOWER(t.source_term) = LOWER(?) OR
1519
- LOWER(t.source_term) LIKE LOWER(?) OR
1520
- LOWER(t.source_term) LIKE LOWER(?) OR
1521
- LOWER(t.source_term) LIKE LOWER(?) OR
1522
- LOWER(RTRIM(t.source_term, '.!?,;:')) = LOWER(?) OR
1523
- LOWER(?) LIKE LOWER(t.source_term) || '%' OR
1524
- LOWER(?) = LOWER(RTRIM(t.source_term, '.!?,;:'))
1525
- )
1557
+ WHERE {match_conditions}
1526
1558
  AND (ta.is_active = 1 OR tb.is_project_termbase = 1)
1527
- """
1528
- # Matching patterns:
1529
- # 1. Exact match: source_term = search_term
1530
- # 2. Glossary term starts with search: source_term LIKE "search_term %"
1531
- # 3. Glossary term ends with search: source_term LIKE "% search_term"
1532
- # 4. Glossary term contains search: source_term LIKE "% search_term %"
1533
- # 5. Glossary term (stripped) = search_term: RTRIM(source_term) = search_term (handles "ca." = "ca")
1534
- # 6. Search starts with glossary term: search_term LIKE source_term || '%'
1535
- # 7. Search = glossary term stripped: search_term = RTRIM(source_term)
1536
- params = [
1537
- project_id if project_id else 0, # Use 0 if no project (won't match any activation records)
1538
- search_term,
1539
- f"{search_term} %",
1540
- f"% {search_term}",
1541
- f"% {search_term} %",
1542
- search_term, # For RTRIM comparison
1543
- search_term, # For reverse LIKE
1544
- search_term # For reverse RTRIM comparison
1545
- ]
1546
-
1547
- # Language filters - if term has no language, use termbase language for filtering
1559
+ """.format(match_conditions=build_match_conditions('source_term'))
1560
+
1561
+ # Base SELECT for reverse matches (target_term matches) - swap source/target in output
1562
+ base_select_reverse = """
1563
+ SELECT
1564
+ t.id, t.target_term as source_term, t.source_term as target_term,
1565
+ t.termbase_id, t.priority,
1566
+ t.forbidden, t.target_lang as source_lang, t.source_lang as target_lang,
1567
+ t.definition, t.domain,
1568
+ t.notes, t.project, t.client,
1569
+ tb.name as termbase_name,
1570
+ tb.target_lang as termbase_source_lang,
1571
+ tb.source_lang as termbase_target_lang,
1572
+ tb.is_project_termbase,
1573
+ COALESCE(ta.priority, tb.ranking) as ranking,
1574
+ 'target' as match_direction
1575
+ FROM termbase_terms t
1576
+ LEFT JOIN termbases tb ON CAST(t.termbase_id AS INTEGER) = tb.id
1577
+ LEFT JOIN termbase_activation ta ON ta.termbase_id = tb.id AND ta.project_id = ? AND ta.is_active = 1
1578
+ WHERE {match_conditions}
1579
+ AND (ta.is_active = 1 OR tb.is_project_termbase = 1)
1580
+ """.format(match_conditions=build_match_conditions('target_term'))
1581
+
1582
+ # Build params
1583
+ project_param = project_id if project_id else 0
1584
+ forward_params = [project_param] + build_match_params()
1585
+ reverse_params = [project_param] + build_match_params()
1586
+
1587
+ # Build language filter conditions
1588
+ lang_conditions_forward = ""
1589
+ lang_conditions_reverse = ""
1590
+ lang_params_forward = []
1591
+ lang_params_reverse = []
1592
+
1548
1593
  if source_lang:
1549
- query += """ AND (
1550
- t.source_lang = ? OR
1594
+ # For forward: filter on source_lang
1595
+ lang_conditions_forward += """ AND (
1596
+ t.source_lang = ? OR
1551
1597
  (t.source_lang IS NULL AND tb.source_lang = ?) OR
1552
1598
  (t.source_lang IS NULL AND tb.source_lang IS NULL)
1553
1599
  )"""
1554
- params.extend([source_lang, source_lang])
1555
-
1600
+ lang_params_forward.extend([source_lang, source_lang])
1601
+ # For reverse: source_lang becomes target_lang (swapped)
1602
+ lang_conditions_reverse += """ AND (
1603
+ t.target_lang = ? OR
1604
+ (t.target_lang IS NULL AND tb.target_lang = ?) OR
1605
+ (t.target_lang IS NULL AND tb.target_lang IS NULL)
1606
+ )"""
1607
+ lang_params_reverse.extend([source_lang, source_lang])
1608
+
1556
1609
  if target_lang:
1557
- query += """ AND (
1558
- t.target_lang = ? OR
1610
+ # For forward: filter on target_lang
1611
+ lang_conditions_forward += """ AND (
1612
+ t.target_lang = ? OR
1559
1613
  (t.target_lang IS NULL AND tb.target_lang = ?) OR
1560
1614
  (t.target_lang IS NULL AND tb.target_lang IS NULL)
1561
1615
  )"""
1562
- params.extend([target_lang, target_lang])
1563
-
1564
- # Project filter: match project-specific terms OR global terms (project_id IS NULL)
1616
+ lang_params_forward.extend([target_lang, target_lang])
1617
+ # For reverse: target_lang becomes source_lang (swapped)
1618
+ lang_conditions_reverse += """ AND (
1619
+ t.source_lang = ? OR
1620
+ (t.source_lang IS NULL AND tb.source_lang = ?) OR
1621
+ (t.source_lang IS NULL AND tb.source_lang IS NULL)
1622
+ )"""
1623
+ lang_params_reverse.extend([target_lang, target_lang])
1624
+
1625
+ # Project filter conditions
1626
+ project_conditions = ""
1627
+ project_params = []
1565
1628
  if project_id:
1566
- query += " AND (t.project_id = ? OR t.project_id IS NULL)"
1567
- params.append(project_id)
1568
-
1629
+ project_conditions = " AND (t.project_id = ? OR t.project_id IS NULL)"
1630
+ project_params = [project_id]
1631
+
1632
+ # Min length conditions
1633
+ min_len_forward = ""
1634
+ min_len_reverse = ""
1569
1635
  if min_length > 0:
1570
- query += f" AND LENGTH(t.source_term) >= {min_length}"
1571
-
1572
- # Sort by ranking (lower number = higher priority)
1573
- # Project termbases (ranking IS NULL) appear first, then by ranking, then alphabetically
1574
- # Use COALESCE to treat NULL as -1 (highest priority)
1575
- query += " ORDER BY COALESCE(tb.ranking, -1) ASC, t.source_term ASC"
1576
-
1636
+ min_len_forward = f" AND LENGTH(t.source_term) >= {min_length}"
1637
+ min_len_reverse = f" AND LENGTH(t.target_term) >= {min_length}"
1638
+
1639
+ # Build forward query
1640
+ forward_query = base_select_forward + lang_conditions_forward + project_conditions + min_len_forward
1641
+ forward_params.extend(lang_params_forward)
1642
+ forward_params.extend(project_params)
1643
+
1644
+ if bidirectional:
1645
+ # Build reverse query
1646
+ reverse_query = base_select_reverse + lang_conditions_reverse + project_conditions + min_len_reverse
1647
+ reverse_params.extend(lang_params_reverse)
1648
+ reverse_params.extend(project_params)
1649
+
1650
+ # Combine with UNION and sort
1651
+ query = f"""
1652
+ SELECT * FROM (
1653
+ {forward_query}
1654
+ UNION ALL
1655
+ {reverse_query}
1656
+ ) combined
1657
+ ORDER BY COALESCE(ranking, -1) ASC, source_term ASC
1658
+ """
1659
+ params = forward_params + reverse_params
1660
+ else:
1661
+ # Original forward-only behavior
1662
+ query = forward_query + " ORDER BY COALESCE(ranking, -1) ASC, source_term ASC"
1663
+ params = forward_params
1664
+
1577
1665
  self.cursor.execute(query, params)
1578
1666
  results = []
1667
+ seen_combinations = set() # Track (source_term, target_term, termbase_id) to avoid duplicates
1668
+
1579
1669
  for row in self.cursor.fetchall():
1580
1670
  result_dict = dict(row)
1671
+
1672
+ # Deduplicate: same term pair from same termbase should only appear once
1673
+ # Prefer 'source' match over 'target' match
1674
+ combo_key = (
1675
+ result_dict.get('source_term', '').lower(),
1676
+ result_dict.get('target_term', '').lower(),
1677
+ result_dict.get('termbase_id')
1678
+ )
1679
+ if combo_key in seen_combinations:
1680
+ continue
1681
+ seen_combinations.add(combo_key)
1682
+
1581
1683
  # SQLite stores booleans as 0/1, explicitly convert to Python bool
1582
1684
  if 'is_project_termbase' in result_dict:
1583
1685
  result_dict['is_project_termbase'] = bool(result_dict['is_project_termbase'])
1584
-
1686
+
1585
1687
  # Fetch target synonyms for this term and include them in the result
1586
1688
  term_id = result_dict.get('id')
1689
+ match_direction = result_dict.get('match_direction', 'source')
1587
1690
  if term_id:
1588
1691
  try:
1692
+ # For reverse matches, fetch 'source' synonyms since they become targets
1693
+ synonym_lang = 'source' if match_direction == 'target' else 'target'
1589
1694
  self.cursor.execute("""
1590
1695
  SELECT synonym_text, forbidden FROM termbase_synonyms
1591
- WHERE term_id = ? AND language = 'target'
1696
+ WHERE term_id = ? AND language = ?
1592
1697
  ORDER BY display_order ASC
1593
- """, (term_id,))
1698
+ """, (term_id, synonym_lang))
1594
1699
  synonyms = []
1595
1700
  for syn_row in self.cursor.fetchall():
1596
1701
  syn_text = syn_row[0]
@@ -1600,7 +1705,7 @@ class DatabaseManager:
1600
1705
  result_dict['target_synonyms'] = synonyms
1601
1706
  except Exception:
1602
1707
  result_dict['target_synonyms'] = []
1603
-
1708
+
1604
1709
  results.append(result_dict)
1605
1710
  return results
1606
1711
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: supervertaler
3
- Version: 1.9.175
3
+ Version: 1.9.177b0
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
@@ -1,4 +1,4 @@
1
- Supervertaler.py,sha256=GSTR2FDPPN0JfSQKl8GbY1vjNeq9U5hnudZOE4WnXRs,2307421
1
+ Supervertaler.py,sha256=4cGlqNtBnj95ts-qLw6W-aNSJqjqkeFDTCbBAWkx5cI,2322643
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=FecvNLJa_mw0PZld8-LozUSSgtAq6wDZ8Mhq_u2aiA0,82563
9
+ modules/database_manager.py,sha256=yNtaJNAKtICBBSc5iyhIufzDn25k7OqkOuFeojmWuM4,87319
10
10
  modules/database_migrations.py,sha256=tndJ4wV_2JBfPggMgO1tQRwdfRFZ9zwvADllCZE9CCk,15663
11
11
  modules/dejavurtf_handler.py,sha256=8NZPPYtHga40SZCypHjPoJPmZTvm9rD-eEUUab7mjtg,28156
12
12
  modules/document_analyzer.py,sha256=t1rVvqLaTcpQTEja228C7zZnh8dXshK4wA9t1E9aGVk,19524
@@ -77,9 +77,9 @@ modules/unified_prompt_manager_qt.py,sha256=U89UFGG-M7BLetoaLAlma0x-n8SIyx682DhS
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.175.dist-info/licenses/LICENSE,sha256=m28u-4qL5nXIWnJ6xlQVw__H30rWFtRK3pCOais2OuY,1092
81
- supervertaler-1.9.175.dist-info/METADATA,sha256=iABI3TeYmFFqP1Ho2qd3cj0_UOjaC-7mbzrqLqHIfUw,5725
82
- supervertaler-1.9.175.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
83
- supervertaler-1.9.175.dist-info/entry_points.txt,sha256=NP4hiCvx-_30YYKqgr-jfJYQvHr1qTYBMfoVmKIXSM8,53
84
- supervertaler-1.9.175.dist-info/top_level.txt,sha256=9tUHBYUSfaE4S2E4W3eavJsDyYymkwLfeWAHHAPT6Dk,22
85
- supervertaler-1.9.175.dist-info/RECORD,,
80
+ supervertaler-1.9.177b0.dist-info/licenses/LICENSE,sha256=m28u-4qL5nXIWnJ6xlQVw__H30rWFtRK3pCOais2OuY,1092
81
+ supervertaler-1.9.177b0.dist-info/METADATA,sha256=KeUutsoVb7QmekOgaHFTiDxTdrgrJLs0yLcThPa0lAQ,5727
82
+ supervertaler-1.9.177b0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
83
+ supervertaler-1.9.177b0.dist-info/entry_points.txt,sha256=NP4hiCvx-_30YYKqgr-jfJYQvHr1qTYBMfoVmKIXSM8,53
84
+ supervertaler-1.9.177b0.dist-info/top_level.txt,sha256=9tUHBYUSfaE4S2E4W3eavJsDyYymkwLfeWAHHAPT6Dk,22
85
+ supervertaler-1.9.177b0.dist-info/RECORD,,