Skip to main content

things3_cloud/wire/
wire_object.rs

1use crate::wire::area::{AreaPatch, AreaProps};
2use crate::wire::checklist::{ChecklistItemPatch, ChecklistItemProps};
3use crate::wire::tags::{TagPatch, TagProps};
4use crate::wire::task::{TaskPatch, TaskProps};
5use crate::wire::tombstone::{CommandProps, TombstoneProps};
6use num_enum::{FromPrimitive, IntoPrimitive};
7use serde::de::DeserializeOwned;
8use serde::ser::SerializeStruct;
9use serde::{Deserialize, Deserializer, Serialize, Serializer};
10use serde_json::Value;
11use std::collections::BTreeMap;
12use strum::{Display, EnumString};
13
14pub type WireItem = BTreeMap<String, WireObject>;
15
16/// A single wire object entry keyed by UUID.
17#[derive(Debug, Clone, PartialEq)]
18pub struct WireObject {
19    pub operation_type: OperationType,
20    pub entity_type: Option<EntityType>,
21    pub payload: Properties,
22}
23
24#[derive(Debug, Clone, PartialEq)]
25pub enum Properties {
26    TaskCreate(TaskProps),
27    TaskUpdate(TaskPatch),
28    ChecklistCreate(ChecklistItemProps),
29    ChecklistUpdate(ChecklistItemPatch),
30    TagCreate(TagProps),
31    TagUpdate(TagPatch),
32    AreaCreate(AreaProps),
33    AreaUpdate(AreaPatch),
34    TombstoneCreate(TombstoneProps),
35    CommandCreate(CommandProps),
36    Delete,
37    /// Known entity families we intentionally skip materializing in store state.
38    Ignored(BTreeMap<String, Value>),
39    /// Unknown/unsupported entity payload preserved for forward compatibility.
40    Unknown(BTreeMap<String, Value>),
41}
42
43impl From<BTreeMap<String, Value>> for Properties {
44    fn from(value: BTreeMap<String, Value>) -> Self {
45        Self::Unknown(value)
46    }
47}
48
49macro_rules! impl_properties_from {
50    ($($source:ty => $variant:ident),+ $(,)?) => {
51        $(
52            impl From<$source> for Properties {
53                fn from(value: $source) -> Self {
54                    Self::$variant(value)
55                }
56            }
57        )+
58    };
59}
60
61impl_properties_from!(
62    TaskProps => TaskCreate,
63    TaskPatch => TaskUpdate,
64    ChecklistItemProps => ChecklistCreate,
65    ChecklistItemPatch => ChecklistUpdate,
66    TagProps => TagCreate,
67    TagPatch => TagUpdate,
68    AreaProps => AreaCreate,
69    AreaPatch => AreaUpdate,
70    TombstoneProps => TombstoneCreate,
71    CommandProps => CommandCreate,
72);
73
74impl WireObject {
75    pub fn properties(&self) -> Result<Properties, serde_json::Error> {
76        match &self.payload {
77            Properties::Unknown(map) => WireObject::properties_from(
78                self.operation_type,
79                self.entity_type.as_ref(),
80                map.clone(),
81            ),
82            other => Ok(other.clone()),
83        }
84    }
85
86    pub fn properties_map(&self) -> BTreeMap<String, Value> {
87        match &self.payload {
88            Properties::TaskCreate(props) => to_map(props),
89            Properties::TaskUpdate(patch) => to_map(patch),
90            Properties::ChecklistCreate(props) => to_map(props),
91            Properties::ChecklistUpdate(patch) => to_map(patch),
92            Properties::TagCreate(props) => to_map(props),
93            Properties::TagUpdate(patch) => to_map(patch),
94            Properties::AreaCreate(props) => to_map(props),
95            Properties::AreaUpdate(patch) => to_map(patch),
96            Properties::TombstoneCreate(props) => to_map(props),
97            Properties::CommandCreate(props) => to_map(props),
98            Properties::Delete => BTreeMap::new(),
99            Properties::Ignored(map) => map.clone(),
100            Properties::Unknown(map) => map.clone(),
101        }
102    }
103
104    pub fn create(entity_type: EntityType, payload: impl Into<Properties>) -> Self {
105        let payload =
106            Self::coerce_known_payload(OperationType::Create, &entity_type, payload.into());
107        Self {
108            operation_type: OperationType::Create,
109            entity_type: Some(entity_type),
110            payload,
111        }
112    }
113
114    pub fn update(entity_type: EntityType, payload: impl Into<Properties>) -> Self {
115        let payload =
116            Self::coerce_known_payload(OperationType::Update, &entity_type, payload.into());
117        Self {
118            operation_type: OperationType::Update,
119            entity_type: Some(entity_type),
120            payload,
121        }
122    }
123
124    pub fn delete(entity_type: EntityType) -> Self {
125        Self {
126            operation_type: OperationType::Delete,
127            entity_type: Some(entity_type),
128            payload: Properties::Delete,
129        }
130    }
131
132    fn properties_from(
133        operation_type: OperationType,
134        entity_type: Option<&EntityType>,
135        properties: BTreeMap<String, Value>,
136    ) -> Result<Properties, serde_json::Error> {
137        use EntityType::*;
138        use Properties::*;
139        let p = properties;
140
141        fn parse<T: DeserializeOwned>(
142            properties: BTreeMap<String, Value>,
143        ) -> Result<T, serde_json::Error> {
144            parse_props_from_map(properties)
145        }
146
147        let payload = match operation_type {
148            OperationType::Delete => Delete,
149            OperationType::Create => match entity_type {
150                Some(Task3 | Task4 | Task6) => TaskCreate(parse(p)?),
151                Some(ChecklistItem | ChecklistItem2 | ChecklistItem3) => ChecklistCreate(parse(p)?),
152                Some(Tag3 | Tag4) => TagCreate(parse(p)?),
153                Some(Area2 | Area3) => AreaCreate(parse(p)?),
154                Some(Tombstone | Tombstone2) => TombstoneCreate(parse(p)?),
155                Some(Command) => CommandCreate(parse(p)?),
156                Some(Settings3 | Settings4 | Settings5) => Ignored(p),
157                _ => Properties::Unknown(p),
158            },
159            OperationType::Update => match entity_type {
160                Some(Task3 | Task4 | Task6) => TaskUpdate(parse(p)?),
161                Some(ChecklistItem | ChecklistItem2 | ChecklistItem3) => ChecklistUpdate(parse(p)?),
162                Some(Tag3 | Tag4) => TagUpdate(parse(p)?),
163                Some(Area2 | Area3) => AreaUpdate(parse(p)?),
164                Some(Settings3 | Settings4 | Settings5) => Ignored(p),
165                _ => Properties::Unknown(p),
166            },
167            OperationType::Unknown(_) => Properties::Unknown(p),
168        };
169
170        Ok(payload)
171    }
172
173    fn coerce_known_payload(
174        operation_type: OperationType,
175        entity_type: &EntityType,
176        payload: Properties,
177    ) -> Properties {
178        match payload {
179            Properties::Unknown(map) => {
180                match Self::properties_from(operation_type, Some(entity_type), map.clone()) {
181                    Ok(parsed) => parsed,
182                    Err(_) => Properties::Unknown(map),
183                }
184            }
185            other => other,
186        }
187    }
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191struct RawWireObject {
192    #[serde(rename = "t")]
193    operation_type: OperationType,
194    #[serde(rename = "e")]
195    entity_type: Option<EntityType>,
196    #[serde(rename = "p", default)]
197    properties: BTreeMap<String, Value>,
198}
199
200impl Serialize for WireObject {
201    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
202        let mut state = serializer.serialize_struct("WireObject", 3)?;
203        state.serialize_field("t", &self.operation_type)?;
204        state.serialize_field("e", &self.entity_type)?;
205        state.serialize_field("p", &self.properties_map())?;
206        state.end()
207    }
208}
209
210impl<'de> Deserialize<'de> for WireObject {
211    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
212        let raw = RawWireObject::deserialize(deserializer)?;
213        let payload = WireObject::properties_from(
214            raw.operation_type,
215            raw.entity_type.as_ref(),
216            raw.properties,
217        )
218        .map_err(serde::de::Error::custom)?;
219        Ok(Self {
220            operation_type: raw.operation_type,
221            entity_type: raw.entity_type,
222            payload,
223        })
224    }
225}
226
227fn parse_props_from_map<T: DeserializeOwned>(
228    properties: BTreeMap<String, Value>,
229) -> Result<T, serde_json::Error> {
230    serde_json::from_value(Value::Object(
231        properties
232            .into_iter()
233            .collect::<serde_json::Map<String, Value>>(),
234    ))
235}
236
237fn to_map<T: Serialize>(value: &T) -> BTreeMap<String, Value> {
238    match serde_json::to_value(value) {
239        Ok(Value::Object(map)) => map.into_iter().collect(),
240        _ => BTreeMap::new(),
241    }
242}
243
244/// Operation type for wire field `t`.
245#[derive(
246    Debug,
247    Clone,
248    Copy,
249    Serialize,
250    Deserialize,
251    PartialEq,
252    Eq,
253    Display,
254    EnumString,
255    FromPrimitive,
256    IntoPrimitive,
257)]
258#[repr(i32)]
259#[serde(from = "i32", into = "i32")]
260pub enum OperationType {
261    /// Full snapshot/create (replace current object state for UUID).
262    Create = 0,
263    /// Partial update (merge `p` into existing properties).
264    Update = 1,
265    /// Deletion event.
266    Delete = 2,
267
268    /// Unknown operation value preserved for forward compatibility.
269    #[num_enum(catch_all)]
270    #[strum(disabled, to_string = "{0}")]
271    Unknown(i32),
272}
273
274#[expect(
275    clippy::derivable_impls,
276    reason = "num_enum(catch_all) conflicts with #[default]"
277)]
278impl Default for OperationType {
279    fn default() -> Self {
280        Self::Create
281    }
282}
283
284/// Entity type for wire field `e`.
285///
286/// Values are versioned by Things (for example `Task6`, `Area3`).
287#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Display, EnumString)]
288#[serde(from = "String", into = "String")]
289pub enum EntityType {
290    /// Task entity (legacy version).
291    Task3,
292    /// Task entity (legacy version).
293    Task4,
294    /// Task/project/heading entity (current observed version).
295    Task6,
296
297    /// Checklist item entity (legacy version).
298    ChecklistItem,
299    /// Checklist item entity (legacy version).
300    ChecklistItem2,
301    /// Checklist item entity (current observed version).
302    ChecklistItem3,
303
304    /// Tag entity (legacy version).
305    Tag3,
306    /// Tag entity (current observed version).
307    Tag4,
308
309    /// Area entity (legacy version).
310    Area2,
311    /// Area entity (current observed version).
312    Area3,
313
314    /// Settings entity.
315    Settings3,
316    Settings4,
317    Settings5,
318
319    /// Tombstone marker for deleted objects (legacy version).
320    Tombstone,
321    /// Tombstone marker for deleted objects.
322    Tombstone2,
323
324    /// One-shot command entity.
325    Command,
326    /// Unknown entity name preserved for forward compatibility.
327    #[strum(default, to_string = "{0}")]
328    Unknown(String),
329}
330
331impl From<String> for EntityType {
332    fn from(value: String) -> Self {
333        value.parse().unwrap_or(Self::Unknown(value))
334    }
335}
336
337impl From<EntityType> for String {
338    fn from(value: EntityType) -> Self {
339        value.to_string()
340    }
341}