Verbesserung Tag-Auswahl im Klassifizierungsdialog

This commit is contained in:
2026-09-08 20:52:47 +02:00
parent 30faf00ba2
commit 4cd8a5622e
4 changed files with 141 additions and 32 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.2.0"/>
<display-name> <display-name>
<name lang="de">Paperless-Connector</name> <name lang="de">Paperless-Connector</name>

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