Promote develop → main (2026-05-22 11:13 UTC) #12
@@ -1,4 +1,16 @@
|
||||
[
|
||||
{
|
||||
"version": "2026-05-22T11:13Z",
|
||||
"date": "2026-05-22",
|
||||
"title": "Interne Verbesserungen",
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"version": "2026-05-22T11:13Z",
|
||||
"date": "2026-05-22",
|
||||
"title": "Interne Verbesserungen",
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"version": "2026-05-22T11:09Z",
|
||||
"date": "2026-05-22",
|
||||
|
||||
@@ -42,7 +42,7 @@ router = APIRouter(prefix="/api/sources", tags=["sources"])
|
||||
|
||||
SOURCE_UPDATE_COLUMNS = {
|
||||
"name", "url", "domain", "source_type", "category", "status", "notes",
|
||||
"language", "primary_language", "bias", "fetch_strategy",
|
||||
"language", "bias", "fetch_strategy",
|
||||
"political_orientation", "media_type", "reliability",
|
||||
"state_affiliated", "country_code",
|
||||
}
|
||||
@@ -118,12 +118,11 @@ 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|x_account)$")
|
||||
source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document)$")
|
||||
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)$")
|
||||
|
||||
@@ -132,12 +131,11 @@ 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|x_account)$")
|
||||
source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document)$")
|
||||
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
|
||||
@@ -232,10 +230,10 @@ async def create_global_source(
|
||||
)
|
||||
|
||||
cursor = await db.execute(
|
||||
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, language, primary_language, bias, fetch_strategy, added_by, tenant_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'system', NULL)""",
|
||||
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, 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.primary_language, data.bias, data.fetch_strategy or "default"),
|
||||
data.language, data.bias, data.fetch_strategy or "default"),
|
||||
)
|
||||
src_id = cursor.lastrowid
|
||||
await db.commit()
|
||||
|
||||
@@ -38,7 +38,6 @@ 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"},
|
||||
]
|
||||
|
||||
|
||||
@@ -48,7 +47,6 @@ 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"},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -329,7 +329,6 @@
|
||||
<button class="nav-tab" data-subtab="tenant-sources">Kundenquellen</button>
|
||||
<button class="nav-tab" data-subtab="source-health">Quellen-Health</button>
|
||||
<button class="nav-tab" data-subtab="classification-review">Klassifikation <span class="sources-tab-badge" id="classificationPendingBadge">0</span></button>
|
||||
<button class="nav-tab" data-subtab="x-accounts">X-Accounts</button>
|
||||
</div>
|
||||
|
||||
<!-- Grundquellen -->
|
||||
@@ -472,36 +471,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- X-Accounts (Sub-Tab) -->
|
||||
<div class="section" id="sub-x-accounts">
|
||||
<div class="action-bar">
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||
<input type="text" class="search-input" id="xAccountSearch" placeholder="X-Account suchen..." oninput="filterXAccounts()">
|
||||
<span class="text-secondary" id="xAccountCount"></span>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="newXAccountBtn" onclick="openXAccountModal()">+ X-Account hinzufügen</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<p class="text-secondary" style="padding:0 4px 12px;">X-Accounts (Twitter), die der Monitor als Recherchequelle nutzt. Pro Lage über die Option „X (Twitter) einbeziehen" zuschaltbar.</p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>URL</th>
|
||||
<th>Sprache</th>
|
||||
<th>Notiz</th>
|
||||
<th>Artikel</th>
|
||||
<th>Status</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="xAccountTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div> <!-- /sec-sources -->
|
||||
|
||||
<!-- Audit-Log Section -->
|
||||
@@ -969,59 +938,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: X-Account -->
|
||||
<div class="modal-overlay" id="modalXAccount">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="xAccountModalTitle">X-Account hinzufügen</h3>
|
||||
<button class="modal-close" onclick="closeModal('modalXAccount')">×</button>
|
||||
</div>
|
||||
<form id="xAccountForm">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="xAccountHandle">X-Handle oder URL</label>
|
||||
<input type="text" id="xAccountHandle" required placeholder="z.B. bellingcat oder https://x.com/bellingcat">
|
||||
<small class="text-secondary">Account-Name mit oder ohne @, oder die volle x.com-URL.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="xAccountName">Anzeigename</label>
|
||||
<input type="text" id="xAccountName" placeholder="optional, z.B. Bellingcat">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="xAccountLanguage">Sprache</label>
|
||||
<select id="xAccountLanguage">
|
||||
<option value="en">Englisch</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="fr">Französisch</option>
|
||||
<option value="ru">Russisch</option>
|
||||
<option value="uk">Ukrainisch</option>
|
||||
<option value="">Unbestimmt</option>
|
||||
</select>
|
||||
<small class="text-secondary">Steuert das Keyword-Matching im Monitor.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="xAccountNotes">Notiz</label>
|
||||
<textarea id="xAccountNotes" rows="2" placeholder="Kurzbeschreibung, hilft der KI bei der Account-Auswahl pro Lage"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="xAccountStatus">Status</label>
|
||||
<select id="xAccountStatus">
|
||||
<option value="active">Aktiv</option>
|
||||
<option value="inactive">Inaktiv</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="xAccountError" class="error-msg" style="display:none"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('modalXAccount')">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/app.js?v=20260522a"></script>
|
||||
<script src="/static/js/sources.js?v=20260522x"></script>
|
||||
<script src="/static/js/sources.js?v=20260509d"></script>
|
||||
<script src="/static/js/source-health.js?v=20260509l"></script>
|
||||
<script src="/static/js/audit.js?v=20260509d"></script>
|
||||
<div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div>
|
||||
|
||||
@@ -38,152 +38,10 @@ 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 = '<tr><td colspan="7" class="text-muted">Lade...</td></tr>';
|
||||
try {
|
||||
const all = await API.get("/api/sources/global");
|
||||
xAccountsCache = (all || []).filter((s) => s.source_type === "x_account");
|
||||
renderXAccounts(xAccountsCache);
|
||||
} catch (err) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">Fehler beim Laden</td></tr>';
|
||||
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 = '<tr><td colspan="7" class="text-muted">Keine X-Accounts. Mit „+ X-Account hinzufügen" anlegen.</td></tr>';
|
||||
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) : '<span class="text-muted">—</span>';
|
||||
const status = s.status === "active"
|
||||
? '<span style="color:var(--success,#2e7d32);">Aktiv</span>'
|
||||
: '<span class="text-muted">Inaktiv</span>';
|
||||
return '<tr>'
|
||||
+ '<td><strong>' + esc(s.name || ("@" + handle)) + '</strong></td>'
|
||||
+ '<td><a href="' + esc(url) + '" target="_blank" rel="noopener">' + esc(handle) + '</a></td>'
|
||||
+ '<td>' + esc(lang) + '</td>'
|
||||
+ '<td>' + notes + '</td>'
|
||||
+ '<td>' + (s.article_count || 0) + '</td>'
|
||||
+ '<td>' + status + '</td>'
|
||||
+ '<td>'
|
||||
+ '<button class="btn btn-secondary btn-small" onclick="openXAccountModal(' + s.id + ')">Bearbeiten</button> '
|
||||
+ '<button class="btn btn-danger btn-small" onclick="confirmDeleteXAccount(' + s.id + ')">Löschen</button>'
|
||||
+ '</td>'
|
||||
+ '</tr>';
|
||||
}).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 {
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren