waffensachkunde

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

/ tools datenschutz_anwendung.py

9,2 KB Rohdatei
tools/datenschutz_anwendung.py — 241 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"}
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()