#!/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()