todoist_tui/
cli.rs

1use crate::{
2    model::{due_date::Due, item::Item, Model},
3    storage::{
4        config_manager::{Auth, ConfigManager},
5        model_manager::ModelManager,
6    },
7    sync::{client::Client, Request, ResourceType},
8};
9use anyhow::{anyhow, Result};
10use chrono::{Local, NaiveDateTime};
11use clap::{Parser, Subcommand};
12use std::io::{self, Write};
13
14#[derive(Parser, Clone)]
15#[command(author)]
16pub struct Args {
17    #[command(subcommand)]
18    pub command: Option<Command>,
19
20    /// Override the URL for the Todoist Sync API (mostly for testing purposes)
21    #[arg(long = "sync-url-override", hide = true)]
22    pub sync_url_override: Option<String>,
23
24    /// Override the local app storage directory (mostly for testing purposes)
25    #[arg(long = "local-dir-override", hide = true)]
26    pub local_dir_override: Option<String>,
27
28    /// Override the date/time the app uses as current date/time
29    #[arg(long = "date-time-override", hide = true)]
30    pub datetime_override: Option<NaiveDateTime>,
31}
32
33#[derive(Subcommand, Clone)]
34pub enum Command {
35    /// Add a new todo to your inbox
36    #[command(name = "add")]
37    AddTodo {
38        /// The text of the todo
39        todo: String,
40
41        /// When the todo is due
42        #[arg(long, short)]
43        due: Option<String>,
44
45        /// Don't sync data with the server
46        #[arg(long = "no-sync", short)]
47        no_sync: bool,
48    },
49
50    /// Mark a todo in the inbox complete
51    #[command(name = "complete")]
52    CompleteTodo {
53        /// The number of the todo that's displayed with the `list` command
54        number: usize,
55
56        /// Don't sync data with the server
57        #[arg(long = "no-sync", short)]
58        no_sync: bool,
59    },
60
61    /// List the items in your inbox
62    #[command(name = "list")]
63    ListInbox,
64
65    /// Store a Todoist API token
66    #[command(name = "set-token")]
67    SetApiToken {
68        /// The Todoist API token
69        token: String,
70    },
71
72    /// Sync data with the Todoist server
73    #[command()]
74    Sync {
75        /// Only sync changes made locally since the last full sync
76        #[arg(long, short)]
77        incremental: bool,
78    },
79}
80
81/// # Errors
82///
83/// Returns an error if `number` does not correspond to a valid item
84pub fn complete_item(number: usize, model: &mut Model) -> Result<()> {
85    // look at the current inbox and determine which task is targeted
86    let inbox_items = model.get_inbox_items(true);
87    let num_items = inbox_items.len();
88
89    let item = inbox_items.get(number - 1).ok_or_else(|| {
90        anyhow!(
91            "'{number}' is outside of the valid range. Pass a number between 1 and {num_items}.",
92        )
93    })?;
94    let content = item.content.clone();
95
96    model.mark_item(&item.id.clone(), true);
97    println!("'{content}' marked complete.");
98
99    Ok(())
100}
101
102/// # Errors
103///
104/// Returns an error if something goes awry while processing the command.
105pub async fn handle_command(
106    command: &Command,
107    args: Args,
108    model_manager: ModelManager<'_>,
109    client: Result<Client>,
110    config_manager: ConfigManager<'_>,
111) -> Result<()> {
112    match command {
113        Command::AddTodo { todo, no_sync, due } => {
114            // TODO: parse the date first, it might be no good and we'll need to error out
115            let today = args
116                .datetime_override
117                .unwrap_or(Local::now().naive_local())
118                .date();
119            let due_date = due.as_ref().and_then(|due| {
120                Due::parse_from_str(due, today).and_then(|(date, range)| {
121                    // reject the due date if it didn't parse exactly
122                    if range == (0..due.len()) {
123                        Some(date)
124                    } else {
125                        None
126                    }
127                })
128            });
129
130            let mut model = model_manager.read_model()?;
131            model.add_item_to_inbox(todo, due_date);
132            println!("'{todo}' added to inbox.");
133            if !no_sync {
134                sync(&mut model, &client?, true).await?;
135            }
136            model_manager.write_model(&model)?;
137        }
138
139        Command::CompleteTodo { number, no_sync } => {
140            let mut model = model_manager.read_model()?;
141            complete_item(*number, &mut model)?;
142            if !no_sync {
143                sync(&mut model, &client?, true).await?;
144            }
145            model_manager.write_model(&model)?;
146        }
147
148        Command::ListInbox => {
149            let model = model_manager.read_model()?;
150            let inbox_items = model.get_inbox_items(true);
151
152            if inbox_items.is_empty() {
153                println!("Your inbox is empty.");
154            } else {
155                println!("Inbox: ");
156                for (index, Item { content, .. }) in inbox_items.iter().enumerate() {
157                    println!("[{}] {content}", index + 1);
158                }
159            }
160        }
161
162        Command::SetApiToken { token } => {
163            config_manager.write_auth_config(&Auth {
164                api_token: token.clone(),
165            })?;
166            println!("Stored API token.");
167        }
168
169        Command::Sync { incremental } => {
170            let mut model = model_manager.read_model()?;
171            sync(&mut model, &client?, *incremental).await?;
172            model_manager.write_model(&model)?;
173        }
174    };
175
176    Ok(())
177}
178
179// FIXME: this probably isn't the right place for this function
180/// # Errors
181///
182/// Returns an error if something goes wrong while sending/receiving data from the Todoist API.
183pub async fn sync(model: &mut Model, client: &Client, incremental: bool) -> Result<()> {
184    let sync_token = if incremental {
185        model.sync_token.clone()
186    } else {
187        "*".to_string()
188    };
189
190    let request = Request {
191        sync_token,
192        resource_types: ResourceType::all(),
193        commands: model.commands.clone(),
194    };
195
196    print!("Syncing... ");
197    io::stdout().flush()?;
198
199    let response = client.make_request(&request).await?;
200    println!("Done.");
201
202    // update the sync_data with the result
203    model.update(response);
204
205    Ok(())
206}