Elf Abschnitte in einer Spalte waren zehn zu viel

Die Verwaltung war eine Seite mit 1786 Zeilen. Wer das Protokoll sehen
wollte, scrollte an Mediatheken, Prüfliste, Benutzern und Speicher
vorbei — und jede neue Einstellung hätte sie länger gemacht.

Jetzt hat sie eine eigene Leiste mit sieben Bereichen, jeder mit seiner
Adresse: `#/verwaltung/protokolle` lässt sich ablegen, und ein Neuladen
bleibt dort stehen. Die Leiste sieht aus wie die Hauptleiste daneben,
damit sie als Navigation gelesen wird und nicht als Knopfreihe; am
Telefon wird aus der Spalte ein Streifen zum Schieben.

**Gebaut wird nur, was offen ist.** Das ist nicht nur sparsamer: das
Abspielfenster fragt im Fünf-Sekunden-Takt nach, die Mediathekenliste
beobachtet laufende Scans. Gemessen: nach dem Wechsel in einen anderen
Bereich kommt zwölf Sekunden lang keine Anfrage mehr.

`admin.ts` ist in acht Dateien zerlegt — eine je Bereich, dazu `teile.ts`
für die gemeinsame Überschrift und `index.ts` für Leiste und Weiche.

Zwei Dinge mussten dabei nachziehen:

**Die Markierung in der Hauptleiste.** Sie verglich stur zwei Segmente;
bei `#/verwaltung/metadaten` traf das auf keinen Eintrag, und
„Verwaltung" verlor seinen Balken. Jetzt gilt auch ein Segment.

**„5 Titel warten — ansehen".** Der Knopf sprang mit `scrollIntoView` zur
Prüfliste. Die steht nicht mehr im selben Dokument — er wechselt jetzt
den Bereich. Die Hervorhebung danach entfällt damit, und mit ihr ihr CSS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-06 16:39:50 +02:00
co-authored by Claude Opus 5
parent 94c6604e8b
commit 1e4a231d1b
13 changed files with 1979 additions and 1796 deletions
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
import { benutzer, benutzerAnlegen } from '../../api';
import { esc, knopf, fehlerzeile, meldung } from '../../util';
import { dialog } from '../../dialog';
import { abschnitt } from './teile';
/** Konten auf diesem Server. */
/* ---------- Benutzer ---------- */
export function benutzerbereich(): HTMLElement {
const bereich = document.createElement('section');
const anlegen = knopf('Benutzer hinzufügen', 'btn');
anlegen.onclick = () => dialog({
titel: 'Benutzer hinzufügen',
felder: [
{ name: 'name', beschriftung: 'Benutzername' },
{
name: 'passwort', beschriftung: 'Passwort', art: 'password',
hinweis: 'Mindestens acht Zeichen.',
},
{ name: 'admin', beschriftung: 'Administrator', schalter: true },
],
bestaetigen: 'Anlegen',
tun: async wert => {
await benutzerAnlegen(wert('name'), wert('passwort'), wert('admin') === 'true');
await laden();
},
});
bereich.appendChild(abschnitt('Benutzer', [anlegen]));
const liste = document.createElement('div');
liste.className = 'einstellungen';
bereich.appendChild(liste);
void laden();
async function laden(): Promise<void> {
// **Der Spinner gehört an den ersten Aufbau, nicht an jedes Auffrischen.**
// Sonst leert sich die Liste sichtbar, bevor dasselbe wieder erscheint —
// und bei einem Takt im Hintergrund blinkt sie dauernd.
if (liste.childElementCount === 0) liste.innerHTML = '<div class="spinner"></div>';
try {
const alle = await benutzer();
liste.innerHTML = '';
alle.forEach(b => {
const d = document.createElement('div');
d.className = 'einstellung';
d.innerHTML = `<div class="zeile"><strong>${esc(b.userName)}</strong>`
+ `<span class="winzig">${b.isAdministrator ? 'Administrator' : 'Benutzer'}</span></div>`;
liste.appendChild(d);
});
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile(meldung(fehler)));
}
}
return bereich;
}
+102
View File
@@ -0,0 +1,102 @@
import { type Bibliothek } from '../../api';
import { icon, type IconName } from '../../icons';
import { dashboard, serverbereich, speicherbereich } from './uebersicht';
import { bibliotheksbereich, vermisstenbereich } from './mediatheken';
import { pruefbereich } from './pruefung';
import { ohneZuordnungBereich, unzugeordnetbereich } from './zuordnung';
import { sprachbereich } from './metadaten';
import { benutzerbereich } from './benutzer';
import { protokollbereich } from './protokolle';
/** Welcher Teil der Verwaltung offen ist. */
export type Bereich =
| 'uebersicht' | 'mediatheken' | 'pruefung' | 'zuordnung'
| 'metadaten' | 'benutzer' | 'protokolle';
const BEREICHE: { wert: Bereich; text: string; bild: IconName }[] = [
{ wert: 'uebersicht', text: 'Übersicht', bild: 'verwaltung' },
{ wert: 'mediatheken', text: 'Mediatheken', bild: 'filme' },
{ wert: 'pruefung', text: 'Zu prüfen', bild: 'hakenKreis' },
{ wert: 'zuordnung', text: 'Zuordnung', bild: 'suche' },
{ wert: 'metadaten', text: 'Metadaten', bild: 'stift' },
{ wert: 'benutzer', text: 'Benutzer', bild: 'person' },
{ wert: 'protokolle', text: 'Protokolle', bild: 'protokoll' },
];
/** Macht aus einem Adressteil einen Bereich — Unbekanntes wird zur Übersicht. */
export function alsBereich(wert: string | undefined): Bereich {
return BEREICHE.some(b => b.wert === wert) ? wert as Bereich : 'uebersicht';
}
/** Die Verwaltung.
*
* **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.
*
* **Warum eine zweite Leiste.** Vorher hingen elf Abschnitte untereinander in
* einer Seite — 1786 Zeilen, und jede neue Einstellung machte sie länger. Wer
* das Protokoll sehen wollte, scrollte an den Mediatheken, der Prüfliste und
* dem Speicher vorbei. Jetzt hat jeder Bereich seine eigene Adresse: man kann
* ihn als Lesezeichen ablegen, und ein Neuladen bleibt dort stehen.
*
* **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 verwaltung(
welcher: Bereich = 'uebersicht',
geaendert: (bekannt?: Bibliothek[]) => void = () => {},
signal: AbortSignal = new AbortController().signal,
): HTMLElement {
const wurzel = document.createElement('div');
wurzel.className = 'verwaltung';
const leiste = document.createElement('nav');
leiste.className = 'verw-leiste';
leiste.setAttribute('aria-label', 'Bereiche der Verwaltung');
BEREICHE.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');
k.onclick = () => { location.hash = `#/verwaltung/${b.wert}`; };
leiste.appendChild(k);
});
const buehne = document.createElement('div');
buehne.className = 'verw-buehne';
inhalt().forEach(a => buehne.appendChild(a));
wurzel.append(leiste, buehne);
return wurzel;
function inhalt(): HTMLElement[] {
switch (welcher) {
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(); });
return [p.wurzel];
}
case 'zuordnung':
return [ohneZuordnungBereich(), unzugeordnetbereich()];
case 'metadaten':
return [sprachbereich()];
case 'benutzer':
return [benutzerbereich()];
case 'protokolle':
return [protokollbereich()];
default:
return [dashboard(signal), serverbereich(geaendert), speicherbereich()];
}
}
}
+482
View File
@@ -0,0 +1,482 @@
import {
bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen, scanStarten,
scanstand, metadatenAuffrischen, vermisste, vermisstesEntfernen, type VermissteDatei,
pruefungSetzen, mediathekZuruecksetzen, type Bibliothek,
} from '../../api';
import { esc, knopf, fehlerzeile, leer, meldung } from '../../util';
import { dialog, nachfragen } from '../../dialog';
import { abschnitt } from './teile';
/** Mediatheken anlegen, einlesen, aufräumen. */
export function bibliotheksbereich(
geaendert: (bekannt?: Bibliothek[]) => void, signal: AbortSignal,
): HTMLElement {
const bereich = document.createElement('section');
const anlegen = knopf('Mediathek hinzufügen', 'btn btn-primary');
anlegen.onclick = () => dialog({
titel: 'Mediathek hinzufügen',
felder: [
{ name: 'name', beschriftung: 'Name', platzhalter: 'etwa Filme' },
{
name: 'art', beschriftung: 'Art',
auswahl: [{ wert: 'Movies', text: 'Filme' }, { wert: 'Shows', text: 'Serien' }],
},
{
name: 'pfad', beschriftung: 'Ordner', art: 'ordner',
platzhalter: '/media/Filme',
hinweis: 'Der Pfad im Container, nicht der auf dem NAS — '
+ 'also das, was in der Compose-Datei hinter dem Doppelpunkt steht.',
},
{
name: 'pruefen', beschriftung: 'Neue Funde erst prüfen', schalter: true,
hinweis: 'Aus heisst: gefundene Titel erscheinen sofort. An heisst: sie '
+ 'warten unter „Zu prüfen", bis du sie freigibst — Zeit, die Zuordnung '
+ 'und die Bilder zu kontrollieren oder einen Termin zu setzen.',
},
],
bestaetigen: 'Anlegen',
tun: async wert => {
await bibliothekAnlegen(wert('name'), wert('art'), wert('pfad'),
wert('pruefen') === 'true');
await laden();
geaendert();
},
});
bereich.appendChild(abschnitt('Mediatheken', [anlegen]));
const liste = document.createElement('div');
liste.className = 'einstellungen';
bereich.appendChild(liste);
/** Die gezeichneten Zeilen, je Mediathek.
*
* **Vor dem ersten `laden()`.** Stand die Map darunter, lief der erste
* Aufruf in die temporale Totzone von `const` — „Cannot access 'zeilen'
* before initialization", und der ganze Abschnitt blieb leer.
*/
const zeilen = new Map<string, Mediathekzeile>();
void laden();
/** **Auffrischen heisst fortschreiben, nicht neu zeichnen.**
*
* Hier stand `liste.innerHTML = ''` und danach ein Neuaufbau — alle drei
* Sekunden, während ein Scan läuft. Der ganze Abschnitt verschwand dabei
* sichtbar und kam wieder; wer gerade zielte, klickte ins Leere.
*
* Schlimmer noch: jeder Neuaufbau startete über `verfolgen(false)` einen
* weiteren Takt. Nach einer Minute liefen zwanzig davon nebeneinander und
* fragten im Sekundentakt denselben Scanstand ab.
*/
async function laden(): Promise<Bibliothek[] | undefined> {
if (zeilen.size === 0 && liste.childElementCount === 0) {
liste.innerHTML = '<div class="spinner"></div>';
}
let alle: Bibliothek[];
try { alle = await bibliotheken(); }
catch (fehler) {
// Beim Auffrischen bleibt stehen, was da ist: ein Aussetzer darf keine
// Liste wegräumen, die noch stimmt.
if (zeilen.size === 0) { liste.innerHTML = ''; liste.appendChild(fehlerzeile(meldung(fehler))); }
return undefined;
}
if (alle.length === 0) {
zeilen.clear();
liste.innerHTML = '';
liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.'));
return alle;
}
// Spinner oder Hinweis weg — aber nur beim allerersten Mal.
if (zeilen.size === 0) liste.innerHTML = '';
// Was es nicht mehr gibt, verschwindet.
[...zeilen.keys()]
.filter(id => !alle.some(b => b.id === id))
.forEach(id => { zeilen.get(id)!.wurzel.remove(); zeilen.delete(id); });
alle.forEach((b, i) => {
let zeile = zeilen.get(b.id);
if (zeile) zeile.aktualisieren(b);
else { zeile = block(b); zeilen.set(b.id, zeile); }
// Reihenfolge angleichen, ohne alles anzufassen: `insertBefore` mit
// einem Knoten, der schon dort steht, tut nichts.
if (liste.children[i] !== zeile.wurzel) {
liste.insertBefore(zeile.wurzel, liste.children[i] ?? null);
}
});
return alle;
}
function block(b: Bibliothek): Mediathekzeile {
// Die Knöpfe lesen den Stand über diese Grösse, nicht über `b` — sonst
// arbeiteten sie nach dem ersten Auffrischen mit veralteten Werten.
let aktuell = b;
const d = document.createElement('div');
d.className = 'einstellung';
const kopf = document.createElement('div');
kopf.className = 'zeile';
d.appendChild(kopf);
function kopfSchreiben(): void {
const serie = aktuell.kind === 'Shows';
// **Alle Zahlen nebeneinander, weil sie Verschiedenes meinen.**
//
// Plex zählte 451 Filme, Flaro 449, und der Scan lief über 461 Dateien.
// Keine davon war falsch — es sind Titel, Dateien und zweite Fassungen.
// Einzeln gezeigt bleibt die Frage offen, nebeneinander beantwortet sie
// sich.
const teile: string[] = [aktuell.path];
if (aktuell.itemCount != null) {
teile.push(`${aktuell.itemCount} ` + (aktuell.itemCount === 1
? (serie ? 'Serie' : 'Film')
: (serie ? 'Serien' : 'Filme')));
}
if (serie && aktuell.episodeCount != null) {
teile.push(`${aktuell.episodeCount} ${aktuell.episodeCount === 1 ? 'Folge' : 'Folgen'}`);
}
teile.push(`${aktuell.fileCount} ${aktuell.fileCount === 1 ? 'Datei' : 'Dateien'}`);
if (aktuell.extraVersionCount > 0) {
teile.push(`${aktuell.extraVersionCount} zweite Fassung`
+ (aktuell.extraVersionCount === 1 ? '' : 'en'));
}
if (aktuell.unassignedCount > 0) teile.push(`${aktuell.unassignedCount} nicht zugeordnet`);
if (aktuell.pendingCount > 0) {
teile.push(`${aktuell.pendingCount} wartet${aktuell.pendingCount === 1 ? '' : 'en'} auf Freigabe`);
}
kopf.innerHTML = `<strong>${esc(aktuell.name)}</strong>`
+ `<span class="winzig">${esc(teile.join(' · '))}</span>`;
}
kopfSchreiben();
const stand = document.createElement('div');
stand.className = 'hinweis';
const los = knopf('Scannen', 'btn');
los.onclick = async () => {
laufendSetzen(true);
try { await scanStarten(aktuell.id); await verfolgen(true); }
catch (fehler) { stand.textContent = meldung(fehler); laufendSetzen(false); }
};
/** Sperrt alles, was einen laufenden Lauf stören würde.
*
* **Nicht nur „Scannen".** Ein zweiter Scan prallt am Server ab und ist
* nur verwirrend — „Zurücksetzen" und „Entfernen" dagegen löschen Zeilen,
* während der Lauf welche schreibt, und das endet in einem Bestand, den
* niemand mehr erklären kann.
*
* **Umbenennen, Prüfung und die Pfeile bleiben bedienbar.** Sie rühren
* den Inhalt nicht an, und ein Knopf, der ohne Not gesperrt ist, ist
* genauso ärgerlich wie einer, der ohne Not etwas kaputtmacht.
*/
function laufendSetzen(an: boolean): void {
const grund = 'Läuft gerade — bitte warten, bis der Lauf durch ist.';
[los, auffrischen, zuruecksetzen, entfernen].forEach(k => {
k.disabled = an;
if (an) k.title = grund;
else if (k.title === grund) k.title = '';
});
if (!an) {
auffrischen.title = 'Holt Titel, Bilder und Besetzung erneut — '
+ 'nötig nach einer Sprachänderung.';
}
}
const auffrischen = knopf('Metadaten auffrischen', 'btn');
auffrischen.title = 'Holt Titel, Bilder und Besetzung erneut — '
+ 'nötig nach einer Sprachänderung.';
auffrischen.onclick = () => nachfragen({
titel: `Metadaten von „${aktuell.name}" auffrischen?`,
text: 'Alle Titel werden erneut bei TMDB geholt — über die bekannte Kennung, '
+ 'nicht über eine neue Suche. Zuordnungen von Hand bleiben damit stehen. '
+ 'Bei vielen Titeln dauert es.',
bestaetigen: 'Auffrischen',
tun: async () => {
laufendSetzen(true);
await metadatenAuffrischen(aktuell.id);
void verfolgen(true);
},
});
const umbenennen = knopf('Umbenennen', 'btn');
umbenennen.onclick = () => dialog({
titel: 'Mediathek umbenennen',
felder: [{ name: 'name', beschriftung: 'Name', wert: aktuell.name }],
bestaetigen: 'Speichern',
tun: async wert => {
await bibliothekUmbenennen(aktuell.id, wert('name'));
await laden();
geaendert();
},
});
// **Zurücksetzen steht neben Entfernen, weil es fast dasselbe tut** — nur
// ohne Namen, Pfad und Einstellungen mitzunehmen. Wer eine Mediathek
// löscht und neu anlegt, um sie sauber zu bekommen, tippt alles noch
// einmal; genau das war bisher der einzige Weg.
const zuruecksetzen = knopf('Zurücksetzen', 'btn');
zuruecksetzen.onclick = () => nachfragen({
titel: `„${aktuell.name}" zurücksetzen?`,
text: 'Alle erfassten Titel, Zuordnungen und der Sehfortschritt dieser '
+ 'Mediathek werden verworfen; Name, Pfad und Einstellungen bleiben. '
+ 'Die Mediendateien auf der Platte bleiben unangetastet. Danach einmal '
+ 'scannen, und der Bestand entsteht neu.',
bestaetigen: 'Zurücksetzen',
tun: async () => {
await mediathekZuruecksetzen(aktuell.id);
await laden();
geaendert();
},
});
const entfernen = knopf('Entfernen', 'btn');
entfernen.onclick = () => nachfragen({
titel: `„${aktuell.name}" entfernen?`,
text: 'Die Mediendateien auf der Platte bleiben unangetastet. Weg sind die '
+ 'Einträge, die Zuordnungen und der Sehfortschritt aller Benutzer dazu.',
bestaetigen: 'Entfernen',
tun: async () => { await bibliothekLoeschen(aktuell.id); await laden(); geaendert(); },
});
/** Fragt nach, bis der Lauf durch ist — er läuft im Hintergrund. */
/// Verfolgt einen Lauf bis zum Ende.
///
/// **`melden` entscheidet, ob am Schluss `geaendert()` gerufen wird.**
/// Beim Aufbau der Seite ist es `false`: ein längst beendeter Lauf meldete
/// sonst „fertig", liess neu zeichnen, und der Neuaufbau verfolgte wieder
/// denselben beendeten Lauf — eine Endlosschleife, die sich als vier
/// dauernd drehende Spinner zeigte.
async function verfolgen(melden: boolean): Promise<void> {
for (let i = 0; i < 900; i++) {
if (signal.aborted) return;
const s = await scanstand(aktuell.id);
// **Was der Server sagt, nicht was wir raten.** Vorher stand hier
// minutenlang „Wird durchsucht…", ohne Hinweis, ob etwas passiert.
// **Die Sperre richtet sich nach dem Server, nicht nach dem Klick.**
// Beim Aufbau der Seite läuft vielleicht schon einer — dann müssen die
// Knöpfe gesperrt sein, ohne dass hier jemand etwas gedrückt hat.
if (s.state === 'running') {
laufendSetzen(true);
stand.textContent = s.step ?? 'Wird durchsucht…';
} else if (s.state === 'enriching') {
laufendSetzen(true);
stand.textContent = s.step ?? 'Metadaten werden geholt…';
} else if (s.state === 'failed') {
laufendSetzen(false);
stand.textContent = s.error ?? 'Gescheitert.';
return;
}
// **Alle Zähler auf demselben Takt.**
//
// Vorher frischte diese Schleife nur die Zeile in der Verwaltung auf,
// und die Seitenleiste erst am Ende des Laufs. In der Mediathek stand
// dann 20, in der Verwaltung 14 und in der Seitenleiste 0 — drei
// Zahlen für dieselbe Sache, und keine davon offensichtlich falsch.
// `geaendert()` baut nur die Seitenleiste neu, nicht die Ansicht;
// es hier zu rufen ist deshalb gefahrlos.
//
// Alle drei Runden statt alle fünf: nah genug am Zähler in der
// Mediathek, ohne je Sekunde zwei Abrufe zu kosten.
if (melden && i > 0 && i % 3 === 0) geaendert(await laden());
if (s.state === 'finished') {
const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];
// „ohne Folgennummer" stimmte nicht mehr: gezählt wird jetzt auch,
// was eine Nummer trägt, aber zu keiner Serie gehört.
if (s.unrecognised) teile.push(`${s.unrecognised} nicht zugeordnet`);
if (s.metadata?.skipped) teile.push('Metadaten übersprungen');
else if (s.metadata) teile.push(`${s.metadata.moviesMatched + s.metadata.seriesMatched} zugeordnet`);
if (s.metadata?.error) teile.push(s.metadata.error);
if (s.missing) teile.push(`${s.missing} nicht mehr gefunden`);
stand.textContent = teile.join(' · ');
laufendSetzen(false);
if (melden) { await laden(); geaendert(); }
return;
}
await new Promise(r => setTimeout(r, 1000));
}
// Nach fünfzehn Minuten ohne Ende: die Sperre fällt, sonst bliebe die
// Mediathek bis zum Neuladen der Seite unbedienbar.
laufendSetzen(false);
}
// Die Prüfung lässt sich je Mediathek umstellen — was beim Anlegen
// entschieden wurde, soll kein Urteil fürs Leben sein.
const pruefung = knopf(aktuell.reviewNewItems ? 'Prüfung an' : 'Prüfung aus', 'btn');
pruefung.onclick = async () => {
pruefung.disabled = true;
try {
await pruefungSetzen(aktuell.id, !aktuell.reviewNewItems);
await laden();
} catch (fehler) {
d.appendChild(fehlerzeile(meldung(fehler)));
pruefung.disabled = false;
}
};
// **Umsortiert wird in der Seitenleiste, nicht hier.**
//
// Die Reihenfolge wirkt dort, also gehört der Griff dorthin: man sieht
// beim Schieben, was man verändert, statt in den Einstellungen zu raten
// und danach nachzusehen. Der Knopf heisst „Seitenleiste bearbeiten".
const knoepfe = document.createElement('div');
knoepfe.className = 'knopfreihe';
knoepfe.append(los, auffrischen, pruefung, umbenennen, zuruecksetzen, entfernen);
// **Nur wenn hier wirklich etwas liegt.** Ein Knopf, der nichts zu tun
// hat, ist schlechter als keiner — und die Zahl daneben sagt, worum es
// geht, ohne dass man erst weiterscrollen muss.
//
// Angelegt wird er trotzdem immer und nur versteckt: ihn beim Auffrischen
// anzulegen und wieder wegzuwerfen wäre genau das Flackern, das hier
// gerade abgestellt wird.
const freigeben = knopf('', 'btn btn-primary');
// **Zeigen statt blind freigeben.**
//
// Der Knopf veröffentlichte bisher auf einen Klick alles, was wartete —
// ohne dass man gesehen hätte, was. Auf die Frage „wo sehe ich die vier
// Filme?" gab es damit keine Antwort: die Liste stand weiter oben, aber
// niemand wusste das, und sie war obendrein nicht nachgeladen worden.
//
// Jetzt führt er dorthin. Veröffentlicht wird dort, wo die Titel mit
// Plakat und Dateiname stehen und sich einzeln anhaken lassen.
//
// **Ein Bereichswechsel, kein Sprung in der Seite.** Solange alles in
// einer Spalte stand, genügte `scrollIntoView`; seit „Zu prüfen" ein
// eigener Bereich ist, steht die Liste gar nicht mehr im selben Dokument.
freigeben.onclick = () => { location.hash = '#/verwaltung/pruefung'; };
knoepfe.appendChild(freigeben);
function freigabeSchreiben(): void {
// **`hidden` reicht hier nicht.** Das Attribut wirkt über
// `display: none` mit der Spezifität von Null — `.btn` setzt
// `display: inline-flex` und gewinnt. Der Knopf stand deshalb als
// „0 Titel freigeben" da, obwohl nichts wartet. `.hidden` trägt ein
// `!important` und setzt sich durch.
freigeben.classList.toggle('hidden', aktuell.pendingCount === 0);
freigeben.textContent = aktuell.pendingCount === 1
? 'Ein Titel wartet — ansehen'
: `${aktuell.pendingCount} Titel warten — ansehen`;
}
freigabeSchreiben();
d.append(knoepfe, stand);
// **Genau ein Takt je Mediathek.** Er entsteht hier, beim Anlegen der
// Zeile — und weil die Zeile nicht mehr neu gebaut wird, bleibt es dabei.
void verfolgen(false);
return {
wurzel: d,
aktualisieren: (neu: Bibliothek) => {
aktuell = neu;
kopfSchreiben();
pruefung.textContent = aktuell.reviewNewItems ? 'Prüfung an' : 'Prüfung aus';
freigabeSchreiben();
},
};
}
return bereich;
}
/* ---------- Nicht mehr gefunden ---------- */
/** Was in der Mediathek steht, aber nicht mehr auf der Platte liegt.
*
* **Erscheint nur, wenn es etwas zu melden gibt.** Ein dauerhaft leerer
* Abschnitt wäre Rauschen; ein gefüllter ist eine Nachricht.
*
* Der Scan löscht nichts mehr von selbst. Eine kurz nicht eingehängte Platte
* sah vorher aus wie „die Filme sind weg" und kostete Titel samt
* Sehfortschritt aller Benutzer. Beides ist für den Scan nicht zu
* unterscheiden — also entscheidet hier ein Mensch. */
export function vermisstenbereich(geaendert: () => void): HTMLElement {
const bereich = document.createElement('section');
bereich.className = 'hidden';
const liste = document.createElement('div');
liste.className = 'einstellungen';
const alle = knopf('Alle entfernen', 'btn btn-gefahr');
alle.onclick = () => nachfragen({
titel: 'Alle vermissten Einträge entfernen',
text: 'Entfernt alle Dateien, die nicht mehr gefunden werden, aus der '
+ 'Mediathek — mitsamt den Titeln, von denen keine Datei übrig bleibt. '
+ 'Auf der Platte wird nichts gelöscht.',
bestaetigen: 'Entfernen',
tun: async () => { await vermisstesEntfernen(); await laden(); geaendert(); },
});
bereich.append(abschnitt('Nicht mehr gefunden', [alle]), liste);
void laden();
async function laden(): Promise<void> {
let fehlend: VermissteDatei[];
try { fehlend = await vermisste(); } catch { bereich.classList.add('hidden'); return; }
if (fehlend.length === 0) { bereich.classList.add('hidden'); return; }
bereich.classList.remove('hidden');
liste.innerHTML = '';
fehlend.forEach(v => {
const d = document.createElement('div');
d.className = 'einstellung';
const oben = document.createElement('div');
oben.className = 'knopfreihe';
const text = document.createElement('div');
text.className = 'zeile';
text.style.flex = '1';
const seit = new Date(v.missingSince).toLocaleDateString('de-CH',
{ day: '2-digit', month: 'long', year: 'numeric' });
text.innerHTML = `<strong>${esc(v.title ?? '(ohne Titel)')}`
+ `${v.versionLabel ? ` · ${esc(v.versionLabel)}` : ''}</strong>`
+ `<span class="winzig">${esc(v.libraryName)} · vermisst seit ${esc(seit)}</span>`;
oben.appendChild(text);
const weg = knopf('Entfernen', 'btn');
weg.onclick = async () => {
weg.disabled = true;
try { await vermisstesEntfernen(v.fileId); await laden(); geaendert(); }
catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); weg.disabled = false; }
};
oben.appendChild(weg);
d.appendChild(oben);
const pfad = document.createElement('div');
pfad.className = 'winzig';
pfad.textContent = v.path;
d.appendChild(pfad);
liste.appendChild(d);
});
}
return bereich;
}
/* ---------- Bibliotheken ---------- */
/** Eine gezeichnete Mediathekszeile und der Weg, sie fortzuschreiben. */
type Mediathekzeile = {
wurzel: HTMLElement;
aktualisieren: (b: Bibliothek) => void;
};
+218
View File
@@ -0,0 +1,218 @@
import {
sprachen, spracheSetzen, uebersetzungenHolen, meineSprache, metadatenEinstellung,
metadatenSetzen, sprachverteilung, abweichendeAuffrischen,
} from '../../api';
import { esc, knopf, fehlerzeile, meldung } from '../../util';
import { dialog } from '../../dialog';
import { icon } from '../../icons';
import { abschnitt } from './teile';
/** Sprache und Herkunft der Metadaten. */
/* ---------- Server ---------- */
/** Sprache — eigener Abschnitt, ganz oben.
*
* **Warum nicht in der Serverliste.** Dort stand sie, zwischen „Name",
* „Metadaten", „Doppelte Einträge" und „Fassung". Gesucht wurde sie
* trotzdem, gefunden nicht — und das ist die Antwort: eine Einstellung, die
* man sucht und nicht findet, steht am falschen Platz, egal wie gut sie
* beschriftet ist.
*
* **Oben, weil sie alles andere färbt.** In dieser Sprache werden Metadaten
* geholt und gespeichert; wer sie spät umstellt, frischt den ganzen Bestand
* noch einmal auf.
*/
export function sprachbereich(): HTMLElement {
const bereich = document.createElement('section');
bereich.appendChild(abschnitt('Sprache'));
const liste = document.createElement('div');
liste.className = 'einstellungen';
bereich.appendChild(liste);
void (async () => {
try {
const stand = await metadatenEinstellung();
const eigeneSprache = meineSprache();
liste.innerHTML = '';
// **Die Sprache des Servers als eigene Zeile.**
//
// Sie steckte im Dialog „Metadaten" — zusammen mit dem TMDB-Schlüssel,
// also dort, wo niemand nach einer Sprache sucht. Wer die Mediathek auf
// Englisch vorfand, sah nur „Meine Sprache" und hielt das für die
// einzige Möglichkeit.
liste.appendChild(zeile(
'Sprache des Servers',
sprachen.find(x => x.wert === stand.language)?.text ?? stand.language,
'In dieser Sprache werden Titel und Beschreibungen geholt, und sie '
+ 'bestimmt die Altersfreigabe. Gilt für alle. Vorhandene Titel behalten '
+ 'ihre alte Sprache — dafür gibt es bei jeder Mediathek „Metadaten '
+ 'auffrischen".',
() => dialog({
titel: 'Sprache des Servers',
text: 'Die Umstellung wirkt auf neue Funde sofort. Für den vorhandenen '
+ 'Bestand drückst du danach bei jeder Mediathek „Metadaten auffrischen" '
+ '— sonst behalten die Titel ihre alte Sprache.',
felder: [{
name: 'sprache', beschriftung: 'Sprache', auswahl: sprachen, wert: stand.language,
}],
bestaetigen: 'Übernehmen',
tun: async wert => {
await metadatenSetzen({ language: wert('sprache') });
location.reload();
},
})));
// **Die eigene Sprache, getrennt von der des Servers.** Die obere
// bestimmt, was geholt wird; diese nur, was man selbst zu sehen bekommt.
// **Was tatsächlich vorliegt — nicht was eingestellt ist.**
//
// Der eigentliche Fehler war nicht, dass Titel englisch blieben, sondern
// dass sich nicht feststellen liess, ob sie es sind: nirgends stand, in
// welcher Sprache ein Titel geholt wurde. Hier steht es, als Zahl.
void sprachverteilung().then(v => {
const benennen = (k: string | null) => k === null
? 'unbekannt (vor dieser Fassung geholt)'
: sprachen.find(x => x.wert === k)?.text ?? k;
const zeileBestand = zeile(
'Bestand',
v.anteile.length === 0
? 'Noch nichts zugeordnet'
: v.anteile.map(a => `${a.anzahl} × ${benennen(a.sprache)}`).join(' · '),
v.abweichend === 0
? 'Alles liegt in der Serversprache vor.'
: `${v.abweichend} Titel liegen in einer anderen Sprache vor. Das Auffrischen `
+ 'holt genau diese neu — nicht den ganzen Bestand.');
if (v.abweichend > 0) {
const los = knopf(`${v.abweichend} auffrischen`, 'btn btn-primary');
los.onclick = async () => {
los.disabled = true;
try {
await abweichendeAuffrischen();
los.textContent = 'Läuft…';
} catch (fehler) {
zeileBestand.appendChild(fehlerzeile(meldung(fehler)));
los.disabled = false;
}
};
zeileBestand.querySelector('.knopfreihe')?.appendChild(los);
}
// Direkt unter die Serversprache, denn dort wird die Frage gestellt.
liste.insertBefore(zeileBestand, liste.children[1] ?? null);
}).catch(() => { /* Ohne Verteilung fehlt nur die Zeile. */ });
// **Weicht sie ab, steht das deutlich dabei.**
//
// Eine eigene Sprache ändert nicht, was der Server holt — nur was man
// selbst sieht. Steht sie auf Englisch, während der Server auf Deutsch
// holt, sieht die ganze Mediathek englisch aus, und jedes „Metadaten
// auffrischen" scheint wirkungslos. Genau in diese Falle läuft man,
// ohne je zu erfahren, warum.
const weichtAb = eigeneSprache !== null && eigeneSprache !== stand.language;
const serversprache = sprachen.find(x => x.wert === stand.language)?.text ?? stand.language;
liste.appendChild(zeile(
weichtAb ? 'Meine Sprache — weicht ab' : 'Meine Sprache',
eigeneSprache ? (sprachen.find(x => x.wert === eigeneSprache)?.text ?? eigeneSprache)
: 'Wie der Server',
weichtAb
? `Du siehst alles auf ${sprachen.find(x => x.wert === eigeneSprache)?.text ?? eigeneSprache}, `
+ `obwohl der Server auf ${serversprache} holt. Titel ohne Übersetzung bleiben `
+ `auf ${serversprache}. Auf „Wie der Server" stellen, um zu sehen, was wirklich `
+ 'gespeichert ist.'
: 'Gilt nur für dich. Was nicht übersetzt vorliegt, erscheint weiterhin in '
+ 'der Sprache des Servers.',
() => dialog({
titel: 'Meine Sprache',
text: 'Die Metadaten liegen in der Sprache des Servers vor. Eine andere '
+ 'zu wählen heisst: du siehst, was übersetzt vorliegt — und für den '
+ 'Rest die Serversprache. Ein Administrator kann die Übersetzungen '
+ 'unten für alle Titel auf einmal holen.',
felder: [{
name: 'sprache', beschriftung: 'Sprache',
auswahl: [{ wert: '', text: 'Wie der Server' }, ...sprachen],
wert: eigeneSprache ?? '',
}],
bestaetigen: 'Übernehmen',
tun: async wert => {
const neu = wert('sprache');
await spracheSetzen(neu || null);
location.reload();
},
})));
liste.appendChild(zeile(
'Übersetzungen holen',
`Füllt eine zweite Sprache neben ${serversprache} auf`,
`Der Bestand liegt auf ${serversprache} vor. Das hier legt zusätzlich `
+ 'eine zweite Fassung an, für Benutzer, die eine andere Sprache gewählt '
+ 'haben — am Bestand selbst ändert es nichts. Kostet je Titel eine '
+ 'Anfrage an TMDB.',
() => dialog({
titel: 'Übersetzungen holen',
text: `Die Titel des Servers bleiben auf ${serversprache}. Geholt wird `
+ 'eine zusätzliche Fassung, die nur Benutzer mit genau dieser Sprache '
+ 'zu sehen bekommen.',
felder: [{
name: 'sprache', beschriftung: 'Für welche Sprache',
// **Nicht stur en-US.** Stand dort „Englisch", während der Server
// auf Deutsch steht, las sich das wie eine Aussage über den
// Server — und führte zu der Vermutung, die Spracheinstellung
// wirke nicht. Vorgewählt wird die eigene Sprache, sonst die
// erste, die nicht schon die des Servers ist.
auswahl: sprachen,
wert: eigeneSprache
?? sprachen.find(x => x.wert !== stand.language)?.wert
?? stand.language,
}],
bestaetigen: 'Holen',
tun: async wert => {
const bericht = await uebersetzungenHolen(wert('sprache'));
alert(`${bericht.gesamt} Übersetzungen geholt, ${bericht.fehlend} ohne Treffer.`);
},
})));
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile(meldung(fehler)));
}
})();
return bereich;
function zeile(
name: string, wert: string, hinweis: string | null, bearbeiten?: () => void,
): HTMLElement {
const d = document.createElement('div');
d.className = 'einstellung';
const oben = document.createElement('div');
oben.className = 'knopfreihe';
const text = document.createElement('div');
text.className = 'zeile';
text.style.flex = '1';
text.innerHTML = `<strong>${esc(name)}</strong><span class="winzig">${esc(wert)}</span>`;
oben.appendChild(text);
if (bearbeiten) {
const b = knopf('Ändern', 'btn');
b.prepend(icon('stift'));
b.onclick = bearbeiten;
oben.appendChild(b);
}
d.appendChild(oben);
if (hinweis) {
const h = document.createElement('div');
h.className = 'hinweis';
h.textContent = hinweis;
d.appendChild(h);
}
return d;
}
}
+110
View File
@@ -0,0 +1,110 @@
import { protokolltage, protokollAdresse, ApiFehler } from '../../api';
import { esc, knopf, leer, meldung } from '../../util';
import { abschnitt } from './teile';
/** Die Tagesprotokolle des Servers. */
export function protokollbereich(): HTMLElement {
const bereich = document.createElement('section');
bereich.appendChild(abschnitt('Protokolle'));
const liste = document.createElement('div');
liste.className = 'einstellungen';
liste.innerHTML = '<div class="spinner"></div>';
bereich.appendChild(liste);
const anzeige = document.createElement('pre');
anzeige.className = 'protokoll hidden';
bereich.appendChild(anzeige);
// **Blättern statt alles auf einmal.** Die ersten 800 Zeilen beantworten
// fast jede Frage; wer weiter zurück muss, verdoppelt mit diesem Knopf.
const mehr = knopf('Mehr laden', 'btn hidden');
bereich.appendChild(mehr);
void (async () => {
let tage;
try { tage = await protokolltage(); } catch { bereich.classList.add('hidden'); return; }
liste.innerHTML = '';
if (tage.length === 0) {
liste.appendChild(leer('Noch nichts protokolliert.'));
return;
}
tage.forEach(t => {
const d = document.createElement('div');
d.className = 'einstellung';
const oben = document.createElement('div');
oben.className = 'knopfreihe';
const text = document.createElement('div');
text.className = 'zeile';
text.style.flex = '1';
const datum = new Date(t.date + 'T12:00:00').toLocaleDateString('de-CH',
{ weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' });
text.innerHTML = `<strong>${esc(datum)}</strong>`
+ `<span class="winzig">${Math.max(1, Math.round(t.bytes / 1024))} KB</span>`;
oben.appendChild(text);
const lesen = knopf('Ansehen', 'btn');
lesen.onclick = () => void zeigen(t.date, false);
const nurFehler = knopf('Nur Fehler', 'btn');
nurFehler.onclick = () => void zeigen(t.date, true);
const holen = document.createElement('a');
holen.className = 'btn';
holen.href = protokollAdresse(t.date);
holen.download = `flaro-${t.date}.log`;
holen.textContent = 'Herunterladen';
oben.append(lesen, nurFehler, holen);
d.appendChild(oben);
liste.appendChild(d);
});
})();
/** Wie viele Zeilen zuletzt geholt wurden — der Knopf „Mehr" verdoppelt. */
let gezeigt = 0;
async function zeigen(tag: string, nurFehler: boolean, zeilen = 0): Promise<void> {
// **Nur das Ende holen, und davon anfangs wenig.** Vorher kam die ganze
// Datei, und davon wurden 95 % weggeworfen — bei zwei Megabyte über eine
// wackelige Verbindung endete das in „Failed to fetch". Bei der
// Fehlersuche mehr, weil davon die meisten weggefiltert werden.
gezeigt = zeilen > 0 ? zeilen : (nurFehler ? 5000 : 800);
anzeige.classList.remove('hidden');
anzeige.textContent = 'Wird geladen…';
mehr.classList.add('hidden');
try {
const antwort = await fetch(protokollAdresse(tag, gezeigt));
if (!antwort.ok) throw new ApiFehler(antwort.status, `${antwort.status}`);
const roh = (await antwort.text()).split('\n');
const gefiltert = nurFehler
? roh.filter(z => z.includes('FEHLER') || z.includes('WARNUNG'))
: roh;
anzeige.textContent = gefiltert.join('\n').trim().length === 0
? (nurFehler ? 'Keine Fehler in den letzten Zeilen.' : 'Leer.')
: gefiltert.join('\n');
anzeige.scrollTop = anzeige.scrollHeight;
// **„Mehr" nur, solange es mehr geben kann.** Kamen weniger Zeilen
// zurück als verlangt, ist die Datei zu Ende — ein Knopf, der dann
// nichts tut, ist schlechter als keiner.
if (roh.length >= gezeigt && gezeigt < 20000) {
mehr.classList.remove('hidden');
mehr.textContent = `Weitere ${Math.min(gezeigt, 20000 - gezeigt)} Zeilen laden`;
mehr.onclick = () => void zeigen(tag, nurFehler, Math.min(gezeigt * 2, 20000));
}
} catch (fehler) {
anzeige.textContent = `Konnte nicht gelesen werden: ${meldung(fehler)}`;
}
}
return bereich;
}
+302
View File
@@ -0,0 +1,302 @@
import {
zuPruefen, veroeffentlichen, zurueckziehen, zurKenntnis, type Pruefeintrag, bildAdresse,
} from '../../api';
import { esc, knopf, fehlerzeile, meldung } from '../../util';
import { dialog } from '../../dialog';
import { icons } from '../../icons';
import { kontextmenue, menueAnZiel, type Menuepunkt } from '../../menue';
import { bilddialog } from '../../bilder';
import { zuordnungsdialog } from '../../zuordnung';
import { titeldialog } from '../../titel';
import { bearbeiten } from '../../bearbeiten';
import { abschnitt } from './teile';
/** Zu prüfen: was der Scan gefunden hat und noch nicht freigegeben ist. */
/* ---------- Zu prüfen ---------- */
/** Neue Funde, die noch nicht erscheinen — und geplante mit ihrem Termin.
*
* **Erscheint nur, wenn etwas wartet.** Ein leerer Abschnitt wäre Rauschen.
*
* Geplante bleiben in der Liste. Ein Titel mit Termin ist nicht erledigt, er
* hat nur schon einen — ihn verschwinden zu lassen hiesse, dass man ihn nur
* wiederfindet, wenn man sich den Termin gemerkt hat. */
export function pruefbereich(geaendert: () => void): {
wurzel: HTMLElement; auffrischen: () => void;
} {
const bereich = document.createElement('section');
bereich.className = 'hidden';
bereich.id = 'zu-pruefen';
const liste = document.createElement('div');
liste.className = 'einstellungen';
/** Was gerade angehakt ist. Als Menge und nicht am DOM abgelesen: beim
* Neuladen bleibt die Auswahl damit erhalten. */
const gewaehlt = new Set<string>();
let stand: Pruefeintrag[] = [];
const alleHaken = document.createElement('label');
alleHaken.className = 'bleiben';
const alleKasten = document.createElement('input');
alleKasten.type = 'checkbox';
alleHaken.append(alleKasten, document.createTextNode(' Alle'));
alleKasten.onchange = () => {
gewaehlt.clear();
if (alleKasten.checked) stand.forEach(e => gewaehlt.add(e.id));
zeichnen();
};
const freigeben = knopf('Veröffentlichen', 'btn btn-primary');
freigeben.onclick = () => void handeln(undefined);
const planen = knopf('Planen…', 'btn');
planen.onclick = () => dialog({
titel: `${betroffene().length} Titel planen`,
text: 'Ab diesem Zeitpunkt erscheinen sie für alle. Es läuft dafür nichts '
+ 'im Hintergrund — die Sichtbarkeit wird bei jeder Abfrage gerechnet und '
+ 'stimmt deshalb auch, wenn der Server gerade nicht lief.',
felder: [{
name: 'wann', beschriftung: 'Zeitpunkt', art: 'zeitpunkt',
wert: inEingabeform(new Date(Date.now() + 86400000)),
min: inEingabeform(new Date()),
}],
bestaetigen: 'Planen',
tun: async wert => {
const wann = new Date(wert('wann'));
if (Number.isNaN(wann.getTime())) throw new Error('Das ist kein Zeitpunkt.');
await handeln(wann.toISOString());
},
});
bereich.append(abschnitt('Zu prüfen', [alleHaken, freigeben, planen]), liste);
void laden();
/** Die Auswahl — oder alles, wenn nichts angehakt ist. */
function betroffene(): Pruefeintrag[] {
const angehakt = stand.filter(e => gewaehlt.has(e.id));
return angehakt.length > 0 ? angehakt : stand;
}
async function handeln(wann: string | undefined): Promise<void> {
const ids = betroffene().flatMap(e => e.ids);
if (ids.length === 0) return;
freigeben.disabled = true;
planen.disabled = true;
try {
await veroeffentlichen(ids, wann);
gewaehlt.clear();
await laden();
geaendert();
} catch (fehler) {
liste.prepend(fehlerzeile(meldung(fehler)));
} finally {
freigeben.disabled = false;
planen.disabled = false;
}
}
async function laden(): Promise<void> {
try { stand = await zuPruefen(); } catch { bereich.classList.add('hidden'); return; }
if (stand.length === 0) { bereich.classList.add('hidden'); return; }
bereich.classList.remove('hidden');
// Was es nicht mehr gibt, bleibt nicht angehakt.
const da = new Set(stand.map(e => e.id));
[...gewaehlt].forEach(id => { if (!da.has(id)) gewaehlt.delete(id); });
zeichnen();
}
function zeichnen(): void {
alleKasten.checked = gewaehlt.size > 0 && gewaehlt.size === stand.length;
alleKasten.indeterminate = gewaehlt.size > 0 && gewaehlt.size < stand.length;
const anzahl = betroffene().reduce((n, e) => n + e.count, 0);
const wieViele = gewaehlt.size > 0 ? `${anzahl} gewählt` : `alle ${anzahl}`;
freigeben.textContent = `Veröffentlichen (${wieViele})`;
liste.innerHTML = '';
// **Zwei Gruppen, zwei Bedeutungen.** Ein neuer Fund wartet auf Freigabe
// und ist bis dahin unsichtbar; ein geänderter Titel steht längst in der
// Mediathek und soll nur nicht unbemerkt anders heissen. Beides in einer
// Reihe hiesse, „Veröffentlichen" auf etwas anzubieten, das veröffentlicht
// ist.
const neue = stand.filter(e => e.grund !== 'geaendert');
const geaenderte = stand.filter(e => e.grund === 'geaendert');
neue.forEach(e => liste.appendChild(zeileFuer(e)));
if (geaenderte.length > 0) {
const kopf = document.createElement('div');
kopf.className = 'pruef-gruppe';
kopf.innerHTML = '<strong>Geändert</strong>'
+ '<span class="winzig">Diese Titel sind schon sichtbar. Ein Auffrischen '
+ 'hat Titel, Jahr oder Plakat geändert — hier nur zur Kenntnis.</span>';
const abhaken = knopf('Alle zur Kenntnis', 'btn');
abhaken.onclick = async () => {
abhaken.disabled = true;
try { await zurKenntnis(); await laden(); }
catch (fehler) { liste.prepend(fehlerzeile(meldung(fehler))); abhaken.disabled = false; }
};
kopf.appendChild(abhaken);
liste.append(kopf, ...geaenderte.map(zeileFuer));
}
}
function zeileFuer(e: Pruefeintrag): HTMLElement {
const d = document.createElement('div');
d.className = 'einstellung pruefzeile';
const geaendertes = e.grund === 'geaendert';
const haken = document.createElement('input');
haken.type = 'checkbox';
haken.checked = gewaehlt.has(e.id);
haken.setAttribute('aria-label', `„${e.title}“ auswählen`);
// Die Sammelaktionen oben veröffentlichen — auf Geändertes passt das nicht.
haken.classList.toggle('hidden', geaendertes);
haken.onchange = () => {
if (haken.checked) gewaehlt.add(e.id); else gewaehlt.delete(e.id);
zeichnen();
};
const bild = document.createElement('div');
bild.className = 'pruef-bild';
const adresse = bildAdresse(e.posterImage);
if (adresse) {
const img = document.createElement('img');
img.src = adresse; img.alt = ''; img.loading = 'lazy';
bild.appendChild(img);
}
const text = document.createElement('div');
text.className = 'zeile';
text.style.flex = '1';
const geplant = e.publishAt ? new Date(e.publishAt) : null;
const was = e.kind === 'series'
? `${e.count} ${e.count === 1 ? 'Folge' : 'Folgen'}`
: null;
const lage = geaendertes
? 'Sichtbar — ein Auffrischen hat etwas geändert'
: geplant
? `Geplant für ${geplant.toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}`
: e.kind === 'series' && e.count > 1 && !e.publishAt
? 'Wartet'
: 'Wartet auf Freigabe';
// Ohne Zuordnung fehlen Titel und Bilder — dann ist nicht die Freigabe
// das Nächste, sondern die Zuordnung.
const hinweis = e.matched ? null : 'noch keinem Titel zugeordnet';
text.innerHTML = `<strong>${esc(e.title)}${e.year ? ` (${e.year})` : ''}</strong>`
+ `<span class="winzig">${[was, lage, hinweis].filter(Boolean).map(esc).join(' · ')}</span>`;
// **Geändertes wird abgehakt, nicht veröffentlicht** — es ist schon
// sichtbar. „Veröffentlichen" wäre dort ein Knopf, der nichts tut.
const nur = knopf(
geaendertes ? 'Zur Kenntnis' : geplant ? 'Jetzt veröffentlichen' : 'Veröffentlichen',
'btn');
nur.onclick = async () => {
nur.disabled = true;
try {
if (geaendertes) await zurKenntnis([e.id]);
else await veroeffentlichen(e.ids);
await laden();
geaendert();
} catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); nur.disabled = false; }
};
// **Dasselbe Dreipunktemenü wie auf einer Kachel.**
//
// Hier stand bisher nur „Veröffentlichen" — ausgerechnet bei dem Titel,
// den man vor der Freigabe geradeziehen will. Bilder, Zuordnung und Titel
// waren an einem wartenden Eintrag überhaupt nicht erreichbar.
const art = e.kind === 'series' ? 'series' : 'movies';
const mehr = document.createElement('button');
mehr.className = 'btn rund';
mehr.innerHTML = icons.punkte;
mehr.setAttribute('aria-label', `Mehr zu „${e.title}“`);
const punkte = (): Menuepunkt[] => [
// Auch hier: ein Ort für alles, die Einzelnen bleiben als Abkürzung.
{ text: 'Bearbeiten', icon: 'stift',
tun: () => bearbeiten(art, e.id, () => void laden()) },
{ text: 'Titel ändern', icon: 'stift',
tun: () => titeldialog(art, e.id, e.title, () => void laden()) },
{ text: 'Bilder bearbeiten', icon: 'stift',
tun: () => bilddialog(art, e.id, e.title, () => void laden()) },
{ text: 'Zuordnung korrigieren', icon: 'suche',
// Ohne Jahr — siehe card.ts: das der falschen Zuordnung grenzt falsch ein.
tun: () => zuordnungsdialog(art, e.id, e.title, null, () => void laden()) },
];
mehr.onclick = () => menueAnZiel(mehr, punkte());
kontextmenue(mehr, punkte);
const oben = document.createElement('div');
oben.className = 'knopfreihe';
oben.append(haken, bild, text, mehr, nur);
if (geplant) {
const zurueck = knopf('Zurückziehen', 'btn');
zurueck.onclick = async () => {
zurueck.disabled = true;
try { await zurueckziehen(e.ids); await laden(); }
catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); zurueck.disabled = false; }
};
oben.appendChild(zurueck);
}
d.appendChild(oben);
if (e.path) {
const pfad = document.createElement('div');
pfad.className = 'winzig';
pfad.textContent = e.path;
d.appendChild(pfad);
}
return d;
}
/** Die Form, die `datetime-local` erwartet — Ortszeit ohne Zone. */
function inEingabeform(d: Date): string {
const z = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`
+ `T${z(d.getHours())}:${z(d.getMinutes())}`;
}
// **Nachladbar, und das fehlte.** Die Liste wurde einmal beim Öffnen
// geholt. Fand ein laufender Scan danach etwas Neues, zählte die Zeile der
// Mediathek es mit — „4 warten auf Freigabe" —, die Liste darüber blieb
// aber leer und sogar versteckt. Da stand eine Zahl ohne das, worauf sie
// sich bezog.
return { wurzel: bereich, auffrischen: () => void laden() };
}
/* ---------- Protokolle ---------- */
/** Eine Datei je Tag, lesbar ohne Verbindung auf das NAS.
*
* Das Containerprotokoll ist nach einem Neustart weg und nur über SSH
* erreichbar — die Frage „warum wurde der Film gestern Nacht nicht erkannt?"
* lässt sich damit nicht beantworten. */
/** Dateien, die zu keinem Titel gehören.
*
* **Warum das eine eigene Ansicht verdient.** Der Scan erfindet keine Serie
* mehr, wenn weder Ordner noch Dateiname eine nennen — richtig so, aber die
* Dateien verschwanden damit lautlos. Bei 3559 von 5884 war das die halbe
* Mediathek, und in der Übersicht stand eine Zahl, die niemand erklären
* konnte.
*
* **Mit Grund je Datei**, weil die beiden Gründe Verschiedenes verlangen:
* fehlt die Folgennummer, muss umbenannt werden; fehlt der Serienname, genügt
* ein Ordner darum herum.
*/
+20
View File
@@ -0,0 +1,20 @@
/** Gemeinsame Bausteine der Verwaltung. */
/** Überschrift mit einem Knopf rechts daneben. */
export function abschnitt(titel: string, knoepfe: HTMLElement[] = []): HTMLElement {
const kopf = document.createElement('div');
kopf.className = 'reihe-kopf';
const h = document.createElement('h2');
h.textContent = titel;
kopf.appendChild(h);
if (knoepfe.length) {
const rechts = document.createElement('div');
rechts.className = 'knopfreihe';
rechts.style.marginLeft = 'auto';
knoepfe.forEach(k => rechts.appendChild(k));
kopf.appendChild(rechts);
}
return kopf;
}
+335
View File
@@ -0,0 +1,335 @@
import {
doppelteZusammenlegen, metadatenEinstellung, metadatenSetzen, servernameSetzen,
systemauskunft, laufendeWiedergaben, serverzustand, bildAdresse, speicherorte,
bildspeicherLeeren, type Wiedergabe,
} from '../../api';
import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung } from '../../util';
import { dialog } from '../../dialog';
import { icon } from '../../icons';
import { abschnitt } from './teile';
/** Übersicht: was läuft, was der Server meldet, was er belegt. */
/* ---------- Dashboard ---------- */
/** Was gerade läuft.
*
* **Fragt alle fünf Sekunden nach.** Ein dauerhaft offener Kanal wäre
* sparsamer, aber für eine Handvoll Zeilen auf einer Seite, die man selten
* offen hat, ist er der grössere Aufwand — auf beiden Seiten. */
export function dashboard(signal: AbortSignal): HTMLElement {
const bereich = document.createElement('section');
bereich.appendChild(abschnitt('Wird abgespielt'));
const liste = document.createElement('div');
liste.className = 'einstellungen';
liste.innerHTML = '<div class="spinner"></div>';
const zustand = document.createElement('div');
zustand.className = 'hinweis';
bereich.append(liste, zustand);
let timer: number | undefined;
void laden();
// Aufhören, sobald die Seite weg ist — sonst fragt eine unsichtbare
// Verwaltung bis zum Neuladen weiter.
const waechter = new MutationObserver(() => {
if (!bereich.isConnected) { window.clearTimeout(timer); waechter.disconnect(); }
});
waechter.observe(document.body, { childList: true, subtree: true });
async function laden(): Promise<void> {
try {
const [laufend, stand] = await Promise.all([laufendeWiedergaben(), serverzustand()]);
liste.innerHTML = '';
if (laufend.length === 0) {
liste.appendChild(leer('Derzeit wird nichts gespielt.'));
} else {
laufend.forEach(w => liste.appendChild(wiedergabe(w)));
}
const teile = [`Läuft seit ${laufzeit(stand.uptime)}`];
if (stand.packaging > 0) teile.push(`${stand.packaging} Umwandlung(en) laufen`);
if (stand.scanning > 0) teile.push(`${stand.scanning} Scan(s) laufen`);
zustand.textContent = teile.join(' · ');
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile(meldung(fehler)));
return; // Nicht weiter nachfragen, wenn es scheitert.
}
// **Nur weitermachen, solange die Ansicht hängt.** Ohne diese Zeile
// legte jeder Besuch der Verwaltung einen weiteren Fünf-Sekunden-Takt
// an, und nach zehn Wechseln liefen zehn davon nebeneinander.
if (signal.aborted) return;
timer = window.setTimeout(() => void laden(), 5000);
}
function wiedergabe(w: Wiedergabe): HTMLElement {
const d = document.createElement('div');
d.className = 'einstellung laeuft';
const bild = document.createElement('div');
bild.className = 'laeuft-bild';
const adresse = bildAdresse(w.posterImage);
if (adresse) {
const img = document.createElement('img');
img.src = adresse;
img.alt = '';
bild.appendChild(img);
}
const text = document.createElement('div');
text.className = 'zeile';
text.style.flex = '1';
const wie = w.runtimeSeconds
? `${dauer(w.positionSeconds)} von ${dauer(w.runtimeSeconds)}`
: dauer(w.positionSeconds);
text.innerHTML = `<strong>${esc(w.title)}</strong>`
+ (w.subTitle ? `<span class="winzig">${esc(w.subTitle)}</span>` : '')
+ `<span class="winzig">${esc(w.userName)} · ${esc(w.deviceName)} · ${esc(wie)}</span>`;
const innen = document.createElement('div');
innen.className = 'knopfreihe';
innen.append(bild, text);
d.appendChild(innen);
if (w.runtimeSeconds) {
const balken = document.createElement('div');
balken.className = 'fortschritt breit';
const gefuellt = document.createElement('div');
gefuellt.style.width = `${Math.min(100, (w.positionSeconds / w.runtimeSeconds) * 100)}%`;
balken.appendChild(gefuellt);
d.appendChild(balken);
}
return d;
}
return bereich;
}
/** „3 Std. 12 Min." — aus Sekunden. */
export function laufzeit(sekunden: number): string {
const tage = Math.floor(sekunden / 86400);
const stunden = Math.floor((sekunden % 86400) / 3600);
const minuten = Math.floor((sekunden % 3600) / 60);
if (tage > 0) return `${tage} Tg. ${stunden} Std.`;
if (stunden > 0) return `${stunden} Std. ${minuten} Min.`;
return `${minuten} Min.`;
}
export function serverbereich(geaendert: () => void): HTMLElement {
const bereich = document.createElement('section');
bereich.appendChild(abschnitt('Server'));
const liste = document.createElement('div');
liste.className = 'einstellungen';
bereich.appendChild(liste);
void (async () => {
try {
const [auskunft, stand] = await Promise.all([systemauskunft(), metadatenEinstellung()]);
liste.innerHTML = '';
liste.appendChild(zeile(
'Name', auskunft.serverName ?? '—',
'So nennen ihn die Apps und die Kopfzeile.',
() => dialog({
titel: 'Server umbenennen',
felder: [{ name: 'name', beschriftung: 'Name', wert: auskunft.serverName ?? '' }],
bestaetigen: 'Speichern',
tun: async wert => { await servernameSetzen(wert('name')); location.reload(); },
})));
// **Wartung, nicht Alltag.** Nach jedem Scan passiert das von selbst;
// dieser Knopf ist für den Fall, dass doch wieder Dubletten auftauchen —
// etwa nachdem eine Zuordnung von Hand geändert wurde.
const wartung = zeile(
'Doppelte Einträge', 'Zusammenlegen, was denselben Film meint',
'Zwei Dateien mit leicht verschiedenen Namen konnten früher zwei '
+ 'Einträge ergeben. Der Sehfortschritt bleibt dabei erhalten — es '
+ 'gewinnt die weitere Stelle.');
const legen = knopf('Zusammenlegen', 'btn');
legen.onclick = async () => {
legen.disabled = true;
const vorher = legen.textContent;
legen.textContent = 'Läuft…';
try {
const bericht = await doppelteZusammenlegen();
legen.textContent = bericht.gesamt === 0
? 'Nichts zu tun'
: `${bericht.gesamt} zusammengelegt`;
if (bericht.gesamt > 0) geaendert();
} catch (fehler) {
wartung.appendChild(fehlerzeile(meldung(fehler)));
legen.textContent = vorher;
legen.disabled = false;
}
};
wartung.querySelector('.knopfreihe')?.appendChild(legen);
liste.appendChild(wartung);
liste.appendChild(zeile(
'Metadaten',
// Ohne die Sprache: die steht eine Zeile tiefer und gehört dort hin.
stand.usingProjectKey ? 'Mitgelieferter TMDB-Zugang'
: stand.hasApiKey ? 'Eigener TMDB-Zugang'
: 'Kein Zugang hinterlegt',
stand.usingProjectKey
? 'Ein eigener Zugang löst den mitgelieferten ab.'
: 'Nach einer Sprachänderung die Metadaten auffrischen — '
+ 'vorhandene Titel ändern sich sonst nicht.',
() => dialog({
titel: 'Metadaten',
felder: [
{
name: 'schluessel', beschriftung: 'Eigener TMDB-Zugang', art: 'password',
platzhalter: 'Read Access Token oder API Key',
hinweis: 'Leer lassen, um den mitgelieferten zu behalten.',
},
],
bestaetigen: 'Speichern',
tun: async wert => {
await metadatenSetzen({ apiKey: wert('schluessel') || undefined });
location.reload();
},
})));
// **Fassung und Build nebeneinander.**
//
// Die Fassung sagt, welcher Stand gemeint ist; der Build sagt, welches
// Abbild wirklich läuft. Ohne den zweiten Wert war nicht feststellbar,
// ob das NAS den Bau von eben zieht — und eine Fehlersuche, die diese
// Frage nicht beantworten kann, dreht sich im Kreis.
liste.appendChild(zeile(
'Fassung',
`${auskunft.version} · Build ${auskunft.build}`,
auskunft.build === 'DEV'
? 'Läuft aus dem Quellbaum, nicht aus einem Abbild.'
: 'Diese Kennung druckt das Veröffentlichungsskript am Ende. '
+ 'Stimmt sie nicht überein, läuft hier ein älteres Abbild.'));
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile(meldung(fehler)));
}
})();
function zeile(
name: string, wert: string, hinweis: string | null, bearbeiten?: () => void,
): HTMLElement {
const d = document.createElement('div');
d.className = 'einstellung';
const oben = document.createElement('div');
oben.className = 'knopfreihe';
const text = document.createElement('div');
text.className = 'zeile';
text.style.flex = '1';
text.innerHTML = `<strong>${esc(name)}</strong><span class="winzig">${esc(wert)}</span>`;
oben.appendChild(text);
if (bearbeiten) {
const b = knopf('Ändern', 'btn');
b.prepend(icon('stift'));
b.onclick = bearbeiten;
oben.appendChild(b);
}
d.appendChild(oben);
if (hinweis) {
const h = document.createElement('div');
h.className = 'hinweis';
h.textContent = hinweis;
d.appendChild(h);
}
return d;
}
liste.innerHTML = '<div class="spinner"></div>';
return bereich;
}
/** Speicher — wo die Dateien des Servers liegen.
*
* **Warum das in die Oberfläche gehört.** Die drei Pfade standen nur in der
* Compose-Datei. Wer wissen wollte, wohin die Bilder wandern oder warum das
* `cache`-Volume vollläuft, musste sich auf das NAS verbinden — für eine
* Auskunft, die der Server längst hat.
*
* **Die Pfade bleiben Umgebungsvariablen.** Sie im Betrieb umzubiegen, während
* Dateien darin liegen, hiesse: die Datenbank zeigt auf den alten Ort, der
* Server schreibt in den neuen, und niemand merkt es, bis ein Bild fehlt.
* Angezeigt wird deshalb der Variablenname — damit klar ist, wo man schraubt.
*/
export function speicherbereich(): HTMLElement {
const bereich = document.createElement('section');
bereich.appendChild(abschnitt('Speicher'));
const liste = document.createElement('div');
liste.className = 'einstellungen';
liste.innerHTML = '<div class="spinner"></div>';
bereich.appendChild(liste);
void laden();
async function laden(): Promise<void> {
let orte;
try { orte = await speicherorte(); }
catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile(meldung(fehler)));
return;
}
liste.innerHTML = '';
orte.forEach(o => {
const d = document.createElement('div');
d.className = 'einstellung';
const oben = document.createElement('div');
oben.className = 'knopfreihe';
const text = document.createElement('div');
text.className = 'zeile';
text.style.flex = '1';
const frei = o.frei === null ? '' : ` · ${groesse(o.frei)} frei`;
text.innerHTML = `<strong>${esc(o.name)}</strong>`
+ `<span class="winzig">${esc(o.pfad)} — ${groesse(o.belegt)} belegt${esc(frei)}</span>`
+ `<span class="winzig">Wird gesetzt über ${esc(o.variable)}.</span>`;
oben.appendChild(text);
// Leeren gibt es nur dort, wo es gefahrlos ist.
if (o.bilder !== null) {
const leeren = knopf('Bildspeicher leeren', 'btn');
leeren.onclick = async () => {
leeren.disabled = true;
try {
const { freed } = await bildspeicherLeeren();
await laden();
// Erst nach dem Neuladen melden, sonst steht die alte Zahl daneben.
const gemeldet = document.createElement('p');
gemeldet.className = 'hinweis';
gemeldet.textContent = `${groesse(freed)} freigegeben. `
+ 'Die Bilder werden beim nächsten Aufruf neu geholt.';
liste.prepend(gemeldet);
} catch (fehler) {
d.appendChild(fehlerzeile(meldung(fehler)));
leeren.disabled = false;
}
};
oben.appendChild(leeren);
}
d.appendChild(oben);
liste.appendChild(d);
});
}
return bereich;
}
+280
View File
@@ -0,0 +1,280 @@
import {
bibliotheken, unzugeordnet, ohneZuordnung, mehrfachfassungen, dateiHerausloesen,
type Mehrfachtitel,
} from '../../api';
import { esc, knopf, meldung, hinweisZeigen } from '../../util';
import { bearbeiten } from '../../bearbeiten';
import { abschnitt } from './teile';
/** Was sich nicht zuordnen liess. */
/** Titel, zu denen die Metadatenquelle nichts hergab.
*
* **Weil die Zahl allein nichts erklärt.** In der Übersicht standen 448
* Filme, in Plex 451 — und nirgends, welche drei fehlen oder warum. Ein Film
* ohne Zuordnung steht zwar in der Mediathek, aber unter seinem Dateinamen
* und ohne Plakat; wer ihn sucht, findet ihn nicht, weil er anders heisst.
*
* Daneben: Titel mit mehr als einer Datei. Zwei Dateien an einem Film sind
* meist zwei Fassungen und in Ordnung — manchmal aber zwei verschiedene
* Filme, die bei der Quelle auf denselben Treffer liefen. An den Dateinamen
* sieht man das in zwei Sekunden.
*/
export function ohneZuordnungBereich(): HTMLElement {
const bereich = document.createElement('section');
bereich.classList.add('hidden');
bereich.appendChild(abschnitt('Ohne Zuordnung'));
const liste = document.createElement('div');
liste.className = 'einstellungen';
bereich.appendChild(liste);
void laden();
async function laden(): Promise<void> {
let alle;
try { alle = await bibliotheken(); }
catch { return; }
const ergebnisse = await Promise.all(alle.map(async b => {
try {
const [offen, mehrfach] = await Promise.all([
ohneZuordnung(b.id), mehrfachfassungen(b.id),
]);
return { b, offen, mehrfach };
} catch { return null; }
}));
const mitFunden = ergebnisse
.filter(e => e !== null)
.filter(e => e!.offen.length > 0 || e!.mehrfach.gesamt > 0);
if (mitFunden.length === 0) { bereich.classList.add('hidden'); return; }
bereich.classList.remove('hidden');
liste.innerHTML = '';
mitFunden.forEach(e => {
const { b, offen, mehrfach } = e!;
if (offen.length > 0) {
liste.appendChild(gruppe(
`${b.name} — ${anzahl(offen.length, 'Titel')} ohne Zuordnung`,
'Diese stehen unter ihrem Dateinamen in der Mediathek. '
+ 'Über „Zuordnung korrigieren" lassen sie sich geradeziehen.',
offen.map(t => ({
titel: t.titel + (t.jahr ? ` (${t.jahr})` : ''),
dazu: t.beispiel ?? `${anzahl(t.dateien, 'Datei')}`,
// **Hier bearbeiten, nicht woandershin springen.** Wer eine
// Liste durchgeht, will nach dem Geradeziehen beim nächsten
// weitermachen — nicht jedes Mal zurücknavigieren.
tun: () => bearbeiten(t.art === 'series' ? 'series' : 'movies', t.id, () => void laden()),
}))));
}
if (mehrfach.gesamt > 0) {
liste.appendChild(fassungsgruppe(
`${b.name} — ${anzahl(mehrfach.gesamt, 'Titel')} mit mehreren Dateien`,
'Meist zwei Fassungen desselben Films — dann ist alles in Ordnung. '
+ 'Stehen hier zwei verschiedene Filme, war einer davon nicht mehr zu '
+ 'sehen: „Eigener Film" löst ihn wieder heraus.',
mehrfach.titel));
}
});
}
function anzahl(n: number, wort: string): string {
return `${n} ${n === 1 ? wort : (wort === 'Datei' ? 'Dateien' : wort)}`;
}
/** Dieselbe Gruppe, aber jede Datei einzeln mit einem Weg heraus. */
function fassungsgruppe(
ueberschrift: string, hinweis: string, titel: Mehrfachtitel[],
): HTMLElement {
const d = document.createElement('div');
d.className = 'einstellung';
const kopf = document.createElement('div');
kopf.className = 'zeile';
kopf.innerHTML = `<strong>${esc(ueberschrift)}</strong>`
+ `<span class="winzig">${esc(hinweis)}</span>`;
d.appendChild(kopf);
const innen = document.createElement('div');
innen.className = 'ohnezuordnung';
titel.forEach(t => {
const zeile = document.createElement('div');
zeile.className = 'fassungstitel';
const name = document.createElement('button');
name.className = 'ohnezuordnung-zeile';
name.innerHTML = `<span class="oz-titel">${esc(t.titel + (t.jahr ? ` (${t.jahr})` : ''))}</span>`;
name.onclick = () => bearbeiten(
t.art === 'series' ? 'series' : 'movies', t.id, () => void laden());
zeile.appendChild(name);
t.dateien.forEach(f => {
const d2 = document.createElement('div');
d2.className = 'fassungsdatei';
const n = document.createElement('span');
n.className = 'oz-dazu';
n.textContent = f.name;
// **Nur bei mehr als einer Datei sinnvoll** — und mehr als eine ist
// hier die Bedingung, überhaupt in der Liste zu stehen.
const raus = knopf('Eigener Film', 'btn klein');
raus.onclick = async () => {
raus.disabled = true;
try {
const neu = await dateiHerausloesen(f.id);
hinweisZeigen(`„${neu.title}" ist jetzt ein eigener Film — ohne Zuordnung.`);
await laden();
} catch (fehler) {
hinweisZeigen(meldung(fehler), 'fehler');
raus.disabled = false;
}
};
d2.append(n, raus);
zeile.appendChild(d2);
});
innen.appendChild(zeile);
});
d.appendChild(innen);
return d;
}
function gruppe(
ueberschrift: string, hinweis: string,
zeilen: { titel: string; dazu: string; tun: () => void }[],
): HTMLElement {
const d = document.createElement('div');
d.className = 'einstellung';
const kopf = document.createElement('div');
kopf.className = 'zeile';
kopf.innerHTML = `<strong>${esc(ueberschrift)}</strong>`
+ `<span class="winzig">${esc(hinweis)}</span>`;
d.appendChild(kopf);
const innen = document.createElement('div');
innen.className = 'ohnezuordnung';
zeilen.forEach(z => {
const b = document.createElement('button');
b.className = 'ohnezuordnung-zeile';
b.innerHTML = `<span class="oz-titel">${esc(z.titel)}</span>`
+ `<span class="oz-dazu">${esc(z.dazu)}</span>`;
b.onclick = z.tun;
innen.appendChild(b);
});
d.appendChild(innen);
return d;
}
return bereich;
}
export function unzugeordnetbereich(): HTMLElement {
const bereich = document.createElement('section');
const kopf = abschnitt('Nicht zugeordnet');
bereich.appendChild(kopf);
const liste = document.createElement('div');
liste.className = 'einstellungen';
bereich.appendChild(liste);
void laden();
async function laden(): Promise<void> {
let alle;
try { alle = await bibliotheken(); }
catch { bereich.classList.add('hidden'); return; }
const ergebnisse = await Promise.all(alle.map(async b => {
try { return { b, offen: await unzugeordnet(b.id) }; }
catch { return null; }
}));
const mitFunden = ergebnisse.filter(e => e !== null).filter(e => e!.offen.gesamt > 0);
// Nichts offen heisst: der Abschnitt hat hier nichts verloren.
if (mitFunden.length === 0) { bereich.classList.add('hidden'); return; }
bereich.classList.remove('hidden');
liste.innerHTML = '';
mitFunden.forEach(eintrag => {
const { b, offen } = eintrag!;
const d = document.createElement('div');
d.className = 'einstellung';
const oben = document.createElement('div');
oben.className = 'knopfreihe';
const text = document.createElement('div');
text.className = 'zeile';
text.style.flex = '1';
text.innerHTML = `<strong>${esc(b.name)}</strong>`
+ `<span class="winzig">${offen.gesamt} ${offen.gesamt === 1 ? 'Datei' : 'Dateien'} `
+ 'ohne Zuordnung</span>';
oben.appendChild(text);
// **Zum Verschicken.** Eine Liste, die man markieren und kopieren muss,
// schickt niemand — und ohne die Pfade lässt sich nicht sagen, woran
// die Erkennung scheitert.
const holen = knopf('Liste herunterladen', 'btn');
holen.onclick = () => {
const zeilen = offen.dateien.map(f => `${f.pfad}\t${f.grund}`).join('\n');
const blob = new Blob(
[`# ${b.name} — ${offen.gesamt} ohne Zuordnung, davon ${offen.dateien.length} aufgeführt\n`
+ zeilen + '\n'],
{ type: 'text/plain;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `nicht-zugeordnet-${b.name}.txt`;
a.click();
URL.revokeObjectURL(a.href);
};
oben.appendChild(holen);
d.appendChild(oben);
// **Nach Grund gebündelt, nicht als Liste von dreitausend Zeilen.** So
// sieht man in zwei Zeilen, ob umzubenennen oder einzuordnen ist.
const gruende = new Map<string, string[]>();
offen.dateien.forEach(f => {
if (!gruende.has(f.grund)) gruende.set(f.grund, []);
gruende.get(f.grund)!.push(f.pfad);
});
[...gruende.entries()]
.sort((a, c) => c[1].length - a[1].length)
.forEach(([grund, pfade]) => {
const g = document.createElement('details');
g.className = 'grundblock';
const z = document.createElement('summary');
z.innerHTML = `<strong>${pfade.length}×</strong> ${esc(grund)}`;
g.appendChild(z);
const pre = document.createElement('pre');
pre.className = 'protokoll';
pre.textContent = pfade.slice(0, 50).join('\n')
+ (pfade.length > 50 ? `\n… und ${pfade.length - 50} weitere` : '');
g.appendChild(pre);
d.appendChild(g);
});
liste.appendChild(d);
});
}
return bereich;
}