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 (