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