diff --git a/server/tests/Flaro.Api.Tests/OrdnerbaumTests.cs b/server/tests/Flaro.Api.Tests/OrdnerbaumTests.cs new file mode 100644 index 0000000..9256be9 --- /dev/null +++ b/server/tests/Flaro.Api.Tests/OrdnerbaumTests.cs @@ -0,0 +1,151 @@ +using Flaro.Api; + +namespace Flaro.Api.Tests; + +/// Das Durchklicken durch das Dateisystem. +/// +/// +/// Alles unter , nicht unter festen Pfaden. +/// Diese Tests laufen auf dem Arbeitsrechner, wo es weder /media noch +/// /volume1 gibt — ein Test, der das voraussetzt, prüft nur, auf +/// welchem Rechner er gerade läuft. +/// +public class OrdnerbaumTests +{ + private string _wurzel = ""; + + [SetUp] + public void Anlegen() => + _wurzel = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "flaro-baum-" + Guid.CreateVersion7().ToString("N"))).FullName; + + [TearDown] + public void Aufraeumen() + { + // Rechte zurücksetzen, sonst bleibt das gesperrte Verzeichnis liegen. + if (!OperatingSystem.IsWindows() && Directory.Exists(_wurzel)) + { + foreach (var d in Directory.EnumerateDirectories(_wurzel, "*", SearchOption.AllDirectories)) + File.SetUnixFileMode(d, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + if (Directory.Exists(_wurzel)) Directory.Delete(_wurzel, recursive: true); + } + + [Test] + public void ListetUnterordnerAlphabetisch() + { + Directory.CreateDirectory(Path.Combine(_wurzel, "Serien")); + Directory.CreateDirectory(Path.Combine(_wurzel, "filme")); + Directory.CreateDirectory(Path.Combine(_wurzel, "Dokus")); + + var baum = Ordnerbaum.Lesen(_wurzel); + + Assert.That(baum, Is.Not.Null); + // Ohne Rücksicht auf Gross- und Kleinschreibung — sonst stünde + // „filme" hinter „Serien", was in einer Liste niemand sucht. + Assert.That(baum!.Entries.Select(e => e.Name), Is.EqualTo(new[] { "Dokus", "filme", "Serien" })); + Assert.That(baum.Note, Is.Null); + } + + /// Dateien gehören nicht in eine Ordnerauswahl. + [Test] + public void ZeigtNurOrdnerKeineDateien() + { + Directory.CreateDirectory(Path.Combine(_wurzel, "Filme")); + File.WriteAllText(Path.Combine(_wurzel, "Dune.mkv"), ""); + + var baum = Ordnerbaum.Lesen(_wurzel); + + Assert.That(baum!.Entries.Select(e => e.Name), Is.EqualTo(new[] { "Filme" })); + } + + [Test] + public void KenntDenUebergeordnetenOrdner() + { + var unten = Directory.CreateDirectory(Path.Combine(_wurzel, "Filme", "Tief")).FullName; + + var baum = Ordnerbaum.Lesen(unten); + + Assert.That(baum!.Parent, Is.EqualTo(Path.Combine(_wurzel, "Filme"))); + } + + /// „/media/../etc" wird zu „/etc" — der angezeigte Pfad ist der, den man tippen würde. + [Test] + public void LoestPunktPunktAuf() + { + Directory.CreateDirectory(Path.Combine(_wurzel, "Filme")); + var verschlungen = Path.Combine(_wurzel, "Filme", "..", "Filme"); + + var baum = Ordnerbaum.Lesen(verschlungen); + + Assert.That(baum!.Path, Is.EqualTo(Path.Combine(_wurzel, "Filme"))); + } + + /// null heisst „diesen Pfad gibt es nicht" — nicht „leer". + [Test] + public void GibtNullFuerEinenPfadDenEsNichtGibt() => + Assert.That(Ordnerbaum.Lesen(Path.Combine(_wurzel, "gibtesnicht")), Is.Null); + + /// Eine Datei ist kein Ordner, auch wenn es sie gibt. + [Test] + public void GibtNullFuerEineDatei() + { + var datei = Path.Combine(_wurzel, "Dune.mkv"); + File.WriteAllText(datei, ""); + + Assert.That(Ordnerbaum.Lesen(datei), Is.Null); + } + + [Test] + public void SagtWennEsNichtsDarunterGibt() + { + var baum = Ordnerbaum.Lesen(_wurzel); + + Assert.That(baum!.Entries, Is.Empty); + Assert.That(baum.Note, Is.EqualTo("Keine Unterordner.")); + } + + /// Ein Ordner ohne Leserecht ist eine Auskunft, kein Serverfehler. + /// + /// + /// Ohne diesen Fall flöge eine bis + /// in die Antwort und würde zu einer 500 — während die ehrliche Antwort + /// lautet: den darfst du nicht lesen. + /// + [Test] + public void MeldetFehlendesLeserechtStattZuWerfen() + { + // Mit `return` statt nur `Assert.Ignore`: der Plattform-Analysator + // kennt `Assert.Ignore` nicht als Abbruch und hielte den Rest sonst + // unter Windows für erreichbar — CA1416. + if (OperatingSystem.IsWindows()) + { + Assert.Ignore("Unix-Rechte gibt es dort nicht."); + return; + } + + + var gesperrt = Directory.CreateDirectory(Path.Combine(_wurzel, "gesperrt")).FullName; + Directory.CreateDirectory(Path.Combine(gesperrt, "darin")); + File.SetUnixFileMode(gesperrt, UnixFileMode.None); + + var baum = Ordnerbaum.Lesen(gesperrt); + + Assert.That(baum, Is.Not.Null); + Assert.That(baum!.Entries, Is.Empty); + Assert.That(baum.Note, Is.EqualTo("Kein Leserecht für diesen Ordner.")); + } + + /// Ohne Angabe fängt die Auswahl irgendwo Sinnvollem an — und existiert. + [Test] + public void FaengtBeiEinemVorhandenenOrdnerAn() + { + var anfang = Ordnerbaum.Anfang(); + + Assert.That(anfang, Is.Not.Empty); + Assert.That(Directory.Exists(anfang), $"Der Anfang „{anfang}\" müsste es geben."); + Assert.That(Ordnerbaum.Lesen(null), Is.Not.Null); + Assert.That(Ordnerbaum.Lesen(" "), Is.Not.Null, "Leerzeichen zählen wie keine Angabe."); + } +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index c4fcec8..6be2196 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -418,6 +418,20 @@ export const laufendeWiedergaben = () => export const serverzustand = () => anfrage('/system/status'); +/** Ein Ordner im Dateisystem des Servers, zum Durchklicken. */ +export type Ordnerstand = { + path: string; + /** `null` an der Wurzel — dann gibt es kein „eine Ebene hoch". */ + parent: string | null; + entries: { name: string; path: string }[]; + /** Warum die Liste leer ist — oder `null`, wenn sie es nicht ist. */ + note: string | null; +}; + +/** Listet Unterordner. Ohne Pfad fängt der Server bei etwas Sinnvollem an. */ +export const ordner = (pfad?: string) => + anfrage('/system/folders' + (pfad ? `?path=${encodeURIComponent(pfad)}` : '')); + export const servernameSetzen = (name: string) => anfrage('/system/name', { method: 'PUT', body: JSON.stringify({ name }) }); diff --git a/web/app/src/dialog.ts b/web/app/src/dialog.ts index 0004278..e717abb 100644 --- a/web/app/src/dialog.ts +++ b/web/app/src/dialog.ts @@ -1,5 +1,6 @@ import { icons } from './icons'; import { knopf } from './util'; +import { ordner, type Ordnerstand } from './api'; /* Dialoge über dem Inhalt statt Blöcken darin. * @@ -9,7 +10,7 @@ import { knopf } from './util'; * Vorlesesoftware kündigt ihn als Dialog an. Jede Zeile, die das selbst * baut, ist eine Zeile, die es schlechter kann. */ -export type Feldart = 'text' | 'password' | 'number'; +export type Feldart = 'text' | 'password' | 'number' | 'ordner'; export type Dialogfeld = { name: string; @@ -71,7 +72,10 @@ export function dialog(optionen: { const eingaben = new Map(); (optionen.felder ?? []).forEach(f => { - const block = document.createElement('label'); + // **Ein `