Refactor UI components and improve logging; enhance search functionality with better error handling and folder management

This commit is contained in:
2026-01-09 19:01:28 +01:00
parent d24f6591bd
commit d5e1a537b7

View File

@@ -10,15 +10,15 @@ import traceback
from sentence_transformers import SentenceTransformer, util from sentence_transformers import SentenceTransformer, util
from rapidfuzz import process, fuzz from rapidfuzz import process, fuzz
# Wichtige Importe für UI und Signale from PyQt6.QtCore import qInstallMessageHandler, QtMsgType, Qt, QThread, pyqtSignal, QUrl, QSize
from PyQt6.QtCore import qInstallMessageHandler, QtMsgType, Qt, QThread, pyqtSignal, QUrl
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QLineEdit, QPushButton, QLabel, QHBoxLayout, QLineEdit, QPushButton, QLabel,
QFileDialog, QTextBrowser, QProgressBar, QMessageBox, QFileDialog, QProgressBar, QMessageBox,
QListWidget, QListWidgetItem, QSplitter, QFrame, QSplashScreen) QListWidget, QListWidgetItem, QSplitter, QFrame,
from PyQt6.QtGui import QDesktopServices, QPixmap QSplashScreen, QScrollArea, QStyle)
from PyQt6.QtGui import QDesktopServices, QPixmap, QCursor, QAction
# --- 0. LOGGING & SYSTEM-SETUP --- # --- 0. LOGGING & SETUP ---
if os.name == 'nt': if os.name == 'nt':
base_dir = os.getenv('LOCALAPPDATA') 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") log_file_path = os.path.join(log_dir, "uff.log")
# Logger-Klasse
class Logger(object): class Logger(object):
def __init__(self): def __init__(self):
self.log = open(log_file_path, "w", encoding="utf-8") self.log = open(log_file_path, "w", encoding="utf-8")
@@ -43,97 +42,120 @@ class Logger(object):
def flush(self): def flush(self):
self.log.flush() self.log.flush()
# stdout und stderr umleiten
sys.stdout = Logger() sys.stdout = Logger()
sys.stderr = sys.stdout sys.stderr = sys.stdout
print(f"--- START LOGGING ---") 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): def qt_message_handler(mode, context, message):
"""
Fängt interne Qt-Nachrichten ab und filtert Font-Fehler heraus.
"""
msg_lower = message.lower() msg_lower = message.lower()
# FILTER-LISTE: Erweitert basierend auf deinen Logs
ignore_keywords = [ ignore_keywords = [
"qt.text.font", "qt.text.font", "qt.qpa.fonts", "opentype", "directwrite",
"qt.qpa.fonts", "unable to create font", "fontbbox", "script"
"opentype support missing",
"directwrite",
"unable to create font",
"fontbbox",
"script 66",
"script 9",
"script 10",
"script 20",
"script 32"
] ]
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: try:
sys.stdout.write(f"[Qt {mode_str}] {message}\n") sys.stdout.write(f"[Qt] {message}\n")
except: except: pass
pass
# Handler installieren (Muss VOR der App-Erstellung passieren)
qInstallMessageHandler(qt_message_handler) qInstallMessageHandler(qt_message_handler)
# Zusätzlich Environment Variable setzen
os.environ["QT_LOGGING_RULES"] = "qt.text.font.db=false;qt.qpa.fonts=false" 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 --- # --- 1. DATENBANK MANAGER ---
class DatabaseHandler: class DatabaseHandler:
def __init__(self): def __init__(self):
if os.name == 'nt': self.app_data_dir = log_dir
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.db_name = os.path.join(self.app_data_dir, "uff_index.db") self.db_name = os.path.join(self.app_data_dir, "uff_index.db")
print(f"Datenbank Pfad: {self.db_name}")
self.model = None self.model = None
self.init_db() self.init_db()
def init_db(self): def init_db(self):
conn = sqlite3.connect(self.db_name) conn = sqlite3.connect(self.db_name)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("CREATE VIRTUAL TABLE IF NOT EXISTS documents USING fts5(filename, path, content);")
CREATE VIRTUAL TABLE IF NOT EXISTS documents cursor.execute("CREATE TABLE IF NOT EXISTS folders (path TEXT PRIMARY KEY, alias TEXT);")
USING fts5(filename, path, content); cursor.execute("CREATE TABLE IF NOT EXISTS embeddings (doc_id INTEGER PRIMARY KEY, vec BLOB);")
""")
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.commit()
conn.close() conn.close()
@@ -143,19 +165,17 @@ class DatabaseHandler:
conn.execute("INSERT OR IGNORE INTO folders (path, alias) VALUES (?, ?)", (path, os.path.basename(path))) conn.execute("INSERT OR IGNORE INTO folders (path, alias) VALUES (?, ?)", (path, os.path.basename(path)))
conn.commit() conn.commit()
return True return True
except: except: return False
return False finally: conn.close()
finally:
conn.close()
def remove_folder(self, path): def remove_folder(self, path):
conn = sqlite3.connect(self.db_name) conn = sqlite3.connect(self.db_name)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT rowid FROM documents WHERE path LIKE ?", (f"{path}%",)) cursor.execute("SELECT rowid FROM documents WHERE path LIKE ?", (f"{path}%",))
ids_to_delete = [row[0] for row in cursor.fetchall()] ids = [row[0] for row in cursor.fetchall()]
if ids_to_delete: if ids:
cursor.execute("DELETE FROM documents WHERE path LIKE ?", (f"{path}%",)) 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,)) cursor.execute("DELETE FROM folders WHERE path = ?", (path,))
conn.commit() conn.commit()
conn.close() conn.close()
@@ -169,455 +189,351 @@ class DatabaseHandler:
def search(self, query): def search(self, query):
if not query.strip() or not self.model: return [] if not query.strip() or not self.model: return []
# PHASE 1: SEMANTIK # 1. Semantik
query_embedding = self.model.encode(query, convert_to_tensor=False) q_vec = self.model.encode(query, convert_to_tensor=False)
conn = sqlite3.connect(self.db_name) conn = sqlite3.connect(self.db_name)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT doc_id, vec FROM embeddings") cursor.execute("SELECT doc_id, vec FROM embeddings")
all_embeddings_data = cursor.fetchall() data = cursor.fetchall()
doc_ids = [item[0] for item in all_embeddings_data] doc_ids = [d[0] for d in data]
if not doc_ids: if not doc_ids:
conn.close() conn.close(); return []
return []
all_embeddings = np.array([np.frombuffer(item[1], dtype=np.float32) for item in all_embeddings_data]) vecs = np.array([np.frombuffer(d[1], dtype=np.float32) for d in data])
cos_scores = util.cos_sim(query_embedding, all_embeddings)[0].numpy() scores = util.cos_sim(q_vec, vecs)[0].numpy()
cos_scores = np.clip(cos_scores, 0, 1) scores = np.clip(scores, 0, 1)
semantic_map = {doc_id: float(score) for doc_id, score in zip(doc_ids, cos_scores)} sem_map = {did: float(s) for did, s in zip(doc_ids, scores)}
# PHASE 2: LEXIKALISCH # 2. Lexikalisch
words = query.replace('"', '').split() words = query.replace('"', '').split()
if not words: words = [query] if not words: words = [query]
sql_query_parts = [f'"{w}"*' for w in words] fts_query = " OR ".join([f'"{w}"*' for w in words])
sql_query_string = " OR ".join(sql_query_parts)
try: try:
fts_rows = cursor.execute(""" fts_rows = cursor.execute("SELECT rowid, filename, content FROM documents WHERE documents MATCH ? LIMIT 100", (fts_query,)).fetchall()
SELECT rowid, filename, content except: fts_rows = []
FROM documents
WHERE documents MATCH ?
LIMIT 100
""", (sql_query_string,)).fetchall()
except:
fts_rows = []
lexical_map = {} lex_map = {}
for doc_id, filename, content in fts_rows: for did, fname, content in fts_rows:
ratio_name = fuzz.partial_ratio(query.lower(), filename.lower()) r1 = fuzz.partial_ratio(query.lower(), fname.lower())
ratio_content = fuzz.partial_token_set_ratio(query.lower(), content[:5000].lower()) r2 = fuzz.partial_token_set_ratio(query.lower(), content[:5000].lower())
best_ratio = max(ratio_name, ratio_content) lex_map[did] = max(r1, r2) / 100.0
lexical_map[doc_id] = best_ratio / 100.0
# PHASE 3: HYBRID # 3. Hybrid
final_scores = {} final = {}
ALPHA = 0.65 ALPHA = 0.65
BETA = 0.35 BETA = 0.35
for doc_id, sem_score in semantic_map.items(): for did, s_score in sem_map.items():
if sem_score < 0.15 and doc_id not in lexical_map: if s_score < 0.15 and did not in lex_map: continue
continue l_score = lex_map.get(did, 0.0)
lex_score = lexical_map.get(doc_id, 0.0) h_score = (s_score * ALPHA) + (l_score * BETA)
hybrid_score = (sem_score * ALPHA) + (lex_score * BETA) if s_score > 0.4 and l_score > 0.6: h_score += 0.1
if sem_score > 0.4 and lex_score > 0.6: final[did] = h_score
hybrid_score += 0.1
final_scores[doc_id] = hybrid_score
# PHASE 4: SORT # 4. Fetch
sorted_ids = sorted(final_scores.keys(), key=lambda x: final_scores[x], reverse=True) sorted_ids = sorted(final.keys(), key=lambda x: final[x], reverse=True)[:50]
results = [] results = []
for doc_id in sorted_ids[:50]: for did in sorted_ids:
row = cursor.execute( row = cursor.execute("SELECT filename, path, snippet(documents, 2, '<b>', '</b>', '...', 15) FROM documents WHERE rowid = ?", (did,)).fetchone()
"SELECT filename, path, snippet(documents, 2, '<b>', '</b>', '...', 15) FROM documents WHERE rowid = ?", if row: results.append(row)
(doc_id,)
).fetchone()
if row:
results.append(row)
conn.close() conn.close()
return results return results
# --- 2. MODEL LOADER --- # --- 2. THREADS ---
class ModelLoaderThread(QThread): class ModelLoaderThread(QThread):
model_loaded = pyqtSignal(object) model_loaded = pyqtSignal(object)
def run(self): def run(self):
print("Lade das semantische Modell (all-MiniLM-L6-v2)...")
try: try:
model = SentenceTransformer('all-MiniLM-L6-v2') model = SentenceTransformer('all-MiniLM-L6-v2')
print("Modell geladen.")
self.model_loaded.emit(model) self.model_loaded.emit(model)
except Exception as e: except: self.model_loaded.emit(None)
print(f"Fehler beim Laden des Modells: {e}")
self.model_loaded.emit(None)
# --- 3. INDEXER ---
class IndexerThread(QThread): class IndexerThread(QThread):
progress_signal = pyqtSignal(str) progress_signal = pyqtSignal(str)
finished_signal = pyqtSignal(int, int, bool) finished_signal = pyqtSignal(int, int, bool)
def __init__(self, folder_path, db_name, model): def __init__(self, folder, db_name, model):
super().__init__() super().__init__()
self.folder_path = folder_path self.folder_path = folder
self.db_name = db_name self.db_name = db_name
self.model = model self.model = model
self.is_running = True self.is_running = True
def stop(self): def stop(self): self.is_running = False
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() ext = os.path.splitext(filename)[1].lower()
text = "" text = ""
try: try:
if ext == ".pdf": if ext == ".pdf":
try: try:
with pdfplumber.open(file_stream) as pdf: with pdfplumber.open(stream) as pdf:
for page in pdf.pages: for p in pdf.pages:
try: if t := p.extract_text(): text += t + "\n"
if page_text := page.extract_text(): except: pass
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
elif ext in [".txt", ".md", ".py", ".json", ".csv", ".html", ".log", ".ini", ".xml"]: elif ext in [".txt", ".md", ".py", ".json", ".csv", ".html", ".log", ".ini", ".xml"]:
if hasattr(file_stream, 'read'): try:
content_bytes = file_stream.read() content = stream.read()
if isinstance(content_bytes, str): if isinstance(content, str): text = content
with open(file_stream, 'r', encoding='utf-8', errors='ignore') as f: else: text = content.decode('utf-8', errors='ignore')
text = f.read() except: pass
else: except: pass
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
return text return text
def run(self): def run(self):
conn = sqlite3.connect(self.db_name) conn = sqlite3.connect(self.db_name)
cursor = conn.cursor() cursor = conn.cursor()
# Cleanup
cursor.execute("SELECT rowid FROM documents WHERE path LIKE ?", (f"{self.folder_path}%",)) cursor.execute("SELECT rowid FROM documents WHERE path LIKE ?", (f"{self.folder_path}%",))
ids_to_delete = [row[0] for row in cursor.fetchall()] ids = [r[0] for r in cursor.fetchall()]
if ids_to_delete: if ids:
cursor.execute("DELETE FROM documents WHERE path LIKE ?", (f"{self.folder_path}%",)) 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() conn.commit()
indexed = 0 indexed = 0
skipped = 0 skipped = 0
was_cancelled = False cancelled = False
for root, dirs, files in os.walk(self.folder_path): for root, dirs, files in os.walk(self.folder_path):
if not self.is_running: if not self.is_running: cancelled = True; break
was_cancelled = True
break
for file in files: for file in files:
if not self.is_running: if not self.is_running: cancelled = True; break
was_cancelled = True path = os.path.join(root, file)
break
file_path = os.path.join(root, file)
self.progress_signal.emit(f"Prüfe: {file}...") self.progress_signal.emit(f"Prüfe: {file}...")
if file.lower().endswith('.zip'): if file.lower().endswith('.zip'):
try: try:
with zipfile.ZipFile(file_path, 'r') as z: with zipfile.ZipFile(path, 'r') as z:
for z_info in z.infolist(): for zi in z.infolist():
if z_info.is_dir(): continue if zi.is_dir(): continue
virtual_path = f"{file_path} :: {z_info.filename}" vpath = f"{path} :: {zi.filename}"
with z.open(z_info) as z_file: with z.open(zi) as zf:
file_in_memory = io.BytesIO(z_file.read()) content = self._extract_text(io.BytesIO(zf.read()), zi.filename)
content = self._extract_text_from_stream(file_in_memory, z_info.filename)
if content and len(content.strip()) > 20: 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 indexed += 1
except Exception as e: except: skipped += 1
print(f"Zip Error {file}: {e}")
skipped += 1
else: 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: if content and len(content.strip()) > 20:
self._save_to_db(cursor, file, file_path, content) self._save(cursor, file, path, content)
indexed += 1 indexed += 1
else: else: skipped += 1
skipped += 1 if cancelled: break
if was_cancelled: break
conn.commit() conn.commit()
conn.close() 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): def _save(self, cursor, fname, path, content):
cursor.execute("INSERT INTO documents (filename, path, content) VALUES (?, ?, ?)", (filename, path, content)) cursor.execute("INSERT INTO documents (filename, path, content) VALUES (?, ?, ?)", (fname, path, content))
doc_id = cursor.lastrowid did = cursor.lastrowid
embedding = self.model.encode(content[:8000], convert_to_tensor=False) vec = self.model.encode(content[:8000], convert_to_tensor=False).tobytes()
embedding_blob = embedding.tobytes() cursor.execute("INSERT INTO embeddings (doc_id, vec) VALUES (?, ?)", (did, vec))
cursor.execute("INSERT INTO embeddings (doc_id, vec) VALUES (?, ?)", (doc_id, embedding_blob))
# --- 3. UI ---
# --- 4. UI ---
class UffWindow(QMainWindow): class UffWindow(QMainWindow):
def __init__(self, splash=None): def __init__(self, splash=None):
super().__init__() super().__init__()
self.splash = splash self.splash = splash
self.db = DatabaseHandler() self.db = DatabaseHandler()
self.indexer_thread = None
self.initUI() self.initUI()
self.load_saved_folders() self.load_saved_folders()
def initUI(self): def initUI(self):
self.setWindowTitle("UFF Text Search") self.setWindowTitle("UFF Search v6.0 (Widget List)")
self.resize(1000, 700) self.resize(1000, 700)
central = QWidget() central = QWidget()
self.setCentralWidget(central) self.setCentralWidget(central)
main_layout = QHBoxLayout(central) main_layout = QHBoxLayout(central)
# LINKS # -- LINKS (Sidebar) --
left_panel = QFrame() left_panel = QFrame()
left_panel.setFixedWidth(250) left_panel.setFixedWidth(250)
left_panel.setStyleSheet("background-color: #f0f0f0; border-right: 1px solid #ccc;")
left_layout = QVBoxLayout(left_panel) 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 = QListWidget()
self.folder_list.setSelectionMode(QListWidget.SelectionMode.SingleSelection) self.folder_list.setStyleSheet("border: 1px solid #ddd; background: white;")
self.btn_add = QPushButton(" + Hinzufügen") btn_add = QPushButton(" + Ordner")
self.btn_add.clicked.connect(self.add_new_folder) btn_add.clicked.connect(self.add_new_folder)
self.btn_remove = QPushButton(" - Entfernen") btn_del = QPushButton(" - Löschen")
self.btn_remove.clicked.connect(self.delete_selected_folder) btn_del.clicked.connect(self.delete_selected_folder)
self.btn_rescan = QPushButton("Neu scannen") self.btn_rescan = QPushButton("Scan")
self.btn_rescan.clicked.connect(self.rescan_selected_folder) self.btn_rescan.clicked.connect(self.rescan_selected_folder)
self.btn_cancel = QPushButton("🛑 Abbrechen") self.btn_cancel = QPushButton("🛑 Stop")
self.btn_cancel.setStyleSheet("background-color: #ffcccc; color: #cc0000; font-weight: bold;")
self.btn_cancel.clicked.connect(self.cancel_indexing) self.btn_cancel.clicked.connect(self.cancel_indexing)
self.btn_cancel.hide() self.btn_cancel.hide()
self.btn_cancel.setStyleSheet("background-color: #ffcccc; color: red;")
left_layout.addWidget(lbl_folders) left_layout.addWidget(QLabel("<b>📂 Indizierte Ordner</b>"))
left_layout.addWidget(self.folder_list) left_layout.addWidget(self.folder_list)
left_layout.addWidget(self.btn_add) left_layout.addWidget(btn_add)
left_layout.addWidget(self.btn_remove) left_layout.addWidget(btn_del)
left_layout.addStretch() left_layout.addStretch()
left_layout.addWidget(self.btn_rescan) left_layout.addWidget(self.btn_rescan)
left_layout.addWidget(self.btn_cancel) left_layout.addWidget(self.btn_cancel)
# RECHTS # -- RECHTS (Suche & Ergebnisse) --
right_panel = QWidget() right_panel = QWidget()
right_layout = QVBoxLayout(right_panel) right_layout = QVBoxLayout(right_panel)
search_container = QHBoxLayout() # Suchleiste
search_box = QHBoxLayout()
self.input_search = QLineEdit() self.input_search = QLineEdit()
self.input_search.setPlaceholderText("Suche... (Hybrid: Inhalt + Keywords)") self.input_search.setPlaceholderText("Suchbegriff eingeben...")
self.input_search.returnPressed.connect(self.perform_search)
self.input_search.setStyleSheet("padding: 8px; font-size: 14px;") 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 = QPushButton("Suchen")
self.btn_go.setFixedWidth(100) 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) self.btn_go.clicked.connect(self.perform_search)
search_container.addWidget(self.input_search) search_box.addWidget(self.input_search)
search_container.addWidget(self.btn_go) search_box.addWidget(self.btn_go)
self.lbl_status = QLabel("Initialisiere...") # Status & Progress
self.lbl_status.setStyleSheet("color: #666;") self.lbl_status = QLabel("Warte auf Modell...")
self.progress_bar = QProgressBar() self.progress_bar = QProgressBar()
self.progress_bar.hide() self.progress_bar.hide()
# STANDARD BROWSER MIT RICHTIGEN EINSTELLUNGEN # ERGEBNIS-BEREICH (QScrollArea statt QTextBrowser)
self.result_browser = QTextBrowser() self.scroll_area = QScrollArea()
# WICHTIG: Interne Links deaktivieren, damit wir sie abfangen können self.scroll_area.setWidgetResizable(True)
self.result_browser.setOpenExternalLinks(False) self.scroll_area.setStyleSheet("background-color: #fafafa; border: none;")
# Wenn wir darauf klicken, wird unser Slot aufgerufen
self.result_browser.anchorClicked.connect(self.link_clicked)
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)
right_layout.addLayout(search_container) self.scroll_area.setWidget(self.results_container)
right_layout.addLayout(search_box)
right_layout.addWidget(self.lbl_status) right_layout.addWidget(self.lbl_status)
right_layout.addWidget(self.progress_bar) 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(left_panel)
splitter.addWidget(right_panel) splitter.addWidget(right_panel)
splitter.setSizes([250, 750]) splitter.setSizes([250, 750])
main_layout.addWidget(splitter) 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.input_search.setEnabled(enabled)
self.btn_go.setEnabled(enabled) self.btn_go.setEnabled(enabled)
self.folder_list.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): def start_model_loading(self):
if self.splash: if self.splash: self.splash.showMessage("Lade KI-Modell...", Qt.AlignmentFlag.AlignBottom, Qt.GlobalColor.white)
self.splash.showMessage("Lade semantisches Modell...", Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter, Qt.GlobalColor.white) self.loader = ModelLoaderThread()
self.model_loader = ModelLoaderThread() self.loader.model_loaded.connect(self.on_model_loaded)
self.model_loader.model_loaded.connect(self.on_model_loaded) self.loader.start()
self.model_loader.start()
def on_model_loaded(self, model): def on_model_loaded(self, model):
if self.splash: if self.splash: self.splash.finish(self)
self.splash.showMessage("Modell geladen. Starte Benutzeroberfläche...", Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter, Qt.GlobalColor.white) if not model:
QMessageBox.critical(self, "Fehler", "Modell konnte nicht geladen werden.")
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.")
return return
self.start_indexing(item.text()) self.db.model = model
self.lbl_status.setText("Bereit.")
def start_indexing(self, folder): self.set_ui_enabled(True)
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()
def perform_search(self): def perform_search(self):
query = self.input_search.text() query = self.input_search.text()
if not query: return if not query: return
self.lbl_status.setText("Suche läuft...") 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) results = self.db.search(query)
self.lbl_status.setText(f"{len(results)} relevante Treffer.") self.lbl_status.setText(f"{len(results)} Treffer.")
html = "" # 3. Neue Ergebnisse als Widgets hinzufügen
if not results: if not results:
html = "<h3 style='color: gray; text-align: center; margin-top: 20px;'>Nichts gefunden.</h3>" 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)
for filename, filepath, snippet in results: # Stretch am Ende, damit alles oben bleibt
if " :: " in filepath: self.results_layout.addStretch()
real_path = filepath.split(" :: ")[0]
display_path = filepath
else:
real_path = filepath
display_path = filepath
# Link für QTextBrowser # --- Folder Management ---
file_url = QUrl.fromLocalFile(real_path).toString() def load_saved_folders(self):
self.folder_list.clear()
for f in self.db.get_folders():
self.folder_list.addItem(QListWidgetItem(f))
html += f""" def add_new_folder(self):
<div style='margin-bottom: 10px; padding: 10px; background-color: #f9f9f9; border-left: 4px solid #2980b9;'> f = QFileDialog.getExistingDirectory(self, "Ordner wählen")
<a href="{file_url}" style='font-size: 16px; font-weight: bold; color: #2980b9; text-decoration: none;'> if f and self.db.add_folder(f):
{filename} self.load_saved_folders()
</a> self.start_idx(f)
<div style='color: #333; margin-top: 5px; font-family: sans-serif; font-size: 13px;'>{snippet}</div>
<div style='color: #999; font-size: 11px; margin-top: 4px;'>{display_path}</div>
</div>
"""
self.result_browser.setHtml(html)
# --- DIE FUNKTION ZUM ÖFFNEN DER LINKS --- def delete_selected_folder(self):
def link_clicked(self, url): item = self.folder_list.currentItem()
print(f"Versuche zu öffnen: {url.toString()}") if item and QMessageBox.question(self, "Löschen", f"Weg damit?\n{item.text()}", QMessageBox.StandardButton.Yes|QMessageBox.StandardButton.No) == QMessageBox.StandardButton.Yes:
QDesktopServices.openUrl(url) 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__": if __name__ == "__main__":
app = QApplication(sys.argv) app = QApplication(sys.argv)
splash = None splash = None
try: try:
pixmap = QPixmap("assets/uff_banner.jpeg") if os.path.exists("assets/uff_banner.jpeg"):
splash = QSplashScreen(pixmap) splash = QSplashScreen(QPixmap("assets/uff_banner.jpeg"))
splash.show() splash.show()
splash.showMessage("Initialisiere Anwendung...", Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter, Qt.GlobalColor.white) except: pass
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()) sys.exit(app.exec())