Export-System: PDF/Word mit Executive Summary, Deckblatt, Klassifizierung

- Neuer report_generator.py: WeasyPrint (PDF) + python-docx (Word)
- 3 Stufen: Executive Summary (KI-generiert), Lagebericht, Vollständiger Bericht
- 3 Klassifizierungsstufen: Offen, Nur für den Dienstgebrauch, Vertraulich
- Deckblatt mit AegisSight Logo, Titel, Typ, Klassifizierung
- Executive Summary: Claude Haiku verdichtet Lagebild auf 3-5 Kernpunkte
- Jinja2 HTML-Template für PDF (A4-optimiert)
- Alte Exporte entfernt (Markdown, JSON, Browser-Print)
- Neues Export-Modal im Dashboard (Umfang/Format/Stufe)
Dieser Commit ist enthalten in:
Claude Dev
2026-03-25 01:28:47 +01:00
Ursprung 8feaac3320
Commit f7deafd14a
6 geänderte Dateien mit 678 neuen und 458 gelöschten Zeilen

387
src/report_generator.py Normale Datei
Datei anzeigen

@@ -0,0 +1,387 @@
"""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 "<p><em>Kein Lagebild verfügbar.</em></p>"
# Basic Markdown -> HTML
html = text
# Headlines
html = re.sub(r'^### (.+)$', r'<h3>\1</h3>', html, flags=re.MULTILINE)
html = re.sub(r'^## (.+)$', r'<h3>\1</h3>', html, flags=re.MULTILINE)
# Bold
html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html)
# Links [text](url)
html = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', html)
# Bullet lists
html = re.sub(r'^- (.+)$', r'<li>\1</li>', html, flags=re.MULTILINE)
html = re.sub(r'(<li>.*</li>\n?)+', lambda m: '<ul>' + m.group(0) + '</ul>', html)
# Paragraphs
paragraphs = html.split('\n\n')
result = []
for p in paragraphs:
p = p.strip()
if not p:
continue
if p.startswith('<h') or p.startswith('<ul') or p.startswith('<ol'):
result.append(p)
else:
result.append(f'<p>{p}</p>')
return '\n'.join(result)
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 "<ul><li>Kein Lagebild verfügbar. Executive Summary kann nicht erstellt werden.</li></ul>"
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)
# In HTML-Liste umwandeln
lines = [line.strip().lstrip("- ").lstrip("* ") for line in result.strip().split("\n") if line.strip().startswith(("-", "*"))]
if not lines:
lines = [result.strip()]
html = "<ul>\n" + "\n".join(f"<li>{line}</li>" for line in lines if line) + "\n</ul>"
return html
except Exception as e:
logger.error(f"Executive Summary Generierung fehlgeschlagen: {e}")
return "<ul><li>Executive Summary konnte nicht generiert werden.</li></ul>"
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(incident.get("summary", "")),
lagebild_timestamp=(incident.get("updated_at") or "")[:16].replace("T", " "),
sources=_prepare_sources(incident),
fact_checks=_prepare_fact_checks(fact_checks),
source_stats=_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)
summary = incident.get("summary") or "Kein Lagebild verfügbar."
# 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 ---
if fact_checks:
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 fact_checks:
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 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()

Datei anzeigen

@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<style>
@page { margin: 20mm 18mm 20mm 18mm; size: A4; @bottom-center { content: "Seite " counter(page) " von " counter(pages); font-size: 8pt; color: #888; } }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 10.5pt; line-height: 1.55; color: #1a1a1a; }
/* Deckblatt */
.cover { page-break-after: always; display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 85vh; text-align: center; }
.cover-logo { width: 80px; height: auto; margin-bottom: 30px; }
.cover-title { font-size: 26pt; font-weight: 700; color: #0a1832; margin-bottom: 8px; }
.cover-subtitle { font-size: 12pt; color: #666; margin-bottom: 40px; }
.cover-type { font-size: 10pt; color: #888; text-transform: uppercase; letter-spacing: 2px; margin-bottom: 6px; }
.cover-classification { display: inline-block; font-size: 10pt; font-weight: 700; text-transform: uppercase; letter-spacing: 2px; padding: 6px 24px; border: 2px solid; margin: 20px 0; }
.cover-classification.offen { color: #22c55e; border-color: #22c55e; }
.cover-classification.dienstgebrauch { color: #f0b429; border-color: #f0b429; }
.cover-classification.vertraulich { color: #ef4444; border-color: #ef4444; }
.cover-meta { font-size: 9pt; color: #888; margin-top: 40px; }
.cover-meta div { margin-bottom: 3px; }
.cover-brand { font-size: 9pt; color: #aaa; margin-top: 50px; letter-spacing: 1px; }
/* Classification Banner */
.classification-banner { text-align: center; font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 2px; padding: 3px 0; margin-bottom: 14px; }
.classification-banner.offen { color: #22c55e; }
.classification-banner.dienstgebrauch { color: #f0b429; }
.classification-banner.vertraulich { color: #ef4444; }
/* Sections */
.section { margin-bottom: 20px; }
.section h2 { font-size: 14pt; font-weight: 700; color: #0a1832; border-bottom: 2px solid #c8a851; padding-bottom: 4px; margin-bottom: 12px; }
.section h3 { font-size: 11pt; font-weight: 600; color: #0a1832; margin: 14px 0 6px; }
/* Executive Summary */
.exec-summary { background: #f8f9fa; border-left: 4px solid #c8a851; padding: 16px 20px; margin-bottom: 20px; }
.exec-summary ul { margin: 8px 0 0 18px; }
.exec-summary li { margin-bottom: 6px; line-height: 1.6; }
/* Lagebild */
.lagebild-content { line-height: 1.7; }
.lagebild-content p { margin-bottom: 8px; }
.lagebild-content strong { font-weight: 600; }
.lagebild-content a { color: #1a5276; text-decoration: underline; }
.lagebild-content ul, .lagebild-content ol { margin: 6px 0 6px 20px; }
.lagebild-content li { margin-bottom: 3px; }
/* Tabellen */
table { width: 100%; border-collapse: collapse; font-size: 9.5pt; margin-bottom: 14px; }
th { background: #0a1832; color: #fff; text-align: left; padding: 6px 10px; font-weight: 600; font-size: 8.5pt; text-transform: uppercase; letter-spacing: 0.5px; }
td { padding: 5px 10px; border-bottom: 1px solid #e0e0e0; }
tr:nth-child(even) { background: #f8f9fa; }
/* Faktencheck */
.fc-badge { display: inline-block; font-size: 7.5pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px; padding: 2px 8px; border-radius: 3px; }
.fc-confirmed { background: #d4edda; color: #155724; }
.fc-disputed { background: #f8d7da; color: #721c24; }
.fc-unconfirmed { background: #fff3cd; color: #856404; }
/* Timeline */
.tl-item { padding: 4px 0; border-left: 2px solid #c8a851; padding-left: 12px; margin-bottom: 6px; }
.tl-date { font-size: 8.5pt; color: #888; }
.tl-title { font-size: 10pt; }
.tl-source { font-size: 8pt; color: #aaa; }
/* Quellenverzeichnis */
.source-ref { font-size: 9pt; color: #666; }
/* Footer */
.report-footer { margin-top: 30px; padding-top: 10px; border-top: 1px solid #ddd; font-size: 8pt; color: #999; text-align: center; }
</style>
</head>
<body>
<!-- Deckblatt -->
<div class="cover">
<img src="data:image/svg+xml;base64,{{ logo_base64 }}" class="cover-logo" alt="AegisSight">
<div class="cover-type">{{ incident_type_label }}</div>
<div class="cover-title">{{ incident.title }}</div>
<div class="cover-subtitle">{{ incident.description or '' }}</div>
<div class="cover-classification {{ classification }}">{{ classification_label }}</div>
<div class="cover-meta">
<div>Stand: {{ report_date }}</div>
<div>Erstellt von: {{ creator }}</div>
{% if incident.organization_name %}<div>Organisation: {{ incident.organization_name }}</div>{% endif %}
</div>
<div class="cover-brand">AegisSight Monitor</div>
</div>
<!-- Classification Banner auf jeder Folgeseite -->
<div class="classification-banner {{ classification }}">{{ classification_label }}</div>
<!-- Executive Summary -->
<div class="section">
<h2>Executive Summary</h2>
<div class="exec-summary">
{{ executive_summary | safe }}
</div>
</div>
{% if scope in ('report', 'full') %}
<!-- Lagebild -->
<div class="section">
<h2>Lagebild</h2>
{% if lagebild_timestamp %}<p style="font-size:9pt;color:#888;margin-bottom:10px;">Aktualisiert: {{ lagebild_timestamp }}</p>{% endif %}
<div class="lagebild-content">{{ lagebild_html | safe }}</div>
</div>
<!-- Quellenverzeichnis -->
{% if sources %}
<div class="section">
<h2>Quellenverzeichnis</h2>
<table>
<thead><tr><th>#</th><th>Quelle</th><th>URL</th></tr></thead>
<tbody>
{% for src in sources %}
<tr><td>{{ loop.index }}</td><td>{{ src.name or src.title or '' }}</td><td class="source-ref">{{ src.url or '' }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Faktencheck -->
{% if fact_checks %}
<div class="section">
<h2>Faktencheck</h2>
<table>
<thead><tr><th>Behauptung</th><th>Status</th><th>Quellen</th></tr></thead>
<tbody>
{% for fc in fact_checks %}
<tr>
<td>{{ fc.claim or '' }}</td>
<td><span class="fc-badge fc-{{ fc.status or 'unconfirmed' }}">{{ fc.status_label }}</span></td>
<td>{{ fc.sources_count or 0 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Quellenstatistik -->
{% if source_stats %}
<div class="section">
<h2>Quellenstatistik</h2>
<table>
<thead><tr><th>Quelle</th><th>Artikel</th><th>Sprache</th></tr></thead>
<tbody>
{% for stat in source_stats %}
<tr><td>{{ stat.name }}</td><td>{{ stat.count }}</td><td>{{ stat.languages }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endif %}
{% if scope == 'full' %}
<!-- Timeline -->
{% if timeline %}
<div class="section" style="page-break-before:always;">
<h2>Ereignis-Timeline</h2>
{% for event in timeline %}
<div class="tl-item">
<div class="tl-date">{{ event.date }}</div>
<div class="tl-title">{{ event.headline }}</div>
<div class="tl-source">{{ event.source }}</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Artikelverzeichnis -->
{% if articles %}
<div class="section" style="page-break-before:always;">
<h2>Artikelverzeichnis ({{ articles | length }} Artikel)</h2>
<table>
<thead><tr><th>Headline</th><th>Quelle</th><th>Sprache</th><th>Datum</th></tr></thead>
<tbody>
{% for art in articles %}
<tr>
<td>{{ art.headline_de or art.headline or 'Ohne Titel' }}</td>
<td>{{ art.source or '' }}</td>
<td>{{ (art.language or 'de') | upper }}</td>
<td>{{ art.pub_date }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endif %}
<div class="report-footer">
Erstellt mit AegisSight Monitor &mdash; aegis-sight.de &mdash; {{ report_date }}
</div>
</body>
</html>

Datei anzeigen

@@ -9,6 +9,7 @@ from datetime import datetime
from config import TIMEZONE
import asyncio
import aiosqlite
import io
import json
import logging
import re
@@ -629,182 +630,18 @@ def _slugify(text: str) -> str:
return text[:80].lower()
def _build_markdown_export(
incident: dict, articles: list, fact_checks: list,
snapshots: list, scope: str, creator: str
) -> str:
"""Markdown-Dokument zusammenbauen."""
typ = "Hintergrundrecherche" if incident.get("type") == "research" else "Breaking News"
updated = (incident.get("updated_at") or "")[:16].replace("T", " ")
lines = []
lines.append(f"# {incident['title']}")
lines.append(f"> {typ} | Erstellt von {creator} | Stand: {updated}")
lines.append("")
# Lagebild
summary = incident.get("summary") or "*Noch kein Lagebild verf\u00fcgbar.*"
lines.append("## Lagebild")
lines.append("")
lines.append(summary)
lines.append("")
# Quellenverzeichnis aus sources_json
sources_json = incident.get("sources_json")
if sources_json:
try:
sources = json.loads(sources_json) if isinstance(sources_json, str) else sources_json
if sources:
lines.append("## Quellenverzeichnis")
lines.append("")
for i, src in enumerate(sources, 1):
name = src.get("name") or src.get("title") or src.get("url", "")
url = src.get("url", "")
if url:
lines.append(f"{i}. [{name}]({url})")
else:
lines.append(f"{i}. {name}")
lines.append("")
except (json.JSONDecodeError, TypeError):
pass
# Faktencheck
if fact_checks:
lines.append("## Faktencheck")
lines.append("")
for fc in fact_checks:
claim = fc.get("claim", "")
fc_status = fc.get("status", "")
sources_count = fc.get("sources_count", 0)
evidence = fc.get("evidence", "")
status_label = {
"confirmed": "Best\u00e4tigt", "unconfirmed": "Unbest\u00e4tigt",
"disputed": "Umstritten", "false": "Falsch",
}.get(fc_status, fc_status)
line = f"- **{claim}** \u2014 {status_label} ({sources_count} Quellen)"
if evidence:
line += f"\n {evidence}"
lines.append(line)
lines.append("")
# Scope=full: Artikel\u00fcbersicht
if scope == "full" and articles:
lines.append("## Artikel\u00fcbersicht")
lines.append("")
lines.append("| Headline | Quelle | Sprache | Datum |")
lines.append("|----------|--------|---------|-------|")
for art in articles:
headline = (art.get("headline_de") or art.get("headline") or "").replace("|", "/")
source = (art.get("source") or "").replace("|", "/")
lang = art.get("language", "")
pub = (art.get("published_at") or art.get("collected_at") or "")[:16]
lines.append(f"| {headline} | {source} | {lang} | {pub} |")
lines.append("")
# Scope=full: Snapshot-Verlauf
if scope == "full" and snapshots:
lines.append("## Snapshot-Verlauf")
lines.append("")
for snap in snapshots:
snap_date = (snap.get("created_at") or "")[:16].replace("T", " ")
art_count = snap.get("article_count", 0)
fc_count = snap.get("fact_check_count", 0)
lines.append(f"### Snapshot vom {snap_date}")
lines.append(f"Artikel: {art_count} | Faktenchecks: {fc_count}")
lines.append("")
snap_summary = snap.get("summary", "")
if snap_summary:
lines.append(snap_summary)
lines.append("")
now = datetime.now(TIMEZONE).strftime("%Y-%m-%d %H:%M Uhr")
lines.append("---")
lines.append(f"*Exportiert am {now} aus AegisSight Monitor*")
return "\n".join(lines)
def _build_json_export(
incident: dict, articles: list, fact_checks: list,
snapshots: list, scope: str, creator: str
) -> dict:
"""Strukturiertes JSON fuer Export."""
now = datetime.now(TIMEZONE).strftime("%Y-%m-%dT%H:%M:%SZ")
sources = []
sources_json = incident.get("sources_json")
if sources_json:
try:
sources = json.loads(sources_json) if isinstance(sources_json, str) else sources_json
except (json.JSONDecodeError, TypeError):
pass
export = {
"export_version": "1.0",
"exported_at": now,
"scope": scope,
"incident": {
"id": incident["id"],
"title": incident["title"],
"description": incident.get("description"),
"type": incident.get("type"),
"status": incident.get("status"),
"visibility": incident.get("visibility"),
"created_by": creator,
"created_at": incident.get("created_at"),
"updated_at": incident.get("updated_at"),
"summary": incident.get("summary"),
"international_sources": bool(incident.get("international_sources")),
"include_telegram": bool(incident.get("include_telegram")),
},
"sources": sources,
"fact_checks": [
{
"claim": fc.get("claim"),
"status": fc.get("status"),
"sources_count": fc.get("sources_count"),
"evidence": fc.get("evidence"),
"checked_at": fc.get("checked_at"),
}
for fc in fact_checks
],
}
if scope == "full":
export["articles"] = [
{
"headline": art.get("headline"),
"headline_de": art.get("headline_de"),
"source": art.get("source"),
"source_url": art.get("source_url"),
"language": art.get("language"),
"published_at": art.get("published_at"),
"collected_at": art.get("collected_at"),
"verification_status": art.get("verification_status"),
}
for art in articles
]
export["snapshots"] = [
{
"created_at": snap.get("created_at"),
"article_count": snap.get("article_count"),
"fact_check_count": snap.get("fact_check_count"),
"summary": snap.get("summary"),
}
for snap in snapshots
]
return export
@router.get("/{incident_id}/export")
async def export_incident(
incident_id: int,
format: str = Query(..., pattern="^(md|json)$"),
scope: str = Query("report", pattern="^(report|full)$"),
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 Markdown oder JSON exportieren."""
"""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)
@@ -837,23 +674,35 @@ async def export_incident(
)
snapshots = [dict(r) for r in await cursor.fetchall()]
# Dateiname
# 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_suffix = "_vollexport" if scope == "full" else ""
if format == "md":
body = _build_markdown_export(incident, articles, fact_checks, snapshots, scope, creator)
filename = f"{slug}{scope_suffix}_{date_str}.md"
media_type = "text/markdown; charset=utf-8"
else:
export_data = _build_json_export(incident, articles, fact_checks, snapshots, scope, creator)
body = json.dumps(export_data, ensure_ascii=False, indent=2)
filename = f"{slug}{scope_suffix}_{date_str}.json"
media_type = "application/json; charset=utf-8"
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(
iter([body]),
media_type=media_type,
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}"'},
)

Datei anzeigen

@@ -15,6 +15,15 @@
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
<link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css">
<link rel="stylesheet" href="/static/css/style.css?v=20260316k">
<style>
/* Export Modal Radio */
.export-radio { display:flex; align-items:center; gap:10px; padding:8px 12px; cursor:pointer; border-radius:var(--radius-sm); transition:background 0.15s; border:1px solid transparent; margin-bottom:4px; }
.export-radio:hover { background:var(--bg-secondary); }
.export-radio input[type="radio"] { accent-color:var(--accent); width:16px; height:16px; cursor:pointer; flex-shrink:0; }
.export-radio input[type="radio"]:checked ~ span:first-of-type { color:var(--accent); font-weight:600; }
.export-radio span:first-of-type { font-size:13px; }
.export-radio-desc { font-size:11px; color:var(--text-tertiary); margin-left:auto; }
</style>
</head>
<body>
<a href="#main-content" class="skip-link">Zum Hauptinhalt springen</a>
@@ -140,18 +149,7 @@
<div class="incident-header-actions">
<button class="btn btn-primary btn-small" id="refresh-btn">Aktualisieren</button>
<button class="btn btn-secondary btn-small" id="edit-incident-btn">Bearbeiten</button>
<div class="export-dropdown" id="export-dropdown">
<button class="btn btn-secondary btn-small" onclick="App.toggleExportDropdown(event)" aria-expanded="false" aria-haspopup="true">Exportieren &#9662;</button>
<div class="export-dropdown-menu" id="export-dropdown-menu" role="menu">
<button class="export-dropdown-item" role="menuitem" onclick="App.exportIncident('md','report')">Lagebericht (Markdown)</button>
<button class="export-dropdown-item" role="menuitem" onclick="App.exportIncident('json','report')">Lagebericht (JSON)</button>
<hr class="export-dropdown-divider" role="separator">
<button class="export-dropdown-item" role="menuitem" onclick="App.exportIncident('md','full')">Vollexport (Markdown)</button>
<button class="export-dropdown-item" role="menuitem" onclick="App.exportIncident('json','full')">Vollexport (JSON)</button>
<hr class="export-dropdown-divider" role="separator">
<button class="export-dropdown-item" role="menuitem" onclick="App.openPdfExportDialog()">PDF exportieren...</button>
</div>
</div>
<button class="btn btn-secondary btn-small" onclick="App.openExportModal()">Bericht exportieren</button>
<button class="btn btn-secondary btn-small" id="archive-incident-btn">Archivieren</button>
<button class="btn btn-danger btn-small" id="delete-incident-btn">Löschen</button>
</div>
@@ -661,25 +659,39 @@
<div class="map-fullscreen-container" id="map-fullscreen-container"></div>
</div>
<!-- PDF Export Dialog -->
<div class="modal-overlay" id="modal-pdf-export" role="dialog" aria-modal="true" aria-labelledby="pdf-export-title">
<!-- Export Modal -->
<div class="modal-overlay" id="modal-export" role="dialog" aria-modal="true">
<div class="modal" style="max-width:420px;">
<div class="modal-header">
<h3 id="pdf-export-title">PDF exportieren</h3>
<button class="modal-close" onclick="closeModal('modal-pdf-export')" aria-label="Schliessen">&times;</button>
<h3>Bericht exportieren</h3>
<button class="modal-close" onclick="closeModal('modal-export')">&times;</button>
</div>
<div class="modal-body" style="padding:20px;">
<p style="margin:0 0 16px;font-size:13px;color:var(--text-secondary);">Kacheln fuer den Export auswaehlen:</p>
<div id="pdf-export-tiles" style="display:flex;flex-direction:column;gap:10px;">
<label class="pdf-tile-option"><input type="checkbox" value="lagebild" checked><span>Lagebild</span></label>
<label class="pdf-tile-option"><input type="checkbox" value="quellen" checked><span>Quellenübersicht</span></label>
<label class="pdf-tile-option"><input type="checkbox" value="faktencheck" checked><span>Faktencheck</span></label>
<label class="pdf-tile-option"><input type="checkbox" value="timeline"><span>Ereignis-Timeline</span></label>
<div style="margin-bottom:16px;">
<label style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);display:block;margin-bottom:8px;">Umfang</label>
<label class="export-radio"><input type="radio" name="export-scope" value="summary" checked><span>Executive Summary</span><span class="export-radio-desc">1-2 Seiten, Kernpunkte</span></label>
<label class="export-radio"><input type="radio" name="export-scope" value="report"><span>Lagebericht</span><span class="export-radio-desc">Lagebild, Faktencheck, Quellen</span></label>
<label class="export-radio"><input type="radio" name="export-scope" value="full"><span>Vollst&auml;ndiger Bericht</span><span class="export-radio-desc">+ Timeline, Artikelverzeichnis</span></label>
</div>
<div style="margin-bottom:16px;">
<label style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);display:block;margin-bottom:8px;">Format</label>
<label class="export-radio"><input type="radio" name="export-format" value="pdf" checked><span>PDF</span></label>
<label class="export-radio"><input type="radio" name="export-format" value="docx"><span>Word (DOCX)</span></label>
</div>
<div style="margin-bottom:8px;">
<label style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);display:block;margin-bottom:8px;">Klassifizierung</label>
<select id="export-classification" class="form-control" style="width:100%;padding:8px 12px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-primary);font-size:13px;">
<option value="offen">Offen</option>
<option value="dienstgebrauch">Nur f&uuml;r den Dienstgebrauch</option>
<option value="vertraulich" selected>Vertraulich</option>
</select>
</div>
</div>
<div class="modal-footer" style="padding:12px 20px;display:flex;justify-content:flex-end;gap:8px;border-top:1px solid var(--border);">
<button class="btn btn-secondary" onclick="closeModal('modal-pdf-export')">Abbrechen</button>
<button class="btn btn-primary" onclick="App.executePdfExport()">Exportieren</button>
<button class="btn btn-secondary" onclick="closeModal('modal-export')">Abbrechen</button>
<button class="btn btn-primary" id="export-submit-btn" onclick="App.submitExport()">Exportieren</button>
</div>
</div>
</div>

Datei anzeigen

@@ -228,9 +228,9 @@ const API = {
resetTutorialState() {
return this._request('DELETE', '/tutorial/state');
},
exportIncident(id, format, scope) {
exportReport(id, format, scope, classification) {
const token = localStorage.getItem('osint_token');
return fetch(`${this.baseUrl}/incidents/${id}/export?format=${format}&scope=${scope}`, {
return fetch(`${this.baseUrl}/incidents/${id}/export?format=${format}&scope=${scope}&classification=${classification}`, {
headers: { 'Authorization': `Bearer ${token}` },
});
},

Datei anzeigen

@@ -772,7 +772,6 @@ const App = {
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 _pdfLabel = document.querySelector('#pdf-export-tiles input[value="lagebild"] + span');
if (_pdfLabel) _pdfLabel.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; } }
@@ -2133,252 +2132,31 @@ const App = {
// === Export ===
toggleExportDropdown(event) {
event.stopPropagation();
const menu = document.getElementById('export-dropdown-menu');
if (!menu) return;
const isOpen = menu.classList.toggle('show');
const btn = menu.previousElementSibling;
if (btn) btn.setAttribute('aria-expanded', String(isOpen));
},
_closeExportDropdown() {
const menu = document.getElementById('export-dropdown-menu');
if (menu) {
menu.classList.remove('show');
const btn = menu.previousElementSibling;
if (btn) btn.setAttribute('aria-expanded', 'false');
}
},
openPdfExportDialog() {
this._closeExportDropdown();
openExportModal() {
if (!this.currentIncidentId) return;
openModal('modal-pdf-export');
openModal('modal-export');
},
executePdfExport() {
closeModal('modal-pdf-export');
const checked = [...document.querySelectorAll('#pdf-export-tiles input:checked')].map(c => c.value);
if (!checked.length) { UI.showToast('Keine Kacheln ausgewählt', 'warning'); return; }
this._generatePdf(checked);
},
_generatePdf(tiles) {
const title = document.getElementById('incident-title')?.textContent || 'Export';
const now = new Date().toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
let sections = '';
const esc = (s) => s ? s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : '';
// === Lagebild ===
if (tiles.includes('lagebild')) {
const summaryEl = document.getElementById('summary-text');
const timestamp = document.getElementById('lagebild-timestamp')?.textContent || '';
if (summaryEl && summaryEl.innerHTML.trim()) {
// Clone innerHTML and make citation links clickable with full URL visible
let summaryHtml = summaryEl.innerHTML;
// Ensure citation links are styled for print (underlined, blue)
summaryHtml = summaryHtml.replace(/<a\s+href="([^"]*)"[^>]*class="citation"[^>]*>(\[[^\]]+\])<\/a>/g,
'<a href="$1" class="citation">$2</a>');
sections += '<div class="pdf-section">'
+ '<h2>' + (this._currentIncidentType === 'research' ? 'Recherchebericht' : 'Lagebild') + '</h2>'
+ (timestamp ? '<p class="pdf-meta">' + esc(timestamp) + '</p>' : '')
+ '<div class="pdf-content">' + summaryHtml + '</div>'
+ '</div>';
}
}
// === Quellen ===
if (tiles.includes('quellen')) {
const articles = this._currentArticles || [];
if (articles.length) {
const sourceMap = {};
articles.forEach(a => {
const name = a.source || 'Unbekannt';
if (!sourceMap[name]) sourceMap[name] = [];
sourceMap[name].push(a);
});
const sources = Object.entries(sourceMap).sort((a,b) => b[1].length - a[1].length);
let s = '<p class="pdf-meta">' + articles.length + ' Artikel aus ' + sources.length + ' Quellen</p>';
s += '<table class="pdf-table"><thead><tr><th>Quelle</th><th>Artikel</th><th>Sprache</th></tr></thead><tbody>';
sources.forEach(([name, arts]) => {
const langs = [...new Set(arts.map(a => (a.language || 'de').toUpperCase()))].join(', ');
s += '<tr><td><strong>' + esc(name) + '</strong></td><td>' + arts.length + '</td><td>' + langs + '</td></tr>';
});
s += '</tbody></table>';
s += '<div class="pdf-article-list">';
sources.forEach(([name, arts]) => {
s += '<h4>' + esc(name) + ' (' + arts.length + ')</h4>';
arts.forEach(a => {
const hl = esc(a.headline_de || a.headline || 'Ohne Titel');
const url = a.source_url || '';
const dateStr = a.published_at ? new Date(a.published_at).toLocaleDateString('de-DE') : '';
s += '<div class="pdf-article-item">';
s += url ? '<a href="' + esc(url) + '">' + hl + '</a>' : '<span>' + hl + '</span>';
if (dateStr) s += ' <span class="pdf-date">(' + dateStr + ')</span>';
s += '</div>';
});
});
s += '</div>';
sections += '<div class="pdf-section"><h2>Quellenübersicht</h2>' + s + '</div>';
}
}
// === Faktencheck ===
if (tiles.includes('faktencheck')) {
const fcItems = document.querySelectorAll('#factcheck-list .factcheck-item');
if (fcItems.length) {
let s = '<div class="pdf-fc-list">';
fcItems.forEach(item => {
const status = item.dataset.fcStatus || '';
const statusEl = item.querySelector('.fc-status-text, .factcheck-status');
const claimEl = item.querySelector('.fc-claim-text, .factcheck-claim');
const evidenceEls = item.querySelectorAll('.fc-evidence-chip, .evidence-chip');
const statusText = statusEl ? statusEl.textContent.trim() : status;
const claim = claimEl ? claimEl.textContent.trim() : '';
const statusClass = (status.includes('confirmed') || status.includes('verified')) ? 'confirmed'
: (status.includes('refuted') || status.includes('disputed')) ? 'refuted'
: 'unverified';
s += '<div class="pdf-fc-item">'
+ '<span class="pdf-fc-badge pdf-fc-' + statusClass + '">' + esc(statusText) + '</span>'
+ '<div class="pdf-fc-claim">' + esc(claim) + '</div>';
if (evidenceEls.length) {
s += '<div class="pdf-fc-evidence">';
evidenceEls.forEach(ev => {
const link = ev.closest('a');
const href = link ? link.href : '';
const text = ev.textContent.trim();
s += href
? '<a href="' + esc(href) + '" class="pdf-fc-ev-link">' + esc(text) + '</a> '
: '<span class="pdf-fc-ev-tag">' + esc(text) + '</span> ';
});
s += '</div>';
}
s += '</div>';
});
s += '</div>';
sections += '<div class="pdf-section"><h2>Faktencheck</h2>' + s + '</div>';
}
}
// === Timeline ===
if (tiles.includes('timeline')) {
const buckets = document.querySelectorAll('#timeline .ht-bucket');
if (buckets.length) {
let s = '<div class="pdf-timeline">';
buckets.forEach(bucket => {
const label = bucket.querySelector('.ht-bucket-label');
const items = bucket.querySelectorAll('.ht-item');
if (label) s += '<h4>' + esc(label.textContent.trim()) + '</h4>';
items.forEach(item => {
const time = item.querySelector('.ht-item-time');
const ttl = item.querySelector('.ht-item-title');
const src = item.querySelector('.ht-item-source');
s += '<div class="pdf-tl-item">';
if (time) s += '<span class="pdf-tl-time">' + esc(time.textContent.trim()) + '</span> ';
if (ttl) s += '<span class="pdf-tl-title">' + esc(ttl.textContent.trim()) + '</span>';
if (src) s += ' <span class="pdf-tl-source">' + esc(src.textContent.trim()) + '</span>';
s += '</div>';
});
});
s += '</div>';
sections += '<div class="pdf-section"><h2>Ereignis-Timeline</h2>' + s + '</div>';
}
}
if (!sections) { UI.showToast('Keine Inhalte zum Exportieren', 'warning'); return; }
const css = `
@page { margin: 18mm 15mm 18mm 15mm; size: A4; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 11pt; line-height: 1.55; color: #1a1a1a; background: #fff; padding: 0; }
a { color: #1a5276; }
/* Header: compact, inline with content */
.pdf-header { border-bottom: 2px solid #2c3e50; padding-bottom: 10px; margin-bottom: 16px; }
.pdf-header h1 { font-size: 18pt; font-weight: 700; color: #2c3e50; margin-bottom: 2px; }
.pdf-header .pdf-subtitle { font-size: 9pt; color: #666; }
/* Sections */
.pdf-section { margin-bottom: 22px; }
.pdf-section h2 { font-size: 13pt; font-weight: 600; color: #2c3e50; border-bottom: 1px solid #ccc; padding-bottom: 4px; margin-bottom: 10px; }
.pdf-section h4 { font-size: 10pt; font-weight: 600; color: #444; margin: 10px 0 3px; }
.pdf-meta { font-size: 9pt; color: #888; margin-bottom: 8px; }
/* Lagebild content */
.pdf-content { font-size: 10.5pt; line-height: 1.6; }
.pdf-content h3 { font-size: 11.5pt; font-weight: 600; color: #2c3e50; margin: 12px 0 5px; }
.pdf-content strong { font-weight: 600; }
.pdf-content ul { margin: 4px 0 4px 18px; }
.pdf-content li { margin-bottom: 2px; }
.pdf-content a, .pdf-content .citation { color: #1a5276; font-weight: 600; text-decoration: underline; cursor: pointer; }
/* Quellen table */
.pdf-table { width: 100%; border-collapse: collapse; font-size: 9.5pt; margin-bottom: 14px; }
.pdf-table th { background: #f0f0f0; text-align: left; padding: 5px 8px; border: 1px solid #ddd; font-weight: 600; font-size: 8.5pt; text-transform: uppercase; color: #555; }
.pdf-table td { padding: 4px 8px; border: 1px solid #ddd; }
.pdf-table tr:nth-child(even) { background: #fafafa; }
.pdf-article-list { font-size: 9.5pt; }
.pdf-article-item { padding: 1px 0; break-inside: avoid; }
.pdf-article-item a { color: #1a5276; text-decoration: none; }
.pdf-article-item a:hover { text-decoration: underline; }
.pdf-date { color: #888; font-size: 8.5pt; }
/* Faktencheck */
.pdf-fc-list { display: flex; flex-direction: column; gap: 10px; }
.pdf-fc-item { border: 1px solid #ddd; border-radius: 4px; padding: 8px 12px; break-inside: avoid; }
.pdf-fc-badge { display: inline-block; font-size: 7.5pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px; padding: 1px 7px; border-radius: 3px; margin-bottom: 3px; }
.pdf-fc-confirmed { background: #d4edda; color: #155724; }
.pdf-fc-refuted { background: #f8d7da; color: #721c24; }
.pdf-fc-unverified { background: #fff3cd; color: #856404; }
.pdf-fc-claim { font-size: 10.5pt; margin-top: 3px; }
.pdf-fc-evidence { margin-top: 5px; font-size: 8.5pt; }
.pdf-fc-ev-link { color: #1a5276; text-decoration: underline; margin-right: 5px; }
.pdf-fc-ev-tag { background: #eee; padding: 1px 5px; border-radius: 3px; margin-right: 3px; }
/* Timeline */
.pdf-timeline h4 { color: #2c3e50; border-bottom: 1px solid #eee; padding-bottom: 2px; margin-top: 8px; }
.pdf-tl-item { padding: 1px 0; font-size: 9.5pt; break-inside: avoid; }
.pdf-tl-time { color: #888; font-size: 8.5pt; min-width: 36px; display: inline-block; }
.pdf-tl-source { color: #888; font-size: 8.5pt; }
/* Footer */
.pdf-footer { margin-top: 24px; padding-top: 8px; border-top: 1px solid #ddd; font-size: 8pt; color: #999; text-align: center; }
`;
const printHtml = '<!DOCTYPE html>\n<html lang="de">\n<head>\n<meta charset="utf-8">\n'
+ '<title>' + esc(title) + ' \u2014 AegisSight Export</title>\n'
+ '<style>' + css + '</style>\n'
+ '</head>\n<body>\n'
+ '<div class="pdf-header">\n'
+ ' <h1>' + esc(title) + '</h1>\n'
+ ' <div class="pdf-subtitle">AegisSight Monitor \u2014 Exportiert am ' + esc(now) + '</div>\n'
+ '</div>\n'
+ sections + '\n'
+ '<div class="pdf-footer">Erstellt mit AegisSight Monitor \u2014 aegis-sight.de</div>\n'
+ '</body></html>';
const printWin = window.open('', '_blank', 'width=800,height=600');
if (!printWin) { UI.showToast('Popup blockiert \u2014 bitte Popup-Blocker deaktivieren', 'error'); return; }
printWin.document.write(printHtml);
printWin.document.close();
printWin.onload = function() { printWin.focus(); printWin.print(); };
setTimeout(function() { try { printWin.focus(); printWin.print(); } catch(e) {} }, 500);
},
async exportIncident(format, scope) {
this._closeExportDropdown();
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.exportIncident(this.currentIncidentId, format, scope);
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 = 'export.' + format;
let filename = 'bericht.' + format;
const match = disposition.match(/filename="?([^"]+)"?/);
if (match) filename = match[1];
const url = URL.createObjectURL(blob);
@@ -2389,14 +2167,16 @@ a { color: #1a5276; }
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
UI.showToast('Export heruntergeladen', 'success');
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() {
@@ -3464,11 +3244,7 @@ document.addEventListener('keydown', (e) => {
NotificationCenter.close();
return;
}
const exportMenu = document.getElementById('export-dropdown-menu');
if (exportMenu && exportMenu.classList.contains('show')) {
App._closeExportDropdown();
return;
}
const fcMenu = document.querySelector('.fc-dropdown-menu.open');
if (fcMenu) {
fcMenu.classList.remove('open');
@@ -3518,8 +3294,6 @@ document.addEventListener('click', (e) => {
// App starten
document.addEventListener('click', (e) => {
if (!e.target.closest('.export-dropdown')) {
App._closeExportDropdown();
}
});
document.addEventListener('DOMContentLoaded', () => App.init());