The check `.contains("user/-/state/com.google/read")` was a substring
match, which also matched "user/-/state/com.google/reading-list" — a
category present on every article fetched from the reading list. This
caused all articles to be treated as read, so nothing ever appeared
bold in the sidebar.
Fix by using == for exact string comparison.
268 lines
8 KiB
Rust
268 lines
8 KiB
Rust
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<Item>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct Item {
|
|
id: String,
|
|
title: Option<String>,
|
|
origin: Option<Origin>,
|
|
canonical: Option<Vec<Link>>,
|
|
published: Option<i64>,
|
|
summary: Option<Summary>,
|
|
author: Option<String>,
|
|
categories: Option<Vec<String>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct Origin {
|
|
title: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct Link {
|
|
href: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct Summary {
|
|
content: Option<String>,
|
|
}
|
|
|
|
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<String> {
|
|
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<Self, String> {
|
|
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<String, String> {
|
|
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<Vec<Article>, 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("<!") || trimmed.to_ascii_lowercase().starts_with("<html")
|
|
}
|
|
|
|
fn human_error(body: &str, status: u16) -> 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::<Vec<_>>().join(" ");
|
|
let decoded = decode_html_entities(&collapsed);
|
|
if decoded.chars().count() <= max_chars {
|
|
decoded
|
|
} else {
|
|
decoded.chars().take(max_chars).collect::<String>() + "…"
|
|
}
|
|
}
|
|
|
|
fn decode_html_entities(s: &str) -> String {
|
|
s.replace("&", "&")
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
.replace(""", "\"")
|
|
.replace("'", "'")
|
|
.replace("'", "'")
|
|
.replace(" ", " ")
|
|
}
|