Skip to main content

max / balanced_breakfast

11.0 KB · 297 lines History Blame Raw
1 /**
2 * @fileoverview Feed management: add, refresh, OPML import/export.
3 *
4 * Adding a feed is a two-step flow: first the user picks a plugin (busser),
5 * then fills in the plugin's config schema form. This avoids showing one
6 * giant form and lets each plugin define its own fields.
7 */
8 (function() {
9 'use strict';
10
11 /**
12 * Show a one-time warning about the plugin threat model before the user
13 * adds their first feed. Stored in localStorage so it only appears once.
14 * @returns {Promise<boolean>} true if the user accepts (or has already accepted).
15 */
16 async function checkPluginWarning() {
17 if (localStorage.getItem('bb_plugin_warning_ack')) return true;
18
19 const sources = BB.state.sources || [];
20 if (sources.length > 0) {
21 // Already has feeds — no need to warn
22 localStorage.setItem('bb_plugin_warning_ack', '1');
23 return true;
24 }
25
26 return new Promise((resolve) => {
27 const overlay = document.getElementById('modal-overlay');
28 const title = document.getElementById('modal-title');
29 const body = document.getElementById('modal-body');
30
31 title.textContent = 'Before You Add a Plugin';
32 // F4 (2026-06-02): per-`<p>` margins now come from .modal-body
33 // `p + p` default; the trailing fine-print uses .modal-intro
34 // for the muted color treatment.
35 body.innerHTML =
36 '<p>Plugins are Rhai scripts that run inside a sandboxed environment. ' +
37 'They <strong>cannot</strong> access your filesystem or run programs, but they <strong>can</strong> make HTTP requests to fetch feed data.</p>' +
38 '<p>Only add plugins from sources you trust. A malicious plugin could ' +
39 'send your configured API keys to a third-party server.</p>' +
40 '<p class="modal-intro">This warning only appears once.</p>';
41
42 const actions = document.createElement('div');
43 actions.className = 'form-actions';
44
45 const cancelBtn = document.createElement('button');
46 cancelBtn.type = 'button';
47 cancelBtn.className = 'btn';
48 cancelBtn.textContent = 'Cancel';
49 cancelBtn.onclick = () => { BB.ui.closeModal(); resolve(false); };
50 actions.appendChild(cancelBtn);
51
52 const acceptBtn = document.createElement('button');
53 acceptBtn.type = 'button';
54 acceptBtn.className = 'btn btn-primary';
55 acceptBtn.textContent = 'I Understand';
56 acceptBtn.onclick = () => {
57 localStorage.setItem('bb_plugin_warning_ack', '1');
58 BB.ui.closeModal();
59 resolve(true);
60 };
61 actions.appendChild(acceptBtn);
62
63 body.appendChild(actions);
64 overlay.style.display = 'flex';
65 });
66 }
67
68 /**
69 * Open the "Add Feed" modal. Step 1: show plugin picker.
70 * On plugin click, proceeds to step 2 (plugin-specific config form).
71 */
72 async function openAddFeed() {
73 try {
74 if (!await checkPluginWarning()) return;
75
76 const plugins = await BB.api.plugins.list();
77
78 if (plugins.length === 0) {
79 BB.ui.showToast('No plugins available', 'error');
80 return;
81 }
82
83 // Show plugin selection
84 const overlay = document.getElementById('modal-overlay');
85 const title = document.getElementById('modal-title');
86 const body = document.getElementById('modal-body');
87
88 title.textContent = 'Add Feed';
89 body.innerHTML = '';
90
91 const intro = document.createElement('p');
92 intro.className = 'modal-intro';
93 intro.textContent = 'Select a source type:';
94 body.appendChild(intro);
95
96 const list = document.createElement('ul');
97 list.className = 'plugin-list';
98
99 // Sort recommended plugins first
100 const recommended = new Set(['rss', 'mastodon', 'reddit']);
101 const sorted = [...plugins].sort((a, b) => {
102 const aRec = recommended.has(a.id) ? 0 : 1;
103 const bRec = recommended.has(b.id) ? 0 : 1;
104 return aRec - bRec || a.name.localeCompare(b.name);
105 });
106
107 sorted.forEach(plugin => {
108 const row = BB.ui.renderRow({
109 tag: 'li',
110 className: 'plugin-item',
111 primary: plugin.name,
112 secondary: plugin.description || null,
113 badges: recommended.has(plugin.id)
114 ? [{ label: 'Recommended', color: 'yellow', filled: true }]
115 : null,
116 onClick: () => selectPlugin(plugin.id),
117 });
118 list.appendChild(row);
119 });
120
121 body.appendChild(list);
122 overlay.style.display = 'flex';
123 } catch (err) {
124 BB.ui.showToast('Failed to load plugins: ' + BB.utils.getErrorMessage(err), 'error');
125 }
126 }
127
128 /**
129 * Step 2 of add-feed: fetch the plugin's config schema, then show its form.
130 * @param {string} pluginId - Plugin identifier selected in step 1.
131 */
132 async function selectPlugin(pluginId) {
133 BB.state.set('selectedPluginId', pluginId);
134
135 try {
136 const schema = await BB.api.plugins.schema(pluginId);
137 showPluginForm(schema);
138 } catch (err) {
139 BB.ui.showToast('Failed to load plugin schema: ' + BB.utils.getErrorMessage(err), 'error');
140 }
141 }
142
143 /**
144 * Build the add-feed form from the plugin schema and open it.
145 * Prepends a "Feed Name" field before the plugin-specific fields.
146 * @param {Object} schema - Plugin config schema with `name` and `fields` array.
147 */
148 function showPluginForm(schema) {
149 const fields = [
150 { name: 'name', type: 'text', label: 'Feed Name', required: true, placeholder: 'My Feed' },
151 ];
152
153 schema.fields.forEach(f => {
154 fields.push({
155 name: f.key,
156 type: f.fieldType,
157 label: f.label,
158 required: f.required,
159 value: f.default || '',
160 options: f.options,
161 placeholder: f.placeholder || '',
162 description: f.description,
163 });
164 });
165
166 BB.ui.openFormModal({
167 title: 'Add ' + schema.name + ' Feed',
168 fields,
169 submitLabel: 'Add Feed',
170 onSubmit: async (data) => {
171 const name = data.name;
172 delete data.name;
173
174 await BB.api.feeds.create({
175 busserId: BB.state.selectedPluginId,
176 name,
177 config: data,
178 });
179
180 BB.ui.showToast('Feed created! Fetching articles...');
181 await BB.sources.load();
182 refresh();
183 },
184 });
185 }
186
187 /**
188 * Trigger a fetch of all enabled feeds. Shows progress bar and result toast.
189 * @returns {Promise<void>}
190 */
191 async function refresh() {
192 const btn = document.getElementById('refresh-btn');
193 // Lock button width before changing text to prevent header reflow
194 btn.style.width = btn.offsetWidth + 'px';
195 btn.disabled = true;
196 btn.textContent = 'Refreshing...';
197
198 const header = document.querySelector('.header');
199 const progress = BB.ui.showProgress(header);
200
201 // Listen for per-plugin progress events
202 let unlisten = null;
203 try {
204 unlisten = await window.__TAURI__.event.listen('fetch-progress', (event) => {
205 const { completed, total } = event.payload;
206 if (total > 0) {
207 progress.set(Math.round((completed / total) * 100));
208 }
209 });
210 } catch (_) {
211 // If event listener fails, progress just won't update
212 }
213
214 try {
215 progress.set(5); // indicate started
216
217 const result = await BB.api.feeds.fetchAll();
218 progress.set(100);
219
220 let msg = 'Fetched ' + result.itemsFetched + ' item' + (result.itemsFetched !== 1 ? 's' : '');
221 if (result.errors && result.errors.length > 0) {
222 msg += ' (' + result.errors.length + ' feed' + (result.errors.length !== 1 ? 's' : '') + ' failed)';
223 BB.ui.showErrorWithRetry(msg, refresh);
224 } else {
225 BB.ui.showToast(msg);
226 }
227
228 BB.sources.load();
229 BB.items.load();
230 } catch (err) {
231 progress.set(100);
232 BB.ui.showErrorWithRetry('Failed to refresh: ' + BB.utils.getErrorMessage(err), refresh);
233 } finally {
234 if (unlisten) unlisten();
235 setTimeout(() => progress.remove(), 400);
236 btn.disabled = false;
237 btn.textContent = 'Refresh';
238 btn.style.width = '';
239 }
240 }
241
242 /**
243 * Export all feeds as an OPML file download.
244 * @returns {Promise<void>}
245 */
246 async function exportOpml() {
247 try {
248 const opml = await BB.api.opml.export();
249 const blob = new Blob([opml], { type: 'application/xml' });
250 const url = URL.createObjectURL(blob);
251 const a = document.createElement('a');
252 a.href = url;
253 a.download = 'balanced-breakfast-feeds.opml';
254 document.body.appendChild(a);
255 a.click();
256 document.body.removeChild(a);
257 URL.revokeObjectURL(url);
258 BB.ui.showToast('Feeds exported');
259 } catch (err) {
260 BB.ui.showToast('Export failed: ' + BB.utils.getErrorMessage(err), 'error');
261 }
262 }
263
264 /** Open a file picker for OPML import, then send content to backend. */
265 function importOpml() {
266 const input = document.createElement('input');
267 input.type = 'file';
268 input.accept = '.opml,.xml';
269 input.onchange = async (e) => {
270 const file = e.target.files[0];
271 if (!file) return;
272
273 BB.ui.showToast('Importing feeds...', 'success', { duration: 10000 });
274
275 try {
276 const content = await file.text();
277 const result = await BB.api.opml.import(content);
278 let msg = 'Imported ' + result.imported + ' feed' + (result.imported !== 1 ? 's' : '');
279 if (result.skipped > 0) {
280 msg += ', ' + result.skipped + ' duplicate' + (result.skipped !== 1 ? 's' : '') + ' skipped';
281 }
282 if (result.errors.length > 0) {
283 msg += ', ' + result.errors.length + ' error' + (result.errors.length !== 1 ? 's' : '');
284 }
285 BB.ui.showToast(msg);
286 BB.sources.load();
287 BB.items.load();
288 } catch (err) {
289 BB.ui.showToast('Import failed: ' + BB.utils.getErrorMessage(err), 'error');
290 }
291 };
292 input.click();
293 }
294
295 BB.feeds = { openAddFeed, selectPlugin, refresh, exportOpml, importOpml };
296 })();
297