import { loadProject, type MigrationResult } from '@/domain/model/migrate'; import { looksLikeProject } from '@/domain/model/schema'; import { CURRENT_SCHEMA_VERSION, type Project } from '@/domain/model/project'; import { desktopBridge } from '@/platform/bridge'; /** * Speichern und Laden von Projekten. * * Drei Wege, bewusst getrennt: * - Sitzungsspeicher (IndexedDB): der zuletzt bearbeitete Stand, damit ein * Programmabsturz keine Arbeit kostet. * - Projektdatei (.lsap, JSON): der eigentliche Datenaustausch. * - Wiederherstellungspunkte: mehrere Staende in Ringpuffer-Form. * * Der Altbestand legte alles unter demselben Schluessel ab und ueberschrieb den * gespeicherten Stand beim Programmstart mit dem leeren Projekt, bevor geprueft * wurde, ob ueberhaupt etwas geladen werden sollte. * * WARUM DER SITZUNGSSPEICHER NICHT MEHR IM localStorage LIEGT * * localStorage gibt je Herkunft rund 5 MB und nimmt ausschliesslich * Zeichenketten. Ein Lageplan-Luftbild von 7000 Bildpunkten Kantenlaenge liegt * als Daten-URL im zweistelligen MB-Bereich. Der Sitzungsstand haette also * ausgerechnet bei den groessten Projekten nicht mehr gepasst - die * automatische Sicherung waere still ins Leere gelaufen, waehrend die * Statuszeile weiter "gesichert" gemeldet haette. IndexedDB hat ein Vielfaches * an Platz, legt strukturierte Werte ohne den Umweg ueber Text ab und schreibt * in Transaktionen: Ein Absturz mitten im Schreiben laesst den vorherigen Stand * unangetastet, statt einen halben Datensatz zu hinterlassen. * * Die PROGRAMMEINSTELLUNGEN bleiben im localStorage. Sie sind wenige hundert * Byte gross, werden an drei Stellen mitten im Aufbau der Oberflaeche gelesen * und muessen dort sofort vorliegen (Erscheinungsbild vor dem ersten Anzeigen). * Ein asynchroner Zugriff wuerde dafuer nur Flackern erzeugen, ohne ein * Platzproblem zu loesen, das es hier gar nicht gibt. */ const STORAGE_PREFIX = 'lsa-planer.v5'; const KEY_SESSION = `${STORAGE_PREFIX}.session`; const KEY_RECOVERY = `${STORAGE_PREFIX}.recovery`; const KEY_SETTINGS = `${STORAGE_PREFIX}.settings`; const RECOVERY_SLOTS = 5; const DB_NAME = 'lsa-planer.v5'; const DB_VERSION = 1; /** Sitzungsstand und Wiederherstellungspunkte. */ const LAGER_STAND = 'stand'; /** Ausgelagerte Grossdaten, praktisch immer das Luftbild. */ const LAGER_GROSSDATEN = 'grossdaten'; const SCHLUESSEL_SITZUNG = 'sitzung'; const SCHLUESSEL_PUNKTE = 'wiederherstellungspunkte'; const SCHLUESSEL_BESCHAEDIGT = 'sitzung.beschaedigt'; /** * Ab dieser Laenge wird eine Zeichenkette ausgelagert. * * 32768 Zeichen liegt weit ueber allem, was ein Projekt an Text enthaelt * (Namen, Bemerkungen, Fundstellen), und weit unter jeder Bild-Daten-URL. */ const GROSSDATEN_GRENZE = 32_768; const ERSATZ_HINWEIS = 'Der dauerhafte Sitzungsspeicher steht nicht zur Verfügung. Der Stand wird im stark begrenzten Browserspeicher gesichert - ein Luftbild passt dort nicht hinein. Speichern Sie das Projekt zusätzlich in eine Datei.'; export const PROJECT_FILE_EXTENSION = 'lsap'; export const PROJECT_FILE_FILTERS = [ { name: 'LSA-Planer Projekt', extensions: [PROJECT_FILE_EXTENSION] }, { name: 'JSON-Datei', extensions: ['json'] }, ] as const; export interface StorageResult { readonly ok: boolean; readonly message: string; } export interface RecoveryPoint { readonly savedAt: string; readonly projectName: string; readonly project: unknown; } /** Womit der Sitzungsstand tatsaechlich gesichert wird. */ export type Speicherart = 'indexeddb' | 'browserspeicher' | 'keiner'; // --- Einspritzbare Umgebung ------------------------------------------------- /** * Zugang zu den beiden Speichern der Anzeige. * * Der Umweg ueber diese Schnittstelle hat einen einzigen Zweck: Die Pruefung * laeuft in Node, wo es weder `indexedDB` noch `localStorage` gibt. Ohne die * Einspritzung waere ausgerechnet die Fehlerbehandlung - voller Speicher, * abgebrochene Transaktion, fehlende Datenbank - nicht pruefbar, und das sind * die Faelle, in denen ein Anwender seinen Arbeitsstand verliert. */ export interface SpeicherUmgebung { readonly indexedDB: IDBFactory | null; readonly localStorage: Storage | null; } let umgebung: SpeicherUmgebung | null = null; /** Setzt die Speicherumgebung; `null` stellt die des Browsers wieder her. */ export function setzeSpeicherUmgebung(neu: SpeicherUmgebung | null): void { umgebung = neu; vorbereitung = null; bekannteGrossdaten.clear(); naechsteGrossdatenId = 1; } function idbFabrik(): IDBFactory | null { if (umgebung !== null) return umgebung.indexedDB; try { return typeof indexedDB === 'undefined' ? null : indexedDB; } catch { return null; } } /** Prueft, ob ein Schluessel-Wert-Speicher zur Verfuegung steht (privater Modus). */ function storage(): Storage | null { const kandidat = umgebung !== null ? umgebung.localStorage : browserSpeicher(); if (kandidat === null) return null; try { const test = `${STORAGE_PREFIX}.probe`; kandidat.setItem(test, '1'); kandidat.removeItem(test); return kandidat; } catch { return null; } } function browserSpeicher(): Storage | null { try { return typeof window === 'undefined' ? null : window.localStorage; } catch { return null; } } // --- IndexedDB: Grundlagen -------------------------------------------------- let vorbereitung: Promise | null = null; /** * Liefert die geoeffnete Datenbank oder `null`, wenn IndexedDB fehlt oder * gesperrt ist. Wirft nie - der Ersatzweg uebernimmt dann. */ function sitzungsspeicher(): Promise { vorbereitung ??= (async () => { const db = await oeffneDatenbank(); if (db === null) return null; // Beide Schritte duerfen den Start nicht verhindern: Ohne sie arbeitet die // Datenbank weiter, nur eben ohne Uebernahme bzw. mit neu vergebenen // Kennungen. try { await lerneVergebeneIds(db); } catch { /* Kennungen werden dann ab 1 vergeben; siehe naechsteGrossdatenId. */ } try { await uebernehmeAltbestand(db); } catch { /* Der Altbestand bleibt liegen und wird beim naechsten Start erneut versucht. */ } return db; })(); return vorbereitung; } function oeffneDatenbank(): Promise { const fabrik = idbFabrik(); if (fabrik === null) return Promise.resolve(null); return new Promise((erfuellen) => { let entschieden = false; const entscheide = (db: IDBDatabase | null): void => { if (entschieden) { // Zu spaet gekommene Verbindung sofort wieder freigeben, sonst blockiert // sie spaeter die Versionsanhebung in einem anderen Fenster. db?.close(); return; } entschieden = true; erfuellen(db); }; let anfrage: IDBOpenDBRequest; try { anfrage = fabrik.open(DB_NAME, DB_VERSION); } catch { entscheide(null); return; } anfrage.onupgradeneeded = () => { const db = anfrage.result; if (!db.objectStoreNames.contains(LAGER_STAND)) db.createObjectStore(LAGER_STAND); if (!db.objectStoreNames.contains(LAGER_GROSSDATEN)) db.createObjectStore(LAGER_GROSSDATEN); }; anfrage.onsuccess = () => { const db = anfrage.result; db.onversionchange = () => { // Ein anderes Fenster hebt die Version an. Wer die Verbindung haelt, // laesst dort den Start haengen - also loslassen. db.close(); vorbereitung = null; }; entscheide(db); }; anfrage.onerror = () => { entscheide(null); }; // Ein blockiertes Oeffnen darf den Programmstart nicht anhalten: Der // Anwender schliesst das andere Fenster vielleicht nie. anfrage.onblocked = () => { entscheide(null); }; }); } /** * Fuehrt Arbeit in EINER Transaktion aus und loest das Versprechen erst, wenn * die Transaktion abgeschlossen ist. * * Das ist der Kern der Zusicherung "kein Datenverlust bei einem Fehler mitten * im Schreiben": Vor `oncomplete` ist nichts dauerhaft, nach `onabort` ist * nichts davon uebrig geblieben. Wer hier "gesichert" gemeldet bekommt, hat * auch wirklich einen vollstaendigen Datensatz auf der Platte. */ function inTransaktion( db: IDBDatabase, lager: readonly string[], modus: IDBTransactionMode, arbeit: (tx: IDBTransaction, fertig: (wert: T) => void) => void, ): Promise { return new Promise((erfuellen, ablehnen) => { let ergebnis: T | undefined; let tx: IDBTransaction; try { tx = db.transaction([...lager], modus); } catch (fehler) { ablehnen(alsFehler(fehler)); return; } tx.oncomplete = () => { erfuellen(ergebnis as T); }; tx.onerror = () => { ablehnen(tx.error ?? new Error('Die Transaktion ist fehlgeschlagen.')); }; tx.onabort = () => { ablehnen(tx.error ?? new Error('Die Transaktion wurde abgebrochen.')); }; try { arbeit(tx, (wert) => { ergebnis = wert; }); } catch (fehler) { try { tx.abort(); } catch { /* Bereits beendet. */ } ablehnen(alsFehler(fehler)); } }); } /** * Alle Zugriffe laufen nacheinander. * * Zwei Gruende. Erstens duerfen sich zwei Sicherungen nicht ueberholen - sonst * bliebe der aeltere Stand als der zuletzt geschriebene stehen. Zweitens wird * das Ausduennen der Grossdaten (siehe raeumeGrossdatenAuf) erst dadurch * eindeutig: Wuerde ein Schreibvorgang seine Zeiger aufbauen, waehrend ein * anderer gerade nicht mehr benoetigte Bilddaten loescht, koennte er auf ein * bereits geloeschtes Bild zeigen - das Luftbild waere weg, die daraus * abgegriffenen Wege aber noch im Projekt. */ let warteschlange: Promise = Promise.resolve(); function nacheinander(arbeit: () => Promise): Promise { const naechste = warteschlange.then(arbeit, arbeit); warteschlange = naechste.catch(() => undefined); return naechste; } // --- Grossdaten (Luftbild) -------------------------------------------------- /** * WARUM DAS LUFTBILD GETRENNT ABGELEGT WIRD * * Die Zusicherung "fuenf Wiederherstellungspunkte" bleibt bestehen. Fuenf * VOLLKOPIEN eines 20-MB-Projekts waeren aber 100 MB, die alle 120 s neu * geschrieben wuerden - und das fuer Daten, die sich zwischen den Staenden * praktisch nie unterscheiden: Das Luftbild wird einmal abgerufen und danach * nur noch bezeichnet. Was sich aendert, sind die gezeichneten Linien, die * Signalgruppen, die Phasen - zusammen einige zehn Kilobyte. * * Deshalb: Jede Zeichenkette ab GROSSDATEN_GRENZE wandert in ein eigenes Lager * und bleibt im Projekt nur als Zeiger stehen. Fuenf Wiederherstellungspunkte * mit demselben Luftbild kosten damit einmal das Bild und fuenfmal das Geruest. * * Gleichheit wird VOLLSTAENDIG geprueft, nicht ueber eine Pruefsumme. Zwei * verschiedene Bilder mit gleicher Pruefsumme wuerden einem Stand das Luftbild * eines anderen unterschieben; die daraus abgegriffenen Raeum- und Einfahrwege * waeren dann fachlich falsch, ohne dass es jemandem auffiele. Ein voller * Vergleich schliesst das aus und kostet bei ungleichen Bildern ohnehin nur * wenige Zeichen, weil sie sich frueh unterscheiden. */ interface Grossdatenzeiger { readonly grossdatenId: number; readonly zeichen: number; } /** Kennung -> Inhalt, fuer alles, was in dieser Sitzung geschrieben oder gelesen wurde. */ const bekannteGrossdaten = new Map(); let naechsteGrossdatenId = 1; function istGrossdatenzeiger(wert: unknown): wert is Grossdatenzeiger { if (wert === null || typeof wert !== 'object' || Array.isArray(wert)) return false; const satz = wert as Record; return typeof satz['grossdatenId'] === 'number' && typeof satz['zeichen'] === 'number'; } function teileGrossdatenAb(wert: unknown): { gerippe: unknown; neue: Map } { const neue = new Map(); return { gerippe: ersetzeDurchZeiger(wert, neue), neue }; } function ersetzeDurchZeiger(wert: unknown, neue: Map): unknown { if (typeof wert === 'string') { if (wert.length < GROSSDATEN_GRENZE) return wert; const zeiger: Grossdatenzeiger = { grossdatenId: kennungFuer(wert, neue), zeichen: wert.length, }; return zeiger; } if (Array.isArray(wert)) return wert.map((eintrag) => ersetzeDurchZeiger(eintrag, neue)); if (wert === null || typeof wert !== 'object') return wert; const ergebnis: Record = {}; for (const [name, eintrag] of Object.entries(wert)) { ergebnis[name] = ersetzeDurchZeiger(eintrag, neue); } return ergebnis; } function kennungFuer(text: string, neue: Map): number { for (const [id, vorhanden] of bekannteGrossdaten) { if (vorhanden.length === text.length && vorhanden === text) return id; } const id = naechsteGrossdatenId; naechsteGrossdatenId += 1; bekannteGrossdaten.set(id, text); neue.set(id, text); return id; } /** * Nimmt vergebene Kennungen zurueck, deren Inhalt nie angekommen ist. * * Nach einer abgebrochenen Transaktion steht die Kennung noch in der * Merkliste, das Bild aber nicht in der Datenbank. Bliebe das so, wuerde der * naechste Schreibvorgang das Bild fuer bereits gespeichert halten und nur den * Zeiger ablegen - der Lageplan waere nach dem naechsten Start ohne Luftbild, * waehrend die daraus abgegriffenen Raeumwege im Projekt stehen blieben. */ function vergissGrossdaten(neue: ReadonlyMap): void { for (const id of neue.keys()) bekannteGrossdaten.delete(id); } function sammleGrossdatenIds(wert: unknown, ziel: Set): void { if (Array.isArray(wert)) { for (const eintrag of wert) sammleGrossdatenIds(eintrag, ziel); return; } if (wert === null || typeof wert !== 'object') return; if (istGrossdatenzeiger(wert)) { ziel.add(wert.grossdatenId); return; } for (const eintrag of Object.values(wert)) sammleGrossdatenIds(eintrag, ziel); } function fuegeGrossdatenEin(wert: unknown, daten: ReadonlyMap): unknown { if (Array.isArray(wert)) return wert.map((eintrag) => fuegeGrossdatenEin(eintrag, daten)); if (wert === null || typeof wert !== 'object') return wert; if (istGrossdatenzeiger(wert)) { // Fehlt der Inhalt, geht das BILD verloren, nicht der Plan: Massstab, // Haltlinien und Fahrlinien liegen getrennt davon und bleiben gueltig. Ein // leeres Bild sieht der Anwender sofort; ein untergeschobenes nicht. return daten.get(wert.grossdatenId) ?? ''; } const ergebnis: Record = {}; for (const [name, eintrag] of Object.entries(wert)) { ergebnis[name] = fuegeGrossdatenEin(eintrag, daten); } return ergebnis; } /** * Loescht Grossdaten, auf die kein gespeicherter Stand mehr zeigt. * * Muss in derselben Transaktion laufen wie das Schreiben des Standes, damit * die Entscheidung auf genau den Daten beruht, die gleich festgeschrieben * werden. */ function raeumeGrossdatenAuf(stand: IDBObjectStore, gross: IDBObjectStore): void { const sitzung = stand.get(SCHLUESSEL_SITZUNG); sitzung.onsuccess = () => { const punkte = stand.get(SCHLUESSEL_PUNKTE); punkte.onsuccess = () => { const beschaedigt = stand.get(SCHLUESSEL_BESCHAEDIGT); beschaedigt.onsuccess = () => { const gebraucht = new Set(); sammleGrossdatenIds(sitzung.result, gebraucht); sammleGrossdatenIds(punkte.result, gebraucht); sammleGrossdatenIds(beschaedigt.result, gebraucht); const schluessel = gross.getAllKeys(); schluessel.onsuccess = () => { for (const key of schluessel.result) { if (typeof key !== 'number' || gebraucht.has(key)) continue; gross.delete(key); // Auch aus der Merkliste nehmen, sonst gaebe kennungFuer() spaeter // eine Kennung heraus, deren Inhalt nicht mehr existiert - das // Luftbild waere beim naechsten Laden verschwunden. Bricht die // Transaktion ab, wird das Bild lediglich einmal zu viel // geschrieben; das ist die harmlose Richtung des Irrtums. bekannteGrossdaten.delete(key); } }; }; }; }; } /** Liest die zu einem Geruest gehoerenden Grossdaten nach und setzt sie ein. */ function ladeGrossdaten( gross: IDBObjectStore, gerippe: unknown, weiter: (vollstaendig: unknown) => void, ): void { const ids = new Set(); sammleGrossdatenIds(gerippe, ids); if (ids.size === 0) { weiter(gerippe); return; } const daten = new Map(); let offen = ids.size; for (const id of ids) { const anfrage = gross.get(id); anfrage.onsuccess = () => { if (typeof anfrage.result === 'string') { daten.set(id, anfrage.result); // Merken, damit die naechste Sicherung dasselbe Bild nicht erneut // schreibt. Ohne das kostete jeder Programmstart eine ueberfluessige // 20-MB-Kopie. bekannteGrossdaten.set(id, anfrage.result); } offen -= 1; if (offen === 0) weiter(fuegeGrossdatenEin(gerippe, daten)); }; } } async function lerneVergebeneIds(db: IDBDatabase): Promise { const schluessel = await inTransaktion( db, [LAGER_GROSSDATEN], 'readonly', (tx, fertig) => { const anfrage = tx.objectStore(LAGER_GROSSDATEN).getAllKeys(); anfrage.onsuccess = () => { fertig(anfrage.result); }; }, ); for (const key of schluessel) { if (typeof key === 'number' && key >= naechsteGrossdatenId) naechsteGrossdatenId = key + 1; } } // --- Sitzungsspeicher ------------------------------------------------------- interface Sitzungssatz { readonly gespeichertAm: string; readonly projekt: unknown; } /** * Sichert den Arbeitsstand. * * ASYNCHRON, anders als frueher: IndexedDB kennt keinen synchronen Zugriff. * Das Versprechen wird erst erfuellt, wenn die Transaktion abgeschlossen ist - * ein `await` darauf bedeutet also wirklich "liegt sicher auf der Platte". */ export function saveSession(project: Project): Promise { return nacheinander(async () => { const db = await sitzungsspeicher(); if (db === null) return schreibeSitzungErsatzweise(project); try { await schreibeSitzung(db, project); return { ok: true, message: '' }; } catch (fehler) { if (isQuotaError(fehler)) { // Bei vollem Speicher werden zuerst die Wiederherstellungspunkte // freigegeben; der aktuelle Stand hat Vorrang. try { await leereWiederherstellungspunkte(db); await schreibeSitzung(db, project); return { ok: true, message: 'Der Speicher war voll. Die Wiederherstellungspunkte wurden gelöscht, um den aktuellen Stand zu sichern.', }; } catch { return { ok: false, message: 'Der Speicher ist voll und der Stand konnte nicht gesichert werden. Speichern Sie das Projekt in eine Datei.', }; } } return { ok: false, message: `Der Stand konnte nicht gesichert werden: ${errorText(fehler)}`, }; } }); } async function schreibeSitzung( db: IDBDatabase, project: Project, jetzt: Date = new Date(), ): Promise { const { gerippe, neue } = teileGrossdatenAb(project); const satz: Sitzungssatz = { gespeichertAm: jetzt.toISOString(), projekt: gerippe }; try { await inTransaktion(db, [LAGER_STAND, LAGER_GROSSDATEN], 'readwrite', (tx, fertig) => { const stand = tx.objectStore(LAGER_STAND); const gross = tx.objectStore(LAGER_GROSSDATEN); // Erst die Grossdaten, dann der Zeiger darauf: Bricht die Transaktion ab, // ist beides weg und der vorherige Stand vollstaendig erhalten. for (const [id, text] of neue) gross.put(text, id); stand.put(satz, SCHLUESSEL_SITZUNG); raeumeGrossdatenAuf(stand, gross); fertig(undefined); }); } catch (fehler) { vergissGrossdaten(neue); throw fehler; } } /** * Ausgang eines Ladeversuchs. * * Warum das unterschieden werden muss: Es gibt drei voellig verschiedene * Gruende, aus denen kein Arbeitsstand zurueckkommt - es wurde nie einer * gespeichert, die Datenbank laesst sich nicht lesen, oder der Satz ist * beschaedigt. Bis hierher lieferten alle drei dasselbe `null`, und der * Programmstart konnte sie nicht auseinanderhalten. Der Anwender bekam in * jedem Fall ein leeres Projekt, ohne ein Wort - auch dann, wenn sein * Arbeitsstand noch da, aber unlesbar war. */ export type Sitzungsbefund = | { readonly art: 'leer' } | { readonly art: 'geladen'; readonly stand: MigrationResult } | { readonly art: 'nicht-lesbar' } /** Beiseitegelegt statt geloescht, damit sich noch etwas retten laesst. */ | { readonly art: 'beschaedigt'; readonly beiseitegelegt: boolean }; /** Laedt den zuletzt gesicherten Arbeitsstand und sagt, was dabei herauskam. */ export function ladeSitzung(): Promise { return nacheinander(async () => { const db = await sitzungsspeicher(); if (db === null) return leseSitzungErsatzweise(); let roh: unknown; try { roh = await leseSitzung(db); } catch { // Lesefehler: Der Datensatz bleibt unangetastet. Ihn wegen einer // abgebrochenen Transaktion beiseitezulegen, hiesse einen womoeglich // heilen Stand aus dem Weg zu raeumen. return { art: 'nicht-lesbar' }; } if (roh === null) return { art: 'leer' }; try { // Ein Satz ohne Projektfeld ist beschaedigt und nicht etwa ein leeres // Projekt. loadProject wuerde daraus klaglos eines bauen. if (roh === UNBRAUCHBAR) throw new Error('Sitzungssatz ohne Projekt.'); return { art: 'geladen', stand: loadProject(roh) }; } catch { let beiseitegelegt = true; try { await legeBeschaedigtenStandBeiseite(db); } catch { /* Dann bleibt er eben liegen und wird beim naechsten Start erneut geprueft. */ beiseitegelegt = false; } return { art: 'beschaedigt', beiseitegelegt }; } }); } /** * Laedt den zuletzt gesicherten Arbeitsstand. * * Duenner Aufsatz auf ladeSitzung() fuer alle Stellen, die nur den Stand * brauchen und nicht den Grund seines Fehlens. */ export async function loadSession(): Promise { const befund = await ladeSitzung(); return befund.art === 'geladen' ? befund.stand : null; } /** * Ein Satz, der zwar dasteht, aber keiner ist. * * Ohne diese Unterscheidung lief ein Satz ohne Projektfeld in * `loadProject(undefined)`. Die Schemapruefung repariert daraus ein LEERES * Projekt, und das Programm meldete "Der zuletzt bearbeitete Stand wurde * wiederhergestellt" - fuer ein Projekt, das nichts enthielt. Ein stiller * Verlust mit einer Erfolgsmeldung darueber ist schlimmer als gar keine * Meldung. */ const UNBRAUCHBAR = Symbol('sitzungssatz-unbrauchbar'); function leseSitzung(db: IDBDatabase): Promise { return inTransaktion(db, [LAGER_STAND, LAGER_GROSSDATEN], 'readonly', (tx, fertig) => { const stand = tx.objectStore(LAGER_STAND); const gross = tx.objectStore(LAGER_GROSSDATEN); const anfrage = stand.get(SCHLUESSEL_SITZUNG); anfrage.onsuccess = () => { const satz = anfrage.result as Sitzungssatz | undefined; // Die Zusicherung eine Zeile hoeher ist eine Behauptung ueber fremden // Inhalt: Der Satz stammt aus IndexedDB und kann von jeder frueheren // Fassung dieses Programms geschrieben worden sein. Der Linter haelt // die Abfrage auf null deshalb fuer ueberfluessig; sie bleibt, weil ein // abgelegtes null sonst in die Abfrage darunter faellt: `typeof null` // ist 'object', der erste Teil greift also nicht, und der Zugriff auf // `.projekt` wirft einen TypeError - hier im onsuccess-Behandler, // statt dass `fertig(null)` das harmlose "kein Stand" meldet. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Satz aus IndexedDB, der Typ ist keine Messung; siehe darueber if (satz === undefined || satz === null) { fertig(null); return; } if (typeof satz !== 'object' || satz.projekt === undefined || satz.projekt === null) { fertig(UNBRAUCHBAR); return; } ladeGrossdaten(gross, satz.projekt, (vollstaendig) => { fertig(vollstaendig); }); }; }); } function legeBeschaedigtenStandBeiseite(db: IDBDatabase): Promise { return inTransaktion(db, [LAGER_STAND], 'readwrite', (tx, fertig) => { const stand = tx.objectStore(LAGER_STAND); const anfrage = stand.get(SCHLUESSEL_SITZUNG); anfrage.onsuccess = () => { if (anfrage.result === undefined) return; stand.put(anfrage.result, SCHLUESSEL_BESCHAEDIGT); stand.delete(SCHLUESSEL_SITZUNG); }; fertig(undefined); }); } /** Welcher Speicher den Sitzungsstand tatsaechlich traegt. */ export async function sitzungsspeicherArt(): Promise { if ((await sitzungsspeicher()) !== null) return 'indexeddb'; return storage() !== null ? 'browserspeicher' : 'keiner'; } // --- Uebernahme des Altbestands --------------------------------------------- /** Was in IndexedDB bereits liegt - je Schluessel getrennt. */ interface Datenbankstand { readonly sitzungBelegt: boolean; /** Zeitpunkt des Sitzungssatzes, oder `null`, wenn er keinen lesbaren traegt. */ readonly sitzungAm: string | null; readonly punkteBelegt: boolean; } /** * Holt einen im Ersatzspeicher liegenden Stand nach IndexedDB. * * Reihenfolge ist hier alles: Erst wird nach IndexedDB geschrieben und die * Transaktion abgewartet, erst danach wird der alte Eintrag abgeraeumt. * Andersherum wuerde ein Absturz zwischen beiden Schritten den letzten * Arbeitsstand vernichten. * * ES IST NICHT NUR EIN ALTBESTAND. Unter demselben Schluessel schreibt * `schreibeSitzungErsatzweise` HEUTE, sobald sich IndexedDB einmal nicht * oeffnen liess (`oeffneDatenbank` liefert dann `null`, und die ganze Sitzung * laeuft im Ersatzspeicher). Die frueher hier stehende Annahme "liegt in * IndexedDB bereits ein Stand, ist der localStorage-Eintrag der aeltere" traf * dann nicht zu: Der Ersatzstand war der JUENGERE, wurde nicht uebernommen und * anschliessend geloescht - zwei Stunden Arbeit ohne ein Wort fort. * * Deshalb wird verglichen statt vermutet, und was nicht uebernommen wird, geht * beiseite statt in den Papierkorb. Sitzung und Wiederherstellungspunkte werden * dabei GETRENNT geprueft: Frueher entschied allein der Sitzungsschluessel, und * die Punkte verschwanden mit, obwohl es in der Datenbank keinen Ersatz fuer * sie gab. */ async function uebernehmeAltbestand(db: IDBDatabase): Promise { const store = storage(); if (store === null) return; const rohSitzung = leseEintrag(store, KEY_SESSION); const rohPunkte = leseEintrag(store, KEY_RECOVERY); if (rohSitzung === null && rohPunkte === null) return; const vorhanden = await inTransaktion( db, [LAGER_STAND], 'readonly', (tx, fertig) => { const stand = tx.objectStore(LAGER_STAND); const sitzung = stand.get(SCHLUESSEL_SITZUNG); sitzung.onsuccess = () => { const punkte = stand.get(SCHLUESSEL_PUNKTE); punkte.onsuccess = () => { const satz = sitzung.result as Partial | undefined; fertig({ sitzungBelegt: satz !== undefined, sitzungAm: typeof satz?.gespeichertAm === 'string' ? satz.gespeichertAm : null, punkteBelegt: Array.isArray(punkte.result) && punkte.result.length > 0, }); }; }; }, ); /* * Uebernommen wird, was nachweislich juenger ist - und alles, wofuer die * Datenbank gar nichts hat. * * Bei fehlendem Zeitpunkt auf einer der beiden Seiten laesst sich die * Reihenfolge nicht feststellen; dann behaelt die Datenbank den Vortritt und * der Ersatzstand wird beiseitegelegt. Er ueberschreibt also nie etwas, von * dem nicht feststeht, dass es aelter ist. */ const ersatz = rohSitzung === null ? null : ersatzsitzung(rohSitzung); const zuUebernehmen = ersatz !== null && (!vorhanden.sitzungBelegt || istJuenger(ersatz.gespeichertAm, vorhanden.sitzungAm)) ? ersatz : null; const altpunkte = jsonOderNull(rohPunkte); const punkteUebernehmen = Array.isArray(altpunkte) && !vorhanden.punkteBelegt; if (zuUebernehmen !== null || punkteUebernehmen) { const { gerippe, neue } = teileGrossdatenAb({ sitzung: zuUebernehmen === null ? null : zuUebernehmen.projekt, punkte: punkteUebernehmen ? altpunkte : null, }); const geteilt = gerippe as { sitzung: unknown; punkte: unknown }; try { await inTransaktion(db, [LAGER_STAND, LAGER_GROSSDATEN], 'readwrite', (tx, fertig) => { const stand = tx.objectStore(LAGER_STAND); const gross = tx.objectStore(LAGER_GROSSDATEN); for (const [id, text] of neue) gross.put(text, id); if (zuUebernehmen !== null) { const satz: Sitzungssatz = { // Der mitgefuehrte Zeitpunkt, nicht der von jetzt: Ein erfundener // Zeitpunkt liesse den Stand beim naechsten Vergleich juenger // aussehen, als er ist. gespeichertAm: zuUebernehmen.gespeichertAm ?? new Date().toISOString(), projekt: geteilt.sitzung, }; stand.put(satz, SCHLUESSEL_SITZUNG); } if (Array.isArray(geteilt.punkte)) { stand.put(geteilt.punkte.slice(0, RECOVERY_SLOTS), SCHLUESSEL_PUNKTE); } fertig(undefined); }); } catch (fehler) { // Der Ersatzstand bleibt liegen; beim naechsten Start wird es erneut // versucht. Nichts entfernen, was nicht angekommen ist. vergissGrossdaten(neue); throw fehler; } } raeumeErsatzplatzAb(store, KEY_SESSION, zuUebernehmen !== null); raeumeErsatzplatzAb(store, KEY_RECOVERY, punkteUebernehmen); } /** Ist `a` nachweislich juenger als `b`? Ohne beide Zeitpunkte: nein. */ function istJuenger(a: string | null, b: string | null): boolean { return a !== null && b !== null && a > b; } /** * Gibt den Platz im Ersatzspeicher frei. * * Uebernommen: entfernen - sonst wuerde bei jedem Start erneut geprueft. * Nicht uebernommen: beiseitelegen statt loeschen, nach demselben Grundsatz wie * beim beschaedigten Satz ("damit sich noch etwas retten laesst"). Scheitert * das Beiseitelegen, bleibt der Eintrag stehen; ein zweiter Anlauf ist besser * als ein Verlust. */ function raeumeErsatzplatzAb(store: Storage, schluessel: string, uebernommen: boolean): void { try { if (!uebernommen) { const roh = store.getItem(schluessel); if (roh !== null && roh !== '') store.setItem(`${schluessel}.beiseite`, roh); } store.removeItem(schluessel); } catch { /* Nicht schlimm: Beim naechsten Start wird der Platz erneut angesehen. */ } } /** * Zerlegt einen Eintrag des Ersatzspeichers in Zeitpunkt und Projekt. * * Zwei Formen kommen vor: der Satz aus `schreibeSitzungErsatzweise` mit * Zeitpunkt und der nackte Projektstand aus einer aelteren Programmfassung. * Beim nackten Stand ist der Zeitpunkt unbekannt - `null` und nicht etwa der * von jetzt. * * `null` heisst: unbrauchbar. Ein Satz ohne Projekt liefe sonst in * `loadProject(null)`, und die Schemapruefung machte daraus klaglos ein LEERES * Projekt - derselbe stille Verlust, gegen den `UNBRAUCHBAR` im * Datenbankpfad steht. */ function ersatzsitzung(roh: string): { gespeichertAm: string | null; projekt: unknown } | null { let gelesen: unknown; try { gelesen = JSON.parse(roh); } catch { return null; } if (gelesen === null || typeof gelesen !== 'object' || Array.isArray(gelesen)) return null; const satz = gelesen as Record; if (typeof satz['gespeichertAm'] === 'string' && 'projekt' in satz) { const projekt = satz['projekt']; if (projekt === null || projekt === undefined) return null; return { gespeichertAm: satz['gespeichertAm'], projekt }; } return { gespeichertAm: null, projekt: gelesen }; } function leseEintrag(store: Storage, schluessel: string): string | null { try { const roh = store.getItem(schluessel); return roh === null || roh === '' ? null : roh; } catch { return null; } } function jsonOderNull(roh: string | null): unknown { if (roh === null) return null; try { return JSON.parse(roh); } catch { return null; } } // --- Wiederherstellungspunkte ---------------------------------------------- /** * Legt einen Wiederherstellungspunkt an. * * DEM ANWENDER wird ein Fehlschlag nicht gemeldet - ein Wiederherstellungspunkt * ist Beiwerk und darf ihn nicht behelligen. Dem AUFRUFER sehr wohl: Das * Versprechen wird abgelehnt, wenn nichts geschrieben wurde. * * Zuvor fing diese Funktion ihren Fehler selbst ab und loeste trotzdem auf. * `AutoSave` setzte daraufhin `letzterPunkt` und sperrte damit jeden weiteren * Versuch fuer denselben Projektzustand - genau das, was der Kommentar dort * ausschliesst ("Ein gescheiterter Punkt liegt nirgends und darf den naechsten * Versuch nicht sperren"). Nicht melden ist nicht dasselbe wie Erfolg melden. * * Wer das Versprechen ignoriert, faengt die Ablehnung mit `.catch()` ab - * sonst endet sie als unbehandelte Ablehnung. */ export function addRecoveryPoint(project: Project, now: Date = new Date()): Promise { return nacheinander(async () => { const db = await sitzungsspeicher(); if (db === null) { fuegePunktErsatzweiseHinzu(project, now); return; } const { gerippe, neue } = teileGrossdatenAb(project); const neuerPunkt: RecoveryPoint = { savedAt: now.toISOString(), projectName: project.meta.name, project: gerippe, }; try { await inTransaktion(db, [LAGER_STAND, LAGER_GROSSDATEN], 'readwrite', (tx, fertig) => { const stand = tx.objectStore(LAGER_STAND); const gross = tx.objectStore(LAGER_GROSSDATEN); for (const [id, text] of neue) gross.put(text, id); const vorhandene = stand.get(SCHLUESSEL_PUNKTE); vorhandene.onsuccess = () => { const bisher = Array.isArray(vorhandene.result) ? (vorhandene.result as unknown[]) : []; stand.put([neuerPunkt, ...bisher].slice(0, RECOVERY_SLOTS), SCHLUESSEL_PUNKTE); raeumeGrossdatenAuf(stand, gross); }; fertig(undefined); }); } catch (fehler) { // Der Sitzungsstand hat Vorrang; gemeldet wird dem Anwender nichts. Der // Aufrufer erfaehrt es trotzdem, damit er den Stand nicht als gesichert // vermerkt und beim naechsten Takt erneut anlaeuft. vergissGrossdaten(neue); throw fehler; } }); } export function listRecoveryPoints(): Promise { return nacheinander(async () => { const db = await sitzungsspeicher(); if (db === null) return lesePunkteErsatzweise(); try { return await inTransaktion( db, [LAGER_STAND, LAGER_GROSSDATEN], 'readonly', (tx, fertig) => { const stand = tx.objectStore(LAGER_STAND); const gross = tx.objectStore(LAGER_GROSSDATEN); const anfrage = stand.get(SCHLUESSEL_PUNKTE); anfrage.onsuccess = () => { if (!Array.isArray(anfrage.result)) { fertig([]); return; } ladeGrossdaten(gross, anfrage.result, (vollstaendig) => { fertig(Array.isArray(vollstaendig) ? (vollstaendig as RecoveryPoint[]) : []); }); }; }, ); } catch { return []; } }); } function leereWiederherstellungspunkte(db: IDBDatabase): Promise { return inTransaktion(db, [LAGER_STAND, LAGER_GROSSDATEN], 'readwrite', (tx, fertig) => { const stand = tx.objectStore(LAGER_STAND); const gross = tx.objectStore(LAGER_GROSSDATEN); stand.delete(SCHLUESSEL_PUNKTE); raeumeGrossdatenAuf(stand, gross); fertig(undefined); }); } // --- Ersatzweg ohne IndexedDB ---------------------------------------------- /** * Ohne IndexedDB laeuft die Anwendung weiter - im localStorage und mit einer * Ansage. Grosse Projekte passen dort nicht hinein; das muss der Anwender * erfahren, solange er noch handeln kann, statt es beim naechsten Start zu * merken. * * Abgelegt wird dasselbe Paar wie in IndexedDB: Zeitpunkt und Projekt. Frueher * stand hier das nackte Projekt, und damit liess sich beim naechsten Start * nicht feststellen, ob dieser Stand aelter oder juenger ist als der in der * Datenbank - `uebernehmeAltbestand` nahm das Aeltere an und loeschte ihn. */ function schreibeSitzungErsatzweise(project: Project, jetzt: Date = new Date()): StorageResult { const store = storage(); if (store === null) { return { ok: false, message: 'Es steht kein Speicher für den Arbeitsstand zur Verfügung. Speichern Sie das Projekt in eine Datei.', }; } const satz: Sitzungssatz = { gespeichertAm: jetzt.toISOString(), projekt: project }; const inhalt = JSON.stringify(satz); try { store.setItem(KEY_SESSION, inhalt); return { ok: true, message: ERSATZ_HINWEIS }; } catch (fehler) { if (isQuotaError(fehler)) { try { store.removeItem(KEY_RECOVERY); store.setItem(KEY_SESSION, inhalt); return { ok: true, message: 'Der Speicher war voll. Die Wiederherstellungspunkte wurden gelöscht, um den aktuellen Stand zu sichern.', }; } catch { return { ok: false, message: 'Der Speicher ist voll und der Stand konnte nicht gesichert werden. Speichern Sie das Projekt in eine Datei.', }; } } return { ok: false, message: `Der Stand konnte nicht gesichert werden: ${errorText(fehler)}` }; } } function leseSitzungErsatzweise(): Sitzungsbefund { const store = storage(); // Kein Speicher ueberhaupt: Es gibt nichts zu laden, aber auch nichts, was // verloren waere. Das ist "leer" und kein Fehler. if (store === null) return { art: 'leer' }; const roh = leseEintrag(store, KEY_SESSION); if (roh === null) return { art: 'leer' }; try { // `ersatzsitzung` kennt beide Formen - den Satz mit Zeitpunkt und den // nackten Stand einer aelteren Fassung - und liefert `null`, wenn nichts // Brauchbares darin steht. const satz = ersatzsitzung(roh); if (satz === null) throw new Error('Sitzungssatz ohne Projekt.'); return { art: 'geladen', stand: loadProject(satz.projekt) }; } catch { let beiseitegelegt = true; try { store.setItem(`${KEY_SESSION}.beschaedigt`, roh); store.removeItem(KEY_SESSION); } catch { /* Wenn selbst das nicht geht, ist der Speicher voll - nichts zu tun. */ beiseitegelegt = false; } return { art: 'beschaedigt', beiseitegelegt }; } } /** * Legt einen Punkt im Ersatzspeicher ab. * * Wirft, wenn nichts abgelegt wurde - aus demselben Grund wie * `addRecoveryPoint`: Der Aufrufer darf den Stand sonst als gesichert * vermerken. Der Kontingentfall wiegt hier besonders schwer, weil dabei die * ganze Punkteliste geleert wird; galte er als Erfolg, bliebe sie fuer diesen * Arbeitsstand leer. * * Ohne jeden Speicher wird NICHT geworfen: Dann gibt es nichts zu wiederholen, * ein erneuter Versuch alle zwei Minuten schriebe wieder nichts. */ function fuegePunktErsatzweiseHinzu(project: Project, now: Date): void { const store = storage(); if (store === null) return; try { const naechste = [ { savedAt: now.toISOString(), projectName: project.meta.name, project }, ...lesePunkteErsatzweise(), ].slice(0, RECOVERY_SLOTS); store.setItem(KEY_RECOVERY, JSON.stringify(naechste)); } catch (fehler) { if (isQuotaError(fehler)) { try { store.setItem(KEY_RECOVERY, JSON.stringify([])); } catch { /* Speicher voll - der Sitzungsstand hat Vorrang. */ } } throw alsFehler(fehler); } } function lesePunkteErsatzweise(): RecoveryPoint[] { const store = storage(); if (store === null) return []; const roh = leseEintrag(store, KEY_RECOVERY); if (roh === null) return []; try { const gelesen: unknown = JSON.parse(roh); return Array.isArray(gelesen) ? (gelesen as RecoveryPoint[]) : []; } catch { return []; } } // --- Programmeinstellungen -------------------------------------------------- export interface AppSettings { readonly theme: 'hell' | 'dunkel' | 'system'; readonly autoSaveEnabled: boolean; readonly autoSaveIntervalSeconds: number; readonly locale: 'de' | 'en'; /** * Anzeigedauer der fluechtigen Kurzmeldungen in Sekunden; `0` laesst sie * stehen (WCAG 2.1 Erfolgskriterium 2.2.1, Befund L8). * * Hinweise und Erfolgsmeldungen verschwanden nach fest verdrahteten fuenf * Sekunden. 2.2.1 verlangt fuer eine vom Inhalt gesetzte Frist mindestens * einen von drei Wegen: abschalten, auf das Zehnfache verlaengern oder vor * Ablauf verlaengern. Die Werte in MELDUNGSDAUER_STUFEN decken die ersten * beiden ab; Warnungen und Fehler standen ohnehin nie unter einer Frist. * * Die Zahl ist ein Bezugswert und keine feste Dauer: Aufrufer geben eigene * Zeiten an (2500 ms fuer "Rueckgaengig: ..."), und die werden im selben * Verhaeltnis gestreckt - naeheres bei `setzeMeldungsdauer` in * src/ui/feedback.ts. */ readonly meldungsdauerSekunden: number; /** * Selbst gepruefte Fundstellen im Regelwerk, je Sachgebiet. * * Das Programm gibt Abschnitts- und Tabellennummern bewusst nicht vor, weil * sie sich zwischen den Ausgaben unterscheiden. Wer die genaue Fundstelle aus * seiner Ausgabe eintraegt, bekommt sie in der Oberflaeche und in den * Planunterlagen ausgegeben. Die Angabe gilt programmweit, nicht je Projekt - * die Gliederung des Regelwerks ist schliesslich fuer alle Projekte dieselbe. */ readonly fundstellen: Readonly>; } /** * Waehlbare Anzeigedauern in Sekunden; `0` heisst "stehen lassen". * * 50 s ist das Zehnfache der Vorgabe - der Wert, den WCAG 2.2.1 fuer die * Anpassung ausdruecklich nennt. Feste Stufen und kein freies Zahlenfeld: Eine * Anzeigedauer von 0,3 s waere unbrauchbar, eine von zwei Stunden dasselbe wie * "stehen lassen", nur mit einer Wartezeit dahinter. */ export const MELDUNGSDAUER_STUFEN: readonly number[] = [5, 10, 20, 50, 0]; export const DEFAULT_APP_SETTINGS: AppSettings = { theme: 'system', autoSaveEnabled: true, autoSaveIntervalSeconds: 120, locale: 'de', meldungsdauerSekunden: 5, fundstellen: {}, }; export function loadAppSettings(): AppSettings { const store = storage(); if (!store) return DEFAULT_APP_SETTINGS; const raw = store.getItem(KEY_SETTINGS); if (raw === null) return DEFAULT_APP_SETTINGS; try { const parsed = JSON.parse(raw) as Partial; return { theme: parsed.theme === 'hell' || parsed.theme === 'dunkel' || parsed.theme === 'system' ? parsed.theme : DEFAULT_APP_SETTINGS.theme, autoSaveEnabled: typeof parsed.autoSaveEnabled === 'boolean' ? parsed.autoSaveEnabled : DEFAULT_APP_SETTINGS.autoSaveEnabled, autoSaveIntervalSeconds: typeof parsed.autoSaveIntervalSeconds === 'number' && Number.isFinite(parsed.autoSaveIntervalSeconds) && parsed.autoSaveIntervalSeconds >= 15 ? parsed.autoSaveIntervalSeconds : DEFAULT_APP_SETTINGS.autoSaveIntervalSeconds, locale: parsed.locale === 'en' ? 'en' : 'de', // Nur die angebotenen Stufen: Ein von Hand eingetragener Zwischenwert // faellt auf die Vorgabe zurueck, sonst zeigte die Auswahlliste eine // Dauer an, die nicht gilt. meldungsdauerSekunden: typeof parsed.meldungsdauerSekunden === 'number' && MELDUNGSDAUER_STUFEN.includes(parsed.meldungsdauerSekunden) ? parsed.meldungsdauerSekunden : DEFAULT_APP_SETTINGS.meldungsdauerSekunden, fundstellen: leseFundstellen(parsed.fundstellen), }; } catch { return DEFAULT_APP_SETTINGS; } } /** Uebernimmt nur Zeichenketten und begrenzt ihre Laenge. */ function leseFundstellen(wert: unknown): Record { if (wert === null || typeof wert !== 'object' || Array.isArray(wert)) return {}; const ergebnis: Record = {}; for (const [schluessel, eintrag] of Object.entries(wert as Record)) { if (typeof eintrag !== 'string') continue; const text = eintrag.trim(); if (text !== '') ergebnis[schluessel] = text.slice(0, 200); } return ergebnis; } export function saveAppSettings(settings: AppSettings): void { try { storage()?.setItem(KEY_SETTINGS, JSON.stringify(settings)); } catch { /* Einstellungen sind nicht kritisch. */ } } // --- Projektdateien --------------------------------------------------------- export function serializeProject(project: Project): string { return JSON.stringify({ ...project, schemaVersion: CURRENT_SCHEMA_VERSION }, null, 2); } export interface FileOperationResult { readonly ok: boolean; readonly canceled: boolean; readonly filePath: string | null; readonly message: string; /** * Der Vorgang lief durch, aber ob wirklich etwas geschrieben wurde, ist nicht * feststellbar. * * Genau ein Weg ist so: das Herunterladen im Browser. `downloadInBrowser` * klickt ein `` an und erfaehrt danach nichts mehr - ob der * Anwender den Speichern-unter-Kasten abbricht oder eine Richtlinie den * Download sperrt, bleibt hier unbekannt. Fehlt die Angabe, ist der * Schreibvorgang bestaetigt; so verhalten sich alle Wege ueber die * Desktop-Bruecke, die einen Pfad zurueckmelden. * * Wer daran die Aenderungsmarke haengt, darf sie in diesem Fall NICHT * loeschen: "gespeichert" waere eine Behauptung ohne Beleg, und "Neu" oder * "Öffnen" verwuerfen die Arbeit danach ohne Rueckfrage. */ readonly unbestaetigt?: boolean; } /** Speichert das Projekt in eine Datei. */ export async function saveProjectToFile( project: Project, existingPath: string | null, ): Promise { const data = serializeProject(project); const bridge = desktopBridge(); const fileName = suggestFileName(project); if (bridge) { const result = existingPath !== null ? await bridge.writeFile(existingPath, data) : await bridge.saveFile({ defaultName: fileName, filters: [...PROJECT_FILE_FILTERS], data, }); return { ok: result.ok, canceled: result.canceled, filePath: result.filePath, message: result.ok ? `Gespeichert: ${result.filePath ?? fileName}` : (result.error ?? 'Die Datei konnte nicht geschrieben werden.'), }; } downloadInBrowser(data, fileName, 'application/json'); // Nicht "Heruntergeladen": Angestossen ist nicht abgelegt - siehe // `unbestaetigt` an FileOperationResult. return { ok: true, canceled: false, filePath: null, unbestaetigt: true, message: `Herunterladen angestoßen: ${fileName}`, }; } /** Oeffnet eine Projektdatei. */ export async function openProjectFromFile(): Promise< FileOperationResult & { result: MigrationResult | null } > { const bridge = desktopBridge(); if (bridge) { const opened = await bridge.openFile([...PROJECT_FILE_FILTERS]); if (opened.canceled) { return { ok: false, canceled: true, filePath: null, message: '', result: null }; } if (!opened.ok || opened.content === null) { return { ok: false, canceled: false, filePath: null, message: opened.error ?? 'Die Datei konnte nicht gelesen werden.', result: null, }; } return parseFileContent(opened.content, opened.filePath); } const file = await pickFileInBrowser(); if (!file) return { ok: false, canceled: true, filePath: null, message: '', result: null }; const content = await file.text(); return parseFileContent(content, file.name); } /** * Byteordnungszeichen U+FEFF, das Windows vor eine Datei mit "UTF-8 mit BOM" * setzt. * * Ueber den Kennwert geschrieben und nicht als Zeichen: Im Quelltext waere es * unsichtbar - niemand saehe, ob dort eines steht, zwei oder keines. */ const BYTEORDNUNGSZEICHEN = String.fromCharCode(0xfeff); function parseFileContent( content: string, filePath: string | null, ): FileOperationResult & { result: MigrationResult | null } { /* * Ein fuehrendes Byteordnungszeichen abschneiden - und genau eines. * * Der Hauptprozess liest mit 'utf8' und behaelt das Zeichen; `JSON.parse` * wirft daran. Eine .lsap, die der Anwender im Windows-Editor mit "UTF-8 mit * BOM" gesichert oder ein Skript ueber `Out-File` geschrieben hat, galt * damit als "kein gültiges JSON" - eine falsche Aussage ueber eine * unversehrte Planunterlage. Auf der Schreibseite setzt services/export/csv.ts * dasselbe Zeichen absichtlich; hier fehlte die Entsprechung. * * Hier und nicht im Hauptprozess: An dieser Stelle laufen Desktop- und * Browserweg zusammen. Der Browserweg ist ueber `Blob.text()` ohnehin schon * immun, ein zweites Abschneiden dort wirkungslos. * * Alles darueber hinaus bleibt streng: Ein zweites Kennzeichen ist Inhalt und * scheitert weiter, ebenso eine als UTF-16 gesicherte Datei. */ const text = content.startsWith(BYTEORDNUNGSZEICHEN) ? content.slice(1) : content; let parsed: unknown; try { parsed = JSON.parse(text); } catch (error) { return { ok: false, canceled: false, filePath: null, message: `Die Datei enthält kein gültiges JSON: ${errorText(error)}`, result: null, }; } /* * Sieht der Inhalt ueberhaupt wie ein Projekt aus? * * Ohne diese Wache reichte jedes geparste JSON an `loadProject` durch. Die * Zerlegehelfer in src/domain/model/schema.ts melden bauartbedingt nur * VORHANDENE, unpassende Werte; fehlende Schluessel erzeugen null Meldungen, * und `showImportIssues` unterdrueckt den Hinweiskasten daraufhin ganz. Eine * Einkaufsliste als .json ergab damit ein leeres Projekt namens * "Importiertes Projekt", die Meldung "Projekt geladen." - und weil der * Dateipfad mitkam und `store.replace` mit Pfad die Aenderungsmarke loescht, * meldete die Kopfzeile "gespeichert". Das naechste Strg+S schrieb das leere * Projekt ohne Dialog in genau diese fremde Datei; der Hauptprozess hatte * ihren Pfad beim Oeffnen freigegeben. * * Dieselbe Vorsicht fuehren beide Sitzungswege laengst (siehe UNBRAUCHBAR * weiter oben): "Ein stiller Verlust mit einer Erfolgsmeldung darueber ist * schlimmer als gar keine Meldung." Nur der Dateiweg hatte sie nicht. * * Die Wache ist grosszuegig und laesst jeden gewollten Weg durch: * `serializeProject` schreibt IMMER eine `schemaVersion`, eine 4.x-Datei * traegt 'projektdaten' oder 'signalgruppen'. */ if (!looksLikeProject(parsed)) { return { ok: false, canceled: false, filePath: null, message: 'Die Datei enthält kein Projekt des LSA-Planers. Es wurde nichts geladen und nichts geändert.', result: null, }; } const result = loadProject(parsed); /* * Eine Datei aus einer neueren Fassung behaelt ihren Pfad NICHT. * * `loadProject` meldet sie nur als Hinweiszeile ("Nicht bekannte Angaben * gehen verloren."). Kam der Pfad trotzdem mit, galt der Stand sofort als * gespeichert - fuer einen Inhalt, von dem das Programm selbst gerade gesagt * hat, dass er unvollstaendig eingelesen wurde. Das naechste Speichern lief * ohne Rueckfrage auf dieselbe Datei und stempelte die eigene Schemaversion * darauf; alles, was diese Fassung nicht kennt, war fort, denn * src/domain/model/schema.ts baut das Projekt feldweise neu auf. * * Ohne Pfad erzwingt "Speichern" den Dialog: Es entsteht eine zweite Datei, * die des Absenders bleibt unangetastet. Die Aenderungsmarke bleibt dabei von * selbst stehen, weil `store.replace` sie nur bei einem Pfad loescht. * * Erkannt wird der Fall an der Meldung, die `loadProject` selbst dafuer * setzt, und nicht an einem zweiten Vergleich mit CURRENT_SCHEMA_VERSION: * Der Vergleich steht in src/domain/model/migrate.ts, und er soll dort * allein stehen bleiben. `path: 'schemaVersion'` vergibt die Fachschicht * ausschliesslich hierfuer. */ const ausNeuererFassung = result.issues.some((issue) => issue.path === 'schemaVersion'); if (ausNeuererFassung) { return { ok: true, canceled: false, filePath: null, message: 'Projekt aus einer neueren Programmversion gelesen. Nicht bekannte Angaben fehlen; ' + 'die Datei wird deshalb nicht überschrieben – „Speichern“ fragt nach einem neuen Ziel.', result, }; } return { ok: true, canceled: false, filePath, message: result.migrated ? `Projekt aus Version ${result.sourceVersion} übernommen.` : 'Projekt geladen.', result, }; } /** * Im Dateinamen verbotene Zeichen: die neun druckbaren und der ganze * Steuerzeichenbereich. * * Windows verbietet in einem Dateinamen ausser \ / : * ? " < > | auch alle * Zeichen von U+0000 bis U+001F. Zuvor standen hier nur die neun druckbaren; * von den Steuerzeichen fielen allein \t \n \v \f \r auf, weil sie als * Leerraum gelten und vom Zusammenziehen darunter erfasst werden. Ein U+0001 * oder U+0007 - aus einer Projektdatei erreichbar, weil `str` in * src/domain/model/schema.ts Zeichenketten ungefiltert an `meta.name` und * `meta.projectNumber` durchreicht - stand damit unveraendert im * Vorschlagsnamen. Der Hauptprozess reinigt nicht nach (`path.basename`), und * `open()` scheitert unter Windows mit ENOENT: Der Anwender bekommt einen * Speicherfehler zu einem Zeichen, das er nicht sehen kann. * * Als Escape und nicht als Zeichen geschrieben - dieselbe Begruendung wie bei * `STEUERZEICHEN` in src/render/pdfSurface.ts: Im Quelltext waeren sie * unsichtbar, und Git fuehrt eine Datei mit einem Nullbyte als Binaerdatei. */ // eslint-disable-next-line no-control-regex -- die Steuerzeichen sind hier der Pruefgegenstand; siehe darueber const VERBOTENE_ZEICHEN = /[\x00-\x1f\\/:*?"<>|]/g; /** Hoechstlaenge des Namensteils in ZEICHEN - siehe `suggestFileName`. */ const MAX_NAMENSZEICHEN = 120; export function suggestFileName(project: Project, extension = PROJECT_FILE_EXTENSION): string { const base = [project.meta.projectNumber, project.meta.name] .filter((s) => s.trim() !== '') .join(' - '); const bereinigt = (base === '' ? 'Projekt' : base) // Leerraum zuerst zusammenziehen, dann ersetzen: Sonst wuerden \t und \n // als Steuerzeichen zu Bindestrichen, statt zu einem Leerzeichen zu // werden. .replace(/\s+/g, ' ') .replace(VERBOTENE_ZEICHEN, '-') .trim(); /* * Nach ZEICHEN schneiden, nicht nach UTF-16-Codeeinheiten. * * `slice(0, 120)` zaehlte Codeeinheiten. Lag an der Schnittstelle ein * Zeichen ausserhalb der Grundebene - ein Emoji etwa -, blieb dessen halbe * Ersatzzeichenfolge stehen. Windows ersetzt eine einsame Ersatzhaelfte beim * Anlegen durch U+FFFD; der zurueckgemeldete Pfad wich damit von dem ab, was * das Programm vorgeschlagen hatte. */ const safe = [...bereinigt].slice(0, MAX_NAMENSZEICHEN).join(''); return `${safe}.${extension}`; } /** Speichert beliebige Daten (PDF, CSV) ueber denselben Weg wie Projektdateien. */ export async function saveDataToFile( data: string | Uint8Array, fileName: string, filters: readonly { name: string; extensions: readonly string[] }[], mimeType: string, ): Promise { const bridge = desktopBridge(); if (bridge) { const result = await bridge.saveFile({ defaultName: fileName, filters, data }); return { ok: result.ok, canceled: result.canceled, filePath: result.filePath, message: result.ok ? `Gespeichert: ${result.filePath ?? fileName}` : (result.error ?? 'Die Datei konnte nicht geschrieben werden.'), }; } downloadInBrowser(data, fileName, mimeType); // Wortgleich mit `saveProjectToFile`: derselbe Rueckfallweg, dieselbe // Auskunft. "Heruntergeladen" stand hier zuvor und behauptete eine Ablage, // die niemand bestaetigt hat - siehe `unbestaetigt` an FileOperationResult. return { ok: true, canceled: false, filePath: null, unbestaetigt: true, message: `Herunterladen angestoßen: ${fileName}`, }; } function downloadInBrowser(data: string | Uint8Array, fileName: string, mimeType: string): void { const blob = typeof data === 'string' ? new Blob([data], { type: `${mimeType};charset=utf-8` }) : new Blob([data as BlobPart], { type: mimeType }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName; document.body.append(anchor); anchor.click(); anchor.remove(); // Ohne revokeObjectURL bleibt der Blob bis zum Neuladen im Speicher; der // Altbestand gab keine der erzeugten URLs wieder frei. setTimeout(() => { URL.revokeObjectURL(url); }, 10_000); } function pickFileInBrowser(): Promise { return new Promise((resolve) => { const input = document.createElement('input'); input.type = 'file'; input.accept = `.${PROJECT_FILE_EXTENSION},.json,application/json`; input.addEventListener('change', () => { resolve(input.files?.[0] ?? null); input.remove(); }); input.addEventListener('cancel', () => { resolve(null); input.remove(); }); input.style.display = 'none'; document.body.append(input); input.click(); }); } function isQuotaError(error: unknown): boolean { if (!(error instanceof Error)) return false; return ( error.name === 'QuotaExceededError' || error.name === 'NS_ERROR_DOM_QUOTA_REACHED' || error.message.toLowerCase().includes('quota') ); } function alsFehler(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error); }