hmgr 0.0.6 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,6 +20,9 @@
20
20
  // Pfade, die hier angezeigt werden, baut der Kern mit `node:path`, also mit dem Trenner der
21
21
  // Plattform — auch dann, wenn HOME selbst in POSIX-Schreibweise gesetzt ist (MSYS/Git-Bash).
22
22
  var TRENNER = '/';
23
+ // Die Plattform selbst (E78): entscheidet, ob der Weg zu einem Homedir in WSL überhaupt im Bild
24
+ // steht. Die Grenze zieht der Kern; hier soll nur kein Knopf stehen, der sicher abbricht.
25
+ var PLATTFORM = '';
23
26
  /** Trenner einer Plattform, wie `process.platform` sie nennt. Rein, nur für die Anzeige. */
24
27
  function plattformTrenner(plattform) { return plattform === 'win32' ? '\\' : '/'; }
25
28
  /**
@@ -163,15 +166,20 @@
163
166
 
164
167
  /* ---------------- Ort- und Grund-Texte ---------------- */
165
168
 
169
+ /* Ein zusätzliches Homedir (E78) heißt nach seiner Adresse, damit die Karten „claude-code · Home"
170
+ und „claude-code · wsl:Ubuntu" im Bild auseinanderzuhalten sind. */
166
171
  function ortName(ort) {
167
- if (ort.art === 'home') return 'Home';
172
+ if (ort.art === 'home') return ort.heim ? ort.heim : 'Home';
168
173
  if (ort.art === 'profil') return 'Profil „' + ort.name + '“';
169
174
  return 'Arbeitsverzeichnis ' + pfadKurz(ort.pfad);
170
175
  }
171
176
  function zielTitel(z) { return harnessName(z.ziel.harness) + ' · ' + ortName(z.ziel.ort); }
177
+ /* Gleichlautend mit zielSchluessel im Kern: ein Ort ohne heim behält seinen bisherigen Schlüssel. */
172
178
  function zielKey(ziel) {
173
179
  var o = ziel.ort;
174
- var ort = o.art === 'home' ? 'home' : o.art === 'profil' ? 'profil:' + o.name : 'dir:' + o.pfad;
180
+ var ort = o.art === 'home'
181
+ ? (o.heim ? 'home:' + o.heim : 'home')
182
+ : o.art === 'profil' ? 'profil:' + o.name : 'dir:' + o.pfad;
175
183
  return ziel.harness + '/' + ort;
176
184
  }
177
185
  function quelleKurz(quelle) { return quelle.art === 'git' ? quelle.url : quelle.pfad; }
@@ -303,10 +311,18 @@
303
311
  var ZIELE = [];
304
312
  var SKILLS = [];
305
313
  var MARKETPLACES = [];
314
+ // Bundles aller bekannten Quellen (E69), entdeckt beim Quellen-Scan: kein eigener Katalog, keine
315
+ // eigene Ansicht. Grundlage der Bundle-Sektion im Dialog „Skills hinzufügen" (F68).
316
+ var BUNDLES = [];
317
+ // Definitionen, die sich nicht lesen ließen: genannt, damit eine kaputte Datei nicht stumm fehlt.
318
+ var BUNDLES_KAPUTT = [];
306
319
  var QUELLEN = [];
307
320
  var HARNESSE_ALLE = [];
308
321
  var AKTIVE_HARNESSE = [];
309
322
  var HARNESSE_NICHT_INSTALLIERT = [];
323
+ // Homedirs je Harness (E78): je Eintrag { harness, heime: [{ adresse?, pfad, eingestellt }],
324
+ // kannZusaetzliche }. Grundlage der Homedir-Zeilen in den Einstellungen.
325
+ var HARNESS_HEIME = [];
310
326
  // MCP-Katalog (E61-Folgerunde): je Eintrag { eintrag, eingehaengt, fehlendeVariablen }, dazu
311
327
  // Einträge in Harness-Konfigurationen, die nicht im Katalog stehen (nur genannt). Seit F46 nur
312
328
  // noch Grundlage für den MCP-Abschnitt der Ziel-Karten (mcpZeilenFuerZiel), die MCP-Ansicht selbst
@@ -321,6 +337,14 @@
321
337
  // Ziel-Karten — nie am Harness-Namen entschieden, sondern an dieser Liste.
322
338
  var MCP_PLAETZE = [];
323
339
  var quellenFilter = 'alle';
340
+ // Sortierung der Skills-Rubrik (E79, Richtung E81): hält für die Sitzung wie quellenFilter,
341
+ // übersteht also ladeAlles. SKILLS selbst bleibt unsortiert nach Datum, damit li.dataset.index
342
+ // stabil bleibt.
343
+ var skillSortierung = 'name';
344
+ // Sortierrichtung je Schlüssel (E81): 'auf' oder 'ab'. Ein Klick auf den bereits aktiven Chip
345
+ // kehrt sie um, ein Klick auf den anderen Chip setzt die Standardrichtung des neuen Schlüssels.
346
+ var skillRichtung = 'auf';
347
+ var SKILL_RICHTUNG_STANDARD = { name: 'auf', datum: 'ab' };
324
348
  var zielFilter = 'alle';
325
349
  // Standard-Ref je Quelle (E60), aus dem Katalog abgeleitet (SKILLS trägt ihn schon je Skill): ein
326
350
  // Ref an einem Link erscheint nur, wenn er davon abweicht (`@v2.1`). Schlüssel wie quelleKurz.
@@ -330,19 +354,43 @@
330
354
 
331
355
  // Ignorierte fremde Einträge zählen hier nicht mit (E43): Ignorieren blendet die Warnung aus,
332
356
  // sonst bliebe das Ziel im Filter „braucht Aufmerksamkeit" stehen.
333
- function zielBraucht(z) {
357
+ function zielAusloeser(z) {
334
358
  var ignoriert = z.ignoriert || [];
335
- return z.eintraege.some(function (ei) {
359
+ return z.eintraege.filter(function (ei) {
336
360
  if (ei.art === 'fremd') return ignoriert.indexOf(ei.name) === -1;
337
361
  return ei.art === 'link-ohne-quelle';
338
- });
362
+ }).length;
363
+ }
364
+
365
+ // Warnung und Warnzahl (E75) sind dieselbe Regel, einmal geschrieben: die Warnung steht genau
366
+ // dann, wenn die Zählung etwas findet — sonst könnten Dreieck und Zahl auseinanderlaufen.
367
+ function zielBraucht(z) { return zielAusloeser(z) > 0; }
368
+
369
+ /**
370
+ * Warnzeichen für den Kartenkopf, mit der Zahl der Auslöser daneben (E75): eine Karte mit
371
+ * „0 Skills" und fünfzehn fremden Einträgen wirkte sonst leer, obwohl der Ordner voll ist. Die
372
+ * E71-Zahl (eigene Skills) bleibt daneben stehen, die Warnzahl ist eine eigene Information.
373
+ * Zeichen statt Text-Badge: trägt die Farbe selbst (Emoji-Darstellung), aria-label hält den
374
+ * Akkordeon-Knopf-Namen brauchbar (role="img", die Bedeutung steht nicht im Glyph selbst) und
375
+ * nennt die Zahl mit, sonst hörte sie im Screenreader gar nicht.
376
+ */
377
+ function warnzeichen(anzahl) {
378
+ var span = e('span', 'warnung');
379
+ span.setAttribute('role', 'img');
380
+ span.setAttribute(
381
+ 'aria-label',
382
+ anzahl === 1 ? '1 Eintrag braucht Aufmerksamkeit' : anzahl + ' Einträge brauchen Aufmerksamkeit',
383
+ );
384
+ span.appendChild(document.createTextNode('⚠️'));
385
+ span.appendChild(e('span', 'warnzahl', String(anzahl)));
386
+ return span;
339
387
  }
340
388
 
341
- // E45: ein Ziel eines inaktiven Harness verschwindet, sobald dort nichts liegt, weder eigene noch
342
- // fremde Einträge noch eine harness-eigene Registrierung. Ein aktiver Harness zeigt sein Ziel immer.
389
+ // E74 (revidiert E45): ein Ziel eines inaktiven Harness erscheint gar nicht, auch wenn dort noch
390
+ // Links, fremde Einträge oder eine harness-eigene Registrierung liegen — was hmgr nicht managed,
391
+ // zeigt es nicht. Beim Aktivieren taucht das Ziel samt allem, was liegen blieb, wieder auf.
343
392
  function zielSichtbar(z) {
344
- if (harnessAktiv(z.ziel.harness)) return true;
345
- return z.eintraege.length > 0 || z.istRegistriert;
393
+ return harnessAktiv(z.ziel.harness);
346
394
  }
347
395
 
348
396
  // Von Hand aufgeklappte Ziele bleiben über Neuaufbauten der Liste offen (E45), Schlüssel wie
@@ -386,6 +434,7 @@
386
434
  rufe(function () { return window.hmgr.plattform(); }),
387
435
  ]);
388
436
  HOME = werte[0];
437
+ PLATTFORM = werte[1];
389
438
  TRENNER = plattformTrenner(werte[1]);
390
439
  $('mcpkatalogpfad').textContent = pfadSchreibweise('~/.hmgr/mcp.yaml');
391
440
  }
@@ -404,6 +453,8 @@
404
453
  rufe(function () { return window.hmgr.nichtVorhandeneHarnesses(); }),
405
454
  rufe(function () { return window.hmgr.mcpUebersicht(); }),
406
455
  rufe(function () { return window.hmgr.mcpPlaetze(); }),
456
+ rufe(function () { return window.hmgr.bundles(); }),
457
+ rufe(function () { return window.hmgr.harnessHeime(); }),
407
458
  ]);
408
459
  ZIELE = ergebnisse[0];
409
460
  SKILLS = ergebnisse[1];
@@ -418,6 +469,10 @@
418
469
  MCP_FREMD = ergebnisse[7].unbekannt;
419
470
  MCP_FREMD_GETEILT = ergebnisse[7].unbekannteGeteilteFundorte;
420
471
  MCP_PLAETZE = ergebnisse[8];
472
+ BUNDLES = ergebnisse[9].bundles;
473
+ BUNDLES.sort(function (a, b) { return a.name.localeCompare(b.name, 'de') || a.quelleText.localeCompare(b.quelleText, 'de'); });
474
+ BUNDLES_KAPUTT = ergebnisse[9].kaputte;
475
+ HARNESS_HEIME = ergebnisse[10] || [];
421
476
  STANDARD_REF = {};
422
477
  SKILLS.forEach(function (s) { if (STANDARD_REF[s.quelleText] === undefined) STANDARD_REF[s.quelleText] = s.ref; });
423
478
 
@@ -484,8 +539,12 @@
484
539
  else a.removeAttribute('aria-current');
485
540
  });
486
541
  ANSICHTEN.forEach(function (x) { $('ansicht-' + x).hidden = x !== name; });
487
- // Der Bericht ist jetzt sichtbar, die Ankündigung „mit Neuem" hat ihren Dienst getan.
488
- if (name === 'update' && standZeit !== null) standRuhig();
542
+ if (name === 'update') {
543
+ // Die Ansicht ist jetzt offen, die Ankündigung „mit Neuem" hat ihren Dienst getan (E80).
544
+ if (standNeu !== null) { standNeu = null; standText(); }
545
+ // Der Verlauf zeigt immer den aktuellen Stand der Datei, nicht nur den des letzten Laufs hier.
546
+ void ladeVerlauf();
547
+ }
489
548
  window.scrollTo(0, 0);
490
549
  }
491
550
  document.querySelectorAll('[data-ansicht]').forEach(function (k) {
@@ -494,26 +553,6 @@
494
553
 
495
554
  /* ================= ZIELE ================= */
496
555
 
497
- /** Hinweiszeile, wenn ein inaktiver Harness noch Links oder eine eigene Registrierung an diesem
498
- Ziel hat (E38 R7): Säubern-Weg direkt in der Karte. Von linkTeil und alleTeil geteilt. */
499
- function ruheZeile(z, hatEigene) {
500
- var rz = e('div', 'ruhezeile');
501
- var rsvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
502
- rsvg.setAttribute('viewBox', '0 0 16 16');
503
- rsvg.setAttribute('stroke-width', '1.6');
504
- rsvg.setAttribute('aria-hidden', 'true');
505
- rsvg.innerHTML = '<circle cx="8" cy="8" r="6.5"/><path d="M8 4.5v4M8 10.5v.5"/>';
506
- rz.appendChild(rsvg);
507
- rz.appendChild(e('p', null, harnessName(z.ziel.harness) + ' ist inaktiv. '
508
- + (hatEigene ? 'hmgr pflegt diese Links nicht mehr.' : 'Ein harness-eigener Eintrag verweist noch auf dieses Ziel.')));
509
- var rk = e('button', 'knopf leise klein', 'Säubern');
510
- rk.type = 'button';
511
- rk.setAttribute('aria-label', harnessName(z.ziel.harness) + ' säubern');
512
- rk.addEventListener('click', function () { frageSaeubern(z.ziel.harness); });
513
- rz.appendChild(rk);
514
- return rz;
515
- }
516
-
517
556
  /** Eine Zeile im Fremdkasten für einen fremden Eintrag x an einem Ziel z. Mit mitZeichen=true
518
557
  steht davor das kleine Harness-Zeichen seines Fundorts (E53 Pkt. 5, „Alle"-Ansicht). Gibt
519
558
  neben dem li auch zurück, ob eine Quelle erkannt wurde (für den Erklärtext). */
@@ -652,18 +691,115 @@
652
691
  return kz;
653
692
  }
654
693
 
694
+ /** Defekte Links (ohne Quelle) zuerst, sie brauchen Aufmerksamkeit (E45), sonst alphabetisch. */
695
+ function sortiereEintraege(eintraege) {
696
+ return eintraege.slice().sort(function (a, b) {
697
+ var da = a.art === 'link-ohne-quelle', db = b.art === 'link-ohne-quelle';
698
+ if (da !== db) return da ? -1 : 1;
699
+ var na = a.art === 'link' ? a.link.name : a.name;
700
+ var nb = b.art === 'link' ? b.link.name : b.name;
701
+ return na.localeCompare(nb, 'de');
702
+ });
703
+ }
704
+
705
+ /** Eine Zeile der Link-Tabelle. `bundle` gesetzt heißt: die Zeile steht in einer Bundle-Gruppe
706
+ (E69), das Entfernen zerbricht dort das Bundle und der Dialog sagt es. */
707
+ function linkZeile(ei, z, bundle) {
708
+ var name = ei.art === 'link' ? ei.link.name : ei.name;
709
+ var li = e('li');
710
+
711
+ if (ei.art === 'link') li.appendChild(repoIcon(ei.link.checkout.quelle));
712
+ else li.appendChild(repoIcon({ art: 'lokal', pfad: ei.checkoutPfad }));
713
+
714
+ li.appendChild(e('span', 'name', name));
715
+
716
+ // Die Beschreibung aus der SKILL.md (E39): die Frage, wegen der die Zeile gelesen wird,
717
+ // deshalb die breiteste Spalte. Ein Link ohne bekannte Quelle kennt seinen Skill nicht.
718
+ li.appendChild(wasZelle(ei.art === 'link' ? ei.link.skill : null));
719
+
720
+ // Quelle: der Name, anklickbar, dahinter nur, was vom Normalfall abweicht — ein Ref, das
721
+ // vom Standard-Ref der Quelle abweicht, und der Grund „über Zeiger" (E57). „Manuell" (der
722
+ // Normalfall) entfällt ersatzlos, ein Plugin-Grund steht stattdessen in der Aktionsspalte.
723
+ var qz = e('span', 'quelle');
724
+ if (ei.art === 'link') {
725
+ var einschraenkung = z.einschraenkungen[name];
726
+ if (einschraenkung) qz.appendChild(document.createTextNode(einschraenkung + ' → '));
727
+ qz.appendChild(quelleLinkElement(ei.link.checkout.quelle));
728
+ var standardRef = STANDARD_REF[quelleKurz(ei.link.checkout.quelle)];
729
+ if (ei.link.checkout.ref && standardRef && ei.link.checkout.ref !== standardRef) {
730
+ qz.appendChild(e('span', 'ref', '@' + ei.link.checkout.ref));
731
+ }
732
+ } else {
733
+ qz.appendChild(e('span', 'mono', pfadKurz(ei.checkoutPfad)));
734
+ }
735
+ var grund = z.gruende[name];
736
+ if (grund && grund.art === 'zeiger') qz.appendChild(e('span', 'grundmarke', 'über Zeiger'));
737
+ li.appendChild(qz);
738
+
739
+ // Aus einem Plugin verlinkt: kein Entfernen-Knopf, das geht nur über das Plugin — der
740
+ // Grund erklärt hier zugleich, warum der Knopf fehlt.
741
+ if (grund && grund.art === 'plugin') {
742
+ li.appendChild(e('span', 'kein-weg', 'nur über Plugin „' + grund.plugin.split('@')[0] + '“'));
743
+ } else {
744
+ var weg = e('button', 'knopf leise klein', 'Entfernen');
745
+ weg.type = 'button';
746
+ weg.setAttribute('aria-label', 'Link auf ' + name + ' entfernen');
747
+ weg.addEventListener('click', function () { frageEntfernen(name, z.ziel, bundle ? bundle.name : null); });
748
+ li.appendChild(weg);
749
+ }
750
+ return li;
751
+ }
752
+
753
+ /** Ein installiertes Bundle als Gruppe über seinen Skill-Zeilen (E69, F68): Kopf mit Name, Quelle
754
+ und „Entfernen" für das Ganze, darunter die Zeilen seiner Mitglieder. */
755
+ function bundleGruppe(b, z, eintraege) {
756
+ var gruppe = e('div', 'bundlegruppe');
757
+ var kopf = e('div', 'bundlekopf');
758
+ var titel = e('span', 'bundletitel');
759
+ titel.appendChild(e('span', 'etikett', 'Bundle'));
760
+ titel.appendChild(e('b', 'bundlename', b.name));
761
+ kopf.appendChild(titel);
762
+ var quelleEl = e('span', 'mono bundlequelle', b.quelleName);
763
+ quelleEl.title = b.quelleText;
764
+ kopf.appendChild(quelleEl);
765
+ kopf.appendChild(e('span', 'bundlezahl', zahlwort(b.skills.length, 'Skill', 'Skills')));
766
+ var weg = e('button', 'knopf leise klein', 'Entfernen');
767
+ weg.type = 'button';
768
+ weg.setAttribute('aria-label', 'Bundle ' + b.name + ' aus ' + zielTitel(z) + ' entfernen');
769
+ weg.addEventListener('click', function () { frageBundleEntfernen(b, z.ziel); });
770
+ kopf.appendChild(weg);
771
+ gruppe.appendChild(kopf);
772
+ var ul = e('ul', 'linkliste');
773
+ eintraege.forEach(function (ei) { ul.appendChild(linkZeile(ei, z, b)); });
774
+ gruppe.appendChild(ul);
775
+ return gruppe;
776
+ }
777
+
778
+ /** Ein Bundle als einzelner Streifen (Chip „Alle" einer Verzeichnis-Karte, E69/E53): Harness-
779
+ Zeichen, Name, seine Mitglieder als Text, Entfernen. Ohne eigene Tabelle. */
780
+ function bundleStreifen(b, z) {
781
+ var streifen = e('div', 'bundlegruppe');
782
+ var kopf = e('div', 'bundlekopf');
783
+ var titel = e('span', 'bundletitel');
784
+ titel.appendChild(harnessZeichen(z.ziel.harness));
785
+ titel.appendChild(e('span', 'etikett', 'Bundle'));
786
+ titel.appendChild(e('b', 'bundlename', b.name));
787
+ kopf.appendChild(titel);
788
+ kopf.appendChild(e('span', 'bundlemitglieder', b.skills.join(', ')));
789
+ var weg = e('button', 'knopf leise klein', 'Entfernen');
790
+ weg.type = 'button';
791
+ weg.setAttribute('aria-label', 'Bundle ' + b.name + ' aus ' + zielTitel(z) + ' entfernen');
792
+ weg.addEventListener('click', function () { frageBundleEntfernen(b, z.ziel); });
793
+ kopf.appendChild(weg);
794
+ streifen.appendChild(kopf);
795
+ return streifen;
796
+ }
797
+
655
798
  function linkTeil(z) {
656
799
  var teil = document.createDocumentFragment();
657
800
  var eigene = z.eintraege.filter(function (ei) { return ei.art !== 'fremd'; });
658
801
  var fremde = z.eintraege.filter(function (ei) { return ei.art === 'fremd'; });
659
802
 
660
- // Harness abgeschaltet, Konfiguration liegt noch (E38, GRILL.md Entwurfskritik 4): der Weg,
661
- // sie loszuwerden, steht hier oben in der Karte. Zählt auch ohne Links, wenn die harness-eigene
662
- // Registrierung noch da ist (E38 R7), etwa Hermes' external_dirs-Eintrag ohne Links darunter.
663
- if (!harnessAktiv(z.ziel.harness) && (eigene.length > 0 || z.istRegistriert)) {
664
- teil.appendChild(ruheZeile(z, eigene.length > 0));
665
- }
666
-
667
803
  var kz = konfigZeile(z);
668
804
  if (kz) teil.appendChild(kz);
669
805
 
@@ -697,63 +833,26 @@
697
833
  kopf.appendChild(e('span'));
698
834
  teil.appendChild(kopf);
699
835
 
700
- var ul = e('ul', 'linkliste');
701
- // Defekte Links (ohne Quelle) zuerst, sie brauchen Aufmerksamkeit (E45), sonst alphabetisch.
702
- eigene
836
+ // Bundles zuerst (E69): sie stehen als Gruppe über den Einzel-Zeilen, ihre Mitglieder liegen
837
+ // in der Gruppe statt in der allgemeinen Liste. Was zum Bundle gehört, sagt der Kern.
838
+ var bundles = (z.bundles || []).filter(function (b) { return b.skills.length > 0; });
839
+ var bundleVon = {};
840
+ bundles.forEach(function (b) { b.skills.forEach(function (n) { bundleVon[n] = b; }); });
841
+ bundles
703
842
  .slice()
704
- .sort(function (a, b) {
705
- var da = a.art === 'link-ohne-quelle', db = b.art === 'link-ohne-quelle';
706
- if (da !== db) return da ? -1 : 1;
707
- var na = a.art === 'link' ? a.link.name : a.name;
708
- var nb = b.art === 'link' ? b.link.name : b.name;
709
- return na.localeCompare(nb, 'de');
710
- })
711
- .forEach(function (ei) {
712
- var name = ei.art === 'link' ? ei.link.name : ei.name;
713
- var li = e('li');
714
-
715
- if (ei.art === 'link') li.appendChild(repoIcon(ei.link.checkout.quelle));
716
- else li.appendChild(repoIcon({ art: 'lokal', pfad: ei.checkoutPfad }));
717
-
718
- li.appendChild(e('span', 'name', name));
719
-
720
- // Die Beschreibung aus der SKILL.md (E39): die Frage, wegen der die Zeile gelesen wird,
721
- // deshalb die breiteste Spalte. Ein Link ohne bekannte Quelle kennt seinen Skill nicht.
722
- li.appendChild(wasZelle(ei.art === 'link' ? ei.link.skill : null));
723
-
724
- // Quelle: der Name, anklickbar, dahinter nur, was vom Normalfall abweicht — ein Ref, das
725
- // vom Standard-Ref der Quelle abweicht, und der Grund „über Zeiger" (E57). „Manuell" (der
726
- // Normalfall) entfällt ersatzlos, ein Plugin-Grund steht stattdessen in der Aktionsspalte.
727
- var qz = e('span', 'quelle');
728
- if (ei.art === 'link') {
729
- var einschraenkung = z.einschraenkungen[name];
730
- if (einschraenkung) qz.appendChild(document.createTextNode(einschraenkung + ' → '));
731
- qz.appendChild(quelleLinkElement(ei.link.checkout.quelle));
732
- var standardRef = STANDARD_REF[quelleKurz(ei.link.checkout.quelle)];
733
- if (ei.link.checkout.ref && standardRef && ei.link.checkout.ref !== standardRef) {
734
- qz.appendChild(e('span', 'ref', '@' + ei.link.checkout.ref));
735
- }
736
- } else {
737
- qz.appendChild(e('span', 'mono', pfadKurz(ei.checkoutPfad)));
738
- }
739
- var grund = z.gruende[name];
740
- if (grund && grund.art === 'zeiger') qz.appendChild(e('span', 'grundmarke', 'über Zeiger'));
741
- li.appendChild(qz);
742
-
743
- // Aus einem Plugin verlinkt: kein Entfernen-Knopf, das geht nur über das Plugin — der
744
- // Grund erklärt hier zugleich, warum der Knopf fehlt.
745
- if (grund && grund.art === 'plugin') {
746
- li.appendChild(e('span', 'kein-weg', 'nur über Plugin „' + grund.plugin.split('@')[0] + '“'));
747
- } else {
748
- var weg = e('button', 'knopf leise klein', 'Entfernen');
749
- weg.type = 'button';
750
- weg.setAttribute('aria-label', 'Link auf ' + name + ' entfernen');
751
- weg.addEventListener('click', function () { frageEntfernen(name, z.ziel); });
752
- li.appendChild(weg);
753
- }
754
- ul.appendChild(li);
843
+ .sort(function (a, b) { return a.name.localeCompare(b.name, 'de'); })
844
+ .forEach(function (b) {
845
+ var mitglieder = eigene.filter(function (ei) {
846
+ return b.skills.indexOf(ei.art === 'link' ? ei.link.name : ei.name) > -1;
847
+ });
848
+ teil.appendChild(bundleGruppe(b, z, sortiereEintraege(mitglieder)));
755
849
  });
756
- teil.appendChild(ul);
850
+
851
+ var ul = e('ul', 'linkliste');
852
+ sortiereEintraege(
853
+ eigene.filter(function (ei) { return !bundleVon[ei.art === 'link' ? ei.link.name : ei.name]; }),
854
+ ).forEach(function (ei) { ul.appendChild(linkZeile(ei, z, null)); });
855
+ if (ul.childNodes.length > 0) teil.appendChild(ul);
757
856
 
758
857
  // MCP-Abschnitt (E61-GUI-Nachtrag): unter der Skill-Link-Tabelle, vor dem Kartenfuß mit den
759
858
  // Knöpfen, Gegenrichtung zur MCP-Ansicht.
@@ -894,6 +993,44 @@
894
993
  return knopf;
895
994
  }
896
995
 
996
+ /** Eigene Links eines Verzeichnisses über alle Harnesses gebündelt: Schlüssel Name+Quelle,
997
+ hängt ein Name je Harness aus verschiedener Quelle, gibt es je Quelle eine eigene Zeile
998
+ (E53 Pkt. 2). Einzige Zählregel für Skills: der Körper zeilt daraus, die Kartenköpfe zählen
999
+ dieselbe Struktur (E71). Ein einzelnes Ziel kommt als einelementige Liste.
1000
+ Fremde Einträge bleiben draußen, defekte Links (ohne Quelle) zählen mit. */
1001
+ function buendleEigene(teile) {
1002
+ var zeilen = {};
1003
+ var reihenfolge = [];
1004
+ teile.forEach(function (z) {
1005
+ z.eintraege.filter(function (ei) { return ei.art !== 'fremd'; }).forEach(function (ei) {
1006
+ var defekt = ei.art === 'link-ohne-quelle';
1007
+ var name = defekt ? ei.name : ei.link.name;
1008
+ var quelleSchluessel = defekt ? ('defekt\u0000' + ei.checkoutPfad)
1009
+ : (quelleKurz(ei.link.checkout.quelle) + '@' + ei.link.checkout.ref);
1010
+ // NUL als Trenner, kein Leerzeichen: Skill-Namen dürfen Leerzeichen enthalten, Quellen-Namen
1011
+ // seit E59 auch, ein lokaler Pfad ohnehin — „a b“ + „x“ und „a“ + „b x“ ergäben sonst
1012
+ // denselben Schlüssel. Immer als Escape geschrieben, nie als rohes Byte im Quelltext.
1013
+ var key = name + '\u0000' + quelleSchluessel;
1014
+ if (!zeilen[key]) {
1015
+ zeilen[key] = {
1016
+ name: name,
1017
+ defekt: defekt,
1018
+ icon: defekt ? { art: 'lokal', pfad: ei.checkoutPfad } : ei.link.checkout.quelle,
1019
+ checkoutPfad: defekt ? ei.checkoutPfad : null,
1020
+ quelle: defekt ? null : ei.link.checkout.quelle,
1021
+ ref: defekt ? null : ei.link.checkout.ref,
1022
+ einschraenkung: defekt ? null : z.einschraenkungen[name],
1023
+ skill: defekt ? null : ei.link.skill,
1024
+ teile: [],
1025
+ };
1026
+ reihenfolge.push(key);
1027
+ }
1028
+ zeilen[key].teile.push(z);
1029
+ });
1030
+ });
1031
+ return { zeilen: zeilen, reihenfolge: reihenfolge };
1032
+ }
1033
+
897
1034
  function zielKarte(z) {
898
1035
  var karte = e('div', 'ziel');
899
1036
  var kopf = e('div', 'zielkopf');
@@ -908,17 +1045,12 @@
908
1045
  kopf.setAttribute('aria-expanded', offen ? 'true' : 'false');
909
1046
  kopf.appendChild(beschriftet(e('h3', null, zielTitel(z)), z.ziel.harness));
910
1047
  kopf.appendChild(e('span', 'mono', pfadKurz(z.verzeichnis)));
911
- if (!harnessAktiv(z.ziel.harness)) kopf.appendChild(e('span', 'ruhend', 'Harness inaktiv'));
912
- if (zielBraucht(z)) {
913
- // Zeichen statt Text-Badge: trägt die Farbe selbst (Emoji-Darstellung), aria-label hält den
914
- // Akkordeon-Knopf-Namen brauchbar (role="img", die Bedeutung steht nicht im Glyph selbst).
915
- var warnspan = e('span', 'warnung', '⚠️');
916
- warnspan.setAttribute('role', 'img');
917
- warnspan.setAttribute('aria-label', 'braucht Aufmerksamkeit');
918
- kopf.appendChild(warnspan);
919
- }
920
- var zahl = z.eintraege.filter(function (ei) { return ei.art !== 'fremd'; }).length;
921
- kopf.appendChild(e('span', 'zahl', zahl + (zahl === 1 ? ' Link' : ' Links')));
1048
+ var ausloeser = zielAusloeser(z);
1049
+ if (ausloeser > 0) kopf.appendChild(warnzeichen(ausloeser));
1050
+ // Der Kopf zählt Skills, nicht Einträge (E71), über dieselbe Bündelung, aus der der Körper zeilt.
1051
+ // Bei einem Harness fällt die Zahl mit der Zahl eigener Einträge zusammen, die Regel bleibt eine.
1052
+ var zahl = buendleEigene([z]).reihenfolge.length;
1053
+ kopf.appendChild(e('span', 'zahl', zahl + (zahl === 1 ? ' Skill' : ' Skills')));
922
1054
  var pfeil = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
923
1055
  pfeil.setAttribute('viewBox', '0 0 16 16');
924
1056
  pfeil.setAttribute('class', 'akkordeonpfeil');
@@ -958,17 +1090,10 @@
958
1090
 
959
1091
  /** Körper des Chips „Alle" einer Verzeichnis-Karte (E53 Pkt. 2, 4, 5): eine gemeinsame Liste
960
1092
  statt gestapelter Harness-Abschnitte. Eigene Links gebündelt (Schlüssel Name+Quelle), fremde
961
- Einträge und die Inaktiv-Zeile je Harness mit dessen Zeichen am Fundort. */
1093
+ Einträge je Harness mit dessen Zeichen am Fundort. */
962
1094
  function alleTeil(pfad, teile) {
963
1095
  var teil = document.createDocumentFragment();
964
1096
 
965
- teile.forEach(function (z) {
966
- var eigeneZ = z.eintraege.filter(function (ei) { return ei.art !== 'fremd'; });
967
- if (!harnessAktiv(z.ziel.harness) && (eigeneZ.length > 0 || z.istRegistriert)) {
968
- teil.appendChild(ruheZeile(z, eigeneZ.length > 0));
969
- }
970
- });
971
-
972
1097
  var beachteteAlle = [], ignorierteAlle = [];
973
1098
  teile.forEach(function (z) {
974
1099
  var ignoriertNamen = z.ignoriert || [];
@@ -981,37 +1106,9 @@
981
1106
  if (beachteteAlle.length > 0) teil.appendChild(fremdKasten(beachteteAlle, true));
982
1107
  if (ignorierteAlle.length > 0) teil.appendChild(ignoriertZeile(ignorierteAlle, true));
983
1108
 
984
- // Eigene Links über alle Harnesses gebündelt: Schlüssel Name+Quelle, hängt ein Name je Harness
985
- // aus verschiedener Quelle, gibt es je Quelle eine eigene Zeile (E53 Pkt. 2).
986
- var zeilen = {};
987
- var reihenfolge = [];
988
- teile.forEach(function (z) {
989
- z.eintraege.filter(function (ei) { return ei.art !== 'fremd'; }).forEach(function (ei) {
990
- var defekt = ei.art === 'link-ohne-quelle';
991
- var name = defekt ? ei.name : ei.link.name;
992
- var quelleSchluessel = defekt ? ('defekt\u0000' + ei.checkoutPfad)
993
- : (quelleKurz(ei.link.checkout.quelle) + '@' + ei.link.checkout.ref);
994
- // NUL als Trenner, kein Leerzeichen: Skill-Namen dürfen Leerzeichen enthalten, Quellen-Namen
995
- // seit E59 auch, ein lokaler Pfad ohnehin — „a b“ + „x“ und „a“ + „b x“ ergäben sonst
996
- // denselben Schlüssel. Immer als Escape geschrieben, nie als rohes Byte im Quelltext.
997
- var key = name + '\u0000' + quelleSchluessel;
998
- if (!zeilen[key]) {
999
- zeilen[key] = {
1000
- name: name,
1001
- defekt: defekt,
1002
- icon: defekt ? { art: 'lokal', pfad: ei.checkoutPfad } : ei.link.checkout.quelle,
1003
- checkoutPfad: defekt ? ei.checkoutPfad : null,
1004
- quelle: defekt ? null : ei.link.checkout.quelle,
1005
- ref: defekt ? null : ei.link.checkout.ref,
1006
- einschraenkung: defekt ? null : z.einschraenkungen[name],
1007
- skill: defekt ? null : ei.link.skill,
1008
- teile: [],
1009
- };
1010
- reihenfolge.push(key);
1011
- }
1012
- zeilen[key].teile.push(z);
1013
- });
1014
- });
1109
+ var gebuendelt = buendleEigene(teile);
1110
+ var zeilen = gebuendelt.zeilen;
1111
+ var reihenfolge = gebuendelt.reihenfolge;
1015
1112
 
1016
1113
  // MCPs hängen am Verzeichnis, nicht am Harness-Chip (E61-GUI-Nachtrag): der Abschnitt unter
1017
1114
  // „Alle" zeigt jedes Ziel, das an genau diesem Pfad MCP kann — dieselbe Fähigkeitsfrage wie unter
@@ -1038,6 +1135,18 @@
1038
1135
  return teil;
1039
1136
  }
1040
1137
 
1138
+ // Bundles unter „Alle" (E69): die Zeilen bleiben nach Name und Quelle gebündelt (E53), ein
1139
+ // Bundle gehört aber zu genau einem Harness — deshalb hier ein Streifen je Bundle mit seinem
1140
+ // Harness-Zeichen statt einer Umgruppierung der Tabelle. Unter dem Harness-Chip zeigt linkTeil
1141
+ // die volle Gruppe über den Zeilen.
1142
+ teile.forEach(function (z) {
1143
+ (z.bundles || [])
1144
+ .filter(function (b) { return b.skills.length > 0; })
1145
+ .slice()
1146
+ .sort(function (a, b) { return a.name.localeCompare(b.name, 'de'); })
1147
+ .forEach(function (b) { teil.appendChild(bundleStreifen(b, z)); });
1148
+ });
1149
+
1041
1150
  var kopf = e('div', 'linkkopf');
1042
1151
  kopf.appendChild(e('span'));
1043
1152
  kopf.appendChild(e('span', 'etikett', 'Skill'));
@@ -1167,16 +1276,14 @@
1167
1276
  .forEach(function (z) { symbole.appendChild(harnessZeichen(z.ziel.harness)); });
1168
1277
  kopf.appendChild(symbole);
1169
1278
 
1170
- if (teile.some(zielBraucht)) {
1171
- var warnspan = e('span', 'warnung', '⚠️');
1172
- warnspan.setAttribute('role', 'img');
1173
- warnspan.setAttribute('aria-label', 'braucht Aufmerksamkeit');
1174
- kopf.appendChild(warnspan);
1175
- }
1176
- var gesamtZahl = teile.reduce(function (n, z) {
1177
- return n + z.eintraege.filter(function (ei) { return ei.art !== 'fremd'; }).length;
1178
- }, 0);
1179
- kopf.appendChild(e('span', 'zahl', gesamtZahl + (gesamtZahl === 1 ? ' Link' : ' Links')));
1279
+ // An der Verzeichnis-Karte ist die Warnzahl die Summe über die Teile (E75): der Nutzer sieht
1280
+ // hier einen Ordner, nicht die Harness-Abschnitte darunter.
1281
+ var ausloeserGesamt = teile.reduce(function (summe, z) { return summe + zielAusloeser(z); }, 0);
1282
+ if (ausloeserGesamt > 0) kopf.appendChild(warnzeichen(ausloeserGesamt));
1283
+ // Skills statt Einträge (E71): eine Summe über die Harness-Abschnitte zählte jeden Skill so oft,
1284
+ // wie ihn Harnesses am selben Verzeichnis führen — der Körper bündelt sie zu einer Zeile.
1285
+ var gesamtZahl = buendleEigene(teile).reihenfolge.length;
1286
+ kopf.appendChild(e('span', 'zahl', gesamtZahl + (gesamtZahl === 1 ? ' Skill' : ' Skills')));
1180
1287
  var pfeil = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
1181
1288
  pfeil.setAttribute('viewBox', '0 0 16 16');
1182
1289
  pfeil.setAttribute('class', 'akkordeonpfeil');
@@ -1223,7 +1330,8 @@
1223
1330
  var gewaehlt = teile.filter(function (z) { return z.ziel.harness === aktivChip; })[0];
1224
1331
  // Welcher Ordner im Verzeichnis gelesen wird, stand vorher im Abschnittskopf (25cddf6) und
1225
1332
  // gehört zum gewählten Harness, nicht zur Karte: er steht rechts in der Chip-Leiste, relativ
1226
- // zum Verzeichnis, mit der Inaktiv-Marke daneben.
1333
+ // zum Verzeichnis. Chips gibt es nur für aktive Harnesses (E74), eine Inaktiv-Marke braucht
1334
+ // die Leiste darum nicht mehr.
1227
1335
  if (gewaehlt) {
1228
1336
  // Nur die Anzeige: liegt der gelesene Ordner unterhalb des Verzeichnisses, steht er
1229
1337
  // relativ, sonst gekürzt. Beide Trenner, sonst greift das unter Windows nie (E68).
@@ -1231,7 +1339,6 @@
1231
1339
  || gewaehlt.verzeichnis.indexOf(pfad + '\\') === 0;
1232
1340
  chipleiste.appendChild(e('span', 'kartenordner mono',
1233
1341
  unterhalb ? gewaehlt.verzeichnis.slice(pfad.length + 1) : pfadKurz(gewaehlt.verzeichnis)));
1234
- if (!harnessAktiv(aktivChip)) chipleiste.appendChild(e('span', 'ruhend', 'Harness inaktiv'));
1235
1342
  }
1236
1343
  koerper.appendChild(chipleiste);
1237
1344
  if (gewaehlt) koerper.appendChild(linkTeil(gewaehlt));
@@ -1326,10 +1433,24 @@
1326
1433
  // entfernenKontext.eintraege: [{harness, ort}], immer eines bei frageEntfernen (Einzel-Ziel),
1327
1434
  // mehrere bei frageEntfernenAlle (E53 Pkt. 2, „Alle"-Zeile über mehrere Harnesses).
1328
1435
  var entfernenKontext = null;
1329
- function frageEntfernen(name, ziel) {
1330
- entfernenKontext = { name: name, eintraege: [{ harness: ziel.harness, ort: ziel.ort }] };
1436
+ /** `bundleName` gesetzt heißt: der Link gehört zu einem Bundle, das Entfernen zerbricht es (E69). */
1437
+ function frageEntfernen(name, ziel, bundleName) {
1438
+ entfernenKontext = { art: 'link', name: name, eintraege: [{ harness: ziel.harness, ort: ziel.ort }] };
1331
1439
  $('dx-titel').textContent = 'Link auf ' + name + ' entfernen?';
1332
- $('dx-text').textContent = 'Der Link in ' + zielTitel({ ziel: ziel }) + ' verschwindet. Der Checkout bleibt liegen, bis kein Link mehr darauf zeigt.';
1440
+ var text = 'Der Link in ' + zielTitel({ ziel: ziel }) + ' verschwindet. Der Checkout bleibt liegen, bis kein Link mehr darauf zeigt.';
1441
+ if (bundleName) {
1442
+ text += ' Damit zerbricht das Bundle „' + bundleName + '“ hier: seine übrigen Links bleiben liegen, aber nicht mehr als Bundle.';
1443
+ }
1444
+ $('dx-text').textContent = text;
1445
+ zeige($('d-entfernen'));
1446
+ }
1447
+
1448
+ /** Entfernen des ganzen Bundles an einem Ziel (E69): alle seine Links auf einmal. */
1449
+ function frageBundleEntfernen(b, ziel) {
1450
+ entfernenKontext = { art: 'bundle', bundle: b.name, ziel: ziel };
1451
+ $('dx-titel').textContent = 'Bundle ' + b.name + ' entfernen?';
1452
+ $('dx-text').textContent = zahlwort(b.skills.length, 'Link', 'Links') + ' aus ' + zielTitel({ ziel: ziel })
1453
+ + ' verschwinden: ' + b.skills.join(', ') + '. Die Checkouts bleiben liegen, bis kein Link mehr darauf zeigt.';
1333
1454
  zeige($('d-entfernen'));
1334
1455
  }
1335
1456
  /** Entfernen einer „Alle"-Zeile (E53 Pkt. 2): entfernbareZiele sind die Ziele (voller z.ziel), in
@@ -1337,6 +1458,7 @@
1337
1458
  und liegen bleibt. Der Dialog nennt beides. */
1338
1459
  function frageEntfernenAlle(name, pfad, entfernbareZiele, nichtEntfernbareHarnesse) {
1339
1460
  entfernenKontext = {
1461
+ art: 'link',
1340
1462
  name: name,
1341
1463
  eintraege: entfernbareZiele.map(function (z) { return { harness: z.ziel.harness, ort: z.ziel.ort }; }),
1342
1464
  };
@@ -1353,6 +1475,19 @@
1353
1475
  nachDialog($('d-entfernen'), async function (wert) {
1354
1476
  if (wert !== 'ja' || entfernenKontext === null) return;
1355
1477
  var kontext = entfernenKontext;
1478
+ // Bundle als Ganzes (E69): ein Aufruf, der Kern nimmt alle Links des Bundles weg.
1479
+ if (kontext.art === 'bundle') {
1480
+ var wegB = await rufe(function () {
1481
+ return window.hmgr.bundleEntfernen({
1482
+ bundle: kontext.bundle,
1483
+ harness: kontext.ziel.harness,
1484
+ ort: kontext.ziel.ort,
1485
+ });
1486
+ });
1487
+ await ladeAlles();
1488
+ melde('Bundle ' + kontext.bundle + ' entfernt, ' + zahlwort(wegB.entfernt.length, 'Link', 'Links') + ' weniger.');
1489
+ return;
1490
+ }
1356
1491
  // Ein Fehlschlag in einem Harness hält die übrigen nicht auf: rufe hat ihn schon gemeldet, die
1357
1492
  // Schleife läuft weiter, und die Liste wird danach in jedem Fall neu geladen. Sonst stünde nach
1358
1493
  // einem Fehler die halb erledigte Arbeit unsichtbar in einer veralteten Liste.
@@ -1663,12 +1798,52 @@
1663
1798
  return zeilen;
1664
1799
  }
1665
1800
 
1801
+ /** ISO-Zeitpunkt als deutsches Datum (E70). Ungültiges oder fehlendes Datum ergibt null. */
1802
+ function datumDeutsch(iso) {
1803
+ if (!iso) return null;
1804
+ var d = new Date(iso);
1805
+ if (isNaN(d.getTime())) return null;
1806
+ return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
1807
+ }
1808
+
1809
+ /** Reihenfolge der Skills-Indizes für die Katalog-Ansicht (E79, Richtung E81). SKILLS selbst
1810
+ bleibt unverändert (Name, dann Quelle sortiert, siehe ladeAlles), damit li.dataset.index
1811
+ stabil bleibt. Bei 'name'/'ab' einfach umgekehrt. Bei 'datum' stehen je nach Richtung die
1812
+ jüngsten oder die ältesten zuerst, Skills ohne Datum in BEIDEN Richtungen ganz hinten, bei
1813
+ Gleichstand (Datum oder „ohne Datum") in beiden Richtungen wie bisher nach Name/Quelle (also
1814
+ die ursprüngliche Reihenfolge). */
1815
+ function skillReihenfolge() {
1816
+ var indizes = SKILLS.map(function (_, i) { return i; });
1817
+ if (skillSortierung === 'name') {
1818
+ if (skillRichtung === 'ab') indizes.reverse();
1819
+ return indizes;
1820
+ }
1821
+ indizes.sort(function (a, b) {
1822
+ // Numerisch vergleichen, nicht als String: geaendert ist ISO-8601 mit dem Zeitzonen-Offset
1823
+ // des Committers (git %cI), zwei verschiedene Offsets ordnet ein String-Vergleich falsch.
1824
+ // Nicht parsebar zählt wie „ohne Datum" (hinten, in beiden Richtungen).
1825
+ var ta = SKILLS[a].geaendert ? Date.parse(SKILLS[a].geaendert) : NaN;
1826
+ var tb = SKILLS[b].geaendert ? Date.parse(SKILLS[b].geaendert) : NaN;
1827
+ var oa = isNaN(ta);
1828
+ var ob = isNaN(tb);
1829
+ if (oa && ob) return a - b;
1830
+ if (oa) return 1;
1831
+ if (ob) return -1;
1832
+ if (ta === tb) return a - b;
1833
+ if (skillRichtung === 'auf') return ta < tb ? -1 : 1;
1834
+ return ta < tb ? 1 : -1;
1835
+ });
1836
+ return indizes;
1837
+ }
1838
+
1666
1839
  function baueSkills() {
1667
1840
  var liste = $('katalog');
1668
1841
  liste.textContent = '';
1669
- SKILLS.forEach(function (skill, i) {
1842
+ skillReihenfolge().forEach(function (i) {
1843
+ var skill = SKILLS[i];
1670
1844
  var li = e('li', 'zeile');
1671
1845
  li.dataset.index = i;
1846
+ if (skill.geaendert) li.dataset.geaendert = skill.geaendert;
1672
1847
  var idx = quellenIndex(skill.quelleText);
1673
1848
  li.style.borderLeftColor = 'var(--q' + (idx + 1) + ')';
1674
1849
 
@@ -1686,12 +1861,17 @@
1686
1861
  // title. Fehlt die Quelle in der Liste (Katalog und Quellen kämen auseinander), bleibt es
1687
1862
  // beim reinen Text — angeschrieben ist die Zeile so oder so.
1688
1863
  var herkunft = e('p', 'herkunft mono');
1864
+ // Das Änderungsdatum steht hinter dem Ref (E70, F70=O1): der jüngste Commit, der den
1865
+ // Skill-Ordner berührt. Nicht der Pull-Zeitpunkt — der steht als „Zuletzt geprüft" in der
1866
+ // Update-Ansicht und wäre für alle Skills einer Quelle derselbe.
1867
+ var geaendert = datumDeutsch(skill.geaendert);
1868
+ var nachRef = ' · Ref ' + skill.ref + (geaendert ? ' · geändert ' + geaendert : '');
1689
1869
  if (herkunftQuelle) {
1690
1870
  herkunft.appendChild(quelleLinkElement(herkunftQuelle));
1691
- herkunft.appendChild(e('b', null, ' · Ref ' + skill.ref));
1871
+ herkunft.appendChild(e('b', null, nachRef));
1692
1872
  } else {
1693
1873
  herkunft.title = skill.quelleText;
1694
- herkunft.appendChild(e('b', null, skill.quelleName + ' · Ref ' + skill.ref));
1874
+ herkunft.appendChild(e('b', null, skill.quelleName + nachRef));
1695
1875
  }
1696
1876
  links.appendChild(herkunft);
1697
1877
 
@@ -1762,6 +1942,32 @@
1762
1942
  }
1763
1943
  $('suchfeld').addEventListener('input', filtereSkills);
1764
1944
 
1945
+ /** Sortierknöpfe der Skills-Rubrik (E79, Richtung E81): statisches Markup, deshalb Listener nur
1946
+ einmal gebunden, nicht in baueSkills. Klick auf den bereits aktiven Chip kehrt nur die
1947
+ Richtung um; Klick auf den anderen Chip wechselt den Schlüssel und setzt dessen
1948
+ Standardrichtung. Nur der aktive Chip trägt den Pfeil (↑/↓) und data-richtung, das liest der
1949
+ Smoke. */
1950
+ function setzeSkillSortierung(wert) {
1951
+ if (wert === skillSortierung) {
1952
+ skillRichtung = skillRichtung === 'auf' ? 'ab' : 'auf';
1953
+ } else {
1954
+ skillSortierung = wert;
1955
+ skillRichtung = SKILL_RICHTUNG_STANDARD[wert];
1956
+ }
1957
+ var pfeil = skillRichtung === 'auf' ? '↑' : '↓';
1958
+ [['sortnach-name', 'name'], ['sortnach-datum', 'datum']].forEach(function (paar) {
1959
+ var chip = $(paar[0]);
1960
+ var aktiv = skillSortierung === paar[1];
1961
+ chip.setAttribute('aria-pressed', aktiv ? 'true' : 'false');
1962
+ chip.querySelector('.sortpfeil').textContent = aktiv ? pfeil : '';
1963
+ if (aktiv) chip.dataset.richtung = skillRichtung;
1964
+ else delete chip.dataset.richtung;
1965
+ });
1966
+ baueSkills();
1967
+ }
1968
+ $('sortnach-name').addEventListener('click', function () { setzeSkillSortierung('name'); });
1969
+ $('sortnach-datum').addEventListener('click', function () { setzeSkillSortierung('datum'); });
1970
+
1765
1971
  var laufendeSkill = null;
1766
1972
  function starteVerlinken(index) {
1767
1973
  laufendeSkill = index;
@@ -1769,7 +1975,14 @@
1769
1975
  $('dz-skill').textContent = skill.name;
1770
1976
  var box = $('dz-ziele');
1771
1977
  box.textContent = '';
1772
- ZIELE.forEach(function (z) {
1978
+ // E76: Ziele inaktiver Harnesses stehen gar nicht erst zur Wahl — der Kern lehnte den Link
1979
+ // ohnehin ab, und seit E74 zeigt keine Ansicht sie. Dieselbe Regel wie die Zielliste.
1980
+ var waehlbar = ZIELE.filter(zielSichtbar);
1981
+ if (waehlbar.length === 0) {
1982
+ box.appendChild(e('p', 'leerhinweis',
1983
+ 'Kein Ziel wählbar: alle Harnesses sind deaktiviert. Unter Einstellungen einen aktivieren.'));
1984
+ }
1985
+ waehlbar.forEach(function (z) {
1773
1986
  var schon = z.eintraege.some(function (ei) {
1774
1987
  var n = ei.art === 'link' ? ei.link.name : ei.name;
1775
1988
  return n === skill.name;
@@ -1840,7 +2053,8 @@
1840
2053
  Skill nur aus, wenn er in ALLEN beteiligten Harnesses hängt, und verlinkt je Harness, wo er fehlt. */
1841
2054
  var skillsKontext = null;
1842
2055
  function dskBilanzAktualisieren() {
1843
- var n = $('dsk-liste').querySelectorAll('input:checked').length;
2056
+ var n = $('dsk-liste').querySelectorAll('input:checked').length
2057
+ + $('dsk-bundles').querySelectorAll('input:checked').length;
1844
2058
  $('dsk-bilanz').textContent = zahlwort(n, 'ausgewählt', 'ausgewählt');
1845
2059
  }
1846
2060
  function filtereDskListe() {
@@ -1849,6 +2063,60 @@
1849
2063
  var skill = SKILLS[+label.dataset.index];
1850
2064
  label.hidden = !!suche && skill.name.toLowerCase().indexOf(suche) === -1;
1851
2065
  });
2066
+ // Die Suche gilt auch für Bundles (E69): sonst bliebe die Sektion bei jeder Eingabe voll stehen.
2067
+ document.querySelectorAll('#dsk-bundles .wahlzeile').forEach(function (label) {
2068
+ var bundle = BUNDLES[+label.dataset.index];
2069
+ label.hidden = !!suche && bundle.name.toLowerCase().indexOf(suche) === -1;
2070
+ });
2071
+ }
2072
+
2073
+ /**
2074
+ * Bundle-Sektion des Dialogs (E69, F68): je Bundle eine Wahlzeile mit Beschreibung, Zahl seiner
2075
+ * Mitglieder und Herkunft. `installiertIn(bundle)` sagt, in welchen der angesteuerten Ziele es
2076
+ * schon hängt — hängt es überall, ist die Zeile ausgegraut, wie bei einem Skill.
2077
+ */
2078
+ function fuelleBundleSektion(installiertIn, zieleZahl) {
2079
+ var box = $('dsk-bundles');
2080
+ box.textContent = '';
2081
+ BUNDLES.forEach(function (bundle, i) {
2082
+ var schon = installiertIn(bundle);
2083
+ var ueberall = schon >= zieleZahl;
2084
+ var label = e('label', 'wahlzeile quellfarbe');
2085
+ label.dataset.index = i;
2086
+ var idx = quellenIndex(bundle.quelleText);
2087
+ label.style.borderLeftColor = 'var(--q' + (idx + 1) + ')';
2088
+ var input = document.createElement('input');
2089
+ input.type = 'checkbox';
2090
+ input.value = String(i);
2091
+ input.disabled = ueberall;
2092
+ input.addEventListener('change', dskBilanzAktualisieren);
2093
+ label.appendChild(input);
2094
+ var txt = e('span');
2095
+ txt.appendChild(e('b', null, bundle.name));
2096
+ txt.appendChild(e('span', 'notiz', bundle.beschreibung));
2097
+ var herkunft = e('span', 'mono', zahlwort(bundle.mitglieder, 'Skill', 'Skills')
2098
+ + ' aus ' + zahlwort(bundle.quellen.length, 'Quelle', 'Quellen') + ' · ' + bundle.quelleName);
2099
+ herkunft.title = bundle.quellen.join('\n');
2100
+ txt.appendChild(herkunft);
2101
+ if (ueberall) txt.appendChild(e('span', 'notiz', zieleZahl > 1 ? 'Hängt schon überall.' : 'Hängt hier schon.'));
2102
+ else if (schon > 0) txt.appendChild(e('span', 'notiz', 'Hängt schon in ' + schon + ' von ' + zieleZahl + ' Harnesses.'));
2103
+ label.appendChild(txt);
2104
+ box.appendChild(label);
2105
+ });
2106
+ $('dsk-bundlebox').hidden = BUNDLES.length === 0 && BUNDLES_KAPUTT.length === 0;
2107
+ // Eine Definition, die sich nicht lesen ließ, wird genannt statt verschluckt (E69).
2108
+ var fehlerZeile = $('dsk-bundlefehler');
2109
+ fehlerZeile.hidden = BUNDLES_KAPUTT.length === 0;
2110
+ fehlerZeile.textContent = BUNDLES_KAPUTT.map(function (k) {
2111
+ return k.quelleName + ': ' + k.datei + ' — ' + k.fehler;
2112
+ }).join('\n');
2113
+ }
2114
+
2115
+ /** Wie oft ein Bundle in den angesteuerten Zielen schon installiert ist (Name plus Quelle, E69). */
2116
+ function bundleHaengtIn(teile, bundle) {
2117
+ return teile.filter(function (z) {
2118
+ return (z.bundles || []).some(function (b) { return b.name === bundle.name && b.quelleText === bundle.quelleText; });
2119
+ }).length;
1852
2120
  }
1853
2121
  $('dsk-suche').addEventListener('input', filtereDskListe);
1854
2122
 
@@ -1883,6 +2151,7 @@
1883
2151
  label.appendChild(txt);
1884
2152
  box.appendChild(label);
1885
2153
  });
2154
+ fuelleBundleSektion(function (bundle) { return bundleHaengtIn([z], bundle); }, 1);
1886
2155
  dskBilanzAktualisieren();
1887
2156
  zeige($('d-skills'));
1888
2157
  $('dsk-suche').focus();
@@ -1927,6 +2196,7 @@
1927
2196
  label.appendChild(txt);
1928
2197
  box.appendChild(label);
1929
2198
  });
2199
+ fuelleBundleSektion(function (bundle) { return bundleHaengtIn(teile, bundle); }, teile.length);
1930
2200
  dskBilanzAktualisieren();
1931
2201
  zeige($('d-skills'));
1932
2202
  $('dsk-suche').focus();
@@ -1934,7 +2204,8 @@
1934
2204
 
1935
2205
  $('d-skills').addEventListener('submit', function (ev) {
1936
2206
  if (ev.submitter && ev.submitter.value === 'los') {
1937
- var gewaehlt = Array.from($('dsk-liste').querySelectorAll('input:checked'));
2207
+ var gewaehlt = Array.from($('dsk-liste').querySelectorAll('input:checked'))
2208
+ .concat(Array.from($('dsk-bundles').querySelectorAll('input:checked')));
1938
2209
  if (gewaehlt.length === 0) ev.preventDefault();
1939
2210
  }
1940
2211
  });
@@ -1943,6 +2214,7 @@
1943
2214
  if (wert !== 'los' || skillsKontext === null) return;
1944
2215
  var kontext = skillsKontext;
1945
2216
  var indizes = Array.from($('dsk-liste').querySelectorAll('input:checked')).map(function (i) { return +i.value; });
2217
+ var bundleIndizes = Array.from($('dsk-bundles').querySelectorAll('input:checked')).map(function (i) { return +i.value; });
1946
2218
 
1947
2219
  if (kontext.modus === 'einzeln') {
1948
2220
  var z = kontext.z;
@@ -1952,6 +2224,26 @@
1952
2224
  // Der zweite gehört daher als Fehler in die Bilanz, sonst meldet der Dialog Links, die es nicht gibt.
1953
2225
  var vergeben = [];
1954
2226
  var hinweiseZiel = [];
2227
+ // Bundles zuerst (E69): sie wirken als Ganzes und bringen ihre Mitglieder mit, ein danach
2228
+ // einzeln gewählter Skill gleichen Namens würde den eben gelegten Link stumm ersetzen.
2229
+ for (var bi = 0; bi < bundleIndizes.length; bi++) {
2230
+ var bundle = BUNDLES[bundleIndizes[bi]];
2231
+ try {
2232
+ var erg = await rufe(function () {
2233
+ return window.hmgr.bundleInstallieren({
2234
+ bundle: bundle.name,
2235
+ harness: z.ziel.harness,
2236
+ ort: z.ziel.ort,
2237
+ quelle: bundle.quelleText,
2238
+ });
2239
+ });
2240
+ hinweiseZiel.push(erg.hinweis);
2241
+ erg.gelegt.concat(erg.uebernommen).forEach(function (n) { vergeben.push(n); });
2242
+ ergebnisZeilen.push({ ok: true, bundle: bundle, erg: erg });
2243
+ } catch (fehler) {
2244
+ ergebnisZeilen.push({ ok: false, bundle: bundle, fehler: fehler.message });
2245
+ }
2246
+ }
1955
2247
  for (var n = 0; n < indizes.length; n++) {
1956
2248
  var skill = SKILLS[indizes[n]];
1957
2249
  if (vergeben.indexOf(skill.name) > -1) {
@@ -1977,18 +2269,30 @@
1977
2269
  // Arbeitsverzeichnis ist das die gebündelte Verzeichnis-Karte, nicht das Ziel allein (E49).
1978
2270
  ZIEL_OFFEN[kartenKey(z.ziel)] = true;
1979
2271
  await ladeAlles();
1980
- var erfolge = ergebnisZeilen.filter(function (r) { return r.ok; }).length;
2272
+ // Ein Bundle zählt als eines, egal wie viele Links es mitbringt: die Zeile darunter nennt sie.
2273
+ var erfolge = ergebnisZeilen.filter(function (r) { return r.ok && !r.bundle; }).length;
2274
+ var erfolgeBundle = ergebnisZeilen.filter(function (r) { return r.ok && r.bundle; }).length;
1981
2275
  $('de-titel').textContent = 'Verlinkt';
1982
- $('de-text').textContent = zahlwort(erfolge, 'Skill hängt', 'Skills hängen') + ' jetzt in ' + zielTitel(z) + '.';
2276
+ $('de-text').textContent = (erfolgeBundle > 0 ? zahlwort(erfolgeBundle, 'Bundle', 'Bundles') + ' und ' : '')
2277
+ + zahlwort(erfolge, 'Skill hängt', 'Skills hängen') + ' jetzt in ' + zielTitel(z) + '.';
1983
2278
  setzeErfolgHinweise(hinweiseZiel);
1984
2279
  var ul = $('de-liste');
1985
2280
  ul.textContent = '';
1986
2281
  ergebnisZeilen.forEach(function (r) {
1987
2282
  var li = e('li');
1988
- li.appendChild(e('span', r.ok ? 'ok' : 'aus', r.ok ? 'Link' : 'Fehler'));
2283
+ li.appendChild(e('span', r.ok ? 'ok' : 'aus', r.ok ? (r.bundle ? 'Bundle' : 'Link') : 'Fehler'));
1989
2284
  var s = e('span');
1990
- s.appendChild(e('b', null, r.skill.name));
2285
+ s.appendChild(e('b', null, r.bundle ? r.bundle.name : r.skill.name));
1991
2286
  if (!r.ok) s.appendChild(e('span', 'mono', r.fehler));
2287
+ else if (r.bundle) {
2288
+ var teileText = r.erg.gelegt.concat(r.erg.uebernommen).join(', ');
2289
+ if (r.erg.neueQuellen.length > 0) {
2290
+ // Neue Quellen (F66) gehören genannt: hmgr hat sie beim Installieren selbst angelegt.
2291
+ teileText += ' · neue ' + zahlwort(r.erg.neueQuellen.length, 'Quelle', 'Quellen') + ': '
2292
+ + r.erg.neueQuellen.join(', ');
2293
+ }
2294
+ s.appendChild(e('span', 'mono', teileText));
2295
+ }
1992
2296
  li.appendChild(s);
1993
2297
  ul.appendChild(li);
1994
2298
  });
@@ -2007,6 +2311,32 @@
2007
2311
  // den eben gelegten Link sonst stumm.
2008
2312
  var vergebenJeHarness = {};
2009
2313
  var hinweiseAlle = [];
2314
+ // Bundles zuerst, je Harness, in dem sie noch nicht hängen (E69): ein Bundle gehört zu genau
2315
+ // einem Ziel, unter „Alle" also einmal je beteiligtem Harness.
2316
+ for (var bj = 0; bj < bundleIndizes.length; bj++) {
2317
+ var bundleAlle = BUNDLES[bundleIndizes[bj]];
2318
+ for (var bt = 0; bt < teile.length; bt++) {
2319
+ var zb = teile[bt];
2320
+ if (bundleHaengtIn([zb], bundleAlle) > 0) continue;
2321
+ try {
2322
+ var ergAlle = await rufe(function () {
2323
+ return window.hmgr.bundleInstallieren({
2324
+ bundle: bundleAlle.name,
2325
+ harness: zb.ziel.harness,
2326
+ ort: zb.ziel.ort,
2327
+ quelle: bundleAlle.quelleText,
2328
+ });
2329
+ });
2330
+ hinweiseAlle.push(ergAlle.hinweis);
2331
+ ergAlle.gelegt.concat(ergAlle.uebernommen).forEach(function (n) {
2332
+ vergebenJeHarness[zb.ziel.harness + '\u0000' + n] = true;
2333
+ });
2334
+ zeilenErgebnis.push({ ok: true, bundle: bundleAlle, harness: zb.ziel.harness, erg: ergAlle });
2335
+ } catch (fehler) {
2336
+ zeilenErgebnis.push({ ok: false, bundle: bundleAlle, harness: zb.ziel.harness, fehler: fehler.message });
2337
+ }
2338
+ }
2339
+ }
2010
2340
  for (var m = 0; m < indizes.length; m++) {
2011
2341
  var sk = SKILLS[indizes[m]];
2012
2342
  for (var t = 0; t < teile.length; t++) {
@@ -2061,81 +2391,140 @@
2061
2391
  /* ================= AKTUALISIEREN ================= */
2062
2392
 
2063
2393
  /**
2064
- * Ein Aktualisieren-Lauf, von Hand über den Knopf oder selbsttätig angestoßen (E44). Eine Sperre
2394
+ * Ein Aktualisieren-Lauf, von Hand über den Kringel oder selbsttätig angestoßen (Start, Intervall
2395
+ * — E44) — `ausloeser` sagt, welcher, und geht an den Kern für den Verlauf (E80). Eine Sperre
2065
2396
  * über den disabled-Zustand des Knopfs verhindert, dass ein Anstoß während eines laufenden Laufs
2066
- * etwas tut. Automatische Läufe rufen den Kern ohne Rückfrage, sie räumen also nichts weg.
2397
+ * etwas tut. Start und Intervall rufen den Kern ohne Rückfrage, sie räumen also nichts weg.
2067
2398
  */
2068
- /* Stand-Zeile der Seitenleiste (Entwurf): hervorgehoben „N Quellen mit Neuem" nach einem
2069
- automatischen Lauf mit Änderungen, bis die Update-Ansicht den Bericht zeigt; sonst ruhig
2070
- „Alles aktuell, HH:MM geprüft", oder bei Fehlschlägen (E51) „Prüfung fehlgeschlagen, HH:MM"
2071
- (alle Quellen-Abrufe gescheitert) bzw. „Geprüft mit Fehlern, HH:MM" (Mischung). Das „mit
2072
- Neuem"-Signal hat Vorrang: es kündigt sich sofort nach einem automatischen Lauf an, die
2073
- Fehlerkennzeichnung gilt erst wieder im ruhigen Zustand danach. */
2399
+ /* Stand-Zeile, gemeinsam für Seitenleiste (#standtext) und Update-Ansicht (#updatestand) (E80):
2400
+ vor dem ersten Lauf „Noch nicht geprüft", während eines Laufs „Prüft …", danach ruhig „Alles
2401
+ aktuell, HH:MM geprüft", oder bei Fehlschlägen (E51) „Prüfung fehlgeschlagen, HH:MM" (alle
2402
+ Quellen-Abrufe gescheitert) bzw. „Geprüft mit Fehlern, HH:MM" (Mischung). Hat ein Start- oder
2403
+ Intervall-Lauf Neues gebracht und die Ansicht war noch nicht offen, hervorgehoben „N Skills
2404
+ aktualisiert, HH:MM" (Fallback „N Quellen"/„N Bundles mit Neuem, HH:MM") — dieses Signal hat
2405
+ Vorrang, bis die Ansicht öffnet (zeigeAnsicht räumt `standNeu` dann ab) oder ein neuer Lauf es
2406
+ ersetzt. `.neu` am Standknopf bleibt die Hervorhebung, solange der Zustand gilt. */
2074
2407
  var standZeit = null;
2075
2408
  var standFehlerZustand = 'keine';
2076
- function standRuhig() {
2077
- var text = 'Alles aktuell, ' + standZeit + ' geprüft';
2078
- if (standZeit === null) text = 'Noch nicht aktualisiert';
2079
- else if (standFehlerZustand === 'alle') text = 'Prüfung fehlgeschlagen, ' + standZeit;
2080
- else if (standFehlerZustand === 'gemischt') text = 'Geprüft mit Fehlern, ' + standZeit;
2081
- $('standtext').textContent = text;
2082
- $('standknopf').classList.remove('neu');
2083
- $('standknopf').textContent = 'Prüfen';
2084
- }
2085
- function standNachLauf(ergebnis, automatisch, uhrzeit) {
2409
+ var standLaeuft = false;
2410
+ var standNeu = null; // { zahlwort: string, anhang: string } | null
2411
+
2412
+ function fuelleStand(el) {
2413
+ el.textContent = '';
2414
+ if (standLaeuft) { el.textContent = 'Prüft …'; return; }
2415
+ if (standNeu !== null) {
2416
+ el.appendChild(e('b', null, standNeu.zahlwort));
2417
+ el.appendChild(document.createTextNode(' ' + standNeu.anhang + ', ' + standZeit));
2418
+ return;
2419
+ }
2420
+ var text = 'Noch nicht geprüft';
2421
+ if (standZeit !== null) {
2422
+ text = standFehlerZustand === 'alle' ? 'Prüfung fehlgeschlagen, ' + standZeit
2423
+ : standFehlerZustand === 'gemischt' ? 'Geprüft mit Fehlern, ' + standZeit
2424
+ : 'Alles aktuell, ' + standZeit + ' geprüft';
2425
+ }
2426
+ el.textContent = text;
2427
+ }
2428
+ function standText() {
2429
+ fuelleStand($('standtext'));
2430
+ fuelleStand($('updatestand'));
2431
+ $('standknopf').classList.toggle('neu', standNeu !== null);
2432
+ }
2433
+
2434
+ function standNachLauf(ergebnis, ausloeser, uhrzeit) {
2086
2435
  standZeit = uhrzeit;
2087
2436
  var quellen = {};
2088
2437
  ergebnis.checkouts.forEach(function (c) {
2089
2438
  if (c.aktualisierung.ergebnis === 'aktualisiert') quellen[quelleKurz(c.checkout.quelle)] = true;
2090
2439
  });
2091
2440
  var neue = Object.keys(quellen).length;
2441
+ // Skill-Ebene (E70): Namen über alle Checkouts, doppelte zählen einmal. Damit sagt die
2442
+ // Stand-Zeile „5 Skills aktualisiert" statt nur „1 Quelle mit Neuem".
2443
+ var geaenderteSkills = {};
2444
+ ergebnis.checkouts.forEach(function (c) {
2445
+ (c.geaenderteSkills || []).forEach(function (name) { geaenderteSkills[name] = true; });
2446
+ });
2447
+ var skillZahl = Object.keys(geaenderteSkills).length;
2448
+ // Ein nachgezogenes Bundle ist Neues wie ein neuer Commit (E69, F64=O1): sonst bliebe die
2449
+ // Stand-Zeile still, obwohl sich am Ziel gerade Links geändert haben.
2450
+ var bundlesNeu = (ergebnis.bundles || []).filter(function (b) { return b.ergebnis === 'aktualisiert'; }).length;
2092
2451
  var fehlgeschlagen = ergebnis.checkouts.filter(function (c) { return c.aktualisierung.ergebnis === 'fehlgeschlagen'; }).length;
2093
2452
  var gesamt = ergebnis.checkouts.length;
2094
2453
  standFehlerZustand = fehlgeschlagen === 0 ? 'keine' : fehlgeschlagen === gesamt ? 'alle' : 'gemischt';
2095
- // Von Hand läuft es in der Update-Ansicht, der Bericht ist schon sichtbar: nichts anzukündigen.
2096
- if (neue === 0 || !automatisch || !$('ansicht-update').hidden) return standRuhig();
2097
- var p = $('standtext');
2098
- p.textContent = '';
2099
- p.appendChild(e('b', null, zahlwort(neue, 'Quelle', 'Quellen')));
2100
- p.appendChild(document.createTextNode(' mit Neuem'));
2101
- $('standknopf').classList.add('neu');
2102
- $('standknopf').textContent = 'Ansehen';
2454
+ // Von Hand (Klick) läuft es in der Update-Ansicht, die schon offen ist: nichts anzukündigen.
2455
+ var automatisch = ausloeser !== 'klick';
2456
+ if ((neue === 0 && bundlesNeu === 0) || !automatisch || !$('ansicht-update').hidden) {
2457
+ standNeu = null;
2458
+ } else if (skillZahl > 0) {
2459
+ // Ist die Skill-Zahl bekannt, sagt sie mehr als die Zahl der Quellen (E70). Sonst bleibt es
2460
+ // beim alten Signal: eine Quelle kann Neues haben, ohne dass ein Skill davon betroffen ist.
2461
+ standNeu = { zahlwort: zahlwort(skillZahl, 'Skill', 'Skills'), anhang: 'aktualisiert' };
2462
+ } else {
2463
+ standNeu = {
2464
+ zahlwort: neue > 0 ? zahlwort(neue, 'Quelle', 'Quellen') : zahlwort(bundlesNeu, 'Bundle', 'Bundles'),
2465
+ anhang: 'mit Neuem',
2466
+ };
2467
+ }
2468
+ standText();
2103
2469
  }
2104
2470
 
2105
- async function aktualisierenLauf(automatisch) {
2471
+ async function aktualisierenLauf(ausloeser) {
2106
2472
  var knopf = $('updatestart');
2107
2473
  if (knopf.disabled) return;
2108
2474
  knopf.disabled = true;
2109
- knopf.textContent = 'Läuft …';
2110
- $('standtext').textContent = 'Prüft …';
2475
+ knopf.classList.add('laeuft');
2476
+ standLaeuft = true;
2477
+ standText();
2478
+ var lauf = rufe(function () {
2479
+ return window.hmgr.aktualisieren({ rueckfragen: ausloeser !== 'klick', ausloeser: ausloeser });
2480
+ });
2481
+ // Jeder Lauf prüft auch hmgr selbst (E80): angestoßen, nicht abgewartet — ein Fund meldet sich
2482
+ // später von sich aus in der Seitenleiste (onSelbstUpdate).
2483
+ window.hmgr.selbstUpdatePruefen();
2111
2484
  try {
2112
- var ergebnis = await rufe(function () {
2113
- return automatisch ? window.hmgr.aktualisieren({ rueckfragen: false }) : window.hmgr.aktualisieren();
2114
- });
2115
- baueBericht(ergebnis);
2485
+ var ergebnis = await lauf;
2116
2486
  var uhrzeit = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
2117
- var ausloeser = automatisch ? 'automatisch' : 'von Hand';
2118
- $('updatestand').textContent = 'Zuletzt ' + uhrzeit + ', ' + ausloeser + ', ' + ergebnis.checkouts.length + ' Checkouts.';
2119
- standNachLauf(ergebnis, automatisch, uhrzeit);
2487
+ standNachLauf(ergebnis, ausloeser, uhrzeit);
2488
+ await ladeVerlauf();
2120
2489
  await ladeAlles();
2121
2490
  } finally {
2491
+ standLaeuft = false;
2122
2492
  knopf.disabled = false;
2123
- knopf.textContent = 'Jetzt aktualisieren';
2493
+ knopf.classList.remove('laeuft');
2124
2494
  // Scheiterte der Lauf, bliebe sonst „Prüft …" stehen.
2125
- if ($('standtext').textContent === 'Prüft …') standRuhig();
2495
+ if ($('standtext').textContent === 'Prüft …') standText();
2126
2496
  }
2127
2497
  }
2128
2498
 
2129
- $('updatestart').addEventListener('click', function () { aktualisierenLauf(false); });
2130
- // Der Standknopf sagt, was ein Klick tut: ruhig „Prüfen" (Ansicht öffnen und Lauf starten),
2131
- // mit Neuem „Ansehen" (nur den Bericht zeigen, der Lauf ist schon gelaufen). Die Klasse muss
2132
- // vor zeigeAnsicht gelesen werden, denn die räumt den neu-Zustand ab.
2133
- $('standknopf').addEventListener('click', function () {
2134
- var neu = this.classList.contains('neu');
2135
- zeigeAnsicht('update');
2136
- if (!neu) aktualisierenLauf(false);
2137
- });
2138
- window.hmgr.onAktualisierenAnstossen(function () { aktualisierenLauf(true); });
2499
+ /* Update-Hinweise über hmgr selbst (E77/E80), unten in der Seitenleiste über dem
2500
+ Einstellungen-Knopf. Zwei getrennte Zeilen, weil beide zugleich zutreffen können: ein
2501
+ geladenes GUI-Paket und eine neuere CLI auf npm sind zwei Pakete und zwei Kanäle (Gitea bzw.
2502
+ npm). Nur die GUI-Zeile trägt seit E80 einen Knopf (Neustart installiert das geladene Paket),
2503
+ die CLI installiert die GUI nicht selbst (F82). */
2504
+ function selbstUpdateZeigen(nachricht) {
2505
+ if (nachricht.art === 'gui') {
2506
+ $('selbstupdate-gui').textContent = 'hmgr ' + nachricht.version + ' bereit — wird beim Beenden installiert';
2507
+ $('selbstupdate-gui').hidden = false;
2508
+ $('selbstupdate-neustart').hidden = false;
2509
+ } else if (nachricht.art === 'cli') {
2510
+ var p = $('selbstupdate-cli');
2511
+ p.textContent = '';
2512
+ p.appendChild(document.createTextNode('hmgr-CLI ' + nachricht.installiert + ' — npm hat ' + nachricht.neu + ': '));
2513
+ p.appendChild(e('code', null, nachricht.befehl));
2514
+ p.hidden = false;
2515
+ } else return;
2516
+ $('selbstupdate').hidden = false;
2517
+ // Die Datei hat gerade ein hmgr-Ereignis bekommen (E80): der Verlauf zeigt den aktuellen Stand.
2518
+ void ladeVerlauf();
2519
+ }
2520
+ window.hmgr.onSelbstUpdate(selbstUpdateZeigen);
2521
+ $('selbstupdate-neustart').addEventListener('click', function () { window.hmgr.neuStarten(); });
2522
+
2523
+ $('updatestart').addEventListener('click', function () { aktualisierenLauf('klick'); });
2524
+ // Der Standknopf öffnet nur noch die Ansicht (E80): kein eigener Lauf mehr an diesem Klick, der
2525
+ // Kringel in der Ansicht startet ihn bei Bedarf.
2526
+ $('standknopf').addEventListener('click', function () { zeigeAnsicht('update'); });
2527
+ window.hmgr.onAktualisierenAnstossen(function (ausloeser) { aktualisierenLauf(ausloeser); });
2139
2528
 
2140
2529
  // Stilles Neuladen bei Änderungen außerhalb der GUI (E62, Fokus-Wechsel und Datei-Watcher): nur
2141
2530
  // ladeAlles, keine Meldung, kein Spinner-Zwang. Nachrutsch-sicher: höchstens ein Durchlauf läuft,
@@ -2166,58 +2555,76 @@
2166
2555
  }
2167
2556
  window.hmgr.onNeuLadenAnstossen(function () { void stillerReload(); });
2168
2557
 
2169
- function baueBericht(ergebnis) {
2170
- var box = $('bericht');
2171
- box.textContent = '';
2172
- box.hidden = false;
2173
-
2174
- var checkoutBlock = e('div', 'block');
2175
- checkoutBlock.appendChild(e('span', 'etikett', 'Checkouts'));
2176
- var cul = e('ul');
2177
- if (ergebnis.checkouts.length === 0) cul.appendChild(e('li', null, 'Keine Checkouts.'));
2178
- ergebnis.checkouts.forEach(function (c) {
2179
- var li = e('li');
2180
- var a = c.aktualisierung;
2181
- var art = a.ergebnis === 'aktualisiert' || a.ergebnis === 'unveraendert' ? 'ok' : 'aus';
2182
- var text = a.ergebnis === 'aktualisiert' ? 'aktualisiert'
2183
- : a.ergebnis === 'unveraendert' ? 'unverändert'
2184
- : a.ergebnis === 'fehlgeschlagen' ? 'fehlgeschlagen (' + a.fehler + ')'
2185
- : 'übersprungen (lokale Änderung)';
2186
- li.appendChild(e('span', 'marke-' + art, text));
2187
- li.appendChild(quelleSpan('pfad', c.checkout.quelle, ' @ ' + c.checkout.ref));
2188
- cul.appendChild(li);
2189
- });
2190
- checkoutBlock.appendChild(cul);
2191
- box.appendChild(checkoutBlock);
2192
-
2193
- if (ergebnis.pluginHarnesses.length > 0) {
2194
- var pluginBlock = e('div', 'block');
2195
- pluginBlock.appendChild(e('span', 'etikett', 'Plugins'));
2196
- var pul = e('ul');
2197
- ergebnis.pluginHarnesses.forEach(function (name) {
2198
- var li = e('li');
2199
- li.appendChild(e('span', 'marke-ok', 'aktualisiert'));
2200
- li.appendChild(e('span', 'pfad', name));
2201
- pul.appendChild(li);
2202
- });
2203
- pluginBlock.appendChild(pul);
2204
- box.appendChild(pluginBlock);
2558
+ /**
2559
+ * Eine Ereigniszeile im Verlauf (E80): Marke + Text, Muster wie die frühere baueBericht-Zeile
2560
+ * (Checkout/Bundle) und die CLI-Ausgabe (verlaufEreignisZeile, src/cli/main.ts).
2561
+ */
2562
+ function verlaufEreignisZeile(li, ereignis) {
2563
+ if (ereignis.art === 'checkout') {
2564
+ li.appendChild(e('span', 'marke-ok', 'aktualisiert'));
2565
+ var skills = ereignis.skills.length > 0
2566
+ ? ' · ' + zahlwort(ereignis.skills.length, 'Skill', 'Skills') + ': ' + ereignis.skills.join(', ')
2567
+ : '';
2568
+ var stand = ' (' + ereignis.von.slice(0, 7) + ' → ' + ereignis.nach.slice(0, 7) + ')';
2569
+ li.appendChild(e('span', 'pfad', ereignis.quelle + ' @ ' + ereignis.ref + skills + stand));
2570
+ } else if (ereignis.art === 'bundle') {
2571
+ li.appendChild(e('span', 'marke-ok', 'Bundle'));
2572
+ var teile = [];
2573
+ if (ereignis.gelegt.length > 0) teile.push('gelegt ' + ereignis.gelegt.join(', '));
2574
+ if (ereignis.entfernt.length > 0) teile.push('entfernt ' + ereignis.entfernt.join(', '));
2575
+ // Ziel als Objekt seit E80-Nachtrag; ein String-Ziel (ältere Einträge) bleibt unverändert.
2576
+ var zielText = typeof ereignis.ziel === 'string'
2577
+ ? ereignis.ziel
2578
+ : harnessName(ereignis.ziel.harness) + ' · ' + ortName(ereignis.ziel.ort);
2579
+ li.appendChild(e('span', 'pfad', ereignis.bundle + ' → ' + zielText
2580
+ + (teile.length > 0 ? ' · ' + teile.join(' · ') : '')));
2581
+ } else if (ereignis.art === 'aufgeraeumt') {
2582
+ li.appendChild(e('span', 'marke-ok', 'aufgeräumt'));
2583
+ li.appendChild(e('span', 'pfad', ereignis.checkout));
2584
+ } else if (ereignis.art === 'fehler') {
2585
+ li.appendChild(e('span', 'marke-aus', 'fehlgeschlagen'));
2586
+ li.appendChild(e('span', 'pfad', ereignis.betrifft + ': ' + ereignis.fehler));
2587
+ } else if (ereignis.art === 'hmgr-installiert') {
2588
+ li.appendChild(e('span', 'marke-ok', 'installiert'));
2589
+ li.appendChild(e('span', 'pfad', (ereignis.paket === 'gui' ? 'hmgr-GUI ' : 'hmgr ') + ereignis.version));
2590
+ } else if (ereignis.art === 'gui-update-geladen') {
2591
+ li.appendChild(e('span', 'marke-ok', 'geladen'));
2592
+ li.appendChild(e('span', 'pfad', 'GUI-Update ' + ereignis.version));
2593
+ } else if (ereignis.art === 'cli-verfuegbar') {
2594
+ li.appendChild(e('span', 'marke-ok', 'verfügbar'));
2595
+ li.appendChild(e('span', 'pfad', 'CLI ' + ereignis.version));
2205
2596
  }
2597
+ }
2206
2598
 
2207
- if (ergebnis.aufraeumen.verwaiste.length > 0) {
2208
- var raeumBlock = e('div', 'block entfernt');
2209
- raeumBlock.appendChild(e('span', 'etikett', 'Verwaiste Checkouts'));
2210
- var rul = e('ul');
2211
- ergebnis.aufraeumen.verwaiste.forEach(function (v) {
2599
+ /**
2600
+ * Baut das Changelog in der Update-Ansicht aus ~/.hmgr/verlauf.json (E80): ein Block je Lauf,
2601
+ * neueste zuerst, das Etikett der lokale Zeitpunkt, darunter eine Zeile je Ereignis. Die Datei
2602
+ * ist die Wahrheit, kein eigener Zustand im Renderer — jeder Aufruf holt sie neu.
2603
+ */
2604
+ async function ladeVerlauf() {
2605
+ var eintraege = await rufe(function () { return window.hmgr.verlauf(); });
2606
+ var box = $('verlauf');
2607
+ box.textContent = '';
2608
+ if (eintraege.length === 0) {
2609
+ box.appendChild(e('p', 'leerhinweis', 'Noch keine Ereignisse.'));
2610
+ return;
2611
+ }
2612
+ eintraege.forEach(function (eintrag) {
2613
+ var block = e('div', 'block');
2614
+ var zeit = new Date(eintrag.zeit);
2615
+ var zwei = function (n) { return String(n).padStart(2, '0'); };
2616
+ var etikett = zwei(zeit.getDate()) + '.' + zwei(zeit.getMonth() + 1) + '.' + zeit.getFullYear()
2617
+ + ' ' + zwei(zeit.getHours()) + ':' + zwei(zeit.getMinutes());
2618
+ block.appendChild(e('span', 'etikett', etikett));
2619
+ var ul = e('ul');
2620
+ eintrag.ereignisse.forEach(function (ereignis) {
2212
2621
  var li = e('li');
2213
- var text = v.geloescht ? 'gelöscht' : v.lokaleAenderung ? 'liegen gelassen (lokale Änderung)' : 'liegen gelassen';
2214
- li.appendChild(e('span', v.geloescht ? 'marke-aus' : 'marke-aus', text));
2215
- li.appendChild(quelleSpan('pfad', v.checkout.quelle, ' @ ' + v.checkout.ref));
2216
- rul.appendChild(li);
2622
+ verlaufEreignisZeile(li, ereignis);
2623
+ ul.appendChild(li);
2217
2624
  });
2218
- raeumBlock.appendChild(rul);
2219
- box.appendChild(raeumBlock);
2220
- }
2625
+ block.appendChild(ul);
2626
+ box.appendChild(block);
2627
+ });
2221
2628
  }
2222
2629
 
2223
2630
  /* ================= PLUGINS ================= */
@@ -2691,6 +3098,132 @@
2691
3098
  + '. Nicht von hmgr verwaltete Einträge liegen unverändert in den Verzeichnissen.';
2692
3099
  }
2693
3100
 
3101
+ /* ---------------- Homedirs eines Harness (E78) ---------------- */
3102
+
3103
+ /**
3104
+ * Die Homedir-Zeilen unter einem Harness-Schalter (E78): das primäre mit „Ändern"/„Zurücksetzen"
3105
+ * (F89), darunter jedes zusätzliche mit „Vergessen" (F87), zuletzt der Knopf zum Eintragen eines
3106
+ * weiteren. Der Knopf steht nur bei einem Harness, der die Fähigkeit hat, und nur unter Windows —
3107
+ * dieselbe Grenze wie im Kern, hier nur damit niemand einen Weg vor sich sieht, der abbricht.
3108
+ */
3109
+ function heimeFuerHarness(h) {
3110
+ var kasten = e('div', 'heime');
3111
+ var eintrag = null;
3112
+ HARNESS_HEIME.forEach(function (x) { if (x.harness === h) eintrag = x; });
3113
+ if (!eintrag) return kasten;
3114
+ eintrag.heime.forEach(function (heim) {
3115
+ var zeile = e('div', 'heimzeile');
3116
+ zeile.appendChild(e('span', 'heimart', heim.adresse ? heim.adresse : 'Homedir'));
3117
+ var pfad = e('code', 'heimpfad', pfadKurz(heim.pfad));
3118
+ pfad.title = heim.pfad;
3119
+ zeile.appendChild(pfad);
3120
+ var knoepfe = e('span', 'heimknoepfe');
3121
+ if (heim.adresse) {
3122
+ var weg = e('button', 'knopf leise klein', 'Vergessen');
3123
+ weg.type = 'button';
3124
+ weg.setAttribute('aria-label', heim.adresse + ' bei ' + harnessName(h) + ' vergessen');
3125
+ weg.addEventListener('click', function () { frageHeimVergessen(h, heim.adresse); });
3126
+ knoepfe.appendChild(weg);
3127
+ } else {
3128
+ var aendern = e('button', 'knopf leise klein', 'Ändern …');
3129
+ aendern.type = 'button';
3130
+ aendern.setAttribute('aria-label', 'Homedir von ' + harnessName(h) + ' ändern');
3131
+ aendern.addEventListener('click', function () { oeffneHeimSetzen(h, heim.pfad); });
3132
+ knoepfe.appendChild(aendern);
3133
+ if (heim.eingestellt) {
3134
+ var zurueck = e('button', 'knopf leise klein', 'Zurücksetzen');
3135
+ zurueck.type = 'button';
3136
+ zurueck.addEventListener('click', function () {
3137
+ rufe(function () { return window.hmgr.harnessHeimZuruecksetzen(h); }).then(async function () {
3138
+ await ladeAlles();
3139
+ melde(harnessName(h) + ' liest sein Homedir wieder am Standardort. Am alten Ort bleibt alles liegen.');
3140
+ });
3141
+ });
3142
+ knoepfe.appendChild(zurueck);
3143
+ }
3144
+ }
3145
+ zeile.appendChild(knoepfe);
3146
+ kasten.appendChild(zeile);
3147
+ });
3148
+ if (eintrag.kannZusaetzliche && PLATTFORM === 'win32') {
3149
+ var neu = e('button', 'knopf leise klein', 'Homedir in WSL hinzufügen …');
3150
+ neu.type = 'button';
3151
+ neu.addEventListener('click', function () { oeffneHeimHinzufuegen(h); });
3152
+ kasten.appendChild(neu);
3153
+ }
3154
+ return kasten;
3155
+ }
3156
+
3157
+ var heimHarness = null;
3158
+ function oeffneHeimSetzen(h, jetzt) {
3159
+ heimHarness = h;
3160
+ $('dhh-titel').textContent = 'Homedir von ' + harnessName(h);
3161
+ $('dhh-pfad').value = jetzt;
3162
+ zeige($('d-heim-pfad'));
3163
+ $('dhh-pfad').focus();
3164
+ }
3165
+ nachDialog($('d-heim-pfad'), async function (wert) {
3166
+ var h = heimHarness;
3167
+ heimHarness = null;
3168
+ if (wert !== 'ok' || !h) return;
3169
+ var pfad = $('dhh-pfad').value.trim();
3170
+ if (!pfad) return;
3171
+ var gesetzt = await rufe(function () { return window.hmgr.harnessHeimSetzen({ harness: h, pfad: pfad }); });
3172
+ await ladeAlles();
3173
+ melde(harnessName(h) + ' liest sein Homedir jetzt unter ' + gesetzt
3174
+ + '. Was am alten Ort liegt, bleibt dort liegen — hmgr zeigt es nicht mehr.');
3175
+ });
3176
+
3177
+ async function oeffneHeimHinzufuegen(h) {
3178
+ heimHarness = h;
3179
+ var auswahl = $('dhw-distro');
3180
+ auswahl.textContent = '';
3181
+ var distros = await rufe(function () { return window.hmgr.wslDistros(); });
3182
+ (distros || []).forEach(function (name) {
3183
+ var option = document.createElement('option');
3184
+ option.value = name;
3185
+ option.textContent = name;
3186
+ auswahl.appendChild(option);
3187
+ });
3188
+ $('dhw-leer').hidden = (distros || []).length > 0;
3189
+ $('dhw-distro').hidden = (distros || []).length === 0;
3190
+ $('dhw-ok').disabled = (distros || []).length === 0;
3191
+ zeige($('d-heim-wsl'));
3192
+ }
3193
+ nachDialog($('d-heim-wsl'), async function (wert) {
3194
+ var h = heimHarness;
3195
+ heimHarness = null;
3196
+ if (wert !== 'ok' || !h) return;
3197
+ var distro = $('dhw-distro').value;
3198
+ if (!distro) return;
3199
+ var heim = await rufe(function () {
3200
+ return window.hmgr.harnessHeimHinzufuegen({ harness: h, adresse: 'wsl:' + distro });
3201
+ });
3202
+ await ladeAlles();
3203
+ melde(harnessName(h) + ' hat jetzt ein zweites Ziel: ' + heim.adresse + ' mit dem Homedir ' + heim.pfad
3204
+ + '. Links legst du dort einzeln, gespiegelt wird nichts.');
3205
+ });
3206
+
3207
+ function frageHeimVergessen(h, adresse) {
3208
+ heimHarness = h;
3209
+ $('dhv-titel').textContent = adresse + ' vergessen?';
3210
+ $('dhv-text').textContent = 'hmgr verwaltet dieses Homedir dann nicht mehr und zeigt sein Ziel nicht mehr an. '
3211
+ + 'Die Links, die dort liegen, bleiben liegen.';
3212
+ $('d-heim-weg').dataset.adresse = adresse;
3213
+ zeige($('d-heim-weg'));
3214
+ }
3215
+ nachDialog($('d-heim-weg'), async function (wert) {
3216
+ var h = heimHarness;
3217
+ heimHarness = null;
3218
+ var adresse = $('d-heim-weg').dataset.adresse;
3219
+ if (wert !== 'ok' || !h || !adresse) return;
3220
+ await rufe(function () { return window.hmgr.harnessHeimEntfernen({ harness: h, adresse: adresse }); });
3221
+ await ladeAlles();
3222
+ melde(harnessName(h) + ' verwaltet ' + adresse + ' nicht mehr. Die Links dort bleiben liegen.');
3223
+ });
3224
+
3225
+ enterBestaetigt($('dhh-pfad'), $('dhh-ok'));
3226
+
2694
3227
  function baueHarnessliste() {
2695
3228
  var ul = $('harnessliste');
2696
3229
  // Der Neuaufbau wirft den Schalter weg, auf dem der Fokus steht. Nach einer Rückfrage ist genau
@@ -2721,6 +3254,7 @@
2721
3254
  txt.appendChild(e('small', null, stand));
2722
3255
  label.appendChild(txt);
2723
3256
  li.appendChild(label);
3257
+ li.appendChild(heimeFuerHarness(h));
2724
3258
  ul.appendChild(li);
2725
3259
  });
2726
3260
  if (merk) {
@@ -2769,40 +3303,27 @@
2769
3303
  var liegen = bilanzText(harnessBilanz(h));
2770
3304
  await rufe(function () { return window.hmgr.harnessDeaktivieren(h); });
2771
3305
  await ladeAlles();
2772
- melde(harnessName(h) + ' deaktiviert. ' + liegen + ' bleiben liegen, hmgr pflegt sie nicht mehr. Im Ziel steht ein Säubern-Knopf, wenn du sie doch loswerden willst.');
3306
+ // E74: die Ziele verschwinden mit dem Harness aus der Ansicht, ein Säubern-Knopf im Ziel
3307
+ // existiert nicht mehr. Was bleibt, ist der Weg über das Wiedereinschalten.
3308
+ melde(harnessName(h) + ' deaktiviert. ' + liegen + ' bleiben liegen, hmgr pflegt sie nicht mehr. Die Ziele verschwinden aus der Liste und erscheinen wieder, sobald du ' + harnessName(h) + ' wieder einschaltest.');
2773
3309
  } else {
2774
3310
  // Abbruch: der Schalter im DOM steht noch auf aus, die Einstellung nicht. Neu aufbauen setzt ihn zurück.
2775
3311
  baueHarnessliste();
2776
3312
  }
2777
3313
  });
2778
3314
 
2779
- /* Derselbe Weg für Konfiguration, die schon ohne aktiven Harness dalag (Säubern-Knopf im Ziel). */
2780
- var saeuberKandidat = null;
2781
- function frageSaeubern(h) {
2782
- saeuberKandidat = h;
2783
- var bilanz = harnessBilanz(h);
2784
- $('ds-titel').textContent = harnessName(h) + ' säubern?';
2785
- beschriftet($('ds-titel'), h);
2786
- $('ds-text').textContent = harnessName(h) + ' ist inaktiv, Konfiguration liegt noch (' + bilanzText(bilanz)
2787
- + '). Säubern entfernt die hmgr-Links und harness-eigene Einträge, nicht von hmgr verwaltete Einträge bleiben.';
2788
- fuelleBilanzListe($('ds-liste'), bilanz);
2789
- zeige($('d-saeubern'));
2790
- }
2791
- nachDialog($('d-saeubern'), async function (wert) {
2792
- var h = saeuberKandidat;
2793
- saeuberKandidat = null;
2794
- if (wert !== 'los' || !h) return;
2795
- var ergebnis = await rufe(function () { return window.hmgr.saeubern(h); });
2796
- await ladeAlles();
2797
- melde(saeuberMeldung(harnessName(h) + ' gesäubert.', ergebnis));
2798
- });
3315
+ /* Einen eigenen Säubern-Weg für einen schon inaktiven Harness gibt es nicht mehr (E74): sein Ziel
3316
+ steht nicht mehr im Bild, also auch kein Knopf darin. Wer doch säubern will, schaltet den
3317
+ Harness ein und wieder aus — dann fragt der Dialog oben erneut. */
2799
3318
 
2800
3319
  function baueEinstellungen() {
2801
3320
  baueHarnessliste();
2802
3321
  var liste = $('quellenliste');
2803
3322
  liste.textContent = '';
2804
3323
  if (QUELLEN.length === 0) {
2805
- liste.appendChild(e('li', null, 'Noch keine Quelle. „Quelle hinzufügen …" legt die erste an.'));
3324
+ // Eigene Klasse statt nacktem Text im Listen-Grid (E75): `.quellenliste li` ist ein Grid mit
3325
+ // 20px-Icon-Spalte, in die der anonyme Textknoten fiel — jedes Wort brach einzeln um.
3326
+ liste.appendChild(e('li', 'leerhinweis', 'Noch keine Quelle. „Quelle hinzufügen …" legt die erste an.'));
2806
3327
  return;
2807
3328
  }
2808
3329
  QUELLEN.forEach(function (q) {