Per-User Domain-Ausschlüsse + Grundquellen-Schutz

- Neue Tabelle user_excluded_domains für benutzerspezifische Ausschlüsse
- Domain-Ausschlüsse wirken nur für den jeweiligen User, nicht org-weit
- user_id wird durch die gesamte Pipeline geschleust (Orchestrator → Researcher → RSS-Parser)
- Grundquellen (is_global) können nicht mehr bearbeitet/gelöscht werden im Frontend
- Grundquelle-Badge bei globalen Quellen statt Edit/Delete-Buttons
- Filter Von mir ausgeschlossen im Quellen-Modal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-03-08 14:30:21 +01:00
Ursprung 18954cf70e
Commit 5e19736a25
13 geänderte Dateien mit 149 neuen und 108 gelöschten Zeilen

Datei anzeigen

@@ -332,7 +332,7 @@ class AgentOrchestrator:
self._running = False self._running = False
logger.info("Agenten-Orchestrator gestoppt") logger.info("Agenten-Orchestrator gestoppt")
async def enqueue_refresh(self, incident_id: int, trigger_type: str = "manual") -> bool: async def enqueue_refresh(self, incident_id: int, trigger_type: str = "manual", user_id: int = None) -> bool:
"""Refresh-Auftrag in die Queue stellen. Gibt False zurueck wenn bereits in Queue/aktiv.""" """Refresh-Auftrag in die Queue stellen. Gibt False zurueck wenn bereits in Queue/aktiv."""
if incident_id in self._queued_ids or self._current_task == incident_id: if incident_id in self._queued_ids or self._current_task == incident_id:
logger.info(f"Refresh fuer Lage {incident_id} uebersprungen: bereits aktiv/in Queue") logger.info(f"Refresh fuer Lage {incident_id} uebersprungen: bereits aktiv/in Queue")
@@ -341,7 +341,7 @@ class AgentOrchestrator:
visibility, created_by, tenant_id = await self._get_incident_visibility(incident_id) visibility, created_by, tenant_id = await self._get_incident_visibility(incident_id)
self._queued_ids.add(incident_id) self._queued_ids.add(incident_id)
await self._queue.put((incident_id, trigger_type)) await self._queue.put((incident_id, trigger_type, user_id))
queue_size = self._queue.qsize() queue_size = self._queue.qsize()
logger.info(f"Refresh fuer Lage {incident_id} eingereiht (Queue: {queue_size}, Trigger: {trigger_type})") logger.info(f"Refresh fuer Lage {incident_id} eingereiht (Queue: {queue_size}, Trigger: {trigger_type})")
@@ -386,7 +386,11 @@ class AgentOrchestrator:
except asyncio.TimeoutError: except asyncio.TimeoutError:
continue continue
incident_id, trigger_type = item if len(item) == 3:
incident_id, trigger_type, user_id = item
else:
incident_id, trigger_type = item
user_id = None
self._queued_ids.discard(incident_id) self._queued_ids.discard(incident_id)
self._current_task = incident_id self._current_task = incident_id
logger.info(f"Starte Refresh für Lage {incident_id} (Trigger: {trigger_type})") logger.info(f"Starte Refresh für Lage {incident_id} (Trigger: {trigger_type})")
@@ -398,7 +402,7 @@ class AgentOrchestrator:
try: try:
for attempt in range(3): for attempt in range(3):
try: try:
await self._run_refresh(incident_id, trigger_type=trigger_type, retry_count=attempt) await self._run_refresh(incident_id, trigger_type=trigger_type, retry_count=attempt, user_id=user_id)
last_error = None last_error = None
break # Erfolg break # Erfolg
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -509,7 +513,7 @@ class AgentOrchestrator:
await db.close() await db.close()
return visibility, created_by, tenant_id return visibility, created_by, tenant_id
async def _run_refresh(self, incident_id: int, trigger_type: str = "manual", retry_count: int = 0): async def _run_refresh(self, incident_id: int, trigger_type: str = "manual", retry_count: int = 0, user_id: int = None):
"""Führt einen kompletten Refresh-Zyklus durch.""" """Führt einen kompletten Refresh-Zyklus durch."""
import aiosqlite import aiosqlite
from database import get_db from database import get_db
@@ -604,7 +608,7 @@ class AgentOrchestrator:
keywords = feed_sel_keywords keywords = feed_sel_keywords
articles = await rss_parser.search_feeds_selective(title, selected_feeds, keywords=keywords) articles = await rss_parser.search_feeds_selective(title, selected_feeds, keywords=keywords)
else: else:
articles = await rss_parser.search_feeds(title, international=international, tenant_id=tenant_id, keywords=keywords) articles = await rss_parser.search_feeds(title, international=international, tenant_id=tenant_id, keywords=keywords, user_id=user_id)
logger.info(f"RSS: {len(articles)} relevante Artikel gefunden (international={international})") logger.info(f"RSS: {len(articles)} relevante Artikel gefunden (international={international})")
return articles, feed_usage return articles, feed_usage
@@ -612,7 +616,7 @@ class AgentOrchestrator:
async def _web_search_pipeline(): async def _web_search_pipeline():
"""Claude WebSearch-Recherche.""" """Claude WebSearch-Recherche."""
researcher = ResearcherAgent() researcher = ResearcherAgent()
results, usage = await researcher.search(title, description, incident_type, international=international) results, usage = await researcher.search(title, description, incident_type, international=international, user_id=user_id)
logger.info(f"Claude-Recherche: {len(results)} Ergebnisse") logger.info(f"Claude-Recherche: {len(results)} Ergebnisse")
return results, usage return results, usage

Datei anzeigen

@@ -269,7 +269,7 @@ class ResearcherAgent:
logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}") logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}")
return None, None return None, None
async def search(self, title: str, description: str = "", incident_type: str = "adhoc", international: bool = True) -> tuple[list[dict], ClaudeUsage | None]: async def search(self, title: str, description: str = "", incident_type: str = "adhoc", international: bool = True, user_id: int = None) -> tuple[list[dict], ClaudeUsage | None]:
"""Sucht nach Informationen zu einem Vorfall.""" """Sucht nach Informationen zu einem Vorfall."""
from config import OUTPUT_LANGUAGE from config import OUTPUT_LANGUAGE
if incident_type == "research": if incident_type == "research":
@@ -290,7 +290,7 @@ class ResearcherAgent:
articles = self._parse_response(result) articles = self._parse_response(result)
# Ausgeschlossene Quellen dynamisch aus DB laden # Ausgeschlossene Quellen dynamisch aus DB laden
excluded_sources = await self._get_excluded_sources() excluded_sources = await self._get_excluded_sources(user_id=user_id)
# Ausgeschlossene Quellen filtern # Ausgeschlossene Quellen filtern
filtered = [] filtered = []
@@ -317,14 +317,23 @@ class ResearcherAgent:
logger.error(f"Recherche-Fehler: {e}") logger.error(f"Recherche-Fehler: {e}")
return [], None return [], None
async def _get_excluded_sources(self) -> list[str]: async def _get_excluded_sources(self, user_id: int = None) -> list[str]:
"""Lädt ausgeschlossene Quellen aus der Datenbank.""" """Laedt ausgeschlossene Quellen (global + per-User)."""
try: try:
from source_rules import get_source_rules from source_rules import get_source_rules, get_user_excluded_domains
rules = await get_source_rules() rules = await get_source_rules()
return rules.get("excluded_domains", []) excluded = list(rules.get("excluded_domains", []))
# User-spezifische Ausschluesse hinzufuegen
if user_id:
user_excluded = await get_user_excluded_domains(user_id)
for domain in user_excluded:
if domain not in excluded:
excluded.append(domain)
return excluded
except Exception as e: except Exception as e:
logger.warning(f"Fallback auf config.py für Excluded Sources: {e}") logger.warning(f"Fallback auf config.py fuer Excluded Sources: {e}")
from config import EXCLUDED_SOURCES from config import EXCLUDED_SOURCES
return list(EXCLUDED_SOURCES) return list(EXCLUDED_SOURCES)

Datei anzeigen

@@ -183,6 +183,15 @@ CREATE TABLE IF NOT EXISTS article_locations (
); );
CREATE INDEX IF NOT EXISTS idx_article_locations_incident ON article_locations(incident_id); CREATE INDEX IF NOT EXISTS idx_article_locations_incident ON article_locations(incident_id);
CREATE INDEX IF NOT EXISTS idx_article_locations_article ON article_locations(article_id); CREATE INDEX IF NOT EXISTS idx_article_locations_article ON article_locations(article_id);
CREATE TABLE IF NOT EXISTS user_excluded_domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
domain TEXT NOT NULL,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, domain)
);
""" """

Datei anzeigen

@@ -26,7 +26,7 @@ class RSSParser:
cleaned = [w for w in words if not w.isdigit()] cleaned = [w for w in words if not w.isdigit()]
return cleaned if cleaned else words return cleaned if cleaned else words
async def search_feeds(self, search_term: str, international: bool = True, tenant_id: int = None, keywords: list[str] | None = None) -> list[dict]: async def search_feeds(self, search_term: str, international: bool = True, tenant_id: int = None, keywords: list[str] | None = None, user_id: int = None) -> list[dict]:
"""Durchsucht RSS-Feeds nach einem Suchbegriff. """Durchsucht RSS-Feeds nach einem Suchbegriff.
Args: Args:
@@ -50,6 +50,19 @@ class RSSParser:
rss_feeds = await self._get_rss_feeds(tenant_id=tenant_id) rss_feeds = await self._get_rss_feeds(tenant_id=tenant_id)
# User-spezifische Ausschluesse anwenden
if user_id:
try:
from source_rules import get_user_excluded_domains
user_excluded = await get_user_excluded_domains(user_id)
if user_excluded:
for cat in rss_feeds:
rss_feeds[cat] = [f for f in rss_feeds[cat]
if not any(excl in (f.get("url", "") + f.get("name", "")).lower()
for excl in user_excluded)]
except Exception as e:
logger.warning(f"User-Ausschluesse konnten nicht geladen werden: {e}")
# Feed-Kategorien filtern # Feed-Kategorien filtern
if international: if international:
categories = rss_feeds.keys() categories = rss_feeds.keys()

Datei anzeigen

@@ -124,6 +124,7 @@ class SourceResponse(BaseModel):
article_count: int = 0 article_count: int = 0
last_seen_at: Optional[str] = None last_seen_at: Optional[str] = None
created_at: str created_at: str
is_global: bool = False
# Source Discovery # Source Discovery

Datei anzeigen

@@ -550,7 +550,7 @@ async def trigger_refresh(
await _check_incident_access(db, incident_id, current_user["id"], tenant_id) await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
from agents.orchestrator import orchestrator from agents.orchestrator import orchestrator
enqueued = await orchestrator.enqueue_refresh(incident_id) enqueued = await orchestrator.enqueue_refresh(incident_id, user_id=current_user["id"])
if not enqueued: if not enqueued:
return {"status": "skipped", "incident_id": incident_id} return {"status": "skipped", "incident_id": incident_id}

Datei anzeigen

@@ -57,7 +57,12 @@ async def list_sources(
query += " ORDER BY source_type, category, name" query += " ORDER BY source_type, category, name"
cursor = await db.execute(query, params) cursor = await db.execute(query, params)
rows = await cursor.fetchall() rows = await cursor.fetchall()
return [dict(row) for row in rows] results = []
for row in rows:
d = dict(row)
d["is_global"] = d.get("tenant_id") is None
results.append(d)
return results
@router.get("/stats") @router.get("/stats")
@@ -285,64 +290,54 @@ async def rediscover_existing_endpoint(
raise HTTPException(status_code=500, detail="Rediscovery fehlgeschlagen") raise HTTPException(status_code=500, detail="Rediscovery fehlgeschlagen")
@router.get("/my-exclusions")
async def get_my_exclusions(
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Gibt die vom aktuellen User ausgeschlossenen Domains zurück."""
user_id = current_user["id"]
cursor = await db.execute(
"SELECT domain, notes, created_at FROM user_excluded_domains WHERE user_id = ? ORDER BY domain",
(user_id,),
)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@router.post("/block-domain") @router.post("/block-domain")
async def block_domain( async def block_domain(
data: DomainActionRequest, data: DomainActionRequest,
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency), db: aiosqlite.Connection = Depends(db_dependency),
): ):
"""Domain ausschließen: Alle Feeds deaktivieren + excluded-Eintrag anlegen.""" """Domain fuer den aktuellen User ausschließen (per-User, nicht org-weit)."""
tenant_id = current_user.get("tenant_id") user_id = current_user["id"]
domain = data.domain.lower().strip() domain = data.domain.lower().strip()
username = current_user["username"]
# Pruefen ob bereits ausgeschlossen
cursor = await db.execute( cursor = await db.execute(
"SELECT added_by FROM sources WHERE LOWER(domain) = ? AND source_type != 'excluded' AND status = 'active' AND (tenant_id IS NULL OR tenant_id = ?)", "SELECT id FROM user_excluded_domains WHERE user_id = ? AND domain = ?",
(domain, tenant_id), (user_id, domain),
)
affected = await cursor.fetchall()
for row in affected:
ab = row["added_by"] or ""
if ab != "system" and ab != username and ab != "":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Domain enthaelt Quellen anderer Nutzer",
)
cursor = await db.execute(
"UPDATE sources SET status = 'inactive' WHERE LOWER(domain) = ? AND source_type != 'excluded' AND tenant_id = ?",
(domain, tenant_id),
)
feeds_deactivated = cursor.rowcount
cursor = await db.execute(
"SELECT id FROM sources WHERE LOWER(domain) = ? AND source_type = 'excluded' AND (tenant_id IS NULL OR tenant_id = ?)",
(domain, tenant_id),
) )
existing = await cursor.fetchone() existing = await cursor.fetchone()
if existing: if existing:
excluded_id = existing["id"]
if data.notes: if data.notes:
await db.execute( await db.execute(
"UPDATE sources SET notes = ? WHERE id = ?", "UPDATE user_excluded_domains SET notes = ? WHERE id = ?",
(data.notes, excluded_id), (data.notes, existing["id"]),
) )
else: await db.commit()
cursor = await db.execute( return {"domain": domain, "status": "already_excluded"}
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id)
VALUES (?, NULL, ?, 'excluded', 'sonstige', 'active', ?, ?, ?)""",
(domain, domain, data.notes, current_user["username"], tenant_id),
)
excluded_id = cursor.lastrowid
await db.execute(
"INSERT INTO user_excluded_domains (user_id, domain, notes) VALUES (?, ?, ?)",
(user_id, domain, data.notes),
)
await db.commit() await db.commit()
return { return {"domain": domain, "status": "excluded"}
"domain": domain,
"feeds_deactivated": feeds_deactivated,
"excluded_id": excluded_id,
}
@router.post("/unblock-domain") @router.post("/unblock-domain")
@@ -351,41 +346,18 @@ async def unblock_domain(
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency), db: aiosqlite.Connection = Depends(db_dependency),
): ):
"""Domain-Ausschluss aufheben: excluded-Eintrag loeschen + Feeds reaktivieren.""" """Domain-Ausschluss fuer den aktuellen User aufheben."""
tenant_id = current_user.get("tenant_id") user_id = current_user["id"]
domain = data.domain.lower().strip() domain = data.domain.lower().strip()
cursor = await db.execute( cursor = await db.execute(
"SELECT COUNT(*) as cnt FROM sources WHERE LOWER(domain) = ? AND source_type != 'excluded' AND (tenant_id IS NULL OR tenant_id = ?)", "DELETE FROM user_excluded_domains WHERE user_id = ? AND domain = ?",
(domain, tenant_id), (user_id, domain),
) )
row = await cursor.fetchone() removed = cursor.rowcount
has_feeds = row["cnt"] > 0
if has_feeds:
await db.execute(
"DELETE FROM sources WHERE LOWER(domain) = ? AND source_type = 'excluded' AND tenant_id = ?",
(domain, tenant_id),
)
cursor = await db.execute(
"UPDATE sources SET status = 'active' WHERE LOWER(domain) = ? AND source_type != 'excluded' AND tenant_id = ?",
(domain, tenant_id),
)
feeds_reactivated = cursor.rowcount
else:
await db.execute(
"""UPDATE sources SET source_type = 'web_source', status = 'active', notes = 'Ausschluss aufgehoben'
WHERE LOWER(domain) = ? AND source_type = 'excluded' AND (tenant_id IS NULL OR tenant_id = ?)""",
(domain, tenant_id),
)
feeds_reactivated = 0
await db.commit() await db.commit()
return { return {"domain": domain, "removed": removed > 0}
"domain": domain,
"feeds_reactivated": feeds_reactivated,
}
@router.delete("/domain/{domain}") @router.delete("/domain/{domain}")

Datei anzeigen

@@ -661,6 +661,24 @@ async def get_feeds_with_metadata(tenant_id: int = None) -> list[dict]:
await db.close() await db.close()
async def get_user_excluded_domains(user_id: int) -> list[str]:
"""Laedt die vom User ausgeschlossenen Domains."""
from database import get_db
db = await get_db()
try:
cursor = await db.execute(
"SELECT domain FROM user_excluded_domains WHERE user_id = ?",
(user_id,),
)
return [row[0] for row in await cursor.fetchall()]
except Exception as e:
logger.warning(f"Fehler beim Laden der User-Ausschluesse: {e}")
return []
finally:
await db.close()
async def get_source_rules(tenant_id: int = None) -> dict: async def get_source_rules(tenant_id: int = None) -> dict:
"""Liest Quellen-Konfiguration aus DB (global + org-spezifisch). """Liest Quellen-Konfiguration aus DB (global + org-spezifisch).

Datei anzeigen

@@ -3437,6 +3437,16 @@ a:hover {
gap: var(--sp-xs); gap: var(--sp-xs);
} }
/* Grundquelle-Badge */
.source-global-badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 3px;
background: var(--bg-tertiary, #2a2a2a);
color: var(--text-secondary, #888);
white-space: nowrap;
}
/* Ausgeschlossene Domain */ /* Ausgeschlossene Domain */
.source-group-header.excluded { .source-group-header.excluded {
grid-template-columns: 1fr auto auto; grid-template-columns: 1fr auto auto;

Datei anzeigen

@@ -427,7 +427,7 @@
<option value="">Alle Typen</option> <option value="">Alle Typen</option>
<option value="rss_feed">RSS-Feed</option> <option value="rss_feed">RSS-Feed</option>
<option value="web_source">Web-Quelle</option> <option value="web_source">Web-Quelle</option>
<option value="excluded">Ausgeschlossen</option> <option value="excluded">Von mir ausgeschlossen</option>
</select> </select>
<label for="sources-filter-category" class="sr-only">Kategorie filtern</label> <label for="sources-filter-category" class="sr-only">Kategorie filtern</label>
<select id="sources-filter-category" class="timeline-filter-select" onchange="App.filterSources()"> <select id="sources-filter-category" class="timeline-filter-select" onchange="App.filterSources()">

Datei anzeigen

@@ -152,6 +152,10 @@ const API = {
return this._request('POST', '/sources/discover-multi', { url }); return this._request('POST', '/sources/discover-multi', { url });
}, },
getMyExclusions() {
return this._request('GET', '/sources/my-exclusions');
},
blockDomain(domain, notes) { blockDomain(domain, notes) {
return this._request('POST', '/sources/block-domain', { domain, notes }); return this._request('POST', '/sources/block-domain', { domain, notes });
}, },

Datei anzeigen

@@ -425,7 +425,7 @@ const App = {
_currentUsername: '', _currentUsername: '',
_allSources: [], _allSources: [],
_sourcesOnly: [], _sourcesOnly: [],
_blacklistOnly: [], _myExclusions: [], // [{domain, notes, created_at}]
_expandedGroups: new Set(), _expandedGroups: new Set(),
_editingSourceId: null, _editingSourceId: null,
_timelineFilter: 'all', _timelineFilter: 'all',
@@ -2173,13 +2173,14 @@ const App = {
async loadSources() { async loadSources() {
try { try {
const [sources, stats] = await Promise.all([ const [sources, stats, myExclusions] = await Promise.all([
API.listSources(), API.listSources(),
API.getSourceStats(), API.getSourceStats(),
API.getMyExclusions(),
]); ]);
this._allSources = sources; this._allSources = sources;
this._sourcesOnly = sources.filter(s => s.source_type !== 'excluded'); this._sourcesOnly = sources.filter(s => s.source_type !== 'excluded');
this._blacklistOnly = sources.filter(s => s.source_type === 'excluded'); this._myExclusions = myExclusions || [];
this.renderSourceStats(stats); this.renderSourceStats(stats);
this.renderSourceList(); this.renderSourceList();
@@ -2194,7 +2195,7 @@ const App = {
const rss = stats.by_type.rss_feed || { count: 0, articles: 0 }; const rss = stats.by_type.rss_feed || { count: 0, articles: 0 };
const web = stats.by_type.web_source || { count: 0, articles: 0 }; const web = stats.by_type.web_source || { count: 0, articles: 0 };
const excluded = this._blacklistOnly.length; const excluded = this._myExclusions.length;
bar.innerHTML = ` bar.innerHTML = `
<span class="sources-stat-item"><span class="sources-stat-value">${rss.count}</span> RSS-Feeds</span> <span class="sources-stat-item"><span class="sources-stat-value">${rss.count}</span> RSS-Feeds</span>
@@ -2221,12 +2222,12 @@ const App = {
const excludedDomains = new Set(); const excludedDomains = new Set();
const excludedNotes = {}; const excludedNotes = {};
// Blacklist-Domains sammeln // User-Ausschlüsse sammeln
this._blacklistOnly.forEach(s => { this._myExclusions.forEach(e => {
const domain = (s.domain || s.name || '').toLowerCase(); const domain = (e.domain || '').toLowerCase();
if (domain) { if (domain) {
excludedDomains.add(domain); excludedDomains.add(domain);
excludedNotes[domain] = s.notes || ''; excludedNotes[domain] = e.notes || '';
} }
}); });
@@ -2238,10 +2239,10 @@ const App = {
}); });
// Ausgeschlossene Domains die keine Feeds haben auch als Gruppe // Ausgeschlossene Domains die keine Feeds haben auch als Gruppe
this._blacklistOnly.forEach(s => { this._myExclusions.forEach(e => {
const domain = (s.domain || s.name || '').toLowerCase(); const domain = (e.domain || '').toLowerCase();
if (domain && !groups.has(domain)) { if (domain && !groups.has(domain)) {
groups.set(domain, [s]); groups.set(domain, []);
} }
}); });
@@ -2249,6 +2250,7 @@ const App = {
let filteredGroups = []; let filteredGroups = [];
for (const [domain, feeds] of groups) { for (const [domain, feeds] of groups) {
const isExcluded = excludedDomains.has(domain); const isExcluded = excludedDomains.has(domain);
const isGlobal = feeds.some(f => f.is_global);
// Typ-Filter // Typ-Filter
if (typeFilter === 'excluded' && !isExcluded) continue; if (typeFilter === 'excluded' && !isExcluded) continue;
@@ -2271,7 +2273,7 @@ const App = {
if (!groupText.includes(search)) continue; if (!groupText.includes(search)) continue;
} }
filteredGroups.push({ domain, feeds, isExcluded }); filteredGroups.push({ domain, feeds, isExcluded, isGlobal });
} }
if (filteredGroups.length === 0) { if (filteredGroups.length === 0) {
@@ -2286,7 +2288,7 @@ const App = {
}); });
list.innerHTML = filteredGroups.map(g => list.innerHTML = filteredGroups.map(g =>
UI.renderSourceGroup(g.domain, g.feeds, g.isExcluded, excludedNotes[g.domain] || '') UI.renderSourceGroup(g.domain, g.feeds, g.isExcluded, excludedNotes[g.domain] || '', g.isGlobal)
).join(''); ).join('');
// Erweiterte Gruppen wiederherstellen // Erweiterte Gruppen wiederherstellen
@@ -2440,11 +2442,11 @@ const App = {
* Domain direkt ausschließen (aus der Gruppenliste). * Domain direkt ausschließen (aus der Gruppenliste).
*/ */
async blockDomainDirect(domain) { async blockDomainDirect(domain) {
if (!await confirmDialog(`"${domain}" wirklich ausschließen? Alle Feeds dieser Domain werden deaktiviert.`)) return; if (!await confirmDialog(`"${domain}" wirklich ausschließen? Artikel dieser Domain werden bei deinen Recherchen ignoriert.`)) return;
try { try {
await API.blockDomain(domain); await API.blockDomain(domain);
UI.showToast(`${domain} gesperrt.`, 'success'); UI.showToast(`${domain} ausgeschlossen.`, 'success');
await this.loadSources(); await this.loadSources();
this.updateSidebarStats(); this.updateSidebarStats();
} catch (err) { } catch (err) {
@@ -2560,7 +2562,7 @@ const App = {
// Prüfen ob Domain ausgeschlossen ist // Prüfen ob Domain ausgeschlossen ist
const inputDomain = url.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0].toLowerCase(); const inputDomain = url.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0].toLowerCase();
const isBlocked = inputDomain && this._blacklistOnly.some(s => (s.domain || '').toLowerCase() === inputDomain); const isBlocked = inputDomain && this._myExclusions.some(e => (e.domain || '').toLowerCase() === inputDomain);
if (isBlocked) { if (isBlocked) {
if (!await confirmDialog(`"${inputDomain}" ist ausgeschlossen. Trotzdem hinzufügen? Der Ausschluss wird dabei aufgehoben.`)) return; if (!await confirmDialog(`"${inputDomain}" ist ausgeschlossen. Trotzdem hinzufügen? Der Ausschluss wird dabei aufgehoben.`)) return;

Datei anzeigen

@@ -529,7 +529,7 @@ const UI = {
/** /**
* Domain-Gruppe rendern (aufklappbar mit Feeds). * Domain-Gruppe rendern (aufklappbar mit Feeds).
*/ */
renderSourceGroup(domain, feeds, isExcluded, excludedNotes) { renderSourceGroup(domain, feeds, isExcluded, excludedNotes, isGlobal) {
const catLabel = this._categoryLabels[feeds[0]?.category] || feeds[0]?.category || ''; const catLabel = this._categoryLabels[feeds[0]?.category] || feeds[0]?.category || '';
const feedCount = feeds.filter(f => f.source_type !== 'excluded').length; const feedCount = feeds.filter(f => f.source_type !== 'excluded').length;
const hasMultiple = feedCount > 1; const hasMultiple = feedCount > 1;
@@ -547,7 +547,6 @@ const UI = {
<span class="source-excluded-badge">Ausgeschlossen</span> <span class="source-excluded-badge">Ausgeschlossen</span>
<div class="source-group-actions"> <div class="source-group-actions">
<button class="btn btn-small btn-secondary" onclick="App.unblockDomain('${escapedDomain}')">Ausschluss aufheben</button> <button class="btn btn-small btn-secondary" onclick="App.unblockDomain('${escapedDomain}')">Ausschluss aufheben</button>
<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">&times;</button>
</div> </div>
</div> </div>
</div>`; </div>`;
@@ -571,8 +570,8 @@ const UI = {
<span class="source-feed-name">${this.escape(feed.name)}</span> <span class="source-feed-name">${this.escape(feed.name)}</span>
<span class="source-type-badge type-${feed.source_type}">${typeLabel}</span> <span class="source-type-badge type-${feed.source_type}">${typeLabel}</span>
<span class="source-feed-url" title="${this.escape(feed.url || '')}">${this.escape(urlDisplay)}</span> <span class="source-feed-url" title="${this.escape(feed.url || '')}">${this.escape(urlDisplay)}</span>
<button class="source-edit-btn" onclick="App.editSource(${feed.id})" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</button> ${!feed.is_global ? `<button class="source-edit-btn" onclick="App.editSource(${feed.id})" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</button>
<button class="source-delete-btn" onclick="App.deleteSingleFeed(${feed.id})" title="Löschen" aria-label="Löschen">&times;</button> <button class="source-delete-btn" onclick="App.deleteSingleFeed(${feed.id})" title="Löschen" aria-label="Löschen">&times;</button>` : '<span class="source-global-badge">Grundquelle</span>'}
</div>`; </div>`;
}); });
feedRows += '</div>'; feedRows += '</div>';
@@ -591,9 +590,9 @@ const UI = {
<span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span> <span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span>
${feedCountBadge} ${feedCountBadge}
<div class="source-group-actions" onclick="event.stopPropagation()"> <div class="source-group-actions" onclick="event.stopPropagation()">
${!hasMultiple && feeds[0]?.id ? `<button class="source-edit-btn" onclick="App.editSource(${feeds[0].id})" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</button>` : ''} ${!isGlobal && !hasMultiple && feeds[0]?.id ? `<button class="source-edit-btn" onclick="App.editSource(${feeds[0].id})" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</button>` : ''}
<button class="btn btn-small btn-secondary" onclick="App.blockDomainDirect('${escapedDomain}')">Ausschließen</button> <button class="btn btn-small btn-secondary" onclick="App.blockDomainDirect('${escapedDomain}')">Ausschließen</button>
<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">&times;</button> ${!isGlobal ? `<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">&times;</button>` : ''}
</div> </div>
</div> </div>
${feedRows} ${feedRows}