Articles: Paginierung, Timeline-Buckets, Sources-Summary-Endpunkt
Backend:
- GET /{id}/articles paginiert jetzt per limit/offset (Default 500,
Max 1000) und unterstuetzt optionalen search-Parameter (LIKE ueber
headline/source/content). Response-Shape: {total, articles}.
- Neuer Endpunkt GET /{id}/articles/sources-summary liefert pro Quelle
{source, article_count, languages} sowie language_counts gesamt —
serverseitige Aggregation, unabhaengig von Artikel-Paginierung.
- Neuer Endpunkt GET /{id}/articles/timeline-buckets?granularity=hour|day|week|month
aggregiert Artikel + Snapshot-Counts pro Zeitbucket (fuer spaetere
Timeline-Zaehler ueber die volle Historie).
- database.py: Index idx_articles_incident_collected auf
(incident_id, collected_at DESC) fuer schnelleres ORDER BY + Pagination.
Frontend:
- api.js: getArticles({limit, offset, search}),
getArticlesSourcesSummary(), getArticlesTimelineBuckets().
- app.js: loadIncidentDetail laedt erste Seite (500 Artikel), startet
_loadSourcesSummary parallel und zieht restliche Artikel
batchweise (500er Bloecke) im Hintergrund nach, bis _currentArticlesTotal
erreicht ist. rerenderTimeline nach jedem Batch.
- components.js: renderSourceOverviewFromSummary(data) rendert aus
Aggregat-Daten (ersetzt clientseitige Zaehlung ueber geladene Artikel).
Hintergrund: /articles lieferte bei der Iran-Lage 22 MB (17.286 Artikel
mit SELECT *). Die Erstantwort sinkt auf ~650 KB (500 Artikel), weitere
werden progressiv im Hintergrund nachgeladen. Quellenuebersicht zeigt
dank Aggregat-Endpunkt sofort alle Quellen + Sprachen komplett.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -787,15 +787,25 @@ const App = {
|
||||
|
||||
async loadIncidentDetail(id) {
|
||||
try {
|
||||
const [incident, articles, factchecks, snapshots, locationsResponse] = await Promise.all([
|
||||
const [incident, articlesResponse, factchecks, snapshots, locationsResponse] = await Promise.all([
|
||||
API.getIncident(id),
|
||||
API.getArticles(id),
|
||||
API.getArticles(id, { limit: 500, offset: 0 }),
|
||||
API.getFactChecks(id),
|
||||
API.getSnapshots(id),
|
||||
API.getLocations(id).catch(() => []),
|
||||
]);
|
||||
|
||||
// Locations-API gibt jetzt {category_labels, locations} oder Array (Rückwärtskompatibel)
|
||||
// Articles: neue Shape {total, articles} oder alter nackter Array (Rueckwaertskompatibel)
|
||||
let articles, articlesTotal;
|
||||
if (Array.isArray(articlesResponse)) {
|
||||
articles = articlesResponse;
|
||||
articlesTotal = articlesResponse.length;
|
||||
} else {
|
||||
articles = articlesResponse.articles || [];
|
||||
articlesTotal = articlesResponse.total || articles.length;
|
||||
}
|
||||
|
||||
// Locations-API gibt jetzt {category_labels, locations} oder Array (Rueckwaertskompatibel)
|
||||
let locations, categoryLabels;
|
||||
if (Array.isArray(locationsResponse)) {
|
||||
locations = locationsResponse;
|
||||
@@ -808,13 +818,63 @@ const App = {
|
||||
categoryLabels = null;
|
||||
}
|
||||
|
||||
this._currentArticlesTotal = articlesTotal;
|
||||
this._currentArticlesLoaded = articles.length;
|
||||
this._currentIncidentIdForLoad = id;
|
||||
|
||||
this.renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels);
|
||||
|
||||
// Quellenuebersicht aus Aggregat-Endpunkt (alle Quellen, nicht nur erste Seite)
|
||||
this._loadSourcesSummary(id).catch(err => console.warn('sources-summary:', err));
|
||||
|
||||
// Wenn mehr Artikel existieren als initial geladen: progressiver Hintergrund-Load
|
||||
if (articlesTotal > articles.length) {
|
||||
this._loadRemainingArticlesInBackground(id).catch(err => console.warn('bg-articles:', err));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('loadIncidentDetail Fehler:', err);
|
||||
UI.showToast('Fehler beim Laden: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
/** Quellenuebersicht aus Aggregat-Endpunkt nachladen (ersetzt Client-Zaehlung). */
|
||||
async _loadSourcesSummary(incidentId) {
|
||||
const data = await API.getArticlesSourcesSummary(incidentId);
|
||||
if (this.currentIncidentId !== incidentId) return; // User hat gewechselt
|
||||
this._currentSourcesSummary = data;
|
||||
const soEl = document.getElementById('source-overview-content');
|
||||
const statsEl = document.getElementById('source-overview-header-stats');
|
||||
if (soEl && typeof UI.renderSourceOverviewFromSummary === 'function') {
|
||||
soEl.innerHTML = UI.renderSourceOverviewFromSummary(data);
|
||||
}
|
||||
if (statsEl && data) {
|
||||
statsEl.textContent = `${data.total} Artikel aus ${data.sources.length} Quellen`;
|
||||
}
|
||||
},
|
||||
|
||||
/** Restliche Artikel seitenweise im Hintergrund nachladen und in _currentArticles mergen. */
|
||||
async _loadRemainingArticlesInBackground(incidentId) {
|
||||
const BATCH = 500;
|
||||
while (this.currentIncidentId === incidentId
|
||||
&& this._currentArticlesLoaded < this._currentArticlesTotal) {
|
||||
let resp;
|
||||
try {
|
||||
resp = await API.getArticles(incidentId, { limit: BATCH, offset: this._currentArticlesLoaded });
|
||||
} catch (err) {
|
||||
console.warn('Hintergrund-Load Artikel fehlgeschlagen:', err);
|
||||
return;
|
||||
}
|
||||
if (this.currentIncidentId !== incidentId) return;
|
||||
const batch = (resp && resp.articles) ? resp.articles : (Array.isArray(resp) ? resp : []);
|
||||
if (!batch.length) break;
|
||||
this._currentArticles = (this._currentArticles || []).concat(batch);
|
||||
this._currentArticlesLoaded += batch.length;
|
||||
this.rerenderTimeline();
|
||||
// Kleiner Yield, damit das UI reaktiv bleibt
|
||||
await new Promise(r => setTimeout(r, 30));
|
||||
}
|
||||
},
|
||||
|
||||
renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels) {
|
||||
// Header Strip
|
||||
{ const _e = document.getElementById('incident-title'); if (_e) _e.textContent = incident.title; }
|
||||
@@ -940,17 +1000,16 @@ const App = {
|
||||
factcheckList.innerHTML = '<div style="padding:12px;font-size:13px;color:var(--text-tertiary);">Noch keine Fakten geprüft</div>';
|
||||
}
|
||||
|
||||
// Quellenübersicht
|
||||
// Quellenuebersicht wird aus dem Aggregat-Endpunkt (_loadSourcesSummary) gefuellt,
|
||||
// damit sie immer alle Artikel der Lage zeigt — unabhaengig von Paginierung.
|
||||
const sourceOverview = document.getElementById('source-overview-content');
|
||||
if (sourceOverview) {
|
||||
sourceOverview.innerHTML = UI.renderSourceOverview(articles);
|
||||
// Stats im Header aktualisieren (sichtbar im zugeklappten Zustand)
|
||||
const _soStats = document.getElementById("source-overview-header-stats");
|
||||
if (_soStats) {
|
||||
const _soSources = new Set(articles.map(a => a.source).filter(Boolean));
|
||||
_soStats.textContent = articles.length + " Artikel aus " + _soSources.size + " Quellen";
|
||||
}
|
||||
// Im Tab-Modus wird die Kachel vom Seiten-Layout bestimmt — kein Resize noetig
|
||||
sourceOverview.innerHTML = '<div style="padding:12px;font-size:13px;color:var(--text-tertiary);">Quellenübersicht wird geladen…</div>';
|
||||
}
|
||||
const _soStats = document.getElementById("source-overview-header-stats");
|
||||
if (_soStats) {
|
||||
const total = (this._currentArticlesTotal != null) ? this._currentArticlesTotal : articles.length;
|
||||
_soStats.textContent = total + " Artikel";
|
||||
}
|
||||
|
||||
// Timeline - Artikel + Snapshots zwischenspeichern und rendern
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren