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