diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e74b14..bced11c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to Klarbild are documented here. Newest first. +## 2026-09-01 (later) — Unique names for print files + +### Fixed +- **Two print PDFs from the same day had the same name** — `2026-09-01_druckbogen.pdf`, + every time. The browser appended "(1)"; anyone filing them in the same folder or on the + NAS lost the first one. Exactly the defect fixed for library images in August, still + sitting in the print module. Print files now follow the same rule as everything else: + `2026-09-01_143207_druck-9-teile_a4_4eca.pdf` — date, time to the second, what is on the + sheet, the paper, and a short tag. **The tag is the id of the history entry**, so a PDF + can be traced back to its run under "Last printed". +- Single images carry the motif of their source: `2026-09-01_143207_lea-badge_130x180_25b3.jpg` + instead of `2026-09-01_1318.jpg`. +- The browser no longer invents its own name. The server sends one and the client reads it + from `Content-Disposition` — there were two truths, and the client's was the worse one. + Names read from that header are stripped of anything path-like before use. + ## 2026-09-01 (later) — Printing from the library, and a print history ### Added diff --git a/src/components/PrintApp.tsx b/src/components/PrintApp.tsx index fbf0500..a38907a 100644 --- a/src/components/PrintApp.tsx +++ b/src/components/PrintApp.tsx @@ -7,6 +7,7 @@ import { import { fitsOnSheet, overflowPlacement, tilePlan, tileSheet, type Oversize } from '../lib/printoversize'; import { baseCrop, clampCrop, centred, limitsFor, type CropRel, type FitMode } from '../lib/cropmath'; import PrintRuns from './PrintRuns'; +import { nameAusKopf } from '../lib/naming'; /* -------------------------------------------------------------------------- Drucken — Bilder ohne KI auf exakte Maße bringen und mehrere davon mit @@ -382,7 +383,10 @@ export default function PrintApp() { }); if (!res.ok) { notify((await res.json().catch(() => ({}))).error || 'Fehlgeschlagen.', true); return; } const blob = await res.blob(); - download(blob, `klarbild-druck-${new Date().toISOString().slice(0, 10)}.pdf`); + // Den Namen schickt der Server mit — er trägt Zeitstempel und das Kürzel + // des Verlaufseintrags. Hier einen eigenen zu bauen hieße, zwei Bogen + // desselben Tages wieder gleich zu nennen. + download(blob, nameAusKopf(res.headers.get('Content-Disposition'), 'klarbild-druck.pdf')); const n = Number(res.headers.get('X-Klarbild-Pages') || 0); const hinweis = res.headers.get('X-Klarbild-Notes'); const liefer = res.headers.get('X-Klarbild-Delivery-Msg'); @@ -401,7 +405,7 @@ export default function PrintApp() { body: JSON.stringify({ src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, dpi, ext, name: c.name, fit: c.fit, bg: c.bg }), }); if (!res.ok) { notify((await res.json().catch(() => ({}))).error || 'Fehlgeschlagen.', true); return; } - download(await res.blob(), `${(c.name || 'bild').replace(/\.[^.]+$/, '')}_${labelMm(s.w, s.h).replace(/[^0-9]+/g, 'x')}.${ext}`); + download(await res.blob(), nameAusKopf(res.headers.get('Content-Disposition'), `bild.${ext}`)); const real = res.headers.get('X-Klarbild-Real-Dpi'); notify(res.headers.get('X-Klarbild-Dpi-Ok') === '0' ? `Heruntergeladen — Achtung: die Quelle reicht nur für ca. ${real} dpi.` diff --git a/src/components/PrintRuns.tsx b/src/components/PrintRuns.tsx index 121f1e6..d25da61 100644 --- a/src/components/PrintRuns.tsx +++ b/src/components/PrintRuns.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; +import { nameAusKopf } from '../lib/naming'; /** * Die letzten Drucke. @@ -74,7 +75,9 @@ export default function PrintRuns({ onLaden }: { onLaden?: (config: any) => void const blob = await res.blob(); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); - a.download = `klarbild-druck-${new Date().toISOString().slice(0, 10)}.pdf`; + // Der Wiederholungsdruck ist eine **neue** Datei mit neuem Zeitstempel und + // neuem Kürzel — er überschreibt den ersten Bogen also nicht. + a.download = nameAusKopf(res.headers.get('Content-Disposition'), 'klarbild-druck.pdf'); document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); sagen('PDF neu erzeugt. Beim Drucken „Tatsächliche Größe / 100 %" wählen.'); diff --git a/src/lib/naming.ts b/src/lib/naming.ts index 9c965b7..35d2012 100644 --- a/src/lib/naming.ts +++ b/src/lib/naming.ts @@ -144,3 +144,89 @@ export function formatToken(outputFormat: string | undefined): string { if (outputFormat === 'hochformat') return 'hochformat'; return outputFormat.toLowerCase(); // 30x40, a4, sticker, … } + +/* --------------------------------------------------------------------------- + * Namen für Druckdateien + * + * Dieselbe Regel wie für Bibliotheksergebnisse, aus demselben Grund: Zwei + * Druckbogen am selben Tag hießen beide `2026-09-01_druckbogen.pdf` — der + * Browser hängte „(1)" an, und wer die Datei ins NAS oder in einen Ordner + * legte, überschrieb den ersten Bogen. Genau der Fehler, der im August für + * die Bilder behoben wurde. + * ------------------------------------------------------------------------ */ + +/** Kurzzeichen fürs Papier: „A4" → `a4`, sonst das Maß in mm (`210x297`). */ +export function paperToken(paperId: string | null | undefined, wMm: number, hMm: number): string { + const s = slugify(String(paperId || '')); + if (s && s.length <= 12) return s; + return `${Math.round(wMm)}x${Math.round(hMm)}`; +} + +/** + * Name für einen Druckbogen: + * `2026-09-01_143207_druck-9-teile_a4_4eca.pdf` + * + * Das Kürzel stammt aus der Kennung des Verlaufseintrags — die Datei lässt + * sich also einem Eintrag unter „Zuletzt gedruckt" zuordnen. + */ +export function buildSheetFilename(opts: { + /** Wunschname des Aufrufers, sonst wird aus Teilen und Bogen einer gebaut. */ + name?: string | null; + paperId?: string | null; + wMm: number; hMm: number; + pieces: number; pages: number; + runId?: string | null; + at?: Date; +}): string { + const teile = opts.pieces === 1 ? 'druck-1-teil' : `druck-${opts.pieces}-teile`; + const bogen = opts.pages > 1 ? `-${opts.pages}-bogen` : ''; + const motiv = slugify(String(opts.name || '')) || `${teile}${bogen}`; + return buildResultFilename(motiv, paperToken(opts.paperId, opts.wMm, opts.hMm), 'pdf', + { id: opts.runId, at: opts.at }); +} + +/** + * Name für ein einzeln aufs Maß gebrachtes Bild: + * `2026-09-01_143207_lea-badge_13x18_25b3.jpg` + * Das Motiv kommt aus der Quelldatei, das Kürzel aus der Bild-Kennung. + */ +export function buildSingleFilename(opts: { + name?: string | null; + sourceName?: string | null; + wMm: number; hMm: number; + ext: string; + id?: string | null; + at?: Date; +}): string { + const motiv = String(opts.name || '') || String(opts.sourceName || ''); + const mass = `${trimZahl(opts.wMm)}x${trimZahl(opts.hMm)}`; + return buildResultFilename(motiv, mass, opts.ext, { id: opts.id, at: opts.at }); +} + +/** 45 → „45", 112.5 → „112-5" (im Dateinamen ist ein Punkt die Endung). */ +function trimZahl(n: number): string { + return String(Math.round(n * 10) / 10).replace('.', '-'); +} + +/** + * Dateinamen aus einem `Content-Disposition`-Kopf lesen. + * + * Der Browser bekommt den Namen vom Server — der Client soll ihn **nicht neu + * erfinden**, sonst gibt es zwei Wahrheiten. Genau das war der Fall: Der Server + * schickte einen Namen, und die Oberfläche überschrieb ihn mit einem eigenen, + * der nur das Datum enthielt. + */ +export function nameAusKopf(header: string | null | undefined, fallback: string): string { + const h = String(header || ''); + // RFC 5987 (filename*=UTF-8''…) hat Vorrang vor dem einfachen filename=. + const stern = /filename\*=UTF-8''([^;]+)/i.exec(h); + if (stern) { try { return sicher(decodeURIComponent(stern[1])) || fallback; } catch { /* weiter unten */ } } + const einfach = /filename="?([^";]+)"?/i.exec(h); + if (einfach) return sicher(einfach[1]) || fallback; + return fallback; +} + +/** Ein aus einem Kopf gelesener Name darf niemals einen Pfad enthalten. */ +function sicher(n: string): string { + return n.replace(/[\\/\r\n\0]/g, '').replace(/^\.+/, '').trim().slice(0, 120); +} diff --git a/src/lib/printruns.ts b/src/lib/printruns.ts index 4f98110..ba717a0 100644 --- a/src/lib/printruns.ts +++ b/src/lib/printruns.ts @@ -27,6 +27,8 @@ export interface PrintRun { } export interface RunEingang { + /** Vorgegebene Kennung — der Dateiname des PDFs trägt ihr Kürzel. */ + id?: string | null; userId: string | null; origin?: string; summary: string; @@ -84,9 +86,10 @@ export function kurzfassung(kinds: string[]): string { */ export async function recordRun(e: RunEingang): Promise { const run = await one<{ id: string }>( - `INSERT INTO print_runs (created_by, origin, summary, paper, pages, pieces, bytes, config) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`, - [e.userId, e.origin || 'web', e.summary, e.paper, e.pages, e.pieces, e.bytes, JSON.stringify(e.config)]); + `INSERT INTO print_runs (id, created_by, origin, summary, paper, pages, pieces, bytes, config) + VALUES (COALESCE($1::uuid, gen_random_uuid()),$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`, + [e.id || null, e.userId, e.origin || 'web', e.summary, e.paper, e.pages, e.pieces, e.bytes, + JSON.stringify(e.config)]); if (!run) return null; const ids = [...new Set(e.itemIds.filter(Boolean))]; diff --git a/src/lib/printsource.ts b/src/lib/printsource.ts index cf71ecd..846943d 100644 --- a/src/lib/printsource.ts +++ b/src/lib/printsource.ts @@ -10,6 +10,19 @@ export interface SessionUser { uid: string | null; role: string } /** Lädt die Bilddaten und prüft dabei die Zugriffsrechte wie /api/items/:id/file. */ export async function loadSource(src: PrintSource, user: SessionUser): Promise { + return (await loadSourceNamed(src, user)).buffer; +} + +export interface GeladeneQuelle { + buffer: Buffer; + /** Ursprünglicher Dateiname, soweit bekannt — für den Namen der Ausgabedatei. */ + filename: string | null; + /** Kennung des Bibliotheksbildes, sonst null — liefert das Kürzel im Namen. */ + itemId: string | null; +} + +/** Wie `loadSource`, gibt aber auch den Namen der Quelle zurück. */ +export async function loadSourceNamed(src: PrintSource, user: SessionUser): Promise { if (!src || typeof src !== 'object') throw new Error('Quelle fehlt.'); if (src.kind === 'upload') { @@ -18,7 +31,7 @@ export async function loadSource(src: PrintSource, user: SessionUser): Promise('SELECT library_visibility FROM settings WHERE id=1'); if (item.private || s?.library_visibility !== 'shared') throw new Error('Kein Zugriff auf dieses Bild.'); } - return getObject(item.result_path); + return { buffer: await getObject(item.result_path), filename: item.filename ?? null, itemId: String(src.id) }; } throw new Error('Unbekannte Quellenart.'); } -/** Sprechender Dateiname für den Download. */ -export function sheetFilename(prefix: string, ext: string): string { - const date = new Date().toISOString().slice(0, 10); - const slug = (prefix || 'druckbogen').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40); - return `${date}_${slug || 'druckbogen'}.${ext}`; -} +// `sheetFilename` ist entfallen: Es setzte nur das Datum vor den Namen, also +// hießen zwei Bogen desselben Tages gleich. Die Namensregeln stehen jetzt +// vollständig in `naming.ts` — dieselben wie für Bibliotheksergebnisse. diff --git a/src/pages/api/print/sheet.ts b/src/pages/api/print/sheet.ts index c95b63b..480bbb0 100644 --- a/src/pages/api/print/sheet.ts +++ b/src/pages/api/print/sheet.ts @@ -1,8 +1,10 @@ import type { APIRoute } from 'astro'; +import { randomUUID } from 'node:crypto'; import { layout, cutMarks, effectiveGap, type PlaceSpec, type SheetSpec, type MarkMode, type Page, type Line } from '../../../lib/printlayout'; import { fitsOnSheet, overflowPlacement, tilePlan, tileCrop, tileSheet, noteInset, type Oversize } from '../../../lib/printoversize'; import { renderCell, type CropRel, type SheetCellImage, buildSheetPdf, type PageExtras, type FooterPos } from '../../../lib/printrender'; -import { loadSource, sheetFilename, type PrintSource } from '../../../lib/printsource'; +import { loadSource, type PrintSource } from '../../../lib/printsource'; +import { buildSheetFilename } from '../../../lib/naming'; import { paperById, parseSizeMm, labelMm } from '../../../lib/paper'; import { cfgForKey } from '../../../lib/delivery'; import { uploadBuffer } from '../../../lib/picdrop'; @@ -182,7 +184,14 @@ export const POST: APIRoute = async ({ request, locals }) => { markWidthPt: numOr(body?.marks?.widthPt, 0.25, 0.1, 2), }); - const filename = sheetFilename(body?.name || 'druckbogen', 'pdf'); + // Die Kennung des Verlaufseintrags entsteht **vor** dem Dateinamen, damit + // beide dasselbe Kürzel tragen: Wer eine PDF-Datei in der Hand hat, findet + // den zugehörigen Eintrag unter „Zuletzt gedruckt" wieder. + const runId = randomUUID(); + const filename = buildSheetFilename({ + name: body?.name, paperId: typeof body?.paper === 'string' ? body.paper : body?.paper?.id, + wMm: sheet.wMm, hMm: sheet.hMm, pieces: totalPieces, pages: pages.length, runId, + }); // Optional: den fertigen Bogen zusätzlich ins gewohnte Ziel schieben // (Standard-Picdrop, NAS oder ein Zusatzziel) — wie bei den Bildern. @@ -211,6 +220,7 @@ export const POST: APIRoute = async ({ request, locals }) => { // dürfen den Bogen nie kosten: Er ist fertig und geht gleich raus. try { await recordRun({ + id: runId, userId: locals.user.uid ?? null, origin: String(body?.origin || 'web'), summary: kurzfassung(kinds), diff --git a/src/pages/api/print/single.ts b/src/pages/api/print/single.ts index acd3452..c8ff4b5 100644 --- a/src/pages/api/print/single.ts +++ b/src/pages/api/print/single.ts @@ -1,6 +1,7 @@ import type { APIRoute } from 'astro'; import { renderCell, type CropRel } from '../../../lib/printrender'; -import { loadSource, sheetFilename, type PrintSource } from '../../../lib/printsource'; +import { loadSourceNamed, type PrintSource } from '../../../lib/printsource'; +import { buildSingleFilename } from '../../../lib/naming'; import { parseSizeMm, mmToPx, labelMm } from '../../../lib/paper'; import { dpiCheck } from '../../../lib/printlayout'; @@ -30,7 +31,8 @@ export const POST: APIRoute = async ({ request, locals }) => { const dpi = Math.min(1200, Math.max(72, Number(body?.dpi) || 300)); const ext: 'jpg' | 'png' = body?.ext === 'png' ? 'png' : 'jpg'; - const buf = await loadSource(src, locals.user as any); + const quelle = await loadSourceNamed(src, locals.user as any); + const buf = quelle.buffer; const fit = body?.fit === 'contain' ? 'contain' : 'cover'; const background = /^#[0-9a-f]{6}$/i.test(String(body?.bg || '')) ? String(body.bg) : '#ffffff'; const out = await renderCell(buf, crop, size.w, size.h, dpi, @@ -43,7 +45,10 @@ export const POST: APIRoute = async ({ request, locals }) => { return new Response(out.buffer, { headers: { 'Content-Type': out.ext === 'png' ? 'image/png' : 'image/jpeg', - 'Content-Disposition': `attachment; filename="${sheetFilename(body?.name || labelMm(size.w, size.h).replace(/[^0-9x×]/g, ''), out.ext)}"`, + 'Content-Disposition': `attachment; filename="${buildSingleFilename({ + name: body?.name, sourceName: quelle.filename, + wMm: size.w, hMm: size.h, ext: out.ext, id: quelle.itemId, + })}"`, 'X-Klarbild-Px': `${out.width}x${out.height}`, 'X-Klarbild-Real-Dpi': String(check.dpi), 'X-Klarbild-Dpi-Ok': check.ok ? '1' : '0', diff --git a/tests/printnames.test.ts b/tests/printnames.test.ts new file mode 100644 index 0000000..a25f720 --- /dev/null +++ b/tests/printnames.test.ts @@ -0,0 +1,93 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildSheetFilename, buildSingleFilename, paperToken, nameAusKopf, KLARBILD_NAME } from '../src/lib/naming.ts'; + +/** + * Namen für Druckdateien. Der Grund für diese Tests ist derselbe wie im August + * bei den Bibliotheksbildern: Zwei Bogen desselben Tages hießen gleich, und wer + * beide in denselben Ordner legte, verlor den ersten. + */ + +const AM = new Date('2026-09-01T12:32:07Z'); // 14:32:07 Berliner Zeit + +test('Ein Bogen bekommt Zeitstempel, Inhalt, Papier und Kürzel', () => { + const n = buildSheetFilename({ wMm: 210, hMm: 297, paperId: 'A4', pieces: 9, pages: 1, runId: '4eca6ec4-…', at: AM }); + assert.equal(n, '2026-09-01_143207_druck-9-teile_a4_4eca.pdf'); +}); + +test('Zwei Bogen derselben Sekunde können sich nicht überschreiben', () => { + const a = buildSheetFilename({ wMm: 210, hMm: 297, paperId: 'A4', pieces: 9, pages: 1, runId: 'aaaa1111', at: AM }); + const b = buildSheetFilename({ wMm: 210, hMm: 297, paperId: 'A4', pieces: 9, pages: 1, runId: 'bbbb2222', at: AM }); + assert.notEqual(a, b, 'gleiche Sekunde, gleiche Einstellung — trotzdem verschieden'); +}); + +test('Einer und mehrere — Einzahl, Mehrzahl, Bogenzahl', () => { + const eins = buildSheetFilename({ wMm: 210, hMm: 297, paperId: 'A4', pieces: 1, pages: 1, runId: 'ab12', at: AM }); + assert.ok(eins.includes('druck-1-teil_'), eins); + const viele = buildSheetFilename({ wMm: 329, hMm: 483, paperId: 'A3PLUS', pieces: 24, pages: 3, runId: 'ffff', at: AM }); + assert.equal(viele, '2026-09-01_143207_druck-24-teile-3-bogen_a3plus_ffff.pdf'); +}); + +test('Eigener Name hat Vorrang und wird entschärft', () => { + const n = buildSheetFilename({ name: 'Kita Müller / Satz 2', wMm: 210, hMm: 297, paperId: 'A4', pieces: 4, pages: 1, runId: 'cccc', at: AM }); + assert.equal(n, '2026-09-01_143207_kita-mueller-satz-2_a4_cccc.pdf'); +}); + +test('Unbekanntes Papier wird zum Maß', () => { + assert.equal(paperToken(null, 100, 150), '100x150'); + assert.equal(paperToken('', 210, 297), '210x297'); + assert.equal(paperToken('A4', 210, 297), 'a4'); + // Ein sehr langer Bezeichner würde den Namen aufblähen — dann lieber das Maß. + assert.equal(paperToken('ein-ellenlanger-bezeichner', 210, 297), '210x297'); +}); + +test('Einzelbilder erben das Motiv der Quelle', () => { + const n = buildSingleFilename({ sourceName: '2026-08-31_221842_kombiniert_30x40_25b3.png', wMm: 130, hMm: 180, ext: 'jpg', id: '25b34b55-e816', at: AM }); + assert.equal(n, '2026-09-01_143207_kombiniert_130x180_25b3.jpg'); +}); + +test('Ohne brauchbares Motiv steht „klarbild" da, nicht nichts', () => { + assert.equal(buildSingleFilename({ sourceName: null, wMm: 35, hMm: 45, ext: 'jpg', id: 'aaaa', at: AM }), + '2026-09-01_143207_klarbild_35x45_aaaa.jpg'); + assert.equal(buildSingleFilename({ sourceName: 'IMG_1234.HEIC', wMm: 35, hMm: 45, ext: 'jpg', id: 'bbbb', at: AM }), + '2026-09-01_143207_img-1234_35x45_bbbb.jpg'); +}); + +test('Krumme Maße verlieren den Punkt — sonst sähe er wie eine Endung aus', () => { + const n = buildSingleFilename({ sourceName: 'x.png', wMm: 150, hMm: 112.5, ext: 'png', id: 'dddd', at: AM }); + assert.equal(n, '2026-09-01_143207_x_150x112-5_dddd.png'); + assert.equal(n.split('.').length, 2, 'genau eine Endung'); +}); + +test('Druckdateien folgen demselben Muster wie Bibliotheksergebnisse', () => { + for (const n of [ + buildSheetFilename({ wMm: 210, hMm: 297, paperId: 'A4', pieces: 9, pages: 1, runId: '4eca', at: AM }), + buildSingleFilename({ sourceName: 'lea.png', wMm: 130, hMm: 180, ext: 'jpg', id: '25b3', at: AM }), + ]) { + assert.ok(KLARBILD_NAME.test(n.replace(/\.[^.]+$/, '')), `passt nicht ins Schema: ${n}`); + } +}); + +test('Namen taugen für jedes Dateisystem', () => { + for (const name of ['Grüße/Köln', 'a\\b:c*d?e"fh|i', '../../..', 'x'.repeat(200)]) { + const n = buildSheetFilename({ name, wMm: 210, hMm: 297, paperId: 'A4', pieces: 2, pages: 1, runId: 'eeee', at: AM }); + assert.ok(/^[a-z0-9_.-]+$/.test(n), `unerwartetes Zeichen: ${n}`); + assert.ok(n.endsWith('.pdf')); + } +}); + +test('Der Name kommt vom Server, nicht aus dem Browser', () => { + assert.equal(nameAusKopf('attachment; filename="2026-09-01_143207_druck-9-teile_a4_4eca.pdf"', 'x.pdf'), + '2026-09-01_143207_druck-9-teile_a4_4eca.pdf'); + assert.equal(nameAusKopf("attachment; filename*=UTF-8''gr%C3%BC%C3%9Fe.pdf", 'x.pdf'), 'grüße.pdf'); + assert.equal(nameAusKopf(null, 'x.pdf'), 'x.pdf'); + assert.equal(nameAusKopf('attachment', 'x.pdf'), 'x.pdf'); +}); + +test('Ein Name aus dem Kopf kann keinen Pfad einschleusen', () => { + for (const boese of ['attachment; filename="../../etc/passwd"', 'attachment; filename="a/b.pdf"', + 'attachment; filename="a\\\\b.pdf"', 'attachment; filename=".hidden"']) { + const n = nameAusKopf(boese, 'x.pdf'); + assert.ok(!n.includes('/') && !n.includes('\\') && !n.startsWith('.'), `Pfad überlebt: ${n}`); + } +});