waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | """Baut ``docs/store-notiz.md`` — die öffentlich verwendbaren Versionshinweise. |
| 2 | |
| 3 | ## Warum es dieses Werkzeug gibt |
| 4 | |
| 5 | ``CHANGELOG.md`` ist für die Werkbank geschrieben. Dort stehen Dateinamen, |
| 6 | Werkzeugnamen, Testzahlen und Begründungen, die nur versteht, wer den |
| 7 | Quelltext kennt. Nichts davon ist geheim — aber nichts davon gehört in einen |
| 8 | Store-Eintrag, den Lernende lesen. |
| 9 | |
| 10 | Gebraucht wird deshalb je Fassung ein zweiter Text: was sich für jemanden |
| 11 | ändert, der die Anwendung **benutzt**. Er geht in das Feld für |
| 12 | Versionshinweise im Partner Center, und er muss bei **jeder** Fassung |
| 13 | entstehen — auch bei einer Unterfassung, die nur einen Fehler behebt. Eine |
| 14 | Fassung ohne öffentlichen Text ist eine Fassung, über die niemand erfährt, was |
| 15 | sie geändert hat. |
| 16 | |
| 17 | ## Wo der Text steht |
| 18 | |
| 19 | Im Changelog selbst, als Abschnitt ``### Für die Öffentlichkeit`` unter der |
| 20 | jeweiligen Fassung — das Gegenstück zu ``### Für die Werkbank``. Die |
| 21 | Überschrift nennt den Leser, und darin liegt ihr Zweck: Wer sie schreibt, |
| 22 | weiß dabei, dass dieser Text hinausgeht. |
| 23 | |
| 24 | Dass er im Changelog steht und nicht in einer eigenen Datei, ist ebenfalls |
| 25 | Absicht. So schreibt man denselben Sachverhalt im selben Augenblick zweimal — |
| 26 | einmal für die Werkbank, einmal für die Öffentlichkeit. Läge der öffentliche |
| 27 | Text woanders, würde er beim nächsten Mal vergessen. Genau diese Art von Drift |
| 28 | ist in diesem Projekt schon zweimal vorgekommen. |
| 29 | |
| 30 | ## Was dieses Werkzeug prüft |
| 31 | |
| 32 | 1. **Vollständigkeit** — jede Fassung hat genau einen solchen Abschnitt. |
| 33 | ``[Unveröffentlicht]`` braucht ihn, sobald dort überhaupt etwas steht. |
| 34 | 2. **Länge** — der Text passt in das Feld des Partner Centers. |
| 35 | 3. **Öffentlichkeit** — er enthält keinen Dateinamen, keinen Pfad, keinen |
| 36 | Werkzeugnamen, keinen Verweis auf ein anderes Dokument, keine Testzahl, |
| 37 | keine Git-Kennung und keine personenbezogene Angabe. |
| 38 | |
| 39 | Schlägt eine der drei Prüfungen an, bricht das Werkzeug ab und nennt Fassung |
| 40 | und Fundstelle. Es schreibt dann nichts — ein halb geprüfter öffentlicher Text |
| 41 | ist schlimmer als keiner. |
| 42 | |
| 43 | ``app/tests/dokumentation.test.ts`` hält dieselben drei Zusagen fest, damit sie |
| 44 | auch dann auffallen, wenn niemand dieses Werkzeug aufruft. |
| 45 | |
| 46 | ## Aufruf |
| 47 | |
| 48 | python tools/store_notiz.py |
| 49 | python tools/store_notiz.py --pruefen # meldet nur, ob die Datei aktuell ist |
| 50 | python tools/store_notiz.py --feld # gibt nur den Text der aktuellen Fassung aus |
| 51 | """ |
| 52 | |
| 53 | from __future__ import annotations |
| 54 | |
| 55 | import io |
| 56 | import json |
| 57 | import re |
| 58 | import sys |
| 59 | import unicodedata |
| 60 | from pathlib import Path |
| 61 | |
| 62 | WURZEL = Path(__file__).resolve().parent.parent |
| 63 | PAKET = WURZEL / 'app' / 'package.json' |
| 64 | CHANGELOG = WURZEL / 'CHANGELOG.md' |
| 65 | ZIEL = WURZEL / 'docs' / 'store-notiz.md' |
| 66 | |
| 67 | #: Überschrift eines Fassungsabschnitts: "## [0.23.0] — 2026-08-29". |
| 68 | FASSUNGSKOPF = re.compile(r'^## \[(?P<nummer>[^\]]+)\](?:\s*—\s*(?P<datum>\S+))?\s*$') |
| 69 | |
| 70 | #: Der Abschnitt, der öffentlich werden darf. Genau dieser, kein anderer. |
| 71 | OEFFENTLICH = '### Für die Öffentlichkeit' |
| 72 | |
| 73 | #: Grenze des Feldes „What's new in this version" im Partner Center (deutsche |
| 74 | #: Dokumentation: „Was ist neu in dieser Version?"; früher „Versionshinweise", |
| 75 | #: in der Übermittlungs-API bis heute ``releaseNotes``). |
| 76 | #: |
| 77 | #: 1500 Zeichen, am 29.08.2026 auf zwei Seiten von Microsoft Learn unabhängig |
| 78 | #: belegt — der MSIX-Seite und der MSI/EXE-Seite zum Store-Eintrag. Maßgeblich |
| 79 | #: ist die MSIX-Seite, weil dieses Projekt den MSIX-Weg geht. |
| 80 | #: |
| 81 | #: Steht die Zahl einmal falsch hier, ist jeder erzeugte Text falsch bemessen — |
| 82 | #: deshalb steht sie an genau einer Stelle, und ``dokumentation.test.ts`` holt |
| 83 | #: sie von hier, statt sie zu wiederholen. |
| 84 | FELDGRENZE = 1500 |
| 85 | |
| 86 | |
| 87 | def fassung() -> str: |
| 88 | return json.loads(PAKET.read_text(encoding='utf-8'))['version'] |
| 89 | |
| 90 | |
| 91 | # ── Was in einem öffentlichen Text nie vorkommen darf ─────────────────────── |
| 92 | # |
| 93 | # Jedes Muster ist an einer echten Fundstelle im Changelog belegt; keines |
| 94 | # trifft einen gewöhnlichen deutschen Satz. Die Reihenfolge ist die der |
| 95 | # Häufigkeit, damit die erste Meldung meist schon die richtige ist. |
| 96 | VERBOTE: list[tuple[str, str, str]] = [ |
| 97 | ( |
| 98 | 'Auszeichnung als Code', |
| 99 | r'`', |
| 100 | 'Rückwärtsschrägstriche zeichnen Quelltext aus. Im Store steht kein Quelltext.', |
| 101 | ), |
| 102 | ( |
| 103 | 'Verweis auf ein Dokument', |
| 104 | r'\]\(', |
| 105 | 'Ein Markdown-Verweis zeigt auf eine Datei, die im Store niemand hat.', |
| 106 | ), |
| 107 | ( |
| 108 | 'Dateiname', |
| 109 | r'\b[\w\-]+\.(?:json|jsonl|ts|tsx|js|mjs|cjs|md|py|yml|yaml|html|css|exe|db|sqlite|png|svg|txt|lock)\b', |
| 110 | 'Ein Dateiname sagt einem Lernenden nichts und verrät den inneren Aufbau.', |
| 111 | ), |
| 112 | ( |
| 113 | 'Pfadangabe', |
| 114 | r'(?:[A-Za-z]:\\|\b(?:app|docs|content|tools|release|resources|src|e2e|tests?)/)', |
| 115 | 'Ein Pfad gehört in den Quelltext, nicht in einen Store-Eintrag.', |
| 116 | ), |
| 117 | ( |
| 118 | 'Umgebungsvariable', |
| 119 | r'%[A-Z][A-Z_]+%', |
| 120 | 'Eine Umgebungsvariable ist eine Angabe für Fachleute.', |
| 121 | ), |
| 122 | ( |
| 123 | # Nicht dabei: FSRS. Das ist kein Werkzeug, sondern das benannte |
| 124 | # Wiedervorlageverfahren — die Produktbeschreibung im Store nennt es |
| 125 | # mit Absicht („Wiedervorlage nach FSRS-6"), weil man es nachschlagen |
| 126 | # kann. Diese Liste stand einmal mit FSRS darin und schlug prompt an |
| 127 | # der eigenen, bereits geprüften Store-Beschreibung an. |
| 128 | 'Werkzeugname', |
| 129 | r'\b(?:npm|Playwright|Vitest|vitest|ESLint|Prettier|electron-builder|electron-vite|Electron|SQLite|better-sqlite3|axe-core|axe|NSIS|Node\.js|TypeScript|React|Python|Git|GitHub)\b', |
| 130 | 'Womit gebaut wurde, ändert für den Benutzer nichts.', |
| 131 | ), |
| 132 | ( |
| 133 | 'Git-Kennung', |
| 134 | r'\b[0-9a-f]{7,40}\b', |
| 135 | 'Eine Commit-Kennung ist ein Zeiger in ein Archiv, das niemand hat.', |
| 136 | ), |
| 137 | ( |
| 138 | # Der Binnenversal braucht zwei Buchstaben davor und zwei danach. |
| 139 | # Ohne diese Schranke traf das Muster „lfB" — die deutsche Bezeichnung |
| 140 | # der Randfeuerpatrone .22 lfB, die in den Erklärungen dieser |
| 141 | # Anwendung vierzigmal vorkommt. Ein Muster, das die Fachsprache des |
| 142 | # eigenen Gegenstands für Quelltext hält, ist unbrauchbar. |
| 143 | 'Bezeichner aus dem Quelltext', |
| 144 | r'\b\w+_\w+\b|\b[a-z]{2,}[A-Z][a-zA-Z]{2,}\b', |
| 145 | 'Ein Bezeichner mit Unterstrich oder Binnenversal stammt aus dem Quelltext.', |
| 146 | ), |
| 147 | ( |
| 148 | 'E-Mail-Adresse', |
| 149 | r'[\w.+-]+@[\w-]+\.[\w.]+', |
| 150 | 'Eine Adresse ist eine personenbezogene Angabe.', |
| 151 | ), |
| 152 | ( |
| 153 | # Der Änderungsverlauf führt drei Adressen, die im Store nichts zu |
| 154 | # suchen haben: die private Webseite mit dem Impressum, den Wirt des |
| 155 | # Webspace und — nach der eigenen Verbotsliste ausdrücklich — das |
| 156 | # Zuwendungskonto. Ein Spendenaufruf in einem Store-Eintrag verstößt |
| 157 | # gegen die Richtlinien. |
| 158 | 'Adresse im Netz', |
| 159 | r'https?://\S+|\b[\w-]+\.(?:com|net|org|io|dev)\b', |
| 160 | 'Ein Verweis ins Netz gehört nicht in einen Versionshinweis.', |
| 161 | ), |
| 162 | ( |
| 163 | 'Name oder Anschrift des Herausgebers', |
| 164 | r'Olaf\s+Willerding|olaf-willerding|ko-fi|kasserver', |
| 165 | 'Wer die Software herausgibt, steht im Copyright-Feld — nicht in jedem Versionshinweis.', |
| 166 | ), |
| 167 | ( |
| 168 | # Nicht wegen des Datenschutzes, sondern wegen der Store-Richtlinien: |
| 169 | # Ein Hinweis auf eine freiwillige Zuwendung bringt die Kaufanmutung |
| 170 | # ins Listing, die das Produkt gerade fernhalten will. Der Store-Eintrag |
| 171 | # hat das in Abschnitt 9.3 entschieden — für die Beschreibung. Für den |
| 172 | # Versionshinweis gilt dasselbe, und dort wäre es beinahe passiert: Der |
| 173 | # erste Entwurf zu 0.22.0 führte die Bitte als ersten Punkt. |
| 174 | # |
| 175 | # Bewusst eng gefasst. „Unterstützung" und „unterstützen" allein sind |
| 176 | # gewöhnliche Wörter — die Anwendung unterstützt Bildschirmleser, und |
| 177 | # das darf sie auch sagen. |
| 178 | 'Bitte um eine Zuwendung', |
| 179 | r'Spende|spenden|Zuwendung|Bitte um Unterstützung|Arbeit unterstützen', |
| 180 | 'Ein Hinweis auf eine Zuwendung lässt den Eintrag nach einem Kauf aussehen (Store-Eintrag 9.3).', |
| 181 | ), |
| 182 | ( |
| 183 | 'Auftragsname aus dem Bau', |
| 184 | r'(?<![\w:/])[a-z]{3,}:[a-z]{3,}(?![\w:/])', |
| 185 | 'Ein Bauauftrag wie „dist:win" ist eine Anweisung an die Werkbank.', |
| 186 | ), |
| 187 | ( |
| 188 | 'Fassungsetikett aus der Versionsverwaltung', |
| 189 | r'(?<!\w)v\d+\.\d+\.\d+(?!\w)', |
| 190 | 'Das „v" davor ist die Schreibweise des Etiketts im Archiv, nicht die der Fassung.', |
| 191 | ), |
| 192 | ( |
| 193 | 'Angabe zu Tests oder Testabdeckung', |
| 194 | r'\bTestabdeckung\b|\bAbdeckung\b[^.\n]{0,60}?(?:%|Prozent)|(?<!\w)\d+\s+(?:von\s+\d+\s+)?Tests?(?!\w)', |
| 195 | 'Wie gut geprüft wurde, gehört in den Stand der Software, nicht in den Store.', |
| 196 | ), |
| 197 | ( |
| 198 | # Nicht das Wort „Werkbank" allein — das ist gewöhnliches Deutsch und |
| 199 | # kommt in den Erklärungen dieser Anwendung vor. Wohl aber die |
| 200 | # Überschrift, die jemand beim Kopieren mitnimmt. |
| 201 | # |
| 202 | # Ebenfalls nicht dabei: „Unveröffentlicht" und „Änderungsverlauf". |
| 203 | # Beide standen hier und fielen im ersten Probelauf durch — sie sind |
| 204 | # gewöhnliche Wörter, und ein Satz wie „Der vollständige |
| 205 | # Änderungsverlauf steht …" wäre in einem Versionshinweis völlig in |
| 206 | # Ordnung. |
| 207 | 'Überschrift aus dem Änderungsverlauf', |
| 208 | r'Für die Werkbank', |
| 209 | 'Diese Überschrift gliedert den internen Verlauf, nicht den Store-Text.', |
| 210 | ), |
| 211 | ] |
| 212 | |
| 213 | #: Projektinterne Wörter. Sie sind nicht falsch, nur unverständlich. |
| 214 | #: |
| 215 | #: Bewusst kurz. Der erste Entwurf führte auch „Werkbank", „Gegenprobe", |
| 216 | #: „Meisterwerk", „Wache" und „Gate" — und schlug prompt an den Erklärungen |
| 217 | #: der eigenen Anwendung an: Eine „Werkbank im Hobbyraum" ist kein |
| 218 | #: Sicherheitsbehältnis nach § 13 AWaffV, und eine Merkregel zum Repetierer |
| 219 | #: heißt dort „die Gegenprobe". Das sind gewöhnliche deutsche Wörter, die hier |
| 220 | #: zufällig auch Projektsprache sind; eine Maschine kann das nicht |
| 221 | #: unterscheiden. Übrig bleiben nur Zusammensetzungen, die es außerhalb dieses |
| 222 | #: Projekts nicht gibt. |
| 223 | JARGON = [ |
| 224 | 'Vollstufe', |
| 225 | 'Schnellstufe', |
| 226 | 'Durchstich', |
| 227 | 'Baukennung', |
| 228 | 'Paketstand', |
| 229 | ] |
| 230 | |
| 231 | |
| 232 | def verstoesse(text: str) -> list[str]: |
| 233 | """Alle Verletzungen der Öffentlichkeitsregeln in einem Text.""" |
| 234 | gefunden: list[str] = [] |
| 235 | for name, muster, warum in VERBOTE: |
| 236 | for treffer in re.finditer(muster, text): |
| 237 | gefunden.append(f'{name}: „{treffer.group(0)}" — {warum}') |
| 238 | for wort in JARGON: |
| 239 | # Nur als eigenständiges Wort, sonst trifft „Gate" jedes „Gateway". |
| 240 | if re.search(rf'\b{re.escape(wort)}\b', text): |
| 241 | gefunden.append(f'Projektjargon: „{wort}" — versteht nur, wer am Projekt arbeitet.') |
| 242 | return gefunden |
| 243 | |
| 244 | |
| 245 | # ── Den Changelog zerlegen ───────────────────────────────────────────────── |
| 246 | |
| 247 | |
| 248 | def abschnitte() -> list[dict[str, object]]: |
| 249 | """Alle Fassungen mit ihrem öffentlichen Block, in Dateireihenfolge.""" |
| 250 | zeilen = CHANGELOG.read_text(encoding='utf-8').split('\n') |
| 251 | |
| 252 | koepfe: list[tuple[int, str, str]] = [] |
| 253 | for i, zeile in enumerate(zeilen): |
| 254 | treffer = FASSUNGSKOPF.match(zeile) |
| 255 | if treffer is not None: |
| 256 | koepfe.append((i, treffer.group('nummer'), treffer.group('datum') or '')) |
| 257 | |
| 258 | if not koepfe: |
| 259 | raise SystemExit('ABBRUCH: CHANGELOG.md hat keine Fassungsüberschrift.') |
| 260 | |
| 261 | # Hinter der ältesten Fassung steht ein Kommentar, der zu keiner gehört. |
| 262 | schluss = len(zeilen) |
| 263 | for i in range(koepfe[-1][0] + 1, len(zeilen)): |
| 264 | if zeilen[i].startswith('<!--'): |
| 265 | schluss = i |
| 266 | break |
| 267 | |
| 268 | ergebnis: list[dict[str, object]] = [] |
| 269 | for k, (start, nummer, datum) in enumerate(koepfe): |
| 270 | ende = koepfe[k + 1][0] if k + 1 < len(koepfe) else schluss |
| 271 | inhalt = zeilen[start + 1 : ende] |
| 272 | ergebnis.append( |
| 273 | { |
| 274 | 'nummer': nummer, |
| 275 | 'datum': datum, |
| 276 | 'zeile': start + 1, |
| 277 | 'unterabschnitte': [z for z in inhalt if z.startswith('### ')], |
| 278 | 'block': block_lesen(inhalt), |
| 279 | } |
| 280 | ) |
| 281 | return ergebnis |
| 282 | |
| 283 | |
| 284 | def block_lesen(inhalt: list[str]) -> list[str] | None: |
| 285 | """Die Zeilen des öffentlichen Blocks, oder ``None``, wenn er fehlt.""" |
| 286 | treffer = [i for i, z in enumerate(inhalt) if z.strip() == OEFFENTLICH] |
| 287 | if not treffer: |
| 288 | return None |
| 289 | if len(treffer) > 1: |
| 290 | raise SystemExit(f'ABBRUCH: „{OEFFENTLICH}" steht mehrfach in einer Fassung.') |
| 291 | |
| 292 | start = treffer[0] + 1 |
| 293 | ende = len(inhalt) |
| 294 | for i in range(start, len(inhalt)): |
| 295 | if inhalt[i].startswith('### ') or inhalt[i].startswith('## '): |
| 296 | ende = i |
| 297 | break |
| 298 | |
| 299 | block = inhalt[start:ende] |
| 300 | while block and not block[0].strip(): |
| 301 | block.pop(0) |
| 302 | while block and not block[-1].strip(): |
| 303 | block.pop() |
| 304 | return block |
| 305 | |
| 306 | |
| 307 | # ── Aus Markdown reinen Text machen ──────────────────────────────────────── |
| 308 | # |
| 309 | # Das Feld im Partner Center nimmt reinen Text. Fettungen blieben dort als |
| 310 | # Sternchen stehen und sähen aus wie ein Versehen. |
| 311 | # |
| 312 | # Der Strich am Zeilenanfang folgt der Hausform der übrigen Store-Texte: Die |
| 313 | # Produktbeschreibung in ``docs/store-eintrag.md`` setzt ihre Aufzählungen |
| 314 | # ebenso, weil das Feld keine Auszeichnung kennt. Ein Aufzählungszeichen wie |
| 315 | # „•" käme je nach Schriftart als Kästchen an. |
| 316 | FETT = re.compile(r'\*\*(?P<inhalt>[^*]+)\*\*') |
| 317 | KURSIV = re.compile(r'(?<![*\w])\*(?P<inhalt>[^*]+)\*(?![*\w])') |
| 318 | |
| 319 | |
| 320 | def reintext(block: list[str]) -> str: |
| 321 | """Der öffentliche Block als reiner Text, so wie er ins Feld kommt.""" |
| 322 | absaetze: list[str] = [] |
| 323 | laufend = '' |
| 324 | for zeile in block: |
| 325 | blank = zeile.strip() |
| 326 | if not blank: |
| 327 | continue |
| 328 | if blank.startswith('- '): |
| 329 | if laufend: |
| 330 | absaetze.append(laufend) |
| 331 | laufend = '- ' + blank[2:] |
| 332 | else: |
| 333 | laufend = (laufend + ' ' + blank).strip() if laufend else blank |
| 334 | if laufend: |
| 335 | absaetze.append(laufend) |
| 336 | |
| 337 | text = '\n'.join(absaetze) |
| 338 | text = FETT.sub(lambda t: t.group('inhalt'), text) |
| 339 | text = KURSIV.sub(lambda t: t.group('inhalt'), text) |
| 340 | return text |
| 341 | |
| 342 | |
| 343 | def zeichenzahl(text: str) -> int: |
| 344 | """Zeichen so gezählt, wie eine Eingabemaske sie zählt. |
| 345 | |
| 346 | Zusammengesetzte Zeichen (``a`` + Trema) zählen dort als eines. Der |
| 347 | Unterschied fällt bei „ä" auf, und deutsche Texte sind voll davon. |
| 348 | """ |
| 349 | return len(unicodedata.normalize('NFC', text)) |
| 350 | |
| 351 | |
| 352 | # ── Prüfen ───────────────────────────────────────────────────────────────── |
| 353 | |
| 354 | |
| 355 | def pruefen(liste: list[dict[str, object]]) -> list[str]: |
| 356 | """Alle Beanstandungen über alle Fassungen.""" |
| 357 | fehler: list[str] = [] |
| 358 | for eintrag in liste: |
| 359 | nummer = str(eintrag['nummer']) |
| 360 | block = eintrag['block'] |
| 361 | unterabschnitte = eintrag['unterabschnitte'] |
| 362 | assert isinstance(unterabschnitte, list) |
| 363 | |
| 364 | if block is None: |
| 365 | # Ein leerer [Unveröffentlicht]-Abschnitt braucht keinen Text. |
| 366 | if nummer == 'Unveröffentlicht' and not unterabschnitte: |
| 367 | continue |
| 368 | fehler.append( |
| 369 | f'Fassung {nummer} (Zeile {eintrag["zeile"]}): ' |
| 370 | f'Der Abschnitt „{OEFFENTLICH}" fehlt. ' |
| 371 | f'Ohne ihn gibt es für diese Fassung keinen Text, der öffentlich werden darf.' |
| 372 | ) |
| 373 | continue |
| 374 | |
| 375 | assert isinstance(block, list) |
| 376 | if not block: |
| 377 | fehler.append(f'Fassung {nummer}: „{OEFFENTLICH}" ist leer.') |
| 378 | continue |
| 379 | |
| 380 | text = reintext(block) |
| 381 | laenge = zeichenzahl(text) |
| 382 | if laenge > FELDGRENZE: |
| 383 | fehler.append( |
| 384 | f'Fassung {nummer}: {laenge} Zeichen, erlaubt sind {FELDGRENZE}. ' |
| 385 | f'Um {laenge - FELDGRENZE} zu lang.' |
| 386 | ) |
| 387 | |
| 388 | for verstoss in verstoesse(text): |
| 389 | fehler.append(f'Fassung {nummer}: {verstoss}') |
| 390 | |
| 391 | return fehler |
| 392 | |
| 393 | |
| 394 | # ── Schreiben ────────────────────────────────────────────────────────────── |
| 395 | |
| 396 | KOPF = """# Öffentliche Versionshinweise |
| 397 | |
| 398 | **Stand: Fassung {nummer}.** Was sich in jeder Fassung für jemanden geändert |
| 399 | hat, der die Anwendung benutzt. |
| 400 | |
| 401 | > Dieses Dokument ist **erzeugt, nicht von Hand geschrieben**. Quelle ist der |
| 402 | > Abschnitt „Für die Öffentlichkeit" der jeweiligen Fassung im |
| 403 | > Änderungsverlauf. Neu erzeugen mit dem Werkzeug im Verzeichnis der |
| 404 | > Hilfsprogramme. |
| 405 | |
| 406 | ## Wozu es da ist |
| 407 | |
| 408 | Der Änderungsverlauf des Projekts ist für die Entwicklung geschrieben. Er nennt |
| 409 | Dateien, Werkzeuge und Zahlen aus dem Prüflauf — nichts davon ist geheim, aber |
| 410 | nichts davon gehört in einen Store-Eintrag. Hier steht dieselbe Fassung noch |
| 411 | einmal, nur das, was beim Benutzen ankommt, und in einer Sprache ohne |
| 412 | Vorwissen. |
| 413 | |
| 414 | Jeder Text ist maschinell daraufhin geprüft, dass er keinen Dateinamen, keinen |
| 415 | Pfad, keinen Werkzeugnamen, keine Zahl aus dem Prüflauf, keine Kennung aus der |
| 416 | Versionsverwaltung und keine personenbezogene Angabe enthält. Diese Prüfung |
| 417 | sieht die Form an, nicht den Sinn: Ob ein Satz inhaltlich hinausgehen soll, |
| 418 | entscheidet, wer ihn schreibt. |
| 419 | |
| 420 | **Zwei Dinge, damit niemand mehr hineinliest, als dasteht.** Erstens ist jeder |
| 421 | Eintrag eine **Auswahl**, keine vollständige Liste — das Feld fasst {grenze} |
| 422 | Zeichen, eine große Fassung ändert mehr. Wer alles wissen will, findet es im |
| 423 | Änderungsverlauf. Zweitens nennt jeder Eintrag die **Zahlen seiner Zeit**: Wo |
| 424 | eine ältere Fassung von 87 Glossareinträgen spricht, waren es damals 87. Was |
| 425 | heute gilt, steht in der Produktbeschreibung. |
| 426 | |
| 427 | ## Was die Fassungsnummern bedeuten |
| 428 | |
| 429 | Die Zahl vorn ist eine Null und bleibt es, bis der Funktionsumfang steht. |
| 430 | Alles unterhalb von Fassung {erste_veroeffentlichte} war ein Entwicklungsstand: |
| 431 | hier vollständig aufgeführt, weil zu jeder Änderung eine Angabe gehört, aber |
| 432 | nie öffentlich zu haben. Diese Liste ist eine Änderungsgeschichte, keine |
| 433 | Veröffentlichungsgeschichte. |
| 434 | |
| 435 | Was seit der letzten Fassung aufgelaufen und noch nicht freigegeben ist, steht |
| 436 | hier **nicht**. Ein Hinweis auf etwas, das niemand bekommen kann, wäre ein |
| 437 | Versprechen. |
| 438 | |
| 439 | **Bei der ersten Einreichung bleibt das Feld leer.** So verlangt es Microsoft |
| 440 | ausdrücklich: Wer eine Anwendung zum ersten Mal übermittelt, lässt „What's new |
| 441 | in this version" frei — es gibt noch nichts, wovon sich etwas unterscheiden |
| 442 | könnte. Der Text unten ist deshalb für die erste **Aktualisierung** gedacht, |
| 443 | nicht für die erste Einreichung. |
| 444 | |
| 445 | --- |
| 446 | |
| 447 | ## Zum Einfügen: Fassung {nummer} |
| 448 | |
| 449 | Für das Feld „What's new in this version" im Store-Eintrag. Grenze {grenze} |
| 450 | Zeichen, dieser Text hat {zeichen} — {reserve} bleiben frei. |
| 451 | |
| 452 | Das Feld nimmt einfachen Text. Ob es Zeilenumbrüche erhält, ist nicht |
| 453 | dokumentiert; deshalb besteht jeder Punkt aus vollständigen Sätzen und bleibt |
| 454 | auch dann lesbar, wenn der Store alles zu einem Absatz zusammenzieht. |
| 455 | |
| 456 | ``` |
| 457 | {feldtext} |
| 458 | ``` |
| 459 | |
| 460 | --- |
| 461 | |
| 462 | ## Alle Fassungen |
| 463 | |
| 464 | """ |
| 465 | |
| 466 | |
| 467 | def bericht() -> str: |
| 468 | liste = abschnitte() |
| 469 | fehler = pruefen(liste) |
| 470 | if fehler: |
| 471 | raise SystemExit( |
| 472 | 'ABBRUCH: Die öffentlichen Versionshinweise sind nicht in Ordnung.\n ' |
| 473 | + '\n '.join(fehler) |
| 474 | ) |
| 475 | |
| 476 | nummer = fassung() |
| 477 | aktuell = next((e for e in liste if e['nummer'] == nummer), None) |
| 478 | if aktuell is None: |
| 479 | raise SystemExit(f'ABBRUCH: CHANGELOG.md hat keinen Abschnitt für Fassung {nummer}.') |
| 480 | |
| 481 | block = aktuell['block'] |
| 482 | assert isinstance(block, list) |
| 483 | feldtext = reintext(block) |
| 484 | |
| 485 | teile = [ |
| 486 | KOPF.format( |
| 487 | nummer=nummer, |
| 488 | grenze=FELDGRENZE, |
| 489 | zeichen=zeichenzahl(feldtext), |
| 490 | reserve=FELDGRENZE - zeichenzahl(feldtext), |
| 491 | feldtext=feldtext, |
| 492 | erste_veroeffentlichte=ERSTE_VEROEFFENTLICHTE, |
| 493 | ) |
| 494 | ] |
| 495 | |
| 496 | for eintrag in liste: |
| 497 | if eintrag['block'] is None: |
| 498 | continue |
| 499 | # Was noch keine Fassung ist, wird auch nicht veröffentlicht. Der Text |
| 500 | # muss trotzdem geschrieben und geprüft sein — sonst entstünde er erst |
| 501 | # unter Zeitdruck am Tag der Freigabe. Er wartet nur, bis der Abschnitt |
| 502 | # eine Nummer bekommt. |
| 503 | if eintrag['nummer'] == 'Unveröffentlicht': |
| 504 | continue |
| 505 | kopf = str(eintrag['nummer']) |
| 506 | if eintrag['datum']: |
| 507 | kopf += f' — {eintrag["datum"]}' |
| 508 | zeilen = eintrag['block'] |
| 509 | assert isinstance(zeilen, list) |
| 510 | teile.append(f'### {kopf}\n\n' + '\n'.join(zeilen) + '\n') |
| 511 | |
| 512 | return '\n'.join(teile) |
| 513 | |
| 514 | |
| 515 | #: Die erste Fassung, die überhaupt zur Veröffentlichung eingereicht wurde. |
| 516 | #: Alles darunter war ein Entwicklungsstand. Steht hier, damit das Dokument |
| 517 | #: nicht den Eindruck einer langen Veröffentlichungsgeschichte erweckt. |
| 518 | ERSTE_VEROEFFENTLICHTE = '0.22.0' |
| 519 | |
| 520 | |
| 521 | def main() -> int: |
| 522 | if '--feld' in sys.argv: |
| 523 | liste = abschnitte() |
| 524 | aktuell = next((e for e in liste if e['nummer'] == fassung()), None) |
| 525 | if aktuell is None or aktuell['block'] is None: |
| 526 | raise SystemExit(f'ABBRUCH: Fassung {fassung()} hat keinen „{OEFFENTLICH}".') |
| 527 | block = aktuell['block'] |
| 528 | assert isinstance(block, list) |
| 529 | text = reintext(block) |
| 530 | |
| 531 | # Geprüft wird auch hier, und zwar bevor etwas ausgegeben wird. |
| 532 | # |
| 533 | # Bis Fassung 0.24.1 lief `pruefen()` ausschliesslich aus `bericht()` |
| 534 | # heraus; dieser Zweig kehrte davor um. Ausgerechnet der Aufruf, dessen |
| 535 | # einziger Zweck es ist, den Text zum Einfuegen ins Partner Center zu |
| 536 | # liefern, lief damit ohne Laengengrenze, ohne Verbotsliste und ohne |
| 537 | # Jargonpruefung — und der Modulkopf sagt das Gegenteil zu. Wer den |
| 538 | # Text von hier nimmt, nimmt ihn ungeprueft nach draussen. |
| 539 | beanstandet = [f'Fassung {fassung()}: {grund}' for grund in verstoesse(text)] |
| 540 | laenge = zeichenzahl(text) |
| 541 | if laenge > FELDGRENZE: |
| 542 | beanstandet.append( |
| 543 | f'Fassung {fassung()}: {laenge} Zeichen, erlaubt sind {FELDGRENZE}.' |
| 544 | ) |
| 545 | if beanstandet: |
| 546 | print('ABBRUCH: Der Text darf so nicht hinaus.', file=sys.stderr) |
| 547 | for grund in beanstandet: |
| 548 | print(f' {grund}', file=sys.stderr) |
| 549 | return 1 |
| 550 | |
| 551 | print(text) |
| 552 | print(f'\n— {laenge} von {FELDGRENZE} Zeichen', file=sys.stderr) |
| 553 | return 0 |
| 554 | |
| 555 | neu = bericht() |
| 556 | |
| 557 | if '--pruefen' in sys.argv: |
| 558 | alt = ZIEL.read_text(encoding='utf-8') if ZIEL.exists() else '' |
| 559 | if alt == neu: |
| 560 | print(f'{ZIEL.relative_to(WURZEL)} ist aktuell.') |
| 561 | return 0 |
| 562 | print(f'{ZIEL.relative_to(WURZEL)} ist NICHT aktuell — neu erzeugen mit:') |
| 563 | print(' python tools/store_notiz.py') |
| 564 | return 1 |
| 565 | |
| 566 | io.open(ZIEL, 'w', encoding='utf-8', newline='').write(neu) |
| 567 | print(f'Geschrieben: {ZIEL.relative_to(WURZEL)} ({len(neu)} Zeichen, Fassung {fassung()}).') |
| 568 | return 0 |
| 569 | |
| 570 | |
| 571 | if __name__ == '__main__': |
| 572 | raise SystemExit(main()) |