Fotoupload im Personenformular ergänzen und Initialen-Platzhalter aussortieren
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
8a0f903a40
commit
9ed59bcf56
6 files changed
+295
-7
No files matched your search
+125
-5
@@ -474,7 +474,7 @@ function renderPersons() {
|
|||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
if (p.active === false) li.classList.add('inactive');
|
if (p.active === false) li.classList.add('inactive');
|
||||||
const av = avatarEl(p, 'plain');
|
const av = avatarEl(p, 'plain');
|
||||||
av.title = 'Foto hochladen (Klick)';
|
av.title = p.hasPhoto ? 'Foto ändern' : 'Foto hochladen';
|
||||||
av.tabIndex = 0;
|
av.tabIndex = 0;
|
||||||
av.setAttribute('role', 'button');
|
av.setAttribute('role', 'button');
|
||||||
av.setAttribute('aria-label', `Foto für ${fullName(p)} hochladen`);
|
av.setAttribute('aria-label', `Foto für ${fullName(p)} hochladen`);
|
||||||
@@ -501,6 +501,12 @@ function renderPersons() {
|
|||||||
edit.textContent = 'Bearbeiten';
|
edit.textContent = 'Bearbeiten';
|
||||||
edit.addEventListener('click', () => editPerson(p));
|
edit.addEventListener('click', () => editPerson(p));
|
||||||
li.appendChild(edit);
|
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) {
|
if (p.hasPhoto) {
|
||||||
const rm = document.createElement('button');
|
const rm = document.createElement('button');
|
||||||
rm.type = '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; }
|
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) {
|
function editPerson(p) {
|
||||||
const f = personForm.elements;
|
const f = personForm.elements;
|
||||||
f.id.value = p.id;
|
f.id.value = p.id;
|
||||||
@@ -534,6 +629,9 @@ function editPerson(p) {
|
|||||||
f.canCo.checked = Boolean(p.canCo);
|
f.canCo.checked = Boolean(p.canCo);
|
||||||
f.active.checked = p.active !== false;
|
f.active.checked = p.active !== false;
|
||||||
for (const cb of personForm.querySelectorAll('input[name="freeDays"]')) cb.checked = (p.freeDays || []).includes(Number(cb.value));
|
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-submit').textContent = 'Änderungen speichern';
|
||||||
$('#person-cancel').hidden = false;
|
$('#person-cancel').hidden = false;
|
||||||
personForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
personForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
@@ -542,6 +640,9 @@ function editPerson(p) {
|
|||||||
function resetPersonForm() {
|
function resetPersonForm() {
|
||||||
personForm.reset();
|
personForm.reset();
|
||||||
personForm.elements.id.value = '';
|
personForm.elements.id.value = '';
|
||||||
|
photoState.person = null;
|
||||||
|
photoState.remove = false;
|
||||||
|
setPendingPhoto(null);
|
||||||
$('#person-submit').textContent = 'Person anlegen';
|
$('#person-submit').textContent = 'Person anlegen';
|
||||||
$('#person-cancel').hidden = true;
|
$('#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 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 };
|
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 {
|
try {
|
||||||
if (f.id.value) await api(`api/persons/${f.id.value}`, { method: 'PUT', body });
|
const saved = f.id.value
|
||||||
else await api('api/persons', { method: 'POST', body });
|
? 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();
|
resetPersonForm();
|
||||||
await load();
|
await load();
|
||||||
toast('Gespeichert.');
|
toast('Gespeichert.');
|
||||||
@@ -626,17 +730,32 @@ contactsForm.addEventListener('submit', async (e) => {
|
|||||||
try {
|
try {
|
||||||
const res = await api('api/nextcloud/contacts/import', { method: 'POST', body: { uids, addressbook: contactsForm.elements.addressbook.value } });
|
const res = await api('api/nextcloud/contacts/import', { method: 'POST', body: { uids, addressbook: contactsForm.elements.addressbook.value } });
|
||||||
await load();
|
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);
|
await loadContacts(contactsForm.elements.addressbook.value);
|
||||||
} catch (err) { out.className = 'result error'; out.textContent = err.message; }
|
} catch (err) { out.className = 'result error'; out.textContent = err.message; }
|
||||||
btn.disabled = false;
|
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;
|
let photoTarget = null;
|
||||||
function choosePhoto(id) { photoTarget = id; $('#photo-input').value = ''; $('#photo-input').click(); }
|
function choosePhoto(id) { photoTarget = id; $('#photo-input').value = ''; $('#photo-input').click(); }
|
||||||
$('#photo-input').addEventListener('change', async (e) => {
|
$('#photo-input').addEventListener('change', async (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file || !photoTarget) return;
|
if (!file) return;
|
||||||
|
if (!photoTarget) { acceptPhotoFile(file); return; }
|
||||||
try {
|
try {
|
||||||
const blob = await squareJpeg(file, 320);
|
const blob = await squareJpeg(file, 320);
|
||||||
await api(`api/persons/${photoTarget}/photo`, { method: 'PUT', raw: { body: blob, type: 'image/jpeg' } });
|
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() {
|
function renderAll() {
|
||||||
renderCalendar();
|
renderCalendar();
|
||||||
renderPersons();
|
renderPersons();
|
||||||
|
renderPhotoField();
|
||||||
renderAbsences();
|
renderAbsences();
|
||||||
renderSettings();
|
renderSettings();
|
||||||
if (!$('#view-abwesenheiten').hidden) prepareAbsenceImport();
|
if (!$('#view-abwesenheiten').hidden) prepareAbsenceImport();
|
||||||
|
|||||||
@@ -64,6 +64,18 @@
|
|||||||
<label>Nachname <input name="lastName" required maxlength="60"></label>
|
<label>Nachname <input name="lastName" required maxlength="60"></label>
|
||||||
<label>E-Mail (optional, für Kalendereinladungen) <input name="email" type="email" maxlength="120"></label>
|
<label>E-Mail (optional, für Kalendereinladungen) <input name="email" type="email" maxlength="120"></label>
|
||||||
<label>Notiz / Grund (optional) <input name="note" maxlength="300" placeholder="z. B. Azubi, nur Nebenmoderation"></label>
|
<label>Notiz / Grund (optional) <input name="note" maxlength="300" placeholder="z. B. Azubi, nur Nebenmoderation"></label>
|
||||||
|
<div class="photo-field wide" id="photo-field" tabindex="0" aria-label="Foto: Datei ablegen oder mit Strg+V einfügen">
|
||||||
|
<span class="avatar plain photo-preview" id="photo-preview" aria-hidden="true"></span>
|
||||||
|
<div class="photo-text">
|
||||||
|
<strong>Foto</strong>
|
||||||
|
<span id="photo-status" class="hint">Kein Foto. Datei wählen, hierher ziehen oder mit Strg+V aus der Zwischenablage einfügen.</span>
|
||||||
|
<div class="photo-actions">
|
||||||
|
<button type="button" id="photo-choose">Datei wählen</button>
|
||||||
|
<button type="button" id="photo-paste" class="ghost">Aus Zwischenablage einfügen</button>
|
||||||
|
<button type="button" id="photo-clear" class="ghost" hidden>Foto entfernen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<fieldset class="checks">
|
<fieldset class="checks">
|
||||||
<legend>Rolle in der Rotation</legend>
|
<legend>Rolle in der Rotation</legend>
|
||||||
<label><input type="checkbox" name="canMain" checked> Hauptmoderation möglich</label>
|
<label><input type="checkbox" name="canMain" checked> Hauptmoderation möglich</label>
|
||||||
@@ -86,6 +98,10 @@
|
|||||||
<button type="button" id="open-contacts">Aus Nextcloud importieren …</button>
|
<button type="button" id="open-contacts">Aus Nextcloud importieren …</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<div class="list-tools">
|
||||||
|
<button type="button" class="ghost" id="remove-placeholders">Platzhalter-Fotos entfernen</button>
|
||||||
|
<span class="hint" id="placeholder-hint"></span>
|
||||||
|
</div>
|
||||||
<ul id="person-list" class="person-list"></ul>
|
<ul id="person-list" class="person-list"></ul>
|
||||||
<input type="file" id="photo-input" accept="image/*" hidden>
|
<input type="file" id="photo-input" accept="image/*" hidden>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -188,6 +188,21 @@ button:disabled { opacity: .45; cursor: default; }
|
|||||||
.month-nav h2 { min-width: 0; }
|
.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 */
|
/* 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, .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; }
|
.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; }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+26
-2
@@ -4,6 +4,7 @@ import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { loadEnvFile } from './env.js';
|
import { loadEnvFile } from './env.js';
|
||||||
import { createStore, imageMime } from './store.js';
|
import { createStore, imageMime } from './store.js';
|
||||||
|
import { looksLikePlaceholder } from './image.js';
|
||||||
import { workingDays, generateSchedule, isValidDate, countAssignments, addDays } from './scheduler.js';
|
import { workingDays, generateSchedule, isValidDate, countAssignments, addDays } from './scheduler.js';
|
||||||
import { germanHolidays, BUNDESLAENDER } from './holidays.js';
|
import { germanHolidays, BUNDESLAENDER } from './holidays.js';
|
||||||
import { buildEventLines, buildCalendar, eventUid } from './ics.js';
|
import { buildEventLines, buildCalendar, eventUid } from './ics.js';
|
||||||
@@ -178,6 +179,26 @@ app.delete('/api/persons/:id/photo', (req, res) => {
|
|||||||
res.status(204).end();
|
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) => {
|
app.get('/photos/:id', (req, res) => {
|
||||||
const id = String(req.params.id).replace(/[^a-f0-9-]/g, '');
|
const id = String(req.params.id).replace(/[^a-f0-9-]/g, '');
|
||||||
const buf = store.readPhoto(id);
|
const buf = store.readPhoto(id);
|
||||||
@@ -579,6 +600,7 @@ app.post('/api/nextcloud/contacts/import', async (req, res) => {
|
|||||||
const created = [];
|
const created = [];
|
||||||
const photos = [];
|
const photos = [];
|
||||||
const skipped = [];
|
const skipped = [];
|
||||||
|
let placeholders = 0;
|
||||||
for (const uid of uids) {
|
for (const uid of uids) {
|
||||||
const c = cards.find((x) => x.uid === uid);
|
const c = cards.find((x) => x.uid === uid);
|
||||||
if (!c || (!c.firstName && !c.lastName)) { skipped.push(uid); continue; }
|
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 (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 === '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 && 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();
|
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); }
|
} catch (err) { ncError(res, err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user