Add a desktop client that knows what is installed locally
A web page cannot see the file system, so it can only guess which version of a
tool sits on a workstation. This client can look: it places the files itself and
records their checksum, so on the next start it compares and reports instead of
estimating — including when someone replaced a file by hand.
Python with pywebview, mirroring the meeting-transkriptor: the same
dh-components frontend, shown in a native window through the WebView2 that
Windows already ships. No second browser engine is bundled; the executable is
14 MB rather than the ~150 MB an Electron build would cost.
The frontend is an npm workspace so it shares bibliothek-types with the server.
software-bibliothek.json builds the client, which lets the library distribute it
the same way it distributes every other internal tool.
3 Dateien geändert
23 Dateien hinzugefügt
| | |
| | | # Abhaengigkeiten |
| | | node_modules |
| | | .venv |
| | | __pycache__/ |
| | | *.pyc |
| | | |
| | | # Build-Ausgaben |
| | | dist |
| | | build |
| | | build-exe |
| | | exe/ |
| | | *.spec |
| | | |
| | | # Logs |
| | | *.log |
| 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() |
| | |
| | | "name": "software-bibliothek", |
| | | "version": "0.1.0", |
| | | "workspaces": [ |
| | | "./packages/*" |
| | | "./packages/*", |
| | | "./desktop/frontend" |
| | | ], |
| | | "devDependencies": { |
| | | "postject": "^1.0.0-alpha.6", |
| | | "prettier": "^3.4.2", |
| | | "typescript": "^5.7.2" |
| | | } |
| | | }, |
| | | "desktop/frontend": { |
| | | "name": "bibliothek-desktop-frontend", |
| | | "version": "0.1.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", |
| | | "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" |
| | | } |
| | | }, |
| | | "node_modules/@cspotcode/source-map-support": { |
| | |
| | | "resolved": "packages/bibliothek-client", |
| | | "link": true |
| | | }, |
| | | "node_modules/bibliothek-desktop-frontend": { |
| | | "resolved": "desktop/frontend", |
| | | "link": true |
| | | }, |
| | | "node_modules/bibliothek-server": { |
| | | "resolved": "packages/bibliothek-server", |
| | | "link": true |
| | |
| | | "description": "Interne Software-Bibliothek — Katalog, Datei-Download, Zugriffsprotokoll (D+H)", |
| | | "private": true, |
| | | "workspaces": [ |
| | | "./packages/*" |
| | | "./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" |
| | | }, |
| 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" |
| | | } |