From 892af55269f3900f2172dcebf3940975154cac1c Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 13 May 2026 22:22:07 +0000 Subject: [PATCH] feat(i18n): Export-Modal + Quellenverwaltung + Chat-Widget + Stats-Bar - Export-Modal: Titel, Bereiche, Format, alle Checkboxes (Zusammenfassung, Recherchebericht / Lagebild, Faktencheck, Quellen), PDF/DOCX, Abbrechen, Exportieren. - Quellenverwaltung-Modal: Title, 8 Filter-Labels (sr-only) + 8 Alle-* Default-Optionen, Search-Placeholder + Label, + Quelle-Button, Add- Form (URL/Erkennen/Name/Kategorie/Typ/RSS-URL/Domain/Notizen + Placeholder), Speichern/Abbrechen, Loading-State. - Stats-Bar (app.js): RSS-Feeds/Web-Quellen/Ausgeschlossen-Labels. - components.js: source-excluded-badge. - Chat-Widget: Title, alle 5 Buttons mit title+aria, Input-Placeholder. - Chat-Begruessung in chat.js auf T() umgestellt. - 50+ neue i18n-Keys. Cache-Buster components.js + chat.js + app.js auf v=20260514e gebumpt. --- src/static/dashboard.html | 106 +- src/static/i18n/de.json | 60 +- src/static/i18n/en.json | 60 +- src/static/js/app.js | 6 +- src/static/js/chat.js | 704 ++++---- src/static/js/components.js | 3324 +++++++++++++++++------------------ 6 files changed, 2188 insertions(+), 2072 deletions(-) diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 4169280..427aa6c 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -449,8 +449,8 @@ @@ -683,26 +683,26 @@ -
- AegisSight Assistent + AegisSight Assistent
- - - +
- -
@@ -726,13 +726,13 @@ - + - + - + @@ -753,26 +753,26 @@ diff --git a/src/static/i18n/de.json b/src/static/i18n/de.json index 60429d6..68a0d10 100644 --- a/src/static/i18n/de.json +++ b/src/static/i18n/de.json @@ -201,5 +201,63 @@ "source.type.rss_feed": "RSS-Feed", "source.type.telegram": "Telegram", "source.type.web": "Web-Quelle", - "modal.hint.sources_german_only": "Nur deutschsprachige Quellen (DE, AT, CH)" + "modal.hint.sources_german_only": "Nur deutschsprachige Quellen (DE, AT, CH)", + "export.sections": "Bereiche", + "export.section.summary": "Zusammenfassung", + "export.section.report": "Recherchebericht / Lagebild", + "export.section.factcheck": "Faktencheck", + "export.section.sources": "Quellen", + "export.format": "Format", + "export.format.pdf": "PDF", + "export.format.docx": "Word (DOCX)", + "export.submit": "Exportieren", + "sources_modal.title": "Quellenverwaltung", + "sources_modal.stats.rss": "RSS-Feeds", + "sources_modal.stats.web": "Web-Quellen", + "sources_modal.stats.telegram": "Telegram", + "sources_modal.stats.excluded": "Ausgeschlossen", + "sources_modal.stats.articles": "Artikel gesamt", + "sources_modal.filter.type": "Quellentyp filtern", + "sources_modal.filter.type_all": "Alle Typen", + "sources_modal.filter.category": "Kategorie filtern", + "sources_modal.filter.category_all": "Alle Kategorien", + "sources_modal.filter.political": "Politische Ausrichtung filtern", + "sources_modal.filter.political_all": "Alle Ausrichtungen", + "sources_modal.filter.mediatype": "Medientyp filtern", + "sources_modal.filter.mediatype_all": "Alle Medientypen", + "sources_modal.filter.reliability": "Glaubwürdigkeit filtern", + "sources_modal.filter.reliability_all": "Alle Glaubwürdigkeiten", + "sources_modal.filter.extern": "Externe Reputation filtern", + "sources_modal.filter.extern_all": "Externe Reputation: alle", + "sources_modal.filter.alignment": "Geopolitische Nähe filtern", + "sources_modal.filter.alignment_all": "Alle Nähen", + "sources_modal.search": "Quellen durchsuchen", + "sources_modal.search_placeholder": "Suche...", + "sources_modal.add_source": "+ Quelle", + "sources_modal.form.url_label": "URL oder Domain", + "sources_modal.form.url_placeholder": "z.B. netzpolitik.org oder t.me/kanalname", + "sources_modal.form.discover": "Erkennen", + "sources_modal.form.name_placeholder": "Wird erkannt...", + "sources_modal.form.category": "Kategorie", + "sources_modal.form.type": "Typ", + "sources_modal.form.rss_url": "RSS-Feed URL", + "sources_modal.form.domain": "Domain", + "sources_modal.form.notes": "Notizen", + "sources_modal.form.notes_placeholder": "Optional", + "sources_modal.list.loading": "Lade Quellen...", + "sources_modal.excluded_badge": "Ausgeschlossen", + "chat.title": "AegisSight Assistent", + "chat.toggle_title": "Chat-Assistent", + "chat.toggle_aria": "Chat-Assistent öffnen", + "chat.new_title": "Neuer Chat", + "chat.new_aria": "Neuen Chat starten", + "chat.fullscreen_title": "Vollbild", + "chat.fullscreen_aria": "Vollbild umschalten", + "chat.close_title": "Schließen", + "chat.close_aria": "Chat schließen", + "chat.input_placeholder": "Frage stellen...", + "chat.send_title": "Senden", + "chat.send_aria": "Nachricht senden", + "chat.greeting": "Hallo! Ich bin der AegisSight Assistent. Stell mir gerne jede Frage rund um die Bedienung des Monitors, ich helfe dir weiter.", + "stats.articles_total": "Artikel gesamt" } diff --git a/src/static/i18n/en.json b/src/static/i18n/en.json index a08db0b..b5eb728 100644 --- a/src/static/i18n/en.json +++ b/src/static/i18n/en.json @@ -201,5 +201,63 @@ "source.type.rss_feed": "RSS feed", "source.type.telegram": "Telegram", "source.type.web": "Web source", - "modal.hint.sources_german_only": "Primary-language sources only" + "modal.hint.sources_german_only": "Primary-language sources only", + "export.sections": "Sections", + "export.section.summary": "Summary", + "export.section.report": "Research report / Briefing", + "export.section.factcheck": "Fact check", + "export.section.sources": "Sources", + "export.format": "Format", + "export.format.pdf": "PDF", + "export.format.docx": "Word (DOCX)", + "export.submit": "Export", + "sources_modal.title": "Source management", + "sources_modal.stats.rss": "RSS feeds", + "sources_modal.stats.web": "Web sources", + "sources_modal.stats.telegram": "Telegram", + "sources_modal.stats.excluded": "Excluded", + "sources_modal.stats.articles": "Articles total", + "sources_modal.filter.type": "Filter by source type", + "sources_modal.filter.type_all": "All types", + "sources_modal.filter.category": "Filter by category", + "sources_modal.filter.category_all": "All categories", + "sources_modal.filter.political": "Filter by political orientation", + "sources_modal.filter.political_all": "All orientations", + "sources_modal.filter.mediatype": "Filter by media type", + "sources_modal.filter.mediatype_all": "All media types", + "sources_modal.filter.reliability": "Filter by reliability", + "sources_modal.filter.reliability_all": "All reliabilities", + "sources_modal.filter.extern": "Filter by external reputation", + "sources_modal.filter.extern_all": "External reputation: any", + "sources_modal.filter.alignment": "Filter by geopolitical alignment", + "sources_modal.filter.alignment_all": "All alignments", + "sources_modal.search": "Search sources", + "sources_modal.search_placeholder": "Search...", + "sources_modal.add_source": "+ Source", + "sources_modal.form.url_label": "URL or domain", + "sources_modal.form.url_placeholder": "e.g. example.com or t.me/channel", + "sources_modal.form.discover": "Detect", + "sources_modal.form.name_placeholder": "Detecting...", + "sources_modal.form.category": "Category", + "sources_modal.form.type": "Type", + "sources_modal.form.rss_url": "RSS feed URL", + "sources_modal.form.domain": "Domain", + "sources_modal.form.notes": "Notes", + "sources_modal.form.notes_placeholder": "Optional", + "sources_modal.list.loading": "Loading sources...", + "sources_modal.excluded_badge": "Excluded", + "chat.title": "AegisSight Assistant", + "chat.toggle_title": "Chat assistant", + "chat.toggle_aria": "Open chat assistant", + "chat.new_title": "New chat", + "chat.new_aria": "Start new chat", + "chat.fullscreen_title": "Fullscreen", + "chat.fullscreen_aria": "Toggle fullscreen", + "chat.close_title": "Close", + "chat.close_aria": "Close chat", + "chat.input_placeholder": "Ask a question...", + "chat.send_title": "Send", + "chat.send_aria": "Send message", + "chat.greeting": "Hi! I'm the AegisSight Assistant. Ask me anything about how to use the monitor and I'll guide you through.", + "stats.articles_total": "Articles total" } diff --git a/src/static/js/app.js b/src/static/js/app.js index 9d1917a..c68bbd2 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -2778,10 +2778,10 @@ async handleRefresh() { const excluded = this._myExclusions.length; bar.innerHTML = ` - ${rss.count} RSS-Feeds - ${web.count} Web-Quellen + ${rss.count} ${(typeof T === 'function' ? T('sources_modal.stats.rss', 'RSS-Feeds') : 'RSS-Feeds')} + ${web.count} ${(typeof T === 'function' ? T('sources_modal.stats.web', 'Web-Quellen') : 'Web-Quellen')} ${tg.count} Telegram - ${excluded} Ausgeschlossen + ${excluded} ${(typeof T === 'function' ? T('sources_modal.stats.excluded', 'Ausgeschlossen') : 'Ausgeschlossen')} ${stats.total_articles} Artikel gesamt `; }, diff --git a/src/static/js/chat.js b/src/static/js/chat.js index 7aaa3a2..3df5d9e 100644 --- a/src/static/js/chat.js +++ b/src/static/js/chat.js @@ -1,352 +1,352 @@ -/** - * AegisSight Chat-Assistent Widget. - */ -const Chat = { - _conversationId: null, - _isOpen: false, - _isLoading: false, - _hasGreeted: false, - _tutorialHintDismissed: false, - _isFullscreen: false, - - init() { - const btn = document.getElementById('chat-toggle-btn'); - const closeBtn = document.getElementById('chat-close-btn'); - const form = document.getElementById('chat-form'); - const input = document.getElementById('chat-input'); - - if (!btn || !form) return; - - btn.addEventListener('click', () => this.toggle()); - closeBtn.addEventListener('click', () => this.close()); - - const resetBtn = document.getElementById('chat-reset-btn'); - if (resetBtn) resetBtn.addEventListener('click', () => this.reset()); - - const fsBtn = document.getElementById('chat-fullscreen-btn'); - if (fsBtn) fsBtn.addEventListener('click', () => this.toggleFullscreen()); - - form.addEventListener('submit', (e) => { - e.preventDefault(); - this.send(); - }); - - // Enter sendet, Shift+Enter für Zeilenumbruch - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - this.send(); - } - }); - - // Auto-resize textarea - input.addEventListener('input', () => { - input.style.height = 'auto'; - input.style.height = Math.min(input.scrollHeight, 120) + 'px'; - }); - }, - - toggle() { - if (this._isOpen) { - this.close(); - } else { - this.open(); - } - }, - - open() { - const win = document.getElementById('chat-window'); - const btn = document.getElementById('chat-toggle-btn'); - if (!win) return; - win.classList.add('open'); - btn.classList.add('active'); - this._isOpen = true; - - if (!this._hasGreeted) { - this._hasGreeted = true; - this.addMessage('assistant', 'Hallo! Ich bin der AegisSight Assistent. Stell mir gerne jede Frage rund um die Bedienung des Monitors, ich helfe dir weiter.'); - } - - // Tutorial-Hinweis temporaer deaktiviert (Ueberarbeitung) - reaktivieren durch Entfernen der Kommentarzeichen: - // if (typeof Tutorial !== 'undefined' && !this._tutorialHintDismissed) { - // var oldHint = document.getElementById('chat-tutorial-hint'); - // if (oldHint) oldHint.remove(); - // this._showTutorialHint(); - // } - - // Focus auf Input - setTimeout(() => { - const input = document.getElementById('chat-input'); - if (input) input.focus(); - }, 200); - }, - - close() { - const win = document.getElementById('chat-window'); - const btn = document.getElementById('chat-toggle-btn'); - if (!win) return; - win.classList.remove('open'); - win.classList.remove('fullscreen'); - btn.classList.remove('active'); - this._isOpen = false; - this._isFullscreen = false; - const fsBtn = document.getElementById('chat-fullscreen-btn'); - if (fsBtn) { - fsBtn.title = 'Vollbild'; - fsBtn.innerHTML = ''; - } - }, - - reset() { - this._conversationId = null; - this._hasGreeted = false; - this._isLoading = false; - const container = document.getElementById('chat-messages'); - if (container) container.innerHTML = ''; - this._updateResetBtn(); - this.open(); - }, - - toggleFullscreen() { - const win = document.getElementById('chat-window'); - const btn = document.getElementById('chat-fullscreen-btn'); - if (!win) return; - this._isFullscreen = !this._isFullscreen; - win.classList.toggle('fullscreen', this._isFullscreen); - if (btn) { - btn.title = this._isFullscreen ? 'Vollbild beenden' : 'Vollbild'; - btn.innerHTML = this._isFullscreen - ? '' - : ''; - } - }, - - _updateResetBtn() { - const btn = document.getElementById('chat-reset-btn'); - if (btn) btn.style.display = this._conversationId ? '' : 'none'; - }, - - async send() { - const input = document.getElementById('chat-input'); - const text = (input.value || '').trim(); - if (!text || this._isLoading) return; - - input.value = ''; - input.style.height = 'auto'; - this.addMessage('user', text); - this._showTyping(); - this._isLoading = true; - - // Tutorial-Keywords temporaer deaktiviert (Ueberarbeitung) - reaktivieren durch Entfernen der Kommentarzeichen: - // var lowerText = text.toLowerCase(); - // if (lowerText === 'rundgang' || lowerText === 'tutorial' || lowerText === 'tour' || lowerText === 'f\u00fchrung') { - // this._hideTyping(); - // this._isLoading = false; - // this.close(); - // if (typeof Tutorial !== 'undefined') Tutorial.start(); - // return; - // } - - try { - const body = { - message: text, - conversation_id: this._conversationId, - }; - - // Aktuelle Lage mitschicken falls geoeffnet - const incidentId = this._getIncidentContext(); - if (incidentId) { - body.incident_id = incidentId; - } - - const data = await this._request(body); - this._conversationId = data.conversation_id; - this._updateResetBtn(); - this._hideTyping(); - this.addMessage('assistant', data.reply); - this._highlightUI(data.reply); - } catch (err) { - this._hideTyping(); - const msg = err.detail || err.message || 'Etwas ist schiefgelaufen. Bitte versuche es erneut.'; - this.addMessage('assistant', msg); - } finally { - this._isLoading = false; - } - }, - - addMessage(role, text) { - const container = document.getElementById('chat-messages'); - if (!container) return; - - const bubble = document.createElement('div'); - bubble.className = 'chat-message ' + role; - - // Einfache Formatierung: Zeilenumbrueche und Fettschrift - const formatted = text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/\n/g, '
'); - - bubble.innerHTML = '
' + formatted + '
'; - container.appendChild(bubble); - - // User-Nachrichten: nach unten scrollen. Antworten: zum Anfang der Antwort scrollen. - if (role === 'user') { - container.scrollTop = container.scrollHeight; - } else { - bubble.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }, - - _showTyping() { - const container = document.getElementById('chat-messages'); - if (!container) return; - const el = document.createElement('div'); - el.className = 'chat-message assistant chat-typing-msg'; - el.innerHTML = '
'; - container.appendChild(el); - container.scrollTop = container.scrollHeight; - }, - - _hideTyping() { - const el = document.querySelector('.chat-typing-msg'); - if (el) el.remove(); - }, - - _getIncidentContext() { - if (typeof App !== 'undefined' && App.currentIncidentId) { - return App.currentIncidentId; - } - return null; - }, - - async _request(body) { - const token = localStorage.getItem('osint_token'); - const resp = await fetch('/api/chat', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': token ? 'Bearer ' + token : '', - }, - body: JSON.stringify(body), - }); - if (!resp.ok) { - const data = await resp.json().catch(() => ({})); - throw data; - } - return await resp.json(); - }, - // ----------------------------------------------------------------------- - // UI-Highlight: Bedienelemente im Dashboard hervorheben wenn im Chat erwaehnt - // ----------------------------------------------------------------------- - _UI_HIGHLIGHTS: [ - { keywords: ['neue lage', 'lage erstellen', 'lage anlegen', 'recherche erstellen', 'neuen fall'], selector: '#new-incident-btn' }, - { keywords: ['theme wechseln', 'theme-umschalter', 'farbschema', 'helles design', 'dunkles design', 'hell- und dunkel', 'hellem und dunklem', 'dark mode', 'light mode'], selector: '#theme-toggle' }, - { keywords: ['barrierefreiheit', 'accessibility', 'hoher kontrast', 'focus-anzeige', 'groessere schrift', 'animationen aus'], selector: '#a11y-btn' }, - { keywords: ['abmelden', 'logout', 'ausloggen', 'abmeldung'], selector: '#logout-btn' }, - { keywords: ['benachrichtigung', 'glocken-symbol', 'abonnieren', 'abonniert'], selector: '#notification-btn' }, - { keywords: ['aktualisieren', 'refresh starten'], selector: '#refresh-btn' }, - { keywords: ['exportieren', 'export-button', 'lagebericht exportieren'], selector: 'button[onclick*="toggleExportDropdown"]' }, - { keywords: ['faktencheck', 'factcheck'], selector: '[gs-id="factcheck"]' }, - { keywords: ['kartenansicht', 'karte angezeigt', 'interaktive karte', 'geoparsing'], selector: '[gs-id="map"]' }, - { keywords: ['quellen verwalten', 'quellenverwaltung', 'quelleneinstellung', 'quellenausschluss', 'quellen-einstellung'], selector: 'button[onclick*="openSourceManagement"]' }, - { keywords: ['sichtbarkeit', 'privat oder oeffentlich', 'lage privat'], selector: '#incident-settings-btn' }, - { keywords: ['eigene lagen', 'nur eigene'], selector: '.sidebar-filter-btn[data-filter="mine"]' }, - { keywords: ['alle lagen anzeigen'], selector: '.sidebar-filter-btn[data-filter="all"]' }, - { keywords: ['feedback senden', 'feedback geben', 'rueckmeldung'], selector: 'button[onclick*="openFeedback"]' }, - { keywords: ['lage loeschen', 'lage entfernen', 'fall loeschen'], selector: '#delete-incident-btn' }, - ], - - _highlightUI(text) { - if (!text) return; - var lower = text.toLowerCase(); - var highlighted = new Set(); - for (var i = 0; i < this._UI_HIGHLIGHTS.length; i++) { - var entry = this._UI_HIGHLIGHTS[i]; - for (var k = 0; k < entry.keywords.length; k++) { - var kw = entry.keywords[k]; - if (lower.indexOf(kw) !== -1) { - var selectors = entry.selector.split(','); - for (var s = 0; s < selectors.length; s++) { - var sel = selectors[s].trim(); - if (highlighted.has(sel)) continue; - var el = document.querySelector(sel); - if (el) { - highlighted.add(sel); - el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - (function(element) { - setTimeout(function() { - element.classList.add('chat-ui-highlight'); - }, 400); - setTimeout(function() { - element.classList.remove('chat-ui-highlight'); - }, 4400); - })(el); - } - } - break; - } - } - } - }, - - async _showTutorialHint() { - var container = document.getElementById('chat-messages'); - if (!container) return; - - // API-State laden (Fallback: Standard-Hint) - var state = null; - try { state = await API.getTutorialState(); } catch(e) {} - - var hint = document.createElement('div'); - hint.className = 'chat-tutorial-hint'; - hint.id = 'chat-tutorial-hint'; - var textDiv = document.createElement('div'); - textDiv.className = 'chat-tutorial-hint-text'; - textDiv.style.cursor = 'pointer'; - - if (state && !state.completed && state.current_step !== null && state.current_step > 0) { - // Mittendrin abgebrochen - var totalSteps = (typeof Tutorial !== 'undefined') ? Tutorial._steps.length : 32; - textDiv.innerHTML = 'Tipp: Sie haben den Rundgang bei Schritt ' + (state.current_step + 1) + '/' + totalSteps + ' unterbrochen. Klicken Sie hier, um fortzusetzen.'; - textDiv.addEventListener('click', function() { - Chat.close(); - Chat._tutorialHintDismissed = true; - if (typeof Tutorial !== 'undefined') Tutorial.start(); - }); - } else if (state && state.completed) { - // Bereits abgeschlossen - textDiv.innerHTML = 'Tipp: Sie haben den Rundgang bereits abgeschlossen. Erneut starten?'; - textDiv.addEventListener('click', async function() { - Chat.close(); - Chat._tutorialHintDismissed = true; - try { await API.resetTutorialState(); } catch(e) {} - if (typeof Tutorial !== 'undefined') Tutorial.start(true); - }); - } else { - // Nie gestartet - textDiv.innerHTML = 'Tipp: Kennen Sie schon den interaktiven Rundgang? Er zeigt Ihnen Schritt für Schritt alle Funktionen des Monitors. Klicken Sie hier, um ihn zu starten.'; - textDiv.addEventListener('click', function() { - Chat.close(); - Chat._tutorialHintDismissed = true; - if (typeof Tutorial !== 'undefined') Tutorial.start(); - }); - } - - var closeBtn = document.createElement('button'); - closeBtn.className = 'chat-tutorial-hint-close'; - closeBtn.title = 'Schließen'; - closeBtn.innerHTML = '×'; - closeBtn.addEventListener('click', function(e) { - e.stopPropagation(); - hint.remove(); - Chat._tutorialHintDismissed = true; - }); - hint.appendChild(textDiv); - hint.appendChild(closeBtn); - container.appendChild(hint); - }, - -}; +/** + * AegisSight Chat-Assistent Widget. + */ +const Chat = { + _conversationId: null, + _isOpen: false, + _isLoading: false, + _hasGreeted: false, + _tutorialHintDismissed: false, + _isFullscreen: false, + + init() { + const btn = document.getElementById('chat-toggle-btn'); + const closeBtn = document.getElementById('chat-close-btn'); + const form = document.getElementById('chat-form'); + const input = document.getElementById('chat-input'); + + if (!btn || !form) return; + + btn.addEventListener('click', () => this.toggle()); + closeBtn.addEventListener('click', () => this.close()); + + const resetBtn = document.getElementById('chat-reset-btn'); + if (resetBtn) resetBtn.addEventListener('click', () => this.reset()); + + const fsBtn = document.getElementById('chat-fullscreen-btn'); + if (fsBtn) fsBtn.addEventListener('click', () => this.toggleFullscreen()); + + form.addEventListener('submit', (e) => { + e.preventDefault(); + this.send(); + }); + + // Enter sendet, Shift+Enter für Zeilenumbruch + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + this.send(); + } + }); + + // Auto-resize textarea + input.addEventListener('input', () => { + input.style.height = 'auto'; + input.style.height = Math.min(input.scrollHeight, 120) + 'px'; + }); + }, + + toggle() { + if (this._isOpen) { + this.close(); + } else { + this.open(); + } + }, + + open() { + const win = document.getElementById('chat-window'); + const btn = document.getElementById('chat-toggle-btn'); + if (!win) return; + win.classList.add('open'); + btn.classList.add('active'); + this._isOpen = true; + + if (!this._hasGreeted) { + this._hasGreeted = true; + this.addMessage('assistant', (typeof T === 'function' ? T('chat.greeting', 'Hallo! Ich bin der AegisSight Assistent. Stell mir gerne jede Frage rund um die Bedienung des Monitors, ich helfe dir weiter.') : 'Hallo! Ich bin der AegisSight Assistent. Stell mir gerne jede Frage rund um die Bedienung des Monitors, ich helfe dir weiter.')); + } + + // Tutorial-Hinweis temporaer deaktiviert (Ueberarbeitung) - reaktivieren durch Entfernen der Kommentarzeichen: + // if (typeof Tutorial !== 'undefined' && !this._tutorialHintDismissed) { + // var oldHint = document.getElementById('chat-tutorial-hint'); + // if (oldHint) oldHint.remove(); + // this._showTutorialHint(); + // } + + // Focus auf Input + setTimeout(() => { + const input = document.getElementById('chat-input'); + if (input) input.focus(); + }, 200); + }, + + close() { + const win = document.getElementById('chat-window'); + const btn = document.getElementById('chat-toggle-btn'); + if (!win) return; + win.classList.remove('open'); + win.classList.remove('fullscreen'); + btn.classList.remove('active'); + this._isOpen = false; + this._isFullscreen = false; + const fsBtn = document.getElementById('chat-fullscreen-btn'); + if (fsBtn) { + fsBtn.title = 'Vollbild'; + fsBtn.innerHTML = ''; + } + }, + + reset() { + this._conversationId = null; + this._hasGreeted = false; + this._isLoading = false; + const container = document.getElementById('chat-messages'); + if (container) container.innerHTML = ''; + this._updateResetBtn(); + this.open(); + }, + + toggleFullscreen() { + const win = document.getElementById('chat-window'); + const btn = document.getElementById('chat-fullscreen-btn'); + if (!win) return; + this._isFullscreen = !this._isFullscreen; + win.classList.toggle('fullscreen', this._isFullscreen); + if (btn) { + btn.title = this._isFullscreen ? 'Vollbild beenden' : 'Vollbild'; + btn.innerHTML = this._isFullscreen + ? '' + : ''; + } + }, + + _updateResetBtn() { + const btn = document.getElementById('chat-reset-btn'); + if (btn) btn.style.display = this._conversationId ? '' : 'none'; + }, + + async send() { + const input = document.getElementById('chat-input'); + const text = (input.value || '').trim(); + if (!text || this._isLoading) return; + + input.value = ''; + input.style.height = 'auto'; + this.addMessage('user', text); + this._showTyping(); + this._isLoading = true; + + // Tutorial-Keywords temporaer deaktiviert (Ueberarbeitung) - reaktivieren durch Entfernen der Kommentarzeichen: + // var lowerText = text.toLowerCase(); + // if (lowerText === 'rundgang' || lowerText === 'tutorial' || lowerText === 'tour' || lowerText === 'f\u00fchrung') { + // this._hideTyping(); + // this._isLoading = false; + // this.close(); + // if (typeof Tutorial !== 'undefined') Tutorial.start(); + // return; + // } + + try { + const body = { + message: text, + conversation_id: this._conversationId, + }; + + // Aktuelle Lage mitschicken falls geoeffnet + const incidentId = this._getIncidentContext(); + if (incidentId) { + body.incident_id = incidentId; + } + + const data = await this._request(body); + this._conversationId = data.conversation_id; + this._updateResetBtn(); + this._hideTyping(); + this.addMessage('assistant', data.reply); + this._highlightUI(data.reply); + } catch (err) { + this._hideTyping(); + const msg = err.detail || err.message || 'Etwas ist schiefgelaufen. Bitte versuche es erneut.'; + this.addMessage('assistant', msg); + } finally { + this._isLoading = false; + } + }, + + addMessage(role, text) { + const container = document.getElementById('chat-messages'); + if (!container) return; + + const bubble = document.createElement('div'); + bubble.className = 'chat-message ' + role; + + // Einfache Formatierung: Zeilenumbrueche und Fettschrift + const formatted = text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\n/g, '
'); + + bubble.innerHTML = '
' + formatted + '
'; + container.appendChild(bubble); + + // User-Nachrichten: nach unten scrollen. Antworten: zum Anfang der Antwort scrollen. + if (role === 'user') { + container.scrollTop = container.scrollHeight; + } else { + bubble.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, + + _showTyping() { + const container = document.getElementById('chat-messages'); + if (!container) return; + const el = document.createElement('div'); + el.className = 'chat-message assistant chat-typing-msg'; + el.innerHTML = '
'; + container.appendChild(el); + container.scrollTop = container.scrollHeight; + }, + + _hideTyping() { + const el = document.querySelector('.chat-typing-msg'); + if (el) el.remove(); + }, + + _getIncidentContext() { + if (typeof App !== 'undefined' && App.currentIncidentId) { + return App.currentIncidentId; + } + return null; + }, + + async _request(body) { + const token = localStorage.getItem('osint_token'); + const resp = await fetch('/api/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': token ? 'Bearer ' + token : '', + }, + body: JSON.stringify(body), + }); + if (!resp.ok) { + const data = await resp.json().catch(() => ({})); + throw data; + } + return await resp.json(); + }, + // ----------------------------------------------------------------------- + // UI-Highlight: Bedienelemente im Dashboard hervorheben wenn im Chat erwaehnt + // ----------------------------------------------------------------------- + _UI_HIGHLIGHTS: [ + { keywords: ['neue lage', 'lage erstellen', 'lage anlegen', 'recherche erstellen', 'neuen fall'], selector: '#new-incident-btn' }, + { keywords: ['theme wechseln', 'theme-umschalter', 'farbschema', 'helles design', 'dunkles design', 'hell- und dunkel', 'hellem und dunklem', 'dark mode', 'light mode'], selector: '#theme-toggle' }, + { keywords: ['barrierefreiheit', 'accessibility', 'hoher kontrast', 'focus-anzeige', 'groessere schrift', 'animationen aus'], selector: '#a11y-btn' }, + { keywords: ['abmelden', 'logout', 'ausloggen', 'abmeldung'], selector: '#logout-btn' }, + { keywords: ['benachrichtigung', 'glocken-symbol', 'abonnieren', 'abonniert'], selector: '#notification-btn' }, + { keywords: ['aktualisieren', 'refresh starten'], selector: '#refresh-btn' }, + { keywords: ['exportieren', 'export-button', 'lagebericht exportieren'], selector: 'button[onclick*="toggleExportDropdown"]' }, + { keywords: ['faktencheck', 'factcheck'], selector: '[gs-id="factcheck"]' }, + { keywords: ['kartenansicht', 'karte angezeigt', 'interaktive karte', 'geoparsing'], selector: '[gs-id="map"]' }, + { keywords: ['quellen verwalten', 'quellenverwaltung', 'quelleneinstellung', 'quellenausschluss', 'quellen-einstellung'], selector: 'button[onclick*="openSourceManagement"]' }, + { keywords: ['sichtbarkeit', 'privat oder oeffentlich', 'lage privat'], selector: '#incident-settings-btn' }, + { keywords: ['eigene lagen', 'nur eigene'], selector: '.sidebar-filter-btn[data-filter="mine"]' }, + { keywords: ['alle lagen anzeigen'], selector: '.sidebar-filter-btn[data-filter="all"]' }, + { keywords: ['feedback senden', 'feedback geben', 'rueckmeldung'], selector: 'button[onclick*="openFeedback"]' }, + { keywords: ['lage loeschen', 'lage entfernen', 'fall loeschen'], selector: '#delete-incident-btn' }, + ], + + _highlightUI(text) { + if (!text) return; + var lower = text.toLowerCase(); + var highlighted = new Set(); + for (var i = 0; i < this._UI_HIGHLIGHTS.length; i++) { + var entry = this._UI_HIGHLIGHTS[i]; + for (var k = 0; k < entry.keywords.length; k++) { + var kw = entry.keywords[k]; + if (lower.indexOf(kw) !== -1) { + var selectors = entry.selector.split(','); + for (var s = 0; s < selectors.length; s++) { + var sel = selectors[s].trim(); + if (highlighted.has(sel)) continue; + var el = document.querySelector(sel); + if (el) { + highlighted.add(sel); + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + (function(element) { + setTimeout(function() { + element.classList.add('chat-ui-highlight'); + }, 400); + setTimeout(function() { + element.classList.remove('chat-ui-highlight'); + }, 4400); + })(el); + } + } + break; + } + } + } + }, + + async _showTutorialHint() { + var container = document.getElementById('chat-messages'); + if (!container) return; + + // API-State laden (Fallback: Standard-Hint) + var state = null; + try { state = await API.getTutorialState(); } catch(e) {} + + var hint = document.createElement('div'); + hint.className = 'chat-tutorial-hint'; + hint.id = 'chat-tutorial-hint'; + var textDiv = document.createElement('div'); + textDiv.className = 'chat-tutorial-hint-text'; + textDiv.style.cursor = 'pointer'; + + if (state && !state.completed && state.current_step !== null && state.current_step > 0) { + // Mittendrin abgebrochen + var totalSteps = (typeof Tutorial !== 'undefined') ? Tutorial._steps.length : 32; + textDiv.innerHTML = 'Tipp: Sie haben den Rundgang bei Schritt ' + (state.current_step + 1) + '/' + totalSteps + ' unterbrochen. Klicken Sie hier, um fortzusetzen.'; + textDiv.addEventListener('click', function() { + Chat.close(); + Chat._tutorialHintDismissed = true; + if (typeof Tutorial !== 'undefined') Tutorial.start(); + }); + } else if (state && state.completed) { + // Bereits abgeschlossen + textDiv.innerHTML = 'Tipp: Sie haben den Rundgang bereits abgeschlossen. Erneut starten?'; + textDiv.addEventListener('click', async function() { + Chat.close(); + Chat._tutorialHintDismissed = true; + try { await API.resetTutorialState(); } catch(e) {} + if (typeof Tutorial !== 'undefined') Tutorial.start(true); + }); + } else { + // Nie gestartet + textDiv.innerHTML = 'Tipp: Kennen Sie schon den interaktiven Rundgang? Er zeigt Ihnen Schritt für Schritt alle Funktionen des Monitors. Klicken Sie hier, um ihn zu starten.'; + textDiv.addEventListener('click', function() { + Chat.close(); + Chat._tutorialHintDismissed = true; + if (typeof Tutorial !== 'undefined') Tutorial.start(); + }); + } + + var closeBtn = document.createElement('button'); + closeBtn.className = 'chat-tutorial-hint-close'; + closeBtn.title = 'Schließen'; + closeBtn.innerHTML = '×'; + closeBtn.addEventListener('click', function(e) { + e.stopPropagation(); + hint.remove(); + Chat._tutorialHintDismissed = true; + }); + hint.appendChild(textDiv); + hint.appendChild(closeBtn); + container.appendChild(hint); + }, + +}; diff --git a/src/static/js/components.js b/src/static/js/components.js index 447dd8c..4e7ee6b 100644 --- a/src/static/js/components.js +++ b/src/static/js/components.js @@ -1,1662 +1,1662 @@ -/** - * 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. - */ - // Faktencheck-Status-Labels (org-sprach-relativ via T()). - // Die DE-Fallbacks sind die historische Quelle der Wahrheit; bei - // englischer Org liefert T() den EN-Text aus i18n/en.json. - _fcLabelDefaultsDE: { - 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', - }, - _fcTooltipDefaultsDE: { - 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.', - }, - _fcChipDefaultsDE: { - confirmed: 'Bestätigt', - unconfirmed: 'Unbestätigt', - contradicted: 'Widerlegt', - developing: 'Unklar', - established: 'Gesichert', - disputed: 'Umstritten', - unverified: 'Ungeprüft', - }, - - get factCheckLabels() { - const out = {}; - for (const k of Object.keys(this._fcLabelDefaultsDE)) { - out[k] = (typeof T === 'function') - ? T('fc.label.' + k, this._fcLabelDefaultsDE[k]) - : this._fcLabelDefaultsDE[k]; - } - return out; - }, - get factCheckTooltips() { - const out = {}; - for (const k of Object.keys(this._fcTooltipDefaultsDE)) { - out[k] = (typeof T === 'function') - ? T('fc.tooltip.' + k, this._fcTooltipDefaultsDE[k]) - : this._fcTooltipDefaultsDE[k]; - } - return out; - }, - get factCheckChipLabels() { - const out = {}; - for (const k of Object.keys(this._fcChipDefaultsDE)) { - out[k] = (typeof T === 'function') - ? T('fc.chip.' + k, this._fcChipDefaultsDE[k]) - : this._fcChipDefaultsDE[k]; - } - return out; - }, - - 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 ` -
${items}
`; - }, - - renderFactCheck(fc) { - const urls = (fc.evidence || '').match(/https?:\/\/[^\s,)]+/g) || []; - const count = urls.length; - return ` -
- - ${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 fallback = { - queued: 'In Warteschlange', - researching: 'Recherchiert...', - deep_researching: 'Tiefenrecherche...', - analyzing: 'Analysiert...', - factchecking: 'Faktencheck...', - cancelling: 'Wird abgebrochen...', - }; - if (!fallback[step]) return step; - return (typeof T === 'function') - ? T('progress.status.' + step, fallback[step]) - : fallback[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 incident-view (Header + Tab-Panels gemeinsam). - const blurTarget = document.getElementById('incident-view'); - if (blurTarget) { - blurTarget.classList.add('refresh-blurred'); - // Sicherheitsnetz: bei viel DOM-Reshuffle im selben Tick - // (Display-Wechsel, renderSidebar, leere innerHTML) greift - // CSS filter:blur erst beim naechsten Layout-Pass. Im - // naechsten Frame nochmal setzen — idempotent. - requestAnimationFrame(() => { - if (state && state.isFirst) blurTarget.classList.add('refresh-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 - haengt von Status ab (queued = wartet, cancelling = bricht ab, sonst laeuft) - const titleEl = document.getElementById('progress-popup-title'); - if (titleEl) { - const _t = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb; - let title; - if (status === 'queued') { - const pos = (state && state._queuePos) ? ' (#' + state._queuePos + ')' : ''; - title = _t('progress.title.queued', 'In Warteschlange') + pos; - } else if (status === 'cancelling') { - title = _t('progress.title.cancelling', 'Wird abgebrochen\u2026'); - } else if (state.isFirst) { - title = _t('progress.title.first_refresh', 'Erste Recherche l\u00e4uft'); - } else { - title = _t('progress.title.refresh', 'Aktualisierung l\u00e4uft'); - } - titleEl.textContent = title; - } - - // 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 blurTarget = document.getElementById('incident-view'); - if (blurTarget) blurTarget.classList.remove('refresh-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 blurTarget = document.getElementById('incident-view'); - if (blurTarget) blurTarget.classList.remove('refresh-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() }; - }, - - /** - * Parst sources: akzeptiert Array (neu, vom /sources-Endpunkt) ODER - * JSON-String (alt, aus sources_json) fuer Rueckwaertskompatibilitaet. - */ - _parseSources(input) { - if (!input) return []; - if (Array.isArray(input)) return input; - try { - const parsed = JSON.parse(input); - return Array.isArray(parsed) ? parsed : []; - } catch (e) { - return []; - } - }, - - /** - * Rendert die Zusammenfassung als HTML (Bullet Points). - */ - renderZusammenfassung(text, sourcesJson) { - if (!text) return 'Noch keine Zusammenfassung.'; - const sources = this._parseSources(sourcesJson); - // 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.'; - const sources = this._parseSources(sourcesJson); - - 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 url = (src && src.url) || ''; - const tgMatch = url.match(/^https?:\/\/t\.me\/([^\/?#]+)/i); - const label = tgMatch ? displayName + ' (t.me/' + tgMatch[1] + ')' : displayName; - const esc = this.escape(label); - const titleEsc = this.escape(displayName); - if (src && src.url) { - return `${esc}`; - } - return `${esc}`; - }; - - const cards = bulletLines.map(line => { - const m = bulletRe.exec(line); - if (!m) { - const body = this.escape(line.replace(/^-\s*/, '')); - return `
    ${body}
    `; - } - 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|URL1, Name2|URL2} oder {Name1, Name2} am Bullet-Ende - const trailing = trailingNamesRe.exec(rawBody); - if (trailing) { - rawBody = rawBody.replace(trailingNamesRe, '').trim(); - const items = trailing[1].split(',').map(s => s.trim()).filter(Boolean); - const seen = new Set(); - pillsHtml = items.map(item => { - // Split am ersten Pipe: "Name|URL" → Name + URL; ohne Pipe nur Name - const pipeIdx = item.indexOf('|'); - const itemName = pipeIdx >= 0 ? item.slice(0, pipeIdx).trim() : item.trim(); - const itemUrl = pipeIdx >= 0 ? item.slice(pipeIdx + 1).trim() : ''; - if (!itemName) return ''; - const key = normalize(itemName); - if (seen.has(key)) return ''; - seen.add(key); - if (/^(unbekannt|unknown|n\/a|keine)$/i.test(itemName)) return ''; - // Wenn URL direkt mitgeliefert wurde: eindeutiger Link, keine Kollision mit sources_json moeglich - if (itemUrl) { - return buildPill({ name: itemName, url: itemUrl }, itemName); - } - // Fallback (Legacy-Bullets ohne URL): Name-Lookup in sources_json - const src = lookupByName(itemName); - return buildPill(src, itemName); - }).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}` : ''; - const timeHtml = `${this.escape(time)} \u00b7 ${this.escape(date)}`; - - return `
    ${sourcesHtml}${timeHtml}
    ${cleanBody}
    `; - }); - - return `
    ${cards.join('')}
    `; - }, - - - renderSummary(summary, sourcesJson, incidentType) { - if (!summary) return 'Noch keine Zusammenfassung.'; - - const sources = this._parseSources(sourcesJson); - - // 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 '
    ' + thead + tbody + '
    '; - }); - - // 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. - */ - /** - * Quellenuebersicht aus Aggregat-Endpunkt rendern (alle Artikel der Lage, - * unabhaengig von Paginierung im Frontend). - * data: {total, sources: [{source, article_count, languages: []}], language_counts: [{language, cnt}]} - */ - renderSourceOverviewFromSummary(data) { - if (!data || !data.sources || data.sources.length === 0) return ''; - - const langChips = (data.language_counts || []) - .map(l => `${(l.language || 'de').toUpperCase()} ${l.cnt}`) - .join(''); - - let html = `
    `; - html += `${data.total} Artikel aus ${data.sources.length} Quellen`; - html += `
    ${langChips}
    `; - html += `
    `; - - html += '
    '; - data.sources.forEach(s => { - const langs = (s.languages || ['de']).map(l => (l || 'de').toUpperCase()).join('/'); - const sourceName = this.escape(s.source || 'Unbekannt'); - html += ``; - }); - html += '
    '; - - return html; - }, - - 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 += `${articles.length} Artikel aus ${sources.length} Quellen`; - html += `
    ${langChips}
    `; - 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', - }, - - _politicalLabels: { - links_extrem: { short: 'L+', full: 'Links (extrem)' }, - links: { short: 'L', full: 'Links' }, - mitte_links: { short: 'ML', full: 'Mitte-Links' }, - liberal: { short: 'LIB', full: 'Liberal' }, - mitte: { short: 'M', full: 'Mitte' }, - konservativ: { short: 'KON', full: 'Konservativ' }, - mitte_rechts: { short: 'MR', full: 'Mitte-Rechts' }, - rechts: { short: 'R', full: 'Rechts' }, - rechts_extrem: { short: 'R+', full: 'Rechts (extrem)' }, - na: { short: '?', full: 'Nicht eingeordnet' }, - }, - _reliabilityLabels: { - sehr_hoch: 'Sehr hoch', - hoch: 'Hoch', - gemischt: 'Gemischt', - niedrig: 'Niedrig', - sehr_niedrig: 'Sehr niedrig', - na: 'Nicht eingeordnet', - }, - _mediaTypeLabels: { - tageszeitung: 'Tageszeitung', - wochenzeitung: 'Wochenzeitung', - magazin: 'Magazin', - tv_sender: 'TV-Sender', - radio: 'Radio', - oeffentlich_rechtlich: 'Öffentlich-Rechtlich', - nachrichtenagentur: 'Nachrichtenagentur', - online_only: 'Online-only', - blog: 'Blog', - telegram_kanal: 'Telegram-Kanal', - telegram_bot: 'Telegram-Bot', - podcast: 'Podcast', - social_media: 'Social Media', - imageboard: 'Imageboard', - think_tank: 'Think Tank', - ngo: 'NGO', - behoerde: 'Behörde', - staatsmedium: 'Staatsmedium', - fachmedium: 'Fachmedium', - sonstige: 'Sonstige', - }, - _alignmentLabels: { - prorussisch: 'prorussisch', - proiranisch: 'proiranisch', - prowestlich: 'prowestlich', - proukrainisch: 'proukrainisch', - prochinesisch: 'prochinesisch', - projapanisch: 'projapanisch', - proisraelisch: 'proisraelisch', - propalaestinensisch: 'propalästinensisch', - protuerkisch: 'protürkisch', - panarabisch: 'panarabisch', - neutral: 'neutral', - sonstige: 'sonstige', - }, - - _renderClassificationBadges(feed) { - const parts = []; - const pol = feed.political_orientation; - if (pol && pol !== 'na') { - const label = this._politicalLabels[pol] || { short: pol, full: pol }; - parts.push(`${this.escape(label.short)}`); - } - const rel = feed.reliability; - if (rel && rel !== 'na') { - const relLabel = this._reliabilityLabels[rel] || rel; - const relSource = feed.ifcn_signatory ? '(IFCN-Faktenchecker)' - : (feed.eu_disinfo_listed ? `(EU-Desinfo, ${feed.eu_disinfo_case_count || 0} Fälle)` - : '(LLM-Schätzung)'); - const relTitle = `Glaubwürdigkeit: ${relLabel} ${relSource}`; - parts.push(``); - } - if (feed.ifcn_signatory) { - parts.push(`✓ IFCN`); - } - if (feed.eu_disinfo_listed) { - const cnt = feed.eu_disinfo_case_count || 0; - const title = `EUvsDisinfo: ${cnt} dokumentierte Desinformations-Fälle`; - parts.push(`⚠ EU-Desinfo (${cnt})`); - } - if (feed.state_affiliated) { - parts.push(``); - } - const aligns = Array.isArray(feed.alignments) ? feed.alignments : []; - aligns.forEach(a => { - const label = this._alignmentLabels[a] || a; - parts.push(`${this.escape(label)}`); - }); - return parts.join(''); - }, - - /** - * 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 `
    -
    -
    - ${this.escape(displayName)}${notesHtml} -
    - Ausgeschlossen -
    - -
    -
    -
    `; - } - - // 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, Klassifikation) - let infoButtonHtml = ''; - const firstFeed = feeds[0] || {}; - const hasInfo = firstFeed.language || firstFeed.bias - || (firstFeed.political_orientation && firstFeed.political_orientation !== 'na') - || (firstFeed.media_type && firstFeed.media_type !== 'sonstige') - || (firstFeed.reliability && firstFeed.reliability !== 'na') - || firstFeed.state_affiliated - || firstFeed.country_code - || (Array.isArray(firstFeed.alignments) && firstFeed.alignments.length > 0); - if (hasInfo) { - const typeMap = { rss_feed: 'RSS-Feed', web_source: 'Web-Quelle', telegram_channel: 'Telegram-Kanal', podcast_feed: 'Podcast' }; - const lines = []; - lines.push('Typ: ' + (typeMap[firstFeed.source_type] || firstFeed.source_type || 'Unbekannt')); - if (firstFeed.language) lines.push('Sprache: ' + firstFeed.language); - if (firstFeed.country_code) lines.push('Land: ' + firstFeed.country_code); - if (firstFeed.media_type && firstFeed.media_type !== 'sonstige') { - lines.push('Medientyp: ' + (this._mediaTypeLabels[firstFeed.media_type] || firstFeed.media_type)); - } - if (firstFeed.political_orientation && firstFeed.political_orientation !== 'na') { - const pl = this._politicalLabels[firstFeed.political_orientation]; - lines.push('Politisch: ' + (pl ? pl.full : firstFeed.political_orientation)); - } - if (firstFeed.reliability && firstFeed.reliability !== 'na') { - const relLabel = this._reliabilityLabels[firstFeed.reliability] || firstFeed.reliability; - const relSrc = firstFeed.ifcn_signatory ? ' (IFCN-Faktenchecker)' - : (firstFeed.eu_disinfo_listed ? ` (EU-Desinfo, ${firstFeed.eu_disinfo_case_count || 0} Fälle)` - : ' (LLM-Schätzung)'); - lines.push('Glaubwürdigkeit: ' + relLabel + relSrc); - } - if (firstFeed.ifcn_signatory) lines.push('IFCN-Faktenchecker: ja'); - if (firstFeed.eu_disinfo_listed) { - lines.push(`EUvsDisinfo: ${firstFeed.eu_disinfo_case_count || 0} Fälle` + (firstFeed.eu_disinfo_last_seen ? ` (zuletzt ${firstFeed.eu_disinfo_last_seen})` : '')); - } - if (firstFeed.state_affiliated) lines.push('Staatsnah: ja'); - if (Array.isArray(firstFeed.alignments) && firstFeed.alignments.length > 0) { - const labels = firstFeed.alignments.map(a => this._alignmentLabels[a] || a); - lines.push('Geopolitische Nähe: ' + labels.join(', ')); - } - if (firstFeed.bias) lines.push('Notiz: ' + firstFeed.bias); - const tooltipText = this.escape(lines.join('\n')); - infoButtonHtml = ` `; - } - - const classificationBadges = this._renderClassificationBadges(firstFeed); - - return `
    -
    - ${toggleIcon} -
    - ${this.escape(displayName)}${infoButtonHtml} -
    - ${catLabel} - ${classificationBadges ? `${classificationBadges}` : ''} - ${feedCountBadge} -
    - ${!isGlobal && !hasMultiple && feeds[0]?.id ? `` : ''} - - ${!isGlobal ? `` : ''} -
    -
    - ${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 = `
    `; - popupHtml += `
    ${this.escape(loc.location_name)}`; - if (loc.country_code) popupHtml += ` ${this.escape(loc.country_code)}`; - popupHtml += `
    `; - popupHtml += `
    ${catLabel}
    `; - popupHtml += `
    ${loc.article_count} Artikel
    `; - popupHtml += `
    `; - 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 += `${headline} ${source}`; - } else { - popupHtml += `
    ${headline} ${source}
    `; - } - }); - if (loc.articles.length > maxShow) { - popupHtml += `
    +${loc.articles.length - maxShow} weitere
    `; - } - 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; - }, -}; +/** + * 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. + */ + // Faktencheck-Status-Labels (org-sprach-relativ via T()). + // Die DE-Fallbacks sind die historische Quelle der Wahrheit; bei + // englischer Org liefert T() den EN-Text aus i18n/en.json. + _fcLabelDefaultsDE: { + 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', + }, + _fcTooltipDefaultsDE: { + 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.', + }, + _fcChipDefaultsDE: { + confirmed: 'Bestätigt', + unconfirmed: 'Unbestätigt', + contradicted: 'Widerlegt', + developing: 'Unklar', + established: 'Gesichert', + disputed: 'Umstritten', + unverified: 'Ungeprüft', + }, + + get factCheckLabels() { + const out = {}; + for (const k of Object.keys(this._fcLabelDefaultsDE)) { + out[k] = (typeof T === 'function') + ? T('fc.label.' + k, this._fcLabelDefaultsDE[k]) + : this._fcLabelDefaultsDE[k]; + } + return out; + }, + get factCheckTooltips() { + const out = {}; + for (const k of Object.keys(this._fcTooltipDefaultsDE)) { + out[k] = (typeof T === 'function') + ? T('fc.tooltip.' + k, this._fcTooltipDefaultsDE[k]) + : this._fcTooltipDefaultsDE[k]; + } + return out; + }, + get factCheckChipLabels() { + const out = {}; + for (const k of Object.keys(this._fcChipDefaultsDE)) { + out[k] = (typeof T === 'function') + ? T('fc.chip.' + k, this._fcChipDefaultsDE[k]) + : this._fcChipDefaultsDE[k]; + } + return out; + }, + + 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 ` +
    ${items}
    `; + }, + + renderFactCheck(fc) { + const urls = (fc.evidence || '').match(/https?:\/\/[^\s,)]+/g) || []; + const count = urls.length; + return ` +
    + + ${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 fallback = { + queued: 'In Warteschlange', + researching: 'Recherchiert...', + deep_researching: 'Tiefenrecherche...', + analyzing: 'Analysiert...', + factchecking: 'Faktencheck...', + cancelling: 'Wird abgebrochen...', + }; + if (!fallback[step]) return step; + return (typeof T === 'function') + ? T('progress.status.' + step, fallback[step]) + : fallback[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 incident-view (Header + Tab-Panels gemeinsam). + const blurTarget = document.getElementById('incident-view'); + if (blurTarget) { + blurTarget.classList.add('refresh-blurred'); + // Sicherheitsnetz: bei viel DOM-Reshuffle im selben Tick + // (Display-Wechsel, renderSidebar, leere innerHTML) greift + // CSS filter:blur erst beim naechsten Layout-Pass. Im + // naechsten Frame nochmal setzen — idempotent. + requestAnimationFrame(() => { + if (state && state.isFirst) blurTarget.classList.add('refresh-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 - haengt von Status ab (queued = wartet, cancelling = bricht ab, sonst laeuft) + const titleEl = document.getElementById('progress-popup-title'); + if (titleEl) { + const _t = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb; + let title; + if (status === 'queued') { + const pos = (state && state._queuePos) ? ' (#' + state._queuePos + ')' : ''; + title = _t('progress.title.queued', 'In Warteschlange') + pos; + } else if (status === 'cancelling') { + title = _t('progress.title.cancelling', 'Wird abgebrochen\u2026'); + } else if (state.isFirst) { + title = _t('progress.title.first_refresh', 'Erste Recherche l\u00e4uft'); + } else { + title = _t('progress.title.refresh', 'Aktualisierung l\u00e4uft'); + } + titleEl.textContent = title; + } + + // 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 blurTarget = document.getElementById('incident-view'); + if (blurTarget) blurTarget.classList.remove('refresh-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 blurTarget = document.getElementById('incident-view'); + if (blurTarget) blurTarget.classList.remove('refresh-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() }; + }, + + /** + * Parst sources: akzeptiert Array (neu, vom /sources-Endpunkt) ODER + * JSON-String (alt, aus sources_json) fuer Rueckwaertskompatibilitaet. + */ + _parseSources(input) { + if (!input) return []; + if (Array.isArray(input)) return input; + try { + const parsed = JSON.parse(input); + return Array.isArray(parsed) ? parsed : []; + } catch (e) { + return []; + } + }, + + /** + * Rendert die Zusammenfassung als HTML (Bullet Points). + */ + renderZusammenfassung(text, sourcesJson) { + if (!text) return 'Noch keine Zusammenfassung.'; + const sources = this._parseSources(sourcesJson); + // 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.'; + const sources = this._parseSources(sourcesJson); + + 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 url = (src && src.url) || ''; + const tgMatch = url.match(/^https?:\/\/t\.me\/([^\/?#]+)/i); + const label = tgMatch ? displayName + ' (t.me/' + tgMatch[1] + ')' : displayName; + const esc = this.escape(label); + const titleEsc = this.escape(displayName); + if (src && src.url) { + return `${esc}`; + } + return `${esc}`; + }; + + const cards = bulletLines.map(line => { + const m = bulletRe.exec(line); + if (!m) { + const body = this.escape(line.replace(/^-\s*/, '')); + return `
    ${body}
    `; + } + 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|URL1, Name2|URL2} oder {Name1, Name2} am Bullet-Ende + const trailing = trailingNamesRe.exec(rawBody); + if (trailing) { + rawBody = rawBody.replace(trailingNamesRe, '').trim(); + const items = trailing[1].split(',').map(s => s.trim()).filter(Boolean); + const seen = new Set(); + pillsHtml = items.map(item => { + // Split am ersten Pipe: "Name|URL" → Name + URL; ohne Pipe nur Name + const pipeIdx = item.indexOf('|'); + const itemName = pipeIdx >= 0 ? item.slice(0, pipeIdx).trim() : item.trim(); + const itemUrl = pipeIdx >= 0 ? item.slice(pipeIdx + 1).trim() : ''; + if (!itemName) return ''; + const key = normalize(itemName); + if (seen.has(key)) return ''; + seen.add(key); + if (/^(unbekannt|unknown|n\/a|keine)$/i.test(itemName)) return ''; + // Wenn URL direkt mitgeliefert wurde: eindeutiger Link, keine Kollision mit sources_json moeglich + if (itemUrl) { + return buildPill({ name: itemName, url: itemUrl }, itemName); + } + // Fallback (Legacy-Bullets ohne URL): Name-Lookup in sources_json + const src = lookupByName(itemName); + return buildPill(src, itemName); + }).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}` : ''; + const timeHtml = `${this.escape(time)} \u00b7 ${this.escape(date)}`; + + return `
    ${sourcesHtml}${timeHtml}
    ${cleanBody}
    `; + }); + + return `
    ${cards.join('')}
    `; + }, + + + renderSummary(summary, sourcesJson, incidentType) { + if (!summary) return 'Noch keine Zusammenfassung.'; + + const sources = this._parseSources(sourcesJson); + + // 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 '
    ' + thead + tbody + '
    '; + }); + + // 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. + */ + /** + * Quellenuebersicht aus Aggregat-Endpunkt rendern (alle Artikel der Lage, + * unabhaengig von Paginierung im Frontend). + * data: {total, sources: [{source, article_count, languages: []}], language_counts: [{language, cnt}]} + */ + renderSourceOverviewFromSummary(data) { + if (!data || !data.sources || data.sources.length === 0) return ''; + + const langChips = (data.language_counts || []) + .map(l => `${(l.language || 'de').toUpperCase()} ${l.cnt}`) + .join(''); + + let html = `
    `; + html += `${data.total} Artikel aus ${data.sources.length} Quellen`; + html += `
    ${langChips}
    `; + html += `
    `; + + html += '
    '; + data.sources.forEach(s => { + const langs = (s.languages || ['de']).map(l => (l || 'de').toUpperCase()).join('/'); + const sourceName = this.escape(s.source || 'Unbekannt'); + html += ``; + }); + html += '
    '; + + return html; + }, + + 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 += `${articles.length} Artikel aus ${sources.length} Quellen`; + html += `
    ${langChips}
    `; + 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', + }, + + _politicalLabels: { + links_extrem: { short: 'L+', full: 'Links (extrem)' }, + links: { short: 'L', full: 'Links' }, + mitte_links: { short: 'ML', full: 'Mitte-Links' }, + liberal: { short: 'LIB', full: 'Liberal' }, + mitte: { short: 'M', full: 'Mitte' }, + konservativ: { short: 'KON', full: 'Konservativ' }, + mitte_rechts: { short: 'MR', full: 'Mitte-Rechts' }, + rechts: { short: 'R', full: 'Rechts' }, + rechts_extrem: { short: 'R+', full: 'Rechts (extrem)' }, + na: { short: '?', full: 'Nicht eingeordnet' }, + }, + _reliabilityLabels: { + sehr_hoch: 'Sehr hoch', + hoch: 'Hoch', + gemischt: 'Gemischt', + niedrig: 'Niedrig', + sehr_niedrig: 'Sehr niedrig', + na: 'Nicht eingeordnet', + }, + _mediaTypeLabels: { + tageszeitung: 'Tageszeitung', + wochenzeitung: 'Wochenzeitung', + magazin: 'Magazin', + tv_sender: 'TV-Sender', + radio: 'Radio', + oeffentlich_rechtlich: 'Öffentlich-Rechtlich', + nachrichtenagentur: 'Nachrichtenagentur', + online_only: 'Online-only', + blog: 'Blog', + telegram_kanal: 'Telegram-Kanal', + telegram_bot: 'Telegram-Bot', + podcast: 'Podcast', + social_media: 'Social Media', + imageboard: 'Imageboard', + think_tank: 'Think Tank', + ngo: 'NGO', + behoerde: 'Behörde', + staatsmedium: 'Staatsmedium', + fachmedium: 'Fachmedium', + sonstige: 'Sonstige', + }, + _alignmentLabels: { + prorussisch: 'prorussisch', + proiranisch: 'proiranisch', + prowestlich: 'prowestlich', + proukrainisch: 'proukrainisch', + prochinesisch: 'prochinesisch', + projapanisch: 'projapanisch', + proisraelisch: 'proisraelisch', + propalaestinensisch: 'propalästinensisch', + protuerkisch: 'protürkisch', + panarabisch: 'panarabisch', + neutral: 'neutral', + sonstige: 'sonstige', + }, + + _renderClassificationBadges(feed) { + const parts = []; + const pol = feed.political_orientation; + if (pol && pol !== 'na') { + const label = this._politicalLabels[pol] || { short: pol, full: pol }; + parts.push(`${this.escape(label.short)}`); + } + const rel = feed.reliability; + if (rel && rel !== 'na') { + const relLabel = this._reliabilityLabels[rel] || rel; + const relSource = feed.ifcn_signatory ? '(IFCN-Faktenchecker)' + : (feed.eu_disinfo_listed ? `(EU-Desinfo, ${feed.eu_disinfo_case_count || 0} Fälle)` + : '(LLM-Schätzung)'); + const relTitle = `Glaubwürdigkeit: ${relLabel} ${relSource}`; + parts.push(``); + } + if (feed.ifcn_signatory) { + parts.push(`✓ IFCN`); + } + if (feed.eu_disinfo_listed) { + const cnt = feed.eu_disinfo_case_count || 0; + const title = `EUvsDisinfo: ${cnt} dokumentierte Desinformations-Fälle`; + parts.push(`⚠ EU-Desinfo (${cnt})`); + } + if (feed.state_affiliated) { + parts.push(``); + } + const aligns = Array.isArray(feed.alignments) ? feed.alignments : []; + aligns.forEach(a => { + const label = this._alignmentLabels[a] || a; + parts.push(`${this.escape(label)}`); + }); + return parts.join(''); + }, + + /** + * 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 `
    +
    +
    + ${this.escape(displayName)}${notesHtml} +
    + ${(typeof T === 'function' ? T('sources_modal.excluded_badge', 'Ausgeschlossen') : 'Ausgeschlossen')} +
    + +
    +
    +
    `; + } + + // 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, Klassifikation) + let infoButtonHtml = ''; + const firstFeed = feeds[0] || {}; + const hasInfo = firstFeed.language || firstFeed.bias + || (firstFeed.political_orientation && firstFeed.political_orientation !== 'na') + || (firstFeed.media_type && firstFeed.media_type !== 'sonstige') + || (firstFeed.reliability && firstFeed.reliability !== 'na') + || firstFeed.state_affiliated + || firstFeed.country_code + || (Array.isArray(firstFeed.alignments) && firstFeed.alignments.length > 0); + if (hasInfo) { + const typeMap = { rss_feed: 'RSS-Feed', web_source: 'Web-Quelle', telegram_channel: 'Telegram-Kanal', podcast_feed: 'Podcast' }; + const lines = []; + lines.push('Typ: ' + (typeMap[firstFeed.source_type] || firstFeed.source_type || 'Unbekannt')); + if (firstFeed.language) lines.push('Sprache: ' + firstFeed.language); + if (firstFeed.country_code) lines.push('Land: ' + firstFeed.country_code); + if (firstFeed.media_type && firstFeed.media_type !== 'sonstige') { + lines.push('Medientyp: ' + (this._mediaTypeLabels[firstFeed.media_type] || firstFeed.media_type)); + } + if (firstFeed.political_orientation && firstFeed.political_orientation !== 'na') { + const pl = this._politicalLabels[firstFeed.political_orientation]; + lines.push('Politisch: ' + (pl ? pl.full : firstFeed.political_orientation)); + } + if (firstFeed.reliability && firstFeed.reliability !== 'na') { + const relLabel = this._reliabilityLabels[firstFeed.reliability] || firstFeed.reliability; + const relSrc = firstFeed.ifcn_signatory ? ' (IFCN-Faktenchecker)' + : (firstFeed.eu_disinfo_listed ? ` (EU-Desinfo, ${firstFeed.eu_disinfo_case_count || 0} Fälle)` + : ' (LLM-Schätzung)'); + lines.push('Glaubwürdigkeit: ' + relLabel + relSrc); + } + if (firstFeed.ifcn_signatory) lines.push('IFCN-Faktenchecker: ja'); + if (firstFeed.eu_disinfo_listed) { + lines.push(`EUvsDisinfo: ${firstFeed.eu_disinfo_case_count || 0} Fälle` + (firstFeed.eu_disinfo_last_seen ? ` (zuletzt ${firstFeed.eu_disinfo_last_seen})` : '')); + } + if (firstFeed.state_affiliated) lines.push('Staatsnah: ja'); + if (Array.isArray(firstFeed.alignments) && firstFeed.alignments.length > 0) { + const labels = firstFeed.alignments.map(a => this._alignmentLabels[a] || a); + lines.push('Geopolitische Nähe: ' + labels.join(', ')); + } + if (firstFeed.bias) lines.push('Notiz: ' + firstFeed.bias); + const tooltipText = this.escape(lines.join('\n')); + infoButtonHtml = ` `; + } + + const classificationBadges = this._renderClassificationBadges(firstFeed); + + return `
    +
    + ${toggleIcon} +
    + ${this.escape(displayName)}${infoButtonHtml} +
    + ${catLabel} + ${classificationBadges ? `${classificationBadges}` : ''} + ${feedCountBadge} +
    + ${!isGlobal && !hasMultiple && feeds[0]?.id ? `` : ''} + + ${!isGlobal ? `` : ''} +
    +
    + ${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 = `
    `; + popupHtml += `
    ${this.escape(loc.location_name)}`; + if (loc.country_code) popupHtml += ` ${this.escape(loc.country_code)}`; + popupHtml += `
    `; + popupHtml += `
    ${catLabel}
    `; + popupHtml += `
    ${loc.article_count} Artikel
    `; + popupHtml += `
    `; + 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 += `${headline} ${source}`; + } else { + popupHtml += `
    ${headline} ${source}
    `; + } + }); + if (loc.articles.length > maxShow) { + popupHtml += `
    +${loc.articles.length - maxShow} weitere
    `; + } + 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; + }, +};