Initial commit

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

View File

@@ -0,0 +1,406 @@
"""
paperless_client.py - REST-Client für Paperless-ngx.
Bewusst OHNE requests: Die in LibreOffice mitgelieferte Python-Laufzeit
bringt nur die Standardbibliothek mit. Multipart-Uploads werden deshalb
von Hand kodiert.
"""
import json
import mimetypes
import os
import ssl
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
TASK_TIMEOUT = 900
TASK_POLL = 1.5
# Obergrenze für Antwortkörper. Ohne Begrenzung könnte eine fehlerhafte
# oder böswillige Gegenstelle den Arbeitsspeicher füllen, indem sie eine
# endlose Antwort liefert.
MAX_JSON = 32 * 1024 * 1024 # 32 MB für Metadaten
MAX_DOWNLOAD = 512 * 1024 * 1024 # 512 MB für Dokumente
# Eigene Kennung statt der Vorgabe von urllib.
#
# urllib sendet standardmäßig "Python-urllib/3.x". Diese Kennung steht in
# den Sperrlisten gängiger Schutzsysteme (CrowdSec http-bad-user-agent,
# fail2ban, WAF-Regelwerke), weil sie typisch für Scanner ist. Folge: Der
# Zugriff über einen Reverse Proxy scheitert mit HTTP 403, während
# derselbe Aufruf direkt an die Anwendung funktioniert - ein schwer zu
# findendes Fehlerbild, zumal die Meldung von einem Dienst stammen kann,
# der mit dem Ziel nichts zu tun hat.
#
# Über den Parameter user_agent bei PaperlessClient anpassbar, falls eine
# Regel auch diesen Namen abweist.
USER_AGENT = "PaperlessLibreOffice/0.1 (+LibreOffice extension)"
class PaperlessError(Exception):
pass
class _SicherRedirect(urllib.request.HTTPRedirectHandler):
"""
Weiterleitungen nur nach http und https zulassen.
Der Vorgabe-Handler von urllib erlaubt auch ftp. Eine böswillige oder
übernommene Gegenstelle könnte damit auf ein anderes Protokoll
umlenken. Für eine REST-Schnittstelle gibt es dafür keinen Grund.
"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
if urllib.parse.urlparse(newurl).scheme not in ("http", "https"):
raise PaperlessError(
"Weiterleitung auf ein nicht zugelassenes Protokoll "
"abgewiesen: %s" % newurl[:120])
return super().redirect_request(req, fp, code, msg, headers, newurl)
class PaperlessClient:
def __init__(self, base_url, token, verify_tls=True, timeout=60,
user_agent=None):
# Nur http und https zulassen. Ohne Prüfung liesse sich hier
# file:// oder ftp:// hinterlegen, und urllib würde das bedienen -
# aus einer Adresseingabe würde ein Dateizugriff.
parsed = urllib.parse.urlparse((base_url or "").strip())
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise PaperlessError(
"Ungültige Basisadresse. Erwartet wird http:// oder "
"https:// mit Rechnernamen, z. B. https://dms.example.org")
self.base = base_url.strip().rstrip("/")
self.token = (token or "").strip()
self.timeout = timeout
self.user_agent = user_agent or USER_AGENT
self._opener = urllib.request.build_opener(_SicherRedirect())
self._ctx = None
if not verify_tls:
# Nur für Testinstanzen mit selbstsigniertem Zertifikat.
self._ctx = ssl.create_default_context()
self._ctx.check_hostname = False
self._ctx.verify_mode = ssl.CERT_NONE
# ------------------------------------------------------------ intern
def _open(self, req):
req.add_header("Authorization", "Token %s" % self.token)
req.add_header("User-Agent", self.user_agent)
req.add_header("Accept", "application/json, */*")
try:
return self._opener.open(req, timeout=self.timeout) \
if self._ctx is None else \
urllib.request.urlopen(req, timeout=self.timeout,
context=self._ctx)
except urllib.error.HTTPError as exc:
body = ""
try:
body = exc.read().decode("utf-8", "replace")[:400]
except Exception:
pass
# Die Adresse kann Suchbegriffe enthalten, aber niemals den
# Token - der steht im Kopfzeilenfeld. Trotzdem vorsichtshalber
# kürzen, damit lange Abfragen die Meldung nicht sprengen.
raise PaperlessError("HTTP %s bei %s: %s"
% (exc.code, req.full_url[:160],
body)) from exc
except urllib.error.URLError as exc:
raise PaperlessError("Verbindung fehlgeschlagen: %s" % exc.reason) from exc
def _json(self, path, params=None, method="GET", payload=None):
url = "%s/api/%s" % (self.base, path.lstrip("/"))
if params:
url += "?" + urllib.parse.urlencode(params, doseq=True)
data = None
req = urllib.request.Request(url, method=method)
if payload is not None:
data = json.dumps(payload).encode("utf-8")
req.add_header("Content-Type", "application/json")
req.data = data
with self._open(req) as r:
raw = r.read(MAX_JSON + 1)
if len(raw) > MAX_JSON:
raise PaperlessError("Antwort überschreitet %d MB"
% (MAX_JSON // 1024 // 1024))
return json.loads(raw.decode("utf-8")) if raw else None
def _multipart(self, path, fields, filename, filedata):
"""Multipart/form-data von Hand, weil requests nicht verfügbar ist."""
boundary = "----paperless%s" % uuid.uuid4().hex
out = []
for key, value in fields.items():
if value is None:
continue
out.append(("--%s\r\n"
"Content-Disposition: form-data; name=\"%s\"\r\n\r\n"
"%s\r\n" % (boundary, key, value)).encode("utf-8"))
ctype = mimetypes.guess_type(filename)[0] or "application/octet-stream"
out.append(("--%s\r\n"
"Content-Disposition: form-data; name=\"document\"; "
"filename=\"%s\"\r\n"
"Content-Type: %s\r\n\r\n"
% (boundary, os.path.basename(filename), ctype)).encode("utf-8"))
out.append(filedata)
out.append(("\r\n--%s--\r\n" % boundary).encode("utf-8"))
body = b"".join(out)
url = "%s/api/%s" % (self.base, path.lstrip("/"))
req = urllib.request.Request(url, data=body, method="POST")
req.add_header("Content-Type",
"multipart/form-data; boundary=%s" % boundary)
with self._open(req) as r:
raw = r.read().decode("utf-8")
try:
return json.loads(raw)
except ValueError:
return raw.strip().strip('"')
# ------------------------------------------------------------ Aufgaben
def wait_for_task(self, task_id, progress=None):
"""
Wartet auf einen Konsumvorgang und liefert die Dokument-ID.
Die Antwortstruktur hat sich zwischen Versionen mehrfach geändert:
Status kommt klein- oder grossgeschrieben, die Dokument-ID steht mal
verschachtelt in result_data, mal als Liste in related_document_ids.
"""
deadline = time.time() + TASK_TIMEOUT
waited = 0
while time.time() < deadline:
task = self._find_task(task_id)
if task:
status = str(task.get("status") or "").upper()
if status in ("SUCCESS", "SUCCEEDED"):
doc = self._doc_id(task)
if doc:
return doc
raise PaperlessError("Aufgabe erfolgreich, aber ohne "
"Dokument-ID: %s" % task)
if status in ("FAILURE", "FAILED", "REVOKED"):
raise PaperlessError(self._error_text(task, status))
time.sleep(TASK_POLL)
waited += TASK_POLL
if progress:
progress(waited)
raise PaperlessError("Zeitüberschreitung nach %ds (Aufgabe %s)"
% (TASK_TIMEOUT, task_id))
def _find_task(self, task_id):
for params in ({"task_id": task_id}, None):
try:
data = self._json("tasks/", params)
except PaperlessError:
continue
rows = data if isinstance(data, list) else (data or {}).get("results", [])
for t in rows:
if str(t.get("task_id")) == str(task_id):
return t
return None
@staticmethod
def _doc_id(task):
def as_int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
for key in ("related_document_ids", "document_ids"):
val = task.get(key)
if isinstance(val, (list, tuple)) and val:
got = as_int(val[0])
if got:
return got
nested = task.get("result_data")
if isinstance(nested, dict):
for key in ("document_id", "related_document", "id"):
got = as_int(nested.get(key))
if got:
return got
for key in ("related_document", "document_id", "related_document_id"):
got = as_int(task.get(key))
if got:
return got
return None
@staticmethod
def _error_text(task, status):
rd = task.get("result_data") or {}
for src in (task.get("result"), rd.get("error"), rd.get("exc_message")):
if src:
txt = " ".join(str(src).split())
if txt and txt.lower() not in ("failure", "failed"):
return txt[:400]
return "Aufgabe %s ohne Meldung" % status
# ------------------------------------------------------------ Dokumente
def ping(self):
self._json("documents/", {"page_size": 1})
return True
def search(self, query, limit=50):
params = {"page_size": limit,
"ordering": "-added",
"fields": "id,title,created,added,archive_serial_number,"
"correspondent,document_type,tags,original_file_name"}
if query:
params["query"] = query
return (self._json("documents/", params) or {}).get("results", [])
def document(self, doc_id, full_perms=False):
params = {"full_perms": "true"} if full_perms else None
return self._json("documents/%d/" % int(doc_id), params)
def version_details(self, doc):
"""
Reichert die versions-Liste eines Dokuments um Format und Dateiname an.
Die eingebettete Liste enthält nur id, added, version_label,
checksum und is_root - nicht aber den Dateityp. Jede Version ist
aber selbst ein Dokumentdatensatz, also je Version ein Abruf.
Vertretbar, weil das nur beim bewussten Öffnen eines Dokuments
passiert.
Rückgabe absteigend nach Versionsnummer, also neueste zuerst -
so wie Paperless die Liste selbst liefert.
"""
versions = doc.get("versions") or []
if not versions:
# Dokument ohne Versionskette: es selbst ist die einzige Fassung.
return [{
"id": doc["id"], "added": doc.get("added"),
"version_label": None, "is_root": True, "no": 1,
"mime_type": doc.get("mime_type"),
"filename": doc.get("original_file_name") or "",
}]
# Paperless liefert absteigend. Die Versionsnummer ergibt sich aus
# der Position von unten gezählt.
total = len(versions)
out = []
for idx, v in enumerate(versions):
entry = dict(v)
entry["no"] = total - idx
try:
full = self.document(v["id"])
entry["mime_type"] = full.get("mime_type")
entry["filename"] = full.get("original_file_name") or ""
except PaperlessError:
entry["mime_type"] = None
entry["filename"] = ""
out.append(entry)
return out
def download(self, doc_id, original=True):
"""Laedt die Datei. original=True liefert das unveraenderte Original."""
url = "%s/api/documents/%d/download/" % (self.base, int(doc_id))
if original:
url += "?original=true"
req = urllib.request.Request(req_url := url)
with self._open(req) as r:
# Der Dateiname stammt aus einem Kopfzeilenfeld der Gegenstelle
# und ist damit nicht vertrauenswürdig. Er wird hier nur roh
# weitergereicht; die Bereinigung passiert beim Ablegen.
disp = r.headers.get("Content-Disposition", "")
name = None
if "filename=" in disp:
name = disp.split("filename=", 1)[1].strip().strip('";')
name = urllib.parse.unquote(name)
data = r.read(MAX_DOWNLOAD + 1)
if len(data) > MAX_DOWNLOAD:
raise PaperlessError(
"Die Datei überschreitet die Obergrenze von %d MB."
% (MAX_DOWNLOAD // 1024 // 1024))
return data, name
def update_version(self, doc_id, filename, filedata, version_label=None):
"""Haengt eine neue Version an. Liefert die Aufgaben-Kennung."""
return self._multipart("documents/%d/update_version/" % int(doc_id),
{"version_label": version_label},
filename, filedata)
def post_document(self, filename, filedata, fields=None):
"""
Legt ein NEUES Dokument an (eigene Versionskette).
Anders als update_version, das eine weitere Fassung an ein
bestehendes Dokument hängt. Liefert die Aufgaben-Kennung.
"""
data = {}
for key, value in (fields or {}).items():
if value in (None, "", []):
continue
data[key] = value
return self._multipart("documents/post_document/", data,
filename, filedata)
def patch_document(self, doc_id, payload):
return self._json("documents/%d/" % int(doc_id),
method="PATCH", payload=payload)
# ------------------------------------------------------------ Stammdaten
def _all(self, path, fields="id,name"):
out, page = [], 1
while True:
data = self._json(path, {"page_size": 200, "page": page,
"fields": fields})
if not data:
break
out.extend(data.get("results", []))
if not data.get("next"):
break
page += 1
return out
def tags(self):
return self._all("tags/")
def correspondents(self):
return self._all("correspondents/")
def document_types(self):
return self._all("document_types/")
def storage_paths(self):
return self._all("storage_paths/")
def custom_fields(self):
return self._all("custom_fields/", fields="id,name,data_type")
def create(self, path, name):
return self._json(path, method="POST", payload={"name": name})
# ------------------------------------------------------------ Freigabe
def share_link(self, doc_id, days=7, file_version="original"):
"""
Erzeugt einen zeitlich begrenzten, unauthentifizierten Link.
Der Endpunkt heißt je nach Version share_links/ oder sharelinks/.
Beide werden probiert.
"""
from datetime import datetime, timedelta, timezone
expiration = None
if days:
expiration = (datetime.now(timezone.utc)
+ timedelta(days=int(days))).isoformat()
payload = {"document": int(doc_id), "file_version": file_version}
if expiration:
payload["expiration"] = expiration
last = None
for path in ("share_links/", "sharelinks/"):
try:
res = self._json(path, method="POST", payload=payload)
slug = res.get("slug") if isinstance(res, dict) else None
if slug:
return "%s/share/%s" % (self.base, slug), res
return None, res
except PaperlessError as exc:
last = exc
raise last

View File

@@ -0,0 +1,250 @@
"""
paperless_config.py - Einstellungen und Dokumentzuordnung.
Zwei Aufgaben:
1. Verbindungsdaten dauerhaft ablegen (Basisadresse, Token).
2. Merken, welches lokal geoeffnete Dokument zu welcher Paperless-ID
gehört.
Zu 2. gibt es zwei Ebenen. Vorrangig stehen die Angaben in den
benutzerdefinierten Dokumenteigenschaften - die überleben einen
Speichervorgang im ODF-Format und wandern sogar mit, wenn die Datei kopiert
wird. Bei Formaten, die keine solchen Eigenschaften kennen (PDF, manche
Fremdformate), greift eine lokale Zuordnungsdatei über den Datei-URL.
"""
import json
import os
import re
import sys
import tempfile
import time
APP_DIR_NAME = "paperless-libreoffice"
PROP_DOC_ID = "PaperlessDocumentId"
PROP_BASE = "PaperlessBaseUrl"
def _config_dir():
"""
Betriebssystemabhängiger Ort für Einstellungen.
Unter Windows gibt es kein XDG_CONFIG_HOME; dort ist %APPDATA% der
vorgesehene Ablageort. Unter macOS wäre ~/Library/Application Support
üblich, ~/.config funktioniert dort aber ebenso und hält die
Installation überschaubar.
"""
if sys.platform.startswith("win"):
base = os.environ.get("APPDATA") or os.path.expanduser("~")
else:
base = os.environ.get("XDG_CONFIG_HOME") or \
os.path.expanduser("~/.config")
path = os.path.join(base, APP_DIR_NAME)
os.makedirs(path, exist_ok=True)
lock_down(path, 0o700)
return path
def lock_down(path, mode):
"""
Rechte einschränken, soweit die Plattform das über chmod zulässt.
Unter Windows ist chmod weitgehend wirkungslos - dort greift die
Zugriffssteuerung über ACLs, und die Datei liegt im Benutzerprofil,
das andere Benutzer standardmäßig nicht lesen können.
"""
try:
os.chmod(path, mode)
except OSError:
pass
def work_dir(*teile):
"""
Arbeitsverzeichnis für heruntergeladene Dateien.
BEWUSST NICHT im allgemeinen Temp-Verzeichnis: Das ist auf
Mehrbenutzersystemen für alle beschreibbar. Ein anderer Benutzer könnte
dort einen symbolischen Verweis unter dem erwarteten Namen anlegen und
das Schreiben in eine fremde Datei umlenken.
Stattdessen ein Unterverzeichnis im Benutzerprofil, in dem nur der
eigene Benutzer schreiben darf.
"""
path = os.path.join(_config_dir(), "arbeit", *[safe_filename(t)
for t in teile])
os.makedirs(path, exist_ok=True)
lock_down(path, 0o700)
return path
# Unter Windows verbotene Zeichen und reservierte Gerätenamen. Eine Datei
# namens "CON.odt" oder "aux" lässt sich dort nicht anlegen, und Namen mit
# ":" oder "\" werden als Pfadangaben gedeutet.
_UNSICHER = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
_RESERVIERT = {
"CON", "PRN", "AUX", "NUL",
*("COM%d" % i for i in range(1, 10)),
*("LPT%d" % i for i in range(1, 10)),
}
def safe_filename(name, vorgabe="Dokument", maxlen=80):
"""
Beliebige Zeichenkette in einen unbedenklichen Dateinamen überführen.
Deckt drei Gefahren ab:
* Pfadanteile. Ein vom Server geliefertes "../../etc/passwd" oder
"..\\..\\datei" darf nicht aus dem Arbeitsverzeichnis
herausführen. Deshalb werden BEIDE Trennzeichen entfernt, nicht
nur das der laufenden Plattform.
* Unter Windows verbotene Zeichen und Gerätenamen.
* Punkte und Leerzeichen am Ende, die Windows stillschweigend
abschneidet - dadurch könnten zwei Namen zusammenfallen.
"""
name = str(name or "")
# Pfadanteile plattformunabhängig entfernen
name = name.replace("\\", "/").split("/")[-1]
name = _UNSICHER.sub("", name).strip()
name = re.sub(r"\s+", " ", name)
name = name.strip(". ")
if not name:
return vorgabe
stamm, punkt, endung = name.rpartition(".")
pruef = (stamm if punkt else name).upper()
if pruef in _RESERVIERT:
name = "_" + name
if len(name) > maxlen:
if punkt and len(endung) <= 8:
name = name[:maxlen - len(endung) - 1] + "." + endung
else:
name = name[:maxlen]
return name or vorgabe
# --------------------------------------------------------------- Einstellungen
def _settings_path():
return os.path.join(_config_dir(), "settings.json")
def load_settings():
try:
with open(_settings_path(), "r", encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, ValueError):
data = {}
data.setdefault("base_url", "")
data.setdefault("token", "")
data.setdefault("verify_tls", True)
data.setdefault("version_label", "LibreOffice {datum}")
data.setdefault("share_days", 7)
data.setdefault("share_tag", "Sharelink")
# Zusatzfelder, deren Name eines dieser Wortteile enthält, werden beim
# Anlegen einer Kopie NICHT übernommen. Sie beziehen sich auf die
# Herkunft des Ursprungsdokuments - eine Kopie mit derselben
# Fremdkennung wäre falsch. Voreingestellt sind gängige Bezeichnungen
# aus vorangegangenen Systemen.
data.setdefault("herkunftsfelder", ["ecodms", "quell-id", "source-id",
"legacy"])
# Anpassbar, falls ein Reverse Proxy oder eine WAF bestimmte
# Kennungen abweist.
data.setdefault("user_agent", "")
# Basisadresse defensiv normalisieren: abschliessender Schraegstrich
# und Leerraum sind haeufige Fehlerquellen.
data["base_url"] = str(data.get("base_url") or "").strip().rstrip("/")
data["token"] = str(data.get("token") or "").strip()
return data
def save_settings(data):
path = _settings_path()
tmp = path + ".neu"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2, ensure_ascii=False)
lock_down(tmp, 0o600)
os.replace(tmp, path)
def settings_complete(s):
return bool(s.get("base_url")) and bool(s.get("token"))
# ------------------------------------------------------------- Zuordnung
def _map_path():
return os.path.join(_config_dir(), "documents.json")
def _load_map():
try:
with open(_map_path(), "r", encoding="utf-8") as fh:
return json.load(fh)
except (OSError, ValueError):
return {}
def _save_map(data):
# Einträge aelter als 90 Tage entfernen, damit die Datei nicht waechst.
cutoff = time.time() - 90 * 86400
data = {k: v for k, v in data.items()
if isinstance(v, dict) and v.get("ts", 0) > cutoff}
path = _map_path()
tmp = path + ".neu"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2)
lock_down(tmp, 0o600)
os.replace(tmp, path)
return data
def remember(url, doc_id, base_url=""):
data = _load_map()
data[url] = {"id": int(doc_id), "base": base_url, "ts": time.time()}
_save_map(data)
def recall(url):
entry = _load_map().get(url)
return int(entry["id"]) if entry else None
# ------------------------------------ Dokumenteigenschaften (bevorzugter Weg)
def set_document_id(doc, doc_id, base_url=""):
"""
Schreibt die Kennung in die benutzerdefinierten Dokumenteigenschaften.
Diese überleben einen ODF-Speichervorgang.
"""
try:
props = doc.getDocumentProperties().getUserDefinedProperties()
container = props.getPropertySetInfo()
for name, value in ((PROP_DOC_ID, str(int(doc_id))),
(PROP_BASE, base_url)):
if container.hasPropertyByName(name):
props.setPropertyValue(name, value)
else:
# 0 = ATTRIBUTE_NONE
props.addProperty(name, 0, value)
return True
except Exception:
return False
def get_document_id(doc):
"""Kennung aus den Dokumenteigenschaften, sonst aus der Zuordnungsdatei."""
try:
props = doc.getDocumentProperties().getUserDefinedProperties()
if props.getPropertySetInfo().hasPropertyByName(PROP_DOC_ID):
raw = props.getPropertyValue(PROP_DOC_ID)
if raw:
return int(str(raw).strip())
except Exception:
pass
try:
url = doc.getURL()
except Exception:
url = None
return recall(url) if url else None

View File

@@ -0,0 +1,790 @@
"""
paperless_dialogs.py - Dialoge, programmatisch mit UNO aufgebaut.
Bewusst ohne XDL-Dateien: Die muessten mitverpackt, über die
Erweiterungs-URL geladen und je Sprache gepflegt werden. Programmatisch
gebaute Dialoge sind ausfuehrlicher im Code, dafuer ohne Paketierungsaufwand
und ohne Ladepfad-Fallstricke.
"""
import uno
from com.sun.star.awt import Rectangle
from com.sun.star.awt.MessageBoxButtons import BUTTONS_OK, BUTTONS_OK_CANCEL
from com.sun.star.awt.MessageBoxType import (ERRORBOX, INFOBOX, QUERYBOX,
WARNINGBOX)
# ---------------------------------------------------------------- Meldungen
def message(ctx, frame, text, title="Paperless", kind=INFOBOX):
parent = frame.getContainerWindow() if frame else None
toolkit = ctx.ServiceManager.createInstanceWithContext(
"com.sun.star.awt.Toolkit", ctx)
box = toolkit.createMessageBox(parent, kind, BUTTONS_OK, title, text)
box.execute()
box.dispose()
def error(ctx, frame, text, title="Paperless — Fehler"):
message(ctx, frame, text, title, ERRORBOX)
def confirm(ctx, frame, text, title="Paperless"):
parent = frame.getContainerWindow() if frame else None
toolkit = ctx.ServiceManager.createInstanceWithContext(
"com.sun.star.awt.Toolkit", ctx)
box = toolkit.createMessageBox(parent, QUERYBOX, BUTTONS_OK_CANCEL,
title, text)
result = box.execute()
box.dispose()
return result == 1 # 1 = OK
def choice(ctx, frame, title, text, options, width=360):
"""
Rückfrage mit mehr als zwei Möglichkeiten.
Die eingebaute MessageBox kennt nur OK und Abbrechen. Und
PushButtonType taugt hier nicht: Es kennt ausschließlich Standard, OK,
Abbrechen und Hilfe - bei drei Schaltflächen wäre die dritte „Hilfe“
und würde den Dialog nicht schließen.
Deshalb gewöhnliche Schaltflächen mit Ereignisbehandler, die das
Ergebnis vermerken und endExecute() aufrufen.
Liefert den Index der gewählten Schaltfläche oder None bei Abbruch.
"""
import unohelper
from com.sun.star.awt import XActionListener
zeilen = text.count("\n") + 1
th = max(30, zeilen * 11)
h = th + 46
d = DialogBuilder(ctx, title, width, h)
d.label("t", 10, 10, width - 20, th, text)
bw = max(60, min(120, (width - 20) // len(options) - 6))
x = width - 10 - len(options) * (bw + 6) + 6
for i, label_text in enumerate(options):
# PushButtonType 0 = Standard: schliesst den Dialog NICHT von selbst.
d.button("b%d" % i, x, h - 26, bw, 16, label_text, 0,
i == len(options) - 1)
x += bw + 6
d.dialog.setModel(d.model)
smgr = ctx.ServiceManager
toolkit = smgr.createInstanceWithContext("com.sun.star.awt.Toolkit", ctx)
d.dialog.createPeer(toolkit, frame.getContainerWindow() if frame else None)
ergebnis = {"index": None}
class Klick(unohelper.Base, XActionListener):
def __init__(self, index):
self.index = index
def actionPerformed(self, _ev):
ergebnis["index"] = self.index
d.dialog.endExecute()
def disposing(self, _ev):
pass
# Die Behandler müssen am Leben bleiben, solange der Dialog läuft.
behandler = []
for i in range(len(options)):
k = Klick(i)
behandler.append(k)
d.get("b%d" % i).addActionListener(k)
d.dialog.execute()
d.dispose()
return ergebnis["index"]
# ------------------------------------------------------- Spaltenlayout
# Listenfelder verwenden standardmäßig eine Proportionalschrift. Mit
# Leerzeichen aufgefüllte Spalten sind darin NICHT ausgerichtet - die
# Überschrift steht dann woanders als der Inhalt. Deshalb wird für die
# Listen und ihre Überschriften eine dicht laufende Schrift gesetzt.
#
# Die Angabe ist eine Ersatzkette: LibreOffice nimmt den ersten
# verfügbaren Namen.
MONO = "Liberation Mono;DejaVu Sans Mono;Noto Sans Mono;Courier New;monospace"
SEP = "" # senkrechter Strich als dezenter Spaltentrenner
def mono(ctrl):
"""
Dicht laufende Schrift auf ein Steuerelement legen.
Die Schriftgröße wird bewusst NICHT gesetzt - so erbt das Element die
Größe des Dialogs und damit die Systemvorgabe. Ein fester Wert wäre
auf hochauflösenden Anzeigen zu klein.
"""
try:
ctrl.FontName = MONO
except Exception:
pass
return ctrl
def columns(values, widths):
"""
Werte auf feste Zeichenbreiten bringen und mit Strichen trennen.
Negative Breite bedeutet rechtsbündig, 0 bedeutet Rest der Zeile.
Nur sinnvoll zusammen mit mono().
"""
out = []
for value, width in zip(values, widths):
text = "" if value is None else str(value)
if width == 0:
out.append(text)
elif width < 0:
w = -width
out.append(text[:w].rjust(w))
else:
out.append(text[:width].ljust(width))
return (" %s " % SEP).join(out)
# ------------------------------------------------------------ Baukasten
class DialogBuilder:
"""Dünne Hülle um UnoControlDialogModel."""
def __init__(self, ctx, title, width, height):
self.ctx = ctx
smgr = ctx.ServiceManager
self.model = smgr.createInstanceWithContext(
"com.sun.star.awt.UnoControlDialogModel", ctx)
self.model.PositionX = 0
self.model.PositionY = 0
self.model.Width = width
self.model.Height = height
self.model.Title = title
self.dialog = smgr.createInstanceWithContext(
"com.sun.star.awt.UnoControlDialog", ctx)
self._smgr = smgr
def add(self, kind, name, x, y, w, h, **props):
ctrl = self.model.createInstance(
"com.sun.star.awt.UnoControl%sModel" % kind)
ctrl.PositionX, ctrl.PositionY = x, y
ctrl.Width, ctrl.Height = w, h
for key, value in props.items():
try:
setattr(ctrl, key, value)
except Exception:
pass
self.model.insertByName(name, ctrl)
return ctrl
def label(self, name, x, y, w, h, text):
return self.add("FixedText", name, x, y, w, h, Label=text)
def edit(self, name, x, y, w, h, text="", **kw):
return self.add("Edit", name, x, y, w, h, Text=text, **kw)
def button(self, name, x, y, w, h, label, action=0, default=False):
return self.add("Button", name, x, y, w, h,
Label=label, PushButtonType=action,
DefaultButton=default)
def listbox(self, name, x, y, w, h, items=(), dropdown=False):
return self.add("ListBox", name, x, y, w, h,
StringItemList=tuple(items), Dropdown=dropdown)
def combo(self, name, x, y, w, h, items=(), text=""):
return self.add("ComboBox", name, x, y, w, h,
StringItemList=tuple(items), Dropdown=True, Text=text)
def check(self, name, x, y, w, h, label, state=0):
return self.add("CheckBox", name, x, y, w, h, Label=label, State=state)
def execute(self, frame=None):
self.dialog.setModel(self.model)
toolkit = self._smgr.createInstanceWithContext(
"com.sun.star.awt.Toolkit", self.ctx)
parent = frame.getContainerWindow() if frame else None
self.dialog.createPeer(toolkit, parent)
result = self.dialog.execute()
return result
def get(self, name):
return self.dialog.getControl(name)
def dispose(self):
try:
self.dialog.dispose()
except Exception:
pass
# ------------------------------------------------------------ Einstellungen
def settings_dialog(ctx, frame, settings):
"""Verbindungsdaten. Liefert das geänderte dict oder None."""
d = DialogBuilder(ctx, "Paperless — Verbindung", 260, 172)
d.label("l1", 8, 10, 70, 12, "Basisadresse")
d.edit("base", 80, 8, 172, 14, settings.get("base_url", ""))
d.label("l1b", 80, 24, 172, 10, "z. B. https://dms.example.org")
d.label("l2", 8, 42, 70, 12, "API-Token")
d.edit("token", 80, 40, 172, 14, settings.get("token", ""),
EchoChar=ord("*"))
d.label("l2b", 80, 56, 172, 10,
"Im Benutzerprofil von Paperless erzeugen")
d.check("tls", 80, 72, 172, 12, "TLS-Zertifikat prüfen",
1 if settings.get("verify_tls", True) else 0)
d.label("l3", 8, 92, 70, 12, "Versionslabel")
d.edit("label", 80, 90, 172, 14, settings.get("version_label", ""))
d.label("l3b", 80, 106, 172, 10, "{datum} wird ersetzt")
d.label("l4", 8, 112, 70, 12, "User-Agent")
d.edit("ua", 80, 110, 172, 14, settings.get("user_agent", ""))
d.label("l4b", 80, 126, 172, 10,
"leer = Vorgabe. Nur ändern, wenn ein Proxy blockt.")
d.button("ok", 140, 146, 52, 16, "Speichern", 1, True)
d.button("cancel", 198, 146, 52, 16, "Abbrechen", 2)
if d.execute(frame) != 1:
d.dispose()
return None
out = dict(settings)
out["base_url"] = d.get("base").getText().strip()
out["token"] = d.get("token").getText().strip()
out["verify_tls"] = bool(d.get("tls").getState())
out["version_label"] = d.get("label").getText().strip()
out["user_agent"] = d.get("ua").getText().strip()
d.dispose()
return out
# ------------------------------------------------------------ Dokumentsuche
def open_dialog(ctx, frame, client):
"""
Ein Dialog von oben nach unten: Suche, Trefferliste, Versionsliste.
Die Trefferliste zeigt je Dokument genau einen Eintrag - das
Wurzeldokument. Wird darin etwas ausgewählt, füllt sich darunter die
Versionsliste mit Datum und Dateiformat je Fassung.
Liefert (dokument, version, nur_lesen) oder None.
"""
import paperless_formats as fmt
W, H = 420, 322
d = DialogBuilder(ctx, "Dokument aus Paperless öffnen", W, H)
# Spaltenbreiten in Zeichen. Negativ = rechtsbündig, 0 = Rest der Zeile.
COL_RES = (-7, 10, 0) # ASN, Erstellt, Titel
COL_VER = (-3, 10, 14, 0) # Nr., Erstellt, Format, Bezeichnung
# Listenfelder rücken ihren Text um wenige Einheiten ein. Die
# Überschrift bekommt denselben Versatz, sonst stehen die Spalten
# gegeneinander verschoben.
HX = 11
# 1. Suche
d.label("l1", 8, 10, 40, 12, "Suche")
d.edit("q", 50, 8, W - 122, 14, "")
d.button("go", W - 66, 8, 58, 14, "Suchen", 0, True)
d.label("hint1", 8, 26, W - 16, 10,
"Leer lassen für die zuletzt hinzugefügten Dokumente. "
"Archiv-Seriennummer funktioniert ebenfalls.")
# 2. Treffer - Bezeichnung, darunter Spaltenüberschrift, darunter Liste
d.label("l2", 8, 42, 200, 11, "Dokumente")
mono(d.label("head1", HX, 55, W - HX - 8, 11,
columns(("ASN", "Erstellt", "Titel"), COL_RES)))
mono(d.listbox("results", 8, 68, W - 16, 104))
# 3. Versionen
d.label("l3", 8, 180, 200, 11, "Versionen")
mono(d.label("head2", HX, 193, W - HX - 8, 11,
" " + columns(("Nr", "Erstellt", "Format", "Bezeichnung"),
COL_VER)))
mono(d.listbox("versions", 8, 206, W - 16, 66))
d.label("hint2", 8, 278, W - 16, 20, "")
d.label("l4", 8, 301, 46, 11, "Öffnen als")
d.listbox("mode", 58, 299, 130, 14,
["Bearbeitbar", "Schreibgeschützt"], dropdown=True)
d.button("ok", W - 122, 298, 54, 16, "Öffnen", 1)
d.button("cancel", W - 62, 298, 54, 16, "Abbrechen", 2)
d.dialog.setModel(d.model)
smgr = ctx.ServiceManager
toolkit = smgr.createInstanceWithContext("com.sun.star.awt.Toolkit", ctx)
d.dialog.createPeer(toolkit, frame.getContainerWindow() if frame else None)
lb_res = d.get("results")
lb_ver = d.get("versions")
state = {"rows": [], "versions": [], "doc": None, "cache": {}, "last": -1}
# ---------------------------------------------------------- Trefferliste
def refresh(query):
try:
rows = client.search(query)
except Exception as exc: # noqa: BLE001
d.get("hint2").setText("Fehler bei der Suche: %s" % str(exc)[:70])
return
state["rows"] = rows
state["last"] = -1
lb_res.removeItems(0, lb_res.getItemCount())
lb_ver.removeItems(0, lb_ver.getItemCount())
entries = []
for r in rows:
asn = r.get("archive_serial_number")
entries.append(columns((
str(asn) if asn is not None else "",
(r.get("created") or "")[:10],
(r.get("title") or "")[:90]), COL_RES))
if entries:
lb_res.addItems(tuple(entries), 0)
lb_res.selectItemPos(0, True)
load_versions()
else:
d.get("hint2").setText("Keine Treffer.")
# --------------------------------------------------------- Versionsliste
def load_versions():
"""
Versionen des gewählten Dokuments nachladen.
Die Formatangabe steht nicht in der eingebetteten versions-Liste,
deshalb je Version ein Abruf. Ergebnisse werden zwischengespeichert,
damit das Blättern in der Trefferliste nicht bei jedem Tastendruck
erneut abfragt.
"""
pos = lb_res.getSelectedItemPos()
if pos == state["last"] or not (0 <= pos < len(state["rows"])):
return
state["last"] = pos
row = state["rows"][pos]
doc_id = row["id"]
lb_ver.removeItems(0, lb_ver.getItemCount())
d.get("hint2").setText("Versionen werden geladen …")
if doc_id in state["cache"]:
full, versions = state["cache"][doc_id]
else:
try:
full = client.document(doc_id)
versions = client.version_details(full)
except Exception as exc: # noqa: BLE001
d.get("hint2").setText("Fehler: %s" % str(exc)[:70])
return
state["cache"][doc_id] = (full, versions)
state["doc"], state["versions"] = full, versions
entries = []
for v in versions:
editable = fmt.is_editable(v.get("filename"), v.get("mime_type"))
entries.append((" " if editable else "") + columns((
v.get("no", "?"),
(v.get("added") or "")[:10],
fmt.label(v.get("filename"), v.get("mime_type")),
(v.get("version_label") or "")[:40]), COL_VER))
if entries:
lb_ver.addItems(tuple(entries), 0)
# Vorgabe: die aktuellste Version. Die Liste kommt absteigend,
# also Position 0.
lb_ver.selectItemPos(0, True)
update_hint()
def update_hint():
"""
Hinweis und Vorbelegung der Öffnungsart.
Die Vorbelegung ist ein Vorschlag, keine Vorschrift - die
Formaterkennung kann danebenliegen, und dann soll der Anwender
trotzdem bearbeitbar öffnen können.
"""
pos = lb_ver.getSelectedItemPos()
vers = state["versions"]
if not (0 <= pos < len(vers)):
d.get("hint2").setText("")
return
v = vers[pos]
fn, mt = v.get("filename"), v.get("mime_type")
if fmt.is_editable(fn, mt):
d.get("mode").selectItemPos(0, True)
d.get("hint2").setText(
"Bearbeitbar. Änderungen können als neue Version abgelegt "
"werden.")
elif not fmt.is_known(fn, mt):
# Unbekanntes Format: kein Grund, das Bearbeiten zu verbieten.
d.get("mode").selectItemPos(0, True)
d.get("hint2").setText(
"Format nicht erkannt: %s\n"
"Bearbeitbar öffnen ist möglich — beim Zurückschreiben "
"wird ODF verwendet." % fmt.detail(fn, mt))
else:
d.get("mode").selectItemPos(1, True)
d.get("hint2").setText(
"%s ist in LibreOffice nicht sinnvoll bearbeitbar.\n"
"Schreibgeschützt empfohlen — bearbeitbar ist trotzdem "
"wählbar." % fmt.label(fn, mt))
# ------------------------------------------------------------- Ereignisse
import unohelper
from com.sun.star.awt import XActionListener, XItemListener
class GoListener(unohelper.Base, XActionListener):
def actionPerformed(self, _ev):
refresh(d.get("q").getText().strip())
def disposing(self, _ev):
pass
class ResultListener(unohelper.Base, XItemListener):
def itemStateChanged(self, _ev):
load_versions()
def disposing(self, _ev):
pass
class VersionListener(unohelper.Base, XItemListener):
def itemStateChanged(self, _ev):
update_hint()
def disposing(self, _ev):
pass
# Die Behandler in einer Liste halten. Als Wegwerfobjekte übergeben
# könnte der Speicherbereiniger sie einsammeln, bevor der Dialog
# fertig ist - die Ereignisse liefen dann ins Leere.
behandler = [GoListener(), ResultListener(), VersionListener()]
d.get("go").addActionListener(behandler[0])
lb_res.addItemListener(behandler[1])
lb_ver.addItemListener(behandler[2])
refresh("")
if d.dialog.execute() != 1:
d.dispose()
return None
vpos = lb_ver.getSelectedItemPos()
# Die Wahl des Anwenders wird NICHT überschrieben. Wer ein als nicht
# bearbeitbar eingestuftes Format trotzdem bearbeitbar öffnen will,
# darf das - die Erkennung kann danebenliegen.
readonly = d.get("mode").getSelectedItemPos() == 1
doc, versions = state["doc"], state["versions"]
d.dispose()
if doc is None or not (0 <= vpos < len(versions)):
return None
return doc, versions[vpos], readonly
# --------------------------------------------------- Ablegeziel wählen
def store_target_dialog(ctx, frame, client, source):
"""
Wohin soll die geöffnete Datei abgelegt werden?
Drei Fälle, die sich in der Absicht unterscheiden:
* ein NEUES Dokument mit eigener Versionskette
* eine neue Version an einem ANDEREN, bereits vorhandenen Dokument
Der zweite Fall ist der, den Dokumentenverwaltungen gern vergessen:
Die Rechnung kommt als PDF, die bearbeitbare Fassung entsteht später.
Beides gehört zusammen, aber das PDF war zuerst da.
Liefert ein dict oder None.
"""
import paperless_formats as fmt
W, H = 430, 300
COL = (-7, 10, 0)
HX = 11
title = (source.get("title") or "")
d = DialogBuilder(ctx, "In Paperless ablegen", W, H)
d.label("l0", 8, 10, 34, 12, "Ziel")
d.listbox("mode", 46, 8, 250, 14,
["Neues Dokument anlegen",
"An bestehendes Dokument anhängen"], dropdown=True)
# --- Fall 1: neues Dokument
d.label("n1", 8, 36, 46, 12, "Titel")
d.edit("title", 58, 34, W - 66, 14, (title + " (Kopie)")[:120])
d.check("meta", 58, 54, W - 66, 12,
"Klassifizierung des geöffneten Dokuments übernehmen", 1)
# --- Fall 2: an bestehendes Dokument anhängen
d.label("e1", 8, 78, 46, 12, "Suche")
d.edit("q", 58, 76, W - 130, 14, "")
d.button("go", W - 66, 76, 58, 14, "Suchen", 0)
mono(d.label("head", HX, 96, W - HX - 8, 11,
columns(("ASN", "Erstellt", "Titel"), COL)))
mono(d.listbox("results", 8, 109, W - 16, 96))
d.label("e2", 8, 209, W - 16, 20, "")
d.check("switch", 8, 236, W - 16, 12,
"Danach mit dem Zieldokument weiterarbeiten", 1)
d.label("l9", 8, 250, W - 16, 20,
"Das geöffnete Fenster wird dem Ziel zugeordnet. Weitere "
"Versionen landen dann dort.")
d.button("ok", W - 122, 276, 54, 16, "Ablegen", 1, True)
d.button("cancel", W - 62, 276, 54, 16, "Abbrechen", 2)
d.dialog.setModel(d.model)
smgr = ctx.ServiceManager
toolkit = smgr.createInstanceWithContext("com.sun.star.awt.Toolkit", ctx)
d.dialog.createPeer(toolkit, frame.getContainerWindow() if frame else None)
lb_res = d.get("results")
state = {"rows": []}
def set_mode():
neu_ = d.get("mode").getSelectedItemPos() == 0
for name in ("n1", "title", "meta"):
d.get(name).setEnable(neu_)
for name in ("e1", "q", "go", "head", "results"):
d.get(name).setEnable(not neu_)
if neu_:
d.get("e2").setText("")
else:
update_target_hint()
def refresh(query):
try:
rows = client.search(query)
except Exception as exc: # noqa: BLE001
d.get("e2").setText("Fehler bei der Suche: %s" % str(exc)[:70])
return
state["rows"] = rows
lb_res.removeItems(0, lb_res.getItemCount())
entries = []
for r in rows:
asn = r.get("archive_serial_number")
entries.append(columns((
str(asn) if asn is not None else "",
(r.get("created") or "")[:10],
(r.get("title") or "")[:90]), COL))
if entries:
lb_res.addItems(tuple(entries), 0)
lb_res.selectItemPos(0, True)
update_target_hint()
def update_target_hint():
pos = lb_res.getSelectedItemPos()
rows = state["rows"]
if not (0 <= pos < len(rows)):
d.get("e2").setText("Kein Zieldokument gewählt.")
return
r = rows[pos]
d.get("e2").setText(
"Die geöffnete Datei wird als neue Version an „%s“ angehängt.\n"
"Die bisherige Fassung bleibt als Version erhalten."
% (r.get("title") or "")[:60])
import unohelper
from com.sun.star.awt import XActionListener, XItemListener
class Go(unohelper.Base, XActionListener):
def actionPerformed(self, _ev):
refresh(d.get("q").getText().strip())
def disposing(self, _ev):
pass
class ModeChanged(unohelper.Base, XItemListener):
def itemStateChanged(self, _ev):
set_mode()
def disposing(self, _ev):
pass
class ResultChanged(unohelper.Base, XItemListener):
def itemStateChanged(self, _ev):
update_target_hint()
def disposing(self, _ev):
pass
behandler = [Go(), ModeChanged(), ResultChanged()]
d.get("go").addActionListener(behandler[0])
d.get("mode").addItemListener(behandler[1])
lb_res.addItemListener(behandler[2])
d.get("mode").selectItemPos(0, True)
set_mode()
if d.dialog.execute() != 1:
d.dispose()
return None
neu_ = d.get("mode").getSelectedItemPos() == 0
out = {"switch": bool(d.get("switch").getState())}
if neu_:
out["mode"] = "new"
out["title"] = d.get("title").getText().strip()
out["take_meta"] = bool(d.get("meta").getState())
d.dispose()
return out if out["title"] else None
pos = lb_res.getSelectedItemPos()
rows = state["rows"]
d.dispose()
if not (0 <= pos < len(rows)):
return None
out["mode"] = "existing"
out["target"] = rows[pos]
return out
# --------------------------------------------------------- Klassifizierung
def classify_dialog(ctx, frame, doc, tags, correspondents, doctypes):
"""
Klassifizierung bearbeiten. Liefert ein PATCH-payload-dict oder None.
Tags werden als Mehrfachauswahl gefuehrt, Korrespondent und Dokumenttyp
als Auswahlfeld mit Freitext - so lassen sich neue Werte anlegen.
"""
def names(rows):
return [r["name"] for r in rows]
def name_of(rows, ident):
for r in rows:
if r["id"] == ident:
return r["name"]
return ""
d = DialogBuilder(ctx, "Klassifizierung bearbeiten", 300, 250)
d.label("l1", 8, 10, 60, 12, "Titel")
d.edit("title", 70, 8, 222, 14, doc.get("title") or "")
d.label("l2", 8, 30, 60, 12, "Erstellt")
d.edit("created", 70, 28, 90, 14, (doc.get("created") or "")[:10])
d.label("l2b", 164, 30, 128, 10, "Format JJJJ-MM-TT")
d.label("l3", 8, 50, 60, 12, "Korrespondent")
d.combo("corr", 70, 48, 222, 14, names(correspondents),
name_of(correspondents, doc.get("correspondent")))
d.label("l4", 8, 70, 60, 12, "Dokumenttyp")
d.combo("dtype", 70, 68, 222, 14, names(doctypes),
name_of(doctypes, doc.get("document_type")))
d.label("l5", 8, 90, 60, 12, "Tags")
lb = d.listbox("tags", 70, 88, 222, 120)
lb.MultiSelection = True
d.label("l5b", 70, 210, 222, 10,
"Mehrfachauswahl mit Strg oder Umschalt")
d.button("ok", 178, 226, 52, 16, "Speichern", 1, True)
d.button("cancel", 236, 226, 56, 16, "Abbrechen", 2)
d.dialog.setModel(d.model)
smgr = ctx.ServiceManager
toolkit = smgr.createInstanceWithContext("com.sun.star.awt.Toolkit", ctx)
d.dialog.createPeer(toolkit, frame.getContainerWindow() if frame else None)
tag_ctrl = d.get("tags")
tag_ctrl.addItems(tuple(names(tags)), 0)
have = set(doc.get("tags") or [])
for i, t in enumerate(tags):
if t["id"] in have:
tag_ctrl.selectItemPos(i, True)
if d.dialog.execute() != 1:
d.dispose()
return None
payload = {"title": d.get("title").getText().strip()}
created = d.get("created").getText().strip()
if created:
payload["created"] = created
selected = d.get("tags").getSelectedItemsPos()
payload["tags"] = [tags[i]["id"] for i in selected if i < len(tags)]
payload["_correspondent_name"] = d.get("corr").getText().strip()
payload["_document_type_name"] = d.get("dtype").getText().strip()
d.dispose()
return payload
# -------------------------------------------------------------- Freigabe
def share_dialog(ctx, frame, default_days=7, default_tag="Sharelink",
known_tags=()):
"""
Laufzeit, Dateivariante und optionaler Tag.
Der Tag macht geteilte Dokumente später auffindbar - Paperless zeigt
zwar die eigenen Freigaben an, aber ein Tag lässt sich in gespeicherten
Ansichten und Regeln verwenden.
Liefert (tage, variante, tagname_oder_None) oder None.
"""
d = DialogBuilder(ctx, "Dokument teilen", 300, 168)
d.label("l1", 8, 12, 90, 12, "Gültigkeit in Tagen")
d.edit("days", 104, 10, 50, 14, str(default_days))
d.label("l1b", 104, 26, 188, 10, "0 = unbegrenzt gültig")
d.label("l2", 8, 44, 90, 12, "Dateivariante")
d.listbox("ver", 104, 42, 188, 14,
["Original", "Archivfassung (PDF)"], dropdown=True)
d.check("tag", 8, 70, 284, 12,
"Dokument mit einem Tag kennzeichnen", 1)
d.combo("tagname", 104, 86, 188, 14, known_tags, default_tag)
d.label("l3b", 104, 102, 188, 20,
"Macht geteilte Dokumente auffindbar. Ist der Tag noch nicht "
"vorhanden, wird er nach Rückfrage angelegt.")
d.button("ok", 178, 144, 54, 16, "Erzeugen", 1, True)
d.button("cancel", 236, 144, 56, 16, "Abbrechen", 2)
d.dialog.setModel(d.model)
smgr = ctx.ServiceManager
toolkit = smgr.createInstanceWithContext("com.sun.star.awt.Toolkit", ctx)
d.dialog.createPeer(toolkit, frame.getContainerWindow() if frame else None)
d.get("ver").selectItemPos(0, True)
if d.dialog.execute() != 1:
d.dispose()
return None
try:
days = int(d.get("days").getText().strip() or "0")
except ValueError:
days = default_days
variant = "original" if d.get("ver").getSelectedItemPos() == 0 else "archive"
tag = d.get("tagname").getText().strip() if d.get("tag").getState() else None
d.dispose()
return days, variant, (tag or None)
def result_dialog(ctx, frame, title, text, url=None):
"""Ergebnis anzeigen, Link zum Herauskopieren in einem Textfeld."""
height = 120 if url else 90
d = DialogBuilder(ctx, title, 320, height)
d.label("l1", 8, 10, 304, 24, text)
if url:
d.label("l2", 8, 42, 304, 10, "Link (zum Kopieren markieren):")
d.edit("url", 8, 54, 304, 14, url, ReadOnly=True)
d.button("ok", 258, height - 24, 54, 16, "Schließen", 1, True)
d.execute(frame)
d.dispose()

View File

@@ -0,0 +1,148 @@
"""
paperless_formats.py - Dateiformate und ihre Bearbeitbarkeit in LibreOffice.
Zentral gehalten, weil drei Stellen dieselbe Information brauchen: die
Versionsauswahl beim Öffnen, die Wahl des Speicherfilters beim Zurueck-
schreiben und der Hinweis, wenn eine Fassung nicht sinnvoll bearbeitbar ist.
"""
# Endung -> (Anzeigename, LibreOffice-Filter oder None, bearbeitbar)
#
# "bearbeitbar" meint: LibreOffice kann die Datei öffnen UND im selben
# Format wieder speichern, sodass ein sinnvoller Rückweg ins Archiv
# entsteht. PDF lässt sich zwar in Draw öffnen, ein Zurückschreiben
# ergaebe aber ein anderes Dokument - deshalb hier "nur Ansicht".
FORMATS = {
".odt": ("ODF Text", "writer8", True),
".ods": ("ODF Tabelle", "calc8", True),
".odp": ("ODF Präsentation", "impress8", True),
".odg": ("ODF Zeichnung", "draw8", True),
".doc": ("Word 97", "MS Word 97", True),
".docx": ("Word", "MS Word 2007 XML", True),
".dot": ("Word-Vorlage", "MS Word 97 Vorlage", True),
".rtf": ("Rich Text", "Rich Text Format", True),
".xls": ("Excel 97", "MS Excel 97", True),
".xlsx": ("Excel", "Calc MS Excel 2007 XML", True),
".ppt": ("PowerPoint 97", "MS PowerPoint 97", True),
".pptx": ("PowerPoint", "Impress MS PowerPoint 2007 XML", True),
".txt": ("Text", "Text", True),
".csv": ("CSV", "Text - txt - csv (StarCalc)", True),
".pdf": ("PDF", None, False),
".eml": ("E-Mail", None, False),
".msg": ("E-Mail", None, False),
".xml": ("XML", None, False),
".html": ("HTML", None, False),
".zip": ("Archiv", None, False),
".png": ("Bild", None, False),
".jpg": ("Bild", None, False),
".jpeg": ("Bild", None, False),
".tif": ("Bild", None, False),
".tiff": ("Bild", None, False),
}
MIME_TO_EXT = {
"application/vnd.oasis.opendocument.text": ".odt",
"application/vnd.oasis.opendocument.spreadsheet": ".ods",
"application/vnd.oasis.opendocument.presentation": ".odp",
"application/vnd.oasis.opendocument.graphics": ".odg",
"application/msword": ".doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/vnd.ms-excel": ".xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"application/vnd.ms-powerpoint": ".ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
"application/rtf": ".rtf",
"text/rtf": ".rtf",
"text/plain": ".txt",
"text/csv": ".csv",
"application/pdf": ".pdf",
"message/rfc822": ".eml",
"text/html": ".html",
"application/xml": ".xml",
"text/xml": ".xml",
"application/zip": ".zip",
}
def extension(filename=None, mime_type=None):
"""Endung aus Dateiname oder MIME-Typ, immer kleingeschrieben."""
if filename and "." in filename:
return "." + filename.rsplit(".", 1)[-1].lower()
if mime_type:
return MIME_TO_EXT.get(mime_type.split(";")[0].strip().lower(), "")
return ""
def describe(filename=None, mime_type=None):
"""(Anzeigename, Filter, bearbeitbar) für eine Datei."""
ext = extension(filename, mime_type)
if ext in FORMATS:
return FORMATS[ext]
return (ext.lstrip(".").upper() or "unbekannt", None, False)
def is_editable(filename=None, mime_type=None):
return describe(filename, mime_type)[2]
def is_known(filename=None, mime_type=None):
"""
Ist das Format in der Tabelle verzeichnet?
Wichtig für die Unterscheidung zweier Fälle: Ein PDF ist BEKANNT und
nicht bearbeitbar — da hilft kein Zureden. Ein unbekanntes Format
dagegen ist womöglich sehr wohl bearbeitbar, es steht nur nicht in der
Tabelle. Dann soll der Anwender entscheiden dürfen, statt vom Programm
ausgebremst zu werden.
"""
return extension(filename, mime_type) in FORMATS
def detail(filename=None, mime_type=None):
"""
Ausführliche Angabe für Hinweistexte: Anzeigename plus die Rohdaten,
aus denen er abgeleitet wurde. Damit lässt sich erkennen, welche
Formate in FORMATS ergänzt werden sollten.
"""
name = describe(filename, mime_type)[0]
teile = [t for t in (filename, mime_type) if t]
return "%s%s" % (name, ", ".join(teile)) if teile else name
# Dokumentart -> Vorgabeformat für Dateien, die noch nie gespeichert wurden.
# Ermittelt über supportsService() am geöffneten Dokument.
SERVICE_DEFAULTS = (
("com.sun.star.text.TextDocument", ".odt", "writer8"),
("com.sun.star.sheet.SpreadsheetDocument", ".ods", "calc8"),
("com.sun.star.presentation.PresentationDocument", ".odp", "impress8"),
("com.sun.star.drawing.DrawingDocument", ".odg", "draw8"),
("com.sun.star.formula.FormulaProperties", ".odf", "math8"),
)
def default_for_document(doc):
"""
(Endung, Filter) für ein Dokument ohne Speicherort.
Ein neu angelegtes, noch nie gespeichertes Dokument hat keinen URL.
Damit es trotzdem abgelegt werden kann, wird es in ein temporäres
Verzeichnis geschrieben - im zur Anwendung passenden ODF-Format.
"""
for service, ext, filt in SERVICE_DEFAULTS:
try:
if doc.supportsService(service):
return ext, filt
except Exception:
continue
return ".odt", "writer8"
def store_filter(filename=None, mime_type=None):
"""LibreOffice-Filtername zum Speichern im Originalformat."""
return describe(filename, mime_type)[1]
def label(filename=None, mime_type=None):
"""Kurzbeschreibung für die Anzeige, z. B. 'ODF Text'."""
return describe(filename, mime_type)[0]

View File

@@ -0,0 +1,392 @@
"""
paperless_i18n.py - Oberflächentexte in Deutsch, Englisch und Französisch.
Als Schlüssel dient der deutsche Text selbst. Das hält den Quelltext
lesbar - man sieht an der Aufrufstelle, was dort steht - und erspart eine
zweite Katalogdatei für die Ausgangssprache.
Fehlt eine Übersetzung, erscheint der deutsche Text. Das ist unschön, aber
besser als ein leerer Dialog oder ein Absturz.
Die Sprache wird aus der Einstellung von LibreOffice gelesen, nicht aus der
Umgebung des Betriebssystems - maßgeblich ist die Sprache der Oberfläche,
in der die Erweiterung erscheint.
"""
_SPRACHE = None
def sprache(ctx=None):
"""
Zweibuchstabiger Sprachcode der LibreOffice-Oberfläche.
Ermittelt über die Einstellung /org.openoffice.Setup/L10N/ooLocale,
ersatzweise über die Umgebungsvariablen. Alles außer en und fr fällt
auf Deutsch zurück, weil das die Ausgangssprache ist.
"""
global _SPRACHE
if _SPRACHE is not None:
return _SPRACHE
code = ""
if ctx is not None:
try:
import uno
from com.sun.star.beans import PropertyValue
p = PropertyValue()
p.Name, p.Value = "nodepath", "/org.openoffice.Setup/L10N"
provider = ctx.ServiceManager.createInstanceWithContext(
"com.sun.star.configuration.ConfigurationProvider", ctx)
node = provider.createInstanceWithArguments(
"com.sun.star.configuration.ConfigurationAccess", (p,))
code = str(node.getByName("ooLocale") or "")
except Exception: # noqa: BLE001
code = ""
if not code:
import os
for var in ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"):
code = os.environ.get(var) or ""
if code:
break
code = code.replace("_", "-").split("-")[0].lower()
_SPRACHE = code if code in ("de", "en", "fr") else "de"
return _SPRACHE
def setze_sprache(code):
"""Nur für Prüfzwecke: Sprache fest vorgeben."""
global _SPRACHE
_SPRACHE = code
def t(text, ctx=None):
"""Übersetzt einen Text in die Oberflächensprache."""
lang = sprache(ctx)
if lang == "de":
return text
return KATALOG.get(lang, {}).get(text, text)
# ---------------------------------------------------------------------------
# Katalog. Schlüssel ist der deutsche Text, exakt wie im Quelltext.
# ---------------------------------------------------------------------------
EN = {
# -- Allgemein
"Paperless — Fehler": "Paperless — Error",
"Abbrechen": "Cancel",
"Speichern": "Save",
"Öffnen": "Open",
"Suchen": "Search",
"Suche": "Search",
"Titel": "Title",
"Erstellt": "Created",
"Format": "Format",
"Bezeichnung": "Label",
"Dokumente": "Documents",
"Versionen": "Versions",
"Nr": "No",
"ASN": "ASN",
"Schließen": "Close",
"Anlegen": "Create",
"Ablegen": "Store",
"Erzeugen": "Create",
"Ziel": "Target",
"Tags": "Tags",
"Korrespondent": "Correspondent",
"Dokumenttyp": "Document type",
"Tag": "Tag",
# -- Verbindung
"Paperless — Verbindung": "Paperless — Connection",
"Basisadresse": "Base address",
"z. B. https://dms.example.org": "e.g. https://dms.example.org",
"API-Token": "API token",
"Im Benutzerprofil von Paperless erzeugen":
"Create it in your Paperless user profile",
"TLS-Zertifikat prüfen": "Verify TLS certificate",
"Abschalten nur für Testinstanzen mit selbst ausgestelltem "
"Zertifikat. Ohne Prüfung ist die Verbindung angreifbar.":
"Disable only for test instances with a self-signed certificate. "
"Without verification the connection can be attacked.",
"Versionslabel": "Version label",
"{datum} wird ersetzt": "{datum} is replaced",
"Bitte zuerst Basisadresse und Token hinterlegen.":
"Please configure base address and token first.",
"Verbindung erfolgreich hergestellt.": "Connection established.",
"Gespeichert, aber die Verbindung schlug fehl:\n\n%s":
"Saved, but the connection failed:\n\n%s",
# -- Öffnen
"Dokument aus Paperless öffnen": "Open document from Paperless",
"Leer lassen für die zuletzt hinzugefügten Dokumente. "
"Archiv-Seriennummer funktioniert ebenfalls.":
"Leave empty for the most recently added documents. "
"The archive serial number works as well.",
"Öffnen als": "Open as",
"Bearbeitbar": "Editable",
"Schreibgeschützt": "Read-only",
"Keine Treffer.": "No matches.",
"Fehler bei der Suche: %s": "Search failed: %s",
"Versionen werden geladen …": "Loading versions …",
"Kein Zieldokument gewählt.": "No target document selected.",
"Bearbeitbar. Änderungen können als neue Version abgelegt "
"werden.":
"Editable. Changes can be stored as a new version.",
"Format nicht erkannt: %s\nBearbeitbar öffnen ist möglich — beim "
"Zurückschreiben wird ODF verwendet.":
"Format not recognised: %s\nOpening as editable is possible — "
"ODF will be used when writing back.",
"%s ist in LibreOffice nicht sinnvoll bearbeitbar.\n"
"Schreibgeschützt empfohlen — bearbeitbar ist trotzdem wählbar.":
"%s cannot reasonably be edited in LibreOffice.\n"
"Read-only recommended — editable remains available.",
"Schreibgeschützt geöffnet.\n\nZum Ablegen einer neuen Version muss "
"das Dokument bearbeitbar geöffnet werden.":
"Opened read-only.\n\nTo store a new version the document must be "
"opened as editable.",
# -- Ablegen
"In Paperless ablegen": "Store in Paperless",
"Neues Dokument anlegen": "Create a new document",
"An bestehendes Dokument anhängen": "Append to an existing document",
"Klassifizierung des geöffneten Dokuments übernehmen":
"Copy classification from the open document",
"Danach mit dem Zieldokument weiterarbeiten":
"Continue working with the target document",
"Das geöffnete Fenster wird dem Ziel zugeordnet. Weitere "
"Versionen landen dann dort.":
"The open window will be linked to the target. Further versions "
"will go there.",
"Kein Dokument geöffnet.": "No document open.",
"Als neue Version im Archiv ablegen?\n\n":
"Store as a new version in the archive?\n\n",
"Die bisherige Fassung bleibt als Version erhalten.":
"The previous state is kept as a version.",
"Neue Version abgelegt.\n\n": "New version stored.\n\n",
"Neues Dokument angelegt.\n\n": "New document created.\n\n",
"Als neue Version angehängt.\n\n": "Appended as a new version.\n\n",
"Die geöffnete Datei als neue Version anhängen?\n\n":
"Append the open file as a new version?\n\n",
"Unerwartete Antwort beim Hochladen:\n%r":
"Unexpected response while uploading:\n%r",
"Das Dokument konnte nicht zwischengespeichert werden:\n%s":
"The document could not be buffered:\n%s",
"Speichern fehlgeschlagen:\n%s": "Saving failed:\n%s",
# -- Klassifizierung
"Klassifizierung bearbeiten": "Edit classification",
"Format JJJJ-MM-TT": "Format YYYY-MM-DD",
"Mehrfachauswahl mit Strg oder Umschalt":
"Multiple selection with Ctrl or Shift",
"Klassifizierung gespeichert.": "Classification saved.",
"Stammdaten konnten nicht geladen werden:\n%s":
"Master data could not be loaded:\n%s",
# -- Teilen
"Dokument teilen": "Share document",
"Gültigkeit in Tagen": "Validity in days",
"0 = unbegrenzt gültig": "0 = valid indefinitely",
"Dateivariante": "File variant",
"Original": "Original",
"Archivfassung (PDF)": "Archived version (PDF)",
"Dokument mit einem Tag kennzeichnen": "Mark document with a tag",
"Macht geteilte Dokumente auffindbar. Ist der Tag noch nicht "
"vorhanden, wird er nach Rückfrage angelegt.":
"Makes shared documents findable. If the tag does not exist yet "
"it will be created after confirmation.",
"Freigabelink erzeugt": "Share link created",
"Nicht abgelegte Änderungen": "Unsaved changes",
"Trotzdem teilen": "Share anyway",
"Erst neue Version ablegen": "Store new version first",
# -- Status
"Paperless — Status": "Paperless — Status",
"Status anzeigen": "Show status",
# -- Nachträge
"Link (zum Kopieren markieren):": "Link (select to copy):",
"leer = Vorgabe. Nur ändern, wenn ein Proxy blockt.":
"empty = default. Change only if a proxy blocks it.",
"Dieses Dokument stammt nicht aus Paperless.\n\n"
"Es wurde keine Paperless-Kennung gefunden. Öffne es "
"über „Aus Paperless öffnen“, damit die "
"Zuordnung erhalten bleibt.":
"This document did not come from Paperless.\n\n"
"No Paperless identifier was found. Open it via "
"\u201cOpen from Paperless\u201d so the link is kept.",
"Dieses Dokument liegt noch nicht in Paperless.\n\n"
"Ein Freigabelink kann erst erzeugt werden, wenn es "
"abgelegt ist.\n\nJetzt ablegen?":
"This document is not in Paperless yet.\n\n"
"A share link can only be created once it is stored.\n\n"
"Store it now?",
"Das geöffnete Dokument enthält Änderungen, die noch nicht "
"in Paperless abgelegt sind.\n\nDer Link würde auf die "
"zuletzt abgelegte Fassung zeigen, nicht auf das, was du "
"gerade vor dir hast.":
"The open document has changes that have not been stored in "
"Paperless.\n\nThe link would point to the last stored state, "
"not to what you see in front of you.",
}
FR = {
"Paperless — Fehler": "Paperless — Erreur",
"Abbrechen": "Annuler",
"Speichern": "Enregistrer",
"Öffnen": "Ouvrir",
"Suchen": "Rechercher",
"Suche": "Recherche",
"Titel": "Titre",
"Erstellt": "Créé le",
"Format": "Format",
"Bezeichnung": "Libellé",
"Dokumente": "Documents",
"Versionen": "Versions",
"Nr": "",
"ASN": "ASN",
"Schließen": "Fermer",
"Anlegen": "Créer",
"Ablegen": "Enregistrer",
"Erzeugen": "Créer",
"Ziel": "Cible",
"Tags": "Étiquettes",
"Korrespondent": "Correspondant",
"Dokumenttyp": "Type de document",
"Tag": "Étiquette",
"Paperless — Verbindung": "Paperless — Connexion",
"Basisadresse": "Adresse de base",
"z. B. https://dms.example.org": "p. ex. https://dms.example.org",
"API-Token": "Jeton d'API",
"Im Benutzerprofil von Paperless erzeugen":
"À créer dans votre profil utilisateur Paperless",
"TLS-Zertifikat prüfen": "Vérifier le certificat TLS",
"Abschalten nur für Testinstanzen mit selbst ausgestelltem "
"Zertifikat. Ohne Prüfung ist die Verbindung angreifbar.":
"À désactiver uniquement pour une instance de test avec un "
"certificat auto-signé. Sans vérification, la connexion est "
"vulnérable.",
"Versionslabel": "Libellé de version",
"{datum} wird ersetzt": "{datum} sera remplacé",
"Bitte zuerst Basisadresse und Token hinterlegen.":
"Veuillez d'abord renseigner l'adresse de base et le jeton.",
"Verbindung erfolgreich hergestellt.": "Connexion établie.",
"Gespeichert, aber die Verbindung schlug fehl:\n\n%s":
"Enregistré, mais la connexion a échoué :\n\n%s",
"Dokument aus Paperless öffnen": "Ouvrir un document depuis Paperless",
"Leer lassen für die zuletzt hinzugefügten Dokumente. "
"Archiv-Seriennummer funktioniert ebenfalls.":
"Laisser vide pour les documents ajoutés récemment. "
"Le numéro de série d'archive fonctionne également.",
"Öffnen als": "Ouvrir en",
"Bearbeitbar": "Modifiable",
"Schreibgeschützt": "Lecture seule",
"Keine Treffer.": "Aucun résultat.",
"Fehler bei der Suche: %s": "Échec de la recherche : %s",
"Versionen werden geladen …": "Chargement des versions …",
"Kein Zieldokument gewählt.": "Aucun document cible sélectionné.",
"Bearbeitbar. Änderungen können als neue Version abgelegt "
"werden.":
"Modifiable. Les modifications peuvent être enregistrées comme "
"nouvelle version.",
"Format nicht erkannt: %s\nBearbeitbar öffnen ist möglich — beim "
"Zurückschreiben wird ODF verwendet.":
"Format non reconnu : %s\nL'ouverture en modification est "
"possible — le format ODF sera utilisé lors de l'enregistrement.",
"%s ist in LibreOffice nicht sinnvoll bearbeitbar.\n"
"Schreibgeschützt empfohlen — bearbeitbar ist trotzdem wählbar.":
"%s ne peut pas être modifié utilement dans LibreOffice.\n"
"Lecture seule recommandée — la modification reste possible.",
"Schreibgeschützt geöffnet.\n\nZum Ablegen einer neuen Version muss "
"das Dokument bearbeitbar geöffnet werden.":
"Ouvert en lecture seule.\n\nPour enregistrer une nouvelle "
"version, le document doit être ouvert en modification.",
"In Paperless ablegen": "Enregistrer dans Paperless",
"Neues Dokument anlegen": "Créer un nouveau document",
"An bestehendes Dokument anhängen": "Ajouter à un document existant",
"Klassifizierung des geöffneten Dokuments übernehmen":
"Reprendre le classement du document ouvert",
"Danach mit dem Zieldokument weiterarbeiten":
"Poursuivre ensuite avec le document cible",
"Das geöffnete Fenster wird dem Ziel zugeordnet. Weitere "
"Versionen landen dann dort.":
"La fenêtre ouverte sera associée à la cible. Les versions "
"suivantes y seront enregistrées.",
"Kein Dokument geöffnet.": "Aucun document ouvert.",
"Als neue Version im Archiv ablegen?\n\n":
"Enregistrer comme nouvelle version dans l'archive ?\n\n",
"Die bisherige Fassung bleibt als Version erhalten.":
"La version précédente est conservée.",
"Neue Version abgelegt.\n\n": "Nouvelle version enregistrée.\n\n",
"Neues Dokument angelegt.\n\n": "Nouveau document créé.\n\n",
"Als neue Version angehängt.\n\n": "Ajouté comme nouvelle version.\n\n",
"Die geöffnete Datei als neue Version anhängen?\n\n":
"Ajouter le fichier ouvert comme nouvelle version ?\n\n",
"Unerwartete Antwort beim Hochladen:\n%r":
"Réponse inattendue lors de l'envoi :\n%r",
"Das Dokument konnte nicht zwischengespeichert werden:\n%s":
"Le document n'a pas pu être mis en cache :\n%s",
"Speichern fehlgeschlagen:\n%s": "Échec de l'enregistrement :\n%s",
"Klassifizierung bearbeiten": "Modifier le classement",
"Format JJJJ-MM-TT": "Format AAAA-MM-JJ",
"Mehrfachauswahl mit Strg oder Umschalt":
"Sélection multiple avec Ctrl ou Maj",
"Klassifizierung gespeichert.": "Classement enregistré.",
"Stammdaten konnten nicht geladen werden:\n%s":
"Les données de référence n'ont pas pu être chargées :\n%s",
"Dokument teilen": "Partager le document",
"Gültigkeit in Tagen": "Validité en jours",
"0 = unbegrenzt gültig": "0 = valable sans limite",
"Dateivariante": "Variante du fichier",
"Original": "Original",
"Archivfassung (PDF)": "Version archivée (PDF)",
"Dokument mit einem Tag kennzeichnen":
"Marquer le document d'une étiquette",
"Macht geteilte Dokumente auffindbar. Ist der Tag noch nicht "
"vorhanden, wird er nach Rückfrage angelegt.":
"Permet de retrouver les documents partagés. Si l'étiquette "
"n'existe pas, elle sera créée après confirmation.",
"Freigabelink erzeugt": "Lien de partage créé",
"Nicht abgelegte Änderungen": "Modifications non enregistrées",
"Trotzdem teilen": "Partager quand même",
"Erst neue Version ablegen": "Enregistrer d'abord une nouvelle version",
"Paperless — Status": "Paperless — État",
"Status anzeigen": "Afficher l'état",
# -- Nachträge
"Link (zum Kopieren markieren):": "Lien (sélectionner pour copier) :",
"leer = Vorgabe. Nur ändern, wenn ein Proxy blockt.":
"vide = valeur par défaut. À modifier uniquement si un proxy "
"bloque.",
"Dieses Dokument stammt nicht aus Paperless.\n\n"
"Es wurde keine Paperless-Kennung gefunden. Öffne es "
"über „Aus Paperless öffnen“, damit die "
"Zuordnung erhalten bleibt.":
"Ce document ne provient pas de Paperless.\n\n"
"Aucun identifiant Paperless n'a été trouvé. Ouvrez-le via "
"\u00ab Ouvrir depuis Paperless \u00bb afin de conserver "
"l'association.",
"Dieses Dokument liegt noch nicht in Paperless.\n\n"
"Ein Freigabelink kann erst erzeugt werden, wenn es "
"abgelegt ist.\n\nJetzt ablegen?":
"Ce document n'est pas encore dans Paperless.\n\n"
"Un lien de partage ne peut être créé qu'une fois le document "
"enregistré.\n\nL'enregistrer maintenant ?",
"Das geöffnete Dokument enthält Änderungen, die noch nicht "
"in Paperless abgelegt sind.\n\nDer Link würde auf die "
"zuletzt abgelegte Fassung zeigen, nicht auf das, was du "
"gerade vor dir hast.":
"Le document ouvert contient des modifications qui n'ont pas été "
"enregistrées dans Paperless.\n\nLe lien pointerait vers le "
"dernier état enregistré, et non vers ce que vous voyez.",
}
KATALOG = {"en": EN, "fr": FR}