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:
7 files changed
+460
-42
No files matched your search
@@ -10,6 +10,7 @@ export default function AdminApp() {
|
|||||||
const [toast, setToast] = useState<string | null>(null);
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState(''); const [nasPw, setNasPw] = useState('');
|
const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState(''); const [nasPw, setNasPw] = useState('');
|
||||||
const [storage, setStorage] = useState<any>(null);
|
const [storage, setStorage] = useState<any>(null);
|
||||||
|
const [rename, setRename] = useState<any>(null); // Probelauf/Ergebnis der Umbenennung
|
||||||
const [targets, setTargets] = useState<any[]>([]);
|
const [targets, setTargets] = useState<any[]>([]);
|
||||||
const [nt, setNt] = useState<any>({ protocol: 'sftp' });
|
const [nt, setNt] = useState<any>({ protocol: 'sftp' });
|
||||||
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); };
|
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'));
|
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();
|
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 () => {
|
const mirrorAll = async () => {
|
||||||
if (!s.nas_enabled) { notify('NAS erst aktivieren & speichern.'); return; }
|
if (!s.nas_enabled) { notify('NAS erst aktivieren & speichern.'); return; }
|
||||||
if (!confirm('Alle fertigen Bilder aufs NAS sichern?')) return;
|
if (!confirm('Alle fertigen Bilder aufs NAS sichern?')) return;
|
||||||
@@ -274,6 +305,38 @@ export default function AdminApp() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section className="karte">
|
||||||
<div className="kopfzeile"><span className="mono-label">Synology-NAS (zweite Sicherung)</span>
|
<div className="kopfzeile"><span className="mono-label">Synology-NAS (zweite Sicherung)</span>
|
||||||
<div className="reihe"><button className="mini" onClick={netcheck}>Erreichbarkeit</button>
|
<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 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);}
|
.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;}
|
.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;}
|
.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{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;}
|
.zeile b{font-size:14px;}
|
||||||
|
|||||||
@@ -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, …
|
||||||
|
}
|
||||||
@@ -82,42 +82,3 @@ export async function heicToPng(buffer: Buffer): Promise<Buffer> {
|
|||||||
return Buffer.from(out);
|
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
@@ -3,7 +3,9 @@ import { getObject, putObject, deleteObject, resultKey, thumbKey } from './stora
|
|||||||
import { generateImage } from './openrouter';
|
import { generateImage } from './openrouter';
|
||||||
import { buildPrompt, buildComposePrompt, buildGeneratePrompt, type Task } from './prompts';
|
import { buildPrompt, buildComposePrompt, buildGeneratePrompt, type Task } from './prompts';
|
||||||
import { resolveDimensions, aspectRatio, type Orientation } from './format';
|
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';
|
import sharp from 'sharp';
|
||||||
|
|
||||||
const DEFAULT_MODEL = 'google/gemini-3.1-flash-image';
|
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); }
|
} 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}`;
|
const outputPx = `${fin.width}x${fin.height}`;
|
||||||
// Private Aufträge: kein Delivery, kein Backup, kein gespeicherter Prompt.
|
// Private Aufträge: kein Delivery, kein Backup, kein gespeicherter Prompt.
|
||||||
const deliveryStatus = (!isPrivate && (r.delivery === 'picdrop' || r.delivery === 'both')) ? 'pending' : 'none';
|
const deliveryStatus = (!isPrivate && (r.delivery === 'picdrop' || r.delivery === 'both')) ? 'pending' : 'none';
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { storageStats, runRetention, purgeSources, mirrorAllToNas, rebuildThumbs } from '../../../lib/maintenance';
|
import { storageStats, runRetention, purgeSources, mirrorAllToNas, rebuildThumbs } from '../../../lib/maintenance';
|
||||||
|
import { renameLibrary, redeliverAll } from '../../../lib/renames';
|
||||||
import { one } from '../../../lib/db';
|
import { one } from '../../../lib/db';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
@@ -8,12 +9,16 @@ const json = (b: unknown, s = 200) =>
|
|||||||
|
|
||||||
export const GET: APIRoute = async () => json({ stats: await storageStats() });
|
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 }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
const b = await request.json().catch(() => ({}));
|
const b = await request.json().catch(() => ({}));
|
||||||
if (b.action === 'purge_sources') return json(await purgeSources());
|
if (b.action === 'purge_sources') return json(await purgeSources());
|
||||||
if (b.action === 'mirror_all') return json(await mirrorAllToNas());
|
if (b.action === 'mirror_all') return json(await mirrorAllToNas());
|
||||||
if (b.action === 'rebuild_thumbs') return json(await rebuildThumbs());
|
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') {
|
if (b.action === 'retention') {
|
||||||
const s = await one<{ retention_days: number | null }>('SELECT retention_days FROM settings WHERE id=1');
|
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);
|
if (!s?.retention_days) return json({ error: 'Keine Aufbewahrungsfrist gesetzt.' }, 400);
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user