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,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