Skip to main content

things3_cloud/wire/
wire_object.rs

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