/** * @fileoverview Feed management: add, refresh, OPML import/export. * * Adding a feed is a two-step flow: first the user picks a plugin (busser), * then fills in the plugin's config schema form. This avoids showing one * giant form and lets each plugin define its own fields. */ (function() { 'use strict'; /** * Show a one-time warning about the plugin threat model before the user * adds their first feed. Stored in localStorage so it only appears once. * @returns {Promise} true if the user accepts (or has already accepted). */ async function checkPluginWarning() { if (localStorage.getItem('bb_plugin_warning_ack')) return true; const sources = BB.state.sources || []; if (sources.length > 0) { // Already has feeds — no need to warn localStorage.setItem('bb_plugin_warning_ack', '1'); return true; } return new Promise((resolve) => { const overlay = document.getElementById('modal-overlay'); const title = document.getElementById('modal-title'); const body = document.getElementById('modal-body'); title.textContent = 'Before You Add a Plugin'; // F4 (2026-06-02): per-`

` margins now come from .modal-body // `p + p` default; the trailing fine-print uses .modal-intro // for the muted color treatment. body.innerHTML = '

Plugins are Rhai scripts that run inside a sandboxed environment. ' + 'They cannot access your filesystem or run programs, but they can make HTTP requests to fetch feed data.

' + '

Only add plugins from sources you trust. A malicious plugin could ' + 'send your configured API keys to a third-party server.

' + ''; const actions = document.createElement('div'); actions.className = 'form-actions'; const cancelBtn = document.createElement('button'); cancelBtn.type = 'button'; cancelBtn.className = 'btn'; cancelBtn.textContent = 'Cancel'; cancelBtn.onclick = () => { BB.ui.closeModal(); resolve(false); }; actions.appendChild(cancelBtn); const acceptBtn = document.createElement('button'); acceptBtn.type = 'button'; acceptBtn.className = 'btn btn-primary'; acceptBtn.textContent = 'I Understand'; acceptBtn.onclick = () => { localStorage.setItem('bb_plugin_warning_ack', '1'); BB.ui.closeModal(); resolve(true); }; actions.appendChild(acceptBtn); body.appendChild(actions); overlay.style.display = 'flex'; }); } /** * Open the "Add Feed" modal. Step 1: show plugin picker. * On plugin click, proceeds to step 2 (plugin-specific config form). */ async function openAddFeed() { try { if (!await checkPluginWarning()) return; const plugins = await BB.api.plugins.list(); if (plugins.length === 0) { BB.ui.showToast('No plugins available', 'error'); return; } // Show plugin selection const overlay = document.getElementById('modal-overlay'); const title = document.getElementById('modal-title'); const body = document.getElementById('modal-body'); title.textContent = 'Add Feed'; body.innerHTML = ''; const intro = document.createElement('p'); intro.className = 'modal-intro'; intro.textContent = 'Select a source type:'; body.appendChild(intro); const list = document.createElement('ul'); list.className = 'plugin-list'; // Sort recommended plugins first const recommended = new Set(['rss', 'mastodon', 'reddit']); const sorted = [...plugins].sort((a, b) => { const aRec = recommended.has(a.id) ? 0 : 1; const bRec = recommended.has(b.id) ? 0 : 1; return aRec - bRec || a.name.localeCompare(b.name); }); sorted.forEach(plugin => { const row = BB.ui.renderRow({ tag: 'li', className: 'plugin-item', primary: plugin.name, secondary: plugin.description || null, badges: recommended.has(plugin.id) ? [{ label: 'Recommended', color: 'yellow', filled: true }] : null, onClick: () => selectPlugin(plugin.id), }); list.appendChild(row); }); body.appendChild(list); overlay.style.display = 'flex'; } catch (err) { BB.ui.showToast('Failed to load plugins: ' + BB.utils.getErrorMessage(err), 'error'); } } /** * Step 2 of add-feed: fetch the plugin's config schema, then show its form. * @param {string} pluginId - Plugin identifier selected in step 1. */ async function selectPlugin(pluginId) { BB.state.set('selectedPluginId', pluginId); try { const schema = await BB.api.plugins.schema(pluginId); showPluginForm(schema); } catch (err) { BB.ui.showToast('Failed to load plugin schema: ' + BB.utils.getErrorMessage(err), 'error'); } } /** * Build the add-feed form from the plugin schema and open it. * Prepends a "Feed Name" field before the plugin-specific fields. * @param {Object} schema - Plugin config schema with `name` and `fields` array. */ function showPluginForm(schema) { const fields = [ { name: 'name', type: 'text', label: 'Feed Name', required: true, placeholder: 'My Feed' }, ]; schema.fields.forEach(f => { fields.push({ name: f.key, type: f.fieldType, label: f.label, required: f.required, value: f.default || '', options: f.options, placeholder: f.placeholder || '', description: f.description, }); }); BB.ui.openFormModal({ title: 'Add ' + schema.name + ' Feed', fields, submitLabel: 'Add Feed', onSubmit: async (data) => { const name = data.name; delete data.name; await BB.api.feeds.create({ busserId: BB.state.selectedPluginId, name, config: data, }); BB.ui.showToast('Feed created! Fetching articles...'); await BB.sources.load(); refresh(); }, }); } /** * Trigger a fetch of all enabled feeds. Shows progress bar and result toast. * @returns {Promise} */ async function refresh() { const btn = document.getElementById('refresh-btn'); // Lock button width before changing text to prevent header reflow btn.style.width = btn.offsetWidth + 'px'; btn.disabled = true; btn.textContent = 'Refreshing...'; const header = document.querySelector('.header'); const progress = BB.ui.showProgress(header); // Listen for per-plugin progress events let unlisten = null; try { unlisten = await window.__TAURI__.event.listen('fetch-progress', (event) => { const { completed, total } = event.payload; if (total > 0) { progress.set(Math.round((completed / total) * 100)); } }); } catch (_) { // If event listener fails, progress just won't update } try { progress.set(5); // indicate started const result = await BB.api.feeds.fetchAll(); progress.set(100); let msg = 'Fetched ' + result.itemsFetched + ' item' + (result.itemsFetched !== 1 ? 's' : ''); if (result.errors && result.errors.length > 0) { msg += ' (' + result.errors.length + ' feed' + (result.errors.length !== 1 ? 's' : '') + ' failed)'; BB.ui.showErrorWithRetry(msg, refresh); } else { BB.ui.showToast(msg); } BB.sources.load(); BB.items.load(); } catch (err) { progress.set(100); BB.ui.showErrorWithRetry('Failed to refresh: ' + BB.utils.getErrorMessage(err), refresh); } finally { if (unlisten) unlisten(); setTimeout(() => progress.remove(), 400); btn.disabled = false; btn.textContent = 'Refresh'; btn.style.width = ''; } } /** * Export all feeds as an OPML file download. * @returns {Promise} */ async function exportOpml() { try { const opml = await BB.api.opml.export(); const blob = new Blob([opml], { type: 'application/xml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'balanced-breakfast-feeds.opml'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); BB.ui.showToast('Feeds exported'); } catch (err) { BB.ui.showToast('Export failed: ' + BB.utils.getErrorMessage(err), 'error'); } } /** Open a file picker for OPML import, then send content to backend. */ function importOpml() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.opml,.xml'; input.onchange = async (e) => { const file = e.target.files[0]; if (!file) return; BB.ui.showToast('Importing feeds...', 'success', { duration: 10000 }); try { const content = await file.text(); const result = await BB.api.opml.import(content); let msg = 'Imported ' + result.imported + ' feed' + (result.imported !== 1 ? 's' : ''); if (result.skipped > 0) { msg += ', ' + result.skipped + ' duplicate' + (result.skipped !== 1 ? 's' : '') + ' skipped'; } if (result.errors.length > 0) { msg += ', ' + result.errors.length + ' error' + (result.errors.length !== 1 ? 's' : ''); } BB.ui.showToast(msg); BB.sources.load(); BB.items.load(); } catch (err) { BB.ui.showToast('Import failed: ' + BB.utils.getErrorMessage(err), 'error'); } }; input.click(); } BB.feeds = { openAddFeed, selectPlugin, refresh, exportOpml, importOpml }; })();