Files
meshcore-bot/modules/web_viewer/templates/feeds.html
T
agessaman ebdc44cd90 fix(web_viewer): status and accent text passes contrast in both themes
Bootstrap's text-success, text-warning, text-info and text-danger are fill tones, so used as text they failed contrast (amber on white was 1.6:1, info 2.0:1), and nothing adjusted them for dark mode. base.html now defines per-theme status text tokens that pass 4.5:1 on every surface tier, and the text utilities, text-muted, form-text, code, links and the outline buttons resolve through them. Info and warning badges carry dark text. Ink Muted moves a step darker so it holds on paper-sunk.

The dark message bubbles on Realtime were solid navy, forest green and purple, which overpowered the page and dropped the sender names to 1.7:1. They are now low-alpha tints of the signal and lamp hues over the card, like the dashboard delta chips, and the error bubble is red in both themes instead of purple in dark.

Also: the light log levels and level toggles move to tones that pass on the log well, the dark ERROR toggle uses dark text, Realtime drops its local orange text-warning and its opacity on message times, and the four copies of the dark .table td * rule no longer force body text color onto badges and buttons. DESIGN.md records the tokens and the rule that a fill hue never goes on text directly.
2026-10-17 17:46:51 -07:00

1231 lines
56 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" aria-label="Close"></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" for="feedType">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" for="feedUrl">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" for="checkInterval">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" for="channelSelect">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" aria-label="New channel name" 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" for="feedName">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" for="messageSendInterval">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">
<div class="d-flex align-items-baseline form-label">
<label class="mb-0" for="outputFormat">Output Format</label>
<button type="button" class="btn btn-sm btn-link p-0 ms-2" data-bs-toggle="collapse" data-bs-target="#formatHelp" aria-expanded="false" aria-controls="formatHelp">
<i class="fas fa-question-circle" aria-hidden="true"></i> Help
</button>
</div>
<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">
<div class="form-label" aria-hidden="true">&nbsp;</div>
<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" aria-expanded="false" aria-controls="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" aria-label="API configuration (JSON)" 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" aria-expanded="false" aria-controls="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" aria-label="Filter configuration (JSON)" 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" aria-expanded="false" aria-controls="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" aria-label="Sort configuration (JSON)" 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" aria-label="Close"></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;
button.setAttribute('aria-label', title);
const icon = document.createElement('i');
icon.className = `fas ${iconClass}`;
icon.setAttribute('aria-hidden', 'true');
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);
}
/* Table cells take the light text color in dark mode; children with their
own color (badges, buttons, status text) keep it. */
[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 %}