lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ src services storage.ts

57,4 KB Rohdatei
src/services/storage.ts — 1555 Zeilen
1 import { loadProject, type MigrationResult } from '@/domain/model/migrate';
2 import { looksLikeProject } from '@/domain/model/schema';
3 import { CURRENT_SCHEMA_VERSION, type Project } from '@/domain/model/project';
4 import { desktopBridge } from '@/platform/bridge';
5
6 /**
7 * Speichern und Laden von Projekten.
8 *
9 * Drei Wege, bewusst getrennt:
10 * - Sitzungsspeicher (IndexedDB): der zuletzt bearbeitete Stand, damit ein
11 * Programmabsturz keine Arbeit kostet.
12 * - Projektdatei (.lsap, JSON): der eigentliche Datenaustausch.
13 * - Wiederherstellungspunkte: mehrere Staende in Ringpuffer-Form.
14 *
15 * Der Altbestand legte alles unter demselben Schluessel ab und ueberschrieb den
16 * gespeicherten Stand beim Programmstart mit dem leeren Projekt, bevor geprueft
17 * wurde, ob ueberhaupt etwas geladen werden sollte.
18 *
19 * WARUM DER SITZUNGSSPEICHER NICHT MEHR IM localStorage LIEGT
20 *
21 * localStorage gibt je Herkunft rund 5 MB und nimmt ausschliesslich
22 * Zeichenketten. Ein Lageplan-Luftbild von 7000 Bildpunkten Kantenlaenge liegt
23 * als Daten-URL im zweistelligen MB-Bereich. Der Sitzungsstand haette also
24 * ausgerechnet bei den groessten Projekten nicht mehr gepasst - die
25 * automatische Sicherung waere still ins Leere gelaufen, waehrend die
26 * Statuszeile weiter "gesichert" gemeldet haette. IndexedDB hat ein Vielfaches
27 * an Platz, legt strukturierte Werte ohne den Umweg ueber Text ab und schreibt
28 * in Transaktionen: Ein Absturz mitten im Schreiben laesst den vorherigen Stand
29 * unangetastet, statt einen halben Datensatz zu hinterlassen.
30 *
31 * Die PROGRAMMEINSTELLUNGEN bleiben im localStorage. Sie sind wenige hundert
32 * Byte gross, werden an drei Stellen mitten im Aufbau der Oberflaeche gelesen
33 * und muessen dort sofort vorliegen (Erscheinungsbild vor dem ersten Anzeigen).
34 * Ein asynchroner Zugriff wuerde dafuer nur Flackern erzeugen, ohne ein
35 * Platzproblem zu loesen, das es hier gar nicht gibt.
36 */
37
38 const STORAGE_PREFIX = 'lsa-planer.v5';
39 const KEY_SESSION = `${STORAGE_PREFIX}.session`;
40 const KEY_RECOVERY = `${STORAGE_PREFIX}.recovery`;
41 const KEY_SETTINGS = `${STORAGE_PREFIX}.settings`;
42 const RECOVERY_SLOTS = 5;
43
44 const DB_NAME = 'lsa-planer.v5';
45 const DB_VERSION = 1;
46 /** Sitzungsstand und Wiederherstellungspunkte. */
47 const LAGER_STAND = 'stand';
48 /** Ausgelagerte Grossdaten, praktisch immer das Luftbild. */
49 const LAGER_GROSSDATEN = 'grossdaten';
50 const SCHLUESSEL_SITZUNG = 'sitzung';
51 const SCHLUESSEL_PUNKTE = 'wiederherstellungspunkte';
52 const SCHLUESSEL_BESCHAEDIGT = 'sitzung.beschaedigt';
53
54 /**
55 * Ab dieser Laenge wird eine Zeichenkette ausgelagert.
56 *
57 * 32768 Zeichen liegt weit ueber allem, was ein Projekt an Text enthaelt
58 * (Namen, Bemerkungen, Fundstellen), und weit unter jeder Bild-Daten-URL.
59 */
60 const GROSSDATEN_GRENZE = 32_768;
61
62 const ERSATZ_HINWEIS =
63 '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.';
64
65 export const PROJECT_FILE_EXTENSION = 'lsap';
66
67 export const PROJECT_FILE_FILTERS = [
68 { name: 'LSA-Planer Projekt', extensions: [PROJECT_FILE_EXTENSION] },
69 { name: 'JSON-Datei', extensions: ['json'] },
70 ] as const;
71
72 export interface StorageResult {
73 readonly ok: boolean;
74 readonly message: string;
75 }
76
77 export interface RecoveryPoint {
78 readonly savedAt: string;
79 readonly projectName: string;
80 readonly project: unknown;
81 }
82
83 /** Womit der Sitzungsstand tatsaechlich gesichert wird. */
84 export type Speicherart = 'indexeddb' | 'browserspeicher' | 'keiner';
85
86 // --- Einspritzbare Umgebung -------------------------------------------------
87
88 /**
89 * Zugang zu den beiden Speichern der Anzeige.
90 *
91 * Der Umweg ueber diese Schnittstelle hat einen einzigen Zweck: Die Pruefung
92 * laeuft in Node, wo es weder `indexedDB` noch `localStorage` gibt. Ohne die
93 * Einspritzung waere ausgerechnet die Fehlerbehandlung - voller Speicher,
94 * abgebrochene Transaktion, fehlende Datenbank - nicht pruefbar, und das sind
95 * die Faelle, in denen ein Anwender seinen Arbeitsstand verliert.
96 */
97 export interface SpeicherUmgebung {
98 readonly indexedDB: IDBFactory | null;
99 readonly localStorage: Storage | null;
100 }
101
102 let umgebung: SpeicherUmgebung | null = null;
103
104 /** Setzt die Speicherumgebung; `null` stellt die des Browsers wieder her. */
105 export function setzeSpeicherUmgebung(neu: SpeicherUmgebung | null): void {
106 umgebung = neu;
107 vorbereitung = null;
108 bekannteGrossdaten.clear();
109 naechsteGrossdatenId = 1;
110 }
111
112 function idbFabrik(): IDBFactory | null {
113 if (umgebung !== null) return umgebung.indexedDB;
114 try {
115 return typeof indexedDB === 'undefined' ? null : indexedDB;
116 } catch {
117 return null;
118 }
119 }
120
121 /** Prueft, ob ein Schluessel-Wert-Speicher zur Verfuegung steht (privater Modus). */
122 function storage(): Storage | null {
123 const kandidat = umgebung !== null ? umgebung.localStorage : browserSpeicher();
124 if (kandidat === null) return null;
125 try {
126 const test = `${STORAGE_PREFIX}.probe`;
127 kandidat.setItem(test, '1');
128 kandidat.removeItem(test);
129 return kandidat;
130 } catch {
131 return null;
132 }
133 }
134
135 function browserSpeicher(): Storage | null {
136 try {
137 return typeof window === 'undefined' ? null : window.localStorage;
138 } catch {
139 return null;
140 }
141 }
142
143 // --- IndexedDB: Grundlagen --------------------------------------------------
144
145 let vorbereitung: Promise<IDBDatabase | null> | null = null;
146
147 /**
148 * Liefert die geoeffnete Datenbank oder `null`, wenn IndexedDB fehlt oder
149 * gesperrt ist. Wirft nie - der Ersatzweg uebernimmt dann.
150 */
151 function sitzungsspeicher(): Promise<IDBDatabase | null> {
152 vorbereitung ??= (async () => {
153 const db = await oeffneDatenbank();
154 if (db === null) return null;
155 // Beide Schritte duerfen den Start nicht verhindern: Ohne sie arbeitet die
156 // Datenbank weiter, nur eben ohne Uebernahme bzw. mit neu vergebenen
157 // Kennungen.
158 try {
159 await lerneVergebeneIds(db);
160 } catch {
161 /* Kennungen werden dann ab 1 vergeben; siehe naechsteGrossdatenId. */
162 }
163 try {
164 await uebernehmeAltbestand(db);
165 } catch {
166 /* Der Altbestand bleibt liegen und wird beim naechsten Start erneut versucht. */
167 }
168 return db;
169 })();
170 return vorbereitung;
171 }
172
173 function oeffneDatenbank(): Promise<IDBDatabase | null> {
174 const fabrik = idbFabrik();
175 if (fabrik === null) return Promise.resolve(null);
176 return new Promise<IDBDatabase | null>((erfuellen) => {
177 let entschieden = false;
178 const entscheide = (db: IDBDatabase | null): void => {
179 if (entschieden) {
180 // Zu spaet gekommene Verbindung sofort wieder freigeben, sonst blockiert
181 // sie spaeter die Versionsanhebung in einem anderen Fenster.
182 db?.close();
183 return;
184 }
185 entschieden = true;
186 erfuellen(db);
187 };
188
189 let anfrage: IDBOpenDBRequest;
190 try {
191 anfrage = fabrik.open(DB_NAME, DB_VERSION);
192 } catch {
193 entscheide(null);
194 return;
195 }
196
197 anfrage.onupgradeneeded = () => {
198 const db = anfrage.result;
199 if (!db.objectStoreNames.contains(LAGER_STAND)) db.createObjectStore(LAGER_STAND);
200 if (!db.objectStoreNames.contains(LAGER_GROSSDATEN)) db.createObjectStore(LAGER_GROSSDATEN);
201 };
202 anfrage.onsuccess = () => {
203 const db = anfrage.result;
204 db.onversionchange = () => {
205 // Ein anderes Fenster hebt die Version an. Wer die Verbindung haelt,
206 // laesst dort den Start haengen - also loslassen.
207 db.close();
208 vorbereitung = null;
209 };
210 entscheide(db);
211 };
212 anfrage.onerror = () => {
213 entscheide(null);
214 };
215 // Ein blockiertes Oeffnen darf den Programmstart nicht anhalten: Der
216 // Anwender schliesst das andere Fenster vielleicht nie.
217 anfrage.onblocked = () => {
218 entscheide(null);
219 };
220 });
221 }
222
223 /**
224 * Fuehrt Arbeit in EINER Transaktion aus und loest das Versprechen erst, wenn
225 * die Transaktion abgeschlossen ist.
226 *
227 * Das ist der Kern der Zusicherung "kein Datenverlust bei einem Fehler mitten
228 * im Schreiben": Vor `oncomplete` ist nichts dauerhaft, nach `onabort` ist
229 * nichts davon uebrig geblieben. Wer hier "gesichert" gemeldet bekommt, hat
230 * auch wirklich einen vollstaendigen Datensatz auf der Platte.
231 */
232 function inTransaktion<T>(
233 db: IDBDatabase,
234 lager: readonly string[],
235 modus: IDBTransactionMode,
236 arbeit: (tx: IDBTransaction, fertig: (wert: T) => void) => void,
237 ): Promise<T> {
238 return new Promise<T>((erfuellen, ablehnen) => {
239 let ergebnis: T | undefined;
240 let tx: IDBTransaction;
241 try {
242 tx = db.transaction([...lager], modus);
243 } catch (fehler) {
244 ablehnen(alsFehler(fehler));
245 return;
246 }
247 tx.oncomplete = () => {
248 erfuellen(ergebnis as T);
249 };
250 tx.onerror = () => {
251 ablehnen(tx.error ?? new Error('Die Transaktion ist fehlgeschlagen.'));
252 };
253 tx.onabort = () => {
254 ablehnen(tx.error ?? new Error('Die Transaktion wurde abgebrochen.'));
255 };
256 try {
257 arbeit(tx, (wert) => {
258 ergebnis = wert;
259 });
260 } catch (fehler) {
261 try {
262 tx.abort();
263 } catch {
264 /* Bereits beendet. */
265 }
266 ablehnen(alsFehler(fehler));
267 }
268 });
269 }
270
271 /**
272 * Alle Zugriffe laufen nacheinander.
273 *
274 * Zwei Gruende. Erstens duerfen sich zwei Sicherungen nicht ueberholen - sonst
275 * bliebe der aeltere Stand als der zuletzt geschriebene stehen. Zweitens wird
276 * das Ausduennen der Grossdaten (siehe raeumeGrossdatenAuf) erst dadurch
277 * eindeutig: Wuerde ein Schreibvorgang seine Zeiger aufbauen, waehrend ein
278 * anderer gerade nicht mehr benoetigte Bilddaten loescht, koennte er auf ein
279 * bereits geloeschtes Bild zeigen - das Luftbild waere weg, die daraus
280 * abgegriffenen Wege aber noch im Projekt.
281 */
282 let warteschlange: Promise<unknown> = Promise.resolve();
283
284 function nacheinander<T>(arbeit: () => Promise<T>): Promise<T> {
285 const naechste = warteschlange.then(arbeit, arbeit);
286 warteschlange = naechste.catch(() => undefined);
287 return naechste;
288 }
289
290 // --- Grossdaten (Luftbild) --------------------------------------------------
291
292 /**
293 * WARUM DAS LUFTBILD GETRENNT ABGELEGT WIRD
294 *
295 * Die Zusicherung "fuenf Wiederherstellungspunkte" bleibt bestehen. Fuenf
296 * VOLLKOPIEN eines 20-MB-Projekts waeren aber 100 MB, die alle 120 s neu
297 * geschrieben wuerden - und das fuer Daten, die sich zwischen den Staenden
298 * praktisch nie unterscheiden: Das Luftbild wird einmal abgerufen und danach
299 * nur noch bezeichnet. Was sich aendert, sind die gezeichneten Linien, die
300 * Signalgruppen, die Phasen - zusammen einige zehn Kilobyte.
301 *
302 * Deshalb: Jede Zeichenkette ab GROSSDATEN_GRENZE wandert in ein eigenes Lager
303 * und bleibt im Projekt nur als Zeiger stehen. Fuenf Wiederherstellungspunkte
304 * mit demselben Luftbild kosten damit einmal das Bild und fuenfmal das Geruest.
305 *
306 * Gleichheit wird VOLLSTAENDIG geprueft, nicht ueber eine Pruefsumme. Zwei
307 * verschiedene Bilder mit gleicher Pruefsumme wuerden einem Stand das Luftbild
308 * eines anderen unterschieben; die daraus abgegriffenen Raeum- und Einfahrwege
309 * waeren dann fachlich falsch, ohne dass es jemandem auffiele. Ein voller
310 * Vergleich schliesst das aus und kostet bei ungleichen Bildern ohnehin nur
311 * wenige Zeichen, weil sie sich frueh unterscheiden.
312 */
313 interface Grossdatenzeiger {
314 readonly grossdatenId: number;
315 readonly zeichen: number;
316 }
317
318 /** Kennung -> Inhalt, fuer alles, was in dieser Sitzung geschrieben oder gelesen wurde. */
319 const bekannteGrossdaten = new Map<number, string>();
320 let naechsteGrossdatenId = 1;
321
322 function istGrossdatenzeiger(wert: unknown): wert is Grossdatenzeiger {
323 if (wert === null || typeof wert !== 'object' || Array.isArray(wert)) return false;
324 const satz = wert as Record<string, unknown>;
325 return typeof satz['grossdatenId'] === 'number' && typeof satz['zeichen'] === 'number';
326 }
327
328 function teileGrossdatenAb(wert: unknown): { gerippe: unknown; neue: Map<number, string> } {
329 const neue = new Map<number, string>();
330 return { gerippe: ersetzeDurchZeiger(wert, neue), neue };
331 }
332
333 function ersetzeDurchZeiger(wert: unknown, neue: Map<number, string>): unknown {
334 if (typeof wert === 'string') {
335 if (wert.length < GROSSDATEN_GRENZE) return wert;
336 const zeiger: Grossdatenzeiger = {
337 grossdatenId: kennungFuer(wert, neue),
338 zeichen: wert.length,
339 };
340 return zeiger;
341 }
342 if (Array.isArray(wert)) return wert.map((eintrag) => ersetzeDurchZeiger(eintrag, neue));
343 if (wert === null || typeof wert !== 'object') return wert;
344 const ergebnis: Record<string, unknown> = {};
345 for (const [name, eintrag] of Object.entries(wert)) {
346 ergebnis[name] = ersetzeDurchZeiger(eintrag, neue);
347 }
348 return ergebnis;
349 }
350
351 function kennungFuer(text: string, neue: Map<number, string>): number {
352 for (const [id, vorhanden] of bekannteGrossdaten) {
353 if (vorhanden.length === text.length && vorhanden === text) return id;
354 }
355 const id = naechsteGrossdatenId;
356 naechsteGrossdatenId += 1;
357 bekannteGrossdaten.set(id, text);
358 neue.set(id, text);
359 return id;
360 }
361
362 /**
363 * Nimmt vergebene Kennungen zurueck, deren Inhalt nie angekommen ist.
364 *
365 * Nach einer abgebrochenen Transaktion steht die Kennung noch in der
366 * Merkliste, das Bild aber nicht in der Datenbank. Bliebe das so, wuerde der
367 * naechste Schreibvorgang das Bild fuer bereits gespeichert halten und nur den
368 * Zeiger ablegen - der Lageplan waere nach dem naechsten Start ohne Luftbild,
369 * waehrend die daraus abgegriffenen Raeumwege im Projekt stehen blieben.
370 */
371 function vergissGrossdaten(neue: ReadonlyMap<number, string>): void {
372 for (const id of neue.keys()) bekannteGrossdaten.delete(id);
373 }
374
375 function sammleGrossdatenIds(wert: unknown, ziel: Set<number>): void {
376 if (Array.isArray(wert)) {
377 for (const eintrag of wert) sammleGrossdatenIds(eintrag, ziel);
378 return;
379 }
380 if (wert === null || typeof wert !== 'object') return;
381 if (istGrossdatenzeiger(wert)) {
382 ziel.add(wert.grossdatenId);
383 return;
384 }
385 for (const eintrag of Object.values(wert)) sammleGrossdatenIds(eintrag, ziel);
386 }
387
388 function fuegeGrossdatenEin(wert: unknown, daten: ReadonlyMap<number, string>): unknown {
389 if (Array.isArray(wert)) return wert.map((eintrag) => fuegeGrossdatenEin(eintrag, daten));
390 if (wert === null || typeof wert !== 'object') return wert;
391 if (istGrossdatenzeiger(wert)) {
392 // Fehlt der Inhalt, geht das BILD verloren, nicht der Plan: Massstab,
393 // Haltlinien und Fahrlinien liegen getrennt davon und bleiben gueltig. Ein
394 // leeres Bild sieht der Anwender sofort; ein untergeschobenes nicht.
395 return daten.get(wert.grossdatenId) ?? '';
396 }
397 const ergebnis: Record<string, unknown> = {};
398 for (const [name, eintrag] of Object.entries(wert)) {
399 ergebnis[name] = fuegeGrossdatenEin(eintrag, daten);
400 }
401 return ergebnis;
402 }
403
404 /**
405 * Loescht Grossdaten, auf die kein gespeicherter Stand mehr zeigt.
406 *
407 * Muss in derselben Transaktion laufen wie das Schreiben des Standes, damit
408 * die Entscheidung auf genau den Daten beruht, die gleich festgeschrieben
409 * werden.
410 */
411 function raeumeGrossdatenAuf(stand: IDBObjectStore, gross: IDBObjectStore): void {
412 const sitzung = stand.get(SCHLUESSEL_SITZUNG);
413 sitzung.onsuccess = () => {
414 const punkte = stand.get(SCHLUESSEL_PUNKTE);
415 punkte.onsuccess = () => {
416 const beschaedigt = stand.get(SCHLUESSEL_BESCHAEDIGT);
417 beschaedigt.onsuccess = () => {
418 const gebraucht = new Set<number>();
419 sammleGrossdatenIds(sitzung.result, gebraucht);
420 sammleGrossdatenIds(punkte.result, gebraucht);
421 sammleGrossdatenIds(beschaedigt.result, gebraucht);
422 const schluessel = gross.getAllKeys();
423 schluessel.onsuccess = () => {
424 for (const key of schluessel.result) {
425 if (typeof key !== 'number' || gebraucht.has(key)) continue;
426 gross.delete(key);
427 // Auch aus der Merkliste nehmen, sonst gaebe kennungFuer() spaeter
428 // eine Kennung heraus, deren Inhalt nicht mehr existiert - das
429 // Luftbild waere beim naechsten Laden verschwunden. Bricht die
430 // Transaktion ab, wird das Bild lediglich einmal zu viel
431 // geschrieben; das ist die harmlose Richtung des Irrtums.
432 bekannteGrossdaten.delete(key);
433 }
434 };
435 };
436 };
437 };
438 }
439
440 /** Liest die zu einem Geruest gehoerenden Grossdaten nach und setzt sie ein. */
441 function ladeGrossdaten(
442 gross: IDBObjectStore,
443 gerippe: unknown,
444 weiter: (vollstaendig: unknown) => void,
445 ): void {
446 const ids = new Set<number>();
447 sammleGrossdatenIds(gerippe, ids);
448 if (ids.size === 0) {
449 weiter(gerippe);
450 return;
451 }
452 const daten = new Map<number, string>();
453 let offen = ids.size;
454 for (const id of ids) {
455 const anfrage = gross.get(id);
456 anfrage.onsuccess = () => {
457 if (typeof anfrage.result === 'string') {
458 daten.set(id, anfrage.result);
459 // Merken, damit die naechste Sicherung dasselbe Bild nicht erneut
460 // schreibt. Ohne das kostete jeder Programmstart eine ueberfluessige
461 // 20-MB-Kopie.
462 bekannteGrossdaten.set(id, anfrage.result);
463 }
464 offen -= 1;
465 if (offen === 0) weiter(fuegeGrossdatenEin(gerippe, daten));
466 };
467 }
468 }
469
470 async function lerneVergebeneIds(db: IDBDatabase): Promise<void> {
471 const schluessel = await inTransaktion<IDBValidKey[]>(
472 db,
473 [LAGER_GROSSDATEN],
474 'readonly',
475 (tx, fertig) => {
476 const anfrage = tx.objectStore(LAGER_GROSSDATEN).getAllKeys();
477 anfrage.onsuccess = () => {
478 fertig(anfrage.result);
479 };
480 },
481 );
482 for (const key of schluessel) {
483 if (typeof key === 'number' && key >= naechsteGrossdatenId) naechsteGrossdatenId = key + 1;
484 }
485 }
486
487 // --- Sitzungsspeicher -------------------------------------------------------
488
489 interface Sitzungssatz {
490 readonly gespeichertAm: string;
491 readonly projekt: unknown;
492 }
493
494 /**
495 * Sichert den Arbeitsstand.
496 *
497 * ASYNCHRON, anders als frueher: IndexedDB kennt keinen synchronen Zugriff.
498 * Das Versprechen wird erst erfuellt, wenn die Transaktion abgeschlossen ist -
499 * ein `await` darauf bedeutet also wirklich "liegt sicher auf der Platte".
500 */
501 export function saveSession(project: Project): Promise<StorageResult> {
502 return nacheinander(async () => {
503 const db = await sitzungsspeicher();
504 if (db === null) return schreibeSitzungErsatzweise(project);
505 try {
506 await schreibeSitzung(db, project);
507 return { ok: true, message: '' };
508 } catch (fehler) {
509 if (isQuotaError(fehler)) {
510 // Bei vollem Speicher werden zuerst die Wiederherstellungspunkte
511 // freigegeben; der aktuelle Stand hat Vorrang.
512 try {
513 await leereWiederherstellungspunkte(db);
514 await schreibeSitzung(db, project);
515 return {
516 ok: true,
517 message:
518 'Der Speicher war voll. Die Wiederherstellungspunkte wurden gelöscht, um den aktuellen Stand zu sichern.',
519 };
520 } catch {
521 return {
522 ok: false,
523 message:
524 'Der Speicher ist voll und der Stand konnte nicht gesichert werden. Speichern Sie das Projekt in eine Datei.',
525 };
526 }
527 }
528 return {
529 ok: false,
530 message: `Der Stand konnte nicht gesichert werden: ${errorText(fehler)}`,
531 };
532 }
533 });
534 }
535
536 async function schreibeSitzung(
537 db: IDBDatabase,
538 project: Project,
539 jetzt: Date = new Date(),
540 ): Promise<void> {
541 const { gerippe, neue } = teileGrossdatenAb(project);
542 const satz: Sitzungssatz = { gespeichertAm: jetzt.toISOString(), projekt: gerippe };
543 try {
544 await inTransaktion<void>(db, [LAGER_STAND, LAGER_GROSSDATEN], 'readwrite', (tx, fertig) => {
545 const stand = tx.objectStore(LAGER_STAND);
546 const gross = tx.objectStore(LAGER_GROSSDATEN);
547 // Erst die Grossdaten, dann der Zeiger darauf: Bricht die Transaktion ab,
548 // ist beides weg und der vorherige Stand vollstaendig erhalten.
549 for (const [id, text] of neue) gross.put(text, id);
550 stand.put(satz, SCHLUESSEL_SITZUNG);
551 raeumeGrossdatenAuf(stand, gross);
552 fertig(undefined);
553 });
554 } catch (fehler) {
555 vergissGrossdaten(neue);
556 throw fehler;
557 }
558 }
559
560 /**
561 * Ausgang eines Ladeversuchs.
562 *
563 * Warum das unterschieden werden muss: Es gibt drei voellig verschiedene
564 * Gruende, aus denen kein Arbeitsstand zurueckkommt - es wurde nie einer
565 * gespeichert, die Datenbank laesst sich nicht lesen, oder der Satz ist
566 * beschaedigt. Bis hierher lieferten alle drei dasselbe `null`, und der
567 * Programmstart konnte sie nicht auseinanderhalten. Der Anwender bekam in
568 * jedem Fall ein leeres Projekt, ohne ein Wort - auch dann, wenn sein
569 * Arbeitsstand noch da, aber unlesbar war.
570 */
571 export type Sitzungsbefund =
572 | { readonly art: 'leer' }
573 | { readonly art: 'geladen'; readonly stand: MigrationResult }
574 | { readonly art: 'nicht-lesbar' }
575 /** Beiseitegelegt statt geloescht, damit sich noch etwas retten laesst. */
576 | { readonly art: 'beschaedigt'; readonly beiseitegelegt: boolean };
577
578 /** Laedt den zuletzt gesicherten Arbeitsstand und sagt, was dabei herauskam. */
579 export function ladeSitzung(): Promise<Sitzungsbefund> {
580 return nacheinander(async () => {
581 const db = await sitzungsspeicher();
582 if (db === null) return leseSitzungErsatzweise();
583
584 let roh: unknown;
585 try {
586 roh = await leseSitzung(db);
587 } catch {
588 // Lesefehler: Der Datensatz bleibt unangetastet. Ihn wegen einer
589 // abgebrochenen Transaktion beiseitezulegen, hiesse einen womoeglich
590 // heilen Stand aus dem Weg zu raeumen.
591 return { art: 'nicht-lesbar' };
592 }
593 if (roh === null) return { art: 'leer' };
594 try {
595 // Ein Satz ohne Projektfeld ist beschaedigt und nicht etwa ein leeres
596 // Projekt. loadProject wuerde daraus klaglos eines bauen.
597 if (roh === UNBRAUCHBAR) throw new Error('Sitzungssatz ohne Projekt.');
598 return { art: 'geladen', stand: loadProject(roh) };
599 } catch {
600 let beiseitegelegt = true;
601 try {
602 await legeBeschaedigtenStandBeiseite(db);
603 } catch {
604 /* Dann bleibt er eben liegen und wird beim naechsten Start erneut geprueft. */
605 beiseitegelegt = false;
606 }
607 return { art: 'beschaedigt', beiseitegelegt };
608 }
609 });
610 }
611
612 /**
613 * Laedt den zuletzt gesicherten Arbeitsstand.
614 *
615 * Duenner Aufsatz auf ladeSitzung() fuer alle Stellen, die nur den Stand
616 * brauchen und nicht den Grund seines Fehlens.
617 */
618 export async function loadSession(): Promise<MigrationResult | null> {
619 const befund = await ladeSitzung();
620 return befund.art === 'geladen' ? befund.stand : null;
621 }
622
623 /**
624 * Ein Satz, der zwar dasteht, aber keiner ist.
625 *
626 * Ohne diese Unterscheidung lief ein Satz ohne Projektfeld in
627 * `loadProject(undefined)`. Die Schemapruefung repariert daraus ein LEERES
628 * Projekt, und das Programm meldete "Der zuletzt bearbeitete Stand wurde
629 * wiederhergestellt" - fuer ein Projekt, das nichts enthielt. Ein stiller
630 * Verlust mit einer Erfolgsmeldung darueber ist schlimmer als gar keine
631 * Meldung.
632 */
633 const UNBRAUCHBAR = Symbol('sitzungssatz-unbrauchbar');
634
635 function leseSitzung(db: IDBDatabase): Promise<unknown> {
636 return inTransaktion<unknown>(db, [LAGER_STAND, LAGER_GROSSDATEN], 'readonly', (tx, fertig) => {
637 const stand = tx.objectStore(LAGER_STAND);
638 const gross = tx.objectStore(LAGER_GROSSDATEN);
639 const anfrage = stand.get(SCHLUESSEL_SITZUNG);
640 anfrage.onsuccess = () => {
641 const satz = anfrage.result as Sitzungssatz | undefined;
642 // Die Zusicherung eine Zeile hoeher ist eine Behauptung ueber fremden
643 // Inhalt: Der Satz stammt aus IndexedDB und kann von jeder frueheren
644 // Fassung dieses Programms geschrieben worden sein. Der Linter haelt
645 // die Abfrage auf null deshalb fuer ueberfluessig; sie bleibt, weil ein
646 // abgelegtes null sonst in die Abfrage darunter faellt: `typeof null`
647 // ist 'object', der erste Teil greift also nicht, und der Zugriff auf
648 // `.projekt` wirft einen TypeError - hier im onsuccess-Behandler,
649 // statt dass `fertig(null)` das harmlose "kein Stand" meldet.
650 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Satz aus IndexedDB, der Typ ist keine Messung; siehe darueber
651 if (satz === undefined || satz === null) {
652 fertig(null);
653 return;
654 }
655 if (typeof satz !== 'object' || satz.projekt === undefined || satz.projekt === null) {
656 fertig(UNBRAUCHBAR);
657 return;
658 }
659 ladeGrossdaten(gross, satz.projekt, (vollstaendig) => {
660 fertig(vollstaendig);
661 });
662 };
663 });
664 }
665
666 function legeBeschaedigtenStandBeiseite(db: IDBDatabase): Promise<void> {
667 return inTransaktion<void>(db, [LAGER_STAND], 'readwrite', (tx, fertig) => {
668 const stand = tx.objectStore(LAGER_STAND);
669 const anfrage = stand.get(SCHLUESSEL_SITZUNG);
670 anfrage.onsuccess = () => {
671 if (anfrage.result === undefined) return;
672 stand.put(anfrage.result, SCHLUESSEL_BESCHAEDIGT);
673 stand.delete(SCHLUESSEL_SITZUNG);
674 };
675 fertig(undefined);
676 });
677 }
678
679 /** Welcher Speicher den Sitzungsstand tatsaechlich traegt. */
680 export async function sitzungsspeicherArt(): Promise<Speicherart> {
681 if ((await sitzungsspeicher()) !== null) return 'indexeddb';
682 return storage() !== null ? 'browserspeicher' : 'keiner';
683 }
684
685 // --- Uebernahme des Altbestands ---------------------------------------------
686
687 /** Was in IndexedDB bereits liegt - je Schluessel getrennt. */
688 interface Datenbankstand {
689 readonly sitzungBelegt: boolean;
690 /** Zeitpunkt des Sitzungssatzes, oder `null`, wenn er keinen lesbaren traegt. */
691 readonly sitzungAm: string | null;
692 readonly punkteBelegt: boolean;
693 }
694
695 /**
696 * Holt einen im Ersatzspeicher liegenden Stand nach IndexedDB.
697 *
698 * Reihenfolge ist hier alles: Erst wird nach IndexedDB geschrieben und die
699 * Transaktion abgewartet, erst danach wird der alte Eintrag abgeraeumt.
700 * Andersherum wuerde ein Absturz zwischen beiden Schritten den letzten
701 * Arbeitsstand vernichten.
702 *
703 * ES IST NICHT NUR EIN ALTBESTAND. Unter demselben Schluessel schreibt
704 * `schreibeSitzungErsatzweise` HEUTE, sobald sich IndexedDB einmal nicht
705 * oeffnen liess (`oeffneDatenbank` liefert dann `null`, und die ganze Sitzung
706 * laeuft im Ersatzspeicher). Die frueher hier stehende Annahme "liegt in
707 * IndexedDB bereits ein Stand, ist der localStorage-Eintrag der aeltere" traf
708 * dann nicht zu: Der Ersatzstand war der JUENGERE, wurde nicht uebernommen und
709 * anschliessend geloescht - zwei Stunden Arbeit ohne ein Wort fort.
710 *
711 * Deshalb wird verglichen statt vermutet, und was nicht uebernommen wird, geht
712 * beiseite statt in den Papierkorb. Sitzung und Wiederherstellungspunkte werden
713 * dabei GETRENNT geprueft: Frueher entschied allein der Sitzungsschluessel, und
714 * die Punkte verschwanden mit, obwohl es in der Datenbank keinen Ersatz fuer
715 * sie gab.
716 */
717 async function uebernehmeAltbestand(db: IDBDatabase): Promise<void> {
718 const store = storage();
719 if (store === null) return;
720 const rohSitzung = leseEintrag(store, KEY_SESSION);
721 const rohPunkte = leseEintrag(store, KEY_RECOVERY);
722 if (rohSitzung === null && rohPunkte === null) return;
723
724 const vorhanden = await inTransaktion<Datenbankstand>(
725 db,
726 [LAGER_STAND],
727 'readonly',
728 (tx, fertig) => {
729 const stand = tx.objectStore(LAGER_STAND);
730 const sitzung = stand.get(SCHLUESSEL_SITZUNG);
731 sitzung.onsuccess = () => {
732 const punkte = stand.get(SCHLUESSEL_PUNKTE);
733 punkte.onsuccess = () => {
734 const satz = sitzung.result as Partial<Sitzungssatz> | undefined;
735 fertig({
736 sitzungBelegt: satz !== undefined,
737 sitzungAm: typeof satz?.gespeichertAm === 'string' ? satz.gespeichertAm : null,
738 punkteBelegt: Array.isArray(punkte.result) && punkte.result.length > 0,
739 });
740 };
741 };
742 },
743 );
744
745 /*
746 * Uebernommen wird, was nachweislich juenger ist - und alles, wofuer die
747 * Datenbank gar nichts hat.
748 *
749 * Bei fehlendem Zeitpunkt auf einer der beiden Seiten laesst sich die
750 * Reihenfolge nicht feststellen; dann behaelt die Datenbank den Vortritt und
751 * der Ersatzstand wird beiseitegelegt. Er ueberschreibt also nie etwas, von
752 * dem nicht feststeht, dass es aelter ist.
753 */
754 const ersatz = rohSitzung === null ? null : ersatzsitzung(rohSitzung);
755 const zuUebernehmen =
756 ersatz !== null &&
757 (!vorhanden.sitzungBelegt || istJuenger(ersatz.gespeichertAm, vorhanden.sitzungAm))
758 ? ersatz
759 : null;
760 const altpunkte = jsonOderNull(rohPunkte);
761 const punkteUebernehmen = Array.isArray(altpunkte) && !vorhanden.punkteBelegt;
762
763 if (zuUebernehmen !== null || punkteUebernehmen) {
764 const { gerippe, neue } = teileGrossdatenAb({
765 sitzung: zuUebernehmen === null ? null : zuUebernehmen.projekt,
766 punkte: punkteUebernehmen ? altpunkte : null,
767 });
768 const geteilt = gerippe as { sitzung: unknown; punkte: unknown };
769 try {
770 await inTransaktion<void>(db, [LAGER_STAND, LAGER_GROSSDATEN], 'readwrite', (tx, fertig) => {
771 const stand = tx.objectStore(LAGER_STAND);
772 const gross = tx.objectStore(LAGER_GROSSDATEN);
773 for (const [id, text] of neue) gross.put(text, id);
774 if (zuUebernehmen !== null) {
775 const satz: Sitzungssatz = {
776 // Der mitgefuehrte Zeitpunkt, nicht der von jetzt: Ein erfundener
777 // Zeitpunkt liesse den Stand beim naechsten Vergleich juenger
778 // aussehen, als er ist.
779 gespeichertAm: zuUebernehmen.gespeichertAm ?? new Date().toISOString(),
780 projekt: geteilt.sitzung,
781 };
782 stand.put(satz, SCHLUESSEL_SITZUNG);
783 }
784 if (Array.isArray(geteilt.punkte)) {
785 stand.put(geteilt.punkte.slice(0, RECOVERY_SLOTS), SCHLUESSEL_PUNKTE);
786 }
787 fertig(undefined);
788 });
789 } catch (fehler) {
790 // Der Ersatzstand bleibt liegen; beim naechsten Start wird es erneut
791 // versucht. Nichts entfernen, was nicht angekommen ist.
792 vergissGrossdaten(neue);
793 throw fehler;
794 }
795 }
796
797 raeumeErsatzplatzAb(store, KEY_SESSION, zuUebernehmen !== null);
798 raeumeErsatzplatzAb(store, KEY_RECOVERY, punkteUebernehmen);
799 }
800
801 /** Ist `a` nachweislich juenger als `b`? Ohne beide Zeitpunkte: nein. */
802 function istJuenger(a: string | null, b: string | null): boolean {
803 return a !== null && b !== null && a > b;
804 }
805
806 /**
807 * Gibt den Platz im Ersatzspeicher frei.
808 *
809 * Uebernommen: entfernen - sonst wuerde bei jedem Start erneut geprueft.
810 * Nicht uebernommen: beiseitelegen statt loeschen, nach demselben Grundsatz wie
811 * beim beschaedigten Satz ("damit sich noch etwas retten laesst"). Scheitert
812 * das Beiseitelegen, bleibt der Eintrag stehen; ein zweiter Anlauf ist besser
813 * als ein Verlust.
814 */
815 function raeumeErsatzplatzAb(store: Storage, schluessel: string, uebernommen: boolean): void {
816 try {
817 if (!uebernommen) {
818 const roh = store.getItem(schluessel);
819 if (roh !== null && roh !== '') store.setItem(`${schluessel}.beiseite`, roh);
820 }
821 store.removeItem(schluessel);
822 } catch {
823 /* Nicht schlimm: Beim naechsten Start wird der Platz erneut angesehen. */
824 }
825 }
826
827 /**
828 * Zerlegt einen Eintrag des Ersatzspeichers in Zeitpunkt und Projekt.
829 *
830 * Zwei Formen kommen vor: der Satz aus `schreibeSitzungErsatzweise` mit
831 * Zeitpunkt und der nackte Projektstand aus einer aelteren Programmfassung.
832 * Beim nackten Stand ist der Zeitpunkt unbekannt - `null` und nicht etwa der
833 * von jetzt.
834 *
835 * `null` heisst: unbrauchbar. Ein Satz ohne Projekt liefe sonst in
836 * `loadProject(null)`, und die Schemapruefung machte daraus klaglos ein LEERES
837 * Projekt - derselbe stille Verlust, gegen den `UNBRAUCHBAR` im
838 * Datenbankpfad steht.
839 */
840 function ersatzsitzung(roh: string): { gespeichertAm: string | null; projekt: unknown } | null {
841 let gelesen: unknown;
842 try {
843 gelesen = JSON.parse(roh);
844 } catch {
845 return null;
846 }
847 if (gelesen === null || typeof gelesen !== 'object' || Array.isArray(gelesen)) return null;
848 const satz = gelesen as Record<string, unknown>;
849 if (typeof satz['gespeichertAm'] === 'string' && 'projekt' in satz) {
850 const projekt = satz['projekt'];
851 if (projekt === null || projekt === undefined) return null;
852 return { gespeichertAm: satz['gespeichertAm'], projekt };
853 }
854 return { gespeichertAm: null, projekt: gelesen };
855 }
856
857 function leseEintrag(store: Storage, schluessel: string): string | null {
858 try {
859 const roh = store.getItem(schluessel);
860 return roh === null || roh === '' ? null : roh;
861 } catch {
862 return null;
863 }
864 }
865
866 function jsonOderNull(roh: string | null): unknown {
867 if (roh === null) return null;
868 try {
869 return JSON.parse(roh);
870 } catch {
871 return null;
872 }
873 }
874
875 // --- Wiederherstellungspunkte ----------------------------------------------
876
877 /**
878 * Legt einen Wiederherstellungspunkt an.
879 *
880 * DEM ANWENDER wird ein Fehlschlag nicht gemeldet - ein Wiederherstellungspunkt
881 * ist Beiwerk und darf ihn nicht behelligen. Dem AUFRUFER sehr wohl: Das
882 * Versprechen wird abgelehnt, wenn nichts geschrieben wurde.
883 *
884 * Zuvor fing diese Funktion ihren Fehler selbst ab und loeste trotzdem auf.
885 * `AutoSave` setzte daraufhin `letzterPunkt` und sperrte damit jeden weiteren
886 * Versuch fuer denselben Projektzustand - genau das, was der Kommentar dort
887 * ausschliesst ("Ein gescheiterter Punkt liegt nirgends und darf den naechsten
888 * Versuch nicht sperren"). Nicht melden ist nicht dasselbe wie Erfolg melden.
889 *
890 * Wer das Versprechen ignoriert, faengt die Ablehnung mit `.catch()` ab -
891 * sonst endet sie als unbehandelte Ablehnung.
892 */
893 export function addRecoveryPoint(project: Project, now: Date = new Date()): Promise<void> {
894 return nacheinander(async () => {
895 const db = await sitzungsspeicher();
896 if (db === null) {
897 fuegePunktErsatzweiseHinzu(project, now);
898 return;
899 }
900 const { gerippe, neue } = teileGrossdatenAb(project);
901 const neuerPunkt: RecoveryPoint = {
902 savedAt: now.toISOString(),
903 projectName: project.meta.name,
904 project: gerippe,
905 };
906 try {
907 await inTransaktion<void>(db, [LAGER_STAND, LAGER_GROSSDATEN], 'readwrite', (tx, fertig) => {
908 const stand = tx.objectStore(LAGER_STAND);
909 const gross = tx.objectStore(LAGER_GROSSDATEN);
910 for (const [id, text] of neue) gross.put(text, id);
911 const vorhandene = stand.get(SCHLUESSEL_PUNKTE);
912 vorhandene.onsuccess = () => {
913 const bisher = Array.isArray(vorhandene.result) ? (vorhandene.result as unknown[]) : [];
914 stand.put([neuerPunkt, ...bisher].slice(0, RECOVERY_SLOTS), SCHLUESSEL_PUNKTE);
915 raeumeGrossdatenAuf(stand, gross);
916 };
917 fertig(undefined);
918 });
919 } catch (fehler) {
920 // Der Sitzungsstand hat Vorrang; gemeldet wird dem Anwender nichts. Der
921 // Aufrufer erfaehrt es trotzdem, damit er den Stand nicht als gesichert
922 // vermerkt und beim naechsten Takt erneut anlaeuft.
923 vergissGrossdaten(neue);
924 throw fehler;
925 }
926 });
927 }
928
929 export function listRecoveryPoints(): Promise<RecoveryPoint[]> {
930 return nacheinander(async () => {
931 const db = await sitzungsspeicher();
932 if (db === null) return lesePunkteErsatzweise();
933 try {
934 return await inTransaktion<RecoveryPoint[]>(
935 db,
936 [LAGER_STAND, LAGER_GROSSDATEN],
937 'readonly',
938 (tx, fertig) => {
939 const stand = tx.objectStore(LAGER_STAND);
940 const gross = tx.objectStore(LAGER_GROSSDATEN);
941 const anfrage = stand.get(SCHLUESSEL_PUNKTE);
942 anfrage.onsuccess = () => {
943 if (!Array.isArray(anfrage.result)) {
944 fertig([]);
945 return;
946 }
947 ladeGrossdaten(gross, anfrage.result, (vollstaendig) => {
948 fertig(Array.isArray(vollstaendig) ? (vollstaendig as RecoveryPoint[]) : []);
949 });
950 };
951 },
952 );
953 } catch {
954 return [];
955 }
956 });
957 }
958
959 function leereWiederherstellungspunkte(db: IDBDatabase): Promise<void> {
960 return inTransaktion<void>(db, [LAGER_STAND, LAGER_GROSSDATEN], 'readwrite', (tx, fertig) => {
961 const stand = tx.objectStore(LAGER_STAND);
962 const gross = tx.objectStore(LAGER_GROSSDATEN);
963 stand.delete(SCHLUESSEL_PUNKTE);
964 raeumeGrossdatenAuf(stand, gross);
965 fertig(undefined);
966 });
967 }
968
969 // --- Ersatzweg ohne IndexedDB ----------------------------------------------
970
971 /**
972 * Ohne IndexedDB laeuft die Anwendung weiter - im localStorage und mit einer
973 * Ansage. Grosse Projekte passen dort nicht hinein; das muss der Anwender
974 * erfahren, solange er noch handeln kann, statt es beim naechsten Start zu
975 * merken.
976 *
977 * Abgelegt wird dasselbe Paar wie in IndexedDB: Zeitpunkt und Projekt. Frueher
978 * stand hier das nackte Projekt, und damit liess sich beim naechsten Start
979 * nicht feststellen, ob dieser Stand aelter oder juenger ist als der in der
980 * Datenbank - `uebernehmeAltbestand` nahm das Aeltere an und loeschte ihn.
981 */
982 function schreibeSitzungErsatzweise(project: Project, jetzt: Date = new Date()): StorageResult {
983 const store = storage();
984 if (store === null) {
985 return {
986 ok: false,
987 message:
988 'Es steht kein Speicher für den Arbeitsstand zur Verfügung. Speichern Sie das Projekt in eine Datei.',
989 };
990 }
991 const satz: Sitzungssatz = { gespeichertAm: jetzt.toISOString(), projekt: project };
992 const inhalt = JSON.stringify(satz);
993 try {
994 store.setItem(KEY_SESSION, inhalt);
995 return { ok: true, message: ERSATZ_HINWEIS };
996 } catch (fehler) {
997 if (isQuotaError(fehler)) {
998 try {
999 store.removeItem(KEY_RECOVERY);
1000 store.setItem(KEY_SESSION, inhalt);
1001 return {
1002 ok: true,
1003 message:
1004 'Der Speicher war voll. Die Wiederherstellungspunkte wurden gelöscht, um den aktuellen Stand zu sichern.',
1005 };
1006 } catch {
1007 return {
1008 ok: false,
1009 message:
1010 'Der Speicher ist voll und der Stand konnte nicht gesichert werden. Speichern Sie das Projekt in eine Datei.',
1011 };
1012 }
1013 }
1014 return { ok: false, message: `Der Stand konnte nicht gesichert werden: ${errorText(fehler)}` };
1015 }
1016 }
1017
1018 function leseSitzungErsatzweise(): Sitzungsbefund {
1019 const store = storage();
1020 // Kein Speicher ueberhaupt: Es gibt nichts zu laden, aber auch nichts, was
1021 // verloren waere. Das ist "leer" und kein Fehler.
1022 if (store === null) return { art: 'leer' };
1023 const roh = leseEintrag(store, KEY_SESSION);
1024 if (roh === null) return { art: 'leer' };
1025 try {
1026 // `ersatzsitzung` kennt beide Formen - den Satz mit Zeitpunkt und den
1027 // nackten Stand einer aelteren Fassung - und liefert `null`, wenn nichts
1028 // Brauchbares darin steht.
1029 const satz = ersatzsitzung(roh);
1030 if (satz === null) throw new Error('Sitzungssatz ohne Projekt.');
1031 return { art: 'geladen', stand: loadProject(satz.projekt) };
1032 } catch {
1033 let beiseitegelegt = true;
1034 try {
1035 store.setItem(`${KEY_SESSION}.beschaedigt`, roh);
1036 store.removeItem(KEY_SESSION);
1037 } catch {
1038 /* Wenn selbst das nicht geht, ist der Speicher voll - nichts zu tun. */
1039 beiseitegelegt = false;
1040 }
1041 return { art: 'beschaedigt', beiseitegelegt };
1042 }
1043 }
1044
1045 /**
1046 * Legt einen Punkt im Ersatzspeicher ab.
1047 *
1048 * Wirft, wenn nichts abgelegt wurde - aus demselben Grund wie
1049 * `addRecoveryPoint`: Der Aufrufer darf den Stand sonst als gesichert
1050 * vermerken. Der Kontingentfall wiegt hier besonders schwer, weil dabei die
1051 * ganze Punkteliste geleert wird; galte er als Erfolg, bliebe sie fuer diesen
1052 * Arbeitsstand leer.
1053 *
1054 * Ohne jeden Speicher wird NICHT geworfen: Dann gibt es nichts zu wiederholen,
1055 * ein erneuter Versuch alle zwei Minuten schriebe wieder nichts.
1056 */
1057 function fuegePunktErsatzweiseHinzu(project: Project, now: Date): void {
1058 const store = storage();
1059 if (store === null) return;
1060 try {
1061 const naechste = [
1062 { savedAt: now.toISOString(), projectName: project.meta.name, project },
1063 ...lesePunkteErsatzweise(),
1064 ].slice(0, RECOVERY_SLOTS);
1065 store.setItem(KEY_RECOVERY, JSON.stringify(naechste));
1066 } catch (fehler) {
1067 if (isQuotaError(fehler)) {
1068 try {
1069 store.setItem(KEY_RECOVERY, JSON.stringify([]));
1070 } catch {
1071 /* Speicher voll - der Sitzungsstand hat Vorrang. */
1072 }
1073 }
1074 throw alsFehler(fehler);
1075 }
1076 }
1077
1078 function lesePunkteErsatzweise(): RecoveryPoint[] {
1079 const store = storage();
1080 if (store === null) return [];
1081 const roh = leseEintrag(store, KEY_RECOVERY);
1082 if (roh === null) return [];
1083 try {
1084 const gelesen: unknown = JSON.parse(roh);
1085 return Array.isArray(gelesen) ? (gelesen as RecoveryPoint[]) : [];
1086 } catch {
1087 return [];
1088 }
1089 }
1090
1091 // --- Programmeinstellungen --------------------------------------------------
1092
1093 export interface AppSettings {
1094 readonly theme: 'hell' | 'dunkel' | 'system';
1095 readonly autoSaveEnabled: boolean;
1096 readonly autoSaveIntervalSeconds: number;
1097 readonly locale: 'de' | 'en';
1098 /**
1099 * Anzeigedauer der fluechtigen Kurzmeldungen in Sekunden; `0` laesst sie
1100 * stehen (WCAG 2.1 Erfolgskriterium 2.2.1, Befund L8).
1101 *
1102 * Hinweise und Erfolgsmeldungen verschwanden nach fest verdrahteten fuenf
1103 * Sekunden. 2.2.1 verlangt fuer eine vom Inhalt gesetzte Frist mindestens
1104 * einen von drei Wegen: abschalten, auf das Zehnfache verlaengern oder vor
1105 * Ablauf verlaengern. Die Werte in MELDUNGSDAUER_STUFEN decken die ersten
1106 * beiden ab; Warnungen und Fehler standen ohnehin nie unter einer Frist.
1107 *
1108 * Die Zahl ist ein Bezugswert und keine feste Dauer: Aufrufer geben eigene
1109 * Zeiten an (2500 ms fuer "Rueckgaengig: ..."), und die werden im selben
1110 * Verhaeltnis gestreckt - naeheres bei `setzeMeldungsdauer` in
1111 * src/ui/feedback.ts.
1112 */
1113 readonly meldungsdauerSekunden: number;
1114 /**
1115 * Selbst gepruefte Fundstellen im Regelwerk, je Sachgebiet.
1116 *
1117 * Das Programm gibt Abschnitts- und Tabellennummern bewusst nicht vor, weil
1118 * sie sich zwischen den Ausgaben unterscheiden. Wer die genaue Fundstelle aus
1119 * seiner Ausgabe eintraegt, bekommt sie in der Oberflaeche und in den
1120 * Planunterlagen ausgegeben. Die Angabe gilt programmweit, nicht je Projekt -
1121 * die Gliederung des Regelwerks ist schliesslich fuer alle Projekte dieselbe.
1122 */
1123 readonly fundstellen: Readonly<Record<string, string>>;
1124 }
1125
1126 /**
1127 * Waehlbare Anzeigedauern in Sekunden; `0` heisst "stehen lassen".
1128 *
1129 * 50 s ist das Zehnfache der Vorgabe - der Wert, den WCAG 2.2.1 fuer die
1130 * Anpassung ausdruecklich nennt. Feste Stufen und kein freies Zahlenfeld: Eine
1131 * Anzeigedauer von 0,3 s waere unbrauchbar, eine von zwei Stunden dasselbe wie
1132 * "stehen lassen", nur mit einer Wartezeit dahinter.
1133 */
1134 export const MELDUNGSDAUER_STUFEN: readonly number[] = [5, 10, 20, 50, 0];
1135
1136 export const DEFAULT_APP_SETTINGS: AppSettings = {
1137 theme: 'system',
1138 autoSaveEnabled: true,
1139 autoSaveIntervalSeconds: 120,
1140 locale: 'de',
1141 meldungsdauerSekunden: 5,
1142 fundstellen: {},
1143 };
1144
1145 export function loadAppSettings(): AppSettings {
1146 const store = storage();
1147 if (!store) return DEFAULT_APP_SETTINGS;
1148 const raw = store.getItem(KEY_SETTINGS);
1149 if (raw === null) return DEFAULT_APP_SETTINGS;
1150 try {
1151 const parsed = JSON.parse(raw) as Partial<AppSettings>;
1152 return {
1153 theme:
1154 parsed.theme === 'hell' || parsed.theme === 'dunkel' || parsed.theme === 'system'
1155 ? parsed.theme
1156 : DEFAULT_APP_SETTINGS.theme,
1157 autoSaveEnabled:
1158 typeof parsed.autoSaveEnabled === 'boolean'
1159 ? parsed.autoSaveEnabled
1160 : DEFAULT_APP_SETTINGS.autoSaveEnabled,
1161 autoSaveIntervalSeconds:
1162 typeof parsed.autoSaveIntervalSeconds === 'number' &&
1163 Number.isFinite(parsed.autoSaveIntervalSeconds) &&
1164 parsed.autoSaveIntervalSeconds >= 15
1165 ? parsed.autoSaveIntervalSeconds
1166 : DEFAULT_APP_SETTINGS.autoSaveIntervalSeconds,
1167 locale: parsed.locale === 'en' ? 'en' : 'de',
1168 // Nur die angebotenen Stufen: Ein von Hand eingetragener Zwischenwert
1169 // faellt auf die Vorgabe zurueck, sonst zeigte die Auswahlliste eine
1170 // Dauer an, die nicht gilt.
1171 meldungsdauerSekunden:
1172 typeof parsed.meldungsdauerSekunden === 'number' &&
1173 MELDUNGSDAUER_STUFEN.includes(parsed.meldungsdauerSekunden)
1174 ? parsed.meldungsdauerSekunden
1175 : DEFAULT_APP_SETTINGS.meldungsdauerSekunden,
1176 fundstellen: leseFundstellen(parsed.fundstellen),
1177 };
1178 } catch {
1179 return DEFAULT_APP_SETTINGS;
1180 }
1181 }
1182
1183 /** Uebernimmt nur Zeichenketten und begrenzt ihre Laenge. */
1184 function leseFundstellen(wert: unknown): Record<string, string> {
1185 if (wert === null || typeof wert !== 'object' || Array.isArray(wert)) return {};
1186 const ergebnis: Record<string, string> = {};
1187 for (const [schluessel, eintrag] of Object.entries(wert as Record<string, unknown>)) {
1188 if (typeof eintrag !== 'string') continue;
1189 const text = eintrag.trim();
1190 if (text !== '') ergebnis[schluessel] = text.slice(0, 200);
1191 }
1192 return ergebnis;
1193 }
1194
1195 export function saveAppSettings(settings: AppSettings): void {
1196 try {
1197 storage()?.setItem(KEY_SETTINGS, JSON.stringify(settings));
1198 } catch {
1199 /* Einstellungen sind nicht kritisch. */
1200 }
1201 }
1202
1203 // --- Projektdateien ---------------------------------------------------------
1204
1205 export function serializeProject(project: Project): string {
1206 return JSON.stringify({ ...project, schemaVersion: CURRENT_SCHEMA_VERSION }, null, 2);
1207 }
1208
1209 export interface FileOperationResult {
1210 readonly ok: boolean;
1211 readonly canceled: boolean;
1212 readonly filePath: string | null;
1213 readonly message: string;
1214 /**
1215 * Der Vorgang lief durch, aber ob wirklich etwas geschrieben wurde, ist nicht
1216 * feststellbar.
1217 *
1218 * Genau ein Weg ist so: das Herunterladen im Browser. `downloadInBrowser`
1219 * klickt ein `<a download>` an und erfaehrt danach nichts mehr - ob der
1220 * Anwender den Speichern-unter-Kasten abbricht oder eine Richtlinie den
1221 * Download sperrt, bleibt hier unbekannt. Fehlt die Angabe, ist der
1222 * Schreibvorgang bestaetigt; so verhalten sich alle Wege ueber die
1223 * Desktop-Bruecke, die einen Pfad zurueckmelden.
1224 *
1225 * Wer daran die Aenderungsmarke haengt, darf sie in diesem Fall NICHT
1226 * loeschen: "gespeichert" waere eine Behauptung ohne Beleg, und "Neu" oder
1227 * "Öffnen" verwuerfen die Arbeit danach ohne Rueckfrage.
1228 */
1229 readonly unbestaetigt?: boolean;
1230 }
1231
1232 /** Speichert das Projekt in eine Datei. */
1233 export async function saveProjectToFile(
1234 project: Project,
1235 existingPath: string | null,
1236 ): Promise<FileOperationResult> {
1237 const data = serializeProject(project);
1238 const bridge = desktopBridge();
1239 const fileName = suggestFileName(project);
1240
1241 if (bridge) {
1242 const result =
1243 existingPath !== null
1244 ? await bridge.writeFile(existingPath, data)
1245 : await bridge.saveFile({
1246 defaultName: fileName,
1247 filters: [...PROJECT_FILE_FILTERS],
1248 data,
1249 });
1250 return {
1251 ok: result.ok,
1252 canceled: result.canceled,
1253 filePath: result.filePath,
1254 message: result.ok
1255 ? `Gespeichert: ${result.filePath ?? fileName}`
1256 : (result.error ?? 'Die Datei konnte nicht geschrieben werden.'),
1257 };
1258 }
1259
1260 downloadInBrowser(data, fileName, 'application/json');
1261 // Nicht "Heruntergeladen": Angestossen ist nicht abgelegt - siehe
1262 // `unbestaetigt` an FileOperationResult.
1263 return {
1264 ok: true,
1265 canceled: false,
1266 filePath: null,
1267 unbestaetigt: true,
1268 message: `Herunterladen angestoßen: ${fileName}`,
1269 };
1270 }
1271
1272 /** Oeffnet eine Projektdatei. */
1273 export async function openProjectFromFile(): Promise<
1274 FileOperationResult & { result: MigrationResult | null }
1275 > {
1276 const bridge = desktopBridge();
1277
1278 if (bridge) {
1279 const opened = await bridge.openFile([...PROJECT_FILE_FILTERS]);
1280 if (opened.canceled) {
1281 return { ok: false, canceled: true, filePath: null, message: '', result: null };
1282 }
1283 if (!opened.ok || opened.content === null) {
1284 return {
1285 ok: false,
1286 canceled: false,
1287 filePath: null,
1288 message: opened.error ?? 'Die Datei konnte nicht gelesen werden.',
1289 result: null,
1290 };
1291 }
1292 return parseFileContent(opened.content, opened.filePath);
1293 }
1294
1295 const file = await pickFileInBrowser();
1296 if (!file) return { ok: false, canceled: true, filePath: null, message: '', result: null };
1297 const content = await file.text();
1298 return parseFileContent(content, file.name);
1299 }
1300
1301 /**
1302 * Byteordnungszeichen U+FEFF, das Windows vor eine Datei mit "UTF-8 mit BOM"
1303 * setzt.
1304 *
1305 * Ueber den Kennwert geschrieben und nicht als Zeichen: Im Quelltext waere es
1306 * unsichtbar - niemand saehe, ob dort eines steht, zwei oder keines.
1307 */
1308 const BYTEORDNUNGSZEICHEN = String.fromCharCode(0xfeff);
1309
1310 function parseFileContent(
1311 content: string,
1312 filePath: string | null,
1313 ): FileOperationResult & { result: MigrationResult | null } {
1314 /*
1315 * Ein fuehrendes Byteordnungszeichen abschneiden - und genau eines.
1316 *
1317 * Der Hauptprozess liest mit 'utf8' und behaelt das Zeichen; `JSON.parse`
1318 * wirft daran. Eine .lsap, die der Anwender im Windows-Editor mit "UTF-8 mit
1319 * BOM" gesichert oder ein Skript ueber `Out-File` geschrieben hat, galt
1320 * damit als "kein gültiges JSON" - eine falsche Aussage ueber eine
1321 * unversehrte Planunterlage. Auf der Schreibseite setzt services/export/csv.ts
1322 * dasselbe Zeichen absichtlich; hier fehlte die Entsprechung.
1323 *
1324 * Hier und nicht im Hauptprozess: An dieser Stelle laufen Desktop- und
1325 * Browserweg zusammen. Der Browserweg ist ueber `Blob.text()` ohnehin schon
1326 * immun, ein zweites Abschneiden dort wirkungslos.
1327 *
1328 * Alles darueber hinaus bleibt streng: Ein zweites Kennzeichen ist Inhalt und
1329 * scheitert weiter, ebenso eine als UTF-16 gesicherte Datei.
1330 */
1331 const text = content.startsWith(BYTEORDNUNGSZEICHEN) ? content.slice(1) : content;
1332 let parsed: unknown;
1333 try {
1334 parsed = JSON.parse(text);
1335 } catch (error) {
1336 return {
1337 ok: false,
1338 canceled: false,
1339 filePath: null,
1340 message: `Die Datei enthält kein gültiges JSON: ${errorText(error)}`,
1341 result: null,
1342 };
1343 }
1344 /*
1345 * Sieht der Inhalt ueberhaupt wie ein Projekt aus?
1346 *
1347 * Ohne diese Wache reichte jedes geparste JSON an `loadProject` durch. Die
1348 * Zerlegehelfer in src/domain/model/schema.ts melden bauartbedingt nur
1349 * VORHANDENE, unpassende Werte; fehlende Schluessel erzeugen null Meldungen,
1350 * und `showImportIssues` unterdrueckt den Hinweiskasten daraufhin ganz. Eine
1351 * Einkaufsliste als .json ergab damit ein leeres Projekt namens
1352 * "Importiertes Projekt", die Meldung "Projekt geladen." - und weil der
1353 * Dateipfad mitkam und `store.replace` mit Pfad die Aenderungsmarke loescht,
1354 * meldete die Kopfzeile "gespeichert". Das naechste Strg+S schrieb das leere
1355 * Projekt ohne Dialog in genau diese fremde Datei; der Hauptprozess hatte
1356 * ihren Pfad beim Oeffnen freigegeben.
1357 *
1358 * Dieselbe Vorsicht fuehren beide Sitzungswege laengst (siehe UNBRAUCHBAR
1359 * weiter oben): "Ein stiller Verlust mit einer Erfolgsmeldung darueber ist
1360 * schlimmer als gar keine Meldung." Nur der Dateiweg hatte sie nicht.
1361 *
1362 * Die Wache ist grosszuegig und laesst jeden gewollten Weg durch:
1363 * `serializeProject` schreibt IMMER eine `schemaVersion`, eine 4.x-Datei
1364 * traegt 'projektdaten' oder 'signalgruppen'.
1365 */
1366 if (!looksLikeProject(parsed)) {
1367 return {
1368 ok: false,
1369 canceled: false,
1370 filePath: null,
1371 message:
1372 'Die Datei enthält kein Projekt des LSA-Planers. Es wurde nichts geladen und nichts geändert.',
1373 result: null,
1374 };
1375 }
1376 const result = loadProject(parsed);
1377 /*
1378 * Eine Datei aus einer neueren Fassung behaelt ihren Pfad NICHT.
1379 *
1380 * `loadProject` meldet sie nur als Hinweiszeile ("Nicht bekannte Angaben
1381 * gehen verloren."). Kam der Pfad trotzdem mit, galt der Stand sofort als
1382 * gespeichert - fuer einen Inhalt, von dem das Programm selbst gerade gesagt
1383 * hat, dass er unvollstaendig eingelesen wurde. Das naechste Speichern lief
1384 * ohne Rueckfrage auf dieselbe Datei und stempelte die eigene Schemaversion
1385 * darauf; alles, was diese Fassung nicht kennt, war fort, denn
1386 * src/domain/model/schema.ts baut das Projekt feldweise neu auf.
1387 *
1388 * Ohne Pfad erzwingt "Speichern" den Dialog: Es entsteht eine zweite Datei,
1389 * die des Absenders bleibt unangetastet. Die Aenderungsmarke bleibt dabei von
1390 * selbst stehen, weil `store.replace` sie nur bei einem Pfad loescht.
1391 *
1392 * Erkannt wird der Fall an der Meldung, die `loadProject` selbst dafuer
1393 * setzt, und nicht an einem zweiten Vergleich mit CURRENT_SCHEMA_VERSION:
1394 * Der Vergleich steht in src/domain/model/migrate.ts, und er soll dort
1395 * allein stehen bleiben. `path: 'schemaVersion'` vergibt die Fachschicht
1396 * ausschliesslich hierfuer.
1397 */
1398 const ausNeuererFassung = result.issues.some((issue) => issue.path === 'schemaVersion');
1399 if (ausNeuererFassung) {
1400 return {
1401 ok: true,
1402 canceled: false,
1403 filePath: null,
1404 message:
1405 'Projekt aus einer neueren Programmversion gelesen. Nicht bekannte Angaben fehlen; ' +
1406 'die Datei wird deshalb nicht überschrieben – „Speichern“ fragt nach einem neuen Ziel.',
1407 result,
1408 };
1409 }
1410 return {
1411 ok: true,
1412 canceled: false,
1413 filePath,
1414 message: result.migrated
1415 ? `Projekt aus Version ${result.sourceVersion} übernommen.`
1416 : 'Projekt geladen.',
1417 result,
1418 };
1419 }
1420
1421 /**
1422 * Im Dateinamen verbotene Zeichen: die neun druckbaren und der ganze
1423 * Steuerzeichenbereich.
1424 *
1425 * Windows verbietet in einem Dateinamen ausser \ / : * ? " < > | auch alle
1426 * Zeichen von U+0000 bis U+001F. Zuvor standen hier nur die neun druckbaren;
1427 * von den Steuerzeichen fielen allein \t \n \v \f \r auf, weil sie als
1428 * Leerraum gelten und vom Zusammenziehen darunter erfasst werden. Ein U+0001
1429 * oder U+0007 - aus einer Projektdatei erreichbar, weil `str` in
1430 * src/domain/model/schema.ts Zeichenketten ungefiltert an `meta.name` und
1431 * `meta.projectNumber` durchreicht - stand damit unveraendert im
1432 * Vorschlagsnamen. Der Hauptprozess reinigt nicht nach (`path.basename`), und
1433 * `open()` scheitert unter Windows mit ENOENT: Der Anwender bekommt einen
1434 * Speicherfehler zu einem Zeichen, das er nicht sehen kann.
1435 *
1436 * Als Escape und nicht als Zeichen geschrieben - dieselbe Begruendung wie bei
1437 * `STEUERZEICHEN` in src/render/pdfSurface.ts: Im Quelltext waeren sie
1438 * unsichtbar, und Git fuehrt eine Datei mit einem Nullbyte als Binaerdatei.
1439 */
1440 // eslint-disable-next-line no-control-regex -- die Steuerzeichen sind hier der Pruefgegenstand; siehe darueber
1441 const VERBOTENE_ZEICHEN = /[\x00-\x1f\\/:*?"<>|]/g;
1442
1443 /** Hoechstlaenge des Namensteils in ZEICHEN - siehe `suggestFileName`. */
1444 const MAX_NAMENSZEICHEN = 120;
1445
1446 export function suggestFileName(project: Project, extension = PROJECT_FILE_EXTENSION): string {
1447 const base = [project.meta.projectNumber, project.meta.name]
1448 .filter((s) => s.trim() !== '')
1449 .join(' - ');
1450 const bereinigt = (base === '' ? 'Projekt' : base)
1451 // Leerraum zuerst zusammenziehen, dann ersetzen: Sonst wuerden \t und \n
1452 // als Steuerzeichen zu Bindestrichen, statt zu einem Leerzeichen zu
1453 // werden.
1454 .replace(/\s+/g, ' ')
1455 .replace(VERBOTENE_ZEICHEN, '-')
1456 .trim();
1457 /*
1458 * Nach ZEICHEN schneiden, nicht nach UTF-16-Codeeinheiten.
1459 *
1460 * `slice(0, 120)` zaehlte Codeeinheiten. Lag an der Schnittstelle ein
1461 * Zeichen ausserhalb der Grundebene - ein Emoji etwa -, blieb dessen halbe
1462 * Ersatzzeichenfolge stehen. Windows ersetzt eine einsame Ersatzhaelfte beim
1463 * Anlegen durch U+FFFD; der zurueckgemeldete Pfad wich damit von dem ab, was
1464 * das Programm vorgeschlagen hatte.
1465 */
1466 const safe = [...bereinigt].slice(0, MAX_NAMENSZEICHEN).join('');
1467 return `${safe}.${extension}`;
1468 }
1469
1470 /** Speichert beliebige Daten (PDF, CSV) ueber denselben Weg wie Projektdateien. */
1471 export async function saveDataToFile(
1472 data: string | Uint8Array,
1473 fileName: string,
1474 filters: readonly { name: string; extensions: readonly string[] }[],
1475 mimeType: string,
1476 ): Promise<FileOperationResult> {
1477 const bridge = desktopBridge();
1478 if (bridge) {
1479 const result = await bridge.saveFile({ defaultName: fileName, filters, data });
1480 return {
1481 ok: result.ok,
1482 canceled: result.canceled,
1483 filePath: result.filePath,
1484 message: result.ok
1485 ? `Gespeichert: ${result.filePath ?? fileName}`
1486 : (result.error ?? 'Die Datei konnte nicht geschrieben werden.'),
1487 };
1488 }
1489 downloadInBrowser(data, fileName, mimeType);
1490 // Wortgleich mit `saveProjectToFile`: derselbe Rueckfallweg, dieselbe
1491 // Auskunft. "Heruntergeladen" stand hier zuvor und behauptete eine Ablage,
1492 // die niemand bestaetigt hat - siehe `unbestaetigt` an FileOperationResult.
1493 return {
1494 ok: true,
1495 canceled: false,
1496 filePath: null,
1497 unbestaetigt: true,
1498 message: `Herunterladen angestoßen: ${fileName}`,
1499 };
1500 }
1501
1502 function downloadInBrowser(data: string | Uint8Array, fileName: string, mimeType: string): void {
1503 const blob =
1504 typeof data === 'string'
1505 ? new Blob([data], { type: `${mimeType};charset=utf-8` })
1506 : new Blob([data as BlobPart], { type: mimeType });
1507 const url = URL.createObjectURL(blob);
1508 const anchor = document.createElement('a');
1509 anchor.href = url;
1510 anchor.download = fileName;
1511 document.body.append(anchor);
1512 anchor.click();
1513 anchor.remove();
1514 // Ohne revokeObjectURL bleibt der Blob bis zum Neuladen im Speicher; der
1515 // Altbestand gab keine der erzeugten URLs wieder frei.
1516 setTimeout(() => {
1517 URL.revokeObjectURL(url);
1518 }, 10_000);
1519 }
1520
1521 function pickFileInBrowser(): Promise<File | null> {
1522 return new Promise((resolve) => {
1523 const input = document.createElement('input');
1524 input.type = 'file';
1525 input.accept = `.${PROJECT_FILE_EXTENSION},.json,application/json`;
1526 input.addEventListener('change', () => {
1527 resolve(input.files?.[0] ?? null);
1528 input.remove();
1529 });
1530 input.addEventListener('cancel', () => {
1531 resolve(null);
1532 input.remove();
1533 });
1534 input.style.display = 'none';
1535 document.body.append(input);
1536 input.click();
1537 });
1538 }
1539
1540 function isQuotaError(error: unknown): boolean {
1541 if (!(error instanceof Error)) return false;
1542 return (
1543 error.name === 'QuotaExceededError' ||
1544 error.name === 'NS_ERROR_DOM_QUOTA_REACHED' ||
1545 error.message.toLowerCase().includes('quota')
1546 );
1547 }
1548
1549 function alsFehler(error: unknown): Error {
1550 return error instanceof Error ? error : new Error(String(error));
1551 }
1552
1553 function errorText(error: unknown): string {
1554 return error instanceof Error ? error.message : String(error);
1555 }