# 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 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 ")

    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()
sind, und wird bei