1use crate::SyncError;
19use crate::dav::Report;
20use jiff::Timestamp;
21use jiff::tz::TimeZone;
22use serde_json::{Value, json};
23use sioul_core::config::Account;
24use sioul_core::lines;
25use sioul_core::tasks::{self, Status};
26use sioul_core::vdir::{self, ItemState, Kind, State};
27use std::collections::{BTreeMap, BTreeSet};
28use std::path::Path;
29use std::sync::Mutex;
30use std::time::Duration;
31
32const API: &str = "https://tasks.googleapis.com/tasks/v1";
33
34fn api() -> String {
35 #[cfg(feature = "insecure-test-tls")]
37 if let Some(base) = std::env::var_os("SIOUL_TEST_GOOGLE_TASKS") {
38 return base.to_string_lossy().trim_end_matches('/').to_string();
39 }
40 API.to_string()
41}
42
43fn list_url(id: &str) -> String {
45 format!("{}/lists/{id}", api())
46}
47
48pub fn is_tasks_list(url: &str) -> bool {
50 url.starts_with(&format!("{}/lists/", api()))
51}
52
53fn list_id(url: &str) -> &str {
54 url.rsplit('/').next().unwrap_or("")
55}
56
57struct Api<'a> {
59 address: &'a str,
60 agent: ureq::Agent,
61 token: Mutex<String>,
62 base: String,
64}
65
66impl<'a> Api<'a> {
67 fn new(address: &'a str) -> Result<Api<'a>, SyncError> {
68 let agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(60))).http_status_as_error(false).build().into();
69 Ok(Api { address, agent, token: Mutex::new(crate::google::access_token(address, false)?), base: api() })
70 }
71
72 fn call(&self, method: &str, path: &str, body: Option<&Value>) -> Result<(u16, Value), SyncError> {
74 let url = format!("{}{path}", self.base);
75 for attempt in 0..2 {
76 let token = self.token.lock().map(|t| t.clone()).unwrap_or_default();
77 let request = ureq::http::Request::builder()
78 .method(method)
79 .uri(&url)
80 .header("Authorization", format!("Bearer {token}"))
81 .header("User-Agent", "Sioul")
82 .header("Content-Type", "application/json")
83 .body(body.map(Value::to_string).unwrap_or_default())
84 .map_err(|e| SyncError::Server(e.to_string()))?;
85 let mut response = self.agent.run(request).map_err(|e| SyncError::Network(e.to_string()))?;
86 let status = response.status().as_u16();
87 if status == 401 && attempt == 0 {
88 let fresh = crate::google::access_token(self.address, true)?;
89 if let Ok(mut token) = self.token.lock() {
90 *token = fresh;
91 }
92 continue;
93 }
94 if status == 401 {
95 return Err(SyncError::Login(format!("{method} {url}: 401")));
96 }
97 let text = response.body_mut().with_config().limit(16 * 1024 * 1024).read_to_string().unwrap_or_default();
98 if status == 403 {
100 let reason: Value = serde_json::from_str(&text).unwrap_or_default();
101 return Err(SyncError::Server(format!("Google Tasks: {}", reason["error"]["message"].as_str().unwrap_or("403"))));
102 }
103 return Ok((status, serde_json::from_str(&text).unwrap_or(Value::Null)));
104 }
105 Err(SyncError::Login(format!("{method} {url}: 401")))
106 }
107
108 fn ok(&self, method: &str, path: &str, body: Option<&Value>) -> Result<Value, SyncError> {
110 match self.call(method, path, body)? {
111 (200..=299, value) => Ok(value),
112 (status, _) => Err(SyncError::Server(format!("{method} {path}: {status}"))),
113 }
114 }
115
116 fn all(&self, path: &str) -> Result<Vec<Value>, SyncError> {
118 let mut items = Vec::new();
119 let mut page: Option<String> = None;
120 loop {
121 let at = match &page {
122 Some(token) => format!("{path}&pageToken={}", crate::google::encode(token)),
123 None => path.to_string(),
124 };
125 let answer = self.ok("GET", &at, None)?;
126 items.extend(answer["items"].as_array().cloned().unwrap_or_default());
127 match answer["nextPageToken"].as_str() {
128 Some(token) if !token.is_empty() => page = Some(token.to_string()),
129 _ => return Ok(items),
130 }
131 }
132 }
133}
134
135fn tasks_granted(address: &str) -> bool {
137 #[cfg(feature = "insecure-test-tls")]
138 if std::env::var_os("SIOUL_TEST_GOOGLE_TASKS").is_some() {
139 return true;
140 }
141 crate::google::grant(address).is_some_and(|g| g.scopes.iter().any(|s| s.ends_with("/auth/tasks")))
142}
143
144pub fn sync(account: &Account) -> Result<Report, SyncError> {
146 let mut report = Report::default();
147 let address = account.address.as_deref().or(account.login()).ok_or(SyncError::NoServer)?;
148 if !tasks_granted(address) {
149 return Ok(report);
150 }
151 let api = Api::new(address)?;
152 let zone = TimeZone::system();
153 send_list_changes(&api, &account.id)?;
154 let known: BTreeMap<String, String> = vdir::every_collection(Kind::Calendars)
156 .into_iter()
157 .filter(|c| c.account == account.id)
158 .map(|c| (State::load(&c.state_path()).url, c.id))
159 .filter(|(url, _)| is_tasks_list(url))
160 .collect();
161 let mut kept = BTreeSet::new();
162 for list in api.all("/users/@me/lists?maxResults=100")? {
163 let Some(id) = list["id"].as_str() else { continue };
164 let url = list_url(id);
165 let local = known.get(&url).cloned().unwrap_or_else(|| format!("tasks-{}", vdir::folder_id(id)));
166 sync_list(&api, &account.id, id, &local, list["title"].as_str().unwrap_or("Tasks"), &zone, &mut report)?;
167 kept.insert(url);
168 report.collections += 1;
169 }
170 forget_gone(&account.id, &kept);
171 Ok(report)
172}
173
174fn send_list_changes(api: &Api, account: &str) -> Result<(), SyncError> {
177 for collection in vdir::every_collection(Kind::Calendars).into_iter().filter(|c| c.account == account) {
178 let path = collection.state_path();
179 let mut state = State::load(&path);
180 if state.pending && collection.components.iter().any(|c| c.eq_ignore_ascii_case("VTODO")) {
181 let made = api.ok("POST", "/users/@me/lists", Some(&json!({ "title": collection.name })))?;
182 let id = made["id"].as_str().ok_or_else(|| SyncError::Server("Google Tasks: a list without id".into()))?;
183 state.pending = false;
184 state.url = list_url(id);
185 state.save(&path).map_err(SyncError::Disk)?;
186 continue;
187 }
188 if !is_tasks_list(&state.url) {
189 continue;
190 }
191 let id = list_id(&state.url).to_string();
192 if state.renamed {
193 api.ok("PATCH", &format!("/users/@me/lists/{id}"), Some(&json!({ "title": collection.name })))?;
194 state.renamed = false;
195 state.save(&path).map_err(SyncError::Disk)?;
196 }
197 if state.deleted {
198 let held = api.ok("GET", &format!("/lists/{id}/tasks?maxResults=1&showCompleted=true&showHidden=true"), None)?;
199 if held["items"].as_array().is_some_and(|items| !items.is_empty()) {
200 state.deleted = false;
201 state.save(&path).map_err(SyncError::Disk)?;
202 continue;
203 }
204 match api.call("DELETE", &format!("/users/@me/lists/{id}"), None)? {
205 (200..=299 | 404, _) => {
206 let _ = std::fs::remove_dir_all(&collection.dir);
207 let _ = std::fs::remove_file(&path);
208 }
209 (status, _) => return Err(SyncError::Server(format!("DELETE list {id}: {status}"))),
210 }
211 }
212 }
213 Ok(())
214}
215
216fn forget_gone(account: &str, kept: &BTreeSet<String>) {
218 for collection in vdir::every_collection(Kind::Calendars).into_iter().filter(|c| c.account == account) {
219 let state = State::load(&collection.state_path());
220 if !is_tasks_list(&state.url) || kept.contains(&state.url) || state.pending {
221 continue;
222 }
223 let known: BTreeSet<&str> = state.items.iter().map(|i| i.file.as_str()).collect();
224 let unsent = files(&collection.dir).iter().any(|f| !known.contains(f.as_str()))
225 || state.items.iter().any(|i| hash_of(&collection.dir.join(&i.file)).is_some_and(|h| h != i.hash));
226 if unsent {
227 continue;
228 }
229 let _ = std::fs::remove_dir_all(&collection.dir);
230 let _ = std::fs::remove_file(collection.state_path());
231 }
232}
233
234fn files(dir: &Path) -> BTreeSet<String> {
235 std::fs::read_dir(dir).into_iter().flatten().filter_map(Result::ok).filter_map(|e| e.file_name().into_string().ok()).filter(|n| n.ends_with(".ics") && !n.starts_with('.')).collect()
236}
237
238fn hash_of(path: &Path) -> Option<String> {
239 std::fs::read(path).ok().map(|bytes| vdir::content_hash(&bytes))
240}
241
242#[allow(clippy::too_many_arguments)]
243fn sync_list(api: &Api, account: &str, id: &str, local: &str, title: &str, zone: &TimeZone, report: &mut Report) -> Result<(), SyncError> {
244 let dir = vdir::prepare(Kind::Calendars, account, local, title, None).map_err(|e| SyncError::Disk(e.to_string()))?;
245 let state_path = vdir::state_path(account, Kind::Calendars, local);
246 let mut state = State::load(&state_path);
247 state.url = list_url(id);
248 state.components = vec!["VTODO".into()];
249 state.read_only = false;
250 let started = Timestamp::now() - jiff::SignedDuration::from_secs(120);
252 let synced = push(api, id, &dir, &mut state, &state_path, zone, report).and_then(|()| pull(api, id, &dir, &mut state, report));
255 if synced.is_ok() {
256 state.sync_token = Some(started.strftime("%Y-%m-%dT%H:%M:%S.000Z").to_string());
257 }
258 state.save(&state_path).map_err(SyncError::Disk)?;
259 synced
260}
261
262fn uid_in(path: &Path) -> Option<String> {
264 let text = std::fs::read_to_string(path).ok()?;
265 lines::unfold(&text).iter().find(|l| lines::name(l) == "UID").map(|l| lines::value(l).trim().to_string())
266}
267
268fn push(api: &Api, list: &str, dir: &Path, state: &mut State, state_path: &Path, zone: &TimeZone, report: &mut Report) -> Result<(), SyncError> {
272 let mut google_of: BTreeMap<String, String> = state.items.iter().filter_map(|i| Some((uid_in(&dir.join(&i.file))?, i.href.clone()))).collect();
274 let mut i = 0;
275 while i < state.items.len() {
276 let item = state.items[i].clone();
277 let path = dir.join(&item.file);
278 let Some(hash) = hash_of(&path) else {
279 match api.call("DELETE", &format!("/lists/{list}/tasks/{}", item.href), None)? {
280 (200..=299 | 404 | 410, _) => report.sent += 1,
281 (status, _) => return Err(SyncError::Server(format!("DELETE task {}: {status}", item.href))),
282 }
283 state.items.remove(i);
284 continue;
285 };
286 if hash == item.hash {
287 i += 1;
288 continue;
289 }
290 let text = std::fs::read_to_string(&path).map_err(|e| SyncError::Disk(e.to_string()))?;
291 let Some(task) = tasks::task_of_text(&text, zone) else {
292 i += 1;
293 continue;
294 };
295 let answer = match api.call("PATCH", &format!("/lists/{list}/tasks/{}", item.href), Some(&fields_of(&task)))? {
296 (200..=299, answer) => answer,
297 (404 | 410, _) => {
299 state.items.remove(i);
300 continue;
301 }
302 (status, _) => return Err(SyncError::Server(format!("PATCH task {}: {status}", item.href))),
303 };
304 let wanted = task.parent().and_then(|uid| google_of.get(uid)).cloned();
305 let answer = if answer["parent"].as_str().map(str::to_string) != wanted { moved(api, list, &item.href, wanted.as_deref())? } else { answer };
306 let text = merged(&text, &answer, task.parent());
307 vdir::write_item(&path, &text).map_err(SyncError::Disk)?;
308 report.sent += 1;
309 state.items[i] = ItemState { etag: answer["etag"].as_str().unwrap_or_default().to_string(), hash: vdir::content_hash(text.as_bytes()), ..item };
310 i += 1;
311 }
312 let known: BTreeSet<String> = state.items.iter().map(|i| i.file.clone()).collect();
314 let mut new: Vec<(String, String, tasks::Task)> = files(dir)
315 .into_iter()
316 .filter(|f| !known.contains(f))
317 .filter_map(|file| {
318 let text = std::fs::read_to_string(dir.join(&file)).ok()?;
319 let task = tasks::task_of_text(&text, zone)?;
320 Some((file, text, task))
321 })
322 .collect();
323 let new_uids: BTreeSet<String> = new.iter().map(|(_, _, t)| t.uid.clone()).collect();
324 new.sort_by_key(|(_, _, t)| t.parent().is_some_and(|p| new_uids.contains(p)));
325 for (file, text, task) in new {
326 let parent = task.parent().and_then(|uid| google_of.get(uid)).cloned();
327 let at = match &parent {
328 Some(parent) => format!("/lists/{list}/tasks?parent={}", crate::google::encode(parent)),
329 None => format!("/lists/{list}/tasks"),
330 };
331 let answer = api.ok("POST", &at, Some(&fields_of(&task)))?;
332 let Some(id) = answer["id"].as_str() else { continue };
333 google_of.insert(task.uid.clone(), id.to_string());
334 let text = merged(&text, &answer, task.parent());
335 state.items.push(ItemState { href: id.to_string(), file: file.clone(), etag: answer["etag"].as_str().unwrap_or_default().to_string(), hash: vdir::content_hash(text.as_bytes()), ..ItemState::default() });
336 state.save(state_path).map_err(SyncError::Disk)?;
338 vdir::write_item(&dir.join(&file), &text).map_err(SyncError::Disk)?;
339 report.sent += 1;
340 }
341 Ok(())
342}
343
344fn moved(api: &Api, list: &str, id: &str, parent: Option<&str>) -> Result<Value, SyncError> {
346 let at = match parent {
347 Some(parent) => format!("/lists/{list}/tasks/{id}/move?parent={}", crate::google::encode(parent)),
348 None => format!("/lists/{list}/tasks/{id}/move"),
349 };
350 api.ok("POST", &at, None)
351}
352
353fn pull(api: &Api, list: &str, dir: &Path, state: &mut State, report: &mut Report) -> Result<(), SyncError> {
356 let since = state.sync_token.clone();
357 let mut path = format!("/lists/{list}/tasks?maxResults=100&showCompleted=true&showHidden=true&showDeleted=true");
358 if let Some(since) = &since {
359 path.push_str(&format!("&updatedMin={}", crate::google::encode(since)));
360 }
361 let found = api.all(&path)?;
362 let mut seen = BTreeSet::new();
363 for task in &found {
364 let Some(id) = task["id"].as_str() else { continue };
365 seen.insert(id.to_string());
366 let position = state.items.iter().position(|i| i.href == id);
367 if task["deleted"].as_bool() == Some(true) {
368 if let Some(i) = position {
369 let _ = std::fs::remove_file(dir.join(&state.items[i].file));
370 state.items.remove(i);
371 report.removed += 1;
372 }
373 continue;
374 }
375 let etag = task["etag"].as_str().unwrap_or_default();
376 if position.is_some_and(|i| state.items[i].etag == etag && !etag.is_empty()) {
377 continue;
378 }
379 let parent = task["parent"].as_str().map(|p| state.items.iter().find(|i| i.href == p).and_then(|i| uid_in(&dir.join(&i.file))).unwrap_or_else(|| p.to_string()));
381 let (file, before) = match position {
382 Some(i) => (state.items[i].file.clone(), std::fs::read_to_string(dir.join(&state.items[i].file)).unwrap_or_else(|_| skeleton(id))),
383 None => {
384 let taken: BTreeSet<String> = state.items.iter().map(|i| i.file.clone()).chain(files(dir)).collect();
385 let stem = vdir::folder_id(id);
386 let file = std::iter::once(format!("{stem}.ics")).chain((2..).map(|n| format!("{stem}-{n}.ics"))).find(|f| !taken.contains(f)).unwrap_or_default();
387 (file, skeleton(id))
388 }
389 };
390 let text = merged(&before, task, parent.as_deref());
391 vdir::write_item(&dir.join(&file), &text).map_err(SyncError::Disk)?;
392 let item = ItemState { href: id.to_string(), file, etag: etag.to_string(), hash: vdir::content_hash(text.as_bytes()), ..ItemState::default() };
393 match position {
394 Some(i) => state.items[i] = item,
395 None => state.items.push(item),
396 }
397 report.received += 1;
398 }
399 if since.is_none() {
400 let mut gone = Vec::new();
401 state.items.retain(|i| {
402 let here = seen.contains(&i.href);
403 if !here {
404 gone.push(i.file.clone());
405 }
406 here
407 });
408 for file in gone {
409 let _ = std::fs::remove_file(dir.join(file));
410 report.removed += 1;
411 }
412 }
413 Ok(())
414}
415
416fn skeleton(uid: &str) -> String {
418 lines::fold(&["BEGIN:VCALENDAR".into(), "VERSION:2.0".into(), "PRODID:-//Sioul//Sioul//EN".into(), "BEGIN:VTODO".into(), format!("UID:{uid}"), "END:VTODO".into(), "END:VCALENDAR".into()])
419}
420
421pub fn fields_of(task: &tasks::Task) -> Value {
423 let done = matches!(task.status, Status::Completed | Status::Cancelled);
424 let completed = done.then(|| task.completed.and_then(|s| Timestamp::from_second(s).ok()).unwrap_or_else(Timestamp::now).strftime("%Y-%m-%dT%H:%M:%S.000Z").to_string());
425 let due = task.due_date().map(|d| format!("{d}T00:00:00.000Z"));
426 json!({
427 "title": task.title.chars().take(1024).collect::<String>(),
428 "notes": task.notes.chars().take(8192).collect::<String>(),
429 "status": if done { "completed" } else { "needsAction" },
430 "completed": completed,
431 "due": due,
432 })
433}
434
435fn stamp(rfc3339: &str) -> Option<String> {
437 rfc3339.parse::<Timestamp>().ok().map(|t| t.strftime("%Y%m%dT%H%M%SZ").to_string())
438}
439
440fn google_lines(task: &Value, parent: Option<&str>) -> Vec<String> {
442 let mut out = Vec::new();
443 if let Some(updated) = task["updated"].as_str().and_then(stamp) {
444 out.push(format!("DTSTAMP:{updated}"));
445 out.push(format!("LAST-MODIFIED:{updated}"));
446 }
447 out.push(format!("SUMMARY:{}", lines::escape(task["title"].as_str().unwrap_or(""))));
448 if let Some(notes) = task["notes"].as_str().filter(|n| !n.is_empty()) {
449 out.push(format!("DESCRIPTION:{}", lines::escape(notes)));
450 }
451 let done = task["status"].as_str() == Some("completed");
452 out.push(format!("STATUS:{}", if done { "COMPLETED" } else { "NEEDS-ACTION" }));
453 if done && let Some(at) = task["completed"].as_str().and_then(stamp) {
454 out.push(format!("COMPLETED:{at}"));
455 }
456 if let Some(day) = task["due"].as_str().and_then(|d| d.get(..10)) {
458 out.push(format!("DUE;VALUE=DATE:{}", day.replace('-', "")));
459 }
460 if let Some(parent) = parent {
461 out.push(tasks::relation_line("PARENT", parent, 0));
462 }
463 out
464}
465
466pub fn merged(text: &str, task: &Value, parent: Option<&str>) -> String {
469 const GOOGLES: &[&str] = &["SUMMARY", "DESCRIPTION", "STATUS", "COMPLETED", "DUE", "DTSTAMP", "LAST-MODIFIED", "PERCENT-COMPLETE"];
470 let source = lines::unfold(text);
471 let mut out = Vec::with_capacity(source.len() + 8);
472 let (mut in_todo, mut nested, mut placed) = (false, 0usize, false);
473 for line in source {
474 let name = lines::name(&line);
475 let value = lines::value(&line).trim().to_ascii_uppercase();
476 match (name.as_str(), value.as_str()) {
477 ("BEGIN", "VTODO") if !in_todo => in_todo = true,
478 ("BEGIN", _) if in_todo => {
479 if nested == 0 && !placed {
480 out.extend(google_lines(task, parent));
481 placed = true;
482 }
483 nested += 1;
484 }
485 ("END", "VTODO") if in_todo && nested == 0 => {
486 if !placed {
487 out.extend(google_lines(task, parent));
488 placed = true;
489 }
490 in_todo = false;
491 }
492 ("END", _) if in_todo && nested > 0 => nested -= 1,
493 _ if in_todo && nested == 0 => {
494 let parent_line = name == "RELATED-TO" && lines::param(&line, "RELTYPE").is_none_or(|t| t.eq_ignore_ascii_case("PARENT"));
495 if GOOGLES.contains(&name.as_str()) || parent_line {
496 continue;
497 }
498 }
499 _ => {}
500 }
501 out.push(line);
502 }
503 lines::fold(&out)
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use crate::dav::stand_in::{Fault, Reply, Request, Server, home, nth, serve};
510 use std::sync::Arc;
511 use std::sync::atomic::{AtomicUsize, Ordering};
512
513 #[test]
514 fn a_task_both_ways() {
515 let google = json!({ "id": "abc", "etag": "\"1\"", "title": "Call the notary, today", "notes": "Ask for the deed", "status": "completed",
516 "completed": "2026-10-03T09:30:00.000Z", "due": "2026-10-05T00:00:00.000Z", "updated": "2026-10-03T09:31:00.000Z", "parent": "top" });
517 let text = merged(&skeleton("abc"), &google, Some("top-uid"));
518 let task = tasks::task_of_text(&text, &TimeZone::UTC).unwrap();
519 assert_eq!((task.uid.as_str(), task.title.as_str(), task.notes.as_str()), ("abc", "Call the notary, today", "Ask for the deed"));
520 assert_eq!((task.status, task.due.as_str(), task.parent()), (Status::Completed, "2026-10-05", Some("top-uid")));
521 let back = fields_of(&task);
522 assert_eq!(back["status"], "completed");
523 assert_eq!(back["completed"], "2026-10-03T09:30:00.000Z");
524 assert_eq!(back["due"], "2026-10-05T00:00:00.000Z");
525 let mine = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:mine\r\nSUMMARY:Old\r\nCATEGORIES:joy\r\nRELATED-TO;RELTYPE=DEPENDS-ON:other\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nEND:VALARM\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
527 let open = json!({ "title": "New", "status": "needsAction" });
528 let text = merged(mine, &open, None);
529 assert!(text.contains("UID:mine") && text.contains("CATEGORIES:joy") && text.contains("RELATED-TO;RELTYPE=DEPENDS-ON:other"), "{text}");
530 assert!(text.contains("SUMMARY:New\r\nSTATUS:NEEDS-ACTION\r\nBEGIN:VALARM") && !text.contains("Old"), "{text}");
531 assert_eq!(fields_of(&tasks::task_of_text(&text, &TimeZone::UTC).unwrap())["due"], Value::Null);
532 }
533
534 #[derive(Default)]
537 struct Google {
538 held: Mutex<BTreeMap<String, Value>>,
539 made: AtomicUsize,
540 faults: Mutex<Vec<Box<dyn FnMut(&Request) -> Option<Fault> + Send>>>,
541 }
542
543 impl Google {
544 fn with(titles: &[(&str, &str)]) -> Arc<Google> {
545 let google = Arc::new(Google::default());
546 for (id, title) in titles {
547 let task = json!({ "id": id, "etag": format!("\"{id}-0\""), "title": title, "status": "needsAction", "updated": "2026-10-01T08:00:00.000Z" });
548 google.held.lock().unwrap().insert(id.to_string(), task);
549 }
550 google
551 }
552
553 fn titles(&self) -> Vec<String> {
554 let mut titles: Vec<String> = self.held.lock().unwrap().values().map(|t| t["title"].as_str().unwrap_or("").to_string()).collect();
555 titles.sort();
556 titles
557 }
558 }
559
560 impl Server for Google {
561 fn misbehaves(&self, request: &Request) -> Option<Fault> {
562 self.faults.lock().unwrap().iter_mut().find_map(|f| f(request))
563 }
564
565 fn answer(&self, request: &Request) -> Reply {
566 let mut held = self.held.lock().unwrap();
567 let (path, query) = request.path.split_once('?').unwrap_or((request.path.as_str(), ""));
568 let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
569 let made = || self.made.fetch_add(1, Ordering::Relaxed) + 1;
570 match (request.method.as_str(), parts.as_slice()) {
571 ("GET", ["lists", _, "tasks"]) => Reply::new(200, json!({ "items": held.values().collect::<Vec<_>>() }).to_string()),
572 ("POST", ["lists", _, "tasks"]) => {
573 let id = format!("n{}", made());
574 let mut task: Value = serde_json::from_str(&request.body).unwrap();
575 task["id"] = json!(id);
576 task["etag"] = json!(format!("\"{id}-0\""));
577 task["updated"] = json!("2026-10-05T10:00:00.000Z");
578 if let Some(parent) = query.split('&').find_map(|p| p.strip_prefix("parent=")) {
579 task["parent"] = json!(parent);
580 }
581 held.insert(id, task.clone());
582 Reply::new(200, task.to_string())
583 }
584 ("PATCH", ["lists", _, "tasks", id]) => match held.get_mut(*id) {
585 Some(task) => {
586 let change: Value = serde_json::from_str(&request.body).unwrap();
587 for (name, value) in change.as_object().into_iter().flatten() {
588 task[name] = value.clone();
589 }
590 task["etag"] = json!(format!("\"{id}-{}\"", made()));
591 Reply::new(200, task.to_string())
592 }
593 None => Reply::new(404, ""),
594 },
595 ("DELETE", ["lists", _, "tasks", id]) => {
596 held.remove(*id);
597 Reply::new(204, "")
598 }
599 _ => Reply::new(404, ""),
600 }
601 }
602 }
603
604 fn api_at(base: &str) -> Api<'static> {
606 let agent = ureq::Agent::config_builder().http_status_as_error(false).build().into();
607 Api { address: "you@example.org", agent, token: Mutex::new("test-token".into()), base: base.to_string() }
608 }
609
610 fn sync_once(api: &Api, account: &str) -> Result<Report, SyncError> {
611 let mut report = Report::default();
612 sync_list(api, account, "L", "tasks-L", "Tasks", &TimeZone::UTC, &mut report).map(|()| report)
613 }
614
615 fn list_dir(account: &str) -> std::path::PathBuf {
616 Kind::Calendars.root().join(account).join("tasks-L")
617 }
618
619 #[test]
621 fn a_task_gone_there_and_changed_here_is_made_once() {
622 home();
623 let google = Google::with(&[("g1", "Call")]);
624 let api = api_at(&serve(google.clone()));
625 sync_once(&api, "gt-once").unwrap();
626 let file = list_dir("gt-once").join("g1.ics");
627 google.held.lock().unwrap().remove("g1");
628 std::fs::write(&file, std::fs::read_to_string(&file).unwrap().replace("SUMMARY:Call", "SUMMARY:Call the notary")).unwrap();
629 sync_once(&api, "gt-once").unwrap();
630 assert_eq!(google.titles(), ["Call the notary"]);
631 }
632
633 #[test]
636 fn a_task_made_on_google_is_not_made_twice() {
637 home();
638 let google = Google::with(&[]);
639 let api = api_at(&serve(google.clone()));
640 sync_once(&api, "gt-twice").unwrap();
641 let zone = TimeZone::UTC;
642 for (uid, title) in [("a-uid", "First"), ("b-uid", "Second")] {
643 let edit = tasks::TaskEdit { title: title.into(), ..Default::default() };
644 std::fs::write(list_dir("gt-twice").join(format!("{uid}.ics")), tasks::new_task(&edit, uid, &zone, &jiff::Zoned::now()).unwrap()).unwrap();
645 }
646 google.faults.lock().unwrap().push(Box::new(nth(2, |r| r.method == "POST", Fault::Status(500))));
647 assert!(sync_once(&api, "gt-twice").is_err());
648 sync_once(&api, "gt-twice").unwrap();
649 assert_eq!(google.titles(), ["First", "Second"]);
650 }
651
652 #[cfg(feature = "insecure-test-tls")]
656 #[test]
657 #[ignore]
658 fn against_a_stand_in() {
659 let base = std::env::var("SIOUL_TEST_GOOGLE_TASKS").expect("SIOUL_TEST_GOOGLE_TASKS");
660 let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into();
661 let google = |path: &str| -> Value { serde_json::from_str(&agent.get(&format!("{base}/_state{path}")).call().unwrap().body_mut().read_to_string().unwrap()).unwrap() };
662 let poke = |path: &str, body: Value| {
663 agent.post(&format!("{base}/_poke{path}")).send(body.to_string()).unwrap();
664 };
665 let account: Account = toml::from_str("id = \"g\"\nkind = \"dav\"\naddress = \"you@example.org\"\nhost = \"apidata.googleusercontent.com\"\nauth = \"google\"\n").unwrap();
666 let zone = TimeZone::UTC;
667 let list = || vdir::collections(Kind::Calendars).into_iter().find(|c| c.account == "g" && c.id == "tasks-L1").expect("tasks-L1 here");
668 let read = |uid: &str| tasks::all(&zone).into_iter().find(|t| t.uid == uid);
669
670 let report = sync(&account).unwrap();
672 assert_eq!((report.collections, report.received), (1, 3), "{report:?}");
673 assert_eq!(read("t2").unwrap().parent(), Some("t1"));
674 assert_eq!(read("t1").unwrap().due, "2026-10-05");
675
676 let dir = list().dir;
678 let t1 = dir.join("t1.ics");
679 std::fs::write(&t1, std::fs::read_to_string(&t1).unwrap().replace("SUMMARY:Buy stamps", "SUMMARY:Buy stamps today")).unwrap();
680 let edit = tasks::TaskEdit { title: "Find a pen".into(), parent: "t1".into(), ..Default::default() };
681 std::fs::write(dir.join("pen.ics"), tasks::new_task(&edit, "pen-uid", &zone, &jiff::Zoned::now()).unwrap()).unwrap();
682 std::fs::remove_file(dir.join("t3.ics")).unwrap();
683 poke("/lists/L1/tasks/t2", json!({ "title": "Find the envelope, the big one" }));
684 let report = sync(&account).unwrap();
685 assert_eq!((report.sent, report.received), (3, 1), "{report:?}");
686 let there = google("/lists/L1");
687 let by_title = |title: &str| there["tasks"].as_array().unwrap().iter().find(|t| t["title"] == title).cloned();
688 assert!(by_title("Buy stamps today").is_some(), "{there}");
689 assert_eq!(by_title("Find a pen").expect("the pen")["parent"], "t1");
690 assert_eq!(there["tasks"].as_array().unwrap().iter().find(|t| t["id"] == "t3").unwrap()["deleted"], true);
691 assert_eq!(read("t2").unwrap().title, "Find the envelope, the big one");
692 assert_eq!(read("pen-uid").unwrap().parent(), Some("t1"), "the new step keeps its UID here");
693
694 let made = vdir::create(Kind::Calendars, "g", "Errands", None, &["VTODO"]).unwrap();
696 sync(&account).unwrap();
697 assert!(google("").as_array().unwrap().iter().any(|l| l["title"] == "Errands"));
698 let made = vdir::collections(Kind::Calendars).into_iter().find(|c| c.id == made.id).unwrap();
699 vdir::rename_collection(&made, "Errands, Saturday").unwrap();
700 sync(&account).unwrap();
701 assert!(google("").as_array().unwrap().iter().any(|l| l["title"] == "Errands, Saturday"));
702 vdir::delete_collection(&vdir::collections(Kind::Calendars).into_iter().find(|c| c.id == made.id).unwrap()).unwrap();
703 sync(&account).unwrap();
704 assert!(!google("").as_array().unwrap().iter().any(|l| l["title"].as_str().unwrap_or("").starts_with("Errands")));
705 assert!(!vdir::every_collection(Kind::Calendars).iter().any(|c| c.id == made.id));
706
707 poke("/delete-list/L1", json!({}));
709 sync(&account).unwrap();
710 assert!(!vdir::every_collection(Kind::Calendars).iter().any(|c| c.id == "tasks-L1"));
711 }
712}