From 0e4434f16d2fbeb8bd33d5bdcf79d6327b1ac09d Mon Sep 17 00:00:00 2001 From: Marco Morath Date: Thu, 10 Sep 2026 17:18:30 +0200 Subject: [PATCH] Verbesserung der Suche vorhandener Dokumente --- description.xml | 2 +- pythonpath/paperless_client.py | 120 +++++++++++++++++++++++++++++++-- 2 files changed, 114 insertions(+), 8 deletions(-) diff --git a/description.xml b/description.xml index 240295f..2adc5c3 100644 --- a/description.xml +++ b/description.xml @@ -5,7 +5,7 @@ xmlns:xlink="http://www.w3.org/1999/xlink"> - + Paperless-Connector diff --git a/pythonpath/paperless_client.py b/pythonpath/paperless_client.py index 6c28157..cda2382 100644 --- a/pythonpath/paperless_client.py +++ b/pythonpath/paperless_client.py @@ -7,6 +7,7 @@ von Hand kodiert. """ import json +import re import mimetypes import os import ssl @@ -242,14 +243,119 @@ class PaperlessClient: self._json("documents/", {"page_size": 1}) return True + # Felder, die die Trefferliste braucht. Ohne diese Einschränkung + # liefert Paperless je Treffer auch den vollständigen Inhalt - bei + # fünfzig Treffern sind das schnell mehrere Megabyte. + SUCHFELDER = ("id,title,created,added,archive_serial_number," + "correspondent,document_type,tags,original_file_name") + + # Zeichen mit besonderer Bedeutung in der Abfragesprache des + # Suchindex. Unbehandelt führen sie zu Syntaxfehlern oder zu + # Treffern, die niemand erwartet - ein Bindestrich im Namen wäre + # sonst ein Ausschluss. + _SONDERZEICHEN = re.compile(r'[+\-&|!(){}\[\]^"~*?:\\/]') + def search(self, query, limit=50): - params = {"page_size": limit, - "ordering": "-added", - "fields": "id,title,created,added,archive_serial_number," - "correspondent,document_type,tags,original_file_name"} - if query: - params["query"] = query - return (self._json("documents/", params) or {}).get("results", []) + """ + Dokumente suchen. Vier Wege, in dieser Reihenfolge: + + 1. Archiv-Seriennummer, wenn die Eingabe nur aus Ziffern + besteht. Sie ist ein eigenes Feld - die Volltextsuche + findet sie NICHT. + + 2. Die GLOBALE SUCHE von Paperless (/api/search/). Das ist + dieselbe Schnittstelle, die die Suchleiste oben in der + Weboberfläche bedient. Sie verbindet Titelsuche und + Volltext und liefert damit die Ergebnisse, die man aus der + Oberfläche kennt. + + 3. Teilzeichenkette in Titel und Inhalt. Greift, wenn die + globale Suche nichts liefert oder in der eingesetzten + Version fehlt. + + 4. Volltextsuche über den Index, mit UND-Verknüpfung. Sie + beherrscht Wortstämme und findet auch Beugungsformen. + + Zwei Fallstricke, die hier berücksichtigt sind: + + * Bei der Volltextsuche darf KEIN ordering mitgegeben werden. + Paperless sortiert dort nach Relevanz; eine eigene + Sortierangabe führte dazu, dass die Suchanfrage verworfen + wurde und schlicht die zuletzt hinzugefügten Dokumente kamen. + + * Ohne UND-Verknüpfung sucht der Index mit ODER. "Anschreiben + Markus Straub" lieferte dann alles, was irgendeines der drei + Wörter enthält - der gesuchte Treffer ging darin unter. + """ + q = (query or "").strip() + + if not q: + return self._suchlauf({"ordering": "-added"}, limit) + + # 1. Seriennummer + if q.isdigit(): + treffer = self._suchlauf({"archive_serial_number": q}, limit) + if treffer: + return treffer + + # 2. Globale Suche wie in der Weboberfläche + treffer = self._globale_suche(q, limit) + if treffer: + return treffer + + # 3. Titel und Inhalt + treffer = self._suchlauf({"title_content": q, + "ordering": "-created"}, limit) + if treffer: + return treffer + + # 4. Volltext mit UND, ohne ordering. + # + # Nach dem Ersetzen der Sonderzeichen ERNEUT zerlegen: Aus + # "Müller-Lüdenscheidt" würde sonst ein Wort mit Leerzeichen, + # und die UND-Verknüpfung wäre wieder aufgehoben. + worte = self._SONDERZEICHEN.sub(" ", q).split() + if not worte: + return [] + return self._suchlauf({"query": " AND ".join(worte)}, limit) + + def _globale_suche(self, q, limit): + """ + Die Schnittstelle hinter der Suchleiste der Weboberfläche. + + Sie gibt es erst ab neueren Paperless-Ständen, und die Form der + Antwort ist nicht überall gleich - mal ein Feld "documents", mal + "results". Deshalb wird beides angenommen und bei jedem Fehler + stillschweigend auf den nächsten Weg ausgewichen. + """ + try: + antwort = self._json("search/", {"query": q, + "page_size": limit}) + except PaperlessError: + return [] + if not antwort: + return [] + + for schluessel in ("documents", "results"): + rows = antwort.get(schluessel) + if isinstance(rows, list) and rows: + # Die globale Suche liefert je nach Version verschiedene + # Objektarten. Nur echte Dokumente durchlassen. + return [r for r in rows + if isinstance(r, dict) and "id" in r + and "title" in r][:limit] + return [] + + def _suchlauf(self, zusatz, limit): + params = {"page_size": limit, "fields": self.SUCHFELDER} + params.update(zusatz) + try: + return (self._json("documents/", params) or {}).get("results", []) + except PaperlessError: + # Ein nicht unterstützter Parameter soll nicht den ganzen + # Dialog zum Scheitern bringen - der nächste Weg wird + # ohnehin probiert. + return [] def document(self, doc_id, full_perms=False): params = {"full_perms": "true"} if full_perms else None