From 9bed6430237e074132d0c671806cdf161d7f87f9 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 21 Mar 2026 02:45:45 +0000 Subject: [PATCH] window: prefetch images and queue offline read/unread actions After a successful article refresh, all images referenced in article content are downloaded in the background so articles can be read offline. The prefetch only runs when the cache-images setting is enabled and the connection is not metered. Read/unread state changes that fail to reach the server (e.g. when offline) are now persisted to a local queue in ~/.cache/net.jeena.FeedTheMonkey/pending_sync.json. The queue is flushed at the start of the next successful fetch. --- src/image_cache.rs | 28 ++++++++++++++++++ src/main.rs | 1 + src/pending_actions.rs | 65 ++++++++++++++++++++++++++++++++++++++++++ src/window.rs | 34 ++++++++++++++++++---- 4 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 src/pending_actions.rs diff --git a/src/image_cache.rs b/src/image_cache.rs index 1d59ab5..f0d644a 100644 --- a/src/image_cache.rs +++ b/src/image_cache.rs @@ -134,6 +134,34 @@ fn serve_file(request: webkit6::URISchemeRequest, path: PathBuf) { } } +/// Prefetch all images referenced in the articles into the cache directory. +/// Runs entirely in the background; already-cached files are skipped. +pub async fn prefetch(articles: Vec
) { + let dir = images_dir(); + std::fs::create_dir_all(&dir).ok(); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .unwrap_or_default(); + + let re = regex::Regex::new(&format!(r#"src="{}([^"]+)""#, regex::escape(SCHEME_PREFIX))).unwrap(); + + for article in &articles { + for cap in re.captures_iter(&article.content) { + let original_url = percent_decode(&cap[1]); + let path = dir.join(url_to_filename(&original_url)); + if !path.exists() { + if let Ok(resp) = client.get(&original_url).send().await { + if let Ok(bytes) = resp.bytes().await { + std::fs::write(&path, &bytes).ok(); + } + } + } + } + } +} + /// Rewrite all remote image src attributes to feedthemonkey-img:// URIs. /// No network requests are made here — images are downloaded lazily by the /// URI scheme handler the first time the WebView requests them, then cached. diff --git a/src/main.rs b/src/main.rs index 11ed652..89e6d38 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod api; mod app; mod cache; mod image_cache; +mod pending_actions; mod filters; mod preferences_dialog; mod article_row; diff --git a/src/pending_actions.rs b/src/pending_actions.rs new file mode 100644 index 0000000..19ccc8b --- /dev/null +++ b/src/pending_actions.rs @@ -0,0 +1,65 @@ +use std::path::PathBuf; + +use crate::api::Api; + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Action { + Read, + Unread, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct PendingAction { + pub action: Action, + pub id: String, +} + +fn path() -> PathBuf { + glib::user_cache_dir() + .join("net.jeena.FeedTheMonkey") + .join("pending_sync.json") +} + +fn load() -> Vec { + let Ok(data) = std::fs::read_to_string(path()) else { return Vec::new() }; + serde_json::from_str(&data).unwrap_or_default() +} + +fn save(actions: &[PendingAction]) { + let dir = path(); + std::fs::create_dir_all(dir.parent().unwrap()).ok(); + if let Ok(s) = serde_json::to_string(actions) { + std::fs::write(path(), s).ok(); + } +} + +/// Queue an action for an article. If the same article already has a pending +/// action, it is replaced with the new one (last writer wins). +pub fn add(action: Action, id: &str) { + let mut actions = load(); + actions.retain(|a| a.id != id); + actions.push(PendingAction { action, id: id.to_string() }); + save(&actions); +} + +/// Send all queued actions to the server. Successfully synced actions are +/// removed; failed ones remain in the queue for the next attempt. +pub async fn flush(api: &Api, write_token: &str) { + let actions = load(); + if actions.is_empty() { + return; + } + + let mut remaining = Vec::new(); + for pending in actions { + let result = match pending.action { + Action::Read => api.mark_read(write_token, &pending.id).await, + Action::Unread => api.mark_unread(write_token, &pending.id).await, + }; + if result.is_err() { + remaining.push(pending); + } + } + save(&remaining); +} diff --git a/src/window.rs b/src/window.rs index 15e6ed8..70f9189 100644 --- a/src/window.rs +++ b/src/window.rs @@ -592,12 +592,19 @@ pub mod imp { let cache_images = settings.boolean("cache-images") && !gio::NetworkMonitor::default().is_network_metered(); + let write_token = self.write_token.borrow().clone(); let win_weak = self.obj().downgrade(); crate::runtime::spawn( async move { + // Flush any read/unread actions that failed to sync earlier. + if let Some(ref wt) = write_token { + crate::pending_actions::flush(&api, wt).await; + } let articles = api.fetch_unread().await?; let articles = if cache_images { - crate::image_cache::process(articles) + let processed = crate::image_cache::process(articles); + crate::runtime::spawn_bg(crate::image_cache::prefetch(processed.clone())); + processed } else { articles }; @@ -677,10 +684,19 @@ pub mod imp { let wt = self.write_token.borrow().clone(); if let (Some(api), Some(wt)) = (api, wt) { crate::runtime::spawn_bg(async move { - if let Err(e) = api.mark_read(&wt, &item_id).await { - eprintln!("mark_read error: {e}"); + if api.mark_read(&wt, &item_id).await.is_err() { + crate::pending_actions::add( + crate::pending_actions::Action::Read, + &item_id, + ); } }); + } else { + // Offline or write token not yet available — queue for later. + crate::pending_actions::add( + crate::pending_actions::Action::Read, + &item_id, + ); } } @@ -706,10 +722,18 @@ pub mod imp { if let (Some(api), Some(wt)) = (api, wt) { let id_clone = id.clone(); crate::runtime::spawn_bg(async move { - if let Err(e) = api.mark_unread(&wt, &id_clone).await { - eprintln!("mark_unread error: {e}"); + if api.mark_unread(&wt, &id_clone).await.is_err() { + crate::pending_actions::add( + crate::pending_actions::Action::Unread, + &id_clone, + ); } }); + } else { + crate::pending_actions::add( + crate::pending_actions::Action::Unread, + &id, + ); } let toast = libadwaita::Toast::new("Marked as unread");