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

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());