feat(cms): AVIF und sprechende Dateinamen fuer Uploads

Uploads werden nach AVIF gewandelt statt nach WebP und heissen jetzt nach
dem Inhalt statt nach Zeitstempel und Zufall:
- Events uebernehmen den Event-Titel, die Gallery den Alt-Text, Bilder in
  den Textbereichen ihren Zweck, PDFs den urspruenglichen Dateinamen.
- Ohne solche Angabe wird der Dateiname vor dem Upload verwendet.
- Umlaute werden ausgeschrieben (Getraenkekarte, nicht Getrnkekarte),
  Sonderzeichen und Pfadanteile fallen weg.
- Gleiche Namen werden durchnummeriert, damit zwei Events namens
  "Karaoke" sich nicht gegenseitig ueberschreiben.

Besitz statt Namensmuster:
Die Loeschsicherheit haing bisher am Namensmuster <zeitstempel>-<zufall>.
Mit sprechenden Namen traegt der Name diese Information nicht mehr - ein
hochgeladenes karaoke-abend.avif ist von einem handgepflegten
event_karaoke.jpg nicht zu unterscheiden. Deshalb fuehrt das CMS jetzt in
managed_assets Buch darueber, welche Dateien es selbst angelegt hat, und
loescht ausschliesslich diese. Uploads von vor der Umstellung werden
weiterhin am alten Muster erkannt, damit sie aufraeumbar bleiben.

initDatabase() legte Tabellen nur an, wenn users noch fehlte. Auf einer
bestehenden Datenbank waere managed_assets damit nie entstanden. Der
Block laeuft jetzt bei jedem Start; alle Anweisungen sind IF NOT EXISTS.

Nebenbei repariert: die Formulare schickten den Alt-Text NACH der Datei.
Zu dem Zeitpunkt hat der Server ihn noch nicht geparst, in der Gallery
landete deshalb immer der Dateiname als Alt-Text. Textfelder gehen jetzt
vor der Datei raus.

Gemessen an einem 4032x3024-Bild: 0.90 MB rein, 36 KB AVIF bei 1600x1200
raus, 579 ms. Das Testbild ist synthetisch und komprimiert besser als ein
echtes Foto - die Verkleinerung auf 1600px greift aber immer.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-08-11 13:39:42 +02:00
co-authored by Claude Opus 5
parent 1a7a4f8a02
commit f9193eebfd
12 changed files with 258 additions and 101 deletions
+30 -3
View File
@@ -1,18 +1,45 @@
import { db } from '../config/database.js';
import { events, galleryImages, contentSections } from '../db/schema.js';
import { AssetService } from './asset.service.js';
import { isManagedAsset, forgetManagedAsset } from './managed-assets.service.js';
const assets = new AssetService();
/**
* Loescht eine Bilddatei, sofern sie von keinem Datensatz mehr benutzt wird.
* Muss NACH dem Loeschen bzw. Aktualisieren der Zeile aufgerufen werden.
* Loescht eine Bilddatei, sofern das CMS sie selbst angelegt hat und kein
* Datensatz sie mehr benutzt. Muss NACH dem Loeschen bzw. Aktualisieren der
* Zeile aufgerufen werden.
*/
export async function dropImageIfUnused(url: string | null | undefined): Promise<boolean> {
if (!url) return false;
if (!(await isManagedAsset(url))) return false;
const referenced = await collectReferencedImageUrls();
if (referenced.has(url)) return false;
return assets.deleteImage(url);
const deleted = assets.deleteImage(url);
await forgetManagedAsset(url);
return deleted;
}
/**
* Entfernt alle vom CMS angelegten Bilder, die nirgends mehr referenziert
* werden. Die Referenzliste deckt bewusst ALLE Zeilen ab, auch
* unveroeffentlichte - sonst verlieren die ihr Bild.
*/
export async function sweepOrphanedImages(): Promise<string[]> {
const referenced = await collectReferencedImageUrls();
const removed: string[] = [];
for (const url of assets.listImageFiles()) {
if (referenced.has(url)) continue;
if (!(await isManagedAsset(url))) continue;
if (assets.deleteImage(url)) removed.push(url);
await forgetManagedAsset(url);
}
return removed;
}
/**