todoist_tui/
model.rs

1use self::{
2    command::{AddItemArgs, Args, Command, CompleteItemArgs},
3    due_date::Due,
4    item::Item,
5    project::Project,
6    section::Section,
7    user::User,
8};
9use crate::sync::{Response, Status};
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13pub mod command;
14pub mod due_date;
15pub mod item;
16pub mod project;
17pub mod section;
18pub mod user;
19
20#[derive(Debug, Serialize, Deserialize)]
21pub struct Model {
22    pub sync_token: String,
23    pub items: Vec<Item>,
24    pub projects: Vec<Project>,
25    pub sections: Vec<Section>,
26    pub user: User,
27    pub commands: Vec<Command>,
28}
29
30impl Model {
31    pub fn add_item(&mut self, item: &str, project_id: project::Id, due_date: Option<Due>) {
32        let new_item = Item::new(item, &project_id).due(due_date.clone());
33
34        self.commands.push(command::Command {
35            request_type: "item_add".to_string(),
36            temp_id: Some(new_item.id.to_string()),
37            uuid: Uuid::new_v4(),
38            args: Args::AddItemCommandArgs(AddItemArgs {
39                project_id,
40                content: item.to_string(),
41                due: due_date,
42            }),
43        });
44        self.items.push(new_item);
45    }
46
47    pub fn add_item_to_inbox(&mut self, item: &str, due_date: Option<Due>) {
48        let project_id = self.user.inbox_project_id.clone();
49        self.add_item(item, project_id, due_date);
50    }
51
52    /// Marks an item as complete (or uncomplete) and creates (removes) a corresponding command
53    ///
54    /// # Note
55    /// This no-ops if an item with the given id does not exist, so check before calling.
56    pub fn mark_item(&mut self, item_id: &item::Id, complete: bool) {
57        let item = self.items.iter_mut().find(|item| &item.id == item_id);
58
59        // If nothing was found, just return
60        let Some(item) = item else { return };
61
62        item.mark_complete(complete);
63
64        if complete {
65            // Add a new command
66            self.commands.push(Command {
67                request_type: "item_complete".to_owned(),
68                temp_id: None,
69                uuid: Uuid::new_v4(),
70                args: Args::CompleteItemCommandArgs(CompleteItemArgs {
71                    id: item.id.clone(),
72                }),
73            });
74        } else {
75            // If there was a pending command to mark this item completed, remove it
76            let cmd_index = self.commands.iter().position(|command| {
77                if let Args::CompleteItemCommandArgs(CompleteItemArgs { ref id }) = command.args {
78                    id == &item.id
79                } else {
80                    false
81                }
82            });
83            if let Some(cmd_index) = cmd_index {
84                self.commands.remove(cmd_index);
85            }
86        }
87    }
88
89    // TODO: test
90    #[must_use]
91    pub fn get_inbox_items(&self, filter_complete: bool) -> Vec<&Item> {
92        // get the items with the correct id
93        let inbox_id = &self.user.inbox_project_id;
94        self.items
95            .iter()
96            .filter(|item| item.project_id == *inbox_id && (!filter_complete || !item.checked))
97            .collect()
98    }
99
100    #[must_use]
101    pub fn get_items_in_project(&self, project_id: &project::Id) -> Vec<&Item> {
102        self.items
103            .iter()
104            .filter(|item| item.project_id == *project_id)
105            .collect()
106    }
107
108    // TODO: test
109    #[must_use]
110    pub fn projects(&self) -> Vec<&Project> {
111        self.projects.iter().collect()
112    }
113
114    #[must_use]
115    pub fn project_with_id(&self, id: &project::Id) -> Option<&Project> {
116        self.projects.iter().find(|project| project.id == *id)
117    }
118
119    pub fn update(&mut self, response: Response) {
120        self.sync_token = response.sync_token;
121
122        if let Some(user) = response.user {
123            self.user = user;
124        }
125
126        // replace the list of projects with the list from the response.
127        // FIXME: we need a more nuanced algorithm to update the projects.
128        // just replacing `self.projects` with `response.projects` is no good
129        // because we don't always query all projects
130        // so for now, only replace projects if the incoming projects
131        // list is nonempty
132        if !response.projects.is_empty() {
133            self.projects = response.projects;
134        }
135
136        // same thing (and same FIXME) with sections
137        if !response.sections.is_empty() {
138            self.sections = response.sections;
139        }
140
141        if response.full_sync {
142            // if this was a full sync, just replace the set of items
143            self.items = response.items;
144        } else {
145            // use the id mapping from the response to update the ids of the existing items
146            response
147                .temp_id_mapping
148                .into_iter()
149                .for_each(|(temp_id, real_id)| {
150                    // HACK: should we do something else if we don't find a match?
151                    if let Some(matching_item) = self
152                        .items
153                        .iter_mut()
154                        .find(|item| item.id == temp_id.clone().into())
155                    {
156                        matching_item.id = real_id.into();
157                    }
158                });
159
160            // look through the list of items that we received -- add any new items
161            // and update any newly checked items
162            response.items.into_iter().for_each(|incoming_item| {
163                // if the incoming item is checked, see if there's a matching item in the model
164                // and remove it
165                // if the incoming item is unchecked, do the same, but if nothing is found, add this
166                // item to the model
167                let matching_index = self
168                    .items
169                    .iter()
170                    .position(|item| item.id == incoming_item.id);
171
172                match matching_index {
173                    Some(index) => {
174                        if incoming_item.checked {
175                            self.items.remove(index);
176                        } else {
177                            self.items[index] = incoming_item;
178                        }
179                    }
180                    None => self.items.push(incoming_item),
181                }
182            });
183        }
184
185        // update the command list by removing the commands that succeeded
186        if let Some(ref status_map) = response.sync_status {
187            self.commands.retain(|command| {
188                !status_map
189                    .get(&command.uuid)
190                    .is_some_and(|status| *status == Status::Ok)
191            });
192        }
193    }
194}
195
196impl Default for Model {
197    fn default() -> Self {
198        let inbox = Project::new("Inbox");
199        let user = User {
200            inbox_project_id: inbox.id.clone(),
201            ..Default::default()
202        };
203        Model {
204            sync_token: "*".to_string(),
205            items: vec![],
206            projects: vec![inbox],
207            sections: vec![],
208            user,
209            commands: vec![],
210        }
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use std::collections::HashMap;
217
218    use super::*;
219
220    #[test]
221    fn add_item_to_inbox() {
222        let mut model = Model::default();
223        model.user.inbox_project_id = "INBOX_ID".into();
224        model.add_item_to_inbox("New item!", None);
225
226        assert_eq!(model.items[0].project_id, "INBOX_ID".into());
227        assert_eq!(model.items[0].content, "New item!");
228        assert_eq!(model.commands[0].request_type, "item_add");
229        assert_eq!(
230            model.commands[0].args,
231            Args::AddItemCommandArgs(AddItemArgs {
232                project_id: "INBOX_ID".into(),
233                content: "New item!".to_string(),
234                due: None
235            })
236        );
237    }
238
239    #[test]
240    fn mark_item_completed() {
241        let mut model = Model::default();
242        let item = Item::new("Item!", "INBOX_ID");
243        let item_id = item.id.clone();
244        model.items.push(item);
245        model.mark_item(&item_id, true);
246
247        assert!(model.items[0].checked);
248        assert_eq!(model.commands[0].request_type, "item_complete");
249        assert_eq!(
250            model.commands[0].args,
251            Args::CompleteItemCommandArgs(CompleteItemArgs { id: item_id })
252        );
253    }
254
255    #[test]
256    fn incremental_update_with_updated_todos() {
257        let mut model = Model::default();
258        let item1 = Item::new("Item One!", "INBOX_ID");
259        let item1_updated = Item {
260            checked: true,
261            ..item1.clone()
262        };
263
264        let item2 = Item::new("Item Two!", "INBOX_ID");
265        let item2_updated = Item {
266            content: "Item Two with a new title!".into(),
267            ..item2.clone()
268        };
269
270        model.items.push(item1);
271        model.items.push(item2);
272        let response = Response {
273            items: vec![item1_updated, item2_updated],
274            full_sync: false,
275            ..Default::default()
276        };
277
278        model.update(response);
279        assert_eq!(model.items.len(), 1);
280        assert_eq!(model.items[0].content, "Item Two with a new title!");
281    }
282
283    #[test]
284    fn incremental_update_with_new_external_todo() {
285        let mut model = Model::default();
286        let item = Item::new("Item!", "INBOX_ID");
287
288        let response = Response {
289            items: vec![item],
290            full_sync: false,
291            ..Default::default()
292        };
293
294        model.update(response);
295        assert_eq!(model.items.len(), 1);
296        assert_eq!(model.items[0].content, "Item!");
297    }
298
299    #[test]
300    fn incremental_update_after_adding_local_todo() {
301        let mut model = Model::default();
302        let item = Item::new("Item!", "INBOX_ID");
303        let item_updated = Item {
304            id: "NEW_ITEM_ID".into(),
305            ..item.clone()
306        };
307        let item_id = item.id.clone();
308        model.items.push(item);
309
310        let response = Response {
311            items: vec![item_updated],
312            full_sync: false,
313            temp_id_mapping: HashMap::from([(item_id.to_string(), "NEW_ITEM_ID".into())]),
314            ..Default::default()
315        };
316
317        model.update(response);
318        assert_eq!(model.items.len(), 1);
319        assert_eq!(model.items[0].content, "Item!");
320    }
321}