···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() {
+264-66
src/main.rs
···01mod errors;
2mod fzf;
3mod stack;
04mod util;
5mod workspace;
6-use clap_complete::{generate, Shell};
7use errors::Result;
8-use std::io;
9use std::path::PathBuf;
10use std::process::exit;
011use std::{env::current_dir, io::Read};
12-use workspace::{Id, TaskIdentifier, Workspace};
01314//use smol;
15//use iocraft::prelude::*;
16-use clap::{value_parser, Args, CommandFactory, Parser, Subcommand};
17use edit::edit as open_editor;
1819fn default_dir() -> PathBuf {
20 current_dir().unwrap()
21}
22000023#[derive(Parser)]
24// TODO: add long_about
25#[command(version, about)]
26struct Cli {
027 #[arg(short = 'C', env = "TSK_ROOT", value_name = "DIR")]
28 dir: Option<PathBuf>,
29 // TODO: other global options
···52 #[command(flatten)]
53 title: Title,
54 },
000000000000000000055 List {
56 /// Whether to list all tasks in the task stack. If specified, -c / count is ignored.
57 #[arg(short = 'a', default_value_t = false)]
···7879 /// Use fuzzy finding with `fzf` to search for a task
80 Find {
81- /// Include the contents of tasks in the search criteria.
82- #[arg(short = 'b', default_value_t = false)]
83- search_body: bool,
84- /// Include archived tasks in the search criteria. Combine with `-b` to include archived
85- /// bodies in the search criteria.
86- #[arg(short = 'a', default_value_t = false)]
87- search_archived: bool,
00000000000008889- #[arg(short = 't', default_value_t = true)]
90- full_id: bool,
000000000000091 },
9293 /// Drops the task on the top of the stack and archives it.
94- Drop,
0000950096 Rot,
0097 Tor,
9899- Reprioritize {
0100 /// The [TSK-]ID to prioritize. If it exists, it is moved to the top of the stack.
101 #[command(flatten)]
102 task_id: TaskId,
103 },
0000000104}
105106#[derive(Args)]
···123 id: Option<u32>,
124125 /// The ID of the task to select with the 'tsk-' prefix.
126- #[arg(short = 'T', value_name = "TSK-ID", value_parser = value_parser!(String))]
127 tsk_id: Option<Id>,
128129 /// Selects a task relative to the top of the stack.
···131 #[arg(short = 'r', value_name = "RELATIVE", default_value_t = 0)]
132 relative_id: u32,
133134- /// Use fuzzy finding to search for and select a task.
135- /// Does not support searching task bodies or archived tasks.
00000000136 #[arg(short = 'f', value_name = "FIND", default_value_t = false)]
137 find: bool,
0000000000000000138}
139140impl From<TaskId> for TaskIdentifier {
141 fn from(value: TaskId) -> Self {
142 if let Some(id) = value.id.map(Id::from).or(value.tsk_id) {
143 TaskIdentifier::Id(id)
00000144 } else {
145- if value.find {
146- TaskIdentifier::Find
147- } else {
148- TaskIdentifier::Relative(value.relative_id)
149- }
150 }
151 }
152}
···154fn main() {
155 let cli = Cli::parse();
156 let dir = cli.dir.unwrap_or(default_dir());
157- let result = match cli.command {
158 Commands::Init => command_init(dir),
159 Commands::Push { edit, body, title } => command_push(dir, edit, body, title),
0160 Commands::List { all, count } => command_list(dir, all, count),
161 Commands::Swap => command_swap(dir),
0000000000162 Commands::Edit { task_id } => command_edit(dir, task_id),
163 Commands::Completion { shell } => command_completion(shell),
164- Commands::Drop => command_drop(dir),
165- Commands::Find {
166- full_id,
167- search_body,
168- search_archived,
169- } => command_find(dir, full_id, search_body, search_archived),
170 Commands::Rot => Workspace::from_path(dir).unwrap().rot(),
171 Commands::Tor => Workspace::from_path(dir).unwrap().tor(),
172- Commands::Reprioritize { task_id } => command_reprioritize(dir, task_id),
0173 };
0174 match result {
175 Ok(_) => exit(0),
176 Err(e) => {
···180 }
181}
182000000000000183fn command_init(dir: PathBuf) -> Result<()> {
184 Workspace::init(dir)
185}
186187-fn command_push(dir: PathBuf, edit: bool, body: Option<String>, title: Title) -> Result<()> {
188- let workspace = Workspace::from_path(dir)?;
0000189 let mut title = if let Some(title) = title.title {
190 title
191 } else if let Some(title) = title.title_simple {
192- let joined = title.join(" ");
193- joined
194 } else {
195 "".to_string()
196 };
197- let mut body = body.unwrap_or_default();
00000000000000198 if body == "-" {
199 // add newline so you can type directly in the shell
200- eprintln!("");
201 body.clear();
202 std::io::stdin().read_to_string(&mut body)?;
203 }
···208 body = content.1.to_string();
209 }
210 }
00211 let task = workspace.new_task(title, body)?;
0000000212 workspace.push_task(task)
213}
214000000215fn command_list(dir: PathBuf, all: bool, count: usize) -> Result<()> {
216 let workspace = Workspace::from_path(dir)?;
217- let stack = if all {
218- workspace.read_stack()?
219- } else {
220- workspace.read_stack()?
221- };
222 if stack.empty() {
223 println!("*No tasks*");
224 exit(0);
225- } else {
226- if !all {
227- for stack_item in stack.into_iter().take(count) {
228- println!("{stack_item}");
229- }
0000230 } else {
231- for stack_item in stack.into_iter() {
232- println!("{stack_item}");
233- }
234 }
235 }
236 Ok(())
···246 let workspace = Workspace::from_path(dir)?;
247 let id: TaskIdentifier = id.into();
248 let mut task = workspace.task(id)?;
0249 let new_content = open_editor(format!("{}\n\n{}", task.title.trim(), task.body.trim()))?;
250 if let Some((title, body)) = new_content.split_once("\n") {
251- task.title = title.to_string();
0252 task.body = body.to_string();
0253 task.save()?;
254 }
255 Ok(())
···260 Ok(())
261}
262263-fn command_drop(dir: PathBuf) -> Result<()> {
264- if let Some(id) = Workspace::from_path(dir)?.drop()? {
265 eprint!("Dropped ");
266 println!("{id}");
267 } else {
···271 Ok(())
272}
273274-fn command_find(
275- dir: PathBuf,
276- full_id: bool,
277- search_body: bool,
278- search_archived: bool,
279-) -> Result<()> {
280- let id = Workspace::from_path(dir)?.search(None, search_body, search_archived)?;
281 if let Some(id) = id {
282- if full_id {
283- println!("{id}");
284- } else {
285 // print as integer
286 println!("{}", id.0);
00287 }
288 } else {
289- eprintln!("No task to drop.");
290 exit(1);
291 }
292 Ok(())
293}
294295-fn command_reprioritize(dir: PathBuf, task_id: TaskId) -> Result<()> {
296- Workspace::from_path(dir)?.reprioritize(task_id.into())
00000000000000000000000000000000000000000000000000000297}
···1+mod attrs;
2mod errors;
3mod fzf;
4mod stack;
5+mod task;
6mod util;
7mod workspace;
8+use clap_complete::{Shell, generate};
9use errors::Result;
10+use std::io::{self, Write};
11use std::path::PathBuf;
12use 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+ },
129130+ /// 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+ },
153154+ /// 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,
160161+ /// 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)]
···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>,
198199 /// Selects a task relative to the top of the stack.
···201 #[arg(short = 'r', value_name = "RELATIVE", default_value_t = 0)]
202 relative_id: u32,
203204+ #[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}
233234impl 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)
0000245 }
246 }
247}
···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),
0000272 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) => {
···284 }
285}
286287+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+}
298+299fn 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)?;
336 }
···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}
356357+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+}
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+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(())
···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(())
···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 {
···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)?;
00000426 if let Some(id) = id {
427+ if short_id {
00428 // 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}
439440+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}
+24-17
src/stack.rs
···45use crate::errors::{Error, Result};
6use crate::util;
7-use std::collections::vec_deque::Iter;
8use std::collections::VecDeque;
09use std::fmt::Display;
010use std::io::{self, BufRead, BufReader, Seek, Write};
011use std::str::FromStr;
12use std::time::{Duration, SystemTime, UNIX_EPOCH};
13-use std::{fs::File, path::PathBuf};
1415use nix::fcntl::{Flock, FlockArg};
16···67 let mut parts = s.trim().split("\t");
68 let id: Id = parts
69 .next()
70- .ok_or(Error::Parse(format!(
71- "Incomplete index line. Missing tsk ID"
72- )))?
73 .parse()?;
74 let title: String = parts
75 .next()
76- .ok_or(Error::Parse(format!(
77- "Incomplete index line. Missing title."
78- )))?
79 .trim()
80 .to_string();
81 // parse the timestamp as an integer
···9697impl StackItem {
98 /// Parses a [`StackItem`] from a string. The expected format is a tab-delimited line with the
99- /// files: task id title
100- fn from_line(workspace_path: &PathBuf, line: String) -> Result<Self> {
101 let mut stack_item: StackItem = line.parse()?;
102103 let task = util::flopen(
104 workspace_path
105 .join(TASKSFOLDER)
106- .join(stack_item.id.to_filename()),
107 FlockArg::LockExclusive,
108 )?;
109 let task_modify_time = task.metadata()?.modified()?;
···125}
126127impl TaskStack {
128- pub fn from_tskdir(workspace_path: &PathBuf) -> Result<Self> {
129 let file = util::flopen(workspace_path.join(INDEXFILE), FlockArg::LockExclusive)?;
130 let index = BufReader::new(&*file).lines();
131 let mut all = VecDeque::new();
···142 self.file.seek(std::io::SeekFrom::Start(0))?;
143 self.file.set_len(0)?;
144 for item in self.all.iter() {
145- self.file.write_all(format!("{item}\n").as_bytes())?;
00146 }
147 Ok(())
148 }
···151 self.all.push_front(item);
152 }
1530000154 pub fn pop(&mut self) -> Option<StackItem> {
155 self.all.pop_front()
156 }
···158 pub fn swap(&mut self) {
159 let tip = self.all.pop_front();
160 let second = self.all.pop_front();
161- if tip.is_some() && second.is_some() {
162- self.all.push_front(tip.unwrap());
163- self.all.push_front(second.unwrap());
164 }
165 }
166···172 self.all.remove(index)
173 }
174175- pub fn iter(&self) -> Iter<StackItem> {
176 self.all.iter()
177 }
178
···45use crate::errors::{Error, Result};
6use crate::util;
07use 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 }
···154 self.all.push_front(item);
155 }
156157+ pub fn push_back(&mut self, item: StackItem) {
158+ self.all.push_back(item);
159+ }
160+161 pub fn pop(&mut self) -> Option<StackItem> {
162 self.all.pop_front()
163 }
···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 }
173···179 self.all.remove(index)
180 }
181182+ pub fn iter(&self) -> Iter<'_, StackItem> {
183 self.all.iter()
184 }
185