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

---
 packages/bibliothek-server/src/umzug.ts |  131 +++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 131 insertions(+), 0 deletions(-)

diff --git a/packages/bibliothek-server/src/umzug.ts b/packages/bibliothek-server/src/umzug.ts
new file mode 100644
index 0000000..dc7accb
--- /dev/null
+++ b/packages/bibliothek-server/src/umzug.ts
@@ -0,0 +1,131 @@
+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;
+}

--
Gitblit v1.9.3