A file-based task manager

FIX: links removed from task will be cleaned up in the attrs of target

+49 -12
+2
.tsk/archive/tsk-24.tsk
···
··· 1 + properly handle removing links from task 2 +
+1 -1
.tsk/next
··· 1 - 24
··· 1 + 25
+1
.tsk/tasks/tsk-24.tsk
···
··· 1 + ../archive/tsk-24.tsk
+3 -2
src/main.rs
··· 303 } 304 } 305 let task = workspace.new_task(title, body)?; 306 - workspace.handle_metadata(&task)?; 307 workspace.push_task(task) 308 } 309 ··· 341 let workspace = Workspace::from_path(dir)?; 342 let id: TaskIdentifier = id.into(); 343 let mut task = workspace.task(id)?; 344 let new_content = open_editor(format!("{}\n\n{}", task.title.trim(), task.body.trim()))?; 345 if let Some((title, body)) = new_content.split_once("\n") { 346 task.title = title.to_string(); 347 task.body = body.to_string(); 348 - workspace.handle_metadata(&task)?; 349 task.save()?; 350 } 351 Ok(())
··· 303 } 304 } 305 let task = workspace.new_task(title, body)?; 306 + workspace.handle_metadata(&task, None)?; 307 workspace.push_task(task) 308 } 309 ··· 341 let workspace = Workspace::from_path(dir)?; 342 let id: TaskIdentifier = id.into(); 343 let mut task = workspace.task(id)?; 344 + let pre_links = task::parse(&task.to_string()).map(|pt| pt.intenal_links()); 345 let new_content = open_editor(format!("{}\n\n{}", task.title.trim(), task.body.trim()))?; 346 if let Some((title, body)) = new_content.split_once("\n") { 347 task.title = title.to_string(); 348 task.body = body.to_string(); 349 + workspace.handle_metadata(&task, pre_links)?; 350 task.save()?; 351 } 352 Ok(())
+13 -1
src/task.rs
··· 1 #![allow(dead_code)] 2 3 - use std::str::FromStr; 4 use url::Url; 5 6 use crate::workspace::Id; ··· 51 pub(crate) struct ParsedTask { 52 pub(crate) content: String, 53 pub(crate) links: Vec<ParsedLink>, 54 } 55 56 pub(crate) fn parse(s: &str) -> Option<ParsedTask> {
··· 1 #![allow(dead_code)] 2 3 + use std::{collections::HashSet, str::FromStr}; 4 use url::Url; 5 6 use crate::workspace::Id; ··· 51 pub(crate) struct ParsedTask { 52 pub(crate) content: String, 53 pub(crate) links: Vec<ParsedLink>, 54 + } 55 + 56 + impl ParsedTask { 57 + pub(crate) fn intenal_links(&self) -> HashSet<Id> { 58 + let mut out = HashSet::with_capacity(self.links.len()); 59 + for link in &self.links { 60 + if let ParsedLink::Internal(id) = link { 61 + out.insert(*id); 62 + } 63 + } 64 + out 65 + } 66 } 67 68 pub(crate) fn parse(s: &str) -> Option<ParsedTask> {
+29 -8
src/workspace.rs
··· 5 use crate::attrs::Attrs; 6 use crate::errors::{Error, Result}; 7 use crate::stack::{StackItem, TaskStack}; 8 - use crate::task::{parse as parse_task, ParsedLink}; 9 use crate::{fzf, util}; 10 - use std::collections::{vec_deque, BTreeMap}; 11 use std::ffi::OsString; 12 use std::fmt::Display; 13 use std::fs::File; ··· 23 const XATTRPREFIX: &str = "user.tsk."; 24 const BACKREFXATTR: &str = "user.tsk.references"; 25 /// A unique identifier for a task. When referenced in text, it is prefixed with `tsk-`. 26 - #[derive(Clone, Copy, Debug, Eq, PartialEq)] 27 pub struct Id(pub u32); 28 29 impl FromStr for Id { ··· 183 }) 184 } 185 186 - pub fn handle_metadata(&self, tsk: &Task) -> Result<()> { 187 // Parse the task and update any backlinks 188 if let Some(parsed_task) = parse_task(&tsk.to_string()) { 189 - for link in parsed_task.links { 190 - if let ParsedLink::Internal(id) = link { 191 - self.add_backlink(id, tsk.id)?; 192 } 193 } 194 } 195 Ok(()) 196 } 197 198 - pub fn add_backlink(&self, to: Id, from: Id) -> Result<()> { 199 let to_task = self.task(TaskIdentifier::Id(to))?; 200 let (_, current_backlinks_text) = 201 Self::read_xattr(&to_task.file, BACKREFXATTR.into()).unwrap_or_default(); ··· 204 .filter_map(|s| Id::from_str(s).ok()) 205 .collect(); 206 backlinks.push(from); 207 Self::set_xattr( 208 &to_task.file, 209 BACKREFXATTR.into(),
··· 5 use crate::attrs::Attrs; 6 use crate::errors::{Error, Result}; 7 use crate::stack::{StackItem, TaskStack}; 8 + use crate::task::parse as parse_task; 9 use crate::{fzf, util}; 10 + use std::collections::{vec_deque, BTreeMap, HashSet}; 11 use std::ffi::OsString; 12 use std::fmt::Display; 13 use std::fs::File; ··· 23 const XATTRPREFIX: &str = "user.tsk."; 24 const BACKREFXATTR: &str = "user.tsk.references"; 25 /// A unique identifier for a task. When referenced in text, it is prefixed with `tsk-`. 26 + #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] 27 pub struct Id(pub u32); 28 29 impl FromStr for Id { ··· 183 }) 184 } 185 186 + pub fn handle_metadata(&self, tsk: &Task, pre_links: Option<HashSet<Id>>) -> Result<()> { 187 // Parse the task and update any backlinks 188 if let Some(parsed_task) = parse_task(&tsk.to_string()) { 189 + let internal_links = parsed_task.intenal_links(); 190 + for link in &internal_links { 191 + self.add_backlink(*link, tsk.id)?; 192 + } 193 + if let Some(pre_links) = pre_links { 194 + let removed_links = pre_links.difference(&internal_links); 195 + for link in removed_links { 196 + self.remove_backlink(*link, tsk.id)?; 197 } 198 } 199 } 200 Ok(()) 201 } 202 203 + fn add_backlink(&self, to: Id, from: Id) -> Result<()> { 204 let to_task = self.task(TaskIdentifier::Id(to))?; 205 let (_, current_backlinks_text) = 206 Self::read_xattr(&to_task.file, BACKREFXATTR.into()).unwrap_or_default(); ··· 209 .filter_map(|s| Id::from_str(s).ok()) 210 .collect(); 211 backlinks.push(from); 212 + Self::set_xattr( 213 + &to_task.file, 214 + BACKREFXATTR.into(), 215 + &itertools::join(backlinks, ","), 216 + ) 217 + } 218 + 219 + fn remove_backlink(&self, to: Id, from: Id) -> Result<()> { 220 + let to_task = self.task(TaskIdentifier::Id(to))?; 221 + let (_, current_backlinks_text) = 222 + Self::read_xattr(&to_task.file, BACKREFXATTR.into()).unwrap_or_default(); 223 + let mut backlinks: HashSet<Id> = current_backlinks_text 224 + .split(',') 225 + .filter_map(|s| Id::from_str(s).ok()) 226 + .collect(); 227 + backlinks.remove(&from); 228 Self::set_xattr( 229 &to_task.file, 230 BACKREFXATTR.into(),