Files
migrate-ecodms-to-paperless/ecodms_notizen.py
2026-09-04 19:51:02 +02:00

259 lines
9.1 KiB
Python

#!/usr/bin/env python3
"""
ecodms_notizen.py - Uebertraegt die Notizen aus econotice nach Paperless.
Liest die export.data-Dateien aller Tranchen. Die Live-Postgres von ecoDMS
enthaelt weder eine Klassifizierungs- noch eine docs-Tabelle - die
inhaltliche Ebene erzeugt erst der Exporter. Die Aufloesung von econotice
auf ein Dokument ist deshalb nur ueber die Exporte moeglich.
export PT=dein_paperless_token
./ecodms_notizen.py --dry-run --csv notizen.csv
./ecodms_notizen.py
Erkenntnisse, die hier kodiert sind:
* econotice.nid enthaelt die docs_id, NICHT die docid. Dasselbe Muster
wie bei docrollen_hist. Inhaltlich verifiziert an drei Faellen mit
auseinanderlaufenden IDs (515 -> 514, 633 -> 622, 869 -> 846).
* econotice.text ist base64-kodiertes Qt-HTML mit CSS-Block im Kopf.
* econotice.username ist im gesamten Bestand leer.
* Die Zuordnung nach Paperless laeuft ueber die Archiv-Seriennummer,
die beim Import auf die ecoDMS-docid gesetzt wurde.
Eine Notiz kann zu einem Dokument aus einer anderen Tranche gehoeren.
Deshalb werden erst ALLE Tranchen eingelesen und eine gemeinsame
Zuordnungstabelle gebaut, bevor uebertragen wird.
Wiederholbar: textgleiche Notizen am selben Dokument werden uebersprungen.
"""
from __future__ import annotations
import argparse
import base64
import binascii
import csv
import html
import os
import re
import sqlite3
import sys
from pathlib import Path
try:
import requests
except ImportError:
sys.exit("Benoetigt python3-requests: sudo zypper in python3-requests")
CSS_REST = re.compile(r"^\s*p,\s*li\s*\{[^}]*\}\s*")
TAGS = re.compile(r"<[^>]+>")
def qt_html_to_text(raw) -> str:
"""base64-kodiertes Qt-HTML -> Klartext."""
if raw is None:
return ""
try:
s = base64.b64decode(raw).decode("utf-8", "replace")
except (binascii.Error, ValueError, TypeError):
s = str(raw)
s = TAGS.sub(" ", s)
s = html.unescape(re.sub(r"\s+", " ", s))
return CSS_REST.sub("", s).strip()
def read_tranches(paths):
"""
Liest alle Tranchen ein und liefert:
notes {notiz_id: {...}} alle Notizen, dedupliziert
mapping {docs_id: docid} ueber alle Tranchen vereinigt
titles {docid: bemerkung} nur fuer die Anzeige
"""
notes, mapping, titles = {}, {}, {}
for p in paths:
if not p.exists():
print(f" uebersprungen (nicht vorhanden): {p}")
continue
con = sqlite3.connect(f"file:{p}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
for r in con.execute("SELECT id, docid FROM docs"):
mapping[r["id"]] = r["docid"]
try:
for r in con.execute(
"SELECT docid, bemerkung FROM klassifizierung"
):
if r["bemerkung"]:
titles.setdefault(r["docid"], r["bemerkung"])
except sqlite3.Error:
pass
n = 0
for r in con.execute("SELECT * FROM econotice"):
notes[r["id"]] = {
"notiz_id": r["id"],
"nid": r["nid"],
"erstellt": r["tdate"],
"text": qt_html_to_text(r["text"]),
"user": (r["username"] or "").strip(),
"tranche": p.parent.parent.name,
}
n += 1
con.close()
print(f" {p.parent.parent.name}: {n} Notizen, "
f"{len(mapping)} Zuordnungen kumuliert")
return notes, mapping, titles
class Paperless:
def __init__(self, base, token, dry_run=False):
self.base = base.rstrip("/")
self.dry_run = dry_run
self.s = requests.Session()
self.s.headers["Authorization"] = f"Token {token}"
self._asn_cache = {}
def load_asn_index(self):
"""Alle Dokumente einmal holen statt je Notiz einzeln abzufragen."""
url = f"{self.base}/api/documents/"
params = {"fields": "id,title,archive_serial_number", "page_size": 200}
while url:
r = self.s.get(url, params=params, timeout=120)
r.raise_for_status()
data = r.json()
for d in data.get("results", []):
asn = d.get("archive_serial_number")
if asn is not None:
self._asn_cache[int(asn)] = d
url, params = data.get("next"), None
return len(self._asn_cache)
def by_asn(self, asn):
return self._asn_cache.get(int(asn))
def notes(self, doc_id):
r = self.s.get(f"{self.base}/api/documents/{doc_id}/notes/", timeout=60)
if r.status_code == 404:
return []
r.raise_for_status()
data = r.json()
return data if isinstance(data, list) else data.get("results", [])
def add_note(self, doc_id, text):
if self.dry_run:
return {"dry_run": True}
r = self.s.post(f"{self.base}/api/documents/{doc_id}/notes/",
json={"note": text}, timeout=60)
if r.status_code >= 400:
raise RuntimeError(f"HTTP {r.status_code}: {r.text[:300]}")
return r.json()
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--exports", default="/data/paperless/export-ecodms",
help="Verzeichnis mit exportN/archive/export.data")
ap.add_argument("--tranchen", default="1-7",
help="Bereich oder Liste, z.B. '1-7' oder '1,3,5'")
ap.add_argument("--api", default="http://localhost:8000")
ap.add_argument("--token", help="oder Umgebungsvariable PT")
ap.add_argument("--csv", help="Notizen zusaetzlich als CSV sichern")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--prefix", default="[ecoDMS {datum}] ",
help="Vorspann je Notiz, {datum} wird ersetzt")
args = ap.parse_args()
token = args.token or os.environ.get("PT")
if not token:
sys.exit("Token fehlt: --token oder export PT=...")
if "-" in args.tranchen:
a, b = args.tranchen.split("-")
nums = range(int(a), int(b) + 1)
else:
nums = [int(x) for x in args.tranchen.split(",")]
paths = [Path(args.exports) / f"export{n}" / "archive" / "export.data"
for n in nums]
print("Tranchen einlesen:")
notes, mapping, titles = read_tranches(paths)
print(f"\n{len(notes)} Notizen insgesamt, "
f"{len(mapping)} docs_id-Zuordnungen\n")
api = Paperless(args.api, token, dry_run=args.dry_run)
print("Paperless-Dokumente laden ...")
print(f" {api.load_asn_index()} Dokumente mit Archiv-Seriennummer\n")
if args.dry_run:
print("TROCKENLAUF - es wird nichts geschrieben\n")
writer = fh = None
if args.csv:
fh = open(args.csv, "w", newline="", encoding="utf-8")
writer = csv.writer(fh)
writer.writerow(["notiz_id", "docs_id", "ecodms_docid", "paperless_id",
"titel", "erstellt", "notiz", "status"])
stats = {}
def count(key):
stats[key] = stats.get(key, 0) + 1
for r in sorted(notes.values(), key=lambda x: x["notiz_id"]):
text, status = r["text"], ""
docid = paperless_id = titel = ""
try:
docs_id = int(r["nid"])
except (TypeError, ValueError):
docs_id = None
if not text:
status = "leer"; count("leer")
elif docs_id is None:
status = f"nid unlesbar: {r['nid']!r}"; count("nid_unlesbar")
elif docs_id not in mapping:
status = "docs_id in keiner Tranche"; count("ohne_zuordnung")
else:
docid = mapping[docs_id]
titel = titles.get(docid, "")
doc = api.by_asn(docid)
if doc is None:
status = f"kein Paperless-Dokument mit ASN {docid}"
count("ohne_dokument")
else:
paperless_id = doc["id"]
titel = doc.get("title") or titel
voll = args.prefix.format(datum=str(r["erstellt"])[:10]) + text
try:
if any(str(n.get("note", "")).strip() == voll.strip()
for n in api.notes(paperless_id)):
status = "bereits vorhanden"; count("vorhanden")
else:
api.add_note(paperless_id, voll)
status = "uebertragen"; count("uebertragen")
except Exception as exc: # noqa: BLE001
status = f"FEHLER: {exc}"; count("fehler")
if writer:
writer.writerow([r["notiz_id"], r["nid"], docid, paperless_id,
titel, r["erstellt"], text, status])
if status.startswith("FEHLER") or status.startswith("kein ") \
or status.startswith("docs_id"):
print(f" notiz {r['notiz_id']} (docs_id {r['nid']}): {status}")
print(f" {text[:90]}")
if fh:
fh.close()
print(f"\nCSV geschrieben: {args.csv}")
print()
for k, v in sorted(stats.items()):
print(f" {k:18s} {v}")
if __name__ == "__main__":
main()