From 30faf00ba2bd34a712d4eb6e7f9348cc181e65d9 Mon Sep 17 00:00:00 2001 From: Marco Morath Date: Fri, 4 Sep 2026 19:49:43 +0200 Subject: [PATCH] Initial commit --- .gitignore | 35 ++ Addons.xcu | 371 +++++++++++++++ META-INF/manifest.xml | 16 + ProtocolHandler.xcu | 13 + SICHERHEIT.md | 200 ++++++++ build.sh | 71 +++ description-de.txt | 13 + description-en.txt | 12 + description-fr.txt | 13 + description.xml | 75 +++ icons/README.txt | 16 + icons/paperless-classify.svg | 12 + icons/paperless-classify_16.png | Bin 0 -> 887 bytes icons/paperless-classify_26.png | Bin 0 -> 1668 bytes icons/paperless-classify_32.png | Bin 0 -> 2094 bytes icons/paperless-logo.svg | 9 + icons/paperless-logo_16.png | Bin 0 -> 680 bytes icons/paperless-logo_26.png | Bin 0 -> 1008 bytes icons/paperless-logo_32.png | Bin 0 -> 1186 bytes icons/paperless-open.svg | 12 + icons/paperless-open_16.png | Bin 0 -> 885 bytes icons/paperless-open_26.png | Bin 0 -> 1633 bytes icons/paperless-open_32.png | Bin 0 -> 2012 bytes icons/paperless-saveas.svg | 17 + icons/paperless-saveas_16.png | Bin 0 -> 899 bytes icons/paperless-saveas_26.png | Bin 0 -> 1676 bytes icons/paperless-saveas_32.png | Bin 0 -> 2127 bytes icons/paperless-settings.svg | 20 + icons/paperless-settings_16.png | Bin 0 -> 908 bytes icons/paperless-settings_26.png | Bin 0 -> 1754 bytes icons/paperless-settings_32.png | Bin 0 -> 2247 bytes icons/paperless-share.svg | 15 + icons/paperless-share_16.png | Bin 0 -> 883 bytes icons/paperless-share_26.png | Bin 0 -> 1694 bytes icons/paperless-share_32.png | Bin 0 -> 2142 bytes icons/paperless-version.svg | 14 + icons/paperless-version_16.png | Bin 0 -> 889 bytes icons/paperless-version_26.png | Bin 0 -> 1635 bytes icons/paperless-version_32.png | Bin 0 -> 2060 bytes make_icons.py | 283 ++++++++++++ paperless_addon.py | 743 ++++++++++++++++++++++++++++++ pythonpath/paperless_client.py | 406 ++++++++++++++++ pythonpath/paperless_config.py | 250 ++++++++++ pythonpath/paperless_dialogs.py | 790 ++++++++++++++++++++++++++++++++ pythonpath/paperless_formats.py | 148 ++++++ pythonpath/paperless_i18n.py | 392 ++++++++++++++++ symbole-vorschau.png | Bin 0 -> 36711 bytes 47 files changed, 3946 insertions(+) create mode 100644 .gitignore create mode 100644 Addons.xcu create mode 100644 META-INF/manifest.xml create mode 100644 ProtocolHandler.xcu create mode 100644 SICHERHEIT.md create mode 100644 build.sh create mode 100644 description-de.txt create mode 100644 description-en.txt create mode 100644 description-fr.txt create mode 100644 description.xml create mode 100644 icons/README.txt create mode 100644 icons/paperless-classify.svg create mode 100644 icons/paperless-classify_16.png create mode 100644 icons/paperless-classify_26.png create mode 100644 icons/paperless-classify_32.png create mode 100644 icons/paperless-logo.svg create mode 100644 icons/paperless-logo_16.png create mode 100644 icons/paperless-logo_26.png create mode 100644 icons/paperless-logo_32.png create mode 100644 icons/paperless-open.svg create mode 100644 icons/paperless-open_16.png create mode 100644 icons/paperless-open_26.png create mode 100644 icons/paperless-open_32.png create mode 100644 icons/paperless-saveas.svg create mode 100644 icons/paperless-saveas_16.png create mode 100644 icons/paperless-saveas_26.png create mode 100644 icons/paperless-saveas_32.png create mode 100644 icons/paperless-settings.svg create mode 100644 icons/paperless-settings_16.png create mode 100644 icons/paperless-settings_26.png create mode 100644 icons/paperless-settings_32.png create mode 100644 icons/paperless-share.svg create mode 100644 icons/paperless-share_16.png create mode 100644 icons/paperless-share_26.png create mode 100644 icons/paperless-share_32.png create mode 100644 icons/paperless-version.svg create mode 100644 icons/paperless-version_16.png create mode 100644 icons/paperless-version_26.png create mode 100644 icons/paperless-version_32.png create mode 100644 make_icons.py create mode 100644 paperless_addon.py create mode 100644 pythonpath/paperless_client.py create mode 100644 pythonpath/paperless_config.py create mode 100644 pythonpath/paperless_dialogs.py create mode 100644 pythonpath/paperless_formats.py create mode 100644 pythonpath/paperless_i18n.py create mode 100644 symbole-vorschau.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26739f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Bauergebnis. Wird von build.sh erzeugt und gehoert nicht ins +# Repository - Releases werden als Anhang veroeffentlicht, nicht +# eingecheckt. +*.oxt + +# Python +__pycache__/ +*.pyc +*.pyo + +# Persoenliche Einstellungen und Arbeitsdaten der Erweiterung. +# Sie liegen im Benutzerprofil, nicht hier. Diese Eintraege fangen den +# Fall ab, dass jemand zum Ausprobieren eine Kopie ins Arbeitsverzeichnis +# legt - in settings.json steht der API-Token im Klartext. +settings.json +documents.json +arbeit/ + +# Editoren und Betriebssysteme +.vscode/ +.idea/ +*.swp +*~ +.DS_Store +Thumbs.db +desktop.ini + +# registration/license-en.txt ist eine Kopie von LICENSE, die build.sh +# erzeugt - LibreOffice erwartet den Lizenztext dort mit .txt-Endung. +registration/ + +# Symbole werden von make_icons.py erzeugt. Sie sind BEWUSST NICHT +# ausgenommen: Ohne sie laesst sich das Paket nicht bauen, und wer nur die +# Erweiterung nutzen will, soll nicht Pillow installieren muessen. +# icons/*.png und icons/*.svg gehoeren also ins Repository. diff --git a/Addons.xcu b/Addons.xcu new file mode 100644 index 0000000..49db314 --- /dev/null +++ b/Addons.xcu @@ -0,0 +1,371 @@ + + + + + + + + + + Paperless-Connector + Paperless-Connector + Paperless-Connector + + + _self + + + + + + paperless.Command:Open + + + + Aus Paperless öffnen… + Ouvrir depuis Paperless… + Open from Paperless… + + _self + + + + + paperless.Command:SaveVersion + + + + Als neue Version ablegen + Enregistrer comme nouvelle version + Store as new version + + _self + + + + + paperless.Command:SaveAs + + + + In Paperless ablegen… + Enregistrer dans Paperless… + Store in Paperless… + + _self + + + + + private:separator + + + + + + paperless.Command:Classify + + + + Klassifizierung bearbeiten… + Modifier le classement… + Edit classification… + + _self + + + + + paperless.Command:Share + + + + Freigabelink erzeugen… + Créer un lien de partage… + Create share link… + + _self + + + + + private:separator + + + + + + paperless.Command:Info + + + + Status anzeigen + Afficher l'état + Show status + + _self + + + + + paperless.Command:Settings + + + + Verbindung einrichten… + Configurer la connexion… + Configure connection… + + _self + + + + + + + + + + + + + + paperless.Command:Info + + + + Paperless + Paperless + Paperless + + _self + + com.sun.star.text.TextDocument,com.sun.star.sheet.SpreadsheetDocument,com.sun.star.presentation.PresentationDocument,com.sun.star.drawing.DrawingDocument + + + + + + private:separator + + + + + + paperless.Command:Open + + + + Öffnen + Ouvrir + Open + + _self + + com.sun.star.text.TextDocument,com.sun.star.sheet.SpreadsheetDocument,com.sun.star.presentation.PresentationDocument,com.sun.star.drawing.DrawingDocument + + + + + + paperless.Command:SaveVersion + + + + Neue Version + Nouvelle version + New version + + _self + + com.sun.star.text.TextDocument,com.sun.star.sheet.SpreadsheetDocument,com.sun.star.presentation.PresentationDocument,com.sun.star.drawing.DrawingDocument + + + + + + paperless.Command:SaveAs + + + + Ablegen unter + Enregistrer sous + Store as + + _self + + com.sun.star.text.TextDocument,com.sun.star.sheet.SpreadsheetDocument,com.sun.star.presentation.PresentationDocument,com.sun.star.drawing.DrawingDocument + + + + + + paperless.Command:Classify + + + + Klassifizierung + Classement + Classification + + _self + + com.sun.star.text.TextDocument,com.sun.star.sheet.SpreadsheetDocument,com.sun.star.presentation.PresentationDocument,com.sun.star.drawing.DrawingDocument + + + + + + paperless.Command:Share + + + + Teilen + Partager + Share + + _self + + com.sun.star.text.TextDocument,com.sun.star.sheet.SpreadsheetDocument,com.sun.star.presentation.PresentationDocument,com.sun.star.drawing.DrawingDocument + + + + + + private:separator + + + + + + paperless.Command:Settings + + + + Verbindung + Connexion + Connection + + _self + + com.sun.star.text.TextDocument,com.sun.star.sheet.SpreadsheetDocument,com.sun.star.presentation.PresentationDocument,com.sun.star.drawing.DrawingDocument + + + + + + + + + + + + paperless.Command:Open + + + + %origin%/icons/paperless-open.svg + + + %origin%/icons/paperless-open.svg + + + + + + paperless.Command:SaveVersion + + + + %origin%/icons/paperless-version.svg + + + %origin%/icons/paperless-version.svg + + + + + + paperless.Command:SaveAs + + + + %origin%/icons/paperless-saveas.svg + + + %origin%/icons/paperless-saveas.svg + + + + + + paperless.Command:Classify + + + + %origin%/icons/paperless-classify.svg + + + %origin%/icons/paperless-classify.svg + + + + + + paperless.Command:Share + + + + %origin%/icons/paperless-share.svg + + + %origin%/icons/paperless-share.svg + + + + + + paperless.Command:Info + + + + %origin%/icons/paperless-logo.svg + + + %origin%/icons/paperless-logo.svg + + + + + + paperless.Command:Settings + + + + %origin%/icons/paperless-settings.svg + + + %origin%/icons/paperless-settings.svg + + + + + + diff --git a/META-INF/manifest.xml b/META-INF/manifest.xml new file mode 100644 index 0000000..307fb8a --- /dev/null +++ b/META-INF/manifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/ProtocolHandler.xcu b/ProtocolHandler.xcu new file mode 100644 index 0000000..981f077 --- /dev/null +++ b/ProtocolHandler.xcu @@ -0,0 +1,13 @@ + + + + + + paperless.Command:* + + + + diff --git a/SICHERHEIT.md b/SICHERHEIT.md new file mode 100644 index 0000000..a472349 --- /dev/null +++ b/SICHERHEIT.md @@ -0,0 +1,200 @@ +# Sicherheitsbetrachtung + +Was die Erweiterung tut, welche Angriffswege sich daraus ergeben und was +dagegen unternommen wird. Stand: Version 1.0.0. + +--- + +## Was die Erweiterung überhaupt macht + +| Handlung | Vertrauensgrenze | +|---|---| +| HTTPS-Anfragen an eine konfigurierte Adresse | Gegenstelle ist nicht vertrauenswürdig | +| Dateien herunterladen und im Benutzerprofil ablegen | Inhalt ist nicht vertrauenswürdig | +| Heruntergeladene Dateien in LibreOffice öffnen | **kritischster Schritt** | +| Lokale Dateien hochladen | eigener Inhalt | +| Token dauerhaft speichern | Geheimnis | + +Der gefährlichste Schritt ist das Öffnen. Alles andere ist Datenverkehr. + +--- + +## 1. Codeeinschleusung über Dokumente + +**Die ernsteste Gefahr.** Ein ODT oder DOCX kann Makros enthalten, die +beim Öffnen ausgeführt werden. Und ein Dokument aus dem Archiv ist fremder +Inhalt — auch aus dem eigenen Bestand, denn dort landen Dateien aus +E-Mail-Anhängen, Scannerordnern und Netzfreigaben. + +Ohne Gegenmaßnahme wäre die Erweiterung ein bequemer Weg, ein präpariertes +Dokument ins Archiv zu legen und darauf zu warten, dass es jemand öffnet. + +**Maßnahme.** Beim Laden werden zwei Einstellungen erzwungen: + +```python +MACRO_NEVER_EXECUTE = 1 # com.sun.star.document.MacroExecMode +UPDATE_NO_UPDATE = 0 # com.sun.star.document.UpdateDocMode + +doc = desktop.loadComponentFromURL( + url, "_blank", 0, + (prop("ReadOnly", readonly), + prop("MacroExecutionMode", MACRO_NEVER_EXECUTE), + prop("UpdateDocMode", UPDATE_NO_UPDATE))) +``` + +`NEVER_EXECUTE` bedeutet: Makros werden nicht ausgeführt, **auch nicht nach +Rückfrage**. Der Anwender kann sie nicht aus Versehen freigeben — anders +als bei der gelben Leiste, die LibreOffice sonst anzeigt. + +`NO_UPDATE` verhindert das Nachladen verknüpfter Inhalte. Sonst könnte ein +Dokument beim Öffnen eine Adresse abrufen und damit bestätigen, dass es +geöffnet wurde, oder Inhalte aus dem lokalen Netz einbinden. + +**Restrisiko.** Sicherheitslücken im Parser von LibreOffice selbst deckt +das nicht ab. Ein präpariertes Dokument, das einen Speicherfehler auslöst, +wirkt unabhängig von diesen Einstellungen. Dagegen hilft nur, LibreOffice +aktuell zu halten. + +--- + +## 2. Pfadmanipulation über Dateinamen + +Der Dateiname beim Herunterladen stammt aus dem Kopfzeilenfeld +`Content-Disposition` der Gegenstelle. Ein Wert wie `../../.bashrc` oder +`..\..\Autostart\x.lnk` würde beim Ablegen aus dem Arbeitsverzeichnis +herausführen. + +**Maßnahme.** Vollständige Bereinigung in `safe_filename()`: + +- **beide** Trennzeichen entfernen, nicht nur das der laufenden Plattform — + unter Linux ist `\` ein gültiges Zeichen im Dateinamen, unter Windows ein + Trenner +- unter Windows verbotene Zeichen `< > : " / \ | ? *` und Steuerzeichen +- reservierte Gerätenamen (`CON`, `AUX`, `NUL`, `COM1`…) mit `_` versehen +- Punkte und Leerzeichen am Ende, die Windows stillschweigend abschneidet +- Länge begrenzen, Endung dabei erhalten + +Geprüft mit: + +| Eingabe | Ergebnis | +|---|---| +| `../../../etc/passwd` | `passwd` | +| `..\..\Windows\System32\evil.dll` | `evil.dll` | +| `CON` | `_CON` | +| ` .. ` | `Dokument` | + +--- + +## 3. Ablageort + +Ein Arbeitsverzeichnis unter `/tmp` wäre auf Mehrbenutzersystemen für alle +beschreibbar. Ein anderer Benutzer könnte dort unter dem erwarteten Namen +einen symbolischen Verweis anlegen und das Schreiben in eine fremde Datei +umlenken — ein klassischer Symlink-Angriff. + +**Maßnahme.** Die Dateien liegen unter +`/paperless-libreoffice/arbeit/`, angelegt mit `0700`. +Einzelne Dateien werden mit `os.open(..., 0o600)` erzeugt, nicht +nachträglich per `chmod` — sonst gäbe es ein Zeitfenster, in dem sie +lesbar wären. + +--- + +## 4. Der Token + +Er liegt im Klartext in `settings.json` mit Modus `0600`. Das entspricht +dem, was Desktop-Anwendungen üblicherweise tun, ist aber **kein +Geheimnisspeicher**. + +Was dagegen sichergestellt ist: + +- Der Token wird nie in eine Fehlermeldung übernommen. Bei HTTP-Fehlern + erscheint die Adresse, nie das Kopfzeilenfeld. +- Er steht nur im Arbeitsspeicher und in der Einstellungsdatei. +- Im Eingabefeld erscheinen Sternchen. + +**Wer es strenger will**, müsste die Erweiterung an den Systemschlüssel- +bund anbinden — über `com.sun.star.task.PasswordContainer` oder libsecret. +Das ist nicht umgesetzt. + +**Empfehlung:** In Paperless einen eigenen Token für die Erweiterung +anlegen, nicht denselben wie für andere Werkzeuge. Dann lässt er sich +einzeln widerrufen. + +--- + +## 5. Transport + +**Adressprüfung.** Nur `http` und `https` werden angenommen. Ohne diese +Prüfung ließe sich `file:///etc/shadow` als Basisadresse hinterlegen, und +`urllib` würde das bedienen — aus einer Adresseingabe würde ein +Dateizugriff. + +| Eingabe | Ergebnis | +|---|---| +| `https://dms.example.org` | angenommen | +| `file:///etc/shadow` | abgewiesen | +| `ftp://server/pfad` | abgewiesen | +| `dms.example.org` (ohne Schema) | abgewiesen | + +**Weiterleitungen.** Der Vorgabe-Handler von `urllib` erlaubt auch `ftp`. +Ein eigener Handler weist alles außer `http` und `https` ab — eine +übernommene Gegenstelle kann so nicht auf ein anderes Protokoll umlenken. + +**Zertifikatsprüfung.** Standardmäßig aktiv. Sie lässt sich für +Testinstanzen abschalten; im Dialog steht dann der Hinweis, dass die +Verbindung ohne Prüfung angreifbar ist. + +**Größenbegrenzung.** 32 MB für Metadaten, 512 MB für Dateien. Ohne +Begrenzung könnte eine fehlerhafte oder böswillige Gegenstelle mit einer +endlosen Antwort den Arbeitsspeicher füllen. + +--- + +## 6. Was an Paperless geschickt wird + +Alle Anfragen laufen über die REST-Schnittstelle mit JSON- oder +Multipart-Kodierung. Es werden keine Zeichenketten in Abfragen +zusammengesetzt, es gibt also keine Einschleusung auf dieser Ebene. + +Der Suchbegriff wird über `urlencode` kodiert. Titel, Tagnamen und +Zusatzfelder gehen als JSON — die Kodierung übernimmt `json.dumps`. + +Die Erweiterung kann in Paperless nur das, was der hinterlegte Token darf. +Wer die Rechte begrenzen will, legt in Paperless einen Benutzer mit +eingeschränkten Objektrechten an und erzeugt dessen Token. + +--- + +## 7. Was die Erweiterung NICHT tut + +- keine Ausführung von Code aus Antworten der Gegenstelle +- kein `eval`, kein `exec`, kein `pickle` +- kein Starten externer Programme +- kein Schreiben außerhalb des eigenen Arbeitsverzeichnisses +- keine Übertragung an Dritte; einzige Gegenstelle ist die konfigurierte + Adresse + +--- + +## 8. Bekannte Grenzen + +| Punkt | Bewertung | +|---|---| +| Token im Klartext in einer Datei | üblich, aber nicht ideal | +| Parser-Lücken in LibreOffice | nicht abdeckbar, aktuell halten | +| Kompromittierte Paperless-Instanz | liefert Dateien; Makros bleiben gesperrt, Parser-Lücken bleiben | +| Kein Schutz gegen einen Angreifer mit Zugriff auf das eigene Benutzerkonto | systembedingt | +| `chmod` unter Windows weitgehend wirkungslos | dort greift die Zugriffssteuerung des Benutzerprofils | + +--- + +## Empfehlungen für den Betrieb + +1. Eigenen Token für die Erweiterung, einzeln widerrufbar +2. Zertifikatsprüfung eingeschaltet lassen +3. LibreOffice aktuell halten — dort liegt das verbleibende Risiko +4. Paperless nie ohne Reverse Proxy mit TLS aus dem Internet erreichbar + machen +5. In Paperless Zwei-Faktor für die Weboberfläche aktivieren; der Token + umgeht das für die Schnittstelle, deshalb Punkt 1 diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..ee182b2 --- /dev/null +++ b/build.sh @@ -0,0 +1,71 @@ +#!/bin/sh +# Baut die Erweiterung zu einer .oxt-Datei. +# +# Eine .oxt ist ein ZIP mit fester Struktur. Wichtig: aus dem +# Wurzelverzeichnis heraus packen, nicht das Verzeichnis selbst +# einpacken - sonst findet LibreOffice die META-INF/manifest.xml nicht. +set -eu +cd "$(dirname "$0")" + +NAME="paperless-libreoffice" +VERSION="$(sed -n 's/.*&2 + printf "\nWahrscheinlich wurden beim Kopieren die Unterverzeichnisse\n" >&2 + printf "ausgelassen. Benoetigt werden META-INF/, pythonpath/ und icons/.\n" >&2 + exit 1 +fi + +rm -f "$OUT" +find . -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true + +# Lizenztext an die Stelle kopieren, an der LibreOffice ihn erwartet. +# Im Wurzelverzeichnis ohne Endung wird er beim Installieren nicht +# gefunden ("NOT_EXISTING"), obwohl er im Paket liegt. +mkdir -p registration +cp -f LICENSE registration/license-en.txt + +zip -r -q "$OUT" \ + META-INF \ + description.xml description-de.txt description-en.txt \ + description-fr.txt LICENSE SICHERHEIT.md registration \ + Addons.xcu ProtocolHandler.xcu \ + paperless_addon.py \ + pythonpath \ + icons \ + -x "*.pyc" "*/__pycache__/*" "*.oxt" "build.sh" "README.md" "make_icons.py" + +# Ergebnis gegenprüfen: Anzahl der Einträge und Vorhandensein des +# Manifests. Ohne META-INF/manifest.xml ist es aus Sicht von LibreOffice +# kein Erweiterungspaket. +ANZ=$(unzip -l "$OUT" | grep -c ' ' || true) +if ! unzip -l "$OUT" | grep -q 'META-INF/manifest.xml'; then + echo "ABBRUCH: META-INF/manifest.xml fehlt im erzeugten Paket." >&2 + exit 1 +fi + +echo "Erstellt: $OUT ($(wc -c < "$OUT") Bytes)" +unzip -l "$OUT" | tail -2 | head -1 +echo +echo "Installieren:" +echo " unopkg add --force $OUT" +echo "Entfernen:" +echo " unopkg remove org.example.paperless.libreoffice" +echo +echo "LibreOffice danach vollstaendig beenden, auch den Schnellstarter:" +echo " soffice --terminate_after_init # oder: pkill soffice" diff --git a/description-de.txt b/description-de.txt new file mode 100644 index 0000000..fb019a1 --- /dev/null +++ b/description-de.txt @@ -0,0 +1,13 @@ +Öffnet Dokumente direkt aus einem Paperless-ngx-Archiv, legt Bearbeitungen +als neue Version ab, bearbeitet die Klassifizierung und erzeugt zeitlich +begrenzte Freigabelinks. + +Die Anbindung erfolgt bewusst über eigene Schaltflächen und nicht über den +normalen Speichern-Knopf. So bleibt jederzeit sichtbar, wann eine neue +Version im Archiv entsteht. + +Voraussetzung ist Paperless-ngx 3.0 oder neuer sowie ein API-Token, das im +eigenen Benutzerprofil erzeugt wird. + +Heruntergeladene Dateien werden ohne Makroausführung geöffnet. Einzelheiten +in SICHERHEIT.md. diff --git a/description-en.txt b/description-en.txt new file mode 100644 index 0000000..8d1cb16 --- /dev/null +++ b/description-en.txt @@ -0,0 +1,12 @@ +Opens documents directly from a Paperless-ngx archive, stores edits as a new +version, edits the classification and creates time-limited share links. + +The integration deliberately uses its own buttons rather than the regular +Save button. This keeps it visible at all times when a new version is +created in the archive. + +Requires Paperless-ngx 3.0 or newer and an API token created in your own +user profile. + +Downloaded files are opened with macro execution disabled. See SICHERHEIT.md +for details. diff --git a/description-fr.txt b/description-fr.txt new file mode 100644 index 0000000..dbe991b --- /dev/null +++ b/description-fr.txt @@ -0,0 +1,13 @@ +Ouvre des documents directement depuis une archive Paperless-ngx, enregistre +les modifications comme nouvelle version, modifie le classement et crée des +liens de partage à durée limitée. + +L'intégration utilise volontairement ses propres boutons plutôt que le +bouton Enregistrer habituel. On voit ainsi toujours clairement quand une +nouvelle version est créée dans l'archive. + +Nécessite Paperless-ngx 3.0 ou plus récent et un jeton d'API créé dans votre +profil utilisateur. + +Les fichiers téléchargés sont ouverts sans exécution de macros. Voir +SICHERHEIT.md pour les détails. diff --git a/description.xml b/description.xml new file mode 100644 index 0000000..66ac83d --- /dev/null +++ b/description.xml @@ -0,0 +1,75 @@ + + + + + + + + Paperless-Connector + Paperless-Connector + Paperless-Connector + + + + + Marco Morath + Marco Morath + Marco Morath + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/README.txt b/icons/README.txt new file mode 100644 index 0000000..e38897f --- /dev/null +++ b/icons/README.txt @@ -0,0 +1,16 @@ +Symbole der Erweiterung. + +Erzeugt werden sie mit ../make_icons.py aus einer gemeinsamen +Geometriebeschreibung - SVG und PNG koennen deshalb nicht auseinanderlaufen. + + paperless-NAME.svg wird von LibreOffice skaliert und passt sich + der eingestellten Symbolgroesse an + paperless-NAME_16.png Rueckfallebene, falls die SVG-Unterstuetzung + paperless-NAME_26.png in der eingesetzten Version nicht greift + paperless-NAME_32.png + +Registriert sind in Addons.xcu die SVG. Erscheint gar kein Symbol, dort auf +die PNG umstellen: + + ImageSmallURL -> %origin%/icons/paperless-NAME_16.png + ImageBigURL -> %origin%/icons/paperless-NAME_26.png diff --git a/icons/paperless-classify.svg b/icons/paperless-classify.svg new file mode 100644 index 0000000..e3ceab9 --- /dev/null +++ b/icons/paperless-classify.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/icons/paperless-classify_16.png b/icons/paperless-classify_16.png new file mode 100644 index 0000000000000000000000000000000000000000..0a951c549ee7299faf55a4471550672619a00072 GIT binary patch literal 887 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 YKUfNgvm>w5i~s-t07*qoM6N<$f@7-)(EtDd literal 0 HcmV?d00001 diff --git a/icons/paperless-logo.svg b/icons/paperless-logo.svg new file mode 100644 index 0000000..1685cae --- /dev/null +++ b/icons/paperless-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/icons/paperless-logo_16.png b/icons/paperless-logo_16.png new file mode 100644 index 0000000000000000000000000000000000000000..cca5463ab363443e4049b8cb414b57502b93f295 GIT binary patch literal 680 zcmV;Z0$2TsP)3n8ELC&Z+bL^ZO?NEBY!22<4m=TNV`o1OXHf zP*pPLKv4{emBcswt?v&<9?6Qsm0q>-)L0uNwS+WF5s`V|(1w%>MN}04L_ihI^k|?W z7p%2GZE~u4aC@&^+qn+!93q0MVyvb5v&!SQ&j?IF%b7X}B~;Oi0ZuKGd+<)Bc z>)Fe~7#bbsU3HLTWE_vjt8Y0}B7@)o5fR2~3C=s}jT+5ngGM@qSI>{JANZn@TNRPi zla`BCCO$xCo?wl^h(V_hfOn3r_D;@hKS8ljVrY1X`!64mFXT~0f*G*$j#5-)@Y6?5 z^`E7^6e9w^CdN3i=NOldUjpDm{DC{q?-CIK5X`AtlKLdOHt*!s^(TZj1cA&sI?C~^ z$i!4K-DBnxcy&Z=c|KP^bM4*@%7qfndje~zHKyq7SjpAXS1`s*C%1q=5Hgn$mtuBr z*-6wEfj~2B@^fO0$3Ynf1faQH_j?%_?tF~+q(fPPPtFInahf5@5VD#aa+rI(UI1VIYTA|?p O0000+C zff(ZP=y-DQ;Hh7v>*20>Axpp0Cfgszy@NS3X@JyBt*`mttt;-azPWjmTIwKzt#gmW z7-w~wAc&C?Y7p{JXl8D9=r*e%JOCO2F{=DMa)~`#U*XZU8>tmJR!!pp1guFpIe3y6 z-q}s~cyVN{sj02xc-QB7zH!Wj8M6_T_!oWMi9^uD+YN_}d_(!=sp_6ifi4 zWm43N8X*L#`51-E8)jZq2_Y~xUPV>uUfE4cM+@G0M9M>~rMiotsuLec>4Vf)=L>uc zEa~f_slAD!R+NI-8KqH!wdpjb7P2Ixt*MRFBp}eXx{ou*&M-VQj2gYdATt7uR7V-j z2T^Gx9%5usa|Z{W-3LT`@MLK-hkp2y?Qia2LF;@xer6!&>o0tK_)~VgwTq4gi>CGf ztT7D#GsKG_6(D;tZN_IZ>60J@1@RzyCh%hof#)GTw zqgLdEP&YxU1{ZyK7|J${)}>nCzmE0&>t>a>wW5kl7-ZH!lea&1`Zz~U{79B&rIo^{ zQaHzL%X(Sed-u#aSGE~K0N|@5U$gs-ooGi3!8b~x#uA1{*#5-xeDLyn*AFx)-KMpV zaOa9uB-U~@Ayq|1D4b*I4P6wjMpBvzGXPB{T&1%@rL!{27>!>)BR?VC7vCS^{m(z5 zy`>F5C7s}d=k;xSsC4$=T~SZYYt_Z_7FpNn+|YRrAN__!?F*;!NDvBNFqT)b#u9@^ zMe)uBseBk~ zVNR{Q%&}8#BH|=f8}j(7EZo}Lo{SeiX=ZK-xi3JJ|7FA|sj;CJF8adv*MGMLdhSva z4~WrQ=SZX>(jr0#@eg%8_1D2u-wJzCUL$lr-o4P7 zZD`e19LGPubN>JPFt?dzsk1q!(j7ju(Ag6N5h~b&qe6UuA@rgL=uKEc7ML%D2qf{1 z5JqTkCK?iDT9|=E1X57g3&Cn5DaF~;ncKbpbIz|9+jN_|Pi_9-a4+1;`F+p#_x(Ky z0NXL_AOL3OhWSs3>VxV-8bB3c0w#hEeQ{#+OzqY9F}q5@~iXV&7M!YkFgFb05jJ$KZMa!A||a#P#|z;Oe7K`A_UJf7Z3e@ zRF=QAJU)N9=d`43s}_8MNPH8xYL&NFM4J;UQE>WKwpJHM=X)JM55{kpKFcf%D-1>fAnc~zW6SQ%VUf!>DUKP zDoXRwsr~Hf+)J(05M(IkMd?BjT@h)}c&y}n2-Mfq(mZ?KkbU5LE&oic%$iAI{y5hd1!-(luCPP{27yCR4+%&fV<&Y#(>m zPse#z9e_AW@qXtnzW(tfbv1Q_;BTiH2B-A^-{l{k*kt3pve^PX>sL#d$|MmBj^DoZWH&5?5fy=C^sR{beE> zq2rV|0YXq(=H8F<304dua_f|+BV_>u_2e3JN7A> z15pIM{VZ)>!s@n_RgZ8L0dPKH+om_@ShbNjibpvyBIJ_-56oXe2)^pqmjs{;b~xt} z+H!5Q<=V=KHIE>pYW`pc4JDb0iVawiu^2IA(%CV?{rKZc9HqypAu(WxqVWqDKqN{S zd%HD;Yd5a*$KPEQ4W-3X(UFQX{Bx}rYwTDM3KC@2^G#0?o1^M9Ah!mTYEr5EVx*(P zpm<3c8w&o_D6;;jRNCmeYA_O-gnn(mbloP3CesIA31Chiw-RcYl0yIUQb=r?xlU zl}*c9MV1jXjjBvi3{(Uc;1@+dzjUbk!tlfY0~96*_-$`vwEzGB07*qoM6N<$g6JPH Ai2wiq literal 0 HcmV?d00001 diff --git a/icons/paperless-open.svg b/icons/paperless-open.svg new file mode 100644 index 0000000..a58fab3 --- /dev/null +++ b/icons/paperless-open.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/icons/paperless-open_16.png b/icons/paperless-open_16.png new file mode 100644 index 0000000000000000000000000000000000000000..d8af0b867343efa92641ff8f0044f1df255cd0ad GIT binary patch literal 885 zcmV-*1B(2KP)I+t|X+ok8m@26XB?Pd=j20qEf3PZHT50F!_Rie7=WHLQ6yt+mva;V+*4}$j zed^BF^RC@q$juvo1a3lL5D*H4XPaXUJJWq>8!1Q(K;vJioSj?O-nwD)#+Ei{<3TKX z7&eeX;ndhLPG3Az-@UtgTi?Mx8$A%%3vT`q_E4;5g;ZJ&&!MxH|D5_wot4M1%oo<@X0Llbyxk;~yhyXCbAm zWF!H%>>}CNjDs)lhZP8m>QKn!N3PEikw#CuV*Y`E4eHq5qx+eeJ^mlA{ar98&R;MS zfBkuqnNv$&2!tY-yf%ebKiZ9)or97Rf&}d10=7TYg;gsa#-1bRke-?&Dg21X@*Uls z+`-KK0G?M`ySOb}g{=>4LBYvGNd;zx>y;6%)Zvl)mt$Z5FESkT;n2WY zeP6$`rfECM{@bf~eG%0RW!%pp6GlyNFl&VEHRA&CC~(TpmT#3WA7`cRUzoG}T2(L2F|LLK=X! zf{~>XCE|W;ZM_Sh45md{8`^Pb7@O8C!v|koMpa0$Ab}m7Nm9|OnDyh>uq=JpC@l1=##KZ8XxZ5NbB#3zo8=Gs-d&#bH(pY4H{5o zC6ELhhsMS)^3gYsuRi>Bq-AhqnjKeDV@=q+*0n}&S{>g-EWJ^c`Fq400000 LNkvXXu0mjfUOS*A literal 0 HcmV?d00001 diff --git a/icons/paperless-open_26.png b/icons/paperless-open_26.png new file mode 100644 index 0000000000000000000000000000000000000000..226b0ff63494dbb27f603fc4058a5c66702055a2 GIT binary patch literal 1633 zcmV-n2A=teP)6NT8|pC1cjes|Z(ZOP%~@cQ zBu4B08F}$o6L93jQUANY?w%B;Q~T@h8?)fzo^z*f2sZA4&#n!Z43!`6+MdY&82MtS zOn7iwQnzZXj9fk8-}Q~7rUNh^I}nZrP)boOmq=Vfcd0-nD)pP?N{Ph9lqzM?snDnd zXRRG?!*G9Y#rPQ!YfaLMu%s9CRZCF=A*PPp^N9La{)Q3eCSxz2kQ7e=%o8|9;|^CQfVK z+8lk{Rkp`Vio_+H`OjGjr96QNKGk_%JgeugKxxx+^@T+L<>>3u($-p?tDR(_xzY>t zAtUc&j4^cNJ6OE&%baX!CY?$n(m#;VhC8E9* zePhcTe(Tn)A2QO~;JhO{D9g{j{R|z2s{}R}kdRV}*d@%KI0NrIT3b+#Y*iLx49{)Y zmVa);HULCKE2VsI=`L7;7w7^H=%&cNf##dg>DzDHMW7DZ?)x+d(Fi*%We} zNLp!1l`=PvxtWHk3%Pgms~kIbmD<5+l;S#r37|}zc-B92&^5PSwNE~D+i#*MnrA`d z#gj>A_}V=W-cXNKPwwaVxen@wWQY^<5_T}?=oc(hVf63^x}GugdqA&X{@BwtR@D4Y|9 z*QE6iUOX&?il5r2QAeSJrJGi8`obB~sSpwAX=Lw6r!wq&s);`xzJONX^*WG?**+Pc0xKZw6+)3D{r0=3kDIv8q4g7GpQevBMeeK)du1uAvd%ZYpQwY zY&&6K`hW`Mn0z^=s~F?F&{c}*EJkEA7Oi`96E7TTZdbNvNcJ?`#s?lBP@Aq|Ojj|& z=$;YgMYwZnj>HSYszZ#1Nh7l?Z5T~kXNhxd-GqSwrMTQx<|7(8?|Ron`MN9-Cl%5u zLv=d9i(s^(9683`FL6o zMapWXjH;m$dqg0c3HbTmlRWXlVT>_^LBQEdT`d3M0Y11=B(NG0Kr3#WI9&AgYdUdB zzZv(Q%kCJB^MY33ypXF&^V64(^Xwmw(a|08)t?-qwY|vTssIt89D8mWRweh&9jENI zvQ?$4u*PCD*#UK0cKbLM&T3}gKQEB0NfRfYq17SVn%?EthfeceOBaKy0-O`9)m&&V z`0WqhrL$@Mo=uWOGO3I^@Q*_-iXsx1eByDUm8QE~WbTwXJpbqt?*87(oNVbZwS&`G z4PJy(7xH9#nRpS-Un=--HO|nh?;0J)F%PM_#eP;6?{iBHui_YjZ$j28o7Mz6qi!-Qr?KQ;Q$Xv zk1+woa$a5T?3A{q_ovhh8outp+aH84|NXo=e!he5a!hVWhK5PCvTEK0nJ^-IzMLd$ fGePj0h}i!Eg9$tHr2_As00000NkvXXu0mjf1JEK- literal 0 HcmV?d00001 diff --git a/icons/paperless-open_32.png b/icons/paperless-open_32.png new file mode 100644 index 0000000000000000000000000000000000000000..323ab4b0d191d5538fe94ac8410e1df9092ab9dd GIT binary patch literal 2012 zcmV<22P622P)$LDa~h{i zoy+2UnJ;9->5|Qff56z>L}0wBb0QFixRrsFic)E%wx##woabeK+}jHs)V4dxFFEJr zob&sh@ALhAf4@i6e6gt(!!hQ%`Q zHsc(|7~FW?b`Hv2#@ExW< z+h6I$)W)i14NJ!Z!aMV#ytJJ1WD4gDViW**5*(M$T);(57pm9Z*NJm&}F~*=grG&*wAuD9y{i(X`{3UEhvuOBFekK4R z#JEFBNlxcTXVYY(tW2i|t+d~H6y)GE-{GBKWH$Nty3=gE~2^T3V4lB9yZkQ=UB&iV(|qD_dD z66-9+8hpKYbq0{&)=n2V!&vB!X;|V@7=o)*7Ug zdE8FWKX{7XzE9{4yVF7lQ(#&Roeu*bg&-H^XqhsVYc5^P|8s;}ubGlp_mJ3dyO=Ah%p~6SJG=aQ*$S^2U27s7}T6O{&l(1>g$oN+A#ediry;oL3fY zeDFHIa)R2oud8ceQ3JvW_tvkripJW;F|YsakMCgZ&JHG3BnY)Vt&7lLY(8-!cG>^~ zPtx6&W#P;k{mT7IJz%vw(^Rz41y?jf_|J++M`)v2|Mw1Fe*Gx5<-?O(=eW4Jg4WrS zxU6O3@Ji8^s#Kh9?{;}>UOQ-kJM$teP&=4SbM)g*jE&9`;Sj*ogcQ}~)vSKCowzSC zXJ|zVK{hfx{FS*}I<1zYCqAWh&>y&B)-v|J-$NqiVeRne1dxj?);V&aE#5nmp?IJ;a^e*4 z{HG@mKnOt?g*4REaqHE$pw!tVD3D~rEDL7LW6z;((m6wA;A08^Yyq$>6v~kB9AKP7 z6c`#}DvS(!-~Z$cE6%35eCB*^Ze2dA_%^)Vj&q_&t8F>rob@oK0q#7|ir4R_3CMEIxeB*HrVu9k|hy6Ud?p>x%E~g~! zV~i#6J(?$_cy!$X4u05AEKo&%6GAX;N(wn1-_QG#V|HUjvHXO{Psn8@J}>R;;I3!i zL`sDPlv3Qa`b}Qi*+E%}Ulgo1`RC`iRJpT8GVAP!;c(=lkc!fPC6}DXKqf-T{3ncc z)Rf10e#>E2y||Z{AF%4hy*$79Fg4}zqF6m87|29iF{6f-DJd~*VvPqeq>#k?#Heh1 z_l8;g@vTlmZSbVTI)^rnie$hue`)8?@c}mN{fLTWfHtnE7i+wfa(x%S)F&GfXkt0u(b8&q;;c@s*04G<1DYFKAQ;0I#cKX-^s zm=oV`yHQ*{w_dz|e1N0fr%2~C+PXZG+L8%1#yKu-n#fZt7K&T1oGP`pKl6Qck8{ow zjW;xwXFcYOuv + + + + + + + + + + + + + + + + diff --git a/icons/paperless-saveas_16.png b/icons/paperless-saveas_16.png new file mode 100644 index 0000000000000000000000000000000000000000..4bd7de627dbd6c8d36f5e7c63e247f8a8668105a GIT binary patch literal 899 zcmV-}1AP36P)Fu zKN-dvl=N}5{}_qHCEkB=C*O=5ru)>9Jpe-uLjpj#?o@7d{T=4DM_<-}W{m?|0#)`8mD!jxEx1uy4e`RoSBsje$wlBg`B>7+XHpOr_VK7YcGq(1b|k4e}HSL zB)h-dL&{Ad<#Y>2I21}n>K87dbIUHm!3d$qOq)5IOba1&OFQZZYXz)PUv%u>Wv#Vk z`#*S-<7S}mkQu-7`%!Cc>-7x+p(wxoagmokdWE!`MoEbf4qo16!(DAWT(^XggKv<# z__Odkf^2-&y40O*>uf215EXbvXu_1TaTMeApKzq zSW3RImRH7Ypl@GWaMdQQwAO@!VVde!lFMiDNrg6sITdpm-Te@mKNHNTUBRLa+Zp|A z3soE6l9ekTA=&?x&h_klLIr~n5?9A~w(|wjZW<{B!WX1-<7SHIPZ3&B$I|CNCt9@v z8L41$;A=uRRrA;BZ)IU(%qq{zQZuibx1ZWUv6M#&fm8vqqrD7z8T{cm*G}#wG;b$U zXS$jAsf%RK0koGQTGxa#eOn7aS(hmrO-Yv#vAH10fCQa4@O^{;>8*r6ZNq`sUjn)Wv++nqrO%`?sRK6fzh?mkQQ#gq^>3 zGipX`R`|zXPsqZ)qSU60<2VRwDQeBr&CgMt8e^>Q5boF@Dip&S4W>-vrjJE*S@Sj( zjl_=iobAmYIsUytOjklk@I29fe#myed7x2Neb6v*<~yssvgX-&-w{H* Z`#0&Dmj&+x_uK#g002ovPDHLkV1k6YvJ3zK literal 0 HcmV?d00001 diff --git a/icons/paperless-saveas_26.png b/icons/paperless-saveas_26.png new file mode 100644 index 0000000000000000000000000000000000000000..3c305a18d4fec6e542372411455827b167202357 GIT binary patch literal 1676 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 + + + + + + + + + + + + + + + + + + + diff --git a/icons/paperless-settings_16.png b/icons/paperless-settings_16.png new file mode 100644 index 0000000000000000000000000000000000000000..75fab42ad7250de26579e42a56d322e66a9bb0de GIT binary patch literal 908 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 VCG`WxJ#+v7002ovPDHLkV1mJ)N~Zt- literal 0 HcmV?d00001 diff --git a/icons/paperless-share.svg b/icons/paperless-share.svg new file mode 100644 index 0000000..0b18cdd --- /dev/null +++ b/icons/paperless-share.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/icons/paperless-share_16.png b/icons/paperless-share_16.png new file mode 100644 index 0000000000000000000000000000000000000000..8bb01366e3acd91c902b1eaea463f1016f37433c GIT binary patch literal 883 zcmV-(1C0EMP)LiP6D`pg>ZQck1jL5=;6rH@qO?tbnK^SgXZG1X45h{gSF-Xy{VOZ$ zU%#m8xbLN;>olhlOFjt6Z3qkkLV@tDjj_9*y*zl?p#zIiH2;E1IEk&zjXTe?oDlsag8zhzZ@AK^<-Pb zRCcVNsBN!}uKHVnL`a!)a}tnXR^}Z>?69hh)#TTeS8Q{X8LR57E2kH6TSQrwg_Cud z{C$d4CWW%psyW~HsfboE^Wi0x&ZNE8lkHotUccI8UNh&#Z$dDNR>j2uZZ6;A-OmPC zc9yX$tKd;cT+gMhdLyTg^5hTSx6yqvS~VZ?qk!Y?Y#KK=SCuVHV)Zc>>f%2F6(aPuC4Xbtg!0TvR=go=tt=CYh>+oc!N zY4ghX_`&{`7Ok|_*g>1d2ey#T7DDuWpLJ!G?D^m$<`(`Y6p5gXq4%-J>1uey0;ufi z(GSLAPpM!q%pW)Yv{d@S8=^8LQ0CPVkTy15yq!yS1ZLu0Y1L;GtUg2T}iG8+YWKV zaZrWY8UXS-k2Dq%k9$f>aJA;>$SBoiQEH-5#=4(pa&8_cm*buBaW<@7D->G$b|9ny zZ3iQ(C8~{;)48RQ_rDpF!DyMtx*ktH{1C%kT>xfydYZ!x4MLTyiP_&@xoG7+aV_oV zk+SeiUOvy!=KahkxAXPPuaw#$My98!j+O#&qODD|*43R5RR=b7Cv(Ydsih?!L19~1 zkjaQATH4J}&$;@yE?n6B<;;vpd!8t>ZU2RMTpy^fe?th-_YVT1gSYcmvqt~`002ov JPDHLkV1fszu=oG~ literal 0 HcmV?d00001 diff --git a/icons/paperless-share_26.png b/icons/paperless-share_26.png new file mode 100644 index 0000000000000000000000000000000000000000..0f81e5ba128a16d4f014c80c75acbb988ae93dd0 GIT binary patch literal 1694 zcmV;P24VS$P)G(`DjM&&Q@e7Rw2swE2{3?U^e`ZVMOaZt<#+WQ=)A}Z6s?5ZB1GjwHS3+Y z`)n>VK+ewt0X&#UiWVj-G02|lZkj>w3YPNl;ocPc52khy^xZMOzJ~kczh?T7Olkh3;4c|1rdfOte-hs zK5aMYH#WavT3T8@MkJ*oG$F-B#r*D(U(uhrhNGRE0un;thXHdY)e)KysWb!u#qk76 zDV}TImVT~z8vxc?DTFXXt&>=5i8vANsawGR6Rvi}>p$IReLr7OR#LSG*t@y4^$$V_ z&swX55aF;Gj4@<#8FGazTItb|1U`Acz~{=xAf@K*3l}(e>NFia-4sG2#+H`43u~t| zHqMyQxOv5j$C|U*?+YQ`veqhXjE`2DYw2q=Ji3%~SI&QmBZ0tT5km6(qrV{*FK7MB zyLh$z6kZtONQn>v&-Z!$=#g+;B4M{SHqP9*V8IdJ_g4rZ_Gu{<#u(y}I2*q9J^K24 zakYybL6Q`bte2;D{1obEEac0-eunn0E-DgU)vUUm!xQ{btAfA-8;(eW7kC6=z}~htUhC+fs<@c{WHMwupS^dE z%$YQc>e4DAj*A@{Z9nj-Dyu|0G1|L6Knuav2bQt+i+7PNL1*cn^ZneUnWCkNeSPYJC{P>5p$z-T<)ZP z+s}q}U>Sii7;A|-n!s435cFm;+`v(2!XcZ;kH{Q;;1P8kZi~mcno4o-bO#abf(@Bg zeLL@Le1wK6Q|L~m81Ov2ARv`5prpiDOGP4aqfsfT&}uM8MrbXrQcSI`=DjOdSpUi% z{&eOn-RTs40|Wfz{`)z)VFSBQ94GF$j4LandH(^9b#|hpWZwAkR*$slAPfk?PrGqw z3{ps%>gssu^OD|M=695EZY8Cme$s?>zk`7jSt$)_JAGih5Ck^n#=^gxd@J<4!Fl=QG!|#=?%if>LxWs1d$#Yxw?$=R&3Y%{KARo7Zi$U*@{)Z9QLGQdGA7@R>7_J#B5Gy}O$M&tpt7$(L$s?CQC5 o?Zoo(f9Hc>UCeO~TWj@y0gi7cqK#W;=Kufz07*qoM6N<$f-`GD_5c6? literal 0 HcmV?d00001 diff --git a/icons/paperless-share_32.png b/icons/paperless-share_32.png new file mode 100644 index 0000000000000000000000000000000000000000..959b8f8903a0a46ec4422a2cd25d52b45c3aa907 GIT binary patch literal 2142 zcmV-k2%-0hP)a|m$YlsxVIoi%U@5)4rB9#RIcI;gC3H~R?j+yjef~K4 zeb4WFzu)(FMB#1aMJO6D7%327G1eud*2b2SzHs;H=3}kYW3z>baV$~TRJK4m_9l!$ zDTNu&Zey@xF@)yRFo>@2*wD6Tyd-QXWn@C5F~(%4X9-JM7z`jrEiQzhr>{r*CMt3q zXJh_NW!H8zx9uJe3EOB(AeEFtn)NH!iSkJmM9N1B`I#HFHdvO;!4rq%ovZIvDKC|4 z$E`Q=Zz^jX4+;Cij|rI*n2^lI7=;*xz(5lWSJYh2tn%sBo4Yov?5ylO%VA^T&84@t zZ#cejEF^|iiJ}N&3}F-!jR9d4qP0dTg=Jfo&}b=Sj&$t57Bo${mMuio4?pB*5)eX+ z8&XRA$j9|uywH=b+ox5e;loY`HXnq2g`rC)j`F!vPVFX2ZcN3!;#+!V>08osHHSV>DBX zr&3*7CEE74nCwKBJE{vJt;tIy`RD#^ta$Qah}&Ss5J}R4o`4&_w3y~c*AuA#DJ9xy zlvc!STR4_u8)K}wOO}Y;TSn-BB_*!!Qdd2f-~8ZN1O~+D9gqmT&}Uj{6*?`~|_y0s5J=12S ziPbgKHP+w%!V5cu5Z)jWDTL4fqNu5I9#XYPqe#JT0Z`mXSZ)>XImTXy}fv0h$RID$xK~WR>so$`f1mcmp^4-ahL1fEre)Wzka1p&*Zz!%{;ej7g}r59S2*= zfuI7;`98kuf>L_X+_~!Id+v#4NO`2&^XBKIr5zPTO_lRBVM{WRG{0QBM!a`$4^oaS zKq(~WygqKb<}1vsoXvu#*0SyJC*)^lA^>F!#u%g!^oJo;1^IlZz8-;Ko$~9l;z~G z^y;fnI-)-aNG8(R@curUo_iikN^;WD&KDFyptMFCgEc79lc^NLL>PntN-Mm;B?!HX z;QB5GL&CNZLf{4govw=|Z5-P|2*Her6S-k}EskZ;<9TRf==FV4o_Enf;8 zMA*_oAgIX8BOZ&9@_im^eud-RU1%fN_)#mCl)UnddwKnv_wj}Da@-(bPDwFyN{Tt> z2N&dtpt_*ov*)#oLujCNvx|!&D@=8`TEM)W2 zrR+HR37;!2#)&!n{H?cG`S&+ScO0@339gzl#auL!X`@Gk!Qh8Jx#?*vs;Qydb;(If zqsQ|&bmk1{j)T&gpKjXB)YYr`{f-@6KBZ#d{#C^H=g+6Ew3O~tiu%e*rWF+ppS=@K z94RF6SQ;@qeo4e*aX_$a!F&pmN&3SO$F@ntV(7sLo|u{BRH~P?TelAWeHoP2fEWlY zgnp#45#T?=uHf*=BScCQs$j&2i5Q%qs!OYQ;^v#VW8FG(GBU8GBr*eUDqksb)6+S8 z_AJlu+`-*-mjm#}_jb~9-~;pc?YHS!lO{>m_m_&Irpfb=@~xpMVYDZil#*Vzmv7y0 zA8YP=jA!0?$2_!QgHXnZ>_h?~#Q6}@X5i!ttExEX`@GrOYJPbA^?FrfqZMiWSj@5> zv63|t%CY3v0AVnrLK`idm?Qr2{#MZ+cw*U?zbd{sy;dCVIK$D-F8YE1r8P=xBBcnm z=D^95q{rj@>8`uP*X!zJ5Jf+8Yw^r%sXT? + + + + + + + + + + + + + diff --git a/icons/paperless-version_16.png b/icons/paperless-version_16.png new file mode 100644 index 0000000000000000000000000000000000000000..9f0cfa5264759b434c3254497dd2d932c6cd34c3 GIT binary patch literal 889 zcmV-<1BU#GP)z5&O1{YU>px%ATYoTMh!908=qd*2&Dt? z84pbwf=l#Vzlb0DGRf`xwr}Y;-r=GR90#EvTBPOYhfg0>MC22Z`mZ|Ab(Q%>n`&B8 znfm7XIkWj@;9#7W{1OMi0h*Z)Wm^$hUsV?*s^e>2NoSJVmQ>8akydyFxaZi75e#DI13i-V_*5D^9RKY0J#)>kff z>%Z;{eNRL!vn_;88^d@0z#H%HgX21oLVz(2ujrzA{Z?#QxCjHC`%xI_V<7;A>f{FJ zsTVd7Q73?xq*6X&ktm*B`3&-2Zu$nKQ3?V)9;v{!qfKxpM!`cS$})s}c3k;pf@-~~ zt+7pqlvGN=GAyiG`Xma)JSv2Q(i(}mH5fec9CCk;qOxHH9&K*L;K@Bmq<4u0+m2zl zYrj&NPhOOU5y9yA7+!9F4UX$TFa}m(p#AyX@NfMDbN)gse)S~mx)qSo1pfT$5GMP+ zM(+2Fa7X${dR`t2=B4n?j!{Aw;lw!2?(%c?H4d~YA=+Rg)pN~evvrmlw7|XvNBp_UATTx zls@x?(g8T>~c5Uv8#|~6r>fP11#V5A+wh>z9sEQ^xTjAmx|x@ ze5=8xR{|4&=ds>fH|R|3>E)vC*s{rA&XV#RX2esvZqr_6Ki2dvW9+^A2t1sN5zf1* P00000NkvXXu0mjfFleNN literal 0 HcmV?d00001 diff --git a/icons/paperless-version_26.png b/icons/paperless-version_26.png new file mode 100644 index 0000000000000000000000000000000000000000..1b67595b62b71bb336c8b1952b92681f491ab79e GIT binary patch literal 1635 zcmV-p2AuhcP)v9)Q-!yqC!3dl)y?&pG?c zx4v($wN*p=xLG#Tw`vK*8l%7in&HEPQi2jy?k_!muIs~NbYR$q<0_#V?`T;Qq{B_c z`~bzM1Smj8`W!?E!hp0*y+>JYKDXn6AnFYwT!RvN7}TB{4sVNwmgbKY}4`w5@qdQH#y9@jU}->8ke+St)@ z1D*Kcz=o(QrO--~N{qUifw-o=ktgqejC>(a5GKsQ_uqC~cmKA@ivB~}qPA(>o!yno zU1bNoq=;S2@sCcBFXjkLa7E{N@hqFa6s67J(${1C*L}O>RL|+EriMuty2}@UE;8~y z!Wcteu8&*Rf19JHx=DsfM1}`4+K}(>+fqo?^LxHFkI=aYUt3rnzA(RvY?{zCrJSofOX;M#p89tREBRr_O7yy7Bh* zX&oCLtQ6L*RZ6`sBF1_jVT~n|%kZ@y-$M84_dlbNLg5jNQatnEGb~D{IQ;AHli9Ze zaS_S{2p}%z(?o;s8Lk1n8a@*qKNGvXbHTyUTu-kD_M1dhM*lpFZ?m>s|JsTV4h%1stD zUOcH}id8q?byZ~!J#ia_Q*RU2kHwX8m}E65g)8=>!!)j#Vf@OC*s6N-@3m8X{p5Lx z+Szw*9Xi3EmMV@bAnua%xTr+rVtV$zOy65O32U42Q4t?kXj{F7N#A`QaWN8CnEuF{ zR85#os;P}xkG`Sbdttp$$z4CBQD44~#Sbs#*xBPG!vrFNLgBn46(;C=v0xAptg*~&`x4D{ zO(cSF$h>hJ(NNz+jWL`#c@P~W@llD1tA0uIyt|2`5{S?=_d6h-cQ>vCrO-i=(|`II zDom08;6KU^Hc3Zyx|;PX9vV>_SI80-`cZ&M*3f&Pi|jkQXkGOSOtOaV=e|cJbBgpA zS~>fdji5B)=&?lk3{kGDNM48O8-YlTq3i-i=b`{KCYle z$alX<{`gx2wGG&G-Qb@t9-PQk4gcTHjW$?wd0Lb>v<*p*y&j3mpe;64i%r#FQ?(ea zG3i=Nx*C*5yc@JV3Y4O%b&lvu$8_xCE2N`-&K*M=2!fBwWZ&6E_UJ!w#eUSK{aR^! zR3aRGoiyFJMA=WvmQJRz#$r>|BkI&RyPb2>7m+>q3SsRvxI&gAPj?Is&LDxZ2@pSM z25l(zXZ@6WHfa(yM{FF&k_uDq@B3eOl}d%!#hHK002ovPDHLkV1gCw8rlE= literal 0 HcmV?d00001 diff --git a/icons/paperless-version_32.png b/icons/paperless-version_32.png new file mode 100644 index 0000000000000000000000000000000000000000..6c51b23a4e34a547b1c941163d3c57501a69d5a3 GIT binary patch literal 2060 zcmV+n2=n)eP)dHHc6db;@_>+g$<00YoV|DdxCsvdlXTClnS18W zIqTbNf9u=dlIj~8YcO1f!zn>PGu|c2TIVUnQ25sVwR`@}Ts%67n81?i#r5-5%v+Ch z7-Mh~dD}TW-$yJxVLn>Wzoz@`iGuKyQjrUt#W|NMDif_V4hP7X$wh?0p+OtCs3sPR zt(&x{{__5{-EU0L%1cr>XOJ-f6pG+DzwJC`HO|z3+q%J|%2HK2#=7cj>z4Me z`DopEKpdAPiXxnIgi%N|jtHX=Yc0kYw5PRLtP)kBV%{GoFFyTpI*FKj{3bsWfQU>u zlu`syKsJ{p7v@wpJ7i5{;-05oo_t;H6?77DKWbb6+(;)FJN}4FMBU_C&S+?3M(uRY zIK5SsB};8C$i+QBzP5UC{d~TUW68<;+dDRS%#We9M(d)9G$TL1<7vV$z~NBJW7qy& zYSDd5!@l8z2~T;8ferM|73e%UfYIdLy?gm!*S`uR9IFjSjw~OA)K}MX_Vn3UYjMsp zecE(p)U`oAy>8AWbD#Uc?Qe)kZUl%D5epzSi<{@6%tnQBn}4!S8mBZ(C~**EQA*+a2>?tk zL(sp61P)bH29$@Ja2(o)K`PAdZkwYfXRpAO|vsur@+Y*}4v;G@0QHRizc2 z*)$7)!ELY7)Ax^3Yk(}%bEvY!~}=g`TQO9ZC(Y|pkhUc_VAOX;2btL zs0QBsGY7W3WUJ0uWZJ&IYF@IU;??2dflDM=v0G{uH_x+#8%q);?#CUE%9ibKqtvMh zs6;W88{)=`mvC0oOx}O=ax&XD;g?m9+@KC{IBQ2u1EPI&yol)V0pgW);p}_lB(Hz;yr^7@6k%{Qr@&^vQ{sMmLv_f*_ zkx%zBb-}l&{=(M?2YUfWtb8i93-2U3r4^khqv1;{2nP0f`_}!`0=E@Nc%-yh_VjdP zYl7pD^7^&AjC_T>Ajlgyau>O#f>yk!rh`#v2~P z+6a@+QFry-LgBL3Tgm7>_6=yG``R1oEQGh6q>C2xZfAAC1+dA3)>@pgze}s5tEjxbQLFT`k zi86hpcf4KzAR^?$JoVMJEW7kZv_7!~1x1k0a$ei#7}~i77YreZ$(U@Ks`Hl8+VQ&) zPZ8iuh?gj3*8Q*1^~k04bpDp&`q{)MHPH9k^H2~1hIVZ|Mv8M;&Tl)HrSq1INxpsW z>?z2#btpfMUs}cP-+zZ>-I+|j;3fciHm@T0aToDP4a6&|_-M@o1z^SHAPO7i$RFBw z^g14`g0Y4$I=LrwHX;@;M)`@Ord2e(n|?(&n5BB|^;BQD41j@m|3q)+YW(sNR4j># z7b7A#YmkvP7cYLiT@n$r(paTW4ge5@M0FG5`A0g#D=IH2WTF&j3wMo5l;Wq7=tL>b zgavJIN56w8l2ckx6Vd%qqgq_oPAqjAHp;`uuAB)GQ7ENRN+Y5QiJd+1LngvYmQmV# zfjhyHS!YijIKprkT~bNKtS@46X+#yggD9Uc(@T2C8>Dxe5sRZl5%-g0R%6?h)q< zC~fN(-KT($j{~sdlU+o{5}N#H4kpeTrq|7&@yk!J{h6EaOR5m1k0c(QoQ~c7=>Ne$ zk8A$Q6Sk-oM5|i>akw$VIx8_hCL7=XhXnay zS$ORoQq_K?4EO9L=--VAvS1^eGdPpS=7+%GeqpOSUFMe3x(l~PkFzry+1`c(tlkDCPiLz;QqL`woZKTea q?Tx3g0000', + '' % (BOX, BOX)] + + def attrs(fill, stroke, width): + a = ' fill="%s"' % (fill or "none") + if stroke: + a += (' stroke="%s" stroke-width="%g" stroke-linecap="round"' + ' stroke-linejoin="round"' % (stroke, width)) + return a + + for kind, data, fill, stroke, width in shapes: + if kind == "poly": + pts = " ".join("%g,%g" % p for p in data) + out.append(' ' + % (pts, attrs(fill, stroke, width))) + elif kind == "circle": + cx, cy, r = data + out.append(' ' + % (cx, cy, r, attrs(fill, stroke, width))) + elif kind == "line": + (x1, y1), (x2, y2) = data + out.append(' ' + % (x1, y1, x2, y2, attrs(None, stroke, width))) + elif kind == "rrect": + x0, y0, x1, y1, r = data + out.append(' ' + % (x0, y0, x1 - x0, y1 - y0, r, + attrs(fill, stroke, width))) + out.append("") + return "\n".join(out) + "\n" + + +# ----------------------------------------------------------- Ausgabe PNG + +def to_png(shapes, size): + from PIL import Image, ImageDraw + + def rgb(c): + c = c.lstrip("#") + return tuple(int(c[i:i + 2], 16) for i in (0, 2, 4)) + (255,) + + s = SCALE + img = Image.new("RGBA", (int(BOX * s), int(BOX * s)), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + + for kind, data, fill, stroke, width in shapes: + f = rgb(fill) if fill else None + st = rgb(stroke) if stroke else None + w = max(1, int(width * s)) + if kind == "poly": + pts = [(x * s, y * s) for x, y in data] + d.polygon(pts, fill=f, outline=st, width=w if st else 0) + elif kind == "circle": + cx, cy, r = data + box = [(cx - r) * s, (cy - r) * s, (cx + r) * s, (cy + r) * s] + if f: + d.ellipse(box, fill=f) + if st: + d.ellipse(box, outline=st, width=w) + elif kind == "line": + (x1, y1), (x2, y2) = data + d.line([(x1 * s, y1 * s), (x2 * s, y2 * s)], fill=st, width=w) + elif kind == "rrect": + x0, y0, x1, y1, r = data + d.rounded_rectangle([x0 * s, y0 * s, x1 * s, y1 * s], + radius=r * s, fill=f, + outline=st, width=w if st else 0) + + return img.resize((size, size), Image.LANCZOS) + + +def main(): + os.makedirs(OUT, exist_ok=True) + for name, fn in ICONS.items(): + shapes = fn() + with open(os.path.join(OUT, name + ".svg"), "w", + encoding="utf-8") as fh: + fh.write(to_svg(shapes)) + for size in PNG_SIZES: + to_png(shapes, size).save( + os.path.join(OUT, "%s_%d.png" % (name, size)), + "PNG", optimize=True) + print("erzeugt: %s.svg und PNG in %s" + % (name, ", ".join("%d" % z for z in PNG_SIZES))) + + +if __name__ == "__main__": + main() diff --git a/paperless_addon.py b/paperless_addon.py new file mode 100644 index 0000000..c103191 --- /dev/null +++ b/paperless_addon.py @@ -0,0 +1,743 @@ +""" +paperless_addon.py - Einstiegspunkt der LibreOffice-Erweiterung. + +Registriert einen Protokoll-Handler für die URLs paperless.Command:*. +Die Symbolleiste in Addons.xcu ruft diese URLs auf. + +Warum kein Eingriff in den normalen Speichern-Knopf: Paperless als echtes +Speicherziel in die Datei-Dialoge einzuhaengen würde einen UCB Content +Provider erfordern - C++-UNO-Arbeit in einer sehr dünn dokumentierten +Ecke. Eigene Schaltflaechen sind außerdem die ehrlichere Lösung, weil +sichtbar bleibt, wann eine neue Version im Archiv entsteht. +""" + +import os +import sys +import traceback + +import uno +import unohelper +from com.sun.star.frame import XDispatchProvider, XDispatch +from com.sun.star.lang import XInitialization, XServiceInfo +from com.sun.star.beans import PropertyValue + +import paperless_config as cfg +import paperless_dialogs as dlg +import paperless_formats as fmt +from paperless_i18n import t +from paperless_client import PaperlessClient, PaperlessError + +IMPL_NAME = "de.mmorath.paperless.AddonImpl" +SERVICE_NAME = "com.sun.star.frame.ProtocolHandler" +PROTOCOL = "paperless.Command:" + +# Ladeeinstellungen für heruntergeladene Dateien. +# +# SICHERHEITSRELEVANT: Ein Dokument aus dem Archiv ist fremder Inhalt - +# auch dann, wenn es aus dem eigenen Bestand stammt, denn dort landen +# Dateien aus E-Mail-Anhängen, Scannerordnern und Freigaben. Ein ODT oder +# DOCX kann Makros enthalten, die beim Öffnen ausgeführt werden, und +# Verweise auf externe Inhalte, die beim Öffnen nachgeladen werden. +# +# MacroExecMode 1 = NEVER_EXECUTE: Makros werden nicht ausgeführt, auch +# nicht nach Rückfrage. Der Anwender kann sie nicht aus +# Versehen freigeben. +# UpdateDocMode 0 = NO_UPDATE: keine Aktualisierung verknüpfter Inhalte, +# also kein Nachladen externer Adressen beim Öffnen. +MACRO_NEVER_EXECUTE = 1 +UPDATE_NO_UPDATE = 0 + + +def _prop(name, value): + p = PropertyValue() + p.Name, p.Value = name, value + return p + + +class PaperlessAddon(unohelper.Base, XDispatchProvider, XDispatch, + XInitialization, XServiceInfo): + + def __init__(self, ctx): + self.ctx = ctx + self.frame = None + self._desktop = None + + # ------------------------------------------------------ UNO-Gerüst + + def initialize(self, args): + if args: + self.frame = args[0] + + def queryDispatch(self, url, _target, _flags): + return self if url.Protocol == PROTOCOL else None + + def queryDispatches(self, requests): + return tuple(self.queryDispatch(r.FeatureURL, r.FrameName, r.SearchFlags) + for r in requests) + + def addStatusListener(self, listener, url): + pass + + def removeStatusListener(self, listener, url): + pass + + def getImplementationName(self): + return IMPL_NAME + + def supportsService(self, name): + return name == SERVICE_NAME + + def getSupportedServiceNames(self): + return (SERVICE_NAME,) + + # ---------------------------------------------------------- Dispatch + + def dispatch(self, url, _args): + command = url.Path + handler = { + "Open": self.cmd_open, + "SaveVersion": self.cmd_save_version, + "SaveAs": self.cmd_save_as, + "Classify": self.cmd_classify, + "Share": self.cmd_share, + "Settings": self.cmd_settings, + "Info": self.cmd_info, + }.get(command) + if handler is None: + return + try: + handler() + except PaperlessError as exc: + dlg.error(self.ctx, self.frame, str(exc)) + except Exception as exc: # noqa: BLE001 + # Der Ablaufbericht steht auf der Konsole, nicht im Dialog. Er + # kann Pfade und Werte enthalten, die niemanden etwas angehen, + # der zufällig auf den Bildschirm sieht. + sys.stderr.write("[paperless] %s\n%s\n" + % (command, traceback.format_exc())) + dlg.error(self.ctx, self.frame, + "Unerwarteter Fehler bei „%s“:\n\n%s\n\n" + "Einzelheiten stehen auf der Konsole " + "(LibreOffice aus dem Terminal starten)." + % (command, str(exc)[:300])) + + # ---------------------------------------------------------- Helfer + + def _desktop_obj(self): + if self._desktop is None: + self._desktop = self.ctx.ServiceManager.createInstanceWithContext( + "com.sun.star.frame.Desktop", self.ctx) + return self._desktop + + def _client(self, require=True): + s = cfg.load_settings() + if not cfg.settings_complete(s): + if require: + dlg.message(self.ctx, self.frame, + "Bitte zuerst Basisadresse und Token hinterlegen.") + self.cmd_settings() + s = cfg.load_settings() + if not cfg.settings_complete(s): + return None, s + return PaperlessClient(s["base_url"], s["token"], + s.get("verify_tls", True), + user_agent=s.get("user_agent") or None), s + + def _current_doc(self): + try: + return self._desktop_obj().getCurrentComponent() + except Exception: + return None + + def _current_doc_id(self, quiet=False): + doc = self._current_doc() + if doc is None: + if not quiet: + dlg.message(self.ctx, self.frame, "Kein Dokument geöffnet.") + return None, None + doc_id = cfg.get_document_id(doc) + if doc_id is None and not quiet: + dlg.message(self.ctx, self.frame, + "Dieses Dokument stammt nicht aus Paperless.\n\n" + "Es wurde keine Paperless-Kennung gefunden. Öffne es " + "über „Aus Paperless öffnen“, damit die " + "Zuordnung erhalten bleibt.") + return doc, doc_id + + # ---------------------------------------------------- Befehl: Öffnen + + def cmd_open(self): + client, settings = self._client() + if client is None: + return + + picked = dlg.open_dialog(self.ctx, self.frame, client) + if not picked: + return + full, version, readonly = picked + root_id = full["id"] + + # Heruntergeladen wird die gewählte Version. Jede Version ist ein + # eigener Dokumentdatensatz mit eigener Kennung. + data, remote_name = client.download(version["id"], original=True) + + # Der Name stammt aus einer Kopfzeile der Gegenstelle und ist damit + # nicht vertrauenswürdig. Ein Wert wie "../../.bashrc" darf nicht + # aus dem Arbeitsverzeichnis herausführen - deshalb vollständige + # Bereinigung, plattformunabhängig für beide Trennzeichen. + name = cfg.safe_filename( + remote_name or version.get("filename") + or full.get("original_file_name") + or ("paperless-%d.bin" % root_id), + vorgabe="paperless-%d.bin" % root_id) + + # Ablage im Benutzerprofil statt im allgemeinen Temp-Verzeichnis. + # Letzteres ist auf Mehrbenutzersystemen für alle beschreibbar; ein + # anderer Benutzer könnte dort unter dem erwarteten Namen einen + # symbolischen Verweis anlegen und das Schreiben umlenken. + path = os.path.join(cfg.work_dir(str(root_id)), name) + # Datei mit eingeschränkten Rechten anlegen. O_EXCL ist hier + # bewusst nicht gesetzt, weil dieselbe Version erneut geöffnet + # werden können soll - das Verzeichnis liegt aber im Benutzer- + # profil und ist für andere nicht beschreibbar. + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + with os.fdopen(fd, "wb") as fh: + fh.write(data) + except Exception: # noqa: BLE001 + raise + + url = unohelper.systemPathToFileUrl(path) + doc = self._desktop_obj().loadComponentFromURL( + url, "_blank", 0, + (_prop("ReadOnly", readonly), + _prop("MacroExecutionMode", MACRO_NEVER_EXECUTE), + _prop("UpdateDocMode", UPDATE_NO_UPDATE))) + + # Gemerkt wird immer die WURZEL-Kennung, nicht die der geöffneten + # Version. Eine neue Version wird ja an die Wurzel angehängt. + if doc is not None: + cfg.set_document_id(doc, root_id, settings["base_url"]) + cfg.remember(url, root_id, settings["base_url"]) + + if readonly: + dlg.message(self.ctx, self.frame, + "Schreibgeschützt geöffnet.\n\nZum Ablegen einer " + "neuen Version muss das Dokument bearbeitbar " + "geöffnet werden.") + + # -------------------------------------------- Befehl: Neue Version + + def cmd_save_version(self): + client, settings = self._client() + if client is None: + return + doc, doc_id = self._current_doc_id() + if doc_id is None: + return + + remote = client.document(doc_id) + title = remote.get("title") or str(doc_id) + + if not dlg.confirm( + self.ctx, self.frame, + "Als neue Version im Archiv ablegen?\n\n" + "Dokument: %s\nPaperless-ID: %d\n\n" + "Die bisherige Fassung bleibt als Version erhalten." + % (title[:70], doc_id)): + return + + path = self._store_local(doc) + if path is None: + return + + with open(path, "rb") as fh: + data = fh.read() + + from datetime import date + label = (settings.get("version_label") or "LibreOffice {datum}") \ + .replace("{datum}", date.today().isoformat()) + + task = client.update_version(doc_id, os.path.basename(path), data, + version_label=label) + if not isinstance(task, str) or not task: + dlg.error(self.ctx, self.frame, + t("Unerwartete Antwort beim Hochladen:\n%r") % (task,)) + return + + new_id = client.wait_for_task(task) + cfg.set_document_id(doc, doc_id, settings["base_url"]) + dlg.message(self.ctx, self.frame, + "Neue Version abgelegt.\n\n" + "Dokument: %s\nWurzel-ID: %d\nVersions-ID: %s\n" + "Bezeichnung: %s" % (title[:70], doc_id, new_id, label)) + + def _store_local(self, doc, vorschlag=None): + """ + Speichert das Dokument und liefert den Pfad auf der Platte. + + Drei Fälle: + + * über „Aus Paperless öffnen“ geladen - getURL() zeigt auf die + temporäre Datei, es genügt ein Speichern im Originalformat + * lokal vorhandene Datei - ebenso + * noch nie gespeichert (Unbenannt 1) - dann gibt es keinen URL. + Statt abzubrechen wird in dasselbe temporäre Verzeichnis + geschrieben, das auch beim Öffnen benutzt wird. + """ + url = doc.getURL() + + if not url: + ext, filt = fmt.default_for_document(doc) + name = cfg.safe_filename( + (vorschlag or self._doc_title(doc)), maxlen=72) + ext + path = os.path.join(cfg.work_dir("neu"), name) + try: + doc.storeToURL(unohelper.systemPathToFileUrl(path), + (_prop("FilterName", filt), + _prop("Overwrite", True))) + cfg.lock_down(path, 0o600) + except Exception as exc: # noqa: BLE001 + dlg.error(self.ctx, self.frame, + t("Das Dokument konnte nicht zwischengespeichert werden:\n%s") % exc) + return None + return path + + path = unohelper.fileUrlToSystemPath(url) + + # Bei unbekanntem Format gibt es keinen passenden Filter. Dann + # speichert LibreOffice im eigenen Format weiter - der Anwender + # sollte wissen, dass die Endung danach nicht mehr zum Inhalt passt. + if not fmt.is_known(path) and doc.isModified(): + if not dlg.confirm( + self.ctx, self.frame, + "Für dieses Format ist kein Speicherfilter hinterlegt " + "(%s).\n\nLibreOffice speichert im eigenen Format. Die " + "Dateiendung passt danach möglicherweise nicht mehr zum " + "Inhalt.\n\nTrotzdem fortfahren?" + % os.path.basename(path)): + return None + + try: + if doc.isModified(): + filt = fmt.store_filter(path) + if filt: + doc.storeToURL(url, (_prop("FilterName", filt), + _prop("Overwrite", True))) + else: + doc.store() + except Exception as exc: # noqa: BLE001 + dlg.error(self.ctx, self.frame, + t("Speichern fehlgeschlagen:\n%s") % exc) + return None + return path + + @staticmethod + def _doc_title(doc): + """Titel aus den Dokumenteigenschaften, sonst der Fenstertitel.""" + try: + t = doc.getDocumentProperties().Title + if t and t.strip(): + return t.strip() + except Exception: # noqa: BLE001 + pass + try: + return (doc.getTitle() or "Dokument").strip() + except Exception: # noqa: BLE001 + return "Dokument" + + # ------------------------------------- Befehl: Als neues Dokument + + def cmd_save_as(self): + """ + Legt die geöffnete Datei in Paperless ab - wahlweise als neues + Dokument oder als neue Version eines ANDEREN vorhandenen Dokuments. + + Der zweite Fall deckt eine Lücke, die Dokumentenverwaltungen gern + offenlassen: Die Rechnung kommt als PDF, die bearbeitbare Fassung + entsteht später. Beides gehört zusammen, aber das PDF war zuerst da + - mit „Als neue Version ablegen“ allein kommt man da nicht hin, + weil das immer auf das eigene Wurzeldokument zielt. + """ + client, settings = self._client() + if client is None: + return + doc, doc_id = self._current_doc_id(quiet=True) + if doc is None: + dlg.message(self.ctx, self.frame, "Kein Dokument geöffnet.") + return + + # Die Herkunft aus Paperless ist optional - auch eine fremde Datei + # kann so ins Archiv wandern. + source = {} + if doc_id is not None: + try: + source = client.document(doc_id) + except PaperlessError: + source = {} + if not source.get("title"): + url = doc.getURL() or "" + base = os.path.splitext(os.path.basename( + unohelper.fileUrlToSystemPath(url)))[0] if url else "Dokument" + source = {"title": base} + + choice = dlg.store_target_dialog(self.ctx, self.frame, client, source) + if choice is None: + return + + # Der gewählte Titel bestimmt bei einem noch nie gespeicherten + # Dokument auch den Namen der temporären Datei - und damit den + # Dateinamen, unter dem Paperless das Original ablegt. + vorschlag = choice.get("title") if choice["mode"] == "new" else \ + (choice.get("target") or {}).get("title") + path = self._store_local(doc, vorschlag) + if path is None: + return + with open(path, "rb") as fh: + data = fh.read() + + if choice["mode"] == "existing": + self._append_to_existing(client, settings, doc, choice, path, data) + else: + self._create_new(client, settings, doc, doc_id, source, choice, + path, data) + + # -- Ziel: bestehendes Dokument ----------------------------------------- + + def _append_to_existing(self, client, settings, doc, choice, path, data): + target = choice["target"] + target_id = target["id"] + + if not dlg.confirm( + self.ctx, self.frame, + "Die geöffnete Datei als neue Version anhängen?\n\n" + "Zieldokument: %s\nPaperless-ID: %d\n\n" + "Die bisherige Fassung des Ziels bleibt als Version " + "erhalten." % ((target.get("title") or "")[:70], target_id)): + return + + from datetime import date + label = (settings.get("version_label") or "LibreOffice {datum}") \ + .replace("{datum}", date.today().isoformat()) + + task = client.update_version(target_id, os.path.basename(path), data, + version_label=label) + if not isinstance(task, str) or not task: + dlg.error(self.ctx, self.frame, + t("Unerwartete Antwort beim Hochladen:\n%r") % (task,)) + return + new_id = client.wait_for_task(task) + + if choice.get("switch"): + # Ab jetzt zeigt das Fenster auf das Zieldokument. Weitere + # Versionen landen dort, nicht mehr beim Ursprung. + cfg.set_document_id(doc, target_id, settings["base_url"]) + url = doc.getURL() + if url: + cfg.remember(url, target_id, settings["base_url"]) + + hinweis = ("Das geöffnete Fenster ist jetzt dem Zieldokument " + "zugeordnet." if choice.get("switch") else + "Das geöffnete Fenster bleibt seiner bisherigen " + "Zuordnung erhalten.") + dlg.message(self.ctx, self.frame, + "Als neue Version angehängt.\n\n" + "Zieldokument: %s\nWurzel-ID: %d\nVersions-ID: %s\n" + "Bezeichnung: %s\n\n%s" + % ((target.get("title") or "")[:70], target_id, new_id, + label, hinweis)) + + # -- Ziel: neues Dokument ------------------------------------------------ + + def _create_new(self, client, settings, doc, doc_id, source, choice, + path, data): + title = choice["title"] + take_meta = choice.get("take_meta") + + fields = {"title": title[:127]} + if take_meta and doc_id is not None: + for key in ("correspondent", "document_type", "storage_path"): + if source.get(key): + fields[key] = source[key] + if source.get("created"): + fields["created"] = str(source["created"])[:10] + + task = client.post_document(os.path.basename(path), data, fields) + if not isinstance(task, str) or not task: + dlg.error(self.ctx, self.frame, + t("Unerwartete Antwort beim Hochladen:\n%r") % (task,)) + return + new_id = client.wait_for_task(task) + + # Tags und Zusatzfelder gehen nicht über post_document, sondern + # nachträglich per PATCH. Die Herkunftskennung wird dabei bewusst + # NICHT kopiert - sie gehört zum Ursprungsdokument. + if take_meta and doc_id is not None and new_id: + payload = {} + if source.get("tags"): + payload["tags"] = source["tags"] + if source.get("owner") is not None: + payload["owner"] = source["owner"] + cf = [f for f in (source.get("custom_fields") or []) + if not self._ist_herkunftsfeld(client, settings, f)] + if cf: + payload["custom_fields"] = cf + if payload: + try: + client.patch_document(new_id, payload) + except PaperlessError as exc: + dlg.message(self.ctx, self.frame, + "Dokument angelegt, aber die Klassifizierung " + "konnte nicht vollständig übernommen " + "werden:\n\n%s" % exc) + + if choice.get("switch") and new_id: + cfg.set_document_id(doc, new_id, settings["base_url"]) + url = doc.getURL() + if url: + cfg.remember(url, new_id, settings["base_url"]) + + hinweis = ("Das geöffnete Fenster ist jetzt diesem Dokument " + "zugeordnet." if choice.get("switch") else + "Das geöffnete Fenster bleibt dem Ursprungsdokument " + "zugeordnet.") + dlg.message(self.ctx, self.frame, + "Neues Dokument angelegt.\n\n" + "Titel: %s\nPaperless-ID: %s\n\n%s" + % (title[:70], new_id, hinweis)) + + # Ein frisch angelegtes Dokument trägt sonst nur den Titel. Ohne + # Klassifizierung findet es später niemand wieder, und nachträglich + # macht man es erfahrungsgemäß nicht mehr. Deshalb direkt anbieten. + if new_id: + self._classify(client, new_id) + + def _ist_herkunftsfeld(self, client, settings, field_entry): + """ + Zusatzfelder mit Herkunftsbezug nicht in Kopien übernehmen. + + Wer aus einem anderen System migriert hat, führt dort oft ein Feld + mit der Fremdkennung. Eine Kopie mit derselben Kennung wäre falsch - + es gäbe zwei Dokumente, die vorgeben, dasselbe Original zu sein. + + Welche Feldnamen das betrifft, steht in den Einstellungen unter + "herkunftsfelder". + """ + muster = [str(m).lower() for m in + (settings.get("herkunftsfelder") or [])] + if not muster: + return False + try: + if not hasattr(self, "_cf_names"): + self._cf_names = {f["id"]: f["name"] + for f in client.custom_fields()} + name = (self._cf_names.get(field_entry.get("field")) or "").lower() + except Exception: # noqa: BLE001 + return False + return any(m in name for m in muster) + + # ------------------------------------------- Befehl: Klassifizierung + + def cmd_classify(self): + client, _settings = self._client() + if client is None: + return + _doc, doc_id = self._current_doc_id() + if doc_id is None: + return + self._classify(client, doc_id) + + def _classify(self, client, doc_id): + """Klassifizierungsdialog für ein bestimmtes Dokument.""" + try: + remote = client.document(doc_id) + tags = client.tags() + corrs = client.correspondents() + dtypes = client.document_types() + except PaperlessError as exc: + dlg.error(self.ctx, self.frame, + t("Stammdaten konnten nicht geladen werden:\n%s") % exc) + return + + payload = dlg.classify_dialog(self.ctx, self.frame, remote, + tags, corrs, dtypes) + if payload is None: + return + + # Korrespondent und Dokumenttyp können als Freitext eingegeben + # werden. Unbekannte Werte werden nach Rückfrage angelegt. + payload["correspondent"] = self._resolve( + client, "correspondents/", corrs, + payload.pop("_correspondent_name", ""), "Korrespondent") + payload["document_type"] = self._resolve( + client, "document_types/", dtypes, + payload.pop("_document_type_name", ""), "Dokumenttyp") + + client.patch_document(doc_id, payload) + dlg.message(self.ctx, self.frame, "Klassifizierung gespeichert.") + + def _resolve(self, client, path, known, name, label): + name = (name or "").strip() + if not name: + return None + for row in known: + if row["name"].strip().casefold() == name.casefold(): + return row["id"] + if not dlg.confirm(self.ctx, self.frame, + "%s „%s“ existiert nicht.\n\nAnlegen?" + % (label, name)): + return None + return client.create(path, name)["id"] + + # ---------------------------------------------------- Befehl: Teilen + + def cmd_share(self): + """ + Erzeugt einen zeitlich begrenzten, unauthentifizierten Link. + + Vorher wird geprüft, ob der geteilte Stand überhaupt im Archiv + liegt. Ein Link auf eine Fassung, die nur lokal existiert, wäre + irreführend - der Empfänger bekäme etwas anderes zu sehen, als der + Absender vor sich hat. + """ + client, settings = self._client() + if client is None: + return + doc, doc_id = self._current_doc_id(quiet=True) + if doc is None: + dlg.message(self.ctx, self.frame, "Kein Dokument geöffnet.") + return + + # 1. Ist das Dokument überhaupt im Archiv? + if doc_id is None: + if dlg.confirm( + self.ctx, self.frame, + "Dieses Dokument liegt noch nicht in Paperless.\n\n" + "Ein Freigabelink kann erst erzeugt werden, wenn es " + "abgelegt ist.\n\nJetzt ablegen?"): + self.cmd_save_as() + return + + # 2. Gibt es lokale Änderungen, die noch nicht abgelegt sind? + try: + geaendert = bool(doc.isModified()) + except Exception: # noqa: BLE001 + geaendert = False + if geaendert: + antwort = dlg.choice( + self.ctx, self.frame, "Nicht abgelegte Änderungen", + "Das geöffnete Dokument enthält Änderungen, die noch nicht " + "in Paperless abgelegt sind.\n\nDer Link würde auf die " + "zuletzt abgelegte Fassung zeigen, nicht auf das, was du " + "gerade vor dir hast.", + ["Abbrechen", "Trotzdem teilen", "Erst neue Version ablegen"]) + if antwort in (None, 0): + return + if antwort == 2: + self.cmd_save_version() + try: + if doc.isModified(): + return # Ablegen wurde abgebrochen + except Exception: # noqa: BLE001 + pass + + bekannte = [] + try: + bekannte = sorted(t["name"] for t in client.tags()) + except PaperlessError: + pass + + auswahl = dlg.share_dialog(self.ctx, self.frame, + settings.get("share_days", 7), + settings.get("share_tag", "Sharelink"), + bekannte) + if auswahl is None: + return + days, variant, tagname = auswahl + + url, roh = client.share_link(doc_id, days, variant) + if not url: + dlg.error(self.ctx, self.frame, + "Der Link konnte nicht ermittelt werden.\n\n" + "Antwort: %r" % (roh,)) + return + + # 3. Kennzeichnung anfügen, ohne vorhandene Tags zu verlieren + tag_hinweis = "" + if tagname: + try: + tag_id = self._resolve(client, "tags/", client.tags(), + tagname, "Tag") + if tag_id: + aktuell = client.document(doc_id).get("tags") or [] + if tag_id not in aktuell: + client.patch_document( + doc_id, {"tags": list(aktuell) + [tag_id]}) + tag_hinweis = "\nTag „%s“ gesetzt." % tagname + # Für den nächsten Aufruf merken + settings["share_tag"] = tagname + cfg.save_settings(settings) + except PaperlessError as exc: + tag_hinweis = "\nTag konnte nicht gesetzt werden: %s" % exc + + gueltig = ("%d Tage" % days) if days else "unbegrenzt" + dlg.result_dialog( + self.ctx, self.frame, "Freigabelink erzeugt", + "Der Link ist ohne Anmeldung abrufbar.\nGültigkeit: %s%s" + % (gueltig, tag_hinweis), url) + + # ------------------------------------------ Befehl: Einstellungen + + def cmd_settings(self): + current = cfg.load_settings() + updated = dlg.settings_dialog(self.ctx, self.frame, current) + if updated is None: + return + cfg.save_settings(updated) + if not cfg.settings_complete(updated): + return + try: + PaperlessClient(updated["base_url"], updated["token"], + updated.get("verify_tls", True), + user_agent=updated.get("user_agent") or None).ping() + dlg.message(self.ctx, self.frame, + "Verbindung erfolgreich hergestellt.") + except PaperlessError as exc: + dlg.error(self.ctx, self.frame, + t("Gespeichert, aber die Verbindung schlug fehl:\n\n%s") % exc) + + # ------------------------------------------------------ Befehl: Info + + def cmd_info(self): + client, settings = self._client(require=False) + doc, doc_id = self._current_doc_id(quiet=True) + + lines = ["Basisadresse: %s" % (settings.get("base_url") or "—")] + if doc is None: + lines.append("Kein Dokument geöffnet.") + elif doc_id is None: + lines.append("Dieses Dokument stammt nicht aus Paperless.") + elif client is None: + lines.append("Paperless-ID: %d" % doc_id) + else: + try: + r = client.document(doc_id) + versions = r.get("versions") or [] + lines += [ + "Paperless-ID: %d" % doc_id, + "Titel: %s" % (r.get("title") or "—"), + "Archiv-Seriennummer: %s" + % (r.get("archive_serial_number") or "—"), + "Versionen: %d" % (len(versions) or 1), + "Erstellt: %s" % ((r.get("created") or "")[:10] or "—"), + ] + except PaperlessError as exc: + lines.append("Abruf fehlgeschlagen: %s" % exc) + + dlg.result_dialog(self.ctx, self.frame, "Paperless — Status", + "\n".join(lines)) + + +g_ImplementationHelper = unohelper.ImplementationHelper() +g_ImplementationHelper.addImplementation(PaperlessAddon, IMPL_NAME, + (SERVICE_NAME,)) diff --git a/pythonpath/paperless_client.py b/pythonpath/paperless_client.py new file mode 100644 index 0000000..6c28157 --- /dev/null +++ b/pythonpath/paperless_client.py @@ -0,0 +1,406 @@ +""" +paperless_client.py - REST-Client für Paperless-ngx. + +Bewusst OHNE requests: Die in LibreOffice mitgelieferte Python-Laufzeit +bringt nur die Standardbibliothek mit. Multipart-Uploads werden deshalb +von Hand kodiert. +""" + +import json +import mimetypes +import os +import ssl +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid + +TASK_TIMEOUT = 900 +TASK_POLL = 1.5 + +# Obergrenze für Antwortkörper. Ohne Begrenzung könnte eine fehlerhafte +# oder böswillige Gegenstelle den Arbeitsspeicher füllen, indem sie eine +# endlose Antwort liefert. +MAX_JSON = 32 * 1024 * 1024 # 32 MB für Metadaten +MAX_DOWNLOAD = 512 * 1024 * 1024 # 512 MB für Dokumente + +# Eigene Kennung statt der Vorgabe von urllib. +# +# urllib sendet standardmäßig "Python-urllib/3.x". Diese Kennung steht in +# den Sperrlisten gängiger Schutzsysteme (CrowdSec http-bad-user-agent, +# fail2ban, WAF-Regelwerke), weil sie typisch für Scanner ist. Folge: Der +# Zugriff über einen Reverse Proxy scheitert mit HTTP 403, während +# derselbe Aufruf direkt an die Anwendung funktioniert - ein schwer zu +# findendes Fehlerbild, zumal die Meldung von einem Dienst stammen kann, +# der mit dem Ziel nichts zu tun hat. +# +# Über den Parameter user_agent bei PaperlessClient anpassbar, falls eine +# Regel auch diesen Namen abweist. +USER_AGENT = "PaperlessLibreOffice/0.1 (+LibreOffice extension)" + + +class PaperlessError(Exception): + pass + + +class _SicherRedirect(urllib.request.HTTPRedirectHandler): + """ + Weiterleitungen nur nach http und https zulassen. + + Der Vorgabe-Handler von urllib erlaubt auch ftp. Eine böswillige oder + übernommene Gegenstelle könnte damit auf ein anderes Protokoll + umlenken. Für eine REST-Schnittstelle gibt es dafür keinen Grund. + """ + + def redirect_request(self, req, fp, code, msg, headers, newurl): + if urllib.parse.urlparse(newurl).scheme not in ("http", "https"): + raise PaperlessError( + "Weiterleitung auf ein nicht zugelassenes Protokoll " + "abgewiesen: %s" % newurl[:120]) + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +class PaperlessClient: + def __init__(self, base_url, token, verify_tls=True, timeout=60, + user_agent=None): + # Nur http und https zulassen. Ohne Prüfung liesse sich hier + # file:// oder ftp:// hinterlegen, und urllib würde das bedienen - + # aus einer Adresseingabe würde ein Dateizugriff. + parsed = urllib.parse.urlparse((base_url or "").strip()) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise PaperlessError( + "Ungültige Basisadresse. Erwartet wird http:// oder " + "https:// mit Rechnernamen, z. B. https://dms.example.org") + self.base = base_url.strip().rstrip("/") + self.token = (token or "").strip() + self.timeout = timeout + self.user_agent = user_agent or USER_AGENT + self._opener = urllib.request.build_opener(_SicherRedirect()) + self._ctx = None + if not verify_tls: + # Nur für Testinstanzen mit selbstsigniertem Zertifikat. + self._ctx = ssl.create_default_context() + self._ctx.check_hostname = False + self._ctx.verify_mode = ssl.CERT_NONE + + # ------------------------------------------------------------ intern + + def _open(self, req): + req.add_header("Authorization", "Token %s" % self.token) + req.add_header("User-Agent", self.user_agent) + req.add_header("Accept", "application/json, */*") + try: + return self._opener.open(req, timeout=self.timeout) \ + if self._ctx is None else \ + urllib.request.urlopen(req, timeout=self.timeout, + context=self._ctx) + except urllib.error.HTTPError as exc: + body = "" + try: + body = exc.read().decode("utf-8", "replace")[:400] + except Exception: + pass + # Die Adresse kann Suchbegriffe enthalten, aber niemals den + # Token - der steht im Kopfzeilenfeld. Trotzdem vorsichtshalber + # kürzen, damit lange Abfragen die Meldung nicht sprengen. + raise PaperlessError("HTTP %s bei %s: %s" + % (exc.code, req.full_url[:160], + body)) from exc + except urllib.error.URLError as exc: + raise PaperlessError("Verbindung fehlgeschlagen: %s" % exc.reason) from exc + + def _json(self, path, params=None, method="GET", payload=None): + url = "%s/api/%s" % (self.base, path.lstrip("/")) + if params: + url += "?" + urllib.parse.urlencode(params, doseq=True) + data = None + req = urllib.request.Request(url, method=method) + if payload is not None: + data = json.dumps(payload).encode("utf-8") + req.add_header("Content-Type", "application/json") + req.data = data + with self._open(req) as r: + raw = r.read(MAX_JSON + 1) + if len(raw) > MAX_JSON: + raise PaperlessError("Antwort überschreitet %d MB" + % (MAX_JSON // 1024 // 1024)) + return json.loads(raw.decode("utf-8")) if raw else None + + def _multipart(self, path, fields, filename, filedata): + """Multipart/form-data von Hand, weil requests nicht verfügbar ist.""" + boundary = "----paperless%s" % uuid.uuid4().hex + out = [] + for key, value in fields.items(): + if value is None: + continue + out.append(("--%s\r\n" + "Content-Disposition: form-data; name=\"%s\"\r\n\r\n" + "%s\r\n" % (boundary, key, value)).encode("utf-8")) + ctype = mimetypes.guess_type(filename)[0] or "application/octet-stream" + out.append(("--%s\r\n" + "Content-Disposition: form-data; name=\"document\"; " + "filename=\"%s\"\r\n" + "Content-Type: %s\r\n\r\n" + % (boundary, os.path.basename(filename), ctype)).encode("utf-8")) + out.append(filedata) + out.append(("\r\n--%s--\r\n" % boundary).encode("utf-8")) + body = b"".join(out) + + url = "%s/api/%s" % (self.base, path.lstrip("/")) + req = urllib.request.Request(url, data=body, method="POST") + req.add_header("Content-Type", + "multipart/form-data; boundary=%s" % boundary) + with self._open(req) as r: + raw = r.read().decode("utf-8") + try: + return json.loads(raw) + except ValueError: + return raw.strip().strip('"') + + # ------------------------------------------------------------ Aufgaben + + def wait_for_task(self, task_id, progress=None): + """ + Wartet auf einen Konsumvorgang und liefert die Dokument-ID. + + Die Antwortstruktur hat sich zwischen Versionen mehrfach geändert: + Status kommt klein- oder grossgeschrieben, die Dokument-ID steht mal + verschachtelt in result_data, mal als Liste in related_document_ids. + """ + deadline = time.time() + TASK_TIMEOUT + waited = 0 + while time.time() < deadline: + task = self._find_task(task_id) + if task: + status = str(task.get("status") or "").upper() + if status in ("SUCCESS", "SUCCEEDED"): + doc = self._doc_id(task) + if doc: + return doc + raise PaperlessError("Aufgabe erfolgreich, aber ohne " + "Dokument-ID: %s" % task) + if status in ("FAILURE", "FAILED", "REVOKED"): + raise PaperlessError(self._error_text(task, status)) + time.sleep(TASK_POLL) + waited += TASK_POLL + if progress: + progress(waited) + raise PaperlessError("Zeitüberschreitung nach %ds (Aufgabe %s)" + % (TASK_TIMEOUT, task_id)) + + def _find_task(self, task_id): + for params in ({"task_id": task_id}, None): + try: + data = self._json("tasks/", params) + except PaperlessError: + continue + rows = data if isinstance(data, list) else (data or {}).get("results", []) + for t in rows: + if str(t.get("task_id")) == str(task_id): + return t + return None + + @staticmethod + def _doc_id(task): + def as_int(v): + try: + return int(v) + except (TypeError, ValueError): + return None + for key in ("related_document_ids", "document_ids"): + val = task.get(key) + if isinstance(val, (list, tuple)) and val: + got = as_int(val[0]) + if got: + return got + nested = task.get("result_data") + if isinstance(nested, dict): + for key in ("document_id", "related_document", "id"): + got = as_int(nested.get(key)) + if got: + return got + for key in ("related_document", "document_id", "related_document_id"): + got = as_int(task.get(key)) + if got: + return got + return None + + @staticmethod + def _error_text(task, status): + rd = task.get("result_data") or {} + for src in (task.get("result"), rd.get("error"), rd.get("exc_message")): + if src: + txt = " ".join(str(src).split()) + if txt and txt.lower() not in ("failure", "failed"): + return txt[:400] + return "Aufgabe %s ohne Meldung" % status + + # ------------------------------------------------------------ Dokumente + + def ping(self): + self._json("documents/", {"page_size": 1}) + return True + + 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", []) + + def document(self, doc_id, full_perms=False): + params = {"full_perms": "true"} if full_perms else None + return self._json("documents/%d/" % int(doc_id), params) + + def version_details(self, doc): + """ + Reichert die versions-Liste eines Dokuments um Format und Dateiname an. + + Die eingebettete Liste enthält nur id, added, version_label, + checksum und is_root - nicht aber den Dateityp. Jede Version ist + aber selbst ein Dokumentdatensatz, also je Version ein Abruf. + Vertretbar, weil das nur beim bewussten Öffnen eines Dokuments + passiert. + + Rückgabe absteigend nach Versionsnummer, also neueste zuerst - + so wie Paperless die Liste selbst liefert. + """ + versions = doc.get("versions") or [] + if not versions: + # Dokument ohne Versionskette: es selbst ist die einzige Fassung. + return [{ + "id": doc["id"], "added": doc.get("added"), + "version_label": None, "is_root": True, "no": 1, + "mime_type": doc.get("mime_type"), + "filename": doc.get("original_file_name") or "", + }] + + # Paperless liefert absteigend. Die Versionsnummer ergibt sich aus + # der Position von unten gezählt. + total = len(versions) + out = [] + for idx, v in enumerate(versions): + entry = dict(v) + entry["no"] = total - idx + try: + full = self.document(v["id"]) + entry["mime_type"] = full.get("mime_type") + entry["filename"] = full.get("original_file_name") or "" + except PaperlessError: + entry["mime_type"] = None + entry["filename"] = "" + out.append(entry) + return out + + def download(self, doc_id, original=True): + """Laedt die Datei. original=True liefert das unveraenderte Original.""" + url = "%s/api/documents/%d/download/" % (self.base, int(doc_id)) + if original: + url += "?original=true" + req = urllib.request.Request(req_url := url) + with self._open(req) as r: + # Der Dateiname stammt aus einem Kopfzeilenfeld der Gegenstelle + # und ist damit nicht vertrauenswürdig. Er wird hier nur roh + # weitergereicht; die Bereinigung passiert beim Ablegen. + disp = r.headers.get("Content-Disposition", "") + name = None + if "filename=" in disp: + name = disp.split("filename=", 1)[1].strip().strip('";') + name = urllib.parse.unquote(name) + data = r.read(MAX_DOWNLOAD + 1) + if len(data) > MAX_DOWNLOAD: + raise PaperlessError( + "Die Datei überschreitet die Obergrenze von %d MB." + % (MAX_DOWNLOAD // 1024 // 1024)) + return data, name + + def update_version(self, doc_id, filename, filedata, version_label=None): + """Haengt eine neue Version an. Liefert die Aufgaben-Kennung.""" + return self._multipart("documents/%d/update_version/" % int(doc_id), + {"version_label": version_label}, + filename, filedata) + + def post_document(self, filename, filedata, fields=None): + """ + Legt ein NEUES Dokument an (eigene Versionskette). + + Anders als update_version, das eine weitere Fassung an ein + bestehendes Dokument hängt. Liefert die Aufgaben-Kennung. + """ + data = {} + for key, value in (fields or {}).items(): + if value in (None, "", []): + continue + data[key] = value + return self._multipart("documents/post_document/", data, + filename, filedata) + + def patch_document(self, doc_id, payload): + return self._json("documents/%d/" % int(doc_id), + method="PATCH", payload=payload) + + # ------------------------------------------------------------ Stammdaten + + def _all(self, path, fields="id,name"): + out, page = [], 1 + while True: + data = self._json(path, {"page_size": 200, "page": page, + "fields": fields}) + if not data: + break + out.extend(data.get("results", [])) + if not data.get("next"): + break + page += 1 + return out + + def tags(self): + return self._all("tags/") + + def correspondents(self): + return self._all("correspondents/") + + def document_types(self): + return self._all("document_types/") + + def storage_paths(self): + return self._all("storage_paths/") + + def custom_fields(self): + return self._all("custom_fields/", fields="id,name,data_type") + + def create(self, path, name): + return self._json(path, method="POST", payload={"name": name}) + + # ------------------------------------------------------------ Freigabe + + def share_link(self, doc_id, days=7, file_version="original"): + """ + Erzeugt einen zeitlich begrenzten, unauthentifizierten Link. + + Der Endpunkt heißt je nach Version share_links/ oder sharelinks/. + Beide werden probiert. + """ + from datetime import datetime, timedelta, timezone + expiration = None + if days: + expiration = (datetime.now(timezone.utc) + + timedelta(days=int(days))).isoformat() + payload = {"document": int(doc_id), "file_version": file_version} + if expiration: + payload["expiration"] = expiration + + last = None + for path in ("share_links/", "sharelinks/"): + try: + res = self._json(path, method="POST", payload=payload) + slug = res.get("slug") if isinstance(res, dict) else None + if slug: + return "%s/share/%s" % (self.base, slug), res + return None, res + except PaperlessError as exc: + last = exc + raise last diff --git a/pythonpath/paperless_config.py b/pythonpath/paperless_config.py new file mode 100644 index 0000000..68d1b51 --- /dev/null +++ b/pythonpath/paperless_config.py @@ -0,0 +1,250 @@ +""" +paperless_config.py - Einstellungen und Dokumentzuordnung. + +Zwei Aufgaben: + + 1. Verbindungsdaten dauerhaft ablegen (Basisadresse, Token). + 2. Merken, welches lokal geoeffnete Dokument zu welcher Paperless-ID + gehört. + +Zu 2. gibt es zwei Ebenen. Vorrangig stehen die Angaben in den +benutzerdefinierten Dokumenteigenschaften - die überleben einen +Speichervorgang im ODF-Format und wandern sogar mit, wenn die Datei kopiert +wird. Bei Formaten, die keine solchen Eigenschaften kennen (PDF, manche +Fremdformate), greift eine lokale Zuordnungsdatei über den Datei-URL. +""" + +import json +import os +import re +import sys +import tempfile +import time + +APP_DIR_NAME = "paperless-libreoffice" +PROP_DOC_ID = "PaperlessDocumentId" +PROP_BASE = "PaperlessBaseUrl" + + +def _config_dir(): + """ + Betriebssystemabhängiger Ort für Einstellungen. + + Unter Windows gibt es kein XDG_CONFIG_HOME; dort ist %APPDATA% der + vorgesehene Ablageort. Unter macOS wäre ~/Library/Application Support + üblich, ~/.config funktioniert dort aber ebenso und hält die + Installation überschaubar. + """ + if sys.platform.startswith("win"): + base = os.environ.get("APPDATA") or os.path.expanduser("~") + else: + base = os.environ.get("XDG_CONFIG_HOME") or \ + os.path.expanduser("~/.config") + path = os.path.join(base, APP_DIR_NAME) + os.makedirs(path, exist_ok=True) + lock_down(path, 0o700) + return path + + +def lock_down(path, mode): + """ + Rechte einschränken, soweit die Plattform das über chmod zulässt. + + Unter Windows ist chmod weitgehend wirkungslos - dort greift die + Zugriffssteuerung über ACLs, und die Datei liegt im Benutzerprofil, + das andere Benutzer standardmäßig nicht lesen können. + """ + try: + os.chmod(path, mode) + except OSError: + pass + + +def work_dir(*teile): + """ + Arbeitsverzeichnis für heruntergeladene Dateien. + + BEWUSST NICHT im allgemeinen Temp-Verzeichnis: Das ist auf + Mehrbenutzersystemen für alle beschreibbar. Ein anderer Benutzer könnte + dort einen symbolischen Verweis unter dem erwarteten Namen anlegen und + das Schreiben in eine fremde Datei umlenken. + + Stattdessen ein Unterverzeichnis im Benutzerprofil, in dem nur der + eigene Benutzer schreiben darf. + """ + path = os.path.join(_config_dir(), "arbeit", *[safe_filename(t) + for t in teile]) + os.makedirs(path, exist_ok=True) + lock_down(path, 0o700) + return path + + +# Unter Windows verbotene Zeichen und reservierte Gerätenamen. Eine Datei +# namens "CON.odt" oder "aux" lässt sich dort nicht anlegen, und Namen mit +# ":" oder "\" werden als Pfadangaben gedeutet. +_UNSICHER = re.compile(r'[<>:"/\\|?*\x00-\x1f]') +_RESERVIERT = { + "CON", "PRN", "AUX", "NUL", + *("COM%d" % i for i in range(1, 10)), + *("LPT%d" % i for i in range(1, 10)), +} + + +def safe_filename(name, vorgabe="Dokument", maxlen=80): + """ + Beliebige Zeichenkette in einen unbedenklichen Dateinamen überführen. + + Deckt drei Gefahren ab: + + * Pfadanteile. Ein vom Server geliefertes "../../etc/passwd" oder + "..\\..\\datei" darf nicht aus dem Arbeitsverzeichnis + herausführen. Deshalb werden BEIDE Trennzeichen entfernt, nicht + nur das der laufenden Plattform. + * Unter Windows verbotene Zeichen und Gerätenamen. + * Punkte und Leerzeichen am Ende, die Windows stillschweigend + abschneidet - dadurch könnten zwei Namen zusammenfallen. + """ + name = str(name or "") + # Pfadanteile plattformunabhängig entfernen + name = name.replace("\\", "/").split("/")[-1] + name = _UNSICHER.sub("", name).strip() + name = re.sub(r"\s+", " ", name) + name = name.strip(". ") + if not name: + return vorgabe + stamm, punkt, endung = name.rpartition(".") + pruef = (stamm if punkt else name).upper() + if pruef in _RESERVIERT: + name = "_" + name + if len(name) > maxlen: + if punkt and len(endung) <= 8: + name = name[:maxlen - len(endung) - 1] + "." + endung + else: + name = name[:maxlen] + return name or vorgabe + + +# --------------------------------------------------------------- Einstellungen + +def _settings_path(): + return os.path.join(_config_dir(), "settings.json") + + +def load_settings(): + try: + with open(_settings_path(), "r", encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, ValueError): + data = {} + data.setdefault("base_url", "") + data.setdefault("token", "") + data.setdefault("verify_tls", True) + data.setdefault("version_label", "LibreOffice {datum}") + data.setdefault("share_days", 7) + data.setdefault("share_tag", "Sharelink") + # Zusatzfelder, deren Name eines dieser Wortteile enthält, werden beim + # Anlegen einer Kopie NICHT übernommen. Sie beziehen sich auf die + # Herkunft des Ursprungsdokuments - eine Kopie mit derselben + # Fremdkennung wäre falsch. Voreingestellt sind gängige Bezeichnungen + # aus vorangegangenen Systemen. + data.setdefault("herkunftsfelder", ["ecodms", "quell-id", "source-id", + "legacy"]) + # Anpassbar, falls ein Reverse Proxy oder eine WAF bestimmte + # Kennungen abweist. + data.setdefault("user_agent", "") + # Basisadresse defensiv normalisieren: abschliessender Schraegstrich + # und Leerraum sind haeufige Fehlerquellen. + data["base_url"] = str(data.get("base_url") or "").strip().rstrip("/") + data["token"] = str(data.get("token") or "").strip() + return data + + +def save_settings(data): + path = _settings_path() + tmp = path + ".neu" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2, ensure_ascii=False) + lock_down(tmp, 0o600) + os.replace(tmp, path) + + +def settings_complete(s): + return bool(s.get("base_url")) and bool(s.get("token")) + + +# ------------------------------------------------------------- Zuordnung + +def _map_path(): + return os.path.join(_config_dir(), "documents.json") + + +def _load_map(): + try: + with open(_map_path(), "r", encoding="utf-8") as fh: + return json.load(fh) + except (OSError, ValueError): + return {} + + +def _save_map(data): + # Einträge aelter als 90 Tage entfernen, damit die Datei nicht waechst. + cutoff = time.time() - 90 * 86400 + data = {k: v for k, v in data.items() + if isinstance(v, dict) and v.get("ts", 0) > cutoff} + path = _map_path() + tmp = path + ".neu" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2) + lock_down(tmp, 0o600) + os.replace(tmp, path) + return data + + +def remember(url, doc_id, base_url=""): + data = _load_map() + data[url] = {"id": int(doc_id), "base": base_url, "ts": time.time()} + _save_map(data) + + +def recall(url): + entry = _load_map().get(url) + return int(entry["id"]) if entry else None + + +# ------------------------------------ Dokumenteigenschaften (bevorzugter Weg) + +def set_document_id(doc, doc_id, base_url=""): + """ + Schreibt die Kennung in die benutzerdefinierten Dokumenteigenschaften. + Diese überleben einen ODF-Speichervorgang. + """ + try: + props = doc.getDocumentProperties().getUserDefinedProperties() + container = props.getPropertySetInfo() + for name, value in ((PROP_DOC_ID, str(int(doc_id))), + (PROP_BASE, base_url)): + if container.hasPropertyByName(name): + props.setPropertyValue(name, value) + else: + # 0 = ATTRIBUTE_NONE + props.addProperty(name, 0, value) + return True + except Exception: + return False + + +def get_document_id(doc): + """Kennung aus den Dokumenteigenschaften, sonst aus der Zuordnungsdatei.""" + try: + props = doc.getDocumentProperties().getUserDefinedProperties() + if props.getPropertySetInfo().hasPropertyByName(PROP_DOC_ID): + raw = props.getPropertyValue(PROP_DOC_ID) + if raw: + return int(str(raw).strip()) + except Exception: + pass + try: + url = doc.getURL() + except Exception: + url = None + return recall(url) if url else None diff --git a/pythonpath/paperless_dialogs.py b/pythonpath/paperless_dialogs.py new file mode 100644 index 0000000..196fa9c --- /dev/null +++ b/pythonpath/paperless_dialogs.py @@ -0,0 +1,790 @@ +""" +paperless_dialogs.py - Dialoge, programmatisch mit UNO aufgebaut. + +Bewusst ohne XDL-Dateien: Die muessten mitverpackt, über die +Erweiterungs-URL geladen und je Sprache gepflegt werden. Programmatisch +gebaute Dialoge sind ausfuehrlicher im Code, dafuer ohne Paketierungsaufwand +und ohne Ladepfad-Fallstricke. +""" + +import uno +from com.sun.star.awt import Rectangle +from com.sun.star.awt.MessageBoxButtons import BUTTONS_OK, BUTTONS_OK_CANCEL +from com.sun.star.awt.MessageBoxType import (ERRORBOX, INFOBOX, QUERYBOX, + WARNINGBOX) + + +# ---------------------------------------------------------------- Meldungen + +def message(ctx, frame, text, title="Paperless", kind=INFOBOX): + parent = frame.getContainerWindow() if frame else None + toolkit = ctx.ServiceManager.createInstanceWithContext( + "com.sun.star.awt.Toolkit", ctx) + box = toolkit.createMessageBox(parent, kind, BUTTONS_OK, title, text) + box.execute() + box.dispose() + + +def error(ctx, frame, text, title="Paperless — Fehler"): + message(ctx, frame, text, title, ERRORBOX) + + +def confirm(ctx, frame, text, title="Paperless"): + parent = frame.getContainerWindow() if frame else None + toolkit = ctx.ServiceManager.createInstanceWithContext( + "com.sun.star.awt.Toolkit", ctx) + box = toolkit.createMessageBox(parent, QUERYBOX, BUTTONS_OK_CANCEL, + title, text) + result = box.execute() + box.dispose() + return result == 1 # 1 = OK + + +def choice(ctx, frame, title, text, options, width=360): + """ + Rückfrage mit mehr als zwei Möglichkeiten. + + Die eingebaute MessageBox kennt nur OK und Abbrechen. Und + PushButtonType taugt hier nicht: Es kennt ausschließlich Standard, OK, + Abbrechen und Hilfe - bei drei Schaltflächen wäre die dritte „Hilfe“ + und würde den Dialog nicht schließen. + + Deshalb gewöhnliche Schaltflächen mit Ereignisbehandler, die das + Ergebnis vermerken und endExecute() aufrufen. + + Liefert den Index der gewählten Schaltfläche oder None bei Abbruch. + """ + import unohelper + from com.sun.star.awt import XActionListener + + zeilen = text.count("\n") + 1 + th = max(30, zeilen * 11) + h = th + 46 + d = DialogBuilder(ctx, title, width, h) + d.label("t", 10, 10, width - 20, th, text) + + bw = max(60, min(120, (width - 20) // len(options) - 6)) + x = width - 10 - len(options) * (bw + 6) + 6 + for i, label_text in enumerate(options): + # PushButtonType 0 = Standard: schliesst den Dialog NICHT von selbst. + d.button("b%d" % i, x, h - 26, bw, 16, label_text, 0, + i == len(options) - 1) + x += bw + 6 + + 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) + + ergebnis = {"index": None} + + class Klick(unohelper.Base, XActionListener): + def __init__(self, index): + self.index = index + + def actionPerformed(self, _ev): + ergebnis["index"] = self.index + d.dialog.endExecute() + + def disposing(self, _ev): + pass + + # Die Behandler müssen am Leben bleiben, solange der Dialog läuft. + behandler = [] + for i in range(len(options)): + k = Klick(i) + behandler.append(k) + d.get("b%d" % i).addActionListener(k) + + d.dialog.execute() + d.dispose() + return ergebnis["index"] + + +# ------------------------------------------------------- Spaltenlayout + +# Listenfelder verwenden standardmäßig eine Proportionalschrift. Mit +# Leerzeichen aufgefüllte Spalten sind darin NICHT ausgerichtet - die +# Überschrift steht dann woanders als der Inhalt. Deshalb wird für die +# Listen und ihre Überschriften eine dicht laufende Schrift gesetzt. +# +# Die Angabe ist eine Ersatzkette: LibreOffice nimmt den ersten +# verfügbaren Namen. +MONO = "Liberation Mono;DejaVu Sans Mono;Noto Sans Mono;Courier New;monospace" + +SEP = "│" # senkrechter Strich als dezenter Spaltentrenner + + +def mono(ctrl): + """ + Dicht laufende Schrift auf ein Steuerelement legen. + + Die Schriftgröße wird bewusst NICHT gesetzt - so erbt das Element die + Größe des Dialogs und damit die Systemvorgabe. Ein fester Wert wäre + auf hochauflösenden Anzeigen zu klein. + """ + try: + ctrl.FontName = MONO + except Exception: + pass + return ctrl + + +def columns(values, widths): + """ + Werte auf feste Zeichenbreiten bringen und mit Strichen trennen. + + Negative Breite bedeutet rechtsbündig, 0 bedeutet Rest der Zeile. + Nur sinnvoll zusammen mit mono(). + """ + out = [] + for value, width in zip(values, widths): + text = "" if value is None else str(value) + if width == 0: + out.append(text) + elif width < 0: + w = -width + out.append(text[:w].rjust(w)) + else: + out.append(text[:width].ljust(width)) + return (" %s " % SEP).join(out) + + +# ------------------------------------------------------------ Baukasten + +class DialogBuilder: + """Dünne Hülle um UnoControlDialogModel.""" + + def __init__(self, ctx, title, width, height): + self.ctx = ctx + smgr = ctx.ServiceManager + self.model = smgr.createInstanceWithContext( + "com.sun.star.awt.UnoControlDialogModel", ctx) + self.model.PositionX = 0 + self.model.PositionY = 0 + self.model.Width = width + self.model.Height = height + self.model.Title = title + self.dialog = smgr.createInstanceWithContext( + "com.sun.star.awt.UnoControlDialog", ctx) + self._smgr = smgr + + def add(self, kind, name, x, y, w, h, **props): + ctrl = self.model.createInstance( + "com.sun.star.awt.UnoControl%sModel" % kind) + ctrl.PositionX, ctrl.PositionY = x, y + ctrl.Width, ctrl.Height = w, h + for key, value in props.items(): + try: + setattr(ctrl, key, value) + except Exception: + pass + self.model.insertByName(name, ctrl) + return ctrl + + def label(self, name, x, y, w, h, text): + return self.add("FixedText", name, x, y, w, h, Label=text) + + 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, + DefaultButton=default) + + def listbox(self, name, x, y, w, h, items=(), dropdown=False): + return self.add("ListBox", name, x, y, w, h, + StringItemList=tuple(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) + + def execute(self, frame=None): + self.dialog.setModel(self.model) + toolkit = self._smgr.createInstanceWithContext( + "com.sun.star.awt.Toolkit", self.ctx) + parent = frame.getContainerWindow() if frame else None + self.dialog.createPeer(toolkit, parent) + result = self.dialog.execute() + return result + + def get(self, name): + return self.dialog.getControl(name) + + def dispose(self): + try: + self.dialog.dispose() + except Exception: + pass + + +# ------------------------------------------------------------ Einstellungen + +def settings_dialog(ctx, frame, settings): + """Verbindungsdaten. Liefert das geänderte dict oder None.""" + d = DialogBuilder(ctx, "Paperless — Verbindung", 260, 172) + d.label("l1", 8, 10, 70, 12, "Basisadresse") + d.edit("base", 80, 8, 172, 14, settings.get("base_url", "")) + d.label("l1b", 80, 24, 172, 10, "z. B. https://dms.example.org") + + d.label("l2", 8, 42, 70, 12, "API-Token") + d.edit("token", 80, 40, 172, 14, settings.get("token", ""), + EchoChar=ord("*")) + d.label("l2b", 80, 56, 172, 10, + "Im Benutzerprofil von Paperless erzeugen") + + d.check("tls", 80, 72, 172, 12, "TLS-Zertifikat prüfen", + 1 if settings.get("verify_tls", True) else 0) + + d.label("l3", 8, 92, 70, 12, "Versionslabel") + d.edit("label", 80, 90, 172, 14, settings.get("version_label", "")) + d.label("l3b", 80, 106, 172, 10, "{datum} wird ersetzt") + + d.label("l4", 8, 112, 70, 12, "User-Agent") + d.edit("ua", 80, 110, 172, 14, settings.get("user_agent", "")) + d.label("l4b", 80, 126, 172, 10, + "leer = Vorgabe. Nur ändern, wenn ein Proxy blockt.") + + d.button("ok", 140, 146, 52, 16, "Speichern", 1, True) + d.button("cancel", 198, 146, 52, 16, "Abbrechen", 2) + + if d.execute(frame) != 1: + d.dispose() + return None + out = dict(settings) + out["base_url"] = d.get("base").getText().strip() + out["token"] = d.get("token").getText().strip() + out["verify_tls"] = bool(d.get("tls").getState()) + out["version_label"] = d.get("label").getText().strip() + out["user_agent"] = d.get("ua").getText().strip() + d.dispose() + return out + + +# ------------------------------------------------------------ Dokumentsuche + +def open_dialog(ctx, frame, client): + """ + Ein Dialog von oben nach unten: Suche, Trefferliste, Versionsliste. + + Die Trefferliste zeigt je Dokument genau einen Eintrag - das + Wurzeldokument. Wird darin etwas ausgewählt, füllt sich darunter die + Versionsliste mit Datum und Dateiformat je Fassung. + + Liefert (dokument, version, nur_lesen) oder None. + """ + import paperless_formats as fmt + + W, H = 420, 322 + d = DialogBuilder(ctx, "Dokument aus Paperless öffnen", W, H) + + # Spaltenbreiten in Zeichen. Negativ = rechtsbündig, 0 = Rest der Zeile. + COL_RES = (-7, 10, 0) # ASN, Erstellt, Titel + COL_VER = (-3, 10, 14, 0) # Nr., Erstellt, Format, Bezeichnung + + # Listenfelder rücken ihren Text um wenige Einheiten ein. Die + # Überschrift bekommt denselben Versatz, sonst stehen die Spalten + # gegeneinander verschoben. + HX = 11 + + # 1. Suche + d.label("l1", 8, 10, 40, 12, "Suche") + d.edit("q", 50, 8, W - 122, 14, "") + d.button("go", W - 66, 8, 58, 14, "Suchen", 0, True) + d.label("hint1", 8, 26, W - 16, 10, + "Leer lassen für die zuletzt hinzugefügten Dokumente. " + "Archiv-Seriennummer funktioniert ebenfalls.") + + # 2. Treffer - Bezeichnung, darunter Spaltenüberschrift, darunter Liste + d.label("l2", 8, 42, 200, 11, "Dokumente") + mono(d.label("head1", HX, 55, W - HX - 8, 11, + columns(("ASN", "Erstellt", "Titel"), COL_RES))) + mono(d.listbox("results", 8, 68, W - 16, 104)) + + # 3. Versionen + d.label("l3", 8, 180, 200, 11, "Versionen") + mono(d.label("head2", HX, 193, W - HX - 8, 11, + " " + columns(("Nr", "Erstellt", "Format", "Bezeichnung"), + COL_VER))) + mono(d.listbox("versions", 8, 206, W - 16, 66)) + + d.label("hint2", 8, 278, W - 16, 20, "") + d.label("l4", 8, 301, 46, 11, "Öffnen als") + d.listbox("mode", 58, 299, 130, 14, + ["Bearbeitbar", "Schreibgeschützt"], dropdown=True) + d.button("ok", W - 122, 298, 54, 16, "Öffnen", 1) + d.button("cancel", W - 62, 298, 54, 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) + + lb_res = d.get("results") + lb_ver = d.get("versions") + state = {"rows": [], "versions": [], "doc": None, "cache": {}, "last": -1} + + # ---------------------------------------------------------- Trefferliste + + def refresh(query): + try: + rows = client.search(query) + except Exception as exc: # noqa: BLE001 + d.get("hint2").setText("Fehler bei der Suche: %s" % str(exc)[:70]) + return + state["rows"] = rows + state["last"] = -1 + lb_res.removeItems(0, lb_res.getItemCount()) + lb_ver.removeItems(0, lb_ver.getItemCount()) + entries = [] + for r in rows: + asn = r.get("archive_serial_number") + entries.append(columns(( + str(asn) if asn is not None else "—", + (r.get("created") or "")[:10], + (r.get("title") or "")[:90]), COL_RES)) + if entries: + lb_res.addItems(tuple(entries), 0) + lb_res.selectItemPos(0, True) + load_versions() + else: + d.get("hint2").setText("Keine Treffer.") + + # --------------------------------------------------------- Versionsliste + + def load_versions(): + """ + Versionen des gewählten Dokuments nachladen. + + Die Formatangabe steht nicht in der eingebetteten versions-Liste, + deshalb je Version ein Abruf. Ergebnisse werden zwischengespeichert, + damit das Blättern in der Trefferliste nicht bei jedem Tastendruck + erneut abfragt. + """ + pos = lb_res.getSelectedItemPos() + if pos == state["last"] or not (0 <= pos < len(state["rows"])): + return + state["last"] = pos + row = state["rows"][pos] + doc_id = row["id"] + + lb_ver.removeItems(0, lb_ver.getItemCount()) + d.get("hint2").setText("Versionen werden geladen …") + + if doc_id in state["cache"]: + full, versions = state["cache"][doc_id] + else: + try: + full = client.document(doc_id) + versions = client.version_details(full) + except Exception as exc: # noqa: BLE001 + d.get("hint2").setText("Fehler: %s" % str(exc)[:70]) + return + state["cache"][doc_id] = (full, versions) + + state["doc"], state["versions"] = full, versions + entries = [] + for v in versions: + editable = fmt.is_editable(v.get("filename"), v.get("mime_type")) + entries.append((" " if editable else "• ") + columns(( + v.get("no", "?"), + (v.get("added") or "")[:10], + fmt.label(v.get("filename"), v.get("mime_type")), + (v.get("version_label") or "")[:40]), COL_VER)) + if entries: + lb_ver.addItems(tuple(entries), 0) + # Vorgabe: die aktuellste Version. Die Liste kommt absteigend, + # also Position 0. + lb_ver.selectItemPos(0, True) + update_hint() + + def update_hint(): + """ + Hinweis und Vorbelegung der Öffnungsart. + + Die Vorbelegung ist ein Vorschlag, keine Vorschrift - die + Formaterkennung kann danebenliegen, und dann soll der Anwender + trotzdem bearbeitbar öffnen können. + """ + pos = lb_ver.getSelectedItemPos() + vers = state["versions"] + if not (0 <= pos < len(vers)): + d.get("hint2").setText("") + return + v = vers[pos] + fn, mt = v.get("filename"), v.get("mime_type") + if fmt.is_editable(fn, mt): + d.get("mode").selectItemPos(0, True) + d.get("hint2").setText( + "Bearbeitbar. Änderungen können als neue Version abgelegt " + "werden.") + elif not fmt.is_known(fn, mt): + # Unbekanntes Format: kein Grund, das Bearbeiten zu verbieten. + d.get("mode").selectItemPos(0, True) + d.get("hint2").setText( + "Format nicht erkannt: %s\n" + "Bearbeitbar öffnen ist möglich — beim Zurückschreiben " + "wird ODF verwendet." % fmt.detail(fn, mt)) + else: + d.get("mode").selectItemPos(1, True) + d.get("hint2").setText( + "%s ist in LibreOffice nicht sinnvoll bearbeitbar.\n" + "Schreibgeschützt empfohlen — bearbeitbar ist trotzdem " + "wählbar." % fmt.label(fn, mt)) + + # ------------------------------------------------------------- Ereignisse + + import unohelper + from com.sun.star.awt import XActionListener, XItemListener + + class GoListener(unohelper.Base, XActionListener): + def actionPerformed(self, _ev): + refresh(d.get("q").getText().strip()) + + def disposing(self, _ev): + pass + + class ResultListener(unohelper.Base, XItemListener): + def itemStateChanged(self, _ev): + load_versions() + + def disposing(self, _ev): + pass + + class VersionListener(unohelper.Base, XItemListener): + def itemStateChanged(self, _ev): + update_hint() + + 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 = [GoListener(), ResultListener(), VersionListener()] + d.get("go").addActionListener(behandler[0]) + lb_res.addItemListener(behandler[1]) + lb_ver.addItemListener(behandler[2]) + + refresh("") + + if d.dialog.execute() != 1: + d.dispose() + return None + + vpos = lb_ver.getSelectedItemPos() + # Die Wahl des Anwenders wird NICHT überschrieben. Wer ein als nicht + # bearbeitbar eingestuftes Format trotzdem bearbeitbar öffnen will, + # darf das - die Erkennung kann danebenliegen. + readonly = d.get("mode").getSelectedItemPos() == 1 + doc, versions = state["doc"], state["versions"] + d.dispose() + + if doc is None or not (0 <= vpos < len(versions)): + return None + return doc, versions[vpos], readonly + + +# --------------------------------------------------- Ablegeziel wählen + +def store_target_dialog(ctx, frame, client, source): + """ + Wohin soll die geöffnete Datei abgelegt werden? + + Drei Fälle, die sich in der Absicht unterscheiden: + + * ein NEUES Dokument mit eigener Versionskette + * eine neue Version an einem ANDEREN, bereits vorhandenen Dokument + + Der zweite Fall ist der, den Dokumentenverwaltungen gern vergessen: + Die Rechnung kommt als PDF, die bearbeitbare Fassung entsteht später. + Beides gehört zusammen, aber das PDF war zuerst da. + + Liefert ein dict oder None. + """ + import paperless_formats as fmt + + W, H = 430, 300 + COL = (-7, 10, 0) + HX = 11 + title = (source.get("title") or "") + + d = DialogBuilder(ctx, "In Paperless ablegen", W, H) + + d.label("l0", 8, 10, 34, 12, "Ziel") + d.listbox("mode", 46, 8, 250, 14, + ["Neues Dokument anlegen", + "An bestehendes Dokument anhängen"], dropdown=True) + + # --- Fall 1: neues Dokument + d.label("n1", 8, 36, 46, 12, "Titel") + d.edit("title", 58, 34, W - 66, 14, (title + " (Kopie)")[:120]) + d.check("meta", 58, 54, W - 66, 12, + "Klassifizierung des geöffneten Dokuments übernehmen", 1) + + # --- Fall 2: an bestehendes Dokument anhängen + d.label("e1", 8, 78, 46, 12, "Suche") + d.edit("q", 58, 76, W - 130, 14, "") + d.button("go", W - 66, 76, 58, 14, "Suchen", 0) + mono(d.label("head", HX, 96, W - HX - 8, 11, + columns(("ASN", "Erstellt", "Titel"), COL))) + mono(d.listbox("results", 8, 109, W - 16, 96)) + d.label("e2", 8, 209, W - 16, 20, "") + + d.check("switch", 8, 236, W - 16, 12, + "Danach mit dem Zieldokument weiterarbeiten", 1) + d.label("l9", 8, 250, W - 16, 20, + "Das geöffnete Fenster wird dem Ziel zugeordnet. Weitere " + "Versionen landen dann dort.") + + d.button("ok", W - 122, 276, 54, 16, "Ablegen", 1, True) + d.button("cancel", W - 62, 276, 54, 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) + + lb_res = d.get("results") + state = {"rows": []} + + def set_mode(): + neu_ = d.get("mode").getSelectedItemPos() == 0 + for name in ("n1", "title", "meta"): + d.get(name).setEnable(neu_) + for name in ("e1", "q", "go", "head", "results"): + d.get(name).setEnable(not neu_) + if neu_: + d.get("e2").setText("") + else: + update_target_hint() + + def refresh(query): + try: + rows = client.search(query) + except Exception as exc: # noqa: BLE001 + d.get("e2").setText("Fehler bei der Suche: %s" % str(exc)[:70]) + return + state["rows"] = rows + lb_res.removeItems(0, lb_res.getItemCount()) + entries = [] + for r in rows: + asn = r.get("archive_serial_number") + entries.append(columns(( + str(asn) if asn is not None else "—", + (r.get("created") or "")[:10], + (r.get("title") or "")[:90]), COL)) + if entries: + lb_res.addItems(tuple(entries), 0) + lb_res.selectItemPos(0, True) + update_target_hint() + + def update_target_hint(): + pos = lb_res.getSelectedItemPos() + rows = state["rows"] + if not (0 <= pos < len(rows)): + d.get("e2").setText("Kein Zieldokument gewählt.") + return + r = rows[pos] + d.get("e2").setText( + "Die geöffnete Datei wird als neue Version an „%s“ angehängt.\n" + "Die bisherige Fassung bleibt als Version erhalten." + % (r.get("title") or "")[:60]) + + import unohelper + from com.sun.star.awt import XActionListener, XItemListener + + class Go(unohelper.Base, XActionListener): + def actionPerformed(self, _ev): + refresh(d.get("q").getText().strip()) + + def disposing(self, _ev): + pass + + class ModeChanged(unohelper.Base, XItemListener): + def itemStateChanged(self, _ev): + set_mode() + + def disposing(self, _ev): + pass + + class ResultChanged(unohelper.Base, XItemListener): + def itemStateChanged(self, _ev): + update_target_hint() + + def disposing(self, _ev): + pass + + behandler = [Go(), ModeChanged(), ResultChanged()] + d.get("go").addActionListener(behandler[0]) + d.get("mode").addItemListener(behandler[1]) + lb_res.addItemListener(behandler[2]) + + d.get("mode").selectItemPos(0, True) + set_mode() + + if d.dialog.execute() != 1: + d.dispose() + return None + + neu_ = d.get("mode").getSelectedItemPos() == 0 + out = {"switch": bool(d.get("switch").getState())} + if neu_: + out["mode"] = "new" + out["title"] = d.get("title").getText().strip() + out["take_meta"] = bool(d.get("meta").getState()) + d.dispose() + return out if out["title"] else None + + pos = lb_res.getSelectedItemPos() + rows = state["rows"] + d.dispose() + if not (0 <= pos < len(rows)): + return None + out["mode"] = "existing" + out["target"] = rows[pos] + return out + + +# --------------------------------------------------------- Klassifizierung + +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. + """ + def names(rows): + return [r["name"] for r in rows] + + def name_of(rows, ident): + for r in rows: + if r["id"] == ident: + return r["name"] + return "" + + d = DialogBuilder(ctx, "Klassifizierung bearbeiten", 300, 250) + + d.label("l1", 8, 10, 60, 12, "Titel") + d.edit("title", 70, 8, 222, 14, doc.get("title") or "") + + d.label("l2", 8, 30, 60, 12, "Erstellt") + d.edit("created", 70, 28, 90, 14, (doc.get("created") or "")[:10]) + d.label("l2b", 164, 30, 128, 10, "Format JJJJ-MM-TT") + + d.label("l3", 8, 50, 60, 12, "Korrespondent") + d.combo("corr", 70, 48, 222, 14, names(correspondents), + name_of(correspondents, doc.get("correspondent"))) + + d.label("l4", 8, 70, 60, 12, "Dokumenttyp") + d.combo("dtype", 70, 68, 222, 14, names(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.button("ok", 178, 226, 52, 16, "Speichern", 1, True) + d.button("cancel", 236, 226, 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) + + if d.dialog.execute() != 1: + d.dispose() + return None + + payload = {"title": d.get("title").getText().strip()} + created = d.get("created").getText().strip() + if created: + payload["created"] = created + + selected = d.get("tags").getSelectedItemsPos() + payload["tags"] = [tags[i]["id"] for i in selected if i < len(tags)] + + payload["_correspondent_name"] = d.get("corr").getText().strip() + payload["_document_type_name"] = d.get("dtype").getText().strip() + d.dispose() + return payload + + +# -------------------------------------------------------------- Freigabe + +def share_dialog(ctx, frame, default_days=7, default_tag="Sharelink", + known_tags=()): + """ + Laufzeit, Dateivariante und optionaler Tag. + + Der Tag macht geteilte Dokumente später auffindbar - Paperless zeigt + zwar die eigenen Freigaben an, aber ein Tag lässt sich in gespeicherten + Ansichten und Regeln verwenden. + + Liefert (tage, variante, tagname_oder_None) oder None. + """ + d = DialogBuilder(ctx, "Dokument teilen", 300, 168) + + d.label("l1", 8, 12, 90, 12, "Gültigkeit in Tagen") + d.edit("days", 104, 10, 50, 14, str(default_days)) + d.label("l1b", 104, 26, 188, 10, "0 = unbegrenzt gültig") + + d.label("l2", 8, 44, 90, 12, "Dateivariante") + d.listbox("ver", 104, 42, 188, 14, + ["Original", "Archivfassung (PDF)"], dropdown=True) + + d.check("tag", 8, 70, 284, 12, + "Dokument mit einem Tag kennzeichnen", 1) + d.combo("tagname", 104, 86, 188, 14, known_tags, default_tag) + d.label("l3b", 104, 102, 188, 20, + "Macht geteilte Dokumente auffindbar. Ist der Tag noch nicht " + "vorhanden, wird er nach Rückfrage angelegt.") + + d.button("ok", 178, 144, 54, 16, "Erzeugen", 1, True) + d.button("cancel", 236, 144, 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) + d.get("ver").selectItemPos(0, True) + + if d.dialog.execute() != 1: + d.dispose() + return None + try: + days = int(d.get("days").getText().strip() or "0") + except ValueError: + days = default_days + variant = "original" if d.get("ver").getSelectedItemPos() == 0 else "archive" + tag = d.get("tagname").getText().strip() if d.get("tag").getState() else None + d.dispose() + return days, variant, (tag or None) + + +def result_dialog(ctx, frame, title, text, url=None): + """Ergebnis anzeigen, Link zum Herauskopieren in einem Textfeld.""" + height = 120 if url else 90 + d = DialogBuilder(ctx, title, 320, height) + d.label("l1", 8, 10, 304, 24, text) + if url: + d.label("l2", 8, 42, 304, 10, "Link (zum Kopieren markieren):") + d.edit("url", 8, 54, 304, 14, url, ReadOnly=True) + d.button("ok", 258, height - 24, 54, 16, "Schließen", 1, True) + d.execute(frame) + d.dispose() diff --git a/pythonpath/paperless_formats.py b/pythonpath/paperless_formats.py new file mode 100644 index 0000000..fe3afea --- /dev/null +++ b/pythonpath/paperless_formats.py @@ -0,0 +1,148 @@ +""" +paperless_formats.py - Dateiformate und ihre Bearbeitbarkeit in LibreOffice. + +Zentral gehalten, weil drei Stellen dieselbe Information brauchen: die +Versionsauswahl beim Öffnen, die Wahl des Speicherfilters beim Zurueck- +schreiben und der Hinweis, wenn eine Fassung nicht sinnvoll bearbeitbar ist. +""" + +# Endung -> (Anzeigename, LibreOffice-Filter oder None, bearbeitbar) +# +# "bearbeitbar" meint: LibreOffice kann die Datei öffnen UND im selben +# Format wieder speichern, sodass ein sinnvoller Rückweg ins Archiv +# entsteht. PDF lässt sich zwar in Draw öffnen, ein Zurückschreiben +# ergaebe aber ein anderes Dokument - deshalb hier "nur Ansicht". +FORMATS = { + ".odt": ("ODF Text", "writer8", True), + ".ods": ("ODF Tabelle", "calc8", True), + ".odp": ("ODF Präsentation", "impress8", True), + ".odg": ("ODF Zeichnung", "draw8", True), + ".doc": ("Word 97", "MS Word 97", True), + ".docx": ("Word", "MS Word 2007 XML", True), + ".dot": ("Word-Vorlage", "MS Word 97 Vorlage", True), + ".rtf": ("Rich Text", "Rich Text Format", True), + ".xls": ("Excel 97", "MS Excel 97", True), + ".xlsx": ("Excel", "Calc MS Excel 2007 XML", True), + ".ppt": ("PowerPoint 97", "MS PowerPoint 97", True), + ".pptx": ("PowerPoint", "Impress MS PowerPoint 2007 XML", True), + ".txt": ("Text", "Text", True), + ".csv": ("CSV", "Text - txt - csv (StarCalc)", True), + + ".pdf": ("PDF", None, False), + ".eml": ("E-Mail", None, False), + ".msg": ("E-Mail", None, False), + ".xml": ("XML", None, False), + ".html": ("HTML", None, False), + ".zip": ("Archiv", None, False), + ".png": ("Bild", None, False), + ".jpg": ("Bild", None, False), + ".jpeg": ("Bild", None, False), + ".tif": ("Bild", None, False), + ".tiff": ("Bild", None, False), +} + +MIME_TO_EXT = { + "application/vnd.oasis.opendocument.text": ".odt", + "application/vnd.oasis.opendocument.spreadsheet": ".ods", + "application/vnd.oasis.opendocument.presentation": ".odp", + "application/vnd.oasis.opendocument.graphics": ".odg", + "application/msword": ".doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", + "application/vnd.ms-excel": ".xls", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", + "application/vnd.ms-powerpoint": ".ppt", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", + "application/rtf": ".rtf", + "text/rtf": ".rtf", + "text/plain": ".txt", + "text/csv": ".csv", + "application/pdf": ".pdf", + "message/rfc822": ".eml", + "text/html": ".html", + "application/xml": ".xml", + "text/xml": ".xml", + "application/zip": ".zip", +} + + +def extension(filename=None, mime_type=None): + """Endung aus Dateiname oder MIME-Typ, immer kleingeschrieben.""" + if filename and "." in filename: + return "." + filename.rsplit(".", 1)[-1].lower() + if mime_type: + return MIME_TO_EXT.get(mime_type.split(";")[0].strip().lower(), "") + return "" + + +def describe(filename=None, mime_type=None): + """(Anzeigename, Filter, bearbeitbar) für eine Datei.""" + ext = extension(filename, mime_type) + if ext in FORMATS: + return FORMATS[ext] + return (ext.lstrip(".").upper() or "unbekannt", None, False) + + +def is_editable(filename=None, mime_type=None): + return describe(filename, mime_type)[2] + + +def is_known(filename=None, mime_type=None): + """ + Ist das Format in der Tabelle verzeichnet? + + Wichtig für die Unterscheidung zweier Fälle: Ein PDF ist BEKANNT und + nicht bearbeitbar — da hilft kein Zureden. Ein unbekanntes Format + dagegen ist womöglich sehr wohl bearbeitbar, es steht nur nicht in der + Tabelle. Dann soll der Anwender entscheiden dürfen, statt vom Programm + ausgebremst zu werden. + """ + return extension(filename, mime_type) in FORMATS + + +def detail(filename=None, mime_type=None): + """ + Ausführliche Angabe für Hinweistexte: Anzeigename plus die Rohdaten, + aus denen er abgeleitet wurde. Damit lässt sich erkennen, welche + Formate in FORMATS ergänzt werden sollten. + """ + name = describe(filename, mime_type)[0] + teile = [t for t in (filename, mime_type) if t] + return "%s — %s" % (name, ", ".join(teile)) if teile else name + + +# Dokumentart -> Vorgabeformat für Dateien, die noch nie gespeichert wurden. +# Ermittelt über supportsService() am geöffneten Dokument. +SERVICE_DEFAULTS = ( + ("com.sun.star.text.TextDocument", ".odt", "writer8"), + ("com.sun.star.sheet.SpreadsheetDocument", ".ods", "calc8"), + ("com.sun.star.presentation.PresentationDocument", ".odp", "impress8"), + ("com.sun.star.drawing.DrawingDocument", ".odg", "draw8"), + ("com.sun.star.formula.FormulaProperties", ".odf", "math8"), +) + + +def default_for_document(doc): + """ + (Endung, Filter) für ein Dokument ohne Speicherort. + + Ein neu angelegtes, noch nie gespeichertes Dokument hat keinen URL. + Damit es trotzdem abgelegt werden kann, wird es in ein temporäres + Verzeichnis geschrieben - im zur Anwendung passenden ODF-Format. + """ + for service, ext, filt in SERVICE_DEFAULTS: + try: + if doc.supportsService(service): + return ext, filt + except Exception: + continue + return ".odt", "writer8" + + +def store_filter(filename=None, mime_type=None): + """LibreOffice-Filtername zum Speichern im Originalformat.""" + return describe(filename, mime_type)[1] + + +def label(filename=None, mime_type=None): + """Kurzbeschreibung für die Anzeige, z. B. 'ODF Text'.""" + return describe(filename, mime_type)[0] diff --git a/pythonpath/paperless_i18n.py b/pythonpath/paperless_i18n.py new file mode 100644 index 0000000..ec7d4b9 --- /dev/null +++ b/pythonpath/paperless_i18n.py @@ -0,0 +1,392 @@ +""" +paperless_i18n.py - Oberflächentexte in Deutsch, Englisch und Französisch. + +Als Schlüssel dient der deutsche Text selbst. Das hält den Quelltext +lesbar - man sieht an der Aufrufstelle, was dort steht - und erspart eine +zweite Katalogdatei für die Ausgangssprache. + +Fehlt eine Übersetzung, erscheint der deutsche Text. Das ist unschön, aber +besser als ein leerer Dialog oder ein Absturz. + +Die Sprache wird aus der Einstellung von LibreOffice gelesen, nicht aus der +Umgebung des Betriebssystems - maßgeblich ist die Sprache der Oberfläche, +in der die Erweiterung erscheint. +""" + +_SPRACHE = None + + +def sprache(ctx=None): + """ + Zweibuchstabiger Sprachcode der LibreOffice-Oberfläche. + + Ermittelt über die Einstellung /org.openoffice.Setup/L10N/ooLocale, + ersatzweise über die Umgebungsvariablen. Alles außer en und fr fällt + auf Deutsch zurück, weil das die Ausgangssprache ist. + """ + global _SPRACHE + if _SPRACHE is not None: + return _SPRACHE + + code = "" + if ctx is not None: + try: + import uno + from com.sun.star.beans import PropertyValue + p = PropertyValue() + p.Name, p.Value = "nodepath", "/org.openoffice.Setup/L10N" + provider = ctx.ServiceManager.createInstanceWithContext( + "com.sun.star.configuration.ConfigurationProvider", ctx) + node = provider.createInstanceWithArguments( + "com.sun.star.configuration.ConfigurationAccess", (p,)) + code = str(node.getByName("ooLocale") or "") + except Exception: # noqa: BLE001 + code = "" + + if not code: + import os + for var in ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"): + code = os.environ.get(var) or "" + if code: + break + + code = code.replace("_", "-").split("-")[0].lower() + _SPRACHE = code if code in ("de", "en", "fr") else "de" + return _SPRACHE + + +def setze_sprache(code): + """Nur für Prüfzwecke: Sprache fest vorgeben.""" + global _SPRACHE + _SPRACHE = code + + +def t(text, ctx=None): + """Übersetzt einen Text in die Oberflächensprache.""" + lang = sprache(ctx) + if lang == "de": + return text + return KATALOG.get(lang, {}).get(text, text) + + +# --------------------------------------------------------------------------- +# Katalog. Schlüssel ist der deutsche Text, exakt wie im Quelltext. +# --------------------------------------------------------------------------- + +EN = { + # -- Allgemein + "Paperless — Fehler": "Paperless — Error", + "Abbrechen": "Cancel", + "Speichern": "Save", + "Öffnen": "Open", + "Suchen": "Search", + "Suche": "Search", + "Titel": "Title", + "Erstellt": "Created", + "Format": "Format", + "Bezeichnung": "Label", + "Dokumente": "Documents", + "Versionen": "Versions", + "Nr": "No", + "ASN": "ASN", + "Schließen": "Close", + "Anlegen": "Create", + "Ablegen": "Store", + "Erzeugen": "Create", + "Ziel": "Target", + "Tags": "Tags", + "Korrespondent": "Correspondent", + "Dokumenttyp": "Document type", + "Tag": "Tag", + + # -- Verbindung + "Paperless — Verbindung": "Paperless — Connection", + "Basisadresse": "Base address", + "z. B. https://dms.example.org": "e.g. https://dms.example.org", + "API-Token": "API token", + "Im Benutzerprofil von Paperless erzeugen": + "Create it in your Paperless user profile", + "TLS-Zertifikat prüfen": "Verify TLS certificate", + "Abschalten nur für Testinstanzen mit selbst ausgestelltem " + "Zertifikat. Ohne Prüfung ist die Verbindung angreifbar.": + "Disable only for test instances with a self-signed certificate. " + "Without verification the connection can be attacked.", + "Versionslabel": "Version label", + "{datum} wird ersetzt": "{datum} is replaced", + "Bitte zuerst Basisadresse und Token hinterlegen.": + "Please configure base address and token first.", + "Verbindung erfolgreich hergestellt.": "Connection established.", + "Gespeichert, aber die Verbindung schlug fehl:\n\n%s": + "Saved, but the connection failed:\n\n%s", + + # -- Öffnen + "Dokument aus Paperless öffnen": "Open document from Paperless", + "Leer lassen für die zuletzt hinzugefügten Dokumente. " + "Archiv-Seriennummer funktioniert ebenfalls.": + "Leave empty for the most recently added documents. " + "The archive serial number works as well.", + "Öffnen als": "Open as", + "Bearbeitbar": "Editable", + "Schreibgeschützt": "Read-only", + "Keine Treffer.": "No matches.", + "Fehler bei der Suche: %s": "Search failed: %s", + "Versionen werden geladen …": "Loading versions …", + "Kein Zieldokument gewählt.": "No target document selected.", + "Bearbeitbar. Änderungen können als neue Version abgelegt " + "werden.": + "Editable. Changes can be stored as a new version.", + "Format nicht erkannt: %s\nBearbeitbar öffnen ist möglich — beim " + "Zurückschreiben wird ODF verwendet.": + "Format not recognised: %s\nOpening as editable is possible — " + "ODF will be used when writing back.", + "%s ist in LibreOffice nicht sinnvoll bearbeitbar.\n" + "Schreibgeschützt empfohlen — bearbeitbar ist trotzdem wählbar.": + "%s cannot reasonably be edited in LibreOffice.\n" + "Read-only recommended — editable remains available.", + "Schreibgeschützt geöffnet.\n\nZum Ablegen einer neuen Version muss " + "das Dokument bearbeitbar geöffnet werden.": + "Opened read-only.\n\nTo store a new version the document must be " + "opened as editable.", + + # -- Ablegen + "In Paperless ablegen": "Store in Paperless", + "Neues Dokument anlegen": "Create a new document", + "An bestehendes Dokument anhängen": "Append to an existing document", + "Klassifizierung des geöffneten Dokuments übernehmen": + "Copy classification from the open document", + "Danach mit dem Zieldokument weiterarbeiten": + "Continue working with the target document", + "Das geöffnete Fenster wird dem Ziel zugeordnet. Weitere " + "Versionen landen dann dort.": + "The open window will be linked to the target. Further versions " + "will go there.", + "Kein Dokument geöffnet.": "No document open.", + "Als neue Version im Archiv ablegen?\n\n": + "Store as a new version in the archive?\n\n", + "Die bisherige Fassung bleibt als Version erhalten.": + "The previous state is kept as a version.", + "Neue Version abgelegt.\n\n": "New version stored.\n\n", + "Neues Dokument angelegt.\n\n": "New document created.\n\n", + "Als neue Version angehängt.\n\n": "Appended as a new version.\n\n", + "Die geöffnete Datei als neue Version anhängen?\n\n": + "Append the open file as a new version?\n\n", + "Unerwartete Antwort beim Hochladen:\n%r": + "Unexpected response while uploading:\n%r", + "Das Dokument konnte nicht zwischengespeichert werden:\n%s": + "The document could not be buffered:\n%s", + "Speichern fehlgeschlagen:\n%s": "Saving failed:\n%s", + + # -- Klassifizierung + "Klassifizierung bearbeiten": "Edit classification", + "Format JJJJ-MM-TT": "Format YYYY-MM-DD", + "Mehrfachauswahl mit Strg oder Umschalt": + "Multiple selection with Ctrl or Shift", + "Klassifizierung gespeichert.": "Classification saved.", + "Stammdaten konnten nicht geladen werden:\n%s": + "Master data could not be loaded:\n%s", + + # -- Teilen + "Dokument teilen": "Share document", + "Gültigkeit in Tagen": "Validity in days", + "0 = unbegrenzt gültig": "0 = valid indefinitely", + "Dateivariante": "File variant", + "Original": "Original", + "Archivfassung (PDF)": "Archived version (PDF)", + "Dokument mit einem Tag kennzeichnen": "Mark document with a tag", + "Macht geteilte Dokumente auffindbar. Ist der Tag noch nicht " + "vorhanden, wird er nach Rückfrage angelegt.": + "Makes shared documents findable. If the tag does not exist yet " + "it will be created after confirmation.", + "Freigabelink erzeugt": "Share link created", + "Nicht abgelegte Änderungen": "Unsaved changes", + "Trotzdem teilen": "Share anyway", + "Erst neue Version ablegen": "Store new version first", + + # -- Status + "Paperless — Status": "Paperless — Status", + "Status anzeigen": "Show status", + + # -- Nachträge + "Link (zum Kopieren markieren):": "Link (select to copy):", + "leer = Vorgabe. Nur ändern, wenn ein Proxy blockt.": + "empty = default. Change only if a proxy blocks it.", + "Dieses Dokument stammt nicht aus Paperless.\n\n" + "Es wurde keine Paperless-Kennung gefunden. Öffne es " + "über „Aus Paperless öffnen“, damit die " + "Zuordnung erhalten bleibt.": + "This document did not come from Paperless.\n\n" + "No Paperless identifier was found. Open it via " + "\u201cOpen from Paperless\u201d so the link is kept.", + "Dieses Dokument liegt noch nicht in Paperless.\n\n" + "Ein Freigabelink kann erst erzeugt werden, wenn es " + "abgelegt ist.\n\nJetzt ablegen?": + "This document is not in Paperless yet.\n\n" + "A share link can only be created once it is stored.\n\n" + "Store it now?", + "Das geöffnete Dokument enthält Änderungen, die noch nicht " + "in Paperless abgelegt sind.\n\nDer Link würde auf die " + "zuletzt abgelegte Fassung zeigen, nicht auf das, was du " + "gerade vor dir hast.": + "The open document has changes that have not been stored in " + "Paperless.\n\nThe link would point to the last stored state, " + "not to what you see in front of you.", +} + +FR = { + "Paperless — Fehler": "Paperless — Erreur", + "Abbrechen": "Annuler", + "Speichern": "Enregistrer", + "Öffnen": "Ouvrir", + "Suchen": "Rechercher", + "Suche": "Recherche", + "Titel": "Titre", + "Erstellt": "Créé le", + "Format": "Format", + "Bezeichnung": "Libellé", + "Dokumente": "Documents", + "Versionen": "Versions", + "Nr": "N°", + "ASN": "ASN", + "Schließen": "Fermer", + "Anlegen": "Créer", + "Ablegen": "Enregistrer", + "Erzeugen": "Créer", + "Ziel": "Cible", + "Tags": "Étiquettes", + "Korrespondent": "Correspondant", + "Dokumenttyp": "Type de document", + "Tag": "Étiquette", + + "Paperless — Verbindung": "Paperless — Connexion", + "Basisadresse": "Adresse de base", + "z. B. https://dms.example.org": "p. ex. https://dms.example.org", + "API-Token": "Jeton d'API", + "Im Benutzerprofil von Paperless erzeugen": + "À créer dans votre profil utilisateur Paperless", + "TLS-Zertifikat prüfen": "Vérifier le certificat TLS", + "Abschalten nur für Testinstanzen mit selbst ausgestelltem " + "Zertifikat. Ohne Prüfung ist die Verbindung angreifbar.": + "À désactiver uniquement pour une instance de test avec un " + "certificat auto-signé. Sans vérification, la connexion est " + "vulnérable.", + "Versionslabel": "Libellé de version", + "{datum} wird ersetzt": "{datum} sera remplacé", + "Bitte zuerst Basisadresse und Token hinterlegen.": + "Veuillez d'abord renseigner l'adresse de base et le jeton.", + "Verbindung erfolgreich hergestellt.": "Connexion établie.", + "Gespeichert, aber die Verbindung schlug fehl:\n\n%s": + "Enregistré, mais la connexion a échoué :\n\n%s", + + "Dokument aus Paperless öffnen": "Ouvrir un document depuis Paperless", + "Leer lassen für die zuletzt hinzugefügten Dokumente. " + "Archiv-Seriennummer funktioniert ebenfalls.": + "Laisser vide pour les documents ajoutés récemment. " + "Le numéro de série d'archive fonctionne également.", + "Öffnen als": "Ouvrir en", + "Bearbeitbar": "Modifiable", + "Schreibgeschützt": "Lecture seule", + "Keine Treffer.": "Aucun résultat.", + "Fehler bei der Suche: %s": "Échec de la recherche : %s", + "Versionen werden geladen …": "Chargement des versions …", + "Kein Zieldokument gewählt.": "Aucun document cible sélectionné.", + "Bearbeitbar. Änderungen können als neue Version abgelegt " + "werden.": + "Modifiable. Les modifications peuvent être enregistrées comme " + "nouvelle version.", + "Format nicht erkannt: %s\nBearbeitbar öffnen ist möglich — beim " + "Zurückschreiben wird ODF verwendet.": + "Format non reconnu : %s\nL'ouverture en modification est " + "possible — le format ODF sera utilisé lors de l'enregistrement.", + "%s ist in LibreOffice nicht sinnvoll bearbeitbar.\n" + "Schreibgeschützt empfohlen — bearbeitbar ist trotzdem wählbar.": + "%s ne peut pas être modifié utilement dans LibreOffice.\n" + "Lecture seule recommandée — la modification reste possible.", + "Schreibgeschützt geöffnet.\n\nZum Ablegen einer neuen Version muss " + "das Dokument bearbeitbar geöffnet werden.": + "Ouvert en lecture seule.\n\nPour enregistrer une nouvelle " + "version, le document doit être ouvert en modification.", + + "In Paperless ablegen": "Enregistrer dans Paperless", + "Neues Dokument anlegen": "Créer un nouveau document", + "An bestehendes Dokument anhängen": "Ajouter à un document existant", + "Klassifizierung des geöffneten Dokuments übernehmen": + "Reprendre le classement du document ouvert", + "Danach mit dem Zieldokument weiterarbeiten": + "Poursuivre ensuite avec le document cible", + "Das geöffnete Fenster wird dem Ziel zugeordnet. Weitere " + "Versionen landen dann dort.": + "La fenêtre ouverte sera associée à la cible. Les versions " + "suivantes y seront enregistrées.", + "Kein Dokument geöffnet.": "Aucun document ouvert.", + "Als neue Version im Archiv ablegen?\n\n": + "Enregistrer comme nouvelle version dans l'archive ?\n\n", + "Die bisherige Fassung bleibt als Version erhalten.": + "La version précédente est conservée.", + "Neue Version abgelegt.\n\n": "Nouvelle version enregistrée.\n\n", + "Neues Dokument angelegt.\n\n": "Nouveau document créé.\n\n", + "Als neue Version angehängt.\n\n": "Ajouté comme nouvelle version.\n\n", + "Die geöffnete Datei als neue Version anhängen?\n\n": + "Ajouter le fichier ouvert comme nouvelle version ?\n\n", + "Unerwartete Antwort beim Hochladen:\n%r": + "Réponse inattendue lors de l'envoi :\n%r", + "Das Dokument konnte nicht zwischengespeichert werden:\n%s": + "Le document n'a pas pu être mis en cache :\n%s", + "Speichern fehlgeschlagen:\n%s": "Échec de l'enregistrement :\n%s", + + "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", + "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", + + "Dokument teilen": "Partager le document", + "Gültigkeit in Tagen": "Validité en jours", + "0 = unbegrenzt gültig": "0 = valable sans limite", + "Dateivariante": "Variante du fichier", + "Original": "Original", + "Archivfassung (PDF)": "Version archivée (PDF)", + "Dokument mit einem Tag kennzeichnen": + "Marquer le document d'une étiquette", + "Macht geteilte Dokumente auffindbar. Ist der Tag noch nicht " + "vorhanden, wird er nach Rückfrage angelegt.": + "Permet de retrouver les documents partagés. Si l'étiquette " + "n'existe pas, elle sera créée après confirmation.", + "Freigabelink erzeugt": "Lien de partage créé", + "Nicht abgelegte Änderungen": "Modifications non enregistrées", + "Trotzdem teilen": "Partager quand même", + "Erst neue Version ablegen": "Enregistrer d'abord une nouvelle version", + + "Paperless — Status": "Paperless — État", + "Status anzeigen": "Afficher l'état", + + # -- Nachträge + "Link (zum Kopieren markieren):": "Lien (sélectionner pour copier) :", + "leer = Vorgabe. Nur ändern, wenn ein Proxy blockt.": + "vide = valeur par défaut. À modifier uniquement si un proxy " + "bloque.", + "Dieses Dokument stammt nicht aus Paperless.\n\n" + "Es wurde keine Paperless-Kennung gefunden. Öffne es " + "über „Aus Paperless öffnen“, damit die " + "Zuordnung erhalten bleibt.": + "Ce document ne provient pas de Paperless.\n\n" + "Aucun identifiant Paperless n'a été trouvé. Ouvrez-le via " + "\u00ab Ouvrir depuis Paperless \u00bb afin de conserver " + "l'association.", + "Dieses Dokument liegt noch nicht in Paperless.\n\n" + "Ein Freigabelink kann erst erzeugt werden, wenn es " + "abgelegt ist.\n\nJetzt ablegen?": + "Ce document n'est pas encore dans Paperless.\n\n" + "Un lien de partage ne peut être créé qu'une fois le document " + "enregistré.\n\nL'enregistrer maintenant ?", + "Das geöffnete Dokument enthält Änderungen, die noch nicht " + "in Paperless abgelegt sind.\n\nDer Link würde auf die " + "zuletzt abgelegte Fassung zeigen, nicht auf das, was du " + "gerade vor dir hast.": + "Le document ouvert contient des modifications qui n'ont pas été " + "enregistrées dans Paperless.\n\nLe lien pointerait vers le " + "dernier état enregistré, et non vers ce que vous voyez.", +} + +KATALOG = {"en": EN, "fr": FR} diff --git a/symbole-vorschau.png b/symbole-vorschau.png new file mode 100644 index 0000000000000000000000000000000000000000..0a43ae9a27949216fde743d0915e033beeb8ff2e GIT binary patch literal 36711 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 r@xP8EGP(AEz1