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:
387
src/report_generator.py
Normale Datei
387
src/report_generator.py
Normale Datei
@@ -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()
|
||||||
198
src/report_templates/report.html
Normale Datei
198
src/report_templates/report.html
Normale Datei
@@ -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 — aegis-sight.de — {{ report_date }}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -9,6 +9,7 @@ from datetime import datetime
|
|||||||
from config import TIMEZONE
|
from config import TIMEZONE
|
||||||
import asyncio
|
import asyncio
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
@@ -629,182 +630,18 @@ def _slugify(text: str) -> str:
|
|||||||
return text[:80].lower()
|
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")
|
@router.get("/{incident_id}/export")
|
||||||
async def export_incident(
|
async def export_incident(
|
||||||
incident_id: int,
|
incident_id: int,
|
||||||
format: str = Query(..., pattern="^(md|json)$"),
|
format: str = Query("pdf", pattern="^(pdf|docx)$"),
|
||||||
scope: str = Query("report", pattern="^(report|full)$"),
|
scope: str = Query("report", pattern="^(summary|report|full)$"),
|
||||||
|
classification: str = Query("offen", pattern="^(offen|dienstgebrauch|vertraulich)$"),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
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")
|
tenant_id = current_user.get("tenant_id")
|
||||||
row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
||||||
incident = dict(row)
|
incident = dict(row)
|
||||||
@@ -837,23 +674,35 @@ async def export_incident(
|
|||||||
)
|
)
|
||||||
snapshots = [dict(r) for r in await cursor.fetchall()]
|
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")
|
date_str = datetime.now(TIMEZONE).strftime("%Y%m%d")
|
||||||
slug = _slugify(incident["title"])
|
slug = _slugify(incident["title"])
|
||||||
scope_suffix = "_vollexport" if scope == "full" else ""
|
scope_labels = {"summary": "executive_summary", "report": "lagebericht", "full": "vollstaendig"}
|
||||||
|
|
||||||
if format == "md":
|
if format == "pdf":
|
||||||
body = _build_markdown_export(incident, articles, fact_checks, snapshots, scope, creator)
|
pdf_bytes = await generate_pdf(incident, articles, fact_checks, snapshots, scope, classification, creator, exec_summary)
|
||||||
filename = f"{slug}{scope_suffix}_{date_str}.md"
|
filename = f"{slug}_{scope_labels[scope]}_{date_str}.pdf"
|
||||||
media_type = "text/markdown; charset=utf-8"
|
return StreamingResponse(
|
||||||
|
io.BytesIO(pdf_bytes),
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
export_data = _build_json_export(incident, articles, fact_checks, snapshots, scope, creator)
|
docx_bytes = await generate_docx(incident, articles, fact_checks, snapshots, scope, classification, creator, exec_summary)
|
||||||
body = json.dumps(export_data, ensure_ascii=False, indent=2)
|
filename = f"{slug}_{scope_labels[scope]}_{date_str}.docx"
|
||||||
filename = f"{slug}{scope_suffix}_{date_str}.json"
|
return StreamingResponse(
|
||||||
media_type = "application/json; charset=utf-8"
|
io.BytesIO(docx_bytes),
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
return StreamingResponse(
|
|
||||||
iter([body]),
|
|
||||||
media_type=media_type,
|
|
||||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -15,6 +15,15 @@
|
|||||||
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
|
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
|
||||||
<link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css">
|
<link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css">
|
||||||
<link rel="stylesheet" href="/static/css/style.css?v=20260316k">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<a href="#main-content" class="skip-link">Zum Hauptinhalt springen</a>
|
<a href="#main-content" class="skip-link">Zum Hauptinhalt springen</a>
|
||||||
@@ -140,18 +149,7 @@
|
|||||||
<div class="incident-header-actions">
|
<div class="incident-header-actions">
|
||||||
<button class="btn btn-primary btn-small" id="refresh-btn">Aktualisieren</button>
|
<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>
|
<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.openExportModal()">Bericht exportieren</button>
|
||||||
<button class="btn btn-secondary btn-small" onclick="App.toggleExportDropdown(event)" aria-expanded="false" aria-haspopup="true">Exportieren ▾</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" id="archive-incident-btn">Archivieren</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>
|
<button class="btn btn-danger btn-small" id="delete-incident-btn">Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -661,25 +659,39 @@
|
|||||||
<div class="map-fullscreen-container" id="map-fullscreen-container"></div>
|
<div class="map-fullscreen-container" id="map-fullscreen-container"></div>
|
||||||
</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" style="max-width:420px;">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 id="pdf-export-title">PDF exportieren</h3>
|
<h3>Bericht exportieren</h3>
|
||||||
<button class="modal-close" onclick="closeModal('modal-pdf-export')" aria-label="Schliessen">×</button>
|
<button class="modal-close" onclick="closeModal('modal-export')">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body" style="padding:20px;">
|
<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 style="margin-bottom:16px;">
|
||||||
<div id="pdf-export-tiles" style="display:flex;flex-direction:column;gap:10px;">
|
<label style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);display:block;margin-bottom:8px;">Umfang</label>
|
||||||
<label class="pdf-tile-option"><input type="checkbox" value="lagebild" checked><span>Lagebild</span></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="pdf-tile-option"><input type="checkbox" value="quellen" checked><span>Quellenübersicht</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="pdf-tile-option"><input type="checkbox" value="faktencheck" checked><span>Faktencheck</span></label>
|
<label class="export-radio"><input type="radio" name="export-scope" value="full"><span>Vollständiger Bericht</span><span class="export-radio-desc">+ Timeline, Artikelverzeichnis</span></label>
|
||||||
<label class="pdf-tile-option"><input type="checkbox" value="timeline"><span>Ereignis-Timeline</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ür den Dienstgebrauch</option>
|
||||||
|
<option value="vertraulich" selected>Vertraulich</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer" style="padding:12px 20px;display:flex;justify-content:flex-end;gap:8px;border-top:1px solid var(--border);">
|
<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-secondary" onclick="closeModal('modal-export')">Abbrechen</button>
|
||||||
<button class="btn btn-primary" onclick="App.executePdfExport()">Exportieren</button>
|
<button class="btn btn-primary" id="export-submit-btn" onclick="App.submitExport()">Exportieren</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -228,9 +228,9 @@ const API = {
|
|||||||
resetTutorialState() {
|
resetTutorialState() {
|
||||||
return this._request('DELETE', '/tutorial/state');
|
return this._request('DELETE', '/tutorial/state');
|
||||||
},
|
},
|
||||||
exportIncident(id, format, scope) {
|
exportReport(id, format, scope, classification) {
|
||||||
const token = localStorage.getItem('osint_token');
|
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}` },
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -772,7 +772,6 @@ const App = {
|
|||||||
if (_cardTitle) { _cardTitle.textContent = _lbLabel; _cardTitle.setAttribute("onclick", "openContentModal('" + _lbLabel + "', 'summary-content')"); }
|
if (_cardTitle) { _cardTitle.textContent = _lbLabel; _cardTitle.setAttribute("onclick", "openContentModal('" + _lbLabel + "', 'summary-content')"); }
|
||||||
const _toggleBtn = document.querySelector('.layout-toggle-btn[data-tile="lagebild"]');
|
const _toggleBtn = document.querySelector('.layout-toggle-btn[data-tile="lagebild"]');
|
||||||
if (_toggleBtn) _toggleBtn.textContent = _lbLabel;
|
if (_toggleBtn) _toggleBtn.textContent = _lbLabel;
|
||||||
const _pdfLabel = document.querySelector('#pdf-export-tiles input[value="lagebild"] + span');
|
|
||||||
if (_pdfLabel) _pdfLabel.textContent = _lbLabel;
|
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; } }
|
{ 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 ===
|
// === Export ===
|
||||||
|
|
||||||
toggleExportDropdown(event) {
|
openExportModal() {
|
||||||
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();
|
|
||||||
if (!this.currentIncidentId) return;
|
if (!this.currentIncidentId) return;
|
||||||
openModal('modal-pdf-export');
|
openModal('modal-export');
|
||||||
},
|
},
|
||||||
|
|
||||||
executePdfExport() {
|
async submitExport() {
|
||||||
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') : '';
|
|
||||||
|
|
||||||
// === 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();
|
|
||||||
if (!this.currentIncidentId) return;
|
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 {
|
try {
|
||||||
const response = await API.exportIncident(this.currentIncidentId, format, scope);
|
const response = await API.exportReport(this.currentIncidentId, format, scope, classification);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const err = await response.json().catch(() => ({}));
|
const err = await response.json().catch(() => ({}));
|
||||||
throw new Error(err.detail || 'Fehler ' + response.status);
|
throw new Error(err.detail || 'Fehler ' + response.status);
|
||||||
}
|
}
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const disposition = response.headers.get('Content-Disposition') || '';
|
const disposition = response.headers.get('Content-Disposition') || '';
|
||||||
let filename = 'export.' + format;
|
let filename = 'bericht.' + format;
|
||||||
const match = disposition.match(/filename="?([^"]+)"?/);
|
const match = disposition.match(/filename="?([^"]+)"?/);
|
||||||
if (match) filename = match[1];
|
if (match) filename = match[1];
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -2389,14 +2167,16 @@ a { color: #1a5276; }
|
|||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
UI.showToast('Export heruntergeladen', 'success');
|
closeModal('modal-export');
|
||||||
|
UI.showToast('Bericht heruntergeladen', 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
UI.showToast('Export fehlgeschlagen: ' + err.message, 'error');
|
UI.showToast('Export fehlgeschlagen: ' + err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = origText;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// === Sidebar-Stats ===
|
// === Sidebar-Stats ===
|
||||||
|
|
||||||
async updateSidebarStats() {
|
async updateSidebarStats() {
|
||||||
@@ -3464,11 +3244,7 @@ document.addEventListener('keydown', (e) => {
|
|||||||
NotificationCenter.close();
|
NotificationCenter.close();
|
||||||
return;
|
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');
|
const fcMenu = document.querySelector('.fc-dropdown-menu.open');
|
||||||
if (fcMenu) {
|
if (fcMenu) {
|
||||||
fcMenu.classList.remove('open');
|
fcMenu.classList.remove('open');
|
||||||
@@ -3518,8 +3294,6 @@ document.addEventListener('click', (e) => {
|
|||||||
|
|
||||||
// App starten
|
// App starten
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (!e.target.closest('.export-dropdown')) {
|
|
||||||
App._closeExportDropdown();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
document.addEventListener('DOMContentLoaded', () => App.init());
|
document.addEventListener('DOMContentLoaded', () => App.init());
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren