diff --git a/CHANGELOG.md b/CHANGELOG.md index e095d53..7ea8b09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. - 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. diff --git a/src/feed.rs b/src/feed.rs index 6cd35f4..9704946 100644 --- a/src/feed.rs +++ b/src/feed.rs @@ -1033,6 +1033,21 @@ const CATEGORIES: &[(&str, &[&str])] = &[ ("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) { + 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): /// 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 @@ -1571,5 +1586,14 @@ mod tests { assert!(super::picked(&answer("Blogs")).is_err(), "not one of Apple's"); 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"); + } } diff --git a/src/web.rs b/src/web.rs index b78536f..c008dbb 100644 --- a/src/web.rs +++ b/src/web.rs @@ -861,8 +861,10 @@ struct PopularRow { subscribers: i64, /// Yours already. Everyone counts, you included, so your own feeds are listed too. 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, + subcategory: Option, /// Any audio or video enclosure. Unlike category, every feed has an answer, so the /// Directory's Podcasts and Blogs between them hold everything. podcast: bool, @@ -902,14 +904,16 @@ async fn popular(state: &WebState, user_id: i64, everything: bool) -> Result { const listed = async id => (await page.evaluate(() => api('/api/directory'))).find(p => p.id === id); 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. await patch('test-show', 'Comedy'); expect((await listed('test-show')).category).toBe('Technology'); diff --git a/web/app.css b/web/app.css index 5717c24..0174dbc 100644 --- a/web/app.css +++ b/web/app.css @@ -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 .tabs{flex:none} .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:hover{color:var(--fg)} .chips button[aria-pressed="true"]{color:var(--fg);border-bottom-color:var(--accent2)} diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index 9808776..461484f 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -70,11 +70,12 @@ function listedFeed(p,cls){ // Directory's filters. Kept out here because a finished scan redraws the pane, which would // 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}; /// 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 -/// 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){ let rows=[]; 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. const cats=[...new Set(ofKind.map(p=>p.category).filter(Boolean))].sort(); 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= (both?`
${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}
`:'')+ - (cats.length?`
${cats.map(c=>btn('cat',c,c===dirCat)).join('')}
`:''); + (cats.length?`
${cats.map(c=>btn('cat',c,c===dirCat)).join('')}
`:'')+ + (subs.length?`
${subs.map(c=>btn('sub',c,c===dirSub)).join('')}
`:''); // A picked chip lifts on a second press. Everything is redrawn, so the keyboard goes back to // the button just pressed. for(const b of $$('button',bar)) b.onclick=()=>{ - const k=b.dataset.kind!=null?'kind':'cat', v=b.dataset[k]; - if(k==='kind') dirKind=v; else dirCat=dirCat===v?null:v; + const k=b.dataset.kind!=null?'kind':b.dataset.cat!=null?'cat':'sub', v=b.dataset[k]; + 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(); }; 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(); 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 // than starting a second chip beside it. if($('#scats')) api('/api/directory').then(rows=>{ $('#scats').innerHTML=[...new Set((rows||[]) - .map(p=>p.category).filter(Boolean))].sort().map(c=>`