From b1f33d4194a1928aaf421ce7f355390972e3c5f9 Mon Sep 17 00:00:00 2001 From: ookami125 Date: Tue, 23 Jun 2026 20:51:08 -0400 Subject: [PATCH] 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 /Atom href), and the manual download falls back to the link URL. Fixes missing + on feeds that only provide a 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 --- public/css/styles.css | 6 ++++++ public/js/app.js | 47 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/public/css/styles.css b/public/css/styles.css index a064769..40d1a09 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -304,6 +304,12 @@ table.dtbl td { padding: 3px 8px; border-bottom: 1px solid var(--line-soft); whi .iconbtn:hover { color: var(--err); background: var(--bg-3); } .rule-feeds { display: flex; flex-wrap: wrap; gap: 4px 12px; max-height: 120px; overflow: auto; border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; background: var(--bg); } +.rule-matches { max-height: 160px; overflow: auto; border: 1px solid var(--line); + border-radius: 6px; background: var(--bg); font-size: 12px; } +.match-row { padding: 3px 8px; border-bottom: 1px solid var(--line); white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; } +.match-row:last-child { border-bottom: none; } +.match-row.grabbed { opacity: .55; } .rule { border: 1px solid var(--line); border-radius: 6px; padding: 10px 12px; margin-bottom: 8px; background: var(--bg-1); } .rule.off { opacity: .55; } .rule-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } diff --git a/public/js/app.js b/public/js/app.js index 993c113..56d4204 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -980,7 +980,7 @@ async function renderRssView(host, seq) { ${articles.map((a, i) => ` ${f.esc(a.feed)}${f.esc(a.title)} ${a.size ? f.bytes(a.size) : '—'}${f.esc(a.pubDate || '')} - ${(a.magnet || a.torrentUrl) + ${(a.magnet || a.torrentUrl || a.link) ? `` : ''}`).join('')} ` : '
No articles yet.
'} @@ -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: '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) => `
+ ${f.esc(a.feed)} ${f.esc(a.title)}${a.grabbed ? ' (grabbed)' : ''}
`).join('') + + (matches.length > 50 ? `
…and ${matches.length - 50} more
` : '') + : '
No current articles match.
'; + }; + ['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 `