waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ tools datenschutz_anwendung.py
| 1 | # SPDX-FileCopyrightText: 2026 Olaf Willerding |
| 2 | # SPDX-License-Identifier: EUPL-1.2 |
| 3 | """Erzeugt die Datenschutzerklärung für die Anwendung. |
| 4 | |
| 5 | ## Warum es diese Datei gibt |
| 6 | |
| 7 | Die Datenschutzerklärung stand bis Fassung 0.24.2 **nirgends in der |
| 8 | Anwendung**. Das Wort kam in der ganzen Oberfläche kein einziges Mal als |
| 9 | sichtbarer Text vor; `docs/datenschutz.html` wurde nicht mitgeliefert, und der |
| 10 | einzige Weg nach außen führt zur Unterstützungsseite und nicht zur Erklärung. |
| 11 | Zugleich sagt „Über diese Software“ zu, die Anwendung übertrage keine Daten — |
| 12 | eine Zusage, deren Beleg der Nutzende nicht erreichen konnte. |
| 13 | |
| 14 | ## Warum aus derselben Quelle und nicht daneben |
| 15 | |
| 16 | Der Fehler, der hier droht, ist nicht „die Erklärung fehlt“. Er ist: **es gibt |
| 17 | sie zweimal, und die zweite ist die falsche.** Der Fall ist bereits |
| 18 | eingetreten — die veröffentlichte Seite stand am 30.08.2026 auf Fassung 1.1, |
| 19 | während `docs/datenschutz.md` bei 1.5 war. Der Erzeuger der HTML-Fassung hatte |
| 20 | gehalten, was er sollte; ausgelaufen ist der Schritt, den nur ein Mensch tut. |
| 21 | |
| 22 | Deshalb wird hier nichts abgeschrieben. Dieses Werkzeug ruft **denselben** |
| 23 | Markdown-Umsetzer auf wie `datenschutz_html.py` — es gibt genau einen Parser |
| 24 | für dieses Dokument — und setzt dessen HTML in Blöcke um, die die Anwendung |
| 25 | ohne `dangerouslySetInnerHTML` darstellen kann. |
| 26 | |
| 27 | ## Warum über das HTML und nicht über das Markdown |
| 28 | |
| 29 | Der Umweg ist Absicht. Ein zweiter Markdown-Parser wäre eine zweite Meinung |
| 30 | darüber, was im Dokument steht — und die erste ist sorgfältig gegen fünf |
| 31 | Auszeichnungsformen abgesichert, an denen sie ausdrücklich abbricht |
| 32 | (Blockzitat, Aufzählung mit `*`, vierte Überschriftenebene, verschachtelte |
| 33 | Liste, Kursivsatz; siehe Befund H41). Diese Absicherung gilt hier mit, ohne |
| 34 | dass sie ein zweites Mal geschrieben werden müsste. |
| 35 | |
| 36 | Der Tagvorrat der Ausgabe ist klein und geschlossen — nachgezählt am |
| 37 | erzeugten Dokument vom 30.08.2026: `h1`, `h2`, `h3`, `p`, `ul`, `li`, `pre`, |
| 38 | `code`, `strong`, `a`. Alles andere lässt dieses Werkzeug abbrechen. |
| 39 | |
| 40 | ## Die Wache |
| 41 | |
| 42 | `content/datenschutz.json` trägt die Prüfsumme des Veröffentlichungsteils von |
| 43 | `docs/datenschutz.md`. `app/tests/datenschutz.test.ts` rechnet sie nach und |
| 44 | wird rot, sobald jemand die Quelle ändert und diesen Erzeuger vergisst. Ein |
| 45 | erzeugtes Dokument, das niemand neu erzeugt, ist stiller falsch als ein |
| 46 | handgeschriebenes — weil niemand mehr hinsieht. |
| 47 | |
| 48 | Aufruf: ``python tools/datenschutz_anwendung.py`` |
| 49 | """ |
| 50 | |
| 51 | from __future__ import annotations |
| 52 | |
| 53 | import hashlib |
| 54 | import json |
| 55 | import re |
| 56 | import sys |
| 57 | from html.parser import HTMLParser |
| 58 | from pathlib import Path |
| 59 | |
| 60 | sys.path.insert(0, str(Path(__file__).resolve().parent)) |
| 61 | |
| 62 | from datenschutz_html import kennung, umsetzen, veroeffentlichungsteil # noqa: E402 |
| 63 | |
| 64 | WURZEL = Path(__file__).resolve().parent.parent |
| 65 | QUELLE = WURZEL / "docs" / "datenschutz.md" |
| 66 | ZIEL = WURZEL / "content" / "datenschutz.json" |
| 67 | |
| 68 | #: Die Standtabelle am Ende der Erklärung führt beide Angaben. Sie werden |
| 69 | #: gelesen und nicht hier eingetragen: Eine Fassungsnummer an zwei Orten ist |
| 70 | #: genau die zweite Wahrheit, die dieses Werkzeug verhindern soll. |
| 71 | FASSUNG_MUSTER = re.compile(r"^\|\s*\*\*Fassung der Erklärung\*\*\s*\|\s*([^|]+?)\s*\|", re.M) |
| 72 | STAND_MUSTER = re.compile(r"^\|\s*\*\*Stand\*\*\s*\|\s*([^|]+?)\s*\|", re.M) |
| 73 | |
| 74 | #: Genau die Elemente, die `datenschutz_html.umsetzen()` erzeugt. |
| 75 | BLOCKTAGS = {"h1", "h2", "h3", "p", "ul", "pre"} |
| 76 | TEILTAGS = {"strong", "code", "a"} |
| 77 | |
| 78 | |
| 79 | class Umsetzer(HTMLParser): |
| 80 | """Setzt das erzeugte HTML in Blöcke um und bricht bei allem Fremden ab.""" |
| 81 | |
| 82 | def __init__(self) -> None: |
| 83 | super().__init__(convert_charrefs=True) |
| 84 | self.bloecke: list[dict[str, object]] = [] |
| 85 | self._block: str | None = None |
| 86 | self._teile: list[dict[str, str]] = [] |
| 87 | self._punkte: list[list[dict[str, str]]] = [] |
| 88 | self._in_li = False |
| 89 | self._teil: str | None = None |
| 90 | self._ziel: str | None = None |
| 91 | |
| 92 | # ── Hilfen ─────────────────────────────────────────────────────────── |
| 93 | |
| 94 | def _text_anhaengen(self, roh: str) -> None: |
| 95 | if self._block is None and not self._in_li: |
| 96 | if roh.strip(): |
| 97 | self._abbruch(f"Text außerhalb eines Blocks: {roh.strip()[:60]!r}") |
| 98 | return |
| 99 | art = self._teil or "text" |
| 100 | eintrag: dict[str, str] = {"art": art, "text": roh} |
| 101 | if art == "verweis": |
| 102 | eintrag["ziel"] = self._ziel or "" |
| 103 | self._teile.append(eintrag) |
| 104 | |
| 105 | def _abbruch(self, was: str) -> None: |
| 106 | sys.exit(f"ABBRUCH: {was}. Der Umsetzer kennt diese Form nicht.") |
| 107 | |
| 108 | @staticmethod |
| 109 | def _zusammenfassen(teile: list[dict[str, str]]) -> list[dict[str, str]]: |
| 110 | """Benachbarte gleichartige Teile verschmelzen, Leeres entfällt.""" |
| 111 | fertig: list[dict[str, str]] = [] |
| 112 | for teil in teile: |
| 113 | if teil["text"] == "": |
| 114 | continue |
| 115 | if ( |
| 116 | fertig |
| 117 | and fertig[-1]["art"] == teil["art"] == "text" |
| 118 | ): |
| 119 | fertig[-1]["text"] += teil["text"] |
| 120 | else: |
| 121 | fertig.append(dict(teil)) |
| 122 | return fertig |
| 123 | |
| 124 | # ── HTMLParser ─────────────────────────────────────────────────────── |
| 125 | |
| 126 | def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: |
| 127 | werte = dict(attrs) |
| 128 | if tag in BLOCKTAGS: |
| 129 | self._block = tag |
| 130 | self._teile = [] |
| 131 | if tag == "ul": |
| 132 | self._punkte = [] |
| 133 | return |
| 134 | if tag == "li": |
| 135 | self._in_li = True |
| 136 | self._teile = [] |
| 137 | return |
| 138 | if tag in TEILTAGS: |
| 139 | if tag == "code" and self._block == "pre": |
| 140 | return # <pre><code> – der Inhalt ist reiner Text |
| 141 | self._teil = {"strong": "stark", "code": "kennzeichnung", "a": "verweis"}[tag] |
| 142 | self._ziel = werte.get("href") |
| 143 | return |
| 144 | self._abbruch(f"unbekanntes Element <{tag}>") |
| 145 | |
| 146 | def handle_endtag(self, tag: str) -> None: |
| 147 | if tag in TEILTAGS: |
| 148 | if tag == "code" and self._block == "pre": |
| 149 | return |
| 150 | self._teil = None |
| 151 | self._ziel = None |
| 152 | return |
| 153 | if tag == "li": |
| 154 | self._punkte.append(self._zusammenfassen(self._teile)) |
| 155 | self._teile = [] |
| 156 | self._in_li = False |
| 157 | return |
| 158 | if tag == "ul": |
| 159 | self.bloecke.append({"art": "liste", "punkte": self._punkte}) |
| 160 | self._block = None |
| 161 | return |
| 162 | if tag == "pre": |
| 163 | self.bloecke.append( |
| 164 | {"art": "code", "text": "".join(t["text"] for t in self._teile)} |
| 165 | ) |
| 166 | self._block = None |
| 167 | self._teile = [] |
| 168 | return |
| 169 | if tag in {"h1", "h2", "h3"}: |
| 170 | teile = self._zusammenfassen(self._teile) |
| 171 | text = "".join(t["text"] for t in teile) |
| 172 | self.bloecke.append( |
| 173 | { |
| 174 | "art": "ueberschrift", |
| 175 | "ebene": int(tag[1]), |
| 176 | "text": text, |
| 177 | "kennung": kennung(text), |
| 178 | } |
| 179 | ) |
| 180 | self._block = None |
| 181 | self._teile = [] |
| 182 | return |
| 183 | if tag == "p": |
| 184 | self.bloecke.append({"art": "absatz", "teile": self._zusammenfassen(self._teile)}) |
| 185 | self._block = None |
| 186 | self._teile = [] |
| 187 | return |
| 188 | self._abbruch(f"unbekanntes schließendes Element </{tag}>") |
| 189 | |
| 190 | def handle_data(self, data: str) -> None: |
| 191 | self._text_anhaengen(data) |
| 192 | |
| 193 | |
| 194 | def pruefsumme(text: str) -> str: |
| 195 | """SHA-256 über den Veröffentlichungsteil, mit Zeilenenden vereinheitlicht. |
| 196 | |
| 197 | Ohne die Vereinheitlichung wäre die Wache auf einem Arbeitsbaum mit CRLF |
| 198 | dauerhaft und grundlos rot – und eine grundlos rote Wache wird |
| 199 | abgeschaltet. |
| 200 | """ |
| 201 | return hashlib.sha256(text.replace("\r\n", "\n").encode("utf-8")).hexdigest() |
| 202 | |
| 203 | |
| 204 | def main() -> None: |
| 205 | markdown = QUELLE.read_text(encoding="utf-8") |
| 206 | teil = veroeffentlichungsteil(markdown) |
| 207 | |
| 208 | fassung = FASSUNG_MUSTER.search(markdown) |
| 209 | stand = STAND_MUSTER.search(markdown) |
| 210 | if fassung is None or stand is None: |
| 211 | sys.exit( |
| 212 | "ABBRUCH: Fassung oder Stand nicht in der Standtabelle gefunden. " |
| 213 | "Beide werden gelesen und nicht eingetragen; ohne sie wüsste die " |
| 214 | "Anwendung nicht, welche Fassung sie zeigt." |
| 215 | ) |
| 216 | |
| 217 | umsetzer = Umsetzer() |
| 218 | umsetzer.feed(umsetzen(teil)) |
| 219 | umsetzer.close() |
| 220 | |
| 221 | if not umsetzer.bloecke: |
| 222 | sys.exit("ABBRUCH: kein einziger Block entstanden.") |
| 223 | |
| 224 | inhalt = { |
| 225 | "quellpruefsumme": pruefsumme(teil), |
| 226 | "fassung": fassung.group(1), |
| 227 | "stand": stand.group(1), |
| 228 | "bloecke": umsetzer.bloecke, |
| 229 | } |
| 230 | |
| 231 | ZIEL.parent.mkdir(parents=True, exist_ok=True) |
| 232 | with ZIEL.open("w", encoding="utf-8", newline="\n") as datei: |
| 233 | json.dump(inhalt, datei, ensure_ascii=False, indent=2) |
| 234 | datei.write("\n") |
| 235 | |
| 236 | zahl = len(umsetzer.bloecke) |
| 237 | print(f"Geschrieben: {ZIEL.relative_to(WURZEL)} ({zahl} Blöcke, Fassung {inhalt['fassung']}).") |
| 238 | |
| 239 | |
| 240 | if __name__ == "__main__": |
| 241 | main() |