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

+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}`);
});