# SPDX-FileCopyrightText: 2026 Olaf Willerding
# SPDX-License-Identifier: EUPL-1.2
"""Erzeugt die Datenschutzerklärung für die Anwendung.
## Warum es diese Datei gibt
Die Datenschutzerklärung stand bis Fassung 0.24.2 **nirgends in der
Anwendung**. Das Wort kam in der ganzen Oberfläche kein einziges Mal als
sichtbarer Text vor; `docs/datenschutz.html` wurde nicht mitgeliefert, und der
einzige Weg nach außen führt zur Unterstützungsseite und nicht zur Erklärung.
Zugleich sagt „Über diese Software“ zu, die Anwendung übertrage keine Daten —
eine Zusage, deren Beleg der Nutzende nicht erreichen konnte.
## Warum aus derselben Quelle und nicht daneben
Der Fehler, der hier droht, ist nicht „die Erklärung fehlt“. Er ist: **es gibt
sie zweimal, und die zweite ist die falsche.** Der Fall ist bereits
eingetreten — die veröffentlichte Seite stand am 30.08.2026 auf Fassung 1.1,
während `docs/datenschutz.md` bei 1.5 war. Der Erzeuger der HTML-Fassung hatte
gehalten, was er sollte; ausgelaufen ist der Schritt, den nur ein Mensch tut.
Deshalb wird hier nichts abgeschrieben. Dieses Werkzeug ruft **denselben**
Markdown-Umsetzer auf wie `datenschutz_html.py` — es gibt genau einen Parser
für dieses Dokument — und setzt dessen HTML in Blöcke um, die die Anwendung
ohne `dangerouslySetInnerHTML` darstellen kann.
## Warum über das HTML und nicht über das Markdown
Der Umweg ist Absicht. Ein zweiter Markdown-Parser wäre eine zweite Meinung
darüber, was im Dokument steht — und die erste ist sorgfältig gegen fünf
Auszeichnungsformen abgesichert, an denen sie ausdrücklich abbricht
(Blockzitat, Aufzählung mit `*`, vierte Überschriftenebene, verschachtelte
Liste, Kursivsatz; siehe Befund H41). Diese Absicherung gilt hier mit, ohne
dass sie ein zweites Mal geschrieben werden müsste.
Der Tagvorrat der Ausgabe ist klein und geschlossen — nachgezählt am
erzeugten Dokument vom 30.08.2026: `h1`, `h2`, `h3`, `p`, `ul`, `li`, `pre`,
`code`, `strong`, `a`. Alles andere lässt dieses Werkzeug abbrechen.
## Die Wache
`content/datenschutz.json` trägt die Prüfsumme des Veröffentlichungsteils von
`docs/datenschutz.md`. `app/tests/datenschutz.test.ts` rechnet sie nach und
wird rot, sobald jemand die Quelle ändert und diesen Erzeuger vergisst. Ein
erzeugtes Dokument, das niemand neu erzeugt, ist stiller falsch als ein
handgeschriebenes — weil niemand mehr hinsieht.
Aufruf: ``python tools/datenschutz_anwendung.py``
"""
from __future__ import annotations
import hashlib
import json
import re
import sys
from html.parser import HTMLParser
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from datenschutz_html import kennung, umsetzen, veroeffentlichungsteil # noqa: E402
WURZEL = Path(__file__).resolve().parent.parent
QUELLE = WURZEL / "docs" / "datenschutz.md"
ZIEL = WURZEL / "content" / "datenschutz.json"
#: Die Standtabelle am Ende der Erklärung führt beide Angaben. Sie werden
#: gelesen und nicht hier eingetragen: Eine Fassungsnummer an zwei Orten ist
#: genau die zweite Wahrheit, die dieses Werkzeug verhindern soll.
FASSUNG_MUSTER = re.compile(r"^\|\s*\*\*Fassung der Erklärung\*\*\s*\|\s*([^|]+?)\s*\|", re.M)
STAND_MUSTER = re.compile(r"^\|\s*\*\*Stand\*\*\s*\|\s*([^|]+?)\s*\|", re.M)
#: Genau die Elemente, die `datenschutz_html.umsetzen()` erzeugt.
BLOCKTAGS = {"h1", "h2", "h3", "p", "ul", "pre", "table"}
#: Die Teile einer Tabelle. Sie stehen INNERHALB von
und dürfen
#: deshalb nicht in BLOCKTAGS – sonst überschriebe den Blockzustand.
TABELLENTAGS = {"thead", "tbody", "tr", "th", "td"}
TEILTAGS = {"strong", "code", "a"}
class Umsetzer(HTMLParser):
"""Setzt das erzeugte HTML in Blöcke um und bricht bei allem Fremden ab."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.bloecke: list[dict[str, object]] = []
self._block: str | None = None
self._teile: list[dict[str, str]] = []
self._punkte: list[list[dict[str, str]]] = []
self._in_li = False
self._kopf: list[str] = []
self._zeilen: list[list[list[dict[str, str]]]] = []
self._zelle: list[dict[str, str]] | None = None
self._teil: str | None = None
self._ziel: str | None = None
# ── Hilfen ───────────────────────────────────────────────────────────
def _text_anhaengen(self, roh: str) -> None:
if self._block is None and not self._in_li and self._zelle is None:
if roh.strip():
self._abbruch(f"Text außerhalb eines Blocks: {roh.strip()[:60]!r}")
return
art = self._teil or "text"
eintrag: dict[str, str] = {"art": art, "text": roh}
if art == "verweis":
eintrag["ziel"] = self._ziel or ""
self._teile.append(eintrag)
def _abbruch(self, was: str) -> None:
sys.exit(f"ABBRUCH: {was}. Der Umsetzer kennt diese Form nicht.")
@staticmethod
def _zusammenfassen(teile: list[dict[str, str]]) -> list[dict[str, str]]:
"""Benachbarte gleichartige Teile verschmelzen, Leeres entfällt."""
fertig: list[dict[str, str]] = []
for teil in teile:
if teil["text"] == "":
continue
if (
fertig
and fertig[-1]["art"] == teil["art"] == "text"
):
fertig[-1]["text"] += teil["text"]
else:
fertig.append(dict(teil))
return fertig
# ── HTMLParser ───────────────────────────────────────────────────────
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
werte = dict(attrs)
if tag in BLOCKTAGS:
self._block = tag
self._teile = []
if tag == "ul":
self._punkte = []
if tag == "table":
self._kopf = []
self._zeilen = []
return
if tag in TABELLENTAGS:
if tag in {"th", "td"}:
self._zelle = []
self._teile = []
elif tag == "tr":
self._teile = []
# Auch die Kopfzeile legt eine an; sie bleibt leer, weil ihre
# Zellen | sind, und wird bei |
wieder verworfen.
self._zeilen.append([])
return
if tag == "li":
self._in_li = True
self._teile = []
return
if tag in TEILTAGS:
if tag == "code" and self._block == "pre":
return # – der Inhalt ist reiner Text
self._teil = {"strong": "stark", "code": "kennzeichnung", "a": "verweis"}[tag]
self._ziel = werte.get("href")
return
self._abbruch(f"unbekanntes Element <{tag}>")
def handle_endtag(self, tag: str) -> None:
if tag in TEILTAGS:
if tag == "code" and self._block == "pre":
return
self._teil = None
self._ziel = None
return
if tag == "li":
self._punkte.append(self._zusammenfassen(self._teile))
self._teile = []
self._in_li = False
return
if tag == "ul":
self.bloecke.append({"art": "liste", "punkte": self._punkte})
self._block = None
return
if tag in TABELLENTAGS:
if tag == "tr":
# Die Kopfzeile hinterlässt keine Datenzeile.
if self._zeilen and not self._zeilen[-1]:
self._zeilen.pop()
return
if tag == "th":
self._kopf.append("".join(t["text"] for t in self._zusammenfassen(self._teile)))
elif tag == "td":
assert self._zelle is not None
self._zelle = self._zusammenfassen(self._teile)
if not self._zeilen:
self._abbruch("Tabellenzelle ohne Zeile")
self._zeilen[-1].append(self._zelle)
if tag in {"th", "td"}:
self._zelle = None
self._teile = []
return
if tag == "table":
self.bloecke.append(
{"art": "tabelle", "kopf": self._kopf, "zeilen": self._zeilen}
)
self._block = None
self._teile = []
return
if tag == "pre":
self.bloecke.append(
{"art": "code", "text": "".join(t["text"] for t in self._teile)}
)
self._block = None
self._teile = []
return
if tag in {"h1", "h2", "h3"}:
teile = self._zusammenfassen(self._teile)
text = "".join(t["text"] for t in teile)
self.bloecke.append(
{
"art": "ueberschrift",
"ebene": int(tag[1]),
"text": text,
"kennung": kennung(text),
}
)
self._block = None
self._teile = []
return
if tag == "p":
self.bloecke.append({"art": "absatz", "teile": self._zusammenfassen(self._teile)})
self._block = None
self._teile = []
return
self._abbruch(f"unbekanntes schließendes Element {tag}>")
def handle_data(self, data: str) -> None:
self._text_anhaengen(data)
def pruefsumme(text: str) -> str:
"""SHA-256 über den Veröffentlichungsteil, mit Zeilenenden vereinheitlicht.
Ohne die Vereinheitlichung wäre die Wache auf einem Arbeitsbaum mit CRLF
dauerhaft und grundlos rot – und eine grundlos rote Wache wird
abgeschaltet.
"""
return hashlib.sha256(text.replace("\r\n", "\n").encode("utf-8")).hexdigest()
def main() -> None:
markdown = QUELLE.read_text(encoding="utf-8")
teil = veroeffentlichungsteil(markdown)
fassung = FASSUNG_MUSTER.search(markdown)
stand = STAND_MUSTER.search(markdown)
if fassung is None or stand is None:
sys.exit(
"ABBRUCH: Fassung oder Stand nicht in der Standtabelle gefunden. "
"Beide werden gelesen und nicht eingetragen; ohne sie wüsste die "
"Anwendung nicht, welche Fassung sie zeigt."
)
umsetzer = Umsetzer()
umsetzer.feed(umsetzen(teil))
umsetzer.close()
if not umsetzer.bloecke:
sys.exit("ABBRUCH: kein einziger Block entstanden.")
inhalt = {
"quellpruefsumme": pruefsumme(teil),
"fassung": fassung.group(1),
"stand": stand.group(1),
"bloecke": umsetzer.bloecke,
}
ZIEL.parent.mkdir(parents=True, exist_ok=True)
with ZIEL.open("w", encoding="utf-8", newline="\n") as datei:
json.dump(inhalt, datei, ensure_ascii=False, indent=2)
datei.write("\n")
zahl = len(umsetzer.bloecke)
print(f"Geschrieben: {ZIEL.relative_to(WURZEL)} ({zahl} Blöcke, Fassung {inhalt['fassung']}).")
if __name__ == "__main__":
main()