diff --git a/CHANGELOG.md b/CHANGELOG.md index 7edaea4..33e1575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ The long form, with what was wrong before and how it was found, is in and a status bar with the totals. - Mark everything read from All Subscriptions, across every feed you subscribe to (`POST /api/read-all`). It asks first. All Subscriptions can also check every feed from its header. +- Click a column heading in the item table to sort by it (kept, title, feed, file type, size, + published); click again to reverse. The server sorts, so it covers the whole list, not just the + fifty shown, and the choice is remembered. ### Changed @@ -45,10 +48,13 @@ The long form, with what was wrong before and how it was found, is in and read as closing the page. The remaining word buttons are icons too: - Log, Add feed, Users, Unsubscribe and OPML. - Popular's Subscribe, Copy and Sign out. + - The Subscribed label in Popular, the Directory and Add feed, which is now a green check. - The player's back, play, forward and close, which were font characters, and the folder arrow. - The toolbar's read and keep buttons show the selected item's state, with the same icons as the item's own buttons. Play, read and keep sit together, and Scan sits with add and unsubscribe. - An OPML subscription's page has the same header as a feed's, with its buttons in the same places. +- The item table's size has its own column, apart from the file's type, and shows KB for small + files instead of "0 MB". The Item heading is now Title. - A file's type is an icon (audio, video, image, PDF, torrent, other), green once it is downloaded and red when the download failed, with the details in its tooltip. One icon per row keeps the column lined up. The DOWNLOADED and PENDING labels are gone. diff --git a/docs/architecture.md b/docs/architecture.md index dcf813a..4be32ed 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -104,7 +104,7 @@ else a `401`. | `GET /login`, `POST /api/login`, `POST /api/logout`, `GET /api/me` | sign-in | | `GET /api/feeds`, `POST /api/feeds` | your subscriptions; subscribe | | `PATCH /api/feeds/{id}`, `DELETE /api/feeds/{id}` | your settings or (admin) the feed's; unsubscribe | -| `GET /api/feeds/{id}/entries` | paged, filtered, searchable | +| `GET /api/feeds/{id}/entries` | paged, filtered, searchable, sortable (`sort` = kept, title, feed, type, size or published; `dir` = asc or desc) | | `GET /api/entries` | the same, across every feed you subscribe to (All Subscriptions) | | `POST /api/feeds/{id}/read-all`, `POST /api/feeds/{id}/download-latest` | | | `POST /api/read-all` | everything read in every feed you subscribe to (All Subscriptions) | diff --git a/src/db.rs b/src/db.rs index 27a220b..34526e1 100644 --- a/src/db.rs +++ b/src/db.rs @@ -620,6 +620,25 @@ fn scope_sql(feed_id: Option<&str>, user_param: u8) -> String { } } +/// The item table's ORDER BY. The column name picks one of these fixed expressions, so nothing +/// the caller sends reaches the query, and anything unrecognised is newest first. Ties fall back +/// to newest first too, so a page boundary is stable across "Load more". +/// +/// ponytail: file type and size look at the item's first and largest file. The row shows the file +/// it summarises, which is almost always that one; sort by that one if they ever disagree. +pub fn order_sql(col: &str, dir: &str) -> String { + let expr = match col { + "kept" => "coalesce(s.flagged, 0)", + "title" => "lower(coalesce(e.title, ''))", + "feed" => "(SELECT lower(coalesce(f.title, f.id)) FROM feeds f WHERE f.id = e.feed_id)", + "type" => "(SELECT min(x.mime) FROM enclosures x WHERE x.feed_id = e.feed_id AND x.guid = e.guid)", + "size" => "(SELECT max(x.length) FROM enclosures x WHERE x.feed_id = e.feed_id AND x.guid = e.guid)", + _ => "coalesce(e.published, e.first_seen)", + }; + let dir = if dir == "asc" { "ASC" } else { "DESC" }; + format!("{expr} {dir}, coalesce(e.published, e.first_seen) DESC, e.rowid DESC") +} + /// Which slice of a feed the UI is asking for. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Filter { @@ -679,7 +698,7 @@ impl Db { offset: i64, limit: i64, ) -> Result> { - self.entries_in(user_id, Some(feed_id), filter, search, offset, limit) + self.entries_in(user_id, Some(feed_id), filter, search, offset, limit, &order_sql("published", "desc")) } /// `entries` for one feed, or across every feed the person subscribes to when `feed_id` @@ -692,6 +711,7 @@ impl Db { search: Option<&str>, offset: i64, limit: i64, + order: &str, ) -> Result> { let conn = self.conn.lock().unwrap(); let like = search @@ -705,7 +725,7 @@ impl Db { LEFT JOIN entry_state s ON s.user_id = ?5 AND s.feed_id = e.feed_id AND s.guid = e.guid WHERE {} AND {} AND {SEARCH} - ORDER BY coalesce(e.published, e.first_seen) DESC, e.rowid DESC + ORDER BY {order} LIMIT ?4 OFFSET ?3", scope_sql(feed_id, 5), filter.sql() @@ -1357,6 +1377,40 @@ pub fn now() -> i64 { mod tests { use super::*; + #[test] + fn every_sort_column_runs_and_orders_both_ways() { + let db = Db::memory().unwrap(); + db.exec_for_test( + "INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0); + INSERT INTO subscriptions (user_id, feed_id, created) VALUES (1,'f',0),(1,'g',0); + INSERT INTO feeds (id, url, title) VALUES ('f','u','Zebra'),('g','v','Aardvark'); + INSERT INTO entries (feed_id, guid, title, first_seen) VALUES + ('f','a','banana',100),('g','b','Apple',200),('f','c','cherry',300); + INSERT INTO enclosures (id, feed_id, guid, url, mime, length, state) VALUES + (1,'f','a','u1','audio/mpeg',300,'pending'),(2,'g','b','u2','image/png',10,'pending'), + (3,'f','c','u3','video/mp4',2000,'pending');", + ) + .unwrap(); + let order = |col: &str, dir: &str| -> Vec { + db.entries_in(1, None, Filter::All, None, 0, 50, &order_sql(col, dir)) + .unwrap() + .into_iter() + .map(|e| e.guid) + .collect() + }; + assert_eq!(order("title", "asc"), ["b", "a", "c"], "Apple, banana, cherry: case folded"); + assert_eq!(order("title", "desc"), ["c", "a", "b"]); + assert_eq!(order("feed", "asc"), ["b", "c", "a"], "Aardvark, then Zebra's newest first"); + assert_eq!(order("type", "asc"), ["a", "b", "c"], "audio, image, video"); + assert_eq!(order("size", "desc"), ["c", "a", "b"]); + assert_eq!(order("published", "desc"), ["c", "b", "a"]); + db.set_entry_flag(1, "f", "a", EntryFlag::Flagged, true).unwrap(); + assert_eq!(order("kept", "desc")[0], "a"); + // An unknown column or direction is newest first; the name itself never reaches the SQL. + assert_eq!(order("title; DROP TABLE entries", "sideways"), ["c", "b", "a"]); + assert!(!order_sql("x'; --", "asc").contains("x'")); + } + #[test] fn deleting_a_shared_file_asks_about_everyone_else() { let db = Db::memory().unwrap(); diff --git a/src/web.rs b/src/web.rs index 59636cf..81b8bc3 100644 --- a/src/web.rs +++ b/src/web.rs @@ -824,6 +824,12 @@ struct Page { filter: Option, #[serde(default)] q: Option, + /// A column name and asc or desc. Anything unrecognised is newest first: the name picks a + /// fixed expression in the query and never reaches it itself. + #[serde(default)] + sort: Option, + #[serde(default)] + dir: Option, } fn fifty() -> i64 { @@ -864,7 +870,12 @@ fn entry_page( let filter = crate::db::Filter::parse(page.filter.as_deref().unwrap_or("all")); let search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty()); let db = &state.ctx.db; - let mut rows = db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200))?; + let order = crate::db::order_sql( + page.sort.as_deref().unwrap_or("published"), + page.dir.as_deref().unwrap_or("desc"), + ); + let mut rows = + db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order)?; // Feed HTML is untrusted: it reaches the page only after ammonia has been through it. for row in &mut rows { if let Some(d) = &row.description { diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 1714045..1c7ef7f 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -642,7 +642,8 @@ test('Popular lists what everyone here reads, but never a private feed', async ( // Everyone counts, you included: it stays listed, marked as yours, with one more subscriber. expect(await row()).toMatchObject({ subscribed: true, subscribers: before.subscribers + 1 }); await piper.locator('#feedlist .place', { hasText: 'Popular' }).click(); - await expect(offered.filter({ hasText: 'Test Show' })).toContainText('Subscribed'); + await expect(offered.filter({ hasText: 'Test Show' }).locator('[title^="Subscribed"]')).toBeVisible(); + await expect(offered.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]')).toHaveCount(0); // All Subscriptions is every item from piper's feeds and only those: the admin's Picture // Blog is not among them. @@ -738,3 +739,31 @@ test('All Subscriptions marks everything read, across every feed', async ({ page await page.locator('.tabs button', { hasText: 'Unread' }).click(); await expect(page.locator('.ep')).toHaveCount(0); }); + +test('the item table sorts by any column, both ways, and remembers', async ({ page }) => { + const all = page.locator('#feedlist .place', { hasText: 'All Subscriptions' }); + await all.click(); + const head = k => page.locator(`#list .ephead [data-sort="${k}"]`); + const titles = () => page.locator('#eps .ep .t').allTextContents(); + // Byte order on lower case, which is what SQLite gives for lower(...). + const cmp = (a, b) => (a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0); + const sorted = (t, dir) => JSON.stringify(t) === JSON.stringify([...t].sort((a, b) => cmp(a, b) * dir)); + await expect(page.locator('#eps .ep').nth(2)).toBeVisible({ timeout: 20_000 }); + expect(new Set(await titles()).size).toBeGreaterThan(2); // or both orders would prove nothing + + await expect(head('title')).toHaveText('Title'); + await head('title').click(); + await expect.poll(async () => sorted(await titles(), 1)).toBe(true); + await head('title').click(); + await expect.poll(async () => sorted(await titles(), -1)).toBe(true); + + // Kept across a reload. + await page.reload(); + await all.click(); + await expect(head('title').locator('.arr.desc')).toBeVisible(); + await expect.poll(async () => sorted(await titles(), -1)).toBe(true); + + // Size has its own column; the file column is just what the file is. + await expect(page.locator('#eps .ep .size', { hasText: /\d/ }).first()).toBeVisible(); + await expect(page.locator('#eps .ep .file', { hasText: /\d/ })).toHaveCount(0); +}); diff --git a/web/index.html b/web/index.html index a3885ef..01d77a3 100644 --- a/web/index.html +++ b/web/index.html @@ -242,6 +242,8 @@ a.btn{text-decoration:none;color:inherit} it is here, red when the download failed. */ .kind{display:inline-grid;place-items:center;color:var(--dim)} .kind.here{color:var(--good)} +.subbed{display:inline-flex;padding:0 9px;color:var(--good)} +.subbed .i{width:18px;height:18px} .kind.bad{color:var(--bad)} .fhead .art .i{width:22px;height:22px} .toolbar{ @@ -254,16 +256,24 @@ a.btn{text-decoration:none;color:inherit} .toolbar .grow{flex:1;min-width:150px;max-width:320px} /* ---------- items ---------- */ -/* A table, as the original's was: unread, kept, the item, its feed, its file, when. */ +/* A table, as the original's was: unread, kept, title, feed, file, size, published. */ .ephead,.ep{ display:grid;gap:8px;align-items:center; - grid-template-columns:22px 22px minmax(120px,1fr) minmax(0,160px) minmax(0,112px) minmax(0,92px) 64px; + grid-template-columns:22px 22px minmax(120px,1fr) minmax(0,160px) 56px minmax(0,72px) minmax(0,92px) 64px; } .ephead{ position:sticky;top:0;z-index:2;background:var(--bg);padding:7px 10px 5px; border-bottom:1px solid var(--line);margin-bottom:3px; font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint); } +/* Each heading sorts by its column; the caret says which way. */ +.ephead .hs{display:flex;align-items:center;gap:4px;min-width:0;padding:0;border:0;background:none; + font:inherit;color:inherit;text-transform:inherit;letter-spacing:inherit;text-align:left;cursor:pointer} +.ephead .hs:hover,.ephead .hs.on{color:var(--fg)} +.ephead .hs .arr{display:inline-flex} +.ephead .hs .arr .i{width:8px;height:8px} +.ephead .hs .arr.asc{transform:rotate(-90deg)} +.ephead .hs .arr.desc{transform:rotate(90deg)} .ep{padding:4px 10px;border-radius:7px;border:1px solid transparent;cursor:pointer;margin-bottom:1px} .ep>*{min-width:0} .ep.sel{background:var(--raise);border-color:var(--line)} @@ -277,12 +287,12 @@ a.btn{text-decoration:none;color:inherit} .ep.read .t{color:var(--dim);font-weight:500} .ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px} .ep .line:empty{display:none} -.ep .fd,.ep .date{color:var(--dim);font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ep .fd,.ep .size,.ep .date{color:var(--dim);font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .ep .file{display:flex;gap:6px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px} .ep .file .dlbar{flex-basis:100%;margin-top:2px} .ep .rowacts{justify-content:flex-end} /* One feed's own table has no need of a Feed column; All Subscriptions does. */ -#split.one .ephead,#split.one .ep{grid-template-columns:22px 22px minmax(120px,1fr) minmax(0,112px) minmax(0,92px) 64px} +#split.one .ephead,#split.one .ep{grid-template-columns:22px 22px minmax(120px,1fr) 56px minmax(0,72px) minmax(0,92px) 64px} #split.one .h-fd,#split.one .ep .fd{display:none} .dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex:none} .chip{ @@ -422,7 +432,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu /* One pane at a time: the list, then the item over it. */ #split{grid-template-columns:1fr;grid-template-rows:1fr;grid-template-areas:"list"} /* Title and date only; the files follow the item's text in the reader instead. */ - #files,.ephead,.ep .fd,.ep .file,.ep .rowacts{display:none} + #files,.ephead,.ep .fd,.ep .file,.ep .size,.ep .rowacts{display:none} .ep,#split.one .ep{grid-template-columns:20px 20px minmax(0,1fr) auto} #grab{display:none} #detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px} @@ -498,6 +508,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu :root[data-theme="classic"] #eps .ep.sel{background:#3875d7;border-color:#3875d7} :root[data-theme="classic"] .ep.sel .t, :root[data-theme="classic"] .ep.sel .fd, +:root[data-theme="classic"] .ep.sel .size, :root[data-theme="classic"] .ep.sel .date, :root[data-theme="classic"] .ep.sel .line, :root[data-theme="classic"] .ep.sel .st, @@ -631,6 +642,7 @@ const ICON={ pause:fa('0 0 384 512',''), // solid/pause caret:fa('0 0 256 512',''), // solid/caret-right left:fa('0 0 512 512',''), // solid/arrow-left + subbed:fa('0 0 512 512',''), // solid/circle-check }; // One meaning per icon: minus unsubscribes, x closes or cancels, plus adds or subscribes, and a // dialog's confirm button carries the icon of what it does. Words go in the tooltip. @@ -693,7 +705,9 @@ const ago = t => { return new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}); }; const dateOf = t => t?new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):''; -const mb = n => n?(n/1048576).toFixed(0)+' MB':''; +// A podcast episode is tens of MB, an article's image a few KB: whole MB made the small ones "0 MB". +const mb = n => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB' + : n<1073741824?Math.round(n/1048576)+' MB' : (n/1073741824).toFixed(1)+' GB'; const initials = s => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?'; function artHTML(url,name,cls){ return url @@ -708,6 +722,9 @@ function nav(on){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden= const S = { feeds:[], feed:null, entries:[], total:0, offset:0, limit:50, filter:'all', q:'', sel:null, busy:new Set(), me:null, + // The item table's order, kept across visits. The server sorts: a list arrives fifty at a time. + sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')); }catch{ return null; } })() + ||{col:'published',dir:'desc'}, }; const LIMIT = 50; @@ -849,9 +866,9 @@ function renderFeed(){ const pane=document.createElement('div'); pane.id='split'; if(f) pane.className='one'; - pane.innerHTML='
'+ICON.flag+''+ - 'ItemFeedFilePublished
'+ + pane.innerHTML='
'+sortHead()+ '
'; + $$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort)); box.appendChild(pane); pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '40%'); dragSplit(pane); @@ -861,6 +878,24 @@ function renderFeed(){ $$('#content .tabs button').forEach(b=>b.onclick=()=>{S.filter=b.dataset.f;S.offset=0;renderFeed();loadEntries()}); } +/// The item table's headings, each a button that sorts by its column. The first click goes the +/// natural way round (A to Z; newest, largest and kept first) and the next one reverses it. +const COLS=[['kept','Kept',ICON.flag],['title','Title'],['feed','Feed'],['type','File'],['size','Size'],['published','Published']]; +function sortHead(){ + return '
'+COLS.map(([k,label,icon])=>{ + const on=S.sort.col===k; + return ``; + }).join('')+'
'; +} +function sortBy(col){ + const first=['published','size','kept'].includes(col)?'desc':'asc'; + S.sort={col,dir:S.sort.col===col?(S.sort.dir==='asc'?'desc':'asc'):first}; + try{ localStorage.setItem('ipx.sort',JSON.stringify(S.sort)); }catch{} + 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. function renderGroup(f,kids){ @@ -942,7 +977,7 @@ async function allAction(a){ async function loadEntries(append){ // Directory and Popular list feeds, not items. if(!S.feed || VIEWS[S.feed]?.url) return; - const p=new URLSearchParams({offset:S.offset,limit:LIMIT,filter:S.filter}); + const p=new URLSearchParams({offset:S.offset,limit:LIMIT,filter:S.filter,sort:S.sort.col,dir:S.sort.dir}); if(S.q) p.set('q',S.q); const r=await api(S.feed===':all' ? `/api/entries?${p}` : `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`); @@ -1002,9 +1037,9 @@ function epEl(e){ ${esc(feedName(e.feed_id))}
${enc?kindIcon(enc):''} - ${enc&&enc.length?`${mb(enc.length)}`:''} ${enc&&!has?`
`:''}
+ ${enc?mb(enc.length):''} ${dateOf(e.published)}
${playable?``: @@ -1480,7 +1515,8 @@ async function listFeeds(url){ el.innerHTML=artHTML(p.image,p.title||p.id)+ `
${esc(p.title||p.id)}`+ `${p.subscribers} subscriber${p.subscribers===1?'':'s'}
`+ - (p.subscribed?'Subscribed' + // Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one. + (p.subscribed?`${ICON.subbed}` :``); // Yours already: the row opens it instead. if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; }