Claude Haiku 4.5 laesst gelegentlich den fuehrenden Dash oder den zweiten
Datums-Punkt im Bullet-Format weg (z.B. "[18.04 21:49]" statt
"- [18.04. 21:49]"). Der strikte Parser-Regex verwarf dadurch alle Bullets.
- Regex akzeptiert nun Dash als optional und zweiten Datums-Punkt als optional
- Parser normalisiert Datum + Zeit auf kanonisches Format "DD.MM. HH:MM" mit Zero-Padding
- Frontend-Regex analog toleranter (auch fuer Altdaten-Mix)
- OUTPUT-FORMAT-Hinweis im Prompt verschaerft ("JEDE Zeile beginnt mit - ")
Backfill-Skript (scripts/backfill_latest_developments.py): Laedt die N
neuesten Artikel einer Lage aus der DB und ruft generate_latest_developments
mit previous_developments=None auf — nuetzlich nach DB-Cleanups, wenn die
inkrementelle Logik zu wenige Bullets liefert.
Einmaliger Run fuer Lage #66 (Militaerblogger): 8 Bullets vom 18.04. mit
aufgeloesten Quellen (Spiegel, Guardian, Bloomberg, n-tv, Telegram-Kanaele).
1425 Zeilen
62 KiB
JavaScript
1425 Zeilen
62 KiB
JavaScript
/**
|
|
* 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 = '<div class="incident-refresh-status queued-status" id="sidebar-refresh-' + incident.id + '"><span>Warteschlange' + pos + '</span></div>';
|
|
} else {
|
|
refreshClass = ' refreshing-item';
|
|
const label = this._getStepLabel(step);
|
|
refreshStatusHtml = '<div class="incident-refresh-status" id="sidebar-refresh-' + incident.id + '"><span class="mini-spinner"></span><span>' + label + '</span><span id="sidebar-refresh-timer-' + incident.id + '" style="margin-left:auto;font-family:var(--font-mono,monospace);font-size:10px;color:var(--text-disabled);"></span></div>';
|
|
}
|
|
}
|
|
|
|
return `
|
|
<div class="incident-item ${activeClass}${refreshClass}" data-id="${incident.id}" onclick="App.selectIncident(${incident.id})" role="button" tabindex="0">
|
|
<span class="incident-dot ${dotClass}" id="dot-${incident.id}" aria-hidden="true"></span>
|
|
<div style="flex:1;min-width:0;">
|
|
<div class="incident-name">${this.escape(incident.title)}</div>
|
|
<div class="incident-meta">${incident.article_count} Artikel · ${this.escape(creator)}</div>
|
|
${refreshStatusHtml}
|
|
</div>
|
|
${incident.visibility === 'private' ? '<span class="badge badge-private" style="font-size:9px;" aria-label="Private Lage">PRIVAT</span>' : ''}
|
|
${incident.refresh_mode === 'auto' ? '<span class="badge badge-auto" role="img" aria-label="Auto-Refresh aktiv">↻</span>' : ''}
|
|
</div>
|
|
`;
|
|
},
|
|
|
|
/**
|
|
* 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 `<label class="fc-dropdown-item" data-status="${status}" title="${tooltip}">
|
|
<input type="checkbox" checked onchange="App.toggleFactCheckFilter('${status}')">
|
|
<span class="factcheck-icon ${status}">${icon}</span>
|
|
<span class="fc-dropdown-label">${chipLabel}</span>
|
|
<span class="fc-dropdown-count">${count}</span>
|
|
</label>`;
|
|
}).join('');
|
|
|
|
return `<button class="fc-dropdown-toggle" onclick="App.toggleFcDropdown(event)" aria-haspopup="true" aria-expanded="false">Filter</button>
|
|
<div class="fc-dropdown-menu" id="fc-dropdown-menu">${items}</div>`;
|
|
},
|
|
|
|
renderFactCheck(fc) {
|
|
const urls = (fc.evidence || '').match(/https?:\/\/[^\s,)]+/g) || [];
|
|
const count = urls.length;
|
|
return `
|
|
<div class="factcheck-item" data-fc-status="${fc.status}">
|
|
<div class="factcheck-icon ${fc.status}" title="${this.factCheckTooltips[fc.status] || this.factCheckLabels[fc.status] || fc.status}" aria-hidden="true">${this.factCheckIcons[fc.status] || '?'}</div>
|
|
<span class="sr-only">${this.factCheckLabels[fc.status] || fc.status}</span>
|
|
<div style="flex:1;">
|
|
<div class="factcheck-claim">${this.escape(fc.claim)}</div>
|
|
<div style="display:flex;align-items:center;gap:6px;margin-top:2px;">
|
|
<span class="factcheck-sources">${count} Quelle${count !== 1 ? 'n' : ''}</span>
|
|
</div>
|
|
<div class="evidence-block">${this.renderEvidence(fc.evidence || '')}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
},
|
|
|
|
/**
|
|
* Evidence mit erklärenden Text UND Quellen-Chips rendern.
|
|
*/
|
|
renderEvidence(text) {
|
|
if (!text) return '<span class="evidence-empty">Keine Belege</span>';
|
|
|
|
const urls = text.match(/https?:\/\/[^\s,)]+/g) || [];
|
|
if (urls.length === 0) {
|
|
return `<span class="evidence-text">${this.escape(text)}</span>`;
|
|
}
|
|
|
|
// 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 `<a href="${this.escape(url)}" target="_blank" rel="noopener" class="evidence-chip" title="${this.escape(url)}">${this.escape(label)}</a>`;
|
|
}).join('');
|
|
|
|
const explanationHtml = explanation
|
|
? `<span class="evidence-text">${this.escape(explanation)}</span>`
|
|
: '';
|
|
|
|
return `${explanationHtml}<div class="evidence-chips">${chips}</div>`;
|
|
},
|
|
|
|
/**
|
|
* 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 = `<span class="toast-text">${this.escape(message)}</span>`;
|
|
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 = '<div class="spinner"></div>';
|
|
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 ? '<span class="total-time">Gesamtzeit: ' + totalTimeStr + '</span>' : '');
|
|
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 = '<span>Warteschlange' + (pos ? ' (#' + pos + ')' : '') + '</span>';
|
|
} else {
|
|
statusEl.className = 'incident-refresh-status';
|
|
const label = this._getStepLabel(status);
|
|
statusEl.innerHTML = '<span class="mini-spinner"></span><span>' + label + '</span><span id="sidebar-refresh-timer-' + incidentId + '" style="margin-left:auto;font-family:var(--font-mono,monospace);font-size:10px;color:var(--text-disabled);"></span>';
|
|
}
|
|
},
|
|
|
|
_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 = '<span>Warteschlange (#' + newPos + ')</span>';
|
|
});
|
|
},
|
|
|
|
|
|
// === 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 '<span style="color:var(--text-disabled);">Noch keine Zusammenfassung.</span>';
|
|
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, '<li>$1</li>');
|
|
html = html.replace(/(<li>.*<\/li>\n?)+/gs, '<ul style="margin:4px 0 4px 18px;line-height:1.7;">$&</ul>');
|
|
// Zeilenumbrueche
|
|
html = html.replace(/\n(?!<)/g, '<br>');
|
|
html = html.replace(/(<br>){2,}/g, '<br>');
|
|
// 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 `<a href="${this.escape(src.url)}" target="_blank" rel="noopener" class="citation" title="${this.escape(src.name)}">[${num}]</a>`;
|
|
}
|
|
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 '<span style="color:var(--text-disabled);">Noch keine Entwicklungen erfasst.</span>';
|
|
let sources = [];
|
|
try { sources = JSON.parse(sourcesJson || '[]'); } catch(e) {}
|
|
|
|
const bulletLines = text.split("\n").map(l => l.trim()).filter(l => l && (l.startsWith("- ") || l.startsWith("[")));
|
|
if (bulletLines.length === 0) {
|
|
return this.renderZusammenfassung(text, sourcesJson);
|
|
}
|
|
|
|
const bulletRe = /^(?:-\s*)?\[\s*(\d{1,2})\.(\d{1,2})\.?(?:\d{2,4})?\s+(\d{1,2}:\d{2})\s*\]\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);
|
|
if (src && src.url) {
|
|
return `<a href="${this.escape(src.url)}" target="_blank" rel="noopener" class="dev-source-pill" title="${esc}">${esc}</a>`;
|
|
}
|
|
return `<span class="dev-source-pill" title="${esc}">${esc}</span>`;
|
|
};
|
|
|
|
const cards = bulletLines.map(line => {
|
|
const m = bulletRe.exec(line);
|
|
if (!m) {
|
|
const body = this.escape(line.replace(/^-\s*/, ''));
|
|
return `<div class="dev-bullet"><div class="dev-body">${body}</div></div>`;
|
|
}
|
|
const day = m[1].padStart(2, '0');
|
|
const month = m[2].padStart(2, '0');
|
|
const date = `${day}.${month}.`;
|
|
const time = m[3];
|
|
let rawBody = m[4];
|
|
|
|
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 ? `<span class="dev-sources">${pillsHtml}</span>` : '<span class="dev-sources"></span>';
|
|
const timeHtml = `<span class="dev-time" title="${this.escape(date + ' ' + time)}">${this.escape(time)} \u00b7 ${this.escape(date)}</span>`;
|
|
|
|
return `<div class="dev-bullet"><div class="dev-bullet-head">${sourcesHtml}${timeHtml}</div><div class="dev-body">${cleanBody}</div></div>`;
|
|
});
|
|
|
|
return `<div class="dev-list">${cards.join('')}</div>`;
|
|
},
|
|
|
|
|
|
renderSummary(summary, sourcesJson, incidentType) {
|
|
if (!summary) return '<span style="color:var(--text-tertiary);">Noch keine Zusammenfassung.</span>';
|
|
|
|
let sources = [];
|
|
try { sources = JSON.parse(sourcesJson || '[]'); } catch(e) {}
|
|
|
|
// Markdown-Rendering
|
|
let html = this.escape(summary);
|
|
|
|
// ## Überschriften
|
|
html = html.replace(/^## (.+)$/gm, '<h3 class="briefing-heading">$1</h3>');
|
|
// **Fettdruck**
|
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
// Listen (- Item)
|
|
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
|
|
html = html.replace(/(<li>.*<\/li>\n?)+/gs, '<ul>$&</ul>');
|
|
// Zeilenumbrüche (aber nicht nach Headings/Listen)
|
|
html = html.replace(/\n(?!<)/g, '<br>');
|
|
// Überflüssige <br> nach Block-Elementen entfernen + doppelte <br> zusammenfassen
|
|
html = html.replace(/<\/h3>(<br>)+/g, '</h3>');
|
|
html = html.replace(/<\/ul>(<br>)+/g, '</ul>');
|
|
html = html.replace(/(<br>){2,}/g, '<br>');
|
|
|
|
// Markdown-Tabellen rendern
|
|
html = html.replace(/(?:^|<br>)((?:\|.+\|(?:<br>|$))+)/g, function(match, tableBlock) {
|
|
var rows = tableBlock.split('<br>').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 = '<thead><tr>' + headerCells.map(function(c) { return '<th>' + c + '</th>'; }).join('') + '</tr></thead>';
|
|
var tbody = '<tbody>' + rows.slice(2).map(function(r) {
|
|
if (isSep(r)) return '';
|
|
var cells = parseRow(r);
|
|
return '<tr>' + cells.map(function(c) { return '<td>' + c + '</td>'; }).join('') + '</tr>';
|
|
}).join('') + '</tbody>';
|
|
return '<div class="summary-table-wrap"><table class="summary-table">' + thead + tbody + '</table></div>';
|
|
});
|
|
|
|
// 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 `<a href="${this.escape(src.url)}" target="_blank" rel="noopener" class="citation" title="${this.escape(src.name)}">[${num}]</a>`;
|
|
}
|
|
return match;
|
|
});
|
|
}
|
|
|
|
return `<div class="briefing-content">${html}</div>`;
|
|
},
|
|
|
|
/**
|
|
* 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]) => `<span class="source-lang-chip">${lang} <strong>${count}</strong></span>`)
|
|
.join('');
|
|
|
|
let html = `<div class="source-overview-header">`;
|
|
html += `<span class="source-overview-stat">${articles.length} Artikel aus ${sources.length} Quellen</span>`;
|
|
html += `<div class="source-lang-chips">${langChips}</div>`;
|
|
html += `</div>`;
|
|
|
|
html += '<div class="source-overview-grid">';
|
|
sources.forEach(([name, data]) => {
|
|
const langs = [...data.languages].map(l => l.toUpperCase()).join('/');
|
|
html += `<div class="source-overview-item">
|
|
<span class="source-overview-name">${this.escape(name)}</span>
|
|
<span class="source-overview-lang">${langs}</span>
|
|
<span class="source-overview-count">${data.count}</span>
|
|
</div>`;
|
|
});
|
|
html += '</div>';
|
|
|
|
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 ? ` <span class="source-group-notes">${this.escape(excludedNotes)}</span>` : '';
|
|
return `<div class="source-group">
|
|
<div class="source-group-header excluded">
|
|
<div class="source-group-info">
|
|
<span class="source-group-name">${this.escape(displayName)}</span>${notesHtml}
|
|
</div>
|
|
<span class="source-excluded-badge">Ausgeschlossen</span>
|
|
<div class="source-group-actions">
|
|
<button class="btn btn-small btn-secondary" onclick="App.unblockDomain('${escapedDomain}')">Ausschluss aufheben</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// Aktive Domain-Gruppe
|
|
const toggleAttr = hasMultiple ? `onclick="App.toggleGroup('${escapedDomain}')" role="button" tabindex="0" aria-expanded="false"` : '';
|
|
const toggleIcon = hasMultiple ? '<span class="source-group-toggle" aria-hidden="true">▶</span>' : '<span class="source-group-toggle-placeholder"></span>';
|
|
|
|
let feedRows = '';
|
|
if (hasMultiple) {
|
|
const realFeeds = feeds.filter(f => f.source_type !== 'excluded');
|
|
feedRows = `<div class="source-group-feeds" data-domain="${escapedDomain}">`;
|
|
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 += `<div class="source-feed-row">
|
|
<span class="source-feed-connector">${connector}</span>
|
|
<span class="source-feed-name">${this.escape(feed.name)}</span>
|
|
<span class="source-type-badge type-${feed.source_type}">${typeLabel}</span>
|
|
<span class="source-feed-url" title="${this.escape(feed.url || '')}">${this.escape(urlDisplay)}</span>
|
|
${!feed.is_global ? `<button class="source-edit-btn" onclick="App.editSource(${feed.id})" title="Bearbeiten" aria-label="Bearbeiten">✎</button>
|
|
<button class="source-delete-btn" onclick="App.deleteSingleFeed(${feed.id})" title="Löschen" aria-label="Löschen">×</button>` : '<span class="source-global-badge">Grundquelle</span>'}
|
|
</div>`;
|
|
});
|
|
feedRows += '</div>';
|
|
}
|
|
|
|
const feedCountBadge = feedCount > 0
|
|
? `<span class="source-feed-count">${feedCount} Feed${feedCount !== 1 ? 's' : ''}</span>`
|
|
: '';
|
|
|
|
// 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 = ` <span class="info-icon tooltip-below" data-tooltip="${tooltipText}"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span>`;
|
|
}
|
|
|
|
return `<div class="source-group">
|
|
<div class="source-group-header" ${toggleAttr}>
|
|
${toggleIcon}
|
|
<div class="source-group-info">
|
|
<span class="source-group-name">${this.escape(displayName)}</span>${infoButtonHtml}
|
|
</div>
|
|
<span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span>
|
|
${feedCountBadge}
|
|
<div class="source-group-actions" onclick="event.stopPropagation()">
|
|
${!isGlobal && !hasMultiple && feeds[0]?.id ? `<button class="source-edit-btn" onclick="App.editSource(${feeds[0].id})" title="Bearbeiten" aria-label="Bearbeiten">✎</button>` : ''}
|
|
<button class="btn btn-small btn-secondary" onclick="App.blockDomainDirect('${escapedDomain}')">Ausschließen</button>
|
|
${!isGlobal ? `<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">×</button>` : ''}
|
|
</div>
|
|
</div>
|
|
${feedRows}
|
|
</div>`;
|
|
},
|
|
|
|
/**
|
|
* 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 = `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="42" viewBox="0 0 28 42">` +
|
|
`<path d="M14 0C6.27 0 0 6.27 0 14c0 10.5 14 28 14 28s14-17.5 14-28C28 6.27 21.73 0 14 0z" fill="${fillColor}" stroke="${strokeColor}" stroke-width="1.5"/>` +
|
|
`<circle cx="14" cy="14" r="7" fill="#fff" opacity="0.9"/>` +
|
|
`<circle cx="14" cy="14" r="4" fill="${fillColor}"/>` +
|
|
`</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: '<div><span>' + count + '</span></div>',
|
|
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 = `<div class="map-popup">`;
|
|
popupHtml += `<div class="map-popup-title">${this.escape(loc.location_name)}`;
|
|
if (loc.country_code) popupHtml += ` <span class="map-popup-cc">${this.escape(loc.country_code)}</span>`;
|
|
popupHtml += `</div>`;
|
|
popupHtml += `<div class="map-popup-category" style="font-size:11px;margin-bottom:4px;"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${catColor};margin-right:4px;vertical-align:middle;"></span>${catLabel}</div>`;
|
|
popupHtml += `<div class="map-popup-count">${loc.article_count} Artikel</div>`;
|
|
popupHtml += `<div class="map-popup-articles">`;
|
|
const maxShow = 5;
|
|
loc.articles.slice(0, maxShow).forEach(art => {
|
|
const headline = this.escape(art.headline || 'Ohne Titel');
|
|
const source = this.escape(art.source || '');
|
|
if (art.source_url) {
|
|
popupHtml += `<a href="${this.escape(art.source_url)}" target="_blank" rel="noopener" class="map-popup-article">${headline} <span class="map-popup-source">${source}</span></a>`;
|
|
} else {
|
|
popupHtml += `<div class="map-popup-article">${headline} <span class="map-popup-source">${source}</span></div>`;
|
|
}
|
|
});
|
|
if (loc.articles.length > maxShow) {
|
|
popupHtml += `<div class="map-popup-more">+${loc.articles.length - maxShow} weitere</div>`;
|
|
}
|
|
popupHtml += `</div></div>`;
|
|
|
|
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 = '<strong style="display:block;margin-bottom:6px;">Filter</strong>';
|
|
['primary', 'secondary', 'tertiary', 'mentioned'].forEach(cat => {
|
|
if (usedCategories.has(cat) && legendLabels[cat]) {
|
|
html += '<label class="map-legend-item" style="display:flex;align-items:center;gap:6px;margin:3px 0;cursor:pointer;">'
|
|
+ '<input type="checkbox" checked data-map-cat="' + cat + '" style="accent-color:' + self2._categoryColors[cat] + ';margin:0;cursor:pointer;">'
|
|
+ '<span style="width:10px;height:10px;border-radius:50%;background:' + self2._categoryColors[cat] + ';flex-shrink:0;"></span>'
|
|
+ '<span>' + legendLabels[cat] + '</span></label>';
|
|
}
|
|
});
|
|
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 = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
|
|
|
|
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;
|
|
},
|
|
};
|