433 Zeilen
16 KiB
JavaScript
433 Zeilen
16 KiB
JavaScript
/* Grundquellen & Kundenquellen Management */
|
|
"use strict";
|
|
|
|
let globalSourcesCache = [];
|
|
let tenantSourcesCache = [];
|
|
let editingSourceId = null;
|
|
let globalSortField = "category";
|
|
let globalSortAsc = true;
|
|
|
|
const CATEGORY_LABELS = {
|
|
nachrichtenagentur: "Nachrichtenagentur",
|
|
"oeffentlich-rechtlich": "Öffentlich-Rechtlich",
|
|
qualitaetszeitung: "Qualitätszeitung",
|
|
behoerde: "Behörde",
|
|
fachmedien: "Fachmedien",
|
|
"think-tank": "Think-Tank",
|
|
international: "International",
|
|
regional: "Regional",
|
|
boulevard: "Boulevard",
|
|
sonstige: "Sonstige",
|
|
"cybercrime": "Cybercrime / Hacktivismus",
|
|
"cybercrime-leaks": "Cybercrime / Leaks",
|
|
"ukraine-russland-krieg": "Ukraine-Russland-Krieg",
|
|
"irankonflikt": "Irankonflikt",
|
|
"osint-international": "OSINT International",
|
|
"extremismus-deutschland": "Extremismus Deutschland",
|
|
"russische-staatspropaganda": "Russische Staatspropaganda",
|
|
"russische-opposition": "Russische Opposition / Exilmedien",
|
|
"syrien-nahost": "Syrien / Nahost",
|
|
};
|
|
|
|
const TYPE_LABELS = {
|
|
rss_feed: "RSS-Feed",
|
|
web_source: "Webquelle",
|
|
telegram_channel: "Telegram-Kanal",
|
|
excluded: "Ausgeschlossen",
|
|
};
|
|
|
|
// --- 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 {
|
|
globalSourcesCache = await API.get("/api/sources/global");
|
|
renderGlobalSources(globalSourcesCache);
|
|
} catch (err) {
|
|
console.error("Grundquellen laden fehlgeschlagen:", err);
|
|
}
|
|
}
|
|
|
|
function renderGlobalSources(sources) {
|
|
const tbody = document.getElementById("globalSourceTable");
|
|
const cols = 7;
|
|
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>`
|
|
: '';
|
|
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>${TYPE_LABELS[s.source_type] || s.source_type}</td>
|
|
<td class="text-right">${s.article_count || 0}</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-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 || "";
|
|
|
|
let filtered = globalSourcesCache.filter((s) => {
|
|
if (q && !(s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.url || "").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;
|
|
return true;
|
|
});
|
|
|
|
// Sortierung anwenden
|
|
filtered.sort((a, b) => {
|
|
let va = a[globalSortField] ?? "";
|
|
let vb = b[globalSortField] ?? "";
|
|
if (globalSortField === "article_count") {
|
|
va = va || 0; vb = 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 || "";
|
|
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,
|
|
};
|
|
|
|
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) {
|
|
alert(err.message);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
// --- Kundenquellen ---
|
|
async function loadTenantSources() {
|
|
try {
|
|
tenantSourcesCache = await API.get("/api/sources/tenant");
|
|
renderTenantSources(tenantSourcesCache);
|
|
} catch (err) {
|
|
console.error("Kundenquellen laden fehlgeschlagen:", err);
|
|
}
|
|
}
|
|
|
|
function renderTenantSources(sources) {
|
|
const tbody = document.getElementById("tenantSourceTable");
|
|
if (sources.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">Keine Kundenquellen</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = sources.map((s) => `
|
|
<tr>
|
|
<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>${TYPE_LABELS[s.source_type] || s.source_type}</td>
|
|
<td>${CATEGORY_LABELS[s.category] || s.category}</td>
|
|
<td>${esc(s.org_name || "-")}</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} Kundenquellen`;
|
|
}
|
|
|
|
// Suche Kundenquellen
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
const el = document.getElementById("tenantSourceSearch");
|
|
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)
|
|
);
|
|
renderTenantSources(filtered);
|
|
});
|
|
}
|
|
});
|
|
|
|
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) {
|
|
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 ausgewählt");
|
|
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";
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|