#13/#18 Argumentketten- und Debatten-Views

Backend:
- /api/analyses/debates: liefert kuratierte Cross-Podcast-Gegenueberstellungen mit
  topic, agreement, divergence, insight, beiden Quellabsaetzen und Episodenmetadaten;
  Filter ueber topic, source_podcast, target_podcast.
- /api/analyses/arguments: liefert klassifizierte Argumentketten mit relation,
  confidence, explanation und beiden Quellabsaetzen; Filter ueber relation, podcast,
  episode. Wortwoertlich identische gleicher_punkt-Paare werden ausgeblendet.

Frontend:
- DebatesView: Topic-Chips als Filter, Split-Screen-Quotes je Debatte, Chips fuer
  Uebereinstimmung/Divergenz/Erkenntnis, Klick fuehrt zur Episode mit Audio-Sprung.
- ArgumentsView: farbcodierte Relations-Chips (erweitert blau, widerspricht rot,
  belegt gruen, relativiert grau, gleicher_punkt violett, kein_bezug grau), Konfidenz-
  Anzeige, Filter ueber Podcast, Klick fuehrt zur Episode-Stelle.
- escAttr-Helper fuer onclick-Werte mit Anfuehrungszeichen.
- hide-Cascade aller Views um die beiden neuen erweitert.
- Buttons in showPodcastSelector und init() fuer beide Views.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-28 02:04:49 +02:00
parent 330b740573
commit b73534d1c3
2 changed files with 364 additions and 9 deletions

View File

@ -243,6 +243,120 @@ def get_shifts_analysis(podcast: Optional[str] = None, theme: Optional[str] = No
} }
@app.get("/api/analyses/debates")
def get_debates(topic: Optional[str] = None, source_podcast: Optional[str] = None,
target_podcast: Optional[str] = None, limit: int = 200):
"""Cross-Podcast-Debatten (#18): kuratierte Gegenueberstellungen je Thema."""
db = get_db()
if not _table_exists(db, "debates"):
db.close()
return {"available": False, "debates": []}
sql = (
"SELECT d.id, d.topic, d.agreement, d.divergence, d.insight, d.score, "
"d.source_podcast, d.source_episode, d.source_idx, "
"d.target_podcast, d.target_episode, d.target_idx, "
"p1.text AS source_text, p1.start_time AS source_start, "
"p2.text AS target_text, p2.start_time AS target_start, "
"pc1.name AS source_pname, pc2.name AS target_pname, "
"e1.title AS source_title, e1.guest AS source_guest, "
"e2.title AS target_title, e2.guest AS target_guest "
"FROM debates d "
"JOIN paragraphs p1 ON d.source_podcast = p1.podcast_id AND d.source_episode = p1.episode_id AND d.source_idx = p1.idx "
"JOIN paragraphs p2 ON d.target_podcast = p2.podcast_id AND d.target_episode = p2.episode_id AND d.target_idx = p2.idx "
"JOIN podcasts pc1 ON d.source_podcast = pc1.id "
"JOIN podcasts pc2 ON d.target_podcast = pc2.id "
"JOIN episodes e1 ON d.source_podcast = e1.podcast_id AND d.source_episode = e1.id "
"JOIN episodes e2 ON d.target_podcast = e2.podcast_id AND d.target_episode = e2.id "
"WHERE d.topic IS NOT NULL AND d.topic != 'error' AND d.topic != ''"
)
params = []
if topic:
sql += " AND d.topic LIKE ?"
params.append(f"%{topic}%")
if source_podcast:
sql += " AND d.source_podcast = ?"
params.append(source_podcast)
if target_podcast:
sql += " AND d.target_podcast = ?"
params.append(target_podcast)
sql += " ORDER BY d.score DESC LIMIT ?"
params.append(limit)
rows = db.execute(sql, params).fetchall()
topics = db.execute(
"SELECT topic, COUNT(*) c FROM debates "
"WHERE topic IS NOT NULL AND topic != 'error' AND topic != '' "
"GROUP BY topic ORDER BY c DESC LIMIT 30"
).fetchall()
podcasts = db.execute("SELECT id, name FROM podcasts").fetchall()
db.close()
return {
"available": True,
"podcasts": [dict(p) for p in podcasts],
"topics": [{"topic": t["topic"], "count": t["c"]} for t in topics],
"debates": [dict(r) for r in rows],
}
@app.get("/api/analyses/arguments")
def get_arguments(relation: Optional[str] = None, podcast_id: Optional[str] = None,
episode_id: Optional[str] = None, source_podcast: Optional[str] = None,
target_podcast: Optional[str] = None, limit: int = 200):
"""Argumentketten (#13): klassifizierte Relationen zwischen Absatz-Paaren."""
db = get_db()
if not _table_exists(db, "argument_links"):
db.close()
return {"available": False, "links": []}
sql = (
"SELECT a.id, a.relation, a.confidence, a.explanation, a.score, "
"a.source_podcast, a.source_episode, a.source_idx, "
"a.target_podcast, a.target_episode, a.target_idx, "
"p1.text AS source_text, p1.start_time AS source_start, "
"p2.text AS target_text, p2.start_time AS target_start, "
"e1.title AS source_title, e1.guest AS source_guest, "
"e2.title AS target_title, e2.guest AS target_guest "
"FROM argument_links a "
"JOIN paragraphs p1 ON a.source_podcast = p1.podcast_id AND a.source_episode = p1.episode_id AND a.source_idx = p1.idx "
"JOIN paragraphs p2 ON a.target_podcast = p2.podcast_id AND a.target_episode = p2.episode_id AND a.target_idx = p2.idx "
"JOIN episodes e1 ON a.source_podcast = e1.podcast_id AND a.source_episode = e1.id "
"JOIN episodes e2 ON a.target_podcast = e2.podcast_id AND a.target_episode = e2.id "
"WHERE a.relation IS NOT NULL AND a.relation != 'error' "
# Wortwoertlich identische Paare als Sicherheitsnetz fuer kuenftige Re-Runs nicht zeigen.
"AND NOT (a.relation = 'gleicher_punkt' AND p1.text = p2.text)"
)
params = []
if relation:
sql += " AND a.relation = ?"
params.append(relation)
if podcast_id:
sql += " AND (a.source_podcast = ? OR a.target_podcast = ?)"
params.extend([podcast_id, podcast_id])
if episode_id:
sql += " AND (a.source_episode = ? OR a.target_episode = ?)"
params.extend([episode_id, episode_id])
if source_podcast:
sql += " AND a.source_podcast = ?"
params.append(source_podcast)
if target_podcast:
sql += " AND a.target_podcast = ?"
params.append(target_podcast)
sql += " ORDER BY a.confidence DESC, a.score DESC LIMIT ?"
params.append(limit)
rows = db.execute(sql, params).fetchall()
relations = db.execute(
"SELECT relation, COUNT(*) c FROM argument_links "
"WHERE relation IS NOT NULL AND relation != 'error' "
"GROUP BY relation ORDER BY c DESC"
).fetchall()
podcasts = db.execute("SELECT id, name FROM podcasts").fetchall()
db.close()
return {
"available": True,
"podcasts": [dict(p) for p in podcasts],
"relations": [{"relation": r["relation"], "count": r["c"]} for r in relations],
"links": [dict(r) for r in rows],
}
@app.get("/api/search") @app.get("/api/search")
def search(q: str = Query(..., min_length=2), podcast_id: Optional[str] = None, limit: int = 50): def search(q: str = Query(..., min_length=2), podcast_id: Optional[str] = None, limit: int = 50):
"""Full-text search across all transcripts.""" """Full-text search across all transcripts."""

View File

@ -746,7 +746,7 @@ const AnalysisView = {
async show(episodeId, mode) { async show(episodeId, mode) {
if (!CURRENT_PODCAST) return; if (!CURRENT_PODCAST) return;
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide();
this.episodeId = episodeId; this.episodeId = episodeId;
this.mode = mode; this.mode = mode;
this.visible = true; this.visible = true;
@ -855,7 +855,7 @@ const GapsView = {
minSize: 0, minSize: 0,
async show() { async show() {
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide();
this.visible = true; this.visible = true;
const panel = document.getElementById('panel'); const panel = document.getElementById('panel');
panel.innerHTML = `<h2>Leerstellen</h2><p class="subtitle">Lädt …</p>`; panel.innerHTML = `<h2>Leerstellen</h2><p class="subtitle">Lädt …</p>`;
@ -942,7 +942,7 @@ const ShiftsView = {
expanded: {}, expanded: {},
async show() { async show() {
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide();
this.visible = true; this.visible = true;
const panel = document.getElementById('panel'); const panel = document.getElementById('panel');
panel.innerHTML = `<h2>Narrative Shifts</h2><p class="subtitle">Lädt …</p>`; panel.innerHTML = `<h2>Narrative Shifts</h2><p class="subtitle">Lädt …</p>`;
@ -1060,6 +1060,235 @@ const ShiftsView = {
hide() { this.visible = false; } hide() { this.visible = false; }
}; };
// ── Debates View (#18 Cross-Podcast-Debatte) ──
const DebatesView = {
visible: false,
data: null,
topicFilter: null,
expanded: {},
async show() {
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); ArgumentsView.hide();
this.visible = true;
const panel = document.getElementById('panel');
panel.innerHTML = `<h2>Debatten</h2><p class="subtitle">Lädt …</p>`;
try {
const r = await fetch(`${API_BASE}/api/analyses/debates?limit=200`);
const data = await r.json();
if (!data.available) {
panel.innerHTML = `<h2>Debatten</h2><p class="subtitle">Keine Debatten-Analyse vorhanden.</p>`;
return;
}
this.data = data;
} catch (e) {
panel.innerHTML = `<h2>Debatten</h2><p style="color:var(--accent-warm)">Fehler: ${escHtml(e.message)}</p>`;
return;
}
this.render();
},
render() {
if (!this.visible || !this.data) return;
const panel = document.getElementById('panel');
const d = this.data;
let html = `<h2>Debatten</h2>`;
html += `<p class="subtitle">${d.debates.length} kuratierte Gegenüberstellungen über ${d.podcasts.length} Podcasts</p>`;
const chip = (label, count, active, onclick) =>
`<span class="theme-tag" style="cursor:pointer;${active ? 'background:var(--accent)33;border-color:var(--accent);color:var(--text)' : ''}" onclick="${onclick}">${label}${count !== null ? ` (${count})` : ''}</span>`;
// Topic-Filter (Top 12 + alle)
html += `<div style="margin-top:12px;display:flex;flex-wrap:wrap;gap:6px">`;
html += chip('alle Themen', d.debates.length, !this.topicFilter, `DebatesView.setTopic(null)`);
(d.topics || []).slice(0, 12).forEach(t => {
html += chip(escHtml(t.topic), t.count, this.topicFilter === t.topic, `DebatesView.setTopic('${escAttr(t.topic)}')`);
});
html += `</div>`;
let filtered = d.debates;
if (this.topicFilter) filtered = filtered.filter(x => x.topic === this.topicFilter);
if (filtered.length === 0) {
html += `<p class="subtitle" style="margin-top:16px">Keine Debatten zum Filter.</p>`;
panel.innerHTML = html;
return;
}
filtered.forEach(deb => {
const key = `${deb.id}`;
const isOpen = this.expanded[key];
const srcEpClick = deb.source_podcast === CURRENT_PODCAST
? `onclick="DebatesView.jumpTo('${deb.source_episode}', ${deb.source_start || 0})"` : '';
const tgtEpClick = deb.target_podcast === CURRENT_PODCAST
? `onclick="DebatesView.jumpTo('${deb.target_episode}', ${deb.target_start || 0})"` : '';
html += `<div class="transcript-para" style="cursor:default;margin-top:10px">`;
html += `<div style="display:flex;justify-content:space-between;gap:8px;margin-bottom:6px;align-items:flex-start">`;
html += `<strong>${escHtml(deb.topic)}</strong>`;
html += `<span class="ts" style="white-space:nowrap">Score ${(deb.score || 0).toFixed(2)}</span>`;
html += `</div>`;
// Split-Screen Quotes
html += `<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:8px 0">`;
html += `<div style="padding:8px;border-left:3px solid var(--accent);${srcEpClick ? 'cursor:pointer' : ''}" ${srcEpClick}>`;
html += `<div class="ts" style="margin-bottom:4px">${escHtml(deb.source_pname)} · ${escHtml(deb.source_episode)}${deb.source_guest ? ' (' + escHtml(deb.source_guest) + ')' : ''}</div>`;
html += `<div style="font-size:13px">${escHtml((deb.source_text || '').slice(0, 240))}${(deb.source_text || '').length > 240 ? '…' : ''}</div>`;
html += `</div>`;
html += `<div style="padding:8px;border-left:3px solid var(--accent-warm);${tgtEpClick ? 'cursor:pointer' : ''}" ${tgtEpClick}>`;
html += `<div class="ts" style="margin-bottom:4px">${escHtml(deb.target_pname)} · ${escHtml(deb.target_episode)}${deb.target_guest ? ' (' + escHtml(deb.target_guest) + ')' : ''}</div>`;
html += `<div style="font-size:13px">${escHtml((deb.target_text || '').slice(0, 240))}${(deb.target_text || '').length > 240 ? '…' : ''}</div>`;
html += `</div>`;
html += `</div>`;
// Synthese
if (deb.agreement) {
html += `<div style="margin-top:6px"><span class="theme-tag" style="font-size:10px;color:#86efac;border-color:#86efac44">Übereinstimmung</span> <span style="font-size:13px">${escHtml(deb.agreement)}</span></div>`;
}
if (deb.divergence && deb.divergence.toLowerCase() !== 'keine wesentliche divergenz') {
html += `<div style="margin-top:4px"><span class="theme-tag" style="font-size:10px;color:#fda4af;border-color:#fda4af44">Divergenz</span> <span style="font-size:13px">${escHtml(deb.divergence)}</span></div>`;
}
if (deb.insight) {
html += `<div style="margin-top:4px"><span class="theme-tag" style="font-size:10px;color:#fcd34d;border-color:#fcd34d44">Erkenntnis</span> <span style="font-size:13px">${escHtml(deb.insight)}</span></div>`;
}
html += `</div>`;
});
panel.innerHTML = html;
},
setTopic(t) { this.topicFilter = t; this.render(); },
jumpTo(episodeId, startTime) {
if (!CURRENT_PODCAST) return;
const ep = DATA && DATA.episodes && DATA.episodes.find(e => e.id === episodeId);
if (ep) {
showEpisode(ep);
if (startTime) setTimeout(() => playFrom(startTime, ep), 200);
}
},
hide() { this.visible = false; }
};
// ── Arguments View (#13 Argumentketten-Tracker) ──
const ArgumentsView = {
visible: false,
data: null,
relationFilter: null,
podcastFilter: null,
RELATION_COLORS: {
'erweitert': '#60a5fa', // blau
'widerspricht': '#f87171', // rot
'belegt': '#86efac', // gruen
'relativiert': '#9ca3af', // grau
'gleicher_punkt': '#a78bfa', // violett
'kein_bezug': '#525252'
},
async show() {
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide();
this.visible = true;
const panel = document.getElementById('panel');
panel.innerHTML = `<h2>Argumentketten</h2><p class="subtitle">Lädt …</p>`;
try {
const r = await fetch(`${API_BASE}/api/analyses/arguments?limit=200`);
const data = await r.json();
if (!data.available) {
panel.innerHTML = `<h2>Argumentketten</h2><p class="subtitle">Keine Argument-Analyse vorhanden.</p>`;
return;
}
this.data = data;
} catch (e) {
panel.innerHTML = `<h2>Argumentketten</h2><p style="color:var(--accent-warm)">Fehler: ${escHtml(e.message)}</p>`;
return;
}
this.render();
},
render() {
if (!this.visible || !this.data) return;
const panel = document.getElementById('panel');
const d = this.data;
let html = `<h2>Argumentketten</h2>`;
html += `<p class="subtitle">Wie sich Aussagen logisch zueinander verhalten · ${d.links.length} Verknüpfungen sichtbar</p>`;
const chip = (label, count, active, onclick, color) => {
const ac = active ? `background:${color || 'var(--accent)'}33;border-color:${color || 'var(--accent)'};color:var(--text)` : (color ? `border-color:${color}66;color:${color}` : '');
return `<span class="theme-tag" style="cursor:pointer;${ac}" onclick="${onclick}">${label}${count !== null ? ` (${count})` : ''}</span>`;
};
// Relation-Filter
html += `<div style="margin-top:12px;display:flex;flex-wrap:wrap;gap:6px">`;
html += chip('alle Relationen', d.links.length, !this.relationFilter, `ArgumentsView.setRelation(null)`);
(d.relations || []).forEach(r => {
html += chip(r.relation, r.count, this.relationFilter === r.relation, `ArgumentsView.setRelation('${r.relation}')`, this.RELATION_COLORS[r.relation]);
});
html += `</div>`;
// Podcast-Filter
if ((d.podcasts || []).length > 1) {
html += `<div style="margin-top:6px;display:flex;flex-wrap:wrap;gap:6px">`;
html += chip('alle Podcasts', null, !this.podcastFilter, `ArgumentsView.setPodcast(null)`);
d.podcasts.forEach(p => {
html += chip(escHtml(p.name), null, this.podcastFilter === p.id, `ArgumentsView.setPodcast('${p.id}')`);
});
html += `</div>`;
}
let filtered = d.links;
if (this.relationFilter) filtered = filtered.filter(x => x.relation === this.relationFilter);
if (this.podcastFilter) filtered = filtered.filter(x => x.source_podcast === this.podcastFilter || x.target_podcast === this.podcastFilter);
if (filtered.length === 0) {
html += `<p class="subtitle" style="margin-top:16px">Keine Verknüpfungen zum Filter.</p>`;
panel.innerHTML = html;
return;
}
filtered.slice(0, 80).forEach(a => {
const c = this.RELATION_COLORS[a.relation] || '#888';
const arrow = a.relation === 'widerspricht' ? '⇄' : (a.relation === 'gleicher_punkt' ? '≡' : '→');
const conf = (a.confidence || 0) * 100;
const srcEpClick = a.source_podcast === CURRENT_PODCAST
? `onclick="ArgumentsView.jumpTo('${a.source_episode}', ${a.source_start || 0})"` : '';
const tgtEpClick = a.target_podcast === CURRENT_PODCAST
? `onclick="ArgumentsView.jumpTo('${a.target_episode}', ${a.target_start || 0})"` : '';
html += `<div class="transcript-para" style="cursor:default;margin-top:8px;border-left:3px solid ${c}">`;
html += `<div style="display:flex;justify-content:space-between;gap:8px;align-items:center;margin-bottom:6px">`;
html += `<span style="color:${c};font-weight:600;font-size:13px">${escHtml(a.relation)} ${arrow}</span>`;
html += `<span class="ts">Konfidenz ${conf.toFixed(0)}%</span>`;
html += `</div>`;
html += `<div class="${srcEpClick ? 'transcript-para' : ''}" style="margin:4px 0;padding:4px 0;${srcEpClick ? 'cursor:pointer' : ''}" ${srcEpClick}>`;
html += `<div class="ts">A: ${escHtml(a.source_episode)}${a.source_guest ? ' · ' + escHtml(a.source_guest) : ''}</div>`;
html += `<div style="font-size:13px">${escHtml((a.source_text || '').slice(0, 220))}${(a.source_text || '').length > 220 ? '…' : ''}</div>`;
html += `</div>`;
html += `<div style="margin:4px 0;padding:4px 0;${tgtEpClick ? 'cursor:pointer' : ''}" ${tgtEpClick}>`;
html += `<div class="ts">B: ${escHtml(a.target_episode)}${a.target_guest ? ' · ' + escHtml(a.target_guest) : ''}</div>`;
html += `<div style="font-size:13px">${escHtml((a.target_text || '').slice(0, 220))}${(a.target_text || '').length > 220 ? '…' : ''}</div>`;
html += `</div>`;
if (a.explanation && !a.explanation.startsWith('rerun-failed')) {
html += `<div class="subtitle" style="margin-top:4px;font-style:italic">${escHtml(a.explanation)}</div>`;
}
html += `</div>`;
});
if (filtered.length > 80) {
html += `<p class="subtitle" style="margin-top:8px">… ${filtered.length - 80} weitere durch Filter eingrenzen.</p>`;
}
panel.innerHTML = html;
},
setRelation(r) { this.relationFilter = r; this.render(); },
setPodcast(p) { this.podcastFilter = p; this.render(); },
jumpTo(episodeId, startTime) {
if (!CURRENT_PODCAST) return;
const ep = DATA && DATA.episodes && DATA.episodes.find(e => e.id === episodeId);
if (ep) {
showEpisode(ep);
if (startTime) setTimeout(() => playFrom(startTime, ep), 200);
}
},
hide() { this.visible = false; }
};
// ── Search ── // ── Search ──
const Search = { const Search = {
init() { init() {
@ -1156,7 +1385,7 @@ const Search = {
showResults(results, query) { showResults(results, query) {
const panel = document.getElementById('panel'); const panel = document.getElementById('panel');
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide();
if (results.length === 0) { if (results.length === 0) {
panel.innerHTML = `<p class="subtitle">Keine Treffer für "${escHtml(query)}"</p>`; panel.innerHTML = `<p class="subtitle">Keine Treffer für "${escHtml(query)}"</p>`;
@ -1184,7 +1413,7 @@ const Search = {
}, },
showSemanticResults(results, query) { showSemanticResults(results, query) {
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide();
const panel = document.getElementById('panel'); const panel = document.getElementById('panel');
let html = `<h2>${results.length} semantische Treffer für "${escHtml(query)}" <span class="semantic-badge">KI</span></h2>`; let html = `<h2>${results.length} semantische Treffer für "${escHtml(query)}" <span class="semantic-badge">KI</span></h2>`;
results.forEach(r => { results.forEach(r => {
@ -1197,7 +1426,7 @@ const Search = {
}, },
showApiResults(results, query) { showApiResults(results, query) {
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide();
const panel = document.getElementById('panel'); const panel = document.getElementById('panel');
let html = `<h2>${results.length} Treffer für "${escHtml(query)}"</h2>`; let html = `<h2>${results.length} Treffer für "${escHtml(query)}"</h2>`;
results.forEach(r => { results.forEach(r => {
@ -1339,6 +1568,8 @@ function showPodcastSelector(podcasts) {
selectorHtml += '<button class="compare-btn" onclick="startCompare()">Podcasts vergleichen</button>'; selectorHtml += '<button class="compare-btn" onclick="startCompare()">Podcasts vergleichen</button>';
selectorHtml += '<button class="compare-btn" onclick="GapsView.show()">Leerstellen anzeigen</button>'; selectorHtml += '<button class="compare-btn" onclick="GapsView.show()">Leerstellen anzeigen</button>';
selectorHtml += '<button class="compare-btn" onclick="ShiftsView.show()">Narrative Shifts</button>'; selectorHtml += '<button class="compare-btn" onclick="ShiftsView.show()">Narrative Shifts</button>';
selectorHtml += '<button class="compare-btn" onclick="DebatesView.show()">Debatten</button>';
selectorHtml += '<button class="compare-btn" onclick="ArgumentsView.show()">Argumentketten</button>';
selectorHtml += '</div>'; selectorHtml += '</div>';
} }
@ -1486,8 +1717,13 @@ function init() {
? `<span style="cursor:pointer" onclick="showPodcastList()" title="Zurück zur Übersicht"></span> <span>${escHtml(name)}</span>` ? `<span style="cursor:pointer" onclick="showPodcastList()" title="Zurück zur Übersicht"></span> <span>${escHtml(name)}</span>`
: `<span>${escHtml(name)}</span>`; : `<span>${escHtml(name)}</span>`;
const gapsBtn = ALL_PODCASTS.length > 1 const gapsBtn = ALL_PODCASTS.length > 1
? `<p style="margin-top:12px"><button class="transcript-toggle" onclick="GapsView.show()">Leerstellen anzeigen</button> <button class="transcript-toggle" onclick="ShiftsView.show()">Narrative Shifts</button></p>` ? `<p style="margin-top:12px;display:flex;flex-wrap:wrap;gap:6px">
: ''; <button class="transcript-toggle" onclick="GapsView.show()">Leerstellen</button>
<button class="transcript-toggle" onclick="ShiftsView.show()">Narrative Shifts</button>
<button class="transcript-toggle" onclick="DebatesView.show()">Debatten</button>
<button class="transcript-toggle" onclick="ArgumentsView.show()">Argumentketten</button>
</p>`
: `<p style="margin-top:12px"><button class="transcript-toggle" onclick="ArgumentsView.show()">Argumentketten</button></p>`;
// Panel kann von showPodcastSelector ueberschrieben worden sein — welcome-panel ggf. neu anlegen // Panel kann von showPodcastSelector ueberschrieben worden sein — welcome-panel ggf. neu anlegen
let welcome = document.getElementById('welcome-panel'); let welcome = document.getElementById('welcome-panel');
if (!welcome) { if (!welcome) {
@ -1720,7 +1956,7 @@ function drag(sim) {
// ── Panel: Theme ── // ── Panel: Theme ──
function showTheme(theme) { function showTheme(theme) {
TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide();
const panel = document.getElementById('panel'); const panel = document.getElementById('panel');
const td = DATA.themes.find(t => t.id === theme.id); const td = DATA.themes.find(t => t.id === theme.id);
const quotes = DATA.quotes.filter(q => q.themes.includes(theme.id)); const quotes = DATA.quotes.filter(q => q.themes.includes(theme.id));
@ -1838,6 +2074,11 @@ function escHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
} }
function escAttr(s) {
// Fuer Werte in inline onclick='...': Anfuehrungszeichen, Slashes, Backslashes neutralisieren.
return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '&quot;').replace(/</g, '&lt;');
}
// ============================================================ // ============================================================
// #2: Obsidian-style Backlinks // #2: Obsidian-style Backlinks
// ============================================================ // ============================================================