Skip to main content

things3_cloud/
client.rs

1use crate::store::{fold_item, RawState};
2use crate::wire::task::{TaskPatch, TaskStatus};
3use crate::wire::wire_object::WireItem;
4use crate::wire::wire_object::{EntityType, WireObject};
5use anyhow::{anyhow, Context, Result};
6use reqwest::blocking::Client;
7use serde_json::{json, Value};
8use std::collections::BTreeMap;
9use std::time::{SystemTime, UNIX_EPOCH};
10use urlencoding::encode;
11
12const BASE_URL: &str = "https://cloud.culturedcode.com/version/1";
13const USER_AGENT: &str = "ThingsMac/32209501";
14const CLIENT_INFO: &str = "eyJkbSI6Ik1hYzE0LDIiLCJsciI6IlVTIiwibmYiOnRydWUsIm5rIjp0cnVlLCJubiI6IlRoaW5nc01hYyIsIm52IjoiMzIyMDk1MDEiLCJvbiI6Im1hY09TIiwib3YiOiIyNi4zLjAiLCJwbCI6ImVuLVVTIiwidWwiOiJlbi1MYXRuLVVTIn0=";
15const APP_ID: &str = "com.culturedcode.ThingsMac";
16const SCHEMA: &str = "301";
17const WRITE_PUSH_PRIORITY: &str = "10";
18
19fn app_instance_id() -> String {
20    std::env::var("THINGS_APP_INSTANCE_ID").unwrap_or_else(|_| "things3-cloud".to_string())
21}
22
23fn now_ts() -> f64 {
24    SystemTime::now()
25        .duration_since(UNIX_EPOCH)
26        .map(|d| d.as_secs_f64())
27        .unwrap_or(0.0)
28}
29
30pub(crate) fn now_timestamp() -> f64 {
31    now_ts()
32}
33
34#[derive(Debug, Clone)]
35pub struct ThingsCloudClient {
36    pub email: String,
37    pub password: String,
38    pub history_key: Option<String>,
39    pub head_index: i64,
40    http: Client,
41}
42
43impl ThingsCloudClient {
44    pub fn new(email: String, password: String) -> Result<Self> {
45        let http = Client::builder().build()?;
46        Ok(Self {
47            email,
48            password,
49            history_key: None,
50            head_index: 0,
51            http,
52        })
53    }
54
55    fn request(
56        &self,
57        method: reqwest::Method,
58        url: &str,
59        body: Option<Value>,
60        extra_headers: &[(&str, String)],
61    ) -> Result<Value> {
62        let mut req = self
63            .http
64            .request(method, url)
65            .header("Accept", "application/json")
66            .header("Accept-Charset", "UTF-8")
67            .header("User-Agent", USER_AGENT)
68            .header("things-client-info", CLIENT_INFO)
69            .header("App-Id", APP_ID)
70            .header("Schema", SCHEMA)
71            .header("App-Instance-Id", app_instance_id());
72
73        for (k, v) in extra_headers {
74            req = req.header(*k, v);
75        }
76
77        if let Some(payload) = body {
78            req = req
79                .header("Content-Type", "application/json; charset=UTF-8")
80                .header("Content-Encoding", "UTF-8")
81                .json(&payload);
82        }
83
84        let resp = req
85            .send()
86            .with_context(|| format!("request failed: {url}"))?;
87        let status = resp.status();
88        let text = resp.text().unwrap_or_default();
89        if !status.is_success() {
90            return Err(anyhow!("HTTP {} for {}: {}", status.as_u16(), url, text));
91        }
92        if text.trim().is_empty() {
93            return Ok(json!({}));
94        }
95        serde_json::from_str(&text).with_context(|| format!("invalid json from {url}"))
96    }
97
98    pub fn authenticate(&mut self) -> Result<String> {
99        let url = format!("{BASE_URL}/account/{}", encode(&self.email));
100        let result = self.request(
101            reqwest::Method::GET,
102            &url,
103            None,
104            &[(
105                "Authorization",
106                format!("Password {}", encode(&self.password)),
107            )],
108        )?;
109        let key = result
110            .get("history-key")
111            .and_then(Value::as_str)
112            .ok_or_else(|| anyhow!("missing history-key in auth response"))?
113            .to_string();
114        self.history_key = Some(key.clone());
115        Ok(key)
116    }
117
118    pub fn get_items_page(&self, start_index: i64) -> Result<Value> {
119        let history_key = self
120            .history_key
121            .as_ref()
122            .ok_or_else(|| anyhow!("Must authenticate first"))?;
123        let url = format!("{BASE_URL}/history/{history_key}/items?start-index={start_index}");
124        self.request(reqwest::Method::GET, &url, None, &[])
125    }
126
127    pub fn get_all_items(&mut self) -> Result<RawState> {
128        if self.history_key.is_none() {
129            let _ = self.authenticate()?;
130        }
131
132        let mut state = RawState::new();
133        let mut start_index = 0i64;
134
135        loop {
136            let page = self.get_items_page(start_index)?;
137            let items = page
138                .get("items")
139                .and_then(Value::as_array)
140                .cloned()
141                .unwrap_or_default();
142            let item_count = items.len();
143            self.head_index = page
144                .get("current-item-index")
145                .and_then(Value::as_i64)
146                .unwrap_or(self.head_index);
147
148            for item in items {
149                let wire: WireItem = serde_json::from_value(item)?;
150                fold_item(wire, &mut state);
151            }
152
153            let end = page
154                .get("end-total-content-size")
155                .and_then(Value::as_i64)
156                .unwrap_or(0);
157            let latest = page
158                .get("latest-total-content-size")
159                .and_then(Value::as_i64)
160                .unwrap_or(0);
161            if end >= latest {
162                break;
163            }
164            start_index += item_count as i64;
165        }
166
167        Ok(state)
168    }
169
170    pub fn commit(
171        &mut self,
172        changes: BTreeMap<String, WireObject>,
173        ancestor_index: Option<i64>,
174    ) -> Result<i64> {
175        let history_key = self
176            .history_key
177            .as_ref()
178            .ok_or_else(|| anyhow!("Must authenticate first"))?;
179        let idx = ancestor_index.unwrap_or(self.head_index);
180        let url = format!("{BASE_URL}/history/{history_key}/commit?ancestor-index={idx}&_cnt=1");
181
182        let mut payload = BTreeMap::new();
183        for (uuid, obj) in changes {
184            payload.insert(uuid, obj);
185        }
186
187        let result = self.request(
188            reqwest::Method::POST,
189            &url,
190            Some(serde_json::to_value(payload)?),
191            &[("Push-Priority", WRITE_PUSH_PRIORITY.to_string())],
192        )?;
193
194        let new_index = result
195            .get("server-head-index")
196            .and_then(Value::as_i64)
197            .unwrap_or(idx);
198        self.head_index = new_index;
199        Ok(new_index)
200    }
201
202    pub fn set_task_status(
203        &mut self,
204        task_uuid: &str,
205        status: i32,
206        entity: Option<String>,
207        stop_date: Option<f64>,
208    ) -> Result<i64> {
209        let entity_type = entity
210            .map(|e| EntityType::from(e))
211            .unwrap_or(EntityType::Task6);
212        let mut changes = BTreeMap::new();
213        changes.insert(
214            task_uuid.to_string(),
215            WireObject::update(
216                entity_type,
217                TaskPatch {
218                    status: Some(TaskStatus::from(status)),
219                    stop_date: Some(stop_date),
220                    modification_date: Some(now_ts()),
221                    ..Default::default()
222                },
223            ),
224        );
225        self.commit(changes, None)
226    }
227}