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.
This commit is contained in:
Jeena 2026-03-21 02:45:45 +00:00
parent 3f759bce2e
commit 9bed643023
4 changed files with 123 additions and 5 deletions

View file

@ -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<Article>) {
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. /// Rewrite all remote image src attributes to feedthemonkey-img:// URIs.
/// No network requests are made here — images are downloaded lazily by the /// No network requests are made here — images are downloaded lazily by the
/// URI scheme handler the first time the WebView requests them, then cached. /// URI scheme handler the first time the WebView requests them, then cached.

View file

@ -2,6 +2,7 @@ mod api;
mod app; mod app;
mod cache; mod cache;
mod image_cache; mod image_cache;
mod pending_actions;
mod filters; mod filters;
mod preferences_dialog; mod preferences_dialog;
mod article_row; mod article_row;

65
src/pending_actions.rs Normal file
View file

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

View file

@ -592,12 +592,19 @@ pub mod imp {
let cache_images = settings.boolean("cache-images") let cache_images = settings.boolean("cache-images")
&& !gio::NetworkMonitor::default().is_network_metered(); && !gio::NetworkMonitor::default().is_network_metered();
let write_token = self.write_token.borrow().clone();
let win_weak = self.obj().downgrade(); let win_weak = self.obj().downgrade();
crate::runtime::spawn( crate::runtime::spawn(
async move { 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 = api.fetch_unread().await?;
let articles = if cache_images { 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 { } else {
articles articles
}; };
@ -677,10 +684,19 @@ pub mod imp {
let wt = self.write_token.borrow().clone(); let wt = self.write_token.borrow().clone();
if let (Some(api), Some(wt)) = (api, wt) { if let (Some(api), Some(wt)) = (api, wt) {
crate::runtime::spawn_bg(async move { crate::runtime::spawn_bg(async move {
if let Err(e) = api.mark_read(&wt, &item_id).await { if api.mark_read(&wt, &item_id).await.is_err() {
eprintln!("mark_read error: {e}"); 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) { if let (Some(api), Some(wt)) = (api, wt) {
let id_clone = id.clone(); let id_clone = id.clone();
crate::runtime::spawn_bg(async move { crate::runtime::spawn_bg(async move {
if let Err(e) = api.mark_unread(&wt, &id_clone).await { if api.mark_unread(&wt, &id_clone).await.is_err() {
eprintln!("mark_unread error: {e}"); 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"); let toast = libadwaita::Toast::new("Marked as unread");