/**
* Parst einen Zeitstring vom Server in ein Date-Objekt.
* Timestamps mit 'Z' oder '+' werden direkt geparst (echtes UTC/Offset).
* Timestamps ohne Zeitzonen-Info werden als Europe/Berlin interpretiert,
* da die DB alle Zeiten in Lokalzeit speichert.
*/
function parseUTC(dateStr) {
if (!dateStr) return null;
try {
if (dateStr.endsWith('Z') || dateStr.includes('+')) {
const d = new Date(dateStr);
return isNaN(d.getTime()) ? null : d;
}
// DB-Timestamps sind Europe/Berlin Lokalzeit.
// Aktuellen Berlin-UTC-Offset ermitteln und anwenden.
const normalized = dateStr.replace(' ', 'T');
const naive = new Date(normalized + 'Z'); // als UTC parsen
if (isNaN(naive.getTime())) return null;
// Berlin-Offset fuer diesen Zeitpunkt bestimmen
const berlinStr = naive.toLocaleString('sv-SE', { timeZone: 'Europe/Berlin' });
const berlinAsUTC = new Date(berlinStr.replace(' ', 'T') + 'Z');
const offsetMs = naive.getTime() - berlinAsUTC.getTime();
const d = new Date(naive.getTime() + offsetMs);
return isNaN(d.getTime()) ? null : d;
} catch (e) {
return null;
}
}
/**
* UI-Komponenten für das Dashboard.
*/
const UI = {
/**
* Sidebar-Eintrag für eine Lage rendern.
*/
renderIncidentItem(incident, isActive) {
const isRefreshing = App._refreshingIncidents && App._refreshingIncidents.has(incident.id);
const dotClass = isRefreshing ? 'refreshing' : (incident.status === 'active' ? 'active' : 'archived');
const activeClass = isActive ? 'active' : '';
const creator = (incident.created_by_username || '').split('@')[0];
// Determine refresh status for sidebar display
let refreshClass = '';
let refreshStatusHtml = '';
if (isRefreshing) {
const state = this._progressState[incident.id];
const step = state ? state.step : 'researching';
const isQueued = (step === 'queued');
if (isQueued) {
refreshClass = ' queued-item';
const pos = state && state._queuePos ? ' (#' + state._queuePos + ')' : '';
refreshStatusHtml = '
';
} else {
refreshClass = ' refreshing-item';
const label = this._getStepLabel(step);
refreshStatusHtml = '';
}
}
return `
${this.escape(incident.title)}
${incident.article_count} Artikel · ${this.escape(creator)}
${refreshStatusHtml}
${incident.visibility === 'private' ? '
PRIVAT' : ''}
${incident.refresh_mode === 'auto' ? '
↻' : ''}
`;
},
/**
* Faktencheck-Eintrag rendern.
*/
factCheckLabels: {
confirmed: 'Bestätigt durch mehrere Quellen',
unconfirmed: 'Nicht unabhängig bestätigt',
contradicted: 'Widerlegt',
developing: 'Faktenlage noch im Fluss',
established: 'Gesicherter Fakt (3+ Quellen)',
disputed: 'Umstrittener Sachverhalt',
unverified: 'Nicht unabhängig verifizierbar',
},
factCheckTooltips: {
confirmed: 'Bestätigt: Mindestens zwei unabhängige, seriöse Quellen stützen diese Aussage übereinstimmend.',
established: 'Gesichert: Drei oder mehr unabhängige Quellen bestätigen den Sachverhalt. Hohe Verlässlichkeit.',
developing: 'Unklar: Die Faktenlage ist noch im Fluss. Neue Informationen können das Bild verändern.',
unconfirmed: 'Unbestätigt: Bisher nur aus einer Quelle bekannt. Eine unabhängige Bestätigung steht aus.',
unverified: 'Ungeprüft: Die Aussage konnte bisher nicht anhand verfügbarer Quellen überprüft werden.',
disputed: 'Umstritten: Quellen widersprechen sich. Es gibt sowohl stützende als auch widersprechende Belege.',
contradicted: 'Widerlegt: Zuverlässige Quellen widersprechen dieser Aussage. Wahrscheinlich falsch.',
},
factCheckChipLabels: {
confirmed: 'Bestätigt',
unconfirmed: 'Unbestätigt',
contradicted: 'Widerlegt',
developing: 'Unklar',
established: 'Gesichert',
disputed: 'Umstritten',
unverified: 'Ungeprüft',
},
factCheckIcons: {
confirmed: '✓',
unconfirmed: '?',
contradicted: '✗',
developing: '↻',
established: '✓',
disputed: '⚠',
unverified: '?',
},
/**
* Faktencheck-Filterleiste rendern.
*/
renderFactCheckFilters(factchecks) {
// Welche Stati kommen tatsächlich vor + Zähler
const statusCounts = {};
factchecks.forEach(fc => {
statusCounts[fc.status] = (statusCounts[fc.status] || 0) + 1;
});
const statusOrder = ['confirmed', 'established', 'developing', 'unconfirmed', 'unverified', 'disputed', 'contradicted'];
const usedStatuses = statusOrder.filter(s => statusCounts[s]);
if (usedStatuses.length <= 1) return '';
const items = usedStatuses.map(status => {
const icon = this.factCheckIcons[status] || '?';
const chipLabel = this.factCheckChipLabels[status] || status;
const tooltip = this.factCheckTooltips[status] || '';
const count = statusCounts[status];
return ``;
}).join('');
return `
`;
},
renderFactCheck(fc) {
const urls = (fc.evidence || '').match(/https?:\/\/[^\s,)]+/g) || [];
const count = urls.length;
return `
${this.factCheckIcons[fc.status] || '?'}
${this.factCheckLabels[fc.status] || fc.status}
${this.escape(fc.claim)}
${count} Quelle${count !== 1 ? 'n' : ''}
${this.renderEvidence(fc.evidence || '')}
`;
},
/**
* Evidence mit erklärenden Text UND Quellen-Chips rendern.
*/
renderEvidence(text) {
if (!text) return 'Keine Belege';
const urls = text.match(/https?:\/\/[^\s,)]+/g) || [];
if (urls.length === 0) {
return `${this.escape(text)}`;
}
// Erklärenden Text extrahieren (URLs entfernen)
let explanation = text;
urls.forEach(url => { explanation = explanation.replace(url, '').trim(); });
// Aufräumen: Klammern, mehrfache Kommas/Leerzeichen
explanation = explanation.replace(/\(\s*\)/g, '');
explanation = explanation.replace(/,\s*,/g, ',');
explanation = explanation.replace(/\s+/g, ' ').trim();
explanation = explanation.replace(/[,.:;]+$/, '').trim();
// Chips für jede URL
const chips = urls.map(url => {
let label;
try { label = new URL(url).hostname.replace('www.', ''); } catch { label = url; }
return `${this.escape(label)}`;
}).join('');
const explanationHtml = explanation
? `${this.escape(explanation)}`
: '';
return `${explanationHtml}${chips}
`;
},
/**
* Toast-Benachrichtigung anzeigen.
*/
_toastTimers: new Map(),
showToast(message, type = 'info', duration = 5000) {
const container = document.getElementById('toast-container');
// Duplikat? Bestehenden Toast neu animieren
const existing = Array.from(container.children).find(
t => t.dataset.msg === message && t.dataset.type === type
);
if (existing) {
clearTimeout(this._toastTimers.get(existing));
// Kurz rausschieben, dann neu reingleiten
existing.style.transition = 'none';
existing.style.opacity = '0';
existing.style.transform = 'translateX(100%)';
void existing.offsetWidth; // Reflow erzwingen
existing.style.transition = 'all 0.3s ease';
existing.style.opacity = '1';
existing.style.transform = 'translateX(0)';
const timer = setTimeout(() => {
existing.style.opacity = '0';
existing.style.transform = 'translateX(100%)';
setTimeout(() => { existing.remove(); this._toastTimers.delete(existing); }, 300);
}, duration);
this._toastTimers.set(existing, timer);
return;
}
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.setAttribute('role', 'status');
toast.dataset.msg = message;
toast.dataset.type = type;
toast.innerHTML = `${this.escape(message)}`;
container.appendChild(toast);
const timer = setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
toast.style.transition = 'all 0.3s ease';
setTimeout(() => { toast.remove(); this._toastTimers.delete(toast); }, 300);
}, duration);
this._toastTimers.set(toast, timer);
},
_progressStartTime: null,
_progressTimer: null,
/**
* Fortschrittsanzeige einblenden und Status setzen.
*/
// === Progress State (per-incident) ===
_progressState: {}, // { incidentId: { step, isFirst, startTime, minimized } }
_progressTimerInterval: null,
_getStepOrder() {
return ['queued', 'researching', 'deep_researching', 'analyzing', 'factchecking'];
},
_getStepLabel(step) {
const map = {
queued: 'In Warteschlange',
researching: 'Recherchiert...',
deep_researching: 'Tiefenrecherche...',
analyzing: 'Analysiert...',
factchecking: 'Faktencheck...',
cancelling: 'Wird abgebrochen...',
};
return map[step] || step;
},
showProgress(status, extra = {}, incidentId = null, isFirstRefresh = false) {
if (!incidentId) incidentId = App.currentIncidentId;
if (!incidentId) return;
// Init state for this incident
if (!this._progressState[incidentId]) {
this._progressState[incidentId] = { step: 'queued', isFirst: isFirstRefresh, startTime: null, minimized: false };
}
const state = this._progressState[incidentId];
state.step = status;
if (isFirstRefresh) state.isFirst = true;
// Start timer on first non-queued status
if (status !== 'queued' && !state.startTime) {
if (extra.started_at) {
const serverStart = typeof parseUTC === 'function' ? parseUTC(extra.started_at) : new Date(extra.started_at);
state.startTime = serverStart ? serverStart.getTime() : Date.now();
} else {
state.startTime = Date.now();
}
}
// Start global timer interval if not running
if (!this._progressTimerInterval) {
this._progressTimerInterval = setInterval(() => this._tickProgressTimers(), 1000);
}
// Store queue position
if (status === 'queued' && extra.queue_position) {
state._queuePos = extra.queue_position;
}
// Update sidebar status for ALL incidents (not just current)
this._updateSidebarRefreshStatus(incidentId, status, extra);
// Only show popup/mini UI for current incident
if (incidentId !== App.currentIncidentId) return;
if (false) { // popup always shown initially
state.minimized = true;
}
if (state.minimized) {
this._showMiniProgress(status, state);
return;
}
this._showPopupProgress(status, extra, state);
},
_showPopupProgress(status, extra, state) {
const overlay = document.getElementById('progress-overlay');
const popup = document.getElementById('progress-popup');
if (!overlay || !popup) return;
overlay.style.display = 'flex';
this._initClickOutside();
// Blocking (no close) for first refresh
if (state.isFirst) {
overlay.classList.add('blocking');
// Apply blur to grid
const grid = document.querySelector('.grid-stack');
if (grid) grid.classList.add('blurred');
} else {
overlay.classList.remove('blocking');
}
// Minimize button: only for updates (not first)
const minBtn = document.getElementById('progress-popup-minimize');
if (minBtn) minBtn.style.display = state.isFirst ? 'none' : '';
// Title
const titleEl = document.getElementById('progress-popup-title');
if (titleEl) titleEl.textContent = state.isFirst ? 'Erste Recherche l\u00e4uft' : 'Aktualisierung l\u00e4uft';
// Multi-pass info
const passEl = document.getElementById('progress-popup-pass');
if (passEl) {
if (extra.research_pass && extra.research_total_passes) {
passEl.textContent = 'Durchlauf ' + extra.research_pass + '/' + extra.research_total_passes;
passEl.style.display = '';
} else {
passEl.style.display = 'none';
}
}
// Update checklist
const stepOrder = this._getStepOrder();
const currentIdx = stepOrder.indexOf(status === 'deep_researching' ? 'researching' : status);
const items = document.querySelectorAll('.progress-check-item');
// Map checklist items to step indices: queued=0, researching=1, analyzing=3, factchecking=4
const checkStepMap = { queued: 0, researching: 1, analyzing: 3, factchecking: 4 };
items.forEach(item => {
const step = item.dataset.step;
const stepIdx = checkStepMap[step] !== undefined ? checkStepMap[step] : -1;
const icon = item.querySelector('.progress-check-icon');
const detail = item.querySelector('.progress-check-detail');
item.classList.remove('active', 'done', 'error');
if (stepIdx < currentIdx || (step === 'queued' && currentIdx > 0)) {
item.classList.add('done');
if (icon) icon.innerHTML = '\u2713';
} else if (stepIdx === currentIdx || (step === 'researching' && (status === 'researching' || status === 'deep_researching'))) {
item.classList.add('active');
if (icon) icon.innerHTML = '';
if (detail && extra.detail) detail.textContent = extra.detail;
else if (detail) detail.textContent = '';
} else {
if (icon) icon.innerHTML = '\u25cb';
if (detail) detail.textContent = '';
}
});
// Cancel button
const cancelBtn = document.getElementById('progress-cancel-btn');
if (cancelBtn) {
cancelBtn.style.display = '';
cancelBtn.textContent = 'Abbrechen';
cancelBtn.disabled = false;
}
// Hide complete summary
const summaryEl = document.getElementById('progress-complete-summary');
if (summaryEl) summaryEl.style.display = 'none';
// Hide mini bar
const mini = document.getElementById('progress-mini');
if (mini) mini.style.display = 'none';
// Lock action buttons during first refresh
this._lockActionsIfFirst(state.isFirst);
},
_lockActionsIfFirst(isFirst) {
const actions = document.querySelector('.incident-header-actions');
if (!actions) return;
if (isFirst) {
actions.classList.add('first-refresh-locked');
} else {
actions.classList.remove('first-refresh-locked');
}
},
_showMiniProgress(status, state) {
const mini = document.getElementById('progress-mini');
if (!mini) return;
mini.style.display = 'flex';
const textEl = document.getElementById('progress-mini-text');
if (textEl) textEl.textContent = this._getStepLabel(status);
// Hide popup
const overlay = document.getElementById('progress-overlay');
if (overlay) overlay.style.display = 'none';
},
minimizeProgress(incidentId) {
if (!incidentId) incidentId = App.currentIncidentId;
const state = this._progressState[incidentId];
if (!state) return;
state.minimized = true;
state._userOpenedPopup = false;
this._showMiniProgress(state.step, state);
},
openProgressPopup(incidentId) {
if (!incidentId) incidentId = App.currentIncidentId;
const state = this._progressState[incidentId];
if (!state) return;
state.minimized = false;
state._userOpenedPopup = true;
this._showPopupProgress(state.step, {}, state);
},
showProgressComplete(data, incidentId) {
if (!incidentId) incidentId = App.currentIncidentId;
const state = this._progressState[incidentId];
// Calculate total time
let totalTimeStr = '';
if (state && state.startTime) {
const elapsed = Math.floor((Date.now() - state.startTime) / 1000);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
totalTimeStr = mins + ':' + String(secs).padStart(2, '0');
}
if (incidentId === App.currentIncidentId) {
// Remove blur
const grid = document.querySelector('.grid-stack');
if (grid) grid.classList.remove('blurred');
const overlay = document.getElementById('progress-overlay');
if (overlay) {
overlay.style.display = 'flex';
overlay.classList.remove('blocking');
}
// Mark all steps done
document.querySelectorAll('.progress-check-item').forEach(item => {
item.classList.remove('active', 'error');
item.classList.add('done');
const icon = item.querySelector('.progress-check-icon');
if (icon) icon.innerHTML = '\u2713';
});
// Show summary
const parts = [];
if (data.new_articles > 0) parts.push(data.new_articles + ' neue Artikel');
if (data.confirmed_count > 0) parts.push(data.confirmed_count + ' Fakten best\u00e4tigt');
if (data.contradicted_count > 0) parts.push(data.contradicted_count + ' widerlegt');
const summaryText = parts.length > 0 ? parts.join(', ') : 'Keine neuen Entwicklungen';
const summaryEl = document.getElementById('progress-complete-summary');
if (summaryEl) {
summaryEl.innerHTML = '\u2713 Abgeschlossen: ' + summaryText
+ (totalTimeStr ? 'Gesamtzeit: ' + totalTimeStr + '' : '');
summaryEl.style.display = 'block';
}
// Update title
const titleEl = document.getElementById('progress-popup-title');
if (titleEl) titleEl.textContent = 'Abgeschlossen';
// Hide cancel, show minimize
const cancelBtn = document.getElementById('progress-cancel-btn');
if (cancelBtn) cancelBtn.style.display = 'none';
const minBtn = document.getElementById('progress-popup-minimize');
if (minBtn) minBtn.style.display = '';
// Hide mini bar
const mini = document.getElementById('progress-mini');
if (mini) mini.style.display = 'none';
}
// Remove sidebar refresh status
this._removeSidebarRefreshStatus(incidentId);
// Clean up state after delay
setTimeout(() => {
this.hideProgress(incidentId);
}, 5000);
},
showProgressError(errorMsg, willRetry = false, delay = 0, incidentId = null) {
if (!incidentId) incidentId = App.currentIncidentId;
if (incidentId !== App.currentIncidentId) return;
const overlay = document.getElementById('progress-overlay');
if (overlay) overlay.style.display = 'flex';
// Mark current step as error
const state = this._progressState[incidentId];
if (state) {
const items = document.querySelectorAll('.progress-check-item.active');
items.forEach(item => {
item.classList.remove('active');
item.classList.add('error');
const icon = item.querySelector('.progress-check-icon');
if (icon) icon.innerHTML = '\u2717';
});
}
const titleEl = document.getElementById('progress-popup-title');
if (titleEl) {
titleEl.textContent = willRetry
? 'Fehlgeschlagen \u2014 erneuter Versuch in ' + delay + 's...'
: 'Fehlgeschlagen: ' + errorMsg;
}
const cancelBtn = document.getElementById('progress-cancel-btn');
if (cancelBtn) cancelBtn.style.display = 'none';
if (!willRetry) {
this._removeSidebarRefreshStatus(incidentId);
setTimeout(() => this.hideProgress(incidentId), 6000);
}
},
hideProgress(incidentId) {
if (!incidentId) incidentId = App.currentIncidentId;
// Remove blur
const grid = document.querySelector('.grid-stack');
if (grid) grid.classList.remove('blurred');
if (incidentId === App.currentIncidentId) {
const overlay = document.getElementById('progress-overlay');
if (overlay) { overlay.style.display = 'none'; overlay.classList.remove('blocking'); }
const mini = document.getElementById('progress-mini');
if (mini) mini.style.display = 'none';
}
// Unlock action buttons
this._lockActionsIfFirst(false);
// Remove sidebar status
this._removeSidebarRefreshStatus(incidentId);
// Clean up state
delete this._progressState[incidentId];
// Stop timer if no more active refreshes
if (Object.keys(this._progressState).length === 0 && this._progressTimerInterval) {
clearInterval(this._progressTimerInterval);
this._progressTimerInterval = null;
}
},
_tickProgressTimers() {
for (const [id, state] of Object.entries(this._progressState)) {
if (!state.startTime) continue;
const elapsed = Math.max(0, Math.floor((Date.now() - state.startTime) / 1000));
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
const timeStr = mins + ':' + String(secs).padStart(2, '0');
if (parseInt(id) === App.currentIncidentId) {
// Update popup timer
const timerEl = document.getElementById('progress-popup-timer');
if (timerEl) timerEl.textContent = timeStr;
// Update mini timer
const miniTimer = document.getElementById('progress-mini-timer');
if (miniTimer) miniTimer.textContent = timeStr;
}
// Update sidebar timer for this incident
const sidebarTimer = document.getElementById('sidebar-refresh-timer-' + id);
if (sidebarTimer) sidebarTimer.textContent = timeStr;
}
},
// === Sidebar Refresh Status ===
_updateSidebarRefreshStatus(incidentId, status, extra) {
const item = document.querySelector('.incident-item[data-id="' + incidentId + '"]');
if (!item) return;
const isQueued = (status === 'queued');
// Add appropriate class
item.classList.remove('refreshing-item', 'queued-item');
item.classList.add(isQueued ? 'queued-item' : 'refreshing-item');
// Add or update status text below meta
let statusEl = document.getElementById('sidebar-refresh-' + incidentId);
if (!statusEl) {
const textCol = item.querySelector('div[style*="flex:1"]');
if (!textCol) return;
statusEl = document.createElement('div');
statusEl.id = 'sidebar-refresh-' + incidentId;
textCol.appendChild(statusEl);
}
if (isQueued) {
const pos = (extra && extra.queue_position) ? extra.queue_position : ((this._progressState[incidentId] || {})._queuePos || '');
// Store queue position in state for renderIncidentItem
const pState = this._progressState[incidentId];
if (pState && pos) pState._queuePos = pos;
statusEl.className = 'incident-refresh-status queued-status';
statusEl.innerHTML = 'Warteschlange' + (pos ? ' (#' + pos + ')' : '') + '';
} else {
statusEl.className = 'incident-refresh-status';
const label = this._getStepLabel(status);
statusEl.innerHTML = '' + label + '';
}
},
_removeSidebarRefreshStatus(incidentId) {
const statusEl = document.getElementById('sidebar-refresh-' + incidentId);
if (statusEl) statusEl.remove();
const item = document.querySelector('.incident-item[data-id="' + incidentId + '"]');
if (item) item.classList.remove('refreshing-item', 'queued-item');
},
_reindexQueuePositions() {
// Collect all queued incidents and renumber sequentially
const queued = [];
for (const [id, state] of Object.entries(this._progressState)) {
if (state && state.step === 'queued') queued.push({ id: Number(id), pos: state._queuePos || 999 });
}
queued.sort((a, b) => a.pos - b.pos);
queued.forEach((item, idx) => {
const newPos = idx + 1;
const state = this._progressState[item.id];
if (state) state._queuePos = newPos;
const statusEl = document.getElementById('sidebar-refresh-' + item.id);
if (statusEl) statusEl.innerHTML = 'Warteschlange (#' + newPos + ')';
});
},
// === Click-outside to auto-minimize popup ===
_initClickOutside() {
if (this._clickOutsideInit) return;
this._clickOutsideInit = true;
document.addEventListener('click', (e) => {
const overlay = document.getElementById('progress-overlay');
if (!overlay || overlay.style.display === 'none') return;
const popup = document.getElementById('progress-popup');
if (!popup) return;
// Ignore clicks inside the popup itself
if (popup.contains(e.target)) return;
// Ignore clicks on the mini bar
const mini = document.getElementById('progress-mini');
if (mini && mini.contains(e.target)) return;
// Don't minimize during first refresh (blocking)
const currentId = App.currentIncidentId;
const state = this._progressState[currentId];
if (state && state.isFirst) return;
// Auto-minimize
if (state && !state.minimized) {
this.minimizeProgress(currentId);
}
});
},
/**
* Zusammenfassung mit Inline-Zitaten und Quellenverzeichnis rendern.
*/
/**
* Extrahiert die ZUSAMMENFASSUNG-Sektion aus einem Research-Briefing.
* Returns: { zusammenfassung: string|null, remaining: string }
*/
extractZusammenfassung(summary) {
if (!summary) return { zusammenfassung: null, remaining: summary };
const pattern = /## (?:ZUSAMMENFASSUNG|ÜBERBLICK)\s*\n(.*?)(?=\n## |$)/s;
const match = summary.match(pattern);
if (!match) return { zusammenfassung: null, remaining: summary };
const zusammenfassung = match[1].trim();
const remaining = summary.substring(0, match.index) + summary.substring(match.index + match[0].length);
return { zusammenfassung, remaining: remaining.trim() };
},
/**
* Rendert die Zusammenfassung als HTML (Bullet Points).
*/
renderZusammenfassung(text, sourcesJson) {
if (!text) return 'Noch keine Zusammenfassung.';
let sources = [];
try { sources = JSON.parse(sourcesJson || '[]'); } catch(e) {}
// Nur Bullet-Point-Zeilen behalten, Fliesstext herausfiltern
const bulletLines = text.split("\n").filter(line => line.trim().startsWith("- "));
const bulletText = bulletLines.length > 0 ? bulletLines.join("\n") : text;
let html = this.escape(bulletText);
// Bullet points
html = html.replace(/^- (.+)$/gm, '$1');
html = html.replace(/(.*<\/li>\n?)+/gs, '');
// Zeilenumbrueche
html = html.replace(/\n(?!<)/g, '
');
html = html.replace(/(
){2,}/g, '
');
// Inline-Zitate als klickbare Links
if (sources.length > 0) {
html = html.replace(/\[(\d+[a-z]?)\]/g, (match, num) => {
let src = sources.find(s => String(s.nr) === num || Number(s.nr) === Number(num));
if ((!src || !src.url) && /[a-z]$/.test(num)) {
const baseNum = num.replace(/[a-z]$/, '');
const baseSrc = sources.find(s => String(s.nr) === baseNum || Number(s.nr) === Number(baseNum));
if (baseSrc && baseSrc.url) src = baseSrc;
}
if (src && src.url) {
return `[${num}]`;
}
return match;
});
}
return html;
},
/**
* Rendert "Neueste Entwicklungen" für Live-Monitoring (adhoc).
* Erwartet Bullets im Format "- [DD.MM. HH:MM] Text {Quelle1, Quelle2}".
* Legacy: Inline-[N]-Citations werden als Fallback ebenfalls erkannt.
*/
renderLatestDevelopments(text, sourcesJson) {
if (!text) return 'Noch keine Entwicklungen erfasst.';
let sources = [];
try { sources = JSON.parse(sourcesJson || '[]'); } catch(e) {}
const bulletLines = text.split("\n").map(l => l.trim()).filter(l => l.startsWith("- "));
if (bulletLines.length === 0) {
return this.renderZusammenfassung(text, sourcesJson);
}
const bulletRe = /^-\s*\[(\d{1,2}\.\d{1,2}\.)\s+(\d{1,2}:\d{2})\]\s*(.+?)\s*$/;
const citationRe = /\[(\d+[a-z]?)\]/g;
const trailingNamesRe = /\s*\{([^{}]+)\}\s*\.?\s*$/;
const lookupByNum = (num) => {
let src = sources.find(s => String(s.nr) === num || Number(s.nr) === Number(num));
if (!src && /[a-z]$/.test(num)) {
const baseNum = num.replace(/[a-z]$/, '');
src = sources.find(s => String(s.nr) === baseNum || Number(s.nr) === Number(baseNum));
}
return src || null;
};
const normalize = (s) => (s || '').toLowerCase().replace(/^(der|die|das)\s+/, '').replace(/\s+/g, ' ').trim();
const lookupByName = (name) => {
const n = normalize(name);
if (!n) return null;
let src = sources.find(s => normalize(s.name) === n);
if (src) return src;
src = sources.find(s => {
const sn = normalize(s.name);
return sn.includes(n) || n.includes(sn);
});
return src || null;
};
const buildPill = (src, fallbackName) => {
const displayName = src ? (src.name || fallbackName) : fallbackName;
const esc = this.escape(displayName);
const biasClass = this._classifyBias(displayName);
const biasHtml = biasClass ? `${this._biasLabel(biasClass)}` : '';
if (src && src.url) {
return `${esc}${biasHtml}`;
}
return `${esc}${biasHtml}`;
};
const cards = bulletLines.map(line => {
const m = bulletRe.exec(line);
if (!m) {
const body = this.escape(line.replace(/^-\s*/, ''));
return ``;
}
const date = m[1];
const time = m[2];
let rawBody = m[3];
let pillsHtml = '';
// Primär: {Name1, Name2} am Bullet-Ende
const trailing = trailingNamesRe.exec(rawBody);
if (trailing) {
rawBody = rawBody.replace(trailingNamesRe, '').trim();
const names = trailing[1].split(',').map(s => s.trim()).filter(Boolean);
const seen = new Set();
pillsHtml = names.map(name => {
const key = normalize(name);
if (seen.has(key)) return '';
seen.add(key);
if (/^(unbekannt|unknown|n\/a|keine)$/i.test(name)) return '';
const src = lookupByName(name);
return buildPill(src, name);
}).filter(Boolean).join('');
}
// Fallback: Inline-[N]-Citations (Legacy-Recherche-Format)
if (!pillsHtml) {
const nums = [];
let cm;
while ((cm = citationRe.exec(rawBody)) !== null) {
if (!nums.includes(cm[1])) nums.push(cm[1]);
}
citationRe.lastIndex = 0;
if (nums.length > 0) {
rawBody = rawBody.replace(citationRe, '').replace(/\s+/g, ' ').trim();
pillsHtml = nums.map(num => {
const src = lookupByNum(num);
return src ? buildPill(src, src.name || `Quelle ${num}`) : '';
}).filter(Boolean).join('');
}
}
const cleanBody = this.escape(rawBody.trim());
const sourcesHtml = pillsHtml
? `${pillsHtml}`
: `Keine Quelle`;
const timeHtml = `${this.escape(time)} \u00b7 ${this.escape(date)}`;
return `${sourcesHtml}${timeHtml}
${cleanBody}
`;
});
return `${cards.join('')}
`;
},
_classifyBias(name) {
if (!name) return null;
const n = name.toLowerCase();
const proRu = ['rybar', 'sputnik', 'tass', 'ria novosti', 'ria.ru', 'tsargrad', 'readovka', 'pravda', 'russia today', 'rt.com', 'rt deutsch'];
const staatsnah = ['cctv', 'global times', 'xinhua', "people's daily", 'press tv', 'irna', 'kcna'];
if (proRu.some(k => n.includes(k))) return 'pro-ru';
if (staatsnah.some(k => n.includes(k))) return 'staatsnah';
return null;
},
_biasLabel(cls) {
if (cls === 'pro-ru') return 'pro-RU';
if (cls === 'staatsnah') return 'staatsnah';
return cls;
},
renderSummary(summary, sourcesJson, incidentType) {
if (!summary) return 'Noch keine Zusammenfassung.';
let sources = [];
try { sources = JSON.parse(sourcesJson || '[]'); } catch(e) {}
// Markdown-Rendering
let html = this.escape(summary);
// ## Überschriften
html = html.replace(/^## (.+)$/gm, '$1
');
// **Fettdruck**
html = html.replace(/\*\*(.+?)\*\*/g, '$1');
// Listen (- Item)
html = html.replace(/^- (.+)$/gm, '$1');
html = html.replace(/(.*<\/li>\n?)+/gs, '');
// Zeilenumbrüche (aber nicht nach Headings/Listen)
html = html.replace(/\n(?!<)/g, '
');
// Überflüssige
nach Block-Elementen entfernen + doppelte
zusammenfassen
html = html.replace(/<\/h3>(
)+/g, '');
html = html.replace(/<\/ul>(
)+/g, '');
html = html.replace(/(
){2,}/g, '
');
// Markdown-Tabellen rendern
html = html.replace(/(?:^|
)((?:\|.+\|(?:
|$))+)/g, function(match, tableBlock) {
var rows = tableBlock.split('
').filter(function(r) { return r.trim().length > 0; });
if (rows.length < 2) return match;
var isSep = function(r) { return /^\|[\s\-:|]+\|$/.test(r.trim()); };
if (!isSep(rows[1])) return match;
var parseRow = function(r) { return r.split('|').slice(1, -1).map(function(c) { return c.trim(); }); };
var headerCells = parseRow(rows[0]);
var thead = '' + headerCells.map(function(c) { return '| ' + c + ' | '; }).join('') + '
';
var tbody = '' + rows.slice(2).map(function(r) {
if (isSep(r)) return '';
var cells = parseRow(r);
return '' + cells.map(function(c) { return '| ' + c + ' | '; }).join('') + '
';
}).join('') + '';
return '';
});
// Inline-Zitate [1], [2], [1383a] etc. als klickbare Links rendern
if (sources.length > 0) {
html = html.replace(/\[(\d+[a-z]?)\]/g, (match, num) => {
// Exakte Suche (auch mit Buchstaben-Suffix)
let src = sources.find(s => String(s.nr) === num || Number(s.nr) === Number(num));
// Fallback: Bei Suffix wie "1383a" auf Basisnummer 1383 zurueckfallen
if ((!src || !src.url) && /[a-z]$/.test(num)) {
const baseNum = num.replace(/[a-z]$/, '');
const baseSrc = sources.find(s => String(s.nr) === baseNum || Number(s.nr) === Number(baseNum));
if (baseSrc && baseSrc.url) src = baseSrc;
}
if (src && src.url) {
return `[${num}]`;
}
return match;
});
}
return `${html}
`;
},
/**
* Quellenübersicht für eine Lage rendern.
*/
renderSourceOverview(articles) {
if (!articles || articles.length === 0) return '';
// Nach Quelle aggregieren
const sourceMap = {};
articles.forEach(a => {
const name = a.source || 'Unbekannt';
if (!sourceMap[name]) {
sourceMap[name] = { count: 0, languages: new Set(), urls: [] };
}
sourceMap[name].count++;
sourceMap[name].languages.add(a.language || 'de');
if (a.source_url) sourceMap[name].urls.push(a.source_url);
});
const sources = Object.entries(sourceMap)
.sort((a, b) => b[1].count - a[1].count);
// Sprach-Statistik
const langCount = {};
articles.forEach(a => {
const lang = (a.language || 'de').toUpperCase();
langCount[lang] = (langCount[lang] || 0) + 1;
});
const langChips = Object.entries(langCount)
.sort((a, b) => b[1] - a[1])
.map(([lang, count]) => `${lang} ${count}`)
.join('');
let html = ``;
html += '';
sources.forEach(([name, data]) => {
const langs = [...data.languages].map(l => l.toUpperCase()).join('/');
html += `
${this.escape(name)}
${langs}
${data.count}
`;
});
html += '
';
return html;
},
/**
* Kategorie-Labels.
*/
_categoryLabels: {
'nachrichtenagentur': 'Agentur',
'oeffentlich-rechtlich': 'ÖR',
'qualitaetszeitung': 'Qualität',
'behoerde': 'Behörde',
'fachmedien': 'Fach',
'think-tank': 'Think Tank',
'international': 'Intl.',
'regional': 'Regional',
'boulevard': 'Boulevard',
'telegram': 'Telegram',
'sonstige': 'Sonstige',
},
/**
* Domain-Gruppe rendern (aufklappbar mit Feeds).
*/
renderSourceGroup(domain, feeds, isExcluded, excludedNotes, isGlobal) {
const catLabel = this._categoryLabels[feeds[0]?.category] || feeds[0]?.category || '';
const feedCount = feeds.filter(f => f.source_type !== 'excluded').length;
const hasMultiple = feedCount > 1;
const displayName = (domain && !domain.startsWith('_single_')) ? domain : (feeds[0]?.name || 'Unbekannt');
const escapedDomain = this.escape(domain);
if (isExcluded) {
// Ausgeschlossene Domain
const notesHtml = excludedNotes ? ` ${this.escape(excludedNotes)}` : '';
return `
`;
}
// Aktive Domain-Gruppe
const toggleAttr = hasMultiple ? `onclick="App.toggleGroup('${escapedDomain}')" role="button" tabindex="0" aria-expanded="false"` : '';
const toggleIcon = hasMultiple ? '▶' : '';
let feedRows = '';
if (hasMultiple) {
const realFeeds = feeds.filter(f => f.source_type !== 'excluded');
feedRows = ``;
realFeeds.forEach((feed, i) => {
const isLast = i === realFeeds.length - 1;
const connector = isLast ? '\u2514\u2500' : '\u251C\u2500';
const typeLabel = feed.source_type === 'rss_feed' ? 'RSS' : 'Web';
const urlDisplay = feed.url ? this._shortenUrl(feed.url) : '';
feedRows += `
${connector}
${this.escape(feed.name)}
${typeLabel}
${this.escape(urlDisplay)}
${!feed.is_global ? `
` : 'Grundquelle'}
`;
});
feedRows += '
';
}
const feedCountBadge = feedCount > 0
? `${feedCount} Feed${feedCount !== 1 ? 's' : ''}`
: '';
// Info-Button mit Tooltip (Typ, Sprache, Ausrichtung)
let infoButtonHtml = '';
const firstFeed = feeds[0] || {};
const hasInfo = firstFeed.language || firstFeed.bias;
if (hasInfo) {
const typeMap = { rss_feed: 'RSS-Feed', web_source: 'Web-Quelle', telegram_channel: 'Telegram-Kanal' };
const lines = [];
lines.push('Typ: ' + (typeMap[firstFeed.source_type] || firstFeed.source_type || 'Unbekannt'));
if (firstFeed.language) lines.push('Sprache: ' + firstFeed.language);
if (firstFeed.bias) lines.push('Ausrichtung: ' + firstFeed.bias);
const tooltipText = this.escape(lines.join('\n'));
infoButtonHtml = ` `;
}
return `
${feedRows}
`;
},
/**
* URL kürzen für die Anzeige in Feed-Zeilen.
*/
_shortenUrl(url) {
try {
const u = new URL(url);
let path = u.pathname;
if (path.length > 40) path = path.substring(0, 37) + '...';
return u.hostname + path;
} catch {
return url.length > 50 ? url.substring(0, 47) + '...' : url;
}
},
/**
* Leaflet-Karte mit Locations rendern.
*/
_map: null,
_mapCluster: null,
_mapCategoryLayers: {},
_mapLegendControl: null,
_pendingLocations: null,
// Farbige Marker-Icons nach Kategorie (inline SVG, keine externen Ressourcen)
_markerIcons: null,
_createSvgIcon(fillColor, strokeColor) {
const svg = ``;
return L.divIcon({
html: svg,
className: 'map-marker-svg',
iconSize: [28, 42],
iconAnchor: [14, 42],
popupAnchor: [0, -36],
});
},
_initMarkerIcons() {
if (this._markerIcons || typeof L === 'undefined') return;
this._markerIcons = {
primary: this._createSvgIcon('#dc3545', '#a71d2a'),
secondary: this._createSvgIcon('#f39c12', '#c47d0a'),
tertiary: this._createSvgIcon('#2a81cb', '#1a5c8f'),
mentioned: this._createSvgIcon('#7b7b7b', '#555555'),
};
},
_defaultCategoryLabels: {
primary: 'Hauptgeschehen',
secondary: 'Reaktionen',
tertiary: 'Beteiligte',
mentioned: 'Erwaehnt',
},
_categoryColors: {
primary: '#cb2b3e',
secondary: '#f39c12',
tertiary: '#2a81cb',
mentioned: '#7b7b7b',
},
_activeCategoryLabels: null,
renderMap(locations, categoryLabels) {
const container = document.getElementById('map-container');
const emptyEl = document.getElementById('map-empty');
const statsEl = document.getElementById('map-stats');
if (!container) return;
// Leaflet noch nicht geladen? Locations merken und spaeter rendern
if (typeof L === 'undefined') {
this._pendingLocations = locations;
// Statistik trotzdem anzeigen
if (locations && locations.length > 0) {
const totalArticles = locations.reduce((s, l) => s + l.article_count, 0);
if (statsEl) statsEl.textContent = `${locations.length} Orte / ${totalArticles} Artikel`;
if (emptyEl) emptyEl.style.display = 'none';
}
return;
}
if (!locations || locations.length === 0) {
if (emptyEl) emptyEl.style.display = 'flex';
if (statsEl) statsEl.textContent = '';
if (this._map) {
this._map.remove();
this._map = null;
this._mapCluster = null;
}
return;
}
if (emptyEl) emptyEl.style.display = 'none';
// Statistik
const totalArticles = locations.reduce((s, l) => s + l.article_count, 0);
if (statsEl) statsEl.textContent = `${locations.length} Orte / ${totalArticles} Artikel`;
// Container-Hoehe sicherstellen (Leaflet braucht px-Hoehe)
const gsItem = container.closest('.grid-stack-item');
if (gsItem) {
const headerEl = container.closest('.map-card')?.querySelector('.card-header');
const headerH = headerEl ? headerEl.offsetHeight : 40;
const available = gsItem.offsetHeight - headerH - 4;
container.style.height = Math.max(available, 200) + 'px';
} else if (container.offsetHeight < 50) {
container.style.height = '300px';
}
// Karte initialisieren oder updaten
if (!this._map) {
this._map = L.map(container, {
zoomControl: true,
attributionControl: true,
minZoom: 2,
maxBounds: [[-85, -180], [85, 180]],
maxBoundsViscosity: 1.0,
}).setView([51.1657, 10.4515], 5); // Deutschland-Zentrum
this._applyMapTiles();
this._mapCluster = L.markerClusterGroup({
maxClusterRadius: 40,
iconCreateFunction: function(cluster) {
const count = cluster.getChildCount();
let size = 'small';
if (count >= 10) size = 'medium';
if (count >= 50) size = 'large';
return L.divIcon({
html: '' + count + '
',
className: 'map-cluster map-cluster-' + size,
iconSize: L.point(40, 40),
});
},
});
this._map.addLayer(this._mapCluster);
} else {
this._mapCluster.clearLayers();
this._mapCategoryLayers = {};
}
// Marker hinzufuegen
const bounds = [];
this._initMarkerIcons();
// Dynamische Labels verwenden (API > Default)
const catLabels = categoryLabels || this._activeCategoryLabels || this._defaultCategoryLabels;
this._activeCategoryLabels = catLabels;
const usedCategories = new Set();
locations.forEach(loc => {
const cat = loc.category || 'mentioned';
usedCategories.add(cat);
const icon = (this._markerIcons && this._markerIcons[cat]) ? this._markerIcons[cat] : undefined;
const markerOpts = icon ? { icon } : {};
const marker = L.marker([loc.lat, loc.lon], markerOpts);
// Popup-Inhalt
const catLabel = catLabels[cat] || this._defaultCategoryLabels[cat] || cat;
const catColor = this._categoryColors[cat] || '#7b7b7b';
let popupHtml = ``;
marker.bindPopup(popupHtml, { maxWidth: 300, className: 'map-popup-container' });
if (!this._mapCategoryLayers[cat]) this._mapCategoryLayers[cat] = L.featureGroup();
this._mapCategoryLayers[cat].addLayer(marker);
this._mapCluster.addLayer(marker);
bounds.push([loc.lat, loc.lon]);
});
// Ansicht auf Marker zentrieren
if (bounds.length > 0) {
if (bounds.length === 1) {
this._map.setView(bounds[0], 8);
} else {
this._map.fitBounds(bounds, { padding: [30, 30], maxZoom: 12 });
}
}
// Legende mit Checkbox-Filter
if (this._map) {
const existingLegend = document.querySelector('.map-legend-ctrl');
if (existingLegend) existingLegend.remove();
if (this._mapLegendControl) {
try { this._map.removeControl(this._mapLegendControl); } catch(e) {}
}
const legend = L.control({ position: 'bottomright' });
const self2 = this;
const legendLabels = catLabels;
legend.onAdd = function() {
const div = L.DomUtil.create('div', 'map-legend-ctrl');
L.DomEvent.disableClickPropagation(div);
let html = 'Filter';
['primary', 'secondary', 'tertiary', 'mentioned'].forEach(cat => {
if (usedCategories.has(cat) && legendLabels[cat]) {
html += '';
}
});
div.innerHTML = html;
div.addEventListener('change', function(e) {
const cb = e.target;
if (!cb.dataset.mapCat) return;
self2._toggleMapCategory(cb.dataset.mapCat, cb.checked);
});
return div;
};
legend.addTo(this._map);
this._mapLegendControl = legend;
}
// Resize-Fix fuer gridstack (mehrere Versuche, da Container-Hoehe erst spaeter steht)
const self = this;
[100, 300, 800].forEach(delay => {
setTimeout(() => {
if (!self._map) return;
self._map.invalidateSize();
if (bounds.length === 1) {
self._map.setView(bounds[0], 8);
} else if (bounds.length > 1) {
self._map.fitBounds(bounds, { padding: [30, 30], maxZoom: 12 });
}
}, delay);
});
},
_applyMapTiles() {
if (!this._map) return;
// Alte Tile-Layer entfernen
this._map.eachLayer(layer => {
if (layer instanceof L.TileLayer) this._map.removeLayer(layer);
});
// Deutsche OSM-Kacheln: deutsche Ortsnamen, einheitlich fuer beide Themes
const tileUrl = 'https://tile.openstreetmap.de/{z}/{x}/{y}.png';
const attribution = '© OpenStreetMap';
L.tileLayer(tileUrl, { attribution, maxZoom: 18, noWrap: true }).addTo(this._map);
},
updateMapTheme() {
this._applyMapTiles();
},
invalidateMap() {
if (this._map) this._map.invalidateSize();
},
retryPendingMap() {
if (this._pendingLocations && typeof L !== 'undefined') {
const locs = this._pendingLocations;
this._pendingLocations = null;
this.renderMap(locs, this._activeCategoryLabels);
}
},
_mapFullscreen: false,
_mapOriginalParent: null,
toggleMapFullscreen() {
const overlay = document.getElementById('map-fullscreen-overlay');
const fsContainer = document.getElementById('map-fullscreen-container');
const mapContainer = document.getElementById('map-container');
const statsEl = document.getElementById('map-stats');
const fsStatsEl = document.getElementById('map-fullscreen-stats');
if (!this._mapFullscreen) {
// Save original parent and height
this._mapOriginalParent = mapContainer.parentElement;
this._savedMapHeight = mapContainer.style.height || mapContainer.offsetHeight + 'px';
// Move entire map-container into fullscreen overlay
fsContainer.appendChild(mapContainer);
mapContainer.style.height = '100%';
if (statsEl && fsStatsEl) {
fsStatsEl.textContent = statsEl.textContent;
}
overlay.classList.add('active');
this._mapFullscreen = true;
// Escape key to close
this._mapFsKeyHandler = (e) => { if (e.key === 'Escape') this.toggleMapFullscreen(); };
document.addEventListener('keydown', this._mapFsKeyHandler);
setTimeout(() => { if (this._map) this._map.invalidateSize(); }, 100);
} else {
// Exit fullscreen: move map-container back to original parent
overlay.classList.remove('active');
if (this._mapOriginalParent) {
this._mapOriginalParent.appendChild(mapContainer);
}
// Restore saved height
mapContainer.style.height = this._savedMapHeight || '';
this._mapFullscreen = false;
if (this._mapFsKeyHandler) {
document.removeEventListener('keydown', this._mapFsKeyHandler);
this._mapFsKeyHandler = null;
}
const self = this;
[100, 300, 600].forEach(delay => {
setTimeout(() => { if (self._map) self._map.invalidateSize(); }, delay);
});
}
},
_mapFsKeyHandler: null,
_toggleMapCategory(cat, visible) {
const layers = this._mapCategoryLayers[cat];
if (!layers || !this._mapCluster) return;
layers.eachLayer(marker => {
if (visible) {
this._mapCluster.addLayer(marker);
} else {
this._mapCluster.removeLayer(marker);
}
});
},
/**
* HTML escapen.
*/
escape(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
};