From 05ec318c4696178ed05fdec634fac5db3a963c91 Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sat, 4 Jan 2025 11:13:41 -0800 Subject: [PATCH] Add max_comment_length to config --- CHANGELOG.md | 1 + docs/configuration.md | 11 +++++++++ src/config.rs | 8 +++++++ src/error.rs | 14 ++++++++++- src/filters.rs | 20 +++++++++++++--- src/list.rs | 56 +++++++++++++++++++++++++++---------------- src/main.rs | 12 +++++----- src/tasks/format.rs | 29 ++++++++++++++++++---- src/test.rs | 19 +++++++++++++++ src/todoist.rs | 28 +++++++++++++++++----- 10 files changed, 158 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d489d0..3ef92b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased (on main branch only) - Render attachment URLs in comments +- Make `max_comment_length` configurable ## 2025-01-02 v0.6.26 diff --git a/docs/configuration.md b/docs/configuration.md index 635a917..6cf9142 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,6 +6,7 @@ - [Values](#values) - [disable_links](#disable_links) - [last_version_check](#last_version_check) + - [max_comment_length](#max_comment_length) - [next_id](#next_id) - [path](#path) - [natural_language_only](#natural_language_only) @@ -66,6 +67,16 @@ If true, disables OSC8 linking and just displays plain text Holds a string date, i.e. `"2023-08-30"` representing the last time crates.io was checked for the latest `tod` version. Tod will check crates.io a maximum of once per day. +### max_comment_length + +``` + type: nullable positive integer + default: null + possible_values: Any positive integer or null +``` + +The maximum number of characters that will be printed in total when showing comments. + ### next_id ``` diff --git a/src/config.rs b/src/config.rs index 7caf7c5..432e8e2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,8 @@ use tokio::fs; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::mpsc::UnboundedSender; +const MAX_COMMENT_LENGTH: u32 = 500; + #[derive(Deserialize, Serialize, Debug, Clone)] pub struct Completed { count: u32, @@ -45,6 +47,8 @@ pub struct Config { #[serde(default)] pub disable_links: bool, pub completed: Option, + // Maximum length for printing comments + pub max_comment_length: Option, pub verbose: Option, /// Don't ask for sections pub no_sections: Option, @@ -108,6 +112,9 @@ impl Default for SortValue { } } impl Config { + pub fn max_comment_length(self: &Config) -> u32 { + self.max_comment_length.unwrap_or(MAX_COMMENT_LENGTH) + } pub async fn reload_projects(self: &mut Config) -> Result { let all_projects = todoist::projects(self).await?; let current_projects = self.projects.clone().unwrap_or_default(); @@ -255,6 +262,7 @@ impl Config { natural_language_only: None, mock_string: None, mock_select: None, + max_comment_length: None, verbose: None, internal: Internal { tx: Some(tx) }, args: Args { diff --git a/src/error.rs b/src/error.rs index 22f640e..f10d665 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,4 +1,7 @@ -use std::{fmt::Display, num::ParseIntError}; +use std::{ + fmt::Display, + num::{ParseIntError, TryFromIntError}, +}; use crate::color; use homedir::GetHomeError; @@ -32,6 +35,15 @@ impl From for Error { } } +impl From for Error { + fn from(value: TryFromIntError) -> Self { + Self { + source: String::from("TryFromIntError"), + message: format!("{value}"), + } + } +} + impl From for Error { fn from(value: JoinError) -> Self { Self { diff --git a/src/filters.rs b/src/filters.rs index d837734..74a410c 100644 --- a/src/filters.rs +++ b/src/filters.rs @@ -10,7 +10,11 @@ use crate::{ }; pub async fn edit_task(config: &Config, filter: String) -> Result { - let tasks = todoist::tasks_for_filter(config, &filter).await?; + let tasks = todoist::tasks_for_filters(config, &filter) + .await? + .into_iter() + .flat_map(|(_, tasks)| tasks.to_owned()) + .collect::>(); let task = input::select(input::TASK, tasks, config.mock_select)?; @@ -52,7 +56,12 @@ pub async fn next_task(config: Config, filter: &str) -> Result { } async fn fetch_next_task(config: &Config, filter: &str) -> Result, Error> { - let tasks = todoist::tasks_for_filter(config, filter).await?; + let tasks = todoist::tasks_for_filters(config, filter) + .await? + .into_iter() + .flat_map(|(_, tasks)| tasks.to_owned()) + .collect::>(); + let tasks = tasks::sort_by_value(tasks, config); Ok(tasks.first().map(|task| (task.to_owned(), tasks.len()))) @@ -60,7 +69,12 @@ async fn fetch_next_task(config: &Config, filter: &str) -> Result Result { - let tasks = todoist::tasks_for_filter(config, filter).await?; + let tasks = todoist::tasks_for_filters(config, filter) + .await? + .into_iter() + .flat_map(|(_, tasks)| tasks.to_owned()) + .collect::>(); + let tasks = tasks::sort(tasks, config, sort); if tasks.is_empty() { diff --git a/src/list.rs b/src/list.rs index 250d5de..f773b1d 100644 --- a/src/list.rs +++ b/src/list.rs @@ -28,26 +28,26 @@ impl Display for Flag { /// Get a list of all tasks pub async fn view(config: &Config, flag: Flag, sort: &SortOrder) -> Result { - let tasks = match flag.clone() { - Flag::Project(project) => todoist::tasks_for_project(config, &project).await?, - Flag::Filter(filter) => todoist::tasks_for_filter(config, &filter).await?, + let list_of_tasks = match flag.clone() { + Flag::Project(project) => vec![( + project.name.clone(), + todoist::tasks_for_project(config, &project).await?, + )], + Flag::Filter(filter) => todoist::tasks_for_filters(config, &filter).await?, }; - let empty_text = format!("No tasks for {flag}"); - let title = format!("Tasks for {flag}"); - - if tasks.is_empty() { - return Ok(empty_text); - } - let mut buffer = String::new(); - buffer.push_str(&color::green_string(&title)); - buffer.push('\n'); - for task in tasks::sort(tasks, config, sort) { - let text = task.fmt(config, FormatType::List, true, false).await?; + for (query, tasks) in list_of_tasks { + let title = format!("Tasks for {query}"); + buffer.push('\n'); + buffer.push_str(&color::green_string(&title)); buffer.push('\n'); - buffer.push_str(&text); + for task in tasks::sort(tasks, config, sort) { + let text = task.fmt(config, FormatType::List, true, false).await?; + buffer.push('\n'); + buffer.push_str(&text); + } } Ok(buffer) } @@ -60,7 +60,11 @@ pub async fn prioritize(config: &Config, flag: Flag, sort: &SortOrder) -> Result .into_iter() .filter(|task| task.priority == Priority::None) .collect::>(), - Flag::Filter(filter) => todoist::tasks_for_filter(config, &filter).await?, + Flag::Filter(filter) => todoist::tasks_for_filters(config, &filter) + .await? + .iter() + .flat_map(|(_, tasks)| tasks.to_owned()) + .collect::>(), }; let empty_text = format!("No tasks for {flag}"); @@ -90,7 +94,11 @@ pub async fn timebox(config: &Config, flag: Flag, sort: &SortOrder) -> Result>(), - Flag::Filter(filter) => todoist::tasks_for_filter(config, &filter).await?, + Flag::Filter(filter) => todoist::tasks_for_filters(config, &filter) + .await? + .into_iter() + .flat_map(|(_, tasks)| tasks.to_owned()) + .collect::>(), }; let empty_text = format!("No tasks for {flag}"); @@ -122,7 +130,11 @@ pub async fn process(config: &Config, flag: Flag, sort: &SortOrder) -> Result todoist::tasks_for_filter(config, &filter).await?, + Flag::Filter(filter) => todoist::tasks_for_filters(config, &filter) + .await? + .into_iter() + .flat_map(|(_, tasks)| tasks.to_owned()) + .collect::>(), }; let tasks = tasks::reject_parent_tasks(tasks, config).await; @@ -156,7 +168,11 @@ pub async fn label( ) -> Result { let tasks = match flag.clone() { Flag::Project(project) => todoist::tasks_for_project(config, &project).await?, - Flag::Filter(filter) => todoist::tasks_for_filter(config, &filter).await?, + Flag::Filter(filter) => todoist::tasks_for_filters(config, &filter) + .await? + .into_iter() + .flat_map(|(_, tasks)| tasks.to_owned()) + .collect::>(), }; let empty_text = format!("No tasks for {flag}"); @@ -474,7 +490,7 @@ mod tests { .await .unwrap(); - assert!(tasks.contains("Tasks for 'today'")); + assert!(tasks.contains("Tasks for today")); mock.assert(); } diff --git a/src/main.rs b/src/main.rs index 3991052..8212f61 100644 --- a/src/main.rs +++ b/src/main.rs @@ -319,7 +319,7 @@ struct ListView { project: Option, #[arg(short, long)] - /// The filter containing the tasks + /// The filter containing the tasks. Can add multiple filters separated by commas. filter: Option, #[arg(short = 't', long, default_value_t = SortOrder::Datetime)] @@ -334,7 +334,7 @@ struct ListProcess { project: Option, #[arg(short, long)] - /// The filter containing the tasks + /// The filter containing the tasks. Can add multiple filters separated by commas. filter: Option, #[arg(short = 't', long, default_value_t = SortOrder::Value)] @@ -349,7 +349,7 @@ struct ListTimebox { project: Option, #[arg(short, long)] - /// The filter containing the tasks, does not filter out tasks with durations unless specified in filter + /// The filter containing the tasks, does not filter out tasks with durations unless specified in filter. Can add multiple filters separated by commas. filter: Option, #[arg(short = 't', long, default_value_t = SortOrder::Value)] @@ -364,7 +364,7 @@ struct ListPrioritize { project: Option, #[arg(short, long)] - /// The filter containing the tasks + /// The filter containing the tasks. Can add multiple filters separated by commas. filter: Option, #[arg(short = 't', long, default_value_t = SortOrder::Value)] @@ -375,7 +375,7 @@ struct ListPrioritize { #[derive(Parser, Debug, Clone)] struct ListLabel { #[arg(short, long)] - /// The filter containing the tasks + /// The filter containing the tasks. Can add multiple filters separated by commas. filter: Option, #[arg(short, long)] @@ -398,7 +398,7 @@ struct ListSchedule { project: Option, #[arg(short, long)] - /// The filter containing the tasks + /// The filter containing the tasks. Can add multiple filters separated by commas. filter: Option, #[arg(short, long, default_value_t = false)] diff --git a/src/tasks/format.rs b/src/tasks/format.rs index 2bc104a..bc9754f 100644 --- a/src/tasks/format.rs +++ b/src/tasks/format.rs @@ -5,8 +5,6 @@ use supports_hyperlinks::Stream; use super::{priority, DateTimeInfo, Duration, Task, Unit}; use crate::{color, config::Config, error::Error, projects::Project, time, todoist}; -const MAX_COMMENT_LENGTH: usize = 500; - pub fn content(task: &Task, config: &Config) -> String { let content = match task.priority { priority::Priority::Low => color::blue_string(&task.content), @@ -146,9 +144,10 @@ pub async fn comments(config: &Config, task: &Task) -> Result { comments.reverse(); let comments = comments.join("\n\n"); let mut formatted_string = format!("\n\n{comment_icon} Comments {comment_icon}\n\n{comments}"); + let max_comment_length: usize = config.max_comment_length().try_into()?; - if formatted_string.len() > MAX_COMMENT_LENGTH { - formatted_string.truncate(MAX_COMMENT_LENGTH); + if formatted_string.len() > max_comment_length { + formatted_string.truncate(max_comment_length); formatted_string.push_str("[TRUNCATED]"); }; @@ -157,6 +156,8 @@ pub async fn comments(config: &Config, task: &Task) -> Result { #[cfg(test)] mod tests { + use crate::test; + use super::*; use pretty_assertions::assert_eq; @@ -176,4 +177,24 @@ mod tests { String::from("\x1B]8;;https://app.todoist.com/app/task/1\x1B\\[link]\x1B]8;;\x1B\\") ) } + + #[tokio::test] + async fn test_comments() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/rest/v2/comments/?task_id=222") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(test::responses::comments()) + .create_async() + .await; + + let config = test::fixtures::config().await.mock_url(server.url()); + let task = test::fixtures::task(); + + let comments = comments(&config, &task).await.unwrap(); + + assert_matches!(comments.as_str(), "\n\nā˜… Comments ā˜…\n\nPosted 2016-09-22 00:00:00 PDT\nAttachment \u{1b}]8;;https://s3.amazonaws.com/domorebe[TRUNCATED]"); + mock.expect(1); + } } diff --git a/src/test.rs b/src/test.rs index e5bd978..1552216 100644 --- a/src/test.rs +++ b/src/test.rs @@ -44,6 +44,7 @@ pub mod fixtures { disable_links: false, completed: None, bell_on_success: false, + max_comment_length: Some(100), bell_on_failure: true, internal: Internal { tx: tx() }, projects: Some(vec![Project { @@ -335,6 +336,24 @@ pub mod responses { ) } + pub fn comments() -> String { + String::from( + "[{ + \"content\": \"Need one bottle of milk\", + \"id\": \"2992679862\", + \"posted_at\": \"2016-09-22T07:00:00.000000Z\", + \"project_id\": null, + \"task_id\": \"2995104339\", + \"attachment\": { + \"file_name\": \"File.pdf\", + \"file_type\": \"application/pdf\", + \"file_url\": \"https://s3.amazonaws.com/domorebetter/Todoist+Setup+Guide.pdf\", + \"resource_type\": \"file\" + } + }]", + ) + } + pub fn user() -> String { String::from( "\ diff --git a/src/todoist.rs b/src/todoist.rs index a4df278..f0e3ae3 100644 --- a/src/todoist.rs +++ b/src/todoist.rs @@ -1,7 +1,7 @@ -use std::collections::HashMap; - +use futures::future; use serde_json::{json, Number, Value}; - +use std::collections::HashMap; +use urlencoding::encode; mod request; use crate::comment::Comment; @@ -95,13 +95,29 @@ pub async fn tasks_for_project(config: &Config, project: &Project) -> Result Result, Error> { - use urlencoding::encode; +pub async fn tasks_for_filters( + config: &Config, + filter: &str, +) -> Result)>, Error> { + let filters: Vec<_> = filter + .split(',') + .map(|f| tasks_for_filter(config, f)) + .collect(); + + let mut acc = Vec::new(); + for result in future::join_all(filters).await { + acc.push(result?); + } + + Ok(acc) +} +pub async fn tasks_for_filter(config: &Config, filter: &str) -> Result<(String, Vec), Error> { let encoded = encode(filter); let url = format!("{TASKS_URL}?filter={encoded}"); let json = request::get_todoist_rest(config, url, true).await?; - tasks::rest_json_to_tasks(json) + let tasks = tasks::rest_json_to_tasks(json)?; + Ok((filter.to_string(), tasks)) } pub async fn sections_for_project(