Browse the Directory by category, then subcategory, as Apple does (#118)
The Directory had one row of chips holding whatever each feed's category was, a category (Technology) or a subcategory (Tech News, Video Games) side by side: a podcast's own <itunes:category> is stored as its subcategory, and Jev's answers (#117) are often subcategories too, so after the first forced scan the row held 22 chips. /api/directory now gives each feed's Apple category and subcategory, worked out from Apple's list, and the page shows the categories, then a picked one's subcategories on a line of their own. A category that is not Apple's, one an admin typed, stands as a category of its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- The Directory browses as Apple's does: a row of categories, and once one is picked, a row of its subcategories (Tech News under News, Video Games under Leisure), instead of one row mixing both.
|
||||||
- An item published without a title shows its opening words, in plain text rather than bold, instead of "(untitled)"; one with no text either shows its file's name, or its show and date. Opened, it starts with its text.
|
- An item published without a title shows its opening words, in plain text rather than bold, instead of "(untitled)"; one with no text either shows its file's name, or its show and date. Opened, it starts with its text.
|
||||||
- A feed that has moved for good (a permanent redirect) is followed to its new address, which iPX then reads from, and says so in the log as `feed_moved`. A temporary redirect changes nothing.
|
- A feed that has moved for good (a permanent redirect) is followed to its new address, which iPX then reads from, and says so in the log as `feed_moved`. A temporary redirect changes nothing.
|
||||||
- The daemon sleeps until the next feed is due, at most ten minutes, instead of looking every minute; refreshing or adding a feed still wakes it at once.
|
- The daemon sleeps until the next feed is due, at most ten minutes, instead of looking every minute; refreshing or adding a feed still wakes it at once.
|
||||||
|
|||||||
24
src/feed.rs
24
src/feed.rs
@@ -1033,6 +1033,21 @@ const CATEGORIES: &[(&str, &[&str])] = &[
|
|||||||
("TV & Film", &["After Shows", "Film History", "Film Interviews", "Film Reviews", "TV Reviews"]),
|
("TV & Film", &["After Shows", "Film History", "Film Interviews", "Film Reviews", "TV Reviews"]),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Where a category sits in Apple's list, as (category, subcategory), so the Directory browses a
|
||||||
|
/// category and then its subcategories as Apple's does (#118). A podcast's own is stored as its
|
||||||
|
/// subcategory, and one an admin typed that is not Apple's stands as a category of its own.
|
||||||
|
pub fn category_path(name: &str) -> (String, Option<String>) {
|
||||||
|
for (category, subs) in CATEGORIES {
|
||||||
|
if category.eq_ignore_ascii_case(name) {
|
||||||
|
return (category.to_string(), None);
|
||||||
|
}
|
||||||
|
if let Some(sub) = subs.iter().find(|s| s.eq_ignore_ascii_case(name)) {
|
||||||
|
return (category.to_string(), Some(sub.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(name.to_string(), None)
|
||||||
|
}
|
||||||
|
|
||||||
/// A Directory category for a feed that names none of its own, which most blogs do not (#117):
|
/// A Directory category for a feed that names none of its own, which most blogs do not (#117):
|
||||||
/// TypeSafe's Jev picks one of Apple's categories or subcategories from the feed's title and
|
/// TypeSafe's Jev picks one of Apple's categories or subcategories from the feed's title and
|
||||||
/// latest items, the subcategory being what a podcast's own category is stored as. The likeliest
|
/// latest items, the subcategory being what a podcast's own category is stored as. The likeliest
|
||||||
@@ -1571,5 +1586,14 @@ mod tests {
|
|||||||
assert!(super::picked(&answer("Blogs")).is_err(), "not one of Apple's");
|
assert!(super::picked(&answer("Blogs")).is_err(), "not one of Apple's");
|
||||||
assert!(super::picked(&serde_json::json!({"error": "rate limited"})).is_err());
|
assert!(super::picked(&serde_json::json!({"error": "rate limited"})).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn category_path_finds_a_subcategory_s_category() {
|
||||||
|
use super::category_path;
|
||||||
|
assert_eq!(category_path("Tech News"), ("News".into(), Some("Tech News".into())));
|
||||||
|
assert_eq!(category_path("video games"), ("Leisure".into(), Some("Video Games".into())), "Apple's spelling");
|
||||||
|
assert_eq!(category_path("Technology"), ("Technology".into(), None));
|
||||||
|
assert_eq!(category_path("Homebrew"), ("Homebrew".into(), None), "not Apple's: its own category");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
src/web.rs
10
src/web.rs
@@ -861,8 +861,10 @@ struct PopularRow {
|
|||||||
subscribers: i64,
|
subscribers: i64,
|
||||||
/// Yours already. Everyone counts, you included, so your own feeds are listed too.
|
/// Yours already. Everyone counts, you included, so your own feeds are listed too.
|
||||||
subscribed: bool,
|
subscribed: bool,
|
||||||
/// The feed's own iTunes category, if it names one; most blogs do not.
|
/// Apple's category, from the feed's own iTunes category or else the catalogue's, and the
|
||||||
|
/// subcategory within it where there is one: the Directory browses them in that order.
|
||||||
category: Option<String>,
|
category: Option<String>,
|
||||||
|
subcategory: Option<String>,
|
||||||
/// Any audio or video enclosure. Unlike category, every feed has an answer, so the
|
/// Any audio or video enclosure. Unlike category, every feed has an answer, so the
|
||||||
/// Directory's Podcasts and Blogs between them hold everything.
|
/// Directory's Podcasts and Blogs between them hold everything.
|
||||||
podcast: bool,
|
podcast: bool,
|
||||||
@@ -902,14 +904,16 @@ async fn popular(state: &WebState, user_id: i64, everything: bool) -> Result<Vec
|
|||||||
}
|
}
|
||||||
let sum = listed.remove(&s.id).map(|l| l.summary).unwrap_or_default();
|
let sum = listed.remove(&s.id).map(|l| l.summary).unwrap_or_default();
|
||||||
let subscribed = mine.contains(&s.id);
|
let subscribed = mine.contains(&s.id);
|
||||||
|
// The feed's own wins; an admin's is for the feeds, mostly blogs, that name none.
|
||||||
|
let path = sum.category.or_else(|| s.cfg.category.clone()).map(|c| crate::feed::category_path(&c));
|
||||||
out.push(PopularRow {
|
out.push(PopularRow {
|
||||||
id: s.id.clone(),
|
id: s.id.clone(),
|
||||||
title: sum.title,
|
title: sum.title,
|
||||||
image: sum.image,
|
image: sum.image,
|
||||||
subscribers: n,
|
subscribers: n,
|
||||||
subscribed,
|
subscribed,
|
||||||
// The feed's own wins; an admin's is for the feeds, mostly blogs, that name none.
|
category: path.as_ref().map(|p| p.0.clone()),
|
||||||
category: sum.category.or_else(|| s.cfg.category.clone()),
|
subcategory: path.and_then(|p| p.1),
|
||||||
podcast: media.contains(&s.id),
|
podcast: media.contains(&s.id),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1177,7 +1177,16 @@ test('an admin can give a blog its Directory category', async ({ page }) => {
|
|||||||
const listed = async id => (await page.evaluate(() => api('/api/directory'))).find(p => p.id === id);
|
const listed = async id => (await page.evaluate(() => api('/api/directory'))).find(p => p.id === id);
|
||||||
|
|
||||||
await patch('picture-blog', 'Visual Arts');
|
await patch('picture-blog', 'Visual Arts');
|
||||||
expect(await listed('picture-blog')).toMatchObject({ podcast: false, category: 'Visual Arts' });
|
// Apple's subcategory, under its category, as the Directory browses them (#118).
|
||||||
|
expect(await listed('picture-blog')).toMatchObject({ podcast: false, category: 'Arts', subcategory: 'Visual Arts' });
|
||||||
|
// A subcategory's chips show once its category is picked, as Apple's directory does.
|
||||||
|
const chip = (row, name) => page.locator(`#dirbar .${row} button`, { hasText: new RegExp(`^${name}$`) });
|
||||||
|
await page.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
||||||
|
await expect(chip('subs', 'Visual Arts')).toHaveCount(0);
|
||||||
|
await chip('chips', 'Arts').click();
|
||||||
|
await chip('subs', 'Visual Arts').click();
|
||||||
|
await expect(chip('subs', 'Visual Arts')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(page.locator('#popular .tile')).toHaveCount(1);
|
||||||
// A feed's own iTunes category wins over one given here.
|
// A feed's own iTunes category wins over one given here.
|
||||||
await patch('test-show', 'Comedy');
|
await patch('test-show', 'Comedy');
|
||||||
expect((await listed('test-show')).category).toBe('Technology');
|
expect((await listed('test-show')).category).toBe('Technology');
|
||||||
|
|||||||
@@ -643,6 +643,8 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
|||||||
.dirbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin-top:4px}
|
.dirbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin-top:4px}
|
||||||
.dirbar .tabs{flex:none}
|
.dirbar .tabs{flex:none}
|
||||||
.chips{display:flex;flex-wrap:wrap;gap:2px 6px;flex:1 1 0;min-width:0}
|
.chips{display:flex;flex-wrap:wrap;gap:2px 6px;flex:1 1 0;min-width:0}
|
||||||
|
/* A category's subcategories go on a line of their own, under the category picked. */
|
||||||
|
.chips.subs{flex-basis:100%}
|
||||||
.chips button{flex:none;padding:4px 6px;font-size:13px;color:var(--dim);white-space:nowrap;border-bottom:2px solid transparent}
|
.chips button{flex:none;padding:4px 6px;font-size:13px;color:var(--dim);white-space:nowrap;border-bottom:2px solid transparent}
|
||||||
.chips button:hover{color:var(--fg)}
|
.chips button:hover{color:var(--fg)}
|
||||||
.chips button[aria-pressed="true"]{color:var(--fg);border-bottom-color:var(--accent2)}
|
.chips button[aria-pressed="true"]{color:var(--fg);border-bottom-color:var(--accent2)}
|
||||||
|
|||||||
@@ -70,11 +70,12 @@ function listedFeed(p,cls){
|
|||||||
|
|
||||||
// Directory's filters. Kept out here because a finished scan redraws the pane, which would
|
// Directory's filters. Kept out here because a finished scan redraws the pane, which would
|
||||||
// otherwise clear them.
|
// otherwise clear them.
|
||||||
let dirKind='All', dirCat=null;
|
let dirKind='All', dirCat=null, dirSub=null;
|
||||||
const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
|
const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
|
||||||
/// Directory: every listed feed as its cover art, under two filters that combine: what a feed is
|
/// Directory: every listed feed as its cover art, under two filters that combine: what a feed is
|
||||||
/// (Podcasts, anything with audio or video, or Blogs, the rest) and what it is about (its iTunes
|
/// (Podcasts, anything with audio or video, or Blogs, the rest) and what it is about (its iTunes
|
||||||
/// category, as chips). Both filter in place, without asking the server again.
|
/// category, as chips, then once one is picked its subcategories, as Apple's directory does).
|
||||||
|
/// They filter in place, without asking the server again.
|
||||||
async function renderDirectory(url,box){
|
async function renderDirectory(url,box){
|
||||||
let rows=[];
|
let rows=[];
|
||||||
try{ rows=await api(url)||[]; }catch{}
|
try{ rows=await api(url)||[]; }catch{}
|
||||||
@@ -89,18 +90,24 @@ async function renderDirectory(url,box){
|
|||||||
// No empty chips: only the categories among the feeds the kind lets through.
|
// No empty chips: only the categories among the feeds the kind lets through.
|
||||||
const cats=[...new Set(ofKind.map(p=>p.category).filter(Boolean))].sort();
|
const cats=[...new Set(ofKind.map(p=>p.category).filter(Boolean))].sort();
|
||||||
if(!cats.includes(dirCat)) dirCat=null;
|
if(!cats.includes(dirCat)) dirCat=null;
|
||||||
|
const ofCat=ofKind.filter(p=>!dirCat||p.category===dirCat);
|
||||||
|
const subs=dirCat?[...new Set(ofCat.map(p=>p.subcategory).filter(Boolean))].sort():[];
|
||||||
|
if(!subs.includes(dirSub)) dirSub=null;
|
||||||
bar.innerHTML=
|
bar.innerHTML=
|
||||||
(both?`<div class="tabs" role="group" aria-label="Kind">${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}</div>`:'')+
|
(both?`<div class="tabs" role="group" aria-label="Kind">${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}</div>`:'')+
|
||||||
(cats.length?`<div class="chips" role="group" aria-label="Category">${cats.map(c=>btn('cat',c,c===dirCat)).join('')}</div>`:'');
|
(cats.length?`<div class="chips" role="group" aria-label="Category">${cats.map(c=>btn('cat',c,c===dirCat)).join('')}</div>`:'')+
|
||||||
|
(subs.length?`<div class="chips subs" role="group" aria-label="Subcategory">${subs.map(c=>btn('sub',c,c===dirSub)).join('')}</div>`:'');
|
||||||
// A picked chip lifts on a second press. Everything is redrawn, so the keyboard goes back to
|
// A picked chip lifts on a second press. Everything is redrawn, so the keyboard goes back to
|
||||||
// the button just pressed.
|
// the button just pressed.
|
||||||
for(const b of $$('button',bar)) b.onclick=()=>{
|
for(const b of $$('button',bar)) b.onclick=()=>{
|
||||||
const k=b.dataset.kind!=null?'kind':'cat', v=b.dataset[k];
|
const k=b.dataset.kind!=null?'kind':b.dataset.cat!=null?'cat':'sub', v=b.dataset[k];
|
||||||
if(k==='kind') dirKind=v; else dirCat=dirCat===v?null:v;
|
if(k==='kind') dirKind=v;
|
||||||
|
else if(k==='cat'){ dirCat=dirCat===v?null:v; dirSub=null; }
|
||||||
|
else dirSub=dirSub===v?null:v;
|
||||||
draw(); $(`[data-${k}="${CSS.escape(v)}"]`,bar)?.focus();
|
draw(); $(`[data-${k}="${CSS.escape(v)}"]`,bar)?.focus();
|
||||||
};
|
};
|
||||||
box.innerHTML='';
|
box.innerHTML='';
|
||||||
for(const p of ofKind.filter(p=>!dirCat||p.category===dirCat)) box.appendChild(listedFeed(p,'tile'));
|
for(const p of ofCat.filter(p=>!dirSub||p.subcategory===dirSub)) box.appendChild(listedFeed(p,'tile'));
|
||||||
};
|
};
|
||||||
draw();
|
draw();
|
||||||
return rows.length;
|
return rows.length;
|
||||||
@@ -307,7 +314,7 @@ function settingsModal(f, newUrl?: string){
|
|||||||
// Offer the categories the Directory already shows, so a blog about games joins Games rather
|
// Offer the categories the Directory already shows, so a blog about games joins Games rather
|
||||||
// than starting a second chip beside it.
|
// than starting a second chip beside it.
|
||||||
if($('#scats')) api('/api/directory').then(rows=>{ $('#scats').innerHTML=[...new Set((rows||[])
|
if($('#scats')) api('/api/directory').then(rows=>{ $('#scats').innerHTML=[...new Set((rows||[])
|
||||||
.map(p=>p.category).filter(Boolean))].sort().map(c=>`<option value="${esc(c)}">`).join(''); }).catch(()=>{});
|
.flatMap(p=>[p.category,p.subcategory]).filter(Boolean))].sort().map(c=>`<option value="${esc(c)}">`).join(''); }).catch(()=>{});
|
||||||
$('#ssave').onclick=async()=>{
|
$('#ssave').onclick=async()=>{
|
||||||
const max=$('#smax').value;
|
const max=$('#smax').value;
|
||||||
try{
|
try{
|
||||||
|
|||||||
Reference in New Issue
Block a user