Step 3: feed fetch and parse
Conditional GET plus an RSS-first, Atom-fallback parser normalising both into one entry model, with iTunes explicit and ttl handling carried over from the Python. Enclosures are recorded as pending; nothing downloads yet. GUID falls back guid -> link -> enclosure url -> title rather than hashing the description as the original did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
This commit is contained in:
122
src/db.rs
122
src/db.rs
@@ -121,6 +121,128 @@ impl Db {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Everything `fetch` needs to decide whether to poll a feed, and how.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct HttpState {
|
||||
pub etag: Option<String>,
|
||||
pub last_modified: Option<String>,
|
||||
pub last_checked: Option<i64>,
|
||||
pub ttl_mins: Option<u64>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn http_state(&self, feed_id: &str) -> Result<HttpState> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT etag, last_modified, last_checked, ttl_mins FROM feeds WHERE id = ?1",
|
||||
[feed_id],
|
||||
|r| {
|
||||
Ok(HttpState {
|
||||
etag: r.get(0)?,
|
||||
last_modified: r.get(1)?,
|
||||
last_checked: r.get(2)?,
|
||||
ttl_mins: r.get::<_, Option<i64>>(3)?.map(|t| t.max(0) as u64),
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()?
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Upsert after a successful poll. Clears any previous error.
|
||||
pub fn record_feed(
|
||||
&self,
|
||||
feed_id: &str,
|
||||
url: &str,
|
||||
title: Option<&str>,
|
||||
etag: Option<&str>,
|
||||
last_modified: Option<&str>,
|
||||
ttl_mins: Option<u64>,
|
||||
) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO feeds (id, url, title, etag, last_modified, last_checked, ttl_mins, last_error)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
url = excluded.url,
|
||||
title = coalesce(excluded.title, feeds.title),
|
||||
etag = excluded.etag,
|
||||
last_modified = excluded.last_modified,
|
||||
last_checked = excluded.last_checked,
|
||||
ttl_mins = excluded.ttl_mins,
|
||||
last_error = NULL",
|
||||
rusqlite::params![feed_id, url, title, etag, last_modified, now(), ttl_mins.map(|t| t as i64)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 304, or any other poll that produced no new data: only the clock moves.
|
||||
pub fn touch_feed(&self, feed_id: &str, url: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO feeds (id, url, last_checked) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(id) DO UPDATE SET last_checked = excluded.last_checked, last_error = NULL",
|
||||
rusqlite::params![feed_id, url, now()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_feed_error(&self, feed_id: &str, url: &str, msg: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO feeds (id, url, last_checked, last_error) VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(id) DO UPDATE SET last_checked = excluded.last_checked, last_error = excluded.last_error",
|
||||
rusqlite::params![feed_id, url, now(), msg],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns true when this entry had not been seen before.
|
||||
///
|
||||
/// A changed description or title flips `read` back to 0, which is what the original's
|
||||
/// textDiff dance was ultimately for -- minus the diff markup, which the UI can do.
|
||||
pub fn record_entry(&self, feed_id: &str, e: &crate::feed::Entry) -> Result<bool> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let inserted = conn.execute(
|
||||
"INSERT OR IGNORE INTO entries
|
||||
(feed_id, guid, title, link, published, description, first_seen, read, flagged)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0, 0)",
|
||||
rusqlite::params![feed_id, e.guid, e.title, e.link, e.published, e.description, now()],
|
||||
)?;
|
||||
if inserted == 0 {
|
||||
// The SET expressions see the pre-update row, so this compares old vs new.
|
||||
conn.execute(
|
||||
"UPDATE entries SET
|
||||
title = coalesce(?3, title),
|
||||
description = coalesce(?4, description),
|
||||
read = CASE WHEN description IS NOT ?4 OR title IS NOT ?3 THEN 0 ELSE read END
|
||||
WHERE feed_id = ?1 AND guid = ?2",
|
||||
rusqlite::params![feed_id, e.guid, e.title, e.description],
|
||||
)?;
|
||||
}
|
||||
Ok(inserted == 1)
|
||||
}
|
||||
|
||||
/// Returns true when this enclosure URL is new. False means we have downloaded it
|
||||
/// before, or deliberately reaped it -- either way it is not fetched again.
|
||||
pub fn record_enclosure(
|
||||
&self,
|
||||
feed_id: &str,
|
||||
guid: &str,
|
||||
enc: &crate::feed::Enclosure,
|
||||
) -> Result<bool> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let inserted = conn.execute(
|
||||
"INSERT OR IGNORE INTO enclosures (feed_id, guid, url, mime, length, state)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 'pending')",
|
||||
rusqlite::params![feed_id, guid, enc.url, enc.mime, enc.length],
|
||||
)?;
|
||||
Ok(inserted == 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unix seconds. Everything time-shaped in the DB is stored this way.
|
||||
pub fn now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
|
||||
Reference in New Issue
Block a user