use reqwest::Client; use serde::Deserialize; #[derive(Debug, Clone)] pub struct Api { client: Client, pub server_url: String, pub auth_token: String, } #[derive(Debug, Deserialize)] struct StreamContents { items: Vec, } #[derive(Debug, Deserialize)] struct Item { id: String, title: Option, origin: Option, canonical: Option>, published: Option, summary: Option, author: Option, categories: Option>, } #[derive(Debug, Deserialize)] struct Origin { title: Option, } #[derive(Debug, Deserialize)] struct Link { href: String, } #[derive(Debug, Deserialize)] struct Summary { content: Option, } use crate::model::Article; /// Return the candidate base URLs to try in order. /// Miniflux serves the Greader API at the server root; /// FreshRSS serves it at /api/greader.php. fn candidate_base_urls(server_url: &str) -> Vec { let base = server_url.trim_end_matches('/'); if base.ends_with("/api/greader.php") { vec![base.to_string()] } else { vec![ base.to_string(), format!("{base}/api/greader.php"), ] } } impl Api { pub async fn login( server_url: &str, username: &str, password: &str, ) -> Result { let client = Client::new(); let candidates = candidate_base_urls(server_url); let mut last_err = String::new(); for base in candidates { let url = format!("{base}/accounts/ClientLogin"); let resp = match client .post(&url) .form(&[("Email", username), ("Passwd", password)]) .send() .await { Ok(r) => r, Err(e) => { last_err = e.to_string(); continue; } }; let status = resp.status(); let body = resp.text().await.map_err(|e| e.to_string())?; if !status.is_success() { last_err = format!("Login failed ({}): {}", status.as_u16(), human_error(&body, status.as_u16())); continue; } let auth_token = match body.lines().find_map(|l| l.strip_prefix("Auth=")) { Some(t) => t.to_string(), None => { last_err = if looks_like_html(&body) { format!( "The server at {base} does not appear to be a \ Greader API endpoint. Check your server URL." ) } else { format!("Unexpected response from server: {}", body.trim()) }; continue; } }; return Ok(Self { client, server_url: base, auth_token }); } Err(last_err) } pub async fn fetch_write_token(&self) -> Result { let url = format!("{}/reader/api/0/token", self.server_url); let resp = self .client .get(&url) .header("Authorization", format!("GoogleLogin auth={}", self.auth_token)) .send() .await .map_err(|e| e.to_string())?; if !resp.status().is_success() { return Err(format!("Failed to fetch write token: {}", resp.status())); } resp.text().await.map_err(|e| e.to_string()).map(|s| s.trim().to_string()) } pub async fn fetch_unread(&self) -> Result, String> { let url = format!( "{}/reader/api/0/stream/contents/reading-list\ ?xt=user/-/state/com.google/read&n=200&output=json", self.server_url ); let resp = self .client .get(&url) .header("Authorization", format!("GoogleLogin auth={}", self.auth_token)) .send() .await .map_err(|e| e.to_string())?; if !resp.status().is_success() { return Err(format!("Failed to fetch articles: {}", resp.status())); } let stream: StreamContents = resp.json().await.map_err(|e| e.to_string())?; Ok(stream.items.into_iter().map(|item| { let unread = !item.categories.as_deref().unwrap_or_default() .iter() .any(|c| c == "user/-/state/com.google/read"); let content = item.summary .as_ref() .and_then(|s| s.content.clone()) .unwrap_or_default(); let excerpt = plain_text_excerpt(&content, 150); Article { id: item.id, title: item.title.unwrap_or_default(), feed_title: item.origin.as_ref().and_then(|o| o.title.clone()).unwrap_or_default(), author: item.author.unwrap_or_default(), link: item.canonical.as_ref() .and_then(|v| v.first()) .map(|l| l.href.clone()) .unwrap_or_default(), published: item.published.unwrap_or(0), content, excerpt, unread, } }).collect()) } pub async fn mark_read( &self, write_token: &str, item_id: &str, ) -> Result<(), String> { self.edit_tag(write_token, item_id, "a", "user/-/state/com.google/read").await } pub async fn mark_unread( &self, write_token: &str, item_id: &str, ) -> Result<(), String> { self.edit_tag(write_token, item_id, "r", "user/-/state/com.google/read").await } async fn edit_tag( &self, write_token: &str, item_id: &str, action_key: &str, state: &str, ) -> Result<(), String> { let url = format!("{}/reader/api/0/edit-tag", self.server_url); let resp = self .client .post(&url) .header("Authorization", format!("GoogleLogin auth={}", self.auth_token)) .form(&[("i", item_id), (action_key, state), ("T", write_token)]) .send() .await .map_err(|e| e.to_string())?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { return Err("UNAUTHORIZED".to_string()); } if !resp.status().is_success() { return Err(format!("edit-tag failed: {}", resp.status())); } Ok(()) } } fn looks_like_html(body: &str) -> bool { let trimmed = body.trim_start(); trimmed.starts_with(" String { if looks_like_html(body) { match status { 401 | 403 => "Wrong username or password.".to_string(), 404 => "API endpoint not found. Check your server URL.".to_string(), _ => format!("Server returned HTTP {status}. Check your server URL."), } } else { let trimmed = body.trim(); if trimmed.is_empty() { format!("Server returned HTTP {status} with no message.") } else { trimmed.to_string() } } } fn plain_text_excerpt(html: &str, max_chars: usize) -> String { // Very simple HTML stripper — remove tags, collapse whitespace let mut out = String::with_capacity(html.len()); let mut in_tag = false; for ch in html.chars() { match ch { '<' => in_tag = true, '>' => in_tag = false, c if !in_tag => out.push(c), _ => {} } } let collapsed: String = out.split_whitespace().collect::>().join(" "); let decoded = decode_html_entities(&collapsed); if decoded.chars().count() <= max_chars { decoded } else { decoded.chars().take(max_chars).collect::() + "…" } } fn decode_html_entities(s: &str) -> String { s.replace("&", "&") .replace("<", "<") .replace(">", ">") .replace(""", "\"") .replace("'", "'") .replace("'", "'") .replace(" ", " ") }