/** * @fileoverview Query feeds: saved filter rules that act as virtual sources. * * Provides CRUD for query feeds and a condition builder modal. */ (function() { 'use strict'; const { escapeHtml, escapeAttr } = BB.utils; /** Valid operators per field. */ const OPERATORS = { title: ['contains', 'not_contains', 'equals', 'matches_regex'], author: ['contains', 'equals', 'matches_regex'], body: ['contains', 'not_contains', 'matches_regex'], source: ['equals'], tag: ['equals'], starred: ['is'], unread: ['is'], }; /** Human-readable labels for operators. */ const OP_LABELS = { contains: 'contains', not_contains: 'does not contain', equals: 'equals', matches_regex: 'matches regex', is: 'is', }; /** * Fetch query feeds from backend and update state. * @returns {Promise} */ async function load() { try { const feeds = await BB.api.queryFeeds.list(); BB.state.set('queryFeeds', feeds); } catch (err) { console.warn('Failed to load query feeds:', err); } } /** * Open the query feed builder modal. * @param {Object|null} existing - Existing feed to edit, or null for new. */ function openBuilder(existing) { const isEdit = !!existing; const name = existing ? existing.name : ''; const conditions = existing ? [...existing.rules] : []; // Ensure at least one empty condition row if (conditions.length === 0) { conditions.push({ field: 'title', operator: 'contains', value: '' }); } const body = document.getElementById('modal-body'); document.getElementById('modal-title').textContent = isEdit ? 'Edit Query Feed' : 'New Query Feed'; function render() { body.innerHTML = `
`; const condContainer = document.getElementById('qf-conditions'); conditions.forEach((c, idx) => renderConditionRow(condContainer, c, idx)); body.querySelector('.add-condition-btn').onclick = () => { conditions.push({ field: 'title', operator: 'contains', value: '' }); render(); }; document.getElementById('qf-cancel').onclick = () => { document.getElementById('modal-overlay').style.display = 'none'; }; document.getElementById('qf-save').onclick = () => save(); } function renderConditionRow(container, condition, idx) { const row = document.createElement('div'); row.className = 'condition-row'; const fields = Object.keys(OPERATORS); const fieldSelect = ``; const ops = OPERATORS[condition.field] || ['contains']; const opSelect = ``; let valueInput; if (condition.field === 'starred' || condition.field === 'unread') { valueInput = ``; } else if (condition.field === 'source') { const sources = BB.state.sources || []; valueInput = ``; } else { valueInput = ``; } row.innerHTML = `${fieldSelect} ${opSelect} ${valueInput} `; // Field change → update operators and re-render row.querySelector('.cond-field').onchange = (e) => { conditions[idx].field = e.target.value; conditions[idx].operator = OPERATORS[e.target.value][0]; conditions[idx].value = ''; render(); }; row.querySelector('.cond-op').onchange = (e) => { conditions[idx].operator = e.target.value; }; const valEl = row.querySelector('.cond-val'); if (valEl.tagName === 'SELECT') { valEl.onchange = (e) => { conditions[idx].value = e.target.value; }; } else { valEl.oninput = (e) => { conditions[idx].value = e.target.value; }; } row.querySelector('.cond-remove').onclick = () => { conditions.splice(idx, 1); if (conditions.length === 0) { conditions.push({ field: 'title', operator: 'contains', value: '' }); } render(); }; container.appendChild(row); } async function save() { const nameVal = document.getElementById('qf-name').value.trim(); if (!nameVal) { BB.ui.showToast('Name is required', 'error'); return; } // Read latest values from DOM document.querySelectorAll('.cond-val').forEach(el => { const idx = parseInt(el.dataset.idx, 10); if (!isNaN(idx) && conditions[idx]) { conditions[idx].value = el.value; } }); // Filter out empty-value conditions const validConditions = conditions.filter(c => c.value.trim() !== ''); try { if (isEdit) { await BB.api.queryFeeds.update(existing.id, nameVal, validConditions); BB.ui.showToast('Query feed updated'); } else { await BB.api.queryFeeds.create({ name: nameVal, rules: validConditions }); BB.ui.showToast('Query feed created'); } document.getElementById('modal-overlay').style.display = 'none'; BB.sources.load(); } catch (err) { BB.ui.showToast('Failed to save: ' + BB.utils.getErrorMessage(err), 'error'); } } document.getElementById('modal-overlay').style.display = 'flex'; render(); } /** * Select a query feed: clear other filters, reload items, * then update sidebar to reflect new active state. * @param {string} id - Query feed ID. */ async function select(id) { BB.state.set('currentQueryFeed', id); BB.state.set('currentSource', ''); BB.state.set('currentTag', ''); BB.state.resetPagination(true); await BB.items.load(); BB.sources.render(BB.state.sources); } /** * Delete a query feed after confirmation. * @param {Object} feed - Query feed object with id and name. */ async function deleteFeed(feed) { const ok = await BB.ui.confirmAction('Delete query feed "' + feed.name + '"?'); if (!ok) return; try { await BB.api.queryFeeds.delete(feed.id); BB.ui.showToast('Deleted ' + feed.name); if (BB.state.currentQueryFeed === feed.id) { BB.state.set('currentQueryFeed', null); } BB.sources.load(); } catch (err) { BB.ui.showToast('Failed to delete: ' + BB.utils.getErrorMessage(err), 'error'); } } BB.queryFeeds = { load, openBuilder, select, deleteFeed }; })();