waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ tools datenschutz_anwendung.py

11,5 KB Rohdatei
tools/datenschutz_anwendung.py — 294 Zeilen
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 03.09.2026: `h1`, `h2`, `h3`, `p`, `ul`, `li`, `pre`,
38 `table`, `thead`, `tbody`, `tr`, `th`, `td`, `code`, `strong`, `a`. Alles
39 andere lässt dieses Werkzeug abbrechen.
40
41 Der Kopf nannte bis Fassung 0.27.2 nur die ersten zehn und verschwieg die
42 sechs Tabellen-Tags — die den aufwendigsten Zweig dieses Umsetzers ausmachen
43 und seit dem Wechsel von `<ul>` auf ein echtes `<table>` in
44 `datenschutz_html.tabelle()` erzeugt werden. Wer sich auf den Kopf verließ,
45 hielt eine Tabelle in `docs/datenschutz.md` für verboten. `BLOCKTAGS`,
46 `TABELLENTAGS` und `TEILTAGS` unten sind der maßgebliche Bestand;
47 `app/tests/datenschutz.test.ts` hält diesen Absatz dagegen.
48
49 ## Die Wache
50
51 `content/datenschutz.json` trägt die Prüfsumme des Veröffentlichungsteils von
52 `docs/datenschutz.md`. `app/tests/datenschutz.test.ts` rechnet sie nach und
53 wird rot, sobald jemand die Quelle ändert und diesen Erzeuger vergisst. Ein
54 erzeugtes Dokument, das niemand neu erzeugt, ist stiller falsch als ein
55 handgeschriebenes — weil niemand mehr hinsieht.
56
57 Aufruf: ``python tools/datenschutz_anwendung.py``
58 """
59
60 from __future__ import annotations
61
62 import hashlib
63 import json
64 import re
65 import sys
66 from html.parser import HTMLParser
67 from pathlib import Path
68
69 sys.path.insert(0, str(Path(__file__).resolve().parent))
70
71 from datenschutz_html import kennung, umsetzen, veroeffentlichungsteil # noqa: E402
72
73 WURZEL = Path(__file__).resolve().parent.parent
74 QUELLE = WURZEL / "docs" / "datenschutz.md"
75 ZIEL = WURZEL / "content" / "datenschutz.json"
76
77 #: Die Standtabelle am Ende der Erklärung führt beide Angaben. Sie werden
78 #: gelesen und nicht hier eingetragen: Eine Fassungsnummer an zwei Orten ist
79 #: genau die zweite Wahrheit, die dieses Werkzeug verhindern soll.
80 FASSUNG_MUSTER = re.compile(r"^\|\s*\*\*Fassung der Erklärung\*\*\s*\|\s*([^|]+?)\s*\|", re.M)
81 STAND_MUSTER = re.compile(r"^\|\s*\*\*Stand\*\*\s*\|\s*([^|]+?)\s*\|", re.M)
82
83 #: Genau die Elemente, die `datenschutz_html.umsetzen()` erzeugt.
84 BLOCKTAGS = {"h1", "h2", "h3", "p", "ul", "pre", "table"}
85 #: Die Teile einer Tabelle. Sie stehen INNERHALB von <table> und dürfen
86 #: deshalb nicht in BLOCKTAGS – sonst überschriebe <tr> den Blockzustand.
87 TABELLENTAGS = {"thead", "tbody", "tr", "th", "td"}
88 TEILTAGS = {"strong", "code", "a"}
89
90
91 class Umsetzer(HTMLParser):
92 """Setzt das erzeugte HTML in Blöcke um und bricht bei allem Fremden ab."""
93
94 def __init__(self) -> None:
95 super().__init__(convert_charrefs=True)
96 self.bloecke: list[dict[str, object]] = []
97 self._block: str | None = None
98 self._teile: list[dict[str, str]] = []
99 self._punkte: list[list[dict[str, str]]] = []
100 self._in_li = False
101 self._kopf: list[str] = []
102 self._zeilen: list[list[list[dict[str, str]]]] = []
103 self._zelle: list[dict[str, str]] | None = None
104 self._teil: str | None = None
105 self._ziel: str | None = None
106
107 # ── Hilfen ───────────────────────────────────────────────────────────
108
109 def _text_anhaengen(self, roh: str) -> None:
110 if self._block is None and not self._in_li and self._zelle is None:
111 if roh.strip():
112 self._abbruch(f"Text außerhalb eines Blocks: {roh.strip()[:60]!r}")
113 return
114 art = self._teil or "text"
115 eintrag: dict[str, str] = {"art": art, "text": roh}
116 if art == "verweis":
117 eintrag["ziel"] = self._ziel or ""
118 self._teile.append(eintrag)
119
120 def _abbruch(self, was: str) -> None:
121 sys.exit(f"ABBRUCH: {was}. Der Umsetzer kennt diese Form nicht.")
122
123 @staticmethod
124 def _zusammenfassen(teile: list[dict[str, str]]) -> list[dict[str, str]]:
125 """Benachbarte gleichartige Teile verschmelzen, Leeres entfällt."""
126 fertig: list[dict[str, str]] = []
127 for teil in teile:
128 if teil["text"] == "":
129 continue
130 if (
131 fertig
132 and fertig[-1]["art"] == teil["art"] == "text"
133 ):
134 fertig[-1]["text"] += teil["text"]
135 else:
136 fertig.append(dict(teil))
137 return fertig
138
139 # ── HTMLParser ───────────────────────────────────────────────────────
140
141 def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
142 werte = dict(attrs)
143 if tag in BLOCKTAGS:
144 self._block = tag
145 self._teile = []
146 if tag == "ul":
147 self._punkte = []
148 if tag == "table":
149 self._kopf = []
150 self._zeilen = []
151 return
152 if tag in TABELLENTAGS:
153 if tag in {"th", "td"}:
154 self._zelle = []
155 self._teile = []
156 elif tag == "tr":
157 self._teile = []
158 # Auch die Kopfzeile legt eine an; sie bleibt leer, weil ihre
159 # Zellen <th> sind, und wird bei </tr> wieder verworfen.
160 self._zeilen.append([])
161 return
162 if tag == "li":
163 self._in_li = True
164 self._teile = []
165 return
166 if tag in TEILTAGS:
167 if tag == "code" and self._block == "pre":
168 return # <pre><code> – der Inhalt ist reiner Text
169 self._teil = {"strong": "stark", "code": "kennzeichnung", "a": "verweis"}[tag]
170 self._ziel = werte.get("href")
171 return
172 self._abbruch(f"unbekanntes Element <{tag}>")
173
174 def handle_endtag(self, tag: str) -> None:
175 if tag in TEILTAGS:
176 if tag == "code" and self._block == "pre":
177 return
178 self._teil = None
179 self._ziel = None
180 return
181 if tag == "li":
182 self._punkte.append(self._zusammenfassen(self._teile))
183 self._teile = []
184 self._in_li = False
185 return
186 if tag == "ul":
187 self.bloecke.append({"art": "liste", "punkte": self._punkte})
188 self._block = None
189 return
190 if tag in TABELLENTAGS:
191 if tag == "tr":
192 # Die Kopfzeile hinterlässt keine Datenzeile.
193 if self._zeilen and not self._zeilen[-1]:
194 self._zeilen.pop()
195 return
196 if tag == "th":
197 self._kopf.append("".join(t["text"] for t in self._zusammenfassen(self._teile)))
198 elif tag == "td":
199 assert self._zelle is not None
200 self._zelle = self._zusammenfassen(self._teile)
201 if not self._zeilen:
202 self._abbruch("Tabellenzelle ohne Zeile")
203 self._zeilen[-1].append(self._zelle)
204 if tag in {"th", "td"}:
205 self._zelle = None
206 self._teile = []
207 return
208 if tag == "table":
209 self.bloecke.append(
210 {"art": "tabelle", "kopf": self._kopf, "zeilen": self._zeilen}
211 )
212 self._block = None
213 self._teile = []
214 return
215 if tag == "pre":
216 self.bloecke.append(
217 {"art": "code", "text": "".join(t["text"] for t in self._teile)}
218 )
219 self._block = None
220 self._teile = []
221 return
222 if tag in {"h1", "h2", "h3"}:
223 teile = self._zusammenfassen(self._teile)
224 text = "".join(t["text"] for t in teile)
225 self.bloecke.append(
226 {
227 "art": "ueberschrift",
228 "ebene": int(tag[1]),
229 "text": text,
230 "kennung": kennung(text),
231 }
232 )
233 self._block = None
234 self._teile = []
235 return
236 if tag == "p":
237 self.bloecke.append({"art": "absatz", "teile": self._zusammenfassen(self._teile)})
238 self._block = None
239 self._teile = []
240 return
241 self._abbruch(f"unbekanntes schließendes Element </{tag}>")
242
243 def handle_data(self, data: str) -> None:
244 self._text_anhaengen(data)
245
246
247 def pruefsumme(text: str) -> str:
248 """SHA-256 über den Veröffentlichungsteil, mit Zeilenenden vereinheitlicht.
249
250 Ohne die Vereinheitlichung wäre die Wache auf einem Arbeitsbaum mit CRLF
251 dauerhaft und grundlos rot – und eine grundlos rote Wache wird
252 abgeschaltet.
253 """
254 return hashlib.sha256(text.replace("\r\n", "\n").encode("utf-8")).hexdigest()
255
256
257 def main() -> None:
258 markdown = QUELLE.read_text(encoding="utf-8")
259 teil = veroeffentlichungsteil(markdown)
260
261 fassung = FASSUNG_MUSTER.search(markdown)
262 stand = STAND_MUSTER.search(markdown)
263 if fassung is None or stand is None:
264 sys.exit(
265 "ABBRUCH: Fassung oder Stand nicht in der Standtabelle gefunden. "
266 "Beide werden gelesen und nicht eingetragen; ohne sie wüsste die "
267 "Anwendung nicht, welche Fassung sie zeigt."
268 )
269
270 umsetzer = Umsetzer()
271 umsetzer.feed(umsetzen(teil))
272 umsetzer.close()
273
274 if not umsetzer.bloecke:
275 sys.exit("ABBRUCH: kein einziger Block entstanden.")
276
277 inhalt = {
278 "quellpruefsumme": pruefsumme(teil),
279 "fassung": fassung.group(1),
280 "stand": stand.group(1),
281 "bloecke": umsetzer.bloecke,
282 }
283
284 ZIEL.parent.mkdir(parents=True, exist_ok=True)
285 with ZIEL.open("w", encoding="utf-8", newline="\n") as datei:
286 json.dump(inhalt, datei, ensure_ascii=False, indent=2)
287 datei.write("\n")
288
289 zahl = len(umsetzer.bloecke)
290 print(f"Geschrieben: {ZIEL.relative_to(WURZEL)} ({zahl} Blöcke, Fassung {inhalt['fassung']}).")
291
292
293 if __name__ == "__main__":
294 main()