diff --git a/README.md b/README.md index 7fdd7dc..28afcff 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,70 @@ From source: cargo install --path . ``` +## Using as a Rust SDK + +`things3-cloud` also exposes an experimental SDK for embedding the same Things +Cloud behavior in other Rust applications. The command-line interface remains +fully supported; the SDK is intended for UI clients and local integrations that +should not shell out to `things3`. + +```rust +use things3_cloud::sdk::{ThingsService, ThingsServiceConfig}; + +let things = ThingsService::new(ThingsServiceConfig::default()); +let today = things.today()?; + +for task in today { + println!("{}", serde_json::to_string_pretty(&task)?); +} +# Ok::<(), Box>(()) +``` + +Configure auth through the SDK: + +```rust +use things3_cloud::sdk::{ThingsService, ThingsServiceConfig}; + +let things = ThingsService::new(ThingsServiceConfig::default()); +things.save_auth("you@example.com", "app-password")?; +# Ok::<(), Box>(()) +``` + +Create, schedule, and complete a task: + +```rust +use things3_cloud::sdk::{ + CreateTaskRequest, MarkStatus, MarkTasksRequest, ScheduleTaskRequest, + ThingsService, ThingsServiceConfig, +}; + +let things = ThingsService::new(ThingsServiceConfig::default()); + +let created = things.create_task(CreateTaskRequest { + title: "Follow up with team".to_string(), + in_target: None, + when: Some("today".to_string()), + before_id: None, + after_id: None, + notes: Some("Send the launch notes.".to_string()), + tags: Some("Work".to_string()), + deadline: Some("2026-04-10".to_string()), +})?; + +let task_id = created.ids[0].clone(); +things.schedule_task(ScheduleTaskRequest { + task_id: task_id.clone(), + when: Some("evening".to_string()), + deadline: None, + clear_deadline: false, +})?; +things.mark_tasks(MarkTasksRequest { + task_ids: vec![task_id], + status: MarkStatus::Done, +})?; +# Ok::<(), Box>(()) +``` + ## Configure auth ```bash diff --git a/src/auth.rs b/src/auth.rs index 3a3d4a3..8620407 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,5 +1,3 @@ -use std::fs; - use anyhow::{Context, Result, anyhow}; use figment::{ Figment, @@ -7,7 +5,7 @@ use figment::{ }; use serde::{Deserialize, Serialize}; -use crate::dirs::auth_file_path; +use crate::dirs::{auth_file_path, ensure_private_dir, write_private_atomic}; #[derive(Debug, Clone, Serialize, Deserialize)] struct AuthPayload { @@ -75,23 +73,12 @@ pub fn write_auth(email: &str, password: &str) -> Result { .parent() .ok_or_else(|| anyhow!("Invalid auth file path"))? .to_path_buf(); - fs::create_dir_all(&parent).with_context(|| format!("Failed creating {}", parent.display()))?; + ensure_private_dir(&parent).with_context(|| format!("Failed creating {}", parent.display()))?; let payload = AuthPayload { email, password }; let serialized = serde_json::to_string(&payload)?; - let tmp_path = path.with_extension("tmp"); - fs::write(&tmp_path, serialized) - .with_context(|| format!("Failed writing {}", tmp_path.display()))?; - fs::rename(&tmp_path, &path) - .with_context(|| format!("Failed finalizing {}", path.display()))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(&path)?.permissions(); - perms.set_mode(0o600); - fs::set_permissions(&path, perms)?; - } + write_private_atomic(&path, serialized.as_bytes()) + .with_context(|| format!("Failed writing {}", path.display()))?; Ok(path) } diff --git a/src/client.rs b/src/client.rs index 92a889f..ed44652 100644 --- a/src/client.rs +++ b/src/client.rs @@ -38,10 +38,10 @@ pub(crate) fn now_timestamp() -> f64 { now_ts() } -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct ThingsCloudClient { - pub email: String, - pub password: String, + email: String, + password: String, pub history_key: Option, pub head_index: i64, http: Client, @@ -88,18 +88,24 @@ impl ThingsCloudClient { .json(&payload); } + let safe_url = redact_url(url); let resp = req .send() - .with_context(|| format!("request failed: {url}"))?; + .with_context(|| format!("request failed: {safe_url}"))?; let status = resp.status(); let text = resp.text().unwrap_or_default(); if !status.is_success() { - return Err(anyhow!("HTTP {} for {}: {}", status.as_u16(), url, text)); + return Err(anyhow!( + "HTTP {} for {}: {}", + status.as_u16(), + safe_url, + text + )); } if text.trim().is_empty() { return Ok(json!({})); } - serde_json::from_str(&text).with_context(|| format!("invalid json from {url}")) + serde_json::from_str(&text).with_context(|| format!("invalid json from {safe_url}")) } pub fn authenticate(&mut self) -> Result { @@ -144,8 +150,8 @@ impl ThingsCloudClient { let items = page .get("items") .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); + .ok_or_else(|| anyhow!("history page missing array field: items"))? + .clone(); let item_count = items.len(); self.head_index = page .get("current-item-index") @@ -160,11 +166,20 @@ impl ThingsCloudClient { let end = page .get("end-total-content-size") .and_then(Value::as_i64) - .unwrap_or(0); + .ok_or_else(|| { + anyhow!("history page missing integer field: end-total-content-size") + })?; let latest = page .get("latest-total-content-size") .and_then(Value::as_i64) - .unwrap_or(0); + .ok_or_else(|| { + anyhow!("history page missing integer field: latest-total-content-size") + })?; + if item_count == 0 && end < latest { + return Err(anyhow!( + "history page made no progress: empty items with end {end} before latest {latest}" + )); + } if end >= latest { break; } @@ -201,7 +216,7 @@ impl ThingsCloudClient { let new_index = result .get("server-head-index") .and_then(Value::as_i64) - .unwrap_or(idx); + .ok_or_else(|| anyhow!("missing server-head-index in commit response"))?; self.head_index = new_index; Ok(new_index) } @@ -230,3 +245,13 @@ impl ThingsCloudClient { self.commit(changes, None) } } + +fn redact_url(url: &str) -> String { + let Some((prefix, suffix)) = url.split_once("/history/") else { + return url.to_string(); + }; + let Some((_, rest)) = suffix.split_once('/') else { + return format!("{prefix}/history/"); + }; + format!("{prefix}/history//{rest}") +} diff --git a/src/cloud_writer.rs b/src/cloud_writer.rs index 31c591c..d364c10 100644 --- a/src/cloud_writer.rs +++ b/src/cloud_writer.rs @@ -36,21 +36,32 @@ impl CloudWriter for LoggingCloudWriter { ancestor_index: Option, ) -> Result { let uuids = changes.keys().cloned().collect::>(); - let request_value = json!({ - "ancestor_index": ancestor_index.unwrap_or(self.inner.head_index()), - "changes": &changes, - }); - let request_json = - serde_json::to_string(&request_value).unwrap_or_else(|_| "{}".to_string()); - debug!( - target: "things_cli::cloud_commit::request", - event = "cloud.commit.request", - ancestor_index, - change_count = uuids.len(), - uuids = ?uuids, - request_json = %request_json, - "cloud commit request" - ); + if std::env::var_os("THINGS3_LOG_COMMIT_PAYLOADS").is_some() { + let request_value = json!({ + "ancestor_index": ancestor_index.unwrap_or(self.inner.head_index()), + "changes": &changes, + }); + let request_json = + serde_json::to_string(&request_value).unwrap_or_else(|_| "{}".to_string()); + debug!( + target: "things_cli::cloud_commit::request", + event = "cloud.commit.request", + ancestor_index, + change_count = uuids.len(), + uuids = ?uuids, + request_json = %request_json, + "cloud commit request" + ); + } else { + debug!( + target: "things_cli::cloud_commit::request", + event = "cloud.commit.request", + ancestor_index, + change_count = uuids.len(), + uuids = ?uuids, + "cloud commit request" + ); + } match self.inner.commit(changes, ancestor_index) { Ok(head_index) => { diff --git a/src/commands/areas.rs b/src/commands/areas.rs index 9db9af1..a8ff05d 100644 --- a/src/commands/areas.rs +++ b/src/commands/areas.rs @@ -62,13 +62,56 @@ pub struct AreasEditArgs { } #[derive(Debug, Clone)] -struct AreasEditPlan { - area: crate::store::Area, - update: AreaPatch, - labels: Vec, +pub(crate) struct AreasNewPlan { + pub(crate) uuid: String, + pub(crate) title: String, + pub(crate) changes: BTreeMap, } -fn build_areas_edit_plan( +pub(crate) fn build_area_new_plan( + args: &AreasNewArgs, + store: &crate::store::ThingsStore, + next_id: &mut dyn FnMut() -> String, +) -> std::result::Result { + let title = args.title.trim(); + if title.is_empty() { + return Err("Area title cannot be empty.".to_string()); + } + + let mut props = AreaProps { + title: title.to_string(), + sort_index: 0, + conflict_overrides: Some(json!({"_t":"oo","sn":{}})), + ..Default::default() + }; + + if let Some(tags) = &args.tags { + let (tag_ids, err) = resolve_tag_ids(store, tags); + if !err.is_empty() { + return Err(err); + } + props.tag_ids = tag_ids; + } + + let uuid = next_id(); + let mut changes = BTreeMap::new(); + changes.insert(uuid.clone(), WireObject::create(EntityType::Area3, props)); + + Ok(AreasNewPlan { + uuid, + title: title.to_string(), + changes, + }) +} + +#[derive(Debug, Clone)] +pub(crate) struct AreasEditPlan { + pub(crate) area: crate::store::Area, + pub(crate) update: AreaPatch, + pub(crate) labels: Vec, +} + +pub(crate) fn build_areas_edit_plan( args: &AreasEditArgs, store: &crate::store::ThingsStore, now: f64, @@ -166,33 +209,17 @@ impl Command for AreasArgs { writeln!(out, "{}", rendered)?; } AreasSubcommand::New(args) => { - let title = args.title.trim(); - if title.is_empty() { - eprintln!("Area title cannot be empty."); - return Ok(()); - } - let store = cli.load_store()?; - let mut props = AreaProps { - title: title.to_string(), - sort_index: 0, - conflict_overrides: Some(json!({"_t":"oo","sn":{}})), - ..Default::default() - }; - - if let Some(tags) = &args.tags { - let (tag_ids, err) = resolve_tag_ids(&store, tags); - if !err.is_empty() { + let mut id_gen = || ctx.next_id(); + let plan = match build_area_new_plan(args, &store, &mut id_gen) { + Ok(plan) => plan, + Err(err) => { eprintln!("{err}"); return Ok(()); } - props.tag_ids = tag_ids; - } + }; - let uuid = ctx.next_id(); - let mut changes = BTreeMap::new(); - changes.insert(uuid.clone(), WireObject::create(EntityType::Area3, props)); - if let Err(e) = ctx.commit_changes(changes, None) { + if let Err(e) = ctx.commit_changes(plan.changes, None) { eprintln!("Failed to create area: {e}"); return Ok(()); } @@ -201,8 +228,8 @@ impl Command for AreasArgs { out, "{} {} {}", colored(format!("{} Created", ICONS.done), &[GREEN], cli.no_color), - title, - colored(&uuid, &[DIM], cli.no_color) + plan.title, + colored(&plan.uuid, &[DIM], cli.no_color) )?; } AreasSubcommand::Edit(args) => { @@ -303,6 +330,45 @@ mod tests { ) } + #[test] + fn areas_new_payload_and_errors() { + let tag_uuid = "WukwpDdL5Z88nX3okGMKTC"; + let new_uuid = "JiqwiDaS3CAyjCmHihBDnB"; + let store = build_store(vec![tag(tag_uuid, "Work")]); + let mut next_id = || new_uuid.to_string(); + + let plan = build_area_new_plan( + &AreasNewArgs { + title: " Home ".to_string(), + tags: Some("Work".to_string()), + }, + &store, + &mut next_id, + ) + .expect("area create"); + + assert_eq!(plan.uuid, new_uuid); + assert_eq!(plan.title, "Home"); + let payload = serde_json::to_value(plan.changes).expect("serialize changes"); + let p = &payload[new_uuid]["p"]; + assert_eq!(payload[new_uuid]["e"], json!("Area3")); + assert_eq!(payload[new_uuid]["t"], json!(0)); + assert_eq!(p["tt"], json!("Home")); + assert_eq!(p["tg"], json!([tag_uuid])); + assert_eq!(p["ix"], json!(0)); + + let err = build_area_new_plan( + &AreasNewArgs { + title: " ".to_string(), + tags: None, + }, + &store, + &mut || new_uuid.to_string(), + ) + .expect_err("empty title"); + assert_eq!(err, "Area title cannot be empty."); + } + #[test] fn areas_edit_payload_and_errors() { let tag1 = "WukwpDdL5Z88nX3okGMKTC"; diff --git a/src/commands/delete.rs b/src/commands/delete.rs index 719e91f..1f69cfd 100644 --- a/src/commands/delete.rs +++ b/src/commands/delete.rs @@ -19,12 +19,15 @@ pub struct DeleteArgs { } #[derive(Debug, Clone)] -struct DeletePlan { - targets: Vec<(String, String, String)>, - changes: BTreeMap, +pub(crate) struct DeletePlan { + pub(crate) targets: Vec<(String, String, String)>, + pub(crate) changes: BTreeMap, } -fn build_delete_plan(args: &DeleteArgs, store: &crate::store::ThingsStore) -> DeletePlan { +pub(crate) fn build_delete_plan( + args: &DeleteArgs, + store: &crate::store::ThingsStore, +) -> DeletePlan { let mut targets: Vec<(String, String, String)> = Vec::new(); let mut seen = HashSet::new(); diff --git a/src/commands/edit.rs b/src/commands/edit.rs index ad4ae66..4d17b57 100644 --- a/src/commands/edit.rs +++ b/src/commands/edit.rs @@ -101,10 +101,10 @@ fn resolve_checklist_items( } #[derive(Debug, Clone)] -struct EditPlan { - tasks: Vec, - changes: BTreeMap, - labels: Vec, +pub(crate) struct EditPlan { + pub(crate) tasks: Vec, + pub(crate) changes: BTreeMap, + pub(crate) labels: Vec, } impl Command for EditArgs { @@ -156,7 +156,7 @@ impl Command for EditArgs { } } -fn build_edit_plan( +pub(crate) fn build_edit_plan( args: &EditArgs, store: &crate::store::ThingsStore, now: f64, @@ -311,16 +311,16 @@ fn build_edit_plan( if let Some(notes) = &args.notes { if notes.is_empty() { - update.notes = Some(TaskNotes::Structured(StructuredTaskNotes { + update.notes = Some(Some(TaskNotes::Structured(StructuredTaskNotes { object_type: Some("tx".to_string()), format_type: 1, ch: Some(0), v: Some(String::new()), ps: Vec::new(), unknown_fields: Default::default(), - })); + }))); } else { - update.notes = Some(task6_note(notes)); + update.notes = Some(Some(task6_note(notes))); } if !labels.iter().any(|l| l == "notes") { labels.push("notes".to_string()); diff --git a/src/commands/find.rs b/src/commands/find.rs index 01dd0c7..37e1b8c 100644 --- a/src/commands/find.rs +++ b/src/commands/find.rs @@ -98,21 +98,21 @@ pub struct FindArgs { value_name = "TAG", help = "Has this tag (title or UUID prefix); repeatable, OR logic" )] - tag_filters: Vec, + pub tag_filters: Vec, #[arg( long = "project", short = 'p', value_name = "PROJECT", help = "In this project (title substring or UUID prefix); repeatable, OR logic" )] - project_filters: Vec, + pub project_filters: Vec, #[arg( long = "area", short = 'a', value_name = "AREA", help = "In this area (title substring or UUID prefix); repeatable, OR logic" )] - area_filters: Vec, + pub area_filters: Vec, #[arg(long, short = 'I', help = "In Inbox view")] pub inbox: bool, #[arg(long, short = 'T', help = "In Today view")] @@ -247,6 +247,51 @@ impl Command for FindArgs { } } +pub(crate) fn find_tasks( + store: &ThingsStore, + args: &FindArgs, + today: &DateTime, +) -> std::result::Result, String> { + for (flag, exprs) in [ + ("--deadline", &args.deadline), + ("--scheduled", &args.scheduled), + ("--created", &args.created), + ("--completed-on", &args.completed_on), + ] { + for expr in exprs { + parse_date_expr(expr, flag, today)?; + } + } + + let mut resolved_tag_uuids = Vec::new(); + for tag_filter in &args.tag_filters { + let (tag, err) = resolve_single_tag(store, tag_filter.as_str()); + if !err.is_empty() { + return Err(err); + } + if let Some(tag) = tag { + resolved_tag_uuids.push(tag.uuid); + } + } + + let mut matched: Vec = store + .tasks_by_uuid + .values() + .filter_map(|task| { + let result = matches(task, store, args, &resolved_tag_uuids, today); + result.matched.then(|| task.clone()) + }) + .collect(); + + matched.sort_by(|a, b| { + let a_proj = if a.is_project() { 0 } else { 1 }; + let b_proj = if b.is_project() { 0 } else { 1 }; + (a_proj, a.index, &a.uuid).cmp(&(b_proj, b.index, &b.uuid)) + }); + + Ok(matched) +} + fn parse_date_value( value: &str, flag: &str, diff --git a/src/commands/mark.rs b/src/commands/mark.rs index 83d7280..9bb2aae 100644 --- a/src/commands/mark.rs +++ b/src/commands/mark.rs @@ -182,11 +182,11 @@ fn validate_mark_target( } #[derive(Debug, Clone)] -struct MarkCommitPlan { - changes: BTreeMap, +pub(crate) struct MarkCommitPlan { + pub(crate) changes: BTreeMap, } -fn build_mark_status_plan( +pub(crate) fn build_mark_status_plan( args: &MarkArgs, store: &crate::store::ThingsStore, now: f64, @@ -260,7 +260,7 @@ fn build_mark_status_plan( (MarkCommitPlan { changes }, successes, errors) } -fn build_mark_checklist_plan( +pub(crate) fn build_mark_checklist_plan( args: &MarkArgs, task: &crate::store::Task, checklist_raw: &str, diff --git a/src/commands/new.rs b/src/commands/new.rs index e5263ef..6d922e0 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -178,13 +178,13 @@ fn plan_ix_insert(ordered: &[Task], insert_at: usize) -> (i32, Vec<(String, i32, } #[derive(Debug, Clone)] -struct NewPlan { - new_uuid: String, - changes: BTreeMap, - title: String, +pub(crate) struct NewPlan { + pub(crate) new_uuid: String, + pub(crate) changes: BTreeMap, + pub(crate) title: String, } -fn build_new_plan( +pub(crate) fn build_new_plan( args: &NewArgs, store: &crate::store::ThingsStore, now: f64, @@ -297,7 +297,7 @@ fn build_new_plan( Ok(None) => return Err("--deadline requires YYYY-MM-DD".to_string()), Err(err) => return Err(err), }; - props.deadline = Some(day_to_timestamp(parsed) as i64); + props.deadline = Some(day_to_timestamp(parsed)); } let anchor_is_today = anchor diff --git a/src/commands/projects.rs b/src/commands/projects.rs index 0786ca8..b144a0b 100644 --- a/src/commands/projects.rs +++ b/src/commands/projects.rs @@ -91,13 +91,108 @@ pub struct ProjectsEditArgs { } #[derive(Debug, Clone)] -struct ProjectsEditPlan { - project: crate::store::Task, - update: TaskPatch, - labels: Vec, +pub(crate) struct ProjectsNewPlan { + pub(crate) uuid: String, + pub(crate) title: String, + pub(crate) changes: BTreeMap, } -fn build_projects_edit_plan( +pub(crate) fn build_project_new_plan( + args: &ProjectsNewArgs, + store: &crate::store::ThingsStore, + now: f64, + today_ts: i64, + next_id: &mut dyn FnMut() -> String, +) -> std::result::Result { + let title = args.title.trim(); + if title.is_empty() { + return Err("Project title cannot be empty.".to_string()); + } + + let mut props = TaskProps { + title: title.to_string(), + notes: Some(task6_note(&args.notes)), + item_type: TaskType::Project, + status: TaskStatus::Incomplete, + start_location: TaskStart::Anytime, + sort_index: 0, + conflict_overrides: Some(serde_json::json!({"_t": "oo", "sn": {}})), + creation_date: Some(now), + modification_date: Some(now), + ..Default::default() + }; + + if let Some(area_id) = &args.area { + let (area_opt, err, _) = store.resolve_area_identifier(area_id); + let Some(area) = area_opt else { + return Err(err); + }; + props.area_ids = vec![area.uuid]; + } + + if let Some(when_raw) = &args.when { + let when = when_raw.trim().to_lowercase(); + if when == "anytime" { + props.start_location = TaskStart::Anytime; + } else if when == "someday" { + props.start_location = TaskStart::Someday; + } else if when == "today" { + props.start_location = TaskStart::Anytime; + props.scheduled_date = Some(today_ts); + props.today_index_reference = Some(today_ts); + } else { + let day = match parse_day(Some(when_raw), "--when") { + Ok(Some(day)) => day, + Ok(None) => { + return Err( + "--when requires anytime, someday, today, or YYYY-MM-DD".to_string() + ); + } + Err(e) => return Err(e), + }; + let ts = day_to_timestamp(day); + props.start_location = TaskStart::Someday; + props.scheduled_date = Some(ts); + props.today_index_reference = Some(ts); + } + } + + if let Some(tags) = &args.tags { + let (tag_ids, err) = resolve_tag_ids(store, tags); + if !err.is_empty() { + return Err(err); + } + props.tag_ids = tag_ids; + } + + if let Some(deadline) = &args.deadline_date { + let day = match parse_day(Some(deadline), "--deadline") { + Ok(Some(day)) => day, + Ok(None) => return Err("--deadline requires YYYY-MM-DD".to_string()), + Err(e) => return Err(e), + }; + props.deadline = Some(day_to_timestamp(day)); + } + + let uuid = next_id(); + let mut changes = BTreeMap::new(); + changes.insert(uuid.clone(), WireObject::create(EntityType::Task6, props)); + + Ok(ProjectsNewPlan { + uuid, + title: title.to_string(), + changes, + }) +} + +#[derive(Debug, Clone)] +pub(crate) struct ProjectsEditPlan { + pub(crate) project: crate::store::Task, + pub(crate) update: TaskPatch, + pub(crate) labels: Vec, +} + +pub(crate) fn build_projects_edit_plan( args: &ProjectsEditArgs, store: &crate::store::ThingsStore, now: f64, @@ -123,7 +218,7 @@ fn build_projects_edit_plan( } if let Some(notes) = &args.notes { - update.notes = Some(if notes.is_empty() { + update.notes = Some(Some(if notes.is_empty() { TaskNotes::Structured(StructuredTaskNotes { object_type: Some("tx".to_string()), format_type: 1, @@ -134,7 +229,7 @@ fn build_projects_edit_plan( }) } else { task6_note(notes) - }); + })); labels.push("notes".to_string()); } @@ -301,89 +396,19 @@ impl Command for ProjectsArgs { writeln!(out, "{}", rendered)?; } Some(ProjectsSubcommand::New(args)) => { - let title = args.title.trim(); - if title.is_empty() { - eprintln!("Project title cannot be empty."); - return Ok(()); - } - let store = cli.load_store()?; let now = ctx.now_timestamp(); - let mut props = TaskProps { - title: title.to_string(), - notes: Some(task6_note(&args.notes)), - item_type: TaskType::Project, - status: TaskStatus::Incomplete, - start_location: TaskStart::Anytime, - sort_index: 0, - conflict_overrides: Some(serde_json::json!({"_t": "oo", "sn": {}})), - creation_date: Some(now), - modification_date: Some(now), - ..Default::default() - }; - - if let Some(area_id) = &args.area { - let (area_opt, err, _) = store.resolve_area_identifier(area_id); - let Some(area) = area_opt else { - eprintln!("{err}"); - return Ok(()); - }; - props.area_ids = vec![area.uuid]; - } - - if let Some(when_raw) = &args.when { - let when = when_raw.trim().to_lowercase(); - if when == "anytime" { - props.start_location = TaskStart::Anytime; - } else if when == "someday" { - props.start_location = TaskStart::Someday; - } else if when == "today" { - let ts = ctx.today_timestamp(); - props.start_location = TaskStart::Anytime; - props.scheduled_date = Some(ts); - props.today_index_reference = Some(ts); - } else { - let day = match parse_day(Some(when_raw), "--when") { - Ok(Some(day)) => day, - Ok(None) => return Ok(()), - Err(e) => { - eprintln!("{e}"); - return Ok(()); - } - }; - let ts = day_to_timestamp(day); - props.start_location = TaskStart::Someday; - props.scheduled_date = Some(ts); - props.today_index_reference = Some(ts); - } - } - - if let Some(tags) = &args.tags { - let (tag_ids, err) = resolve_tag_ids(&store, tags); - if !err.is_empty() { + let today_ts = ctx.today_timestamp(); + let mut id_gen = || ctx.next_id(); + let plan = match build_project_new_plan(args, &store, now, today_ts, &mut id_gen) { + Ok(plan) => plan, + Err(err) => { eprintln!("{err}"); return Ok(()); } - props.tag_ids = tag_ids; - } - - if let Some(deadline) = &args.deadline_date { - let day = match parse_day(Some(deadline), "--deadline") { - Ok(Some(day)) => day, - Ok(None) => return Ok(()), - Err(e) => { - eprintln!("{e}"); - return Ok(()); - } - }; - props.deadline = Some(day_to_timestamp(day) as i64); - } - - let uuid = ctx.next_id(); + }; - let mut changes = BTreeMap::new(); - changes.insert(uuid.clone(), WireObject::create(EntityType::Task6, props)); - if let Err(e) = ctx.commit_changes(changes, None) { + if let Err(e) = ctx.commit_changes(plan.changes, None) { eprintln!("Failed to create project: {e}"); return Ok(()); } @@ -392,8 +417,8 @@ impl Command for ProjectsArgs { out, "{} {} {}", colored(format!("{} Created", ICONS.done), &[GREEN], cli.no_color), - title, - colored(&uuid, &[DIM], cli.no_color) + plan.title, + colored(&plan.uuid, &[DIM], cli.no_color) )?; } Some(ProjectsSubcommand::Edit(args)) => { @@ -519,6 +544,73 @@ mod tests { ) } + #[test] + fn projects_new_payload_variants() { + let new_uuid = "MpkEei6ybkFS2n6SXvwfLf"; + let area_uuid = "JFdhhhp37fpryAKu8UXwzK"; + let tag_uuid = "WukwpDdL5Z88nX3okGMKTC"; + let deadline = "2026-04-10"; + let deadline_ts = day_to_timestamp( + parse_day(Some(deadline), "--deadline") + .expect("deadline parses") + .expect("deadline day"), + ); + let store = build_store(vec![area(area_uuid, "Personal"), tag(tag_uuid, "Work")]); + let mut next_id = || new_uuid.to_string(); + + let plan = build_project_new_plan( + &ProjectsNewArgs { + title: " Roadmap ".to_string(), + area: Some(area_uuid.to_string()), + when: Some("today".to_string()), + notes: "Launch notes".to_string(), + tags: Some("Work".to_string()), + deadline_date: Some(deadline.to_string()), + }, + &store, + NOW, + 1_700_000_000, + &mut next_id, + ) + .expect("project create"); + + assert_eq!(plan.uuid, new_uuid); + assert_eq!(plan.title, "Roadmap"); + let payload = serde_json::to_value(plan.changes).expect("serialize changes"); + let p = &payload[new_uuid]["p"]; + assert_eq!(payload[new_uuid]["e"], json!("Task6")); + assert_eq!(payload[new_uuid]["t"], json!(0)); + assert_eq!(p["tt"], json!("Roadmap")); + assert!(p["nt"].is_object()); + assert_eq!(p["tp"], json!(1)); + assert_eq!(p["ss"], json!(0)); + assert_eq!(p["st"], json!(1)); + assert_eq!(p["sr"], json!(1_700_000_000)); + assert_eq!(p["tir"], json!(1_700_000_000)); + assert_eq!(p["ar"], json!([area_uuid])); + assert_eq!(p["tg"], json!([tag_uuid])); + assert_eq!(p["dd"], json!(deadline_ts)); + assert_eq!(p["cd"], json!(NOW)); + assert_eq!(p["md"], json!(NOW)); + + let err = build_project_new_plan( + &ProjectsNewArgs { + title: " ".to_string(), + area: None, + when: None, + notes: String::new(), + tags: None, + deadline_date: None, + }, + &store, + NOW, + 1_700_000_000, + &mut || new_uuid.to_string(), + ) + .expect_err("empty title"); + assert_eq!(err, "Project title cannot be empty."); + } + #[test] fn projects_edit_payload_variants() { let target_area_uuid = "JFdhhhp37fpryAKu8UXwzK"; diff --git a/src/commands/reorder.rs b/src/commands/reorder.rs index aa8da16..5fb4c37 100644 --- a/src/commands/reorder.rs +++ b/src/commands/reorder.rs @@ -26,19 +26,19 @@ pub struct ReorderArgs { } #[derive(Debug, Clone)] -struct ReorderCommit { - changes: BTreeMap, - ancestor_index: Option, +pub(crate) struct ReorderCommit { + pub(crate) changes: BTreeMap, + pub(crate) ancestor_index: Option, } #[derive(Debug, Clone)] -struct ReorderPlan { - item: crate::store::Task, - commits: Vec, - reorder_label: String, +pub(crate) struct ReorderPlan { + pub(crate) item: crate::store::Task, + pub(crate) commits: Vec, + pub(crate) reorder_label: String, } -fn build_reorder_plan( +pub(crate) fn build_reorder_plan( args: &ReorderArgs, store: &crate::store::ThingsStore, now: f64, diff --git a/src/commands/schedule.rs b/src/commands/schedule.rs index 873ca9e..23accaf 100644 --- a/src/commands/schedule.rs +++ b/src/commands/schedule.rs @@ -31,13 +31,13 @@ pub struct ScheduleArgs { } #[derive(Debug, Clone)] -struct SchedulePlan { - task: crate::store::Task, - update: TaskPatch, - labels: Vec, +pub(crate) struct SchedulePlan { + pub(crate) task: crate::store::Task, + pub(crate) update: TaskPatch, + pub(crate) labels: Vec, } -fn build_schedule_plan( +pub(crate) fn build_schedule_plan( args: &ScheduleArgs, store: &crate::store::ThingsStore, now: f64, diff --git a/src/commands/tags.rs b/src/commands/tags.rs index fcb77c2..419d7de 100644 --- a/src/commands/tags.rs +++ b/src/commands/tags.rs @@ -66,13 +66,56 @@ pub struct TagsDeleteArgs { } #[derive(Debug, Clone)] -struct TagsEditPlan { - tag: crate::store::Tag, - update: TagPatch, - labels: Vec, +pub(crate) struct TagsNewPlan { + pub(crate) uuid: String, + pub(crate) name: String, + pub(crate) changes: BTreeMap, } -fn build_tags_edit_plan( +pub(crate) fn build_tag_new_plan( + args: &TagsNewArgs, + store: &crate::store::ThingsStore, + next_id: &mut dyn FnMut() -> String, +) -> std::result::Result { + let name = args.name.trim(); + if name.is_empty() { + return Err("Tag name cannot be empty.".to_string()); + } + + let mut props = TagProps { + title: name.to_string(), + sort_index: 0, + conflict_overrides: Some(json!({"_t": "oo", "sn": {}})), + ..Default::default() + }; + + if let Some(parent_raw) = &args.parent { + let (parent, err) = resolve_single_tag(store, parent_raw); + let Some(parent) = parent else { + return Err(err); + }; + props.parent_ids = vec![parent.uuid]; + } + + let uuid = next_id(); + let mut changes = BTreeMap::new(); + changes.insert(uuid.clone(), WireObject::create(EntityType::Tag4, props)); + + Ok(TagsNewPlan { + uuid, + name: name.to_string(), + changes, + }) +} + +#[derive(Debug, Clone)] +pub(crate) struct TagsEditPlan { + pub(crate) tag: crate::store::Tag, + pub(crate) update: TagPatch, + pub(crate) labels: Vec, +} + +pub(crate) fn build_tags_edit_plan( args: &TagsEditArgs, store: &crate::store::ThingsStore, now: f64, @@ -171,33 +214,17 @@ impl Command for TagsArgs { writeln!(out, "{}", rendered)?; } TagsSubcommand::New(args) => { - let name = args.name.trim(); - if name.is_empty() { - eprintln!("Tag name cannot be empty."); - return Ok(()); - } - let store = cli.load_store()?; - let mut props = TagProps { - title: name.to_string(), - sort_index: 0, - conflict_overrides: Some(json!({"_t": "oo", "sn": {}})), - ..Default::default() - }; - - if let Some(parent_raw) = &args.parent { - let (parent, err) = resolve_single_tag(&store, parent_raw); - let Some(parent) = parent else { + let mut id_gen = || ctx.next_id(); + let plan = match build_tag_new_plan(args, &store, &mut id_gen) { + Ok(plan) => plan, + Err(err) => { eprintln!("{err}"); return Ok(()); - }; - props.parent_ids = vec![parent.uuid]; - } + } + }; - let uuid = ctx.next_id(); - let mut changes = BTreeMap::new(); - changes.insert(uuid.clone(), WireObject::create(EntityType::Tag4, props)); - if let Err(e) = ctx.commit_changes(changes, None) { + if let Err(e) = ctx.commit_changes(plan.changes, None) { eprintln!("Failed to create tag: {e}"); return Ok(()); } @@ -206,8 +233,8 @@ impl Command for TagsArgs { out, "{} {} {}", colored(format!("{} Created", ICONS.done), &[GREEN], cli.no_color), - name, - colored(&uuid, &[DIM], cli.no_color) + plan.name, + colored(&plan.uuid, &[DIM], cli.no_color) )?; } TagsSubcommand::Edit(args) => { @@ -320,6 +347,44 @@ mod tests { ) } + #[test] + fn tags_new_payload_and_errors() { + let new_uuid = "MpkEei6ybkFS2n6SXvwfLf"; + let store = build_store(vec![tag(TAG_UUID, "Work", None)]); + let mut next_id = || new_uuid.to_string(); + + let plan = build_tag_new_plan( + &TagsNewArgs { + name: " Meetings ".to_string(), + parent: Some("Work".to_string()), + }, + &store, + &mut next_id, + ) + .expect("tag create"); + + assert_eq!(plan.uuid, new_uuid); + assert_eq!(plan.name, "Meetings"); + let payload = serde_json::to_value(plan.changes).expect("serialize changes"); + let p = &payload[new_uuid]["p"]; + assert_eq!(payload[new_uuid]["e"], json!("Tag4")); + assert_eq!(payload[new_uuid]["t"], json!(0)); + assert_eq!(p["tt"], json!("Meetings")); + assert_eq!(p["pn"], json!([TAG_UUID])); + assert_eq!(p["ix"], json!(0)); + + let err = build_tag_new_plan( + &TagsNewArgs { + name: " ".to_string(), + parent: None, + }, + &store, + &mut || new_uuid.to_string(), + ) + .expect_err("empty name"); + assert_eq!(err, "Tag name cannot be empty."); + } + #[test] fn tags_edit_payloads_and_errors() { let store = build_store(vec![ diff --git a/src/commands/webserver.rs b/src/commands/webserver.rs index 569fec5..fc5b2bf 100644 --- a/src/commands/webserver.rs +++ b/src/commands/webserver.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; -use crate::{app::Cli, commands::Command}; +use crate::{app::Cli, commands::Command, ids::ThingsId}; #[derive(Debug, Args)] pub struct WebserverArgs { @@ -16,6 +16,9 @@ pub struct WebserverArgs { /// TCP port to listen on #[arg(long, default_value_t = 8765)] pub port: u16, + /// Bearer token required for requests. Defaults to THINGS3_WEBSERVER_TOKEN or a generated token. + #[arg(long)] + pub token: Option, } #[derive(Debug, Deserialize)] @@ -68,13 +71,26 @@ impl Command for WebserverArgs { _out: &mut dyn std::io::Write, _ctx: &mut dyn crate::cmd_ctx::CmdCtx, ) -> Result<()> { + if !is_loopback_host(&self.host) { + anyhow::bail!( + "refusing to bind webserver to non-loopback host {}; use an external authenticated proxy if remote access is required", + self.host + ); + } + let addr = format!("{}:{}", self.host, self.port); let server = Server::http(&addr).map_err(|err| anyhow::anyhow!("failed to bind {addr}: {err}"))?; + let token = self + .token + .clone() + .or_else(|| std::env::var("THINGS3_WEBSERVER_TOKEN").ok()) + .unwrap_or_else(|| ThingsId::random().to_string()); eprintln!("things3 webserver listening on http://{addr}"); + eprintln!("things3 webserver bearer token: {token}"); for request in server.incoming_requests() { - if let Err(err) = handle_request(request) { + if let Err(err) = handle_request(request, &token) { eprintln!("webserver request error: {err}"); } } @@ -83,8 +99,8 @@ impl Command for WebserverArgs { } } -fn handle_request(mut request: Request) -> Result<()> { - let (status, payload) = match process_request(&mut request) { +fn handle_request(mut request: Request, token: &str) -> Result<()> { + let (status, payload) = match process_request(&mut request, token) { Ok(response) => (StatusCode(200), response), Err(err) => ( err.status, @@ -98,7 +114,10 @@ fn handle_request(mut request: Request) -> Result<()> { send_json(request, status, &payload) } -fn process_request(request: &mut Request) -> std::result::Result { +fn process_request( + request: &mut Request, + token: &str, +) -> std::result::Result { if request.method() != &Method::Post { return Err(RequestError::new(StatusCode(405), "method not allowed")); } @@ -107,6 +126,10 @@ fn process_request(request: &mut Request) -> std::result::Result std::result::Result bool { + let expected = format!("Bearer {token}"); + request.headers().iter().any(|header| { + header.field.equiv("Authorization") && header.value.as_str() == expected + || header.field.equiv("X-Things3-Token") && header.value.as_str() == token + }) +} + +fn is_loopback_host(host: &str) -> bool { + matches!(host, "127.0.0.1" | "::1" | "localhost") +} + fn normalize_args(args: Option>) -> Vec { let mut out = args .unwrap_or_default() diff --git a/src/common.rs b/src/common.rs index 129ebea..5010715 100644 --- a/src/common.rs +++ b/src/common.rs @@ -9,10 +9,14 @@ use crate::{ wire::notes::{StructuredTaskNotes, TaskNotes}, }; -/// Return today as a UTC midnight `DateTime`. +/// Return local today at midnight, represented as UTC. pub fn today_utc() -> DateTime { - let today = Utc::now().date_naive().and_hms_opt(0, 0, 0).unwrap(); - Utc.from_utc_datetime(&today) + let today = Local::now().date_naive().and_hms_opt(0, 0, 0).unwrap(); + Local + .from_local_datetime(&today) + .single() + .unwrap_or_else(Local::now) + .with_timezone(&Utc) } /// Return current wall-clock unix timestamp in seconds (fractional). diff --git a/src/dirs.rs b/src/dirs.rs index e6675b4..a72ad24 100644 --- a/src/dirs.rs +++ b/src/dirs.rs @@ -1,4 +1,8 @@ -use std::{fs, path::PathBuf}; +use std::{ + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, +}; const APP_NAME: &str = "things3"; const LEGACY_APP_NAME: &str = "things-cli"; @@ -44,3 +48,55 @@ pub fn append_log_dir() -> PathBuf { pub fn auth_file_path() -> PathBuf { app_state_dir().join("auth.json") } + +pub fn ensure_private_dir(path: &Path) -> std::io::Result<()> { + fs::create_dir_all(path)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + } + + Ok(()) +} + +pub fn write_private_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let parent = path.parent().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid private file path", + ) + })?; + ensure_private_dir(parent)?; + + let tmp_path = path.with_extension("tmp"); + match fs::remove_file(&tmp_path) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err), + } + + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + + { + let mut file = options.open(&tmp_path)?; + file.write_all(bytes)?; + file.sync_all()?; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o600))?; + } + + fs::rename(&tmp_path, path)?; + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 14924c5..7dc5d74 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod dirs; pub mod ids; pub mod log_cache; pub mod logging; +pub mod sdk; pub mod store; pub mod ui; pub mod wire; diff --git a/src/log_cache.rs b/src/log_cache.rs index ef63415..6384a7f 100644 --- a/src/log_cache.rs +++ b/src/log_cache.rs @@ -10,6 +10,7 @@ use serde_json::Value; use crate::{ client::ThingsCloudClient, + dirs::{ensure_private_dir, write_private_atomic}, store::{RawState, fold_item}, wire::wire_object::WireItem, }; @@ -60,16 +61,15 @@ fn write_cursor( "head_index": head_index, "updated_at": crate::client::now_timestamp(), }))?; - let tmp = path.with_extension("tmp"); - fs::write(&tmp, payload)?; - fs::rename(tmp, path)?; + write_private_atomic(path, payload.as_bytes())?; Ok(()) } pub fn sync_append_log(client: &mut ThingsCloudClient, cache_dir: &Path) -> Result<()> { - fs::create_dir_all(cache_dir)?; + ensure_private_dir(cache_dir)?; let log_path = cache_dir.join("things.log"); let cursor_path = cache_dir.join("cursor.json"); + let state_cache_path = cache_dir.join("state_cache.json"); let cursor = read_cursor(&cursor_path); let mut start_index = cursor.next_start_index; @@ -82,17 +82,38 @@ pub fn sync_append_log(client: &mut ThingsCloudClient, cache_dir: &Path) -> Resu } } - let mut fp = OpenOptions::new() - .create(true) - .append(true) + let mut options = OpenOptions::new(); + options.create(true).append(true).read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut fp = options .open(&log_path) .with_context(|| format!("failed to open {}", log_path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&log_path, fs::Permissions::from_mode(0o600))?; + } + loop { let page = match client.get_items_page(start_index) { Ok(v) => v, Err(_) => { let _ = client.authenticate()?; + if client.history_key.as_deref() != Some(cursor.history_key.as_str()) { + start_index = 0; + fp.set_len(0)?; + fp.seek(SeekFrom::Start(0))?; + match fs::remove_file(&state_cache_path) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + } client.get_items_page(start_index)? } }; @@ -100,16 +121,23 @@ pub fn sync_append_log(client: &mut ThingsCloudClient, cache_dir: &Path) -> Resu let items = page .get("items") .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); + .ok_or_else(|| anyhow!("history page missing array field: items"))? + .clone(); let end = page .get("end-total-content-size") .and_then(Value::as_i64) - .unwrap_or(0); + .ok_or_else(|| anyhow!("history page missing integer field: end-total-content-size"))?; let latest = page .get("latest-total-content-size") .and_then(Value::as_i64) - .unwrap_or(0); + .ok_or_else(|| { + anyhow!("history page missing integer field: latest-total-content-size") + })?; + if items.is_empty() && end < latest { + return Err(anyhow!( + "history page made no progress: empty items with end {end} before latest {latest}" + )); + } client.head_index = page .get("current-item-index") .and_then(Value::as_i64) @@ -174,9 +202,7 @@ fn write_state_cache(cache_dir: &Path, state: &RawState, log_offset: u64) -> Res log_offset, state: state.clone(), })?; - let tmp = path.with_extension("tmp"); - fs::write(&tmp, payload)?; - fs::rename(tmp, path)?; + write_private_atomic(&path, payload.as_bytes())?; Ok(()) } diff --git a/src/sdk/mod.rs b/src/sdk/mod.rs new file mode 100644 index 0000000..549bef3 --- /dev/null +++ b/src/sdk/mod.rs @@ -0,0 +1,1002 @@ +//! Experimental Rust SDK surface for embedding Things Cloud workflows. +//! +//! The CLI remains the stable user interface. This module exposes the same +//! read and mutation behavior through typed Rust APIs for app integrations. + +use std::{collections::BTreeMap, fs, io::Read, path::PathBuf}; + +use chrono::{TimeZone, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::{ + app::Cli, + arg_types::IdentifierToken, + auth::{load_auth, write_auth}, + client::ThingsCloudClient, + cmd_ctx::{CmdCtx, DefaultCmdCtx}, + commands::{ + DetailedArgs, TagDeltaArgs, + areas::{AreasEditArgs, AreasNewArgs, build_area_new_plan, build_areas_edit_plan}, + delete::{DeleteArgs, build_delete_plan}, + edit::{EditArgs, build_edit_plan}, + find::{FindArgs, find_tasks}, + mark::{MarkArgs, build_mark_checklist_plan, build_mark_status_plan}, + new::{NewArgs, build_new_plan}, + projects::{ + ProjectsEditArgs, ProjectsNewArgs, build_project_new_plan, build_projects_edit_plan, + }, + reorder::{ReorderArgs, build_reorder_plan}, + schedule::{ScheduleArgs, build_schedule_plan}, + tags::{ + TagsDeleteArgs, TagsEditArgs, TagsNewArgs, build_tag_new_plan, build_tags_edit_plan, + }, + }, + common::{parse_day, resolve_single_tag}, + dirs::append_log_dir, + log_cache::{fold_state_from_append_log, get_state_with_append_log}, + logging, + store::{RawState, Task, ThingsStore, fold_items}, + ui::views::json::common::{ + ResolvedAreaJson, ResolvedTagJson, ResolvedTaskJson, build_area_json, build_tags_json, + build_tasks_json, + }, + wire::{ + task::TaskStatus, + wire_object::{EntityType, WireItem, WireObject}, + }, +}; + +pub type SdkResult = std::result::Result; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "message", rename_all = "snake_case")] +pub enum ThingsSdkError { + Auth(String), + Sync(String), + Validation(String), + NotFound(String), + CloudCommit(String), + Io(String), +} + +impl std::fmt::Display for ThingsSdkError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Auth(message) + | Self::Sync(message) + | Self::Validation(message) + | Self::NotFound(message) + | Self::CloudCommit(message) + | Self::Io(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for ThingsSdkError {} + +#[derive(Debug, Clone, Default)] +pub struct ThingsServiceConfig { + pub cache_only: bool, + pub dry_run: bool, + pub journal_path: Option, + pub today_ts: Option, + pub now_ts: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthStatus { + pub configured: bool, + pub email: Option, + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MutationResult { + pub ids: Vec, + pub titles: Vec, + pub labels: Vec, + pub head_index: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LogbookQuery { + pub from: Option, + pub to: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AreaQuery { + pub all: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FindQuery { + pub query: Option, + pub notes: bool, + pub checklists: bool, + pub incomplete: bool, + pub completed: bool, + pub canceled: bool, + pub any_status: bool, + pub tags: Vec, + pub projects: Vec, + pub areas: Vec, + pub inbox: bool, + pub today: bool, + pub someday: bool, + pub evening: bool, + pub has_deadline: bool, + pub no_deadline: bool, + pub recurring: bool, + pub deadline: Vec, + pub scheduled: Vec, + pub created: Vec, + pub completed_on: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateTaskRequest { + pub title: String, + pub in_target: Option, + pub when: Option, + pub before_id: Option, + pub after_id: Option, + pub notes: Option, + pub tags: Option, + pub deadline: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct EditTasksRequest { + pub task_ids: Vec, + pub title: Option, + pub notes: Option, + pub move_target: Option, + pub add_tags: Option, + pub remove_tags: Option, + pub add_checklist: Vec, + pub remove_checklist: Option, + pub rename_checklist: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MarkStatus { + Done, + Incomplete, + Canceled, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MarkTasksRequest { + pub task_ids: Vec, + pub status: MarkStatus, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ChecklistStatus { + Checked, + Unchecked, + Canceled, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MutateChecklistRequest { + pub task_id: String, + pub checklist_ids: String, + pub status: ChecklistStatus, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ScheduleTaskRequest { + pub task_id: String, + pub when: Option, + pub deadline: Option, + pub clear_deadline: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ReorderItemRequest { + pub item_id: String, + pub before_id: Option, + pub after_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteItemsRequest { + pub item_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateProjectRequest { + pub title: String, + pub area: Option, + pub when: Option, + pub notes: Option, + pub tags: Option, + pub deadline: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct EditProjectRequest { + pub project_id: String, + pub title: Option, + pub move_target: Option, + pub notes: Option, + pub add_tags: Option, + pub remove_tags: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateAreaRequest { + pub title: String, + pub tags: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct EditAreaRequest { + pub area_id: String, + pub title: Option, + pub add_tags: Option, + pub remove_tags: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateTagRequest { + pub name: String, + pub parent: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct EditTagRequest { + pub tag_id: String, + pub name: Option, + pub move_target: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteTagRequest { + pub tag_id: String, +} + +#[derive(Debug, Clone)] +pub struct ThingsService { + config: ThingsServiceConfig, +} + +impl ThingsService { + pub fn new(config: ThingsServiceConfig) -> Self { + Self { config } + } + + pub fn auth_status(&self) -> AuthStatus { + match load_auth() { + Ok((email, _)) => AuthStatus { + configured: true, + email: Some(email), + message: None, + }, + Err(err) => AuthStatus { + configured: false, + email: None, + message: Some(err.to_string()), + }, + } + } + + pub fn save_auth(&self, email: &str, password: &str) -> SdkResult { + write_auth(email, password).map_err(|err| ThingsSdkError::Auth(err.to_string())) + } + + pub fn load_store(&self) -> SdkResult { + let state = self.load_state()?; + Ok(ThingsStore::from_raw_state(&state)) + } + + pub fn inbox(&self) -> SdkResult> { + let store = self.load_store()?; + Ok(self.tasks_json(&store.inbox(), &store)) + } + + pub fn today(&self) -> SdkResult> { + let store = self.load_store()?; + let today = self.current_day(); + let mut items: Vec<_> = store + .tasks(Some(TaskStatus::Incomplete), Some(false), None) + .into_iter() + .filter(|t| { + !t.is_heading() + && !t.title.trim().is_empty() + && t.entity == "Task6" + && (t.is_today(&today) || t.evening) + }) + .collect(); + items.sort_by_key(|task| { + let tir = task.today_index_reference.unwrap_or(0); + ( + std::cmp::Reverse(tir), + task.today_index, + std::cmp::Reverse(task.index), + ) + }); + Ok(self.tasks_json(&items, &store)) + } + + pub fn upcoming(&self) -> SdkResult> { + let store = self.load_store()?; + let today = self.current_day(); + let now_ts = today.timestamp(); + let mut tasks = Vec::new(); + for task in store.tasks(Some(TaskStatus::Incomplete), Some(false), None) { + if task.in_someday() { + continue; + } + let Some(start_date) = task.start_date else { + continue; + }; + if start_date.timestamp() > now_ts { + tasks.push(task); + } + } + tasks.sort_by_key(|task| task.start_date); + Ok(self.tasks_json(&tasks, &store)) + } + + pub fn anytime(&self) -> SdkResult> { + let store = self.load_store()?; + Ok(self.tasks_json(&store.anytime(&self.current_day()), &store)) + } + + pub fn someday(&self) -> SdkResult> { + let store = self.load_store()?; + Ok(self.tasks_json(&store.someday(), &store)) + } + + pub fn logbook(&self, query: LogbookQuery) -> SdkResult> { + let store = self.load_store()?; + let from = + parse_day(query.from.as_deref(), "--from").map_err(ThingsSdkError::Validation)?; + let to = parse_day(query.to.as_deref(), "--to").map_err(ThingsSdkError::Validation)?; + if let (Some(from), Some(to)) = (from, to) + && from > to + { + return Err(ThingsSdkError::Validation( + "--from date must be before or equal to --to date".to_string(), + )); + } + Ok(self.tasks_json(&store.logbook(from, to), &store)) + } + + pub fn projects(&self) -> SdkResult> { + let store = self.load_store()?; + Ok(self.tasks_json(&store.projects(Some(TaskStatus::Incomplete)), &store)) + } + + pub fn project(&self, project_id: &str) -> SdkResult> { + let store = self.load_store()?; + let (project, err, _) = store.resolve_mark_identifier(project_id); + let Some(project) = project else { + return Err(not_found(err)); + }; + if !project.is_project() { + return Err(ThingsSdkError::Validation(format!( + "Not a project: {}", + project.title + ))); + } + let mut children = store + .tasks(None, Some(false), None) + .into_iter() + .filter(|task| store.effective_project_uuid(task).as_ref() == Some(&project.uuid)) + .collect::>(); + children.sort_by_key(|task| task.index); + Ok(self.tasks_json(&children, &store)) + } + + pub fn areas(&self) -> SdkResult> { + let store = self.load_store()?; + Ok(store + .areas() + .iter() + .map(|area| build_area_json(area, &store)) + .collect()) + } + + pub fn area(&self, area_id: &str, query: AreaQuery) -> SdkResult> { + let store = self.load_store()?; + let (area, err, _) = store.resolve_area_identifier(area_id); + let Some(area) = area else { + return Err(not_found(err)); + }; + + let status_filter = if query.all { + None + } else { + Some(TaskStatus::Incomplete) + }; + let mut items = store + .projects(status_filter) + .into_iter() + .filter(|project| project.area.as_ref() == Some(&area.uuid)) + .collect::>(); + items.extend( + store + .tasks(status_filter, Some(false), None) + .into_iter() + .filter(|task| { + task.area.as_ref() == Some(&area.uuid) + && !task.is_project() + && store.effective_project_uuid(task).is_none() + }), + ); + items.sort_by(|a, b| { + let a_proj = if a.is_project() { 0 } else { 1 }; + let b_proj = if b.is_project() { 0 } else { 1 }; + (a_proj, a.index, &a.uuid).cmp(&(b_proj, b.index, &b.uuid)) + }); + Ok(self.tasks_json(&items, &store)) + } + + pub fn tags(&self) -> SdkResult> { + let store = self.load_store()?; + Ok(build_tags_json(&store.tags(), &store)) + } + + pub fn find(&self, query: FindQuery) -> SdkResult> { + let store = self.load_store()?; + let args = query.into_find_args(); + let tasks = + find_tasks(&store, &args, &self.current_day()).map_err(ThingsSdkError::Validation)?; + Ok(self.tasks_json(&tasks, &store)) + } + + pub fn create_task(&self, request: CreateTaskRequest) -> SdkResult { + let store = self.load_store()?; + let mut ctx = self.ctx(); + let args = NewArgs { + title: request.title, + in_target: request.in_target.unwrap_or_else(|| "inbox".to_string()), + when: request.when, + before_id: request.before_id, + after_id: request.after_id, + notes: request.notes.unwrap_or_default(), + tags: request.tags, + deadline_date: request.deadline, + }; + let now = ctx.now_timestamp(); + let today = ctx.today_timestamp(); + let mut next_id = || ctx.next_id(); + let plan = build_new_plan(&args, &store, now, today, &mut next_id) + .map_err(ThingsSdkError::Validation)?; + let head_index = self.commit(&mut ctx, plan.changes, None)?; + Ok(MutationResult { + ids: vec![plan.new_uuid], + titles: vec![plan.title], + labels: vec!["created".to_string()], + head_index: Some(head_index), + }) + } + + pub fn edit_tasks(&self, request: EditTasksRequest) -> SdkResult { + let store = self.load_store()?; + let mut ctx = self.ctx(); + let args = EditArgs { + task_ids: ids(request.task_ids), + title: request.title, + notes: request.notes, + move_target: request.move_target, + tag_delta: TagDeltaArgs { + add_tags: request.add_tags, + remove_tags: request.remove_tags, + }, + add_checklist: request.add_checklist, + remove_checklist: request.remove_checklist, + rename_checklist: request.rename_checklist, + }; + let now = ctx.now_timestamp(); + let mut next_id = || ctx.next_id(); + let plan = build_edit_plan(&args, &store, now, &mut next_id) + .map_err(ThingsSdkError::Validation)?; + let titles = plan.tasks.iter().map(|task| task.title.clone()).collect(); + let ids = plan + .tasks + .iter() + .map(|task| task.uuid.to_string()) + .collect(); + let head_index = self.commit(&mut ctx, plan.changes, None)?; + Ok(MutationResult { + ids, + titles, + labels: plan.labels, + head_index: Some(head_index), + }) + } + + pub fn mark_tasks(&self, request: MarkTasksRequest) -> SdkResult { + let store = self.load_store()?; + let mut ctx = self.ctx(); + let args = MarkArgs { + task_ids: ids(request.task_ids), + done: matches!(request.status, MarkStatus::Done), + incomplete: matches!(request.status, MarkStatus::Incomplete), + canceled: matches!(request.status, MarkStatus::Canceled), + check_ids: None, + uncheck_ids: None, + check_cancel_ids: None, + }; + let (plan, successes, errors) = build_mark_status_plan(&args, &store, ctx.now_timestamp()); + if !errors.is_empty() { + return Err(ThingsSdkError::Validation(errors.join("; "))); + } + let titles = successes.iter().map(|task| task.title.clone()).collect(); + let ids = successes.iter().map(|task| task.uuid.to_string()).collect(); + let head_index = self.commit(&mut ctx, plan.changes, None)?; + Ok(MutationResult { + ids, + titles, + labels: vec![format!("{:?}", request.status).to_lowercase()], + head_index: Some(head_index), + }) + } + + pub fn mutate_checklist(&self, request: MutateChecklistRequest) -> SdkResult { + let store = self.load_store()?; + let mut ctx = self.ctx(); + let args = MarkArgs { + task_ids: ids(vec![request.task_id.clone()]), + done: false, + incomplete: false, + canceled: false, + check_ids: matches!(request.status, ChecklistStatus::Checked) + .then(|| request.checklist_ids.clone()), + uncheck_ids: matches!(request.status, ChecklistStatus::Unchecked) + .then(|| request.checklist_ids.clone()), + check_cancel_ids: matches!(request.status, ChecklistStatus::Canceled) + .then(|| request.checklist_ids.clone()), + }; + let (task, err, _) = store.resolve_mark_identifier(&request.task_id); + let Some(task) = task else { + return Err(not_found(err)); + }; + if task.checklist_items.is_empty() { + return Err(ThingsSdkError::Validation(format!( + "Task has no checklist items: {}", + task.title + ))); + } + let (plan, items, label) = + build_mark_checklist_plan(&args, &task, &request.checklist_ids, ctx.now_timestamp()) + .map_err(ThingsSdkError::Validation)?; + let head_index = self.commit(&mut ctx, plan.changes, None)?; + Ok(MutationResult { + ids: items.iter().map(|item| item.uuid.to_string()).collect(), + titles: items.iter().map(|item| item.title.clone()).collect(), + labels: vec![label], + head_index: Some(head_index), + }) + } + + pub fn schedule_task(&self, request: ScheduleTaskRequest) -> SdkResult { + let store = self.load_store()?; + let mut ctx = self.ctx(); + let args = ScheduleArgs { + task_id: request.task_id, + when: request.when, + deadline_date: request.deadline, + clear_deadline: request.clear_deadline, + }; + let plan = build_schedule_plan(&args, &store, ctx.now_timestamp(), ctx.today_timestamp()) + .map_err(ThingsSdkError::Validation)?; + let mut changes = BTreeMap::new(); + changes.insert( + plan.task.uuid.to_string(), + WireObject::update(EntityType::from(plan.task.entity.clone()), plan.update), + ); + let head_index = self.commit(&mut ctx, changes, None)?; + Ok(MutationResult { + ids: vec![plan.task.uuid.to_string()], + titles: vec![plan.task.title], + labels: plan.labels, + head_index: Some(head_index), + }) + } + + pub fn reorder_item(&self, request: ReorderItemRequest) -> SdkResult { + let store = self.load_store()?; + let mut ctx = self.ctx(); + let args = ReorderArgs { + item_id: request.item_id, + before_id: request.before_id, + after_id: request.after_id, + }; + let plan = build_reorder_plan( + &args, + &store, + ctx.now_timestamp(), + ctx.today_timestamp(), + None, + ) + .map_err(ThingsSdkError::Validation)?; + let mut last_head = None; + for commit in plan.commits { + last_head = Some(self.commit(&mut ctx, commit.changes, commit.ancestor_index)?); + } + Ok(MutationResult { + ids: vec![plan.item.uuid.to_string()], + titles: vec![plan.item.title], + labels: vec![plan.reorder_label], + head_index: last_head, + }) + } + + pub fn delete_items(&self, request: DeleteItemsRequest) -> SdkResult { + let store = self.load_store()?; + let mut ctx = self.ctx(); + let plan = build_delete_plan( + &DeleteArgs { + item_ids: ids(request.item_ids), + }, + &store, + ); + if plan.targets.is_empty() { + return Err(ThingsSdkError::NotFound( + "No matching items to delete.".to_string(), + )); + } + let ids = plan.targets.iter().map(|(id, _, _)| id.clone()).collect(); + let titles = plan + .targets + .iter() + .map(|(_, _, title)| title.clone()) + .collect(); + let head_index = self.commit(&mut ctx, plan.changes, None)?; + Ok(MutationResult { + ids, + titles, + labels: vec!["deleted".to_string()], + head_index: Some(head_index), + }) + } + + pub fn create_project(&self, request: CreateProjectRequest) -> SdkResult { + let store = self.load_store()?; + let mut ctx = self.ctx(); + let args = ProjectsNewArgs { + title: request.title, + area: request.area, + when: request.when, + notes: request.notes.unwrap_or_default(), + tags: request.tags, + deadline_date: request.deadline, + }; + let now = ctx.now_timestamp(); + let today = ctx.today_timestamp(); + let mut next_id = || ctx.next_id(); + let plan = build_project_new_plan(&args, &store, now, today, &mut next_id) + .map_err(|err| project_create_error(&store, &args, err))?; + let head_index = self.commit(&mut ctx, plan.changes, None)?; + Ok(MutationResult { + ids: vec![plan.uuid], + titles: vec![plan.title], + labels: vec!["created".to_string()], + head_index: Some(head_index), + }) + } + + pub fn edit_project(&self, request: EditProjectRequest) -> SdkResult { + let store = self.load_store()?; + let mut ctx = self.ctx(); + let args = ProjectsEditArgs { + project_id: request.project_id, + title: request.title, + move_target: request.move_target, + notes: request.notes, + tag_delta: TagDeltaArgs { + add_tags: request.add_tags, + remove_tags: request.remove_tags, + }, + }; + let plan = build_projects_edit_plan(&args, &store, ctx.now_timestamp()) + .map_err(ThingsSdkError::Validation)?; + let mut changes = BTreeMap::new(); + changes.insert( + plan.project.uuid.to_string(), + WireObject::update(EntityType::from(plan.project.entity.clone()), plan.update), + ); + let head_index = self.commit(&mut ctx, changes, None)?; + Ok(MutationResult { + ids: vec![plan.project.uuid.to_string()], + titles: vec![plan.project.title], + labels: plan.labels, + head_index: Some(head_index), + }) + } + + pub fn create_area(&self, request: CreateAreaRequest) -> SdkResult { + let store = self.load_store()?; + let mut ctx = self.ctx(); + let args = AreasNewArgs { + title: request.title, + tags: request.tags, + }; + let mut next_id = || ctx.next_id(); + let plan = + build_area_new_plan(&args, &store, &mut next_id).map_err(ThingsSdkError::Validation)?; + let head_index = self.commit(&mut ctx, plan.changes, None)?; + Ok(MutationResult { + ids: vec![plan.uuid], + titles: vec![plan.title], + labels: vec!["created".to_string()], + head_index: Some(head_index), + }) + } + + pub fn edit_area(&self, request: EditAreaRequest) -> SdkResult { + let store = self.load_store()?; + let mut ctx = self.ctx(); + let args = AreasEditArgs { + area_id: request.area_id, + title: request.title, + tag_delta: TagDeltaArgs { + add_tags: request.add_tags, + remove_tags: request.remove_tags, + }, + }; + let plan = build_areas_edit_plan(&args, &store, ctx.now_timestamp()) + .map_err(ThingsSdkError::Validation)?; + let mut changes = BTreeMap::new(); + changes.insert( + plan.area.uuid.to_string(), + WireObject::update(EntityType::Area3, plan.update), + ); + let head_index = self.commit(&mut ctx, changes, None)?; + Ok(MutationResult { + ids: vec![plan.area.uuid.to_string()], + titles: vec![plan.area.title], + labels: plan.labels, + head_index: Some(head_index), + }) + } + + pub fn create_tag(&self, request: CreateTagRequest) -> SdkResult { + let store = self.load_store()?; + let mut ctx = self.ctx(); + let args = TagsNewArgs { + name: request.name, + parent: request.parent, + }; + let mut next_id = || ctx.next_id(); + let plan = + build_tag_new_plan(&args, &store, &mut next_id).map_err(ThingsSdkError::Validation)?; + let head_index = self.commit(&mut ctx, plan.changes, None)?; + Ok(MutationResult { + ids: vec![plan.uuid], + titles: vec![plan.name], + labels: vec!["created".to_string()], + head_index: Some(head_index), + }) + } + + pub fn edit_tag(&self, request: EditTagRequest) -> SdkResult { + let store = self.load_store()?; + let mut ctx = self.ctx(); + let args = TagsEditArgs { + tag_id: request.tag_id, + name: request.name, + move_target: request.move_target, + }; + let plan = build_tags_edit_plan(&args, &store, ctx.now_timestamp()) + .map_err(ThingsSdkError::Validation)?; + let mut changes = BTreeMap::new(); + changes.insert( + plan.tag.uuid.to_string(), + WireObject::update(EntityType::Tag4, plan.update), + ); + let head_index = self.commit(&mut ctx, changes, None)?; + Ok(MutationResult { + ids: vec![plan.tag.uuid.to_string()], + titles: vec![plan.tag.title], + labels: plan.labels, + head_index: Some(head_index), + }) + } + + pub fn delete_tag(&self, request: DeleteTagRequest) -> SdkResult { + let store = self.load_store()?; + let mut ctx = self.ctx(); + let args = TagsDeleteArgs { + tag_id: request.tag_id, + }; + let (tag, err) = resolve_single_tag(&store, &args.tag_id); + let Some(tag) = tag else { + return Err(not_found(err)); + }; + let mut changes = BTreeMap::new(); + changes.insert(tag.uuid.to_string(), WireObject::delete(EntityType::Tag4)); + let head_index = self.commit(&mut ctx, changes, None)?; + Ok(MutationResult { + ids: vec![tag.uuid.to_string()], + titles: vec![tag.title], + labels: vec!["deleted".to_string()], + head_index: Some(head_index), + }) + } + + fn load_state(&self) -> SdkResult { + if let Some(journal_path) = &self.config.journal_path { + let mut raw = String::new(); + if journal_path == std::path::Path::new("-") { + std::io::stdin() + .read_to_string(&mut raw) + .map_err(|err| ThingsSdkError::Io(err.to_string()))?; + } else { + raw = fs::read_to_string(journal_path) + .map_err(|err| ThingsSdkError::Io(err.to_string()))?; + }; + let items: Vec = + serde_json::from_str(&raw).map_err(|err| ThingsSdkError::Sync(err.to_string()))?; + return Ok(fold_items(items)); + } + + if self.config.cache_only || self.config.dry_run { + return fold_state_from_append_log(&append_log_dir()) + .map_err(|err| ThingsSdkError::Sync(err.to_string())); + } + + let (email, password) = load_auth().map_err(|err| ThingsSdkError::Auth(err.to_string()))?; + let mut client = ThingsCloudClient::new(email, password) + .map_err(|err| ThingsSdkError::Auth(err.to_string()))?; + get_state_with_append_log(&mut client, append_log_dir()) + .map_err(|err| ThingsSdkError::Sync(err.to_string())) + } + + fn tasks_json(&self, tasks: &[Task], store: &ThingsStore) -> Vec { + build_tasks_json(tasks, store, &self.current_day()) + } + + fn current_day(&self) -> chrono::DateTime { + let ts = self + .config + .today_ts + .unwrap_or_else(|| crate::common::today_utc().timestamp()); + Utc.timestamp_opt(ts, 0) + .single() + .unwrap_or_else(crate::common::today_utc) + } + + fn ctx(&self) -> DefaultCmdCtx { + DefaultCmdCtx::from_cli(&Cli { + no_color: true, + json: true, + no_sync: self.config.cache_only || self.config.dry_run, + no_cloud: self.config.dry_run, + log_level: logging::Level::Info, + log_format: logging::LogFormat::Auto, + log_filter: None, + today_ts: self.config.today_ts, + now_ts: self.config.now_ts, + load_journal: self.config.journal_path.clone(), + command: None, + }) + } + + fn commit( + &self, + ctx: &mut DefaultCmdCtx, + changes: BTreeMap, + ancestor_index: Option, + ) -> SdkResult { + ctx.commit_changes(changes, ancestor_index) + .map_err(|err| ThingsSdkError::CloudCommit(err.to_string())) + } +} + +impl FindQuery { + fn into_find_args(self) -> FindArgs { + FindArgs { + detailed: DetailedArgs { detailed: false }, + query: self.query, + incomplete: self.incomplete, + notes: self.notes, + checklists: self.checklists, + completed: self.completed, + canceled: self.canceled, + any_status: self.any_status, + tag_filters: ids(self.tags), + project_filters: ids(self.projects), + area_filters: ids(self.areas), + inbox: self.inbox, + today: self.today, + someday: self.someday, + evening: self.evening, + has_deadline: self.has_deadline, + no_deadline: self.no_deadline, + recurring: self.recurring, + deadline: self.deadline, + scheduled: self.scheduled, + created: self.created, + completed_on: self.completed_on, + } + } +} + +fn ids(values: Vec) -> Vec { + values.into_iter().map(IdentifierToken::from).collect() +} + +fn not_found(message: String) -> ThingsSdkError { + ThingsSdkError::NotFound(message) +} + +fn project_create_error( + store: &ThingsStore, + args: &ProjectsNewArgs, + err: String, +) -> ThingsSdkError { + if let Some(area_id) = &args.area { + let (area, area_err, _) = store.resolve_area_identifier(area_id); + if area.is_none() && area_err == err { + return not_found(err); + } + } + ThingsSdkError::Validation(err) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(path: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path) + } + + #[test] + fn sdk_reads_today_view_from_journal_fixture() { + let service = ThingsService::new(ThingsServiceConfig { + journal_path: Some(fixture("trycmd/today/basic_list.in/journal.json")), + today_ts: Some(1_774_396_800), + ..Default::default() + }); + + let tasks = service.today().expect("today view"); + let value = serde_json::to_value(tasks).expect("serialize tasks"); + let titles = value + .as_array() + .expect("array") + .iter() + .map(|task| task["title"].as_str().expect("title").to_string()) + .collect::>(); + + assert_eq!(titles, vec!["Morning workout", "Read email"]); + } + + #[test] + fn sdk_can_plan_create_task_in_dry_run_mode() { + let service = ThingsService::new(ThingsServiceConfig { + dry_run: true, + journal_path: Some(fixture("trycmd/today/basic_list.in/journal.json")), + today_ts: Some(1_700_000_000), + now_ts: Some(1_700_000_000.0), + ..Default::default() + }); + + let result = service + .create_task(CreateTaskRequest { + title: "Ship release".to_string(), + in_target: None, + when: None, + before_id: None, + after_id: None, + notes: None, + tags: None, + deadline: None, + }) + .expect("create task"); + + assert_eq!(result.titles, vec!["Ship release"]); + assert_eq!(result.labels, vec!["created"]); + assert_eq!(result.head_index, Some(1)); + assert_eq!(result.ids.len(), 1); + } +} diff --git a/src/store/entities.rs b/src/store/entities.rs index d8f0bdf..dbee388 100644 --- a/src/store/entities.rs +++ b/src/store/entities.rs @@ -341,7 +341,7 @@ impl From for TaskStateProps { task.title = title; } if let Some(notes) = patch.notes { - task.notes = notes.to_plain_text(); + task.notes = notes.and_then(|notes| notes.to_plain_text()); } if let Some(start_location) = patch.start_location { task.start_location = start_location; diff --git a/src/store/state.rs b/src/store/state.rs index 3d4ed17..21e707d 100644 --- a/src/store/state.rs +++ b/src/store/state.rs @@ -22,7 +22,7 @@ fn apply_task_patch(task: &mut TaskStateProps, patch: TaskPatch) { task.title = title; } if let Some(notes) = patch.notes { - task.notes = notes.to_plain_text(); + task.notes = notes.and_then(|notes| notes.to_plain_text()); } if let Some(start_location) = patch.start_location { task.start_location = start_location; diff --git a/src/ui/views/json/common.rs b/src/ui/views/json/common.rs index 1f585d8..7cf726f 100644 --- a/src/ui/views/json/common.rs +++ b/src/ui/views/json/common.rs @@ -10,17 +10,17 @@ use crate::{ #[derive(Debug, Serialize)] pub struct ResolvedTaskJson { #[serde(flatten)] - core: TaskCoreJson, + pub core: TaskCoreJson, #[serde(flatten)] - links: TaskLinksJson, - dates: TaskDatesJson, - notes: Option, - checklist: Vec, - recurrence: TaskRecurrenceJson, - flags: TaskFlagsJson, - indexes: TaskIndexesJson, + pub links: TaskLinksJson, + pub dates: TaskDatesJson, + pub notes: Option, + pub checklist: Vec, + pub recurrence: TaskRecurrenceJson, + pub flags: TaskFlagsJson, + pub indexes: TaskIndexesJson, #[serde(skip_serializing_if = "Option::is_none")] - progress: Option, + pub progress: Option, } #[derive(Debug, Serialize)] @@ -43,9 +43,9 @@ pub struct ResolvedTagJson { } #[derive(Debug, Serialize)] -struct TaskProgressJson { - done: i32, - total: i32, +pub struct TaskProgressJson { + pub done: i32, + pub total: i32, } #[derive(Debug, Serialize)] diff --git a/src/wire/task.rs b/src/wire/task.rs index 956b88f..3bf7c5a 100644 --- a/src/wire/task.rs +++ b/src/wire/task.rs @@ -180,9 +180,14 @@ pub struct TaskPatch { #[serde(rename = "tt", skip_serializing_if = "Option::is_none")] pub title: Option, - /// `nt`: notes payload. - #[serde(rename = "nt", skip_serializing_if = "Option::is_none")] - pub notes: Option, + /// `nt`: notes payload (`null` clears notes). + #[serde( + rename = "nt", + default, + deserialize_with = "deserialize_optional_field", + skip_serializing_if = "Option::is_none" + )] + pub notes: Option>, /// `st`: start location. #[serde(rename = "st", skip_serializing_if = "Option::is_none")] diff --git a/src/wire/wire_object.rs b/src/wire/wire_object.rs index c051683..6ef5b9c 100644 --- a/src/wire/wire_object.rs +++ b/src/wire/wire_object.rs @@ -25,6 +25,7 @@ pub struct WireObject { pub payload: Properties, } +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq)] pub enum Properties { TaskCreate(TaskProps), diff --git a/trycmd/run.sh b/trycmd/run.sh index c888d7b..d2665e2 100755 --- a/trycmd/run.sh +++ b/trycmd/run.sh @@ -71,6 +71,7 @@ if [[ ${#argv[@]} -gt 0 && "${argv[0]}" == "things3" ]]; then if [[ ${#globals[@]} -gt 0 ]]; then argv=("${argv[0]}" "${globals[@]}" "${argv[@]:1}") fi + export THINGS3_LOG_COMMIT_PAYLOADS=1 fi stderr_file="$(mktemp)"