use std::path::{Path, PathBuf}; use reqwest::Client; use yup_oauth2::{ authenticator::Authenticator, hyper::client::connect::HttpConnector, hyper_rustls::HttpsConnector, InstalledFlowAuthenticator, InstalledFlowReturnMethod, }; use crate::domain::models::*; #[derive(Debug)] pub enum ApiError { Network(String), Auth(String), Api(String), } impl std::fmt::Display for ApiError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ApiError::Network(s) => write!(f, "Network error: {}", s), ApiError::Auth(s) => write!(f, "Auth error: {}", s), ApiError::Api(s) => write!(f, "API error: {}", s), } } } impl std::error::Error for ApiError {} pub struct ApiClient { client: Client, authenticator: Authenticator>, token_path: PathBuf, } const SCOPES: &[&str] = &[ "https://www.googleapis.com/auth/tasks", "https://www.googleapis.com/auth/calendar.readonly", ]; impl ApiClient { pub async fn new(secret_path: impl AsRef) -> Result { let token_path = dirs::config_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("task_app") .join("token.json"); if let Some(parent) = token_path.parent() { std::fs::create_dir_all(parent).ok(); } let secret = yup_oauth2::read_application_secret(secret_path) .await .map_err(|e| ApiError::Auth(format!("Failed to read secret file: {}", e)))?; let authenticator = InstalledFlowAuthenticator::builder( secret, InstalledFlowReturnMethod::HTTPRedirect, ) .persist_tokens_to_disk(token_path.clone()) .build() .await .map_err(|e| ApiError::Auth(format!("Failed to build authenticator: {}", e)))?; Ok(Self { client: Client::new(), authenticator, token_path, }) } pub fn has_token(&self) -> bool { self.token_path.exists() } pub fn token_has_all_scopes(&self) -> bool { let content = match std::fs::read_to_string(&self.token_path) { Ok(c) => c, Err(_) => return false, }; let entries: Vec = match serde_json::from_str(&content) { Ok(v) => v, Err(_) => return false, }; for entry in &entries { let scopes = match entry["scopes"].as_array() { Some(s) => s, None => continue, }; let has_all: Vec<&str> = scopes .iter() .filter_map(|s| s.as_str()) .collect(); if SCOPES.iter().all(|s| has_all.contains(s)) { return true; } } false } pub async fn start_and_wait_for_auth(&self) -> Result<(), ApiError> { self.authenticator.token(SCOPES).await.map_err(|e| { ApiError::Auth(format!("Authorization failed: {}", e)) })?; Ok(()) } async fn get_token(&self) -> Result { let token = self .authenticator .token(SCOPES) .await .map_err(|e| ApiError::Auth(format!("Token error: {}", e)))?; Ok(token.token().unwrap_or("").to_string()) } pub async fn fetch_lists(&self) -> Result, ApiError> { let token = self.get_token().await?; let resp = self .client .get("https://tasks.googleapis.com/tasks/v1/users/@me/lists") .bearer_auth(&token) .send() .await .map_err(|e| ApiError::Network(e.to_string()))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(ApiError::Api(format!("Fetch lists failed: {} - {}", status, body))); } let data: serde_json::Value = resp .json() .await .map_err(|e| ApiError::Api(e.to_string()))?; let empty = vec![]; let items = data["items"].as_array().unwrap_or(&empty); let lists = items .iter() .map(|item| TaskList { id: item["id"].as_str().unwrap_or("").to_string(), title: item["title"].as_str().unwrap_or("").to_string(), }) .collect(); Ok(lists) } pub async fn fetch_tasks(&self, list_id: &str) -> Result, ApiError> { let token = self.get_token().await?; let mut all_items: Vec = Vec::new(); let mut page_token: Option = None; loop { let mut url = format!( "https://tasks.googleapis.com/tasks/v1/lists/{}/tasks?showCompleted=true&showHidden=true&maxResults=100", list_id ); if let Some(ref pt) = page_token { url.push_str(&format!("&pageToken={}", pt)); } let resp = self .client .get(&url) .bearer_auth(&token) .send() .await .map_err(|e| ApiError::Network(e.to_string()))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(ApiError::Api(format!("Fetch tasks failed: {} - {}", status, body))); } let data: serde_json::Value = resp .json() .await .map_err(|e| ApiError::Api(e.to_string()))?; if let Some(items) = data["items"].as_array() { all_items.extend(items.iter().cloned()); } match data["nextPageToken"].as_str() { Some(token) if !token.is_empty() => page_token = Some(token.to_string()), _ => break, } } let tasks = all_items .iter() .enumerate() .map(|(i, item)| parse_task_value(item, list_id, i)) .collect(); Ok(tasks) } pub async fn fetch_tasks_since(&self, list_id: &str, since: &chrono::NaiveDateTime) -> Result, ApiError> { let token = self.get_token().await?; let since_str = since.format("%Y-%m-%dT%H:%M:%S.000Z").to_string(); let mut all_items: Vec = Vec::new(); let mut page_token: Option = None; loop { let mut url = format!( "https://tasks.googleapis.com/tasks/v1/lists/{}/tasks?showCompleted=true&showHidden=true&maxResults=100&updatedMin={}", list_id, since_str ); if let Some(ref pt) = page_token { url.push_str(&format!("&pageToken={}", pt)); } let resp = self .client .get(&url) .bearer_auth(&token) .send() .await .map_err(|e| ApiError::Network(e.to_string()))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(ApiError::Api(format!("Fetch tasks since failed: {} - {}", status, body))); } let data: serde_json::Value = resp .json() .await .map_err(|e| ApiError::Api(e.to_string()))?; if let Some(items) = data["items"].as_array() { all_items.extend(items.iter().cloned()); } match data["nextPageToken"].as_str() { Some(token) if !token.is_empty() => page_token = Some(token.to_string()), _ => break, } } let tasks = all_items .iter() .enumerate() .map(|(i, item)| parse_task_value(item, list_id, i)) .collect(); Ok(tasks) } pub async fn create_task(&self, list_id: &str, task: &Task) -> Result { let token = self.get_token().await?; let mut body = serde_json::json!({ "title": task.title, }); if let Some(ref notes) = task.notes { body["notes"] = serde_json::Value::String(notes.clone()); } if let Some(due) = task.due { body["due"] = serde_json::Value::String(due.format("%Y-%m-%dT%H:%M:00.000Z").to_string()); } if task.status == TaskStatus::Completed { body["status"] = serde_json::Value::String("completed".to_string()); } let url = format!( "https://tasks.googleapis.com/tasks/v1/lists/{}/tasks", list_id ); let resp = self .client .post(&url) .bearer_auth(&token) .json(&body) .send() .await .map_err(|e| ApiError::Network(e.to_string()))?; if !resp.status().is_success() { let status = resp.status(); let body_text = resp.text().await.unwrap_or_default(); return Err(ApiError::Api(format!("Create failed: {} - {}", status, body_text))); } let data: serde_json::Value = resp .json() .await .map_err(|e| ApiError::Api(format!("Create parse error: {}", e)))?; Ok(parse_task_value(&data, list_id, 0)) } pub async fn create_list(&self, title: &str) -> Result { let token = self.get_token().await?; let body = serde_json::json!({ "title": title }); let resp = self .client .post("https://tasks.googleapis.com/tasks/v1/users/@me/lists") .bearer_auth(&token) .json(&body) .send() .await .map_err(|e| ApiError::Network(e.to_string()))?; if !resp.status().is_success() { let status = resp.status(); let body_text = resp.text().await.unwrap_or_default(); return Err(ApiError::Api(format!("Create list failed: {} - {}", status, body_text))); } let data: serde_json::Value = resp .json() .await .map_err(|e| ApiError::Api(format!("Create list parse error: {}", e)))?; Ok(TaskList { id: data["id"].as_str().unwrap_or("").to_string(), title: data["title"].as_str().unwrap_or("").to_string(), }) } pub async fn update_task(&self, list_id: &str, task: &Task) -> Result<(), ApiError> { let token = self.get_token().await?; let mut body = serde_json::json!({ "title": task.title, }); if let Some(ref notes) = task.notes { body["notes"] = serde_json::Value::String(notes.clone()); } if let Some(due) = task.due { body["due"] = serde_json::Value::String(due.format("%Y-%m-%dT%H:%M:00.000Z").to_string()); } body["status"] = serde_json::Value::String(match task.status { TaskStatus::Completed => "completed".to_string(), TaskStatus::NeedsAction => "needsAction".to_string(), }); let url = format!( "https://tasks.googleapis.com/tasks/v1/lists/{}/tasks/{}", list_id, task.id ); let resp = self .client .patch(&url) .bearer_auth(&token) .json(&body) .send() .await .map_err(|e| ApiError::Network(e.to_string()))?; if !resp.status().is_success() { return Err(ApiError::Api(format!( "Update failed: {}", resp.status() ))); } Ok(()) } pub async fn delete_task(&self, list_id: &str, task_id: &str) -> Result<(), ApiError> { let token = self.get_token().await?; let url = format!( "https://tasks.googleapis.com/tasks/v1/lists/{}/tasks/{}", list_id, task_id ); let resp = self .client .delete(&url) .bearer_auth(&token) .send() .await .map_err(|e| ApiError::Network(e.to_string()))?; if !resp.status().is_success() { // 404 means already deleted — treat as success if resp.status() == 404 { return Ok(()); } return Err(ApiError::Api(format!( "Delete failed: {}", resp.status() ))); } Ok(()) } pub async fn delete_list(&self, list_id: &str) -> Result<(), ApiError> { let token = self.get_token().await?; let url = format!( "https://tasks.googleapis.com/tasks/v1/users/@me/lists/{}", list_id ); let resp = self .client .delete(&url) .bearer_auth(&token) .send() .await .map_err(|e| ApiError::Network(e.to_string()))?; if !resp.status().is_success() { let status = resp.status(); // 404 means already deleted — treat as success if status == 404 { return Ok(()); } let body = resp.text().await.unwrap_or_default(); return Err(ApiError::Api(format!("Delete list failed: {} - {}", status, body))); } Ok(()) } pub async fn move_task( &self, list_id: &str, task_id: &str, prev: Option<&str>, sibling: Option<&str>, ) -> Result<(), ApiError> { let token = self.get_token().await?; let url = format!( "https://tasks.googleapis.com/tasks/v1/lists/{}/tasks/{}/move", list_id, task_id ); let mut req = self.client.post(&url).bearer_auth(&token); if let Some(p) = prev { req = req.query(&[("previous", p)]); } if let Some(s) = sibling { req = req.query(&[("destinationTaskList", s)]); } let resp = req .header(reqwest::header::CONTENT_LENGTH, "0") .send() .await .map_err(|e| ApiError::Network(e.to_string()))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(ApiError::Api(format!("Move failed: {} - {}", status, body))); } Ok(()) } pub async fn fetch_upcoming_events(&self, max_results: u32) -> Result, ApiError> { let token = self.get_token().await?; let now = chrono::Utc::now(); let end_time = now + chrono::Duration::days(30); let resp = self .client .get("https://www.googleapis.com/calendar/v3/calendars/primary/events") .bearer_auth(&token) .query(&[ ("orderBy", "startTime"), ("singleEvents", "true"), ("timeMin", &now.format("%Y-%m-%dT%H:%M:%SZ").to_string()), ("timeMax", &end_time.format("%Y-%m-%dT%H:%M:%SZ").to_string()), ("maxResults", &max_results.to_string()), ]) .send() .await .map_err(|e| ApiError::Network(e.to_string()))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(ApiError::Api(format!("Calendar fetch failed: {} - {}", status, body))); } let body: serde_json::Value = resp.json().await .map_err(|e| ApiError::Api(format!("Calendar JSON parse error: {}", e)))?; let items = body["items"].as_array() .map(|arr| { arr.iter().filter_map(|item| { let summary = item["summary"].as_str()?.to_string(); let start = parse_calendar_time(&item["start"]); let end = parse_calendar_time(&item["end"]); let location = item["location"].as_str().map(|s| s.to_string()); Some(CalendarEvent { summary, start, end, location }) }).collect::>() }) .unwrap_or_default(); Ok(items) } } fn parse_task_value(item: &serde_json::Value, list_id: &str, position: usize) -> Task { let due_str = item["due"].as_str().and_then(|s| { chrono::NaiveDateTime::parse_from_str( &s.replace("T", " ") .replace("Z", "") .chars() .take(16) .collect::(), "%Y-%m-%d %H:%M", ) .ok() }); let updated = item["updated"].as_str().and_then(|s| { chrono::NaiveDateTime::parse_from_str( &s.replace("T", " ") .replace("Z", "") .chars() .take(19) .collect::(), "%Y-%m-%d %H:%M:%S", ) .ok() }); Task { id: item["id"].as_str().unwrap_or("").to_string(), list_id: list_id.to_string(), title: item["title"].as_str().unwrap_or("").to_string(), notes: item["notes"].as_str().map(|s| s.to_string()), status: if item["status"].as_str() == Some("completed") { TaskStatus::Completed } else { TaskStatus::NeedsAction }, due: due_str, position: position as i64, created_at: None, updated_at: updated, } } fn parse_calendar_time(obj: &serde_json::Value) -> Option { if let Some(dt) = obj["dateTime"].as_str() { chrono::DateTime::parse_from_rfc3339(dt).ok().map(|d| d.naive_local()) } else if let Some(d) = obj["date"].as_str() { chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").ok() .map(|date| date.and_hms_opt(0, 0, 0).unwrap()) } else { None } }