waffensachkunde

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

/ tools datenschutz_anwendung.py

11,0 KB Rohdatei
tools/datenschutz_anwendung.py — 285 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 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", "table"}
76 #: Die Teile einer Tabelle. Sie stehen INNERHALB von <table> und dürfen
77 #: deshalb nicht in BLOCKTAGS – sonst überschriebe <tr> den Blockzustand.
78 TABELLENTAGS = {"thead", "tbody", "tr", "th", "td"}
79 TEILTAGS = {"strong", "code", "a"}
80
81
82 class Umsetzer(HTMLParser):
83 """Setzt das erzeugte HTML in Blöcke um und bricht bei allem Fremden ab."""
84
85 def __init__(self) -> None:
86 super().__init__(convert_charrefs=True)
87 self.bloecke: list[dict[str, object]] = []
88 self._block: str | None = None
89 self._teile: list[dict[str, str]] = []
90 self._punkte: list[list[dict[str, str]]] = []
91 self._in_li = False
92 self._kopf: list[str] = []
93 self._zeilen: list[list[list[dict[str, str]]]] = []
94 self._zelle: list[dict[str, str]] | None = None
95 self._teil: str | None = None
96 self._ziel: str | None = None
97
98 # ── Hilfen ───────────────────────────────────────────────────────────
99
100 def _text_anhaengen(self, roh: str) -> None:
101 if self._block is None and not self._in_li and self._zelle is None:
102 if roh.strip():
103 self._abbruch(f"Text außerhalb eines Blocks: {roh.strip()[:60]!r}")
104 return
105 art = self._teil or "text"
106 eintrag: dict[str, str] = {"art": art, "text": roh}
107 if art == "verweis":
108 eintrag["ziel"] = self._ziel or ""
109 self._teile.append(eintrag)
110
111 def _abbruch(self, was: str) -> None:
112 sys.exit(f"ABBRUCH: {was}. Der Umsetzer kennt diese Form nicht.")
113
114 @staticmethod
115 def _zusammenfassen(teile: list[dict[str, str]]) -> list[dict[str, str]]:
116 """Benachbarte gleichartige Teile verschmelzen, Leeres entfällt."""
117 fertig: list[dict[str, str]] = []
118 for teil in teile:
119 if teil["text"] == "":
120 continue
121 if (
122 fertig
123 and fertig[-1]["art"] == teil["art"] == "text"
124 ):
125 fertig[-1]["text"] += teil["text"]
126 else:
127 fertig.append(dict(teil))
128 return fertig
129
130 # ── HTMLParser ───────────────────────────────────────────────────────
131
132 def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
133 werte = dict(attrs)
134 if tag in BLOCKTAGS:
135 self._block = tag
136 self._teile = []
137 if tag == "ul":
138 self._punkte = []
139 if tag == "table":
140 self._kopf = []
141 self._zeilen = []
142 return
143 if tag in TABELLENTAGS:
144 if tag in {"th", "td"}:
145 self._zelle = []
146 self._teile = []
147 elif tag == "tr":
148 self._teile = []
149 # Auch die Kopfzeile legt eine an; sie bleibt leer, weil ihre
150 # Zellen <th> sind, und wird bei </tr> wieder verworfen.
151 self._zeilen.append([])
152 return
153 if tag == "li":
154 self._in_li = True
155 self._teile = []
156 return
157 if tag in TEILTAGS:
158 if tag == "code" and self._block == "pre":
159 return # <pre><code> – der Inhalt ist reiner Text
160 self._teil = {"strong": "stark", "code": "kennzeichnung", "a": "verweis"}[tag]
161 self._ziel = werte.get("href")
162 return
163 self._abbruch(f"unbekanntes Element <{tag}>")
164
165 def handle_endtag(self, tag: str) -> None:
166 if tag in TEILTAGS:
167 if tag == "code" and self._block == "pre":
168 return
169 self._teil = None
170 self._ziel = None
171 return
172 if tag == "li":
173 self._punkte.append(self._zusammenfassen(self._teile))
174 self._teile = []
175 self._in_li = False
176 return
177 if tag == "ul":
178 self.bloecke.append({"art": "liste", "punkte": self._punkte})
179 self._block = None
180 return
181 if tag in TABELLENTAGS:
182 if tag == "tr":
183 # Die Kopfzeile hinterlässt keine Datenzeile.
184 if self._zeilen and not self._zeilen[-1]:
185 self._zeilen.pop()
186 return
187 if tag == "th":
188 self._kopf.append("".join(t["text"] for t in self._zusammenfassen(self._teile)))
189 elif tag == "td":
190 assert self._zelle is not None
191 self._zelle = self._zusammenfassen(self._teile)
192 if not self._zeilen:
193 self._abbruch("Tabellenzelle ohne Zeile")
194 self._zeilen[-1].append(self._zelle)
195 if tag in {"th", "td"}:
196 self._zelle = None
197 self._teile = []
198 return
199 if tag == "table":
200 self.bloecke.append(
201 {"art": "tabelle", "kopf": self._kopf, "zeilen": self._zeilen}
202 )
203 self._block = None
204 self._teile = []
205 return
206 if tag == "pre":
207 self.bloecke.append(
208 {"art": "code", "text": "".join(t["text"] for t in self._teile)}
209 )
210 self._block = None
211 self._teile = []
212 return
213 if tag in {"h1", "h2", "h3"}:
214 teile = self._zusammenfassen(self._teile)
215 text = "".join(t["text"] for t in teile)
216 self.bloecke.append(
217 {
218 "art": "ueberschrift",
219 "ebene": int(tag[1]),
220 "text": text,
221 "kennung": kennung(text),
222 }
223 )
224 self._block = None
225 self._teile = []
226 return
227 if tag == "p":
228 self.bloecke.append({"art": "absatz", "teile": self._zusammenfassen(self._teile)})
229 self._block = None
230 self._teile = []
231 return
232 self._abbruch(f"unbekanntes schließendes Element </{tag}>")
233
234 def handle_data(self, data: str) -> None:
235 self._text_anhaengen(data)
236
237
238 def pruefsumme(text: str) -> str:
239 """SHA-256 über den Veröffentlichungsteil, mit Zeilenenden vereinheitlicht.
240
241 Ohne die Vereinheitlichung wäre die Wache auf einem Arbeitsbaum mit CRLF
242 dauerhaft und grundlos rot – und eine grundlos rote Wache wird
243 abgeschaltet.
244 """
245 return hashlib.sha256(text.replace("\r\n", "\n").encode("utf-8")).hexdigest()
246
247
248 def main() -> None:
249 markdown = QUELLE.read_text(encoding="utf-8")
250 teil = veroeffentlichungsteil(markdown)
251
252 fassung = FASSUNG_MUSTER.search(markdown)
253 stand = STAND_MUSTER.search(markdown)
254 if fassung is None or stand is None:
255 sys.exit(
256 "ABBRUCH: Fassung oder Stand nicht in der Standtabelle gefunden. "
257 "Beide werden gelesen und nicht eingetragen; ohne sie wüsste die "
258 "Anwendung nicht, welche Fassung sie zeigt."
259 )
260
261 umsetzer = Umsetzer()
262 umsetzer.feed(umsetzen(teil))
263 umsetzer.close()
264
265 if not umsetzer.bloecke:
266 sys.exit("ABBRUCH: kein einziger Block entstanden.")
267
268 inhalt = {
269 "quellpruefsumme": pruefsumme(teil),
270 "fassung": fassung.group(1),
271 "stand": stand.group(1),
272 "bloecke": umsetzer.bloecke,
273 }
274
275 ZIEL.parent.mkdir(parents=True, exist_ok=True)
276 with ZIEL.open("w", encoding="utf-8", newline="\n") as datei:
277 json.dump(inhalt, datei, ensure_ascii=False, indent=2)
278 datei.write("\n")
279
280 zahl = len(umsetzer.bloecke)
281 print(f"Geschrieben: {ZIEL.relative_to(WURZEL)} ({zahl} Blöcke, Fassung {inhalt['fassung']}).")
282
283
284 if __name__ == "__main__":
285 main()