Files
meshcore-bot/modules/web_viewer/templates/feeds.html
T
agessamanandClaude Opus 4.8 03a1d4056b Merge origin/dev into codex/p0-security-hardening
Resolve the feeds.html conflict by keeping the branch's XSS-safe DOM
construction of the feed-details view and re-adding dev's per-feed and
reset-all error buttons via addEventListener instead of inline onclick,
since /feeds is a nonce-CSP page where inline handlers are blocked.

Update tests/test_feed_manager_extended.py::TestPollFeedPosting to patch
the SafeUrlPolicy.validate_async path (this branch's SSRF refactor) rather
than the removed module-level validate_external_url symbol, matching the
idiom already used throughout that test file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 14:28:13 -07:00

1229 lines
55 KiB
HTML

{% extends "base.html" %}
{% block title %}Feed Management - MeshCore Bot{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="fas fa-rss"></i> Feed Management
</h1>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Total Subscriptions</h5>
<h2 id="total-subscriptions">-</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Active Feeds</h5>
<h2 id="active-feeds">-</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Items (24h)</h5>
<h2 id="items-24h">-</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Active Errors</h5>
<h2 id="active-errors">-</h2>
</div>
</div>
</div>
</div>
<!-- Feed List -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Feed Subscriptions</h5>
<div>
<button type="button" id="resetAllErrorsBtn" class="btn btn-outline-secondary me-2" title="Clear recorded errors for all feeds">
<i class="fas fa-eraser"></i> Reset Errors
</button>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addFeedModal">
<i class="fas fa-plus"></i> Add Feed
</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover" id="feedsTable">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Name</th>
<th>Type</th>
<th>Channel</th>
<th>Status</th>
<th>Last Check</th>
<th>Items</th>
<th>Errors</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="feedsTableBody">
<tr>
<td colspan="9" class="text-center">Loading...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Add/Edit Feed Modal -->
<div class="modal fade" id="addFeedModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="feedModalTitle">Add Feed Subscription</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" style="max-height: calc(100vh - 200px); overflow-y: auto;">
<form id="addFeedForm">
<input type="hidden" id="feedId" name="feed_id">
<!-- Basic Settings Row -->
<div class="row mb-3">
<div class="col-md-3">
<label class="form-label">Feed Type</label>
<select class="form-select" id="feedType" name="feed_type" required>
<option value="rss">RSS Feed</option>
<option value="api">API Feed</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Feed URL</label>
<input type="url" class="form-control" id="feedUrl" name="feed_url" required>
</div>
<div class="col-md-3">
<label class="form-label">Check Interval (seconds)</label>
<input type="number" class="form-control" id="checkInterval" name="check_interval_seconds" value="300" min="60">
</div>
</div>
<!-- Channel and Name Row -->
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Channel</label>
<div class="input-group">
<select class="form-select" id="channelSelect" name="channel_name" required>
<option value="">Select channel...</option>
</select>
<button type="button" class="btn btn-outline-secondary" id="createChannelBtn">
<i class="fas fa-plus"></i> New
</button>
</div>
<div id="newChannelGroup" style="display: none;" class="mt-2">
<div class="input-group input-group-sm">
<input type="text" class="form-control" id="newChannelName" placeholder="#channelname" autocomplete="off">
<button type="button" class="btn btn-primary" id="submitNewChannelBtn" title="Create channel on the bot (same as Radio page)">
<i class="fas fa-check"></i> Create channel
</button>
</div>
<small class="form-text text-muted">Creates a hashtag channel on the bot. <code>#</code> is added if missing.</small>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Feed Name <small class="text-muted">(Optional)</small></label>
<input type="text" class="form-control" id="feedName" name="feed_name" placeholder="My Feed">
</div>
<div class="col-md-3">
<label class="form-label">Send Interval (seconds)</label>
<input type="number" class="form-control" id="messageSendInterval" name="message_send_interval_seconds" value="2.0" min="0.5" step="0.1">
<small class="form-text text-muted">Time between messages</small>
</div>
</div>
<!-- Output Format Row -->
<div class="row mb-3">
<div class="col-md-8">
<label class="form-label">
Output Format
<button type="button" class="btn btn-sm btn-link p-0 ms-2" data-bs-toggle="collapse" data-bs-target="#formatHelp" aria-expanded="false">
<i class="fas fa-question-circle"></i> Help
</button>
</label>
<textarea class="form-control font-monospace" id="outputFormat" name="output_format" rows="5" placeholder="{emoji} {body|truncate:100} - {date}\n{link|truncate:50}"></textarea>
<div class="collapse mt-2" id="formatHelp">
<div class="card card-body bg-light small" style="background-color: var(--bg-secondary) !important; color: var(--text-color) !important;">
<strong>Placeholders:</strong> {title}, {body}, {date}, {link}, {emoji}<br>
<strong>API Fields:</strong> {raw.field} or {raw.nested.field}<br>
<strong>Shortening:</strong> {field|truncate:N}, {field|word_wrap:N}, {field|first_words:N}<br>
<strong>Regex:</strong> {field|regex:pattern} or {field|regex:pattern:group}<br>
<strong>Conditional:</strong> {field|if_regex:pattern:then:else}<br>
<strong>Switch:</strong> {field|switch:value1:result1:value2:result2:...:default}<br>
<strong>Extract & Check:</strong> {field|regex_cond:extract_pattern:check_pattern:then:group}
</div>
</div>
</div>
<div class="col-md-4">
<label class="form-label">&nbsp;</label>
<div class="d-grid">
<button type="button" class="btn btn-outline-primary" id="previewBtn" title="Preview format with live feed">
<i class="fas fa-eye"></i> Preview Format
</button>
</div>
<div id="previewResults" style="display: none;" class="mt-3">
<div class="card">
<div class="card-header py-2">
<h6 class="mb-0 small">Preview (first 3 items)</h6>
</div>
<div class="card-body p-2" id="previewContent" style="max-height: 200px; overflow-y: auto; font-size: 0.85rem;">
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Advanced Options Accordion -->
<div class="accordion mb-3" id="advancedOptions">
<div class="accordion-item" id="apiConfigGroup" style="display: none;">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#apiConfigCollapse">
<i class="fas fa-cog me-2"></i> API Configuration
</button>
</h2>
<div id="apiConfigCollapse" class="accordion-collapse collapse" data-bs-parent="#advancedOptions">
<div class="accordion-body">
<textarea class="form-control font-monospace" id="apiConfig" name="api_config" rows="8" placeholder="{
&quot;method&quot;: &quot;GET&quot;,
&quot;headers&quot;: {},
&quot;params&quot;: {
&quot;AccessCode&quot;: &quot;YOUR_ACCESS_CODE&quot;
},
&quot;response_parser&quot;: {
&quot;items_path&quot;: &quot;&quot;,
&quot;id_field&quot;: &quot;AlertID&quot;,
&quot;title_field&quot;: &quot;HeadlineDescription&quot;,
&quot;description_field&quot;: &quot;ExtendedDescription&quot;,
&quot;timestamp_field&quot;: &quot;LastUpdatedTime&quot;,
&quot;emoji_field&quot;: &quot;emoji&quot;
}
}"></textarea>
<small class="form-text text-muted mt-2 d-block">
Configure API request and parsing. Use <code>params</code> for query parameters (e.g., AccessCode for WSDOT) or <code>headers</code> for HTTP headers.
</small>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#filterConfigCollapse">
<i class="fas fa-filter me-2"></i> Filter Configuration <small class="text-muted ms-2">(Optional)</small>
</button>
</h2>
<div id="filterConfigCollapse" class="accordion-collapse collapse" data-bs-parent="#advancedOptions">
<div class="accordion-body">
<textarea class="form-control font-monospace" id="filterConfig" name="filter_config" rows="8" placeholder="{
&quot;conditions&quot;: [
{
&quot;field&quot;: &quot;raw.Priority&quot;,
&quot;operator&quot;: &quot;in&quot;,
&quot;values&quot;: [&quot;highest&quot;, &quot;high&quot;]
},
{
&quot;field&quot;: &quot;raw.EventStatus&quot;,
&quot;operator&quot;: &quot;equals&quot;,
&quot;value&quot;: &quot;open&quot;
}
],
&quot;logic&quot;: &quot;AND&quot;
}"></textarea>
<small class="form-text text-muted mt-2 d-block">
Only send items that match these conditions. Leave empty to send all items.<br>
<strong>Operators:</strong> equals, not_equals, in, not_in, matches (regex), not_matches, contains, not_contains, within_days, within_weeks (see docs/FEEDS.md)<br>
<strong>Logic:</strong> AND (all conditions) or OR (any condition)<br>
<strong>Fields:</strong> Use raw.field or raw.nested.field for API fields; use <code>published</code> for RSS time filters
</small>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#sortConfigCollapse">
<i class="fas fa-sort me-2"></i> Sort Configuration <small class="text-muted ms-2">(Optional)</small>
</button>
</h2>
<div id="sortConfigCollapse" class="accordion-collapse collapse" data-bs-parent="#advancedOptions">
<div class="accordion-body">
<textarea class="form-control font-monospace" id="sortConfig" name="sort_config" rows="4" placeholder="{
&quot;field&quot;: &quot;raw.LastUpdatedTime&quot;,
&quot;order&quot;: &quot;desc&quot;
}"></textarea>
<small class="form-text text-muted mt-2 d-block">
Sort items before processing. Leave empty to use default order (oldest first).<br>
<strong>Field:</strong> Field path to sort by (e.g., raw.LastUpdatedTime, raw.Priority, published)<br>
<strong>Order:</strong> asc (ascending) or desc (descending)<br>
<strong>Note:</strong> Supports Microsoft date format /Date(timestamp-offset)/ (e.g., WSDOT API)
</small>
</div>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveFeedBtn">Save</button>
</div>
</div>
</div>
</div>
<!-- Feed Details Modal -->
<div class="modal fade" id="feedDetailsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Feed Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="feedDetailsContent">
Loading...
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script nonce="{{ g.csp_nonce }}">
class FeedManager {
constructor() {
this.feeds = [];
this.channels = [];
this.maxChannels = 40;
this.initialize();
}
async initialize() {
await this.loadChannelStats();
await this.loadChannels();
await this.loadFeeds();
await this.loadStatistics();
this.setupEventHandlers();
// Auto-refresh every 30 seconds
setInterval(() => this.loadFeeds(), 30000);
setInterval(() => this.loadStatistics(), 60000);
}
async loadChannelStats() {
try {
const response = await fetch('/api/channels/stats');
const data = await response.json();
this.maxChannels = data.max_channels || 40;
} catch (error) {
console.error('Error loading channel stats:', error);
}
}
async loadChannels() {
try {
const response = await fetch('/api/channels');
const data = await response.json();
this.channels = (data.channels || []).filter(c => !c.decode_only);
this.updateChannelSelect();
} catch (error) {
console.error('Error loading channels:', error);
}
}
getLowestAvailableChannelIndex() {
if (typeof MeshCoreChannelOps === 'undefined') {
return null;
}
return MeshCoreChannelOps.getLowestAvailableChannelIndex(this.channels, this.maxChannels);
}
/**
* Create a hashtag channel on the bot; refresh dropdown and select it.
* @param {string} channelName — e.g. #news
* @param {object} [opts] — optional .button (HTMLElement) for loading state
* @returns {Promise<boolean>}
*/
async createMeshChannel(channelName, opts) {
opts = opts || {};
if (typeof MeshCoreChannelOps === 'undefined') {
this.showError('Channel helper not loaded. Refresh the page.');
return false;
}
const name = channelName.trim().startsWith('#') ? channelName.trim() : '#' + channelName.trim();
const idx = this.getLowestAvailableChannelIndex();
if (idx === null) {
const limitMsg = this.maxChannels < 40
? `No available channel slots. All ${this.maxChannels} channels are in use (limited by bot.max_channels). Increase [Bot] max_channels in config.ini (max 40).`
: `No available channel slots. All ${this.maxChannels} channels are in use.`;
this.showError(limitMsg);
return false;
}
const btn = opts.button || null;
const originalHtml = btn ? btn.innerHTML : '';
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Creating...';
}
try {
const pr = await MeshCoreChannelOps.postChannel({ name: name, channel_idx: idx });
const result = pr.result;
if (!pr.ok) {
this.showError(result.error || 'Failed to create channel');
return false;
}
if (result.pending && result.operation_id) {
if (btn) {
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Processing...';
}
const pollStatus = await MeshCoreChannelOps.pollChannelOperation(result.operation_id, {
button: btn,
originalButtonText: originalHtml,
onFailed: (msg) => { this.showError(msg); },
onSlowOperationWarning: () => {
this.showError('Channel operation is taking longer than expected. Check the Radio page if this stalls.');
},
onFinalTimeout: async () => {
this.showError('Channel operation timed out. Check the Radio page or channel list.');
await this.loadChannels();
}
});
if (pollStatus !== 'completed') {
return false;
}
}
await this.loadChannels();
await this.loadChannelStats();
this.updateChannelSelect();
document.getElementById('channelSelect').value = name;
document.getElementById('newChannelName').value = '';
document.getElementById('newChannelGroup').style.display = 'none';
this.showSuccess('Channel created');
return true;
} catch (e) {
this.showError('Error creating channel: ' + e.message);
return false;
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = originalHtml;
}
}
}
async createChannelFromFeedModal() {
const raw = document.getElementById('newChannelName').value.trim();
if (!raw) {
this.showError('Enter a channel name (e.g. #onion)');
return;
}
const submitBtn = document.getElementById('submitNewChannelBtn');
await this.createMeshChannel(raw, { button: submitBtn });
}
/**
* If the user typed a new channel name, create it on the bot before saving the feed.
* @returns {Promise<boolean>}
*/
async ensureChannelReadyForFeedSave() {
const raw = document.getElementById('newChannelName').value.trim();
const select = document.getElementById('channelSelect');
if (!raw) {
if (!select.value) {
this.showError('Select a channel or create a new one.');
return false;
}
return true;
}
const name = raw.startsWith('#') ? raw : '#' + raw;
const exists = this.channels.some(c => (c.name || c.channel_name) === name);
if (exists) {
select.value = name;
return true;
}
return await this.createMeshChannel(name, { button: document.getElementById('saveFeedBtn') });
}
async loadFeeds() {
try {
const response = await fetch('/api/feeds');
const data = await response.json();
this.feeds = data.feeds || [];
this.renderFeeds();
} catch (error) {
console.error('Error loading feeds:', error);
this.showError('Failed to load feeds: ' + error.message);
}
}
async loadStatistics() {
try {
const response = await fetch('/api/feeds/stats');
const data = await response.json();
document.getElementById('total-subscriptions').textContent = data.total_subscriptions || 0;
document.getElementById('active-feeds').textContent = data.enabled_subscriptions || 0;
document.getElementById('items-24h').textContent = data.items_24h || 0;
document.getElementById('active-errors').textContent = data.active_errors || 0;
} catch (error) {
console.error('Error loading statistics:', error);
}
}
updateChannelSelect() {
const select = document.getElementById('channelSelect');
select.replaceChildren();
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = 'Select channel...';
select.appendChild(placeholder);
this.channels.forEach(channel => {
const option = document.createElement('option');
option.value = channel.name || channel.channel_name;
option.textContent = channel.name || channel.channel_name;
select.appendChild(option);
});
}
renderFeeds() {
const tbody = document.getElementById('feedsTableBody');
tbody.replaceChildren();
if (this.feeds.length === 0) {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 9;
cell.className = 'text-center';
cell.textContent = 'No feed subscriptions';
row.appendChild(cell);
tbody.appendChild(row);
return;
}
this.feeds.forEach(feed => {
// Parse timestamp and convert to local time
let lastCheck = 'Never';
if (feed.last_check_time) {
try {
// Handle ISO format with timezone, or SQLite format (treat as UTC)
let dateStr = feed.last_check_time;
// If it's SQLite format (YYYY-MM-DD HH:MM:SS), append 'Z' to indicate UTC
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(dateStr)) {
dateStr = dateStr.replace(' ', 'T') + 'Z';
}
const date = new Date(dateStr);
if (!isNaN(date.getTime())) {
lastCheck = date.toLocaleString();
}
} catch (e) {
lastCheck = feed.last_check_time; // Fallback to raw value
}
}
const row = document.createElement('tr');
const addTextCell = (value) => {
const cell = document.createElement('td');
cell.textContent = String(value ?? '');
row.appendChild(cell);
return cell;
};
addTextCell(feed.id);
addTextCell(feed.feed_name || String(feed.feed_url || '').substring(0, 30));
const typeCell = addTextCell('');
const typeBadge = document.createElement('span');
typeBadge.className = 'badge bg-info';
typeBadge.textContent = String(feed.feed_type || '').toUpperCase();
typeCell.appendChild(typeBadge);
addTextCell(feed.channel_name);
const statusCell = addTextCell('');
const statusBadge = document.createElement('span');
statusBadge.className = `badge ${feed.enabled ? 'bg-success' : 'bg-secondary'}`;
statusBadge.textContent = feed.enabled ? 'Enabled' : 'Disabled';
statusCell.appendChild(statusBadge);
addTextCell(lastCheck);
addTextCell(feed.item_count || 0);
const errorCell = addTextCell('');
if (Number(feed.error_count) > 0) {
const errorBadge = document.createElement('span');
errorBadge.className = 'badge bg-danger';
errorBadge.textContent = String(feed.error_count);
errorCell.appendChild(errorBadge);
} else {
errorCell.textContent = '0';
}
const actions = addTextCell('');
const addAction = (classes, iconClass, title, handler) => {
const button = document.createElement('button');
button.type = 'button';
button.className = `btn btn-sm ${classes}`;
button.title = title;
const icon = document.createElement('i');
icon.className = `fas ${iconClass}`;
button.appendChild(icon);
button.addEventListener('click', handler);
actions.appendChild(button);
actions.appendChild(document.createTextNode(' '));
};
addAction('btn-info', 'fa-eye', 'View Details', () => this.viewFeed(feed.id));
addAction('btn-primary', 'fa-edit', 'Edit', () => this.editFeed(feed.id));
addAction(
feed.enabled ? 'btn-warning' : 'btn-success',
feed.enabled ? 'fa-pause' : 'fa-play',
feed.enabled ? 'Disable' : 'Enable',
() => this.toggleFeed(feed.id, !feed.enabled),
);
addAction('btn-danger', 'fa-trash', 'Delete', () => this.deleteFeed(feed.id));
tbody.appendChild(row);
});
}
setupEventHandlers() {
// Feed type change
document.getElementById('feedType').addEventListener('change', (e) => {
const apiConfigGroup = document.getElementById('apiConfigGroup');
apiConfigGroup.style.display = e.target.value === 'api' ? 'block' : 'none';
// If switching to API, expand the API config accordion
if (e.target.value === 'api') {
const apiCollapseEl = document.getElementById('apiConfigCollapse');
if (apiCollapseEl) {
const apiCollapse = new bootstrap.Collapse(apiCollapseEl, {show: true});
}
}
});
// Create channel button (toggle extra row)
document.getElementById('createChannelBtn').addEventListener('click', () => {
const newChannelGroup = document.getElementById('newChannelGroup');
newChannelGroup.style.display = newChannelGroup.style.display === 'none' ? 'block' : 'none';
});
document.getElementById('submitNewChannelBtn').addEventListener('click', () => {
this.createChannelFromFeedModal();
});
// Save feed
document.getElementById('saveFeedBtn').addEventListener('click', () => this.saveFeed());
// Preview button
document.getElementById('previewBtn').addEventListener('click', () => this.previewFormat());
// Reset-all-errors button (inline onclick removed for the nonce CSP)
const resetAllErrorsBtn = document.getElementById('resetAllErrorsBtn');
if (resetAllErrorsBtn) {
resetAllErrorsBtn.addEventListener('click', () => this.resetAllErrors());
}
// Load default format when opening add modal
document.getElementById('addFeedModal').addEventListener('show.bs.modal', () => {
this.loadChannelStats();
if (!document.getElementById('feedId').value) {
// Only load default if it's a new feed (not editing)
this.loadDefaultFormat();
}
});
// Reset form when modal is hidden
document.getElementById('addFeedModal').addEventListener('hidden.bs.modal', () => {
this.resetForm();
});
}
async editFeed(feedId) {
try {
const response = await fetch(`/api/feeds/${feedId}`);
const feed = await response.json();
// Populate form with feed data
document.getElementById('feedId').value = feed.id;
document.getElementById('feedModalTitle').textContent = 'Edit Feed Subscription';
document.getElementById('feedType').value = feed.feed_type;
document.getElementById('feedUrl').value = feed.feed_url;
document.getElementById('feedUrl').disabled = true; // Don't allow changing URL
document.getElementById('channelSelect').value = feed.channel_name;
document.getElementById('newChannelName').value = '';
document.getElementById('newChannelGroup').style.display = 'none';
document.getElementById('feedName').value = feed.feed_name || '';
document.getElementById('checkInterval').value = feed.check_interval_seconds || 300;
// Load default format if feed doesn't have a custom one
if (feed.output_format) {
document.getElementById('outputFormat').value = feed.output_format;
} else {
await this.loadDefaultFormat();
}
document.getElementById('messageSendInterval').value = feed.message_send_interval_seconds || 2.0;
if (feed.feed_type === 'api') {
document.getElementById('apiConfigGroup').style.display = 'block';
document.getElementById('apiConfig').value = feed.api_config ? JSON.stringify(JSON.parse(feed.api_config), null, 2) : '';
// Expand API config accordion when editing API feed
const apiCollapseEl = document.getElementById('apiConfigCollapse');
if (apiCollapseEl) {
const apiCollapse = new bootstrap.Collapse(apiCollapseEl, {show: true});
}
} else {
document.getElementById('apiConfigGroup').style.display = 'none';
}
// Load filter config if present
if (feed.filter_config) {
try {
document.getElementById('filterConfig').value = JSON.stringify(JSON.parse(feed.filter_config), null, 2);
} catch (e) {
document.getElementById('filterConfig').value = feed.filter_config;
}
} else {
document.getElementById('filterConfig').value = '';
}
// Load sort config if present
if (feed.sort_config) {
try {
document.getElementById('sortConfig').value = JSON.stringify(JSON.parse(feed.sort_config), null, 2);
} catch (e) {
document.getElementById('sortConfig').value = feed.sort_config;
}
} else {
document.getElementById('sortConfig').value = '';
}
const modal = new bootstrap.Modal(document.getElementById('addFeedModal'));
modal.show();
} catch (error) {
this.showError('Error loading feed: ' + error.message);
}
}
async saveFeed() {
if (!(await this.ensureChannelReadyForFeedSave())) {
return;
}
const form = document.getElementById('addFeedForm');
const formData = new FormData(form);
const feedId = document.getElementById('feedId').value;
const isEdit = !!feedId;
const channelName = document.getElementById('channelSelect').value;
if (!channelName) {
this.showError('Select a channel');
return;
}
const data = {
feed_type: formData.get('feed_type'),
feed_url: formData.get('feed_url'),
channel_name: channelName,
feed_name: formData.get('feed_name') || null,
check_interval_seconds: parseInt(formData.get('check_interval_seconds')) || 300,
output_format: formData.get('output_format') || null,
message_send_interval_seconds: parseFloat(formData.get('message_send_interval_seconds')) || 2.0
};
if (data.feed_type === 'api') {
const apiConfigText = document.getElementById('apiConfig').value;
if (apiConfigText) {
try {
data.api_config = JSON.parse(apiConfigText);
} catch (e) {
this.showError('Invalid API config JSON');
return;
}
}
}
// Parse filter config if provided
const filterConfigText = document.getElementById('filterConfig').value;
if (filterConfigText && filterConfigText.trim()) {
try {
data.filter_config = JSON.parse(filterConfigText);
} catch (e) {
this.showError('Invalid filter config JSON: ' + e.message);
return;
}
} else {
data.filter_config = null;
}
// Parse sort config if provided
const sortConfigText = document.getElementById('sortConfig').value;
if (sortConfigText && sortConfigText.trim()) {
try {
data.sort_config = JSON.parse(sortConfigText);
} catch (e) {
this.showError('Invalid sort config JSON: ' + e.message);
return;
}
} else {
data.sort_config = null;
}
try {
const url = isEdit ? `/api/feeds/${feedId}` : '/api/feeds';
const method = isEdit ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok) {
this.showSuccess(`Feed subscription ${isEdit ? 'updated' : 'created'}`);
bootstrap.Modal.getInstance(document.getElementById('addFeedModal')).hide();
this.resetForm();
await this.loadFeeds();
await this.loadStatistics();
} else {
this.showError(result.error || `Failed to ${isEdit ? 'update' : 'create'} feed`);
}
} catch (error) {
this.showError(`Error ${isEdit ? 'updating' : 'creating'} feed: ` + error.message);
}
}
async loadDefaultFormat() {
try {
const response = await fetch('/api/feeds/default-format');
const data = await response.json();
document.getElementById('outputFormat').value = data.default_format || '{emoji} {body|truncate:100} - {date}\n{link|truncate:50}';
} catch (error) {
console.error('Error loading default format:', error);
// Fallback to hardcoded default
document.getElementById('outputFormat').value = '{emoji} {body|truncate:100} - {date}\n{link|truncate:50}';
}
}
async previewFormat() {
const feedUrl = document.getElementById('feedUrl').value;
const feedType = document.getElementById('feedType').value;
const outputFormat = document.getElementById('outputFormat').value;
const apiConfigText = document.getElementById('apiConfig').value;
const filterConfigText = document.getElementById('filterConfig').value;
const sortConfigText = document.getElementById('sortConfig').value;
if (!feedUrl) {
this.showError('Please enter a feed URL first');
return;
}
if (!outputFormat) {
this.showError('Please enter an output format');
return;
}
const previewResults = document.getElementById('previewResults');
const previewContent = document.getElementById('previewContent');
previewResults.style.display = 'block';
const spinner = document.createElement('div');
spinner.className = 'spinner-border spinner-border-sm';
spinner.setAttribute('role', 'status');
const spinnerText = document.createElement('span');
spinnerText.className = 'visually-hidden';
spinnerText.textContent = 'Loading...';
spinner.appendChild(spinnerText);
previewContent.replaceChildren(spinner);
try {
const data = {
feed_url: feedUrl,
feed_type: feedType,
output_format: outputFormat
};
if (feedType === 'api' && apiConfigText) {
try {
data.api_config = JSON.parse(apiConfigText);
} catch (e) {
this.showError('Invalid API config JSON');
previewResults.style.display = 'none';
return;
}
}
if (filterConfigText && filterConfigText.trim()) {
try {
data.filter_config = JSON.parse(filterConfigText);
} catch (e) {
this.showError('Invalid filter config JSON: ' + e.message);
previewResults.style.display = 'none';
return;
}
}
if (sortConfigText && sortConfigText.trim()) {
try {
data.sort_config = JSON.parse(sortConfigText);
} catch (e) {
this.showError('Invalid sort config JSON: ' + e.message);
previewResults.style.display = 'none';
return;
}
}
const response = await fetch('/api/feeds/preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.items) {
previewContent.replaceChildren();
if (result.items.length === 0) {
const empty = document.createElement('p');
empty.className = 'text-muted small mb-0';
empty.textContent = 'No items found in feed';
previewContent.appendChild(empty);
} else {
result.items.forEach((item, index) => {
const wrapper = document.createElement('div');
wrapper.className = 'mb-2 p-2 border rounded';
const label = document.createElement('small');
label.className = 'text-muted d-block mb-1 fw-bold';
label.textContent = `Item ${index + 1}:`;
const formatted = document.createElement('pre');
formatted.className = 'mb-1 bg-light p-2 rounded small feed-preview-text';
formatted.textContent = String(item.formatted ?? '');
wrapper.append(label, formatted);
previewContent.appendChild(wrapper);
});
}
} else {
const errorMessage = document.createElement('p');
errorMessage.className = 'text-danger small mb-0';
errorMessage.textContent = `Error: ${result.error || 'Failed to preview feed'}`;
previewContent.replaceChildren(errorMessage);
}
} catch (error) {
const errorMessage = document.createElement('p');
errorMessage.className = 'text-danger';
errorMessage.textContent = `Error: ${error.message}`;
previewContent.replaceChildren(errorMessage);
}
}
resetForm() {
const form = document.getElementById('addFeedForm');
form.reset();
document.getElementById('feedId').value = '';
document.getElementById('feedModalTitle').textContent = 'Add Feed Subscription';
document.getElementById('feedUrl').disabled = false;
document.getElementById('apiConfigGroup').style.display = 'none';
document.getElementById('newChannelGroup').style.display = 'none';
document.getElementById('newChannelName').value = '';
document.getElementById('previewResults').style.display = 'none';
document.getElementById('filterConfig').value = '';
document.getElementById('sortConfig').value = '';
}
async viewFeed(feedId) {
try {
const response = await fetch(`/api/feeds/${feedId}`);
const feed = await response.json();
let lastCheck = 'Never';
if (feed.last_check_time) {
try {
let dateStr = feed.last_check_time;
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(dateStr)) {
dateStr = dateStr.replace(' ', 'T') + 'Z';
}
const date = new Date(dateStr);
lastCheck = !isNaN(date.getTime()) ? date.toLocaleString() : feed.last_check_time;
} catch (e) {
lastCheck = feed.last_check_time;
}
}
const content = document.getElementById('feedDetailsContent');
const row = document.createElement('div');
row.className = 'row';
const configColumn = document.createElement('div');
configColumn.className = 'col-md-6';
const activityColumn = document.createElement('div');
activityColumn.className = 'col-md-6';
const addHeading = (parent, text) => {
const heading = document.createElement('h6');
heading.textContent = text;
parent.appendChild(heading);
};
const addDetail = (parent, label, value) => {
const paragraph = document.createElement('p');
const strong = document.createElement('strong');
strong.textContent = `${label}:`;
paragraph.append(strong, document.createTextNode(` ${String(value ?? '')}`));
parent.appendChild(paragraph);
};
addHeading(configColumn, 'Configuration');
addDetail(configColumn, 'Name', feed.feed_name || 'N/A');
addDetail(configColumn, 'Type', String(feed.feed_type || '').toUpperCase());
addDetail(configColumn, 'URL', feed.feed_url);
addDetail(configColumn, 'Channel', feed.channel_name);
addDetail(configColumn, 'Check Interval', `${feed.check_interval_seconds}s`);
addDetail(configColumn, 'Send Interval', `${feed.message_send_interval_seconds || 2.0}s`);
addDetail(configColumn, 'Status', feed.enabled ? 'Enabled' : 'Disabled');
if (feed.output_format) {
const paragraph = document.createElement('p');
const strong = document.createElement('strong');
strong.textContent = 'Output Format:';
const format = document.createElement('code');
format.className = 'small d-block feed-output-format';
format.textContent = String(feed.output_format);
paragraph.append(strong, format);
configColumn.appendChild(paragraph);
}
addHeading(activityColumn, 'Activity');
addDetail(activityColumn, 'Last Check', lastCheck);
addDetail(activityColumn, 'Last Item', feed.last_item_id ? String(feed.last_item_id).substring(0, 30) + '...' : 'None');
addDetail(activityColumn, 'Total Items', feed.activity?.length || 0);
// Errors row: value plus, when non-zero, a per-feed reset button.
// Wired via addEventListener because /feeds runs under a nonce CSP
// (no unsafe-inline), so an inline onclick handler would be blocked.
const errorCount = feed.errors?.length || 0;
const errorParagraph = document.createElement('p');
const errorStrong = document.createElement('strong');
errorStrong.textContent = 'Errors:';
errorParagraph.append(errorStrong, document.createTextNode(` ${errorCount}`));
if (errorCount > 0) {
const resetButton = document.createElement('button');
resetButton.type = 'button';
resetButton.className = 'btn btn-sm btn-outline-secondary ms-2';
resetButton.title = 'Clear recorded errors for this feed';
const eraseIcon = document.createElement('i');
eraseIcon.className = 'fas fa-eraser';
resetButton.append(eraseIcon, document.createTextNode(' Reset'));
resetButton.addEventListener('click', () => this.resetFeedErrors(feed.id));
errorParagraph.append(document.createTextNode(' '), resetButton);
}
activityColumn.appendChild(errorParagraph);
row.append(configColumn, activityColumn);
content.replaceChildren(row);
const modal = new bootstrap.Modal(document.getElementById('feedDetailsModal'));
modal.show();
} catch (error) {
this.showError('Error loading feed details: ' + error.message);
}
}
async toggleFeed(feedId, enabled) {
try {
const response = await fetch(`/api/feeds/${feedId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({enabled: enabled})
});
if (response.ok) {
this.showSuccess(`Feed ${enabled ? 'enabled' : 'disabled'}`);
await this.loadFeeds();
} else {
const result = await response.json();
this.showError(result.error || 'Failed to update feed');
}
} catch (error) {
this.showError('Error updating feed: ' + error.message);
}
}
async deleteFeed(feedId) {
if (!confirm('Are you sure you want to delete this feed subscription?')) {
return;
}
try {
const response = await fetch(`/api/feeds/${feedId}`, {
method: 'DELETE',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
if (response.ok) {
this.showSuccess('Feed subscription deleted');
await this.loadFeeds();
await this.loadStatistics();
} else {
const result = await response.json();
this.showError(result.error || 'Failed to delete feed');
}
} catch (error) {
this.showError('Error deleting feed: ' + error.message);
}
}
async resetAllErrors() {
if (!confirm('Reset error counts for ALL feeds? This clears the recorded error history.')) {
return;
}
try {
const response = await fetch('/api/feeds/errors/reset', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
if (response.ok) {
this.showSuccess('Feed errors reset');
await this.loadFeeds();
} else {
const result = await response.json();
this.showError(result.error || 'Failed to reset feed errors');
}
} catch (error) {
this.showError('Error resetting feed errors: ' + error.message);
}
}
async resetFeedErrors(feedId) {
try {
const response = await fetch(`/api/feeds/${feedId}/errors/reset`, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
if (response.ok) {
this.showSuccess('Feed errors reset');
await this.loadFeeds();
await this.viewFeed(feedId);
} else {
const result = await response.json();
this.showError(result.error || 'Failed to reset feed errors');
}
} catch (error) {
this.showError('Error resetting feed errors: ' + error.message);
}
}
showError(message) {
this.showAlert(message, 'danger', 5000);
}
showSuccess(message) {
this.showAlert(message, 'success', 3000);
}
showAlert(message, kind, timeout) {
const alert = document.createElement('div');
alert.className = `alert alert-${kind} alert-dismissible fade show position-fixed`;
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; max-width: 300px;';
const messageNode = document.createElement('span');
messageNode.textContent = String(message ?? '');
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'btn-close';
closeButton.setAttribute('data-bs-dismiss', 'alert');
closeButton.setAttribute('aria-label', 'Close');
alert.append(messageNode, closeButton);
document.body.appendChild(alert);
setTimeout(() => alert.remove(), timeout);
}
}
// Initialize feed manager when page loads
let feedManager;
document.addEventListener('DOMContentLoaded', () => {
feedManager = new FeedManager();
});
</script>
<style>
.feed-preview-text {
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 0.8rem;
background-color: var(--bg-secondary) !important;
color: var(--text-color) !important;
}
.feed-output-format {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* Dark mode table styling with better contrast - matching contacts page */
[data-theme="dark"] .table-striped > tbody > tr:nth-of-type(odd) > td {
background-color: #3a3a3a; /* Slightly lighter than even rows, but not too light */
}
[data-theme="dark"] .table-striped > tbody > tr:nth-of-type(even) > td {
background-color: #2d2d2d; /* Darker gray (card-bg) */
}
[data-theme="dark"] .table-hover > tbody > tr:hover > td {
background-color: #404040 !important; /* Slightly lighter for hover, but maintains good contrast */
}
[data-theme="dark"] .table-dark {
background-color: var(--bg-tertiary);
color: var(--text-color);
}
[data-theme="dark"] .table-dark th {
background-color: var(--bg-tertiary);
border-color: var(--border-color);
color: var(--text-color);
}
[data-theme="dark"] .table td {
border-color: var(--border-color);
color: var(--text-color);
}
/* Ensure all text in table cells is light in dark mode */
[data-theme="dark"] .table td,
[data-theme="dark"] .table td * {
color: var(--text-color) !important;
}
/* Ensure small text is also light */
[data-theme="dark"] .table td small {
color: var(--text-muted) !important;
}
</style>
{% endblock %}