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:
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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')">×</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;">
|
||||||
|
|||||||
@@ -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);">✓ ' + 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren