···1011This does suggest that I should add a reindex command to add tasks that are not
12present in the index but are in the tasks folder to the index at the *bottom*,
13-preserving the existing order of the index.0000
···1011This does suggest that I should add a reindex command to add tasks that are not
12present 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+Add flag to only print IDs in list command
2+
+8
.tsk/archive/tsk-31.tsk
···00000000
···1+DO THE THING
2+3+4+remember to do part1
5+6+and part2
7+8+and part3
+5-4
.tsk/index
···1-tsk-28 Add tool to clean up old tasks not in index 1733166288
02tsk-10 foreign workspaces 1732594198
3tsk-21 Add command to setup git stuff 1732594198
4-tsk-8 IMAP4-based sync 1732594198
5tsk-17 Add reopen command 1732594198
6-tsk-16 Add ability to search archived tasks with find command 1732594198
7tsk-15 Add link identification to tasks 1732594198
8tsk-9 fix timestamp storage and parsing 1732594198
9tsk-7 allow for creating tasks that don't go to top of stack 1732594198
10tsk-13 user-defined labels 1732594198
11-tsk-18 Add reindex command 1732594198
···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
3tsk-10 foreign workspaces 1732594198
4tsk-21 Add command to setup git stuff 1732594198
5+tsk-8 IMAP4-based sync 1767469318
6tsk-17 Add reopen command 1732594198
7+tsk-16 Add ability to search archived tasks with find command 1767466011
8tsk-15 Add link identification to tasks 1732594198
9tsk-9 fix timestamp storage and parsing 1732594198
10tsk-7 allow for creating tasks that don't go to top of stack 1732594198
11tsk-13 user-defined labels 1732594198
12+tsk-18 Add reindex command 1735006716
···184A quick overview of the format:
185186- \!Bolded\! text is surrounded by exclamation marks (!)
187-- \*Italicized\* text is surrouneded by single asterists (*)
188- \_Underlined\_ text is surrounded by underscores (_)
189-- \~Strikenthrough\~ text is surrounded by tildes (~)
00190191Links like in Markdown, along with the wiki-style links documented above.
0192193Misc
194----
···184A quick overview of the format:
185186- \!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 (`)
192193Links like in Markdown, along with the wiki-style links documented above.
194+Raw links can also be written as \<https://example.com\>.
195196Misc
197----
···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, "{item}\n")?;
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() {
+82-34
src/main.rs
···5mod task;
6mod util;
7mod workspace;
8-use clap_complete::{generate, Shell};
9use errors::Result;
10use std::io::{self, Write};
11use std::path::PathBuf;
···13use std::str::FromStr as _;
14use std::{env::current_dir, io::Read};
15use task::ParsedLink;
16-use workspace::{Id, TaskIdentifier, Workspace};
1718//use smol;
19//use iocraft::prelude::*;
···25}
2627fn parse_id(s: &str) -> std::result::Result<Id, &'static str> {
28- Ok(Id::from_str(s).map_err(|_| "Unable to parse tsk- ID")?)
29}
3031#[derive(Parser)]
···46 Init,
47 /// Creates a new task, automatically assigning it a unique identifider and persisting
48 Push {
00000000000000000049 /// Whether to open $EDITOR to edit the content of the task. The first line if the
50 /// resulting file will be the task's title. The body follows the title after two newlines,
51 /// similr to the format of a commit message.
···202#[derive(Args)]
203#[group(required = false, multiple = false)]
204struct FindArgs {
205- /// Include the contents of tasks in the search criteria.
206 #[arg(short = 'b', default_value_t = false)]
207- search_body: bool,
208 /* TODO: implement this
209 /// Include archived tasks in the search criteria. Combine with `-b` to include archived
210 /// bodies in the search criteria.
···217 fn from(value: TaskId) -> Self {
218 if let Some(id) = value.id.map(Id::from).or(value.tsk_id) {
219 TaskIdentifier::Id(id)
00000220 } else {
221- if value.find.find {
222- TaskIdentifier::Find {
223- search_body: value.find.args.search_body,
224- archived: false,
225- }
226- } else {
227- TaskIdentifier::Relative(value.relative_id)
228- }
229 }
230 }
231}
···236 let var_name = match cli.command {
237 Commands::Init => command_init(dir),
238 Commands::Push { edit, body, title } => command_push(dir, edit, body, title),
0239 Commands::List { all, count } => command_list(dir, all, count),
240 Commands::Swap => command_swap(dir),
241 Commands::Show {
···274 relative_id: 0,
275 find: Find {
276 find: false,
277- args: FindArgs { search_body: false },
278 },
279 }
280}
···283 Workspace::init(dir)
284}
285286-fn command_push(dir: PathBuf, edit: bool, body: Option<String>, title: Title) -> Result<()> {
287- let workspace = Workspace::from_path(dir)?;
0000288 let mut title = if let Some(title) = title.title {
289 title
290 } else if let Some(title) = title.title_simple {
291- let joined = title.join(" ");
292- joined
293 } else {
294 "".to_string()
295 };
296- let mut body = body.unwrap_or_default();
00000000000000297 if body == "-" {
298 // add newline so you can type directly in the shell
299 //eprintln!("");
···307 body = content.1.to_string();
308 }
309 }
00310 let task = workspace.new_task(title, body)?;
311 workspace.handle_metadata(&task, None)?;
000000312 workspace.push_task(task)
313}
314000000315fn command_list(dir: PathBuf, all: bool, count: usize) -> Result<()> {
316 let workspace = Workspace::from_path(dir)?;
317- let stack = if all {
318- workspace.read_stack()?
319- } else {
320- workspace.read_stack()?
321- };
322 if stack.empty() {
323 println!("*No tasks*");
324 exit(0);
325- } else {
326- if !all {
327- for stack_item in stack.into_iter().take(count) {
328- println!("{stack_item}");
329- }
0000330 } else {
331- for stack_item in stack.into_iter() {
332- println!("{stack_item}");
333- }
334 }
335 }
336 Ok(())
···349 let pre_links = task::parse(&task.to_string()).map(|pt| pt.intenal_links());
350 let new_content = open_editor(format!("{}\n\n{}", task.title.trim(), task.body.trim()))?;
351 if let Some((title, body)) = new_content.split_once("\n") {
352- task.title = title.to_string();
0353 task.body = body.to_string();
354 workspace.handle_metadata(&task, pre_links)?;
355 task.save()?;
···374}
375376fn command_find(dir: PathBuf, short_id: bool, find_args: FindArgs) -> Result<()> {
377- let id = Workspace::from_path(dir)?.search(None, find_args.search_body, false)?;
378 if let Some(id) = id {
379 if short_id {
380 // print as integer
···5mod task;
6mod util;
7mod workspace;
8+use clap_complete::{Shell, generate};
9use errors::Result;
10use std::io::{self, Write};
11use std::path::PathBuf;
···13use std::str::FromStr as _;
14use std::{env::current_dir, io::Read};
15use task::ParsedLink;
16+use workspace::{Id, Task, TaskIdentifier, Workspace};
1718//use smol;
19//use iocraft::prelude::*;
···25}
2627fn parse_id(s: &str) -> std::result::Result<Id, &'static str> {
28+ Id::from_str(s).map_err(|_| "Unable to parse tsk- ID")
29}
3031#[derive(Parser)]
···46 Init,
47 /// Creates a new task, automatically assigning it a unique identifider and persisting
48 Push {
49+ /// Whether to open $EDITOR to edit the content of the task. The first line if the
50+ /// resulting file will be the task's title. The body follows the title after two newlines,
51+ /// similr to the format of a commit message.
52+ #[arg(short = 'e', default_value_t = false)]
53+ edit: bool,
54+55+ /// The body of the task. It may be specified as either a string using quotes or the
56+ /// special character '-' to read from stdin.
57+ #[arg(short = 'b')]
58+ body: Option<String>,
59+60+ /// The title of the task as a raw string. It mus be proceeded by two dashes (--).
61+ #[command(flatten)]
62+ title: Title,
63+ },
64+ /// Creates a new task just like `push`, but instead of putting it at the top of the stack, it
65+ /// puts it at the bottom
66+ Append {
67 /// Whether to open $EDITOR to edit the content of the task. The first line if the
68 /// resulting file will be the task's title. The body follows the title after two newlines,
69 /// similr to the format of a commit message.
···220#[derive(Args)]
221#[group(required = false, multiple = false)]
222struct FindArgs {
223+ /// Exclude the contents of tasks in the search criteria.
224 #[arg(short = 'b', default_value_t = false)]
225+ exclude_body: bool,
226 /* TODO: implement this
227 /// Include archived tasks in the search criteria. Combine with `-b` to include archived
228 /// bodies in the search criteria.
···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)
0000000245 }
246 }
247}
···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 {
···291 relative_id: 0,
292 find: Find {
293 find: false,
294+ args: FindArgs { exclude_body: true },
295 },
296 }
297}
···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!("");
···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(())
···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()?;
···422}
423424fn 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
+17-16
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();
···164 pub fn swap(&mut self) {
165 let tip = self.all.pop_front();
166 let second = self.all.pop_front();
167- if tip.is_some() && second.is_some() {
168- self.all.push_front(tip.unwrap());
169- self.all.push_front(second.unwrap());
170 }
171 }
172···178 self.all.remove(index)
179 }
180181- pub fn iter(&self) -> Iter<StackItem> {
182 self.all.iter()
183 }
184
···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();
···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