diff --git a/RELEASES.json b/RELEASES.json index 7dbffef..7153d53 100644 --- a/RELEASES.json +++ b/RELEASES.json @@ -1,4 +1,12 @@ [ + { + "version": "2026-05-22T11:09Z", + "date": "2026-05-22", + "title": "X-Konten direkt im Verwaltungsportal verwalten", + "items": [ + "X-Konten können jetzt zentral über das Verwaltungsportal angelegt und verwaltet werden." + ] + }, { "version": "2026-05-22T09:37Z", "date": "2026-05-22", diff --git a/src/routers/sources.py b/src/routers/sources.py index 458893b..55377f9 100644 --- a/src/routers/sources.py +++ b/src/routers/sources.py @@ -42,7 +42,7 @@ router = APIRouter(prefix="/api/sources", tags=["sources"]) SOURCE_UPDATE_COLUMNS = { "name", "url", "domain", "source_type", "category", "status", "notes", - "language", "bias", "fetch_strategy", + "language", "primary_language", "bias", "fetch_strategy", "political_orientation", "media_type", "reliability", "state_affiliated", "country_code", } @@ -118,11 +118,12 @@ class GlobalSourceCreate(BaseModel): name: str = Field(min_length=1, max_length=200) url: Optional[str] = None domain: Optional[str] = None - source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document)$") + source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document|x_account)$") category: str = Field(default="sonstige") status: str = Field(default="active", pattern="^(active|inactive)$") notes: Optional[str] = None language: Optional[str] = Field(default=None, max_length=100) + primary_language: Optional[str] = Field(default=None, max_length=16) bias: Optional[str] = Field(default=None, max_length=500) fetch_strategy: Optional[str] = Field(default="default", pattern="^(default|googlebot|paywall|skip)$") @@ -131,11 +132,12 @@ class GlobalSourceUpdate(BaseModel): name: Optional[str] = Field(default=None, max_length=200) url: Optional[str] = None domain: Optional[str] = None - source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document)$") + source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document|x_account)$") category: Optional[str] = None status: Optional[str] = Field(default=None, pattern="^(active|inactive)$") notes: Optional[str] = None language: Optional[str] = Field(default=None, max_length=100) + primary_language: Optional[str] = Field(default=None, max_length=16) bias: Optional[str] = Field(default=None, max_length=500) political_orientation: Optional[str] = None media_type: Optional[str] = None @@ -230,10 +232,10 @@ async def create_global_source( ) cursor = await db.execute( - """INSERT INTO sources (name, url, domain, source_type, category, status, notes, language, bias, fetch_strategy, added_by, tenant_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'system', NULL)""", + """INSERT INTO sources (name, url, domain, source_type, category, status, notes, language, primary_language, bias, fetch_strategy, added_by, tenant_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'system', NULL)""", (data.name, data.url, data.domain, data.source_type, data.category, data.status, data.notes, - data.language, data.bias, data.fetch_strategy or "default"), + data.language, data.primary_language, data.bias, data.fetch_strategy or "default"), ) src_id = cursor.lastrowid await db.commit() diff --git a/src/source_meta.py b/src/source_meta.py index 9f6faa8..4a9e25f 100644 --- a/src/source_meta.py +++ b/src/source_meta.py @@ -38,6 +38,7 @@ SOURCE_CATEGORIES: list[CategoryEntry] = [ {"key": "russische-staatspropaganda", "label": "Russische Staatspropaganda"}, {"key": "russische-opposition", "label": "Russische Opposition / Exilmedien"}, {"key": "syrien-nahost", "label": "Syrien / Nahost"}, + {"key": "x", "label": "X-Recherche"}, ] @@ -47,6 +48,7 @@ SOURCE_TYPES: list[TypeEntry] = [ {"key": "telegram_channel", "label": "Telegram-Kanal"}, {"key": "podcast_feed", "label": "Podcast-Feed"}, {"key": "excluded", "label": "Ausgeschlossen"}, + {"key": "x_account", "label": "X-Account"}, ] diff --git a/src/static/dashboard.html b/src/static/dashboard.html index b4813a3..10051c4 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -329,6 +329,7 @@ + @@ -471,6 +472,36 @@ + +
+
+
+ + +
+ +
+
+

X-Accounts (Twitter), die der Monitor als Recherchequelle nutzt. Pro Lage über die Option „X (Twitter) einbeziehen" zuschaltbar.

+
+ + + + + + + + + + + + + +
AccountURLSpracheNotizArtikelStatusAktionen
+
+
+
+ @@ -938,8 +969,59 @@ + + + - +
diff --git a/src/static/js/sources.js b/src/static/js/sources.js index 7c51850..2b4aa12 100644 --- a/src/static/js/sources.js +++ b/src/static/js/sources.js @@ -38,10 +38,152 @@ function setupSourceSubTabs() { else if (subtab === "tenant-sources") loadTenantSources(); else if (subtab === "source-health") loadHealthData(); else if (subtab === "classification-review") loadClassificationQueue(); + else if (subtab === "x-accounts") loadXAccounts(); }); }); } +// --- X-Accounts (Recherche-Accounts für den Monitor) --- +let xAccountsCache = []; +let editingXAccountId = null; + +function normalizeXHandle(raw) { + let h = (raw || "").trim(); + h = h.replace(/^https?:\/\//i, "").replace(/^www\./i, ""); + h = h.replace(/^(x\.com\/|twitter\.com\/|nitter\.net\/)/i, ""); + h = h.replace(/^@/, "").replace(/\/+$/, ""); + return h.split(/[/?#]/)[0]; +} + +async function loadXAccounts() { + setupXAccountForm(); + const tbody = document.getElementById("xAccountTable"); + tbody.innerHTML = 'Lade...'; + try { + const all = await API.get("/api/sources/global"); + xAccountsCache = (all || []).filter((s) => s.source_type === "x_account"); + renderXAccounts(xAccountsCache); + } catch (err) { + tbody.innerHTML = 'Fehler beim Laden'; + showToast("X-Accounts konnten nicht geladen werden", "error"); + } +} + +function renderXAccounts(list) { + const tbody = document.getElementById("xAccountTable"); + const cnt = document.getElementById("xAccountCount"); + if (cnt) cnt.textContent = list.length + (list.length === 1 ? " Account" : " Accounts"); + if (!list.length) { + tbody.innerHTML = 'Keine X-Accounts. Mit „+ X-Account hinzufügen" anlegen.'; + return; + } + tbody.innerHTML = list.map((s) => { + const handle = normalizeXHandle(s.url || s.domain || s.name || ""); + const url = "https://x.com/" + handle; + const lang = s.primary_language || s.language || "—"; + const notes = s.notes ? esc(s.notes) : ''; + const status = s.status === "active" + ? 'Aktiv' + : 'Inaktiv'; + return '' + + '' + esc(s.name || ("@" + handle)) + '' + + '' + esc(handle) + '' + + '' + esc(lang) + '' + + '' + notes + '' + + '' + (s.article_count || 0) + '' + + '' + status + '' + + '' + + ' ' + + '' + + '' + + ''; + }).join(""); +} + +function filterXAccounts() { + const q = (document.getElementById("xAccountSearch").value || "").toLowerCase(); + if (!q) { renderXAccounts(xAccountsCache); return; } + renderXAccounts(xAccountsCache.filter((s) => + (s.name || "").toLowerCase().includes(q) + || (s.url || "").toLowerCase().includes(q) + || (s.notes || "").toLowerCase().includes(q) + )); +} + +function openXAccountModal(id) { + editingXAccountId = id || null; + const errEl = document.getElementById("xAccountError"); + errEl.style.display = "none"; + const s = editingXAccountId ? xAccountsCache.find((a) => a.id === editingXAccountId) : null; + if (editingXAccountId && !s) return; + document.getElementById("xAccountModalTitle").textContent = s ? "X-Account bearbeiten" : "X-Account hinzufügen"; + document.getElementById("xAccountHandle").value = s ? normalizeXHandle(s.url || s.domain || "") : ""; + document.getElementById("xAccountName").value = s ? (s.name || "") : ""; + document.getElementById("xAccountLanguage").value = s ? (s.primary_language || s.language || "en") : "en"; + document.getElementById("xAccountNotes").value = s ? (s.notes || "") : ""; + document.getElementById("xAccountStatus").value = s ? (s.status || "active") : "active"; + openModal("modalXAccount"); +} + +function setupXAccountForm() { + const form = document.getElementById("xAccountForm"); + if (!form || form.dataset.wired) return; + form.dataset.wired = "1"; + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const errEl = document.getElementById("xAccountError"); + errEl.style.display = "none"; + const handle = normalizeXHandle(document.getElementById("xAccountHandle").value); + if (!handle) { + errEl.textContent = "Bitte einen Handle oder eine x.com-URL eingeben."; + errEl.style.display = "block"; + return; + } + const nameVal = document.getElementById("xAccountName").value.trim(); + const body = { + name: nameVal || ("@" + handle), + url: "x.com/" + handle, + domain: "x.com/" + handle, + source_type: "x_account", + category: "x", + status: document.getElementById("xAccountStatus").value, + notes: document.getElementById("xAccountNotes").value.trim() || null, + primary_language: document.getElementById("xAccountLanguage").value || null, + }; + try { + if (editingXAccountId) { + await API.put("/api/sources/global/" + editingXAccountId, body); + } else { + await API.post("/api/sources/global", body); + } + closeModal("modalXAccount"); + loadXAccounts(); + showToast("X-Account gespeichert.", "success"); + } catch (err) { + errEl.textContent = err.message || "Speichern fehlgeschlagen"; + errEl.style.display = "block"; + } + }); +} + +function confirmDeleteXAccount(id) { + const s = xAccountsCache.find((a) => a.id === id); + if (!s) return; + showConfirm( + "X-Account entfernen", + 'Soll der X-Account "' + (s.name || "") + '" als Recherchequelle entfernt werden?', + async () => { + try { + await API.del("/api/sources/global/" + id); + loadXAccounts(); + showToast("X-Account entfernt.", "success"); + } catch (err) { + showToast(err.message || "Löschen fehlgeschlagen", "error"); + } + } + ); +} + // --- Grundquellen --- async function loadGlobalSources() { try {