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
@@ -79,6 +79,15 @@ export const icons = {
|
|||||||
|
|
||||||
/** Und wieder aufs ganze Bild. */
|
/** Und wieder aufs ganze Bild. */
|
||||||
vergroessern: rahmen('<path d="m5 14.5 7-6.5 7 6.5"/>'),
|
vergroessern: rahmen('<path d="m5 14.5 7-6.5 7 6.5"/>'),
|
||||||
|
/** Ein Mensch — Konten, Profil. */
|
||||||
|
person: rahmen('<circle cx="12" cy="8" r="3.6"/>'
|
||||||
|
+ '<path d="M4.8 20c.6-3.6 3.6-5.6 7.2-5.6s6.6 2 7.2 5.6"/>'),
|
||||||
|
/** Ein beschriebenes Blatt — die Tagesprotokolle. */
|
||||||
|
protokoll: rahmen('<path d="M6 3.5h8L19 8v12.5H6Z"/><path d="M14 3.5V8h5"/>'
|
||||||
|
+ '<path d="M9 12.5h7M9 16h7"/>'),
|
||||||
|
/** Schieberegler — Einstellungen, die eine Stufe haben. */
|
||||||
|
stufen: rahmen('<path d="M5 7h14M5 17h14"/><circle cx="9.5" cy="7" r="2.3"/>'
|
||||||
|
+ '<circle cx="15" cy="17" r="2.3"/>'),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type IconName = keyof typeof icons;
|
export type IconName = keyof typeof icons;
|
||||||
|
|||||||
+9
-3
@@ -11,7 +11,7 @@ import { anmeldung } from './views/login';
|
|||||||
import { startseite } from './views/home';
|
import { startseite } from './views/home';
|
||||||
import { mediathek } from './views/library';
|
import { mediathek } from './views/library';
|
||||||
import { detailseite } from './views/detail';
|
import { detailseite } from './views/detail';
|
||||||
import { verwaltung } from './views/admin';
|
import { verwaltung, alsBereich } from './views/verwaltung';
|
||||||
import { suchseite } from './views/search';
|
import { suchseite } from './views/search';
|
||||||
import { esc, meldung } from './util';
|
import { esc, meldung } from './util';
|
||||||
|
|
||||||
@@ -361,9 +361,15 @@ function zeichnen(): void {
|
|||||||
const weg = location.hash.replace(/^#\/?/, '');
|
const weg = location.hash.replace(/^#\/?/, '');
|
||||||
const teile = weg.split('/').filter(Boolean);
|
const teile = weg.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
// **Zwei Segmente, sonst eines.** Eine Mediathek heisst `#/m/{id}` und
|
||||||
|
// braucht beide; die Verwaltung heisst `#/verwaltung` und hat seit der
|
||||||
|
// zweiten Leiste einen Bereich dahinter. Verglich man stur zwei Segmente,
|
||||||
|
// verlor „Verwaltung" seine Markierung, sobald man darin etwas anklickte.
|
||||||
|
const zweiTeilig = `#/${teile.slice(0, 2).join('/')}`;
|
||||||
|
const einTeilig = `#/${teile.slice(0, 1).join('/')}`;
|
||||||
leiste.querySelectorAll('button[data-ziel]').forEach(b => {
|
leiste.querySelectorAll('button[data-ziel]').forEach(b => {
|
||||||
const ziel = (b as HTMLElement).dataset.ziel!;
|
const ziel = (b as HTMLElement).dataset.ziel!;
|
||||||
b.classList.toggle('check', ziel === `#/${teile.slice(0, 2).join('/')}`);
|
b.classList.toggle('check', ziel === zweiTeilig || ziel === einTeilig);
|
||||||
});
|
});
|
||||||
|
|
||||||
// **Aushängen, nicht leeren.** `innerHTML = ''` zerstörte die Ansicht —
|
// **Aushängen, nicht leeren.** `innerHTML = ''` zerstörte die Ansicht —
|
||||||
@@ -397,7 +403,7 @@ function zeichnen(): void {
|
|||||||
} else if (teile[0] === 'verwaltung' && istAdmin()) {
|
} else if (teile[0] === 'verwaltung' && istAdmin()) {
|
||||||
// **Die Verwaltung auch nicht** — sie zeigt Laufendes. Ihr Takt hängt am
|
// **Die Verwaltung auch nicht** — sie zeigt Laufendes. Ihr Takt hängt am
|
||||||
// Signal und hört auf, sobald man weggeht.
|
// Signal und hört auf, sobald man weggeht.
|
||||||
einhaengen(weg, inhalt, signal => verwaltung(bekannt => {
|
einhaengen(weg, inhalt, signal => verwaltung(alsBereich(teile[1]), bekannt => {
|
||||||
// Gemerkte Ansichten wegwerfen: der Bestand hat sich geändert.
|
// Gemerkte Ansichten wegwerfen: der Bestand hat sich geändert.
|
||||||
// Die Seitenleiste bekommt neue Zahlen — die Seite selbst bleibt.
|
// Die Seitenleiste bekommt neue Zahlen — die Seite selbst bleibt.
|
||||||
vergessen();
|
vergessen();
|
||||||
|
|||||||
+50
-7
@@ -1226,6 +1226,56 @@ body:has(.pl.pl-klein) .auswahlleiste { bottom: 92px; }
|
|||||||
.hero-bald { font-size: 12px; padding: 0 var(--s-snug); }
|
.hero-bald { font-size: 12px; padding: 0 var(--s-snug); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Verwaltung ----------------------------------------------------------
|
||||||
|
*
|
||||||
|
* **Eine zweite Leiste, keine zweite Seite.** Die Verwaltung war elf
|
||||||
|
* Abschnitte in einer Spalte; wer das Protokoll sehen wollte, scrollte an
|
||||||
|
* allem anderen vorbei. Die Leiste sieht aus wie die Hauptleiste daneben —
|
||||||
|
* derselbe Balken links, dieselbe Farbe —, damit sie als Navigation gelesen
|
||||||
|
* wird und nicht als Knopfreihe. */
|
||||||
|
|
||||||
|
.verwaltung { display: flex; align-items: flex-start; gap: var(--s-section); }
|
||||||
|
|
||||||
|
.verw-leiste {
|
||||||
|
position: sticky; top: calc(var(--topbar-h) + var(--s-section));
|
||||||
|
flex: none; width: 196px;
|
||||||
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
|
}
|
||||||
|
.verw-eintrag {
|
||||||
|
display: flex; align-items: center; gap: var(--s-regular);
|
||||||
|
background: none; border: 0; text-align: left;
|
||||||
|
padding: 9px var(--s-regular); color: var(--text-dim);
|
||||||
|
border-left: 3px solid transparent; border-radius: 0 var(--r-control) var(--r-control) 0;
|
||||||
|
font-size: var(--t-normal);
|
||||||
|
}
|
||||||
|
.verw-eintrag .icon { flex: none; color: var(--text-muted); }
|
||||||
|
.verw-eintrag:hover { color: var(--text); background: rgba(255, 255, 255, .04); }
|
||||||
|
.verw-eintrag:hover .icon { color: var(--text-dim); }
|
||||||
|
.verw-eintrag.check {
|
||||||
|
color: var(--accent); border-left-color: var(--accent);
|
||||||
|
background: linear-gradient(90deg, var(--accent-soft), transparent 70%);
|
||||||
|
}
|
||||||
|
.verw-eintrag.check .icon { color: var(--accent); }
|
||||||
|
|
||||||
|
.verw-buehne { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
/* Wie die Hauptleiste: aus der Spalte wird ein Streifen zum Schieben. */
|
||||||
|
.verwaltung { flex-direction: column; gap: var(--s-loose); }
|
||||||
|
.verw-leiste {
|
||||||
|
position: static; width: auto;
|
||||||
|
flex-direction: row; overflow-x: auto; gap: var(--s-tight);
|
||||||
|
scrollbar-width: none; padding-bottom: var(--s-tight);
|
||||||
|
}
|
||||||
|
.verw-leiste::-webkit-scrollbar { display: none; }
|
||||||
|
.verw-eintrag {
|
||||||
|
flex: none; white-space: nowrap;
|
||||||
|
border-left: 0; border-bottom: 2px solid transparent; border-radius: var(--r-control);
|
||||||
|
}
|
||||||
|
.verw-eintrag.check { border-bottom-color: var(--accent); background: var(--accent-soft); }
|
||||||
|
.verw-buehne { width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Kontextmenü ---------------------------------------------------------
|
/* --- Kontextmenü ---------------------------------------------------------
|
||||||
*
|
*
|
||||||
* Gesehen, Favorit und Weglegen gab es nur auf der Detailseite. In einer
|
* Gesehen, Favorit und Weglegen gab es nur auf der Detailseite. In einer
|
||||||
@@ -1678,13 +1728,6 @@ body:has(.auswahlleiste:not(.hidden)) .hinweise { bottom: 104px; }
|
|||||||
.fassungsdatei .oz-dazu { flex: 1 1 auto; }
|
.fassungsdatei .oz-dazu { flex: 1 1 auto; }
|
||||||
.btn.klein { padding: 4px 10px; font-size: 12px; flex: 0 0 auto; }
|
.btn.klein { padding: 4px 10px; font-size: 12px; flex: 0 0 auto; }
|
||||||
|
|
||||||
/* Kurz hervorheben, wohin gesprungen wurde — sonst sucht man oben weiter. */
|
|
||||||
.hervor { animation: hervor 1.6s ease-out; }
|
|
||||||
@keyframes hervor {
|
|
||||||
0%, 60% { background: var(--accent-soft); border-radius: var(--r-karte); }
|
|
||||||
100% { background: transparent; }
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) { .hervor { animation: none; } }
|
|
||||||
|
|
||||||
/* Der Ordner in den Medienangaben — schmaler gesetzt, er ist ein Pfad. */
|
/* Der Ordner in den Medienangaben — schmaler gesetzt, er ist ein Pfad. */
|
||||||
.medientabelle dd.pfad {
|
.medientabelle dd.pfad {
|
||||||
|
|||||||
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