Telegram-Kategorie-Checkboxen: Nutzer koennen bei Lage-Erstellung einzelne Telegram-Quellkategorien auswaehlen

Dieser Commit ist enthalten in:
Claude Dev
2026-03-13 19:08:36 +01:00
Ursprung bb3711a471
Commit 2792e916c2
8 geänderte Dateien mit 435 neuen und 261 gelöschten Zeilen

Datei anzeigen

@@ -536,6 +536,14 @@ class AgentOrchestrator:
incident_type = incident["type"] or "adhoc" incident_type = incident["type"] or "adhoc"
international = bool(incident["international_sources"]) if "international_sources" in incident.keys() else True international = bool(incident["international_sources"]) if "international_sources" in incident.keys() else True
include_telegram = bool(incident["include_telegram"]) if "include_telegram" in incident.keys() else False include_telegram = bool(incident["include_telegram"]) if "include_telegram" in incident.keys() else False
telegram_categories_raw = incident["telegram_categories"] if "telegram_categories" in incident.keys() else None
telegram_categories = None
if telegram_categories_raw:
import json
try:
telegram_categories = json.loads(telegram_categories_raw) if isinstance(telegram_categories_raw, str) else telegram_categories_raw
except (json.JSONDecodeError, TypeError):
telegram_categories = None
visibility = incident["visibility"] if "visibility" in incident.keys() else "public" visibility = incident["visibility"] if "visibility" in incident.keys() else "public"
created_by = incident["created_by"] if "created_by" in incident.keys() else None created_by = incident["created_by"] if "created_by" in incident.keys() else None
tenant_id = incident["tenant_id"] if "tenant_id" in incident.keys() else None tenant_id = incident["tenant_id"] if "tenant_id" in incident.keys() else None
@@ -625,7 +633,7 @@ class AgentOrchestrator:
"""Telegram-Kanal-Suche.""" """Telegram-Kanal-Suche."""
from feeds.telegram_parser import TelegramParser from feeds.telegram_parser import TelegramParser
tg_parser = TelegramParser() tg_parser = TelegramParser()
articles = await tg_parser.search_channels(title, tenant_id=tenant_id, keywords=None) articles = await tg_parser.search_channels(title, tenant_id=tenant_id, keywords=None, categories=telegram_categories)
logger.info(f"Telegram-Pipeline: {len(articles)} Nachrichten") logger.info(f"Telegram-Pipeline: {len(articles)} Nachrichten")
return articles, None return articles, None

Datei anzeigen

@@ -264,6 +264,11 @@ async def init_db():
await db.commit() await db.commit()
logger.info("Migration: include_telegram zu incidents hinzugefuegt") logger.info("Migration: include_telegram zu incidents hinzugefuegt")
if "telegram_categories" not in columns:
await db.execute("ALTER TABLE incidents ADD COLUMN telegram_categories TEXT DEFAULT NULL")
await db.commit()
logger.info("Migration: telegram_categories zu incidents hinzugefuegt")
if "tenant_id" not in columns: if "tenant_id" not in columns:
await db.execute("ALTER TABLE incidents ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)") await db.execute("ALTER TABLE incidents ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)")

Datei anzeigen

@@ -61,7 +61,7 @@ class TelegramParser:
return None return None
async def search_channels(self, search_term: str, tenant_id: int = None, async def search_channels(self, search_term: str, tenant_id: int = None,
keywords: list[str] = None) -> list[dict]: keywords: list[str] = None, categories: list[str] = None) -> list[dict]:
"""Liest Nachrichten aus konfigurierten Telegram-Kanaelen. """Liest Nachrichten aus konfigurierten Telegram-Kanaelen.
Gibt Artikel-Dicts zurueck (kompatibel mit RSS-Parser-Format). Gibt Artikel-Dicts zurueck (kompatibel mit RSS-Parser-Format).
@@ -72,7 +72,7 @@ class TelegramParser:
return [] return []
# Telegram-Kanaele aus DB laden # Telegram-Kanaele aus DB laden
channels = await self._get_telegram_channels(tenant_id) channels = await self._get_telegram_channels(tenant_id, categories=categories)
if not channels: if not channels:
logger.info("Keine Telegram-Kanaele konfiguriert") logger.info("Keine Telegram-Kanaele konfiguriert")
return [] return []
@@ -106,19 +106,30 @@ class TelegramParser:
logger.info("Telegram: %d relevante Nachrichten aus %d Kanaelen", len(all_articles), len(channels)) logger.info("Telegram: %d relevante Nachrichten aus %d Kanaelen", len(all_articles), len(channels))
return all_articles return all_articles
async def _get_telegram_channels(self, tenant_id: int = None) -> list[dict]: async def _get_telegram_channels(self, tenant_id: int = None, categories: list[str] = None) -> list[dict]:
"""Laedt Telegram-Kanaele aus der sources-Tabelle.""" """Laedt Telegram-Kanaele aus der sources-Tabelle."""
try: try:
from database import get_db from database import get_db
db = await get_db() db = await get_db()
try: try:
cursor = await db.execute( if categories and len(categories) > 0:
"""SELECT id, name, url FROM sources placeholders = ",".join("?" for _ in categories)
WHERE source_type = 'telegram_channel' cursor = await db.execute(
AND status = 'active' f"""SELECT id, name, url FROM sources
AND (tenant_id IS NULL OR tenant_id = ?)""", WHERE source_type = 'telegram_channel'
(tenant_id,), AND status = 'active'
) AND (tenant_id IS NULL OR tenant_id = ?)
AND category IN ({placeholders})""",
(tenant_id, *categories),
)
else:
cursor = await db.execute(
"""SELECT id, name, url FROM sources
WHERE source_type = 'telegram_channel'
AND status = 'active'
AND (tenant_id IS NULL OR tenant_id = ?)""",
(tenant_id,),
)
rows = await cursor.fetchall() rows = await cursor.fetchall()
return [dict(row) for row in rows] return [dict(row) for row in rows]
finally: finally:

Datei anzeigen

@@ -53,6 +53,7 @@ class IncidentCreate(BaseModel):
retention_days: int = Field(default=0, ge=0, le=999) retention_days: int = Field(default=0, ge=0, le=999)
international_sources: bool = True international_sources: bool = True
include_telegram: bool = False include_telegram: bool = False
telegram_categories: Optional[list[str]] = None
visibility: str = Field(default="public", pattern="^(public|private)$") visibility: str = Field(default="public", pattern="^(public|private)$")
@@ -66,6 +67,7 @@ class IncidentUpdate(BaseModel):
retention_days: Optional[int] = Field(default=None, ge=0, le=999) retention_days: Optional[int] = Field(default=None, ge=0, le=999)
international_sources: Optional[bool] = None international_sources: Optional[bool] = None
include_telegram: Optional[bool] = None include_telegram: Optional[bool] = None
telegram_categories: Optional[list[str]] = None
visibility: Optional[str] = Field(default=None, pattern="^(public|private)$") visibility: Optional[str] = Field(default=None, pattern="^(public|private)$")
@@ -83,6 +85,7 @@ class IncidentResponse(BaseModel):
sources_json: Optional[str] = None sources_json: Optional[str] = None
international_sources: bool = True international_sources: bool = True
include_telegram: bool = False include_telegram: bool = False
telegram_categories: Optional[list[str]] = None
created_by: int created_by: int
created_by_username: str = "" created_by_username: str = ""
created_at: str created_at: str

Datei anzeigen

@@ -20,7 +20,7 @@ router = APIRouter(prefix="/api/incidents", tags=["incidents"])
INCIDENT_UPDATE_COLUMNS = { INCIDENT_UPDATE_COLUMNS = {
"title", "description", "type", "status", "refresh_mode", "title", "description", "type", "status", "refresh_mode",
"refresh_interval", "retention_days", "international_sources", "include_telegram", "visibility", "refresh_interval", "retention_days", "international_sources", "include_telegram", "telegram_categories", "visibility",
} }
@@ -64,6 +64,14 @@ async def _enrich_incident(db: aiosqlite.Connection, row: aiosqlite.Row) -> dict
incident["article_count"] = article_count incident["article_count"] = article_count
incident["source_count"] = source_count incident["source_count"] = source_count
incident["created_by_username"] = user_row["email"] if user_row else "Unbekannt" incident["created_by_username"] = user_row["email"] if user_row else "Unbekannt"
# telegram_categories: JSON-String -> Liste
tc = incident.get("telegram_categories")
if tc and isinstance(tc, str):
import json
try:
incident["telegram_categories"] = json.loads(tc)
except (json.JSONDecodeError, TypeError):
incident["telegram_categories"] = None
return incident return incident
@@ -105,9 +113,9 @@ async def create_incident(
now = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S') now = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')
cursor = await db.execute( cursor = await db.execute(
"""INSERT INTO incidents (title, description, type, refresh_mode, refresh_interval, """INSERT INTO incidents (title, description, type, refresh_mode, refresh_interval,
retention_days, international_sources, include_telegram, visibility, retention_days, international_sources, include_telegram, telegram_categories, visibility,
tenant_id, created_by, created_at, updated_at) tenant_id, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
( (
data.title, data.title,
data.description, data.description,
@@ -117,6 +125,7 @@ async def create_incident(
data.retention_days, data.retention_days,
1 if data.international_sources else 0, 1 if data.international_sources else 0,
1 if data.include_telegram else 0, 1 if data.include_telegram else 0,
__import__('json').dumps(data.telegram_categories) if data.telegram_categories else None,
data.visibility, data.visibility,
tenant_id, tenant_id,
current_user["id"], current_user["id"],
@@ -180,7 +189,13 @@ async def update_incident(
for field, value in data.model_dump(exclude_none=True).items(): for field, value in data.model_dump(exclude_none=True).items():
if field not in INCIDENT_UPDATE_COLUMNS: if field not in INCIDENT_UPDATE_COLUMNS:
continue continue
updates[field] = value if field == "telegram_categories":
import json
updates[field] = json.dumps(value) if value else None
elif field in ("international_sources", "include_telegram"):
updates[field] = 1 if value else 0
else:
updates[field] = value
if not updates: if not updates:
return await _enrich_incident(db, row) return await _enrich_incident(db, row)
@@ -723,6 +738,7 @@ def _build_json_export(
"summary": incident.get("summary"), "summary": incident.get("summary"),
"international_sources": bool(incident.get("international_sources")), "international_sources": bool(incident.get("international_sources")),
"include_telegram": bool(incident.get("include_telegram")), "include_telegram": bool(incident.get("include_telegram")),
"telegram_categories": incident.get("telegram_categories"),
}, },
"sources": sources, "sources": sources,
"fact_checks": [ "fact_checks": [

Datei anzeigen

@@ -4545,3 +4545,54 @@ a.map-popup-article:hover {
height: 100% !important; height: 100% !important;
} }
/* Telegram Category Selection Panel */
.tg-categories-panel {
margin-top: 8px;
padding: 12px 14px;
background: var(--bg-tertiary);
border-radius: var(--radius);
border: 1px solid var(--border);
}
.tg-cat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 16px;
}
.tg-cat-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
padding: 3px 0;
}
.tg-cat-item input[type="checkbox"] {
accent-color: var(--accent);
width: 15px;
height: 15px;
cursor: pointer;
}
.tg-cat-count {
font-size: 11px;
color: var(--text-disabled);
margin-left: auto;
}
.tg-cat-actions {
margin-top: 8px;
display: flex;
gap: 12px;
}
.btn-link {
background: none;
border: none;
color: var(--accent);
font-size: 12px;
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.btn-link:hover {
color: var(--accent-hover);
}

Datei anzeigen

@@ -347,6 +347,24 @@
</label> </label>
<div class="form-hint" id="telegram-hint">Nachrichten aus konfigurierten Telegram-Kanälen berücksichtigen</div> <div class="form-hint" id="telegram-hint">Nachrichten aus konfigurierten Telegram-Kanälen berücksichtigen</div>
</div> </div>
<div class="tg-categories-panel" id="tg-categories-panel" style="display:none;">
<div class="form-hint" style="margin-bottom:6px;font-weight:500;">Telegram-Kategorien auswählen:</div>
<div class="tg-cat-grid">
<label class="tg-cat-item"><input type="checkbox" class="tg-cat-cb" value="ukraine-russland-krieg" checked><span>Ukraine-Russland-Krieg</span><span class="tg-cat-count" data-cat="ukraine-russland-krieg"></span></label>
<label class="tg-cat-item"><input type="checkbox" class="tg-cat-cb" value="russische-staatspropaganda" checked><span>Russische Staatspropaganda</span><span class="tg-cat-count" data-cat="russische-staatspropaganda"></span></label>
<label class="tg-cat-item"><input type="checkbox" class="tg-cat-cb" value="russische-opposition" checked><span>Russische Opposition</span><span class="tg-cat-count" data-cat="russische-opposition"></span></label>
<label class="tg-cat-item"><input type="checkbox" class="tg-cat-cb" value="extremismus-deutschland" checked><span>Extremismus Deutschland</span><span class="tg-cat-count" data-cat="extremismus-deutschland"></span></label>
<label class="tg-cat-item"><input type="checkbox" class="tg-cat-cb" value="cybercrime" checked><span>Cybercrime</span><span class="tg-cat-count" data-cat="cybercrime"></span></label>
<label class="tg-cat-item"><input type="checkbox" class="tg-cat-cb" value="cybercrime-leaks" checked><span>Cybercrime Leaks</span><span class="tg-cat-count" data-cat="cybercrime-leaks"></span></label>
<label class="tg-cat-item"><input type="checkbox" class="tg-cat-cb" value="osint-international" checked><span>OSINT International</span><span class="tg-cat-count" data-cat="osint-international"></span></label>
<label class="tg-cat-item"><input type="checkbox" class="tg-cat-cb" value="irankonflikt" checked><span>Irankonflikt</span><span class="tg-cat-count" data-cat="irankonflikt"></span></label>
<label class="tg-cat-item"><input type="checkbox" class="tg-cat-cb" value="syrien-nahost" checked><span>Syrien / Nahost</span><span class="tg-cat-count" data-cat="syrien-nahost"></span></label>
</div>
<div class="tg-cat-actions">
<button type="button" class="btn-link" onclick="toggleAllTgCats(true)">Alle</button>
<button type="button" class="btn-link" onclick="toggleAllTgCats(false)">Keine</button>
</div>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Sichtbarkeit</label> <label>Sichtbarkeit</label>
@@ -466,7 +484,7 @@
<div class="sources-form-row"> <div class="sources-form-row">
<div class="form-group flex-1"> <div class="form-group flex-1">
<label for="src-discover-url">URL oder Domain</label> <label for="src-discover-url">URL oder Domain</label>
<input type="text" id="src-discover-url" placeholder="z.B. netzpolitik.org"> <input type="text" id="src-discover-url" placeholder="z.B. netzpolitik.org oder t.me/kanalname">
</div> </div>
<button class="btn btn-secondary btn-small" id="src-discover-btn" onclick="App.discoverSource()">Erkennen</button> <button class="btn btn-secondary btn-small" id="src-discover-btn" onclick="App.discoverSource()">Erkennen</button>
</div> </div>
@@ -491,11 +509,19 @@
<option value="regional">Regional</option> <option value="regional">Regional</option>
<option value="boulevard">Boulevard</option> <option value="boulevard">Boulevard</option>
<option value="sonstige" selected>Sonstige</option> <option value="sonstige" selected>Sonstige</option>
<option value="ukraine-russland-krieg">Ukraine-Russland-Krieg</option>
<option value="irankonflikt">Irankonflikt</option>
<option value="osint-international">OSINT International</option>
<option value="extremismus-deutschland">Extremismus Deutschland</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Typ</label> <label for="src-type-select">Typ</label>
<input type="text" id="src-type-display" class="input-readonly" readonly> <select id="src-type-select">
<option value="rss_feed">RSS-Feed</option>
<option value="web_source">Web-Quelle</option>
<option value="telegram_channel">Telegram-Kanal</option>
</select>
</div> </div>
<div class="form-group" id="src-rss-url-group"> <div class="form-group" id="src-rss-url-group">
<label>RSS-Feed URL</label> <label>RSS-Feed URL</label>

Datei anzeigen

@@ -502,6 +502,12 @@ const App = {
document.getElementById('archive-incident-btn').addEventListener('click', () => this.handleArchive()); document.getElementById('archive-incident-btn').addEventListener('click', () => this.handleArchive());
document.getElementById('inc-international').addEventListener('change', () => updateSourcesHint()); document.getElementById('inc-international').addEventListener('change', () => updateSourcesHint());
document.getElementById('inc-visibility').addEventListener('change', () => updateVisibilityHint()); document.getElementById('inc-visibility').addEventListener('change', () => updateVisibilityHint());
// Telegram-Kategorien Toggle
const tgCheckbox = document.getElementById('inc-telegram');
if (tgCheckbox) {
tgCheckbox.addEventListener('change', function() { toggleTgCategories(this.checked); });
}
// Feedback // Feedback
document.getElementById('feedback-form').addEventListener('submit', (e) => this.submitFeedback(e)); document.getElementById('feedback-form').addEventListener('submit', (e) => this.submitFeedback(e));
@@ -1453,6 +1459,9 @@ const App = {
retention_days: parseInt(document.getElementById('inc-retention').value) || 0, retention_days: parseInt(document.getElementById('inc-retention').value) || 0,
international_sources: document.getElementById('inc-international').checked, international_sources: document.getElementById('inc-international').checked,
include_telegram: document.getElementById('inc-telegram').checked, include_telegram: document.getElementById('inc-telegram').checked,
telegram_categories: document.getElementById('inc-telegram').checked
? Array.from(document.querySelectorAll('.tg-cat-cb:checked')).map(cb => cb.value)
: null,
visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private', visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private',
}; };
}, },
@@ -1807,6 +1816,15 @@ const App = {
document.getElementById('inc-retention').value = incident.retention_days; document.getElementById('inc-retention').value = incident.retention_days;
document.getElementById('inc-international').checked = incident.international_sources !== false && incident.international_sources !== 0; document.getElementById('inc-international').checked = incident.international_sources !== false && incident.international_sources !== 0;
document.getElementById('inc-telegram').checked = !!incident.include_telegram; document.getElementById('inc-telegram').checked = !!incident.include_telegram;
// Telegram-Kategorien wiederherstellen
toggleTgCategories(!!incident.include_telegram);
if (incident.telegram_categories) {
let cats = incident.telegram_categories;
if (typeof cats === 'string') { try { cats = JSON.parse(cats); } catch(e) { cats = []; } }
document.querySelectorAll('.tg-cat-cb').forEach(cb => {
cb.checked = cats.includes(cb.value);
});
}
document.getElementById('inc-visibility').checked = incident.visibility !== 'private'; document.getElementById('inc-visibility').checked = incident.visibility !== 'private';
updateVisibilityHint(); updateVisibilityHint();
updateSourcesHint(); updateSourcesHint();
@@ -2596,6 +2614,7 @@ const App = {
document.getElementById('src-discovery-result').style.display = 'none'; document.getElementById('src-discovery-result').style.display = 'none';
document.getElementById('src-discover-btn').disabled = false; document.getElementById('src-discover-btn').disabled = false;
document.getElementById('src-discover-btn').textContent = 'Erkennen'; document.getElementById('src-discover-btn').textContent = 'Erkennen';
document.getElementById('src-type-select').value = 'rss_feed';
// Save-Button Text zurücksetzen // Save-Button Text zurücksetzen
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary'); const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
if (saveBtn) saveBtn.textContent = 'Speichern'; if (saveBtn) saveBtn.textContent = 'Speichern';
@@ -2612,6 +2631,27 @@ const App = {
async discoverSource() { async discoverSource() {
const urlInput = document.getElementById('src-discover-url'); const urlInput = document.getElementById('src-discover-url');
const urlVal = urlInput.value.trim();
// Telegram-URLs direkt behandeln (kein Discovery noetig)
if (urlVal.match(/^(https?:\/\/)?(t\.me|telegram\.me)\//i)) {
const channelName = urlVal.replace(/^(https?:\/\/)?(t\.me|telegram\.me)\//, '').replace(/\/$/, '');
const tgUrl = 't.me/' + channelName;
this._discoveredData = {
name: '@' + channelName,
domain: 't.me',
source_type: 'telegram_channel',
rss_url: null,
};
document.getElementById('src-name').value = '@' + channelName;
document.getElementById('src-type-select').value = 'telegram_channel';
document.getElementById('src-domain').value = tgUrl;
document.getElementById('src-rss-url-group').style.display = 'none';
document.getElementById('src-discovery-result').style.display = 'block';
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; }
return;
}
const url = urlInput.value.trim(); const url = urlInput.value.trim();
if (!url) { if (!url) {
UI.showToast('Bitte URL oder Domain eingeben.', 'warning'); UI.showToast('Bitte URL oder Domain eingeben.', 'warning');
@@ -2652,6 +2692,8 @@ const App = {
document.getElementById('src-notes').value = ''; document.getElementById('src-notes').value = '';
const typeLabel = this._discoveredData.source_type === 'rss_feed' ? 'RSS-Feed' : this._discoveredData.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle'; const typeLabel = this._discoveredData.source_type === 'rss_feed' ? 'RSS-Feed' : this._discoveredData.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle';
const typeSelect = document.getElementById('src-type-select');
if (typeSelect) typeSelect.value = this._discoveredData.source_type || 'web_source';
document.getElementById('src-type-display').value = typeLabel; document.getElementById('src-type-display').value = typeLabel;
const rssGroup = document.getElementById('src-rss-url-group'); const rssGroup = document.getElementById('src-rss-url-group');
@@ -2730,6 +2772,8 @@ const App = {
document.getElementById('src-domain').value = source.domain || ''; document.getElementById('src-domain').value = source.domain || '';
const typeLabel = source.source_type === 'rss_feed' ? 'RSS-Feed' : source.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle'; const typeLabel = source.source_type === 'rss_feed' ? 'RSS-Feed' : source.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle';
const typeSelect = document.getElementById('src-type-select');
if (typeSelect) typeSelect.value = source.source_type || 'web_source';
document.getElementById('src-type-display').value = typeLabel; document.getElementById('src-type-display').value = typeLabel;
const rssGroup = document.getElementById('src-rss-url-group'); const rssGroup = document.getElementById('src-rss-url-group');
@@ -2769,9 +2813,9 @@ const App = {
const discovered = this._discoveredData || {}; const discovered = this._discoveredData || {};
const data = { const data = {
name, name,
source_type: discovered.source_type || 'web_source', source_type: document.getElementById('src-type-select') ? document.getElementById('src-type-select').value : (discovered.source_type || 'web_source'),
category: document.getElementById('src-category').value, category: document.getElementById('src-category').value,
url: discovered.rss_url || null, url: discovered.rss_url || (discovered.source_type === 'telegram_channel' ? (document.getElementById('src-domain').value || null) : null),
domain: document.getElementById('src-domain').value.trim() || discovered.domain || null, domain: document.getElementById('src-domain').value.trim() || discovered.domain || null,
notes: document.getElementById('src-notes').value.trim() || null, notes: document.getElementById('src-notes').value.trim() || null,
}; };
@@ -3068,6 +3112,16 @@ function buildDetailedSourceOverview() {
return html; return html;
} }
function toggleTgCategories(show) {
const panel = document.getElementById('tg-categories-panel');
if (panel) panel.style.display = show ? 'block' : 'none';
}
function toggleAllTgCats(checked) {
document.querySelectorAll('.tg-cat-cb').forEach(cb => { cb.checked = checked; });
}
function toggleRefreshInterval() { function toggleRefreshInterval() {
const mode = document.getElementById('inc-refresh-mode').value; const mode = document.getElementById('inc-refresh-mode').value;
const field = document.getElementById('refresh-interval-field'); const field = document.getElementById('refresh-interval-field');