A file-based task manager
1mod attrs; 2mod errors; 3mod fzf; 4mod stack; 5mod task; 6mod util; 7mod workspace; 8use clap_complete::{generate, Shell}; 9use errors::Result; 10use std::io::{self, Write}; 11use std::path::PathBuf; 12use std::process::exit; 13use std::str::FromStr as _; 14use std::{env::current_dir, io::Read}; 15use task::ParsedLink; 16use workspace::{Id, Task, TaskIdentifier, Workspace}; 17 18//use smol; 19//use iocraft::prelude::*; 20use clap::{Args, CommandFactory, Parser, Subcommand}; 21use edit::edit as open_editor; 22 23fn default_dir() -> PathBuf { 24 current_dir().unwrap() 25} 26 27fn parse_id(s: &str) -> std::result::Result<Id, &'static str> { 28 Id::from_str(s).map_err(|_| "Unable to parse tsk- ID") 29} 30 31#[derive(Parser)] 32// TODO: add long_about 33#[command(version, about)] 34struct Cli { 35 /// Override the tsk root directory. 36 #[arg(short = 'C', env = "TSK_ROOT", value_name = "DIR")] 37 dir: Option<PathBuf>, 38 // TODO: other global options 39 #[command(subcommand)] 40 command: Commands, 41} 42 43#[derive(Subcommand)] 44enum Commands { 45 /// Initializes a .tsk workspace in the current effective directory, which defaults to PWD. 46 Init, 47 /// Creates a new task, automatically assigning it a unique identifider and persisting 48 Push { 49 /// Whether to open $EDITOR to edit the content of the task. The first line if the 50 /// resulting file will be the task's title. The body follows the title after two newlines, 51 /// similr to the format of a commit message. 52 #[arg(short = 'e', default_value_t = false)] 53 edit: bool, 54 55 /// The body of the task. It may be specified as either a string using quotes or the 56 /// special character '-' to read from stdin. 57 #[arg(short = 'b')] 58 body: Option<String>, 59 60 /// The title of the task as a raw string. It mus be proceeded by two dashes (--). 61 #[command(flatten)] 62 title: Title, 63 }, 64 /// Creates a new task just like `push`, but instead of putting it at the top of the stack, it 65 /// puts it at the bottom 66 Append { 67 /// Whether to open $EDITOR to edit the content of the task. The first line if the 68 /// resulting file will be the task's title. The body follows the title after two newlines, 69 /// similr to the format of a commit message. 70 #[arg(short = 'e', default_value_t = false)] 71 edit: bool, 72 73 /// The body of the task. It may be specified as either a string using quotes or the 74 /// special character '-' to read from stdin. 75 #[arg(short = 'b')] 76 body: Option<String>, 77 78 /// The title of the task as a raw string. It mus be proceeded by two dashes (--). 79 #[command(flatten)] 80 title: Title, 81 }, 82 /// Print the task stack. This will include just TSK-IDs and the title. 83 List { 84 /// Whether to list all tasks in the task stack. If specified, -c / count is ignored. 85 #[arg(short = 'a', default_value_t = false)] 86 all: bool, 87 #[arg(short = 'c', default_value_t = 10)] 88 count: usize, 89 #[arg(short = 'i', default_value_t = false)] 90 ids: bool 91 }, 92 93 /// Swaps the top two tasks on the stack. If there are less than 2 tasks on the stack, there is 94 /// no effect. 95 Swap, 96 97 /// Open up an editor to modify the task with the given ID. 98 Edit { 99 #[command(flatten)] 100 task_id: TaskId, 101 }, 102 103 /// Generates completion for a given shell. 104 Completion { 105 #[arg(short = 's')] 106 shell: Shell, 107 }, 108 109 /// Use fuzzy finding with `fzf` to search for a task 110 Find { 111 #[command(flatten)] 112 args: FindArgs, 113 /// Whether to print the a shortened tsk ID (just the integer portion). Defaults to *false* 114 #[arg(short = 'f', default_value_t = false)] 115 short_id: bool, 116 }, 117 118 /// Prints the contents of a task, parsing the body as rich text and formatting it using ANSI 119 /// escape sequences. 120 Show { 121 /// Shows raw file attributes for the file 122 #[arg(short = 'x', default_value_t = false)] 123 show_attrs: bool, 124 125 #[arg(short = 'R', default_value_t = false)] 126 raw: bool, 127 /// The [TSK-]ID of the task to display 128 #[command(flatten)] 129 task_id: TaskId, 130 }, 131 132 /// Follow a link that is parsed from a task body. It may be an internal or external link (ie. 133 /// a url or a wiki-style link using double square brackets). When using the `tsk show` 134 /// command, links that are successfully parsed get a numeric superscript that may be used to 135 /// address the link. That number should be supplied to the -l/link_index where it will be 136 /// subsequently followed opened or shown. 137 Follow { 138 /// The task whose body will be searched for links. 139 #[command(flatten)] 140 task_id: TaskId, 141 /// The index of the link to open. Must be supplied. 142 #[arg(short = 'l', default_value_t = 1)] 143 link_index: usize, 144 /// When opening an internal link, whether to show or edit the addressed task. 145 #[arg(short = 'e', default_value_t = false)] 146 edit: bool, 147 }, 148 149 /// Drops the task on the top of the stack and archives it. 150 Drop { 151 /// The [TSK-]ID of the task to drop. 152 #[command(flatten)] 153 task_id: TaskId, 154 }, 155 156 /// Moves the 3rd item on the stack to the front of the stack, shifting everything else down by 157 /// one. If there are less than 3 tasks on the stack, has no effect. 158 Rot, 159 /// Moves the task on the top of the stack back behind the 2nd element, shifting the next two 160 /// task up. 161 Tor, 162 163 /// Prioritizes an arbitrary task to the top of the stack. 164 Prioritize { 165 /// The [TSK-]ID to prioritize. If it exists, it is moved to the top of the stack. 166 #[command(flatten)] 167 task_id: TaskId, 168 }, 169 170 /// Deprioritizes a task to the bottom of the stack. 171 Deprioritize { 172 /// The [TSK-]ID to deprioritize. If it exists, it is moved to the bottom of the stack. 173 #[command(flatten)] 174 task_id: TaskId, 175 }, 176} 177 178#[derive(Args)] 179#[group(required = true, multiple = false)] 180struct Title { 181 /// The title of the task. This is useful for when you also wish to specify the body of the 182 /// task as an argument (ie. with -b). 183 #[arg(short, value_name = "TITLE")] 184 title: Option<String>, 185 186 #[arg(value_name = "TITLE")] 187 title_simple: Option<Vec<String>>, 188} 189 190#[derive(Args)] 191#[group(required = false, multiple = false)] 192struct TaskId { 193 /// The ID of the task to select as a plain integer. 194 #[arg(short = 't', value_name = "ID")] 195 id: Option<u32>, 196 197 /// The ID of the task to select with the 'tsk-' prefix. 198 #[arg(short = 'T', value_name = "TSK-ID", value_parser = parse_id)] 199 tsk_id: Option<Id>, 200 201 /// Selects a task relative to the top of the stack. 202 /// If no option is specified, the task selected will be the top of the stack. 203 #[arg(short = 'r', value_name = "RELATIVE", default_value_t = 0)] 204 relative_id: u32, 205 206 #[command(flatten)] 207 find: Find, 208} 209 210/// Use fuzzy finding to search for and select a task. 211/// Does not support searching task bodies or archived tasks. 212#[derive(Args)] 213#[group(required = false, multiple = true)] 214struct Find { 215 /// Use fuzzy finding to select a task. 216 #[arg(short = 'f', value_name = "FIND", default_value_t = false)] 217 find: bool, 218 #[command(flatten)] 219 args: FindArgs, 220} 221 222#[derive(Args)] 223#[group(required = false, multiple = false)] 224struct FindArgs { 225 /// Include the contents of tasks in the search criteria. 226 #[arg(short = 'b', default_value_t = false)] 227 search_body: bool, 228 /* TODO: implement this 229 /// Include archived tasks in the search criteria. Combine with `-b` to include archived 230 /// bodies in the search criteria. 231 #[arg(short = 'a', default_value_t = false)] 232 search_archived: bool, 233 */ 234} 235 236impl From<TaskId> for TaskIdentifier { 237 fn from(value: TaskId) -> Self { 238 if let Some(id) = value.id.map(Id::from).or(value.tsk_id) { 239 TaskIdentifier::Id(id) 240 } else if value.find.find { 241 TaskIdentifier::Find { 242 search_body: value.find.args.search_body, 243 archived: false, 244 } 245 } else { 246 TaskIdentifier::Relative(value.relative_id) 247 } 248 } 249} 250 251fn main() { 252 let cli = Cli::parse(); 253 let dir = cli.dir.unwrap_or(default_dir()); 254 let var_name = match cli.command { 255 Commands::Init => command_init(dir), 256 Commands::Push { edit, body, title } => command_push(dir, edit, body, title), 257 Commands::Append { edit, body, title } => command_append(dir, edit, body, title), 258 Commands::List { all, count, ids } => command_list(dir, all, ids, count), 259 Commands::Swap => command_swap(dir), 260 Commands::Show { 261 task_id, 262 raw, 263 show_attrs, 264 } => command_show(dir, task_id, show_attrs, raw), 265 Commands::Follow { 266 task_id, 267 link_index, 268 edit, 269 } => command_follow(dir, task_id, link_index, edit), 270 Commands::Edit { task_id } => command_edit(dir, task_id), 271 Commands::Completion { shell } => command_completion(shell), 272 Commands::Drop { task_id } => command_drop(dir, task_id), 273 Commands::Find { args, short_id } => command_find(dir, short_id, args), 274 Commands::Rot => Workspace::from_path(dir).unwrap().rot(), 275 Commands::Tor => Workspace::from_path(dir).unwrap().tor(), 276 Commands::Prioritize { task_id } => command_prioritize(dir, task_id), 277 Commands::Deprioritize { task_id } => command_deprioritize(dir, task_id), 278 }; 279 let result = var_name; 280 match result { 281 Ok(_) => exit(0), 282 Err(e) => { 283 eprintln!("{e}"); 284 exit(2); 285 } 286 } 287} 288 289fn taskid_from_tsk_id(tsk_id: Id) -> TaskId { 290 TaskId { 291 tsk_id: Some(tsk_id), 292 id: None, 293 relative_id: 0, 294 find: Find { 295 find: false, 296 args: FindArgs { search_body: false }, 297 }, 298 } 299} 300 301fn command_init(dir: PathBuf) -> Result<()> { 302 Workspace::init(dir) 303} 304 305fn create_task( 306 workspace: &mut Workspace, 307 edit: bool, 308 body: Option<String>, 309 title: Title, 310) -> Result<Task> { 311 let mut title = if let Some(title) = title.title { 312 title 313 } else if let Some(title) = title.title_simple { 314 title.join(" ") 315 } else { 316 "".to_string() 317 }; 318 let mut body = body.unwrap_or_default(); 319 if body == "-" { 320 // add newline so you can type directly in the shell 321 //eprintln!(""); 322 body.clear(); 323 std::io::stdin().read_to_string(&mut body)?; 324 } 325 if edit { 326 let new_content = open_editor(format!("{title}\n\n{body}"))?; 327 if let Some(content) = new_content.split_once("\n") { 328 title = content.0.to_string(); 329 body = content.1.to_string(); 330 } 331 } 332 let task = workspace.new_task(title, body)?; 333 workspace.handle_metadata(&task, None)?; 334 Ok(task) 335} 336 337fn command_push(dir: PathBuf, edit: bool, body: Option<String>, title: Title) -> Result<()> { 338 let mut workspace = Workspace::from_path(dir)?; 339 let task = create_task(&mut workspace, edit, body, title)?; 340 workspace.push_task(task) 341} 342 343fn command_append(dir: PathBuf, edit: bool, body: Option<String>, title: Title) -> Result<()> { 344 let mut workspace = Workspace::from_path(dir)?; 345 let task = create_task(&mut workspace, edit, body, title)?; 346 workspace.append_task(task) 347} 348 349fn command_list(dir: PathBuf, all: bool, only_print_ids: bool, count: usize) -> Result<()> { 350 let workspace = Workspace::from_path(dir)?; 351 let stack = workspace.read_stack()?; 352 353 if stack.empty() { 354 println!("*No tasks*"); 355 exit(0); 356 } 357 358 for (_, stack_item) in stack 359 .into_iter() 360 .enumerate() 361 .take_while(|(idx, _)| all || idx < &count) 362 { 363 match (task::parse(&stack_item.title), only_print_ids) { 364 (None, false) => { 365 println!("{stack_item}"); 366 }, 367 (Some(parsed), false) => { 368 println!("{}\t{}", stack_item.id, parsed.content.trim()) 369 }, 370 (None, true) | (Some(_), true) => { 371 println!("{}", stack_item.id) 372 }, 373 } 374 } 375 Ok(()) 376} 377 378fn command_swap(dir: PathBuf) -> Result<()> { 379 let workspace = Workspace::from_path(dir)?; 380 workspace.swap_top()?; 381 Ok(()) 382} 383 384fn command_edit(dir: PathBuf, id: TaskId) -> Result<()> { 385 let workspace = Workspace::from_path(dir)?; 386 let id: TaskIdentifier = id.into(); 387 let mut task = workspace.task(id)?; 388 let pre_links = task::parse(&task.to_string()).map(|pt| pt.intenal_links()); 389 let new_content = open_editor(format!("{}\n\n{}", task.title.trim(), task.body.trim()))?; 390 if let Some((title, body)) = new_content.split_once("\n") { 391 task.title = title.to_string(); 392 task.body = body.to_string(); 393 workspace.handle_metadata(&task, pre_links)?; 394 task.save()?; 395 } 396 Ok(()) 397} 398 399fn command_completion(shell: Shell) -> Result<()> { 400 generate(shell, &mut Cli::command(), "tsk", &mut io::stdout()); 401 Ok(()) 402} 403 404fn command_drop(dir: PathBuf, task_id: TaskId) -> Result<()> { 405 if let Some(id) = Workspace::from_path(dir)?.drop(task_id.into())? { 406 eprint!("Dropped "); 407 println!("{id}"); 408 } else { 409 eprintln!("No task to drop."); 410 exit(1); 411 } 412 Ok(()) 413} 414 415fn command_find(dir: PathBuf, short_id: bool, find_args: FindArgs) -> Result<()> { 416 let id = Workspace::from_path(dir)?.search(None, find_args.search_body, false)?; 417 if let Some(id) = id { 418 if short_id { 419 // print as integer 420 println!("{}", id.0); 421 } else { 422 println!("{id}"); 423 } 424 } else { 425 eprintln!("No task selected."); 426 exit(1); 427 } 428 Ok(()) 429} 430 431fn command_prioritize(dir: PathBuf, task_id: TaskId) -> Result<()> { 432 Workspace::from_path(dir)?.prioritize(task_id.into()) 433} 434 435fn command_deprioritize(dir: PathBuf, task_id: TaskId) -> Result<()> { 436 Workspace::from_path(dir)?.deprioritize(task_id.into()) 437} 438 439fn command_show(dir: PathBuf, task_id: TaskId, show_attrs: bool, raw: bool) -> Result<()> { 440 let task = Workspace::from_path(dir)?.task(task_id.into())?; 441 // YAML front-matter style. YAML is gross, but it's what everyone uses! 442 if show_attrs && !task.attributes.is_empty() { 443 println!("---"); 444 for (attr, value) in task.attributes.iter() { 445 println!("{attr}: \"{value}\""); 446 } 447 println!("---"); 448 } 449 match task::parse(&task.to_string()) { 450 Some(styled_task) if !raw => { 451 writeln!(io::stdout(), "{}", styled_task.content)?; 452 } 453 _ => { 454 println!("{task}"); 455 } 456 } 457 Ok(()) 458} 459 460fn command_follow(dir: PathBuf, task_id: TaskId, link_index: usize, edit: bool) -> Result<()> { 461 let task = Workspace::from_path(dir.clone())?.task(task_id.into())?; 462 if let Some(parsed_task) = task::parse(&task.to_string()) { 463 if link_index == 0 || link_index > parsed_task.links.len() { 464 eprintln!("Link index out of bounds."); 465 exit(1); 466 } 467 let link = &parsed_task.links[link_index - 1]; 468 match link { 469 ParsedLink::External(url) => { 470 open::that_detached(url.as_str())?; 471 Ok(()) 472 } 473 ParsedLink::Internal(id) => { 474 let taskid = taskid_from_tsk_id(*id); 475 if edit { 476 command_edit(dir, taskid) 477 } else { 478 command_show(dir, taskid, false, false) 479 } 480 } 481 } 482 } else { 483 eprintln!("Unable to parse any links from body."); 484 exit(1); 485 } 486}