// ----------------------------- // Projektübersicht — CSV-Serialisierung & Parsing // Hinweis: weekToCsv() und parseSessionCsv() sind invers zueinander. // Das Spaltenlayout darf nicht geändert werden, ohne beide Funktionen anzupassen. // ----------------------------- // Diese Datei enthält das gesamte CSV-Handling (Serialisieren und Parsen) und // wird nach js/date-picker.js von beiden HTML-Einstiegspunkten geladen. Sie // stellt die globalen Funktionen weekToCsv/parseSessionCsv, das Personen-CSV // (globalPeopleToCsv/parseGlobalPeopleCsv) sowie Helfer wie weekSortKey und // decodeWeekLabel bereit. Hauptnutzer sind js/store.js (Laden/Speichern aller // Sitzungen), js/weeks.js (Wochen-Klonen) und js/plan/plan-state.js (savePlan), // die die CSV-Zeichenketten über js/server.js austauschen. // filenameForWeek: leitet aus dem Wochenlabel einen Dateinamen ab // (alle Nicht-Wort-Zeichen werden durch _ ersetzt) und hängt .csv an. // Parameter: label (String). Rückgabe: String wie 'KW_30___2026.csv'. function filenameForWeek(label){ return (label.replace(/[^\w]/g,'_'))+'.csv'; } // csvEscape: maskiert ein CSV-Feld, falls es Sonderzeichen (;, ", Zeilenumbruch) enthält // Parameter: v (beliebiger Wert). Rückgabe: String, bei Bedarf in Anführungszeichen. function csvEscape(v){ const s = (v === null || v === undefined ? '' : String(v)); if(/[;"\n\r]/.test(s)) return '"' + s.replace(/"/g,'""') + '"'; return s; } // globalPeopleToCsv: serialisiert die globalen Personen (aller Abteilungen) als CSV-Text // Parameter: people (Array von Person-Objekten). Rückgabe: UTF-8-BOM-CSV-String. function globalPeopleToCsv(people){ const rows = [['NAME', 'Rollen', 'Voller Name', 'E-Mail', 'Abwesenheiten']]; people.slice().sort((a,b) => a.name.localeCompare(b.name)).forEach(p => { const absences = Array.isArray(p.absences) ? JSON.stringify(p.absences) : ''; rows.push([p.name, (p.role || []).join(','), p.fullName || '', p.email || '', absences]); }); return '\ufeff' + rows.map(r => r.map(csvEscape).join(';')).join('\n'); } // parseGlobalPeopleCsv: parst den _people.csv-Inhalt zurück in Person-Objekte // Parameter: text (String). Rückgabe: Array von Person-Objekten (mit stabiler ID). function parseGlobalPeopleCsv(text){ if(!text) return []; const rows = parseCsvText(text); if(!rows.length) return []; const people = []; rows.slice(1).forEach(row => { const name = (row[0] || '').trim(); if(!name) return; const roles = String(row[1] || 'Planung').split(',').map(x => x.trim()).filter(Boolean); const fullName = (row[2] || '').trim() || name; const email = (row[3] || '').trim(); let absences = []; if(row[4]){ try { const parsed = JSON.parse(row[4]); if(Array.isArray(parsed)) absences = parsed; } catch(e) { // Ignoriere fehlerhafte JSON-Daten } } people.push({ id: personIdForName(name), name, fullName, email, role: roles, absences }); }); return people; } // weekToCsv: serialisiert eine komplette Sitzung (Woche) in das feste CSV-Spaltenlayout // Parameter: w (Wochen-Objekt mit label, date, locked, people, attendance, projects). // Rückgabe: UTF-8-BOM-CSV-String. Invers zu parseSessionCsv(). function weekToCsv(w){ // allPeople enthält für das Board jede (Person, Rolle)-Kombination einmal. const grouped = peopleByRole(w); const allPeople = []; ROLES.forEach(r => grouped[r].forEach(entry => allPeople.push(entry))); const rows = []; // Metadaten-Zeile: Label, Datum und Sperrstatus der Sitzung rows.push(['WOCHE', w.label || '', w.date || '', w.locked ? '1' : '']); // PERSONEN-Zeile: Pro Spalte Name|Rolle|Voller Name, damit beim Laden // die Zuordnung pro Rolle eindeutig bleibt und der volle Name erhalten ist. // Der volle Name wird immer als dritter Teil gespeichert (auch leer), // damit er beim Bearbeiten nicht versehentlich verloren geht. const peopleMeta = allPeople.map(e => `${e.person.name}|${e.role}|${e.person.fullName || ''}`); rows.push(['PERSONEN', '', '', ...peopleMeta, ...LPHS.map(() => ''), '','','','','']); rows.push(['Pr.-Nr.','Bezeichnung','Status', ...allPeople.map(e => e.person.name), ...LPHS, 'Gruppe','Index','Plan','Aufwand','Aktive Phasen']); rows.push(['ANWESENHEIT','', ...allPeople.map(e => w.attendance && w.attendance[e.person.id] ? 'X' : ''), ...LPHS.map(() => ''),'','','','','']); w.projects.forEach(p => { const arr = [p.nr || '', p.name || '', p.status || '']; allPeople.forEach(entry => { const s = p.assign[assignKey(entry.person.id, entry.role)]; arr.push(s === 1 ? 'X' : s === 2 ? '(X)' : ''); }); LPHS.forEach(l => { const v = p.lph[l]; arr.push(v !== undefined && v !== '' ? String(v).replace(/%/g,'') + '%' : ''); }); arr.push(p.group || ''); arr.push(p.order !== undefined ? String(p.order) : ''); arr.push(p.plan ? JSON.stringify(p.plan) : ''); arr.push(p.aufwandProLP ? JSON.stringify(p.aufwandProLP) : ''); arr.push(p.aktuelleLP ? JSON.stringify(p.aktuelleLP) : ''); rows.push(arr); }); return '\ufeff' + rows.map(r => r.map(csvEscape).join(';')).join('\n'); } // parseCsvText: entfernt ein (mehrfach kodiertes) BOM und zerlegt den Text in Zeilen-Arrays // Parameter: text (String). Rückgabe: Array aus Zeilen, jede ein Feld-Array. function parseCsvText(text){ // Manche ältere CSV-Dateien enthalten ein UTF-8-BOM gefolgt von einem // literalen "" (dreifach kodiert). Beides am Anfang entfernen. return text.replace(/^[\ufeff\u00EF\u00BB\u00BF]+/,'').split(/\r?\n/).filter(line => line.trim() !== '').map(parseCsvLine); } // Toleranter CSV-Zeilenparser. Versteht sauber gequotetes CSV (wie es // csvEscape/weekToCsv erzeugen). Semikolons innerhalb eines quoted Feldes sind // Feldinhalt und kein Trennzeichen – so bleibt weekToCsv()/parseSessionCsv() // auch bei Status-Texten, die mit ";" enden, invers. // parseCsvLine: zerlegt eine einzelne CSV-Zeile in ein Feld-Array // Parameter: line (String). Rückgabe: Array der Felder. function parseCsvLine(line){ const result = []; let current = ''; let inQuotes = false; let hasUnclosedQuotes = false; let i = 0; while(i < line.length){ const c = line[i]; const n = line[i+1]; if(inQuotes){ if(c === '"' && n === '"'){ current += '"'; i += 2; } else if(c === '"' && (n === ';' || n === undefined)){ result.push(current); current = ''; inQuotes = false; i += (n === ';' ? 2 : 1); } else { current += c; i++; } } else { if(c === ';'){ result.push(current); current = ''; i++; } else if(c === '"'){ inQuotes = true; i++; } else { current += c; i++; } } } if(inQuotes) hasUnclosedQuotes = true; result.push(current); if(hasUnclosedQuotes){ console.warn('CSV-Zeile enthält nicht geschlossene Anführungszeichen:', line.slice(0,80)); } return result; } // decodeWeekLabel: rekonstruiert das Wochenlabel aus einem Dateinamen // (ersetzt _ durch Leerzeichen und ergänzt das Layout "KW XX / YYYY"). // Parameter: filename (String). Rückgabe: Label-String. function decodeWeekLabel(filename){ const base = filename.replace(/\.csv$/i,''); let label = base.replace(/_+/g,' ').trim(); if(/^KW \d+ \d+$/.test(label)){ label = label.replace(/^KW (\d+) (\d+)$/, 'KW $1 / $2'); } return label || base; } // Erzeugt einen Sortierschlüssel aus einem Wochenlabel, sodass z.B. // "KW 9 / 2026" vor "KW 10 / 2026" kommt. // weekSortKey: liefert einen nach Jahr und Kalenderwoche sortierbaren Schlüssel // 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(); } // parseSessionCsv: parst die CSV-Zeilen einer Sitzung zurück in ein Wochen-Objekt // Parameter: filename (String), rows (Array von Feld-Arrays aus parseCsvText). // Rückgabe: Wochen-Objekt {id, label, date, people, projects, attendance, locked} // oder null bei leerer Eingabe. Invers zu weekToCsv(). function parseSessionCsv(filename, rows){ if(!rows || rows.length === 0) return null; let label, date, headerRowIdx = 0, attendanceRowIdx = 1, projectStartIdx = 2; let locked = false; let metaPeopleRow = null; if(rows[0][0] === 'WOCHE'){ label = rows[0][1] || decodeWeekLabel(filename); date = rows[0][2] || ''; locked = (rows[0][3] || '').trim() === '1'; if(rows[1] && rows[1][0] === 'PERSONEN'){ metaPeopleRow = rows[1]; headerRowIdx = 2; attendanceRowIdx = 3; } else { headerRowIdx = 1; attendanceRowIdx = 2; } } else { label = decodeWeekLabel(filename); date = ''; } projectStartIdx = attendanceRowIdx + 1; const header = rows[headerRowIdx]; if(!header || header.length < 4) return { id: uid(), label, date, people: [], projects: [], attendance: {} }; const lphStart = header.indexOf('LPH 1'); const groupIdx = header.indexOf('Gruppe'); const orderIdx = header.indexOf('Index'); const planIdx = header.indexOf('Plan'); const aufwandIdx = header.indexOf('Aufwand'); const aktiveIdx = header.indexOf('Aktive Phasen'); const peopleCols = header.slice(3, lphStart === -1 ? header.length : lphStart); const people = []; const colMap = []; const personMeta = metaPeopleRow ? metaPeopleRow.slice(3, lphStart === -1 ? metaPeopleRow.length : lphStart) : []; const personByName = {}; const nameOccurrences = {}; peopleCols.forEach((name, idx) => { const n = (name || '').trim(); if(!n){ colMap.push(null); return; } let roles = ['Planung']; let fullName = n; if(personMeta[idx]){ const parts = String(personMeta[idx]).split('|'); const rawRoles = parts[1] || ''; const parsedRoles = rawRoles.split(',').map(x => x.trim()).filter(x => ROLES.includes(x)); if(parsedRoles.length > 0) roles = parsedRoles; // Voller Name ist alles ab dem zweiten Pipe; damit sind auch Pipe-Zeichen // im Namen möglich. Leere Strings werden bewusst übernommen. if(parts.length >= 3) fullName = parts.slice(2).join('|').trim(); } else { const occurrence = nameOccurrences[n] || 0; roles = [ROLES[Math.min(occurrence, ROLES.length - 1)]]; nameOccurrences[n] = occurrence + 1; } // Gleiche Person (gleiches Kürzel) wird im Board pro Rolle mehrfach // dargestellt, soll aber im Datenmodell nur einmal existieren. let person = personByName[n]; if(!person){ person = { id: personIdForName(n), name: n, fullName: fullName, role: [] }; personByName[n] = person; people.push(person); } // Überschreibe den vollen Namen mit dem Wert aus der CSV; die CSV ist // hier die führende Datenquelle für den vollständigen Namen. person.fullName = fullName; // Sammle alle bekannten Rollen der Person; die aktuelle Spaltenrolle // wird für die Zuordnung über colMap verwendet. roles.forEach(r => { if(!person.role.includes(r)) person.role.push(r); }); colMap.push({ person, role: roles[0] }); }); const projects = []; for(let rIdx = projectStartIdx; rIdx < rows.length; rIdx++){ const row = rows[rIdx]; if(row.length < 4) continue; const nr = row[0] || ''; const name = row[1] || ''; const status = row[2] || ''; if(!nr.trim() && !name.trim()) continue; const assign = {}; peopleCols.forEach((_, i) => { const entry = colMap[i]; if(!entry) return; const v = (row[3+i] || '').trim(); const key = assignKey(entry.person.id, entry.role); if(v === 'X') assign[key] = 1; else if(v === '(X)') assign[key] = 2; }); const lph = {}; if(lphStart !== -1){ LPHS.forEach((l, j) => { const raw = row[lphStart + j]; if(raw !== undefined && raw !== ''){ const num = String(raw).replace(/%/g,''); if(!isNaN(parseFloat(num)) && isFinite(num)) lph[l] = num; } }); } let group = 'Aktive Projekte'; if(groupIdx >= 0 && row[groupIdx]) group = row[groupIdx].trim() || group; else if(row.length > 3) group = row[row.length - 1].trim() || group; let order = rIdx - projectStartIdx; if(orderIdx >= 0 && row[orderIdx] !== undefined && row[orderIdx] !== ''){ const parsed = parseInt(row[orderIdx], 10); if(!isNaN(parsed)) order = parsed; } let plan = null; if(planIdx >= 0 && row[planIdx]){ try { const parsed = JSON.parse(row[planIdx]); if(parsed && typeof parsed === 'object') plan = parsed; } catch(e) { console.warn('Plan-Daten für Projekt konnten nicht geparst werden:', nr, name, e); } } let aufwandProLP; if(aufwandIdx >= 0 && row[aufwandIdx]){ try { const parsed = JSON.parse(row[aufwandIdx]); if(parsed && typeof parsed === 'object') aufwandProLP = parsed; } catch(e) { console.warn('LP-Aufwand für Projekt konnten nicht geparst werden:', nr, name, e); } } let aktuelleLP; if(aktiveIdx >= 0 && row[aktiveIdx]){ try { const parsed = JSON.parse(row[aktiveIdx]); if(Array.isArray(parsed)) aktuelleLP = parsed; else if(typeof parsed === 'string') aktuelleLP = [parsed]; } catch(e) { console.warn('Aktive Phasen für Projekt konnten nicht geparst werden:', nr, name, e); } } projects.push({ id: uid(), nr, name, status, group, lph, assign, order, plan, aufwandProLP, aktuelleLP }); } const attendance = {}; const attRow = rows[attendanceRowIdx]; if(attRow && attRow[0] === 'ANWESENHEIT'){ peopleCols.forEach((_, i) => { const entry = colMap[i]; if(entry && (attRow[2+i] || '').trim() === 'X'){ attendance[entry.person.id] = true; } }); } return { id: uid(), label, date, people, projects, attendance, locked }; }