/* Grundquellen & Kundenquellen Management */
"use strict";
let globalSourcesCache = [];
let tenantSourcesCache = [];
// Phase 3c: Tenant-Tab State
let tenantFilters = { search: "", type: "", category: "", org: "", language: "" };
let tenantSort = { field: "org_name", asc: true };
let tenantSelected = new Set();
let editingSourceId = null;
let globalSortField = "category";
let globalSortAsc = true;
// CATEGORY_LABELS jetzt global (aus app.js loadMeta)
// TYPE_LABELS jetzt global (aus app.js loadMeta)
// --- Init ---
document.addEventListener("DOMContentLoaded", () => {
setupSourceSubTabs();
setupSourceForms();
// Beim Tab-Wechsel auf "Quellen" laden
document.querySelectorAll('.nav-tab[data-section="sources"]').forEach((tab) => {
tab.addEventListener("click", () => loadGlobalSources());
});
});
function setupSourceSubTabs() {
document.querySelectorAll("#sourceSubTabs .nav-tab").forEach((tab) => {
tab.addEventListener("click", () => {
const subtab = tab.dataset.subtab;
document.querySelectorAll("#sourceSubTabs .nav-tab").forEach((t) => t.classList.remove("active"));
tab.classList.add("active");
document.querySelectorAll("#sec-sources > .section").forEach((s) => s.classList.remove("active"));
document.getElementById("sub-" + subtab).classList.add("active");
if (subtab === "global-sources") loadGlobalSources();
else if (subtab === "tenant-sources") loadTenantSources();
else if (subtab === "source-health") loadHealthData();
});
});
}
// --- Grundquellen ---
async function loadGlobalSources() {
try {
// Kategorien/Typen-Dropdowns aus META befüllen (idempotent)
if (window.META && window.META.categories && window.META.categories.length) {
populateSelect(document.getElementById("globalFilterCategory"), window.META.categories, "Alle Kategorien");
populateSelect(document.getElementById("globalFilterType"),
(window.META.types || []).filter(t => t.key !== "excluded"), "Alle Typen");
}
const [list, stats, languages] = await Promise.all([
API.get("/api/sources/global"),
API.get("/api/sources/global/stats"),
API.get("/api/sources/global/languages").catch(() => []),
]);
globalSourcesCache = list;
populateSelect(
document.getElementById("globalFilterLanguage"),
(languages || []).map(l => ({ key: l, label: l })),
"Alle Sprachen",
);
populateSelect(
document.getElementById("tenantFilterLanguage"),
(languages || []).map(l => ({ key: l, label: l })),
"Alle Sprachen",
);
// datalist fuer Edit-Modal
const dl = document.getElementById("languageSuggestions");
if (dl) {
dl.innerHTML = "";
(languages || []).forEach(l => {
const o = document.createElement("option");
o.value = l;
dl.appendChild(o);
});
}
renderGlobalStats(stats);
renderGlobalSources(globalSourcesCache);
} catch (err) {
console.error("Grundquellen laden fehlgeschlagen:", err);
}
}
async function showSourceAudit(sourceId, sourceName) {
document.getElementById("auditTitle").textContent = `Audit-Spur: ${sourceName}`;
document.getElementById("auditContent").innerHTML = '
Lade...
';
openModal("modalAudit");
try {
const res = await API.get(`/api/audit-log?resource_type=source&resource_id=${sourceId}&limit=50`);
renderAuditEntries(res.items || []);
} catch (err) {
document.getElementById("auditContent").innerHTML =
`Audit konnte nicht geladen werden: ${esc(err.message || String(err))}
`;
}
}
function renderAuditEntries(items) {
const c = document.getElementById("auditContent");
if (!items.length) {
c.innerHTML = 'Keine Audit-Einträge für diese Quelle.
';
return;
}
c.innerHTML = items.map(e => {
const meta = `${formatDateTime(e.ts)} · ${esc(e.admin_username || "-")} · ${esc(e.ip || "-")}`;
const hasDiff = (e.before && Object.keys(e.before).length) || (e.after && Object.keys(e.after).length);
const diffPayload = JSON.stringify({ before: e.before, after: e.after }, null, 2);
return `
${esc(e.action)}
${meta}
${hasDiff ? `
Diff anzeigen
${esc(diffPayload)}
` : ""}
`;
}).join("");
}
function formatDateTime(iso) {
if (!iso) return "-";
try {
const d = new Date(iso);
return d.toLocaleString("de-DE", {
day: "2-digit", month: "2-digit", year: "numeric",
hour: "2-digit", minute: "2-digit",
});
} catch { return iso; }
}
function renderGlobalStats(stats) {
const bar = document.getElementById("globalStatsBar");
if (!bar) return;
if (!stats || !stats.by_type) { bar.innerHTML = ""; return; }
const types = window.META && window.META.types ? window.META.types : [];
const parts = [];
parts.push(`${stats.total || 0} Quellen gesamt`);
for (const t of types) {
if (t.key === "excluded") continue;
const v = stats.by_type[t.key] || { count: 0, articles: 0 };
parts.push(`${v.count} ${esc(t.label)}`);
}
parts.push(`${stats.total_articles || 0} Artikel`);
const h = stats.health || { errors: 0, warnings: 0, ok: 0 };
if (h.errors) parts.push(`${h.errors} Fehler`);
if (h.warnings) parts.push(`${h.warnings} Warnungen`);
if (h.ok) parts.push(`${h.ok} OK`);
bar.innerHTML = parts.join("");
}
function renderGlobalSources(sources) {
const tbody = document.getElementById("globalSourceTable");
const cols = 13;
if (sources.length === 0) {
tbody.innerHTML = `| Keine Grundquellen |
`;
return;
}
// Nach Kategorie gruppieren (Reihenfolge beibehalten)
const grouped = {};
const order = [];
sources.forEach((s) => {
const cat = s.category || "sonstige";
if (!grouped[cat]) { grouped[cat] = []; order.push(cat); }
grouped[cat].push(s);
});
let html = "";
order.forEach((cat) => {
const label = CATEGORY_LABELS[cat] || cat;
const count = grouped[cat].length;
html += ``;
grouped[cat].forEach((s) => {
const hasNotes = s.notes && s.notes.trim();
const infoBtn = hasNotes
? `ⓘ`
: '';
const notesRow = hasNotes
? `| ${esc(s.notes)} |
`
: '';
const lastSeen = s.last_seen_at ? formatDate(s.last_seen_at) : "-";
const hs = s.health_status || "unknown";
const hsLabel = { error: "Fehler", warning: "Warnung", ok: "OK", unknown: "—" }[hs];
const hsClass = "health-badge-" + (hs === "unknown" ? "unknown" : hs);
html += `
| ${infoBtn} ${esc(s.name)} |
${esc(s.url || "-")} |
${esc(s.domain || "-")} |
${typeLabel(s.source_type)} |
${s.article_count || 0} |
${s.articles_7d || 0} / ${s.articles_30d || 0} |
${s.tenant_excluded_count || 0} |
${esc(s.language || "-")} |
${esc(s.bias || "-")} |
${lastSeen} |
${hsLabel} |
${s.status === "active" ? "Aktiv" : "Inaktiv"} |
|
${notesRow}`;
});
});
tbody.innerHTML = html;
document.getElementById("globalSourceCount").textContent = `${sources.length} Grundquellen`;
// Sort-Icons aktualisieren
document.querySelectorAll("th.sortable .sort-icon").forEach(el => el.textContent = "");
const activeHeader = document.querySelector(`th.sortable[data-sort="${globalSortField}"] .sort-icon`);
if (activeHeader) activeHeader.textContent = globalSortAsc ? " ▲" : " ▼";
}
// Filter + Sortierung
document.addEventListener("DOMContentLoaded", () => {
const el = document.getElementById("globalSourceSearch");
if (el) {
el.addEventListener("input", () => filterGlobalSources());
}
});
function filterGlobalSources() {
const q = (document.getElementById("globalSourceSearch")?.value || "").toLowerCase();
const typeFilter = document.getElementById("globalFilterType")?.value || "";
const catFilter = document.getElementById("globalFilterCategory")?.value || "";
const statusFilter = document.getElementById("globalFilterStatus")?.value || "";
const langFilter = document.getElementById("globalFilterLanguage")?.value || "";
let filtered = globalSourcesCache.filter((s) => {
if (q && !(s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.url || "").toLowerCase().includes(q) || (s.bias || "").toLowerCase().includes(q))) return false;
if (typeFilter && s.source_type !== typeFilter) return false;
if (catFilter && s.category !== catFilter) return false;
if (statusFilter && s.status !== statusFilter) return false;
if (langFilter && s.language !== langFilter) return false;
return true;
});
// Sortierung anwenden
filtered.sort((a, b) => {
let va = a[globalSortField] ?? "";
let vb = b[globalSortField] ?? "";
const NUMERIC_FIELDS = ["article_count", "articles_7d", "articles_30d", "tenant_excluded_count"];
if (NUMERIC_FIELDS.includes(globalSortField)) {
va = parseInt(va) || 0;
vb = parseInt(vb) || 0;
return globalSortAsc ? va - vb : vb - va;
}
va = String(va).toLowerCase();
vb = String(vb).toLowerCase();
const cmp = va.localeCompare(vb, "de");
return globalSortAsc ? cmp : -cmp;
});
renderGlobalSources(filtered);
}
function sortGlobalSources(field) {
if (globalSortField === field) {
globalSortAsc = !globalSortAsc;
} else {
globalSortField = field;
globalSortAsc = true;
}
filterGlobalSources();
}
// --- Grundquelle erstellen/bearbeiten ---
function openNewGlobalSource() {
editingSourceId = null;
document.getElementById("sourceModalTitle").textContent = "Neue Grundquelle";
document.getElementById("sourceForm").reset();
openModal("modalSource");
}
function editGlobalSource(id) {
const s = globalSourcesCache.find((x) => x.id === id);
if (!s) return;
editingSourceId = id;
document.getElementById("sourceModalTitle").textContent = "Grundquelle bearbeiten";
document.getElementById("sourceName").value = s.name;
document.getElementById("sourceUrl").value = s.url || "";
document.getElementById("sourceDomain").value = s.domain || "";
document.getElementById("sourceType").value = s.source_type;
document.getElementById("sourceCategory").value = s.category;
document.getElementById("sourceStatus").value = s.status;
document.getElementById("sourceNotes").value = s.notes || "";
document.getElementById("sourceLanguage").value = s.language || "";
document.getElementById("sourceBias").value = s.bias || "";
document.getElementById("sourceFetchStrategy").value = s.fetch_strategy || "default";
openModal("modalSource");
}
function setupSourceForms() {
document.getElementById("newGlobalSourceBtn").addEventListener("click", openNewGlobalSource);
document.getElementById("discoverSourceBtn").addEventListener("click", () => {
document.getElementById("discoverUrl").value = "";
document.getElementById("discoverStatus").style.display = "none";
document.getElementById("discoverResults").style.display = "none";
openModal("modalDiscover");
});
document.getElementById("sourceForm").addEventListener("submit", async (e) => {
e.preventDefault();
const errEl = document.getElementById("sourceError");
errEl.style.display = "none";
const body = {
name: document.getElementById("sourceName").value,
url: document.getElementById("sourceUrl").value || null,
domain: document.getElementById("sourceDomain").value || null,
source_type: document.getElementById("sourceType").value,
category: document.getElementById("sourceCategory").value,
status: document.getElementById("sourceStatus").value,
notes: document.getElementById("sourceNotes").value || null,
language: document.getElementById("sourceLanguage").value || null,
bias: document.getElementById("sourceBias").value || null,
fetch_strategy: document.getElementById("sourceFetchStrategy").value || "default",
};
try {
if (editingSourceId) {
await API.put("/api/sources/global/" + editingSourceId, body);
} else {
await API.post("/api/sources/global", body);
}
closeModal("modalSource");
loadGlobalSources();
} catch (err) {
errEl.textContent = err.message;
errEl.style.display = "block";
}
});
// Domain aus URL ableiten
document.getElementById("sourceUrl").addEventListener("blur", (e) => {
const domainField = document.getElementById("sourceDomain");
if (domainField.value) return;
try {
const url = new URL(e.target.value);
domainField.value = url.hostname.replace(/^www\./, "");
} catch (_) {}
});
}
function confirmDeleteGlobalSource(id, name) {
showConfirm(
"Grundquelle löschen",
`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) {
showToast(err.message, "error");
}
}
);
}
// --- Kundenquellen ---
async function loadTenantSources() {
try {
tenantSourcesCache = await API.get("/api/sources/tenant");
tenantSelected.clear();
populateTenantFilters();
applyTenantFilterAndSort();
} catch (err) {
console.error("Kundenquellen laden fehlgeschlagen:", err);
showToast("Kundenquellen konnten nicht geladen werden", "error");
}
}
function populateTenantFilters() {
// Typ + Kategorie aus META, Org aus Cache
if (window.META && window.META.types) {
populateSelect(document.getElementById("tenantFilterType"),
window.META.types.filter(t => t.key !== "excluded"), "Alle Typen");
}
if (window.META && window.META.categories) {
populateSelect(document.getElementById("tenantFilterCategory"),
window.META.categories, "Alle Kategorien");
}
// Org-Liste aus den Daten extrahieren (eindeutig)
const orgs = Array.from(new Set(tenantSourcesCache.map(s => s.org_name).filter(Boolean))).sort();
populateSelect(
document.getElementById("tenantFilterOrg"),
orgs.map(o => ({ key: o, label: o })),
"Alle Organisationen",
);
}
function applyTenantFilterAndSort() {
const q = (tenantFilters.search || "").toLowerCase();
let filtered = tenantSourcesCache.filter(s => {
if (q && !(
(s.name || "").toLowerCase().includes(q)
|| (s.domain || "").toLowerCase().includes(q)
|| (s.org_name || "").toLowerCase().includes(q)
|| (s.url || "").toLowerCase().includes(q)
)) return false;
if (tenantFilters.type && s.source_type !== tenantFilters.type) return false;
if (tenantFilters.category && s.category !== tenantFilters.category) return false;
if (tenantFilters.org && s.org_name !== tenantFilters.org) return false;
if (tenantFilters.language && s.language !== tenantFilters.language) return false;
return true;
});
filtered.sort((a, b) => {
const va = String(a[tenantSort.field] ?? "").toLowerCase();
const vb = String(b[tenantSort.field] ?? "").toLowerCase();
const cmp = va.localeCompare(vb, "de");
return tenantSort.asc ? cmp : -cmp;
});
renderTenantSources(filtered);
// Sort-Icons aktualisieren
document.querySelectorAll("#sub-tenant-sources th.sortable .sort-icon").forEach(el => el.textContent = "");
const active = document.querySelector(`#sub-tenant-sources th.sortable[data-sort="${tenantSort.field}"] .sort-icon`);
if (active) active.textContent = tenantSort.asc ? " \u25B2" : " \u25BC";
}
function filterTenantSources() {
tenantFilters.search = (document.getElementById("tenantSourceSearch")?.value || "").trim();
tenantFilters.type = document.getElementById("tenantFilterType")?.value || "";
tenantFilters.category = document.getElementById("tenantFilterCategory")?.value || "";
tenantFilters.org = document.getElementById("tenantFilterOrg")?.value || "";
tenantFilters.language = document.getElementById("tenantFilterLanguage")?.value || "";
applyTenantFilterAndSort();
}
function sortTenantSources(field) {
if (tenantSort.field === field) tenantSort.asc = !tenantSort.asc;
else { tenantSort.field = field; tenantSort.asc = true; }
applyTenantFilterAndSort();
}
function toggleTenantSelectAll(checked) {
document.querySelectorAll("#tenantSourceTable input.tenant-select").forEach(cb => {
cb.checked = checked;
const id = parseInt(cb.dataset.id);
if (checked) tenantSelected.add(id); else tenantSelected.delete(id);
});
updateBulkButton();
}
function toggleTenantSelect(id, checked) {
id = parseInt(id);
if (checked) tenantSelected.add(id); else tenantSelected.delete(id);
updateBulkButton();
// Header-Checkbox anpassen
const visible = document.querySelectorAll("#tenantSourceTable input.tenant-select").length;
const checkedVisible = document.querySelectorAll("#tenantSourceTable input.tenant-select:checked").length;
const all = document.getElementById("tenantSelectAll");
if (all) all.checked = visible > 0 && visible === checkedVisible;
}
function updateBulkButton() {
const btn = document.getElementById("tenantBulkPromoteBtn");
if (!btn) return;
const n = tenantSelected.size;
btn.disabled = n === 0;
btn.textContent = `Ausgewählte übernehmen (${n})`;
}
async function bulkPromoteSelected() {
if (tenantSelected.size === 0) return;
const ids = Array.from(tenantSelected);
const ok = await showConfirm(
"Ausgewählte als Grundquelle übernehmen",
`Sollen ${ids.length} Kundenquelle(n) als Grundquelle übernommen werden? Sie werden dann für alle Monitore verfügbar.`,
);
if (!ok) return;
try {
const result = await API.post("/api/sources/tenant/bulk-promote", { source_ids: ids });
let msg = `${result.promoted} übernommen`;
if (result.skipped && result.skipped.length) msg += `, ${result.skipped.length} übersprungen`;
if (result.failed && result.failed.length) msg += `, ${result.failed.length} Fehler`;
showToast(msg, result.failed && result.failed.length ? "warning" : "success");
tenantSelected.clear();
await loadTenantSources();
} catch (err) {
showToast("Bulk-Promote fehlgeschlagen: " + err.message, "error");
}
}
function renderTenantSources(sources) {
const tbody = document.getElementById("tenantSourceTable");
const cols = 10;
if (sources.length === 0) {
tbody.innerHTML = `| Keine Kundenquellen |
`;
document.getElementById("tenantSourceCount").textContent = `0 / ${tenantSourcesCache.length} Kundenquellen`;
updateBulkButton();
return;
}
tbody.innerHTML = sources.map((s) => {
const checked = tenantSelected.has(s.id) ? "checked" : "";
return `
|
${esc(s.name)} |
${esc(s.domain || "-")} |
${typeLabel(s.source_type)} |
${categoryLabel(s.category)} |
${esc(s.org_name || "-")} |
${esc(s.language || "-")} |
${esc(s.bias || "-")} |
${esc(s.added_by || "-")} |
|
`;
}).join("");
document.getElementById("tenantSourceCount").textContent = `${sources.length} / ${tenantSourcesCache.length} Kundenquellen`;
updateBulkButton();
}
// Suche Kundenquellen
document.addEventListener("DOMContentLoaded", () => {
const el = document.getElementById("tenantSourceSearch");
if (el) el.addEventListener("input", () => filterTenantSources());
});
function promoteSource(id, name) {
showConfirm(
"Zur Grundquelle machen",
`Soll "${name}" als Grundquelle übernommen werden? Sie wird dann für alle Monitore verfügbar.`,
async () => {
try {
await API.post("/api/sources/tenant/" + id + "/promote");
loadTenantSources();
} catch (err) {
showToast(err.message, "error");
}
}
);
}
// --- Discovery ---
let discoveredFeeds = [];
async function runDiscover() {
const url = document.getElementById("discoverUrl").value.trim();
if (!url) return;
const btn = document.getElementById("discoverBtn");
const statusEl = document.getElementById("discoverStatus");
const resultsEl = document.getElementById("discoverResults");
btn.disabled = true;
btn.textContent = "Suche...";
statusEl.style.display = "block";
statusEl.textContent = "Analysiere Website und suche RSS-Feeds...";
resultsEl.style.display = "none";
try {
const data = await API.post("/api/sources/discover?url=" + encodeURIComponent(url));
discoveredFeeds = data.feeds || [];
if (discoveredFeeds.length === 0 && (!data.existing || data.existing.length === 0)) {
statusEl.textContent = data.message || "Keine RSS-Feeds gefunden für " + data.domain;
return;
}
statusEl.style.display = "none";
resultsEl.style.display = "block";
// Bereits vorhandene anzeigen
const existingEl = document.getElementById("discoverExisting");
if (data.existing && data.existing.length > 0) {
existingEl.style.display = "block";
existingEl.innerHTML = 'Bereits als Grundquelle vorhanden:
' +
data.existing.map(f => '✓ ' + esc(f.name) + '
').join("");
} else {
existingEl.style.display = "none";
}
// Neue Feeds mit Checkboxen
const feedsEl = document.getElementById("discoverFeeds");
if (discoveredFeeds.length > 0) {
feedsEl.innerHTML = 'Neue Feeds gefunden (' + data.domain + ', ' + (CATEGORY_LABELS[data.category] || data.category) + '):
' +
discoveredFeeds.map((f, i) => `
`).join("");
document.getElementById("addDiscoveredBtn").style.display = "";
} else {
feedsEl.innerHTML = 'Alle Feeds dieser Domain sind bereits als Grundquellen vorhanden.
';
document.getElementById("addDiscoveredBtn").style.display = "none";
}
} catch (err) {
statusEl.textContent = "Fehler: " + err.message;
} finally {
btn.disabled = false;
btn.textContent = "Erkennen";
}
}
async function addDiscoveredFeeds() {
const checkboxes = document.querySelectorAll("#discoverFeeds input[type=checkbox]:checked");
const selected = [];
checkboxes.forEach(cb => {
const idx = parseInt(cb.dataset.idx);
if (discoveredFeeds[idx]) selected.push(discoveredFeeds[idx]);
});
if (selected.length === 0) {
showToast("Keine Feeds ausgewählt", "warning");
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();
showToast(result.added + " Grundquelle(n) hinzugefügt" + (result.skipped ? ", " + result.skipped + " übersprungen" : ""), "success");
} catch (err) {
showToast("Fehler: " + err.message, "error");
} finally {
btn.disabled = false;
btn.textContent = "Ausgewählte hinzufügen";
}
}
function toggleSourceInfo(id) {
const row = document.getElementById("notes-" + id);
if (!row) return;
const isVisible = row.style.display !== "none";
row.style.display = isVisible ? "none" : "table-row";
const mainRow = row.previousElementSibling;
if (mainRow) {
const btn = mainRow.querySelector(".src-info-toggle");
if (btn) btn.classList.toggle("active", !isVisible);
}
}