feat: Discovery-Funktion in Grundquellen-Verwaltung integriert

- POST /api/sources/discover: URL analysieren, RSS-Feeds erkennen, Duplikate prüfen
- POST /api/sources/discover/add: Erkannte Feeds als Grundquellen anlegen (inkl. Web-Source)
- Erkennen-Button und Modal im Dashboard mit Feed-Auswahl per Checkbox
- Duplikat-Erkennung zeigt bereits vorhandene Grundquellen an
- source_rules aus Monitor importiert für Feed-Discovery und Claude-Bewertung
- config.py um Discovery-Konfiguration erweitert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-03-05 20:04:18 +01:00
Ursprung 19fbf152eb
Commit 801944a7ea
4 geänderte Dateien mit 689 neuen und 409 gelöschten Zeilen

Datei anzeigen

@@ -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 (fuer OSINT-Monitor Einladungen)
MAGIC_LINK_BASE_URL = os.environ.get("MAGIC_LINK_BASE_URL", "https://osint.intelsight.de") MAGIC_LINK_BASE_URL = os.environ.get("MAGIC_LINK_BASE_URL", "https://osint.intelsight.de")
MAGIC_LINK_EXPIRE_MINUTES = 10 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

Datei anzeigen

@@ -1,167 +1,314 @@
"""Grundquellen-Verwaltung und Kundenquellen-Übersicht.""" """Grundquellen-Verwaltung und Kundenquellen-Übersicht."""
from fastapi import APIRouter, Depends, HTTPException, status import sys
from pydantic import BaseModel, Field import logging
from typing import Optional
from auth import get_current_admin # Monitor-Source-Rules verfügbar machen
from database import db_dependency sys.path.insert(0, "/home/claude-dev/AegisSight-Monitor/src")
import aiosqlite
from fastapi import APIRouter, Depends, HTTPException, status
router = APIRouter(prefix="/api/sources", tags=["sources"]) from pydantic import BaseModel, Field
from typing import Optional
from auth import get_current_admin
class GlobalSourceCreate(BaseModel): from database import db_dependency
name: str = Field(min_length=1, max_length=200) import aiosqlite
url: Optional[str] = None
domain: Optional[str] = None from source_rules import (
source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded)$") discover_source,
category: str = Field(default="sonstige") discover_all_feeds,
status: str = Field(default="active", pattern="^(active|inactive)$") evaluate_feeds_with_claude,
notes: Optional[str] = None _extract_domain,
_detect_category,
domain_to_display_name,
class GlobalSourceUpdate(BaseModel): )
name: Optional[str] = Field(default=None, max_length=200)
url: Optional[str] = None logger = logging.getLogger("verwaltung.sources")
domain: Optional[str] = None
source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded)$") router = APIRouter(prefix="/api/sources", tags=["sources"])
category: Optional[str] = None
status: Optional[str] = Field(default=None, pattern="^(active|inactive)$")
notes: Optional[str] = None class GlobalSourceCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
url: Optional[str] = None
@router.get("/global") domain: Optional[str] = None
async def list_global_sources( source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded)$")
admin: dict = Depends(get_current_admin), category: str = Field(default="sonstige")
db: aiosqlite.Connection = Depends(db_dependency), status: str = Field(default="active", pattern="^(active|inactive)$")
): notes: Optional[str] = None
"""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" class GlobalSourceUpdate(BaseModel):
) name: Optional[str] = Field(default=None, max_length=200)
return [dict(row) for row in await cursor.fetchall()] url: Optional[str] = None
domain: Optional[str] = None
source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded)$")
@router.post("/global", status_code=201) category: Optional[str] = None
async def create_global_source( status: Optional[str] = Field(default=None, pattern="^(active|inactive)$")
data: GlobalSourceCreate, notes: Optional[str] = None
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
): @router.get("/global")
"""Neue Grundquelle anlegen.""" async def list_global_sources(
if data.url: admin: dict = Depends(get_current_admin),
cursor = await db.execute( db: aiosqlite.Connection = Depends(db_dependency),
"SELECT id, name FROM sources WHERE url = ? AND tenant_id IS NULL", ):
(data.url,), """Alle Grundquellen auflisten (tenant_id IS NULL)."""
) cursor = await db.execute(
existing = await cursor.fetchone() "SELECT * FROM sources WHERE tenant_id IS NULL ORDER BY category, source_type, name"
if existing: )
raise HTTPException( return [dict(row) for row in await cursor.fetchall()]
status_code=409,
detail=f"URL bereits vorhanden: {existing['name']}",
) @router.post("/global", status_code=201)
async def create_global_source(
cursor = await db.execute( data: GlobalSourceCreate,
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id) admin: dict = Depends(get_current_admin),
VALUES (?, ?, ?, ?, ?, ?, ?, 'system', NULL)""", db: aiosqlite.Connection = Depends(db_dependency),
(data.name, data.url, data.domain, data.source_type, data.category, data.status, data.notes), ):
) """Neue Grundquelle anlegen."""
await db.commit() if data.url:
cursor = await db.execute(
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (cursor.lastrowid,)) "SELECT id, name FROM sources WHERE url = ? AND tenant_id IS NULL",
return dict(await cursor.fetchone()) (data.url,),
)
existing = await cursor.fetchone()
@router.put("/global/{source_id}") if existing:
async def update_global_source( raise HTTPException(
source_id: int, status_code=409,
data: GlobalSourceUpdate, detail=f"URL bereits vorhanden: {existing['name']}",
admin: dict = Depends(get_current_admin), )
db: aiosqlite.Connection = Depends(db_dependency),
): cursor = await db.execute(
"""Grundquelle bearbeiten.""" """INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id)
cursor = await db.execute( VALUES (?, ?, ?, ?, ?, ?, ?, 'system', NULL)""",
"SELECT * FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,) (data.name, data.url, data.domain, data.source_type, data.category, data.status, data.notes),
) )
row = await cursor.fetchone() await db.commit()
if not row:
raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden") cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (cursor.lastrowid,))
return dict(await cursor.fetchone())
updates = {}
for field, value in data.model_dump(exclude_none=True).items():
updates[field] = value @router.put("/global/{source_id}")
async def update_global_source(
if not updates: source_id: int,
return dict(row) data: GlobalSourceUpdate,
admin: dict = Depends(get_current_admin),
set_clause = ", ".join(f"{k} = ?" for k in updates) db: aiosqlite.Connection = Depends(db_dependency),
values = list(updates.values()) + [source_id] ):
await db.execute(f"UPDATE sources SET {set_clause} WHERE id = ?", values) """Grundquelle bearbeiten."""
await db.commit() cursor = await db.execute(
"SELECT * FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,)
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,)) )
return dict(await cursor.fetchone()) row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden")
@router.delete("/global/{source_id}", status_code=204)
async def delete_global_source( updates = {}
source_id: int, for field, value in data.model_dump(exclude_none=True).items():
admin: dict = Depends(get_current_admin), updates[field] = value
db: aiosqlite.Connection = Depends(db_dependency),
): if not updates:
"""Grundquelle loeschen.""" return dict(row)
cursor = await db.execute(
"SELECT id FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,) set_clause = ", ".join(f"{k} = ?" for k in updates)
) values = list(updates.values()) + [source_id]
if not await cursor.fetchone(): await db.execute(f"UPDATE sources SET {set_clause} WHERE id = ?", values)
raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden") await db.commit()
await db.execute("DELETE FROM sources WHERE id = ?", (source_id,)) cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
await db.commit() return dict(await cursor.fetchone())
@router.get("/tenant") @router.delete("/global/{source_id}", status_code=204)
async def list_tenant_sources( async def delete_global_source(
admin: dict = Depends(get_current_admin), source_id: int,
db: aiosqlite.Connection = Depends(db_dependency), admin: dict = Depends(get_current_admin),
): db: aiosqlite.Connection = Depends(db_dependency),
"""Alle tenant-spezifischen Quellen mit Org-Name auflisten.""" ):
cursor = await db.execute(""" """Grundquelle loeschen."""
SELECT s.*, o.name as org_name cursor = await db.execute(
FROM sources s "SELECT id FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,)
LEFT JOIN organizations o ON o.id = s.tenant_id )
WHERE s.tenant_id IS NOT NULL if not await cursor.fetchone():
ORDER BY o.name, s.category, s.name raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden")
""")
return [dict(row) for row in await cursor.fetchall()] await db.execute("DELETE FROM sources WHERE id = ?", (source_id,))
await db.commit()
@router.post("/tenant/{source_id}/promote")
async def promote_to_global( @router.get("/tenant")
source_id: int, async def list_tenant_sources(
admin: dict = Depends(get_current_admin), admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency), db: aiosqlite.Connection = Depends(db_dependency),
): ):
"""Tenant-Quelle zur Grundquelle befoerdern.""" """Alle tenant-spezifischen Quellen mit Org-Name auflisten."""
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,)) cursor = await db.execute("""
row = await cursor.fetchone() SELECT s.*, o.name as org_name
if not row: FROM sources s
raise HTTPException(status_code=404, detail="Quelle nicht gefunden") LEFT JOIN organizations o ON o.id = s.tenant_id
if row["tenant_id"] is None: WHERE s.tenant_id IS NOT NULL
raise HTTPException(status_code=400, detail="Bereits eine Grundquelle") ORDER BY o.name, s.category, s.name
""")
if row["url"]: return [dict(row) for row in await cursor.fetchall()]
cursor = await db.execute(
"SELECT id FROM sources WHERE url = ? AND tenant_id IS NULL",
(row["url"],), @router.post("/tenant/{source_id}/promote")
) async def promote_to_global(
if await cursor.fetchone(): source_id: int,
raise HTTPException(status_code=409, detail="URL bereits als Grundquelle vorhanden") admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
await db.execute( ):
"UPDATE sources SET tenant_id = NULL, added_by = 'system' WHERE id = ?", """Tenant-Quelle zur Grundquelle befoerdern."""
(source_id,), cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
) row = await cursor.fetchone()
await db.commit() if not row:
raise HTTPException(status_code=404, detail="Quelle nicht gefunden")
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,)) if row["tenant_id"] is None:
return dict(await cursor.fetchone()) 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}

Datei anzeigen

@@ -209,6 +209,7 @@
<input type="text" class="search-input" id="globalSourceSearch" placeholder="Grundquelle suchen..."> <input type="text" class="search-input" id="globalSourceSearch" placeholder="Grundquelle suchen...">
<span class="text-secondary" id="globalSourceCount"></span> <span class="text-secondary" id="globalSourceCount"></span>
</div> </div>
<button class="btn btn-secondary" id="discoverSourceBtn">Erkennen</button>
<button class="btn btn-primary" id="newGlobalSourceBtn">+ Neue Grundquelle</button> <button class="btn btn-primary" id="newGlobalSourceBtn">+ Neue Grundquelle</button>
</div> </div>
<div class="card"> <div class="card">
@@ -421,6 +422,33 @@
</div> </div>
</div> </div>
<!-- Modal: Discover Sources -->
<div class="modal-overlay" id="modalDiscover">
<div class="modal" style="max-width:600px;">
<div class="modal-header">
<h3>Quellen erkennen</h3>
<button class="modal-close" onclick="closeModal('modalDiscover')">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="discoverUrl">Website-URL</label>
<div style="display:flex;gap:8px;">
<input type="url" id="discoverUrl" placeholder="https://www.example.de" style="flex:1;" required>
<button class="btn btn-primary" id="discoverBtn" onclick="runDiscover()">Erkennen</button>
</div>
</div>
<div id="discoverStatus" style="display:none;padding:12px 0;color:var(--text-secondary);font-size:13px;"></div>
<div id="discoverResults" style="display:none;">
<div id="discoverExisting" style="display:none;margin-bottom:12px;"></div>
<div id="discoverFeeds"></div>
<div style="margin-top:12px;display:flex;justify-content:flex-end;">
<button class="btn btn-primary" id="addDiscoveredBtn" style="display:none;" onclick="addDiscoveredFeeds()">Ausgewählte hinzufügen</button>
</div>
</div>
</div>
</div>
</div>
<!-- Modal: Confirm --> <!-- Modal: Confirm -->
<div class="modal-overlay" id="modalConfirm"> <div class="modal-overlay" id="modalConfirm">
<div class="modal" style="max-width: 400px;"> <div class="modal" style="max-width: 400px;">

Datei anzeigen

@@ -1,242 +1,342 @@
/* Grundquellen & Kundenquellen Management */ /* Grundquellen & Kundenquellen Management */
"use strict"; "use strict";
let globalSourcesCache = []; let globalSourcesCache = [];
let tenantSourcesCache = []; let tenantSourcesCache = [];
let editingSourceId = null; let editingSourceId = null;
const CATEGORY_LABELS = { const CATEGORY_LABELS = {
nachrichtenagentur: "Nachrichtenagentur", nachrichtenagentur: "Nachrichtenagentur",
"oeffentlich-rechtlich": "Öffentlich-Rechtlich", "oeffentlich-rechtlich": "Öffentlich-Rechtlich",
qualitaetszeitung: "Qualitätszeitung", qualitaetszeitung: "Qualitätszeitung",
behoerde: "Behörde", behoerde: "Behörde",
fachmedien: "Fachmedien", fachmedien: "Fachmedien",
"think-tank": "Think-Tank", "think-tank": "Think-Tank",
international: "International", international: "International",
regional: "Regional", regional: "Regional",
boulevard: "Boulevard", boulevard: "Boulevard",
sonstige: "Sonstige", sonstige: "Sonstige",
}; };
const TYPE_LABELS = { const TYPE_LABELS = {
rss_feed: "RSS-Feed", rss_feed: "RSS-Feed",
web_source: "Webquelle", web_source: "Webquelle",
excluded: "Gesperrt", excluded: "Gesperrt",
}; };
// --- Init --- // --- Init ---
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
setupSourceSubTabs(); setupSourceSubTabs();
setupSourceForms(); setupSourceForms();
// Beim Tab-Wechsel auf "Quellen" laden // Beim Tab-Wechsel auf "Quellen" laden
document.querySelectorAll('.nav-tab[data-section="sources"]').forEach((tab) => { document.querySelectorAll('.nav-tab[data-section="sources"]').forEach((tab) => {
tab.addEventListener("click", () => loadGlobalSources()); tab.addEventListener("click", () => loadGlobalSources());
}); });
}); });
function setupSourceSubTabs() { function setupSourceSubTabs() {
document.querySelectorAll("#sourceSubTabs .nav-tab").forEach((tab) => { document.querySelectorAll("#sourceSubTabs .nav-tab").forEach((tab) => {
tab.addEventListener("click", () => { tab.addEventListener("click", () => {
const subtab = tab.dataset.subtab; const subtab = tab.dataset.subtab;
document.querySelectorAll("#sourceSubTabs .nav-tab").forEach((t) => t.classList.remove("active")); document.querySelectorAll("#sourceSubTabs .nav-tab").forEach((t) => t.classList.remove("active"));
tab.classList.add("active"); tab.classList.add("active");
document.querySelectorAll("#sec-sources > .section").forEach((s) => s.classList.remove("active")); document.querySelectorAll("#sec-sources > .section").forEach((s) => s.classList.remove("active"));
document.getElementById("sub-" + subtab).classList.add("active"); document.getElementById("sub-" + subtab).classList.add("active");
if (subtab === "global-sources") loadGlobalSources(); if (subtab === "global-sources") loadGlobalSources();
else if (subtab === "tenant-sources") loadTenantSources(); else if (subtab === "tenant-sources") loadTenantSources();
}); });
}); });
} }
// --- Grundquellen --- // --- Grundquellen ---
async function loadGlobalSources() { async function loadGlobalSources() {
try { try {
globalSourcesCache = await API.get("/api/sources/global"); globalSourcesCache = await API.get("/api/sources/global");
renderGlobalSources(globalSourcesCache); renderGlobalSources(globalSourcesCache);
} catch (err) { } catch (err) {
console.error("Grundquellen laden fehlgeschlagen:", err); console.error("Grundquellen laden fehlgeschlagen:", err);
} }
} }
function renderGlobalSources(sources) { function renderGlobalSources(sources) {
const tbody = document.getElementById("globalSourceTable"); const tbody = document.getElementById("globalSourceTable");
if (sources.length === 0) { if (sources.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">Keine Grundquellen</td></tr>'; tbody.innerHTML = '<tr><td colspan="7" class="text-muted">Keine Grundquellen</td></tr>';
return; return;
} }
tbody.innerHTML = sources.map((s) => ` tbody.innerHTML = sources.map((s) => `
<tr> <tr>
<td>${esc(s.name)}</td> <td>${esc(s.name)}</td>
<td class="text-secondary" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.url || '')}">${esc(s.url || "-")}</td> <td class="text-secondary" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.url || '')}">${esc(s.url || "-")}</td>
<td>${esc(s.domain || "-")}</td> <td>${esc(s.domain || "-")}</td>
<td>${TYPE_LABELS[s.source_type] || s.source_type}</td> <td>${TYPE_LABELS[s.source_type] || s.source_type}</td>
<td>${CATEGORY_LABELS[s.category] || s.category}</td> <td>${CATEGORY_LABELS[s.category] || s.category}</td>
<td><span class="badge badge-${s.status === "active" ? "active" : "inactive"}">${s.status === "active" ? "Aktiv" : "Inaktiv"}</span></td> <td><span class="badge badge-${s.status === "active" ? "active" : "inactive"}">${s.status === "active" ? "Aktiv" : "Inaktiv"}</span></td>
<td> <td>
<button class="btn btn-secondary btn-small" onclick="editGlobalSource(${s.id})">Bearbeiten</button> <button class="btn btn-secondary btn-small" onclick="editGlobalSource(${s.id})">Bearbeiten</button>
<button class="btn btn-danger btn-small" onclick="confirmDeleteGlobalSource(${s.id}, '${esc(s.name)}')">Loeschen</button> <button class="btn btn-danger btn-small" onclick="confirmDeleteGlobalSource(${s.id}, '${esc(s.name)}')">Loeschen</button>
</td> </td>
</tr> </tr>
`).join(""); `).join("");
document.getElementById("globalSourceCount").textContent = `${sources.length} Grundquellen`; document.getElementById("globalSourceCount").textContent = `${sources.length} Grundquellen`;
} }
// Suche // Suche
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const el = document.getElementById("globalSourceSearch"); const el = document.getElementById("globalSourceSearch");
if (el) { if (el) {
el.addEventListener("input", () => { el.addEventListener("input", () => {
const q = el.value.toLowerCase(); const q = el.value.toLowerCase();
const filtered = globalSourcesCache.filter((s) => const filtered = globalSourcesCache.filter((s) =>
s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.category || "").toLowerCase().includes(q) s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.category || "").toLowerCase().includes(q)
); );
renderGlobalSources(filtered); renderGlobalSources(filtered);
}); });
} }
}); });
// --- Grundquelle erstellen/bearbeiten --- // --- Grundquelle erstellen/bearbeiten ---
function openNewGlobalSource() { function openNewGlobalSource() {
editingSourceId = null; editingSourceId = null;
document.getElementById("sourceModalTitle").textContent = "Neue Grundquelle"; document.getElementById("sourceModalTitle").textContent = "Neue Grundquelle";
document.getElementById("sourceForm").reset(); document.getElementById("sourceForm").reset();
openModal("modalSource"); openModal("modalSource");
} }
function editGlobalSource(id) { function editGlobalSource(id) {
const s = globalSourcesCache.find((x) => x.id === id); const s = globalSourcesCache.find((x) => x.id === id);
if (!s) return; if (!s) return;
editingSourceId = id; editingSourceId = id;
document.getElementById("sourceModalTitle").textContent = "Grundquelle bearbeiten"; document.getElementById("sourceModalTitle").textContent = "Grundquelle bearbeiten";
document.getElementById("sourceName").value = s.name; document.getElementById("sourceName").value = s.name;
document.getElementById("sourceUrl").value = s.url || ""; document.getElementById("sourceUrl").value = s.url || "";
document.getElementById("sourceDomain").value = s.domain || ""; document.getElementById("sourceDomain").value = s.domain || "";
document.getElementById("sourceType").value = s.source_type; document.getElementById("sourceType").value = s.source_type;
document.getElementById("sourceCategory").value = s.category; document.getElementById("sourceCategory").value = s.category;
document.getElementById("sourceStatus").value = s.status; document.getElementById("sourceStatus").value = s.status;
document.getElementById("sourceNotes").value = s.notes || ""; document.getElementById("sourceNotes").value = s.notes || "";
openModal("modalSource"); openModal("modalSource");
} }
function setupSourceForms() { function setupSourceForms() {
document.getElementById("newGlobalSourceBtn").addEventListener("click", openNewGlobalSource); document.getElementById("newGlobalSourceBtn").addEventListener("click", openNewGlobalSource);
document.getElementById("discoverSourceBtn").addEventListener("click", () => {
document.getElementById("sourceForm").addEventListener("submit", async (e) => { document.getElementById("discoverUrl").value = "";
e.preventDefault(); document.getElementById("discoverStatus").style.display = "none";
const errEl = document.getElementById("sourceError"); document.getElementById("discoverResults").style.display = "none";
errEl.style.display = "none"; openModal("modalDiscover");
});
const body = {
name: document.getElementById("sourceName").value, document.getElementById("sourceForm").addEventListener("submit", async (e) => {
url: document.getElementById("sourceUrl").value || null, e.preventDefault();
domain: document.getElementById("sourceDomain").value || null, const errEl = document.getElementById("sourceError");
source_type: document.getElementById("sourceType").value, errEl.style.display = "none";
category: document.getElementById("sourceCategory").value,
status: document.getElementById("sourceStatus").value, const body = {
notes: document.getElementById("sourceNotes").value || null, name: document.getElementById("sourceName").value,
}; url: document.getElementById("sourceUrl").value || null,
domain: document.getElementById("sourceDomain").value || null,
try { source_type: document.getElementById("sourceType").value,
if (editingSourceId) { category: document.getElementById("sourceCategory").value,
await API.put("/api/sources/global/" + editingSourceId, body); status: document.getElementById("sourceStatus").value,
} else { notes: document.getElementById("sourceNotes").value || null,
await API.post("/api/sources/global", body); };
}
closeModal("modalSource"); try {
loadGlobalSources(); if (editingSourceId) {
} catch (err) { await API.put("/api/sources/global/" + editingSourceId, body);
errEl.textContent = err.message; } else {
errEl.style.display = "block"; await API.post("/api/sources/global", body);
} }
}); closeModal("modalSource");
loadGlobalSources();
// Domain aus URL ableiten } catch (err) {
document.getElementById("sourceUrl").addEventListener("blur", (e) => { errEl.textContent = err.message;
const domainField = document.getElementById("sourceDomain"); errEl.style.display = "block";
if (domainField.value) return; }
try { });
const url = new URL(e.target.value);
domainField.value = url.hostname.replace(/^www\./, ""); // Domain aus URL ableiten
} catch (_) {} document.getElementById("sourceUrl").addEventListener("blur", (e) => {
}); const domainField = document.getElementById("sourceDomain");
} if (domainField.value) return;
try {
function confirmDeleteGlobalSource(id, name) { const url = new URL(e.target.value);
showConfirm( domainField.value = url.hostname.replace(/^www\./, "");
"Grundquelle loeschen", } catch (_) {}
`Soll die Grundquelle "${name}" endgueltig geloescht werden? Sie wird fuer alle Monitore entfernt.`, });
async () => { }
try {
await API.del("/api/sources/global/" + id); function confirmDeleteGlobalSource(id, name) {
loadGlobalSources(); showConfirm(
} catch (err) { "Grundquelle loeschen",
alert(err.message); `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) {
// --- Kundenquellen --- alert(err.message);
async function loadTenantSources() { }
try { }
tenantSourcesCache = await API.get("/api/sources/tenant"); );
renderTenantSources(tenantSourcesCache); }
} catch (err) {
console.error("Kundenquellen laden fehlgeschlagen:", err); // --- Kundenquellen ---
} async function loadTenantSources() {
} try {
tenantSourcesCache = await API.get("/api/sources/tenant");
function renderTenantSources(sources) { renderTenantSources(tenantSourcesCache);
const tbody = document.getElementById("tenantSourceTable"); } catch (err) {
if (sources.length === 0) { console.error("Kundenquellen laden fehlgeschlagen:", err);
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">Keine Kundenquellen</td></tr>'; }
return; }
}
tbody.innerHTML = sources.map((s) => ` function renderTenantSources(sources) {
<tr> const tbody = document.getElementById("tenantSourceTable");
<td>${esc(s.name)}</td> if (sources.length === 0) {
<td class="text-secondary" style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.url || '')}">${esc(s.domain || "-")}</td> tbody.innerHTML = '<tr><td colspan="7" class="text-muted">Keine Kundenquellen</td></tr>';
<td>${TYPE_LABELS[s.source_type] || s.source_type}</td> return;
<td>${CATEGORY_LABELS[s.category] || s.category}</td> }
<td>${esc(s.org_name || "-")}</td> tbody.innerHTML = sources.map((s) => `
<td>${esc(s.added_by || "-")}</td> <tr>
<td> <td>${esc(s.name)}</td>
<button class="btn btn-primary btn-small" onclick="promoteSource(${s.id}, '${esc(s.name)}')">Uebernehmen</button> <td class="text-secondary" style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.url || '')}">${esc(s.domain || "-")}</td>
</td> <td>${TYPE_LABELS[s.source_type] || s.source_type}</td>
</tr> <td>${CATEGORY_LABELS[s.category] || s.category}</td>
`).join(""); <td>${esc(s.org_name || "-")}</td>
<td>${esc(s.added_by || "-")}</td>
document.getElementById("tenantSourceCount").textContent = `${sources.length} Kundenquellen`; <td>
} <button class="btn btn-primary btn-small" onclick="promoteSource(${s.id}, '${esc(s.name)}')">Uebernehmen</button>
</td>
// Suche Kundenquellen </tr>
document.addEventListener("DOMContentLoaded", () => { `).join("");
const el = document.getElementById("tenantSourceSearch");
if (el) { document.getElementById("tenantSourceCount").textContent = `${sources.length} Kundenquellen`;
el.addEventListener("input", () => { }
const q = el.value.toLowerCase();
const filtered = tenantSourcesCache.filter((s) => // Suche Kundenquellen
s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.org_name || "").toLowerCase().includes(q) document.addEventListener("DOMContentLoaded", () => {
); const el = document.getElementById("tenantSourceSearch");
renderTenantSources(filtered); 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)
function promoteSource(id, name) { );
showConfirm( renderTenantSources(filtered);
"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"); function promoteSource(id, name) {
loadTenantSources(); showConfirm(
} catch (err) { "Zur Grundquelle machen",
alert(err.message); `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 = '<div class="text-secondary" style="font-size:12px;margin-bottom:8px;">Bereits als Grundquelle vorhanden:</div>' +
data.existing.map(f => '<div style="padding:4px 0;font-size:13px;color:var(--text-tertiary);">&#10003; ' + esc(f.name) + '</div>').join("");
} else {
existingEl.style.display = "none";
}
// Neue Feeds mit Checkboxen
const feedsEl = document.getElementById("discoverFeeds");
if (discoveredFeeds.length > 0) {
feedsEl.innerHTML = '<div class="text-secondary" style="font-size:12px;margin-bottom:8px;">Neue Feeds gefunden (' + data.domain + ', ' + (CATEGORY_LABELS[data.category] || data.category) + '):</div>' +
discoveredFeeds.map((f, i) => `
<label style="display:flex;align-items:center;gap:8px;padding:6px 0;font-size:13px;cursor:pointer;">
<input type="checkbox" checked data-idx="${i}">
<span>${esc(f.name)}</span>
<span class="text-secondary" style="font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:250px;" title="${esc(f.url)}">${esc(f.url)}</span>
</label>
`).join("");
document.getElementById("addDiscoveredBtn").style.display = "";
} else {
feedsEl.innerHTML = '<div class="text-muted" style="font-size:13px;">Alle Feeds dieser Domain sind bereits als Grundquellen vorhanden.</div>';
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";
}
}