made modular
This commit is contained in:
56
config.py
Normal file
56
config.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# config.py
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# --- PFADE ---
|
||||||
|
if os.name == 'nt':
|
||||||
|
base_dir = os.getenv('LOCALAPPDATA')
|
||||||
|
else:
|
||||||
|
base_dir = os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||||
|
|
||||||
|
APP_DATA_DIR = os.path.join(base_dir, "UFF_Search")
|
||||||
|
if not os.path.exists(APP_DATA_DIR):
|
||||||
|
os.makedirs(APP_DATA_DIR)
|
||||||
|
|
||||||
|
DB_NAME = os.path.join(APP_DATA_DIR, "uff_index.db")
|
||||||
|
LOG_FILE = os.path.join(APP_DATA_DIR, "uff.log")
|
||||||
|
|
||||||
|
# --- LOGGING KLASSE ---
|
||||||
|
class Logger(object):
|
||||||
|
def __init__(self):
|
||||||
|
self.log = open(LOG_FILE, "w", encoding="utf-8")
|
||||||
|
|
||||||
|
def write(self, message):
|
||||||
|
self.log.write(message)
|
||||||
|
self.log.flush()
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
self.log.flush()
|
||||||
|
|
||||||
|
# --- QT MESSAGE HANDLER (Filter) ---
|
||||||
|
def qt_message_handler(mode, context, message):
|
||||||
|
msg_lower = message.lower()
|
||||||
|
ignore = ["qt.text.font", "qt.qpa.fonts", "opentype", "directwrite", "fontbbox", "script"]
|
||||||
|
if any(k in msg_lower for k in ignore): return
|
||||||
|
try:
|
||||||
|
sys.stdout.write(f"[Qt] {message}\n")
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# --- STYLESHEET ---
|
||||||
|
STYLESHEET = """
|
||||||
|
QMainWindow { background-color: #f4f7f6; }
|
||||||
|
QFrame#Sidebar { background-color: #2c3e50; border: none; }
|
||||||
|
QLabel#SidebarTitle { color: #ecf0f1; font-weight: bold; font-size: 16px; padding: 10px; }
|
||||||
|
QListWidget { background-color: #34495e; color: #ecf0f1; border: none; font-size: 13px; }
|
||||||
|
QListWidget::item { padding: 8px; border-bottom: 1px solid #2c3e50; }
|
||||||
|
QListWidget::item:selected { background-color: #1abc9c; color: white; }
|
||||||
|
QPushButton#SidebarBtn { background-color: #34495e; color: #bdc3c7; border: 1px solid #2c3e50; padding: 8px; text-align: left; border-radius: 4px; margin: 2px 10px; }
|
||||||
|
QPushButton#SidebarBtn:hover { background-color: #1abc9c; color: white; border: 1px solid #16a085; }
|
||||||
|
QPushButton#CancelBtn { background-color: #e74c3c; color: white; font-weight: bold; border-radius: 4px; margin: 10px; padding: 8px; }
|
||||||
|
QLineEdit { padding: 10px; border: 1px solid #bdc3c7; border-radius: 20px; font-size: 14px; background-color: white; }
|
||||||
|
QLineEdit:focus { border: 2px solid #3498db; }
|
||||||
|
QPushButton#SearchBtn { background-color: #3498db; color: white; font-weight: bold; border-radius: 20px; padding: 10px 20px; font-size: 14px; }
|
||||||
|
QPushButton#SearchBtn:hover { background-color: #2980b9; }
|
||||||
|
QScrollArea { border: none; background-color: transparent; }
|
||||||
|
QWidget#ResultsContainer { background-color: transparent; }
|
||||||
|
"""
|
||||||
128
database.py
Normal file
128
database.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# database.py
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
import numpy as np
|
||||||
|
import traceback # WICHTIG: Damit wir den vollen Fehler sehen
|
||||||
|
from sentence_transformers import util
|
||||||
|
from rapidfuzz import fuzz
|
||||||
|
from config import DB_NAME, APP_DATA_DIR
|
||||||
|
|
||||||
|
class DatabaseHandler:
|
||||||
|
def __init__(self):
|
||||||
|
self.app_data_dir = APP_DATA_DIR
|
||||||
|
self.db_name = 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);")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def add_folder(self, path):
|
||||||
|
conn = sqlite3.connect(self.db_name)
|
||||||
|
try:
|
||||||
|
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()
|
||||||
|
|
||||||
|
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 = [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))})", ids)
|
||||||
|
cursor.execute("DELETE FROM folders WHERE path = ?", (path,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_folders(self):
|
||||||
|
conn = sqlite3.connect(self.db_name)
|
||||||
|
rows = conn.execute("SELECT path FROM folders").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [r[0] for r in rows]
|
||||||
|
|
||||||
|
def search(self, query):
|
||||||
|
# Sicherheitscheck
|
||||||
|
if not query.strip() or not self.model:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Semantische Vorbereitung
|
||||||
|
q_vec = self.model.encode(query, convert_to_tensor=False)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(self.db_name)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Embeddings laden
|
||||||
|
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 []
|
||||||
|
|
||||||
|
# Umwandlung BLOB -> Numpy Array
|
||||||
|
# Hier knallt es oft, wenn die DB korrupt ist oder Dimensionen nicht passen
|
||||||
|
vecs = np.array([np.frombuffer(d[1], dtype=np.float32) for d in data])
|
||||||
|
|
||||||
|
# Cosine Similarity berechnen
|
||||||
|
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)}
|
||||||
|
|
||||||
|
# 2. Lexikalische Suche (FTS)
|
||||||
|
words = query.replace('"', '').split()
|
||||||
|
if not words: words = [query]
|
||||||
|
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", (fts_query,)).fetchall()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FTS Fehler (ignoriert): {e}")
|
||||||
|
fts_rows = []
|
||||||
|
|
||||||
|
lex_map = {}
|
||||||
|
for did, fname, content in fts_rows:
|
||||||
|
r1 = fuzz.partial_ratio(query.lower(), fname.lower())
|
||||||
|
# Content kürzen für Performance
|
||||||
|
r2 = fuzz.partial_token_set_ratio(query.lower(), content[:5000].lower())
|
||||||
|
lex_map[did] = max(r1, r2) / 100.0
|
||||||
|
|
||||||
|
# 3. Hybrid Fusion
|
||||||
|
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)
|
||||||
|
# Kleiner Boost wenn beides passt
|
||||||
|
if s_score > 0.4 and l_score > 0.6: h_score += 0.1
|
||||||
|
final[did] = h_score
|
||||||
|
|
||||||
|
# 4. Ergebnisse holen
|
||||||
|
sorted_ids = sorted(final.keys(), key=lambda x: final[x], reverse=True)[:50]
|
||||||
|
results = []
|
||||||
|
for did in sorted_ids:
|
||||||
|
row = cursor.execute("SELECT filename, path, snippet(documents, 2, '<b>', '</b>', '...', 15) FROM documents WHERE rowid = ?", (did,)).fetchone()
|
||||||
|
if row: results.append(row)
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# DIESER TEIL IST NEU: Er schreibt den Fehler ins Logfile
|
||||||
|
print(f"!!! KRITISCHER FEHLER IN DER SUCHE !!!")
|
||||||
|
print(f"Fehler: {e}")
|
||||||
|
print(traceback.format_exc())
|
||||||
|
return []
|
||||||
134
indexer.py
Normal file
134
indexer.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# indexer.py
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import pdfplumber
|
||||||
|
import zipfile
|
||||||
|
import io
|
||||||
|
from PyQt6.QtCore import QThread, pyqtSignal
|
||||||
|
|
||||||
|
# Importe optionaler Libraries
|
||||||
|
try: import docx
|
||||||
|
except ImportError: docx = None
|
||||||
|
try: import openpyxl
|
||||||
|
except ImportError: openpyxl = None
|
||||||
|
try: from pptx import Presentation
|
||||||
|
except ImportError: Presentation = None
|
||||||
|
|
||||||
|
class IndexerThread(QThread):
|
||||||
|
progress_signal = pyqtSignal(str)
|
||||||
|
finished_signal = pyqtSignal(int, int, bool)
|
||||||
|
|
||||||
|
def __init__(self, folder, db_name, model):
|
||||||
|
super().__init__()
|
||||||
|
self.folder_path = folder
|
||||||
|
self.db_name = db_name
|
||||||
|
self.model = model
|
||||||
|
self.is_running = True
|
||||||
|
|
||||||
|
def stop(self): self.is_running = False
|
||||||
|
|
||||||
|
def _extract_text(self, stream, filename):
|
||||||
|
ext = os.path.splitext(filename)[1].lower()
|
||||||
|
text = ""
|
||||||
|
try:
|
||||||
|
if ext == ".pdf":
|
||||||
|
try:
|
||||||
|
with pdfplumber.open(stream) as pdf:
|
||||||
|
for p in pdf.pages:
|
||||||
|
if t := p.extract_text(): text += t + "\n"
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
elif ext == ".docx" and docx:
|
||||||
|
try:
|
||||||
|
doc = docx.Document(stream)
|
||||||
|
for para in doc.paragraphs: text += para.text + "\n"
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
elif ext == ".xlsx" and openpyxl:
|
||||||
|
try:
|
||||||
|
wb = openpyxl.load_workbook(stream, data_only=True, read_only=True)
|
||||||
|
for sheet in wb.worksheets:
|
||||||
|
text += f"\n--- {sheet.title} ---\n"
|
||||||
|
for row in sheet.iter_rows(values_only=True):
|
||||||
|
row_text = " ".join([str(c) for c in row if c is not None])
|
||||||
|
if row_text.strip(): text += row_text + "\n"
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
elif ext == ".pptx" and Presentation:
|
||||||
|
try:
|
||||||
|
prs = Presentation(stream)
|
||||||
|
for i, slide in enumerate(prs.slides):
|
||||||
|
text += f"\n--- Folie {i+1} ---\n"
|
||||||
|
for shape in slide.shapes:
|
||||||
|
if shape.has_text_frame:
|
||||||
|
for p in shape.text_frame.paragraphs:
|
||||||
|
for r in p.runs: text += r.text + " "
|
||||||
|
text += "\n"
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
elif ext in [".txt", ".md", ".py", ".json", ".csv", ".html", ".log", ".ini", ".xml"]:
|
||||||
|
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 old entries
|
||||||
|
cursor.execute("SELECT rowid FROM documents WHERE path LIKE ?", (f"{self.folder_path}%",))
|
||||||
|
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))})", ids)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
indexed = 0
|
||||||
|
skipped = 0
|
||||||
|
cancelled = False
|
||||||
|
|
||||||
|
for root, dirs, files in os.walk(self.folder_path):
|
||||||
|
if not self.is_running: cancelled = True; break
|
||||||
|
for file in files:
|
||||||
|
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(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(cursor, zi.filename, vpath, content)
|
||||||
|
indexed += 1
|
||||||
|
except: skipped += 1
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
file_content = io.BytesIO(f.read())
|
||||||
|
content = self._extract_text(file_content, file)
|
||||||
|
if content and len(content.strip()) > 20:
|
||||||
|
self._save(cursor, file, path, content)
|
||||||
|
indexed += 1
|
||||||
|
else: skipped += 1
|
||||||
|
except: skipped += 1
|
||||||
|
|
||||||
|
if cancelled: break
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
self.finished_signal.emit(indexed, skipped, cancelled)
|
||||||
|
|
||||||
|
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))
|
||||||
38
main.py
Normal file
38
main.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# main.py
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from PyQt6.QtWidgets import QApplication, QSplashScreen
|
||||||
|
from PyQt6.QtGui import QPixmap, QFont
|
||||||
|
from PyQt6.QtCore import qInstallMessageHandler
|
||||||
|
|
||||||
|
from config import Logger, qt_message_handler, LOG_FILE
|
||||||
|
from ui import UffWindow
|
||||||
|
|
||||||
|
# 1. Logging Setup
|
||||||
|
sys.stdout = Logger()
|
||||||
|
sys.stderr = sys.stdout
|
||||||
|
print(f"--- APP START ---")
|
||||||
|
print(f"Logfile: {LOG_FILE}")
|
||||||
|
|
||||||
|
# 2. Filter für Qt Meldungen installieren
|
||||||
|
qInstallMessageHandler(qt_message_handler)
|
||||||
|
os.environ["QT_LOGGING_RULES"] = "qt.text.font.db=false;qt.qpa.fonts=false"
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
|
||||||
|
# Globale Schriftart
|
||||||
|
app.setFont(QFont("Segoe UI", 10))
|
||||||
|
|
||||||
|
splash = None
|
||||||
|
if os.path.exists("assets/uff_banner.jpeg"):
|
||||||
|
try:
|
||||||
|
splash = QSplashScreen(QPixmap("assets/uff_banner.jpeg"))
|
||||||
|
splash.show()
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
window = UffWindow(splash)
|
||||||
|
window.show()
|
||||||
|
window.start_model_loading()
|
||||||
|
|
||||||
|
sys.exit(app.exec())
|
||||||
716
uff_app.py
716
uff_app.py
@@ -1,716 +0,0 @@
|
|||||||
import sys
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import pdfplumber
|
|
||||||
import numpy as np
|
|
||||||
import zipfile
|
|
||||||
import io
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
# --- OPTIONALE IMPORTE ---
|
|
||||||
try:
|
|
||||||
import docx
|
|
||||||
except ImportError:
|
|
||||||
docx = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
import openpyxl
|
|
||||||
except ImportError:
|
|
||||||
openpyxl = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
from pptx import Presentation
|
|
||||||
except ImportError:
|
|
||||||
Presentation = None
|
|
||||||
|
|
||||||
from sentence_transformers import SentenceTransformer, util
|
|
||||||
from rapidfuzz import process, fuzz
|
|
||||||
|
|
||||||
from PyQt6.QtCore import qInstallMessageHandler, QtMsgType, Qt, QThread, pyqtSignal, QUrl, QSize
|
|
||||||
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
|
|
||||||
QHBoxLayout, QLineEdit, QPushButton, QLabel,
|
|
||||||
QFileDialog, QProgressBar, QMessageBox,
|
|
||||||
QListWidget, QListWidgetItem, QSplitter, QFrame,
|
|
||||||
QSplashScreen, QScrollArea, QStyle, QGraphicsDropShadowEffect)
|
|
||||||
from PyQt6.QtGui import QDesktopServices, QPixmap, QCursor, QAction, QColor, QPalette, QFont
|
|
||||||
|
|
||||||
# --- 0. LOGGING & SETUP ---
|
|
||||||
|
|
||||||
if os.name == 'nt':
|
|
||||||
base_dir = os.getenv('LOCALAPPDATA')
|
|
||||||
else:
|
|
||||||
base_dir = os.path.join(os.path.expanduser("~"), ".local", "share")
|
|
||||||
|
|
||||||
log_dir = os.path.join(base_dir, "UFF_Search")
|
|
||||||
if not os.path.exists(log_dir):
|
|
||||||
os.makedirs(log_dir)
|
|
||||||
|
|
||||||
log_file_path = os.path.join(log_dir, "uff.log")
|
|
||||||
|
|
||||||
class Logger(object):
|
|
||||||
def __init__(self):
|
|
||||||
self.log = open(log_file_path, "w", encoding="utf-8")
|
|
||||||
|
|
||||||
def write(self, message):
|
|
||||||
self.log.write(message)
|
|
||||||
self.log.flush()
|
|
||||||
|
|
||||||
def flush(self):
|
|
||||||
self.log.flush()
|
|
||||||
|
|
||||||
sys.stdout = Logger()
|
|
||||||
sys.stderr = sys.stdout
|
|
||||||
|
|
||||||
# --- STYLESHEET ---
|
|
||||||
STYLESHEET = """
|
|
||||||
QMainWindow {
|
|
||||||
background-color: #f4f7f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Styles */
|
|
||||||
QFrame#Sidebar {
|
|
||||||
background-color: #2c3e50;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
QLabel#SidebarTitle {
|
|
||||||
color: #ecf0f1;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
QListWidget {
|
|
||||||
background-color: #34495e;
|
|
||||||
color: #ecf0f1;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
QListWidget::item {
|
|
||||||
padding: 8px;
|
|
||||||
border-bottom: 1px solid #2c3e50;
|
|
||||||
}
|
|
||||||
QListWidget::item:selected {
|
|
||||||
background-color: #1abc9c;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
QListWidget::item:hover {
|
|
||||||
background-color: #16a085;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Buttons */
|
|
||||||
QPushButton#SidebarBtn {
|
|
||||||
background-color: #34495e;
|
|
||||||
color: #bdc3c7;
|
|
||||||
border: 1px solid #2c3e50;
|
|
||||||
padding: 8px;
|
|
||||||
text-align: left;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 2px 10px;
|
|
||||||
}
|
|
||||||
QPushButton#SidebarBtn:hover {
|
|
||||||
background-color: #1abc9c;
|
|
||||||
color: white;
|
|
||||||
border: 1px solid #16a085;
|
|
||||||
}
|
|
||||||
QPushButton#CancelBtn {
|
|
||||||
background-color: #e74c3c;
|
|
||||||
color: white;
|
|
||||||
font-weight: bold;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 10px;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main Area */
|
|
||||||
QLineEdit {
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid #bdc3c7;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
background-color: white;
|
|
||||||
selection-background-color: #3498db;
|
|
||||||
}
|
|
||||||
QLineEdit:focus {
|
|
||||||
border: 2px solid #3498db;
|
|
||||||
}
|
|
||||||
|
|
||||||
QPushButton#SearchBtn {
|
|
||||||
background-color: #3498db;
|
|
||||||
color: white;
|
|
||||||
font-weight: bold;
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 10px 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
QPushButton#SearchBtn:hover {
|
|
||||||
background-color: #2980b9;
|
|
||||||
}
|
|
||||||
QPushButton#SearchBtn:pressed {
|
|
||||||
background-color: #1f618d;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scroll Area & Results */
|
|
||||||
QScrollArea {
|
|
||||||
border: none;
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
QWidget#ResultsContainer {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
QLabel#StatusLabel {
|
|
||||||
color: #7f8c8d;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
QProgressBar {
|
|
||||||
border: none;
|
|
||||||
background-color: #ecf0f1;
|
|
||||||
height: 4px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
QProgressBar::chunk {
|
|
||||||
background-color: #1abc9c;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
def qt_message_handler(mode, context, message):
|
|
||||||
msg_lower = message.lower()
|
|
||||||
ignore_keywords = [
|
|
||||||
"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
|
|
||||||
try:
|
|
||||||
sys.stdout.write(f"[Qt] {message}\n")
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
qInstallMessageHandler(qt_message_handler)
|
|
||||||
os.environ["QT_LOGGING_RULES"] = "qt.text.font.db=false;qt.qpa.fonts=false"
|
|
||||||
|
|
||||||
|
|
||||||
# --- WIDGET: Modernes Suchergebnis (Fixed Tooltips) ---
|
|
||||||
class SearchResultItem(QFrame):
|
|
||||||
def __init__(self, filename, filepath, snippet, parent=None):
|
|
||||||
super().__init__(parent)
|
|
||||||
self.filepath = filepath
|
|
||||||
|
|
||||||
# WICHTIG: Tooltip auf das gesamte Frame setzen, nicht nur auf Kinder
|
|
||||||
self.setToolTip(filepath)
|
|
||||||
|
|
||||||
# Design der Karte
|
|
||||||
self.setFrameShape(QFrame.Shape.StyledPanel)
|
|
||||||
self.setStyleSheet("""
|
|
||||||
SearchResultItem {
|
|
||||||
background-color: white;
|
|
||||||
border: 1px solid #e0e0e0;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
SearchResultItem:hover {
|
|
||||||
border: 1px solid #3498db;
|
|
||||||
background-color: #fbfbfb;
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Schatten-Effekt
|
|
||||||
shadow = QGraphicsDropShadowEffect(self)
|
|
||||||
shadow.setBlurRadius(10)
|
|
||||||
shadow.setXOffset(0)
|
|
||||||
shadow.setYOffset(2)
|
|
||||||
shadow.setColor(QColor(0, 0, 0, 30))
|
|
||||||
self.setGraphicsEffect(shadow)
|
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
|
||||||
layout.setContentsMargins(15, 15, 15, 15)
|
|
||||||
layout.setSpacing(5)
|
|
||||||
|
|
||||||
# 1. Titel (Dateiname)
|
|
||||||
self.btn_title = QPushButton(filename)
|
|
||||||
self.btn_title.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
||||||
# MouseTracking aktivieren hilft manchmal bei schnellen Bewegungen
|
|
||||||
self.btn_title.setMouseTracking(True)
|
|
||||||
self.btn_title.setStyleSheet("""
|
|
||||||
QPushButton {
|
|
||||||
text-align: left;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 16px;
|
|
||||||
color: #2c3e50;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
padding: 0px;
|
|
||||||
}
|
|
||||||
QPushButton:hover {
|
|
||||||
color: #3498db;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
self.btn_title.clicked.connect(self.open_file)
|
|
||||||
|
|
||||||
# 2. Snippet
|
|
||||||
self.lbl_snippet = QLabel(snippet)
|
|
||||||
self.lbl_snippet.setWordWrap(True)
|
|
||||||
self.lbl_snippet.setStyleSheet("color: #555; font-size: 13px; line-height: 1.4;")
|
|
||||||
|
|
||||||
# 3. Pfad (unten, klein)
|
|
||||||
path_layout = QHBoxLayout()
|
|
||||||
lbl_icon = QLabel("📄")
|
|
||||||
lbl_icon.setStyleSheet("font-size: 10px; color: #95a5a6;")
|
|
||||||
|
|
||||||
self.lbl_path = QLabel(filepath)
|
|
||||||
self.lbl_path.setStyleSheet("color: #95a5a6; font-size: 11px;")
|
|
||||||
|
|
||||||
path_layout.addWidget(lbl_icon)
|
|
||||||
path_layout.addWidget(self.lbl_path)
|
|
||||||
path_layout.addStretch()
|
|
||||||
|
|
||||||
layout.addWidget(self.btn_title)
|
|
||||||
layout.addWidget(self.lbl_snippet)
|
|
||||||
layout.addLayout(path_layout)
|
|
||||||
|
|
||||||
def open_file(self):
|
|
||||||
target_path = self.filepath
|
|
||||||
if " :: " in target_path:
|
|
||||||
target_path = target_path.split(" :: ")[0]
|
|
||||||
url = QUrl.fromLocalFile(target_path)
|
|
||||||
QDesktopServices.openUrl(url)
|
|
||||||
|
|
||||||
|
|
||||||
# --- 1. DATENBANK MANAGER ---
|
|
||||||
|
|
||||||
class DatabaseHandler:
|
|
||||||
def __init__(self):
|
|
||||||
self.app_data_dir = log_dir
|
|
||||||
self.db_name = os.path.join(self.app_data_dir, "uff_index.db")
|
|
||||||
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);")
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def add_folder(self, path):
|
|
||||||
conn = sqlite3.connect(self.db_name)
|
|
||||||
try:
|
|
||||||
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()
|
|
||||||
|
|
||||||
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 = [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))})", ids)
|
|
||||||
cursor.execute("DELETE FROM folders WHERE path = ?", (path,))
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def get_folders(self):
|
|
||||||
conn = sqlite3.connect(self.db_name)
|
|
||||||
rows = conn.execute("SELECT path FROM folders").fetchall()
|
|
||||||
conn.close()
|
|
||||||
return [r[0] for r in rows]
|
|
||||||
|
|
||||||
def search(self, query):
|
|
||||||
if not query.strip() or not self.model: return []
|
|
||||||
|
|
||||||
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")
|
|
||||||
data = cursor.fetchall()
|
|
||||||
doc_ids = [d[0] for d in data]
|
|
||||||
if not doc_ids:
|
|
||||||
conn.close(); return []
|
|
||||||
|
|
||||||
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)}
|
|
||||||
|
|
||||||
words = query.replace('"', '').split()
|
|
||||||
if not words: words = [query]
|
|
||||||
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", (fts_query,)).fetchall()
|
|
||||||
except: fts_rows = []
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
sorted_ids = sorted(final.keys(), key=lambda x: final[x], reverse=True)[:50]
|
|
||||||
results = []
|
|
||||||
for did in sorted_ids:
|
|
||||||
row = cursor.execute("SELECT filename, path, snippet(documents, 2, '<b>', '</b>', '...', 15) FROM documents WHERE rowid = ?", (did,)).fetchone()
|
|
||||||
if row: results.append(row)
|
|
||||||
conn.close()
|
|
||||||
return results
|
|
||||||
|
|
||||||
# --- 2. THREADS ---
|
|
||||||
|
|
||||||
class ModelLoaderThread(QThread):
|
|
||||||
model_loaded = pyqtSignal(object)
|
|
||||||
def run(self):
|
|
||||||
try:
|
|
||||||
model = SentenceTransformer('all-MiniLM-L6-v2')
|
|
||||||
self.model_loaded.emit(model)
|
|
||||||
except: self.model_loaded.emit(None)
|
|
||||||
|
|
||||||
class IndexerThread(QThread):
|
|
||||||
progress_signal = pyqtSignal(str)
|
|
||||||
finished_signal = pyqtSignal(int, int, bool)
|
|
||||||
|
|
||||||
def __init__(self, folder, db_name, model):
|
|
||||||
super().__init__()
|
|
||||||
self.folder_path = folder
|
|
||||||
self.db_name = db_name
|
|
||||||
self.model = model
|
|
||||||
self.is_running = True
|
|
||||||
|
|
||||||
def stop(self): self.is_running = False
|
|
||||||
|
|
||||||
def _extract_text(self, stream, filename):
|
|
||||||
ext = os.path.splitext(filename)[1].lower()
|
|
||||||
text = ""
|
|
||||||
try:
|
|
||||||
if ext == ".pdf":
|
|
||||||
try:
|
|
||||||
with pdfplumber.open(stream) as pdf:
|
|
||||||
for p in pdf.pages:
|
|
||||||
if t := p.extract_text(): text += t + "\n"
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
elif ext == ".docx" and docx is not None:
|
|
||||||
try:
|
|
||||||
doc = docx.Document(stream)
|
|
||||||
for para in doc.paragraphs: text += para.text + "\n"
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
elif ext == ".xlsx" and openpyxl is not None:
|
|
||||||
try:
|
|
||||||
wb = openpyxl.load_workbook(stream, data_only=True, read_only=True)
|
|
||||||
for sheet in wb.worksheets:
|
|
||||||
text += f"\n--- {sheet.title} ---\n"
|
|
||||||
for row in sheet.iter_rows(values_only=True):
|
|
||||||
row_text = " ".join([str(c) for c in row if c is not None])
|
|
||||||
if row_text.strip(): text += row_text + "\n"
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
elif ext == ".pptx" and Presentation is not None:
|
|
||||||
try:
|
|
||||||
prs = Presentation(stream)
|
|
||||||
for i, slide in enumerate(prs.slides):
|
|
||||||
text += f"\n--- Folie {i+1} ---\n"
|
|
||||||
for shape in slide.shapes:
|
|
||||||
if shape.has_text_frame:
|
|
||||||
for paragraph in shape.text_frame.paragraphs:
|
|
||||||
for run in paragraph.runs: text += run.text + " "
|
|
||||||
text += "\n"
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
elif ext in [".txt", ".md", ".py", ".json", ".csv", ".html", ".log", ".ini", ".xml"]:
|
|
||||||
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()
|
|
||||||
|
|
||||||
cursor.execute("SELECT rowid FROM documents WHERE path LIKE ?", (f"{self.folder_path}%",))
|
|
||||||
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))})", ids)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
indexed = 0
|
|
||||||
skipped = 0
|
|
||||||
cancelled = False
|
|
||||||
|
|
||||||
for root, dirs, files in os.walk(self.folder_path):
|
|
||||||
if not self.is_running: cancelled = True; break
|
|
||||||
for file in files:
|
|
||||||
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(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(cursor, zi.filename, vpath, content)
|
|
||||||
indexed += 1
|
|
||||||
except: skipped += 1
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
file_content = io.BytesIO(f.read())
|
|
||||||
content = self._extract_text(file_content, file)
|
|
||||||
if content and len(content.strip()) > 20:
|
|
||||||
self._save(cursor, file, path, content)
|
|
||||||
indexed += 1
|
|
||||||
else: skipped += 1
|
|
||||||
except: skipped += 1
|
|
||||||
|
|
||||||
if cancelled: break
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
self.finished_signal.emit(indexed, skipped, cancelled)
|
|
||||||
|
|
||||||
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))
|
|
||||||
|
|
||||||
# --- 3. UI MAIN WINDOW ---
|
|
||||||
|
|
||||||
class UffWindow(QMainWindow):
|
|
||||||
def __init__(self, splash=None):
|
|
||||||
super().__init__()
|
|
||||||
self.splash = splash
|
|
||||||
self.db = DatabaseHandler()
|
|
||||||
self.initUI()
|
|
||||||
self.load_saved_folders()
|
|
||||||
|
|
||||||
def initUI(self):
|
|
||||||
self.setWindowTitle("UFF Search v7.2 (Stable Tooltips)")
|
|
||||||
self.resize(1100, 750)
|
|
||||||
|
|
||||||
self.setStyleSheet(STYLESHEET)
|
|
||||||
|
|
||||||
central = QWidget()
|
|
||||||
self.setCentralWidget(central)
|
|
||||||
main_layout = QHBoxLayout(central)
|
|
||||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
main_layout.setSpacing(0)
|
|
||||||
|
|
||||||
# -- SIDEBAR --
|
|
||||||
left_panel = QFrame()
|
|
||||||
left_panel.setObjectName("Sidebar")
|
|
||||||
left_panel.setFixedWidth(260)
|
|
||||||
left_layout = QVBoxLayout(left_panel)
|
|
||||||
left_layout.setContentsMargins(0, 20, 0, 20)
|
|
||||||
|
|
||||||
lbl_title = QLabel(" UFF SEARCH")
|
|
||||||
lbl_title.setObjectName("SidebarTitle")
|
|
||||||
|
|
||||||
self.folder_list = QListWidget()
|
|
||||||
|
|
||||||
icon_add = self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogNewFolder)
|
|
||||||
icon_del = self.style().standardIcon(QStyle.StandardPixmap.SP_TrashIcon)
|
|
||||||
icon_refresh = self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload)
|
|
||||||
icon_stop = self.style().standardIcon(QStyle.StandardPixmap.SP_DialogCancelButton)
|
|
||||||
|
|
||||||
btn_add = QPushButton(" Ordner hinzufügen")
|
|
||||||
btn_add.setObjectName("SidebarBtn")
|
|
||||||
btn_add.setIcon(icon_add)
|
|
||||||
btn_add.clicked.connect(self.add_new_folder)
|
|
||||||
|
|
||||||
btn_del = QPushButton(" Ordner entfernen")
|
|
||||||
btn_del.setObjectName("SidebarBtn")
|
|
||||||
btn_del.setIcon(icon_del)
|
|
||||||
btn_del.clicked.connect(self.delete_selected_folder)
|
|
||||||
|
|
||||||
self.btn_rescan = QPushButton(" Neu scannen")
|
|
||||||
self.btn_rescan.setObjectName("SidebarBtn")
|
|
||||||
self.btn_rescan.setIcon(icon_refresh)
|
|
||||||
self.btn_rescan.clicked.connect(self.rescan_selected_folder)
|
|
||||||
|
|
||||||
self.btn_cancel = QPushButton("STOPPEN")
|
|
||||||
self.btn_cancel.setObjectName("CancelBtn")
|
|
||||||
self.btn_cancel.setIcon(icon_stop)
|
|
||||||
self.btn_cancel.clicked.connect(self.cancel_indexing)
|
|
||||||
self.btn_cancel.hide()
|
|
||||||
|
|
||||||
left_layout.addWidget(lbl_title)
|
|
||||||
left_layout.addSpacing(10)
|
|
||||||
left_layout.addWidget(self.folder_list)
|
|
||||||
left_layout.addSpacing(10)
|
|
||||||
left_layout.addWidget(btn_add)
|
|
||||||
left_layout.addWidget(btn_del)
|
|
||||||
left_layout.addWidget(self.btn_rescan)
|
|
||||||
left_layout.addWidget(self.btn_cancel)
|
|
||||||
|
|
||||||
# -- RECHTS (Hauptbereich) --
|
|
||||||
right_panel = QWidget()
|
|
||||||
right_panel.setObjectName("MainArea")
|
|
||||||
right_layout = QVBoxLayout(right_panel)
|
|
||||||
right_layout.setContentsMargins(30, 30, 30, 30)
|
|
||||||
right_layout.setSpacing(15)
|
|
||||||
|
|
||||||
# Header
|
|
||||||
search_box = QHBoxLayout()
|
|
||||||
self.input_search = QLineEdit()
|
|
||||||
self.input_search.setPlaceholderText("Wonach suchst du heute?")
|
|
||||||
self.input_search.returnPressed.connect(self.perform_search)
|
|
||||||
|
|
||||||
self.btn_go = QPushButton("Suchen")
|
|
||||||
self.btn_go.setObjectName("SearchBtn")
|
|
||||||
self.btn_go.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
||||||
self.btn_go.clicked.connect(self.perform_search)
|
|
||||||
|
|
||||||
search_box.addWidget(self.input_search)
|
|
||||||
search_box.addWidget(self.btn_go)
|
|
||||||
|
|
||||||
# Status
|
|
||||||
status_box = QHBoxLayout()
|
|
||||||
self.lbl_status = QLabel("Modell wird geladen...")
|
|
||||||
self.lbl_status.setObjectName("StatusLabel")
|
|
||||||
self.progress_bar = QProgressBar()
|
|
||||||
self.progress_bar.hide()
|
|
||||||
status_box.addWidget(self.lbl_status)
|
|
||||||
status_box.addWidget(self.progress_bar)
|
|
||||||
|
|
||||||
# Ergebnisse
|
|
||||||
self.scroll_area = QScrollArea()
|
|
||||||
self.scroll_area.setWidgetResizable(True)
|
|
||||||
|
|
||||||
self.results_container = QWidget()
|
|
||||||
self.results_container.setObjectName("ResultsContainer")
|
|
||||||
self.results_layout = QVBoxLayout(self.results_container)
|
|
||||||
self.results_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
|
||||||
self.results_layout.setSpacing(15)
|
|
||||||
self.scroll_area.setWidget(self.results_container)
|
|
||||||
|
|
||||||
right_layout.addLayout(search_box)
|
|
||||||
right_layout.addLayout(status_box)
|
|
||||||
right_layout.addWidget(self.scroll_area)
|
|
||||||
|
|
||||||
main_layout.addWidget(left_panel)
|
|
||||||
main_layout.addWidget(right_panel)
|
|
||||||
self.set_ui_enabled(False)
|
|
||||||
|
|
||||||
def set_ui_enabled(self, enabled):
|
|
||||||
self.input_search.setEnabled(enabled)
|
|
||||||
self.btn_go.setEnabled(enabled)
|
|
||||||
self.folder_list.setEnabled(enabled)
|
|
||||||
|
|
||||||
def start_model_loading(self):
|
|
||||||
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.finish(self)
|
|
||||||
if not model:
|
|
||||||
QMessageBox.critical(self, "Fehler", "Modell konnte nicht geladen werden.")
|
|
||||||
return
|
|
||||||
self.db.model = model
|
|
||||||
self.lbl_status.setText("Bereit für deine Suche.")
|
|
||||||
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()
|
|
||||||
|
|
||||||
while self.results_layout.count():
|
|
||||||
child = self.results_layout.takeAt(0)
|
|
||||||
if child.widget(): child.widget().deleteLater()
|
|
||||||
|
|
||||||
results = self.db.search(query)
|
|
||||||
self.lbl_status.setText(f"{len(results)} Treffer gefunden.")
|
|
||||||
|
|
||||||
if not results:
|
|
||||||
lbl = QLabel("Leider keine Ergebnisse.")
|
|
||||||
lbl.setStyleSheet("color: #95a5a6; font-size: 18px; margin-top: 40px;")
|
|
||||||
lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
|
||||||
self.results_layout.addWidget(lbl)
|
|
||||||
else:
|
|
||||||
for fname, fpath, snippet in results:
|
|
||||||
self.results_layout.addWidget(SearchResultItem(fname, fpath, snippet))
|
|
||||||
self.results_layout.addStretch()
|
|
||||||
|
|
||||||
def load_saved_folders(self):
|
|
||||||
self.folder_list.clear()
|
|
||||||
for f in self.db.get_folders():
|
|
||||||
item = QListWidgetItem(self.style().standardIcon(QStyle.StandardPixmap.SP_DirIcon), f)
|
|
||||||
item.setToolTip(f)
|
|
||||||
self.folder_list.addItem(item)
|
|
||||||
|
|
||||||
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 "Indexierung fertig"
|
|
||||||
self.lbl_status.setText(f"{msg}: {n} neu, {s} übersprungen.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app = QApplication(sys.argv)
|
|
||||||
|
|
||||||
font = QFont("Segoe UI", 10)
|
|
||||||
app.setFont(font)
|
|
||||||
|
|
||||||
splash = None
|
|
||||||
try:
|
|
||||||
if os.path.exists("assets/uff_banner.jpeg"):
|
|
||||||
splash = QSplashScreen(QPixmap("assets/uff_banner.jpeg"))
|
|
||||||
splash.show()
|
|
||||||
except: pass
|
|
||||||
w = UffWindow(splash)
|
|
||||||
w.show()
|
|
||||||
w.start_model_loading()
|
|
||||||
sys.exit(app.exec())
|
|
||||||
266
ui.py
Normal file
266
ui.py
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
# ui.py
|
||||||
|
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||||
|
QLineEdit, QPushButton, QLabel, QFileDialog,
|
||||||
|
QProgressBar, QMessageBox, QListWidget, QListWidgetItem,
|
||||||
|
QSplitter, QFrame, QScrollArea, QStyle, QGraphicsDropShadowEffect)
|
||||||
|
from PyQt6.QtCore import Qt, QUrl, QThread, pyqtSignal
|
||||||
|
from PyQt6.QtGui import QDesktopServices, QColor, QFont
|
||||||
|
from sentence_transformers import SentenceTransformer
|
||||||
|
|
||||||
|
from database import DatabaseHandler
|
||||||
|
from indexer import IndexerThread
|
||||||
|
from config import STYLESHEET
|
||||||
|
|
||||||
|
# Thread zum Laden des Modells (UI-Helper)
|
||||||
|
class ModelLoaderThread(QThread):
|
||||||
|
model_loaded = pyqtSignal(object)
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
model = SentenceTransformer('all-MiniLM-L6-v2')
|
||||||
|
self.model_loaded.emit(model)
|
||||||
|
except: self.model_loaded.emit(None)
|
||||||
|
|
||||||
|
# Widget für einzelne Ergebnisse
|
||||||
|
class SearchResultItem(QFrame):
|
||||||
|
def __init__(self, filename, filepath, snippet, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.filepath = filepath
|
||||||
|
self.setToolTip(filepath)
|
||||||
|
|
||||||
|
self.setFrameShape(QFrame.Shape.StyledPanel)
|
||||||
|
self.setStyleSheet("""
|
||||||
|
SearchResultItem { background-color: white; border: 1px solid #e0e0e0; border-radius: 8px; }
|
||||||
|
SearchResultItem:hover { border: 1px solid #3498db; background-color: #fbfbfb; }
|
||||||
|
""")
|
||||||
|
|
||||||
|
shadow = QGraphicsDropShadowEffect(self)
|
||||||
|
shadow.setBlurRadius(10)
|
||||||
|
shadow.setXOffset(0)
|
||||||
|
shadow.setYOffset(2)
|
||||||
|
shadow.setColor(QColor(0, 0, 0, 30))
|
||||||
|
self.setGraphicsEffect(shadow)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(15, 15, 15, 15)
|
||||||
|
layout.setSpacing(5)
|
||||||
|
|
||||||
|
self.btn_title = QPushButton(filename)
|
||||||
|
self.btn_title.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
self.btn_title.setMouseTracking(True)
|
||||||
|
self.btn_title.setStyleSheet("""
|
||||||
|
QPushButton { text-align: left; font-weight: bold; font-size: 16px; color: #2c3e50; border: none; background: transparent; padding: 0px; }
|
||||||
|
QPushButton:hover { color: #3498db; text-decoration: underline; }
|
||||||
|
""")
|
||||||
|
self.btn_title.clicked.connect(self.open_file)
|
||||||
|
|
||||||
|
self.lbl_snippet = QLabel(snippet)
|
||||||
|
self.lbl_snippet.setWordWrap(True)
|
||||||
|
self.lbl_snippet.setStyleSheet("color: #555; font-size: 13px; line-height: 1.4;")
|
||||||
|
|
||||||
|
path_layout = QHBoxLayout()
|
||||||
|
lbl_icon = QLabel("📄")
|
||||||
|
lbl_icon.setStyleSheet("font-size: 10px; color: #95a5a6;")
|
||||||
|
|
||||||
|
self.lbl_path = QLabel(filepath)
|
||||||
|
self.lbl_path.setStyleSheet("color: #95a5a6; font-size: 11px;")
|
||||||
|
|
||||||
|
path_layout.addWidget(lbl_icon)
|
||||||
|
path_layout.addWidget(self.lbl_path)
|
||||||
|
path_layout.addStretch()
|
||||||
|
|
||||||
|
layout.addWidget(self.btn_title)
|
||||||
|
layout.addWidget(self.lbl_snippet)
|
||||||
|
layout.addLayout(path_layout)
|
||||||
|
|
||||||
|
def open_file(self):
|
||||||
|
target = self.filepath.split(" :: ")[0] if " :: " in self.filepath else self.filepath
|
||||||
|
QDesktopServices.openUrl(QUrl.fromLocalFile(target))
|
||||||
|
|
||||||
|
class UffWindow(QMainWindow):
|
||||||
|
def __init__(self, splash=None):
|
||||||
|
super().__init__()
|
||||||
|
self.splash = splash
|
||||||
|
self.db = DatabaseHandler()
|
||||||
|
self.initUI()
|
||||||
|
self.load_saved_folders()
|
||||||
|
|
||||||
|
def initUI(self):
|
||||||
|
self.setWindowTitle("UFF Search v8.0 (Modular)")
|
||||||
|
self.resize(1100, 750)
|
||||||
|
self.setStyleSheet(STYLESHEET)
|
||||||
|
|
||||||
|
central = QWidget()
|
||||||
|
self.setCentralWidget(central)
|
||||||
|
main_layout = QHBoxLayout(central)
|
||||||
|
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
main_layout.setSpacing(0)
|
||||||
|
|
||||||
|
# -- SIDEBAR --
|
||||||
|
left_panel = QFrame()
|
||||||
|
left_panel.setObjectName("Sidebar")
|
||||||
|
left_panel.setFixedWidth(260)
|
||||||
|
left = QVBoxLayout(left_panel)
|
||||||
|
left.setContentsMargins(0, 20, 0, 20)
|
||||||
|
|
||||||
|
lbl_title = QLabel(" UFF SEARCH")
|
||||||
|
lbl_title.setObjectName("SidebarTitle")
|
||||||
|
|
||||||
|
self.folder_list = QListWidget()
|
||||||
|
|
||||||
|
btn_add = QPushButton(" Ordner hinzufügen")
|
||||||
|
btn_add.setObjectName("SidebarBtn")
|
||||||
|
btn_add.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogNewFolder))
|
||||||
|
btn_add.clicked.connect(self.add_new_folder)
|
||||||
|
|
||||||
|
btn_del = QPushButton(" Ordner entfernen")
|
||||||
|
btn_del.setObjectName("SidebarBtn")
|
||||||
|
btn_del.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TrashIcon))
|
||||||
|
btn_del.clicked.connect(self.delete_selected_folder)
|
||||||
|
|
||||||
|
self.btn_rescan = QPushButton(" Neu scannen")
|
||||||
|
self.btn_rescan.setObjectName("SidebarBtn")
|
||||||
|
self.btn_rescan.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload))
|
||||||
|
self.btn_rescan.clicked.connect(self.rescan)
|
||||||
|
|
||||||
|
self.btn_cancel = QPushButton("STOPPEN")
|
||||||
|
self.btn_cancel.setObjectName("CancelBtn")
|
||||||
|
self.btn_cancel.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogCancelButton))
|
||||||
|
self.btn_cancel.clicked.connect(self.cancel_idx)
|
||||||
|
self.btn_cancel.hide()
|
||||||
|
|
||||||
|
left.addWidget(lbl_title)
|
||||||
|
left.addSpacing(10)
|
||||||
|
left.addWidget(self.folder_list)
|
||||||
|
left.addSpacing(10)
|
||||||
|
left.addWidget(btn_add)
|
||||||
|
left.addWidget(btn_del)
|
||||||
|
left.addWidget(self.btn_rescan)
|
||||||
|
left.addWidget(self.btn_cancel)
|
||||||
|
|
||||||
|
# -- MAIN AREA --
|
||||||
|
right_panel = QWidget()
|
||||||
|
right_panel.setObjectName("MainArea")
|
||||||
|
right = QVBoxLayout(right_panel)
|
||||||
|
right.setContentsMargins(30, 30, 30, 30)
|
||||||
|
right.setSpacing(15)
|
||||||
|
|
||||||
|
search_box = QHBoxLayout()
|
||||||
|
self.input = QLineEdit()
|
||||||
|
self.input.setPlaceholderText("Wonach suchst du heute?")
|
||||||
|
self.input.returnPressed.connect(self.search)
|
||||||
|
|
||||||
|
self.btn_go = QPushButton("Suchen")
|
||||||
|
self.btn_go.setObjectName("SearchBtn")
|
||||||
|
self.btn_go.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
self.btn_go.clicked.connect(self.search)
|
||||||
|
|
||||||
|
search_box.addWidget(self.input)
|
||||||
|
search_box.addWidget(self.btn_go)
|
||||||
|
|
||||||
|
status_box = QHBoxLayout()
|
||||||
|
self.lbl_status = QLabel("Modell wird geladen...")
|
||||||
|
self.lbl_status.setObjectName("StatusLabel")
|
||||||
|
self.prog = QProgressBar()
|
||||||
|
self.prog.hide()
|
||||||
|
status_box.addWidget(self.lbl_status)
|
||||||
|
status_box.addWidget(self.prog)
|
||||||
|
|
||||||
|
self.scroll = QScrollArea()
|
||||||
|
self.scroll.setWidgetResizable(True)
|
||||||
|
self.res_cont = QWidget()
|
||||||
|
self.res_cont.setObjectName("ResultsContainer")
|
||||||
|
self.res_layout = QVBoxLayout(self.res_cont)
|
||||||
|
self.res_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||||
|
self.res_layout.setSpacing(15)
|
||||||
|
self.scroll.setWidget(self.res_cont)
|
||||||
|
|
||||||
|
right.addLayout(search_box)
|
||||||
|
right.addLayout(status_box)
|
||||||
|
right.addWidget(self.scroll)
|
||||||
|
|
||||||
|
main_layout.addWidget(left_panel)
|
||||||
|
main_layout.addWidget(right_panel)
|
||||||
|
self.set_ui_enabled(False)
|
||||||
|
|
||||||
|
def set_ui_enabled(self, enabled):
|
||||||
|
self.input.setEnabled(enabled)
|
||||||
|
self.btn_go.setEnabled(enabled)
|
||||||
|
self.folder_list.setEnabled(enabled)
|
||||||
|
|
||||||
|
def start_model_loading(self):
|
||||||
|
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.finish(self)
|
||||||
|
if not model:
|
||||||
|
QMessageBox.critical(self, "Fehler", "Modell konnte nicht geladen werden.")
|
||||||
|
return
|
||||||
|
self.db.model = model
|
||||||
|
self.lbl_status.setText("Bereit für deine Suche.")
|
||||||
|
self.set_ui_enabled(True)
|
||||||
|
|
||||||
|
def search(self):
|
||||||
|
query = self.input.text()
|
||||||
|
if not query: return
|
||||||
|
self.lbl_status.setText("Suche läuft...")
|
||||||
|
QApplication.processEvents()
|
||||||
|
|
||||||
|
while self.res_layout.count():
|
||||||
|
child = self.res_layout.takeAt(0)
|
||||||
|
if child.widget(): child.widget().deleteLater()
|
||||||
|
|
||||||
|
results = self.db.search(query)
|
||||||
|
self.lbl_status.setText(f"{len(results)} Treffer gefunden.")
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
lbl = QLabel("Leider keine Ergebnisse.")
|
||||||
|
lbl.setStyleSheet("color: #95a5a6; font-size: 18px; margin-top: 40px;")
|
||||||
|
lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||||
|
self.res_layout.addWidget(lbl)
|
||||||
|
else:
|
||||||
|
for fname, fpath, snippet in results:
|
||||||
|
self.res_layout.addWidget(SearchResultItem(fname, fpath, snippet))
|
||||||
|
self.res_layout.addStretch()
|
||||||
|
|
||||||
|
def load_saved_folders(self):
|
||||||
|
self.folder_list.clear()
|
||||||
|
for f in self.db.get_folders():
|
||||||
|
item = QListWidgetItem(self.style().standardIcon(QStyle.StandardPixmap.SP_DirIcon), f)
|
||||||
|
item.setToolTip(f)
|
||||||
|
self.folder_list.addItem(item)
|
||||||
|
|
||||||
|
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(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.prog.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_idx(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.prog.hide()
|
||||||
|
msg = "Abgebrochen" if c else "Indexierung fertig"
|
||||||
|
self.lbl_status.setText(f"{msg}: {n} neu, {s} übersprungen.")
|
||||||
Reference in New Issue
Block a user