Sortable item table, Size in its own column, Subscribed as an icon

- Every column heading sorts (kept, title, feed, file type, size,
  published); a second click reverses it. The server sorts through a
  fixed whitelist (order_sql), so it covers the whole list, not the
  fifty loaded; the choice is remembered in the browser.
- Size is its own column and shows KB for small files instead of
  "0 MB". The Item heading is Title.
- Popular/Directory/Add feed: Subscribed is a green circle-check.
- Tests: every sort column runs and orders both ways (db); the table
  sorts by title both ways and remembers across a reload (browser).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
This commit is contained in:
2026-09-11 17:17:16 +00:00
parent 0efc49519c
commit 5362436766
6 changed files with 152 additions and 16 deletions

View File

@@ -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.

View File

@@ -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) |

View File

@@ -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<Vec<EntryRow>> {
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<Vec<EntryRow>> {
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<String> {
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();

View File

@@ -824,6 +824,12 @@ struct Page {
filter: Option<String>,
#[serde(default)]
q: Option<String>,
/// 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<String>,
#[serde(default)]
dir: Option<String>,
}
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 {

View File

@@ -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);
});

View File

@@ -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','<path fill="currentColor" d="M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z"/>'), // solid/pause
caret:fa('0 0 256 512','<path fill="currentColor" d="M249.3 235.8c10.2 12.6 9.5 31.1-2.2 42.8l-128 128c-9.2 9.2-22.9 11.9-34.9 6.9S64.5 396.9 64.5 384l0-256c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l128 128 2.2 2.4z"/>'), // solid/caret-right
left:fa('0 0 512 512','<path fill="currentColor" d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 288 480 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-370.7 0 105.4-105.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/>'), // solid/arrow-left
subbed:fa('0 0 512 512','<path fill="currentColor" d="M256 512a256 256 0 1 1 0-512 256 256 0 1 1 0 512zM374 145.7c-10.7-7.8-25.7-5.4-33.5 5.3L221.1 315.2 169 263.1c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l72 72c5 5 11.8 7.5 18.8 7s13.4-4.1 17.5-9.8L379.3 179.2c7.8-10.7 5.4-25.7-5.3-33.5z"/>'), // 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='<div id="list"><div class="ephead"><span></span><span title="Kept">'+ICON.flag+'</span>'+
'<span>Item</span><span class="h-fd">Feed</span><span>File</span><span>Published</span><span></span></div>'+
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));
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 '<div class="ephead"><span></span>'+COLS.map(([k,label,icon])=>{
const on=S.sort.col===k;
return `<button class="hs${on?' on':''}${k==='feed'?' h-fd':''}" data-sort="${k}"`+
` title="Sort by ${label.toLowerCase()}" aria-label="Sort by ${label.toLowerCase()}">${icon||label}`+
`${on?`<span class="arr ${S.sort.dir}">${ICON.caret}</span>`:''}</button>`;
}).join('')+'<span></span></div>';
}
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){
<span class="fd">${esc(feedName(e.feed_id))}</span>
<div class="file">
${enc?kindIcon(enc):''}
${enc&&enc.length?`<span>${mb(enc.length)}</span>`:''}
${enc&&!has?`<div class="dlbar" data-bar="${enc.id}"><i></i></div>`:''}
</div>
<span class="size">${enc?mb(enc.length):''}</span>
<span class="date">${dateOf(e.published)}</span>
<div class="rowacts">
${playable?`<button class="iconbtn" data-a="play" title="Play" aria-label="Play">${ICON.play}</button>`:
@@ -1480,7 +1515,8 @@ async function listFeeds(url){
el.innerHTML=artHTML(p.image,p.title||p.id)+
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
(p.subscribed?'<span class="tag">Subscribed</span>'
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
// Yours already: the row opens it instead.
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; }