From f695554e5d6318d93b2fccd1e5fa8d123fd4c034 Mon Sep 17 00:00:00 2001 From: Till Heidrich Date: Tue, 1 Sep 2026 02:15:29 +0000 Subject: [PATCH] fix: unique, chronologically sortable result filenames Two badges of the same person, same format, same day produced the same filename -- `2026-07-23_lea_the-frame.png` -- and the second upload silently overwrote the first at Picdrop. The name now carries the time down to the second and a short tag derived from the item id: 2026-08-20_143207_lea-badge_the-frame_a3f9.jpg The timestamp leads, so any file listing sorts chronologically on its own. The tag is derived, not random, so re-running the backfill is idempotent. Timestamps use Europe/Berlin (KLARBILD_TZ) rather than UTC -- a picture taken at 00:30 should not be dated to the previous day. Naming moved into its own dependency-free module so the rules are testable and identical everywhere. The generator strips its own stamp when a result is fed back in as a source, so names cannot accumulate. Backfill for the existing library in Admin -> Dateinamen: preview first, then apply. It re-stamps from `created_at` and the item id, keeping the motif and format already chosen, and leaves the stored files alone -- they live under their id, not their name. A separate button re-delivers everything under the new name; the old copies stay put at the destination, since deleting a customer's files automatically is the wrong reflex. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1 --- src/components/AdminApp.tsx | 68 +++++++++++++++ src/lib/naming.ts | 146 +++++++++++++++++++++++++++++++++ src/lib/pipeline.ts | 39 --------- src/lib/process.ts | 9 +- src/lib/renames.ts | 103 +++++++++++++++++++++++ src/pages/api/admin/storage.ts | 7 +- tests/naming.test.ts | 130 +++++++++++++++++++++++++++++ 7 files changed, 460 insertions(+), 42 deletions(-) create mode 100644 src/lib/naming.ts create mode 100644 src/lib/renames.ts create mode 100644 tests/naming.test.ts diff --git a/src/components/AdminApp.tsx b/src/components/AdminApp.tsx index a692bb8..4a1df68 100644 --- a/src/components/AdminApp.tsx +++ b/src/components/AdminApp.tsx @@ -10,6 +10,7 @@ export default function AdminApp() { const [toast, setToast] = useState(null); const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState(''); const [nasPw, setNasPw] = useState(''); const [storage, setStorage] = useState(null); + const [rename, setRename] = useState(null); // Probelauf/Ergebnis der Umbenennung const [targets, setTargets] = useState([]); const [nt, setNt] = useState({ protocol: 'sftp' }); const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); }; @@ -129,6 +130,36 @@ export default function AdminApp() { notify(r.deleted != null ? `${r.deleted} alte Positionen entfernt.` : r.purged != null ? `${r.purged} Quelldateien gelöscht.` : r.made != null ? `${r.made} Vorschaubilder erzeugt.` : (r.error || 'OK')); load(); }; + // Eindeutige Dateinamen — erst zeigen, was passieren würde, dann anwenden. + const renamePreview = async () => { + notify('Prüfe Dateinamen …'); + const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'rename_preview' }) }).then((x) => x.json()); + if (r.error) { notify(r.error); return; } + setRename(r); + notify(r.umbenannt + ? `${r.umbenannt} von ${r.geprueft} Bildern bekämen einen neuen Namen${r.vorherDoppelt ? ` — ${r.vorherDoppelt} Namen sind aktuell doppelt vergeben` : ''}.` + : `Alle ${r.geprueft} Namen sind bereits eindeutig.`); + }; + const renameApply = async () => { + if (!confirm('Alle Bilder der Bibliothek auf eindeutige Namen bringen?\n\nDie Dateien selbst bleiben unberührt — sie liegen unter ihrer ID im Speicher. ' + + 'Bereits bei Picdrop abgelegte Kopien behalten ihren alten Namen; dafür gibt es danach „Erneut ausliefern".')) return; + notify('Benenne um …'); + const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'rename_apply' }) }).then((x) => x.json()); + setRename(r); + notify(r.error || `${r.umbenannt} Bilder umbenannt, ${r.unveraendert} waren schon in Ordnung.`); + load(); + }; + const redeliverAll = async () => { + if (!confirm('Alle bereits ausgelieferten Bilder erneut hochladen — unter dem neuen Namen?\n\n' + + 'Die alten Dateien bleiben am Ziel liegen und müssen dort von Hand entfernt werden. Das kann dauern.')) return; + notify('Liefere erneut aus … (kann dauern)'); + const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'redeliver_all' }) }).then((x) => x.json()); + notify(r.versucht != null ? `${r.ok}/${r.versucht} erneut ausgeliefert${r.fehler ? `, ${r.fehler} fehlgeschlagen` : ''}.` : (r.error || 'Fehler')); + load(); + }; const mirrorAll = async () => { if (!s.nas_enabled) { notify('NAS erst aktivieren & speichern.'); return; } if (!confirm('Alle fertigen Bilder aufs NAS sichern?')) return; @@ -274,6 +305,38 @@ export default function AdminApp() { +
+
Dateinamen + JJJJ-MM-TT_HHMMSS_motiv_format_kürzel
+
+
Jedes Ergebnis bekommt einen Namen, der mit Datum und Uhrzeit beginnt — dadurch sortiert + jede Dateiliste von allein chronologisch — und mit einem kurzen Kürzel endet, das aus der Bild-ID stammt. + Damit können zwei gleichartige Bilder desselben Tages einander nicht mehr überschreiben. + Beispiel: 2026-08-20_143207_lea-badge_the-frame_a3f9.jpg
+
+ + + +
+ {rename && ( +
+ {rename.geprueft} Bilder geprüft · {rename.umbenannt} {rename.probelauf ? 'bekämen einen neuen Namen' : 'umbenannt'} · + {' '}{rename.unveraendert} unverändert{rename.vorherDoppelt ? ` · ${rename.vorherDoppelt} Namen waren doppelt vergeben` : ''} + {rename.beispiele?.length ? ( +
    + {rename.beispiele.map((b: any) => ( +
  • {b.alt} → {b.neu}
  • + ))} +
+ ) : null} +
+ )} +
„Erneut ausliefern" lädt alle schon ausgelieferten Bilder unter dem neuen Namen erneut + zum Ziel hoch. Die alten Dateien bleiben dort liegen — automatisch etwas beim Kunden zu löschen wäre der + falsche Reflex; das machst du bei Picdrop selbst.
+
+
+
Synology-NAS (zweite Sicherung)
@@ -464,6 +527,11 @@ function AdminStyles() { .zahlen div{background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:11px;} .zahlen b{display:block;font-size:22px;line-height:1;}.zahlen span{font-size:11.5px;color:var(--soft);} .karte>.fein{padding:0 16px 14px;} + /* Vorher/Nachher der Umbenennung — lange Namen dürfen die Karte nicht sprengen. */ + .namensliste{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:4px;max-height:220px;overflow:auto;} + .namensliste li{font-family:var(--font-mono);font-size:10.5px;line-height:1.5;overflow-wrap:anywhere;} + .namensliste s{color:var(--soft);} + .namensliste code{color:var(--ink);} .liste{padding:8px 16px 16px;display:flex;flex-direction:column;} .zeile{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:9px 0;border-bottom:1px solid var(--line);} .zeile b{font-size:14px;} diff --git a/src/lib/naming.ts b/src/lib/naming.ts new file mode 100644 index 0000000..9c965b7 --- /dev/null +++ b/src/lib/naming.ts @@ -0,0 +1,146 @@ +// Dateinamen für Klarbild-Ergebnisse — bewusst **ohne jede Abhängigkeit**, damit +// die Regeln testbar bleiben und überall dieselben sind (Auftragsverarbeitung, +// Nachbenennung, Tests). +// +// Schema: `JJJJ-MM-TT_HHMMSS___.` +// 2026-08-20_143207_lea-badge_the-frame_a3f9.jpg +// +// Warum: Der Zeitstempel steht vorn, also sortiert jede Dateiliste — Picdrop, +// Finder, NAS — von allein chronologisch. Eindeutig wird der Name durch die +// Sekunde **und** ein aus der Item-ID abgeleitetes Kürzel. Vorher hieß es nur +// `JJJJ-MM-TT__`, und zwei Badges desselben Motivs am selben Tag +// bekamen denselben Namen — beim Ausliefern überschrieb der zweite den ersten. + +/** Klein, mit Bindestrichen, ohne Umlaut-Probleme. */ +export function slugify(s: string): string { + return (s || '') + .toLowerCase() + .replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[c] || c)) + .normalize('NFKD').replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48); +} + +const GENERIC = new Set(['', 'foto', 'photo', 'bild', 'image', 'img', 'telegram', 'unbenannt', 'klarbild', 'screenshot', 'pxl', 'whatsapp']); + +/** Ein von uns selbst erzeugter Name — Zeitstempel vorn, Kürzel hinten. + * Wird gebraucht, um beim Weiterverarbeiten den eigenen Stempel wieder abzuziehen + * und um zu erkennen, ob ein Name schon im neuen Schema steht. */ +// Alle Bestandteile sind Slugs (a–z, 0–9, Bindestrich) und enthalten daher nie +// einen Unterstrich — das Muster zerlegt den Namen also eindeutig in fünf Teile. +export const KLARBILD_NAME = + /^(\d{4}-\d{2}-\d{2})_(\d{6})_([a-z0-9-]+)_([a-z0-9-]+)_([0-9a-z]{4})(?:-(\d+))?$/; + +/** Motiv aus einem beliebigen (evtl. schon verarbeiteten) Namen herausschälen: + * Datumspräfixe, UUIDs und lange Hex-Ketten entfernen, damit keine Doppelungen entstehen. */ +function cleanMotif(name: string | null): string { + let s = (name || '').replace(/\.[^.]+$/, ''); + // Zuerst den eigenen Stempel gezielt abziehen — nur wenn der Name wirklich unserem + // Muster entspricht. Pauschal alle Sechsergruppen zu entfernen wäre zu grob und + // würde echte Motive wie „raum104235" zerlegen. + const m = KLARBILD_NAME.exec(s); + if (m) s = m[3]; + // Ohne \b arbeiten — Wortgrenzen greifen an Unterstrichen nicht (…_2026-07-24…). + s = s.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ' '); // UUID + s = s.replace(/\d{4}-\d{2}-\d{2}/g, ' '); // Datum (auch mehrfach) + s = s.replace(/[0-9a-f]{16,}/gi, ' '); // lange Hex-Ketten + return slugify(s); +} + +/** Zeitzone für den Stempel im Dateinamen. Bewusst die Zeit, in der Till lebt — + * ein Bild von 0:30 Uhr soll nicht auf den Vortag datiert sein (UTC wäre so). */ +const TZ = process.env.KLARBILD_TZ || 'Europe/Berlin'; +const STAMP_FMT = new Intl.DateTimeFormat('en-CA', { + timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, +}); + +/** „2026-08-20_143207" — lexikografisch sortiert = chronologisch sortiert. */ +export function timeStamp(at: Date = new Date()): string { + const p: Record = {}; + for (const { type, value } of STAMP_FMT.formatToParts(at)) p[type] = value; + // Manche Node-Fassungen liefern „24" statt „00" für Mitternacht. + const hh = p.hour === '24' ? '00' : p.hour; + return `${p.year}-${p.month}-${p.day}_${hh}${p.minute}${p.second}`; +} + +/** Vierstelliges Kürzel, **stabil aus der Item-ID abgeleitet**. + * Stabil ist wichtig: Ein zweiter Lauf der Umbenennung muss denselben Namen + * ergeben, sonst wandern die Dateien bei jedem Lauf erneut. */ +export function shortTag(id?: string | null): string { + const hex = String(id || '').replace(/[^0-9a-f]/gi, '').toLowerCase(); + if (hex.length >= 4) return hex.slice(0, 4); + return Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0'); +} + +export interface NameParts { + /** Ursprünglicher Dateiname, aus dem das Motiv geschält wird. */ + originalName?: string | null; + formatToken?: string; + ext?: string; + /** Zeitpunkt für den Stempel — bei der Nachbenennung das `created_at` des Bildes. */ + at?: Date; + /** Item-ID für das stabile Kürzel. */ + id?: string | null; + /** Zähler, falls der Name trotz allem schon vergeben ist (2, 3, …). */ + seq?: number; +} + +/** + * Eindeutiger, chronologisch sortierbarer Dateiname: + * `JJJJ-MM-TT_HHMMSS___.` + * z. B. `2026-08-20_143207_lea_the-frame_a3f9.png` + * + * Warum so: Der Name beginnt mit dem Zeitstempel, also sortiert jede Dateiliste + * (Picdrop, Finder, NAS) von allein chronologisch. Eindeutig wird er durch die + * Sekunde **und** das aus der Item-ID abgeleitete Kürzel — zwei Bilder desselben + * Motivs im selben Format am selben Tag überschreiben sich damit nicht mehr. + */ +export function buildResultFilename( + originalName: string | null, formatToken: string, ext = 'png', opts: Omit = {}, +): string { + const raw = cleanMotif(originalName); + const motif = (!raw || GENERIC.has(raw)) ? 'klarbild' : raw; + const fmt = slugify(formatToken) || 'original'; + const seq = opts.seq && opts.seq > 1 ? `-${opts.seq}` : ''; + return `${timeStamp(opts.at)}_${motif}_${fmt}_${shortTag(opts.id)}${seq}.${ext}`; +} + +export interface OldName { motif: string; format: string; ext: string } + +/** + * Einen vorhandenen Namen in Motiv, Format und Endung zerlegen — für die + * Nachbenennung, damit die schon gewählten Bezeichnungen erhalten bleiben und + * nur der Zeitstempel plus Kürzel neu gesetzt werden. + */ +export function splitOldName(filename: string | null): OldName { + const raw = String(filename || ''); + const dot = raw.lastIndexOf('.'); + const ext = dot > 0 ? raw.slice(dot + 1).toLowerCase() : 'png'; + const stem = dot > 0 ? raw.slice(0, dot) : raw; + + // Schon im neuen Schema: Motiv und Format unverändert übernehmen. + const neu = KLARBILD_NAME.exec(stem); + if (neu) return { motif: neu[3], format: neu[4], ext }; + + const parts = stem.split('_').filter(Boolean); + if (/^\d{4}-\d{2}-\d{2}$/.test(parts[0] || '')) parts.shift(); // altes Datumspräfix + const format = parts.length > 1 ? slugify(parts.pop()!) : 'original'; + const motif = slugify(parts.join('-')) || 'klarbild'; + return { motif: motif || 'klarbild', format: format || 'original', ext }; +} + +/** Der Name, den ein bestehendes Bild nach dem neuen Schema tragen müsste. */ +export function newNameFor(it: { id: string; filename: string | null; created_at: Date | string }, seq = 1): string { + const { motif, format, ext } = splitOldName(it.filename); + const at = it.created_at instanceof Date ? it.created_at : new Date(it.created_at); + const s = seq > 1 ? `-${seq}` : ''; + return `${timeStamp(at)}_${motif}_${format}_${shortTag(it.id)}${s}.${ext}`; +} + +/** Formatschlüssel für den Dateinamen (aus dem output_format). */ +export function formatToken(outputFormat: string | undefined): string { + if (!outputFormat || outputFormat === 'keep') return 'original'; + if (outputFormat === 'theframe') return 'the-frame'; + if (outputFormat === 'hochformat') return 'hochformat'; + return outputFormat.toLowerCase(); // 30x40, a4, sticker, … +} diff --git a/src/lib/pipeline.ts b/src/lib/pipeline.ts index f9b8ad7..c69a776 100644 --- a/src/lib/pipeline.ts +++ b/src/lib/pipeline.ts @@ -82,42 +82,3 @@ export async function heicToPng(buffer: Buffer): Promise { return Buffer.from(out); } -/** Klein, mit Bindestrichen, ohne Umlaut-Probleme. */ -export function slugify(s: string): string { - return (s || '') - .toLowerCase() - .replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[c] || c)) - .normalize('NFKD').replace(/[̀-ͯ]/g, '') - .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48); -} - -const GENERIC = new Set(['', 'foto', 'photo', 'bild', 'image', 'img', 'telegram', 'unbenannt', 'klarbild', 'screenshot', 'pxl', 'whatsapp']); - -/** Motiv aus einem beliebigen (evtl. schon verarbeiteten) Namen herausschälen: - * Datumspräfixe, UUIDs und lange Hex-Ketten entfernen, damit keine Doppelungen entstehen. */ -function cleanMotif(name: string | null): string { - let s = (name || '').replace(/\.[^.]+$/, ''); - // Ohne \b arbeiten — Wortgrenzen greifen an Unterstrichen nicht (…_2026-07-24…). - s = s.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ' '); // UUID - s = s.replace(/\d{4}-\d{2}-\d{2}/g, ' '); // Datum (auch mehrfach) - s = s.replace(/[0-9a-f]{16,}/gi, ' '); // lange Hex-Ketten - return slugify(s); -} - -/** Logischer Dateiname: JJJJ-MM-TT__. - * z. B. 2026-07-23_lea_the-frame.png */ -export function buildResultFilename(originalName: string | null, formatToken: string, ext = 'png'): string { - const raw = cleanMotif(originalName); - const motif = (!raw || GENERIC.has(raw)) ? 'klarbild' : raw; - const date = new Date().toISOString().slice(0, 10); - const fmt = slugify(formatToken) || 'original'; - return `${date}_${motif}_${fmt}.${ext}`; -} - -/** Formatschlüssel für den Dateinamen (aus dem output_format). */ -export function formatToken(outputFormat: string | undefined): string { - if (!outputFormat || outputFormat === 'keep') return 'original'; - if (outputFormat === 'theframe') return 'the-frame'; - if (outputFormat === 'hochformat') return 'hochformat'; - return outputFormat.toLowerCase(); // 30x40, a4, sticker, … -} diff --git a/src/lib/process.ts b/src/lib/process.ts index e795a05..5ddeb12 100644 --- a/src/lib/process.ts +++ b/src/lib/process.ts @@ -3,7 +3,9 @@ import { getObject, putObject, deleteObject, resultKey, thumbKey } from './stora import { generateImage } from './openrouter'; import { buildPrompt, buildComposePrompt, buildGeneratePrompt, type Task } from './prompts'; import { resolveDimensions, aspectRatio, type Orientation } from './format'; -import { finalizeToFormat, stickerContour, buildResultFilename, formatToken, type CropMode } from './pipeline'; +import { finalizeToFormat, stickerContour, type CropMode } from './pipeline'; +import { formatToken } from './naming'; +import { uniqueFilename } from './renames'; import sharp from 'sharp'; const DEFAULT_MODEL = 'google/gemini-3.1-flash-image'; @@ -174,7 +176,10 @@ export async function processItem(itemId: string): Promise { } catch (e) { console.error('[process] Thumbnail fehlgeschlagen', e); } } - const filename = buildResultFilename(item.filename, formatToken(r.output_format), ext); + // Eindeutig je Bild: Sekunde + Item-Kürzel im Namen, plus Gegenprobe in der + // Datenbank. Vorher konnten zwei gleichartige Bilder desselben Tages denselben + // Namen bekommen und sich bei Picdrop gegenseitig überschreiben. + const filename = await uniqueFilename(itemId, item.filename, formatToken(r.output_format), ext); const outputPx = `${fin.width}x${fin.height}`; // Private Aufträge: kein Delivery, kein Backup, kein gespeicherter Prompt. const deliveryStatus = (!isPrivate && (r.delivery === 'picdrop' || r.delivery === 'both')) ? 'pending' : 'none'; diff --git a/src/lib/renames.ts b/src/lib/renames.ts new file mode 100644 index 0000000..b4f0787 --- /dev/null +++ b/src/lib/renames.ts @@ -0,0 +1,103 @@ +// Eindeutige Dateinamen — Vergabe für neue Bilder und Nachbenennung der Bibliothek. +// +// Hintergrund: Bis 20.08.2026 hieß ein Ergebnis `JJJJ-MM-TT__.`. +// Wer am selben Tag zweimal dasselbe Motiv im selben Format erzeugte — etwa zwei +// Badges von Lea —, bekam zweimal denselben Namen. Bei Picdrop überschrieb das +// zweite Bild das erste. Seitdem steckt zusätzlich die Uhrzeit auf die Sekunde und +// ein aus der Item-ID abgeleitetes Kürzel im Namen. +import { query, one } from './db.ts'; +import { buildResultFilename, newNameFor } from './naming.ts'; + +/** Ist dieser Name schon an ein *anderes* Bild vergeben? */ +async function taken(name: string, exceptId: string): Promise { + const row = await one<{ id: string }>( + 'SELECT id FROM items WHERE filename = $1 AND id <> $2 LIMIT 1', [name, exceptId]); + return !!row; +} + +/** + * Der Name ist durch Sekunde + Item-Kürzel praktisch schon eindeutig. Diese + * Schleife ist der Gürtel zum Hosenträger: Sie hängt `-2`, `-3` … an, falls der + * Name wider Erwarten doch vergeben ist. Bewusst als Code-Schleife und **nicht** + * als UNIQUE-Bedingung in der Datenbank — ein abgelehnter Schreibvorgang würde + * einen bereits bezahlten KI-Auftrag im letzten Schritt scheitern lassen. + */ +export async function uniqueFilename( + itemId: string, originalName: string | null, formatToken: string, ext: string, at?: Date, + /** Nur für Tests: sonst wird die Bibliothek befragt. */ + istVergeben: (name: string) => Promise = (n) => taken(n, itemId), +): Promise { + for (let seq = 1; seq <= 50; seq++) { + const name = buildResultFilename(originalName, formatToken, ext, { at, id: itemId, seq }); + if (!(await istVergeben(name))) return name; + } + // Unerreichbar, solange Item-IDs eindeutig sind — aber lieber ein hässlicher + // Name als ein überschriebenes Bild. + return buildResultFilename(originalName, formatToken, ext, { at, id: `${itemId}${Date.now()}` }); +} + +export interface RenameRow { id: string; alt: string; neu: string } +export interface RenameResult { + geprueft: number; + umbenannt: number; + unveraendert: number; + /** Namen, die vorher doppelt vergeben waren — genau der gemeldete Fehler. */ + vorherDoppelt: number; + probelauf: boolean; + beispiele: RenameRow[]; +} + +/** + * Alle Bibliotheksbilder auf das neue Namensschema bringen. + * Idempotent: Zeitstempel kommt aus `created_at`, Kürzel aus der Item-ID — ein + * zweiter Lauf ändert nichts mehr. Die Dateien selbst bleiben unberührt, sie + * liegen im Speicher unter der Item-ID, nicht unter dem Namen. + */ +export async function renameLibrary(probelauf = true): Promise { + const items = await query<{ id: string; filename: string | null; created_at: Date }>( + `SELECT id, filename, created_at FROM items + WHERE status='done' AND filename IS NOT NULL ORDER BY created_at`); + + // Wie viele Namen waren vorher mehrfach vergeben? + const zaehler = new Map(); + for (const it of items) zaehler.set(it.filename!, (zaehler.get(it.filename!) || 0) + 1); + const vorherDoppelt = [...zaehler.values()].filter((n) => n > 1).reduce((a, n) => a + n - 1, 0); + + const vergeben = new Set(); + const rows: RenameRow[] = []; + let umbenannt = 0, unveraendert = 0; + + for (const it of items) { + let neu = newNameFor(it); + for (let seq = 2; vergeben.has(neu) && seq <= 50; seq++) neu = newNameFor(it, seq); + vergeben.add(neu); + if (neu === it.filename) { unveraendert++; continue; } + umbenannt++; + if (rows.length < 25) rows.push({ id: it.id, alt: it.filename!, neu }); + if (!probelauf) await query('UPDATE items SET filename=$2 WHERE id=$1', [it.id, neu]); + } + + return { geprueft: items.length, umbenannt, unveraendert, vorherDoppelt, probelauf, beispiele: rows }; +} + +/** + * Alle schon ausgelieferten Bilder erneut ausliefern — unter dem neuen Namen. + * Nötig, weil die Umbenennung nur die Bibliothek betrifft: Was bereits bei + * Picdrop liegt, heißt dort weiterhin alt. Die alten Dateien bleiben stehen und + * müssen bei Bedarf von Hand entfernt werden; automatisch etwas beim Kunden zu + * löschen wäre der falsche Reflex. + */ +export async function redeliverAll(): Promise<{ versucht: number; ok: number; fehler: number }> { + const items = await query<{ id: string }>( + `SELECT i.id FROM items i JOIN jobs j ON j.id = i.job_id + WHERE i.status='done' AND i.result_path IS NOT NULL + AND i.delivery_status IN ('delivered','failed') AND j.private = false + ORDER BY i.created_at`); + const { deliverItem } = await import('./delivery.ts'); + let ok = 0, fehler = 0; + for (const it of items) { + const r = await deliverItem(it.id).catch(() => ({ ok: false })); + r.ok ? ok++ : fehler++; + } + return { versucht: items.length, ok, fehler }; +} diff --git a/src/pages/api/admin/storage.ts b/src/pages/api/admin/storage.ts index 8684d2b..c645da2 100644 --- a/src/pages/api/admin/storage.ts +++ b/src/pages/api/admin/storage.ts @@ -1,5 +1,6 @@ import type { APIRoute } from 'astro'; import { storageStats, runRetention, purgeSources, mirrorAllToNas, rebuildThumbs } from '../../../lib/maintenance'; +import { renameLibrary, redeliverAll } from '../../../lib/renames'; import { one } from '../../../lib/db'; export const prerender = false; @@ -8,12 +9,16 @@ const json = (b: unknown, s = 200) => export const GET: APIRoute = async () => json({ stats: await storageStats() }); -// POST { action: 'retention' | 'purge_sources' } +// POST { action: 'retention' | 'purge_sources' | 'rename_preview' | 'rename_apply' | 'redeliver_all' | … } export const POST: APIRoute = async ({ request }) => { const b = await request.json().catch(() => ({})); if (b.action === 'purge_sources') return json(await purgeSources()); if (b.action === 'mirror_all') return json(await mirrorAllToNas()); if (b.action === 'rebuild_thumbs') return json(await rebuildThumbs()); + // Eindeutige Dateinamen: erst zeigen, was passieren würde, dann anwenden. + if (b.action === 'rename_preview') return json(await renameLibrary(true)); + if (b.action === 'rename_apply') return json(await renameLibrary(false)); + if (b.action === 'redeliver_all') return json(await redeliverAll()); if (b.action === 'retention') { const s = await one<{ retention_days: number | null }>('SELECT retention_days FROM settings WHERE id=1'); if (!s?.retention_days) return json({ error: 'Keine Aufbewahrungsfrist gesetzt.' }, 400); diff --git a/tests/naming.test.ts b/tests/naming.test.ts new file mode 100644 index 0000000..2ed7037 --- /dev/null +++ b/tests/naming.test.ts @@ -0,0 +1,130 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildResultFilename, timeStamp, shortTag, slugify, KLARBILD_NAME, splitOldName, newNameFor } from '../src/lib/naming.ts'; + +const AT = new Date('2026-08-20T12:32:07Z'); // = 14:32:07 in Berlin (Sommerzeit) +const ID1 = 'a3f9c1de-1111-2222-3333-444455556666'; +const ID2 = '77b21e00-1111-2222-3333-444455556666'; + +test('Der gemeldete Fehler: zwei Badges am selben Tag überschreiben sich nicht mehr', () => { + // Gleiches Motiv, gleiches Format, gleiche Sekunde — früher zweimal derselbe Name. + const a = buildResultFilename('Lea Badge.png', 'the-frame', 'jpg', { at: AT, id: ID1 }); + const b = buildResultFilename('Lea Badge.png', 'the-frame', 'jpg', { at: AT, id: ID2 }); + assert.notEqual(a, b, 'zwei Bilder dürfen nie denselben Namen tragen'); + assert.equal(a, '2026-08-20_143207_lea-badge_the-frame_a3f9.jpg'); + assert.equal(b, '2026-08-20_143207_lea-badge_the-frame_77b2.jpg'); +}); + +test('Namen sortieren lexikografisch = chronologisch', () => { + const zeiten = [ + new Date('2026-08-19T23:59:59Z'), + new Date('2026-08-20T00:00:01Z'), + new Date('2026-08-20T12:32:07Z'), + new Date('2026-12-24T08:00:00Z'), // Winterzeit — anderer UTC-Versatz + new Date('2027-01-01T00:00:00Z'), + ]; + const namen = zeiten.map((at, i) => buildResultFilename('foto', '30x40', 'jpg', { at, id: `${i}${i}${i}${i}0000-0000-0000-0000-000000000000` })); + const sortiert = [...namen].sort(); + assert.deepEqual(sortiert, namen, `Sortierung weicht ab:\n${sortiert.join('\n')}`); +}); + +test('Zeitstempel steht in Berliner Zeit, nicht in UTC', () => { + // 00:30 Uhr Berliner Sommerzeit ist 22:30 UTC am Vortag — der Name muss den + // Tag zeigen, an dem Till das Bild gemacht hat. + assert.equal(timeStamp(new Date('2026-08-19T22:30:00Z')), '2026-08-20_003000'); + assert.equal(timeStamp(new Date('2026-12-24T07:05:09Z')), '2026-12-24_080509'); // Winterzeit: +1 +}); + +test('Mitternacht wird als 00 geschrieben, nicht als 24', () => { + assert.match(timeStamp(new Date('2026-08-19T22:00:00Z')), /^2026-08-20_00000/); +}); + +test('Kürzel ist stabil — ein zweiter Lauf ergibt denselben Namen', () => { + assert.equal(shortTag(ID1), shortTag(ID1)); + assert.equal(shortTag(ID1), 'a3f9'); + const it = { id: ID1, filename: '2026-07-23_lea_the-frame.png', created_at: AT }; + assert.equal(newNameFor(it), newNameFor(it)); +}); + +test('Erneutes Verarbeiten stapelt keine Stempel', () => { + const einmal = buildResultFilename('Lea Badge.png', 'the-frame', 'jpg', { at: AT, id: ID1 }); + const zweimal = buildResultFilename(einmal, '30x40', 'jpg', { at: AT, id: ID2 }); + assert.equal(zweimal, '2026-08-20_143207_lea-badge_30x40_77b2.jpg'); + const dreimal = buildResultFilename(zweimal, 'a4', 'jpg', { at: AT, id: ID1 }); + assert.equal(dreimal, '2026-08-20_143207_lea-badge_a4_a3f9.jpg'); +}); + +test('Das eigene Muster erkennt eigene Namen und nur die', () => { + assert.ok(KLARBILD_NAME.test('2026-08-20_143207_lea-badge_the-frame_a3f9')); + assert.ok(KLARBILD_NAME.test('2026-08-20_143207_lea_30x40_a3f9-2')); + assert.ok(!KLARBILD_NAME.test('2026-07-23_lea_the-frame')); // altes Schema + assert.ok(!KLARBILD_NAME.test('urlaub-2026_strand')); // fremder Name +}); + +test('Alte Namen werden korrekt zerlegt', () => { + assert.deepEqual(splitOldName('2026-07-23_lea_the-frame.png'), { motif: 'lea', format: 'the-frame', ext: 'png' }); + assert.deepEqual(splitOldName('2026-07-23_klarbild_30x40.jpg'), { motif: 'klarbild', format: '30x40', ext: 'jpg' }); + // Fremde Datei ohne unser Schema: alles wird zum Motiv, Format „original". + assert.deepEqual(splitOldName('Urlaub Kreta.JPEG'), { motif: 'urlaub-kreta', format: 'original', ext: 'jpeg' }); + // Schon neues Schema: Motiv und Format bleiben, damit die Nachbenennung idempotent ist. + assert.deepEqual(splitOldName('2026-08-20_143207_lea-badge_the-frame_a3f9.jpg'), + { motif: 'lea-badge', format: 'the-frame', ext: 'jpg' }); +}); + +test('Nachbenennung ist idempotent — zweiter Lauf ändert nichts', () => { + const alt = { id: ID1, filename: '2026-07-23_lea_the-frame.png', created_at: new Date('2026-07-23T09:15:42Z') }; + const neu = newNameFor(alt); + assert.equal(neu, '2026-07-23_111542_lea_the-frame_a3f9.png'); + assert.equal(newNameFor({ ...alt, filename: neu }), neu, 'zweiter Lauf muss denselben Namen liefern'); +}); + +test('Nachbenennung nimmt das Erstelldatum, nicht das Datum aus dem alten Namen', () => { + // Falls ein Name mal ein falsches Datum trug, gewinnt created_at aus der Datenbank. + const it = { id: ID2, filename: '2020-01-01_lea_a4.png', created_at: new Date('2026-03-05T06:00:00Z') }; + assert.match(newNameFor(it), /^2026-03-05_070000_lea_a4_77b2\.png$/); +}); + +test('Zähler hängt sich an, wenn ein Name wirklich vergeben ist', () => { + const a = buildResultFilename('lea', 'a4', 'png', { at: AT, id: ID1 }); + const b = buildResultFilename('lea', 'a4', 'png', { at: AT, id: ID1, seq: 2 }); + assert.equal(b, a.replace('.png', '-2.png')); + assert.ok(KLARBILD_NAME.test(b.replace('.png', ''))); +}); + +test('Motive bleiben lesbar und dateisystemtauglich', () => { + const n = buildResultFilename('Größe & Maß – Übersicht!.png', '13x18', 'jpg', { at: AT, id: ID1 }); + assert.equal(n, '2026-08-20_143207_groesse-mass-uebersicht_13x18_a3f9.jpg'); + assert.ok(!/[^a-z0-9._-]/.test(n), `unerwartete Zeichen in ${n}`); +}); + +test('Generische Namen werden zu „klarbild", statt Unsinn zu erben', () => { + for (const n of ['IMG.png', 'foto.jpg', 'telegram.jpg', null]) { + const out = buildResultFilename(n as any, 'a4', 'png', { at: AT, id: ID1 }); + assert.match(out, /_klarbild_a4_a3f9\.png$/, `${n} → ${out}`); + } +}); + +test('slugify bleibt unverändert nutzbar', () => { + assert.equal(slugify('Grüße aus Köln'), 'gruesse-aus-koeln'); +}); + +// --- Kollisionsschleife (der Gürtel zum Hosenträger) -------------------------- +// Die Schleife wird mit einer eingesetzten „ist vergeben"-Prüfung getestet, damit +// dafür keine Datenbank laufen muss. +import { uniqueFilename } from '../src/lib/renames.ts'; + +test('Ist der Wunschname wider Erwarten vergeben, hängt sich ein Zähler an', async () => { + const belegt = new Set(['2026-08-20_143207_lea_a4_a3f9.png']); + const n = await uniqueFilename(ID1, 'lea', 'a4', 'png', AT, async (x) => belegt.has(x)); + assert.equal(n, '2026-08-20_143207_lea_a4_a3f9-2.png'); +}); + +test('Freier Name wird unverändert vergeben', async () => { + const n = await uniqueFilename(ID1, 'lea', 'a4', 'png', AT, async () => false); + assert.equal(n, '2026-08-20_143207_lea_a4_a3f9.png'); +}); + +test('Die Schleife gibt auch bei dauerhaft belegten Namen etwas zurück', async () => { + const n = await uniqueFilename(ID1, 'lea', 'a4', 'png', AT, async () => true); + assert.ok(n.endsWith('.png') && n.length > 10, `unbrauchbarer Rückfall: ${n}`); +});