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}