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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
This commit is contained in:
tillandClaude Opus 5 committed 2026-09-01 02:15:29 +00:00
1 parent 5ebe8026e0
commit f695554e5d
7 files changed
+460 -42

No files matched your search

+68
View File
@@ -10,6 +10,7 @@ export default function AdminApp() {
const [toast, setToast] = useState<string | null>(null);
const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState(''); const [nasPw, setNasPw] = useState('');
const [storage, setStorage] = useState<any>(null);
const [rename, setRename] = useState<any>(null); // Probelauf/Ergebnis der Umbenennung
const [targets, setTargets] = useState<any[]>([]);
const [nt, setNt] = useState<any>({ 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() {
</div>
</section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Dateinamen</span>
<span className="fein">JJJJ-MM-TT_HHMMSS_motiv_format_kürzel</span></div>
<div className="steuer">
<div className="fein">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 <b>nicht mehr überschreiben</b>.
Beispiel: <code>2026-08-20_143207_lea-badge_the-frame_a3f9.jpg</code></div>
<div className="reihe">
<button className="mini" onClick={renamePreview}>Bibliothek prüfen</button>
<button className="mini" onClick={renameApply}>Jetzt umbenennen</button>
<button className="mini" onClick={redeliverAll}>Erneut ausliefern</button>
</div>
{rename && (
<div className="fein">
<b>{rename.geprueft} Bilder geprüft</b> · {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 ? (
<ul className="namensliste">
{rename.beispiele.map((b: any) => (
<li key={b.id}><s>{b.alt}</s> → <code>{b.neu}</code></li>
))}
</ul>
) : null}
</div>
)}
<div className="fein">„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.</div>
</div>
</section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Synology-NAS (zweite Sicherung)</span>
<div className="reihe"><button className="mini" onClick={netcheck}>Erreichbarkeit</button>
@@ -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;}
+146
View File
@@ -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_<motiv>_<format>_<kürzel>.<ext>`
// 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_<motiv>_<format>`, 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<string, string> = {};
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_<motiv>_<format>_<kürzel>.<ext>`
* 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<NameParts, 'originalName' | 'formatToken' | 'ext'> = {},
): 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, …
}
-39
View File
@@ -82,42 +82,3 @@ export async function heicToPng(buffer: Buffer): Promise<Buffer> {
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_<motiv>_<format>.<ext>
* 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, …
}
+7 -2
View File
@@ -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<ProcessResult> {
} 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';
+103
View File
@@ -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_<motiv>_<format>.<ext>`.
// 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<boolean> {
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<boolean> = (n) => taken(n, itemId),
): Promise<string> {
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<RenameResult> {
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<string, number>();
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<string>();
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 };
}
+6 -1
View File
@@ -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);
+130
View File
@@ -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}`);
});