"""Baut ``docs/store-notiz.md`` — die öffentlich verwendbaren Versionshinweise. ## Warum es dieses Werkzeug gibt ``CHANGELOG.md`` ist für die Werkbank geschrieben. Dort stehen Dateinamen, Werkzeugnamen, Testzahlen und Begründungen, die nur versteht, wer den Quelltext kennt. Nichts davon ist geheim — aber nichts davon gehört in einen Store-Eintrag, den Lernende lesen. Gebraucht wird deshalb je Fassung ein zweiter Text: was sich für jemanden ändert, der die Anwendung **benutzt**. Er geht in das Feld für Versionshinweise im Partner Center, und er muss bei **jeder** Fassung entstehen — auch bei einer Unterfassung, die nur einen Fehler behebt. Eine Fassung ohne öffentlichen Text ist eine Fassung, über die niemand erfährt, was sie geändert hat. ## Wo der Text steht Im Changelog selbst, als Abschnitt ``### Für die Öffentlichkeit`` unter der jeweiligen Fassung — das Gegenstück zu ``### Für die Werkbank``. Die Überschrift nennt den Leser, und darin liegt ihr Zweck: Wer sie schreibt, weiß dabei, dass dieser Text hinausgeht. Dass er im Changelog steht und nicht in einer eigenen Datei, ist ebenfalls Absicht. So schreibt man denselben Sachverhalt im selben Augenblick zweimal — einmal für die Werkbank, einmal für die Öffentlichkeit. Läge der öffentliche Text woanders, würde er beim nächsten Mal vergessen. Genau diese Art von Drift ist in diesem Projekt schon zweimal vorgekommen. ## Was dieses Werkzeug prüft 1. **Vollständigkeit** — jede Fassung hat genau einen solchen Abschnitt. ``[Unveröffentlicht]`` braucht ihn, sobald dort überhaupt etwas steht. 2. **Länge** — der Text passt in das Feld des Partner Centers. 3. **Öffentlichkeit** — er enthält keinen Dateinamen, keinen Pfad, keinen Werkzeugnamen, keinen Verweis auf ein anderes Dokument, keine Testzahl, keine Git-Kennung und keine personenbezogene Angabe. Schlägt eine der drei Prüfungen an, bricht das Werkzeug ab und nennt Fassung und Fundstelle. Es schreibt dann nichts — ein halb geprüfter öffentlicher Text ist schlimmer als keiner. ``app/tests/dokumentation.test.ts`` hält dieselben drei Zusagen fest, damit sie auch dann auffallen, wenn niemand dieses Werkzeug aufruft. ## Aufruf python tools/store_notiz.py python tools/store_notiz.py --pruefen # meldet nur, ob die Datei aktuell ist python tools/store_notiz.py --feld # gibt nur den Text der aktuellen Fassung aus """ from __future__ import annotations import io import json import re import sys import unicodedata from pathlib import Path WURZEL = Path(__file__).resolve().parent.parent PAKET = WURZEL / 'app' / 'package.json' CHANGELOG = WURZEL / 'CHANGELOG.md' ZIEL = WURZEL / 'docs' / 'store-notiz.md' #: Überschrift eines Fassungsabschnitts: "## [0.23.0] — 2026-08-29". FASSUNGSKOPF = re.compile(r'^## \[(?P[^\]]+)\](?:\s*—\s*(?P\S+))?\s*$') #: Der Abschnitt, der öffentlich werden darf. Genau dieser, kein anderer. OEFFENTLICH = '### Für die Öffentlichkeit' #: Grenze des Feldes „What's new in this version" im Partner Center (deutsche #: Dokumentation: „Was ist neu in dieser Version?"; früher „Versionshinweise", #: in der Übermittlungs-API bis heute ``releaseNotes``). #: #: 1500 Zeichen, am 29.08.2026 auf zwei Seiten von Microsoft Learn unabhängig #: belegt — der MSIX-Seite und der MSI/EXE-Seite zum Store-Eintrag. Maßgeblich #: ist die MSIX-Seite, weil dieses Projekt den MSIX-Weg geht. #: #: Steht die Zahl einmal falsch hier, ist jeder erzeugte Text falsch bemessen — #: deshalb steht sie an genau einer Stelle, und ``dokumentation.test.ts`` holt #: sie von hier, statt sie zu wiederholen. FELDGRENZE = 1500 def fassung() -> str: return json.loads(PAKET.read_text(encoding='utf-8'))['version'] # ── Was in einem öffentlichen Text nie vorkommen darf ─────────────────────── # # Jedes Muster ist an einer echten Fundstelle im Changelog belegt; keines # trifft einen gewöhnlichen deutschen Satz. Die Reihenfolge ist die der # Häufigkeit, damit die erste Meldung meist schon die richtige ist. VERBOTE: list[tuple[str, str, str]] = [ ( 'Auszeichnung als Code', r'`', 'Rückwärtsschrägstriche zeichnen Quelltext aus. Im Store steht kein Quelltext.', ), ( 'Verweis auf ein Dokument', r'\]\(', 'Ein Markdown-Verweis zeigt auf eine Datei, die im Store niemand hat.', ), ( 'Dateiname', r'\b[\w\-]+\.(?:json|jsonl|ts|tsx|js|mjs|cjs|md|py|yml|yaml|html|css|exe|db|sqlite|png|svg|txt|lock)\b', 'Ein Dateiname sagt einem Lernenden nichts und verrät den inneren Aufbau.', ), ( 'Pfadangabe', r'(?:[A-Za-z]:\\|\b(?:app|docs|content|tools|release|resources|src|e2e|tests?)/)', 'Ein Pfad gehört in den Quelltext, nicht in einen Store-Eintrag.', ), ( 'Umgebungsvariable', r'%[A-Z][A-Z_]+%', 'Eine Umgebungsvariable ist eine Angabe für Fachleute.', ), ( # Nicht dabei: FSRS. Das ist kein Werkzeug, sondern das benannte # Wiedervorlageverfahren — die Produktbeschreibung im Store nennt es # mit Absicht („Wiedervorlage nach FSRS-6"), weil man es nachschlagen # kann. Diese Liste stand einmal mit FSRS darin und schlug prompt an # der eigenen, bereits geprüften Store-Beschreibung an. 'Werkzeugname', 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', 'Womit gebaut wurde, ändert für den Benutzer nichts.', ), ( 'Git-Kennung', r'\b[0-9a-f]{7,40}\b', 'Eine Commit-Kennung ist ein Zeiger in ein Archiv, das niemand hat.', ), ( # Der Binnenversal braucht zwei Buchstaben davor und zwei danach. # Ohne diese Schranke traf das Muster „lfB" — die deutsche Bezeichnung # der Randfeuerpatrone .22 lfB, die in den Erklärungen dieser # Anwendung vierzigmal vorkommt. Ein Muster, das die Fachsprache des # eigenen Gegenstands für Quelltext hält, ist unbrauchbar. 'Bezeichner aus dem Quelltext', r'\b\w+_\w+\b|\b[a-z]{2,}[A-Z][a-zA-Z]{2,}\b', 'Ein Bezeichner mit Unterstrich oder Binnenversal stammt aus dem Quelltext.', ), ( 'E-Mail-Adresse', r'[\w.+-]+@[\w-]+\.[\w.]+', 'Eine Adresse ist eine personenbezogene Angabe.', ), ( # Der Änderungsverlauf führt drei Adressen, die im Store nichts zu # suchen haben: die private Webseite mit dem Impressum, den Wirt des # Webspace und — nach der eigenen Verbotsliste ausdrücklich — das # Zuwendungskonto. Ein Spendenaufruf in einem Store-Eintrag verstößt # gegen die Richtlinien. 'Adresse im Netz', r'https?://\S+|\b[\w-]+\.(?:com|net|org|io|dev)\b', 'Ein Verweis ins Netz gehört nicht in einen Versionshinweis.', ), ( 'Name oder Anschrift des Herausgebers', r'Olaf\s+Willerding|olaf-willerding|ko-fi|kasserver', 'Wer die Software herausgibt, steht im Copyright-Feld — nicht in jedem Versionshinweis.', ), ( # Nicht wegen des Datenschutzes, sondern wegen der Store-Richtlinien: # Ein Hinweis auf eine freiwillige Zuwendung bringt die Kaufanmutung # ins Listing, die das Produkt gerade fernhalten will. Der Store-Eintrag # hat das in Abschnitt 9.3 entschieden — für die Beschreibung. Für den # Versionshinweis gilt dasselbe, und dort wäre es beinahe passiert: Der # erste Entwurf zu 0.22.0 führte die Bitte als ersten Punkt. # # Bewusst eng gefasst. „Unterstützung" und „unterstützen" allein sind # gewöhnliche Wörter — die Anwendung unterstützt Bildschirmleser, und # das darf sie auch sagen. 'Bitte um eine Zuwendung', r'Spende|spenden|Zuwendung|Bitte um Unterstützung|Arbeit unterstützen', 'Ein Hinweis auf eine Zuwendung lässt den Eintrag nach einem Kauf aussehen (Store-Eintrag 9.3).', ), ( 'Auftragsname aus dem Bau', r'(? list[str]: """Alle Verletzungen der Öffentlichkeitsregeln in einem Text.""" gefunden: list[str] = [] for name, muster, warum in VERBOTE: for treffer in re.finditer(muster, text): gefunden.append(f'{name}: „{treffer.group(0)}" — {warum}') for wort in JARGON: # Nur als eigenständiges Wort, sonst trifft „Gate" jedes „Gateway". if re.search(rf'\b{re.escape(wort)}\b', text): gefunden.append(f'Projektjargon: „{wort}" — versteht nur, wer am Projekt arbeitet.') return gefunden # ── Den Changelog zerlegen ───────────────────────────────────────────────── def abschnitte() -> list[dict[str, object]]: """Alle Fassungen mit ihrem öffentlichen Block, in Dateireihenfolge.""" zeilen = CHANGELOG.read_text(encoding='utf-8').split('\n') koepfe: list[tuple[int, str, str]] = [] for i, zeile in enumerate(zeilen): treffer = FASSUNGSKOPF.match(zeile) if treffer is not None: koepfe.append((i, treffer.group('nummer'), treffer.group('datum') or '')) if not koepfe: raise SystemExit('ABBRUCH: CHANGELOG.md hat keine Fassungsüberschrift.') # Hinter der ältesten Fassung steht ein Kommentar, der zu keiner gehört. schluss = len(zeilen) for i in range(koepfe[-1][0] + 1, len(zeilen)): if zeilen[i].startswith('