From 84cb3ee2003bf90b018719771fe9fc58796e8e17 Mon Sep 17 00:00:00 2001
From: D4rkst3r
Date: Tue, 11 Aug 2026 19:10:59 +0200
Subject: [PATCH] feat: Galerie fuer 900 Dateien, und ein Finder fuer Verwaiste
SORTIERUNG. Nach Pfad, Groesse, Anlage- oder Aenderungsdatum, auf- und
absteigend. Bisher gab es genau eine Reihenfolge (zuletzt geaendert), was bei
228 Dateien schon nicht mehr reicht.
Die Sortierung kommt vom Aufrufer und geht in SQL, also wird sie NIEMALS
durchgereicht, sondern gegen eine feste Liste getauscht. Nachgemessen mit
"sort=; DROP TABLE media--": faellt auf den Standard zurueck, Datenbank
unveraendert. Nach dem Pfad wird ohne Ruecksicht auf Gross- und Kleinschreibung
sortiert, sonst stuende "Adder" vor "adder" und dazwischen nichts.
ALLE TREFFER WAEHLEN. GET /media/ids liefert nur die IDs zur aktuellen
Auswahl -- ohne das braucht das Leeren eines Ordners mit 900 Bildern fuenfzehn
Seiten Klicken. Gedeckelt auf 5000, und wer daran stoesst, bekommt es GESAGT
(gekappt: true) statt still eine halbe Auswahl.
VERWAISTE. Ein Knopf zaehlt drei Arten von Abweichung: Dateien ohne Datensatz,
Datensaetze ohne Datei, Vorschauen ohne Bild. ERST ZAEHLEN, DANN FRAGEN -- die
Suche veraendert nichts.
Und das Aufraeumen geht in ZWEI Richtungen, wobei die schonende die
Voreinstellung ist: "aufnehmen" gibt Dateien ohne Datensatz einen, sie sind ja
da und werden ausgeliefert -- sie fehlen nur in der Liste. Erst "aufraeumen"
entfernt etwas, und auch dann nur Verweise auf Dateien, die es nicht mehr gibt,
und abgeleitete Vorschauen. Beides landet im Verlauf.
Am laufenden System durchgespielt: Datei am Dienst vorbei ins Volume gelegt ->
gefunden; aufgenommen -> steht in der Galerie; Datei hinter dem Ruecken
geloescht -> Datensatz ohne Datei gefunden; aufgeraeumt -> weg. Verlauf hat
beide Schritte mit user:admin.
(Der erste Versuch dieses Tests hat gelogen: Git Bash schreibt einen absoluten
Pfad in `docker exec` zu einem Windows-Pfad um -- rm suchte nach
"C:/Program Files/Git/data/...". `rm -f` schweigt dabei. Mit `sh -c` drumherum
bleibt der Pfad heil.)
Co-Authored-By: Claude Opus 5
---
server/src/routes/dash.ts | 198 ++++++++++++++++++++++++++++++-
server/ui/src/api.ts | 32 ++++-
server/ui/src/pages/Galerie.tsx | 65 +++++++++-
server/ui/src/pages/Speicher.tsx | 164 ++++++++++++++++++++++++-
4 files changed, 450 insertions(+), 9 deletions(-)
diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts
index 4c944ea..125331e 100644
--- a/server/src/routes/dash.ts
+++ b/server/src/routes/dash.ts
@@ -31,7 +31,8 @@ import {
userForSession,
verifyPassword,
} from '../auth.js'
-import { readFile, stat } from 'node:fs/promises'
+import { readdir, readFile, rm, stat } from 'node:fs/promises'
+import { join } from 'node:path'
import { DISCORD_KEYS, ausDatenbank, discordConfig, setSetting } from '../settings.js'
import { discordRoutes, redirectUri } from './discord.js'
import {
@@ -304,12 +305,28 @@ dashRoutes.get('/media', (c) => {
const where = bedingungen.length ? `WHERE ${bedingungen.join(' AND ')}` : ''
+ // Die Sortierung kommt vom Aufrufer und geht in SQL -- also NIEMALS
+ // durchgereicht, sondern gegen eine feste Liste getauscht. Ein Feldname
+ // aus einer Adresszeile ist die aelteste Einladung ueberhaupt.
+ const SPALTEN: Record = {
+ geaendert: 'updated_at',
+ angelegt: 'created_at',
+ pfad: 'path',
+ groesse: 'size',
+ }
+ const sortSpalte = SPALTEN[c.req.query('sort') ?? ''] ?? 'updated_at'
+ const richtung = c.req.query('dir') === 'auf' ? 'ASC' : 'DESC'
+ // Nach dem Pfad wird ohne Ruecksicht auf Gross- und Kleinschreibung
+ // sortiert: sonst stuende "Adder" vor "adder" und dazwischen nichts.
+ const nachSpalte = sortSpalte === 'path' ? 'path COLLATE NOCASE' : sortSpalte
+
const total = db.prepare(`SELECT COUNT(*) AS n FROM media ${where}`).get(...params) as {
n: number
}
const rows = db
.prepare(
- `SELECT * FROM media ${where} ORDER BY updated_at DESC LIMIT ? OFFSET ?`,
+ `SELECT * FROM media ${where} ORDER BY ${nachSpalte} ${richtung}, id DESC
+ LIMIT ? OFFSET ?`,
)
.all(...params, limit, offset) as Media[]
@@ -369,6 +386,43 @@ dashRoutes.get('/folders', (c) => {
})
})
+/** Nur die IDs zu einer Auswahl — fuer "alle Treffer waehlen".
+ *
+ * Ohne das muesste jemand fuenfzehn Seiten durchblaettern und auf jeder
+ * "Seite waehlen" druecken, um einen Ordner mit 900 Bildern zu leeren.
+ *
+ * Gedeckelt auf 5000: das ist mehr, als hier je auf einmal ausgewaehlt wird,
+ * und verhindert, dass eine Antwort in die Megabyte laeuft. Wer daran stoesst,
+ * bekommt es GESAGT (gekappt: true) statt still eine halbe Auswahl.
+ */
+dashRoutes.get('/media/ids', (c) => {
+ const query = c.req.query('query')?.trim() ?? ''
+ const folder = c.req.query('folder')?.trim().replace(/\/+$/, '') ?? ''
+ const maskiert = (t: string) => t.replace(/[\\%_]/g, (z) => `\\${z}`)
+
+ const bedingungen: string[] = []
+ const params: unknown[] = []
+ if (query) {
+ bedingungen.push(`path LIKE ? ESCAPE '\\'`)
+ params.push(`%${maskiert(query)}%`)
+ }
+ if (folder) {
+ bedingungen.push(`path LIKE ? ESCAPE '\\'`)
+ params.push(`${maskiert(folder)}/%`)
+ }
+ const where = bedingungen.length ? `WHERE ${bedingungen.join(' AND ')}` : ''
+
+ const DECKEL = 5000
+ const rows = db
+ .prepare(`SELECT id FROM media ${where} ORDER BY updated_at DESC LIMIT ?`)
+ .all(...params, DECKEL + 1) as { id: number }[]
+
+ return c.json({
+ ids: rows.slice(0, DECKEL).map((r) => r.id),
+ gekappt: rows.length > DECKEL,
+ })
+})
+
dashRoutes.delete('/media/:id', async (c) => {
const id = Number(c.req.param('id'))
const row = db.prepare('SELECT * FROM media WHERE id = ?').get(id) as Media | undefined
@@ -835,6 +889,146 @@ dashRoutes.post('/maintenance/prune-sessions', (c) => {
return c.json({ ok: true })
})
+/** Was nicht zusammenpasst: Dateien ohne Datensatz, Datensaetze ohne Datei,
+ * Vorschauen ohne Bild.
+ *
+ * ERST ZAEHLEN, DANN FRAGEN. Diese Route veraendert NICHTS -- sie sagt nur,
+ * was sie sieht, mit Beispielen. Ein Knopf, der ungefragt aufraeumt, ist bei
+ * einer Ablage ohne Papierkorb der falsche Knopf.
+ *
+ * Wie es dazu kommt: jemand loescht im Volume von Hand, ein Upload bricht
+ * zwischen Datei und Datensatz ab, oder ein Bild wird ausserhalb des Dienstes
+ * hineinkopiert. Das Sicherungsskript zaehlt beides schon gegeneinander und
+ * meldet die Abweichung -- reparieren konnte es sie bisher nicht. */
+async function verwaisteSuchen() {
+ const rows = db.prepare('SELECT id, path, size FROM media').all() as {
+ id: number
+ path: string
+ size: number
+ }[]
+ const bekannt = new Map(rows.map((r) => [r.path, r]))
+
+ // Alles, was wirklich auf der Platte liegt.
+ const aufPlatte: string[] = []
+ async function durchlaufen(rel: string) {
+ let eintraege
+ try {
+ eintraege = await readdir(join(config.filesDir, rel), { withFileTypes: true })
+ } catch {
+ return
+ }
+ for (const e of eintraege) {
+ const p = rel ? `${rel}/${e.name}` : e.name
+ if (e.isDirectory()) await durchlaufen(p)
+ else if (e.isFile()) aufPlatte.push(p)
+ }
+ }
+ await durchlaufen('')
+
+ const dateiOhneDatensatz = aufPlatte.filter((p) => !bekannt.has(p))
+ const datensatzOhneDatei: typeof rows = []
+ for (const r of rows) {
+ try {
+ await stat(absolutePath(r.path))
+ } catch {
+ datensatzOhneDatei.push(r)
+ }
+ }
+
+ // Vorschauen ohne Bild. Der Name traegt die Endung des Originals plus
+ // ".webp" -- deshalb wird sie hier abgeschnitten, um zurueckzurechnen.
+ const vorschauen: string[] = []
+ async function thumbsDurchlaufen(rel: string) {
+ let eintraege
+ try {
+ eintraege = await readdir(join(config.dataDir, 'thumbs', rel), {
+ withFileTypes: true,
+ })
+ } catch {
+ return
+ }
+ for (const e of eintraege) {
+ const p = rel ? `${rel}/${e.name}` : e.name
+ if (e.isDirectory()) await thumbsDurchlaufen(p)
+ else if (e.isFile()) vorschauen.push(p)
+ }
+ }
+ await thumbsDurchlaufen('')
+ const vorschauOhneBild = vorschauen.filter((p) => !bekannt.has(p.replace(/\.webp$/, '')))
+
+ return { rows, dateiOhneDatensatz, datensatzOhneDatei, vorschauOhneBild }
+}
+
+dashRoutes.get('/maintenance/orphans', async (c) => {
+ const f = await verwaisteSuchen()
+ return c.json({
+ dateiOhneDatensatz: {
+ anzahl: f.dateiOhneDatensatz.length,
+ beispiele: f.dateiOhneDatensatz.slice(0, 20),
+ },
+ datensatzOhneDatei: {
+ anzahl: f.datensatzOhneDatei.length,
+ beispiele: f.datensatzOhneDatei.slice(0, 20).map((r) => r.path),
+ },
+ vorschauOhneBild: {
+ anzahl: f.vorschauOhneBild.length,
+ beispiele: f.vorschauOhneBild.slice(0, 20),
+ },
+ })
+})
+
+/** Aufraeumen -- in zwei Richtungen, und die schonende ist die Voreinstellung.
+ *
+ * 'aufnehmen' Dateien ohne Datensatz kommen in die Datenbank. Sie sind da,
+ * sie sind ausgeliefert -- sie fehlen nur in der Liste. Das ist
+ * fast immer das Richtige und loescht NICHTS.
+ * 'aufraeumen' Datensaetze ohne Datei und verwaiste Vorschauen fliegen raus.
+ * Hier geht etwas weg, aber nur Verweise auf Dateien, die es
+ * nicht mehr gibt, und abgeleitete Bilder. */
+dashRoutes.post('/maintenance/orphans', async (c) => {
+ const body = await jsonBody<{ aktion?: string }>(c)
+ const aktion = body?.aktion ?? 'aufnehmen'
+ const f = await verwaisteSuchen()
+ const wer = c.get('user').username
+
+ if (aktion === 'aufnehmen') {
+ let aufgenommen = 0
+ for (const pfad of f.dateiOhneDatensatz) {
+ try {
+ const daten = await readFile(absolutePath(pfad))
+ const zeit = now()
+ db.prepare(
+ `INSERT INTO media (path, size, sha256, mime, token_id, created_at, updated_at)
+ VALUES (?, ?, ?, ?, NULL, ?, ?)`,
+ ).run(pfad, daten.length, sha256(daten), mimeFor(pfad), zeit, zeit)
+ if (kannVorschau(mimeFor(pfad))) await writeThumb(pfad, daten)
+ logEvent('upload', pfad, daten.length, 'user', wer)
+ aufgenommen++
+ } catch (err) {
+ console.error('[verwaiste] ' + pfad, err)
+ }
+ }
+ return c.json({ aktion, aufgenommen })
+ }
+
+ if (aktion === 'aufraeumen') {
+ let datensaetze = 0
+ for (const r of f.datensatzOhneDatei) {
+ db.prepare('DELETE FROM media WHERE id = ?').run(r.id)
+ logEvent('delete', r.path, r.size, 'user', wer)
+ datensaetze++
+ }
+ let vorschauen = 0
+ for (const p of f.vorschauOhneBild) {
+ await rm(join(config.dataDir, 'thumbs', p), { force: true })
+ vorschauen++
+ }
+ return c.json({ aktion, datensaetze, vorschauen })
+ }
+
+ return c.json({ error: 'unbekannte Aktion — "aufnehmen" oder "aufraeumen"' }, 400)
+})
+
/** Fehlende Vorschaubilder nachziehen.
*
* Braucht es, weil die Vorschauen erst spaeter dazukamen: was vorher
diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts
index 8cbc18e..1efb550 100644
--- a/server/ui/src/api.ts
+++ b/server/ui/src/api.ts
@@ -159,6 +159,15 @@ export type Folders = {
/** Der billige Fuehler fuer die automatische Auffrischung. */
export type Stand = { medien: number; neuest: number; ereignis: number }
+export type Sortierung = 'geaendert' | 'angelegt' | 'pfad' | 'groesse'
+export type Richtung = 'auf' | 'ab'
+
+export type Verwaiste = {
+ dateiOhneDatensatz: { anzahl: number; beispiele: string[] }
+ datensatzOhneDatei: { anzahl: number; beispiele: string[] }
+ vorschauOhneBild: { anzahl: number; beispiele: string[] }
+}
+
export type Einstellungen = {
discord: {
clientId: string
@@ -241,10 +250,23 @@ export const api = {
changePassword: (current: string, next: string) =>
post<{ ok: true }>('/api/dash/auth/password', { current, next }),
- media: (query: string, folder: string, limit: number, offset: number) =>
+ media: (
+ query: string,
+ folder: string,
+ limit: number,
+ offset: number,
+ sort: Sortierung = 'geaendert',
+ dir: Richtung = 'ab',
+ ) =>
get(
`/api/dash/media?query=${encodeURIComponent(query)}` +
- `&folder=${encodeURIComponent(folder)}&limit=${limit}&offset=${offset}`,
+ `&folder=${encodeURIComponent(folder)}&limit=${limit}&offset=${offset}` +
+ `&sort=${sort}&dir=${dir}`,
+ ),
+ /** Nur die IDs — fuer "alle Treffer waehlen", ohne 15 Seiten zu blaettern. */
+ medienIds: (query: string, folder: string) =>
+ get<{ ids: number[]; gekappt: boolean }>(
+ `/api/dash/media/ids?query=${encodeURIComponent(query)}&folder=${encodeURIComponent(folder)}`,
),
folders: () => get('/api/dash/folders'),
deleteMedia: (id: number) => del<{ deleted: string }>(`/api/dash/media/${id}`),
@@ -271,6 +293,12 @@ export const api = {
stand: () => get('/api/dash/stand'),
stats: () => get('/api/dash/stats'),
thumbsNachziehen: () => post('/api/dash/maintenance/thumbs'),
+ verwaiste: () => get('/api/dash/maintenance/orphans'),
+ verwaisteBehandeln: (aktion: 'aufnehmen' | 'aufraeumen') =>
+ post<{ aktion: string; aufgenommen?: number; datensaetze?: number; vorschauen?: number }>(
+ '/api/dash/maintenance/orphans',
+ { aktion },
+ ),
tokens: () => get<{ items: TokenRow[] }>('/api/dash/tokens'),
createToken: (name: string, prefix: string, canDelete: boolean) =>
diff --git a/server/ui/src/pages/Galerie.tsx b/server/ui/src/pages/Galerie.tsx
index dce3c2e..ee69337 100644
--- a/server/ui/src/pages/Galerie.tsx
+++ b/server/ui/src/pages/Galerie.tsx
@@ -6,6 +6,7 @@ import {
Copy,
ExternalLink,
FileQuestion,
+ ArrowDownUp,
ImageOff,
Loader2,
RefreshCw,
@@ -13,7 +14,7 @@ import {
Trash2,
X,
} from 'lucide-react'
-import { api, vorschauUrl, type Folders, type MediaItem, type MediaPage } from '../api'
+import { api, vorschauUrl, type Folders, type MediaItem, type MediaPage, type Richtung, type Sortierung } from '../api'
import { run, useStore } from '../store'
import { bytes, istBild, zeit } from '../format'
import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
@@ -30,6 +31,8 @@ export function Galerie() {
const [ordner, setOrdner] = useState('')
const [ordnerDaten, setOrdnerDaten] = useState(null)
const [seite, setSeite] = useState(0)
+ const [sortierung, setSortierung] = useState('geaendert')
+ const [richtung, setRichtung] = useState('ab')
const [daten, setDaten] = useState(null)
const [laedt, setLaedt] = useState(true)
const [gewaehlt, setGewaehlt] = useState>(new Set())
@@ -61,7 +64,14 @@ export function Galerie() {
const meine = ++lauf.current
setLaedt(true)
try {
- const seiteDaten = await api.media(sucheAktiv, ordner, PRO_SEITE, seite * PRO_SEITE)
+ const seiteDaten = await api.media(
+ sucheAktiv,
+ ordner,
+ PRO_SEITE,
+ seite * PRO_SEITE,
+ sortierung,
+ richtung,
+ )
if (meine !== lauf.current) return
setDaten(seiteDaten)
} catch (err) {
@@ -70,7 +80,7 @@ export function Galerie() {
} finally {
if (meine === lauf.current) setLaedt(false)
}
- }, [sucheAktiv, ordner, seite, note])
+ }, [sucheAktiv, ordner, seite, sortierung, richtung, note])
useEffect(() => {
void laden()
@@ -119,6 +129,17 @@ export function Galerie() {
const alleGewaehlt = items.length > 0 && items.every((i) => gewaehlt.has(i.id))
+ async function alleTrefferWaehlen() {
+ const antwort = await run(
+ () => api.medienIds(sucheAktiv, ordner),
+ (r) =>
+ r.gekappt
+ ? `${r.ids.length} gewählt — mehr gibt der Dienst auf einmal nicht heraus.`
+ : `${r.ids.length} gewählt.`,
+ )
+ if (antwort) setGewaehlt(new Set(antwort.ids))
+ }
+
function alleUmschalten() {
setGewaehlt((alt) => {
const neu = new Set(alt)
@@ -240,6 +261,35 @@ export function Galerie() {
automatisch
+
+
+ {
+ setSortierung(e.target.value as Sortierung)
+ setSeite(0)
+ }}
+ className="rounded-md border border-rand bg-flaeche px-2 py-1.5 text-sm
+ text-schrift focus:border-akzent focus:outline-none"
+ >
+ zuletzt geändert
+ zuerst hochgeladen
+ Pfad
+ Größe
+
+ {
+ setRichtung(richtung === 'ab' ? 'auf' : 'ab')
+ setSeite(0)
+ }}
+ className="rounded-md border border-rand px-2 py-1.5 text-schrift-leise
+ hover:bg-flaeche-hoch hover:text-schrift"
+ title={richtung === 'ab' ? 'absteigend' : 'aufsteigend'}
+ >
+ {richtung === 'ab' ? '↓' : '↑'}
+
+
+
{items.length > 0 && (
)}
+ {/* Erst anbieten, wenn es ueberhaupt mehr als eine Seite gibt --
+ sonst steht dort ein Knopf, der dasselbe tut wie der davor. */}
+ {gesamt > items.length && (
+ void alleTrefferWaehlen()}
+ kind={`Alle ${gesamt} wählen`}
+ />
+ )}
+
{gewaehlt.size > 0 && (
s.note)
const [stats, setStats] = useState(null)
const [laedt, setLaedt] = useState(true)
const [zieht, setZieht] = useState(false)
+ const [verwaiste, setVerwaiste] = useState(null)
+ const [sucht, setSucht] = useState(false)
+ const [handelt, setHandelt] = useState(false)
+ const [fragt, setFragt] = useState<'aufnehmen' | 'aufraeumen' | null>(null)
const { kopiert, kopieren } = useKopieren()
useEffect(() => {
@@ -99,8 +113,119 @@ export function Speicher() {
hochgeladen wurde, hat noch keine.
+
+
+
+
{
+ setSucht(true)
+ const v = await run(
+ () => api.verwaiste(),
+ (r) =>
+ r.dateiOhneDatensatz.anzahl +
+ r.datensatzOhneDatei.anzahl +
+ r.vorschauOhneBild.anzahl ===
+ 0
+ ? 'Alles passt zusammen.'
+ : 'Abweichungen gefunden — siehe unten.',
+ )
+ setSucht(false)
+ if (v) setVerwaiste(v)
+ }}
+ kind={
+ <>
+
+ Verwaiste suchen
+ >
+ }
+ />
+
+ Dateien ohne Datensatz, Datensätze ohne Datei, Vorschauen ohne
+ Bild. Sucht nur — verändert nichts.
+
+
+
+ {verwaiste && (
+
+
0 ? (
+ setFragt('aufnehmen')}
+ kind="In die Liste aufnehmen"
+ />
+ ) : null
+ }
+ />
+
+
+ {(verwaiste.datensatzOhneDatei.anzahl > 0 ||
+ verwaiste.vorschauOhneBild.anzahl > 0) && (
+
+ setFragt('aufraeumen')}
+ kind="Leere Datensätze und Vorschauen entfernen"
+ />
+
+ )}
+
+ )}
+
+ setFragt(null)}
+ onJa={async () => {
+ if (!fragt) return
+ setHandelt(true)
+ await run(
+ () => api.verwaisteBehandeln(fragt),
+ (r) =>
+ fragt === 'aufnehmen'
+ ? `${r.aufgenommen} Dateien in die Liste aufgenommen.`
+ : `${r.datensaetze} Datensätze und ${r.vorschauen} Vorschauen entfernt.`,
+ )
+ setHandelt(false)
+ setFragt(null)
+ const v = await run(() => api.verwaiste(), 'Neu gezählt.')
+ if (v) setVerwaiste(v)
+ }}
+ text={
+ fragt === 'aufnehmen' ? (
+
+ Die gefundenen Dateien bekommen einen Datensatz und erscheinen in der
+ Galerie. Es wird nichts gelöscht und nichts überschrieben.
+
+ ) : (
+
+ Datensätze, deren Datei fehlt, werden entfernt — die Dateien sind
+ ohnehin weg. Verwaiste Vorschauen werden gelöscht; sie lassen sich
+ jederzeit neu rechnen.
+
+ )
+ }
+ />
+
{stats.folders.length === 0 ? (
@@ -142,6 +267,41 @@ export function Speicher() {
)
}
+function Befund({
+ titel,
+ daten,
+ hinweis,
+ knopf,
+}: {
+ titel: string
+ daten: { anzahl: number; beispiele: string[] }
+ hinweis: string
+ knopf?: React.ReactNode
+}) {
+ return (
+
+
+ 0 ? 'font-medium text-warn' : 'text-schrift-leise'}>
+ {daten.anzahl}
+
+ {titel}
+ {hinweis}
+ {knopf}
+
+ {daten.beispiele.length > 0 && (
+
+ {daten.beispiele.map((b) => (
+ {b}
+ ))}
+ {daten.anzahl > daten.beispiele.length && (
+ … und {daten.anzahl - daten.beispiele.length} weitere
+ )}
+
+ )}
+
+ )
+}
+
function Zahl({ kind, was, wert }: { kind: React.ReactNode; was: string; wert: string }) {
return (