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;
|
}
|
}
|