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:
co-authored by
Claude Opus 5
parent
94c6604e8b
commit
1e4a231d1b
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user