//! HTTP client for the `fil serve` daemon, shared by every surface that //! talks to it (the Elm TUI, the LSP), plus the types that cross the wire. use std::sync::Arc; use color_eyre::eyre::{Result, eyre}; use dto::{DateTime, NanoId}; use eventsource_stream::Eventsource as _; use fil_core::{ChangeSet, Group, Priority, Tag, Task, TodoTree, Zettel, ZettelId}; use futures::{Stream, StreamExt as _}; use serde::Deserialize; use serde_json::json; /// The daemon's full state at revision `rev`, from `GET /api/snapshot`. /// Change sets after `rev` continue from it. #[derive(Debug, Deserialize)] pub struct Snapshot { pub rev: u64, pub tree: TodoTree, pub zettels: Vec, } /// An inspector edit to send to the daemon. `is_group` picks between the /// task and group routes for fields both have. #[derive(Debug)] pub enum TodoUpdate { Name { id: NanoId, is_group: bool, name: String, }, Priority { id: NanoId, is_group: bool, priority: Priority, }, /// `None` clears the due date. Tasks only. Due { id: NanoId, due: Option, }, RandomColor { group_id: NanoId, }, } /// One event from `GET /api/events`. #[derive(Debug)] pub enum StreamEvent { /// The daemon published changes. Changes(ChangeSet), /// The daemon dropped changes this subscriber was too slow to receive; /// fetch a fresh snapshot. Resync, } /// HTTP client for the `fil serve` daemon at `url`, with one method per /// endpoint. Cheap to clone (both fields are `Arc`-backed), so each request /// can run on its own task. #[derive(Debug, Clone)] pub struct Client { http: reqwest::Client, url: Arc, } impl Client { pub fn new(url: &str) -> Self { Self { http: reqwest::Client::new(), url: url.trim_end_matches('/').into(), } } pub async fn fetch_snapshot(&self) -> Result { self.get_json("/api/snapshot").await } pub async fn fetch_zettels(&self) -> Result> { self.get_json("/api/zettels").await } /// The zettel with `id`, including where its file lives. pub async fn fetch_zettel(&self, id: &ZettelId) -> Result { self.get_json(&format!("/api/zettels/{id}")).await } pub async fn fetch_tags(&self) -> Result> { self.get_json("/api/tags").await } /// Opens `GET /api/events`. Resolves once the stream is open, then yields /// an item per event until the daemon closes it. Events that don't parse /// are skipped: the revision gap they leave triggers a resync. pub async fn events(&self) -> Result>> { let response = self .http .get(format!("{}/api/events", self.url)) .send() .await? .error_for_status()?; Ok(response .bytes_stream() .eventsource() .filter_map(|event| async move { let event = match event { Ok(event) => event, Err(error) => return Some(Err(eyre!("event stream: {error}"))), }; match event.event.as_str() { "changes" => serde_json::from_str(&event.data) .ok() .map(|set| Ok(StreamEvent::Changes(set))), "resync" => Some(Ok(StreamEvent::Resync)), _ => None, } })) } pub async fn fetch_file(&self, id: &ZettelId) -> Result { let contents = self .http .get(format!("{}/api/zettels/{id}/file", self.url)) .send() .await? .error_for_status()? .text() .await?; Ok(contents) } pub async fn save_file(&self, id: &ZettelId, contents: String) -> Result { let zettel = self .http .put(format!("{}/api/zettels/{id}/file", self.url)) .body(contents) .send() .await? .error_for_status()? .json::() .await?; Ok(zettel) } pub async fn create_zettel(&self, title: String) -> Result { let zettel = self .http .post(format!("{}/api/zettels", self.url)) .json(&json!({ "title": title, "tags": [] })) .send() .await? .error_for_status()? .json::() .await?; Ok(zettel) } pub async fn create_task(&self, name: String, group_id: NanoId) -> Result { let task = self .http .post(format!("{}/api/todo/task", self.url)) .json(&json!({ "name": name, "parent_id": group_id, "due": null, "priority": Priority::default(), })) .send() .await? .error_for_status()? .json::() .await?; Ok(task) } pub async fn create_group(&self, name: String, parent_id: Option) -> Result { let group = self .http .post(format!("{}/api/todo/group", self.url)) .json(&json!({ "name": name, "parent_id": parent_id })) .send() .await? .error_for_status()? .json::() .await?; Ok(group) } pub async fn update_todo(&self, update: &TodoUpdate) -> Result<()> { let kind = |is_group| if is_group { "group" } else { "task" }; let request = match update { TodoUpdate::Name { id, is_group, name } => self .http .put(format!( "{}/api/todo/{}/{id}/name", self.url, kind(*is_group) )) .json(&json!({ "name": name })), TodoUpdate::Priority { id, is_group, priority, } => self .http .put(format!( "{}/api/todo/{}/{id}/priority", self.url, kind(*is_group) )) .json(&json!({ "priority": priority })), TodoUpdate::Due { id, due } => self .http .put(format!("{}/api/todo/task/{id}/due", self.url)) .json(&json!({ "due": due })), TodoUpdate::RandomColor { group_id } => self.http.post(format!( "{}/api/todo/group/{group_id}/randomize-color", self.url )), }; request.send().await?.error_for_status()?; Ok(()) } pub async fn toggle_task(&self, id: &NanoId) -> Result<()> { self.http .post(format!("{}/api/todo/task/{id}/toggle", self.url)) .send() .await? .error_for_status()?; Ok(()) } } impl Client { async fn get_json(&self, path: &str) -> Result { Ok(self .http .get(format!("{}{path}", self.url)) .send() .await? .error_for_status()? .json::() .await?) } }