251 lines
8.1 KiB
Python
251 lines
8.1 KiB
Python
"""
|
|
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
|