# 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"} 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._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: 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 = [] 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 == "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()