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}