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