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 1 + tsk-30 Add flag to only print IDs in list command 1763257109 2 2 tsk-28 Add tool to clean up old tasks not in index 1735006519 3 3 tsk-10 foreign workspaces 1732594198 4 4 tsk-21 Add command to setup git stuff 1732594198 5 - tsk-8 IMAP4-based sync 1732594198 5 + tsk-8 IMAP4-based sync 1767469318 6 6 tsk-17 Add reopen command 1732594198 7 - tsk-16 Add ability to search archived tasks with find command 1732594198 7 + tsk-16 Add ability to search archived tasks with find command 1767466011 8 8 tsk-15 Add link identification to tasks 1732594198 9 9 tsk-9 fix timestamp storage and parsing 1732594198 10 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 5 utils.url = "github:numtide/flake-utils"; 6 6 }; 7 7 8 - outputs = { self, nixpkgs, utils, naersk }: 9 - utils.lib.eachDefaultSystem (system: 8 + outputs = 9 + { 10 + self, 11 + nixpkgs, 12 + utils, 13 + naersk, 14 + }: 15 + utils.lib.eachDefaultSystem ( 16 + system: 10 17 let 11 18 pkgs = import nixpkgs { inherit system; }; 12 19 naersk-lib = pkgs.callPackage naersk { }; 13 20 in 14 21 { 15 22 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 - }; 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 + }; 20 39 } 21 40 ); 22 41 }
+5 -2
readme
··· 184 184 A quick overview of the format: 185 185 186 186 - \!Bolded\! text is surrounded by exclamation marks (!) 187 - - \*Italicized\* text is surrouneded by single asterists (*) 187 + - \*Italicized\* text is surrounded by single asterisks (*) 188 188 - \_Underlined\_ text is surrounded by underscores (_) 189 - - \~Strikenthrough\~ text is surrounded by tildes (~) 189 + - \~Strikethrough\~ text is surrounded by tildes (~) 190 + - \=Highlighted\= text is surrounded by equals signs (=) 191 + - \`Inline code\` is surrounded by backticks (`) 190 192 191 193 Links like in Markdown, along with the wiki-style links documented above. 194 + Raw links can also be written as \<https://example.com\>. 192 195 193 196 Misc 194 197 ----
+19 -2
src/main.rs
··· 313 313 } else { 314 314 "".to_string() 315 315 }; 316 - let mut body = body.unwrap_or_default(); 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 + }; 317 331 if body == "-" { 318 332 // add newline so you can type directly in the shell 319 333 //eprintln!(""); ··· 327 341 body = content.1.to_string(); 328 342 } 329 343 } 344 + // Ensure title never contains newlines (invariant for index file format) 345 + title = title.replace(['\n', '\r'], " "); 330 346 let task = workspace.new_task(title, body)?; 331 347 workspace.handle_metadata(&task, None)?; 332 348 Ok(task) ··· 380 396 let pre_links = task::parse(&task.to_string()).map(|pt| pt.intenal_links()); 381 397 let new_content = open_editor(format!("{}\n\n{}", task.title.trim(), task.body.trim()))?; 382 398 if let Some((title, body)) = new_content.split_once("\n") { 383 - task.title = title.to_string(); 399 + // Ensure title never contains newlines (invariant for index file format) 400 + task.title = title.replace(['\n', '\r'], " "); 384 401 task.body = body.to_string(); 385 402 workspace.handle_metadata(&task, pre_links)?; 386 403 task.save()?;
+27 -24
src/task.rs
··· 6 6 use crate::workspace::Id; 7 7 use colored::Colorize; 8 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 + 9 14 #[derive(Debug, Eq, PartialEq, Clone, Copy)] 10 15 enum ParserState { 11 16 // Started by ` =`, terminated by `= ··· 98 103 panic!("Internal link is not a valid id: {contents}"); 99 104 } 100 105 } 101 - (' ' | '\r' | '\n', '[', _) => { 106 + (last, '[', _) if is_boundary(last) => { 102 107 state.push(Linktext(end, char_pos)); 103 108 } 104 109 (']', '(', Some(Linktext(_, _))) => { 105 110 state.push(Link(end, char_pos)); 106 111 } 107 - (')', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Link(_, _))) => { 112 + (')', c, Some(Link(_, _))) if is_boundary(c) => { 108 113 // TODO: this needs to be updated to use `s` instead of `out` for position 109 114 // parsing 110 115 let linkpos = if let Link(lp, _) = state.pop().unwrap() { ··· 130 135 out.replace_range(linktextpos..end, &linktext); 131 136 } 132 137 } 133 - ('>', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(RawLink(hl, s_pos))) 134 - if s_pos != char_pos - 1 => 138 + ('>', c, Some(RawLink(hl, s_pos))) 139 + if is_boundary(c) && s_pos != char_pos - 1 => 135 140 { 136 141 state.pop(); 137 142 let link = s.get(s_pos + 1..char_pos - 1)?; ··· 142 147 out.replace_range(hl..end, &linktext); 143 148 } 144 149 } 145 - (' ' | '\r' | '\n', '<', _) => { 150 + (last, '<', _) if is_boundary(last) => { 146 151 state.push(RawLink(end, char_pos)); 147 152 } 148 - ('=', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Highlight(hl, s_pos))) 149 - if s_pos != char_pos - 1 => 153 + ('=', c, Some(Highlight(hl, s_pos))) 154 + if is_boundary(c) && s_pos != char_pos - 1 => 150 155 { 151 156 state.pop(); 152 157 out.replace_range( ··· 154 159 &s.get(s_pos + 1..char_pos - 1)?.reversed().to_string(), 155 160 ); 156 161 } 157 - (' ' | '\r' | '\n', '=', _) => { 162 + (last, '=', _) if is_boundary(last) => { 158 163 state.push(Highlight(end, char_pos)); 159 164 } 160 - (' ' | '\r' | '\n', '*', _) => { 165 + (last, '*', _) if is_boundary(last) => { 161 166 state.push(Italics(end, char_pos)); 162 167 } 163 - ('*', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Italics(il, s_pos))) 164 - if s_pos != char_pos - 1 => 168 + ('*', c, Some(Italics(il, s_pos))) 169 + if is_boundary(c) && s_pos != char_pos - 1 => 165 170 { 166 171 state.pop(); 167 172 out.replace_range( ··· 169 174 &s.get(s_pos + 1..char_pos - 1)?.italic().to_string(), 170 175 ); 171 176 } 172 - (' ' | '\r' | '\n', '!', _) => { 177 + (last, '!', _) if is_boundary(last) => { 173 178 state.push(Bold(end, char_pos)); 174 179 } 175 - ('!', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Bold(il, s_pos))) 176 - if s_pos != char_pos - 1 => 177 - { 180 + ('!', c, Some(Bold(il, s_pos))) if is_boundary(c) && s_pos != char_pos - 1 => { 178 181 state.pop(); 179 182 out.replace_range( 180 183 il..end, 181 184 &s.get(s_pos + 1..char_pos - 1)?.bold().to_string(), 182 185 ); 183 186 } 184 - (' ' | '\r' | '\n', '_', _) => { 187 + (last, '_', _) if is_boundary(last) => { 185 188 state.push(Underline(end, char_pos)); 186 189 } 187 - ('_', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Underline(il, s_pos))) 188 - if s_pos != char_pos - 1 => 190 + ('_', c, Some(Underline(il, s_pos))) 191 + if is_boundary(c) && s_pos != char_pos - 1 => 189 192 { 190 193 state.pop(); 191 194 out.replace_range( ··· 193 196 &s.get(s_pos + 1..char_pos - 1)?.underline().to_string(), 194 197 ); 195 198 } 196 - (' ' | '\r' | '\n', '~', _) => { 199 + (last, '~', _) if is_boundary(last) => { 197 200 state.push(Strikethrough(end, char_pos)); 198 201 } 199 - ('~', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Strikethrough(il, s_pos))) 200 - if s_pos != char_pos - 1 => 202 + ('~', c, Some(Strikethrough(il, s_pos))) 203 + if is_boundary(c) && s_pos != char_pos - 1 => 201 204 { 202 205 state.pop(); 203 206 out.replace_range( ··· 205 208 &s.get(s_pos + 1..char_pos - 1)?.strikethrough().to_string(), 206 209 ); 207 210 } 208 - ('`', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(InlineBlock(hl, s_pos))) 209 - if s_pos != char_pos - 1 => 211 + ('`', c, Some(InlineBlock(hl, s_pos))) 212 + if is_boundary(c) && s_pos != char_pos - 1 => 210 213 { 211 214 out.replace_range( 212 215 hl..end, 213 216 &s.get(s_pos + 1..char_pos - 1)?.green().to_string(), 214 217 ); 215 218 } 216 - (' ' | '\n' | '\r' | '.' | '!' | '?', '`', _) => { 219 + (last, '`', _) if is_boundary(last) => { 217 220 state.push(InlineBlock(end, char_pos)); 218 221 } 219 222 _ => (),