hmgr 0.0.45 → 0.0.46

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.
@@ -18,7 +18,8 @@ function deuten(roh) {
18
18
  aktiveHarnesses: (daten.aktiveHarnesses ?? ['claude-code']),
19
19
  // Fehlt das Feld (state.json vor E43), gilt: nichts ignoriert.
20
20
  ignorierteEintraege: (daten.ignorierteEintraege ?? []),
21
- // Fehlt das Feld (state.json vor E69), gilt: kein Bundle installiert.
21
+ // Fehlt das Feld (state.json vor E69), gilt: kein Bundle installiert. Ein Eintrag ohne
22
+ // `herausgenommen` (vor E114) hat keine herausgenommenen Mitglieder, das Feld bleibt dann weg.
22
23
  bundleInstallationen: (daten.bundleInstallationen ?? []),
23
24
  // Fehlt das Feld (state.json vor E78), gilt: jeder Harness hat nur sein Standard-Homedir.
24
25
  harnessHeime: (daten.harnessHeime ?? []),
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "fassung": 1,
3
3
  "versionen": {
4
+ "0.0.46": [
5
+ "Bundles lassen sich aufklappen und zeigen ihre Skills mit Beschreibung, auch schon im Dialog „Skills hinzufügen“. Entfernst du einen einzelnen Skill, bleibt das Bundle bestehen und zeigt, was fehlt; mit „Vervollständigen“ oder „Wieder verlinken“ holst du Skills zurück, und „Entfernen“ am Bundle räumt den Rest weg."
6
+ ],
4
7
  "0.0.45": [
5
8
  "MCP-Server und die Listen in den Einstellungen (Harnesses, zusätzliche Homedirs) erscheinen jetzt alphabetisch sortiert, ebenso in hmgr mcp list."
6
9
  ],
package/dist/cli/hilfe.js CHANGED
@@ -315,9 +315,13 @@ Liegt am Ziel schon derselbe Skill aus derselben Quelle als hmgr-Link, wird er a
315
315
  übernommen: aus Bestand wird ein Bundle, ohne erst Links von Hand zu entfernen. Alles andere unter
316
316
  demselben Namen ist ein Konflikt und wird genannt.
317
317
 
318
- hmgr unlink <skill> auf ein einzelnes Mitglied zerbricht das Bundle in diesem Ziel: die übrigen
319
- Links bleiben als einzelne Links liegen, der Bundle-Verweis fällt weg. Das ist kein Fehler, hmgr
320
- sagt es nur.
318
+ hmgr unlink <skill> auf ein einzelnes Mitglied nimmt es aus dem Bundle heraus: nur dieser Link
319
+ verschwindet, das Bundle bleibt im Ziel und merkt sich den Skill als herausgenommen. sync und
320
+ update legen ihn nicht wieder an, neue Mitglieder der Definition ergänzen sie weiter. Zurück kommt
321
+ er mit hmgr link <skill> desselben Skills aus derselben Quelle oder mit bundle sync --complete, das
322
+ alle fehlenden Mitglieder verlinkt. remove nimmt die noch verlinkten Mitglieder weg, auch wenn
323
+ schon welche herausgenommen sind. hmgr status und bundle list zeigen die Lage je Ziel, etwa
324
+ „4/5 … fehlt: x".
321
325
 
322
326
  Den Abgleich macht schon hmgr update für alle installierten Bundles mit, es ist derselbe Vorgang
323
327
  und dieselbe Ausgabe (E69): eine geänderte Definition kommt also mit dem gewöhnlichen
@@ -335,11 +339,14 @@ Schalter (add, remove und sync):
335
339
  kennt nur hermes, wsl:<distro> nur claude-code unter Windows (E78)
336
340
  --composer <pfad> composer.json des Arbeitsverzeichnisses, wenn mehrere in Frage kommen
337
341
  --quelle <teil> nur bei add: Quelle eingrenzen, wenn den Namen mehrere Quellen führen
342
+ --complete nur bei sync: auch herausgenommene Mitglieder verlinken (Vervollständigen),
343
+ die Liste der herausgenommenen ist danach leer. Auch --vervollstaendigen
338
344
 
339
345
  Beispiele:
340
346
  hmgr bundle list
341
347
  hmgr bundle add wordpress --ort dir:.
342
348
  hmgr bundle sync
349
+ hmgr bundle sync wordpress --ort dir:. --complete
343
350
  hmgr bundle remove wordpress --ort dir:.
344
351
 
345
352
  Häufige Fälle:
package/dist/cli/main.js CHANGED
@@ -125,7 +125,7 @@ const SCHALTER = {
125
125
  unlink: { werte: ['harness', 'ort', 'composer'], flaggen: ['local', 'lokal'] },
126
126
  adopt: { werte: ['harness', 'ort', 'composer'], flaggen: ['local', 'lokal', 'probelauf'] },
127
127
  plugin: { werte: ['harness', 'ort', 'composer', 'quelle'], flaggen: ['local', 'lokal'] },
128
- bundle: { werte: ['harness', 'ort', 'composer', 'quelle'], flaggen: [] },
128
+ bundle: { werte: ['harness', 'ort', 'composer', 'quelle'], flaggen: ['complete', 'vervollstaendigen'] },
129
129
  mcp: { werte: ['harness', 'ort'], flaggen: [] },
130
130
  sync: { werte: [], flaggen: [] },
131
131
  update: { werte: ['quelle'], flaggen: [] },
@@ -160,7 +160,8 @@ const UNTERKOMMANDOS = {
160
160
  add: { woerter: 2, schalter: ['harness', 'ort', 'composer', 'quelle'] },
161
161
  remove: { woerter: 2, schalter: ['harness', 'ort', 'composer'] },
162
162
  // sync nimmt einen Bundle-Namen, aber keinen zwingend: ohne Namen gilt der ganze Bestand.
163
- sync: { woerter: 2, schalter: ['harness', 'ort', 'composer'] },
163
+ // --complete/--vervollstaendigen holt herausgenommene Mitglieder zurück (E114 L4).
164
+ sync: { woerter: 2, schalter: ['harness', 'ort', 'composer', 'complete', 'vervollstaendigen'] },
164
165
  list: { woerter: 1, schalter: [] },
165
166
  },
166
167
  mcp: {
@@ -491,9 +492,10 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
491
492
  if (kommando === 'unlink') {
492
493
  const ergebnis = await anwendungsfaelle.linkEntfernen({ skill, ziel, lokal });
493
494
  console.log(`Link ${skill} entfernt.`);
494
- // Zerbrochen (E69): die übrigen Links des Bundles bleiben als Einzel-Installationen liegen.
495
- for (const bundle of ergebnis.zerbrochen)
496
- console.log(`Bundle „${bundle}" ist damit zerbrochen, seine übrigen Links bleiben als einzelne Links liegen.`);
495
+ // Herausgenommen (E114 L2): das Bundle bleibt, der Abgleich legt den Skill nicht wieder an.
496
+ for (const bundle of ergebnis.herausgenommenAus)
497
+ console.log(`„${skill}" ist aus dem Bundle „${bundle}" herausgenommen, das Bundle bleibt. ` +
498
+ `Zurück mit hmgr link ${skill} oder hmgr bundle sync ${bundle} --complete.`);
497
499
  if (ergebnis.hinweis !== undefined)
498
500
  console.log(ergebnis.hinweis);
499
501
  return 0;
@@ -612,10 +614,15 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
612
614
  if (kommando === 'bundle') {
613
615
  const schalter = schalterLesen(argv.slice(1), 'bundle');
614
616
  unterkommandoPruefen('bundle', schalter);
615
- const { rest, werte } = schalter;
617
+ const { rest, werte, flaggen } = schalter;
616
618
  const unterkommando = rest[0];
617
619
  if (unterkommando === 'list') {
618
620
  const katalog = await anwendungsfaelle.bundleKatalog();
621
+ // Je Bundle die Ziele, in denen es liegt, mit seiner Lage (E114 L6). Nur nötig, wenn
622
+ // überhaupt ein Bundle installiert ist; der Ist-Zustand kostet sonst einen Scan aller Ziele.
623
+ const installiert = (await ports.zustand.lesen()).bundleInstallationen.length > 0
624
+ ? (await anwendungsfaelle.istZustand({ plugins: false })).flatMap((z) => z.bundles.map((b) => ({ ziel: z.ziel, bundle: b })))
625
+ : [];
619
626
  const leer = katalog.every((k) => k.bundles.length === 0 && k.kaputte.length === 0 && k.fehler === undefined);
620
627
  if (leer)
621
628
  console.log('Keine Quelle mit Bundles. hmgr help bundle zeigt das Format von bundles/<name>.yaml.');
@@ -631,6 +638,9 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
631
638
  console.log(` ${bundle.name} ${kappen(bundle.beschreibung)}`);
632
639
  for (const mitglied of bundle.mitglieder)
633
640
  console.log(` ${mitglied.pfad} aus ${quelleAlsText(mitglied.quelle)}`);
641
+ for (const { ziel, bundle: lage } of installiert)
642
+ if (lage.name === bundle.name && quelleSchluessel(lage.quelle) === quelleSchluessel(bundle.quelle))
643
+ console.log(` installiert ${ziel.harness} ${beschreibeOrt(ziel.ort)} ${bundleLage(lage)}`);
634
644
  }
635
645
  // Eine kaputte Definition verdeckt die übrigen nicht, sie wird eigens genannt (E69).
636
646
  for (const kaputt of eintrag.kaputte)
@@ -683,9 +693,11 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
683
693
  }
684
694
  // sync: ohne Ziel-Schalter der ganze Bestand, mit --ort/--harness nur dieses Ziel.
685
695
  const nurZiel = werte.has('harness') || werte.has('ort');
696
+ const vervollstaendigen = flaggen.has('complete') || flaggen.has('vervollstaendigen');
686
697
  const eintraege = await anwendungsfaelle.bundleAbgleich({
687
698
  ...(name === undefined ? {} : { bundle: name }),
688
699
  ...(nurZiel ? { ziel } : {}),
700
+ ...(vervollstaendigen ? { aufnehmen: 'alle' } : {}),
689
701
  });
690
702
  if (eintraege.length === 0)
691
703
  console.log('Kein Bundle installiert, nichts abzugleichen.');
@@ -888,7 +900,7 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
888
900
  }
889
901
  // Bundles des Ziels (E69), unter den Einträgen: welche Links als Einheit zusammengehören.
890
902
  for (const bundle of zielZustand.bundles)
891
- console.log(` Bundle ${bundle.name} (${quelleAlsText(bundle.quelle)}) ${bundle.skills.join(', ')}`);
903
+ console.log(` Bundle ${bundle.name} (${quelleAlsText(bundle.quelle)}) ${bundleLage(bundle)}`);
892
904
  if (zielZustand.plugins !== undefined)
893
905
  console.log(` Plugins: ${zielZustand.plugins.length === 0 ? 'keine' : zielZustand.plugins.join(', ')}`);
894
906
  }
@@ -1134,6 +1146,16 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
1134
1146
  }
1135
1147
  throw new Error(`Unbekanntes Kommando „${kommando}". hmgr --help zeigt alle Kommandos.`);
1136
1148
  }
1149
+ /**
1150
+ * Lage eines installierten Bundles in einem Ziel (E114 L6), für `status` und `bundle list`:
1151
+ * verlinkt von gesamt, die verlinkten Namen und, wenn etwas fehlt, was.
1152
+ */
1153
+ function bundleLage(bundle) {
1154
+ const gesamt = bundle.skills.length + bundle.nichtVerlinkt.length;
1155
+ const verlinkt = bundle.skills.length === 0 ? '' : ` ${bundle.skills.join(', ')}`;
1156
+ const fehlt = bundle.nichtVerlinkt.length === 0 ? '' : ` fehlt: ${bundle.nichtVerlinkt.join(', ')}`;
1157
+ return `${bundle.skills.length}/${gesamt}${verlinkt}${fehlt}`;
1158
+ }
1137
1159
  /**
1138
1160
  * Bericht über den Bundle-Abgleich (E69), gleich für `hmgr bundle sync` und `hmgr update`: je
1139
1161
  * Bundle eine Zeile mit seinem Ausgang, darunter, was sich geändert hat. „fehlgeschlagen" heißt
package/dist/gui/main.js CHANGED
@@ -174,6 +174,9 @@ function zielZustandSerialisieren(z) {
174
174
  bundles: z.bundles.map((b) => ({
175
175
  name: b.name,
176
176
  skills: b.skills,
177
+ // E114 L3: nicht verlinkte Mitglieder (herausgenommen oder von Hand gelöscht).
178
+ nichtVerlinkt: b.nichtVerlinkt,
179
+ herausgenommen: b.herausgenommen,
177
180
  quelleText: quelleAlsText(b.quelle),
178
181
  quelleName: quelleName(b.quelle),
179
182
  })),
@@ -234,8 +237,10 @@ function registriereIpc() {
234
237
  return ergebnis;
235
238
  }));
236
239
  // Bundles aller bekannten Quellen (E69): flach wie der Skill-Katalog, je Bundle seine Quelle und
237
- // die Zahl seiner Mitglieder. Kaputte Definitionen kommen als eigene Liste mit, damit die GUI sie
238
- // nennen kann, statt sie zu verschlucken.
240
+ // seine Mitglieder als Quelle plus Pfad (E114 L1). Name und Beschreibung eines Mitglieds sucht der
241
+ // Renderer im Skill-Katalog nach (`quelleSchluessel` + `pfad`): eine noch nicht eingerichtete
242
+ // Quelle wird hier nicht gelesen, sonst klonte jedes Öffnen des Dialogs. Kaputte Definitionen
243
+ // kommen als eigene Liste mit, damit die GUI sie nennen kann, statt sie zu verschlucken.
239
244
  ipcMain.handle('hmgr:bundles', async () => auffangen(async () => {
240
245
  const bundles = [];
241
246
  const kaputte = [];
@@ -244,7 +249,11 @@ function registriereIpc() {
244
249
  bundles.push({
245
250
  name: bundle.name,
246
251
  beschreibung: bundle.beschreibung,
247
- mitglieder: bundle.mitglieder.length,
252
+ mitglieder: bundle.mitglieder.map((m) => ({
253
+ quelleSchluessel: quelleSchluessel(m.quelle),
254
+ quelleText: quelleAlsText(m.quelle),
255
+ pfad: m.pfad,
256
+ })),
248
257
  // Anzeige im Dialog: aus welchen Quellen das Bundle Skills holt, ohne Dubletten.
249
258
  quellen: [...new Set(bundle.mitglieder.map((m) => quelleAlsText(m.quelle)))],
250
259
  quelleText: quelleAlsText(bundle.quelle),
@@ -272,6 +281,24 @@ function registriereIpc() {
272
281
  };
273
282
  }));
274
283
  ipcMain.handle('hmgr:bundleEntfernen', async (_event, args) => auffangen(async () => anwendungsfaelle.bundleEntfernen({ bundle: args.bundle, ziel: zielAusIpc(args.harness, args.ort) })));
284
+ // Vervollständigen (E114 L4, `aufnehmen: 'alle'`) und „Wieder verlinken" einzelner Mitglieder (L3,
285
+ // Namensliste): der Abgleich dieses einen Bundles in diesem Ziel. Wie bundleEntfernen ohne Zeiger.
286
+ // Der Abgleich meldet einen Fehlschlag im Ergebnis statt zu werfen; hier wird er zum Fehler, damit
287
+ // `rufe` im Renderer ihn zeigt.
288
+ ipcMain.handle('hmgr:bundleAbgleichen', async (_event, args) => auffangen(async () => {
289
+ const eintraege = await anwendungsfaelle.bundleAbgleich({
290
+ bundle: args.bundle,
291
+ quelle: quelleAusText(args.quelleText),
292
+ ziel: zielAusIpc(args.harness, args.ort),
293
+ aufnehmen: args.aufnehmen,
294
+ });
295
+ const eintrag = eintraege[0];
296
+ if (eintrag === undefined)
297
+ throw new Error(`Bundle „${args.bundle}" ist dort nicht installiert.`);
298
+ if (eintrag.ergebnis === 'fehlgeschlagen')
299
+ throw new Error(`Bundle „${args.bundle}" ließ sich nicht vervollständigen: ${eintrag.fehler ?? ''}`);
300
+ return { gelegt: eintrag.gelegt };
301
+ }));
275
302
  // Nur die Herkunft, ohne die Quellen zu lesen: der Katalog daneben tut das schon.
276
303
  ipcMain.handle('hmgr:quellen', async () => auffangen(async () => (await anwendungsfaelle.quellen()).map((quelle) => ({
277
304
  art: quelle.art,
@@ -858,6 +885,7 @@ function fensterOeffnen() {
858
885
  // fällt, was ins Ziel kommt, und zeigt je Bundle Beschreibung und Herkunft.
859
886
  var dskBundleBoxSichtbar = false;
860
887
  var dskBundleText = null;
888
+ var dskBundleZuVorher = null, dskBundleKoerperText = null;
861
889
  // Aufklappbereich je Zeile (E92): zu vor dem Klick, danach sichtbar mit der ungekürzten
862
890
  // Beschreibung; die Checkbox bleibt unberührt, der Dialog offen.
863
891
  var dskKoerperVorher = null;
@@ -895,6 +923,13 @@ function fensterOeffnen() {
895
923
  dskBundleBoxSichtbar = document.getElementById('dsk-bundlebox')?.hidden === false;
896
924
  dskBundleText = document.querySelector('#dsk-bundles .wahlzeile')?.textContent
897
925
  ?.replace(/\\s+/g, ' ').trim() ?? null;
926
+ // E114 L1: die Bundle-Wahlzeile klappt auf und zeigt ihre Skills samt Beschreibung.
927
+ var dskBundleKoerper = document.querySelector('#dsk-bundles .wahlzeile .wahlkoerper');
928
+ dskBundleZuVorher = dskBundleKoerper?.hidden ?? null;
929
+ document.querySelector('#dsk-bundles .wahlzeile .aufklappknopf')?.click();
930
+ dskBundleKoerperText = dskBundleKoerper && !dskBundleKoerper.hidden
931
+ ? dskBundleKoerper.textContent?.replace(/\\s+/g, ' ').trim() ?? null
932
+ : null;
898
933
  document.getElementById('d-skills')?.close();
899
934
  }
900
935
  // Verlinken-Dialog „Wohin?" (E76): er bietet nur Ziele aktiver Harnesses an. Der Text
@@ -908,20 +943,33 @@ function fensterOeffnen() {
908
943
  ?.replace(/\\s+/g, ' ').trim() ?? null;
909
944
  document.getElementById('d-ziel')?.close();
910
945
  }
911
- // Bundle-Gruppe an der Ziel-Karte (E69): Kopf mit Name, Quelle und Entfernen, darunter
912
- // die Zeilen der Mitglieder — sie stehen in der Gruppe, nicht in der allgemeinen Liste.
913
- var bundleGruppeText = document.querySelector('#zielliste .bundlegruppe .bundlekopf')?.textContent
946
+ // Bundle-Gruppe an der Claude-Code-Home-Karte (E69, E114): Kopf mit Name, Quelle, Zählung
947
+ // „1 von 2 Skills", „Vervollständigen" und „Entfernen"; darunter zugeklappt die Zeilen
948
+ // aller Mitglieder. „skill-creator" hat die Fixture herausgenommen, es steht als blasse
949
+ // Zeile „nicht verlinkt" mit „Wieder verlinken" in der Gruppe.
950
+ var homeGruppe = Array.from(document.querySelectorAll('#zielliste .ziel'))
951
+ .find((k) => (k.querySelector('.zielkopf')?.textContent || '').indexOf('Claude Code · Home') > -1)
952
+ ?.querySelector('.bundlegruppe') ?? null;
953
+ var bundleGruppeText = homeGruppe?.querySelector('.bundlekopf')?.textContent
914
954
  ?.replace(/\\s+/g, ' ').trim() ?? null;
915
- var bundleGruppeZeilen = Array.from(document.querySelectorAll('#zielliste .bundlegruppe .linkliste .name'))
955
+ var bundleGruppeZeilen = Array.from(homeGruppe?.querySelectorAll('.linkliste li:not(.nichtverlinkt) .name') ?? [])
916
956
  .map((n) => n.textContent.trim()).join(',');
917
- // Entfernen an der Einzelzeile innerhalb der Gruppe sagt, dass das Bundle zerbricht.
918
- var bundleZerbrichtText = null;
957
+ var bundleNichtVerlinktText = homeGruppe?.querySelector('.linkliste li.nichtverlinkt')?.textContent
958
+ ?.replace(/\\s+/g, ' ').trim() ?? null;
959
+ var bundleVervollstaendigenKnopf = Array.from(homeGruppe?.querySelectorAll('.bundlekopf button') ?? [])
960
+ .some((k) => k.textContent === 'Vervollständigen');
961
+ var bundleKoerperZuVorher = homeGruppe?.querySelector('.linkliste')?.hidden ?? null;
962
+ homeGruppe?.querySelector('.bundlekopf .aufklappknopf')?.click();
963
+ var bundleKoerperAufNachKlick = homeGruppe?.querySelector('.linkliste')?.hidden === false;
964
+ // Entfernen an der Mitgliedszeile sagt, dass der Skill herausgenommen wird und das
965
+ // Bundle bleibt (E114 L2, früher: zerbricht).
966
+ var bundleHerausnehmenText = null;
919
967
  try {
920
- document.querySelector('#zielliste .bundlegruppe .linkliste .knopf')?.click();
921
- bundleZerbrichtText = document.getElementById('dx-text')?.textContent ?? null;
968
+ homeGruppe?.querySelector('.linkliste li:not(.nichtverlinkt) .knopf')?.click();
969
+ bundleHerausnehmenText = document.getElementById('dx-text')?.textContent ?? null;
922
970
  document.querySelector('#d-entfernen button[value="nein"]')?.click();
923
971
  } catch (fehler) {
924
- bundleZerbrichtText = String(fehler);
972
+ bundleHerausnehmenText = String(fehler);
925
973
  }
926
974
  // Sammelstreifen unter „Alle" (E95): das Bundle „vorhaben" liegt an kunde-x in Claude Code
927
975
  // und Hermes — ein Streifen mit beiden Harness-Zeichen, Entfernen nennt beide Harnesses
@@ -932,12 +980,21 @@ function fensterOeffnen() {
932
980
  var streifenZahl = null, streifenHarnesse = null, streifenText = null;
933
981
  var streifenDialogTitel = null, streifenDialogText = null;
934
982
  var chipGruppeZahl = null, chipGruppeZeilenZahl = null, chipStreifenZahl = null;
983
+ // E114 L1: unter „Alle" stehen die Mitglieder in der Gruppe und nicht zusätzlich in der
984
+ // Tabelle. Name ohne Harness-Zeichen: nur die Textknoten der Namenszelle.
985
+ var nurName = (n) => Array.from(n.childNodes).filter((t) => t.nodeType === 3).map((t) => t.textContent).join('').trim();
986
+ var alleGruppeZeilen = null, alleTabelleZeilen = null;
935
987
  try {
936
988
  var streifen = Array.from(streifenKarte?.querySelectorAll('.bundlegruppe') ?? []);
937
989
  streifenZahl = streifen.length;
938
990
  streifenHarnesse = streifen[0]?.querySelector('.zeilenzeichen')?.getAttribute('aria-label') ?? null;
939
- streifenText = streifen[0]?.textContent?.replace(/\\s+/g, ' ').trim() ?? null;
940
- streifen[0]?.querySelector('button')?.click();
991
+ streifenText = streifen[0]?.querySelector('.bundlekopf')?.textContent?.replace(/\\s+/g, ' ').trim() ?? null;
992
+ alleGruppeZeilen = Array.from(streifen[0]?.querySelectorAll('.linkliste li .name') ?? []).map(nurName).join(',');
993
+ alleTabelleZeilen = Array.from(streifenKarte?.querySelectorAll('.linkliste li') ?? [])
994
+ .filter((li) => li.closest('.bundlegruppe') === null && li.querySelector('.name'))
995
+ .map((li) => nurName(li.querySelector('.name'))).join(',');
996
+ Array.from(streifen[0]?.querySelectorAll('.bundlekopf button') ?? [])
997
+ .find((k) => k.textContent === 'Entfernen')?.click();
941
998
  streifenDialogTitel = document.getElementById('dx-titel')?.textContent ?? null;
942
999
  streifenDialogText = document.getElementById('dx-text')?.textContent ?? null;
943
1000
  document.querySelector('#d-entfernen button[value="nein"]')?.click();
@@ -1236,13 +1293,21 @@ function fensterOeffnen() {
1236
1293
  dskAufklappFreiAngehakt,
1237
1294
  dskBundleBoxSichtbar,
1238
1295
  dskBundleText,
1296
+ dskBundleZuVorher,
1297
+ dskBundleKoerperText,
1239
1298
  dzZieleText,
1240
1299
  bundleGruppeText,
1241
1300
  bundleGruppeZeilen,
1242
- bundleZerbrichtText,
1301
+ bundleNichtVerlinktText,
1302
+ bundleVervollstaendigenKnopf,
1303
+ bundleKoerperZuVorher,
1304
+ bundleKoerperAufNachKlick,
1305
+ bundleHerausnehmenText,
1243
1306
  streifenZahl,
1244
1307
  streifenHarnesse,
1245
1308
  streifenText,
1309
+ alleGruppeZeilen,
1310
+ alleTabelleZeilen,
1246
1311
  streifenDialogTitel,
1247
1312
  streifenDialogText,
1248
1313
  chipGruppeZahl,
@@ -15,6 +15,8 @@ contextBridge.exposeInMainWorld('hmgr', {
15
15
  bundles: () => ipcRenderer.invoke('hmgr:bundles'),
16
16
  bundleInstallieren: (args) => ipcRenderer.invoke('hmgr:bundleInstallieren', args),
17
17
  bundleEntfernen: (args) => ipcRenderer.invoke('hmgr:bundleEntfernen', args),
18
+ /** Vervollständigen ('alle') oder einzelne Mitglieder wieder verlinken (E114). */
19
+ bundleAbgleichen: (args) => ipcRenderer.invoke('hmgr:bundleAbgleichen', args),
18
20
  /** Icon einer Quelle als data-URL, oder null (E60). */
19
21
  icon: (text) => ipcRenderer.invoke('hmgr:icon', text),
20
22
  /** Öffnet eine Quelle: Repo-Seite im Browser oder Ordner im Dateimanager (E60). */
@@ -745,7 +745,7 @@
745
745
  }
746
746
 
747
747
  /** Eine Zeile der Link-Tabelle. `bundle` gesetzt heißt: die Zeile steht in einer Bundle-Gruppe
748
- (E69), das Entfernen zerbricht dort das Bundle und der Dialog sagt es. */
748
+ (E69), das Entfernen nimmt den Skill dort aus dem Bundle heraus und der Dialog sagt es (E114). */
749
749
  function linkZeile(ei, z, bundle) {
750
750
  var name = ei.art === 'link' ? ei.link.name : ei.name;
751
751
  var li = e('li');
@@ -801,63 +801,156 @@
801
801
  return li;
802
802
  }
803
803
 
804
- /** Ein installiertes Bundle als Gruppe über seinen Skill-Zeilen (E69, F68): Kopf mit Name, Quelle
805
- und „Entfernen" für das Ganze, darunter die Zeilen seiner Mitglieder. */
806
- function bundleGruppe(b, z, eintraege) {
804
+ /** Alle Mitglieder eines installierten Bundles in einem Ziel (E114 L3): verlinkte und nicht
805
+ verlinkte, aus dem Zustand — ohne die Definition, die Gruppe steht auch bei fehlender Quelle. */
806
+ function bundleAlleMitglieder(b) {
807
+ return b.skills.concat(b.nichtVerlinkt || []);
808
+ }
809
+
810
+ /** Skill zu einem Mitglied einer Definition aus dem Skill-Katalog (E114 L1): gleiche Quelle, gleicher
811
+ Pfad. null, wenn die Quelle (noch) nicht eingerichtet oder nicht lesbar ist. */
812
+ function bundleMitgliedSkill(m) {
813
+ return SKILLS.filter(function (s) { return s.quelleSchluessel === m.quelleSchluessel && s.pfad === m.pfad; })[0] || null;
814
+ }
815
+
816
+ /** Skill eines nicht verlinkten Mitglieds über seinen Namen, gesucht unter den Mitgliedern der
817
+ Definition (Name plus Quelle des Bundles). null, wenn sich das nicht auflösen lässt. */
818
+ function bundleSkillNachName(b, name) {
819
+ var katalog = BUNDLES.filter(function (k) { return k.name === b.name && k.quelleText === b.quelleText; })[0];
820
+ if (!katalog) return null;
821
+ for (var i = 0; i < katalog.mitglieder.length; i++) {
822
+ var skill = bundleMitgliedSkill(katalog.mitglieder[i]);
823
+ if (skill && skill.name === name) return skill;
824
+ }
825
+ return null;
826
+ }
827
+
828
+ /** Vervollständigen (E114 L4, `aufnehmen` 'alle') oder Wieder verlinken (L3, Namensliste) eines
829
+ Bundles in den genannten Zielen. Ein Fehlschlag in einem Ziel hält die übrigen nicht auf,
830
+ `rufe` meldet ihn selbst. */
831
+ async function bundleAufnehmen(b, ziele, aufnehmen) {
832
+ var gelegt = 0;
833
+ var gescheitert = 0;
834
+ for (var i = 0; i < ziele.length; i++) {
835
+ var ziel = ziele[i];
836
+ try {
837
+ var ergebnis = await rufe(function () {
838
+ return window.hmgr.bundleAbgleichen({
839
+ bundle: b.name, quelleText: b.quelleText, harness: ziel.harness, ort: ziel.ort, aufnehmen: aufnehmen,
840
+ });
841
+ });
842
+ gelegt += ergebnis.gelegt.length;
843
+ } catch (fehler) {
844
+ gescheitert++;
845
+ }
846
+ }
847
+ await ladeAlles();
848
+ if (gescheitert === 0) melde('Bundle ' + b.name + ': ' + zahlwort(gelegt, 'Link', 'Links') + ' gelegt.');
849
+ }
850
+
851
+ // Aufklappzustand der Bundle-Gruppen (E114 L1), überlebt den Neuaufbau durch ladeAlles wie
852
+ // SKILL_OFFEN. Zu Beginn sind die Gruppen zugeklappt.
853
+ var BUNDLE_OFFEN = {};
854
+ var bundleKoerperNr = 0;
855
+
856
+ /** Rahmen einer Bundle-Gruppe (E69, E114 L1/L3/L4): Kopf mit Name, Quelle, „x von N Skills",
857
+ „Vervollständigen", wenn etwas fehlt, und „Entfernen" für das Ganze; darunter aufklappbar die
858
+ Zeilen aller Mitglieder. `harnesse` nur unter „Alle" (Harness-Zeichen am Kopf, E95). */
859
+ function bundleRahmen(b, harnesse, verlinkt, gesamt, ziele, zeilen, entfernen, schluessel) {
807
860
  var gruppe = e('div', 'bundlegruppe');
808
861
  var kopf = e('div', 'bundlekopf');
809
862
  var titel = e('span', 'bundletitel');
863
+ if (harnesse) titel.appendChild(harnesseZeichenSammel(harnesse));
810
864
  titel.appendChild(e('span', 'etikett', 'Bundle'));
811
865
  titel.appendChild(e('b', 'bundlename', b.name));
812
866
  kopf.appendChild(titel);
813
867
  var quelleEl = e('span', 'mono bundlequelle', b.quelleName);
814
868
  quelleEl.title = b.quelleText;
815
869
  kopf.appendChild(quelleEl);
816
- kopf.appendChild(e('span', 'bundlezahl', zahlwort(b.skills.length, 'Skill', 'Skills')));
870
+ kopf.appendChild(e('span', 'bundlezahl', verlinkt === gesamt
871
+ ? zahlwort(gesamt, 'Skill', 'Skills')
872
+ : verlinkt + ' von ' + zahlwort(gesamt, 'Skill', 'Skills')));
873
+ var aktionen = e('span', 'bundleaktionen');
874
+ if (verlinkt < gesamt) {
875
+ var voll = e('button', 'knopf leise klein', 'Vervollständigen');
876
+ voll.type = 'button';
877
+ voll.setAttribute('aria-label', 'Bundle ' + b.name + ' vervollständigen: alle fehlenden Skills verlinken');
878
+ voll.addEventListener('click', function () { bundleAufnehmen(b, ziele, 'alle'); });
879
+ aktionen.appendChild(voll);
880
+ }
817
881
  var weg = e('button', 'knopf leise klein', 'Entfernen');
818
882
  weg.type = 'button';
819
- weg.setAttribute('aria-label', 'Bundle ' + b.name + ' aus ' + zielTitel(z) + ' entfernen');
820
- weg.addEventListener('click', function () { frageBundleEntfernen(b, z.ziel); });
821
- kopf.appendChild(weg);
883
+ weg.setAttribute('aria-label', 'Bundle ' + b.name + ' entfernen');
884
+ weg.addEventListener('click', entfernen);
885
+ aktionen.appendChild(weg);
886
+ var offen = !!BUNDLE_OFFEN[schluessel];
887
+ var koerperId = 'bundlekoerper-' + bundleKoerperNr++;
888
+ var umschalter = e('button', 'aufklappknopf');
889
+ umschalter.type = 'button';
890
+ umschalter.setAttribute('aria-expanded', offen ? 'true' : 'false');
891
+ umschalter.setAttribute('aria-controls', koerperId);
892
+ umschalter.setAttribute('aria-label', 'Skills des Bundles ' + b.name + ' ein- oder ausklappen');
893
+ var pfeil = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
894
+ pfeil.setAttribute('viewBox', '0 0 16 16');
895
+ pfeil.setAttribute('class', 'akkordeonpfeil dunkel');
896
+ pfeil.setAttribute('aria-hidden', 'true');
897
+ pfeil.innerHTML = '<path d="M4 6.5 8 10.5 12 6.5"/>';
898
+ umschalter.appendChild(pfeil);
899
+ aktionen.appendChild(umschalter);
900
+ kopf.appendChild(aktionen);
822
901
  gruppe.appendChild(kopf);
823
902
  var ul = e('ul', 'linkliste');
824
- eintraege.forEach(function (ei) { ul.appendChild(linkZeile(ei, z, b)); });
903
+ ul.id = koerperId;
904
+ ul.hidden = !offen;
905
+ zeilen.forEach(function (li) { ul.appendChild(li); });
825
906
  gruppe.appendChild(ul);
907
+ function schalte() {
908
+ offen = !offen;
909
+ BUNDLE_OFFEN[schluessel] = offen;
910
+ umschalter.setAttribute('aria-expanded', offen ? 'true' : 'false');
911
+ ul.hidden = !offen;
912
+ }
913
+ umschalter.addEventListener('click', schalte);
914
+ // Der Kopf schaltet mit, außer über seine Knöpfe (wie der Kopf einer Ziel-Karte).
915
+ kopf.addEventListener('click', function (ev) {
916
+ if (ev.target.closest('button, a') === null) schalte();
917
+ });
826
918
  return gruppe;
827
919
  }
828
920
 
829
- /** Ein Bundle als einzelner Streifen (Chip „Alle" einer Verzeichnis-Karte, E69/E53/E95): die
830
- Harness-Zeichen aller Harnesses, in denen es liegt (wie in den Skill-Zeilen), Name, Mitglieder,
831
- Entfernen für alle diese Harnesses. `treffer`: [{b, z}], je Harness einer, gleicher Name und
832
- gleiche Quelle. Mitglieder: die des ersten Treffers, abweichende anderer Harnesses folgen. */
833
- function bundleStreifen(treffer) {
834
- var b = treffer[0].b;
835
- var streifen = e('div', 'bundlegruppe');
836
- var kopf = e('div', 'bundlekopf');
837
- var titel = e('span', 'bundletitel');
838
- var harnesse = [];
839
- treffer.forEach(function (t) { if (harnesse.indexOf(t.z.ziel.harness) === -1) harnesse.push(t.z.ziel.harness); });
840
- harnesse.sort(function (x, y) { return harnessName(x).localeCompare(harnessName(y), 'de'); });
841
- titel.appendChild(harnesseZeichenSammel(harnesse));
842
- titel.appendChild(e('span', 'etikett', 'Bundle'));
843
- titel.appendChild(e('b', 'bundlename', b.name));
844
- kopf.appendChild(titel);
845
- var mitglieder = [];
846
- treffer.forEach(function (t) {
847
- t.b.skills.forEach(function (n) { if (mitglieder.indexOf(n) === -1) mitglieder.push(n); });
848
- });
849
- kopf.appendChild(e('span', 'bundlemitglieder', mitglieder.join(', ')));
850
- var weg = e('button', 'knopf leise klein', 'Entfernen');
851
- weg.type = 'button';
852
- weg.setAttribute('aria-label', 'Bundle ' + b.name + ' aus '
853
- + treffer.map(function (t) { return zielTitel(t.z); }).join(', ') + ' entfernen');
854
- weg.addEventListener('click', function () {
855
- if (treffer.length === 1) frageBundleEntfernen(b, treffer[0].z.ziel);
856
- else frageBundleEntfernenAlle(b.name, mitglieder, treffer);
921
+ /** Zeile eines nicht verlinkten Mitglieds (E114 L3): blass, „nicht verlinkt", „Wieder verlinken".
922
+ `skill` aus dem Katalog oder null, wenn die Definition oder Quelle nicht lesbar ist. */
923
+ function nichtVerlinktZeile(name, skill, wiederVerlinken) {
924
+ var li = e('li', 'nichtverlinkt');
925
+ li.appendChild(e('span'));
926
+ li.appendChild(e('span', 'name', name));
927
+ if (skill) li.appendChild(wasZelle(skill));
928
+ else {
929
+ var feld = e('span', 'wasfeld');
930
+ feld.appendChild(e('span', 'was fehlt', 'Beschreibung nicht lesbar, die Quelle fehlt hier'));
931
+ li.appendChild(feld);
932
+ }
933
+ li.appendChild(e('span', 'quelle', 'nicht verlinkt'));
934
+ var knopf = e('button', 'knopf leise klein', 'Wieder verlinken');
935
+ knopf.type = 'button';
936
+ knopf.setAttribute('aria-label', name + ' wieder verlinken');
937
+ knopf.addEventListener('click', wiederVerlinken);
938
+ li.appendChild(knopf);
939
+ return li;
940
+ }
941
+
942
+ /** Ein installiertes Bundle unter dem Harness-Chip (E69, E114): alle Mitglieder in der Gruppe,
943
+ verlinkte als gewöhnliche Link-Zeilen, nicht verlinkte blass mit „Wieder verlinken". */
944
+ function bundleGruppe(b, z, eintraege) {
945
+ var zeilen = eintraege.map(function (ei) { return linkZeile(ei, z, b); });
946
+ (b.nichtVerlinkt || []).slice().sort(function (x, y) { return x.localeCompare(y, 'de'); }).forEach(function (name) {
947
+ zeilen.push(nichtVerlinktZeile(name, bundleSkillNachName(b, name), function () {
948
+ bundleAufnehmen(b, [z.ziel], [name]);
949
+ }));
857
950
  });
858
- kopf.appendChild(weg);
859
- streifen.appendChild(kopf);
860
- return streifen;
951
+ return bundleRahmen(b, null, b.skills.length, bundleAlleMitglieder(b).length, [z.ziel], zeilen, function () {
952
+ frageBundleEntfernen(b, z.ziel);
953
+ }, 'ziel\u0000' + zielKey(z.ziel) + '\u0000' + b.name + '\u0000' + b.quelleText);
861
954
  }
862
955
 
863
956
  function linkTeil(z) {
@@ -881,8 +974,13 @@
881
974
  }
882
975
  }
883
976
 
977
+ // Bundles zuerst (E69): sie stehen als Gruppe über den Einzel-Zeilen, ihre Mitglieder liegen
978
+ // in der Gruppe statt in der allgemeinen Liste. Was zum Bundle gehört, sagt der Kern. Eine Gruppe
979
+ // steht auch, wenn kein Mitglied mehr verlinkt ist („0 von N", E114).
980
+ var bundles = (z.bundles || []).filter(function (b) { return bundleAlleMitglieder(b).length > 0; });
981
+
884
982
  var fussContainer;
885
- if (eigene.length === 0) {
983
+ if (eigene.length === 0 && bundles.length === 0) {
886
984
  var leer = e('div', 'leerzeile');
887
985
  leer.appendChild(e('p', 'ohne', 'Hier liegt noch nichts.'));
888
986
  teil.appendChild(leer);
@@ -898,9 +996,6 @@
898
996
  kopf.appendChild(e('span'));
899
997
  teil.appendChild(kopf);
900
998
 
901
- // Bundles zuerst (E69): sie stehen als Gruppe über den Einzel-Zeilen, ihre Mitglieder liegen
902
- // in der Gruppe statt in der allgemeinen Liste. Was zum Bundle gehört, sagt der Kern.
903
- var bundles = (z.bundles || []).filter(function (b) { return b.skills.length > 0; });
904
999
  var bundleVon = {};
905
1000
  bundles.forEach(function (b) { b.skills.forEach(function (n) { bundleVon[n] = b; }); });
906
1001
  bundles
@@ -931,7 +1026,7 @@
931
1026
 
932
1027
  // Ohne eigene Links (leer-Fall) steht der MCP-Abschnitt hinter der Leerzeile, die dort schon den
933
1028
  // Kartenfuß bildet (Nachricht + Knöpfe in einer Zeile, wie überall sonst im leeren Zustand).
934
- if (eigene.length === 0) {
1029
+ if (eigene.length === 0 && bundles.length === 0) {
935
1030
  var mcpAbschnOhneTabelle = mcpAbschnitt(z.ziel);
936
1031
  if (mcpAbschnOhneTabelle) teil.appendChild(mcpAbschnOhneTabelle);
937
1032
  }
@@ -1220,7 +1315,37 @@
1220
1315
  verzMcpZiele.push(z);
1221
1316
  });
1222
1317
 
1223
- if (reihenfolge.length === 0) {
1318
+ // Bundles unter „Alle" (E69, E95, E114 L1): eine aufklappbare Gruppe je Bundle, wie unter dem
1319
+ // Harness-Chip, aber über alle Harnesses am Ort zusammengefasst: Schlüssel ist Name plus Quelle,
1320
+ // gleichnamige Bundles aus zwei Quellen bleiben getrennt. Eine Gruppe steht auch, wenn kein
1321
+ // Mitglied mehr verlinkt ist („0 von N").
1322
+ var bundleGruppen = {}, bundleReihenfolge = [];
1323
+ teile.forEach(function (z) {
1324
+ (z.bundles || [])
1325
+ .filter(function (b) { return bundleAlleMitglieder(b).length > 0; })
1326
+ .forEach(function (b) {
1327
+ var schluessel = b.name + '\u0000' + b.quelleText;
1328
+ if (!bundleGruppen[schluessel]) { bundleGruppen[schluessel] = []; bundleReihenfolge.push(schluessel); }
1329
+ bundleGruppen[schluessel].push({ b: b, z: z });
1330
+ });
1331
+ });
1332
+ // Eine Tabellenzeile geht in einer Gruppe auf, wenn ihr Name in JEDEM Harness der Zeile ein
1333
+ // verlinktes Mitglied dieses Bundles ist — dann steht sie nur in der Gruppe, nicht zusätzlich in
1334
+ // der Tabelle (E114 L1). Hängt derselbe Skill in einem Harness als Einzel-Link, bleibt die
1335
+ // Zeile in der Tabelle. Die Zählung im Kartenkopf (E71) bleibt davon unberührt.
1336
+ var zeileInGruppe = {};
1337
+ reihenfolge.forEach(function (key) {
1338
+ var row = zeilen[key];
1339
+ bundleReihenfolge.forEach(function (gk) {
1340
+ if (zeileInGruppe[key]) return;
1341
+ var ganz = row.teile.every(function (z) {
1342
+ return bundleGruppen[gk].some(function (t) { return t.z === z && t.b.skills.indexOf(row.name) > -1; });
1343
+ });
1344
+ if (ganz) zeileInGruppe[key] = gk;
1345
+ });
1346
+ });
1347
+
1348
+ if (reihenfolge.length === 0 && bundleReihenfolge.length === 0) {
1224
1349
  var leer = e('div', 'leerzeile');
1225
1350
  leer.appendChild(e('p', 'ohne', 'Hier liegt noch nichts.'));
1226
1351
  leer.appendChild(alleSkillsKnopf(pfad, teile));
@@ -1236,27 +1361,6 @@
1236
1361
  return teil;
1237
1362
  }
1238
1363
 
1239
- // Bundles unter „Alle" (E69): die Zeilen bleiben nach Name und Quelle gebündelt (E53), ein
1240
- // Bundle gehört aber zu genau einem Harness — deshalb hier ein Streifen je Bundle mit seinem
1241
- // Harness-Zeichen statt einer Umgruppierung der Tabelle. Unter dem Harness-Chip zeigt linkTeil
1242
- // die volle Gruppe über den Zeilen.
1243
- // Ein Bundle, das in mehreren Harnesses am selben Ort liegt, ist hier ein Streifen (E95), wie
1244
- // die Skill-Zeilen: Schlüssel ist Name plus Quelle, gleichnamige Bundles aus zwei Quellen
1245
- // bleiben getrennt.
1246
- var bundleGruppen = {}, bundleReihenfolge = [];
1247
- teile.forEach(function (z) {
1248
- (z.bundles || [])
1249
- .filter(function (b) { return b.skills.length > 0; })
1250
- .forEach(function (b) {
1251
- var schluessel = b.name + '\u0000' + b.quelleText;
1252
- if (!bundleGruppen[schluessel]) { bundleGruppen[schluessel] = []; bundleReihenfolge.push(schluessel); }
1253
- bundleGruppen[schluessel].push({ b: b, z: z });
1254
- });
1255
- });
1256
- bundleReihenfolge
1257
- .sort(function (a, b) { return bundleGruppen[a][0].b.name.localeCompare(bundleGruppen[b][0].b.name, 'de'); })
1258
- .forEach(function (k) { teil.appendChild(bundleStreifen(bundleGruppen[k])); });
1259
-
1260
1364
  var kopf = e('div', 'linkkopf');
1261
1365
  kopf.appendChild(e('span'));
1262
1366
  kopf.appendChild(e('span', 'etikett', 'Skill'));
@@ -1265,16 +1369,17 @@
1265
1369
  kopf.appendChild(e('span'));
1266
1370
  teil.appendChild(kopf);
1267
1371
 
1268
- var ul = e('ul', 'linkliste');
1269
- reihenfolge
1372
+ var sortiert = reihenfolge
1270
1373
  .slice()
1271
1374
  .sort(function (ka, kb) {
1272
1375
  var a = zeilen[ka], b = zeilen[kb];
1273
1376
  if (a.defekt !== b.defekt) return a.defekt ? -1 : 1;
1274
1377
  return a.name.localeCompare(b.name, 'de');
1275
- })
1276
- .forEach(function (key) {
1277
- var row = zeilen[key];
1378
+ });
1379
+
1380
+ /** Eine gebündelte Zeile unter „Alle" (E53). `bundleName` gesetzt: sie steht in einer
1381
+ Bundle-Gruppe, das Entfernen nimmt den Skill dort heraus (E114). */
1382
+ function alleZeile(row, bundleName) {
1278
1383
  var harnesse = [];
1279
1384
  row.teile.forEach(function (z) { if (harnesse.indexOf(z.ziel.harness) === -1) harnesse.push(z.ziel.harness); });
1280
1385
  harnesse.sort(function (a, b) { return harnessName(a).localeCompare(harnessName(b), 'de'); });
@@ -1325,14 +1430,64 @@
1325
1430
  // Namens nebeneinander, sobald ein Skill je Harness aus verschiedener Quelle hängt.
1326
1431
  weg.setAttribute('aria-label', 'Link auf ' + row.name + ' in '
1327
1432
  + entfernbar.map(function (z) { return harnessName(z.ziel.harness); }).join(', ') + ' entfernen');
1328
- weg.addEventListener('click', function () { frageEntfernenAlle(row.name, pfad, entfernbar, nichtEntfernbar); });
1433
+ weg.addEventListener('click', function () {
1434
+ frageEntfernenAlle(row.name, pfad, entfernbar, nichtEntfernbar, bundleName);
1435
+ });
1329
1436
  li.appendChild(weg);
1330
1437
  } else {
1331
1438
  li.appendChild(e('span', 'kein-weg', 'nur über Plugin „' + pluginNamen.join('“, „') + '“'));
1332
1439
  }
1333
- ul.appendChild(li);
1440
+ return li;
1441
+ }
1442
+
1443
+ // Erst die Bundle-Gruppen, darunter die übrigen Zeilen (wie unter dem Harness-Chip, E69).
1444
+ bundleReihenfolge
1445
+ .sort(function (a, b) { return bundleGruppen[a][0].b.name.localeCompare(bundleGruppen[b][0].b.name, 'de'); })
1446
+ .forEach(function (gk) {
1447
+ var treffer = bundleGruppen[gk];
1448
+ var b = treffer[0].b;
1449
+ var harnesse = [];
1450
+ treffer.forEach(function (t) { if (harnesse.indexOf(t.z.ziel.harness) === -1) harnesse.push(t.z.ziel.harness); });
1451
+ harnesse.sort(function (x, y) { return harnessName(x).localeCompare(harnessName(y), 'de'); });
1452
+ // Mitglieder über alle Harnesses ohne Doppelte; verlinkt zählt, was in jedem Harness liegt.
1453
+ var mitglieder = [];
1454
+ treffer.forEach(function (t) {
1455
+ bundleAlleMitglieder(t.b).forEach(function (n) { if (mitglieder.indexOf(n) === -1) mitglieder.push(n); });
1456
+ });
1457
+ var ueberall = mitglieder.filter(function (n) {
1458
+ return treffer.every(function (t) { return t.b.skills.indexOf(n) > -1; });
1459
+ });
1460
+ var gruppenZeilen = sortiert
1461
+ .filter(function (key) { return zeileInGruppe[key] === gk; })
1462
+ .map(function (key) { return alleZeile(zeilen[key], b.name); });
1463
+ // Nirgends verlinkte Mitglieder als blasse Zeile; „Wieder verlinken" in jedem Harness, in
1464
+ // dem das Mitglied fehlt.
1465
+ mitglieder
1466
+ .filter(function (n) { return treffer.every(function (t) { return t.b.skills.indexOf(n) === -1; }); })
1467
+ .sort(function (x, y) { return x.localeCompare(y, 'de'); })
1468
+ .forEach(function (n) {
1469
+ gruppenZeilen.push(nichtVerlinktZeile(n, bundleSkillNachName(b, n), function () {
1470
+ bundleAufnehmen(b, treffer
1471
+ .filter(function (t) { return (t.b.nichtVerlinkt || []).indexOf(n) > -1; })
1472
+ .map(function (t) { return t.z.ziel; }), [n]);
1473
+ }));
1474
+ });
1475
+ var verlinkteNamen = [];
1476
+ treffer.forEach(function (t) {
1477
+ t.b.skills.forEach(function (n) { if (verlinkteNamen.indexOf(n) === -1) verlinkteNamen.push(n); });
1478
+ });
1479
+ teil.appendChild(bundleRahmen(b, harnesse, ueberall.length, mitglieder.length,
1480
+ treffer.map(function (t) { return t.z.ziel; }), gruppenZeilen, function () {
1481
+ if (treffer.length === 1) frageBundleEntfernen(b, treffer[0].z.ziel);
1482
+ else frageBundleEntfernenAlle(b.name, verlinkteNamen, treffer);
1483
+ }, 'verz\u0000' + pfad + '\u0000' + gk));
1334
1484
  });
1335
- teil.appendChild(ul);
1485
+
1486
+ var ul = e('ul', 'linkliste');
1487
+ sortiert
1488
+ .filter(function (key) { return !zeileInGruppe[key]; })
1489
+ .forEach(function (key) { ul.appendChild(alleZeile(zeilen[key], null)); });
1490
+ if (ul.childNodes.length > 0) teil.appendChild(ul);
1336
1491
 
1337
1492
  // MCP-Abschnitt (E61-GUI-Nachtrag): auf Kartenebene unter „Alle", wie unter dem Harness-Chip,
1338
1493
  // der an diesem Pfad MCP kann.
@@ -1638,28 +1793,42 @@
1638
1793
  // entfernenKontext.eintraege: [{harness, ort}], immer eines bei frageEntfernen (Einzel-Ziel),
1639
1794
  // mehrere bei frageEntfernenAlle (E53 Pkt. 2, „Alle"-Zeile über mehrere Harnesses).
1640
1795
  var entfernenKontext = null;
1641
- /** `bundleName` gesetzt heißt: der Link gehört zu einem Bundle, das Entfernen zerbricht es (E69). */
1796
+ /** Satz für den Entfernen-Dialog einer Zeile in einer Bundle-Gruppe (E114 L2): das Bundle bleibt. */
1797
+ function herausnehmenSatz(bundleName) {
1798
+ return ' Der Skill wird damit aus dem Bundle „' + bundleName + '“ herausgenommen: das Bundle bleibt, '
1799
+ + 'das Aktualisieren legt den Link nicht wieder an. „Wieder verlinken“ oder „Vervollständigen“ am Bundle holt ihn zurück.';
1800
+ }
1801
+
1802
+ /** `bundleName` gesetzt heißt: der Link gehört zu einem Bundle, das Entfernen nimmt ihn dort
1803
+ heraus (E114, früher Zerbrechen nach E69). */
1642
1804
  function frageEntfernen(name, ziel, bundleName) {
1643
1805
  entfernenKontext = { art: 'link', name: name, eintraege: [{ harness: ziel.harness, ort: ziel.ort }] };
1644
1806
  $('dx-titel').textContent = 'Link auf ' + name + ' entfernen?';
1645
1807
  var text = 'Der Link in ' + zielTitel({ ziel: ziel }) + ' verschwindet. Der Checkout bleibt liegen, bis kein Link mehr darauf zeigt.';
1646
- if (bundleName) {
1647
- text += ' Damit zerbricht das Bundle „' + bundleName + '“ hier: seine übrigen Links bleiben liegen, aber nicht mehr als Bundle.';
1648
- }
1808
+ if (bundleName) text += herausnehmenSatz(bundleName);
1649
1809
  $('dx-text').textContent = text;
1650
1810
  zeige($('d-entfernen'));
1651
1811
  }
1652
1812
 
1653
- /** Entfernen des ganzen Bundles an einem Ziel (E69): alle seine Links auf einmal. */
1813
+ /** Zusatz für die Bundle-Dialoge (E114): was schon nicht mehr verlinkt ist, bleibt unberührt. */
1814
+ function nichtVerlinktSatz(namen) {
1815
+ return namen.length === 0 ? '' : ' Nicht verlinkt sind schon: ' + namen.join(', ') + '.';
1816
+ }
1817
+
1818
+ /** Entfernen des ganzen Bundles an einem Ziel (E69, E114 L2): alle noch verlinkten Mitglieder auf
1819
+ einmal, auch nach Teil-Löschung. */
1654
1820
  function frageBundleEntfernen(b, ziel) {
1655
1821
  entfernenKontext = { art: 'bundle', bundle: b.name, ziele: [ziel] };
1656
1822
  $('dx-titel').textContent = 'Bundle ' + b.name + ' entfernen?';
1657
- $('dx-text').textContent = zahlwort(b.skills.length, 'Link', 'Links') + ' aus ' + zielTitel({ ziel: ziel })
1658
- + ' verschwinden: ' + b.skills.join(', ') + '. Die Checkouts bleiben liegen, bis kein Link mehr darauf zeigt.';
1823
+ $('dx-text').textContent = (b.skills.length === 0
1824
+ ? 'Kein Link mehr da, aus ' + zielTitel({ ziel: ziel }) + ' verschwindet nur der Bundle-Verweis.'
1825
+ : zahlwort(b.skills.length, 'Link', 'Links') + ' aus ' + zielTitel({ ziel: ziel })
1826
+ + ' verschwinden: ' + b.skills.join(', ') + '. Die Checkouts bleiben liegen, bis kein Link mehr darauf zeigt.')
1827
+ + nichtVerlinktSatz(b.nichtVerlinkt || []);
1659
1828
  zeige($('d-entfernen'));
1660
1829
  }
1661
- /** Entfernen eines Bundles in allen Harnesses seines Streifens (E95): `treffer` wie bei
1662
- bundleStreifen, der Dialog nennt die Harnesses mit ihrer Linkzahl und die Mitglieder. */
1830
+ /** Entfernen eines Bundles in allen Harnesses seiner Gruppe unter „Alle" (E95): `treffer` je
1831
+ Harness {b, z}, der Dialog nennt die Harnesses mit ihrer Linkzahl und die verlinkten Mitglieder. */
1663
1832
  function frageBundleEntfernenAlle(name, mitglieder, treffer) {
1664
1833
  entfernenKontext = { art: 'bundle', bundle: name, ziele: treffer.map(function (t) { return t.z.ziel; }) };
1665
1834
  var proHarness = treffer.map(function (t) {
@@ -1667,13 +1836,15 @@
1667
1836
  });
1668
1837
  $('dx-titel').textContent = 'Bundle ' + name + ' entfernen?';
1669
1838
  $('dx-text').textContent = 'Das Bundle verschwindet in ' + treffer.length + ' Harnesses (' + proHarness.join(', ')
1670
- + '): ' + mitglieder.join(', ') + '. Die Checkouts bleiben liegen, bis kein Link mehr darauf zeigt.';
1839
+ + ')' + (mitglieder.length === 0 ? '.' : ': ' + mitglieder.join(', ') + '.')
1840
+ + ' Die Checkouts bleiben liegen, bis kein Link mehr darauf zeigt.';
1671
1841
  zeige($('d-entfernen'));
1672
1842
  }
1673
1843
  /** Entfernen einer „Alle"-Zeile (E53 Pkt. 2): entfernbareZiele sind die Ziele (voller z.ziel), in
1674
1844
  denen der Link weg soll, nichtEntfernbareHarnesse die, in denen er nur über ein Plugin hängt
1675
- und liegen bleibt. Der Dialog nennt beides. */
1676
- function frageEntfernenAlle(name, pfad, entfernbareZiele, nichtEntfernbareHarnesse) {
1845
+ und liegen bleibt. Der Dialog nennt beides. `bundleName` gesetzt: die Zeile steht in einer
1846
+ Bundle-Gruppe, der Skill wird dort herausgenommen (E114). */
1847
+ function frageEntfernenAlle(name, pfad, entfernbareZiele, nichtEntfernbareHarnesse, bundleName) {
1677
1848
  entfernenKontext = {
1678
1849
  art: 'link',
1679
1850
  name: name,
@@ -1686,6 +1857,7 @@
1686
1857
  text += ' In ' + nichtEntfernbareHarnesse.map(harnessName).join(', ') + ' bleibt er bestehen (nur über das Plugin).';
1687
1858
  }
1688
1859
  text += ' Der Checkout bleibt liegen, bis kein Link mehr darauf zeigt.';
1860
+ if (bundleName) text += herausnehmenSatz(bundleName);
1689
1861
  $('dx-text').textContent = text;
1690
1862
  zeige($('d-entfernen'));
1691
1863
  }
@@ -2328,13 +2500,14 @@
2328
2500
  var txt = e('span');
2329
2501
  txt.appendChild(e('b', null, bundle.name));
2330
2502
  txt.appendChild(e('span', 'notiz', bundle.beschreibung));
2331
- var herkunft = e('span', 'mono', zahlwort(bundle.mitglieder, 'Skill', 'Skills')
2503
+ var herkunft = e('span', 'mono', zahlwort(bundle.mitglieder.length, 'Skill', 'Skills')
2332
2504
  + ' aus ' + zahlwort(bundle.quellen.length, 'Quelle', 'Quellen') + ' · ' + bundle.quelleName);
2333
2505
  herkunft.title = bundle.quellen.join('\n');
2334
2506
  txt.appendChild(herkunft);
2335
2507
  if (ueberall) txt.appendChild(e('span', 'notiz', zieleZahl > 1 ? 'Hängt schon überall.' : 'Hängt hier schon.'));
2336
2508
  else if (schon > 0) txt.appendChild(e('span', 'notiz', 'Hängt schon in ' + schon + ' von ' + zieleZahl + ' Harnesses.'));
2337
2509
  label.appendChild(txt);
2510
+ bundleAufklappbereich(bundle, label, txt, i);
2338
2511
  box.appendChild(label);
2339
2512
  });
2340
2513
  $('dsk-bundlebox').hidden = BUNDLES.length === 0 && BUNDLES_KAPUTT.length === 0;
@@ -2346,6 +2519,54 @@
2346
2519
  }).join('\n');
2347
2520
  }
2348
2521
 
2522
+ /** Aufklappbereich einer Bundle-Wahlzeile (E114 L1, F133=O3): die Skills des Bundles mit ihrer
2523
+ Beschreibung, vor der Installation. Muster und Verhalten wie skillAufklappbereich (E92): Knopf
2524
+ oben rechts am Label, Körper als eigenes Grid-Kind, preventDefault gegen das Umschalten der
2525
+ Checkbox, je Dialog-Öffnung zugeklappt. Name und Beschreibung kommen aus dem Skill-Katalog;
2526
+ liegt ein Mitglied in einer noch nicht eingerichteten Quelle, steht dort sein Pfad. */
2527
+ function bundleAufklappbereich(bundle, label, txt, index) {
2528
+ var koerperId = 'dsk-bundlekoerper-' + index;
2529
+ var knopf = e('button', 'aufklappknopf');
2530
+ knopf.type = 'button';
2531
+ knopf.setAttribute('aria-expanded', 'false');
2532
+ knopf.setAttribute('aria-controls', koerperId);
2533
+ knopf.setAttribute('aria-label', 'Skills des Bundles ' + bundle.name + ' ein- oder ausklappen');
2534
+ var pfeil = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
2535
+ pfeil.setAttribute('viewBox', '0 0 16 16');
2536
+ pfeil.setAttribute('class', 'akkordeonpfeil dunkel');
2537
+ pfeil.setAttribute('aria-hidden', 'true');
2538
+ pfeil.innerHTML = '<path d="M4 6.5 8 10.5 12 6.5"/>';
2539
+ knopf.appendChild(pfeil);
2540
+
2541
+ var koerper = e('div', 'wahlkoerper');
2542
+ koerper.id = koerperId;
2543
+ koerper.hidden = true;
2544
+ bundle.mitglieder.forEach(function (m) {
2545
+ var skill = bundleMitgliedSkill(m);
2546
+ var zeile = e('p', 'skilldetail bundlemitglied');
2547
+ if (skill) {
2548
+ zeile.appendChild(e('b', null, skill.name + ': '));
2549
+ zeile.appendChild(document.createTextNode(skill.beschreibung || 'keine Beschreibung in der SKILL.md'));
2550
+ } else {
2551
+ zeile.appendChild(e('b', null, m.pfad + ': '));
2552
+ zeile.appendChild(document.createTextNode('aus ' + m.quelleText + ', die Quelle ist hier noch nicht eingerichtet'));
2553
+ }
2554
+ koerper.appendChild(zeile);
2555
+ });
2556
+
2557
+ knopf.addEventListener('click', function (ev) {
2558
+ ev.preventDefault();
2559
+ var war = knopf.getAttribute('aria-expanded') === 'true';
2560
+ knopf.setAttribute('aria-expanded', war ? 'false' : 'true');
2561
+ koerper.hidden = war;
2562
+ });
2563
+ koerper.addEventListener('click', function (ev) { ev.preventDefault(); });
2564
+
2565
+ txt.classList.add('mitknopf');
2566
+ label.appendChild(knopf);
2567
+ label.appendChild(koerper);
2568
+ }
2569
+
2349
2570
  /** Wie oft ein Bundle in den angesteuerten Zielen schon installiert ist (Name plus Quelle, E69). */
2350
2571
  function bundleHaengtIn(teile, bundle) {
2351
2572
  return teile.filter(function (z) {
@@ -798,6 +798,14 @@ dialog::backdrop{background:rgba(9,45,37,.62)}
798
798
  .bundlemitglieder{font-family:var(--mono);font-size:16px;letter-spacing:-.012em;color:var(--tinte-weich)}
799
799
  .bundlekopf .knopf{margin-left:auto}
800
800
  .bundlegruppe .linkliste li:last-child{border-bottom:1px solid var(--strich-zart)}
801
+ /* E114: Knöpfe und Aufklapp-Umschalter rechts im Kopf; der Kopf ist zugleich Klickfläche. */
802
+ .bundlekopf{cursor:pointer}
803
+ .bundleaktionen{margin-left:auto;display:inline-flex;align-items:center;gap:8px}
804
+ .bundleaktionen .knopf{margin-left:0}
805
+ .bundlegruppe .linkliste[hidden]{display:none}
806
+ /* Nicht verlinktes Mitglied (E114 L3): blass, der Knopf bleibt lesbar. */
807
+ .linkliste li.nichtverlinkt .name,.linkliste li.nichtverlinkt .was{color:var(--tinte-leise)}
808
+ .linkliste li.nichtverlinkt .quelle{color:var(--tinte-leise);font-style:italic}
801
809
 
802
810
  /* Bundle- und Skill-Sektion im Dialog „Skills hinzufügen" (E69): zwei benannte Gruppen unter
803
811
  demselben Suchfeld. */
@@ -659,7 +659,7 @@ export class Anwendungsfaelle {
659
659
  await this.p.ziele.linkEntfernen(ziel, verzeichnis, name);
660
660
  entfernteLinks.push(name);
661
661
  }
662
- if (Anwendungsfaelle.bundlesZerbrechen(nachZustand, ziel, entfernteLinks).length > 0)
662
+ if (Anwendungsfaelle.bundleVerweiseFallenLassen(nachZustand, ziel))
663
663
  bundlesGeaendert = true;
664
664
  const konfigurationZurueckgebaut = kannZurueckbauen(harness) ? await harness.zurueckbauen(ziel.ort) : false;
665
665
  zielErgebnisse.push({ ziel, entfernteLinks, konfigurationZurueckgebaut });
@@ -801,12 +801,12 @@ export class Anwendungsfaelle {
801
801
  throw new QuelleAufraeumenLokaleAenderungen(inventar.checkoutsMitLokalenAenderungen);
802
802
  for (const link of inventar.links)
803
803
  await this.p.ziele.linkEntfernen(link.ziel, link.verzeichnis, link.name);
804
- // Wo dabei ein Bundle-Mitglied fiel, zerbricht das Bundle in diesem Ziel (E69), wie beim
805
- // einzelnen `linkEntfernen`: die übrigen Links bleiben als Einzel-Installationen liegen.
804
+ // Wo dabei ein Bundle-Mitglied fiel, gilt es als herausgenommen (E114), wie beim einzelnen
805
+ // `linkEntfernen`: das Bundle bleibt, der Abgleich legt das Mitglied nicht wieder an.
806
806
  const zustand = await this.p.zustand.lesen();
807
807
  let bundlesGeaendert = false;
808
808
  for (const link of inventar.links)
809
- if (Anwendungsfaelle.bundlesZerbrechen(zustand, link.ziel, [link.name]).length > 0)
809
+ if (Anwendungsfaelle.bundleMitgliederHerausnehmen(zustand, link.ziel, [link.name]).length > 0)
810
810
  bundlesGeaendert = true;
811
811
  if (bundlesGeaendert)
812
812
  await this.p.zustand.schreiben(zustand);
@@ -1122,8 +1122,11 @@ export class Anwendungsfaelle {
1122
1122
  }
1123
1123
  return angelegt;
1124
1124
  }
1125
- /** Die Zugehörigkeit der Links eines Bundles im Ziel merken (E69), einen alten Eintrag ersetzend. */
1126
- async bundleInstallationMerken(ziel, bundle, skills) {
1125
+ /**
1126
+ * Die Zugehörigkeit der Links eines Bundles im Ziel merken (E69), einen alten Eintrag ersetzend.
1127
+ * `herausgenommen` (E114) wird nur geschrieben, wenn es etwas enthält.
1128
+ */
1129
+ async bundleInstallationMerken(ziel, bundle, skills, herausgenommen = []) {
1127
1130
  const zustand = await this.p.zustand.lesen();
1128
1131
  const schluessel = zielSchluessel(ziel);
1129
1132
  const eigener = bundleSchluessel(bundle);
@@ -1135,27 +1138,43 @@ export class Anwendungsfaelle {
1135
1138
  bundle: bundle.name,
1136
1139
  quelle: bundle.quelle,
1137
1140
  skills,
1141
+ ...(herausgenommen.length === 0 ? {} : { herausgenommen }),
1138
1142
  });
1139
1143
  await this.p.zustand.schreiben(zustand);
1140
1144
  }
1141
1145
  /**
1142
- * Zerbrechen (E69): Wird ein einzelner Link entfernt, der zu einem Bundle gehört, zerbricht das
1143
- * Bundle in diesem Ziel — die übrigen Links werden Einzel-Installationen, der Bundle-Verweis
1144
- * fällt weg. Ändert nur den übergebenen Zustand, geschrieben wird er vom Aufrufer, der ohnehin
1145
- * schreibt. Gibt die Namen der zerbrochenen Bundles zurück, damit die Oberfläche es sagen kann.
1146
+ * Herausnehmen (E114 L2, ersetzt das Zerbrechen aus E69): Wird ein einzelner Link entfernt, der
1147
+ * zu einem Bundle gehört, bleibt das Bundle im Ziel und merkt sich das Mitglied als herausgenommen
1148
+ * — auch wenn danach keines mehr verlinkt ist („0 von N", weiter entfernbar). Ändert nur den
1149
+ * übergebenen Zustand, geschrieben wird er vom Aufrufer. Gibt die Namen der betroffenen Bundles
1150
+ * zurück, damit die Oberfläche es sagen kann.
1146
1151
  */
1147
- static bundlesZerbrechen(zustand, ziel, namen) {
1152
+ static bundleMitgliederHerausnehmen(zustand, ziel, namen) {
1148
1153
  const schluessel = zielSchluessel(ziel);
1149
- const zerbrochen = [];
1150
- zustand.bundleInstallationen = zustand.bundleInstallationen.filter((installation) => {
1154
+ const betroffen = [];
1155
+ for (const installation of zustand.bundleInstallationen) {
1151
1156
  if (zielSchluessel(Anwendungsfaelle.installationZiel(installation)) !== schluessel)
1152
- return true;
1153
- if (!installation.skills.some((name) => namen.includes(name)))
1154
- return true;
1155
- zerbrochen.push(installation.bundle);
1156
- return false;
1157
- });
1158
- return zerbrochen;
1157
+ continue;
1158
+ const raus = installation.skills.filter((name) => namen.includes(name));
1159
+ if (raus.length === 0)
1160
+ continue;
1161
+ installation.skills = installation.skills.filter((name) => !raus.includes(name));
1162
+ installation.herausgenommen = [...(installation.herausgenommen ?? []), ...raus];
1163
+ betroffen.push(installation.bundle);
1164
+ }
1165
+ return betroffen;
1166
+ }
1167
+ /**
1168
+ * Bundle-Verweise eines Ziels fallen lassen, wenn „Säubern" dort alle Links abbaut (E69). Kein
1169
+ * Herausnehmen: Säubern räumt den Harness ab, ein Bundle mit „0 von N" bliebe ein Rest, den der
1170
+ * Nutzer gerade loswerden wollte. Darum fallen alle Verweise des Ziels, auch die von Bundles, von
1171
+ * denen gerade kein Link mehr lag.
1172
+ */
1173
+ static bundleVerweiseFallenLassen(zustand, ziel) {
1174
+ const schluessel = zielSchluessel(ziel);
1175
+ const vorher = zustand.bundleInstallationen.length;
1176
+ zustand.bundleInstallationen = zustand.bundleInstallationen.filter((installation) => zielSchluessel(Anwendungsfaelle.installationZiel(installation)) !== schluessel);
1177
+ return zustand.bundleInstallationen.length !== vorher;
1159
1178
  }
1160
1179
  /**
1161
1180
  * Bundle installieren (E69): das Bundle als Ganzes in ein Ziel. Erst wird alles geprüft — jedes
@@ -1205,9 +1224,11 @@ export class Anwendungsfaelle {
1205
1224
  return treffer[0];
1206
1225
  }
1207
1226
  /**
1208
- * Bundle entfernen (E69): alle seine Links im Ziel und den Bundle-Verweis. Entfernt wird direkt
1209
- * über den Ziele-Port, nicht über den Anwendungsfall `linkEntfernen` — der macht Zeiger-Arbeit
1210
- * (E57) und ließe das Bundle bei jedem einzelnen Link zerbrechen, das es hier gerade verlässt.
1227
+ * Bundle entfernen (E69): alle seine noch verlinkten Mitglieder im Ziel und den Bundle-Verweis,
1228
+ * auch nach Teil-Löschung (E114 L2): herausgenommene stehen nicht in `skills` und bleiben
1229
+ * unberührt, ein Link gleichen Namens an ihrer Stelle gehört nicht dem Bundle. Entfernt wird
1230
+ * direkt über den Ziele-Port, nicht über den Anwendungsfall `linkEntfernen` — der macht
1231
+ * Zeiger-Arbeit (E57) und nähme jedes Mitglied einzeln heraus.
1211
1232
  */
1212
1233
  async bundleEntfernen(args) {
1213
1234
  const harness = await this.harnessGeprueft(args.ziel.harness);
@@ -1237,6 +1258,11 @@ export class Anwendungsfaelle {
1237
1258
  * Wirkt auf das Bundle als Ganzes: Lässt sich eine Definitionsänderung nicht vollständig nachziehen
1238
1259
  * (Quelle weg, Pfad kein Skill mehr, Namenskonflikt), bleibt das Bundle im betroffenen Ziel auf dem
1239
1260
  * alten Stand und der Lauf meldet es, statt es halb nachzuziehen (F66b).
1261
+ *
1262
+ * Herausgenommene Mitglieder (E114 L5) lässt der Abgleich aus: kein Link, keine Konfliktprüfung
1263
+ * (ein anderer Link gleichen Namens an ihrer Stelle ist dann kein Konflikt). Fällt eines aus der
1264
+ * Definition, fällt es auch aus der Liste. `aufnehmen` holt herausgenommene zurück: 'alle' ist
1265
+ * „Vervollständigen" (L4), eine Namensliste „Wieder verlinken" einzelner Mitglieder (L3).
1240
1266
  */
1241
1267
  async bundleAbgleich(args) {
1242
1268
  const zustand = await this.p.zustand.lesen();
@@ -1256,7 +1282,10 @@ export class Anwendungsfaelle {
1256
1282
  // ein Fehlschlag (kein Netz) arbeitet mit dem vorhandenen Stand weiter.
1257
1283
  await this.refsNachziehen(installation.quelle);
1258
1284
  const bundle = await this.bundleAusQuelle(installation.bundle, installation.quelle);
1259
- const { funde, neue } = await this.bundleAufloesen(bundle);
1285
+ const { funde: alle, neue } = await this.bundleAufloesen(bundle);
1286
+ const aufnehmen = args?.aufnehmen;
1287
+ const herausgenommen = (installation.herausgenommen ?? []).filter((name) => aufnehmen !== 'alle' && !(aufnehmen ?? []).includes(name) && alle.some((f) => f.skill.name === name));
1288
+ const funde = alle.filter((f) => !herausgenommen.includes(f.skill.name));
1260
1289
  const verzeichnis = harness.zielVerzeichnis(ziel.ort);
1261
1290
  const { konflikte } = await this.bundleKonflikte(bundle, ziel, verzeichnis, funde);
1262
1291
  if (konflikte.length > 0)
@@ -1272,7 +1301,7 @@ export class Anwendungsfaelle {
1272
1301
  await this.p.ziele.linkEntfernen(ziel, verzeichnis, name);
1273
1302
  entfernt.push(name);
1274
1303
  }
1275
- await this.bundleInstallationMerken(ziel, bundle, namen);
1304
+ await this.bundleInstallationMerken(ziel, bundle, namen, herausgenommen);
1276
1305
  const gelegt = namen.filter((n) => !installation.skills.includes(n));
1277
1306
  ergebnis.push({
1278
1307
  ...kopf,
@@ -1486,7 +1515,8 @@ export class Anwendungsfaelle {
1486
1515
  const version = args.version;
1487
1516
  if (version === undefined) {
1488
1517
  const fund = await this.skillFinden(args.skill, args.quelle);
1489
- return await this.linkAusFund(harness, args.ziel, fund, args.ersetzen, args.lokal);
1518
+ const ergebnis = await this.linkAusFund(harness, args.ziel, fund, args.ersetzen, args.lokal);
1519
+ return Anwendungsfaelle.hinweisAnhaengen(ergebnis, await this.bundleMitgliedZurueckholen(args.ziel, fund));
1490
1520
  }
1491
1521
  if (args.lokal === true || args.ziel.ort.art !== 'arbeitsverzeichnis')
1492
1522
  throw new EinschraenkungOhneZeiger(args.ziel, version);
@@ -1500,7 +1530,57 @@ export class Anwendungsfaelle {
1500
1530
  const link = await this.linkAnlegen(harness, args.ziel, fund, args.ersetzen);
1501
1531
  await this.zeigerErgaenzen(zeigerPfad, fund, version);
1502
1532
  const hinweis = await this.harnessHinweis(harness, args.ziel.ort);
1503
- return hinweis === undefined ? link : { ...link, hinweis };
1533
+ return Anwendungsfaelle.hinweisAnhaengen(hinweis === undefined ? link : { ...link, hinweis }, await this.bundleMitgliedZurueckholen(args.ziel, fund));
1534
+ }
1535
+ /** Weitere Hinweiszeilen an ein Link-Ergebnis hängen, ohne einen vorhandenen Hinweis zu verdrängen. */
1536
+ static hinweisAnhaengen(ergebnis, zeilen) {
1537
+ if (zeilen.length === 0)
1538
+ return ergebnis;
1539
+ const alle = ergebnis.hinweis === undefined ? zeilen : [ergebnis.hinweis, ...zeilen];
1540
+ return { ...ergebnis, hinweis: alle.join('\n') };
1541
+ }
1542
+ /**
1543
+ * `link` eines herausgenommenen Bundle-Mitglieds (E114 L6): Ist es derselbe Skill aus derselben
1544
+ * Quelle, wie ihn die Definition nennt (Quelle und Pfad, dieselbe Probe wie die Übernahme in
1545
+ * F67), gehört er wieder zum Bundle. Die Definition wird nur gelesen, wenn der Name in diesem
1546
+ * Ziel herausgenommen ist; lässt sie sich nicht lesen, bleibt es beim Einzel-Link. Gibt je
1547
+ * zurückgeholtem Bundle eine Hinweiszeile zurück.
1548
+ */
1549
+ async bundleMitgliedZurueckholen(ziel, fund) {
1550
+ const name = fund.skill.name;
1551
+ const kandidaten = Anwendungsfaelle.installationenAn(await this.p.zustand.lesen(), ziel).filter((i) => (i.herausgenommen ?? []).includes(name));
1552
+ if (kandidaten.length === 0)
1553
+ return [];
1554
+ const passend = [];
1555
+ for (const installation of kandidaten) {
1556
+ try {
1557
+ const bundle = await this.bundleAusQuelle(installation.bundle, installation.quelle);
1558
+ const gleich = bundle.mitglieder.some((m) => quelleSchluessel(m.quelle) === quelleSchluessel(fund.quelle) && m.pfad === fund.skill.pfad);
1559
+ if (gleich)
1560
+ passend.push(bundleSchluessel(bundle));
1561
+ }
1562
+ catch {
1563
+ // Definition nicht lesbar: der Link bleibt ein Einzel-Link, das Mitglied herausgenommen.
1564
+ }
1565
+ }
1566
+ if (passend.length === 0)
1567
+ return [];
1568
+ const zustand = await this.p.zustand.lesen();
1569
+ const zeilen = [];
1570
+ for (const installation of Anwendungsfaelle.installationenAn(zustand, ziel)) {
1571
+ if (!passend.includes(bundleSchluessel({ name: installation.bundle, quelle: installation.quelle })))
1572
+ continue;
1573
+ const rest = (installation.herausgenommen ?? []).filter((n) => n !== name);
1574
+ if (rest.length === 0)
1575
+ delete installation.herausgenommen;
1576
+ else
1577
+ installation.herausgenommen = rest;
1578
+ if (!installation.skills.includes(name))
1579
+ installation.skills.push(name);
1580
+ zeilen.push(`„${name}" gehört wieder zum Bundle „${installation.bundle}".`);
1581
+ }
1582
+ await this.p.zustand.schreiben(zustand);
1583
+ return zeilen;
1504
1584
  }
1505
1585
  /** Link legen, wenn Quelle, Ref und Skill schon feststehen. Die Zeiger-Arbeit ist dieselbe. */
1506
1586
  async linkAusFund(harness, ziel, fund, ersetzen, lokal) {
@@ -1553,9 +1633,9 @@ export class Anwendungsfaelle {
1553
1633
  * Link entfernen. Ohne `lokal` verschwindet der Skill auch aus dem Zeiger (E17). Mit `lokal` an
1554
1634
  * einem gebundenen Ziel bleibt der Zeiger unangetastet, ein Hinweis sagt das (E57, F32).
1555
1635
  *
1556
- * Gehörte der Link zu einem Bundle, zerbricht es in diesem Ziel (E69): die übrigen Links bleiben
1557
- * als Einzel-Installationen liegen, der Bundle-Verweis fällt weg. `zerbrochen` nennt die Bundles,
1558
- * damit die Oberfläche es sagen kann.
1636
+ * Gehörte der Link zu einem Bundle, bleibt das Bundle im Ziel und merkt sich den Skill als
1637
+ * herausgenommen (E114 L2). `herausgenommenAus` nennt die Bundles, damit die Oberfläche es sagen
1638
+ * kann.
1559
1639
  */
1560
1640
  async linkEntfernen(args) {
1561
1641
  const harness = await this.harnessGeprueft(args.ziel.harness);
@@ -1563,25 +1643,25 @@ export class Anwendungsfaelle {
1563
1643
  await this.zeigerSchreibenPruefen(args.ziel, args.lokal);
1564
1644
  await this.p.ziele.linkEntfernen(args.ziel, harness.zielVerzeichnis(args.ziel.ort), args.skill);
1565
1645
  const zustand = await this.p.zustand.lesen();
1566
- const zerbrochen = Anwendungsfaelle.bundlesZerbrechen(zustand, args.ziel, [args.skill]);
1567
- if (zerbrochen.length > 0)
1646
+ const herausgenommenAus = Anwendungsfaelle.bundleMitgliederHerausnehmen(zustand, args.ziel, [args.skill]);
1647
+ if (herausgenommenAus.length > 0)
1568
1648
  await this.p.zustand.schreiben(zustand);
1569
1649
  if (args.lokal === true && args.ziel.ort.art === 'arbeitsverzeichnis') {
1570
1650
  const zeigerPfad = await this.bindungFinden(args.ziel);
1571
1651
  if (zeigerPfad !== undefined)
1572
- return { hinweis: 'Der Link ist nur lokal entfernt, der Zeiger bleibt unangetastet (F32).', zerbrochen };
1573
- return { zerbrochen };
1652
+ return { hinweis: 'Der Link ist nur lokal entfernt, der Zeiger bleibt unangetastet (F32).', herausgenommenAus };
1653
+ return { herausgenommenAus };
1574
1654
  }
1575
1655
  if (args.lokal === true || args.ziel.ort.art !== 'arbeitsverzeichnis')
1576
- return { zerbrochen };
1656
+ return { herausgenommenAus };
1577
1657
  const zeigerPfad = await this.bindungFinden(args.ziel);
1578
1658
  if (zeigerPfad === undefined)
1579
- return { zerbrochen };
1659
+ return { herausgenommenAus };
1580
1660
  const zeiger = await this.p.zeiger.lesen(zeigerPfad);
1581
1661
  if (!zeiger.skills.delete(args.skill))
1582
- return { zerbrochen };
1662
+ return { herausgenommenAus };
1583
1663
  await this.p.zeiger.schreiben(zeigerPfad, zeiger);
1584
- return { zerbrochen };
1664
+ return { herausgenommenAus };
1585
1665
  }
1586
1666
  /**
1587
1667
  * Ignoriert einen fremden Eintrag (E43): idempotent, kein Duplikat. Gleichheit über harness, Ort
@@ -2580,14 +2660,20 @@ export class Anwendungsfaelle {
2580
2660
  for (const ei of eintraege)
2581
2661
  if (ei.art === 'fremd')
2582
2662
  uebernahme.set(ei.name, await this.erkennenFremd(harness, verzeichnis, ei, bekannt, true));
2583
- // Bundles dieses Ziels (E69), rein aus dem Zustand: nur die Mitglieder, die dort auch
2584
- // wirklich noch liegen — ein von Hand gelöschter Link soll die Gruppe nicht länger führen.
2663
+ // Bundles dieses Ziels (E69), rein aus dem Zustand: verlinkt sind die Mitglieder, die dort
2664
+ // auch wirklich noch liegen; herausgenommene und von Hand gelöschte stehen als nicht
2665
+ // verlinkt daneben (E114 L3), die Gruppe zeigt alle.
2585
2666
  const eigeneNamen = new Set(eintraege.filter((ei) => ei.art !== 'fremd').map((ei) => (ei.art === 'link' ? ei.link.name : ei.name)));
2586
- const bundles = Anwendungsfaelle.installationenAn(zustand, ziel).map((i) => ({
2587
- name: i.bundle,
2588
- quelle: i.quelle,
2589
- skills: i.skills.filter((name) => eigeneNamen.has(name)),
2590
- }));
2667
+ const bundles = Anwendungsfaelle.installationenAn(zustand, ziel).map((i) => {
2668
+ const herausgenommen = i.herausgenommen ?? [];
2669
+ return {
2670
+ name: i.bundle,
2671
+ quelle: i.quelle,
2672
+ skills: i.skills.filter((name) => eigeneNamen.has(name)),
2673
+ nichtVerlinkt: [...i.skills.filter((name) => !eigeneNamen.has(name)), ...herausgenommen],
2674
+ herausgenommen,
2675
+ };
2676
+ });
2591
2677
  ergebnis.push({
2592
2678
  ziel,
2593
2679
  verzeichnis,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hmgr",
3
- "version": "0.0.45",
3
+ "version": "0.0.46",
4
4
  "description": "Harness Manager: Skills und Plugins für AI-Harnesses verwalten",
5
5
  "license": "MIT",
6
6
  "author": "Tim Kaufmann",