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:
58
src/db.rs
58
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<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();
|
||||
|
||||
13
src/web.rs
13
src/web.rs
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user