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,
Bearbeitungen als **neue Version desselben Dokuments** ablegt, die
@@ -241,7 +241,7 @@ selbstgebautem Multipart-Upload.
```bash
./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
@@ -443,7 +443,7 @@ weiterreicht.
## 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
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
Versionen statt eines Konflikts. Bei einem kleinen Kreis ist das
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">
<identifier value="de.mmorath.paperless"/>
<version value="1.1.1"/>
<version value="1.3.0"/>
<display-name>
<name lang="de">Paperless-Connector</name>

View File

@@ -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
"""
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

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,
WARNINGBOX)
from paperless_i18n import t
# ---------------------------------------------------------------- Meldungen
def message(ctx, frame, text, title="Paperless", kind=INFOBOX):
text, title = t(text, ctx), t(title, ctx)
parent = frame.getContainerWindow() if frame else None
toolkit = ctx.ServiceManager.createInstanceWithContext(
"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"):
text, title = t(text, ctx), t(title, ctx)
parent = frame.getContainerWindow() if frame else None
toolkit = ctx.ServiceManager.createInstanceWithContext(
"com.sun.star.awt.Toolkit", ctx)
@@ -57,6 +61,7 @@ def choice(ctx, frame, title, text, options, width=360):
import unohelper
from com.sun.star.awt import XActionListener
text = t(text, ctx)
zeilen = text.count("\n") + 1
th = max(30, zeilen * 11)
h = th + 46
@@ -156,6 +161,7 @@ class DialogBuilder:
"""Dünne Hülle um UnoControlDialogModel."""
def __init__(self, ctx, title, width, height):
title = t(title, ctx)
self.ctx = ctx
smgr = ctx.ServiceManager
self.model = smgr.createInstanceWithContext(
@@ -183,26 +189,32 @@ class DialogBuilder:
return ctrl
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):
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):
return self.add("Button", name, x, y, w, h,
Label=label, PushButtonType=action,
Label=t(label, self.ctx), PushButtonType=action,
DefaultButton=default)
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,
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=""):
return self.add("ComboBox", name, x, y, w, h,
StringItemList=tuple(items), Dropdown=True, Text=text)
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):
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",
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.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.
Tags werden als Mehrfachauswahl gefuehrt, Korrespondent und Dokumenttyp
als Auswahlfeld mit Freitext - so lassen sich neue Werte anlegen.
Die Tagauswahl arbeitet mit einem Suchfeld und Kontrollkästchen statt
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):
return [r["name"] for r in rows]
@@ -669,7 +694,7 @@ def classify_dialog(ctx, frame, doc, tags, correspondents, doctypes):
return r["name"]
return ""
d = DialogBuilder(ctx, "Klassifizierung bearbeiten", 300, 250)
d = DialogBuilder(ctx, "Klassifizierung bearbeiten", 300, 276)
d.label("l1", 8, 10, 60, 12, "Titel")
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")))
d.label("l5", 8, 90, 60, 12, "Tags")
lb = d.listbox("tags", 70, 88, 222, 120)
lb.MultiSelection = True
d.label("l5b", 70, 210, 222, 10,
"Mehrfachauswahl mit Strg oder Umschalt")
d.edit("filter", 70, 88, 222, 14, "")
d.label("l5c", 70, 104, 222, 10, "Tippen zum Filtern")
d.button("ok", 178, 226, 52, 16, "Speichern", 1, True)
d.button("cancel", 236, 226, 56, 16, "Abbrechen", 2)
mono(d.listbox("tags", 70, 116, 222, 108))
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)
smgr = ctx.ServiceManager
toolkit = smgr.createInstanceWithContext("com.sun.star.awt.Toolkit", ctx)
d.dialog.createPeer(toolkit, frame.getContainerWindow() if frame else None)
tag_ctrl = d.get("tags")
tag_ctrl.addItems(tuple(names(tags)), 0)
have = set(doc.get("tags") or [])
for i, t in enumerate(tags):
if t["id"] in have:
tag_ctrl.selectItemPos(i, True)
lb = d.get("tags")
gewaehlt = set(doc.get("tags") or [])
# Zuordnung von Listenposition zu Tag. Sie ändert sich mit jedem
# Filterlauf, deshalb wird sie beim Zeichnen neu aufgebaut.
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:
d.dispose()
@@ -716,8 +823,9 @@ def classify_dialog(ctx, frame, doc, tags, correspondents, doctypes):
if created:
payload["created"] = created
selected = d.get("tags").getSelectedItemsPos()
payload["tags"] = [tags[i]["id"] for i in selected if i < len(tags)]
# Aus der Menge, nicht aus der Liste: Die Anzeige kann gefiltert sein,
# 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["_document_type_name"] = d.get("dtype").getText().strip()

View File

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