waffensachkunde

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

/ tools store_notiz.py

23,0 KB Rohdatei
tools/store_notiz.py — 572 Zeilen
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())