From 98bce1b0da675715b2e5482c0ad61ba21135f62f Mon Sep 17 00:00:00 2001
From: Server-Bibliothek <server-bibliothek@lokal>
Date: Di, 04 Aug 2026 07:00:04 +0200
Subject: [PATCH] Add a desktop client that knows what is installed locally

---
 packages/bibliothek-client/src/components/bibliothek-formular.ts |  441 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 441 insertions(+), 0 deletions(-)

diff --git a/packages/bibliothek-client/src/components/bibliothek-formular.ts b/packages/bibliothek-client/src/components/bibliothek-formular.ts
new file mode 100644
index 0000000..e07ce28
--- /dev/null
+++ b/packages/bibliothek-client/src/components/bibliothek-formular.ts
@@ -0,0 +1,441 @@
+import { LitElement, html, nothing } from 'lit';
+import { customElement, property, state } from 'lit/decorators.js';
+import { event, type EventRef } from '@dh-software/lit-extensions';
+import type { DhInput } from '@dh-software/dh-components';
+import {
+  EINSTUFUNGEN,
+  PROGRAMMARTEN,
+  type Einstufung,
+  type GeheimnisUebersicht,
+  type Programmart,
+  type QuelleTyp,
+  type Software,
+  type SoftwareEingabe,
+} from 'bibliothek-types';
+import { softwareAnlegen, softwareAktualisieren, dateiHochladen, ladeGeheimnisse, speichereGeheimnisse } from '../api-client';
+
+interface QuellenOption {
+  dataId: string;
+  showContent: string;
+  selected?: boolean;
+}
+
+/**
+ * dh-select verwirft Klicks auf Einträge ohne dataId (`if (!dataId) return;`) und lässt
+ * die Liste dann offen stehen. „Keine Quelle" braucht deshalb eine eigene Kennung, die
+ * beim Lesen und Schreiben auf den Leerwert übersetzt wird.
+ */
+const OHNE_QUELLE = 'keine';
+
+const QUELLEN_OPTIONEN: QuellenOption[] = [
+  { dataId: OHNE_QUELLE, showContent: 'Keine (nur Katalog-Eintrag)' },
+  { dataId: 'manuell', showContent: 'Manuell — Datei hochladen (kein Auto-Update)' },
+  { dataId: 'direkt', showContent: 'Direkt-Download-Link' },
+  { dataId: 'github', showContent: 'GitHub-Release' },
+  { dataId: 'winget', showContent: 'winget-Paket' },
+  { dataId: 'svn', showContent: 'SVN-Export' },
+  { dataId: 'git', showContent: 'Git-Repository — wird selbst gebaut' },
+];
+
+const WERT_PLATZHALTER: Record<string, string> = {
+  '': 'zuerst eine Quelle wählen',
+  direkt: 'https://…/setup.exe',
+  github: 'besitzer/projekt  (z.B. ip7z/7zip)',
+  winget: 'Paket-ID  (z.B. Google.Chrome)',
+  svn: 'https://svn…/pfad/datei.exe',
+  git: 'ssh://git.server.de:29418/gruppe/tool.git   (optional mit #zweig)',
+};
+
+/** Was die gewählte Programmart für den Nutzer bedeutet — steht als Hilfstext unter dem Feld. */
+const PROGRAMMART_ERLAEUTERUNG: Record<Programmart, string> = {
+  unbestimmt: 'Noch nicht eingeordnet — am Eintrag erscheint kein Hinweis',
+  installer: 'Am Eintrag erscheint: Datei kann nach der Installation gelöscht werden',
+  eigenstaendig: 'Am Eintrag erscheint: läuft direkt, an einen festen Ort legen',
+};
+
+/** Formular zum Anlegen/Bearbeiten einer Software inkl. Bezugsquelle. */
+@customElement('bibliothek-formular')
+export class BibliothekFormular extends LitElement {
+  @property({ attribute: false }) software?: Software;
+  /** Eigenständige Tools, denen dieser Eintrag als Plug-in zugeordnet werden kann. */
+  @property({ attribute: false }) moeglicheEltern: Software[] = [];
+
+  @state() private name = '';
+  @state() private beschreibung = '';
+  @state() private elternId = 0;
+  @state() private elternOptionen: QuellenOption[] = [];
+  @state() private quelleTyp: QuelleTyp | '' = '';
+  @state() private quelleWert = '';
+  @state() private bauBefehl = '';
+  @state() private bauErgebnis = '';
+  @state() private einstufung: Einstufung = 'lizenzfrei';
+  @state() private einstufungsOptionen: QuellenOption[] = [];
+  @state() private programmart: Programmart = 'unbestimmt';
+  @state() private programmartOptionen: QuellenOption[] = [];
+  @state() private hinweis = '';
+  @state() private anleitungUrl = '';
+  @state() private erwarteterHerausgeber = '';
+  @state() private geheimnisStand: GeheimnisUebersicht[] = [];
+  @state() private geheimwerte: Record<string, string> = {};
+  @state() private datei?: File;
+  @state() private quellenOptionen: QuellenOption[] = QUELLEN_OPTIONEN.map((option) => ({ ...option }));
+  @state() private fehler = '';
+  @state() private speichert = false;
+
+  @event({ bubbles: true, composed: true }, 'gespeichert')
+  private gespeichert!: EventRef<void>;
+
+  @event({ bubbles: true, composed: true }, 'abgebrochen')
+  private abgebrochen!: EventRef<void>;
+
+  // Light DOM, damit das globale dh-components-Theme greift.
+  createRenderRoot() {
+    return this;
+  }
+
+  connectedCallback(): void {
+    super.connectedCallback();
+    if (this.software) {
+      this.name = this.software.name;
+      this.beschreibung = this.software.beschreibung;
+      this.elternId = this.software.elternId;
+      this.quelleTyp = this.software.quelleTyp;
+      this.quelleWert = this.software.quelleWert;
+      this.bauBefehl = this.software.bauBefehl;
+      this.bauErgebnis = this.software.bauErgebnis;
+      this.einstufung = this.software.einstufung;
+      this.programmart = this.software.programmart;
+      this.hinweis = this.software.hinweis;
+      this.anleitungUrl = this.software.anleitungUrl;
+      this.erwarteterHerausgeber = this.software.erwarteterHerausgeber;
+      this.quellenOptionen = QUELLEN_OPTIONEN.map((option) => ({
+        ...option,
+        selected: option.dataId === (this.quelleTyp || OHNE_QUELLE),
+      }));
+    }
+    this.einstufungsOptionen = EINSTUFUNGEN.map((eintrag) => ({
+      dataId: eintrag.id,
+      showContent: eintrag.text,
+      selected: eintrag.id === this.einstufung,
+    }));
+    this.programmartOptionen = PROGRAMMARTEN.map((eintrag) => ({
+      dataId: eintrag.id,
+      showContent: eintrag.text,
+      selected: eintrag.id === this.programmart,
+    }));
+    if (this.software) {
+      void this.geheimnisStandLaden(this.software.id);
+    }
+    // Sich selbst nicht als eigenes Eltern-Tool anbieten.
+    this.elternOptionen = [
+      { dataId: '0', showContent: 'Eigenständiges Tool', selected: !this.elternId },
+      ...this.moeglicheEltern
+        .filter((tool) => tool.id !== this.software?.id)
+        .map((tool) => ({ dataId: String(tool.id), showContent: tool.name, selected: tool.id === this.elternId })),
+    ];
+  }
+
+  render() {
+    return html`
+      <dh-card variant="outlined" no-hover-effect no-click-effect>
+        <span slot="header">${this.software ? 'Software bearbeiten' : 'Neue Software'}</span>
+        <dh-form-grid slot="content">
+          <dh-form-field label="Name">
+            <dh-input type="text" .value=${this.name} @input=${this.beiName}></dh-input>
+          </dh-form-field>
+          <dh-form-field label="Einstufung">
+            <dh-select
+              withoutEmptyDefault
+              notSearchable
+              .selectOptions=${this.einstufungsOptionen}
+              @changeSelectOption=${this.beiEinstufung}
+            ></dh-select>
+          </dh-form-field>
+          <dh-form-field label="Programmart">
+            <dh-select
+              withoutEmptyDefault
+              notSearchable
+              .selectOptions=${this.programmartOptionen}
+              @changeSelectOption=${this.beiProgrammart}
+            ></dh-select>
+            <span class="feldhinweis">${PROGRAMMART_ERLAEUTERUNG[this.programmart]}</span>
+          </dh-form-field>
+          <dh-form-field label="Gehört zu">
+            <dh-select
+              withoutEmptyDefault
+              notSearchable
+              .selectOptions=${this.elternOptionen}
+              @changeSelectOption=${this.beiEltern}
+            ></dh-select>
+            <span class="feldhinweis">Zusatzinhalte (Plug-ins, Sprachpakete, Vorlagen …) erscheinen unter dem gewählten Tool</span>
+          </dh-form-field>
+          <dh-form-field label="Bezugsquelle">
+            <dh-select
+              withoutEmptyDefault
+              notSearchable
+              .selectOptions=${this.quellenOptionen}
+              @changeSelectOption=${this.beiQuelleTyp}
+            ></dh-select>
+          </dh-form-field>
+          ${this.rendereQuellenAngabe()} ${this.rendereBauFelder()} ${this.rendereGeheimnisse()}
+          <dh-form-field label="Anleitung (Link, optional)">
+            <dh-input type="text" placeholder="https://wiki…" .value=${this.anleitungUrl} @input=${this.beiAnleitung}></dh-input>
+          </dh-form-field>
+          <dh-form-field label="Hinweis (optional)">
+            <dh-input
+              type="text"
+              placeholder="z. B. braucht Adminrechte"
+              .value=${this.hinweis}
+              @input=${this.beiHinweis}
+            ></dh-input>
+            <span class="feldhinweis">Erscheint am Eintrag im Katalog</span>
+          </dh-form-field>
+          <dh-form-field label="Erwarteter Signaturgeber (optional)">
+            <dh-input
+              type="text"
+              placeholder="z. B. NOTEPAD++"
+              .value=${this.erwarteterHerausgeber}
+              @input=${this.beiErwarteterHerausgeber}
+            ></dh-input>
+            <span class="feldhinweis">Weicht die Signatur der geholten Datei davon ab, wird sie nicht zum Download angeboten.</span>
+          </dh-form-field>
+          <dh-form-field label="Beschreibung" span="2">
+            <dh-textarea .value=${this.beschreibung} @isChange=${this.beiBeschreibung}></dh-textarea>
+          </dh-form-field>
+        </dh-form-grid>
+        <div slot="actions">
+          ${this.fehler ? html`<span class="fehler">${this.fehler}</span>` : ''}
+          <dh-button buttonText="Abbrechen" variant="standard" @click=${this.beiAbbrechen}></dh-button>
+          <dh-button
+            buttonText=${this.speichert ? 'Speichert…' : 'Speichern'}
+            variant="filled"
+            ?disabled=${this.speichert}
+            @click=${this.beiSpeichern}
+          ></dh-button>
+        </div>
+      </dh-card>
+    `;
+  }
+
+  /** Je nach Quelle: Datei-Auswahl (manuell) oder Textfeld für die Quell-Angabe. */
+  private rendereQuellenAngabe() {
+    if (this.quelleTyp === 'manuell') {
+      return html`
+        <dh-form-field label="Datei">
+          <input type="file" @change=${this.beiDatei} />
+          ${this.software?.dateiname ? html`<span class="feldhinweis">Aktuell hinterlegt: ${this.software.dateiname}</span>` : ''}
+        </dh-form-field>
+      `;
+    }
+    return html`
+      <dh-form-field label="Quell-Angabe">
+        <dh-input
+          type="text"
+          placeholder=${WERT_PLATZHALTER[this.quelleTyp]}
+          ?disabled=${this.quelleTyp === ''}
+          .value=${this.quelleWert}
+          @input=${this.beiQuelleWert}
+        ></dh-input>
+        ${this.quelleTyp === 'git' && this.quelleWert.includes('@')
+          ? html`<span class="feldhinweis warnhinweis">
+              Die URL enthält einen Benutzernamen. Besser ohne — dann liefert die SSH-Konfiguration des
+              Servers die Kennung, und ein Kontowechsel erfordert keine Änderung an den Einträgen.
+            </span>`
+          : nothing}
+      </dh-form-field>
+    `;
+  }
+
+  /**
+   * Bau-Geheimnisse: Das Repository deklariert in `software-bibliothek.json`, was es
+   * braucht; hier wird nur der Wert hinterlegt. Bereits gespeicherte Werte werden nie
+   * zurückgeliefert — ein leer gelassenes Feld lässt den bisherigen Wert unverändert.
+   */
+  private rendereGeheimnisse() {
+    if (this.quelleTyp !== 'git' || !this.software) {
+      return nothing;
+    }
+    const bedarf = this.software.benoetigteGeheimnisse ?? [];
+    if (bedarf.length === 0) {
+      return html`<dh-form-field label="Schlüssel für den Bau" span="2">
+        <span class="feldhinweis">
+          Dieses Repository fordert keine an. Bedarf wird in <code>software-bibliothek.json</code> unter
+          <code>secrets</code> deklariert und beim Bau als Umgebungsvariable bereitgestellt.
+        </span>
+      </dh-form-field>`;
+    }
+    return bedarf.map(
+      (eintrag) => html`<dh-form-field label=${eintrag.name} span="2">
+        <dh-input
+          type="password"
+          toggle-password
+          placeholder=${this.istHinterlegt(eintrag.name) ? 'hinterlegt — leer lassen, um ihn zu behalten' : 'noch nicht hinterlegt'}
+          .value=${this.geheimwerte[eintrag.name] ?? ''}
+          @input=${(ereignis: Event) => this.beiGeheimwert(eintrag.name, ereignis)}
+        ></dh-input>
+        <span class="feldhinweis">${eintrag.beschreibung || 'Wird dem Bau als Umgebungsvariable übergeben.'}</span>
+      </dh-form-field>`
+    );
+  }
+
+  private istHinterlegt(name: string): boolean {
+    return this.geheimnisStand.some((eintrag) => eintrag.name === name && eintrag.hinterlegt);
+  }
+
+  private beiGeheimwert(name: string, ereignis: Event): void {
+    this.geheimwerte = { ...this.geheimwerte, [name]: (ereignis.target as DhInput).value };
+  }
+
+  /** Nur für die Git-Quelle: Bau-Befehl und Pfad des Ergebnisses. */
+  private rendereBauFelder() {
+    if (this.quelleTyp !== 'git') {
+      return nothing;
+    }
+    return html`
+      <dh-form-field label="Bau-Befehl (optional)" span="2">
+        <dh-input
+          type="text"
+          placeholder="kommt normalerweise aus software-bibliothek.json im Repository"
+          .value=${this.bauBefehl}
+          @input=${this.beiBauBefehl}
+        ></dh-input>
+        <span class="feldhinweis">
+          Das Repository beschreibt seinen Bau selbst in <code>software-bibliothek.json</code>
+          (<code>bauBefehl</code> und <code>ergebnis</code>). Nur ausfüllen, um das zu übersteuern.
+        </span>
+      </dh-form-field>
+      <dh-form-field label="Ergebnis-Datei (optional)" span="2">
+        <dh-input type="text" placeholder="z. B. dist/MeinTool.exe" .value=${this.bauErgebnis} @input=${this.beiBauErgebnis}></dh-input>
+        <span class="feldhinweis">Pfad relativ zur Arbeitskopie; im Dateinamen ist ein * erlaubt.</span>
+      </dh-form-field>
+    `;
+  }
+
+  private beiBauBefehl(ereignis: Event): void {
+    this.bauBefehl = (ereignis.target as DhInput).value;
+  }
+
+  private beiBauErgebnis(ereignis: Event): void {
+    this.bauErgebnis = (ereignis.target as DhInput).value;
+  }
+
+  private beiName(ereignis: Event): void {
+    this.name = (ereignis.target as DhInput).value;
+  }
+
+  private beiQuelleWert(ereignis: Event): void {
+    this.quelleWert = (ereignis.target as DhInput).value;
+  }
+
+  private beiAnleitung(ereignis: Event): void {
+    this.anleitungUrl = (ereignis.target as DhInput).value;
+  }
+
+  private beiErwarteterHerausgeber(ereignis: Event): void {
+    this.erwarteterHerausgeber = (ereignis.target as DhInput).value;
+  }
+
+  private async geheimnisStandLaden(id: number): Promise<void> {
+    try {
+      this.geheimnisStand = await ladeGeheimnisse(id);
+    } catch {
+      this.geheimnisStand = []; // ohne lokalen Zugriff nicht abrufbar — dann eben leer
+    }
+  }
+
+  /** Speichert nur die tatsächlich eingegebenen Werte; leere bleiben unangetastet. */
+  private async geheimnisseSpeichern(id: number): Promise<void> {
+    const bedarf = this.software?.benoetigteGeheimnisse ?? [];
+    if (bedarf.length === 0) {
+      return;
+    }
+    await speichereGeheimnisse(
+      id,
+      bedarf.map((eintrag) => ({ name: eintrag.name, wert: this.geheimwerte[eintrag.name] ?? '' }))
+    );
+  }
+
+  private beiEinstufung(ereignis: CustomEvent<{ selectedOption: { dataId: string } }>): void {
+    this.einstufung = ereignis.detail.selectedOption.dataId as Einstufung;
+  }
+
+  private beiProgrammart(ereignis: CustomEvent<{ selectedOption: { dataId: string } }>): void {
+    this.programmart = ereignis.detail.selectedOption.dataId as Programmart;
+  }
+
+  private beiHinweis(ereignis: CustomEvent<string>): void {
+    this.hinweis = ereignis.detail;
+  }
+
+  private beiBeschreibung(ereignis: CustomEvent<string>): void {
+    this.beschreibung = ereignis.detail;
+  }
+
+  private beiDatei(ereignis: Event): void {
+    this.datei = (ereignis.target as HTMLInputElement).files?.[0];
+  }
+
+  private beiQuelleTyp(ereignis: CustomEvent<{ selectedOption: { dataId: string } }>): void {
+    const gewaehlt = ereignis.detail.selectedOption.dataId;
+    this.quelleTyp = gewaehlt === OHNE_QUELLE ? '' : (gewaehlt as QuelleTyp);
+  }
+
+  private beiEltern(ereignis: CustomEvent<{ selectedOption: { dataId: string } }>): void {
+    this.elternId = Number(ereignis.detail.selectedOption.dataId) || 0;
+  }
+
+  private beiAbbrechen(): void {
+    this.abgebrochen.dispatch();
+  }
+
+  private async beiSpeichern(): Promise<void> {
+    if (!this.name.trim()) {
+      this.fehler = 'Name ist erforderlich.';
+      return;
+    }
+    if (this.quelleTyp && this.quelleTyp !== 'manuell' && !this.quelleWert.trim()) {
+      this.fehler = 'Für die gewählte Quelle fehlt die Angabe.';
+      return;
+    }
+    if (this.quelleTyp === 'manuell' && !this.datei && !this.software?.dateiname) {
+      this.fehler = 'Bitte eine Datei auswählen.';
+      return;
+    }
+    this.speichert = true;
+    this.fehler = '';
+    const eingabe: SoftwareEingabe = {
+      name: this.name.trim(),
+      beschreibung: this.beschreibung.trim(),
+      elternId: this.elternId,
+      quelleTyp: this.quelleTyp,
+      quelleWert: this.quelleTyp === 'manuell' ? '' : this.quelleWert.trim(),
+      bauBefehl: this.quelleTyp === 'git' ? this.bauBefehl.trim() : '',
+      bauErgebnis: this.quelleTyp === 'git' ? this.bauErgebnis.trim() : '',
+      einstufung: this.einstufung,
+      programmart: this.programmart,
+      hinweis: this.hinweis.trim(),
+      anleitungUrl: this.anleitungUrl.trim(),
+      erwarteterHerausgeber: this.erwarteterHerausgeber.trim(),
+    };
+    try {
+      const gespeicherteSoftware = this.software
+        ? await softwareAktualisieren(this.software.id, eingabe)
+        : await softwareAnlegen(eingabe);
+      if (this.datei) {
+        await dateiHochladen(gespeicherteSoftware.id, this.datei);
+      }
+      await this.geheimnisseSpeichern(gespeicherteSoftware.id);
+      this.gespeichert.dispatch();
+    } catch (fehler) {
+      this.fehler = fehler instanceof Error ? fehler.message : 'Speichern fehlgeschlagen.';
+    } finally {
+      this.speichert = false;
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'bibliothek-formular': BibliothekFormular;
+  }
+}

--
Gitblit v1.9.3