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