Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e4434f16d | |||
| 4cd8a5622e |
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user