Initial commit
This commit is contained in:
406
pythonpath/paperless_client.py
Normal file
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
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
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
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
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}
|
||||
Reference in New Issue
Block a user