Phase 3b: Kategorien/Typen aus Backend (/api/sources/meta)

- src/source_meta.py NEU: SOURCE_CATEGORIES + SOURCE_TYPES als
  Single Source of Truth (Liste mit {key, label}). category_label/type_label
  Lookup-Funktionen, get_meta() liefert das gesamte Set.
- src/routers/sources.py: GET /api/sources/meta ergänzt (admin-auth,
  liefert Kategorien + Typen)
- src/static/js/app.js: window.META + loadMeta() + categoryLabel/typeLabel +
  populateSelect Helper. Beim DOMContentLoaded wird Meta geladen, befüllt
  globale CATEGORY_LABELS und TYPE_LABELS.
- src/static/js/sources.js: hardcoded const CATEGORY_LABELS und TYPE_LABELS
  entfernt - werden jetzt aus app.js loadMeta() global gesetzt.
  loadGlobalSources() ruft populateSelect() für die Filter-Dropdowns auf.
- src/static/js/source-health.js: gleiche hardcoded Listen entfernt.
- src/static/dashboard.html: <option>-Listen für globalFilterCategory und
  globalFilterType entfernt (nur noch default Alle). JS befüllt sie dynamisch.

Ergebnis: Bei einer neuen Kategorie nur source_meta.py anpassen,
keine 3-fach-Pflege mehr in HTML+sources.js+source-health.js.
Dieser Commit ist enthalten in:
claude-dev
2026-05-09 03:05:16 +00:00
Ursprung 5a87168416
Commit eda60f9299
5 geänderte Dateien mit 138 neuen und 53 gelöschten Zeilen

Datei anzeigen

@@ -11,6 +11,7 @@ import aiosqlite
from auth import get_current_admin
from database import db_dependency
from audit import log_action, get_client_ip, row_to_dict
from source_meta import get_meta
from config import HEALTH_CHECK_USER_AGENT, HEALTH_CHECK_TIMEOUT_S
from shared.source_rules import (
discover_source,
@@ -28,6 +29,17 @@ router = APIRouter(prefix="/api/sources", tags=["sources"])
SOURCE_UPDATE_COLUMNS = {"name", "url", "domain", "source_type", "category", "status", "notes"}
@router.get("/meta")
async def get_sources_meta(admin: dict = Depends(get_current_admin)):
"""Liefert Kategorien und Typen als Single Source of Truth.
Frontend lädt das beim Init und befüllt damit Filter-Dropdowns + Label-Lookups.
Damit gibt es keine hardcoded Listen mehr im JS/HTML.
"""
return get_meta()
class GlobalSourceCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
url: Optional[str] = None

73
src/source_meta.py Normale Datei
Datei anzeigen

@@ -0,0 +1,73 @@
"""Single Source of Truth für Quellen-Kategorien und -Typen.
Wird vom Backend über GET /api/sources/meta exportiert.
Frontend (sources.js, source-health.js, dashboard.html) lädt diese
beim Init und befüllt damit Filter-Dropdowns und Label-Lookups.
"""
from typing import TypedDict
class CategoryEntry(TypedDict):
key: str
label: str
class TypeEntry(TypedDict):
key: str
label: str
SOURCE_CATEGORIES: list[CategoryEntry] = [
{"key": "nachrichtenagentur", "label": "Nachrichtenagentur"},
{"key": "oeffentlich-rechtlich", "label": "Öffentlich-Rechtlich"},
{"key": "qualitaetszeitung", "label": "Qualitätszeitung"},
{"key": "behoerde", "label": "Behörde"},
{"key": "fachmedien", "label": "Fachmedien"},
{"key": "think-tank", "label": "Think-Tank"},
{"key": "international", "label": "International"},
{"key": "regional", "label": "Regional"},
{"key": "boulevard", "label": "Boulevard"},
{"key": "sonstige", "label": "Sonstige"},
{"key": "cybercrime", "label": "Cybercrime / Hacktivismus"},
{"key": "cybercrime-leaks", "label": "Cybercrime / Leaks"},
{"key": "ukraine-russland-krieg", "label": "Ukraine-Russland-Krieg"},
{"key": "irankonflikt", "label": "Irankonflikt"},
{"key": "osint-international", "label": "OSINT International"},
{"key": "extremismus-deutschland", "label": "Extremismus Deutschland"},
{"key": "russische-staatspropaganda", "label": "Russische Staatspropaganda"},
{"key": "russische-opposition", "label": "Russische Opposition / Exilmedien"},
{"key": "syrien-nahost", "label": "Syrien / Nahost"},
]
SOURCE_TYPES: list[TypeEntry] = [
{"key": "rss_feed", "label": "RSS-Feed"},
{"key": "web_source", "label": "Webquelle"},
{"key": "telegram_channel", "label": "Telegram-Kanal"},
{"key": "podcast_feed", "label": "Podcast-Feed"},
{"key": "excluded", "label": "Ausgeschlossen"},
]
def get_meta() -> dict:
"""Vollständige Meta-Information für Frontend-Konsumenten."""
return {
"categories": SOURCE_CATEGORIES,
"types": SOURCE_TYPES,
}
def category_label(key: str) -> str:
"""Lookup: Kategorie-Key -> Label. Fallback: Key selbst."""
for c in SOURCE_CATEGORIES:
if c["key"] == key:
return c["label"]
return key
def type_label(key: str) -> str:
"""Lookup: Typ-Key -> Label. Fallback: Key selbst."""
for t in SOURCE_TYPES:
if t["key"] == key:
return t["label"]
return key

Datei anzeigen

@@ -303,32 +303,9 @@
<input type="text" class="search-input" id="globalSourceSearch" placeholder="Grundquelle suchen...">
<select class="filter-select" id="globalFilterType" onchange="filterGlobalSources()">
<option value="">Alle Typen</option>
<option value="rss_feed">RSS-Feed</option>
<option value="web_source">Webquelle</option>
<option value="telegram_channel">Telegram-Kanal</option>
<option value="podcast_feed">Podcast-Feed</option>
</select>
<select class="filter-select" id="globalFilterCategory" onchange="filterGlobalSources()">
<option value="">Alle Kategorien</option>
<option value="nachrichtenagentur">Nachrichtenagentur</option>
<option value="oeffentlich-rechtlich">Öffentlich-Rechtlich</option>
<option value="qualitaetszeitung">Qualitätszeitung</option>
<option value="behoerde">Behörde</option>
<option value="fachmedien">Fachmedien</option>
<option value="think-tank">Think-Tank</option>
<option value="international">International</option>
<option value="regional">Regional</option>
<option value="boulevard">Boulevard</option>
<option value="sonstige">Sonstige</option>
<option value="cybercrime">Cybercrime / Hacktivismus</option>
<option value="cybercrime-leaks">Cybercrime / Leaks</option>
<option value="ukraine-russland-krieg">Ukraine-Russland-Krieg</option>
<option value="irankonflikt">Irankonflikt</option>
<option value="osint-international">OSINT International</option>
<option value="extremismus-deutschland">Extremismus Deutschland</option>
<option value="russische-staatspropaganda">Russische Staatspropaganda</option>
<option value="russische-opposition">Russische Opposition / Exilmedien</option>
<option value="syrien-nahost">Syrien / Nahost</option>
</select>
<select class="filter-select" id="globalFilterStatus" onchange="filterGlobalSources()">
<option value="">Alle Status</option>

Datei anzeigen

@@ -833,3 +833,48 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
});
// === Source-Meta (Kategorien + Typen aus dem Backend) ===
window.META = { categories: [], types: [] };
window.CATEGORY_LABELS = {};
window.TYPE_LABELS = {};
async function loadMeta() {
try {
const data = await API.get("/api/sources/meta");
window.META = data;
window.CATEGORY_LABELS = Object.fromEntries((data.categories || []).map(c => [c.key, c.label]));
window.TYPE_LABELS = Object.fromEntries((data.types || []).map(t => [t.key, t.label]));
return data;
} catch (err) {
console.warn("loadMeta:", err);
return null;
}
}
function categoryLabel(key) {
return window.CATEGORY_LABELS[key] || key || "";
}
function typeLabel(key) {
return window.TYPE_LABELS[key] || key || "";
}
function populateSelect(el, items, allLabel) {
if (!el) return;
const current = el.value;
el.innerHTML = '<option value="">' + (allLabel || "Alle") + '</option>';
items.forEach(it => {
const opt = document.createElement("option");
opt.value = it.key;
opt.textContent = it.label;
el.appendChild(opt);
});
if (current && items.some(it => it.key === current)) el.value = current;
}
document.addEventListener("DOMContentLoaded", () => {
// Beim Page-Load Meta einmalig laden (asynchron, blockiert nicht)
if (window.API && (localStorage.getItem("token") || window.location.pathname === "/dashboard")) {
loadMeta();
}
});

Datei anzeigen

@@ -7,36 +7,8 @@ let editingSourceId = null;
let globalSortField = "category";
let globalSortAsc = true;
const CATEGORY_LABELS = {
nachrichtenagentur: "Nachrichtenagentur",
"oeffentlich-rechtlich": "Öffentlich-Rechtlich",
qualitaetszeitung: "Qualitätszeitung",
behoerde: "Behörde",
fachmedien: "Fachmedien",
"think-tank": "Think-Tank",
international: "International",
regional: "Regional",
boulevard: "Boulevard",
sonstige: "Sonstige",
"cybercrime": "Cybercrime / Hacktivismus",
"cybercrime-leaks": "Cybercrime / Leaks",
"ukraine-russland-krieg": "Ukraine-Russland-Krieg",
"irankonflikt": "Irankonflikt",
"osint-international": "OSINT International",
"extremismus-deutschland": "Extremismus Deutschland",
"russische-staatspropaganda": "Russische Staatspropaganda",
"russische-opposition": "Russische Opposition / Exilmedien",
"syrien-nahost": "Syrien / Nahost",
};
const TYPE_LABELS = {
rss_feed: "RSS-Feed",
web_source: "Webquelle",
telegram_channel: "Telegram-Kanal",
podcast_feed: "Podcast-Feed",
excluded: "Ausgeschlossen",
};
// CATEGORY_LABELS jetzt global (aus app.js loadMeta)
// TYPE_LABELS jetzt global (aus app.js loadMeta)
// --- Init ---
document.addEventListener("DOMContentLoaded", () => {
setupSourceSubTabs();
@@ -67,6 +39,12 @@ function setupSourceSubTabs() {
// --- Grundquellen ---
async function loadGlobalSources() {
try {
// Kategorien/Typen-Dropdowns aus META befüllen (idempotent)
if (window.META && window.META.categories && window.META.categories.length) {
populateSelect(document.getElementById("globalFilterCategory"), window.META.categories, "Alle Kategorien");
populateSelect(document.getElementById("globalFilterType"),
(window.META.types || []).filter(t => t.key !== "excluded"), "Alle Typen");
}
globalSourcesCache = await API.get("/api/sources/global");
renderGlobalSources(globalSourcesCache);
} catch (err) {