···78here's [a link](https://ngp.computer).
910-and an internal link: [[tsk-11]]
1112and some _underlined text_
13
···78here's [a link](https://ngp.computer).
910+and an internal link: [[tsk-11]]. This should add a backlink
1112and some _underlined text_
13
+3-1
.tsk/archive/tsk-21.tsk
···23Will 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).00
···23Will 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+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
+3-1
.tsk/archive/tsk-6.tsk
···1automatically add backlinks
23I 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 reference00
···1automatically add backlinks
23I 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.
+12-14
.tsk/index
···1-tsk-21 Add command to setup git stuff
2-tsk-20 fix issue where links use absolute paths
3-tsk-19 add "raw" output option for show
4-tsk-8 IMAP4-based sync
5-tsk-17 Add reopen command
6-tsk-16 Add ability to search archived tasks with find command
7-tsk-15 Add link identification to tasks
8-tsk-14 parse internal links from body
9-tsk-6 automatically add backlinks
10-tsk-9 fix timestamp storage and parsing
11-tsk-10 foreign workspaces
12-tsk-7 allow for creating tasks that don't go to top of stack
13-tsk-13 user-defined labels
14-tsk-18 Add reindex command
···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
00
···36tsk expects to run on POSIX-like systems. Microsoft Windows and other
37non-UNIX-ey operating systems will never be directly supported.
3800000000039Building
40--------
41···174175A quick overview of the format:
176177-!Bolded! text is surrounded by exclamation marks (!)
178-*Italicized* text is surrouneded by single asterists (*)
179-_Underlined_ text is surrounded by underscores (_)
180-~Strikenthrough~ text is surrounded by tildes (~)
00181182Links like in Markdown, along with the wiki-style links documented above.
0183184Misc
185----
···36tsk expects to run on POSIX-like systems. Microsoft Windows and other
37non-UNIX-ey operating systems will never be directly supported.
3839+40+Installation
41+------------
42+43+```sh
44+cargo install --locked tsk-cli
45+```
46+47+48Building
49--------
50···183184A 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() {
+114-51
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;
12use std::process::exit;
013use std::{env::current_dir, io::Read};
14use task::ParsedLink;
15-use workspace::{Id, TaskIdentifier, Workspace};
1617//use smol;
18//use iocraft::prelude::*;
19-use clap::{value_parser, Args, CommandFactory, Parser, Subcommand};
20use edit::edit as open_editor;
2122fn default_dir() -> PathBuf {
23 current_dir().unwrap()
000024}
2526#[derive(Parser)]
···56 #[command(flatten)]
57 title: Title,
58 },
00000000000000000059 /// Print the task stack. This will include just TSK-IDs and the title.
60 List {
61 /// Whether to list all tasks in the task stack. If specified, -c / count is ignored.
···85 Find {
86 #[command(flatten)]
87 args: FindArgs,
88- /// Whether to print the full TSK-ID (instead of just an integer)
89- #[arg(short = 'F', default_value_t = true)]
90- full_id: bool,
91 },
9293 /// Prints the contents of a task, parsing the body as rich text and formatting it using ANSI
···96 /// Shows raw file attributes for the file
97 #[arg(short = 'x', default_value_t = false)]
98 show_attrs: bool,
00099 /// The [TSK-]ID of the task to display
100 #[command(flatten)]
101 task_id: TaskId,
···111 #[command(flatten)]
112 task_id: TaskId,
113 /// The index of the link to open. Must be supplied.
114- #[arg(short = 'l')]
115 link_index: usize,
116 /// When opening an internal link, whether to show or edit the addressed task.
117 #[arg(short = 'e', default_value_t = false)]
···167 id: Option<u32>,
168169 /// The ID of the task to select with the 'tsk-' prefix.
170- #[arg(short = 'T', value_name = "TSK-ID", value_parser = value_parser!(String))]
171 tsk_id: Option<Id>,
172173 /// Selects a task relative to the top of the stack.
···194#[derive(Args)]
195#[group(required = false, multiple = false)]
196struct FindArgs {
197- /// Include the contents of tasks in the search criteria.
198 #[arg(short = 'b', default_value_t = false)]
199- search_body: bool,
200 /* TODO: implement this
201 /// Include archived tasks in the search criteria. Combine with `-b` to include archived
202 /// bodies in the search criteria.
···209 fn from(value: TaskId) -> Self {
210 if let Some(id) = value.id.map(Id::from).or(value.tsk_id) {
211 TaskIdentifier::Id(id)
00000212 } else {
213- if value.find.find {
214- TaskIdentifier::Find {
215- search_body: value.find.args.search_body,
216- archived: false,
217- }
218- } else {
219- TaskIdentifier::Relative(value.relative_id)
220- }
221 }
222 }
223}
···228 let var_name = match cli.command {
229 Commands::Init => command_init(dir),
230 Commands::Push { edit, body, title } => command_push(dir, edit, body, title),
0231 Commands::List { all, count } => command_list(dir, all, count),
232 Commands::Swap => command_swap(dir),
233 Commands::Show {
234 task_id,
0235 show_attrs,
236- } => command_show(dir, task_id, show_attrs),
237 Commands::Follow {
238 task_id,
239 link_index,
···242 Commands::Edit { task_id } => command_edit(dir, task_id),
243 Commands::Completion { shell } => command_completion(shell),
244 Commands::Drop { task_id } => command_drop(dir, task_id),
245- Commands::Find { args, full_id } => command_find(dir, full_id, args),
246 Commands::Rot => Workspace::from_path(dir).unwrap().rot(),
247 Commands::Tor => Workspace::from_path(dir).unwrap().tor(),
248 Commands::Prioritize { task_id } => command_prioritize(dir, task_id),
···265 relative_id: 0,
266 find: Find {
267 find: false,
268- args: FindArgs { search_body: false },
269 },
270 }
271}
···274 Workspace::init(dir)
275}
276277-fn command_push(dir: PathBuf, edit: bool, body: Option<String>, title: Title) -> Result<()> {
278- let workspace = Workspace::from_path(dir)?;
0000279 let mut title = if let Some(title) = title.title {
280 title
281 } else if let Some(title) = title.title_simple {
282- let joined = title.join(" ");
283- joined
284 } else {
285 "".to_string()
286 };
287- let mut body = body.unwrap_or_default();
00000000000000288 if body == "-" {
289 // add newline so you can type directly in the shell
290 //eprintln!("");
···298 body = content.1.to_string();
299 }
300 }
00301 let task = workspace.new_task(title, body)?;
0000000302 workspace.push_task(task)
303}
304000000305fn command_list(dir: PathBuf, all: bool, count: usize) -> Result<()> {
306 let workspace = Workspace::from_path(dir)?;
307- let stack = if all {
308- workspace.read_stack()?
309- } else {
310- workspace.read_stack()?
311- };
312 if stack.empty() {
313 println!("*No tasks*");
314 exit(0);
315- } else {
316- if !all {
317- for stack_item in stack.into_iter().take(count) {
318- println!("{stack_item}");
319- }
0000320 } else {
321- for stack_item in stack.into_iter() {
322- println!("{stack_item}");
323- }
324 }
325 }
326 Ok(())
···336 let workspace = Workspace::from_path(dir)?;
337 let id: TaskIdentifier = id.into();
338 let mut task = workspace.task(id)?;
0339 let new_content = open_editor(format!("{}\n\n{}", task.title.trim(), task.body.trim()))?;
340 if let Some((title, body)) = new_content.split_once("\n") {
341- task.title = title.to_string();
0342 task.body = body.to_string();
0343 task.save()?;
344 }
345 Ok(())
···361 Ok(())
362}
363364-fn command_find(dir: PathBuf, full_id: bool, find_args: FindArgs) -> Result<()> {
365- let id = Workspace::from_path(dir)?.search(None, find_args.search_body, false)?;
366 if let Some(id) = id {
367- if full_id {
368- println!("{id}");
369- } else {
370 // print as integer
371 println!("{}", id.0);
00372 }
373 } else {
374 eprintln!("No task selected.");
···385 Workspace::from_path(dir)?.deprioritize(task_id.into())
386}
387388-fn command_show(dir: PathBuf, task_id: TaskId, show_attrs: bool) -> Result<()> {
389 let task = Workspace::from_path(dir)?.task(task_id.into())?;
390 // YAML front-matter style. YAML is gross, but it's what everyone uses!
391 if show_attrs && !task.attributes.is_empty() {
···395 }
396 println!("---");
397 }
398- if let Some(styled_task) = task::parse(&task.to_string()) {
399- writeln!(io::stdout(), "{}", styled_task.content)?;
400- } else {
401- println!("{task}");
000402 }
403 Ok(())
404}
···421 if edit {
422 command_edit(dir, taskid)
423 } else {
424- command_show(dir, taskid, false)
425 }
426 }
427 }
···5mod task;
6mod util;
7mod workspace;
8+use clap_complete::{Shell, generate};
9use errors::Result;
10use 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};
15use 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+}
26+27+fn 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)]
···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.
···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 },
115116 /// Prints the contents of a task, parsing the body as rich text and formatting it using ANSI
···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,
···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)]
···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.
···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 {
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,
···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),
···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(())
···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(())
···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 {
00428 // print as integer
429 println!("{}", id.0);
430+ } else {
431+ println!("{id}");
432 }
433 } else {
434 eprintln!("No task selected.");
···445 Workspace::from_path(dir)?.deprioritize(task_id.into())
446}
447448+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() {
···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}
···484 if edit {
485 command_edit(dir, taskid)
486 } else {
487+ command_show(dir, taskid, false, false)
488 }
489 }
490 }
+20-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 }
···162 pub fn swap(&mut self) {
163 let tip = self.all.pop_front();
164 let second = self.all.pop_front();
165- if tip.is_some() && second.is_some() {
166- self.all.push_front(tip.unwrap());
167- self.all.push_front(second.unwrap());
168 }
169 }
170···176 self.all.remove(index)
177 }
178179- pub fn iter(&self) -> Iter<StackItem> {
180 self.all.iter()
181 }
182
···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 }
···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
+89-56
src/task.rs
···1#![allow(dead_code)]
23-use std::str::FromStr;
4use url::Url;
56use crate::workspace::Id;
7use colored::Colorize;
8000009#[derive(Debug, Eq, PartialEq, Clone, Copy)]
10enum ParserState {
11 // Started by ` =`, terminated by `=
12- Highlight(usize),
13 // Started by ` [`, terminated by `](`
14- Linktext(usize),
15 // Started by `](`, terminated by `) `, must immedately follow a Linktext
16- Link(usize),
17- RawLink(usize),
18 // Started by ` [[`, terminated by `]] `
19- InternalLink(usize),
20 // Started by ` *`, terminated by `* `
21- Italics(usize),
22 // Started by ` !`, termianted by `!`
23- Bold(usize),
24 // Started by ` _`, terminated by `_ `
25- Underline(usize),
26 // Started by ` -`, terminated by `- `
27- Strikethrough(usize),
2829 // TODO: implement these.
30 // Started by `_ `, terminated by `_`
···37 // `\n` and followed by a `\n`
38 BlockEnd(usize),
39 // Started by ` ``, terminated by `` ` or `\n`
40- InlineBlock(usize),
41 // Started by `^\w+>`, terminated by `\n`
42 Blockquote(usize),
43}
···53 pub(crate) links: Vec<ParsedLink>,
54}
5500000000000056pub(crate) fn parse(s: &str) -> Option<ParsedTask> {
57 let mut state: Vec<ParserState> = Vec::new();
58 let mut out = String::with_capacity(s.len());
···64 let state_last = state.last().cloned();
65 match stream.next() {
66 // there will always be an op code in the stack
67- Some((_, c)) => {
68 out.push(c);
69 let end = out.len() - 1;
70 match (last, c, state_last) {
71 ('[', '[', _) => {
72- state.push(InternalLink(end));
73 }
74- (']', ']', Some(InternalLink(il))) => {
75 state.pop();
76- let contents = out.get(il + 1..out.len() - 2)?;
77- if let Ok(id) = Id::from_str(&contents) {
78 let linktext = format!(
79 "{}{}",
80 contents.purple(),
···86 panic!("Internal link is not a valid id: {contents}");
87 }
88 }
89- (' ' | '\r' | '\n', '[', _) => {
90- state.push(Linktext(end));
91 }
92- (']', '(', Some(Linktext(_))) => {
93- state.push(Link(end));
94 }
95- (')', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Link(_))) => {
96- let linkpos = if let Link(lp) = state.pop().unwrap() {
0097 lp
98 } else {
99 // remove the linktext state, it is always present.
100 state.pop();
101 continue;
102 };
103- let linktextpos = if let Linktext(lt) = state.pop().unwrap() {
104 lt
105 } else {
106 continue;
···116 out.replace_range(linktextpos..end, &linktext);
117 }
118 }
119- ('>', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(RawLink(hl))) => {
00120 state.pop();
121- let link = out.get(hl + 1..out.len() - 2)?;
122 if let Ok(url) = Url::parse(link) {
123 let linktext =
124 format!("{}{}", link.blue(), super_num(links.len() + 1).purple());
···126 out.replace_range(hl..end, &linktext);
127 }
128 }
129- (' ' | '\r' | '\n', '<', _) => {
130- state.push(RawLink(end));
131 }
132- ('=', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Highlight(hl))) => {
00133 state.pop();
134 out.replace_range(
135 hl..end,
136- &out.get(hl + 1..out.len() - 2)?.reversed().to_string(),
137 );
138 }
139- (' ' | '\r' | '\n', '=', _) => {
140- state.push(Highlight(end));
141 }
142- (' ' | '\r' | '\n', '*', _) => {
143- state.push(Italics(end));
144 }
145- ('*', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Italics(il))) => {
00146 state.pop();
147 out.replace_range(
148 il..end,
149- &out.get(il + 1..out.len() - 2)?.italic().to_string(),
150 );
151 }
152- (' ' | '\r' | '\n', '!', _) => {
153- state.push(Bold(end));
154 }
155- ('!', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Bold(il))) => {
156 state.pop();
157- out.replace_range(il..end, &out.get(il + 1..end - 1)?.bold().to_string());
000158 }
159- (' ' | '\r' | '\n', '_', _) => {
160- state.push(Underline(end));
161 }
162- ('_', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Underline(il))) => {
00163 state.pop();
164 out.replace_range(
165 il..end,
166- &out.get(il + 1..end - 1)?.underline().to_string(),
167 );
168 }
169- (' ' | '\r' | '\n', '~', _) => {
170- state.push(Strikethrough(end));
171 }
172- ('~', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Strikethrough(il))) => {
00173 state.pop();
174 out.replace_range(
175 il..end,
176- &out.get(il + 1..end - 1)?.strikethrough().to_string(),
177 );
178 }
179- ('`', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(InlineBlock(hl))) => {
180- state.pop();
0181 out.replace_range(
182 hl..end,
183- &out.get(hl + 1..out.len() - 2)?.green().to_string(),
184 );
185 }
186- (' ' | '\r' | '\n', '`', _) => {
187- state.push(InlineBlock(end));
188 }
189 _ => (),
190 }
···261 fn test_link_no_terminal_link() {
262 let input = "hello [world](https://ngp.computer\n";
263 let output = parse(input).expect("parse to work");
264- assert!(output.links.len() == 0);
265 assert_eq!(input, output.content);
266 }
267 #[test]
268 fn test_link_bad_no_start_link() {
269 let input = "hello [world]https://ngp.computer)\n";
270 let output = parse(input).expect("parse to work");
271- assert!(output.links.len() == 0);
272 assert_eq!(input, output.content);
273 }
274 #[test]
275 fn test_link_bad_no_link() {
276 let input = "hello [world]\n";
277 let output = parse(input).expect("parse to work");
278- assert!(output.links.len() == 0);
279 assert_eq!(input, output.content);
280 }
281···294 fn test_internal_link_bad() {
295 let input = "hello [[tsk-123";
296 let output = parse(input).expect("parse to work");
297- assert!(output.links.len() == 0);
298 assert_eq!(input, output.content);
299 }
300
···1#![allow(dead_code)]
23+use std::{collections::HashSet, str::FromStr};
4use url::Url;
56use crate::workspace::Id;
7use colored::Colorize;
89+/// Returns true if the character is a word boundary (whitespace or punctuation)
10+fn is_boundary(c: char) -> bool {
11+ c.is_whitespace() || c.is_ascii_punctuation()
12+}
13+14#[derive(Debug, Eq, PartialEq, Clone, Copy)]
15enum ParserState {
16 // Started by ` =`, terminated by `=
17+ Highlight(usize, usize),
18 // Started by ` [`, terminated by `](`
19+ Linktext(usize, usize),
20 // Started by `](`, terminated by `) `, must immedately follow a Linktext
21+ Link(usize, usize),
22+ RawLink(usize, usize),
23 // Started by ` [[`, terminated by `]] `
24+ InternalLink(usize, usize),
25 // Started by ` *`, terminated by `* `
26+ Italics(usize, usize),
27 // Started by ` !`, termianted by `!`
28+ Bold(usize, usize),
29 // Started by ` _`, terminated by `_ `
30+ Underline(usize, usize),
31 // Started by ` -`, terminated by `- `
32+ Strikethrough(usize, usize),
3334 // TODO: implement these.
35 // Started by `_ `, terminated by `_`
···42 // `\n` and followed by a `\n`
43 BlockEnd(usize),
44 // Started by ` ``, terminated by `` ` or `\n`
45+ InlineBlock(usize, usize),
46 // Started by `^\w+>`, terminated by `\n`
47 Blockquote(usize),
48}
···58 pub(crate) links: Vec<ParsedLink>,
59}
6061+impl ParsedTask {
62+ pub(crate) fn intenal_links(&self) -> HashSet<Id> {
63+ let mut out = HashSet::with_capacity(self.links.len());
64+ for link in &self.links {
65+ if let ParsedLink::Internal(id) = link {
66+ out.insert(*id);
67+ }
68+ }
69+ out
70+ }
71+}
72+73pub(crate) fn parse(s: &str) -> Option<ParsedTask> {
74 let mut state: Vec<ParserState> = Vec::new();
75 let mut out = String::with_capacity(s.len());
···81 let state_last = state.last().cloned();
82 match stream.next() {
83 // there will always be an op code in the stack
84+ Some((char_pos, c)) => {
85 out.push(c);
86 let end = out.len() - 1;
87 match (last, c, state_last) {
88 ('[', '[', _) => {
89+ state.push(InternalLink(end, char_pos));
90 }
91+ (']', ']', Some(InternalLink(il, s_pos))) => {
92 state.pop();
93+ let contents = s.get(s_pos + 1..char_pos - 1)?;
94+ if let Ok(id) = Id::from_str(contents) {
95 let linktext = format!(
96 "{}{}",
97 contents.purple(),
···103 panic!("Internal link is not a valid id: {contents}");
104 }
105 }
106+ (last, '[', _) if is_boundary(last) => {
107+ state.push(Linktext(end, char_pos));
108 }
109+ (']', '(', Some(Linktext(_, _))) => {
110+ state.push(Link(end, char_pos));
111 }
112+ (')', c, Some(Link(_, _))) if is_boundary(c) => {
113+ // TODO: this needs to be updated to use `s` instead of `out` for position
114+ // parsing
115+ let linkpos = if let Link(lp, _) = state.pop().unwrap() {
116 lp
117 } else {
118 // remove the linktext state, it is always present.
119 state.pop();
120 continue;
121 };
122+ let linktextpos = if let Linktext(lt, _) = state.pop().unwrap() {
123 lt
124 } else {
125 continue;
···135 out.replace_range(linktextpos..end, &linktext);
136 }
137 }
138+ ('>', c, Some(RawLink(hl, s_pos)))
139+ if is_boundary(c) && s_pos != char_pos - 1 =>
140+ {
141 state.pop();
142+ let link = s.get(s_pos + 1..char_pos - 1)?;
143 if let Ok(url) = Url::parse(link) {
144 let linktext =
145 format!("{}{}", link.blue(), super_num(links.len() + 1).purple());
···147 out.replace_range(hl..end, &linktext);
148 }
149 }
150+ (last, '<', _) if is_boundary(last) => {
151+ state.push(RawLink(end, char_pos));
152 }
153+ ('=', c, Some(Highlight(hl, s_pos)))
154+ if is_boundary(c) && s_pos != char_pos - 1 =>
155+ {
156 state.pop();
157 out.replace_range(
158 hl..end,
159+ &s.get(s_pos + 1..char_pos - 1)?.reversed().to_string(),
160 );
161 }
162+ (last, '=', _) if is_boundary(last) => {
163+ state.push(Highlight(end, char_pos));
164 }
165+ (last, '*', _) if is_boundary(last) => {
166+ state.push(Italics(end, char_pos));
167 }
168+ ('*', c, Some(Italics(il, s_pos)))
169+ if is_boundary(c) && s_pos != char_pos - 1 =>
170+ {
171 state.pop();
172 out.replace_range(
173 il..end,
174+ &s.get(s_pos + 1..char_pos - 1)?.italic().to_string(),
175 );
176 }
177+ (last, '!', _) if is_boundary(last) => {
178+ state.push(Bold(end, char_pos));
179 }
180+ ('!', c, Some(Bold(il, s_pos))) if is_boundary(c) && s_pos != char_pos - 1 => {
181 state.pop();
182+ out.replace_range(
183+ il..end,
184+ &s.get(s_pos + 1..char_pos - 1)?.bold().to_string(),
185+ );
186 }
187+ (last, '_', _) if is_boundary(last) => {
188+ state.push(Underline(end, char_pos));
189 }
190+ ('_', c, Some(Underline(il, s_pos)))
191+ if is_boundary(c) && s_pos != char_pos - 1 =>
192+ {
193 state.pop();
194 out.replace_range(
195 il..end,
196+ &s.get(s_pos + 1..char_pos - 1)?.underline().to_string(),
197 );
198 }
199+ (last, '~', _) if is_boundary(last) => {
200+ state.push(Strikethrough(end, char_pos));
201 }
202+ ('~', c, Some(Strikethrough(il, s_pos)))
203+ if is_boundary(c) && s_pos != char_pos - 1 =>
204+ {
205 state.pop();
206 out.replace_range(
207 il..end,
208+ &s.get(s_pos + 1..char_pos - 1)?.strikethrough().to_string(),
209 );
210 }
211+ ('`', c, Some(InlineBlock(hl, s_pos)))
212+ if is_boundary(c) && s_pos != char_pos - 1 =>
213+ {
214 out.replace_range(
215 hl..end,
216+ &s.get(s_pos + 1..char_pos - 1)?.green().to_string(),
217 );
218 }
219+ (last, '`', _) if is_boundary(last) => {
220+ state.push(InlineBlock(end, char_pos));
221 }
222 _ => (),
223 }
···294 fn test_link_no_terminal_link() {
295 let input = "hello [world](https://ngp.computer\n";
296 let output = parse(input).expect("parse to work");
297+ assert!(output.links.is_empty());
298 assert_eq!(input, output.content);
299 }
300 #[test]
301 fn test_link_bad_no_start_link() {
302 let input = "hello [world]https://ngp.computer)\n";
303 let output = parse(input).expect("parse to work");
304+ assert!(output.links.is_empty());
305 assert_eq!(input, output.content);
306 }
307 #[test]
308 fn test_link_bad_no_link() {
309 let input = "hello [world]\n";
310 let output = parse(input).expect("parse to work");
311+ assert!(output.links.is_empty());
312 assert_eq!(input, output.content);
313 }
314···327 fn test_internal_link_bad() {
328 let input = "hello [[tsk-123";
329 let output = parse(input).expect("parse to work");
330+ assert!(output.links.is_empty());
331 assert_eq!(input, output.content);
332 }
333