From 9ed59bcf56ec32959bdd87d911f82e2293829a4f Mon Sep 17 00:00:00 2001 From: ferdi2go Date: Wed, 16 Sep 2026 09:57:54 +0000 Subject: [PATCH] =?UTF-8?q?Fotoupload=20im=20Personenformular=20erg=C3=A4n?= =?UTF-8?q?zen=20und=20Initialen-Platzhalter=20aussortieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fotofeld mit Dateiauswahl, Einfügen per Strg+V, Zwischenablage-Schaltfläche und Drag & Drop; sichtbarer Foto-Button je Zeile. Foto wird beim Anlegen oder Speichern übernommen bzw. entfernt. Nextcloud meldet generierte Initialen-Avatare teils als eigene Profilbilder. Der Import erkennt Bilder mit dominanter Hintergrundfarbe und lässt sie aus; „Platzhalter-Fotos entfernen" bereinigt bereits importierte. Co-Authored-By: Claude Fable 5.1 --- public/app.js | 130 +++++++++++++++++++++++++++++++++++++++++++-- public/index.html | 16 ++++++ public/style.css | 15 ++++++ server/image.js | 70 ++++++++++++++++++++++++ server/index.js | 28 +++++++++- test/image.test.js | 43 +++++++++++++++ 6 files changed, 295 insertions(+), 7 deletions(-) create mode 100644 server/image.js create mode 100644 test/image.test.js diff --git a/public/app.js b/public/app.js index 7d5d0c3..66bb51b 100644 --- a/public/app.js +++ b/public/app.js @@ -474,7 +474,7 @@ function renderPersons() { const li = document.createElement('li'); if (p.active === false) li.classList.add('inactive'); const av = avatarEl(p, 'plain'); - av.title = 'Foto hochladen (Klick)'; + av.title = p.hasPhoto ? 'Foto ändern' : 'Foto hochladen'; av.tabIndex = 0; av.setAttribute('role', 'button'); av.setAttribute('aria-label', `Foto für ${fullName(p)} hochladen`); @@ -501,6 +501,12 @@ function renderPersons() { edit.textContent = 'Bearbeiten'; edit.addEventListener('click', () => editPerson(p)); li.appendChild(edit); + const photoBtn = document.createElement('button'); + photoBtn.type = 'button'; + photoBtn.className = 'ghost'; + photoBtn.textContent = p.hasPhoto ? 'Foto ändern' : 'Foto hochladen'; + photoBtn.addEventListener('click', () => choosePhoto(p.id)); + li.appendChild(photoBtn); if (p.hasPhoto) { const rm = document.createElement('button'); rm.type = 'button'; @@ -523,6 +529,95 @@ function renderPersons() { } function tag(text, cls = '') { const s = document.createElement('span'); s.className = `tag ${cls}`; s.textContent = text; return s; } +// Foto im Formular: pending = neues Bild (Blob), remove = vorhandenes Foto beim Speichern löschen +const photoState = { pending: null, remove: false, person: null, url: null }; +function setPendingPhoto(blob) { + if (photoState.url) URL.revokeObjectURL(photoState.url); + photoState.pending = blob; + photoState.remove = false; + photoState.url = blob ? URL.createObjectURL(blob) : null; + renderPhotoField(); +} +function renderPhotoField() { + const preview = $('#photo-preview'); + const status = $('#photo-status'); + const clear = $('#photo-clear'); + preview.textContent = ''; + preview.style.backgroundImage = ''; + preview.classList.remove('empty'); + const p = photoState.person; + const hasExisting = Boolean(p && p.hasPhoto && !photoState.remove); + if (photoState.pending) { + preview.style.backgroundImage = `url("${photoState.url}")`; + preview.style.backgroundSize = 'cover'; + status.textContent = 'Neues Foto gewählt. Wird mit „Speichern" bzw. „Person anlegen" übernommen.'; + clear.hidden = false; + clear.textContent = 'Auswahl verwerfen'; + } else if (hasExisting) { + preview.style.backgroundImage = `url("photos/${p.id}?v=${p.photoVersion || 0}")`; + preview.style.backgroundSize = 'cover'; + status.textContent = 'Vorhandenes Foto. Datei wählen, hierher ziehen oder mit Strg+V ersetzen.'; + clear.hidden = false; + clear.textContent = 'Foto entfernen'; + } else { + preview.classList.add('empty'); + preview.textContent = p ? initials(p) : ''; + status.textContent = photoState.remove ? 'Foto wird beim Speichern entfernt.' : 'Kein Foto. Datei wählen, hierher ziehen oder mit Strg+V aus der Zwischenablage einfügen.'; + clear.hidden = !photoState.remove; + clear.textContent = 'Entfernen rückgängig'; + } +} +async function acceptPhotoFile(file) { + if (!file || !file.type.startsWith('image/')) { toast('Das ist kein Bild.', true); return; } + try { + setPendingPhoto(await squareJpeg(file, 320)); + $('#photo-field').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } catch (err) { toast(`Bild konnte nicht verarbeitet werden: ${err.message}`, true); } +} +function imageFromClipboard(data) { + if (!data) return null; + for (const item of data.items || []) { + if (item.kind === 'file' && item.type.startsWith('image/')) return item.getAsFile(); + } + for (const file of data.files || []) if (file.type.startsWith('image/')) return file; + return null; +} +// Strg+V überall in der Personenansicht: Bild aus der Zwischenablage landet im Formular +document.addEventListener('paste', (e) => { + if ($('#view-personen').hidden || !state.data) return; + const file = imageFromClipboard(e.clipboardData); + if (!file) return; + e.preventDefault(); + acceptPhotoFile(file); + toast('Bild aus der Zwischenablage übernommen.'); +}); +$('#photo-paste').addEventListener('click', async () => { + // Schaltfläche für Mäuse und Touch: liest die Zwischenablage, wenn der Browser es erlaubt + if (!navigator.clipboard?.read) { toast('Dein Browser erlaubt das nur per Tastatur: Strg+V im Formular drücken.', true); return; } + try { + for (const item of await navigator.clipboard.read()) { + const type = item.types.find((t) => t.startsWith('image/')); + if (type) { await acceptPhotoFile(new File([await item.getType(type)], 'clipboard', { type })); return; } + } + toast('Kein Bild in der Zwischenablage.', true); + } catch { toast('Zugriff auf die Zwischenablage abgelehnt. Alternativ Strg+V drücken.', true); } +}); +$('#photo-choose').addEventListener('click', () => { photoTarget = null; $('#photo-input').value = ''; $('#photo-input').click(); }); +$('#photo-clear').addEventListener('click', () => { + if (photoState.pending) { setPendingPhoto(null); return; } + photoState.remove = !photoState.remove; + renderPhotoField(); +}); +const photoField = $('#photo-field'); +photoField.addEventListener('dragover', (e) => { e.preventDefault(); photoField.classList.add('dragover'); }); +photoField.addEventListener('dragleave', () => photoField.classList.remove('dragover')); +photoField.addEventListener('drop', (e) => { + e.preventDefault(); + photoField.classList.remove('dragover'); + const file = [...(e.dataTransfer?.files || [])].find((f) => f.type.startsWith('image/')); + if (file) acceptPhotoFile(file); else toast('Bitte eine Bilddatei ablegen.', true); +}); + function editPerson(p) { const f = personForm.elements; f.id.value = p.id; @@ -534,6 +629,9 @@ function editPerson(p) { f.canCo.checked = Boolean(p.canCo); f.active.checked = p.active !== false; for (const cb of personForm.querySelectorAll('input[name="freeDays"]')) cb.checked = (p.freeDays || []).includes(Number(cb.value)); + photoState.person = p; + photoState.remove = false; + setPendingPhoto(null); $('#person-submit').textContent = 'Änderungen speichern'; $('#person-cancel').hidden = false; personForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); @@ -542,6 +640,9 @@ function editPerson(p) { function resetPersonForm() { personForm.reset(); personForm.elements.id.value = ''; + photoState.person = null; + photoState.remove = false; + setPendingPhoto(null); $('#person-submit').textContent = 'Person anlegen'; $('#person-cancel').hidden = true; } @@ -552,8 +653,11 @@ personForm.addEventListener('submit', async (e) => { const freeDays = [...personForm.querySelectorAll('input[name="freeDays"]:checked')].map((cb) => Number(cb.value)); const body = { firstName: f.firstName.value, lastName: f.lastName.value, email: f.email.value, note: f.note.value, canMain: f.canMain.checked, canCo: f.canCo.checked, active: f.active.checked, freeDays }; try { - if (f.id.value) await api(`api/persons/${f.id.value}`, { method: 'PUT', body }); - else await api('api/persons', { method: 'POST', body }); + const saved = f.id.value + ? await api(`api/persons/${f.id.value}`, { method: 'PUT', body }) + : await api('api/persons', { method: 'POST', body }); + if (photoState.pending) await api(`api/persons/${saved.id}/photo`, { method: 'PUT', raw: { body: photoState.pending, type: 'image/jpeg' } }); + else if (photoState.remove) await api(`api/persons/${saved.id}/photo`, { method: 'DELETE' }); resetPersonForm(); await load(); toast('Gespeichert.'); @@ -626,17 +730,32 @@ contactsForm.addEventListener('submit', async (e) => { try { const res = await api('api/nextcloud/contacts/import', { method: 'POST', body: { uids, addressbook: contactsForm.elements.addressbook.value } }); await load(); - out.textContent = `${res.created} Person(en) angelegt, ${res.withPhoto} mit Foto.${res.skipped ? ` ${res.skipped} übersprungen.` : ''}`; + out.textContent = `${res.created} Person(en) angelegt, ${res.withPhoto} mit Foto.${res.placeholders ? ` ${res.placeholders} Initialen-Platzhalter aus Nextcloud nicht übernommen.` : ''}${res.skipped ? ` ${res.skipped} übersprungen.` : ''}`; await loadContacts(contactsForm.elements.addressbook.value); } catch (err) { out.className = 'result error'; out.textContent = err.message; } btn.disabled = false; }); +$('#remove-placeholders').addEventListener('click', async () => { + const btn = $('#remove-placeholders'); + btn.disabled = true; + try { + const { ids } = await api('api/persons/placeholder-photos'); + if (ids.length === 0) { toast('Keine Platzhalter-Fotos gefunden.'); btn.disabled = false; return; } + if (!confirm(`${ids.length} Foto(s) sehen wie Initialen-Platzhalter aus (eine Hintergrundfarbe mit Buchstaben). Entfernen? Die Personen bekommen dann Initialen im Rotatory-Stil.`)) { btn.disabled = false; return; } + const res = await api('api/persons/placeholder-photos/remove', { method: 'POST' }); + await load(); + toast(`${res.removed} Platzhalter-Foto(s) entfernt.`); + } catch (err) { toast(err.message, true); } + btn.disabled = false; +}); + let photoTarget = null; function choosePhoto(id) { photoTarget = id; $('#photo-input').value = ''; $('#photo-input').click(); } $('#photo-input').addEventListener('change', async (e) => { const file = e.target.files[0]; - if (!file || !photoTarget) return; + if (!file) return; + if (!photoTarget) { acceptPhotoFile(file); return; } try { const blob = await squareJpeg(file, 320); await api(`api/persons/${photoTarget}/photo`, { method: 'PUT', raw: { body: blob, type: 'image/jpeg' } }); @@ -845,6 +964,7 @@ $('#holiday-add').addEventListener('submit', async (e) => { function renderAll() { renderCalendar(); renderPersons(); + renderPhotoField(); renderAbsences(); renderSettings(); if (!$('#view-abwesenheiten').hidden) prepareAbsenceImport(); diff --git a/public/index.html b/public/index.html index 68f5672..9409f31 100644 --- a/public/index.html +++ b/public/index.html @@ -64,6 +64,18 @@ +
+ +
+ Foto + Kein Foto. Datei wählen, hierher ziehen oder mit Strg+V aus der Zwischenablage einfügen. +
+ + + +
+
+
Rolle in der Rotation @@ -86,6 +98,10 @@ +
+ + +
    diff --git a/public/style.css b/public/style.css index fb88635..7339be9 100644 --- a/public/style.css +++ b/public/style.css @@ -188,6 +188,21 @@ button:disabled { opacity: .45; cursor: default; } .month-nav h2 { min-width: 0; } } +/* Fotofeld im Personenformular: Ablagefläche für Datei, Drag & Drop und Zwischenablage */ +.photo-field { display: flex; gap: 16px; align-items: center; padding: 16px; border: 1px dashed var(--line-2); border-radius: var(--radius); } +.photo-field.dragover { border-color: var(--tradeo-blue); background: var(--tint-1); } +.photo-field:focus-visible { outline: 2px solid var(--tradeo-blue); outline-offset: 2px; border-style: solid; } +.photo-field .photo-preview { width: 64px; height: 64px; font-size: 18px; cursor: default; } +.photo-field .photo-preview.empty { border-style: dashed; } +.photo-text { display: grid; gap: 8px; min-width: 0; flex: 1; } +.photo-text strong { font-family: var(--font-title); font-weight: 500; font-size: 14px; } +.photo-text .hint { margin: 0; } +.photo-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.photo-actions button { min-height: 34px; padding: 0 12px; font-size: 13px; } + +.list-tools { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 0 0 8px; } +.list-tools .hint { margin: 0; } + /* Listen: Zeilen mit dezenten Trennlinien statt einzelner Kästen */ .list, .person-list, .contacts-list { list-style: none; padding: 0; margin: 0; border-top: 1px solid var(--line); } .list li, .person-list li, .contacts-list li { display: flex; align-items: center; gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--line); flex-wrap: wrap; } diff --git a/server/image.js b/server/image.js new file mode 100644 index 0000000..407e2bb --- /dev/null +++ b/server/image.js @@ -0,0 +1,70 @@ +// Erkennung von Buchstaben-Platzhaltern (eine dominante Hintergrundfarbe, Initialen darauf). +// Nextcloud liefert solche Bilder teils als „eigene" Avatare aus; im Kalender sollen aber nur echte Fotos landen. +import zlib from 'node:zlib'; +import { imageMime } from './store.js'; + +const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +/** Anteil der häufigsten Farbe (0–1) und Zahl unterschiedlicher Farben, per Stichprobe. Nur PNG (8 Bit, RGB/RGBA/Grau). */ +export function pngDominantShare(buffer, step = 4) { + if (!buffer || buffer.length < 33 || !buffer.subarray(0, 8).equals(PNG_SIG)) return null; + let pos = 8; + let width = 0; let height = 0; let depth = 0; let colorType = 0; + const idat = []; + while (pos + 8 <= buffer.length) { + const len = buffer.readUInt32BE(pos); + const type = buffer.toString('ascii', pos + 4, pos + 8); + const body = buffer.subarray(pos + 8, pos + 8 + len); + if (type === 'IHDR') { width = body.readUInt32BE(0); height = body.readUInt32BE(4); depth = body[8]; colorType = body[9]; } + else if (type === 'IDAT') idat.push(body); + else if (type === 'IEND') break; + pos += 12 + len; + } + const channels = { 0: 1, 2: 3, 4: 2, 6: 4 }[colorType]; + if (!channels || depth !== 8 || !width || !height) return null; + let raw; + try { raw = zlib.inflateSync(Buffer.concat(idat)); } catch { return null; } + const bpp = channels; + const stride = width * bpp; + let prev = Buffer.alloc(stride); + let offset = 0; + const counts = new Map(); + let total = 0; + for (let y = 0; y < height; y += 1) { + const filter = raw[offset]; + const line = Buffer.from(raw.subarray(offset + 1, offset + 1 + stride)); + offset += 1 + stride; + for (let x = 0; x < stride; x += 1) { + const a = x >= bpp ? line[x - bpp] : 0; + const b = prev[x]; + const c = x >= bpp ? prev[x - bpp] : 0; + if (filter === 1) line[x] = (line[x] + a) & 255; + else if (filter === 2) line[x] = (line[x] + b) & 255; + else if (filter === 3) line[x] = (line[x] + ((a + b) >> 1)) & 255; + else if (filter === 4) { + const p = a + b - c; + const pa = Math.abs(p - a); const pb = Math.abs(p - b); const pc = Math.abs(p - c); + line[x] = (line[x] + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 255; + } + } + if (y % step === 0) { + for (let x = 0; x < stride; x += bpp * step) { + const key = channels >= 3 ? (line[x] << 16) | (line[x + 1] << 8) | line[x + 2] : line[x] * 0x010101; + counts.set(key, (counts.get(key) || 0) + 1); + total += 1; + } + } + prev = line; + } + if (total === 0) return null; + let max = 0; + for (const n of counts.values()) if (n > max) max = n; + return { share: max / total, colors: counts.size, width, height }; +} + +/** true, wenn das Bild wie ein generierter Initialen-Avatar aussieht: eine Farbe füllt den Großteil der Fläche. */ +export function looksLikePlaceholder(buffer, threshold = 0.75) { + if (imageMime(buffer) !== 'image/png') return false; // JPEG/WebP sind praktisch immer echte Fotos + const info = pngDominantShare(buffer); + return Boolean(info && info.share >= threshold); +} diff --git a/server/index.js b/server/index.js index 2849d5d..97bbc40 100644 --- a/server/index.js +++ b/server/index.js @@ -4,6 +4,7 @@ import { randomUUID, timingSafeEqual } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { loadEnvFile } from './env.js'; import { createStore, imageMime } from './store.js'; +import { looksLikePlaceholder } from './image.js'; import { workingDays, generateSchedule, isValidDate, countAssignments, addDays } from './scheduler.js'; import { germanHolidays, BUNDESLAENDER } from './holidays.js'; import { buildEventLines, buildCalendar, eventUid } from './ics.js'; @@ -178,6 +179,26 @@ app.delete('/api/persons/:id/photo', (req, res) => { res.status(204).end(); }); +/** Bereits gespeicherte Fotos, die wie Initialen-Platzhalter aussehen. */ +function placeholderPhotoIds() { + return S().persons.filter((p) => { const buf = store.readPhoto(p.id); return buf && looksLikePlaceholder(buf); }).map((p) => p.id); +} + +app.get('/api/persons/placeholder-photos', (req, res) => { + res.json({ ids: placeholderPhotoIds() }); +}); + +app.post('/api/persons/placeholder-photos/remove', (req, res) => { + const ids = placeholderPhotoIds(); + for (const id of ids) { + const p = S().persons.find((x) => x.id === id); + store.deletePhoto(id); + if (p) p.photoVersion = (p.photoVersion || 0) + 1; + } + store.save(); + res.json({ removed: ids.length, persons: S().persons.map(publicPerson) }); +}); + app.get('/photos/:id', (req, res) => { const id = String(req.params.id).replace(/[^a-f0-9-]/g, ''); const buf = store.readPhoto(id); @@ -579,6 +600,7 @@ app.post('/api/nextcloud/contacts/import', async (req, res) => { const created = []; const photos = []; const skipped = []; + let placeholders = 0; for (const uid of uids) { const c = cards.find((x) => x.uid === uid); if (!c || (!c.firstName && !c.lastName)) { skipped.push(uid); continue; } @@ -591,10 +613,12 @@ app.post('/api/nextcloud/contacts/import', async (req, res) => { if (book.system) img = await client.fetchAvatar(uid).catch(() => null); if (!img && c.photo?.kind === 'inline') { const buffer = Buffer.from(c.photo.base64, 'base64'); if (imageMime(buffer)) img = { buffer }; } if (!img && c.photo?.kind === 'url') img = await client.fetchBinary(c.photo.url).catch(() => null); - if (img && imageMime(img.buffer)) { store.savePhoto(person.id, img.buffer); person.photoVersion = 1; photos.push(person.id); } + // Generierte Initialen-Avatare zählen nicht als Foto, auch wenn Nextcloud sie als „eigen" meldet + if (img && imageMime(img.buffer) && !looksLikePlaceholder(img.buffer)) { store.savePhoto(person.id, img.buffer); person.photoVersion = 1; photos.push(person.id); } + else if (img) placeholders += 1; } store.save(); - res.json({ created: created.length, withPhoto: photos.length, skipped: skipped.length, persons: st.persons.map(publicPerson) }); + res.json({ created: created.length, withPhoto: photos.length, placeholders, skipped: skipped.length, persons: st.persons.map(publicPerson) }); } catch (err) { ncError(res, err); } }); diff --git a/test/image.test.js b/test/image.test.js new file mode 100644 index 0000000..5c9fd56 --- /dev/null +++ b/test/image.test.js @@ -0,0 +1,43 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import zlib from 'node:zlib'; +import { pngDominantShare, looksLikePlaceholder } from '../server/image.js'; + +/** Minimaler PNG-Encoder für Testbilder (RGB, 8 Bit, Filter 0). */ +function png(width, height, pixel) { + const raw = Buffer.alloc((width * 3 + 1) * height); + for (let y = 0; y < height; y += 1) { + raw[y * (width * 3 + 1)] = 0; + for (let x = 0; x < width; x += 1) { + const [r, g, b] = pixel(x, y); + raw.set([r, g, b], y * (width * 3 + 1) + 1 + x * 3); + } + } + const chunk = (type, body) => { + const len = Buffer.alloc(4); len.writeUInt32BE(body.length); + const crc = Buffer.alloc(4); crc.writeUInt32BE(zlib.crc32(Buffer.concat([Buffer.from(type), body])) >>> 0); + return Buffer.concat([len, Buffer.from(type), body, crc]); + }; + const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; ihdr[9] = 2; + return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]); +} + +test('Initialen-Platzhalter: eine Hintergrundfarbe mit Buchstaben wird erkannt', () => { + const img = png(64, 64, (x, y) => (x > 20 && x < 44 && y > 24 && y < 40 ? [40, 40, 40] : [230, 200, 150])); + const info = pngDominantShare(img); + assert.ok(info.share > 0.85, `share ${info.share}`); + assert.ok(looksLikePlaceholder(img)); +}); + +test('Foto mit Farbverlauf ist kein Platzhalter', () => { + const img = png(64, 64, (x, y) => [x * 4, y * 4, (x + y) * 2]); + const info = pngDominantShare(img); + assert.ok(info.share < 0.1, `share ${info.share}`); + assert.ok(!looksLikePlaceholder(img)); +}); + +test('JPEG und Unbekanntes werden nie als Platzhalter gewertet', () => { + assert.equal(looksLikePlaceholder(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0, 0])), false); + assert.equal(pngDominantShare(Buffer.from('kein png')), null); + assert.equal(looksLikePlaceholder(null), false); +});