waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app src shared datenschutz.ts
| 1 | /** |
| 2 | * Die Datenschutzerklärung, wie die Anwendung sie zeigt. |
| 3 | * |
| 4 | * ## Warum sie überhaupt in der Anwendung steht |
| 5 | * |
| 6 | * Bis Fassung 0.24.2 war sie **nirgends** erreichbar. Das Wort |
| 7 | * „Datenschutzerklärung“ kam in der ganzen Oberfläche kein einziges Mal als |
| 8 | * sichtbarer Text vor; `docs/datenschutz.html` wurde nicht mitgeliefert |
| 9 | * (`electron-builder.yml` führte kein `docs/`), und der einzige Weg nach |
| 10 | * außen führt zur Unterstützungsseite. Zugleich sagt „Über diese Software“ |
| 11 | * zu, die Anwendung übertrage keine Daten — die weitreichendste Zusage nach |
| 12 | * der Offline-Zusage, und ihr Beleg war für den Nutzenden unerreichbar. |
| 13 | * |
| 14 | * ## Warum ein Blockmodell und kein HTML |
| 15 | * |
| 16 | * Dieselbe Überlegung wie bei {@link Hilfeblock} in `hilfe.ts`: In den |
| 17 | * Zeichenketten steht kein Markup, und nichts davon wird über |
| 18 | * `dangerouslySetInnerHTML` gerendert. Der Renderer kennt |
| 19 | * `dangerouslySetInnerHTML` an keiner Stelle, und das soll so bleiben — die |
| 20 | * Inhaltssicherheitsrichtlinie (`shared/csp.ts`) verbietet ohnehin fremde |
| 21 | * Skripte, aber ein Blockmodell braucht die Frage gar nicht erst zu stellen. |
| 22 | * |
| 23 | * ## Warum erzeugt und nicht geschrieben |
| 24 | * |
| 25 | * `content/datenschutz.json` entsteht aus `docs/datenschutz.md` über |
| 26 | * `tools/datenschutz_anwendung.py`, und zwar über **denselben** Umsetzer, |
| 27 | * der auch die veröffentlichte HTML-Fassung erzeugt. Es gibt genau einen |
| 28 | * Parser für dieses Dokument. |
| 29 | * |
| 30 | * Der Grund ist nicht Bequemlichkeit: Die veröffentlichte Seite stand am |
| 31 | * 30.08.2026 auf Fassung 1.1, während die Quelldatei bei 1.5 war — vier |
| 32 | * Revisionen Abstand in vier Tagen, bei vorhandenem Erzeuger und vorhandener |
| 33 | * Prüfliste. Ausgelaufen ist der Schritt, den nur ein Mensch tut. Deshalb |
| 34 | * trägt die erzeugte Datei die Prüfsumme ihrer Quelle, und |
| 35 | * `tests/datenschutz.test.ts` rechnet sie nach. |
| 36 | */ |
| 37 | |
| 38 | /** Ein ausgezeichneter Teil innerhalb eines Absatzes oder Listenpunkts. */ |
| 39 | export type Datenschutzteil = |
| 40 | | { readonly art: 'text'; readonly text: string } |
| 41 | /** Hervorhebung – im Markdown `**fett**`. */ |
| 42 | | { readonly art: 'stark'; readonly text: string } |
| 43 | /** Datei-, Feld- oder Spaltenname – im Markdown `` `so` ``. */ |
| 44 | | { readonly art: 'kennzeichnung'; readonly text: string } |
| 45 | /** Verweis mit Ziel; die Anwendung zeigt ihn als Text samt Adresse. */ |
| 46 | | { readonly art: 'verweis'; readonly text: string; readonly ziel: string }; |
| 47 | |
| 48 | /** |
| 49 | * Ein Baustein der Erklärung. |
| 50 | * |
| 51 | * Genau die fünf Formen, die der Erzeuger hervorbringt. Nachgezählt am |
| 52 | * Dokument vom 01.09.2026: 94 Absätze, 34 Überschriften, 9 Listen, |
| 53 | * 6 Codeblöcke und 2 Tabellen. Was hier nicht vorgesehen ist, lässt den Erzeuger abbrechen, |
| 54 | * statt es stillschweigend wegzulassen. |
| 55 | */ |
| 56 | export type Datenschutzblock = |
| 57 | | { |
| 58 | readonly art: 'ueberschrift'; |
| 59 | readonly ebene: 1 | 2 | 3; |
| 60 | readonly text: string; |
| 61 | /** Sprungziel, gebildet wie in der HTML-Fassung. */ |
| 62 | readonly kennung: string; |
| 63 | } |
| 64 | | { readonly art: 'absatz'; readonly teile: readonly Datenschutzteil[] } |
| 65 | | { readonly art: 'liste'; readonly punkte: readonly (readonly Datenschutzteil[])[] } |
| 66 | | { readonly art: 'code'; readonly text: string } |
| 67 | | { |
| 68 | readonly art: 'tabelle'; |
| 69 | /** |
| 70 | * Die Spaltenüberschriften – **leer**, wenn die Tabelle keine hat. |
| 71 | * |
| 72 | * Die Standtabelle am Ende der Erklärung ist so eine: Sie ist in |
| 73 | * Wahrheit eine Aufzählung von Merkmal und Wert. Die Anzeige macht |
| 74 | * daraus Zeilenköpfe statt leerer Spaltenköpfe – eine Kopfzelle ohne |
| 75 | * Text ist für einen Bildschirmleser eine Ansage ins Nichts. |
| 76 | */ |
| 77 | readonly kopf: readonly string[]; |
| 78 | readonly zeilen: readonly (readonly (readonly Datenschutzteil[])[])[]; |
| 79 | }; |
| 80 | |
| 81 | export interface Datenschutzerklaerung { |
| 82 | /** SHA-256 des Veröffentlichungsteils von `docs/datenschutz.md`. */ |
| 83 | readonly quellpruefsumme: string; |
| 84 | /** Fassung der Erklärung, aus ihrer Standtabelle gelesen. */ |
| 85 | readonly fassung: string; |
| 86 | /** Stand der Erklärung, aus ihrer Standtabelle gelesen. */ |
| 87 | readonly stand: string; |
| 88 | readonly bloecke: readonly Datenschutzblock[]; |
| 89 | } |
| 90 | |
| 91 | export interface Datenschutzangaben { |
| 92 | /** Die Erklärung; `null`, wenn die Datei fehlt oder unlesbar ist. */ |
| 93 | readonly erklaerung: Datenschutzerklaerung | null; |
| 94 | /** |
| 95 | * Warum sie fehlt – für die Anzeige, nicht fürs Protokoll. |
| 96 | * |
| 97 | * Ein leerer Bereich ohne Begründung wäre die schlechteste Auskunft: Der |
| 98 | * Nutzende hielte die Erklärung für nicht vorhanden statt für nicht |
| 99 | * ladbar. |
| 100 | */ |
| 101 | readonly warnung: string | null; |
| 102 | } |
| 103 | |
| 104 | /** Ohne Datei: die Anwendung läuft weiter und sagt, was fehlt. */ |
| 105 | export const DATENSCHUTZ_LEER: Datenschutzangaben = Object.freeze({ |
| 106 | erklaerung: null, |
| 107 | warnung: null, |
| 108 | }); |
| 109 | |
| 110 | /** |
| 111 | * Die Überschriften, die ein Sprungmenü anbietet. |
| 112 | * |
| 113 | * Nur die zweite Ebene: Die Erklärung hat 13 Abschnitte und 20 |
| 114 | * Unterabschnitte; ein Verzeichnis über alle 33 wäre selbst wieder eine |
| 115 | * Wand aus Text. Die erste Ebene ist der Titel und braucht kein Sprungziel. |
| 116 | */ |
| 117 | export function abschnitte( |
| 118 | erklaerung: Datenschutzerklaerung, |
| 119 | ): readonly { readonly text: string; readonly kennung: string }[] { |
| 120 | return erklaerung.bloecke |
| 121 | .filter( |
| 122 | (block): block is Extract<Datenschutzblock, { art: 'ueberschrift' }> => |
| 123 | block.art === 'ueberschrift' && block.ebene === 2, |
| 124 | ) |
| 125 | .map((block) => ({ text: block.text, kennung: block.kennung })); |
| 126 | } |