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:
parent
3f759bce2e
commit
9bed643023
4 changed files with 123 additions and 5 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
65
src/pending_actions.rs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue