From 254a518dd805a1144813fe1793000b2836bcecfe Mon Sep 17 00:00:00 2001
From: Claude Dev
Date: Wed, 25 Mar 2026 23:51:07 +0100
Subject: [PATCH] Cleanup: Backup-Dateien (.bak) entfernt
---
src/report_generator.py.bak | 485 ----
src/report_templates/report.html.bak | 199 --
src/routers/incidents.py.bak | 708 ------
src/static/dashboard.html.bak | 700 ------
src/static/js/api.js.bak | 237 --
src/static/js/app.js.bak | 3298 --------------------------
6 files changed, 5627 deletions(-)
delete mode 100644 src/report_generator.py.bak
delete mode 100644 src/report_templates/report.html.bak
delete mode 100644 src/routers/incidents.py.bak
delete mode 100644 src/static/dashboard.html.bak
delete mode 100644 src/static/js/api.js.bak
delete mode 100644 src/static/js/app.js.bak
diff --git a/src/report_generator.py.bak b/src/report_generator.py.bak
deleted file mode 100644
index f472f5a..0000000
--- a/src/report_generator.py.bak
+++ /dev/null
@@ -1,485 +0,0 @@
-"""Report-Generator: PDF und Word Berichte aus Lage-Daten."""
-import base64
-import io
-import json
-import logging
-import re
-from collections import defaultdict
-from datetime import datetime
-from pathlib import Path
-
-from jinja2 import Environment, FileSystemLoader
-from weasyprint import HTML
-from docx import Document
-from docx.shared import Inches, Pt, Cm, RGBColor
-from docx.enum.text import WD_ALIGN_PARAGRAPH
-from docx.enum.table import WD_TABLE_ALIGNMENT
-
-from config import TIMEZONE, CLAUDE_MODEL_FAST
-
-logger = logging.getLogger("osint.report")
-
-TEMPLATE_DIR = Path(__file__).parent / "report_templates"
-LOGO_PATH = Path(__file__).parent / "static" / "favicon.svg"
-
-CLASSIFICATION_LABELS = {
- "offen": "Offen",
- "dienstgebrauch": "Nur für den Dienstgebrauch",
- "vertraulich": "Vertraulich",
-}
-
-FC_STATUS_LABELS = {
- "confirmed": "Bestätigt",
- "unconfirmed": "Unbestätigt",
- "disputed": "Umstritten",
- "false": "Falsch",
-}
-
-
-def _get_logo_base64() -> str:
- """Logo als Base64 für HTML-Embedding."""
- try:
- return base64.b64encode(LOGO_PATH.read_bytes()).decode()
- except Exception:
- return ""
-
-
-def _prepare_sources(incident: dict) -> list:
- """Quellenverzeichnis aus sources_json parsen."""
- raw = incident.get("sources_json")
- if not raw:
- return []
- try:
- return json.loads(raw) if isinstance(raw, str) else raw
- except (json.JSONDecodeError, TypeError):
- return []
-
-
-def _prepare_source_stats(articles: list) -> list:
- """Quellenstatistik: Artikel pro Quelle + Sprachen."""
- source_map = defaultdict(lambda: {"count": 0, "langs": set()})
- for art in articles:
- name = art.get("source") or "Unbekannt"
- source_map[name]["count"] += 1
- source_map[name]["langs"].add((art.get("language") or "de").upper())
- stats = []
- for name, data in sorted(source_map.items(), key=lambda x: -x[1]["count"]):
- stats.append({"name": name, "count": data["count"], "languages": ", ".join(sorted(data["langs"]))})
- return stats
-
-
-def _prepare_fact_checks(fact_checks: list) -> list:
- """Faktenchecks mit Label aufbereiten."""
- result = []
- for fc in fact_checks:
- fc_copy = dict(fc)
- fc_copy["status_label"] = FC_STATUS_LABELS.get(fc.get("status", ""), fc.get("status", "Unbekannt"))
- result.append(fc_copy)
- return result
-
-
-def _prepare_timeline(articles: list) -> list:
- """Timeline aus Artikeln: sortiert nach Datum."""
- timeline = []
- for art in articles:
- pub = art.get("published_at") or art.get("collected_at") or ""
- headline = art.get("headline_de") or art.get("headline") or "Ohne Titel"
- source = art.get("source") or ""
- if pub:
- try:
- dt = datetime.fromisoformat(pub.replace("Z", "+00:00"))
- date_str = dt.strftime("%d.%m.%Y %H:%M")
- except Exception:
- date_str = pub[:16]
- else:
- date_str = ""
- timeline.append({"date": date_str, "headline": headline, "source": source, "sort_key": pub})
- timeline.sort(key=lambda x: x["sort_key"], reverse=True)
- return timeline[:100] # Max 100 Einträge
-
-
-def _markdown_to_html(text: str) -> str:
- """Einfache Markdown -> HTML Konvertierung für Lagebild."""
- if not text:
- return "Kein Lagebild verfügbar.
"
- # Basic Markdown -> HTML
- html = text
- # Headlines
- html = re.sub(r'^### (.+)$', r'\1 ', html, flags=re.MULTILINE)
- html = re.sub(r'^## (.+)$', r'\1 ', html, flags=re.MULTILINE)
- # Bold
- html = re.sub(r'\*\*(.+?)\*\*', r'\1 ', html)
- # Links [text](url)
- html = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1 ', html)
- # Bullet lists
- html = re.sub(r'^- (.+)$', r'\1 ', html, flags=re.MULTILINE)
- html = re.sub(r'(.* \n?)+', lambda m: '', html)
- # Paragraphs
- paragraphs = html.split('\n\n')
- result = []
- for p in paragraphs:
- p = p.strip()
- if not p:
- continue
- if p.startswith('{p}
')
- return '\n'.join(result)
-
-
-def _truncate_lagebild(summary_text: str, max_chars: int = 4000) -> str:
- """Lagebild für den Lagebericht auf die Zusammenfassung kürzen.
-
- Nimmt nur den ersten Abschnitt (bis zur zweiten H2/H3-Überschrift)
- oder kürzt auf max_chars Zeichen mit sauberem Abbruch am Absatzende.
- """
- if not summary_text or len(summary_text) <= max_chars:
- return summary_text
-
- lines = summary_text.split("\n")
- result_lines = []
- heading_count = 0
- char_count = 0
-
- for line in lines:
- stripped = line.strip()
- # Zähle Überschriften (## oder ###)
- if stripped.startswith("## ") or stripped.startswith("### "):
- heading_count += 1
- # Nach der 3. Überschrift abbrechen (= 2 Abschnitte)
- if heading_count > 3:
- break
-
- result_lines.append(line)
- char_count += len(line) + 1
-
- # Hard-Limit bei max_chars, aber am Absatzende abbrechen
- if char_count > max_chars and stripped == "":
- break
-
- text = "\n".join(result_lines).rstrip()
- if len(text) < len(summary_text) - 100:
- text += "\n\n*[Vollständiges Lagebild im Vollständigen Bericht]*"
- return text
-
-
-def _strip_citation_numbers(text: str) -> str:
- """Entfernt [1234]-Quellenreferenzen aus dem Text."""
- # Einzelne Referenzen: [1302]
- text = re.sub(r"\s*\[\d{1,5}\]", "", text)
- # Mehrfach-Referenzen: [725][765][768]
- text = re.sub(r"(\[\d{1,5}\]){2,}", "", text)
- # Aufräumen: Doppelte Leerzeichen
- text = re.sub(r" +", " ", text)
- return text
-
-
-
-async def generate_executive_summary(summary_text: str) -> str:
- """KI-verdichtetes Executive Summary aus dem Lagebild."""
- if not summary_text or len(summary_text.strip()) < 50:
- return "Kein Lagebild verfügbar. Executive Summary kann nicht erstellt werden. "
-
- from agents.claude_client import call_claude
-
- prompt = f"""Du bist ein Intelligence-Analyst für ein OSINT-Lagemonitoring-System.
-Verdichte das folgende Lagebild auf genau 3-5 Kernpunkte.
-
-REGELN:
-- Jeder Punkt: 1-2 Sätze, faktenbasiert
-- Fokus: Was ist passiert? Was bedeutet es? Was ist die aktuelle Dynamik?
-- Sprache: Deutsch, sachlich, prägnant
-- Format: Gib NUR die Bullet Points aus, einen pro Zeile, mit "- " am Anfang
-- KEINE Einleitung, KEINE Überschrift, NUR die Punkte
-
-LAGEBILD:
-{summary_text}"""
-
- try:
- result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
- # Robuster Parser: Akzeptiert JSON, Markdown-Listen oder Freitext
- lines = []
- text = result.strip()
- # Code-Fences entfernen (```json ... ```)
- if text.startswith("```"):
- text = re.sub(r"^```\w*\n?", "", text)
- text = re.sub(r"\n?```$", "", text)
- text = text.strip()
-
- # Fall 1: JSON-Antwort (Haiku gibt manchmal JSON zurück)
- if text.startswith("{"):
- try:
- data = json.loads(text)
- for key in data:
- if isinstance(data[key], list):
- for item in data[key]:
- clean = str(item).strip().lstrip("- ").lstrip("* ")
- if clean:
- lines.append(clean)
- break
- except json.JSONDecodeError:
- pass
-
- # Fall 2: Markdown Bullet Points
- if not lines:
- for line in text.split("\n"):
- stripped = line.strip()
- if stripped.startswith(("- ", "* ")):
- clean = stripped.lstrip("- ").lstrip("* ").strip()
- if clean:
- lines.append(clean)
-
- # Fall 3: Nummerierte Liste (1. 2. 3.)
- if not lines:
- for line in text.split("\n"):
- m = re.match(r"^\d+\.\s+(.+)", line.strip())
- if m:
- lines.append(m.group(1).strip())
-
- # Fallback: Ganzen Text als einen Punkt
- if not lines:
- lines = [text[:500]]
-
- html = "\n" + "\n".join(f"{line} " for line in lines if line) + "\n "
- return html
- except Exception as e:
- logger.error(f"Executive Summary Generierung fehlgeschlagen: {e}")
- return "Executive Summary konnte nicht generiert werden. "
-
-
-async def generate_pdf(
- incident: dict, articles: list, fact_checks: list, snapshots: list,
- scope: str, classification: str, creator: str, executive_summary_html: str,
-) -> bytes:
- """PDF-Report via WeasyPrint generieren."""
- env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
- template = env.get_template("report.html")
-
- now = datetime.now(TIMEZONE)
- incident_type_label = "Hintergrundrecherche" if incident.get("type") == "research" else "Live-Monitoring"
-
- html_content = template.render(
- incident=incident,
- incident_type_label=incident_type_label,
- classification=classification,
- classification_label=CLASSIFICATION_LABELS.get(classification, classification),
- report_date=now.strftime("%d.%m.%Y, %H:%M Uhr"),
- creator=creator,
- logo_base64=_get_logo_base64(),
- executive_summary=executive_summary_html,
- scope=scope,
- lagebild_html=_markdown_to_html(
- _strip_citation_numbers(
- _truncate_lagebild(incident.get("summary", ""), 4000) if scope == "report"
- else incident.get("summary", "")
- )
- ),
- lagebild_timestamp=(incident.get("updated_at") or "")[:16].replace("T", " "),
- sources=_prepare_sources(incident)[:30] if scope == "report" else _prepare_sources(incident),
- fact_checks=_prepare_fact_checks(fact_checks[:20] if scope == "report" else fact_checks),
- source_stats=_prepare_source_stats(articles)[:20] if scope == "report" else _prepare_source_stats(articles),
- timeline=_prepare_timeline(articles) if scope == "full" else [],
- articles=articles if scope == "full" else [],
- )
-
- # Artikel pub_date aufbereiten
- for art in articles:
- pub = art.get("published_at") or art.get("collected_at") or ""
- try:
- dt = datetime.fromisoformat(pub.replace("Z", "+00:00"))
- art["pub_date"] = dt.strftime("%d.%m.%Y")
- except Exception:
- art["pub_date"] = pub[:10] if pub else ""
-
- pdf_bytes = HTML(string=html_content).write_pdf()
- return pdf_bytes
-
-
-async def generate_docx(
- incident: dict, articles: list, fact_checks: list, snapshots: list,
- scope: str, classification: str, creator: str, executive_summary_text: str,
-) -> bytes:
- """Word-Report via python-docx generieren."""
- doc = Document()
-
- # Styles
- style = doc.styles['Normal']
- style.font.size = Pt(10)
- style.font.name = 'Calibri'
-
- # --- Deckblatt ---
- for _ in range(6):
- doc.add_paragraph()
-
- title_para = doc.add_paragraph()
- title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
- run = title_para.add_run("AegisSight Monitor")
- run.font.size = Pt(12)
- run.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
-
- doc.add_paragraph()
-
- type_label = "Hintergrundrecherche" if incident.get("type") == "research" else "Live-Monitoring"
- type_para = doc.add_paragraph()
- type_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
- run = type_para.add_run(type_label)
- run.font.size = Pt(10)
- run.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
-
- title_para2 = doc.add_paragraph()
- title_para2.alignment = WD_ALIGN_PARAGRAPH.CENTER
- run = title_para2.add_run(incident.get("title", ""))
- run.font.size = Pt(24)
- run.font.bold = True
- run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)
-
- if incident.get("description"):
- desc_para = doc.add_paragraph()
- desc_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
- run = desc_para.add_run(incident["description"])
- run.font.size = Pt(11)
- run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
-
- doc.add_paragraph()
-
- # Klassifizierung
- class_label = CLASSIFICATION_LABELS.get(classification, classification)
- class_para = doc.add_paragraph()
- class_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
- run = class_para.add_run(f"— {class_label} —")
- run.font.size = Pt(11)
- run.font.bold = True
- colors = {"offen": RGBColor(0x22, 0xc5, 0x5e), "dienstgebrauch": RGBColor(0xf0, 0xb4, 0x29), "vertraulich": RGBColor(0xef, 0x44, 0x44)}
- run.font.color.rgb = colors.get(classification, RGBColor(0x88, 0x88, 0x88))
-
- for _ in range(3):
- doc.add_paragraph()
-
- now = datetime.now(TIMEZONE)
- meta_para = doc.add_paragraph()
- meta_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
- run = meta_para.add_run(f"Stand: {now.strftime('%d.%m.%Y, %H:%M Uhr')}\nErstellt von: {creator}")
- run.font.size = Pt(9)
- run.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
-
- doc.add_page_break()
-
- # --- Executive Summary ---
- doc.add_heading("Executive Summary", level=1)
-
- # HTML-Tags entfernen und als Bullet Points
- clean_text = re.sub(r'<[^>]+>', '', executive_summary_text)
- lines = [line.strip().lstrip("- ").lstrip("* ") for line in clean_text.strip().split("\n") if line.strip()]
- for line in lines:
- if line:
- doc.add_paragraph(line, style='List Bullet')
-
- if scope in ("report", "full"):
- # --- Lagebild ---
- doc.add_heading("Lagebild", level=1)
- raw_summary = incident.get("summary") or "Kein Lagebild verfügbar."
- summary = _strip_citation_numbers(
- _truncate_lagebild(raw_summary, 4000) if scope == "report" else raw_summary
- )
- # Markdown-Formatierung entfernen
- clean_summary = re.sub(r'\*\*(.+?)\*\*', r'\1', summary)
- clean_summary = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', clean_summary)
- clean_summary = re.sub(r'^#{1,3}\s+', '', clean_summary, flags=re.MULTILINE)
- for para_text in clean_summary.split("\n\n"):
- para_text = para_text.strip()
- if para_text:
- if para_text.startswith("- "):
- for bullet in para_text.split("\n"):
- bullet = bullet.lstrip("- ").strip()
- if bullet:
- doc.add_paragraph(bullet, style='List Bullet')
- else:
- doc.add_paragraph(para_text)
-
- # --- Faktencheck ---
- report_fcs = fact_checks[:20] if scope == 'report' else fact_checks
- if report_fcs:
- doc.add_heading("Faktencheck", level=1)
- table = doc.add_table(rows=1, cols=3)
- table.style = 'Table Grid'
- table.alignment = WD_TABLE_ALIGNMENT.CENTER
- hdr = table.rows[0].cells
- hdr[0].text = "Behauptung"
- hdr[1].text = "Status"
- hdr[2].text = "Quellen"
- for cell in hdr:
- for p in cell.paragraphs:
- p.runs[0].font.bold = True
- p.runs[0].font.size = Pt(9)
- for fc in report_fcs:
- row = table.add_row().cells
- row[0].text = fc.get("claim", "")
- row[1].text = FC_STATUS_LABELS.get(fc.get("status", ""), fc.get("status", ""))
- row[2].text = str(fc.get("sources_count", 0))
-
- # --- Quellenstatistik ---
- source_stats = _prepare_source_stats(articles)
- if scope == 'report':
- source_stats = source_stats[:20]
- if source_stats:
- doc.add_heading("Quellenstatistik", level=1)
- table = doc.add_table(rows=1, cols=3)
- table.style = 'Table Grid'
- table.alignment = WD_TABLE_ALIGNMENT.CENTER
- hdr = table.rows[0].cells
- hdr[0].text = "Quelle"
- hdr[1].text = "Artikel"
- hdr[2].text = "Sprache"
- for cell in hdr:
- for p in cell.paragraphs:
- p.runs[0].font.bold = True
- p.runs[0].font.size = Pt(9)
- for stat in source_stats:
- row = table.add_row().cells
- row[0].text = stat["name"]
- row[1].text = str(stat["count"])
- row[2].text = stat["languages"]
-
- if scope == "full":
- # --- Artikelverzeichnis ---
- if articles:
- doc.add_page_break()
- doc.add_heading(f"Artikelverzeichnis ({len(articles)} Artikel)", level=1)
- table = doc.add_table(rows=1, cols=4)
- table.style = 'Table Grid'
- table.alignment = WD_TABLE_ALIGNMENT.CENTER
- hdr = table.rows[0].cells
- for i, txt in enumerate(["Headline", "Quelle", "Sprache", "Datum"]):
- hdr[i].text = txt
- for p in hdr[i].paragraphs:
- p.runs[0].font.bold = True
- p.runs[0].font.size = Pt(8)
- for art in articles:
- row = table.add_row().cells
- row[0].text = art.get("headline_de") or art.get("headline") or "Ohne Titel"
- row[1].text = art.get("source") or ""
- row[2].text = (art.get("language") or "de").upper()
- pub = art.get("published_at") or art.get("collected_at") or ""
- try:
- dt = datetime.fromisoformat(pub.replace("Z", "+00:00"))
- row[3].text = dt.strftime("%d.%m.%Y")
- except Exception:
- row[3].text = pub[:10] if pub else ""
- # Schriftgröße reduzieren
- for cell in row:
- for p in cell.paragraphs:
- for run in p.runs:
- run.font.size = Pt(8)
-
- # --- Footer ---
- doc.add_paragraph()
- footer = doc.add_paragraph()
- footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
- run = footer.add_run(f"Erstellt mit AegisSight Monitor — aegis-sight.de — {now.strftime('%d.%m.%Y')}")
- run.font.size = Pt(8)
- run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
-
- buf = io.BytesIO()
- doc.save(buf)
- return buf.getvalue()
diff --git a/src/report_templates/report.html.bak b/src/report_templates/report.html.bak
deleted file mode 100644
index 50da798..0000000
--- a/src/report_templates/report.html.bak
+++ /dev/null
@@ -1,199 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
{{ incident_type_label }}
-
{{ incident.title }}
-
-
{{ classification_label }}
-
-
AegisSight Monitor
-
-
-
-{{ classification_label }}
-
-
-
-
Executive Summary
-
- {{ executive_summary | safe }}
-
-
-
-{% if scope in ('report', 'full') %}
-
-{% if fact_checks %}
-
-
Faktencheck
-
- Behauptung Status Quellen
-
- {% for fc in fact_checks %}
-
- {{ fc.claim or '' }}
- {{ fc.status_label }}
- {{ fc.sources_count or 0 }}
-
- {% endfor %}
-
-
-
-{% endif %}
-
-
-{% if source_stats %}
-
-
Quellenstatistik
-
- Quelle Artikel Sprache
-
- {% for stat in source_stats %}
- {{ stat.name }} {{ stat.count }} {{ stat.languages }}
- {% endfor %}
-
-
-
-
-
-
Lagebild
- {% if lagebild_timestamp %}
Aktualisiert: {{ lagebild_timestamp }}
{% endif %}
-
{{ lagebild_html | safe }}
-
-
-
-{% if sources %}
-
-
Quellenverzeichnis
-
- # Quelle URL
-
- {% for src in sources %}
- {{ loop.index }} {{ src.name or src.title or '' }} {{ src.url or '' }}
- {% endfor %}
-
-
-
-{% endif %}
-
-{% endif %}
-{% endif %}
-
-{% if scope == 'full' %}
-
-{% if timeline %}
-
-
Ereignis-Timeline
- {% for event in timeline %}
-
-
{{ event.date }}
-
{{ event.headline }}
-
{{ event.source }}
-
- {% endfor %}
-
-{% endif %}
-
-
-{% if articles %}
-
-
Artikelverzeichnis ({{ articles | length }} Artikel)
-
- Headline Quelle Sprache Datum
-
- {% for art in articles %}
-
- {{ art.headline_de or art.headline or 'Ohne Titel' }}
- {{ art.source or '' }}
- {{ (art.language or 'de') | upper }}
- {{ art.pub_date }}
-
- {% endfor %}
-
-
-
-{% endif %}
-{% endif %}
-
-
-
-
diff --git a/src/routers/incidents.py.bak b/src/routers/incidents.py.bak
deleted file mode 100644
index fed0d20..0000000
--- a/src/routers/incidents.py.bak
+++ /dev/null
@@ -1,708 +0,0 @@
-"""Incidents-Router: Lagen verwalten (Multi-Tenant)."""
-from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
-from fastapi.responses import StreamingResponse
-from models import IncidentCreate, IncidentUpdate, IncidentResponse, SubscriptionUpdate, SubscriptionResponse
-from auth import get_current_user
-from middleware.license_check import require_writable_license
-from database import db_dependency, get_db
-from datetime import datetime
-from config import TIMEZONE
-import asyncio
-import aiosqlite
-import io
-import json
-import logging
-import re
-import unicodedata
-
-_geoparse_logger = logging.getLogger("osint.geoparse_bg")
-
-router = APIRouter(prefix="/api/incidents", tags=["incidents"])
-
-INCIDENT_UPDATE_COLUMNS = {
- "title", "description", "type", "status", "refresh_mode",
- "refresh_interval", "retention_days", "international_sources", "include_telegram", "visibility",
-}
-
-
-async def _check_incident_access(
- db: aiosqlite.Connection, incident_id: int, user_id: int, tenant_id: int
-) -> aiosqlite.Row:
- """Lage laden und Zugriff pruefen (Tenant + Sichtbarkeit)."""
- cursor = await db.execute(
- "SELECT * FROM incidents WHERE id = ? AND tenant_id = ?",
- (incident_id, tenant_id),
- )
- row = await cursor.fetchone()
- if not row:
- raise HTTPException(status_code=404, detail="Lage nicht gefunden")
- if row["visibility"] == "private" and row["created_by"] != user_id:
- raise HTTPException(status_code=403, detail="Kein Zugriff auf private Lage")
- return row
-
-
-async def _enrich_incident(db: aiosqlite.Connection, row: aiosqlite.Row) -> dict:
- """Incident-Row mit Statistiken und Ersteller-Name anreichern."""
- incident = dict(row)
- cursor = await db.execute(
- "SELECT COUNT(*) as cnt FROM articles WHERE incident_id = ?",
- (incident["id"],),
- )
- article_count = (await cursor.fetchone())["cnt"]
-
- cursor = await db.execute(
- "SELECT COUNT(DISTINCT source) as cnt FROM articles WHERE incident_id = ?",
- (incident["id"],),
- )
- source_count = (await cursor.fetchone())["cnt"]
-
- cursor = await db.execute(
- "SELECT email FROM users WHERE id = ?",
- (incident["created_by"],),
- )
- user_row = await cursor.fetchone()
-
- incident["article_count"] = article_count
- incident["source_count"] = source_count
- incident["created_by_username"] = user_row["email"] if user_row else "Unbekannt"
-
- return incident
-
-
-@router.get("", response_model=list[IncidentResponse])
-async def list_incidents(
- status_filter: str = None,
- current_user: dict = Depends(get_current_user),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Alle Lagen des Tenants auflisten (oeffentliche + eigene private)."""
- tenant_id = current_user.get("tenant_id")
- user_id = current_user["id"]
-
- query = "SELECT * FROM incidents WHERE tenant_id = ? AND (visibility = 'public' OR created_by = ?)"
- params = [tenant_id, user_id]
-
- if status_filter:
- query += " AND status = ?"
- params.append(status_filter)
-
- query += " ORDER BY updated_at DESC"
- cursor = await db.execute(query, params)
- rows = await cursor.fetchall()
-
- results = []
- for row in rows:
- results.append(await _enrich_incident(db, row))
- return results
-
-
-@router.post("", response_model=IncidentResponse, status_code=status.HTTP_201_CREATED)
-async def create_incident(
- data: IncidentCreate,
- current_user: dict = Depends(require_writable_license),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Neue Lage anlegen."""
- tenant_id = current_user.get("tenant_id")
- now = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')
- cursor = await db.execute(
- """INSERT INTO incidents (title, description, type, refresh_mode, refresh_interval,
- retention_days, international_sources, include_telegram, visibility,
- tenant_id, created_by, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
- (
- data.title,
- data.description,
- data.type,
- data.refresh_mode,
- data.refresh_interval,
- data.retention_days,
- 1 if data.international_sources else 0,
- 1 if data.include_telegram else 0,
- data.visibility,
- tenant_id,
- current_user["id"],
- now,
- now,
- ),
- )
- await db.commit()
-
- cursor = await db.execute("SELECT * FROM incidents WHERE id = ?", (cursor.lastrowid,))
- row = await cursor.fetchone()
- return await _enrich_incident(db, row)
-
-
-@router.get("/refreshing")
-async def get_refreshing_incidents(
- current_user: dict = Depends(get_current_user),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Gibt IDs aller Lagen mit laufendem Refresh zurueck (nur eigener Tenant)."""
- tenant_id = current_user.get("tenant_id")
- cursor = await db.execute(
- """SELECT rl.incident_id, rl.started_at FROM refresh_log rl
- JOIN incidents i ON i.id = rl.incident_id
- WHERE rl.status = 'running'
- AND i.tenant_id = ?
- AND (i.visibility = 'public' OR i.created_by = ?)""",
- (tenant_id, current_user["id"]),
- )
- rows = await cursor.fetchall()
- return {
- "refreshing": [row["incident_id"] for row in rows],
- "details": {str(row["incident_id"]): {"started_at": row["started_at"]} for row in rows},
- }
-
-
-@router.get("/{incident_id}", response_model=IncidentResponse)
-async def get_incident(
- incident_id: int,
- current_user: dict = Depends(get_current_user),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Einzelne Lage abrufen."""
- tenant_id = current_user.get("tenant_id")
- row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
- return await _enrich_incident(db, row)
-
-
-@router.put("/{incident_id}", response_model=IncidentResponse)
-async def update_incident(
- incident_id: int,
- data: IncidentUpdate,
- current_user: dict = Depends(get_current_user),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Lage aktualisieren."""
- tenant_id = current_user.get("tenant_id")
- row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
-
- updates = {}
- for field, value in data.model_dump(exclude_none=True).items():
- if field not in INCIDENT_UPDATE_COLUMNS:
- continue
- if field in ("international_sources", "include_telegram"):
- updates[field] = 1 if value else 0
- else:
- updates[field] = value
-
- if not updates:
- return await _enrich_incident(db, row)
-
- updates["updated_at"] = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')
- set_clause = ", ".join(f"{k} = ?" for k in updates)
- values = list(updates.values()) + [incident_id]
-
- await db.execute(f"UPDATE incidents SET {set_clause} WHERE id = ?", values)
- await db.commit()
-
- cursor = await db.execute("SELECT * FROM incidents WHERE id = ?", (incident_id,))
- row = await cursor.fetchone()
- return await _enrich_incident(db, row)
-
-
-@router.delete("/{incident_id}", status_code=status.HTTP_204_NO_CONTENT)
-async def delete_incident(
- incident_id: int,
- current_user: dict = Depends(get_current_user),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Lage loeschen (nur Ersteller)."""
- tenant_id = current_user.get("tenant_id")
- cursor = await db.execute(
- "SELECT id, created_by FROM incidents WHERE id = ? AND tenant_id = ?",
- (incident_id, tenant_id),
- )
- incident = await cursor.fetchone()
- if not incident:
- raise HTTPException(status_code=404, detail="Lage nicht gefunden")
-
- if incident["created_by"] != current_user["id"]:
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail="Nur der Ersteller kann diese Lage loeschen",
- )
-
- try:
- await db.execute("DELETE FROM incidents WHERE id = ?", (incident_id,))
- await db.commit()
- except Exception as e:
- if "database is locked" in str(e):
- raise HTTPException(
- status_code=status.HTTP_409_CONFLICT,
- detail="Datenbank ist momentan beschaeftigt. Bitte in wenigen Sekunden erneut versuchen.",
- )
- raise
-
-
-@router.get("/{incident_id}/articles")
-async def get_articles(
- incident_id: int,
- current_user: dict = Depends(get_current_user),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Alle Artikel einer Lage abrufen."""
- tenant_id = current_user.get("tenant_id")
- await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
- cursor = await db.execute(
- "SELECT * FROM articles WHERE incident_id = ? ORDER BY collected_at DESC",
- (incident_id,),
- )
- rows = await cursor.fetchall()
- return [dict(row) for row in rows]
-
-
-@router.get("/{incident_id}/snapshots")
-async def get_snapshots(
- incident_id: int,
- current_user: dict = Depends(get_current_user),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Lageberichte (Snapshots) einer Lage abrufen."""
- tenant_id = current_user.get("tenant_id")
- await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
- cursor = await db.execute(
- """SELECT id, incident_id, summary, sources_json,
- article_count, fact_check_count, created_at
- FROM incident_snapshots WHERE incident_id = ?
- ORDER BY created_at DESC""",
- (incident_id,),
- )
- rows = await cursor.fetchall()
- return [dict(row) for row in rows]
-
-
-@router.get("/{incident_id}/factchecks")
-async def get_factchecks(
- incident_id: int,
- current_user: dict = Depends(get_current_user),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Faktenchecks einer Lage abrufen."""
- tenant_id = current_user.get("tenant_id")
- await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
- cursor = await db.execute(
- "SELECT * FROM fact_checks WHERE incident_id = ? ORDER BY checked_at DESC",
- (incident_id,),
- )
- rows = await cursor.fetchall()
- return [dict(row) for row in rows]
-
-
-@router.get("/{incident_id}/locations")
-async def get_locations(
- incident_id: int,
- current_user: dict = Depends(get_current_user),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Geografische Orte einer Lage abrufen (aggregiert nach Ort)."""
- tenant_id = current_user.get("tenant_id")
- await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
- cursor = await db.execute(
- """SELECT al.location_name, al.location_name_normalized, al.country_code,
- al.latitude, al.longitude, al.confidence, al.category,
- a.id as article_id, a.headline, a.headline_de, a.source, a.source_url
- FROM article_locations al
- JOIN articles a ON a.id = al.article_id
- WHERE al.incident_id = ?
- ORDER BY al.location_name_normalized, a.collected_at DESC""",
- (incident_id,),
- )
- rows = await cursor.fetchall()
-
- # Aggregierung nach normalisiertem Ortsnamen + Koordinaten
- loc_map = {}
- for row in rows:
- row = dict(row)
- key = (row["location_name_normalized"] or row["location_name"], round(row["latitude"], 2), round(row["longitude"], 2))
- if key not in loc_map:
- loc_map[key] = {
- "location_name": row["location_name_normalized"] or row["location_name"],
- "lat": row["latitude"],
- "lon": row["longitude"],
- "country_code": row["country_code"],
- "confidence": row["confidence"],
- "article_count": 0,
- "articles": [],
- "categories": {},
- }
- loc_map[key]["article_count"] += 1
- cat = row["category"] or "mentioned"
- loc_map[key]["categories"][cat] = loc_map[key]["categories"].get(cat, 0) + 1
- # Maximal 10 Artikel pro Ort mitliefern
- if len(loc_map[key]["articles"]) < 10:
- loc_map[key]["articles"].append({
- "id": row["article_id"],
- "headline": row["headline_de"] or row["headline"],
- "source": row["source"],
- "source_url": row["source_url"],
- })
-
- # Dominanteste Kategorie pro Ort bestimmen (Prioritaet: primary > secondary > tertiary > mentioned)
- priority = {"primary": 4, "secondary": 3, "tertiary": 2, "mentioned": 1}
- result = []
- for loc in loc_map.values():
- cats = loc.pop("categories")
- if cats:
- best_cat = max(cats, key=lambda c: (priority.get(c, 0), cats[c]))
- else:
- best_cat = "mentioned"
- loc["category"] = best_cat
- result.append(loc)
-
- # Category-Labels aus Incident laden
- cursor = await db.execute(
- "SELECT category_labels FROM incidents WHERE id = ?", (incident_id,)
- )
- inc_row = await cursor.fetchone()
- category_labels = None
- if inc_row and inc_row["category_labels"]:
- try:
- category_labels = json.loads(inc_row["category_labels"])
- except (json.JSONDecodeError, TypeError):
- pass
-
- return {"category_labels": category_labels, "locations": result}
-
-
-# Geoparse-Status pro Incident (in-memory)
-_geoparse_status: dict[int, dict] = {}
-
-
-async def _run_geoparse_background(incident_id: int, tenant_id: int | None):
- """Hintergrund-Task: Geoparsing fuer alle Artikel einer Lage."""
- _geoparse_status[incident_id] = {"status": "running", "processed": 0, "total": 0, "locations": 0}
- db = None
- try:
- from agents.geoparsing import geoparse_articles
- db = await get_db()
-
- # Incident-Kontext fuer Haiku laden
- cursor = await db.execute(
- "SELECT title, description FROM incidents WHERE id = ?", (incident_id,)
- )
- inc_row = await cursor.fetchone()
- incident_context = ""
- if inc_row:
- incident_context = f"{inc_row['title']} - {inc_row['description'] or ''}"
-
- cursor = await db.execute(
- """SELECT a.* FROM articles a
- WHERE a.incident_id = ?
- AND a.id NOT IN (SELECT DISTINCT article_id FROM article_locations WHERE incident_id = ?)""",
- (incident_id, incident_id),
- )
- articles = [dict(row) for row in await cursor.fetchall()]
-
- if not articles:
- _geoparse_status[incident_id] = {"status": "done", "processed": 0, "total": 0, "locations": 0}
- return
-
- total = len(articles)
- _geoparse_status[incident_id]["total"] = total
- _geoparse_logger.info(f"Geoparsing Hintergrund: {total} Artikel fuer Lage {incident_id}")
-
- # In Batches verarbeiten (50 Artikel pro Batch)
- batch_size = 50
- geo_count = 0
- processed = 0
- for i in range(0, total, batch_size):
- batch = articles[i:i + batch_size]
- geo_result = await geoparse_articles(batch, incident_context)
- # Tuple-Rückgabe: (locations_dict, category_labels)
- if isinstance(geo_result, tuple):
- batch_geo_results, batch_labels = geo_result
- # Labels beim ersten Batch speichern
- if batch_labels and i == 0:
- try:
- await db.execute(
- "UPDATE incidents SET category_labels = ? WHERE id = ? AND category_labels IS NULL",
- (json.dumps(batch_labels, ensure_ascii=False), incident_id),
- )
- await db.commit()
- except Exception:
- pass
- else:
- batch_geo_results = geo_result
- for art_id, locations in batch_geo_results.items():
- for loc in locations:
- await db.execute(
- """INSERT INTO article_locations
- (article_id, incident_id, location_name, location_name_normalized,
- country_code, latitude, longitude, confidence, source_text, tenant_id, category)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
- (art_id, incident_id, loc["location_name"], loc["location_name_normalized"],
- loc["country_code"], loc["lat"], loc["lon"], loc["confidence"],
- loc.get("source_text", ""), tenant_id, loc.get("category", "mentioned")),
- )
- geo_count += 1
- await db.commit()
- processed += len(batch)
- _geoparse_status[incident_id] = {"status": "running", "processed": processed, "total": total, "locations": geo_count}
-
- _geoparse_status[incident_id] = {"status": "done", "processed": processed, "total": total, "locations": geo_count}
- _geoparse_logger.info(f"Geoparsing fertig: {geo_count} Orte aus {processed} Artikeln (Lage {incident_id})")
- except Exception as e:
- _geoparse_status[incident_id] = {"status": "error", "error": str(e)}
- _geoparse_logger.error(f"Geoparsing Fehler (Lage {incident_id}): {e}")
- finally:
- if db:
- await db.close()
-
-
-@router.post("/{incident_id}/geoparse")
-async def trigger_geoparse(
- incident_id: int,
- current_user: dict = Depends(get_current_user),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Geoparsing fuer alle Artikel einer Lage als Hintergrund-Task starten."""
- tenant_id = current_user.get("tenant_id")
- await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
-
- # Bereits laufend?
- existing = _geoparse_status.get(incident_id, {})
- if existing.get("status") == "running":
- return {"status": "running", "message": f"Läuft bereits ({existing.get('processed', 0)}/{existing.get('total', 0)} Artikel)"}
-
- # Pruefen ob es ueberhaupt ungeparsete Artikel gibt
- cursor = await db.execute(
- """SELECT COUNT(*) as cnt FROM articles a
- WHERE a.incident_id = ?
- AND a.id NOT IN (SELECT DISTINCT article_id FROM article_locations WHERE incident_id = ?)""",
- (incident_id, incident_id),
- )
- count = (await cursor.fetchone())["cnt"]
- if count == 0:
- return {"status": "done", "message": "Alle Artikel wurden bereits verarbeitet", "locations": 0}
-
- # Hintergrund-Task starten
- asyncio.create_task(_run_geoparse_background(incident_id, tenant_id))
- return {"status": "started", "message": f"Geoparsing gestartet für {count} Artikel"}
-
-
-@router.get("/{incident_id}/geoparse-status")
-async def get_geoparse_status(
- incident_id: int,
- current_user: dict = Depends(get_current_user),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Status des laufenden Geoparsing-Tasks abfragen."""
- tenant_id = current_user.get("tenant_id")
- await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
- return _geoparse_status.get(incident_id, {"status": "idle"})
-
-
-@router.get("/{incident_id}/refresh-log")
-async def get_refresh_log(
- incident_id: int,
- limit: int = 20,
- current_user: dict = Depends(get_current_user),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Refresh-Verlauf einer Lage abrufen."""
- tenant_id = current_user.get("tenant_id")
- await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
- cursor = await db.execute(
- """SELECT id, started_at, completed_at, articles_found, status,
- trigger_type, retry_count, error_message
- FROM refresh_log WHERE incident_id = ?
- ORDER BY started_at DESC LIMIT ?""",
- (incident_id, min(limit, 100)),
- )
- rows = await cursor.fetchall()
- results = []
- for row in rows:
- entry = dict(row)
- if entry["started_at"] and entry["completed_at"]:
- try:
- start = datetime.fromisoformat(entry["started_at"])
- end = datetime.fromisoformat(entry["completed_at"])
- entry["duration_seconds"] = round((end - start).total_seconds(), 1)
- except Exception:
- entry["duration_seconds"] = None
- else:
- entry["duration_seconds"] = None
- results.append(entry)
- return results
-
-
-@router.get("/{incident_id}/subscription", response_model=SubscriptionResponse)
-async def get_subscription(
- incident_id: int,
- current_user: dict = Depends(get_current_user),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """E-Mail-Abo-Einstellungen des aktuellen Nutzers fuer eine Lage abrufen."""
- tenant_id = current_user.get("tenant_id")
- await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
- cursor = await db.execute(
- """SELECT notify_email_summary, notify_email_new_articles, notify_email_status_change
- FROM incident_subscriptions WHERE user_id = ? AND incident_id = ?""",
- (current_user["id"], incident_id),
- )
- row = await cursor.fetchone()
- if row:
- return dict(row)
- return {"notify_email_summary": False, "notify_email_new_articles": False, "notify_email_status_change": False}
-
-
-@router.put("/{incident_id}/subscription", response_model=SubscriptionResponse)
-async def update_subscription(
- incident_id: int,
- data: SubscriptionUpdate,
- current_user: dict = Depends(get_current_user),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """E-Mail-Abo-Einstellungen des aktuellen Nutzers fuer eine Lage setzen."""
- tenant_id = current_user.get("tenant_id")
- await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
- await db.execute(
- """INSERT INTO incident_subscriptions (user_id, incident_id, notify_email_summary, notify_email_new_articles, notify_email_status_change)
- VALUES (?, ?, ?, ?, ?)
- ON CONFLICT(user_id, incident_id) DO UPDATE SET
- notify_email_summary = excluded.notify_email_summary,
- notify_email_new_articles = excluded.notify_email_new_articles,
- notify_email_status_change = excluded.notify_email_status_change""",
- (
- current_user["id"],
- incident_id,
- 1 if data.notify_email_summary else 0,
- 1 if data.notify_email_new_articles else 0,
- 1 if data.notify_email_status_change else 0,
- ),
- )
- await db.commit()
- return {
- "notify_email_summary": data.notify_email_summary,
- "notify_email_new_articles": data.notify_email_new_articles,
- "notify_email_status_change": data.notify_email_status_change,
- }
-
-
-@router.post("/{incident_id}/refresh")
-async def trigger_refresh(
- incident_id: int,
- current_user: dict = Depends(require_writable_license),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Manuellen Refresh fuer eine Lage ausloesen."""
- tenant_id = current_user.get("tenant_id")
- await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
-
- from agents.orchestrator import orchestrator
- enqueued = await orchestrator.enqueue_refresh(incident_id, user_id=current_user["id"])
-
- if not enqueued:
- return {"status": "skipped", "incident_id": incident_id}
- return {"status": "queued", "incident_id": incident_id}
-
-
-@router.post("/{incident_id}/cancel-refresh")
-async def cancel_refresh(
- incident_id: int,
- current_user: dict = Depends(get_current_user),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Laufenden Refresh fuer eine Lage abbrechen."""
- tenant_id = current_user.get("tenant_id")
- await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
-
- from agents.orchestrator import orchestrator
- cancelled = await orchestrator.cancel_refresh(incident_id)
-
- return {"status": "cancelling" if cancelled else "not_running"}
-
-
-
-def _slugify(text: str) -> str:
- """Dateinamen-sicherer Slug aus Titel."""
- replacements = {
- "\u00e4": "ae", "\u00f6": "oe", "\u00fc": "ue", "\u00df": "ss",
- "\u00c4": "Ae", "\u00d6": "Oe", "\u00dc": "Ue",
- }
- for src, dst in replacements.items():
- text = text.replace(src, dst)
- text = unicodedata.normalize("NFKD", text)
- text = re.sub(r"[^\w\s-]", "", text)
- text = re.sub(r"[\s_]+", "-", text).strip("-")
- return text[:80].lower()
-
-
-@router.get("/{incident_id}/export")
-async def export_incident(
- incident_id: int,
- format: str = Query("pdf", pattern="^(pdf|docx)$"),
- scope: str = Query("report", pattern="^(summary|report|full)$"),
- classification: str = Query("offen", pattern="^(offen|dienstgebrauch|vertraulich)$"),
- current_user: dict = Depends(get_current_user),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Lage als PDF oder Word exportieren."""
- from report_generator import generate_pdf, generate_docx, generate_executive_summary
-
- tenant_id = current_user.get("tenant_id")
- row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
- incident = dict(row)
-
- # Ersteller-Name
- cursor = await db.execute("SELECT email FROM users WHERE id = ?", (incident["created_by"],))
- user_row = await cursor.fetchone()
- creator = user_row["email"] if user_row else "Unbekannt"
-
- # Artikel
- cursor = await db.execute(
- "SELECT * FROM articles WHERE incident_id = ? ORDER BY collected_at DESC",
- (incident_id,),
- )
- articles = [dict(r) for r in await cursor.fetchall()]
-
- # Faktenchecks
- cursor = await db.execute(
- "SELECT * FROM fact_checks WHERE incident_id = ? ORDER BY checked_at DESC",
- (incident_id,),
- )
- fact_checks = [dict(r) for r in await cursor.fetchall()]
-
- # Snapshots (nur bei full)
- snapshots = []
- if scope == "full":
- cursor = await db.execute(
- "SELECT * FROM incident_snapshots WHERE incident_id = ? ORDER BY created_at DESC",
- (incident_id,),
- )
- snapshots = [dict(r) for r in await cursor.fetchall()]
-
- # Executive Summary (KI-generiert, gecacht)
- exec_summary = incident.get("executive_summary")
- if not exec_summary:
- summary_text = incident.get("summary") or ""
- exec_summary = await generate_executive_summary(summary_text)
- await db.execute(
- "UPDATE incidents SET executive_summary = ? WHERE id = ?",
- (exec_summary, incident_id),
- )
- await db.commit()
-
- date_str = datetime.now(TIMEZONE).strftime("%Y%m%d")
- slug = _slugify(incident["title"])
- scope_labels = {"summary": "executive_summary", "report": "lagebericht", "full": "vollstaendig"}
-
- if format == "pdf":
- pdf_bytes = await generate_pdf(incident, articles, fact_checks, snapshots, scope, classification, creator, exec_summary)
- filename = f"{slug}_{scope_labels[scope]}_{date_str}.pdf"
- return StreamingResponse(
- io.BytesIO(pdf_bytes),
- media_type="application/pdf",
- headers={"Content-Disposition": f'attachment; filename="{filename}"'},
- )
- else:
- docx_bytes = await generate_docx(incident, articles, fact_checks, snapshots, scope, classification, creator, exec_summary)
- filename = f"{slug}_{scope_labels[scope]}_{date_str}.docx"
- return StreamingResponse(
- io.BytesIO(docx_bytes),
- media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
- headers={"Content-Disposition": f'attachment; filename="{filename}"'},
- )
-
diff --git a/src/static/dashboard.html.bak b/src/static/dashboard.html.bak
deleted file mode 100644
index d3b85fe..0000000
--- a/src/static/dashboard.html.bak
+++ /dev/null
@@ -1,700 +0,0 @@
-
-
-
-
-
-
-
-
- AegisSight Monitor
-
-
-
-
-
-
-
-
-
-
-
- Zum Hauptinhalt springen
-
-
-
-
-
-
-
-
-
-
-
☉
-
Kein Vorfall ausgewählt
-
Erstelle einen neuen Fall oder wähle einen bestehenden aus der Seitenleiste.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Warte auf Start...
-
-
-
Abbrechen
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Noch keine Fakten geprüft
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Kategorie
-
- Fehlerbericht
- Feature-Wunsch
- Frage
- Sonstiges
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/static/js/api.js.bak b/src/static/js/api.js.bak
deleted file mode 100644
index d4f834a..0000000
--- a/src/static/js/api.js.bak
+++ /dev/null
@@ -1,237 +0,0 @@
-/**
- * API-Client für den OSINT Lagemonitor.
- */
-const API = {
- baseUrl: '/api',
-
- _getHeaders() {
- const token = localStorage.getItem('osint_token');
- return {
- 'Content-Type': 'application/json',
- 'Authorization': token ? `Bearer ${token}` : '',
- };
- },
-
- async _request(method, path, body = null) {
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), 30000);
-
- const options = {
- method,
- headers: this._getHeaders(),
- signal: controller.signal,
- };
- if (body) {
- options.body = JSON.stringify(body);
- }
-
- let response;
- try {
- response = await fetch(`${this.baseUrl}${path}`, options);
- } catch (err) {
- clearTimeout(timeout);
- if (err.name === 'AbortError') {
- throw new Error('Zeitüberschreitung bei der Anfrage');
- }
- throw err;
- }
- clearTimeout(timeout);
-
- if (response.status === 401) {
- localStorage.removeItem('osint_token');
- localStorage.removeItem('osint_username');
- window.location.href = '/';
- return;
- }
-
- if (!response.ok) {
- const data = await response.json().catch(() => ({}));
- let detail = data.detail;
- if (Array.isArray(detail)) {
- detail = detail.map(e => e.msg || JSON.stringify(e)).join('; ');
- } else if (typeof detail === 'object' && detail !== null) {
- detail = JSON.stringify(detail);
- }
- throw new Error(detail || `Fehler ${response.status}`);
- }
-
- if (response.status === 204) return null;
- return response.json();
- },
-
- // Auth
- getMe() {
- return this._request('GET', '/auth/me');
- },
-
- // Incidents
- listIncidents(statusFilter = null) {
- const query = statusFilter ? `?status_filter=${statusFilter}` : '';
- return this._request('GET', `/incidents${query}`);
- },
-
- createIncident(data) {
- return this._request('POST', '/incidents', data);
- },
-
- getRefreshingIncidents() {
- return this._request('GET', '/incidents/refreshing');
- },
-
- getIncident(id) {
- return this._request('GET', `/incidents/${id}`);
- },
-
- updateIncident(id, data) {
- return this._request('PUT', `/incidents/${id}`, data);
- },
-
- deleteIncident(id) {
- return this._request('DELETE', `/incidents/${id}`);
- },
-
- getArticles(incidentId) {
- return this._request('GET', `/incidents/${incidentId}/articles`);
- },
-
- getFactChecks(incidentId) {
- return this._request('GET', `/incidents/${incidentId}/factchecks`);
- },
-
- getSnapshots(incidentId) {
- return this._request('GET', `/incidents/${incidentId}/snapshots`);
- },
-
- getLocations(incidentId) {
- return this._request('GET', `/incidents/${incidentId}/locations`);
- },
-
- triggerGeoparse(incidentId) {
- return this._request('POST', `/incidents/${incidentId}/geoparse`);
- },
-
- getGeoparseStatus(incidentId) {
- return this._request('GET', `/incidents/${incidentId}/geoparse-status`);
- },
-
- refreshIncident(id) {
- return this._request('POST', `/incidents/${id}/refresh`);
- },
-
- getRefreshLog(incidentId, limit = 20) {
- return this._request('GET', `/incidents/${incidentId}/refresh-log?limit=${limit}`);
- },
-
- // Sources (Quellenverwaltung)
- listSources(params = {}) {
- const query = new URLSearchParams();
- if (params.source_type) query.set('source_type', params.source_type);
- if (params.category) query.set('category', params.category);
- if (params.source_status) query.set('source_status', params.source_status);
- const qs = query.toString();
- return this._request('GET', `/sources${qs ? '?' + qs : ''}`);
- },
-
- createSource(data) {
- return this._request('POST', '/sources', data);
- },
-
- updateSource(id, data) {
- return this._request('PUT', `/sources/${id}`, data);
- },
-
- deleteSource(id) {
- return this._request('DELETE', `/sources/${id}`);
- },
-
- getSourceStats() {
- return this._request('GET', '/sources/stats');
- },
-
- discoverMulti(url) {
- return this._request('POST', '/sources/discover-multi', { url });
- },
-
- getMyExclusions() {
- return this._request('GET', '/sources/my-exclusions');
- },
-
- blockDomain(domain, notes) {
- return this._request('POST', '/sources/block-domain', { domain, notes });
- },
-
- unblockDomain(domain) {
- return this._request('POST', '/sources/unblock-domain', { domain });
- },
-
- deleteDomain(domain) {
- return this._request('DELETE', `/sources/domain/${encodeURIComponent(domain)}`);
- },
-
- cancelRefresh(id) {
- return this._request('POST', `/incidents/${id}/cancel-refresh`);
- },
-
- // Notifications
- listNotifications(limit = 50) {
- return this._request('GET', `/notifications?limit=${limit}`);
- },
-
- markNotificationsRead(ids = null) {
- return this._request('PUT', '/notifications/mark-read', { notification_ids: ids });
- },
-
-
-
- // Subscriptions (E-Mail-Benachrichtigungen)
- getSubscription(incidentId) {
- return this._request('GET', '/incidents/' + incidentId + '/subscription');
- },
-
- updateSubscription(incidentId, data) {
- return this._request('PUT', '/incidents/' + incidentId + '/subscription', data);
- },
-
- // Feedback
- sendFeedback(data) {
- return this._request('POST', '/feedback', data);
- },
-
- async sendFeedbackForm(formData) {
- const token = localStorage.getItem('osint_token');
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), 60000);
- const resp = await fetch(this.baseUrl + '/feedback', {
- method: 'POST',
- headers: { 'Authorization': token ? 'Bearer ' + token : '' },
- body: formData,
- signal: controller.signal,
- });
- clearTimeout(timeout);
- if (!resp.ok) {
- const err = await resp.json().catch(() => ({}));
- throw new Error(err.detail || 'Fehler ' + resp.status);
- }
- },
-
- // Export
-
- // Tutorial-Fortschritt
- getTutorialState() {
- return this._request('GET', '/tutorial/state');
- },
-
- saveTutorialState(data) {
- return this._request('PUT', '/tutorial/state', data);
- },
-
- resetTutorialState() {
- return this._request('DELETE', '/tutorial/state');
- },
- exportReport(id, format, scope, classification) {
- const token = localStorage.getItem('osint_token');
- return fetch(`${this.baseUrl}/incidents/${id}/export?format=${format}&scope=${scope}&classification=${classification}`, {
- headers: { 'Authorization': `Bearer ${token}` },
- });
- },
-};
diff --git a/src/static/js/app.js.bak b/src/static/js/app.js.bak
deleted file mode 100644
index 784f85c..0000000
--- a/src/static/js/app.js.bak
+++ /dev/null
@@ -1,3298 +0,0 @@
-/**
- * OSINT Lagemonitor - Hauptanwendungslogik.
- */
-
-/** Feste Zeitzone fuer alle Anzeigen — NIEMALS aendern. */
-const TIMEZONE = 'Europe/Berlin';
-
-/** Gibt Jahr/Monat(0-basiert)/Tag/Stunde/Minute in Berliner Zeit zurueck. */
-function _tz(d) {
- const s = d.toLocaleString('en-CA', {
- timeZone: TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit',
- hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
- });
- const m = s.match(/(\d{4})-(\d{2})-(\d{2}),?\s*(\d{2}):(\d{2}):(\d{2})/);
- if (!m) return { year: d.getFullYear(), month: d.getMonth(), date: d.getDate(), hours: d.getHours(), minutes: d.getMinutes() };
- return { year: +m[1], month: +m[2] - 1, date: +m[3], hours: +m[4], minutes: +m[5] };
-}
-
-/**
- * Theme Manager: Dark/Light Theme Toggle mit localStorage-Persistenz.
- */
-const ThemeManager = {
- _key: 'osint_theme',
- init() {
- const saved = localStorage.getItem(this._key);
- const theme = saved || 'dark';
- document.documentElement.setAttribute('data-theme', theme);
- this._updateIcon(theme);
- },
- toggle() {
- const current = document.documentElement.getAttribute('data-theme') || 'dark';
- const next = current === 'dark' ? 'light' : 'dark';
- document.documentElement.setAttribute('data-theme', next);
- localStorage.setItem(this._key, next);
- this._updateIcon(next);
- UI.updateMapTheme();
- },
- _updateIcon(theme) {
- const el = document.getElementById('theme-toggle');
- if (!el) return;
- el.classList.remove('dark', 'light');
- el.classList.add(theme);
- el.setAttribute('aria-checked', theme === 'dark' ? 'true' : 'false');
- }
-};
-
-/**
- * Barrierefreiheits-Manager: Panel mit 4 Schaltern (Kontrast, Focus, Schrift, Animationen).
- */
-const A11yManager = {
- _key: 'osint_a11y',
- _isOpen: false,
- _settings: { contrast: false, focus: false, fontsize: false, motion: false },
-
- init() {
- // Einstellungen aus localStorage laden
- try {
- const saved = JSON.parse(localStorage.getItem(this._key) || '{}');
- Object.keys(this._settings).forEach(k => {
- if (typeof saved[k] === 'boolean') this._settings[k] = saved[k];
- });
- } catch (e) { /* Ungültige Daten ignorieren */ }
-
- // Button + Panel dynamisch in .header-right einfügen (vor Theme-Toggle)
- const headerRight = document.querySelector('.header-right');
- const themeToggle = document.getElementById('theme-toggle');
- if (!headerRight) return;
-
- const container = document.createElement('div');
- container.className = 'a11y-center';
- container.innerHTML = `
-
-
-
-
-
-
-
- `;
-
- if (themeToggle) {
- headerRight.insertBefore(container, themeToggle);
- } else {
- headerRight.prepend(container);
- }
-
- // Toggle-Event-Listener
- ['contrast', 'focus', 'fontsize', 'motion'].forEach(key => {
- document.getElementById('a11y-' + key).addEventListener('change', () => this.toggle(key));
- });
-
- // Button öffnet/schließt Panel
- document.getElementById('a11y-btn').addEventListener('click', (e) => {
- e.stopPropagation();
- this._isOpen ? this._closePanel() : this._openPanel();
- });
-
- // Klick außerhalb schließt Panel
- document.addEventListener('click', (e) => {
- if (this._isOpen && !container.contains(e.target)) {
- this._closePanel();
- }
- });
-
- // Keyboard: Esc schließt, Pfeiltasten navigieren
- container.addEventListener('keydown', (e) => {
- if (e.key === 'Escape' && this._isOpen) {
- e.stopPropagation();
- this._closePanel();
- return;
- }
- if (!this._isOpen) return;
- if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
- e.preventDefault();
- const options = Array.from(document.querySelectorAll('.a11y-option input[type="checkbox"]'));
- const idx = options.indexOf(document.activeElement);
- let next;
- if (e.key === 'ArrowDown') {
- next = idx < options.length - 1 ? idx + 1 : 0;
- } else {
- next = idx > 0 ? idx - 1 : options.length - 1;
- }
- options[next].focus();
- }
- });
-
- // Einstellungen anwenden + Checkboxen synchronisieren
- this._apply();
- this._syncUI();
- },
-
- toggle(key) {
- this._settings[key] = !this._settings[key];
- this._apply();
- this._syncUI();
- this._save();
- },
-
- _apply() {
- const root = document.documentElement;
- Object.keys(this._settings).forEach(k => {
- if (this._settings[k]) {
- root.setAttribute('data-a11y-' + k, 'true');
- } else {
- root.removeAttribute('data-a11y-' + k);
- }
- });
- },
-
- _syncUI() {
- Object.keys(this._settings).forEach(k => {
- const cb = document.getElementById('a11y-' + k);
- if (cb) cb.checked = this._settings[k];
- });
- },
-
- _save() {
- localStorage.setItem(this._key, JSON.stringify(this._settings));
- },
-
- _openPanel() {
- this._isOpen = true;
- document.getElementById('a11y-panel').style.display = '';
- document.getElementById('a11y-btn').setAttribute('aria-expanded', 'true');
- // Fokus auf erste Option setzen
- requestAnimationFrame(() => {
- const first = document.querySelector('.a11y-option input[type="checkbox"]');
- if (first) first.focus();
- });
- },
-
- _closePanel() {
- this._isOpen = false;
- document.getElementById('a11y-panel').style.display = 'none';
- const btn = document.getElementById('a11y-btn');
- btn.setAttribute('aria-expanded', 'false');
- btn.focus();
- }
-};
-
-/**
- * Notification-Center: Glocke mit Badge + History-Panel.
- */
-const NotificationCenter = {
- _notifications: [],
- _unreadCount: 0,
- _isOpen: false,
- _maxItems: 50,
- _syncTimer: null,
-
- async init() {
- // Glocken-Container dynamisch in .header-right vor #header-user einfügen
- const headerRight = document.querySelector('.header-right');
- const headerUser = document.getElementById('header-user');
- if (!headerRight || !headerUser) return;
-
- const container = document.createElement('div');
- container.className = 'notification-center';
- container.innerHTML = `
-
-
-
-
-
- 0
-
-
-
-
-
Keine Benachrichtigungen
-
-
- `;
- headerRight.insertBefore(container, headerUser);
-
- // Event-Listener
- document.getElementById('notification-bell').addEventListener('click', (e) => {
- e.stopPropagation();
- this.toggle();
- });
- document.getElementById('notification-mark-read').addEventListener('click', (e) => {
- e.stopPropagation();
- this.markAllRead();
- });
- // Klick außerhalb schließt Panel
- document.addEventListener('click', (e) => {
- if (this._isOpen && !container.contains(e.target)) {
- this.close();
- }
- });
-
- // Notifications aus DB laden
- await this._loadFromDB();
- },
-
- add(notification) {
- // Optimistisches UI: sofort anzeigen
- notification.read = false;
- notification.timestamp = notification.timestamp || new Date().toISOString();
- this._notifications.unshift(notification);
- if (this._notifications.length > this._maxItems) {
- this._notifications.pop();
- }
- this._unreadCount++;
- this._updateBadge();
- this._renderList();
-
- // DB-Sync mit Debounce (Orchestrator schreibt parallel in DB)
- clearTimeout(this._syncTimer);
- this._syncTimer = setTimeout(() => this._syncFromDB(), 500);
- },
-
- toggle() {
- this._isOpen ? this.close() : this.open();
- },
-
- open() {
- this._isOpen = true;
- const panel = document.getElementById('notification-panel');
- if (panel) panel.style.display = 'flex';
- const bell = document.getElementById('notification-bell');
- if (bell) bell.setAttribute('aria-expanded', 'true');
- },
-
- close() {
- this._isOpen = false;
- const panel = document.getElementById('notification-panel');
- if (panel) panel.style.display = 'none';
- const bell = document.getElementById('notification-bell');
- if (bell) bell.setAttribute('aria-expanded', 'false');
- },
-
- async markAllRead() {
- this._notifications.forEach(n => n.read = true);
- this._unreadCount = 0;
- this._updateBadge();
- this._renderList();
-
- // In DB als gelesen markieren (fire-and-forget)
- try {
- await API.markNotificationsRead(null);
- } catch (e) {
- console.warn('Notifications als gelesen markieren fehlgeschlagen:', e);
- }
- },
-
- _updateBadge() {
- const badge = document.getElementById('notification-badge');
- if (!badge) return;
- if (this._unreadCount > 0) {
- badge.style.display = 'flex';
- badge.textContent = this._unreadCount > 99 ? '99+' : this._unreadCount;
- document.title = `(${this._unreadCount}) ${App._originalTitle}`;
- } else {
- badge.style.display = 'none';
- document.title = App._originalTitle;
- }
- },
-
- _renderList() {
- const list = document.getElementById('notification-panel-list');
- if (!list) return;
-
- if (this._notifications.length === 0) {
- list.innerHTML = 'Keine Benachrichtigungen
';
- return;
- }
-
- list.innerHTML = this._notifications.map(n => {
- const time = new Date(n.timestamp);
- const timeStr = time.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
- const unreadClass = n.read ? '' : ' unread';
- const icon = n.icon || 'info';
- return `
-
${this._iconSymbol(icon)}
-
-
${this._escapeHtml(n.title)}
-
${this._escapeHtml(n.text)}
-
-
${timeStr}
-
`;
- }).join('');
- },
-
- _handleClick(incidentId) {
- this.close();
- if (incidentId) {
- App.selectIncident(incidentId);
- }
- },
-
- _iconSymbol(type) {
- switch (type) {
- case 'success': return '\u2713';
- case 'warning': return '!';
- case 'error': return '\u2717';
- default: return 'i';
- }
- },
-
- _escapeHtml(text) {
- const d = document.createElement('div');
- d.textContent = text || '';
- return d.innerHTML;
- },
-
- async _loadFromDB() {
- try {
- const items = await API.listNotifications(50);
- this._notifications = items.map(n => ({
- id: n.id,
- incident_id: n.incident_id,
- title: n.title,
- text: n.text,
- icon: n.icon || 'info',
- type: n.type,
- read: !!n.is_read,
- timestamp: n.created_at,
- }));
- this._unreadCount = this._notifications.filter(n => !n.read).length;
- this._updateBadge();
- this._renderList();
- } catch (e) {
- console.warn('Notifications laden fehlgeschlagen:', e);
- }
- },
-
- async _syncFromDB() {
- try {
- const items = await API.listNotifications(50);
- this._notifications = items.map(n => ({
- id: n.id,
- incident_id: n.incident_id,
- title: n.title,
- text: n.text,
- icon: n.icon || 'info',
- type: n.type,
- read: !!n.is_read,
- timestamp: n.created_at,
- }));
- this._unreadCount = this._notifications.filter(n => !n.read).length;
- this._updateBadge();
- this._renderList();
- } catch (e) {
- console.warn('Notifications sync fehlgeschlagen:', e);
- }
- },
-};
-
-const App = {
- currentIncidentId: null,
- incidents: [],
- _originalTitle: document.title,
- _refreshingIncidents: new Set(),
- _editingIncidentId: null,
- _currentArticles: [],
- _currentIncidentType: 'adhoc',
- _sidebarFilter: 'all',
- _currentUsername: '',
- _allSources: [],
- _sourcesOnly: [],
- _myExclusions: [], // [{domain, notes, created_at}]
- _expandedGroups: new Set(),
- _editingSourceId: null,
- _timelineFilter: 'all',
- _timelineRange: 'all',
- _activePointIndex: null,
- _timelineSearchTimer: null,
- _pendingComplete: null,
- _pendingCompleteTimer: null,
-
- async init() {
- ThemeManager.init();
- A11yManager.init();
- // Auth prüfen
- const token = localStorage.getItem('osint_token');
- if (!token) {
- window.location.href = '/';
- return;
- }
-
- try {
- const user = await API.getMe();
- this._currentUsername = user.email;
- document.getElementById('header-user').textContent = user.email;
-
- // Dropdown-Daten befuellen
- const orgNameEl = document.getElementById('header-org-name');
- if (orgNameEl) orgNameEl.textContent = user.org_name || '-';
-
- const licInfoEl = document.getElementById('header-license-info');
- if (licInfoEl) {
- const licenseLabels = {
- trial: 'Trial',
- annual: 'Jahreslizenz',
- permanent: 'Permanent',
- };
- const label = user.read_only ? 'Abgelaufen'
- : licenseLabels[user.license_type] || user.license_status || '-';
- licInfoEl.textContent = label;
- }
-
- // Credits-Anzeige im Dropdown
- const creditsSection = document.getElementById('credits-section');
- if (creditsSection && user.credits_total) {
- creditsSection.style.display = 'block';
- const bar = document.getElementById('credits-bar');
- const remainingEl = document.getElementById('credits-remaining');
- const totalEl = document.getElementById('credits-total');
-
- const remaining = user.credits_remaining || 0;
- const total = user.credits_total || 1;
- const percentUsed = user.credits_percent_used || 0;
- const percentRemaining = Math.max(0, 100 - percentUsed);
-
- remainingEl.textContent = remaining.toLocaleString('de-DE');
- totalEl.textContent = total.toLocaleString('de-DE');
- bar.style.width = percentRemaining + '%';
-
- // Farbwechsel je nach Verbrauch
- bar.classList.remove('warning', 'critical');
- if (percentUsed > 80) {
- bar.classList.add('critical');
- } else if (percentUsed > 50) {
- bar.classList.add('warning');
- }
- const percentEl = document.getElementById("credits-percent");
- if (percentEl) percentEl.textContent = percentRemaining.toFixed(0) + "% verbleibend";
- }
-
- // Dropdown Toggle
- const userBtn = document.getElementById('header-user-btn');
- const userDropdown = document.getElementById('header-user-dropdown');
- if (userBtn && userDropdown) {
- userBtn.addEventListener('click', (e) => {
- e.stopPropagation();
- const isOpen = userDropdown.classList.toggle('open');
- userBtn.setAttribute('aria-expanded', isOpen);
- });
- document.addEventListener('click', () => {
- userDropdown.classList.remove('open');
- userBtn.setAttribute('aria-expanded', 'false');
- });
- }
-
- // Warnung bei abgelaufener Lizenz
- const warningEl = document.getElementById('header-license-warning');
- if (warningEl && user.read_only) {
- warningEl.textContent = 'Lizenz abgelaufen – nur Lesezugriff';
- warningEl.classList.add('visible');
- }
- } catch {
- window.location.href = '/';
- return;
- }
-
- // Event-Listener
- document.getElementById('logout-btn').addEventListener('click', () => this.logout());
- document.getElementById('new-incident-btn').addEventListener('click', () => openModal('modal-new'));
- document.getElementById('new-incident-form').addEventListener('submit', (e) => this.handleFormSubmit(e));
- document.getElementById('refresh-btn').addEventListener('click', () => this.handleRefresh());
- document.getElementById('delete-incident-btn').addEventListener('click', () => this.handleDelete());
- document.getElementById('edit-incident-btn').addEventListener('click', () => this.handleEdit());
- document.getElementById('archive-incident-btn').addEventListener('click', () => this.handleArchive());
- document.getElementById('inc-international').addEventListener('change', () => updateSourcesHint());
- document.getElementById('inc-visibility').addEventListener('change', () => updateVisibilityHint());
- // Telegram-Kategorien Toggle
- const tgCheckbox = document.getElementById('inc-telegram');
- if (tgCheckbox) {
-
- }
-
-
- // Feedback
- document.getElementById('feedback-form').addEventListener('submit', (e) => this.submitFeedback(e));
- document.getElementById('fb-message').addEventListener('input', (e) => {
- document.getElementById('fb-char-count').textContent = e.target.value.length.toLocaleString('de-DE');
- });
-
- // Sidebar-Chevrons initial auf offen setzen (Archiv geschlossen)
- document.querySelectorAll('.sidebar-chevron').forEach(c => c.classList.add('open'));
- document.getElementById('chevron-archived-incidents').classList.remove('open');
-
- // Lagen laden (frueh, damit Sidebar sofort sichtbar)
- await this.loadIncidents();
-
- // Netzwerkanalysen laden
-
- // Notification-Center initialisieren
- try { await NotificationCenter.init(); } catch (e) { console.warn('NotificationCenter:', e); }
-
- // WebSocket
- WS.connect();
- WS.on('status_update', (msg) => this.handleStatusUpdate(msg));
- WS.on('refresh_complete', (msg) => this.handleRefreshComplete(msg));
- WS.on('refresh_summary', (msg) => this.handleRefreshSummary(msg));
- WS.on('refresh_error', (msg) => this.handleRefreshError(msg));
- WS.on('refresh_cancelled', (msg) => this.handleRefreshCancelled(msg));
-
- // Laufende Refreshes wiederherstellen
- try {
- const data = await API.getRefreshingIncidents();
- if (data.refreshing && data.refreshing.length > 0) {
- data.refreshing.forEach(id => this._refreshingIncidents.add(id));
- // Sidebar-Dots aktualisieren
- data.refreshing.forEach(id => this._updateSidebarDot(id));
- }
- } catch (e) { /* Kein kritischer Fehler */ }
-
- // Zuletzt ausgewählte Lage wiederherstellen
- const savedId = localStorage.getItem('selectedIncidentId');
- if (savedId) {
- const id = parseInt(savedId, 10);
- if (this.incidents.some(inc => inc.id === id)) {
- await this.selectIncident(id);
- }
- }
-
- // Leaflet-Karte nachladen falls CDN langsam war
- setTimeout(() => UI.retryPendingMap(), 2000);
- },
-
- async loadIncidents() {
- try {
- this.incidents = await API.listIncidents();
- this.renderSidebar();
- } catch (err) {
- UI.showToast('Fehler beim Laden der Lagen: ' + err.message, 'error');
- }
- },
-
- renderSidebar() {
- const activeContainer = document.getElementById('active-incidents');
- const researchContainer = document.getElementById('active-research');
- const archivedContainer = document.getElementById('archived-incidents');
-
- // Filter-Buttons aktualisieren
- document.querySelectorAll('.sidebar-filter-btn').forEach(btn => {
- const isActive = btn.dataset.filter === this._sidebarFilter;
- btn.classList.toggle('active', isActive);
- btn.setAttribute('aria-pressed', String(isActive));
- });
-
- // Lagen nach Filter einschränken
- let filtered = this.incidents;
- if (this._sidebarFilter === 'mine') {
- filtered = filtered.filter(i => i.created_by_username === this._currentUsername);
- }
-
- // Aktive Lagen nach Typ aufteilen
- const activeAdhoc = filtered.filter(i => i.status === 'active' && (!i.type || i.type === 'adhoc'));
- const activeResearch = filtered.filter(i => i.status === 'active' && i.type === 'research');
- const archived = filtered.filter(i => i.status === 'archived');
-
- const emptyLabelAdhoc = this._sidebarFilter === 'mine' ? 'Kein eigenes Live-Monitoring' : 'Kein Live-Monitoring';
- const emptyLabelResearch = this._sidebarFilter === 'mine' ? 'Keine eigenen Deep-Research' : 'Keine Deep-Research';
-
- activeContainer.innerHTML = activeAdhoc.length
- ? activeAdhoc.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
- : `${emptyLabelAdhoc}
`;
-
- researchContainer.innerHTML = activeResearch.length
- ? activeResearch.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
- : `${emptyLabelResearch}
`;
-
- archivedContainer.innerHTML = archived.length
- ? archived.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
- : 'Kein Archiv
';
-
- // Zähler aktualisieren
- const countAdhoc = document.getElementById('count-active-incidents');
- const countResearch = document.getElementById('count-active-research');
- const countArchived = document.getElementById('count-archived-incidents');
- if (countAdhoc) countAdhoc.textContent = `(${activeAdhoc.length})`;
- if (countResearch) countResearch.textContent = `(${activeResearch.length})`;
- if (countArchived) countArchived.textContent = `(${archived.length})`;
-
- // Sidebar-Stats aktualisieren
- this.updateSidebarStats();
- },
-
- setSidebarFilter(filter) {
- this._sidebarFilter = filter;
- this.renderSidebar();
- },
-
- _announceForSR(text) {
- let el = document.getElementById('sr-announcement');
- if (!el) {
- el = document.createElement('div');
- el.id = 'sr-announcement';
- el.setAttribute('role', 'status');
- el.setAttribute('aria-live', 'polite');
- el.className = 'sr-only';
- document.body.appendChild(el);
- }
- el.textContent = '';
- requestAnimationFrame(() => { el.textContent = text; });
- },
-
- async selectIncident(id) {
- this.closeRefreshHistory();
- this.currentIncidentId = id;
- localStorage.setItem('selectedIncidentId', id);
- const inc = this.incidents.find(i => i.id === id);
- if (inc) this._announceForSR('Lage ausgewählt: ' + inc.title);
- this.renderSidebar();
-
- var mc = document.getElementById("main-content");
- mc.scrollTop = 0;
-
- document.getElementById('empty-state').style.display = 'none';
- document.getElementById('incident-view').style.display = 'flex';
-
- // GridStack-Animation deaktivieren und Scroll komplett sperren
- // bis alle Tile-Resize-Operationen (doppeltes rAF) abgeschlossen sind
- var gridEl = document.querySelector('.grid-stack');
- if (gridEl) gridEl.classList.remove('grid-stack-animate');
- var scrollLock = function() { mc.scrollTop = 0; };
- mc.addEventListener('scroll', scrollLock);
-
- // gridstack-Layout initialisieren (einmalig)
- if (typeof LayoutManager !== 'undefined') LayoutManager.init();
-
- // Refresh-Status fuer diese Lage wiederherstellen
- const isRefreshing = this._refreshingIncidents.has(id);
- this._updateRefreshButton(isRefreshing);
- if (isRefreshing) {
- UI.showProgress('researching');
- } else {
- UI.hideProgress();
- }
-
-// Alte Inhalte sofort leeren um Flackern beim Wechsel zu vermeiden
- var el;
- el = document.getElementById("incident-title"); if (el) el.textContent = "";
- el = document.getElementById("summary-content"); if (el) el.scrollTop = 0;
- el = document.getElementById("summary-text"); if (el) el.innerHTML = "";
- el = document.getElementById("factcheck-filters"); if (el) el.innerHTML = "";
- el = document.querySelector(".factcheck-list"); if (el) el.scrollTop = 0;
- el = document.getElementById("factcheck-list"); if (el) el.innerHTML = "";
- el = document.getElementById("source-overview-content"); if (el) el.innerHTML = "";
- el = document.getElementById("source-overview-header-stats"); if (el) el.textContent = "";
- el = document.getElementById("timeline-entries"); if (el) el.innerHTML = "";
- await this.loadIncidentDetail(id);
-
- // Scroll-Sperre nach 3 Frames aufheben (nach allen doppelten rAF-Callbacks)
- mc.scrollTop = 0;
- requestAnimationFrame(() => {
- requestAnimationFrame(() => {
- requestAnimationFrame(() => {
- mc.scrollTop = 0;
- mc.removeEventListener('scroll', scrollLock);
- if (gridEl) gridEl.classList.add('grid-stack-animate');
- });
- });
- });
-
-
-
- },
-
- async loadIncidentDetail(id) {
- try {
- const [incident, articles, factchecks, snapshots, locationsResponse] = await Promise.all([
- API.getIncident(id),
- API.getArticles(id),
- API.getFactChecks(id),
- API.getSnapshots(id),
- API.getLocations(id).catch(() => []),
- ]);
-
- // Locations-API gibt jetzt {category_labels, locations} oder Array (Rückwärtskompatibel)
- let locations, categoryLabels;
- if (Array.isArray(locationsResponse)) {
- locations = locationsResponse;
- categoryLabels = null;
- } else if (locationsResponse && locationsResponse.locations) {
- locations = locationsResponse.locations;
- categoryLabels = locationsResponse.category_labels || null;
- } else {
- locations = [];
- categoryLabels = null;
- }
-
- this.renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels);
- } catch (err) {
- console.error('loadIncidentDetail Fehler:', err);
- UI.showToast('Fehler beim Laden: ' + err.message, 'error');
- }
- },
-
- renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels) {
- // Header Strip
- { const _e = document.getElementById('incident-title'); if (_e) _e.textContent = incident.title; }
- { const _e = document.getElementById('incident-description'); if (_e) _e.textContent = incident.description || ''; }
-
- // Typ-Badge
- const typeBadge = document.getElementById('incident-type-badge');
- typeBadge.className = 'incident-type-badge ' + (incident.type === 'research' ? 'type-research' : 'type-adhoc');
- typeBadge.textContent = incident.type === 'research' ? 'Analyse' : 'Live';
-
- // Kachel-Label: 'Recherchebericht' fuer Recherche-Lagen, 'Lagebild' fuer Live-Monitoring
- const _lbLabel = incident.type === 'research' ? 'Recherchebericht' : 'Lagebild';
- const _cardTitle = document.querySelector('[gs-id="lagebild"] .card-title');
- if (_cardTitle) { _cardTitle.textContent = _lbLabel; _cardTitle.setAttribute("onclick", "openContentModal('" + _lbLabel + "', 'summary-content')"); }
- const _toggleBtn = document.querySelector('.layout-toggle-btn[data-tile="lagebild"]');
- if (_toggleBtn) _toggleBtn.textContent = _lbLabel;
- { const _nt = document.querySelector("#inc-notify-summary"); if (_nt) { const _ns = _nt.closest("label")?.querySelector(".toggle-text"); if (_ns) _ns.textContent = "Neues " + _lbLabel; } }
-
- // Archiv-Button Text
- this._updateArchiveButton(incident.status);
-
- // Ersteller anzeigen
- const creatorEl = document.getElementById('incident-creator');
- if (creatorEl) {
- creatorEl.textContent = (incident.created_by_username || '').split('@')[0];
- }
-
- // Delete-Button: nur Ersteller darf löschen
- const deleteBtn = document.getElementById('delete-incident-btn');
- const isCreator = incident.created_by_username === this._currentUsername;
- deleteBtn.disabled = !isCreator;
- deleteBtn.title = isCreator ? '' : `Nur ${(incident.created_by_username || '').split('@')[0]} kann diese Lage löschen`;
-
- // Zusammenfassung mit Quellenverzeichnis
- const summaryText = document.getElementById('summary-text');
- if (incident.summary) {
- summaryText.innerHTML = UI.renderSummary(
- incident.summary,
- incident.sources_json,
- incident.type
- );
- } else {
- summaryText.innerHTML = 'Noch keine Zusammenfassung. Klicke auf "Aktualisieren" um die Recherche zu starten. ';
- }
-
- // Meta (im Header-Strip) — relative Zeitangabe mit vollem Datum als Tooltip
- const updated = incident.updated_at ? parseUTC(incident.updated_at) : null;
- const metaUpdated = document.getElementById('meta-updated');
- if (updated) {
- const fullDate = `${updated.toLocaleDateString('de-DE', { timeZone: TIMEZONE })} ${updated.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })}`;
- metaUpdated.textContent = `Stand: ${App._timeAgo(updated)}`;
- metaUpdated.title = fullDate;
- } else {
- metaUpdated.textContent = '';
- metaUpdated.title = '';
- }
-
- // Zeitstempel direkt im Lagebild-Card-Header
- const lagebildTs = document.getElementById('lagebild-timestamp');
- if (lagebildTs) {
- lagebildTs.textContent = updated
- ? `Stand: ${updated.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: TIMEZONE })} ${updated.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })} Uhr`
- : '';
- }
-
- { const _e = document.getElementById('meta-refresh-mode'); if (_e) _e.textContent = incident.refresh_mode === 'auto'
- ? `Auto alle ${App._formatInterval(incident.refresh_interval)}`
- : 'Manuell'; }
-
- // International-Badge
- const intlBadge = document.getElementById('intl-badge');
- if (intlBadge) {
- const isIntl = incident.international_sources !== false && incident.international_sources !== 0;
- intlBadge.className = 'intl-badge ' + (isIntl ? 'intl-yes' : 'intl-no');
- intlBadge.textContent = isIntl ? 'International' : 'Nur DE';
- }
-
- // Faktencheck
- const fcFilters = document.getElementById('fc-filters');
- const factcheckList = document.getElementById('factcheck-list');
- if (factchecks.length > 0) {
- fcFilters.innerHTML = UI.renderFactCheckFilters(factchecks);
- factcheckList.innerHTML = factchecks.map(fc => UI.renderFactCheck(fc)).join('');
- } else {
- fcFilters.innerHTML = '';
- factcheckList.innerHTML = 'Noch keine Fakten geprüft
';
- }
-
- // Quellenübersicht
- const sourceOverview = document.getElementById('source-overview-content');
- if (sourceOverview) {
- sourceOverview.innerHTML = UI.renderSourceOverview(articles);
- // Stats im Header aktualisieren (sichtbar im zugeklappten Zustand)
- const _soStats = document.getElementById("source-overview-header-stats");
- if (_soStats) {
- const _soSources = new Set(articles.map(a => a.source).filter(Boolean));
- _soStats.textContent = articles.length + " Artikel aus " + _soSources.size + " Quellen";
- }
- // Kachel an Inhalt anpassen
- if (typeof LayoutManager !== 'undefined' && LayoutManager._grid) {
- if (sourceOverview.style.display !== 'none') {
- // Offen → an Inhalt anpassen
- requestAnimationFrame(() => requestAnimationFrame(() => {
- LayoutManager.resizeTileToContent('quellen');
- }));
- } else {
- // Geschlossen → einheitliche Default-Höhe
- const defaults = LayoutManager.DEFAULT_LAYOUT.find(d => d.id === 'quellen');
- if (defaults) {
- const node = LayoutManager._grid.engine.nodes.find(
- n => n.el && n.el.getAttribute('gs-id') === 'quellen'
- );
- if (node) LayoutManager._grid.update(node.el, { h: defaults.h });
- }
- }
- }
- }
-
- // Timeline - Artikel + Snapshots zwischenspeichern und rendern
- this._currentArticles = articles;
- this._currentSnapshots = snapshots || [];
- this._currentIncidentType = incident.type;
- this._timelineFilter = 'all';
- this._timelineRange = 'all';
- this._activePointIndex = null;
- const _tsEl = document.getElementById('timeline-search'); if (_tsEl) _tsEl.value = '';
- document.querySelectorAll('.ht-filter-btn').forEach(btn => {
- const isActive = btn.dataset.filter === 'all';
- btn.classList.toggle('active', isActive);
- btn.setAttribute('aria-pressed', String(isActive));
- });
- document.querySelectorAll('.ht-range-btn').forEach(btn => {
- const isActive = btn.dataset.range === 'all';
- btn.classList.toggle('active', isActive);
- btn.setAttribute('aria-pressed', String(isActive));
- });
- this.rerenderTimeline();
- this._resizeTimelineTile();
-
- // Karte rendern
- UI.renderMap(locations || [], categoryLabels);
- },
-
- _collectEntries(filterType, searchTerm, range) {
- const type = this._currentIncidentType;
- const getArticleDate = (a) => (type === 'research' && a.published_at) ? a.published_at : a.collected_at;
-
- let entries = [];
-
- if (filterType === 'all' || filterType === 'articles') {
- let articles = this._currentArticles || [];
- if (searchTerm) {
- articles = articles.filter(a => {
- const text = `${a.headline || ''} ${a.headline_de || ''} ${a.source || ''} ${a.content_de || ''} ${a.content_original || ''}`.toLowerCase();
- return text.includes(searchTerm);
- });
- }
- articles.forEach(a => entries.push({ kind: 'article', data: a, timestamp: getArticleDate(a) || '' }));
- }
-
- if (filterType === 'all' || filterType === 'snapshots') {
- let snapshots = this._currentSnapshots || [];
- if (searchTerm) {
- snapshots = snapshots.filter(s => (s.summary || '').toLowerCase().includes(searchTerm));
- }
- snapshots.forEach(s => entries.push({ kind: 'snapshot', data: s, timestamp: s.created_at || '' }));
- }
-
- if (range && range !== 'all') {
- const now = Date.now();
- const cutoff = range === '24h' ? now - 24 * 60 * 60 * 1000 : now - 7 * 24 * 60 * 60 * 1000;
- entries = entries.filter(e => new Date(e.timestamp || 0).getTime() >= cutoff);
- }
-
- return entries;
- },
-
- _updateTimelineCount(entries) {
- const articleCount = entries.filter(e => e.kind === 'article').length;
- const snapshotCount = entries.filter(e => e.kind === 'snapshot').length;
- const countEl = document.getElementById('article-count');
- if (!countEl) return;
- if (articleCount > 0 && snapshotCount > 0) {
- countEl.innerHTML = ` ${articleCount} Meldung${articleCount !== 1 ? 'en' : ''} + ${snapshotCount} Lagebericht${snapshotCount !== 1 ? 'e' : ''}`;
- } else if (articleCount > 0) {
- countEl.innerHTML = ` ${articleCount} Meldung${articleCount !== 1 ? 'en' : ''}`;
- } else if (snapshotCount > 0) {
- countEl.innerHTML = ` ${snapshotCount} Lagebericht${snapshotCount !== 1 ? 'e' : ''}`;
- } else {
- countEl.textContent = '0 Meldungen';
- }
- },
-
- debouncedRerenderTimeline() {
- clearTimeout(this._timelineSearchTimer);
- this._timelineSearchTimer = setTimeout(() => this.rerenderTimeline(), 250);
- },
-
- rerenderTimeline() {
- const container = document.getElementById('timeline');
- if (!container) return;
- const searchTerm = (document.getElementById('timeline-search')?.value || '').toLowerCase();
- const filterType = this._timelineFilter;
- const range = this._timelineRange;
-
- let entries = this._collectEntries(filterType, searchTerm, range);
- this._updateTimelineCount(entries);
-
- if (entries.length === 0) {
- this._activePointIndex = null;
- container.innerHTML = (searchTerm || range !== 'all')
- ? 'Keine Einträge im gewählten Zeitraum.
'
- : 'Noch keine Meldungen. Starte eine Recherche mit "Aktualisieren".
';
- return;
- }
-
- entries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
-
- const granularity = this._calcGranularity(entries, range);
- let buckets = this._buildBuckets(entries, granularity);
- buckets = this._mergeCloseBuckets(buckets);
-
- // Aktiven Index validieren
- if (this._activePointIndex !== null && this._activePointIndex >= buckets.length) {
- this._activePointIndex = null;
- }
-
- // Achsen-Bereich
- const rangeStart = buckets[0].timestamp;
- const rangeEnd = buckets[buckets.length - 1].timestamp;
- const maxCount = Math.max(...buckets.map(b => b.entries.length));
-
- // Stunden- vs. Tages-Granularität
- const isHourly = granularity === 'hour';
- const axisLabels = this._buildAxisLabels(buckets, granularity, true);
-
- // HTML aufbauen
- let html = ``;
-
- // Datums-Marker (immer anzeigen, ausgedünnt)
- const dayMarkers = this._thinLabels(this._buildDayMarkers(buckets, rangeStart, rangeEnd), 10);
- html += '
';
- dayMarkers.forEach(m => {
- html += `
`;
- html += `
${UI.escape(m.text)}
`;
- html += `
`;
- html += `
`;
- });
- html += '
';
-
- // Punkte
- html += '
';
- buckets.forEach((bucket, idx) => {
- const pos = this._bucketPositionPercent(bucket, rangeStart, rangeEnd, buckets.length);
- const size = this._calcPointSize(bucket.entries.length, maxCount);
- const hasSnapshots = bucket.entries.some(e => e.kind === 'snapshot');
- const hasArticles = bucket.entries.some(e => e.kind === 'article');
-
- let pointClass = 'ht-point';
- if (filterType === 'snapshots') {
- pointClass += ' ht-snapshot-point';
- } else if (hasSnapshots) {
- pointClass += ' ht-mixed-point';
- }
- if (this._activePointIndex === idx) pointClass += ' active';
-
- const tooltip = `${bucket.label}: ${bucket.entries.length} Eintr${bucket.entries.length === 1 ? 'ag' : 'äge'}`;
-
- html += `
`;
- html += `
${UI.escape(tooltip)}
`;
- html += `
`;
- });
- html += '
';
-
- // Achsenlinie
- html += '
';
-
- // Achsen-Labels (ausgedünnt um Überlappung zu vermeiden)
- const thinned = this._thinLabels(axisLabels);
- html += '
';
- thinned.forEach(lbl => {
- html += `
${UI.escape(lbl.text)}
`;
- });
- html += '
';
- html += '
';
-
- // Detail-Panel (wenn ein Punkt aktiv ist)
- if (this._activePointIndex !== null && this._activePointIndex < buckets.length) {
- html += this._renderDetailPanel(buckets[this._activePointIndex]);
- }
-
- container.innerHTML = html;
- },
-
- _calcGranularity(entries, range) {
- if (entries.length < 2) return 'day';
- const timestamps = entries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
- if (timestamps.length < 2) return 'day';
- const span = Math.max(...timestamps) - Math.min(...timestamps);
- if (range === '24h' || span <= 48 * 60 * 60 * 1000) return 'hour';
- return 'day';
- },
-
- _buildBuckets(entries, granularity) {
- const bucketMap = {};
- entries.forEach(e => {
- const d = new Date(e.timestamp || 0);
- const b = _tz(d);
- let key, label, ts;
- if (granularity === 'hour') {
- key = `${b.year}-${b.month + 1}-${b.date}-${b.hours}`;
- label = d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE }) + ', ' + b.hours.toString().padStart(2, '0') + ':00';
- ts = new Date(b.year, b.month, b.date, b.hours).getTime();
- } else {
- key = `${b.year}-${b.month + 1}-${b.date}`;
- label = d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', timeZone: TIMEZONE });
- ts = new Date(b.year, b.month, b.date, 12).getTime();
- }
- if (!bucketMap[key]) {
- bucketMap[key] = { key, label, timestamp: ts, entries: [] };
- }
- bucketMap[key].entries.push(e);
- });
- return Object.values(bucketMap).sort((a, b) => a.timestamp - b.timestamp);
- },
-
- _mergeCloseBuckets(buckets) {
- if (buckets.length < 2) return buckets;
- const rangeStart = buckets[0].timestamp;
- const rangeEnd = buckets[buckets.length - 1].timestamp;
- if (rangeEnd <= rangeStart) return buckets;
-
- const container = document.getElementById('timeline');
- const axisWidth = (container ? container.offsetWidth : 800) * 0.92;
- const maxCount = Math.max(...buckets.map(b => b.entries.length));
- const result = [buckets[0]];
-
- for (let i = 1; i < buckets.length; i++) {
- const prev = result[result.length - 1];
- const curr = buckets[i];
-
- const distPx = ((curr.timestamp - prev.timestamp) / (rangeEnd - rangeStart)) * axisWidth;
- const prevSize = Math.min(32, this._calcPointSize(prev.entries.length, maxCount));
- const currSize = Math.min(32, this._calcPointSize(curr.entries.length, maxCount));
- const minDistPx = (prevSize + currSize) / 2 + 6;
-
- if (distPx < minDistPx) {
- prev.entries = prev.entries.concat(curr.entries);
- } else {
- result.push(curr);
- }
- }
- return result;
- },
-
- _bucketPositionPercent(bucket, rangeStart, rangeEnd, totalBuckets) {
- if (totalBuckets === 1) return 50;
- if (rangeEnd === rangeStart) return 50;
- return ((bucket.timestamp - rangeStart) / (rangeEnd - rangeStart)) * 100;
- },
-
- _calcPointSize(count, maxCount) {
- if (maxCount <= 1) return 16;
- const minSize = 12;
- const maxSize = 32;
- const logScale = Math.log(count + 1) / Math.log(maxCount + 1);
- return Math.round(minSize + logScale * (maxSize - minSize));
- },
-
- _buildAxisLabels(buckets, granularity, timeOnly) {
- if (buckets.length === 0) return [];
- const maxLabels = 8;
- const labels = [];
- const rangeStart = buckets[0].timestamp;
- const rangeEnd = buckets[buckets.length - 1].timestamp;
-
- const getLabelText = (b) => {
- if (timeOnly) {
- // Bei Tages-Granularität: Uhrzeit des ersten Eintrags nehmen
- const ts = (granularity === 'day' && b.entries && b.entries.length > 0)
- ? new Date(b.entries[0].timestamp || b.timestamp)
- : new Date(b.timestamp);
- const tp = _tz(ts);
- return tp.hours.toString().padStart(2, '0') + ':' + tp.minutes.toString().padStart(2, '0');
- }
- return b.label;
- };
-
- if (buckets.length <= maxLabels) {
- buckets.forEach(b => {
- labels.push({ text: getLabelText(b), pos: this._bucketPositionPercent(b, rangeStart, rangeEnd, buckets.length) });
- });
- } else {
- const step = (buckets.length - 1) / (maxLabels - 1);
- for (let i = 0; i < maxLabels; i++) {
- const idx = Math.round(i * step);
- const b = buckets[idx];
- labels.push({ text: getLabelText(b), pos: this._bucketPositionPercent(b, rangeStart, rangeEnd, buckets.length) });
- }
- }
- return labels;
- },
-
- _thinLabels(labels, minGapPercent) {
- if (!labels || labels.length <= 1) return labels;
- const gap = minGapPercent || 8;
- const result = [labels[0]];
- for (let i = 1; i < labels.length; i++) {
- if (labels[i].pos - result[result.length - 1].pos >= gap) {
- result.push(labels[i]);
- }
- }
- return result;
- },
-
- _buildDayMarkers(buckets, rangeStart, rangeEnd) {
- const seen = {};
- const markers = [];
- buckets.forEach(b => {
- const d = new Date(b.timestamp);
- const bp = _tz(d);
- const dayKey = `${bp.year}-${bp.month}-${bp.date}`;
- if (!seen[dayKey]) {
- seen[dayKey] = true;
- const np = _tz(new Date());
- const todayKey = `${np.year}-${np.month}-${np.date}`;
- const yp = _tz(new Date(Date.now() - 86400000));
- const yesterdayKey = `${yp.year}-${yp.month}-${yp.date}`;
- let label;
- const dateStr = d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
- if (dayKey === todayKey) {
- label = 'Heute, ' + dateStr;
- } else if (dayKey === yesterdayKey) {
- label = 'Gestern, ' + dateStr;
- } else {
- label = d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', timeZone: TIMEZONE });
- }
- const pos = this._bucketPositionPercent(b, rangeStart, rangeEnd, buckets.length);
- markers.push({ text: label, pos });
- }
- });
- return markers;
- },
-
- _renderDetailPanel(bucket) {
- const type = this._currentIncidentType;
- const sorted = [...bucket.entries].sort((a, b) => {
- if (a.kind === 'snapshot' && b.kind !== 'snapshot') return -1;
- if (a.kind !== 'snapshot' && b.kind === 'snapshot') return 1;
- return new Date(b.timestamp || 0) - new Date(a.timestamp || 0);
- });
-
- let entriesHtml = '';
- sorted.forEach(e => {
- if (e.kind === 'snapshot') {
- entriesHtml += this._renderSnapshotEntry(e.data);
- } else {
- entriesHtml += this._renderArticleEntry(e.data, type, 0);
- }
- });
-
- return ``;
- },
-
- setTimelineFilter(filter) {
- this._timelineFilter = filter;
- this._activePointIndex = null;
- document.querySelectorAll('.ht-filter-btn').forEach(btn => {
- const isActive = btn.dataset.filter === filter;
- btn.classList.toggle('active', isActive);
- btn.setAttribute('aria-pressed', String(isActive));
- });
- this.rerenderTimeline();
- },
-
- setTimelineRange(range) {
- this._timelineRange = range;
- this._activePointIndex = null;
- document.querySelectorAll('.ht-range-btn').forEach(btn => {
- const isActive = btn.dataset.range === range;
- btn.classList.toggle('active', isActive);
- btn.setAttribute('aria-pressed', String(isActive));
- });
- this.rerenderTimeline();
- },
-
- openTimelineDetail(bucketIndex) {
- if (this._activePointIndex === bucketIndex) {
- this._activePointIndex = null;
- } else {
- this._activePointIndex = bucketIndex;
- }
- this.rerenderTimeline();
- this._resizeTimelineTile();
- },
-
- closeTimelineDetail() {
- this._activePointIndex = null;
- this.rerenderTimeline();
- this._resizeTimelineTile();
- },
-
- _resizeTimelineTile() {
- if (typeof LayoutManager === 'undefined' || !LayoutManager._grid) return;
- requestAnimationFrame(() => { requestAnimationFrame(() => {
- // Prüfen ob Detail-Panel oder expandierter Eintrag offen ist
- const hasDetail = document.querySelector('.ht-detail-panel') !== null;
- const hasExpanded = document.querySelector('.timeline-card .vt-entry.expanded') !== null;
-
- if (hasDetail || hasExpanded) {
- LayoutManager.resizeTileToContent('timeline');
- } else {
- // Zurück auf Default-Höhe
- const defaults = LayoutManager.DEFAULT_LAYOUT.find(d => d.id === 'timeline');
- if (defaults) {
- const node = LayoutManager._grid.engine.nodes.find(
- n => n.el && n.el.getAttribute('gs-id') === 'timeline'
- );
- if (node) {
- LayoutManager._grid.update(node.el, { h: defaults.h });
- LayoutManager._debouncedSave();
- }
- }
- }
- // Scroll in Sicht
- const card = document.querySelector('.timeline-card');
- const main = document.querySelector('.main-content');
- if (!card || !main) return;
- const cardBottom = card.getBoundingClientRect().bottom;
- const mainBottom = main.getBoundingClientRect().bottom;
- if (cardBottom > mainBottom) {
- main.scrollBy({ top: cardBottom - mainBottom + 16, behavior: document.documentElement.dataset.a11yMotion ? 'instant' : 'smooth' });
- }
- }); });
- },
-
- _buildFullVerticalTimeline(filterType, searchTerm) {
- let entries = this._collectEntries(filterType, searchTerm);
- if (entries.length === 0) {
- return 'Keine Einträge.
';
- }
-
- entries.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
- const granularity = this._calcGranularity(entries);
- const groups = this._groupByTimePeriod(entries, granularity);
-
- let html = '';
- groups.forEach(g => {
- html += `
`;
- html += `
${UI.escape(g.label)}
`;
- html += this._renderTimeGroupEntries(g.entries, this._currentIncidentType);
- html += `
`;
- });
- html += '
';
- return html;
- },
-
- /**
- * Einträge nach Zeitperiode gruppieren.
- */
- _groupByTimePeriod(entries, granularity) {
- const np = _tz(new Date());
- const todayKey = `${np.year}-${np.month}-${np.date}`;
- const yp = _tz(new Date(Date.now() - 86400000));
- const yesterdayKey = `${yp.year}-${yp.month}-${yp.date}`;
-
- const groups = [];
- let currentGroup = null;
-
- entries.forEach(entry => {
- const d = entry.timestamp ? new Date(entry.timestamp) : null;
- let key, label;
-
- if (!d || isNaN(d.getTime())) {
- key = 'unknown';
- label = 'Unbekannt';
- } else if (granularity === 'hour') {
- const ep = _tz(d);
- key = `${ep.year}-${ep.month}-${ep.date}-${ep.hours}`;
- label = `${ep.hours.toString().padStart(2, '0')}:00 Uhr`;
- } else {
- const ep = _tz(d);
- key = `${ep.year}-${ep.month}-${ep.date}`;
- if (key === todayKey) {
- label = 'Heute';
- } else if (key === yesterdayKey) {
- label = 'Gestern';
- } else {
- label = d.toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric', month: 'short', timeZone: TIMEZONE });
- }
- }
-
- if (!currentGroup || currentGroup.key !== key) {
- currentGroup = { key, label, entries: [] };
- groups.push(currentGroup);
- }
- currentGroup.entries.push(entry);
- });
-
- return groups;
- },
-
- /**
- * Entries einer Zeitgruppe rendern, mit Cluster-Erkennung.
- */
- _renderTimeGroupEntries(entries, type) {
- // Cluster-Erkennung: ≥4 Artikel pro Minute
- const minuteCounts = {};
- entries.forEach(e => {
- if (e.kind === 'article') {
- const mk = this._getMinuteKey(e.timestamp);
- minuteCounts[mk] = (minuteCounts[mk] || 0) + 1;
- }
- });
-
- const minuteRendered = {};
- let html = '';
-
- entries.forEach(e => {
- if (e.kind === 'snapshot') {
- html += this._renderSnapshotEntry(e.data);
- } else {
- const mk = this._getMinuteKey(e.timestamp);
- const isCluster = minuteCounts[mk] >= 4;
- const isFirstInCluster = isCluster && !minuteRendered[mk];
- if (isFirstInCluster) minuteRendered[mk] = true;
- html += this._renderArticleEntry(e.data, type, isFirstInCluster ? minuteCounts[mk] : 0);
- }
- });
-
- return html;
- },
-
- /**
- * Artikel-Eintrag für den Zeitstrahl rendern.
- */
- _renderArticleEntry(article, type, clusterCount) {
- const dateField = (type === 'research' && article.published_at)
- ? article.published_at : article.collected_at;
- const time = dateField
- ? (parseUTC(dateField) || new Date(dateField)).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })
- : '--:--';
-
- const headline = article.headline_de || article.headline;
- const sourceUrl = article.source_url
- ? `${UI.escape(article.source)} `
- : UI.escape(article.source);
-
- const langBadge = article.language && article.language !== 'de'
- ? `${article.language.toUpperCase()} ` : '';
-
- const clusterBadge = clusterCount > 0
- ? `${clusterCount} ` : '';
-
- const content = article.content_de || article.content_original || '';
- const hasContent = content.length > 0;
-
- let detailHtml = '';
- if (hasContent) {
- const truncated = content.length > 400 ? content.substring(0, 400) + '...' : content;
- detailHtml = `
-
${UI.escape(truncated)}
- ${article.source_url ? `
Artikel öffnen → ` : ''}
-
`;
- }
-
- return `
-
- ${time}
- ${sourceUrl}
- ${langBadge}${clusterBadge}
-
-
${UI.escape(headline)}
- ${detailHtml}
-
`;
- },
-
- /**
- * Snapshot/Lagebericht-Eintrag für den Zeitstrahl rendern.
- */
- _renderSnapshotEntry(snapshot) {
- const time = snapshot.created_at
- ? (parseUTC(snapshot.created_at) || new Date(snapshot.created_at)).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })
- : '--:--';
-
- const stats = [];
- if (snapshot.article_count) stats.push(`${snapshot.article_count} Artikel`);
- if (snapshot.fact_check_count) stats.push(`${snapshot.fact_check_count} Fakten`);
- const statsText = stats.join(', ');
-
- // Vorschau: erste 200 Zeichen der Zusammenfassung
- const summaryText = snapshot.summary || '';
- const preview = summaryText.length > 200 ? summaryText.substring(0, 200) + '...' : summaryText;
-
- // Vollständige Zusammenfassung via UI.renderSummary
- const fullSummary = UI.renderSummary(snapshot.summary, snapshot.sources_json, this._currentIncidentType);
-
- return `
-
-
${UI.escape(preview)}
-
${fullSummary}
-
`;
- },
-
- /**
- * Timeline-Eintrag auf-/zuklappen (mutual-exclusive pro Zeitgruppe).
- */
- toggleTimelineEntry(el) {
- const container = el.closest('.ht-detail-content') || el.closest('.vt-time-group');
- if (container) {
- container.querySelectorAll('.vt-entry.expanded').forEach(item => {
- if (item !== el) item.classList.remove('expanded');
- });
- }
- el.classList.toggle('expanded');
- if (el.classList.contains('expanded')) {
- requestAnimationFrame(() => {
- var scrollParent = el.closest('.ht-detail-content');
- if (scrollParent && el.classList.contains('vt-snapshot')) {
- scrollParent.scrollTo({ top: 0, behavior: document.documentElement.dataset.a11yMotion ? 'instant' : 'smooth' });
- } else {
- el.scrollIntoView({ behavior: document.documentElement.dataset.a11yMotion ? 'instant' : 'smooth', block: 'nearest' });
- }
- });
- }
- // Timeline-Kachel an Inhalt anpassen
- this._resizeTimelineTile();
- },
-
- /**
- * Minutenschlüssel für Cluster-Erkennung.
- */
- _getMinuteKey(timestamp) {
- if (!timestamp) return 'none';
- const d = new Date(timestamp);
- const p = _tz(d);
- return `${p.year}-${p.month}-${p.date}-${p.hours}-${p.minutes}`;
- },
-
- // === Event Handlers ===
-
- _getFormData() {
- const value = parseInt(document.getElementById('inc-refresh-value').value) || 15;
- const unit = parseInt(document.getElementById('inc-refresh-unit').value) || 1;
- const interval = Math.max(10, Math.min(10080, value * unit));
- return {
- title: document.getElementById('inc-title').value.trim(),
- description: document.getElementById('inc-description').value.trim() || null,
- type: document.getElementById('inc-type').value,
- refresh_mode: document.getElementById('inc-refresh-mode').value,
- refresh_interval: interval,
- retention_days: parseInt(document.getElementById('inc-retention').value) || 0,
- international_sources: document.getElementById('inc-international').checked,
- include_telegram: document.getElementById('inc-telegram').checked,
- visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private',
- };
- },
-
- _clearFormErrors(formEl) {
- formEl.querySelectorAll('.form-error').forEach(el => el.remove());
- formEl.querySelectorAll('[aria-invalid]').forEach(el => {
- el.removeAttribute('aria-invalid');
- el.removeAttribute('aria-describedby');
- });
- },
-
- _showFieldError(field, message) {
- field.setAttribute('aria-invalid', 'true');
- const errorId = field.id + '-error';
- field.setAttribute('aria-describedby', errorId);
- const errorEl = document.createElement('div');
- errorEl.className = 'form-error';
- errorEl.id = errorId;
- errorEl.setAttribute('role', 'alert');
- errorEl.textContent = message;
- field.parentNode.appendChild(errorEl);
- },
-
- async handleFormSubmit(e) {
- e.preventDefault();
- const submitBtn = document.getElementById('modal-new-submit');
- const form = document.getElementById('new-incident-form');
- this._clearFormErrors(form);
-
- // Validierung
- const titleField = document.getElementById('inc-title');
- if (!titleField.value.trim()) {
- this._showFieldError(titleField, 'Bitte einen Titel eingeben.');
- titleField.focus();
- return;
- }
-
- submitBtn.disabled = true;
-
- try {
- const data = this._getFormData();
-
- if (this._editingIncidentId) {
- // Edit-Modus: ID sichern bevor closeModal sie löscht
- const editId = this._editingIncidentId;
- await API.updateIncident(editId, data);
-
- // E-Mail-Subscription speichern
- await API.updateSubscription(editId, {
- notify_email_summary: document.getElementById('inc-notify-summary').checked,
- notify_email_new_articles: document.getElementById('inc-notify-new-articles').checked,
- notify_email_status_change: document.getElementById('inc-notify-status-change').checked,
- });
-
- closeModal('modal-new');
- await this.loadIncidents();
- await this.loadIncidentDetail(editId);
- UI.showToast('Lage aktualisiert.', 'success');
- } else {
- // Create-Modus
- const incident = await API.createIncident(data);
-
- // E-Mail-Subscription speichern
- await API.updateSubscription(incident.id, {
- notify_email_summary: document.getElementById('inc-notify-summary').checked,
- notify_email_new_articles: document.getElementById('inc-notify-new-articles').checked,
- notify_email_status_change: document.getElementById('inc-notify-status-change').checked,
- });
-
- closeModal('modal-new');
-
- await this.loadIncidents();
- await this.selectIncident(incident.id);
-
- // Sofort ersten Refresh starten
- this._refreshingIncidents.add(incident.id);
- this._updateRefreshButton(true);
- UI.showProgress('queued');
- await API.refreshIncident(incident.id);
- UI.showToast(`Lage "${incident.title}" angelegt. Recherche gestartet.`, 'success');
- }
- } catch (err) {
- UI.showToast('Fehler: ' + err.message, 'error');
- } finally {
- submitBtn.disabled = false;
- this._editingIncidentId = null;
- }
- },
-
- async handleRefresh() {
- if (!this.currentIncidentId) return;
- if (this._refreshingIncidents.has(this.currentIncidentId)) {
- UI.showToast('Recherche läuft bereits...', 'warning');
- return;
- }
- try {
- this._refreshingIncidents.add(this.currentIncidentId);
- this._updateRefreshButton(true);
- UI.showProgress('queued');
- const result = await API.refreshIncident(this.currentIncidentId);
- if (result && result.status === 'skipped') {
- this._refreshingIncidents.delete(this.currentIncidentId);
- this._updateRefreshButton(false);
- UI.hideProgress();
- UI.showToast('Recherche läuft bereits oder ist in der Warteschlange.', 'warning');
- }
- } catch (err) {
- this._refreshingIncidents.delete(this.currentIncidentId);
- this._updateRefreshButton(false);
- UI.hideProgress();
- UI.showToast('Fehler: ' + err.message, 'error');
- }
- },
-
- _geoparsePolling: null,
-
- async triggerGeoparse() {
- if (!this.currentIncidentId) return;
- const btn = document.getElementById('geoparse-btn');
- if (btn) { btn.disabled = true; btn.textContent = 'Wird gestartet...'; }
- try {
- const result = await API.triggerGeoparse(this.currentIncidentId);
- if (result.status === 'done') {
- UI.showToast(result.message, 'info');
- if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
- return;
- }
- UI.showToast(result.message, 'info');
- this._pollGeoparse(this.currentIncidentId);
- } catch (err) {
- UI.showToast('Geoparsing fehlgeschlagen: ' + err.message, 'error');
- if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
- }
- },
-
- _pollGeoparse(incidentId) {
- if (this._geoparsePolling) clearInterval(this._geoparsePolling);
- const btn = document.getElementById('geoparse-btn');
- this._geoparsePolling = setInterval(async () => {
- try {
- const st = await API.getGeoparseStatus(incidentId);
- if (st.status === 'running') {
- if (btn) btn.textContent = `${st.processed}/${st.total} Artikel...`;
- } else {
- clearInterval(this._geoparsePolling);
- this._geoparsePolling = null;
- if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
- if (st.status === 'done' && st.locations > 0) {
- UI.showToast(`${st.locations} Orte aus ${st.processed} Artikeln erkannt`, 'success');
- const locResp = await API.getLocations(incidentId).catch(() => []);
- let locs, catLabels;
- if (Array.isArray(locResp)) { locs = locResp; catLabels = null; }
- else if (locResp && locResp.locations) { locs = locResp.locations; catLabels = locResp.category_labels || null; }
- else { locs = []; catLabels = null; }
- UI.renderMap(locs, catLabels);
- } else if (st.status === 'done') {
- UI.showToast('Keine neuen Orte gefunden', 'info');
- } else if (st.status === 'error') {
- UI.showToast('Geoparsing fehlgeschlagen: ' + (st.error || ''), 'error');
- }
- }
- } catch {
- clearInterval(this._geoparsePolling);
- this._geoparsePolling = null;
- if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
- }
- }, 3000);
- },
-
- _formatInterval(minutes) {
- if (minutes >= 10080 && minutes % 10080 === 0) {
- const w = minutes / 10080;
- return w === 1 ? '1 Woche' : `${w} Wochen`;
- }
- if (minutes >= 1440 && minutes % 1440 === 0) {
- const d = minutes / 1440;
- return d === 1 ? '1 Tag' : `${d} Tage`;
- }
- if (minutes >= 60 && minutes % 60 === 0) {
- const h = minutes / 60;
- return h === 1 ? '1 Stunde' : `${h} Stunden`;
- }
- return `${minutes} Min.`;
- },
-
- _setIntervalFields(minutes) {
- let value, unit;
- if (minutes >= 10080 && minutes % 10080 === 0) {
- value = minutes / 10080; unit = '10080';
- } else if (minutes >= 1440 && minutes % 1440 === 0) {
- value = minutes / 1440; unit = '1440';
- } else if (minutes >= 60 && minutes % 60 === 0) {
- value = minutes / 60; unit = '60';
- } else {
- value = minutes; unit = '1';
- }
- const input = document.getElementById('inc-refresh-value');
- input.value = value;
- input.min = unit === '1' ? 10 : 1;
- { const _e = document.getElementById('inc-refresh-unit'); if (_e) _e.value = unit; }
- },
-
- _refreshHistoryOpen: false,
-
- toggleRefreshHistory() {
- if (this._refreshHistoryOpen) {
- this.closeRefreshHistory();
- } else {
- this._openRefreshHistory();
- }
- },
-
- async _openRefreshHistory() {
- if (!this.currentIncidentId) return;
- const popover = document.getElementById('refresh-history-popover');
- if (!popover) return;
-
- this._refreshHistoryOpen = true;
- popover.style.display = 'flex';
-
- // Lade Refresh-Log
- const list = document.getElementById('refresh-history-list');
- list.innerHTML = 'Lade...
';
-
- try {
- const logs = await API.getRefreshLog(this.currentIncidentId, 20);
- this._renderRefreshHistory(logs);
- } catch (e) {
- list.innerHTML = 'Fehler beim Laden
';
- }
-
- // Outside-Click Listener
- setTimeout(() => {
- const handler = (e) => {
- if (!popover.contains(e.target) && !e.target.closest('.meta-updated-link')) {
- this.closeRefreshHistory();
- document.removeEventListener('click', handler);
- }
- };
- document.addEventListener('click', handler);
- popover._outsideHandler = handler;
- }, 0);
- },
-
- closeRefreshHistory() {
- this._refreshHistoryOpen = false;
- const popover = document.getElementById('refresh-history-popover');
- if (popover) {
- popover.style.display = 'none';
- if (popover._outsideHandler) {
- document.removeEventListener('click', popover._outsideHandler);
- delete popover._outsideHandler;
- }
- }
- },
-
- _renderRefreshHistory(logs) {
- const list = document.getElementById('refresh-history-list');
- if (!list) return;
-
- if (!logs || logs.length === 0) {
- list.innerHTML = 'Noch keine Refreshes durchgeführt
';
- return;
- }
-
- list.innerHTML = logs.map(log => {
- const started = parseUTC(log.started_at) || new Date(log.started_at);
- const timeStr = started.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', timeZone: TIMEZONE }) + ' ' +
- started.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
-
- let detail = '';
- if (log.status === 'completed') {
- detail = `${log.articles_found} Artikel`;
- if (log.duration_seconds != null) {
- detail += ` in ${this._formatDuration(log.duration_seconds)}`;
- }
- } else if (log.status === 'running') {
- detail = 'Läuft...';
- } else if (log.status === 'error') {
- detail = '';
- }
-
- const retryInfo = log.retry_count > 0 ? ` (Versuch ${log.retry_count + 1})` : '';
- const errorHtml = log.error_message
- ? `${log.error_message}
`
- : '';
-
- return `
-
-
-
${timeStr}${retryInfo}
- ${detail ? `
${detail}
` : ''}
- ${errorHtml}
-
-
${log.trigger_type === 'auto' ? 'Auto' : 'Manuell'}
-
`;
- }).join('');
- },
-
- _formatDuration(seconds) {
- if (seconds == null) return '';
- if (seconds < 60) return `${Math.round(seconds)}s`;
- const m = Math.floor(seconds / 60);
- const s = Math.round(seconds % 60);
- return s > 0 ? `${m}m ${s}s` : `${m}m`;
- },
-
- _timeAgo(date) {
- if (!date) return '';
- const now = new Date();
- const diff = Math.floor((now - date) / 1000);
- if (diff < 60) return 'gerade eben';
- if (diff < 3600) return `vor ${Math.floor(diff / 60)}m`;
- if (diff < 86400) return `vor ${Math.floor(diff / 3600)}h`;
- return `vor ${Math.floor(diff / 86400)}d`;
- },
-
- _updateRefreshButton(disabled) {
- const btn = document.getElementById('refresh-btn');
- if (!btn) return;
- btn.disabled = disabled;
- btn.textContent = disabled ? 'Läuft...' : 'Aktualisieren';
- },
-
- async handleDelete() {
- if (!this.currentIncidentId) return;
- if (!await confirmDialog('Lage wirklich löschen? Alle gesammelten Daten gehen verloren.')) return;
-
- try {
- await API.deleteIncident(this.currentIncidentId);
- this.currentIncidentId = null;
- if (typeof LayoutManager !== 'undefined') LayoutManager.destroy();
- document.getElementById('incident-view').style.display = 'none';
- document.getElementById('empty-state').style.display = 'flex';
- await this.loadIncidents();
- UI.showToast('Lage gelöscht.', 'success');
- } catch (err) {
- UI.showToast('Fehler: ' + err.message, 'error');
- }
- },
-
- async handleEdit() {
- if (!this.currentIncidentId) return;
- const incident = this.incidents.find(i => i.id === this.currentIncidentId);
- if (!incident) return;
-
- this._editingIncidentId = this.currentIncidentId;
-
- // Formular mit aktuellen Werten füllen
- { const _e = document.getElementById('inc-title'); if (_e) _e.value = incident.title; }
- { const _e = document.getElementById('inc-description'); if (_e) _e.value = incident.description || ''; }
- { const _e = document.getElementById('inc-type'); if (_e) _e.value = incident.type || 'adhoc'; }
- { const _e = document.getElementById('inc-refresh-mode'); if (_e) _e.value = incident.refresh_mode; }
- App._setIntervalFields(incident.refresh_interval);
- { const _e = document.getElementById('inc-retention'); if (_e) _e.value = incident.retention_days; }
- { const _e = document.getElementById('inc-international'); if (_e) _e.checked = incident.international_sources !== false && incident.international_sources !== 0; }
- { const _e = document.getElementById('inc-telegram'); if (_e) _e.checked = !!incident.include_telegram; }
-
- { const _e = document.getElementById('inc-visibility'); if (_e) _e.checked = incident.visibility !== 'private'; }
- updateVisibilityHint();
- updateSourcesHint();
- toggleTypeDefaults();
- toggleRefreshInterval();
-
- // Modal-Titel und Submit ändern
- { const _e = document.getElementById('modal-new-title'); if (_e) _e.textContent = 'Lage bearbeiten'; }
- { const _e = document.getElementById('modal-new-submit'); if (_e) _e.textContent = 'Speichern'; }
-
- // E-Mail-Subscription laden
- try {
- const sub = await API.getSubscription(this.currentIncidentId);
- { const _e = document.getElementById('inc-notify-summary'); if (_e) _e.checked = !!sub.notify_email_summary; }
- { const _e = document.getElementById('inc-notify-new-articles'); if (_e) _e.checked = !!sub.notify_email_new_articles; }
- { const _e = document.getElementById('inc-notify-status-change'); if (_e) _e.checked = !!sub.notify_email_status_change; }
- } catch (e) {
- { const _e = document.getElementById('inc-notify-summary'); if (_e) _e.checked = false; }
- { const _e = document.getElementById('inc-notify-new-articles'); if (_e) _e.checked = false; }
- { const _e = document.getElementById('inc-notify-status-change'); if (_e) _e.checked = false; }
- }
-
- openModal('modal-new');
- },
-
- async handleArchive() {
- if (!this.currentIncidentId) return;
- const incident = this.incidents.find(i => i.id === this.currentIncidentId);
- if (!incident) return;
-
- const isArchived = incident.status === 'archived';
- const action = isArchived ? 'wiederherstellen' : 'archivieren';
-
- if (!await confirmDialog(`Lage wirklich ${action}?`)) return;
-
- try {
- const newStatus = isArchived ? 'active' : 'archived';
- await API.updateIncident(this.currentIncidentId, { status: newStatus });
- await this.loadIncidents();
- await this.loadIncidentDetail(this.currentIncidentId);
- this._updateArchiveButton(newStatus);
- UI.showToast(isArchived ? 'Lage wiederhergestellt.' : 'Lage archiviert.', 'success');
- } catch (err) {
- UI.showToast('Fehler: ' + err.message, 'error');
- }
- },
-
- _updateSidebarDot(incidentId, mode) {
- const dot = document.getElementById(`dot-${incidentId}`);
- if (!dot) return;
- const incident = this.incidents.find(i => i.id === incidentId);
- const baseClass = incident ? (incident.status === 'active' ? 'active' : 'archived') : 'active';
-
- if (mode === 'error') {
- dot.className = `incident-dot refresh-error`;
- setTimeout(() => {
- dot.className = `incident-dot ${baseClass}`;
- }, 3000);
- } else if (this._refreshingIncidents.has(incidentId)) {
- dot.className = `incident-dot refreshing`;
- } else {
- dot.className = `incident-dot ${baseClass}`;
- }
- },
-
- _updateArchiveButton(status) {
- const btn = document.getElementById('archive-incident-btn');
- if (!btn) return;
- btn.textContent = status === 'archived' ? 'Wiederherstellen' : 'Archivieren';
- },
-
- // === WebSocket Handlers ===
-
- handleStatusUpdate(msg) {
- const status = msg.data.status;
- if (status === 'retrying') {
- // Retry-Status → Fehleranzeige mit Retry-Info
- if (msg.incident_id === this.currentIncidentId) {
- UI.showProgressError('', true, msg.data.delay || 120);
- }
- return;
- }
- if (status !== 'idle') {
- this._refreshingIncidents.add(msg.incident_id);
- }
- this._updateSidebarDot(msg.incident_id);
- if (msg.incident_id === this.currentIncidentId) {
- UI.showProgress(status, msg.data);
- this._updateRefreshButton(status !== 'idle');
- }
- },
-
- async handleRefreshComplete(msg) {
- this._refreshingIncidents.delete(msg.incident_id);
- this._updateSidebarDot(msg.incident_id);
-
- if (msg.incident_id === this.currentIncidentId) {
- this._updateRefreshButton(false);
- await this.loadIncidentDetail(msg.incident_id);
-
- // Progress-Bar nicht sofort ausblenden — auf refresh_summary warten
- this._pendingComplete = msg.incident_id;
- // Fallback: Wenn nach 5s kein refresh_summary kommt → direkt ausblenden
- if (this._pendingCompleteTimer) clearTimeout(this._pendingCompleteTimer);
- this._pendingCompleteTimer = setTimeout(() => {
- if (this._pendingComplete === msg.incident_id) {
- this._pendingComplete = null;
- UI.hideProgress();
- }
- }, 5000);
- }
-
- await this.loadIncidents();
- },
-
-
-
- handleRefreshSummary(msg) {
- const d = msg.data;
- const title = d.incident_title || 'Lage';
-
- // Abschluss-Animation auslösen wenn pending
- if (this._pendingComplete === msg.incident_id) {
- if (this._pendingCompleteTimer) {
- clearTimeout(this._pendingCompleteTimer);
- this._pendingCompleteTimer = null;
- }
- this._pendingComplete = null;
- UI.showProgressComplete(d);
- setTimeout(() => UI.hideProgress(), 4000);
- }
-
- // Toast-Text zusammenbauen
- const parts = [];
- if (d.new_articles > 0) {
- parts.push(`${d.new_articles} neue Meldung${d.new_articles !== 1 ? 'en' : ''}`);
- }
- if (d.confirmed_count > 0) {
- parts.push(`${d.confirmed_count} bestätigt`);
- }
- if (d.contradicted_count > 0) {
- parts.push(`${d.contradicted_count} widersprochen`);
- }
- if (d.status_changes && d.status_changes.length > 0) {
- parts.push(`${d.status_changes.length} Statusänderung${d.status_changes.length !== 1 ? 'en' : ''}`);
- }
-
- const summaryText = parts.length > 0
- ? parts.join(', ')
- : 'Keine neuen Entwicklungen';
-
- // 1 Toast statt 5-10
- UI.showToast(`Recherche abgeschlossen: ${summaryText}`, 'success', 6000);
-
- // Ins NotificationCenter eintragen
- NotificationCenter.add({
- incident_id: msg.incident_id,
- title: title,
- text: `Recherche: ${summaryText}`,
- icon: d.contradicted_count > 0 ? 'warning' : 'success',
- });
-
- // Status-Änderungen als separate Einträge
- if (d.status_changes) {
- d.status_changes.forEach(sc => {
- const oldLabel = this._translateStatus(sc.old_status);
- const newLabel = this._translateStatus(sc.new_status);
- NotificationCenter.add({
- incident_id: msg.incident_id,
- title: title,
- text: `${sc.claim}: ${oldLabel} \u2192 ${newLabel}`,
- icon: sc.new_status === 'contradicted' || sc.new_status === 'disputed' ? 'error' : 'success',
- });
- });
- }
-
- // Sidebar-Dot blinken
- const dot = document.getElementById(`dot-${msg.incident_id}`);
- if (dot) {
- dot.classList.add('has-notification');
- setTimeout(() => dot.classList.remove('has-notification'), 10000);
- }
- },
-
- _translateStatus(status) {
- const map = {
- confirmed: 'Bestätigt',
- established: 'Gesichert',
- unconfirmed: 'Unbestätigt',
- contradicted: 'Widersprochen',
- disputed: 'Umstritten',
- developing: 'In Entwicklung',
- unverified: 'Ungeprüft',
- };
- return map[status] || status;
- },
-
- handleRefreshError(msg) {
- this._refreshingIncidents.delete(msg.incident_id);
- this._updateSidebarDot(msg.incident_id, 'error');
- if (msg.incident_id === this.currentIncidentId) {
- this._updateRefreshButton(false);
- // Pending-Complete aufräumen
- if (this._pendingCompleteTimer) {
- clearTimeout(this._pendingCompleteTimer);
- this._pendingCompleteTimer = null;
- }
- this._pendingComplete = null;
- UI.showProgressError(msg.data.error, false);
- }
- UI.showToast(`Recherche-Fehler: ${msg.data.error}`, 'error');
- },
-
- handleRefreshCancelled(msg) {
- this._refreshingIncidents.delete(msg.incident_id);
- this._updateSidebarDot(msg.incident_id);
- if (msg.incident_id === this.currentIncidentId) {
- this._updateRefreshButton(false);
- if (this._pendingCompleteTimer) {
- clearTimeout(this._pendingCompleteTimer);
- this._pendingCompleteTimer = null;
- }
- this._pendingComplete = null;
- UI.hideProgress();
- }
- UI.showToast('Recherche abgebrochen.', 'info');
- },
-
- async cancelRefresh() {
- if (!this.currentIncidentId) return;
- const ok = await confirmDialog('Laufende Recherche abbrechen?');
- if (!ok) return;
-
- const btn = document.getElementById('progress-cancel-btn');
- if (btn) {
- btn.textContent = 'Wird abgebrochen...';
- btn.disabled = true;
- }
-
- try {
- await API.cancelRefresh(this.currentIncidentId);
- } catch (err) {
- UI.showToast('Abbrechen fehlgeschlagen: ' + err.message, 'error');
- if (btn) {
- btn.textContent = 'Abbrechen';
- btn.disabled = false;
- }
- }
- },
-
- // === Export ===
-
- openExportModal() {
- if (!this.currentIncidentId) return;
- openModal('modal-export');
- },
-
- async submitExport() {
- if (!this.currentIncidentId) return;
- const scope = document.querySelector('input[name="export-scope"]:checked').value;
- const format = document.querySelector('input[name="export-format"]:checked').value;
- const classification = document.getElementById('export-classification').value;
-
- const btn = document.getElementById('export-submit-btn');
- const origText = btn.textContent;
- btn.disabled = true;
- btn.textContent = scope === 'summary' ? 'KI generiert Executive Summary...' : 'Wird erstellt...';
-
- try {
- const response = await API.exportReport(this.currentIncidentId, format, scope, classification);
- if (!response.ok) {
- const err = await response.json().catch(() => ({}));
- throw new Error(err.detail || 'Fehler ' + response.status);
- }
- const blob = await response.blob();
- const disposition = response.headers.get('Content-Disposition') || '';
- let filename = 'bericht.' + format;
- const match = disposition.match(/filename="?([^"]+)"?/);
- if (match) filename = match[1];
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- closeModal('modal-export');
- UI.showToast('Bericht heruntergeladen', 'success');
- } catch (err) {
- UI.showToast('Export fehlgeschlagen: ' + err.message, 'error');
- } finally {
- btn.disabled = false;
- btn.textContent = origText;
- }
- },
-
- // === Sidebar-Stats ===
-
- async updateSidebarStats() {
- try {
- const stats = await API.getSourceStats();
- const srcCount = document.getElementById('stat-sources-count');
- const artCount = document.getElementById('stat-articles-count');
- if (srcCount) srcCount.textContent = `${stats.total_sources} Quellen`;
- if (artCount) artCount.textContent = `${stats.total_articles} Artikel`;
- } catch {
- // Fallback: aus Lagen berechnen
- const totalArticles = this.incidents.reduce((sum, i) => sum + i.article_count, 0);
- const totalSources = this.incidents.reduce((sum, i) => sum + i.source_count, 0);
- const srcCount = document.getElementById('stat-sources-count');
- const artCount = document.getElementById('stat-articles-count');
- if (srcCount) srcCount.textContent = `${totalSources} Quellen`;
- if (artCount) artCount.textContent = `${totalArticles} Artikel`;
- }
- },
-
- // === Soft-Refresh (F5) ===
-
- async softRefresh() {
- try {
- await this.loadIncidents();
- if (this.currentIncidentId) {
- await this.selectIncident(this.currentIncidentId);
- }
- UI.showToast('Daten aktualisiert.', 'success', 2000);
- } catch (err) {
- UI.showToast('Aktualisierung fehlgeschlagen: ' + err.message, 'error');
- }
- },
-
- // === Feedback ===
-
- openFeedback() {
- const form = document.getElementById('feedback-form');
- if (form) form.reset();
- const counter = document.getElementById('fb-char-count');
- if (counter) counter.textContent = '0';
- openModal('modal-feedback');
- },
-
- async submitFeedback(e) {
- e.preventDefault();
- const form = document.getElementById('feedback-form');
- this._clearFormErrors(form);
-
- const btn = document.getElementById('fb-submit-btn');
- const category = document.getElementById('fb-category').value;
- const msgField = document.getElementById('fb-message');
- const message = msgField.value.trim();
-
- if (message.length < 10) {
- this._showFieldError(msgField, 'Bitte mindestens 10 Zeichen eingeben.');
- msgField.focus();
- return;
- }
-
- // Dateien pruefen
- const fileInput = document.getElementById('fb-files');
- const files = fileInput ? Array.from(fileInput.files) : [];
- if (files.length > 3) {
- UI.showToast('Maximal 3 Bilder erlaubt.', 'error');
- return;
- }
- for (const f of files) {
- if (f.size > 5 * 1024 * 1024) {
- UI.showToast('Datei "' + f.name + '" ist groesser als 5 MB.', 'error');
- return;
- }
- }
-
- btn.disabled = true;
- btn.textContent = 'Wird gesendet...';
- try {
- const formData = new FormData();
- formData.append('category', category);
- formData.append('message', message);
- for (const f of files) {
- formData.append('files', f);
- }
- await API.sendFeedbackForm(formData);
- closeModal('modal-feedback');
- UI.showToast('Feedback gesendet. Vielen Dank!', 'success');
- } catch (err) {
- UI.showToast('Fehler: ' + err.message, 'error');
- } finally {
- btn.disabled = false;
- btn.textContent = 'Absenden';
- }
- },
-
- // === Sidebar Sektionen ein-/ausklappen ===
-
- toggleSidebarSection(sectionId) {
- const list = document.getElementById(sectionId);
- if (!list) return;
- const chevron = document.getElementById('chevron-' + sectionId);
- const isHidden = list.style.display === 'none';
- list.style.display = isHidden ? '' : 'none';
- if (chevron) {
- chevron.classList.toggle('open', isHidden);
- }
- // aria-expanded auf dem Section-Title synchronisieren
- const title = chevron ? chevron.closest('.sidebar-section-title') : null;
- if (title) title.setAttribute('aria-expanded', String(isHidden));
- },
-
- // === Quellenverwaltung ===
-
- async openSourceManagement() {
- openModal('modal-sources');
- await this.loadSources();
- },
-
- async loadSources() {
- try {
- const [sources, stats, myExclusions] = await Promise.all([
- API.listSources(),
- API.getSourceStats(),
- API.getMyExclusions(),
- ]);
- this._allSources = sources;
- this._sourcesOnly = sources.filter(s => s.source_type !== 'excluded');
- this._myExclusions = myExclusions || [];
-
- this.renderSourceStats(stats);
- this.renderSourceList();
- } catch (err) {
- UI.showToast('Fehler beim Laden der Quellen: ' + err.message, 'error');
- }
- },
-
- renderSourceStats(stats) {
- const bar = document.getElementById('sources-stats-bar');
- if (!bar) return;
-
- const rss = stats.by_type.rss_feed || { count: 0, articles: 0 };
- const web = stats.by_type.web_source || { count: 0, articles: 0 };
- const tg = stats.by_type.telegram_channel || { count: 0, articles: 0 };
- const excluded = this._myExclusions.length;
-
- bar.innerHTML = `
- ${rss.count} RSS-Feeds
- ${web.count} Web-Quellen
- ${tg.count} Telegram
- ${excluded} Ausgeschlossen
- ${stats.total_articles} Artikel gesamt
- `;
- },
-
- /**
- * Quellen nach Domain gruppiert rendern.
- */
- renderSourceList() {
- const list = document.getElementById('sources-list');
- if (!list) return;
-
- // Filter anwenden
- const typeFilter = document.getElementById('sources-filter-type')?.value || '';
- const catFilter = document.getElementById('sources-filter-category')?.value || '';
- const search = (document.getElementById('sources-search')?.value || '').toLowerCase();
-
- // Alle Quellen nach Domain gruppieren
- const groups = new Map();
- const excludedDomains = new Set();
- const excludedNotes = {};
-
- // User-Ausschlüsse sammeln
- this._myExclusions.forEach(e => {
- const domain = (e.domain || '').toLowerCase();
- if (domain) {
- excludedDomains.add(domain);
- excludedNotes[domain] = e.notes || '';
- }
- });
-
- // Feeds nach Domain gruppieren
- this._sourcesOnly.forEach(s => {
- const domain = (s.domain || '').toLowerCase() || `_single_${s.id}`;
- if (!groups.has(domain)) groups.set(domain, []);
- groups.get(domain).push(s);
- });
-
- // Ausgeschlossene Domains die keine Feeds haben auch als Gruppe
- this._myExclusions.forEach(e => {
- const domain = (e.domain || '').toLowerCase();
- if (domain && !groups.has(domain)) {
- groups.set(domain, []);
- }
- });
-
- // Filter auf Gruppen anwenden
- let filteredGroups = [];
- for (const [domain, feeds] of groups) {
- const isExcluded = excludedDomains.has(domain);
- const isGlobal = feeds.some(f => f.is_global);
-
- // Typ-Filter
- if (typeFilter === 'excluded' && !isExcluded) continue;
- if (typeFilter && typeFilter !== 'excluded') {
- const hasMatchingType = feeds.some(f => f.source_type === typeFilter);
- if (!hasMatchingType) continue;
- }
-
- // Kategorie-Filter
- if (catFilter) {
- const hasMatchingCat = feeds.some(f => f.category === catFilter);
- if (!hasMatchingCat) continue;
- }
-
- // Suche
- if (search) {
- const groupText = feeds.map(f =>
- `${f.name} ${f.domain || ''} ${f.url || ''} ${f.notes || ''}`
- ).join(' ').toLowerCase() + ' ' + domain;
- if (!groupText.includes(search)) continue;
- }
-
- filteredGroups.push({ domain, feeds, isExcluded, isGlobal });
- }
-
- if (filteredGroups.length === 0) {
- list.innerHTML = 'Keine Quellen gefunden
';
- return;
- }
-
- // Sortierung: Aktive zuerst (alphabetisch), dann ausgeschlossene
- filteredGroups.sort((a, b) => {
- if (a.isExcluded !== b.isExcluded) return a.isExcluded ? 1 : -1;
- return a.domain.localeCompare(b.domain);
- });
-
- list.innerHTML = filteredGroups.map(g =>
- UI.renderSourceGroup(g.domain, g.feeds, g.isExcluded, excludedNotes[g.domain] || '', g.isGlobal)
- ).join('');
-
- // Erweiterte Gruppen wiederherstellen
- this._expandedGroups.forEach(domain => {
- const feedsEl = list.querySelector(`.source-group-feeds[data-domain="${CSS.escape(domain)}"]`);
- if (feedsEl) {
- feedsEl.classList.add('expanded');
- const header = feedsEl.previousElementSibling;
- if (header) header.classList.add('expanded');
- }
- });
- },
-
- filterSources() {
- this.renderSourceList();
- },
-
- /**
- * Domain-Gruppe auf-/zuklappen.
- */
- toggleSourceOverview() {
- const content = document.getElementById('source-overview-content');
- const chevron = document.getElementById('source-overview-chevron');
- if (!content) return;
- const isHidden = content.style.display === 'none';
- content.style.display = isHidden ? '' : 'none';
- if (chevron) {
- chevron.classList.toggle('open', isHidden);
- chevron.title = isHidden ? 'Einklappen' : 'Aufklappen';
- }
- // aria-expanded auf dem Header-Toggle synchronisieren
- const header = chevron ? chevron.closest('[role="button"]') : null;
- if (header) header.setAttribute('aria-expanded', String(isHidden));
- // gridstack-Kachel an Inhalt anpassen (doppelter rAF für vollständiges Layout)
- if (typeof LayoutManager !== 'undefined' && LayoutManager._grid) {
- if (isHidden) {
- // Aufgeklappt → Inhalt muss erst layouten
- requestAnimationFrame(() => requestAnimationFrame(() => {
- LayoutManager.resizeTileToContent('quellen');
- }));
- } else {
- // Zugeklappt → auf Default-Höhe zurück
- const defaults = LayoutManager.DEFAULT_LAYOUT.find(d => d.id === 'quellen');
- if (defaults) {
- const node = LayoutManager._grid.engine.nodes.find(
- n => n.el && n.el.getAttribute('gs-id') === 'quellen'
- );
- if (node) {
- LayoutManager._grid.update(node.el, { h: defaults.h });
- LayoutManager._debouncedSave();
- }
- }
- }
- }
- },
-
- toggleGroup(domain) {
- const list = document.getElementById('sources-list');
- if (!list) return;
- const feedsEl = list.querySelector(`.source-group-feeds[data-domain="${CSS.escape(domain)}"]`);
- if (!feedsEl) return;
-
- const isExpanded = feedsEl.classList.toggle('expanded');
- const header = feedsEl.previousElementSibling;
- if (header) {
- header.classList.toggle('expanded', isExpanded);
- header.setAttribute('aria-expanded', String(isExpanded));
- }
-
- if (isExpanded) {
- this._expandedGroups.add(domain);
- } else {
- this._expandedGroups.delete(domain);
- }
- },
-
- /**
- * Domain ausschließen (aus dem Inline-Formular).
- */
- async blockDomain() {
- const input = document.getElementById('block-domain-input');
- const domain = (input?.value || '').trim();
- if (!domain) {
- UI.showToast('Domain ist erforderlich.', 'warning');
- return;
- }
-
- const notes = (document.getElementById('block-domain-notes')?.value || '').trim() || null;
-
- try {
- await API.blockDomain(domain, notes);
- UI.showToast(`${domain} ausgeschlossen.`, 'success');
- this.showBlockDomainDialog(false);
- await this.loadSources();
- this.updateSidebarStats();
- } catch (err) {
- UI.showToast('Fehler: ' + err.message, 'error');
- }
- },
-
- /**
- * Faktencheck-Filter umschalten.
- */
- toggleFactCheckFilter(status) {
- const checkbox = document.querySelector(`.fc-dropdown-item[data-status="${status}"] input`);
- if (!checkbox) return;
- const isActive = checkbox.checked;
-
- document.querySelectorAll(`.factcheck-item[data-fc-status="${status}"]`).forEach(el => {
- el.style.display = isActive ? '' : 'none';
- });
- },
-
- toggleFcDropdown(e) {
- e.stopPropagation();
- const btn = e.target.closest('.fc-dropdown-toggle');
- const menu = btn ? btn.nextElementSibling : document.getElementById('fc-dropdown-menu');
- if (!menu) return;
- const isOpen = menu.classList.toggle('open');
- if (btn) btn.setAttribute('aria-expanded', String(isOpen));
- if (isOpen) {
- const close = (ev) => {
- if (!menu.contains(ev.target)) {
- menu.classList.remove('open');
- document.removeEventListener('click', close);
- }
- };
- setTimeout(() => document.addEventListener('click', close), 0);
- }
- },
-
- filterModalTimeline(searchTerm) {
- const filterBtn = document.querySelector('.ht-modal-filter-btn.active');
- const filterType = filterBtn ? filterBtn.dataset.filter : 'all';
- const body = document.getElementById('content-viewer-body');
- if (!body) return;
- body.innerHTML = this._buildFullVerticalTimeline(filterType, (searchTerm || '').toLowerCase());
- },
-
- filterModalTimelineType(filterType, btn) {
- document.querySelectorAll('.ht-modal-filter-btn').forEach(b => b.classList.remove('active'));
- if (btn) btn.classList.add('active');
- const searchInput = document.querySelector('#content-viewer-header-extra .timeline-filter-input');
- const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
- const body = document.getElementById('content-viewer-body');
- if (!body) return;
- body.innerHTML = this._buildFullVerticalTimeline(filterType, searchTerm);
- },
-
- /**
- * Domain direkt ausschließen (aus der Gruppenliste).
- */
- async blockDomainDirect(domain) {
- if (!await confirmDialog(`"${domain}" wirklich ausschließen? Artikel dieser Domain werden bei allen deinen Recherchen ignoriert. Dies betrifft nicht andere Nutzer deiner Organisation.`)) return;
-
- try {
- await API.blockDomain(domain);
- UI.showToast(`${domain} ausgeschlossen.`, 'success');
- await this.loadSources();
- this.updateSidebarStats();
- } catch (err) {
- UI.showToast('Fehler: ' + err.message, 'error');
- }
- },
-
- /**
- * Domain-Ausschluss aufheben.
- */
- async unblockDomain(domain) {
- try {
- await API.unblockDomain(domain);
- UI.showToast(`${domain} Ausschluss aufgehoben.`, 'success');
- await this.loadSources();
- this.updateSidebarStats();
- } catch (err) {
- UI.showToast('Fehler: ' + err.message, 'error');
- }
- },
-
- /**
- * Alle Quellen einer Domain löschen.
- */
- async deleteDomain(domain) {
- if (!await confirmDialog(`Alle Quellen von "${domain}" wirklich löschen?`)) return;
-
- try {
- await API.deleteDomain(domain);
- UI.showToast(`${domain} gelöscht.`, 'success');
- await this.loadSources();
- this.updateSidebarStats();
- } catch (err) {
- UI.showToast('Fehler: ' + err.message, 'error');
- }
- },
-
- /**
- * Einzelnen Feed löschen.
- */
- async deleteSingleFeed(sourceId) {
- try {
- await API.deleteSource(sourceId);
- this._allSources = this._allSources.filter(s => s.id !== sourceId);
- this._sourcesOnly = this._sourcesOnly.filter(s => s.id !== sourceId);
- this.renderSourceList();
- this.updateSidebarStats();
- UI.showToast('Feed gelöscht.', 'success');
- } catch (err) {
- UI.showToast('Fehler: ' + err.message, 'error');
- }
- },
-
- /**
- * "Domain ausschließen" Dialog ein-/ausblenden.
- */
- showBlockDomainDialog(show) {
- const form = document.getElementById('sources-block-form');
- if (!form) return;
-
- if (show === undefined || show === true) {
- form.style.display = 'block';
- document.getElementById('block-domain-input').value = '';
- document.getElementById('block-domain-notes').value = '';
- // Add-Form ausblenden
- const addForm = document.getElementById('sources-add-form');
- if (addForm) addForm.style.display = 'none';
- } else {
- form.style.display = 'none';
- }
- },
-
- _discoveredData: null,
-
- toggleSourceForm(show) {
- const form = document.getElementById('sources-add-form');
- if (!form) return;
-
- if (show === undefined) {
- show = form.style.display === 'none';
- }
-
- form.style.display = show ? 'block' : 'none';
-
- if (show) {
- this._editingSourceId = null;
- this._discoveredData = null;
- document.getElementById('src-discover-url').value = '';
- document.getElementById('src-discovery-result').style.display = 'none';
- document.getElementById('src-discover-btn').disabled = false;
- document.getElementById('src-discover-btn').textContent = 'Erkennen';
- document.getElementById('src-type-select').value = 'rss_feed';
- // Save-Button Text zurücksetzen
- const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
- if (saveBtn) saveBtn.textContent = 'Speichern';
- // Block-Form ausblenden
- const blockForm = document.getElementById('sources-block-form');
- if (blockForm) blockForm.style.display = 'none';
- } else {
- // Beim Schließen: Bearbeitungsmodus zurücksetzen
- this._editingSourceId = null;
- const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
- if (saveBtn) saveBtn.textContent = 'Speichern';
- }
- },
-
- async discoverSource() {
- const urlInput = document.getElementById('src-discover-url');
- const urlVal = urlInput.value.trim();
-
- // Telegram-URLs direkt behandeln (kein Discovery noetig)
- if (urlVal.match(/^(https?:\/\/)?(t\.me|telegram\.me)\//i)) {
- const channelName = urlVal.replace(/^(https?:\/\/)?(t\.me|telegram\.me)\//, '').replace(/\/$/, '');
- const tgUrl = 't.me/' + channelName;
- this._discoveredData = {
- name: '@' + channelName,
- domain: 't.me',
- source_type: 'telegram_channel',
- rss_url: null,
- };
- document.getElementById('src-name').value = '@' + channelName;
- document.getElementById('src-type-select').value = 'telegram_channel';
- document.getElementById('src-type-display').value = 'Telegram';
- document.getElementById('src-domain').value = tgUrl;
- document.getElementById('src-rss-url-group').style.display = 'none';
- document.getElementById('src-discovery-result').style.display = 'block';
- const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
- if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; }
- return;
- }
- const url = urlInput.value.trim();
- if (!url) {
- UI.showToast('Bitte URL oder Domain eingeben.', 'warning');
- return;
- }
-
- // Prüfen ob Domain ausgeschlossen ist
- const inputDomain = url.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0].toLowerCase();
- const isBlocked = inputDomain && this._myExclusions.some(e => (e.domain || '').toLowerCase() === inputDomain);
-
- if (isBlocked) {
- if (!await confirmDialog(`"${inputDomain}" ist ausgeschlossen. Trotzdem hinzufügen? Der Ausschluss wird dabei aufgehoben.`)) return;
- await API.unblockDomain(inputDomain);
- }
-
- const btn = document.getElementById('src-discover-btn');
- btn.disabled = true;
- btn.textContent = 'Suche Feeds...';
-
- try {
- const result = await API.discoverMulti(url);
-
- if (result.fallback_single) {
- this._discoveredData = {
- name: result.domain,
- domain: result.domain,
- category: result.category,
- source_type: result.total_found > 0 ? 'rss_feed' : 'web_source',
- rss_url: result.sources.length > 0 ? result.sources[0].url : null,
- };
- if (result.sources.length > 0) {
- this._discoveredData.name = result.sources[0].name;
- }
-
- document.getElementById('src-name').value = this._discoveredData.name || '';
- document.getElementById('src-category').value = this._discoveredData.category || 'sonstige';
- document.getElementById('src-domain').value = this._discoveredData.domain || '';
- document.getElementById('src-notes').value = '';
-
- const typeLabel = this._discoveredData.source_type === 'rss_feed' ? 'RSS-Feed' : this._discoveredData.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle';
- const typeSelect = document.getElementById('src-type-select');
- if (typeSelect) typeSelect.value = this._discoveredData.source_type || 'web_source';
- document.getElementById('src-type-display').value = typeLabel;
-
- const rssGroup = document.getElementById('src-rss-url-group');
- const rssInput = document.getElementById('src-rss-url');
- if (this._discoveredData.rss_url) {
- rssInput.value = this._discoveredData.rss_url;
- rssGroup.style.display = 'block';
- } else {
- rssInput.value = '';
- rssGroup.style.display = 'none';
- }
-
- document.getElementById('src-discovery-result').style.display = 'block';
-
- if (result.added_count > 0) {
- UI.showToast(`${result.domain}: Feed wurde automatisch hinzugefügt.`, 'success');
- this.toggleSourceForm(false);
- await this.loadSources();
- } else if (result.total_found === 0) {
- UI.showToast('Kein RSS-Feed gefunden. Als Web-Quelle speichern?', 'info');
- } else {
- UI.showToast('Feed bereits vorhanden.', 'info');
- }
- } else {
- document.getElementById('src-discovery-result').style.display = 'none';
-
- if (result.added_count > 0) {
- UI.showToast(`${result.domain}: ${result.added_count} Feeds hinzugefügt` +
- (result.skipped_count > 0 ? ` (${result.skipped_count} bereits vorhanden)` : ''),
- 'success');
- } else if (result.skipped_count > 0) {
- UI.showToast(`${result.domain}: Alle ${result.skipped_count} Feeds bereits vorhanden.`, 'info');
- } else {
- UI.showToast(`${result.domain}: Keine relevanten Feeds gefunden.`, 'info');
- }
-
- this.toggleSourceForm(false);
- await this.loadSources();
- }
- } catch (err) {
- UI.showToast('Erkennung fehlgeschlagen: ' + err.message, 'error');
- } finally {
- btn.disabled = false;
- btn.textContent = 'Erkennen';
- }
- },
-
- editSource(id) {
- const source = this._sourcesOnly.find(s => s.id === id);
- if (!source) {
- UI.showToast('Quelle nicht gefunden.', 'error');
- return;
- }
-
- this._editingSourceId = id;
-
- // Formular öffnen falls geschlossen (direkt, ohne toggleSourceForm das _editingSourceId zurücksetzt)
- const form = document.getElementById('sources-add-form');
- if (form) {
- form.style.display = 'block';
- const blockForm = document.getElementById('sources-block-form');
- if (blockForm) blockForm.style.display = 'none';
- }
-
- // Discovery-URL mit vorhandener URL/Domain befüllen
- const discoverUrlInput = document.getElementById('src-discover-url');
- if (discoverUrlInput) {
- discoverUrlInput.value = source.url || source.domain || '';
- }
-
- // Discovery-Ergebnis anzeigen und Felder befüllen
- document.getElementById('src-discovery-result').style.display = 'block';
- document.getElementById('src-name').value = source.name || '';
- document.getElementById('src-category').value = source.category || 'sonstige';
- document.getElementById('src-notes').value = source.notes || '';
- document.getElementById('src-domain').value = source.domain || '';
-
- const typeLabel = source.source_type === 'rss_feed' ? 'RSS-Feed' : source.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle';
- const typeSelect = document.getElementById('src-type-select');
- if (typeSelect) typeSelect.value = source.source_type || 'web_source';
- document.getElementById('src-type-display').value = typeLabel;
-
- const rssGroup = document.getElementById('src-rss-url-group');
- const rssInput = document.getElementById('src-rss-url');
- if (source.url) {
- rssInput.value = source.url;
- rssGroup.style.display = 'block';
- } else {
- rssInput.value = '';
- rssGroup.style.display = 'none';
- }
-
- // _discoveredData setzen damit saveSource() die richtigen Werte nutzt
- this._discoveredData = {
- name: source.name,
- domain: source.domain,
- category: source.category,
- source_type: source.source_type,
- rss_url: source.url,
- };
-
- // Submit-Button-Text ändern
- const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
- if (saveBtn) saveBtn.textContent = 'Quelle speichern';
-
- // Zum Formular scrollen
- if (form) form.scrollIntoView({ behavior: 'smooth', block: 'start' });
- },
-
- async saveSource() {
- const name = document.getElementById('src-name').value.trim();
- if (!name) {
- UI.showToast('Name ist erforderlich. Bitte erst "Erkennen" klicken.', 'warning');
- return;
- }
-
- const discovered = this._discoveredData || {};
- const data = {
- name,
- source_type: discovered.source_type || 'web_source',
- category: document.getElementById('src-category').value,
- url: discovered.rss_url || (discovered.source_type === 'telegram_channel' ? (document.getElementById('src-domain').value || null) : null),
- domain: document.getElementById('src-domain').value.trim() || discovered.domain || null,
- notes: document.getElementById('src-notes').value.trim() || null,
- };
-
- if (!data.domain && discovered.domain) {
- data.domain = discovered.domain;
- }
-
- try {
- if (this._editingSourceId) {
- await API.updateSource(this._editingSourceId, data);
- UI.showToast('Quelle aktualisiert.', 'success');
- } else {
- await API.createSource(data);
- UI.showToast('Quelle hinzugefügt.', 'success');
- }
-
- this.toggleSourceForm(false);
- await this.loadSources();
- this.updateSidebarStats();
- } catch (err) {
- UI.showToast('Fehler: ' + err.message, 'error');
- }
- },
-
- logout() {
- localStorage.removeItem('osint_token');
- localStorage.removeItem('osint_username');
- this._sessionWarningShown = false;
- WS.disconnect();
- window.location.href = '/';
- },
-};
-
-// === Barrierefreier Bestätigungsdialog ===
-
-function confirmDialog(message) {
- return new Promise((resolve) => {
- // Overlay erstellen
- const overlay = document.createElement('div');
- overlay.className = 'modal-overlay active';
- overlay.setAttribute('role', 'alertdialog');
- overlay.setAttribute('aria-modal', 'true');
- overlay.setAttribute('aria-labelledby', 'confirm-dialog-msg');
-
- const modal = document.createElement('div');
- modal.className = 'modal';
- modal.style.maxWidth = '420px';
- modal.innerHTML = `
-
-
-
${message.replace(//g, '>')}
-
-
- `;
- overlay.appendChild(modal);
- document.body.appendChild(overlay);
-
- const previousFocus = document.activeElement;
-
- const cleanup = (result) => {
- releaseFocus(overlay);
- overlay.remove();
- if (previousFocus) previousFocus.focus();
- resolve(result);
- };
-
- modal.querySelector('#confirm-cancel').addEventListener('click', () => cleanup(false));
- modal.querySelector('#confirm-ok').addEventListener('click', () => cleanup(true));
- overlay.addEventListener('click', (e) => {
- if (e.target === overlay) cleanup(false);
- });
- overlay.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') cleanup(false);
- });
-
- trapFocus(overlay);
- });
-}
-
-// === Globale Hilfsfunktionen ===
-
-// --- Focus-Trap für Modals (WCAG 2.4.3) ---
-const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
-
-function trapFocus(modalEl) {
- const handler = (e) => {
- if (e.key !== 'Tab') return;
- const focusable = Array.from(modalEl.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => el.offsetParent !== null);
- if (focusable.length === 0) return;
- const first = focusable[0];
- const last = focusable[focusable.length - 1];
- if (e.shiftKey && document.activeElement === first) {
- e.preventDefault();
- last.focus();
- } else if (!e.shiftKey && document.activeElement === last) {
- e.preventDefault();
- first.focus();
- }
- };
- modalEl._focusTrapHandler = handler;
- modalEl.addEventListener('keydown', handler);
- // Fokus auf erstes Element setzen
- requestAnimationFrame(() => {
- const focusable = Array.from(modalEl.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => el.offsetParent !== null);
- if (focusable.length > 0) focusable[0].focus();
- });
-}
-
-function releaseFocus(modalEl) {
- if (modalEl._focusTrapHandler) {
- modalEl.removeEventListener('keydown', modalEl._focusTrapHandler);
- delete modalEl._focusTrapHandler;
- }
-}
-
-function openModal(id) {
- if (id === 'modal-new' && !App._editingIncidentId) {
- // Create-Modus: Formular zurücksetzen
- document.getElementById('new-incident-form').reset();
- document.getElementById('modal-new-title').textContent = 'Neue Lage anlegen';
- document.getElementById('modal-new-submit').textContent = 'Lage anlegen';
- // E-Mail-Checkboxen zuruecksetzen
- document.getElementById('inc-notify-summary').checked = false;
- document.getElementById('inc-notify-new-articles').checked = false;
- document.getElementById('inc-notify-status-change').checked = false;
- toggleTypeDefaults();
- toggleRefreshInterval();
- }
- const modal = document.getElementById(id);
- modal._previousFocus = document.activeElement;
- modal.classList.add('active');
- trapFocus(modal);
-}
-
-function closeModal(id) {
- const modal = document.getElementById(id);
- releaseFocus(modal);
- modal.classList.remove('active');
- if (modal._previousFocus) {
- modal._previousFocus.focus();
- delete modal._previousFocus;
- }
- if (id === 'modal-new') {
- App._editingIncidentId = null;
- document.getElementById('modal-new-title').textContent = 'Neue Lage anlegen';
- document.getElementById('modal-new-submit').textContent = 'Lage anlegen';
- }
-}
-
-function openContentModal(title, sourceElementId) {
- const source = document.getElementById(sourceElementId);
- if (!source) return;
-
- document.getElementById('content-viewer-title').textContent = title;
- const body = document.getElementById('content-viewer-body');
- const headerExtra = document.getElementById('content-viewer-header-extra');
- headerExtra.innerHTML = '';
-
- if (sourceElementId === 'factcheck-list') {
- // Faktencheck: Filter in den Modal-Header, Liste in den Body
- const filters = document.getElementById('fc-filters');
- if (filters && filters.innerHTML.trim()) {
- headerExtra.innerHTML = `${filters.innerHTML}
`;
- }
- body.innerHTML = source.innerHTML;
- // Filter im Modal auf Modal-Items umleiten
- headerExtra.querySelectorAll('.fc-dropdown-item input[type="checkbox"]').forEach(cb => {
- cb.onchange = function() {
- const status = this.closest('.fc-dropdown-item').dataset.status;
- body.querySelectorAll(`.factcheck-item[data-fc-status="${status}"]`).forEach(el => {
- el.style.display = cb.checked ? '' : 'none';
- });
- };
- });
- } else if (sourceElementId === 'source-overview-content') {
- // Quellenübersicht: Detailansicht mit Suchleiste
- headerExtra.innerHTML = ' ';
- body.innerHTML = buildDetailedSourceOverview();
- } else if (sourceElementId === 'timeline') {
- // Timeline: Vollständige vertikale Timeline im Modal mit Filter + Suche
- headerExtra.innerHTML = `
-
- Alle
- Meldungen
- Lageberichte
-
-
-
`;
- body.innerHTML = App._buildFullVerticalTimeline('all', '');
- } else {
- body.innerHTML = source.innerHTML;
- }
-
- openModal('modal-content-viewer');
-}
-
-App.filterModalSources = function(query) {
- const q = query.toLowerCase().trim();
- const details = document.querySelectorAll('#content-viewer-body details');
- details.forEach(d => {
- if (!q) {
- d.style.display = '';
- d.removeAttribute('open');
- return;
- }
- const name = d.querySelector('summary').textContent.toLowerCase();
- // Quellenname oder Artikel-Headlines durchsuchen
- const articles = d.querySelectorAll('div > div');
- let articleMatch = false;
- articles.forEach(a => {
- const text = a.textContent.toLowerCase();
- const hit = text.includes(q);
- a.style.display = hit ? '' : 'none';
- if (hit) articleMatch = true;
- });
- const match = name.includes(q) || articleMatch;
- d.style.display = match ? '' : 'none';
- // Bei Artikeltreffer aufklappen, bei Namens-Match alle Artikel zeigen
- if (match && articleMatch && !name.includes(q)) {
- d.setAttribute('open', '');
- } else if (name.includes(q)) {
- articles.forEach(a => a.style.display = '');
- }
- });
-};
-
-function buildDetailedSourceOverview() {
- const articles = App._currentArticles || [];
- if (!articles.length) return 'Keine Artikel vorhanden
';
-
- // Nach Quelle gruppieren
- const sourceMap = {};
- articles.forEach(a => {
- const name = a.source || 'Unbekannt';
- if (!sourceMap[name]) sourceMap[name] = { articles: [], languages: new Set() };
- sourceMap[name].articles.push(a);
- sourceMap[name].languages.add((a.language || 'de').toUpperCase());
- });
-
- const sources = Object.entries(sourceMap).sort((a, b) => b[1].articles.length - a[1].articles.length);
-
- // Sprach-Statistik Header
- 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 = ``;
-
- sources.forEach(([name, data]) => {
- const langs = [...data.languages].join('/');
- const escapedName = UI.escape(name);
- html += `
-
- ▸
- ${escapedName}
- ${langs}
- ${data.articles.length}
-
- `;
- data.articles.forEach(a => {
- const headline = UI.escape(a.headline_de || a.headline || 'Ohne Titel');
- const time = a.collected_at
- ? (parseUTC(a.collected_at) || new Date(a.collected_at)).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })
- : '';
- const langBadge = a.language && a.language !== 'de'
- ? `
${a.language.toUpperCase()} ` : '';
- const link = a.source_url
- ? `
↗ ` : '';
- html += `
- ${time}
- ${headline}
- ${langBadge}
- ${link}
-
`;
- });
- html += `
`;
- });
-
- return html;
-}
-
-
-
-
-function toggleRefreshInterval() {
- const mode = document.getElementById('inc-refresh-mode').value;
- const field = document.getElementById('refresh-interval-field');
- field.classList.toggle('visible', mode === 'auto');
-}
-
-function updateIntervalMin() {
- const unit = parseInt(document.getElementById('inc-refresh-unit').value);
- const input = document.getElementById('inc-refresh-value');
- if (unit === 1) {
- // Minuten: Minimum 10
- input.min = 10;
- if (parseInt(input.value) < 10) input.value = 10;
- } else {
- // Stunden/Tage/Wochen: Minimum 1
- input.min = 1;
- if (parseInt(input.value) < 1) input.value = 1;
- }
-}
-
-function updateVisibilityHint() {
- const isPublic = document.getElementById('inc-visibility').checked;
- const text = document.getElementById('visibility-text');
- if (text) {
- text.textContent = isPublic
- ? 'Öffentlich — für alle Nutzer sichtbar'
- : 'Privat — nur für dich sichtbar';
- }
-}
-
-function updateSourcesHint() {
- const intl = document.getElementById('inc-international').checked;
- const hint = document.getElementById('sources-hint');
- if (hint) {
- hint.textContent = intl
- ? 'DE + internationale Feeds (Reuters, BBC, Al Jazeera etc.)'
- : 'Nur deutschsprachige Quellen (DE, AT, CH)';
- }
-}
-
-function toggleTypeDefaults() {
- const type = document.getElementById('inc-type').value;
- const hint = document.getElementById('type-hint');
- const refreshMode = document.getElementById('inc-refresh-mode');
-
- if (type === 'research') {
- hint.textContent = 'Recherchiert in Tiefe: Nachrichtenarchive, Parlamentsdokumente, Fachmedien, Expertenquellen. Empfohlen: Manuell starten und bei Bedarf vertiefen.';
- refreshMode.value = 'manual';
- toggleRefreshInterval();
- } else {
- hint.textContent = 'Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.';
- }
-}
-
-// Tab-Fokus: Nur Tab-Badge (Titel-Counter) zurücksetzen, nicht alle Notifications
-window.addEventListener('focus', () => {
- document.title = App._originalTitle;
-});
-
-// ESC schließt Modals
-// F5: Daten aktualisieren statt Seite neu laden
-document.addEventListener('keydown', (e) => {
- if (e.key === 'F5') {
- e.preventDefault();
- App.softRefresh();
- }
-});
-
-document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') {
- // Schließ-Reihenfolge: A11y-Panel > Notification-Panel > Export-Dropdown > FC-Dropdown > Modals
- if (A11yManager._isOpen) {
- A11yManager._closePanel();
- return;
- }
- if (NotificationCenter._isOpen) {
- NotificationCenter.close();
- return;
- }
-
- const fcMenu = document.querySelector('.fc-dropdown-menu.open');
- if (fcMenu) {
- fcMenu.classList.remove('open');
- const fcBtn = fcMenu.previousElementSibling;
- if (fcBtn) fcBtn.setAttribute('aria-expanded', 'false');
- return;
- }
- document.querySelectorAll('.modal-overlay.active').forEach(m => {
- closeModal(m.id);
- });
- }
-});
-
-// Keyboard-Handler: Enter/Space auf [role="button"] löst click aus (WCAG 2.1.1)
-document.addEventListener('keydown', (e) => {
- if ((e.key === 'Enter' || e.key === ' ') && e.target.matches('[role="button"]')) {
- e.preventDefault();
- e.target.click();
- }
-});
-
-// Session-Ablauf prüfen (alle 60 Sekunden)
-setInterval(() => {
- const token = localStorage.getItem('osint_token');
- if (!token) return;
- try {
- const payload = JSON.parse(atob(token.split('.')[1]));
- const expiresAt = payload.exp * 1000;
- const remaining = expiresAt - Date.now();
- const fiveMinutes = 5 * 60 * 1000;
- if (remaining <= 0) {
- App.logout();
- } else if (remaining <= fiveMinutes && !App._sessionWarningShown) {
- App._sessionWarningShown = true;
- const mins = Math.ceil(remaining / 60000);
- UI.showToast(`Session läuft in ${mins} Minute${mins !== 1 ? 'n' : ''} ab. Bitte erneut anmelden.`, 'warning', 15000);
- }
- } catch (e) { /* Token nicht parsbar */ }
-}, 60000);
-
-// Modal-Overlays: Klick auf Backdrop schließt NICHT mehr (nur X-Button)
-document.addEventListener('click', (e) => {
- if (e.target.classList.contains('modal-overlay') && e.target.classList.contains('active')) {
- // closeModal deaktiviert - Modal nur ueber X-Button schliessbar
- }
-});
-
-// App starten
-document.addEventListener('click', (e) => {
-
-});
-document.addEventListener('DOMContentLoaded', () => App.init());