Initial commit

This commit is contained in:
2026-09-04 19:49:43 +02:00
parent 7ce9248e95
commit 30faf00ba2
47 changed files with 3946 additions and 0 deletions

35
.gitignore vendored Normal file
View File

@@ -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.

371
Addons.xcu Normal file
View File

@@ -0,0 +1,371 @@
<?xml version="1.0" encoding="UTF-8"?>
<oor:component-data xmlns:oor="http://openoffice.org/2001/registry"
xmlns:xs="http://www.w3.org/2001/XMLSchema"
oor:name="Addons"
oor:package="org.openoffice.Office">
<node oor:name="AddonUI">
<!-- Eigenes Menü unter "Extras" -->
<node oor:name="OfficeMenuBar">
<node oor:name="de.mmorath.paperless" oor:op="replace">
<prop oor:name="Title" oor:type="xs:string">
<value/>
<value xml:lang="de">Paperless-Connector</value>
<value xml:lang="fr">Paperless-Connector</value>
<value xml:lang="en-US">Paperless-Connector</value>
</prop>
<prop oor:name="Target" oor:type="xs:string">
<value>_self</value>
</prop>
<node oor:name="Submenu">
<node oor:name="m01" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>paperless.Command:Open</value>
</prop>
<prop oor:name="Title" oor:type="xs:string">
<value/>
<value xml:lang="de">Aus Paperless öffnen…</value>
<value xml:lang="fr">Ouvrir depuis Paperless…</value>
<value xml:lang="en-US">Open from Paperless…</value>
</prop>
<prop oor:name="Target" oor:type="xs:string"><value>_self</value></prop>
</node>
<node oor:name="m02" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>paperless.Command:SaveVersion</value>
</prop>
<prop oor:name="Title" oor:type="xs:string">
<value/>
<value xml:lang="de">Als neue Version ablegen</value>
<value xml:lang="fr">Enregistrer comme nouvelle version</value>
<value xml:lang="en-US">Store as new version</value>
</prop>
<prop oor:name="Target" oor:type="xs:string"><value>_self</value></prop>
</node>
<node oor:name="m02b" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>paperless.Command:SaveAs</value>
</prop>
<prop oor:name="Title" oor:type="xs:string">
<value/>
<value xml:lang="de">In Paperless ablegen…</value>
<value xml:lang="fr">Enregistrer dans Paperless…</value>
<value xml:lang="en-US">Store in Paperless…</value>
</prop>
<prop oor:name="Target" oor:type="xs:string"><value>_self</value></prop>
</node>
<node oor:name="m03" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>private:separator</value>
</prop>
</node>
<node oor:name="m04" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>paperless.Command:Classify</value>
</prop>
<prop oor:name="Title" oor:type="xs:string">
<value/>
<value xml:lang="de">Klassifizierung bearbeiten…</value>
<value xml:lang="fr">Modifier le classement…</value>
<value xml:lang="en-US">Edit classification…</value>
</prop>
<prop oor:name="Target" oor:type="xs:string"><value>_self</value></prop>
</node>
<node oor:name="m05" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>paperless.Command:Share</value>
</prop>
<prop oor:name="Title" oor:type="xs:string">
<value/>
<value xml:lang="de">Freigabelink erzeugen…</value>
<value xml:lang="fr">Créer un lien de partage…</value>
<value xml:lang="en-US">Create share link…</value>
</prop>
<prop oor:name="Target" oor:type="xs:string"><value>_self</value></prop>
</node>
<node oor:name="m06" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>private:separator</value>
</prop>
</node>
<node oor:name="m07" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>paperless.Command:Info</value>
</prop>
<prop oor:name="Title" oor:type="xs:string">
<value/>
<value xml:lang="de">Status anzeigen</value>
<value xml:lang="fr">Afficher l'état</value>
<value xml:lang="en-US">Show status</value>
</prop>
<prop oor:name="Target" oor:type="xs:string"><value>_self</value></prop>
</node>
<node oor:name="m08" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>paperless.Command:Settings</value>
</prop>
<prop oor:name="Title" oor:type="xs:string">
<value/>
<value xml:lang="de">Verbindung einrichten…</value>
<value xml:lang="fr">Configurer la connexion…</value>
<value xml:lang="en-US">Configure connection…</value>
</prop>
<prop oor:name="Target" oor:type="xs:string"><value>_self</value></prop>
</node>
</node>
</node>
</node>
<!-- Eigene Symbolleiste -->
<node oor:name="OfficeToolBar">
<node oor:name="de.mmorath.paperless.bar" oor:op="replace">
<!-- Markensymbol am Anfang der Leiste. Es liegt auf
"Status anzeigen", ist also nicht nur Zierde. -->
<node oor:name="t00" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>paperless.Command:Info</value>
</prop>
<prop oor:name="Title" oor:type="xs:string">
<value/>
<value xml:lang="de">Paperless</value>
<value xml:lang="fr">Paperless</value>
<value xml:lang="en-US">Paperless</value>
</prop>
<prop oor:name="Target" oor:type="xs:string"><value>_self</value></prop>
<prop oor:name="Context" oor:type="xs:string">
<value>com.sun.star.text.TextDocument,com.sun.star.sheet.SpreadsheetDocument,com.sun.star.presentation.PresentationDocument,com.sun.star.drawing.DrawingDocument</value>
</prop>
</node>
<node oor:name="t00b" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>private:separator</value>
</prop>
</node>
<node oor:name="t01" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>paperless.Command:Open</value>
</prop>
<prop oor:name="Title" oor:type="xs:string">
<value/>
<value xml:lang="de">Öffnen</value>
<value xml:lang="fr">Ouvrir</value>
<value xml:lang="en-US">Open</value>
</prop>
<prop oor:name="Target" oor:type="xs:string"><value>_self</value></prop>
<prop oor:name="Context" oor:type="xs:string">
<value>com.sun.star.text.TextDocument,com.sun.star.sheet.SpreadsheetDocument,com.sun.star.presentation.PresentationDocument,com.sun.star.drawing.DrawingDocument</value>
</prop>
</node>
<node oor:name="t02" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>paperless.Command:SaveVersion</value>
</prop>
<prop oor:name="Title" oor:type="xs:string">
<value/>
<value xml:lang="de">Neue Version</value>
<value xml:lang="fr">Nouvelle version</value>
<value xml:lang="en-US">New version</value>
</prop>
<prop oor:name="Target" oor:type="xs:string"><value>_self</value></prop>
<prop oor:name="Context" oor:type="xs:string">
<value>com.sun.star.text.TextDocument,com.sun.star.sheet.SpreadsheetDocument,com.sun.star.presentation.PresentationDocument,com.sun.star.drawing.DrawingDocument</value>
</prop>
</node>
<node oor:name="t02b" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>paperless.Command:SaveAs</value>
</prop>
<prop oor:name="Title" oor:type="xs:string">
<value/>
<value xml:lang="de">Ablegen unter</value>
<value xml:lang="fr">Enregistrer sous</value>
<value xml:lang="en-US">Store as</value>
</prop>
<prop oor:name="Target" oor:type="xs:string"><value>_self</value></prop>
<prop oor:name="Context" oor:type="xs:string">
<value>com.sun.star.text.TextDocument,com.sun.star.sheet.SpreadsheetDocument,com.sun.star.presentation.PresentationDocument,com.sun.star.drawing.DrawingDocument</value>
</prop>
</node>
<node oor:name="t03" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>paperless.Command:Classify</value>
</prop>
<prop oor:name="Title" oor:type="xs:string">
<value/>
<value xml:lang="de">Klassifizierung</value>
<value xml:lang="fr">Classement</value>
<value xml:lang="en-US">Classification</value>
</prop>
<prop oor:name="Target" oor:type="xs:string"><value>_self</value></prop>
<prop oor:name="Context" oor:type="xs:string">
<value>com.sun.star.text.TextDocument,com.sun.star.sheet.SpreadsheetDocument,com.sun.star.presentation.PresentationDocument,com.sun.star.drawing.DrawingDocument</value>
</prop>
</node>
<node oor:name="t04" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>paperless.Command:Share</value>
</prop>
<prop oor:name="Title" oor:type="xs:string">
<value/>
<value xml:lang="de">Teilen</value>
<value xml:lang="fr">Partager</value>
<value xml:lang="en-US">Share</value>
</prop>
<prop oor:name="Target" oor:type="xs:string"><value>_self</value></prop>
<prop oor:name="Context" oor:type="xs:string">
<value>com.sun.star.text.TextDocument,com.sun.star.sheet.SpreadsheetDocument,com.sun.star.presentation.PresentationDocument,com.sun.star.drawing.DrawingDocument</value>
</prop>
</node>
<node oor:name="t05" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>private:separator</value>
</prop>
</node>
<node oor:name="t06" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>paperless.Command:Settings</value>
</prop>
<prop oor:name="Title" oor:type="xs:string">
<value/>
<value xml:lang="de">Verbindung</value>
<value xml:lang="fr">Connexion</value>
<value xml:lang="en-US">Connection</value>
</prop>
<prop oor:name="Target" oor:type="xs:string"><value>_self</value></prop>
<prop oor:name="Context" oor:type="xs:string">
<value>com.sun.star.text.TextDocument,com.sun.star.sheet.SpreadsheetDocument,com.sun.star.presentation.PresentationDocument,com.sun.star.drawing.DrawingDocument</value>
</prop>
</node>
</node>
</node>
<!-- Symbole. %origin% löst sich auf das Wurzelverzeichnis der
installierten Erweiterung auf.
Verwendet werden SVG, damit LibreOffice sie auf die eingestellte
Symbolgröße skaliert (Extras → Optionen → Ansicht → Symbolgröße).
Mit PNG wären nur zwei feste Größen möglich, und bei "Sehr groß"
würde hochgerechnet.
Sollte die SVG-Unterstützung in einer Version nicht greifen und
gar kein Symbol erscheinen, liegen im Paket weiterhin PNG in 16,
26 und 32 Pixeln. Dann hier auf
%origin%/icons/paperless-NAME_16.png (ImageSmallURL)
%origin%/icons/paperless-NAME_26.png (ImageBigURL)
umstellen. -->
<node oor:name="Images">
<node oor:name="de.mmorath.paperless.img.open" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>paperless.Command:Open</value>
</prop>
<node oor:name="UserDefinedImages">
<prop oor:name="ImageSmallURL" oor:type="xs:string">
<value>%origin%/icons/paperless-open.svg</value>
</prop>
<prop oor:name="ImageBigURL" oor:type="xs:string">
<value>%origin%/icons/paperless-open.svg</value>
</prop>
</node>
</node>
<node oor:name="de.mmorath.paperless.img.version" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>paperless.Command:SaveVersion</value>
</prop>
<node oor:name="UserDefinedImages">
<prop oor:name="ImageSmallURL" oor:type="xs:string">
<value>%origin%/icons/paperless-version.svg</value>
</prop>
<prop oor:name="ImageBigURL" oor:type="xs:string">
<value>%origin%/icons/paperless-version.svg</value>
</prop>
</node>
</node>
<node oor:name="de.mmorath.paperless.img.saveas" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>paperless.Command:SaveAs</value>
</prop>
<node oor:name="UserDefinedImages">
<prop oor:name="ImageSmallURL" oor:type="xs:string">
<value>%origin%/icons/paperless-saveas.svg</value>
</prop>
<prop oor:name="ImageBigURL" oor:type="xs:string">
<value>%origin%/icons/paperless-saveas.svg</value>
</prop>
</node>
</node>
<node oor:name="de.mmorath.paperless.img.classify" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>paperless.Command:Classify</value>
</prop>
<node oor:name="UserDefinedImages">
<prop oor:name="ImageSmallURL" oor:type="xs:string">
<value>%origin%/icons/paperless-classify.svg</value>
</prop>
<prop oor:name="ImageBigURL" oor:type="xs:string">
<value>%origin%/icons/paperless-classify.svg</value>
</prop>
</node>
</node>
<node oor:name="de.mmorath.paperless.img.share" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>paperless.Command:Share</value>
</prop>
<node oor:name="UserDefinedImages">
<prop oor:name="ImageSmallURL" oor:type="xs:string">
<value>%origin%/icons/paperless-share.svg</value>
</prop>
<prop oor:name="ImageBigURL" oor:type="xs:string">
<value>%origin%/icons/paperless-share.svg</value>
</prop>
</node>
</node>
<node oor:name="de.mmorath.paperless.img.logo" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>paperless.Command:Info</value>
</prop>
<node oor:name="UserDefinedImages">
<prop oor:name="ImageSmallURL" oor:type="xs:string">
<value>%origin%/icons/paperless-logo.svg</value>
</prop>
<prop oor:name="ImageBigURL" oor:type="xs:string">
<value>%origin%/icons/paperless-logo.svg</value>
</prop>
</node>
</node>
<node oor:name="de.mmorath.paperless.img.settings" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>paperless.Command:Settings</value>
</prop>
<node oor:name="UserDefinedImages">
<prop oor:name="ImageSmallURL" oor:type="xs:string">
<value>%origin%/icons/paperless-settings.svg</value>
</prop>
<prop oor:name="ImageBigURL" oor:type="xs:string">
<value>%origin%/icons/paperless-settings.svg</value>
</prop>
</node>
</node>
</node>
</node>
</oor:component-data>

16
META-INF/manifest.xml Normal file
View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="http://openoffice.org/2001/manifest">
<manifest:file-entry
manifest:media-type="application/vnd.sun.star.configuration-data"
manifest:full-path="Addons.xcu"/>
<manifest:file-entry
manifest:media-type="application/vnd.sun.star.configuration-data"
manifest:full-path="ProtocolHandler.xcu"/>
<manifest:file-entry
manifest:media-type="application/vnd.sun.star.uno-component;type=Python"
manifest:full-path="paperless_addon.py"/>
</manifest:manifest>

13
ProtocolHandler.xcu Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<oor:component-data xmlns:oor="http://openoffice.org/2001/registry"
xmlns:xs="http://www.w3.org/2001/XMLSchema"
oor:name="ProtocolHandler"
oor:package="org.openoffice.Office">
<node oor:name="HandlerSet">
<node oor:name="de.mmorath.paperless.AddonImpl" oor:op="replace">
<prop oor:name="Protocols" oor:type="oor:string-list">
<value>paperless.Command:*</value>
</prop>
</node>
</node>
</oor:component-data>

200
SICHERHEIT.md Normal file
View File

@@ -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
`<Benutzerprofil>/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

71
build.sh Normal file
View File

@@ -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/.*<version value="\([^"]*\)".*/\1/p' description.xml)"
OUT="${NAME}-${VERSION}.oxt"
# Vollständigkeit prüfen. zip überspringt fehlende Pfade stillschweigend -
# ohne diese Prüfung entstünde ein Paket, das sich zwar bauen lässt, aber
# beim Installieren nur "Medientyp konnte nicht ermittelt werden" liefert.
FEHLT=""
for f in META-INF/manifest.xml description.xml Addons.xcu \
ProtocolHandler.xcu paperless_addon.py LICENSE \
pythonpath/paperless_client.py pythonpath/paperless_config.py \
pythonpath/paperless_dialogs.py pythonpath/paperless_formats.py \
pythonpath/paperless_i18n.py \
icons/paperless-logo.svg icons/paperless-logo_32.png
do
[ -e "$f" ] || FEHLT="$FEHLT $f\n"
done
if [ -n "$FEHLT" ]; then
printf "ABBRUCH - diese Dateien fehlen:\n%b" "$FEHLT" >&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"

13
description-de.txt Normal file
View File

@@ -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.

12
description-en.txt Normal file
View File

@@ -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.

13
description-fr.txt Normal file
View File

@@ -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.

75
description.xml Normal file
View File

@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<description xmlns="http://openoffice.org/extensions/description/2006"
xmlns:d="http://openoffice.org/extensions/description/2006"
xmlns:lo="http://libreoffice.org/extensions/description/2011"
xmlns:xlink="http://www.w3.org/1999/xlink">
<identifier value="de.mmorath.paperless"/>
<version value="1.1.1"/>
<display-name>
<name lang="de">Paperless-Connector</name>
<name lang="en">Paperless-Connector</name>
<name lang="fr">Paperless-Connector</name>
</display-name>
<!-- HERAUSGEBERANGABE
Vor einer Veröffentlichung durch eigene Angaben ersetzen. Sie
erscheint im Extension Manager und ist bei einer Verbreitung in
Deutschland Teil der Anbieterkennzeichnung nach § 5 DDG. -->
<publisher>
<name xlink:href="https://gitea.cloud.mmorath.de/marco.morath/libreoffice-paperless-connector"
lang="de">Marco Morath</name>
<name xlink:href="https://gitea.cloud.mmorath.de/marco.morath/libreoffice-paperless-connector"
lang="en">Marco Morath</name>
<name xlink:href="https://gitea.cloud.mmorath.de/marco.morath/libreoffice-paperless-connector"
lang="fr">Marco Morath</name>
</publisher>
<extension-description>
<src lang="de" xlink:href="description-de.txt"/>
<src lang="en" xlink:href="description-en.txt"/>
<src lang="fr" xlink:href="description-fr.txt"/>
</extension-description>
<!-- Lizenz.
"accept-by=admin" fragt einmal je Installation, "user" jeden
Benutzer. suppress-on-update verhindert die erneute Abfrage bei
Aktualisierungen.
WICHTIG: Der Lizenztext gehört unter registration/ und braucht eine
.txt-Endung. Eine Datei ohne Endung im Wurzelverzeichnis wird beim
Installieren mit "Could not read file ... NOT_EXISTING" abgewiesen,
obwohl sie im Paket liegt - LibreOffice erkennt sie über den
Paketzugriff nicht als lesbaren Text.
Die Datei LICENSE im Wurzelverzeichnis bleibt zusätzlich bestehen,
weil Git-Oberflächen dort danach suchen. Beide haben denselben
Inhalt; erzeugt wird die Kopie von build.sh. -->
<registration>
<simple-license accept-by="admin" default-license-id="lic-mit"
suppress-on-update="true">
<license-text xlink:href="registration/license-en.txt" lang="en"
license-id="lic-mit"/>
</simple-license>
</registration>
<dependencies>
<!-- ACHTUNG: OpenOffice.org-minimal-version bezieht sich auf die
OpenOffice-REFERENZVERSION. Die ist bei LibreOffice dauerhaft auf
4.1 eingefroren, unabhängig von der tatsächlichen Version. Ein
Wert wie 7.0 schließt hier JEDE Installation aus.
Für echte LibreOffice-Versionen gibt es
lo:LibreOffice-minimal-version im eigenen Namensraum. -->
<OpenOffice.org-minimal-version value="4.1"
d:name="OpenOffice.org 4.1 / LibreOffice"/>
<lo:LibreOffice-minimal-version value="7.0"
d:name="LibreOffice 7.0"/>
</dependencies>
<icon>
<default xlink:href="icons/paperless-logo_32.png"/>
</icon>
</description>

16
icons/README.txt Normal file
View File

@@ -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

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<polygon points="0.6,0.5 13,0.5 18.4,5.9 18.4,23.4 0.6,23.4" fill="#fcfcfa" stroke="#175420" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<polygon points="13,0.5 18.4,5.9 13,5.9" fill="#175420"/>
<line x1="2.8" y1="7.9" x2="13.2" y2="7.9" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="2.8" y1="11.7" x2="15.4" y2="11.7" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="2.8" y1="15.5" x2="11.2" y2="15.5" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="16.9" cy="17.1" r="7.8" fill="#ffffff"/>
<circle cx="16.9" cy="17.1" r="6.7" fill="#703e98"/>
<polygon points="13.3,16.6 17.3,13.1 20.6,16.5 16.7,20.7" fill="#fcfcfa"/>
<circle cx="18.3" cy="15.6" r="0.9" fill="#703e98"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 887 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

9
icons/paperless-logo.svg Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect x="0.4" y="0.4" width="23.2" height="23.2" rx="4" fill="#175420"/>
<polygon points="5,3.9 14.6,3.9 19,8.3 19,20.1 5,20.1" fill="#fcfcfa"/>
<polygon points="14.6,3.9 19,8.3 14.6,8.3" fill="#3a8a44"/>
<line x1="7" y1="9.9" x2="14.4" y2="9.9" fill="none" stroke="#175420" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="7" y1="13.3" x2="16.2" y2="13.3" fill="none" stroke="#175420" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="7" y1="16.7" x2="13" y2="16.7" fill="none" stroke="#175420" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 763 B

BIN
icons/paperless-logo_16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 B

BIN
icons/paperless-logo_26.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 B

BIN
icons/paperless-logo_32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

12
icons/paperless-open.svg Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<polygon points="0.6,0.5 13,0.5 18.4,5.9 18.4,23.4 0.6,23.4" fill="#fcfcfa" stroke="#175420" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<polygon points="13,0.5 18.4,5.9 13,5.9" fill="#175420"/>
<line x1="2.8" y1="7.9" x2="13.2" y2="7.9" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="2.8" y1="11.7" x2="15.4" y2="11.7" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="2.8" y1="15.5" x2="11.2" y2="15.5" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="16.9" cy="17.1" r="7.8" fill="#ffffff"/>
<circle cx="16.9" cy="17.1" r="6.7" fill="#1e60a8"/>
<polygon points="16.9,20.6 14,17.4 19.8,17.4" fill="#fcfcfa"/>
<line x1="16.9" y1="14.125" x2="16.9" y2="17.7" fill="none" stroke="#fcfcfa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
icons/paperless-open_16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 885 B

BIN
icons/paperless-open_26.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
icons/paperless-open_32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<polygon points="4.4,0.5 18.4,0.5 18.4,4.6 4.4,4.6" fill="#fcfcfa" stroke="#3a8a44" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polygon points="0.6,0.5 13,0.5 18.4,5.9 18.4,23.4 0.6,23.4" fill="#fcfcfa" stroke="#175420" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<polygon points="13,0.5 18.4,5.9 13,5.9" fill="#175420"/>
<line x1="2.8" y1="7.9" x2="13.2" y2="7.9" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="2.8" y1="11.7" x2="15.4" y2="11.7" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="2.8" y1="15.5" x2="11.2" y2="15.5" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="16.9" cy="17.1" r="7.8" fill="#ffffff"/>
<circle cx="16.9" cy="17.1" r="6.7" fill="#c86a14"/>
<polygon points="15.7,13.6 12.8,16.8 18.6,16.8" fill="#fcfcfa"/>
<line x1="15.7" y1="16.5" x2="15.7" y2="20.075" fill="none" stroke="#fcfcfa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="18.4" y1="14.5" x2="22.2" y2="14.5" fill="none" stroke="#fcfcfa" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="20.3" y1="12.6" x2="20.3" y2="16.4" fill="none" stroke="#fcfcfa" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="18.97" y1="13.17" x2="21.63" y2="15.83" fill="none" stroke="#fcfcfa" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="21.63" y1="13.17" x2="18.97" y2="15.83" fill="none" stroke="#fcfcfa" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 899 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<polygon points="0.6,0.5 13,0.5 18.4,5.9 18.4,23.4 0.6,23.4" fill="#fcfcfa" stroke="#175420" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<polygon points="13,0.5 18.4,5.9 13,5.9" fill="#175420"/>
<line x1="2.8" y1="7.9" x2="13.2" y2="7.9" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="2.8" y1="11.7" x2="15.4" y2="11.7" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="2.8" y1="15.5" x2="11.2" y2="15.5" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="16.9" cy="17.1" r="7.8" fill="#ffffff"/>
<circle cx="16.9" cy="17.1" r="6.7" fill="#5c6064"/>
<line x1="19.28" y1="17.1" x2="21.9" y2="17.1" fill="none" stroke="#fcfcfa" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="18.5829" y1="18.7829" x2="20.4355" y2="20.6355" fill="none" stroke="#fcfcfa" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="16.9" y1="19.48" x2="16.9" y2="22.1" fill="none" stroke="#fcfcfa" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="15.2171" y1="18.7829" x2="13.3645" y2="20.6355" fill="none" stroke="#fcfcfa" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="14.52" y1="17.1" x2="11.9" y2="17.1" fill="none" stroke="#fcfcfa" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="15.2171" y1="15.4171" x2="13.3645" y2="13.5645" fill="none" stroke="#fcfcfa" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="16.9" y1="14.72" x2="16.9" y2="12.1" fill="none" stroke="#fcfcfa" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="18.5829" y1="15.4171" x2="20.4355" y2="13.5645" fill="none" stroke="#fcfcfa" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="16.9" cy="17.1" r="3.4" fill="none" stroke="#fcfcfa" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="16.9" cy="17.1" r="1.6" fill="#5c6064"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 908 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

15
icons/paperless-share.svg Normal file
View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<polygon points="0.6,0.5 13,0.5 18.4,5.9 18.4,23.4 0.6,23.4" fill="#fcfcfa" stroke="#175420" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<polygon points="13,0.5 18.4,5.9 13,5.9" fill="#175420"/>
<line x1="2.8" y1="7.9" x2="13.2" y2="7.9" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="2.8" y1="11.7" x2="15.4" y2="11.7" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="2.8" y1="15.5" x2="11.2" y2="15.5" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="16.9" cy="17.1" r="7.8" fill="#ffffff"/>
<circle cx="16.9" cy="17.1" r="6.7" fill="#117a7a"/>
<line x1="13.7" y1="17.1" x2="19.5" y2="13.9" fill="none" stroke="#fcfcfa" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="13.7" y1="17.1" x2="19.5" y2="20.3" fill="none" stroke="#fcfcfa" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="19.5" cy="13.9" r="1.65" fill="#fcfcfa"/>
<circle cx="19.5" cy="20.3" r="1.65" fill="#fcfcfa"/>
<circle cx="13.7" cy="17.1" r="1.65" fill="#fcfcfa"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<polygon points="0.6,0.5 13,0.5 18.4,5.9 18.4,23.4 0.6,23.4" fill="#fcfcfa" stroke="#175420" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<polygon points="13,0.5 18.4,5.9 13,5.9" fill="#175420"/>
<line x1="2.8" y1="7.9" x2="13.2" y2="7.9" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="2.8" y1="11.7" x2="15.4" y2="11.7" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="2.8" y1="15.5" x2="11.2" y2="15.5" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="16.9" cy="17.1" r="7.8" fill="#ffffff"/>
<circle cx="16.9" cy="17.1" r="6.7" fill="#c86a14"/>
<polygon points="15.7,13.6 12.8,16.8 18.6,16.8" fill="#fcfcfa"/>
<line x1="15.7" y1="16.5" x2="15.7" y2="20.075" fill="none" stroke="#fcfcfa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="18.6" y1="14.5" x2="22" y2="14.5" fill="none" stroke="#fcfcfa" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="20.3" y1="12.8" x2="20.3" y2="16.2" fill="none" stroke="#fcfcfa" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

283
make_icons.py Normal file
View File

@@ -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 = ['<?xml version="1.0" encoding="UTF-8"?>',
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" '
'viewBox="0 0 %g %g">' % (BOX, BOX)]
def attrs(fill, stroke, width):
a = ' fill="%s"' % (fill or "none")
if stroke:
a += (' stroke="%s" stroke-width="%g" stroke-linecap="round"'
' stroke-linejoin="round"' % (stroke, width))
return a
for kind, data, fill, stroke, width in shapes:
if kind == "poly":
pts = " ".join("%g,%g" % p for p in data)
out.append(' <polygon points="%s"%s/>'
% (pts, attrs(fill, stroke, width)))
elif kind == "circle":
cx, cy, r = data
out.append(' <circle cx="%g" cy="%g" r="%g"%s/>'
% (cx, cy, r, attrs(fill, stroke, width)))
elif kind == "line":
(x1, y1), (x2, y2) = data
out.append(' <line x1="%g" y1="%g" x2="%g" y2="%g"%s/>'
% (x1, y1, x2, y2, attrs(None, stroke, width)))
elif kind == "rrect":
x0, y0, x1, y1, r = data
out.append(' <rect x="%g" y="%g" width="%g" height="%g" '
'rx="%g"%s/>'
% (x0, y0, x1 - x0, y1 - y0, r,
attrs(fill, stroke, width)))
out.append("</svg>")
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()

743
paperless_addon.py Normal file
View File

@@ -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,))

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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]

View File

@@ -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": "",
"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}

BIN
symbole-vorschau.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB