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

---
 desktop/backend/werkzeuge.py |  153 +++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 153 insertions(+), 0 deletions(-)

diff --git a/desktop/backend/werkzeuge.py b/desktop/backend/werkzeuge.py
new file mode 100644
index 0000000..c51b62b
--- /dev/null
+++ b/desktop/backend/werkzeuge.py
@@ -0,0 +1,153 @@
+"""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

--
Gitblit v1.9.3