app: implement Epics 2–10
Add the full application logic on top of the Epic 1 skeleton:
Epic 2 — Authentication
- LoginDialog (AdwDialog, Blueprint template) with server URL,
username, and password fields; emits logged-in signal on submit
- credentials.rs: store/load/clear via libsecret (password_store_sync /
password_search_sync / password_clear_sync, v0_19 feature)
- api.rs: Api::login() parses Auth= token from ClientLogin response;
fetch_write_token() fetches the write token
- Auto-login on startup from stored credentials; logout with
AdwAlertDialog confirmation; login errors shown in AdwAlertDialog
Epic 3 — Article fetching
- model.rs: Article struct and ArticleObject GObject wrapper with
unread property for list store binding
- Api::fetch_unread() deserializes Google Reader JSON, derives unread
from categories, generates plain-text excerpt
- Sidebar uses a GtkStack with placeholder / loading / empty / error /
list pages; AdwSpinnerPaintable while fetching; Try Again button
Epic 4 — Sidebar
- article_row.blp: composite template with feed title, date, title,
and excerpt labels
- ArticleRow GObject subclass; binds ArticleObject, watches unread
notify to apply .dim-label on the title; relative timestamp format
Epic 5 — Content pane
- content.html updated: setArticle(), checkKey(), feedthemonkey: URI
navigation scheme; dark mode via prefers-color-scheme
- content.css: proper article layout, dark mode, code blocks
- WebView loaded from GResource; decide-policy intercepts
feedthemonkey:{next,previous,open} and all external links
Epic 6 — Read state
- Api::mark_read() / mark_unread() via edit-tag endpoint
- Optimistic unread toggle on ArticleObject; background API calls;
mark_unread_guard prevents re-marking on navigation
- AdwToast shown on mark-unread
Epic 7 — Keyboard shortcuts
- GtkShortcutController on window for all shortcuts from the backlog
- shortcuts.blp: AdwShortcutsWindow documenting all shortcuts
- F1 opens shortcuts dialog; Ctrl+W closes window; Ctrl+Q quits
Epic 8 — Zoom
- zoom_in/zoom_out/zoom_reset wired to Ctrl+±/0; zoom level saved to
and restored from GSettings zoom-level key
Epic 9 — Window state persistence
- Window width/height/maximized saved on close, restored on open
- (Sidebar width deferred — AdwNavigationSplitView fraction binding)
Epic 10 — Polish
- AdwAboutDialog with app name, version, GPL-3.0, website
- Logout confirmation AdwAlertDialog with destructive button
- Win.toggle-fullscreen action (F11)
- Api dropped on window close to cancel in-flight requests
This commit is contained in:
parent
8db0b16954
commit
813dda3579
22 changed files with 1838 additions and 42 deletions
204
src/api.rs
Normal file
204
src/api.rs
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
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;
|
||||
|
||||
impl Api {
|
||||
pub async fn login(
|
||||
server_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<Self, String> {
|
||||
let client = Client::new();
|
||||
let url = format!("{}/accounts/ClientLogin", server_url.trim_end_matches('/'));
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.form(&[("Email", username), ("Passwd", password)])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.map_err(|e| e.to_string())?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!("Login failed ({}): {}", status, body.trim()));
|
||||
}
|
||||
|
||||
let auth_token = body
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("Auth="))
|
||||
.ok_or_else(|| "No Auth token in response".to_string())?
|
||||
.to_string();
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
server_url: server_url.trim_end_matches('/').to_string(),
|
||||
auth_token,
|
||||
})
|
||||
}
|
||||
|
||||
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.contains("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 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(" ");
|
||||
if collapsed.chars().count() <= max_chars {
|
||||
collapsed
|
||||
} else {
|
||||
collapsed.chars().take(max_chars).collect::<String>() + "…"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue