// ----------------------------- // Projektübersicht — allgemeine Helferfunktionen // ----------------------------- // Diese Datei bündelt kleine, allgemein verwendbare Hilfsfunktionen (Escape-, // ID-, Sortier- und Datumsfunktionen) und wird nach js/globals.js von beiden // HTML-Einstiegspunkten geladen. Sie stellt ausschließlich globale Funktionen // ohne eigenen Zustand bereit; genutzt wird sie praktisch von allen Modulen, // u. a. js/board/board.js, js/load/load.js sowie den Plan-Modulen unter js/plan/* // (z. B. für ISO-Datumsformate und Werktage) und von js/gantt.js. // esc: einfache Escape-Funktion für Ausgabe in HTML // Parameter: s (String). Rückgabe: HTML-sicherer String (&, <, >, ", ' ersetzt). // Das einfache Anführungszeichen MUSS mit maskiert werden: An mehreren Stellen // landet der Rückgabewert in einem einfach gequoteten JS-String innerhalb eines // onclick-Attributs (z. B. takeCommentFromHistory in js/board/board.js, // setPlanAssign in js/plan/plan-info.js). Ein Apostroph im Sitzungslabel oder // in einer Personen-Kennung brach dort sonst den Handler auf. function esc(s){ // String() statt (s||""): Leistungsphasen-Prozentwerte und andere Zahlen // kommen je nach Herkunft als Number an (Beispieldaten, ältere Datensätze), // und (100).replace ist keine Funktion — der Aufruf riss bisher das ganze // Rendern mit. 0 und false sollen als "0"/"false" erscheinen, nur null und // undefined werden zu "". return (s === null || s === undefined ? '' : String(s)) .replace(/&/g,'&').replace(//g,'>') .replace(/"/g,'"').replace(/'/g,'''); } // uid: generiert eine einfache, ausreichend eindeutige ID // Parameter: keine. Rückgabe: String aus Zeitstempel und Zufallsteil. function uid(){ return Date.now().toString(36)+Math.random().toString(36).slice(2,7); } // assignKey: Schlüssel für Projektzuordnungen (Person + Rolle getrennt) // Parameter: personId (String), role (String). Rückgabe: kombinierter Schlüssel 'personId|role'. function assignKey(personId, role){ return personId + '|' + role; } // sortGroups: sortiert Gruppen-Namen nach GROUP_ORDER, unbekannte ans Ende // Parameter: arr (Array von Gruppennamen, wird in-place sortiert). Rückgabe: dasselbe Array. function sortGroups(arr){ return arr.sort((a,b)=>{ const ia=GROUP_ORDER.indexOf(a), ib=GROUP_ORDER.indexOf(b); return (ia===-1?999:ia)-(ib===-1?999:ib); }); } // peopleByRole: gruppiert Personen nach Rolle zurück in ein Objekt. // Parameter `target`: Woche(n) mit `people`-Array (oder undefined → aktuelle // Woche). Rückgabe: Objekt `{ Rolle: [{person, role}] }` mit Deduplizierung. // Liegt im gemeinsamen Modul, weil board/load/pdf auf sie zugreifen. function peopleByRole(target){ const m = {}; ROLES.forEach(r=>m[r]=[]); const people = []; if(Array.isArray(target)){ target.forEach(w => { if(w && Array.isArray(w.people)) people.push(...w.people); }); } else if(target && Array.isArray(target.people)){ people.push(...target.people); } else if(Array.isArray(currentWeek()?.people)){ people.push(...currentWeek().people); } const seen = new Set(); people.forEach(p=>{ const personRoles = Array.isArray(p.role) ? p.role : [p.role]; personRoles.forEach(role=>{ if(!m[role]) m[role]=[]; const key = `${p.id}|${role}`; if(!seen.has(key)){ seen.add(key); m[role].push({person:p, role}); } }); }); return m; } // weekSortKey: liefert einen nach Jahr und Kalenderwoche sortierbaren Schlüssel, // sodass z.B. "KW 9 / 2026" vor "KW 10 / 2026" kommt. // Parameter: label (String). Rückgabe: String (Jahr-KW) oder kleingeschriebenes Label. function weekSortKey(label){ const m = String(label || '').match(/KW\s*(\d+)[^\d]+(\d{4})/i); if(m){ const kw = m[1].padStart(2,'0'); return `${m[2]}-${kw}`; } return String(label || '').toLowerCase(); } // Persistente ID fuer eine Person aus dem Kuerzel, damit Anwesenheit und // Zuordnungen auch nach einem Neuladen stabil bleiben. // Parameter: name (String). Rückgabe: String wie 'p_ab'. // beteiligteJePerson: fasst die Zuordnungen eines Projekts ({person, role, // state}; state 1 = Haupt, 2 = Unterstützung) zu EINEM Eintrag je Person // zusammen — wer Planung und Projektleitung macht, erscheint im // Avatar-Stapel nur einmal (Eriks Wunsch). Die Farbe folgt der wichtigsten // Rolle (Haupt vor Unterstützung, dann Reihenfolge ROLES), der Tooltip nennt // alle. Rückgabe: [{person, role, state, title}] in der Reihenfolge des // ersten Auftretens. function beteiligteJePerson(eintraege){ const jePerson = new Map(); (eintraege || []).forEach(e => { if(!e || !e.person || !(e.state > 0)) return; const key = e.person.id; if(!jePerson.has(key)) jePerson.set(key, { person: e.person, rollen: [] }); jePerson.get(key).rollen.push({ role: e.role, state: Number(e.state) }); }); const rang = r => (r.state === 1 ? 0 : 10) + Math.max(0, ROLES.indexOf(r.role)); return Array.from(jePerson.values()).map(({ person, rollen }) => { const haupt = rollen.slice().sort((a, b) => rang(a) - rang(b))[0]; const liste = rollen.slice().sort((a, b) => ROLES.indexOf(a.role) - ROLES.indexOf(b.role)) .map(r => r.role + (r.state === 2 ? ' (Unterstützung)' : '')).join(', '); const sortiert = rollen.slice().sort((a, b) => ROLES.indexOf(a.role) - ROLES.indexOf(b.role)); return { person, role: haupt.role, state: haupt.state, rollen: sortiert, title: (person.fullName || person.name) + ' · ' + liste }; }); } // ROLLEN_FARBE: dieselben Farben wie .board-avatar.role-* in styles.css. const ROLLEN_FARBE = { 'Planung': 'var(--green)', 'Projektleitung': 'var(--red)', 'Bauüberwachung': 'var(--accent)' }; // beteiligtenAvatarHtml: Kürzel-Kreis eines Eintrags aus beteiligteJePerson(). // Bei mehreren Rollen (Eriks Wunsch) ist der Kreis in Segmente geteilt — je // Rolle eines in ihrer Farbe. Parameter: b, klasse // (Rollen-Klasse, z. B. roleClass(b.role)). Rückgabe: HTML-String. function beteiligtenAvatarHtml(b, klasse){ const rollen = (b.rollen || []).filter(r => ROLLEN_FARBE[r.role]); if(rollen.length < 2){ return `${esc(b.person.name)}`; } const teil = 100 / rollen.length; const stopps = rollen.map((r, i) => { // Immer die volle Rollenfarbe — eine blassere Abstufung für // Unterstützung wurde im Dunkelmodus zu einem eigenen, dunklen Ton. // Ob Haupt oder Unterstützung, steht im Tooltip. const farbe = ROLLEN_FARBE[r.role]; return `${farbe} ${(i * teil).toFixed(2)}% ${((i + 1) * teil).toFixed(2)}%`; }).join(', '); return `${esc(b.person.name)}`; } function personIdForName(name){ return 'p_' + String(name).trim().toLowerCase().replace(/[^a-z0-9]/g,'_'); } // Schlägt aus einem Namen ein kurzes Kürzel vor (für schmale Board-Spalten- // köpfe, 2-4 Zeichen). Bei mehreren durch Leerzeichen getrennten Wörtern die // Anfangsbuchstaben der ersten vier Wörter, bei einem einzelnen Wort dessen // erste drei Buchstaben. Nur ein Vorschlag — bleibt manuell überschreibbar // (siehe personIdManuallyEdited-Muster in admin-users.js). // Parameter: name (String). Rückgabe: String wie 'MM' oder ''. function initialsForName(name){ const words = String(name || '').trim().split(/\s+/).filter(Boolean); if(words.length === 0) return ''; if(words.length === 1) return words[0].slice(0, 3).toUpperCase(); return words.slice(0, 4).map(w => w[0]).join('').toUpperCase(); } // EMAIL_DOMAIN: Domain der Firmen-Adressen, Grundlage von emailVorschlag(). const EMAIL_DOMAIN = 'rauschenberg-ing.de'; // emailVorschlag: schlägt aus einem Namen die Firmen-Adresse nach dem Schema // „Anfangsbuchstabe Vorname . Nachname @rauschenberg-ing.de" vor, klein // geschrieben, Umlaute umschrieben (ä→ae, ß→ss), übrige Sonderzeichen // entfernt: "Miriam Körner" → "m.koerner@rauschenberg-ing.de". Vorname ist // das erste, Nachname das letzte Wort. Nur ein Vorschlag — bleibt // überschreibbar. Parameter: name (String) — oder, wenn `nachname` gegeben // ist, Vorname und Nachname getrennt (dann zählt der ganze Nachname, z. B. // "von Plum" → "vonplum"). Rückgabe: Adresse oder ''. function emailVorschlag(name, nachname){ if(nachname !== undefined){ const v = String(name || '').trim(), n = String(nachname || '').trim(); if(!v || !n) return emailVorschlag((v + ' ' + n).trim()); const l = emailVorschlag(v.split(/\s+/)[0] + ' ' + n.replace(/\s+/g, '')); return l; } const teil = s => String(s).toLowerCase() .replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss') .normalize('NFD').replace(/[\u0300-\u036f]/g,'') .replace(/[^a-z0-9-]/g,''); const woerter = String(name || '').trim().split(/\s+/).map(teil).filter(Boolean); if(woerter.length === 0) return ''; const lokal = woerter.length === 1 ? woerter[0] : woerter[0][0] + '.' + woerter[woerter.length - 1]; return lokal + '@' + EMAIL_DOMAIN; } // benutzernameVorschlag: Benutzername eines neuen Kontos = Anfang der // E-Mail-Adresse, also "Anfangsbuchstabe Vorname . Nachname" in der // Schreibweise von emailVorschlag() ("Miriam Körner" → "m.koerner"). // Angemeldet wird mit genau diesem Namen. Rückgabe: String oder ''. function benutzernameVorschlag(vorname, nachname){ const adresse = emailVorschlag(vorname, nachname); return adresse ? adresse.slice(0, adresse.indexOf('@')) : ''; } // grossAnfang: erster Buchstabe groß, Leerraum zusammengefasst — für Vor- // und Nachnamen beim Speichern ("miriam" → "Miriam"). function grossAnfang(s){ s = String(s || '').trim().replace(/\s+/g, ' '); return s ? s.charAt(0).toUpperCase() + s.slice(1) : ''; } // formatWeekDate: wandelt ein ISO-Datum (YYYY-MM-DD) in deutsches Format DD.MM.YYYY um // Parameter: dateString (String). Rückgabe: formatierter String oder leer. function formatWeekDate(dateString){ if(!dateString) return ''; const parts = dateString.split('-'); if(parts.length!==3) return dateString; return `${parts[2]}.${parts[1]}.${parts[0]}`; } // ---------- Datumshilfsfunktionen (GANTT) ---------- // parseDateISO: parst einen ISO-Datumsstring (YYYY-MM-DD) in ein Date-Objekt (lokal) // Parameter: d (String). Rückgabe: Date-Objekt oder null bei ungültigem Format. function parseDateISO(d){ if(!d) return null; const m = String(d).match(/^(\d{4})-(\d{2})-(\d{2})$/); if(!m) return null; const date = new Date(parseInt(m[1]), parseInt(m[2])-1, parseInt(m[3])); if(isNaN(date.getTime())) return null; return date; } // formatDateISO: formatiert ein Date-Objekt als ISO-Datum (YYYY-MM-DD) // Parameter: d (Date). Rückgabe: String oder leer bei ungültigem Datum. function formatDateISO(d){ if(!d || isNaN(d.getTime())) return ''; const y = d.getFullYear(); const m = String(d.getMonth()+1).padStart(2,'0'); const day = String(d.getDate()).padStart(2,'0'); return `${y}-${m}-${day}`; } // addDays: addiert n Tage zu einem Datum (ohne das Original zu verändern) // Parameter: d (Date), n (Anzahl Tage). Rückgabe: neues Date-Objekt. function addDays(d, n){ const res = new Date(d.getTime()); res.setDate(res.getDate() + n); return res; } // heuteIso: das heutige Datum als ISO-Zeichenkette (YYYY-MM-TT) in ORTSZEIT. // // Nicht new Date().toISOString().slice(0,10): Das rechnet nach UTC um. In // deutscher Sommerzeit (UTC+2) ist damit zwischen 00:00 und 02:00 Ortszeit // noch der Vortag gemeint — ein Termin von heute galt in diesem Zeitfenster // als überfällig. Rückgabe: String. function heuteIso(){ const d = new Date(); const m = String(d.getMonth() + 1).padStart(2, '0'); const t = String(d.getDate()).padStart(2, '0'); return d.getFullYear() + '-' + m + '-' + t; } // isoPlusTage: Datum in n Tagen als ISO-Zeichenkette in Ortszeit (siehe // heuteIso zur Begründung). Parameter: n (ganze Zahl, auch negativ). // Rückgabe: String. function isoPlusTage(n){ const d = new Date(); d.setDate(d.getDate() + (Number(n) || 0)); const m = String(d.getMonth() + 1).padStart(2, '0'); const t = String(d.getDate()).padStart(2, '0'); return d.getFullYear() + '-' + m + '-' + t; } // daysBetween: Anzahl der Kalendertage zwischen zwei Daten // Parameter: a, b (Date). Rückgabe: ganzzahlige Tagesdifferenz (b - a). function daysBetween(a, b){ const ms = b.getTime() - a.getTime(); return Math.round(ms / (1000 * 60 * 60 * 24)); } // isWorkday: prüft, ob ein Datum ein Arbeitstag ist — kein Wochenende und // kein gepflegter Feiertag (HOLIDAYS in js/globals.js, gefüllt aus // GET /holidays). Vor dem Ausbau der Einstellungen kannte die Anwendung nur // Wochenenden; ein Terminplan über Weihnachten fiel damit um mehrere Tage zu // kurz aus. Parameter: d (Date). Rückgabe: Boolean. function isWorkday(d){ if(!d) return false; const day = d.getDay(); if(day === 0 || day === 6) return false; return !(typeof HOLIDAYS !== 'undefined' && HOLIDAYS.has(formatDateISO(d))); } // workdaysBetween: Anzahl der Arbeitstage im Zeitraum inkl. Start- und Endtag. // Wochenenden und Feiertage werden nicht mitgezählt – z.B. für die Auslastung. // Parameter: a, b (Date). Rückgabe: Anzahl der Arbeitstage oder 0 bei ungültigem Zeitraum. function workdaysBetween(a, b){ if(!a || !b || b < a) return 0; let count = 0; let current = new Date(a.getTime()); while(current <= b){ if(isWorkday(current)) count++; current = addDays(current, 1); } return count; } // addWorkdays: liefert das Datum, das `n` Arbeitstage nach `d` liegt. // n = 0 ergibt `d` selbst, sofern das ein Arbeitstag ist — fällt `d` auf ein // Wochenende oder einen Feiertag, wird auf den nächsten Arbeitstag vorgerückt. // Gegenstück zu workdaysBetween(); gebraucht von der Honorar-Terminplanung // (js/plan/plan-honorar.js), die Phasen nahtlos in Arbeitstagen aneinanderreiht. // Die Obergrenze der Schleifen ist eine Reißleine: Wären versehentlich alle // Tage als Feiertag gepflegt, liefe sie sonst endlos. // Parameter: d (Date), n (Anzahl Arbeitstage, >= 0). Rückgabe: Date. function addWorkdays(d, n){ if(!d) return null; let current = new Date(d.getTime()); let guard = 0; // Startdatum selbst auf einen Arbeitstag schieben while(!isWorkday(current) && guard++ < 400) current = addDays(current, 1); let remaining = Math.max(0, Math.floor(n)); guard = 0; while(remaining > 0 && guard++ < 100000){ current = addDays(current, 1); if(isWorkday(current)) remaining--; } return current; } // formatDateDE: formatiert ein Date-Objekt als deutsches Datum (DD.MM.YYYY) // Parameter: d (Date). Rückgabe: String oder leer bei ungültigem Datum. function formatDateDE(d){ if(!d || isNaN(d.getTime())) return ''; return `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}.${d.getFullYear()}`; } // ISO-Kalenderwoche nach https://en.wikipedia.org/wiki/ISO_week_date // Parameter: d (Date). Rückgabe: Kalenderwochennummer (1-53). function getWeekNumber(d){ const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); const dayNum = date.getUTCDay() || 7; date.setUTCDate(date.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); return Math.ceil((((date - yearStart) / 86400000) + 1) / 7); } // ---------- Status-Editor (Board & Projektplan) ---------- // Der contenteditable Status-/Kommentar-Editor (kleine Formatierungs-Toolbar: // Fett, Farben, Absätze) wird sowohl im Projektboard (js/board/board.js, // Detailpanel) als auch auf der Projektplan-Seite (js/plan/plan-info.js) // verwendet — deshalb liegen die reinen Hilfsfunktionen hier im gemeinsamen // Modul. Nur der // Plan-Gegenpart (planStatusBlur() in plan-info.js) bleiben modul-eigen, da // sie unterschiedliche Speicherpfade ansteuern. // currentStatusEditor: aktuell fokussiertes contenteditable-Element des // Status-Editors; wird für die Formatierungs-Toolbar (`formatStatus`) benötigt. let currentStatusEditor = null; // currentStatusPid: Projekt-ID der gerade fokussierten Status-Zelle (nur vom // Board genutzt, siehe statusBlur() dort); auf der Projektplan-Seite bleibt // sie ungenutzt (dort gibt es nur einen Status-Editor pro Seite). let currentStatusPid = null; // statusFocus: merkt sich das fokussierte Status-Element und die zugehörige // Projekt-ID (`el`, `pid`), damit die Toolbar und das Blur-Speichern sie nutzen. function statusFocus(el, pid){ currentStatusEditor = el; currentStatusPid = pid; } // ---------- Kommentar und "besprochen" je Gewerk ---------- // // ELT, HLS und ARCH halten ihre Besprechung getrennt (Eriks Wunsch: "Die // Kommentare und Anwesenheiten von ELT brauchen nicht in HLS oder Arch zu // sein" — und "auch wenn das Projekt in ELT besprochen ist, heißt das nicht, // dass HLS das Projekt auch besprochen hat"). Ein Projekt mit mehreren // Gewerken trägt deshalb je Gewerk einen eigenen Sitzungskommentar samt // Verfasser/Zeitstempel und einen eigenen Besprochen-Haken: // p.kommentare = { ELT: { status, statusAuthor, statusAt, besprochen }, … } // (Tabelle project_kommentare, server.js). Die früheren Felder // p.status/statusAuthor/statusAt/besprochen werden nicht mehr gelesen oder // geschrieben — alles läuft über die drei Helfer unten. // kommentarGewerk: das Gewerk, in dessen Zusammenhang gerade gelesen bzw. // geschrieben wird — im Wochenbericht das dort gewählte, überall sonst null // (= alle Gewerke zusammen, nur zur Anzeige). Keine Parameter. function kommentarGewerk(){ if(typeof currentTab !== 'undefined' && currentTab === 'wochenbericht' && typeof wochenberichtGewerk !== 'undefined' && wochenberichtGewerk) return wochenberichtGewerk; return null; } // kommentarVon: der Kommentar eines Projekts für ein Gewerk. Ohne Gewerk die // gewerkübergreifende Anzeige: die Kommentare aller Gewerke untereinander, // jeweils mit dem Gewerk davor (bei nur einem Gewerk ohne Beschriftung), // Verfasser/Zeit des jüngsten, besprochen nur, wenn ALLE Gewerke des // Projekts es abgehakt haben. Parameter: p (Projekt), g (Gewerk oder // null/undefined). Rückgabe: { status, statusAuthor, statusAt, besprochen }. // kommentareDesProjekts: die Kommentare nur der Gewerke, die das Projekt hat. // Ein Kommentar eines Gewerks, das dem Projekt nicht (mehr) gehört — etwa aus // einer Zeit, als es noch HLS hatte —, gehört nicht in die Gesamtanzeige // (Eriks Meldung zu 21-4030). Ohne gepflegte Gewerke: alle. Rückgabe: Objekt. function kommentareDesProjekts(p){ const alle = (p && p.kommentare) || {}; const gewerke = Array.isArray(p && p.gewerke) ? p.gewerke : []; if(!gewerke.length) return alle; const aus = {}; Object.keys(alle).forEach(k => { if(gewerke.includes(k)) aus[k] = alle[k]; }); return aus; } function kommentarVon(p, g){ const alle = g ? ((p && p.kommentare) || {}) : kommentareDesProjekts(p); if(g){ const e = alle[g] || {}; return { status: e.status || '', statusAuthor: e.statusAuthor || '', statusAt: e.statusAt || '', besprochen: !!e.besprochen }; } const reihenfolge = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH']; Object.keys(alle).forEach(k => { if(!reihenfolge.includes(k)) reihenfolge.push(k); }); const mitText = reihenfolge.filter(k => alle[k] && statusPlainText(alle[k].status) !== ''); const gewerke = Array.isArray(p && p.gewerke) ? p.gewerke : []; let status = ''; if(mitText.length === 1 && gewerke.length <= 1) status = alle[mitText[0]].status; // Jeder Kommentar in einem eigenen Block: Die Klartext-Vorschau // (statusPlainText/statusPreviewText) trennt nur an Blockgrenzen — ohne // eigenen Block klebten die Gewerke dort aneinander ("…fettHLS: …"). else status = mitText.map(k => `
${esc(k)}:
${alle[k].status}
`).join(''); const juengster = mitText.map(k => alle[k]).sort((a, b) => String(b.statusAt || '').localeCompare(String(a.statusAt || '')))[0] || {}; const pruefen = gewerke.length ? gewerke : Object.keys(alle); const besprochen = pruefen.length > 0 && pruefen.every(k => alle[k] && alle[k].besprochen); return { status, statusAuthor: juengster.statusAuthor || '', statusAt: juengster.statusAt || '', besprochen }; } // kommentarAnzeigeHtml: der Kommentar zur ANZEIGE, bereinigt. Mit Gewerk g // nur dessen Text; ohne Gewerk die Texte aller Gewerke des Projekts. Bei // mehreren Gewerken Darstellung „B – Überschrift mit Linie“ (Eriks Wahl): // je Gewerk eine Kopfzeile mit dem Gewerk in seiner Farbe, einer feinen Linie // und (mitMeta) Verfasser und Zeit, darunter der Text — Systemtext, nicht // Teil des Kommentars (anders als die fette "ELT:"-Zeile, die kommentarVon() // für Klartext-Vorschauen einfügt). Parameter: p, g, mitMeta. // Rückgabe: HTML ('' ohne Text). function kommentarAnzeigeHtml(p, g, mitMeta){ if(g) return sanitizeStatusHtml(kommentarVon(p, g).status || ''); const alle = kommentareDesProjekts(p); const reihenfolge = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH']; Object.keys(alle).forEach(k => { if(!reihenfolge.includes(k)) reihenfolge.push(k); }); const mitText = reihenfolge.filter(k => alle[k] && statusPlainText(alle[k].status) !== ''); if(mitText.length === 0) return ''; if(mitText.length === 1) return sanitizeStatusHtml(alle[mitText[0]].status); return '
' + mitText.map(k => { const e = alle[k]; const zeit = e.statusAt && typeof formatCommentStamp === 'function' ? formatCommentStamp(e.statusAt) : ''; const meta = mitMeta ? [e.statusAuthor, zeit].filter(Boolean).join(' · ') : ''; return '
' + '
' + esc(k) + '' + '' + (meta ? '' + esc(meta) + '' : '') + '
' + '
' + sanitizeStatusHtml(e.status) + '
'; }).join('') + '
'; } // kommentarGewerkeMitText: wie viele Gewerke des Projekts einen Kommentar // haben (für die Frage, ob eine gemeinsame Verfasserzeile Sinn ergibt). function kommentarGewerkeMitText(p){ const alle = kommentareDesProjekts(p); return Object.keys(alle).filter(k => alle[k] && statusPlainText(alle[k].status) !== '').length; } // kommentarSetzen: ändert den Kommentar-Eintrag eines Gewerks (nur die in // patch genannten Felder). Parameter: p (Projekt), g (Gewerk), patch // (Objekt). Keine Rückgabe. function kommentarSetzen(p, g, patch){ if(!p || !g) return; p.kommentare = p.kommentare || {}; p.kommentare[g] = Object.assign(kommentarVon(p, g), patch || {}); } // personGewerk: das Gewerk einer Person aus dem Teilnehmerkreis einer // Sitzung. week.people kennt es selbst nicht; es kommt aus dem Konto // (db.globalPeople, users.department) — gesucht über die Kennung, ersatzweise // über den vollen Namen (Altbestand mit abweichender Kennung, siehe // kapazitaetsFaktor()). Parameter: person. Rückgabe: Gewerk oder ''. function personGewerk(person){ const liste = (typeof db !== 'undefined' && Array.isArray(db.globalPeople)) ? db.globalPeople : []; const treffer = liste.find(x => x.id === person.id) || (person.fullName ? liste.find(x => x.fullName === person.fullName) : null); return (treffer && treffer.gewerk) || ''; } // personenDesGewerks: der Teilnehmerkreis einer Sitzung, eingeschränkt auf // ein Gewerk — für die Anwesenheit, die je Gewerk getrennt geführt wird. // Personen ohne bekanntes Gewerk bleiben überall sichtbar, damit niemand // unauffindbar wird. Ohne Gewerk alle. Parameter: w (Woche), g. // Rückgabe: Array. function personenDesGewerks(w, g){ const leute = Array.isArray(w && w.people) ? w.people : []; if(!g) return leute; return leute.filter(person => { const pg = personGewerk(person); return !pg || pg === g; }); } // statusValueFromHtml: normalisiert den HTML-Inhalt eines Status-Editors. // Liefert bei reinem Leerraum '' zurück, sonst den unveränderten HTML-String // (Formatierung bleibt erhalten). Parameter: `html`, Rückgabe: String. function statusValueFromHtml(html){ const tmp = document.createElement('div'); tmp.innerHTML = html; const text = (tmp.textContent || tmp.innerText || '').replace(/ /g, ' '); return text.trim() === '' ? '' : html; } // formatStatus: wendet einen document.execCommand-Befehl (z.B. 'bold', // 'foreColor') auf das fokussierte Status-Element an. Parameter: `command`, // optionaler `value`; keine Rückgabe. function formatStatus(command, value){ if(!currentStatusEditor) return; currentStatusEditor.focus(); document.execCommand(command, false, value || null); } // sanitizeStatusHtml: bereinigt übergebenen Status-HTML. Erlaubt nur eine kleine // Whitelist an HTML-Tags/Attributen (Formatierung), damit keine Skripte // eingeschleust werden können; im style-Attribut sind nur color/font-weight // erlaubt. Parameter: `html` (String). Rückgabe: bereinigter HTML-String ('' bei // leerem Eingabewert). // // DOMParser statt `div.innerHTML = html` — das ist der GANZE Sicherheitsgewinn // dieser Funktion, nicht bloß Geschmackssache: innerHTML lädt eingebettete // Ressourcen und führt deren Fehler-Handler aus, AUCH wenn das Element gar // nicht im Dokument hängt. Ein `` lief damit schon // WÄHREND des Bereinigens — also auf jedem Anzeigepfad, der hier // durchkommt, obwohl das Bild danach korrekt entfernt wurde. Der Server // speichert Kommentare und Protokolltexte unverändert, ein angemeldetes // Konto konnte solches HTML also über die API hinterlegen und damit Code in // der Sitzung aller anderen ausführen. parseFromString() erzeugt dagegen ein // inertes Dokument: kein Laden, kein Handler. Nachgemessen — mit innerHTML // feuerte der Handler, mit DOMParser nicht, bei identischem Ergebnisbaum. // fontFarbeErlaubt: nur echte Hex-Farben, und keine Grau-/Schwarz-/Weißtöne — // eingefügter Text bringt oft color="#1a1a1a" mit, das im Dunkelmodus // unsichtbar wäre und ohnehin nichts hervorhebt. Rückgabe: Boolean. function fontFarbeErlaubt(wert){ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(wert || '').trim()); if(!m) return false; const h = m[1].length === 3 ? m[1].replace(/./g, c => c + c) : m[1]; const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16); return Math.max(r, g, b) - Math.min(r, g, b) > 40; } function sanitizeStatusHtml(html){ if(!html) return ''; // Die Bereinigung läuft vollständig IM inerten Dokument ab: Würden die // Knoten vorher in ein Element des Hauptdokuments übernommen, zöge // appendChild() ein adoptNode() nach sich — und ein dabei übernommenes // begänne wieder zu laden, also genau das, was hier // verhindert werden soll. const inert = new DOMParser().parseFromString(String(html), 'text/html'); const div = inert.body; const allowedTags = new Set(['B','STRONG','I','EM','U','P','BR','DIV','SPAN','FONT']); const allowedAttrs = new Set(['style']); // Elemente, deren INHALT mit verschwindet, statt ausgepackt zu werden: // Bei