An OPML's page lists its shows' items together, not the shows (#283)
GET /api/feeds/{id}/entries for an OPML or a Patreon creator now lists the
items of the shows in it the person subscribes to, as All Subscriptions does
every feed's. The shows are looked up first and queried by list: an OR with a
subquery, or an IN over one, made a single feed's page a sequential scan of
entries (clarkesworld 10 ms to 26 ms or a second, EXPLAIN on production), so a
single feed keeps its `feed_id = $1`.
The page draws the OPML with its own header and the item list, tabs, search
and sort of any feed, with the feed column showing which show each is from.
A show's scan or change refreshes its OPML's list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -62,7 +62,7 @@ function connect(){
|
||||
else if(ev.ev==='feed_done'){
|
||||
setScanning(ev.feed,false);
|
||||
if(!mine(ev.feed)) return;
|
||||
if(ev.feed===S.feed||S.feed===':all') refreshEntries();
|
||||
if(ev.feed===S.feed||S.feed===':all'||S.feeds.some(f=>f.id===ev.feed&&f.group===S.feed)) refreshEntries();
|
||||
}
|
||||
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's
|
||||
// red ! marks the feed instead, and its page says why.
|
||||
|
||||
@@ -14,9 +14,8 @@ function renderFeed(){
|
||||
const name=f?(f.title||f.id):sg?sg.name:v.title;
|
||||
$<HTMLInputElement>('#epSearch').placeholder=`Search ${name}…`;
|
||||
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
|
||||
if(kids.length){ box.classList.add('plain'); renderGroup(f!,kids); return; }
|
||||
const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||
box.innerHTML = (f ? `
|
||||
box.innerHTML = (f&&kids.length ? groupHead(f,kids) : f ? `
|
||||
<div class="fhead slim">
|
||||
${artHTML(f.image,name)}
|
||||
<div class="meta">
|
||||
@@ -61,7 +60,8 @@ function renderFeed(){
|
||||
|
||||
const pane=document.createElement('div');
|
||||
pane.id='split';
|
||||
if(f) pane.className='one';
|
||||
// One feed's list leaves out the feed column; an OPML's shows are told apart by it.
|
||||
if(f&&!kids.length) pane.className='one';
|
||||
pane.innerHTML='<div id="list">'+sortHead()+
|
||||
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
|
||||
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort!));
|
||||
@@ -100,26 +100,25 @@ function sortBy(col: string){
|
||||
S.offset=0; renderFeed(); loadEntries();
|
||||
}
|
||||
|
||||
/// An OPML subscription's page lists the feeds inside it rather than items, but keeps
|
||||
/// every action a normal feed has -- it is still an ordinary feed entry underneath.
|
||||
/// An OPML subscription's page lists the items of the shows inside it together, as All
|
||||
/// Subscriptions does every feed's (#283), under the header a feed has, its buttons in the
|
||||
/// same places: it is still an ordinary feed entry underneath.
|
||||
// A Patreon creator split into its shows is drawn like an OPML, and named for what it is.
|
||||
const isPatreon=(f: {url: string}|null)=>/patreon\.com\//.test(f&&f.url||'');
|
||||
|
||||
function renderGroup(f: Feed,kids: Feed[]){
|
||||
function groupHead(f: Feed,kids: Feed[]){
|
||||
const unread=kids.reduce((n,c)=>n+c.unread,0);
|
||||
const saved=kids.reduce((n,c)=>n+c.downloaded,0);
|
||||
const gone=kids.filter(c=>c.orphaned).length;
|
||||
$('#count').textContent=`${f.title||f.id}: ${kids.length} feed${kids.length===1?'':'s'}, ${unread} unread`;
|
||||
// The same header as a feed's, buttons in the same places: it is a feed underneath.
|
||||
$('#content').innerHTML = `
|
||||
return `
|
||||
<div class="fhead slim">
|
||||
${folderArt(f,kids)}
|
||||
<div class="meta">
|
||||
<h2>${esc(f.title||f.id)}</h2>
|
||||
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}">${isPatreon(f)?'Patreon creator':'OPML subscription'}
|
||||
· ${plural(kids.length,'feed')}, ${unread} unread, ${saved} downloaded · checked ${ago(f.last_checked)}</div>
|
||||
· ${plural(kids.length,'show')}, ${unread} unread, ${saved} downloaded · checked ${ago(f.last_checked)}</div>
|
||||
${failBannerHTML(f)}
|
||||
${gone?`<div class="sub" style="color:var(--warn)">${gone} feed${gone===1?' is':'s are'} no longer
|
||||
${gone?`<div class="sub" style="color:var(--warn)">${gone} show${gone===1?' is':'s are'} no longer
|
||||
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
|
||||
</div>
|
||||
<div class="acts">
|
||||
@@ -130,36 +129,7 @@ function renderGroup(f: Feed,kids: Feed[]){
|
||||
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<input type="search" class="grow" id="kidSearch" placeholder="Search these feeds…">
|
||||
<span style="color:var(--faint);font-size:12.5px">${esc(f.url)}</span>
|
||||
</div>
|
||||
<div class="childlist" id="kidlist"></div>`;
|
||||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a!,f));
|
||||
wireFailBanner($('#content'),f);
|
||||
const draw=()=>{
|
||||
const q=($<HTMLInputElement>('#kidSearch').value||'').trim().toLowerCase();
|
||||
const box=$('#kidlist'); box.innerHTML='';
|
||||
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst);
|
||||
if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; }
|
||||
for(const c of rows){
|
||||
const el=document.createElement('div');
|
||||
el.className='childrow';
|
||||
el.innerHTML = artHTML(c.image,c.title||c.id)+
|
||||
`<div class="txt"><b>${esc(c.title||c.id)}</b>`+
|
||||
`<small class="meta">${plural(c.entries,'item')} · ${c.downloaded} downloaded`+
|
||||
(c.failing?` · <span style="color:var(--bad)" title="${esc(c.failing.reason)}">error</span>`
|
||||
:c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
|
||||
(c.orphaned?'<span class="tag">Gone</span>':'')+
|
||||
(c.failing?`<span class="tag" style="color:var(--bad)" title="${esc(c.failing.reason)}">Error</span>`:'')+
|
||||
`<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`;
|
||||
el.onclick=()=>selectFeed(c.id);
|
||||
box.appendChild(el);
|
||||
}
|
||||
};
|
||||
$<HTMLInputElement>('#kidSearch').oninput=draw;
|
||||
draw();
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/// Keep, for a show from an OPML or a Patreon creator: it stays yours, a show of its own, should
|
||||
|
||||
@@ -24,11 +24,9 @@ function keepList(key: string, r: ListPage){
|
||||
/// Fetches a list's first page ahead of a click: when the page has loaded, and when the pointer
|
||||
/// reaches a feed or a filter. Not one kept already or on its way.
|
||||
function prefetch(feed: string, filter=S.filter){
|
||||
// Currently Listening and the Directory draw from their own address; a folder's page lists its
|
||||
// feeds, not items.
|
||||
// Currently Listening and the Directory draw from their own address.
|
||||
const url=VIEWS[feed]?.url;
|
||||
if(url) return prefetchPage(url);
|
||||
if(S.feeds.some(c=>c.group===feed)) return;
|
||||
const key=listKey(feed,filter);
|
||||
if(lists.has(key)||fetching.has(key)) return;
|
||||
const got: Promise<ListPage>=api(listUrl(feed,filter));
|
||||
@@ -51,8 +49,9 @@ const prefetchPage=(url: string)=>{ if(!pages.has(url)) fetchPage(url).catch(()=
|
||||
/// A feed changed (a scan, a read elsewhere): its kept lists, and every one of All Subscriptions,
|
||||
/// are fetched again when next wanted.
|
||||
function forgetLists(feed: string){
|
||||
// A Signal holds items of any feed.
|
||||
for(const k of [...lists.keys()]) if(k.startsWith(feed+'\n')||k.startsWith(':all\n')||k.startsWith(':signal:')) lists.delete(k);
|
||||
// A Signal holds items of any feed, and an OPML's list its shows' (#283).
|
||||
const group=S.feeds.find(f=>f.id===feed)?.group;
|
||||
for(const k of [...lists.keys()]) if(k.startsWith(feed+'\n')||k.startsWith(':all\n')||k.startsWith(':signal:')||(group&&k.startsWith(group+'\n'))) lists.delete(k);
|
||||
}
|
||||
/// What is likely to be opened next, fetched two at a time once the page is idle: All Subscriptions
|
||||
/// and the feeds with something unread, most first.
|
||||
@@ -125,7 +124,9 @@ function listLoading(box=$('#eps')){
|
||||
function renderEntries(){
|
||||
const box=$('#eps'); if(!box) return;
|
||||
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed ?? ''], sg=signalOf(S.feed);
|
||||
const unread=f?f.unread:sg?sg.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||
// An OPML's own row counts nothing: its shows do.
|
||||
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
|
||||
const unread=kids.length?kids.reduce((n,x)=>n+x.unread,0):f?f.unread:sg?sg.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||
$('#count').textContent=
|
||||
`${f?(f.title||f.id):sg?sg.name:v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
|
||||
const pane=$('#list'), top=pane?pane.scrollTop:0;
|
||||
|
||||
Reference in New Issue
Block a user