- migrations/2026-05-09e_fetch_strategy.py NEU: ALTER TABLE sources ADD COLUMN fetch_strategy. Pre-flagging fuer FT/WSJ/NZZ etc. (paywall) und Rheinische Post/Verfassungsschutz (googlebot). - shared/services/source_health.py: gesynct vom Monitor (Phase-18-Code mit Retry-Logik + Strategien default/googlebot/paywall/skip). - routers/sources.py: GlobalSourceCreate/Update um fetch_strategy (Pattern-Validation), SOURCE_UPDATE_COLUMNS + INSERT erweitert. - dashboard.html: Edit-Modal hat jetzt Dropdown sourceFetchStrategy. - sources.js: laedt + sendet fetch_strategy mit. Cache-Buster 20260509c -> 20260509d.
655 Zeilen
26 KiB
JavaScript
655 Zeilen
26 KiB
JavaScript
/* 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 = '<div class="text-muted">Lade...</div>';
|
|
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 =
|
|
`<div class="text-danger">Audit konnte nicht geladen werden: ${esc(err.message || String(err))}</div>`;
|
|
}
|
|
}
|
|
|
|
function renderAuditEntries(items) {
|
|
const c = document.getElementById("auditContent");
|
|
if (!items.length) {
|
|
c.innerHTML = '<div class="text-muted">Keine Audit-Einträge für diese Quelle.</div>';
|
|
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 `
|
|
<div class="audit-entry">
|
|
<div class="audit-entry-head">
|
|
<span class="audit-entry-action audit-action-${esc(e.action)}">${esc(e.action)}</span>
|
|
<span class="audit-entry-meta">${meta}</span>
|
|
</div>
|
|
${hasDiff ? `<details class="audit-entry-detail">
|
|
<summary>Diff anzeigen</summary>
|
|
<pre>${esc(diffPayload)}</pre>
|
|
</details>` : ""}
|
|
</div>
|
|
`;
|
|
}).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(`<span class="sources-stat-item"><span class="sources-stat-value">${stats.total || 0}</span> Quellen gesamt</span>`);
|
|
for (const t of types) {
|
|
if (t.key === "excluded") continue;
|
|
const v = stats.by_type[t.key] || { count: 0, articles: 0 };
|
|
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${v.count}</span> ${esc(t.label)}</span>`);
|
|
}
|
|
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${stats.total_articles || 0}</span> Artikel</span>`);
|
|
|
|
const h = stats.health || { errors: 0, warnings: 0, ok: 0 };
|
|
if (h.errors) parts.push(`<span class="sources-stat-item health-error"><span class="sources-stat-value">${h.errors}</span> Fehler</span>`);
|
|
if (h.warnings) parts.push(`<span class="sources-stat-item health-warning"><span class="sources-stat-value">${h.warnings}</span> Warnungen</span>`);
|
|
if (h.ok) parts.push(`<span class="sources-stat-item health-ok"><span class="sources-stat-value">${h.ok}</span> OK</span>`);
|
|
|
|
bar.innerHTML = parts.join("");
|
|
}
|
|
|
|
function renderGlobalSources(sources) {
|
|
const tbody = document.getElementById("globalSourceTable");
|
|
const cols = 13;
|
|
if (sources.length === 0) {
|
|
tbody.innerHTML = `<tr><td colspan="${cols}" class="text-muted">Keine Grundquellen</td></tr>`;
|
|
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 += `<tr class="cat-header-row"><td colspan="${cols}"><span class="cat-header-label">${esc(label)}</span><span class="cat-header-count">${count}</span></td></tr>`;
|
|
grouped[cat].forEach((s) => {
|
|
const hasNotes = s.notes && s.notes.trim();
|
|
const infoBtn = hasNotes
|
|
? `<span class="src-info-toggle" onclick="toggleSourceInfo(${s.id})" title="Info einblenden">ⓘ</span>`
|
|
: '';
|
|
const notesRow = hasNotes
|
|
? `<tr class="src-notes-row" id="notes-${s.id}" style="display:none;"><td colspan="${cols}" class="src-notes-cell">${esc(s.notes)}</td></tr>`
|
|
: '';
|
|
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 += `<tr>
|
|
<td>${infoBtn} ${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>${esc(s.domain || "-")}</td>
|
|
<td>${typeLabel(s.source_type)}</td>
|
|
<td class="text-right">${s.article_count || 0}</td>
|
|
<td class="${(s.articles_30d || 0) === 0 ? "activity-cell activity-zero" : "activity-cell"}" title="7 Tage / 30 Tage"><strong>${s.articles_7d || 0}</strong> / ${s.articles_30d || 0}</td>
|
|
<td class="text-right"><span class="${(s.tenant_excluded_count || 0) === 0 ? "exclude-badge exclude-zero" : "exclude-badge"}">${s.tenant_excluded_count || 0}</span></td>
|
|
<td class="text-secondary">${esc(s.language || "-")}</td>
|
|
<td class="text-secondary" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.bias || "")}">${esc(s.bias || "-")}</td>
|
|
<td class="text-secondary">${lastSeen}</td>
|
|
<td><span class="health-badge ${hsClass}">${hsLabel}</span></td>
|
|
<td><span class="badge badge-${s.status === "active" ? "active" : "inactive"}">${s.status === "active" ? "Aktiv" : "Inaktiv"}</span></td>
|
|
<td>
|
|
<button class="btn btn-secondary btn-small" onclick="editGlobalSource(${s.id})">Bearbeiten</button>
|
|
<button class="btn btn-secondary btn-small" onclick="showSourceAudit(${s.id}, '${esc(s.name)}')">Audit</button>
|
|
<button class="btn btn-danger btn-small" onclick="confirmDeleteGlobalSource(${s.id}, '${esc(s.name)}')">Löschen</button>
|
|
</td>
|
|
</tr>${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 = `<tr><td colspan="${cols}" class="text-muted">Keine Kundenquellen</td></tr>`;
|
|
document.getElementById("tenantSourceCount").textContent = `0 / ${tenantSourcesCache.length} Kundenquellen`;
|
|
updateBulkButton();
|
|
return;
|
|
}
|
|
tbody.innerHTML = sources.map((s) => {
|
|
const checked = tenantSelected.has(s.id) ? "checked" : "";
|
|
return `
|
|
<tr>
|
|
<td><input type="checkbox" class="tenant-select" data-id="${s.id}" ${checked} onchange="toggleTenantSelect(${s.id}, this.checked)"></td>
|
|
<td>${esc(s.name)}</td>
|
|
<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>${typeLabel(s.source_type)}</td>
|
|
<td>${categoryLabel(s.category)}</td>
|
|
<td>${esc(s.org_name || "-")}</td>
|
|
<td class="text-secondary">${esc(s.language || "-")}</td>
|
|
<td class="text-secondary" style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.bias || "")}">${esc(s.bias || "-")}</td>
|
|
<td>${esc(s.added_by || "-")}</td>
|
|
<td>
|
|
<button class="btn btn-primary btn-small" onclick="promoteSource(${s.id}, '${esc(s.name)}')">Übernehmen</button>
|
|
</td>
|
|
</tr>`;
|
|
}).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 = '<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) {
|
|
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);
|
|
}
|
|
}
|