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}