Files
Flaro/web/app/src/views/einstellungen/index.ts
T
ScarriffleandClaude Opus 5 532473b1f9 Ein Plan für das, was an keinem Aufruf hängt
Sichern, Datenbank aufräumen, verwaiste Bilder wegwerfen — alle drei Tage,
alle sieben Tage, von selbst.

**Der erste Hintergrunddienst in diesem Server, und das mit Bedacht.** An
drei Stellen steht sinngemäss, ein eigener Dienst wäre Aufwand ohne
Gegenwert, und für das Wegräumen abgelaufener Gerätecodes stimmt das:
solche Arbeit hängt an einem Aufruf, der ohnehin kommt. Eine Sicherung
hängt an nichts. Sie soll alle drei Tage geschehen, auch wenn niemand etwas
anklickt, und dafür gibt es eine Uhr. Alle fünfzehn Minuten eine Frage an
eine Tabelle mit einer Handvoll Zeilen; ist nichts fällig — der Normalfall
—, kostet es nichts.

Der letzte Lauf steht in der Datenbank, nicht im Arbeitsspeicher. Ein
Server, der jede Nacht neu startet, sicherte sonst jeden Morgen — oder,
bei einem Zähler, der bei null anfängt, nie. Auch ein Fehlschlag wird
vermerkt, sonst versuchte der Plan es alle fünfzehn Minuten wieder und
eine volle Platte erzeugte ein volles Protokoll.

**Die Sicherung ist `VACUUM INTO`, nicht Datei kopieren.** Die Datenbank
läuft im WAL-Modus: neben `flaro.db` stehen `-wal` und `-shm`, und wer nur
die erste Datei kopiert, bekommt einen Stand von vor Stunden oder einen
halben. `VACUUM INTO` schreibt eine vollständige, aufgeräumte Kopie im
laufenden Betrieb. Der Zielordner ist einstellbar und geht als gebundener
Wert in die Anweisung — eine Einstellung soll keine SQL-Anweisung werden.

Die Prüfung verlässt sich nicht darauf, dass eine Datei entstanden ist: sie
öffnet die Kopie als Datenbank und zählt die Zeilen. Eine Sicherung, die
sich nicht öffnen lässt, ist schlimmer als keine — man verlässt sich
darauf und merkt es erst, wenn es zu spät ist.

**Bilder nur, wenn verwaist.** Den ganzen Bildspeicher zu leeren gibt es
als Knopf und ist gefahrlos, kostet danach aber tausende Abrufe bei TMDB.
Hier fliegt nur, worauf kein Titel mehr zeigt — mit einer Stunde
Schonfrist, weil ein Bild abgelegt wird, bevor der Verweis darauf
gespeichert ist. Ohne diese Frist räumte ein Lauf mitten im Scan genau das
weg, was gerade geholt wurde.

**Eine eigene Seite „Wartung".** Die drei Kratzplatzzahlen standen unter
„Wiedergabe" — dort ist ihr Anlass, aber nicht ihr Thema. Sie ziehen mit
um: alles auf dieser Seite ist etwas, das der Server ohne Zutun erledigt,
und man sieht es an einem Ort nach. Je Aufgabe steht da, wann sie zuletzt
lief und was herauskam, dazu ein Knopf „Jetzt ausführen" — wer gleich etwas
Grosses vorhat, will die Kopie jetzt und nicht in drei Tagen.

725 Tests grün, davon elf neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 21:02:42 +02:00

178 lines
7.2 KiB
TypeScript

import { istAdmin, meldungen, type Bibliothek } from '../../api';
import { icon, type IconName } from '../../icons';
import { kontoseite } from './konto';
import { geschautseite } from './geschaut';
import { dashboard, serverbereich, speicherbereich } from './uebersicht';
import { bibliotheksbereich, vermisstenbereich } from './mediatheken';
import { pruefbereich, meldungsbereich } from './pruefung';
import { ohneZuordnungBereich, unzugeordnetbereich } from './zuordnung';
import { wiedergabebereich } from './wiedergabe';
import { sprachbereich } from './metadaten';
import { benutzerbereich } from './benutzer';
import { protokollbereich } from './protokolle';
import { wartungsbereich } from './wartung';
/** Welcher Teil der Einstellungen offen ist. */
export type Bereich =
| 'konto' | 'geschaut'
| 'uebersicht' | 'mediatheken' | 'pruefung' | 'zuordnung'
| 'wiedergabe' | 'metadaten' | 'benutzer' | 'wartung' | 'protokolle';
type Eintrag = { wert: Bereich; text: string; bild: IconName };
/** Was jedem gehört — das eigene Konto und die eigenen Zahlen. */
const MEINS: Eintrag[] = [
{ wert: 'konto', text: 'Mein Konto', bild: 'person' },
{ wert: 'geschaut', text: 'Geschaut', bild: 'hakenKreis' },
];
/** Was dem Server gehört. Nur Administratoren sehen diese Gruppe. */
const SERVER: Eintrag[] = [
{ wert: 'uebersicht', text: 'Übersicht', bild: 'verwaltung' },
{ wert: 'mediatheken', text: 'Mediatheken', bild: 'filme' },
{ wert: 'pruefung', text: 'Zu prüfen', bild: 'haken' },
{ wert: 'zuordnung', text: 'Zuordnung', bild: 'suche' },
{ wert: 'wiedergabe', text: 'Wiedergabe', bild: 'stufen' },
{ wert: 'metadaten', text: 'Metadaten', bild: 'stift' },
{ wert: 'benutzer', text: 'Benutzer', bild: 'serien' },
{ wert: 'wartung', text: 'Wartung', bild: 'stufen' },
{ wert: 'protokolle', text: 'Protokolle', bild: 'protokoll' },
];
/** Macht aus einem Adressteil einen Bereich.
*
* Unbekanntes und Verbotenes werden zum eigenen Konto: das hat jeder, und es
* ist die Seite, auf der man landen will, wenn man hier nichts zu suchen hat.
*/
export function alsBereich(wert: string | undefined, admin: boolean): Bereich {
if (MEINS.some(b => b.wert === wert)) return wert as Bereich;
if (admin && SERVER.some(b => b.wert === wert)) return wert as Bereich;
return 'konto';
}
/** Die Einstellungen.
*
* **Ein Ort, zwei Gruppen.** Oben steht, was einem selbst gehört — Bild,
* Sprache, die eigenen Zahlen. Darunter, nur für Administratoren, was dem
* Server gehört. Vorher hiess die ganze Seite „Verwaltung" und war für alle
* anderen gar nicht erreichbar; „Meine Sprache" lag dadurch hinter einer Tür,
* die den meisten verschlossen ist.
*
* **Was etwas anlegt oder ändert, steht in einem Dialog**, nicht als Block in
* der Seite. Ein Formular, das immer dasteht, nimmt Platz für etwas weg, das
* man einmal im Monat braucht — und sieht aus wie eine Eingabe, die noch
* aussteht.
*
* **Nur der offene Bereich wird gebaut.** Das ist nicht nur sparsamer — das
* Abspielfenster der Übersicht fragt im Takt von fünf Sekunden nach, und die
* Mediathekenliste beobachtet laufende Scans. Was nicht dasteht, fragt auch
* nicht.
*
* `geaendert` wird gerufen, wenn sich die Mediathekenliste geändert hat; die
* Seitenleiste steht auf ihr und muss dann neu gezeichnet werden. */
export function einstellungenseite(
welcher: Bereich = 'konto',
geaendert: (bekannt?: Bibliothek[]) => void = () => {},
signal: AbortSignal = new AbortController().signal,
): HTMLElement {
const admin = istAdmin();
const wurzel = document.createElement('div');
wurzel.className = 'verwaltung';
/** Das Feld für die Zahl offener Meldungen — erst beim Bauen der Leiste da. */
let zahl: HTMLElement | null = null;
const leiste = document.createElement('nav');
leiste.className = 'verw-leiste';
leiste.setAttribute('aria-label', 'Bereiche der Einstellungen');
gruppe('Einstellungen', MEINS);
if (admin) gruppe('Verwaltung', SERVER);
const buehne = document.createElement('div');
buehne.className = 'verw-buehne';
inhalt().forEach(a => buehne.appendChild(a));
// **Die Zahl holt sich nur, wer sie nicht ohnehin hat.** Steht „Zu prüfen"
// offen, meldet der Meldungsabschnitt sie selbst — und zwar nach jedem
// Abhaken. Sonst eine Anfrage je Seitenaufbau, und nur für Administratoren.
//
// Verworfen: die Zahl an `/system/info` oder am Konto mitführen. Beide
// werden ohnehin geholt, wären also „umsonst" — und wären ab dem ersten
// Abhaken falsch.
//
// Scheitert sie, bleibt es still: ein Abzeichen, das nicht lädt, fehlt eben.
if (admin && welcher !== 'pruefung') {
void meldungen(false).then(m => abzeichen(m.length)).catch(() => {});
}
wurzel.append(leiste, buehne);
return wurzel;
/** Eine beschriftete Gruppe — die Beschriftung ist hier die halbe Aussage.
*
* „Verwaltung" über den unteren Einträgen sagt ohne weiteren Satz, dass
* diese dem Server gehören und nicht einem selbst. */
/** Die Zahl offener Meldungen auf „Zu prüfen". Leer statt „0": eine Null
* ist Rauschen. */
function abzeichen(offen: number): void {
if (zahl) zahl.textContent = offen > 0 ? String(offen) : '';
}
function gruppe(name: string, eintraege: Eintrag[]): void {
const kopf = document.createElement('div');
kopf.className = 'verw-gruppe';
kopf.textContent = name;
leiste.appendChild(kopf);
eintraege.forEach(b => {
const k = document.createElement('button');
k.type = 'button';
k.className = 'verw-eintrag' + (b.wert === welcher ? ' check' : '');
k.appendChild(icon(b.bild));
k.append(document.createTextNode(b.text));
if (b.wert === welcher) k.setAttribute('aria-current', 'page');
if (b.wert === 'pruefung') {
zahl = document.createElement('span');
zahl.className = 'zahl';
k.appendChild(zahl);
}
k.onclick = () => { location.hash = `#/einstellungen/${b.wert}`; };
leiste.appendChild(k);
});
}
function inhalt(): HTMLElement[] {
switch (welcher) {
case 'geschaut':
return [geschautseite()];
case 'mediatheken':
return [bibliotheksbereich(geaendert, signal), vermisstenbereich(geaendert)];
case 'pruefung': {
// **Die Prüfliste hört auf sich selbst.** Wird etwas freigegeben,
// ändert sich der Bestand — und die Liste darüber muss nachziehen.
const p = pruefbereich(() => { geaendert(); p.auffrischen(); });
// Zwei Sektionen, nicht eine: die Prüfliste versteckt sich, sobald
// nichts auf Freigabe wartet, und nähme die Meldungen sonst mit.
return [p.wurzel, meldungsbereich(abzeichen).wurzel];
}
case 'zuordnung':
return [ohneZuordnungBereich(), unzugeordnetbereich()];
case 'wiedergabe':
return [wiedergabebereich()];
case 'metadaten':
return [sprachbereich()];
case 'benutzer':
return [benutzerbereich()];
case 'wartung':
return [wartungsbereich()];
case 'protokolle':
return [protokollbereich()];
case 'uebersicht':
return [dashboard(signal), serverbereich(geaendert), speicherbereich()];
default:
return [kontoseite()];
}
}
}