Initial commit
35
.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
12
icons/paperless-classify.svg
Normal 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 |
BIN
icons/paperless-classify_16.png
Normal file
|
After Width: | Height: | Size: 887 B |
BIN
icons/paperless-classify_26.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
icons/paperless-classify_32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
9
icons/paperless-logo.svg
Normal 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
|
After Width: | Height: | Size: 680 B |
BIN
icons/paperless-logo_26.png
Normal file
|
After Width: | Height: | Size: 1008 B |
BIN
icons/paperless-logo_32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
12
icons/paperless-open.svg
Normal 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
|
After Width: | Height: | Size: 885 B |
BIN
icons/paperless-open_26.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
icons/paperless-open_32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
17
icons/paperless-saveas.svg
Normal 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 |
BIN
icons/paperless-saveas_16.png
Normal file
|
After Width: | Height: | Size: 899 B |
BIN
icons/paperless-saveas_26.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
icons/paperless-saveas_32.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
20
icons/paperless-settings.svg
Normal 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 |
BIN
icons/paperless-settings_16.png
Normal file
|
After Width: | Height: | Size: 908 B |
BIN
icons/paperless-settings_26.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
icons/paperless-settings_32.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
15
icons/paperless-share.svg
Normal 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 |
BIN
icons/paperless-share_16.png
Normal file
|
After Width: | Height: | Size: 883 B |
BIN
icons/paperless-share_26.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
icons/paperless-share_32.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
14
icons/paperless-version.svg
Normal 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 |
BIN
icons/paperless-version_16.png
Normal file
|
After Width: | Height: | Size: 889 B |
BIN
icons/paperless-version_26.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
icons/paperless-version_32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
283
make_icons.py
Normal 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
@@ -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,))
|
||||
406
pythonpath/paperless_client.py
Normal 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
|
||||
250
pythonpath/paperless_config.py
Normal 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
|
||||
790
pythonpath/paperless_dialogs.py
Normal 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()
|
||||
148
pythonpath/paperless_formats.py
Normal 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]
|
||||
392
pythonpath/paperless_i18n.py
Normal 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": "N°",
|
||||
"ASN": "ASN",
|
||||
"Schließen": "Fermer",
|
||||
"Anlegen": "Créer",
|
||||
"Ablegen": "Enregistrer",
|
||||
"Erzeugen": "Créer",
|
||||
"Ziel": "Cible",
|
||||
"Tags": "Étiquettes",
|
||||
"Korrespondent": "Correspondant",
|
||||
"Dokumenttyp": "Type de document",
|
||||
"Tag": "Étiquette",
|
||||
|
||||
"Paperless — Verbindung": "Paperless — Connexion",
|
||||
"Basisadresse": "Adresse de base",
|
||||
"z. B. https://dms.example.org": "p. ex. https://dms.example.org",
|
||||
"API-Token": "Jeton d'API",
|
||||
"Im Benutzerprofil von Paperless erzeugen":
|
||||
"À créer dans votre profil utilisateur Paperless",
|
||||
"TLS-Zertifikat prüfen": "Vérifier le certificat TLS",
|
||||
"Abschalten nur für Testinstanzen mit selbst ausgestelltem "
|
||||
"Zertifikat. Ohne Prüfung ist die Verbindung angreifbar.":
|
||||
"À désactiver uniquement pour une instance de test avec un "
|
||||
"certificat auto-signé. Sans vérification, la connexion est "
|
||||
"vulnérable.",
|
||||
"Versionslabel": "Libellé de version",
|
||||
"{datum} wird ersetzt": "{datum} sera remplacé",
|
||||
"Bitte zuerst Basisadresse und Token hinterlegen.":
|
||||
"Veuillez d'abord renseigner l'adresse de base et le jeton.",
|
||||
"Verbindung erfolgreich hergestellt.": "Connexion établie.",
|
||||
"Gespeichert, aber die Verbindung schlug fehl:\n\n%s":
|
||||
"Enregistré, mais la connexion a échoué :\n\n%s",
|
||||
|
||||
"Dokument aus Paperless öffnen": "Ouvrir un document depuis Paperless",
|
||||
"Leer lassen für die zuletzt hinzugefügten Dokumente. "
|
||||
"Archiv-Seriennummer funktioniert ebenfalls.":
|
||||
"Laisser vide pour les documents ajoutés récemment. "
|
||||
"Le numéro de série d'archive fonctionne également.",
|
||||
"Öffnen als": "Ouvrir en",
|
||||
"Bearbeitbar": "Modifiable",
|
||||
"Schreibgeschützt": "Lecture seule",
|
||||
"Keine Treffer.": "Aucun résultat.",
|
||||
"Fehler bei der Suche: %s": "Échec de la recherche : %s",
|
||||
"Versionen werden geladen …": "Chargement des versions …",
|
||||
"Kein Zieldokument gewählt.": "Aucun document cible sélectionné.",
|
||||
"Bearbeitbar. Änderungen können als neue Version abgelegt "
|
||||
"werden.":
|
||||
"Modifiable. Les modifications peuvent être enregistrées comme "
|
||||
"nouvelle version.",
|
||||
"Format nicht erkannt: %s\nBearbeitbar öffnen ist möglich — beim "
|
||||
"Zurückschreiben wird ODF verwendet.":
|
||||
"Format non reconnu : %s\nL'ouverture en modification est "
|
||||
"possible — le format ODF sera utilisé lors de l'enregistrement.",
|
||||
"%s ist in LibreOffice nicht sinnvoll bearbeitbar.\n"
|
||||
"Schreibgeschützt empfohlen — bearbeitbar ist trotzdem wählbar.":
|
||||
"%s ne peut pas être modifié utilement dans LibreOffice.\n"
|
||||
"Lecture seule recommandée — la modification reste possible.",
|
||||
"Schreibgeschützt geöffnet.\n\nZum Ablegen einer neuen Version muss "
|
||||
"das Dokument bearbeitbar geöffnet werden.":
|
||||
"Ouvert en lecture seule.\n\nPour enregistrer une nouvelle "
|
||||
"version, le document doit être ouvert en modification.",
|
||||
|
||||
"In Paperless ablegen": "Enregistrer dans Paperless",
|
||||
"Neues Dokument anlegen": "Créer un nouveau document",
|
||||
"An bestehendes Dokument anhängen": "Ajouter à un document existant",
|
||||
"Klassifizierung des geöffneten Dokuments übernehmen":
|
||||
"Reprendre le classement du document ouvert",
|
||||
"Danach mit dem Zieldokument weiterarbeiten":
|
||||
"Poursuivre ensuite avec le document cible",
|
||||
"Das geöffnete Fenster wird dem Ziel zugeordnet. Weitere "
|
||||
"Versionen landen dann dort.":
|
||||
"La fenêtre ouverte sera associée à la cible. Les versions "
|
||||
"suivantes y seront enregistrées.",
|
||||
"Kein Dokument geöffnet.": "Aucun document ouvert.",
|
||||
"Als neue Version im Archiv ablegen?\n\n":
|
||||
"Enregistrer comme nouvelle version dans l'archive ?\n\n",
|
||||
"Die bisherige Fassung bleibt als Version erhalten.":
|
||||
"La version précédente est conservée.",
|
||||
"Neue Version abgelegt.\n\n": "Nouvelle version enregistrée.\n\n",
|
||||
"Neues Dokument angelegt.\n\n": "Nouveau document créé.\n\n",
|
||||
"Als neue Version angehängt.\n\n": "Ajouté comme nouvelle version.\n\n",
|
||||
"Die geöffnete Datei als neue Version anhängen?\n\n":
|
||||
"Ajouter le fichier ouvert comme nouvelle version ?\n\n",
|
||||
"Unerwartete Antwort beim Hochladen:\n%r":
|
||||
"Réponse inattendue lors de l'envoi :\n%r",
|
||||
"Das Dokument konnte nicht zwischengespeichert werden:\n%s":
|
||||
"Le document n'a pas pu être mis en cache :\n%s",
|
||||
"Speichern fehlgeschlagen:\n%s": "Échec de l'enregistrement :\n%s",
|
||||
|
||||
"Klassifizierung bearbeiten": "Modifier le classement",
|
||||
"Format JJJJ-MM-TT": "Format AAAA-MM-JJ",
|
||||
"Mehrfachauswahl mit Strg oder Umschalt":
|
||||
"Sélection multiple avec Ctrl ou Maj",
|
||||
"Klassifizierung gespeichert.": "Classement enregistré.",
|
||||
"Stammdaten konnten nicht geladen werden:\n%s":
|
||||
"Les données de référence n'ont pas pu être chargées :\n%s",
|
||||
|
||||
"Dokument teilen": "Partager le document",
|
||||
"Gültigkeit in Tagen": "Validité en jours",
|
||||
"0 = unbegrenzt gültig": "0 = valable sans limite",
|
||||
"Dateivariante": "Variante du fichier",
|
||||
"Original": "Original",
|
||||
"Archivfassung (PDF)": "Version archivée (PDF)",
|
||||
"Dokument mit einem Tag kennzeichnen":
|
||||
"Marquer le document d'une étiquette",
|
||||
"Macht geteilte Dokumente auffindbar. Ist der Tag noch nicht "
|
||||
"vorhanden, wird er nach Rückfrage angelegt.":
|
||||
"Permet de retrouver les documents partagés. Si l'étiquette "
|
||||
"n'existe pas, elle sera créée après confirmation.",
|
||||
"Freigabelink erzeugt": "Lien de partage créé",
|
||||
"Nicht abgelegte Änderungen": "Modifications non enregistrées",
|
||||
"Trotzdem teilen": "Partager quand même",
|
||||
"Erst neue Version ablegen": "Enregistrer d'abord une nouvelle version",
|
||||
|
||||
"Paperless — Status": "Paperless — État",
|
||||
"Status anzeigen": "Afficher l'état",
|
||||
|
||||
# -- Nachträge
|
||||
"Link (zum Kopieren markieren):": "Lien (sélectionner pour copier) :",
|
||||
"leer = Vorgabe. Nur ändern, wenn ein Proxy blockt.":
|
||||
"vide = valeur par défaut. À modifier uniquement si un proxy "
|
||||
"bloque.",
|
||||
"Dieses Dokument stammt nicht aus Paperless.\n\n"
|
||||
"Es wurde keine Paperless-Kennung gefunden. Öffne es "
|
||||
"über „Aus Paperless öffnen“, damit die "
|
||||
"Zuordnung erhalten bleibt.":
|
||||
"Ce document ne provient pas de Paperless.\n\n"
|
||||
"Aucun identifiant Paperless n'a été trouvé. Ouvrez-le via "
|
||||
"\u00ab Ouvrir depuis Paperless \u00bb afin de conserver "
|
||||
"l'association.",
|
||||
"Dieses Dokument liegt noch nicht in Paperless.\n\n"
|
||||
"Ein Freigabelink kann erst erzeugt werden, wenn es "
|
||||
"abgelegt ist.\n\nJetzt ablegen?":
|
||||
"Ce document n'est pas encore dans Paperless.\n\n"
|
||||
"Un lien de partage ne peut être créé qu'une fois le document "
|
||||
"enregistré.\n\nL'enregistrer maintenant ?",
|
||||
"Das geöffnete Dokument enthält Änderungen, die noch nicht "
|
||||
"in Paperless abgelegt sind.\n\nDer Link würde auf die "
|
||||
"zuletzt abgelegte Fassung zeigen, nicht auf das, was du "
|
||||
"gerade vor dir hast.":
|
||||
"Le document ouvert contient des modifications qui n'ont pas été "
|
||||
"enregistrées dans Paperless.\n\nLe lien pointerait vers le "
|
||||
"dernier état enregistré, et non vers ce que vous voyez.",
|
||||
}
|
||||
|
||||
KATALOG = {"en": EN, "fr": FR}
|
||||
BIN
symbole-vorschau.png
Normal file
|
After Width: | Height: | Size: 36 KiB |