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 = ['',
+ '")
+ 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