3 Dateien geändert
63 Dateien hinzugefügt
23 Dateien gelöscht
| | |
| | | # Echte Datenbank und Nutzdaten - nicht versionieren, ist Bestand des jeweiligen Servers |
| | | data/bibliothek.db |
| | | data/doku_bilder/ |
| | | |
| | | # Von Nutzern abgelegte Installer-Dateien (Pfad ist frei waehlbar, dieser Ordner ist nur ein Vorschlag) |
| | | installers/* |
| | | !installers/LIESMICH.txt |
| | | |
| | | # Python |
| | | # Abhaengigkeiten |
| | | node_modules |
| | | .venv |
| | | __pycache__/ |
| | | *.pyc |
| | | |
| | | # Betriebssystem |
| | | # Build-Ausgaben |
| | | dist |
| | | build |
| | | build-exe |
| | | exe/ |
| | | *.spec |
| | | |
| | | # Logs |
| | | *.log |
| | | |
| | | # Laufzeit-Daten: Datenbank und geholte Programm-Dateien |
| | | # (gehoeren dem jeweiligen Server, nicht ins Repo) |
| | | data/ |
| | | |
| | | # Editor / Betriebssystem / Tooling |
| | | .idea |
| | | .vscode |
| | | .claude |
| | | Thumbs.db |
| | | .DS_Store |
| Neue Datei |
| | |
| | | @dh-software:registry=https://npm.furnco.de |
| Neue Datei |
| | |
| | | { |
| | | "printWidth": 200, |
| | | "singleQuote": true |
| | | } |
| | |
| | | # Architektur-Dokumentation â Server-Bibliothek |
| | | # Architektur |
| | | |
| | | Diese Datei ist für die technische Ãbergabe gedacht: sie erklärt, wie das |
| | | Projekt aufgebaut ist, welche Entscheidungen warum getroffen wurden, und was |
| | | bei einer Anpassung an eine andere Infrastruktur zu beachten ist. |
| | | |
| | | Für die Bedienungsanleitung (Endnutzer-Sicht) siehe `README.md`. |
| | | Für die Entwicklungshistorie (was wurde wann gebaut) siehe `CHANGELOG.md`. |
| | | Die technische Innensicht: wie das Projekt aufgebaut ist, welche Entscheidungen warum so |
| | | getroffen wurden und wo die Grenzen liegen. Für Betrieb und Bedienung siehe |
| | | [README.md](README.md). |
| | | |
| | | --- |
| | | |
| | | ## 1. Tech-Stack |
| | | ## 1. Grundriss |
| | | |
| | | - **Python 3.8+**, **Flask** (Web-Framework) â einzige externe Abhängigkeit |
| | | laut `requirements.txt` |
| | | - **SQLite** (`data/bibliothek.db`) â eine einzelne Datei, kein Datenbankserver |
| | | - **Server-seitig gerenderte HTML-Templates** (Jinja2), kein JavaScript-Framework, |
| | | kein Build-Prozess â nur Vanilla-JS für Interaktivität (Checklisten, |
| | | Formular-Verhalten) |
| | | - Läuft als einfacher Flask-Entwicklungsserver (`app.run(host="0.0.0.0", ...)`) |
| | | â **nicht produktionsgehärtet**, siehe Abschnitt 7 "Bekannte Grenzen" |
| | | npm-Workspaces mit drei Paketen. Die Typen liegen bewusst dazwischen: Server und |
| | | Oberfläche teilen sich einen Vertrag, den der Ãbersetzer prüft â ändert sich ein Feld, |
| | | bricht die Ãbersetzung auf beiden Seiten. |
| | | |
| | | Bewusste Design-Entscheidung: keine Frameworks/Build-Tools, damit eine Person |
| | | ohne Programmiererfahrung (Zielgruppe dieses Projekts) das System mit einem |
| | | Doppelklick starten kann, ohne `npm install` o. Ã. |
| | | | Paket | Rolle | |
| | | |---|---| |
| | | | `bibliothek-types` | Gemeinsame Typen. Kennt weder Server noch Browser | |
| | | | `bibliothek-server` | Express, SQLite, Bezugsquellen, Zugriffsgrenze | |
| | | | `bibliothek-client` | Oberfläche aus `@dh-software/dh-components` (Lit) | |
| | | |
| | | ## 2. Projektstruktur |
| | | TypeScript durchgehend im `strict`-Modus. |
| | | |
| | | ``` |
| | | server-bibliothek/ |
| | | âââ app.py Haupt-Flask-Anwendung, alle Routen |
| | | âââ updater.py Update-Check-Logik (winget/GitHub/Direkt-Link) |
| | | âââ automatische_pruefung.py Eigenständiges Skript für geplante Update-Checks |
| | | âââ requirements.txt Python-Abhängigkeiten (nur Flask) |
| | | âââ data/ |
| | | â âââ bibliothek.db SQLite-Datenbank (wird beim ersten Start angelegt) |
| | | â âââ doku_bilder/<software_id>/ Hochgeladene Doku-Screenshots |
| | | âââ installers/ Standard-Ablageort für Installer-Dateien |
| | | â (Nutzer koennen aber JEDEN Pfad eintragen, |
| | | â dieser Ordner ist nur ein Vorschlag) |
| | | âââ static/style.css Gesamtes Styling (ein File, keine Build-Pipeline) |
| | | âââ templates/ Jinja2-HTML-Templates |
| | | âââ install.bat Einmalige Einrichtung (Python-Abhaengigkeiten) |
| | | âââ start.bat Startet den Webserver |
| | | âââ pruefe_updates.bat Manueller Update-Check-Lauf |
| | | âââ automatisierung_einrichten.bat Richtet taeglichen Update-Check per Windows-Aufgabenplanung ein |
| | | âââ firewall-freigabe-einrichten.bat Oeffnet Port 5000 in der Windows-Firewall |
| | | âââ Update-Quellen-Uebersicht.md Nachschlagehilfe fuer winget-IDs/GitHub-Pfade |
| | | âââ README.md Bedienungsanleitung |
| | | âââ ARCHITEKTUR.md Diese Datei |
| | | âââ CHANGELOG.md Entwicklungshistorie |
| | | ### Warum Node und nicht Flask |
| | | |
| | | Der übernommene Prototyp war Flask. Der Umbau auf Node hatte zwei Gründe: Die Oberfläche |
| | | sollte auf dem hauseigenen Komponenten-Satz aufsetzen, und der Auslieferungsstand sollte |
| | | eine einzelne Programmdatei ohne installierte Laufzeitumgebung sein. Beides zieht Node |
| | | nach sich. |
| | | |
| | | ### Warum keine Datenbankbibliothek |
| | | |
| | | Ab Node 22 bringt die Laufzeitumgebung SQLite mit (`node:sqlite`). Damit entfällt eine |
| | | native Abhängigkeit, die für jede Node-Version neu übersetzt werden müsste â genau daran |
| | | war ein erster Versuch mit `better-sqlite3` gescheitert. Journalmodus WAL, damit Lesen |
| | | während eines laufenden Abgleichs nicht blockiert. |
| | | |
| | | --- |
| | | |
| | | ## 2. Bezugsquellen |
| | | |
| | | Das Herzstück. Jede Art, an eine Datei zu kommen, ist eine Klasse hinter derselben |
| | | Schnittstelle: |
| | | |
| | | ```ts |
| | | interface Bezugsquelle { |
| | | gleicheAb(auftrag: Abgleichauftrag): Promise<QuellenAbgleich>; |
| | | } |
| | | ``` |
| | | |
| | | ## 3. Datenbankschema |
| | | |
| | | Alle Tabellen werden in `init_db()` (app.py) angelegt. Migrationen (neue |
| | | Spalten) laufen automatisch beim Start via `ALTER TABLE ... ADD COLUMN`, |
| | | bestehende Daten bleiben dabei erhalten. |
| | | |
| | | ### `software` â der Tool-Katalog |
| | | | Spalte | Typ | Bedeutung | |
| | | |---|---|---| |
| | | | id | INTEGER PK | | |
| | | | name | TEXT | Anzeigename, wird auch für Dateinamen-Ableitung sanitisiert | |
| | | | kategorie | TEXT | Freitext, für Gruppierung in Checklisten | |
| | | | version | TEXT | Bei "direkt"-Update-Quelle: technische Kennung (ETag), keine echte Versionsnummer | |
| | | | installer_pfad | TEXT | Ordner **oder** Datei-Pfad, siehe Abschnitt 4 "Ordner-Konvention" | |
| | | | install_parameter | TEXT | Silent-Install-Flags, z. B. `/S` oder `/quiet /norestart` | |
| | | | dokumentation | TEXT | Freitext-Anleitung | |
| | | | letzte_pruefung | TEXT (Datum) | Für die Status-Anzeige (Aktuell/Bitte prüfen/Nie geprüft) | |
| | | | erstellt_am | TEXT (Zeitstempel) | | |
| | | | auto_update | INTEGER (0/1) | Automatische Aktualisierung aktiv? | |
| | | | update_quelle | TEXT | `winget` \| `github` \| `direkt` \| leer | |
| | | | update_wert | TEXT | Paket-ID / `besitzer/projekt` / URL, je nach Quelle | |
| | | |
| | | ### `zuweisungen` â einzelne Tool-Zuweisungen (Zeilen eines Protokolls) |
| | | | Spalte | Bedeutung | |
| | | | Typ | Vorgehen | |
| | | |---|---| |
| | | | id, software_id | | |
| | | | protokoll_id | FK auf `protokolle` (siehe unten) | |
| | | | mitarbeiter, rechnername | Denormalisiert hier UND im Protokoll gespeichert (Absicht: die Installationsverlauf-Ansicht pro Software bleibt dadurch einfach) | |
| | | | installierte_version, installiert_von, installiert_am, notiz | | |
| | | | `direkt` | HTTP-Abruf eines festen Links | |
| | | | `github` | Neuestes Release über die GitHub-Schnittstelle, passendes Asset wählen | |
| | | | `winget` | `winget download`, danach `winget show` für das Veröffentlichungsdatum | |
| | | | `svn` | `svn export` einer Datei | |
| | | | `git` | Repository klonen bzw. aktualisieren, bauen, Ergebnis übernehmen | |
| | | | `manuell` | Kein Abruf â die Datei wird einmalig hinterlegt und bleibt liegen | |
| | | |
| | | ### `protokolle` â ein Eintrag pro Rechner-Einrichtung |
| | | | Spalte | Bedeutung | |
| | | |---|---| |
| | | | id, mitarbeiter, rechnername, datum, erstellt_von, notiz, erstellt_zeitpunkt | | |
| | | Eine neue Quelle anzubinden heiÃt: eine Klasse schreiben und in der Ãbersicht eintragen. |
| | | Der Rest â Abgleich, Prüfung, Speicherung, Anzeige â bleibt unberührt. |
| | | |
| | | Ein Protokoll fasst mehrere `zuweisungen`-Zeilen zusammen (1 Protokoll : n |
| | | Zuweisungen), damit bei z. B. 40 Mitarbeitern à 40 Tools keine unübersichtliche |
| | | flache Liste entsteht, sondern eine Ãbersicht pro Person/Rechner. |
| | | ### Nichts wird ersetzt, bevor es geprüft ist |
| | | |
| | | ### `update_log` â Protokoll jedes automatischen Update-Checks |
| | | | Spalte | Bedeutung | |
| | | |---|---| |
| | | | id, software_id, zeitpunkt, alte_version, neue_version, erfolg (0/1), meldung | | |
| | | Der teuerste Fehler wäre, eine funktionierende Datei gegen Müll zu tauschen, weil eine |
| | | Quelle stillschweigend etwas anderes liefert als erwartet. Deshalb schreibt keine Quelle |
| | | direkt in den Zielordner: |
| | | |
| | | ### `doku_bilder` â hochgeladene Screenshots zur Dokumentation |
| | | | Spalte | Bedeutung | |
| | | |---|---| |
| | | | id, software_id, dateiname, hochgeladen_am | Datei liegt unter `data/doku_bilder/<software_id>/<dateiname>` | |
| | | 1. Herunterladen bzw. bauen in `<zielordner>.neu` |
| | | 2. `inhaltBeanstanden()` prüft: leer? HTML statt Programmdatei (Signatur und |
| | | Inhaltstyp)? plötzlich unter der Hälfte der bisherigen GröÃe und kleiner als 100 KB? |
| | | 3. Erst wenn nichts zu beanstanden ist, wird getauscht â sonst verworfen |
| | | |
| | | ## 4. Kernkonzept: Ordner-Konvention (`updater.py`) |
| | | Der Eintrag meldet dann das Problem, behält aber seine bisherige Datei. Eine tote Quelle |
| | | kostet Aktualität, nie den Download. |
| | | |
| | | Das Feld `installer_pfad` kann zwei Formen haben: |
| | | - **Datei-Pfad** (endet auf `.exe`/`.msi`/etc.): ältere/explizite Schreibweise |
| | | - **Ordner-Pfad** (kein Dateiname): empfohlene, aktuelle Schreibweise |
| | | ### Selbstgebaute Werkzeuge |
| | | |
| | | Die Funktion `updater.effektiver_ablageordner(installer_pfad, name)` ist die |
| | | zentrale Logik: sie sorgt **immer** dafür, dass jedes Tool in einem eigenen |
| | | Unterordner landet (benannt nach dem sanitisierten Tool-Namen), selbst wenn |
| | | im Feld nur ein geteilter Basisordner steht oder ein alter kompletter |
| | | Dateipfad. Das verhindert, dass mehrere Tools sich einen Ordner teilen und |
| | | Dateien "nackt" nebeneinander landen (siehe CHANGELOG, Runden r6âr8, dort |
| | | wurde das mehrfach nachgeschärft, nachdem reale Testfälle Lücken zeigten). |
| | | Bei `git` beschreibt das Repository seinen eigenen Bau in `software-bibliothek.json`: |
| | | |
| | | `updater.verwaltete_datei_pfad(installer_pfad, name)` findet die aktuell |
| | | abgelegte Datei (falls vorhanden), inkl. Rückwärtskompatibilität zu Dateien, |
| | | die noch am alten, nicht verschachtelten Ort liegen (Ãbergangslogik). |
| | | ```json |
| | | { |
| | | "buildCommand": "â¦", |
| | | "artifact": "dist/MeinWerkzeug.exe", |
| | | "secrets": [{ "name": "â¦", "description": "â¦" }] |
| | | } |
| | | ``` |
| | | |
| | | **Wichtig für Weiterentwicklung:** Jede neue Stelle im Code, die mit |
| | | Installer-Dateien arbeitet, MUSS über diese beiden Funktionen gehen, statt |
| | | `installer_pfad` direkt zu verwenden â sonst wird die |
| | | Unterordner-Garantie umgangen. |
| | | Damit muss die Bibliothek nichts über einzelne Werkzeuge wissen â ein neues anzubinden |
| | | erfordert keine Ãnderung an ihr. Gebaut wird nur, wenn sich der Commit geändert hat. |
| | | Zeitgrenze 30 Minuten, weil Bauvorgänge mit vielen Abhängigkeiten lange dauern. |
| | | |
| | | ## 5. Automatische Aktualisierung (drei Quellen) |
| | | Die Kennung für den Zugriff kommt aus den Einstellungen (`GIT_SSH_COMMAND`), nicht aus der |
| | | URL. So bleibt der Katalogeintrag frei von Kontoangaben, und ein Kontowechsel ist eine |
| | | einzige Ãnderung statt einer pro Eintrag. |
| | | |
| | | `updater.pruefe_software(name, quelle, update_wert, aktuelle_version, installer_pfad)` |
| | | ist der Einstiegspunkt, delegiert an: |
| | | ### Bau-Geheimnisse |
| | | |
| | | - **`pruefe_winget`** â ruft `winget show`/`winget download` auf (nur unter |
| | | Windows verfügbar; auf Nicht-Windows-Systemen liefert es einen sauberen |
| | | Fehler statt eines Absturzes) |
| | | - **`pruefe_github`** â nutzt die öffentliche GitHub-API |
| | | (`api.github.com/repos/.../releases/latest`), sucht ein `.exe`/`.msi`-Asset |
| | | - **`pruefe_direkt`** â fester Download-Link, Versionsvergleich über |
| | | HTTP-`ETag`/`Last-Modified`-Header statt echter Versionsnummer |
| | | Werte liegen getrennt vom Katalog in `secrets/<id>.json` und werden beim Bau als |
| | | Umgebungsvariablen gesetzt â sie berühren die Platte der Arbeitskopie nie. Das Verfahren |
| | | im Ganzen beschreibt der Skill `build-secrets` im Repository `dh-claude-rules`. |
| | | |
| | | Alle drei prüfen zusätzlich, ob die Datei **physisch noch existiert** â auch |
| | | bei gleicher Versionsnummer wird neu heruntergeladen, falls die Datei manuell |
| | | gelöscht wurde (Bug wurde in Runde r5 gefunden und behoben). |
| | | Geschützt sind sie durch Dateiberechtigungen (`icacls`, beschränkt auf SYSTEM, |
| | | Administratoren und das Dienstkonto, bei jedem Start neu gesetzt), durch die |
| | | Zugriffsgrenze und dadurch, dass sie in keiner aus dem Netzwerk lesbaren Schnittstelle |
| | | auftauchen. Eine Verschlüsselung wäre unter Windows nur über die Datenschutz-Schnittstelle |
| | | des Betriebssystems sinnvoll; die ist aus der Laufzeitumgebung nicht ohne Zusatzpaket |
| | | erreichbar. |
| | | |
| | | Vor jedem Ersetzen einer Datei wird über `sichern_und_ersetzen()` eine |
| | | Sicherheitskopie in einen `_sicherungen`-Unterordner gelegt. |
| | | --- |
| | | |
| | | ## 6. "Rechner einrichten" â Verteilungs-Mechanismus |
| | | ## 3. Zugriffsgrenze |
| | | |
| | | Route `/einrichten` (app.py) erzeugt bei Bedarf ein individuelles |
| | | Installations-Paket (ZIP mit `einrichten.ps1` + `Rechner-einrichten-starten.bat` |
| | | **+ den echten Installer-Dateien/ZIPs selbst** unter `tools/<id>.<endung>`), |
| | | statt vorab für jede Mitarbeiter/Tool-Kombination ein festes Skript zu pflegen. |
| | | Kein Anmeldeverfahren, sondern eine Grenze an der Netzwerkschnittstelle: Eine Middleware |
| | | prüft, ob die Anfrage von `localhost` kommt. Verwaltende Routen tragen sie, lesende nicht. |
| | | |
| | | **Wichtige Design-Entscheidung (Runde r16):** Die Installer-Dateien werden |
| | | bereits beim Erzeugen des Pakets (auf dem PC, der die Checkliste ausfüllt) |
| | | eingebettet, nicht erst beim Ausführen auf dem Zielrechner nachgeladen. Ein |
| | | früherer Ansatz lud die Dateien per `Invoke-WebRequest` zur Laufzeit vom |
| | | Server nach â das erforderte Netzwerkzugriff des Zielrechners auf den Server |
| | | in genau dem Moment der Einrichtung, was in der Praxis nicht immer gegeben |
| | | ist (z. B. Rechner noch nicht ans Firmennetz angebunden). Jetzt braucht der |
| | | Zielrechner für die eigentliche Installation **kein Netzwerk mehr** â nur die |
| | | abschlieÃende, optionale Meldung ans zentrale Protokoll (`/zuweisungen/neu`) |
| | | versucht noch einen Netzwerkzugriff, scheitert dieser, bleibt trotzdem das |
| | | lokale Protokoll (`protokoll.log`) vollständig. |
| | | - Frei erreichbar: Katalog lesen, einzeln oder als ZIP herunterladen, Umgebung abfragen |
| | | - Nur lokal: Anlegen, Ãndern, Löschen, Abgleich anstoÃen, Datei hinterlegen, Geheimnisse, |
| | | Export/Import, Einstellungen, Zugriffsprotokoll |
| | | |
| | | Ablauf beim Ausführen auf dem Zielrechner: |
| | | 1. `.bat` kopiert den **kompletten entpackten Ordner** (inkl. `tools/`- |
| | | Unterordner mit den echten Installer-Dateien) nach `C:\ClientBibliothek` |
| | | (per `xcopy /E /I /Y`) |
| | | 2. Self-Elevation (Admin-Rechte-Anfrage) über `Start-Process -Verb RunAs` |
| | | 3. Automatische Windows-Defender-Ausnahme für `C:\ClientBibliothek` |
| | | (`Add-MpPreference -ExclusionPath`) â ein einziger Pfad statt vieler |
| | | Einzelausnahmen |
| | | 4. Pro ausgewähltem Tool: entweder stille Installation aus der **lokal |
| | | mitgelieferten** Datei unter `tools/<id>.<endung>` (mit hinterlegtem |
| | | Silent-Parameter, `.msi` läuft über `msiexec`) oder â bei Tools ohne |
| | | einzelne Installer-Datei ("Ordner-Typ", z. B. portable Programme wie |
| | | Blender) â die mitgelieferte ZIP-Datei wird nach `C:\ClientBibliothek\pakete\` |
| | | kopiert, **bewusst ohne automatisches Entpacken** (Entscheidung in Runde |
| | | r14: der Zielort für portable Tools variiert, das soll eine Person bewusst |
| | | entscheiden) |
| | | 5. Lokales Protokoll unter `C:\ClientBibliothek\protokoll.log` |
| | | 6. Optionaler POST an `/zuweisungen/neu` auf dem Server, um das zentrale |
| | | Protokoll zu befüllen (Rechnername wird dabei über `$env:COMPUTERNAME` |
| | | automatisch erkannt) â best effort, kein Abbruch bei Fehlschlag |
| | | Die Oberfläche fragt beim Start `/api/umgebung` und blendet aus, was ohnehin abgewiesen |
| | | würde. Das ist Bequemlichkeit, keine Absicherung â die liegt beim Server. |
| | | |
| | | Die Ordner-Struktur unter `C:\ClientBibliothek`: |
| | | - `\tools` â die mitgelieferten Installer-Dateien/ZIPs (Quelle für die |
| | | Installation, kommen direkt aus dem heruntergeladenen Paket) |
| | | - `\pakete` â ZIPs von portablen Tools, bleiben bewusst liegen |
| | | - `\protokoll.log` â lokale Mitschrift |
| | | Das passt zum Auftrag (âVollzugriff nur am Server") und kommt ohne Benutzerverwaltung aus. |
| | | Der Preis: Wer am Server sitzt, darf alles. Rollen und Rechte sind im Ticket bewusst |
| | | zurückgestellt. |
| | | |
| | | Serverseitig teilen sich `/einrichten` (POST) und `/software/<id>/download` |
| | | die Hilfsfunktion `_zippe_ordner_in_bytes()` für das ZIP-Packen eines |
| | | Ordners, um Code-Duplikation zu vermeiden. |
| | | --- |
| | | |
| | | **Der Client-Ordner-Pfad ist eine Konstante** (`CLIENT_ORDNER` in `app.py`, |
| | | aktuell `C:\ClientBibliothek`) â zentral anpassbar, falls die Infrastruktur |
| | | einen anderen Standardpfad vorgibt. |
| | | ## 4. Oberfläche |
| | | |
| | | ## 6a. Zugriffsbeschränkung: lokal (Server) vs. Netzwerk |
| | | Aufgebaut aus `@dh-software/dh-components`, damit das Werkzeug aussieht wie der Rest des |
| | | Hauses. Zwei Besonderheiten: |
| | | |
| | | Seit Runde r17 unterscheidet die Anwendung, ob eine Anfrage direkt vom Server |
| | | selbst kommt (z. B. per RDP-Sitzung, im Browser dort `localhost`/`127.0.0.1` |
| | | aufgerufen) oder über das Netzwerk von einem anderen Rechner. |
| | | **Light DOM statt Shadow DOM.** Die Komponenten werden über `createRenderRoot()` ins Light |
| | | DOM gerendert, sonst greifen die globalen `::part()`-Regeln des Themes nicht. |
| | | |
| | | - `ist_lokaler_zugriff()` prüft `request.remote_addr` gegen `127.0.0.1`/`::1` |
| | | - `@app.before_request`-Hook `_zugriff_beschraenken()` blockiert für |
| | | Netzwerk-Zugriffe alle Routen auÃer denen in `OEFFENTLICHE_ENDPUNKTE` |
| | | (aktuell nur `einrichten` und `static`) mit einer 403-Seite |
| | | - Die Seite `/einrichten` zeigt sich bei Netzwerkzugriff unter anderem Namen |
| | | ("Software anfordern" statt "Rechner einrichten") und mit Hinweis auf die |
| | | Protokollierung |
| | | - Jeder Aufruf/Download über `/einrichten` wird in der Tabelle `zugriffslog` |
| | | festgehalten (Zeitpunkt, IP-Adresse, per Reverse-DNS ermittelter |
| | | Rechnername falls auflösbar, selbst angegebener Name, Aktion) â einsehbar |
| | | unter `/zugriffslog` (selbst wieder nur lokal erreichbar) |
| | | **Themewechsel zur Laufzeit.** Zwei `<style>`-Elemente mit den Farbdefinitionen für hell |
| | | und dunkel, umgeschaltet über `.disabled`. Drei Modi: System, hell, dunkel. Nach dem |
| | | Vorbild des Meeting-Transkriptors, damit sich die Werkzeuge gleich anfühlen. |
| | | |
| | | **Wichtige Einschränkung:** Ein echter Windows-Benutzername lässt sich über |
| | | reines HTTP **nicht** zuverlässig ermitteln â dafür wäre eine |
| | | NTLM/Kerberos-Integration nötig (z. B. über einen IIS-Reverse-Proxy vor |
| | | Flask, der die Windows-Anmeldung übernimmt und den Benutzernamen per Header |
| | | an die Flask-App durchreicht). Das ist in diesem Prototyp **nicht** |
| | | umgesetzt. Aktuell basiert die Identifizierung auf der IP-Adresse, einem per |
| | | Reverse-DNS aufgelösten Rechnernamen (funktioniert nur, wenn im |
| | | Firmennetzwerk DNS-Einträge für Client-Rechner existieren) und dem selbst im |
| | | Formular angegebenen Namen (nicht verifiziert). Für eine belastbarere |
| | | Identifizierung müsste diese IIS/NTLM-Integration nachgerüstet werden. |
| | | Der Katalog ist eine kompakte Liste statt Karten â er soll dreistellige Zahlen an |
| | | Einträgen aushalten. Reiter trennen lizenzfrei, lizenzpflichtig und intern; die |
| | | Mehrfachauswahl wirkt über Reitergrenzen hinweg und lädt als ZIP. |
| | | |
| | | **Anpassung an andere Infrastruktur:** Sollte der Server z. B. hinter einem |
| | | Reverse-Proxy laufen, liefert `request.remote_addr` ggf. die Proxy-IP statt |
| | | der echten Client-IP â in dem Fall müssten `X-Forwarded-For`-Header |
| | | ausgewertet werden (aktuell nicht implementiert, da kein Proxy im Einsatz). |
| | | Zusatzinhalte (Sprachpakete, Erweiterungen) hängen über `elternId` an ihrem Werkzeug und |
| | | stehen eingerückt darunter, ohne eigene Trennlinie â Werkzeug und Zubehör lesen sich als |
| | | ein Block. |
| | | |
| | | ## 7. Bekannte Grenzen / nicht vollständig getestet |
| | | --- |
| | | |
| | | - **Keine echte Authentifizierung, nur Herkunfts-Beschränkung.** Seit r17 |
| | | ist die Verwaltung (Katalog, Protokolle, Zugriffsprotokoll) nur noch vom |
| | | Server selbst aus erreichbar (siehe Abschnitt 6a) â das ist aber eine |
| | | Netzwerk-/IP-basierte Einschränkung, **kein Login mit Benutzername/Passwort**. |
| | | Wer direkt am Server sitzt (oder sich als diesen ausgibt, z. B. über |
| | | IP-Spoofing im selben Netzwerksegment), hat vollen Zugriff. Für ein |
| | | belastbares Sicherheitskonzept mit echten Benutzerkonten wäre ein |
| | | richtiges Login-System nötig. |
| | | - **Kein HTTPS/TLS.** Die Kommunikation läuft unverschlüsselt über HTTP. Für |
| | | ein internes, abgeschottetes Netzwerk meist unkritisch, sollte aber vor |
| | | einem gröÃeren Rollout bewertet werden. |
| | | - **Flask-Entwicklungsserver**, kein WSGI-Produktivserver (z. B. Waitress, |
| | | gunicorn). Für kleine interne Nutzung ausreichend, bei mehr gleichzeitigen |
| | | Nutzern ggf. wechseln. |
| | | - **PowerShell-Teile des Verteilungs-Skripts** (Self-Elevation, `msiexec`, |
| | | `Add-MpPreference`) konnten in der Entwicklungsumgebung (Linux-Sandbox) |
| | | nicht Ende-zu-Ende ausgeführt werden, nur mit dem echten PowerShell-Parser |
| | | auf Syntaxfehler geprüft, plus die reinen Netzwerk-Teile |
| | | (Invoke-WebRequest/Invoke-RestMethod, Expand-Archive) wurden live gegen den |
| | | echten Server getestet. Vor gröÃerem Rollout: an 1â2 unkritischen Rechnern |
| | | gegentesten. |
| | | - **winget-Pfad der automatischen Aktualisierung** konnte aus demselben |
| | | Grund nicht direkt ausgeführt werden (winget existiert nur unter Windows). |
| | | Der GitHub- und Direkt-Link-Pfad wurden dagegen live gegen echte |
| | | Schnittstellen getestet. |
| | | - **Keine mehrsprachige Oberfläche** â komplett auf Deutsch hartkodiert. |
| | | ## 5. Auslieferung als eine Datei |
| | | |
| | | ## 8. Konfigurationspunkte für die Anpassung |
| | | `npm run build:exe` in vier Schritten: |
| | | |
| | | | Was | Wo | |
| | | |---|---| |
| | | | Client-Ordner auf Zielrechnern | `CLIENT_ORDNER` in `app.py` | |
| | | | Port des Webservers | `app.run(..., port=5000)` am Ende von `app.py`, sowie in den `.bat`-Dateien/Firewall-Skript | |
| | | | Schwelle "Bitte prüfen"-Warnung | `TAGE_BIS_WARNUNG` in `app.py` (aktuell 30 Tage) | |
| | | | Erlaubte Bildformate für Doku-Upload | `ERLAUBTE_BILD_ENDUNGEN` in `app.py` | |
| | | | Max. UploadgröÃe | `app.config["MAX_CONTENT_LENGTH"]` in `app.py` | |
| | | | Versionsmarker (Diagnose, ob neue Version läuft) | `APP_VERSION` in `app.py` | |
| | | 1. TypeScript übersetzen |
| | | 2. Server mit esbuild zu einer CommonJS-Datei bündeln |
| | | 3. Die Oberfläche als Einzelressourcen in die SEA-Konfiguration eintragen |
| | | 4. Node-Programmdatei kopieren und den Blob per `postject` hineinschreiben |
| | | |
| | | **Warum Einzelressourcen und nicht ein eingebettetes Bündel:** Ein früher Versuch hat das |
| | | JavaScript-Bündel als Zeichenkette in die Server-Datei eingebettet. Die eingebettete |
| | | Schriftart enthält Bytefolgen, die als UTF-8 ungültig sind â sie wurden zu U+FFFD und das |
| | | Bündel damit unbrauchbar. Die Seite blieb weiÃ, ohne Fehlermeldung. Aufgefallen ist es |
| | | erst beim Vergleich der ausgelieferten Bytes mit der Datei auf der Platte. Ãber |
| | | `sea.getRawAsset()` kommen die Ressourcen unverändert an. |
| | | |
| | | **Warum esbuild und nicht webpack:** webpack scheitert an einer Abhängigkeit in der |
| | | Server-Kette, deren `tsconfig.json` per `extends` auf ein nicht aufgelöstes Paket zeigt. |
| | | esbuild kümmert das nicht. |
| | | |
| | | Der ZIP-Erzeuger für Mehrfach-Downloads ist von Hand geschrieben (nur speichernd, ohne |
| | | Kompression â Installer sind ohnehin komprimiert). Das spart eine Abhängigkeit in einem |
| | | Bündel, das ohne Fremdcode auskommen soll. |
| | | |
| | | --- |
| | | |
| | | ## 6. Datenmodell |
| | | |
| | | Eine Tabelle `software` und eine Tabelle für das Zugriffsprotokoll. Neue Spalten kommen |
| | | über eine Liste hinzu, die beim Start mit dem tatsächlichen Schema abgeglichen wird â |
| | | fehlende werden ergänzt. Damit übersteht eine bestehende Installation ein Update ohne |
| | | Handarbeit. |
| | | |
| | | Ordnernamen im Datenverzeichnis haben denselben Weg: Eine Umbenennungsliste beim Start |
| | | zieht ältere Installationen nach. |
| | | |
| | | Zur Version: Angezeigt wird das **Veröffentlichungsdatum des Anbieters**, nicht der |
| | | Abrufzeitpunkt. Nur wenn eine Quelle keins liefert, wird ersatzweise der Abrufzeitpunkt |
| | | genannt und als solcher benannt â die Frage âist das aktuell?" beantwortet das Datum des |
| | | Anbieters, unser Abrufzeitpunkt sagt darüber nichts. |
| | | |
| | | --- |
| | | |
| | | ## 7. Grenzen |
| | | |
| | | - **Kein HTTPS.** Damit fehlt dem Browser der sichere Kontext, und die Schnittstellen für |
| | | Dateizugriff (`showDirectoryPicker`, `crypto.subtle`) stehen an der Netzwerkadresse nicht |
| | | zur Verfügung. Nachgemessen: an `localhost` vorhanden, an der IP nicht. |
| | | - **Kein Dienstbetrieb.** Die Programmdatei muss von Hand gestartet werden. |
| | | - **Keine automatisierten Tests.** Geprüft wurde bisher von Hand gegen die echten Quellen. |
| | | - **Keine Rollen.** Wer am Server sitzt, darf alles. |
| | | - **Ein hinterlegtes Geheimnis lässt sich über die Oberfläche nicht einzeln löschen** â |
| | | ein leeres Feld bedeutet âunverändert lassen". Nur über die Schnittstelle mit einer |
| | | Liste, die den Namen weglässt. |
| | | - **Bauvorgänge laufen mit den Rechten des Dienstkontos.** Wer einen Bau-Befehl hinterlegen |
| | | kann, kann Code auf dem Server ausführen. Das setzt Vertrauen in die angebundenen |
| | | Repositories voraus. |
| | |
| | | # Server-Bibliothek â Prototyp |
| | | # Software-Bibliothek |
| | | |
| | | > **Für eine technische Ãbergabe (z. B. Git-Repository):** Diese Datei ist |
| | | > die Bedienungsanleitung. Die technische Architektur (Datenbankschema, |
| | | > Kernkonzepte, bekannte Grenzen) steht in `ARCHITEKTUR.md`, die |
| | | > Entwicklungshistorie in `CHANGELOG.md`. |
| | | Zentrale Softwareverteilung für den Betrieb: Ein Server hält Installer und interne |
| | | Werkzeuge auf dem aktuellsten Stand, alle anderen laden sie über den Browser herunter. |
| | | |
| | | Ein einfaches internes Web-Tool für euren Windows-11-Server: |
| | | - Katalog aller Software-Tools mit Version, Installer-Pfad und Dokumentation |
| | | - Status-Anzeige: **Aktuell / Bitte prüfen / Nie geprüft** (Warnung ab 30 Tagen ohne Prüfung) |
| | | - Protokoll: welcher Mitarbeiter hat welche Software auf welchem Rechner bekommen |
| | | Die Bibliothek holt sich die Dateien selbst â aus GitHub-Releases, über winget, aus SVN, |
| | | von einem Direktlink oder indem sie ein Git-Repository klont und baut. Es wird nichts von |
| | | Hand hochgeladen, und niemand muss mehr suchen, ob eine Version noch aktuell ist. |
| | | |
| | | Läuft komplett lokal auf eurem Server, keine Cloud, keine Installation von Zusatzsoftware |
| | | auÃer Python. Die Daten liegen in einer einzigen Datei (`data/bibliothek.db`). |
| | | Die technische Innensicht steht in [ARCHITEKTUR.md](ARCHITEKTUR.md). |
| | | |
| | | --- |
| | | |
| | | ## 1. Einmalige Einrichtung auf dem Server |
| | | ## Betrieb |
| | | |
| | | ### Schritt 1 â Ordner kopieren |
| | | Kopiert den kompletten Ordner `server-bibliothek` auf den Windows-11-Server, |
| | | z. B. nach `C:\server-bibliothek`. |
| | | ### Die fertige Programmdatei |
| | | |
| | | ### Schritt 2 â Python installieren (falls noch nicht vorhanden) |
| | | 1. Ãffnet im Browser: https://www.python.org/downloads/ |
| | | 2. Ladet die neueste Version für Windows herunter und startet die Installation. |
| | | 3. **Wichtig:** Auf der ersten Seite des Installers unten das Kästchen |
| | | **"Add python.exe to PATH"** anhaken, bevor ihr auf "Install Now" klickt. |
| | | Der Auslieferungsstand ist eine einzelne `software-bibliothek.exe`. Sie braucht kein |
| | | installiertes Node, keine Laufzeitumgebung und keine Installation â ablegen, starten, |
| | | fertig. Beim ersten Start legt sie ihren Datenordner an und meldet im Fenster, wo er liegt. |
| | | |
| | | ### Schritt 3 â Einrichtung starten |
| | | Im Ordner `C:\server-bibliothek` die Datei **`install.bat`** doppelklicken. |
| | | Es öffnet sich ein schwarzes Fenster, das die benötigte Bibliothek installiert. |
| | | Am Ende steht "Fertig!" â das Fenster kann dann geschlossen werden. |
| | | (Falls Python noch fehlt, sagt dir das Fenster das und verlinkt die Download-Seite.) |
| | | |
| | | ## 2. Programm starten |
| | | |
| | | Im Ordner `C:\server-bibliothek` die Datei **`start.bat`** doppelklicken. |
| | | |
| | | Es öffnet sich ein Fenster mit der Meldung, dass der Server läuft. |
| | | Dieses Fenster muss offen bleiben, solange das Tool genutzt werden soll â |
| | | es ist quasi der "Motor", der die Web-Seite im Hintergrund am Laufen hält. |
| | | |
| | | Jetzt im Browser öffnen: |
| | | ``` |
| | | http://localhost:5000 |
| | | Software-Bibliothek läuft auf http://localhost:3000 |
| | | Daten: C:\ProgramData\Software-Bibliothek\data |
| | | Einstellungen: C:\ProgramData\Software-Bibliothek\konfiguration.json |
| | | ``` |
| | | |
| | | Von **anderen Rechnern im selben Netzwerk** erreicht ihr es über die IP-Adresse |
| | | des Servers, z. B. `http://192.168.1.50:5000` (steht auch direkt im |
| | | Konsolenfenster von `start.bat`, in der Zeile die NICHT mit `127.0.0.1` |
| | | beginnt). |
| | | ### Wo die Daten liegen |
| | | |
| | | **Klappt der Zugriff von einem anderen Rechner nicht?** Das liegt fast immer |
| | | an der Windows-Firewall, die eingehende Verbindungen auf Port 5000 |
| | | standardmäÃig blockiert. Einmalig `firewall-freigabe-einrichten.bat` |
| | | doppelklicken â richtet die nötige Firewall-Regel automatisch ein. |
| | | Standard ist `%ProgramData%\Software-Bibliothek` â bewusst auÃerhalb des Programmordners, |
| | | damit ein Austausch der Programmdatei nichts mitreiÃt. Ein anderer Ort lässt sich in den |
| | | Einstellungen hinterlegen (Zahnrad oben rechts, nur direkt am Server erreichbar). |
| | | |
| | | Zum Beenden: das Konsolenfenster einfach schlieÃen (oder `STRG+C` drücken). |
| | | | Unterordner | Inhalt | |
| | | |---|---| |
| | | | `files/<id>/` | Die bereitgestellten Programmdateien â das, was heruntergeladen wird | |
| | | | `sources/<id>/` | Arbeitskopien der Git-Repositories, die selbst gebaut werden | |
| | | | `secrets/<id>.json` | Hinterlegte API-Schlüssel; Zugriff auf SYSTEM, Administratoren und das Dienstkonto beschränkt | |
| | | | `bibliothek.db` | Katalog und Zugriffsprotokoll (SQLite) | |
| | | |
| | | Für die Datensicherung genügt dieser Ordner. |
| | | |
| | | ### Wer was darf |
| | | |
| | | Die Grenze verläuft an der Netzwerkschnittstelle, nicht an einer Anmeldung: |
| | | |
| | | - **Direkt am Server** (`localhost`) â vollständige Verwaltung: Einträge anlegen, ändern, |
| | | löschen, Quellen prüfen, Schlüssel hinterlegen, Einstellungen, Zugriffsprotokoll. |
| | | - **Aus dem Netzwerk** â Katalog ansehen und herunterladen. Jede verwaltende Route |
| | | antwortet mit 403. |
| | | |
| | | Jeder Download wird protokolliert: Zeitpunkt, IP, aufgelöster Rechnername, Programm. |
| | | |
| | | ### Aktualität |
| | | |
| | | Einmal täglich prüft die Bibliothek alle Quellen selbsttätig; âAlle prüfen" stöÃt es von |
| | | Hand an. Liefert eine Quelle nichts Brauchbares â Seite tot, HTML statt Programmdatei, |
| | | plötzlich winzige Datei â bleibt die zuletzt bereitgestellte Datei unangetastet und der |
| | | Eintrag meldet das Problem. Ein kaputter Link führt nie dazu, dass ein funktionierender |
| | | Download verschwindet. |
| | | |
| | | --- |
| | | |
| | | ## 3. Bedienung |
| | | ## Entwicklung |
| | | |
| | | - **Software-Katalog** (Startseite): alle Tools mit Status auf einen Blick. |
| | | Ãber "+ Neues Tool anlegen" ein neues Tool mit Installer-Pfad, Silent-Parametern |
| | | und Doku-Text hinterlegen. |
| | | - Auf einem Tool auf **Details** klicken â dort steht die komplette Anleitung, |
| | | auÃerdem der Button **"Jetzt als geprüft markieren"** â das solltet ihr klicken, |
| | | wann immer ihr den Installer manuell aktualisiert habt. Danach zählt der |
| | | 30-Tage-Timer wieder von vorn. |
| | | - **Zuweisungen**: Protokolliert dort jede Installation (Mitarbeiter, Rechnername, |
| | | installierte Version, Datum). Ãber die Filterfelder lässt sich nach Mitarbeiter |
| | | oder Rechnername suchen. |
| | | Node 22 oder neuer (die eingebaute SQLite-Schnittstelle wird gebraucht), npm-Workspaces. |
| | | |
| | | ```bash |
| | | npm install |
| | | ``` |
| | | |
| | | Server und Oberfläche laufen getrennt: |
| | | |
| | | ```bash |
| | | npm run dev:server |
| | | ``` |
| | | |
| | | ```bash |
| | | npm run dev:client |
| | | ``` |
| | | |
| | | Der Server bedient `http://localhost:3000` und liefert dort auch die gebaute Oberfläche |
| | | aus. Der Entwicklungsserver auf `http://localhost:5000` bringt Hot Reload mit und leitet |
| | | `/api` an Port 3000 weiter â Ãnderungen an der Oberfläche sind dort sofort sichtbar, auf |
| | | Port 3000 erst nach einem Bau. |
| | | |
| | | ### Aufteilung |
| | | |
| | | | Paket | Inhalt | |
| | | |---|---| |
| | | | `packages/bibliothek-types` | Gemeinsame Typen â der Vertrag zwischen Server und Oberfläche | |
| | | | `packages/bibliothek-server` | Express, SQLite, Bezugsquellen, Zugriffsgrenze | |
| | | | `packages/bibliothek-client` | Oberfläche aus dh-components (Lit) | |
| | | |
| | | ### Bauen |
| | | |
| | | ```bash |
| | | npm run build |
| | | ``` |
| | | |
| | | ```bash |
| | | npm run build:exe |
| | | ``` |
| | | |
| | | Der zweite Befehl erzeugt die eigenständige Programmdatei: TypeScript übersetzen, den |
| | | Server mit esbuild bündeln, die Oberfläche als Ressourcen einbetten und beides über Nodes |
| | | Single-Executable-Verfahren in eine `.exe` schreiben. |
| | | |
| | | --- |
| | | |
| | | ## 3a. Rechner einrichten (Verteilung an neue Rechner) |
| | | ## Ein internes Werkzeug anbinden |
| | | |
| | | Menüpunkt "Rechner einrichten": Häkchen bei den benötigten Tools setzen, |
| | | Mitarbeiter eintragen, "Installations-Paket herunterladen" klicken. Das |
| | | erzeugte ZIP enthält die ausgewählten Installer-Dateien **direkt mit** (kein |
| | | Nachladen vom Server nötig). |
| | | Werkzeuge, die selbst gebaut werden, beschreiben ihren Bau im eigenen Repository â die |
| | | Bibliothek muss nichts über sie wissen. Dafür liegt eine `software-bibliothek.json` im |
| | | Wurzelverzeichnis: |
| | | |
| | | Auf dem **neuen Rechner**: ZIP entpacken, `Rechner-einrichten-starten.bat` |
| | | doppelklicken. Das Skript: |
| | | - fragt bei Bedarf automatisch nach Admin-Rechten |
| | | - installiert jedes Tool still mit dem hinterlegten Silent-Parameter |
| | | - trägt automatisch eine Windows-Defender-Ausnahme für `C:\ClientBibliothek` |
| | | ein (dort landen alle Dateien gesammelt) |
| | | - meldet das Ergebnis am Ende automatisch ans zentrale Protokoll zurück |
| | | (Rechnername wird dabei selbst erkannt) |
| | | ```json |
| | | { |
| | | "buildCommand": "npm install && npm run build-win", |
| | | "artifact": "dist/MeinWerkzeug.exe" |
| | | } |
| | | ``` |
| | | |
| | | Tools ohne einzelne Installer-Datei (z. B. bereits entpackte/portable Tools |
| | | wie Blender) werden als ZIP nach `C:\ClientBibliothek\pakete\` gelegt â |
| | | bewusst ohne automatisches Entpacken, das entscheidet ihr von Fall zu Fall |
| | | selbst. |
| | | Braucht der Bau einen API-Schlüssel, wird er dort deklariert und sein Wert in der |
| | | Bibliothek hinterlegt; beim Bau steht er als Umgebungsvariable bereit. Das vollständige |
| | | Verfahren beschreibt der Skill `build-secrets` im Repository `dh-claude-rules`. |
| | | |
| | | â ï¸ Die Windows-spezifischen Teile (Admin-Rechte-Anfrage, stille Installation) |
| | | wurden nicht auf einem echten Windows-Rechner entwickelt und getestet, nur |
| | | mit dem echten PowerShell-Parser auf Syntaxfehler geprüft sowie die |
| | | Netzwerk-/Datei-Logik separat verifiziert. Bei gröÃerem Rollout zuerst an |
| | | 1â2 unkritischen Rechnern gegentesten. |
| | | |
| | | **Zugriff aus dem Netzwerk:** Ruft jemand die Bibliothek nicht direkt am |
| | | Server, sondern über das Netzwerk auf (z. B. von einem anderen PC), sieht |
| | | diese Person nur diese eingeschränkte Seite (dort "Software anfordern" |
| | | genannt) â der Software-Katalog, die Protokolle usw. bleiben dem Server |
| | | selbst vorbehalten (z. B. per RDP-Sitzung, dort im Browser "localhost" |
| | | statt der Netzwerk-Adresse aufrufen). Jeder Zugriff/Download aus dem |
| | | Netzwerk wird im Zugriffsprotokoll (Menüpunkt "Zugriffsprotokoll", |
| | | ebenfalls nur direkt am Server einsehbar) mit IP-Adresse und angegebenem |
| | | Namen festgehalten. |
| | | Im Katalog wird als Quelle **nur die Repository-URL ohne Benutzernamen** eingetragen â die |
| | | Kennung kommt aus den Einstellungen der Bibliothek, damit ein Kontowechsel nicht jeden |
| | | Eintrag berührt. |
| | | |
| | | --- |
| | | |
| | | ## 4. Automatische Aktualisierung |
| | | ## Offen |
| | | |
| | | Bei jedem Tool kann beim Anlegen/Bearbeiten "Automatische Aktualisierung |
| | | aktivieren" angehakt werden. Dann sucht das Tool selbst nach neuen Versionen |
| | | und **ersetzt die Installer-Datei automatisch, ohne Rückfrage**. Dabei: |
| | | |
| | | - wird die alte Datei vorher immer in einen Unterordner `_sicherungen` |
| | | kopiert (Sicherheitsnetz, falls mal etwas schiefgeht) |
| | | - wird jeder Check protokolliert (sichtbar auf der Detailseite des Tools |
| | | unter "Update-Protokoll") |
| | | - bleibt ein Tool bei "Bitte prüfen" stehen, wenn eine Prüfung fehlschlägt |
| | | (z. B. Server nicht erreichbar) â Fehler werden nie stillschweigend |
| | | übergangen |
| | | |
| | | Als Quelle stehen drei Möglichkeiten zur Wahl: **winget** (für die meisten |
| | | bekannten Programme, siehe `Update-Quellen-Uebersicht.md`), **GitHub-Projekt** |
| | | (für Open-Source-Tools) oder ein **direkter Download-Link**. Tools ohne |
| | | Häkchen bleiben komplett unangetastet â ideal für interne oder bewusst |
| | | fest angepinnte Software. |
| | | |
| | | **Update jetzt manuell auslösen:** |
| | | - Für ein einzelnes Tool: auf der Detailseite "Jetzt automatisch prüfen & aktualisieren" |
| | | - Für alle Tools auf einmal: Button oben im Software-Katalog, oder Doppelklick auf `pruefe_updates.bat` |
| | | |
| | | **Täglich automatisch im Hintergrund laufen lassen (empfohlen):** |
| | | Einmalig `automatisierung_einrichten.bat` doppelklicken. Das richtet in der |
| | | Windows-Aufgabenplanung einen täglichen Lauf um 03:00 Uhr nachts ein â dann |
| | | kümmert sich das Tool von selbst darum, auch wenn niemand die Weboberfläche |
| | | geöffnet hat. Falls eine Fehlermeldung kommt: Rechtsklick auf die Datei â |
| | | "Als Administrator ausführen". |
| | | |
| | | â ï¸ **Hinweis zum winget-Pfad:** Dieser Teil konnte in meiner Entwicklungsumgebung |
| | | nicht direkt getestet werden, da winget nur unter Windows existiert (der |
| | | GitHub-Pfad wurde dagegen live gegen die echte GitHub-Schnittstelle getestet). |
| | | Probiert den winget-Weg daher am besten zuerst an einem unkritischen Tool aus, |
| | | bevor ihr euch vollständig darauf verlasst. |
| | | |
| | | ## 5. Dauerhafter Betrieb der Weboberfläche |
| | | |
| | | Aktuell müsst ihr `start.bat` manuell starten, wenn ihr den Katalog im Browser |
| | | ansehen wollt â die automatische Update-Prüfung (siehe oben) läuft davon |
| | | unabhängig auch ohne offene Weboberfläche. Falls gewünscht, kann auch die |
| | | Weboberfläche selbst automatisch mit dem Server starten â dafür einfach |
| | | melden, wenn ihr so weit seid. |
| | | |
| | | ## 6. Datensicherung |
| | | |
| | | Alle Daten liegen in der Datei `data\bibliothek.db`. Diese Datei ab und zu zu |
| | | sichern (z. B. in euer normales Backup einschlieÃen) reicht als Absicherung |
| | | gegen Datenverlust vollständig aus. |
| | | - HTTPS; solange die Verbindung unverschlüsselt ist, bleiben dem Browser die Schnittstellen |
| | | für Dateizugriff verwehrt |
| | | - Betrieb als Windows-Dienst mit Autostart |
| | | - Automatisierte Tests |
| | | - Rollen und Rechte (im Ticket bewusst zurückgestellt) |
| Neue Datei |
| | |
| | | /** |
| | | * Baut aus dem gebündelten Server und der gebauten Oberfläche eine einzelne |
| | | * ausführbare Datei (Node SEA). Die Dateien der Oberfläche werden als Assets |
| | | * eingebettet und zur Laufzeit binärsicher aus der exe ausgeliefert. |
| | | * |
| | | * Voraussetzung: `npm run build` und `npm run bundle -w packages/bibliothek-server`. |
| | | */ |
| | | const { execFileSync } = require('child_process'); |
| | | const fs = require('fs'); |
| | | const path = require('path'); |
| | | |
| | | const EXE_NAME = 'Software-Bibliothek.exe'; |
| | | const SENTINEL = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2'; |
| | | |
| | | const wurzel = __dirname; |
| | | const oberflaechenOrdner = path.join(wurzel, 'packages', 'bibliothek-client', 'dist'); |
| | | const bundleOrdner = path.join(wurzel, 'packages', 'bibliothek-server', 'build-exe'); |
| | | const konfigPfad = path.join(bundleOrdner, 'sea-config.json'); |
| | | const blobPfad = path.join(bundleOrdner, 'sea-prep.blob'); |
| | | const ausgabeOrdner = path.join(wurzel, 'exe'); |
| | | const exePfad = path.join(ausgabeOrdner, EXE_NAME); |
| | | |
| | | function schritt(beschreibung, aktion) { |
| | | process.stdout.write(`${beschreibung} ⦠`); |
| | | const ergebnis = aktion(); |
| | | console.log(ergebnis ?? 'ok'); |
| | | } |
| | | |
| | | schritt('Oberflaechen-Dateien einsammeln', () => { |
| | | const dateien = fs.readdirSync(oberflaechenOrdner).filter((name) => fs.statSync(path.join(oberflaechenOrdner, name)).isFile()); |
| | | if (!dateien.includes('index.html')) { |
| | | throw new Error(`In ${oberflaechenOrdner} fehlt index.html â wurde der Client gebaut?`); |
| | | } |
| | | const assets = {}; |
| | | for (const name of dateien) { |
| | | assets[name] = path.relative(wurzel, path.join(oberflaechenOrdner, name)).replace(/\\/g, '/'); |
| | | } |
| | | fs.writeFileSync( |
| | | konfigPfad, |
| | | JSON.stringify( |
| | | { |
| | | main: path.relative(wurzel, path.join(bundleOrdner, 'server.cjs')).replace(/\\/g, '/'), |
| | | output: path.relative(wurzel, blobPfad).replace(/\\/g, '/'), |
| | | disableExperimentalSEAWarning: true, |
| | | assets, |
| | | }, |
| | | null, |
| | | 2 |
| | | ) |
| | | ); |
| | | return `${dateien.length} Datei(en)`; |
| | | }); |
| | | |
| | | schritt('Blob erzeugen (Server + eingebettete Oberflaeche)', () => { |
| | | execFileSync(process.execPath, ['--experimental-sea-config', konfigPfad], { stdio: 'pipe', cwd: wurzel }); |
| | | }); |
| | | |
| | | schritt('Node-Binary kopieren', () => { |
| | | fs.mkdirSync(ausgabeOrdner, { recursive: true }); |
| | | fs.copyFileSync(process.execPath, exePfad); |
| | | }); |
| | | |
| | | schritt('Code in die exe injizieren', () => { |
| | | execFileSync('npx', ['--yes', 'postject', exePfad, 'NODE_SEA_BLOB', blobPfad, '--sentinel-fuse', SENTINEL], { |
| | | stdio: 'pipe', |
| | | shell: process.platform === 'win32', |
| | | }); |
| | | }); |
| | | |
| | | const groesseMb = (fs.statSync(exePfad).size / 1024 / 1024).toFixed(1); |
| | | console.log(`\nFertig: ${exePfad} (${groesseMb} MB)`); |
| | | console.log('Zum Betrieb: exe starten, dann http://localhost:3000 aufrufen.'); |
| | | console.log('Die Nutzdaten (Datenbank + geholte Installer) landen im Ordner "data" neben der exe.'); |
| Neue Datei |
| | |
| | | """Backend des Software-Bibliothek-Clients. |
| | | |
| | | Der Client zeigt den Katalog der Bibliothek, legt heruntergeladene Programme an |
| | | einen festen Ort und meldet, wenn ein Werkzeug dort veraltet ist. Anders als die |
| | | Weboberflaeche darf er ins Dateisystem sehen â das ist sein einziger Daseinszweck. |
| | | """ |
| Neue Datei |
| | |
| | | """Startpunkt: oeffnet ein natives Fenster und verbindet es mit der Api. |
| | | |
| | | Start: py -m backend (aus dem Ordner `desktop`, im aktivierten venv) |
| | | |
| | | Das Fenster nutzt das WebView2 von Windows â dieselbe Engine, die der Browser |
| | | ohnehin mitbringt. Es wird also kein zweites Chromium ausgeliefert. |
| | | """ |
| | | |
| | | from __future__ import annotations |
| | | |
| | | import sys |
| | | from pathlib import Path |
| | | |
| | | import webview |
| | | |
| | | from .api import Api |
| | | |
| | | # Im Betrieb aus dem Quelltext liegt die gebaute Oberflaeche unter |
| | | # <projekt>/frontend/dist; in der PyInstaller-exe im Bundle-Verzeichnis. |
| | | if getattr(sys, "frozen", False): |
| | | _BASIS = Path(sys._MEIPASS) # type: ignore[attr-defined] |
| | | else: |
| | | _BASIS = Path(__file__).resolve().parent.parent |
| | | |
| | | FRONTEND_INDEX = _BASIS / "frontend" / "dist" / "index.html" |
| | | |
| | | _PLATZHALTER_HTML = """ |
| | | <!doctype html><html lang="de"><body style="font-family:sans-serif;padding:2rem"> |
| | | <h1>Oberfläche noch nicht gebaut</h1> |
| | | <p>Im Ordner <code>desktop/frontend</code> einmal <code>npm install</code> und |
| | | <code>npm run build</code> ausführen, dann erneut starten.</p> |
| | | </body></html> |
| | | """ |
| | | |
| | | |
| | | def main() -> None: |
| | | api = Api() |
| | | gemeinsam = { |
| | | "js_api": api, |
| | | "width": 1100, |
| | | "height": 780, |
| | | "min_size": (820, 600), |
| | | } |
| | | if FRONTEND_INDEX.exists(): |
| | | webview.create_window("Software-Bibliothek", url=str(FRONTEND_INDEX), **gemeinsam) |
| | | else: |
| | | webview.create_window("Software-Bibliothek", html=_PLATZHALTER_HTML, **gemeinsam) |
| | | webview.start(http_server=True) |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | main() |
| Neue Datei |
| | |
| | | """Bruecke zwischen Oberflaeche (pywebview) und Backend. |
| | | |
| | | Jede oeffentliche Methode ist aus dem Frontend als |
| | | `window.pywebview.api.<methode>(...)` erreichbar und liefert JSON-taugliche Werte. |
| | | Fehler werden nicht geworfen, sondern als `{ok: false, meldung: ...}` gemeldet â |
| | | ein Ausnahmefehler ueber die Bruecke kaeme in der Oberflaeche nur als |
| | | unverstaendlicher Text an. |
| | | """ |
| | | |
| | | from __future__ import annotations |
| | | |
| | | import subprocess |
| | | import tempfile |
| | | from dataclasses import asdict |
| | | from pathlib import Path |
| | | from typing import Any |
| | | |
| | | from . import bibliothek, konfiguration |
| | | from .werkzeuge import Werkzeugordner, Zustand |
| | | |
| | | # Installationsprogramme kommen nicht zwischen die eigenstaendigen Werkzeuge â |
| | | # sie werden einmal ausgefuehrt und sind danach entbehrlich. |
| | | UNTERORDNER_INSTALLER = "Installationsprogramme" |
| | | |
| | | # Vorerst melden nur unsere eigenen Werkzeuge Updates. Fremdprogramme kuemmern |
| | | # sich teils selbst darum, und wo nicht, fehlt uns ein verlaesslicher Weg, ihre |
| | | # installierte Fassung zu erkennen. Bewusst eng gehalten, leicht zu weiten. |
| | | EINSTUFUNG_MIT_UPDATEPRUEFUNG = "intern" |
| | | |
| | | |
| | | class Api: |
| | | def __init__(self) -> None: |
| | | self._einstellungen = konfiguration.laden() |
| | | self._ordner = Werkzeugordner(self._einstellungen.werkzeugOrdner) |
| | | self._ordner.altlasten_aufraeumen() |
| | | |
| | | # --- Einstellungen --- |
| | | |
| | | def einstellungen(self) -> dict[str, Any]: |
| | | return { |
| | | "einstellungen": asdict(self._einstellungen), |
| | | "vorgaben": { |
| | | "serverAdresse": konfiguration.STANDARD_SERVER, |
| | | "werkzeugOrdner": konfiguration.STANDARD_WERKZEUG_ORDNER, |
| | | }, |
| | | } |
| | | |
| | | def einstellungen_speichern(self, serverAdresse: str, werkzeugOrdner: str) -> dict[str, Any]: |
| | | self._einstellungen = konfiguration.speichern( |
| | | konfiguration.Einstellungen(serverAdresse=serverAdresse, werkzeugOrdner=werkzeugOrdner) |
| | | ) |
| | | self._ordner = Werkzeugordner(self._einstellungen.werkzeugOrdner) |
| | | return {"ok": True, **self.einstellungen()} |
| | | |
| | | # --- Katalog --- |
| | | |
| | | def katalog(self) -> dict[str, Any]: |
| | | """Katalog der Bibliothek, angereichert um den Befund auf diesem Rechner.""" |
| | | try: |
| | | eintraege = bibliothek.katalog(self._einstellungen.serverAdresse) |
| | | except bibliothek.BibliothekNichtErreichbar as fehler: |
| | | return {"ok": False, "meldung": str(fehler), "eintraege": []} |
| | | |
| | | angereichert = [] |
| | | for eintrag in eintraege: |
| | | eigenstaendig = eintrag.get("programmart") == "eigenstaendig" |
| | | befund = ( |
| | | self._ordner.befund(int(eintrag["id"]), str(eintrag.get("version") or "")) |
| | | if eigenstaendig |
| | | else None |
| | | ) |
| | | angereichert.append( |
| | | { |
| | | **eintrag, |
| | | "lokal": asdict(befund) if befund else None, |
| | | "ueberwacht": eigenstaendig |
| | | and eintrag.get("einstufung") == EINSTUFUNG_MIT_UPDATEPRUEFUNG, |
| | | } |
| | | ) |
| | | return {"ok": True, "eintraege": angereichert, "ordner": str(self._ordner.ordner)} |
| | | |
| | | def veraltete(self) -> list[dict[str, str]]: |
| | | """Kurzfassung fuer die Meldung beim Start: was ist ueberwacht und nicht aktuell.""" |
| | | stand = self.katalog() |
| | | if not stand["ok"]: |
| | | return [] |
| | | return [ |
| | | {"name": eintrag["name"], "zustand": eintrag["lokal"]["zustand"]} |
| | | for eintrag in stand["eintraege"] |
| | | if eintrag["ueberwacht"] |
| | | and eintrag["lokal"] |
| | | and eintrag["lokal"]["zustand"] in (Zustand.VERALTET.value, Zustand.FREMD.value) |
| | | ] |
| | | |
| | | # --- Holen --- |
| | | |
| | | def herunterladen(self, software_id: int, dateiname: str, version: str, programmart: str) -> dict[str, Any]: |
| | | """Holt eine Datei und legt sie am passenden Ort ab. |
| | | |
| | | Eigenstaendige Programme kommen in den Werkzeug-Ordner und werden im Stand |
| | | vermerkt; Installationsprogramme daneben in einen eigenen Unterordner, ohne |
| | | Vermerk â was installiert ist, sagt der Ordner ohnehin nicht. |
| | | """ |
| | | if not dateiname: |
| | | return {"ok": False, "meldung": "Für diesen Eintrag liegt keine Datei bereit."} |
| | | |
| | | eigenstaendig = programmart == "eigenstaendig" |
| | | with tempfile.TemporaryDirectory(prefix="bibliothek-") as zwischenlager: |
| | | zwischenziel = Path(zwischenlager) / dateiname |
| | | try: |
| | | bibliothek.datei_holen(self._einstellungen.serverAdresse, int(software_id), zwischenziel) |
| | | except bibliothek.BibliothekNichtErreichbar as fehler: |
| | | return {"ok": False, "meldung": str(fehler)} |
| | | |
| | | try: |
| | | if eigenstaendig: |
| | | abgelegt = self._ordner.ablegen(int(software_id), zwischenziel, dateiname, version) |
| | | else: |
| | | ziel = self._ordner.ordner / UNTERORDNER_INSTALLER / dateiname |
| | | ziel.parent.mkdir(parents=True, exist_ok=True) |
| | | ziel.unlink(missing_ok=True) |
| | | zwischenziel.replace(ziel) |
| | | abgelegt = ziel |
| | | except OSError as fehler: |
| | | return {"ok": False, "meldung": f"Ablegen fehlgeschlagen: {fehler}"} |
| | | |
| | | return { |
| | | "ok": True, |
| | | "pfad": str(abgelegt), |
| | | "meldung": ( |
| | | f"Abgelegt unter {abgelegt}. Lief das Programm gerade, wirkt die neue " |
| | | "Fassung nach dem nächsten Start." |
| | | if eigenstaendig |
| | | else f"Abgelegt unter {abgelegt}. Nach der Installation kann die Datei gelöscht werden." |
| | | ), |
| | | } |
| | | |
| | | # --- Windows-Handreichungen --- |
| | | |
| | | def ordner_oeffnen(self, unterordner: str = "") -> dict[str, Any]: |
| | | ziel = self._ordner.ordner / unterordner if unterordner else self._ordner.ordner |
| | | ziel.mkdir(parents=True, exist_ok=True) |
| | | subprocess.Popen(["explorer", str(ziel)]) # noqa: S603,S607 - fester Befehl |
| | | return {"ok": True} |
| | | |
| | | def verknuepfung_anlegen(self, pfad: str, name: str) -> dict[str, Any]: |
| | | """Legt eine Verknuepfung auf dem Desktop an â das kann kein Browser.""" |
| | | ziel = Path(pfad) |
| | | if not ziel.exists(): |
| | | return {"ok": False, "meldung": "Die Datei liegt nicht (mehr) an diesem Ort."} |
| | | skript = ( |
| | | "$verknuepfung = (New-Object -ComObject WScript.Shell)." |
| | | "CreateShortcut([Environment]::GetFolderPath('Desktop') + '\\' + $env:VERKNUEPFUNG_NAME + '.lnk');" |
| | | "$verknuepfung.TargetPath = $env:VERKNUEPFUNG_ZIEL;" |
| | | "$verknuepfung.WorkingDirectory = Split-Path $env:VERKNUEPFUNG_ZIEL;" |
| | | "$verknuepfung.Save()" |
| | | ) |
| | | try: |
| | | subprocess.run( # noqa: S603 |
| | | ["powershell", "-NoProfile", "-NonInteractive", "-Command", skript], |
| | | check=True, |
| | | capture_output=True, |
| | | timeout=30, |
| | | env={**_umgebung(), "VERKNUEPFUNG_ZIEL": str(ziel), "VERKNUEPFUNG_NAME": _dateiname_saeubern(name)}, |
| | | ) |
| | | except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as fehler: |
| | | return {"ok": False, "meldung": f"Verknüpfung fehlgeschlagen: {fehler}"} |
| | | return {"ok": True, "meldung": f"Verknüpfung â{name}â liegt auf dem Desktop."} |
| | | |
| | | |
| | | def _umgebung() -> dict[str, str]: |
| | | import os |
| | | |
| | | return dict(os.environ) |
| | | |
| | | |
| | | def _dateiname_saeubern(name: str) -> str: |
| | | verboten = '\\/:*?"<>|' |
| | | gesaeubert = "".join(zeichen for zeichen in name if zeichen not in verboten).strip() |
| | | return gesaeubert or "Programm" |
| Neue Datei |
| | |
| | | """Zugriff auf den Server der Software-Bibliothek. |
| | | |
| | | Nur die beiden Routen, die aus dem Netzwerk erreichbar sind: Katalog lesen und |
| | | Datei holen. Die Verwaltung bleibt der Weboberflaeche am Server vorbehalten. |
| | | |
| | | Bewusst nur mit der Standardbibliothek â jede Abhaengigkeit mehr muss beim Bau |
| | | der exe mitgeschleppt werden, und fuer zwei GET-Aufrufe lohnt das nicht. |
| | | """ |
| | | |
| | | from __future__ import annotations |
| | | |
| | | import json |
| | | import urllib.error |
| | | import urllib.request |
| | | from pathlib import Path |
| | | from typing import Any, Callable |
| | | |
| | | ZEITGRENZE_KATALOG_S = 15 |
| | | ZEITGRENZE_DOWNLOAD_S = 600 |
| | | |
| | | |
| | | class BibliothekNichtErreichbar(Exception): |
| | | """Der Server antwortet nicht oder liefert etwas Unerwartetes.""" |
| | | |
| | | |
| | | def _lesbarer_fehler(adresse: str, fehler: Exception) -> BibliothekNichtErreichbar: |
| | | if isinstance(fehler, urllib.error.HTTPError): |
| | | return BibliothekNichtErreichbar(f"{adresse} antwortet mit HTTP {fehler.code}.") |
| | | if isinstance(fehler, urllib.error.URLError): |
| | | return BibliothekNichtErreichbar(f"{adresse} ist nicht erreichbar: {fehler.reason}") |
| | | return BibliothekNichtErreichbar(f"{adresse}: {fehler}") |
| | | |
| | | |
| | | def katalog(server_adresse: str) -> list[dict[str, Any]]: |
| | | """Holt alle Katalogeintraege.""" |
| | | adresse = f"{server_adresse.rstrip('/')}/api/software" |
| | | try: |
| | | with urllib.request.urlopen(adresse, timeout=ZEITGRENZE_KATALOG_S) as antwort: |
| | | gelesen = json.loads(antwort.read().decode("utf-8")) |
| | | except Exception as fehler: # noqa: BLE001 - jede Ursache wird zur selben Meldung |
| | | raise _lesbarer_fehler(server_adresse, fehler) from fehler |
| | | if not isinstance(gelesen, list): |
| | | raise BibliothekNichtErreichbar(f"{server_adresse} liefert keinen Katalog.") |
| | | return gelesen |
| | | |
| | | |
| | | def datei_holen( |
| | | server_adresse: str, |
| | | software_id: int, |
| | | ziel: Path, |
| | | fortschritt: Callable[[int, int], None] | None = None, |
| | | ) -> Path: |
| | | """Laedt die bereitgestellte Datei nach `ziel`. |
| | | |
| | | Geschrieben wird zuerst nach `<ziel>.teil` und erst nach vollstaendigem |
| | | Empfang umbenannt. Ein Abbruch mitten im Download darf keine halbe Datei |
| | | hinterlassen, die spaeter fuer ein fertiges Programm gehalten wird. |
| | | """ |
| | | adresse = f"{server_adresse.rstrip('/')}/api/software/{software_id}/download" |
| | | unfertig = ziel.with_name(ziel.name + ".teil") |
| | | ziel.parent.mkdir(parents=True, exist_ok=True) |
| | | try: |
| | | with urllib.request.urlopen(adresse, timeout=ZEITGRENZE_DOWNLOAD_S) as antwort: |
| | | gesamt = int(antwort.headers.get("Content-Length") or 0) |
| | | geladen = 0 |
| | | with unfertig.open("wb") as datei: |
| | | while True: |
| | | brocken = antwort.read(256 * 1024) |
| | | if not brocken: |
| | | break |
| | | datei.write(brocken) |
| | | geladen += len(brocken) |
| | | if fortschritt: |
| | | fortschritt(geladen, gesamt) |
| | | except Exception as fehler: # noqa: BLE001 |
| | | unfertig.unlink(missing_ok=True) |
| | | raise _lesbarer_fehler(server_adresse, fehler) from fehler |
| | | |
| | | if unfertig.stat().st_size == 0: |
| | | unfertig.unlink(missing_ok=True) |
| | | raise BibliothekNichtErreichbar("Der Server hat eine leere Datei geliefert.") |
| | | |
| | | ziel.unlink(missing_ok=True) |
| | | unfertig.replace(ziel) |
| | | return ziel |
| Neue Datei |
| | |
| | | """Einstellungen des Clients: Wo steht die Bibliothek, wohin kommen die Werkzeuge. |
| | | |
| | | Bewusst einstellbar statt eingebaut: Die Bibliothek zieht spaeter auf einen anderen |
| | | Rechner um, und dann soll nicht jeder Arbeitsplatz eine neue exe brauchen. |
| | | """ |
| | | |
| | | from __future__ import annotations |
| | | |
| | | import json |
| | | import os |
| | | from dataclasses import asdict, dataclass |
| | | from pathlib import Path |
| | | |
| | | # Vorgabe, solange nichts eingestellt wurde. Der Rechnername ist dem einer IP |
| | | # vorzuziehen â er ueberlebt einen Wechsel der Adresse. |
| | | STANDARD_SERVER = "http://dhs-main-dt:3000" |
| | | |
| | | # Fester Ort fuer eigenstaendige Programme. Unter C:\ vererbt Windows das |
| | | # Aenderungsrecht an angemeldete Benutzer, ein Update braucht also keine |
| | | # Administratorrechte â anders als unter C:\Programme. |
| | | STANDARD_WERKZEUG_ORDNER = r"C:\D+H\Software" |
| | | |
| | | |
| | | @dataclass |
| | | class Einstellungen: |
| | | serverAdresse: str = STANDARD_SERVER |
| | | werkzeugOrdner: str = STANDARD_WERKZEUG_ORDNER |
| | | |
| | | |
| | | def konfigurations_verzeichnis() -> Path: |
| | | """Je Benutzer, nicht je Installation â die exe darf ausgetauscht werden.""" |
| | | basis = os.environ.get("LOCALAPPDATA") or str(Path.home()) |
| | | return Path(basis) / "Software-Bibliothek-Client" |
| | | |
| | | |
| | | def _konfigurationsdatei() -> Path: |
| | | return konfigurations_verzeichnis() / "konfiguration.json" |
| | | |
| | | |
| | | def laden() -> Einstellungen: |
| | | """Liest die Einstellungen; bei jedem Problem gelten die Vorgaben. |
| | | |
| | | Eine kaputte Datei darf den Start nicht verhindern â der Nutzer soll die |
| | | Adresse in der Oberflaeche korrigieren koennen, statt vor einem toten |
| | | Fenster zu sitzen. |
| | | """ |
| | | datei = _konfigurationsdatei() |
| | | if not datei.exists(): |
| | | return Einstellungen() |
| | | try: |
| | | gelesen = json.loads(datei.read_text(encoding="utf-8")) |
| | | except (OSError, json.JSONDecodeError): |
| | | return Einstellungen() |
| | | if not isinstance(gelesen, dict): |
| | | return Einstellungen() |
| | | return Einstellungen( |
| | | serverAdresse=str(gelesen.get("serverAdresse") or STANDARD_SERVER).strip(), |
| | | werkzeugOrdner=str(gelesen.get("werkzeugOrdner") or STANDARD_WERKZEUG_ORDNER).strip(), |
| | | ) |
| | | |
| | | |
| | | def speichern(einstellungen: Einstellungen) -> Einstellungen: |
| | | """Schreibt die Einstellungen und liefert den bereinigten Stand zurueck.""" |
| | | bereinigt = Einstellungen( |
| | | serverAdresse=(einstellungen.serverAdresse or "").strip().rstrip("/") or STANDARD_SERVER, |
| | | werkzeugOrdner=(einstellungen.werkzeugOrdner or "").strip() or STANDARD_WERKZEUG_ORDNER, |
| | | ) |
| | | verzeichnis = konfigurations_verzeichnis() |
| | | verzeichnis.mkdir(parents=True, exist_ok=True) |
| | | _konfigurationsdatei().write_text( |
| | | json.dumps(asdict(bereinigt), indent=2, ensure_ascii=False), |
| | | encoding="utf-8", |
| | | ) |
| | | return bereinigt |
| Neue Datei |
| | |
| | | """Der Werkzeug-Ordner auf diesem Rechner: lesen, ablegen, vergleichen. |
| | | |
| | | Das ist der Teil, den eine Weboberflaeche nicht kann, und der einzige Grund, |
| | | warum es diesen Client gibt. |
| | | |
| | | Der Kniff gegen jedes Raten: Wir legen die Dateien selbst ab und merken uns dabei |
| | | ihre Pruefsumme. Beim naechsten Start wird nachgerechnet â stimmt sie, wissen wir |
| | | sicher, welche Fassung dort liegt. Stimmt sie nicht, hat jemand von Hand |
| | | eingegriffen, und genau das wird gemeldet statt geschaetzt. |
| | | """ |
| | | |
| | | from __future__ import annotations |
| | | |
| | | import hashlib |
| | | import json |
| | | from dataclasses import dataclass |
| | | from enum import Enum |
| | | from pathlib import Path |
| | | |
| | | # Liegt im Werkzeug-Ordner selbst. Damit zieht der Stand mit, wenn jemand den |
| | | # Ordner verschiebt oder auf einen anderen Rechner kopiert. |
| | | STANDSDATEI = ".bibliothek-stand.json" |
| | | |
| | | # Endung fuer die verdraengte Vorgaengerdatei. Ein laufendes Programm laesst sich |
| | | # unter Windows umbenennen, aber nicht ueberschreiben â daher dieser Umweg. |
| | | ALTLAST_ENDUNG = ".alt" |
| | | |
| | | |
| | | class Zustand(str, Enum): |
| | | FEHLT = "fehlt" |
| | | """Nichts abgelegt â das Werkzeug ist auf diesem Rechner nicht vorhanden.""" |
| | | |
| | | AKTUELL = "aktuell" |
| | | """Liegt da und entspricht genau dem, was die Bibliothek anbietet.""" |
| | | |
| | | VERALTET = "veraltet" |
| | | """Liegt da, aber die Bibliothek bietet inzwischen eine andere Fassung an.""" |
| | | |
| | | FREMD = "fremd" |
| | | """Liegt da, stammt aber nicht von uns â von Hand ersetzt oder veraendert.""" |
| | | |
| | | |
| | | @dataclass |
| | | class Befund: |
| | | zustand: Zustand |
| | | dateiname: str = "" |
| | | abgelegteVersion: str = "" |
| | | pfad: str = "" |
| | | |
| | | |
| | | def pruefsumme(datei: Path) -> str: |
| | | """SHA-256 im Strom â die Dateien sind teils dreistellig viele Megabyte gross.""" |
| | | rechner = hashlib.sha256() |
| | | with datei.open("rb") as offen: |
| | | for brocken in iter(lambda: offen.read(1024 * 1024), b""): |
| | | rechner.update(brocken) |
| | | return rechner.hexdigest() |
| | | |
| | | |
| | | class Werkzeugordner: |
| | | def __init__(self, ordner: str | Path) -> None: |
| | | self.ordner = Path(ordner) |
| | | |
| | | # --- Stand --- |
| | | |
| | | def _standsdatei(self) -> Path: |
| | | return self.ordner / STANDSDATEI |
| | | |
| | | def _stand_lesen(self) -> dict[str, dict[str, str]]: |
| | | datei = self._standsdatei() |
| | | if not datei.exists(): |
| | | return {} |
| | | try: |
| | | gelesen = json.loads(datei.read_text(encoding="utf-8")) |
| | | except (OSError, json.JSONDecodeError): |
| | | return {} |
| | | return gelesen if isinstance(gelesen, dict) else {} |
| | | |
| | | def _stand_schreiben(self, stand: dict[str, dict[str, str]]) -> None: |
| | | self.ordner.mkdir(parents=True, exist_ok=True) |
| | | self._standsdatei().write_text( |
| | | json.dumps(stand, indent=2, ensure_ascii=False), encoding="utf-8" |
| | | ) |
| | | |
| | | # --- Abfrage --- |
| | | |
| | | def befund(self, software_id: int, angeboteneVersion: str) -> Befund: |
| | | """Was liegt hier, und passt es zu dem, was die Bibliothek anbietet?""" |
| | | vermerk = self._stand_lesen().get(str(software_id)) |
| | | if not vermerk: |
| | | return Befund(Zustand.FEHLT) |
| | | |
| | | datei = self.ordner / vermerk.get("dateiname", "") |
| | | if not vermerk.get("dateiname") or not datei.exists(): |
| | | return Befund(Zustand.FEHLT) |
| | | |
| | | try: |
| | | tatsaechlich = pruefsumme(datei) |
| | | except OSError: |
| | | return Befund(Zustand.FREMD, vermerk.get("dateiname", ""), pfad=str(datei)) |
| | | |
| | | if tatsaechlich != vermerk.get("pruefsumme"): |
| | | return Befund(Zustand.FREMD, vermerk["dateiname"], vermerk.get("version", ""), str(datei)) |
| | | |
| | | zustand = Zustand.AKTUELL if vermerk.get("version") == angeboteneVersion else Zustand.VERALTET |
| | | return Befund(zustand, vermerk["dateiname"], vermerk.get("version", ""), str(datei)) |
| | | |
| | | # --- Ablegen --- |
| | | |
| | | def ablegen(self, software_id: int, quelle: Path, dateiname: str, version: str) -> Path: |
| | | """Legt eine geholte Datei ab und vermerkt ihre Pruefsumme. |
| | | |
| | | Eine vorhandene gleichnamige Datei wird zuerst umbenannt statt geloescht: |
| | | Laeuft das Programm gerade, laesst Windows das Umbenennen zu, das |
| | | Ueberschreiben nicht. Der Rest wird beim naechsten Lauf weggeraeumt. |
| | | """ |
| | | self.ordner.mkdir(parents=True, exist_ok=True) |
| | | ziel = self.ordner / dateiname |
| | | |
| | | if ziel.exists(): |
| | | altlast = ziel.with_name(ziel.name + ALTLAST_ENDUNG) |
| | | altlast.unlink(missing_ok=True) |
| | | ziel.replace(altlast) |
| | | |
| | | quelle.replace(ziel) |
| | | |
| | | stand = self._stand_lesen() |
| | | stand[str(software_id)] = { |
| | | "dateiname": dateiname, |
| | | "version": version, |
| | | "pruefsumme": pruefsumme(ziel), |
| | | } |
| | | self._stand_schreiben(stand) |
| | | return ziel |
| | | |
| | | def vergessen(self, software_id: int) -> None: |
| | | """Nimmt einen Eintrag aus dem Stand â die Datei selbst bleibt liegen.""" |
| | | stand = self._stand_lesen() |
| | | if stand.pop(str(software_id), None) is not None: |
| | | self._stand_schreiben(stand) |
| | | |
| | | def altlasten_aufraeumen(self) -> int: |
| | | """Loescht verdraengte Vorgaengerdateien, sofern sie niemand mehr haelt.""" |
| | | if not self.ordner.exists(): |
| | | return 0 |
| | | entfernt = 0 |
| | | for datei in self.ordner.glob("*" + ALTLAST_ENDUNG): |
| | | try: |
| | | datei.unlink() |
| | | entfernt += 1 |
| | | except OSError: |
| | | continue # laeuft noch â beim naechsten Mal wieder versuchen |
| | | return entfernt |
| Neue Datei |
| | |
| | | # Baut EINE eigenstaendige Windows-.exe des Software-Bibliothek-Clients. |
| | | # |
| | | # Laeuft aus einem FRISCHEN Git-Klon ohne Vorbereitung: die virtuelle Umgebung wird |
| | | # angelegt, Python- und npm-Abhaengigkeiten werden installiert, die Oberflaeche |
| | | # gebaut und alles zu einer Datei gepackt. Damit ist dieses Skript der einzige |
| | | # Bau-Befehl, den die Software-Bibliothek braucht (siehe software-bibliothek.json). |
| | | $ErrorActionPreference = "Stop" |
| | | $desktop = $PSScriptRoot |
| | | $projekt = Split-Path $desktop -Parent |
| | | Set-Location $desktop |
| | | |
| | | $python = "$desktop\.venv\Scripts\python.exe" |
| | | |
| | | # 1. Virtuelle Umgebung sicherstellen. Ein frischer Klon hat keine; der Starter 'py' |
| | | # ist auf Windows der zuverlaessigste Weg (nicht der Store-Stub). |
| | | if (-not (Test-Path $python)) { |
| | | Write-Host "Virtuelle Umgebung fehlt - wird angelegt ..." |
| | | $angelegt = $false |
| | | foreach ($starter in @(@("py", @("-3")), @("python", @()), @("python3", @()))) { |
| | | $befehl = $starter[0] |
| | | if (-not (Get-Command $befehl -ErrorAction SilentlyContinue)) { continue } |
| | | & $befehl @($starter[1] + @("-m", "venv", "$desktop\.venv")) |
| | | if ($LASTEXITCODE -eq 0 -and (Test-Path $python)) { $angelegt = $true; break } |
| | | } |
| | | if (-not $angelegt) { throw "Keine Python-Installation gefunden (py / python / python3)." } |
| | | } |
| | | |
| | | # 2. Python-Abhaengigkeiten |
| | | Write-Host "Python-Abhaengigkeiten ..." |
| | | & $python -m pip install --upgrade pip --quiet |
| | | & $python -m pip install -r "$desktop\requirements.txt" --quiet |
| | | & $python -m pip install pyinstaller --quiet |
| | | if ($LASTEXITCODE -ne 0) { throw "pip fehlgeschlagen." } |
| | | |
| | | # 3. Oberflaeche bauen. Sie liegt als npm-Workspace im Projekt-Wurzelverzeichnis |
| | | # und teilt sich die Typen mit dem Server - deshalb wird dort installiert. |
| | | Write-Host "Oberflaeche bauen ..." |
| | | Push-Location $projekt |
| | | npm install --no-audit --no-fund |
| | | if ($LASTEXITCODE -ne 0) { Pop-Location; throw "npm install fehlgeschlagen." } |
| | | npm run build:desktop-ui |
| | | if ($LASTEXITCODE -ne 0) { Pop-Location; throw "Bau der Oberflaeche fehlgeschlagen." } |
| | | Pop-Location |
| | | |
| | | if (-not (Test-Path "$desktop\frontend\dist\index.html")) { |
| | | throw "Die gebaute Oberflaeche fehlt unter frontend\dist." |
| | | } |
| | | |
| | | # 4. Alte Bau-Artefakte weg, damit nichts von einem frueheren Lauf ueberlebt. |
| | | Remove-Item "$desktop\build", "$desktop\dist" -Recurse -Force -ErrorAction SilentlyContinue |
| | | |
| | | # 5. Eine Datei daraus machen. Die Oberflaeche wird als Datenverzeichnis |
| | | # mitgepackt; __main__.py findet sie zur Laufzeit unter sys._MEIPASS. |
| | | # |
| | | # `--collect-all` ist bei den drei webview-Paketen Pflicht: pywebview zeigt das |
| | | # Fenster unter Windows ueber WebView2, und das spricht es via pythonnet in .NET |
| | | # an. Diese Bestandteile findet PyInstaller nicht durch Nachverfolgen der |
| | | # Importe â ohne sie startet die exe und beendet sich sofort wieder. |
| | | Write-Host "PyInstaller ..." |
| | | & $python -m PyInstaller ` |
| | | --noconfirm ` |
| | | --onefile ` |
| | | --windowed ` |
| | | --name "Software-Bibliothek" ` |
| | | --icon "$desktop\assets\icon.ico" ` |
| | | --distpath "$desktop\dist" --workpath "$desktop\build" ` |
| | | --add-data "$desktop\frontend\dist;frontend/dist" ` |
| | | --collect-all webview ` |
| | | --collect-all pythonnet ` |
| | | --collect-all clr_loader ` |
| | | "$desktop\run_app.py" |
| | | if ($LASTEXITCODE -ne 0) { throw "PyInstaller fehlgeschlagen." } |
| | | |
| | | $ergebnis = "$desktop\dist\Software-Bibliothek.exe" |
| | | if (-not (Test-Path $ergebnis)) { throw "Erwartete Datei fehlt: $ergebnis" } |
| | | Write-Host "Fertig: $ergebnis ($([math]::Round((Get-Item $ergebnis).Length / 1MB, 1)) MB)" |
| Neue Datei |
| | |
| | | { |
| | | "name": "bibliothek-desktop-frontend", |
| | | "version": "0.1.0", |
| | | "type": "module", |
| | | "private": true, |
| | | "scripts": { |
| | | "build": "webpack --node-env=production", |
| | | "build:dev": "webpack --node-env=development", |
| | | "watch": "webpack --watch --node-env=development" |
| | | }, |
| | | "dependencies": { |
| | | "@dh-software/dh-components": "^3.0.23", |
| | | "@dh-software/dh-components-themes": "^1.0.21", |
| | | "@dh-software/furnview-icons": "^1.0.21", |
| | | "@dh-software/lit-extensions": "^0.0.14", |
| | | "bibliothek-types": "^0.1.0", |
| | | "construct-style-sheets-polyfill": "^3.1.0", |
| | | "lit": "^3.3.2" |
| | | }, |
| | | "devDependencies": { |
| | | "css-loader": "^7.1.2", |
| | | "html-webpack-plugin": "^5.6.3", |
| | | "style-loader": "^4.0.0", |
| | | "ts-loader": "^9.5.2", |
| | | "typescript": "^5.7.3", |
| | | "webpack": "^5.97.1", |
| | | "webpack-cli": "^6.0.1" |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | /** |
| | | * Typisierter Zugang zur Python-Seite. |
| | | * |
| | | * pywebview hängt seine Brücke erst ein, wenn das Fenster bereit ist, und meldet |
| | | * das über das Ereignis `pywebviewready`. Jeder Aufruf davor liefe ins Leere â |
| | | * deshalb wartet `bruecke()` einmalig darauf. |
| | | */ |
| | | import type { Software } from 'bibliothek-types'; |
| | | |
| | | /** Zustand einer Datei im Werkzeug-Ordner, ermittelt über ihre Prüfsumme. */ |
| | | export type LokalerZustand = 'fehlt' | 'aktuell' | 'veraltet' | 'fremd'; |
| | | |
| | | export interface LokalerBefund { |
| | | zustand: LokalerZustand; |
| | | dateiname: string; |
| | | abgelegteVersion: string; |
| | | pfad: string; |
| | | } |
| | | |
| | | /** Katalogeintrag, angereichert um das, was auf diesem Rechner liegt. */ |
| | | export interface EintragMitBefund extends Software { |
| | | /** Nur bei eigenständigen Programmen gefüllt â bei Installern sagt der Ordner nichts. */ |
| | | lokal: LokalerBefund | null; |
| | | /** FlieÃt dieser Eintrag in die Update-Meldung ein? */ |
| | | ueberwacht: boolean; |
| | | } |
| | | |
| | | export interface Einstellungen { |
| | | serverAdresse: string; |
| | | werkzeugOrdner: string; |
| | | } |
| | | |
| | | interface PywebviewApi { |
| | | einstellungen(): Promise<{ einstellungen: Einstellungen; vorgaben: Einstellungen }>; |
| | | einstellungen_speichern( |
| | | serverAdresse: string, |
| | | werkzeugOrdner: string |
| | | ): Promise<{ ok: boolean; einstellungen: Einstellungen; vorgaben: Einstellungen }>; |
| | | katalog(): Promise<{ ok: boolean; meldung?: string; eintraege: EintragMitBefund[]; ordner?: string }>; |
| | | herunterladen( |
| | | softwareId: number, |
| | | dateiname: string, |
| | | version: string, |
| | | programmart: string |
| | | ): Promise<{ ok: boolean; meldung: string; pfad?: string }>; |
| | | ordner_oeffnen(unterordner?: string): Promise<{ ok: boolean }>; |
| | | verknuepfung_anlegen(pfad: string, name: string): Promise<{ ok: boolean; meldung?: string }>; |
| | | } |
| | | |
| | | declare global { |
| | | interface Window { |
| | | pywebview?: { api: PywebviewApi }; |
| | | } |
| | | } |
| | | |
| | | let bereit: Promise<PywebviewApi> | undefined; |
| | | |
| | | export function bruecke(): Promise<PywebviewApi> { |
| | | if (!bereit) { |
| | | bereit = new Promise((aufloesen) => { |
| | | if (window.pywebview?.api) { |
| | | aufloesen(window.pywebview.api); |
| | | return; |
| | | } |
| | | window.addEventListener('pywebviewready', () => aufloesen(window.pywebview!.api), { once: true }); |
| | | }); |
| | | } |
| | | return bereit; |
| | | } |
| Neue Datei |
| | |
| | | import { LitElement, html, nothing } from 'lit'; |
| | | import { customElement, state } from 'lit/decorators.js'; |
| | | import { EINSTUFUNGEN, type Einstufung } from 'bibliothek-types'; |
| | | import { bruecke, type EintragMitBefund, type Einstellungen } from '../bruecke'; |
| | | import { gemerkterModus, setzeTheme, type ThemeModus } from '../theme'; |
| | | import './client-zeile'; |
| | | |
| | | interface Auswahloption { |
| | | dataId: string; |
| | | showContent: string; |
| | | selected?: boolean; |
| | | } |
| | | |
| | | const THEME_OPTIONEN: Array<{ id: ThemeModus; text: string }> = [ |
| | | { id: 'system', text: 'System' }, |
| | | { id: 'hell', text: 'Hell' }, |
| | | { id: 'dunkel', text: 'Dunkel' }, |
| | | ]; |
| | | |
| | | /** Wurzel-Komponente des Clients. */ |
| | | @customElement('client-app') |
| | | export class ClientApp extends LitElement { |
| | | @state() private eintraege: EintragMitBefund[] = []; |
| | | @state() private aktiveEinstufung: Einstufung = 'lizenzfrei'; |
| | | @state() private suchbegriff = ''; |
| | | @state() private ladefehler = ''; |
| | | @state() private meldung = ''; |
| | | @state() private laeuftFuer = 0; |
| | | @state() private einstellungenOffen = false; |
| | | @state() private einstellungen?: Einstellungen; |
| | | @state() private serverAdresse = ''; |
| | | @state() private werkzeugOrdner = ''; |
| | | @state() private themeOptionen: Auswahloption[] = []; |
| | | |
| | | // Light DOM, damit das globale dh-components-Theme greift. |
| | | createRenderRoot() { |
| | | return this; |
| | | } |
| | | |
| | | connectedCallback(): void { |
| | | super.connectedCallback(); |
| | | const modus = gemerkterModus(); |
| | | this.themeOptionen = THEME_OPTIONEN.map((eintrag) => ({ |
| | | dataId: eintrag.id, |
| | | showContent: eintrag.text, |
| | | selected: eintrag.id === modus, |
| | | })); |
| | | void this.alesLaden(); |
| | | } |
| | | |
| | | render() { |
| | | return html` |
| | | <div class="rahmen"> |
| | | <header class="kopf"> |
| | | <h1>Software-Bibliothek</h1> |
| | | <div class="kopf-aktionen"> |
| | | <dh-select |
| | | class="theme-select" |
| | | withoutEmptyDefault |
| | | notSearchable |
| | | .selectOptions=${this.themeOptionen} |
| | | @changeSelectOption=${this.beiTheme} |
| | | ></dh-select> |
| | | <dh-button icon-button variant="standard" title="Werkzeug-Ordner öffnen" @click=${this.beiOrdnerOeffnen}> |
| | | <span slot="img" class="fv-icon-folder"></span> |
| | | </dh-button> |
| | | <dh-button icon-button variant="standard" title="Einstellungen" @click=${this.beiEinstellungenOeffnen}> |
| | | <span slot="img" class="fv-icon-settings"></span> |
| | | </dh-button> |
| | | </div> |
| | | </header> |
| | | |
| | | ${this.ladefehler ? html`<p class="warnung">${this.ladefehler}</p>` : nothing} |
| | | ${this.meldung ? html`<p class="hinweis">${this.meldung}</p>` : nothing} |
| | | ${this.rendereUpdateHinweis()} |
| | | ${this.einstellungenOffen ? this.rendereEinstellungen() : this.rendereKatalog()} |
| | | </div> |
| | | `; |
| | | } |
| | | |
| | | // --- Teilansichten --- |
| | | |
| | | /** Der Grund, warum es diesen Client gibt: ungefragt sagen, was veraltet ist. */ |
| | | private rendereUpdateHinweis() { |
| | | const veraltet = this.eintraege.filter( |
| | | (eintrag) => eintrag.ueberwacht && (eintrag.lokal?.zustand === 'veraltet' || eintrag.lokal?.zustand === 'fremd') |
| | | ); |
| | | if (veraltet.length === 0) { |
| | | return nothing; |
| | | } |
| | | return html`<p class="update-hinweis"> |
| | | ${veraltet.length === 1 ? 'Ein Werkzeug ist nicht auf dem neuesten Stand' : `${veraltet.length} Werkzeuge sind nicht auf dem neuesten Stand`}: |
| | | ${veraltet.map((eintrag) => eintrag.name).join(', ')}. |
| | | </p>`; |
| | | } |
| | | |
| | | private rendereKatalog() { |
| | | if (this.eintraege.length === 0) { |
| | | return html`<p class="leer">${this.ladefehler ? '' : 'Katalog wird geladen â¦'}</p>`; |
| | | } |
| | | return html` |
| | | <dh-input class="suche" type="text" placeholder="Suchen" .value=${this.suchbegriff} @input=${this.beiSuche}></dh-input> |
| | | <dh-tab stretch @clickTab=${this.beiReiterWechsel}> |
| | | ${EINSTUFUNGEN.map( |
| | | (eintrag) => html`<div |
| | | slot="tab-head" |
| | | content=${eintrag.id} |
| | | class=${eintrag.id === this.aktiveEinstufung ? 'selected' : ''} |
| | | > |
| | | ${eintrag.text} (${this.mitEinstufung(eintrag.id).length}) |
| | | </div>` |
| | | )} |
| | | ${EINSTUFUNGEN.map( |
| | | (eintrag) => html`<div |
| | | slot="tab-content" |
| | | tab=${eintrag.id} |
| | | class=${eintrag.id === this.aktiveEinstufung ? '' : 'hidden'} |
| | | > |
| | | ${eintrag.id === this.aktiveEinstufung ? this.rendereListe() : nothing} |
| | | </div>` |
| | | )} |
| | | </dh-tab> |
| | | `; |
| | | } |
| | | |
| | | private rendereListe() { |
| | | const gefiltert = this.gefiltert(); |
| | | if (gefiltert.length === 0) { |
| | | return html`<p class="leer"> |
| | | ${this.suchbegriff ? html`Keine Treffer für â${this.suchbegriff}".` : 'In dieser Einstufung ist nichts hinterlegt.'} |
| | | </p>`; |
| | | } |
| | | return html`<div class="liste"> |
| | | ${gefiltert.map( |
| | | (eintrag) => html`<client-zeile |
| | | .eintrag=${eintrag} |
| | | ?laeuft=${this.laeuftFuer === eintrag.id} |
| | | @holen=${this.beiHolen} |
| | | @verknuepfen=${this.beiVerknuepfen} |
| | | ></client-zeile>` |
| | | )} |
| | | </div>`; |
| | | } |
| | | |
| | | private rendereEinstellungen() { |
| | | return html` |
| | | <dh-card variant="outlined" no-hover-effect no-click-effect> |
| | | <span slot="header">Einstellungen</span> |
| | | <dh-form-grid slot="content"> |
| | | <dh-form-field label="Adresse der Bibliothek"> |
| | | <dh-input type="text" .value=${this.serverAdresse} @input=${this.beiServerAdresse}></dh-input> |
| | | <span class="feldhinweis"> |
| | | Rechnername statt IP-Adresse ist die bessere Wahl â er überlebt einen Wechsel der Adresse. |
| | | </span> |
| | | </dh-form-field> |
| | | <dh-form-field label="Werkzeug-Ordner"> |
| | | <dh-input type="text" .value=${this.werkzeugOrdner} @input=${this.beiWerkzeugOrdner}></dh-input> |
| | | <span class="feldhinweis"> |
| | | Hierhin kommen eigenständige Programme; Installationsprogramme in den Unterordner |
| | | âInstallationsprogramme". |
| | | </span> |
| | | </dh-form-field> |
| | | </dh-form-grid> |
| | | <div slot="actions"> |
| | | <dh-button buttonText="Speichern" variant="filled" @click=${this.beiEinstellungenSpeichern}></dh-button> |
| | | <dh-button buttonText="Abbrechen" variant="standard" @click=${this.beiEinstellungenSchliessen}></dh-button> |
| | | </div> |
| | | </dh-card> |
| | | `; |
| | | } |
| | | |
| | | // --- Daten --- |
| | | |
| | | private async alesLaden(): Promise<void> { |
| | | const api = await bruecke(); |
| | | const stand = await api.einstellungen(); |
| | | this.einstellungen = stand.einstellungen; |
| | | this.serverAdresse = stand.einstellungen.serverAdresse; |
| | | this.werkzeugOrdner = stand.einstellungen.werkzeugOrdner; |
| | | await this.katalogLaden(); |
| | | } |
| | | |
| | | private async katalogLaden(): Promise<void> { |
| | | const api = await bruecke(); |
| | | const antwort = await api.katalog(); |
| | | if (!antwort.ok) { |
| | | this.ladefehler = `${antwort.meldung} â Adresse in den Einstellungen prüfen.`; |
| | | this.eintraege = []; |
| | | return; |
| | | } |
| | | this.ladefehler = ''; |
| | | this.eintraege = antwort.eintraege; |
| | | } |
| | | |
| | | private mitEinstufung(einstufung: Einstufung): EintragMitBefund[] { |
| | | return this.eintraege.filter((eintrag) => eintrag.einstufung === einstufung && !eintrag.elternId); |
| | | } |
| | | |
| | | private gefiltert(): EintragMitBefund[] { |
| | | const suche = this.suchbegriff.trim().toLowerCase(); |
| | | const inDerEinstufung = this.mitEinstufung(this.aktiveEinstufung); |
| | | if (!suche) { |
| | | return inDerEinstufung; |
| | | } |
| | | return inDerEinstufung.filter( |
| | | (eintrag) => |
| | | eintrag.name.toLowerCase().includes(suche) || eintrag.beschreibung.toLowerCase().includes(suche) |
| | | ); |
| | | } |
| | | |
| | | // --- Ereignisse --- |
| | | |
| | | private beiTheme(ereignis: CustomEvent<{ selectedOption: { dataId: string } }>): void { |
| | | setzeTheme(ereignis.detail.selectedOption.dataId as ThemeModus); |
| | | } |
| | | |
| | | private beiSuche(ereignis: CustomEvent<string>): void { |
| | | this.suchbegriff = ereignis.detail; |
| | | } |
| | | |
| | | /** dh-tab liefert die Kennung des Reiters unmittelbar als `detail`. */ |
| | | private beiReiterWechsel(ereignis: CustomEvent<string>): void { |
| | | this.aktiveEinstufung = ereignis.detail as Einstufung; |
| | | } |
| | | |
| | | private async beiHolen(ereignis: CustomEvent<EintragMitBefund>): Promise<void> { |
| | | const eintrag = ereignis.detail; |
| | | this.laeuftFuer = eintrag.id; |
| | | this.meldung = `${eintrag.name} wird geholt â¦`; |
| | | const api = await bruecke(); |
| | | const ergebnis = await api.herunterladen(eintrag.id, eintrag.dateiname, eintrag.version, eintrag.programmart); |
| | | this.meldung = ergebnis.meldung; |
| | | this.laeuftFuer = 0; |
| | | await this.katalogLaden(); |
| | | } |
| | | |
| | | private async beiVerknuepfen(ereignis: CustomEvent<EintragMitBefund>): Promise<void> { |
| | | const eintrag = ereignis.detail; |
| | | if (!eintrag.lokal?.pfad) { |
| | | return; |
| | | } |
| | | const api = await bruecke(); |
| | | const ergebnis = await api.verknuepfung_anlegen(eintrag.lokal.pfad, eintrag.name); |
| | | this.meldung = ergebnis.meldung ?? ''; |
| | | } |
| | | |
| | | private async beiOrdnerOeffnen(): Promise<void> { |
| | | const api = await bruecke(); |
| | | await api.ordner_oeffnen(); |
| | | } |
| | | |
| | | private beiEinstellungenOeffnen(): void { |
| | | this.serverAdresse = this.einstellungen?.serverAdresse ?? ''; |
| | | this.werkzeugOrdner = this.einstellungen?.werkzeugOrdner ?? ''; |
| | | this.einstellungenOffen = true; |
| | | } |
| | | |
| | | private beiEinstellungenSchliessen(): void { |
| | | this.einstellungenOffen = false; |
| | | } |
| | | |
| | | private beiServerAdresse(ereignis: CustomEvent<string>): void { |
| | | this.serverAdresse = ereignis.detail; |
| | | } |
| | | |
| | | private beiWerkzeugOrdner(ereignis: CustomEvent<string>): void { |
| | | this.werkzeugOrdner = ereignis.detail; |
| | | } |
| | | |
| | | private async beiEinstellungenSpeichern(): Promise<void> { |
| | | const api = await bruecke(); |
| | | const ergebnis = await api.einstellungen_speichern(this.serverAdresse, this.werkzeugOrdner); |
| | | this.einstellungen = ergebnis.einstellungen; |
| | | this.einstellungenOffen = false; |
| | | this.meldung = 'Einstellungen gespeichert.'; |
| | | await this.katalogLaden(); |
| | | } |
| | | } |
| | | |
| | | declare global { |
| | | interface HTMLElementTagNameMap { |
| | | 'client-app': ClientApp; |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | import { LitElement, html, nothing } from 'lit'; |
| | | import { customElement, property } from 'lit/decorators.js'; |
| | | import { event, type EventRef } from '@dh-software/lit-extensions'; |
| | | import type { EintragMitBefund, LokalerZustand } from '../bruecke'; |
| | | |
| | | /** Was der jeweilige Zustand für den Nutzer bedeutet. */ |
| | | const ZUSTAND_TEXT: Record<LokalerZustand, string> = { |
| | | fehlt: 'nicht auf diesem Rechner', |
| | | aktuell: 'aktuell', |
| | | veraltet: 'veraltet', |
| | | fremd: 'von Hand verändert', |
| | | }; |
| | | |
| | | const ZUSTAND_KLASSE: Record<LokalerZustand, string> = { |
| | | fehlt: 'zustand-fehlt', |
| | | aktuell: 'zustand-aktuell', |
| | | veraltet: 'zustand-veraltet', |
| | | fremd: 'zustand-fremd', |
| | | }; |
| | | |
| | | /** |
| | | * Eine Zeile des Katalogs im Client. Anders als in der Weboberfläche steht hier, |
| | | * was auf diesem Rechner liegt â das ist der ganze Unterschied. |
| | | */ |
| | | @customElement('client-zeile') |
| | | export class ClientZeile extends LitElement { |
| | | @property({ attribute: false }) eintrag!: EintragMitBefund; |
| | | @property({ type: Boolean }) laeuft = false; |
| | | |
| | | @event({ bubbles: true, composed: true }, 'holen') |
| | | private holen!: EventRef<EintragMitBefund>; |
| | | |
| | | @event({ bubbles: true, composed: true }, 'verknuepfen') |
| | | private verknuepfen!: EventRef<EintragMitBefund>; |
| | | |
| | | // Light DOM, damit das globale dh-components-Theme greift. |
| | | createRenderRoot() { |
| | | return this; |
| | | } |
| | | |
| | | render() { |
| | | const eintrag = this.eintrag; |
| | | return html` |
| | | <div class="zeile"> |
| | | <div class="zeile-info"> |
| | | <span class="zeile-name"> |
| | | ${eintrag.name} |
| | | ${eintrag.einstufung === 'lizenzpflichtig' |
| | | ? html`<span class="marke lizenz-marke">lizenzpflichtig</span>` |
| | | : nothing} |
| | | ${eintrag.einstufung === 'intern' ? html`<span class="marke intern-marke">intern</span>` : nothing} |
| | | </span> |
| | | <span class="zeile-meta">${this.rendereStand()}${this.rendereZustand()}</span> |
| | | ${eintrag.hinweis ? html`<span class="zeile-hinweis">${eintrag.hinweis}</span>` : nothing} |
| | | </div> |
| | | <div class="zeile-aktionen">${this.rendereAktion()}${this.rendereVerknuepfung()}</div> |
| | | </div> |
| | | `; |
| | | } |
| | | |
| | | private rendereStand() { |
| | | const zeitpunkt = this.eintrag.veroeffentlichtAm || this.eintrag.dateiStandVom; |
| | | if (!zeitpunkt) { |
| | | return nothing; |
| | | } |
| | | const datum = new Date(zeitpunkt); |
| | | if (Number.isNaN(datum.getTime())) { |
| | | return nothing; |
| | | } |
| | | return html`Stand ${datum.toLocaleDateString('de-DE')}`; |
| | | } |
| | | |
| | | /** Nur bei eigenständigen Programmen aussagekräftig â Installer verraten hier nichts. */ |
| | | private rendereZustand() { |
| | | const befund = this.eintrag.lokal; |
| | | if (!befund) { |
| | | return nothing; |
| | | } |
| | | return html` · |
| | | <span class=${ZUSTAND_KLASSE[befund.zustand]}>${ZUSTAND_TEXT[befund.zustand]}</span>`; |
| | | } |
| | | |
| | | /** |
| | | * Ein rundes Symbol wie in der Weboberfläche. Was der Klick bewirkt, steht |
| | | * ohnehin schon als Zustand in der Zeile â die Beschriftung wäre eine |
| | | * Wiederholung. Steht ein Update an, ist der Knopf gefüllt statt flach. |
| | | */ |
| | | private rendereAktion() { |
| | | const eintrag = this.eintrag; |
| | | if (!eintrag.dateiname) { |
| | | return html`<span class="zeile-hinweis">keine Datei hinterlegt</span>`; |
| | | } |
| | | const zustand = eintrag.lokal?.zustand; |
| | | const stehtAn = zustand === 'veraltet' || zustand === 'fremd'; |
| | | const zweck = stehtAn ? 'aktualisieren' : zustand === 'aktuell' ? 'erneut holen' : 'holen'; |
| | | return html`<dh-button |
| | | icon-button |
| | | variant=${stehtAn ? 'filled' : 'standard'} |
| | | title=${`${eintrag.name} ${zweck}`} |
| | | ?disabled=${this.laeuft} |
| | | @click=${this.beiHolen} |
| | | > |
| | | <span slot="img" class="fv-icon-download"></span> |
| | | </dh-button>`; |
| | | } |
| | | |
| | | private rendereVerknuepfung() { |
| | | const befund = this.eintrag.lokal; |
| | | if (!befund || befund.zustand === 'fehlt') { |
| | | return nothing; |
| | | } |
| | | return html`<dh-button |
| | | icon-button |
| | | variant="standard" |
| | | title="Verknüpfung auf dem Desktop anlegen" |
| | | @click=${this.beiVerknuepfen} |
| | | > |
| | | <span slot="img" class="fv-icon-link"></span> |
| | | </dh-button>`; |
| | | } |
| | | |
| | | private beiHolen(): void { |
| | | this.holen.dispatch(this.eintrag); |
| | | } |
| | | |
| | | private beiVerknuepfen(): void { |
| | | this.verknuepfen.dispatch(this.eintrag); |
| | | } |
| | | } |
| | | |
| | | declare global { |
| | | interface HTMLElementTagNameMap { |
| | | 'client-zeile': ClientZeile; |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | html, |
| | | body { |
| | | margin: 0; |
| | | min-height: 100%; |
| | | background: var(--md-sys-color-surface, #ffffff); |
| | | color: var(--md-sys-color-on-surface, #1a1a1a); |
| | | font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; |
| | | } |
| | | |
| | | .rahmen { |
| | | padding: 20px 24px 32px; |
| | | max-width: 1000px; |
| | | margin: 0 auto; |
| | | } |
| | | |
| | | .kopf { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 16px; |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | .kopf h1 { |
| | | font-size: 1.3rem; |
| | | margin: 0; |
| | | } |
| | | |
| | | .kopf-aktionen { |
| | | display: flex; |
| | | gap: 8px; |
| | | align-items: center; |
| | | } |
| | | |
| | | .theme-select { |
| | | min-width: 140px; |
| | | } |
| | | |
| | | .hinweis { |
| | | background: var(--md-sys-color-secondary-container, #e6e0ec); |
| | | color: var(--md-sys-color-on-secondary-container, #1d1b20); |
| | | padding: 12px 16px; |
| | | border-radius: 8px; |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | .warnung { |
| | | padding: 12px 16px; |
| | | margin-bottom: 16px; |
| | | border-radius: 8px; |
| | | border: 1px solid var(--md-sys-color-error, #b00020); |
| | | background: var(--md-sys-color-error-container, rgba(176, 0, 32, 0.12)); |
| | | color: var(--md-sys-color-on-error-container, #410002); |
| | | } |
| | | |
| | | /* Die Meldung, für die es den Client gibt â deutlicher als ein normaler Hinweis. */ |
| | | .update-hinweis { |
| | | padding: 12px 16px; |
| | | margin-bottom: 16px; |
| | | border-radius: 8px; |
| | | border-left: 4px solid var(--md-sys-color-primary, #ff8014); |
| | | background: var(--md-sys-color-surface-container-high, #eeeeee); |
| | | color: var(--md-sys-color-on-surface, #1a1a1a); |
| | | } |
| | | |
| | | .suche { |
| | | display: block; |
| | | max-width: 400px; |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | dh-tab { |
| | | display: block; |
| | | } |
| | | |
| | | [slot='tab-content'].hidden { |
| | | display: none; |
| | | } |
| | | |
| | | .leer { |
| | | padding: 16px 4px; |
| | | opacity: 0.7; |
| | | } |
| | | |
| | | .liste { |
| | | border: 1px solid var(--md-sys-color-outline-variant, #cac4d0); |
| | | border-radius: 8px; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .liste > client-zeile { |
| | | display: block; |
| | | border-bottom: 1px solid var(--md-sys-color-outline-variant, #e7e0ec); |
| | | } |
| | | |
| | | .liste > client-zeile:last-child { |
| | | border-bottom: none; |
| | | } |
| | | |
| | | .zeile { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 16px; |
| | | padding: 8px 16px; |
| | | } |
| | | |
| | | .zeile-info { |
| | | display: flex; |
| | | flex-direction: column; |
| | | min-width: 0; |
| | | flex: 1 1 auto; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .zeile-info > * { |
| | | white-space: nowrap; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | } |
| | | |
| | | .zeile-name { |
| | | font-weight: 600; |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .zeile-meta { |
| | | font-size: 0.85em; |
| | | opacity: 0.7; |
| | | } |
| | | |
| | | .zeile-hinweis { |
| | | font-size: 0.85em; |
| | | color: var(--md-sys-color-on-surface-variant, #49454f); |
| | | font-style: italic; |
| | | } |
| | | |
| | | /* Feste Spalten, damit die Schaltflächen über alle Zeilen untereinander stehen. */ |
| | | .zeile-aktionen { |
| | | display: grid; |
| | | grid-template-columns: 48px 48px; |
| | | gap: 8px; |
| | | align-items: center; |
| | | justify-items: center; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .marke { |
| | | flex-shrink: 0; |
| | | font-size: 0.72em; |
| | | font-weight: 500; |
| | | text-transform: uppercase; |
| | | letter-spacing: 0.04em; |
| | | padding: 2px 8px; |
| | | border-radius: 999px; |
| | | } |
| | | |
| | | .intern-marke { |
| | | background: var(--md-sys-color-primary-container, #ffe4cc); |
| | | color: var(--md-sys-color-on-primary-container, #4a1e00); |
| | | } |
| | | |
| | | .lizenz-marke { |
| | | background: var(--md-sys-color-tertiary-container, #ffd9a0); |
| | | color: var(--md-sys-color-on-tertiary-container, #2b1700); |
| | | } |
| | | |
| | | .zustand-aktuell { |
| | | color: var(--md-sys-color-primary, #ff8014); |
| | | } |
| | | |
| | | .zustand-veraltet, |
| | | .zustand-fremd { |
| | | color: var(--md-sys-color-error, #b00020); |
| | | font-weight: 500; |
| | | } |
| | | |
| | | .zustand-fehlt { |
| | | font-style: italic; |
| | | } |
| | | |
| | | .feldhinweis { |
| | | display: block; |
| | | font-size: 0.8em; |
| | | opacity: 0.7; |
| | | margin-top: 4px; |
| | | } |
| | | |
| | | dh-card { |
| | | display: block; |
| | | } |
| | | |
| | | [slot='actions'] { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 8px; |
| | | align-items: center; |
| | | } |
| Neue Datei |
| | |
| | | <!DOCTYPE html> |
| | | <html lang="de"> |
| | | <head> |
| | | <meta charset="UTF-8" /> |
| | | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| | | <title>Software-Bibliothek</title> |
| | | </head> |
| | | <body> |
| | | <client-app></client-app> |
| | | </body> |
| | | </html> |
| Neue Datei |
| | |
| | | import 'construct-style-sheets-polyfill'; |
| | | |
| | | // Theme in fester Reihenfolge: Tokens -> Styles -> Komponenten. |
| | | import { initTheme } from './theme'; |
| | | import '@dh-software/dh-components-themes/styles.css'; |
| | | import '@dh-software/dh-components'; |
| | | |
| | | import './global.css'; |
| | | |
| | | import { injectStylesheets } from '@dh-software/furnview-icons'; |
| | | injectStylesheets(true); |
| | | |
| | | initTheme(); |
| | | |
| | | import './components/client-app'; |
| Neue Datei |
| | |
| | | /** Import mit `?raw` liefert den Dateiinhalt als Zeichenkette (webpack asset/source). */ |
| | | declare module '*?raw' { |
| | | const inhalt: string; |
| | | export default inhalt; |
| | | } |
| Neue Datei |
| | |
| | | /** |
| | | * Theme-Umschaltung (System/hell/dunkel) auf Basis der Design-System-Defines. |
| | | * Beide Defines werden als <style> eingehängt; je nach Modus ist genau eines |
| | | * aktiv (das andere per `disabled` abgeschaltet). âSystem" folgt der |
| | | * Windows-Einstellung (prefers-color-scheme). |
| | | * |
| | | * Gleiches Vorgehen wie in der Weboberfläche und im meeting-transkriptor, damit |
| | | * die Bedienung über unsere Anwendungen hinweg einheitlich bleibt. |
| | | */ |
| | | // Relativer Pfad (umgeht das exports-Feld des Pakets, das ?raw sonst blockt). |
| | | import hellCss from '../../../node_modules/@dh-software/dh-components-themes/build/defines.orange-light.css?raw'; |
| | | import dunkelCss from '../../../node_modules/@dh-software/dh-components-themes/build/defines.orange-dark.css?raw'; |
| | | |
| | | export type ThemeModus = 'system' | 'hell' | 'dunkel'; |
| | | |
| | | const SPEICHER_SCHLUESSEL = 'software-bibliothek-client-theme'; |
| | | |
| | | let hellStil: HTMLStyleElement; |
| | | let dunkelStil: HTMLStyleElement; |
| | | let aktuellerModus: ThemeModus = 'system'; |
| | | const dunkelMedia = window.matchMedia('(prefers-color-scheme: dark)'); |
| | | |
| | | function anwenden(): void { |
| | | const dunkel = aktuellerModus === 'dunkel' || (aktuellerModus === 'system' && dunkelMedia.matches); |
| | | hellStil.disabled = dunkel; |
| | | dunkelStil.disabled = !dunkel; |
| | | document.documentElement.style.colorScheme = dunkel ? 'dark' : 'light'; |
| | | document.documentElement.dataset.theme = dunkel ? 'dunkel' : 'hell'; |
| | | } |
| | | |
| | | /** Liest den zuletzt gewählten Modus (Standard: System). */ |
| | | export function gemerkterModus(): ThemeModus { |
| | | const gemerkt = localStorage.getItem(SPEICHER_SCHLUESSEL); |
| | | return gemerkt === 'hell' || gemerkt === 'dunkel' || gemerkt === 'system' ? gemerkt : 'system'; |
| | | } |
| | | |
| | | /** Hängt beide Theme-Defines ein und aktiviert den gemerkten Modus. */ |
| | | export function initTheme(): void { |
| | | hellStil = document.createElement('style'); |
| | | hellStil.textContent = hellCss; |
| | | dunkelStil = document.createElement('style'); |
| | | dunkelStil.textContent = dunkelCss; |
| | | document.head.append(hellStil, dunkelStil); |
| | | dunkelMedia.addEventListener('change', () => { |
| | | if (aktuellerModus === 'system') { |
| | | anwenden(); |
| | | } |
| | | }); |
| | | aktuellerModus = gemerkterModus(); |
| | | anwenden(); |
| | | } |
| | | |
| | | /** Setzt den Modus und merkt ihn für den nächsten Aufruf. */ |
| | | export function setzeTheme(modus: ThemeModus): void { |
| | | aktuellerModus = modus; |
| | | localStorage.setItem(SPEICHER_SCHLUESSEL, modus); |
| | | anwenden(); |
| | | } |
| Neue Datei |
| | |
| | | { |
| | | "compilerOptions": { |
| | | "target": "ES2020", |
| | | "lib": ["DOM", "DOM.Iterable", "ES2021"], |
| | | "module": "ESNext", |
| | | "moduleResolution": "bundler", |
| | | "experimentalDecorators": true, |
| | | "useDefineForClassFields": false, |
| | | "strict": true, |
| | | "esModuleInterop": true, |
| | | "skipLibCheck": true, |
| | | "forceConsistentCasingInFileNames": true |
| | | }, |
| | | "exclude": ["node_modules", "dist"] |
| | | } |
| Neue Datei |
| | |
| | | import path from 'path'; |
| | | import { fileURLToPath } from 'url'; |
| | | import HtmlWebpackPlugin from 'html-webpack-plugin'; |
| | | |
| | | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| | | const isProduction = process.env.NODE_ENV === 'production'; |
| | | |
| | | export default { |
| | | entry: './src/index.ts', |
| | | mode: isProduction ? 'production' : 'development', |
| | | devtool: isProduction ? false : 'inline-source-map', |
| | | experiments: { |
| | | topLevelAwait: true, |
| | | }, |
| | | module: { |
| | | rules: [ |
| | | { |
| | | test: /\.ts$/, |
| | | use: 'ts-loader', |
| | | exclude: /node_modules\/(?!@dh-software)/, |
| | | }, |
| | | { |
| | | test: /\.css$/i, |
| | | resourceQuery: { not: [/raw/] }, |
| | | use: ['style-loader', 'css-loader'], |
| | | }, |
| | | { |
| | | // Import mit ?raw liefert den CSS-Quelltext als String (Theme-Umschaltung). |
| | | resourceQuery: /raw/, |
| | | type: 'asset/source', |
| | | }, |
| | | { |
| | | test: /\.(svg|ttf|woff|woff2|png|jpg|gif|ico)$/i, |
| | | type: 'asset', |
| | | }, |
| | | ], |
| | | }, |
| | | resolve: { |
| | | extensions: ['.ts', '.js'], |
| | | }, |
| | | plugins: [ |
| | | new HtmlWebpackPlugin({ |
| | | template: 'src/index.html', |
| | | // Das Fenster laedt die Datei direkt vom Dateisystem â relative Pfade. |
| | | publicPath: '', |
| | | }), |
| | | ], |
| | | output: { |
| | | path: path.resolve(__dirname, 'dist'), |
| | | filename: 'index.[contenthash].js', |
| | | clean: true, |
| | | }, |
| | | stats: { |
| | | errorDetails: true, |
| | | }, |
| | | }; |
| Neue Datei |
| | |
| | | """Erzeugt das App-Icon als assets/icon.ico. |
| | | |
| | | Pillow wird nur zum Erzeugen gebraucht, nicht zur Laufzeit â deshalb steht es |
| | | bewusst nicht in requirements.txt: |
| | | |
| | | .venv\\Scripts\\python.exe -m pip install pillow |
| | | .venv\\Scripts\\python.exe generate_icon.py |
| | | |
| | | Das Ergebnis gehoert ins Repo: Ein frischer Klon soll bauen koennen, ohne |
| | | Pillow zu installieren. |
| | | """ |
| | | |
| | | from pathlib import Path |
| | | |
| | | from PIL import Image, ImageDraw |
| | | |
| | | GROESSE = 256 |
| | | ORANGE = (255, 128, 20, 255) # Markenfarbe --md-sys-color-primary |
| | | WEISS = (255, 255, 255, 255) |
| | | |
| | | bild = Image.new("RGBA", (GROESSE, GROESSE), (0, 0, 0, 0)) |
| | | zeichnung = ImageDraw.Draw(bild) |
| | | |
| | | # Hintergrund: abgerundetes Quadrat in Markenorange â wie beim Transkriptor, |
| | | # damit unsere Werkzeuge in der Taskleiste als Familie erkennbar sind. |
| | | zeichnung.rounded_rectangle((8, 8, 248, 248), radius=52, fill=ORANGE) |
| | | |
| | | # Pfeil nach unten: das Zeichen fuer âholen", dasselbe wie in der Oberflaeche. |
| | | zeichnung.rounded_rectangle((112, 44, 144, 132), radius=10, fill=WEISS) |
| | | zeichnung.polygon([(80, 118), (176, 118), (128, 186)], fill=WEISS) |
| | | |
| | | # Ablage darunter â angedeutet als offene Schale, nicht als geschlossener Kasten. |
| | | zeichnung.rounded_rectangle((66, 198, 190, 216), radius=9, fill=WEISS) |
| | | zeichnung.rounded_rectangle((66, 160, 84, 216), radius=9, fill=WEISS) |
| | | zeichnung.rounded_rectangle((172, 160, 190, 216), radius=9, fill=WEISS) |
| | | |
| | | ziel = Path(__file__).resolve().parent / "assets" / "icon.ico" |
| | | ziel.parent.mkdir(parents=True, exist_ok=True) |
| | | bild.save(ziel, sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]) |
| | | print(f"geschrieben: {ziel}") |
| Neue Datei |
| | |
| | | # Abhaengigkeiten des Software-Bibliothek-Clients. |
| | | # |
| | | # Bewusst genau eine: Der Rest â Katalog holen, Dateien ablegen, Pruefsummen |
| | | # rechnen â laeuft mit der Standardbibliothek. Jedes Paket mehr muesste beim Bau |
| | | # der exe mitgeschleppt werden. |
| | | pywebview>=5.3 # natives Fenster (WebView2 unter Windows) + Bruecke zur Oberflaeche |
| Neue Datei |
| | |
| | | """Direkter Startpunkt (auch Einstiegspunkt fuer den exe-Bau). |
| | | |
| | | Entwicklung: .venv\\Scripts\\python.exe run_app.py |
| | | oder: .venv\\Scripts\\python.exe -m backend |
| | | """ |
| | | |
| | | from backend.__main__ import main |
| | | |
| | | if __name__ == "__main__": |
| | | main() |
| Neue Datei |
| Zu viele Änderungen; Diff wird nicht angezeigt |
| Neue Datei |
| | |
| | | { |
| | | "name": "software-bibliothek", |
| | | "version": "0.1.0", |
| | | "description": "Interne Software-Bibliothek â Katalog, Datei-Download, Zugriffsprotokoll (D+H)", |
| | | "private": true, |
| | | "workspaces": [ |
| | | "./packages/*", |
| | | "./desktop/frontend" |
| | | ], |
| | | "scripts": { |
| | | "build": "npm run build -w packages/bibliothek-types && npm run build -w packages/bibliothek-server && npm run build -w packages/bibliothek-client", |
| | | "build:exe": "npm run build && npm run bundle -w packages/bibliothek-server && node bauen-exe.cjs", |
| | | "build:desktop-ui": "npm run build -w packages/bibliothek-types && npm run build -w desktop/frontend", |
| | | "dev:server": "npm run dev -w packages/bibliothek-server", |
| | | "dev:client": "npm run dev -w packages/bibliothek-client" |
| | | }, |
| | | "devDependencies": { |
| | | "postject": "^1.0.0-alpha.6", |
| | | "prettier": "^3.4.2", |
| | | "typescript": "^5.7.2" |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | { |
| | | "name": "bibliothek-client", |
| | | "version": "0.1.0", |
| | | "type": "module", |
| | | "scripts": { |
| | | "build": "webpack --node-env=production", |
| | | "dev": "webpack serve --node-env=development --host 0.0.0.0" |
| | | }, |
| | | "dependencies": { |
| | | "@dh-software/dh-components": "^3.0.23", |
| | | "@dh-software/dh-components-themes": "^1.0.21", |
| | | "@dh-software/furnview-icons": "^1.0.21", |
| | | "@dh-software/lit-extensions": "^0.0.14", |
| | | "construct-style-sheets-polyfill": "^3.1.0", |
| | | "lit": "^3.3.2" |
| | | }, |
| | | "devDependencies": { |
| | | "css-loader": "^7.1.2", |
| | | "html-webpack-plugin": "^5.6.3", |
| | | "sass": "^1.83.0", |
| | | "sass-loader": "^16.0.4", |
| | | "style-loader": "^4.0.0", |
| | | "ts-loader": "^9.5.2", |
| | | "webpack": "^5.97.1", |
| | | "webpack-cli": "^6.0.1", |
| | | "webpack-dev-server": "^5.2.0" |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | import type { Einstellungen, EinstellungenAnsicht, GeheimnisUebersicht, Software, SoftwareEingabe } from 'bibliothek-types'; |
| | | |
| | | /** Wertet eine Antwort aus, wirft bei Fehler mit der Server-Meldung. */ |
| | | async function jsonOderFehler<T>(antwort: Response): Promise<T> { |
| | | if (!antwort.ok) { |
| | | const daten = (await antwort.json().catch(() => ({}))) as { fehler?: string }; |
| | | throw new Error(daten.fehler ?? `Fehler ${antwort.status}`); |
| | | } |
| | | return antwort.json() as Promise<T>; |
| | | } |
| | | |
| | | /** Lädt den kompletten Software-Katalog vom Server. */ |
| | | export async function ladeKatalog(): Promise<Software[]> { |
| | | return jsonOderFehler(await fetch('/api/software')); |
| | | } |
| | | |
| | | /** Fragt, ob der Zugriff direkt am Server (lokal) erfolgt â steuert die Sichtbarkeit der Verwaltung. */ |
| | | export async function ladeUmgebung(): Promise<{ lokal: boolean }> { |
| | | return jsonOderFehler(await fetch('/api/umgebung')); |
| | | } |
| | | |
| | | export async function softwareAnlegen(eingabe: SoftwareEingabe): Promise<Software> { |
| | | return jsonOderFehler( |
| | | await fetch('/api/software', { |
| | | method: 'POST', |
| | | headers: { 'Content-Type': 'application/json' }, |
| | | body: JSON.stringify(eingabe), |
| | | }) |
| | | ); |
| | | } |
| | | |
| | | export async function softwareAktualisieren(id: number, eingabe: SoftwareEingabe): Promise<Software> { |
| | | return jsonOderFehler( |
| | | await fetch(`/api/software/${id}`, { |
| | | method: 'PUT', |
| | | headers: { 'Content-Type': 'application/json' }, |
| | | body: JSON.stringify(eingabe), |
| | | }) |
| | | ); |
| | | } |
| | | |
| | | export async function softwareLoeschen(id: number): Promise<void> { |
| | | const antwort = await fetch(`/api/software/${id}`, { method: 'DELETE' }); |
| | | if (!antwort.ok) { |
| | | throw new Error(`Löschen fehlgeschlagen (Status ${antwort.status})`); |
| | | } |
| | | } |
| | | |
| | | /** Hinterlegt eine Datei von Hand (Quellentyp âmanuell"). */ |
| | | export async function dateiHochladen(id: number, datei: File): Promise<Software> { |
| | | return jsonOderFehler( |
| | | await fetch(`/api/software/${id}/datei?dateiname=${encodeURIComponent(datei.name)}`, { |
| | | method: 'POST', |
| | | headers: { 'Content-Type': 'application/octet-stream' }, |
| | | body: datei, |
| | | }) |
| | | ); |
| | | } |
| | | |
| | | /** Löst einen sofortigen Abgleich der Bezugsquelle einer Software aus. */ |
| | | export async function abgleichen(id: number): Promise<Software> { |
| | | return jsonOderFehler(await fetch(`/api/software/${id}/abgleichen`, { method: 'POST' })); |
| | | } |
| | | |
| | | /** Gleicht alle Einträge mit gesetzter Quelle ab. */ |
| | | export async function alleAbgleichen(): Promise<Software[]> { |
| | | return jsonOderFehler(await fetch('/api/abgleich/alle', { method: 'POST' })); |
| | | } |
| | | |
| | | /** Stand der Bau-Geheimnisse einer Software (nur Namen, nie die Werte). */ |
| | | export async function ladeGeheimnisse(id: number): Promise<GeheimnisUebersicht[]> { |
| | | return jsonOderFehler(await fetch(`/api/software/${id}/geheimnisse`)); |
| | | } |
| | | |
| | | /** Speichert Bau-Geheimnisse. Ein leerer Wert lässt den bisherigen unverändert. */ |
| | | export async function speichereGeheimnisse(id: number, werte: Array<{ name: string; wert: string }>): Promise<GeheimnisUebersicht[]> { |
| | | return jsonOderFehler( |
| | | await fetch(`/api/software/${id}/geheimnisse`, { |
| | | method: 'PUT', |
| | | headers: { 'Content-Type': 'application/json' }, |
| | | body: JSON.stringify(werte), |
| | | }) |
| | | ); |
| | | } |
| | | |
| | | /** URL für den Umzugs-Export (Katalog + Einstellungen, optional mit Geheimnissen). */ |
| | | export function exportUrl(mitGeheimnissen: boolean): string { |
| | | return `/api/export${mitGeheimnissen ? '?geheimnisse=ja' : ''}`; |
| | | } |
| | | |
| | | /** Liest ein Umzugs-Paket ein und ersetzt damit den bisherigen Katalog. */ |
| | | export async function importiereUmzugsPaket(paket: unknown): Promise<{ uebernommen: number; mitGeheimnissen: number; hinweis: string }> { |
| | | return jsonOderFehler( |
| | | await fetch('/api/import', { |
| | | method: 'POST', |
| | | headers: { 'Content-Type': 'application/json' }, |
| | | body: JSON.stringify(paket), |
| | | }) |
| | | ); |
| | | } |
| | | |
| | | /** Lädt die Einstellungen samt der daraus abgeleiteten Pfade (nur lokal erlaubt). */ |
| | | export async function ladeEinstellungen(): Promise<EinstellungenAnsicht> { |
| | | return jsonOderFehler(await fetch('/api/einstellungen')); |
| | | } |
| | | |
| | | export async function speichereEinstellungen(einstellungen: Einstellungen): Promise<EinstellungenAnsicht> { |
| | | return jsonOderFehler( |
| | | await fetch('/api/einstellungen', { |
| | | method: 'PUT', |
| | | headers: { 'Content-Type': 'application/json' }, |
| | | body: JSON.stringify(einstellungen), |
| | | }) |
| | | ); |
| | | } |
| | | |
| | | /** URL zum Herunterladen mehrerer Programme als ZIP. */ |
| | | export function downloadMehrereUrl(ids: number[], angegebenerName?: string): string { |
| | | const parameter = new URLSearchParams({ ids: ids.join(',') }); |
| | | if (angegebenerName) { |
| | | parameter.set('name', angegebenerName); |
| | | } |
| | | return `/api/download-mehrere?${parameter.toString()}`; |
| | | } |
| | | |
| | | /** URL zum Herunterladen der Datei einer Software (optional mit angegebenem Namen fürs Protokoll). */ |
| | | export function downloadUrl(id: number, angegebenerName?: string): string { |
| | | const basis = `/api/software/${id}/download`; |
| | | return angegebenerName ? `${basis}?name=${encodeURIComponent(angegebenerName)}` : basis; |
| | | } |
| Neue Datei |
| | |
| | | import { LitElement, html, nothing } from 'lit'; |
| | | import { customElement, query, state } from 'lit/decorators.js'; |
| | | import type { DhInput, DhSelect } from '@dh-software/dh-components'; |
| | | import { EINSTUFUNGEN, type Einstufung, type Software } from 'bibliothek-types'; |
| | | import { ladeKatalog, ladeUmgebung, softwareLoeschen, abgleichen, alleAbgleichen, downloadUrl, downloadMehrereUrl } from '../api-client'; |
| | | import { gemerkterModus, setzeTheme, type ThemeModus } from '../theme'; |
| | | import './bibliothek-zeile'; |
| | | import './bibliothek-formular'; |
| | | import './bibliothek-einstellungen'; |
| | | |
| | | function themeOptionen(aktuell: ThemeModus) { |
| | | // Frische Kopie je dh-select (die Komponente mutiert die Options-Objekte). |
| | | return [ |
| | | { dataId: 'system', showContent: 'System', selected: aktuell === 'system' }, |
| | | { dataId: 'hell', showContent: 'Hell', selected: aktuell === 'hell' }, |
| | | { dataId: 'dunkel', showContent: 'Dunkel', selected: aktuell === 'dunkel' }, |
| | | ]; |
| | | } |
| | | |
| | | /** Wurzel-Komponente: Katalog als kompakte, durchsuchbare Liste; Verwaltung + Abgleich nur lokal. */ |
| | | @customElement('bibliothek-app') |
| | | export class BibliothekApp extends LitElement { |
| | | @query('#theme-select') private themeSelect?: DhSelect; |
| | | |
| | | @state() private katalog: Software[] = []; |
| | | @state() private lokal = false; |
| | | @state() private ladefehler = ''; |
| | | @state() private formularOffen = false; |
| | | @state() private einstellungenOffen = false; |
| | | @state() private zuBearbeiten?: Software; |
| | | @state() private suchbegriff = ''; |
| | | @state() private beschaeftigt = false; |
| | | @state() private themeModus: ThemeModus = gemerkterModus(); |
| | | @state() private aktiveEinstufung: Einstufung = 'lizenzfrei'; |
| | | /** Ids der für den Sammel-Download angehakten Einträge â bleibt über Reiter hinweg bestehen. */ |
| | | @state() private auswahl: ReadonlySet<number> = new Set(); |
| | | |
| | | // Light DOM, damit die globalen dh-components-Theme-Regeln greifen. |
| | | createRenderRoot() { |
| | | return this; |
| | | } |
| | | |
| | | connectedCallback(): void { |
| | | super.connectedCallback(); |
| | | this.datenLaden(); |
| | | } |
| | | |
| | | protected firstUpdated(): void { |
| | | if (this.themeSelect) { |
| | | (this.themeSelect as unknown as { selectOptions: ReturnType<typeof themeOptionen> }).selectOptions = themeOptionen(this.themeModus); |
| | | } |
| | | } |
| | | |
| | | render() { |
| | | return html` |
| | | <div class="bibliothek-app-rahmen"> |
| | | <header class="kopf"> |
| | | <h1>Software-Bibliothek</h1> |
| | | <div class="kopf-aktionen"> |
| | | <dh-select |
| | | id="theme-select" |
| | | class="theme-select" |
| | | withoutEmptyDefault |
| | | notSearchable |
| | | @changeSelectOption=${this.beiThemeWechsel} |
| | | ></dh-select> |
| | | ${this.lokal && !this.formularOffen && !this.einstellungenOffen |
| | | ? html` |
| | | <dh-button buttonText="Alle prüfen" variant="standard" ?disabled=${this.beschaeftigt} @click=${this.allePruefen}></dh-button> |
| | | <dh-button buttonText="+ Neue Software" variant="filled" @click=${this.neuAnlegen}></dh-button> |
| | | <dh-button icon-button title="Einstellungen" variant="standard" @click=${this.einstellungenOeffnen}> |
| | | <span slot="img" class="fv-icon-settings"></span> |
| | | </dh-button> |
| | | ` |
| | | : nothing} |
| | | </div> |
| | | </header> |
| | | ${this.beschaeftigt ? html`<p class="hinweis">Bezugsquellen werden geprüft â¦</p>` : nothing} |
| | | ${this.rendereProblemleiste()} |
| | | ${this.auswahl.size > 0 && !this.formularOffen |
| | | ? html`<div class="auswahl-leiste"> |
| | | <dh-button icon-button variant="standard" title="Auswahl aufheben" @click=${this.auswahlAufheben}> |
| | | <span slot="img" class="fv-icon-close"></span> |
| | | </dh-button> |
| | | <span class="auswahl-anzahl">${this.auswahl.size} ausgewählt</span> |
| | | <dh-button buttonText="Herunterladen" variant="filled" @click=${this.auswahlHerunterladen}></dh-button> |
| | | </div>` |
| | | : nothing} |
| | | ${this.ladefehler ? html`<p class="fehler">${this.ladefehler}</p>` : nothing} |
| | | ${this.einstellungenOffen |
| | | ? html`<bibliothek-einstellungen |
| | | @geschlossen=${this.einstellungenSchliessen} |
| | | @umzugAbgeschlossen=${this.datenLaden} |
| | | ></bibliothek-einstellungen>` |
| | | : this.formularOffen |
| | | ? html`<bibliothek-formular |
| | | .software=${this.zuBearbeiten} |
| | | .moeglicheEltern=${this.hauptEintraege()} |
| | | @gespeichert=${this.beiGespeichert} |
| | | @abgebrochen=${this.formularSchliessen} |
| | | ></bibliothek-formular>` |
| | | : this.rendereKatalog()} |
| | | </div> |
| | | `; |
| | | } |
| | | |
| | | /** |
| | | * Weist auf Einträge hin, deren letzter Abgleich fehlgeschlagen ist â sonst |
| | | * fällt ein toter Bezugslink nur auf, wenn jemand gezielt in die Liste schaut. |
| | | */ |
| | | private rendereProblemleiste() { |
| | | const betroffene = this.katalog.filter((software) => software.letzterAbgleich && !software.abgleichErfolg); |
| | | if (betroffene.length === 0) { |
| | | return nothing; |
| | | } |
| | | const namen = betroffene.slice(0, 3).map((software) => software.name).join(', '); |
| | | const weitere = betroffene.length > 3 ? ` und ${betroffene.length - 3} weitere` : ''; |
| | | // Der Hinweis auf den erhaltenen Stand ergibt nur Sinn, wenn es überhaupt einen gibt. |
| | | const mitBisherigerDatei = betroffene.some((software) => software.dateiname); |
| | | return html`<p class="warnung"> |
| | | <strong>${betroffene.length} Eintrag${betroffene.length === 1 ? '' : 'e'} mit Problemen beim Abgleich:</strong> |
| | | ${namen}${weitere}.${mitBisherigerDatei ? ' Die zuletzt bereitgestellte Datei bleibt dabei erhalten.' : ''} |
| | | </p>`; |
| | | } |
| | | |
| | | private rendereKatalog() { |
| | | if (this.katalog.length === 0) { |
| | | return html`<p>Noch keine Software im Katalog.</p>`; |
| | | } |
| | | return html` |
| | | <dh-input |
| | | class="suche" |
| | | type="text" |
| | | placeholder="Suchen" |
| | | .value=${this.suchbegriff} |
| | | @input=${this.beiSuche} |
| | | ></dh-input> |
| | | <dh-tab stretch @clickTab=${this.beiReiterWechsel}> |
| | | ${EINSTUFUNGEN.map( |
| | | (eintrag) => html`<div |
| | | slot="tab-head" |
| | | content=${eintrag.id} |
| | | class=${eintrag.id === this.aktiveEinstufung ? 'selected' : ''} |
| | | > |
| | | ${eintrag.text} (${this.eintraegeMitEinstufung(eintrag.id).length}) |
| | | </div>` |
| | | )} |
| | | ${EINSTUFUNGEN.map( |
| | | (eintrag) => html`<div slot="tab-content" tab=${eintrag.id} class=${eintrag.id === this.aktiveEinstufung ? '' : 'hidden'}> |
| | | ${eintrag.id === this.aktiveEinstufung ? this.rendereListe() : nothing} |
| | | </div>` |
| | | )} |
| | | </dh-tab> |
| | | `; |
| | | } |
| | | |
| | | private rendereListe() { |
| | | const gefiltert = this.gefilterterKatalog(); |
| | | if (gefiltert.length === 0) { |
| | | return html`<p class="leer"> |
| | | ${this.suchbegriff ? html`Keine Treffer für â${this.suchbegriff}".` : 'In dieser Einstufung ist noch nichts hinterlegt.'} |
| | | </p>`; |
| | | } |
| | | return html` |
| | | ${this.aktiveEinstufung === 'lizenzpflichtig' |
| | | ? html`<p class="hinweis">Für diese Programme kommt der Lizenzschlüssel über den Support.</p>` |
| | | : nothing} |
| | | <div class="liste ${this.lokal ? 'verwaltung' : ''}"> |
| | | ${gefiltert.map( |
| | | (software) => html`<bibliothek-zeile |
| | | .software=${software} |
| | | .zusatzinhalte=${this.zusatzinhalteVon(software.id)} |
| | | .auswahl=${this.auswahl} |
| | | ?gewaehlt=${this.auswahl.has(software.id)} |
| | | ?lokal=${this.lokal} |
| | | @auswahlGeaendert=${this.beiAuswahlGeaendert} |
| | | @herunterladen=${this.beiHerunterladen} |
| | | @pruefen=${this.beiPruefen} |
| | | @bearbeiten=${this.beiBearbeiten} |
| | | @loeschen=${this.beiLoeschen} |
| | | ></bibliothek-zeile>` |
| | | )} |
| | | </div>`; |
| | | } |
| | | |
| | | private beiAuswahlGeaendert(ereignis: CustomEvent<{ software: Software; gewaehlt: boolean }>): void { |
| | | const { software, gewaehlt } = ereignis.detail; |
| | | const neueAuswahl = new Set(this.auswahl); |
| | | if (gewaehlt) { |
| | | neueAuswahl.add(software.id); |
| | | } else { |
| | | neueAuswahl.delete(software.id); |
| | | } |
| | | this.auswahl = neueAuswahl; |
| | | } |
| | | |
| | | private auswahlAufheben(): void { |
| | | this.auswahl = new Set(); |
| | | } |
| | | |
| | | private auswahlHerunterladen(): void { |
| | | const angegebenerName = this.namenErfragen(); |
| | | if (angegebenerName === null) { |
| | | return; |
| | | } |
| | | window.location.href = downloadMehrereUrl([...this.auswahl], angegebenerName); |
| | | } |
| | | |
| | | /** Fragt im Netzwerk-Betrieb nach dem Namen fürs Protokoll; lokal nicht nötig. */ |
| | | private namenErfragen(): string | undefined | null { |
| | | if (this.lokal) { |
| | | return undefined; |
| | | } |
| | | return prompt('Bitte deinen Namen angeben (wird protokolliert):'); |
| | | } |
| | | |
| | | private beiReiterWechsel(ereignis: CustomEvent<string>): void { |
| | | this.aktiveEinstufung = ereignis.detail as Einstufung; |
| | | } |
| | | |
| | | /** Eigenständige Tools einer Einstufung (Grundlage für die Zähler an den Reitern). */ |
| | | private eintraegeMitEinstufung(einstufung: Einstufung): Software[] { |
| | | return this.hauptEintraege().filter((software) => software.einstufung === einstufung); |
| | | } |
| | | |
| | | /** Eigenständige Tools (ohne die Zusatzinhalte, die unter ihrem Tool erscheinen). */ |
| | | private hauptEintraege(): Software[] { |
| | | return this.katalog.filter((software) => !software.elternId); |
| | | } |
| | | |
| | | private zusatzinhalteVon(elternId: number): Software[] { |
| | | return this.katalog.filter((software) => software.elternId === elternId); |
| | | } |
| | | |
| | | /** Einträge des aktiven Reiters, zusätzlich nach dem Suchbegriff gefiltert. */ |
| | | private gefilterterKatalog(): Software[] { |
| | | const eintraege = this.eintraegeMitEinstufung(this.aktiveEinstufung); |
| | | const begriff = this.suchbegriff.trim().toLowerCase(); |
| | | if (!begriff) { |
| | | return eintraege; |
| | | } |
| | | return eintraege.filter( |
| | | (software) => software.name.toLowerCase().includes(begriff) || software.beschreibung.toLowerCase().includes(begriff) |
| | | ); |
| | | } |
| | | |
| | | private beiSuche(ereignis: Event): void { |
| | | this.suchbegriff = (ereignis.target as DhInput).value; |
| | | } |
| | | |
| | | private beiThemeWechsel(ereignis: CustomEvent<{ selectedOption: { dataId: string } }>): void { |
| | | this.themeModus = ereignis.detail.selectedOption.dataId as ThemeModus; |
| | | setzeTheme(this.themeModus); |
| | | } |
| | | |
| | | public async datenLaden(): Promise<void> { |
| | | try { |
| | | const [umgebung, katalog] = await Promise.all([ladeUmgebung(), ladeKatalog()]); |
| | | this.lokal = umgebung.lokal; |
| | | this.katalog = katalog; |
| | | this.ladefehler = ''; |
| | | } catch (fehler) { |
| | | this.ladefehler = fehler instanceof Error ? fehler.message : 'Daten konnten nicht geladen werden.'; |
| | | } |
| | | } |
| | | |
| | | private neuAnlegen(): void { |
| | | this.zuBearbeiten = undefined; |
| | | this.formularOffen = true; |
| | | } |
| | | |
| | | private einstellungenOeffnen(): void { |
| | | this.einstellungenOffen = true; |
| | | } |
| | | |
| | | private einstellungenSchliessen(): void { |
| | | this.einstellungenOffen = false; |
| | | } |
| | | |
| | | private beiBearbeiten(ereignis: CustomEvent<Software>): void { |
| | | this.zuBearbeiten = ereignis.detail; |
| | | this.formularOffen = true; |
| | | } |
| | | |
| | | private formularSchliessen(): void { |
| | | this.formularOffen = false; |
| | | this.zuBearbeiten = undefined; |
| | | } |
| | | |
| | | private beiGespeichert(): void { |
| | | this.formularSchliessen(); |
| | | this.datenLaden(); |
| | | } |
| | | |
| | | private beiHerunterladen(ereignis: CustomEvent<Software>): void { |
| | | const angegebenerName = this.namenErfragen(); |
| | | if (angegebenerName === null) { |
| | | return; |
| | | } |
| | | window.location.href = downloadUrl(ereignis.detail.id, angegebenerName); |
| | | } |
| | | |
| | | private async beiPruefen(ereignis: CustomEvent<Software>): Promise<void> { |
| | | await this.mitBeschaeftigt(() => abgleichen(ereignis.detail.id)); |
| | | this.datenLaden(); |
| | | } |
| | | |
| | | private async allePruefen(): Promise<void> { |
| | | await this.mitBeschaeftigt(() => alleAbgleichen()); |
| | | this.datenLaden(); |
| | | } |
| | | |
| | | private async beiLoeschen(ereignis: CustomEvent<Software>): Promise<void> { |
| | | const software = ereignis.detail; |
| | | const anzahlZusatzinhalte = this.zusatzinhalteVon(software.id).length; |
| | | const zusatz = anzahlZusatzinhalte > 0 ? ` Die ${anzahlZusatzinhalte} zugehörigen Zusatzinhalte werden mitgelöscht.` : ''; |
| | | if (!confirm(`â${software.name}" wirklich löschen?${zusatz}`)) { |
| | | return; |
| | | } |
| | | try { |
| | | await softwareLoeschen(software.id); |
| | | this.datenLaden(); |
| | | } catch (fehler) { |
| | | this.ladefehler = fehler instanceof Error ? fehler.message : 'Löschen fehlgeschlagen.'; |
| | | } |
| | | } |
| | | |
| | | private async mitBeschaeftigt(aktion: () => Promise<unknown>): Promise<void> { |
| | | this.beschaeftigt = true; |
| | | this.ladefehler = ''; |
| | | try { |
| | | await aktion(); |
| | | } catch (fehler) { |
| | | this.ladefehler = fehler instanceof Error ? fehler.message : 'Abgleich fehlgeschlagen.'; |
| | | } finally { |
| | | this.beschaeftigt = false; |
| | | } |
| | | } |
| | | } |
| | | |
| | | declare global { |
| | | interface HTMLElementTagNameMap { |
| | | 'bibliothek-app': BibliothekApp; |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | import { LitElement, html, nothing } from 'lit'; |
| | | import { customElement, state } from 'lit/decorators.js'; |
| | | import { event, type EventRef } from '@dh-software/lit-extensions'; |
| | | import type { DhInput } from '@dh-software/dh-components'; |
| | | import type { EinstellungenAnsicht } from 'bibliothek-types'; |
| | | import { ladeEinstellungen, speichereEinstellungen, exportUrl, importiereUmzugsPaket } from '../api-client'; |
| | | |
| | | /** Einstellungen der Anwendung â nur direkt am Server erreichbar. */ |
| | | @customElement('bibliothek-einstellungen') |
| | | export class BibliothekEinstellungen extends LitElement { |
| | | @state() private ansicht?: EinstellungenAnsicht; |
| | | @state() private datenOrdner = ''; |
| | | @state() private port = 3000; |
| | | @state() private gitBenutzer = ''; |
| | | @state() private gitSchluessel = ''; |
| | | @state() private meldung = ''; |
| | | @state() private fehler = ''; |
| | | @state() private speichert = false; |
| | | |
| | | @event({ bubbles: true, composed: true }, 'geschlossen') |
| | | private geschlossen!: EventRef<void>; |
| | | |
| | | @event({ bubbles: true, composed: true }, 'umzugAbgeschlossen') |
| | | private umzugAbgeschlossen!: EventRef<void>; |
| | | |
| | | // Light DOM, damit das globale dh-components-Theme greift. |
| | | createRenderRoot() { |
| | | return this; |
| | | } |
| | | |
| | | connectedCallback(): void { |
| | | super.connectedCallback(); |
| | | this.einstellungenLaden(); |
| | | } |
| | | |
| | | render() { |
| | | if (!this.ansicht) { |
| | | return html`<dh-card variant="outlined"><span slot="content">Einstellungen werden geladen â¦</span></dh-card>`; |
| | | } |
| | | const neustartNoetig = |
| | | this.datenOrdner.trim() !== this.ansicht.einstellungen.datenOrdner || this.port !== this.ansicht.laufenderPort; |
| | | return html` |
| | | <dh-card variant="outlined" no-hover-effect no-click-effect> |
| | | <span slot="header">Einstellungen</span> |
| | | <div slot="content"> |
| | | <dh-form-grid> |
| | | <dh-form-field label="Datenordner" span="2"> |
| | | <dh-input |
| | | type="text" |
| | | placeholder=${'leer = Standardordner unter ProgramData'} |
| | | .value=${this.datenOrdner} |
| | | @input=${this.beiDatenOrdner} |
| | | ></dh-input> |
| | | <span class="feldhinweis">Hier liegen die Datenbank und alle bereitgestellten Programm-Dateien.</span> |
| | | </dh-form-field> |
| | | <dh-form-field label="Port"> |
| | | <dh-input type="number" min="1" max="65535" .value=${String(this.port)} @input=${this.beiPort}></dh-input> |
| | | </dh-form-field> |
| | | <dh-form-field label="Git-Benutzer (für interne Tools)"> |
| | | <dh-input |
| | | type="text" |
| | | placeholder="leer = SSH-Konfiguration des Dienstbenutzers" |
| | | .value=${this.gitBenutzer} |
| | | @input=${this.beiGitBenutzer} |
| | | ></dh-input> |
| | | <span class="feldhinweis"> |
| | | Gilt für alle Git-Quellen. Dadurch bleiben die Katalog-URLs frei von Konto-Angaben â ein |
| | | Kontowechsel ist eine Ãnderung hier statt an jedem Eintrag. |
| | | </span> |
| | | </dh-form-field> |
| | | <dh-form-field label="SSH-Schlüssel (optional)"> |
| | | <dh-input |
| | | type="text" |
| | | placeholder="C:\\ProgramData\\Software-Bibliothek\\id_bibliothek" |
| | | .value=${this.gitSchluessel} |
| | | @input=${this.beiGitSchluessel} |
| | | ></dh-input> |
| | | <span class="feldhinweis"> |
| | | Nur nötig, wenn das Konto, unter dem die Bibliothek läuft, keinen eigenen SSH-Schlüssel hat â |
| | | sonst leer lassen. Wird relevant beim Betrieb als Dienst oder nach einem Serverumzug. |
| | | </span> |
| | | </dh-form-field> |
| | | </dh-form-grid> |
| | | |
| | | <dl class="einstellungen-infos"> |
| | | <dt>Datenordner (aktuell genutzt)</dt> |
| | | <dd>${this.ansicht.effektiverDatenOrdner}</dd> |
| | | <dt>Konfigurationsdatei</dt> |
| | | <dd>${this.ansicht.konfigurationsDatei}</dd> |
| | | <dt>Laufender Port</dt> |
| | | <dd>${this.ansicht.laufenderPort}</dd> |
| | | </dl> |
| | | |
| | | <div class="umzug"> |
| | | <h3>Umzug auf einen anderen Rechner</h3> |
| | | <p class="feldhinweis"> |
| | | Gesichert werden Katalog, Bezugsquellen und Einstellungen â wenige Kilobyte. Die geholten |
| | | Programm-Dateien kommen nicht mit; die holt der Abgleich auf dem neuen Rechner selbst wieder. |
| | | </p> |
| | | <div class="umzug-aktionen"> |
| | | <dh-button buttonText="Exportieren" variant="standard" @click=${this.beiExport}></dh-button> |
| | | <dh-button buttonText="Mit Schlüsseln exportieren" variant="standard" @click=${this.beiExportMitGeheimnissen}></dh-button> |
| | | <dh-button buttonText="Paket einlesen" variant="standard" @click=${this.beiImportWaehlen}></dh-button> |
| | | <input id="import-datei" type="file" accept="application/json,.json" hidden @change=${this.beiImportDatei} /> |
| | | </div> |
| | | <p class="feldhinweis warnhinweis"> |
| | | âMit Schlüsseln" schreibt die hinterlegten Werte im Klartext in die Datei â nur bewusst nutzen |
| | | und die Datei danach sicher aufbewahren bzw. löschen. |
| | | </p> |
| | | </div> |
| | | |
| | | ${neustartNoetig ? html`<p class="hinweis">Ãnderungen an Datenordner oder Port wirken erst nach einem Neustart der Anwendung.</p>` : nothing} |
| | | ${this.meldung ? html`<p class="hinweis">${this.meldung}</p>` : nothing} |
| | | </div> |
| | | <div slot="actions"> |
| | | ${this.fehler ? html`<span class="fehler">${this.fehler}</span>` : nothing} |
| | | <dh-button buttonText="SchlieÃen" variant="standard" @click=${this.beiSchliessen}></dh-button> |
| | | <dh-button |
| | | buttonText=${this.speichert ? 'Speichertâ¦' : 'Speichern'} |
| | | variant="filled" |
| | | ?disabled=${this.speichert} |
| | | @click=${this.beiSpeichern} |
| | | ></dh-button> |
| | | </div> |
| | | </dh-card> |
| | | `; |
| | | } |
| | | |
| | | private async einstellungenLaden(): Promise<void> { |
| | | try { |
| | | this.ansicht = await ladeEinstellungen(); |
| | | this.datenOrdner = this.ansicht.einstellungen.datenOrdner; |
| | | this.port = this.ansicht.einstellungen.port; |
| | | this.gitBenutzer = this.ansicht.einstellungen.gitBenutzer; |
| | | this.gitSchluessel = this.ansicht.einstellungen.gitSchluessel; |
| | | } catch (fehler) { |
| | | this.fehler = fehler instanceof Error ? fehler.message : 'Einstellungen konnten nicht geladen werden.'; |
| | | } |
| | | } |
| | | |
| | | private beiDatenOrdner(ereignis: Event): void { |
| | | this.datenOrdner = (ereignis.target as DhInput).value; |
| | | } |
| | | |
| | | private beiPort(ereignis: Event): void { |
| | | this.port = Number((ereignis.target as DhInput).value) || 0; |
| | | } |
| | | |
| | | private beiGitBenutzer(ereignis: Event): void { |
| | | this.gitBenutzer = (ereignis.target as DhInput).value; |
| | | } |
| | | |
| | | private beiGitSchluessel(ereignis: Event): void { |
| | | this.gitSchluessel = (ereignis.target as DhInput).value; |
| | | } |
| | | |
| | | private beiSchliessen(): void { |
| | | this.geschlossen.dispatch(); |
| | | } |
| | | |
| | | private beiExport(): void { |
| | | window.location.href = exportUrl(false); |
| | | } |
| | | |
| | | private beiExportMitGeheimnissen(): void { |
| | | if (confirm('Die Datei enthält dann die hinterlegten Schlüssel im Klartext. Wirklich so exportieren?')) { |
| | | window.location.href = exportUrl(true); |
| | | } |
| | | } |
| | | |
| | | private beiImportWaehlen(): void { |
| | | this.querySelector<HTMLInputElement>('#import-datei')?.click(); |
| | | } |
| | | |
| | | private async beiImportDatei(ereignis: Event): Promise<void> { |
| | | const feld = ereignis.target as HTMLInputElement; |
| | | const datei = feld.files?.[0]; |
| | | feld.value = ''; // erlaubt, dieselbe Datei erneut zu wählen |
| | | if (!datei) { |
| | | return; |
| | | } |
| | | if (!confirm('Der bisherige Katalog wird dabei vollständig ersetzt. Fortfahren?')) { |
| | | return; |
| | | } |
| | | this.fehler = ''; |
| | | this.meldung = ''; |
| | | try { |
| | | const ergebnis = await importiereUmzugsPaket(JSON.parse(await datei.text())); |
| | | this.meldung = `${ergebnis.uebernommen} Einträge übernommen${ergebnis.mitGeheimnissen > 0 ? `, davon ${ergebnis.mitGeheimnissen} mit Schlüsseln` : ''}. ${ergebnis.hinweis}`; |
| | | this.umzugAbgeschlossen.dispatch(); |
| | | } catch (fehler) { |
| | | this.fehler = fehler instanceof Error ? fehler.message : 'Paket konnte nicht eingelesen werden.'; |
| | | } |
| | | } |
| | | |
| | | private async beiSpeichern(): Promise<void> { |
| | | this.speichert = true; |
| | | this.fehler = ''; |
| | | this.meldung = ''; |
| | | try { |
| | | this.ansicht = await speichereEinstellungen({ |
| | | datenOrdner: this.datenOrdner.trim(), |
| | | port: this.port, |
| | | gitBenutzer: this.gitBenutzer.trim(), |
| | | gitSchluessel: this.gitSchluessel.trim(), |
| | | }); |
| | | this.datenOrdner = this.ansicht.einstellungen.datenOrdner; |
| | | this.port = this.ansicht.einstellungen.port; |
| | | this.gitBenutzer = this.ansicht.einstellungen.gitBenutzer; |
| | | this.gitSchluessel = this.ansicht.einstellungen.gitSchluessel; |
| | | this.meldung = 'Gespeichert. Datenordner und Port gelten ab dem nächsten Start.'; |
| | | } catch (fehler) { |
| | | this.fehler = fehler instanceof Error ? fehler.message : 'Speichern fehlgeschlagen.'; |
| | | } finally { |
| | | this.speichert = false; |
| | | } |
| | | } |
| | | } |
| | | |
| | | declare global { |
| | | interface HTMLElementTagNameMap { |
| | | 'bibliothek-einstellungen': BibliothekEinstellungen; |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | 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; |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | import { LitElement, html, nothing } from 'lit'; |
| | | import { customElement, property } from 'lit/decorators.js'; |
| | | import { event, type EventRef } from '@dh-software/lit-extensions'; |
| | | import type { Software } from 'bibliothek-types'; |
| | | |
| | | /** |
| | | * Eine kompakte Katalog-Zeile für einen Software-Eintrag. Feuert Aktions-Events an die App. |
| | | * Die Aktionen liegen in festen Spalten, damit sie über alle Zeilen hinweg untereinander stehen; |
| | | * fehlt eine Aktion, bleibt ihre Spalte leer. |
| | | * |
| | | * Zusatzinhalte (Plug-ins, Sprachpakete, Vorlagen â¦) stehen eingerückt direkt unter ihrem Tool |
| | | * und bilden mit ihm eine Gruppe. |
| | | */ |
| | | @customElement('bibliothek-zeile') |
| | | export class BibliothekZeile extends LitElement { |
| | | @property({ attribute: false }) software!: Software; |
| | | /** Weitere Einträge, die zu dieser Software gehören. */ |
| | | @property({ attribute: false }) zusatzinhalte: Software[] = []; |
| | | @property({ type: Boolean }) lokal = false; |
| | | /** Zeile eines Zusatzinhalts (eingerückt unter seinem Tool). */ |
| | | @property({ type: Boolean }) eingerueckt = false; |
| | | /** Ist dieser Eintrag für den Sammel-Download angehakt? */ |
| | | @property({ type: Boolean }) gewaehlt = false; |
| | | /** Ids aller angehakten Einträge â nötig, damit auch eingerückte Zeilen ihren Zustand kennen. */ |
| | | @property({ attribute: false }) auswahl: ReadonlySet<number> = new Set(); |
| | | |
| | | @event({ bubbles: true, composed: true }, 'auswahlGeaendert') |
| | | private auswahlGeaendert!: EventRef<{ software: Software; gewaehlt: boolean }>; |
| | | |
| | | @event({ bubbles: true, composed: true }, 'herunterladen') |
| | | private herunterladen!: EventRef<Software>; |
| | | |
| | | @event({ bubbles: true, composed: true }, 'pruefen') |
| | | private pruefen!: EventRef<Software>; |
| | | |
| | | @event({ bubbles: true, composed: true }, 'bearbeiten') |
| | | private bearbeiten!: EventRef<Software>; |
| | | |
| | | @event({ bubbles: true, composed: true }, 'loeschen') |
| | | private loeschen!: EventRef<Software>; |
| | | |
| | | // Light DOM, damit das globale dh-components-Theme greift. |
| | | createRenderRoot() { |
| | | return this; |
| | | } |
| | | |
| | | render() { |
| | | const software = this.software; |
| | | return html` |
| | | <div class="zeile ${this.eingerueckt ? 'zeile-eingerueckt' : ''}"> |
| | | <span class="zeile-auswahl"> |
| | | <dh-checkbox |
| | | ?checked=${this.gewaehlt} |
| | | ?disabled=${!software.dateiname} |
| | | title=${software.dateiname ? 'Für Sammel-Download auswählen' : 'Keine Datei vorhanden'} |
| | | @isChange=${this.beiAuswahl} |
| | | ></dh-checkbox> |
| | | </span> |
| | | <div class="zeile-info"> |
| | | <span class="zeile-name"> |
| | | ${software.name} |
| | | ${software.einstufung === 'lizenzpflichtig' |
| | | ? html`<span class="lizenz-marke" title="Lizenzschlüssel über den Support beziehen">lizenzpflichtig</span>` |
| | | : nothing} |
| | | ${software.einstufung === 'intern' ? html`<span class="intern-marke">intern</span>` : nothing} |
| | | </span> |
| | | <span class="zeile-meta"> |
| | | ${this.rendereStand()} |
| | | <span class="${this.statusKlasse()}">${this.statusText()}</span> |
| | | ${this.rendereSignatur()} |
| | | </span> |
| | | ${this.rendereHinweise()} |
| | | </div> |
| | | <div class="zeile-aktionen"> |
| | | <span class="aktion"> |
| | | ${software.anleitungUrl |
| | | ? html`<a class="anleitung" href=${software.anleitungUrl} target="_blank" rel="noopener noreferrer">Anleitung</a>` |
| | | : nothing} |
| | | </span> |
| | | <span class="aktion"> |
| | | ${software.dateiname |
| | | ? html`<dh-button |
| | | icon-button |
| | | variant="filled" |
| | | title=${`${software.name} herunterladen`} |
| | | @click=${this.beiHerunterladen} |
| | | > |
| | | <span slot="img" class="fv-icon-download"></span> |
| | | </dh-button>` |
| | | : nothing} |
| | | </span> |
| | | ${this.lokal |
| | | ? html` |
| | | <span class="aktion"> |
| | | ${software.quelleTyp && software.quelleTyp !== 'manuell' |
| | | ? html`<dh-button buttonText="Prüfen" variant="standard" @click=${this.beiPruefen}></dh-button>` |
| | | : nothing} |
| | | </span> |
| | | <span class="aktion"> |
| | | <dh-button buttonText="Bearbeiten" variant="standard" @click=${this.beiBearbeiten}></dh-button> |
| | | </span> |
| | | <span class="aktion"> |
| | | <dh-button buttonText="Löschen" variant="standard" @click=${this.beiLoeschen}></dh-button> |
| | | </span> |
| | | ` |
| | | : nothing} |
| | | </div> |
| | | </div> |
| | | ${this.zusatzinhalte.map( |
| | | (zusatzinhalt) => html`<bibliothek-zeile |
| | | .software=${zusatzinhalt} |
| | | .auswahl=${this.auswahl} |
| | | ?gewaehlt=${this.auswahl.has(zusatzinhalt.id)} |
| | | ?lokal=${this.lokal} |
| | | eingerueckt |
| | | ></bibliothek-zeile>` |
| | | )} |
| | | `; |
| | | } |
| | | |
| | | private beiAuswahl(ereignis: CustomEvent<boolean>): void { |
| | | this.auswahlGeaendert.dispatch({ software: this.software, gewaehlt: ereignis.detail }); |
| | | } |
| | | |
| | | /** |
| | | * Hinweise zum Umgang mit der Datei: was aus der Programmart folgt, plus der |
| | | * freie Hinweis am Eintrag. Die eigene Zeile kostet nur dort Höhe, wo wirklich |
| | | * etwas zu sagen ist â die meisten Einträge bleiben einzeilig. |
| | | */ |
| | | private rendereHinweise() { |
| | | const software = this.software; |
| | | const teile: string[] = []; |
| | | if (software.dateiname && software.programmart === 'installer') { |
| | | teile.push('Datei kann nach der Installation gelöscht werden'); |
| | | } |
| | | if (software.dateiname && software.programmart === 'eigenstaendig') { |
| | | teile.push('Läuft direkt â an einen festen Ort legen'); |
| | | } |
| | | if (software.hinweis) { |
| | | teile.push(software.hinweis); |
| | | } |
| | | return teile.length > 0 ? html`<span class="zeile-hinweis">${teile.join(' · ')}</span>` : nothing; |
| | | } |
| | | |
| | | /** |
| | | * Signaturbefund der bereitgestellten Datei â nur in der Verwaltung. |
| | | * Für die Netzwerk-Ansicht ist das Betriebswissen ohne Nutzen: Wer etwas |
| | | * herunterlädt, kann daraus keine Entscheidung ableiten. |
| | | */ |
| | | private rendereSignatur() { |
| | | const software = this.software; |
| | | if (!this.lokal || !software.dateiname || !software.signaturStatus) { |
| | | return nothing; |
| | | } |
| | | if (software.signaturStatus === 'Valid') { |
| | | return html` · |
| | | <span class="signatur-gut" title="Gültig signiert von ${software.signaturHerausgeber}"> |
| | | signiert: ${software.signaturHerausgeber} |
| | | </span>`; |
| | | } |
| | | const text = software.signaturStatus === 'NotSigned' ? 'unsigniert' : `Signatur unklar (${software.signaturStatus})`; |
| | | return html` · <span class="signatur-unklar" title="Windows-Signaturstatus: ${software.signaturStatus}">${text}</span>`; |
| | | } |
| | | |
| | | /** |
| | | * Zeigt bevorzugt das Veröffentlichungsdatum des Anbieters â das ist die Angabe, |
| | | * die den Nutzer interessiert. Nur wenn die Quelle keins liefert, wird ersatzweise |
| | | * unser Abrufzeitpunkt genannt (und als solcher benannt). |
| | | */ |
| | | private rendereStand() { |
| | | const software = this.software; |
| | | if (software.veroeffentlichtAm) { |
| | | return html`Stand ${this.alsDatum(software.veroeffentlichtAm)} · `; |
| | | } |
| | | if (software.dateiStandVom) { |
| | | return html`geholt ${this.alsDatum(software.dateiStandVom)} · `; |
| | | } |
| | | return nothing; |
| | | } |
| | | |
| | | private alsDatum(zeitstempel: string): string { |
| | | const zeitpunkt = new Date(zeitstempel); |
| | | return Number.isNaN(zeitpunkt.getTime()) ? 'â' : zeitpunkt.toLocaleDateString('de-DE'); |
| | | } |
| | | |
| | | private statusText(): string { |
| | | const software = this.software; |
| | | if (software.dateiname) { |
| | | return software.dateiname; |
| | | } |
| | | if (!software.quelleTyp) { |
| | | return 'keine Quelle'; |
| | | } |
| | | if (software.quelleTyp === 'manuell') { |
| | | return 'keine Datei hinterlegt'; |
| | | } |
| | | if (software.letzterAbgleich && !software.abgleichErfolg) { |
| | | return software.abgleichMeldung || 'Abgleich fehlgeschlagen'; |
| | | } |
| | | return 'Quelle hinterlegt, noch nicht geladen'; |
| | | } |
| | | |
| | | private statusKlasse(): string { |
| | | const software = this.software; |
| | | if (software.dateiname) { |
| | | return 'status-ok'; |
| | | } |
| | | if (!software.quelleTyp || software.quelleTyp === 'manuell' || (software.letzterAbgleich && !software.abgleichErfolg)) { |
| | | return 'status-fehler'; |
| | | } |
| | | return 'status-ausstehend'; |
| | | } |
| | | |
| | | private beiHerunterladen(): void { |
| | | this.herunterladen.dispatch(this.software); |
| | | } |
| | | |
| | | private beiPruefen(): void { |
| | | this.pruefen.dispatch(this.software); |
| | | } |
| | | |
| | | private beiBearbeiten(): void { |
| | | this.bearbeiten.dispatch(this.software); |
| | | } |
| | | |
| | | private beiLoeschen(): void { |
| | | this.loeschen.dispatch(this.software); |
| | | } |
| | | } |
| | | |
| | | declare global { |
| | | interface HTMLElementTagNameMap { |
| | | 'bibliothek-zeile': BibliothekZeile; |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | html, |
| | | body { |
| | | margin: 0; |
| | | min-height: 100%; |
| | | background: var(--md-sys-color-surface, #ffffff); |
| | | color: var(--md-sys-color-on-surface, #1a1a1a); |
| | | font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; |
| | | } |
| | | |
| | | .bibliothek-app-rahmen { |
| | | padding: 24px; |
| | | /* Dauerhaft Platz für die schwebende Auswahl-Leiste â so verschiebt sich |
| | | auch am Listenende nichts, wenn sie erscheint. */ |
| | | padding-bottom: 96px; |
| | | max-width: 1100px; |
| | | margin: 0 auto; |
| | | } |
| | | |
| | | .kopf { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 16px; |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | .kopf-aktionen { |
| | | display: flex; |
| | | gap: 8px; |
| | | align-items: center; |
| | | } |
| | | |
| | | .theme-select { |
| | | min-width: 150px; |
| | | } |
| | | |
| | | .hinweis { |
| | | background: var(--md-sys-color-secondary-container, #e6e0ec); |
| | | color: var(--md-sys-color-on-secondary-container, #1d1b20); |
| | | padding: 12px 16px; |
| | | border-radius: 8px; |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | /* --- Kompakte Katalog-Liste --- */ |
| | | |
| | | .suche { |
| | | display: block; |
| | | max-width: 420px; |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | dh-tab { |
| | | display: block; |
| | | } |
| | | |
| | | /* Inhalt eines nicht gewählten Reiters (die Komponente setzt die Klasse ebenfalls). */ |
| | | [slot='tab-content'].hidden { |
| | | display: none; |
| | | } |
| | | |
| | | .leer { |
| | | padding: 16px 4px; |
| | | opacity: 0.7; |
| | | } |
| | | |
| | | .liste { |
| | | border: 1px solid var(--md-sys-color-outline-variant, #cac4d0); |
| | | border-radius: 8px; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | /* |
| | | * Die Trennlinie sitzt auf der Gruppe (Tool + seine Zusatzinhalte), nicht auf der |
| | | * einzelnen Zeile â so wirken zusammengehörige Einträge als ein Block. |
| | | */ |
| | | .liste > bibliothek-zeile { |
| | | display: block; |
| | | border-bottom: 1px solid var(--md-sys-color-outline-variant, #e7e0ec); |
| | | } |
| | | |
| | | .liste > bibliothek-zeile:last-child { |
| | | border-bottom: none; |
| | | } |
| | | |
| | | .zeile { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 16px; |
| | | padding: 8px 16px; |
| | | } |
| | | |
| | | .zeile-auswahl { |
| | | display: flex; |
| | | align-items: center; |
| | | flex: 0 0 28px; |
| | | } |
| | | |
| | | /* Bei knapper Breite lieber abschneiden als umbrechen â sonst franst die Liste aus. */ |
| | | .zeile-info { |
| | | display: flex; |
| | | flex-direction: column; |
| | | min-width: 0; |
| | | flex: 1 1 auto; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .zeile-info > * { |
| | | white-space: nowrap; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | } |
| | | |
| | | .warnung { |
| | | padding: 12px 16px; |
| | | margin-bottom: 16px; |
| | | border-radius: 8px; |
| | | border: 1px solid var(--md-sys-color-error, #b00020); |
| | | background: var(--md-sys-color-error-container, rgba(176, 0, 32, 0.12)); |
| | | color: var(--md-sys-color-on-error-container, #410002); |
| | | } |
| | | |
| | | /* |
| | | * Schwebt über dem Inhalt statt ihn zu verschieben: Beim Anhaken soll die Liste |
| | | * an Ort und Stelle bleiben. Bleibt beim Blättern sichtbar â bei langen Listen |
| | | * ist die Auswahl sonst aus dem Blick. |
| | | * |
| | | * Bewusst eine gewöhnliche erhobene Fläche statt der umgekehrten Farbe: Auf |
| | | * einer umgekehrten Fläche greift keine der Button-Gestaltungen des Themes, |
| | | * die Schaltflächen wären dann nicht mehr als solche zu erkennen. |
| | | */ |
| | | .auswahl-leiste { |
| | | position: fixed; |
| | | bottom: 24px; |
| | | left: 50%; |
| | | transform: translateX(-50%); |
| | | z-index: 10; |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 12px; |
| | | padding: 8px; |
| | | border-radius: var(--md-sys-shape-corner-large, 16px); |
| | | border: 1px solid var(--md-sys-color-outline-variant, #cac4d0); |
| | | background: var(--md-sys-color-surface-container-high); |
| | | color: var(--md-sys-color-on-surface); |
| | | box-shadow: |
| | | 0 3px 5px -1px rgba(0, 0, 0, 0.2), |
| | | 0 6px 10px 0 rgba(0, 0, 0, 0.14), |
| | | 0 1px 18px 0 rgba(0, 0, 0, 0.12); |
| | | max-width: calc(100vw - 32px); |
| | | } |
| | | |
| | | .auswahl-anzahl { |
| | | font-weight: 500; |
| | | } |
| | | |
| | | .zeile-name { |
| | | font-weight: 600; |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .lizenz-marke, |
| | | .intern-marke { |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .zeile-meta { |
| | | font-size: 0.85em; |
| | | opacity: 0.7; |
| | | } |
| | | |
| | | /* Handhabungs-Hinweise stehen unter den Metadaten, etwas zurückgenommen. */ |
| | | .zeile-hinweis { |
| | | font-size: 0.85em; |
| | | color: var(--md-sys-color-on-surface-variant, #49454f); |
| | | font-style: italic; |
| | | } |
| | | |
| | | /* |
| | | * Aktionen in festen Spalten, damit gleichartige Schaltflächen über alle Zeilen |
| | | * hinweg exakt untereinander stehen. Fehlt eine Aktion, bleibt ihre Spalte leer. |
| | | * Reihenfolge: Plug-ins | Anleitung | Herunterladen [| Prüfen | Bearbeiten | Löschen] |
| | | */ |
| | | .zeile-aktionen { |
| | | display: grid; |
| | | grid-template-columns: 84px 48px; |
| | | gap: 8px; |
| | | align-items: center; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .liste.verwaltung .zeile-aktionen { |
| | | grid-template-columns: 84px 48px 84px 108px 88px; |
| | | } |
| | | |
| | | .aktion { |
| | | display: flex; |
| | | justify-content: center; |
| | | align-items: center; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .aktion dh-button { |
| | | max-width: 100%; |
| | | } |
| | | |
| | | /* --- Zusatzinhalte: eingerückt, ohne eigene Trennlinie --- */ |
| | | |
| | | .zeile-eingerueckt { |
| | | padding-left: 44px; |
| | | padding-top: 4px; |
| | | padding-bottom: 4px; |
| | | } |
| | | |
| | | .intern-marke { |
| | | font-size: 0.72em; |
| | | font-weight: 500; |
| | | text-transform: uppercase; |
| | | letter-spacing: 0.04em; |
| | | padding: 2px 8px; |
| | | border-radius: 999px; |
| | | background: var(--md-sys-color-primary-container, #ffe4cc); |
| | | color: var(--md-sys-color-on-primary-container, #4a1e00); |
| | | } |
| | | |
| | | .lizenz-marke { |
| | | font-size: 0.72em; |
| | | font-weight: 500; |
| | | text-transform: uppercase; |
| | | letter-spacing: 0.04em; |
| | | padding: 2px 8px; |
| | | border-radius: 999px; |
| | | background: var(--md-sys-color-tertiary-container, #ffd9a0); |
| | | color: var(--md-sys-color-on-tertiary-container, #2b1700); |
| | | } |
| | | |
| | | .status-ok { |
| | | color: var(--md-sys-color-primary, #ff8014); |
| | | } |
| | | |
| | | .status-fehler { |
| | | color: var(--md-sys-color-error, #b00020); |
| | | } |
| | | |
| | | .status-ausstehend { |
| | | font-style: italic; |
| | | } |
| | | |
| | | .signatur-gut { |
| | | color: var(--md-sys-color-primary, #ff8014); |
| | | } |
| | | |
| | | .signatur-unklar { |
| | | color: var(--md-sys-color-error, #b00020); |
| | | } |
| | | |
| | | .warnhinweis { |
| | | color: var(--md-sys-color-error, #b00020); |
| | | opacity: 1; |
| | | } |
| | | |
| | | .anleitung { |
| | | color: var(--md-sys-color-primary, #ff8014); |
| | | font-size: 0.9em; |
| | | text-decoration: none; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .anleitung:hover { |
| | | text-decoration: underline; |
| | | } |
| | | |
| | | /* --- Formular --- */ |
| | | |
| | | dh-card { |
| | | display: block; |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | [slot='actions'] { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 8px; |
| | | align-items: center; |
| | | } |
| | | |
| | | .umzug { |
| | | margin-top: 24px; |
| | | padding-top: 16px; |
| | | border-top: 1px solid var(--md-sys-color-outline-variant, #cac4d0); |
| | | } |
| | | |
| | | .umzug h3 { |
| | | margin: 0 0 4px; |
| | | font-size: 1em; |
| | | } |
| | | |
| | | .umzug-aktionen { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 8px; |
| | | margin: 12px 0 8px; |
| | | } |
| | | |
| | | .einstellungen-infos { |
| | | margin: 20px 0 0; |
| | | display: grid; |
| | | grid-template-columns: max-content 1fr; |
| | | gap: 4px 16px; |
| | | font-size: 0.9em; |
| | | } |
| | | |
| | | .einstellungen-infos dt { |
| | | opacity: 0.7; |
| | | } |
| | | |
| | | .einstellungen-infos dd { |
| | | margin: 0; |
| | | font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; |
| | | word-break: break-all; |
| | | } |
| | | |
| | | .feldhinweis { |
| | | display: block; |
| | | font-size: 0.8em; |
| | | opacity: 0.7; |
| | | margin-top: 4px; |
| | | } |
| | | |
| | | .fehler { |
| | | color: var(--md-sys-color-error, #b00020); |
| | | margin-right: auto; |
| | | } |
| Neue Datei |
| | |
| | | // CSS-Imports haben keine TypeScript-Typen. |
| | | // Mit `?raw` liefert webpack den Quelltext als String (für die Theme-Umschaltung). |
| | | declare module '*.css?raw' { |
| | | const quelltext: string; |
| | | export default quelltext; |
| | | } |
| | | |
| | | declare module '*.css'; |
| Neue Datei |
| | |
| | | <!DOCTYPE html> |
| | | <html lang="de"> |
| | | <head> |
| | | <meta charset="UTF-8" /> |
| | | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| | | <title>Software-Bibliothek</title> |
| | | </head> |
| | | <body> |
| | | <bibliothek-app></bibliothek-app> |
| | | </body> |
| | | </html> |
| Neue Datei |
| | |
| | | import 'construct-style-sheets-polyfill'; |
| | | |
| | | // Theme in fester Reihenfolge: Tokens -> Styles -> Komponenten. |
| | | // Die Farb-Tokens hängt initTheme() als umschaltbare <style>-Elemente ein. |
| | | import { initTheme } from './theme'; |
| | | import '@dh-software/dh-components-themes/styles.css'; |
| | | import '@dh-software/dh-components'; |
| | | |
| | | // eigenes globales Grundlayout (nutzt die Theme-Tokens) |
| | | import './global.css'; |
| | | |
| | | // dh-Icon-Font (furnview-icons) beim Start in den <head> injizieren |
| | | import { injectStylesheets } from '@dh-software/furnview-icons'; |
| | | injectStylesheets(true); |
| | | |
| | | initTheme(); |
| | | |
| | | // eigene Wurzel-Komponente |
| | | import './components/bibliothek-app'; |
| Neue Datei |
| | |
| | | /** |
| | | * Theme-Umschaltung (System/hell/dunkel) auf Basis der Design-System-Defines. |
| | | * Beide Defines werden als <style> eingehängt; je nach Modus ist genau eines |
| | | * aktiv (das andere per `disabled` abgeschaltet). âSystem" folgt der |
| | | * Windows-Einstellung (prefers-color-scheme). |
| | | * |
| | | * Gleiches Vorgehen wie im Projekt meeting-transkriptor, damit die Bedienung |
| | | * über unsere Anwendungen hinweg einheitlich bleibt. |
| | | */ |
| | | // Relativer Pfad (umgeht das exports-Feld des Pakets, das ?raw sonst blockt). |
| | | import hellCss from '../../../node_modules/@dh-software/dh-components-themes/build/defines.orange-light.css?raw'; |
| | | import dunkelCss from '../../../node_modules/@dh-software/dh-components-themes/build/defines.orange-dark.css?raw'; |
| | | |
| | | export type ThemeModus = 'system' | 'hell' | 'dunkel'; |
| | | |
| | | const SPEICHER_SCHLUESSEL = 'software-bibliothek-theme'; |
| | | |
| | | let hellStil: HTMLStyleElement; |
| | | let dunkelStil: HTMLStyleElement; |
| | | let aktuellerModus: ThemeModus = 'system'; |
| | | const dunkelMedia = window.matchMedia('(prefers-color-scheme: dark)'); |
| | | |
| | | function anwenden(): void { |
| | | const dunkel = aktuellerModus === 'dunkel' || (aktuellerModus === 'system' && dunkelMedia.matches); |
| | | hellStil.disabled = dunkel; |
| | | dunkelStil.disabled = !dunkel; |
| | | document.documentElement.style.colorScheme = dunkel ? 'dark' : 'light'; |
| | | document.documentElement.dataset.theme = dunkel ? 'dunkel' : 'hell'; |
| | | } |
| | | |
| | | /** Liest den zuletzt gewählten Modus (Standard: System). */ |
| | | export function gemerkterModus(): ThemeModus { |
| | | const gemerkt = localStorage.getItem(SPEICHER_SCHLUESSEL); |
| | | return gemerkt === 'hell' || gemerkt === 'dunkel' || gemerkt === 'system' ? gemerkt : 'system'; |
| | | } |
| | | |
| | | /** Hängt beide Theme-Defines ein und aktiviert den gemerkten Modus. */ |
| | | export function initTheme(): void { |
| | | hellStil = document.createElement('style'); |
| | | hellStil.textContent = hellCss; |
| | | dunkelStil = document.createElement('style'); |
| | | dunkelStil.textContent = dunkelCss; |
| | | document.head.append(hellStil, dunkelStil); |
| | | dunkelMedia.addEventListener('change', () => { |
| | | if (aktuellerModus === 'system') { |
| | | anwenden(); |
| | | } |
| | | }); |
| | | aktuellerModus = gemerkterModus(); |
| | | anwenden(); |
| | | } |
| | | |
| | | /** Setzt den Modus und merkt ihn für den nächsten Aufruf. */ |
| | | export function setzeTheme(modus: ThemeModus): void { |
| | | aktuellerModus = modus; |
| | | localStorage.setItem(SPEICHER_SCHLUESSEL, modus); |
| | | anwenden(); |
| | | } |
| Neue Datei |
| | |
| | | { |
| | | "compilerOptions": { |
| | | "target": "ES2020", |
| | | "lib": ["DOM", "DOM.Iterable", "ES2021"], |
| | | "module": "ESNext", |
| | | "moduleResolution": "bundler", |
| | | "experimentalDecorators": true, |
| | | "useDefineForClassFields": false, |
| | | "strict": true, |
| | | "esModuleInterop": true, |
| | | "skipLibCheck": true, |
| | | "forceConsistentCasingInFileNames": true |
| | | }, |
| | | "exclude": ["node_modules", "dist"] |
| | | } |
| Neue Datei |
| | |
| | | import path from 'path'; |
| | | import { fileURLToPath } from 'url'; |
| | | import HtmlWebpackPlugin from 'html-webpack-plugin'; |
| | | |
| | | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| | | const isProduction = process.env.NODE_ENV === 'production'; |
| | | |
| | | export default { |
| | | mode: isProduction ? 'production' : 'development', |
| | | entry: './src/index.ts', |
| | | devtool: isProduction ? false : 'inline-source-map', |
| | | experiments: { |
| | | topLevelAwait: true, |
| | | }, |
| | | module: { |
| | | rules: [ |
| | | { |
| | | test: /\.ts$/, |
| | | use: 'ts-loader', |
| | | exclude: /node_modules/, |
| | | }, |
| | | { |
| | | test: /\.css$/i, |
| | | resourceQuery: { not: [/raw/] }, |
| | | use: ['style-loader', 'css-loader'], |
| | | }, |
| | | { |
| | | // Import mit ?raw liefert den CSS-Quelltext als String (zum Umschalten |
| | | // der Theme-Defines zur Laufzeit). |
| | | resourceQuery: /raw/, |
| | | type: 'asset/source', |
| | | }, |
| | | { |
| | | test: /\.(woff|woff2|ttf|eot|svg|png|jpg|gif|ico)$/i, |
| | | type: 'asset', |
| | | }, |
| | | ], |
| | | }, |
| | | resolve: { |
| | | extensions: ['.ts', '.js'], |
| | | }, |
| | | plugins: [ |
| | | new HtmlWebpackPlugin({ |
| | | template: 'src/index.html', |
| | | }), |
| | | ], |
| | | devServer: { |
| | | host: '0.0.0.0', |
| | | port: 5000, |
| | | open: false, |
| | | proxy: [{ context: ['/api'], target: 'http://localhost:3000' }], |
| | | }, |
| | | output: { |
| | | path: path.resolve(__dirname, 'dist'), |
| | | filename: 'index.[contenthash].js', |
| | | clean: true, |
| | | }, |
| | | stats: { |
| | | errorDetails: true, |
| | | }, |
| | | }; |
| Neue Datei |
| | |
| | | { |
| | | "name": "bibliothek-server", |
| | | "version": "0.1.0", |
| | | "main": "build/index.js", |
| | | "scripts": { |
| | | "build": "tsc", |
| | | "bundle": "esbuild build/index.js --bundle --platform=node --target=node22 --format=cjs --outfile=build-exe/server.cjs --log-level=warning", |
| | | "start": "node --disable-warning=ExperimentalWarning build/index.js", |
| | | "dev": "ts-node src/index.ts" |
| | | }, |
| | | "dependencies": { |
| | | "bibliothek-types": "*", |
| | | "express": "^4.21.2" |
| | | }, |
| | | "devDependencies": { |
| | | "@types/express": "^4.17.21", |
| | | "@types/node": "^24.0.0", |
| | | "esbuild": "^0.25.0", |
| | | "ts-node": "^10.9.2" |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | import * as fs from 'fs'; |
| | | import * as path from 'path'; |
| | | import type { AbgleichErgebnis, Software } from 'bibliothek-types'; |
| | | import { DATEIEN_ORDNER, QUELLEN_ORDNER, abgleichSpeichern } from './datenbank'; |
| | | import { bezugsquelleFuer, type QuellenAbgleich } from './bezugsquellen'; |
| | | import { pruefeSignatur, weichtHerausgeberAb } from './signatur'; |
| | | import { alsUmgebung, ladeGeheimnisse } from './geheimnisse'; |
| | | import { gitSshBefehl } from './konfiguration'; |
| | | |
| | | /** GröÃe der aktuell bereitgestellten Datei â Grundlage der Plausibilitätsprüfung. */ |
| | | function bisherigeGroesse(software: Software): number { |
| | | if (!software.dateiname) { |
| | | return 0; |
| | | } |
| | | const pfad = path.join(DATEIEN_ORDNER, String(software.id), software.dateiname); |
| | | return fs.existsSync(pfad) ? fs.statSync(pfad).size : 0; |
| | | } |
| | | |
| | | /** Unveränderten Stand zurückschreiben (bei Misserfolg oder unbekannter Quelle). */ |
| | | function unveraendert(software: Software, meldung: string, version = software.version): AbgleichErgebnis { |
| | | return { |
| | | version, |
| | | dateiname: software.dateiname, |
| | | erfolg: false, |
| | | meldung, |
| | | geaendert: false, |
| | | signaturStatus: software.signaturStatus, |
| | | signaturHerausgeber: software.signaturHerausgeber, |
| | | veroeffentlichtAm: software.veroeffentlichtAm, |
| | | benoetigteGeheimnisse: software.benoetigteGeheimnisse, |
| | | }; |
| | | } |
| | | |
| | | /** Gleicht eine Software mit ihrer Bezugsquelle ab und speichert das Ergebnis zurück. */ |
| | | export async function gleicheSoftwareAb(software: Software): Promise<void> { |
| | | if (software.quelleTyp === 'manuell') { |
| | | return; // Datei wird von Hand hinterlegt â es gibt nichts abzugleichen |
| | | } |
| | | if (!software.quelleTyp || !software.quelleWert) { |
| | | return; // keine Quelle gesetzt â nichts zu holen |
| | | } |
| | | const quelle = bezugsquelleFuer(software.quelleTyp); |
| | | if (!quelle) { |
| | | abgleichSpeichern(software.id, unveraendert(software, `Unbekannte Bezugsquelle: ${software.quelleTyp}`)); |
| | | return; |
| | | } |
| | | |
| | | const zielordner = path.join(DATEIEN_ORDNER, String(software.id)); |
| | | const ergebnis = await quelle.gleicheAb({ |
| | | wert: software.quelleWert, |
| | | aktuelleVersion: software.version, |
| | | zielordner, |
| | | arbeitsordner: path.join(QUELLEN_ORDNER, String(software.id)), |
| | | bauBefehl: software.bauBefehl, |
| | | bauErgebnis: software.bauErgebnis, |
| | | vorherigeGroesse: bisherigeGroesse(software), |
| | | geheimnisse: alsUmgebung(ladeGeheimnisse(software.id)), |
| | | gitSshBefehl: gitSshBefehl(), |
| | | }); |
| | | |
| | | if (!ergebnis.erfolg) { |
| | | abgleichSpeichern(software.id, unveraendert(software, ergebnis.meldung, ergebnis.version)); |
| | | return; |
| | | } |
| | | |
| | | abgleichSpeichern(software.id, await mitSignaturbefund(software, ergebnis, zielordner)); |
| | | } |
| | | |
| | | /** |
| | | * Ergänzt das Abgleich-Ergebnis um die Windows-Signatur der bereitgestellten Datei. |
| | | * Ist ein Signaturgeber erwartet und weicht der gefundene ab, wird die Datei nicht |
| | | * zum Download freigegeben (`dateiname` bleibt leer) â sie bleibt aber zur Ansicht liegen. |
| | | */ |
| | | async function mitSignaturbefund(software: Software, ergebnis: QuellenAbgleich, zielordner: string): Promise<AbgleichErgebnis> { |
| | | const befund = await pruefeSignatur(path.join(zielordner, ergebnis.dateiname)); |
| | | const gemeinsam = { |
| | | version: ergebnis.version, |
| | | signaturStatus: befund.status, |
| | | signaturHerausgeber: befund.herausgeber, |
| | | veroeffentlichtAm: ergebnis.veroeffentlichtAm ?? software.veroeffentlichtAm, |
| | | benoetigteGeheimnisse: ergebnis.benoetigteGeheimnisse ?? software.benoetigteGeheimnisse, |
| | | }; |
| | | if (weichtHerausgeberAb(software.erwarteterHerausgeber, befund.herausgeber)) { |
| | | return { |
| | | ...gemeinsam, |
| | | dateiname: '', // nicht ausliefern, bis das geklärt ist |
| | | erfolg: false, |
| | | meldung: `Signatur weicht ab: erwartet â${software.erwarteterHerausgeber}", gefunden â${befund.herausgeber || 'unsigniert'}" (${befund.status}). Die Datei wird nicht zum Download angeboten.`, |
| | | geaendert: false, |
| | | }; |
| | | } |
| | | return { |
| | | ...gemeinsam, |
| | | dateiname: ergebnis.dateiname, |
| | | erfolg: true, |
| | | meldung: ergebnis.meldung, |
| | | geaendert: ergebnis.geaendert, |
| | | }; |
| | | } |
| | | |
| | | /** Gleicht nacheinander alle Einträge mit gesetzter Quelle ab (täglicher Auto-Check). */ |
| | | export async function gleicheAlleAb(alle: Software[]): Promise<void> { |
| | | for (const software of alle) { |
| | | if (software.quelleTyp && software.quelleWert) { |
| | | await gleicheSoftwareAb(software); |
| | | } |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | import * as fs from 'fs'; |
| | | import * as path from 'path'; |
| | | |
| | | /** Alles, was eine Bezugsquelle für einen Abgleich braucht. */ |
| | | export interface Abgleichauftrag { |
| | | /** Quell-spezifische Angabe: URL, `besitzer/repo`, Paket-ID, Repo-URL ⦠*/ |
| | | wert: string; |
| | | /** Bisher bekannte Versions-/Stand-Kennung. */ |
| | | aktuelleVersion: string; |
| | | /** Ordner, in dem die bereitgestellte Datei liegen soll. */ |
| | | zielordner: string; |
| | | /** Arbeitskopie für Quellen, die etwas auschecken/bauen müssen. */ |
| | | arbeitsordner: string; |
| | | /** Befehl zum Bauen (nur für die Git-Quelle). */ |
| | | bauBefehl: string; |
| | | /** Pfad des Bau-Ergebnisses relativ zur Arbeitskopie (nur für die Git-Quelle). */ |
| | | bauErgebnis: string; |
| | | /** GröÃe der bisher bereitgestellten Datei â für die Plausibilitätsprüfung. */ |
| | | vorherigeGroesse: number; |
| | | /** Hinterlegte Bau-Geheimnisse, die dem Bau als Umgebungsvariablen mitgegeben werden. */ |
| | | geheimnisse: Record<string, string>; |
| | | /** SSH-Aufruf für Git-Zugriffe aus den Einstellungen (leer = SSH-Konfiguration entscheidet). */ |
| | | gitSshBefehl: string; |
| | | } |
| | | |
| | | /** Ein Geheimnis, das ein Repository für seinen Bau anfordert. */ |
| | | export interface GeheimnisBedarf { |
| | | name: string; |
| | | beschreibung: string; |
| | | } |
| | | |
| | | /** Ergebnis eines Quellen-Abgleichs. */ |
| | | export interface QuellenAbgleich { |
| | | erfolg: boolean; |
| | | /** Wurde eine neue Datei geholt (true) oder war schon alles aktuell (false)? */ |
| | | geaendert: boolean; |
| | | /** Neue Versions-/Stand-Kennung. */ |
| | | version: string; |
| | | /** Name der aktuell vorliegenden Datei. */ |
| | | dateiname: string; |
| | | meldung: string; |
| | | /** Veröffentlichungsdatum beim Anbieter (ISO), falls die Quelle es kennt. */ |
| | | veroeffentlichtAm?: string; |
| | | /** Vom Repository angeforderte Bau-Geheimnisse (nur die Git-Quelle liefert das). */ |
| | | benoetigteGeheimnisse?: GeheimnisBedarf[]; |
| | | } |
| | | |
| | | /** |
| | | * Eine Bezugsquelle prüft ihre Quelle und stellt bei Bedarf die aktuelle Datei bereit. |
| | | * Jede neue Quellenart implementiert nur dieses Interface. |
| | | */ |
| | | export interface Bezugsquelle { |
| | | gleicheAb(auftrag: Abgleichauftrag): Promise<QuellenAbgleich>; |
| | | } |
| | | |
| | | export function abgleichMisserfolg(version: string, meldung: string): QuellenAbgleich { |
| | | return { erfolg: false, geaendert: false, version, dateiname: '', meldung }; |
| | | } |
| | | |
| | | /** Wandelt eine Datumsangabe der Quelle in ISO um (leer, wenn unbrauchbar). */ |
| | | export function alsIsoDatum(rohwert: string | null | undefined): string { |
| | | if (!rohwert) { |
| | | return ''; |
| | | } |
| | | const zeitpunkt = new Date(rohwert); |
| | | return Number.isNaN(zeitpunkt.getTime()) ? '' : zeitpunkt.toISOString(); |
| | | } |
| | | |
| | | // --- Plausibilitätsprüfung --- |
| | | |
| | | /** Unterhalb dieser GröÃe gilt eine Datei als verdächtig, wenn sie zudem stark geschrumpft ist. */ |
| | | const VERDAECHTIG_KLEIN_BYTES = 100 * 1024; |
| | | |
| | | /** |
| | | * Prüft eine frisch geholte Datei auf typische Anzeichen eines toten oder |
| | | * übernommenen Links (Fehler-/Anmeldeseite statt Datei, leere oder plötzlich |
| | | * winzige Datei). Liefert einen Beanstandungstext oder `undefined`, wenn alles passt. |
| | | */ |
| | | export function inhaltBeanstanden(inhalt: Buffer, dateiname: string, inhaltstyp: string | null, vorherigeGroesse: number): string | undefined { |
| | | if (inhalt.length === 0) { |
| | | return 'Die Quelle hat eine leere Datei geliefert.'; |
| | | } |
| | | |
| | | const istWebseiteErwartet = /\.(html?|htm)$/i.test(dateiname); |
| | | if (!istWebseiteErwartet) { |
| | | const anfang = inhalt.subarray(0, 200).toString('latin1').trimStart().toLowerCase(); |
| | | if (anfang.startsWith('<!doctype html') || anfang.startsWith('<html')) { |
| | | return 'Die Quelle hat eine Webseite statt der Datei geliefert â der Link ist vermutlich veraltet oder verlangt eine Anmeldung.'; |
| | | } |
| | | if (inhaltstyp && inhaltstyp.toLowerCase().includes('text/html')) { |
| | | return `Die Quelle hat eine Webseite geliefert (${inhaltstyp}) â der Link ist vermutlich veraltet.`; |
| | | } |
| | | } |
| | | |
| | | // Ein plötzlicher Einbruch auf eine winzige Datei deutet auf eine Fehlerseite hin. |
| | | if (vorherigeGroesse > 0 && inhalt.length < vorherigeGroesse / 2 && inhalt.length < VERDAECHTIG_KLEIN_BYTES) { |
| | | return `Die geholte Datei ist auffällig klein (${inhalt.length} statt bisher ${vorherigeGroesse} Bytes) â bitte die Quelle prüfen.`; |
| | | } |
| | | |
| | | return undefined; |
| | | } |
| | | |
| | | // --- Dateiablage: erst vollständig vorbereiten, dann ersetzen --- |
| | | |
| | | function temporaerenOrdnerAnlegen(zielordner: string): string { |
| | | const temporaer = `${zielordner}.neu`; |
| | | fs.rmSync(temporaer, { recursive: true, force: true }); |
| | | fs.mkdirSync(temporaer, { recursive: true }); |
| | | return temporaer; |
| | | } |
| | | |
| | | /** |
| | | * Ersetzt den Inhalt des Zielordners durch den des temporären Ordners. |
| | | * Der alte Stand wird erst angefasst, wenn der neue vollständig und geprüft ist â |
| | | * ein fehlgeschlagener Abgleich kann die funktionierende Datei damit nicht zerstören. |
| | | */ |
| | | function uebernehmeOrdner(temporaer: string, zielordner: string): void { |
| | | fs.rmSync(zielordner, { recursive: true, force: true }); |
| | | fs.renameSync(temporaer, zielordner); |
| | | } |
| | | |
| | | export function verwerfeOrdner(temporaer: string): void { |
| | | fs.rmSync(temporaer, { recursive: true, force: true }); |
| | | } |
| | | |
| | | /** Legt eine Datei als neuen, alleinigen Inhalt des Zielordners ab (alter Stand bleibt bis zuletzt erhalten). */ |
| | | export function ersetzeDateiSicher(zielordner: string, dateiname: string, inhalt: Buffer): void { |
| | | const temporaer = temporaerenOrdnerAnlegen(zielordner); |
| | | fs.writeFileSync(path.join(temporaer, dateiname), inhalt); |
| | | uebernehmeOrdner(temporaer, zielordner); |
| | | } |
| | | |
| | | /** |
| | | * Für Quellen, die über ein Kommandozeilen-Werkzeug in einen Ordner schreiben: |
| | | * liefert einen leeren temporären Ordner und übernimmt ihn erst nach erfolgreicher Prüfung. |
| | | */ |
| | | export function mitTemporaeremOrdner(zielordner: string): { ordner: string; uebernehmen: () => void; verwerfen: () => void } { |
| | | const temporaer = temporaerenOrdnerAnlegen(zielordner); |
| | | return { |
| | | ordner: temporaer, |
| | | uebernehmen: () => uebernehmeOrdner(temporaer, zielordner), |
| | | verwerfen: () => verwerfeOrdner(temporaer), |
| | | }; |
| | | } |
| | | |
| | | /** Prüft, ob im Zielordner bereits eine bestimmte Datei liegt. */ |
| | | export function dateiVorhanden(zielordner: string, dateiname: string): boolean { |
| | | return !!dateiname && fs.existsSync(path.join(zielordner, dateiname)); |
| | | } |
| | | |
| | | /** Leitet einen Dateinamen aus einer URL ab (letztes Pfadsegment ohne Query). */ |
| | | export function dateinameAusUrl(url: string): string { |
| | | try { |
| | | const segment = decodeURIComponent(new URL(url).pathname.split('/').pop() ?? ''); |
| | | return segment || 'download.bin'; |
| | | } catch { |
| | | return 'download.bin'; |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | import { |
| | | abgleichMisserfolg, |
| | | alsIsoDatum, |
| | | dateinameAusUrl, |
| | | dateiVorhanden, |
| | | ersetzeDateiSicher, |
| | | inhaltBeanstanden, |
| | | type Abgleichauftrag, |
| | | type Bezugsquelle, |
| | | type QuellenAbgleich, |
| | | } from './basis'; |
| | | |
| | | /** Direkter Download-Link: lädt die Datei, erkennt Ãnderungen über ETag/Last-Modified. */ |
| | | export class DirektLinkQuelle implements Bezugsquelle { |
| | | async gleicheAb(auftrag: Abgleichauftrag): Promise<QuellenAbgleich> { |
| | | try { |
| | | const antwort = await fetch(auftrag.wert, { redirect: 'follow' }); |
| | | if (!antwort.ok) { |
| | | return abgleichMisserfolg(auftrag.aktuelleVersion, `Server nicht erreichbar (HTTP ${antwort.status}).`); |
| | | } |
| | | const kennung = antwort.headers.get('etag') ?? antwort.headers.get('last-modified') ?? ''; |
| | | const veroeffentlichtAm = alsIsoDatum(antwort.headers.get('last-modified')); |
| | | const dateiname = dateinameAusUrl(auftrag.wert); |
| | | if (kennung && kennung === auftrag.aktuelleVersion && dateiVorhanden(auftrag.zielordner, dateiname)) { |
| | | return { erfolg: true, geaendert: false, version: kennung, dateiname, meldung: 'Bereits aktuell.', veroeffentlichtAm }; |
| | | } |
| | | |
| | | const inhalt = Buffer.from(await antwort.arrayBuffer()); |
| | | const beanstandung = inhaltBeanstanden(inhalt, dateiname, antwort.headers.get('content-type'), auftrag.vorherigeGroesse); |
| | | if (beanstandung) { |
| | | return abgleichMisserfolg(auftrag.aktuelleVersion, beanstandung); |
| | | } |
| | | |
| | | ersetzeDateiSicher(auftrag.zielordner, dateiname, inhalt); |
| | | return { erfolg: true, geaendert: true, version: kennung || new Date().toISOString(), dateiname, meldung: 'Aktualisiert.', veroeffentlichtAm }; |
| | | } catch (fehler) { |
| | | return abgleichMisserfolg(auftrag.aktuelleVersion, `Download fehlgeschlagen: ${fehler instanceof Error ? fehler.message : String(fehler)}`); |
| | | } |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | import { exec, execFile } from 'child_process'; |
| | | import { promisify } from 'util'; |
| | | import * as fs from 'fs'; |
| | | import * as path from 'path'; |
| | | import { |
| | | abgleichMisserfolg, |
| | | alsIsoDatum, |
| | | dateiVorhanden, |
| | | ersetzeDateiSicher, |
| | | inhaltBeanstanden, |
| | | type Abgleichauftrag, |
| | | type Bezugsquelle, |
| | | type GeheimnisBedarf, |
| | | type QuellenAbgleich, |
| | | } from './basis'; |
| | | |
| | | const execAsync = promisify(exec); |
| | | const execFileAsync = promisify(execFile); |
| | | |
| | | /** Ein Bau darf lange dauern (z. B. PyInstaller mit vielen Abhängigkeiten). */ |
| | | const BAU_ZEITGRENZE_MS = 30 * 60 * 1000; |
| | | const GIT_ZEITGRENZE_MS = 10 * 60 * 1000; |
| | | |
| | | /** Name der Bauanleitung, die ein Repository selbst mitbringt. */ |
| | | export const BAUANLEITUNG_DATEI = 'software-bibliothek.json'; |
| | | |
| | | interface Bauanleitung { |
| | | bauBefehl: string; |
| | | ergebnis: string; |
| | | geheimnisse: GeheimnisBedarf[]; |
| | | } |
| | | |
| | | /** |
| | | * Aufbau der Anleitung, wie sie im Repository steht. Die Feldnamen sind englisch, |
| | | * weil sie ein Vertrag nach auÃen sind â jedes anzubindende Tool schreibt sie. |
| | | * Beschrieben im Skill âbuild-secrets" des Repos dh-claude-rules. |
| | | */ |
| | | interface AnleitungImRepo { |
| | | buildCommand?: string; |
| | | artifact?: string; |
| | | secrets?: Array<{ name?: string; description?: string }>; |
| | | } |
| | | |
| | | /** |
| | | * Interne Tools aus einem Git-Repository: Arbeitskopie holen bzw. aktualisieren, |
| | | * bauen und das erzeugte Programm bereitstellen. |
| | | * |
| | | * Angabe der Quelle: nur die Repo-URL, optional mit `#zweig` (z. B. `ssh://â¦/tool.git#main`). |
| | | * Wie gebaut wird, beschreibt das Repository selbst in `software-bibliothek.json`: |
| | | * |
| | | * { "buildCommand": "powershell -ExecutionPolicy Bypass -File build_exe.ps1", |
| | | * "artifact": "dist/MeinTool.exe" } |
| | | * |
| | | * So bleibt die Bauanleitung dort, wo sie hingehört â beim Tool. In der Bibliothek |
| | | * lassen sich beide Angaben bei Bedarf überschreiben (für fremde Repos, die man |
| | | * nicht ändern kann). |
| | | * |
| | | * Die Arbeitskopie legt die Anwendung selbst an (`<Datenordner>\quellen\<id>`) â es |
| | | * wird nichts vorausgesetzt, was auf dem jeweiligen Rechner schon vorhanden sein müsste. |
| | | * |
| | | * Hinweis zur Sicherheit: Der Bau-Befehl wird auf dem Server mit den Rechten des |
| | | * Dienstbenutzers ausgeführt. Pflegen kann ihn nur, wer lokal am Server arbeitet |
| | | * bzw. wer ins Repository schreiben darf. |
| | | */ |
| | | export class GitBuildQuelle implements Bezugsquelle { |
| | | async gleicheAb(auftrag: Abgleichauftrag): Promise<QuellenAbgleich> { |
| | | const [repoUrl, gewuenschterZweig] = auftrag.wert.split('#'); |
| | | let commit = ''; |
| | | try { |
| | | commit = await arbeitskopieAktualisieren(repoUrl.trim(), gewuenschterZweig?.trim() ?? '', auftrag.arbeitsordner, auftrag.gitSshBefehl); |
| | | } catch (fehler) { |
| | | return abgleichMisserfolg(auftrag.aktuelleVersion, gitFehlertext(fehler)); |
| | | } |
| | | |
| | | const veroeffentlichtAm = await commitZeitpunkt(auftrag.arbeitsordner); |
| | | const anleitung = leseBauanleitung(auftrag); |
| | | if (!anleitung.bauBefehl || !anleitung.ergebnis) { |
| | | return abgleichMisserfolg( |
| | | auftrag.aktuelleVersion, |
| | | `Im Repository fehlt die Datei ${BAUANLEITUNG_DATEI} mit âbuildCommand" und âartifact" â alternativ beides in der Bibliothek hinterlegen.` |
| | | ); |
| | | } |
| | | |
| | | // Nur bauen, wenn sich seit dem letzten Mal etwas geändert hat. |
| | | const bekannterDateiname = ergebnisDateiname(anleitung.ergebnis); |
| | | if (commit === auftrag.aktuelleVersion && dateiVorhanden(auftrag.zielordner, bekannterDateiname)) { |
| | | return { erfolg: true, geaendert: false, version: commit, dateiname: bekannterDateiname, meldung: 'Bereits aktuell (unveränderter Stand).', veroeffentlichtAm, benoetigteGeheimnisse: anleitung.geheimnisse }; |
| | | } |
| | | |
| | | const fehlende = anleitung.geheimnisse.filter((bedarf) => !auftrag.geheimnisse[bedarf.name]).map((bedarf) => bedarf.name); |
| | | try { |
| | | // Geheimnisse gehen als Umgebungsvariablen in den Bau â das Tool entscheidet selbst, |
| | | // was es daraus macht. Sie landen dadurch nirgends auf der Platte. |
| | | await execAsync(anleitung.bauBefehl, { |
| | | cwd: auftrag.arbeitsordner, |
| | | timeout: BAU_ZEITGRENZE_MS, |
| | | windowsHide: true, |
| | | maxBuffer: 32 * 1024 * 1024, |
| | | env: { ...process.env, ...auftrag.geheimnisse }, |
| | | }); |
| | | } catch (fehler) { |
| | | const hinweis = fehlende.length > 0 ? ` Nicht hinterlegte Schlüssel: ${fehlende.join(', ')}.` : ''; |
| | | return abgleichMisserfolg(auftrag.aktuelleVersion, `Bau fehlgeschlagen: ${kurzeFehlermeldung(fehler)}${hinweis}`); |
| | | } |
| | | |
| | | const ergebnisPfad = findeErgebnis(auftrag.arbeitsordner, anleitung.ergebnis); |
| | | if (!ergebnisPfad) { |
| | | return abgleichMisserfolg(auftrag.aktuelleVersion, `Der Bau lief durch, aber unter â${anleitung.ergebnis}" liegt keine Datei.`); |
| | | } |
| | | |
| | | const dateiname = path.basename(ergebnisPfad); |
| | | const inhalt = fs.readFileSync(ergebnisPfad); |
| | | const beanstandung = inhaltBeanstanden(inhalt, dateiname, null, auftrag.vorherigeGroesse); |
| | | if (beanstandung) { |
| | | return abgleichMisserfolg(auftrag.aktuelleVersion, beanstandung); |
| | | } |
| | | |
| | | ersetzeDateiSicher(auftrag.zielordner, dateiname, inhalt); |
| | | return { |
| | | erfolg: true, |
| | | geaendert: true, |
| | | version: commit, |
| | | dateiname, |
| | | meldung: `Aus Git gebaut (Stand ${commit.slice(0, 8)}).`, |
| | | veroeffentlichtAm, |
| | | benoetigteGeheimnisse: anleitung.geheimnisse, |
| | | }; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Bauanleitung ermitteln: bevorzugt aus dem Repository selbst, ersatzweise aus den |
| | | * in der Bibliothek hinterlegten Angaben (die dort gepflegten haben Vorrang, damit |
| | | * sich fremde Repos übersteuern lassen). |
| | | */ |
| | | function leseBauanleitung(auftrag: Abgleichauftrag): Bauanleitung { |
| | | let ausRepo: AnleitungImRepo = {}; |
| | | const anleitungsPfad = path.join(auftrag.arbeitsordner, BAUANLEITUNG_DATEI); |
| | | if (fs.existsSync(anleitungsPfad)) { |
| | | try { |
| | | ausRepo = JSON.parse(fs.readFileSync(anleitungsPfad, 'utf8')) as AnleitungImRepo; |
| | | } catch { |
| | | // Unlesbare Anleitung wie ânicht vorhanden" behandeln â die Angaben aus der |
| | | // Bibliothek können den Bau trotzdem ermöglichen. |
| | | } |
| | | } |
| | | const angeforderte = Array.isArray(ausRepo.secrets) ? ausRepo.secrets : []; |
| | | return { |
| | | bauBefehl: auftrag.bauBefehl.trim() || (ausRepo.buildCommand ?? '').trim(), |
| | | ergebnis: auftrag.bauErgebnis.trim() || (ausRepo.artifact ?? '').trim(), |
| | | geheimnisse: angeforderte |
| | | .filter((bedarf): bedarf is { name: string; description?: string } => typeof bedarf?.name === 'string' && !!bedarf.name.trim()) |
| | | .map((bedarf) => ({ name: bedarf.name.trim(), beschreibung: String(bedarf.description ?? '') })), |
| | | }; |
| | | } |
| | | |
| | | /** Klont die Arbeitskopie oder bringt sie auf den neuesten Stand. Liefert den Commit. */ |
| | | async function arbeitskopieAktualisieren(repoUrl: string, gewuenschterZweig: string, arbeitsordner: string, sshBefehl: string): Promise<string> { |
| | | // Die Kennung kommt aus den Einstellungen, nicht aus der URL â so bleibt der |
| | | // Katalogeintrag frei von Konto-Angaben und ein Kontowechsel ist eine Ãnderung. |
| | | const umgebung = sshBefehl ? { ...process.env, GIT_SSH_COMMAND: sshBefehl } : process.env; |
| | | const git = (argumente: string[], cwd?: string) => |
| | | execFileAsync('git', argumente, { cwd, timeout: GIT_ZEITGRENZE_MS, windowsHide: true, maxBuffer: 16 * 1024 * 1024, env: umgebung }); |
| | | |
| | | if (!fs.existsSync(path.join(arbeitsordner, '.git'))) { |
| | | fs.rmSync(arbeitsordner, { recursive: true, force: true }); |
| | | fs.mkdirSync(path.dirname(arbeitsordner), { recursive: true }); |
| | | await git(['clone', repoUrl, arbeitsordner]); |
| | | } else { |
| | | await git(['remote', 'set-url', 'origin', repoUrl], arbeitsordner); |
| | | await git(['fetch', '--prune', 'origin'], arbeitsordner); |
| | | } |
| | | |
| | | const zweig = gewuenschterZweig || (await ermittleStandardZweig(arbeitsordner)); |
| | | // Harter Abgleich auf den Fernstand â lokale Reste aus einem früheren Bau stören sonst. |
| | | await git(['checkout', '-B', zweig, `origin/${zweig}`], arbeitsordner); |
| | | await git(['reset', '--hard', `origin/${zweig}`], arbeitsordner); |
| | | await git(['clean', '-ffdx', '-e', 'node_modules', '-e', '.venv'], arbeitsordner); |
| | | |
| | | const { stdout } = await git(['rev-parse', 'HEAD'], arbeitsordner); |
| | | return stdout.trim(); |
| | | } |
| | | |
| | | /** Zeitpunkt des ausgecheckten Commits â das ist der âStand" des internen Tools. */ |
| | | async function commitZeitpunkt(arbeitsordner: string): Promise<string> { |
| | | try { |
| | | const { stdout } = await execFileAsync('git', ['log', '-1', '--format=%cI'], { |
| | | cwd: arbeitsordner, |
| | | timeout: 30000, |
| | | windowsHide: true, |
| | | }); |
| | | return alsIsoDatum(stdout.trim()); |
| | | } catch { |
| | | return ''; |
| | | } |
| | | } |
| | | |
| | | async function ermittleStandardZweig(arbeitsordner: string): Promise<string> { |
| | | try { |
| | | const { stdout } = await execFileAsync('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { |
| | | cwd: arbeitsordner, |
| | | timeout: 30000, |
| | | windowsHide: true, |
| | | }); |
| | | return stdout.trim().replace(/^origin\//, '') || 'main'; |
| | | } catch { |
| | | return 'main'; // Repos ohne gesetzten HEAD-Zeiger |
| | | } |
| | | } |
| | | |
| | | /** Löst den Ergebnis-Pfad auf; im Dateinamen ist ein `*` erlaubt. */ |
| | | function findeErgebnis(arbeitsordner: string, muster: string): string | undefined { |
| | | const vollstaendig = path.resolve(arbeitsordner, muster); |
| | | if (!muster.includes('*')) { |
| | | return fs.existsSync(vollstaendig) ? vollstaendig : undefined; |
| | | } |
| | | const ordner = path.dirname(vollstaendig); |
| | | if (!fs.existsSync(ordner)) { |
| | | return undefined; |
| | | } |
| | | const regel = new RegExp(`^${path.basename(vollstaendig).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, 'i'); |
| | | const treffer = fs.readdirSync(ordner).filter((name) => regel.test(name)); |
| | | return treffer.length > 0 ? path.join(ordner, treffer[0]) : undefined; |
| | | } |
| | | |
| | | function ergebnisDateiname(muster: string): string { |
| | | const name = path.basename(muster); |
| | | return name.includes('*') ? '' : name; |
| | | } |
| | | |
| | | function gitFehlertext(fehler: unknown): string { |
| | | const text = kurzeFehlermeldung(fehler); |
| | | if (/authenticat|permission denied|could not read|host key|publickey/i.test(text)) { |
| | | return `Git-Anmeldung fehlgeschlagen. Der Dienstbenutzer braucht Zugriff auf das Repository (SSH-Schlüssel bzw. einmalige Anmeldung im Windows-Anmeldespeicher). Ursprüngliche Meldung: ${text}`; |
| | | } |
| | | return `Git fehlgeschlagen: ${text}`; |
| | | } |
| | | |
| | | /** Kommandozeilen-Fehler enthalten oft seitenlange Ausgaben â auf das Wesentliche kürzen. */ |
| | | function kurzeFehlermeldung(fehler: unknown): string { |
| | | const roh = fehler instanceof Error ? ((fehler as Error & { stderr?: string }).stderr || fehler.message) : String(fehler); |
| | | const zeilen = roh.split('\n').map((zeile) => zeile.trim()).filter(Boolean); |
| | | return zeilen.slice(-4).join(' | ').slice(0, 500); |
| | | } |
| Neue Datei |
| | |
| | | import { |
| | | abgleichMisserfolg, |
| | | alsIsoDatum, |
| | | dateiVorhanden, |
| | | ersetzeDateiSicher, |
| | | inhaltBeanstanden, |
| | | type Abgleichauftrag, |
| | | type Bezugsquelle, |
| | | type QuellenAbgleich, |
| | | } from './basis'; |
| | | |
| | | interface GithubAsset { |
| | | name: string; |
| | | browser_download_url: string; |
| | | } |
| | | |
| | | interface GithubRelease { |
| | | tag_name: string; |
| | | /** Veröffentlichungszeitpunkt des Releases beim Anbieter. */ |
| | | published_at: string; |
| | | assets: GithubAsset[]; |
| | | } |
| | | |
| | | /** GitHub-Releases: holt das neueste Release und lädt ein passendes Installer-Asset. */ |
| | | export class GithubQuelle implements Bezugsquelle { |
| | | async gleicheAb(auftrag: Abgleichauftrag): Promise<QuellenAbgleich> { |
| | | try { |
| | | const antwort = await fetch(`https://api.github.com/repos/${auftrag.wert}/releases/latest`, { |
| | | headers: { 'User-Agent': 'software-bibliothek', Accept: 'application/vnd.github+json' }, |
| | | }); |
| | | if (!antwort.ok) { |
| | | return abgleichMisserfolg(auftrag.aktuelleVersion, `GitHub nicht erreichbar (HTTP ${antwort.status}).`); |
| | | } |
| | | const release = (await antwort.json()) as GithubRelease; |
| | | const asset = waehlePassendesAsset(release.assets); |
| | | if (!asset) { |
| | | return abgleichMisserfolg(auftrag.aktuelleVersion, `Im Release ${release.tag_name} liegt keine herunterladbare Datei.`); |
| | | } |
| | | const veroeffentlichtAm = alsIsoDatum(release.published_at); |
| | | if (release.tag_name === auftrag.aktuelleVersion && dateiVorhanden(auftrag.zielordner, asset.name)) { |
| | | return { erfolg: true, geaendert: false, version: release.tag_name, dateiname: asset.name, meldung: 'Bereits aktuell.', veroeffentlichtAm }; |
| | | } |
| | | |
| | | const dateiAntwort = await fetch(asset.browser_download_url, { headers: { 'User-Agent': 'software-bibliothek' }, redirect: 'follow' }); |
| | | if (!dateiAntwort.ok) { |
| | | return abgleichMisserfolg(auftrag.aktuelleVersion, `Asset-Download fehlgeschlagen (HTTP ${dateiAntwort.status}).`); |
| | | } |
| | | const inhalt = Buffer.from(await dateiAntwort.arrayBuffer()); |
| | | const beanstandung = inhaltBeanstanden(inhalt, asset.name, dateiAntwort.headers.get('content-type'), auftrag.vorherigeGroesse); |
| | | if (beanstandung) { |
| | | return abgleichMisserfolg(auftrag.aktuelleVersion, beanstandung); |
| | | } |
| | | |
| | | ersetzeDateiSicher(auftrag.zielordner, asset.name, inhalt); |
| | | return { erfolg: true, geaendert: true, version: release.tag_name, dateiname: asset.name, meldung: `Auf ${release.tag_name} aktualisiert.`, veroeffentlichtAm }; |
| | | } catch (fehler) { |
| | | return abgleichMisserfolg(auftrag.aktuelleVersion, `GitHub-Abgleich fehlgeschlagen: ${fehler instanceof Error ? fehler.message : String(fehler)}`); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Wählt aus den Release-Dateien den passenden Windows-Installer aus. |
| | | * |
| | | * Bewertet statt âerster Treffer gewinnt", damit die Reihenfolge im Release |
| | | * das Ergebnis nicht beeinflusst: 64-Bit vor allem anderen, ARM und 32-Bit |
| | | * ausgeschlossen, `.exe` vor `.msi`, portable Varianten nur als Notnagel. |
| | | */ |
| | | export function waehlePassendesAsset(assets: GithubAsset[]): GithubAsset | undefined { |
| | | const kandidaten = assets |
| | | .filter((eintrag) => /\.(exe|msi|msix|zip)$/i.test(eintrag.name)) |
| | | .filter((eintrag) => !/arm|aarch|x86|ia32|32.?bit/i.test(eintrag.name)); |
| | | if (kandidaten.length === 0) { |
| | | return undefined; |
| | | } |
| | | const bewertet = kandidaten.map((eintrag) => ({ eintrag, punkte: bewerteAsset(eintrag.name) })); |
| | | bewertet.sort((links, rechts) => rechts.punkte - links.punkte); |
| | | return bewertet[0].eintrag; |
| | | } |
| | | |
| | | function bewerteAsset(name: string): number { |
| | | let punkte = 0; |
| | | if (/x64|amd64|win64|64.?bit/i.test(name)) { |
| | | punkte += 100; // ausdrücklich 64-Bit |
| | | } |
| | | if (/\.exe$/i.test(name)) { |
| | | punkte += 20; // Installer bevorzugt gegenüber .msi/.zip |
| | | } else if (/\.msi$/i.test(name)) { |
| | | punkte += 10; |
| | | } |
| | | if (/portable/i.test(name)) { |
| | | punkte -= 50; // nur nehmen, wenn es nichts anderes gibt |
| | | } |
| | | return punkte; |
| | | } |
| Neue Datei |
| | |
| | | import type { QuelleTyp } from 'bibliothek-types'; |
| | | import type { Bezugsquelle } from './basis'; |
| | | import { DirektLinkQuelle } from './direkt-link'; |
| | | import { GithubQuelle } from './github'; |
| | | import { WingetQuelle } from './winget'; |
| | | import { SvnQuelle } from './svn'; |
| | | import { GitBuildQuelle } from './git-build'; |
| | | |
| | | export type { Abgleichauftrag, Bezugsquelle, QuellenAbgleich } from './basis'; |
| | | |
| | | // Registry der automatisch abgleichbaren Bezugsquellen. Eine neue Quellenart |
| | | // hinzufügen = hier einen Eintrag ergänzen. `manuell` fehlt bewusst: dort wird |
| | | // die Datei von Hand hinterlegt, es gibt nichts abzugleichen. |
| | | const registry: Partial<Record<QuelleTyp, Bezugsquelle>> = { |
| | | direkt: new DirektLinkQuelle(), |
| | | github: new GithubQuelle(), |
| | | winget: new WingetQuelle(), |
| | | svn: new SvnQuelle(), |
| | | git: new GitBuildQuelle(), |
| | | }; |
| | | |
| | | /** Liefert den Handler für einen Quell-Typ (oder undefined, falls unbekannt). */ |
| | | export function bezugsquelleFuer(typ: QuelleTyp): Bezugsquelle | undefined { |
| | | return registry[typ]; |
| | | } |
| Neue Datei |
| | |
| | | import { execFile } from 'child_process'; |
| | | import { promisify } from 'util'; |
| | | import * as path from 'path'; |
| | | import { abgleichMisserfolg, alsIsoDatum, dateiVorhanden, mitTemporaeremOrdner, type Abgleichauftrag, type Bezugsquelle, type QuellenAbgleich } from './basis'; |
| | | |
| | | const execFileAsync = promisify(execFile); |
| | | |
| | | /** |
| | | * SVN: exportiert eine Datei aus einem Repository, erkennt Ãnderungen über die Revision. |
| | | * Anmeldedaten kommen aus dem Windows-Anmeldespeicher des Dienstbenutzers â |
| | | * die Erstanmeldung muss einmal interaktiv als dieser Benutzer erfolgt sein. |
| | | */ |
| | | export class SvnQuelle implements Bezugsquelle { |
| | | async gleicheAb(auftrag: Abgleichauftrag): Promise<QuellenAbgleich> { |
| | | const dateiname = decodeURIComponent(auftrag.wert.split('/').pop() ?? 'datei'); |
| | | let revision = ''; |
| | | let veroeffentlichtAm = ''; |
| | | try { |
| | | const svnInfo = (element: string) => |
| | | execFileAsync('svn', ['info', '--show-item', element, '--non-interactive', auftrag.wert], { timeout: 60000, windowsHide: true }); |
| | | revision = (await svnInfo('last-changed-revision')).stdout.trim(); |
| | | veroeffentlichtAm = alsIsoDatum((await svnInfo('last-changed-date')).stdout.trim()); |
| | | } catch (fehler) { |
| | | return abgleichMisserfolg(auftrag.aktuelleVersion, svnFehlertext(fehler)); |
| | | } |
| | | |
| | | if (revision && revision === auftrag.aktuelleVersion && dateiVorhanden(auftrag.zielordner, dateiname)) { |
| | | return { erfolg: true, geaendert: false, version: revision, dateiname, meldung: 'Bereits aktuell.', veroeffentlichtAm }; |
| | | } |
| | | |
| | | const ablage = mitTemporaeremOrdner(auftrag.zielordner); |
| | | try { |
| | | await execFileAsync('svn', ['export', '--force', '--non-interactive', auftrag.wert, path.join(ablage.ordner, dateiname)], { |
| | | timeout: 300000, |
| | | windowsHide: true, |
| | | }); |
| | | ablage.uebernehmen(); |
| | | return { erfolg: true, geaendert: true, version: revision || new Date().toISOString(), dateiname, meldung: `SVN-Revision ${revision} exportiert.`, veroeffentlichtAm }; |
| | | } catch (fehler) { |
| | | ablage.verwerfen(); |
| | | return abgleichMisserfolg(auftrag.aktuelleVersion, svnFehlertext(fehler)); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** Macht fehlende Anmeldedaten als häufigste Ursache kenntlich. */ |
| | | function svnFehlertext(fehler: unknown): string { |
| | | const text = fehler instanceof Error ? fehler.message : String(fehler); |
| | | if (/authoriz|authentic|E170001|credentials/i.test(text)) { |
| | | return `SVN-Anmeldung fehlt oder ist ungültig. Einmalig als Dienstbenutzer anmelden (die Anmeldung wird dann im Windows-Speicher hinterlegt). Ursprüngliche Meldung: ${text}`; |
| | | } |
| | | return `SVN fehlgeschlagen: ${text}`; |
| | | } |
| Neue Datei |
| | |
| | | import { execFile } from 'child_process'; |
| | | import { promisify } from 'util'; |
| | | import * as fs from 'fs'; |
| | | import * as path from 'path'; |
| | | import { abgleichMisserfolg, alsIsoDatum, mitTemporaeremOrdner, type Abgleichauftrag, type Bezugsquelle, type QuellenAbgleich } from './basis'; |
| | | |
| | | const execFileAsync = promisify(execFile); |
| | | |
| | | /** winget: lädt das Paket per `winget download` (nur unter Windows verfügbar). */ |
| | | export class WingetQuelle implements Bezugsquelle { |
| | | async gleicheAb(auftrag: Abgleichauftrag): Promise<QuellenAbgleich> { |
| | | // In einen temporären Ordner laden â der bisherige Stand bleibt erhalten, falls etwas schiefgeht. |
| | | const ablage = mitTemporaeremOrdner(auftrag.zielordner); |
| | | try { |
| | | const { stdout } = await execFileAsync( |
| | | 'winget', |
| | | [ |
| | | 'download', |
| | | '--id', |
| | | auftrag.wert, |
| | | '--exact', |
| | | '--download-directory', |
| | | ablage.ordner, |
| | | '--accept-package-agreements', |
| | | '--accept-source-agreements', |
| | | '--disable-interactivity', |
| | | ], |
| | | { timeout: 300000, windowsHide: true } |
| | | ); |
| | | const geladeneDateien = fs.readdirSync(ablage.ordner); |
| | | const installer = geladeneDateien.find((name) => /\.(exe|msi|msix|appx|appxbundle|zip)$/i.test(name)); |
| | | if (!installer) { |
| | | ablage.verwerfen(); |
| | | return abgleichMisserfolg(auftrag.aktuelleVersion, 'winget hat keine Installer-Datei geliefert.'); |
| | | } |
| | | // winget legt zusätzlich sein Manifest (.yaml) ab â im Ablageordner soll nur der Installer liegen. |
| | | for (const name of geladeneDateien) { |
| | | if (name !== installer) { |
| | | fs.rmSync(path.join(ablage.ordner, name), { recursive: true, force: true }); |
| | | } |
| | | } |
| | | const version = versionAusAusgabe(stdout) || new Date().toISOString(); |
| | | ablage.uebernehmen(); |
| | | return { |
| | | erfolg: true, |
| | | geaendert: true, |
| | | version, |
| | | dateiname: installer, |
| | | meldung: `Ãber winget geladen (${version}).`, |
| | | // Das Veröffentlichungsdatum steht nur in der Paket-Auskunft, nicht in der Download-Ausgabe. |
| | | veroeffentlichtAm: alsIsoDatum(await veroeffentlichungsdatum(auftrag.wert)), |
| | | }; |
| | | } catch (fehler) { |
| | | ablage.verwerfen(); |
| | | return abgleichMisserfolg(auftrag.aktuelleVersion, `winget fehlgeschlagen: ${fehler instanceof Error ? fehler.message : String(fehler)}`); |
| | | } |
| | | } |
| | | } |
| | | |
| | | function versionAusAusgabe(ausgabe: string): string { |
| | | const treffer = ausgabe.match(/[Vv]ersion[:\s]+([0-9][0-9.]*)/); |
| | | return treffer ? treffer[1] : ''; |
| | | } |
| | | |
| | | /** |
| | | * Fragt das Veröffentlichungsdatum beim Paket-Verzeichnis ab. `winget download` |
| | | * gibt es nicht aus, `winget show` schon â dafür ein zweiter, kurzer Aufruf. |
| | | */ |
| | | async function veroeffentlichungsdatum(paketId: string): Promise<string> { |
| | | try { |
| | | const { stdout } = await execFileAsync( |
| | | 'winget', |
| | | ['show', '--id', paketId, '--exact', '--accept-source-agreements', '--disable-interactivity'], |
| | | { timeout: 120000, windowsHide: true } |
| | | ); |
| | | const treffer = stdout.match(/(?:Release\s*Date|Ver[öo]ffentlich\w*)[:\s]+([0-9]{4}-[0-9]{2}-[0-9]{2})/i); |
| | | return treffer ? treffer[1] : ''; |
| | | } catch { |
| | | return ''; // ohne Datum ist die Zeile nur weniger aussagekräftig, kein Grund zu scheitern |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | import { DatabaseSync } from 'node:sqlite'; |
| | | import * as fs from 'fs'; |
| | | import * as path from 'path'; |
| | | import type { AbgleichErgebnis, GeheimnisBedarf, Software, SoftwareEingabe, ZugriffslogEintrag } from 'bibliothek-types'; |
| | | import { effektiverDatenOrdner } from './konfiguration'; |
| | | |
| | | // Ablageort aus den Einstellungen (Standard: Ordner âdata" neben der Anwendung). |
| | | const datenOrdner = effektiverDatenOrdner(); |
| | | |
| | | /** Ablageordner für die bereitgestellten Programm-Dateien (Downloads), je Software ein Unterordner. */ |
| | | export const DATEIEN_ORDNER = path.join(datenOrdner, 'files'); |
| | | |
| | | /** Arbeitskopien der Quellen, die etwas auschecken/bauen müssen (Git), je Software ein Unterordner. */ |
| | | export const QUELLEN_ORDNER = path.join(datenOrdner, 'sources'); |
| | | |
| | | /** |
| | | * Bau-Geheimnisse (z. B. API-Schlüssel), die vor dem Bau in die Arbeitskopie gelegt werden. |
| | | * Wird nirgends ausgeliefert â die Download-Route bedient nur `files/`. |
| | | */ |
| | | export const GEHEIMNIS_ORDNER = path.join(datenOrdner, 'secrets'); |
| | | |
| | | /** |
| | | * Frühere Fassungen legten die Unterordner unter deutschen Namen an. Beim Start |
| | | * einmalig umbenennen â sonst stünde eine bestehende Installation ohne ihre |
| | | * Dateien da. Existieren beide Namen, bleibt der alte unangetastet; das von Hand |
| | | * zu klären ist sicherer, als stillschweigend zusammenzuführen. |
| | | */ |
| | | function benenneAlteOrdnerUm(): void { |
| | | for (const [alterName, neuerName] of [ |
| | | ['dateien', 'files'], |
| | | ['quellen', 'sources'], |
| | | ['geheimnisse', 'secrets'], |
| | | ]) { |
| | | const alterPfad = path.join(datenOrdner, alterName); |
| | | const neuerPfad = path.join(datenOrdner, neuerName); |
| | | if (!fs.existsSync(alterPfad)) { |
| | | continue; |
| | | } |
| | | if (fs.existsSync(neuerPfad)) { |
| | | console.warn(`Ordner â${alterName}" und â${neuerName}" liegen beide vor â bitte von Hand zusammenführen.`); |
| | | continue; |
| | | } |
| | | fs.renameSync(alterPfad, neuerPfad); |
| | | console.log(`Datenordner umbenannt: ${alterName} â ${neuerName}`); |
| | | } |
| | | } |
| | | |
| | | benenneAlteOrdnerUm(); |
| | | |
| | | fs.mkdirSync(DATEIEN_ORDNER, { recursive: true }); |
| | | fs.mkdirSync(QUELLEN_ORDNER, { recursive: true }); |
| | | fs.mkdirSync(GEHEIMNIS_ORDNER, { recursive: true }); |
| | | |
| | | export const datenbank = new DatabaseSync(path.join(datenOrdner, 'bibliothek.db')); |
| | | datenbank.exec('PRAGMA journal_mode = WAL'); |
| | | |
| | | /** Legt die Tabellen an und migriert fehlende Spalten. Bestehende Daten bleiben erhalten. */ |
| | | export function initialisiereDatenbank(): void { |
| | | datenbank.exec(` |
| | | CREATE TABLE IF NOT EXISTS software ( |
| | | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| | | name TEXT NOT NULL, |
| | | beschreibung TEXT NOT NULL DEFAULT '', |
| | | elternId INTEGER NOT NULL DEFAULT 0, |
| | | quelleTyp TEXT NOT NULL DEFAULT '', |
| | | quelleWert TEXT NOT NULL DEFAULT '', |
| | | bauBefehl TEXT NOT NULL DEFAULT '', |
| | | bauErgebnis TEXT NOT NULL DEFAULT '', |
| | | einstufung TEXT NOT NULL DEFAULT 'lizenzfrei', |
| | | programmart TEXT NOT NULL DEFAULT 'unbestimmt', |
| | | hinweis TEXT NOT NULL DEFAULT '', |
| | | anleitungUrl TEXT NOT NULL DEFAULT '', |
| | | version TEXT NOT NULL DEFAULT '', |
| | | dateiname TEXT NOT NULL DEFAULT '', |
| | | dateiStandVom TEXT NOT NULL DEFAULT '', |
| | | veroeffentlichtAm TEXT NOT NULL DEFAULT '', |
| | | signaturStatus TEXT NOT NULL DEFAULT '', |
| | | signaturHerausgeber TEXT NOT NULL DEFAULT '', |
| | | erwarteterHerausgeber TEXT NOT NULL DEFAULT '', |
| | | benoetigteGeheimnisse TEXT NOT NULL DEFAULT '[]', |
| | | letzterAbgleich TEXT NOT NULL DEFAULT '', |
| | | abgleichErfolg INTEGER NOT NULL DEFAULT 0, |
| | | abgleichMeldung TEXT NOT NULL DEFAULT '', |
| | | erstelltAm TEXT NOT NULL, |
| | | aktualisiertAm TEXT NOT NULL |
| | | ); |
| | | |
| | | CREATE TABLE IF NOT EXISTS zugriffslog ( |
| | | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| | | zeitpunkt TEXT NOT NULL, |
| | | ipAdresse TEXT NOT NULL DEFAULT '', |
| | | rechnername TEXT NOT NULL DEFAULT '', |
| | | angegebenerName TEXT NOT NULL DEFAULT '', |
| | | aktion TEXT NOT NULL DEFAULT '' |
| | | ); |
| | | `); |
| | | |
| | | // Migration bestehender Datenbanken: neue Spalten ergänzen, falls sie fehlen. |
| | | const neueSpalten: Array<[string, string]> = [ |
| | | ['quelleTyp', "TEXT NOT NULL DEFAULT ''"], |
| | | ['quelleWert', "TEXT NOT NULL DEFAULT ''"], |
| | | ['elternId', 'INTEGER NOT NULL DEFAULT 0'], |
| | | ['dateiStandVom', "TEXT NOT NULL DEFAULT ''"], |
| | | ['veroeffentlichtAm', "TEXT NOT NULL DEFAULT ''"], |
| | | ['signaturStatus', "TEXT NOT NULL DEFAULT ''"], |
| | | ['signaturHerausgeber', "TEXT NOT NULL DEFAULT ''"], |
| | | ['erwarteterHerausgeber', "TEXT NOT NULL DEFAULT ''"], |
| | | ['benoetigteGeheimnisse', "TEXT NOT NULL DEFAULT '[]'"], |
| | | ['bauBefehl', "TEXT NOT NULL DEFAULT ''"], |
| | | ['bauErgebnis', "TEXT NOT NULL DEFAULT ''"], |
| | | ['einstufung', "TEXT NOT NULL DEFAULT 'lizenzfrei'"], |
| | | ['programmart', "TEXT NOT NULL DEFAULT 'unbestimmt'"], |
| | | ['hinweis', "TEXT NOT NULL DEFAULT ''"], |
| | | ['anleitungUrl', "TEXT NOT NULL DEFAULT ''"], |
| | | ['letzterAbgleich', "TEXT NOT NULL DEFAULT ''"], |
| | | ['abgleichErfolg', 'INTEGER NOT NULL DEFAULT 0'], |
| | | ['abgleichMeldung', "TEXT NOT NULL DEFAULT ''"], |
| | | ]; |
| | | for (const [spalte, definition] of neueSpalten) { |
| | | spalteErgaenzenFallsFehlt('software', spalte, definition); |
| | | } |
| | | |
| | | // Ablösung des früheren Ja/Nein-Feldes durch die dreiteilige Einstufung. |
| | | if (spalteVorhanden('software', 'lizenzpflichtig')) { |
| | | datenbank.exec("UPDATE software SET einstufung = 'lizenzpflichtig' WHERE lizenzpflichtig = 1"); |
| | | datenbank.exec('ALTER TABLE software DROP COLUMN lizenzpflichtig'); |
| | | } |
| | | |
| | | // Kategorien haben sich als überflüssig erwiesen â die Einstufung gliedert den Katalog. |
| | | if (spalteVorhanden('software', 'kategorie')) { |
| | | datenbank.exec('ALTER TABLE software DROP COLUMN kategorie'); |
| | | } |
| | | } |
| | | |
| | | function spalteVorhanden(tabelle: string, spalte: string): boolean { |
| | | const vorhandene = datenbank.prepare(`PRAGMA table_info(${tabelle})`).all() as Array<{ name: string }>; |
| | | return vorhandene.some((eintrag) => eintrag.name === spalte); |
| | | } |
| | | |
| | | function spalteErgaenzenFallsFehlt(tabelle: string, spalte: string, definition: string): void { |
| | | if (!spalteVorhanden(tabelle, spalte)) { |
| | | datenbank.exec(`ALTER TABLE ${tabelle} ADD COLUMN ${spalte} ${definition}`); |
| | | } |
| | | } |
| | | |
| | | // --- Software-Katalog --- |
| | | |
| | | /** Wandelt eine DB-Zeile in ein Software-Objekt (SQLite kennt kein boolean â INTEGER 0/1). */ |
| | | function zeileZuSoftware(zeile: Record<string, unknown>): Software { |
| | | return { |
| | | ...zeile, |
| | | abgleichErfolg: Boolean(zeile.abgleichErfolg), |
| | | benoetigteGeheimnisse: alsGeheimnisBedarf(zeile.benoetigteGeheimnisse), |
| | | } as Software; |
| | | } |
| | | |
| | | /** Die angeforderten Geheimnisse liegen als JSON-Text in der Spalte. */ |
| | | function alsGeheimnisBedarf(rohwert: unknown): GeheimnisBedarf[] { |
| | | if (typeof rohwert !== 'string' || !rohwert.trim()) { |
| | | return []; |
| | | } |
| | | try { |
| | | const gelesen = JSON.parse(rohwert) as GeheimnisBedarf[]; |
| | | return Array.isArray(gelesen) ? gelesen : []; |
| | | } catch { |
| | | return []; |
| | | } |
| | | } |
| | | |
| | | export function alleSoftware(): Software[] { |
| | | const zeilen = datenbank.prepare('SELECT * FROM software ORDER BY name COLLATE NOCASE').all() as Array<Record<string, unknown>>; |
| | | return zeilen.map(zeileZuSoftware); |
| | | } |
| | | |
| | | export function softwareNachId(id: number): Software | undefined { |
| | | const zeile = datenbank.prepare('SELECT * FROM software WHERE id = ?').get(id) as Record<string, unknown> | undefined; |
| | | return zeile ? zeileZuSoftware(zeile) : undefined; |
| | | } |
| | | |
| | | export function softwareAnlegen(eingabe: SoftwareEingabe): Software { |
| | | const jetzt = new Date().toISOString(); |
| | | const ergebnis = datenbank |
| | | .prepare( |
| | | `INSERT INTO software (name, beschreibung, elternId, quelleTyp, quelleWert, bauBefehl, bauErgebnis, einstufung, programmart, hinweis, anleitungUrl, erwarteterHerausgeber, erstelltAm, aktualisiertAm) |
| | | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` |
| | | ) |
| | | .run( |
| | | eingabe.name, |
| | | eingabe.beschreibung, |
| | | eingabe.elternId, |
| | | eingabe.quelleTyp, |
| | | eingabe.quelleWert, |
| | | eingabe.bauBefehl, |
| | | eingabe.bauErgebnis, |
| | | eingabe.einstufung, |
| | | eingabe.programmart, |
| | | eingabe.hinweis, |
| | | eingabe.anleitungUrl, |
| | | eingabe.erwarteterHerausgeber, |
| | | jetzt, |
| | | jetzt |
| | | ); |
| | | return softwareNachId(Number(ergebnis.lastInsertRowid)) as Software; |
| | | } |
| | | |
| | | export function softwareAktualisieren(id: number, eingabe: SoftwareEingabe): Software | undefined { |
| | | if (!softwareNachId(id)) { |
| | | return undefined; |
| | | } |
| | | const jetzt = new Date().toISOString(); |
| | | datenbank |
| | | .prepare( |
| | | `UPDATE software SET name = ?, beschreibung = ?, elternId = ?, quelleTyp = ?, quelleWert = ?, |
| | | bauBefehl = ?, bauErgebnis = ?, einstufung = ?, programmart = ?, hinweis = ?, anleitungUrl = ?, |
| | | erwarteterHerausgeber = ?, aktualisiertAm = ? |
| | | WHERE id = ?` |
| | | ) |
| | | .run( |
| | | eingabe.name, |
| | | eingabe.beschreibung, |
| | | eingabe.elternId, |
| | | eingabe.quelleTyp, |
| | | eingabe.quelleWert, |
| | | eingabe.bauBefehl, |
| | | eingabe.bauErgebnis, |
| | | eingabe.einstufung, |
| | | eingabe.programmart, |
| | | eingabe.hinweis, |
| | | eingabe.anleitungUrl, |
| | | eingabe.erwarteterHerausgeber, |
| | | jetzt, |
| | | id |
| | | ); |
| | | return softwareNachId(id); |
| | | } |
| | | |
| | | /** |
| | | * Löscht eine Software samt ihrer Zusatzinhalte (ein Zusatzinhalt ohne sein Tool |
| | | * wäre sinnlos) und räumt die zugehörigen Ordner mit ab â sonst bleiben die |
| | | * bereitgestellten Dateien und Arbeitskopien für immer liegen. |
| | | */ |
| | | export function softwareLoeschen(id: number): boolean { |
| | | for (const zusatzinhalt of datenbank.prepare('SELECT id FROM software WHERE elternId = ?').all(id) as Array<{ id: number }>) { |
| | | entferneOrdnerVon(Number(zusatzinhalt.id)); |
| | | } |
| | | datenbank.prepare('DELETE FROM software WHERE elternId = ?').run(id); |
| | | const ergebnis = datenbank.prepare('DELETE FROM software WHERE id = ?').run(id); |
| | | if (Number(ergebnis.changes) > 0) { |
| | | entferneOrdnerVon(id); |
| | | return true; |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | /** Entfernt bereitgestellte Datei und Arbeitskopie einer Software. */ |
| | | export function entferneOrdnerVon(id: number): void { |
| | | fs.rmSync(path.join(DATEIEN_ORDNER, String(id)), { recursive: true, force: true }); |
| | | fs.rmSync(path.join(QUELLEN_ORDNER, String(id)), { recursive: true, force: true }); |
| | | } |
| | | |
| | | /** Schreibt das Ergebnis eines Bezugsquellen-Abgleichs zurück (Version, Datei, Status). */ |
| | | export function abgleichSpeichern(id: number, ergebnis: AbgleichErgebnis): void { |
| | | const jetzt = new Date().toISOString(); |
| | | // Der Dateistand wird nur fortgeschrieben, wenn wirklich eine neue Datei kam â |
| | | // eine reine Prüfung ohne Ãnderung ist kein Update. |
| | | const bisheriger = softwareNachId(id)?.dateiStandVom ?? ''; |
| | | datenbank |
| | | .prepare( |
| | | `UPDATE software SET version = ?, dateiname = ?, dateiStandVom = ?, veroeffentlichtAm = ?, signaturStatus = ?, signaturHerausgeber = ?, |
| | | benoetigteGeheimnisse = ?, letzterAbgleich = ?, abgleichErfolg = ?, abgleichMeldung = ?, aktualisiertAm = ? |
| | | WHERE id = ?` |
| | | ) |
| | | .run( |
| | | ergebnis.version, |
| | | ergebnis.dateiname, |
| | | ergebnis.geaendert ? jetzt : bisheriger, |
| | | ergebnis.veroeffentlichtAm, |
| | | ergebnis.signaturStatus, |
| | | ergebnis.signaturHerausgeber, |
| | | JSON.stringify(ergebnis.benoetigteGeheimnisse), |
| | | jetzt, |
| | | ergebnis.erfolg ? 1 : 0, |
| | | ergebnis.meldung, |
| | | jetzt, |
| | | id |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * Ãbernimmt beim Umzug bekannte Zusatzangaben, ohne einen Abgleich vorzutäuschen: |
| | | * `letzterAbgleich` bleibt leer, damit der Eintrag als ânoch nicht geholt" gilt |
| | | * und nicht als Problemfall erscheint. |
| | | */ |
| | | export function umzugsAngabenSetzen(id: number, veroeffentlichtAm: string, benoetigteGeheimnisse: GeheimnisBedarf[]): void { |
| | | datenbank |
| | | .prepare('UPDATE software SET veroeffentlichtAm = ?, benoetigteGeheimnisse = ? WHERE id = ?') |
| | | .run(veroeffentlichtAm, JSON.stringify(benoetigteGeheimnisse), id); |
| | | } |
| | | |
| | | // --- Zugriffsprotokoll --- |
| | | |
| | | export function zugriffProtokollieren(eintrag: Omit<ZugriffslogEintrag, 'id'>): void { |
| | | datenbank |
| | | .prepare( |
| | | `INSERT INTO zugriffslog (zeitpunkt, ipAdresse, rechnername, angegebenerName, aktion) |
| | | VALUES (?, ?, ?, ?, ?)` |
| | | ) |
| | | .run(eintrag.zeitpunkt, eintrag.ipAdresse, eintrag.rechnername, eintrag.angegebenerName, eintrag.aktion); |
| | | } |
| | | |
| | | export function alleZugriffe(): ZugriffslogEintrag[] { |
| | | return datenbank.prepare('SELECT * FROM zugriffslog ORDER BY id DESC').all() as unknown as ZugriffslogEintrag[]; |
| | | } |
| Neue Datei |
| | |
| | | import { execFileSync } from 'child_process'; |
| | | import * as fs from 'fs'; |
| | | import * as path from 'path'; |
| | | import { GEHEIMNIS_ORDNER } from './datenbank'; |
| | | |
| | | /** |
| | | * Bau-Geheimnisse (API-Schlüssel, Tokens â¦) nach einheitlichem Muster. |
| | | * |
| | | * Standard â bewusst wie bei gängigen Bau-Systemen, damit die Bibliothek nichts |
| | | * über einzelne Tools wissen muss: |
| | | * |
| | | * 1. Das Repository deklariert in `software-bibliothek.json`, welche Geheimnisse |
| | | * es braucht: "secrets": [{ "name": "ANTHROPIC_API_KEY", "description": "â¦" }] |
| | | * 2. Der Wert wird in der Bibliothek hinterlegt (nur lokal am Server pflegbar). |
| | | * 3. Beim Bau wird er als **Umgebungsvariable** gesetzt. Was das Tool daraus macht |
| | | * â Datei erzeugen, direkt einkompilieren â entscheidet sein eigenes Bau-Skript. |
| | | * |
| | | * Ablage getrennt vom Katalog und NIE im Software-Datensatz: `/api/software` ist aus |
| | | * dem Netzwerk lesbar. Auch der Ordner `secrets/` wird nirgends ausgeliefert â |
| | | * die Download-Route bedient ausschlieÃlich `files/<id>/<datei>`. |
| | | */ |
| | | export interface Geheimniswert { |
| | | /** Name der Umgebungsvariablen, z. B. `ANTHROPIC_API_KEY`. */ |
| | | name: string; |
| | | wert: string; |
| | | } |
| | | |
| | | /** Was die Oberfläche zu sehen bekommt â nie der Wert selbst. */ |
| | | export interface GeheimnisUebersicht { |
| | | name: string; |
| | | hinterlegt: boolean; |
| | | zeichen: number; |
| | | } |
| | | |
| | | function ablagePfad(softwareId: number): string { |
| | | return path.join(GEHEIMNIS_ORDNER, `${softwareId}.json`); |
| | | } |
| | | |
| | | /** |
| | | * Schränkt die Zugriffsrechte auf den Geheimnis-Ordner ein. |
| | | * |
| | | * Nötig, weil Unterordner von %ProgramData% die Leseberechtigung für die |
| | | * Gruppe âBenutzer" erben â sonst könnte jeder lokal angemeldete Benutzer die |
| | | * hinterlegten Schlüssel lesen. Node's `mode` greift unter Windows nicht auf |
| | | * ACLs, deshalb über `icacls`. Bezeichnung über die bekannten SIDs, damit es |
| | | * unabhängig von der Systemsprache funktioniert. |
| | | */ |
| | | export function sichereGeheimnisOrdner(): void { |
| | | if (process.platform !== 'win32') { |
| | | return; |
| | | } |
| | | const dienstbenutzer = `${process.env.USERDOMAIN ?? ''}\\${process.env.USERNAME ?? ''}`; |
| | | const rechte = [ |
| | | '*S-1-5-18:(OI)(CI)F', // SYSTEM |
| | | '*S-1-5-32-544:(OI)(CI)F', // Administratoren |
| | | ]; |
| | | if (process.env.USERNAME) { |
| | | rechte.push(`${dienstbenutzer}:(OI)(CI)F`); // der Benutzer, unter dem der Dienst läuft |
| | | } |
| | | try { |
| | | execFileSync('icacls', [GEHEIMNIS_ORDNER, '/inheritance:r', '/grant:r', ...rechte], { stdio: 'pipe', windowsHide: true }); |
| | | } catch (fehler) { |
| | | console.warn(`Zugriffsrechte des Schlüssel-Ordners konnten nicht eingeschränkt werden: ${String(fehler).slice(0, 200)}`); |
| | | } |
| | | } |
| | | |
| | | export function ladeGeheimnisse(softwareId: number): Geheimniswert[] { |
| | | const pfad = ablagePfad(softwareId); |
| | | if (!fs.existsSync(pfad)) { |
| | | return []; |
| | | } |
| | | try { |
| | | const gelesen = JSON.parse(fs.readFileSync(pfad, 'utf8')) as Geheimniswert[]; |
| | | return Array.isArray(gelesen) ? gelesen.filter((eintrag) => istGueltigerName(eintrag?.name)) : []; |
| | | } catch { |
| | | return []; |
| | | } |
| | | } |
| | | |
| | | /** Nur Namen und Länge â der Wert verlässt den Server nicht. */ |
| | | export function uebersichtGeheimnisse(softwareId: number): GeheimnisUebersicht[] { |
| | | return ladeGeheimnisse(softwareId).map((eintrag) => ({ |
| | | name: eintrag.name, |
| | | hinterlegt: eintrag.wert.length > 0, |
| | | zeichen: eintrag.wert.length, |
| | | })); |
| | | } |
| | | |
| | | /** |
| | | * Speichert die Werte. Ein leerer Wert bedeutet âunverändert lassen" â so kann die |
| | | * Oberfläche die Liste zurückschicken, ohne die Geheimnisse je gesehen zu haben. |
| | | */ |
| | | export function speichereGeheimnisse(softwareId: number, eingehende: Geheimniswert[]): GeheimnisUebersicht[] { |
| | | const bisherige = new Map(ladeGeheimnisse(softwareId).map((eintrag) => [eintrag.name, eintrag.wert])); |
| | | const zusammengefuehrt: Geheimniswert[] = []; |
| | | for (const eintrag of eingehende) { |
| | | const name = (eintrag?.name ?? '').trim(); |
| | | if (!istGueltigerName(name)) { |
| | | continue; |
| | | } |
| | | const neuerWert = String(eintrag.wert ?? ''); |
| | | zusammengefuehrt.push({ name, wert: neuerWert || bisherige.get(name) || '' }); |
| | | } |
| | | fs.mkdirSync(GEHEIMNIS_ORDNER, { recursive: true }); |
| | | fs.writeFileSync(ablagePfad(softwareId), JSON.stringify(zusammengefuehrt, null, 2), { mode: 0o600 }); |
| | | return zusammengefuehrt.map((eintrag) => ({ name: eintrag.name, hinterlegt: eintrag.wert.length > 0, zeichen: eintrag.wert.length })); |
| | | } |
| | | |
| | | export function loescheGeheimnisse(softwareId: number): void { |
| | | fs.rmSync(ablagePfad(softwareId), { force: true }); |
| | | } |
| | | |
| | | /** Umgebungsvariablen für den Bau â nur Einträge mit hinterlegtem Wert. */ |
| | | export function alsUmgebung(geheimnisse: Geheimniswert[]): Record<string, string> { |
| | | const umgebung: Record<string, string> = {}; |
| | | for (const eintrag of geheimnisse) { |
| | | if (eintrag.wert) { |
| | | umgebung[eintrag.name] = eintrag.wert; |
| | | } |
| | | } |
| | | return umgebung; |
| | | } |
| | | |
| | | /** Nur was als Umgebungsvariable taugt â schützt zugleich vor Einschleusen. */ |
| | | function istGueltigerName(name: string | undefined): boolean { |
| | | return !!name && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name); |
| | | } |
| Neue Datei |
| | |
| | | import './warnungen'; // muss vor dem Laden von node:sqlite stehen |
| | | import express from 'express'; |
| | | import * as fs from 'fs'; |
| | | import * as path from 'path'; |
| | | import * as dns from 'node:dns'; |
| | | import { EINSTUFUNGEN, PROGRAMMARTEN, type Einstufung, type Programmart, type QuelleTyp, type SoftwareEingabe } from 'bibliothek-types'; |
| | | import { |
| | | initialisiereDatenbank, |
| | | alleSoftware, |
| | | softwareNachId, |
| | | softwareAnlegen, |
| | | softwareAktualisieren, |
| | | softwareLoeschen, |
| | | abgleichSpeichern, |
| | | zugriffProtokollieren, |
| | | alleZugriffe, |
| | | DATEIEN_ORDNER, |
| | | } from './datenbank'; |
| | | import { gleicheSoftwareAb, gleicheAlleAb } from './abgleich'; |
| | | import { erzeugeZip } from './zip'; |
| | | import { pruefeSignatur } from './signatur'; |
| | | import { loescheGeheimnisse, sichereGeheimnisOrdner, speichereGeheimnisse, uebersichtGeheimnisse } from './geheimnisse'; |
| | | import { erstelleUmzugsPaket, uebernehmeUmzugsPaket } from './umzug'; |
| | | import { istEinzeldatei, oberflaechenOrdner } from './pfade'; |
| | | import { aktuelleEinstellungen, speichereEinstellungen, effektiverDatenOrdner, effektiverPort, konfigurationsDatei } from './konfiguration'; |
| | | |
| | | const PORT = effektiverPort(); |
| | | const ABGLEICH_INTERVALL_MS = 24 * 60 * 60 * 1000; // täglicher Auto-Check |
| | | |
| | | initialisiereDatenbank(); |
| | | sichereGeheimnisOrdner(); |
| | | |
| | | const anwendung = express(); |
| | | // Umzugs-Pakete können je nach KataloggröÃe etwas umfangreicher sein. |
| | | anwendung.use(express.json({ limit: '25mb' })); |
| | | |
| | | // --- Zugriffs-Herkunft --- |
| | | |
| | | function anfrageIp(anfrage: express.Request): string { |
| | | const roh = anfrage.ip ?? anfrage.socket.remoteAddress ?? ''; |
| | | return roh.replace(/^::ffff:/, ''); // IPv4-mapped IPv6 auf reine IPv4 normalisieren |
| | | } |
| | | |
| | | function istLokalerZugriff(anfrage: express.Request): boolean { |
| | | const ip = anfrageIp(anfrage); |
| | | return ip === '127.0.0.1' || ip === '::1'; |
| | | } |
| | | |
| | | /** Middleware: erlaubt die Route nur bei direktem Zugriff am Server (localhost). */ |
| | | function nurLokal(anfrage: express.Request, antwort: express.Response, next: express.NextFunction): void { |
| | | if (!istLokalerZugriff(anfrage)) { |
| | | antwort.status(403).json({ fehler: 'Diese Aktion ist nur direkt am Server möglich.' }); |
| | | return; |
| | | } |
| | | next(); |
| | | } |
| | | |
| | | // --- Umgebung & Status --- |
| | | |
| | | anwendung.get('/api/umgebung', (anfrage, antwort) => { |
| | | antwort.json({ lokal: istLokalerZugriff(anfrage) }); |
| | | }); |
| | | |
| | | anwendung.get('/api/status', (_anfrage, antwort) => { |
| | | antwort.json({ status: 'ok', dienst: 'software-bibliothek', softwareAnzahl: alleSoftware().length }); |
| | | }); |
| | | |
| | | // --- Katalog lesen (auch aus dem Netzwerk erreichbar) --- |
| | | |
| | | anwendung.get('/api/software', (_anfrage, antwort) => { |
| | | antwort.json(alleSoftware()); |
| | | }); |
| | | |
| | | anwendung.get('/api/software/:id', (anfrage, antwort) => { |
| | | const eintrag = softwareNachId(Number(anfrage.params.id)); |
| | | if (!eintrag) { |
| | | antwort.status(404).json({ fehler: 'Software nicht gefunden' }); |
| | | return; |
| | | } |
| | | antwort.json(eintrag); |
| | | }); |
| | | |
| | | // --- Datei-Download (auch aus dem Netzwerk, wird protokolliert) --- |
| | | |
| | | anwendung.get('/api/software/:id/download', async (anfrage, antwort) => { |
| | | const eintrag = softwareNachId(Number(anfrage.params.id)); |
| | | if (!eintrag || !eintrag.dateiname) { |
| | | antwort.status(404).json({ fehler: 'Für diese Software liegt (noch) keine Datei vor.' }); |
| | | return; |
| | | } |
| | | const dateipfad = path.join(DATEIEN_ORDNER, String(eintrag.id), eintrag.dateiname); |
| | | if (!fs.existsSync(dateipfad)) { |
| | | antwort.status(404).json({ fehler: 'Die Datei fehlt auf dem Server.' }); |
| | | return; |
| | | } |
| | | await protokolliereDownload(anfrage, eintrag.name); |
| | | antwort.download(dateipfad, eintrag.dateiname); |
| | | }); |
| | | |
| | | // --- Sammel-Download mehrerer Programme als ZIP (auch aus dem Netzwerk) --- |
| | | |
| | | anwendung.get('/api/download-mehrere', async (anfrage, antwort) => { |
| | | const ids = String(anfrage.query.ids ?? '') |
| | | .split(',') |
| | | .map((eintrag) => Number(eintrag.trim())) |
| | | .filter((id) => Number.isInteger(id) && id > 0); |
| | | if (ids.length === 0) { |
| | | antwort.status(400).json({ fehler: 'Keine Auswahl übergeben' }); |
| | | return; |
| | | } |
| | | |
| | | const eintraege = []; |
| | | const namen: string[] = []; |
| | | for (const id of ids) { |
| | | const software = softwareNachId(id); |
| | | if (!software?.dateiname) { |
| | | continue; |
| | | } |
| | | const dateipfad = path.join(DATEIEN_ORDNER, String(id), software.dateiname); |
| | | if (!fs.existsSync(dateipfad)) { |
| | | continue; |
| | | } |
| | | // Je Programm ein eigener Ordner im Archiv â verhindert Namenskollisionen. |
| | | eintraege.push({ dateiname: `${dateinameSaeubern(software.name)}/${software.dateiname}`, inhalt: fs.readFileSync(dateipfad) }); |
| | | namen.push(software.name); |
| | | } |
| | | if (eintraege.length === 0) { |
| | | antwort.status(404).json({ fehler: 'Zu der Auswahl liegen keine Dateien vor.' }); |
| | | return; |
| | | } |
| | | |
| | | await protokolliereDownload(anfrage, `${eintraege.length} Programme: ${namen.join(', ')}`); |
| | | antwort.type('application/zip'); |
| | | antwort.setHeader('Content-Disposition', 'attachment; filename="software-auswahl.zip"'); |
| | | antwort.send(erzeugeZip(eintraege)); |
| | | }); |
| | | |
| | | // --- Katalog verwalten (nur lokal am Server) --- |
| | | |
| | | anwendung.post('/api/software', nurLokal, (anfrage, antwort) => { |
| | | const eingabe = normalisiereEingabe(anfrage.body); |
| | | if (!eingabe.name) { |
| | | antwort.status(400).json({ fehler: 'Name ist erforderlich' }); |
| | | return; |
| | | } |
| | | const angelegt = softwareAnlegen(eingabe); |
| | | void gleicheSoftwareAb(angelegt); // Datei im Hintergrund holen, Antwort nicht blockieren |
| | | antwort.status(201).json(angelegt); |
| | | }); |
| | | |
| | | anwendung.put('/api/software/:id', nurLokal, (anfrage, antwort) => { |
| | | const eingabe = normalisiereEingabe(anfrage.body); |
| | | if (!eingabe.name) { |
| | | antwort.status(400).json({ fehler: 'Name ist erforderlich' }); |
| | | return; |
| | | } |
| | | const aktualisiert = softwareAktualisieren(Number(anfrage.params.id), eingabe); |
| | | if (!aktualisiert) { |
| | | antwort.status(404).json({ fehler: 'Software nicht gefunden' }); |
| | | return; |
| | | } |
| | | void gleicheSoftwareAb(aktualisiert); // Quelle könnte geändert sein â neu holen |
| | | antwort.json(aktualisiert); |
| | | }); |
| | | |
| | | anwendung.delete('/api/software/:id', nurLokal, (anfrage, antwort) => { |
| | | const id = Number(anfrage.params.id); |
| | | const geloescht = softwareLoeschen(id); |
| | | if (geloescht) { |
| | | loescheGeheimnisse(id); |
| | | } |
| | | antwort.status(geloescht ? 204 : 404).end(); |
| | | }); |
| | | |
| | | // --- Bezugsquellen-Abgleich (nur lokal am Server) --- |
| | | |
| | | anwendung.post('/api/software/:id/abgleichen', nurLokal, async (anfrage, antwort) => { |
| | | const software = softwareNachId(Number(anfrage.params.id)); |
| | | if (!software) { |
| | | antwort.status(404).json({ fehler: 'Software nicht gefunden' }); |
| | | return; |
| | | } |
| | | await gleicheSoftwareAb(software); |
| | | antwort.json(softwareNachId(software.id)); |
| | | }); |
| | | |
| | | anwendung.post('/api/abgleich/alle', nurLokal, async (_anfrage, antwort) => { |
| | | await gleicheAlleAb(alleSoftware()); |
| | | antwort.json(alleSoftware()); |
| | | }); |
| | | |
| | | // --- Datei von Hand hinterlegen (Quellentyp âmanuell", nur lokal am Server) --- |
| | | |
| | | anwendung.post('/api/software/:id/datei', nurLokal, (anfrage, antwort) => { |
| | | const software = softwareNachId(Number(anfrage.params.id)); |
| | | if (!software) { |
| | | antwort.status(404).json({ fehler: 'Software nicht gefunden' }); |
| | | return; |
| | | } |
| | | // basename schützt davor, über den Dateinamen aus dem Ablageordner auszubrechen. |
| | | const dateiname = path.basename(String(anfrage.query.dateiname ?? '')).trim(); |
| | | if (!dateiname) { |
| | | antwort.status(400).json({ fehler: 'Dateiname fehlt' }); |
| | | return; |
| | | } |
| | | const zielordner = path.join(DATEIEN_ORDNER, String(software.id)); |
| | | fs.rmSync(zielordner, { recursive: true, force: true }); // nur eine aktuelle Datei je Software |
| | | fs.mkdirSync(zielordner, { recursive: true }); |
| | | |
| | | const zielpfad = path.join(zielordner, dateiname); |
| | | const zielDatei = fs.createWriteStream(zielpfad); |
| | | anfrage.pipe(zielDatei); |
| | | zielDatei.on('finish', () => { |
| | | void (async () => { |
| | | const befund = await pruefeSignatur(zielpfad); |
| | | abgleichSpeichern(software.id, { |
| | | version: software.version, |
| | | dateiname, |
| | | erfolg: true, |
| | | meldung: 'Manuell hinterlegt.', |
| | | geaendert: true, |
| | | signaturStatus: befund.status, |
| | | signaturHerausgeber: befund.herausgeber, |
| | | veroeffentlichtAm: software.veroeffentlichtAm, |
| | | benoetigteGeheimnisse: software.benoetigteGeheimnisse, |
| | | }); |
| | | antwort.json(softwareNachId(software.id)); |
| | | })(); |
| | | }); |
| | | zielDatei.on('error', (fehler) => { |
| | | antwort.status(500).json({ fehler: `Datei konnte nicht gespeichert werden: ${fehler.message}` }); |
| | | }); |
| | | }); |
| | | |
| | | // --- Bau-Geheimnisse (nur lokal am Server; Werte verlassen den Server nie) --- |
| | | |
| | | anwendung.get('/api/software/:id/geheimnisse', nurLokal, (anfrage, antwort) => { |
| | | antwort.json(uebersichtGeheimnisse(Number(anfrage.params.id))); |
| | | }); |
| | | |
| | | anwendung.put('/api/software/:id/geheimnisse', nurLokal, (anfrage, antwort) => { |
| | | const eingehende = Array.isArray(anfrage.body) ? anfrage.body : []; |
| | | antwort.json(speichereGeheimnisse(Number(anfrage.params.id), eingehende)); |
| | | }); |
| | | |
| | | // --- Umzug auf einen anderen Rechner (nur lokal am Server) --- |
| | | |
| | | anwendung.get('/api/export', nurLokal, (anfrage, antwort) => { |
| | | const mitGeheimnissen = anfrage.query.geheimnisse === 'ja'; |
| | | const paket = erstelleUmzugsPaket(mitGeheimnissen); |
| | | const namenszusatz = mitGeheimnissen ? '-mit-schluesseln' : ''; |
| | | antwort.setHeader('Content-Disposition', `attachment; filename="software-bibliothek${namenszusatz}.json"`); |
| | | antwort.type('application/json').send(JSON.stringify(paket, null, 2)); |
| | | }); |
| | | |
| | | anwendung.post('/api/import', nurLokal, (anfrage, antwort) => { |
| | | try { |
| | | antwort.json(uebernehmeUmzugsPaket(anfrage.body)); |
| | | } catch (fehler) { |
| | | antwort.status(400).json({ fehler: fehler instanceof Error ? fehler.message : 'Paket konnte nicht eingelesen werden.' }); |
| | | } |
| | | }); |
| | | |
| | | // --- Einstellungen (nur lokal am Server) --- |
| | | |
| | | anwendung.get('/api/einstellungen', nurLokal, (_anfrage, antwort) => { |
| | | antwort.json({ |
| | | einstellungen: aktuelleEinstellungen(), |
| | | konfigurationsDatei: konfigurationsDatei(), |
| | | effektiverDatenOrdner: effektiverDatenOrdner(), |
| | | laufenderPort: PORT, |
| | | }); |
| | | }); |
| | | |
| | | anwendung.put('/api/einstellungen', nurLokal, (anfrage, antwort) => { |
| | | const gespeicherte = speichereEinstellungen(anfrage.body ?? {}); |
| | | antwort.json({ |
| | | einstellungen: gespeicherte, |
| | | konfigurationsDatei: konfigurationsDatei(), |
| | | // Nach dem Speichern gilt der neue Ordner erst beim nächsten Start â |
| | | // die Datenbank ist bereits geöffnet. |
| | | effektiverDatenOrdner: effektiverDatenOrdner(), |
| | | laufenderPort: PORT, |
| | | }); |
| | | }); |
| | | |
| | | // --- Zugriffsprotokoll (nur lokal am Server) --- |
| | | |
| | | anwendung.get('/api/zugriffslog', nurLokal, (_anfrage, antwort) => { |
| | | antwort.json(alleZugriffe()); |
| | | }); |
| | | |
| | | // --- Hilfsfunktionen --- |
| | | |
| | | const ERLAUBTE_QUELLEN: Array<QuelleTyp | ''> = ['', 'direkt', 'github', 'winget', 'svn', 'git', 'manuell']; |
| | | |
| | | /** Säubert die Eingabe und stellt einen gültigen Quell-Typ sicher. */ |
| | | function normalisiereEingabe(koerper: Partial<SoftwareEingabe>): SoftwareEingabe { |
| | | const typ = ERLAUBTE_QUELLEN.includes((koerper.quelleTyp ?? '') as QuelleTyp) ? (koerper.quelleTyp ?? '') : ''; |
| | | return { |
| | | name: (koerper.name ?? '').trim(), |
| | | beschreibung: (koerper.beschreibung ?? '').trim(), |
| | | elternId: Number(koerper.elternId ?? 0) || 0, |
| | | quelleTyp: typ as QuelleTyp | '', |
| | | quelleWert: (koerper.quelleWert ?? '').trim(), |
| | | bauBefehl: (koerper.bauBefehl ?? '').trim(), |
| | | bauErgebnis: (koerper.bauErgebnis ?? '').trim(), |
| | | einstufung: EINSTUFUNGEN.some((eintrag) => eintrag.id === koerper.einstufung) ? (koerper.einstufung as Einstufung) : 'lizenzfrei', |
| | | programmart: PROGRAMMARTEN.some((eintrag) => eintrag.id === koerper.programmart) ? (koerper.programmart as Programmart) : 'unbestimmt', |
| | | hinweis: (koerper.hinweis ?? '').trim(), |
| | | erwarteterHerausgeber: (koerper.erwarteterHerausgeber ?? '').trim(), |
| | | anleitungUrl: (koerper.anleitungUrl ?? '').trim(), |
| | | }; |
| | | } |
| | | |
| | | /** Ersetzt Zeichen, die in Datei-/Ordnernamen nicht zulässig sind. */ |
| | | function dateinameSaeubern(name: string): string { |
| | | return name.replace(/[\\/:*?"<>|]/g, '_').trim() || 'Programm'; |
| | | } |
| | | |
| | | /** Schreibt einen Download in das Zugriffsprotokoll (IP, Reverse-DNS-Rechnername, angegebener Name). */ |
| | | async function protokolliereDownload(anfrage: express.Request, softwareName: string): Promise<void> { |
| | | const ip = anfrageIp(anfrage); |
| | | let rechnername = ''; |
| | | try { |
| | | const namen = await dns.promises.reverse(ip); |
| | | rechnername = namen[0] ?? ''; |
| | | } catch { |
| | | // Kein Reverse-DNS-Eintrag verfügbar â Rechnername bleibt leer. |
| | | } |
| | | const angegebenerName = typeof anfrage.query.name === 'string' ? anfrage.query.name : ''; |
| | | zugriffProtokollieren({ |
| | | zeitpunkt: new Date().toISOString(), |
| | | ipAdresse: ip, |
| | | rechnername, |
| | | angegebenerName, |
| | | aktion: `Heruntergeladen: ${softwareName}`, |
| | | }); |
| | | } |
| | | |
| | | /** Liest ein in die exe eingebettetes Asset als Puffer (undefined, falls nicht vorhanden). */ |
| | | function eingebettetesAsset(name: string): Buffer | undefined { |
| | | try { |
| | | const sea = require('node:sea') as { getRawAsset(schluessel: string): ArrayBuffer }; |
| | | return Buffer.from(sea.getRawAsset(name)); |
| | | } catch { |
| | | return undefined; // Asset nicht eingebettet |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Liefert die gebaute Oberfläche aus: als exe aus den eingebetteten Assets, |
| | | * sonst aus dem dist-Ordner des Client-Pakets. Muss nach den API-Routen stehen. |
| | | */ |
| | | function richteOberflaecheEin(): void { |
| | | if (istEinzeldatei()) { |
| | | anwendung.use((anfrage, antwort, next) => { |
| | | if (anfrage.path.startsWith('/api/')) { |
| | | next(); |
| | | return; |
| | | } |
| | | const gewuenscht = anfrage.path === '/' ? 'index.html' : anfrage.path.replace(/^\//, ''); |
| | | const inhalt = eingebettetesAsset(gewuenscht); |
| | | if (inhalt) { |
| | | antwort.type(path.extname(gewuenscht) || '.html').send(inhalt); |
| | | return; |
| | | } |
| | | const startseite = eingebettetesAsset('index.html'); |
| | | if (!startseite) { |
| | | next(); |
| | | return; |
| | | } |
| | | antwort.type('.html').send(startseite); |
| | | }); |
| | | return; |
| | | } |
| | | if (fs.existsSync(oberflaechenOrdner())) { |
| | | anwendung.use(express.static(oberflaechenOrdner())); |
| | | } |
| | | } |
| | | |
| | | richteOberflaecheEin(); |
| | | |
| | | // Täglicher Hintergrund-Abgleich aller Quellen. |
| | | setInterval(() => { |
| | | void gleicheAlleAb(alleSoftware()); |
| | | }, ABGLEICH_INTERVALL_MS); |
| | | |
| | | anwendung.listen(PORT, () => { |
| | | console.log(`Software-Bibliothek läuft auf http://localhost:${PORT}`); |
| | | console.log(`Daten: ${effektiverDatenOrdner()}`); |
| | | console.log(`Einstellungen: ${konfigurationsDatei()}`); |
| | | }); |
| Neue Datei |
| | |
| | | import * as fs from 'fs'; |
| | | import * as path from 'path'; |
| | | import type { Einstellungen } from 'bibliothek-types'; |
| | | import { anwendungsDatenOrdner } from './pfade'; |
| | | |
| | | /** |
| | | * Einstellungen liegen in `konfiguration.json` neben der Anwendung. Die Datei wird |
| | | * beim ersten Start mit Standardwerten angelegt und kann über die Oberfläche |
| | | * (nur lokal am Server) oder von Hand gepflegt werden. |
| | | * |
| | | * Umgebungsvariablen haben Vorrang â praktisch für abweichende Testläufe. |
| | | */ |
| | | |
| | | const STANDARD_EINSTELLUNGEN: Einstellungen = { |
| | | datenOrdner: '', // leer = Unterordner âdata" im Anwendungsdaten-Ordner |
| | | port: 3000, |
| | | gitBenutzer: '', // leer = SSH-Konfiguration des Dienstbenutzers entscheidet |
| | | gitSchluessel: '', |
| | | }; |
| | | |
| | | const konfigurationsPfad = path.join(anwendungsDatenOrdner(), 'konfiguration.json'); |
| | | |
| | | /** Liest die Konfigurationsdatei (und legt sie beim ersten Start an). */ |
| | | function leseOderErzeuge(): Einstellungen { |
| | | try { |
| | | if (fs.existsSync(konfigurationsPfad)) { |
| | | return normalisiere(JSON.parse(fs.readFileSync(konfigurationsPfad, 'utf8')) as Partial<Einstellungen>); |
| | | } |
| | | const standard = { ...STANDARD_EINSTELLUNGEN }; |
| | | schreibeDatei(standard); |
| | | return standard; |
| | | } catch (fehler) { |
| | | console.warn(`konfiguration.json konnte nicht gelesen werden, Standardwerte greifen: ${String(fehler)}`); |
| | | return { ...STANDARD_EINSTELLUNGEN }; |
| | | } |
| | | } |
| | | |
| | | // Beim Laden des Moduls einlesen, damit auch die Datenbank-Schicht schon den |
| | | // konfigurierten Ordner sieht (sie wird beim Start direkt initialisiert). |
| | | let gelesene: Einstellungen = leseOderErzeuge(); |
| | | |
| | | /** Speichert geänderte Einstellungen. Der Datenordner wirkt erst nach einem Neustart. */ |
| | | export function speichereEinstellungen(neue: Partial<Einstellungen>): Einstellungen { |
| | | gelesene = normalisiere({ ...gelesene, ...neue }); |
| | | schreibeDatei(gelesene); |
| | | return gelesene; |
| | | } |
| | | |
| | | export function aktuelleEinstellungen(): Einstellungen { |
| | | return gelesene; |
| | | } |
| | | |
| | | export function konfigurationsDatei(): string { |
| | | return konfigurationsPfad; |
| | | } |
| | | |
| | | /** Tatsächlich genutzter Datenordner â Umgebungsvariable vor Einstellung vor Standard. */ |
| | | export function effektiverDatenOrdner(): string { |
| | | const ausUmgebung = process.env.DATEN_ORDNER?.trim(); |
| | | if (ausUmgebung) { |
| | | return path.resolve(ausUmgebung); |
| | | } |
| | | if (gelesene.datenOrdner.trim()) { |
| | | return path.resolve(gelesene.datenOrdner.trim()); |
| | | } |
| | | return path.join(anwendungsDatenOrdner(), 'data'); |
| | | } |
| | | |
| | | /** Tatsächlich genutzter Port â Umgebungsvariable vor Einstellung vor Standard. */ |
| | | export function effektiverPort(): number { |
| | | const ausUmgebung = Number(process.env.PORT); |
| | | if (Number.isInteger(ausUmgebung) && ausUmgebung > 0) { |
| | | return ausUmgebung; |
| | | } |
| | | return gelesene.port; |
| | | } |
| | | |
| | | function normalisiere(eingabe: Partial<Einstellungen>): Einstellungen { |
| | | const port = Number(eingabe.port); |
| | | return { |
| | | datenOrdner: typeof eingabe.datenOrdner === 'string' ? eingabe.datenOrdner.trim() : '', |
| | | port: Number.isInteger(port) && port > 0 && port < 65536 ? port : STANDARD_EINSTELLUNGEN.port, |
| | | // Nur was als SSH-Benutzername taugt â schützt zugleich vor Einschleusen in die Befehlszeile. |
| | | gitBenutzer: /^[A-Za-z0-9._-]*$/.test(String(eingabe.gitBenutzer ?? '').trim()) ? String(eingabe.gitBenutzer ?? '').trim() : '', |
| | | gitSchluessel: String(eingabe.gitSchluessel ?? '').trim(), |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * Baut den SSH-Aufruf für Git aus den Einstellungen. Leer, wenn nichts gepflegt ist â |
| | | * dann greift die SSH-Konfiguration des Dienstbenutzers wie bisher. |
| | | */ |
| | | export function gitSshBefehl(): string { |
| | | const teile = ['ssh']; |
| | | if (gelesene.gitBenutzer) { |
| | | teile.push('-o', `User=${gelesene.gitBenutzer}`); |
| | | } |
| | | if (gelesene.gitSchluessel) { |
| | | teile.push('-i', `"${gelesene.gitSchluessel.replace(/\\/g, '/')}"`); |
| | | } |
| | | return teile.length > 1 ? teile.join(' ') : ''; |
| | | } |
| | | |
| | | function schreibeDatei(einstellungen: Einstellungen): void { |
| | | fs.mkdirSync(path.dirname(konfigurationsPfad), { recursive: true }); |
| | | fs.writeFileSync(konfigurationsPfad, `${JSON.stringify(einstellungen, null, 2)}\n`); |
| | | } |
| Neue Datei |
| | |
| | | import * as path from 'path'; |
| | | |
| | | /** Läuft die Anwendung als gebündelte Einzeldatei (Node SEA / exe)? */ |
| | | export function istEinzeldatei(): boolean { |
| | | try { |
| | | return (require('node:sea') as { isSea(): boolean }).isSea(); |
| | | } catch { |
| | | return false; // node:sea nicht verfügbar -> normaler Node-Start |
| | | } |
| | | } |
| | | |
| | | const ANWENDUNGSNAME = 'Software-Bibliothek'; |
| | | |
| | | /** |
| | | * Ordner für Konfiguration und Daten der Anwendung. |
| | | * |
| | | * Bewusst neutral unter `%ProgramData%` (üblicher Ort für maschinenweite |
| | | * Anwendungsdaten unter Windows) statt neben der exe oder gar im Quellcode- |
| | | * Verzeichnis: So liegen die Daten immer am selben Ort, egal wo die exe |
| | | * abgelegt oder wohin sie verschoben wird â und ein Entwicklungslauf |
| | | * verschmutzt das Projektverzeichnis nicht. |
| | | * |
| | | * Der Datenordner selbst lässt sich in den Einstellungen frei umlegen. |
| | | */ |
| | | export function anwendungsDatenOrdner(): string { |
| | | const programmDaten = process.env.ProgramData?.trim(); |
| | | if (programmDaten) { |
| | | return path.join(programmDaten, ANWENDUNGSNAME); |
| | | } |
| | | // Kein Windows bzw. Variable nicht gesetzt: neben der Anwendung ablegen. |
| | | return path.join(anwendungsOrdner(), `.${ANWENDUNGSNAME.toLowerCase()}`); |
| | | } |
| | | |
| | | /** Ordner der Anwendung selbst: als exe der Ordner neben der exe, sonst die Projekt-Wurzel. */ |
| | | export function anwendungsOrdner(): string { |
| | | if (istEinzeldatei()) { |
| | | return path.dirname(process.execPath); |
| | | } |
| | | return path.join(__dirname, '..', '..', '..'); |
| | | } |
| | | |
| | | /** Ordner mit der gebauten Oberfläche (nur im Nicht-exe-Betrieb genutzt). */ |
| | | export function oberflaechenOrdner(): string { |
| | | return path.join(__dirname, '..', '..', 'bibliothek-client', 'dist'); |
| | | } |
| Neue Datei |
| | |
| | | import { execFile } from 'child_process'; |
| | | import { promisify } from 'util'; |
| | | |
| | | const execFileAsync = promisify(execFile); |
| | | |
| | | export interface Signaturbefund { |
| | | /** Windows-Status: Valid, NotSigned, UnknownError, HashMismatch ⦠bzw. ânicht geprüft". */ |
| | | status: string; |
| | | /** Anzeigename des Signaturgebers (leer, wenn unsigniert). */ |
| | | herausgeber: string; |
| | | } |
| | | |
| | | const NICHT_GEPRUEFT: Signaturbefund = { status: 'nicht geprüft', herausgeber: '' }; |
| | | |
| | | /** |
| | | * Prüft die Windows-Signatur (Authenticode) einer Datei. |
| | | * |
| | | * Bewusst nur erfassend, nicht blockierend: Manche Programme werden legitim |
| | | * unsigniert ausgeliefert (z. B. 7-Zip). Erst ein hinterlegter Erwartungswert |
| | | * macht daraus eine Zusicherung â siehe `weichtHerausgeberAb`. |
| | | * |
| | | * Der Dateipfad geht über eine Umgebungsvariable an PowerShell, damit |
| | | * Anführungszeichen im Pfad nichts zerbrechen können. |
| | | */ |
| | | export async function pruefeSignatur(dateipfad: string): Promise<Signaturbefund> { |
| | | if (process.platform !== 'win32') { |
| | | return NICHT_GEPRUEFT; |
| | | } |
| | | const skript = [ |
| | | '$befund = Get-AuthenticodeSignature -LiteralPath $env:ZU_PRUEFENDE_DATEI;', |
| | | '$name = if ($befund.SignerCertificate) { $befund.SignerCertificate.Subject } else { "" };', |
| | | 'ConvertTo-Json -Compress @{ status = $befund.Status.ToString(); herausgeber = $name }', |
| | | ].join(' '); |
| | | try { |
| | | const { stdout } = await execFileAsync('powershell', ['-NoProfile', '-NonInteractive', '-Command', skript], { |
| | | timeout: 60000, |
| | | windowsHide: true, |
| | | env: { ...process.env, ZU_PRUEFENDE_DATEI: dateipfad }, |
| | | }); |
| | | const roh = JSON.parse(stdout) as { status?: string; herausgeber?: string }; |
| | | return { |
| | | status: roh.status ?? 'nicht geprüft', |
| | | herausgeber: lesbarerHerausgeber(roh.herausgeber ?? ''), |
| | | }; |
| | | } catch { |
| | | return NICHT_GEPRUEFT; // Prüfung selbst fehlgeschlagen â kein Grund, den Abgleich zu verwerfen |
| | | } |
| | | } |
| | | |
| | | /** Zieht aus dem Zertifikats-Betreff den sprechenden Teil (CN bzw. erstes Feld). */ |
| | | function lesbarerHerausgeber(betreff: string): string { |
| | | if (!betreff) { |
| | | return ''; |
| | | } |
| | | const felder = betreff.split(',').map((feld) => feld.trim()); |
| | | const gemeinsamerName = felder.find((feld) => feld.startsWith('CN=')); |
| | | return (gemeinsamerName ?? felder[0]).replace(/^CN=/, '').replace(/^"|"$/g, ''); |
| | | } |
| | | |
| | | /** Weicht der gefundene Herausgeber von der Erwartung ab? Ohne Erwartung nie. */ |
| | | export function weichtHerausgeberAb(erwartet: string, gefunden: string): boolean { |
| | | if (!erwartet.trim()) { |
| | | return false; |
| | | } |
| | | return !gefunden.toLowerCase().includes(erwartet.trim().toLowerCase()); |
| | | } |
| Neue Datei |
| | |
| | | import type { Einstellungen, Software } from 'bibliothek-types'; |
| | | import { alleSoftware, softwareAnlegen, softwareLoeschen, umzugsAngabenSetzen } from './datenbank'; |
| | | import { aktuelleEinstellungen, speichereEinstellungen } from './konfiguration'; |
| | | import { ladeGeheimnisse, loescheGeheimnisse, speichereGeheimnisse, type Geheimniswert } from './geheimnisse'; |
| | | |
| | | /** |
| | | * Umzug auf einen anderen Rechner. |
| | | * |
| | | * Bewusst als lesbares JSON und nicht als Ordner-Kopie: |
| | | * - Die geholten Dateien (mehrere hundert MB) und die Git-Arbeitskopien (GB) |
| | | * sind jederzeit reproduzierbar â ein Abgleich holt sie neu. Nur Katalog, |
| | | * Einstellungen und Geheimnisse sind unersetzlich, und das sind wenige KB. |
| | | * - Die Datenbank im laufenden Betrieb zu kopieren wäre unsicher: die jüngsten |
| | | * Ãnderungen stehen im WAL und fehlten in der Kopie. |
| | | * - JSON übersteht Schema-Ãnderungen: beim Einlesen werden Felder zugeordnet, |
| | | * statt eine Binärdatei einer bestimmten Version vorauszusetzen. |
| | | */ |
| | | |
| | | /** Aktuelle Fassung des Austauschformats â erlaubt später sauberes Nachziehen. */ |
| | | const FORMAT_FASSUNG = 1; |
| | | |
| | | export interface UmzugsPaket { |
| | | fassung: number; |
| | | erstelltAm: string; |
| | | einstellungen: Einstellungen; |
| | | software: Software[]; |
| | | /** Nur enthalten, wenn ausdrücklich mit exportiert (enthält Klartext-Schlüssel!). */ |
| | | geheimnisse?: Record<string, Geheimniswert[]>; |
| | | } |
| | | |
| | | export function erstelleUmzugsPaket(mitGeheimnissen: boolean): UmzugsPaket { |
| | | const eintraege = alleSoftware(); |
| | | const paket: UmzugsPaket = { |
| | | fassung: FORMAT_FASSUNG, |
| | | erstelltAm: new Date().toISOString(), |
| | | einstellungen: aktuelleEinstellungen(), |
| | | software: eintraege, |
| | | }; |
| | | if (mitGeheimnissen) { |
| | | paket.geheimnisse = {}; |
| | | for (const eintrag of eintraege) { |
| | | const werte = ladeGeheimnisse(eintrag.id); |
| | | if (werte.length > 0) { |
| | | paket.geheimnisse[String(eintrag.id)] = werte; |
| | | } |
| | | } |
| | | } |
| | | return paket; |
| | | } |
| | | |
| | | export interface UmzugsErgebnis { |
| | | uebernommen: number; |
| | | mitGeheimnissen: number; |
| | | hinweis: string; |
| | | } |
| | | |
| | | /** |
| | | * Liest ein Paket ein und ersetzt den bisherigen Katalog vollständig. |
| | | * Die eigentlichen Programm-Dateien kommen nicht mit â sie werden beim |
| | | * nächsten Abgleich neu geholt bzw. gebaut. |
| | | */ |
| | | export function uebernehmeUmzugsPaket(paket: UmzugsPaket): UmzugsErgebnis { |
| | | if (!paket || typeof paket !== 'object' || !Array.isArray(paket.software)) { |
| | | throw new Error('Die Datei ist kein gültiges Umzugs-Paket.'); |
| | | } |
| | | if (paket.fassung > FORMAT_FASSUNG) { |
| | | throw new Error(`Das Paket stammt aus einer neueren Fassung (${paket.fassung}) â bitte die Anwendung aktualisieren.`); |
| | | } |
| | | |
| | | // Bisherigen Bestand räumen, damit keine Mischung aus altem und neuem Stand entsteht. |
| | | for (const vorhandene of alleSoftware()) { |
| | | loescheGeheimnisse(vorhandene.id); |
| | | softwareLoeschen(vorhandene.id); |
| | | } |
| | | |
| | | // Die Ids ändern sich beim Neuanlegen â für die Eltern-Zuordnung mitführen. |
| | | const idZuordnung = new Map<number, number>(); |
| | | for (const eintrag of paket.software.filter((eintrag) => !eintrag.elternId)) { |
| | | idZuordnung.set(eintrag.id, uebernehmeEintrag(eintrag, 0).id); |
| | | } |
| | | for (const eintrag of paket.software.filter((eintrag) => eintrag.elternId)) { |
| | | idZuordnung.set(eintrag.id, uebernehmeEintrag(eintrag, idZuordnung.get(eintrag.elternId) ?? 0).id); |
| | | } |
| | | |
| | | let mitGeheimnissen = 0; |
| | | for (const [alteId, werte] of Object.entries(paket.geheimnisse ?? {})) { |
| | | const neueId = idZuordnung.get(Number(alteId)); |
| | | if (neueId) { |
| | | speichereGeheimnisse(neueId, werte); |
| | | mitGeheimnissen++; |
| | | } |
| | | } |
| | | |
| | | if (paket.einstellungen) { |
| | | // Datenordner und Port gehören zum Zielrechner und werden bewusst nicht überschrieben. |
| | | speichereEinstellungen({ |
| | | gitBenutzer: paket.einstellungen.gitBenutzer ?? '', |
| | | gitSchluessel: paket.einstellungen.gitSchluessel ?? '', |
| | | }); |
| | | } |
| | | |
| | | return { |
| | | uebernommen: idZuordnung.size, |
| | | mitGeheimnissen, |
| | | hinweis: 'Die Programm-Dateien wurden nicht mit übertragen â sie werden beim nächsten Abgleich neu geholt bzw. gebaut.', |
| | | }; |
| | | } |
| | | |
| | | /** Legt einen Eintrag neu an und stellt den bekannten Abgleich-Stand wieder her. */ |
| | | function uebernehmeEintrag(eintrag: Software, elternId: number): Software { |
| | | const angelegt = softwareAnlegen({ |
| | | name: eintrag.name ?? '', |
| | | beschreibung: eintrag.beschreibung ?? '', |
| | | elternId, |
| | | quelleTyp: eintrag.quelleTyp ?? '', |
| | | quelleWert: eintrag.quelleWert ?? '', |
| | | bauBefehl: eintrag.bauBefehl ?? '', |
| | | bauErgebnis: eintrag.bauErgebnis ?? '', |
| | | einstufung: eintrag.einstufung ?? 'lizenzfrei', |
| | | programmart: eintrag.programmart ?? 'unbestimmt', |
| | | hinweis: eintrag.hinweis ?? '', |
| | | anleitungUrl: eintrag.anleitungUrl ?? '', |
| | | erwarteterHerausgeber: eintrag.erwarteterHerausgeber ?? '', |
| | | }); |
| | | // Version und Datei bleiben leer (Standard beim Anlegen): Die Datei fehlt auf dem |
| | | // neuen Rechner noch, der nächste Abgleich soll sie wirklich holen. Bewusst KEIN |
| | | // fehlgeschlagener Abgleich â ânoch nicht geholt" ist kein Problemfall und soll |
| | | // nicht in der Warnleiste auftauchen. |
| | | umzugsAngabenSetzen(angelegt.id, eintrag.veroeffentlichtAm ?? '', eintrag.benoetigteGeheimnisse ?? []); |
| | | return angelegt; |
| | | } |
| Neue Datei |
| | |
| | | /** |
| | | * Node meldet `node:sqlite` beim Start als experimentelles Feature. Für den Betrieb |
| | | * ist das ohne Belang, im Konsolenfenster der Anwendung verunsichert die Meldung aber. |
| | | * Alle anderen Warnungen werden weiterhin ausgegeben. |
| | | * |
| | | * Muss als erstes Modul importiert werden, damit der Filter vor dem Laden von |
| | | * `node:sqlite` steht. |
| | | */ |
| | | process.removeAllListeners('warning'); |
| | | process.on('warning', (warnung: Error) => { |
| | | if (warnung.name !== 'ExperimentalWarning') { |
| | | console.warn(warnung); |
| | | } |
| | | }); |
| Neue Datei |
| | |
| | | /** |
| | | * Minimaler ZIP-Schreiber (Speicher-Methode, ohne Komprimierung). |
| | | * |
| | | * Bewusst ohne Fremdbibliothek: Installer und Archive sind bereits komprimiert, |
| | | * erneutes Packen brächte nichts und kostet nur Zeit. Damit bleibt der Server |
| | | * ohne Zusatz-Abhängigkeit. |
| | | * |
| | | * Grenze: Das klassische ZIP-Format adressiert mit 32 Bit, ein Archiv (bzw. eine |
| | | * Einzeldatei darin) darf also 4 GB nicht überschreiten â ZIP64 ist nicht umgesetzt. |
| | | */ |
| | | |
| | | const CRC_TABELLE = erzeugeCrcTabelle(); |
| | | |
| | | function erzeugeCrcTabelle(): Uint32Array { |
| | | const tabelle = new Uint32Array(256); |
| | | for (let index = 0; index < 256; index++) { |
| | | let wert = index; |
| | | for (let bit = 0; bit < 8; bit++) { |
| | | wert = wert & 1 ? 0xedb88320 ^ (wert >>> 1) : wert >>> 1; |
| | | } |
| | | tabelle[index] = wert >>> 0; |
| | | } |
| | | return tabelle; |
| | | } |
| | | |
| | | function crc32(daten: Buffer): number { |
| | | let pruefsumme = 0xffffffff; |
| | | for (const byte of daten) { |
| | | pruefsumme = CRC_TABELLE[(pruefsumme ^ byte) & 0xff] ^ (pruefsumme >>> 8); |
| | | } |
| | | return (pruefsumme ^ 0xffffffff) >>> 0; |
| | | } |
| | | |
| | | /** Wandelt einen Zeitpunkt in das von ZIP verwendete DOS-Format (Datum + Uhrzeit). */ |
| | | function dosZeitstempel(zeitpunkt: Date): { zeit: number; datum: number } { |
| | | const zeit = (zeitpunkt.getHours() << 11) | (zeitpunkt.getMinutes() << 5) | (Math.floor(zeitpunkt.getSeconds() / 2) & 0x1f); |
| | | const datum = ((zeitpunkt.getFullYear() - 1980) << 9) | ((zeitpunkt.getMonth() + 1) << 5) | zeitpunkt.getDate(); |
| | | return { zeit, datum }; |
| | | } |
| | | |
| | | export interface ZipEintrag { |
| | | dateiname: string; |
| | | inhalt: Buffer; |
| | | } |
| | | |
| | | /** Packt die Einträge zu einem ZIP-Archiv (ohne Komprimierung). */ |
| | | export function erzeugeZip(eintraege: ZipEintrag[], zeitpunkt: Date = new Date()): Buffer { |
| | | const { zeit, datum } = dosZeitstempel(zeitpunkt); |
| | | const lokaleBloecke: Buffer[] = []; |
| | | const zentralBloecke: Buffer[] = []; |
| | | let versatz = 0; |
| | | |
| | | for (const eintrag of eintraege) { |
| | | const nameBytes = Buffer.from(eintrag.dateiname, 'utf8'); |
| | | const pruefsumme = crc32(eintrag.inhalt); |
| | | const groesse = eintrag.inhalt.length; |
| | | |
| | | const lokalerKopf = Buffer.alloc(30); |
| | | lokalerKopf.writeUInt32LE(0x04034b50, 0); // Signatur |
| | | lokalerKopf.writeUInt16LE(20, 4); // benötigte Version |
| | | lokalerKopf.writeUInt16LE(0x0800, 6); // Flags: Dateiname ist UTF-8 |
| | | lokalerKopf.writeUInt16LE(0, 8); // Methode 0 = gespeichert |
| | | lokalerKopf.writeUInt16LE(zeit, 10); |
| | | lokalerKopf.writeUInt16LE(datum, 12); |
| | | lokalerKopf.writeUInt32LE(pruefsumme, 14); |
| | | lokalerKopf.writeUInt32LE(groesse, 18); // komprimierte GröÃe |
| | | lokalerKopf.writeUInt32LE(groesse, 22); // OriginalgröÃe |
| | | lokalerKopf.writeUInt16LE(nameBytes.length, 26); |
| | | lokalerKopf.writeUInt16LE(0, 28); // keine Extra-Felder |
| | | lokaleBloecke.push(lokalerKopf, nameBytes, eintrag.inhalt); |
| | | |
| | | const zentralKopf = Buffer.alloc(46); |
| | | zentralKopf.writeUInt32LE(0x02014b50, 0); // Signatur |
| | | zentralKopf.writeUInt16LE(20, 4); // erstellt mit Version |
| | | zentralKopf.writeUInt16LE(20, 6); // benötigte Version |
| | | zentralKopf.writeUInt16LE(0x0800, 8); |
| | | zentralKopf.writeUInt16LE(0, 10); |
| | | zentralKopf.writeUInt16LE(zeit, 12); |
| | | zentralKopf.writeUInt16LE(datum, 14); |
| | | zentralKopf.writeUInt32LE(pruefsumme, 16); |
| | | zentralKopf.writeUInt32LE(groesse, 20); |
| | | zentralKopf.writeUInt32LE(groesse, 24); |
| | | zentralKopf.writeUInt16LE(nameBytes.length, 28); |
| | | zentralKopf.writeUInt16LE(0, 30); // Extra |
| | | zentralKopf.writeUInt16LE(0, 32); // Kommentar |
| | | zentralKopf.writeUInt16LE(0, 34); // Datenträger |
| | | zentralKopf.writeUInt16LE(0, 36); // interne Attribute |
| | | zentralKopf.writeUInt32LE(0, 38); // externe Attribute |
| | | zentralKopf.writeUInt32LE(versatz, 42); // Position des lokalen Kopfes |
| | | zentralBloecke.push(zentralKopf, nameBytes); |
| | | |
| | | versatz += lokalerKopf.length + nameBytes.length + groesse; |
| | | } |
| | | |
| | | const zentralVerzeichnis = Buffer.concat(zentralBloecke); |
| | | const abschluss = Buffer.alloc(22); |
| | | abschluss.writeUInt32LE(0x06054b50, 0); // Signatur |
| | | abschluss.writeUInt16LE(0, 4); // Datenträger |
| | | abschluss.writeUInt16LE(0, 6); // Datenträger mit Verzeichnis |
| | | abschluss.writeUInt16LE(eintraege.length, 8); |
| | | abschluss.writeUInt16LE(eintraege.length, 10); |
| | | abschluss.writeUInt32LE(zentralVerzeichnis.length, 12); |
| | | abschluss.writeUInt32LE(versatz, 16); // Beginn des Zentralverzeichnisses |
| | | abschluss.writeUInt16LE(0, 20); // kein Kommentar |
| | | |
| | | return Buffer.concat([...lokaleBloecke, zentralVerzeichnis, abschluss]); |
| | | } |
| Neue Datei |
| | |
| | | { |
| | | "compilerOptions": { |
| | | "target": "es2021", |
| | | "module": "commonjs", |
| | | "moduleResolution": "node", |
| | | "rootDir": "./src", |
| | | "outDir": "./build", |
| | | "strict": true, |
| | | "esModuleInterop": true, |
| | | "skipLibCheck": true, |
| | | "forceConsistentCasingInFileNames": true |
| | | }, |
| | | "exclude": ["node_modules"] |
| | | } |
| Neue Datei |
| | |
| | | { |
| | | "name": "bibliothek-types", |
| | | "version": "0.1.0", |
| | | "main": "build/index.js", |
| | | "types": "build/index.d.ts", |
| | | "files": ["build"], |
| | | "scripts": { |
| | | "build": "tsc" |
| | | } |
| | | } |
| Neue Datei |
| | |
| | | /** Geteilte Typen zwischen Server und Client. */ |
| | | |
| | | /** |
| | | * Bezugsquelle, aus der die Datei einer Software geholt und aktuell gehalten wird. |
| | | * `manuell` bedeutet: die Datei wird von Hand hochgeladen und nicht automatisch aktualisiert. |
| | | */ |
| | | export type QuelleTyp = 'direkt' | 'github' | 'winget' | 'svn' | 'git' | 'manuell'; |
| | | |
| | | /** Einstufung einer Software â gliedert den Katalog und steuert die Hinweise. */ |
| | | export type Einstufung = 'lizenzfrei' | 'lizenzpflichtig' | 'intern'; |
| | | |
| | | /** Alle Einstufungen mit Anzeigetext, in der Reihenfolge der Katalog-Reiter. */ |
| | | export const EINSTUFUNGEN: Array<{ id: Einstufung; text: string }> = [ |
| | | { id: 'lizenzfrei', text: 'Lizenzfrei' }, |
| | | { id: 'lizenzpflichtig', text: 'Lizenzpflichtig' }, |
| | | { id: 'intern', text: 'Intern' }, |
| | | ]; |
| | | |
| | | /** |
| | | * Wie das Programm auf den Rechner kommt. `unbestimmt` ist der Ausgangswert â |
| | | * ein Eintrag muss nicht sofort eingeordnet werden. |
| | | */ |
| | | export type Programmart = 'unbestimmt' | 'installer' | 'eigenstaendig'; |
| | | |
| | | /** Alle Programmarten mit Anzeigetext, in der Reihenfolge des Auswahlfelds. */ |
| | | export const PROGRAMMARTEN: Array<{ id: Programmart; text: string }> = [ |
| | | { id: 'unbestimmt', text: 'Unbestimmt' }, |
| | | { id: 'installer', text: 'Installationsprogramm' }, |
| | | { id: 'eigenstaendig', text: 'Eigenständiges Programm' }, |
| | | ]; |
| | | |
| | | /** Ein Bau-Geheimnis, das ein Repository für seinen Bau anfordert. */ |
| | | export interface GeheimnisBedarf { |
| | | /** Name der Umgebungsvariablen, z. B. `ANTHROPIC_API_KEY`. */ |
| | | name: string; |
| | | beschreibung: string; |
| | | } |
| | | |
| | | /** Stand eines Bau-Geheimnisses in der Oberfläche â nie der Wert selbst. */ |
| | | export interface GeheimnisUebersicht { |
| | | name: string; |
| | | hinterlegt: boolean; |
| | | zeichen: number; |
| | | } |
| | | |
| | | /** Ein Software-Eintrag im Katalog. */ |
| | | export interface Software { |
| | | id: number; |
| | | name: string; |
| | | beschreibung: string; |
| | | /** Id der übergeordneten Software, falls dieser Eintrag ein Zusatzinhalt dazu ist (0 = eigenständiges Tool). */ |
| | | elternId: number; |
| | | /** Typ der Bezugsquelle (leer, falls noch keine gesetzt). */ |
| | | quelleTyp: QuelleTyp | ''; |
| | | /** Quell-spezifische Angabe: URL (direkt), `besitzer/repo` (github), Paket-ID (winget), Repo-URL (svn/git). Bei `manuell` leer. */ |
| | | quelleWert: string; |
| | | /** Nur bei Quelle `git`: Befehl, der das Tool baut (im Wurzelverzeichnis der Arbeitskopie ausgeführt). */ |
| | | bauBefehl: string; |
| | | /** Nur bei Quelle `git`: Pfad des Bau-Ergebnisses relativ zur Arbeitskopie, `*` im Dateinamen erlaubt. */ |
| | | bauErgebnis: string; |
| | | /** Einstufung: lizenzfrei, lizenzpflichtig (Schlüssel über den Support) oder intern (Eigenentwicklung). */ |
| | | einstufung: Einstufung; |
| | | /** Installationsprogramm oder eigenständige Datei â bestimmt den Hinweis am Eintrag. */ |
| | | programmart: Programmart; |
| | | /** Freier Hinweis am Eintrag, z. B. âbraucht Adminrechte". Leer = keiner. */ |
| | | hinweis: string; |
| | | /** Link zur Anleitung (WikiJS intern oder extern), leer falls keine hinterlegt. */ |
| | | anleitungUrl: string; |
| | | /** Aktuelle Versions-/Stand-Kennung, vom Abgleich gesetzt (Release-Tag, ETag, winget-Version, SVN-Revision). */ |
| | | version: string; |
| | | /** Aktuell bereitgestellte Datei im Ablageordner (leer, solange noch nichts vorliegt). */ |
| | | dateiname: string; |
| | | /** Zeitpunkt, zu dem die bereitgestellte Datei zuletzt tatsächlich erneuert wurde (unser Abruf). */ |
| | | dateiStandVom: string; |
| | | /** Veröffentlichungsdatum beim Anbieter, sofern die Quelle es mitliefert. */ |
| | | veroeffentlichtAm: string; |
| | | /** Windows-Signaturstatus der bereitgestellten Datei (Valid, NotSigned, â¦). */ |
| | | signaturStatus: string; |
| | | /** Signaturgeber der bereitgestellten Datei (leer, wenn unsigniert). */ |
| | | signaturHerausgeber: string; |
| | | /** Optional: erwarteter Signaturgeber. Weicht er ab, wird die Datei nicht bereitgestellt. */ |
| | | erwarteterHerausgeber: string; |
| | | /** Vom Repository angeforderte Bau-Geheimnisse (aus `software-bibliothek.json`). */ |
| | | benoetigteGeheimnisse: GeheimnisBedarf[]; |
| | | /** Zeitpunkt des letzten Abgleichs (leer, falls nie geprüft). */ |
| | | letzterAbgleich: string; |
| | | /** War der letzte Abgleich erfolgreich? */ |
| | | abgleichErfolg: boolean; |
| | | /** Meldung des letzten Abgleichs (z.B. âAktualisiert." oder ein Fehlertext). */ |
| | | abgleichMeldung: string; |
| | | erstelltAm: string; |
| | | aktualisiertAm: string; |
| | | } |
| | | |
| | | /** Nutzlast beim Anlegen/Bearbeiten (server-verwaltete Felder wie Version/Datei/Abgleich ausgenommen). */ |
| | | export interface SoftwareEingabe { |
| | | name: string; |
| | | beschreibung: string; |
| | | elternId: number; |
| | | quelleTyp: QuelleTyp | ''; |
| | | quelleWert: string; |
| | | bauBefehl: string; |
| | | bauErgebnis: string; |
| | | einstufung: Einstufung; |
| | | programmart: Programmart; |
| | | hinweis: string; |
| | | anleitungUrl: string; |
| | | erwarteterHerausgeber: string; |
| | | } |
| | | |
| | | /** Ergebnis eines Bezugsquellen-Abgleichs, das in die DB zurückgeschrieben wird. */ |
| | | export interface AbgleichErgebnis { |
| | | version: string; |
| | | dateiname: string; |
| | | erfolg: boolean; |
| | | meldung: string; |
| | | /** Wurde tatsächlich eine neue Datei bereitgestellt? Nur dann zählt es als Update. */ |
| | | geaendert: boolean; |
| | | signaturStatus: string; |
| | | signaturHerausgeber: string; |
| | | veroeffentlichtAm: string; |
| | | benoetigteGeheimnisse: GeheimnisBedarf[]; |
| | | } |
| | | |
| | | /** Einstellungen der Anwendung (Datei `konfiguration.json` neben der Anwendung). */ |
| | | export interface Einstellungen { |
| | | /** Ablageort für Datenbank und Programm-Dateien. Leer = Ordner âdata" neben der Anwendung. */ |
| | | datenOrdner: string; |
| | | /** Port, auf dem die Oberfläche erreichbar ist. */ |
| | | port: number; |
| | | /** |
| | | * Kennung für Git-Zugriffe über SSH. Leer = die SSH-Konfiguration des |
| | | * Dienstbenutzers entscheidet. Hier zentral gepflegt, damit ein Kontowechsel |
| | | * keine Anpassung an den Katalogeinträgen erfordert. |
| | | */ |
| | | gitBenutzer: string; |
| | | /** Optionaler Pfad zum privaten SSH-Schlüssel für Git-Zugriffe. */ |
| | | gitSchluessel: string; |
| | | } |
| | | |
| | | /** Einstellungen samt der daraus abgeleiteten, tatsächlich genutzten Pfade. */ |
| | | export interface EinstellungenAnsicht { |
| | | einstellungen: Einstellungen; |
| | | /** Vollständiger Pfad der Konfigurationsdatei. */ |
| | | konfigurationsDatei: string; |
| | | /** Tatsächlich verwendeter Datenordner (aufgelöst aus der Einstellung). */ |
| | | effektiverDatenOrdner: string; |
| | | /** Läuft gerade auf diesem Port (Ãnderungen wirken erst nach Neustart). */ |
| | | laufenderPort: number; |
| | | } |
| | | |
| | | /** Ein protokollierter Zugriff/Download aus dem Netzwerk. */ |
| | | export interface ZugriffslogEintrag { |
| | | id: number; |
| | | zeitpunkt: string; |
| | | ipAdresse: string; |
| | | rechnername: string; |
| | | angegebenerName: string; |
| | | aktion: string; |
| | | } |
| Neue Datei |
| | |
| | | { |
| | | "compilerOptions": { |
| | | "target": "es2021", |
| | | "module": "ESNext", |
| | | "moduleResolution": "node", |
| | | "rootDir": "./src", |
| | | "outDir": "./build", |
| | | "declaration": true, |
| | | "strict": true, |
| | | "esModuleInterop": true, |
| | | "skipLibCheck": true, |
| | | "forceConsistentCasingInFileNames": true |
| | | }, |
| | | "include": ["src/**/*"], |
| | | "exclude": ["node_modules", "build"] |
| | | } |
| Neue Datei |
| | |
| | | { |
| | | "_hinweis": "Bau-Anleitung für die Software-Bibliothek selbst. Verteilt wird von hier nicht der Server (den legt man beim Aufsetzen hin), sondern der Client für die Arbeitsplätze. Verfahren: Skill build-secrets im Repo dh-claude-rules.", |
| | | "buildCommand": "powershell -ExecutionPolicy Bypass -File desktop/build_exe.ps1", |
| | | "artifact": "desktop/dist/Software-Bibliothek.exe" |
| | | } |