| 1 |
|
| 2 |
* @fileoverview Query feeds: saved filter rules that act as virtual sources. |
| 3 |
* |
| 4 |
* Provides CRUD for query feeds and a condition builder modal. |
| 5 |
|
| 6 |
(function() { |
| 7 |
'use strict'; |
| 8 |
|
| 9 |
const { escapeHtml, escapeAttr } = BB.utils; |
| 10 |
|
| 11 |
|
| 12 |
const OPERATORS = { |
| 13 |
title: ['contains', 'not_contains', 'equals', 'matches_regex'], |
| 14 |
author: ['contains', 'equals', 'matches_regex'], |
| 15 |
body: ['contains', 'not_contains', 'matches_regex'], |
| 16 |
source: ['equals'], |
| 17 |
tag: ['equals'], |
| 18 |
starred: ['is'], |
| 19 |
unread: ['is'], |
| 20 |
}; |
| 21 |
|
| 22 |
|
| 23 |
const OP_LABELS = { |
| 24 |
contains: 'contains', |
| 25 |
not_contains: 'does not contain', |
| 26 |
equals: 'equals', |
| 27 |
matches_regex: 'matches regex', |
| 28 |
is: 'is', |
| 29 |
}; |
| 30 |
|
| 31 |
|
| 32 |
* Fetch query feeds from backend and update state. |
| 33 |
* @returns {Promise<void>} |
| 34 |
|
| 35 |
async function load() { |
| 36 |
try { |
| 37 |
const feeds = await BB.api.queryFeeds.list(); |
| 38 |
BB.state.set('queryFeeds', feeds); |
| 39 |
} catch (err) { |
| 40 |
console.warn('Failed to load query feeds:', err); |
| 41 |
} |
| 42 |
} |
| 43 |
|
| 44 |
|
| 45 |
* Open the query feed builder modal. |
| 46 |
* @param {Object|null} existing - Existing feed to edit, or null for new. |
| 47 |
|
| 48 |
function openBuilder(existing) { |
| 49 |
const isEdit = !!existing; |
| 50 |
const name = existing ? existing.name : ''; |
| 51 |
const conditions = existing ? [...existing.rules] : []; |
| 52 |
|
| 53 |
|
| 54 |
if (conditions.length === 0) { |
| 55 |
conditions.push({ field: 'title', operator: 'contains', value: '' }); |
| 56 |
} |
| 57 |
|
| 58 |
const body = document.getElementById('modal-body'); |
| 59 |
document.getElementById('modal-title').textContent = isEdit ? 'Edit Query Feed' : 'New Query Feed'; |
| 60 |
|
| 61 |
function render() { |
| 62 |
body.innerHTML = ` |
| 63 |
<div class="qf-builder"> |
| 64 |
<div class="form-group"> |
| 65 |
<label for="qf-name">Name</label> |
| 66 |
<input type="text" id="qf-name" class="search-input" value="${escapeAttr(name)}" placeholder="My filter" maxlength="200" style="width:100%"> |
| 67 |
</div> |
| 68 |
<div class="form-group"> |
| 69 |
<label>Conditions (all must match)</label> |
| 70 |
<div id="qf-conditions"></div> |
| 71 |
<button class="btn btn-small add-condition-btn" type="button">+ Add Condition</button> |
| 72 |
</div> |
| 73 |
<div class="match-preview" id="qf-match-preview"></div> |
| 74 |
<div class="modal-actions"> |
| 75 |
<button class="btn" id="qf-cancel">Cancel</button> |
| 76 |
<button class="btn btn-primary" id="qf-save">${isEdit ? 'Save' : 'Create'}</button> |
| 77 |
</div> |
| 78 |
</div> |
| 79 |
`; |
| 80 |
|
| 81 |
const condContainer = document.getElementById('qf-conditions'); |
| 82 |
conditions.forEach((c, idx) => renderConditionRow(condContainer, c, idx)); |
| 83 |
|
| 84 |
body.querySelector('.add-condition-btn').onclick = () => { |
| 85 |
conditions.push({ field: 'title', operator: 'contains', value: '' }); |
| 86 |
render(); |
| 87 |
}; |
| 88 |
|
| 89 |
document.getElementById('qf-cancel').onclick = () => { |
| 90 |
document.getElementById('modal-overlay').style.display = 'none'; |
| 91 |
}; |
| 92 |
|
| 93 |
document.getElementById('qf-save').onclick = () => save(); |
| 94 |
} |
| 95 |
|
| 96 |
function renderConditionRow(container, condition, idx) { |
| 97 |
const row = document.createElement('div'); |
| 98 |
row.className = 'condition-row'; |
| 99 |
|
| 100 |
const fields = Object.keys(OPERATORS); |
| 101 |
const fieldSelect = `<select class="sort-select cond-field" data-idx="${idx}"> |
| 102 |
${fields.map(f => `<option value="${f}" ${f === condition.field ? 'selected' : ''}>${escapeHtml(f)}</option>`).join('')} |
| 103 |
</select>`; |
| 104 |
|
| 105 |
const ops = OPERATORS[condition.field] || ['contains']; |
| 106 |
const opSelect = `<select class="sort-select cond-op" data-idx="${idx}"> |
| 107 |
${ops.map(o => `<option value="${o}" ${o === condition.operator ? 'selected' : ''}>${escapeHtml(OP_LABELS[o] || o)}</option>`).join('')} |
| 108 |
</select>`; |
| 109 |
|
| 110 |
let valueInput; |
| 111 |
if (condition.field === 'starred' || condition.field === 'unread') { |
| 112 |
valueInput = `<select class="sort-select cond-val" data-idx="${idx}"> |
| 113 |
<option value="true" ${condition.value === 'true' ? 'selected' : ''}>true</option> |
| 114 |
<option value="false" ${condition.value === 'false' ? 'selected' : ''}>false</option> |
| 115 |
</select>`; |
| 116 |
} else if (condition.field === 'source') { |
| 117 |
const sources = BB.state.sources || []; |
| 118 |
valueInput = `<select class="sort-select cond-val" data-idx="${idx}"> |
| 119 |
${sources.map(s => `<option value="${escapeAttr(s.id)}" ${s.id === condition.value ? 'selected' : ''}>${escapeHtml(s.name)}</option>`).join('')} |
| 120 |
</select>`; |
| 121 |
} else { |
| 122 |
valueInput = `<input type="text" class="search-input cond-val" data-idx="${idx}" value="${escapeAttr(condition.value)}" placeholder="value">`; |
| 123 |
} |
| 124 |
|
| 125 |
row.innerHTML = `${fieldSelect} ${opSelect} ${valueInput} |
| 126 |
<button class="btn btn-small cond-remove" data-idx="${idx}" title="Remove" aria-label="Remove condition">×</button>`; |
| 127 |
|
| 128 |
|
| 129 |
row.querySelector('.cond-field').onchange = (e) => { |
| 130 |
conditions[idx].field = e.target.value; |
| 131 |
conditions[idx].operator = OPERATORS[e.target.value][0]; |
| 132 |
conditions[idx].value = ''; |
| 133 |
render(); |
| 134 |
}; |
| 135 |
|
| 136 |
row.querySelector('.cond-op').onchange = (e) => { |
| 137 |
conditions[idx].operator = e.target.value; |
| 138 |
}; |
| 139 |
|
| 140 |
const valEl = row.querySelector('.cond-val'); |
| 141 |
if (valEl.tagName === 'SELECT') { |
| 142 |
valEl.onchange = (e) => { conditions[idx].value = e.target.value; }; |
| 143 |
} else { |
| 144 |
valEl.oninput = (e) => { conditions[idx].value = e.target.value; }; |
| 145 |
} |
| 146 |
|
| 147 |
row.querySelector('.cond-remove').onclick = () => { |
| 148 |
conditions.splice(idx, 1); |
| 149 |
if (conditions.length === 0) { |
| 150 |
conditions.push({ field: 'title', operator: 'contains', value: '' }); |
| 151 |
} |
| 152 |
render(); |
| 153 |
}; |
| 154 |
|
| 155 |
container.appendChild(row); |
| 156 |
} |
| 157 |
|
| 158 |
async function save() { |
| 159 |
const nameVal = document.getElementById('qf-name').value.trim(); |
| 160 |
if (!nameVal) { |
| 161 |
BB.ui.showToast('Name is required', 'error'); |
| 162 |
return; |
| 163 |
} |
| 164 |
|
| 165 |
|
| 166 |
document.querySelectorAll('.cond-val').forEach(el => { |
| 167 |
const idx = parseInt(el.dataset.idx, 10); |
| 168 |
if (!isNaN(idx) && conditions[idx]) { |
| 169 |
conditions[idx].value = el.value; |
| 170 |
} |
| 171 |
}); |
| 172 |
|
| 173 |
|
| 174 |
const validConditions = conditions.filter(c => c.value.trim() !== ''); |
| 175 |
|
| 176 |
try { |
| 177 |
if (isEdit) { |
| 178 |
await BB.api.queryFeeds.update(existing.id, nameVal, validConditions); |
| 179 |
BB.ui.showToast('Query feed updated'); |
| 180 |
} else { |
| 181 |
await BB.api.queryFeeds.create({ name: nameVal, rules: validConditions }); |
| 182 |
BB.ui.showToast('Query feed created'); |
| 183 |
} |
| 184 |
document.getElementById('modal-overlay').style.display = 'none'; |
| 185 |
BB.sources.load(); |
| 186 |
} catch (err) { |
| 187 |
BB.ui.showToast('Failed to save: ' + BB.utils.getErrorMessage(err), 'error'); |
| 188 |
} |
| 189 |
} |
| 190 |
|
| 191 |
document.getElementById('modal-overlay').style.display = 'flex'; |
| 192 |
render(); |
| 193 |
} |
| 194 |
|
| 195 |
|
| 196 |
* Select a query feed: clear other filters, reload items, |
| 197 |
* then update sidebar to reflect new active state. |
| 198 |
* @param {string} id - Query feed ID. |
| 199 |
|
| 200 |
async function select(id) { |
| 201 |
BB.state.set('currentQueryFeed', id); |
| 202 |
BB.state.set('currentSource', ''); |
| 203 |
BB.state.set('currentTag', ''); |
| 204 |
BB.state.resetPagination(true); |
| 205 |
await BB.items.load(); |
| 206 |
BB.sources.render(BB.state.sources); |
| 207 |
} |
| 208 |
|
| 209 |
|
| 210 |
* Delete a query feed after confirmation. |
| 211 |
* @param {Object} feed - Query feed object with id and name. |
| 212 |
|
| 213 |
async function deleteFeed(feed) { |
| 214 |
const ok = await BB.ui.confirmAction('Delete query feed "' + feed.name + '"?'); |
| 215 |
if (!ok) return; |
| 216 |
try { |
| 217 |
await BB.api.queryFeeds.delete(feed.id); |
| 218 |
BB.ui.showToast('Deleted ' + feed.name); |
| 219 |
if (BB.state.currentQueryFeed === feed.id) { |
| 220 |
BB.state.set('currentQueryFeed', null); |
| 221 |
} |
| 222 |
BB.sources.load(); |
| 223 |
} catch (err) { |
| 224 |
BB.ui.showToast('Failed to delete: ' + BB.utils.getErrorMessage(err), 'error'); |
| 225 |
} |
| 226 |
} |
| 227 |
|
| 228 |
BB.queryFeeds = { load, openBuilder, select, deleteFeed }; |
| 229 |
})(); |
| 230 |
|