The original iPodderX layout: toolbar, places, item table, Files pane
- A toolbar across the window with the original's groups: add and unsubscribe, play, mark read and keep for the selected item, scan, a search box for what is showing, and Settings and Log (admins only). - Directory, Popular and All Subscriptions sit at the top of the feed list and open in the main pane; the Popular and Directory buttons and their dialogs are gone. - All Subscriptions lists every item from every feed you subscribe to: GET /api/entries, the per-feed query with its scope widened. The enclosure lookup after it matches files to rows by feed and guid, since a page can now span feeds. - Items are a table (unread, kept, item, feed, file, published) with a Files pane beside it, the text below, and a status bar with totals. On a phone the files follow the text and the table is title and date. - Tests: enclosures are checked in #files; the toolbar's read, keep and play act on the selected item; All Subscriptions holds only your feeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
This commit is contained in:
52
src/db.rs
52
src/db.rs
@@ -609,6 +609,17 @@ pub struct EntryRow {
|
||||
const SEARCH: &str = "(?2 = '' OR lower(coalesce(e.title, '')) LIKE ?2
|
||||
OR lower(coalesce(e.description, '')) LIKE ?2)";
|
||||
|
||||
/// Which feeds a query covers: one, or every feed the person subscribes to. Both forms
|
||||
/// mention `?1`, since binding a parameter the statement does not use is an error.
|
||||
fn scope_sql(feed_id: Option<&str>, user_param: u8) -> String {
|
||||
match feed_id {
|
||||
Some(_) => "e.feed_id = ?1".into(),
|
||||
None => format!(
|
||||
"?1 IS NULL AND e.feed_id IN (SELECT feed_id FROM subscriptions WHERE user_id = ?{user_param})"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Which slice of a feed the UI is asking for.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Filter {
|
||||
@@ -667,6 +678,20 @@ impl Db {
|
||||
search: Option<&str>,
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
) -> Result<Vec<EntryRow>> {
|
||||
self.entries_in(user_id, Some(feed_id), filter, search, offset, limit)
|
||||
}
|
||||
|
||||
/// `entries` for one feed, or across every feed the person subscribes to when `feed_id`
|
||||
/// is None: the All Subscriptions view.
|
||||
pub fn entries_in(
|
||||
&self,
|
||||
user_id: i64,
|
||||
feed_id: Option<&str>,
|
||||
filter: Filter,
|
||||
search: Option<&str>,
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
) -> Result<Vec<EntryRow>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let like = search
|
||||
@@ -679,9 +704,10 @@ impl Db {
|
||||
FROM entries e
|
||||
LEFT JOIN entry_state s
|
||||
ON s.user_id = ?5 AND s.feed_id = e.feed_id AND s.guid = e.guid
|
||||
WHERE e.feed_id = ?1 AND {} AND {SEARCH}
|
||||
WHERE {} AND {} AND {SEARCH}
|
||||
ORDER BY coalesce(e.published, e.first_seen) DESC, e.rowid DESC
|
||||
LIMIT ?4 OFFSET ?3",
|
||||
scope_sql(feed_id, 5),
|
||||
filter.sql()
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
@@ -711,14 +737,14 @@ impl Db {
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
// Only the guids on this page, so a feed with thousands of entries stays cheap.
|
||||
// Only the guids on this page, so a feed with thousands of entries stays cheap. A page
|
||||
// can span feeds, so each file is matched to its row by feed as well as guid, below.
|
||||
let placeholders = std::iter::repeat_n("?", rows.len()).collect::<Vec<_>>().join(",");
|
||||
let sql = format!(
|
||||
"SELECT id, feed_id, guid, url, mime, length, path, state, last_error
|
||||
FROM enclosures WHERE feed_id = ? AND guid IN ({placeholders}) ORDER BY id"
|
||||
FROM enclosures WHERE guid IN ({placeholders}) ORDER BY id"
|
||||
);
|
||||
let mut params: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(rows.len() + 1);
|
||||
params.push(&feed_id);
|
||||
let mut params: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(rows.len());
|
||||
for row in &rows {
|
||||
params.push(&row.guid);
|
||||
}
|
||||
@@ -740,7 +766,7 @@ impl Db {
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
for enc in encs {
|
||||
if let Some(row) = rows.iter_mut().find(|r| r.guid == enc.guid) {
|
||||
if let Some(row) = rows.iter_mut().find(|r| r.guid == enc.guid && r.feed_id == enc.feed_id) {
|
||||
row.enclosures.push(enc);
|
||||
}
|
||||
}
|
||||
@@ -754,6 +780,17 @@ impl Db {
|
||||
feed_id: &str,
|
||||
filter: Filter,
|
||||
search: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
self.count_in(user_id, Some(feed_id), filter, search)
|
||||
}
|
||||
|
||||
/// `count_entries` for one feed, or across every feed the person subscribes to.
|
||||
pub fn count_in(
|
||||
&self,
|
||||
user_id: i64,
|
||||
feed_id: Option<&str>,
|
||||
filter: Filter,
|
||||
search: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let like = search
|
||||
@@ -763,7 +800,8 @@ impl Db {
|
||||
"SELECT count(*) FROM entries e
|
||||
LEFT JOIN entry_state s
|
||||
ON s.user_id = ?3 AND s.feed_id = e.feed_id AND s.guid = e.guid
|
||||
WHERE e.feed_id = ?1 AND {} AND {SEARCH}",
|
||||
WHERE {} AND {} AND {SEARCH}",
|
||||
scope_sql(feed_id, 3),
|
||||
filter.sql()
|
||||
);
|
||||
Ok(conn.query_row(&sql, rusqlite::params![feed_id, like, user_id], |r| r.get(0))?)
|
||||
|
||||
28
src/web.rs
28
src/web.rs
@@ -65,6 +65,7 @@ pub fn router(state: WebState) -> Router {
|
||||
.route("/api/feeds", get(feeds).post(add_feed))
|
||||
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
|
||||
.route("/api/feeds/{id}/entries", get(entries))
|
||||
.route("/api/entries", get(all_entries))
|
||||
.route("/api/entries/{feed_id}/{guid}/flags", post(set_flags))
|
||||
.route("/api/entries/{feed_id}/{guid}/position", post(set_position))
|
||||
.route("/api/feeds/{id}/read-all", post(read_all))
|
||||
@@ -824,20 +825,37 @@ async fn entries(
|
||||
Path(id): Path<String>,
|
||||
user: crate::db::User,
|
||||
Query(page): Query<Page>,
|
||||
) -> Result<Json<EntryPage>, ApiError> {
|
||||
entry_page(&state, user.id, Some(&id), &page)
|
||||
}
|
||||
|
||||
/// Every subscribed feed's items together, newest first: All Subscriptions.
|
||||
async fn all_entries(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
Query(page): Query<Page>,
|
||||
) -> Result<Json<EntryPage>, ApiError> {
|
||||
entry_page(&state, user.id, None, &page)
|
||||
}
|
||||
|
||||
/// One feed's page of items, or every subscribed feed's when `feed` is None.
|
||||
fn entry_page(
|
||||
state: &WebState,
|
||||
user_id: i64,
|
||||
feed: Option<&str>,
|
||||
page: &Page,
|
||||
) -> Result<Json<EntryPage>, ApiError> {
|
||||
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 mut rows = state
|
||||
.ctx
|
||||
.db
|
||||
.entries(user.id, &id, filter, search, page.offset, page.limit.clamp(1, 200))?;
|
||||
let db = &state.ctx.db;
|
||||
let mut rows = db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200))?;
|
||||
// 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 {
|
||||
row.description = Some(ammonia::clean(d));
|
||||
}
|
||||
}
|
||||
let total = state.ctx.db.count_entries(user.id, &id, filter, search)?;
|
||||
let total = db.count_in(user_id, feed, filter, search)?;
|
||||
Ok(Json(EntryPage { total, entries: rows }))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user