Initial commit
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user