waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | """Baut ``docs/updatebericht.md`` — den Bericht zur jeweils aktuellen Fassung. |
| 2 | |
| 3 | Warum es diesen Bericht gibt |
| 4 | ---------------------------- |
| 5 | ``CHANGELOG.md`` fuehrt die ganze Geschichte, und der Etikettentext im Git |
| 6 | fasst eine Fassung fuer Anwender zusammen. Beides erreicht aber niemanden, der |
| 7 | die Anwendung nur installiert hat: Der Changelog liegt im Quelltextarchiv, das |
| 8 | Etikett im Git. Dieser Bericht ist das Blatt, das man jemandem zusammen mit der |
| 9 | Installationsdatei in die Hand gibt. |
| 10 | |
| 11 | Warum er erzeugt und nicht geschrieben wird |
| 12 | ------------------------------------------- |
| 13 | Weil er sonst driftet. Genau das ist in diesem Projekt zweimal passiert - die |
| 14 | Sperrdatei stand zwei Fassungen lang still, und die Anzahl der Paketpruefungen |
| 15 | wanderte durch neun Dokumentstellen. Der Mittelteil dieses Berichts stammt |
| 16 | deshalb **Wort fuer Wort** aus dem Abschnitt der Fassung in ``CHANGELOG.md``; |
| 17 | die Fassungsnummer kommt aus ``app/package.json``. Von Hand geschrieben ist nur |
| 18 | der Rahmen, und der aendert sich zwischen Fassungen nicht. |
| 19 | |
| 20 | ``app/tests/dokumentation.test.ts`` haelt fest, dass der Bericht zur Fassung |
| 21 | passt und den Changelog-Abschnitt unveraendert enthaelt. |
| 22 | |
| 23 | Aufruf |
| 24 | ------ |
| 25 | python tools/updatebericht.py |
| 26 | python tools/updatebericht.py --pruefen # meldet nur, ob er aktuell ist |
| 27 | """ |
| 28 | |
| 29 | from __future__ import annotations |
| 30 | |
| 31 | import io |
| 32 | import json |
| 33 | import re |
| 34 | import sys |
| 35 | from pathlib import Path |
| 36 | |
| 37 | WURZEL = Path(__file__).resolve().parent.parent |
| 38 | PAKET = WURZEL / 'app' / 'package.json' |
| 39 | CHANGELOG = WURZEL / 'CHANGELOG.md' |
| 40 | ZIEL = WURZEL / 'docs' / 'updatebericht.md' |
| 41 | |
| 42 | #: Ueberschrift eines Fassungsabschnitts: "## [0.23.0] — 2026-08-29". |
| 43 | #: Der Gedankenstrich ist ein Halbgeviertstrich, kein Bindestrich. |
| 44 | FASSUNGSKOPF = re.compile(r'^## \[(?P<nummer>[^\]]+)\](?:\s*—\s*(?P<datum>\S+))?\s*$') |
| 45 | |
| 46 | |
| 47 | def fassung() -> str: |
| 48 | return json.loads(PAKET.read_text(encoding='utf-8'))['version'] |
| 49 | |
| 50 | |
| 51 | def abschnitt(nummer: str) -> tuple[str, list[str]]: |
| 52 | """Der Changelog-Abschnitt einer Fassung: Datum und Zeilen ohne Ueberschrift.""" |
| 53 | zeilen = CHANGELOG.read_text(encoding='utf-8').split('\n') |
| 54 | |
| 55 | start = None |
| 56 | datum = '' |
| 57 | for i, zeile in enumerate(zeilen): |
| 58 | treffer = FASSUNGSKOPF.match(zeile) |
| 59 | if treffer is not None and treffer.group('nummer') == nummer: |
| 60 | start = i |
| 61 | datum = treffer.group('datum') or '' |
| 62 | break |
| 63 | if start is None: |
| 64 | raise SystemExit(f'ABBRUCH: CHANGELOG.md hat keinen Abschnitt „## [{nummer}]“.') |
| 65 | |
| 66 | ende = len(zeilen) |
| 67 | for i in range(start + 1, len(zeilen)): |
| 68 | # Hinter der aeltesten Fassung steht ein HTML-Kommentar, der zu keiner |
| 69 | # gehoert. Ohne diese Bedingung liefe der letzte Abschnitt bis zum |
| 70 | # Dateiende und zoege ihn mit hinein. Heute trifft das nichts - erzeugt |
| 71 | # wird immer die Fassung aus der package.json, und die ist nie die |
| 72 | # aelteste. Die Bedingung steht hier, damit das so bleibt. |
| 73 | if FASSUNGSKOPF.match(zeilen[i]) or zeilen[i].startswith('<!--'): |
| 74 | ende = i |
| 75 | break |
| 76 | |
| 77 | inhalt = zeilen[start + 1 : ende] |
| 78 | while inhalt and not inhalt[0].strip(): |
| 79 | inhalt.pop(0) |
| 80 | while inhalt and not inhalt[-1].strip(): |
| 81 | inhalt.pop() |
| 82 | # Die Trennlinie vor der naechsten Fassung gehoert nicht zum Abschnitt. |
| 83 | while inhalt and inhalt[-1].strip() == '---': |
| 84 | inhalt.pop() |
| 85 | while inhalt and not inhalt[-1].strip(): |
| 86 | inhalt.pop() |
| 87 | |
| 88 | return datum, [verweise_umhaengen(zeile) for zeile in inhalt] |
| 89 | |
| 90 | |
| 91 | #: Verweise im Changelog gelten vom Wurzelverzeichnis aus, der Bericht liegt in |
| 92 | #: ``docs/``. Ohne Umhaengen zeigte ``docs/stand.md`` von dort auf |
| 93 | #: ``docs/docs/stand.md`` - ein toter Verweis in einem erzeugten Dokument, den |
| 94 | #: niemand bemerkt, weil ihn niemand von Hand geschrieben hat. |
| 95 | VERWEIS = re.compile(r'\]\((?P<ziel>[^)]+)\)') |
| 96 | |
| 97 | |
| 98 | def verweise_umhaengen(zeile: str) -> str: |
| 99 | """Rechnet relative Verweise vom Wurzelverzeichnis nach ``docs/`` um.""" |
| 100 | |
| 101 | def ersetzen(treffer: re.Match[str]) -> str: |
| 102 | ziel = treffer.group('ziel') |
| 103 | # Adressen und reine Sprungmarken bleiben, wie sie sind. |
| 104 | if ziel.startswith(('http://', 'https://', 'mailto:', '#')): |
| 105 | return treffer.group(0) |
| 106 | if ziel.startswith('docs/'): |
| 107 | neues_ziel = ziel[len('docs/') :] |
| 108 | else: |
| 109 | # Alles Uebrige liegt im Wurzelverzeichnis, also eine Ebene hoeher. |
| 110 | neues_ziel = '../' + ziel |
| 111 | return '](' + neues_ziel + ')' |
| 112 | |
| 113 | return VERWEIS.sub(ersetzen, zeile) |
| 114 | |
| 115 | |
| 116 | RAHMEN_KOPF = """# Updatebericht zur Fassung {nummer} |
| 117 | |
| 118 | **Stand {datum}.** Was sich geändert hat, wie Sie aktualisieren und was diese |
| 119 | Fassung ausdrücklich nicht leistet. |
| 120 | |
| 121 | > Dieser Bericht ist **erzeugt, nicht von Hand geschrieben**. Der Abschnitt |
| 122 | > „Was sich geändert hat“ stammt Wort für Wort aus |
| 123 | > [CHANGELOG.md](../CHANGELOG.md), die Fassungsnummer aus `app/package.json`. |
| 124 | > Neu erzeugen mit `python tools/updatebericht.py`. Die vollständige |
| 125 | > Geschichte aller Fassungen steht im Änderungsverlauf; hier steht eine. |
| 126 | |
| 127 | --- |
| 128 | |
| 129 | ## Was sich geändert hat |
| 130 | |
| 131 | """ |
| 132 | |
| 133 | RAHMEN_FUSS = """ |
| 134 | --- |
| 135 | |
| 136 | ## Wie Sie aktualisieren |
| 137 | |
| 138 | Es gibt **keine automatische Aktualisierung**. Die Anwendung sucht von sich aus |
| 139 | nie nach einer neuen Fassung — sie stellt überhaupt keine Verbindung her. |
| 140 | |
| 141 | 1. Die Datei `Waffensachkunde Lernsoftware-{nummer}-Setup-x64.exe` starten. |
| 142 | 2. Windows meldet sich, weil die Datei nicht signiert ist: „Weitere |
| 143 | Informationen“ → „Trotzdem ausführen“. Das kommt bei **jeder** neuen |
| 144 | Fassung wieder, auch wenn Sie den Hinweis schon einmal weggeklickt haben — |
| 145 | jede Setup-Datei ist für das Betriebssystem eine neue, unbekannte Datei. |
| 146 | Warum das so ist, steht in [installation.md](installation.md). |
| 147 | 3. Der Assistent installiert über die vorhandene Fassung. Eine vorherige |
| 148 | Deinstallation ist nicht nötig. |
| 149 | |
| 150 | **Ihr Lernstand bleibt.** Er liegt nicht im Programmverzeichnis, sondern unter |
| 151 | `%APPDATA%\\Waffensachkunde Lernsoftware`, und das Installationsprogramm fasst |
| 152 | dieses Verzeichnis nicht an — auch beim Deinstallieren nicht |
| 153 | (`deleteAppDataOnUninstall: false`). Bringt eine Fassung eine neue Form der |
| 154 | Datenbank mit, wird der vorhandene Lernstand beim ersten Öffnen **nur |
| 155 | ergänzt**: Es kommen Tabellen und Spalten dazu, nichts wird geändert oder |
| 156 | gelöscht. Geprüft ist das für jeden Schritt von der ersten Form bis zur |
| 157 | heutigen. |
| 158 | |
| 159 | **Der Weg zurück ist versperrt.** Sobald eine neuere Fassung Ihren Lernstand |
| 160 | einmal angefasst hat, öffnet ihn eine ältere nicht mehr; sie meldet |
| 161 | stattdessen „Der Lernstand wurde mit einer neueren Programmversion angelegt“. |
| 162 | Das ist Absicht: Eine ältere Fassung kennt die neuen Felder nicht, und was sie |
| 163 | schriebe, wäre unvollständig, ohne dass es jemand bemerkte. Dieselbe Sperre |
| 164 | gilt für Sicherungen — eine Sicherung aus einer neueren Fassung lässt sich in |
| 165 | eine ältere nicht einspielen. |
| 166 | |
| 167 | **Deshalb vor dem Aktualisieren sichern**, wenn Sie sich die Rückkehr |
| 168 | offenhalten wollen: Startbildschirm → „Lernstand sichern und übertragen“ → |
| 169 | „Sicherung speichern …“. Diese Sicherung stammt noch aus der alten Fassung und |
| 170 | lässt sich dort auch wieder einspielen. |
| 171 | |
| 172 | ## Was diese Fassung nicht leistet |
| 173 | |
| 174 | - **Sie ist nicht signiert.** Windows SmartScreen meldet sich beim ersten |
| 175 | Start. Das sagt nichts darüber, ob die Anwendung schädlich ist — nur, dass |
| 176 | das Betriebssystem den Herausgeber nicht kennt. |
| 177 | - **Es gibt keine Fassung für macOS.** Der Bau ist vorbereitet, aber kein Paket |
| 178 | ist je entstanden, und nichts an dieser Software lief je auf einem Mac. |
| 179 | - **Barrierefreiheit ist gebaut, aber nicht nachgewiesen.** Was automatisch |
| 180 | prüfbar ist, wird bei jedem Lauf geprüft; die Szenarien am echten |
| 181 | Screenreader stehen aus. Einzelheiten in |
| 182 | [stand.md](stand.md), Abschnitt 5. |
| 183 | - **Über das Bestehen der Prüfung entscheidet allein der zuständige |
| 184 | Prüfungsausschuss.** Diese Anwendung übt den schriftlichen Teil; den |
| 185 | praktischen Teil nach § 2 Abs. 3 AWaffV bildet sie nicht ab. |
| 186 | |
| 187 | ## Woran Sie die Angaben nachprüfen können |
| 188 | |
| 189 | - Die installierte Fassung steht in der Anwendung: Startbildschirm → |
| 190 | **Systemzustand** → „Programmversion“. Daneben steht der Baustand; trägt er |
| 191 | ein `+`, stammt das Paket aus einem unfertigen Arbeitsbaum. |
| 192 | - Der vollständige Änderungsverlauf: [CHANGELOG.md](../CHANGELOG.md). |
| 193 | - Wo die Software steht — auch mit ihren Lücken: [stand.md](stand.md). |
| 194 | - Welche Daten entstehen und wo sie liegen: |
| 195 | [datenschutz.md](datenschutz.md). |
| 196 | - Der Quelltext steht unter der European Union Public Licence 1.2 und ist |
| 197 | öffentlich einsehbar: <https://olaf-willerding.de/quelltext>. Klonen können |
| 198 | Sie ihn mit `git clone https://olaf-willerding.de/git/waffensachkunde.git`. |
| 199 | """ |
| 200 | |
| 201 | |
| 202 | def bericht() -> str: |
| 203 | nummer = fassung() |
| 204 | datum, inhalt = abschnitt(nummer) |
| 205 | if not datum: |
| 206 | raise SystemExit(f'ABBRUCH: Der Abschnitt „## [{nummer}]“ nennt kein Datum.') |
| 207 | |
| 208 | return ( |
| 209 | RAHMEN_KOPF.format(nummer=nummer, datum=datum) |
| 210 | + '\n'.join(inhalt) |
| 211 | + '\n' |
| 212 | + RAHMEN_FUSS.format(nummer=nummer) |
| 213 | ) |
| 214 | |
| 215 | |
| 216 | def main() -> int: |
| 217 | neu = bericht() |
| 218 | |
| 219 | if '--pruefen' in sys.argv: |
| 220 | alt = ZIEL.read_text(encoding='utf-8') if ZIEL.exists() else '' |
| 221 | if alt == neu: |
| 222 | print(f'{ZIEL.relative_to(WURZEL)} ist aktuell.') |
| 223 | return 0 |
| 224 | print(f'{ZIEL.relative_to(WURZEL)} ist NICHT aktuell — neu erzeugen mit:') |
| 225 | print(' python tools/updatebericht.py') |
| 226 | return 1 |
| 227 | |
| 228 | io.open(ZIEL, 'w', encoding='utf-8', newline='').write(neu) |
| 229 | print(f'Geschrieben: {ZIEL.relative_to(WURZEL)} ({len(neu)} Zeichen, Fassung {fassung()}).') |
| 230 | return 0 |
| 231 | |
| 232 | |
| 233 | if __name__ == '__main__': |
| 234 | raise SystemExit(main()) |