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
131 lines
6.7 KiB
TypeScript
131 lines
6.7 KiB
TypeScript
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}`);
|
||
});
|