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

@ -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); } .iconbtn:hover { color: var(--err); background: var(--bg-3); }
.rule-feeds { display: flex; flex-wrap: wrap; gap: 4px 12px; max-height: 120px; overflow: auto; .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); } 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 { border: 1px solid var(--line); border-radius: 6px; padding: 10px 12px; margin-bottom: 8px; background: var(--bg-1); }
.rule.off { opacity: .55; } .rule.off { opacity: .55; }
.rule-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } .rule-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }

View file

@ -980,7 +980,7 @@ async function renderRssView(host, seq) {
${articles.map((a, i) => `<tr style="${a.isRead ? 'opacity:.5' : ''}"> ${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="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 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>` ? `<button class="btn dl-art" data-art="${i}"${a.grabbed ? ' disabled' : ''}>${a.grabbed ? '✓ Added' : ' Add'}</button>`
: '<span class="faint">—</span>'}</td></tr>`).join('')} : '<span class="faint">—</span>'}</td></tr>`).join('')}
</tbody></table>` : '<div class="dim">No articles yet.</div>'} </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 () => { host.querySelectorAll('.dl-art').forEach((b) => b.addEventListener('click', () => guard(async () => {
const a = articles[+b.dataset.art]; 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; b.textContent = '✓ Added'; b.disabled = true;
}, 'Failed to add torrent'))); }, 'Failed to add torrent')));
host.querySelectorAll('[data-rule-edit]').forEach((b) => b.addEventListener('click', () => 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="rEnabled" ${r.enabled ? 'checked' : ''}> Enabled</label>
<label><input type="checkbox" id="rRegex" ${r.useRegex ? 'checked' : ''}> Use regex</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> <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: 'Cancel', act: closeModal }, {
label: 'Save', primary: true, act: () => guard(async () => { label: 'Save', primary: true, act: () => guard(async () => {
const name = document.getElementById('rName').value.trim(); const name = document.getElementById('rName').value.trim();
@ -1084,9 +1086,48 @@ function openRuleEditor(rule, feeds) {
renderView(); renderView();
}, 'Failed to save rule'), }, '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); 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) { function renderRule(r) {
return `<div class="rule ${r.enabled ? '' : 'off'}"> return `<div class="rule ${r.enabled ? '' : 'off'}">
<div class="rule-head"> <div class="rule-head">