Skip to main content

things3_cloud/wire/
task.rs

1use crate::ids::ThingsId;
2use crate::wire::deserialize_optional_field;
3use crate::wire::notes::TaskNotes;
4use crate::wire::recurrence::RecurrenceRule;
5use num_enum::{FromPrimitive, IntoPrimitive};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::BTreeMap;
9use strum::{Display, EnumString};
10
11/// Task wire properties (`p` fields for `Task6`).
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
13pub struct TaskProps {
14    /// `tt`: title.
15    #[serde(rename = "tt", default)]
16    pub title: String,
17
18    /// `nt`: notes payload (legacy XML or modern structured text object).
19    #[serde(rename = "nt", default)]
20    pub notes: Option<TaskNotes>,
21
22    /// `tp`: task type (`Todo`, `Project`, `Heading`).
23    #[serde(rename = "tp", default)]
24    pub item_type: TaskType,
25
26    /// `ss`: task status (`Incomplete`, `Canceled`, `Completed`).
27    #[serde(rename = "ss", default)]
28    pub status: TaskStatus,
29
30    /// `sp`: completion/cancellation timestamp.
31    #[serde(rename = "sp", default)]
32    pub stop_date: Option<f64>,
33
34    /// `st`: list location (`Inbox`, `Anytime`, `Someday`).
35    #[serde(rename = "st", default)]
36    pub start_location: TaskStart,
37
38    /// `sr`: scheduled/start day timestamp.
39    #[serde(rename = "sr", default)]
40    pub scheduled_date: Option<i64>,
41
42    /// `tir`: today index reference day timestamp.
43    #[serde(rename = "tir", default)]
44    pub today_index_reference: Option<i64>,
45
46    /// `dd`: deadline day timestamp.
47    #[serde(rename = "dd", default)]
48    pub deadline: Option<i64>,
49
50    /// `dds`: deadline suppressed day timestamp (rare/usually null in observed data).
51    #[serde(rename = "dds", default)]
52    pub deadline_suppressed_date: Option<Value>,
53
54    /// `pr`: parent project IDs (typically 0 or 1).
55    #[serde(rename = "pr", default)]
56    pub parent_project_ids: Vec<ThingsId>,
57
58    /// `ar`: area IDs (typically 0 or 1).
59    #[serde(rename = "ar", default)]
60    pub area_ids: Vec<ThingsId>,
61
62    /// `agr`: heading/action-group IDs (typically 0 or 1).
63    #[serde(rename = "agr", default)]
64    pub action_group_ids: Vec<ThingsId>,
65
66    /// `tg`: applied tag IDs.
67    #[serde(rename = "tg", default)]
68    pub tag_ids: Vec<ThingsId>,
69
70    /// `ix`: structural sort index in its container.
71    #[serde(rename = "ix", default)]
72    pub sort_index: i32,
73
74    /// `ti`: Today-view sort index.
75    #[serde(rename = "ti", default)]
76    pub today_sort_index: i32,
77
78    /// `do`: due date offset (observed as `0` in typical payloads).
79    #[serde(rename = "do", default)]
80    pub due_date_offset: i32,
81
82    /// `rr`: recurrence rule object (`null` for non-recurring).
83    #[serde(rename = "rr", default)]
84    pub recurrence_rule: Option<RecurrenceRule>,
85
86    /// `rt`: recurrence template IDs (instance -> template link).
87    #[serde(rename = "rt", default)]
88    pub recurrence_template_ids: Vec<ThingsId>,
89
90    /// `icsd`: instance creation suppressed date timestamp for recurrence templates.
91    #[serde(rename = "icsd", default)]
92    pub instance_creation_suppressed_date: Option<i64>,
93
94    /// `acrd`: after-completion reference date timestamp for recurrence scheduling.
95    #[serde(rename = "acrd", default)]
96    pub after_completion_reference_date: Option<i64>,
97
98    /// `icc`: checklist item count.
99    #[serde(rename = "icc", default)]
100    pub checklist_item_count: i32,
101
102    /// `icp`: instance creation paused flag.
103    #[serde(rename = "icp", default)]
104    pub instance_creation_paused: bool,
105
106    /// `ato`: alarm time offset in seconds from day start.
107    #[serde(rename = "ato", default)]
108    pub alarm_time_offset: Option<i64>,
109
110    /// `lai`: last alarm interaction timestamp.
111    #[serde(rename = "lai", default)]
112    pub last_alarm_interaction: Option<f64>,
113
114    /// `sb`: evening section bit (`1` evening, `0` normal).
115    #[serde(rename = "sb", default)]
116    pub evening_bit: i32,
117
118    /// `lt`: leaves tombstone when deleted.
119    #[serde(rename = "lt", default)]
120    pub leaves_tombstone: bool,
121
122    /// `tr`: trashed state.
123    #[serde(rename = "tr", default)]
124    pub trashed: bool,
125
126    /// `dl`: deadline list metadata (rarely used, often empty).
127    #[serde(rename = "dl", default)]
128    pub deadline_list: Vec<Value>,
129
130    /// `xx`: conflict override metadata (CRDT internals).
131    #[serde(rename = "xx", default)]
132    pub conflict_overrides: Option<Value>,
133
134    /// `cd`: creation timestamp.
135    #[serde(rename = "cd", default)]
136    pub creation_date: Option<f64>,
137
138    /// `md`: last user-modification timestamp.
139    #[serde(rename = "md", default)]
140    pub modification_date: Option<f64>,
141}
142
143/// Sparse patch fields for Task `t=1` updates.
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
145pub struct TaskPatch {
146    /// `tt`: title.
147    #[serde(rename = "tt", skip_serializing_if = "Option::is_none")]
148    pub title: Option<String>,
149
150    /// `nt`: notes payload.
151    #[serde(rename = "nt", skip_serializing_if = "Option::is_none")]
152    pub notes: Option<TaskNotes>,
153
154    /// `st`: start location.
155    #[serde(rename = "st", skip_serializing_if = "Option::is_none")]
156    pub start_location: Option<TaskStart>,
157
158    /// `sr`: scheduled day timestamp (`null` clears date).
159    #[serde(
160        rename = "sr",
161        default,
162        deserialize_with = "deserialize_optional_field",
163        skip_serializing_if = "Option::is_none"
164    )]
165    pub scheduled_date: Option<Option<i64>>,
166
167    /// `tir`: today reference day timestamp (`null` clears today placement).
168    #[serde(
169        rename = "tir",
170        default,
171        deserialize_with = "deserialize_optional_field",
172        skip_serializing_if = "Option::is_none"
173    )]
174    pub today_index_reference: Option<Option<i64>>,
175
176    /// `pr`: parent project IDs.
177    #[serde(rename = "pr", skip_serializing_if = "Option::is_none")]
178    pub parent_project_ids: Option<Vec<ThingsId>>,
179
180    /// `ar`: area IDs.
181    #[serde(rename = "ar", skip_serializing_if = "Option::is_none")]
182    pub area_ids: Option<Vec<ThingsId>>,
183
184    /// `agr`: heading/action-group IDs.
185    #[serde(rename = "agr", skip_serializing_if = "Option::is_none")]
186    pub action_group_ids: Option<Vec<ThingsId>>,
187
188    /// `tg`: tag IDs.
189    #[serde(rename = "tg", skip_serializing_if = "Option::is_none")]
190    pub tag_ids: Option<Vec<ThingsId>>,
191
192    /// `sb`: evening section bit (`1` evening, `0` normal).
193    #[serde(rename = "sb", skip_serializing_if = "Option::is_none")]
194    pub evening_bit: Option<i32>,
195
196    /// `tp`: task type.
197    #[serde(rename = "tp", skip_serializing_if = "Option::is_none")]
198    pub item_type: Option<TaskType>,
199
200    /// `ss`: task status.
201    #[serde(rename = "ss", skip_serializing_if = "Option::is_none")]
202    pub status: Option<TaskStatus>,
203
204    /// `sp`: completion/cancellation timestamp.
205    #[serde(
206        rename = "sp",
207        default,
208        deserialize_with = "deserialize_optional_field",
209        skip_serializing_if = "Option::is_none"
210    )]
211    pub stop_date: Option<Option<f64>>,
212
213    /// `dd`: deadline timestamp.
214    #[serde(
215        rename = "dd",
216        default,
217        deserialize_with = "deserialize_optional_field",
218        skip_serializing_if = "Option::is_none"
219    )]
220    pub deadline: Option<Option<f64>>,
221
222    /// `ix`: sort index.
223    #[serde(rename = "ix", skip_serializing_if = "Option::is_none")]
224    pub sort_index: Option<i32>,
225
226    /// `ti`: today sort index.
227    #[serde(rename = "ti", skip_serializing_if = "Option::is_none")]
228    pub today_sort_index: Option<i32>,
229
230    /// `rr`: recurrence rule.
231    #[serde(
232        rename = "rr",
233        default,
234        deserialize_with = "deserialize_optional_field",
235        skip_serializing_if = "Option::is_none"
236    )]
237    pub recurrence_rule: Option<Option<RecurrenceRule>>,
238
239    /// `rt`: recurrence template IDs.
240    #[serde(rename = "rt", skip_serializing_if = "Option::is_none")]
241    pub recurrence_template_ids: Option<Vec<ThingsId>>,
242
243    /// `icp`: instance creation paused.
244    #[serde(rename = "icp", skip_serializing_if = "Option::is_none")]
245    pub instance_creation_paused: Option<bool>,
246
247    /// `lt`: leaves tombstone.
248    #[serde(rename = "lt", skip_serializing_if = "Option::is_none")]
249    pub leaves_tombstone: Option<bool>,
250
251    /// `tr`: trashed.
252    #[serde(rename = "tr", skip_serializing_if = "Option::is_none")]
253    pub trashed: Option<bool>,
254
255    /// `cd`: creation timestamp.
256    #[serde(
257        rename = "cd",
258        default,
259        deserialize_with = "deserialize_optional_field",
260        skip_serializing_if = "Option::is_none"
261    )]
262    pub creation_date: Option<Option<f64>>,
263
264    /// `md`: modification timestamp.
265    #[serde(rename = "md", skip_serializing_if = "Option::is_none")]
266    pub modification_date: Option<f64>,
267}
268
269impl TaskPatch {
270    pub fn is_empty(&self) -> bool {
271        self.title.is_none()
272            && self.notes.is_none()
273            && self.start_location.is_none()
274            && self.scheduled_date.is_none()
275            && self.today_index_reference.is_none()
276            && self.parent_project_ids.is_none()
277            && self.area_ids.is_none()
278            && self.action_group_ids.is_none()
279            && self.tag_ids.is_none()
280            && self.evening_bit.is_none()
281            && self.item_type.is_none()
282            && self.status.is_none()
283            && self.stop_date.is_none()
284            && self.deadline.is_none()
285            && self.sort_index.is_none()
286            && self.today_sort_index.is_none()
287            && self.recurrence_rule.is_none()
288            && self.recurrence_template_ids.is_none()
289            && self.instance_creation_paused.is_none()
290            && self.leaves_tombstone.is_none()
291            && self.trashed.is_none()
292            && self.creation_date.is_none()
293            && self.modification_date.is_none()
294    }
295
296    pub fn into_properties(self) -> BTreeMap<String, Value> {
297        match serde_json::to_value(self) {
298            Ok(Value::Object(map)) => map.into_iter().collect(),
299            _ => BTreeMap::new(),
300        }
301    }
302}
303
304/// Task kind used in `tp`.
305#[derive(
306    Debug,
307    Clone,
308    Copy,
309    Serialize,
310    Deserialize,
311    PartialEq,
312    Eq,
313    Display,
314    EnumString,
315    FromPrimitive,
316    IntoPrimitive,
317)]
318#[repr(i32)]
319#[serde(from = "i32", into = "i32")]
320pub enum TaskType {
321    /// Regular leaf task.
322    Todo = 0,
323    /// Project container.
324    Project = 1,
325    /// Heading/section under a project.
326    Heading = 2,
327
328    /// Unknown value preserved for forward compatibility.
329    #[num_enum(catch_all)]
330    #[strum(disabled, to_string = "{0}")]
331    Unknown(i32),
332}
333
334#[expect(
335    clippy::derivable_impls,
336    reason = "num_enum(catch_all) conflicts with #[default]"
337)]
338impl Default for TaskType {
339    fn default() -> Self {
340        Self::Todo
341    }
342}
343
344/// Task status used in `ss`.
345#[derive(
346    Debug,
347    Clone,
348    Copy,
349    Serialize,
350    Deserialize,
351    PartialEq,
352    Eq,
353    Display,
354    EnumString,
355    FromPrimitive,
356    IntoPrimitive,
357)]
358#[repr(i32)]
359#[serde(from = "i32", into = "i32")]
360pub enum TaskStatus {
361    /// Open/incomplete.
362    Incomplete = 0,
363    /// Canceled.
364    Canceled = 2,
365    /// Completed.
366    Completed = 3,
367
368    /// Unknown value preserved for forward compatibility.
369    #[num_enum(catch_all)]
370    #[strum(disabled, to_string = "{0}")]
371    Unknown(i32),
372}
373
374#[expect(
375    clippy::derivable_impls,
376    reason = "num_enum(catch_all) conflicts with #[default]"
377)]
378impl Default for TaskStatus {
379    fn default() -> Self {
380        Self::Incomplete
381    }
382}
383
384/// Start location used in `st`.
385#[derive(
386    Debug,
387    Clone,
388    Copy,
389    Serialize,
390    Deserialize,
391    PartialEq,
392    Eq,
393    Display,
394    EnumString,
395    FromPrimitive,
396    IntoPrimitive,
397)]
398#[repr(i32)]
399#[serde(from = "i32", into = "i32")]
400pub enum TaskStart {
401    /// Inbox list.
402    Inbox = 0,
403    /// Anytime list.
404    Anytime = 1,
405    /// Someday list.
406    Someday = 2,
407
408    /// Unknown value preserved for forward compatibility.
409    #[num_enum(catch_all)]
410    #[strum(disabled, to_string = "{0}")]
411    Unknown(i32),
412}
413
414#[expect(
415    clippy::derivable_impls,
416    reason = "num_enum(catch_all) conflicts with #[default]"
417)]
418impl Default for TaskStart {
419    fn default() -> Self {
420        Self::Inbox
421    }
422}