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 |