A file-based task manager
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}