13 KiB
Das ecoDMS-Exportformat
Nachschlagewerk zu den Eigenheiten des Exports. Der größte Teil davon steht in keiner Dokumentation und wurde bei der Migration eines gewachsenen Bestands aufgedeckt.
Wer nur ein Dokument aus diesem Repository liest, sollte es dieses sein. Fast jeder der hier beschriebenen Punkte kann zu stiller Fehlzuordnung führen — zu Dokumenten am falschen Platz, zu Rechten an der falschen Stelle, zu verlorenen Versionen.
Aufbau eines Exports
Das ecoDMS-Exportwerkzeug erzeugt ein ZIP mit dieser Struktur:
offline_export/
├── archive/
│ ├── export.data SQLite-Datenbank mit Metadaten
│ ├── export.xml dieselben Daten plus Historie
│ ├── lucene/ Suchindex des Offline-Clients
│ ├── ecodms_docid_0010325.pdf Kopfversion als PDF/A
│ ├── ecodms_docid_0010325_revision_0001.odt Original je Version
│ └── …
└── (Programmdateien des Offline-Clients)
Die Dateien liegen im Dateisystem, nicht in der Datenbank. Die
BLOB-Spalten data, pdf und ocrtext in export.data sind
durchgehend NULL. Das ist gut so — eine SQLite mit dem gesamten
Dateibestand wäre unhandlich.
Fallstrick 1: docid gegen docs_id
Der wichtigste Punkt des ganzen Dokuments.
ecoDMS führt zwei Nummern, die leicht verwechselt werden:
| Nummer | Bedeutung | Wo |
|---|---|---|
docid |
das Dokument | klassifizierung.docid, ecosimsversions.docid |
docs_id |
die Klassifizierungsinstanz | docs.id |
Bei niedrigen Nummern stimmen beide zufällig überein. Bei höheren laufen sie auseinander:
docs.id 718 → docid 697
docs.id 2022 → docid 1960
Ein Join über die falsche Spalte liefert deshalb bei alten Dokumenten korrekte Ergebnisse und bei neuen falsche. Das fällt beim Testen mit wenigen Datensätzen nicht auf.
Verschärfung: falsch benannte Spalten
Zwei Tabellen haben eine Spalte namens docid, die in Wahrheit die
docs_id enthält:
CREATE TABLE docrollen_hist(docid bigint, role varchar, doc_right char(1), revision varchar);
-- ^^^^^ enthält docs_id
CREATE TABLE econotice(id bigint, text vcharacter, tdate timestamp, nid vcharacter, username varchar);
-- ^^^ enthält docs_id
Der korrekte Join ist in beiden Fällen … = docs.id.
So prüft man es
Nicht an Datensätzen mit niedrigen Nummern — dort sind beide Werte gleich. Such gezielt Fälle mit Abweichung und prüf inhaltlich, welche Auflösung zum Datensatz passt:
SELECT n.id, n.nid,
(SELECT docid FROM docs WHERE id = CAST(n.nid AS INTEGER)) AS als_docs_id,
(SELECT id FROM docs WHERE docid = CAST(n.nid AS INTEGER)) AS als_docid
FROM econotice n
WHERE als_docs_id IS NOT als_docid;
Dann die Bemerkung der beiden Kandidaten ansehen. Eine Notiz „an Herrn X gesendet" gehört zum Dienstplan, nicht zur Zinsmitteilung der Bank.
Fallstrick 2: Mehrfachklassifizierung
Ein Dokument kann in ecoDMS mehrfach abgelegt sein — dieselbe Datei in zwei Ordnern, mit je eigener Klassifizierung.
docs.id 10829 (kid 22325) → Haushalt/Kleingeräte, Zeitraum leer
docs.id 10834 (kid 22540) → Steuer/ESt/Anlage N, Zeitraum 2025
beide: docid 10347
Daraus folgt: Eine docid entspricht nicht einer Zeile in docs.
Wer nach docs_id gruppiert, legt das Dokument zweimal an.
Paperless bildet das sogar besser ab, weil Tags many-to-many sind: Aus zwei
Ablageorten wird ein Dokument mit den Tags beider Pfade. Man braucht aber
eine Zusammenführungsregel für die skalaren Felder — bewährt hat sich: der
jüngste ctimestamp gewinnt, Ordner werden vereinigt, Abweichungen bei
Titel, Dokumentart, Status und Datum werden protokolliert.
Häufigkeit im Testbestand: eines von vier Dokumenten einer Stichprobe. Nicht als Randfall behandeln.
Fallstrick 3: Revision ist nicht Version
Zwei völlig unabhängige Zähler:
| Begriff | Wo | Zählt |
|---|---|---|
klassifizierung.revision |
1.0, 1.1, 1.2 … |
Klassifizierungsänderungen |
ecosimsversions.version |
1, 2, 3 … |
Dateiversionen |
Beispiele aus einem echten Bestand:
| docid | Dateiversionen | Revision |
|---|---|---|
| 10325 | 4 | 1.3 (= 4. Revision) |
| 10422 | 3 | 1.1 (= 2. Revision) |
| 11072 | 0 | 1.5 (= 6. Revision) |
| 11141 | 1 | 1.2 (= 3. Revision) |
Es gibt keine Umrechnung. Für die Versionskette in Paperless zählt
ausschließlich ecosimsversions.version.
Und beachte docid 11072: null Dateiversionen. Solche Dokumente haben
keine Zeile in ecosimsversions und müssen auf ecosimsarchive
zurückfallen, sonst verschwinden sie stillschweigend.
Fallstrick 4: Immer das Original importieren
ecosimsversions führt zwei Dateinamen:
| Spalte | Inhalt |
|---|---|
realname |
Originaldatei |
pdfrealname |
PDF/A-Fassung |
Immer realname verwenden. Der Unterschied ist nicht kosmetisch:
- Bei ZUGFeRD enthält das Original die eingebettete XML. Eine neu gerenderte Fassung womöglich nicht.
- Bei E-Mails enthält die
.emldie Anhänge. Das gerenderte PDF nicht. In einem Testfall: 34,0 KB Original gegen 25,8 KB PDF — die Differenz war der Rechnungsanhang.
Bei reinen PDF-Dokumenten sind beide Spalten identisch, die Regel kostet dort also nichts.
Was fehlt
Bei Nicht-PDF-Originalen exportiert ecoDMS pro Version nur das
Original, nicht die PDF/A-Fassung — obwohl pdfrealname sie referenziert.
Die XML verweist dann auf Dateien, die im Archiv nicht existieren.
Für Paperless unkritisch, es erzeugt Archivdateien selbst. Aber ein Skript, das blind allen Referenzen folgt, läuft ins Leere.
Fallstrick 5: Rechte sind historisiert
docrollen_hist ist eine Historientabelle. Sie enthält alle jemals
gesetzten Rechte, nicht nur die aktuellen:
docs_id 10829 ecoSIMSUSER W 1.0
docs_id 10829 r_benutzer_a W 1.1
docs_id 10829 r_benutzer_b W 1.1
ecoSIMSUSER ist das globale Importrecht — jeder hat Zugriff. Es wird
beim Import gesetzt und bei der ersten Klassifizierung durch die echten
Berechtigten ersetzt.
Wer alle Zeilen übernimmt, öffnet jedes Dokument für alle.
Maßgeblich sind nur die Zeilen mit der höchsten in docrollen_hist
vorhandenen Revision je docs_id. Nicht der Revisionswert aus
klassifizierung — der kann höher sein, wenn zuletzt nur die
Klassifizierung geändert wurde.
Revisionen richtig sortieren
revision ist ein String. Lexikografisch ist '1.10' kleiner als
'1.9'. Bei einem Bestand mit mehr als neun Revisionen führt das zu
falschen Ergebnissen. Als Tupel aus Integern parsen:
def revtuple(r):
return tuple(int(p) for p in str(r).split("."))
Dokumente ohne Rechteeintrag
Viele Dokumente haben gar keine Zeile in docrollen_hist — in einer
Stichprobe sechs von acht. Die Tabelle speichert offenbar nur ausdrücklich
vergebene Berechtigungen.
Das ist der gefährlichste Punkt der ganzen Migration, denn:
In Paperless bedeutet „keine Rechte gesetzt" nicht gesperrt, sondern unbeschränkt.
Ein Dokument ohne Eigentümer und ohne Objektrechte ist für jeden angelegten Benutzer sichtbar. Die Migration muss deshalb jedem Dokument einen Eigentümer geben, auch wenn aus ecoDMS nichts überliefert ist.
Fallstrick 6: Base64 an unregelmäßigen Stellen
Manche Felder sind base64-kodiert, benachbarte nicht:
| Kodiert | Klartext |
|---|---|
ecosimsversions.savedate |
ecosimsversions.realname |
ecosimsversions.filename |
ecosimsversions.pdfrealname |
ecosimsarchive.version |
ecosimsarchive.filename |
econotice.text |
econotice.tdate |
XML <date> |
XML letzte-änderung |
ecodmsexporter.exp_query |
Es gibt kein erkennbares System. Im Zweifel dekodieren und bei einem Fehlschlag den Originalwert nehmen.
Notiztexte
econotice.text ist base64-kodiertes Qt-HTML mit CSS-Block im Kopf.
Zum Auspacken:
s = base64.b64decode(raw).decode("utf-8", "replace")
s = re.sub(r"<[^>]+>", " ", s) # Tags
s = html.unescape(re.sub(r"\s+", " ", s)) # Entities, Leerraum
s = re.sub(r"^\s*p,\s*li\s*\{[^}]*\}\s*", "", s) # CSS-Rest
Der letzte Schritt ist nötig, weil der CSS-Block nach dem Entfernen der
Tags als Text übrigbleibt: p, li { white-space: pre-wrap; }
Fallstrick 7: Ordnernamen enthalten Schrägstriche
Ordner heißen in der Praxis auch Telefon / Internet oder TV / Rundfunk.
Wer den Ordnerpfad zu einem String verkettet und später am Schrägstrich
zerlegt, zerschneidet solche Namen in Telefon und Internet.
Die Folge sind Tags mit Leerzeichen am Rand — und wenn das Zielsystem die beim Speichern entfernt, kollidieren sie mit einer echten Ebene gleichen Namens.
Ordnerpfade als Liste von Segmenten führen, nicht als verketteten String.
Wo die Wiedervorlage steckt
Nicht in dynattribute, sondern in klassifizierung.defdate. Es ist
eine eingebaute Funktion, kein dynamisches Attribut. Passend dazu gibt es
in status den Wert Wiedervorlage.
econotice ist nicht die Wiedervorlage, sondern die Notizfunktion.
Die Live-Datenbank ist etwas anderes
Wer die Klassifizierungsdaten in der laufenden ecoDMS-PostgreSQL sucht,
sucht vergeblich. Dort gibt es weder klassifizierung noch docs —
die inhaltliche Ebene erzeugt erst der Exporter aus dem
Container-Speichersystem.
In der Live-Datenbank liegt nur die Verwaltungsebene: Archive, Dateien, Versionen, Benutzer, Rollen, Suchindex.
Das erklärt auch, warum Dokumentverknüpfungen nicht auffindbar sind. Vier unabhängige Prüfungen an einem produktiven Bestand:
- keine Tabelle mit zwei Dokumentspalten
- beim Anlegen einer Verknüpfung entsteht keine neue Zeile in irgendeiner Tabelle
- die Partner-ID kommt in keiner Textspalte vor
- ebenso in keiner Zahlenspalte außer den eigenen Datensätzen
Verknüpfungen sind damit weder im Export noch in der Datenbank greifbar. Sie müssen von Hand nachgetragen werden.
Der Exporter selbst
AND statt OR
Bei manueller Mehrfachauswahl kann der Exporter eine unerfüllbare Abfrage erzeugen:
docid = '11072' AND docid = '11094' AND docid = '11143' AND …
Das Ergebnis ist ein Export mit vollständigen Stammdaten und null
Dokumenten, ohne Fehlermeldung. Die Abfrage steht base64-kodiert in
ecodmsexporter.exp_query:
base64.b64decode(row["exp_query"]).decode()
Nach jedem Export prüfen. Und Exporte besser über ein Suchkriterium oder einen ID-Bereich definieren als über eine Liste einzeln markierter Dokumente.
Tabellen im Export
| Tabelle | Inhalt |
|---|---|
docs |
id (= docs_id), docid, kid, archiv, trashed, attachments |
klassifizierung |
kid, docid, mainfolder, folder, bemerkung, status, revision, docart, ctimestamp, cdate, defdate, docs_id, dyn_* |
ecosimsversions |
docid, version, savedate, realname, pdfrealname, checksum, comment, fixiert |
ecosimsarchive |
Kopfdokument je docid, Rückfall wenn keine Versionen |
docrollen_hist |
docid (= docs_id!), role, doc_right, revision |
econotice |
id, text (base64 Qt-HTML), tdate, nid (= docs_id!), username |
systemordner |
oid, name, parentid — Hierarchie über parentid |
documentenart, status |
Stammdaten, mit trashed-Kennzeichen |
dynattribute |
Spaltenname → Anzeigename und Typ, mit deleted-Kennzeichen |
dup_items, duplicatesindexer |
Duplikaterkennung, für die Migration irrelevant |
attachments bedeutet etwas anderes
docs.attachments zählt eingebettete Anhänge, nicht separate Dateien:
- bei ZUGFeRD die im PDF eingebettete XML
- bei E-Mails die Anhänge innerhalb der
.eml
Beide reisen mit dem Original mit. Es fehlt nichts — solange man
realname importiert.
lucene ist wertlos
Der Ordner enthält den Suchindex des Offline-Clients. Gespeichert sind nur Klassifizierungsdaten, die ohnehin vorliegen. Der Dokumenttext existiert lediglich als kleingeschriebene Termliste im invertierten Index und ist nicht rekonstruierbar.
Schade — sonst hätte man sich das erneute OCR sparen können.
Prüfliste vor jedem Import
exp_querydekodiert und plausibel?- Anzahl Dokumente im Export gegen die Erwartung?
- Alle in der XML referenzierten Dateien im Archiv vorhanden?
- Rollen im Export vollständig in der Übersetzung abgebildet?
- Vorkommende
doc_right-Werte bekannt? - Dateitypen bekannt und von Paperless unterstützt?
- Häufigkeit der Mehrfachklassifizierung geprüft?
- Dokumente ohne Rechteeintrag — Regel festgelegt?
ecodms_extract.py beantwortet all das im Prüfbericht.