From 15634f38bbf86a3f59407163a33f6ae677110caa Mon Sep 17 00:00:00 2001 From: Till Heidrich Date: Tue, 1 Sep 2026 14:17:15 +0000 Subject: [PATCH] Drucken: Bibliothek als Quelle, Verlauf der Drucke, Mailzugang aus der Umgebung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drei Dinge, die im Alltag gefehlt haben. **Bilder aus der Bibliothek in den Druckbereich.** Bisher musste man eine Datei hochladen, auch wenn Klarbild sie längst hatte. Jetzt gibt es einen Wähler mit Suche, Ordnerfilter und Mehrfachauswahl — erreichbar aus der leeren Ablage, aus der Kopfzeile der ersten Karte und an jeder Zellengruppe. **Verlauf der Drucke.** Gespeichert wird bewusst nur die Anleitung, nicht das Ergebnis: ein A3-Bogen ist schnell 20 bis 80 MB, ein Verlaufseintrag rund ein bis drei Kilobyte. Weil das Druckmodul ohne KI rechnet, ergibt derselbe Auftrag wieder exakt dasselbe Blatt — nur die Zeitstempel im PDF unterscheiden sich, der Bildinhalt ist Byte für Byte gleich. „Nochmal drucken" gibt es deshalb nur, wenn alle Zellen aus der Bibliothek kamen und alle Bilder noch da sind; sonst steht am Eintrag, woran es liegt. Je Konto bleiben die letzten 100 Drucke, ältere räumt der Eintrag selbst weg. Sichtbar sind nur die eigenen Läufe, Admins sehen alle. **Mailzugang aus der Umgebung.** Der Versand nimmt jetzt die Server- Einstellung, solange unter Admin → E-Mail nichts eingetragen ist. Die Zugangsdaten liegen als Umgebungsvariablen in Coolify, nicht im Repo. Der Admin zeigt an, welche Quelle gerade wirklich greift. Unterwegs mitgenommen: - Auf schmalen Geräten stand die rechte Spalte oben (order:-1, damit die Vorschau gleich sichtbar ist). Mit der Verlaufskarte hätte man an einem ganzen Block vorbeigescrollt, ehe man ein Bild hinzufügen kann — die Karten laufen jetzt in ihrer eigenen Nummerierung. - Die Kurzfassung listete jede Zelle einzeln: „10 × 15 cm, 10 × 15 cm". Gleiche Maße werden zusammengezogen, vorhandene Stückzahlen addiert. Der erste Ansatz machte dabei aus „13 × 18 cm" prompt „13 cm ×18" — das × im Maß sah aus wie eine Stückzahl. Sieben Tests halten das jetzt fest. - „das PDF war 0.0 MB groß" für einen 20-KB-Bogen: unter einem Megabyte wird in KB gerechnet. - Fehlt eine Datei auf der Platte, zeigte der Browser ein kaputtes Bildsymbol. Jetzt bleibt die Fläche ruhig. 111 Tests grün. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1 --- docker-compose.yaml | 9 ++ migrations/016_print_history.sql | 40 +++++ src/components/AdminApp.tsx | 19 +++ src/components/PrintApp.tsx | 250 ++++++++++++++++++++++++++++--- src/components/PrintRuns.tsx | 217 +++++++++++++++++++++++++++ src/lib/mail.ts | 60 ++++++-- src/lib/printruns.ts | 190 +++++++++++++++++++++++ src/pages/api/admin/settings.ts | 5 + src/pages/api/print/runs.ts | 23 +++ src/pages/api/print/sheet.ts | 21 +++ tests/printruns.test.ts | 43 ++++++ 11 files changed, 842 insertions(+), 35 deletions(-) create mode 100644 migrations/016_print_history.sql create mode 100644 src/components/PrintRuns.tsx create mode 100644 src/lib/printruns.ts create mode 100644 src/pages/api/print/runs.ts create mode 100644 tests/printruns.test.ts diff --git a/docker-compose.yaml b/docker-compose.yaml index 587d0e6..4566fbe 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -23,6 +23,15 @@ services: SEED_LEA_PASSWORD: "${SEED_LEA_PASSWORD}" PUBLIC_BASE_URL: "${PUBLIC_BASE_URL}" N8N_WEBHOOK_URL: "${N8N_WEBHOOK_URL}" + # E-Mail-Versand. Die Datenbank hat Vorrang; diese Werte greifen, solange + # unter Admin → E-Mail nichts eingetragen ist. So läuft der Versand nach + # einem frischen Aufsetzen sofort, ohne dass jemand etwas abtippen muss. + SMTP_HOST: "${SMTP_HOST}" + SMTP_PORT: "${SMTP_PORT}" + SMTP_SECURE: "${SMTP_SECURE}" + SMTP_USER: "${SMTP_USER}" + SMTP_PASSWORD: "${SMTP_PASSWORD}" + SMTP_FROM: "${SMTP_FROM}" depends_on: - db - minio diff --git a/migrations/016_print_history.sql b/migrations/016_print_history.sql new file mode 100644 index 0000000..dcad541 --- /dev/null +++ b/migrations/016_print_history.sql @@ -0,0 +1,40 @@ +-- Druck-Historie: was zuletzt gedruckt wurde ------------------------------ +-- +-- Gespeichert wird **nur die Anleitung**, nicht das Ergebnis: die +-- Bogen-Einstellung, die Verweise auf die Quellbilder und ein paar Eckdaten. +-- Ein A3-Druckbogen als PDF ist schnell 20–80 MB; bei ein paar Dutzend +-- Drucken wäre die Platte voll, und zwar mit Dateien, die sich in Sekunden +-- neu erzeugen lassen. Der Bogen wird beim erneuten Drucken einfach neu +-- gerechnet — deterministisch, aus denselben Quellen. +-- +-- Zeilengröße: rund 1–3 KB je Eintrag. Tausend Drucke sind damit ein paar +-- Megabyte statt vieler Gigabyte. +CREATE TABLE IF NOT EXISTS print_runs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + -- Woher der Druck kam: 'web' | 'telegram' | 'mcp' | 'api' + origin text NOT NULL DEFAULT 'web', + -- Sprechende Kurzfassung für die Liste („13 × 18 cm ×1, 35 × 45 mm ×8"). + summary text, + paper text, -- „DIN A4 · 210 × 297 mm" + pages integer NOT NULL DEFAULT 1, + pieces integer NOT NULL DEFAULT 1, + bytes integer, -- Größe des erzeugten PDFs, nur zur Information + -- Die vollständige Anfrage, aus der sich der Bogen neu erzeugen lässt. + -- Bewusst der Anfragekörper und nicht ein eigenes Format: Was hier steht, + -- lässt sich unverändert wieder an /api/print/sheet schicken. + config jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS print_runs_user_idx ON print_runs(created_by, created_at DESC); +CREATE INDEX IF NOT EXISTS print_runs_created_idx ON print_runs(created_at DESC); + +-- Welche Bibliotheksbilder in einem Druck steckten. Nur für Bilder aus der +-- Bibliothek — hochgeladene Einzeldateien haben keine dauerhafte Kennung und +-- sind nach dem Aufräumen der Quellen ohnehin weg. +CREATE TABLE IF NOT EXISTS print_run_items ( + run_id uuid NOT NULL REFERENCES print_runs(id) ON DELETE CASCADE, + item_id uuid NOT NULL REFERENCES items(id) ON DELETE CASCADE, + PRIMARY KEY (run_id, item_id) +); +CREATE INDEX IF NOT EXISTS print_run_items_item_idx ON print_run_items(item_id); diff --git a/src/components/AdminApp.tsx b/src/components/AdminApp.tsx index 2a9021e..23ef501 100644 --- a/src/components/AdminApp.tsx +++ b/src/components/AdminApp.tsx @@ -336,6 +336,21 @@ export default function AdminApp() { von selbst und es kommt kein weiterer Anbieter ins Spiel. Gebraucht wird es für Passwort zurücksetzen, Hinweise zu fertigen Aufträgen und Teilen-Links per Mail. Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt. + {s.smtp_quelle === 'umgebung' && s.smtp_wirksam && ( +
+ Der Versand läuft bereits — über die Server-Einstellung + ({s.smtp_wirksam.user} auf {s.smtp_wirksam.host}:{s.smtp_wirksam.port}). + Die Felder unten sind leer, weil hier nichts eingetragen ist; das ist in Ordnung. + Wer hier etwas einträgt und speichert, überschreibt die Server-Einstellung. +
+ )} + {s.smtp_quelle === 'datenbank' && ( +
Der Versand läuft über das, was hier eingetragen ist.
+ )} + {!s.smtp_quelle && ( +
Noch kein Versand eingerichtet — Passwort vergessen, + Auftragshinweise und der Mailversand von Teilen-Links funktionieren bis dahin nicht.
+ )}
field('smtp_host', e.target.value)} />
@@ -599,6 +614,10 @@ 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;} + .hinweiskasten{padding:10px 12px;border:1px solid var(--line);border-radius:var(--radius-sm); + background:var(--paper);font-size:12.5px;line-height:1.55;} + .hinweiskasten.warn{border-color:var(--warn);background:oklch(72% .15 75 / .08);} + .hinweiskasten code{font-family:var(--font-mono);font-size:11.5px;} /* Am Handy war alles zweispaltig — ein Feld „Postausgangsserver" bekam so rund 156 px. Und Tippziele unter 44 px trifft man nicht zuverlässig. */ @media(max-width:560px){ diff --git a/src/components/PrintApp.tsx b/src/components/PrintApp.tsx index ff1200d..c80699c 100644 --- a/src/components/PrintApp.tsx +++ b/src/components/PrintApp.tsx @@ -6,6 +6,7 @@ import { } from '../lib/printlayout'; 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'; /* -------------------------------------------------------------------------- Drucken — Bilder ohne KI auf exakte Maße bringen und mehrere davon mit @@ -127,6 +128,8 @@ export default function PrintApp() { const [targets, setTargets] = useState([]); const [editing, setEditing] = useState(null); const [picker, setPicker] = useState(false); + // Der Verlauf lädt erst beim Aufklappen — eine Anfrage weniger auf jeder Seite. + const [verlaufOffen, setVerlaufOffen] = useState(false); const [presets, setPresets] = useState([]); const [presetSel, setPresetSel] = useState(''); const [busy, setBusy] = useState(null); @@ -292,6 +295,32 @@ export default function PrintApp() { const loadPresets = () => fetch('/api/print/presets').then((r) => r.json()) .then((j) => setPresets(j.presets || [])).catch(() => {}); + /** + * Die Einstellung eines früheren Drucks übernehmen — Papier, Marken, Ränder, + * Qualität. Die **Bilder** kommen bewusst nicht mit: Wer „übernehmen" drückt, + * will meist dieselbe Einrichtung für ein anderes Motiv. Wer denselben Bogen + * will, nimmt „Nochmal drucken". + */ + const configUebernehmen = (cfg: any) => { + if (!cfg) return; + try { + if (cfg.paper?.id) setPaperId(cfg.paper.id); + else if (cfg.paper?.size) { setPaperId('custom'); setPaperCustom(String(cfg.paper.size)); } + if (typeof cfg.landscape === 'boolean') setPaperLandscape(cfg.landscape); + if (cfg.marks?.mode) setMarks(cfg.marks.mode); + if (cfg.marks?.lengthMm != null) setMarkLen(Number(cfg.marks.lengthMm)); + if (cfg.marks?.offsetMm != null) setMarkOff(Number(cfg.marks.offsetMm)); + if (cfg.marginMm != null) setMarginMm(Number(cfg.marginMm)); + if (cfg.bleedMm != null) setBleedMm(Number(cfg.bleedMm)); + if (cfg.gapMm != null) { setAutoGap(false); setGapMm(Number(cfg.gapMm)); } + if (cfg.dpi != null) setDpi(Number(cfg.dpi)); + if (cfg.ext) setExt(cfg.ext === 'png' ? 'png' : 'jpg'); + if (typeof cfg.center === 'boolean') setCenter(cfg.center); + notify('Einstellung übernommen. Die Bilder wählst du selbst — „Nochmal drucken" nimmt auch die.'); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } catch { notify('Diese Einstellung ließ sich nicht übernehmen.', true); } + }; + const addFromLibrary = (it: any) => { const [w, h] = String(it.output_px || '').split(/[x×]/).map((n: string) => parseInt(n, 10) || 0); const id = uid(); @@ -475,6 +504,15 @@ export default function PrintApp() { Auch mit ⌘V einfügen · bis zu {MAX_BILDER} Stück · kein KI-Schritt, nur zuschneiden, skalieren, setzen + {/* Der zweite Weg gehört gleichberechtigt hierher: Die meisten + Bilder liegen längst in Klarbild und müssen nicht erst + exportiert und wieder hochgeladen werden. */} +
oder
+ {presets.length > 0 && (
Oder mit einer Vorlage beginnen @@ -500,7 +538,10 @@ export default function PrintApp() { onDuplicate={() => duplicate(c.id)} onRemove={() => setCells((p) => p.filter((x) => x.id !== c.id))} /> ))} - +
+ + +
)} Im Druckdialog „Tatsächliche Größe" / 100 % wählen — nicht „an Seitengröße anpassen".}
+ + {/* Zuletzt gedruckt — zugeklappt, weil es beim Arbeiten nicht stört, + aber zur Hand ist, wenn man denselben Bogen noch einmal braucht. */} + +
+
setVerlaufOffen((e.target as HTMLDetailsElement).open)}> + + Liste anzeigen + denselben Bogen noch einmal erzeugen + +
+ {verlaufOffen && } +
+
+
+
{editCell && setEditing(null)} onChange={(crop) => patch(editCell.id, { crop })} />} - {picker && setPicker(false)} onPick={(it) => { addFromLibrary(it); setPicker(false); }} />} + {picker && ( + setPicker(false)} + onPick={(gewaehlt) => { gewaehlt.forEach(addFromLibrary); setPicker(false); }} /> + )} {toast && (
{toast.text} @@ -1116,41 +1176,136 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop /* ---------------- Bibliothek ---------------- */ -function LibraryPicker({ onPick, onClose }: { onPick: (it: any) => void; onClose: () => void }) { +/** + * Bilder aus der Bibliothek holen. + * + * Der erste Entwurf war eine Textzeile im Kartenkopf und eine ungefilterte + * Kachelwand — bei ein paar hundert Bildern unbrauchbar, und kaum zu finden. + * Jetzt: Suche, Ordner, Mehrfachauswahl. Gesucht wird im Browser über die + * geladene Liste; das ist bei 400 Einträgen sofort da und spart eine Rundreise + * pro Tastendruck. + */ +function LibraryPicker({ onPick, onClose }: { onPick: (items: any[]) => void; onClose: () => void }) { const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); - const modalRef = useRef(null); + const [ordner, setOrdner] = useState([]); + const [laden, setLaden] = useState(true); + const [fehler, setFehler] = useState(false); + const [suche, setSuche] = useState(''); + const [imOrdner, setImOrdner] = useState(''); + const [gewaehlt, setGewaehlt] = useState([]); + const box = useRef(null); const titelId = useId(); - useEffect(() => { - fetch('/api/items').then((r) => r.json()) - .then((j) => setItems(j.items || [])).catch(() => {}).finally(() => setLoading(false)); + const holen = useCallback(() => { + setLaden(true); setFehler(false); + Promise.all([ + fetch('/api/items').then((r) => { if (!r.ok) throw new Error(); return r.json(); }), + fetch('/api/folders').then((r) => (r.ok ? r.json() : { folders: [] })).catch(() => ({ folders: [] })), + ]) + .then(([i, f]) => { setItems(i.items || []); setOrdner(f.folders || []); }) + .catch(() => setFehler(true)) + .finally(() => setLaden(false)); }, []); + useEffect(holen, [holen]); + + // Hintergrund festhalten, Fokus einfangen, Escape schließt. useEffect(() => { const y = window.scrollY; const vorher = document.activeElement as HTMLElement | null; document.body.style.overflow = 'hidden'; - return () => { document.body.style.overflow = ''; window.scrollTo(0, y); vorher?.focus?.(); }; - }, []); + box.current?.querySelector('input')?.focus(); + const key = (e: KeyboardEvent) => { + if (e.key === 'Escape') { e.stopPropagation(); onClose(); } + }; + document.addEventListener('keydown', key); + return () => { + document.removeEventListener('keydown', key); + document.body.style.overflow = ''; window.scrollTo(0, y); vorher?.focus?.(); + }; + }, [onClose]); + + const gefiltert = useMemo(() => { + const q = suche.trim().toLowerCase(); + return items.filter((it) => + (!imOrdner || it.folder_id === imOrdner) + && (!q || String(it.filename || '').toLowerCase().includes(q) + || String(it.prompt_used || '').toLowerCase().includes(q))); + }, [items, suche, imOrdner]); + + const umschalten = (id: string) => + setGewaehlt((g) => (g.includes(id) ? g.filter((x) => x !== id) : [...g, id])); + + const uebernehmen = () => { + const gewaehlteBilder = gewaehlt + .map((id) => items.find((i) => i.id === id)) + .filter(Boolean); + if (gewaehlteBilder.length) onPick(gewaehlteBilder); + }; return (
-
e.stopPropagation()} - onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }}> +
e.stopPropagation()}>

Aus der Bibliothek wählen

+ +
+ setSuche(e.target.value)} + placeholder="Nach Name oder Beschreibung suchen …" aria-label="Bibliothek durchsuchen" /> + {ordner.length > 0 && ( + + )} +
+
- {loading ?

Wird geladen …

+ {laden ?

Wird geladen …

+ : fehler ? ( +

Die Bibliothek ließ sich nicht laden.{' '} +

+ ) : !items.length ?

Noch keine Bilder in der Bibliothek.

- : items.map((it) => ( - - ))} + : !gefiltert.length ?

Nichts gefunden. Andere Suche oder Ordner probieren.

+ : gefiltert.map((it) => { + const an = gewaehlt.includes(it.id); + return ( + + ); + })} +
+ +
+ + {gewaehlt.length + ? `${zahl(gewaehlt.length, 'Bild', 'Bilder')} gewählt` + : `${zahl(gefiltert.length, 'Bild', 'Bilder')}${suche || imOrdner ? ' gefunden' : ' in der Bibliothek'}`} + +
+ {gewaehlt.length > 0 && ( + + )} + +
@@ -1170,7 +1325,12 @@ function PrintStyles() { .arbeit-rechts{position:sticky;top:82px;} @media(max-width:1080px){ .arbeit{grid-template-columns:1fr;gap:14px;} - .arbeit-rechts{position:static;order:-1;} /* Vorschau vor die Einstellungen */ + /* Auf schmalen Geräten laufen die Karten in ihrer eigenen Nummerierung: + 1 Bilder, 2 Bogen & Schnitt, 3 Vorschau, 4 Zuletzt gedruckt. Früher + stand die rechte Spalte oben (order:-1, damit die Vorschau gleich + sichtbar ist) — mit der Verlaufskarte scrollte man dadurch an einem + ganzen Block vorbei, ehe man überhaupt ein Bild hinzufügen konnte. */ + .arbeit-rechts{position:static;} } .karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);} @@ -1191,6 +1351,48 @@ function PrintStyles() { .drop-titel{font-size:15px;font-weight:600;} .drop-fein{color:var(--soft);font-size:12.5px;line-height:1.6;max-width:44ch;} .kbd{font-family:var(--font-mono);font-size:12px;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 6px;} + /* „oder" zwischen den beiden gleichwertigen Wegen ins Modul */ + .oder{display:flex;align-items:center;gap:12px;margin:12px 0;color:var(--soft); + font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;} + .oder::before,.oder::after{content:'';flex:1;height:1px;background:var(--line);} + .drop-lib{min-height:0;padding:18px;border-style:solid;background:var(--paper);} + .drop-lib:hover{border-color:var(--accent);} + .zelle-add-gruppe{display:flex;flex-direction:column;gap:8px;align-self:start;} + .zelle-add-gruppe .zelle-add{min-height:0;padding:13px;} + + .lib{display:flex;flex-direction:column;max-height:min(86vh,780px);} + .lib-filter{display:flex;gap:8px;padding:0 16px 12px;} + .lib-filter .input{flex:1;min-width:0;} + .lib-filter .select{flex:0 0 auto;max-width:44%;} + .lib-fuss{display:flex;align-items:center;justify-content:space-between;gap:12px; + padding:12px 16px;border-top:1px solid var(--line);} + .pick.an{border-color:var(--accent);background:var(--accent-bg);} + .pick-bild{position:relative;display:block;line-height:0;background:var(--paper);} + .pick-haken{position:absolute;top:5px;left:5px;width:22px;height:22px;border-radius:50%; + border:1.5px solid #fff;background:rgba(22,21,15,.4);color:#fff;display:flex; + align-items:center;justify-content:center;font-size:12px;line-height:1;} + .pick.an .pick-haken{background:var(--accent);border-color:var(--card);} + .pick-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} + .pick-mass{display:block;font-family:var(--font-mono);font-size:9px;letter-spacing:.08em;color:var(--soft);} + @media(max-width:640px){ + .lib-filter{flex-direction:column;} + .lib-filter .select{max-width:none;} + .lib-fuss{flex-direction:column;align-items:stretch;gap:8px;} + .lib-fuss .reihe{display:flex;gap:8px;} + .lib-fuss .reihe>*{flex:1;} + } + + .verlauf{border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--paper);} + .verlauf>summary{list-style:none;cursor:pointer;display:flex;align-items:baseline; + justify-content:space-between;gap:10px;padding:12px;font-size:13.5px;} + .verlauf>summary::-webkit-details-marker{display:none;} + .verlauf>summary::after{content:'▾';color:var(--soft);font-size:11px;transition:transform .15s;} + .verlauf[open]>summary::after{transform:rotate(180deg);} + .verlauf>summary em{font-style:normal;font-size:11.5px;color:var(--soft);margin-left:auto;} + /* Auf dem Handy bricht der Zusatz um und macht aus der Zeile einen Block. */ + @media(max-width:560px){ .verlauf>summary em{display:none;} } + .verlauf-inhalt{padding:0 12px 12px;} + .schnellstart{margin-top:14px;padding-top:14px;border-top:1px solid var(--line);} .schnell-reihe{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;} @@ -1341,7 +1543,11 @@ function PrintStyles() { .pick{border:1px solid var(--line);background:#fff;border-radius:3px;padding:0;cursor:pointer;overflow:hidden; display:flex;flex-direction:column;font-family:inherit;} .pick img{width:100%;aspect-ratio:1;object-fit:cover;display:block;} - .pick span{font-size:10px;color:var(--soft);padding:4px 5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} + /* Nur die Textzeilen polstern: Ein Selektor auf alle Spans traf auch die + Hülle um das Bild und hätte den Haken verschoben. */ + .pick-name,.pick-mass{font-size:10px;color:var(--soft);padding:4px 5px 0; + white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} + .pick-mass{padding-bottom:5px;} .pick:hover{border-color:var(--accent);} .toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7; diff --git a/src/components/PrintRuns.tsx b/src/components/PrintRuns.tsx new file mode 100644 index 0000000..121f1e6 --- /dev/null +++ b/src/components/PrintRuns.tsx @@ -0,0 +1,217 @@ +import { useCallback, useEffect, useState } from 'react'; + +/** + * Die letzten Drucke. + * + * Gespeichert ist **nur die Anleitung** — Bogen, Maße, Stückzahlen und die + * Verweise auf die Bilder. Kein PDF: Ein A3-Bogen ist schnell 20–80 MB, und + * das Modul rechnet ohne KI, also ergibt derselbe Auftrag wieder exakt + * dasselbe Blatt. Neu erzeugen ist billiger als aufheben. + * + * „Nochmal drucken" geht deshalb nur, wenn **alle** Bilder aus der Bibliothek + * kamen und noch da sind. Bei hochgeladenen Dateien ist die Quelle nach dem + * Aufräumen weg — das steht dann auch so am Eintrag. + */ +interface Run { + id: string; origin: string; summary: string | null; paper: string | null; + pages: number; pieces: number; bytes: number | null; created_at: string; + bilder: { id: string; filename: string | null }[]; + fehlend: number; hochgeladen: number; wiederholbar: boolean; config: any; +} + +const wann = (s: string) => { + const d = new Date(s); + const minuten = Math.round((Date.now() - d.getTime()) / 60000); + if (minuten < 1) return 'gerade eben'; + if (minuten < 60) return `vor ${minuten} min`; + if (minuten < 24 * 60) return `vor ${Math.round(minuten / 60)} h`; + return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); +}; +// Ein Testbogen sind 20 KB, ein A3-Poster 80 MB. `toFixed(1)` in MB machte aus +// dem kleinen Fall „0.0 MB groß" — also unterhalb eines Megabytes in KB. +const groesse = (b: number | null) => { + if (!b) return ''; + if (b < 1024 * 1024) return `${Math.max(1, Math.round(b / 1024))} KB`; + return `${(b / 1048576).toFixed(1)} MB`.replace('.', ','); +}; +const zahl = (n: number, ein: string, viele: string) => `${n} ${n === 1 ? ein : viele}`; + +export default function PrintRuns({ onLaden }: { onLaden?: (config: any) => void }) { + const [runs, setRuns] = useState([]); + const [laden, setLaden] = useState(true); + const [fehler, setFehler] = useState(false); + const [busy, setBusy] = useState(null); + const [msg, setMsg] = useState<{ t: string; ok: boolean } | null>(null); + + const holen = useCallback(async () => { + try { + const r = await fetch('/api/print/runs'); + if (!r.ok) throw new Error(String(r.status)); + const d = await r.json(); + setRuns(d.runs || []); setFehler(false); + } catch { setFehler(true); } + finally { setLaden(false); } + }, []); + useEffect(() => { holen(); }, [holen]); + + const sagen = (t: string, ok = true) => { setMsg({ t, ok }); setTimeout(() => setMsg(null), 5000); }; + + /** Denselben Bogen noch einmal erzeugen — dieselbe Anfrage, dasselbe PDF. */ + const nochmal = async (r: Run) => { + setBusy(r.id); + try { + const res = await fetch('/api/print/sheet', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + // `origin` markiert den Lauf als Wiederholung, damit die Liste nicht + // aussieht, als hätte jemand denselben Bogen zweimal von Hand gebaut. + body: JSON.stringify({ ...r.config, origin: 'wiederholung' }), + }); + if (!res.ok) { + const j = await res.json().catch(() => ({})); + sagen(j.error || 'Das hat nicht geklappt — vielleicht fehlt ein Bild.', false); + return; + } + 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`; + 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.'); + holen(); + } catch { sagen('Netzwerkfehler.', false); } + finally { setBusy(null); } + }; + + const loeschen = async (r: Run) => { + if (!confirm('Diesen Eintrag aus der Liste nehmen?\n\nDie Bilder bleiben in der Bibliothek — es verschwindet nur der Verlaufseintrag.')) return; + const res = await fetch(`/api/print/runs?id=${r.id}`, { method: 'DELETE' }).then((x) => x.json()).catch(() => ({})); + if (res.ok) { setRuns((p) => p.filter((x) => x.id !== r.id)); } else sagen(res.error || 'Hat nicht geklappt.', false); + }; + + if (laden) return

Wird geladen …

; + + if (fehler) return ( +
+

Der Verlauf ließ sich nicht laden.{' '} +

+ +
+ ); + + if (!runs.length) return ( +
+
+ +

Noch nichts gedruckt

+

Sobald du im Druckbereich ein PDF erzeugst, steht es hier — + mit einem Knopf, um genau denselben Bogen noch einmal zu bekommen.

+
+ +
+ ); + + return ( +
+ {msg &&

{msg.t}

} + +
+ {runs.map((r) => ( +
+
+
+

{r.summary || 'Druckbogen'}

+ {wann(r.created_at)} +
+ {r.origin === 'wiederholung' && wiederholt} + {r.origin === 'telegram' && Telegram} +
+ +
+ {r.paper} · {zahl(r.pages, 'Bogen', 'Bogen')} · {zahl(r.pieces, 'Teil', 'Teile')} + {r.bytes ? <> · das PDF war {groesse(r.bytes)} groß : null} +
+ + {r.bilder.length > 0 && ( +
+ {r.bilder.slice(0, 8).map((b) => ( + {b.filename { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; }} /> + ))} + {r.bilder.length > 8 && +{r.bilder.length - 8}} +
+ )} + +
+ {r.wiederholbar ? ( + + ) : ( + + {r.fehlend > 0 + ? `${zahl(r.fehlend, 'Bild ist', 'Bilder sind')} inzwischen gelöscht — nicht wiederholbar.` + : 'Damals hochgeladen statt aus der Bibliothek — die Quelldatei ist weg.'} + + )} + {onLaden && ( + + )} + +
+
+ ))} +
+ +

+ Hier liegen nur die Einstellungen, nicht die PDFs — ein A3-Bogen wären schnell 20 bis 80 MB. + Weil das Druckmodul ohne KI rechnet, ergibt derselbe Auftrag wieder exakt dasselbe Blatt. + Aufgehoben werden die letzten {100} Drucke je Person. +

+ + +
+ ); +} + +function PrintRunsStyles() { + return ; +} diff --git a/src/lib/mail.ts b/src/lib/mail.ts index 4d8b213..725f986 100644 --- a/src/lib/mail.ts +++ b/src/lib/mail.ts @@ -15,21 +15,55 @@ export interface MailCfg { user: string; password: string; from: string; } -/** SMTP-Zugang aus den Einstellungen — `null`, wenn nichts konfiguriert ist. */ -export async function loadMailConfig(): Promise { +export interface MailQuelle { cfg: MailCfg; quelle: 'datenbank' | 'umgebung' } + +/** + * SMTP-Zugang — erst aus den Einstellungen, sonst aus der Umgebung. + * + * Dieselbe Rangfolge wie beim OpenRouter-Schlüssel: Was jemand im Admin + * einträgt, gewinnt. Die Umgebungsvariablen sind der Startwert, damit der + * Versand nach einem frischen Aufsetzen sofort läuft, ohne dass jemand + * Zugangsdaten abtippen muss — und damit das Passwort **nur** dort und in der + * verschlüsselten Spalte liegt, nie im Repository. + */ +export async function loadMailSource(): Promise { const s = await one( `SELECT smtp_host, smtp_port, smtp_secure, smtp_user, smtp_password, smtp_from FROM settings WHERE id=1`); - if (!s?.smtp_host || !s?.smtp_user || !s?.smtp_password) return null; - let password = ''; - try { password = decrypt(s.smtp_password); } catch { return null; } - return { - host: s.smtp_host, - port: Number(s.smtp_port) || 465, - secure: s.smtp_secure !== false, - user: s.smtp_user, - password, - from: s.smtp_from || s.smtp_user, - }; + + if (s?.smtp_host && s?.smtp_user && s?.smtp_password) { + try { + return { quelle: 'datenbank', cfg: { + host: s.smtp_host, + port: Number(s.smtp_port) || 465, + secure: s.smtp_secure !== false, + user: s.smtp_user, + password: decrypt(s.smtp_password), + from: s.smtp_from || s.smtp_user, + } }; + } catch { + // Entschlüsselung gescheitert (z. B. nach einem Wechsel des + // ENCRYPTION_KEY) — dann lieber die Umgebung als gar nichts. + console.error('[mail] Gespeichertes Passwort nicht lesbar, weiche auf die Umgebung aus.'); + } + } + + const e = process.env; + if (e.SMTP_HOST && e.SMTP_USER && e.SMTP_PASSWORD) { + return { quelle: 'umgebung', cfg: { + host: e.SMTP_HOST, + port: Number(e.SMTP_PORT) || 465, + secure: e.SMTP_SECURE !== 'false', + user: e.SMTP_USER, + password: e.SMTP_PASSWORD, + from: e.SMTP_FROM || e.SMTP_USER, + } }; + } + return null; +} + +/** Nur der Zugang, ohne die Herkunft. */ +export async function loadMailConfig(): Promise { + return (await loadMailSource())?.cfg ?? null; } export const mailReady = async (): Promise => !!(await loadMailConfig()); diff --git a/src/lib/printruns.ts b/src/lib/printruns.ts new file mode 100644 index 0000000..4f98110 --- /dev/null +++ b/src/lib/printruns.ts @@ -0,0 +1,190 @@ +// Druck-Historie — was zuletzt gedruckt wurde, und wie man es wiederholt. +// +// Der springende Punkt: Gespeichert wird die **Anleitung**, nicht das Ergebnis. +// Ein A3-Bogen als PDF ist schnell 20–80 MB; ein paar Dutzend davon füllen die +// Platte mit Dateien, die sich in Sekunden neu rechnen lassen. Also liegt hier +// der Anfragekörper, mit dem `/api/print/sheet` denselben Bogen erneut erzeugt +// — rund 1–3 KB je Eintrag. +// +// Das geht nur, weil das Druckmodul **deterministisch** ist: dieselben Quellen +// und dieselbe Einstellung ergeben dasselbe PDF. Bei den KI-Aufträgen wäre das +// Unsinn, dort käme jedes Mal etwas anderes heraus. +import { one, query } from './db.ts'; + +export const MAX_RUNS_JE_NUTZER = 100; + +export interface PrintRun { + id: string; + created_by: string | null; + origin: string; + summary: string | null; + paper: string | null; + pages: number; + pieces: number; + bytes: number | null; + config: any; + created_at: string; +} + +export interface RunEingang { + userId: string | null; + origin?: string; + summary: string; + paper: string; + pages: number; + pieces: number; + bytes: number; + /** Der Anfragekörper, unverändert. */ + config: any; + /** Kennungen der verwendeten Bibliotheksbilder. */ + itemIds: string[]; +} + +/** + * Was am Bogen hing, in einem Satz. + * + * Die Liste kommt zellenweise herein — zwei Zellen mit demselben Maß ergäben + * sonst „10 × 15 cm, 10 × 15 cm". Gleiche Maße werden deshalb zusammengezogen + * und ihre Stückzahlen addiert; ein vorhandenes „×3" wird dabei mitgerechnet, + * damit nicht „×3 ×2" entsteht. + */ +export function kurzfassung(kinds: string[]): string { + if (!kinds.length) return 'Druckbogen'; + + // Nur die **abschließende** Stückzahl zählt: „35 × 45 mm ×8" hat zwei Mal ein + // ×, und ein gieriger Ausdruck machte aus „13 × 18 cm" prompt „13 cm ×18". + // Deshalb ans Ende verankert, mit optionalem Zusatz in Klammern dahinter. + const ZAHL = /^(.*?)\s*×\s*(\d+)(\s*\([^)]*\))?$/; + + const summe = new Map(); + for (const k of kinds) { + const m = ZAHL.exec(k); + const grund = m ? `${m[1]}${m[3] || ''}` : k; + const anzahl = m ? Number(m[2]) : 1; + summe.set(grund, (summe.get(grund) || 0) + anzahl); + } + + // „30 × 40 cm (Poster) ×3" läse sich schief — die Stückzahl gehört vor den + // Zusatz, dorthin, wo sie hereinkam. + const teile = [...summe].map(([grund, n]) => { + if (n <= 1) return grund; + const z = /^(.*?)(\s*\([^)]*\))$/.exec(grund); + return z ? `${z[1]} ×${n}${z[2]}` : `${grund} ×${n}`; + }); + const s = teile.join(', '); + return s.length <= 120 ? s : `${s.slice(0, 117)}…`; +} + +/** + * Einen Druck festhalten. + * + * Fehler hier dürfen den Druck **nie** scheitern lassen — das PDF ist zu dem + * Zeitpunkt längst fertig und unterwegs zum Nutzer. Deshalb fängt der Aufrufer + * ab, und diese Funktion meldet nur ins Log. + */ +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)]); + if (!run) return null; + + const ids = [...new Set(e.itemIds.filter(Boolean))]; + if (ids.length) { + // Bilder können zwischenzeitlich gelöscht worden sein — dann fällt die + // Verknüpfung weg, der Eintrag bleibt. + await query( + `INSERT INTO print_run_items (run_id, item_id) + SELECT $1, x FROM unnest($2::uuid[]) AS x + WHERE EXISTS (SELECT 1 FROM items WHERE id = x) + ON CONFLICT DO NOTHING`, [run.id, ids]).catch((err) => + console.error('[printruns] Bildverweise nicht gespeichert:', err?.message || err)); + } + + // Alte Einträge desselben Nutzers wegräumen. Ohne das wächst die Liste + // unbegrenzt, und niemand scrollt bis zum Druck von vor zwei Jahren. + if (e.userId) { + await query( + `DELETE FROM print_runs + WHERE created_by = $1 AND id NOT IN ( + SELECT id FROM print_runs WHERE created_by = $1 + ORDER BY created_at DESC LIMIT $2)`, + [e.userId, MAX_RUNS_JE_NUTZER]).catch(() => {}); + } + return run.id; +} + +export interface RunListe extends PrintRun { + /** Bilder, die es noch gibt — nur damit lässt sich der Druck wiederholen. */ + bilder: { id: string; filename: string | null }[]; + /** Wie viele **Bibliotheksbilder** inzwischen gelöscht sind. */ + fehlend: number; + /** Wie viele Zellen aus einer hochgeladenen Datei kamen — die sind weg. */ + hochgeladen: number; + /** Lässt sich der Bogen unverändert neu erzeugen? */ + wiederholbar: boolean; +} + +/** + * Die letzten Drucke. Admins sehen alle, sonst nur die eigenen — dieselbe + * Regel wie bei den Teilen-Links. + */ +export async function listRuns(userId: string | null, istAdmin: boolean, limit = 30): Promise { + const runs = await query( + `SELECT * FROM print_runs + WHERE $2::boolean OR created_by = $1 + ORDER BY created_at DESC LIMIT $3`, [userId, istAdmin, Math.min(limit, 100)]); + if (!runs.length) return []; + + const bilder = await query<{ run_id: string; id: string; filename: string | null }>( + `SELECT ri.run_id, i.id, i.filename + FROM print_run_items ri JOIN items i ON i.id = ri.item_id + WHERE ri.run_id = ANY($1::uuid[]) AND i.status = 'done' AND i.result_path IS NOT NULL`, + [runs.map((r) => r.id)]); + + const nachRun = new Map(); + for (const b of bilder) { + const l = nachRun.get(b.run_id) || []; + l.push({ id: b.id, filename: b.filename }); + nachRun.set(b.run_id, l); + } + + return runs.map((r) => { + const vorhanden = nachRun.get(r.id) || []; + const zellen: any[] = Array.isArray(r.config?.cells) ? r.config.cells : []; + // Nur Zellen aus der Bibliothek lassen sich wiederholen. Hochgeladene + // Dateien liegen unter `sources/…` und werden je nach Einstellung nach der + // Verarbeitung aufgeräumt — dann fehlt die Quelle. + const ausBibliothek = zellen.filter((c) => c?.src?.kind === 'item').length; + // Verschiedene Gründe, verschieden erklärt: Ein gelöschtes Bibliotheksbild + // ist etwas anderes als eine Datei, die nie in der Bibliothek lag. Beides + // in einen Zähler zu werfen führte zu „1 Bild ist nicht mehr da" für einen + // Bogen, dessen Quelle schlicht ein Upload war. + const eindeutige = new Set(zellen.filter((c) => c?.src?.kind === 'item') + .map((c) => String(c.src.id))).size; + return { + ...r, + bilder: vorhanden, + fehlend: Math.max(0, eindeutige - vorhanden.length), + hochgeladen: zellen.length - ausBibliothek, + // Wiederholbar nur, wenn **alle** Zellen aus der Bibliothek kommen und + // alle Bilder noch da sind. Halb geht nicht: Ein Bogen mit fehlendem + // Bild wäre nicht derselbe Bogen. + wiederholbar: zellen.length > 0 && ausBibliothek === zellen.length && vorhanden.length === zellen.length, + }; + }); +} + +/** Einen einzelnen Druck holen — für das erneute Erzeugen. */ +export async function getRun(id: string, userId: string | null, istAdmin: boolean): Promise { + return one( + `SELECT * FROM print_runs WHERE id = $1 AND ($3::boolean OR created_by = $2)`, + [id, userId, istAdmin]); +} + +export async function deleteRun(id: string, userId: string | null, istAdmin: boolean): Promise { + const r = await query<{ id: string }>( + `DELETE FROM print_runs WHERE id = $1 AND ($3::boolean OR created_by = $2) RETURNING id`, + [id, userId, istAdmin]); + return r.length > 0; +} diff --git a/src/pages/api/admin/settings.ts b/src/pages/api/admin/settings.ts index f8735c8..21d057b 100644 --- a/src/pages/api/admin/settings.ts +++ b/src/pages/api/admin/settings.ts @@ -2,6 +2,7 @@ import type { APIRoute } from 'astro'; import { randomBytes } from 'node:crypto'; import { one, query } from '../../../lib/db'; import { encrypt, maskSecret, decrypt } from '../../../lib/crypto'; +import { loadMailSource } from '../../../lib/mail'; export const prerender = false; const randomToken = () => 'klb_' + randomBytes(24).toString('hex'); @@ -10,6 +11,7 @@ const json = (b: unknown, s = 200) => export const GET: APIRoute = async () => { const s = await one('SELECT * FROM settings WHERE id=1'); + const mail = await loadMailSource(); if (!s) return json({ settings: null }); let orMask = ''; if (s.openrouter_key_enc) { try { orMask = maskSecret(decrypt(s.openrouter_key_enc)); } catch { orMask = 'gesetzt'; } } @@ -35,6 +37,9 @@ export const GET: APIRoute = async () => { // E-Mail-Versand (Passwort wird nie zurückgegeben, nur ob es gesetzt ist) smtp_host: s.smtp_host, smtp_port: s.smtp_port, smtp_secure: s.smtp_secure, smtp_user: s.smtp_user, smtp_from: s.smtp_from, smtp_password_set: !!s.smtp_password, + // Woher der gerade wirksame Zugang kommt: 'datenbank' | 'umgebung' | null + smtp_quelle: mail?.quelle ?? null, + smtp_wirksam: mail ? { host: mail.cfg.host, port: mail.cfg.port, user: mail.cfg.user, from: mail.cfg.from } : null, // Teilen share_default_days: s.share_default_days, } }); diff --git a/src/pages/api/print/runs.ts b/src/pages/api/print/runs.ts new file mode 100644 index 0000000..d40d755 --- /dev/null +++ b/src/pages/api/print/runs.ts @@ -0,0 +1,23 @@ +import type { APIRoute } from 'astro'; +import { listRuns, deleteRun } from '../../../lib/printruns'; + +export const prerender = false; +const json = (b: unknown, s = 200) => + new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Die letzten Drucke — Einstellung und Quellen, nie das PDF selbst. */ +export const GET: APIRoute = async ({ locals, url }) => { + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + const limit = Number(url.searchParams.get('limit')) || 30; + const runs = await listRuns(locals.user.uid, locals.user.role === 'admin', limit); + return json({ runs }); +}; + +export const DELETE: APIRoute = async ({ locals, url }) => { + if (!locals.user) return new Response('Unauthorized', { status: 401 }); + const id = url.searchParams.get('id') || ''; + if (!UUID.test(id)) return json({ error: 'Unbekannter Eintrag.' }, 404); + const ok = await deleteRun(id, locals.user.uid, locals.user.role === 'admin'); + return ok ? json({ ok: true }) : json({ error: 'Unbekannter Eintrag.' }, 404); +}; diff --git a/src/pages/api/print/sheet.ts b/src/pages/api/print/sheet.ts index 35f345f..c95b63b 100644 --- a/src/pages/api/print/sheet.ts +++ b/src/pages/api/print/sheet.ts @@ -7,6 +7,7 @@ import { paperById, parseSizeMm, labelMm } from '../../../lib/paper'; import { cfgForKey } from '../../../lib/delivery'; import { uploadBuffer } from '../../../lib/picdrop'; import { one } from '../../../lib/db'; +import { recordRun, kurzfassung } from '../../../lib/printruns'; export const prerender = false; @@ -206,6 +207,26 @@ export const POST: APIRoute = async ({ request, locals }) => { } } + // Den Druck festhalten — nur die Anleitung, nicht das PDF. Fehler dabei + // dürfen den Bogen nie kosten: Er ist fertig und geht gleich raus. + try { + await recordRun({ + userId: locals.user.uid ?? null, + origin: String(body?.origin || 'web'), + summary: kurzfassung(kinds), + paper: `${labelMm(sheet.wMm, sheet.hMm)}`, + pages: pages.length, + pieces: totalPieces, + bytes: bytes.length, + config: body, + itemIds: [...cellsIn, ...big.map((b) => b.cell)] + .map((c: any) => (c?.src?.kind === 'item' ? String(c.src.id) : null)) + .filter(Boolean) as string[], + }); + } catch (e: any) { + console.error('[print] Historie nicht gespeichert:', e?.message || e); + } + return new Response(Buffer.from(bytes), { headers: { 'Content-Type': 'application/pdf', diff --git a/tests/printruns.test.ts b/tests/printruns.test.ts new file mode 100644 index 0000000..40fb065 --- /dev/null +++ b/tests/printruns.test.ts @@ -0,0 +1,43 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { kurzfassung } from '../src/lib/printruns.ts'; + +/** + * Die Kurzfassung steht in der Verlaufsliste. Sie kommt zellenweise herein — + * zwei Zellen mit demselben Maß dürfen dort nicht zweimal auftauchen. + */ + +test('Einzelnes Maß bleibt, wie es ist', () => { + assert.equal(kurzfassung(['13 × 18 cm']), '13 × 18 cm'); +}); + +test('Gleiche Maße werden zusammengezogen', () => { + assert.equal(kurzfassung(['10 × 15 cm', '10 × 15 cm']), '10 × 15 cm ×2'); +}); + +test('Vorhandene Stückzahlen werden addiert, nicht angehängt', () => { + assert.equal(kurzfassung(['35 × 45 mm ×8', '35 × 45 mm ×4']), '35 × 45 mm ×12'); + assert.equal(kurzfassung(['10 × 15 cm ×3', '10 × 15 cm']), '10 × 15 cm ×4'); +}); + +test('Verschiedene Maße bleiben nebeneinander, in ihrer Reihenfolge', () => { + assert.equal(kurzfassung(['13 × 18 cm', '35 × 45 mm ×8']), '13 × 18 cm, 35 × 45 mm ×8'); +}); + +test('Zusätze wie (Poster) trennen die Gruppen', () => { + assert.equal(kurzfassung(['30 × 40 cm ×2 (Poster)', '30 × 40 cm ×1 (Poster)']), + '30 × 40 cm ×3 (Poster)'); + assert.equal(kurzfassung(['30 × 40 cm (Poster)', '30 × 40 cm (überstehend)']), + '30 × 40 cm (Poster), 30 × 40 cm (überstehend)'); +}); + +test('Leere Liste ergibt etwas Lesbares', () => { + assert.equal(kurzfassung([]), 'Druckbogen'); +}); + +test('Sehr lange Aufzählungen werden gekürzt', () => { + const viele = Array.from({ length: 40 }, (_, i) => `${i + 10} × ${i + 15} cm`); + const s = kurzfassung(viele); + assert.ok(s.length <= 118, `zu lang: ${s.length}`); + assert.ok(s.endsWith('…')); +});