webui: always show RSS Add button + live rule match preview

- Article Add button now shows whenever the item has any source
  (magnet, enclosure, or a plain <link>/Atom href), and the manual
  download falls back to the link URL. Fixes missing + on feeds that
  only provide a <link> to the torrent.
- The rule editor now shows a live "current matches" list that updates
  as you type the filters / toggle regex / pick feeds, mirroring the
  daemon's matcher, with grabbed items dimmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ookami125 2026-06-23 20:51:08 -04:00
parent 0aeb039586
commit b1f33d4194
2 changed files with 50 additions and 3 deletions

View file

@ -980,7 +980,7 @@ async function renderRssView(host, seq) {
${articles.map((a, i) => `<tr style="${a.isRead ? 'opacity:.5' : ''}">
<td class="dim">${f.esc(a.feed)}</td><td>${f.esc(a.title)}</td>
<td class="num">${a.size ? f.bytes(a.size) : '—'}</td><td class="num dim">${f.esc(a.pubDate || '')}</td>
<td>${(a.magnet || a.torrentUrl)
<td>${(a.magnet || a.torrentUrl || a.link)
? `<button class="btn dl-art" data-art="${i}"${a.grabbed ? ' disabled' : ''}>${a.grabbed ? '✓ Added' : ' Add'}</button>`
: '<span class="faint">—</span>'}</td></tr>`).join('')}
</tbody></table>` : '<div class="dim">No articles yet.</div>'}
@ -1010,7 +1010,7 @@ async function renderRssView(host, seq) {
}));
host.querySelectorAll('.dl-art').forEach((b) => b.addEventListener('click', () => guard(async () => {
const a = articles[+b.dataset.art];
await api.rssDownload({ magnet: a.magnet || '', torrentUrl: a.torrentUrl || '', key: a.key || '' });
await api.rssDownload({ magnet: a.magnet || '', torrentUrl: a.torrentUrl || a.link || '', key: a.key || '' });
b.textContent = '✓ Added'; b.disabled = true;
}, 'Failed to add torrent')));
host.querySelectorAll('[data-rule-edit]').forEach((b) => b.addEventListener('click', () =>
@ -1064,7 +1064,9 @@ function openRuleEditor(rule, feeds) {
<label><input type="checkbox" id="rEnabled" ${r.enabled ? 'checked' : ''}> Enabled</label>
<label><input type="checkbox" id="rRegex" ${r.useRegex ? 'checked' : ''}> Use regex</label>
<label><input type="checkbox" id="rPaused" ${r.addPaused ? 'checked' : ''}> Add paused</label>
</div>`,
</div>
<div class="field" style="margin-top:6px"><label>Current matches <span id="rMatchCount" class="dim"></span></label>
<div id="rMatches" class="rule-matches"></div></div>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Save', primary: true, act: () => guard(async () => {
const name = document.getElementById('rName').value.trim();
@ -1084,9 +1086,48 @@ function openRuleEditor(rule, feeds) {
renderView();
}, 'Failed to save rule'),
}]);
// Live preview of which current articles this rule matches.
const allArticles = (feeds || []).flatMap((fd) => fd.articles.map((a) => ({ title: a.title, feed: fd.name, grabbed: a.grabbed })));
const updateMatches = () => {
const opts = {
must: document.getElementById('rMust').value.trim(),
mustNot: document.getElementById('rMustNot').value.trim(),
regex: document.getElementById('rRegex').checked,
feeds: [...document.querySelectorAll('.rule-feeds input:checked')].map((c) => c.value),
};
const matches = allArticles.filter((a) => ruleMatchesArticle(opts, a.feed, a.title));
document.getElementById('rMatchCount').textContent = `${matches.length} of ${allArticles.length} article(s)`;
const box = document.getElementById('rMatches');
box.innerHTML = matches.length
? matches.slice(0, 50).map((a) => `<div class="match-row${a.grabbed ? ' grabbed' : ''}">
<span class="dim">${f.esc(a.feed)}</span> ${f.esc(a.title)}${a.grabbed ? ' <span class="faint">(grabbed)</span>' : ''}</div>`).join('')
+ (matches.length > 50 ? `<div class="dim" style="padding:4px">…and ${matches.length - 50} more</div>` : '')
: '<div class="dim" style="padding:4px">No current articles match.</div>';
};
['rMust', 'rMustNot'].forEach((id) => document.getElementById(id).addEventListener('input', updateMatches));
document.getElementById('rRegex').addEventListener('change', updateMatches);
document.querySelectorAll('.rule-feeds input').forEach((c) => c.addEventListener('change', updateMatches));
updateMatches();
setTimeout(() => document.getElementById(rule ? 'rMust' : 'rName')?.focus(), 0);
}
// Client-side mirror of the daemon's rule matcher (for live preview).
function ruleMatchesArticle(opts, feedName, title) {
if (opts.feeds && opts.feeds.length && !opts.feeds.includes(feedName)) return false;
if (opts.regex) {
try {
if (opts.must && !new RegExp(opts.must, 'i').test(title)) return false;
if (opts.mustNot && new RegExp(opts.mustNot, 'i').test(title)) return false;
} catch (e) { return false; } // invalid regex matches nothing
} else {
const t = title.toLowerCase();
if (opts.must && !t.includes(opts.must.toLowerCase())) return false;
if (opts.mustNot && t.includes(opts.mustNot.toLowerCase())) return false;
}
return true;
}
function renderRule(r) {
return `<div class="rule ${r.enabled ? '' : 'off'}">
<div class="rule-head">