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 [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;}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/** 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 { 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';
|
||||
|
||||
@@ -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 { 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);
|
||||
|
||||
Reference in new issue
Block a user