···1+parse internal links from body
2+3+This is some !test bold text!.
4+here's some =highlighted text=
5+6+and finally some *italics!*
7+8+here's [a link](https://ngp.computer).
9+10+and an internal link: [[tsk-11]]. This should add a backlink
11+12+and some _underlined text_
13+14+some ~strikethrough~.
+11
.tsk/archive/tsk-15.tsk
···00000000000
···1+Add link identification to tasks
2+3+[This crate](https://docs.rs/linkify/latest/linkify/) should be helpful for
4+that, though I've only done a cursory search.
5+6+The intent here is to provide a command that allows you to list and open a link
7+from a provided task and optionall use a system-handler to open the link.
8+9+Something along the lines of `tsk hyperlinks -t 12 -s`, which will scan TSK-12 for
10+hyperlinks (or email addresses?) and pipe them to `fzf` for selection and
11+opening (-s flag) or simply print the link if no option is specified.
+3
.tsk/archive/tsk-16.tsk
···000
···1+Add ability to search archived tasks with find command
2+3+Probably want to use `-a` flag or something
+5
.tsk/archive/tsk-17.tsk
···00000
···1+Add reopen command
2+3+Reopen will allow selecting an *archived* task (note: this needs to be
4+restricted to archived tasks) and recreating the symlink in tasks/ to mark it as
5+open.
···1+add "raw" output option for show
2+3+Should probably be some variant of `tsk show -x` or something to skip the parsing step,
4+just display the body of the text directly.
5+6+This does suggest I should add a `format` subcommand that takes in a body and outputs
7+the parsed + styled form, could be useful for editor plugins
···1+fix issue where links use absolute paths
2+3+ MacOS
4+5+
+7
.tsk/archive/tsk-21.tsk
···0000000
···1+Add command to setup git stuff
2+3+Will want to prompt to add `.tsk` to the `.git/info/exclude` file (or
4+.gitignore/globally) and *probably* set up
5+[metastore](https://github.com/przemoc/metastore)
6+7+What else should we do?
+3
.tsk/archive/tsk-22.tsk
···000
···1+Figure out why link parsing isn't working in tsk-21
2+3+Actually, it appears *all* styling is broken somehow
+2
.tsk/archive/tsk-23.tsk
···00
···1+Allow selecting which task to follow links from
2+
···1+Add tool to clean up old tasks not in index
2+3+Previously the `drop` command did not remove the symlink in .tsk/tasks when a
4+task was dropped, only removing it from the index. I don't recall if this was
5+because my original design expected the index to be the source of truth on
6+whether a task was prioritized or not or if I simply forgot to add it. Either
7+way, drop now properly removes the symlink but basically every existing
8+workspace has a bunch of junk in it. I can write a simple script that deletes
9+all symlinks that aren't present in the index.
10+11+This does suggest that I should add a reindex command to add tasks that are not
12+present in the index but are in the tasks folder to the index at the *bottom*,
13+preserving the existing order of the index.
14+15+16+How about just adding a `fixup` command that does this and reindex as laid out in
17+[[tsk-18]].
···1+automatically add backlinks
2+3+I need to parse on save/edit/create for outgoing internal links. If any exist and their
4+corresponding task exists, update the targetted task with a backlink reference
5+6+Using [[tsk-11]] as my test.
+2
.tsk/archive/tsk-7.tsk
···00
···1+allow for creating tasks that don't go to top of stack
2+
···1+fix timestamp storage and parsing
2+3+It looks like timestamps aren't being stored or parsed from the index anymore.
4+I'm not quite sure how this broke, but it's like an issue in `StackItem`'s
5+FromStr and Display implementations.
+12
.tsk/index
···000000000000
···1+tsk-30 Add flag to only print IDs in list command 1763257109
2+tsk-28 Add tool to clean up old tasks not in index 1735006519
3+tsk-10 foreign workspaces 1732594198
4+tsk-21 Add command to setup git stuff 1732594198
5+tsk-8 IMAP4-based sync 1767469318
6+tsk-17 Add reopen command 1732594198
7+tsk-16 Add ability to search archived tasks with find command 1767466011
8+tsk-15 Add link identification to tasks 1732594198
9+tsk-9 fix timestamp storage and parsing 1732594198
10+tsk-7 allow for creating tasks that don't go to top of stack 1732594198
11+tsk-13 user-defined labels 1732594198
12+tsk-18 Add reindex command 1735006716
···1+MIT License
2+3+Copyright (c) 2024 Noah Pederson
4+5+Permission is hereby granted, free of charge, to any person obtaining a copy
6+of this software and associated documentation files (the "Software"), to deal
7+in the Software without restriction, including without limitation the rights
8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+copies of the Software, and to permit persons to whom the Software is
10+furnished to do so, subject to the following conditions:
11+12+The above copyright notice and this permission notice shall be included in all
13+copies or substantial portions of the Software.
14+15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+SOFTWARE.
···1+tsk
2+===
3+4+A fast & simple CLI task manager
5+--------------------------------
6+7+The motivation for tsk is simple: make managing tasks as fast and easy as
8+possible with a focus on small, ephemeral tasks.
9+10+Secondary goals include:
11+12+- Provide the minimum amount of features necessary to be usable by a team
13+- Support local and non-local workspaces
14+- Be adaptable to almost any environment, even employer-mandated JIRA use
15+- Be flexible, within reason
16+17+tsk takes inspiration from git and FORTH and is expected to be used alongside
18+the former.
19+20+Dependencies
21+------------
22+23+tsk is written in Rust. To compile from source, a recent Rust toolchain is
24+required. As of now, it is developed using Rust 1.81.0.
25+26+Additionally, for fuzzy-finding functionality, the fzf command must be installed
27+and in the shell's PATH.
28+29+ https://github.com/junegunn/fzf
30+31+tsk workspaces must be created on filesystems that support symlinking.
32+33+Task-level metadata requires Linux's xattr(7) API and a filesystem that supports
34+it. Patches that implement this for other operating systems are welcome.
35+36+tsk expects to run on POSIX-like systems. Microsoft Windows and other
37+non-UNIX-ey operating systems will never be directly supported.
38+39+40+Installation
41+------------
42+43+```sh
44+cargo install --locked tsk-cli
45+```
46+47+48+Building
49+--------
50+51+```sh
52+cargo install --path .
53+```
54+55+Make sure ~/.cargo/bin is in your PATH.
56+57+Overview
58+--------
59+60+A summary of commands and their functionality can be seen with:
61+62+ tsk help
63+64+tsk uses plain text files for all of its functionality. A workspace is a folder
65+that contains a .tsk/ directory created with the `tsk init` command. The
66+presence of a .tsk/ folder is searched recursively upwards until a filesystem
67+boundary or root is encountered. This means you can nest workspaces and use
68+folders to namespace tasks while also using tsk commands at any location within
69+a workspace.
70+71+New tasks are created with the `tsk push` command. A title is always required,
72+but can be modified later. A unique identifier is selected automatically and a
73+file with the title and any body contents supplied are stored in the
74+.tsk/archive folder. A symlink is then created in the .tsk/tasks folder marking
75+the task as "open." The task is then added to the top of the "stack" by having
76+its tsk-ID and title added to the .tsk/index file.
77+78+The contents of the stack may be printed using the `tsk list` command.
79+80+Tasks are marked as "completed" and removed from the index with the `tsk drop`
81+command. They will remain in the .tsk/archive folder, but are excluded from
82+fuzzy searches by default.
83+84+The priority of a task may be manipulated in any of several ways:
85+86+`tsk swap` swaps the top two task on the stack
87+88+ โโโโโโโโโโโ โโโโโโโโโโโ
89+ โ tsk-100 โ โ tsk-102 โ
90+ โโโโโโโโโโโ โโโฌโโโโโโฒโโ
91+ โ โ
92+ โโโโโโโโโโโ โโโผโโโโโโดโโ
93+ โ tsk-102 โ โโโโโโโโบ โ tsk-100 โ
94+ โโโโโโโโโโโ โโโโโโโโโโโ
95+96+ โโโโโโโโโโโ โโโโโโโโโโโ
97+ โ tsk-108 โ โ tsk-108 โ
98+ โโโโโโโโโโโ โโโโโโโโโโโ
99+100+`tsk rot` moves the 3rd task on the stack to the top of the stack and shifts
101+the first and second down
102+103+ โโโโโโโโโโโ โโโโโโโโโโโ
104+ โ tsk-100 โ โ tsk-108 โโโ
105+ โโโโโโโโโโโ โโโโโโฌโโโโโ โ
106+ โ โ
107+ โโโโโโโโโโโ โโโโโโผโโโโโ โ
108+ โ tsk-102 โ โโโโโโโโบ โ tsk-100 โ โ
109+ โโโโโโโโโโโ โโโโโโฌโโโโโ โ
110+ โ โ
111+ โโโโโโโโโโโ โโโโโโผโโโโโ โ
112+ โ tsk-108 โ โ tsk-102 โโโ
113+ โโโโโโโโโโโ โโโโโโโโโโโ
114+115+`tsk tor` moves the task on the top of the stack behind the third, shifting the
116+second and third tasks up.
117+118+ โโโโโโโโโโโ โโโโโโโโโโโ
119+ โ tsk-100 โ โ tsk-102 โโโ
120+ โโโโโโโโโโโ โโโโโโฒโโโโโ โ
121+ โ โ
122+ โโโโโโโโโโโ โโโโโโดโโโโโ โ
123+ โ tsk-102 โ โโโโโโโโบ โ tsk-108 โ โ
124+ โโโโโโโโโโโ โโโโโโฒโโโโโ โ
125+ โ โ
126+ โโโโโโโโโโโ โโโโโโดโโโโโ โ
127+ โ tsk-108 โ โ tsk-100 โโโ
128+ โโโโโโโโโโโ โโโโโโโโโโโ
129+130+`tsk prioritize` will take a selected task and move it to the top of the stack
131+from any other position in the stack. It is selected either by ID or using fuzzy
132+finding.
133+134+`tsk deprioritize` moves a selected task to the bottom of the stack from any
135+position.
136+137+Roadmap
138+-------
139+140+- Configurable workspace-scoped prefix tags (tsk- vs example-)
141+- Extended Attribute-based Metadata
142+- Task Linking
143+- IMAP4/SMTP-based synchronization and sharing
144+- Export + sync with external task managers
145+ - GitLab
146+ - GitHub
147+ - JIRA
148+ - Obsidian
149+ - More?
150+- tsk -> html export
151+- Editor plugins
152+ - nvim-telescope
153+ - nvim
154+ - others?
155+- Man page
156+157+Format
158+------
159+160+The tsk text format can be thought of as a derivative of Markdown and scdoc, but
161+not quite either. Markdown is a great baseline for rich-text while scdoc
162+restricts itself to rich text formatting that can be displayed effectively in a
163+terminal. As tsk's primary goal is to be a fast, terminal-centric task manager,
164+this property is a *must.*
165+166+Additionally, it should be similar enough to Markdown such that it is easy to
167+export to other applications, as outlined above in the roadmap.
168+169+Meanwhile, both Markdown and scdoc have some limitations and make choices that,
170+while appropriate for their domain, are not appropriate for tsk. Some notable
171+differences from both:
172+173+- There is only one way to do any type of formatting
174+- Hard line breaks are real, not imaginary
175+- Inline formatting control characters must be surrounded by space, newline, or
176+ common punctuation
177+178+A core feature of the format is *linking*. That is, references to other tasks
179+utilizing wiki-link style links: `[[]]`. The content within the link is mapped
180+to the local workspace if the `tsk-` prefix is used, or a mapped non-local
181+workspace if another prefix is used. These mappings are specified using a text
182+file within the .tsk folder.
183+184+A quick overview of the format:
185+186+- \!Bolded\! text is surrounded by exclamation marks (!)
187+- \*Italicized\* text is surrounded by single asterisks (*)
188+- \_Underlined\_ text is surrounded by underscores (_)
189+- \~Strikethrough\~ text is surrounded by tildes (~)
190+- \=Highlighted\= text is surrounded by equals signs (=)
191+- \`Inline code\` is surrounded by backticks (`)
192+193+Links like in Markdown, along with the wiki-style links documented above.
194+Raw links can also be written as \<https://example.com\>.
195+196+Misc
197+----
198+199+tsk is heavily inspired by git. It mimics its folder structure and some
200+commands. The concept of the stack is inspired by FORTH and the observation that
201+most of the time, only the top 3 priorities at any given moment matter and tasks
202+tend to be created when they are most important. This facilitates small,
203+frequent creation of tasks that help both document problems and manage
204+fast-paced work environments.
205+206+tsk is not intended to be checked into git, however there is not a reason that it
207+cannot be. This repository's development is managed using tsk itself.
208+209+Git does *not* track extended filesystem attributes. If you wish to avoid constantly
210+re-indexing, use something like metastore:
211+212+ https://github.com/przemoc/metastore
···1use crate::errors::{Error, Result};
02use std::fmt::Display;
3use std::io::Write;
4use std::process::{Command, Stdio};
···67/// Sends each item as a line to stdin to the `fzf` command and returns the selected item's string
8/// representation as output
9-pub fn select<I>(input: impl IntoIterator<Item = I>) -> Result<Option<I>>
00010where
11- I: Display + FromStr,
12- Error: From<<I as FromStr>::Err>,
0013{
14- let mut child = Command::new("fzf")
15- .args(["-d", "\t"])
0016 .stderr(Stdio::inherit())
17 .stdin(Stdio::piped())
18 .stdout(Stdio::piped())
···20 // unwrap: this can never fail
21 let child_in = child.stdin.as_mut().unwrap();
22 for item in input.into_iter() {
23- write!(child_in, "{}\n", item.to_string())?;
24 }
25 let output = child.wait_with_output()?;
26 if output.stdout.is_empty() {
···1use crate::errors::{Error, Result};
2+use std::ffi::OsStr;
3use std::fmt::Display;
4use std::io::Write;
5use std::process::{Command, Stdio};
···78/// Sends each item as a line to stdin to the `fzf` command and returns the selected item's string
9/// representation as output
10+pub fn select<I, O, S>(
11+ input: impl IntoIterator<Item = I>,
12+ extra: impl IntoIterator<Item = S>,
13+) -> Result<Option<O>>
14where
15+ O: FromStr,
16+ I: Display,
17+ Error: From<<O as FromStr>::Err>,
18+ S: AsRef<OsStr>,
19{
20+ let mut command = Command::new("fzf");
21+ let mut child = command
22+ .args(extra)
23+ .arg("--read0")
24 .stderr(Stdio::inherit())
25 .stdin(Stdio::piped())
26 .stdout(Stdio::piped())
···28 // unwrap: this can never fail
29 let child_in = child.stdin.as_mut().unwrap();
30 for item in input.into_iter() {
31+ write!(child_in, "{item}\0")?;
32 }
33 let output = child.wait_with_output()?;
34 if output.stdout.is_empty() {
+346-89
src/main.rs
···01mod errors;
2mod fzf;
3mod stack;
04mod util;
5mod workspace;
6-use clap_complete::{generate, Shell};
7-use std::io;
08use std::path::PathBuf;
009use std::{env::current_dir, io::Read};
10-use workspace::{Id, Workspace};
01112//use smol;
13//use iocraft::prelude::*;
14-use clap::{value_parser, Args, CommandFactory, Parser, Subcommand};
15use edit::edit as open_editor;
1617fn default_dir() -> PathBuf {
18 current_dir().unwrap()
19}
20000021#[derive(Parser)]
22// TODO: add long_about
23#[command(version, about)]
24struct Cli {
025 #[arg(short = 'C', env = "TSK_ROOT", value_name = "DIR")]
26 dir: Option<PathBuf>,
27 // TODO: other global options
···50 #[command(flatten)]
51 title: Title,
52 },
000000000000000000053 List {
54 /// Whether to list all tasks in the task stack. If specified, -c / count is ignored.
55 #[arg(short = 'a', default_value_t = false)]
···7677 /// Use fuzzy finding with `fzf` to search for a task
78 Find {
79- /// Include the contents of tasks in the search criteria.
80- #[arg(short = 'b', default_value_t = false)]
81- search_body: bool,
82- /// Include archived tasks in the search criteria. Combine with `-b` to include archived
83- /// bodies in the search criteria.
84- #[arg(short = 'a', default_value_t = false)]
85- search_archived: bool,
0000000000000000000000000000086 },
8788 /// Drops the task on the top of the stack and archives it.
89- Drop,
000000000000000000000000090}
9192#[derive(Args)]
···104#[derive(Args)]
105#[group(required = false, multiple = false)]
106struct TaskId {
0107 #[arg(short = 't', value_name = "ID")]
108 id: Option<u32>,
109110- #[arg(short = 'T', value_name = "TSK-ID", value_parser = value_parser!(String))]
0111 tsk_id: Option<Id>,
0000000000000000000000000000000000000000000000000112}
113114fn main() {
115 let cli = Cli::parse();
116- match cli.command {
117- Commands::Init => command_init(cli.dir.unwrap_or(default_dir())),
118- Commands::Push { edit, body, title } => {
119- command_push(cli.dir.unwrap_or(default_dir()), edit, body, title)
120- }
121- Commands::List { all, count } => command_list(cli.dir.unwrap_or(default_dir()), all, count),
122- Commands::Swap => command_swap(cli.dir.unwrap_or(default_dir())),
123- Commands::Edit { task_id } => command_edit(cli.dir.unwrap_or(default_dir()), task_id),
0000000000124 Commands::Completion { shell } => command_completion(shell),
125- Commands::Drop => command_drop(cli.dir.unwrap_or(default_dir())),
126- Commands::Find {
127- search_body,
128- search_archived,
129- } => command_search(cli.dir.unwrap_or(default_dir())),
000000000000000000000130 }
131}
132133-fn command_init(dir: PathBuf) {
134- Workspace::init(dir).expect("Init failed")
135}
136137-fn command_push(dir: PathBuf, edit: bool, body: Option<String>, title: Title) {
138- let workspace = Workspace::from_path(dir).expect("Unable to find .tsk dir");
0000139 let mut title = if let Some(title) = title.title {
140 title
141 } else if let Some(title) = title.title_simple {
142- let joined = title.join(" ");
143- joined
144 } else {
145 "".to_string()
146 };
147- let mut body = body.unwrap_or_default();
00000000000000148 if body == "-" {
149 // add newline so you can type directly in the shell
150- eprintln!("");
151 body.clear();
152- std::io::stdin()
153- .read_to_string(&mut body)
154- .expect("Failed to read stdin");
155 }
156 if edit {
157- let new_content = open_editor(format!("{title}\n\n{body}")).expect("Failed to edit file");
158 if let Some(content) = new_content.split_once("\n") {
159 title = content.0.to_string();
160 body = content.1.to_string();
161 }
162 }
163- let task = workspace
164- .new_task(title, body)
165- .expect("Failed to create task");
166- workspace
167- .push_task(task)
168- .expect("Failed to push task to stack");
00000000000169}
170171-fn command_list(dir: PathBuf, all: bool, count: usize) {
172- let workspace = Workspace::from_path(dir).expect("Unable to find .tsk dir");
173- let stack = if all {
174- workspace.read_stack().expect("Failed to read index")
175- } else {
176- workspace.read_stack().expect("Failed to read index")
177- };
178 if stack.empty() {
179 println!("*No tasks*");
180- } else {
181- if !all {
182- for stack_item in stack.into_iter().take(count) {
183- println!("{stack_item}");
184- }
00000185 } else {
186- for stack_item in stack.into_iter() {
187- println!("{stack_item}");
188- }
189 }
190 }
0191}
192193-fn command_swap(dir: PathBuf) {
194- let workspace = Workspace::from_path(dir).expect("Unable to find .tsk dir");
195- workspace.swap_top().expect("swap to work");
0196}
197198-fn command_edit(dir: PathBuf, id: TaskId) {
199- let workspace = Workspace::from_path(dir).expect("Unable to find .tsk dir");
200- let tsk_id: Option<Id> = id.id.map(Id::from).or(id.tsk_id);
201- let mut task = if let Some(id) = tsk_id {
202- workspace.task(id.into()).expect("To read task from disk")
203- } else {
204- let mut stack = workspace.read_stack().expect("to read stack");
205- let stack_item = stack.pop().expect("No tasks on stack.");
206- workspace.task(stack_item.id).expect("couldn't read task")
207- };
208- let new_content = open_editor(format!("{}\n\n{}", task.title.trim(), task.body.trim()))
209- .expect("Failed to edit file");
210 if let Some((title, body)) = new_content.split_once("\n") {
211- task.title = title.to_string();
0212 task.body = body.to_string();
213- task.save().expect("Failed to save task");
0214 }
0215}
216217-fn command_completion(shell: Shell) {
218- generate(shell, &mut Cli::command(), "tsk", &mut io::stdout())
0219}
220221-fn command_drop(dir: PathBuf) {
222- if let Some(id) = Workspace::from_path(dir)
223- .expect("Unable to find .tsk dir")
224- .drop()
225- .expect("Unable to drop task.") {
226- println!("Dropped {id}")
0227 }
0228}
229230-fn command_search(dir: PathBuf) {
231- let id = Workspace::from_path(dir).unwrap().search().unwrap();
232 if let Some(id) = id {
233- eprint!("Dropping ");
234- println!("{id}");
0000235 } else {
236- eprintln!("No task to drop.")
00000000000000000000000000000000000000000000000000000000000237 }
238}
···1+mod attrs;
2mod errors;
3mod fzf;
4mod stack;
5+mod task;
6mod util;
7mod workspace;
8+use clap_complete::{Shell, generate};
9+use errors::Result;
10+use std::io::{self, Write};
11use std::path::PathBuf;
12+use std::process::exit;
13+use std::str::FromStr as _;
14use std::{env::current_dir, io::Read};
15+use task::ParsedLink;
16+use workspace::{Id, Task, TaskIdentifier, Workspace};
1718//use smol;
19//use iocraft::prelude::*;
20+use clap::{Args, CommandFactory, Parser, Subcommand};
21use edit::edit as open_editor;
2223fn default_dir() -> PathBuf {
24 current_dir().unwrap()
25}
2627+fn 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
···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)]
···106107 /// 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 },
146147 /// 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}
175176#[derive(Args)]
···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>,
194195+ /// 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)]
212+struct 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)]
222+struct 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+234+impl 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}
248249fn 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+287+fn 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}
298299+fn command_init(dir: PathBuf) -> Result<()> {
300+ Workspace::init(dir)
301}
302303+fn 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(" ")
0313 } 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)?;
00336 }
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+351+fn 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+357+fn 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}
362363+fn command_list(dir: PathBuf, all: bool, count: usize) -> Result<()> {
364+ let workspace = Workspace::from_path(dir)?;
365+ let stack = workspace.read_stack()?;
366+000367 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}");
00381 }
382 }
383+ Ok(())
384}
385386+fn command_swap(dir: PathBuf) -> Result<()> {
387+ let workspace = Workspace::from_path(dir)?;
388+ workspace.swap_top()?;
389+ Ok(())
390}
391392+fn 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()))?;
000000398 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}
407408+fn command_completion(shell: Shell) -> Result<()> {
409+ generate(shell, &mut Cli::command(), "tsk", &mut io::stdout());
410+ Ok(())
411}
412413+fn 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}
423424+fn 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+440+fn command_prioritize(dir: PathBuf, task_id: TaskId) -> Result<()> {
441+ Workspace::from_path(dir)?.prioritize(task_id.into())
442+}
443+444+fn command_deprioritize(dir: PathBuf, task_id: TaskId) -> Result<()> {
445+ Workspace::from_path(dir)?.deprioritize(task_id.into())
446+}
447+448+fn 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+469+fn 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}
+35-15
src/stack.rs
···5use crate::errors::{Error, Result};
6use crate::util;
7use std::collections::VecDeque;
08use std::fmt::Display;
09use std::io::{self, BufRead, BufReader, Seek, Write};
010use std::str::FromStr;
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12-use std::{fs::File, path::PathBuf};
1314use nix::fcntl::{Flock, FlockArg};
15···66 let mut parts = s.trim().split("\t");
67 let id: Id = parts
68 .next()
69- .ok_or(Error::Parse(format!(
70- "Incomplete index line. Missing tsk ID"
71- )))?
72 .parse()?;
73 let title: String = parts
74 .next()
75- .ok_or(Error::Parse(format!(
76- "Incomplete index line. Missing title."
77- )))?
78 .trim()
79 .to_string();
80 // parse the timestamp as an integer
···9596impl StackItem {
97 /// Parses a [`StackItem`] from a string. The expected format is a tab-delimited line with the
98- /// files: task id title
99- fn from_line(workspace_path: &PathBuf, line: String) -> Result<Self> {
100 let mut stack_item: StackItem = line.parse()?;
101102 let task = util::flopen(
103 workspace_path
104 .join(TASKSFOLDER)
105- .join(stack_item.id.to_filename()),
106 FlockArg::LockExclusive,
107 )?;
108 let task_modify_time = task.metadata()?.modified()?;
···124}
125126impl TaskStack {
127- pub fn from_tskdir(workspace_path: &PathBuf) -> Result<Self> {
128 let file = util::flopen(workspace_path.join(INDEXFILE), FlockArg::LockExclusive)?;
129 let index = BufReader::new(&*file).lines();
130 let mut all = VecDeque::new();
···141 self.file.seek(std::io::SeekFrom::Start(0))?;
142 self.file.set_len(0)?;
143 for item in self.all.iter() {
144- self.file.write_all(format!("{item}\n").as_bytes())?;
00145 }
146 Ok(())
147 }
148149 pub fn push(&mut self, item: StackItem) {
150 self.all.push_front(item);
0000151 }
152153 pub fn pop(&mut self) -> Option<StackItem> {
···157 pub fn swap(&mut self) {
158 let tip = self.all.pop_front();
159 let second = self.all.pop_front();
160- if tip.is_some() && second.is_some() {
161- self.all.push_front(tip.unwrap());
162- self.all.push_front(second.unwrap());
163 }
164 }
165166 pub fn empty(&self) -> bool {
167 self.all.is_empty()
000000000000168 }
169}
170
···5use crate::errors::{Error, Result};
6use crate::util;
7use std::collections::VecDeque;
8+use std::collections::vec_deque::Iter;
9use std::fmt::Display;
10+use std::fs::File;
11use std::io::{self, BufRead, BufReader, Seek, Write};
12+use std::path::Path;
13use std::str::FromStr;
14use std::time::{Duration, SystemTime, UNIX_EPOCH};
01516use nix::fcntl::{Flock, FlockArg};
17···68 let mut parts = s.trim().split("\t");
69 let id: Id = parts
70 .next()
71+ .ok_or(Error::Parse(
72+ "Incomplete index line. Missing tsk ID".to_owned(),
73+ ))?
74 .parse()?;
75 let title: String = parts
76 .next()
77+ .ok_or(Error::Parse(
78+ "Incomplete index line. Missing title.".to_owned(),
79+ ))?
80 .trim()
81 .to_string();
82 // parse the timestamp as an integer
···9798impl StackItem {
99 /// Parses a [`StackItem`] from a string. The expected format is a tab-delimited line with the
100+ /// files: task id title
101+ fn from_line(workspace_path: &Path, line: String) -> Result<Self> {
102 let mut stack_item: StackItem = line.parse()?;
103104 let task = util::flopen(
105 workspace_path
106 .join(TASKSFOLDER)
107+ .join(stack_item.id.filename()),
108 FlockArg::LockExclusive,
109 )?;
110 let task_modify_time = task.metadata()?.modified()?;
···126}
127128impl TaskStack {
129+ pub fn from_tskdir(workspace_path: &Path) -> Result<Self> {
130 let file = util::flopen(workspace_path.join(INDEXFILE), FlockArg::LockExclusive)?;
131 let index = BufReader::new(&*file).lines();
132 let mut all = VecDeque::new();
···143 self.file.seek(std::io::SeekFrom::Start(0))?;
144 self.file.set_len(0)?;
145 for item in self.all.iter() {
146+ let time = item.modify_time.duration_since(UNIX_EPOCH)?.as_secs();
147+ self.file
148+ .write_all(format!("{item}\t{}\n", time).as_bytes())?;
149 }
150 Ok(())
151 }
152153 pub fn push(&mut self, item: StackItem) {
154 self.all.push_front(item);
155+ }
156+157+ pub fn push_back(&mut self, item: StackItem) {
158+ self.all.push_back(item);
159 }
160161 pub fn pop(&mut self) -> Option<StackItem> {
···165 pub fn swap(&mut self) {
166 let tip = self.all.pop_front();
167 let second = self.all.pop_front();
168+ if let Some((tip, second)) = tip.zip(second) {
169+ self.all.push_front(tip);
170+ self.all.push_front(second);
171 }
172 }
173174 pub fn empty(&self) -> bool {
175 self.all.is_empty()
176+ }
177+178+ pub fn remove(&mut self, index: usize) -> Option<StackItem> {
179+ self.all.remove(index)
180+ }
181+182+ pub fn iter(&self) -> Iter<'_, StackItem> {
183+ self.all.iter()
184+ }
185+186+ pub fn get(&self, index: usize) -> Option<&StackItem> {
187+ self.all.get(index)
188 }
189}
190