diff --git a/src/config.py b/src/config.py
index baa3ca7..ba379d7 100644
--- a/src/config.py
+++ b/src/config.py
@@ -30,3 +30,8 @@ SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "true").lower() == "true"
# Magic Link Base URL (fuer OSINT-Monitor Einladungen)
MAGIC_LINK_BASE_URL = os.environ.get("MAGIC_LINK_BASE_URL", "https://osint.intelsight.de")
MAGIC_LINK_EXPIRE_MINUTES = 10
+
+# Source Discovery (geteilte Config mit OSINT-Monitor)
+CLAUDE_PATH = os.environ.get("CLAUDE_PATH", "/home/claude-dev/.claude/local/claude")
+CLAUDE_TIMEOUT = 300
+MAX_FEEDS_PER_DOMAIN = 3
diff --git a/src/routers/sources.py b/src/routers/sources.py
index 047dc2b..3a5a7bd 100644
--- a/src/routers/sources.py
+++ b/src/routers/sources.py
@@ -1,167 +1,314 @@
-"""Grundquellen-Verwaltung und Kundenquellen-Übersicht."""
-from fastapi import APIRouter, Depends, HTTPException, status
-from pydantic import BaseModel, Field
-from typing import Optional
-from auth import get_current_admin
-from database import db_dependency
-import aiosqlite
-
-router = APIRouter(prefix="/api/sources", tags=["sources"])
-
-
-class GlobalSourceCreate(BaseModel):
- name: str = Field(min_length=1, max_length=200)
- url: Optional[str] = None
- domain: Optional[str] = None
- source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded)$")
- category: str = Field(default="sonstige")
- status: str = Field(default="active", pattern="^(active|inactive)$")
- notes: Optional[str] = None
-
-
-class GlobalSourceUpdate(BaseModel):
- name: Optional[str] = Field(default=None, max_length=200)
- url: Optional[str] = None
- domain: Optional[str] = None
- source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded)$")
- category: Optional[str] = None
- status: Optional[str] = Field(default=None, pattern="^(active|inactive)$")
- notes: Optional[str] = None
-
-
-@router.get("/global")
-async def list_global_sources(
- admin: dict = Depends(get_current_admin),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Alle Grundquellen auflisten (tenant_id IS NULL)."""
- cursor = await db.execute(
- "SELECT * FROM sources WHERE tenant_id IS NULL ORDER BY category, source_type, name"
- )
- return [dict(row) for row in await cursor.fetchall()]
-
-
-@router.post("/global", status_code=201)
-async def create_global_source(
- data: GlobalSourceCreate,
- admin: dict = Depends(get_current_admin),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Neue Grundquelle anlegen."""
- if data.url:
- cursor = await db.execute(
- "SELECT id, name FROM sources WHERE url = ? AND tenant_id IS NULL",
- (data.url,),
- )
- existing = await cursor.fetchone()
- if existing:
- raise HTTPException(
- status_code=409,
- detail=f"URL bereits vorhanden: {existing['name']}",
- )
-
- cursor = await db.execute(
- """INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id)
- VALUES (?, ?, ?, ?, ?, ?, ?, 'system', NULL)""",
- (data.name, data.url, data.domain, data.source_type, data.category, data.status, data.notes),
- )
- await db.commit()
-
- cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (cursor.lastrowid,))
- return dict(await cursor.fetchone())
-
-
-@router.put("/global/{source_id}")
-async def update_global_source(
- source_id: int,
- data: GlobalSourceUpdate,
- admin: dict = Depends(get_current_admin),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Grundquelle bearbeiten."""
- cursor = await db.execute(
- "SELECT * FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,)
- )
- row = await cursor.fetchone()
- if not row:
- raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden")
-
- updates = {}
- for field, value in data.model_dump(exclude_none=True).items():
- updates[field] = value
-
- if not updates:
- return dict(row)
-
- set_clause = ", ".join(f"{k} = ?" for k in updates)
- values = list(updates.values()) + [source_id]
- await db.execute(f"UPDATE sources SET {set_clause} WHERE id = ?", values)
- await db.commit()
-
- cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
- return dict(await cursor.fetchone())
-
-
-@router.delete("/global/{source_id}", status_code=204)
-async def delete_global_source(
- source_id: int,
- admin: dict = Depends(get_current_admin),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Grundquelle loeschen."""
- cursor = await db.execute(
- "SELECT id FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,)
- )
- if not await cursor.fetchone():
- raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden")
-
- await db.execute("DELETE FROM sources WHERE id = ?", (source_id,))
- await db.commit()
-
-
-@router.get("/tenant")
-async def list_tenant_sources(
- admin: dict = Depends(get_current_admin),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Alle tenant-spezifischen Quellen mit Org-Name auflisten."""
- cursor = await db.execute("""
- SELECT s.*, o.name as org_name
- FROM sources s
- LEFT JOIN organizations o ON o.id = s.tenant_id
- WHERE s.tenant_id IS NOT NULL
- ORDER BY o.name, s.category, s.name
- """)
- return [dict(row) for row in await cursor.fetchall()]
-
-
-@router.post("/tenant/{source_id}/promote")
-async def promote_to_global(
- source_id: int,
- admin: dict = Depends(get_current_admin),
- db: aiosqlite.Connection = Depends(db_dependency),
-):
- """Tenant-Quelle zur Grundquelle befoerdern."""
- cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
- row = await cursor.fetchone()
- if not row:
- raise HTTPException(status_code=404, detail="Quelle nicht gefunden")
- if row["tenant_id"] is None:
- raise HTTPException(status_code=400, detail="Bereits eine Grundquelle")
-
- if row["url"]:
- cursor = await db.execute(
- "SELECT id FROM sources WHERE url = ? AND tenant_id IS NULL",
- (row["url"],),
- )
- if await cursor.fetchone():
- raise HTTPException(status_code=409, detail="URL bereits als Grundquelle vorhanden")
-
- await db.execute(
- "UPDATE sources SET tenant_id = NULL, added_by = 'system' WHERE id = ?",
- (source_id,),
- )
- await db.commit()
-
- cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
- return dict(await cursor.fetchone())
+"""Grundquellen-Verwaltung und Kundenquellen-Übersicht."""
+import sys
+import logging
+
+# Monitor-Source-Rules verfügbar machen
+sys.path.insert(0, "/home/claude-dev/AegisSight-Monitor/src")
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from pydantic import BaseModel, Field
+from typing import Optional
+from auth import get_current_admin
+from database import db_dependency
+import aiosqlite
+
+from source_rules import (
+ discover_source,
+ discover_all_feeds,
+ evaluate_feeds_with_claude,
+ _extract_domain,
+ _detect_category,
+ domain_to_display_name,
+)
+
+logger = logging.getLogger("verwaltung.sources")
+
+router = APIRouter(prefix="/api/sources", tags=["sources"])
+
+
+class GlobalSourceCreate(BaseModel):
+ name: str = Field(min_length=1, max_length=200)
+ url: Optional[str] = None
+ domain: Optional[str] = None
+ source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded)$")
+ category: str = Field(default="sonstige")
+ status: str = Field(default="active", pattern="^(active|inactive)$")
+ notes: Optional[str] = None
+
+
+class GlobalSourceUpdate(BaseModel):
+ name: Optional[str] = Field(default=None, max_length=200)
+ url: Optional[str] = None
+ domain: Optional[str] = None
+ source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded)$")
+ category: Optional[str] = None
+ status: Optional[str] = Field(default=None, pattern="^(active|inactive)$")
+ notes: Optional[str] = None
+
+
+@router.get("/global")
+async def list_global_sources(
+ admin: dict = Depends(get_current_admin),
+ db: aiosqlite.Connection = Depends(db_dependency),
+):
+ """Alle Grundquellen auflisten (tenant_id IS NULL)."""
+ cursor = await db.execute(
+ "SELECT * FROM sources WHERE tenant_id IS NULL ORDER BY category, source_type, name"
+ )
+ return [dict(row) for row in await cursor.fetchall()]
+
+
+@router.post("/global", status_code=201)
+async def create_global_source(
+ data: GlobalSourceCreate,
+ admin: dict = Depends(get_current_admin),
+ db: aiosqlite.Connection = Depends(db_dependency),
+):
+ """Neue Grundquelle anlegen."""
+ if data.url:
+ cursor = await db.execute(
+ "SELECT id, name FROM sources WHERE url = ? AND tenant_id IS NULL",
+ (data.url,),
+ )
+ existing = await cursor.fetchone()
+ if existing:
+ raise HTTPException(
+ status_code=409,
+ detail=f"URL bereits vorhanden: {existing['name']}",
+ )
+
+ cursor = await db.execute(
+ """INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'system', NULL)""",
+ (data.name, data.url, data.domain, data.source_type, data.category, data.status, data.notes),
+ )
+ await db.commit()
+
+ cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (cursor.lastrowid,))
+ return dict(await cursor.fetchone())
+
+
+@router.put("/global/{source_id}")
+async def update_global_source(
+ source_id: int,
+ data: GlobalSourceUpdate,
+ admin: dict = Depends(get_current_admin),
+ db: aiosqlite.Connection = Depends(db_dependency),
+):
+ """Grundquelle bearbeiten."""
+ cursor = await db.execute(
+ "SELECT * FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,)
+ )
+ row = await cursor.fetchone()
+ if not row:
+ raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden")
+
+ updates = {}
+ for field, value in data.model_dump(exclude_none=True).items():
+ updates[field] = value
+
+ if not updates:
+ return dict(row)
+
+ set_clause = ", ".join(f"{k} = ?" for k in updates)
+ values = list(updates.values()) + [source_id]
+ await db.execute(f"UPDATE sources SET {set_clause} WHERE id = ?", values)
+ await db.commit()
+
+ cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
+ return dict(await cursor.fetchone())
+
+
+@router.delete("/global/{source_id}", status_code=204)
+async def delete_global_source(
+ source_id: int,
+ admin: dict = Depends(get_current_admin),
+ db: aiosqlite.Connection = Depends(db_dependency),
+):
+ """Grundquelle loeschen."""
+ cursor = await db.execute(
+ "SELECT id FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,)
+ )
+ if not await cursor.fetchone():
+ raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden")
+
+ await db.execute("DELETE FROM sources WHERE id = ?", (source_id,))
+ await db.commit()
+
+
+@router.get("/tenant")
+async def list_tenant_sources(
+ admin: dict = Depends(get_current_admin),
+ db: aiosqlite.Connection = Depends(db_dependency),
+):
+ """Alle tenant-spezifischen Quellen mit Org-Name auflisten."""
+ cursor = await db.execute("""
+ SELECT s.*, o.name as org_name
+ FROM sources s
+ LEFT JOIN organizations o ON o.id = s.tenant_id
+ WHERE s.tenant_id IS NOT NULL
+ ORDER BY o.name, s.category, s.name
+ """)
+ return [dict(row) for row in await cursor.fetchall()]
+
+
+@router.post("/tenant/{source_id}/promote")
+async def promote_to_global(
+ source_id: int,
+ admin: dict = Depends(get_current_admin),
+ db: aiosqlite.Connection = Depends(db_dependency),
+):
+ """Tenant-Quelle zur Grundquelle befoerdern."""
+ cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
+ row = await cursor.fetchone()
+ if not row:
+ raise HTTPException(status_code=404, detail="Quelle nicht gefunden")
+ if row["tenant_id"] is None:
+ raise HTTPException(status_code=400, detail="Bereits eine Grundquelle")
+
+ if row["url"]:
+ cursor = await db.execute(
+ "SELECT id FROM sources WHERE url = ? AND tenant_id IS NULL",
+ (row["url"],),
+ )
+ if await cursor.fetchone():
+ raise HTTPException(status_code=409, detail="URL bereits als Grundquelle vorhanden")
+
+ await db.execute(
+ "UPDATE sources SET tenant_id = NULL, added_by = 'system' WHERE id = ?",
+ (source_id,),
+ )
+ await db.commit()
+
+ cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
+ return dict(await cursor.fetchone())
+
+
+
+@router.post("/discover")
+async def discover_source_endpoint(
+ url: str,
+ admin: dict = Depends(get_current_admin),
+ db: aiosqlite.Connection = Depends(db_dependency),
+):
+ """URL analysieren: Domain, Kategorie und RSS-Feeds automatisch erkennen.
+
+ Findet alle Feeds einer Domain, bewertet sie mit Claude und gibt
+ die relevanten zurueck. Prueft auf bereits vorhandene Grundquellen.
+ """
+ try:
+ multi = await discover_all_feeds(url)
+ except Exception as e:
+ logger.error(f"Discovery fehlgeschlagen: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail="Discovery fehlgeschlagen")
+
+ domain = multi["domain"]
+ category = multi["category"]
+ feeds = multi.get("feeds", [])
+
+ # Fallback auf Einzel-Discovery wenn keine Feeds gefunden
+ if not feeds:
+ try:
+ single = await discover_source(url)
+ if single.get("rss_url"):
+ feeds = [{"name": single["name"], "url": single["rss_url"]}]
+ domain = single.get("domain", domain)
+ category = single.get("category", category)
+ except Exception:
+ pass
+
+ if not feeds:
+ return {
+ "domain": domain,
+ "category": category,
+ "feeds": [],
+ "existing": [],
+ "message": "Keine RSS-Feeds gefunden",
+ }
+
+ # Mit Claude bewerten
+ try:
+ relevant_feeds = await evaluate_feeds_with_claude(domain, feeds)
+ except Exception:
+ relevant_feeds = feeds[:3]
+
+ # Bereits vorhandene Grundquellen pruefen
+ cursor = await db.execute(
+ "SELECT url FROM sources WHERE tenant_id IS NULL AND url IS NOT NULL"
+ )
+ existing_urls = {row["url"] for row in await cursor.fetchall()}
+
+ result_feeds = []
+ existing = []
+ for feed in relevant_feeds:
+ info = {
+ "name": feed.get("name", domain_to_display_name(domain)),
+ "url": feed["url"],
+ "domain": domain,
+ "category": category,
+ }
+ if feed["url"] in existing_urls:
+ existing.append(info)
+ else:
+ result_feeds.append(info)
+
+ return {
+ "domain": domain,
+ "category": category,
+ "feeds": result_feeds,
+ "existing": existing,
+ }
+
+
+@router.post("/discover/add")
+async def add_discovered_sources(
+ feeds: list[dict],
+ admin: dict = Depends(get_current_admin),
+ db: aiosqlite.Connection = Depends(db_dependency),
+):
+ """Erkannte Feeds als Grundquellen anlegen.
+
+ Erwartet eine Liste von {name, url, domain, category}.
+ Ueberspringt bereits vorhandene URLs.
+ """
+ cursor = await db.execute(
+ "SELECT url FROM sources WHERE tenant_id IS NULL AND url IS NOT NULL"
+ )
+ existing_urls = {row["url"] for row in await cursor.fetchall()}
+
+ added = 0
+ skipped = 0
+ for feed in feeds:
+ if not feed.get("url"):
+ continue
+ if feed["url"] in existing_urls:
+ skipped += 1
+ continue
+
+ domain = feed.get("domain", "")
+ await db.execute(
+ """INSERT INTO sources (name, url, domain, source_type, category, status, added_by, tenant_id)
+ VALUES (?, ?, ?, 'rss_feed', ?, 'active', 'system', NULL)""",
+ (feed["name"], feed["url"], domain, feed.get("category", "sonstige")),
+ )
+ existing_urls.add(feed["url"])
+ added += 1
+
+ # Web-Source für die Domain anlegen wenn noch nicht vorhanden
+ if feeds and feeds[0].get("domain"):
+ domain = feeds[0]["domain"]
+ cursor = await db.execute(
+ "SELECT id FROM sources WHERE LOWER(domain) = ? AND source_type = 'web_source' AND tenant_id IS NULL",
+ (domain.lower(),),
+ )
+ if not await cursor.fetchone():
+ await db.execute(
+ """INSERT INTO sources (name, url, domain, source_type, category, status, added_by, tenant_id)
+ VALUES (?, ?, ?, 'web_source', ?, 'active', 'system', NULL)""",
+ (domain_to_display_name(domain), f"https://{domain}", domain,
+ feeds[0].get("category", "sonstige")),
+ )
+ added += 1
+
+ await db.commit()
+ return {"added": added, "skipped": skipped}
diff --git a/src/static/dashboard.html b/src/static/dashboard.html
index a792cc0..79d255d 100644
--- a/src/static/dashboard.html
+++ b/src/static/dashboard.html
@@ -209,6 +209,7 @@
+
@@ -421,6 +422,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/static/js/sources.js b/src/static/js/sources.js
index aa1cd4e..6901b14 100644
--- a/src/static/js/sources.js
+++ b/src/static/js/sources.js
@@ -1,242 +1,342 @@
-/* Grundquellen & Kundenquellen Management */
-"use strict";
-
-let globalSourcesCache = [];
-let tenantSourcesCache = [];
-let editingSourceId = null;
-
-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",
-};
-
-const TYPE_LABELS = {
- rss_feed: "RSS-Feed",
- web_source: "Webquelle",
- excluded: "Gesperrt",
-};
-
-// --- Init ---
-document.addEventListener("DOMContentLoaded", () => {
- setupSourceSubTabs();
- setupSourceForms();
-
- // Beim Tab-Wechsel auf "Quellen" laden
- document.querySelectorAll('.nav-tab[data-section="sources"]').forEach((tab) => {
- tab.addEventListener("click", () => loadGlobalSources());
- });
-});
-
-function setupSourceSubTabs() {
- document.querySelectorAll("#sourceSubTabs .nav-tab").forEach((tab) => {
- tab.addEventListener("click", () => {
- const subtab = tab.dataset.subtab;
- document.querySelectorAll("#sourceSubTabs .nav-tab").forEach((t) => t.classList.remove("active"));
- tab.classList.add("active");
- document.querySelectorAll("#sec-sources > .section").forEach((s) => s.classList.remove("active"));
- document.getElementById("sub-" + subtab).classList.add("active");
-
- if (subtab === "global-sources") loadGlobalSources();
- else if (subtab === "tenant-sources") loadTenantSources();
- });
- });
-}
-
-// --- Grundquellen ---
-async function loadGlobalSources() {
- try {
- globalSourcesCache = await API.get("/api/sources/global");
- renderGlobalSources(globalSourcesCache);
- } catch (err) {
- console.error("Grundquellen laden fehlgeschlagen:", err);
- }
-}
-
-function renderGlobalSources(sources) {
- const tbody = document.getElementById("globalSourceTable");
- if (sources.length === 0) {
- tbody.innerHTML = '
| Keine Grundquellen |
';
- return;
- }
- tbody.innerHTML = sources.map((s) => `
-
- | ${esc(s.name)} |
- ${esc(s.url || "-")} |
- ${esc(s.domain || "-")} |
- ${TYPE_LABELS[s.source_type] || s.source_type} |
- ${CATEGORY_LABELS[s.category] || s.category} |
- ${s.status === "active" ? "Aktiv" : "Inaktiv"} |
-
-
-
- |
-
- `).join("");
-
- document.getElementById("globalSourceCount").textContent = `${sources.length} Grundquellen`;
-}
-
-// Suche
-document.addEventListener("DOMContentLoaded", () => {
- const el = document.getElementById("globalSourceSearch");
- if (el) {
- el.addEventListener("input", () => {
- const q = el.value.toLowerCase();
- const filtered = globalSourcesCache.filter((s) =>
- s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.category || "").toLowerCase().includes(q)
- );
- renderGlobalSources(filtered);
- });
- }
-});
-
-// --- Grundquelle erstellen/bearbeiten ---
-function openNewGlobalSource() {
- editingSourceId = null;
- document.getElementById("sourceModalTitle").textContent = "Neue Grundquelle";
- document.getElementById("sourceForm").reset();
- openModal("modalSource");
-}
-
-function editGlobalSource(id) {
- const s = globalSourcesCache.find((x) => x.id === id);
- if (!s) return;
- editingSourceId = id;
- document.getElementById("sourceModalTitle").textContent = "Grundquelle bearbeiten";
- document.getElementById("sourceName").value = s.name;
- document.getElementById("sourceUrl").value = s.url || "";
- document.getElementById("sourceDomain").value = s.domain || "";
- document.getElementById("sourceType").value = s.source_type;
- document.getElementById("sourceCategory").value = s.category;
- document.getElementById("sourceStatus").value = s.status;
- document.getElementById("sourceNotes").value = s.notes || "";
- openModal("modalSource");
-}
-
-function setupSourceForms() {
- document.getElementById("newGlobalSourceBtn").addEventListener("click", openNewGlobalSource);
-
- document.getElementById("sourceForm").addEventListener("submit", async (e) => {
- e.preventDefault();
- const errEl = document.getElementById("sourceError");
- errEl.style.display = "none";
-
- const body = {
- name: document.getElementById("sourceName").value,
- url: document.getElementById("sourceUrl").value || null,
- domain: document.getElementById("sourceDomain").value || null,
- source_type: document.getElementById("sourceType").value,
- category: document.getElementById("sourceCategory").value,
- status: document.getElementById("sourceStatus").value,
- notes: document.getElementById("sourceNotes").value || null,
- };
-
- try {
- if (editingSourceId) {
- await API.put("/api/sources/global/" + editingSourceId, body);
- } else {
- await API.post("/api/sources/global", body);
- }
- closeModal("modalSource");
- loadGlobalSources();
- } catch (err) {
- errEl.textContent = err.message;
- errEl.style.display = "block";
- }
- });
-
- // Domain aus URL ableiten
- document.getElementById("sourceUrl").addEventListener("blur", (e) => {
- const domainField = document.getElementById("sourceDomain");
- if (domainField.value) return;
- try {
- const url = new URL(e.target.value);
- domainField.value = url.hostname.replace(/^www\./, "");
- } catch (_) {}
- });
-}
-
-function confirmDeleteGlobalSource(id, name) {
- showConfirm(
- "Grundquelle loeschen",
- `Soll die Grundquelle "${name}" endgueltig geloescht werden? Sie wird fuer alle Monitore entfernt.`,
- async () => {
- try {
- await API.del("/api/sources/global/" + id);
- loadGlobalSources();
- } catch (err) {
- alert(err.message);
- }
- }
- );
-}
-
-// --- Kundenquellen ---
-async function loadTenantSources() {
- try {
- tenantSourcesCache = await API.get("/api/sources/tenant");
- renderTenantSources(tenantSourcesCache);
- } catch (err) {
- console.error("Kundenquellen laden fehlgeschlagen:", err);
- }
-}
-
-function renderTenantSources(sources) {
- const tbody = document.getElementById("tenantSourceTable");
- if (sources.length === 0) {
- tbody.innerHTML = '
| Keine Kundenquellen |
';
- return;
- }
- tbody.innerHTML = sources.map((s) => `
-
- | ${esc(s.name)} |
- ${esc(s.domain || "-")} |
- ${TYPE_LABELS[s.source_type] || s.source_type} |
- ${CATEGORY_LABELS[s.category] || s.category} |
- ${esc(s.org_name || "-")} |
- ${esc(s.added_by || "-")} |
-
-
- |
-
- `).join("");
-
- document.getElementById("tenantSourceCount").textContent = `${sources.length} Kundenquellen`;
-}
-
-// Suche Kundenquellen
-document.addEventListener("DOMContentLoaded", () => {
- const el = document.getElementById("tenantSourceSearch");
- if (el) {
- el.addEventListener("input", () => {
- const q = el.value.toLowerCase();
- const filtered = tenantSourcesCache.filter((s) =>
- s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.org_name || "").toLowerCase().includes(q)
- );
- renderTenantSources(filtered);
- });
- }
-});
-
-function promoteSource(id, name) {
- showConfirm(
- "Zur Grundquelle machen",
- `Soll "${name}" als Grundquelle uebernommen werden? Sie wird dann fuer alle Monitore verfuegbar.`,
- async () => {
- try {
- await API.post("/api/sources/tenant/" + id + "/promote");
- loadTenantSources();
- } catch (err) {
- alert(err.message);
- }
- }
- );
-}
+/* Grundquellen & Kundenquellen Management */
+"use strict";
+
+let globalSourcesCache = [];
+let tenantSourcesCache = [];
+let editingSourceId = null;
+
+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",
+};
+
+const TYPE_LABELS = {
+ rss_feed: "RSS-Feed",
+ web_source: "Webquelle",
+ excluded: "Gesperrt",
+};
+
+// --- Init ---
+document.addEventListener("DOMContentLoaded", () => {
+ setupSourceSubTabs();
+ setupSourceForms();
+
+ // Beim Tab-Wechsel auf "Quellen" laden
+ document.querySelectorAll('.nav-tab[data-section="sources"]').forEach((tab) => {
+ tab.addEventListener("click", () => loadGlobalSources());
+ });
+});
+
+function setupSourceSubTabs() {
+ document.querySelectorAll("#sourceSubTabs .nav-tab").forEach((tab) => {
+ tab.addEventListener("click", () => {
+ const subtab = tab.dataset.subtab;
+ document.querySelectorAll("#sourceSubTabs .nav-tab").forEach((t) => t.classList.remove("active"));
+ tab.classList.add("active");
+ document.querySelectorAll("#sec-sources > .section").forEach((s) => s.classList.remove("active"));
+ document.getElementById("sub-" + subtab).classList.add("active");
+
+ if (subtab === "global-sources") loadGlobalSources();
+ else if (subtab === "tenant-sources") loadTenantSources();
+ });
+ });
+}
+
+// --- Grundquellen ---
+async function loadGlobalSources() {
+ try {
+ globalSourcesCache = await API.get("/api/sources/global");
+ renderGlobalSources(globalSourcesCache);
+ } catch (err) {
+ console.error("Grundquellen laden fehlgeschlagen:", err);
+ }
+}
+
+function renderGlobalSources(sources) {
+ const tbody = document.getElementById("globalSourceTable");
+ if (sources.length === 0) {
+ tbody.innerHTML = '
| Keine Grundquellen |
';
+ return;
+ }
+ tbody.innerHTML = sources.map((s) => `
+
+ | ${esc(s.name)} |
+ ${esc(s.url || "-")} |
+ ${esc(s.domain || "-")} |
+ ${TYPE_LABELS[s.source_type] || s.source_type} |
+ ${CATEGORY_LABELS[s.category] || s.category} |
+ ${s.status === "active" ? "Aktiv" : "Inaktiv"} |
+
+
+
+ |
+
+ `).join("");
+
+ document.getElementById("globalSourceCount").textContent = `${sources.length} Grundquellen`;
+}
+
+// Suche
+document.addEventListener("DOMContentLoaded", () => {
+ const el = document.getElementById("globalSourceSearch");
+ if (el) {
+ el.addEventListener("input", () => {
+ const q = el.value.toLowerCase();
+ const filtered = globalSourcesCache.filter((s) =>
+ s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.category || "").toLowerCase().includes(q)
+ );
+ renderGlobalSources(filtered);
+ });
+ }
+});
+
+// --- Grundquelle erstellen/bearbeiten ---
+function openNewGlobalSource() {
+ editingSourceId = null;
+ document.getElementById("sourceModalTitle").textContent = "Neue Grundquelle";
+ document.getElementById("sourceForm").reset();
+ openModal("modalSource");
+}
+
+function editGlobalSource(id) {
+ const s = globalSourcesCache.find((x) => x.id === id);
+ if (!s) return;
+ editingSourceId = id;
+ document.getElementById("sourceModalTitle").textContent = "Grundquelle bearbeiten";
+ document.getElementById("sourceName").value = s.name;
+ document.getElementById("sourceUrl").value = s.url || "";
+ document.getElementById("sourceDomain").value = s.domain || "";
+ document.getElementById("sourceType").value = s.source_type;
+ document.getElementById("sourceCategory").value = s.category;
+ document.getElementById("sourceStatus").value = s.status;
+ document.getElementById("sourceNotes").value = s.notes || "";
+ openModal("modalSource");
+}
+
+function setupSourceForms() {
+ document.getElementById("newGlobalSourceBtn").addEventListener("click", openNewGlobalSource);
+ document.getElementById("discoverSourceBtn").addEventListener("click", () => {
+ document.getElementById("discoverUrl").value = "";
+ document.getElementById("discoverStatus").style.display = "none";
+ document.getElementById("discoverResults").style.display = "none";
+ openModal("modalDiscover");
+ });
+
+ document.getElementById("sourceForm").addEventListener("submit", async (e) => {
+ e.preventDefault();
+ const errEl = document.getElementById("sourceError");
+ errEl.style.display = "none";
+
+ const body = {
+ name: document.getElementById("sourceName").value,
+ url: document.getElementById("sourceUrl").value || null,
+ domain: document.getElementById("sourceDomain").value || null,
+ source_type: document.getElementById("sourceType").value,
+ category: document.getElementById("sourceCategory").value,
+ status: document.getElementById("sourceStatus").value,
+ notes: document.getElementById("sourceNotes").value || null,
+ };
+
+ try {
+ if (editingSourceId) {
+ await API.put("/api/sources/global/" + editingSourceId, body);
+ } else {
+ await API.post("/api/sources/global", body);
+ }
+ closeModal("modalSource");
+ loadGlobalSources();
+ } catch (err) {
+ errEl.textContent = err.message;
+ errEl.style.display = "block";
+ }
+ });
+
+ // Domain aus URL ableiten
+ document.getElementById("sourceUrl").addEventListener("blur", (e) => {
+ const domainField = document.getElementById("sourceDomain");
+ if (domainField.value) return;
+ try {
+ const url = new URL(e.target.value);
+ domainField.value = url.hostname.replace(/^www\./, "");
+ } catch (_) {}
+ });
+}
+
+function confirmDeleteGlobalSource(id, name) {
+ showConfirm(
+ "Grundquelle loeschen",
+ `Soll die Grundquelle "${name}" endgültig gelöscht werden? Sie wird für alle Monitore entfernt.`,
+ async () => {
+ try {
+ await API.del("/api/sources/global/" + id);
+ loadGlobalSources();
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+ );
+}
+
+// --- Kundenquellen ---
+async function loadTenantSources() {
+ try {
+ tenantSourcesCache = await API.get("/api/sources/tenant");
+ renderTenantSources(tenantSourcesCache);
+ } catch (err) {
+ console.error("Kundenquellen laden fehlgeschlagen:", err);
+ }
+}
+
+function renderTenantSources(sources) {
+ const tbody = document.getElementById("tenantSourceTable");
+ if (sources.length === 0) {
+ tbody.innerHTML = '
| Keine Kundenquellen |
';
+ return;
+ }
+ tbody.innerHTML = sources.map((s) => `
+
+ | ${esc(s.name)} |
+ ${esc(s.domain || "-")} |
+ ${TYPE_LABELS[s.source_type] || s.source_type} |
+ ${CATEGORY_LABELS[s.category] || s.category} |
+ ${esc(s.org_name || "-")} |
+ ${esc(s.added_by || "-")} |
+
+
+ |
+
+ `).join("");
+
+ document.getElementById("tenantSourceCount").textContent = `${sources.length} Kundenquellen`;
+}
+
+// Suche Kundenquellen
+document.addEventListener("DOMContentLoaded", () => {
+ const el = document.getElementById("tenantSourceSearch");
+ if (el) {
+ el.addEventListener("input", () => {
+ const q = el.value.toLowerCase();
+ const filtered = tenantSourcesCache.filter((s) =>
+ s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.org_name || "").toLowerCase().includes(q)
+ );
+ renderTenantSources(filtered);
+ });
+ }
+});
+
+function promoteSource(id, name) {
+ showConfirm(
+ "Zur Grundquelle machen",
+ `Soll "${name}" als Grundquelle übernommen werden? Sie wird dann für alle Monitore verfügbar.`,
+ async () => {
+ try {
+ await API.post("/api/sources/tenant/" + id + "/promote");
+ loadTenantSources();
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+ );
+}
+
+
+// --- Discovery ---
+let discoveredFeeds = [];
+
+async function runDiscover() {
+ const url = document.getElementById("discoverUrl").value.trim();
+ if (!url) return;
+
+ const btn = document.getElementById("discoverBtn");
+ const statusEl = document.getElementById("discoverStatus");
+ const resultsEl = document.getElementById("discoverResults");
+
+ btn.disabled = true;
+ btn.textContent = "Suche...";
+ statusEl.style.display = "block";
+ statusEl.textContent = "Analysiere Website und suche RSS-Feeds...";
+ resultsEl.style.display = "none";
+
+ try {
+ const data = await API.post("/api/sources/discover?url=" + encodeURIComponent(url));
+ discoveredFeeds = data.feeds || [];
+
+ if (discoveredFeeds.length === 0 && (!data.existing || data.existing.length === 0)) {
+ statusEl.textContent = data.message || "Keine RSS-Feeds gefunden für " + data.domain;
+ return;
+ }
+
+ statusEl.style.display = "none";
+ resultsEl.style.display = "block";
+
+ // Bereits vorhandene anzeigen
+ const existingEl = document.getElementById("discoverExisting");
+ if (data.existing && data.existing.length > 0) {
+ existingEl.style.display = "block";
+ existingEl.innerHTML = '
Bereits als Grundquelle vorhanden:
' +
+ data.existing.map(f => '
✓ ' + esc(f.name) + '
').join("");
+ } else {
+ existingEl.style.display = "none";
+ }
+
+ // Neue Feeds mit Checkboxen
+ const feedsEl = document.getElementById("discoverFeeds");
+ if (discoveredFeeds.length > 0) {
+ feedsEl.innerHTML = '
Neue Feeds gefunden (' + data.domain + ', ' + (CATEGORY_LABELS[data.category] || data.category) + '):
' +
+ discoveredFeeds.map((f, i) => `
+
+ `).join("");
+ document.getElementById("addDiscoveredBtn").style.display = "";
+ } else {
+ feedsEl.innerHTML = '
Alle Feeds dieser Domain sind bereits als Grundquellen vorhanden.
';
+ document.getElementById("addDiscoveredBtn").style.display = "none";
+ }
+ } catch (err) {
+ statusEl.textContent = "Fehler: " + err.message;
+ } finally {
+ btn.disabled = false;
+ btn.textContent = "Erkennen";
+ }
+}
+
+async function addDiscoveredFeeds() {
+ const checkboxes = document.querySelectorAll("#discoverFeeds input[type=checkbox]:checked");
+ const selected = [];
+ checkboxes.forEach(cb => {
+ const idx = parseInt(cb.dataset.idx);
+ if (discoveredFeeds[idx]) selected.push(discoveredFeeds[idx]);
+ });
+
+ if (selected.length === 0) {
+ alert("Keine Feeds ausgewaehlt");
+ return;
+ }
+
+ const btn = document.getElementById("addDiscoveredBtn");
+ btn.disabled = true;
+ btn.textContent = "Wird hinzugefügt...";
+
+ try {
+ const result = await API.post("/api/sources/discover/add", selected);
+ closeModal("modalDiscover");
+ loadGlobalSources();
+ alert(result.added + " Grundquelle(n) hinzugefügt" + (result.skipped ? ", " + result.skipped + " übersprungen" : ""));
+ } catch (err) {
+ alert("Fehler: " + err.message);
+ } finally {
+ btn.disabled = false;
+ btn.textContent = "Ausgewählte hinzufügen";
+ }
+}