Files
agessaman 8653f8c23d Enhance channel management functionality in web viewer
- Updated `pyproject.toml` to include JavaScript files for the web viewer.
- Added a new script reference in `base.html` for channel operations.
- Improved the channel creation process in `feeds.html` with enhanced UI elements and error handling.
- Refactored channel index retrieval in `radio.html` to utilize a centralized method for better maintainability.
- Implemented asynchronous channel statistics loading to improve responsiveness during channel operations.
2026-03-21 17:19:51 -07:00

1038 lines
47 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>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addFeedModal">
<i class="fas fa-plus"></i> Add Feed
</button>
</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='{
"method": "GET",
"headers": {},
"params": {
"AccessCode": "YOUR_ACCESS_CODE"
},
"response_parser": {
"items_path": "",
"id_field": "AlertID",
"title_field": "HeadlineDescription",
"description_field": "ExtendedDescription",
"timestamp_field": "LastUpdatedTime"
}
}'></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='{
"conditions": [
{
"field": "raw.Priority",
"operator": "in",
"values": ["highest", "high"]
},
{
"field": "raw.EventStatus",
"operator": "equals",
"value": "open"
}
],
"logic": "AND"
}'></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='{
"field": "raw.LastUpdatedTime",
"order": "desc"
}'></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>
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.innerHTML = '<option value="">Select channel...</option>';
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');
if (this.feeds.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center">No feed subscriptions</td></tr>';
return;
}
tbody.innerHTML = this.feeds.map(feed => {
const statusBadge = feed.enabled
? '<span class="badge bg-success">Enabled</span>'
: '<span class="badge bg-secondary">Disabled</span>';
// 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
}
}
return `
<tr>
<td>${feed.id}</td>
<td>${feed.feed_name || feed.feed_url.substring(0, 30)}</td>
<td><span class="badge bg-info">${feed.feed_type.toUpperCase()}</span></td>
<td>${feed.channel_name}</td>
<td>${statusBadge}</td>
<td>${lastCheck}</td>
<td>${feed.item_count || 0}</td>
<td>${feed.error_count > 0 ? `<span class="badge bg-danger">${feed.error_count}</span>` : '0'}</td>
<td>
<button class="btn btn-sm btn-info" onclick="feedManager.viewFeed(${feed.id})" title="View Details">
<i class="fas fa-eye"></i>
</button>
<button class="btn btn-sm btn-primary" onclick="feedManager.editFeed(${feed.id})" title="Edit">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-sm btn-${feed.enabled ? 'warning' : 'success'}" onclick="feedManager.toggleFeed(${feed.id}, ${!feed.enabled})" title="${feed.enabled ? 'Disable' : 'Enable'}">
<i class="fas fa-${feed.enabled ? 'pause' : 'play'}"></i>
</button>
<button class="btn btn-sm btn-danger" onclick="feedManager.deleteFeed(${feed.id})" title="Delete">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`;
}).join('');
}
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());
// 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'},
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';
previewContent.innerHTML = '<div class="spinner-border spinner-border-sm" role="status"><span class="visually-hidden">Loading...</span></div>';
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'},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.items) {
if (result.items.length === 0) {
previewContent.innerHTML = '<p class="text-muted small mb-0">No items found in feed</p>';
} else {
previewContent.innerHTML = result.items.map((item, index) => `
<div class="mb-2 p-2 border rounded">
<small class="text-muted d-block mb-1"><strong>Item ${index + 1}:</strong></small>
<pre class="mb-1 bg-light p-2 rounded small" style="white-space: pre-wrap; word-wrap: break-word; font-size: 0.8rem; background-color: var(--bg-secondary) !important; color: var(--text-color) !important;">${item.formatted}</pre>
</div>
`).join('');
}
} else {
previewContent.innerHTML = `<p class="text-danger small mb-0">Error: ${result.error || 'Failed to preview feed'}</p>`;
}
} catch (error) {
previewContent.innerHTML = `<p class="text-danger">Error: ${error.message}</p>`;
}
}
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();
const content = `
<div class="row">
<div class="col-md-6">
<h6>Configuration</h6>
<p><strong>Name:</strong> ${feed.feed_name || 'N/A'}</p>
<p><strong>Type:</strong> ${feed.feed_type.toUpperCase()}</p>
<p><strong>URL:</strong> ${feed.feed_url}</p>
<p><strong>Channel:</strong> ${feed.channel_name}</p>
<p><strong>Check Interval:</strong> ${feed.check_interval_seconds}s</p>
<p><strong>Send Interval:</strong> ${feed.message_send_interval_seconds || 2.0}s</p>
<p><strong>Status:</strong> ${feed.enabled ? 'Enabled' : 'Disabled'}</p>
${feed.output_format ? `<p><strong>Output Format:</strong><br><code class="small">${feed.output_format.replace(/\n/g, '<br>')}</code></p>` : ''}
</div>
<div class="col-md-6">
<h6>Activity</h6>
<p><strong>Last Check:</strong> ${(() => {
if (!feed.last_check_time) return 'Never';
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);
return !isNaN(date.getTime()) ? date.toLocaleString() : feed.last_check_time;
} catch (e) {
return feed.last_check_time;
}
})()}</p>
<p><strong>Last Item:</strong> ${feed.last_item_id ? feed.last_item_id.substring(0, 30) + '...' : 'None'}</p>
<p><strong>Total Items:</strong> ${feed.activity?.length || 0}</p>
<p><strong>Errors:</strong> ${feed.errors?.length || 0}</p>
</div>
</div>
`;
document.getElementById('feedDetailsContent').innerHTML = content;
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'},
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'
});
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);
}
}
showError(message) {
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible fade show position-fixed';
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; max-width: 300px;';
alert.innerHTML = `${message}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
document.body.appendChild(alert);
setTimeout(() => alert.remove(), 5000);
}
showSuccess(message) {
const alert = document.createElement('div');
alert.className = 'alert alert-success alert-dismissible fade show position-fixed';
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; max-width: 300px;';
alert.innerHTML = `${message}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
document.body.appendChild(alert);
setTimeout(() => alert.remove(), 3000);
}
}
// Initialize feed manager when page loads
let feedManager;
document.addEventListener('DOMContentLoaded', () => {
feedManager = new FeedManager();
});
</script>
<style>
/* 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 %}