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 0000000..0a951c5 Binary files /dev/null and b/icons/paperless-classify_16.png differ diff --git a/icons/paperless-classify_26.png b/icons/paperless-classify_26.png new file mode 100644 index 0000000..04119dd Binary files /dev/null and b/icons/paperless-classify_26.png differ diff --git a/icons/paperless-classify_32.png b/icons/paperless-classify_32.png new file mode 100644 index 0000000..3df6bcd Binary files /dev/null and b/icons/paperless-classify_32.png differ 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 0000000..cca5463 Binary files /dev/null and b/icons/paperless-logo_16.png differ diff --git a/icons/paperless-logo_26.png b/icons/paperless-logo_26.png new file mode 100644 index 0000000..a07aeeb Binary files /dev/null and b/icons/paperless-logo_26.png differ diff --git a/icons/paperless-logo_32.png b/icons/paperless-logo_32.png new file mode 100644 index 0000000..4471dfc Binary files /dev/null and b/icons/paperless-logo_32.png differ 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 0000000..d8af0b8 Binary files /dev/null and b/icons/paperless-open_16.png differ diff --git a/icons/paperless-open_26.png b/icons/paperless-open_26.png new file mode 100644 index 0000000..226b0ff Binary files /dev/null and b/icons/paperless-open_26.png differ diff --git a/icons/paperless-open_32.png b/icons/paperless-open_32.png new file mode 100644 index 0000000..323ab4b Binary files /dev/null and b/icons/paperless-open_32.png differ diff --git a/icons/paperless-saveas.svg b/icons/paperless-saveas.svg new file mode 100644 index 0000000..7e628ef --- /dev/null +++ b/icons/paperless-saveas.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/icons/paperless-saveas_16.png b/icons/paperless-saveas_16.png new file mode 100644 index 0000000..4bd7de6 Binary files /dev/null and b/icons/paperless-saveas_16.png differ diff --git a/icons/paperless-saveas_26.png b/icons/paperless-saveas_26.png new file mode 100644 index 0000000..3c305a1 Binary files /dev/null and b/icons/paperless-saveas_26.png differ diff --git a/icons/paperless-saveas_32.png b/icons/paperless-saveas_32.png new file mode 100644 index 0000000..89c3208 Binary files /dev/null and b/icons/paperless-saveas_32.png differ diff --git a/icons/paperless-settings.svg b/icons/paperless-settings.svg new file mode 100644 index 0000000..5b2a385 --- /dev/null +++ b/icons/paperless-settings.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/icons/paperless-settings_16.png b/icons/paperless-settings_16.png new file mode 100644 index 0000000..75fab42 Binary files /dev/null and b/icons/paperless-settings_16.png differ diff --git a/icons/paperless-settings_26.png b/icons/paperless-settings_26.png new file mode 100644 index 0000000..5030bd1 Binary files /dev/null and b/icons/paperless-settings_26.png differ diff --git a/icons/paperless-settings_32.png b/icons/paperless-settings_32.png new file mode 100644 index 0000000..949daa9 Binary files /dev/null and b/icons/paperless-settings_32.png differ 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 0000000..8bb0136 Binary files /dev/null and b/icons/paperless-share_16.png differ diff --git a/icons/paperless-share_26.png b/icons/paperless-share_26.png new file mode 100644 index 0000000..0f81e5b Binary files /dev/null and b/icons/paperless-share_26.png differ diff --git a/icons/paperless-share_32.png b/icons/paperless-share_32.png new file mode 100644 index 0000000..959b8f8 Binary files /dev/null and b/icons/paperless-share_32.png differ diff --git a/icons/paperless-version.svg b/icons/paperless-version.svg new file mode 100644 index 0000000..953713d --- /dev/null +++ b/icons/paperless-version.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/icons/paperless-version_16.png b/icons/paperless-version_16.png new file mode 100644 index 0000000..9f0cfa5 Binary files /dev/null and b/icons/paperless-version_16.png differ diff --git a/icons/paperless-version_26.png b/icons/paperless-version_26.png new file mode 100644 index 0000000..1b67595 Binary files /dev/null and b/icons/paperless-version_26.png differ diff --git a/icons/paperless-version_32.png b/icons/paperless-version_32.png new file mode 100644 index 0000000..6c51b23 Binary files /dev/null and b/icons/paperless-version_32.png differ diff --git a/make_icons.py b/make_icons.py new file mode 100644 index 0000000..1591904 --- /dev/null +++ b/make_icons.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +""" +make_icons.py - Erzeugt die Symbole der Erweiterung als SVG und PNG. + +Die Geometrie ist EINMAL beschrieben und wird von zwei Ausgaben gelesen. +So können SVG und PNG nicht auseinanderlaufen. + + SVG wird von LibreOffice skaliert und passt sich damit der eingestellten + Symbolgröße an (Extras → Optionen → Ansicht → Symbolgröße). + PNG in 16, 26 und 32 Pixeln als Rückfallebene, falls die + SVG-Unterstützung der eingesetzten Version nicht greift. + +Alle Symbole teilen dieselbe Grundform - ein Blatt mit umgeknickter Ecke in +Paperless-Grün - damit die Leiste als zusammengehörige Gruppe erkennbar +bleibt. Unterschieden wird über eine farbige Scheibe unten rechts. + +Aufruf: python3 make_icons.py (benötigt Pillow für die PNG-Ausgabe) +""" + +import math +import os + +OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "icons") + +# Zeichenfläche in Einheiten. Die Formen reichen bewusst bis dicht an den +# Rand, damit bei kleiner Darstellung möglichst viel Fläche trägt. +BOX = 24.0 +PNG_SIZES = (16, 26, 32) +SCALE = 12 # Überabtastung für die PNG-Ausgabe + +GREEN = "#175420" +GREEN_LIGHT = "#3a8a44" +PAPER = "#fcfcfa" +WHITE = "#ffffff" +BLUE = "#1e60a8" # herunterladen +ORANGE = "#c86a14" # hinauf +VIOLET = "#703e98" # klassifizieren +TEAL = "#117a7a" # teilen +GREY = "#5c6064" # einstellungen + + +# ----------------------------------------------------------- Grundformen + +def poly(points, fill=None, stroke=None, width=0.0): + return ("poly", points, fill, stroke, width) + + +def circle(cx, cy, r, fill=None, stroke=None, width=0.0): + return ("circle", (cx, cy, r), fill, stroke, width) + + +def line(p1, p2, stroke, width): + return ("line", (p1, p2), None, stroke, width) + + +def rrect(x0, y0, x1, y1, r, fill=None, stroke=None, width=0.0): + return ("rrect", (x0, y0, x1, y1, r), fill, stroke, width) + + +def sheet(colour=GREEN): + """Blatt mit umgeknickter Ecke, füllt die Fläche links oben.""" + x0, y0, x1, y1 = 0.6, 0.5, 18.4, 23.4 + fold = 5.4 + out = [ + poly([(x0, y0), (x1 - fold, y0), (x1, y0 + fold), (x1, y1), (x0, y1)], + fill=PAPER, stroke=colour, width=1.8), + poly([(x1 - fold, y0), (x1, y0 + fold), (x1 - fold, y0 + fold)], + fill=colour), + ] + for i, w in enumerate((10.4, 12.6, 8.4)): + yy = y0 + 7.4 + i * 3.8 + out.append(line((x0 + 2.2, yy), (x0 + 2.2 + w, yy), colour, 1.4)) + return out + + +def badge(colour): + """Scheibe unten rechts, mit hellem Ring zur Abgrenzung vom Blatt.""" + cx, cy, r = 16.9, 17.1, 6.7 + return cx, cy, r, [ + circle(cx, cy, r + 1.1, fill=WHITE), + circle(cx, cy, r, fill=colour), + ] + + +def arrow(cx, cy, up=True, colour=PAPER): + w, h = 2.9, 3.5 + if up: + return [poly([(cx, cy - h), (cx - w, cy - 0.3), (cx + w, cy - 0.3)], + fill=colour), + line((cx, cy - 0.6), (cx, cy + h * 0.85), colour, 2.0)] + return [poly([(cx, cy + h), (cx - w, cy + 0.3), (cx + w, cy + 0.3)], + fill=colour), + line((cx, cy - h * 0.85), (cx, cy + 0.6), colour, 2.0)] + + +# --------------------------------------------------------------- Symbole + +def icon_logo(): + """Markensymbol für den Anfang der Leiste: gefüllte Fläche, weißes Blatt.""" + out = [rrect(0.4, 0.4, 23.6, 23.6, 4.0, fill=GREEN)] + x0, y0, x1, y1 = 5.0, 3.9, 19.0, 20.1 + fold = 4.4 + out += [ + poly([(x0, y0), (x1 - fold, y0), (x1, y0 + fold), (x1, y1), (x0, y1)], + fill=PAPER), + poly([(x1 - fold, y0), (x1, y0 + fold), (x1 - fold, y0 + fold)], + fill=GREEN_LIGHT), + ] + for i, w in enumerate((7.4, 9.2, 6.0)): + yy = y0 + 6.0 + i * 3.4 + out.append(line((x0 + 2.0, yy), (x0 + 2.0 + w, yy), GREEN, 1.5)) + return out + + +def icon_open(): + out = sheet() + cx, cy, _r, b = badge(BLUE) + return out + b + arrow(cx, cy, up=False) + + +def icon_version(): + out = sheet() + cx, cy, _r, b = badge(ORANGE) + out += b + arrow(cx - 1.2, cy, up=True) + px, py, a = cx + 3.4, cy - 2.6, 1.7 + out += [line((px - a, py), (px + a, py), PAPER, 1.4), + line((px, py - a), (px, py + a), PAPER, 1.4)] + return out + + +def icon_save_as(): + # Zweites Blatt dahinter: es kann ein eigenständiges Dokument entstehen. + out = [poly([(4.4, 0.5), (18.4, 0.5), (18.4, 4.6), (4.4, 4.6)], + fill=PAPER, stroke=GREEN_LIGHT, width=1.5)] + out += sheet() + cx, cy, _r, b = badge(ORANGE) + out += b + arrow(cx - 1.2, cy, up=True) + sx, sy, a = cx + 3.4, cy - 2.6, 1.9 + for dx, dy in ((a, 0), (0, a), (a * .7, a * .7), (-a * .7, a * .7)): + out.append(line((sx - dx, sy - dy), (sx + dx, sy + dy), PAPER, 1.15)) + return out + + +def icon_classify(): + out = sheet() + cx, cy, _r, b = badge(VIOLET) + out += b + [ + poly([(cx - 3.6, cy - 0.5), (cx + 0.4, cy - 4.0), + (cx + 3.7, cy - 0.6), (cx - 0.2, cy + 3.6)], fill=PAPER), + circle(cx + 1.4, cy - 1.5, 0.9, fill=VIOLET), + ] + return out + + +def icon_share(): + out = sheet() + cx, cy, _r, b = badge(TEAL) + a, bb, c = (cx + 2.6, cy - 3.2), (cx + 2.6, cy + 3.2), (cx - 3.2, cy) + out += b + [line(c, a, PAPER, 1.3), line(c, bb, PAPER, 1.3)] + for p in (a, bb, c): + out.append(circle(p[0], p[1], 1.65, fill=PAPER)) + return out + + +def icon_settings(): + out = sheet() + cx, cy, _r, b = badge(GREY) + out += b + outer, inner, hole = 5.0, 3.4, 1.6 + for i in range(8): + ang = math.pi * i / 4 + dx, dy = math.cos(ang), math.sin(ang) + out.append(line((cx + dx * inner * .7, cy + dy * inner * .7), + (cx + dx * outer, cy + dy * outer), PAPER, 1.7)) + out += [circle(cx, cy, inner, stroke=PAPER, width=1.5), + circle(cx, cy, hole, fill=GREY)] + return out + + +ICONS = { + "paperless-logo": icon_logo, + "paperless-open": icon_open, + "paperless-version": icon_version, + "paperless-saveas": icon_save_as, + "paperless-classify": icon_classify, + "paperless-share": icon_share, + "paperless-settings": icon_settings, +} + + +# ----------------------------------------------------------- Ausgabe SVG + +def to_svg(shapes): + out = ['', + '' % (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 0000000..0a43ae9 Binary files /dev/null and b/symbole-vorschau.png differ