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 `