Skip to main content

things3_cloud/wire/
task.rs

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