Skip to main content

max / balanced_breakfast

9.2 KB · 230 lines History Blame Raw
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 /** Valid operators per field. */
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 /** Human-readable labels for operators. */
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 // Ensure at least one empty condition row
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">&times;</button>`;
127
128 // Field change → update operators and re-render
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 // Read latest values from DOM
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 // Filter out empty-value conditions
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