···11+Add tool to clean up old tasks not in index
22+33+Previously the `drop` command did not remove the symlink in .tsk/tasks when a
44+task was dropped, only removing it from the index. I don't recall if this was
55+because my original design expected the index to be the source of truth on
66+whether a task was prioritized or not or if I simply forgot to add it. Either
77+way, drop now properly removes the symlink but basically every existing
88+workspace has a bunch of junk in it. I can write a simple script that deletes
99+all symlinks that aren't present in the index.
1010+1111+This does suggest that I should add a reindex command to add tasks that are not
1212+present in the index but are in the tasks folder to the index at the *bottom*,
1313+preserving the existing order of the index.
1414+1515+1616+How about just adding a `fixup` command that does this and reindex as laid out in
1717+[[tsk-18]].
···11+Add flag to only print IDs in list command
22+
+8
.tsk/archive/tsk-31.tsk
···11+DO THE THING
22+33+44+remember to do part1
55+66+and part2
77+88+and part3
+12-10
.tsk/index
···11-tsk-21 Add command to setup git stuff 1732399520
22-tsk-8 IMAP4-based sync 1728535307
33-tsk-17 Add reopen command 1731441422
44-tsk-16 Add ability to search archived tasks with find command 1731441422
55-tsk-15 Add link identification to tasks 1731441422
66-tsk-9 fix timestamp storage and parsing 1728534908
77-tsk-10 foreign workspaces 1728534609
88-tsk-7 allow for creating tasks that don't go to top of stack 1728426156
99-tsk-13 user-defined labels 1728759002
1010-tsk-18 Add reindex command 1731441422
11+tsk-30 Add flag to only print IDs in list command 1763257109
22+tsk-28 Add tool to clean up old tasks not in index 1735006519
33+tsk-10 foreign workspaces 1732594198
44+tsk-21 Add command to setup git stuff 1732594198
55+tsk-8 IMAP4-based sync 1767469318
66+tsk-17 Add reopen command 1732594198
77+tsk-16 Add ability to search archived tasks with find command 1767466011
88+tsk-15 Add link identification to tasks 1732594198
99+tsk-9 fix timestamp storage and parsing 1732594198
1010+tsk-7 allow for creating tasks that don't go to top of stack 1732594198
1111+tsk-13 user-defined labels 1732594198
1212+tsk-18 Add reindex command 1735006716
···183183184184A quick overview of the format:
185185186186-!Bolded! text is surrounded by exclamation marks (!)
187187-*Italicized* text is surrouneded by single asterists (*)
188188-_Underlined_ text is surrounded by underscores (_)
189189-~Strikenthrough~ text is surrounded by tildes (~)
186186+- \!Bolded\! text is surrounded by exclamation marks (!)
187187+- \*Italicized\* text is surrounded by single asterisks (*)
188188+- \_Underlined\_ text is surrounded by underscores (_)
189189+- \~Strikethrough\~ text is surrounded by tildes (~)
190190+- \=Highlighted\= text is surrounded by equals signs (=)
191191+- \`Inline code\` is surrounded by backticks (`)
190192191193Links like in Markdown, along with the wiki-style links documented above.
194194+Raw links can also be written as \<https://example.com\>.
192195193196Misc
194197----
···11use crate::errors::{Error, Result};
22+use std::ffi::OsStr;
23use std::fmt::Display;
34use std::io::Write;
45use std::process::{Command, Stdio};
···6778/// Sends each item as a line to stdin to the `fzf` command and returns the selected item's string
89/// representation as output
99-pub fn select<I>(input: impl IntoIterator<Item = I>) -> Result<Option<I>>
1010+pub fn select<I, O, S>(
1111+ input: impl IntoIterator<Item = I>,
1212+ extra: impl IntoIterator<Item = S>,
1313+) -> Result<Option<O>>
1014where
1111- I: Display + FromStr,
1212- Error: From<<I as FromStr>::Err>,
1515+ O: FromStr,
1616+ I: Display,
1717+ Error: From<<O as FromStr>::Err>,
1818+ S: AsRef<OsStr>,
1319{
1414- let mut child = Command::new("fzf")
1515- .args(["-d", "\t"])
2020+ let mut command = Command::new("fzf");
2121+ let mut child = command
2222+ .args(extra)
2323+ .arg("--read0")
1624 .stderr(Stdio::inherit())
1725 .stdin(Stdio::piped())
1826 .stdout(Stdio::piped())
···2028 // unwrap: this can never fail
2129 let child_in = child.stdin.as_mut().unwrap();
2230 for item in input.into_iter() {
2323- write!(child_in, "{item}\n")?;
3131+ write!(child_in, "{item}\0")?;
2432 }
2533 let output = child.wait_with_output()?;
2634 if output.stdout.is_empty() {
+93-45
src/main.rs
···55mod task;
66mod util;
77mod workspace;
88-use clap_complete::{generate, Shell};
88+use clap_complete::{Shell, generate};
99use errors::Result;
1010use std::io::{self, Write};
1111use std::path::PathBuf;
···1313use std::str::FromStr as _;
1414use std::{env::current_dir, io::Read};
1515use task::ParsedLink;
1616-use workspace::{Id, TaskIdentifier, Workspace};
1616+use workspace::{Id, Task, TaskIdentifier, Workspace};
17171818//use smol;
1919//use iocraft::prelude::*;
···2525}
26262727fn parse_id(s: &str) -> std::result::Result<Id, &'static str> {
2828- Ok(Id::from_str(s).map_err(|_| "Unable to parse tsk- ID")?)
2828+ Id::from_str(s).map_err(|_| "Unable to parse tsk- ID")
2929}
30303131#[derive(Parser)]
···6161 #[command(flatten)]
6262 title: Title,
6363 },
6464+ /// Creates a new task just like `push`, but instead of putting it at the top of the stack, it
6565+ /// puts it at the bottom
6666+ Append {
6767+ /// Whether to open $EDITOR to edit the content of the task. The first line if the
6868+ /// resulting file will be the task's title. The body follows the title after two newlines,
6969+ /// similr to the format of a commit message.
7070+ #[arg(short = 'e', default_value_t = false)]
7171+ edit: bool,
7272+7373+ /// The body of the task. It may be specified as either a string using quotes or the
7474+ /// special character '-' to read from stdin.
7575+ #[arg(short = 'b')]
7676+ body: Option<String>,
7777+7878+ /// The title of the task as a raw string. It mus be proceeded by two dashes (--).
7979+ #[command(flatten)]
8080+ title: Title,
8181+ },
6482 /// Print the task stack. This will include just TSK-IDs and the title.
6583 List {
6684 /// Whether to list all tasks in the task stack. If specified, -c / count is ignored.
···90108 Find {
91109 #[command(flatten)]
92110 args: FindArgs,
9393- /// Whether to print the full TSK-ID (instead of just an integer)
9494- #[arg(short = 'F', default_value_t = true)]
9595- full_id: bool,
111111+ /// Whether to print the a shortened tsk ID (just the integer portion). Defaults to *false*
112112+ #[arg(short = 'f', default_value_t = false)]
113113+ short_id: bool,
96114 },
9711598116 /// Prints the contents of a task, parsing the body as rich text and formatting it using ANSI
···115133 /// address the link. That number should be supplied to the -l/link_index where it will be
116134 /// subsequently followed opened or shown.
117135 Follow {
118118- /// The index of the link to open. Must be supplied.
119119- #[arg(short = 'l')]
120120- link_index: usize,
121136 /// The task whose body will be searched for links.
122137 #[command(flatten)]
123138 task_id: TaskId,
139139+ /// The index of the link to open. Must be supplied.
140140+ #[arg(short = 'l', default_value_t = 1)]
141141+ link_index: usize,
124142 /// When opening an internal link, whether to show or edit the addressed task.
125143 #[arg(short = 'e', default_value_t = false)]
126144 edit: bool,
···202220#[derive(Args)]
203221#[group(required = false, multiple = false)]
204222struct FindArgs {
205205- /// Include the contents of tasks in the search criteria.
223223+ /// Exclude the contents of tasks in the search criteria.
206224 #[arg(short = 'b', default_value_t = false)]
207207- search_body: bool,
225225+ exclude_body: bool,
208226 /* TODO: implement this
209227 /// Include archived tasks in the search criteria. Combine with `-b` to include archived
210228 /// bodies in the search criteria.
···217235 fn from(value: TaskId) -> Self {
218236 if let Some(id) = value.id.map(Id::from).or(value.tsk_id) {
219237 TaskIdentifier::Id(id)
220220- } else {
221221- if value.find.find {
222222- TaskIdentifier::Find {
223223- search_body: value.find.args.search_body,
224224- archived: false,
225225- }
226226- } else {
227227- TaskIdentifier::Relative(value.relative_id)
238238+ } else if value.find.find {
239239+ TaskIdentifier::Find {
240240+ exclude_body: value.find.args.exclude_body,
241241+ archived: false,
228242 }
243243+ } else {
244244+ TaskIdentifier::Relative(value.relative_id)
229245 }
230246 }
231247}
···236252 let var_name = match cli.command {
237253 Commands::Init => command_init(dir),
238254 Commands::Push { edit, body, title } => command_push(dir, edit, body, title),
255255+ Commands::Append { edit, body, title } => command_append(dir, edit, body, title),
239256 Commands::List { all, count } => command_list(dir, all, count),
240257 Commands::Swap => command_swap(dir),
241258 Commands::Show {
···251268 Commands::Edit { task_id } => command_edit(dir, task_id),
252269 Commands::Completion { shell } => command_completion(shell),
253270 Commands::Drop { task_id } => command_drop(dir, task_id),
254254- Commands::Find { args, full_id } => command_find(dir, full_id, args),
271271+ Commands::Find { args, short_id } => command_find(dir, short_id, args),
255272 Commands::Rot => Workspace::from_path(dir).unwrap().rot(),
256273 Commands::Tor => Workspace::from_path(dir).unwrap().tor(),
257274 Commands::Prioritize { task_id } => command_prioritize(dir, task_id),
···274291 relative_id: 0,
275292 find: Find {
276293 find: false,
277277- args: FindArgs { search_body: false },
294294+ args: FindArgs { exclude_body: true },
278295 },
279296 }
280297}
···283300 Workspace::init(dir)
284301}
285302286286-fn command_push(dir: PathBuf, edit: bool, body: Option<String>, title: Title) -> Result<()> {
287287- let workspace = Workspace::from_path(dir)?;
303303+fn create_task(
304304+ workspace: &mut Workspace,
305305+ edit: bool,
306306+ body: Option<String>,
307307+ title: Title,
308308+) -> Result<Task> {
288309 let mut title = if let Some(title) = title.title {
289310 title
290311 } else if let Some(title) = title.title_simple {
291291- let joined = title.join(" ");
292292- joined
312312+ title.join(" ")
293313 } else {
294314 "".to_string()
295315 };
296296- let mut body = body.unwrap_or_default();
316316+ // If no body was explicitly provided and the title contains newlines,
317317+ // treat the first line as the title and the rest as the body (like git commit -m)
318318+ let mut body = if body.is_none() {
319319+ if let Some((first_line, rest)) = title.split_once('\n') {
320320+ let extracted_body = rest.to_string();
321321+ title = first_line.to_string();
322322+ extracted_body
323323+ } else {
324324+ String::new()
325325+ }
326326+ } else {
327327+ // Body was explicitly provided, so strip any newlines from title
328328+ title = title.replace(['\n', '\r'], " ");
329329+ body.unwrap_or_default()
330330+ };
297331 if body == "-" {
298332 // add newline so you can type directly in the shell
299333 //eprintln!("");
···307341 body = content.1.to_string();
308342 }
309343 }
344344+ // Ensure title never contains newlines (invariant for index file format)
345345+ title = title.replace(['\n', '\r'], " ");
310346 let task = workspace.new_task(title, body)?;
311347 workspace.handle_metadata(&task, None)?;
348348+ Ok(task)
349349+}
350350+351351+fn command_push(dir: PathBuf, edit: bool, body: Option<String>, title: Title) -> Result<()> {
352352+ let mut workspace = Workspace::from_path(dir)?;
353353+ let task = create_task(&mut workspace, edit, body, title)?;
312354 workspace.push_task(task)
313355}
314356357357+fn command_append(dir: PathBuf, edit: bool, body: Option<String>, title: Title) -> Result<()> {
358358+ let mut workspace = Workspace::from_path(dir)?;
359359+ let task = create_task(&mut workspace, edit, body, title)?;
360360+ workspace.append_task(task)
361361+}
362362+315363fn command_list(dir: PathBuf, all: bool, count: usize) -> Result<()> {
316364 let workspace = Workspace::from_path(dir)?;
317317- let stack = if all {
318318- workspace.read_stack()?
319319- } else {
320320- workspace.read_stack()?
321321- };
365365+ let stack = workspace.read_stack()?;
366366+322367 if stack.empty() {
323368 println!("*No tasks*");
324369 exit(0);
325325- } else {
326326- if !all {
327327- for stack_item in stack.into_iter().take(count) {
328328- println!("{stack_item}");
329329- }
370370+ }
371371+372372+ for (_, stack_item) in stack
373373+ .into_iter()
374374+ .enumerate()
375375+ .take_while(|(idx, _)| all || idx < &count)
376376+ {
377377+ if let Some(parsed) = task::parse(&stack_item.title) {
378378+ println!("{}\t{}", stack_item.id, parsed.content.trim());
330379 } else {
331331- for stack_item in stack.into_iter() {
332332- println!("{stack_item}");
333333- }
380380+ println!("{stack_item}");
334381 }
335382 }
336383 Ok(())
···349396 let pre_links = task::parse(&task.to_string()).map(|pt| pt.intenal_links());
350397 let new_content = open_editor(format!("{}\n\n{}", task.title.trim(), task.body.trim()))?;
351398 if let Some((title, body)) = new_content.split_once("\n") {
352352- task.title = title.to_string();
399399+ // Ensure title never contains newlines (invariant for index file format)
400400+ task.title = title.replace(['\n', '\r'], " ");
353401 task.body = body.to_string();
354402 workspace.handle_metadata(&task, pre_links)?;
355403 task.save()?;
···373421 Ok(())
374422}
375423376376-fn command_find(dir: PathBuf, full_id: bool, find_args: FindArgs) -> Result<()> {
377377- let id = Workspace::from_path(dir)?.search(None, find_args.search_body, false)?;
424424+fn command_find(dir: PathBuf, short_id: bool, find_args: FindArgs) -> Result<()> {
425425+ let id = Workspace::from_path(dir)?.search(None, !find_args.exclude_body, false)?;
378426 if let Some(id) = id {
379379- if full_id {
380380- println!("{id}");
381381- } else {
427427+ if short_id {
382428 // print as integer
383429 println!("{}", id.0);
430430+ } else {
431431+ println!("{id}");
384432 }
385433 } else {
386434 eprintln!("No task selected.");
+19-17
src/stack.rs
···4455use crate::errors::{Error, Result};
66use crate::util;
77-use std::collections::vec_deque::Iter;
87use std::collections::VecDeque;
88+use std::collections::vec_deque::Iter;
99use std::fmt::Display;
1010+use std::fs::File;
1011use std::io::{self, BufRead, BufReader, Seek, Write};
1212+use std::path::Path;
1113use std::str::FromStr;
1214use std::time::{Duration, SystemTime, UNIX_EPOCH};
1313-use std::{fs::File, path::PathBuf};
14151516use nix::fcntl::{Flock, FlockArg};
1617···6768 let mut parts = s.trim().split("\t");
6869 let id: Id = parts
6970 .next()
7070- .ok_or(Error::Parse(format!(
7171- "Incomplete index line. Missing tsk ID"
7272- )))?
7171+ .ok_or(Error::Parse(
7272+ "Incomplete index line. Missing tsk ID".to_owned(),
7373+ ))?
7374 .parse()?;
7475 let title: String = parts
7576 .next()
7676- .ok_or(Error::Parse(format!(
7777- "Incomplete index line. Missing title."
7878- )))?
7777+ .ok_or(Error::Parse(
7878+ "Incomplete index line. Missing title.".to_owned(),
7979+ ))?
7980 .trim()
8081 .to_string();
8182 // parse the timestamp as an integer
···96979798impl StackItem {
9899 /// Parses a [`StackItem`] from a string. The expected format is a tab-delimited line with the
9999- /// files: task id title
100100- fn from_line(workspace_path: &PathBuf, line: String) -> Result<Self> {
100100+ /// files: task id title
101101+ fn from_line(workspace_path: &Path, line: String) -> Result<Self> {
101102 let mut stack_item: StackItem = line.parse()?;
102103103104 let task = util::flopen(
104105 workspace_path
105106 .join(TASKSFOLDER)
106106- .join(stack_item.id.to_filename()),
107107+ .join(stack_item.id.filename()),
107108 FlockArg::LockExclusive,
108109 )?;
109110 let task_modify_time = task.metadata()?.modified()?;
···125126}
126127127128impl TaskStack {
128128- pub fn from_tskdir(workspace_path: &PathBuf) -> Result<Self> {
129129+ pub fn from_tskdir(workspace_path: &Path) -> Result<Self> {
129130 let file = util::flopen(workspace_path.join(INDEXFILE), FlockArg::LockExclusive)?;
130131 let index = BufReader::new(&*file).lines();
131132 let mut all = VecDeque::new();
···143144 self.file.set_len(0)?;
144145 for item in self.all.iter() {
145146 let time = item.modify_time.duration_since(UNIX_EPOCH)?.as_secs();
146146- self.file.write_all(format!("{item}\t{}\n", time).as_bytes())?;
147147+ self.file
148148+ .write_all(format!("{item}\t{}\n", time).as_bytes())?;
147149 }
148150 Ok(())
149151 }
···163165 pub fn swap(&mut self) {
164166 let tip = self.all.pop_front();
165167 let second = self.all.pop_front();
166166- if tip.is_some() && second.is_some() {
167167- self.all.push_front(tip.unwrap());
168168- self.all.push_front(second.unwrap());
168168+ if let Some((tip, second)) = tip.zip(second) {
169169+ self.all.push_front(tip);
170170+ self.all.push_front(second);
169171 }
170172 }
171173···177179 self.all.remove(index)
178180 }
179181180180- pub fn iter(&self) -> Iter<StackItem> {
182182+ pub fn iter(&self) -> Iter<'_, StackItem> {
181183 self.all.iter()
182184 }
183185