Skip to main content

things3_cloud/
client.rs

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