2 Commits
v1.1.1 ... main

5 changed files with 254 additions and 39 deletions

View File

@@ -1,4 +1,4 @@
# Paperless-Connector für LibreOffice # Paperless-ngx für LibreOffice
Erweiterung, die Dokumente direkt aus einem Paperless-ngx-Archiv öffnet, Erweiterung, die Dokumente direkt aus einem Paperless-ngx-Archiv öffnet,
Bearbeitungen als **neue Version desselben Dokuments** ablegt, die Bearbeitungen als **neue Version desselben Dokuments** ablegt, die
@@ -241,7 +241,7 @@ selbstgebautem Multipart-Upload.
```bash ```bash
./build.sh ./build.sh
unopkg add --force paperless-libreoffice-1.1.0.oxt unopkg add --force paperless-libreoffice-1.2.0.oxt
``` ```
> **Zur Versionsabhängigkeit:** `OpenOffice.org-minimal-version` in > **Zur Versionsabhängigkeit:** `OpenOffice.org-minimal-version` in
@@ -443,7 +443,7 @@ weiterreicht.
## Stand und Grenzen ## Stand und Grenzen
Version 1.1.0. Das Gerüst ist vollständig und die Client-Logik getestet, Version 1.2.0. Das Gerüst ist vollständig und die Client-Logik getestet,
aber die Erweiterung ist an einer echten Instanz noch nicht über längere aber die Erweiterung ist an einer echten Instanz noch nicht über längere
Zeit erprobt. Zeit erprobt.
@@ -460,6 +460,3 @@ Der letzte Punkt ist der relevanteste für den Mehrbenutzerbetrieb.
Paperless kennt keine Sperren; wer zeitgleich arbeitet, erzeugt zwei Paperless kennt keine Sperren; wer zeitgleich arbeitet, erzeugt zwei
Versionen statt eines Konflikts. Bei einem kleinen Kreis ist das Versionen statt eines Konflikts. Bei einem kleinen Kreis ist das
verschmerzbar. verschmerzbar.
Die Software wird ohne jegliche Gewähr oder Haftungsübernahme bereitgestellt.
Die Nutzung erfolgt auf eigene Gefahr.

View File

@@ -5,7 +5,7 @@
xmlns:xlink="http://www.w3.org/1999/xlink"> xmlns:xlink="http://www.w3.org/1999/xlink">
<identifier value="de.mmorath.paperless"/> <identifier value="de.mmorath.paperless"/>
<version value="1.1.1"/> <version value="1.3.0"/>
<display-name> <display-name>
<name lang="de">Paperless-Connector</name> <name lang="de">Paperless-Connector</name>

View File

@@ -7,6 +7,7 @@ von Hand kodiert.
""" """
import json import json
import re
import mimetypes import mimetypes
import os import os
import ssl import ssl
@@ -242,14 +243,119 @@ class PaperlessClient:
self._json("documents/", {"page_size": 1}) self._json("documents/", {"page_size": 1})
return True 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): def search(self, query, limit=50):
params = {"page_size": limit, """
"ordering": "-added", Dokumente suchen. Vier Wege, in dieser Reihenfolge:
"fields": "id,title,created,added,archive_serial_number,"
"correspondent,document_type,tags,original_file_name"} 1. Archiv-Seriennummer, wenn die Eingabe nur aus Ziffern
if query: besteht. Sie ist ein eigenes Feld - die Volltextsuche
params["query"] = query 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", []) 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): def document(self, doc_id, full_perms=False):
params = {"full_perms": "true"} if full_perms else None params = {"full_perms": "true"} if full_perms else None

View File

@@ -13,10 +13,13 @@ from com.sun.star.awt.MessageBoxButtons import BUTTONS_OK, BUTTONS_OK_CANCEL
from com.sun.star.awt.MessageBoxType import (ERRORBOX, INFOBOX, QUERYBOX, from com.sun.star.awt.MessageBoxType import (ERRORBOX, INFOBOX, QUERYBOX,
WARNINGBOX) WARNINGBOX)
from paperless_i18n import t
# ---------------------------------------------------------------- Meldungen # ---------------------------------------------------------------- Meldungen
def message(ctx, frame, text, title="Paperless", kind=INFOBOX): def message(ctx, frame, text, title="Paperless", kind=INFOBOX):
text, title = t(text, ctx), t(title, ctx)
parent = frame.getContainerWindow() if frame else None parent = frame.getContainerWindow() if frame else None
toolkit = ctx.ServiceManager.createInstanceWithContext( toolkit = ctx.ServiceManager.createInstanceWithContext(
"com.sun.star.awt.Toolkit", ctx) "com.sun.star.awt.Toolkit", ctx)
@@ -30,6 +33,7 @@ def error(ctx, frame, text, title="Paperless — Fehler"):
def confirm(ctx, frame, text, title="Paperless"): def confirm(ctx, frame, text, title="Paperless"):
text, title = t(text, ctx), t(title, ctx)
parent = frame.getContainerWindow() if frame else None parent = frame.getContainerWindow() if frame else None
toolkit = ctx.ServiceManager.createInstanceWithContext( toolkit = ctx.ServiceManager.createInstanceWithContext(
"com.sun.star.awt.Toolkit", ctx) "com.sun.star.awt.Toolkit", ctx)
@@ -57,6 +61,7 @@ def choice(ctx, frame, title, text, options, width=360):
import unohelper import unohelper
from com.sun.star.awt import XActionListener from com.sun.star.awt import XActionListener
text = t(text, ctx)
zeilen = text.count("\n") + 1 zeilen = text.count("\n") + 1
th = max(30, zeilen * 11) th = max(30, zeilen * 11)
h = th + 46 h = th + 46
@@ -156,6 +161,7 @@ class DialogBuilder:
"""Dünne Hülle um UnoControlDialogModel.""" """Dünne Hülle um UnoControlDialogModel."""
def __init__(self, ctx, title, width, height): def __init__(self, ctx, title, width, height):
title = t(title, ctx)
self.ctx = ctx self.ctx = ctx
smgr = ctx.ServiceManager smgr = ctx.ServiceManager
self.model = smgr.createInstanceWithContext( self.model = smgr.createInstanceWithContext(
@@ -183,26 +189,32 @@ class DialogBuilder:
return ctrl return ctrl
def label(self, name, x, y, w, h, text): def label(self, name, x, y, w, h, text):
return self.add("FixedText", name, x, y, w, h, Label=text) return self.add("FixedText", name, x, y, w, h,
Label=t(text, self.ctx))
def edit(self, name, x, y, w, h, text="", **kw): def edit(self, name, x, y, w, h, text="", **kw):
return self.add("Edit", name, x, y, w, h, Text=text, **kw) return self.add("Edit", name, x, y, w, h, Text=text, **kw)
def button(self, name, x, y, w, h, label, action=0, default=False): def button(self, name, x, y, w, h, label, action=0, default=False):
return self.add("Button", name, x, y, w, h, return self.add("Button", name, x, y, w, h,
Label=label, PushButtonType=action, Label=t(label, self.ctx), PushButtonType=action,
DefaultButton=default) DefaultButton=default)
def listbox(self, name, x, y, w, h, items=(), dropdown=False): def listbox(self, name, x, y, w, h, items=(), dropdown=False):
# Einträge fester Auswahllisten sind Oberflächentexte. Inhalte aus
# Paperless werden nicht über diesen Weg gesetzt, sondern später
# mit addItems() - die bleiben also unangetastet.
return self.add("ListBox", name, x, y, w, h, return self.add("ListBox", name, x, y, w, h,
StringItemList=tuple(items), Dropdown=dropdown) StringItemList=tuple(t(i, self.ctx) for i in items),
Dropdown=dropdown)
def combo(self, name, x, y, w, h, items=(), text=""): def combo(self, name, x, y, w, h, items=(), text=""):
return self.add("ComboBox", name, x, y, w, h, return self.add("ComboBox", name, x, y, w, h,
StringItemList=tuple(items), Dropdown=True, Text=text) StringItemList=tuple(items), Dropdown=True, Text=text)
def check(self, name, x, y, w, h, label, state=0): def check(self, name, x, y, w, h, label, state=0):
return self.add("CheckBox", name, x, y, w, h, Label=label, State=state) return self.add("CheckBox", name, x, y, w, h,
Label=t(label, self.ctx), State=state)
def execute(self, frame=None): def execute(self, frame=None):
self.dialog.setModel(self.model) self.dialog.setModel(self.model)
@@ -240,6 +252,9 @@ def settings_dialog(ctx, frame, settings):
d.check("tls", 80, 72, 172, 12, "TLS-Zertifikat prüfen", d.check("tls", 80, 72, 172, 12, "TLS-Zertifikat prüfen",
1 if settings.get("verify_tls", True) else 0) 1 if settings.get("verify_tls", True) else 0)
d.label("l2c", 80, 86, 172, 20,
"Abschalten nur für Testinstanzen mit selbst ausgestelltem "
"Zertifikat. Ohne Prüfung ist die Verbindung angreifbar.")
d.label("l3", 8, 92, 70, 12, "Versionslabel") d.label("l3", 8, 92, 70, 12, "Versionslabel")
d.edit("label", 80, 90, 172, 14, settings.get("version_label", "")) d.edit("label", 80, 90, 172, 14, settings.get("version_label", ""))
@@ -657,9 +672,19 @@ def classify_dialog(ctx, frame, doc, tags, correspondents, doctypes):
""" """
Klassifizierung bearbeiten. Liefert ein PATCH-payload-dict oder None. Klassifizierung bearbeiten. Liefert ein PATCH-payload-dict oder None.
Tags werden als Mehrfachauswahl gefuehrt, Korrespondent und Dokumenttyp Die Tagauswahl arbeitet mit einem Suchfeld und Kontrollkästchen statt
als Auswahlfeld mit Freitext - so lassen sich neue Werte anlegen. mit der Mehrfachauswahl der Liste. Der Grund ist praktischer Natur:
Bei dreissig Tags ist Strg-Klicken mühsam, und ein versehentlicher
Klick ohne Strg löscht die ganze bisherige Auswahl. Mit
Kontrollkästchen kann das nicht passieren.
Gewählte Tags stehen oben, abgetrennt vom Rest. Sie bleiben dort auch
dann sichtbar, wenn der Filter sie ausschliessen würde - sonst
verliert man beim Suchen den Überblick, was schon angehakt ist.
""" """
import unohelper
from com.sun.star.awt import XItemListener, XTextListener
def names(rows): def names(rows):
return [r["name"] for r in rows] return [r["name"] for r in rows]
@@ -669,7 +694,7 @@ def classify_dialog(ctx, frame, doc, tags, correspondents, doctypes):
return r["name"] return r["name"]
return "" return ""
d = DialogBuilder(ctx, "Klassifizierung bearbeiten", 300, 250) d = DialogBuilder(ctx, "Klassifizierung bearbeiten", 300, 276)
d.label("l1", 8, 10, 60, 12, "Titel") d.label("l1", 8, 10, 60, 12, "Titel")
d.edit("title", 70, 8, 222, 14, doc.get("title") or "") d.edit("title", 70, 8, 222, 14, doc.get("title") or "")
@@ -687,25 +712,107 @@ def classify_dialog(ctx, frame, doc, tags, correspondents, doctypes):
name_of(doctypes, doc.get("document_type"))) name_of(doctypes, doc.get("document_type")))
d.label("l5", 8, 90, 60, 12, "Tags") d.label("l5", 8, 90, 60, 12, "Tags")
lb = d.listbox("tags", 70, 88, 222, 120) d.edit("filter", 70, 88, 222, 14, "")
lb.MultiSelection = True d.label("l5c", 70, 104, 222, 10, "Tippen zum Filtern")
d.label("l5b", 70, 210, 222, 10,
"Mehrfachauswahl mit Strg oder Umschalt")
d.button("ok", 178, 226, 52, 16, "Speichern", 1, True) mono(d.listbox("tags", 70, 116, 222, 108))
d.button("cancel", 236, 226, 56, 16, "Abbrechen", 2) d.label("l5b", 70, 226, 222, 20,
"Anklicken schaltet um. Gewählte stehen oben.")
d.button("ok", 178, 252, 52, 16, "Speichern", 1, True)
d.button("cancel", 236, 252, 56, 16, "Abbrechen", 2)
d.dialog.setModel(d.model) d.dialog.setModel(d.model)
smgr = ctx.ServiceManager smgr = ctx.ServiceManager
toolkit = smgr.createInstanceWithContext("com.sun.star.awt.Toolkit", ctx) toolkit = smgr.createInstanceWithContext("com.sun.star.awt.Toolkit", ctx)
d.dialog.createPeer(toolkit, frame.getContainerWindow() if frame else None) d.dialog.createPeer(toolkit, frame.getContainerWindow() if frame else None)
tag_ctrl = d.get("tags") lb = d.get("tags")
tag_ctrl.addItems(tuple(names(tags)), 0) gewaehlt = set(doc.get("tags") or [])
have = set(doc.get("tags") or [])
for i, t in enumerate(tags): # Zuordnung von Listenposition zu Tag. Sie ändert sich mit jedem
if t["id"] in have: # Filterlauf, deshalb wird sie beim Zeichnen neu aufgebaut.
tag_ctrl.selectItemPos(i, True) zustand = {"zeilen": [], "stumm": False}
TRENNER = None # Platzhalter für die Trennzeile
def sichtbare():
muster = d.get("filter").getText().strip().lower()
oben, unten = [], []
for t in tags:
if t["id"] in gewaehlt:
oben.append(t)
elif not muster or muster in t["name"].lower():
unten.append(t)
return oben, unten
def zeichnen():
"""Liste neu aufbauen. Setzt stumm, damit das Umschalten nicht
durch die eigene Änderung erneut ausgelöst wird."""
zustand["stumm"] = True
try:
lb.removeItems(0, lb.getItemCount())
oben, unten = sichtbare()
zeilen, eintraege = [], []
for t in oben:
zeilen.append(t)
eintraege.append("[x] " + t["name"])
if oben and unten:
zeilen.append(TRENNER)
eintraege.append(" " + "-" * 30)
for t in unten:
zeilen.append(t)
eintraege.append("[ ] " + t["name"])
if not eintraege:
zeilen.append(TRENNER)
eintraege.append(" (keine Übereinstimmung)")
zustand["zeilen"] = zeilen
lb.addItems(tuple(eintraege), 0)
finally:
zustand["stumm"] = False
def umschalten():
if zustand["stumm"]:
return
pos = lb.getSelectedItemPos()
zeilen = zustand["zeilen"]
if not (0 <= pos < len(zeilen)):
return
t = zeilen[pos]
if t is TRENNER:
return
if t["id"] in gewaehlt:
gewaehlt.discard(t["id"])
else:
gewaehlt.add(t["id"])
zeichnen()
class Klick(unohelper.Base, XItemListener):
def itemStateChanged(self, _ev):
umschalten()
def disposing(self, _ev):
pass
class Tippen(unohelper.Base, XTextListener):
def textChanged(self, _ev):
if not zustand["stumm"]:
zeichnen()
def disposing(self, _ev):
pass
# Die Behandler in einer Liste halten. Als Wegwerfobjekte übergeben
# könnte der Speicherbereiniger sie einsammeln, bevor der Dialog
# fertig ist - die Ereignisse liefen dann ins Leere.
behandler = [Klick(), Tippen()]
lb.addItemListener(behandler[0])
d.get("filter").addTextListener(behandler[1])
zeichnen()
if d.dialog.execute() != 1: if d.dialog.execute() != 1:
d.dispose() d.dispose()
@@ -716,8 +823,9 @@ def classify_dialog(ctx, frame, doc, tags, correspondents, doctypes):
if created: if created:
payload["created"] = created payload["created"] = created
selected = d.get("tags").getSelectedItemsPos() # Aus der Menge, nicht aus der Liste: Die Anzeige kann gefiltert sein,
payload["tags"] = [tags[i]["id"] for i in selected if i < len(tags)] # die Auswahl gilt aber vollständig.
payload["tags"] = [t["id"] for t in tags if t["id"] in gewaehlt]
payload["_correspondent_name"] = d.get("corr").getText().strip() payload["_correspondent_name"] = d.get("corr").getText().strip()
payload["_document_type_name"] = d.get("dtype").getText().strip() payload["_document_type_name"] = d.get("dtype").getText().strip()

View File

@@ -179,8 +179,10 @@ EN = {
# -- Klassifizierung # -- Klassifizierung
"Klassifizierung bearbeiten": "Edit classification", "Klassifizierung bearbeiten": "Edit classification",
"Format JJJJ-MM-TT": "Format YYYY-MM-DD", "Format JJJJ-MM-TT": "Format YYYY-MM-DD",
"Mehrfachauswahl mit Strg oder Umschalt": "Tippen zum Filtern": "Type to filter",
"Multiple selection with Ctrl or Shift", "Anklicken schaltet um. Gewählte stehen oben.":
"Click to toggle. Selected tags are listed first.",
" (keine Übereinstimmung)": " (no match)",
"Klassifizierung gespeichert.": "Classification saved.", "Klassifizierung gespeichert.": "Classification saved.",
"Stammdaten konnten nicht geladen werden:\n%s": "Stammdaten konnten nicht geladen werden:\n%s":
"Master data could not be loaded:\n%s", "Master data could not be loaded:\n%s",
@@ -335,8 +337,10 @@ FR = {
"Klassifizierung bearbeiten": "Modifier le classement", "Klassifizierung bearbeiten": "Modifier le classement",
"Format JJJJ-MM-TT": "Format AAAA-MM-JJ", "Format JJJJ-MM-TT": "Format AAAA-MM-JJ",
"Mehrfachauswahl mit Strg oder Umschalt": "Tippen zum Filtern": "Saisir pour filtrer",
"Sélection multiple avec Ctrl ou Maj", "Anklicken schaltet um. Gewählte stehen oben.":
"Cliquer pour basculer. Les étiquettes choisies sont en haut.",
" (keine Übereinstimmung)": " (aucune correspondance)",
"Klassifizierung gespeichert.": "Classement enregistré.", "Klassifizierung gespeichert.": "Classement enregistré.",
"Stammdaten konnten nicht geladen werden:\n%s": "Stammdaten konnten nicht geladen werden:\n%s":
"Les données de référence n'ont pas pu être chargées :\n%s", "Les données de référence n'ont pas pu être chargées :\n%s",