diff --git a/uff_app.py b/uff_app.py index faea5dd..dab1341 100644 --- a/uff_app.py +++ b/uff_app.py @@ -10,15 +10,15 @@ import traceback from sentence_transformers import SentenceTransformer, util from rapidfuzz import process, fuzz -# Wichtige Importe für UI und Signale -from PyQt6.QtCore import qInstallMessageHandler, QtMsgType, Qt, QThread, pyqtSignal, QUrl +from PyQt6.QtCore import qInstallMessageHandler, QtMsgType, Qt, QThread, pyqtSignal, QUrl, QSize from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLineEdit, QPushButton, QLabel, - QFileDialog, QTextBrowser, QProgressBar, QMessageBox, - QListWidget, QListWidgetItem, QSplitter, QFrame, QSplashScreen) -from PyQt6.QtGui import QDesktopServices, QPixmap + QFileDialog, QProgressBar, QMessageBox, + QListWidget, QListWidgetItem, QSplitter, QFrame, + QSplashScreen, QScrollArea, QStyle) +from PyQt6.QtGui import QDesktopServices, QPixmap, QCursor, QAction -# --- 0. LOGGING & SYSTEM-SETUP --- +# --- 0. LOGGING & SETUP --- if os.name == 'nt': base_dir = os.getenv('LOCALAPPDATA') @@ -31,7 +31,6 @@ if not os.path.exists(log_dir): log_file_path = os.path.join(log_dir, "uff.log") -# Logger-Klasse class Logger(object): def __init__(self): self.log = open(log_file_path, "w", encoding="utf-8") @@ -43,97 +42,120 @@ class Logger(object): def flush(self): self.log.flush() -# stdout und stderr umleiten sys.stdout = Logger() sys.stderr = sys.stdout print(f"--- START LOGGING ---") -print(f"Logfile liegt hier: {log_file_path}") +print(f"Logfile: {log_file_path}") -# --- QT MESSAGE HANDLER (Der Filter für C++ Errors) --- def qt_message_handler(mode, context, message): - """ - Fängt interne Qt-Nachrichten ab und filtert Font-Fehler heraus. - """ msg_lower = message.lower() - - # FILTER-LISTE: Erweitert basierend auf deinen Logs ignore_keywords = [ - "qt.text.font", - "qt.qpa.fonts", - "opentype support missing", - "directwrite", - "unable to create font", - "fontbbox", - "script 66", - "script 9", - "script 10", - "script 20", - "script 32" + "qt.text.font", "qt.qpa.fonts", "opentype", "directwrite", + "unable to create font", "fontbbox", "script" ] + if any(k in msg_lower for k in ignore_keywords): return - # Wenn eines der Keywords vorkommt -> Nachricht ignorieren (return) - if any(keyword in msg_lower for keyword in ignore_keywords): - return - - # Formatierung für das Logfile - mode_str = "INFO" - if mode == QtMsgType.QtWarningMsg: mode_str = "WARNING" - elif mode == QtMsgType.QtCriticalMsg: mode_str = "CRITICAL" - elif mode == QtMsgType.QtFatalMsg: mode_str = "FATAL" - - # Nur relevante Nachrichten ins Log schreiben try: - sys.stdout.write(f"[Qt {mode_str}] {message}\n") - except: - pass + sys.stdout.write(f"[Qt] {message}\n") + except: pass -# Handler installieren (Muss VOR der App-Erstellung passieren) qInstallMessageHandler(qt_message_handler) - -# Zusätzlich Environment Variable setzen os.environ["QT_LOGGING_RULES"] = "qt.text.font.db=false;qt.qpa.fonts=false" +# --- NEUE KOMPONENTE: Ein einzelnes Suchergebnis als Widget --- +class SearchResultItem(QFrame): + """ + Stellt ein einzelnes Suchergebnis als 'Karte' dar. + """ + def __init__(self, filename, filepath, snippet, parent=None): + super().__init__(parent) + self.filepath = filepath + + # Optik der Karte + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setStyleSheet(""" + SearchResultItem { + background-color: #ffffff; + border: 1px solid #ddd; + border-radius: 5px; + margin-bottom: 5px; + } + SearchResultItem:hover { + background-color: #f0f8ff; + border: 1px solid #2980b9; + } + """) + + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + + # 1. Dateiname (Sieht aus wie ein Link, ist aber ein Button) + self.btn_title = QPushButton(filename) + self.btn_title.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_title.setStyleSheet(""" + QPushButton { + text-align: left; + font-weight: bold; + font-size: 14pt; + color: #2980b9; + border: none; + background: transparent; + } + QPushButton:hover { + text-decoration: underline; + } + """) + self.btn_title.clicked.connect(self.open_file) + + # 2. Snippet (Textvorschau) + # Wir nutzen QLabel mit WordWrap. HTML für Fettung ist okay. + self.lbl_snippet = QLabel(snippet) + self.lbl_snippet.setWordWrap(True) + self.lbl_snippet.setStyleSheet("color: #444; font-size: 10pt; margin-top: 5px;") + + # 3. Pfad (Grau und klein) + self.lbl_path = QLabel(filepath) + self.lbl_path.setStyleSheet("color: #888; font-size: 8pt; margin-top: 5px;") + + layout.addWidget(self.btn_title) + layout.addWidget(self.lbl_snippet) + layout.addWidget(self.lbl_path) + + def open_file(self): + """ + Öffnet die Datei direkt über QDesktopServices. + """ + print(f"Öffne Datei: {self.filepath}") + + target_path = self.filepath + # Falls es ein ZIP-Pfad ist (erkennbar am Trenner " :: ") + if " :: " in target_path: + target_path = target_path.split(" :: ")[0] + + url = QUrl.fromLocalFile(target_path) + success = QDesktopServices.openUrl(url) + + if not success: + print("Fehler: Konnte Datei nicht öffnen.") + + # --- 1. DATENBANK MANAGER --- class DatabaseHandler: def __init__(self): - if os.name == 'nt': - base_dir = os.getenv('LOCALAPPDATA') - else: - base_dir = os.path.join(os.path.expanduser("~"), ".local", "share") - - self.app_data_dir = os.path.join(base_dir, "UFF_Search") - - if not os.path.exists(self.app_data_dir): - os.makedirs(self.app_data_dir) - + self.app_data_dir = log_dir self.db_name = os.path.join(self.app_data_dir, "uff_index.db") - print(f"Datenbank Pfad: {self.db_name}") self.model = None - self.init_db() def init_db(self): conn = sqlite3.connect(self.db_name) cursor = conn.cursor() - cursor.execute(""" - CREATE VIRTUAL TABLE IF NOT EXISTS documents - USING fts5(filename, path, content); - """) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS folders ( - path TEXT PRIMARY KEY, - alias TEXT - ); - """) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS embeddings ( - doc_id INTEGER PRIMARY KEY, - vec BLOB - ); - """) + cursor.execute("CREATE VIRTUAL TABLE IF NOT EXISTS documents USING fts5(filename, path, content);") + cursor.execute("CREATE TABLE IF NOT EXISTS folders (path TEXT PRIMARY KEY, alias TEXT);") + cursor.execute("CREATE TABLE IF NOT EXISTS embeddings (doc_id INTEGER PRIMARY KEY, vec BLOB);") conn.commit() conn.close() @@ -143,19 +165,17 @@ class DatabaseHandler: conn.execute("INSERT OR IGNORE INTO folders (path, alias) VALUES (?, ?)", (path, os.path.basename(path))) conn.commit() return True - except: - return False - finally: - conn.close() + except: return False + finally: conn.close() def remove_folder(self, path): conn = sqlite3.connect(self.db_name) cursor = conn.cursor() cursor.execute("SELECT rowid FROM documents WHERE path LIKE ?", (f"{path}%",)) - ids_to_delete = [row[0] for row in cursor.fetchall()] - if ids_to_delete: + ids = [row[0] for row in cursor.fetchall()] + if ids: cursor.execute("DELETE FROM documents WHERE path LIKE ?", (f"{path}%",)) - cursor.execute(f"DELETE FROM embeddings WHERE doc_id IN ({','.join('?'*len(ids_to_delete))})", ids_to_delete) + cursor.execute(f"DELETE FROM embeddings WHERE doc_id IN ({','.join('?'*len(ids))})", ids) cursor.execute("DELETE FROM folders WHERE path = ?", (path,)) conn.commit() conn.close() @@ -169,455 +189,351 @@ class DatabaseHandler: def search(self, query): if not query.strip() or not self.model: return [] - # PHASE 1: SEMANTIK - query_embedding = self.model.encode(query, convert_to_tensor=False) + # 1. Semantik + q_vec = self.model.encode(query, convert_to_tensor=False) conn = sqlite3.connect(self.db_name) cursor = conn.cursor() - cursor.execute("SELECT doc_id, vec FROM embeddings") - all_embeddings_data = cursor.fetchall() - doc_ids = [item[0] for item in all_embeddings_data] + cursor.execute("SELECT doc_id, vec FROM embeddings") + data = cursor.fetchall() + doc_ids = [d[0] for d in data] if not doc_ids: - conn.close() - return [] + conn.close(); return [] - all_embeddings = np.array([np.frombuffer(item[1], dtype=np.float32) for item in all_embeddings_data]) - cos_scores = util.cos_sim(query_embedding, all_embeddings)[0].numpy() - cos_scores = np.clip(cos_scores, 0, 1) - semantic_map = {doc_id: float(score) for doc_id, score in zip(doc_ids, cos_scores)} + vecs = np.array([np.frombuffer(d[1], dtype=np.float32) for d in data]) + scores = util.cos_sim(q_vec, vecs)[0].numpy() + scores = np.clip(scores, 0, 1) + sem_map = {did: float(s) for did, s in zip(doc_ids, scores)} - # PHASE 2: LEXIKALISCH + # 2. Lexikalisch words = query.replace('"', '').split() if not words: words = [query] - sql_query_parts = [f'"{w}"*' for w in words] - sql_query_string = " OR ".join(sql_query_parts) + fts_query = " OR ".join([f'"{w}"*' for w in words]) try: - fts_rows = cursor.execute(""" - SELECT rowid, filename, content - FROM documents - WHERE documents MATCH ? - LIMIT 100 - """, (sql_query_string,)).fetchall() - except: - fts_rows = [] + fts_rows = cursor.execute("SELECT rowid, filename, content FROM documents WHERE documents MATCH ? LIMIT 100", (fts_query,)).fetchall() + except: fts_rows = [] - lexical_map = {} - for doc_id, filename, content in fts_rows: - ratio_name = fuzz.partial_ratio(query.lower(), filename.lower()) - ratio_content = fuzz.partial_token_set_ratio(query.lower(), content[:5000].lower()) - best_ratio = max(ratio_name, ratio_content) - lexical_map[doc_id] = best_ratio / 100.0 + lex_map = {} + for did, fname, content in fts_rows: + r1 = fuzz.partial_ratio(query.lower(), fname.lower()) + r2 = fuzz.partial_token_set_ratio(query.lower(), content[:5000].lower()) + lex_map[did] = max(r1, r2) / 100.0 - # PHASE 3: HYBRID - final_scores = {} - ALPHA = 0.65 - BETA = 0.35 - for doc_id, sem_score in semantic_map.items(): - if sem_score < 0.15 and doc_id not in lexical_map: - continue - lex_score = lexical_map.get(doc_id, 0.0) - hybrid_score = (sem_score * ALPHA) + (lex_score * BETA) - if sem_score > 0.4 and lex_score > 0.6: - hybrid_score += 0.1 - final_scores[doc_id] = hybrid_score + # 3. Hybrid + final = {} + ALPHA = 0.65 + BETA = 0.35 + for did, s_score in sem_map.items(): + if s_score < 0.15 and did not in lex_map: continue + l_score = lex_map.get(did, 0.0) + h_score = (s_score * ALPHA) + (l_score * BETA) + if s_score > 0.4 and l_score > 0.6: h_score += 0.1 + final[did] = h_score - # PHASE 4: SORT - sorted_ids = sorted(final_scores.keys(), key=lambda x: final_scores[x], reverse=True) + # 4. Fetch + sorted_ids = sorted(final.keys(), key=lambda x: final[x], reverse=True)[:50] results = [] - for doc_id in sorted_ids[:50]: - row = cursor.execute( - "SELECT filename, path, snippet(documents, 2, '', '', '...', 15) FROM documents WHERE rowid = ?", - (doc_id,) - ).fetchone() - if row: - results.append(row) + for did in sorted_ids: + row = cursor.execute("SELECT filename, path, snippet(documents, 2, '', '', '...', 15) FROM documents WHERE rowid = ?", (did,)).fetchone() + if row: results.append(row) conn.close() return results -# --- 2. MODEL LOADER --- +# --- 2. THREADS --- + class ModelLoaderThread(QThread): model_loaded = pyqtSignal(object) - def run(self): - print("Lade das semantische Modell (all-MiniLM-L6-v2)...") try: model = SentenceTransformer('all-MiniLM-L6-v2') - print("Modell geladen.") self.model_loaded.emit(model) - except Exception as e: - print(f"Fehler beim Laden des Modells: {e}") - self.model_loaded.emit(None) + except: self.model_loaded.emit(None) -# --- 3. INDEXER --- class IndexerThread(QThread): progress_signal = pyqtSignal(str) finished_signal = pyqtSignal(int, int, bool) - def __init__(self, folder_path, db_name, model): + def __init__(self, folder, db_name, model): super().__init__() - self.folder_path = folder_path + self.folder_path = folder self.db_name = db_name self.model = model self.is_running = True - def stop(self): - self.is_running = False + def stop(self): self.is_running = False - def _extract_text_from_stream(self, file_stream, filename): + def _extract_text(self, stream, filename): ext = os.path.splitext(filename)[1].lower() text = "" try: if ext == ".pdf": try: - with pdfplumber.open(file_stream) as pdf: - for page in pdf.pages: - try: - if page_text := page.extract_text(): - text += page_text + "\n" - except Exception as e: - print(f"Warnung: Konnte eine Seite in '{filename}' nicht lesen. Fehler: {e}") - continue - except Exception as e: - print(f"Warnung: PDF '{filename}' defekt. Fehler: {e}") - return None + with pdfplumber.open(stream) as pdf: + for p in pdf.pages: + if t := p.extract_text(): text += t + "\n" + except: pass elif ext in [".txt", ".md", ".py", ".json", ".csv", ".html", ".log", ".ini", ".xml"]: - if hasattr(file_stream, 'read'): - content_bytes = file_stream.read() - if isinstance(content_bytes, str): - with open(file_stream, 'r', encoding='utf-8', errors='ignore') as f: - text = f.read() - else: - text = content_bytes.decode('utf-8', errors='ignore') - else: - with open(file_stream, "r", encoding="utf-8", errors="ignore") as f: - text = f.read() - except Exception as e: - return None + try: + content = stream.read() + if isinstance(content, str): text = content + else: text = content.decode('utf-8', errors='ignore') + except: pass + except: pass return text def run(self): conn = sqlite3.connect(self.db_name) cursor = conn.cursor() + # Cleanup cursor.execute("SELECT rowid FROM documents WHERE path LIKE ?", (f"{self.folder_path}%",)) - ids_to_delete = [row[0] for row in cursor.fetchall()] - if ids_to_delete: + ids = [r[0] for r in cursor.fetchall()] + if ids: cursor.execute("DELETE FROM documents WHERE path LIKE ?", (f"{self.folder_path}%",)) - cursor.execute(f"DELETE FROM embeddings WHERE doc_id IN ({','.join('?'*len(ids_to_delete))})", ids_to_delete) + cursor.execute(f"DELETE FROM embeddings WHERE doc_id IN ({','.join('?'*len(ids))})", ids) conn.commit() indexed = 0 skipped = 0 - was_cancelled = False + cancelled = False for root, dirs, files in os.walk(self.folder_path): - if not self.is_running: - was_cancelled = True - break - + if not self.is_running: cancelled = True; break for file in files: - if not self.is_running: - was_cancelled = True - break - - file_path = os.path.join(root, file) + if not self.is_running: cancelled = True; break + path = os.path.join(root, file) self.progress_signal.emit(f"Prüfe: {file}...") if file.lower().endswith('.zip'): try: - with zipfile.ZipFile(file_path, 'r') as z: - for z_info in z.infolist(): - if z_info.is_dir(): continue - virtual_path = f"{file_path} :: {z_info.filename}" - with z.open(z_info) as z_file: - file_in_memory = io.BytesIO(z_file.read()) - content = self._extract_text_from_stream(file_in_memory, z_info.filename) + with zipfile.ZipFile(path, 'r') as z: + for zi in z.infolist(): + if zi.is_dir(): continue + vpath = f"{path} :: {zi.filename}" + with z.open(zi) as zf: + content = self._extract_text(io.BytesIO(zf.read()), zi.filename) if content and len(content.strip()) > 20: - self._save_to_db(cursor, z_info.filename, virtual_path, content) + self._save(cursor, zi.filename, vpath, content) indexed += 1 - except Exception as e: - print(f"Zip Error {file}: {e}") - skipped += 1 + except: skipped += 1 else: - content = self._extract_text_from_stream(file_path, file) + with open(path, "rb") as f: + content = self._extract_text(f, file) if content and len(content.strip()) > 20: - self._save_to_db(cursor, file, file_path, content) + self._save(cursor, file, path, content) indexed += 1 - else: - skipped += 1 - - if was_cancelled: break + else: skipped += 1 + if cancelled: break conn.commit() conn.close() - self.finished_signal.emit(indexed, skipped, was_cancelled) + self.finished_signal.emit(indexed, skipped, cancelled) - def _save_to_db(self, cursor, filename, path, content): - cursor.execute("INSERT INTO documents (filename, path, content) VALUES (?, ?, ?)", (filename, path, content)) - doc_id = cursor.lastrowid - embedding = self.model.encode(content[:8000], convert_to_tensor=False) - embedding_blob = embedding.tobytes() - cursor.execute("INSERT INTO embeddings (doc_id, vec) VALUES (?, ?)", (doc_id, embedding_blob)) + def _save(self, cursor, fname, path, content): + cursor.execute("INSERT INTO documents (filename, path, content) VALUES (?, ?, ?)", (fname, path, content)) + did = cursor.lastrowid + vec = self.model.encode(content[:8000], convert_to_tensor=False).tobytes() + cursor.execute("INSERT INTO embeddings (doc_id, vec) VALUES (?, ?)", (did, vec)) - -# --- 4. UI --- +# --- 3. UI --- class UffWindow(QMainWindow): def __init__(self, splash=None): super().__init__() self.splash = splash self.db = DatabaseHandler() - self.indexer_thread = None self.initUI() self.load_saved_folders() def initUI(self): - self.setWindowTitle("UFF Text Search") + self.setWindowTitle("UFF Search v6.0 (Widget List)") self.resize(1000, 700) - central = QWidget() self.setCentralWidget(central) main_layout = QHBoxLayout(central) - # LINKS + # -- LINKS (Sidebar) -- left_panel = QFrame() left_panel.setFixedWidth(250) + left_panel.setStyleSheet("background-color: #f0f0f0; border-right: 1px solid #ccc;") left_layout = QVBoxLayout(left_panel) - left_layout.setContentsMargins(0, 0, 0, 0) - lbl_folders = QLabel("📂 Meine Ordner") - lbl_folders.setStyleSheet("font-weight: bold; font-size: 14px;") self.folder_list = QListWidget() - self.folder_list.setSelectionMode(QListWidget.SelectionMode.SingleSelection) - - self.btn_add = QPushButton(" + Hinzufügen") - self.btn_add.clicked.connect(self.add_new_folder) - self.btn_remove = QPushButton(" - Entfernen") - self.btn_remove.clicked.connect(self.delete_selected_folder) - self.btn_rescan = QPushButton(" ↻ Neu scannen") + self.folder_list.setStyleSheet("border: 1px solid #ddd; background: white;") + + btn_add = QPushButton(" + Ordner") + btn_add.clicked.connect(self.add_new_folder) + btn_del = QPushButton(" - Löschen") + btn_del.clicked.connect(self.delete_selected_folder) + self.btn_rescan = QPushButton(" ↻ Scan") self.btn_rescan.clicked.connect(self.rescan_selected_folder) - self.btn_cancel = QPushButton("🛑 Abbrechen") - self.btn_cancel.setStyleSheet("background-color: #ffcccc; color: #cc0000; font-weight: bold;") + self.btn_cancel = QPushButton("🛑 Stop") self.btn_cancel.clicked.connect(self.cancel_indexing) self.btn_cancel.hide() + self.btn_cancel.setStyleSheet("background-color: #ffcccc; color: red;") - left_layout.addWidget(lbl_folders) + left_layout.addWidget(QLabel("📂 Indizierte Ordner")) left_layout.addWidget(self.folder_list) - left_layout.addWidget(self.btn_add) - left_layout.addWidget(self.btn_remove) + left_layout.addWidget(btn_add) + left_layout.addWidget(btn_del) left_layout.addStretch() left_layout.addWidget(self.btn_rescan) left_layout.addWidget(self.btn_cancel) - # RECHTS + # -- RECHTS (Suche & Ergebnisse) -- right_panel = QWidget() right_layout = QVBoxLayout(right_panel) - - search_container = QHBoxLayout() + + # Suchleiste + search_box = QHBoxLayout() self.input_search = QLineEdit() - self.input_search.setPlaceholderText("Suche... (Hybrid: Inhalt + Keywords)") - self.input_search.returnPressed.connect(self.perform_search) + self.input_search.setPlaceholderText("Suchbegriff eingeben...") self.input_search.setStyleSheet("padding: 8px; font-size: 14px;") + self.input_search.returnPressed.connect(self.perform_search) self.btn_go = QPushButton("Suchen") self.btn_go.setFixedWidth(100) + self.btn_go.setStyleSheet("background-color: #2980b9; color: white; padding: 8px; font-weight: bold;") self.btn_go.clicked.connect(self.perform_search) - search_container.addWidget(self.input_search) - search_container.addWidget(self.btn_go) + search_box.addWidget(self.input_search) + search_box.addWidget(self.btn_go) - self.lbl_status = QLabel("Initialisiere...") - self.lbl_status.setStyleSheet("color: #666;") + # Status & Progress + self.lbl_status = QLabel("Warte auf Modell...") self.progress_bar = QProgressBar() self.progress_bar.hide() - # STANDARD BROWSER MIT RICHTIGEN EINSTELLUNGEN - self.result_browser = QTextBrowser() - # WICHTIG: Interne Links deaktivieren, damit wir sie abfangen können - self.result_browser.setOpenExternalLinks(False) - # Wenn wir darauf klicken, wird unser Slot aufgerufen - self.result_browser.anchorClicked.connect(self.link_clicked) + # ERGEBNIS-BEREICH (QScrollArea statt QTextBrowser) + self.scroll_area = QScrollArea() + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setStyleSheet("background-color: #fafafa; border: none;") - self.result_browser.setStyleSheet("background-color: white; border: 1px solid #ccc;") + # Container Widget für die Ergebnisse + self.results_container = QWidget() + self.results_container.setStyleSheet("background-color: transparent;") + self.results_layout = QVBoxLayout(self.results_container) + self.results_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.results_layout.setSpacing(10) + + self.scroll_area.setWidget(self.results_container) - right_layout.addLayout(search_container) + right_layout.addLayout(search_box) right_layout.addWidget(self.lbl_status) right_layout.addWidget(self.progress_bar) - right_layout.addWidget(self.result_browser) + right_layout.addWidget(self.scroll_area) - splitter = QSplitter(Qt.Orientation.Horizontal) + # Splitter + splitter = QSplitter() splitter.addWidget(left_panel) splitter.addWidget(right_panel) splitter.setSizes([250, 750]) - + main_layout.addWidget(splitter) - self.set_main_ui_enabled(False) + self.set_ui_enabled(False) - def set_main_ui_enabled(self, enabled): + def set_ui_enabled(self, enabled): self.input_search.setEnabled(enabled) self.btn_go.setEnabled(enabled) self.folder_list.setEnabled(enabled) - self.btn_add.setEnabled(enabled) - self.btn_remove.setEnabled(enabled) - self.btn_rescan.setEnabled(enabled) def start_model_loading(self): - if self.splash: - self.splash.showMessage("Lade semantisches Modell...", Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter, Qt.GlobalColor.white) - self.model_loader = ModelLoaderThread() - self.model_loader.model_loaded.connect(self.on_model_loaded) - self.model_loader.start() + if self.splash: self.splash.showMessage("Lade KI-Modell...", Qt.AlignmentFlag.AlignBottom, Qt.GlobalColor.white) + self.loader = ModelLoaderThread() + self.loader.model_loaded.connect(self.on_model_loaded) + self.loader.start() def on_model_loaded(self, model): - if self.splash: - self.splash.showMessage("Modell geladen. Starte Benutzeroberfläche...", Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter, Qt.GlobalColor.white) - - if model is None: - self.lbl_status.setText("Fehler: Modell konnte nicht geladen werden.") - QMessageBox.critical(self, "Kritischer Fehler", "Das semantische Modell konnte nicht geladen werden.") - self.close() - else: - self.db.model = model - self.lbl_status.setText("Bereit. Hybrid-Modell geladen.") - self.set_main_ui_enabled(True) - - if self.splash: - self.splash.finish(self) - - def load_saved_folders(self): - self.folder_list.clear() - folders = self.db.get_folders() - for f in folders: - item = QListWidgetItem(f) - item.setToolTip(f) - self.folder_list.addItem(item) - - def add_new_folder(self): - folder = QFileDialog.getExistingDirectory(self, "Ordner wählen") - if folder: - if self.db.add_folder(folder): - self.load_saved_folders() - self.start_indexing(folder) - else: - QMessageBox.warning(self, "Info", "Ordner ist bereits vorhanden.") - - def delete_selected_folder(self): - item = self.folder_list.currentItem() - if not item: return - path = item.text() - if QMessageBox.question(self, "Löschen", f"Ordner entfernen?\n{path}", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) == QMessageBox.StandardButton.Yes: - self.db.remove_folder(path) - self.load_saved_folders() - self.result_browser.clear() - self.lbl_status.setText("Ordner entfernt.") - - def rescan_selected_folder(self): - item = self.folder_list.currentItem() - if not item: - QMessageBox.information(self, "Info", "Bitte Ordner links auswählen.") + if self.splash: self.splash.finish(self) + if not model: + QMessageBox.critical(self, "Fehler", "Modell konnte nicht geladen werden.") return - self.start_indexing(item.text()) - - def start_indexing(self, folder): - if not self.db.model: - QMessageBox.warning(self, "Bitte warten", "Das Suchmodell wird noch geladen.") - return - - self.set_ui_busy(True) - self.lbl_status.setText(f"Starte... {os.path.basename(folder)}") - - self.indexer_thread = IndexerThread(folder, db_name=self.db.db_name, model=self.db.model) - self.indexer_thread.progress_signal.connect(lambda msg: self.lbl_status.setText(msg)) - self.indexer_thread.finished_signal.connect(self.indexing_finished) - self.indexer_thread.start() - - def cancel_indexing(self): - if self.indexer_thread and self.indexer_thread.isRunning(): - self.lbl_status.setText("Breche ab...") - self.indexer_thread.stop() - - def indexing_finished(self, indexed, skipped, was_cancelled): - self.set_ui_busy(False) - if was_cancelled: - self.lbl_status.setText(f"Abgebrochen. ({indexed} indiziert).") - QMessageBox.information(self, "Abbruch", f"Vorgang abgebrochen.\nBis dahin indiziert: {indexed}") - else: - self.lbl_status.setText(f"Fertig. {indexed} neu, {skipped} übersprungen.") - QMessageBox.information(self, "Fertig", f"Scan abgeschlossen!\n{indexed} Dateien im Index.") - - def set_ui_busy(self, busy): - self.input_search.setEnabled(not busy) - self.folder_list.setEnabled(not busy) - self.btn_add.setEnabled(not busy) - self.btn_remove.setEnabled(not busy) - self.btn_go.setEnabled(not busy) - self.btn_rescan.setVisible(not busy) - self.btn_cancel.setVisible(busy) - if busy: - self.progress_bar.setRange(0, 0) - self.progress_bar.show() - else: - self.progress_bar.hide() + self.db.model = model + self.lbl_status.setText("Bereit.") + self.set_ui_enabled(True) def perform_search(self): query = self.input_search.text() if not query: return self.lbl_status.setText("Suche läuft...") - QApplication.processEvents() + QApplication.processEvents() # UI Update erzwingen + # 1. Alte Ergebnisse löschen + while self.results_layout.count(): + child = self.results_layout.takeAt(0) + if child.widget(): + child.widget().deleteLater() + + # 2. Suchen results = self.db.search(query) - self.lbl_status.setText(f"{len(results)} relevante Treffer.") - - html = "" - if not results: - html = "

Nichts gefunden.

" - - for filename, filepath, snippet in results: - if " :: " in filepath: - real_path = filepath.split(" :: ")[0] - display_path = filepath - else: - real_path = filepath - display_path = filepath - - # Link für QTextBrowser - file_url = QUrl.fromLocalFile(real_path).toString() - - html += f""" -
- - {filename} - -
{snippet}
-
{display_path}
-
- """ - self.result_browser.setHtml(html) + self.lbl_status.setText(f"{len(results)} Treffer.") - # --- DIE FUNKTION ZUM ÖFFNEN DER LINKS --- - def link_clicked(self, url): - print(f"Versuche zu öffnen: {url.toString()}") - QDesktopServices.openUrl(url) + # 3. Neue Ergebnisse als Widgets hinzufügen + if not results: + lbl = QLabel("Keine Ergebnisse gefunden.") + lbl.setStyleSheet("color: #777; font-size: 14pt; margin-top: 20px;") + lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.results_layout.addWidget(lbl) + else: + for fname, fpath, snippet in results: + item = SearchResultItem(fname, fpath, snippet) + self.results_layout.addWidget(item) + + # Stretch am Ende, damit alles oben bleibt + self.results_layout.addStretch() + + # --- Folder Management --- + def load_saved_folders(self): + self.folder_list.clear() + for f in self.db.get_folders(): + self.folder_list.addItem(QListWidgetItem(f)) + + def add_new_folder(self): + f = QFileDialog.getExistingDirectory(self, "Ordner wählen") + if f and self.db.add_folder(f): + self.load_saved_folders() + self.start_idx(f) + + def delete_selected_folder(self): + item = self.folder_list.currentItem() + if item and QMessageBox.question(self, "Löschen", f"Weg damit?\n{item.text()}", QMessageBox.StandardButton.Yes|QMessageBox.StandardButton.No) == QMessageBox.StandardButton.Yes: + self.db.remove_folder(item.text()) + self.load_saved_folders() + + def rescan_selected_folder(self): + if item := self.folder_list.currentItem(): self.start_idx(item.text()) + + def start_idx(self, folder): + if not self.db.model: return + self.set_ui_enabled(False) + self.btn_cancel.show(); self.btn_rescan.hide(); self.progress_bar.show() + self.idx_thread = IndexerThread(folder, self.db.db_name, self.db.model) + self.idx_thread.progress_signal.connect(self.lbl_status.setText) + self.idx_thread.finished_signal.connect(self.idx_done) + self.idx_thread.start() + + def cancel_indexing(self): + if self.idx_thread: self.idx_thread.stop() + + def idx_done(self, n, s, c): + self.set_ui_enabled(True) + self.btn_cancel.hide(); self.btn_rescan.show(); self.progress_bar.hide() + msg = "Abgebrochen" if c else "Fertig" + self.lbl_status.setText(f"{msg}: {n} neu, {s} übersprungen.") if __name__ == "__main__": app = QApplication(sys.argv) splash = None try: - pixmap = QPixmap("assets/uff_banner.jpeg") - splash = QSplashScreen(pixmap) - splash.show() - splash.showMessage("Initialisiere Anwendung...", Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter, Qt.GlobalColor.white) - except: - pass + if os.path.exists("assets/uff_banner.jpeg"): + splash = QSplashScreen(QPixmap("assets/uff_banner.jpeg")) + splash.show() + except: pass - app.processEvents() - - window = UffWindow(splash) - window.show() - window.start_model_loading() - + w = UffWindow(splash) + w.show() + w.start_model_loading() sys.exit(app.exec()) \ No newline at end of file