A file-based task manager

Fix parsing, allow multiline push

+88 -38
+8
.tsk/archive/tsk-31.tsk
···
··· 1 + DO THE THING 2 + 3 + 4 + remember to do part1 5 + 6 + and part2 7 + 8 + and part3
+3 -3
.tsk/index
··· 1 - tsk-30 Add flag to only print IDs in list command 1735007126 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 1732594198 6 tsk-17 Add reopen command 1732594198 7 - tsk-16 Add ability to search archived tasks with find command 1732594198 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
··· 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
+1 -1
.tsk/next
··· 1 - 31
··· 1 + 32
+25 -6
flake.nix
··· 5 utils.url = "github:numtide/flake-utils"; 6 }; 7 8 - outputs = { self, nixpkgs, utils, naersk }: 9 - utils.lib.eachDefaultSystem (system: 10 let 11 pkgs = import nixpkgs { inherit system; }; 12 naersk-lib = pkgs.callPackage naersk { }; 13 in 14 { 15 defaultPackage = naersk-lib.buildPackage ./.; 16 - devShell = with pkgs; mkShell { 17 - buildInputs = [ libiconv cargo rustc rustfmt rust-analyzer rustPackages.clippy plan9port pandoc ]; 18 - RUST_SRC_PATH = rustPlatform.rustLibSrc; 19 - }; 20 } 21 ); 22 }
··· 5 utils.url = "github:numtide/flake-utils"; 6 }; 7 8 + outputs = 9 + { 10 + self, 11 + nixpkgs, 12 + utils, 13 + naersk, 14 + }: 15 + utils.lib.eachDefaultSystem ( 16 + system: 17 let 18 pkgs = import nixpkgs { inherit system; }; 19 naersk-lib = pkgs.callPackage naersk { }; 20 in 21 { 22 defaultPackage = naersk-lib.buildPackage ./.; 23 + devShell = 24 + with pkgs; 25 + mkShell { 26 + buildInputs = [ 27 + libiconv 28 + cargo 29 + rustc 30 + rustfmt 31 + rust-analyzer 32 + rustPackages.clippy 33 + plan9port 34 + pandoc 35 + codeberg-cli 36 + ]; 37 + RUST_SRC_PATH = rustPlatform.rustLibSrc; 38 + }; 39 } 40 ); 41 }
+5 -2
readme
··· 184 A quick overview of the format: 185 186 - \!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 (~) 190 191 Links like in Markdown, along with the wiki-style links documented above. 192 193 Misc 194 ----
··· 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 ----
+19 -2
src/main.rs
··· 313 } else { 314 "".to_string() 315 }; 316 - let mut body = body.unwrap_or_default(); 317 if body == "-" { 318 // add newline so you can type directly in the shell 319 //eprintln!(""); ··· 327 body = content.1.to_string(); 328 } 329 } 330 let task = workspace.new_task(title, body)?; 331 workspace.handle_metadata(&task, None)?; 332 Ok(task) ··· 380 let pre_links = task::parse(&task.to_string()).map(|pt| pt.intenal_links()); 381 let new_content = open_editor(format!("{}\n\n{}", task.title.trim(), task.body.trim()))?; 382 if let Some((title, body)) = new_content.split_once("\n") { 383 - task.title = title.to_string(); 384 task.body = body.to_string(); 385 workspace.handle_metadata(&task, pre_links)?; 386 task.save()?;
··· 313 } 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) ··· 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()?;
+27 -24
src/task.rs
··· 6 use crate::workspace::Id; 7 use colored::Colorize; 8 9 #[derive(Debug, Eq, PartialEq, Clone, Copy)] 10 enum ParserState { 11 // Started by ` =`, terminated by `= ··· 98 panic!("Internal link is not a valid id: {contents}"); 99 } 100 } 101 - (' ' | '\r' | '\n', '[', _) => { 102 state.push(Linktext(end, char_pos)); 103 } 104 (']', '(', Some(Linktext(_, _))) => { 105 state.push(Link(end, char_pos)); 106 } 107 - (')', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Link(_, _))) => { 108 // TODO: this needs to be updated to use `s` instead of `out` for position 109 // parsing 110 let linkpos = if let Link(lp, _) = state.pop().unwrap() { ··· 130 out.replace_range(linktextpos..end, &linktext); 131 } 132 } 133 - ('>', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(RawLink(hl, s_pos))) 134 - if s_pos != char_pos - 1 => 135 { 136 state.pop(); 137 let link = s.get(s_pos + 1..char_pos - 1)?; ··· 142 out.replace_range(hl..end, &linktext); 143 } 144 } 145 - (' ' | '\r' | '\n', '<', _) => { 146 state.push(RawLink(end, char_pos)); 147 } 148 - ('=', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Highlight(hl, s_pos))) 149 - if s_pos != char_pos - 1 => 150 { 151 state.pop(); 152 out.replace_range( ··· 154 &s.get(s_pos + 1..char_pos - 1)?.reversed().to_string(), 155 ); 156 } 157 - (' ' | '\r' | '\n', '=', _) => { 158 state.push(Highlight(end, char_pos)); 159 } 160 - (' ' | '\r' | '\n', '*', _) => { 161 state.push(Italics(end, char_pos)); 162 } 163 - ('*', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Italics(il, s_pos))) 164 - if s_pos != char_pos - 1 => 165 { 166 state.pop(); 167 out.replace_range( ··· 169 &s.get(s_pos + 1..char_pos - 1)?.italic().to_string(), 170 ); 171 } 172 - (' ' | '\r' | '\n', '!', _) => { 173 state.push(Bold(end, char_pos)); 174 } 175 - ('!', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Bold(il, s_pos))) 176 - if s_pos != char_pos - 1 => 177 - { 178 state.pop(); 179 out.replace_range( 180 il..end, 181 &s.get(s_pos + 1..char_pos - 1)?.bold().to_string(), 182 ); 183 } 184 - (' ' | '\r' | '\n', '_', _) => { 185 state.push(Underline(end, char_pos)); 186 } 187 - ('_', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Underline(il, s_pos))) 188 - if s_pos != char_pos - 1 => 189 { 190 state.pop(); 191 out.replace_range( ··· 193 &s.get(s_pos + 1..char_pos - 1)?.underline().to_string(), 194 ); 195 } 196 - (' ' | '\r' | '\n', '~', _) => { 197 state.push(Strikethrough(end, char_pos)); 198 } 199 - ('~', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Strikethrough(il, s_pos))) 200 - if s_pos != char_pos - 1 => 201 { 202 state.pop(); 203 out.replace_range( ··· 205 &s.get(s_pos + 1..char_pos - 1)?.strikethrough().to_string(), 206 ); 207 } 208 - ('`', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(InlineBlock(hl, s_pos))) 209 - if s_pos != char_pos - 1 => 210 { 211 out.replace_range( 212 hl..end, 213 &s.get(s_pos + 1..char_pos - 1)?.green().to_string(), 214 ); 215 } 216 - (' ' | '\n' | '\r' | '.' | '!' | '?', '`', _) => { 217 state.push(InlineBlock(end, char_pos)); 218 } 219 _ => (),
··· 6 use crate::workspace::Id; 7 use colored::Colorize; 8 9 + /// 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)] 15 enum ParserState { 16 // Started by ` =`, terminated by `= ··· 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() { ··· 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)?; ··· 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( ··· 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( ··· 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( ··· 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( ··· 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 _ => (),