Skip to main content

things3_cloud/wire/
task.rs

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