waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | /** |
| 2 | * Wie aktuell ist das gepackte Paket unter `release/win-unpacked/`? |
| 3 | * |
| 4 | * ## Warum es diese Datei gibt |
| 5 | * |
| 6 | * `e2e/gepackt.spec.ts` prüft die **gepackte** Anwendung – die Auflösung der |
| 7 | * Pfade unter `resources/`, die Versionsanzeige, den Start aus dem Paket |
| 8 | * heraus. Diese Prüfungen liefen bisher gegen das, was gerade in |
| 9 | * `release/win-unpacked/` lag, gleich wie alt es war. |
| 10 | * |
| 11 | * Das hat in diesem Projekt **zweimal** einen echten Fehler verdeckt: |
| 12 | * |
| 13 | * 1. Beim Umbau der Reifekennzahl hing die Prüfung weiter am Wortlaut |
| 14 | * „0 von 575 Fragen sicher“, den es nicht mehr gab. |
| 15 | * 2. Als der Systemzustand den Quellort des Fragenkatalogs bekam, wurde der |
| 16 | * Locator `.statusliste code` mehrdeutig. |
| 17 | * |
| 18 | * Beide Male meldete `npm run gate` grün, und beide Male fiel es erst beim |
| 19 | * nächsten Paketbau auf – weil das Paket bis dahin die Änderung gar nicht |
| 20 | * enthielt. |
| 21 | * |
| 22 | * `bauPruefen()` in `e2e/electron-hilfe.ts` führt dieses Argument für `out/` |
| 23 | * schon selbst: „Ein Bau von gestern gegen den Quelltext von heute ist |
| 24 | * schlimmer als gar keiner: Der Lauf ist grün und misst die falsche |
| 25 | * Anwendung.“ Auf das Paket wurde es nie angewandt. |
| 26 | * |
| 27 | * ## Warum der Vergleich nicht bei `src/` aufhört |
| 28 | * |
| 29 | * Die erste Fassung verglich das Paket allein mit `app/src`. Das ist die |
| 30 | * halbe Lieferung: Der **Inhalt** – Fragenkatalog, Erklärungen, Glossar, |
| 31 | * Prüfzeichen – liegt gar nicht unter `app/src`, sondern unter `content/` |
| 32 | * und wird über `extraResources` in `electron-builder.yml` mitgepackt. Wer |
| 33 | * eine Erklärung ändert und dann prüft, bekam ein Paket gemeldet, das „so |
| 34 | * jung wie der Quelltext“ sei – und die Prüfungen maßen die vorige |
| 35 | * Fassung der Inhalte und meldeten grün. Genau die Fehlerklasse, gegen die |
| 36 | * diese Datei angelegt wurde, nur eine Tür weiter. |
| 37 | * |
| 38 | * Deshalb liest der Vergleich die Liste der mitgelieferten Pfade **aus |
| 39 | * `electron-builder.yml`** statt sie hier noch einmal aufzuschreiben. Zwei |
| 40 | * Listen desselben Inhalts laufen beim nächsten Umbau auseinander, und dann |
| 41 | * schweigt die Wache wieder. Was electron-builder packt, wird geprüft; was |
| 42 | * dazukommt, wird ohne Zutun mitgeprüft. |
| 43 | * |
| 44 | * ## Warum eine eigene Datei und kein zweiter Abgleich |
| 45 | * |
| 46 | * Zwei Stellen brauchen dieselbe Antwort: die Prüfung selbst, damit sie sich |
| 47 | * mit Grund überspringt, und `gate-stempel.mjs`, damit der Gate-Bericht sagt, |
| 48 | * was er **nicht** geprüft hat. Zwei Umsetzungen desselben Vergleichs liefen |
| 49 | * irgendwann auseinander, und dann widerspräche der Bericht dem Lauf. |
| 50 | * |
| 51 | * Als `.mjs` und nicht als TypeScript, weil `gate-stempel.mjs` sie ohne |
| 52 | * Übersetzungsschritt lädt. |
| 53 | */ |
| 54 | |
| 55 | import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; |
| 56 | import { dirname, join, relative, resolve } from 'node:path'; |
| 57 | |
| 58 | /** |
| 59 | * Toleranz gegen die Auflösung des Dateisystems. |
| 60 | * |
| 61 | * Derselbe Wert wie in `bauPruefen()`: Ein Paketbau schreibt seine Ausgaben |
| 62 | * nicht in derselben Millisekunde, in der er die Quellen liest. |
| 63 | */ |
| 64 | const TOLERANZ_MS = 2000; |
| 65 | |
| 66 | /** Verzeichnisse, die für den Vergleich nichts beitragen. */ |
| 67 | const UEBERGANGEN = new Set(['node_modules', '.git', 'out', 'release', 'test-results']); |
| 68 | |
| 69 | /** |
| 70 | * Was `out/` für den Vergleich ersetzt. |
| 71 | * |
| 72 | * `electron-builder.yml` packt `out/**` – das ist aber kein Quelltext, |
| 73 | * sondern das Erzeugnis von `electron-vite build` aus `src/`. Auf `out/` |
| 74 | * selbst zu schauen ginge zweimal daneben: `npm run gate` baut `out/` in |
| 75 | * jedem Lauf neu, das Paket sähe also unmittelbar nach jedem Gate veraltet |
| 76 | * aus; und die Frage, ob der Quelltext weitergewandert ist, beantwortet es |
| 77 | * ohnehin nicht. `src/` ist die ehrliche Entsprechung. |
| 78 | */ |
| 79 | const ERSATZ_FUER_ERZEUGNIS = new Map([['out', 'src']]); |
| 80 | |
| 81 | /** Zeichen, an denen electron-builder einen Glob erkennt. */ |
| 82 | const GLOB_ZEICHEN = /[*?[\]{}]/u; |
| 83 | |
| 84 | /** |
| 85 | * Nimmt einer YAML-Skalarzeile Anführungszeichen und Zeilenkommentar ab. |
| 86 | * |
| 87 | * @param {string} roh |
| 88 | * @returns {string} |
| 89 | */ |
| 90 | function entwerten(roh) { |
| 91 | const wert = roh.trim(); |
| 92 | |
| 93 | for (const anfuehrung of ["'", '"']) { |
| 94 | if (wert.startsWith(anfuehrung)) { |
| 95 | const ende = wert.indexOf(anfuehrung, 1); |
| 96 | if (ende < 0) { |
| 97 | throw new Error(`electron-builder.yml: unbeendetes Anführungszeichen in ${wert}`); |
| 98 | } |
| 99 | return wert.slice(1, ende); |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | const kommentar = wert.indexOf(' #'); |
| 104 | return kommentar < 0 ? wert : wert.slice(0, kommentar).trim(); |
| 105 | } |
| 106 | |
| 107 | /** |
| 108 | * Liest die beiden Listen aus `electron-builder.yml`, die etwas ausliefern. |
| 109 | * |
| 110 | * Bewusst ein winziger, **strenger** Ausschnitt von YAML statt einer |
| 111 | * Abhängigkeit: `js-yaml` liegt zwar unter `node_modules`, aber nur als |
| 112 | * Beipack von electron-builder – ein Import darauf wäre eine nicht erklärte |
| 113 | * Abhängigkeit. Der Ausschnitt versteht genau die Formen, die diese Datei |
| 114 | * heute enthält, und **wirft** bei allem anderen. Ein lauter Fehler beim |
| 115 | * nächsten Umbau ist der Punkt: Eine Wache, die eine neue Schreibweise still |
| 116 | * überliest, bewacht wieder nichts. |
| 117 | * |
| 118 | * @param {string} text Inhalt von `electron-builder.yml`. |
| 119 | * @returns {{ programm: string[], inhalte: string[] }} Muster, wie sie in der |
| 120 | * Datei stehen: `programm` aus `files:`, `inhalte` aus `extraResources:`. |
| 121 | */ |
| 122 | export function musterAusBauplan(text) { |
| 123 | /** @type {string[]} */ const programm = []; |
| 124 | /** @type {string[]} */ const inhalte = []; |
| 125 | |
| 126 | /** @type {'files' | 'extraResources' | null} */ |
| 127 | let block = null; |
| 128 | // Einzug der `- `-Zeilen des laufenden Blocks; -1, solange unbekannt. |
| 129 | let eintragsEinzug = -1; |
| 130 | |
| 131 | for (const zeile of text.split(/\r?\n/u)) { |
| 132 | const ohneEinzug = zeile.trimStart(); |
| 133 | if (ohneEinzug === '' || ohneEinzug.startsWith('#')) { |
| 134 | continue; |
| 135 | } |
| 136 | const einzug = zeile.length - ohneEinzug.length; |
| 137 | |
| 138 | // Oberste Ebene: ein neuer Schlüssel beendet den laufenden Block. |
| 139 | if (einzug === 0) { |
| 140 | const schluessel = /^([A-Za-z][A-Za-z0-9_]*):/u.exec(ohneEinzug); |
| 141 | const name = schluessel?.[1]; |
| 142 | block = name === 'files' || name === 'extraResources' ? name : null; |
| 143 | eintragsEinzug = -1; |
| 144 | continue; |
| 145 | } |
| 146 | |
| 147 | if (block === null) { |
| 148 | continue; |
| 149 | } |
| 150 | |
| 151 | if (!ohneEinzug.startsWith('- ')) { |
| 152 | // Eigenschaft eines Eintrags (`to:`, `filter:`) – liefert selbst nichts. |
| 153 | if (eintragsEinzug >= 0 && einzug > eintragsEinzug) { |
| 154 | continue; |
| 155 | } |
| 156 | throw new Error(`electron-builder.yml: unverstandene Zeile im Block ${block}: ${ohneEinzug}`); |
| 157 | } |
| 158 | |
| 159 | if (eintragsEinzug < 0) { |
| 160 | eintragsEinzug = einzug; |
| 161 | } |
| 162 | if (einzug > eintragsEinzug) { |
| 163 | // Unterliste eines Eintrags, etwa `filter:` – kein eigener Pfad. |
| 164 | continue; |
| 165 | } |
| 166 | if (einzug < eintragsEinzug) { |
| 167 | throw new Error( |
| 168 | `electron-builder.yml: Listeneintrag mit fremdem Einzug im Block ${block}: ${ohneEinzug}`, |
| 169 | ); |
| 170 | } |
| 171 | |
| 172 | const wert = ohneEinzug.slice(2).trim(); |
| 173 | // Beide Blöcke erlauben sowohl `- pfad` als auch `- from: pfad`. |
| 174 | const ausFrom = /^from:\s*(.+)$/u.exec(wert); |
| 175 | const muster = entwerten(ausFrom ? ausFrom[1] : wert); |
| 176 | |
| 177 | // Ausschlussmuster liefern nichts aus und tragen zum Alter nichts bei. |
| 178 | if (muster.startsWith('!')) { |
| 179 | continue; |
| 180 | } |
| 181 | |
| 182 | (block === 'files' ? programm : inhalte).push(muster); |
| 183 | } |
| 184 | |
| 185 | return { programm, inhalte }; |
| 186 | } |
| 187 | |
| 188 | /** |
| 189 | * Liest die `files:`-Listen der Plattformblöcke `win:` und `mac:`. |
| 190 | * |
| 191 | * ## Warum das eine eigene Auskunft ist |
| 192 | * |
| 193 | * electron-builder behandelt eine plattformeigene `files:`-Liste **nicht** als |
| 194 | * Ergänzung der obersten, sondern als deren Ersatz: Die oberste wird zu einem |
| 195 | * zweiten Kopierauftrag, die plattformeigene wird der Hauptvergleich. Besteht |
| 196 | * sie ausschließlich aus Ausschlussmustern, stellt `getMainFileMatchers` ihr |
| 197 | * `**\/*` voran (app-builder-lib/out/fileMatcher.js, `containsOnlyIgnore`) – |
| 198 | * und damit landet **alles** im Paket, was die oberste Liste gerade |
| 199 | * heraushalten sollte. |
| 200 | * |
| 201 | * Genau das ist bis Fassung 0.27.2 passiert: Beide Plattformblöcke enthielten |
| 202 | * nur je ein `!`-Muster für die fremden Fertigteile von better-sqlite3. Das |
| 203 | * ausgelieferte `app.asar` der Fassung 0.27.2 trug deshalb `src/` (200 |
| 204 | * Dateien), `tests/` (81), `e2e/` (26), `tools/` (3), sämtliche |
| 205 | * Konfigurationsdateien und den HTML-Abdeckungsbericht des letzten |
| 206 | * Gate-Laufs (`coverage/`, 217 Dateien, 8.967.480 Byte) – während zwei Zeilen |
| 207 | * über der obersten Liste steht: „Nur die gebauten Bundles paketieren – die |
| 208 | * Quellen bleiben draußen.“ |
| 209 | * |
| 210 | * Gelesen werden auch die `!`-Muster: Ob eine Liste **nur** aus ihnen besteht, |
| 211 | * ist die Bedingung, an der die Falle hängt. |
| 212 | * |
| 213 | * @param {string} text Inhalt von `electron-builder.yml`. |
| 214 | * @returns {Record<string, string[]>} Je Plattformschlüssel die Muster ihrer |
| 215 | * `files:`-Liste, in der Schreibweise der Datei. Plattformen ohne eigene |
| 216 | * Liste fehlen. |
| 217 | */ |
| 218 | export function plattformDateilisten(text) { |
| 219 | const PLATTFORMEN = new Set(['win', 'mac', 'linux']); |
| 220 | /** @type {Record<string, string[]>} */ const listen = {}; |
| 221 | |
| 222 | /** @type {string | null} */ let plattform = null; |
| 223 | // Einzug der `files:`-Zeile im laufenden Plattformblock; -1 = nicht darin. |
| 224 | let listenEinzug = -1; |
| 225 | |
| 226 | for (const zeile of text.split(/\r?\n/u)) { |
| 227 | const ohneEinzug = zeile.trimStart(); |
| 228 | if (ohneEinzug === '' || ohneEinzug.startsWith('#')) { |
| 229 | continue; |
| 230 | } |
| 231 | const einzug = zeile.length - ohneEinzug.length; |
| 232 | |
| 233 | if (einzug === 0) { |
| 234 | const name = /^([A-Za-z][A-Za-z0-9_]*):/u.exec(ohneEinzug)?.[1]; |
| 235 | plattform = name !== undefined && PLATTFORMEN.has(name) ? name : null; |
| 236 | listenEinzug = -1; |
| 237 | continue; |
| 238 | } |
| 239 | |
| 240 | if (plattform === null) { |
| 241 | continue; |
| 242 | } |
| 243 | |
| 244 | if (listenEinzug < 0) { |
| 245 | if (/^files:\s*$/u.test(ohneEinzug)) { |
| 246 | listenEinzug = einzug; |
| 247 | listen[plattform] = []; |
| 248 | } |
| 249 | continue; |
| 250 | } |
| 251 | |
| 252 | if (einzug <= listenEinzug) { |
| 253 | // Der nächste Schlüssel des Plattformblocks beendet die Liste. |
| 254 | listenEinzug = -1; |
| 255 | continue; |
| 256 | } |
| 257 | |
| 258 | if (!ohneEinzug.startsWith('- ')) { |
| 259 | throw new Error( |
| 260 | `electron-builder.yml: unverstandene Zeile in ${plattform}.files: ${ohneEinzug}`, |
| 261 | ); |
| 262 | } |
| 263 | listen[plattform]?.push(entwerten(ohneEinzug.slice(2).trim())); |
| 264 | } |
| 265 | |
| 266 | return listen; |
| 267 | } |
| 268 | |
| 269 | /** |
| 270 | * Macht aus einem Auslieferungsmuster den Pfad, dessen Alter zählt. |
| 271 | * |
| 272 | * `out/**\/*` wird zu `src`, `../content/katalog` zum Verzeichnis selbst. |
| 273 | * |
| 274 | * @param {string} appWurzel |
| 275 | * @param {string} muster |
| 276 | * @returns {string} Absoluter Pfad. |
| 277 | */ |
| 278 | function pfadZuMuster(appWurzel, muster) { |
| 279 | const teile = muster.split('/'); |
| 280 | /** @type {string[]} */ const fest = []; |
| 281 | for (const teil of teile) { |
| 282 | if (GLOB_ZEICHEN.test(teil)) { |
| 283 | break; |
| 284 | } |
| 285 | fest.push(teil); |
| 286 | } |
| 287 | |
| 288 | if (fest.length === 0) { |
| 289 | throw new Error( |
| 290 | `electron-builder.yml: Muster ${muster} beginnt mit einem Platzhalter. ` + |
| 291 | 'Es ließe sich nur auf das ganze Projektverzeichnis auflösen und ' + |
| 292 | 'wäre als Altersvergleich wertlos.', |
| 293 | ); |
| 294 | } |
| 295 | |
| 296 | const ersatz = ERSATZ_FUER_ERZEUGNIS.get(fest[0]); |
| 297 | if (ersatz !== undefined) { |
| 298 | fest.splice(0, fest.length, ersatz); |
| 299 | } |
| 300 | |
| 301 | const pfad = resolve(appWurzel, ...fest); |
| 302 | if (!existsSync(pfad)) { |
| 303 | throw new Error( |
| 304 | `electron-builder.yml verweist auf ${muster}, unter ${pfad} liegt aber nichts. ` + |
| 305 | 'Entweder ist der Bauplan veraltet oder der Altersvergleich in ' + |
| 306 | 'tools/paketstand.mjs löst ihn falsch auf – beides muss auffallen.', |
| 307 | ); |
| 308 | } |
| 309 | return pfad; |
| 310 | } |
| 311 | |
| 312 | /** |
| 313 | * Das Verzeichnis, aus dem electron-builder die Bauzutaten holt. |
| 314 | * |
| 315 | * Das Programmsymbol liegt weder unter `files:` noch unter `extraResources:` |
| 316 | * – es steht in `directories.buildResources` und wird von electron-builder |
| 317 | * in die exe und in das Installationsprogramm eingebaut. Genau deshalb fehlte |
| 318 | * es hier zuerst: Wer das Symbol änderte, bekam ein Paket gemeldet, das „so |
| 319 | * jung wie der Quelltext“ sei, und trug das alte Bild weiter. Dieselbe |
| 320 | * Fehlerklasse wie damals bei `content/`, nur eine Tür weiter. |
| 321 | * |
| 322 | * Gelesen wird auch das aus dem Bauplan statt es hier aufzuschreiben. Zwei |
| 323 | * Listen desselben Sachverhalts laufen beim nächsten Umbau auseinander. |
| 324 | * |
| 325 | * @param {string} text Inhalt von `electron-builder.yml`. |
| 326 | * @returns {string | null} Der Verzeichnisname, oder `null`, wenn keiner steht. |
| 327 | */ |
| 328 | export function bauressourcenAusBauplan(text) { |
| 329 | let imBlock = false; |
| 330 | for (const zeile of text.split(/\r?\n/u)) { |
| 331 | const ohneEinzug = zeile.trimStart(); |
| 332 | if (ohneEinzug === '' || ohneEinzug.startsWith('#')) { |
| 333 | continue; |
| 334 | } |
| 335 | if (zeile.length === ohneEinzug.length) { |
| 336 | // Oberste Ebene: ein neuer Schlüssel beendet den laufenden Block. |
| 337 | imBlock = /^directories:/u.test(ohneEinzug); |
| 338 | continue; |
| 339 | } |
| 340 | if (imBlock) { |
| 341 | const treffer = /^buildResources:\s*(\S+)/u.exec(ohneEinzug); |
| 342 | if (treffer) { |
| 343 | return treffer[1]; |
| 344 | } |
| 345 | } |
| 346 | } |
| 347 | return null; |
| 348 | } |
| 349 | |
| 350 | /** |
| 351 | * Alle Pfade, deren Alter über die Aktualität des Pakets entscheidet. |
| 352 | * |
| 353 | * Eigene Funktion und nicht in `paketstand()` versteckt, weil sie für sich |
| 354 | * prüfbar sein muss: Sie ist die Stelle, an der Bauplan und Vergleich |
| 355 | * auseinanderlaufen könnten. `paketstand()` beantwortet sie nicht, wenn gar |
| 356 | * kein Paket dasteht – ein Test, der nur über `paketstand()` ginge, prüfte |
| 357 | * die Auflösung an einem Arbeitsplatz ohne `release/` also gar nicht. |
| 358 | * |
| 359 | * @param {string} appWurzel Das Verzeichnis `app/`. |
| 360 | * @returns {{ quelltext: string[], inhalte: string[] }} Absolute Pfade. |
| 361 | */ |
| 362 | export function ausgelieferteQuellen(appWurzel) { |
| 363 | const bauplan = join(appWurzel, 'electron-builder.yml'); |
| 364 | if (!existsSync(bauplan)) { |
| 365 | throw new Error( |
| 366 | `Kein Bauplan unter ${bauplan}. Ohne ihn ist nicht zu sagen, was das Paket ` + |
| 367 | 'ausliefert – und ein Altersvergleich, der das nicht weiß, prüft nichts.', |
| 368 | ); |
| 369 | } |
| 370 | |
| 371 | const text = readFileSync(bauplan, 'utf8'); |
| 372 | const muster = musterAusBauplan(text); |
| 373 | const quelltext = muster.programm.map((m) => pfadZuMuster(appWurzel, m)); |
| 374 | |
| 375 | /* |
| 376 | Der Bauplan selbst gehört dazu. |
| 377 | |
| 378 | Er sagt nicht nur, **was** ausgeliefert wird, sondern bestimmt auch, **wie**: |
| 379 | Ziele, Dateilisten, ASAR-Entpackung, die Angaben des Store-Pakets. Wer |
| 380 | daran etwas ändert, hat ein anderes Paket vor sich – bis Fassung 0.24.1 |
| 381 | galt der alte Beleg trotzdem weiter, weil die Wache jeden Pfad ansah, |
| 382 | den der Bauplan nennt, nur nicht ihn selbst. Derselbe blinde Fleck wie |
| 383 | bei den Bauressourcen (7.23), eine Ebene höher. |
| 384 | */ |
| 385 | quelltext.push(bauplan); |
| 386 | |
| 387 | const bauressourcen = bauressourcenAusBauplan(text); |
| 388 | if (bauressourcen !== null) { |
| 389 | quelltext.push(pfadZuMuster(appWurzel, bauressourcen)); |
| 390 | } |
| 391 | |
| 392 | return { |
| 393 | quelltext, |
| 394 | inhalte: muster.inhalte.map((m) => pfadZuMuster(appWurzel, m)), |
| 395 | }; |
| 396 | } |
| 397 | |
| 398 | /** |
| 399 | * @typedef {object} Aenderung |
| 400 | * @property {number} zeit Änderungszeit in Millisekunden; 0, wenn nichts da war. |
| 401 | * @property {string} pfad Die Datei, von der sie stammt; leer bei 0. |
| 402 | */ |
| 403 | |
| 404 | /** |
| 405 | * Jüngste Änderung unterhalb eines Pfades – samt der Datei, die sie trägt. |
| 406 | * |
| 407 | * Die Datei mitzuführen kostet nichts und macht aus „irgendetwas ist neuer“ |
| 408 | * eine nachprüfbare Aussage: Wer die Meldung liest, weiß sofort, ob er neu |
| 409 | * bauen muss oder ob er selbst eine Datei angefasst hat. |
| 410 | * |
| 411 | * @param {string} pfad Datei oder Verzeichnis. |
| 412 | * @returns {Aenderung} |
| 413 | */ |
| 414 | function juengsteAenderung(pfad) { |
| 415 | const eigenschaften = statSync(pfad); |
| 416 | if (!eigenschaften.isDirectory()) { |
| 417 | return { zeit: eigenschaften.mtimeMs, pfad }; |
| 418 | } |
| 419 | |
| 420 | /** @type {Aenderung} */ let juengste = { zeit: 0, pfad: '' }; |
| 421 | for (const eintrag of readdirSync(pfad, { withFileTypes: true })) { |
| 422 | if (UEBERGANGEN.has(eintrag.name)) { |
| 423 | continue; |
| 424 | } |
| 425 | const kind = juengsteAenderung(join(pfad, eintrag.name)); |
| 426 | if (kind.zeit > juengste.zeit) { |
| 427 | juengste = kind; |
| 428 | } |
| 429 | } |
| 430 | return juengste; |
| 431 | } |
| 432 | |
| 433 | /** |
| 434 | * Jüngste Änderung über mehrere Pfade hinweg. |
| 435 | * |
| 436 | * @param {string[]} pfade |
| 437 | * @returns {Aenderung} |
| 438 | */ |
| 439 | function juengsteUeber(pfade) { |
| 440 | /** @type {Aenderung} */ let juengste = { zeit: 0, pfad: '' }; |
| 441 | for (const pfad of pfade) { |
| 442 | const kandidat = juengsteAenderung(pfad); |
| 443 | if (kandidat.zeit > juengste.zeit) { |
| 444 | juengste = kandidat; |
| 445 | } |
| 446 | } |
| 447 | return juengste; |
| 448 | } |
| 449 | |
| 450 | /** |
| 451 | * Pfad, wie ihn ein Mensch im Projekt sucht: relativ zur Projektwurzel. |
| 452 | * |
| 453 | * @param {string} appWurzel |
| 454 | * @param {string} pfad |
| 455 | * @returns {string} |
| 456 | */ |
| 457 | function lesbar(appWurzel, pfad) { |
| 458 | return relative(dirname(appWurzel), pfad).replaceAll('\\', '/'); |
| 459 | } |
| 460 | |
| 461 | /** |
| 462 | * @typedef {object} Paketstand |
| 463 | * @property {boolean} vorhanden Ob überhaupt ein gepacktes Paket dasteht. |
| 464 | * @property {boolean} veraltet Ob es älter ist als das, was es ausliefert. |
| 465 | * @property {number} alterSekunden Um wie viel es zurückliegt; 0, wenn aktuell. |
| 466 | * @property {'quelltext' | 'inhalte' | 'beides' | null} ursache |
| 467 | * Was weitergewandert ist. `null`, solange nichts veraltet ist. |
| 468 | * @property {string} juengsteQuelle |
| 469 | * Die Datei, die den Ausschlag gibt, relativ zur Projektwurzel; leer, wenn |
| 470 | * das Paket aktuell ist oder fehlt. |
| 471 | * @property {string} grund Ein Satz, der den Zustand benennt. |
| 472 | */ |
| 473 | |
| 474 | /** |
| 475 | * Vergleicht das gepackte Paket mit allem, was es ausliefert. |
| 476 | * |
| 477 | * @param {string} appWurzel Das Verzeichnis `app/`. |
| 478 | * @returns {Paketstand} |
| 479 | */ |
| 480 | export function paketstand(appWurzel) { |
| 481 | const exe = join(appWurzel, 'release', 'win-unpacked', 'Waffensachkunde Lernsoftware.exe'); |
| 482 | |
| 483 | if (!existsSync(exe)) { |
| 484 | return { |
| 485 | vorhanden: false, |
| 486 | veraltet: false, |
| 487 | alterSekunden: 0, |
| 488 | ursache: null, |
| 489 | juengsteQuelle: '', |
| 490 | grund: |
| 491 | 'Kein gepacktes Paket vorhanden. Die Prüfungen gegen das gebaute Paket ' + |
| 492 | 'wurden übersprungen – zuerst `npm run dist:win` ausführen.', |
| 493 | }; |
| 494 | } |
| 495 | |
| 496 | const quellen = ausgelieferteQuellen(appWurzel); |
| 497 | const quelltext = juengsteUeber(quellen.quelltext); |
| 498 | const inhalte = juengsteUeber(quellen.inhalte); |
| 499 | |
| 500 | const gepacktAm = statSync(exe).mtimeMs; |
| 501 | const grenze = gepacktAm + TOLERANZ_MS; |
| 502 | const quelltextNeuer = quelltext.zeit > grenze; |
| 503 | const inhalteNeuer = inhalte.zeit > grenze; |
| 504 | |
| 505 | if (!quelltextNeuer && !inhalteNeuer) { |
| 506 | return { |
| 507 | vorhanden: true, |
| 508 | veraltet: false, |
| 509 | alterSekunden: 0, |
| 510 | ursache: null, |
| 511 | juengsteQuelle: '', |
| 512 | grund: 'Das gepackte Paket ist so jung wie Quelltext und mitgelieferte Inhalte.', |
| 513 | }; |
| 514 | } |
| 515 | |
| 516 | const juengste = quelltext.zeit >= inhalte.zeit ? quelltext : inhalte; |
| 517 | const alterSekunden = Math.round((juengste.zeit - gepacktAm) / 1000); |
| 518 | const quelle = lesbar(appWurzel, juengste.pfad); |
| 519 | |
| 520 | /* Der Grundtext sagt weiterhin, WARUM übersprungen wird – und jetzt auch, |
| 521 | WAS weitergewandert ist. Der Unterschied ist keine Feinheit: „Quelltext“ |
| 522 | heißt neu bauen, „Inhalte“ heißt, dass eine Änderung an content/ noch |
| 523 | nicht im Paket steckt. Beim ersten Auftreten dieses Falls stand in der |
| 524 | Meldung nichts davon, weil der Vergleich content/ gar nicht ansah. */ |
| 525 | /** @type {'quelltext' | 'inhalte' | 'beides'} */ |
| 526 | let ursache = 'beides'; |
| 527 | if (!inhalteNeuer) { |
| 528 | ursache = 'quelltext'; |
| 529 | } else if (!quelltextNeuer) { |
| 530 | ursache = 'inhalte'; |
| 531 | } |
| 532 | |
| 533 | /* „das Programm“ statt „der Quelltext“, seit auch das Verzeichnis mit dem |
| 534 | Programmsymbol mitzählt: Ein Symbol ist kein Quelltext, steckt aber |
| 535 | genauso in der gebauten exe. Die Meldung nennt ohnehin die Datei, die den |
| 536 | Ausschlag gibt – daran sieht man sofort, welcher Fall vorliegt. */ |
| 537 | const benennung = { |
| 538 | quelltext: `das Programm (${quelle})`, |
| 539 | inhalte: `die mitgelieferten Inhalte (${quelle})`, |
| 540 | beides: `Programm und mitgelieferte Inhalte (jüngste Änderung: ${quelle})`, |
| 541 | }[ursache]; |
| 542 | |
| 543 | return { |
| 544 | vorhanden: true, |
| 545 | veraltet: true, |
| 546 | alterSekunden, |
| 547 | ursache, |
| 548 | juengsteQuelle: quelle, |
| 549 | grund: |
| 550 | `Das gepackte Paket ist ${String(alterSekunden)} s älter als ${benennung}. ` + |
| 551 | 'Die Prüfungen dagegen würden die vorige Fassung messen und grün melden; ' + |
| 552 | 'sie wurden deshalb übersprungen – zuerst `npm run dist:win` ausführen.', |
| 553 | }; |
| 554 | } |